Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -351,8 +351,8 @@ type BridgeWithdrawal
createdAt: String!
currency: String!
failureReason: String
id: ID!
status: String!
state: String!
transferId: ID!
}

"""
Expand Down
12 changes: 11 additions & 1 deletion docs/bridge-integration/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
90 changes: 75 additions & 15 deletions src/graphql/error-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,28 @@ import {
UnauthorizedIPMetadataCountryError,
IbexError,
InvalidLnurlError,
CustomApolloError,
} from "@graphql/error"
import { baseLogger } from "@services/logger"

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 = ""
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/graphql/public/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,8 @@ type BridgeWithdrawal {
createdAt: String!
currency: String!
failureReason: String
id: ID!
status: String!
state: String!
transferId: ID!
}

type BuildInformation {
Expand Down
4 changes: 2 additions & 2 deletions src/graphql/public/types/object/bridge-withdrawal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
}),
Expand Down
54 changes: 54 additions & 0 deletions test/flash/unit/graphql/bridge-error-map.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Original file line number Diff line number Diff line change
@@ -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")
})
})