Skip to content

feat: Add Multichain API to Flask #14756

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 219 commits into
base: main
Choose a base branch
from

Conversation

ffmcgee725
Copy link
Member

@ffmcgee725 ffmcgee725 commented Apr 21, 2025

Description

This branch adds support for the Multichain API to the Flask build of the Extension.

The existing API (via injected provider) should be completely unchanged.

(Very Briefly) What is the MetaMask Multichain API

  • Concurrent connection to any number of chains (no network switching)
  • Unified entry point for all chain ecosystems (EVM, BTC, Solana, Cosmos, Polkadot etc)
  • Accessible via window.postMessage(). Not accessible via an injected global like window.ethereum

Key Documents/Standards

mip = MetaMask Improvement Proposal

  • MIP-5 (Overview of the Multichain API)
    • CAIP-25 (new connection request API)
    • CAIP-27 (new request API, envelope with target scope/chainId included)
  • MIP-6 (Overview of how the Multichain API’s EVM support diverges from the 1193 injected provider)

Manual testing steps

(RECOMMENDED) Use the Multichain Test Dapp
NOTE: window.postMessage should be used as extensionId for mobile.

OR

yarn setup:flask

Then

yarn start:android:flask

or

yarn start:ios:flask

Setup event listener via dev tools

window.addEventListener('message', (event) => {
  const {
    target,
    data
  } = event.data;
  if (
    target !== "metamask-inpage" ||
    data?.name !== 'metamask-multichain-provider'
  ) {
    return;
  }
  console.log(data.data)
})

Send a multichain API request via dev tools

const caipPostMessage = (data) => {
  window.postMessage({
    target: 'metamask-contentscript',
    data: {
      name: 'metamask-multichain-provider',
      data
    }
  }, location.origin)
}

// create session
caipPostMessage({
    jsonrpc: '2.0',
    method: 'wallet_createSession',
    params: {
      optionalScopes: {}
    }
})

// get session
caipPostMessage({
    jsonrpc: '2.0',
    method: 'wallet_getSession',
    params: {}
})

// revoke session
caipPostMessage({
    jsonrpc: '2.0',
    method: 'wallet_revokeSession',
    params: {}
})

// invoke method
caipPostMessage({
    jsonrpc: '2.0',
    method: 'wallet_invokeMethod',
    params: {
            scope: 'eip155:1',
            request: {
                "method": "eth_blockNumber",
                "params": [],
            }
        }
})

More detailed example of manual requests

taken from extension equivalent Add Multichain API to Flask work

Pre-merge author checklist

  • I’ve followed MetaMask Coding Standards.
  • I've completed the PR template to the best of my ability
  • I’ve included tests if applicable
  • I’ve documented my code using JSDoc format if applicable
  • I’ve applied the right labels on the PR (see labeling guidelines). Not required for external contributors.

Pre-merge reviewer checklist

  • I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed).
  • I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.

ffmcgee725 and others added 30 commits March 12, 2025 13:43
@metamaskbot metamaskbot added team-wallet-api-platform INVALID-PR-TEMPLATE PR's body doesn't match template labels Apr 21, 2025
Copy link
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.

