diff --git a/src/constants/errorCodes.ts b/src/constants/errorCodes.ts index 0475d667..5458b442 100644 --- a/src/constants/errorCodes.ts +++ b/src/constants/errorCodes.ts @@ -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", @@ -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; } diff --git a/src/locales/en.json b/src/locales/en.json index 3186f302..f0ffa86a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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": { diff --git a/src/locales/es.json b/src/locales/es.json index b24c31ec..0cc7854f 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -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": { diff --git a/src/locales/fr.json b/src/locales/fr.json index 11b9a2d3..0d70dce0 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -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": { diff --git a/src/locales/pt.json b/src/locales/pt.json index ba1353bb..73fd295d 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -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": { diff --git a/src/locales/sw.json b/src/locales/sw.json index fbb3ae9d..49a0e1ac 100644 --- a/src/locales/sw.json +++ b/src/locales/sw.json @@ -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": { diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index dbadcfb5..2dcf152c 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -24,6 +24,8 @@ export interface AppError extends Error { code?: string; statusCode?: number; details?: Record; + /** Always included in the API response regardless of NODE_ENV (for critical user-facing data). */ + meta?: Record; locale?: string; requestId?: string; } @@ -183,7 +185,7 @@ 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 } = { code: errorCode, message: localizedMessage, message_en: englishMessage, @@ -191,6 +193,7 @@ export const errorHandler = ( statusCode, requestId, details, + meta: err.meta, }; if (details && typeof details === "object" && typeof details.error === "string") { @@ -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); diff --git a/src/services/stellar/stellarService.ts b/src/services/stellar/stellarService.ts index c06df2ae..256850d9 100644 --- a/src/services/stellar/stellarService.ts +++ b/src/services/stellar/stellarService.ts @@ -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(); @@ -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(), diff --git a/src/stellar/__tests__/payments.test.ts b/src/stellar/__tests__/payments.test.ts index feb1b2a7..4b839ca5 100644 --- a/src/stellar/__tests__/payments.test.ts +++ b/src/stellar/__tests__/payments.test.ts @@ -5,6 +5,7 @@ import { SlippageError, PathPaymentParams, } from "../payments"; +import { MissingTrustlineError } from "../trustlineValidation"; // ── Mocks ───────────────────────────────────────────────────────────────────── @@ -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 ────────────────────────────────────────────────────────────────── @@ -66,6 +71,9 @@ function mockServer(overrides: Partial> = {}) { 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: [], }); @@ -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"); @@ -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 () => { @@ -228,26 +234,23 @@ 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); @@ -255,7 +258,7 @@ describe("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); }); diff --git a/src/stellar/__tests__/trustlineValidation.test.ts b/src/stellar/__tests__/trustlineValidation.test.ts new file mode 100644 index 00000000..9b78abee --- /dev/null +++ b/src/stellar/__tests__/trustlineValidation.test.ts @@ -0,0 +1,363 @@ +import * as StellarSdk from "stellar-sdk"; +import { + validateRecipientTrustline, + MissingTrustlineError, +} from "../trustlineValidation"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +jest.mock("../../config/stellar", () => ({ + getStellarServer: jest.fn(), + getNetworkPassphrase: jest.fn().mockReturnValue("Test SDF Network ; September 2015"), +})); + +jest.mock("../../services/layeredCache", () => ({ + layeredCache: { + get: jest.fn(), + set: jest.fn(), + }, +})); + +jest.mock("../muxed", () => ({ + resolveToBaseAddress: jest.fn((addr: string) => addr), +})); + +import { getStellarServer } from "../../config/stellar"; +import { layeredCache } from "../../services/layeredCache"; + +const mockGetStellarServer = getStellarServer as jest.Mock; +const mockCacheGet = layeredCache.get as jest.Mock; +const mockCacheSet = layeredCache.set as jest.Mock; + +const mockLoadAccount = jest.fn(); +const mockSubmitTransaction = jest.fn(); + +const mockServer = { + loadAccount: mockLoadAccount, + submitTransaction: mockSubmitTransaction, +}; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; +const USDC = new StellarSdk.Asset("USDC", ISSUER); +const XLM = StellarSdk.Asset.native(); + +const recipientKeypair = StellarSdk.Keypair.random(); +const recipientAddress = recipientKeypair.publicKey(); + +function makeAccount(trustedAssets: StellarSdk.Asset[] = []) { + return { + id: recipientAddress, + account_id: recipientAddress, + sequence: "100", + // TransactionBuilder.build() requires these three Account methods + sequenceNumber: () => "100", + accountId: () => recipientAddress, + incrementSequenceNumber: jest.fn(), + balances: [ + { asset_type: "native", balance: "10.0000000" }, + ...trustedAssets.map((a) => ({ + asset_type: a.getCode().length <= 4 ? "credit_alphanum4" : "credit_alphanum12", + asset_code: a.getCode(), + asset_issuer: a.getIssuer(), + balance: "0.0000000", + limit: "922337203685.4775807", + })), + ], + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockGetStellarServer.mockReturnValue(mockServer); + mockCacheGet.mockResolvedValue(null); // cache miss by default + mockCacheSet.mockResolvedValue(undefined); + mockSubmitTransaction.mockResolvedValue({ hash: "abc", ledger: 1 }); +}); + +// ── Native XLM (no trustline required) ─────────────────────────────────────── + +describe("validateRecipientTrustline – native XLM", () => { + it("resolves immediately without querying Horizon or the cache", async () => { + await expect(validateRecipientTrustline(recipientAddress, XLM)).resolves.toBeUndefined(); + expect(mockCacheGet).not.toHaveBeenCalled(); + expect(mockLoadAccount).not.toHaveBeenCalled(); + }); +}); + +// ── Cache hit: trustline present ────────────────────────────────────────────── + +describe("validateRecipientTrustline – cache hit (trusted)", () => { + it("returns without calling Horizon when the cache says the trustline exists", async () => { + mockCacheGet.mockResolvedValue(true); + + await expect(validateRecipientTrustline(recipientAddress, USDC)).resolves.toBeUndefined(); + + expect(mockLoadAccount).not.toHaveBeenCalled(); + expect(mockCacheSet).not.toHaveBeenCalled(); + }); +}); + +// ── Cache hit: trustline missing ────────────────────────────────────────────── + +describe("validateRecipientTrustline – cache hit (missing)", () => { + it("throws MissingTrustlineError with XDR when cache says trustline is absent", async () => { + mockCacheGet.mockResolvedValue(false); + // loadAccount is called to build the fresh XDR + mockLoadAccount.mockResolvedValue(makeAccount()); + + await expect( + validateRecipientTrustline(recipientAddress, USDC), + ).rejects.toThrow(MissingTrustlineError); + + expect(mockLoadAccount).toHaveBeenCalledTimes(1); + expect(mockCacheSet).not.toHaveBeenCalled(); // no re-caching on hit path + }); + + it("includes assetCode, assetIssuer, and changeTrustXdr in the error", async () => { + mockCacheGet.mockResolvedValue(false); + mockLoadAccount.mockResolvedValue(makeAccount()); + + let error: MissingTrustlineError | undefined; + try { + await validateRecipientTrustline(recipientAddress, USDC); + } catch (err) { + error = err as MissingTrustlineError; + } + + expect(error).toBeInstanceOf(MissingTrustlineError); + expect(error!.assetCode).toBe("USDC"); + expect(error!.assetIssuer).toBe(ISSUER); + expect(error!.changeTrustXdr).toBeTruthy(); + expect(typeof error!.changeTrustXdr).toBe("string"); + }); +}); + +// ── Cache miss: trustline present ──────────────────────────────────────────── + +describe("validateRecipientTrustline – cache miss (trusted)", () => { + it("queries Horizon, caches true, and resolves when the trustline exists", async () => { + mockCacheGet.mockResolvedValue(null); + mockLoadAccount.mockResolvedValue(makeAccount([USDC])); + + await expect(validateRecipientTrustline(recipientAddress, USDC)).resolves.toBeUndefined(); + + expect(mockLoadAccount).toHaveBeenCalledTimes(1); + expect(mockCacheSet).toHaveBeenCalledWith( + expect.stringContaining("trustline:validation:"), + true, + 60, + ); + }); +}); + +// ── Cache miss: trustline missing ───────────────────────────────────────────── + +describe("validateRecipientTrustline – cache miss (missing)", () => { + it("queries Horizon, caches false, and throws MissingTrustlineError", async () => { + mockCacheGet.mockResolvedValue(null); + mockLoadAccount.mockResolvedValue(makeAccount()); // no USDC trustline + + await expect( + validateRecipientTrustline(recipientAddress, USDC), + ).rejects.toThrow(MissingTrustlineError); + + expect(mockCacheSet).toHaveBeenCalledWith( + expect.stringContaining("trustline:validation:"), + false, + 60, + ); + }); + + it("error code is ERR_MISSING_TRUSTLINE", async () => { + mockCacheGet.mockResolvedValue(null); + mockLoadAccount.mockResolvedValue(makeAccount()); + + let error: MissingTrustlineError | undefined; + try { + await validateRecipientTrustline(recipientAddress, USDC); + } catch (err) { + error = err as MissingTrustlineError; + } + + expect(error!.code).toBe("ERR_MISSING_TRUSTLINE"); + }); + + it("error carries meta with changeTrustXdr, assetCode and assetIssuer", async () => { + mockCacheGet.mockResolvedValue(null); + mockLoadAccount.mockResolvedValue(makeAccount()); + + let error: MissingTrustlineError | undefined; + try { + await validateRecipientTrustline(recipientAddress, USDC); + } catch (err) { + error = err as MissingTrustlineError; + } + + expect(error!.meta).toMatchObject({ + assetCode: "USDC", + assetIssuer: ISSUER, + changeTrustXdr: expect.any(String), + }); + }); + + it("XDR decodes to a ChangeTrust operation for the correct asset", async () => { + mockCacheGet.mockResolvedValue(null); + mockLoadAccount.mockResolvedValue(makeAccount()); + + let error: MissingTrustlineError | undefined; + try { + await validateRecipientTrustline(recipientAddress, USDC); + } catch (err) { + error = err as MissingTrustlineError; + } + + const envelope = StellarSdk.TransactionBuilder.fromXDR( + error!.changeTrustXdr, + "Test SDF Network ; September 2015", + ); + const tx = envelope as StellarSdk.Transaction; + expect(tx.operations).toHaveLength(1); + expect(tx.operations[0].type).toBe("changeTrust"); + const op = tx.operations[0] as StellarSdk.Operation.ChangeTrust; + expect((op.line as StellarSdk.Asset).getCode()).toBe("USDC"); + expect((op.line as StellarSdk.Asset).getIssuer()).toBe(ISSUER); + }); + + it("does not call Horizon again to build the XDR when loadAccount succeeded", async () => { + mockCacheGet.mockResolvedValue(null); + mockLoadAccount.mockResolvedValue(makeAccount()); + + await expect( + validateRecipientTrustline(recipientAddress, USDC), + ).rejects.toThrow(MissingTrustlineError); + + // loadAccount called exactly once (for the trustline check + XDR reuse) + expect(mockLoadAccount).toHaveBeenCalledTimes(1); + }); +}); + +// ── Account not found on Stellar ────────────────────────────────────────────── + +describe("validateRecipientTrustline – account does not exist (404)", () => { + it("treats 404 as missing trustline, caches false, and throws MissingTrustlineError", async () => { + mockCacheGet.mockResolvedValue(null); + // First loadAccount (trustline check) → 404 + // Second loadAccount (XDR build) → 404 → falls back to dummy sequence + mockLoadAccount + .mockRejectedValueOnce({ response: { status: 404 } }) // check + .mockRejectedValueOnce({ response: { status: 404 } }); // XDR build + + await expect( + validateRecipientTrustline(recipientAddress, USDC), + ).rejects.toThrow(MissingTrustlineError); + + expect(mockCacheSet).toHaveBeenCalledWith( + expect.stringContaining("trustline:validation:"), + false, + 60, + ); + }); + + it("still provides a changeTrustXdr template when the account does not exist", async () => { + mockCacheGet.mockResolvedValue(null); + mockLoadAccount.mockRejectedValue({ response: { status: 404 } }); + + let error: MissingTrustlineError | undefined; + try { + await validateRecipientTrustline(recipientAddress, USDC); + } catch (err) { + error = err as MissingTrustlineError; + } + + expect(error).toBeInstanceOf(MissingTrustlineError); + expect(typeof error!.changeTrustXdr).toBe("string"); + expect(error!.changeTrustXdr.length).toBeGreaterThan(0); + }); +}); + +// ── Unexpected Horizon errors ───────────────────────────────────────────────── + +describe("validateRecipientTrustline – unexpected Horizon error", () => { + it("re-throws non-404 Horizon errors without caching", async () => { + mockCacheGet.mockResolvedValue(null); + mockLoadAccount.mockRejectedValue(new Error("Horizon 500 Internal Server Error")); + + await expect( + validateRecipientTrustline(recipientAddress, USDC), + ).rejects.toThrow("Horizon 500 Internal Server Error"); + + expect(mockCacheSet).not.toHaveBeenCalled(); + }); +}); + +// ── Cache key structure ─────────────────────────────────────────────────────── + +describe("validateRecipientTrustline – cache key", () => { + it("uses a key that contains the recipient address, asset code, and issuer", async () => { + mockCacheGet.mockResolvedValue(null); + mockLoadAccount.mockResolvedValue(makeAccount([USDC])); + + await validateRecipientTrustline(recipientAddress, USDC); + + const [[cacheKey]] = mockCacheSet.mock.calls; + expect(cacheKey).toContain(recipientAddress); + expect(cacheKey).toContain("USDC"); + expect(cacheKey).toContain(ISSUER); + }); + + it("caches the result for exactly 60 seconds", async () => { + mockCacheGet.mockResolvedValue(null); + mockLoadAccount.mockResolvedValue(makeAccount([USDC])); + + await validateRecipientTrustline(recipientAddress, USDC); + + const [, , ttl] = mockCacheSet.mock.calls[0]; + expect(ttl).toBe(60); + }); +}); + +// ── MissingTrustlineError class ─────────────────────────────────────────────── + +describe("MissingTrustlineError", () => { + const params = { + recipientAddress, + assetCode: "USDC", + assetIssuer: ISSUER, + changeTrustXdr: "AAAAAQAAAAA...", + }; + + it("is an instance of Error and MissingTrustlineError", () => { + const err = new MissingTrustlineError(params); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(MissingTrustlineError); + }); + + it("has name MissingTrustlineError", () => { + const err = new MissingTrustlineError(params); + expect(err.name).toBe("MissingTrustlineError"); + }); + + it("has HTTP status 400", () => { + const err = new MissingTrustlineError(params); + expect(err.statusCode).toBe(400); + }); + + it("exposes assetCode, assetIssuer, recipientAddress, changeTrustXdr", () => { + const err = new MissingTrustlineError(params); + expect(err.assetCode).toBe("USDC"); + expect(err.assetIssuer).toBe(ISSUER); + expect(err.recipientAddress).toBe(recipientAddress); + expect(err.changeTrustXdr).toBe("AAAAAQAAAAA..."); + }); + + it("populates meta with changeTrustXdr (always visible in API response)", () => { + const err = new MissingTrustlineError(params); + expect(err.meta).toEqual({ + assetCode: "USDC", + assetIssuer: ISSUER, + changeTrustXdr: "AAAAAQAAAAA...", + }); + }); +}); diff --git a/src/stellar/payments.ts b/src/stellar/payments.ts index e84c5d55..1674e617 100644 --- a/src/stellar/payments.ts +++ b/src/stellar/payments.ts @@ -1,6 +1,6 @@ import * as StellarSdk from "stellar-sdk"; import { getStellarServer, getNetworkPassphrase } from "../config/stellar"; -import { AssetService } from "../services/stellar/assetService"; +import { validateRecipientTrustline } from "./trustlineValidation"; export interface PathPaymentParams { /** Keypair of the account sending the payment */ @@ -69,18 +69,11 @@ export async function executePathPayment( } = params; const server = getStellarServer(); - const assetService = new AssetService(); - // Verify destination has a trustline for the asset it will receive - if (!destAsset.isNative()) { - const trusted = await assetService.hasTrustline(destination, destAsset); - if (!trusted) { - throw new Error( - `Destination has no trustline for ${destAsset.getCode()}. ` + - `Ask the recipient to add a trustline before sending.`, - ); - } - } + // Verify destination has a trustline for the asset it will receive. + // Throws MissingTrustlineError (ERR_MISSING_TRUSTLINE) with a changeTrustXdr + // when the trustline is absent; result is Redis-cached for 60 s. + await validateRecipientTrustline(destination, destAsset); const account = await server.loadAccount(senderKeypair.publicKey()); diff --git a/src/stellar/trustlineValidation.ts b/src/stellar/trustlineValidation.ts new file mode 100644 index 00000000..c1edabdf --- /dev/null +++ b/src/stellar/trustlineValidation.ts @@ -0,0 +1,203 @@ +import * as StellarSdk from "stellar-sdk"; +import { getStellarServer, getNetworkPassphrase } from "../config/stellar"; +import { layeredCache } from "../services/layeredCache"; +import { resolveToBaseAddress } from "./muxed"; +import { BusinessLogicError } from "../utils/errors"; +import { ERROR_CODES } from "../constants/errorCodes"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const TRUSTLINE_CACHE_TTL_SEC = 60; +const MAX_TRUSTLINE_LIMIT = "922337203685.4775807"; + +// ── Error class ─────────────────────────────────────────────────────────────── + +/** + * Thrown when a recipient account is missing a required Stellar trustline. + * + * The `changeTrustXdr` field contains an unsigned ChangeTrust transaction XDR + * that the recipient can import into any Stellar wallet, sign, and submit to + * establish the trustline. It is surfaced in the `meta` field of the API + * response so clients always have it — even in production. + */ +export class MissingTrustlineError extends BusinessLogicError { + readonly assetCode: string; + readonly assetIssuer: string; + readonly recipientAddress: string; + readonly changeTrustXdr: string; + + constructor(params: { + recipientAddress: string; + assetCode: string; + assetIssuer: string; + changeTrustXdr: string; + }) { + super( + `Recipient ${params.recipientAddress} has no trustline for ${params.assetCode}:${params.assetIssuer}`, + ERROR_CODES.ERR_MISSING_TRUSTLINE, + { + assetCode: params.assetCode, + assetIssuer: params.assetIssuer, + recipientAddress: params.recipientAddress, + }, + ); + this.name = "MissingTrustlineError"; + this.assetCode = params.assetCode; + this.assetIssuer = params.assetIssuer; + this.recipientAddress = params.recipientAddress; + this.changeTrustXdr = params.changeTrustXdr; + + // meta is always included in the API response (never stripped in production) + this.meta = { + assetCode: params.assetCode, + assetIssuer: params.assetIssuer, + changeTrustXdr: params.changeTrustXdr, + }; + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function trustlineCacheKey( + baseAddress: string, + assetCode: string, + assetIssuer: string, +): string { + return `trustline:validation:${baseAddress}:${assetCode}:${assetIssuer}`; +} + +/** + * Builds an UNSIGNED ChangeTrust transaction XDR that the recipient can sign + * to establish a trustline for `asset`. + * + * When the account already exists on Horizon its current sequence number is + * used so the XDR is immediately submittable. For accounts that are not yet + * funded we fall back to sequence "0" so a usable template is still returned + * (the recipient must fund the account first; the sequence will need refreshing). + */ +function buildChangeTrustXdrFromAccount( + account: StellarSdk.Horizon.AccountResponse | StellarSdk.Account, + asset: StellarSdk.Asset, +): string { + const tx = new StellarSdk.TransactionBuilder(account as any, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation( + StellarSdk.Operation.changeTrust({ + asset, + limit: MAX_TRUSTLINE_LIMIT, + }), + ) + .setTimeout(0) // no expiry — recipient signs when ready + .build(); + + return tx.toEnvelope().toXDR("base64"); +} + +async function loadAccountForXdr( + baseAddress: string, + asset: StellarSdk.Asset, +): Promise { + const server = getStellarServer(); + let account: StellarSdk.Horizon.AccountResponse | StellarSdk.Account; + + try { + account = await server.loadAccount(baseAddress); + } catch (err: unknown) { + const e = err as { response?: { status?: number } }; + if (e.response?.status === 404) { + // Account not yet funded — build template with dummy sequence + account = new StellarSdk.Account(baseAddress, "0"); + } else { + throw err; + } + } + + return buildChangeTrustXdrFromAccount(account, asset); +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Validates that `destinationAccount` holds a trustline for `asset` before a + * payment is sent. Skips the check entirely for native XLM. + * + * The validation result is cached in Redis for 60 seconds to avoid redundant + * Horizon round-trips on hot payment paths. + * + * @throws {MissingTrustlineError} when the trustline is absent, including an + * unsigned ChangeTrust XDR so the recipient can fix this immediately. + * @throws re-throws unexpected Horizon errors as-is. + */ +export async function validateRecipientTrustline( + destinationAccount: string, + asset: StellarSdk.Asset, +): Promise { + if (asset.isNative()) return; + + const baseAddress = resolveToBaseAddress(destinationAccount); + const assetCode = asset.getCode(); + const assetIssuer = asset.getIssuer(); + const cacheKey = trustlineCacheKey(baseAddress, assetCode, assetIssuer); + + // ── Cache hit ───────────────────────────────────────────────────────────── + const cached = await layeredCache.get(cacheKey); + + if (cached === true) return; + + if (cached === false) { + // Trustline confirmed missing within the TTL window. + // Build a fresh XDR with the current sequence number and throw. + const changeTrustXdr = await loadAccountForXdr(baseAddress, asset); + throw new MissingTrustlineError({ + recipientAddress: destinationAccount, + assetCode, + assetIssuer, + changeTrustXdr, + }); + } + + // ── Cache miss: query Horizon ───────────────────────────────────────────── + const server = getStellarServer(); + let loadedAccount: StellarSdk.Horizon.AccountResponse | null = null; + let hasTrustline: boolean; + + try { + loadedAccount = await server.loadAccount(baseAddress); + hasTrustline = loadedAccount.balances.some( + (b) => + b.asset_type !== "native" && + b.asset_type !== "liquidity_pool_shares" && + "asset_code" in b && + b.asset_code === assetCode && + "asset_issuer" in b && + b.asset_issuer === assetIssuer, + ); + } catch (err: unknown) { + const e = err as { response?: { status?: number } }; + if (e.response?.status === 404) { + // Account doesn't exist on-chain → cannot have a trustline + hasTrustline = false; + } else { + throw err; + } + } + + // Cache the boolean result for TRUSTLINE_CACHE_TTL_SEC seconds + await layeredCache.set(cacheKey, hasTrustline, TRUSTLINE_CACHE_TTL_SEC); + + if (!hasTrustline) { + // Reuse the already-loaded account if possible to avoid a second Horizon call + const changeTrustXdr = loadedAccount + ? buildChangeTrustXdrFromAccount(loadedAccount, asset) + : await loadAccountForXdr(baseAddress, asset); + + throw new MissingTrustlineError({ + recipientAddress: destinationAccount, + assetCode, + assetIssuer, + changeTrustXdr, + }); + } +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index a10b8d77..0104d1ee 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -95,6 +95,7 @@ export class BusinessLogicError extends Error implements AppError { code: string; statusCode: number; details?: Record; + meta?: Record; constructor( message: string,