Skip to content

feat(walletconnect): add multichain routing and namespace approval for first non-EVM chain (Tron)#29428

Open
Olivier-BB wants to merge 35 commits into
mainfrom
wallet-connect-4o
Open

feat(walletconnect): add multichain routing and namespace approval for first non-EVM chain (Tron)#29428
Olivier-BB wants to merge 35 commits into
mainfrom
wallet-connect-4o

Conversation

@Olivier-BB
Copy link
Copy Markdown
Contributor

@Olivier-BB Olivier-BB commented Apr 28, 2026

Description

This PR refactors WalletConnect multichain handling by introducing a per-chain ChainAdapter registry and moving non-EVM behavior behind that adapter layer (with Tron as the first implementation). The goal is to reduce coupling in WalletConnect session code and make additional non-EVM chains easier to add.

It also improves namespace approval and routing behavior for mixed EVM + Tron sessions by approving only requested namespaces/chains, preserving EVM chain selection, and normalizing Tron request/response mapping for compatibility with dapp expectations.

Changelog

CHANGELOG entry: Improved WalletConnect multichain compatibility by refining namespace approval and request routing for mixed EVM and Tron sessions.

Related issues

Fixes:

Manual testing steps

Feature: WalletConnect multichain routing and namespace approval

  Scenario: user connects to a dapp requesting mixed EVM and Tron namespaces
    Given the user has at least one EVM account and one Tron account available
    And a dapp initiates a WalletConnect v2 proposal with EVM and Tron requirements

    When user approves the WalletConnect connection
    Then the approved namespaces include only namespaces and chains requested by the dapp
    And existing EVM chain/account selection is preserved

  Scenario: user signs a Tron request through WalletConnect
    Given an active WalletConnect session for Tron
    When user approves a Tron signing request from the dapp
    Then the request is mapped through the Tron chain adapter
    And the response is normalized to WalletConnect format expected by the dapp

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **High Risk**
> High risk because it changes WalletConnect v2 session approval, chain permission checks, namespace updates, and request routing, including new Snap-based handling for non-EVM requests; mistakes could break existing EVM sessions or incorrectly permit/deny chains.
> 
> **Overview**
> Adds a new WalletConnect `multichain` adapter/registry layer and implements the first non-EVM adapter for **Tron**, including CAIP chain-id normalization (hex/decimal), request mapping to Snap RPC shapes, and response normalization for Tron dapp expectations.
> 
> Updates WalletConnect v2 flows to **approve and update only requested namespaces/chains**, preserve previously approved non-EVM namespaces during updates, normalize/validate session namespaces, and route non-EVM requests through `MultichainRoutingService` (with improved unauthorized-chain errors and redirect-methods keyed by namespace).
> 
> Extends permissions defaults and lookups to be **namespace-agnostic** (adds Tron optional scope behind feature flag; `getPermittedChains` now returns all non-`wallet` scopes), tightens network-selection expansion logic to avoid broadening explicit `eip155` chain requests, and adjusts logging/error handling plus related unit/e2e test coverage.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 72d4ea9ea37aa4d69f6efb36ea5ce3ea61393f36. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

@github-actions
Copy link
Copy Markdown
Contributor

CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes.

0xEdouardEth and others added 14 commits April 29, 2026 14:43
…compatibility

Normalize WalletConnect Tron requests to canonical Snap methods and sanitize params for JSON-RPC validity. Expand Tron session chain/account compatibility to support both decimal and hex CAIP references so dapps and wallet stay interoperable.

Made-with: Cursor
Ensure tron signTransaction requests execute with metamask origin and adapt signature-only Snap results into a full signed transaction payload expected by WalletConnect dapps.

Made-with: Cursor
@Olivier-BB Olivier-BB marked this pull request as ready for review April 29, 2026 12:54
@Olivier-BB Olivier-BB requested review from a team as code owners April 29, 2026 12:54
@metamaskbotv2 metamaskbotv2 Bot added the INVALID-PR-TEMPLATE PR's body doesn't match template label Apr 29, 2026
Comment thread app/core/WalletConnect/WalletConnect2Session.ts
Comment thread app/core/WalletConnect/WalletConnect2Session.ts Outdated
Comment thread app/core/WalletConnect/WalletConnect2Session.ts Outdated
@Olivier-BB Olivier-BB changed the title Feature: WalletConnect multichain routing and namespace approval feat(walletconnect): add multichain routing and namespace approval for first non-EVM chain (Tron) Apr 29, 2026
Comment thread app/core/WalletConnect/wc-utils.ts Outdated
},
existingNamespaces: mergedNamespaces,
});
Object.assign(mergedNamespaces, adapterSlices);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

it's not clear what this does on the surface level. Is this effectively filtering out namespaces without accounts?

