Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
a350455
Use fastforex api for fiat exchange rates over rates based on swap pools
ebma Jun 2, 2026
188e426
Fix priceFeed.service.test.ts
ebma Jun 2, 2026
56975b3
Update quote-lifecycle.md
ebma Jun 2, 2026
3f9f377
Amend changes
ebma Jun 3, 2026
a7989e2
Fix getFastforexRate URL/Headers construction and test isolation issues
Copilot Jun 3, 2026
7ffd3ae
Validate target currency as fiat before fetching exchange rates
ebma Jun 4, 2026
53e1c3e
Address review comments
ebma Jun 4, 2026
4f22193
Merge branch 'staging' into replace-nabla-swap-price-with-fastforex
ebma Jun 4, 2026
c563263
Enhance USD-to-fiat exchange rate handling and add sanity checks for …
ebma Jun 5, 2026
c2cbfdf
Don't use pendulum onchain prices for anything but always use fastforex
ebma Jun 5, 2026
0d60858
Merge branch 'staging' into replace-nabla-swap-price-with-fastforex
ebma Jun 15, 2026
2907b9b
Adjust security-spec
ebma Jun 15, 2026
11a7765
Address PR review comments
ebma Jun 15, 2026
8288acb
Change 'Buy & Sell' to 'Open App' in MobileMenu
prayagd Jul 8, 2026
b319cfc
fix(api): repair postProcessCompletedStates JSONB query that never ma…
ebma Jul 8, 2026
f2784fa
feat(rebalancer): log BlindPay shadow quote in usdc-brla-usdc-base ro…
ebma Jul 8, 2026
9072ae3
fix(shared): stop polling loops when their timeout fires or caller ab…
ebma Jul 8, 2026
530a741
fix(api): abort abandoned phase executions instead of leaking them
ebma Jul 8, 2026
4ea6bff
fix(api): bound brlaOnrampMint waits so the payment timeout can fire
ebma Jul 8, 2026
bb3da24
fix(api): keep warm DB connections to cut pooler handshake churn
ebma Jul 8, 2026
0d62495
feat(rebalancer): surface BlindPay shadow quote in Slack completion n…
ebma Jul 8, 2026
d646e46
Merge pull request #1256 from pendulum-chain/fix/cleanup-worker-postp…
ebma Jul 8, 2026
4d232fa
fix(shared): handle pre-aborted signals in sleep without a registrati…
ebma Jul 8, 2026
6612fd3
fix(api): guard phase-processor env overrides and isolate the cancell…
ebma Jul 8, 2026
593a4bc
fix(rebalancer): time out BlindPay requests and test the client
ebma Jul 8, 2026
66c89b0
fix(shared): make ApiManager refresh paths actually replace the stale…
ebma Jul 8, 2026
bce2392
Add i18n strings for 'Open App' in en and pt translations
Copilot Jul 8, 2026
ad1592e
Merge pull request #1255 from pendulum-chain/consistent-text
ebma Jul 8, 2026
2eaaf55
fix(api): don't leak the phase timeout timer on synchronous handler t…
ebma Jul 8, 2026
b5db18e
fix(shared): swap cached api only after the replacement is ready
ebma Jul 8, 2026
77610a9
Merge pull request #1259 from pendulum-chain/fix/api-manager-refresh-…
ebma Jul 8, 2026
bf296bb
Merge staging into fix/cancel-abandoned-phase-polling
ebma Jul 8, 2026
34ec79a
style(frontend): fix en.json formatting that broke bun verify on staging
ebma Jul 8, 2026
f59a0f3
style(frontend): format en.json
ebma Jul 8, 2026
89d5927
Merge pull request #1260 from pendulum-chain/style/fix-en-json-format…
ebma Jul 8, 2026
0f3247c
Merge pull request #1257 from pendulum-chain/fix/cancel-abandoned-pha…
ebma Jul 8, 2026
605299a
Merge remote-tracking branch 'origin/staging' into replace-nabla-swap…
ebma Jul 8, 2026
e60dd11
Make priceFeed config tests robust to config/vars import order in ful…
ebma Jul 8, 2026
fd939ba
Align bun.lock format with staging (drop configVersion field)
ebma Jul 8, 2026
a043fd1
Update stale oracle-price comments to reflect fastforex rate source
ebma Jul 8, 2026
ff12cb6
fix(api): surface fiat-provider failures on ramp endpoints with clear…
ebma Jul 8, 2026
ca11d4b
Restore @vortexfi/shared mock in priceFeed test to stop cross-file leak
ebma Jul 8, 2026
5bd43a4
Merge pull request #1190 from pendulum-chain/replace-nabla-swap-price…
ebma Jul 8, 2026
f8a1c2a
fix(api): address PR review — rename provider error, wrap transport f…
ebma Jul 8, 2026
ba5d8a9
docs(security-spec): spell out full ramp endpoint paths in provider-e…
ebma Jul 8, 2026
f161a47
Merge pull request #1262 from pendulum-chain/improve/avenia-register-…
ebma Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ MOONPAY_PROD_URL=https://api.moonpay.com
MOONPAY_API_KEY=your-moonpay-api-key
COINGECKO_API_KEY=your-coingecko-api-key
COINGECKO_API_URL=https://pro-api.coingecko.com/api/v3
FASTFOREX_API_KEY=your-fastforex-api-key
FASTFOREX_API_URL=https://api.fastforex.io
SUBSCAN_API_KEY=your-subscan-api-key

