From 2e2480d1a8a43f34f267c6f00de833316e190233 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 18:32:33 +0200 Subject: [PATCH 1/4] test(contracts): add Alfredpay external API contract schemas and suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 2 of docs/features/contract-tests.md: consumed-contract zod schemas for configs, quotes (both directions), onramp/offramp order creation and polling, fiat accounts, KYC status, and the 409 trade-limit error body — parsed against the fake (hermetic, PR-blocking) and the partner API (live, nightly). Order-creating live tests are gated behind pre-provisioned sandbox fixtures and stop before any payment. Live verification surfaced a wire-type lie: the limit-breach errorMetadata quantities arrive as JSON numbers, not strings — the client now stringifies them at the boundary so AlfredpayTradeLimitError.quantity stays a truthful string. getQuote has no production consumers and is deliberately uncovered. --- .../contracts/alfredpay.contract.test.ts | 302 ++++++++++++++++++ .../services/alfredpay/alfredpayApiService.ts | 6 +- .../shared/src/services/alfredpay/index.ts | 1 + .../src/services/alfredpay/schemas.test.ts | 182 +++++++++++ .../shared/src/services/alfredpay/schemas.ts | 160 ++++++++++ 5 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/tests/contracts/alfredpay.contract.test.ts create mode 100644 packages/shared/src/services/alfredpay/schemas.test.ts create mode 100644 packages/shared/src/services/alfredpay/schemas.ts diff --git a/apps/api/src/tests/contracts/alfredpay.contract.test.ts b/apps/api/src/tests/contracts/alfredpay.contract.test.ts new file mode 100644 index 000000000..2f185026a --- /dev/null +++ b/apps/api/src/tests/contracts/alfredpay.contract.test.ts @@ -0,0 +1,302 @@ +/** + * External API contract: Alfredpay (docs/features/contract-tests.md). + * + * The same consumed-contract schemas run against the fake (hermetic, PR-blocking) + * and against the partner API (live, nightly). Live tests skip cleanly when + * ALFREDPAY_* credentials are absent; the transaction-creating and account-reading + * tests additionally require pre-provisioned sandbox fixtures (see .env.example): + * + * - ALFREDPAY_CONTRACT_CUSTOMER_ID KYC-approved MX sandbox customer + * - ALFREDPAY_CONTRACT_FIAT_ACCOUNT_ID SPEI fiat account of that customer + * - ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID a KYC submission of that customer + * + * Per PRD, at most one transaction per direction is created per run, and nothing + * progresses past the point where a real payment would be required (an onramp + * stays CREATED / awaiting payment). `getQuote` has no production consumers and + * is deliberately uncovered. + */ +import { describe, expect, test } from "bun:test"; +import { + AlfredpayApiService, + AlfredpayChain, + alfredpayConfigsResponseSchema, + alfredpayCreateOnrampResponseSchema, + AlfredpayFeeType, + alfredpayFiatAccountsResponseSchema, + AlfredpayFiatAccountType, + AlfredpayFiatCurrency, + alfredpayKycStatusResponseSchema, + alfredpayOfframpTransactionSchema, + AlfredpayOnChainCurrency, + alfredpayOnrampTransactionSchema, + AlfredpayPaymentMethodType, + alfredpayQuoteResponseSchema, + AlfredpayTradeLimitError, + type CreateAlfredpayOnrampQuoteRequest +} from "@vortexfi/shared"; +import { assertLiveCoverage, runLive } from "../../test-utils/contract-support"; +import { FakeAlfredpay } from "../../test-utils/fake-world/fake-anchors"; + +const RUN_LIVE = !!process.env.RUN_LIVE_TESTS; +const HAS_CREDS = !!(process.env.ALFREDPAY_API_KEY && process.env.ALFREDPAY_API_SECRET); +const CUSTOMER_ID = process.env.ALFREDPAY_CONTRACT_CUSTOMER_ID; +const FIAT_ACCOUNT_ID = process.env.ALFREDPAY_CONTRACT_FIAT_ACCOUNT_ID; +const KYC_SUBMISSION_ID = process.env.ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID; + +if (RUN_LIVE && !HAS_CREDS) { + console.warn("[contract:live] Alfredpay live half skipped: ALFREDPAY_API_KEY/ALFREDPAY_API_SECRET not set"); +} + +// Unremarkable placeholder wallet, mirroring the squidrouter suite. +const TEST_ADDRESS = "0x1234567890123456789012345678901234567890"; + +// Sentinel used by production quote requests for anonymous rate discovery +// (ALFREDPAY_ANONYMOUS_CUSTOMER_ID in quote/alfredpay-customer.ts — metadata.customerId +// is tracking-only on quote requests). +const QUOTE_METADATA = { businessId: "vortex", customerId: "anonymous" }; + +function onrampQuoteRequest(fromAmount: string): CreateAlfredpayOnrampQuoteRequest { + // Mirrors OnRampInitializeAlfredpayEngine: fiat -> USDC minted on Polygon. + return { + chain: AlfredpayChain.MATIC, + fromAmount, + fromCurrency: AlfredpayFiatCurrency.MXN, + metadata: QUOTE_METADATA, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: AlfredpayOnChainCurrency.USDC + }; +} + +describe("Alfredpay external API contract — hermetic (fake)", () => { + function seededFake() { + const fake = new FakeAlfredpay(); + fake.quoteFees = [{ amount: "12.50", currency: "MXN", type: AlfredpayFeeType.PROCESSING_FEE }]; + return fake; + } + + test("fake onramp and offramp quotes satisfy the quote contract", async () => { + const api = seededFake().asService(); + const onrampQuote = await api.createOnrampQuote(onrampQuoteRequest("500")); + expect(() => alfredpayQuoteResponseSchema.parse(onrampQuote)).not.toThrow(); + + const offrampQuote = await api.createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: "30", + fromCurrency: AlfredpayOnChainCurrency.USDC, + metadata: QUOTE_METADATA, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: AlfredpayFiatCurrency.MXN + }); + expect(() => alfredpayQuoteResponseSchema.parse(offrampQuote)).not.toThrow(); + }); + + test("fake onramp order and transaction polling satisfy their contracts", async () => { + const fake = seededFake(); + const api = fake.asService(); + const order = await api.createOnramp({ + amount: "500", + chain: AlfredpayChain.MATIC, + customerId: "cust-1", + depositAddress: TEST_ADDRESS, + fromCurrency: AlfredpayFiatCurrency.MXN, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + quoteId: "quote-1", + toCurrency: AlfredpayOnChainCurrency.USDC + }); + expect(() => alfredpayCreateOnrampResponseSchema.parse(order)).not.toThrow(); + + const transaction = await api.getOnrampTransaction(order.transaction.transactionId); + expect(() => alfredpayOnrampTransactionSchema.parse(transaction)).not.toThrow(); + }); + + test("fake offramp order and transaction polling satisfy their contracts", async () => { + const api = seededFake().asService(); + const order = await api.createOfframp({ + amount: "30", + chain: AlfredpayChain.MATIC, + customerId: "cust-1", + fiatAccountId: "fa-1", + fromCurrency: AlfredpayOnChainCurrency.USDC, + originAddress: TEST_ADDRESS, + quoteId: "quote-1", + toCurrency: AlfredpayFiatCurrency.MXN + }); + expect(() => alfredpayOfframpTransactionSchema.parse(order)).not.toThrow(); + + const transaction = await api.getOfframpTransaction(order.transactionId); + expect(() => alfredpayOfframpTransactionSchema.parse(transaction)).not.toThrow(); + }); + + test("fake fiat account listing satisfies the contract", async () => { + const fake = seededFake(); + fake.fiatAccountsByCustomer.set("cust-1", [ + { + accountNumber: "646180157000000004", + accountType: "checking", + customerId: "cust-1", + fiatAccountId: "fa-1", + type: AlfredpayFiatAccountType.SPEI + } + ]); + const accounts = await fake.asService().listFiatAccounts("cust-1"); + expect(() => alfredpayFiatAccountsResponseSchema.parse(accounts)).not.toThrow(); + }); +}); + +describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — live", () => { + const api = () => AlfredpayApiService.getInstance(); + + test( + "GET /configurations response satisfies the configs contract", + async () => { + const configs = await runLive("alfredpay getAllConfigs", () => api().getAllConfigs()); + if (!configs) return; // inconclusive — see test-utils/contract-support.ts + alfredpayConfigsResponseSchema.parse(configs); + }, + 60_000 + ); + + test( + "POST /quotes responses satisfy the quote contract (both directions)", + async () => { + const onrampQuote = await runLive("alfredpay createOnrampQuote", () => api().createOnrampQuote(onrampQuoteRequest("500"))); + if (onrampQuote) alfredpayQuoteResponseSchema.parse(onrampQuote); + + const offrampQuote = await runLive("alfredpay createOfframpQuote", () => + api().createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: "30", + fromCurrency: AlfredpayOnChainCurrency.USDC, + metadata: QUOTE_METADATA, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: AlfredpayFiatCurrency.MXN + }) + ); + if (offrampQuote) alfredpayQuoteResponseSchema.parse(offrampQuote); + }, + 60_000 + ); + + test( + "an absurd quote amount still yields the limit-breach error contract", + async () => { + // The 409 body is consumed inside executeRequest, which turns it into + // AlfredpayTradeLimitError — the parsed error carries the consumed fields. A + // generic Error here would mean the errorCode/errorMetadata shape drifted. + const limitError = await runLive("alfredpay limit breach", async () => { + try { + await api().createOnrampQuote(onrampQuoteRequest("999999999999")); + return null; // no maximum configured for the pair — nothing to assert + } catch (error) { + if (error instanceof AlfredpayTradeLimitError) return error; + throw error; // network/5xx etc. -> inconclusive + } + }); + if (!limitError) return; + expect(limitError.kind).toBe("above"); + expect(limitError.quantity).toMatch(/^\d+(\.\d+)?$/); + expect(limitError.fromCurrency.length).toBeGreaterThan(0); + }, + 60_000 + ); + + test.skipIf(!CUSTOMER_ID)( + "GET /fiatAccounts response satisfies the fiat accounts contract", + async () => { + const accounts = await runLive("alfredpay listFiatAccounts", () => api().listFiatAccounts(CUSTOMER_ID as string)); + if (!accounts) return; + alfredpayFiatAccountsResponseSchema.parse(accounts); + }, + 60_000 + ); + + test.skipIf(!CUSTOMER_ID || !KYC_SUBMISSION_ID)( + "GET /kyc/{submissionId}/status response satisfies the KYC status contract", + async () => { + const status = await runLive("alfredpay getKycStatus", () => + api().getKycStatus(CUSTOMER_ID as string, KYC_SUBMISSION_ID as string) + ); + if (!status) return; + alfredpayKycStatusResponseSchema.parse(status); + }, + 60_000 + ); + + test.skipIf(!CUSTOMER_ID)( + "POST /onramp + GET /onramp/{id} responses satisfy their contracts (stops at awaiting payment)", + async () => { + const quote = await runLive("alfredpay onramp quote (order)", () => + api().createOnrampQuote({ + ...onrampQuoteRequest("500"), + metadata: { businessId: "vortex", customerId: CUSTOMER_ID as string } + }) + ); + if (!quote) return; + + const order = await runLive("alfredpay createOnramp", () => + api().createOnramp({ + amount: quote.fromAmount, + chain: AlfredpayChain.MATIC, + customerId: CUSTOMER_ID as string, + depositAddress: TEST_ADDRESS, + fromCurrency: AlfredpayFiatCurrency.MXN, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + quoteId: quote.quoteId, + toCurrency: AlfredpayOnChainCurrency.USDC + }) + ); + if (!order) return; + alfredpayCreateOnrampResponseSchema.parse(order); + + const transaction = await runLive("alfredpay getOnrampTransaction", () => + api().getOnrampTransaction(order.transaction.transactionId) + ); + if (!transaction) return; + alfredpayOnrampTransactionSchema.parse(transaction); + }, + 120_000 + ); + + test.skipIf(!CUSTOMER_ID || !FIAT_ACCOUNT_ID)( + "POST /offramp + GET /offramp/{id} responses satisfy their contracts (no deposit is made)", + async () => { + const quote = await runLive("alfredpay offramp quote (order)", () => + api().createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: "30", + fromCurrency: AlfredpayOnChainCurrency.USDC, + metadata: { businessId: "vortex", customerId: CUSTOMER_ID as string }, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: AlfredpayFiatCurrency.MXN + }) + ); + if (!quote) return; + + const order = await runLive("alfredpay createOfframp", () => + api().createOfframp({ + amount: quote.fromAmount, + chain: AlfredpayChain.MATIC, + customerId: CUSTOMER_ID as string, + fiatAccountId: FIAT_ACCOUNT_ID as string, + fromCurrency: AlfredpayOnChainCurrency.USDC, + originAddress: TEST_ADDRESS, + quoteId: quote.quoteId, + toCurrency: AlfredpayFiatCurrency.MXN + }) + ); + if (!order) return; + alfredpayOfframpTransactionSchema.parse(order); + + const transaction = await runLive("alfredpay getOfframpTransaction", () => api().getOfframpTransaction(order.transactionId)); + if (!transaction) return; + alfredpayOfframpTransactionSchema.parse(transaction); + }, + 120_000 + ); +}); + +// Not gated on HAS_CREDS: in the nightly (CONTRACT_EXPECT_LIVE=1) missing credentials +// are exactly the rot this must turn into a failure. +test.skipIf(!RUN_LIVE)("live contract coverage actually ran", () => { + assertLiveCoverage(); +}); diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index 26f7157bc..9b451d9f1 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -113,9 +113,11 @@ export class AlfredpayApiService { logger.current.warn( `Alfredpay trade limit hit: minQuantity=${minQuantity} maxQuantity=${maxQuantity} fromCurrency=${fromCurrency}` ); + // The wire carries the quantities as JSON numbers (see alfredpayLimitErrorBodySchema); + // the error exposes them as strings. throw maxQuantity !== undefined - ? AlfredpayTradeLimitError.above(maxQuantity, fromCurrency) - : AlfredpayTradeLimitError.below(minQuantity, fromCurrency); + ? AlfredpayTradeLimitError.above(String(maxQuantity), fromCurrency) + : AlfredpayTradeLimitError.below(String(minQuantity), fromCurrency); } } catch (parseError) { if (parseError instanceof AlfredpayTradeLimitError) { diff --git a/packages/shared/src/services/alfredpay/index.ts b/packages/shared/src/services/alfredpay/index.ts index b28ebfbd0..0fe3545b0 100644 --- a/packages/shared/src/services/alfredpay/index.ts +++ b/packages/shared/src/services/alfredpay/index.ts @@ -1,2 +1,3 @@ export * from "./alfredpayApiService"; +export * from "./schemas"; export * from "./types"; diff --git a/packages/shared/src/services/alfredpay/schemas.test.ts b/packages/shared/src/services/alfredpay/schemas.test.ts new file mode 100644 index 000000000..3038142d9 --- /dev/null +++ b/packages/shared/src/services/alfredpay/schemas.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test"; +import { + alfredpayConfigsResponseSchema, + alfredpayCreateOnrampResponseSchema, + alfredpayFiatAccountsResponseSchema, + alfredpayKycStatusResponseSchema, + alfredpayLimitErrorBodySchema, + alfredpayOfframpTransactionSchema, + alfredpayOnrampTransactionSchema, + alfredpayQuoteResponseSchema +} from "./schemas"; + +function validQuoteBody() { + return { + chain: "MATIC", + expiration: "2026-07-07T12:00:00.000Z", + fees: [{ amount: "12.50", currency: "MXN", type: "processingFee" }], + fromAmount: "500", + fromCurrency: "MXN", + metadata: { businessId: "vortex" }, + paymentMethodType: "BANK", + quoteId: "q-abc", + rate: "0.058", + toAmount: "28.75", + toCurrency: "USDC" + }; +} + +describe("alfredpayConfigsResponseSchema", () => { + test("accepts a pair including fields we don't consume and a null typeCustomer", () => { + const body = { + supportedPairs: [ + { + businessId: null, + createdAt: "2026-01-01T00:00:00Z", + decimals: "2", + fromCurrency: "MXN", + id: "pair-1", + maxQuantity: "100000", + minQuantity: "100", + toCurrency: "USDC", + typeCustomer: null, + updatedAt: "2026-01-01T00:00:00Z" + } + ] + }; + expect(() => alfredpayConfigsResponseSchema.parse(body)).not.toThrow(); + }); + + test("rejects a pair with a missing consumed field (minQuantity)", () => { + const body = { + supportedPairs: [{ decimals: "2", fromCurrency: "MXN", maxQuantity: "100000", toCurrency: "USDC", typeCustomer: null }] + }; + expect(() => alfredpayConfigsResponseSchema.parse(body)).toThrow(); + }); +}); + +describe("alfredpayQuoteResponseSchema", () => { + test("accepts a full quote including fields we don't consume", () => { + expect(() => alfredpayQuoteResponseSchema.parse(validQuoteBody())).not.toThrow(); + }); + + test("rejects a missing consumed field (quoteId)", () => { + const body = validQuoteBody(); + delete (body as Record).quoteId; + expect(() => alfredpayQuoteResponseSchema.parse(body)).toThrow(); + }); + + test("rejects a non-decimal toAmount", () => { + const body = validQuoteBody(); + body.toAmount = "28,75"; + expect(() => alfredpayQuoteResponseSchema.parse(body)).toThrow(); + }); + + test("rejects an unparseable expiration", () => { + const body = validQuoteBody(); + body.expiration = "soon"; + expect(() => alfredpayQuoteResponseSchema.parse(body)).toThrow(); + }); +}); + +describe("alfredpayCreateOnrampResponseSchema", () => { + test("accepts rail-specific instructions as an opaque object", () => { + const body = { + fiatPaymentInstructions: { clabe: "646180157000000004", paymentType: "SPEI", reference: "REF" }, + transaction: { status: "CREATED", transactionId: "tx-1" } + }; + expect(() => alfredpayCreateOnrampResponseSchema.parse(body)).not.toThrow(); + }); + + test("rejects a missing transactionId", () => { + const body = { fiatPaymentInstructions: {}, transaction: { status: "CREATED" } }; + expect(() => alfredpayCreateOnrampResponseSchema.parse(body)).toThrow(); + }); +}); + +describe("alfredpayOnrampTransactionSchema", () => { + test("accepts a transaction without metadata (read defensively)", () => { + expect(() => alfredpayOnrampTransactionSchema.parse({ status: "CREATED" })).not.toThrow(); + expect(() => alfredpayOnrampTransactionSchema.parse({ metadata: null, status: "FAILED" })).not.toThrow(); + }); + + test("rejects a status outside the consumed enum", () => { + expect(() => alfredpayOnrampTransactionSchema.parse({ status: "SETTLED" })).toThrow(); + }); +}); + +describe("alfredpayOfframpTransactionSchema", () => { + test("rejects a non-EVM depositAddress", () => { + const body = { + depositAddress: "not-an-address", + expiration: "2026-07-07T12:00:00.000Z", + fromAmount: "25", + status: "ON_CHAIN_DEPOSIT_RECEIVED", + toCurrency: "MXN", + transactionId: "tx-2" + }; + expect(() => alfredpayOfframpTransactionSchema.parse(body)).toThrow(); + body.depositAddress = "0x5afe00000000000000000000000000000000d0e5"; + expect(() => alfredpayOfframpTransactionSchema.parse(body)).not.toThrow(); + }); +}); + +describe("alfredpayFiatAccountsResponseSchema", () => { + test("accepts accounts with and without the optional display fields", () => { + const body = [ + { accountNumber: "000123456789", fiatAccountId: "fa-1", type: "ACH" }, + { + accountName: "Main", + accountNumber: "646180157000000004", + customerId: "c-1", + fiatAccountId: "fa-2", + metadata: { accountHolderName: "Test User", documentType: "INE" }, + type: "SPEI" + } + ]; + expect(() => alfredpayFiatAccountsResponseSchema.parse(body)).not.toThrow(); + }); + + test("rejects an account with a missing consumed field (accountNumber)", () => { + expect(() => alfredpayFiatAccountsResponseSchema.parse([{ fiatAccountId: "fa-1", type: "ACH" }])).toThrow(); + }); +}); + +describe("alfredpayKycStatusResponseSchema", () => { + test("accepts all guarded metadata shapes", () => { + expect(() => alfredpayKycStatusResponseSchema.parse({ status: "COMPLETED", updatedAt: "x" })).not.toThrow(); + expect(() => alfredpayKycStatusResponseSchema.parse({ metadata: null, status: "FAILED" })).not.toThrow(); + expect(() => + alfredpayKycStatusResponseSchema.parse({ metadata: { failureReason: "doc blurry" }, status: "UPDATE_REQUIRED" }) + ).not.toThrow(); + }); + + test("rejects a status outside the consumed enum", () => { + expect(() => alfredpayKycStatusResponseSchema.parse({ status: "APPROVED" })).toThrow(); + }); +}); + +describe("alfredpayLimitErrorBodySchema", () => { + test("accepts both the above-max and below-min variants (quantities are wire numbers)", () => { + expect(() => + alfredpayLimitErrorBodySchema.parse({ + errorCode: 111426, + errorMetadata: { fromCurrency: "MXN", maxQuantity: 86996891.21 }, + message: "Trade limit exceeded" + }) + ).not.toThrow(); + expect(() => + alfredpayLimitErrorBodySchema.parse({ errorCode: 111426, errorMetadata: { fromCurrency: "MXN", minQuantity: 100 } }) + ).not.toThrow(); + }); + + test("rejects a different errorCode, stringly quantities, or missing fromCurrency", () => { + expect(() => + alfredpayLimitErrorBodySchema.parse({ errorCode: 111427, errorMetadata: { fromCurrency: "MXN", maxQuantity: 1 } }) + ).toThrow(); + expect(() => + alfredpayLimitErrorBodySchema.parse({ errorCode: 111426, errorMetadata: { fromCurrency: "MXN", maxQuantity: "1" } }) + ).toThrow(); + expect(() => alfredpayLimitErrorBodySchema.parse({ errorCode: 111426, errorMetadata: { maxQuantity: 1 } })).toThrow(); + }); +}); diff --git a/packages/shared/src/services/alfredpay/schemas.ts b/packages/shared/src/services/alfredpay/schemas.ts new file mode 100644 index 000000000..beb1f727a --- /dev/null +++ b/packages/shared/src/services/alfredpay/schemas.ts @@ -0,0 +1,160 @@ +import { z } from "zod"; +import { AlfredpayCustomerType } from "../../tokens/types/base"; +import { + AlfredpayConfigPair, + AlfredpayFee, + AlfredpayFiatAccount, + AlfredpayFiatAccountType, + AlfredpayFiatPaymentInstructions, + AlfredpayKycStatus, + AlfredpayOfframpStatus, + AlfredpayOfframpTransaction, + AlfredpayOnrampQuote, + AlfredpayOnrampStatus, + AlfredpayOnrampTransaction, + GetKycStatusResponse +} from "./types"; + +/** + * External API contract schemas for Alfredpay (see docs/features/contract-tests.md). + * + * These model the raw wire JSON of the fields Vortex actually consumes — not the full + * partner response. Unknown extra fields always pass (loose objects); a removed or + * renamed consumed field fails. Discipline: no z.any(), no .optional() unless the code + * genuinely tolerates absence, no input-widening coercions. + */ + +// Consumed subsets of the full shared types. Deriving them via Pick ties the schemas to +// the types: renaming a consumed field in types.ts breaks compilation here. +type ConsumedConfigPair = Pick< + AlfredpayConfigPair, + "fromCurrency" | "toCurrency" | "minQuantity" | "maxQuantity" | "decimals" | "typeCustomer" +>; +type ConsumedFee = Pick; +type ConsumedQuote = Pick & { + fees: ConsumedFee[]; +}; +type ConsumedOnrampTransaction = Pick & { + metadata?: { txHash?: string; failureReason?: string } | null; +}; +type ConsumedOfframpTransaction = Pick< + AlfredpayOfframpTransaction, + "transactionId" | "status" | "depositAddress" | "expiration" | "toCurrency" | "fromAmount" +>; +type ConsumedFiatAccount = Pick & { + metadata?: { accountHolderName?: string }; +}; +type ConsumedKycStatus = Pick & { + metadata?: { failureReason?: string } | null; +}; + +const DECIMAL_STRING = /^\d+(\.\d+)?$/; +const DIGITS = /^\d+$/; +const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/; +// expiration is consumed via `new Date(...)` — the property that matters is parseability. +const parseableTimestamp = z.string().refine(value => !Number.isNaN(Date.parse(value)), "not a parseable timestamp"); + +/** One entry of the GET …/configurations `supportedPairs` array. */ +export const alfredpayConfigPairSchema = z.looseObject({ + decimals: z.string().regex(DIGITS), + fromCurrency: z.string().min(1), + maxQuantity: z.string().regex(DECIMAL_STRING), + minQuantity: z.string().regex(DECIMAL_STRING), + toCurrency: z.string().min(1), + typeCustomer: z.enum(AlfredpayCustomerType).nullable() +}) satisfies z.ZodType; + +/** The body of a GET …/configurations response. */ +export const alfredpayConfigsResponseSchema = z.looseObject({ + supportedPairs: z.array(alfredpayConfigPairSchema) +}) satisfies z.ZodType<{ supportedPairs: ConsumedConfigPair[] }>; + +/** + * The body of a POST …/quotes response, BUY and SELL alike — the consumed fields are + * direction-independent (`fromCurrency`/`toCurrency`/`rate` are never read back; Vortex + * trusts its own request there). + */ +export const alfredpayQuoteResponseSchema = z.looseObject({ + expiration: parseableTimestamp, + fees: z.array( + z.looseObject({ + amount: z.string().regex(DECIMAL_STRING), + currency: z.string().min(1) + }) + ), + fromAmount: z.string().regex(DECIMAL_STRING), + quoteId: z.string().min(1), + toAmount: z.string().regex(DECIMAL_STRING) +}) satisfies z.ZodType; + +/** + * The body of a POST …/onramp response. `fiatPaymentInstructions` is forwarded to the + * client verbatim (rail-specific fields, read only by the frontend) — the consumed + * contract is "a JSON object is present". + */ +export const alfredpayCreateOnrampResponseSchema = z.looseObject({ + fiatPaymentInstructions: z.looseObject({}), + transaction: z.looseObject({ + transactionId: z.string().min(1) + }) +}) satisfies z.ZodType<{ + transaction: Pick; + fiatPaymentInstructions: Partial; +}>; + +/** The body of a GET …/onramp/{id} response. */ +export const alfredpayOnrampTransactionSchema = z.looseObject({ + metadata: z + .looseObject({ + failureReason: z.string().optional(), + txHash: z.string().optional() + }) + .nullish(), + status: z.enum(AlfredpayOnrampStatus) +}) satisfies z.ZodType; + +/** The body of a POST …/offramp and GET …/offramp/{id} response (same transaction shape). */ +export const alfredpayOfframpTransactionSchema = z.looseObject({ + depositAddress: z.string().regex(EVM_ADDRESS), + expiration: parseableTimestamp, + fromAmount: z.string().regex(DECIMAL_STRING), + status: z.enum(AlfredpayOfframpStatus), + toCurrency: z.string().min(1), + transactionId: z.string().min(1) +}) satisfies z.ZodType; + +/** The body of a GET …/fiatAccounts response. */ +export const alfredpayFiatAccountsResponseSchema = z.array( + z.looseObject({ + accountName: z.string().optional(), + accountNumber: z.string().min(1), + fiatAccountId: z.string().min(1), + metadata: z.looseObject({ accountHolderName: z.string().optional() }).optional(), + type: z.enum(AlfredpayFiatAccountType) + }) +) satisfies z.ZodType; + +/** The body of a GET …/kyc/{submissionId}/status (and KYB equivalent) response. */ +export const alfredpayKycStatusResponseSchema = z.looseObject({ + metadata: z.looseObject({ failureReason: z.string().optional() }).nullish(), + status: z.enum(AlfredpayKycStatus) +}) satisfies z.ZodType; + +/** + * The 409 error body of a trade-limit breach, parsed in `executeRequest` into + * `AlfredpayTradeLimitError`. Exactly one of `minQuantity`/`maxQuantity` is expected; + * the client branches on `maxQuantity !== undefined`. Unlike the configuration + * endpoint's stringly quantities, these arrive as JSON numbers (observed live: + * `"maxQuantity":86996891.21`); the client stringifies them at the boundary. + */ +export const alfredpayLimitErrorBodySchema = z.looseObject({ + errorCode: z.literal(111426), + errorMetadata: z.looseObject({ + fromCurrency: z.string().min(1), + maxQuantity: z.number().optional(), + minQuantity: z.number().optional() + }) +}) satisfies z.ZodType<{ + errorCode: 111426; + errorMetadata: { fromCurrency: string; minQuantity?: number; maxQuantity?: number }; +}>; From f2a6e18ff430badb498fa7cd6d8776fb02ade05e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 18:32:49 +0200 Subject: [PATCH 2/4] test(contracts): add Avenia/BRLA external API contract schemas and suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 3 of docs/features/contract-tests.md: consumed-contract zod schemas for quotes, pix-key validation, PIX ticket creation/listing, payout ticket status, limits, balances, and subaccount info. The subaccount-scoped live tests are gated behind a pre-provisioned sandbox subaccount; the only transaction created per run is a PIX pay-in ticket that expires unpaid. Payout tickets are covered hermetically only — creating one live would move funds. Resolves the wire-type lie the PRD calls out: BaseTicket timestamps and PixInputTicketOutput.expiration are ISO strings on the wire, not Date (nothing ever consumed them as Date). The contract also caught FakeBrla drift: subaccountInfo served an empty accountInfo while production reads accountInfo.identityStatus — now scriptable on the fake. createOnchainSwapQuote/Ticket, getMainAccountBalance and getAveniaSwapTicket have no production consumers and are deliberately uncovered. --- .../src/test-utils/fake-world/fake-anchors.ts | 6 +- .../tests/contracts/avenia.contract.test.ts | 201 ++++++++++++++++++ packages/shared/src/services/brla/index.ts | 1 + .../shared/src/services/brla/schemas.test.ts | 116 ++++++++++ packages/shared/src/services/brla/schemas.ts | 134 ++++++++++++ packages/shared/src/services/brla/types.ts | 10 +- 6 files changed, 462 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/tests/contracts/avenia.contract.test.ts create mode 100644 packages/shared/src/services/brla/schemas.test.ts create mode 100644 packages/shared/src/services/brla/schemas.ts diff --git a/apps/api/src/test-utils/fake-world/fake-anchors.ts b/apps/api/src/test-utils/fake-world/fake-anchors.ts index 3e6dc9c56..8af8940ca 100644 --- a/apps/api/src/test-utils/fake-world/fake-anchors.ts +++ b/apps/api/src/test-utils/fake-world/fake-anchors.ts @@ -150,6 +150,8 @@ export class FakeBrla { payOutRate = 1; subaccountId = "test-subaccount-id"; subaccountEvmWallet = "0x7ba99e99bc669b3508aff9cc0a898e869459f877"; + /** KYC state served in subaccountInfo().accountInfo; production gates ramps on CONFIRMED. */ + identityStatus: "NOT-IDENTIFIED" | "CONFIRMED" = "CONFIRMED"; /** Internal Avenia subaccount balances served by getAccountBalance; script per test. */ accountBalances = { BRLA: 0, USDC: 0, USDM: 0, USDT: 0 }; /** Status reported for every pay-in ticket by getAveniaPayinTickets. */ @@ -184,7 +186,7 @@ export class FakeBrla { createPixInputTicket: async () => { const ticket = { brCode: `brcode-${++this.counter}`, - expiration: new Date(Date.now() + 3600_000), + expiration: new Date(Date.now() + 3600_000).toISOString(), id: `pix-in-${this.counter}` }; this.pixInputTickets.push(ticket); @@ -219,7 +221,7 @@ export class FakeBrla { } }), subaccountInfo: async () => ({ - accountInfo: {}, + accountInfo: { identityStatus: this.identityStatus }, brCode: "test-brcode", createdAt: new Date().toISOString(), id: this.subaccountId, diff --git a/apps/api/src/tests/contracts/avenia.contract.test.ts b/apps/api/src/tests/contracts/avenia.contract.test.ts new file mode 100644 index 000000000..172777e63 --- /dev/null +++ b/apps/api/src/tests/contracts/avenia.contract.test.ts @@ -0,0 +1,201 @@ +/** + * External API contract: Avenia/BRLA (docs/features/contract-tests.md). + * + * The same consumed-contract schemas run against the fake (hermetic, PR-blocking) + * and against the partner API (live, nightly). Live tests skip cleanly when BRLA_* + * credentials are absent; the subaccount-scoped tests additionally require a + * pre-provisioned, KYC-approved sandbox subaccount (see .env.example): + * + * - AVENIA_CONTRACT_SUBACCOUNT_ID + * + * Per PRD, only one transaction (a PIX pay-in ticket, which expires unpaid) is + * created per run. Payout tickets are covered hermetically only — creating one + * live would move BRLA balance, and reading one needs the id of a real payout. + * `createOnchainSwapQuote`/`createOnchainSwapTicket`/`getMainAccountBalance`/ + * `getAveniaSwapTicket` have no production consumers and are deliberately uncovered. + */ +import { describe, expect, test } from "bun:test"; +import { + aveniaAccountBalanceSchema, + aveniaAccountInfoSchema, + aveniaAccountLimitsSchema, + aveniaPayinTicketsSchema, + AveniaPaymentMethod, + aveniaPayoutTicketSchema, + aveniaPixInputTicketSchema, + aveniaPixKeyDataSchema, + aveniaQuoteResponseSchema, + BlockchainSendMethod, + BrlaApiService, + BrlaCurrency, + type PayInQuoteParams +} from "@vortexfi/shared"; +import { assertLiveCoverage, runLive } from "../../test-utils/contract-support"; +import { FakeBrla } from "../../test-utils/fake-world/fake-anchors"; + +const RUN_LIVE = !!process.env.RUN_LIVE_TESTS; +const HAS_CREDS = !!(process.env.BRLA_API_KEY && process.env.BRLA_PRIVATE_KEY); +const SUBACCOUNT_ID = process.env.AVENIA_CONTRACT_SUBACCOUNT_ID; + +if (RUN_LIVE && !HAS_CREDS) { + console.warn("[contract:live] Avenia live half skipped: BRLA_API_KEY/BRLA_PRIVATE_KEY not set"); +} + +// Mirrors OnRampInitializeAveniaEngine / prepareOnrampBrlTransactions: BRL arrives +// via PIX and lands as BRLA on the (sub)account's internal balance. +function payInQuoteParams(subAccountId?: string): PayInQuoteParams { + return { + inputAmount: "100", + inputCurrency: BrlaCurrency.BRL, + inputPaymentMethod: AveniaPaymentMethod.PIX, + inputThirdParty: false, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: AveniaPaymentMethod.INTERNAL, + outputThirdParty: false, + ...(subAccountId ? { subAccountId } : {}) + }; +} + +describe("Avenia external API contract — hermetic (fake)", () => { + test("fake pay-in and payout quotes satisfy the quote contract", async () => { + const api = new FakeBrla().asService(); + const payInQuote = await api.createPayInQuote(payInQuoteParams()); + expect(() => aveniaQuoteResponseSchema.parse(payInQuote)).not.toThrow(); + + const payOutQuote = await api.createPayOutQuote({ outputAmount: "50", outputThirdParty: false }); + expect(() => aveniaQuoteResponseSchema.parse(payOutQuote)).not.toThrow(); + }); + + test("fake pix key validation satisfies the contract", async () => { + const pixKeyData = await new FakeBrla().asService().validatePixKey("test-pix-key"); + expect(() => aveniaPixKeyDataSchema.parse(pixKeyData)).not.toThrow(); + }); + + test("fake ticket creation and polling satisfy their contracts", async () => { + const fake = new FakeBrla(); + const api = fake.asService(); + + const pixInTicket = await api.createPixInputTicket( + { + quoteToken: "quote-token", + ticketBlockchainOutput: { beneficiaryWalletId: "00000000-0000-0000-0000-000000000000" } + }, + fake.subaccountId + ); + expect(() => aveniaPixInputTicketSchema.parse(pixInTicket)).not.toThrow(); + + const payinTickets = await api.getAveniaPayinTickets(fake.subaccountId); + expect(() => aveniaPayinTicketsSchema.parse(payinTickets)).not.toThrow(); + + const payoutTicket = await api.getAveniaPayoutTicket("pix-out-1", fake.subaccountId); + expect(() => aveniaPayoutTicketSchema.parse(payoutTicket)).not.toThrow(); + }); + + test("fake account limits, balances and info satisfy their contracts", async () => { + const fake = new FakeBrla(); + const api = fake.asService(); + + const limits = await api.getSubaccountUsedLimit(fake.subaccountId); + expect(() => aveniaAccountLimitsSchema.parse(limits)).not.toThrow(); + + const balances = await api.getAccountBalance(fake.subaccountId); + expect(() => aveniaAccountBalanceSchema.parse(balances)).not.toThrow(); + + const info = await api.subaccountInfo(fake.subaccountId); + expect(() => aveniaAccountInfoSchema.parse(info)).not.toThrow(); + }); +}); + +describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Avenia external API contract — live", () => { + const api = () => BrlaApiService.getInstance(); + + test( + "GET /quote/fixed-rate responses satisfy the quote contract (pay-in, transfer, payout)", + async () => { + const payInQuote = await runLive("avenia createPayInQuote", () => api().createPayInQuote(payInQuoteParams())); + if (payInQuote) aveniaQuoteResponseSchema.parse(payInQuote); + + // Second production pay-in shape: internal BRLA moved to Moonbeam via permit. + const transferQuote = await runLive("avenia createPayInQuote (moonbeam)", () => + api().createPayInQuote({ + blockchainSendMethod: BlockchainSendMethod.PERMIT, + inputAmount: "100", + inputCurrency: BrlaCurrency.BRLA, + inputPaymentMethod: AveniaPaymentMethod.INTERNAL, + inputThirdParty: false, + outputCurrency: BrlaCurrency.BRLA, + outputPaymentMethod: AveniaPaymentMethod.MOONBEAM, + outputThirdParty: false + }) + ); + if (transferQuote) aveniaQuoteResponseSchema.parse(transferQuote); + + const payOutQuote = await runLive("avenia createPayOutQuote", () => + api().createPayOutQuote({ outputAmount: "50", outputThirdParty: false }) + ); + if (payOutQuote) aveniaQuoteResponseSchema.parse(payOutQuote); + }, + 60_000 + ); + + test.skipIf(!SUBACCOUNT_ID)( + "GET /account/limits, /balances and /account-info responses satisfy their contracts", + async () => { + const limits = await runLive("avenia getSubaccountUsedLimit", () => api().getSubaccountUsedLimit(SUBACCOUNT_ID as string)); + if (limits) aveniaAccountLimitsSchema.parse(limits); + + const balances = await runLive("avenia getAccountBalance", () => api().getAccountBalance(SUBACCOUNT_ID as string)); + if (balances) aveniaAccountBalanceSchema.parse(balances); + + const info = await runLive("avenia subaccountInfo", () => api().subaccountInfo(SUBACCOUNT_ID as string)); + if (info) { + aveniaAccountInfoSchema.parse(info); + // Piggy-back pix-key validation on the subaccount's own key — no separate fixture. + if (info.pixKey) { + const pixKeyData = await runLive("avenia validatePixKey", () => api().validatePixKey(info.pixKey)); + if (pixKeyData) aveniaPixKeyDataSchema.parse(pixKeyData); + } else { + console.warn("[contract:live] avenia validatePixKey skipped: subaccount has no pixKey"); + } + } + }, + 60_000 + ); + + test.skipIf(!SUBACCOUNT_ID)( + "POST /account/tickets (PIX pay-in) + ticket listing satisfy their contracts (ticket expires unpaid)", + async () => { + const quote = await runLive("avenia pay-in quote (ticket)", () => + api().createPayInQuote(payInQuoteParams(SUBACCOUNT_ID)) + ); + if (!quote) return; + + const ticket = await runLive("avenia createPixInputTicket", () => + api().createPixInputTicket( + { + quoteToken: quote.quoteToken, + ticketBlockchainOutput: { beneficiaryWalletId: "00000000-0000-0000-0000-000000000000" }, + ticketBrlPixInput: { additionalData: "contract-test" } + }, + SUBACCOUNT_ID as string + ) + ); + if (!ticket) return; + aveniaPixInputTicketSchema.parse(ticket); + + const payinTickets = await runLive("avenia getAveniaPayinTickets", () => api().getAveniaPayinTickets(SUBACCOUNT_ID as string)); + if (!payinTickets) return; + aveniaPayinTicketsSchema.parse(payinTickets); + // The envelope/discriminator path is only exercised live through the real client; + // our just-created ticket disappearing from the list would mean it drifted. + expect(payinTickets.map(t => t.id)).toContain(ticket.id); + }, + 120_000 + ); +}); + +// Not gated on HAS_CREDS: in the nightly (CONTRACT_EXPECT_LIVE=1) missing credentials +// are exactly the rot this must turn into a failure. +test.skipIf(!RUN_LIVE)("live contract coverage actually ran", () => { + assertLiveCoverage(); +}); diff --git a/packages/shared/src/services/brla/index.ts b/packages/shared/src/services/brla/index.ts index 77a498c3f..86a00b443 100644 --- a/packages/shared/src/services/brla/index.ts +++ b/packages/shared/src/services/brla/index.ts @@ -1,4 +1,5 @@ export * from "./brlaApiService"; export * from "./helpers"; export * from "./mappings"; +export * from "./schemas"; export * from "./types"; diff --git a/packages/shared/src/services/brla/schemas.test.ts b/packages/shared/src/services/brla/schemas.test.ts new file mode 100644 index 000000000..108f22f21 --- /dev/null +++ b/packages/shared/src/services/brla/schemas.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; +import { + aveniaAccountBalanceSchema, + aveniaAccountInfoSchema, + aveniaAccountLimitsSchema, + aveniaPayinTicketsSchema, + aveniaPayoutTicketSchema, + aveniaPixInputTicketSchema, + aveniaPixKeyDataSchema, + aveniaQuoteResponseSchema +} from "./schemas"; + +function validQuoteBody() { + return { + appliedFees: [ + { amount: "0.25", currency: "BRLA", rebatable: false, type: "Gas Fee" }, + { amount: "1.00", currency: "BRL", rebatable: true, type: "In Fee" } + ], + basePrice: "1", + inputAmount: "100", + inputCurrency: "BRL", + inputPaymentMethod: "PIX", + outputAmount: "98.75", + quoteToken: "quote-token-1" + }; +} + +describe("aveniaQuoteResponseSchema", () => { + test("accepts a full quote including fields we don't consume", () => { + expect(() => aveniaQuoteResponseSchema.parse(validQuoteBody())).not.toThrow(); + }); + + test("rejects a missing consumed field (quoteToken)", () => { + const body = validQuoteBody(); + delete (body as Record).quoteToken; + expect(() => aveniaQuoteResponseSchema.parse(body)).toThrow(); + }); + + test("rejects a fee type outside the consumed enum", () => { + const body = validQuoteBody(); + body.appliedFees[0].type = "Express Fee"; + expect(() => aveniaQuoteResponseSchema.parse(body)).toThrow(); + }); +}); + +describe("aveniaPixKeyDataSchema", () => { + test("accepts a masked taxId, rejects an empty one", () => { + expect(() => aveniaPixKeyDataSchema.parse({ bankName: "B", name: "N", taxId: "***.123.456-**" })).not.toThrow(); + expect(() => aveniaPixKeyDataSchema.parse({ bankName: "B", name: "N", taxId: "" })).toThrow(); + }); +}); + +describe("aveniaPixInputTicketSchema", () => { + test("requires id and brCode", () => { + expect(() => + aveniaPixInputTicketSchema.parse({ brCode: "brcode-1", expiration: "2026-07-07T12:00:00Z", id: "t-1" }) + ).not.toThrow(); + expect(() => aveniaPixInputTicketSchema.parse({ id: "t-1" })).toThrow(); + }); +}); + +describe("aveniaPayoutTicketSchema / aveniaPayinTicketsSchema", () => { + test("accepts consumed statuses, rejects an unknown one", () => { + expect(() => aveniaPayoutTicketSchema.parse({ id: "t-1", status: "PAID" })).not.toThrow(); + expect(() => aveniaPayoutTicketSchema.parse({ id: "t-1", status: "SETTLED" })).toThrow(); + expect(() => aveniaPayinTicketsSchema.parse([{ id: "t-1", status: "PENDING" }])).not.toThrow(); + expect(() => aveniaPayinTicketsSchema.parse([{ status: "PENDING" }])).toThrow(); + }); +}); + +describe("aveniaAccountLimitsSchema", () => { + test("accepts the consumed limit fields, rejects a missing usedLimit entry", () => { + const body = { + limitInfo: { + blocked: false, + createdAt: "2026-01-01T00:00:00Z", + limits: [ + { + currency: "BRL", + maxChainIn: "1000", + maxChainOut: "1000", + maxFiatIn: "10000", + maxFiatOut: "10000", + usedLimit: { month: 7, usedChainIn: "0", usedChainOut: "0", usedFiatIn: "150.50", usedFiatOut: "0", year: 2026 } + } + ] + } + }; + expect(() => aveniaAccountLimitsSchema.parse(body)).not.toThrow(); + delete (body.limitInfo.limits[0].usedLimit as Record).usedFiatIn; + expect(() => aveniaAccountLimitsSchema.parse(body)).toThrow(); + }); +}); + +describe("aveniaAccountBalanceSchema", () => { + test("requires a numeric BRLA balance", () => { + expect(() => aveniaAccountBalanceSchema.parse({ balances: { BRLA: 12.5, USDC: 0, USDM: 0, USDT: 0 } })).not.toThrow(); + expect(() => aveniaAccountBalanceSchema.parse({ balances: { BRLA: "12.5" } })).toThrow(); + }); +}); + +describe("aveniaAccountInfoSchema", () => { + test("accepts a subaccount with an EVM wallet, rejects an unknown identityStatus", () => { + const body = { + accountInfo: { accountType: "INDIVIDUAL", identityStatus: "CONFIRMED" }, + brCode: "brcode", + createdAt: "2026-01-01T00:00:00Z", + id: "sub-1", + pixKey: "pix-key", + wallets: [{ chain: "EVM", id: "w-1", walletAddress: "0x7ba99e99bc669b3508aff9cc0a898e869459f877" }] + }; + expect(() => aveniaAccountInfoSchema.parse(body)).not.toThrow(); + body.accountInfo.identityStatus = "PENDING"; + expect(() => aveniaAccountInfoSchema.parse(body)).toThrow(); + }); +}); diff --git a/packages/shared/src/services/brla/schemas.ts b/packages/shared/src/services/brla/schemas.ts new file mode 100644 index 000000000..c8e7836fb --- /dev/null +++ b/packages/shared/src/services/brla/schemas.ts @@ -0,0 +1,134 @@ +import { z } from "zod"; +import { + AccountLimitsResponse, + AveniaAccountBalanceResponse, + AveniaAccountInfoResponse, + AveniaFeeType, + AveniaOperationFee, + AveniaPayinTicket, + AveniaPayoutTicket, + AveniaQuoteResponse, + AveniaSubaccountAccountInfo, + AveniaSubaccountWallet, + AveniaTicketStatus, + Limit, + PixInputTicketOutput, + PixKeyData, + PixOutputTicketOutput, + UsedLimitDetails +} from "./types"; + +/** + * External API contract schemas for Avenia/BRLA (see docs/features/contract-tests.md). + * + * These model the raw wire JSON of the fields Vortex actually consumes — not the full + * partner response. Unknown extra fields always pass (loose objects); a removed or + * renamed consumed field fails. Discipline: no z.any(), no .optional() unless the code + * genuinely tolerates absence, no input-widening coercions. + * + * The schemas describe the *return values of the BrlaApiService methods* — for the + * ticket getters that is the payload after the client unwraps the `ticket`/`tickets` + * envelope and discriminates pay-in vs. payout; the envelope itself is exercised by the + * live suite through the real client (an envelope rename surfaces as the client's + * "Invalid response from Avenia API" error). + */ + +// Consumed subsets of the full shared types. Deriving them via Pick ties the schemas to +// the types: renaming a consumed field in types.ts breaks compilation here. +type ConsumedFee = Pick; +type ConsumedQuote = Pick & { + appliedFees: ConsumedFee[]; +}; +type ConsumedLimit = Pick & { + usedLimit: Pick; +}; +type ConsumedAccountInfo = Pick & { + accountInfo: Pick; + wallets: Pick[]; +}; + +const DECIMAL_STRING = /^\d+(\.\d+)?$/; + +/** The body of a GET /v2/account/quote/fixed-rate response (pay-in, payout, and swap quotes alike). */ +export const aveniaQuoteResponseSchema = z.looseObject({ + appliedFees: z.array( + z.looseObject({ + amount: z.string().regex(DECIMAL_STRING), + type: z.enum(AveniaFeeType) + }) + ), + inputAmount: z.string().regex(DECIMAL_STRING), + outputAmount: z.string().regex(DECIMAL_STRING), + quoteToken: z.string().min(1) +}) satisfies z.ZodType; + +/** + * The body of a GET /v2/account/bank-accounts/brl/pix-info response. `taxId` arrives + * masked (e.g. `***.123.456-**`) + * and is consumed by mask-aware comparison, so the only wire property is non-emptiness. + */ +export const aveniaPixKeyDataSchema = z.looseObject({ + taxId: z.string().min(1) +}) satisfies z.ZodType>; + +/** A POST /v2/account/tickets response for a PIX pay-in ticket. */ +export const aveniaPixInputTicketSchema = z.looseObject({ + brCode: z.string().min(1), + id: z.string().min(1) +}) satisfies z.ZodType>; + +/** A POST /v2/account/tickets response for a PIX payout ticket. */ +export const aveniaPixOutputTicketSchema = z.looseObject({ + id: z.string().min(1) +}) satisfies z.ZodType; + +/** A payout ticket as returned by `getAveniaPayoutTicket` (unwrapped from the `ticket` envelope). */ +export const aveniaPayoutTicketSchema = z.looseObject({ + status: z.enum(AveniaTicketStatus) +}) satisfies z.ZodType>; + +/** The pay-in tickets list as returned by `getAveniaPayinTickets` (unwrapped and discriminated). */ +export const aveniaPayinTicketsSchema = z.array( + z.looseObject({ + id: z.string().min(1), + status: z.enum(AveniaTicketStatus) + }) +) satisfies z.ZodType[]>; + +/** The body of a GET /v2/account/limits response. */ +export const aveniaAccountLimitsSchema = z.looseObject({ + limitInfo: z.looseObject({ + limits: z.array( + z.looseObject({ + currency: z.string().min(1), + maxFiatIn: z.string().regex(DECIMAL_STRING), + maxFiatOut: z.string().regex(DECIMAL_STRING), + usedLimit: z.looseObject({ + usedFiatIn: z.string().regex(DECIMAL_STRING), + usedFiatOut: z.string().regex(DECIMAL_STRING) + }) + }) + ) + }) +}) satisfies z.ZodType<{ limitInfo: { limits: ConsumedLimit[] } }>; + +/** The body of a GET /v2/account/balances response. */ +export const aveniaAccountBalanceSchema = z.looseObject({ + balances: z.looseObject({ + BRLA: z.number() + }) +}) satisfies z.ZodType<{ balances: Pick }>; + +/** The body of a GET /v2/account/account-info response. */ +export const aveniaAccountInfoSchema = z.looseObject({ + accountInfo: z.looseObject({ + identityStatus: z.enum(["NOT-IDENTIFIED", "CONFIRMED"]) + }), + brCode: z.string().min(1), + wallets: z.array( + z.looseObject({ + chain: z.string().min(1), + walletAddress: z.string().min(1) + }) + ) +}) satisfies z.ZodType; diff --git a/packages/shared/src/services/brla/types.ts b/packages/shared/src/services/brla/types.ts index 0a71b864a..138531a07 100644 --- a/packages/shared/src/services/brla/types.ts +++ b/packages/shared/src/services/brla/types.ts @@ -116,9 +116,10 @@ export interface BaseTicket { userId: string; reason: string; failureReason: string; - createdAt: Date; - updatedAt: Date; - expiresAt: Date; + // Wire timestamps are ISO strings — JSON cannot contain a Date. + createdAt: string; + updatedAt: string; + expiresAt: string; quote: { id: string; ticketId: string; @@ -161,7 +162,8 @@ export interface PixInputTicketPayload { export interface PixInputTicketOutput { id: string; brCode: string; - expiration: Date; + // Wire timestamp is an ISO string — JSON cannot contain a Date. + expiration: string; } export interface PixOutputTicketOutput { From eccdc5e4e7631044f60dd20f12e50069d31ad190 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 18:32:59 +0200 Subject: [PATCH 3/4] test(contracts): add CoinGecko price feed contract schema and suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 4 of docs/features/contract-tests.md: the simple/price shape getCryptoPrice consumes, as a schema next to priceFeed.service.ts (its future Milestone-5 warn-only parse site). FakePrices patches above the HTTP seam, so the hermetic half is fixture-based; the live half mirrors the service's request construction and asserts presence of the requested ids. Falls back to the keyless public API when no COINGECKO_API_KEY is set. Nabla/DIA rates are chain state — out of scope per the no-chain-fidelity non-goal. Adds zod to apps/api (catalog version, already a transitive dependency via shared). --- apps/api/package.json | 3 +- .../api/src/api/services/priceFeed.schemas.ts | 19 +++++ .../contracts/pricefeeds.contract.test.ts | 80 +++++++++++++++++++ bun.lock | 1 + 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/api/services/priceFeed.schemas.ts create mode 100644 apps/api/src/tests/contracts/pricefeeds.contract.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index fdb4f9c98..9b0393326 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -48,7 +48,8 @@ "uuid": "^11.1.0", "viem": "catalog:", "web3": "^4.16.0", - "winston": "^3.1.0" + "winston": "^3.1.0", + "zod": "catalog:" }, "description": "", "devDependencies": { diff --git a/apps/api/src/api/services/priceFeed.schemas.ts b/apps/api/src/api/services/priceFeed.schemas.ts new file mode 100644 index 000000000..68fbd56db --- /dev/null +++ b/apps/api/src/api/services/priceFeed.schemas.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +/** + * External API contract schema for the CoinGecko price feed consumed by + * PriceFeedService (see docs/features/contract-tests.md). + * + * GET /simple/price returns `{ [tokenId]: { [vsCurrency]: number } }`; + * getCryptoPrice reads exactly `data[tokenId][vsCurrency]` and treats a missing + * token or currency key as an error, so the consumed contract is: every entry + * present is an object of numeric prices. Which keys must be present depends on + * the request — the contract test asserts presence for the ids it requested. + * + * The Nabla AMM and DIA oracle rates the service also serves are read from + * Pendulum chain state, not HTTP — out of scope per the no-chain-fidelity + * non-goal of the contract-test PRD. + */ +export const coingeckoSimplePriceResponseSchema = z.record(z.string(), z.record(z.string(), z.number())) satisfies z.ZodType< + Record> +>; diff --git a/apps/api/src/tests/contracts/pricefeeds.contract.test.ts b/apps/api/src/tests/contracts/pricefeeds.contract.test.ts new file mode 100644 index 000000000..e302aa749 --- /dev/null +++ b/apps/api/src/tests/contracts/pricefeeds.contract.test.ts @@ -0,0 +1,80 @@ +/** + * External API contract: CoinGecko price feed (docs/features/contract-tests.md). + * + * Unlike the anchor fakes, FakePrices patches PriceFeedService's methods *above* + * the HTTP seam (it never produces wire JSON), so the verified-fake half is + * fixture-based here: the hermetic tests pin the schema's accept/reject behavior, + * and drift detection comes entirely from the live half (plus, later, warn-only + * production parsing per Milestone 5). + * + * The live half mirrors getCryptoPrice's request construction: the ids/currencies + * requested are the ones production actually asks for ("usd-coin" as the USD proxy + * for the CoinGecko fiat fallback with vs_currencies mxn/cop/ars, and tokenIdMap + * entries like "ethereum" priced in usd). No credentials are strictly required: + * with COINGECKO_API_KEY set it uses the configured (pro) base URL like production, + * otherwise it falls back to the keyless public API, which serves the same shape. + */ +import { describe, expect, test } from "bun:test"; +import { coingeckoSimplePriceResponseSchema } from "../../api/services/priceFeed.schemas"; +import { assertLiveCoverage, runLive } from "../../test-utils/contract-support"; + +const RUN_LIVE = !!process.env.RUN_LIVE_TESTS; + +const REQUESTED_IDS = ["usd-coin", "ethereum"]; +const REQUESTED_CURRENCIES = ["usd", "mxn", "cop", "ars"]; + +describe("CoinGecko external API contract — hermetic (fixtures)", () => { + test("accepts the consumed simple/price shape including unknown keys", () => { + const body = { + ethereum: { last_updated_at: 1751882400, usd: 2500.12 }, + "usd-coin": { ars: 1000, cop: 4000, mxn: 17.2, usd: 1.0 } + }; + expect(() => coingeckoSimplePriceResponseSchema.parse(body)).not.toThrow(); + }); + + test("rejects non-numeric prices", () => { + expect(() => coingeckoSimplePriceResponseSchema.parse({ "usd-coin": { usd: "1.0" } })).toThrow(); + expect(() => coingeckoSimplePriceResponseSchema.parse({ "usd-coin": null })).toThrow(); + }); +}); + +describe.skipIf(!RUN_LIVE)("CoinGecko external API contract — live", () => { + test( + "GET /simple/price response satisfies the consumed contract", + async () => { + const { config } = await import("../../config/vars"); + const apiKey = config.priceProviders.coingecko.apiKey; + const baseUrl = apiKey ? config.priceProviders.coingecko.baseUrl : "https://api.coingecko.com/api/v3"; + + const body = await runLive("coingecko simple/price", async () => { + const url = new URL(`${baseUrl}/simple/price`); + url.searchParams.append("ids", REQUESTED_IDS.join(",")); + url.searchParams.append("vs_currencies", REQUESTED_CURRENCIES.join(",")); + const headers: HeadersInit = { Accept: "application/json" }; + if (apiKey) headers["x-cg-pro-api-key"] = apiKey; + const response = await fetch(url.toString(), { headers }); + if (!response.ok) { + throw new Error(`CoinGecko API error: ${response.status} ${await response.text()}`); + } + return (await response.json()) as unknown; + }); + if (!body) return; // inconclusive — see test-utils/contract-support.ts + + const parsed = coingeckoSimplePriceResponseSchema.parse(body); + // getCryptoPrice throws when a requested id/currency key is absent — presence + // of what was asked for is part of the consumed contract. + for (const id of REQUESTED_IDS) { + expect(parsed[id]).toBeDefined(); + expect(parsed[id]?.usd).toBeGreaterThan(0); + } + for (const currency of REQUESTED_CURRENCIES) { + expect(parsed["usd-coin"]?.[currency]).toBeGreaterThan(0); + } + }, + 60_000 + ); +}); + +test.skipIf(!RUN_LIVE)("live contract coverage actually ran", () => { + assertLiveCoverage(); +}); diff --git a/bun.lock b/bun.lock index 95632caa2..ce3fad378 100644 --- a/bun.lock +++ b/bun.lock @@ -66,6 +66,7 @@ "viem": "catalog:", "web3": "^4.16.0", "winston": "^3.1.0", + "zod": "catalog:", }, "devDependencies": { "@pendulum-chain/types": "catalog:", From 1a6b335db18ab52535df437b57a0a73d0a8ccb53 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 7 Jul 2026 18:33:17 +0200 Subject: [PATCH 4/4] docs(contracts): wire nightly secrets, document fixtures, update status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nightly contracts workflow now passes sandbox credentials and pre-provisioned fixtures from repository secrets (CONTRACT_ALFREDPAY_*, CONTRACT_BRLA_*, CONTRACT_AVENIA_SUBACCOUNT_ID, COINGECKO_API_KEY). Until those are provisioned, missing secrets resolve to empty and CONTRACT_EXPECT_LIVE fails the run loudly — the intended rot alarm, per the PRD. .env.example documents the BRLA credentials and the sandbox-only contract fixtures; testing-strategy.md and the PRD status line reflect milestones 1-4 being implemented, with milestone 5 (warn-only production parsing) shipping separately per endpoint after a quiet week of nightlies. --- .github/workflows/contracts.yml | 15 +++++++++++++++ apps/api/.env.example | 14 ++++++++++++++ docs/features/contract-tests.md | 7 ++++++- docs/testing-strategy.md | 16 ++++++++++++---- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 0787c41ce..5567ce825 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -36,6 +36,21 @@ jobs: - name: 🧪 Live contract suites working-directory: apps/api + # Sandbox credentials and pre-provisioned fixtures (see .env.example). A missing + # secret resolves to "" -> the suite's live half skips -> CONTRACT_EXPECT_LIVE + # fails that suite loudly instead of letting the drift detector rot as green. + env: + ALFREDPAY_BASE_URL: ${{ secrets.CONTRACT_ALFREDPAY_BASE_URL }} + ALFREDPAY_API_KEY: ${{ secrets.CONTRACT_ALFREDPAY_API_KEY }} + ALFREDPAY_API_SECRET: ${{ secrets.CONTRACT_ALFREDPAY_API_SECRET }} + ALFREDPAY_CONTRACT_CUSTOMER_ID: ${{ secrets.CONTRACT_ALFREDPAY_CUSTOMER_ID }} + ALFREDPAY_CONTRACT_FIAT_ACCOUNT_ID: ${{ secrets.CONTRACT_ALFREDPAY_FIAT_ACCOUNT_ID }} + ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID: ${{ secrets.CONTRACT_ALFREDPAY_KYC_SUBMISSION_ID }} + BRLA_BASE_URL: ${{ secrets.CONTRACT_BRLA_BASE_URL }} + BRLA_API_KEY: ${{ secrets.CONTRACT_BRLA_API_KEY }} + BRLA_PRIVATE_KEY: ${{ secrets.CONTRACT_BRLA_PRIVATE_KEY }} + AVENIA_CONTRACT_SUBACCOUNT_ID: ${{ secrets.CONTRACT_AVENIA_SUBACCOUNT_ID }} + COINGECKO_API_KEY: ${{ secrets.COINGECKO_API_KEY }} run: bun test src/tests/contracts/ # Non-blocking runs are only useful if somebody hears about failures. diff --git a/apps/api/.env.example b/apps/api/.env.example index aefc6a09f..7148fe3d0 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -99,6 +99,20 @@ ALFREDPAY_BASE_URL=your-alfredpay-base-url ALFREDPAY_API_KEY=your-alfredpay-api-key ALFREDPAY_API_SECRET=your-alfredpay-api-secret +# BRLA / Avenia +# BRLA_BASE_URL= +BRLA_API_KEY=your-brla-api-key +BRLA_PRIVATE_KEY=your-brla-private-key + # Integration test helpers (only required for phase-processor integration tests) # BACKEND_TEST_STARTER_ACCOUNT= # TAX_ID= + +# External API contract tests (RUN_LIVE_TESTS=1, see docs/features/contract-tests.md). +# Pre-provisioned SANDBOX fixtures — the fixture-gated live tests create real (unpaid) +# sandbox transactions, so only ever point these at sandbox objects. Tests skip cleanly +# when unset. +# ALFREDPAY_CONTRACT_CUSTOMER_ID= # KYC-approved MX sandbox customer +# ALFREDPAY_CONTRACT_FIAT_ACCOUNT_ID= # SPEI fiat account of that customer +# ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID= # a KYC submission of that customer +# AVENIA_CONTRACT_SUBACCOUNT_ID= # KYC-approved Avenia sandbox subaccount diff --git a/docs/features/contract-tests.md b/docs/features/contract-tests.md index c8d27eaf5..a8e2cfdc0 100644 --- a/docs/features/contract-tests.md +++ b/docs/features/contract-tests.md @@ -1,6 +1,11 @@ # PRD: External API contract tests — verifying the fake world against the real one -Status: milestone 1 (SquidRouter) implemented; milestones 2–5 pending. +Status: milestones 1–4 (SquidRouter, Alfredpay, Avenia/BRLA, price feeds) implemented; +milestone 5 (warn-only production parsing) ships separately per endpoint once its schema +has survived a quiet week of nightlies. Methods without production consumers +(`getQuote` on Alfredpay; `createOnchainSwapQuote`/`createOnchainSwapTicket`/ +`getMainAccountBalance`/`getAveniaSwapTicket` on Avenia) are deliberately uncovered — +there is no consumed contract to verify. Reference: extends the test suite described in [`docs/testing-strategy.md`](../testing-strategy.md). Naming: this layer is called **"external API contracts"** everywhere (docs, directory names, diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 8dc331e45..90acd4eba 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -23,7 +23,7 @@ together with the shared test harness (`apps/api/src/test-utils`) — see "How t | 4. SDK contract | Real SDK against the real API in-process: BRL onramp lifecycle (`sdk-contract.test.ts`), the SELL/user-transaction surface — offramp lifecycle via submitUserTransactions, updateRamp, getQuote, listAlfredpayFiatAccounts (`sdk-contract.offramp.test.ts`) — and full per-currency lifecycles for all four Alfredpay currencies in both directions: SELL offramp lifecycles for USD/ach, MXN/spei, COP/ach and ARS/cbu (`sdk-contract.alfredpay-offramp.test.ts`) and BUY onramp lifecycles for MXN/spei, USD/ach, COP/ach and ARS/cbu (`sdk-contract.alfredpay-onramp.test.ts`) | `apps/api/src/tests/sdk-contract*.test.ts` | `bun test` | | 5. Frontend | XState machine tests, actor tests (register/sign/start/KYC-routing against MSW with mocked wallet seams), component tests (RTL + MSW + mock wagmi) | `apps/frontend/src` | Vitest | | 6. E2E | Critical Playwright journeys with a mock wallet: BRL on/offramp plus parameterized Alfredpay journeys for all four currencies in both directions | `apps/frontend/e2e/` | Playwright (non-blocking) | -| 7. External API contracts | Consumed-contract zod schemas (`packages/shared/src/services/*/schemas.ts`) validated against the fakes (PR-blocking) and against the real partner APIs (live, nightly, non-blocking); currently SquidRouter | `apps/api/src/tests/contracts/` | `bun test` / nightly `contracts.yml` | +| 7. External API contracts | Consumed-contract zod schemas (`packages/shared/src/services/*/schemas.ts`, plus `apps/api/.../priceFeed.schemas.ts`) validated against the fakes (PR-blocking) and against the real partner APIs (live, nightly, non-blocking); SquidRouter, Alfredpay, Avenia/BRLA, CoinGecko | `apps/api/src/tests/contracts/` | `bun test` / nightly `contracts.yml` | ### The invariants the suite protects @@ -194,9 +194,17 @@ the PR-blocking api suite) and against the real partner API (`RUN_LIVE_TESTS=1`, Sandbox shakiness is priced in: an error from the live call itself is *inconclusive* (warn + skip); only a successful response that violates the schema fails. The nightly sets `CONTRACT_EXPECT_LIVE=1`, which fails a run where zero live calls completed, so credential rot or -a dead endpoint alerts within a day instead of rotting as green. Covered so far: SquidRouter -(`/v2/route` live; the status endpoint only hermetically — it needs a real recent transaction -hash). Next per the PRD: Alfredpay, Avenia, price feeds. +a dead endpoint alerts within a day instead of rotting as green. Covered: SquidRouter (`/v2/route` +live; the status endpoint only hermetically — it needs a real recent transaction hash), Alfredpay +(configs, quotes both directions, the trade-limit 409 error shape live with credentials only; +order creation/polling, fiat accounts and KYC status live behind pre-provisioned sandbox fixtures, +see `.env.example`), Avenia/BRLA (quotes live with credentials only; limits/balances/account-info, +pix-key validation and PIX pay-in ticket creation/listing behind a sandbox subaccount fixture; +payout tickets hermetically only — creating one live would move funds), and the CoinGecko +`simple/price` feed (schema in `apps/api/src/api/services/priceFeed.schemas.ts` — the price fake +patches above the HTTP seam, so its hermetic half is fixture-based). Client methods with no +production consumers are deliberately uncovered. Next per the PRD: milestone 5, warn-only +production parsing per endpoint after a quiet week of nightlies. ## CI