Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 22 additions & 30 deletions apps/fxblox-web/src/components/setup/WalletSigner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,15 @@
* ## And the wallet can still hang, for a reason that is not ours
*
* A later report: MetaMask on Android wedges on its splash screen on the first hop after connecting, every
* time, and recovers only once it is force-quit and reopened. Asked to retry WITHOUT force-quitting — the same
* URL, from a real tap, so user activation cannot be the difference — the user reports it hangs just the same.
* A cold wallet works; a warm one does not, whatever this page does. So the fault is inside the wallet, and the
* only levers here are (a) not repeating a hop that has already been shown not to work — see `openWallet` — and
* (b) telling the user what happened the moment they come back, rather than on a timer that runs out while they
* are still staring at the wallet.
* time, and recovers only once it is force-quit and reopened. Two things were then tried on the phone. A retry
* from a real tap, same URL — hangs. A retry sending the bare `metamask://` instead of the request link, so the
* wallet is asked only to come to the front — hangs too. A cold wallet works; a warm one does not, whatever
* this page sends it.
*
* So the wallet deadlocks on being resumed by a deep link, and there is no URL that avoids it. Nothing here can
* fix that. What is left is to be quick and honest about it: tell the user the moment they come back, rather
* than on a timer that runs out while they are still staring at the wallet, and name the one step that
* actually works instead of offering a retry that does not.
*
* `signChainCode()` stays byte-identical to mobile: the signature seeds the DID secret key, so a changed byte
* means web and mobile derive different identities from the same password and wallet.
Expand Down Expand Up @@ -122,12 +125,10 @@ export default function WalletSigner({
const [phase, setPhase] = useState<SignerPhase>('idle');
const [showNudge, setShowNudge] = useState(false);
const [showStuckHint, setShowStuckHint] = useState(false);
// Have we sent them into the wallet for THIS request, and did they come back with it still unanswered?
// Mirrored into refs for the stable tap handler below.
// Have we sent them into the wallet for THIS request (a ref: the stable tap handler below writes it), and
// did they come back with it still unanswered?
const wentToWalletRef = useRef(false);
const [walletShowedNothing, setWalletShowedNothing] = useState(false);
const walletShowedNothingRef = useRef(false);
walletShowedNothingRef.current = walletShowedNothing;
// Set once the request is on the relay: the deep link that opens the wallet ON this request, rather than on
// its home screen. Mirrored into a ref for the stable tap handler below.
const [requestLink, setRequestLink] = useState<string | null>(null);
Expand Down Expand Up @@ -311,31 +312,22 @@ export default function WalletSigner({
* Scheme-aware — see `hopToWallet` in walletRedirect.ts for why an https universal link must not go through
* `assign`.
*
* ## Why a second attempt drops the request id
*
* `…/wc?requestId=` is the right link the first time: it is what makes the wallet surface THIS prompt rather
* than its home screen. But once the user has been there and come back empty-handed, sending the same link
* again is sending them back into the thing that just failed.
* ## What this cannot do, and does not pretend to
*
* The field report is MetaMask on Android sitting on its splash screen, every time, on the first hop after
* the connect — and recovering only after the wallet is force-quit and reopened. A cold wallet works; a warm
* one does not. The likeliest reading is that `…/wc?requestId=` puts MetaMask into the route that WAITS for
* that request to arrive over its OWN relay socket — the socket Android suspended while it sat in the
* background — and that wait is the full-screen "splash" the user is stuck on. Nothing on a web page can
* reach into another app and reconnect its socket.
* A previous version dropped the request id on a second attempt, on the theory that `…/wc?requestId=` was
* what wedged MetaMask — that it put the wallet into a route waiting for a request to arrive over its own
* Android-suspended socket, and that a bare `metamask://` would merely resume the app instead.
*
* What a page CAN do is stop asking for the part that hangs. The bare scheme requests nothing but the app
* itself, so a wallet that did receive the request shows it (a session request is modal wherever you are),
* and a wallet that did not at least comes up usable instead of wedged. It is strictly a fallback: this only
* runs after the request-scoped link has already been tried and produced nothing.
* Tested on the reporter's phone, that is wrong. A warm MetaMask sits on its splash screen for the bare
* scheme exactly as it does for the request link. The wallet deadlocks on being resumed by a deep link at
* all, and no URL a web page can produce avoids it; only force-quitting and reopening clears it.
*
* `walletStuckHint` has to name this one FIRST, before the force-quit. A user who kills the wallet on the
* way past never reaches this branch — a cold wallet handles `…/wc?requestId=` perfectly well — so a hint
* that led with "close it from your recent apps" would route every user around the cheaper fix.
* So the link never changes. Always the request-scoped one, which is the correct link for the case that does
* work — a cold wallet, where it surfaces THIS prompt rather than the home screen. The recovery is a thing
* the user has to do, and `walletStuckHint` says so plainly rather than offering a retry that will not help.
*/
const openWallet = useCallback(() => {
const bare = connectedWalletLink(latest.current.wallet.provider);
const link = (walletShowedNothingRef.current ? bare : null) ?? requestLinkRef.current ?? bare;
const link = requestLinkRef.current ?? connectedWalletLink(latest.current.wallet.provider);
if (!link) return;
console.log('[sign] opening the wallet by hand:', link);
wentToWalletRef.current = true;
Expand Down
2 changes: 1 addition & 1 deletion apps/fxblox-web/src/i18n/locales/en/setup.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"signaturePortalHint": "Sign at fxblox.fx.land, then paste the signature below together with the wallet address you signed with.",
"walletConnectedTapSign": "Wallet connected. Tap below to approve the signature — this opens your wallet again.",
"approveInWallet": "Approve the request in your wallet…",
"walletStuckHint": "Wallet opened but stuck on its splash screen? Tap Open wallet to approve again first — the second try opens it a different way. If it is still stuck, close the wallet completely from your recent apps and tap once more. Your request is still waiting either way.",
"walletStuckHint": "Wallet opened but stuck on its splash screen? Close it completely from your recent apps, then tap Open wallet to approve again. That is the only thing that clears it — tapping again without closing the wallet first will not. Your request stays valid, so nothing is lost.",
"openWalletToApprove": "Open wallet to approve"
},
"connectToBlox": {
Expand Down
2 changes: 1 addition & 1 deletion apps/fxblox-web/src/i18n/locales/zh/setup.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"signaturePortalHint": "请在 fxblox.fx.land 完成签名,然后将签名与用于签名的钱包地址一起粘贴到下方。",
"walletConnectedTapSign": "钱包已连接。点击下方按钮进行签名 — 这会再次打开您的钱包。",
"approveInWallet": "请在您的钱包中确认此请求…",
"walletStuckHint": "钱包已打开但停在启动画面?请先再次点击“打开钱包进行签名” — 第二次会以另一种方式打开钱包。如果仍然卡住,请从最近任务中彻底关闭钱包,然后再点击一次。无论哪种情况,您的签名请求都仍在等待。",
"walletStuckHint": "钱包已打开但停在启动画面?请从最近任务中彻底关闭钱包,然后再次点击“打开钱包进行签名”。只有这样才能解决不先关闭钱包而直接再次点击是没有用的。您的签名请求仍然有效,不会丢失。",
"openWalletToApprove": "打开钱包进行签名"
},
"connectToBlox": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,16 +389,17 @@ describe('LinkPassword', () => {
expect(linking.assign).not.toHaveBeenCalled();
});

it('wallet path: coming back with the request unanswered says so at once, and drops the request id', async () => {
it('wallet path: coming back with the request unanswered says so at once, and keeps the request id', async () => {
// The field report: MetaMask on Android wedges on its splash screen on the first hop after connecting,
// every time, and only a force-quit clears it. Retried from a real tap, same URL, it hangs the same way —
// so the fault is inside the wallet, not in how the page navigates.
// every time, and only a force-quit clears it. Retried from a real tap it hangs the same way, and so does
// a retry that sends the bare scheme instead of the request link — so the wallet deadlocks on being
// resumed by a deep link at all, and no URL this page can produce avoids it.
//
// Two things follow, and this covers both. Returning to this page with the request still out is proof the
// wallet showed nothing, so the recovery hint belongs on screen NOW rather than when a 12s timer that ran
// while the user was inside the wallet finally expires. And the next tap must not repeat the link that
// just failed: `…/wc?requestId=` is what puts the wallet into the route that waits for a request it never
// received, so the retry asks only for the app.
// What is left is speed and honesty. Returning to this page with the request still out is proof the wallet
// showed nothing, so the recovery hint belongs on screen NOW rather than when a 12s timer that ran while
// the user was inside the wallet finally expires. The link itself does not change: the request-scoped one
// is correct for the case that does work — a cold wallet, where it surfaces this prompt rather than the
// home screen.
const user = userEvent.setup();
vi.mocked(linking.assign).mockClear();
const listeners = new Set<(payload: unknown) => void>();
Expand Down Expand Up @@ -440,7 +441,7 @@ describe('LinkPassword', () => {

vi.mocked(linking.assign).mockClear();
await user.click(screen.getByTestId('open-wallet'));
expect(linking.assign).toHaveBeenCalledWith('metamask://');
expect(linking.assign).toHaveBeenCalledWith('metamask://wc?requestId=42&sessionTopic=topic-1');

// The request was never abandoned: a signature approved after all this still lands.
await act(async () => {
Expand Down
Loading