Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion src/constants/errorCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export const ERROR_CODES = {
RATE_LIMIT: "RATE_LIMIT",
/** Destination Stellar account has not established a trustline for the payment asset. */
TRUSTLINE_MISSING: "TRUSTLINE_MISSING",
/** Standardized code returned when a recipient lacks a required Stellar trustline. */
ERR_MISSING_TRUSTLINE: "ERR_MISSING_TRUSTLINE",

// Server errors (5000+) - HTTP 500+
INTERNAL_ERROR: "INTERNAL_ERROR",
Expand Down Expand Up @@ -134,7 +136,8 @@ export const getHttpStatus = (code: string): number => {
code === ERROR_CODES.INSUFFICIENT_BALANCE ||
code === ERROR_CODES.INSUFFICIENT_FUNDS ||
code === ERROR_CODES.TRANSACTION_FAILED ||
code === ERROR_CODES.TRUSTLINE_MISSING
code === ERROR_CODES.TRUSTLINE_MISSING ||
code === ERROR_CODES.ERR_MISSING_TRUSTLINE
) {
return 400;
}
Expand Down
2 changes: 2 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"INTERNAL_ERROR": "Internal server error",
"SERVICE_UNAVAILABLE": "Service temporarily unavailable",
"DATABASE_ERROR": "Database operation failed",
"TRUSTLINE_MISSING": "Recipient account is missing a required Stellar trustline for the payment asset",
"ERR_MISSING_TRUSTLINE": "Recipient account is missing a Stellar trustline for the payment asset. Use the Change Trust XDR in the response to establish it.",
"DEFAULT": "An error occurred"
},
"sms": {
Expand Down
2 changes: 2 additions & 0 deletions src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"INTERNAL_ERROR": "Error interno del servidor",
"SERVICE_UNAVAILABLE": "El servicio no esta disponible temporalmente",
"DATABASE_ERROR": "La operacion de base de datos fallo",
"TRUSTLINE_MISSING": "La cuenta del destinatario no tiene una linea de confianza Stellar requerida para el activo de pago",
"ERR_MISSING_TRUSTLINE": "La cuenta del destinatario no tiene una linea de confianza Stellar para el activo de pago. Use el XDR Change Trust proporcionado para establecerla.",
"DEFAULT": "Ocurrio un error"
},
"sms": {
Expand Down
2 changes: 2 additions & 0 deletions src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"INTERNAL_ERROR": "Erreur interne du serveur",
"SERVICE_UNAVAILABLE": "Le service est temporairement indisponible",
"DATABASE_ERROR": "L operation de base de donnees a echoue",
"TRUSTLINE_MISSING": "Le compte destinataire n a pas de ligne de confiance Stellar requise pour l actif de paiement",
"ERR_MISSING_TRUSTLINE": "Le compte destinataire n a pas de ligne de confiance Stellar pour l actif de paiement. Utilisez le XDR Change Trust fourni pour l etablir.",
"DEFAULT": "Une erreur est survenue"
},
"sms": {
Expand Down
2 changes: 2 additions & 0 deletions src/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"INTERNAL_ERROR": "Erro interno do servidor",
"SERVICE_UNAVAILABLE": "O servico esta temporariamente indisponivel",
"DATABASE_ERROR": "Falha na operacao do banco de dados",
"TRUSTLINE_MISSING": "A conta do destinatario nao possui uma linha de confianca Stellar necessaria para o ativo de pagamento",
"ERR_MISSING_TRUSTLINE": "A conta do destinatario nao possui uma linha de confianca Stellar para o ativo de pagamento. Use o XDR Change Trust fornecido para estabelece-la.",
"DEFAULT": "Ocorreu um erro"
},
"sms": {
Expand Down
2 changes: 2 additions & 0 deletions src/locales/sw.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"INTERNAL_ERROR": "Hitilafu ya ndani ya seva",
"SERVICE_UNAVAILABLE": "Huduma haipatikani kwa muda",
"DATABASE_ERROR": "Uendeshaji wa hifadhidata umeshindikana",
"TRUSTLINE_MISSING": "Akaunti ya mpokeaji haina mstari wa uaminifu wa Stellar unaohitajika kwa mali ya malipo",
"ERR_MISSING_TRUSTLINE": "Akaunti ya mpokeaji haina mstari wa uaminifu wa Stellar kwa mali ya malipo. Tumia XDR ya Change Trust iliyotolewa kuianzisha.",
"DEFAULT": "Hitilafu imetokea"
},
"sms": {
Expand Down
6 changes: 5 additions & 1 deletion src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface AppError extends Error {
code?: string;
statusCode?: number;
details?: Record<string, unknown>;
/** Always included in the API response regardless of NODE_ENV (for critical user-facing data). */
meta?: Record<string, unknown>;
locale?: string;
requestId?: string;
}
Expand Down Expand Up @@ -183,14 +185,15 @@ export const errorHandler = (
}, 'Request Error');

