Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
0775bb3
doc
0xEdouardEth Mar 30, 2026
bdd7ee3
WIP Wallet-connect
0xEdouardEth Mar 31, 2026
dc7091d
refactor(walletconnect): stabilize tron multichain flow and clean log…
Olivier-BB Apr 8, 2026
bf68506
refactor(walletconnect): stabilize tron request adaptation and chain …
Olivier-BB Apr 10, 2026
ac40253
fix(walletconnect): normalize tron signing response for WalletConnect
Olivier-BB Apr 10, 2026
4eca361
refactor(walletconnect): split multichain connector into per-chain ad…
Olivier-BB Apr 15, 2026
a6620f3
fix: preserve EVM chain selection in mixed Tron WalletConnect requests
Olivier-BB Apr 20, 2026
ebd1289
fix(wallet-connect): only approve namespaces the dapp requested
Olivier-BB Apr 20, 2026
1c5c033
refactor(walletconnect): route non-EVM requests via MultichainRouting…
Olivier-BB Apr 28, 2026
2a63d5a
refactor(walletconnect): introduce ChainAdapter registry to isolate n…
Olivier-BB Apr 28, 2026
f411910
chore(walletconnect): remove unrelated QRScanner and Braze changes
Olivier-BB Apr 28, 2026
e9e2b6f
chore(walletconnect): drop unrelated changes and trim debug logs
Olivier-BB Apr 28, 2026
567fd8d
fix(walletconnect): remove duplicate chain-change relay and clarify c…
Olivier-BB Apr 29, 2026
c22d169
chore(walletconnect): enforce multichain caveat origin and harden acc…
Olivier-BB Apr 29, 2026
c79d2f2
fix(walletconnect): avoid optional non-EVM blocking updates and corre…
Olivier-BB Apr 29, 2026
1857154
test(permissions): include default Tron optional scope in CAIP-25 cav…
Olivier-BB Apr 29, 2026
4ac9e55
refactor(walletconnect): move Tron scoped permissions to multichain a…
Olivier-BB Apr 29, 2026
c96d410
test(walletconnect): stabilize invalid chainId session request expect…
Olivier-BB Apr 29, 2026
4453b5e
fix(walletconnect): avoid overriding tron namespace methods in scoped…
Olivier-BB Apr 29, 2026
b70ebb5
test(walletconnect): align invalid chainId request expectation with c…
Olivier-BB Apr 29, 2026
67d3c75
fix(walletconnect): align chainChanged emit payload and restore walle…
Olivier-BB Apr 29, 2026
3d109b0
refactor(walletconnect): move tron chain-id compatibility to multicha…
Olivier-BB Apr 29, 2026
6e8a850
fix(walletconnect): reject malformed CAIP chainIds before non-EVM sna…
Olivier-BB Apr 29, 2026
a33c251
fix(walletconnect): align multichain routing helper with CAIP-typed m…
Olivier-BB Apr 29, 2026
a4244bb
fix(walletconnect): guard malformed request chainId and emit chainCha…
Olivier-BB Apr 30, 2026
57ff4b2
fix(walletconnect): await async session listener cleanup in WalletCon…
Olivier-BB Apr 30, 2026
8eaab03
refactor(walletconnect): route wc-utils multichain helpers through ad…
Olivier-BB Apr 30, 2026
3a42f5a
fix(walletconnect): seed tron permissions only for tron proposals
Olivier-BB Apr 30, 2026
7bcd18d
test(e2e): relax multichain session chain count assertions
Olivier-BB May 4, 2026
40fcb0b
fix(walletconnect): preserve existing sessions and strict chain scoping
Olivier-BB May 5, 2026
a14098e
fix(walletconnect): apply PR review fixes for Tron gating, non-EVM au…
Olivier-BB May 5, 2026
c98e3b9
fix(walletconnect): stabilize multichain WC permissions, refresh rehy…
Olivier-BB May 5, 2026
a45c3c1
fix(walletconnect): stabilize multichain WC auth, Tron guards, and re…
Olivier-BB May 5, 2026
955a50f
test(walletconnect): cover multichain permission/normalization regres…
Olivier-BB May 6, 2026
72d4ea9
fix(walletconnect): finalize multichain review follow-ups and cleanup
Olivier-BB May 6, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,11 @@ jest.mock(
}),
);

