Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions packages/core-backend/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- `AccountActivityService` subscribes to all supported scopes for a given account ([#0000](https://github.com/MetaMask/core/pull/0000))

## [6.5.0]

### Added
Expand Down
1 change: 1 addition & 0 deletions packages/core-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@metamask/controller-utils": "^12.3.0",
"@metamask/keyring-controller": "^27.1.0",
"@metamask/messenger": "^1.2.0",
"@metamask/multichain-account-service": "^10.0.3",
"@metamask/profile-sync-controller": "^28.2.0",
"@metamask/utils": "^11.11.0",
"@tanstack/query-core": "^5.62.16",
Expand Down
57 changes: 47 additions & 10 deletions packages/core-backend/src/ws/AccountActivityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
AccountsControllerGetSelectedAccountAction,
AccountsControllerSelectedAccountChangeEvent,
} from '@metamask/accounts-controller';
import type { MultichainAccountServiceGetMultichainAccountGroupAction } from '@metamask/multichain-account-service';
import type { TraceCallback } from '@metamask/controller-utils';
import type { InternalAccount } from '@metamask/keyring-internal-api';
import type { Messenger } from '@metamask/messenger';
Expand Down Expand Up @@ -52,6 +53,8 @@ const MESSENGER_EXPOSED_METHODS = ['subscribe', 'unsubscribe'] as const;

const SUBSCRIPTION_NAMESPACE = 'account-activity.v1';

const SUPPORTED_CHAIN_PREFIXES = ['eip155', 'solana'] as const;

/**
* Account subscription options
*/
Expand Down Expand Up @@ -88,6 +91,7 @@ export const ACCOUNT_ACTIVITY_SERVICE_ALLOWED_ACTIONS = [
'BackendWebSocketService:findSubscriptionsByChannelPrefix',
'BackendWebSocketService:addChannelCallback',
'BackendWebSocketService:removeChannelCallback',
'MultichainAccountService:getMultichainAccountGroup',
] as const;

// Allowed events that AccountActivityService can listen to
Expand All @@ -98,7 +102,8 @@ export const ACCOUNT_ACTIVITY_SERVICE_ALLOWED_EVENTS = [

export type AllowedActions =
| AccountsControllerGetSelectedAccountAction
| BackendWebSocketServiceMethodActions;
| BackendWebSocketServiceMethodActions
| MultichainAccountServiceGetMultichainAccountGroupAction;

// Event types for the messaging system

Expand Down Expand Up @@ -404,14 +409,15 @@ export class AccountActivityService {
}

try {
// Convert new account to CAIP-10 format
const newAddress = this.#convertToCaip10Address(newAccount);

// First, unsubscribe from all current account activity subscriptions to avoid multiple subscriptions
await this.#unsubscribeFromAllAccountActivity();

// Then, subscribe to the new selected account
await this.subscribe({ address: newAddress });
for (const address of this.#getSupportedMultichainCaip10Addresses(
newAccount,
)) {
// Subscribe to the new selected account in CAIP-10 format
await this.subscribe({ address });
}
} catch (error) {
log('Account change failed', { error });
}
Expand Down Expand Up @@ -514,9 +520,11 @@ export class AccountActivityService {
return;
}

// Convert to CAIP-10 format and subscribe
const address = this.#convertToCaip10Address(selectedAccount);
await this.subscribe({ address });
for (const address of this.#getSupportedMultichainCaip10Addresses(
selectedAccount,
)) {
await this.subscribe({ address });
}
}

