@metamask/multichain-account-service
Version:
Service to manage multichain accounts
331 lines • 17.9 kB
JavaScript
"use strict";
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
};
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
if (kind === "m") throw new TypeError("Private method is not writable");
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
};
var _SnapAccountProvider_instances, _SnapAccountProvider_client, _SnapAccountProvider_sender, _SnapAccountProvider_queue, _SnapAccountProvider_trace, _SnapAccountProvider_createSender, _SnapAccountProvider_resolveClient, _SnapAccountProvider_withSnapKeyring;
Object.defineProperty(exports, "__esModule", { value: true });
exports.isSnapAccountProvider = exports.SnapAccountProvider = void 0;
const account_api_1 = require("@metamask/account-api");
const v2_1 = require("@metamask/eth-snap-keyring/v2");
const keyring_api_1 = require("@metamask/keyring-api");
const snaps_utils_1 = require("@metamask/snaps-utils");
const async_mutex_1 = require("async-mutex");
const analytics_1 = require("../analytics/index.cjs");
const errors_1 = require("../errors.cjs");
const logger_1 = require("../logger.cjs");
const BaseBip44AccountProvider_1 = require("./BaseBip44AccountProvider.cjs");
const SnapKeyringClient_1 = require("./SnapKeyringClient.cjs");
const utils_1 = require("./utils.cjs");
class SnapAccountProvider extends BaseBip44AccountProvider_1.BaseBip44AccountProvider {
constructor(snapId, messenger, config,
/* istanbul ignore next */
trace = analytics_1.traceFallback) {
super(messenger);
_SnapAccountProvider_instances.add(this);
/**
* The Snap's keyring capabilities, sourced from `SnapAccountService` (which
* reads them from the Snap's manifest). Populated the first time the client
* is resolved; defaults to an empty capability set until then.
*/
this.capabilities = v2_1.EMPTY_CAPABILITIES;
/**
* Version-agnostic keyring client, resolved lazily once the Snap is ready and
* its capabilities are known — see {@link SnapAccountProvider.withSnap}.
*/
_SnapAccountProvider_client.set(this, void 0);
_SnapAccountProvider_sender.set(this, void 0);
_SnapAccountProvider_queue.set(this, void 0);
_SnapAccountProvider_trace.set(this, void 0);
this.snapId = snapId;
__classPrivateFieldSet(this, _SnapAccountProvider_sender, __classPrivateFieldGet(this, _SnapAccountProvider_instances, "m", _SnapAccountProvider_createSender).call(this, snapId), "f");
const maxConcurrency = config.maxConcurrency ?? Infinity;
this.config = {
...config,
discovery: {
...config.discovery,
enabled: config.discovery.enabled ?? true,
},
maxConcurrency,
};
// Create semaphore only if concurrency is limited
if (isFinite(maxConcurrency)) {
__classPrivateFieldSet(this, _SnapAccountProvider_queue, new async_mutex_1.Semaphore(maxConcurrency), "f");
}
__classPrivateFieldSet(this, _SnapAccountProvider_trace, trace, "f");
}
/**
* Ensures that the Snap is ready to be used.
*
* Once this resolves, a Snap keyring for {@link snapId} is guaranteed to
* exist in the `KeyringController`, so subsequent {@link #withSnapKeyring}
* calls will not fail with "No keyring matches the selector".
*
* @returns A promise that resolves when the Snap is ready.
* @throws An error if the Snap could not become ready.
*/
async ensureReady() {
return this.messenger.call('SnapAccountService:ensureReady', this.snapId);
}
/**
* Wraps an async operation with concurrency limiting based on maxConcurrency config.
* If maxConcurrency is Infinity (the default), the operation runs immediately without throttling.
* Otherwise, it's queued through the semaphore to respect the concurrency limit.
*
* @param operation - The async operation to execute.
* @returns The result of the operation.
*/
async withMaxConcurrency(operation) {
if (__classPrivateFieldGet(this, _SnapAccountProvider_queue, "f")) {
return __classPrivateFieldGet(this, _SnapAccountProvider_queue, "f").runExclusive(operation);
}
return operation();
}
async trace(request, fn) {
return __classPrivateFieldGet(this, _SnapAccountProvider_trace, "f").call(this, request, fn);
}
/**
* Whether the Snap supports the v2 keyring protocol, inferred from its
* declared capabilities (a v2-capable Snap declares BIP-44 capabilities).
*
* @returns `true` if the Snap is v2-capable.
*/
isV2() {
return Boolean(this.capabilities.bip44);
}
async resyncAccounts(accounts) {
await this.withSnap(async ({ client, keyring }) => {
const localSnapAccounts = accounts.filter((account) => account.metadata.snap?.id === this.snapId);
const snapAccounts = new Set((await client.getAccounts()).map((account) => account.id));
// NOTE: This should never happen, but if it does, we recover by deleting the
// extra accounts from the Snap to bring it back in sync with MetaMask.
if (localSnapAccounts.length < snapAccounts.size) {
const autoRemoveExtraSnapAccounts = this.config.resyncAccounts?.autoRemoveExtraSnapAccounts ?? true;
if (autoRemoveExtraSnapAccounts) {
// Build a set of local account IDs for quick lookup
const localAccountIds = new Set(localSnapAccounts.map((account) => account.id));
// Find and delete accounts that exist in Snap but not in MetaMask
await Promise.all([...snapAccounts].map(async (snapAccountId) => {
try {
if (!localAccountIds.has(snapAccountId)) {
// This account exists in the Snap but not in MetaMask, delete it from
// the Snap.
await client.deleteAccount(snapAccountId);
// Update the local Set so subsequent checks use the correct size
snapAccounts.delete(snapAccountId);
}
}
catch (error) {
(0, errors_1.reportError)(this.messenger, `Unable to delete de-synced Snap account: ${this.snapId}`, error, {
provider: this.getName(),
snapAccountId,
});
}
}));
}
else {
const message = `Snap "${this.snapId}" has de-synced accounts, Snap has more accounts than MetaMask! (${localSnapAccounts.length} < ${snapAccounts.size})`;
(0, logger_1.projectLogger)(`${logger_1.WARNING_PREFIX} ${message}`);
console.warn(message);
return;
}
}
// We want this part to be fast, so we only check for sizes, but we might need
// to make a real "diff" between the 2 states to not miss any de-sync.
if (localSnapAccounts.length > snapAccounts.size) {
// We always use the MetaMask list as the main reference here.
await Promise.all(localSnapAccounts.map(async (account) => {
const { id: entropySource, groupIndex } = account.options.entropy;
try {
if (!snapAccounts.has(account.id)) {
// We still need to remove the accounts from the Snap keyring since we're
// about to create the same account again, which will use a new ID, but will
// keep using the same address, and the Snap keyring does not allow this.
await keyring.deleteAccount(account.id);
// The Snap has no account in its state for this one, we re-create it.
await this.createAccounts({
type: keyring_api_1.AccountCreationType.Bip44DeriveIndex,
entropySource,
groupIndex,
});
}
}
catch (error) {
(0, errors_1.reportError)(this.messenger, 'Unable to re-sync accounts', error, {
provider: this.getName(),
groupIndex,
});
}
}));
}
});
}
async withSnap(operation) {
await this.ensureReady();
const client = await __classPrivateFieldGet(this, _SnapAccountProvider_instances, "m", _SnapAccountProvider_resolveClient).call(this);
const keyring = {
createAccounts: (options) => __classPrivateFieldGet(this, _SnapAccountProvider_instances, "m", _SnapAccountProvider_withSnapKeyring).call(this, ({ keyring: snapKeyring }) => snapKeyring.createAccounts(options)),
deleteAccount: (id) => __classPrivateFieldGet(this, _SnapAccountProvider_instances, "m", _SnapAccountProvider_withSnapKeyring).call(this, ({ keyring: snapKeyring }) => snapKeyring.deleteAccount(id)),
};
return await operation({ client, keyring });
}
toBip44Account(account, _options) {
(0, account_api_1.assertIsBip44Account)(account);
return account;
}
async createBip44Accounts(keyring, options) {
return this.withMaxConcurrency(async () => {
const { entropySource } = options;
const snapAccounts = await (0, utils_1.withTimeout)(() => this.trace({
name: analytics_1.TraceName.ProviderCreateAccounts,
data: {
provider: this.getName(),
...(0, analytics_1.toCreateAccountsV2DataTraces)(options),
},
}, () => keyring.createAccounts(options)), this.config.createAccounts.timeoutMs);
const groupIndexOffset = options.type === `${keyring_api_1.AccountCreationType.Bip44DeriveIndexRange}`
? options.range.from
: options.groupIndex;
return snapAccounts.map((snapAccount, index) => {
const groupIndex = groupIndexOffset + index;
const account = this.toBip44Account(snapAccount, {
entropySource,
groupIndex,
});
this.accounts.add(snapAccount.id);
return account;
});
});
}
async createAccounts(options) {
(0, keyring_api_1.assertCreateAccountOptionIsSupported)(options, [
`${keyring_api_1.AccountCreationType.Bip44DeriveIndex}`,
`${keyring_api_1.AccountCreationType.Bip44DeriveIndexRange}`,
]);
return this.withSnap(async ({ keyring }) => this.createBip44Accounts(keyring, options));
}
/**
* Delete a snap account by id.
*
* Resolves the account's address from the tracked account, then forwards to
* the legacy `SnapKeyring.removeAccount(address)`. The Snap keyring takes
* care of notifying the snap to clean up its own state through the normal
* account-removal flow (same path used by `resyncAccounts`).
*
* @param id - The id of the account to delete.
*/
async deleteAccount(id) {
const account = this.getAccount(id);
await __classPrivateFieldGet(this, _SnapAccountProvider_instances, "m", _SnapAccountProvider_withSnapKeyring).call(this, async ({ keyring }) => {
await keyring.deleteAccount(account.id);
});
this.accounts.delete(id);
}
/**
* Discovers accounts for the given entropy source and group index.
*
* v2 Snaps drive discovery through `createAccounts({ bip44:discover })`: the
* Snap checks for on-chain activity (using its own supported scopes) and
* returns the created account(s), or nothing once discovery is exhausted.
*
* v1 Snaps use the client's `discoverAccounts` to detect activity on
* {@link v1DiscoveryScopes}, then create the account for the group index.
*
* @param options - The discovery options.
* @param options.entropySource - The entropy source to discover accounts for.
* @param options.groupIndex - The group index to discover accounts for.
* @returns The discovered (and created) accounts, or an empty array when
* there is nothing to discover at this group index.
*/
async discoverAccounts({ entropySource, groupIndex, }) {
return this.withSnap(async ({ client, keyring }) => this.trace({
name: analytics_1.TraceName.SnapDiscoverAccounts,
data: {
provider: this.getName(),
},
}, async () => {
if (!this.config.discovery.enabled) {
return [];
}
if (this.isV2()) {
// The v2 client has no `discoverAccounts`, so discovery is only
// possible when the Snap supports `bip44:discover`. Otherwise there
// is no way to discover and we report nothing.
if (!this.capabilities.bip44?.discover) {
return [];
}
// v2: the Snap detects on-chain activity and creates the account in
// a single `createAccounts({ bip44:discover })` call. An empty
// result means discovery is exhausted at this group index.
return this.createBip44Accounts(keyring, {
type: keyring_api_1.AccountCreationType.Bip44Discover,
entropySource,
groupIndex,
});
}
// v1: detect activity via the client, then create the account for
// this group index.
const discoveredAccounts = await (0, utils_1.withRetry)(() => (0, utils_1.withTimeout)(() => client.discoverAccounts(this.v1DiscoveryScopes, entropySource, groupIndex), this.config.discovery.timeoutMs), {
maxAttempts: this.config.discovery.maxAttempts,
backOffMs: this.config.discovery.backOffMs,
});
if (!discoveredAccounts.length) {
return [];
}
return this.createBip44Accounts(keyring, {
type: keyring_api_1.AccountCreationType.Bip44DeriveIndex,
entropySource,
groupIndex,
});
}));
}
}
exports.SnapAccountProvider = SnapAccountProvider;
_SnapAccountProvider_client = new WeakMap(), _SnapAccountProvider_sender = new WeakMap(), _SnapAccountProvider_queue = new WeakMap(), _SnapAccountProvider_trace = new WeakMap(), _SnapAccountProvider_instances = new WeakSet(), _SnapAccountProvider_createSender = function _SnapAccountProvider_createSender(snapId) {
return {
send: async (request) => {
const response = await this.messenger.call('SnapController:handleRequest', {
snapId: snapId,
origin: 'metamask',
handler: snaps_utils_1.HandlerType.OnKeyringRequest,
request,
});
return response;
},
};
}, _SnapAccountProvider_resolveClient =
/**
* Resolves the version-agnostic keyring client, fetching the Snap's
* capabilities from `SnapAccountService` on first use and caching both the
* capabilities and the resulting client.
*
* Callers must ensure the Snap is ready (via
* {@link SnapAccountProvider.ensureReady}) beforehand so that the
* capabilities are reliably populated — {@link SnapAccountProvider.withSnap}
* guarantees this ordering.
*
* @returns The resolved {@link SnapKeyringClient}.
*/
async function _SnapAccountProvider_resolveClient() {
if (!__classPrivateFieldGet(this, _SnapAccountProvider_client, "f")) {
this.capabilities = await this.messenger.call('SnapAccountService:getCapabilities', this.snapId);
__classPrivateFieldSet(this, _SnapAccountProvider_client, (0, SnapKeyringClient_1.createSnapKeyringClient)(__classPrivateFieldGet(this, _SnapAccountProvider_sender, "f"), this.isV2()), "f");
}
return __classPrivateFieldGet(this, _SnapAccountProvider_client, "f");
}, _SnapAccountProvider_withSnapKeyring = async function _SnapAccountProvider_withSnapKeyring(operation) {
return this.withKeyringV2({
filter: (keyring) => (0, v2_1.isSnapKeyring)(keyring) && keyring.snapId === this.snapId,
}, (args) => operation(args));
};
const isSnapAccountProvider = (provider) => {
return provider instanceof SnapAccountProvider;
};
exports.isSnapAccountProvider = isSnapAccountProvider;
//# sourceMappingURL=SnapAccountProvider.cjs.map