diff --git a/docs/solver-onboarding.md b/docs/solver-onboarding.md index 18c57779..ad992d3a 100644 --- a/docs/solver-onboarding.md +++ b/docs/solver-onboarding.md @@ -121,6 +121,26 @@ Solvers must maintain a collateral bond in the Soroban `SolverRegistryContract` - **Minimum Bond Requirement**: Solvers cannot accept high-value intents without adequate active collateral. - **On-Chain Settlement**: Bond balances are recorded on-chain via the Soroban contract and synchronized with the backend registry. +### Bond and Exposure Checks on Acceptance +Every accept request verifies the solver's bond amount and active status against +the registry contract through a read-only Soroban simulation. A successful +result is cached for at most 30 seconds and registry bond/activity events +invalidate the cache. If the registry RPC, contract, or response is unavailable, +the API fails closed with `503`; the projected `bondAmount` in the backend +solver record is not used as a substitute. + +The backend values the intent's source amount in USD using integer arithmetic +and enforces this ceiling before the state transition: + +> accepted exposure in USD + new intent value in USD ≤ on-chain bond value in USD × `maxExposureRatio` + +`maxExposureRatio` is the current governance parameter. Stellar bond amounts are +interpreted in 7-decimal XLM base units and valued using the configured XLM USD +price. An intent price snapshot older than five minutes, a missing/non-positive +price, or malformed amount fails closed with `503`. An intent exceeding the +remaining capacity is rejected with `403` and error code `INSUFFICIENT_BOND`. +Bond top-ups remain an on-chain operation and are outside this API flow. + ### Reputation & Bond Reconciliation A solver's active reputation score is continuously computed using completion rates and account age: $$\text{ReputationScore} = \text{SuccessRate} \times e^{-\frac{\text{AgeInDays}}{180}}$$ diff --git a/src/intents/intent-exposure.ts b/src/intents/intent-exposure.ts new file mode 100644 index 00000000..5c254c9d --- /dev/null +++ b/src/intents/intent-exposure.ts @@ -0,0 +1,72 @@ +import { ServiceUnavailableException } from "@nestjs/common"; +import { Intent } from "./intents.types"; + +/** Price snapshots older than this are not safe for collateral decisions. */ +export const MAX_EXPOSURE_PRICE_AGE_SECONDS = 300; +const USD_SCALE = 1_000_000n; + +/** Convert a decimal USD price to six-decimal fixed point. */ +function priceToMicros(priceUsd: number): bigint { + if (!Number.isFinite(priceUsd) || priceUsd <= 0) { + throw new ServiceUnavailableException({ + code: "STALE_PRICE", + error: "A valid USD price is unavailable for exposure calculation", + message: "A valid USD price is unavailable for exposure calculation", + }); + } + return BigInt(Math.round(priceUsd * Number(USD_SCALE))); +} + +/** Convert arbitrary token base units to micro-USD using fixed-point math. */ +export function baseUnitsToUsdMicros(amount: bigint, decimals: number, priceUsd: number): bigint { + if (!Number.isInteger(decimals) || decimals < 0 || amount < 0n) { + throw new ServiceUnavailableException({ + code: "INVALID_EXPOSURE_AMOUNT", + error: "Amount cannot be valued safely", + message: "Amount cannot be valued safely", + }); + } + return (amount * priceToMicros(priceUsd)) / (10n ** BigInt(decimals)); +} + +/** + * Value an intent's source-token amount in micro-USD using its price snapshot. + * `createdAt` bounds snapshot age conservatively because token snapshots do + * not currently carry an independent oracle timestamp. + */ +export function intentExposureUsdMicros(intent: Intent, nowSeconds = Math.floor(Date.now() / 1000)): bigint { + const token = intent.srcToken; + if ( + !token || + !Number.isInteger(token.decimals) || + token.decimals < 0 || + nowSeconds - intent.createdAt > MAX_EXPOSURE_PRICE_AGE_SECONDS || + intent.createdAt > nowSeconds + ) { + throw new ServiceUnavailableException({ + code: "STALE_PRICE", + error: "A fresh USD price is unavailable for this intent", + message: "A fresh USD price is unavailable for this intent", + }); + } + + let amount: bigint; + try { + amount = BigInt(intent.srcAmount); + } catch { + throw new ServiceUnavailableException({ + code: "INVALID_EXPOSURE_AMOUNT", + error: "Intent amount cannot be valued safely", + message: "Intent amount cannot be valued safely", + }); + } + if (amount < 0n) { + throw new ServiceUnavailableException({ + code: "INVALID_EXPOSURE_AMOUNT", + error: "Intent amount cannot be valued safely", + message: "Intent amount cannot be valued safely", + }); + } + + return baseUnitsToUsdMicros(amount, token.decimals, token.priceUSD ?? 0); +} \ No newline at end of file diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index e69de29b..36e66f67 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -0,0 +1,760 @@ +import { + BadRequestException, + Body, + ConflictException, + Controller, + ForbiddenException, + Get, + GoneException, + NotFoundException, + Param, + Post, + Query, + ServiceUnavailableException, + UseGuards, +} from "@nestjs/common"; +import { + ApiTags, + ApiOkResponse, + ApiNotFoundResponse, + ApiConflictResponse, + ApiForbiddenResponse, + ApiGoneResponse, + ApiBadRequestResponse, + ApiTooManyRequestsResponse, + ApiOperation, + ApiServiceUnavailableResponse, +} from "@nestjs/swagger"; +import { Throttle } from "@nestjs/throttler"; +import { IntentsService } from "./intents.service"; +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"; +import { FillIntentDto } from "./dto/fill-intent.dto"; +import { CancelIntentDto } from "./dto/cancel-intent.dto"; +import { QuoteRequestDto } from "./dto/quote-request.dto"; +import { QuoteResponseDto } from "./dto/quote-response.dto"; +import { ListIntentsDto } from "./dto/list-intents.dto"; +import { BatchLookupDto } from "./dto/batch-lookup.dto"; +import { UserThrottlerGuard } from "./user-throttler.guard"; +import { + verifyStellarSignature, + buildAcceptMessage, + buildCancelMessage, + buildFillMessage, +} from "../common/stellar-signature"; +import { + applyVarianceScale, + calculateProtocolFee, + parseBaseUnits, + toDecimalNumber, + varianceScaleFromPerfScore, +} from "../common/amount"; +import { Intent, SupportedChain } from "./intents.types"; +import { + assertNotPaused, + KillSwitchGate, + KillSwitchGuard, +} from "../killswitch/killswitch.guard"; +import { KillSwitchService } from "../killswitch/killswitch.service"; +import { KillSwitchOperation } from "../killswitch/killswitch.types"; +import { ConfigService } from "@nestjs/config"; +import { AppConfig } from "../config/configuration"; +import { isCanaryIntent } from "../common/canary"; +import { SolverBondService } from "../soroban/solver-bond.service"; +import { ProtocolParamsService } from "../governance/params.service"; +import { baseUnitsToUsdMicros, intentExposureUsdMicros } from "./intent-exposure"; + +@ApiTags("intents") +@Controller("api/v1/intents") +export class IntentsController { + constructor( + private readonly intentsService: IntentsService, + private readonly solversService: SolversService, + private readonly intentsGateway: IntentsGateway, + private readonly tokensService: TokensService, + private readonly routingService: RoutingService, + private readonly killSwitch: KillSwitchService, + config: ConfigService, + private readonly solverBondService: SolverBondService, + private readonly protocolParamsService: ProtocolParamsService, + ) { + this.canary = new Set(config.get("canaryAddresses", { infer: true }) ?? []); + } + + /** Canary addresses (issue #496). */ + private readonly canary: ReadonlySet; + + /** + * Re-assert the kill-switch hierarchy against a *loaded* intent. + * + * `KillSwitchGuard` runs before the handler and can only read the route path + * and body. For `:id` routes that is not enough to evaluate a chain- or + * token-scoped pause, so `accept` and `fill` call this once the record is in + * hand. The global-scope and snapshot-readiness checks are still done by the + * guard, so this is strictly additional coverage, not a replacement. + */ + private assertIntentNotPaused(intent: Intent, operation: KillSwitchOperation): void { + assertNotPaused( + this.killSwitch, + { + chain: intent.srcChain, + // Prefer the contract address: symbols are not unique within a chain, + // so a symbol-scoped pause would over-match and an address-scoped one + // would under-match. Operators pause by address. + token: intent.srcToken?.address ?? null, + operation, + }, + { retryAfterSeconds: 30 }, + ); + } + + @Get() + @ApiBadRequestResponse({ description: "Invalid limit or offset" }) + async list(@Query() dto: ListIntentsDto) { + let intents = await this.intentsService.getAll(); + + if (dto.state) intents = intents.filter((i) => i.state === dto.state); + if (dto.user) intents = intents.filter((i) => i.user.toLowerCase() === dto.user!.toLowerCase()); + if (dto.chain) intents = intents.filter((i) => i.srcChain === dto.chain); + + const limit = Math.min(dto.limit ?? 20, 100); + const offset = dto.offset ?? 0; + + if ((dto.limit ?? 20) > 100) { + throw new BadRequestException("Limit exceeds maximum allowed value of 100"); + } + + const page = intents.slice(offset, offset + limit); + return { intents: page, total: intents.length, limit, offset }; + } + + @Get("open") + async listOpen(@Query() dto: ListIntentsDto) { + const open = await this.intentsService.getByState("open"); + const limit = Math.min(dto.limit ?? 20, 100); + const offset = dto.offset ?? 0; + + if ((dto.limit ?? 20) > 100) { + throw new BadRequestException("Limit exceeds maximum allowed value of 100"); + } + + const page = open.slice(offset, offset + limit); + return { intents: page, total: open.length, count: open.length, limit, offset }; + } + + @Get("user/:address") + async listByUser(@Param("address") address: string, @Query() dto: ListIntentsDto) { + const intents = await this.intentsService.getByUser(address); + const limit = Math.min(dto.limit ?? 20, 100); + const offset = dto.offset ?? 0; + + if ((dto.limit ?? 20) > 100) { + throw new BadRequestException("Limit exceeds maximum allowed value of 100"); + } + + const page = intents.slice(offset, offset + limit); + return { intents: page, total: intents.length, count: intents.length, limit, offset }; + } + + @Get(":id") + @ApiNotFoundResponse({ description: "Intent not found" }) + async getOne(@Param("id") id: string) { + const intent = await this.intentsService.get(id); + if (!intent) throw new NotFoundException("Intent not found"); + return intent; + } + + /** + * GET /api/v1/intents/:id/audit + * + * Returns the full state-transition history for an intent, oldest-first. + * Issue #217 — backs the in-memory audit trail with a persistent DB table + * (intent_audit_log) so the log survives restarts and is independently + * queryable (see DATABASE_INDEXES.md section 3 and the runbooks that depend + * on this trail: docs/runbooks/onchain-cutover.md, RUNBOOK_BACKUP_RESTORE.md). + */ + @Get(":id/audit") + @ApiOperation({ + summary: "Get audit trail for an intent", + description: + "Returns the full state-transition history for an intent ordered oldest-first. " + + "Each entry records the state the intent moved into, who triggered it, and why.", + }) + @ApiOkResponse({ + description: "Audit trail for the intent", + schema: { + type: "object", + properties: { + intentId: { type: "string" }, + entries: { + type: "array", + items: { + type: "object", + properties: { + timestamp: { type: "string", format: "date-time" }, + toState: { type: "string" }, + actor: { type: "string" }, + reason: { type: "string" }, + metadata: { type: "object", nullable: true }, + }, + }, + }, + }, + }, + }) + @ApiNotFoundResponse({ description: "Intent not found" }) + async getAudit(@Param("id") id: string, @Query() dto: ListIntentsDto) { + const intent = await this.intentsService.get(id); + if (!intent) throw new NotFoundException("Intent not found"); + + const limit = Math.min(dto.limit ?? 20, 100); + const offset = dto.offset ?? 0; + if ((dto.limit ?? 20) > 100) { + throw new BadRequestException("Limit exceeds maximum allowed value of 100"); + } + + const allEntries = this.intentsService.getAuditLog(id); + const entries = this.intentsService.getAuditLog(id, limit, offset); + const total = allEntries.length; + return { intentId: id, entries, total, limit, offset }; + } + + /** + * GET /api/v1/intents/:id/quote + * + * Returns the persisted best quote for an intent (the quotedDstAmount stored + * on the intent after a POST /quote call with intentId). + */ + @Get(":id/quote") + @ApiOkResponse({ description: "Persisted quote for the intent" }) + @ApiNotFoundResponse({ description: "Intent not found or no quote persisted" }) + async getPersistedQuote(@Param("id") id: string) { + const intent = await this.intentsService.get(id); + if (!intent) throw new NotFoundException("Intent not found"); + if (!intent.quotedDstAmount) throw new NotFoundException("No quote persisted for this intent"); + return { intentId: id, quotedDstAmount: intent.quotedDstAmount }; + } + + /** + * Issue #44 — global IP throttle already applied via AppModule guard. + * Issue #45 — additionally throttle per dto.user: 10 creates / 60 s. + */ + @Post() + @UseGuards(UserThrottlerGuard, KillSwitchGuard) + @KillSwitchGate({ operation: "create" }) + @ApiTooManyRequestsResponse({ + description: + "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); + + // #219: use typed resolveToken instead of ad-hoc duck-typed any casts. + // #276: reject unrecognised tokens outright instead of silently creating an + // intent whose priceUSD defaults to undefined. + // #473: enforce the per-user open-intent cap as a fast-path rejection. + // The atomic guarantee lives in the persistence layer (conditional write); + // this pre-check keeps the common over-cap case cheap without adding a + // round trip on the happy path. + const openCount = await this.intentsService.countOpenByUser(dto.user); + if (openCount >= MAX_OPEN_INTENTS_PER_USER) { + throw new ConflictException( + `Open-intent cap reached — max ${MAX_OPEN_INTENTS_PER_USER} open/accepted intents per user`, + ); + } + const srcToken = await this.tokensService.resolveSrcTokenOrThrow( + dto.srcChain as SupportedChain, + dto.srcTokenAddress, + ); + const dstToken = await this.tokensService.resolveDstTokenOrThrow(dto.dstTokenContract); + + const intent = await this.intentsService.create( + { + user: dto.user, + srcChain: dto.srcChain, + srcToken: { + address: dto.srcTokenAddress, + symbol: dto.srcTokenSymbol, + name: dto.srcTokenSymbol, + decimals: dto.srcTokenDecimals, + chain: dto.srcChain, + priceUSD: srcToken?.priceUSD, + }, + srcAmount: dto.srcAmount, + dstToken: { + contract: dto.dstTokenContract, + symbol: dto.dstTokenSymbol, + decimals: dto.dstTokenDecimals, + priceUSD: dstToken?.priceUSD, + }, + minDstAmount: dto.minDstAmount, + deadline: dto.deadline ?? now + (CHAIN_DEADLINE_DEFAULTS[dto.srcChain] ?? DEFAULT_DEADLINE_SECONDS), + }, + dto.idempotencyKey, + ); + this.intentsGateway.broadcast({ type: "intent_created", intent }); + return intent; + } + + /** + * POST /api/v1/intents/batch + * + * Issue #275 — bounded batch status lookup. Lets a solver bot (or a frontend + * showing a full history) reconcile a known set of intent IDs against current + * server state in one call instead of N `GET /:id` requests. + * + * `POST` (not `GET`) because the ID list can exceed a comfortable query-string + * length. Subject to the same global rate limits as every other endpoint — + * no dedicated tier. Read-only: batch accept/fill/cancel is explicitly out of + * scope. + */ + @Post("batch") + @ApiOperation({ + summary: "Batch-fetch current intent records by ID", + description: + "Returns the current record for each supplied intent ID. IDs with no " + + "matching record are omitted (not individually 404'd). Capped at 100 IDs.", + }) + @ApiOkResponse({ description: "Records for the found intent IDs, plus a count" }) + @ApiBadRequestResponse({ + description: "intentIds missing, not an array of strings, or exceeds 100 entries", + }) + async batchLookup(@Body() dto: BatchLookupDto) { + const intents = await this.intentsService.getMany(dto.intentIds); + return { intents, count: intents.length }; + } + + @Post(":id/accept") + @UseGuards(KillSwitchGuard) + @KillSwitchGate({ operation: "accept" }) + @ApiNotFoundResponse({ description: "Intent not found" }) + @ApiConflictResponse({ description: "Intent is not in open state" }) + @ApiGoneResponse({ description: "Intent has expired" }) + @ApiForbiddenResponse({ description: "Solver not registered or inactive" }) + @ApiServiceUnavailableResponse({ description: "Solver bond or a fresh USD price could not be verified" }) + async accept(@Param("id") id: string, @Body() dto: AcceptIntentDto) { + // Fast-path snapshot only — guards below are advisory. The atomic + // decision is the conditional `acceptIfOpen` write (state=open AND + // deadline > now in SQL), so a concurrent cancel/expiry always wins. + const intent = await this.intentsService.get(id); + if (!intent) throw new NotFoundException("Intent not found"); + + // The guard above can only see the path parameter, so it could not know + // which chain/token this intent belongs to. Re-assert now that the record + // is loaded, otherwise a chain- or token-scoped pause would not stop + // accepts. Deliberately placed after the 404 so an unknown id still 404s. + this.assertIntentNotPaused(intent, "accept"); + + const now = Math.floor(Date.now() / 1000); + if (intent.deadline <= now) { + // Atomic expiry attempt: never blindly overwrite — an `accepted` + // intent must slash, never expire (issue #473). + await this.intentsService.expireIfOpen(id); + throw new GoneException("Intent has expired"); + } + + // Verify the solver controls the claimed address before it can accept. + verifyStellarSignature(dto.solver, buildAcceptMessage(id, dto.solver), dto.signature); + + if (this.solversService.isSuspended(dto.solver)) { + throw new ForbiddenException("Solver is suspended by an active guardian action"); + } + // Canary intents pair only with canary solvers (issue #496) so synthetic + // traffic never affects real solvers' stats or real users' fills. + if (isCanaryIntent(intent, this.canary) !== this.canary.has(dto.solver)) { + throw new ForbiddenException("Canary intents may only be accepted by canary solvers, and vice versa"); + } + + const chainBond = await this.solverBondService.getBond(dto.solver); + if (!chainBond.isActive || chainBond.bondAmount <= 0n) { + throw new ForbiddenException({ + code: "INSUFFICIENT_BOND", + error: "Solver has no active on-chain bond", + message: "Solver has no active on-chain bond", + }); + } + + const xlmPrice = await this.tokensService.getUsdPrice("XLM"); + const bondUsdMicros = baseUnitsToUsdMicros(chainBond.bondAmount, 7, xlmPrice); + const candidateExposureUsdMicros = intentExposureUsdMicros(intent, now); + const ratio = this.protocolParamsService.getCurrent().maxExposureRatio; + if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) { + throw new ServiceUnavailableException({ + code: "INVALID_EXPOSURE_RATIO", + error: "The configured maximum exposure ratio is invalid", + }); + } + const ratioScale = 1_000_000_000n; + const ratioFixed = BigInt(Math.floor(ratio * Number(ratioScale))); + const maxExposureUsdMicros = (bondUsdMicros * ratioFixed) / ratioScale; + + const capacity = await this.intentsService.acceptIfOpenWithinExposure( + id, + dto.solver, + candidateExposureUsdMicros, + maxExposureUsdMicros, + now, + ); + if (capacity.exposureExceeded) { + throw new ForbiddenException({ + code: "INSUFFICIENT_BOND", + error: "Accepted exposure would exceed the solver's bond limit", + message: "Accepted exposure would exceed the solver's bond limit", + }); + } + const updated = capacity.intent; + if (!updated) { + const current = await this.intentsService.get(id); + if (!current) throw new NotFoundException("Intent not found"); + if ((current.deadline ?? 0) <= Math.floor(Date.now() / 1000)) { + throw new GoneException("Intent has expired"); + } + throw new ConflictException(`Intent is ${current?.state ?? "unknown"}, cannot accept`); + } + + this.intentsService.appendAuditEntry(id, "accepted", dto.solver, "solver accepted", { + deadline: updated.deadline, + }); + this.intentsGateway.broadcast({ + type: "intent_accepted", + intentId: id, + solver: dto.solver, + }); + return updated; + } + + @Post(":id/fill") + @UseGuards(KillSwitchGuard) + @KillSwitchGate({ operation: "fill" }) + @ApiNotFoundResponse({ description: "Intent not found" }) + @ApiServiceUnavailableResponse({ + description: "An emergency kill-switch is active for this intent's scope (503 + Retry-After)", + }) + @ApiConflictResponse({ description: "Intent is not in accepted state" }) + @ApiForbiddenResponse({ description: "Wrong solver for this intent" }) + @ApiGoneResponse({ description: "Fill window has expired" }) + @ApiBadRequestResponse({ description: "Fill amount below minimum" }) + async fill(@Param("id") id: string, @Body() dto: FillIntentDto) { + const intent = await this.intentsService.get(id); + if (!intent) throw new NotFoundException("Intent not found"); + + // Same reason as in `accept`: the route guard cannot resolve the intent's + // chain/token from `:id`, so re-assert against the loaded record. + this.assertIntentNotPaused(intent, "fill"); + + const now = Math.floor(Date.now() / 1000); + if (intent.deadline <= now) { + throw new GoneException("Fill window has expired"); + } + + // Verify the solver controls the claimed address + verifyStellarSignature(dto.solver, buildFillMessage(id, dto.solver), dto.signature); + + const fillAmount = parseBaseUnits(dto.fillAmount); + let minAmount: bigint; + try { + minAmount = BigInt(intent.minDstAmount); + } catch { + throw new BadRequestException({ + error: "Data integrity error: intent minDstAmount is not a valid integer", + intentId: id, + minDstAmount: intent.minDstAmount, + }); + } + if (fillAmount < minAmount) { + throw new BadRequestException({ + error: "Fill amount below minimum", + fillAmount: dto.fillAmount, + minDstAmount: intent.minDstAmount, + }); + } + + const feeAmount = (BigInt(dto.fillAmount) * 5n) / 10000n; + + const updated = await this.intentsService.fillIfAccepted(id, dto.solver, { + filledAt: now, + fillAmount: dto.fillAmount, + feeAmount: feeAmount.toString(), + txHash: dto.txHash, + }); + if (!updated) { + const current = await this.intentsService.get(id); + if (current?.solver !== dto.solver) { + throw new ForbiddenException("Wrong solver for this intent"); + } + throw new ConflictException(`Intent is ${current?.state ?? "unknown"}, cannot fill`); + } + + await this.solversService.recordSuccessfulFill(dto.solver); + + this.intentsService.appendAuditEntry(id, "filled", dto.solver, "solver filled", { + fillAmount: dto.fillAmount, + txHash: dto.txHash, + }); + this.intentsGateway.broadcast({ + type: "intent_filled", + intentId: id, + solver: dto.solver, + fillAmount: dto.fillAmount, + }); + return updated; + } + + @Post(":id/cancel") + @ApiNotFoundResponse({ description: "Intent not found" }) + @ApiForbiddenResponse({ description: "Unauthorized" }) + @ApiConflictResponse({ description: "Intent is not in open state" }) + async cancel(@Param("id") id: string, @Body() dto: CancelIntentDto) { + const intent = await this.intentsService.get(id); + if (!intent) throw new NotFoundException("Intent not found"); + if (intent.user.toLowerCase() !== dto.user.toLowerCase()) { + throw new ForbiddenException("Unauthorized"); + } + if (intent.state !== "open") { + throw new ConflictException(`Cannot cancel intent in state: ${intent.state}`); + } + + // Verify the user controls the claimed address + verifyStellarSignature(dto.user, buildCancelMessage(id), dto.signature); + + const updated = await this.intentsService.cancelIfOpen(id); + if (!updated) { + const current = await this.intentsService.get(id); + throw new ConflictException(`Cannot cancel intent in state: ${current?.state ?? "unknown"}`); + } + + // Audit trail (issue #217 / #62): record who cancelled and when. + this.intentsService.appendAuditEntry(id, "cancelled", dto.user, "user cancelled"); + + this.intentsGateway.broadcast({ type: "intent_cancelled", intentId: id }); + return updated; + } + + /** + * Issue #44 — document 429 on quote too, since it's under the global guard. + * Issue #220 — routes are now computed via RoutingService and attached to each quote. + */ + @Post("quote") + @Throttle({ default: { limit: 20, ttl: 60_000 } }) + @ApiTooManyRequestsResponse({ + description: "Rate limit exceeded — max 20 quote requests per 60 s per IP", + }) + @ApiOkResponse({ type: QuoteResponseDto }) + async quote(@Body() dto: QuoteRequestDto): Promise { + const solvers = (await this.solversService.getAll()).filter((s) => s.isActive); + + // #219: use typed resolveSrcToken / resolveDstToken — no more any casts. + // #276: a quote may be requested by symbol alone (no contract/address), but + // when a token identifier IS supplied it must resolve — otherwise the quote + // engine would silently substitute a fake $1 price. + const srcToken = dto.srcTokenAddress + ? await this.tokensService.resolveSrcTokenOrThrow( + dto.srcChain as SupportedChain, + dto.srcTokenAddress, + ) + : undefined; + const dstToken = dto.dstTokenContract + ? await this.tokensService.resolveDstTokenOrThrow(dto.dstTokenContract) + : undefined; + + const srcAmountBigInt = parseBaseUnits(dto.srcAmount); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dstPriceUSD: number = (dstToken as any)?.priceUSD ?? 1; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const srcPriceUSD: number = (srcToken as any)?.priceUSD ?? dstPriceUSD; + + const quotes = solvers + .map((solver) => { + // Issue #118: weight variance by solver performance history. + const totalFills = solver.fillsCompleted + solver.fillsFailed; + const successRate = totalFills > 0 ? solver.fillsCompleted / totalFills : 0.5; + const fillCountScore = Math.min(solver.fillsCompleted / 100, 1); + const perfScore = successRate * 0.7 + fillCountScore * 0.3; + const varianceScaled = varianceScaleFromPerfScore(perfScore); + const dstAmount = applyVarianceScale(srcAmountBigInt, varianceScaled); + const fee = calculateProtocolFee(dstAmount); // 0.05% + + // Issue #126: compute USD fee total and price impact. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const feeUnits = toDecimalNumber(fee, (dstToken as any)?.decimals ?? 7); + const totalFeesUSD = feeUnits * dstPriceUSD; + const srcUnits = toDecimalNumber(srcAmountBigInt, srcToken?.decimals ?? 7); + const dstUnits = toDecimalNumber(dstAmount, dstToken?.decimals ?? 7); + const priceImpact = + srcPriceUSD > 0 && dstPriceUSD > 0 + ? Math.max(0, 1 - (dstUnits * dstPriceUSD) / (srcUnits * srcPriceUSD)) + : 0; + + // #220: attach a computed route to each solver quote. + // Build minimal TokenInfo objects for routing (uses resolved data when available). + const srcTokenInfo = { + address: dto.srcTokenAddress ?? "", + symbol: dto.srcTokenSymbol, + name: srcToken?.name ?? dto.srcTokenSymbol, + decimals: srcToken?.decimals ?? 18, + chain: (dto.srcChain as SupportedChain) ?? "ethereum", + priceUSD: srcToken?.priceUSD, + }; + const dstTokenInfo = { + address: dstToken?.contract ?? dto.dstTokenContract ?? "", + symbol: dto.dstTokenSymbol, + name: dstToken?.name ?? dto.dstTokenSymbol, + decimals: dstToken?.decimals ?? 7, + chain: "stellar" as SupportedChain, + priceUSD: dstToken?.priceUSD, + }; + + // Try a direct route; fall back to a two-hop via USDC intermediate when + // a direct solver path is not viable (different base tokens). + const route = this.routingService.buildRoute(srcTokenInfo, dstTokenInfo, solver.address, { + totalFeesUSD, + priceImpact, + estimatedFillTime: solver.avgFillTime + Math.floor(Math.random() * 30), + }); + + return { + solver: solver.address, + solverName: solver.name, + dstAmount: dstAmount.toString(), + fee: fee.toString(), + fillTime: solver.avgFillTime + Math.floor(Math.random() * 30), + expiresAt: Math.floor(Date.now() / 1000) + 60, + totalFeesUSD, + priceImpact, + route, + }; + }) + // nosemgrep: no-number-money -- sort comparator on bounded quote diffs only; amounts stay strings elsewhere. + .sort((a, b) => Number(BigInt(b.dstAmount) - BigInt(a.dstAmount))); + + if (dto.intentId && quotes.length > 0) { + await this.intentsService.update(dto.intentId, { quotedDstAmount: quotes[0].dstAmount }); + } + + const best = quotes[0] ?? null; + return { + quotes, + bestQuote: best, + srcChain: dto.srcChain, + srcTokenSymbol: dto.srcTokenSymbol, + srcAmount: dto.srcAmount, + dstTokenSymbol: dto.dstTokenSymbol, + estimatedFillTime: best?.fillTime ?? 0, + totalFeesUSD: best?.totalFeesUSD ?? 0, + priceImpact: best?.priceImpact ?? 0, + }; + } + + /** + * POST /api/v1/intents/:id/requote + * + * Convenience endpoint for re-quoting an already-created intent without + * resupplying srcChain/srcToken/srcAmount/dstToken — they're read straight + * off the stored Intent record. Only valid while the intent is "open". + */ + @Post(":id/requote") + @Throttle({ default: { limit: 20, ttl: 60_000 } }) + @ApiOperation({ summary: "Re-quote an existing open intent using its stored fields" }) + @ApiTooManyRequestsResponse({ + description: "Rate limit exceeded — max 20 quote requests per 60 s per IP", + }) + @ApiOkResponse({ type: QuoteResponseDto }) + @ApiNotFoundResponse({ description: "Intent not found" }) + @ApiConflictResponse({ description: "Intent is not in the open state" }) + async requote(@Param("id") id: string): Promise { + const intent = await this.intentsService.get(id); + if (!intent) throw new NotFoundException("Intent not found"); + if (intent.state !== "open") { + throw new ConflictException( + `Cannot requote intent in state "${intent.state}"; only open intents can be requoted`, + ); + } + + const solvers = (await this.solversService.getAll()).filter((s) => s.isActive); + const srcToken = intent.srcToken; + const dstToken = intent.dstToken; + const srcAmountBigInt = BigInt(intent.srcAmount); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dstPriceUSD: number = (dstToken as any)?.priceUSD ?? 1; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const srcPriceUSD: number = (srcToken as any)?.priceUSD ?? dstPriceUSD; + + const quotes = solvers + .map((solver) => { + const totalFills = solver.fillsCompleted + solver.fillsFailed; + const successRate = totalFills > 0 ? solver.fillsCompleted / totalFills : 0.5; + const fillCountScore = Math.min(solver.fillsCompleted / 100, 1); + const perfScore = successRate * 0.7 + fillCountScore * 0.3; + const variancePct = (1 - perfScore) * 0.008; + const varianceScaled = Math.round(1000 * (1 - variancePct)); + const dstAmount = (srcAmountBigInt * BigInt(varianceScaled)) / BigInt(1000); + const fee = (dstAmount * BigInt(5)) / BigInt(10000); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const feeUnits = Number(fee) / Math.pow(10, (dstToken as any)?.decimals ?? 7); + const totalFeesUSD = feeUnits * dstPriceUSD; + const srcUnits = Number(srcAmountBigInt) / Math.pow(10, srcToken?.decimals ?? 7); + const dstUnits = Number(dstAmount) / Math.pow(10, dstToken?.decimals ?? 7); + const priceImpact = + srcPriceUSD > 0 && dstPriceUSD > 0 + ? Math.max(0, 1 - (dstUnits * dstPriceUSD) / (srcUnits * srcPriceUSD)) + : 0; + + const dstTokenInfo = { + address: dstToken?.contract ?? "", + symbol: dstToken?.symbol ?? "", + name: dstToken?.symbol ?? "", + decimals: dstToken?.decimals ?? 7, + chain: "stellar" as SupportedChain, + priceUSD: dstToken?.priceUSD, + }; + + const route = this.routingService.buildRoute(srcToken, dstTokenInfo, solver.address, { + totalFeesUSD, + priceImpact, + estimatedFillTime: solver.avgFillTime + Math.floor(Math.random() * 30), + }); + + return { + solver: solver.address, + solverName: solver.name, + dstAmount: dstAmount.toString(), + fee: fee.toString(), + fillTime: solver.avgFillTime + Math.floor(Math.random() * 30), + expiresAt: Math.floor(Date.now() / 1000) + 60, + totalFeesUSD, + priceImpact, + route, + }; + }) + // nosemgrep: no-number-money -- sort comparator on bounded quote diffs only; amounts stay strings elsewhere. + .sort((a, b) => Number(BigInt(b.dstAmount) - BigInt(a.dstAmount))); + + if (quotes.length > 0) { + await this.intentsService.update(id, { quotedDstAmount: quotes[0].dstAmount }); + } + + const best = quotes[0] ?? null; + return { + quotes, + bestQuote: best, + srcChain: intent.srcChain, + srcTokenSymbol: srcToken?.symbol ?? "", + srcAmount: intent.srcAmount, + dstTokenSymbol: dstToken?.symbol ?? "", + estimatedFillTime: best?.fillTime ?? 0, + totalFeesUSD: best?.totalFeesUSD ?? 0, + priceImpact: best?.priceImpact ?? 0, + }; + } +} diff --git a/src/intents/intents.repository.ts b/src/intents/intents.repository.ts index e69de29b..6a304e86 100644 --- a/src/intents/intents.repository.ts +++ b/src/intents/intents.repository.ts @@ -0,0 +1,321 @@ +import { Injectable } from "@nestjs/common"; +import { v4 as uuidv4 } from "uuid"; +import { Intent, IntentState } from "./intents.types"; +import { buildSeedIntents } from "./intents.seed"; +import { intentExposureUsdMicros } from "./intent-exposure"; + +/** + * NestJS injection token for the intents repository. + * + * Use this token instead of a concrete class so any module can swap + * InMemoryIntentsRepository for a Prisma-backed adapter without touching + * IntentsService. + * + * @example + * \@Inject(INTENTS_REPOSITORY) private readonly repo: IIntentsRepository + */ +export const INTENTS_REPOSITORY = Symbol("INTENTS_REPOSITORY"); + +/** + * Storage contract for intent records. + * + * All methods are synchronous for the in-memory adapter and return Promises + * for the Prisma adapter — callers always `await` so both shapes work. + */ +export interface IIntentsRepository { + /** + * Persist a fully-formed intent record and return it. + * If a record with the same intentId already exists it is overwritten. + */ + save(intent: Intent): Intent | Promise; + + /** + * Find an intent by its unique intentId. + * Returns `undefined` when no matching record exists. + */ + findById(id: string): Intent | undefined | Promise; + + /** + * Return all intent records sorted by createdAt descending. + */ + findAll(): Intent[] | Promise; + + /** + * Return all intents matching the given state, sorted by createdAt descending. + */ + findByState(state: IntentState): Intent[] | Promise; + + /** + * Return all intents belonging to the given user (case-insensitive address match). + */ + findByUser(user: string): Intent[] | Promise; + + /** + * Apply a partial patch to an existing intent and return the updated record. + * Returns `null` when no record with the given id exists. + */ + update(id: string, patch: Partial): 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. + */ + delete(id: string): boolean | Promise; + + /** + * Atomically transition an intent from `open` → `accepted` only if it is + * currently in the `open` state AND its deadline is still in the future. + * Mirrors the DB pattern: + * UPDATE intents SET state='accepted', solver=$2, deadline=$3 + * WHERE intent_id=$1 AND state='open' AND deadline > $4 + * RETURNING * + * Returns the updated intent on success, `null` when the intent is not + * found, already taken, or past deadline (sweeper wins the race). + * + * Lock ordering (issue #473): callers holding a per-solver advisory lock + * must acquire it BEFORE invoking this method; this method itself only + * touches the single intent row so no lock inversion is possible. + */ + acceptIfOpen( + id: string, + solver: string, + newDeadline: number, + now?: number, + ): Intent | null | Promise; + + /** + * Atomically enforce the solver-wide accepted-exposure cap and accept an + * open intent. Implementations must serialize this check per solver. + */ + acceptIfOpenWithinExposure( + id: string, + solver: string, + newDeadline: number, + now: number, + candidateExposureUsdMicros: bigint, + maxExposureUsdMicros: bigint, + ): Promise<{ intent: Intent | null; exposureExceeded: boolean }> | { intent: Intent | null; exposureExceeded: boolean }; + + /** + * Atomically transition an intent from `accepted` → `filled` only if it is + * currently accepted by the specified solver AND the fill window has not + * elapsed. Mirrors the DB pattern: + * UPDATE intents SET state='filled', ...patch + * WHERE intent_id=$1 AND state='accepted' AND solver=$2 AND deadline > $3 + * RETURNING * + * Returns the updated intent on success, `null` on any guard failure. + * The `minDstAmount` invariant (fill >= minDst) is enforced by the + * controller/service layer with bigint comparison before this write; the + * state+deadline predicates here make the write itself race-free. + */ + fillIfAccepted( + id: string, + solver: string, + patch: Omit, "state" | "solver">, + now?: number, + ): Intent | null | Promise; + + /** + * Atomically transition an intent from `open` → `cancelled` only if it is + * currently in the `open` state. Mirrors the DB pattern: + * UPDATE intents SET state='cancelled' + * WHERE intent_id=$1 AND state='open' + * RETURNING * + * Returns the updated intent on success, `null` when the intent is not + * found or is not in the `open` state (e.g. already accepted or expired). + */ + cancelIfOpen(id: string): Intent | null | Promise; + + /** + * Atomically transition an intent from `open` → `expired` only if it is + * currently in the `open` state. Guards the sweeper's expiry pass against + * a concurrent user cancel() or solver accept() on the same intent. + */ + expireIfOpen(id: string): Intent | null | Promise; + + /** + * Atomically push an accepted intent's deadline out to at least `newDeadline`, + * only while it is still in the `accepted` state. Mirrors the DB pattern: + * UPDATE intents SET deadline=$2 + * WHERE intent_id=$1 AND state='accepted' AND deadline < $2 + * RETURNING * + * + * Issue #477: while an emergency pause covers `fill`, the sweeper cannot slash + * missed fills — but leaving the deadline untouched would expire those intents + * on the next cycle anyway and penalise the solver for a pause they did not + * cause. The `deadline < $2` guard makes this idempotent and never shortens + * a window, and the state predicate means a concurrent fill or slash wins. + * + * Returns the updated intent, or `null` when the intent is no longer accepted + * or already has a later deadline. + */ + extendDeadlineIfAccepted( + id: string, + newDeadline: number, + ): Intent | null | Promise; + + /** + * Atomically transition an intent from `accepted` → `slashed` only if it is + * currently in the `accepted` state. Guards the sweeper's slashing pass + * against a concurrent solver fill(). + */ + slashIfAccepted( + id: string, + patch: { slashedAt: number; slashReason: string }, + ): Intent | null | Promise; +} + +/** + * In-memory implementation of IIntentsRepository. + * + * Stores intents in a plain `Map` and seeds demo data on construction. + * This adapter ships with the current in-memory backend; swap the binding in + * IntentsModule to replace it with a Prisma-backed adapter — IntentsService + * stays unchanged. + */ +@Injectable() +export class InMemoryIntentsRepository implements IIntentsRepository { + private readonly store = new Map(); + + constructor() { + this.seed(); + } + + save(intent: Intent): Intent { + this.store.set(intent.intentId, intent); + return intent; + } + + findById(id: string): Intent | undefined { + return this.store.get(id); + } + + findAll(): Intent[] { + return [...this.store.values()].sort((a, b) => b.createdAt - a.createdAt); + } + + findByState(state: IntentState): Intent[] { + return this.findAll().filter((i) => i.state === state); + } + + findByUser(user: string): Intent[] { + return this.findAll().filter((i) => i.user.toLowerCase() === user.toLowerCase()); + } + + update(id: string, patch: Partial): Intent | null { + const existing = this.store.get(id); + if (!existing) return null; + const updated: Intent = { ...existing, ...patch }; + this.store.set(id, updated); + return updated; + } + + delete(id: string): boolean { + return this.store.delete(id); + } + + acceptIfOpen(id: string, solver: string, newDeadline: number, now?: number): Intent | null { + const existing = this.store.get(id); + if (!existing || existing.state !== "open") return null; + // Deadline predicate pushed into the atomic check (issue #473): a solver + // racing the sweeper past expiry must lose even in-process. + const nowSec = now ?? Math.floor(Date.now() / 1000); + if (existing.deadline <= nowSec) return null; + const updated: Intent = { ...existing, state: "accepted", solver, deadline: newDeadline }; + this.store.set(id, updated); + return updated; + } + + acceptIfOpenWithinExposure( + id: string, + solver: string, + newDeadline: number, + now: number, + candidateExposureUsdMicros: bigint, + maxExposureUsdMicros: bigint, + ): { intent: Intent | null; exposureExceeded: boolean } { + const existing = this.store.get(id); + if (!existing || existing.state !== "open" || existing.deadline <= now) { + return { intent: null, exposureExceeded: false }; + } + let acceptedExposure = 0n; + for (const intent of this.store.values()) { + if (intent.state === "accepted" && intent.solver?.toLowerCase() === solver.toLowerCase()) { + acceptedExposure += intentExposureUsdMicros(intent, now); + } + } + if (acceptedExposure + candidateExposureUsdMicros > maxExposureUsdMicros) { + return { intent: null, exposureExceeded: true }; + } + const updated: Intent = { ...existing, state: "accepted", solver, deadline: newDeadline }; + this.store.set(id, updated); + return { intent: updated, exposureExceeded: false }; + } + + fillIfAccepted( + id: string, + solver: string, + patch: Omit, "state" | "solver">, + now?: number, + ): Intent | null { + const existing = this.store.get(id); + if (!existing || existing.state !== "accepted" || existing.solver !== solver) return null; + const nowSec = now ?? Math.floor(Date.now() / 1000); + if (existing.deadline <= nowSec) return null; + const updated: Intent = { ...existing, ...patch, state: "filled" }; + this.store.set(id, updated); + return updated; + } + + cancelIfOpen(id: string): Intent | null { + const existing = this.store.get(id); + if (!existing || existing.state !== "open") return null; + const updated: Intent = { ...existing, state: "cancelled" }; + this.store.set(id, updated); + return updated; + } + + expireIfOpen(id: string): Intent | null { + const existing = this.store.get(id); + if (!existing || existing.state !== "open") return null; + const updated: Intent = { ...existing, state: "expired" }; + this.store.set(id, updated); + return updated; + } + + slashIfAccepted( + id: string, + patch: { slashedAt: number; slashReason: string }, + ): Intent | null { + const existing = this.store.get(id); + if (!existing || existing.state !== "accepted") return null; + const updated: Intent = { ...existing, ...patch, state: "slashed" }; + this.store.set(id, updated); + return updated; + } + + extendDeadlineIfAccepted(id: string, newDeadline: number): Intent | null { + const existing = this.store.get(id); + if (!existing || existing.state !== "accepted") return null; + // Never shorten: a later deadline is left untouched so repeated sweeps are + // no-ops rather than a countdown. + if (existing.deadline >= newDeadline) return null; + const updated: Intent = { ...existing, deadline: newDeadline }; + this.store.set(id, updated); + return updated; + } + + // ── seed ──────────────────────────────────────────────────────────────────── + + seed(): void { + const now = Math.floor(Date.now() / 1000); + for (const data of buildSeedIntents(now)) { + const intent: Intent = { + ...data, + intentId: uuidv4(), + createdAt: now - Math.floor(Math.random() * 600), + }; + this.store.set(intent.intentId, intent); + } + } +} diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index e69de29b..a158eb1d 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -0,0 +1,796 @@ +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; + +/** + * 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, + /** + * 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, + private readonly protocolParamsService: ProtocolParamsService, + @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; + } + + /** + * 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); + } + + /** + * 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; + } + + /** Accept only when this solver remains below the configured exposure cap. */ + async acceptIfOpenWithinExposure( + id: string, + solver: string, + candidateExposureUsdMicros: bigint, + maxExposureUsdMicros: bigint, + now = Math.floor(Date.now() / 1000), + ): Promise<{ intent: Intent | null; exposureExceeded: boolean }> { + const intent = await this.repo.findById(id); + if (!intent) return { intent: null, exposureExceeded: false }; + const fillWindow = this.protocolParamsService.snapshotForChain(intent.srcChain).fillWindowSeconds; + const result = await this.repo.acceptIfOpenWithinExposure( + id, + solver, + now + fillWindow, + now, + candidateExposureUsdMicros, + maxExposureUsdMicros, + ); + if (result.intent !== null) this.countTransition("open", "accepted"); + if (this.beginShadowObservation()) { + this.observeAccept(result.intent ?? intent, solver, result.intent !== null); + } + return result; + } + + /** 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. + if (subject?.solver) { + this.reportShadow( + "slash", + subject.intentId, + updated !== null, + "slash_intent", + this.safeArgs(() => [ + nativeToScVal(subject.intentId, { type: "string" }), + new Address(subject.solver).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/soroban/events/solver-registry-events.service.ts b/src/soroban/events/solver-registry-events.service.ts index d2b7b4cb..f60f1309 100644 --- a/src/soroban/events/solver-registry-events.service.ts +++ b/src/soroban/events/solver-registry-events.service.ts @@ -44,6 +44,7 @@ import { SorobanService } from "../soroban.service"; import { SolversService } from "../../solvers/solvers.service"; import { IntentsGateway } from "../../intents/intents.gateway"; import { MetricsService } from "../../metrics/metrics.service"; +import { SolverBondService } from "../solver-bond.service"; import { logger as appLogger } from "../../common/logger"; import { parseEventIndex, buildDedupeKey } from "../event-ingestion.service"; @@ -63,6 +64,7 @@ export class SolverRegistryEventsService implements OnModuleInit, OnModuleDestro private readonly solversService: SolversService, @Optional() private readonly gateway?: IntentsGateway, @Optional() private readonly metricsService?: MetricsService, + @Optional() private readonly solverBondService?: SolverBondService, ) {} onModuleInit(): void { @@ -121,6 +123,12 @@ export class SolverRegistryEventsService implements OnModuleInit, OnModuleDestro const eventName = typeof topic[0] === "string" ? topic[0] : undefined; if (!eventName) return; + if (["SolverRegistered", "BondUpdated", "BondDeposited", "BondWithdrawn", "SolverSlashed", "SolverDeactivated"].includes(eventName)) { + const address = topic[1]; + if (typeof address === "string") this.solverBondService?.invalidate(address); + else this.solverBondService?.invalidateAll(); + } + try { switch (eventName) { case "SolverRegistered": diff --git a/src/soroban/solver-bond.service.ts b/src/soroban/solver-bond.service.ts new file mode 100644 index 00000000..94420849 --- /dev/null +++ b/src/soroban/solver-bond.service.ts @@ -0,0 +1,116 @@ +import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + Address, + BASE_FEE, + Contract, + Networks, + SorobanRpc, + TransactionBuilder, + scValToNative, +} from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; + +const CACHE_TTL_MS = 30_000; +const NETWORK_PASSPHRASE: Record = { + testnet: Networks.TESTNET, + futurenet: Networks.FUTURENET, + mainnet: Networks.PUBLIC, +}; + +export interface OnChainSolverBond { + bondAmount: bigint; + isActive: boolean; +} + +interface CacheEntry { + value: OnChainSolverBond; + expiresAt: number; +} + +/** Read-only, short-lived view of a solver's authoritative registry state. */ +@Injectable() +export class SolverBondService { + private readonly logger = new Logger(SolverBondService.name); + private readonly contractId: string; + private readonly networkPassphrase: string; + private readonly server: SorobanRpc.Server; + private readonly cache = new Map(); + + constructor(config: ConfigService) { + this.contractId = config.get("stellar.solverRegistryContractId", { infer: true }); + const network = config.get("stellar.network", { infer: true }); + this.networkPassphrase = NETWORK_PASSPHRASE[network]; + const rpcUrl = config.get("stellar.sorobanRpcUrl", { infer: true }); + this.server = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith("http://") }); + } + + /** + * Read the bond and active bit from the solver-registry contract using a + * non-mutating Soroban simulation. RPC/configuration failures return 503; + * cached successful reads live for at most 30 seconds. + */ + async getBond(address: string): Promise { + const key = address.toLowerCase(); + const cached = this.cache.get(key); + if (cached && cached.expiresAt > Date.now()) return cached.value; + if (!this.contractId) { + throw this.unavailable("Solver registry contract is not configured"); + } + + try { + const account = await this.server.getAccount(address); + const contract = new Contract(this.contractId); + const transaction = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(contract.call("get_bond", Address.fromString(address).toScVal())) + .addOperation(contract.call("is_active", Address.fromString(address).toScVal())) + .setTimeout(30) + .build(); + const simulation = await this.server.simulateTransaction(transaction); + if (SorobanRpc.Api.isSimulationError(simulation) || !simulation.results || simulation.results.length < 2) { + throw new Error(SorobanRpc.Api.isSimulationError(simulation) ? simulation.error : "missing simulation results"); + } + + const bondNative = scValToNative(simulation.results[0].xdr); + const activeNative = scValToNative(simulation.results[1].xdr); + const bondAmount = this.parseBondAmount(bondNative); + if (typeof activeNative !== "boolean") throw new Error("invalid active status returned by registry"); + + const value = { bondAmount, isActive: activeNative }; + this.cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS }); + return value; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + this.logger.warn(`On-chain solver-bond read failed for ${address}: ${detail}`); + throw this.unavailable("Unable to verify solver bond with the registry contract"); + } + } + + /** Invalidate a cached solver state when a registry event changes it. */ + invalidate(address: string): void { + this.cache.delete(address.toLowerCase()); + } + + /** Invalidate every entry, e.g. after an unaddressed registry event. */ + invalidateAll(): void { + this.cache.clear(); + } + + private parseBondAmount(value: unknown): bigint { + if (typeof value === "bigint") return value; + if (typeof value === "number" && Number.isSafeInteger(value)) return BigInt(value); + if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value); + throw new Error("invalid bond amount returned by registry"); + } + + private unavailable(message: string): ServiceUnavailableException { + return new ServiceUnavailableException({ + code: "BOND_VERIFICATION_UNAVAILABLE", + error: message, + message, + }); + } +} \ No newline at end of file diff --git a/src/tokens/price-feed.provider.ts b/src/tokens/price-feed.provider.ts index af3664d0..5d9e83c4 100644 --- a/src/tokens/price-feed.provider.ts +++ b/src/tokens/price-feed.provider.ts @@ -1,3 +1,8 @@ export interface PriceFeedProvider { + /** + * Return a positive USD quote for a token symbol. Consumers making + * collateral decisions must independently enforce their quote-freshness + * policy and fail closed when no usable quote is available. + */ getUsdPrice(symbol: string): Promise; }