Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
01d42bc
feat: Integrate StorageService for large controller data
andrepimenta Nov 19, 2025
72939b0
refactor: Update mobile adapter for new StorageService interface
andrepimenta Nov 25, 2025
d2ab2d1
refactor(storage-service): update adapter to handle key building and …
andrepimenta Nov 25, 2025
e431a4f
Add preview package of storage-service
andrepimenta Nov 26, 2025
1f10b96
Merge branch 'main' into feature/storage-service
andrepimenta Nov 26, 2025
7294e96
Merge branch 'feature/storage-service' of https://github.com/MetaMask…
andrepimenta Nov 26, 2025
3e6f2ea
Use metamask-previews for now
andrepimenta Nov 26, 2025
f886613
fix(storage-service): add StorageServiceEvents to GlobalEvents
andrepimenta Nov 26, 2025
ba326e3
fix(storage-service): add StorageService to STATELESS_NON_CONTROLLER_…
andrepimenta Nov 26, 2025
23fc5b6
test(storage-service): achieve 100% coverage for storage-service-init
andrepimenta Nov 26, 2025
7d6da38
fix(storage-service): use undefined instead of null for FilesystemSto…
andrepimenta Nov 26, 2025
08c5d98
test(storage-service): rename test to avoid weasel words
andrepimenta Nov 26, 2025
58d0b47
fix(storage-service): throw errors consistently in all adapter methods
andrepimenta Nov 27, 2025
049a5ff
refactor(storage-service): use Json type and remove wrapper
andrepimenta Nov 27, 2025
ca1941d
Merge branch 'main' into feature/storage-service
andrepimenta Nov 28, 2025
a7de4a4
Merge branch 'main' into feature/storage-service
andrepimenta Dec 12, 2025
11b7710
chore: update @metamask-previews/storage-service with StorageGetResul…
andrepimenta Dec 12, 2025
52d20ab
refactor(storage-service): use @metamask/storage-service with Storage…
andrepimenta Dec 12, 2025
5cf519d
fix: prettier formatting in storage-service-init.test.ts
andrepimenta Dec 12, 2025
71d6519
Merge branch 'main' into feature/storage-service
andrepimenta Dec 12, 2025
6cdc688
Merge branch 'main' into feature/storage-service
andrepimenta Dec 15, 2025
650045d
Merge branch 'main' into feature/storage-service
andrepimenta Dec 15, 2025
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
2 changes: 2 additions & 0 deletions app/core/Engine/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@
import { swapsControllerInit } from './controllers/swaps-controller-init';
import { remoteFeatureFlagControllerInit } from './controllers/remote-feature-flag-controller-init';
import { errorReportingServiceInit } from './controllers/error-reporting-service-init';
import { storageServiceInit } from './controllers/storage-service-init';
import { loggingControllerInit } from './controllers/logging-controller-init';
import { phishingControllerInit } from './controllers/phishing-controller-init';
import { addressBookControllerInit } from './controllers/address-book-controller-init';
Expand All @@ -191,7 +192,7 @@
/**
* Flag to disable automatic vault backups (used during wallet reset)
*/
static disableAutomaticVaultBackup = false;

