Skip to content

fix(walletkit): harden handling of dApp metadata, sessions and pairing links - #1060

Draft
CassioMG wants to merge 10 commits into
mainfrom
cg-harden-dapp-input
Draft

CassioMG wants to merge 10 commits into
mainfrom
cg-harden-dapp-input

Conversation

@CassioMG

@CassioMG CassioMG commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

dApp metadata, stored WalletConnect sessions and pairing links all come from outside the app, and a few places read them assuming a well-formed shape. This PR makes each of those places handle whatever it gets:

  • dApp names, URLs and icons are normalized once, before anything displays them.
  • Stored sessions without Stellar accounts are skipped and disconnected.
  • A pairing link that doesn't decode shows the pairing error.
  • signXDR and signAndSubmitXDR reject a request whose xdr isn't a string, as the other two methods already do.

If handling a proposal or request still throws, the proposal or request is rejected and its sheet closes, instead of the error escaping.

The connection toasts also say "dApp" instead of "App", as the Discover screen does.

Lines changed

Kind Files Lines added Lines removed
Source code 10 397 82
Tests 9 835 1
Other (translations) 2 10 6
Total 21 1,242 89

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

Check iOS Android
A pairing link whose uri isn't valid percent-encoding, with the app running
The same link from a cold start
A connection request with a non-string name and no icons
stellar_signXDR with a non-string xdr
A stored session without Stellar accounts, then a relaunch
Implementation details (for agents)

