Skip to content

feat(bridge): gate KYC initiation on phone-country eligibility (ERPNext-managed allowlist) - #502

Open
islandbitcoin wants to merge 4 commits into
mainfrom
feat/bridge-kyc-gate
Open

feat(bridge): gate KYC initiation on phone-country eligibility (ERPNext-managed allowlist)#502
islandbitcoin wants to merge 4 commits into
mainfrom
feat/bridge-kyc-gate

Conversation

@islandbitcoin

@islandbitcoin islandbitcoin commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Why

On 2026-09-01 a wave of reward-hunting signups from countries Bridge cannot issue Flash a USD virtual account for started Bridge KYC within minutes of registering (81 initiations / 15 min at peak). Bridge approved them and then refused the virtual account — 29 "The customer is not authorized to create USD Virtual Accounts" refusals in 3 h. The user did the work, got a green check, and still had nothing.

An earlier revision of this branch gated on a minimum account age. That rule is dropped: it directly punishes real users who sign up precisely to get their US virtual account right away (Jabari, 2026-09-01). A delay doesn't prevent the bad outcome above, it only postpones it.

What

bridgeInitiateKyc now asks the one question that predicts the refusal, before sending anyone through KYC: is the user's phone country one Bridge can serve?

  • Signal: user.phoneMetadata.countryCode — the Twilio Lookup country stamped at signup. Fallback: the phone number's parsed region (libphonenumber-js). When libphonenumber can parse the number but not name its region (~340 assigned NANP area codes are absent from the lockfile's libphonenumber-js 1.12.25 metadata (what yarn install --frozen-lockfile ships and CI tests) — +1 924, +1 472, +1 861 — and the UK 07700 900xxx range), the gate falls back to every region the calling code could denote and passes if any of them is allowed, the same fallback checkAuthCodeDestination makes (fix(auth): reject unsupported OTP destinations before paying Twilio #496). Only a number with no phone, or an unparsable one, is BRIDGE_KYC_PHONE_REQUIRED. This is the hardest signal to fake at signup: the wave typed "Jamaica" into the address form while verifying real Indian and Nigerian SIMs. It is also the easiest to go stale afterwards: the stamp is written once, at account creation, and userPhoneDelete / userPhoneRegistrationValidate replace the number without refreshing it — a JM signup that re-registers a Nigerian SIM carries countryCode: "JM" over phone: "+234…". So the gate trusts the stamp only while the number on file cannot contradict it (no resolvable number, the same region, or an unattributable number on a calling code that includes the stamped region); otherwise the number decides and the evaluation is stamped bridge.kyc_gate.lookup_stale: true.
  • Allowed set — bridge.kycGate.countryAllowlist.source:
    • "config" (default): defaultCountries is the list. No ERPNext read. This is what makes the PR safe to deploy on its own (see Deploy order).
    • "erpnext": ERPNext doctype Allowed Country, rows with flash_allowed ticked; ops toggle countries at /app/allowed-country with no release. Read via GET /api/resource/Allowed Country?filters=[["flash_allowed","=",1]]&fields=["alpha2_code","country_name","flash_allowed"]&limit_page_length=1000, memoised 60 s (successes and failures, same pattern as fee-discounts.ts). flash_allowed is re-checked on each row so a lost filter can't widen the list. When ERPNext can't be read (or returns no allowed rows) the reader falls back to defaultCountries — never "allow everyone", never "deny everyone".
  • Default deny: a country not in the set is not allowed.
  • Order: verified-email rule (if enabled) → country rule (if enabled).
  • Every denial is logged at warn with { accountId, countryCode, countrySource, rule } and stamped on the span (bridge.kyc_gate.denied/rule/country); bridge.kyc_gate.country_source (lookup | number | calling-code | none) is stamped on every evaluation so the calling-code fallback rate is visible, and bridge.kyc_gate.lookup_stale: true (also on the denial log as lookupStale) whenever the number on file overruled the signup stamp, so that rate is visible too. An ERPNext fallback is logged at info.

Deploy order (read this)

The ERPNext doctype as seeded on frappe-flash-admin main today ticks flash_allowed for 165 countries — NG and IN among them. The reseed to the 30-country list is lnflash/frappe-flash-admin#76 (feat/bridge-kyc-country-allowlist), not yet merged. Reading ERPNext before that patch has run would admit exactly the wave this gate exists to stop.

That is why source defaults to "config". The two PRs are order-independent; flipping to ERPNext is a deliberate later step:

  1. Merge + deploy this PR (config list is live immediately).
  2. Merge + deploy frappe-flash-admin#76; bench migrate runs the reseed.
  3. Verify on the target site: GET /api/resource/Allowed Country?filters=[["flash_allowed","=",1]]&limit_page_length=1000 returns the expected ~35 rows and no NG/IN.
  4. Only then set bridge.kycGate.countryAllowlist.source: erpnext in the configmap.

Config (absent keys take these defaults)

key default
bridge.kycGate.requireVerifiedEmail false (rule kept, off by default)
bridge.kycGate.countryAllowlist.enabled true
bridge.kycGate.countryAllowlist.source "config"
bridge.kycGate.countryAllowlist.defaultCountries JM TT BB BS DO KY AG DM GD KN LC VC BZ GY SR AW CW SX BM TC VG AI MS US PR VI GU AS MP CA GB MX SV SN KE

Seed = Caribbean + US/CA/GB + MX, SV, SN (Dakar), KE, per instruction, plus the five US territories in the NANP (PR, VI, GU, AS, MP): Twilio Lookup reports them by territory (+1 787 → PR), not as US, and they are US persons Bridge can serve; without them a Puerto Rico user gets "US virtual accounts aren't available in Puerto Rico yet." With them the default list covers every NANP region (pinned in schema.spec.ts). Caveat: Bridge's published country table marks Kenya ineligible for US ACH/FedWire; it is seeded on instruction and ops can untick it in ERPNext. HT and CU are deliberately absent (no ACH for HT; CU prohibited).

Companion change needed on frappe-flash-admin#76: kyc_allowlist.py's BRIDGE_KYC_ALLOWLIST does not yet carry PR/VI/GU/AS/MP (the rows exist in ADDITIONAL_COUNTRIES, unticked). Add them there before step 4 above, or the ERPNext list will be narrower than this default and Puerto Rico users regress the moment source flips.

User-facing messages (shown verbatim by the app)

  • BRIDGE_KYC_COUNTRY_NOT_SUPPORTED — "US virtual accounts aren't available in {Country} yet." (country name when the runtime can name it, else the code; for an unattributable number on a shared calling code with no allowed region, the candidates are named — "in Kazakhstan or Russia yet." — or, past three, "in your country yet.")
  • BRIDGE_KYC_PHONE_REQUIRED — "Add and verify a phone number before starting identity verification." (no phone, or unparsable)
  • BRIDGE_KYC_EMAIL_NOT_VERIFIED — "Verify your email address before starting identity verification." (only when the email rule is switched on)

Files

src/app/bridge/kyc-gate.ts (pure checkBridgeKycEligibility, resolvePhoneCountries, assertBridgeKycEligible), src/domain/users/phone-regions.ts (countriesForCallingCode, lifted out of request-code.ts so both gates share one candidate set), src/services/frappe/allowed-countries.ts (reader with the source switch), src/services/frappe/coerce.ts (toAlpha2 — one alpha-2 rule for ERPNext rows, config entries and Lookup results), src/services/frappe/ErpNext.ts (getAllowedCountries), src/services/frappe/errors.ts, src/services/bridge/errors.ts, src/graphql/error-map.ts, src/graphql/public/root/mutation/bridge-initiate-kyc.ts, src/config/schema.ts + schema.types.d.ts; specs for the gate, the reader, the resolver, the error map, ErpNext.getAllowedCountries, toAlpha2, countriesForCallingCode, and the schema defaults.

Verification

Test Suites: 227 passed, 227 total
Tests:       3 skipped, 2529 passed, 2532 total
tsc --noEmit (all three tsconfigs): only the 2 pre-existing errors (graphql-server.ts:222, graphql-admin-server.ts:200)
eslint: exit 0 on all changed files (under the lockfile's prettier 3.6.2; a drifted local 3.9.6 disagrees only on the pre-existing `BridgeKycGateRule` union, which CI's 3.6.2 accepts)
prettier@3.6.2 (lockfile version) --check: clean on all changed files
typos: clean
libphonenumber-js 1.12.25 (lockfile) and 1.13.11 (local drift) verified to agree on every number the specs use

Follow-ups (not in this PR)

  • Refresh phoneMetadata with a fresh Twilio Lookup inside verifyPhone / userPhoneRegistrationValidate so the stamp cannot go stale on a phone change. The gate's number cross-check is the guard until then.
  • Surface Bridge's post-approval refusal reason on the KYC status query so the app can explain an approved-but-no-account state.
  • Per-account daily KYC-start cap + a global hourly circuit breaker with an ops alert (bounds retry loops and waves without touching first-time users).
  • Ask Bridge for the authoritative list of residencies enabled for Flash's USD virtual accounts (their public table says IN/NG are eligible, yet they were refused).

🤖 Generated with Claude Code

https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8

…xt-managed allowlist)

