diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 8816d85df..f4545b6bb 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -271,6 +271,19 @@ type BridgeAddExternalAccountPayload externalAccount: BridgeExternalAccountLink } +input BridgeCancelWithdrawalRequestInput + @join__type(graph: PUBLIC) +{ + withdrawalId: ID! +} + +type BridgeCancelWithdrawalRequestPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + withdrawal: BridgeWithdrawal +} + type BridgeCreateVirtualAccountPayload @join__type(graph: PUBLIC) { @@ -312,8 +325,7 @@ type BridgeInitiateKycPayload input BridgeInitiateWithdrawalInput @join__type(graph: PUBLIC) { - amount: String! - externalAccountId: ID! + withdrawalId: ID! } type BridgeInitiateWithdrawalPayload @@ -330,6 +342,20 @@ type BridgeKycLink tosLink: String! } +input BridgeRequestWithdrawalInput + @join__type(graph: PUBLIC) +{ + amount: String! + externalAccountId: ID! +} + +type BridgeRequestWithdrawalPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + withdrawal: BridgeWithdrawal +} + type BridgeVirtualAccount @join__type(graph: PUBLIC) { @@ -348,11 +374,13 @@ type BridgeWithdrawal @join__type(graph: PUBLIC) { amount: String! + bridgeTransferId: String createdAt: String! currency: String! + externalAccountId: String failureReason: String - state: String! - transferId: ID! + id: ID! + status: String! } """ @@ -1232,9 +1260,11 @@ type Mutation accountUpdateDefaultWalletId(input: AccountUpdateDefaultWalletIdInput!): AccountUpdateDefaultWalletIdPayload! accountUpdateDisplayCurrency(input: AccountUpdateDisplayCurrencyInput!): AccountUpdateDisplayCurrencyPayload! bridgeAddExternalAccount: BridgeAddExternalAccountPayload! + bridgeCancelWithdrawalRequest(input: BridgeCancelWithdrawalRequestInput!): BridgeCancelWithdrawalRequestPayload! bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload! bridgeInitiateKyc(input: BridgeInitiateKycInput!): BridgeInitiateKycPayload! bridgeInitiateWithdrawal(input: BridgeInitiateWithdrawalInput!): BridgeInitiateWithdrawalPayload! + bridgeRequestWithdrawal(input: BridgeRequestWithdrawalInput!): BridgeRequestWithdrawalPayload! businessAccountUpgradeRequest(input: BusinessAccountUpgradeRequestInput!): AccountUpgradePayload! callbackEndpointAdd(input: CallbackEndpointAddInput!): CallbackEndpointAddPayload! callbackEndpointDelete(input: CallbackEndpointDeleteInput!): SuccessPayload! @@ -1700,6 +1730,7 @@ type Query bridgeExternalAccounts: [BridgeExternalAccount] bridgeKycStatus: String bridgeVirtualAccount: BridgeVirtualAccount + bridgeWithdrawalRequest(id: ID!): BridgeWithdrawal bridgeWithdrawals: [BridgeWithdrawal] btcPrice(currency: DisplayCurrency! = "USD"): Price @deprecated(reason: "Deprecated in favor of realtimePrice") btcPriceList(range: PriceGraphRange!): [PricePoint] diff --git a/dev/bruno/Flash GraphQL API/environments/local.bru b/dev/bruno/Flash GraphQL API/environments/local.bru index 1cda70a56..55107b224 100644 --- a/dev/bruno/Flash GraphQL API/environments/local.bru +++ b/dev/bruno/Flash GraphQL API/environments/local.bru @@ -10,4 +10,6 @@ vars { walletIdUsd: c593736e-5a58-42e4-93fa-dc895856c1f1 userEmail: mauriente@gmail.com userFullName: maurientes + bridgeExternalAccountId: + bridgeWithdrawalId: } diff --git a/dev/bruno/Flash GraphQL API/token/mutations/bridgeCancelWithdrawalRequest.bru b/dev/bruno/Flash GraphQL API/token/mutations/bridgeCancelWithdrawalRequest.bru new file mode 100644 index 000000000..cbcfbc6fa --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/mutations/bridgeCancelWithdrawalRequest.bru @@ -0,0 +1,45 @@ +meta { + name: bridgeCancelWithdrawalRequest + type: graphql + seq: 39 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + mutation BridgeCancelWithdrawalRequest($input: BridgeCancelWithdrawalRequestInput!) { + bridgeCancelWithdrawalRequest(input: $input) { + errors { + message + } + withdrawal { + id + amount + currency + status + createdAt + } + } + } +} + +body:graphql:vars { + { + "input": { + "withdrawalId": "{{bridgeWithdrawalId}}" + } + } +} + +settings { + encodeUrl: false + timeout: 30000 +} diff --git a/dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateWithdrawal.bru b/dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateWithdrawal.bru new file mode 100644 index 000000000..e938f7ddd --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateWithdrawal.bru @@ -0,0 +1,45 @@ +meta { + name: bridgeInitiateWithdrawal + type: graphql + seq: 38 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + mutation BridgeInitiateWithdrawal($input: BridgeInitiateWithdrawalInput!) { + bridgeInitiateWithdrawal(input: $input) { + errors { + message + } + withdrawal { + id + amount + currency + status + createdAt + } + } + } +} + +body:graphql:vars { + { + "input": { + "withdrawalId": "{{bridgeWithdrawalId}}" + } + } +} + +settings { + encodeUrl: false + timeout: 30000 +} diff --git a/dev/bruno/Flash GraphQL API/token/mutations/bridgeRequestWithdrawal.bru b/dev/bruno/Flash GraphQL API/token/mutations/bridgeRequestWithdrawal.bru new file mode 100644 index 000000000..49fe78ba9 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/mutations/bridgeRequestWithdrawal.bru @@ -0,0 +1,47 @@ +meta { + name: bridgeRequestWithdrawal + type: graphql + seq: 37 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + mutation BridgeRequestWithdrawal($input: BridgeRequestWithdrawalInput!) { + bridgeRequestWithdrawal(input: $input) { + errors { + message + } + withdrawal { + id + amount + currency + externalAccountId + status + createdAt + } + } + } +} + +body:graphql:vars { + { + "input": { + "amount": "50.00", + "externalAccountId": "{{bridgeExternalAccountId}}" + } + } +} + +settings { + encodeUrl: false + timeout: 30000 +} diff --git a/dev/bruno/Flash GraphQL API/token/queries/bridgeWithdrawalRequest.bru b/dev/bruno/Flash GraphQL API/token/queries/bridgeWithdrawalRequest.bru new file mode 100644 index 000000000..68a428f40 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/queries/bridgeWithdrawalRequest.bru @@ -0,0 +1,40 @@ +meta { + name: bridgeWithdrawalRequest + type: graphql + seq: 33 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + query BridgeWithdrawalRequest($id: ID!) { + bridgeWithdrawalRequest(id: $id) { + id + amount + currency + externalAccountId + status + failureReason + createdAt + } + } +} + +body:graphql:vars { + { + "id": "{{bridgeWithdrawalId}}" + } +} + +settings { + encodeUrl: false + timeout: 30000 +} diff --git a/docs/bridge-integration/API.md b/docs/bridge-integration/API.md index 7628b9b90..f43046226 100644 --- a/docs/bridge-integration/API.md +++ b/docs/bridge-integration/API.md @@ -83,9 +83,43 @@ mutation BridgeAddExternalAccount { --- +### `bridgeRequestWithdrawal` + +Validates a withdrawal and creates a pending record for the confirmation screen. Does **not** call the Bridge API. If an identical pending request already exists (same account, amount, and external account), the existing record is returned. + +**Request:** +```graphql +mutation BridgeRequestWithdrawal($input: BridgeRequestWithdrawalInput!) { + bridgeRequestWithdrawal(input: $input) { + errors { + message + } + withdrawal { + id + amount + currency + externalAccountId + status + createdAt + } + } +} +``` + +**Input:** +- `amount`: String representation of the amount (e.g., "100.00"). Must be positive with at most 6 decimal places and above the configured minimum. +- `externalAccountId`: The ID of the linked bank account. + +**Response:** +- `id`: MongoDB withdrawal record ID — pass this to `bridgeInitiateWithdrawal` or `bridgeCancelWithdrawalRequest`. +- `status`: Always `"pending"` on success. +- `externalAccountId`: Linked bank account used for the withdrawal. + +--- + ### `bridgeInitiateWithdrawal` -Initiates a withdrawal from the user's USDT balance to a linked external bank account. +Submits a previously requested withdrawal to Bridge. Re-checks USDT balance at execution time. **Request:** ```graphql @@ -95,22 +129,61 @@ mutation BridgeInitiateWithdrawal($input: BridgeInitiateWithdrawalInput!) { message } withdrawal { - transferId + id amount currency - state + status + createdAt } } } ``` **Input:** -- `amount`: String representation of the amount (e.g., "100.00"). -- `externalAccountId`: The ID of the linked bank account. +- `withdrawalId`: The `id` returned by `bridgeRequestWithdrawal`. **Response:** -- `transferId`: Unique identifier for the transfer. -- `state`: Current state of the transfer (e.g., "pending", "processing"). +- `id`: Withdrawal record ID. +- `status`: Withdrawal status after Bridge transfer creation (typically `"pending"` until the webhook settles). + +**Errors:** +- `BridgeWithdrawalNotFoundError`: Withdrawal ID does not exist or belongs to another account. +- `BridgeWithdrawalAlreadyInitiatedError`: Withdrawal was already submitted to Bridge. +- `BridgeInsufficientFundsError`: Balance dropped between request and confirm. + +--- + +### `bridgeCancelWithdrawalRequest` + +Cancels a pending withdrawal before it has been submitted to Bridge. + +**Request:** +```graphql +mutation BridgeCancelWithdrawalRequest($input: BridgeCancelWithdrawalRequestInput!) { + bridgeCancelWithdrawalRequest(input: $input) { + errors { + message + } + withdrawal { + id + amount + currency + status + createdAt + } + } +} +``` + +**Input:** +- `withdrawalId`: The `id` returned by `bridgeRequestWithdrawal`. + +**Response:** +- `status`: `"cancelled"` on success. + +**Errors:** +- `BridgeWithdrawalNotFoundError`: Withdrawal ID does not exist or belongs to another account. +- `BridgeWithdrawalAlreadyInitiatedError`: Transfer was already submitted to Bridge and cannot be cancelled. --- @@ -171,18 +244,41 @@ query BridgeExternalAccounts { --- +### `bridgeWithdrawalRequest` + +Fetches a single withdrawal record by ID for the confirmation screen. Returns `null` if the ID does not exist or belongs to another account (no cross-account leakage). + +**Request:** +```graphql +query BridgeWithdrawalRequest($id: ID!) { + bridgeWithdrawalRequest(id: $id) { + id + amount + currency + externalAccountId + status + failureReason + createdAt + } +} +``` + +--- + ### `bridgeWithdrawals` -Lists the user's withdrawal history. +Lists the user's withdrawal history (submitted transfers only). **Request:** ```graphql query BridgeWithdrawals { bridgeWithdrawals { - transferId + id amount currency - state + status + bridgeTransferId + failureReason createdAt } } @@ -200,6 +296,8 @@ query BridgeWithdrawals { | `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_WITHDRAWAL_NOT_FOUND` | Withdrawal request not found or does not belong to the caller. | +| `BRIDGE_WITHDRAWAL_ALREADY_INITIATED` | Withdrawal was already submitted to Bridge. | | `BRIDGE_INSUFFICIENT_FUNDS` | USDT balance is insufficient for the withdrawal. | | `BRIDGE_RATE_LIMIT` | Bridge rate-limited the request. | | `BRIDGE_TIMEOUT` | Bridge request timed out. | diff --git a/docs/bridge-integration/FLOWS.md b/docs/bridge-integration/FLOWS.md index 9b76cffe8..f147d3c4b 100644 --- a/docs/bridge-integration/FLOWS.md +++ b/docs/bridge-integration/FLOWS.md @@ -96,17 +96,23 @@ User Flash App Flash Backend Bridge.xyz | | | 13. ext_acc.verified| | | | |<--------------------| | | 14. Withdraw | | | | - |----------------->| 15. bridgeInitWith| | | - | |------------------>| 16. Create Transfer | | - | | |-------------------->| | - | | 17. Pending | | | + |----------------->| 15. bridgeRequest | | | + | | Withdrawal | | | + | |------------------>| 16. Store pending | | + | | 17. Confirm screen| withdrawal | | + | |<------------------| | | + | 18. Confirm | | | | + |----------------->| 19. bridgeInitWith| | | + | | (withdrawalId)| 20. Create Transfer | | + | |------------------>|-------------------->| | + | | 21. Pending | | | |<-----------------| | | | - | | | | 18. Convert USDT | - | | | | 19. Send ACH | + | | | | 22. Convert USDT | + | | | | 23. Send ACH | | | | |------------------>| - | | | 20. trans.completed | | + | | | 24. trans.completed | | | | |<--------------------| | - | 21. Funds Arrive | | | | + | 25. Funds Arrive | | | | |<-------------------------------------------------------------------------------| ``` @@ -118,14 +124,18 @@ User Flash App Flash Backend Bridge.xyz 4. **Redirect**: App opens the Bridge/Plaid flow. 5. **Authentication**: User logs into their bank and selects an account. 6. **Verification Webhook**: Bridge notifies Flash when the external account is verified. -7. **Initiate Withdrawal**: User enters amount and selects the linked bank account. -8. **GraphQL Mutation**: App calls `bridgeInitiateWithdrawal`. -9. **Bridge Transfer**: Flash creates a transfer in Bridge from the user's Tron address to the external account. -10. **Confirmation**: App shows the withdrawal as "Pending". -11. **Conversion**: Bridge converts USDT from the user's balance to USD. -12. **ACH Transfer**: Bridge sends USD to the user's bank via ACH. -13. **Transfer Webhook**: Bridge sends `transfer.completed` webhook to Flash. -14. **Completion**: User receives funds in their bank account (usually 1-3 business days). +7. **Request Withdrawal**: User enters amount and selects the linked bank account. +8. **GraphQL Mutation**: App calls `bridgeRequestWithdrawal` with `amount` and `externalAccountId`. +9. **Validation**: Flash checks USDT balance, account level, and external account ownership/verification. A `pending` withdrawal record is stored in MongoDB. If an identical pending request already exists (same account, amount, and bank account), the existing record is reused. +10. **Confirmation Screen**: App fetches the pending withdrawal via `bridgeWithdrawalRequest(id)` and displays amount, bank account, and fees for user review. +11. **User Confirms or Cancels**: + - **Confirm**: App calls `bridgeInitiateWithdrawal` with `withdrawalId`. Flash re-checks balance, then creates a transfer in Bridge from the user's Ethereum USDT address to the external account. + - **Cancel**: App calls `bridgeCancelWithdrawalRequest` with `withdrawalId`. The pending record is marked `cancelled` and a push notification is sent. +12. **Pending State**: After initiation, app shows the withdrawal as "Pending". +13. **Conversion**: Bridge converts USDT from the user's balance to USD. +14. **ACH Transfer**: Bridge sends USD to the user's bank via ACH. +15. **Transfer Webhook**: Bridge sends `transfer.completed` (or failure) webhook to Flash. +16. **Completion**: User receives funds in their bank account (usually 1-3 business days). ## Fee Structure diff --git a/src/app/bridge/send-withdrawal-notification.ts b/src/app/bridge/send-withdrawal-notification.ts index cc5c7ae29..07003a18f 100644 --- a/src/app/bridge/send-withdrawal-notification.ts +++ b/src/app/bridge/send-withdrawal-notification.ts @@ -20,7 +20,7 @@ const i18n = getI18nInstance() const formatWithdrawalAmount = (amount: string, currency: string): string => `${amount} ${currency.toUpperCase()}` -export type BridgeWithdrawalNotificationOutcome = "completed" | "failed" +export type BridgeWithdrawalNotificationOutcome = "completed" | "failed" | "cancelled" export const sendBridgeWithdrawalNotification = async ({ accountId: accountIdRaw, diff --git a/src/config/locales/en.json b/src/config/locales/en.json index da4398833..2b286f01c 100644 --- a/src/config/locales/en.json +++ b/src/config/locales/en.json @@ -46,6 +46,10 @@ "title": "Deposit received" }, "bridgeWithdrawal": { + "cancelled": { + "body": "Your withdrawal of {{amount}} has been cancelled.", + "title": "Withdrawal cancelled" + }, "completed": { "body": "Your withdrawal of {{amount}} has been sent to your bank account.", "title": "Withdrawal complete" diff --git a/src/config/locales/es.json b/src/config/locales/es.json index aa4cef58f..9d3530d0a 100644 --- a/src/config/locales/es.json +++ b/src/config/locales/es.json @@ -42,6 +42,10 @@ "title": "Depósito recibido" }, "bridgeWithdrawal": { + "cancelled": { + "body": "Su retiro de {{amount}} ha sido cancelado.", + "title": "Retiro cancelado" + }, "completed": { "body": "Su retiro de {{amount}} se envió a su cuenta bancaria.", "title": "Retiro completado" diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 392cfb0e5..9defa8f00 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -555,6 +555,22 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message, }) + case "BridgeWithdrawalNotFoundError": + message = error.message || "Withdrawal request not found" + return bridgeGqlError({ + code: "BRIDGE_WITHDRAWAL_NOT_FOUND", + message, + }) + + case "BridgeWithdrawalAlreadyInitiatedError": + message = + error.message || + "Withdrawal has already been submitted to Bridge and cannot be cancelled" + return bridgeGqlError({ + code: "BRIDGE_WITHDRAWAL_ALREADY_INITIATED", + message, + }) + case "BridgeRateLimitError": message = "Rate limit exceeded, please try again later" return bridgeGqlError({ @@ -794,9 +810,8 @@ export const mapError = (error: ApplicationError): CustomApolloError => { case "InvalidCarrierForPhoneMetadataError": case "InvalidCarrierTypeForPhoneMetadataError": case "InvalidCountryCodeForPhoneMetadataError": - message = `Unexpected error occurred, please try again or contact support if it persists (code: ${ - error.name - }${error.message ? ": " + error.message : ""})` + message = `Unexpected error occurred, please try again or contact support if it persists (code: ${error.name + }${error.message ? ": " + error.message : ""})` return new UnexpectedClientError({ message, logger: baseLogger }) case "MissingSessionIdError": @@ -892,9 +907,8 @@ export const mapError = (error: ApplicationError): CustomApolloError => { return new ValidationInternalError({ message, logger: baseLogger }) case "UnknownCaptchaError": - message = `Unknown error occurred (code: ${error.name}${ - error.message ? ": " + error.message : "" - })` + message = `Unknown error occurred (code: ${error.name}${error.message ? ": " + error.message : "" + })` return new UnknownClientError({ message, logger: baseLogger }) default: diff --git a/src/graphql/public/mutations.ts b/src/graphql/public/mutations.ts index 75207efc4..79f9d3df7 100644 --- a/src/graphql/public/mutations.ts +++ b/src/graphql/public/mutations.ts @@ -65,7 +65,9 @@ import UpdateExternalWalletMutation from "./root/mutation/update-external-wallet import BridgeInitiateKycMutation from "./root/mutation/bridge-initiate-kyc" import BridgeCreateVirtualAccountMutation from "./root/mutation/bridge-create-virtual-account" import BridgeAddExternalAccountMutation from "./root/mutation/bridge-add-external-account" +import BridgeRequestWithdrawalMutation from "./root/mutation/bridge-request-withdrawal" import BridgeInitiateWithdrawalMutation from "./root/mutation/bridge-initiate-withdrawal" +import BridgeCancelWithdrawalRequestMutation from "./root/mutation/bridge-cancel-withdrawal-request" // TODO: // const fields: { [key: string]: GraphQLFieldConfig } export const mutationFields = { @@ -123,7 +125,9 @@ export const mutationFields = { bridgeInitiateKyc: BridgeInitiateKycMutation, bridgeCreateVirtualAccount: BridgeCreateVirtualAccountMutation, bridgeAddExternalAccount: BridgeAddExternalAccountMutation, + bridgeRequestWithdrawal: BridgeRequestWithdrawalMutation, bridgeInitiateWithdrawal: BridgeInitiateWithdrawalMutation, + bridgeCancelWithdrawalRequest: BridgeCancelWithdrawalRequestMutation, }, atWalletLevel: { diff --git a/src/graphql/public/queries.ts b/src/graphql/public/queries.ts index 7a401d97e..7bb5a98ab 100644 --- a/src/graphql/public/queries.ts +++ b/src/graphql/public/queries.ts @@ -26,6 +26,7 @@ import SupportedBanksQuery from "./root/query/supported-banks" import BridgeKycStatusQuery from "./root/query/bridge-kyc-status" import BridgeVirtualAccountQuery from "./root/query/bridge-virtual-account" import BridgeExternalAccountsQuery from "./root/query/bridge-external-accounts" +import BridgeWithdrawalRequestQuery from "./root/query/bridge-withdrawal-request" import BridgeWithdrawalsQuery from "./root/query/bridge-withdrawals" export const queryFields = { @@ -55,6 +56,7 @@ export const queryFields = { bridgeKycStatus: BridgeKycStatusQuery, bridgeVirtualAccount: BridgeVirtualAccountQuery, bridgeExternalAccounts: BridgeExternalAccountsQuery, + bridgeWithdrawalRequest: BridgeWithdrawalRequestQuery, bridgeWithdrawals: BridgeWithdrawalsQuery, }, atWalletLevel: { diff --git a/src/graphql/public/root/mutation/bridge-cancel-withdrawal-request.ts b/src/graphql/public/root/mutation/bridge-cancel-withdrawal-request.ts new file mode 100644 index 000000000..1cdd9d9f1 --- /dev/null +++ b/src/graphql/public/root/mutation/bridge-cancel-withdrawal-request.ts @@ -0,0 +1,52 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import IError from "@graphql/shared/types/abstract/error" +import BridgeWithdrawal from "@graphql/public/types/object/bridge-withdrawal" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" + +const BridgeCancelWithdrawalRequestInput = GT.Input({ + name: "BridgeCancelWithdrawalRequestInput", + fields: () => ({ + withdrawalId: { type: GT.NonNull(GT.ID) }, + }), +}) + +const BridgeCancelWithdrawalRequestPayload = GT.Object({ + name: "BridgeCancelWithdrawalRequestPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + withdrawal: { type: BridgeWithdrawal }, + }), +}) + +const bridgeCancelWithdrawalRequest = GT.Field({ + type: GT.NonNull(BridgeCancelWithdrawalRequestPayload), + args: { + input: { type: GT.NonNull(BridgeCancelWithdrawalRequestInput) }, + }, + resolve: async (_, args, { domainAccount }: GraphQLPublicContextAuth) => { + const { withdrawalId } = args.input + + if (!BridgeConfig.enabled) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] } + } + + if (!domainAccount || domainAccount.level <= 0) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] } + } + + const result = await BridgeService.cancelWithdrawalRequest( + domainAccount.id, + withdrawalId, + ) + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { withdrawal: result, errors: [] } + }, +}) + +export default bridgeCancelWithdrawalRequest diff --git a/src/graphql/public/root/mutation/bridge-initiate-withdrawal.ts b/src/graphql/public/root/mutation/bridge-initiate-withdrawal.ts index 4eff628ba..855136aaf 100644 --- a/src/graphql/public/root/mutation/bridge-initiate-withdrawal.ts +++ b/src/graphql/public/root/mutation/bridge-initiate-withdrawal.ts @@ -4,18 +4,12 @@ import IError from "@graphql/shared/types/abstract/error" import BridgeWithdrawal from "@graphql/public/types/object/bridge-withdrawal" import { BridgeConfig } from "@config" import BridgeService from "@services/bridge" -import { - BridgeDisabledError, - BridgeAccountLevelError, - BridgeInvalidAmountError, - BridgeBelowMinimumWithdrawalError, -} from "@services/bridge/errors" +import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" const BridgeInitiateWithdrawalInput = GT.Input({ name: "BridgeInitiateWithdrawalInput", fields: () => ({ - amount: { type: GT.NonNull(GT.String) }, - externalAccountId: { type: GT.NonNull(GT.ID) }, + withdrawalId: { type: GT.NonNull(GT.ID) }, }), }) @@ -33,25 +27,7 @@ const bridgeInitiateWithdrawal = GT.Field({ input: { type: GT.NonNull(BridgeInitiateWithdrawalInput) }, }, resolve: async (_, args, { domainAccount }: GraphQLPublicContextAuth) => { - const { amount, externalAccountId } = args.input - - // validate the amount is positive and has at most 6 decimal places - if (!/^\d+(\.\d{1,6})?$/.test(amount) || parseFloat(amount) <= 0) { - return { - errors: [mapAndParseErrorForGqlResponse(new BridgeInvalidAmountError())], - } - } - - // validate the amount is greater than the minimum withdrawal amount - if (parseFloat(amount) < BridgeConfig.minWithdrawalAmount) { - return { - errors: [ - mapAndParseErrorForGqlResponse( - new BridgeBelowMinimumWithdrawalError(BridgeConfig.minWithdrawalAmount), - ), - ], - } - } + const { withdrawalId } = args.input if (!BridgeConfig.enabled) { return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] } @@ -61,11 +37,7 @@ const bridgeInitiateWithdrawal = GT.Field({ return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] } } - const result = await BridgeService.initiateWithdrawal( - domainAccount.id, - amount, - externalAccountId, - ) + const result = await BridgeService.initiateWithdrawal(domainAccount.id, withdrawalId) if (result instanceof Error) { return { errors: [mapAndParseErrorForGqlResponse(result)] } } diff --git a/src/graphql/public/root/mutation/bridge-request-withdrawal.ts b/src/graphql/public/root/mutation/bridge-request-withdrawal.ts new file mode 100644 index 000000000..2aba8f945 --- /dev/null +++ b/src/graphql/public/root/mutation/bridge-request-withdrawal.ts @@ -0,0 +1,75 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import IError from "@graphql/shared/types/abstract/error" +import BridgeWithdrawal from "@graphql/public/types/object/bridge-withdrawal" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { + BridgeDisabledError, + BridgeAccountLevelError, + BridgeInvalidAmountError, + BridgeBelowMinimumWithdrawalError, +} from "@services/bridge/errors" + +const BridgeRequestWithdrawalInput = GT.Input({ + name: "BridgeRequestWithdrawalInput", + fields: () => ({ + amount: { type: GT.NonNull(GT.String) }, + externalAccountId: { type: GT.NonNull(GT.ID) }, + }), +}) + +const BridgeRequestWithdrawalPayload = GT.Object({ + name: "BridgeRequestWithdrawalPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + withdrawal: { type: BridgeWithdrawal }, + }), +}) + +const bridgeRequestWithdrawal = GT.Field({ + type: GT.NonNull(BridgeRequestWithdrawalPayload), + args: { + input: { type: GT.NonNull(BridgeRequestWithdrawalInput) }, + }, + resolve: async (_, args, { domainAccount }: GraphQLPublicContextAuth) => { + const { amount, externalAccountId } = args.input + + if (!/^\d+(\.\d{1,6})?$/.test(amount) || parseFloat(amount) <= 0) { + return { + errors: [mapAndParseErrorForGqlResponse(new BridgeInvalidAmountError())], + } + } + + if (parseFloat(amount) < BridgeConfig.minWithdrawalAmount) { + return { + errors: [ + mapAndParseErrorForGqlResponse( + new BridgeBelowMinimumWithdrawalError(BridgeConfig.minWithdrawalAmount), + ), + ], + } + } + + if (!BridgeConfig.enabled) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] } + } + + if (!domainAccount || domainAccount.level <= 0) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] } + } + + const result = await BridgeService.requestWithdrawal( + domainAccount.id, + amount, + externalAccountId, + ) + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { withdrawal: result, errors: [] } + }, +}) + +export default bridgeRequestWithdrawal diff --git a/src/graphql/public/root/query/bridge-withdrawal-request.ts b/src/graphql/public/root/query/bridge-withdrawal-request.ts new file mode 100644 index 000000000..289fd83d2 --- /dev/null +++ b/src/graphql/public/root/query/bridge-withdrawal-request.ts @@ -0,0 +1,40 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import BridgeWithdrawal from "@graphql/public/types/object/bridge-withdrawal" +import { BridgeConfig } from "@config" +import { BridgeDisabledError } from "@services/bridge/errors" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" +import { RepositoryError } from "@domain/errors" + +const bridgeWithdrawalRequest = GT.Field({ + type: BridgeWithdrawal, + args: { + id: { type: GT.NonNull(GT.ID) }, + }, + resolve: async (_, args, { domainAccount }: GraphQLPublicContextAuth) => { + if (!BridgeConfig.enabled) { + throw mapAndParseErrorForGqlResponse(new BridgeDisabledError()) + } + + if (!domainAccount) return null + + const withdrawal = await BridgeAccountsRepo.findWithdrawalById(args.id) + if (withdrawal instanceof RepositoryError) return null + + // Ownership check — never expose another account's withdrawal + if (withdrawal.accountId !== (domainAccount.id as string)) return null + + return { + id: withdrawal.id, + amount: withdrawal.amount, + currency: withdrawal.currency, + externalAccountId: withdrawal.externalAccountId, + status: withdrawal.status, + bridgeTransferId: withdrawal.bridgeTransferId, + failureReason: withdrawal.failureReason, + createdAt: withdrawal.createdAt.toISOString(), + } + }, +}) + +export default bridgeWithdrawalRequest diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index fbc3366e0..26dd8eae0 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -246,6 +246,15 @@ type BridgeAddExternalAccountPayload { externalAccount: BridgeExternalAccountLink } +input BridgeCancelWithdrawalRequestInput { + withdrawalId: ID! +} + +type BridgeCancelWithdrawalRequestPayload { + errors: [Error!]! + withdrawal: BridgeWithdrawal +} + type BridgeCreateVirtualAccountPayload { errors: [Error!]! virtualAccount: BridgeVirtualAccount @@ -275,8 +284,7 @@ type BridgeInitiateKycPayload { } input BridgeInitiateWithdrawalInput { - amount: String! - externalAccountId: ID! + withdrawalId: ID! } type BridgeInitiateWithdrawalPayload { @@ -289,6 +297,16 @@ type BridgeKycLink { tosLink: String! } +input BridgeRequestWithdrawalInput { + amount: String! + externalAccountId: ID! +} + +type BridgeRequestWithdrawalPayload { + errors: [Error!]! + withdrawal: BridgeWithdrawal +} + type BridgeVirtualAccount { accountNumber: String accountNumberLast4: String @@ -303,11 +321,13 @@ type BridgeVirtualAccount { type BridgeWithdrawal { amount: String! + bridgeTransferId: String createdAt: String! currency: String! + externalAccountId: String failureReason: String - state: String! - transferId: ID! + id: ID! + status: String! } type BuildInformation { @@ -956,9 +976,11 @@ type Mutation { accountUpdateDefaultWalletId(input: AccountUpdateDefaultWalletIdInput!): AccountUpdateDefaultWalletIdPayload! accountUpdateDisplayCurrency(input: AccountUpdateDisplayCurrencyInput!): AccountUpdateDisplayCurrencyPayload! bridgeAddExternalAccount: BridgeAddExternalAccountPayload! + bridgeCancelWithdrawalRequest(input: BridgeCancelWithdrawalRequestInput!): BridgeCancelWithdrawalRequestPayload! bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload! bridgeInitiateKyc(input: BridgeInitiateKycInput!): BridgeInitiateKycPayload! bridgeInitiateWithdrawal(input: BridgeInitiateWithdrawalInput!): BridgeInitiateWithdrawalPayload! + bridgeRequestWithdrawal(input: BridgeRequestWithdrawalInput!): BridgeRequestWithdrawalPayload! businessAccountUpgradeRequest(input: BusinessAccountUpgradeRequestInput!): AccountUpgradePayload! callbackEndpointAdd(input: CallbackEndpointAddInput!): CallbackEndpointAddPayload! callbackEndpointDelete(input: CallbackEndpointDeleteInput!): SuccessPayload! @@ -1336,6 +1358,7 @@ type Query { bridgeExternalAccounts: [BridgeExternalAccount] bridgeKycStatus: String bridgeVirtualAccount: BridgeVirtualAccount + bridgeWithdrawalRequest(id: ID!): BridgeWithdrawal bridgeWithdrawals: [BridgeWithdrawal] btcPrice(currency: DisplayCurrency! = "USD"): Price @deprecated(reason: "Deprecated in favor of realtimePrice") btcPriceList(range: PriceGraphRange!): [PricePoint] diff --git a/src/graphql/public/types/object/bridge-withdrawal.ts b/src/graphql/public/types/object/bridge-withdrawal.ts index 00a51c13c..7d95cbf33 100644 --- a/src/graphql/public/types/object/bridge-withdrawal.ts +++ b/src/graphql/public/types/object/bridge-withdrawal.ts @@ -3,10 +3,12 @@ import { GT } from "@graphql/index" const BridgeWithdrawal = GT.Object({ name: "BridgeWithdrawal", fields: () => ({ - transferId: { type: GT.NonNullID }, + id: { type: GT.NonNullID }, amount: { type: GT.NonNull(GT.String) }, currency: { type: GT.NonNull(GT.String) }, - state: { type: GT.NonNull(GT.String) }, + externalAccountId: { type: GT.String }, + status: { type: GT.NonNull(GT.String) }, + bridgeTransferId: { type: GT.String }, failureReason: { type: GT.String }, createdAt: { type: GT.NonNull(GT.String) }, }), diff --git a/src/services/bridge/errors.ts b/src/services/bridge/errors.ts index b0b6178fc..9eff26707 100644 --- a/src/services/bridge/errors.ts +++ b/src/services/bridge/errors.ts @@ -95,6 +95,18 @@ export class BridgeWebhookValidationError extends BridgeError { } } +export class BridgeWithdrawalNotFoundError extends BridgeError { + constructor(message: string = "Withdrawal request not found") { + super(message) + } +} + +export class BridgeWithdrawalAlreadyInitiatedError extends BridgeError { + constructor(message: string = "Withdrawal has already been submitted to Bridge and cannot be cancelled") { + super(message) + } +} + /** * Maps HTTP status codes from Bridge API to domain error types */ diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index ba0fab74e..b2c19fe57 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -10,12 +10,14 @@ import { BridgeConfig } from "@config" import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" import { AccountsRepository } from "@services/mongoose/accounts" +import { BridgeVirtualAccount } from "@services/mongoose/schema" import { wrapAsyncFunctionsToRunInSpan } from "@services/tracing" import { baseLogger } from "@services/logger" import { RepositoryError } from "@domain/errors" import { toBridgeCustomerId, toBridgeVirtualAccountId } from "@domain/primitives/bridge" import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" +import { sendBridgeWithdrawalNotificationBestEffort } from "@app/bridge/send-withdrawal-notification" import { USDTAmount, WalletCurrency } from "@domain/shared" import { WalletType } from "@domain/wallets" import { WalletsRepository } from "@services/mongoose/wallets" @@ -32,9 +34,10 @@ import { BridgeKycRejectedError, BridgeKycOffboardedError, BridgeCustomerNotFoundError, + BridgeWithdrawalNotFoundError, + BridgeWithdrawalAlreadyInitiatedError, } from "./errors" -import BridgeApiClient, { BridgeClient } from "./client" -import { BridgeVirtualAccount } from "@services/mongoose/schema" +import BridgeApiClient from "./client" // ============ Types ============ @@ -57,18 +60,41 @@ type AddExternalAccountResult = { expiresAt: string } +type WithdrawalRequestResult = { + id: string + amount: string + currency: string + externalAccountId: string + status: string + failureReason?: string + createdAt: string +} + type InitiateWithdrawalResult = { - transferId: string + id: string + amount: string + currency: string + status: string + bridgeTransferId?: string + createdAt: string +} + +type CancelWithdrawalResult = { + id: string amount: string currency: string - state: string + status: string + createdAt: string } type WithdrawalResult = { - transferId: string + id: string amount: string currency: string - state: string + externalAccountId: string + status: string + bridgeTransferId?: string + failureReason?: string createdAt: string } @@ -221,7 +247,7 @@ const initiateKyc = async ({ // store the customer id and the kyc status const customerId = toBridgeCustomerId(bridgeError.response.existing_kyc_link.customer_id) - const updateResult = await AccountsRepository().updateBridgeFields(accountId, { + await AccountsRepository().updateBridgeFields(accountId, { bridgeCustomerId: customerId, bridgeKycStatus: "not_started", }) @@ -295,7 +321,7 @@ const createVirtualAccount = async ( return customer } - let kycStatus = customer.status + const kycStatus = customer.status // Check KYC status @@ -333,7 +359,7 @@ const createVirtualAccount = async ( let ethereumAddress = account.bridgeEthereumAddress if (!ethereumAddress) { - let option = await IbexClient.getEthereumUsdtOption() + const option = await IbexClient.getEthereumUsdtOption() if (option instanceof Error) return new BridgeError(option.message) option.name = `USDT-ETH ${account.username}-${crypto.randomBytes(4).toString("hex")}` @@ -456,16 +482,17 @@ const addExternalAccount = async ( } /** - * Initiates a withdrawal from USDT to USD bank account - * - Orchestrates IBEX → Bridge transfer + * Requests a withdrawal — validates everything and stores a pending record in MongoDB. + * Does NOT call the Bridge API. Returns the pending withdrawal so the frontend can + * display a confirmation screen before the user commits. */ -const initiateWithdrawal = async ( +const requestWithdrawal = async ( accountId: AccountId, amount: string, externalAccountId: string, -): Promise => { +): Promise => { baseLogger.info( - { accountId, amount, externalAccountId, operation: "initiateWithdrawal" }, + { accountId, amount, externalAccountId, operation: "requestWithdrawal" }, "Bridge operation started", ) @@ -513,12 +540,7 @@ const initiateWithdrawal = async ( const availableBalance = balance.toIbex() if (availableBalance < withdrawalAmount) { baseLogger.warn( - { - accountId, - availableBalance, - withdrawalAmount, - operation: "initiateWithdrawal", - }, + { accountId, availableBalance, withdrawalAmount, operation: "requestWithdrawal" }, "Insufficient USDT balance for withdrawal", ) return new BridgeInsufficientFundsError( @@ -526,8 +548,7 @@ const initiateWithdrawal = async ( ) } - // CRIT-2 (ENG-281): Verify caller owns this external account (ownership enforced here - // and at DB level via compound index — see schema.ts BridgeExternalAccountSchema) + // CRIT-2 (ENG-281): Verify caller owns this external account const externalAccounts = await BridgeAccountsRepo.findExternalAccountsByAccountId( accountId as string, ) @@ -537,7 +558,6 @@ const initiateWithdrawal = async ( (acc) => acc.bridgeExternalAccountId === externalAccountId, ) if (!targetAccount) { - // Do not leak existence — return same error regardless of whether account exists return new Error("External account not found") } if (targetAccount.status !== "verified") { @@ -552,7 +572,6 @@ const initiateWithdrawal = async ( ) if (existingWithdrawal instanceof Error) return existingWithdrawal - // Store withdrawal record, or reuse the in-flight row for a retry of the same request. const pendingWithdrawal = existingWithdrawal || (await BridgeAccountsRepo.createWithdrawal({ @@ -564,9 +583,100 @@ const initiateWithdrawal = async ( })) if (pendingWithdrawal instanceof Error) return pendingWithdrawal + baseLogger.info( + { accountId, operation: "requestWithdrawal", withdrawalId: pendingWithdrawal.id }, + "Bridge operation completed", + ) + + return { + id: pendingWithdrawal.id, + amount: pendingWithdrawal.amount, + currency: pendingWithdrawal.currency, + externalAccountId: pendingWithdrawal.externalAccountId, + status: pendingWithdrawal.status, + failureReason: pendingWithdrawal.failureReason, + createdAt: pendingWithdrawal.createdAt.toISOString(), + } + } catch (error) { + baseLogger.error( + { accountId, operation: "requestWithdrawal", error }, + "Bridge operation failed", + ) + return error instanceof Error ? error : new Error(String(error)) + } +} + +/** + * Initiates a previously requested withdrawal — fetches the pending record by ID, + * re-checks balance, then submits the transfer to Bridge. + */ +const initiateWithdrawal = async ( + accountId: AccountId, + withdrawalId: string, +): Promise => { + baseLogger.info( + { accountId, withdrawalId, operation: "initiateWithdrawal" }, + "Bridge operation started", + ) + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + try { + const customerId = account.bridgeCustomerId + if (!customerId) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + + const ethereumAddress = account.bridgeEthereumAddress + if (!ethereumAddress) { + return new Error("Account has no Ethereum address. Create virtual account first.") + } + + const pendingWithdrawal = await BridgeAccountsRepo.findWithdrawalById(withdrawalId) + if (pendingWithdrawal instanceof Error) { + return new BridgeWithdrawalNotFoundError() + } + if (pendingWithdrawal.accountId !== (accountId as string)) { + return new BridgeWithdrawalNotFoundError() + } + if (pendingWithdrawal.status !== "pending" || pendingWithdrawal.bridgeTransferId) { + return new BridgeWithdrawalAlreadyInitiatedError() + } + + const { amount, externalAccountId } = pendingWithdrawal + + // Re-check balance at execution time — funds may have changed since the request + const wallets = await WalletsRepository().listByAccountId(accountId) + if (wallets instanceof Error) return wallets + const usdtWallet = wallets.find( + (w) => w.currency === WalletCurrency.Usdt && w.type === WalletType.Checking, + ) + if (!usdtWallet) { + return new BridgeInsufficientFundsError("No USDT wallet found on account") + } + const balance = await getBalanceForWallet({ + walletId: usdtWallet.id, + currency: WalletCurrency.Usdt, + }) + if (balance instanceof Error) return balance + if (!(balance instanceof USDTAmount)) { + return new BridgeInsufficientFundsError("Invalid balance type") + } + const availableBalance = balance.toIbex() + if (availableBalance < parseFloat(amount)) { + return new BridgeInsufficientFundsError( + `Insufficient USDT balance: available ${availableBalance}, requested ${amount}`, + ) + } + const idempotencyKey = deriveWithdrawalIdempotencyKey(pendingWithdrawal.id) - // Create transfer via Bridge const transfer = await BridgeApiClient.createTransfer( customerId, { @@ -586,28 +696,27 @@ const initiateWithdrawal = async ( idempotencyKey, ) - const result: InitiateWithdrawalResult = { - transferId: transfer.id, - amount: transfer.amount, - currency: transfer.currency, - state: transfer.state, - } - - const withdrawalResult = await BridgeAccountsRepo.updateWithdrawalTransferId( + const updated = await BridgeAccountsRepo.updateWithdrawalTransferId( pendingWithdrawal.id, transfer.id, transfer.amount, transfer.currency, ) - - if (withdrawalResult instanceof Error) return withdrawalResult + if (updated instanceof Error) return updated baseLogger.info( { accountId, operation: "initiateWithdrawal", transferId: transfer.id }, "Bridge operation completed", ) - return result + return { + id: updated.id, + amount: updated.amount, + currency: updated.currency, + status: updated.status, + bridgeTransferId: updated.bridgeTransferId, + createdAt: updated.createdAt.toISOString(), + } } catch (error) { baseLogger.error( { accountId, operation: "initiateWithdrawal", error }, @@ -617,6 +726,74 @@ const initiateWithdrawal = async ( } } +/** + * Cancels a pending withdrawal request before it has been submitted to Bridge. + * Fails if the withdrawal already has a bridgeTransferId (transfer in-flight). + */ +const cancelWithdrawalRequest = async ( + accountId: AccountId, + withdrawalId: string, +): Promise => { + baseLogger.info( + { accountId, withdrawalId, operation: "cancelWithdrawalRequest" }, + "Bridge operation started", + ) + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + try { + // Verify the withdrawal exists and belongs to this account before attempting cancel + const withdrawal = await BridgeAccountsRepo.findWithdrawalById(withdrawalId) + if (withdrawal instanceof Error) { + return new BridgeWithdrawalNotFoundError() + } + if (withdrawal.accountId !== (accountId as string)) { + return new BridgeWithdrawalNotFoundError() + } + if (withdrawal.bridgeTransferId) { + return new BridgeWithdrawalAlreadyInitiatedError() + } + + const cancelled = await BridgeAccountsRepo.cancelWithdrawal( + accountId as string, + withdrawalId, + ) + if (cancelled instanceof Error) { + return new BridgeWithdrawalNotFoundError() + } + + baseLogger.info( + { accountId, operation: "cancelWithdrawalRequest", withdrawalId }, + "Bridge operation completed", + ) + + await sendBridgeWithdrawalNotificationBestEffort({ + accountId: accountId as string, + amount: cancelled.amount, + currency: cancelled.currency, + outcome: "cancelled", + }) + + return { + id: cancelled.id, + amount: cancelled.amount, + currency: cancelled.currency, + status: cancelled.status, + createdAt: cancelled.createdAt.toISOString(), + } + } catch (error) { + baseLogger.error( + { accountId, operation: "cancelWithdrawalRequest", error }, + "Bridge operation failed", + ) + return error instanceof Error ? error : new Error(String(error)) + } +} + /** * Returns KYC status for an account */ @@ -791,11 +968,10 @@ const getVirtualAccount = async ( // delete the virtual account from our repo since it's no longer valid - const deleteResult = await BridgeVirtualAccount.deleteOne({ + await BridgeVirtualAccount.deleteOne({ bridgeVirtualAccountId: virtualAccount.bridgeVirtualAccountId! as string }) - return null } @@ -893,12 +1069,15 @@ const getWithdrawals = async ( if (withdrawals instanceof Error) return withdrawals const result: WithdrawalResult[] = withdrawals - .filter((w) => w.bridgeTransferId !== null || w.bridgeTransferId !== undefined) + .filter((w) => w.bridgeTransferId !== null && w.bridgeTransferId !== undefined) .map((w) => ({ - transferId: w.bridgeTransferId!, + id: w.id, amount: w.amount, currency: w.currency, - state: w.status, + externalAccountId: w.externalAccountId, + status: w.status, + bridgeTransferId: w.bridgeTransferId, + failureReason: w.failureReason, createdAt: w.createdAt.toISOString(), })) @@ -925,7 +1104,9 @@ export default wrapAsyncFunctionsToRunInSpan({ initiateKyc, createVirtualAccount, addExternalAccount, + requestWithdrawal, initiateWithdrawal, + cancelWithdrawalRequest, getKycStatus, getVirtualAccount, getExternalAccounts, diff --git a/src/services/bridge/webhook-server/routes/deposit.ts b/src/services/bridge/webhook-server/routes/deposit.ts index 1105ac2be..4fe09f432 100644 --- a/src/services/bridge/webhook-server/routes/deposit.ts +++ b/src/services/bridge/webhook-server/routes/deposit.ts @@ -22,6 +22,13 @@ export const depositHandler = async (req: Request, res: Response) => { } try { + const lockKey = `bridge-deposit:${id}:${state}` + const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) + if (lockResult instanceof Error) { + baseLogger.info({ event_id, id, state }, "Duplicate Bridge deposit webhook") + return res.status(200).json({ status: "already_processed" }) + } + baseLogger.info( { id, @@ -53,7 +60,7 @@ export const depositHandler = async (req: Request, res: Response) => { ? String(receipt.developer_fee) : event_object?.developer_fee != null ? String(event_object.developer_fee) - : "0", + : "0.0", subtotalAmount: receipt?.subtotal_amount != null ? String(receipt.subtotal_amount) : undefined, initialAmount: @@ -92,9 +99,9 @@ export const depositHandler = async (req: Request, res: Response) => { // Idempotency: mark processed only after local and ERPNext writes succeed, so // provider retries can recover audit gaps after transient ERPNext failures. - const lockKey = `bridge-deposit:${event_id}` - const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) - if (lockResult instanceof Error) { + const auditLockKey = `bridge-deposit:${event_id}` + const auditLockResult = await LockService().lockIdempotencyKey(auditLockKey as IdempotencyKey) + if (auditLockResult instanceof Error) { baseLogger.info({ event_id, id, state }, "Duplicate Bridge deposit webhook") return res.status(200).json({ status: "already_processed" }) } diff --git a/src/services/mongoose/bridge-accounts.ts b/src/services/mongoose/bridge-accounts.ts index 4afd2c974..41f7fec4c 100644 --- a/src/services/mongoose/bridge-accounts.ts +++ b/src/services/mongoose/bridge-accounts.ts @@ -167,7 +167,7 @@ export const updateWithdrawalTransferId = async ( try { const record = await BridgeWithdrawal.findByIdAndUpdate( id, - { bridgeTransferId, amount, currency, updatedAt: new Date() }, + { bridgeTransferId, amount, currency, status: "submitted", updatedAt: new Date() }, { new: true }, ) return record || new RepositoryError("Withdrawal not found") @@ -196,7 +196,7 @@ export const updateWithdrawalStatus = async ( if (truncatedReason !== undefined) update.failureReason = truncatedReason const record = await BridgeWithdrawal.findOneAndUpdate( - { bridgeTransferId, status: "pending" }, + { bridgeTransferId, status: "submitted" }, update, { new: true }, ) @@ -224,3 +224,25 @@ export const findWithdrawalByBridgeTransferId = async (transferId: BridgeTransfe return new RepositoryError(String(error)) } } + +export const findWithdrawalById = async (id: string) => { + try { + const record = await BridgeWithdrawal.findById(id) + return record || new RepositoryError("Withdrawal not found") + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const cancelWithdrawal = async (accountId: string, withdrawalId: string) => { + try { + const record = await BridgeWithdrawal.findOneAndUpdate( + { _id: withdrawalId, accountId, status: "pending", bridgeTransferId: { $exists: false } }, + { status: "cancelled", updatedAt: new Date() }, + { new: true }, + ) + return record || new RepositoryError("Withdrawal not found or cannot be cancelled") + } catch (error) { + return new RepositoryError(String(error)) + } +} diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index a15023362..291a8c8b7 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -47,7 +47,7 @@ interface IBridgeWithdrawalRecord { bridgeTransferId?: string amount: string currency: string - status: "pending" | "completed" | "failed" + status: "pending" | "submitted" | "completed" | "failed" | "cancelled" failureReason?: string externalAccountId: string createdAt: Date @@ -715,7 +715,7 @@ const BridgeWithdrawalSchema = new Schema({ bridgeTransferId: { type: String, unique: true, sparse: true }, amount: { type: String, required: true }, currency: { type: String, required: true }, - status: { type: String, enum: ["pending", "completed", "failed"], default: "pending" }, + status: { type: String, enum: ["pending", "submitted", "completed", "failed", "cancelled"], default: "pending" }, failureReason: { type: String, maxlength: 512 }, externalAccountId: { type: String, required: true }, createdAt: { type: Date, default: Date.now }, diff --git a/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts b/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts index d00d89dfa..30f3bb9c0 100644 --- a/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts +++ b/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts @@ -119,4 +119,33 @@ describe("sendBridgeWithdrawalNotification", () => { expect(result).toBe(true) }) + + it("sends a cancelled withdrawal notification with the correct phrase key and data type", async () => { + const result = await sendBridgeWithdrawalNotification({ + accountId, + amount: "25.00", + currency: "usdt", + outcome: "cancelled", + }) + + expect(result).toBe(true) + expect(sendFilteredNotification).toHaveBeenCalledWith( + expect.objectContaining({ + deviceTokens: mockUser.deviceTokens, + notificationCategory: "Cashout", + data: expect.objectContaining({ type: "bridge_withdrawal_cancelled" }), + }), + ) + expect(mockI18n.__).toHaveBeenCalledWith( + expect.objectContaining({ + phrase: "notification.bridgeWithdrawal.cancelled.title", + }), + ) + expect(mockI18n.__).toHaveBeenCalledWith( + expect.objectContaining({ + phrase: "notification.bridgeWithdrawal.cancelled.body", + }), + expect.objectContaining({ amount: "25.00 USDT" }), + ) + }) }) diff --git a/test/flash/unit/graphql/error-map.spec.ts b/test/flash/unit/graphql/error-map.spec.ts index 9be4c30e9..0fd27d505 100644 --- a/test/flash/unit/graphql/error-map.spec.ts +++ b/test/flash/unit/graphql/error-map.spec.ts @@ -1,7 +1,25 @@ import { mapError } from "@graphql/error-map" import { PhoneAccountAlreadyExistsCannotUpgradeError } from "@services/kratos" +import { + BridgeWithdrawalNotFoundError, + BridgeWithdrawalAlreadyInitiatedError, +} from "@services/bridge/errors" describe("error-map", () => { + it("maps BridgeWithdrawalNotFoundError to BRIDGE_WITHDRAWAL_NOT_FOUND", () => { + const result = mapError(new BridgeWithdrawalNotFoundError()) + + expect(result.extensions.code).toBe("BRIDGE_WITHDRAWAL_NOT_FOUND") + expect(result.message).toContain("Withdrawal request not found") + }) + + it("maps BridgeWithdrawalAlreadyInitiatedError to BRIDGE_WITHDRAWAL_ALREADY_INITIATED", () => { + const result = mapError(new BridgeWithdrawalAlreadyInitiatedError()) + + expect(result.extensions.code).toBe("BRIDGE_WITHDRAWAL_ALREADY_INITIATED") + expect(result.message).toContain("already been submitted") + }) + it("maps PhoneAccountAlreadyExistsCannotUpgradeError to correct GQL error", () => { const input = new PhoneAccountAlreadyExistsCannotUpgradeError() const result = mapError(input) diff --git a/test/flash/unit/graphql/public/root/mutation/bridge-withdrawal.spec.ts b/test/flash/unit/graphql/public/root/mutation/bridge-withdrawal.spec.ts new file mode 100644 index 000000000..9d9223261 --- /dev/null +++ b/test/flash/unit/graphql/public/root/mutation/bridge-withdrawal.spec.ts @@ -0,0 +1,214 @@ +// jest.mock calls are hoisted before imports + +jest.mock("@services/bridge", () => ({ + __esModule: true, + default: { + requestWithdrawal: jest.fn(), + initiateWithdrawal: jest.fn(), + cancelWithdrawalRequest: jest.fn(), + }, +})) + +jest.mock("@config", () => ({ + BridgeConfig: { enabled: true, minWithdrawalAmount: 10 }, + getOnChainWalletConfig: jest.fn().mockReturnValue({ dustThreshold: 546 }), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +import BridgeService from "@services/bridge" +import BridgeRequestWithdrawalMutation from "@graphql/public/root/mutation/bridge-request-withdrawal" +import BridgeInitiateWithdrawalMutation from "@graphql/public/root/mutation/bridge-initiate-withdrawal" +import BridgeCancelWithdrawalRequestMutation from "@graphql/public/root/mutation/bridge-cancel-withdrawal-request" +import { + BridgeWithdrawalNotFoundError, + BridgeWithdrawalAlreadyInitiatedError, +} from "@services/bridge/errors" + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const ACCOUNT_ID = "account-001" as AccountId +const EXTERNAL_ACCOUNT_ID = "ext-001" +const AMOUNT = "50" +const WITHDRAWAL_ID = "withdrawal-001" +const TRANSFER_ID = "transfer-001" +const CREATED_AT = new Date("2026-01-01T00:00:00Z") + +const ctx = { + domainAccount: { id: ACCOUNT_ID, level: 2 }, +} as unknown as GraphQLPublicContextAuth + +const makePendingRow = (overrides: Record = {}) => ({ + id: WITHDRAWAL_ID, + accountId: ACCOUNT_ID as string, + amount: AMOUNT, + currency: "usdt", + externalAccountId: EXTERNAL_ACCOUNT_ID, + status: "pending" as const, + createdAt: CREATED_AT, + ...overrides, +}) + +// ── bridgeRequestWithdrawal ─────────────────────────────────────────────────── + +describe("bridgeRequestWithdrawal resolver", () => { + beforeEach(() => jest.clearAllMocks()) + + it("creates a pending withdrawal and returns it", async () => { + const pendingRow = makePendingRow() + ;(BridgeService.requestWithdrawal as jest.Mock).mockResolvedValue(pendingRow) + + const result = await BridgeRequestWithdrawalMutation.resolve?.( + null, + { input: { amount: AMOUNT, externalAccountId: EXTERNAL_ACCOUNT_ID } }, + ctx, + {} as never, + ) + + expect(BridgeService.requestWithdrawal).toHaveBeenCalledWith( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + expect(result?.errors).toEqual([]) + expect(result?.withdrawal).toEqual(pendingRow) + expect(result?.withdrawal?.status).toBe("pending") + expect(result?.withdrawal?.externalAccountId).toBe(EXTERNAL_ACCOUNT_ID) + }) + + it("returns an existing pending row when the service deduplicates the request", async () => { + const existingRow = makePendingRow({ id: "existing-withdrawal-001" }) + ;(BridgeService.requestWithdrawal as jest.Mock).mockResolvedValue(existingRow) + + const result = await BridgeRequestWithdrawalMutation.resolve?.( + null, + { input: { amount: AMOUNT, externalAccountId: EXTERNAL_ACCOUNT_ID } }, + ctx, + {} as never, + ) + + expect(result?.errors).toEqual([]) + expect(result?.withdrawal?.id).toBe("existing-withdrawal-001") + expect(result?.withdrawal?.status).toBe("pending") + }) +}) + +// ── bridgeInitiateWithdrawal ────────────────────────────────────────────────── + +describe("bridgeInitiateWithdrawal resolver", () => { + beforeEach(() => jest.clearAllMocks()) + + it("submits the pending row and returns the withdrawal with bridgeTransferId recorded", async () => { + const initiatedRow = makePendingRow({ bridgeTransferId: TRANSFER_ID, status: "submitted" }) + ;(BridgeService.initiateWithdrawal as jest.Mock).mockResolvedValue(initiatedRow) + + const result = await BridgeInitiateWithdrawalMutation.resolve?.( + null, + { input: { withdrawalId: WITHDRAWAL_ID } }, + ctx, + {} as never, + ) + + expect(BridgeService.initiateWithdrawal).toHaveBeenCalledWith(ACCOUNT_ID, WITHDRAWAL_ID) + expect(result?.errors).toEqual([]) + expect(result?.withdrawal?.status).toBe("submitted") + expect(result?.withdrawal?.bridgeTransferId).toBe(TRANSFER_ID) + }) + + it("maps BridgeWithdrawalNotFoundError to BRIDGE_WITHDRAWAL_NOT_FOUND when ID is missing or wrong-owner", async () => { + ;(BridgeService.initiateWithdrawal as jest.Mock).mockResolvedValue( + new BridgeWithdrawalNotFoundError(), + ) + + const result = await BridgeInitiateWithdrawalMutation.resolve?.( + null, + { input: { withdrawalId: WITHDRAWAL_ID } }, + ctx, + {} as never, + ) + + expect(result?.errors).toHaveLength(1) + expect(result?.errors[0].code).toBe("BRIDGE_WITHDRAWAL_NOT_FOUND") + expect(result?.withdrawal).toBeUndefined() + }) + + it("maps BridgeWithdrawalAlreadyInitiatedError to BRIDGE_WITHDRAWAL_ALREADY_INITIATED", async () => { + ;(BridgeService.initiateWithdrawal as jest.Mock).mockResolvedValue( + new BridgeWithdrawalAlreadyInitiatedError(), + ) + + const result = await BridgeInitiateWithdrawalMutation.resolve?.( + null, + { input: { withdrawalId: WITHDRAWAL_ID } }, + ctx, + {} as never, + ) + + expect(result?.errors).toHaveLength(1) + expect(result?.errors[0].code).toBe("BRIDGE_WITHDRAWAL_ALREADY_INITIATED") + expect(result?.withdrawal).toBeUndefined() + }) +}) + +// ── bridgeCancelWithdrawalRequest ───────────────────────────────────────────── + +describe("bridgeCancelWithdrawalRequest resolver", () => { + beforeEach(() => jest.clearAllMocks()) + + it("delegates to cancelWithdrawalRequest and returns the cancelled withdrawal", async () => { + const cancelledRow = makePendingRow({ status: "cancelled" }) + ;(BridgeService.cancelWithdrawalRequest as jest.Mock).mockResolvedValue(cancelledRow) + + const result = await BridgeCancelWithdrawalRequestMutation.resolve?.( + null, + { input: { withdrawalId: WITHDRAWAL_ID } }, + ctx, + {} as never, + ) + + expect(BridgeService.cancelWithdrawalRequest).toHaveBeenCalledWith( + ACCOUNT_ID, + WITHDRAWAL_ID, + ) + expect(result?.errors).toEqual([]) + expect(result?.withdrawal?.status).toBe("cancelled") + expect(result?.withdrawal?.id).toBe(WITHDRAWAL_ID) + expect(result?.withdrawal?.amount).toBe(AMOUNT) + }) + + it("maps BridgeWithdrawalNotFoundError to BRIDGE_WITHDRAWAL_NOT_FOUND when ID is missing or wrong-owner", async () => { + ;(BridgeService.cancelWithdrawalRequest as jest.Mock).mockResolvedValue( + new BridgeWithdrawalNotFoundError(), + ) + + const result = await BridgeCancelWithdrawalRequestMutation.resolve?.( + null, + { input: { withdrawalId: WITHDRAWAL_ID } }, + ctx, + {} as never, + ) + + expect(result?.errors).toHaveLength(1) + expect(result?.errors[0].code).toBe("BRIDGE_WITHDRAWAL_NOT_FOUND") + expect(result?.withdrawal).toBeUndefined() + }) + + it("maps BridgeWithdrawalAlreadyInitiatedError to BRIDGE_WITHDRAWAL_ALREADY_INITIATED when already submitted", async () => { + ;(BridgeService.cancelWithdrawalRequest as jest.Mock).mockResolvedValue( + new BridgeWithdrawalAlreadyInitiatedError(), + ) + + const result = await BridgeCancelWithdrawalRequestMutation.resolve?.( + null, + { input: { withdrawalId: WITHDRAWAL_ID } }, + ctx, + {} as never, + ) + + expect(result?.errors).toHaveLength(1) + expect(result?.errors[0].code).toBe("BRIDGE_WITHDRAWAL_ALREADY_INITIATED") + expect(result?.withdrawal).toBeUndefined() + }) +}) diff --git a/test/flash/unit/graphql/public/root/query/bridge-withdrawals.spec.ts b/test/flash/unit/graphql/public/root/query/bridge-withdrawals.spec.ts new file mode 100644 index 000000000..2f3023543 --- /dev/null +++ b/test/flash/unit/graphql/public/root/query/bridge-withdrawals.spec.ts @@ -0,0 +1,62 @@ +jest.mock("@services/bridge", () => ({ + __esModule: true, + default: { + getWithdrawals: jest.fn(), + }, +})) + +jest.mock("@config", () => ({ + BridgeConfig: { enabled: true }, + getOnChainWalletConfig: jest.fn().mockReturnValue({ dustThreshold: 546 }), +})) + +jest.mock("@services/logger", () => { + const logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn(), + } + logger.child.mockReturnValue(logger) + return { baseLogger: logger } +}) + +import BridgeService from "@services/bridge" +import bridgeWithdrawals from "@graphql/public/root/query/bridge-withdrawals" + +const ACCOUNT_ID = "account-001" as AccountId +const WITHDRAWAL_ID = "withdrawal-001" +const TRANSFER_ID = "transfer-001" +const CREATED_AT = "2026-01-01T00:00:00.000Z" + +const ctx = { + domainAccount: { id: ACCOUNT_ID, level: 2 }, +} as unknown as GraphQLPublicContextAuth + +describe("bridgeWithdrawals resolver", () => { + beforeEach(() => jest.clearAllMocks()) + + it("returns service rows with id/status for the BridgeWithdrawal GraphQL type", async () => { + const serviceRow = { + id: WITHDRAWAL_ID, + amount: "50", + currency: "usdt", + externalAccountId: "ext-001", + status: "submitted", + bridgeTransferId: TRANSFER_ID, + failureReason: undefined, + createdAt: CREATED_AT, + } + ;(BridgeService.getWithdrawals as jest.Mock).mockResolvedValue([serviceRow]) + + const result = await bridgeWithdrawals.resolve?.(null, {}, ctx, {} as never) + + expect(BridgeService.getWithdrawals).toHaveBeenCalledWith(ACCOUNT_ID) + expect(result).toEqual([serviceRow]) + expect(result?.[0].id).toBe(WITHDRAWAL_ID) + expect(result?.[0].status).toBe("submitted") + expect((result?.[0] as Record).transferId).toBeUndefined() + expect((result?.[0] as Record).state).toBeUndefined() + }) +}) 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 index 08afbfdc1..1a373d2c0 100644 --- a/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts +++ b/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts @@ -6,31 +6,53 @@ describe("Bridge public GraphQL object contract", () => { it("exposes withdrawal fields returned by BridgeService", () => { const fields = BridgeWithdrawal.getFields() - expect(fields).toHaveProperty("transferId") + expect(fields).toHaveProperty("id") expect(fields).toHaveProperty("amount") expect(fields).toHaveProperty("currency") - expect(fields).toHaveProperty("state") + expect(fields).toHaveProperty("externalAccountId") + expect(fields).toHaveProperty("status") + expect(fields).toHaveProperty("bridgeTransferId") + expect(fields).toHaveProperty("failureReason") expect(fields).toHaveProperty("createdAt") - expect(fields).not.toHaveProperty("id") - expect(fields).not.toHaveProperty("status") + expect(fields).not.toHaveProperty("transferId") + expect(fields).not.toHaveProperty("state") }) - it("resolves withdrawal transferId and state from service-shaped results", () => { + it("resolves withdrawal id and status from service-shaped results", () => { const fields = BridgeWithdrawal.getFields() const withdrawal = { - transferId: "transfer-001", + id: "withdrawal-001", amount: "25.00", currency: "usdt", - state: "pending", + externalAccountId: "ext-001", + status: "pending", + bridgeTransferId: undefined, createdAt: "2026-06-05T00:00:00.000Z", } + expect(fields.id).toBeDefined() + expect(fields.status).toBeDefined() + expect(fields.bridgeTransferId).toBeDefined() + expect( - defaultFieldResolver(withdrawal, {}, {}, { fieldName: "transferId" } as never), - ).toBe("transfer-001") + defaultFieldResolver(withdrawal, {}, {}, { fieldName: "id", field: fields.id } as never), + ).toBe("withdrawal-001") expect( - defaultFieldResolver(withdrawal, {}, {}, { fieldName: "state" } as never), + defaultFieldResolver( + withdrawal, + {}, + {}, + { fieldName: "status", field: fields.status } as never, + ), ).toBe("pending") + expect( + defaultFieldResolver( + withdrawal, + {}, + {}, + { fieldName: "bridgeTransferId", field: fields.bridgeTransferId } as never, + ), + ).toBeUndefined() }) it("uses bridgeVirtualAccountId as the virtual account id returned by read queries", () => { diff --git a/test/flash/unit/services/bridge/index.spec.ts b/test/flash/unit/services/bridge/index.spec.ts index 67d6b50c1..7fd60e2ad 100644 --- a/test/flash/unit/services/bridge/index.spec.ts +++ b/test/flash/unit/services/bridge/index.spec.ts @@ -11,6 +11,12 @@ jest.mock("@services/tracing", () => ({ jest.mock("@config", () => ({ BridgeConfig: { enabled: true, minWithdrawalAmount: 10 }, + // Minimal stubs so schema.ts can run its module-level initialisation + getFeesConfig: jest.fn().mockReturnValue({ depositFeeVariable: 0, depositFeeFixed: 0, withdrawFeeVariable: 0, withdrawFeeFixed: 0 }), + getDefaultAccountsConfig: jest.fn().mockReturnValue({ initialStatus: "active", initialLevel: 0, maxCurrencies: 5 }), + getDefaultFCMTopics: jest.fn().mockReturnValue([]), + Levels: [0, 1, 2, 3], + getI18nInstance: jest.fn().mockReturnValue({ __: jest.fn() }), })) jest.mock("@services/logger", () => ({ @@ -24,18 +30,17 @@ jest.mock("@services/mongoose/bridge-accounts", () => ({ findPendingWithdrawalWithoutTransfer: jest.fn(), findExternalAccountsByAccountId: jest.fn(), updateWithdrawalTransferId: jest.fn(), + findWithdrawalById: jest.fn(), + findWithdrawalsByAccountId: jest.fn(), + cancelWithdrawal: jest.fn(), })) jest.mock("@services/bridge/client", () => ({ - __esModule: true, - default: { createVirtualAccount: jest.fn(), createTransfer: jest.fn() }, -})) - -jest.mock("@services/ibex/client", () => ({ __esModule: true, default: { - getEthereumUsdtOption: jest.fn(), - createCryptoReceiveInfo: jest.fn(), + createVirtualAccount: jest.fn(), + createTransfer: jest.fn(), + getCustomer: jest.fn().mockResolvedValue({ status: "active" }), }, })) @@ -68,18 +73,17 @@ jest.mock("@domain/primitives/bridge", () => ({ toBridgeExternalAccountId: (id: string) => id, })) -// USDTAmount is not re-exported from @domain/shared — provide a minimal stand-in so the -// service's `instanceof USDTAmount` guard is satisfied during tests. -// The class is defined inside the factory because jest.mock factories are hoisted before -// variable declarations; access it at runtime via require("@domain/shared").USDTAmount. -// USDTAmount is not re-exported from @domain/shared/index.ts (pre-existing issue). -// Spread the real module and inject a minimal stand-in so the service's -// `instanceof USDTAmount` guard is satisfied without breaking other domain exports. +// USDTAmount stand-in: the real type is not exported from @domain/shared. +// We spread the real module and inject a minimal class so `instanceof USDTAmount` +// guards in the service are satisfied during tests. jest.mock("@domain/shared", () => { class USDTAmount { - constructor(private readonly ibexValue: number) { - // Parameter property initializes ibexValue. + ibexValue: number + + constructor(ibexValue: number) { + this.ibexValue = ibexValue } + toIbex() { return this.ibexValue } @@ -87,6 +91,10 @@ jest.mock("@domain/shared", () => { return { ...jest.requireActual("@domain/shared"), USDTAmount } }) +jest.mock("@app/bridge/send-withdrawal-notification", () => ({ + sendBridgeWithdrawalNotificationBestEffort: jest.fn().mockResolvedValue(undefined), +})) + import BridgeService, { deriveWithdrawalIdempotencyKey } from "@services/bridge" import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" import BridgeClient from "@services/bridge/client" @@ -95,6 +103,7 @@ import { WalletsRepository } from "@services/mongoose/wallets" import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" import IbexClient from "@services/ibex/client" import { RepositoryError } from "@domain/errors" +import { sendBridgeWithdrawalNotificationBestEffort } from "@app/bridge/send-withdrawal-notification" // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -104,9 +113,11 @@ const AMOUNT = "50" const CUSTOMER_ID = "cust-001" const ETHEREUM_ADDRESS = "ETH_ADDR_001" const TRANSFER_ID = "transfer-bridge-001" +const WITHDRAWAL_ID = "withdrawal-mongo-001" const USDT_WALLET_ID = "ibex-eth-usdt-wallet-001" const RECEIVE_INFO_ID = "receive-info-001" const VIRTUAL_ACCOUNT_ID = "virtual-account-001" +const CREATED_AT = new Date("2026-01-01T00:00:00Z") const mockAccount = { id: ACCOUNT_ID, @@ -117,7 +128,7 @@ const mockAccount = { kratosUserId: "kratos-001", } -const makeRow = (id: string) => ({ +const makeRow = (id: string, overrides: Record = {}) => ({ id, accountId: ACCOUNT_ID as string, amount: AMOUNT, @@ -125,6 +136,9 @@ const makeRow = (id: string) => ({ externalAccountId: EXTERNAL_ACCOUNT_ID, status: "pending" as const, bridgeTransferId: undefined, + failureReason: undefined, + createdAt: CREATED_AT, + ...overrides, }) const mockTransfer = { @@ -152,27 +166,40 @@ const makeWallet = (id: string, currency: string) => ({ // ── Helpers ─────────────────────────────────────────────────────────────────── -const setupGuards = () => { +const getUSDTAmount = (ibex: number) => { const { USDTAmount } = jest.requireMock("@domain/shared") as { USDTAmount: new (ibexValue: number) => { toIbex: () => number } } - const balance = new USDTAmount(1000) // 1000 USDT — well above minWithdrawalAmount + return new USDTAmount(ibex) +} +const expectSuccess = (result: T | Error): T => { + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) throw result + return result +} + +/** Sets up the guards common to requestWithdrawal and initiateWithdrawal. */ +const setupGuards = () => { ;(AccountsRepository as jest.Mock).mockReturnValue({ findById: jest.fn().mockResolvedValue(mockAccount), + update: jest.fn(), + updateBridgeFields: jest.fn(), }) ;(WalletsRepository as jest.Mock).mockReturnValue({ listByAccountId: jest.fn().mockResolvedValue([ - { id: "wallet-001", currency: "USDT", type: "checking" }, + { id: USDT_WALLET_ID, currency: "USDT", type: "checking" }, ]), + persistNew: jest.fn(), }) - ;(getBalanceForWallet as jest.Mock).mockResolvedValue(balance) + ;(getBalanceForWallet as jest.Mock).mockResolvedValue(getUSDTAmount(1000)) ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ { bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, status: "verified" }, ]) ;(BridgeAccountsRepo.updateWithdrawalTransferId as jest.Mock).mockResolvedValue({ - ...makeRow("any"), + ...makeRow(WITHDRAWAL_ID), bridgeTransferId: TRANSFER_ID, + status: "submitted" as const, }) ;(BridgeClient.createTransfer as jest.Mock).mockResolvedValue(mockTransfer) } @@ -211,13 +238,6 @@ describe("deriveWithdrawalIdempotencyKey", () => { /** * Linear ENG-296 — ETH-USDT Cash Wallet + Bridge virtual account - * @see https://linear.app/island-bitcoin/issue/ENG-296 - * - * Acceptance ↔ tests (staging steps: `dev/qa/ENG-296-staging-checklist.md`): - * - AC1 IBEX ETH-USDT as Cash Wallet: first `it` — USDT checking persisted/reused, Ibex - * `createCryptoReceiveInfo` uses USDT wallet id; account `bridgeEthereumAddress` updated. - * - AC2 USD not primary: first `it` — `AccountsRepository.update` sets `defaultWalletId` to USDT wallet. - * - AC3 createVirtualAccount E2E: first & second `it` — Bridge + repo; third `it` — idempotent VA return. */ describe("createVirtualAccount — ETH-USDT Cash Wallet provisioning (ENG-296)", () => { beforeEach(() => { @@ -384,100 +404,641 @@ describe("createVirtualAccount — ETH-USDT Cash Wallet provisioning (ENG-296)", }) }) -describe("initiateWithdrawal — idempotency key wiring", () => { +// ───────────────────────────────────────────────────────────────────────────── +// requestWithdrawal +// Step 1 of the split flow: validates everything and writes a pending MongoDB +// record — does NOT call the Bridge API. +// ───────────────────────────────────────────────────────────────────────────── + +describe("requestWithdrawal", () => { beforeEach(() => { jest.clearAllMocks() setupGuards() + ;(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock).mockResolvedValue( + null, + ) + ;(BridgeAccountsRepo.createWithdrawal as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID), + ) + }) + + it("creates a pending withdrawal record and returns the full result", async () => { + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + expect(BridgeAccountsRepo.createWithdrawal).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID as string, + amount: AMOUNT, + currency: "usdt", + externalAccountId: EXTERNAL_ACCOUNT_ID, + status: "pending", + }) + expect(expectSuccess(result)).toMatchObject({ + id: WITHDRAWAL_ID, + amount: AMOUNT, + currency: "usdt", + externalAccountId: EXTERNAL_ACCOUNT_ID, + status: "pending", + createdAt: expect.any(String), + }) + }) + + it("never calls the Bridge API", async () => { + await BridgeService.requestWithdrawal(ACCOUNT_ID, AMOUNT, EXTERNAL_ACCOUNT_ID) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("reuses an existing pending withdrawal for the same account, amount, and external account", async () => { + const existingRow = makeRow("withdrawal-existing-001") + ;(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock).mockResolvedValue( + existingRow, + ) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + expect(expectSuccess(result)).toMatchObject({ + id: "withdrawal-existing-001", + status: "pending", + }) + }) + + it("returns an error when the external account does not belong to the caller (CRIT-2)", async () => { + ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ + { bridgeExternalAccountId: "somebody-elses-account", status: "verified" }, + ]) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + expect(result).toBeInstanceOf(Error) + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + }) + + it("returns an error when the external account is not yet verified", async () => { + ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ + { bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, status: "pending" }, + ]) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + expect(result).toBeInstanceOf(Error) + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + }) + + it("returns BridgeInsufficientFundsError when USDT balance is below the requested amount", async () => { + ;(getBalanceForWallet as jest.Mock).mockResolvedValue(getUSDTAmount(5)) // < AMOUNT=50 + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + const { BridgeInsufficientFundsError } = jest.requireActual("@services/bridge/errors") + expect(result).toBeInstanceOf(BridgeInsufficientFundsError) + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + }) + + it("returns BridgeCustomerNotFoundError when account has no Bridge customer ID", async () => { + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue({ ...mockAccount, bridgeCustomerId: undefined }), + }) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + const { BridgeCustomerNotFoundError } = jest.requireActual("@services/bridge/errors") + expect(result).toBeInstanceOf(BridgeCustomerNotFoundError) + }) + + it("returns an error when account has no Ethereum address", async () => { + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue({ + ...mockAccount, + bridgeEthereumAddress: undefined, + }), + }) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + expect(result).toBeInstanceOf(Error) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// initiateWithdrawal (refactored) +// Step 2A: fetches the pending record by ID, re-checks balance, calls Bridge. +// ───────────────────────────────────────────────────────────────────────────── + +describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { + beforeEach(() => { + jest.clearAllMocks() + setupGuards() + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID), + ) + }) + + it("fetches the pending withdrawal from MongoDB before calling Bridge", async () => { + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(BridgeAccountsRepo.findWithdrawalById).toHaveBeenCalledWith(WITHDRAWAL_ID) + }) + + it("never calls createWithdrawal — the row already exists from requestWithdrawal", async () => { + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + }) + + it("uses the idempotency key derived from the withdrawalId", async () => { + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const expectedKey = deriveWithdrawalIdempotencyKey(WITHDRAWAL_ID) + expect(BridgeClient.createTransfer).toHaveBeenCalledWith( + CUSTOMER_ID, + expect.any(Object), + expectedKey, + ) + }) + + it("calling twice with the same withdrawalId passes the same idempotency key to Bridge", async () => { + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const calls = (BridgeClient.createTransfer as jest.Mock).mock.calls + expect(calls[0][2]).toBe(calls[1][2]) + expect(calls[0][2]).toBe(deriveWithdrawalIdempotencyKey(WITHDRAWAL_ID)) + }) + + it("updates the withdrawal record with the Bridge transfer ID and transitions status to submitted", async () => { + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(BridgeAccountsRepo.updateWithdrawalTransferId).toHaveBeenCalledWith( + WITHDRAWAL_ID, + TRANSFER_ID, + AMOUNT, + "usd", + ) + expect(expectSuccess(result)).toMatchObject({ + status: "submitted", + bridgeTransferId: TRANSFER_ID, + }) + }) + + it("returns BridgeWithdrawalNotFoundError when the withdrawal ID does not exist", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + new RepositoryError("Withdrawal not found"), + ) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalNotFoundError } = jest.requireActual("@services/bridge/errors") + expect(result).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("returns BridgeWithdrawalNotFoundError when the withdrawal belongs to a different account", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { accountId: "different-account" }), + ) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalNotFoundError } = jest.requireActual("@services/bridge/errors") + expect(result).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("returns BridgeWithdrawalAlreadyInitiatedError when bridgeTransferId is already set", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { bridgeTransferId: "already-submitted" }), + ) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalAlreadyInitiatedError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeWithdrawalAlreadyInitiatedError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("returns BridgeWithdrawalAlreadyInitiatedError when status is not pending", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { status: "cancelled" }), + ) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalAlreadyInitiatedError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeWithdrawalAlreadyInitiatedError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("returns BridgeInsufficientFundsError when balance dropped between request and initiate", async () => { + ;(getBalanceForWallet as jest.Mock).mockResolvedValue(getUSDTAmount(5)) // < AMOUNT=50 + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeInsufficientFundsError } = jest.requireActual("@services/bridge/errors") + expect(result).toBeInstanceOf(BridgeInsufficientFundsError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// cancelWithdrawalRequest +// Step 2B: marks the pending record "cancelled" and sends a push notification. +// Only allowed before the Bridge API has been called (no bridgeTransferId). +// ───────────────────────────────────────────────────────────────────────────── - describe("fresh request (no in-flight row)", () => { - it("creates a pending withdrawal row before calling Bridge", async () => { - const row = makeRow("fresh-row-001") - ;( - BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock - ).mockResolvedValue(null) - ;(BridgeAccountsRepo.createWithdrawal as jest.Mock).mockResolvedValue(row) +describe("cancelWithdrawalRequest", () => { + beforeEach(() => { + jest.clearAllMocks() + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockAccount), + }) + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID), + ) + ;(BridgeAccountsRepo.cancelWithdrawal as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { status: "cancelled" }), + ) + }) - await BridgeService.initiateWithdrawal(ACCOUNT_ID, AMOUNT, EXTERNAL_ACCOUNT_ID) + it("cancels the pending withdrawal and returns status cancelled", async () => { + const result = await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) - const createOrder = (BridgeAccountsRepo.createWithdrawal as jest.Mock).mock - .invocationCallOrder[0] - const transferOrder = (BridgeClient.createTransfer as jest.Mock).mock - .invocationCallOrder[0] - expect(createOrder).toBeLessThan(transferOrder) + expect(expectSuccess(result)).toMatchObject({ + status: "cancelled", + id: WITHDRAWAL_ID, + amount: AMOUNT, }) + }) + + it("calls cancelWithdrawal with the correct accountId and withdrawalId", async () => { + await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) - it("derives the idempotency key from the newly created row's id", async () => { - const rowId = "fresh-row-id-abc" - ;( - BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock - ).mockResolvedValue(null) - ;(BridgeAccountsRepo.createWithdrawal as jest.Mock).mockResolvedValue( - makeRow(rowId), - ) - - await BridgeService.initiateWithdrawal(ACCOUNT_ID, AMOUNT, EXTERNAL_ACCOUNT_ID) - - const expectedKey = deriveWithdrawalIdempotencyKey(rowId) - expect(BridgeClient.createTransfer).toHaveBeenCalledWith( - CUSTOMER_ID, - expect.any(Object), - expectedKey, - ) + expect(BridgeAccountsRepo.cancelWithdrawal).toHaveBeenCalledWith( + ACCOUNT_ID as string, + WITHDRAWAL_ID, + ) + }) + + it("never calls the Bridge API", async () => { + await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("sends a cancelled push notification after a successful cancel", async () => { + await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(sendBridgeWithdrawalNotificationBestEffort).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID as string, + amount: AMOUNT, + currency: "usdt", + outcome: "cancelled", }) }) - describe("retry — in-flight row already exists", () => { - it("does not create a second withdrawal row", async () => { - const existingRow = makeRow("existing-row-001") - ;( - BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock - ).mockResolvedValue(existingRow) + it("returns BridgeWithdrawalNotFoundError when the withdrawal ID does not exist", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + new RepositoryError("Withdrawal not found"), + ) + + const result = await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) - await BridgeService.initiateWithdrawal(ACCOUNT_ID, AMOUNT, EXTERNAL_ACCOUNT_ID) + const { BridgeWithdrawalNotFoundError } = jest.requireActual("@services/bridge/errors") + expect(result).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) + + it("returns BridgeWithdrawalNotFoundError when the withdrawal belongs to a different account", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { accountId: "different-account" }), + ) + + const result = await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalNotFoundError } = jest.requireActual("@services/bridge/errors") + expect(result).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) + + it("returns BridgeWithdrawalAlreadyInitiatedError when the transfer was already submitted to Bridge", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { bridgeTransferId: "already-submitted-id" }), + ) - expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + const result = await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalAlreadyInitiatedError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeWithdrawalAlreadyInitiatedError) + expect(BridgeAccountsRepo.cancelWithdrawal).not.toHaveBeenCalled() + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) + + it("does not send a notification when the repo cancelWithdrawal fails (e.g. race condition)", async () => { + ;(BridgeAccountsRepo.cancelWithdrawal as jest.Mock).mockResolvedValue( + new RepositoryError("Withdrawal not found or cannot be cancelled"), + ) + + const result = await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalNotFoundError } = jest.requireActual("@services/bridge/errors") + expect(result).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// withdrawal request → confirm/cancel flow +// Chains the three service steps to pin the contract promised by the PR. +// ───────────────────────────────────────────────────────────────────────────── + +describe("withdrawal request → confirm/cancel flow", () => { + beforeEach(() => { + jest.clearAllMocks() + setupGuards() + ;(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock).mockResolvedValue( + null, + ) + ;(BridgeAccountsRepo.createWithdrawal as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID), + ) + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID), + ) + ;(BridgeAccountsRepo.cancelWithdrawal as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { status: "cancelled" }), + ) + ;(BridgeAccountsRepo.updateWithdrawalTransferId as jest.Mock).mockResolvedValue({ + ...makeRow(WITHDRAWAL_ID), + bridgeTransferId: TRANSFER_ID, + status: "submitted" as const, }) + }) - it("derives the key from the existing row's id — identical to the first attempt's key", async () => { - const rowId = "existing-row-001" - ;( - BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock - ).mockResolvedValue(makeRow(rowId)) + it("request creates a pending row, then initiate submits it and records bridgeTransferId", async () => { + const requested = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + const pending = expectSuccess(requested) + expect(pending).toMatchObject({ status: "pending", id: WITHDRAWAL_ID }) - await BridgeService.initiateWithdrawal(ACCOUNT_ID, AMOUNT, EXTERNAL_ACCOUNT_ID) + const initiated = expectSuccess( + await BridgeService.initiateWithdrawal(ACCOUNT_ID, pending.id), + ) + expect(initiated).toMatchObject({ + status: "submitted", + bridgeTransferId: TRANSFER_ID, + }) + expect(BridgeClient.createTransfer).toHaveBeenCalledTimes(1) + expect(BridgeAccountsRepo.updateWithdrawalTransferId).toHaveBeenCalledWith( + WITHDRAWAL_ID, + TRANSFER_ID, + AMOUNT, + "usd", + ) + }) - const expectedKey = deriveWithdrawalIdempotencyKey(rowId) - expect(BridgeClient.createTransfer).toHaveBeenCalledWith( - CUSTOMER_ID, - expect.any(Object), - expectedKey, - ) + it("duplicate request reuses the pending row, then initiate still submits that row", async () => { + const existingRow = makeRow("deduped-withdrawal-001") + ;(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock).mockResolvedValue( + existingRow, + ) + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue(existingRow) + ;(BridgeAccountsRepo.updateWithdrawalTransferId as jest.Mock).mockResolvedValue({ + ...existingRow, + bridgeTransferId: TRANSFER_ID, + status: "submitted" as const, + }) + + const first = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + const second = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + const firstPending = expectSuccess(first) + const secondPending = expectSuccess(second) + expect(firstPending).toMatchObject({ id: "deduped-withdrawal-001" }) + expect(secondPending).toMatchObject({ id: "deduped-withdrawal-001" }) + + const initiated = expectSuccess( + await BridgeService.initiateWithdrawal(ACCOUNT_ID, firstPending.id), + ) + expect(initiated.bridgeTransferId).toBe(TRANSFER_ID) + expect(BridgeAccountsRepo.updateWithdrawalTransferId).toHaveBeenCalledWith( + "deduped-withdrawal-001", + TRANSFER_ID, + AMOUNT, + "usd", + ) + }) + + it("request then cancel marks the row cancelled and sends the cancelled notification", async () => { + const requested = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + const pending = expectSuccess(requested) + const cancelled = expectSuccess( + await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, pending.id), + ) + + expect(cancelled.status).toBe("cancelled") + expect(BridgeAccountsRepo.cancelWithdrawal).toHaveBeenCalledWith( + ACCOUNT_ID as string, + WITHDRAWAL_ID, + ) + expect(sendBridgeWithdrawalNotificationBestEffort).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID as string, + amount: AMOUNT, + currency: "usdt", + outcome: "cancelled", + }) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("initiate returns BridgeWithdrawalNotFoundError for missing or wrong-owner withdrawalId", async () => { + const { BridgeWithdrawalNotFoundError } = jest.requireActual("@services/bridge/errors") + + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + new RepositoryError("Withdrawal not found"), + ) + expect( + await BridgeService.initiateWithdrawal(ACCOUNT_ID, "missing-withdrawal"), + ).toBeInstanceOf(BridgeWithdrawalNotFoundError) + + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { accountId: "other-account" }), + ) + expect( + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID), + ).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("initiate returns BridgeWithdrawalAlreadyInitiatedError when the row was already submitted", async () => { + const { BridgeWithdrawalAlreadyInitiatedError } = jest.requireActual( + "@services/bridge/errors", + ) + + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { bridgeTransferId: TRANSFER_ID, status: "submitted" }), + ) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + expect(result).toBeInstanceOf(BridgeWithdrawalAlreadyInitiatedError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + }) + + it("cancel returns BridgeWithdrawalNotFoundError for missing or wrong-owner withdrawalId", async () => { + const { BridgeWithdrawalNotFoundError } = jest.requireActual("@services/bridge/errors") + + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + new RepositoryError("Withdrawal not found"), + ) + expect( + await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, "missing-withdrawal"), + ).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { accountId: "other-account" }), + ) + expect( + await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID), + ).toBeInstanceOf(BridgeWithdrawalNotFoundError) + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) + + it("cancel returns BridgeWithdrawalAlreadyInitiatedError when bridgeTransferId is already set", async () => { + const { BridgeWithdrawalAlreadyInitiatedError } = jest.requireActual( + "@services/bridge/errors", + ) + + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { bridgeTransferId: TRANSFER_ID, status: "submitted" }), + ) + + const result = await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) + expect(result).toBeInstanceOf(BridgeWithdrawalAlreadyInitiatedError) + expect(BridgeAccountsRepo.cancelWithdrawal).not.toHaveBeenCalled() + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// getWithdrawals +// Returns the account's withdrawal history mapped to the GQL-facing shape +// (id/status/bridgeTransferId — NOT the old transferId/state fields). +// ───────────────────────────────────────────────────────────────────────────── + +describe("getWithdrawals", () => { + beforeEach(() => { + jest.clearAllMocks() + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockAccount), }) }) - describe("two rapid calls for the same request", () => { - it("pass the same idempotency key to Bridge — collapsing into one transfer", async () => { - // Call 1: no in-flight row → creates row A - // Call 2: finds row A (created by call 1) → reuses its id - const rowId = "shared-row-concurrent" - const row = makeRow(rowId) + it("maps submitted rows to id/status — not transferId or state", async () => { + ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ + makeRow(WITHDRAWAL_ID, { bridgeTransferId: TRANSFER_ID, status: "submitted" }), + ]) + + const result = expectSuccess(await BridgeService.getWithdrawals(ACCOUNT_ID)) - ;(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock) - .mockResolvedValueOnce(null) // call 1: nothing in-flight yet - .mockResolvedValueOnce(row) // call 2: row A now visible - ;(BridgeAccountsRepo.createWithdrawal as jest.Mock).mockResolvedValue(row) + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ id: WITHDRAWAL_ID, status: "submitted" }) + expect((result[0] as Record).transferId).toBeUndefined() + expect((result[0] as Record).state).toBeUndefined() + }) - await BridgeService.initiateWithdrawal(ACCOUNT_ID, AMOUNT, EXTERNAL_ACCOUNT_ID) - await BridgeService.initiateWithdrawal(ACCOUNT_ID, AMOUNT, EXTERNAL_ACCOUNT_ID) + it("includes bridgeTransferId for submitted/completed rows", async () => { + ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ + makeRow(WITHDRAWAL_ID, { bridgeTransferId: TRANSFER_ID, status: "completed" }), + ]) - const calls = (BridgeClient.createTransfer as jest.Mock).mock.calls - expect(calls).toHaveLength(2) + const result = expectSuccess(await BridgeService.getWithdrawals(ACCOUNT_ID)) - const key1 = calls[0][2] - const key2 = calls[1][2] - expect(key1).toBe(key2) - expect(key1).toBe(deriveWithdrawalIdempotencyKey(rowId)) + expect(result[0]).toMatchObject({ + bridgeTransferId: TRANSFER_ID, + status: "completed", }) }) + + it("excludes pending rows that have no bridgeTransferId (pre-initiation)", async () => { + ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ + makeRow(WITHDRAWAL_ID), // bridgeTransferId: undefined — pre-approval + ]) + + const result = expectSuccess(await BridgeService.getWithdrawals(ACCOUNT_ID)) + + expect(result).toHaveLength(0) + }) + + it("excludes cancelled rows without a bridgeTransferId, includes submitted/completed/failed", async () => { + ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ + makeRow("w-1", { status: "pending" }), // excluded + makeRow("w-2", { status: "cancelled" }), // excluded (no transferId) + makeRow("w-3", { status: "submitted", bridgeTransferId: TRANSFER_ID }), + makeRow("w-4", { status: "completed", bridgeTransferId: "t-completed" }), + makeRow("w-5", { status: "failed", bridgeTransferId: "t-failed" }), + ]) + + const result = expectSuccess(await BridgeService.getWithdrawals(ACCOUNT_ID)) + + expect(result).toHaveLength(3) + expect(result.map((r) => r.status)).toEqual(["submitted", "completed", "failed"]) + }) + + it("formats createdAt as an ISO string", async () => { + ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ + makeRow(WITHDRAWAL_ID, { bridgeTransferId: TRANSFER_ID, status: "submitted" }), + ]) + + const result = expectSuccess(await BridgeService.getWithdrawals(ACCOUNT_ID)) + + expect(result[0].createdAt).toBe(CREATED_AT.toISOString()) + }) }) diff --git a/test/flash/unit/services/bridge/return-shapes.spec.ts b/test/flash/unit/services/bridge/return-shapes.spec.ts new file mode 100644 index 000000000..ceba1affa --- /dev/null +++ b/test/flash/unit/services/bridge/return-shapes.spec.ts @@ -0,0 +1,242 @@ +/** + * Bridge service return shapes must match the public BridgeWithdrawal GraphQL type. + * + * Withdrawal mutation/query resolvers return BridgeService results directly with no + * resolver-level field mapping, so the service is the source of truth for the GQL + * contract: NonNull `id`, `amount`, `currency`, `status`, `createdAt`; optional + * `externalAccountId`, `bridgeTransferId`, `failureReason`. + */ +jest.mock("@services/tracing", () => ({ + wrapAsyncFunctionsToRunInSpan: ({ + fns, + }: { + namespace: string + fns: Record unknown> + }) => fns, +})) + +jest.mock("@config", () => ({ + ...jest.requireActual("@config"), + BridgeConfig: { enabled: true, minWithdrawalAmount: 10 }, +})) + +jest.mock("@services/logger", () => { + const logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn(), + } + logger.child.mockReturnValue(logger) + return { baseLogger: logger } +}) + +jest.mock("@app/bridge/send-withdrawal-notification", () => ({ + sendBridgeWithdrawalNotificationBestEffort: jest.fn().mockResolvedValue(undefined), +})) + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + getEthereumUsdtOption: jest.fn(), + createCryptoReceiveInfo: jest.fn(), + }, +})) + +jest.mock("@services/mongoose/bridge-accounts", () => ({ + createWithdrawal: jest.fn(), + findPendingWithdrawalWithoutTransfer: jest.fn(), + findExternalAccountsByAccountId: jest.fn(), + findWithdrawalsByAccountId: jest.fn(), + findWithdrawalById: jest.fn(), + updateWithdrawalTransferId: jest.fn(), +})) + +jest.mock("@services/bridge/client", () => ({ + __esModule: true, + default: { createTransfer: jest.fn() }, +})) + +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: jest.fn(), +})) + +jest.mock("@services/mongoose/wallets", () => ({ + WalletsRepository: jest.fn(), +})) + +jest.mock("@app/wallets/get-balance-for-wallet", () => ({ + getBalanceForWallet: jest.fn(), +})) + +jest.mock("@services/kratos", () => ({ + IdentityRepository: jest.fn(), +})) + +jest.mock("@domain/primitives/bridge", () => ({ + toBridgeCustomerId: (id: string) => id, + toBridgeExternalAccountId: (id: string) => id, +})) + +jest.mock("@domain/shared", () => { + class USDTAmount { + private readonly ibexValue: number + + constructor(ibexValue: number) { + this.ibexValue = ibexValue + } + + toIbex() { + return this.ibexValue + } + } + return { ...jest.requireActual("@domain/shared"), USDTAmount } +}) + +import BridgeService from "@services/bridge" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" +import BridgeClient from "@services/bridge/client" +import { AccountsRepository } from "@services/mongoose/accounts" +import { WalletsRepository } from "@services/mongoose/wallets" +import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" + +const ACCOUNT_ID = "account-001" as AccountId +const EXTERNAL_ACCOUNT_ID = "ext-account-001" +const AMOUNT = "50" +const CUSTOMER_ID = "cust-001" +const ETHEREUM_ADDRESS = "ETH_ADDR_001" +const TRANSFER_ID = "transfer-bridge-001" +const WITHDRAWAL_ID = "withdrawal-mongo-001" +const CREATED_AT = new Date("2026-06-05T00:00:00.000Z") + +const mockAccount = { + id: ACCOUNT_ID, + level: 2, + bridgeCustomerId: CUSTOMER_ID, + bridgeEthereumAddress: ETHEREUM_ADDRESS, + bridgeKycStatus: "approved", + kratosUserId: "kratos-001", +} + +const mockTransfer = { + id: TRANSFER_ID, + amount: AMOUNT, + currency: "usd", + state: "pending", +} + +const makeRow = (overrides: Record = {}) => ({ + id: WITHDRAWAL_ID, + accountId: ACCOUNT_ID as string, + amount: AMOUNT, + currency: "usdt", + externalAccountId: EXTERNAL_ACCOUNT_ID, + status: "pending" as const, + bridgeTransferId: undefined, + failureReason: undefined, + createdAt: CREATED_AT, + ...overrides, +}) + +const setupGuards = () => { + const { USDTAmount } = jest.requireMock("@domain/shared") as { + USDTAmount: new (ibexValue: number) => { toIbex: () => number } + } + + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockAccount), + }) + ;(WalletsRepository as jest.Mock).mockReturnValue({ + listByAccountId: jest + .fn() + .mockResolvedValue([{ id: "wallet-001", currency: "USDT", type: "checking" }]), + }) + ;(getBalanceForWallet as jest.Mock).mockResolvedValue(new USDTAmount(1000)) + ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ + { bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, status: "verified" }, + ]) + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue(makeRow()) + ;(BridgeAccountsRepo.updateWithdrawalTransferId as jest.Mock).mockResolvedValue({ + ...makeRow(), + bridgeTransferId: TRANSFER_ID, + status: "submitted" as const, + }) + ;(BridgeClient.createTransfer as jest.Mock).mockResolvedValue(mockTransfer) +} + +describe("initiateWithdrawal — BridgeWithdrawal GraphQL contract shape", () => { + beforeEach(() => { + jest.clearAllMocks() + setupGuards() + }) + + it("returns every NonNull field required by the BridgeWithdrawal GraphQL type", async () => { + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + + expect(result.id).toBe(WITHDRAWAL_ID) + expect(result.amount).toBe(AMOUNT) + expect(result.currency).toBe("usdt") + expect(result.status).toBe("submitted") + expect(result.createdAt).toBe(CREATED_AT.toISOString()) + expect(result.bridgeTransferId).toBe(TRANSFER_ID) + expect((result as Record).transferId).toBeUndefined() + expect((result as Record).state).toBeUndefined() + }) +}) + +describe("getWithdrawals — BridgeWithdrawal GraphQL contract shape", () => { + beforeEach(() => { + jest.clearAllMocks() + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockAccount), + }) + }) + + it("maps Mongo rows to id/status (not legacy transferId/state)", async () => { + ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ + makeRow({ bridgeTransferId: TRANSFER_ID, status: "submitted", failureReason: "ACH return" }), + ]) + + const result = await BridgeService.getWithdrawals(ACCOUNT_ID) + + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + id: WITHDRAWAL_ID, + amount: AMOUNT, + currency: "usdt", + externalAccountId: EXTERNAL_ACCOUNT_ID, + status: "submitted", + bridgeTransferId: TRANSFER_ID, + failureReason: "ACH return", + createdAt: CREATED_AT.toISOString(), + }) + expect((result[0] as Record).transferId).toBeUndefined() + expect((result[0] as Record).state).toBeUndefined() + }) + + it("excludes rows without bridgeTransferId so NonNull id/status never resolve undefined", async () => { + ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ + makeRow({ status: "pending" }), + makeRow({ id: "w-cancelled", status: "cancelled" }), + makeRow({ + id: "w-submitted", + bridgeTransferId: TRANSFER_ID, + status: "submitted", + }), + ]) + + const result = await BridgeService.getWithdrawals(ACCOUNT_ID) + + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result).toHaveLength(1) + expect(result[0].id).toBe("w-submitted") + expect(result[0].status).toBe("submitted") + }) +})