diff --git a/CHANGELOG.md b/CHANGELOG.md index 599b189..43961cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,8 +31,23 @@ Commit message format is enforced via [commitlint](https://commitlint.js.org/) s - Husky `commit-msg` hook — runs commitlint on every local commit (Closes #137) - CI job `commitlint` — validates commit messages on every push/PR in GitHub Actions (Closes #137) +- `src/common/amount.ts` — shared, unit-tested base-units ↔ decimal conversion plus + the protocol fee (0.05 %) and quote-variance helpers; `IntentsController.quote()` + and `fill()` now use it instead of duplicated inline `BigInt`/decimal math + (Closes #272) +- `PATCH /api/v1/solvers/:address` (`UpdateSolverDto`, `buildUpdateSolverMessage`) — + signature-verified partial update of a solver's mutable profile fields + (`name`, `supportedChains`, `supportedTokens`, `avgFillTime`); immutable fields + are stripped by the DTO whitelist (Closes #273) +- Typed Swagger response documentation for every `SorobanController` and + `TokensController` route, including the account route's 400/429 responses + (Closes #271) ### Fixed +- `IntentsService.create()` idempotency-key handling is now race-safe — concurrent + requests carrying the same key synchronously claim an in-flight slot before any + `await`, so exactly one intent is created and the losers replay its result + (Closes #274) - `TokensModule` was missing `exports: [TokensService]` — `IntentsController` could not inject `TokensService` outside the Jest test environment - `IntentsModule` was missing `exports: [IntentsGateway]` — `StatsService` diff --git a/docs/solver-onboarding.md b/docs/solver-onboarding.md index 8d5d814..093700a 100644 --- a/docs/solver-onboarding.md +++ b/docs/solver-onboarding.md @@ -78,6 +78,42 @@ register: --- +## 1a. Updating Your Solver Profile + +Once registered, a solver operator can edit their **mutable** profile fields as +their operation scales — for example adding a newly-supported chain or fixing a +typo in the display name — without re-registering. + +### HTTP Request +`PATCH /api/v1/solvers/:address` + +- **Authentication**: same proof-of-control convention as registration. Sign the + UTF-8 bytes of the message `update-solver:G...` (the `:address` path segment) + with the solver's Stellar secret key and send the base64 signature in the body. +- **Editable fields**: `name`, `supportedChains`, `supportedTokens`, `avgFillTime` + (all optional — send only what changes). Arrays are replaced wholesale. +- **Immutable fields** (`address`, `bondAmount`, `fillsCompleted`, `fillsFailed`, + `totalVolume`, `registeredAt`, `isActive`) are silently stripped by the + request validator; sending them is a no-op, not an error. Bond changes are an + on-chain concern — see section 2. + +**Payload (`UpdateSolverDto`)**: +```json +{ + "name": "Alpha-Liquidity-Solver", + "supportedChains": ["stellar", "ethereum", "polygon", "arbitrum", "base"], + "supportedTokens": ["USDC", "XLM", "ETH", "WBTC"], + "avgFillTime": 38, + "signature": "base64EncodedSignatureString==" +} +``` + +**Responses**: `200 OK` with the updated solver record; `400` for an invalid +body (e.g. an unsupported chain or a missing signature); `401` for a bad or +mismatched signature; `404` when `:address` is not a registered solver. + +--- + ## 2. Bond Posting & On-Chain Enforcement ### Posting a Bond diff --git a/src/common/amount.spec.ts b/src/common/amount.spec.ts new file mode 100644 index 0000000..d6b4775 --- /dev/null +++ b/src/common/amount.spec.ts @@ -0,0 +1,162 @@ +import { + BPS_DENOMINATOR, + PROTOCOL_FEE_BPS, + VARIANCE_SCALE, + assertValidDecimals, + applyVarianceScale, + calculateProtocolFee, + parseBaseUnits, + toBaseUnits, + toDecimalNumber, + varianceScaleFromPerfScore, +} from "./amount"; + +describe("common/amount", () => { + describe("constants", () => { + it("encodes the documented 0.05% protocol fee", () => { + expect(PROTOCOL_FEE_BPS).toBe(5n); + expect(BPS_DENOMINATOR).toBe(10_000n); + expect(VARIANCE_SCALE).toBe(1_000n); + }); + }); + + describe("assertValidDecimals", () => { + it.each([0, 6, 7, 18, 36])("accepts %s", (d) => { + expect(() => assertValidDecimals(d)).not.toThrow(); + }); + + it.each([-1, 1.5, 37, NaN, Infinity])("rejects %s", (d) => { + expect(() => assertValidDecimals(d)).toThrow(RangeError); + }); + }); + + describe("parseBaseUnits", () => { + it("passes through a non-negative bigint", () => { + expect(parseBaseUnits(42n)).toBe(42n); + }); + + it("parses a digit string, tolerating surrounding whitespace", () => { + expect(parseBaseUnits(" 1000000 ")).toBe(1_000_000n); + }); + + it("parses amounts far beyond Number.MAX_SAFE_INTEGER without loss", () => { + const huge = "123456789012345678901234567890"; + expect(parseBaseUnits(huge)).toBe(BigInt(huge)); + }); + + it.each(["-1", "1.5", "0x10", "", "abc", "1e3"])("rejects %j", (v) => { + expect(() => parseBaseUnits(v)).toThrow(RangeError); + }); + + it("rejects a negative bigint", () => { + expect(() => parseBaseUnits(-1n)).toThrow(RangeError); + }); + }); + + describe("toDecimalNumber", () => { + it("scales by the given decimals", () => { + expect(toDecimalNumber("1000000", 6)).toBe(1); + expect(toDecimalNumber("1500000", 6)).toBe(1.5); + expect(toDecimalNumber("1", 7)).toBe(0.0000001); + }); + + it("handles zero decimals as an identity", () => { + expect(toDecimalNumber("123", 0)).toBe(123); + }); + + it("handles a zero amount for any decimals", () => { + for (let d = 0; d <= 18; d++) { + expect(toDecimalNumber("0", d)).toBe(0); + } + }); + + it("accepts a bigint input", () => { + expect(toDecimalNumber(2_500_000n, 6)).toBe(2.5); + }); + + it("stays precise for very large amounts where Number division would drift", () => { + // 10^30 base units at 18 decimals = 10^12 whole units, exactly representable. + const baseUnits = "1" + "0".repeat(30); + expect(toDecimalNumber(baseUnits, 18)).toBe(1e12); + }); + + it("is the inverse of toBaseUnits for representable values", () => { + for (const [amount, decimals] of [ + [1, 6], + [1234.56, 2], + [0.0000001, 7], + [999999.999999, 6], + ] as const) { + expect(toDecimalNumber(toBaseUnits(amount, decimals), decimals)).toBeCloseTo(amount, decimals); + } + }); + + it("rejects invalid decimals", () => { + expect(() => toDecimalNumber("1", -1)).toThrow(RangeError); + }); + }); + + describe("toBaseUnits", () => { + it("scales up by the given decimals", () => { + expect(toBaseUnits(1, 6)).toBe("1000000"); + expect(toBaseUnits(1.5, 6)).toBe("1500000"); + expect(toBaseUnits(0, 18)).toBe("0"); + }); + + it("truncates sub-unit precision rather than rounding", () => { + expect(toBaseUnits(1.2345678, 6)).toBe("1234567"); + }); + + it("rejects negative or non-finite amounts", () => { + expect(() => toBaseUnits(-1, 6)).toThrow(RangeError); + expect(() => toBaseUnits(Infinity, 6)).toThrow(RangeError); + }); + }); + + describe("calculateProtocolFee", () => { + it("takes 0.05% of the destination amount, floored", () => { + expect(calculateProtocolFee("1000000")).toBe(500n); // 0.05% of 1_000_000 + expect(calculateProtocolFee(0n)).toBe(0n); + expect(calculateProtocolFee("19999")).toBe(9n); // 9.9995 -> 9 + }); + + it("matches the inline formula for a large bigint amount", () => { + const dst = 987654321987654321987654321n; + expect(calculateProtocolFee(dst)).toBe((dst * 5n) / 10_000n); + }); + }); + + describe("varianceScaleFromPerfScore", () => { + it("applies no haircut for a perfect score", () => { + expect(varianceScaleFromPerfScore(1)).toBe(1000); + }); + + it("applies the maximum 0.8% haircut for a zero score", () => { + expect(varianceScaleFromPerfScore(0)).toBe(992); + }); + + it("clamps out-of-range scores", () => { + expect(varianceScaleFromPerfScore(5)).toBe(1000); + expect(varianceScaleFromPerfScore(-5)).toBe(992); + }); + + it("reproduces the original inline computation", () => { + for (const perfScore of [0.1, 0.25, 0.5, 0.73, 0.9]) { + const expected = Math.round(1000 * (1 - (1 - perfScore) * 0.008)); + expect(varianceScaleFromPerfScore(perfScore)).toBe(expected); + } + }); + }); + + describe("applyVarianceScale", () => { + it("scales the source amount by scale / VARIANCE_SCALE in bigint", () => { + expect(applyVarianceScale("1000000", 1000)).toBe(1_000_000n); + expect(applyVarianceScale("1000000", 992)).toBe(992_000n); + }); + + it("matches the inline formula for a large bigint amount", () => { + const src = 123456789012345678901234567890n; + expect(applyVarianceScale(src, 997)).toBe((src * 997n) / 1000n); + }); + }); +}); diff --git a/src/common/amount.ts b/src/common/amount.ts new file mode 100644 index 0000000..7f11b1a --- /dev/null +++ b/src/common/amount.ts @@ -0,0 +1,160 @@ +/** + * Shared helpers for base-unit ↔ decimal amount conversion and the protocol's + * fee / quote-variance arithmetic. + * + * Why this module exists + * ---------------------- + * `IntentsController.quote()` and `fill()` each grew their own inline copy of + * `BigInt` arithmetic and `Number(x) / Math.pow(10, decimals)` scaling. + * `CHANGELOG.md` already records one *"Precision loss in quote calculation for + * large bigint amounts"* bug — exactly the class of defect that duplicated, + * ad-hoc bigint-and-decimals math tends to reintroduce. Consolidating the + * conversions here gives every caller a single, unit-tested source of truth. + * + * Precision contract + * ------------------ + * All arithmetic is performed in `BigInt`. The **only** place a value is + * converted to `Number` is the final display-scaling step in + * {@link toDecimalNumber}, and even there the integer and fractional parts are + * split as strings first so large amounts do not lose precision before scaling. + */ + +/** Basis-points denominator: 1 bp = 0.01 %, so 10 000 bp = 100 %. */ +export const BPS_DENOMINATOR = 10_000n; + +/** Protocol fee, expressed in basis points: 0.05 % = 5 bp. */ +export const PROTOCOL_FEE_BPS = 5n; + +/** + * Fixed-point scale used when weighting a quote's destination amount by a + * solver's performance variance (see {@link varianceScaleFromPerfScore}). + */ +export const VARIANCE_SCALE = 1_000n; + +/** + * Largest `decimals` value we accept. Well beyond any real token (18 is the + * practical maximum) while still guarding against absurd input. + */ +const MAX_DECIMALS = 36; + +/** + * Validate that `decimals` is a non-negative integer within a sane range. + * + * @throws {RangeError} when `decimals` is not an integer in `[0, 36]`. + */ +export function assertValidDecimals(decimals: number): void { + if (!Number.isInteger(decimals) || decimals < 0 || decimals > MAX_DECIMALS) { + throw new RangeError( + `decimals must be an integer in [0, ${MAX_DECIMALS}], received ${decimals}`, + ); + } +} + +/** + * Parse a non-negative integer base-unit amount into a `BigInt`. + * + * Accepts either a `BigInt` (returned as-is after a sign check) or a decimal + * string of ASCII digits. Leading/trailing whitespace is tolerated. + * + * @throws {RangeError} when the value is not a non-negative integer. + */ +export function parseBaseUnits(value: string | bigint): bigint { + if (typeof value === "bigint") { + if (value < 0n) { + throw new RangeError(`base-unit amount must be non-negative, received ${value}`); + } + return value; + } + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) { + throw new RangeError(`invalid base-unit amount: ${JSON.stringify(value)}`); + } + return BigInt(trimmed); +} + +/** + * Convert a base-unit integer amount to a human-scaled decimal `Number`. + * + * Equivalent to `Number(baseUnits) / 10 ** decimals` but precision-safe for + * amounts above `Number.MAX_SAFE_INTEGER`: the whole and fractional parts are + * assembled as a decimal string and parsed once, so the only rounding is the + * unavoidable `string → Number` step. + * + * @param baseUnits Non-negative integer amount in the token's smallest unit. + * @param decimals Number of decimal places the token uses (e.g. `6` for USDC). + */ +export function toDecimalNumber(baseUnits: string | bigint, decimals: number): number { + assertValidDecimals(decimals); + const units = parseBaseUnits(baseUnits); + if (decimals === 0) { + return Number(units); + } + const divisor = 10n ** BigInt(decimals); + const whole = units / divisor; + const fraction = (units % divisor).toString().padStart(decimals, "0").replace(/0+$/, ""); + return Number(fraction ? `${whole}.${fraction}` : whole.toString()); +} + +/** + * Convert a human-scaled decimal `Number` back to a base-unit integer string. + * + * The inverse of {@link toDecimalNumber}. Any precision in `amount` beyond + * `decimals` places is truncated (not rounded), matching how on-chain token + * transfers treat sub-unit dust. + * + * @throws {RangeError} when `amount` is negative or not finite. + */ +export function toBaseUnits(amount: number, decimals: number): string { + assertValidDecimals(decimals); + if (!Number.isFinite(amount) || amount < 0) { + throw new RangeError(`amount must be a non-negative finite number, received ${amount}`); + } + // `toFixed` expands any exponential notation and gives us a fixed-point string + // with one extra digit, which we then truncate to `decimals` places. + const fixed = amount.toFixed(decimals + 1); + const [whole, fractionRaw = ""] = fixed.split("."); + const fraction = fractionRaw.slice(0, decimals).padEnd(decimals, "0"); + const combined = `${whole}${fraction}`.replace(/^0+(?=\d)/, ""); + return BigInt(combined).toString(); +} + +/** + * Calculate the protocol fee for a destination amount. + * + * `fee = dstAmount * PROTOCOL_FEE_BPS / BPS_DENOMINATOR` (0.05 %), floored to + * an integer number of base units via `BigInt` division. + * + * @param dstAmount Destination amount in base units. + * @returns The fee in base units. + */ +export function calculateProtocolFee(dstAmount: string | bigint): bigint { + return (parseBaseUnits(dstAmount) * PROTOCOL_FEE_BPS) / BPS_DENOMINATOR; +} + +/** + * Derive the integer variance scale (out of {@link VARIANCE_SCALE}) applied to + * a quote's destination amount, given a solver's `[0, 1]` performance score. + * + * A perfect score (`1`) yields no haircut (`VARIANCE_SCALE`); a zero score + * yields the maximum 0.8 % haircut. `perfScore` is clamped to `[0, 1]`. + */ +export function varianceScaleFromPerfScore(perfScore: number): number { + const clamped = Math.min(Math.max(perfScore, 0), 1); + const variancePct = (1 - clamped) * 0.008; + return Math.round(Number(VARIANCE_SCALE) * (1 - variancePct)); +} + +/** + * Apply an integer variance scale (see {@link varianceScaleFromPerfScore}) to a + * source amount, entirely in `BigInt`: + * `dstAmount = srcAmount * varianceScale / VARIANCE_SCALE`. + * + * @param srcAmount Source amount in base units. + * @param varianceScale Integer scale, typically `0 … VARIANCE_SCALE`. + */ +export function applyVarianceScale( + srcAmount: string | bigint, + varianceScale: number, +): bigint { + return (parseBaseUnits(srcAmount) * BigInt(varianceScale)) / VARIANCE_SCALE; +} diff --git a/src/common/stellar-signature.ts b/src/common/stellar-signature.ts index c1313e8..29b4330 100644 --- a/src/common/stellar-signature.ts +++ b/src/common/stellar-signature.ts @@ -73,3 +73,15 @@ export function buildRegisterMessage(address: string): string { export function buildSolverStatusMessage(action: "deactivate" | "reactivate" | "deregister", address: string): string { return `${action}:${address}`; } + +/** + * Build the canonical message that a solver must sign to update their mutable + * profile fields (name / supportedChains / supportedTokens / avgFillTime). + * + * Signing over just the address is sufficient here: it proves control of the + * account whose profile is being edited, and the request body is already + * constrained by the DTO whitelist so no immutable field can ride along. + */ +export function buildUpdateSolverMessage(address: string): string { + return `update-solver:${address}`; +} diff --git a/src/generated/api-types.ts b/src/generated/api-types.ts index 8287540..b96dc7f 100644 --- a/src/generated/api-types.ts +++ b/src/generated/api-types.ts @@ -188,6 +188,42 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/intents/{id}/audit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 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. + */ + get: operations["IntentsController_getAudit"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/intents/{id}/quote": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["IntentsController_getPersistedQuote"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/intents/{id}/accept": { parameters: { query?: never; @@ -281,7 +317,7 @@ export interface paths { delete?: never; options?: never; head?: never; - patch?: never; + patch: operations["SolversController_updateSolver"]; trace?: never; }; "/api/v1/solvers/{address}/stats": { @@ -384,6 +420,21 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + StellarTokenDto: { + /** @description Stellar contract ID of the token */ + contract: string; + /** @example XLM */ + symbol: string; + /** @example Stellar Lumens */ + name: string; + /** @example 7 */ + decimals: number; + /** @example 0.1182 */ + priceUSD: number; + }; + StellarTokensResponseDto: { + tokens: components["schemas"]["StellarTokenDto"][]; + }; CreateIntentDto: { /** @description Stellar address of the user creating the intent */ user: string; @@ -449,6 +500,36 @@ export interface components { dstTokenSymbol: string; /** @description Intent ID to persist the quote to */ intentId?: string; + /** @description Source token contract address / ID (used for precise token resolution) */ + srcTokenAddress?: string; + /** @description Destination Stellar token contract ID (used for precise token resolution) */ + dstTokenContract?: string; + }; + RouteStepDto: { + /** @enum {string} */ + type: "bridge" | "swap" | "transfer"; + /** @description Protocol name, e.g. 'direct-solver', 'uniswap-v3' */ + protocol: string; + fromChain: string; + toChain: string; + /** @description Source token info for this hop */ + fromToken: Record; + /** @description Destination token info for this hop */ + toToken: Record; + /** @description Estimated execution time in seconds for this step */ + estimatedTime: number; + /** @description Estimated gas cost in the source token's base unit */ + estimatedGas: string; + }; + RouteDto: { + /** @description Ordered list of steps to execute the swap */ + steps: components["schemas"]["RouteStepDto"][]; + /** @description Total estimated time for all steps in seconds */ + totalTime: number; + /** @description Total fees in USD across all steps */ + totalFeesUSD: number; + /** @description Estimated price impact as a decimal fraction, e.g. 0.003 = 0.3% */ + priceImpact: number; }; QuoteDto: { /** @description Solver address */ @@ -463,6 +544,12 @@ export interface components { fillTime: number; /** @description Unix timestamp when quote expires */ expiresAt: number; + /** @description Total fees in USD (protocol fee converted at token price) */ + totalFeesUSD: number; + /** @description Estimated price impact as a decimal fraction, e.g. 0.003 = 0.3% */ + priceImpact: number; + /** @description Computed execution route (direct single-step or multi-hop via USDC intermediate) */ + route: components["schemas"]["RouteDto"]; }; QuoteResponseDto: { /** @description Array of quotes sorted by best dstAmount first */ @@ -479,6 +566,10 @@ export interface components { dstTokenSymbol: string; /** @description Estimated fill time in seconds for the best quote */ estimatedFillTime: number; + /** @description Total fees in USD for the best quote (0 when no quote available) */ + totalFeesUSD: number; + /** @description Price impact for the best quote as a decimal fraction (0 when no quote available) */ + priceImpact: number; }; RegisterSolverDto: { /** @description Solver's Stellar address */ @@ -493,6 +584,20 @@ export interface components { supportedChains: ("stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche")[]; /** @description Token symbols this solver supports */ supportedTokens: unknown[][]; + /** @description Proof-of-control signature for the advertised solver address */ + proofSignature: string; + }; + UpdateSolverDto: { + /** @description New display name */ + name?: string; + /** @description Replacement list of chains this solver supports */ + supportedChains?: ("stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche")[]; + /** @description Replacement list of supported token symbols */ + supportedTokens?: unknown[][]; + /** @description Updated average fill time in seconds */ + avgFillTime?: number; + /** @description Base64-encoded Ed25519 signature of the message "update-solver:
" produced by the solver's private key, proving control of :address */ + signature: string; }; }; responses: never; @@ -529,11 +634,20 @@ export interface operations { }; requestBody?: never; responses: { + /** @description Soroban RPC node health status (pass-through of the RPC `getHealth` result). */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + /** @example healthy */ + status: string; + latestLedger?: number; + oldestLedger?: number; + ledgerRetentionWindow?: number; + }; + }; }; }; }; @@ -546,11 +660,19 @@ export interface operations { }; requestBody?: never; responses: { + /** @description Latest closed ledger as reported by the Soroban RPC node. */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + id: string; + /** @example 12345678 */ + sequence: number; + protocolVersion?: number; + }; + }; }; }; }; @@ -563,11 +685,19 @@ export interface operations { }; requestBody?: never; responses: { + /** @description Network passphrase and protocol metadata for the configured RPC node. */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + friendbotUrl?: string | null; + /** @example Test SDF Network ; September 2015 */ + passphrase: string; + protocolVersion?: number; + }; + }; }; }; }; @@ -576,13 +706,39 @@ export interface operations { query?: never; header?: never; path: { + /** @description Stellar Ed25519 account public key (starts with `G`, 56 characters). */ publicKey: string; }; cookie?: never; }; requestBody?: never; responses: { + /** @description On-chain account record (id, sequence number, and balances). */ 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** @example 987654321 */ + sequence: string; + balances?: { + balance?: string; + asset_type?: string; + }[]; + }; + }; + }; + /** @description Invalid Stellar public key format */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Per-account rate limit exceeded (AccountRateLimitGuard) */ + 429: { headers: { [name: string]: unknown; }; @@ -592,8 +748,9 @@ export interface operations { }; TokensController_getTokens: { parameters: { - query: { - chain: string; + query?: { + /** @description Restrict the result to a single chain (e.g. `stellar`, `ethereum`, `base`). When omitted, every supported chain plus the Stellar token list is returned. */ + chain?: string; }; header?: never; path?: never; @@ -601,11 +758,54 @@ export interface operations { }; requestBody?: never; responses: { + /** @description Supported tokens. With `chain` set, `{ tokens: Token[], chain }`; without it, `tokens` is keyed by chain and `stellarTokens` holds the Stellar list. */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + tokens: { + address?: string; + contract?: string; + /** @example USDC */ + symbol: string; + /** @example USD Coin */ + name: string; + /** @example 6 */ + decimals: number; + /** @example 1 */ + priceUSD: number; + }[]; + /** @example ethereum */ + chain: string; + } | { + tokens: { + [key: string]: { + address: string; + /** @example USDC */ + symbol: string; + /** @example USD Coin */ + name: string; + /** @example 6 */ + decimals: number; + /** @example 1 */ + priceUSD: number; + }[]; + }; + stellarTokens: { + contract: string; + /** @example XLM */ + symbol: string; + /** @example Stellar Lumens */ + name: string; + /** @example 7 */ + decimals: number; + /** @example 0.1182 */ + priceUSD: number; + }[]; + }; + }; }; }; }; @@ -618,11 +818,14 @@ export interface operations { }; requestBody?: never; responses: { + /** @description The full list of supported Stellar destination tokens. */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["StellarTokensResponseDto"]; + }; }; }; }; @@ -637,6 +840,8 @@ export interface operations { chain?: string; /** @description Number of results per page */ limit: number; + /** @description Cursor for the next page of intents */ + cursor?: string; /** @description Number of results to skip */ offset: number; }; @@ -740,6 +945,72 @@ export interface operations { }; }; }; + IntentsController_getAudit: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Audit trail for the intent */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + intentId?: string; + entries?: { + /** Format: date-time */ + timestamp?: string; + toState?: string; + actor?: string; + reason?: string; + metadata?: Record | null; + }[]; + }; + }; + }; + /** @description Intent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_getPersistedQuote: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Persisted quote for the intent */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent not found or no quote persisted */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; IntentsController_accept: { parameters: { query?: never; @@ -896,7 +1167,7 @@ export interface operations { "application/json": components["schemas"]["QuoteResponseDto"]; }; }; - /** @description Rate limit exceeded — max 100 req/min per IP globally */ + /** @description Rate limit exceeded — max 20 quote requests per 60 s per IP */ 429: { headers: { [name: string]: unknown; @@ -962,6 +1233,51 @@ export interface operations { }; }; }; + SolversController_updateSolver: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateSolverDto"]; + }; + }; + responses: { + /** @description Updated solver record */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid update body */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid signature */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Solver not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; SolversController_getSolverStats: { parameters: { query?: never; diff --git a/src/generated/openapi.json b/src/generated/openapi.json index a6d4f5d..d8f58d0 100644 --- a/src/generated/openapi.json +++ b/src/generated/openapi.json @@ -21,7 +21,32 @@ "parameters": [], "responses": { "200": { - "description": "" + "description": "Soroban RPC node health status (pass-through of the RPC `getHealth` result).", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "healthy" + }, + "latestLedger": { + "type": "number" + }, + "oldestLedger": { + "type": "number" + }, + "ledgerRetentionWindow": { + "type": "number" + } + }, + "required": [ + "status" + ] + } + } + } } }, "tags": [ @@ -35,7 +60,30 @@ "parameters": [], "responses": { "200": { - "description": "" + "description": "Latest closed ledger as reported by the Soroban RPC node.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sequence": { + "type": "number", + "example": 12345678 + }, + "protocolVersion": { + "type": "number" + } + }, + "required": [ + "id", + "sequence" + ] + } + } + } } }, "tags": [ @@ -49,7 +97,30 @@ "parameters": [], "responses": { "200": { - "description": "" + "description": "Network passphrase and protocol metadata for the configured RPC node.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "friendbotUrl": { + "type": "string", + "nullable": true + }, + "passphrase": { + "type": "string", + "example": "Test SDF Network ; September 2015" + }, + "protocolVersion": { + "type": "number" + } + }, + "required": [ + "passphrase" + ] + } + } + } } }, "tags": [ @@ -65,14 +136,56 @@ "name": "publicKey", "required": true, "in": "path", + "description": "Stellar Ed25519 account public key (starts with `G`, 56 characters).", "schema": { + "example": "GABC1234567890TESTPUBLICKEY000000000000000000000000000000", "type": "string" } } ], "responses": { "200": { - "description": "" + "description": "On-chain account record (id, sequence number, and balances).", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sequence": { + "type": "string", + "example": "987654321" + }, + "balances": { + "type": "array", + "items": { + "type": "object", + "properties": { + "balance": { + "type": "string" + }, + "asset_type": { + "type": "string" + } + } + } + } + }, + "required": [ + "id", + "sequence" + ] + } + } + } + }, + "400": { + "description": "Invalid Stellar public key format" + }, + "429": { + "description": "Per-account rate limit exceeded (AccountRateLimitGuard)" } }, "tags": [ @@ -86,8 +199,9 @@ "parameters": [ { "name": "chain", - "required": true, + "required": false, "in": "query", + "description": "Restrict the result to a single chain (e.g. `stellar`, `ethereum`, `base`). When omitted, every supported chain plus the Stellar token list is returned.", "schema": { "type": "string" } @@ -95,7 +209,144 @@ ], "responses": { "200": { - "description": "" + "description": "Supported tokens. With `chain` set, `{ tokens: Token[], chain }`; without it, `tokens` is keyed by chain and `stellarTokens` holds the Stellar list.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "contract": { + "type": "string" + }, + "symbol": { + "type": "string", + "example": "USDC" + }, + "name": { + "type": "string", + "example": "USD Coin" + }, + "decimals": { + "type": "number", + "example": 6 + }, + "priceUSD": { + "type": "number", + "example": 1 + } + }, + "required": [ + "symbol", + "name", + "decimals", + "priceUSD" + ] + } + }, + "chain": { + "type": "string", + "example": "ethereum" + } + }, + "required": [ + "tokens", + "chain" + ] + }, + { + "type": "object", + "properties": { + "tokens": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "symbol": { + "type": "string", + "example": "USDC" + }, + "name": { + "type": "string", + "example": "USD Coin" + }, + "decimals": { + "type": "number", + "example": 6 + }, + "priceUSD": { + "type": "number", + "example": 1 + } + }, + "required": [ + "address", + "symbol", + "name", + "decimals", + "priceUSD" + ] + } + } + }, + "stellarTokens": { + "type": "array", + "items": { + "type": "object", + "properties": { + "contract": { + "type": "string" + }, + "symbol": { + "type": "string", + "example": "XLM" + }, + "name": { + "type": "string", + "example": "Stellar Lumens" + }, + "decimals": { + "type": "number", + "example": 7 + }, + "priceUSD": { + "type": "number", + "example": 0.1182 + } + }, + "required": [ + "contract", + "symbol", + "name", + "decimals", + "priceUSD" + ] + } + } + }, + "required": [ + "tokens", + "stellarTokens" + ] + } + ] + } + } + } } }, "tags": [ @@ -109,7 +360,14 @@ "parameters": [], "responses": { "200": { - "description": "" + "description": "The full list of supported Stellar destination tokens.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StellarTokensResponseDto" + } + } + } } }, "tags": [ @@ -160,6 +418,15 @@ "type": "number" } }, + { + "name": "cursor", + "required": false, + "in": "query", + "description": "Cursor for the next page of intents", + "schema": { + "type": "string" + } + }, { "name": "offset", "required": true, @@ -267,6 +534,97 @@ ] } }, + "/api/v1/intents/{id}/audit": { + "get": { + "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.", + "operationId": "IntentsController_getAudit", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Audit trail for the intent", + "content": { + "application/json": { + "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 + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Intent not found" + } + }, + "summary": "Get audit trail for an intent", + "tags": [ + "intents" + ] + } + }, + "/api/v1/intents/{id}/quote": { + "get": { + "operationId": "IntentsController_getPersistedQuote", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Persisted quote for the intent" + }, + "404": { + "description": "Intent not found or no quote persisted" + } + }, + "tags": [ + "intents" + ] + } + }, "/api/v1/intents/{id}/accept": { "post": { "operationId": "IntentsController_accept", @@ -419,7 +777,7 @@ } }, "429": { - "description": "Rate limit exceeded — max 100 req/min per IP globally" + "description": "Rate limit exceeded — max 20 quote requests per 60 s per IP" } }, "tags": [ @@ -484,6 +842,46 @@ "tags": [ "solvers" ] + }, + "patch": { + "operationId": "SolversController_updateSolver", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSolverDto" + } + } + } + }, + "responses": { + "200": { + "description": "Updated solver record" + }, + "400": { + "description": "Invalid update body" + }, + "401": { + "description": "Missing or invalid signature" + }, + "404": { + "description": "Solver not found" + } + }, + "tags": [ + "solvers" + ] } }, "/api/v1/solvers/{address}/stats": { @@ -647,6 +1045,52 @@ ], "components": { "schemas": { + "StellarTokenDto": { + "type": "object", + "properties": { + "contract": { + "type": "string", + "description": "Stellar contract ID of the token" + }, + "symbol": { + "type": "string", + "example": "XLM" + }, + "name": { + "type": "string", + "example": "Stellar Lumens" + }, + "decimals": { + "type": "number", + "example": 7 + }, + "priceUSD": { + "type": "number", + "example": 0.1182 + } + }, + "required": [ + "contract", + "symbol", + "name", + "decimals", + "priceUSD" + ] + }, + "StellarTokensResponseDto": { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StellarTokenDto" + } + } + }, + "required": [ + "tokens" + ] + }, "CreateIntentDto": { "type": "object", "properties": { @@ -816,6 +1260,14 @@ "intentId": { "type": "string", "description": "Intent ID to persist the quote to" + }, + "srcTokenAddress": { + "type": "string", + "description": "Source token contract address / ID (used for precise token resolution)" + }, + "dstTokenContract": { + "type": "string", + "description": "Destination Stellar token contract ID (used for precise token resolution)" } }, "required": [ @@ -825,6 +1277,85 @@ "dstTokenSymbol" ] }, + "RouteStepDto": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "bridge", + "swap", + "transfer" + ] + }, + "protocol": { + "type": "string", + "description": "Protocol name, e.g. 'direct-solver', 'uniswap-v3'" + }, + "fromChain": { + "type": "string" + }, + "toChain": { + "type": "string" + }, + "fromToken": { + "type": "object", + "description": "Source token info for this hop" + }, + "toToken": { + "type": "object", + "description": "Destination token info for this hop" + }, + "estimatedTime": { + "type": "number", + "description": "Estimated execution time in seconds for this step" + }, + "estimatedGas": { + "type": "string", + "description": "Estimated gas cost in the source token's base unit" + } + }, + "required": [ + "type", + "protocol", + "fromChain", + "toChain", + "fromToken", + "toToken", + "estimatedTime", + "estimatedGas" + ] + }, + "RouteDto": { + "type": "object", + "properties": { + "steps": { + "description": "Ordered list of steps to execute the swap", + "type": "array", + "items": { + "$ref": "#/components/schemas/RouteStepDto" + } + }, + "totalTime": { + "type": "number", + "description": "Total estimated time for all steps in seconds" + }, + "totalFeesUSD": { + "type": "number", + "description": "Total fees in USD across all steps" + }, + "priceImpact": { + "type": "number", + "description": "Estimated price impact as a decimal fraction, e.g. 0.003 = 0.3%" + } + }, + "required": [ + "steps", + "totalTime", + "totalFeesUSD", + "priceImpact" + ] + }, "QuoteDto": { "type": "object", "properties": { @@ -851,6 +1382,22 @@ "expiresAt": { "type": "number", "description": "Unix timestamp when quote expires" + }, + "totalFeesUSD": { + "type": "number", + "description": "Total fees in USD (protocol fee converted at token price)" + }, + "priceImpact": { + "type": "number", + "description": "Estimated price impact as a decimal fraction, e.g. 0.003 = 0.3%" + }, + "route": { + "description": "Computed execution route (direct single-step or multi-hop via USDC intermediate)", + "allOf": [ + { + "$ref": "#/components/schemas/RouteDto" + } + ] } }, "required": [ @@ -859,7 +1406,10 @@ "dstAmount", "fee", "fillTime", - "expiresAt" + "expiresAt", + "totalFeesUSD", + "priceImpact", + "route" ] }, "QuoteResponseDto": { @@ -901,6 +1451,14 @@ "estimatedFillTime": { "type": "number", "description": "Estimated fill time in seconds for the best quote" + }, + "totalFeesUSD": { + "type": "number", + "description": "Total fees in USD for the best quote (0 when no quote available)" + }, + "priceImpact": { + "type": "number", + "description": "Price impact for the best quote as a decimal fraction (0 when no quote available)" } }, "required": [ @@ -910,7 +1468,9 @@ "srcTokenSymbol", "srcAmount", "dstTokenSymbol", - "estimatedFillTime" + "estimatedFillTime", + "totalFeesUSD", + "priceImpact" ] }, "RegisterSolverDto": { @@ -954,6 +1514,10 @@ "items": { "type": "array" } + }, + "proofSignature": { + "type": "string", + "description": "Proof-of-control signature for the advertised solver address" } }, "required": [ @@ -962,7 +1526,51 @@ "bondAmount", "avgFillTime", "supportedChains", - "supportedTokens" + "supportedTokens", + "proofSignature" + ] + }, + "UpdateSolverDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "New display name" + }, + "supportedChains": { + "type": "array", + "description": "Replacement list of chains this solver supports", + "items": { + "type": "string", + "enum": [ + "stellar", + "ethereum", + "base", + "polygon", + "arbitrum", + "optimism", + "avalanche" + ] + } + }, + "supportedTokens": { + "description": "Replacement list of supported token symbols", + "type": "array", + "items": { + "type": "array" + } + }, + "avgFillTime": { + "type": "number", + "description": "Updated average fill time in seconds" + }, + "signature": { + "type": "string", + "description": "Base64-encoded Ed25519 signature of the message \"update-solver:
\" produced by the solver's private key, proving control of :address" + } + }, + "required": [ + "signature" ] } } diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index c072a02..b83872d 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -23,6 +23,7 @@ import { ApiTooManyRequestsResponse, ApiOperation, } from "@nestjs/swagger"; +import { Throttle } from "@nestjs/throttler"; import { IntentsService } from "./intents.service"; import { IntentsGateway } from "./intents.gateway"; import { SolversService } from "../solvers/solvers.service"; @@ -42,6 +43,13 @@ import { buildCancelMessage, buildFillMessage, } from "../common/stellar-signature"; +import { + applyVarianceScale, + calculateProtocolFee, + parseBaseUnits, + toDecimalNumber, + varianceScaleFromPerfScore, +} from "../common/amount"; import { SupportedChain } from "./intents.types"; @ApiTags("intents") @@ -261,7 +269,7 @@ export class IntentsController { // Verify the solver controls the claimed address verifyStellarSignature(dto.solver, buildFillMessage(id, dto.solver), dto.signature); - const fillAmount = BigInt(dto.fillAmount); + const fillAmount = parseBaseUnits(dto.fillAmount); let minAmount: bigint; try { minAmount = BigInt(intent.minDstAmount); @@ -338,8 +346,8 @@ export class IntentsController { description: "Rate limit exceeded — max 20 quote requests per 60 s per IP", }) @ApiOkResponse({ type: QuoteResponseDto }) - quote(@Body() dto: QuoteRequestDto): QuoteResponseDto { - const solvers = this.solversService.getAll().filter((s) => s.isActive); + 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 const srcToken = this.tokensService.resolveSrcToken( @@ -348,7 +356,7 @@ export class IntentsController { ); const dstToken = this.tokensService.resolveDstToken(dto.dstTokenContract ?? ""); - const srcAmountBigInt = BigInt(dto.srcAmount); + 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 @@ -361,17 +369,16 @@ export class IntentsController { 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); // 0.05% + 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 = Number(fee) / Math.pow(10, (dstToken as any)?.decimals ?? 7); + const feeUnits = toDecimalNumber(fee, (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 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)) diff --git a/src/intents/intents.service.idempotency.spec.ts b/src/intents/intents.service.idempotency.spec.ts new file mode 100644 index 0000000..3bf04fe --- /dev/null +++ b/src/intents/intents.service.idempotency.spec.ts @@ -0,0 +1,194 @@ +import { ConfigService } from "@nestjs/config"; +import { IntentsService } from "./intents.service"; +import { IIntentsRepository } from "./intents.repository"; +import { Intent } from "./intents.types"; +import { AppConfig } from "../config/configuration"; +import { StellarTxService } from "../soroban/stellar-tx.service"; +import { PrismaService } from "../prisma/prisma.service"; + +/** + * Issue #274 — the idempotency-key path in IntentsService.create() must be + * race-safe: N concurrent requests carrying the same key produce exactly one + * intent, and the losers receive the winner's result. + */ + +type CreateData = Omit; + +const baseData: CreateData = { + user: "GUSERADDRESS000000000000000000000000000000000000000000000", + srcChain: "ethereum", + srcToken: { + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + symbol: "USDC", + name: "USD Coin", + decimals: 6, + chain: "ethereum", + }, + srcAmount: "1000000", + dstToken: { + contract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + symbol: "USDC", + decimals: 7, + }, + minDstAmount: "990000", + deadline: Math.floor(Date.now() / 1000) + 3600, +}; + +/** Minimal repository double with a tunable write delay to widen the race window. */ +class FakeIntentsRepository { + readonly store = new Map(); + saveCalls = 0; + saveDelayMs = 0; + failNextSave = false; + + async save(intent: Intent): Promise { + this.saveCalls += 1; + if (this.failNextSave) { + this.failNextSave = false; + throw new Error("simulated persistence failure"); + } + if (this.saveDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, this.saveDelayMs)); + } + this.store.set(intent.intentId, intent); + return intent; + } + + async findById(id: string): Promise { + return this.store.get(id); + } +} + +interface Harness { + service: IntentsService; + repo: FakeIntentsRepository; +} + +function buildService(onchain = false): Harness { + const repo = new FakeIntentsRepository(); + + const config = { + get: (key: string) => { + if (key === "onchainIntentsEnabled") return onchain; + if (key === "stellar.settlementContractId") return "CONTRACT"; + return undefined; + }, + } as unknown as ConfigService; + + const stellarTx = {} as unknown as StellarTxService; + const prisma = {} as unknown as PrismaService; + + const service = new IntentsService( + repo as unknown as IIntentsRepository, + config, + stellarTx, + prisma, + ); + + return { service, repo }; +} + +describe("IntentsService.create — idempotency race safety (#274)", () => { + let harness: Harness; + + afterEach(() => { + harness?.service.onModuleDestroy(); + jest.restoreAllMocks(); + }); + + it("creates exactly one intent for N concurrent calls with the same key", async () => { + harness = buildService(); + harness.repo.saveDelayMs = 15; // widen the check-then-act window + + const results = await Promise.all( + Array.from({ length: 25 }, () => harness.service.create(baseData, "same-key")), + ); + + expect(harness.repo.saveCalls).toBe(1); + expect(harness.repo.store.size).toBe(1); + expect(new Set(results.map((r) => r.intentId)).size).toBe(1); + }); + + it("returns the first result to every racing caller", async () => { + harness = buildService(); + harness.repo.saveDelayMs = 10; + + const [first, ...rest] = await Promise.all( + Array.from({ length: 10 }, () => harness.service.create(baseData, "key-a")), + ); + + for (const other of rest) { + expect(other).toEqual(first); + } + }); + + it("replays the cached intent for a sequential repeat of the same key", async () => { + harness = buildService(); + + const first = await harness.service.create(baseData, "key-b"); + const second = await harness.service.create(baseData, "key-b"); + + expect(second.intentId).toBe(first.intentId); + expect(harness.repo.saveCalls).toBe(1); + }); + + it("does not deduplicate calls that omit an idempotency key", async () => { + harness = buildService(); + harness.repo.saveDelayMs = 10; + + const results = await Promise.all( + Array.from({ length: 5 }, () => harness.service.create(baseData)), + ); + + expect(harness.repo.saveCalls).toBe(5); + expect(new Set(results.map((r) => r.intentId)).size).toBe(5); + }); + + it("keeps distinct keys independent", async () => { + harness = buildService(); + harness.repo.saveDelayMs = 10; + + await Promise.all([ + harness.service.create(baseData, "key-1"), + harness.service.create(baseData, "key-2"), + harness.service.create(baseData, "key-1"), + harness.service.create(baseData, "key-2"), + ]); + + expect(harness.repo.saveCalls).toBe(2); + }); + + it("releases the in-flight claim on failure so a later retry succeeds", async () => { + harness = buildService(); + harness.repo.failNextSave = true; + + await expect(harness.service.create(baseData, "key-retry")).rejects.toThrow( + "simulated persistence failure", + ); + + const intent = await harness.service.create(baseData, "key-retry"); + expect(intent.intentId).toBeDefined(); + expect(harness.repo.store.size).toBe(1); + }); + + it("claims the key before the on-chain registration await", async () => { + harness = buildService(true); + const registerSpy = jest + .spyOn( + harness.service as unknown as { registerOnChain: (intent: Intent) => Promise }, + "registerOnChain", + ) + .mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + + await Promise.all( + Array.from({ length: 8 }, () => harness.service.create(baseData, "key-onchain")), + ); + + // If the claim were taken after registerOnChain()'s await, several racers + // would each reach the on-chain call before the first cache write landed. + expect(registerSpy).toHaveBeenCalledTimes(1); + expect(harness.repo.saveCalls).toBe(1); + }); +}); diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index e569332..13adc67 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -13,9 +13,13 @@ import { AppConfig } from "../config/configuration"; import { CHAIN_DEADLINE_DEFAULTS, DEFAULT_DEADLINE_SECONDS } from "../config/configuration"; import { StellarTxService } from "../soroban/stellar-tx.service"; import { PrismaService } from "../prisma/prisma.service"; +import { INTENTS_REPOSITORY, IIntentsRepository } from "./intents.repository"; const STORE_SIZE_LOG_INTERVAL_MS = 60_000; +/** How long a completed idempotency-key result stays replayable. */ +const IDEMPOTENCY_TTL_SECONDS = 86_400; // 24 hours + /** * Orchestration layer for intents. * @@ -35,6 +39,14 @@ export class IntentsService implements OnModuleDestroy { */ 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 @@ -71,25 +83,72 @@ export class IntentsService implements OnModuleDestroy { data: Omit, idempotencyKey?: string, ): Promise { + if (!idempotencyKey) { + return this.persistNewIntent(data); + } + const now = Math.floor(Date.now() / 1000); - if (idempotencyKey) { - const cached = this.idempotencyCache.get(idempotencyKey); - if (cached && cached.expiresAt > now) { - const cachedIntent = await this.repo.findById(cached.intentId); - if (cachedIntent) { - return cachedIntent; - } + // 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); + const intent: Intent = { ...data, intentId: uuidv4(), state: "open", createdAt: now, - deadline: data.deadline ?? now + (CHAIN_DEADLINE_DEFAULTS[data.srcChain] ?? DEFAULT_DEADLINE_SECONDS), + deadline: + data.deadline ?? now + (CHAIN_DEADLINE_DEFAULTS[data.srcChain] ?? DEFAULT_DEADLINE_SECONDS), }; if (this.configService.get("onchainIntentsEnabled", { infer: true })) { @@ -97,15 +156,6 @@ export class IntentsService implements OnModuleDestroy { } await this.repo.save(intent); - - if (idempotencyKey) { - const ttl = 86400; // 24 hours - this.idempotencyCache.set(idempotencyKey, { - intentId: intent.intentId, - expiresAt: now + ttl, - }); - } - return intent; } diff --git a/src/solvers/dto/update-solver.dto.ts b/src/solvers/dto/update-solver.dto.ts new file mode 100644 index 0000000..4680cc3 --- /dev/null +++ b/src/solvers/dto/update-solver.dto.ts @@ -0,0 +1,72 @@ +import { + ArrayMaxSize, + IsArray, + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Min, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { SupportedChain } from "../../intents/intents.types"; + +const SUPPORTED_CHAINS: SupportedChain[] = [ + "stellar", + "ethereum", + "base", + "polygon", + "arbitrum", + "optimism", + "avalanche", +]; + +/** + * Partial update to a solver's *mutable* profile fields (issue #273). + * + * Only `name`, `supportedChains`, `supportedTokens` and `avgFillTime` may be + * changed. Immutable fields (`address`, `bondAmount`, `fillsCompleted`, + * `fillsFailed`, `totalVolume`, `registeredAt`, `isActive`) are not declared + * here, so the global `ValidationPipe({ whitelist: true })` strips them from + * the request body before this DTO is ever handed to the controller. + */ +export class UpdateSolverDto { + @ApiPropertyOptional({ description: "New display name" }) + @IsOptional() + @IsString() + @IsNotEmpty() + name?: string; + + @ApiPropertyOptional({ + enum: SUPPORTED_CHAINS, + isArray: true, + description: "Replacement list of chains this solver supports", + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(8) + @IsIn(SUPPORTED_CHAINS, { each: true }) + supportedChains?: SupportedChain[]; + + @ApiPropertyOptional({ isArray: true, description: "Replacement list of supported token symbols" }) + @IsOptional() + @IsArray() + @ArrayMaxSize(32) + @IsString({ each: true }) + supportedTokens?: string[]; + + @ApiPropertyOptional({ description: "Updated average fill time in seconds" }) + @IsOptional() + @IsInt() + @Min(0) + avgFillTime?: number; + + @ApiProperty({ + description: + 'Base64-encoded Ed25519 signature of the message "update-solver:
" ' + + "produced by the solver's private key, proving control of :address", + }) + @IsString() + @IsNotEmpty() + signature!: string; +} diff --git a/src/solvers/solvers.controller.ts b/src/solvers/solvers.controller.ts index 7292ba1..a527ddf 100644 --- a/src/solvers/solvers.controller.ts +++ b/src/solvers/solvers.controller.ts @@ -4,13 +4,25 @@ import { Get, NotFoundException, Param, + Patch, Post, } from "@nestjs/common"; -import { ApiTags } from "@nestjs/swagger"; +import { + ApiTags, + ApiOkResponse, + ApiNotFoundResponse, + ApiBadRequestResponse, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; import { SolversService } from "./solvers.service"; import { RegisterSolverDto } from "./dto/register-solver.dto"; import { UpdateSolverStatusDto } from "./dto/update-solver-status.dto"; -import { verifyStellarSignature, buildSolverStatusMessage } from "../common/stellar-signature"; +import { UpdateSolverDto } from "./dto/update-solver.dto"; +import { + verifyStellarSignature, + buildSolverStatusMessage, + buildUpdateSolverMessage, +} from "../common/stellar-signature"; @ApiTags("solvers") @Controller("api/v1/solvers") @@ -94,4 +106,27 @@ export class SolversController { if (!solver) throw new NotFoundException("Solver not found"); return solver; } + + /** + * PATCH /api/v1/solvers/:address + * + * Issue #273 — lets a solver operator edit their mutable profile fields + * (`name`, `supportedChains`, `supportedTokens`, `avgFillTime`) as they scale + * liquidity. Signature-verified per the repo's `verifyStellarSignature` + * convention (issue #19/#21): the operator proves control of `:address` + * before any write. Immutable fields are stripped by the DTO whitelist. + */ + @Patch(":address") + @ApiOkResponse({ description: "Updated solver record" }) + @ApiBadRequestResponse({ description: "Invalid update body" }) + @ApiUnauthorizedResponse({ description: "Missing or invalid signature" }) + @ApiNotFoundResponse({ description: "Solver not found" }) + async updateSolver(@Param("address") address: string, @Body() dto: UpdateSolverDto) { + verifyStellarSignature(address, buildUpdateSolverMessage(address), dto.signature); + + const { signature: _signature, ...patch } = dto; + const solver = await this.solversService.update(address, patch); + if (!solver) throw new NotFoundException("Solver not found"); + return solver; + } } diff --git a/src/solvers/solvers.service.ts b/src/solvers/solvers.service.ts index 4c40181..ace5410 100644 --- a/src/solvers/solvers.service.ts +++ b/src/solvers/solvers.service.ts @@ -72,7 +72,29 @@ export class SolversService { async reactivate(address: string): Promise { const solver = await this.repo.findByAddress(address); if (!solver) return null; - const updated = { ...solver, isActive }; + const updated = { ...solver, isActive: true }; + return this.repo.save(updated); + } + + /** + * Apply a partial update to a solver's mutable profile fields + * (`name`, `supportedChains`, `supportedTokens`, `avgFillTime`) — issue #273. + * + * `undefined` values in `patch` are ignored so an absent field never clears + * existing data. Returns `undefined` when no solver exists for `address`. + */ + async update( + address: string, + patch: Partial>, + ): Promise { + const solver = await this.repo.findByAddress(address); + if (!solver) return undefined; + + const applied = Object.fromEntries( + Object.entries(patch).filter(([, value]) => value !== undefined), + ) as Partial; + + const updated: SolverRecord = { ...solver, ...applied }; return this.repo.save(updated); } diff --git a/src/solvers/solvers.service.update.spec.ts b/src/solvers/solvers.service.update.spec.ts new file mode 100644 index 0000000..41ddc1b --- /dev/null +++ b/src/solvers/solvers.service.update.spec.ts @@ -0,0 +1,69 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { SolversService } from "./solvers.service"; +import { InMemorySolversRepository } from "./in-memory-solvers.repository"; +import { SOLVERS_REPOSITORY } from "./solvers.repository"; +import { SEED_SOLVER_KEYPAIRS } from "./solvers.seed"; + +const ALPHA_ADDR = SEED_SOLVER_KEYPAIRS.ALPHA.publicKey(); + +/** + * Issue #273 — SolversService.update() applies a partial patch to the mutable + * profile fields only. + */ +describe("SolversService.update", () => { + let service: SolversService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { provide: SOLVERS_REPOSITORY, useClass: InMemorySolversRepository }, + SolversService, + ], + }).compile(); + service = module.get(SolversService); + }); + + it("updates only the fields present in the patch", async () => { + const before = await service.get(ALPHA_ADDR); + const updated = await service.update(ALPHA_ADDR, { name: "Alpha MM v2" }); + + expect(updated?.name).toBe("Alpha MM v2"); + expect(updated?.supportedChains).toEqual(before?.supportedChains); + expect(updated?.avgFillTime).toBe(before?.avgFillTime); + }); + + it("replaces array fields wholesale", async () => { + const updated = await service.update(ALPHA_ADDR, { + supportedChains: ["stellar", "base"], + supportedTokens: ["XLM", "USDC"], + }); + expect(updated?.supportedChains).toEqual(["stellar", "base"]); + expect(updated?.supportedTokens).toEqual(["XLM", "USDC"]); + }); + + it("ignores undefined values instead of clearing existing data", async () => { + const before = await service.get(ALPHA_ADDR); + const updated = await service.update(ALPHA_ADDR, { + name: undefined, + avgFillTime: 42, + }); + expect(updated?.name).toBe(before?.name); + expect(updated?.avgFillTime).toBe(42); + }); + + it("never touches immutable fields", async () => { + const before = await service.get(ALPHA_ADDR); + const updated = await service.update(ALPHA_ADDR, { name: "x" }); + expect(updated?.address).toBe(before?.address); + expect(updated?.bondAmount).toBe(before?.bondAmount); + expect(updated?.fillsCompleted).toBe(before?.fillsCompleted); + expect(updated?.fillsFailed).toBe(before?.fillsFailed); + expect(updated?.totalVolume).toBe(before?.totalVolume); + expect(updated?.registeredAt).toBe(before?.registeredAt); + expect(updated?.isActive).toBe(before?.isActive); + }); + + it("returns undefined for an unknown address", async () => { + expect(await service.update("NOPE", { name: "ghost" })).toBeUndefined(); + }); +}); diff --git a/src/soroban/soroban.controller.ts b/src/soroban/soroban.controller.ts index fb1abca..2cb5cbb 100644 --- a/src/soroban/soroban.controller.ts +++ b/src/soroban/soroban.controller.ts @@ -1,5 +1,11 @@ import { Controller, Get, Param, BadRequestException, UseGuards } from "@nestjs/common"; -import { ApiTags } from "@nestjs/swagger"; +import { + ApiTags, + ApiOkResponse, + ApiBadRequestResponse, + ApiParam, + ApiTooManyRequestsResponse, +} from "@nestjs/swagger"; import { StrKey } from "@stellar/stellar-sdk"; import { SorobanService } from "./soroban.service"; import { AccountRateLimitGuard } from "./account-rate-limit.guard"; @@ -10,22 +16,89 @@ export class SorobanController { constructor(private readonly sorobanService: SorobanService) {} @Get("health") + @ApiOkResponse({ + description: "Soroban RPC node health status (pass-through of the RPC `getHealth` result).", + schema: { + type: "object", + properties: { + status: { type: "string", example: "healthy" }, + latestLedger: { type: "number" }, + oldestLedger: { type: "number" }, + ledgerRetentionWindow: { type: "number" }, + }, + required: ["status"], + }, + }) getHealth() { return this.sorobanService.getHealth(); } @Get("ledger") + @ApiOkResponse({ + description: "Latest closed ledger as reported by the Soroban RPC node.", + schema: { + type: "object", + properties: { + id: { type: "string" }, + sequence: { type: "number", example: 12345678 }, + protocolVersion: { type: "number" }, + }, + required: ["id", "sequence"], + }, + }) getLatestLedger() { return this.sorobanService.getLatestLedger(); } @Get("network") + @ApiOkResponse({ + description: "Network passphrase and protocol metadata for the configured RPC node.", + schema: { + type: "object", + properties: { + friendbotUrl: { type: "string", nullable: true }, + passphrase: { type: "string", example: "Test SDF Network ; September 2015" }, + protocolVersion: { type: "number" }, + }, + required: ["passphrase"], + }, + }) getNetwork() { return this.sorobanService.getNetwork(); } @Get("account/:publicKey") @UseGuards(AccountRateLimitGuard) + @ApiParam({ + name: "publicKey", + description: "Stellar Ed25519 account public key (starts with `G`, 56 characters).", + example: "GABC1234567890TESTPUBLICKEY000000000000000000000000000000", + }) + @ApiOkResponse({ + description: "On-chain account record (id, sequence number, and balances).", + schema: { + type: "object", + properties: { + id: { type: "string" }, + sequence: { type: "string", example: "987654321" }, + balances: { + type: "array", + items: { + type: "object", + properties: { + balance: { type: "string" }, + asset_type: { type: "string" }, + }, + }, + }, + }, + required: ["id", "sequence"], + }, + }) + @ApiBadRequestResponse({ description: "Invalid Stellar public key format" }) + @ApiTooManyRequestsResponse({ + description: "Per-account rate limit exceeded (AccountRateLimitGuard)", + }) getAccount(@Param("publicKey") publicKey: string) { if ( !publicKey || @@ -37,4 +110,3 @@ export class SorobanController { return this.sorobanService.getAccount(publicKey); } } - diff --git a/src/tokens/dto/token-response.dto.ts b/src/tokens/dto/token-response.dto.ts new file mode 100644 index 0000000..5a28f59 --- /dev/null +++ b/src/tokens/dto/token-response.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from "@nestjs/swagger"; + +/** + * Swagger response shapes for `TokensController` (issue #271). + * + * These mirror the objects `TokensService` already returns — they add no new + * fields and change no behaviour, they just give `/docs` a typed schema. + */ +export class StellarTokenDto { + @ApiProperty({ description: "Stellar contract ID of the token" }) + contract!: string; + + @ApiProperty({ example: "XLM" }) + symbol!: string; + + @ApiProperty({ example: "Stellar Lumens" }) + name!: string; + + @ApiProperty({ example: 7 }) + decimals!: number; + + @ApiProperty({ example: 0.1182 }) + priceUSD!: number; +} + +export class StellarTokensResponseDto { + @ApiProperty({ type: [StellarTokenDto] }) + tokens!: StellarTokenDto[]; +} diff --git a/src/tokens/tokens.controller.ts b/src/tokens/tokens.controller.ts index 442a25e..31c5e91 100644 --- a/src/tokens/tokens.controller.ts +++ b/src/tokens/tokens.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Query } from "@nestjs/common"; -import { ApiTags } from "@nestjs/swagger"; +import { ApiTags, ApiOkResponse, ApiQuery } from "@nestjs/swagger"; import { TokensService } from "./tokens.service"; +import { StellarTokensResponseDto } from "./dto/token-response.dto"; @ApiTags("tokens") @Controller("api/v1/tokens") @@ -8,11 +9,90 @@ export class TokensController { constructor(private readonly tokensService: TokensService) {} @Get() + @ApiQuery({ + name: "chain", + required: false, + description: + "Restrict the result to a single chain (e.g. `stellar`, `ethereum`, `base`). " + + "When omitted, every supported chain plus the Stellar token list is returned.", + }) + @ApiOkResponse({ + description: + "Supported tokens. With `chain` set, `{ tokens: Token[], chain }`; without it, " + + "`tokens` is keyed by chain and `stellarTokens` holds the Stellar list.", + schema: { + oneOf: [ + { + type: "object", + properties: { + tokens: { + type: "array", + items: { + type: "object", + properties: { + address: { type: "string" }, + contract: { type: "string" }, + symbol: { type: "string", example: "USDC" }, + name: { type: "string", example: "USD Coin" }, + decimals: { type: "number", example: 6 }, + priceUSD: { type: "number", example: 1 }, + }, + required: ["symbol", "name", "decimals", "priceUSD"], + }, + }, + chain: { type: "string", example: "ethereum" }, + }, + required: ["tokens", "chain"], + }, + { + type: "object", + properties: { + tokens: { + type: "object", + additionalProperties: { + type: "array", + items: { + type: "object", + properties: { + address: { type: "string" }, + symbol: { type: "string", example: "USDC" }, + name: { type: "string", example: "USD Coin" }, + decimals: { type: "number", example: 6 }, + priceUSD: { type: "number", example: 1 }, + }, + required: ["address", "symbol", "name", "decimals", "priceUSD"], + }, + }, + }, + stellarTokens: { + type: "array", + items: { + type: "object", + properties: { + contract: { type: "string" }, + symbol: { type: "string", example: "XLM" }, + name: { type: "string", example: "Stellar Lumens" }, + decimals: { type: "number", example: 7 }, + priceUSD: { type: "number", example: 0.1182 }, + }, + required: ["contract", "symbol", "name", "decimals", "priceUSD"], + }, + }, + }, + required: ["tokens", "stellarTokens"], + }, + ], + }, + }) getTokens(@Query("chain") chain?: string) { return this.tokensService.getByChain(chain); } @Get("stellar") + @ApiOkResponse({ + type: StellarTokensResponseDto, + description: "The full list of supported Stellar destination tokens.", + }) getStellarTokens() { return this.tokensService.getStellarTokens(); } diff --git a/test/load/concurrent-idempotent-create.test.ts b/test/load/concurrent-idempotent-create.test.ts new file mode 100644 index 0000000..0e2b906 --- /dev/null +++ b/test/load/concurrent-idempotent-create.test.ts @@ -0,0 +1,83 @@ +import { INestApplication } from "@nestjs/common"; +import request from "supertest"; +import { randomUUID } from "node:crypto"; +import { createTestApp } from "../utils/create-test-app"; + +/** + * Issue #274 — concurrent-retry load test for the idempotency-key path in + * IntentsService.create(). + * + * Mirrors test/load/concurrent-accept.test.ts: fire N simultaneous POST + * /api/v1/intents requests that all carry the *same* idempotencyKey and assert + * that exactly one intent is created and every response points at it. + */ + +const validCreateBody = { + user: "GRACETESTUSER1234567", + srcChain: "ethereum", + srcTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + srcTokenSymbol: "USDC", + srcTokenDecimals: 6, + srcAmount: "1000000", + dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + dstTokenSymbol: "USDC", + dstTokenDecimals: 7, + minDstAmount: "990000", +}; + +describe("Concurrent idempotent create race load test", () => { + let app: INestApplication; + + beforeAll(async () => { + app = await createTestApp(); + }); + + afterAll(async () => { + await app.close(); + }); + + it("creates exactly one intent when N concurrent create() calls share an idempotencyKey", async () => { + const idempotencyKey = randomUUID(); + const concurrency = 25; + + const results = await Promise.allSettled( + Array.from({ length: concurrency }, () => + request(app.getHttpServer()) + .post("/api/v1/intents") + .send({ ...validCreateBody, idempotencyKey }), + ), + ); + + const fulfilled = results.filter( + (r): r is PromiseFulfilledResult => r.status === "fulfilled", + ); + const created = fulfilled.filter((r) => r.value.status === 201); + + // Every accepted response must describe the same single intent. + const intentIds = new Set(created.map((r) => r.value.body.intentId)); + expect(intentIds.size).toBe(1); + + const [intentId] = [...intentIds]; + const listed = ( + await request(app.getHttpServer()).get("/api/v1/intents").expect(200) + ).body.intents as Array<{ intentId: string }>; + const matches = listed.filter((i) => i.intentId === intentId); + expect(matches).toHaveLength(1); + }); + + it("still creates distinct intents for concurrent calls with different keys", async () => { + const concurrency = 10; + + const results = await Promise.all( + Array.from({ length: concurrency }, () => + request(app.getHttpServer()) + .post("/api/v1/intents") + .send({ ...validCreateBody, idempotencyKey: randomUUID() }) + .expect(201), + ), + ); + + const intentIds = new Set(results.map((r) => r.body.intentId)); + expect(intentIds.size).toBe(concurrency); + }); +}); diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index 855a6f9..9702d82 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -88,4 +88,42 @@ describe("OpenAPI contract (e2e)", () => { const paths: Record = res.body.paths; expect(paths["/api/v1/solvers"]).toBeDefined(); }); + + // ------------------------------------------------------------------------- + // Issue #271 — SorobanController and TokensController must document their + // response shapes just like every other controller. + // ------------------------------------------------------------------------- + + const okSchema = (op: { responses?: Record }> }) => + op.responses?.["200"]?.content?.["application/json"]?.schema; + + it("documents a 200 response schema for every chain (Soroban) route", async () => { + const res = await request(app.getHttpServer()).get("/docs-json").expect(200); + const paths = res.body.paths; + for (const route of [ + "/api/v1/chain/health", + "/api/v1/chain/ledger", + "/api/v1/chain/network", + "/api/v1/chain/account/{publicKey}", + ]) { + expect(paths[route]).toBeDefined(); + expect(okSchema(paths[route].get)).toBeDefined(); + } + }); + + it("documents the 400 and 429 responses on the account route", async () => { + const res = await request(app.getHttpServer()).get("/docs-json").expect(200); + const op = res.body.paths["/api/v1/chain/account/{publicKey}"].get; + expect(op.responses["400"]).toBeDefined(); + expect(op.responses["429"]).toBeDefined(); + }); + + it("documents a 200 response schema for every tokens route", async () => { + const res = await request(app.getHttpServer()).get("/docs-json").expect(200); + const paths = res.body.paths; + for (const route of ["/api/v1/tokens", "/api/v1/tokens/stellar"]) { + expect(paths[route]).toBeDefined(); + expect(okSchema(paths[route].get)).toBeDefined(); + } + }); }); diff --git a/test/solvers-update.e2e-spec.ts b/test/solvers-update.e2e-spec.ts new file mode 100644 index 0000000..cfc38fd --- /dev/null +++ b/test/solvers-update.e2e-spec.ts @@ -0,0 +1,115 @@ +import { INestApplication } from "@nestjs/common"; +import request from "supertest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { SEED_SOLVER_KEYPAIRS } from "../src/solvers/solvers.seed"; +import { buildUpdateSolverMessage } from "../src/common/stellar-signature"; +import { createTestApp } from "./utils/create-test-app"; + +/** + * Issue #273 — PATCH /api/v1/solvers/:address + */ +const ALPHA = SEED_SOLVER_KEYPAIRS.ALPHA; +const ALPHA_ADDR = ALPHA.publicKey(); + +function sign(address: string): string { + return ALPHA.sign(Buffer.from(buildUpdateSolverMessage(address), "utf8")).toString("base64"); +} + +describe("PATCH /api/v1/solvers/:address (e2e)", () => { + let app: INestApplication; + + beforeAll(async () => { + app = await createTestApp(); + }); + + afterAll(async () => { + await app.close(); + }); + + it("updates mutable profile fields with a valid signature", async () => { + const res = await request(app.getHttpServer()) + .patch(`/api/v1/solvers/${ALPHA_ADDR}`) + .send({ + name: "Alpha MM (updated)", + supportedChains: ["ethereum", "stellar"], + supportedTokens: ["USDC", "WETH"], + avgFillTime: 41, + signature: sign(ALPHA_ADDR), + }) + .expect(200); + + expect(res.body.name).toBe("Alpha MM (updated)"); + expect(res.body.supportedChains).toEqual(["ethereum", "stellar"]); + expect(res.body.supportedTokens).toEqual(["USDC", "WETH"]); + expect(res.body.avgFillTime).toBe(41); + }); + + it("silently strips immutable fields (whitelist: true) rather than erroring", async () => { + const before = (await request(app.getHttpServer()).get(`/api/v1/solvers/${ALPHA_ADDR}`)).body; + + const res = await request(app.getHttpServer()) + .patch(`/api/v1/solvers/${ALPHA_ADDR}`) + .send({ + name: "Alpha renamed", + bondAmount: "1", + fillsCompleted: 999999, + isActive: false, + registeredAt: 0, + signature: sign(ALPHA_ADDR), + }) + .expect(200); + + expect(res.body.name).toBe("Alpha renamed"); + expect(res.body.bondAmount).toBe(before.bondAmount); + expect(res.body.fillsCompleted).toBe(before.fillsCompleted); + expect(res.body.isActive).toBe(before.isActive); + expect(res.body.registeredAt).toBe(before.registeredAt); + }); + + it("rejects a missing signature with 400", async () => { + await request(app.getHttpServer()) + .patch(`/api/v1/solvers/${ALPHA_ADDR}`) + .send({ name: "no sig" }) + .expect(400); + }); + + it("rejects an invalid signature with 401", async () => { + await request(app.getHttpServer()) + .patch(`/api/v1/solvers/${ALPHA_ADDR}`) + .send({ name: "bad sig", signature: Buffer.from("not-a-real-signature").toString("base64") }) + .expect(401); + }); + + it("rejects a signature from the wrong key with 401", async () => { + await request(app.getHttpServer()) + .patch(`/api/v1/solvers/${ALPHA_ADDR}`) + .send({ + name: "wrong signer", + signature: SEED_SOLVER_KEYPAIRS.BETA.sign( + Buffer.from(buildUpdateSolverMessage(ALPHA_ADDR), "utf8"), + ).toString("base64"), + }) + .expect(401); + }); + + it("rejects an unsupported chain with 400", async () => { + await request(app.getHttpServer()) + .patch(`/api/v1/solvers/${ALPHA_ADDR}`) + .send({ supportedChains: ["ethereum", "solana"], signature: sign(ALPHA_ADDR) }) + .expect(400); + }); + + it("404s for a valid but unregistered solver address", async () => { + const stranger = Keypair.random(); + const addr = stranger.publicKey(); + await request(app.getHttpServer()) + .patch(`/api/v1/solvers/${addr}`) + .send({ + name: "ghost", + signature: stranger + .sign(Buffer.from(buildUpdateSolverMessage(addr), "utf8")) + .toString("base64"), + }) + .expect(404); + }); +}); diff --git a/test/utils/create-test-app.ts b/test/utils/create-test-app.ts index 5242a6c..e1e4c22 100644 --- a/test/utils/create-test-app.ts +++ b/test/utils/create-test-app.ts @@ -1,5 +1,6 @@ import { INestApplication, ValidationPipe } from "@nestjs/common"; import { Test } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; import { WsAdapter } from "@nestjs/platform-ws"; import { json } from "express"; import { AppModule } from "../../src/app.module";