What changed:

  • src/helpers/dappMetadata.ts (new). sanitizeDappMetadata returns name, description, url and icons with fixed types and lengths:

    • name: up to 100 characters. A missing, blank or non-string name falls back to the url's host, capped the same way.
    • description: up to 500 characters.
    • url: up to 2048 characters. A longer one is dropped, not cut.
    • icons: up to 5 string URLs of up to 2048 characters each.

    toConnectedDapp builds a Connected Apps row from a stored session.

    export const sanitizeDappMetadata = (metadata: unknown): CoreTypes.Metadata => {
    if (!isRecord(metadata)) {
    return { name: "", description: "", url: "", icons: [] };
    }
    const { name, description, url, icons } = metadata;
    const cleanUrl =
    typeof url === "string" && url.length <= DAPP_METADATA_LIMITS.URL
    ? url
    : "";
    const cappedName =
    typeof name === "string" ? name.slice(0, DAPP_METADATA_LIMITS.NAME) : "";
    return {
    // A host can be far longer than a name, so the fallback is cut as well.
    name:
    cappedName.trim() !== ""
    ? cappedName
    : (getDisplayHost(cleanUrl) ?? "").slice(0, DAPP_METADATA_LIMITS.NAME),
    description:
    typeof description === "string"
    ? description.slice(0, DAPP_METADATA_LIMITS.DESCRIPTION)
    : "",
    url: cleanUrl,
    icons: Array.isArray(icons)
    ? icons
    .filter(
    (icon): icon is string =>
    typeof icon === "string" &&
    icon.length <= DAPP_METADATA_LIMITS.ICON_URL,
    )
    .slice(0, DAPP_METADATA_LIMITS.ICONS)
    : [],
    };
    };
    /**
    * Builds the Connected Apps row for a stored session from its normalized
    * metadata. A dApp whose url matches a listed protocol shows that protocol's
    * name and icon.
    *
    * @param session - A stored WalletConnect session
    * @param protocols - The listed protocols
    * @returns The row's topic, name and icon
    */
    export const toConnectedDapp = (
    session: SessionTypes.Struct,
    protocols: DiscoverProtocol[],
    ): ConnectedDapp => {
    const metadata = sanitizeDappMetadata(session.peer?.metadata);
    const matchedProtocol = findMatchedProtocol({
    protocols,
    searchUrl: metadata.url,
    });
    return {
    topic: session.topic,
    name: matchedProtocol?.name ?? metadata.name,
    favicon: matchedProtocol?.iconUrl ?? metadata.icons[0],
    };
    };

  • src/hooks/useDappMetadata.ts: getDappMetadataFromEvent, which feeds the connection sheet and the request headers, returns normalized metadata. ConnectedApps.tsx builds its rows with toConnectedDapp, and AppInitials accepts a non-string name.

  • src/helpers/walletKitUtil.ts:

    • partitionAccountSessions splits stored sessions into the active account's sessions and those whose namespaces.stellar.accounts isn't an array of strings.
    • getActiveSessions leaves the second group out and disconnects them.
    • approveSessionProposal's toasts use the normalized name.

    export const partitionAccountSessions = (
    allSessions: ActiveSessions,
    activeAccount: string,
    ): { accountSessions: ActiveSessions; malformedTopics: string[] } => {
    const accountSessions: ActiveSessions = {};
    const malformedTopics: string[] = [];
    Object.entries(allSessions).forEach(([topic, session]) => {
    const accounts: unknown = session?.namespaces?.stellar?.accounts;
    if (
    !Array.isArray(accounts) ||
    !accounts.every((account) => typeof account === "string")
    ) {
    malformedTopics.push(topic);
    return;
    }
    if (accounts.includes(activeAccount)) {
    accountSessions[topic] = session;
    }
    });
    return { accountSessions, malformedTopics };
    };
    /**
    * Retrieves all active Wallet Connect sessions for a given public key and network.
    * Stored sessions without Stellar accounts are left out and disconnected.
    * @param {string} publicKey - The public key of the account to get sessions for
    * @param {NETWORKS} network - The network to get sessions for
    * @returns {ActiveSessions} The active account's sessions
    */
    export const getActiveSessions = (
    publicKey: string,
    network: NETWORKS,
    ): ActiveSessions => {
    const allActiveSessions = walletKit.getActiveSessions() as ActiveSessions;
    const activeChain =
    network === NETWORKS.PUBLIC
    ? StellarRpcChains.PUBLIC
    : StellarRpcChains.TESTNET;
    const activeAccount = `${activeChain}:${publicKey}`;
    const { accountSessions, malformedTopics } = partitionAccountSessions(
    allActiveSessions,
    activeAccount,
    );
    if (malformedTopics.length > 0) {
    logger.warn(
    "getActiveSessions",
    "Skipped sessions without Stellar accounts",
    { count: malformedTopics.length },
    );
    // disconnectSession catches and logs its own failures.
    malformedTopics.forEach((topic) => {
    disconnectSession(topic);
    });
    }
    return accountSessions;
    };

  • src/ducks/walletKit.ts: if reading the sessions fails, fetchActiveSessions logs it and keeps the last list.

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

    • parsePairingLink returns ignored, invalid, or valid with the decoded URI.
    • It decodes inside a try, because decodeURIComponent throws on malformed percent-encoding, and so does React Native's URLSearchParams.
    • onDeepLink uses it and shows the pairing error for invalid, and getInitialURL() gets a .catch.

    export const parsePairingLink = (
    url: string | null | undefined,
    redirectLink: string,
    ): PairingLinkResult => {
    if (!url || !url.includes(redirectLink)) {
    return { status: "ignored" };
    }
    try {
    const uriParam = new URL(url).search.split("uri=")[1];
    if (!uriParam) {
    return { status: "ignored" };
    }
    return { status: "valid", uri: decodeURIComponent(uriParam) };
    } catch {
    return { status: "invalid" };
    }
    };

    const onDeepLink = useCallback(
    (event: { url: string | null }): void => {
    const link = parsePairingLink(event.url, WALLET_KIT_MT_REDIRECT_NATIVE);
    // Not a pairing link, or a bare one that only brings the wallet forward
    if (link.status === "ignored") {
    return;
    }
    if (link.status === "invalid") {
    logger.warn("onDeepLink", "Pairing link could not be decoded");
    showToast({
    title: t("walletKit.errorPairing"),
    message: t("walletKit.errorInvalidPairingLink"),
    variant: "error",
    });
    return;
    }
    // Try pairing with the dApp using the provided URI
    walletKit.pair({ uri: link.uri }).catch((error) => {
    showToast({
    title: t("walletKit.errorPairing"),
    message: t("common.error", {
    errorMessage:
    error instanceof Error ? error.message : t("common.unknownError"),
    }),
    variant: "error",
    });
    });
    },
    [t, showToast],

  • src/providers/WalletKitProvider.tsx:

    • handleSessionProposal and handleSessionRequest now wrap the unchanged bodies, renamed processSessionProposal and processSessionRequest, in a try.
    • On a throw, they log, reject the proposal or request, show a toast, clear the event and run the usual teardown, which releases the request queue.
    • prevalidateSignXdr validates xdr before the Blockaid scan, the way prevalidateSignMessage validates the message.

    const prevalidateSignXdr = (
    sessionRequest: WalletKitSessionRequest,
    ): string | null => {
    const xdrParam = (
    sessionRequest.params as
    | { request?: { params?: { xdr?: unknown } } }
    | undefined
    )?.request?.params?.xdr;
    const result = validateSignXdrContent(xdrParam);
    if (!result.valid) {
    showToast({
    title: t("walletKit.invalidRequestTitle"),
    message: t(result.errorKey),
    variant: "error",
    });
    rejectSessionRequest({
    sessionRequest,
    message: t(result.errorKey),
    });
    clearEvent();
    isProcessingRequestRef.current = false;
    return null;
    }
    return result.value;
    };

    const handleSessionProposal = (sessionProposal: WalletKitSessionProposal) => {
    try {
    processSessionProposal(sessionProposal);
    } catch (error) {
    logger.error(
    "WalletKitProvider",
    "Failed to handle a session proposal",
    error,
    );
    rejectSessionProposal({
    sessionProposal,
    message: t("walletKit.invalidRequestTitle"),
    });
    showToast({
    title: t("walletKit.invalidRequestTitle"),
    variant: "error",
    });
    handleClearDappConnection();
    }
    };

    const handleSessionRequest = (sessionRequest: WalletKitSessionRequest) => {
    try {
    processSessionRequest(sessionRequest);
    } catch (error) {
    logger.error(
    "WalletKitProvider",
    "Failed to handle a session request",
    error,
    );
    // Answered here, so the teardown below doesn't reject it a second time.
    hasRespondedRef.current = true;
    rejectSessionRequest({
    sessionRequest,
    message: t("walletKit.invalidRequestTitle"),
    }).catch((rejectError: unknown) => {
    logger.warn(
    "WalletKitProvider",
    "Failed to reject a session request",
    rejectError,
    );
    });
    showToast({
    title: t("walletKit.invalidRequestTitle"),
    variant: "error",
    });
    // Clear the event now, like the other reject paths: the teardown below
    // clears it only after a delay, and a dependency change before then
    // would hand this request to the dispatch effect again.
    clearEvent();
    handleClearDappRequest();
    }
    };

  • src/helpers/walletKitValidation.ts: validateSignXdrContent and ValidationErrorKeys.INVALID_XDR.

  • i18n (en, pt):

    • new: walletKit.errorInvalidPairingLink and walletKit.errorInvalidXdr;
    • "dApp" instead of "App" in errorPairing, errorUnsupportedChainMessage, pleaseLoginToConnect and pleaseUnlockToConnect.

Verification:

  • yarn test: 352 suites pass, with 5,239 tests passing and 6 skipped, as on main. The new tests cover:
    • the normalizer;
    • the session split and disconnect;
    • the link parser and the deep-link handler;
    • the provider's failure paths, including a session refresh during the teardown;
    • the XDR check.
  • yarn lint:ts passes, and ESLint and Prettier pass on every changed file.
  • I ran 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:
    • A pairing link whose uri isn't valid percent-encoding, with the app running and from a cold start: the pairing error toast.
    • A connection request with a non-string name and no icons: the sheet shows the domain and initials.
    • stellar_signXDR with a non-string xdr: rejected with a toast, and the dApp receives the error.
    • A stored session without Stellar accounts, then a relaunch: the app starts normally, and the session is left out and disconnected.

🤖 Generated with Claude Code

CassioMG and others added 10 commits November 11, 2026 13:18
Coerces name, url, description and icons to the types and lengths the UI expects, falling back to the dApp's domain for a missing name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The connection sheet, request headers and Connected Apps now get string names and urls and an array of icon urls whatever the dApp sent, and app initials accept a non-string name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sessions whose stellar namespace doesn't list accounts are left out of the active list and disconnected, and reading the list can no longer throw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A link with a malformed percent-encoding now shows the pairing error toast instead of throwing, and a failure reading the launch link is logged.

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

An unexpected error while handling a dApp event now rejects that event, closes its sheet and moves on to the next queued request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
stellar_signXDR and stellar_signAndSubmitXDR now validate their xdr param before scanning or display, as the message and auth-entry methods already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The connection success and failure toasts now use the normalized dApp name, so a non-string or oversized name reads the same as on the connection sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When handling a dApp request throws, its event is now cleared right away, as on every other reject path. A session refresh during the teardown can no longer queue the rejected request again, and a request that arrives in that window is shown once the teardown runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A missing or blank name falls back to the url's host, which can be far longer than a name; the fallback is now cut to the same 100 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pairing, unsupported-chain and log-in/unlock-to-connect toasts said "App"; they now say "dApp", as the Discover screen does.

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

Copy link
Copy Markdown
Contributor

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

@github-actions

Copy link
Copy Markdown
Contributor

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

Comment thread src/ducks/walletKit.ts
} catch (error) {
// Stored sessions come from dApps; keep the last good list rather than
// letting a bad one take the app down.
logger.error(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this should be a warn

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