Skip to content

feat: DFX App 2.0 preview at /app2 - #1162

Open
joshuakrueger-dfx wants to merge 37 commits into
DFXswiss:developfrom
joshuakrueger-dfx:feat/app2-preview
Open

feat: DFX App 2.0 preview at /app2#1162
joshuakrueger-dfx wants to merge 37 commits into
DFXswiss:developfrom
joshuakrueger-dfx:feat/app2-preview

Conversation

@joshuakrueger-dfx

@joshuakrueger-dfx joshuakrueger-dfx commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

DFX App 2.0 — React target at /app2/

This PR ships App 2.0 as a fourth React build target alongside the production app, the widget and the library. It is built from src/app2/, talks to the API exclusively through @dfx.swiss/react, and deploys as its own isolated artifact under /app2/.

What ships

  • React target: hash-routed buy, sell, swap, account, transactions, KYC, support, OpenCryptoPay merchant views and wallet connect flows.
  • Isolated artifact: scripts/build-app2.sh produces app2-dist/; scripts/postprocess-app2.js strips the shared main-app payload, stages only App 2.0's own PWA assets, injects an environment-specific CSP and App 2.0's own social preview metadata, and fails the build if any of that does not hold.
  • CI/CD: PR CI compiles the target; DEV and PRD build app2-dist/, stage it at build/app2/, upload it, and re-upload app2/index.html with no-cache. Content-hashed assets are uploaded last with immutable.
  • Single implementation: the previous static public/app2/ tree, its vendored browser bundles and its vendored WebAssembly are deleted.

Changes since the last review round

The trade panel and the payment sheet were separated onto the two API calls they belong to:

  • The panels quote through the public PUT {buy,sell,swap}/quote endpoints. The authenticated paymentInfos call runs only when the user moves to pay. Previously any account-state gate (no e-mail on file, KYC, limit) blanked the rate and the calculation instead of surfacing in the sheet, and paymentInfos — which creates a route and a transaction request server-side — fired on every debounced keystroke.
  • All three modes now share one arming path, one 20s timeout, one retry and one cancel-on-input-change. Sell no longer needs a payout account before it can show a rate.
  • An invalid quote no longer renders as 0 with a live countdown, and the payment sheet fails closed on isValid: false rather than printing an IBAN.
  • Fee display: the summary line is derived from the API's rate (final, including fees) instead of client-side division; the breakdown line shows exchangeRate (market rate). Both are labelled so they cannot be read as the same number.

Wallet session and artifact hardening:

  • WalletConnect persistence: the cleanup cleared localStorage, but @walletconnect/keyvaluestorage persists to IndexedDB once it has migrated, so a shared browser could resume the previous owner's session after logout and reload. The store is now emptied in place (a delete would block on the SDK's own open connection), the upgrade path creates the store idb-keyval expects, and an aborted teardown can no longer wipe a newer session's state.
  • accountsChanged/chainChanged now listen on the provider instance the session actually authenticated with (EIP-6963) rather than window.ethereum, and only invalidate when the active address really changes.
  • Invite codes are classified against the API's own two formats before being sent as usedRef or recommendationCode, on the wallet, mail and Alby paths alike.
  • The artifact no longer inherits the main app's social identity, so an /app2 link no longer unfurls as the production app. The artifact root is validated against an allow-list.
  • Instant SEPA is offered only when the API would accept it (currency.instantSellable and asset.instantBuyable); card is not offered at all, since the API rejects it unconditionally.
  • KYC document uploads send the full data URL the API parses; transaction states render translated labels; de, it and fr are compile-checked against the English key set.
  • Third-party notices corrected: the bundled flags are circle-flags, the glyphs are web3icons, and both MIT texts ship with the artifact.

Verification

  • npm run lint — clean
  • npx prettier --check "src/app2/**/*.{ts,tsx,css}" — clean
  • npm run test40 suites, 382 tests
  • npm run build:dev, npm run widget:dev, npm run app2:dev, npm run app2
  • Buy, sell and swap exercised against dev.api.dfx.swiss in the browser, including the payment sheet with real deposit details and the e-mail gate
  • Generated artifact checked for CSP, external-only scripts, its own PWA and social identity, and the absence of shared or nested assets

Known gaps

Deliberately not addressed here, to keep this reviewable:

  • OpenCryptoPay and the hardware/chain connectors (BitBox, Ledger, Trezor, Alby, Cardano, CLI) get a follow-up PR on top of this one. Known issues in those areas — the point-of-sale currency label, payment polling, and connector-specific flows — are tracked there.
  • Buy is never confirmed (PUT /buy/paymentInfos/:id/confirm is not called), so an open purchase does not appear under transactions.
  • KYC data steps have three server-side rejections left (financial questions, nationality field name, beneficial-owner field).
  • The transaction history is fetched without a date window; the SDK offers no pagination for that endpoint. Rendering is paged.
  • frame-ancestors cannot be delivered through a <meta> CSP and needs a response header at the edge.

Deploy behaviour

On merge to develop, DEV publishes dev.app.dfx.swiss/app2/. The production workflow publishes the same target to app.dfx.swiss/app2/, which is a conscious change from the previously DEV-only state. The root app is unaffected.

Ships the new standalone single-file DFX app under public/app2/, served
from the same $web static site at /app2/index.html on dev and prod.

- Coexists with the current React app; no changes to src/, build config,
  or the SPA-fallback routing. Entry is the explicit /app2/index.html.
- All asset paths are document-relative and the service worker is scoped
  to root only, so the app runs unchanged under the subpath.
- Adds an app2 upload step to dev.yml and prd.yml (mirrors the widget
  upload); the entry HTML is uploaded no-cache so redeploys are fresh.
- Fix DOM XSS (js/xss-through-dom): the image-fallback handler no longer
  assigns a DOM attribute value to img.src; the mono placeholder is rebuilt
  via mono(), whose encodeURIComponent neutralises any injected markup.
