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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions src/graphql/public/root/mutation/bridge-add-external-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import IError from "@graphql/shared/types/abstract/error"
import BridgeExternalAccountLink from "@graphql/public/types/object/bridge-external-account-link"
import { BridgeConfig } from "@config"
import BridgeService from "@services/bridge"
import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors"
import {
BridgeDisabledError,
BridgeAccountLevelError,
BridgePlaidNotAvailableError,
} from "@services/bridge/errors"
import { notifyOpsEvent } from "@services/alerts/ops-events"

const BridgeAddExternalAccountPayload = GT.Object({
name: "BridgeAddExternalAccountPayload",
Expand All @@ -16,7 +21,7 @@ const BridgeAddExternalAccountPayload = GT.Object({

const bridgeAddExternalAccount = GT.Field({
type: GT.NonNull(BridgeAddExternalAccountPayload),
resolve: async (_, __, { domainAccount }: GraphQLPublicContextAuth) => {
resolve: async (_, __, { domainAccount, ip }: GraphQLPublicContextAuth) => {
if (!BridgeConfig.enabled) {
return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] }
}
Expand All @@ -25,6 +30,25 @@ const bridgeAddExternalAccount = GT.Field({
return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] }
}

// Plaid onboarding is US-only. Send a non-US IP straight to the client's
// manual-entry fallback (BRIDGE_PLAID_NOT_AVAILABLE) rather than opening a
// Plaid session that gets blocked inside the webview.
const { available: plaidAvailable, country } = await BridgeService.plaidGateForIp(ip)
if (!plaidAvailable) {
// Surface to the #flash-activity ops feed so we can see non-US demand and
// confirm the gate is actually firing in prod. Fire-and-forget.
notifyOpsEvent({
flow: "cashout",
phase: "plaid-blocked-non-us",
status: "failed",
accountId: domainAccount.id,
meta: { country: country ?? "unknown" },
})
return {
errors: [mapAndParseErrorForGqlResponse(new BridgePlaidNotAvailableError())],
}
}

const result = await BridgeService.addExternalAccount(domainAccount.id)
if (result instanceof Error) {
return { errors: [mapAndParseErrorForGqlResponse(result)] }
Expand Down
96 changes: 94 additions & 2 deletions src/services/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,26 @@ 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 {
addAttributesToCurrentSpan,
recordExceptionInCurrentSpan,
wrapAsyncFunctionsToRunInSpan,
} from "@services/tracing"
import { baseLogger } from "@services/logger"
import { IpFetcher } from "@services/ipfetcher"

import { CacheServiceError } from "@domain/cache"
import { RepositoryError } from "@domain/errors"
import { isPrivateIp } from "@domain/accounts-ips"
import { IpFetcherServiceError } from "@domain/ipfetcher"
import {
toBridgeCustomerId,
toBridgeExternalAccountId,
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 { ErrorLevel, USDTAmount, WalletCurrency } from "@domain/shared"
import { WalletType } from "@domain/wallets"
import { WalletsRepository } from "@services/mongoose/wallets"

Expand Down Expand Up @@ -602,6 +609,90 @@ const createVirtualAccount = async (
}
}

type PlaidGateResult = {
// false only when the IP is a CONFIRMED non-US country.
available: boolean
// ISO country code when known (uppercased), else null. For telemetry/ops.
country: string | null
}

/**
* Whether Plaid Link is usable from the caller's IP, plus the resolved country.
*
* Plaid onboarding is US-only: a non-US egress IP is rejected *inside* the
* Plaid webview (surfacing as "rate limit exceeded" on the phone step), which
* the backend never sees and which dead-ends the user. We gate it here so the
* caller can return BRIDGE_PLAID_NOT_AVAILABLE and the client routes straight
* to manual bank-details entry instead of opening a Plaid session that can't
* complete.
*
* Fail-open: only a CONFIRMED non-US IP is unavailable. No IP, a private/dev
* IP, a geo-lookup failure, or an unknown country all stay available — so a
* lookup blip never locks a legitimate US user out of Plaid, and the client's
* on-exit fallback still catches any non-US user that slips through.
*
* Every branch tags the current span with `plaid.gate.decision` (and country)
* so prod efficacy — and silent fail-open (e.g. missing proxycheck key) — is
* observable rather than invisible.
*/
const plaidGateForIp = async (ip: IpAddress | undefined): Promise<PlaidGateResult> => {
if (!ip) {
addAttributesToCurrentSpan({ "plaid.gate.decision": "allowed-no-ip" })
return { available: true, country: null }
}

try {
// isPrivateIp parses as IPv4 and THROWS on a real IPv6 address. Treat a
// throw as "not private" so IPv6 clients (common on mobile carriers) still
// get geo-checked rather than crashing the mutation — proxycheck resolves
// IPv6 fine, so the US gate keeps working for them.
let isPrivate = false
try {
isPrivate = isPrivateIp(ip)
} catch {
isPrivate = false
}
if (isPrivate) {
addAttributesToCurrentSpan({ "plaid.gate.decision": "allowed-private-ip" })
return { available: true, country: null }
}

const ipInfo = await IpFetcher().fetchIPInfo(ip)
if (ipInfo instanceof IpFetcherServiceError) {
recordExceptionInCurrentSpan({
error: ipInfo,
level: ErrorLevel.Warn,
attributes: { ip },
})
addAttributesToCurrentSpan({ "plaid.gate.decision": "allowed-lookup-failed" })
return { available: true, country: null }
}

const country = ipInfo.isoCode ? ipInfo.isoCode.toUpperCase() : null
if (!country) {
addAttributesToCurrentSpan({ "plaid.gate.decision": "allowed-unknown-country" })
return { available: true, country: null }
}

const available = country === "US"
addAttributesToCurrentSpan({
"plaid.gate.decision": available ? "allowed-us" : "blocked-non-us",
"plaid.gate.country": country,
})
return { available, country }
} catch (error) {
// Backstop: any unexpected failure fails open. Never block a legitimate US
// user from linking a bank because of a geo hiccup.
recordExceptionInCurrentSpan({
error: error instanceof Error ? error : new Error(String(error)),
level: ErrorLevel.Warn,
attributes: { ip },
})
addAttributesToCurrentSpan({ "plaid.gate.decision": "allowed-error" })
return { available: true, country: null }
}
}

/**
* Requests a Plaid link_token from Bridge for adding external accounts.
*/
Expand Down Expand Up @@ -1805,6 +1896,7 @@ export default wrapAsyncFunctionsToRunInSpan({
fns: {
initiateKyc,
createVirtualAccount,
plaidGateForIp,
addExternalAccount,
exchangePlaidPublicToken,
createExternalAccount,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Resolver-level tests for the US-only Plaid gate wiring: a non-US IP must
// short-circuit to BRIDGE_PLAID_NOT_AVAILABLE (which the client routes to
// manual entry), must NOT open a Plaid session, and must emit an ops-feed
// event; a US IP proceeds normally and emits nothing.

jest.mock("@services/bridge", () => ({
__esModule: true,
default: {
plaidGateForIp: jest.fn(),
addExternalAccount: jest.fn(),
},
}))

jest.mock("@config", () => ({
BridgeConfig: { enabled: true },
getOnChainWalletConfig: jest.fn().mockReturnValue({ dustThreshold: 546 }),
}))

jest.mock("@services/logger", () => ({
baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
}))

jest.mock("@services/alerts/ops-events", () => ({
notifyOpsEvent: jest.fn(),
}))

import BridgeService from "@services/bridge"
import { notifyOpsEvent } from "@services/alerts/ops-events"
import BridgeAddExternalAccountMutation from "@graphql/public/root/mutation/bridge-add-external-account"

const ACCOUNT_ID = "account-001" as AccountId

const ctxWithIp = (ipValue?: string) =>
({
domainAccount: { id: ACCOUNT_ID, level: 2 },
ip: ipValue as unknown as IpAddress | undefined,
}) as unknown as GraphQLPublicContextAuth

type MutationResult = {
errors: Array<{ code?: string; message?: string }>
externalAccount?: unknown
}

type ResolvableMutation = {
resolve?: (
source: null,
args: Record<string, never>,
context: GraphQLPublicContextAuth,
info: never,
) => Promise<unknown> | unknown
}

const resolveAddExternal = async (
ctx: GraphQLPublicContextAuth,
): Promise<MutationResult> => {
const mutation = BridgeAddExternalAccountMutation as ResolvableMutation
if (!mutation.resolve) throw new Error("Missing resolver")
return (await mutation.resolve(null, {}, ctx, {} as never)) as MutationResult
}

describe("bridgeAddExternalAccount — US-only Plaid gate", () => {
beforeEach(() => jest.clearAllMocks())

it("blocks a non-US IP: returns BRIDGE_PLAID_NOT_AVAILABLE, skips Plaid, notifies ops", async () => {
;(BridgeService.plaidGateForIp as jest.Mock).mockResolvedValue({
available: false,
country: "JM",
})

const result = await resolveAddExternal(ctxWithIp("69.160.103.177"))

expect(BridgeService.plaidGateForIp).toHaveBeenCalledWith("69.160.103.177")
// The whole point: Plaid is never requested for a blocked IP.
expect(BridgeService.addExternalAccount).not.toHaveBeenCalled()
expect(result.errors).toHaveLength(1)
expect(result.errors[0].code).toBe("BRIDGE_PLAID_NOT_AVAILABLE")
expect(result.externalAccount).toBeUndefined()
// Ops-feed signal carries the resolved country.
expect(notifyOpsEvent).toHaveBeenCalledWith(
expect.objectContaining({
flow: "cashout",
phase: "plaid-blocked-non-us",
status: "failed",
accountId: ACCOUNT_ID,
meta: { country: "JM" },
}),
)
})

it("issues the Plaid link token when the IP is allowed (US) and notifies nothing", async () => {
;(BridgeService.plaidGateForIp as jest.Mock).mockResolvedValue({
available: true,
country: "US",
})
const linkResult = { linkToken: "link-tok", expiresAt: "later" }
;(BridgeService.addExternalAccount as jest.Mock).mockResolvedValue(linkResult)

const result = await resolveAddExternal(ctxWithIp("8.8.8.8"))

expect(BridgeService.addExternalAccount).toHaveBeenCalledWith(ACCOUNT_ID)
expect(notifyOpsEvent).not.toHaveBeenCalled()
expect(result.errors).toEqual([])
expect(result.externalAccount).toEqual(linkResult)
})
})
Loading
Loading