Comment on lines 517 to 605
// wallet_notify for solana accountChanged when permission changes
controllerMessenger.subscribe(
`${this.permissionController.name}:stateChange`,
async (currentValue, previousValue) => {
const origins = uniq([
...previousValue.keys(),
...currentValue.keys(),
]);
origins.forEach((origin) => {
const previousCaveatValue = previousValue.get(origin);
const currentCaveatValue = currentValue.get(origin);

const previousSolanaAccountChangedNotificationsEnabled = Boolean(
previousCaveatValue?.sessionProperties?.[
KnownSessionProperties.SolanaAccountChangedNotifications
],
);
const currentSolanaAccountChangedNotificationsEnabled = Boolean(
currentCaveatValue?.sessionProperties?.[
KnownSessionProperties.SolanaAccountChangedNotifications
],
);

if (
!previousSolanaAccountChangedNotificationsEnabled &&
!currentSolanaAccountChangedNotificationsEnabled
) {
return;
}

const previousSolanaCaipAccountIds = previousCaveatValue
? getPermittedAccountsForScopes(previousCaveatValue, [
MultichainNetworks.SOLANA,
MultichainNetworks.SOLANA_DEVNET,
MultichainNetworks.SOLANA_TESTNET,
])
: [];
const previousNonUniqueSolanaHexAccountAddresses =
previousSolanaCaipAccountIds.map((caipAccountId) => {
const { address } = parseCaipAccountId(caipAccountId);
return address;
});
const previousSolanaHexAccountAddresses = uniq(
previousNonUniqueSolanaHexAccountAddresses,
);
const [previousSelectedSolanaAccountAddress] =
this.sortMultichainAccountsByLastSelected(
previousSolanaHexAccountAddresses,
);

const currentSolanaCaipAccountIds = currentCaveatValue
? getPermittedAccountsForScopes(currentCaveatValue, [
MultichainNetworks.SOLANA,
MultichainNetworks.SOLANA_DEVNET,
MultichainNetworks.SOLANA_TESTNET,
])
: [];
const currentNonUniqueSolanaHexAccountAddresses =
currentSolanaCaipAccountIds.map((caipAccountId) => {
const { address } = parseCaipAccountId(caipAccountId);
return address;
});
const currentSolanaHexAccountAddresses = uniq(
currentNonUniqueSolanaHexAccountAddresses,
);
const [currentSelectedSolanaAccountAddress] =
this.sortMultichainAccountsByLastSelected(
currentSolanaHexAccountAddresses,
);

if (
previousSelectedSolanaAccountAddress !==
currentSelectedSolanaAccountAddress
) {
// TODO: [ffmcgee] implement notifySolanaAccountChange ?
// this._notifySolanaAccountChange(
// origin,
// currentSelectedSolanaAccountAddress
// ? [currentSelectedSolanaAccountAddress]
// : [],
// );
}
});
},
getAuthorizedScopesByOrigin,
);

Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// wallet_notify for solana accountChanged when permission changes
controllerMessenger.subscribe(
`${this.permissionController.name}:stateChange`,
async (currentValue, previousValue) => {
const origins = uniq([
...previousValue.keys(),
...currentValue.keys(),
]);
origins.forEach((origin) => {
const previousCaveatValue = previousValue.get(origin);
const currentCaveatValue = currentValue.get(origin);
const previousSolanaAccountChangedNotificationsEnabled = Boolean(
previousCaveatValue?.sessionProperties?.[
KnownSessionProperties.SolanaAccountChangedNotifications
],
);
const currentSolanaAccountChangedNotificationsEnabled = Boolean(
currentCaveatValue?.sessionProperties?.[
KnownSessionProperties.SolanaAccountChangedNotifications
],
);
if (
!previousSolanaAccountChangedNotificationsEnabled &&
!currentSolanaAccountChangedNotificationsEnabled
) {
return;
}
const previousSolanaCaipAccountIds = previousCaveatValue
? getPermittedAccountsForScopes(previousCaveatValue, [
MultichainNetworks.SOLANA,
MultichainNetworks.SOLANA_DEVNET,
MultichainNetworks.SOLANA_TESTNET,
])
: [];
const previousNonUniqueSolanaHexAccountAddresses =
previousSolanaCaipAccountIds.map((caipAccountId) => {
const { address } = parseCaipAccountId(caipAccountId);
return address;
});
const previousSolanaHexAccountAddresses = uniq(
previousNonUniqueSolanaHexAccountAddresses,
);
const [previousSelectedSolanaAccountAddress] =
this.sortMultichainAccountsByLastSelected(
previousSolanaHexAccountAddresses,
);
const currentSolanaCaipAccountIds = currentCaveatValue
? getPermittedAccountsForScopes(currentCaveatValue, [
MultichainNetworks.SOLANA,
MultichainNetworks.SOLANA_DEVNET,
MultichainNetworks.SOLANA_TESTNET,
])
: [];
const currentNonUniqueSolanaHexAccountAddresses =
currentSolanaCaipAccountIds.map((caipAccountId) => {
const { address } = parseCaipAccountId(caipAccountId);
return address;
});
const currentSolanaHexAccountAddresses = uniq(
currentNonUniqueSolanaHexAccountAddresses,
);
const [currentSelectedSolanaAccountAddress] =
this.sortMultichainAccountsByLastSelected(
currentSolanaHexAccountAddresses,
);
if (
previousSelectedSolanaAccountAddress !==
currentSelectedSolanaAccountAddress
) {
// TODO: [ffmcgee] implement notifySolanaAccountChange ?
// this._notifySolanaAccountChange(
// origin,
// currentSelectedSolanaAccountAddress
// ? [currentSelectedSolanaAccountAddress]
// : [],
// );
}
});
},
getAuthorizedScopesByOrigin,
);