On 2026-09-01 a wave of reward-hunting signups from countries Bridge cannot
issue Flash a USD virtual account for started KYC within minutes of
registering. Bridge approved them and then refused the virtual account
("The customer is not authorized to create USD Virtual Accounts"): the user
did the work, got a green check, and still had nothing.

bridgeInitiateKyc now asks the one question that predicts that outcome
before sending anyone through KYC: is the user's PHONE country one Bridge
can serve? The phone country is the Twilio Lookup country stamped at signup
(`user.phoneMetadata.countryCode`), with the phone number's parsed region as
the fallback; it is the hardest signal to fake (the wave typed "Jamaica" into
the address form while verifying real Indian and Nigerian SIMs). Ineligible
users get an honest answer up front instead of a KYC they cannot use:
"US virtual accounts aren't available in {country} yet."

The allowed set is ops-managed: the ERPNext "Allowed Country" doctype rows
with `flash_allowed` ticked (/app/allowed-country), read through the same
60s-memoised pattern as fee-discounts.ts. When ERPNext cannot be read the
reader falls back to `bridge.kycGate.countryAllowlist.defaultCountries`
(never "allow everyone", never "deny everyone"). Default deny: a country not
in the set is not allowed.

There is deliberately NO account-age rule (an earlier revision of this branch
had one): a real user must be able to start KYC the moment they sign up.
`requireVerifiedEmail` stays available but is now off by default.

