Skip to content

Commit 54bc644

Browse files
committed
wip
1 parent 24518b2 commit 54bc644

File tree

16 files changed

+475
-1
lines changed

16 files changed

+475
-1
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [Unreleased]
9+
10+
### Added
11+
12+
- Initial release
13+
14+
[Unreleased]: https://github.com/MetaMask/core/releases/tag/@metamask/cash-account-service@0.0.0
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 MetaMask
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
TODO
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* For a detailed explanation regarding each configuration property and type check, visit:
3+
* https://jestjs.io/docs/configuration
4+
*/
5+
6+
const merge = require('deepmerge');
7+
const path = require('path');
8+
9+
const baseConfig = require('../../jest.config.packages');
10+
11+
const displayName = path.basename(__dirname);
12+
13+
module.exports = merge(baseConfig, {
14+
// The display name when running multiple projects
15+
displayName,
16+
17+
// An object that configures minimum threshold enforcement for coverage results
18+
coverageThreshold: {
19+
global: {
20+
branches: 100,
21+
functions: 100,
22+
lines: 100,
23+
statements: 100,
24+
},
25+
},
26+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
{
2+
"name": "@metamask/cash-account-service",
3+
"version": "0.0.0",
4+
"description": "Service to manage cash accounts",
5+
"keywords": [
6+
"MetaMask",
7+
"Ethereum"
8+
],
9+
"homepage": "https://github.com/MetaMask/core/tree/main/packages/cash-account-service#readme",
10+
"bugs": {
11+
"url": "https://github.com/MetaMask/core/issues"
12+
},
13+
"repository": {
14+
"type": "git",
15+
"url": "https://github.com/MetaMask/core.git"
16+
},
17+
"license": "MIT",
18+
"sideEffects": false,
19+
"exports": {
20+
".": {
21+
"import": {
22+
"types": "./dist/index.d.mts",
23+
"default": "./dist/index.mjs"
24+
},
25+
"require": {
26+
"types": "./dist/index.d.cts",
27+
"default": "./dist/index.cjs"
28+
}
29+
},
30+
"./package.json": "./package.json"
31+
},
32+
"main": "./dist/index.cjs",
33+
"types": "./dist/index.d.cts",
34+
"files": [
35+
"dist/"
36+
],
37+
"scripts": {
38+
"build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references",
39+
"build:all": "ts-bridge --project tsconfig.build.json --verbose --clean",
40+
"build:docs": "typedoc",
41+
"changelog:update": "../../scripts/update-changelog.sh @metamask/cash-account-service",
42+
"changelog:validate": "../../scripts/validate-changelog.sh @metamask/cash-account-service",
43+
"since-latest-release": "../../scripts/since-latest-release.sh",
44+
"test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter",
45+
"test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache",
46+
"test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
47+
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
48+
},
49+
"dependencies": {
50+
"@metamask/base-controller": "^9.0.0",
51+
"@metamask/keyring-controller": "^25.1.0",
52+
"@metamask/messenger": "^0.3.0",
53+
"@metamask/utils": "^11.9.0"
54+
},
55+
"devDependencies": {
56+
"@metamask/auto-changelog": "^3.4.4",
57+
"@metamask/eth-hd-keyring": "^13.0.0",
58+
"@ts-bridge/cli": "^0.6.4",
59+
"@types/jest": "^29.5.14",
60+
"deepmerge": "^4.2.2",
61+
"jest": "^29.7.0",
62+
"ts-jest": "^29.2.5",
63+
"typedoc": "^0.25.13",
64+
"typedoc-plugin-missing-exports": "^2.0.0",
65+
"typescript": "~5.3.3"
66+
},
67+
"engines": {
68+
"node": "^18.18 || >=20"
69+
},
70+
"publishConfig": {
71+
"access": "public",
72+
"registry": "https://registry.npmjs.org/"
73+
}
74+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { CashAccountService } from './CashAccountService';
2+
3+
export type CashAccountServiceCreateCashAccountAction = {
4+
type: `CashAccountService:createCashAccount`;
5+
handler: CashAccountService['createCashAccount'];
6+
};
7+
8+
export type CashAccountServiceMethodActions =
9+
CashAccountServiceCreateCashAccountAction;
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { HdKeyring } from '@metamask/eth-hd-keyring';
2+
import { KeyringTypes } from '@metamask/keyring-controller';
3+
import type { KeyringObject } from '@metamask/keyring-controller';
4+
import type {
5+
MessengerActions,
6+
MessengerEvents,
7+
MockAnyNamespace,
8+
} from '@metamask/messenger';
9+
import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger';
10+
11+
import { CashAccountService, serviceName } from './CashAccountService';
12+
import type { CashAccountServiceMessenger } from './types';
13+
14+
type AllActions = MessengerActions<CashAccountServiceMessenger>;
15+
type AllEvents = MessengerEvents<CashAccountServiceMessenger>;
16+
17+
type RootMessenger = Messenger<MockAnyNamespace, AllActions, AllEvents>;
18+
19+
const MOCK_MNEMONIC = new Uint8Array([
20+
116, 101, 115, 116, 32, 116, 101, 115, 116, 32, 116, 101, 115, 116, 32, 116,
21+
101, 115, 116, 32, 116, 101, 115, 116, 32, 116, 101, 115, 116, 32, 116, 101,
22+
115, 116, 32, 116, 101, 115, 116, 32, 116, 101, 115, 116, 32, 116, 101, 115,
23+
116, 32, 116, 101, 115, 116, 32, 106, 117, 110, 107,
24+
]);
25+
26+
const MOCK_ENTROPY_SOURCE = 'mock-entropy-source-id';
27+
28+
const MOCK_HD_KEYRING: KeyringObject = {
29+
type: KeyringTypes.hd,
30+
accounts: ['0x1234'],
31+
metadata: { id: MOCK_ENTROPY_SOURCE, name: '' },
32+
};
33+
34+
function setup(): {
35+
service: CashAccountService;
36+
rootMessenger: RootMessenger;
37+
mocks: {
38+
getState: jest.Mock;
39+
getKeyringsByType: jest.Mock;
40+
addNewKeyring: jest.Mock;
41+
};
42+
} {
43+
const rootMessenger: RootMessenger = new Messenger({
44+
namespace: MOCK_ANY_NAMESPACE,
45+
captureException: jest.fn(),
46+
});
47+
48+
const messenger: CashAccountServiceMessenger = new Messenger({
49+
namespace: serviceName,
50+
parent: rootMessenger,
51+
});
52+
53+
rootMessenger.delegate({
54+
messenger,
55+
actions: [
56+
'KeyringController:getState',
57+
'KeyringController:getKeyringsByType',
58+
'KeyringController:addNewKeyring',
59+
],
60+
events: [],
61+
});
62+
63+
const mocks = {
64+
getState: jest.fn().mockReturnValue({
65+
isUnlocked: true,
66+
keyrings: [MOCK_HD_KEYRING],
67+
}),
68+
getKeyringsByType: jest
69+
.fn()
70+
.mockReturnValue([{ mnemonic: MOCK_MNEMONIC } as unknown as HdKeyring]),
71+
addNewKeyring: jest.fn().mockResolvedValue({
72+
id: 'new-cash-keyring-id',
73+
name: '',
74+
}),
75+
};
76+
77+
rootMessenger.registerActionHandler(
78+
'KeyringController:getState',
79+
mocks.getState,
80+
);
81+
rootMessenger.registerActionHandler(
82+
'KeyringController:getKeyringsByType',
83+
mocks.getKeyringsByType,
84+
);
85+
rootMessenger.registerActionHandler(
86+
'KeyringController:addNewKeyring',
87+
mocks.addNewKeyring,
88+
);
89+
90+
const service = new CashAccountService({ messenger });
91+
92+
return { service, rootMessenger, mocks };
93+
}
94+
95+
describe('CashAccountService', () => {
96+
describe('createCashAccount', () => {
97+
it('creates a Cash keyring from the HD keyring mnemonic', async () => {
98+
const { service, mocks } = setup();
99+
100+
const result = await service.createCashAccount(MOCK_ENTROPY_SOURCE);
101+
102+
expect(mocks.getKeyringsByType).toHaveBeenCalledWith(KeyringTypes.hd);
103+
expect(mocks.addNewKeyring).toHaveBeenCalledWith(KeyringTypes.cash, {
104+
mnemonic: MOCK_MNEMONIC,
105+
});
106+
expect(result).toStrictEqual({ id: 'new-cash-keyring-id', name: '' });
107+
});
108+
109+
it('is callable via the messenger', async () => {
110+
const { rootMessenger } = setup();
111+
112+
const result = await rootMessenger.call(
113+
'CashAccountService:createCashAccount',
114+
MOCK_ENTROPY_SOURCE,
115+
);
116+
117+
expect(result).toStrictEqual({ id: 'new-cash-keyring-id', name: '' });
118+
});
119+
120+
it('throws if no HD keyring matches the entropy source', async () => {
121+
const { service } = setup();
122+
123+
await expect(
124+
service.createCashAccount('nonexistent-entropy-source'),
125+
).rejects.toThrow(
126+
'No HD keyring found for entropy source: nonexistent-entropy-source',
127+
);
128+
});
129+
130+
it('throws if the HD keyring has no mnemonic', async () => {
131+
const { service, mocks } = setup();
132+
133+
mocks.getKeyringsByType.mockReturnValue([
134+
{ mnemonic: null } as unknown as HdKeyring,
135+
]);
136+
137+
await expect(
138+
service.createCashAccount(MOCK_ENTROPY_SOURCE),
139+
).rejects.toThrow(
140+
'HD keyring does not have a mnemonic for the given entropy source.',
141+
);
142+
});
143+
});
144+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { HdKeyring } from '@metamask/eth-hd-keyring';
2+
import { KeyringTypes } from '@metamask/keyring-controller';
3+
import type {
4+
KeyringMetadata,
5+
KeyringObject,
6+
} from '@metamask/keyring-controller';
7+
8+
import type { CashAccountServiceMessenger } from './types';
9+
10+
export const serviceName = 'CashAccountService';
11+
12+
const MESSENGER_EXPOSED_METHODS = ['createCashAccount'] as const;
13+
14+
export class CashAccountService {
15+
readonly #messenger: CashAccountServiceMessenger;
16+
17+
name: typeof serviceName = serviceName;
18+
19+
constructor({ messenger }: { messenger: CashAccountServiceMessenger }) {
20+
this.#messenger = messenger;
21+
22+
this.#messenger.registerMethodActionHandlers(
23+
this,
24+
MESSENGER_EXPOSED_METHODS,
25+
);
26+
}
27+
28+
/**
29+
* Creates a Cash keyring derived from the HD keyring identified by
30+
* the given entropy source, and returns the new keyring's metadata.
31+
*
32+
* @param entropySource - The metadata id of the HD keyring to derive from.
33+
* @returns The metadata of the newly created Cash keyring.
34+
*/
35+
async createCashAccount(entropySource: string): Promise<KeyringMetadata> {
36+
const { keyrings } = this.#messenger.call('KeyringController:getState');
37+
38+
const hdKeyringIndex = keyrings.findIndex(
39+
(kr: KeyringObject) =>
40+
kr.type === KeyringTypes.hd && kr.metadata.id === entropySource,
41+
);
42+
if (hdKeyringIndex === -1) {
43+
throw new Error(
44+
`No HD keyring found for entropy source: ${entropySource}`,
45+
);
46+
}
47+
48+
const hdKeyrings = this.#messenger.call(
49+
'KeyringController:getKeyringsByType',
50+
KeyringTypes.hd,
51+
) as HdKeyring[];
52+
53+
const hdKeyring = hdKeyrings[hdKeyringIndex];
54+
if (!hdKeyring?.mnemonic) {
55+
throw new Error(
56+
'HD keyring does not have a mnemonic for the given entropy source.',
57+
);
58+
}
59+
60+
return await this.#messenger.call(
61+
'KeyringController:addNewKeyring',
62+
KeyringTypes.cash,
63+
{ mnemonic: hdKeyring.mnemonic },
64+
);
65+
}
66+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export type {
2+
CashAccountServiceActions,
3+
CashAccountServiceMessenger,
4+
} from './types';
5+
export type {
6+
CashAccountServiceCreateCashAccountAction,
7+
CashAccountServiceMethodActions,
8+
} from './CashAccountService-method-action-types';
9+
export { CashAccountService } from './CashAccountService';
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type {
2+
KeyringControllerAddNewKeyringAction,
3+
KeyringControllerGetKeyringsByTypeAction,
4+
KeyringControllerGetStateAction,
5+
} from '@metamask/keyring-controller';
6+
import type { Messenger } from '@metamask/messenger';
7+
8+
import type { serviceName } from './CashAccountService';
9+
import type { CashAccountServiceMethodActions } from './CashAccountService-method-action-types';
10+
11+
export type CashAccountServiceActions = CashAccountServiceMethodActions;
12+
13+
type AllowedActions =
14+
| KeyringControllerGetStateAction
15+
| KeyringControllerGetKeyringsByTypeAction
16+
| KeyringControllerAddNewKeyringAction;
17+
18+
type AllowedEvents = never;
19+
20+
export type CashAccountServiceMessenger = Messenger<
21+
typeof serviceName,
22+
CashAccountServiceActions | AllowedActions,
23+
AllowedEvents
24+
>;

0 commit comments

Comments
 (0)