Skip to content

fix(walletkit): don't show a dApp request again after the app closed on it - #1062

Draft
CassioMG wants to merge 2 commits into
cg-bound-dapp-contentfrom
cg-dapp-request-replay-breaker
Draft

CassioMG wants to merge 2 commits into
cg-bound-dapp-contentfrom
cg-dapp-request-replay-breaker

Conversation

@CassioMG

@CassioMG CassioMG commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

Stacked on #1061. If the app closes while a dApp request is on screen (a crash, the system ending it for memory, or the user force-quitting a frozen app), the next start no longer brings that request back.

Before a request's sheet opens (or the Blockaid sheet shown in front of it), the wallet saves which request it is, and forgets it once the request is answered or dismissed. If it's still saved at the next start, the app closed while showing it: before handling any request, the wallet rejects every pending request from that dApp and shows a toast naming it. The dApp can send the request again.

There's no crash detection: the saved request is the whole signal, so a force-quit with a request open also rejects it. Connection proposals aren't covered, since one that fails is delivered only once.

Lines changed

Kind Files Lines added Lines removed
Source code 3 250 7
Tests 2 413 5
Other (translations) 2 6 0
Total 7 669 12

Only this PR's own changes, not those of the PRs it's stacked on.

Screenshots and recordings (iPhone SE simulator on iOS 27.2, Pixel 10 Pro emulator)

A local test dApp sends a sign-message request; with its sheet open, the app is force-quit and reopened.

Step iOS Android
The request's sheet is open when the app is force-quit
On reopening: no sheet, a toast, and the dApp receives "Varial closed while this request was open"
Recording (screenshot time-lapse; the dev build loads from Metro on reopening)
Implementation details (for agents)

