Skip to content

feat(mobile): Plaid Link SDK cutover for Bridge external accounts [ENG-524]#668

Merged
islandbitcoin merged 10 commits into
mainfrom
jabariennis/eng-524-featmobile-plaid-link-sdk-cutover-linktoken
Jul 18, 2026
Merged

feat(mobile): Plaid Link SDK cutover for Bridge external accounts [ENG-524]#668
islandbitcoin merged 10 commits into
mainfrom
jabariennis/eng-524-featmobile-plaid-link-sdk-cutover-linktoken

Conversation

@islandbitcoin

@islandbitcoin islandbitcoin commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Why

Client side of backend PR lnflash/flash#446 (deployed to TEST and PROD 2026-07-17), which replaced the hosted Bridge Plaid linkUrl webview with the Plaid Link SDK + server-side public_token exchange (the Api-Key never leaves the backend). Closes ENG-524.

Backend contract:

  • bridgeAddExternalAccount returns linkToken: String! (+ expiresAt); linkUrl is nullable and @deprecated.
  • bridgeExchangePlaidPublicToken(input: { linkToken, publicToken }): { errors, message }.
  • Error codes BRIDGE_INVALID_PLAID_TOKEN, BRIDGE_PLAID_NOT_AVAILABLE.

What changed

  • Dependency: react-native-plaid-link-sdk@12.8.3 (exact pin) — the last line with the classic imperative create()/open() API and no Expo-modules dependency.
  • usePlaidLink hook (app/hooks/use-plaid-link.ts) owns the whole flow — extraction mirrors useBridgeKyc, and makes it testable. linkBankAccount is the full entry point (request linkToken → open Plaid Link → exchange; BRIDGE_PLAID_NOT_AVAILABLE → caller's manual-entry fallback), consumed by both entry points: TopupCashout's settle path and the Bank accounts hub's "Add bank account" CTA (device testing found the hub still went straight to the manual form — the path users with existing accounts actually take):
    • In-flight session guard: the SDK's native module is a singleton; a second create()/open() while a session is up cross-wires stored callbacks and {linkToken, publicToken} pairs. The guard spans the whole flow — including the link-token request, so a double-tap can't mint two tokens — until the session terminates.
    • Exit errors detected by content, not presence: the iOS bridge always embeds an error object with empty-string fields on a plain user cancel (Android omits the key). A real failure requires non-empty errorCode/errorMessage/displayMessage; the alert prefers Plaid's user-facing displayMessage. Cancels are silent on both platforms.
    • Success is decided by the exchange alone: after a clean bridgeExchangePlaidPublicToken, the onLinked refetch is best-effort — its failure is never reported as a link failure (which would invite re-linking and duplicate external accounts). The linked account arrives asynchronously via Bridge's external_account webhook; the success copy says it will appear shortly.
  • TopupCashout.tsx: checkBridgeKyc requires linkToken (error alert if absent) and addExternalAccount is try/caught so a thrown mutation can't strand the activity spinner. The hosted-URL fallback branch is removed — against a pre-[Bug] - improve on-chain receive copy string to be compatible with CashApp #446 backend the linkToken-selecting operation fails validation entirely, so the branch was unreachable dead code, and the backend is deployed. The BridgeExternalAccountWebView screen/route stay registered (currently unreferenced) as an operational escape hatch until on-device SDK verification; removing them is a tracked follow-up.
  • Schema snapshot + codegen regenerated; linkUrl is no longer selected anywhere, so its deprecation adds no new deprecated-field note — graphql-check output is unchanged from main (its one pre-existing note covers username/contacts/price-fragment fields untouched by this PR). BRIDGE_PLAID_NOT_AVAILABLE → manual bank entry path unchanged.
  • i18n: all new user-facing copy under PlaidLink.* (en + 23 locales).
  • jest: manual mock for the native module; the spec inventory lives in Testing below.

Testing

  • __tests__/hooks/use-plaid-link.spec.tsx — 15 tests (plus a three-rung exit-copy case): six linkBankAccount cases (token→Plaid, manual fallback, payload error, missing token, thrown mutation, one-guard-across-request+session double-tap; each pins guard re-arm); exchange payload/variables, refetch settle-order pinned via a deferred promise (no success alert while the refetch is in flight), refetch-rejection-still-succeeds, payload-error and thrown-error paths (indicator always cleared, no refetch, guard re-arms for retry), in-flight guard with re-arm on every session-termination path (cancel exit, error exit, completed, failed), content-based exit errors preferring displayMessage, both platform cancel shapes silent.
  • Full suite green (see CI); yarn tsc:check clean; changed files lint clean; translation-drift clean; codegen deterministic.
  • Hardened by iterative multi-agent adversarial review until a clean round (findings verified against the vendored Plaid SDK 12.8.3 iOS/Android bridge sources; test findings verified by empirically running mutants). All code defects were confirmed and fixed in the first round; every later round confirmed only test-hardening and doc-wording findings.

Not in this PR / needs device testing

  • OAuth-bank redirect config — iOS associated domains + AppDelegate redirect handler, Android intent-filter, plus a redirect URI registered in the Plaid dashboard. Non-OAuth Link works without it; tracked follow-up.
  • Device/simulator runpod install (autolinked) and an end-to-end link against TEST remain to be exercised before release. Expect [!] Plaid has been deprecated from pod install: the CocoaPods Plaid pod is deprecated upstream with no successor (Plaid moved iOS distribution to SPM); 6.4.7 — what ~> 6.4.3 resolves — is the final version it will ever carry. Harmless today; LinkKit updates beyond 6.4.7 arrive only via the RN SDK v13+ (Expo-modules) line, which is the eventual migration trigger.

🤖 Generated with Claude Code

…G-524]