# Price Feed Cache Configuration
Expand Down
31 changes: 30 additions & 1 deletion apps/api/src/api/controllers/brla.controller.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {AveniaAccountType, BrlaApiService} from "@vortexfi/shared";
import {AveniaAccountType, BrlaApiError, BrlaApiService} from "@vortexfi/shared";
import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
import httpStatus from "http-status";
import logger from "../../config/logger";
Expand Down Expand Up @@ -139,6 +139,35 @@ describe("getAveniaUser", () => {
expect(res.statusCode).toBe(httpStatus.FORBIDDEN);
expect(res.body).toEqual({ error: "This tax ID is not linked to your user profile and cannot be used." });
});

it("still parses a BrlaApiError 400 into a 400 'Invalid request' with details (message-format invariant)", async () => {
TaxId.findOne = mock(async () => ({ subAccountId: "subaccount-1", userId: "user-1" })) as typeof TaxId.findOne;
BrlaApiService.getInstance = mock(
() =>
({
subaccountInfo: mock(async () => {
throw new BrlaApiError({
endpoint: "/v2/account/account-info",
method: "GET",
responseBody: JSON.stringify({ error: "user is blocked" }),
status: 400
});
})
}) as unknown as BrlaApiService
);

const res = createResponse();
await getAveniaUser(
{
query: { taxId: "08786985906" },
userId: "user-1"
} as any,
res as any
);

expect(res.statusCode).toBe(httpStatus.BAD_REQUEST);
expect(res.body).toEqual({ details: { error: "user is blocked" }, error: "Invalid request" });
});
});