const { useAccountGroupsForPermissions: mockUseAccountGroupsForPermissions } =
jest.requireMock(
'../../../hooks/useAccountGroupsForPermissions/useAccountGroupsForPermissions',
);

// Mock useWalletInfo hook
jest.mock(
'../../../../components/Views/MultichainAccounts/WalletDetails/hooks/useWalletInfo',
Expand Down Expand Up @@ -699,6 +704,174 @@ describe('MultichainAccountConnect', () => {
});
});

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', () => {

renderWithProvider(
<MultichainAccountConnect
route={{
params: {
hostInfo: {
metadata: {
id: 'mockId',
origin: 'wc-channel-id',
isEip1193Request: false,
},
permissions: createMockCaip25Permission({
'wallet:eip155': {
accounts: [],
},
'tron:728126428': {
accounts: [],
},
}),
},
permissionRequestId: 'test-mixed-request',
},
}}
/>,
{ state: createMockState() },
);

const requestedChainIds =
mockUseAccountGroupsForPermissions.mock.calls.at(-1)?.[2] ?? [];
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.

guessing a plain ole toHaveBeenCalledWith wouldn't work here for some reason? Is there some other value in the rendered component we could query for instead here that would imply that mockUseAccountGroupsForPermissions was called with the expected chains?

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.

Good point. We used hook-call inspection because the regression is specifically in the derived chain list passed into useAccountGroupsForPermissions. We also added coverage for delegated expansion vs explicit-chain restriction to validate behavior.

expect(requestedChainIds).toContain('eip155:1');
expect(requestedChainIds).toContain('tron:728126428');
});

it('does not expand explicit eip155 chain requests to all EVM chains', () => {
const stateWithMultipleEvmNetworks: DeepPartial<RootState> = {
...createMockState(),
engine: {
...createMockState().engine,
backgroundState: {
...createMockState().engine?.backgroundState,
NetworkController: {
...createMockState().engine?.backgroundState?.NetworkController,
networksMetadata: {
...(createMockState().engine?.backgroundState?.NetworkController
?.networksMetadata ?? {}),
polygon: { status: NetworkStatus.Available, EIPS: {} },
},
networkConfigurationsByChainId: {
...(createMockState().engine?.backgroundState?.NetworkController
?.networkConfigurationsByChainId ?? {}),
'0x89': {
blockExplorerUrls: ['https://polygonscan.com'],
chainId: '0x89',
defaultRpcEndpointIndex: 0,
name: 'Polygon Mainnet',
nativeCurrency: 'POL',
rpcEndpoints: [
{
networkClientId: 'polygon',
type: RpcEndpointType.Custom,
url: 'https://polygon-rpc.com',
},
],
},
},
},
},
},
};

renderWithProvider(
<MultichainAccountConnect
route={{
params: {
hostInfo: {
metadata: {
id: 'mockId',
origin: 'wc-channel-id',
isEip1193Request: false,
},
permissions: createMockCaip25Permission({
'wallet:eip155': {
accounts: [],
},
'eip155:1': {
accounts: [],
},
}),
},
permissionRequestId: 'test-explicit-eip155-request',
},
}}
/>,
{ state: stateWithMultipleEvmNetworks },
);

const requestedChainIds =
mockUseAccountGroupsForPermissions.mock.calls.at(-1)?.[2] ?? [];
expect(requestedChainIds).toContain('eip155:1');
expect(requestedChainIds).not.toContain('eip155:137');
});