- Fix prototype pollution (js/prototype-polluting-assignment x2): the wallet
  switcher lookup uses a null-prototype object, so URL-seeded addresses such
  as __proto__ can no longer reach Object.prototype.
  Verified with CodeQL 2.25.6 (javascript-code-scanning): 0 alerts.
- Add SIL OFL 1.1 license for the self-hosted Inter fonts and a
  THIRD-PARTY-NOTICES.md for the vendored libraries.
- Deploy the preview on dev only (remove the app2 upload from prd.yml).
Model which network the CONNECTED wallet can actually settle on (Lightning-
aware) instead of treating every session chain as an interchangeable receive
target:
- Lightning is offered only for a Lightning identity (LNURL/LNNID) or a pure-
  Lightning session — never for an on-chain SegWit (bc1) address, which is
  paired with Lightning in the JWT but cannot receive on it. Fixes the BTC
  buy defaulting to / offering the Lightning network.
- Swap now validates the receive chain (chain2) too, prompting a wallet
  connect instead of letting the output land on an unreachable chain.
- Mode switches and defaults pick a wallet-reachable chain, not the first one.
- Asset-picker filter chips are derived from the reachable pool (no dead-end
  'no assets found' on single-chain wallets) with an 'All' chip and a helpful
  'not available with your wallet' hint; sell fee never mislabels a crypto
  amount with a fiat symbol; corrected the 'we flag the cheapest' copy and the
  scary pre-filled sell/swap amount.

CodeQL javascript-code-scanning: 0 alerts. Offline suite: 144 passed.
New DFX users auto-register on connect, but the backend blocks trading until a
referral is submitted (RecommendationRequired). Instead of linking out to
app.dfx.swiss, the confirm-sheet gate now:
- shows an 'Invitation needed' state explaining a referral is required,
- takes the invitation/referral code OR email inline and submits it to the KYC
  Recommendation step (PUT the step session URL with { key }),
- falls back to the full KYC flow when prerequisite steps come first,
- maps 404/400 to clear 'no matching customer' / 'invalid code' messages,
- tells the user the referral must still be confirmed by whoever invited them.
The KYC Recommendation step form gains the same lead + confirmation note.

CodeQL javascript-code-scanning: 0 alerts. Offline suite: 146 passed.
… account

- The drawer's OpenCryptoPay entry used a generic cube glyph; it now renders
  the actual OCP brand mark (buildMenu gains full-SVG icon support).
- Account 'Display currency' was hard-wired to EUR; it now reflects the
  selected fiat (updated in renderAccount and on currency change).

CodeQL: 0 alerts. Offline suite: 145 passed.
Replace the muddy squiggle glyph on the account Display-currency row with the
universal currency sign — a clean circle with four rays — that reads clearly
at icon size and matches the line-icon set.
…more blurry flag)

Replace the low-res flag png in the buy/sell/swap fiat pill and the currency
picker with a sharp SVG coin showing the currency symbol (EUR €, CHF Fr, USD $,
GBP £), matching the round crypto coins. Language picker keeps its flags.
Bring back the flags (EUR/CHF/USD/GBP) but render them as inline SVG instead of
<img src>: an img rasterises the SVG at CSS-px size and looks blurry on retina,
while inline SVG renders at device resolution and stays crisp. Each flag uses a
unique mask id so multiple inline flags don't collide on a shared '#a'. Currency
symbol coin remains the fallback for currencies without a bundled flag.
…n fallback

Harden shownChainsFor/supportedChainOf so a Taproot/on-chain BTC address never
sees Lightning as a network even when the session's blockchain list is empty or
unexpected (the previous fallback returned all of the asset's chains).
…nal-IBAN overclaim

The pay box hid the reference and claimed 'This is your personal IBAN' whenever
the API flagged isPersonalIban — misleading (most users have no personal IBAN)
and money-critical: if a shared/collection IBAN ever carried isPersonalIban the
required reference would be hidden and the transfer stranded. Now the reference
is shown whenever the API returns a remittanceInfo (the authoritative signal),
the label stays a neutral 'IBAN', and a no-reference IBAN just says 'no payment
reference needed' without claiming it's personal. Added a money-safety test.
…es=)

