From e6f368137285991c709ac46bb4e0c4663048317b Mon Sep 17 00:00:00 2001 From: paul_motron Date: Tue, 29 Sep 2026 00:03:50 +0100 Subject: [PATCH] feat(intents): enforce on-chain solver bond exposure --- docs/solver-onboarding.md | 20 +++ src/intents/intent-exposure.ts | 72 +++++++++++ src/intents/intents.controller.ts | 53 ++++++-- src/intents/intents.repository.ts | 40 ++++++ src/intents/intents.service.ts | 29 ++++- src/intents/prisma-intents.repository.ts | 44 +++++++ .../events/solver-registry-events.service.ts | 8 ++ src/soroban/solver-bond.service.ts | 116 ++++++++++++++++++ src/soroban/soroban.module.ts | 3 + src/tokens/price-feed.provider.ts | 5 + src/tokens/tokens.service.ts | 23 +++- 11 files changed, 400 insertions(+), 13 deletions(-) create mode 100644 src/intents/intent-exposure.ts create mode 100644 src/soroban/solver-bond.service.ts diff --git a/docs/solver-onboarding.md b/docs/solver-onboarding.md index 53fa706e..b177a3ab 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 14040590..36e66f67 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -10,6 +10,7 @@ import { Param, Post, Query, + ServiceUnavailableException, UseGuards, } from "@nestjs/common"; import { @@ -65,6 +66,9 @@ 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") @@ -77,6 +81,8 @@ export class IntentsController { 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 }) ?? []); } @@ -335,6 +341,7 @@ export class IntentsController { @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 @@ -359,13 +366,6 @@ export class IntentsController { // Verify the solver controls the claimed address before it can accept. verifyStellarSignature(dto.solver, buildAcceptMessage(id, dto.solver), dto.signature); - const solver = await this.solversService.get(dto.solver); - if (!solver?.isActive) { - throw new ForbiddenException("Solver not registered or inactive"); - } - if (!solver.bondAmount || BigInt(solver.bondAmount) <= 0n) { - throw new ForbiddenException("Solver has insufficient bond"); - } if (this.solversService.isSuspended(dto.solver)) { throw new ForbiddenException("Solver is suspended by an active guardian action"); } @@ -375,7 +375,44 @@ export class IntentsController { throw new ForbiddenException("Canary intents may only be accepted by canary solvers, and vice versa"); } - const updated = await this.intentsService.acceptIfOpen(id, dto.solver, now); + 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"); diff --git a/src/intents/intents.repository.ts b/src/intents/intents.repository.ts index e361a781..6a304e86 100644 --- a/src/intents/intents.repository.ts +++ b/src/intents/intents.repository.ts @@ -2,6 +2,7 @@ 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. @@ -82,6 +83,19 @@ export interface IIntentsRepository { 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 @@ -212,6 +226,32 @@ export class InMemoryIntentsRepository implements IIntentsRepository { 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, diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index c8222ed3..a158eb1d 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -520,6 +520,32 @@ export class IntentsService { 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( @@ -533,9 +559,6 @@ export class IntentsService { nativeToScVal(intent.deadline, { type: "u64" }), ]), ); - const snapshot = this.protocolParamsService.snapshotForChain(intent.srcChain); - const fillWindow = snapshot.fillWindowSeconds; - return this.repo.acceptIfOpen(id, solver, nowSec + fillWindow, nowSec); } /** diff --git a/src/intents/prisma-intents.repository.ts b/src/intents/prisma-intents.repository.ts index 4a99461e..27b4be44 100644 --- a/src/intents/prisma-intents.repository.ts +++ b/src/intents/prisma-intents.repository.ts @@ -3,6 +3,7 @@ import { PrismaService } from "../prisma/prisma.service"; import { IIntentsRepository } from "./intents.repository"; import { Intent, IntentState, StellarToken, TokenInfo } from "./intents.types"; import { IntentState as PrismaIntentState, Prisma } from "@prisma/client"; +import { intentExposureUsdMicros } from "./intent-exposure"; /** * Prisma-backed implementation of IIntentsRepository. @@ -117,6 +118,49 @@ export class PrismaIntentsRepository implements IIntentsRepository { return row ? this.fromRow(row) : null; } + /** + * Enforce the solver's exposure ceiling while holding a transaction-scoped + * advisory lock, then conditionally accept the intent in that transaction. + */ + async acceptIfOpenWithinExposure( + id: string, + solver: string, + newDeadline: number, + now: number, + candidateExposureUsdMicros: bigint, + maxExposureUsdMicros: bigint, + ): Promise<{ intent: Intent | null; exposureExceeded: boolean }> { + return this.prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtext(${solver.toLowerCase()}))`; + + const accepted = await tx.intent.findMany({ + where: { + solver: { equals: solver, mode: "insensitive" }, + state: PrismaIntentState.accepted, + }, + }); + const acceptedExposure = accepted.reduce( + (total, row) => total + intentExposureUsdMicros(this.fromRow(row), now), + 0n, + ); + if (acceptedExposure + candidateExposureUsdMicros > maxExposureUsdMicros) { + return { intent: null, exposureExceeded: true }; + } + + const result = await tx.intent.updateMany({ + where: { intentId: id, state: PrismaIntentState.open, deadline: { gt: now } }, + data: { + state: PrismaIntentState.accepted, + solver, + deadline: newDeadline, + }, + }); + if (result.count === 0) return { intent: null, exposureExceeded: false }; + const row = await tx.intent.findUnique({ where: { intentId: id } }); + return { intent: row ? this.fromRow(row) : null, exposureExceeded: false }; + }); + } + /** * Acquire a transaction-scoped advisory lock for a solver key (issue #473). * 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/soroban/soroban.module.ts b/src/soroban/soroban.module.ts index f8b97d06..16a51bf4 100644 --- a/src/soroban/soroban.module.ts +++ b/src/soroban/soroban.module.ts @@ -7,6 +7,7 @@ import { ShadowService } from "./shadow.service"; import { SorobanController } from "./soroban.controller"; import { SorobanService } from "./soroban.service"; import { SolverRegistryService } from "./solver-registry.service"; +import { SolverBondService } from "./solver-bond.service"; import { SignerService } from "./signer.service"; import { StellarTxService } from "./stellar-tx.service"; import { TxConfirmationService } from "./tx-confirmation.service"; @@ -60,6 +61,7 @@ import { IntentsModule } from "../intents/intents.module"; StellarTxService, SolverRegistryService, + SolverBondService, EventIngestionService, // ── Solver-registry event ingestion (issue #399) ────────────────────── @@ -71,6 +73,7 @@ import { IntentsModule } from "../intents/intents.module"; exports: [ SorobanService, SolverRegistryService, + SolverBondService, SignerService, StellarTxService, TxConfirmationService, 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; } diff --git a/src/tokens/tokens.service.ts b/src/tokens/tokens.service.ts index eb27c5ca..c43db08f 100644 --- a/src/tokens/tokens.service.ts +++ b/src/tokens/tokens.service.ts @@ -1,8 +1,9 @@ -import { BadRequestException, Inject, Injectable } from "@nestjs/common"; +import { BadRequestException, Inject, Injectable, ServiceUnavailableException } from "@nestjs/common"; import { SUPPORTED_TOKENS, StellarToken } from "./tokens.data"; import { SUPPORTED_TOKENS, STELLAR_TOKENS, StellarToken } from "./tokens.data"; import { SupportedChain } from "../intents/intents.types"; import { ITokensRepository, TOKENS_REPOSITORY, TokenRecord } from "./tokens.repository"; +import { PriceFeedProvider } from "./price-feed.provider"; /** * A resolved source-chain (EVM or Stellar source) token — always has a @@ -48,12 +49,30 @@ export interface TokensByChainResponse { } @Injectable() -export class TokensService { +export class TokensService implements PriceFeedProvider { constructor( @Inject(TOKENS_REPOSITORY) private readonly repo: ITokensRepository, ) {} + /** + * Resolve the configured USD price for a symbol. Stellar entries take + * precedence when a symbol is shared across chains (notably XLM). + */ + async getUsdPrice(symbol: string): Promise { + const matches = this.repo.findAll().filter((token) => token.symbol.toLowerCase() === symbol.toLowerCase()); + const token = matches.find((candidate) => candidate.chain === "stellar") ?? matches[0]; + const price = token?.priceUsd; + if (price === undefined || price === null || !Number.isFinite(price) || price <= 0) { + throw new ServiceUnavailableException({ + code: "STALE_PRICE", + error: `No usable USD price is available for ${symbol}`, + message: `No usable USD price is available for ${symbol}`, + }); + } + return price; + } + /** * Look up a source token by chain + address/contract. *