const details = extractLegacyDetails(err);
const body: ErrorResponse & { statusCode: number; error?: string } = {
const body: ErrorResponse & { statusCode: number; error?: string; meta?: Record<string, unknown> } = {
code: errorCode,
message: localizedMessage,
message_en: englishMessage,
timestamp: new Date().toISOString(),
statusCode,
requestId,
details,
meta: err.meta,
};

if (details && typeof details === "object" && typeof details.error === "string") {
Expand All @@ -203,6 +206,7 @@ export const errorHandler = (

if (process.env.NODE_ENV === "production") {
delete body.details;
// meta is intentionally kept — it contains user-actionable data (e.g. changeTrustXdr)
}

res.status(statusCode).json(body);
Expand Down
16 changes: 5 additions & 11 deletions src/services/stellar/stellarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { transactionTotal, transactionErrorsTotal } from "../../utils/metrics";
import { AssetService, getConfiguredPaymentAsset } from "./assetService";
import { sanctionService } from "../sanctionService";
import { resolveToBaseAddress } from "../../stellar/muxed";
import { validateRecipientTrustline } from "../../stellar/trustlineValidation";

dotenv.config();

Expand Down Expand Up @@ -182,17 +183,10 @@ export class StellarService {

// REAL MODE
const paymentAsset = getConfiguredPaymentAsset();
if (!paymentAsset.isNative()) {
const trusted = await this.assetService.hasTrustline(
resolvedDestinationAddress,
paymentAsset,
);
if (!trusted) {
throw new Error(
`Recipient has no trustline for ${paymentAsset.getCode()}. Add a trustline before paying this asset.`,
);
}
}

// Throws MissingTrustlineError (ERR_MISSING_TRUSTLINE) with a changeTrustXdr
// when the recipient lacks a trustline; result is Redis-cached for 60 s.
await validateRecipientTrustline(resolvedDestinationAddress, paymentAsset);

const account = await this.server.loadAccount(
this.issuerKeypair.publicKey(),
Expand Down
59 changes: 31 additions & 28 deletions src/stellar/__tests__/payments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
SlippageError,
PathPaymentParams,
} from "../payments";
import { MissingTrustlineError } from "../trustlineValidation";

// ── Mocks ─────────────────────────────────────────────────────────────────────

Expand All @@ -13,17 +14,21 @@ jest.mock("../../config/stellar", () => ({
getNetworkPassphrase: jest.fn(() => StellarSdk.Networks.TESTNET),
}));

jest.mock("../../services/stellar/assetService", () => ({
AssetService: jest.fn().mockImplementation(() => ({
hasTrustline: jest.fn().mockResolvedValue(true),
})),
}));
// validateRecipientTrustline is mocked to resolve by default (trustline present).
// Individual tests override this to simulate a missing trustline.
jest.mock("../trustlineValidation", () => {
const actual = jest.requireActual("../trustlineValidation");
return {
...actual,
validateRecipientTrustline: jest.fn().mockResolvedValue(undefined),
};
});

import { getStellarServer } from "../../config/stellar";
import { AssetService } from "../../services/stellar/assetService";
import { validateRecipientTrustline } from "../trustlineValidation";

const mockGetStellarServer = getStellarServer as jest.Mock;
const mockAssetService = AssetService as jest.Mock;
const mockGetStellarServer = getStellarServer as jest.Mock;
const mockValidateTrustline = validateRecipientTrustline as jest.Mock;

// ── Fixtures ──────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -66,6 +71,9 @@ function mockServer(overrides: Partial<Record<string, jest.Mock>> = {}) {
const loadAccount = jest.fn().mockResolvedValue({
account_id: senderKeypair.publicKey(),
sequence: "1000",
// TransactionBuilder.build() requires these three Account methods
sequenceNumber: () => "1000",
accountId: () => senderKeypair.publicKey(),
incrementSequenceNumber: jest.fn(),
balances: [],
});
Expand Down Expand Up @@ -106,9 +114,9 @@ describe("findPaymentPaths", () => {
);

expect(server.strictReceivePaths).toHaveBeenCalledWith(
xafAsset,
[xafAsset],
usdcAsset,
"5",
[destinationAccount],
);
expect(paths).toHaveLength(1);
expect(paths[0].destination_asset_code).toBe("USDC");
Expand Down Expand Up @@ -211,10 +219,8 @@ describe("findPaymentPaths", () => {

describe("executePathPayment", () => {
beforeEach(() => {
// Reset AssetService mock to return hasTrustline: true by default
mockAssetService.mockImplementation(() => ({
hasTrustline: jest.fn().mockResolvedValue(true),
}));
// validateRecipientTrustline resolves (trustline present) by default
mockValidateTrustline.mockResolvedValue(undefined);
});

it("submits a PathPaymentStrictReceive and returns hash and ledger", async () => {
Expand All @@ -228,34 +234,31 @@ describe("executePathPayment", () => {
expect(result.ledger).toBe(55);
});

it("throws an error when the destination has no trustline for destAsset", async () => {
mockAssetService.mockImplementation(() => ({
hasTrustline: jest.fn().mockResolvedValue(false),
}));
it("throws MissingTrustlineError when the destination has no trustline for destAsset", async () => {
const missingError = new MissingTrustlineError({
recipientAddress: "GDEST...",
assetCode: "USDC",
assetIssuer: USDC_ISSUER,
changeTrustXdr: "AAAA...",
});
mockValidateTrustline.mockRejectedValue(missingError);

const server = mockServer();
mockGetStellarServer.mockReturnValue(server);

await expect(executePathPayment(makeParams())).rejects.toThrow(
"Destination has no trustline",
);
await expect(executePathPayment(makeParams())).rejects.toThrow(MissingTrustlineError);
expect(server.submitTransaction).not.toHaveBeenCalled();
});

it("skips the trustline check when destAsset is native XLM", async () => {
const hasTrustlineMock = jest.fn();
mockAssetService.mockImplementation(() => ({
hasTrustline: hasTrustlineMock,
}));

it("skips the trustline validation when destAsset is native XLM", async () => {
const server = mockServer();
mockGetStellarServer.mockReturnValue(server);

await executePathPayment(
makeParams({ destAsset: StellarSdk.Asset.native(), destAmount: "10" }),
);

expect(hasTrustlineMock).not.toHaveBeenCalled();
// validateRecipientTrustline is still called but internally short-circuits for native
expect(server.submitTransaction).toHaveBeenCalledTimes(1);
});

Expand Down
Loading
Loading