describe("createSubaccount", () => {
Expand Down
135 changes: 135 additions & 0 deletions apps/api/src/api/controllers/ramp.controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { AlfredpayApiError, BrlaApiError } from "@vortexfi/shared";
import { describe, expect, it } from "bun:test";
import httpStatus from "http-status";
import { APIError } from "../errors/api-error";
import { classifyApiClientError } from "../observability/errorClassifier";
import { mapProviderFailure } from "./ramp.controller";

describe("mapProviderFailure", () => {
it("maps a 4xx Avenia rejection (e.g. blocked user) to a 422 with a sanitized public message", () => {
const providerError = new BrlaApiError({
endpoint: "/v2/account/tickets",
method: "POST",
responseBody: JSON.stringify({ error: "user is blocked" }),
status: 400
});

const { error, logContext } = mapProviderFailure(providerError);

expect(error).toBeInstanceOf(APIError);
const apiError = error as APIError;
expect(apiError.status).toBe(httpStatus.UNPROCESSABLE_ENTITY);
expect(apiError.isPublic).toBe(true);
// The caller learns which stage failed (the payment provider) but never the raw body.
expect(apiError.message).toContain("payment provider");
expect(apiError.message.toLowerCase()).not.toContain("blocked");
expect(apiError.message).not.toContain("user is blocked");

// Logging pinpoints exactly which provider call failed, and why (server-side only).
expect(logContext).toEqual({
provider: "avenia",
providerEndpoint: "/v2/account/tickets",
providerMethod: "POST",
providerResponseBody: JSON.stringify({ error: "user is blocked" }),
providerStatus: 400
});
});

it("classifies the mapped 4xx error as a provider_error for telemetry", () => {
const { error } = mapProviderFailure(
new BrlaApiError({
endpoint: "/v2/account/tickets",
method: "POST",
responseBody: JSON.stringify({ error: "user is blocked" }),
status: 400
})
);

expect(classifyApiClientError(error)).toBe("provider_error");
});

it("maps a 5xx / transport Avenia failure to a 502 provider-unavailable message", () => {
const providerError = new BrlaApiError({
endpoint: "/v2/account/quote/fixed-rate",
method: "GET",
responseBody: "upstream exploded",
status: 503
});

const { error, logContext } = mapProviderFailure(providerError);

const apiError = error as APIError;
expect(apiError.status).toBe(httpStatus.BAD_GATEWAY);
expect(apiError.isPublic).toBe(true);
expect(apiError.message).toContain("payment provider");
expect(apiError.message).not.toContain("upstream exploded");
expect(logContext.providerStatus).toBe(503);
expect(logContext.providerEndpoint).toBe("/v2/account/quote/fixed-rate");
});

it("also generalizes to Alfredpay provider failures and tags the correct provider", () => {
const providerError = new AlfredpayApiError({
endpoint: "/customers",
method: "POST",
responseBody: JSON.stringify({ error: "customer rejected" }),
status: 422
});

const { error, logContext } = mapProviderFailure(providerError);

const apiError = error as APIError;
expect(apiError.status).toBe(httpStatus.UNPROCESSABLE_ENTITY);
expect(apiError.message).toContain("payment provider");
expect(apiError.message).not.toContain("customer rejected");
expect(logContext).toEqual({
provider: "alfredpay",
providerEndpoint: "/customers",
providerMethod: "POST",
providerResponseBody: JSON.stringify({ error: "customer rejected" }),
providerStatus: 422
});
expect(classifyApiClientError(error)).toBe("provider_error");
});

it("maps a transport failure (status 0, no HTTP response) to a 502", () => {
const providerError = new BrlaApiError({
endpoint: "/v2/account/tickets",
method: "POST",
responseBody: "fetch failed: ECONNRESET",
status: 0
});

const { error, logContext } = mapProviderFailure(providerError);

expect((error as APIError).status).toBe(httpStatus.BAD_GATEWAY);
expect(logContext.providerStatus).toBe(0);
});

it("truncates the logged provider response body to bound size/PII", () => {
const providerError = new BrlaApiError({
endpoint: "/v2/account/tickets",
method: "POST",
responseBody: "x".repeat(5000),
status: 400
});

const { logContext } = mapProviderFailure(providerError);

expect((logContext.providerResponseBody as string).length).toBe(300);
});

it("returns non-provider errors unchanged with empty log context", () => {
const original = new APIError({ message: "Quote has expired", status: httpStatus.BAD_REQUEST });

const { error, logContext } = mapProviderFailure(original);

expect(error).toBe(original);
expect(logContext).toEqual({});
});

it("passes through an unknown non-Error value unchanged", () => {
const { error, logContext } = mapProviderFailure("boom");
expect(error).toBe("boom");
expect(logContext).toEqual({});
});
});
79 changes: 70 additions & 9 deletions apps/api/src/api/controllers/ramp.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
GetRampHistoryResponse,
GetRampStatusRequest,
GetRampStatusResponse,
ProviderHttpError,
RampProcess,
StartRampRequest,
StartRampResponse,
Expand All @@ -24,6 +25,51 @@ import { getRequestDurationMs } from "../observability/requestContext";
import { ApiClientOperation } from "../observability/types";
import rampService from "../services/ramp/ramp.service";

/**
* Translate a raw fiat-provider (Avenia/BRLA, Alfredpay) failure raised while handling a ramp
* request into a caller-facing `APIError` plus structured log context.
*
* - Logging: for a `ProviderHttpError` we surface the exact `provider`/`endpoint`/`method`/
* `status` of the failing call plus a truncated response body (the reason, e.g. "user is
* blocked") so operators can tell *where* and *why* it failed without guessing. The body is
* logged server-side only and never returned to the caller. A transport failure carries
* `status: 0`, which maps to the same 502 as an upstream `5xx`.
* - Caller message: the provider's raw body (e.g. `{"error":"user is blocked"}`) is never
* forwarded. A `4xx` means the account/request was rejected (unprocessable), a `5xx`/
* transport failure means the provider is unavailable (bad gateway). Both messages indicate
* the failing stage — the payment provider — without leaking internal detail. The word
* "provider" keeps `classifyApiClientError` reporting this as `provider_error`.
*
* Non-provider errors (validation, ownership, quote state, …) are returned unchanged.
*/
const MAX_LOGGED_PROVIDER_BODY_LENGTH = 300;

export function mapProviderFailure(error: unknown): { error: unknown; logContext: Record<string, unknown> } {
if (!(error instanceof ProviderHttpError)) {
return { error, logContext: {} };
}

const isClientRejection = error.status >= 400 && error.status < 500;
const mapped = new APIError({
isPublic: true,
message: isClientRejection
? "The payment provider could not process this request for the associated account. Please verify the account status or contact support."
: "The payment provider is temporarily unavailable. Please try again shortly.",
status: isClientRejection ? httpStatus.UNPROCESSABLE_ENTITY : httpStatus.BAD_GATEWAY
});

return {
error: mapped,
logContext: {
provider: error.provider,
providerEndpoint: error.endpoint,
providerMethod: error.method,
providerResponseBody: error.responseBody.slice(0, MAX_LOGGED_PROVIDER_BODY_LENGTH),
providerStatus: error.status
}
};
}

/**
* Register a new ramping process
* @public
Expand Down Expand Up @@ -62,9 +108,14 @@ export const registerRamp = async (req: Request, res: Response<RampProcess>, nex

res.status(httpStatus.CREATED).json(ramp);
} catch (error) {
logger.error("Error registering ramp", { errorType: classifyApiClientError(error), requestId: req.requestId });
observeRampFailure(req, "ramp_register", error, { quoteId: req.body?.quoteId || null });
next(error);
const { error: mappedError, logContext } = mapProviderFailure(error);
logger.error("Error registering ramp", {
errorType: classifyApiClientError(mappedError),
requestId: req.requestId,
...logContext
});
observeRampFailure(req, "ramp_register", mappedError, { quoteId: req.body?.quoteId || null });
next(mappedError);
}
};

Expand Down Expand Up @@ -114,9 +165,14 @@ export const updateRamp = async (

res.status(httpStatus.OK).json(ramp);
} catch (error) {
logger.error("Error updating ramp", { errorType: classifyApiClientError(error), requestId: req.requestId });
observeRampFailure(req, "ramp_update", error, { rampId: req.body?.rampId || null });
next(error);
const { error: mappedError, logContext } = mapProviderFailure(error);
logger.error("Error updating ramp", {
errorType: classifyApiClientError(mappedError),
requestId: req.requestId,
...logContext
});
observeRampFailure(req, "ramp_update", mappedError, { rampId: req.body?.rampId || null });
next(mappedError);
}
};

Expand Down Expand Up @@ -156,9 +212,14 @@ export const startRamp = async (

res.status(httpStatus.OK).json(ramp);
} catch (error) {
logger.error("Error starting ramp", { errorType: classifyApiClientError(error), requestId: req.requestId });
observeRampFailure(req, "ramp_start", error, { rampId: req.body?.rampId || null });
next(error);
const { error: mappedError, logContext } = mapProviderFailure(error);
logger.error("Error starting ramp", {
errorType: classifyApiClientError(mappedError),
requestId: req.requestId,
...logContext
});
observeRampFailure(req, "ramp_start", mappedError, { rampId: req.body?.rampId || null });
next(mappedError);
}
};

Expand Down
12 changes: 8 additions & 4 deletions apps/api/src/api/services/phases/base-phase-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ export interface PhaseHandler {
/**
* Execute the phase
* @param state The current ramp state
* @param signal Aborted when the processor gives up on this execution; long-running
* waits must stop when it fires so abandoned executions don't keep running
* @returns The updated ramp state
*/
execute(state: RampState): Promise<RampState>;
execute(state: RampState, signal?: AbortSignal): Promise<RampState>;

/**
* Get the phase name
Expand All @@ -41,7 +43,7 @@ export abstract class BasePhaseHandler implements PhaseHandler {
* @param state The current ramp state
* @returns The updated ramp state
*/
public async execute(state: RampState): Promise<RampState> {
public async execute(state: RampState, signal?: AbortSignal): Promise<RampState> {
try {
logger.info(`Executing phase ${this.getPhaseName()} for ramp ${state.id}`);

Expand All @@ -54,7 +56,7 @@ export abstract class BasePhaseHandler implements PhaseHandler {
}

// Execute the phase
const updatedState = await this.executePhase(state);
const updatedState = await this.executePhase(state, signal);

// Log the phase execution
logger.info(`Phase ${this.getPhaseName()} executed successfully for ramp ${state.id}`);
Expand Down Expand Up @@ -90,9 +92,11 @@ export abstract class BasePhaseHandler implements PhaseHandler {
/**
* Execute the phase implementation
* @param state The current ramp state
* @param signal Aborted when the processor abandons this execution; implementations
* with long-running waits should pass it to their polling helpers
* @returns The updated ramp state
*/
protected abstract executePhase(state: RampState): Promise<RampState>;
protected abstract executePhase(state: RampState, signal?: AbortSignal): Promise<RampState>;

/**
* Transition to the next phase
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ import { BasePhaseHandler } from "../base-phase-handler";
import { syncAveniaOnHoldState } from "../helpers/brla-onramp-hold";
import { StateMetadata } from "../meta-state-types";

// The rationale for these difference is that it allows for a finer check over the payment timeout in
// case of service restart. A smaller timeout for the balance check loop allows to get out to the outer
// process loop and check for the operation timestamp.
// The check loops use a smaller timeout than the overall payment timeout so each execution
// returns to the outer process loop well below the processor's execution timeout and checks
// the operation timestamp there. The previous 30-minute Avenia wait always outlived the
// processor's 10-minute race, so the payment-timeout cancellation below never ran for
// recovered ramps and abandoned executions piled up.
const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
const AVENIA_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const EVM_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const AVENIA_HOLD_STATUS_CHECK_INTERVAL_MS = 60 * 1000; // 1 minute

Expand All @@ -45,7 +48,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler {
return "brlaOnrampMint";
}

protected async executePhase(state: RampState): Promise<RampState> {
protected async executePhase(state: RampState, signal?: AbortSignal): Promise<RampState> {
const { evmEphemeralAddress } = state.state as StateMetadata;

if (!evmEphemeralAddress) {
Expand Down Expand Up @@ -138,7 +141,8 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler {
return Number(balances.BRLA) >= Number(Big(quote.metadata.aveniaMint.outputAmountDecimal).toFixed(2, 0));
},
5000,
PAYMENT_TIMEOUT_MS
AVENIA_BALANCE_CHECK_TIMEOUT_MS,
signal
);
} catch (error) {
const isCheckTimeout = error instanceof Error && error.message.includes("Timeout");
Expand Down Expand Up @@ -202,7 +206,8 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler {
expectedAmountReceived,
pollingTimeMs,
EVM_BALANCE_CHECK_TIMEOUT_MS,
Networks.Base
Networks.Base,
signal
);
} catch (error) {
if (!(error instanceof BalanceCheckError)) throw error;
Expand Down
Loading
Loading