This should be added in a separate PR later on, per our sequencing/breakdown: https://docs.google.com/document/d/1uUK2L6KBSHnz5yzyW54MVYhsSXcaZajNKVIUczljL8g/edit?tab=t.0

Copy link
Member Author

Choose a reason for hiding this comment

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

* Note that account-related notifications emitted when the extension
* becomes unlocked are handled in MetaMaskController._onUnlock.
*/
setupControllerEventSubscriptions() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Have we verified whether some of these subscriptions are being handled elsewhere?

Copy link
Member Author

Choose a reason for hiding this comment

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

The smartTransactionPoller logic seems to be handled in the Engine https://github.com/MetaMask/metamask-mobile/blob/main/app/core/Engine/Engine.ts#L1507C10-L1507C22, but the starting condition doesn't seem to be the same.

and _checkTokenListPolling doesn't seem to be handled elsewhere.

so all in all, if we are talking about controllerMessenger subscription to PreferencesController:stateChange, none existed prior to this implementation.

Comment on lines +522 to +685
if (chains.length > 0 && !chains.includes(currentChainIdForOrigin)) {
const networkClientId =
NetworkController.findNetworkClientIdByChainId(chains[0]);
// setActiveNetwork should be called before setNetworkClientIdForDomain
// to ensure that the isConnected value can be accurately inferred from
// NetworkController.state.networksMetadata in return value of
// `metamask_getProviderState` requests and `metamask_chainChanged` events.
NetworkController.setActiveNetwork(networkClientId);
SelectedNetworkController.setNetworkClientIdForDomain(
origin,
networkClientId,
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

we'll need to verify whether this is in place somewhere for the WIP per-dapp selected network feature still being finished on Mobile by Eric Lamontagne (don't remember his gh handle)

Comment on lines +558 to +717
controllerMessenger.subscribe(
'NetworkController:networkDidChange',
async () => {
if (PreferencesController.state.useExternalServices) {
TransactionController.stopIncomingTransactionPolling();
await TransactionController.updateIncomingTransactions();
TransactionController.startIncomingTransactionPolling();
}
},
Copy link
Contributor

Choose a reason for hiding this comment

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

I imagine this must exist somewhere else already...

Copy link
Member Author

Choose a reason for hiding this comment

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

searching up NetworkController:networkDidChange, it doesn't seem to be the case.

I only see stuff such as this, and not an actual subscription by the controllerMessenger to this action.

Base automatically changed from feat/caip-25-refactor to main May 6, 2025 15:57
@ffmcgee725 ffmcgee725 added the Run Smoke E2E Triggers smoke e2e on Bitrise label May 6, 2025
Copy link
Contributor

github-actions bot commented May 6, 2025

https://bitrise.io/ Bitrise

✅✅✅ pr_smoke_e2e_pipeline passed on Bitrise! ✅✅✅

Commit hash: 5dd1dd3
Build link: https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/ecc496d0-949e-493e-93b2-9aeda0fc6256

Note

  • You can kick off another pr_smoke_e2e_pipeline on Bitrise by removing and re-applying the Run Smoke E2E label on the pull request

Copy link

sonarqubecloud bot commented May 7, 2025

Quality Gate Failed Quality Gate failed

Failed conditions
50.5% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

…d testability

- Added comprehensive tests for utility functions in TASK_QUALITY_TEST.md, improving coverage metrics significantly.
- Refactored BackgroundBridge.js to convert private methods to regular methods, enhancing testability and adding safety checks.
- Updated BackgroundBridge.test.js with new tests for transaction polling and token list management.
- Improved permission differencing utilities tests in differs.test.ts for better coverage and clarity.
- Enhanced validator utilities tests in index.test.ts to cover edge cases and improve validation logic.
@abretonc7s abretonc7s force-pushed the feat/caip-multichain-migrate-core branch from 4e8b903 to ae96745 Compare May 8, 2025 04:08
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 Run Smoke E2E Triggers smoke e2e on Bitrise team-wallet-api-platform
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants