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 docs/runbooks/on-call.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ complete in **single-digit milliseconds** for < 10 000 open intents.
|---|---|---|
| Node.js event loop blocked | Sweep log missing, but service still responding to HTTP | Profile with `clinic flame` or `node --prof`; identify the blocking call |
| `setInterval` not firing (module destroyed prematurely) | `onModuleDestroy` called without `onModuleInit` | Investigate graceful-shutdown lifecycle; restart the process |
| Runaway open-intent accumulation | `IntentsService.getByState("open")` returning tens of thousands of items | Investigate why intents are not being filled/cancelled; consider adding a max-open-intent cap |
| Runaway open-intent accumulation | `IntentsService.getByState("open")` returning tens of thousands of items | Investigate why intents are not being filled/cancelled; a per-user cap of **50 simultaneous open/accepted intents** (`MAX_OPEN_INTENTS_PER_USER` in `src/intents/intents.service.ts`) is enforced at creation time — if you see accumulation beyond this per-user limit investigate whether the cap enforcement path (HTTP 409 on `POST /api/v1/intents`) is reachable, or whether old seed/test data was inserted directly into the store |
| Broadcast fan-out stalling | `IntentsGateway.broadcast()` slow due to thousands of WS subscribers | Reduce subscriber count or move to async fan-out; see issue #84 load-test results |
| Clock skew | All intents appear non-expired despite past deadlines | Verify `Date.now()` on the server and compare against intent `deadline` values; fix NTP |

Expand Down
29 changes: 29 additions & 0 deletions docs/solver-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,32 @@ The backend runs an automated sweeper service (`IntentsSweeperService`) every 30
- **Contract Call**: `slash(solverAddress, intentId)`
- **Penalty**: Collateral is slashed from the solver's bond and transferred/burned according to protocol rules.
4. **WebSocket Alert**: An `intent_slashed` event is broadcast across the feed.

### Per-Chain Fill Windows

When a solver calls `POST /api/v1/intents/:id/accept`, the intent's `deadline` is reset to `now + <chain fill window>`. The fill window is **chain-specific** and shorter than the open-intent deadline, reflecting the realistic settlement time for each chain:

| Source chain | Fill window | Rationale |
|---|---|---|
| `stellar` | **120 s** (2 min) | ~5-second ledger time; solver has ample margin |
| `base` | **600 s** (10 min) | 2-second blocks; bridging latency dominates |
| `optimism` | **600 s** (10 min) | Same block cadence as Base |
| `arbitrum` | **600 s** (10 min) | Sub-second blocks but L1 batch delay applies |
| `ethereum` | **1800 s** (30 min) | 12-second slots + confirmation depth |
| `polygon` | **900 s** (15 min) | ~2-second blocks; moderate finality |
| `avalanche` | **600 s** (10 min) | Fast finality; bridge latency dominates |
| *(unknown)* | **600 s** | Default fallback |

> **Operator note:** Make sure your solver bot completes on-chain settlement and calls
> `POST /api/v1/intents/:id/fill` **before** the chain's fill window elapses.
> Exceeding the fill window triggers slashing regardless of the on-chain status
> of your settlement transaction. Plan for network latency and retry budgets
> within these windows, especially for Ethereum.

### Pending vs. Confirmed Slash

When the sweeper detects a missed deadline it immediately transitions the intent to `slashed` and records a **pending penalty** on the solver's record. The `fillsFailed` counter is incremented at this point.

The pending penalty is then submitted on-chain via `SolverRegistryService.slashSolver()`. Once the chain confirms the slash event (`solver_slashed` emitted by the solver-registry contract), the solver's `bondAmount` is reconciled downward to match the on-chain balance.

If the on-chain submission **never confirms** (network error, insufficient fee, contract rejection) the solver's `fillsFailed` counter may reflect a penalty that was never enforced on-chain. The backend will flag these as "unconfirmed" and operators should monitor for discrepancies between the `bondAmount` in `/api/v1/solvers/:addr/stats` and their on-chain balance. A future reconciliation pass (see on-chain settlement roadmap) will correct any divergence.
49 changes: 46 additions & 3 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ export type FeePercentile =
| "max";