/**
Expand All @@ -543,9 +551,14 @@ export class AccountActivityService {
* Convert an InternalAccount address to CAIP-10 format or raw address
*
* @param account - The internal account to convert
* @param account.address - The raw address of the account
* @param account.scopes - The scopes of the account (used to determine chain type)
* @returns The CAIP-10 formatted address or raw address
*/
#convertToCaip10Address(account: InternalAccount): string {
#convertToCaip10Address(account: {
address: InternalAccount['address'];
scopes: InternalAccount['scopes'];
}): string {
// Check if account has EVM scopes
if (account.scopes.some((scope) => scope.startsWith('eip155:'))) {
// CAIP-10 format: eip155:0:address (subscribe to all EVM chains)
Expand All @@ -562,6 +575,30 @@ export class AccountActivityService {
return account.address;
}

/**
* Get all supported multichain CAIP-10 addresses for a given account
* This is used to determine which accounts to subscribe to for activity updates
*
* @param account - The internal account to get supported addresses for
* @returns An array of CAIP-10 formatted addresses that are supported for subscription
*/
#getSupportedMultichainCaip10Addresses(account: InternalAccount): string[] {
const multichainAccounts =
account.options.entropy?.type === 'mnemonic'
? this.#messenger
.call('MultichainAccountService:getMultichainAccountGroup', {
entropySource: account.options.entropy.id,
groupIndex: account.options.entropy.groupIndex,
})
.getAccounts()
: [account];
return multichainAccounts
.map((acct) => this.#convertToCaip10Address(acct))
.filter((address) =>
SUPPORTED_CHAIN_PREFIXES.some((prefix) => address.startsWith(prefix)),
);
}

/**
* Force WebSocket reconnection to clean up subscription state
*/
Expand Down
1 change: 1 addition & 0 deletions packages/core-backend/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
{ "path": "../controller-utils/tsconfig.build.json" },
{ "path": "../keyring-controller/tsconfig.build.json" },
{ "path": "../messenger/tsconfig.build.json" },
{ "path": "../multichain-account-service/tsconfig.build.json" },
{ "path": "../profile-sync-controller/tsconfig.build.json" }
],
"include": ["../../types", "./src"]
Expand Down
3 changes: 3 additions & 0 deletions packages/core-backend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
},
{
"path": "../profile-sync-controller"
},
{
"path": "../multichain-account-service"
}
],
"include": ["../../types", "./src"]
Expand Down
2 changes: 2 additions & 0 deletions packages/multichain-transactions-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **BREAKING:** `MultichainTransactionController` now requires a messenger with the `AccountActivityService:transactionUpdated` delegate event ([#0000](https://github.com/MetaMask/core/pull/0000)]
- The `MultichainTransactionsController` now relies on the `AccountActivityService:transactionUpdated` event to receive transaction updates.
- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074))
- Bump `@metamask/accounts-controller` from `^39.0.0` to `^39.0.4` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9218](https://github.com/MetaMask/core/pull/9218), [#9231](https://github.com/MetaMask/core/pull/9231), [#9349](https://github.com/MetaMask/core/pull/9349))
- Bump `@metamask/polling-controller` from `^16.0.6` to `^16.0.8` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9349](https://github.com/MetaMask/core/pull/9349))
Expand Down
1 change: 1 addition & 0 deletions packages/multichain-transactions-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"dependencies": {
"@metamask/accounts-controller": "^39.0.4",
"@metamask/base-controller": "^9.1.0",
"@metamask/core-backend": "^6.3.3",
"@metamask/keyring-api": "^23.3.0",
"@metamask/keyring-internal-api": "^11.0.1",
"@metamask/keyring-snap-client": "^9.0.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ const setupController = ({
'AccountsController:accountAdded',
'AccountsController:accountRemoved',
'AccountsController:accountTransactionsUpdated',
'AccountActivityService:transactionUpdated',
],
});

Expand Down Expand Up @@ -615,6 +616,66 @@ describe('MultichainTransactionsController', () => {
});
});

it('flips a pending non-EVM transaction to terminal on AccountActivityService:transactionUpdated', async () => {
const accountId = 'sol-account-id';
const chain =
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as unknown as CaipChainId;
const pending = {
id: 'sig-1',
account: accountId,
chain,
type: 'send' as const,
status: 'submitted' as const,
timestamp: 1,
from: [{ address: 'a', asset: null }],
to: [],
fees: [],
events: [{ status: 'submitted' as const, timestamp: 1 }],
};
const { controller, rootMessenger } = setupController({
state: {
nonEvmTransactions: {
[accountId]: {
[chain]: {
transactions: [pending],
next: null,
lastUpdated: Date.now(),
},
},
},
},
});

// Non-terminal / unknown updates are ignored.
rootMessenger.publish('AccountActivityService:transactionUpdated', {
id: 'sig-1',
chain,
status: 'submitted',
});
rootMessenger.publish('AccountActivityService:transactionUpdated', {
id: 'does-not-exist',
chain,
status: 'confirmed',
});
await waitForAllPromises();
expect(
controller.state.nonEvmTransactions[accountId][chain].transactions[0]
.status,
).toBe('submitted');

// A terminal update for a known pending tx flips its status.
rootMessenger.publish('AccountActivityService:transactionUpdated', {
id: 'sig-1',
chain,
status: 'confirmed',
});
await waitForAllPromises();
expect(
controller.state.nonEvmTransactions[accountId][chain].transactions[0]
.status,
).toBe('confirmed');
});

