Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
68505d2
first pass, add ars changes
gianfra-t May 8, 2026
3c5fedc
simplify ARS components, helpers
gianfra-t May 12, 2026
98fc828
Merge branch 'staging' into add-alfredpay-ars-support
gianfra-t May 12, 2026
11862c6
add logic for broken kyc flows, ARS and cbu type supprts
gianfra-t May 12, 2026
f94e661
Merge branch 'staging' into add-alfredpay-ars-support
gianfra-t May 20, 2026
3f55ea7
add ars as "free" token configuration, minor adjustments
gianfra-t May 20, 2026
3ef04ab
Merge branch 'staging' into add-alfredpay-ars-support
gianfra-t May 27, 2026
db6320d
Merge branch 'staging' into add-alfredpay-ars-support
gianfra-t May 27, 2026
ddbfd68
rollback unintended changes
gianfra-t May 27, 2026
75b35fd
remove testing logs
gianfra-t May 27, 2026
af8aa7f
fix decimal roundown issue for alfredpay offramp operation
gianfra-t May 28, 2026
788314c
add ARS limits and required config objects
gianfra-t May 28, 2026
dbe970f
Merge branch 'staging' into add-alfredpay-ars-support
gianfra-t May 28, 2026
b109ae0
re-use alfredpay kyc form component
gianfra-t May 28, 2026
a42b5d4
Display deposit details
gianfra-t May 29, 2026
34bec2e
Merge branch 'staging' into add-alfredpay-ars-support
gianfra-t May 29, 2026
690ce20
enforce maximum time window check only for stellar ramps
gianfra-t May 29, 2026
754a564
fix ars limits, kyc error propagation in UI
gianfra-t May 29, 2026
7e9a419
fix i18 namespaces
gianfra-t May 29, 2026
f5da1a8
improve bun config
gianfra-t Jun 1, 2026
609281f
Merge branch 'staging' into add-alfredpay-ars-support
gianfra-t Jun 1, 2026
b6d52d5
Potential fix for pull request finding 'Unused variable, import, func…
gianfra-t Jun 1, 2026
51e6d25
fix typecheck error
gianfra-t Jun 1, 2026
13546a8
change default buy/sell onchain token to usdc
Sharqiewicz Jun 7, 2026
b88039e
Merge branch 'staging' into add-alfredpay-ars-support
gianfra-t Jun 8, 2026
04d6393
adjust destinationTransfer params
gianfra-t Jun 8, 2026
6b4f0c2
fix(api): avoid scientific notation in Alfredpay direct transfer amou…
Copilot Jun 8, 2026
2024f77
Potential fix for pull request finding
gianfra-t Jun 8, 2026
eb54284
fix(api): use fixed-point raw amount string in onramp transfer
Copilot Jun 8, 2026
0bfe4e7
fix(api): avoid scientific notation in polygon same-chain amountRaw
Copilot Jun 8, 2026
36d8117
fix(api): avoid scientific notation for fallback raw amount
Copilot Jun 8, 2026
fab87bb
Update contact sales card copy
naka-bot Jun 9, 2026
4c4ad2d
re-add expiration check for ramp
gianfra-t Jun 9, 2026
0a6dc24
Merge pull request #1210 from naka-bot/naka/contact-page-sales-copy
ebma Jun 9, 2026
ed04c57
remove concurrency from bun run dev script
gianfra-t Jun 9, 2026
2c09d8e
Merge branch 'staging' into add-alfredpay-ars-support
gianfra-t Jun 9, 2026
616536f
Merge pull request #1141 from pendulum-chain/add-alfredpay-ars-support
gianfra-t Jun 9, 2026
2f4b002
Add API client event metadata helpers
ebma Jun 9, 2026
e840136
Record request context on quote and ramp failures
ebma Jun 9, 2026
a8793e1
Improve auth client event attribution
ebma Jun 9, 2026
3f9a2c0
Document client event metadata safeguards
ebma Jun 9, 2026
412ee0b
Address pr review comments
ebma Jun 9, 2026
ba09f10
Address pr review comments
ebma Jun 9, 2026
92f3512
Merge pull request #1213 from pendulum-chain/improve-api-client-event…
ebma Jun 9, 2026
2f68293
Improve 'no liquidity' error message
ebma Jun 11, 2026
c9f679c
Recognize Avenia on-hold pay-in tickets
ebma Jun 11, 2026
113e885
Persist Avenia pay-in hold metadata
ebma Jun 11, 2026
a27478f
Register compliance hold ramp phase
ebma Jun 11, 2026
a952477
Return compliance hold phase from ramp status
ebma Jun 11, 2026
1f949be
Align SDK ramp status response typing
ebma Jun 11, 2026
5c22e8b
Add compliance hold progress copy
ebma Jun 11, 2026
7a870fd
Document compliance hold ramp phase
ebma Jun 11, 2026
89ccd5a
Address PR feedback
ebma Jun 11, 2026
f9a2822
Address PR review feedback
ebma Jun 11, 2026
5b23d13
Move BRLA onramp hold helper out of handlers
ebma Jun 11, 2026
cf431f0
Return correct final tx hash and explorer link
ebma Jun 11, 2026
2fda83d
Adjust text message
ebma Jun 11, 2026
bf7c89c
Potential fix for pull request finding
ebma Jun 11, 2026
6c3f545
Merge pull request #1218 from pendulum-chain/improve-final-tx-explore…
ebma Jun 11, 2026
539d0d9
Merge pull request #1217 from pendulum-chain/return-on-hold-indication
ebma Jun 11, 2026
9168bdd
Add validation error details and improve error response structure
ebma Jun 11, 2026
2fc3938
Merge pull request #1204 from pendulum-chain/chore/change-default-buy…
ebma Jun 11, 2026
4590084
Merge branch 'staging' into improve-no-liquidity-error-message
ebma Jun 11, 2026
f579c36
Fix issue with wrong `bun run dev` script
ebma Jun 11, 2026
1bf3d3f
Remove 'type: "BAD_REQUEST"'
ebma Jun 11, 2026
dd0eced
Address PR review findings
ebma Jun 11, 2026
dbd7b9a
Potential fix for pull request finding
ebma Jun 11, 2026
166eabb
Merge pull request #1216 from pendulum-chain/improve-no-liquidity-err…
ebma Jun 11, 2026
9bc49c8
Reject AssetHub CBU Alfredpay quotes
ebma Jun 11, 2026
70f3bde
Fix ramp terminal transaction metadata
ebma Jun 11, 2026
d0deec3
Align frontend ramp terminal handling
ebma Jun 11, 2026
1df2a8e
Normalize Argentina KYC validation
ebma Jun 11, 2026
48cad30
Sanitize Argentina KYC phone input
ebma Jun 11, 2026
742959f
Merge pull request #1220 from pendulum-chain/minor-improvements
ebma Jun 11, 2026
f1f23f2
Remove hydration from ephemeral check (as it's not used atm)
ebma Jun 12, 2026
e9b7f73
Allow overriding the substrate network WSS urls
ebma Jun 12, 2026
e4dc192
Potential fix for pull request finding
ebma Jun 12, 2026
07c58d0
Merge pull request #1221 from pendulum-chain/substrate-network-improv…
ebma Jun 12, 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: 1 addition & 1 deletion .clinerules/01-general-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

## Architecture Decision Records

Create ADRs in /docs/adr for:
Create ADRs in /docs/adr for:

- Major dependency changes
- Architectural pattern changes
Expand Down
4 changes: 4 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ DB_SSL_CA_CERT_PATH=
# Blockchain
AMPLITUDE_WSS=wss://rpc-amplitude.pendulumchain.tech
PENDULUM_WSS=wss://rpc-pendulum.prd.pendulumchain.tech
# Optional Substrate RPC overrides. Comma-separate multiple URLs for failover.
ASSETHUB_WSS=wss://dot-rpc.stakeworld.io/assethub
HYDRATION_WSS=wss://rpc.hydradx.cloud
MOONBEAM_WSS=wss://wss.api.moonbeam.network,wss://moonbeam.api.onfinality.io/public-ws,wss://moonbeam.ibp.network
FUNDING_SECRET=your-funding-secret
PENDULUM_FUNDING_SEED=your-pendulum-funding-seed
MOONBEAM_EXECUTOR_PRIVATE_KEY=your-moonbeam-executor-private-key
Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
"name": "vortex-backend",
"scripts": {
"build": "bun run swc src -d dist --strip-leading-paths",
"dev": "concurrently -n 'shared,backend' -c '#ffa500,007755' 'cd ../../packages/shared && bun run dev' 'NODE_ENV=development bun --watch src/index.ts'",
"dev": "NODE_ENV=development bun --watch src/index.ts",
"migrate": "bun -r @swc-node/register src/database/migrator.ts",
"migrate:revert": "bun -r @swc-node/register src/database/migrator.ts revert-all",
"migrate:revert-last": "bun -r @swc-node/register src/database/migrator.ts revert",
Expand Down
73 changes: 65 additions & 8 deletions apps/api/src/api/controllers/alfredpay.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,20 @@ export class AlfredpayController {

const alfredpayService = AlfredpayApiService.getInstance();

const newCustomer = await alfredpayService.createCustomer(userEmail, AlfredpayCustomerType.INDIVIDUAL, country);
const customerId = newCustomer.customerId;
let customerId: string;
try {
const newCustomer = await alfredpayService.createCustomer(userEmail, AlfredpayCustomerType.INDIVIDUAL, country);
customerId = newCustomer.customerId;
} catch (error) {
const errorMessage = (error as Error)?.message || "";
if (errorMessage.includes("409") || errorMessage.includes("already registered")) {
logger.info("Customer already exists in Alfredpay, fetching existing customer");
const existingCustomer = await alfredpayService.findCustomer(userEmail, country);
customerId = existingCustomer.customerId;
} else {
throw error;
}
}

await AlfredPayCustomer.create({
alfredPayId: customerId,
Expand Down Expand Up @@ -360,7 +372,7 @@ export class AlfredpayController {
const linkResponse = await alfredpayService.getKybRedirectLink(alfredPayCustomer.alfredPayId);
await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted });
return res.json(linkResponse as AlfredpayGetKybRedirectLinkResponse);
} else if (country === "MX" || country === "CO") {
} else if (country === "MX" || country === "CO" || country === "AR") {
// MX/CO use API-based (form) KYC — no redirect link needed.
// Just reset status so the user can re-fill the form.
await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted });
Expand Down Expand Up @@ -399,8 +411,20 @@ export class AlfredpayController {

const alfredpayService = AlfredpayApiService.getInstance();

const newCustomer = await alfredpayService.createCustomer(userEmail, type, country);
const customerId = newCustomer.customerId;
let customerId: string;
try {
const newCustomer = await alfredpayService.createCustomer(userEmail, type, country);
customerId = newCustomer.customerId;
} catch (error) {
const errorMessage = (error as Error)?.message || "";
if (errorMessage.includes("409") || errorMessage.includes("already registered")) {
logger.info("Business customer already exists in Alfredpay, fetching existing customer");
const existingCustomer = await alfredpayService.findCustomer(userEmail, country);
customerId = existingCustomer.customerId;
} else {
throw error;
}
}

await AlfredPayCustomer.create({
alfredPayId: customerId,
Expand Down Expand Up @@ -463,7 +487,7 @@ export class AlfredpayController {

static async submitKycInformation(req: Request, res: Response) {
try {
const { country, ...kycData } = req.body as SubmitKycInformationRequest & { country: string };
const { country, ...kycData } = req.body as SubmitKycInformationRequest;
const userId = req.userId!;

const alfredPayCustomer = await AlfredPayCustomer.findOne({
Expand All @@ -475,7 +499,21 @@ export class AlfredpayController {
}

const alfredpayService = AlfredpayApiService.getInstance();
const result = await alfredpayService.submitKycInformation(alfredPayCustomer.alfredPayId, { ...kycData, country });
let result: Awaited<ReturnType<typeof alfredpayService.submitKycInformation>>;
try {
result = await alfredpayService.submitKycInformation(alfredPayCustomer.alfredPayId, { ...kycData, country });
} catch (error) {
const errorMessage = (error as Error)?.message || "";
if (errorMessage.includes("422") && errorMessage.includes("KYC record cannot be retried")) {
logger.info("KYC record cannot be retried, fetching existing submission");
const existingSubmission = await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId);
result = { submissionId: existingSubmission.submissionId } as Awaited<
ReturnType<typeof alfredpayService.submitKycInformation>
>;
} else {
throw error;
}
}

res.json(result);
} catch (error) {
Expand Down Expand Up @@ -557,7 +595,21 @@ export class AlfredpayController {
}

const alfredpayService = AlfredpayApiService.getInstance();
const result = await alfredpayService.submitKybInformation(alfredPayCustomer.alfredPayId, { ...kybData, country });
let result: Awaited<ReturnType<typeof alfredpayService.submitKybInformation>>;
try {
result = await alfredpayService.submitKybInformation(alfredPayCustomer.alfredPayId, { ...kybData, country });
} catch (error) {
const errorMessage = (error as Error)?.message || "";
if (errorMessage.includes("422") && errorMessage.includes("KYC record cannot be retried")) {
logger.info("KYB record cannot be retried, fetching existing submission");
const existingSubmission = await alfredpayService.getLastKybSubmission(alfredPayCustomer.alfredPayId);
result = { submissionId: existingSubmission.submissionId } as Awaited<
ReturnType<typeof alfredpayService.submitKybInformation>
>;
} else {
throw error;
}
}

res.json(result);
} catch (error) {
Expand Down Expand Up @@ -749,6 +801,11 @@ export class AlfredpayController {
accountType: accountType ?? "",
metadata: { accountHolderName: accountName, documentNumber, documentType }
};
} else if (alfredpayFiatAccountType === AlfredpayFiatAccountType.COELSA) {
fiatAccountFields = {
accountNumber,
accountType: accountType ?? ""
};
} else {
// BANK_USA — external accounts need address fields inside metadata
fiatAccountFields = isExternal
Expand Down
45 changes: 35 additions & 10 deletions apps/api/src/api/controllers/quote.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import { NextFunction, Request, Response } from "express";
import httpStatus from "http-status";
import logger from "../../config/logger";
import { APIError } from "../errors/api-error";
import { observeApiClientEvent } from "../observability/apiClientEvent.service";
import {
buildApiClientRequestMetadata,
getSafeApiKeyPrefix,
observeApiClientEvent
} from "../observability/apiClientEvent.service";
import { classifyApiClientError, getErrorMessage } from "../observability/errorClassifier";
import { getRequestDurationMs } from "../observability/requestContext";
import quoteService from "../services/quote";
Expand Down Expand Up @@ -57,7 +61,7 @@ export const createQuote = async (
});

observeApiClientEvent({
apiKeyPrefix: getSafePublicKeyPrefix(publicApiKey),
apiKeyPrefix: getSafeApiKeyPrefix(publicApiKey, ["pk_"]),
durationMs: getRequestDurationMs(req),
httpStatus: httpStatus.CREATED,
network,
Expand All @@ -76,7 +80,7 @@ export const createQuote = async (
} catch (error) {
logger.error("Error creating quote", { errorType: classifyApiClientError(error), requestId: req.requestId });
observeQuoteFailure(req, "quote_create", error, {
apiKeyPrefix: getSafePublicKeyPrefix(req.body?.apiKey || req.validatedPublicKey?.apiKey),
apiKeyPrefix: getSafeApiKeyPrefix(req.body?.apiKey || req.validatedPublicKey?.apiKey, ["pk_"]),
network: getNetworkFromDestination(req.body?.rampType === RampDirection.BUY ? req.body?.to : req.body?.from),
partnerId: req.authenticatedPartner?.id || req.body?.partnerId || null,
partnerName: req.authenticatedPartner?.name || req.validatedPublicKey?.partnerName || null,
Expand Down Expand Up @@ -121,7 +125,7 @@ export const createBestQuote = async (
});

observeApiClientEvent({
apiKeyPrefix: getSafePublicKeyPrefix(publicApiKey),
apiKeyPrefix: getSafeApiKeyPrefix(publicApiKey, ["pk_"]),
durationMs: getRequestDurationMs(req),
httpStatus: httpStatus.CREATED,
network: quote.network,
Expand All @@ -140,7 +144,7 @@ export const createBestQuote = async (
} catch (error) {
logger.error("Error creating best quote", { errorType: classifyApiClientError(error), requestId: req.requestId });
observeQuoteFailure(req, "quote_create_best", error, {
apiKeyPrefix: getSafePublicKeyPrefix(req.body?.apiKey || req.validatedPublicKey?.apiKey),
apiKeyPrefix: getSafeApiKeyPrefix(req.body?.apiKey || req.validatedPublicKey?.apiKey, ["pk_"]),
partnerId: req.authenticatedPartner?.id || req.body?.partnerId || null,
partnerName: req.authenticatedPartner?.name || req.validatedPublicKey?.partnerName || null,
rampType: req.body?.rampType
Expand Down Expand Up @@ -194,6 +198,11 @@ export const getQuote = async (
type QuoteOperation = "quote_create" | "quote_create_best" | "quote_get";

interface ObservedQuoteRequest {
body?: unknown;
method?: string;
params?: unknown;
path?: string;
query?: unknown;
requestId?: string;
requestStartedAt?: number;
userId?: string;
Expand All @@ -220,18 +229,34 @@ function observeQuoteFailure(
errorMessage: getErrorMessage(error),
errorType: classifyApiClientError(error, status),
httpStatus: status,
metadata: buildQuoteRequestMetadata(req, operation),
operation,
requestId: req.requestId,
status: "failure",
userId: req.userId || null
});
}

function getHttpStatus(error: unknown): number {
return error instanceof APIError ? error.status || httpStatus.INTERNAL_SERVER_ERROR : httpStatus.INTERNAL_SERVER_ERROR;
function buildQuoteRequestMetadata(req: ObservedQuoteRequest, operation: QuoteOperation): Record<string, unknown> {
if (operation === "quote_get") {
return buildApiClientRequestMetadata(req, { paramKeys: ["id"] });
}

return buildApiClientRequestMetadata(req, {
bodyKeys: [
"countryCode",
"from",
"inputAmount",
"inputCurrency",
"networks",
"outputCurrency",
"partnerId",
"rampType",
"to"
]
});
}

function getSafePublicKeyPrefix(apiKey: string | null | undefined): string | null {
if (!apiKey?.startsWith("pk_")) return null;
return apiKey.slice(0, 8);
function getHttpStatus(error: unknown): number {
return error instanceof APIError ? error.status || httpStatus.INTERNAL_SERVER_ERROR : httpStatus.INTERNAL_SERVER_ERROR;
}
28 changes: 27 additions & 1 deletion apps/api/src/api/controllers/ramp.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import logger from "../../config/logger";
import { APIError } from "../errors/api-error";
import { enrichAdditionalDataWithClientIp } from "../helpers/clientIp";
import { assertQuoteOwnership, assertRampOwnership } from "../middlewares/ownershipAuth";
import { observeApiClientEvent } from "../observability/apiClientEvent.service";
import { buildApiClientRequestMetadata, observeApiClientEvent } from "../observability/apiClientEvent.service";
import { classifyApiClientError, getErrorMessage } from "../observability/errorClassifier";
import { getRequestDurationMs } from "../observability/requestContext";
import { ApiClientOperation } from "../observability/types";
Expand Down Expand Up @@ -288,6 +288,11 @@ interface RampObservationContext {

interface ObservedRampRequest {
authenticatedPartner?: { id: string; name: string };
body?: unknown;
method?: string;
params?: unknown;
path?: string;
query?: unknown;
requestId?: string;
requestStartedAt?: number;
userId?: string;
Expand Down Expand Up @@ -325,6 +330,7 @@ function observeRampFailure(
errorMessage: getErrorMessage(error),
errorType: classifyApiClientError(error, status),
httpStatus: status,
metadata: buildRampRequestMetadata(req, operation),
operation,
partnerId: req.authenticatedPartner?.id || null,
partnerName: req.authenticatedPartner?.name || null,
Expand All @@ -334,6 +340,26 @@ function observeRampFailure(
});
}

function buildRampRequestMetadata(req: ObservedRampRequest, operation: RampObservedOperation): Record<string, unknown> {
if (operation === "ramp_register") {
return buildApiClientRequestMetadata(req, { bodyKeys: ["quoteId", "signingAccounts", "additionalData"] });
}

if (operation === "ramp_update") {
return buildApiClientRequestMetadata(req, { bodyKeys: ["rampId", "presignedTxs", "additionalData"] });
}

if (operation === "ramp_start") {
return buildApiClientRequestMetadata(req, { bodyKeys: ["rampId"] });
}

if (operation === "ramp_status") {
return buildApiClientRequestMetadata(req, { paramKeys: ["id"], queryKeys: ["showUnsignedTxs"] });
}

return buildApiClientRequestMetadata(req, { paramKeys: ["id"] });
}

function getHttpStatus(error: unknown): number {
return error instanceof APIError ? error.status || httpStatus.INTERNAL_SERVER_ERROR : httpStatus.INTERNAL_SERVER_ERROR;
}
6 changes: 4 additions & 2 deletions apps/api/src/api/errors/api-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface APIErrorParams {
stack?: string;
status?: number;
isPublic?: boolean;
type?: string;
}

/**
Expand All @@ -20,13 +21,14 @@ export class APIError extends ExtendableError {
* @param {number} status - HTTP status code of error.
* @param {boolean} isPublic - Whether the message should be visible to user or not.
*/
constructor({ message, errors, stack, status = httpStatus.INTERNAL_SERVER_ERROR, isPublic = false }: APIErrorParams) {
constructor({ message, errors, stack, status = httpStatus.INTERNAL_SERVER_ERROR, isPublic = false, type }: APIErrorParams) {
super({
errors,
isPublic,
message,
stack,
status
status,
type
});
}
}
6 changes: 5 additions & 1 deletion apps/api/src/api/errors/extendable-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ interface ExtendableErrorParams {
status?: number;
isPublic?: boolean;
stack?: string;
type?: string;
}

/**
Expand All @@ -19,7 +20,9 @@ class ExtendableError extends Error {

readonly isOperational: boolean;

constructor({ message, errors, status, isPublic = false, stack }: ExtendableErrorParams) {
readonly type?: string;

constructor({ message, errors, status, isPublic = false, stack, type }: ExtendableErrorParams) {
super(message);
this.name = this.constructor.name;
this.message = message;
Expand All @@ -28,6 +31,7 @@ class ExtendableError extends Error {
this.isPublic = isPublic;
this.isOperational = true;
this.stack = stack;
this.type = type;
// Error.captureStackTrace(this, this.constructor.name);
}
}
Expand Down
Loading
Loading