optionalNamespaces: {},
});
const missingRequiredAdapterNamespaces = requiredAdapterNamespaces.filter(
(ns) => !mergedNamespaces[ns]?.chains?.length,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm having trouble understanding what is going on here

requiredAdapterNamespaces returns adapter generated namespaces from this.session.requiredNamespaces that have a matching adapter, which effectively is requiredNamespaces with an adapter and accounts.

mergedNamespaces contains this.session.namespace, which contains this.requiredNamespace

missingRequiredAdapterNamespaces returns this.session.requiredNamespaces with matching adapter and non-empty accounts but are missing entirely or have no chains defined in mergedNamespaces.

Is this right? Can this be explained in simpler terms?

// (a session previously approved eip155 should keep eip155 on update).
Object.keys(currentNamespaces).forEach((key) =>
allowedNamespaceKeys.add(key),
);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

currentNamespaces is this.session.namespaces. Is this not the same set of keys as this.session.requiredNamespaces and this.session.optionalNamespaces?

const redirectNamespace =
requestNamespace === KnownCaipNamespace.Wallet
? KnownCaipNamespace.Eip155
: requestNamespace;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i don't think WC supports targeting the wallet or wallet:eip155 scopes. requestNamespace may be safe as is

}
throw error;
}
};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

if the session exists, then a permission should exist for it. Are you seeing this not be the case?

const permittedChainsFromSession =
this.session.topic !== this.channelId
? await getPermittedChainsSafe(this.session.topic)
: [];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

won't this always be empty array because permissions are not key'ed by this.session.topic, but rather channelId / this.session.pairingTopic?