it('keeps delegated wallet:eip155 expansion when no explicit eip155 chains are requested', () => {
const stateWithMultipleEvmNetworks: DeepPartial<RootState> = {
...createMockState(),
engine: {
...createMockState().engine,
backgroundState: {
...createMockState().engine?.backgroundState,
NetworkController: {
...createMockState().engine?.backgroundState?.NetworkController,
networksMetadata: {
...(createMockState().engine?.backgroundState?.NetworkController
?.networksMetadata ?? {}),
polygon: { status: NetworkStatus.Available, EIPS: {} },
},
networkConfigurationsByChainId: {
...(createMockState().engine?.backgroundState?.NetworkController
?.networkConfigurationsByChainId ?? {}),
'0x89': {
blockExplorerUrls: ['https://polygonscan.com'],
chainId: '0x89',
defaultRpcEndpointIndex: 0,
name: 'Polygon Mainnet',
nativeCurrency: 'POL',
rpcEndpoints: [
{
networkClientId: 'polygon',
type: RpcEndpointType.Custom,
url: 'https://polygon-rpc.com',
},
],
},
},
},
},
},
};

renderWithProvider(
<MultichainAccountConnect
route={{
params: {
hostInfo: {
metadata: {
id: 'mockId',
origin: 'wc-channel-id',
isEip1193Request: false,
},
permissions: createMockCaip25Permission({
'wallet:eip155': {
accounts: [],
},
}),
},
permissionRequestId: 'test-delegated-eip155-expansion',
},
}}
/>,
{ state: stateWithMultipleEvmNetworks },
);

const requestedChainIds =
mockUseAccountGroupsForPermissions.mock.calls.at(-1)?.[2] ?? [];
expect(requestedChainIds).toContain('eip155:1');
expect(requestedChainIds).toContain('eip155:137');
});

