Skip to content
Merged
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
8 changes: 4 additions & 4 deletions packages/kyc-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Add `KycController.getCustomerIdentity()` method and the `KycController:getCustomerIdentity` messenger action (plus the exported `KycControllerGetCustomerIdentityAction` and `KycCustomerIdentity` types). Returns the vendor-scoped `{ vendor, id }` for the currently authenticated customer, or `null` before authentication and after `reset()`. Lets consumers (e.g. ramps autoramp creation) attach the vendor customer id to downstream calls without reading the full KYC state, which also holds session/access tokens. The id is session-scoped and never persisted.
- Add Iron (Money/VBA) KYC path to `@metamask/kyc-controller`: `vendor: 'iron'` skips MoonPay Check/Auth frames; `KycService` clients for `/vendors/iron/*`, `POST /consents`, and `GET /kyc/status`; `refreshKycStatus` + `statusChanged` for Money toast state ([#9852](https://github.com/MetaMask/core/pull/9852))
- `KycController:registerMoneyAccountWallet`, an address-only action that resolves the MoonPay customer, signs a Monad Money Account ownership message, and registers the wallet through the MetaMask neobank-proxy ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847))
- Internal wallet registration service and state machine support for `409` disambiguation, transient-failure reconciliation, UTC date rollover, and typed failures ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847))
- Targets transparent neobank routes (`GET /neobank/customers/{external_id}/external`, `GET /neobank/addresses/crypto/{customer_id}`, `POST /neobank/addresses/crypto/selfhosted`), client-side Monad filtering, `Idempotency-Key`, and upstream error bodies mirrored 1:1.
- Optional `neobankBaseUrl` on `KycService` so KYC and wallet registration can use different hosts.
- Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781))
- Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615))
- `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter.
Expand All @@ -25,4 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add UKYC session-status polling to `KycController`
- Add handling in `KycController.startSumSub` for applicants already being processed by the vendor

### Removed

- Move Money Account wallet registration to `@metamask/ramps-controller`: removes `KycController.registerMoneyAccountWallet`, the `KycService` wallet-registration methods (`getMoonpayCustomerId`, `getWalletRegistrationStatus`, `registerSelfHostedWallet`), the `neobankBaseUrl` service option, and the wallet registration exports (`WalletRegistrationError`, `SelfHostedRegistration`, `MoneyAccountWalletRegistrationResult`, and related types). Wallet ownership signing is a Money Movement (neobank-proxy) concern, so it now lives on `RampsController` / `NeoBankService`.

[Unreleased]: https://github.com/MetaMask/core/
1 change: 0 additions & 1 deletion packages/kyc-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@
"@metamask/base-data-service": "^0.1.3",
"@metamask/controller-utils": "^12.3.0",
"@metamask/geolocation-controller": "^1.0.0",
"@metamask/keyring-controller": "^27.1.1",
"@metamask/messenger": "^2.0.0",
"@metamask/profile-sync-controller": "^29.0.0",
"@metamask/superstruct": "^3.4.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,26 +215,6 @@ export type KycControllerGetSessionStatusAction = {
handler: KycController['getSessionStatus'];
};

/**
* Registers a Money Account wallet with MoonPay Iron via neobank-proxy.
*
* Consumers provide only the Monad address. The controller reuses the Iron
* customer id captured from MoonPay's hosted frame when available, otherwise
* it resolves the id via `GET /neobank/customers/{external_id}/external`
* (MetaMask canonical profile id). Customer resolution happens before the
* first list/lookup because list requires `customer_id` in the path.
* Message construction, signing, submission, and ambiguous-write
* reconciliation stay internal to KYC.
*
* @param params - Money Account wallet registration parameters.
* @param params.address - Monad Money Account address.
* @returns The successful registration state.
*/
export type KycControllerRegisterMoneyAccountWalletAction = {
type: `KycController:registerMoneyAccountWallet`;
handler: KycController['registerMoneyAccountWallet'];
};

/**
* Resets the flow to idle, clearing session tokens and sub-flow state while
* preserving persisted terms acceptance and the per-product cache.
Expand Down Expand Up @@ -263,5 +243,4 @@ export type KycControllerMethodActions =
| KycControllerStartSumSubAction
| KycControllerRefreshKycStatusAction
| KycControllerGetSessionStatusAction
| KycControllerRegisterMoneyAccountWalletAction
| KycControllerResetAction;
238 changes: 0 additions & 238 deletions packages/kyc-controller/src/KycController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import type { KycControllerMessenger } from './KycController.js';
import type { KycSumSubLauncher } from './types.js';
import { verifyJwtChain } from './ukyc/jwtChain.js';
import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js';
import { WalletRegistrationError } from './wallet-registration-service.js';

// `verifyJwtChain` (JWKS attestation) and `wrapEncryptionKey` (X25519 sealing)
// need a real signed chain / valid keys, so they are stubbed here; the rest of
Expand Down Expand Up @@ -1779,204 +1778,6 @@ describe('KycController', () => {
});
});

describe('registerMoneyAccountWallet', () => {
const registration = {
id: 'wallet-1',
address: '0xabc',
blockchain: 'Monad' as const,
disabled: false,
isSelf: true,
};

it('returns an existing active registration without signing', async () => {
await withController(async ({ controller, handlers }) => {
handlers.getWalletRegistrationStatus.mockResolvedValue({
type: 'active',
registration,
});

expect(
await controller.registerMoneyAccountWallet({ address: '0xabc' }),
).toStrictEqual({
type: 'alreadyRegistered',
registration,
});
expect(handlers.getMoonpayCustomerId).toHaveBeenCalledTimes(1);
expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({
customerId: 'iron-customer-fallback',
address: '0xabc',
});
expect(handlers.signPersonalMessage).not.toHaveBeenCalled();
});
});

it('returns an existing disabled registration without signing', async () => {
await withController(async ({ controller, handlers }) => {
handlers.getWalletRegistrationStatus.mockResolvedValue({
type: 'disabled',
registration: { ...registration, disabled: true },
});

expect(
await controller.registerMoneyAccountWallet({ address: '0xabc' }),
).toMatchObject({ type: 'registeredDisabled' });
expect(handlers.signPersonalMessage).not.toHaveBeenCalled();
});
});

it('prefers the customer id captured from the MoonPay frame', async () => {
await withController(
{ options: { state: { moonpayCustomerId: 'frame-customer' } } },
async ({ controller, handlers }) => {
expect(
await controller.registerMoneyAccountWallet({ address: '0xabc' }),
).toMatchObject({ type: 'registered' });

expect(handlers.getMoonpayCustomerId).not.toHaveBeenCalled();
expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({
customerId: 'frame-customer',
address: '0xabc',
});
expect(handlers.signPersonalMessage).toHaveBeenCalledWith({
data: expect.stringContaining('as customer frame-customer.'),
from: '0xabc',
});
expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith(
expect.objectContaining({
address: '0xabc',
customerId: 'frame-customer',
signature: '0xsig',
idempotencyKey: expect.any(String),
}),
);
},
);
});

it('falls back to resolving the customer id from the proxy before list', async () => {
await withController(async ({ controller, handlers }) => {
await controller.registerMoneyAccountWallet({ address: '0xabc' });

expect(handlers.getMoonpayCustomerId).toHaveBeenCalledTimes(1);
expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({
customerId: 'iron-customer-fallback',
address: '0xabc',
});
expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith(
expect.objectContaining({
customerId: 'iron-customer-fallback',
idempotencyKey: expect.any(String),
}),
);
});
});

it('reconciles an ambiguous conflict as already registered', async () => {
await withController(async ({ controller, handlers }) => {
handlers.getWalletRegistrationStatus
.mockResolvedValueOnce({ type: 'absent' })
.mockResolvedValueOnce({ type: 'active', registration });
handlers.registerSelfHostedWallet.mockRejectedValue(
new WalletRegistrationError('conflict', { httpStatus: 409 }),
);

expect(
await controller.registerMoneyAccountWallet({ address: '0xabc' }),
).toStrictEqual({
type: 'alreadyRegistered',
registration,
});
});
});

it('rethrows a transient failure when reconciliation remains absent', async () => {
await withController(async ({ controller, handlers }) => {
const error = new WalletRegistrationError('transient', {
httpStatus: 502,
});
handlers.registerSelfHostedWallet.mockRejectedValue(error);

await expect(
controller.registerMoneyAccountWallet({ address: '0xabc' }),
).rejects.toBe(error);
expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(4);
expect(handlers.registerSelfHostedWallet).toHaveBeenCalledTimes(3);
});
});

it('rebuilds and re-signs after a UTC date rollover', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-08-12T23:59:59.999Z'));
try {
await withController(async ({ controller, handlers }) => {
handlers.registerSelfHostedWallet
.mockImplementationOnce(async () => {
jest.setSystemTime(new Date('2026-08-13T00:00:00.000Z'));
throw new WalletRegistrationError('validation', {
httpStatus: 400,
});
})
.mockResolvedValueOnce({
type: 'registered',
registration,
});

await controller.registerMoneyAccountWallet({ address: '0xabc' });

expect(handlers.signPersonalMessage).toHaveBeenCalledTimes(2);
expect(handlers.signPersonalMessage.mock.calls[0][0].data).toContain(
'signed on 12/08/2026',
);
expect(handlers.signPersonalMessage.mock.calls[1][0].data).toContain(
'signed on 13/08/2026',
);
});
} finally {
jest.useRealTimers();
}
});

it.each([
new WalletRegistrationError('validation', { httpStatus: 400 }),
new WalletRegistrationError('rateLimited', { httpStatus: 429 }),
new WalletRegistrationError('unauthorized', { httpStatus: 401 }),
new Error('unexpected'),
])('rethrows terminal registration failure %#', async (error) => {
await withController(async ({ controller, handlers }) => {
handlers.registerSelfHostedWallet.mockRejectedValue(error);

await expect(
controller.registerMoneyAccountWallet({ address: '0xabc' }),
).rejects.toBe(error);
expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(1);
});
});

it('rethrows an initial lookup failure without signing', async () => {
await withController(async ({ controller, handlers }) => {
const error = new Error('lookup failed');
handlers.getWalletRegistrationStatus.mockRejectedValue(error);

await expect(
controller.registerMoneyAccountWallet({ address: '0xabc' }),
).rejects.toBe(error);
expect(handlers.signPersonalMessage).not.toHaveBeenCalled();
});
});

it('rethrows a signing failure without submitting', async () => {
await withController(async ({ controller, handlers }) => {
const error = new Error('signing failed');
handlers.signPersonalMessage.mockRejectedValue(error);

await expect(
controller.registerMoneyAccountWallet({ address: '0xabc' }),
).rejects.toBe(error);
expect(handlers.registerSelfHostedWallet).not.toHaveBeenCalled();
});
});
});

describe('reset', () => {
it('clears session state but preserves persisted terms', async () => {
await withController(
Expand Down Expand Up @@ -2699,10 +2500,6 @@ type ServiceHandlers = {
createUkycSession: jest.Mock;
createJourney: jest.Mock;
getSessionStatus: jest.Mock;
getMoonpayCustomerId: jest.Mock;
getWalletRegistrationStatus: jest.Mock;
registerSelfHostedWallet: jest.Mock;
signPersonalMessage: jest.Mock;
performGetStorage: jest.Mock;
performSetStorage: jest.Mock;
};
Expand Down Expand Up @@ -2738,10 +2535,6 @@ const SERVICE_ACTIONS = [
'KycService:createUkycSession',
'KycService:createJourney',
'KycService:getSessionStatus',
'KycService:getMoonpayCustomerId',
'KycService:getWalletRegistrationStatus',
'KycService:registerSelfHostedWallet',
'KeyringController:signPersonalMessage',
'UserStorageController:performGetStorage',
'UserStorageController:performSetStorage',
] as const;
Expand Down Expand Up @@ -2826,21 +2619,6 @@ function withController<ReturnValue>(
.fn()
.mockResolvedValue({ status: 'ok', applicantAccessToken: 'aat' }),
getSessionStatus: jest.fn().mockResolvedValue(sessionStatus('approved')),
getMoonpayCustomerId: jest.fn().mockResolvedValue('iron-customer-fallback'),
getWalletRegistrationStatus: jest
.fn()
.mockResolvedValue({ type: 'absent' }),
registerSelfHostedWallet: jest.fn().mockResolvedValue({
type: 'registered',
registration: {
id: 'wallet-1',
address: '0xabc',
blockchain: 'Monad',
disabled: false,
isSelf: true,
},
}),
signPersonalMessage: jest.fn().mockResolvedValue('0xsig'),
performGetStorage: jest.fn().mockResolvedValue(null),
performSetStorage: jest.fn().mockResolvedValue(undefined),
};
Expand Down Expand Up @@ -2900,22 +2678,6 @@ function withController<ReturnValue>(
'KycService:getSessionStatus',
handlers.getSessionStatus,
);
rootMessenger.registerActionHandler(
'KycService:getMoonpayCustomerId',
handlers.getMoonpayCustomerId,
);
rootMessenger.registerActionHandler(
'KycService:getWalletRegistrationStatus',
handlers.getWalletRegistrationStatus,
);
rootMessenger.registerActionHandler(
'KycService:registerSelfHostedWallet',
handlers.registerSelfHostedWallet,
);
rootMessenger.registerActionHandler(
'KeyringController:signPersonalMessage',
handlers.signPersonalMessage,
);
rootMessenger.registerActionHandler(
'UserStorageController:performGetStorage',
handlers.performGetStorage,
Expand Down
Loading
Loading