Match backend flash#446, which replaced the hosted Plaid linkUrl with a
linkToken + server-side public_token exchange.

- Add react-native-plaid-link-sdk (12.8.3, classic imperative API; no Expo dep).
- Schema snapshot: BridgeExternalAccountLink gains linkToken; linkUrl is now
  nullable + @deprecated. Add bridgeExchangePlaidPublicToken mutation + input/
  payload types. Regen codegen.
- GraphQL docs: select linkToken in BridgeAddExternalAccount; add
  BridgeExchangePlaidPublicToken mutation.
- TopupCashout.checkBridgeKyc: open Plaid Link with linkToken, then call
  bridgeExchangePlaidPublicToken({ linkToken, publicToken }) on success. The
  linked account arrives asynchronously via Bridge's webhook.
- Additive fallback: no linkToken (older backend) -> existing linkUrl WebView;
  BRIDGE_PLAID_NOT_AVAILABLE -> manual bank entry (unchanged).
- jest mock for the native SDK.

Follow-ups: OAuth-bank redirect config (iOS associated domains + AppDelegate
handler, Android intent-filter; needs a Plaid-dashboard redirect URI) and
device/simulator verification. Do not merge before flash#446 ships.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@linear

linear Bot commented Jul 16, 2026

Copy link
Copy Markdown

ENG-524

Inline review pass (house standards from the ENG-513/516 cycle):

- Plaid Link + public_token exchange moves from TopupCashout into a
  usePlaidLink hook (same extraction pattern as useBridgeKyc), taking
  onLinked for the webhook-async refetch — and becoming testable.
- All five new user-facing strings localized (PlaidLink.* keys, en +
  23 locales); the onExit fallback no longer mislabels a real Plaid
  failure as "cancelled".
- 5 tests over the flow using the SDK manual mock: exchange payload +
  variables, payload-error and thrown-error paths (indicator always
  cleared, no refetch), exit-error alert with/without Plaid's message,
  silent user cancel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@islandbitcoin
islandbitcoin force-pushed the jabariennis/eng-524-featmobile-plaid-link-sdk-cutover-linktoken branch from 3153e7f to e81f4f5 Compare July 17, 2026 15:38
…s reporting

Adversarial-review round 1 (5 confirmed findings):

- BLOCKING: on iOS the bridge ALWAYS embeds an error object with
  empty-string fields in the Link exit payload, so `if (exit.error)`
  alerted "Bank linking failed" on every plain user cancel (Android
  omits the key and stayed silent). Real failures are now detected by
  content (errorCode/errorMessage/displayMessage non-empty), and the
  alert prefers Plaid's user-facing displayMessage.
- In-flight session guard: the SDK's native module is a singleton — a
  double-tap ran create()/open() twice, cross-wiring stored callbacks
  and {linkToken, publicToken} pairs. Second call is now a no-op until
  the session terminates.