What changed:

  • src/helpers/dappRequestReplayBreaker.ts (new):

    • markDappRequestOnScreen({ id, topic, dappName }) saves the request in dataStorage under STORAGE_KEYS.DAPP_REQUEST_ON_SCREEN.
    • clearDappRequestOnScreen(id) removes it, unless a later request has replaced it.
    • rejectRequestsLeftOnScreen(message) reads and removes the saved request, then rejects every walletKit.getPendingSessionRequests() entry with its session topic (code 5000). It removes the record first, so a failure can't repeat on every start, and a failed rejection is logged without stopping the others. It returns the dApp name and the ids it rejected, or null when nothing was saved or nothing from that dApp was pending.

    export const markDappRequestOnScreen = (
    request: DappRequestOnScreen,
    ): Promise<void> =>
    dataStorage.setItem(
    STORAGE_KEYS.DAPP_REQUEST_ON_SCREEN,
    JSON.stringify(request),
    );
    /**
    * Removes the marker once request `id` is answered or dismissed. A marker
    * saved since for another request is kept.
    */
    export const clearDappRequestOnScreen = async (id: number): Promise<void> => {
    const marker = await readMarker();
    if (marker && marker.id !== id) {
    return;
    }
    await dataStorage.remove(STORAGE_KEYS.DAPP_REQUEST_ON_SCREEN);
    };
    /**
    * Run at start, before any request is handled. If a request was still on
    * screen when the app last closed, rejects every pending request from that
    * dApp's session (with `message`), so none of them is shown again, and
    * removes the marker first, so a failure here can't repeat on every start.
    *
    * @returns The dApp's name and the ids rejected, or null when nothing was
    * left on screen or nothing from that dApp was still pending.
    */
    export const rejectRequestsLeftOnScreen = async (
    message: string,
    ): Promise<{ dappName: string; rejectedIds: number[] } | null> => {
    const marker = await readMarker();
    await dataStorage.remove(STORAGE_KEYS.DAPP_REQUEST_ON_SCREEN);
    if (!marker) {
    return null;
    }
    const pendingRequests = walletKit
    .getPendingSessionRequests()
    .filter((request) => request.topic === marker.topic);
    if (pendingRequests.length === 0) {
    return null;
    }
    await Promise.all(
    pendingRequests.map(({ id, topic }) =>
    walletKit
    .respondSessionRequest({
    topic,
    response: { id, jsonrpc: "2.0", error: { code: 5000, message } },
    })
    .catch((error: unknown) => {
    logger.warn(
    "dappRequestReplayBreaker",
    "Failed to reject a request left on screen",
    error,
    );
    }),
    ),
    );
    return {
    dappName: marker.dappName,
    rejectedIds: pendingRequests.map(({ id }) => id),
    };
    };

  • src/providers/WalletKitProvider.tsx:

    • presentRequestSheet saves the request, waits for the write, then presents the sheet. A failed write is logged and the sheet shows anyway. The four present() calls in processSessionRequest go through it.
    • The teardown, handleClearDappRequest, forgets the request. It runs when a request is answered, cancelled or dismissed.
    • A start effect runs rejectRequestsLeftOnScreen once WalletKit is initialized, logs a warning with the count, and shows the toast. The dispatch effect handles no request until that check is done, and drops any request it rejected. WalletKit replays pending requests about a second after it starts, so a replay can already be waiting.

    /**
    * Shows a request's first sheet (the request sheet, or the Blockaid sheet
    * in front of it) once the request is saved as on screen. If showing it
    * crashes or freezes the app, the next start finds the request saved and
    * rejects it instead of showing it again. A failed save is logged, and the
    * sheet shows anyway.
    */
    const presentRequestSheet = (
    sessionRequest: WalletKitSessionRequest,
    dappName: string,
    sheetRef: React.RefObject<BottomSheetModal | null>,
    ) => {
    markDappRequestOnScreen({
    id: sessionRequest.id,
    topic: sessionRequest.topic,
    dappName,
    })
    .catch((error: unknown) => {
    logger.error(
    "WalletKitProvider",
    "Failed to save the dApp request on screen",
    error,
    );
    })
    .finally(() => {
    sheetRef.current?.present();
    });

    siteSecurityWarningBottomSheetModalRef.current?.dismiss();
    if (requestEvent) {
    clearDappRequestOnScreen(requestEvent.id).catch((error: unknown) => {
    logger.error(
    "WalletKitProvider",
    "Failed to clear the dApp request on screen",
    error,
    );
    });

    /**
    * Start check: if the app closed while a dApp request was on screen (a
    * crash, a kill for memory, or a freeze the user ended), rejects that dApp's
    * pending requests so none of them shows again, and says so. WalletKit
    * replays pending requests as soon as it starts, so requests wait for this.
    */
    useEffect(() => {
    if (!initialized) {
    return;
    }
    rejectRequestsLeftOnScreen(t("walletKit.requestRejectedAtStartReason"))
    .then((result) => {
    if (!result) {
    return;
    }
    result.rejectedIds.forEach((id) =>
    requestsRejectedAtStartRef.current.add(id),
    );
    logger.warn(
    "WalletKitProvider",
    "Rejected the requests of a dApp whose request was on screen when the app closed",
    { count: result.rejectedIds.length },
    );
    showToast({
    title: t("walletKit.requestRejectedAtStartTitle"),
    message: t("walletKit.requestRejectedAtStart", {
    dappName: result.dappName || "dApp",
    }),
    variant: "error",
    });
    })
    .catch((error: unknown) => {
    logger.error(
    "WalletKitProvider",
    "Failed to check for a dApp request left on screen",
    error,
    );
    })
    .finally(() => {
    setHasCheckedRequestsLeftOnScreen(true);
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [initialized]);
    /**
    * Effect that dispatches WalletKit events to their respective handlers.
    */
    useEffect(() => {
    if (event.type === WalletKitEventTypes.SESSION_PROPOSAL) {
    handleSessionProposal(event as WalletKitSessionProposal);
    }
    if (
    event.type === WalletKitEventTypes.SESSION_REQUEST &&
    hasCheckedRequestsLeftOnScreen
    ) {
    const sessionRequest = event as WalletKitSessionRequest;
    // Rejected by the start check: a replay WalletKit had already queued.
    if (requestsRejectedAtStartRef.current.has(sessionRequest.id)) {
    clearEvent();
    return;
    }
    handleSessionRequest(sessionRequest);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [
    activeSessions,
    event.type,
    authStatus,
    transactionMemo,
    overriddenBlockaidResponse,
    hasCheckedRequestsLeftOnScreen,
    ]);

  • src/config/constants.ts: STORAGE_KEYS.DAPP_REQUEST_ON_SCREEN. It's cleared with the other non-sensitive keys when the wallet is wiped.

  • i18n (en, pt): walletKit.requestRejectedAtStartTitle, walletKit.requestRejectedAtStart and walletKit.requestRejectedAtStartReason (what the dApp receives).

Verification:

  • yarn test: 354 suites pass, 5,307 tests pass and 6 are skipped. New tests cover the helper (save, clear only its own request, reject only the dApp's pending requests, a corrupt record, a failed rejection) and the provider: the save happens before the sheet or the Blockaid sheet is presented, a failed save still shows the request and is logged, answering or cancelling forgets it, the start check rejects and toasts before any request is handled, a replay already waiting is dropped, and requests wait for the check.
  • yarn lint:ts passes, and ESLint and Prettier pass on every changed file.
  • Dev builds on an iPhone SE (3rd generation) iOS 27.2 simulator and a Pixel 10 Pro emulator, driven by a local WalletConnect test client (screenshots above): with a sign-message request's sheet open, the app is force-quit and reopened. No sheet comes back, the toast names the dApp, and the dApp receives "Varial closed while this request was open". Before that, the request itself opened as usual with the save in front of it.

Follow-ups / out of scope:

  • A request's sheet that the system ends while the app is in the background (the user switched away and the OS reclaimed memory) also counts, since there's no telling it apart from a crash. The dApp gets a rejection instead of the request coming back.
  • If a start rejection can't be sent (for example, the device is offline at start), the request is still dropped and the toast still says it was rejected. WalletConnect then holds it until it expires (5 minutes by default), and later requests wait behind it. Rare (a crash followed by an offline start) and bounded, so left as is.

🤖 Generated with Claude Code

CassioMG and others added 2 commits November 11, 2026 20:15
Adds a small helper that saves the dApp request whose sheet is about to
show, clears it once that request is answered or dismissed, and, at the
next start, rejects the pending requests of a dApp whose request was still
on screen when the app closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on one

Saves the dApp request whose sheet is about to show, and waits for the
save before showing it. If the app closes while that request is on
screen (a crash, the system ending it for memory, or a force-quit), the
next start finds it: before handling any request, the wallet rejects
every pending request from that dApp, drops any replay already waiting,
and shows a toast naming the dApp, instead of showing the request again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Android Emulator preview APK is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-684c2bcf5d39e7c948e5
Backend: sandbox (CassioMG). SDF collaborators only — install instructions in the release description.

@github-actions

Copy link
Copy Markdown
Contributor

iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-323f70b53b058b66ad69
Backend: sandbox (CassioMG). SDF collaborators only — install instructions in the release description.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant