Replace nabla swap price with fastforex#1190
Open
ebma wants to merge 13 commits into
Open
Conversation
✅ Deploy Preview for vortexfi ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
❌ Deploy Preview for vortex-sandbox failed. Why did it fail? →
|
Contributor
There was a problem hiding this comment.
Pull request overview
This PR switches the API’s fiat FX rate sourcing from Pendulum/Nabla swap-derived rates to fastforex.io (with CoinGecko as a fallback), updating configuration, documentation, and tests accordingly.
Changes:
- Added Fastforex configuration (API key + base URL) to the API config and
.env.example. - Updated
PriceFeedServiceto fetch USD→fiat rates from fastforex first, then fall back to CoinGecko, with caching preserved. - Updated the security spec and reworked unit tests to cover the new fastforex-first behavior and fallback paths.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/security-spec/03-ramp-engine/quote-lifecycle.md | Updates quote lifecycle/security invariants to reflect fastforex fiat FX sourcing with fallback. |
| apps/api/src/config/vars.ts | Adds priceProviders.fastforex config (API key + base URL). |
| apps/api/src/api/services/priceFeed.service.ts | Implements fastforex-backed USD→fiat exchange rate fetching with CoinGecko fallback and caching. |
| apps/api/src/api/services/priceFeed.service.test.ts | Updates tests to validate fastforex primary + CoinGecko fallback behavior and caching. |
| apps/api/.env.example | Documents FASTFOREX_API_KEY and FASTFOREX_API_URL. |
Comments suppressed due to low confidence (1)
apps/api/src/api/services/priceFeed.service.ts:202
getUsdToFiatExchangeRateis documented/used as a USD→fiat helper, but it acceptsRampCurrency(fiat or on-chain). If this is ever called with a non-fiat token (e.g. an EVM symbol), it will make an invalid fastforex/CoinGecko request and fail at runtime. Add an explicit fiat guard up front so misuse fails fast with a clear error.
public async getUsdToFiatExchangeRate(toCurrency: RampCurrency): Promise<number> {
const fromCurrency = "USD";
const cacheKey = `fiat:${fromCurrency}:${toCurrency}`;
const cachedEntry = this.fiatExchangeRateCache.get(cacheKey);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+435
to
+444
| const url = new URL(`${this.fastforexApiBaseUrl}/fetch-one`); | ||
| url.searchParams.append("from", fromCurrency); | ||
| url.searchParams.append("to", toCurrency); | ||
|
|
||
| const headers: HeadersInit = { Accept: "application/json" }; | ||
| if (this.fastforexApiKey) { | ||
| headers["X-API-Key"] = this.fastforexApiKey; | ||
| } | ||
|
|
||
| const response = await fetchWithTimeout(url.toString(), { headers }); |
Comment on lines
+270
to
273
| it("should return the same amount when currencies match", async () => { | ||
| const result = await priceFeedService.convertCurrency("100", "BRL" as any, "BRL" as any); | ||
| expect(result).toBe("100.00"); | ||
| }); |
Comment on lines
275
to
277
| it("should perform 1:1 conversion between USD-like stablecoins", async () => { | ||
| const result = await priceFeedService.convertCurrency("100", "USDC" as any, "USDT" as any); | ||
| expect(result).toBe("100"); |
Comment on lines
+4
to
+6
| const originalEnv = { ...process.env }; | ||
| const originalFetch = global.fetch; | ||
|
|
Comment on lines
+73
to
+77
| beforeEach(() => { | ||
| // Store original env and Date.now | ||
| originalEnv = { ...process.env }; | ||
| originalDateNow = Date.now; | ||
|
|
||
| // Mock environment variables for each test | ||
| process.env = { | ||
| ...originalEnv, // Start with original to avoid missing Node internal vars | ||
| COINGECKO_API_KEY: "test-api-key", | ||
| COINGECKO_API_URL: "https://api.coingecko.com/api/v3", | ||
| CRYPTO_CACHE_TTL_MS: "300000", // 5 minutes | ||
| FIAT_CACHE_TTL_MS: "300000" // 5 minutes | ||
| } as any; | ||
|
|
||
| // Create a fresh fetch mock for each test | ||
| fetchMock = mock(() => | ||
| Promise.resolve({ | ||
| clone() { | ||
| return this; | ||
| }, | ||
| headers: new Headers(), | ||
| json: () => Promise.resolve(mockCoinGeckoResponse), | ||
| ok: true, | ||
| redirected: false, | ||
| status: 200, | ||
| statusText: "OK", | ||
| text: () => Promise.resolve(JSON.stringify(mockCoinGeckoResponse)), | ||
| type: "basic", | ||
| url: "" | ||
| } as Response) | ||
| ); | ||
|
|
||
| // Mock fetch | ||
| global.fetch = fetchMock as any; | ||
|
|
||
| // Reset mocks before each test | ||
| (getTokenOutAmountMock as any).mockClear(); | ||
| // Reset Nabla mock to default implementation if needed (if tests modify its behavior) | ||
| (getTokenOutAmountMock as any).mockImplementation(async () => ({ | ||
| effectiveExchangeRate: "1.25", | ||
| preciseQuotedAmountOut: { preciseBigDecimal: { toString: () => "1.25" } }, | ||
| roundedDownQuotedAmountOut: { toString: () => "1.25" }, | ||
| swapFee: { toString: () => "0.01" } | ||
| })); | ||
|
|
||
| // Ensure singleton is reset *before* each test to pick up fresh env vars/mocks | ||
| fetchMock = mock(async () => mockFastforexResponse(5.85)); | ||
| global.fetch = fetchMock as unknown as typeof fetch; | ||
| getApiMock.mockClear(); |
Comment on lines
+83
to
+87
| afterEach(() => { | ||
| // Restore fetch | ||
| Date.now = originalDateNow; | ||
| global.fetch = originalFetch; | ||
| // @ts-expect-error - accessing private property for testing | ||
| PriceFeedService.instance = undefined; |
Comment on lines
+483
to
+499
| private async assertFastforexRateWithinSanityBand(targetCurrency: RampCurrency, fastforexRate: number): Promise<void> { | ||
| this.assertValidFiatRate("fastforex", "USD", targetCurrency, fastforexRate); | ||
|
|
||
| const referenceRate = await this.getCryptoPrice("usd-coin", targetCurrency.toLowerCase()); | ||
| this.assertValidFiatRate("CoinGecko", "USD", targetCurrency, referenceRate); | ||
|
|
||
| const spread = Big(fastforexRate).minus(referenceRate).abs().div(referenceRate).toNumber(); | ||
| const limit = FASTFOREX_SANITY_SPREAD_LIMITS[targetCurrency] ?? 0.03; | ||
|
|
||
| if (spread > limit) { | ||
| throw new Error( | ||
| `fastforex USD-${targetCurrency} rate ${fastforexRate} differs from CoinGecko reference ${referenceRate} by ${( | ||
| spread * 100 | ||
| ).toFixed(2)}%, above ${(limit * 100).toFixed(2)}% limit` | ||
| ); | ||
| } | ||
| } |
Comment on lines
325
to
333
| } catch (error) { | ||
| if (error instanceof Error) { | ||
| logger.error(`Error converting ${amount} from ${fromCurrency} to ${toCurrency}: ${error.message}`); | ||
| } else { | ||
| logger.error(`Unknown error converting ${amount} from ${fromCurrency} to ${toCurrency}`); | ||
| } | ||
|
|
||
| // Return the original amount as fallback | ||
| logger.warn(`Returning original amount ${amount} as fallback due to conversion error`); | ||
| return amount; | ||
| throw error; | ||
| } |
Comment on lines
+15
to
+25
| global.setInterval = mock(() => 0) as unknown as typeof setInterval; | ||
|
|
||
| process.env = { | ||
| ...originalEnv, | ||
| COINGECKO_API_KEY: "test-api-key", | ||
| COINGECKO_API_URL: "https://api.coingecko.com/api/v3", | ||
| CRYPTO_CACHE_TTL_MS: "300000", | ||
| FASTFOREX_API_KEY: "test-fastforex-key", | ||
| FASTFOREX_API_URL: "https://api.fastforex.io", | ||
| FIAT_CACHE_TTL_MS: "300000" | ||
| } as NodeJS.ProcessEnv; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the fiat rates that currently are derived from the swap rates on the Pendulum DEX, with fiat rates from fastforex.