describe('Phishing detection', () => {
describe('dapp scanning is enabled', () => {
it('does not show phishing modal for safe URLs', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
import {
CaipAccountId,
CaipChainId,
CaipNamespace,
KnownCaipNamespace,
parseCaipChainId,
} from '@metamask/utils';
Expand Down Expand Up @@ -215,6 +216,27 @@
[requestedNamespaces],
);

const requestedNamespacesForNetworkSelection = useMemo(() => {
const namespaces = new Set(requestedNamespacesWithoutWallet);
const rawScopeKeys = [
...Object.keys(requestedCaip25CaveatValue.requiredScopes ?? {}),
...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?

// Expand wallet:<namespace> scopes so default network selection still includes
// those concrete namespaces in multichain WalletConnect flows.
rawScopeKeys.forEach((scope) => {
const { namespace, reference } = parseCaipChainId(scope as CaipChainId);
if (namespace !== KnownCaipNamespace.Wallet || !reference) {
return;
}
namespaces.add(reference as CaipNamespace);
});

return Array.from(namespaces);
}, [requestedNamespacesWithoutWallet, requestedCaip25CaveatValue]);

const networkConfigurations = useSelector(
selectNetworkConfigurationsByCaipChainId,
);
Expand Down Expand Up @@ -322,14 +344,28 @@
return defaultSelectedNetworkList;
}

let additionalChains: CaipChainId[] = [];
if (isEip1193Request) {
additionalChains = nonTestNetworkCaipChainIds.filter((caipChainId) =>
requestedNamespacesWithoutWallet.includes(
parseCaipChainId(caipChainId).namespace,
),
);
}
const namespacesWithExplicitChainRequests = new Set(
requestedCaipChainIds.map(
(caipChainId) => parseCaipChainId(caipChainId).namespace,
),
);

// 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.

Check warning on line 359 in app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZ398EXu_xOelAgCQx-v&open=AZ398EXu_xOelAgCQx-v&pullRequest=29428
Comment on lines +353 to +359
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

const additionalChains = nonTestNetworkCaipChainIds.filter(
(caipChainId) => {
const { namespace } = parseCaipChainId(caipChainId);
return (
requestedNamespacesForNetworkSelection.includes(namespace) &&
!namespacesWithExplicitChainRequests.has(namespace)
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.

so if wallet:eip155 and eip155:1 are requested together, then no additional evm caip chain ids would be included in addtionalChains. Not saying that is right or wrong, just pointing it out. Not sure what we want here

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.

Correct, intentional. When explicit eip155 chains are present, we treat that list as authoritative and avoid namespace-wide expansion to prevent over-approving chains. Delegated expansion still applies when no explicit chain IDs are provided.

);
},
);

const supportedRequestedCaipChainIds = Array.from(
new Set([
Expand All @@ -350,12 +386,12 @@
);
}

if (requestedNamespaces.length > 0) {
if (requestedNamespacesForNetworkSelection.length > 0) {
return Array.from(
new Set(
defaultSelectedNetworkList.filter((caipChainId) => {
const { namespace } = parseCaipChainId(caipChainId);
return requestedNamespaces.includes(namespace);
return requestedNamespacesForNetworkSelection.includes(namespace);
}),
),
);
Expand All @@ -368,8 +404,7 @@
requestedCaipChainIds,
isEip1193Request,
currentlySelectedNetwork.chainId,
requestedNamespaces,
requestedNamespacesWithoutWallet,
requestedNamespacesForNetworkSelection,
alreadyConnectedCaipChainIds,
isSolanaWalletStandardRequest,
]);
Expand Down
5 changes: 4 additions & 1 deletion app/core/Permissions/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,10 @@ describe('Permission Utility Functions', () => {
const result = getDefaultCaip25CaveatValue();
expect(result).toEqual({
requiredScopes: {},
optionalScopes: { 'wallet:eip155': { accounts: [] } },
optionalScopes: {
'wallet:eip155': { accounts: [] },
'tron:728126428': { accounts: [] },
},
sessionProperties: {},
isMultichainOrigin: false,
});
Expand Down
51 changes: 32 additions & 19 deletions app/core/Permissions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
parseCaipAccountId,
parseCaipChainId,
} from '@metamask/utils';
///: BEGIN:ONLY_INCLUDE_IF(tron)
import { TrxScope } from '@metamask/keyring-api';
///: END:ONLY_INCLUDE_IF
import { InternalAccount } from '@metamask/keyring-internal-api';
import {
Caip25CaveatType,
Expand All @@ -16,7 +19,7 @@
getAllScopesFromCaip25CaveatValue,
getCaipAccountIdsFromCaip25CaveatValue,
getEthAccounts,
getPermittedEthChainIds,

Check warning on line 22 in app/core/Permissions/index.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'getPermittedEthChainIds'.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZ3Zdwdj384hUvdeVAyV&open=AZ3Zdwdj384hUvdeVAyV&pullRequest=29428
isInternalAccountInPermittedAccountIds,
setChainIdsInCaip25CaveatValue,
setNonSCACaipAccountIdsInCaip25CaveatValue,
Expand Down Expand Up @@ -297,18 +300,25 @@

/**
* 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).

* @returns Default {@link Caip25CaveatValue}
*/
export const getDefaultCaip25CaveatValue = (): Caip25CaveatValue => ({
requiredScopes: {},
optionalScopes: {
'wallet:eip155': {
accounts: [],
},
},
sessionProperties: {},
isMultichainOrigin: false,
});
export const getDefaultCaip25CaveatValue = (): Caip25CaveatValue => {
const optionalScopes: Caip25CaveatValue['optionalScopes'] = {
'wallet:eip155': { accounts: [] },
};

///: BEGIN:ONLY_INCLUDE_IF(tron)
optionalScopes[TrxScope.Mainnet] = { accounts: [] };
///: END:ONLY_INCLUDE_IF
Comment on lines +311 to +313
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.


return {
requiredScopes: {},
optionalScopes,
sessionProperties: {},
isMultichainOrigin: false,
};
};

// Returns the CAIP-25 caveat or undefined if it does not exist
export const getCaip25Caveat = (origin: string) => {
Expand Down Expand Up @@ -624,10 +634,11 @@
};

/**
* Get permitted chains for the given the host.
* Get permitted chains for the given the host, across all namespaces.
* Returns all non-wallet scopes stored in the CAIP-25 caveat.
*
* @param hostname - Subject to check if permissions exists. Ex: A Dapp is a subject.
* @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

hostname: string,
Expand All @@ -640,14 +651,16 @@
);

if (caveat) {
const chains = getPermittedEthChainIds(caveat.value).map(
(chainId: string) =>
`${KnownCaipNamespace.Eip155.toString()}:${parseInt(
chainId,
)}` as CaipChainId,
return getAllScopesFromCaip25CaveatValue(caveat.value).filter(
(caipChainId: CaipChainId) => {
try {
const { namespace } = parseCaipChainId(caipChainId);
return namespace !== KnownCaipNamespace.Wallet;
} catch {
return false;
}
},
);

return chains;
}

return [];
Expand Down
Loading
Loading