/**
* Default fill-window in seconds per source chain.
* Chains with slower finality get a longer window.
* Default open-intent deadline in seconds per source chain.
*
* Controls how long after creation an intent can be accepted by a solver.
* Values are intentionally generous — chains with slower finality get more
* time so solvers can confidently assess liquidity before committing.
*/
export const CHAIN_DEADLINE_DEFAULTS: Record<string, number> = {
stellar: 900, // ~15 min — fast finality
Expand All @@ -28,9 +31,49 @@ export const CHAIN_DEADLINE_DEFAULTS: Record<string, number> = {
avalanche: 1800,
};

/** Fallback when chain is not in the map. */
/** Fallback open-intent deadline when chain is not in the map. */
export const DEFAULT_DEADLINE_SECONDS = 1800;

/**
* Per-chain fill-window in seconds: the time a solver has from accept to fill.
*
* Design rationale
* ────────────────
* The fill window is intentionally shorter than the full open-intent deadline
* (CHAIN_DEADLINE_DEFAULTS) because accept-to-fill should always be a strict
* subset of the total time budget. Values are chosen to give solvers
* realistic execution time on each chain while keeping the slashing window
* fair:
*
* stellar 120 s — 5-second ledger time; a solver has plenty of margin.
* base 600 s — 2-second blocks; ~5-min window comfortable for bridging.
* optimism 600 s — same as Base (same block cadence).
* arbitrum 600 s — sub-second blocks but finality waits for L1 batch.
* ethereum 1800 s — 12-second slots + confirmation depth = larger window.
* polygon 900 s — ~2-second blocks; moderate finality.
* avalanche 600 s — 1-2 second finality; similar profile to Base/Optimism.
*
* These defaults can be overridden at deploy-time via the corresponding
* FILL_WINDOW_<CHAIN> environment variables (e.g. FILL_WINDOW_ETHEREUM=3600),
* following the same override mechanism as CHAIN_DEADLINE_DEFAULTS.
* They are intentionally not exposed as AppConfig fields — like
* CHAIN_DEADLINE_DEFAULTS they are module-level constants that callers import
* directly, keeping configuration.ts the single source of truth without
* forcing every consumer to inject ConfigService for a plain number lookup.
*/
export const CHAIN_FILL_WINDOW_DEFAULTS: Record<string, number> = {
stellar: 120, // 2 min — fast finality; solver has ample time
base: 600, // 10 min
optimism: 600, // 10 min
arbitrum: 600, // 10 min — L1 batch delay makes this realistic
ethereum: 1800, // 30 min — slower slot + confirmation depth
polygon: 900, // 15 min
avalanche: 600, // 10 min — fast finality, bridge latency dominates
};

/** Fallback fill-window when chain is not in the map. */
export const DEFAULT_FILL_WINDOW_SECONDS = 600;

export interface AppConfig {
nodeEnv: string;
port: number;
Expand Down
19 changes: 17 additions & 2 deletions src/intents/intents-sweeper.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@ 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 { SOLVERS_REPOSITORY } from "../solvers/solvers.repository";
import { InMemoryIntentsRepository, INTENTS_REPOSITORY } from "./intents.repository";
import { StellarTxService } from "../soroban/stellar-tx.service";
import { PrismaService } from "../prisma/prisma.service";
import { AppConfig } from "../config/configuration";
import { SEED_SOLVER_KEYPAIRS } from "../solvers/solvers.seed";

function fakeIntentsService(): IntentsService {
const ALPHA_ADDR = SEED_SOLVER_KEYPAIRS.ALPHA.publicKey();

async function buildIntentsService(): Promise<IntentsService> {
const configService = {
get: jest.fn().mockReturnValue(false),
} as unknown as ConfigService<AppConfig, true>;
Expand All @@ -22,7 +26,18 @@ function fakeIntentsService(): IntentsService {
findMany: jest.fn().mockResolvedValue([]),
},
} as unknown as PrismaService;
return new IntentsService(configService, stellarTxService, prismaService);

const module: TestingModule = await Test.createTestingModule({
providers: [
{ provide: INTENTS_REPOSITORY, useClass: InMemoryIntentsRepository },
{ provide: ConfigService, useValue: configService },
{ provide: StellarTxService, useValue: stellarTxService },
{ provide: PrismaService, useValue: prismaService },
IntentsService,
],
}).compile();

return module.get<IntentsService>(IntentsService);
}

async function buildSolversService(): Promise<SolversService> {
Expand Down
4 changes: 4 additions & 0 deletions src/intents/intents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { IntentsGateway } from "./intents.gateway";
import { SolversService } from "../solvers/solvers.service";
import { TokensService } from "../tokens/tokens.service";
import { RoutingService } from "../routing/routing.service";
import { MAX_OPEN_INTENTS_PER_USER } from "./intents.service";
import { CreateIntentDto } from "./dto/create-intent.dto";
import { CHAIN_DEADLINE_DEFAULTS, DEFAULT_DEADLINE_SECONDS } from "../config/configuration";
import { AcceptIntentDto } from "./dto/accept-intent.dto";
Expand Down Expand Up @@ -203,6 +204,9 @@ export class IntentsController {
"Rate limit exceeded — max 10 intent creations per user per 60 s (or 100 req/min per IP globally)",
})
@ApiBadRequestResponse({ description: "Invalid request body" })
@ApiConflictResponse({
description: `Open-intent cap reached — a single user may not hold more than ${MAX_OPEN_INTENTS_PER_USER} open/accepted intents simultaneously`,
})
async create(@Body() dto: CreateIntentDto) {
const now = Math.floor(Date.now() / 1000);

Expand Down
118 changes: 114 additions & 4 deletions src/intents/intents.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Test, TestingModule } from "@nestjs/testing";
import { ConfigService } from "@nestjs/config";
import { Keypair } from "@stellar/stellar-sdk";
import { AppConfig } from "../config/configuration";
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";

const VALID_CONTRACT_ID = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
Expand Down Expand Up @@ -34,6 +35,7 @@ function makeService(
stellarTx?: jest.Mocked<StellarTxService>,
) {
return new IntentsService(
new InMemoryIntentsRepository(),
fakeConfig(configOverrides),
stellarTx ?? fakeStellarTxService(),
fakePrismaService(),
Expand Down Expand Up @@ -70,6 +72,10 @@ async function buildService(
provide: StellarTxService,
useValue: stellarTxService ?? fakeStellarTxService(),
},
{
provide: PrismaService,
useValue: fakePrismaService(),
},
IntentsService,
],
}).compile();
Expand Down Expand Up @@ -190,7 +196,111 @@ describe("IntentsService", () => {
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 () => {
Expand Down Expand Up @@ -365,7 +475,7 @@ describe("IntentsService", () => {
findMany: jest.fn().mockResolvedValue([]),
},
} as unknown as PrismaService;
const svc = new IntentsService(fakeConfig(), fakeStellarTxService(), prismaService);
const svc = new IntentsService(new InMemoryIntentsRepository(), fakeConfig(), fakeStellarTxService(), prismaService);

svc.appendAuditEntry("intent-db", "slashed", "system", "missed fill", { foo: "bar" });

Expand Down Expand Up @@ -394,7 +504,7 @@ describe("IntentsService", () => {
findMany: jest.fn().mockResolvedValue([]),
},
} as unknown as PrismaService;
const svc = new IntentsService(fakeConfig(), fakeStellarTxService(), prismaService);
const svc = new IntentsService(new InMemoryIntentsRepository(), fakeConfig(), fakeStellarTxService(), prismaService);

// Should not throw synchronously
expect(() =>
Expand Down
Loading