Skip to content

fix(buy): stop the completion mail step from silently swallowing its result - #1204

Merged
TaprootFreak merged 10 commits into
developfrom
fix/buy-flow-mail-step-dead-end
Jul 29, 2026
Merged

fix(buy): stop the completion mail step from silently swallowing its result#1204
TaprootFreak merged 10 commits into
developfrom
fix/buy-flow-mail-step-dead-end

Conversation

@Danswar

@Danswar Danswar commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Problem

BuyCompletion renders MailEdit when the account has no mail yet — the "Is this email address correct? / [Change] [Confirm]" step at the end of a buy. Two defects make it a dead end.

The success path does nothing. buy-completion.tsx passed:

onSubmit={(email) => (!email || email.length === 0) && close()}

For a non-empty address that is false && close() — a no-op. MailEdit keeps showConfirmation true and pendingEmail set, so it re-renders the identical screen. user.mail is still stale (the react package's updateMail does not refresh it), so BuyCompletion stays in the MailEdit branch too. The user gets no confirmation, no navigation, nothing — and clicks Confirm again.

The second click's error is discarded. The account now has a mail, so the API requires 2FA and answers 403 {"code":"TFA_REQUIRED", ...}. saveUser only handled the merged-account 401 and rethrew everything else into a promise nobody catches — an unhandled rejection with zero UI change, which invites another click. The Confirm button is never disabled either: it receives isLoading={isUserUpdating}, and that flag is never set for this call.

Production, PUT /v2/user/mail over 30 days: 1206 TFA_REQUIRED against 19 successful sets. A representative session:

16:48:51  PUT /v2/user/mail  200      <- the address was saved here
16:49:00  PUT /v2/user/mail  403
16:49:24  PUT /v2/user/mail  403      (x17 more over two minutes)

Reported by a partner-wallet user who saw the 403 and assumed the step had failed. It had not — their address was stored on the first click.

Changes

Buy completion mail step

  • buy-completion.tsx closes on a successful submit, which is what its own Close button does in the branch for accounts that already have a mail.
  • Local isSaving state disables Confirm while the request is in flight, so the fix does not depend on a @dfx.swiss/react release landing first.
  • Two outcomes that retrying can never resolve now get their own short screen with an OK button that ends the step, instead of the generic ErrorHint — whose fixed opening line ("Something went wrong. Please try again…") is the wrong instruction for both:
    • TFA_REQUIRED — the account already carries an address. This step can only ever set a first one; changing an existing one additionally needs a code mailed to the new address, and this component has no field to enter one.
    • 409 "account already exists - account merge request sent" — reachable on the first-set path too. Retrying only sends another merge mail; the user has to follow the link. Reuses the hint the account and KYC screens already show for this case.
  • close() falls back to the account screen when closeServices has nothing to do. On /buy/info (navigateOnClose={false}, not a widget, not in an iframe, no redirect URI) it executed nothing, and blanking the component left an empty body with the back button suppressed. That state predates this branch, but this PR routes three more outcomes into it.
  • A stale error no longer survives the Change button.
  • The one new string is added to the German, French and Italian translations.

Mail app-parameter (settings.context.tsx) — a second source of the same 403s

useEffect(() => {
  if (user && mail && user.mail !== mail) updateUserMail(mail);
}, [user, mail]);

The comparison is raw, but the API normalises the address on write (@Transform(Util.toLowerCaseTrim) plus a toLowerCase() in doUpdateUserMail) and the mail parameter is never normalised on the client. So ?mail=John.Doe@Example.com — or a padded a@b.com — never satisfies the guard: the effect re-submits on every change of the user object, and once the address is stored each of those is another 403 TFA_REQUIRED. The parameter is persisted to local storage, so it survives reloads.

The guard now normalises the same way the server does, and remembers the address it already submitted. That bound also covers the two cases normalising alone cannot close: a value the API rejects outright, and a change accepted with 202 that stays pending mail verification, where the stored address legitimately does not move.

Why not redirect to /2fa

An earlier revision of this PR did redirect, mirroring kyc.screen.tsx. Review showed that is worse than the dead end it replaces: setRedirect records only the pathname (navigation.hook.ts), while the completion view is component-local state (buy.screen.tsx showsCompletion, buy-info.screen.tsx). Returning from /2fa therefore remounts an empty buy form — typed address gone, completion screen unreachable, no explanation. Routing to /account/mail instead was considered; it sends the user on an errand they usually do not need, since reaching this branch at all means the account already has a valid address.

Known limitation (pre-existing, not addressed here)

When the account already has a mail and a valid 2FA log exists, PUT /v2/user/mail answers 202 and mails a verification code instead of 403. updateMail is typed Promise<void> and every 2xx resolves identically, so the client cannot distinguish 202 from 200 and will close as if the address were stored — when it is only pending. Narrow (it needs a stale user.mail, a recent same-IP 2FA log, and a different address) and unchanged by this PR. It applies to the MailEdit step only — the app-parameter path above is bounded by its submitted-address guard. Closing it properly needs the status code surfaced through @dfx.swiss/react plus the token field this component lacks; tracked for a follow-up rather than smuggled in here.

Verification

The four CI commands pass locally on Node 20.20.2 / npm 10.8.2: npm run lint, npm run test (62 suites, 617 tests), npm run build:dev and npm run widget:dev. npx tsc -p tsconfig.build.json --noEmit, the type check the review bot runs, is also clean.

widget:dev is the build that matters for strictness: scripts/build-widget.sh does not pin CI, so GitHub Actions runs it with warnings-as-errors. scripts/build.sh pins CI=false inline, in the repo, so build:dev never enforces that gate anywhere.

(A bare npx tsc --noEmit is not a usable gate in this repo and is not part of CI: tsconfig.json sets "typeRoots": ["typings"], so @types/jest never resolves and the test files produce thousands of Cannot find name 'describe' errors on develop as well. None of them touch the files in this PR.)

Related

…result

MailEdit is rendered by BuyCompletion when the account has no mail yet. Two
defects turned that step into a dead end:

BuyCompletion passed `onSubmit={(email) => (!email || email.length === 0) &&
close()}`, which for a non-empty address evaluates to `false && close()` and
therefore does nothing. After a successful save the confirmation screen stayed
exactly as it was — same address, same enabled Confirm button, no feedback — so
the user clicked Confirm again. The account now carried a mail, which the API
answers with 403 TFA_REQUIRED, and MailEdit rethrew that into an unhandled
promise rejection with no UI change at all, inviting yet another click. In
production this produced 1206 TFA_REQUIRED responses on PUT /v2/user/mail over
30 days against 19 successful sets.

MailEdit now handles the 403 the way kyc.screen.tsx already does — redirect to
/2fa with the Basic level — surfaces every other error through ErrorHint instead
of discarding it, and tracks its own in-flight state so Confirm is disabled
during the request regardless of the isUserUpdating flag from the react package.
BuyCompletion closes on a successful submit, matching what its own Close button
does in the branch for accounts that already have a mail.
Danswar added 5 commits July 28, 2026 17:34
…g out of the flow

Follow-up to the previous commit, from review:

The 2FA redirect it introduced was not an improvement. setRedirect stores only the
pathname, while the completion view is component-local state in the buy screens, so
returning from /2fa remounts an empty buy form: the typed address is gone, the buy
completion screen is unreachable, and nothing explains why. That trades a visible
dead end for a silent flow reset. This step can only ever set a FIRST address —
changing an existing one also needs a code sent to the new address, and this
component has no field to enter one — so it now says exactly that and leaves the
user on the completion screen, where Close still works.

Two more cases the previous commit routed into the generic error hint:

- A 409 "account already exists - account merge request sent" reaches this step on
  the first-set path too. ErrorHint tells the user to try again, which only mails
  another merge request; the correct action is to follow the link already sent. It
  now shows the same hint the account and KYC screens use for this case.

- A stale error survived the Change button, so a new address could be presented
  with the previous address's error still on screen.

The new string is added to the German, French and Italian translations.
…er refresh

The effect that applies the mail app-parameter compares it against the cached
user address with a case-sensitive !==, but the API lowercases the address on
write. A parameter such as ?mail=John.Doe@Example.com therefore never satisfies
the guard: it fires the update again on every change of the user object, which
is one of the ways an account that already carries an address ends up hammering
PUT /v2/user/mail and collecting 403 TFA_REQUIRED. Comparing case-insensitively
lets the guard close once the address has been applied.
…en and a way out

Review found the previous commit inconsistent with its own reasoning. It moved the
409 merge case out of ErrorHint because ErrorHint's fixed opening line tells the
user to try again, which is the wrong action there — then routed the 2FA case into
ErrorHint, where retrying is just as futile: the account has an address, so every
further PUT answers 403 and this component has no field for the emailed code. The
user read "Please try again" directly above "you can change it in your account
settings", with Confirm still enabled.

It also had no exit. BuyCompletion renders its Close button only in the branch for
accounts that already carry an address, and both host screens suppress the layout
back button while the completion is showing, so this state could only be left
through the burger menu.

The case now gets the same treatment as the merge hint: its own short screen with
an OK button that ends the step.
The case-insensitive guard from the previous commit only mirrored half of the
server's normaliser. The API applies trim and lowercase, so a padded parameter
such as ?mail=%20a@b.com is stored as a@b.com while the guard keeps comparing
against the padded value — permanently open, and re-submitting on every change
of the user object exactly as before. The parameter is persisted to local
storage, so that survives reloads.

The guard now normalises the same way the server does, and additionally
remembers the address it already submitted. That also covers the two cases
normalisation alone cannot close: a value the API rejects outright, and a change
that is accepted with 202 and stays pending mail verification, where the stored
address legitimately does not move. The normalised address is what gets sent,
which is what the server would store anyway.
…do anything

close() calls closeServices and then blanks the component. On /buy/info that
combination strands the user: the screen passes navigateOnClose={false}, and
closeServices only acts when the app runs as a widget, inside an iframe, or with
a redirect URI configured — outside those it executes nothing at all. The body
then renders empty with the layout back button suppressed, leaving only the
burger menu.

That state predates this branch, reachable through the existing Close button and
the empty-address Finish path, but this PR routes three more outcomes into it. It
now falls back to the account screen when closing has nowhere to go.
@Danswar

Danswar commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Took 5 review passes to reach zero defects. The diff is small; the path to it was not, so here is what changed and why — several of these were regressions introduced by earlier revisions of this PR.

  1. The first attempt redirected to /2fa on TFA_REQUIRED. That is worse than the dead end it replaced: setRedirect records only the pathname, while the completion view is component-local state, so returning remounts an empty buy form with the typed address gone. Replaced with an in-place explanation.
  2. That explanation then went through ErrorHint, whose fixed opening line is "Something went wrong. Please try again." — directly contradicting it, with Confirm still enabled and no way off the screen. It now has its own short screen with an OK button, the same treatment the merge hint gets.
  3. The 409 "account already exists" case was left on the generic error path, where "please try again" only sends another merge mail.
  4. The mail app-parameter guard was fixed twice. Making the comparison case-insensitive was only half of it: the server applies trim and lowercase, so a padded parameter kept the guard permanently open — and the parameter is persisted to local storage, so that survives reloads. It now normalises identically and remembers the address it submitted, which also bounds the 202 pending-verification case.
  5. close() is a genuine no-op on /buy/infonavigateOnClose={false}, and closeServices only acts as a widget, in an iframe, or with a redirect URI. It then blanked the component with the back button suppressed. Pre-existing, but this PR routed three more outcomes into it, so it now falls back to the account screen.

Two claims in this description were also wrong and have been corrected: a Close button that is not rendered in that branch, and npx tsc --noEmit listed as passing when it fails repo-wide on develop (a typeRoots issue unrelated to these files).

npm run lint, npm run test (62 suites / 617 tests), npx tsc -p tsconfig.build.json --noEmit, npm run build:dev and npm run widget:dev all pass on Node 20.

Danswar and others added 3 commits July 29, 2026 10:28
Keep why each branch exists — retrying cannot succeed, closing can be a no-op,
the parameter guard has to normalise like the API — and drop the narration.
isEmbedded and canClose describe the host, not whether closeServices did
anything. A widget host that leaves its optional close callback unbound, and a
stored redirect URI that is discarded as unsafe, both satisfied the flags while
nothing fired -- so the completion blanked itself and, with the back button
suppressed, stranded the user.

closeServices now reports whether a channel actually fired and the completion
falls back to the account screen when none did. Three callers that returned its
result from a Promise<void> function are adjusted accordingly.
The guard was set before the request and never taken back, so a single offline
blip or a rate-limit response lost the mail parameter for the rest of the
session. It now survives anything transient and keeps blocking only what
retrying cannot fix, and the rejection is handled instead of escaping unhandled.

@TaprootFreak TaprootFreak left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed with an independent second-model pass, then verified locally on a dedicated build host.

Verified

  • tsc -p tsconfig.build.json --noEmit, lint, build:dev and widget:dev — all clean (widget:dev is the one that matters, since it runs without the pinned CI=false and treats warnings as errors)
  • Full suite: 64 suites, 622 tests green
  • Mutation-tested both new files: removing the fallback navigation fails exactly falls back to the account without blanking the completion when no close channel fired; removing the retry release fails exactly retries the normalized parameter after a transient rate-limit error. Neighbours stay green.
  • One suite (transaction-document-error) failed once under heavy load on the build host and passes both in isolation and in a full rerun — a timing flake, not a defect.

Changed on this branch (37d5cf7, ae58301)

  1. The new fallback keyed on isEmbedded/canClose, which describe the host rather than whether closeServices did anything. Two reachable holes stayed open: a widget host that leaves the optional close callback unbound (closeServices only does props.closeCallback?.(...)), and a stored redirect URI discarded as unsafe (canClose is redirectUri != null, but the sink drops it at !isSafeRedirectUri). Both left the screen blank with the back button suppressed. closeServices now returns whether a channel actually fired.
  2. Three callers returned that value from Promise<void> functions, which does not compile (TS2322 in sell.screen.tsx:493, sell-info.screen.tsx:231, payment-info.tsx:113) — adjusted to a plain call plus return.
  3. The mail-parameter guard was set before the request and never released, so one offline blip or a 429 — a status this route is about to start returning once DFXswiss/api#4439 lands — lost the parameter for the rest of the session. It now releases on anything transient and keeps blocking only what retrying cannot fix; the rejection is handled instead of escaping unhandled.

Note on the API-side counterpart
That coupling is worth stating explicitly: api#4439 introduces the rate limit that makes the transient case reachable in the first place. The two PRs declare each other optional, which is true for the dead end itself, but this particular interaction only appears once both are live.

Left open
No Playwright baselines for the two new screens. Not treated as a blocker: none of the 149 existing baselines covers the mail step, and the three specs mentioning "email address" only assert on page text without a screenshot, so nothing existing goes stale. Adding focused cases would still be worthwhile.

Disclosure: I contributed the commits above, so this approval covers my own changes as well.

@TaprootFreak

Copy link
Copy Markdown
Collaborator

@Danswar Heads-up: I pushed two commits to your branch (37d5cf7, ae58301) while reviewing.

The !navigateOnClose && !isEmbedded && !canClose fallback keys on the host rather than on whether closeServices did anything, so two cases still blanked the screen: a widget host that leaves the optional close callback unbound, and a stored redirect URI that gets discarded as unsafe. closeServices now returns whether a channel actually fired. Three callers returned that value from Promise<void> functions, which does not compile — adjusted.

Second commit: the mail-parameter guard was set before the request and never released, so a single offline blip or a 429 lost the parameter for the session. Worth noting since #4439 is about to start returning 429 on that route.

Lint, type-check, build:dev, widget:dev and all 622 tests green; both new test files mutation-checked. Approved.

The lockfile pinned 1.7.0-beta.0, so npm ci installed the prerelease even
though the caret range already allowed the stable release. 1.7.0 is the one
that carries the updateMail cache refresh this branch builds on.

Brings @dfx.swiss/core 0.6.0-beta.0 -> 0.6.0 along as a transitive
requirement of react 1.7.0; nothing else in the lockfile moves.
@TaprootFreak
TaprootFreak merged commit 00f7224 into develop Jul 29, 2026
6 checks passed
@TaprootFreak
TaprootFreak deleted the fix/buy-flow-mail-step-dead-end branch July 29, 2026 17:23
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