Config (absent keys take these defaults):
  bridge.kycGate.requireVerifiedEmail: false
  bridge.kycGate.countryAllowlist.enabled: true
  bridge.kycGate.countryAllowlist.defaultCountries: JM TT BB BS DO KY AG DM
    GD KN LC VC BZ GY SR AW CW SX BM TC VG AI MS US CA GB MX SV SN KE

KE is seeded on instruction although Bridge's published table marks Kenya
ineligible for ACH/FedWire; HT and CU are absent (no ACH for HT, CU
prohibited). Every denial is logged at warn with accountId/countryCode/rule
and stamped on the span (bridge.kyc_gate.*).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
@islandbitcoin islandbitcoin changed the title feat(bridge): gate KYC initiation behind verified email and minimum account age feat(bridge): gate KYC initiation on phone-country eligibility (ERPNext-managed allowlist) Sep 1, 2026
bobodread876 and others added 3 commits September 1, 2026 16:40
…its defaults

Review fixes for #502.

- countryAllowlist.source ("config" | "erpnext", default "config"). The
  ERPNext doctype as seeded on frappe-flash-admin main ticks flash_allowed
  for 165 countries, NG and IN included; reading it before the reseed
  (frappe-flash-admin#76) has run would admit the wave this gate exists to
  stop. Defaulting to the config list makes the two PRs order-independent;
  the flip to ERPNext is a deliberate configmap step after verifying the
  reseed.
- Calling-code fallback for numbers libphonenumber parses but cannot
  attribute (~340 assigned NANP area codes: +1 924/472/861; the UK 07700
  900xxx range). Such a user has a verified phone on file; the old gate
  told them to add one. The gate now resolves every region the calling
  code could denote and passes if any is allowed, the same fallback
  checkAuthCodeDestination makes. countriesForCallingCode is lifted out of
  request-code.ts into @domain/users/phone-regions so both gates share one
  candidate set. bridge.kyc_gate.country_source is stamped on every
  evaluation so ops can see the fallback rate.
- Add the five NANP US territories (PR, VI, GU, AS, MP) to the default
  list: Twilio Lookup reports them by territory, not as US, and they are
  US persons Bridge can serve. The list now covers every NANP region.
- One toAlpha2 in @services/frappe/coerce replaces the three copies of
  the trim/upper/^[A-Z]{2}$ rule.
- Tests: pin the kycGate schema defaults (enabled, email rule off, source
  "config", 35 codes, territories, every NANP region, no IN/NG/HT/CU);
  cover ErpNext.getAllowedCountries (query shape, non-array data, axios
  reject); cover toAlpha2 and countriesForCallingCode; drop the "no age
  rule" case that was a byte-for-byte duplicate of the first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
…dicts

Review fixes for #502.

`resolvePhoneCountries` trusted `phoneMetadata.countryCode` unconditionally,
but that stamp is written only at account creation and both public phone
mutations carry it under a new number: `userPhoneDelete` spreads `...user`
so the metadata survives, and `userPhoneRegistrationValidate` writes the
new number with no fresh Twilio Lookup. A JM signup that re-registers a
Nigerian SIM therefore carried `countryCode: "JM"` over `phone: "+234…"`
and the gate waved it through to the virtual-account refusal it exists to
stop.

The stamp is now accepted only while the number on file cannot contradict
it: no resolvable number, the same region, or an unattributable number on a
calling code that includes the stamped region. Otherwise the number decides
and the resolution carries `lookupStale: true`, stamped on the span as
`bridge.kyc_gate.lookup_stale` and on the denial log so ops can count the
rate. Refreshing the stamp on phone change is the deeper fix; not done here.

Also cite the lockfile's libphonenumber-js (1.12.25 — what `--frozen-lockfile`
ships and CI tests) in the spec instead of a drifted local 1.13.11; both
versions were verified to behave identically on every number the tests use.

Tests: stale JM stamp over +234… denies naming Nigeria with the stale flag
stamped; stale NG stamp over a JM number passes on the number; JM stamp over
an unattributable +1 stands; stamp with no number stands; the stale
calling-code fallback keeps the candidate set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
islandbitcoin pushed a commit to lnflash/frappe-flash-admin that referenced this pull request Sep 2, 2026
…allowlist

Mirrors the flash `bridge.kycGate.countryAllowlist.defaultCountries` seed
after the Simon review of lnflash/flash#502: Twilio Lookup returns these
codes for +1 787/939, 340, 671, 684 and 670 numbers, and Bridge serves their
residents as US persons, so they must not be told "US virtual accounts aren't
available in Puerto Rico yet". Adds seed rows for GU/AS/MP (PR/VI already
existed) and bumps the contract test to the 35-code list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants