feat(mobile): Plaid Link SDK cutover for Bridge external accounts [ENG-524]#668
Merged
islandbitcoin merged 10 commits intoJul 18, 2026
Conversation
…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>
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
force-pushed
the
jabariennis/eng-524-featmobile-plaid-link-sdk-cutover-linktoken
branch
from
July 17, 2026 15:38
3153e7f to
e81f4f5
Compare
…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
marked this pull request as ready for review
July 17, 2026 17:49
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Client side of backend PR lnflash/flash#446 (deployed to TEST and PROD 2026-07-17), which replaced the hosted Bridge Plaid
linkUrlwebview with the Plaid Link SDK + server-sidepublic_tokenexchange (the Api-Key never leaves the backend). Closes ENG-524.Backend contract:
bridgeAddExternalAccountreturnslinkToken: String!(+expiresAt);linkUrlis nullable and@deprecated.bridgeExchangePlaidPublicToken(input: { linkToken, publicToken }): { errors, message }.BRIDGE_INVALID_PLAID_TOKEN,BRIDGE_PLAID_NOT_AVAILABLE.What changed
react-native-plaid-link-sdk@12.8.3(exact pin) — the last line with the classic imperativecreate()/open()API and no Expo-modules dependency.usePlaidLinkhook (app/hooks/use-plaid-link.ts) owns the whole flow — extraction mirrorsuseBridgeKyc, and makes it testable.linkBankAccountis 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):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.errorobject with empty-string fields on a plain user cancel (Android omits the key). A real failure requires non-emptyerrorCode/errorMessage/displayMessage; the alert prefers Plaid's user-facingdisplayMessage. Cancels are silent on both platforms.bridgeExchangePlaidPublicToken, theonLinkedrefetch 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'sexternal_accountwebhook; the success copy says it will appear shortly.TopupCashout.tsx:checkBridgeKycrequireslinkToken(error alert if absent) andaddExternalAccountis 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 thelinkToken-selecting operation fails validation entirely, so the branch was unreachable dead code, and the backend is deployed. TheBridgeExternalAccountWebViewscreen/route stay registered (currently unreferenced) as an operational escape hatch until on-device SDK verification; removing them is a tracked follow-up.linkUrlis no longer selected anywhere, so its deprecation adds no new deprecated-field note —graphql-checkoutput 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.PlaidLink.*(en + 23 locales).Testing
__tests__/hooks/use-plaid-link.spec.tsx— 15 tests (plus a three-rung exit-copy case): sixlinkBankAccountcases (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 preferringdisplayMessage, both platform cancel shapes silent.yarn tsc:checkclean; changed files lint clean; translation-drift clean; codegen deterministic.Not in this PR / needs device testing
AppDelegateredirect handler, Android intent-filter, plus a redirect URI registered in the Plaid dashboard. Non-OAuth Link works without it; tracked follow-up.pod install(autolinked) and an end-to-end link against TEST remain to be exercised before release. Expect[!] Plaid has been deprecatedfrom pod install: the CocoaPodsPlaidpod is deprecated upstream with no successor (Plaid moved iOS distribution to SPM); 6.4.7 — what~> 6.4.3resolves — 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