When the embedding wallet reports balances via the ?balances=amount@asset
contract (DFX's own primary balance source), the sell / swap-source picker now
lists ONLY the assets actually held, sorted by balance, with the amount shown
per row. Without balance data (e.g. a bare hardware-wallet connect, no contract
addresses available client-side) it falls back to all sellable assets.
The API's Swiss QR-bill SVG was inlined via innerHTML into a fixed 158x158
box that forced a non-square aspect (distorted modules) and was too small for
a dense Swiss QR to scan. Now it renders as a data-URI <img> at 220px with
object-fit:contain (never distorted), matching the production app; GiroCode
text still gets encoded locally. The QR content itself is API-provided.
…i18n keys

- The account Display-currency icon was a static € even when the selected fiat
  was CHF/USD; it now follows the currency (€/Fr/$/£), matching the value.
- Remove the now-unused yourIban / personalIbanNote strings left over from the
  remittanceInfo-driven IBAN rework.
…ache CSP breakage

On localhost the app no longer registers the SW and actively unregisters any
leftover worker + clears caches. A stale cached index.html served by an old SW
had a CSP hash that no longer matched the inline script, so the browser blocked
ALL app JS — connect-wallet and every button went dead. Root cause removed for
local dev; prod/root still gets the SW.
- The asset-picker 'not available with your wallet' message wrongly appeared
  when a category chip simply filtered out a reachable asset during search
  (e.g. Bitcoin chip + 'ETH'); it now shows plain 'no results' and only claims
  wallet-incompatibility when the asset is genuinely unreachable.
- Escape the currency symbol in fiatGlyph, matching curSymSvg.
…verflow the rounded corners

.panels had border-radius but no overflow:hidden, so the darker You-receive
panel's square background poked past the rounded bottom corners. Added
overflow:hidden (fab is centered, box-shadow is unaffected).
@Danswar

Danswar commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Review notes

Scope check: the diff outside public/app2/ is limited to dev.yml (prd.yml nets to zero — the app2 upload was added then removed in a later commit, confirmed no leftover references). Everything else is new assets plus one file, public/app2/index.html, which is the entire app (~3,600 lines) inside a single inline <script> tag.

Findings

  1. No test files in the diff. Several commits claim an "offline suite" (144/145/146 tests) and a dedicated "money-safety test" for the IBAN/remittanceInfo fix, but the PR contains zero test files. As it stands these claims aren't reproducible or CI-enforced.
  2. CSP script hash is hand-maintained. script-src pins the inline script via a literal sha256-... hash (verified it currently matches the script body byte-for-byte). Every future edit to that script requires manually regenerating this hash, or the browser silently blocks all app JS with no console error explaining why — this is the same failure class the "no service worker on localhost" commit had to patch around from the caching side. Worth a CI check that fails the build if the hash goes stale.
  3. Cache-header ordering clobbers the wasm rule. dev.yml uploads all *.wasm first with max-age=31536000, immutable, then the new app2/* upload-batch step (--overwrite) matches recursively and re-uploads assets/vendor/bitbox_api_bg.wasm with only max-age=3600. Minor, but the immutable caching intended for wasm assets doesn't apply under /app2/.
  4. A couple of hrefs built from API-returned URLs are HTML-escaped but not scheme-validated (e.g. the KYC identification link). esc() neutralizes markup but wouldn't stop a javascript: URL if the API ever returned one. Low risk given the source is first-party, but cheap to add an ^https?:\/\// check.

What looks solid: isolation from the existing app is real (verified no src/ changes, no leftover prd.yml references), the esc() helper is applied consistently across the innerHTML call sites, the prototype-pollution guard (null-prototype map for address lookups) is correctly used, and the Lightning chain-reachability / IBAN reference logic matches their commit descriptions on inspection.

One architectural question worth settling before this grows further: the app doesn't reuse any of the existing codebase's wallet-connect, chain-config, or KYC-flow logic — it's a from-scratch reimplementation, which is how it ends up re-solving (and re-introducing bugs in) problems the existing app already fixed. That's a reasonable tradeoff for a short-lived stakeholder-preview spike, but the PR description's own "ahead of any root cutover" framing suggests this might be heading toward production. Worth confirming the intended lifecycle now — disposable preview vs. seed of the real rewrite — since that changes how much investment (tests, shared logic, maintainability) makes sense here.

@Danswar

Danswar commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Architecture suggestion — keep the isolation, gain reusability

Following up on my review notes: the standalone approach makes sense for a fast stakeholder preview, but if there's any chance this graduates ("ahead of any root cutover"), the current shape — the full app hand-written in one inline <script>, with its own reimplementation of auth/session, buy/sell/swap, KYC, and payment routes — means everything gets thrown away or rewritten at exactly the moment it succeeds. There's a way to keep the same deploy isolation while making the work promotable, and this repo already contains all the pieces.

Two observations from the repo:

  1. @dfx.swiss/react already provides, headless (no UI), everything this app reimplements: auth, api-session, buy, sell, swap, asset, fiat, kyc, bank-account, payment-routes, support (tickets/chat), transaction, user, language. The API semantics this PR had to rediscover commit-by-commit (Lightning reachability, remittanceInfo/IBAN handling, KYC step flow) are already encoded and battle-tested behind those hooks.
  2. The repo already builds multiple artifacts from one codebase — the main app, the widget (index-widget.tsx + build-widget.shbuild/widget/, deployed by the same upload step this PR mirrors), and the npm lib (Main.lib.tsx@dfx.swiss/services-react). A fourth entry point is the established pattern here, not a new concept.

Proposal: build the new app as a fourth build target

  • src/app2/ (name TBD, see below) — the new UI written fresh in React + TypeScript. No imports from the existing app's screens//components/, so the visual/UX experiment stays clean-room.
  • All state/API/wallet logic from @dfx.swiss/react and the repo's existing wallet-connect hooks — no reimplementation of money-path semantics.
  • Entry point + build script cloned from the widget pattern (index-app2.tsx, scripts/build-app2.sh with BUILD_PATH=./app2), output landing in build/app2/the deploy step in this PR then works completely unchanged (same pattern allowlist, same explicit entry path, SPA fallback untouched).
  • Zero new dependencies. Every runtime dep the app needs is already in package.json (React, the DFX hooks, Ledger/Trezor/BitBox, WalletConnect, wagmi/viem, Solana/Tron, QR). Notably, the current PR is what adds duplicate dependencies: assets/vendor/wc-provider.js, ledger.js, bitbox_api_bg.wasm, and qrcode.js are frozen copies of libraries npm already manages — outside npm audit, lockfile, and version updates.

What this buys:

  • Same isolation: separate artifact, separate path, root app byte-for-byte untouched — identical to this PR's deploy story.
  • CSP hash problem disappears: bundled, content-hashed JS under script-src 'self' instead of a hand-maintained inline-script hash that silently bricks the app when it goes stale.
  • CI-enforced quality: the code falls under the repo's existing TypeScript, ESLint, Jest, and Playwright setup (extend the lint globs, point e2e specs at the subpath) — replacing the uncommitted "offline suite" with tests that run on every PR.
  • Mechanical promotion: if the experiment wins, cutover means pointing the root build at the new entry — components are already typed and wired to the production logic layer. If it loses, delete one folder.

An optional step further would be giving the new entry its own Vite config (two devDependencies) instead of a third instance of the index-swap build hack — also a low-stakes pilot for eventually leaving CRA/react-app-rewired, which is deprecated upstream. But the zero-new-tooling variant above already delivers the main value.

Naming: reconsider app2

Cheap to change now, expensive after preview links circulate — the string is about to be baked into the CDN path, the workflow pattern allowlist, and every bookmark stakeholders share for sign-off.

  • It names the app by sequence number rather than role, unlike the repo's existing artifacts (widget, lib). If the experiment dies, /app2 is a dangling number; if it wins, the folder is called app2 while being the app — either way the name is wrong at end-of-life.
  • "App 2.0" is also a branding/versioning claim that probably belongs to a product decision, not a folder name.

Suggestions: /next (truthful through the whole lifecycle — it's "the next app" while experimental, and the path simply retires at cutover; cf. react.dev/Reddit/YouTube shipping under next/new paths) or /preview (matches exactly what the PR description says it is). Happy to discuss.

@joshuakrueger-dfx

Copy link
Copy Markdown
Contributor Author

@Danswar Thanks for the review — both the findings and the architecture proposal were on point. Rather than argue, I built it: your fourth-build-target proposal is now implemented on feat/app2-react (diff vs develop).

What it is — exactly the shape you sketched:

  • src/app2/ — the App 2.0 UI rebuilt in React + TS, visually 1:1 with the preview (CSS ported verbatim, 650 i18n keys × 4 languages, landing/connect/trade/account/tx/KYC/support).
  • All state/API/money logic from @dfx.swiss/reactuseBuy/useSell/useSwap, useAuth/useApiSession, useKyc, useTransaction, useSupportChat. Zero hand-rolled fetches, zero reimplemented API semantics.
  • scripts/build-app2.sh + npm run app2[:dev|:loc], cloned from the widget pattern (BUILD_PATH, index swap). Hash routing, document-relative assets — the artifact runs unchanged under /app2/, deploy step stays as-is.
  • Zero new dependencies, zero vendored bundlesassets/vendor/* is gone; WalletConnect/Ledger/BitBox/QR all come from the existing lockfile.

Your findings, addressed by construction or directly:

  1. Tests — the code now falls under the repo's TS/ESLint/Jest/Playwright setup; porting the offline suite into CI is the immediate next step on this branch.
  2. CSP hash — no inline script anymore, so the hand-maintained hash ceases to exist.
  3. wasm cache ordering — moot once the static tree is replaced by this build (no vendored wasm under app2/).
  4. Scheme validation — every href built from API data goes through an https-check helper (KYC ident link included).

Login slice so far: MetaMask + WalletConnect v2 + email magic-link (signInWithMail), invite-code gate; the other wallet methods are rendered as "coming soon" until their connectors are ported. On-chain sell/swap signing and the full KYC data-entry wizards are the remaining gaps. An adversarial review round (money-path, auth, e2e smoke on both viewports) ran against the branch — findings like a WalletConnect cancel deadlock and a quote-refresh race on the payment sheet are already fixed in the second commit.

Open question where I'd like your take: keep shipping under /app2 for link-compat with this preview, or take the rename window now (/next or /preview) before stakeholder links spread?

@Danswar

Danswar commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Keep /app2 — the preview links already circulating outweigh any naming win, and a later rename is cheap via redirect if it ever matters. Your feat/app2-react commits are now on this PR (c2c8305..18b2c29, fast-forward).

@Danswar Danswar 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.

Review round 2 — React build target (3e398fc4, 18b2c291)

The architecture is what was proposed, and a lot of it verifies clean (credit list below). But the PR currently contradicts its own description on the most important point, and the money path has two flow-bricking bugs plus a cluster of inverted API semantics — several of them regressions of bugs the static preview's own commit history already fixed once. Requesting changes.

The structural problem: both apps ship, and only the old one deploys

  1. build-app2.sh outputs to app2-dist/ (gitignored) — no workflow builds or uploads it. dev.yml uploads ./build with --pattern "app2/*", which is CRA's verbatim copy of public/app2/ — the static preview. Through CI, /app2/ still serves the vanilla app; none of the React work reaches users.
  2. public/app2/ is fully intact at head (94 files, 4.9 MB, including all of assets/vendor/ — 3.3 MB). "assets/vendor/* is gone" doesn't hold: the PR contains zero deletions.
  3. Consequently round-1 findings 2 and 3 are not moot — the hand-maintained CSP hash and the wasm cache-order clobber (dev.yml:117 uploads *.wasm immutable, :135 re-uploads app2/assets/vendor/*.wasm at max-age=3600) are both still live in what deploys.
  4. The React artifact itself (built locally — CI never has): 8.2 MB, with the full 5.2 MB static tree embedded at app2-dist/app2/ (CRA copies public/ wholesale into BUILD_PATH), plus the main app's favicon.ico/logo.png/manifest.json. And it ships no CSP at all — the strict policy exists only in the static index.html. On the CSP axis the React artifact is currently a regression, not a fix.
  5. Round-1 finding 1 (tests) is still fully open: zero test files, npm run test is --passWithNoTests, and pr.yml doesn't run app2:dev — green CI proves typecheck and nothing more for ~10.7k new lines of money-path UI.
  6. The PR description still describes the static single-file drop-in — worth rewriting so approvals are based on what the PR now is.

Code findings

Details are inline. The two critical ones:

  • New users are bricked in all three trade modes: every gate that explains/unblocks (email verification, KYC/limits, min/max) renders only inside the PaymentSheet, which only opens on a valid quote — and a new user's every quote fails (EmailRequired/RecommendationRequired). Disabled CTA, "quote error", no way forward. (home.tsx:312)
  • Persisted WalletConnect sessions survive logout and pre-empt new pairings: silent auto-login as the previous wallet on shared machines, and a self-bricking hang after a phone-side disconnect, with no reachable code path that clears the stale session. (providers.ts:127)

High-severity, inline: fiat buyable/sellable filters inverted against the API convention (home.tsx:615); card/instant offered although the API rejects card unconditionally (PaymentMethodPicker.tsx:45); chain reachability wrong in both directions — blockchains[0] hides valid EVM chains (session.tsx:190) while the asset-pool fallback re-offers Lightning to non-LN wallets (asset-pool.ts:66); SEPA details missing the beneficiary (PaymentSheet.tsx:413); sell quotes fired without the mandatory IBAN and the 400 latched into the payment snapshot (useTradeQuote.ts:60); the cancel fix only covers the QR phase (session.tsx:238); injected addresses sent to /auth lowercase (providers.ts:102); Sumsub ident sessions dead-end silently (kyc.tsx:58); KYC data-entry steps loop with no portal link (kyc.tsx:366); failed support messages silently lost (support.tsx:255); PDF attachments can't be opened (support.tsx:507).

Not inline but worth fixing in the same pass: no accountsChanged/chainChanged handling anywhere in src/app2 (switching MetaMask accounts leaves the session — and sell instructions — on the old address; production re-verifies, src/hooks/wallets/metamask.hook.ts:111); no error boundary above the router (App.tsx — a provider throw is an unbranded error page in a money app); the Sheet background is neither inert nor focus-trapped, which is also what lets the "frozen" snapshot re-latch under an open sheet (home.tsx:287); ticket/thread load failures render as empty states ("No tickets yet") with unhandled rejections (support.tsx:167); transactions fetch and render the full history unbounded (transactions.tsx:132); the referral row copies the bare code while toasting "Referral link copied" (account.tsx:263); Drawer's Sell/Swap items route to / without setting the mode (Drawer.tsx:53) — decorative today.

Verified as claimed — credit where due

  • Zero hand-rolled fetches; zero new dependencies — both checked mechanically. All hook usage exists in the pinned lib version. (One version edge: err.code === 'TFA_REQUIRED' in kyc.tsx:145 needs ≥1.4.1 — fine for the locked build, silently dead when developing against a stale local install; worth a note.)
  • i18n claim verified: 659 keys × 4 languages, zero missing/mismatched, placeholder tokens consistent, all dynamic enum keys (kn_*, ks_*, is_*) match the lib exactly.
  • Round-1 finding 4 properly fixed: real URL parsing, rejects javascript:/data:/protocol-relative, applied at every API-derived href/window.open sink I could find — stricter than production, which iframes the ident URL unchecked. (Two identical copies of the helper exist — format.ts:89 and amount.ts:85 — merge them before they drift.)
  • No HTML-injection surface: no dangerouslySetInnerHTML/innerHTML in src/app2; the API-supplied QR-bill SVG is deliberately rendered via img data-URI; every target="_blank" carries noopener.
  • The quote engine's ordering discipline (monotonic seq + key-tied freshness) is sound; there is zero client-side money arithmetic on the payment path; copy buttons copy raw machine values; the magic-link flow returns to /app2/ (not the root app), scrubs credentials from the URL, and authMsgOk refusing to blind-sign an unrelated challenge is a protection most dapps skip.

What I'd gate merge on

  1. Resolve the two-apps state: wire app2:dev into pr.yml, deploy app2-dist in dev.yml, decouple build-app2.sh from public/app2, then delete the static tree.
  2. The two criticals + the fiat-flag inversion.
  3. A CSP for the React artifact (meta tag in the template or headers at the edge).
  4. A committed smoke-level test suite — the money-path fixes in this PR's own history are exactly the regressions tests should be pinning.
  5. Updated PR description.

Comment thread .github/workflows/dev.yml
Comment thread package.json
Comment thread scripts/build-app2.sh Outdated
Comment thread src/app2/screens/home.tsx
Comment thread src/app2/wallets/providers.ts
Comment thread src/app2/screens/kyc.tsx Outdated
Comment thread src/app2/screens/support.tsx Outdated
Comment thread src/app2/screens/support.tsx Outdated
Comment thread src/app2/wallets/providers.ts
Comment thread src/app2/screens/parts/Landing.tsx Outdated

Copy link
Copy Markdown
Contributor Author

@Danswar I addressed the complete round-2 review in commit 22dd0cc and updated the PR description to match the React build.

Highlights:

  • CI now builds and deploys the isolated app2-dist artifact in PR/DEV/PRD; the 94-file legacy public/app2 tree, vendored bundles, and WASM are removed. The generated React artifact has its own CSP/PWA identity and no nested/shared app payload.
  • Trade fixes cover actionable gate errors, corrected fiat semantics, Bank-only payments, mandatory-IBAN sell quotes, beneficiary details, current-key error handling, active-mode quote gating, full JWT chain reachability, and no unreachable-chain fallback.
  • Wallet fixes cover WC cleanup before pairing/on logout, cancellation through signing/auth, EIP-55 normalization, and injected account/chain invalidation.
  • KYC token/data steps now have safe portal handoffs; support tracks real delivery and retries failures, PDFs open with error handling; the remaining navigation, history, referral, error-boundary, Sheet focus/inert, and URL-helper findings are also addressed.
  • Added App2 regression coverage for the money-path semantics and invite normalization.

Local verification:

  • 29 test suites / 318 tests passed
  • lint passed
  • main DEV build, widget DEV build, App2 DEV build, and App2 production build passed
  • all three workflow YAML files and the generated CSP/artifact checks passed

I left the review threads open for your verification.

@Danswar Danswar 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.

@joshuakrueger-dfx

Review round 3 (22dd0cc)

Round-2 verification first: all 20 threads check out against the code — resolved. The structural fix is genuinely complete (CI builds and deploys app2-dist in all three workflows, static tree gone, CSP injected with fail-closed assertions), and the regression suite is real: every test imports the production modules, and this PR's own CI log shows PASS src/app2/__tests__/review-regressions.test.ts. Credit also for: i18n mechanically clean (660 keys x 4 languages, zero drift, all literal t() usages resolve), the clean-room import boundary holds (nothing escapes src/app2/ except the SDK and existing lockfile deps), the CSP checks out against the app's actual runtime traffic (API, WC relay/verify, blob attachments, self-hosted fonts), and build-app2.sh's EXIT trap correctly restores the entry swap on failure so a broken app2 build can't poison the widget build that runs after it.

This round: one finding I'd gate merge on, a band of mediums inline, and grouped smalls below.

Gate: the WalletConnect storage cleanup is a no-op after any page reload (providers.ts:228, details inline) — the round-2 fix only holds within a single page lifetime, and any reload re-opens the shared-machine path round 2 called critical.

Inline (mediums): URL-credential bootstrap dead-ends silently on RecommendationRequired; prod-hardcoded KYC/setup handoffs break the dev artifact; 2FA setup failure is an unrecoverable spinner; account-merge/switch responses loop; the sell sheet can auto-open unprompted; the buy rate renders a dangling slash; TranslationKey collapses to string; Drawer claims aria-modal without modal behavior; the postprocess identity strips are unasserted; support retry/timeout/empty-thread delivery gaps.

Smaller, fine as a fast-follow:

  • The swap rate is computed client-side (estimatedAmount / quote.amount, fee-inclusive) although the Swap response carries exchangeRate — the one breach of the zero-client-money-math claim (FeesPanel.tsx:86); the fees.bank ?? component-sum fallback on :83 is dead per the SDK type.
  • "1,000" parses as 1.0 (amount.ts:14-22) — the mismatch is visible in the receive field, but it yields a wrong-amount quote rather than a rejection.
  • AMOUNT_TOO_LOW/HIGH opens a gate titled "Setup required" with an external app.dfx.swiss CTA when the fix is editing the amount locally (PaymentSheet.tsx:315-338); the isCard branch at :364 is dead code now that Bank is the only method.
  • Transaction states render as raw enum strings in all four languages (transactions.tsx:273); the language switch never propagates to the API user (LanguageSheet.tsx:22-26), so server-driven mails/ident sessions stay in the account's old language.
  • CSP: https://pulse.walletconnect.org is missing from connect-src (telemetry fetch blocked, console noise on every WC connect); the single static CSP ships localhost:3000/dev-API entries in the prod artifact; and a meta-delivered CSP cannot carry frame-ancestors, so /app2 clickjacking protection needs a response header at the edge (infra, not this repo).
  • app2's content-hashed bundles upload at max-age=3600 instead of immutable, and app2 PNGs upload twice (86400 then 3600) — matches the widget precedent, perf nit only.
  • WC_OPTIONAL_CHAINS claims to mirror wagmi.config.ts but omits Citrea — harmless while app2 only does personal_sign; fix the list or the comment before the first on-chain feature.
  • No global 401-to-logout: an idle tab whose JWT expires stays visually logged in until the next data call. Shared gap with the production app, noting for parity, not as a regression.

One deploy-scope flag: this commit wires app2 into prd.yml for the first time — merging now publishes to app.dfx.swiss/app2/, where the previous state was dev-only. That matches the updated PR description; just making sure it's a conscious call.

Comment thread src/app2/wallets/providers.ts Outdated
Comment thread src/app2/wallets/session.tsx
Comment thread src/app2/screens/kyc.tsx Outdated
Comment thread src/app2/screens/kyc.tsx Outdated
Comment thread src/app2/screens/kyc.tsx Outdated
Comment thread src/app2/screens/trade/FeesPanel.tsx Outdated
Comment thread src/app2/i18n.tsx Outdated
Comment thread src/app2/components/Drawer.tsx
Comment thread scripts/postprocess-app2.js Outdated
Comment thread src/app2/screens/support.tsx
Comment thread scripts/postprocess-app2.js Fixed
… hardware BTC/EVM)

Wire up every wallet DFX supports so the connect sheet no longer shows
grey coming-soon entries: EVM browser wallets (EIP-6963), Solana/Tron via
dynamic-imported adapters, hardware BitBox/Ledger/Trezor (Bitcoin + EVM),
Alby (WebLN/LNURL), Cardano (CIP-30), and CLI manual paste-signing. Only
DFX Taro and Internet Computer remain coming-soon, matching the original.
Adds the scoped connect.trezor.io / sumsub CSP entries in postprocess.
Port the full OCP merchant experience from the original: home hub, apply
(PartnershipRequest), payment routes (activate/create), invoice generator
with real scannable LNURL QR (react-qr-code) + PNG download/print/sticker,
payment links, POS terminal with live payment polling, history, config,
and a demo mode. LNURL bech32 encoding is pure client-side; no new deps.
Bring the React rewrite to parity with the original static preview (the
pixel-perfect + functional source of truth): buy/sell/swap, asset/chain
picker, account, transactions, KYC steps, support/chat. Adds the limit
increase request screen and the external-return routes (/account-merge,
/buy/success cko-poll, /buy/failure), and wires the previously dead
OpenCryptoPay / payment-routes / limit menu items. Also makes the pay and
receive pills equal width and aligned within each half.
The buy panel drove its rate off the authenticated paymentInfos call, so any
account gate (400 EmailRequired, KYC, limit) blanked both the rate and the
calculation with no way to learn why. Buy and swap now run the panel on the
public quote endpoint — as sell already did and as the static preview did —
and only request payment details when the user moves to pay, where the gate
UI lives. Display and payment are separate engines, so a gate can no longer
take the rate down with it, and paymentInfos (which creates a transaction
request server-side) no longer fires on every keystroke.

Accepted trade-off, documented in useTradeQuote.ts: the public quote prices
against the default fee set and the generic limit, so partner-wallet and
individual fee agreements only appear in the payment response — which is what
the sheet renders before anyone pays. Sell keeps its previous behaviour.

Also in this change:
- the wallet bar showed the Cardano mark for every CLI wallet, because the
  Cardano catalog entry declares walletType CLI and precedes the CLI entry;
  icon lookup now resolves own identity before a shared walletType, and DFX
  Wallet gets the DFX mark
- fiat pills were shorter than asset pills (30px vs 38px glyph) in the trade
  panels and the picker rows
- an invalid quote no longer renders as "0" with a refresh countdown, and the
  payment sheet fails closed on an invalid quote instead of printing an IBAN
- the payment sheet's gate no longer shows a success tick and a "transfer the
  amount below" line with no amount below
- real Open CryptoPay mark in the drawer and the OCP hero badge
- display-currency row shows the currency's own symbol
- .btn-mini had no horizontal padding, so "Verbindung entfernen" touched both
  edges
Sell was left on the old wiring: because the default payout account is
preselected, its panel ran on the authenticated paymentInfos endpoint from the
first render — so an account gate could still blank the rate, and every
debounced keystroke created a sell route plus a transaction request
server-side (api › sell.service.createSellPaymentInfo). It now quotes through
the public endpoint like the other two modes and asks for payment details only
when the user moves to pay.

That also collapses the two armed intents into one: picking the payout account
the sell CTA asked for arms the same state the CTA arms, so the sheet opens on
that exact keyed response. The 20s watchdog, the retry re-arm and the
cancel-on-input-change now cover all three modes identically, and the sell CTA
no longer needs a payout account before it can show a rate.

Verified against dev.api.dfx.swiss with a temporary payout account on both
paths (account on file, and adding one from the CTA's picker): the panel keeps
its rate and the sheet shows the real deposit details.

Removes hasSellQuoteInputs: it was already unused, and its test asserted the
opposite of the shipped behaviour ("no sell quote until an IBAN is present").
Merge gate: WalletConnect sessions restored from IndexedDB after logout on a
shared browser (localStorage-only cleanup missed the layer the SDK actually
persists to once @walletconnect/keyvaluestorage migrates off it). Clears the
IndexedDB database too, with a bounded timeout so a blocked delete can never
hang the logout path.

Also addresses:
- accountsChanged/chainChanged listened on window.ethereum instead of the
  provider instance a session actually authenticated with, mixing up account
  changes between installed wallets.
- Instant SEPA was offered/rejected on the wrong currency flag and ignored
  the asset entirely; Card removed (the API hard-disables it unconditionally).
- Invite codes sent as the wrong API field (usedRef vs recommendationCode),
  no uppercase normalization, truncated input, and no prefill on the
  recommendation-gate retry.
- it/fr were missing two translation keys, and only the en dictionary was
  typed to catch that at compile time.
- transaction status rendered the raw state enum instead of a translated
  label.
- the swap exchange-rate line showed the reciprocal of the correct rate.
- --passWithNoTests removed now that a real suite exists.
- app2's content-hashed bundles (including the root-level wasm module) were
  losing their immutable cache header to a later, broader upload pass.
- KYC document upload sent a bare base64 payload instead of the full data
  URL the API expects, causing a 500.
- a failed quote refresh left its countdown timer running forever with no
  automatic or manual way to recover; added bounded backoff retries and a
  retry affordance in the UI.
- country/nationality trading restrictions were mapped to the "try a
  different asset" message instead of an honest account-level one.
- THIRD-PARTY-NOTICES.md misattributed the flag icons and didn't mention
  web3icons at all; both are now credited with their MIT license text
  shipped in the artifact.
…ssification

Merge gate: the IndexedDB cleanup from the previous round was ineffective in
the same tab that had just used WalletConnect. idb-keyval (the storage layer
@walletconnect/keyvaluestorage builds on) opens its database once and never
closes the connection or releases it on disconnect, so deleteDatabase() sat
on onblocked until the timeout gave up — leaving the old session data intact
rather than just being slow about removing it. Switched to clearing the
object store's contents via a plain, non-version-bumping open() + readwrite
transaction, which never needs exclusive access and so isn't blocked by that
still-open connection; the connect path no longer pays the timeout at all in
the common case.

Also addresses:
- the Alby hosted-account OAuth redirect sent the invite code unclassified,
  so a full recommendation code sent as usedRef 400'd server-side and (being
  a full-page redirect) dropped the user on a raw API error page.
- the quote panel's retry button could appear where refresh() was a no-op
  (inputs not yet resolved, or mid TTL-rollover) — gated on the engine's own
  errorIsCurrent instead.
- the recommendation-gate form could prefill a code shaped for the other
  field, guaranteeing its own resubmit would fail — only prefills a value
  that actually classifies as a recommendation code.
- strengthened the countdown-interval test to cover a success followed by a
  later failure, the scenario the fix is actually about (the previous test
  only exercised repeated failures, which never created an interval to
  begin with).
- Landing's invite placeholder and comment described a code shape neither
  API regex accepts; replaced with a real example and added an inline hint
  for a typed code that doesn't classify, instead of silently dropping it.
- a stray Italian typographic apostrophe and an overly technical German
  status label brought back in line with their sibling translations; the
  dead Card payment-method strings (removed with Card itself) deleted from
  all four dictionaries.
… break WalletConnect storage

The round-2 fallback path (databases() unavailable/throwing, database not yet
created) opened the WalletConnect IndexedDB without a version argument and,
on onupgradeneeded, only recorded that the database was new without creating
the keyvaluestorage object store. That left the database at version 1 with
no store at all. idb-keyval's own createStore() (what
@walletconnect/keyvaluestorage is built on) also always opens without a
version, and real IndexedDB never fires onupgradeneeded a second time for a
database once it exists at a given version — so the SDK's own store-creation
code would never run again, and every future WalletConnect connect attempt
in that browser profile would throw NotFoundError. Since disconnectWalletConnect()
runs before every connect attempt, this could trigger on the very first
WalletConnect click in a browser without a working databases() API.

Fixed by creating the keyvaluestorage store ourselves in onupgradeneeded,
matching idb-keyval's own schema exactly, so a database our cleanup has to
create is left in the same shape the SDK would have created it in.
…'s social identity

The App2 build artifact replaced <title> but never touched the inherited
twitter:*/alby:* meta tags from the shared public/index.html. Link-preview
unfurls (Slack/X/Telegram/Signal) read those tags, not <title>, so every
shared /app2 link rendered the old app's title, description, and screenshot.

Fixed by stripping the inherited social-preview tags and injecting App2's
own (no twitter:image, since there's no App2-specific screenshot — an absent
card beats a wrong one). twitter:site/creator keep the real @dfx_swiss
account, which is accurate for this product too. Both the title and
description replacements now throw instead of silently no-op'ing if the CRA
template no longer matches, and a final check fails the build if any of the
main app's exact title/description/image/alby values are still present
anywhere in the artifact.

Also closed the shared public/ strip's blind spot: it only ever removed a
hardcoded list of stale files, so a file added to public/ later would ride
along into the artifact unnoticed. The artifact root is now checked against
an explicit allow-list after every build step, failing the build on anything
unaccounted for instead of shipping it quietly.
…export, fee-math labeling

An abandoned WalletConnect teardown (cancelConnectAttempt's fire-and-forget
disconnectWalletConnect()) nulled the cached provider promise immediately but
kept awaiting the old provider's still-in-flight init(). If a fresh connect
attempt started in that window, it would establish a new session before the
stale teardown resumed and wiped it out again once the old init() finally
settled. Fixed with a generation counter: the final clear only runs if
nothing newer has taken over since.

Also addresses:
- a valid short partner ref code silently disappeared on the email login
  path (AuthMailDto has no field for it) with no feedback; now shown with
  its own hint distinct from "unrecognized code".
- the recommendation-gate field's placeholder and lead text still promised
  "or email" support that the sign-up endpoint this form actually submits to
  never had, on top of now enforcing a code-only length and uppercase.
- a leftover unused export in the postprocess helpers wired into an
  additional structural assertion (no twitter:/og:/alby: tag survives the
  removal step, independent of the value-based main-app-identity check).
- the fee panel's collapsed summary and expanded detail rows showed two
  different numbers for the same pair with neither labeled: the summary
  still hand-divided the settled amounts (fee-inclusive) for sell/swap while
  the detail row showed the pre-fee market rate. Both now derive from the
  API's own fields (rate vs exchangeRate) and are labeled accordingly in all
  four languages.
…f a fixed list

The artifact-root allow-list added in the previous commit did its job on the
first build against a moved base: develop now ships public/_headers and
public/_redirects for the Cloudflare Pages deploy, CRA copies public/ wholesale,
and both landed in the App2 artifact root. A hand-maintained denylist means the
next file the main app adds ships silently again, so the list is now read from
public/ at build time — index.html excepted, since CRA rewrote that one into
App2's own document.

Verified by staging both files locally and building: the artifact root comes out
with App2 entries only.
@joshuakrueger-dfx

Copy link
Copy Markdown
Contributor Author

@Danswar — ready for another look. All 32 inline threads from rounds 2 and 3 are addressed and answered individually; the PR description is rewritten to match what this actually is now.

Your round-3 merge gate needed three attempts, and the first two were wrong. The cleanup cleared localStorage, but @walletconnect/keyvaluestorage migrates into IndexedDB and never writes localStorage again — so the reload path stayed open, and the regression test only proved its own mock because it seeded the wc@2: keys itself. Deleting the database then looked right but silently timed out on onblocked: idb-keyval opens its connection lazily and never closes it, so a same-tab logout never completed. The store is now emptied in place, the versionless open() creates the store idb-keyval expects (otherwise the database is left at v1 without it and every later connect fails with NotFoundError), and an aborted teardown can no longer wipe a newer session's state.

Also fixed beyond the threads:

  • The trade panels quote through the public {buy,sell,swap}/quote endpoints; the authenticated paymentInfos call now runs only when the user moves to pay. Before, any account gate blanked the rate instead of surfacing in the sheet, and paymentInfos — which creates a route and a transaction request server-side — fired on every debounced keystroke, including for sell as soon as a default payout account existed.
  • An invalid quote no longer renders as 0 with a live countdown, and the sheet fails closed on isValid: false instead of printing an IBAN.
  • The artifact no longer inherits the main app's Twitter/OG identity, so an /app2 link stopped unfurling as the production app.
  • The artifact root is validated against an allow-list. It earned its keep on the first build against a moved base: it caught public/_headers and public/_redirects from the Cloudflare Pages deploy on develop, which CRA had copied into the artifact. The strip list is now derived from public/ instead of hand-maintained.

Test suite went from 29 suites / 318 tests to 40 / 382. Each new test was verified red against the state it fixes, and the two you called out as weak — the WalletConnect mock and the vacuous paymentMethodsFor fixture — are replaced.

Known gaps, deliberately left open and listed in the description: OpenCryptoPay and the hardware/chain connectors move to a follow-up PR on top of this one. Buy is never confirmed via PUT /buy/paymentInfos/:id/confirm, so an open purchase does not show under transactions; three KYC data steps still hit server-side rejections; the transaction history is fetched without a date window. Those are real, they are not fixed here, and I would rather have them written down than discovered.

One note on your round-3 deploy flag: prd.yml still publishes to app.dfx.swiss/app2/. That remains a conscious call.

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.

3 participants