From 08e2b081dc5c6fc7cefce75cd11d98d256d5fdaf Mon Sep 17 00:00:00 2001 From: Vandana Date: Fri, 5 Jun 2026 18:50:23 -0700 Subject: [PATCH] fix(bridge): align GraphQL contract and error codes --- dev/apollo-federation/supergraph.graphql | 4 +- docs/bridge-integration/API.md | 12 ++- src/graphql/error-map.ts | 90 +++++++++++++++---- src/graphql/public/schema.graphql | 4 +- .../public/types/object/bridge-withdrawal.ts | 4 +- .../unit/graphql/bridge-error-map.spec.ts | 54 +++++++++++ .../types/object/bridge-contract.spec.ts | 48 ++++++++++ 7 files changed, 194 insertions(+), 22 deletions(-) create mode 100644 test/flash/unit/graphql/bridge-error-map.spec.ts create mode 100644 test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index ee20520f6..5dcf55abd 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -351,8 +351,8 @@ type BridgeWithdrawal createdAt: String! currency: String! failureReason: String - id: ID! - status: String! + state: String! + transferId: ID! } """ diff --git a/docs/bridge-integration/API.md b/docs/bridge-integration/API.md index 2bd24e5fb..7628b9b90 100644 --- a/docs/bridge-integration/API.md +++ b/docs/bridge-integration/API.md @@ -193,7 +193,17 @@ query BridgeWithdrawals { | Code | Description | | --- | --- | | `BRIDGE_DISABLED` | Bridge integration is disabled in configuration. | -| `BRIDGE_ACCOUNT_LEVEL_ERROR` | User account level is below 2. | +| `BRIDGE_ACCOUNT_LEVEL_ERROR` | User account level is below the required Bridge access level. | +| `BRIDGE_INVALID_AMOUNT` | Withdrawal amount is malformed or not positive. | +| `BRIDGE_BELOW_MINIMUM_WITHDRAWAL` | Withdrawal amount is below the configured minimum. | | `BRIDGE_KYC_PENDING` | Operation requires approved KYC, but it is still pending. | | `BRIDGE_KYC_REJECTED` | KYC was rejected. | +| `BRIDGE_KYC_OFFBOARDED` | Bridge offboarded the customer. | | `BRIDGE_CUSTOMER_NOT_FOUND` | Bridge customer record not found for the user. | +| `BRIDGE_INSUFFICIENT_FUNDS` | USDT balance is insufficient for the withdrawal. | +| `BRIDGE_RATE_LIMIT` | Bridge rate-limited the request. | +| `BRIDGE_TIMEOUT` | Bridge request timed out. | +| `BRIDGE_TRANSFER_FAILED` | Bridge transfer failed. | +| `BRIDGE_WEBHOOK_VALIDATION` | Bridge webhook signature validation failed. | +| `BRIDGE_API_ERROR` | Bridge API returned an unclassified provider error. | +| `BRIDGE_ERROR` | Unclassified Bridge domain error. | diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 480711d87..6dce737f1 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -37,6 +37,7 @@ import { UnauthorizedIPMetadataCountryError, IbexError, InvalidLnurlError, + CustomApolloError, } from "@graphql/error" import { baseLogger } from "@services/logger" @@ -44,6 +45,20 @@ const assertUnreachable = (x: unknown): never => { throw new Error(`This should never compile with ${x}`) } +const bridgeGqlError = ({ + code, + message, +}: { + code: string + message: string +}): CustomApolloError => + new CustomApolloError({ + code, + message, + forwardToClient: true, + logger: baseLogger, + }) + export const mapError = (error: ApplicationError): CustomApolloError => { const errorName = error.name as ApplicationErrorKey let message = "" @@ -479,60 +494,105 @@ export const mapError = (error: ApplicationError): CustomApolloError => { case "BridgeInvalidAmountError": message = error.message || "Amount must be strictly positive with at most 6 decimal places" - return new ValidationInternalError({ message, logger: baseLogger }) + return bridgeGqlError({ + code: "BRIDGE_INVALID_AMOUNT", + message, + }) case "BridgeBelowMinimumWithdrawalError": message = error.message || "Withdrawal amount is below the minimum" - return new ValidationInternalError({ message, logger: baseLogger }) + return bridgeGqlError({ + code: "BRIDGE_BELOW_MINIMUM_WITHDRAWAL", + message, + }) case "BridgeDisabledError": message = "Bridge integration is currently disabled" - return new ValidationInternalError({ message, logger: baseLogger }) + return bridgeGqlError({ + code: "BRIDGE_DISABLED", + message, + }) case "BridgeAccountLevelError": - message = "Bridge requires Pro account (Level 2+)" - return new ValidationInternalError({ message, logger: baseLogger }) + message = error.message || "Bridge requires at least a Personal account (Level 1+)" + return bridgeGqlError({ + code: "BRIDGE_ACCOUNT_LEVEL_ERROR", + message, + }) case "BridgeKycPendingError": message = "KYC verification is pending" - return new ValidationInternalError({ message, logger: baseLogger }) + return bridgeGqlError({ + code: "BRIDGE_KYC_PENDING", + message, + }) case "BridgeKycRejectedError": message = "KYC verification was rejected" - return new ValidationInternalError({ message, logger: baseLogger }) + return bridgeGqlError({ + code: "BRIDGE_KYC_REJECTED", + message, + }) case "BridgeKycOffboardedError": message = "Your account has been offboarded from Bridge. Please contact support." - return new ValidationInternalError({ message, logger: baseLogger }) + return bridgeGqlError({ + code: "BRIDGE_KYC_OFFBOARDED", + message, + }) case "BridgeCustomerNotFoundError": message = "Bridge customer not found" - return new ValidationInternalError({ message, logger: baseLogger }) + return bridgeGqlError({ + code: "BRIDGE_CUSTOMER_NOT_FOUND", + message, + }) case "BridgeInsufficientFundsError": message = "Insufficient funds for withdrawal" - return new ValidationInternalError({ message, logger: baseLogger }) + return bridgeGqlError({ + code: "BRIDGE_INSUFFICIENT_FUNDS", + message, + }) case "BridgeRateLimitError": message = "Rate limit exceeded, please try again later" - return new ValidationInternalError({ message, logger: baseLogger }) + return bridgeGqlError({ + code: "BRIDGE_RATE_LIMIT", + message, + }) case "BridgeTimeoutError": message = "Request timed out" - return new ValidationInternalError({ message, logger: baseLogger }) + return bridgeGqlError({ + code: "BRIDGE_TIMEOUT", + message, + }) case "BridgeTransferFailedError": message = error.message || "Transfer failed" - return new ValidationInternalError({ message, logger: baseLogger }) + return bridgeGqlError({ + code: "BRIDGE_TRANSFER_FAILED", + message, + }) case "BridgeWebhookValidationError": message = "Invalid webhook signature" - return new ValidationInternalError({ message, logger: baseLogger }) + return bridgeGqlError({ + code: "BRIDGE_WEBHOOK_VALIDATION", + message, + }) case "BridgeApiError": + message = error.message || "Bridge API error" + return bridgeGqlError({ + code: "BRIDGE_API_ERROR", + message, + }) + case "BridgeError": message = error.message || "Bridge API error" - return new UnknownClientError({ message, logger: baseLogger }) + return bridgeGqlError({ code: "BRIDGE_ERROR", message }) // ---------- // Unhandled below here diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index e78c343a2..d7f81eed8 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -306,8 +306,8 @@ type BridgeWithdrawal { createdAt: String! currency: String! failureReason: String - id: ID! - status: String! + state: String! + transferId: ID! } type BuildInformation { diff --git a/src/graphql/public/types/object/bridge-withdrawal.ts b/src/graphql/public/types/object/bridge-withdrawal.ts index 0c6298215..00a51c13c 100644 --- a/src/graphql/public/types/object/bridge-withdrawal.ts +++ b/src/graphql/public/types/object/bridge-withdrawal.ts @@ -3,10 +3,10 @@ import { GT } from "@graphql/index" const BridgeWithdrawal = GT.Object({ name: "BridgeWithdrawal", fields: () => ({ - id: { type: GT.NonNullID }, + transferId: { type: GT.NonNullID }, amount: { type: GT.NonNull(GT.String) }, currency: { type: GT.NonNull(GT.String) }, - status: { type: GT.NonNull(GT.String) }, + state: { type: GT.NonNull(GT.String) }, failureReason: { type: GT.String }, createdAt: { type: GT.NonNull(GT.String) }, }), diff --git a/test/flash/unit/graphql/bridge-error-map.spec.ts b/test/flash/unit/graphql/bridge-error-map.spec.ts new file mode 100644 index 000000000..194a83b39 --- /dev/null +++ b/test/flash/unit/graphql/bridge-error-map.spec.ts @@ -0,0 +1,54 @@ +import { mapAndParseErrorForGqlResponse, mapError } from "@graphql/error-map" +import { + BridgeAccountLevelError, + BridgeApiError, + BridgeBelowMinimumWithdrawalError, + BridgeCustomerNotFoundError, + BridgeDisabledError, + BridgeError, + BridgeInsufficientFundsError, + BridgeInvalidAmountError, + BridgeKycOffboardedError, + BridgeKycPendingError, + BridgeKycRejectedError, + BridgeRateLimitError, + BridgeTimeoutError, + BridgeTransferFailedError, + BridgeWebhookValidationError, +} from "@services/bridge/errors" + +describe("error-map: Bridge errors", () => { + const cases: Array<[Error, string]> = [ + [new BridgeInvalidAmountError(), "BRIDGE_INVALID_AMOUNT"], + [new BridgeBelowMinimumWithdrawalError(10), "BRIDGE_BELOW_MINIMUM_WITHDRAWAL"], + [new BridgeDisabledError(), "BRIDGE_DISABLED"], + [new BridgeAccountLevelError(), "BRIDGE_ACCOUNT_LEVEL_ERROR"], + [new BridgeKycPendingError(), "BRIDGE_KYC_PENDING"], + [new BridgeKycRejectedError(), "BRIDGE_KYC_REJECTED"], + [new BridgeKycOffboardedError(), "BRIDGE_KYC_OFFBOARDED"], + [new BridgeCustomerNotFoundError(), "BRIDGE_CUSTOMER_NOT_FOUND"], + [new BridgeInsufficientFundsError(), "BRIDGE_INSUFFICIENT_FUNDS"], + [new BridgeRateLimitError(), "BRIDGE_RATE_LIMIT"], + [new BridgeTimeoutError(), "BRIDGE_TIMEOUT"], + [new BridgeTransferFailedError(), "BRIDGE_TRANSFER_FAILED"], + [new BridgeWebhookValidationError(), "BRIDGE_WEBHOOK_VALIDATION"], + [new BridgeApiError("Bridge API error", 500), "BRIDGE_API_ERROR"], + [new BridgeError("Bridge unavailable"), "BRIDGE_ERROR"], + ] + + it.each(cases)("maps %p to %s", (input, expectedCode) => { + const result = mapError(input as ApplicationError) + + expect(result.extensions.code).toBe(expectedCode) + expect(result.extensions.code).not.toBe("INVALID_INPUT") + expect(result.message).toBeTruthy() + }) + + it.each(cases)("parses %p into payload error code %s", (input, expectedCode) => { + const result = mapAndParseErrorForGqlResponse(input as ApplicationError) + + expect(result.code).toBe(expectedCode) + expect(result.code).not.toBe("INVALID_INPUT") + expect(result.message).toBeTruthy() + }) +}) diff --git a/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts b/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts new file mode 100644 index 000000000..08afbfdc1 --- /dev/null +++ b/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts @@ -0,0 +1,48 @@ +import BridgeVirtualAccount from "@graphql/public/types/object/bridge-virtual-account" +import BridgeWithdrawal from "@graphql/public/types/object/bridge-withdrawal" +import { defaultFieldResolver } from "graphql" + +describe("Bridge public GraphQL object contract", () => { + it("exposes withdrawal fields returned by BridgeService", () => { + const fields = BridgeWithdrawal.getFields() + + expect(fields).toHaveProperty("transferId") + expect(fields).toHaveProperty("amount") + expect(fields).toHaveProperty("currency") + expect(fields).toHaveProperty("state") + expect(fields).toHaveProperty("createdAt") + expect(fields).not.toHaveProperty("id") + expect(fields).not.toHaveProperty("status") + }) + + it("resolves withdrawal transferId and state from service-shaped results", () => { + const fields = BridgeWithdrawal.getFields() + const withdrawal = { + transferId: "transfer-001", + amount: "25.00", + currency: "usdt", + state: "pending", + createdAt: "2026-06-05T00:00:00.000Z", + } + + expect( + defaultFieldResolver(withdrawal, {}, {}, { fieldName: "transferId" } as never), + ).toBe("transfer-001") + expect( + defaultFieldResolver(withdrawal, {}, {}, { fieldName: "state" } as never), + ).toBe("pending") + }) + + it("uses bridgeVirtualAccountId as the virtual account id returned by read queries", () => { + const idField = BridgeVirtualAccount.getFields().id + const virtualAccount = { + bridgeVirtualAccountId: "bridge-va-001", + bankName: "Test Bank", + routingNumber: "123456789", + accountNumber: "123456789012", + accountNumberLast4: "9012", + } + + expect(idField.resolve?.(virtualAccount, {}, {}, {})).toBe("bridge-va-001") + }) +})