it('initializes new accounts with empty transactions array when receiving updates', async () => {
const { chain } = mockTransactionResult.data[0];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import type {
ControllerGetStateAction,
ControllerStateChangeEvent,
} from '@metamask/base-controller';
import {
AccountActivityServiceTransactionUpdatedEvent,
Transaction as BackendTransactionUpdate,
} from '@metamask/core-backend';
import { isEvmAccountType, TransactionStatus } from '@metamask/keyring-api';
import type {
Transaction,
Expand All @@ -30,6 +34,24 @@ const controllerName = 'MultichainTransactionsController';

const MESSENGER_EXPOSED_METHODS = ['updateTransactionsForAccount'] as const;

/**
* Maps a backend WebSocket status string to a terminal keyring status, or
* `undefined` for non-terminal / unknown statuses (which are ignored).
*
* @param status - The status reported by the account-activity WebSocket.
* @returns The terminal {@link TransactionStatus}, or `undefined`.
*/
function toTerminalStatus(status: string): TransactionStatus | undefined {
const normalized = status.toLowerCase();
if (normalized === TransactionStatus.Confirmed) {
return TransactionStatus.Confirmed;
}
if (normalized === TransactionStatus.Failed) {
return TransactionStatus.Failed;
}
return undefined;
}

/**
* PaginationOptions
*
Expand Down Expand Up @@ -136,7 +158,8 @@ type AllowedActions =
type AllowedEvents =
| AccountsControllerAccountAddedEvent
| AccountsControllerAccountRemovedEvent
| AccountsControllerAccountTransactionsUpdatedEvent;
| AccountsControllerAccountTransactionsUpdatedEvent
| AccountActivityServiceTransactionUpdatedEvent;

/**
* {@link MultichainTransactionsController}'s metadata.
Expand Down Expand Up @@ -217,6 +240,13 @@ export class MultichainTransactionsController extends BaseController<
(transactionsUpdate: AccountTransactionsUpdatedEventPayload) =>
this.#handleOnAccountTransactionsUpdated(transactionsUpdate),
);
// Client-owned terminal tracking: flip pending (Submitted) non-EVM entries to
// Confirmed/Failed from the account-activity WebSocket, replacing the snap's
// former confirmation tracking (see snap-transactions offload, ticket 5).
this.messenger.subscribe(
'AccountActivityService:transactionUpdated',
(update) => this.#handleOnBackendTransactionUpdated(update),
);
}

/**
Expand Down Expand Up @@ -471,6 +501,60 @@ export class MultichainTransactionsController extends BaseController<
});
}

/**
* Handles a transaction status update from the account-activity WebSocket.
*
* Finds the matching pending entry (by chain-specific id) and flips it to the
* reported terminal status. Non-terminal updates and unknown transactions are
* ignored.
*
* @param update - The backend transaction update.
*/
#handleOnBackendTransactionUpdated(update: BackendTransactionUpdate): void {
const terminalStatus = toTerminalStatus(update.status);
if (!terminalStatus) {
return;
}

let location:
| { accountId: string; chain: CaipChainId; existing: Transaction }
| undefined;
for (const [accountId, chains] of Object.entries(
this.state.nonEvmTransactions,
)) {
for (const [chain, entry] of Object.entries(chains)) {
const existing = entry.transactions.find((tx) => tx.id === update.id);
if (existing) {
location = { accountId, chain: chain as CaipChainId, existing };
break;
}
}
if (location) {
break;
}
}

if (!location || location.existing.status === terminalStatus) {
return;
}

const { accountId, chain } = location;
const updatedTransaction: Transaction = {
...location.existing,
status: terminalStatus,
};

this.update((state) => {
const entry = state.nonEvmTransactions[accountId][chain];
entry.transactions = entry.transactions.map((tx) =>
tx.id === update.id ? updatedTransaction : tx,
);
entry.lastUpdated = Date.now();
});

this.#publishTransactionUpdateEvent(updatedTransaction);
}

/**
* Gets a `KeyringClient` for a Snap.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"references": [
{ "path": "../accounts-controller/tsconfig.build.json" },
{ "path": "../base-controller/tsconfig.build.json" },
{ "path": "../core-backend/tsconfig.build.json" },
{ "path": "../keyring-controller/tsconfig.build.json" },
{ "path": "../polling-controller/tsconfig.build.json" },
{ "path": "../messenger/tsconfig.build.json" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"references": [
{ "path": "../accounts-controller" },
{ "path": "../base-controller" },
{ "path": "../core-backend" },
{ "path": "../keyring-controller" },
{ "path": "../polling-controller" },
{ "path": "../messenger" }
Expand Down
Loading
Loading