Check warning on line 195 in app/core/Engine/Engine.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make this public static property readonly.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZrBABNj1dYlcNLGkLoB&open=AZrBABNj1dYlcNLGkLoB&pullRequest=22943
/**
* A collection of all controller instances
*/
Expand Down Expand Up @@ -284,6 +285,7 @@
const { controllersByName } = initModularizedControllers({
controllerInitFunctions: {
ErrorReportingService: errorReportingServiceInit,
StorageService: storageServiceInit,
LoggingController: loggingControllerInit,
PreferencesController: preferencesControllerInit,
RemoteFeatureFlagController: remoteFeatureFlagControllerInit,
Expand Down
64 changes: 64 additions & 0 deletions app/core/Engine/controllers/storage-service-init.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { buildControllerInitRequestMock } from '../utils/test-utils';
import { ExtendedMessenger } from '../../ExtendedMessenger';
import { getStorageServiceMessenger } from '../messengers/storage-service-messenger';
import { ControllerInitRequest } from '../types';
import { storageServiceInit } from './storage-service-init';
import {
StorageService,
StorageServiceMessenger,
} from '@metamask-previews/storage-service';
import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger';

jest.mock('@metamask-previews/storage-service');

function getInitRequestMock(): jest.Mocked<
ControllerInitRequest<StorageServiceMessenger>
> {
const baseMessenger = new ExtendedMessenger<MockAnyNamespace, never, never>({
namespace: MOCK_ANY_NAMESPACE,
});

const requestMock = {
...buildControllerInitRequestMock(baseMessenger),
controllerMessenger: getStorageServiceMessenger(baseMessenger),
initMessenger: undefined,
};

return requestMock;
}

describe('storageServiceInit', () => {
it('initializes the service', () => {
const { controller } = storageServiceInit(getInitRequestMock());

expect(controller).toBeInstanceOf(StorageService);
});

it('passes the proper arguments to the service', () => {
storageServiceInit(getInitRequestMock());

const serviceMock = jest.mocked(StorageService);

expect(serviceMock).toHaveBeenCalledWith({
messenger: expect.any(Object),
storage: expect.objectContaining({
getItem: expect.any(Function),
setItem: expect.any(Function),
removeItem: expect.any(Function),
}),
});
});

it('provides FilesystemStorage adapter with required methods', () => {
storageServiceInit(getInitRequestMock());

const serviceMock = jest.mocked(StorageService);
const callArguments = serviceMock.mock.calls[0][0];

expect(callArguments.storage).toBeDefined();
expect(callArguments.storage?.getItem).toBeInstanceOf(Function);
expect(callArguments.storage?.setItem).toBeInstanceOf(Function);
expect(callArguments.storage?.removeItem).toBeInstanceOf(Function);
// getAllKeys and clear are optional
});
});
189 changes: 189 additions & 0 deletions app/core/Engine/controllers/storage-service-init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { ControllerInitFunction } from '../types';
import {
StorageService,
StorageServiceMessenger,
StorageAdapter,
STORAGE_KEY_PREFIX,
} from '@metamask-previews/storage-service';
import FilesystemStorage from 'redux-persist-filesystem-storage';
import Device from '../../../util/device';
import Logger from '../../../util/Logger';

/**
* Wrapper for stored data with metadata.
* Each adapter defines its own wrapper structure.
*/
interface StoredDataWrapper<T = unknown> {
/** Timestamp when data was stored (milliseconds since epoch). */
timestamp: number;
/** The actual data being stored. */
data: T;
}

/**
* Mobile-specific storage adapter using FilesystemStorage.
* This provides persistent storage for large controller data.
*
* Extension will provide its own adapter using IndexedDB.
* Tests use InMemoryStorageAdapter (default when no storage provided).
*/
const mobileStorageAdapter: StorageAdapter = {
/**
* Get an item from filesystem storage.
* Deserializes and unwraps the stored data.
*
* @param namespace - The controller namespace.
* @param key - The data key.
* @returns The unwrapped data, or null if not found.
*/
async getItem(namespace: string, key: string): Promise<unknown> {
try {
// Build full key: storageService:namespace:key
const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`;
const serialized = await FilesystemStorage.getItem(fullKey);

if (!serialized) {
return null;
}

const wrapper: StoredDataWrapper = JSON.parse(serialized);
return wrapper.data;
} catch (error) {
Logger.error(error as Error, {
message: `StorageService: Failed to get item: ${namespace}:${key}`,
});
return null;
}
},

/**
* Set an item in filesystem storage.
* Wraps with metadata and serializes to string.
*
* @param namespace - The controller namespace.
* @param key - The data key.
* @param value - The value to store (will be wrapped and serialized).
*/
async setItem(namespace: string, key: string, value: unknown): Promise<void> {
try {
// Build full key: storageService:namespace:key
const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`;

// Wrap with metadata
const wrapper: StoredDataWrapper = {
timestamp: Date.now(),
data: value,
};

await FilesystemStorage.setItem(
fullKey,
JSON.stringify(wrapper),
Device.isIos(),
);
} catch (error) {
Logger.error(error as Error, {
message: `StorageService: Failed to set item: ${namespace}:${key}`,
});
throw error;
}
},

/**
* Remove an item from filesystem storage.
*
* @param namespace - The controller namespace.
* @param key - The data key.
*/
async removeItem(namespace: string, key: string): Promise<void> {
try {
// Build full key: storageService:namespace:key
const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`;
await FilesystemStorage.removeItem(fullKey);
} catch (error) {
Logger.error(error as Error, {
message: `StorageService: Failed to remove item: ${namespace}:${key}`,
});
throw error;
}
},

/**
* Get all keys for a specific namespace.
* Filters keys by namespace prefix and returns without prefix.
*
* @param namespace - The namespace to get keys for.
* @returns Array of keys (without prefix) for this namespace.
*/
async getAllKeys(namespace: string): Promise<string[]> {
try {
const allKeys = await FilesystemStorage.getAllKeys();

if (!allKeys) {
return [];
}

const prefix = `${STORAGE_KEY_PREFIX}${namespace}:`;

return allKeys
.filter((key) => key.startsWith(prefix))
.map((key) => key.slice(prefix.length));
} catch (error) {
Logger.error(error as Error, {
message: `StorageService: Failed to get keys for ${namespace}`,
});
return [];
}
},

/**
* Clear all items for a specific namespace.
*
* @param namespace - The namespace to clear.
*/
async clear(namespace: string): Promise<void> {
try {
const allKeys = await FilesystemStorage.getAllKeys();

if (!allKeys) {
return;
}

const prefix = `${STORAGE_KEY_PREFIX}${namespace}:`;
const keysToDelete = allKeys.filter((key) => key.startsWith(prefix));

await Promise.all(
keysToDelete.map((key) => FilesystemStorage.removeItem(key)),
);

Logger.log(
`StorageService: Cleared ${keysToDelete.length} keys for ${namespace}`,
);
} catch (error) {
Logger.error(error as Error, {
message: `StorageService: Failed to clear namespace ${namespace}`,
});
throw error;
}
},
};

/**
* Initialize the storage service.
*
* @param request - The request object.
* @param request.controllerMessenger - The messenger to use for the service.
* @returns The initialized service.
*/
export const storageServiceInit: ControllerInitFunction<
StorageService,
StorageServiceMessenger
> = ({ controllerMessenger }) => {
const controller = new StorageService({
messenger: controllerMessenger,
storage: mobileStorageAdapter,
});

return {
controller,
};
};
5 changes: 5 additions & 0 deletions app/core/Engine/messengers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import {
} from './delegation/delegation-controller-messenger';
import { getRemoteFeatureFlagControllerMessenger } from './remote-feature-flag-controller-messenger';
import { getErrorReportingServiceMessenger } from './error-reporting-service-messenger';
import { getStorageServiceMessenger } from './storage-service-messenger';
import { getLoggingControllerMessenger } from './logging-controller-messenger';
import { getPhishingControllerMessenger } from './phishing-controller-messenger';
import { getAddressBookControllerMessenger } from './address-book-controller-messenger';
Expand Down Expand Up @@ -219,6 +220,10 @@ export const CONTROLLER_MESSENGERS = {
getMessenger: getSignatureControllerMessenger,
getInitMessenger: noop,
},
StorageService: {
getMessenger: getStorageServiceMessenger,
getInitMessenger: noop,
},
DeFiPositionsController: {
getMessenger: getDeFiPositionsControllerMessenger,
getInitMessenger: getDeFiPositionsControllerInitMessenger,
Expand Down
31 changes: 31 additions & 0 deletions app/core/Engine/messengers/storage-service-messenger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ExtendedMessenger } from '../../ExtendedMessenger';
import { getStorageServiceMessenger } from './storage-service-messenger';
import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger';

describe('getStorageServiceMessenger', () => {
it('creates a messenger with StorageService namespace', () => {
const baseMessenger = new ExtendedMessenger<MockAnyNamespace, never, never>(
{
namespace: MOCK_ANY_NAMESPACE,
},
);

const messenger = getStorageServiceMessenger(baseMessenger);

expect(messenger).toBeDefined();
});

it('returns a messenger with correct namespace', () => {
const baseMessenger = new ExtendedMessenger<MockAnyNamespace, never, never>(
{
namespace: MOCK_ANY_NAMESPACE,
},
);

const messenger = getStorageServiceMessenger(baseMessenger);

// Verify messenger has correct structure by checking it has the expected methods
expect(typeof messenger.call).toBe('function');
expect(typeof messenger.registerActionHandler).toBe('function');
});
});
29 changes: 29 additions & 0 deletions app/core/Engine/messengers/storage-service-messenger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
Messenger,
MessengerActions,
MessengerEvents,
} from '@metamask/messenger';
import { StorageServiceMessenger } from '@metamask-previews/storage-service';

import { RootMessenger } from '../types';

/**
* Get the StorageServiceMessenger for the StorageService.
*
* @param rootMessenger - The root messenger.
* @returns The StorageServiceMessenger.
*/
export function getStorageServiceMessenger(
rootMessenger: RootMessenger,
): StorageServiceMessenger {
const messenger = new Messenger<
'StorageService',
MessengerActions<StorageServiceMessenger>,
MessengerEvents<StorageServiceMessenger>,
RootMessenger
>({
namespace: 'StorageService',
parent: rootMessenger,

Check failure on line 26 in app/core/Engine/messengers/storage-service-messenger.ts

View workflow job for this annotation

GitHub Actions / scripts (lint:tsc)

Type 'RootMessenger' is not assignable to type 'undefined'.
});
return messenger;
}
Loading
Loading