(chainId) => activeSessionChains.includes(chainId),
);
const isPermittedRequestChain =
isPermittedByPermissionController || isPermittedByActiveSession;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the result from the PermissionController should be the source of truth ultimately. Both the activeSession and the permission are kept in sync - ish, so at minimum only one of these needs to be checked, not both. Unless you're seeing some reason otherwise?

}): Promise<unknown> =>
Engine.controllerMessenger.call('MultichainRoutingService:handleRequest', {
connectedAddresses,
origin: 'metamask',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is an unsafe internal origin. The value used here should probably be the channelId

} catch (err) {
console.warn(`WC2::init can't update session ${sessionKey}`);
DevLogger.log(`WC2::init can't update session ${sessionKey}`);
await this.cleanupBrokenRestoredSession(session, err);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why is this needed now?

const referencedAdapterNamespaces = proposalReferencedAdapterNamespaces(
proposal.params,
);
const isMultichainOrigin = referencedAdapterNamespaces.length > 0;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@adonesky1 . does it really matter what value this flag is now?

Comment on lines +751 to +757
const allProposalNamespaces = {
...optionalNamespaces,
...requiredNamespaces,
} as Record<
string,
{ chains?: string[]; methods?: string[]; events?: string[] }
>;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

keys can overlap here

Comment on lines +793 to +800
namespaces.wallet = {
chains: walletChains,
methods: namespaces.eip155.methods ?? [],
events: namespaces.eip155.events ?? [],
accounts: eip155Accounts.map((account) => {
const [, , address] = account.split(':');
return `wallet:eip155:${address}` as `${string}:${string}:${string}`;
}),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is this wallet namespace used later? I feel like i've already seen special handling of wallet:eip155 that would imply that namespaces['wallet'] is not used, but maybe I'm wrong

Comment on lines +812 to +815
const allowedNamespaceKeys = new Set<string>([
...Object.keys(proposal.params.requiredNamespaces ?? {}),
...Object.keys(proposal.params.optionalNamespaces ?? {}),
]);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is key collision possible here?

Comment on lines +1008 to +1020
if (!session) {
const activeSession = this.getSession(sessionTopic);
if (activeSession) {
session = new WalletConnect2Session({
web3Wallet: this.web3Wallet,
channelId: activeSession.pairingTopic,
navigation: this.navigation,
deeplink: true,
session: activeSession,
});
this.sessions[sessionTopic] = session;
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why is this needed now?

export const getChainChangedEmissionForWalletConnect = ({
namespaces,
fallbackEvmDecimal,
fallbackEvmHex,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

it feels odd for the caller to be responsible to provide the evm chain id formatted in both decimal and hex

return String(raw);
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

confused on how the WC changes would warrant this change here

});
});

it('passes EVM chains to permissions hook for mixed wallet:eip155 + tron requests', () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

right?

Suggested change
it('passes EVM chains to permissions hook for mixed wallet:eip155 + tron requests', () => {
it('passes EVM chains and Tron to permissions hook for mixed wallet:eip155 + tron requests', () => {

...Object.keys(requestedCaip25CaveatValue.optionalScopes ?? {}),
];

// CAIP-25 may encode delegated namespace requests (for example wallet:eip155).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not sure what encode delegated namespace requests means?

Comment on lines +353 to +359
// Expand only namespaces that were requested without explicit chains
// (e.g. wallet:eip155 delegated requests in mixed namespace proposals).
// Intentionally restrictive: if explicit eip155 chains are requested, treat
// that list as authoritative and do not expand to all eip155 networks. This
// prevents approving more chains than requested. Delegated namespace
// expansion still applies when no explicit chain ids are provided.
// TODO: check if this restrictive logic has already been applied in the past or if this modifies previous behavior.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this makes sense for this pass. Just noting that we are probably going to be loosening chain permissions quite a bit in the near future: i.e. you ask for 1 EVM chain you get all EVM chains. But TBD


/**
* Returns a default CAIP-25 caveat value.
* Each chain appends its optional scope below (feature-flagged).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[nit]

Suggested change
* Each chain appends its optional scope below (feature-flagged).
* Each ecosystem (i.e. EVM, Tron) appends its optional scope below (feature-flagged).

Comment on lines +311 to +313
///: BEGIN:ONLY_INCLUDE_IF(tron)
optionalScopes[TrxScope.Mainnet] = { accounts: [] };
///: END:ONLY_INCLUDE_IF
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

not sure I'm following why this would be added as a default namespace for permissions? I'm struggling to figure out how to test this properly and I'm not totally remembering or understanding how this helper function is used but its name implies that this addition will mean Tron network permissions will be granted by default? TBH I'm not sure how that would be the case... but either way I'm not sure I understand the purpose of this addition.

* @returns An array containing permitted chains for the specified host.
* @returns An array containing permitted CAIP chain IDs for the specified host.
*/
export const getPermittedChains = async (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
export const getPermittedChains = async (
export const getPermittedCaipChainIds = async (

Yes the typing gives this context, but the sibling getPermittedAccounts is currently only returning EVM accounts... so this could get confusing


export const normalizeCaipChainIdInboundForWalletConnectTron = (
caipChainId: string,
): string => {
Copy link
Copy Markdown
Contributor

@adonesky1 adonesky1 May 7, 2026

Choose a reason for hiding this comment

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

[nit] shouldn't the return type also be caipChainId?

const TRON_PREFIX = 'tron:';
const DEFAULT_TRON_CHAIN_ID = 'tron:0x2b6653dc';

export const normalizeCaipChainIdInboundForWalletConnectTron = (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should add a comment explaining why this normalization is required? I asked Claude to explain why this was necessary because it's nonobvious when just looking at it. WalletConnect tends to encode the Tron chainIds with hex values for the reference i.e. tron:0x2b6653dc while we use decimals for the reference tron:728126428

return caipChainId;
};

export const getCompatibleTronCaipChainIdsForWalletConnect = (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Lets please add some JSdocs to explain why this utility function is necessary?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looking at where this is used, I'm not understanding why we need this encoded in both hex and decimal formats side by side ever?

Comment on lines +118 to +121
const listTronEoaAddresses = (): string[] =>
Engine.context.AccountsController.listAccounts()
.filter((account: { type: string }) => account.type === TrxAccountType.Eoa)
.map((account: { address: string }) => account.address);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just so I'm clear do we support Tron SCAs?

* Shape of a single namespace slice in WalletConnect's approved
* namespaces map. Mirrors `SessionTypes.Namespace` but kept loose.
*/
export interface TronNamespaceSlice {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

hmmm this seems tied to a mixed version of CAIP-25. Can you point me to the docs where this type is described?

* matches `SessionTypes.Namespace.accounts` which is a plain string[].
*/
accounts: string[];
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we have two different versions of this same interface? https://github.com/MetaMask/metamask-mobile/pull/29428/changes#diff-09c1410947d91a7bf8ff2bd7a39a10ad19c1b4d40083abfdc2c8b86d08580f73R47

Also TronNamespaceSlice is awfully similar...?

Comment on lines +397 to +410
const originalTransaction =
typeof transactionContainer === 'object' &&
transactionContainer !== null &&
!Array.isArray(transactionContainer) &&
typeof (transactionContainer as Record<string, unknown>).transaction ===
'object' &&
(transactionContainer as Record<string, unknown>).transaction !== null
? ((transactionContainer as Record<string, unknown>)
.transaction as Record<string, unknown>)
: typeof transactionContainer === 'object' &&
transactionContainer !== null &&
!Array.isArray(transactionContainer)
? (transactionContainer as Record<string, unknown>)
: undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ummm this can't be reasoned about in any meaningful way

.filter((account: { type: string }) => account.type === TrxAccountType.Eoa)
.map((account: { address: string }) => account.address);

export const buildTronScopedPermissionsNamespace = ({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When a function gets this long I find it can help to add some inline comments explain what is going on at each step

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

Labels

INVALID-PR-TEMPLATE PR's body doesn't match template size-XL team-accounts team-mobile-platform Mobile Platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants