Skip to content
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

feat: add multichain assets controller #5138

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

Conversation

sahar-fehri
Copy link
Contributor

Explanation

References

Changelog

@metamask/package-a

  • : Your change here
  • : Your change here

@metamask/package-b

  • : Your change here
  • : Your change here

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've highlighted breaking changes using the "BREAKING" category above as appropriate
  • I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes

@sahar-fehri
Copy link
Contributor Author

@metamaskbot publish-preview

Copy link
Contributor

Preview builds have been published. See these instructions for more information about preview builds.

Expand for full list of packages and versions.
{
  "@metamask-previews/accounts-controller": "20.0.2-preview-62a75dc",
  "@metamask-previews/address-book-controller": "6.0.2-preview-62a75dc",
  "@metamask-previews/announcement-controller": "7.0.2-preview-62a75dc",
  "@metamask-previews/approval-controller": "7.1.2-preview-62a75dc",
  "@metamask-previews/assets-controllers": "45.1.2-preview-62a75dc",
  "@metamask-previews/base-controller": "7.1.1-preview-62a75dc",
  "@metamask-previews/build-utils": "3.0.2-preview-62a75dc",
  "@metamask-previews/chain-controller": "0.2.2-preview-62a75dc",
  "@metamask-previews/composable-controller": "10.0.0-preview-62a75dc",
  "@metamask-previews/controller-utils": "11.4.5-preview-62a75dc",
  "@metamask-previews/ens-controller": "15.0.1-preview-62a75dc",
  "@metamask-previews/eth-json-rpc-provider": "4.1.7-preview-62a75dc",
  "@metamask-previews/gas-fee-controller": "22.0.2-preview-62a75dc",
  "@metamask-previews/json-rpc-engine": "10.0.2-preview-62a75dc",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.6-preview-62a75dc",
  "@metamask-previews/keyring-controller": "19.0.2-preview-62a75dc",
  "@metamask-previews/logging-controller": "6.0.3-preview-62a75dc",
  "@metamask-previews/message-manager": "11.0.3-preview-62a75dc",
  "@metamask-previews/multichain": "2.0.0-preview-62a75dc",
  "@metamask-previews/name-controller": "8.0.2-preview-62a75dc",
  "@metamask-previews/network-controller": "22.1.1-preview-62a75dc",
  "@metamask-previews/notification-services-controller": "0.15.0-preview-62a75dc",
  "@metamask-previews/permission-controller": "11.0.5-preview-62a75dc",
  "@metamask-previews/permission-log-controller": "3.0.2-preview-62a75dc",
  "@metamask-previews/phishing-controller": "12.3.1-preview-62a75dc",
  "@metamask-previews/polling-controller": "12.0.2-preview-62a75dc",
  "@metamask-previews/preferences-controller": "15.0.1-preview-62a75dc",
  "@metamask-previews/profile-sync-controller": "3.3.0-preview-62a75dc",
  "@metamask-previews/queued-request-controller": "8.0.2-preview-62a75dc",
  "@metamask-previews/rate-limit-controller": "6.0.2-preview-62a75dc",
  "@metamask-previews/remote-feature-flag-controller": "1.3.0-preview-62a75dc",
  "@metamask-previews/selected-network-controller": "20.0.2-preview-62a75dc",
  "@metamask-previews/signature-controller": "23.2.0-preview-62a75dc",
  "@metamask-previews/transaction-controller": "42.1.0-preview-62a75dc",
  "@metamask-previews/user-operation-controller": "21.0.0-preview-62a75dc"
}

symbol: 'SOL',
native: true,
fungible: true,
iconBase64:

Choose a reason for hiding this comment

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

shouldn't this be an url? We are getting the url from the static api

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought so too, it was defined as iconBase64 in this document which i think is different than a url.

Copy link
Contributor

Choose a reason for hiding this comment

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

IMO, this is better to use a base64 here, in case the logo we want to use is not hosted "anywhere".

Also, using "untrusted URLs" seems always """risky""" (I don't have good example for now) 🙈

// Mock End To be removed once the above is implemented

// Identify the correct snap that has the right endowment:assets permission
const currentAssetChain = assets[0].split('/')[0];
Copy link
Contributor

Choose a reason for hiding this comment

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

We should use parseCaipAssetType here (from @metamask/utils), that would be something like this:

Suggested change
const currentAssetChain = assets[0].split('/')[0];
const { chainId } = parseCaipChainId(asset);

And we can even deconstruct more than that, but if you need the CAIP-2 chain ID here, then chainId is what we need.

Comment on lines 219 to 289
async #handleOnAccountAdded(account: InternalAccount) {
if (!this.#isNonEvmAccount(account)) {
// Nothing to do here for EVM accounts
return;
}

// Get assets list
if (account.metadata.snap) {
const assets = await this.#getAssets(
account.id,
account.metadata.snap.id,
);
const assetsWithoutMetadata = assets.filter(
(asset) => !this.state.metadata[asset],
);
const snaps = this.#getAllSnaps();

const permissions = snaps.map((snap) =>
this.#getSnapsPermissions(snap.id),
);

// Mock start To be removed once the above is implemented
permissions.forEach((singlePermission) => {
(singlePermission as unknown as AssetEndowment) = {
...singlePermission,
'endowment:assets': {
scopes: ['bip122:000000000019d6689c085ae165831e93'],
},
};
});
(permissions[0] as unknown as AssetEndowment) = {
...permissions[0],
'endowment:assets': {
scopes: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'],
},
};
// Mock End To be removed once the above is implemented

// Identify the correct snap that has the right endowment:assets permission
// TODO: create a mapping of the assets to the snapId based on the permission['endowment:assets']?.scopes from permissions array and the assets[0].split('/')[0] from assets array
const mapAssetsToSnapId = new Map<CaipAssetType, string[]>();
assets.forEach((asset) => {
const snapIds: string[] = [];
permissions.forEach((permission: AssetEndowment, index: number) => {
if (
permission['endowment:assets']?.scopes.includes(asset.split('/')[0])
) {
snapIds.push(snaps[index].id);
}
});
mapAssetsToSnapId.set(asset, snapIds);
});
// should take the first snapId from the mapAssetsToSnapId and use it to get the assets

// call the snap to get the metadata
if (assetsWithoutMetadata.length > 0) {
const metadata = await this.#getMetadata(assetsWithoutMetadata);

const newMetadata = {
...this.state.metadata,
...metadata.assets,
};
this.update((state) => {
state.metadata = newMetadata;
});
}
this.update((state) => {
state.allNonEvmTokens[account.id] = assets;
});
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

As discussed together, I do think we should architecture this controller slightly differently.

I believe that having a in-memory mapping of CAIP-2 chain IDs <> Assets Snaps could be a good idea.

This would allow to easily access relevant Snaps for a given scope (CAIP-2 chain ID).

Here's what I have in mind (playground).

Here's the code:

class MultichainAssetsController {
    /// Mapping of CAIP-2 Chain ID to Asset Snaps.
    #snaps: Record<CaipChainId, Snap[]>;

    #assets: Record<CaipChainId, AssetMetadata>;


    constructor() {
        this.#snaps = this.#getAssetSnaps();
        this.#assets = {};

        // TODO: Register to SnapController events when the Snaps list is updated (installed/removed, etc...)
    }

    updateAssetsMetadata(assets: CaipAssetType[]) {
        for (const asset of assets) {
            // TODO: Do we need to refresh those metadata somehow? Maybe invalidate them based on some timing? diff?
            if (this.#assets[asset]) {
                // We have metadata already, skipping.
                continue;
            }

            // Extract CAIP-2 chain ID from asset type.
            const { chainId } = parseCaipAssetType(asset); // @metamask/utils

            // Now fetch metadata from the associated asset Snaps:
            // Pick only the first one, we ignore the other Snaps if there are multiple candidates for now.
            const [snap] = this.#snaps[chainId];
            if (snap) {
                this.#assets[asset] = dispatchOnAssetLookup(snap);
            }
        }
    }

    handleAccountAssetListUpdated(assets: CaipAssetType[]) {
        this.updateAssetsMetadata(assets);
    }

    handleAccountAdded(account: InternalAccount) {
        // TODO: Check non-EVM account.
        this.updateAssetsMetadata(account.assets);
    }

    // Creates a mapping of CAIP-2 Chain ID to Asset Snaps.
    #getAssetSnaps() {
        const snaps: Record<CaipChainId, Snap[]> = {};

        for (const snap of getSnaps().filter((snap) => this.#isAssetSnap(snap))) {
            for (const scope of snap.scopes) {
                if (!snaps[scope]) {
                    snaps[scope] = [];
                }
                snaps[scope].push(snap)
            }
        }
        return snaps;
    }
  
    #isAssetSnap(snap: Snap) {
        // TODO: Check permissions here.
        ...
    }
}

Do you have any opinion on this @danroc?

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks @ccharly, indeed I think it's a good approach

Copy link
Contributor Author

@sahar-fehri sahar-fehri Jan 21, 2025

Choose a reason for hiding this comment

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

Thanks @ccharly ! I wonder if its necessary to do the this.#snaps = this.#getAssetSnaps(); inside the constructor;
Im thinking if i do it as part of handleOnAccountAdded; it will avoid making subscriptions to o SnapController events when the Snaps list is updated (installed/removed, etc...) making the controller simpler

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, we can start with this idea @sahar-fehri, my point was to avoid re-listing Snaps every time there's an incoming events.

Re-computing the "Snaps mapping" for every events, seems unnecessary to me. That's why I had the #getAssetSnaps in the constructor to get the initial mapping, and afterward, we would update the mapping based on the SnapController events.

But for now, we can start with a simple approach first! I would still keep the mapping as it is though, WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Regarding this comment:
// TODO: Do we need to refresh those metadata somehow? Maybe invalidate them based on some timing? diff?
Agree, I do not expect the token metadata to be refreshed that often too.
For non-evm tokens; the one part of token metadata that users would usually update is the token logo.
And we refetch the tokenList once a day from token-api.

In our case; we can refresh the metadata once a day for all assets we have in state.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hey @danroc @ccharly @aganglada 👋 what do you guys think about this ☝️
The notification we will get from the notification system is notify:accountAssetListUpdated which as we've discussed in the past should tell us about the new acquired tokens. We could update all asset metadata once we get notified. However, if a user never gets new tokens for any reason, his asset metadata might stay stale. (outdated logo for exp)
We can make the MultichainAssetsController extends StaticIntervalPollingController and we refetch all asset metadata for all assets once a day. Should be enough. What do you guys think?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd be okay with this approach and 👍 for the StaticIntervalPollingConttroller too. As long as the interval is "big enough", that should be ok.

Comment on lines +9 to +11
export function parseCaipAssetType(asset: CaipAssetType): CaipChainId {
return asset.split('/')[0] as CaipChainId;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Oh sorry, this wasn't clear during my last review, but we do have this helper already 😅

I'd use this one (from @metamask/utils):

So I guess we could get rid of this one in favor of the other one?

const tmpAssets: Record<CaipChainId, CaipAssetType[]> = {};
for (const asset of assets) {
const chainId = parseCaipAssetType(
asset as `${string}:${string}/${string}:${string}`,
Copy link
Contributor

Choose a reason for hiding this comment

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

This type cast feels unnecessary? We're already using CaipAssetType at that point, no?

Comment on lines +241 to +258
const assetsWithoutMetadata: CaipAssetType[] = assets.filter(
(asset) => !this.state.metadata[asset],
);

// call the snap to get the metadata
if (assetsWithoutMetadata.length > 0) {
// check if for every asset in assetsWithoutMetadata there is a snap in snaps by chainId else call getAssetSnaps
if (
!assetsWithoutMetadata.every((asset: CaipAssetType) => {
const chainId = parseCaipAssetType(
asset as `${string}:${string}/${string}:${string}`,
);
console.log('this.snaps', this.#snaps);
return this.#snaps[chainId as CaipChainId]?.length > 0;
})
) {
this.#snaps = this.#getAssetSnaps();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

WDYT about moving this logic in another method to avoid too much logic here?

Could be something like:

#refreshAssetsSnapsFor(asset: CaipAssetType[]) {
      const assetsWithoutMetadata: CaipAssetType[] = assets.filter(
        (asset) => !this.state.metadata[asset],
      );

      // call the snap to get the metadata
      if (assetsWithoutMetadata.length > 0) {
        // check if for every asset in assetsWithoutMetadata there is a snap in snaps by chainId else call getAssetSnaps
        if (
          !assetsWithoutMetadata.every((asset: CaipAssetType) => {
            const chainId = parseCaipAssetType(
              asset as `${string}:${string}/${string}:${string}`,
            );
            console.log('this.snaps', this.#snaps);
            return this.#snaps[chainId as CaipChainId]?.length > 0;
          })
        ) {
          this.#snaps = this.#getAssetSnaps();
        }
}

This way we could re-use this method elsewhere if we want to refresh the Snaps list using any assets list?

asset as `${string}:${string}/${string}:${string}`,
);
console.log('this.snaps', this.#snaps);
return this.#snaps[chainId as CaipChainId]?.length > 0;
Copy link
Contributor

Choose a reason for hiding this comment

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

We're also having this pattern when using this notation:

const [snap] = this.#snaps[chainId as CaipChainId];

So IMO, that would be great to have another method for this like:

#getAssetSnapFor(scope: CaipChainId): Snap {
  const [snap] = this.#snaps[scope];
  return snap; // Will be undefined if there's no Snaps candidate for this scope.
}

And this would just become:

Suggested change
return this.#snaps[chainId as CaipChainId]?.length > 0;
return Boolean(this.#getAssetSnapFor(chainId));

Or using !== undefined if you prefer.

}
}

async updateAssetsMetadata(assets: CaipAssetType[]) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Could be private?

}

async updateAssetsMetadata(assets: CaipAssetType[]) {
const tmpAssets: Record<CaipChainId, CaipAssetType[]> = {};
Copy link
Contributor

Choose a reason for hiding this comment

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

Just to be a bit more explicit. I would also rename tmpAssets to assetsByScope, WDYT?

Suggested change
const tmpAssets: Record<CaipChainId, CaipAssetType[]> = {};
// Creates a mapping of scope to their respective assets list.
const assetsByScope: Record<CaipChainId, CaipAssetType[]> = {};

const assetsForChain = tmpAssets[chainId as CaipChainId];
// Now fetch metadata from the associated asset Snaps:
// Pick only the first one, we ignore the other Snaps if there are multiple candidates for now.
const [snap] = this.#snaps[chainId as CaipChainId];
Copy link
Contributor

Choose a reason for hiding this comment

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

We could re-use the new #getAssetSnapFor method (from my other comment)

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.

4 participants