- A rejection from the best-effort onLinked refetch was reported as
  "Failed to link your bank" AFTER a successful exchange — inviting
  users to re-link and create duplicate external accounts at Bridge.
  Success is now decided by the exchange alone.
- The linkUrl fallback branch was unreachable dead code (the
  linkToken-selecting operation fails validation entirely on a
  pre-flash#446 backend) and that same failure stranded the activity
  spinner: addExternalAccount is now try/caught, the branch and the
  dead linkUrl selection are removed (codegen regenerated). The
  hosted-webview screen stays registered as an operational escape
  hatch until on-device SDK verification.
- Spec: 8 tests — refetch-rejection still succeeds, refetch-before-
  alert ordering pinned, in-flight guard + re-arm after exit and after
  completion, content-based exit errors, both platform cancel shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@islandbitcoin
islandbitcoin marked this pull request as ready for review July 17, 2026 17:49
Dread and others added 7 commits July 17, 2026 11:00
Adversarial-review round 3: the guard's release after a failed exchange
was enforced solely by the finally block, and both natural
finally-removal mutants (re-arm at end-of-try, re-arm after try/catch)
passed the whole suite — a "redundant finally" refactor would strand
users on a latched guard after any failure until remount. The
payload-error and thrown-exchange tests now retry and assert a second
session opens; each assertion kills one mutant (verified empirically
by the reviewer).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial-review round 4: the branch-scoped release mutant (guard
freed only on silent-cancel exits) survived all 8 tests — a real
Plaid error (bank down, the case users retry) would latch the guard
and dead the link button until remount. The error-exit test now
retries and asserts a second session opens. (Round 4 also corrected
the PR body's graphql-check claim — the one deprecated-field note is
pre-existing and unrelated to linkUrl.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial-review round 5: the ordering assertion compared mock
invocationCallOrder — call order, not settle order — so the
fire-and-forget mutant (onLinked?.()?.catch()) passed the whole suite
while shipping "Bank connected" over a still-stale account list. The
new test holds the refetch open on a deferred promise, asserts no
success alert while it is pending (with onLinked already called, so
the window is proven reached), then resolves and asserts the alert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial-review round 6: deleting the errorMessage fallback term
passed all 9 tests, so a "simplification" would silently swap Plaid's
actionable developer message (display_message is commonly null on real
errors) for the generic fallback. New exit case pins the middle rung
with the iOS empty-string displayMessage shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ndition

Adversarial-review round 8: an order-inverting mutant passed all 9
tests, yet open()-before-create() is a total outage on device (Android
throws LinkException, iOS error-exits "Create was not called."). The
shared helper now asserts invocation order (flip-detectable, unlike the
vacuous ordering assertion removed in round 5) and the guard-re-arm
test pins the second session's pair too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
main's schema snapshot (synced from the flash#452 branch) predated the
Plaid fields, so neither side was a superset: public-schema.graphql now
carries the union, with the three Plaid members verified field-by-field
against the live production API via introspection. Codegen and
i18n-types regenerated from the merged sources; hooks barrel keeps all
three new exports. Full suite on the union: 42 suites / 194 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…vice-test gap)

Device testing on prod found the Bank accounts hub's "Add bank account"
still navigated straight to the manual bank-details form — the Plaid
flow was only wired into TopupCashout's settle path for users with zero
linked accounts, so the hub entry point (the one users with accounts
actually use) never saw Plaid.

The token-request→Plaid-or-fallback decision moves into usePlaidLink as
linkBankAccount({onManualEntry}): requests the linkToken, opens Plaid
Link, and on BRIDGE_PLAID_NOT_AVAILABLE runs the caller's manual-entry
fallback. The reentrancy guard now spans the token request too, so a
double-tap can't mint two link tokens. Both TopupCashout and the Bank
accounts hub consume it (one implementation, two entry points); the
"failed to get link" copy is localized (PlaidLink.linkTokenFailed, en +
23 locales).

Spec: 15 tests — six new linkBankAccount cases (token→Plaid, manual
fallback, payload error, missing token, thrown mutation, and the
one-guard-across-request+session double-tap), each pinning guard
re-arm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@islandbitcoin
islandbitcoin merged commit e145a39 into main Jul 18, 2026
7 checks passed
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.

1 participant