From a140c3173e6f3f67a41308ed2ad7ce3f7429d5db Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 13 Aug 2026 00:44:18 -0600 Subject: [PATCH] refactor(ramps): move Money Account wallet signing from kyc to ramps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wallet ownership signing is a Money Movement / neobank-proxy concern, not KYC. Moving it into ramps-controller also removes the ramps→kyc package dependency that broke the monorepo project-reference build and blocked publish-preview for core PR #9853. Co-authored-by: Cursor --- packages/kyc-controller/CHANGELOG.md | 8 +- packages/kyc-controller/package.json | 1 - .../src/KycController-method-action-types.ts | 21 -- .../kyc-controller/src/KycController.test.ts | 238 --------------- packages/kyc-controller/src/KycController.ts | 206 +------------ .../src/KycService-method-action-types.ts | 39 --- .../kyc-controller/src/KycService.test.ts | 104 ------- packages/kyc-controller/src/KycService.ts | 118 +------- packages/kyc-controller/src/index.test.ts | 1 - packages/kyc-controller/src/index.ts | 13 - packages/kyc-controller/tsconfig.build.json | 1 - packages/kyc-controller/tsconfig.json | 1 - packages/ramps-controller/CHANGELOG.md | 5 +- packages/ramps-controller/package.json | 1 - .../src/NeoBankService-method-action-types.ts | 42 ++- .../src/NeoBankService.test.ts | 113 ++++++- .../ramps-controller/src/NeoBankService.ts | 118 +++++++- .../RampsController-method-action-types.ts | 29 +- .../src/RampsController.test.ts | 279 +++++++++++++++++- .../ramps-controller/src/RampsController.ts | 236 ++++++++++++++- packages/ramps-controller/src/index.ts | 19 ++ .../src/ownership-message.test.ts | 0 .../src/ownership-message.ts | 0 .../src/wallet-registration-machine.test.ts | 0 .../src/wallet-registration-machine.ts | 0 .../src/wallet-registration-service.test.ts | 0 .../src/wallet-registration-service.ts | 0 packages/ramps-controller/tsconfig.build.json | 3 - packages/ramps-controller/tsconfig.json | 3 - yarn.lock | 4 +- 30 files changed, 834 insertions(+), 769 deletions(-) rename packages/{kyc-controller => ramps-controller}/src/ownership-message.test.ts (100%) rename packages/{kyc-controller => ramps-controller}/src/ownership-message.ts (100%) rename packages/{kyc-controller => ramps-controller}/src/wallet-registration-machine.test.ts (100%) rename packages/{kyc-controller => ramps-controller}/src/wallet-registration-machine.ts (100%) rename packages/{kyc-controller => ramps-controller}/src/wallet-registration-service.test.ts (100%) rename packages/{kyc-controller => ramps-controller}/src/wallet-registration-service.ts (100%) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index ec6129b1a94..c661ce10e1b 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -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. @@ -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/ diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index 22967e56612..5b0c23f7fff 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -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", diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 4a837f24527..b2aa85cca42 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -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. @@ -263,5 +243,4 @@ export type KycControllerMethodActions = | KycControllerStartSumSubAction | KycControllerRefreshKycStatusAction | KycControllerGetSessionStatusAction - | KycControllerRegisterMoneyAccountWalletAction | KycControllerResetAction; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 34dcb0edb92..aef1d4f36e9 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -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 @@ -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( @@ -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; }; @@ -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; @@ -2826,21 +2619,6 @@ function withController( .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), }; @@ -2900,22 +2678,6 @@ function withController( '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, diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index b9dfc3b2b14..29e7f21e12e 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -4,13 +4,12 @@ import type { StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import type { KeyringControllerSignPersonalMessageAction } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { UserStorageControllerPerformGetStorageAction, UserStorageControllerPerformSetStorageAction, } from '@metamask/profile-sync-controller/user-storage'; -import type { Hex, Json } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; import { x25519 } from '@noble/curves/ed25519'; import { decryptCredentials, generateKeyPair } from './crypto.js'; @@ -18,7 +17,6 @@ import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto.js'; import { toBase64Url } from './encoding.js'; import type { KycControllerMethodActions } from './KycController-method-action-types.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; -import { buildOwnershipMessage } from './ownership-message.js'; import type { KycCustomerIdentity, KycDisclaimer, @@ -36,18 +34,6 @@ import { getOrCreateLocalUserSecret } from './ukyc/localUserSecret.js'; import type { UkycLocalUserSecretStore } from './ukyc/localUserSecret.js'; import { signStorageAccessToken } from './ukyc/storageAccessToken.js'; import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; -import { - createInitialState, - transition as transitionWalletRegistration, -} from './wallet-registration-machine.js'; -import { - createIdempotencyKey, - WalletRegistrationError, -} from './wallet-registration-service.js'; -import type { - RegistrationStatus, - SelfHostedRegistration, -} from './wallet-registration-service.js'; // === GENERAL === @@ -394,7 +380,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'refreshKycStatus', 'startSumSub', 'getSessionStatus', - 'registerMoneyAccountWallet', 'reset', ] as const; @@ -409,7 +394,6 @@ export type KycControllerActions = type AllowedActions = | KycServiceMethodActions - | KeyringControllerSignPersonalMessageAction | UserStorageControllerPerformGetStorageAction | UserStorageControllerPerformSetStorageAction; @@ -469,16 +453,6 @@ export type KycControllerOptions = { userStatusPollIntervalMs?: number; }; -export type MoneyAccountWalletRegistrationResult = - | { - type: 'registered' | 'alreadyRegistered'; - registration: SelfHostedRegistration; - } - | { - type: 'registeredDisabled'; - registration: SelfHostedRegistration; - }; - /** * The shape of a message posted by a Check/Auth frame. */ @@ -1840,184 +1814,6 @@ export class KycController extends BaseController< } } - /** - * 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. - */ - async registerMoneyAccountWallet({ - address, - }: { - address: Hex; - }): Promise { - let machine = transitionWalletRegistration(createInitialState(), { - type: 'START', - }); - - const toExistingResult = ( - status: RegistrationStatus, - ): MoneyAccountWalletRegistrationResult | undefined => { - if (status.type === 'active') { - return { type: 'alreadyRegistered', registration: status.registration }; - } - if (status.type === 'disabled') { - return { - type: 'registeredDisabled', - registration: status.registration, - }; - } - return undefined; - }; - - // List requires customer_id in the neobank path, so resolve Iron's id - // before the first lookup. Prefer the ephemeral frame-captured value. - const customerId = - this.state.moonpayCustomerId ?? - (await this.messenger.call('KycService:getMoonpayCustomerId')); - - const lookup = async (): Promise => { - try { - return await this.messenger.call( - 'KycService:getWalletRegistrationStatus', - { customerId, address }, - ); - } catch (error) { - machine = transitionWalletRegistration(machine, { - type: 'LOOKUP_FAILED', - }); - throw error; - } - }; - - const applyLookup = ( - status: RegistrationStatus, - ): MoneyAccountWalletRegistrationResult | undefined => { - let eventType: 'LOOKUP_ACTIVE' | 'LOOKUP_DISABLED' | 'LOOKUP_ABSENT' = - 'LOOKUP_ABSENT'; - if (status.type === 'active') { - eventType = 'LOOKUP_ACTIVE'; - } else if (status.type === 'disabled') { - eventType = 'LOOKUP_DISABLED'; - } - machine = transitionWalletRegistration(machine, { - type: eventType, - }); - return toExistingResult(status); - }; - - const existingStatus = await lookup(); - const existingResult = applyLookup(existingStatus); - if (existingResult) { - return existingResult; - } - - // Stable across transient retries of the same ownership proof; refreshed - // when the UTC-dated message must be rebuilt and re-signed. - let idempotencyKey = createIdempotencyKey(); - let lastMessage: string | undefined; - - while (true) { - const message = buildOwnershipMessage({ - address, - customerId, - now: new Date(), - }); - if (lastMessage !== undefined && message !== lastMessage) { - idempotencyKey = createIdempotencyKey(); - } - lastMessage = message; - - let signature: string; - try { - signature = await this.messenger.call( - 'KeyringController:signPersonalMessage', - { data: message, from: address }, - ); - machine = transitionWalletRegistration(machine, { type: 'SIGN_OK' }); - } catch (error) { - machine = transitionWalletRegistration(machine, { - type: 'SIGN_FAILED', - retryable: false, - }); - throw error; - } - - try { - const result = await this.messenger.call( - 'KycService:registerSelfHostedWallet', - { - address, - customerId, - message, - signature, - idempotencyKey, - }, - ); - machine = transitionWalletRegistration(machine, { type: 'SUBMIT_OK' }); - return result; - } catch (error) { - if (!(error instanceof WalletRegistrationError)) { - machine = transitionWalletRegistration(machine, { - type: 'SUBMIT_TERMINAL', - }); - throw error; - } - - if (error.kind === 'conflict') { - machine = transitionWalletRegistration(machine, { - type: 'SUBMIT_CONFLICT', - }); - } else if (error.kind === 'transient') { - machine = transitionWalletRegistration(machine, { - type: 'SUBMIT_TRANSIENT', - }); - } else if (error.kind === 'validation') { - machine = transitionWalletRegistration(machine, { - type: 'SUBMIT_VALIDATION', - utcRollover: - buildOwnershipMessage({ - address, - customerId, - now: new Date(), - }) !== message, - }); - } else if (error.kind === 'rateLimited') { - machine = transitionWalletRegistration(machine, { - type: 'SUBMIT_RATE_LIMITED', - }); - } else { - machine = transitionWalletRegistration(machine, { - type: 'SUBMIT_TERMINAL', - }); - } - - if ( - machine.status === 'disambiguate409' || - machine.status === 'checkThenRetry' - ) { - const reconciledResult = applyLookup(await lookup()); - if (reconciledResult) { - return reconciledResult; - } - } - - if (machine.status !== 'signing') { - throw error; - } - } - } - } - /** * Resets the flow to idle, clearing session tokens and sub-flow state while * preserving persisted terms acceptance and the per-product cache. diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index e5458df0d95..7f38f86aa14 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -17,42 +17,6 @@ export type KycServiceGetGeoCountryAction = { handler: KycService['getGeoCountry']; }; -/** - * Resolves Iron's internal customer id via neobank-proxy customer lookup, - * using the MetaMask canonical profile id as the partner `external_id`. - * - * @returns Iron's internal customer id. - */ -export type KycServiceGetMoonpayCustomerIdAction = { - type: `KycService:getMoonpayCustomerId`; - handler: KycService['getMoonpayCustomerId']; -}; - -/** - * Checks whether a Monad Money Account address is already registered for the - * given Iron customer. - * - * @param params - Customer id and address to check. - * @param params.customerId - Iron / MoonPay customer UUID. - * @param params.address - Money Account address. - * @returns Active, disabled, or absent registration status. - */ -export type KycServiceGetWalletRegistrationStatusAction = { - type: `KycService:getWalletRegistrationStatus`; - handler: KycService['getWalletRegistrationStatus']; -}; - -/** - * Submits a signed Monad Money Account ownership proof. - * - * @param params - Signed ownership proof. - * @returns Registered wallet record. - */ -export type KycServiceRegisterSelfHostedWalletAction = { - type: `KycService:registerSelfHostedWallet`; - handler: KycService['registerSelfHostedWallet']; -}; - /** * Fetches the disclaimers the customer must accept before a session is * created. @@ -226,9 +190,6 @@ export type KycServiceGetSessionStatusAction = { */ export type KycServiceMethodActions = | KycServiceGetGeoCountryAction - | KycServiceGetMoonpayCustomerIdAction - | KycServiceGetWalletRegistrationStatusAction - | KycServiceRegisterSelfHostedWalletAction | KycServiceFetchDisclaimersAction | KycServiceCreateSessionAction | KycServiceCheckKycRequiredAction diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index efe4c4ad95d..c7d5d6c9db9 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -59,92 +59,6 @@ describe('KycService', () => { }); }); - describe('Money Account wallet registration', () => { - it('resolves the Iron customer id via neobank customer lookup', async () => { - nock(MOCK_API_URL) - .get('/neobank/customers/canonical-profile-1/external') - .matchHeader('authorization', 'Bearer test-bearer') - .reply(200, { - id: 'iron-customer-1', - external_id: 'canonical-profile-1', - }); - - const { service } = getService(); - - expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); - }); - - it('checks Monad wallet registration status for a customer', async () => { - nock(MOCK_API_URL) - .get('/neobank/addresses/crypto/iron-customer-1') - .query({ filter: 'SelfHosted' }) - .reply(200, []); - - const { service } = getService(); - - expect( - await service.getWalletRegistrationStatus({ - customerId: 'iron-customer-1', - address: '0xabc', - }), - ).toStrictEqual({ type: 'absent' }); - }); - - it('submits a signed Monad wallet ownership proof with Idempotency-Key', async () => { - nock(MOCK_API_URL) - .post( - '/neobank/addresses/crypto/selfhosted', - { - customer_id: 'iron-customer-1', - address: '0xabc', - blockchain: 'Monad', - message: 'ownership message', - signature: '0xsig', - }, - { reqheaders: { 'idempotency-key': 'idem-1' } }, - ) - .reply(200, { - id: 'wallet-1', - address: '0xabc', - disabled: false, - }); - - const { service } = getService(); - - expect( - await service.registerSelfHostedWallet({ - customerId: 'iron-customer-1', - address: '0xabc', - message: 'ownership message', - signature: '0xsig', - idempotencyKey: 'idem-1', - }), - ).toMatchObject({ - type: 'registered', - registration: { id: 'wallet-1', blockchain: 'Monad' }, - }); - }); - - it('uses neobankBaseUrl when provided for wallet routes', async () => { - const neobankUrl = 'https://on-ramp.dev-api.cx.metamask.io'; - nock(neobankUrl) - .get('/neobank/customers/canonical-profile-1/external') - .reply(200, { id: 'iron-customer-1' }); - - const { service } = getService({ neobankBaseUrl: neobankUrl }); - - expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); - }); - - it('throws when the session profile has no usable external id', async () => { - const { service } = getService({ canonicalProfileId: '' }); - - await expect(service.getMoonpayCustomerId()).rejects.toThrow( - /Unable to resolve MetaMask canonical profile id/u, - ); - }); - }); - describe('fetchDisclaimers', () => { it('returns the disclaimers for a country', async () => { const disclaimers = [ @@ -837,11 +751,8 @@ type RootMessenger = Messenger< * @param args.geolocation - The location the geolocation handler returns. * @param args.defaultPolicy - When true, omit `policyOptions` to use defaults. * @param args.baseUrl - Base URL of the KYC API. - * @param args.neobankBaseUrl - Optional on-ramp / neobank-proxy base URL. * @param args.fractalEncryptionBaseUrl - Fractal base URL; `null` omits the * option so the service falls back to an empty string. - * @param args.canonicalProfileId - Canonical profile id returned by - * `AuthenticationController:getSessionProfile`. * @returns The service, root messenger, and service messenger. */ function getService({ @@ -849,19 +760,15 @@ function getService({ geolocation = 'US-NY', defaultPolicy = false, baseUrl = MOCK_API_URL, - neobankBaseUrl, // `null` means "omit the option entirely" (exercises the constructor's // `?? ''` fallback); omitting the field defaults to the mock Fractal URL. fractalEncryptionBaseUrl = MOCK_FRACTAL_URL, - canonicalProfileId = 'canonical-profile-1', }: { bearerToken?: string; geolocation?: string | null; defaultPolicy?: boolean; baseUrl?: string; - neobankBaseUrl?: string; fractalEncryptionBaseUrl?: string | null; - canonicalProfileId?: string; } = {}): { service: KycService; rootMessenger: RootMessenger; @@ -877,7 +784,6 @@ function getService({ rootMessenger.delegate({ actions: [ 'AuthenticationController:getBearerToken', - 'AuthenticationController:getSessionProfile', 'GeolocationController:getGeolocation', ], events: [], @@ -887,15 +793,6 @@ function getService({ 'AuthenticationController:getBearerToken', async () => bearerToken, ); - rootMessenger.registerActionHandler( - 'AuthenticationController:getSessionProfile', - async () => ({ - identifierId: 'id-1', - profileId: canonicalProfileId, - canonicalProfileId, - metaMetricsId: 'mm-1', - }), - ); rootMessenger.registerActionHandler( 'GeolocationController:getGeolocation', async () => geolocation as string, @@ -905,7 +802,6 @@ function getService({ fetch, messenger, baseUrl, - ...(neobankBaseUrl === undefined ? {} : { neobankBaseUrl }), ...(fractalEncryptionBaseUrl === null ? {} : { fractalEncryptionBaseUrl }), ...(defaultPolicy ? {} : { policyOptions: { maxRetries: 0 } }), }); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 624b88de456..89201eb63a0 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -8,10 +8,7 @@ import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; import { HttpError } from '@metamask/controller-utils'; import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; import type { Messenger } from '@metamask/messenger'; -import type { - AuthenticationControllerGetBearerTokenAction, - AuthenticationControllerGetSessionProfileAction, -} from '@metamask/profile-sync-controller/auth'; +import type { AuthenticationControllerGetBearerTokenAction } from '@metamask/profile-sync-controller/auth'; import type { Infer, Struct } from '@metamask/superstruct'; import { array, @@ -38,11 +35,6 @@ import type { import { UKYC_JWKS_PATH } from './ukyc/constants.js'; import { encodeStorageAccessTokenForHeader } from './ukyc/storageAccessToken.js'; import type { UkycStorageAccessToken } from './ukyc/storageAccessToken.js'; -import { WalletRegistrationService } from './wallet-registration-service.js'; -import type { - RegistrationOutcome, - RegistrationStatus, -} from './wallet-registration-service.js'; // === GENERAL === @@ -68,9 +60,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'createUkycSession', 'createJourney', 'getSessionStatus', - 'getMoonpayCustomerId', - 'getWalletRegistrationStatus', - 'registerSelfHostedWallet', ] as const; /** @@ -91,7 +80,6 @@ export type KycServiceActions = */ type AllowedActions = | AuthenticationControllerGetBearerTokenAction - | AuthenticationControllerGetSessionProfileAction | GeolocationControllerGetGeolocationAction; /** @@ -139,13 +127,6 @@ export type KycServiceOptions = { * Mandatory value that sets the base url to KYC api */ baseUrl: string; - /** - * Base URL of the on-ramp / neobank-proxy host used for Money Account wallet - * registration (e.g. `https://on-ramp.dev-api.cx.metamask.io`). Paths are - * under `/neobank`. When omitted, falls back to {@link baseUrl} so local - * tests can target a single mock host. - */ - neobankBaseUrl?: string; /** * Base URL of the Fractal encryption service, from which the JWKS used to * verify the `jwtChain` returned by {@link KycService.getWrappingKey} is @@ -322,23 +303,6 @@ export type GetSessionStatusParams = { sessionId: string; }; -export type GetWalletRegistrationStatusParams = { - customerId: string; - address: string; -}; - -export type RegisterSelfHostedWalletParams = { - customerId: string; - address: string; - message: string; - signature: string; - /** - * Forwarded as `Idempotency-Key` on the neobank-proxy POST. Prefer a stable - * key across retries of the same ownership body. - */ - idempotencyKey?: string; -}; - // === SERVICE DEFINITION === /** @@ -364,8 +328,6 @@ export class KycService extends BaseDataService< readonly #fractalEncryptionBaseUrl: string; - readonly #walletRegistrationService: WalletRegistrationService; - /** * Constructs a new KycService. * @@ -373,8 +335,6 @@ export class KycService extends BaseDataService< * @param options.messenger - The messenger suited for this service. * @param options.fetch - A function used to make HTTP requests. * @param options.baseUrl - Base URL of the KYC API - * @param options.neobankBaseUrl - Base URL of the neobank-proxy host for - * wallet registration. Defaults to `baseUrl` when omitted. * @param options.fractalEncryptionBaseUrl - Base URL of the Fractal * encryption service, from which the JWKS used to verify the wrapping-key * `jwtChain` is fetched. @@ -386,7 +346,6 @@ export class KycService extends BaseDataService< messenger, fetch: fetchFunction, baseUrl, - neobankBaseUrl, fractalEncryptionBaseUrl, queryClientConfig = {}, policyOptions = {}, @@ -403,13 +362,6 @@ export class KycService extends BaseDataService< } this.#baseUrl = baseUrl; this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl ?? ''; - this.#walletRegistrationService = new WalletRegistrationService({ - fetch: fetchFunction, - baseUrl: neobankBaseUrl ?? baseUrl, - getAuthToken: async (): Promise => this.#getBearerToken(), - getExternalId: async (): Promise => - this.#getCanonicalExternalId(), - }); this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, @@ -449,51 +401,6 @@ export class KycService extends BaseDataService< return alpha3; } - /** - * Resolves Iron's internal customer id via neobank-proxy customer lookup, - * using the MetaMask canonical profile id as the partner `external_id`. - * - * @returns Iron's internal customer id. - */ - async getMoonpayCustomerId(): Promise { - return await this.#walletRegistrationService.getMoonpayCustomerId(); - } - - /** - * Checks whether a Monad Money Account address is already registered for the - * given Iron customer. - * - * @param params - Customer id and address to check. - * @param params.customerId - Iron / MoonPay customer UUID. - * @param params.address - Money Account address. - * @returns Active, disabled, or absent registration status. - */ - async getWalletRegistrationStatus({ - customerId, - address, - }: GetWalletRegistrationStatusParams): Promise { - return await this.#walletRegistrationService.getRegistrationStatus({ - customerId, - address, - blockchain: 'Monad', - }); - } - - /** - * Submits a signed Monad Money Account ownership proof. - * - * @param params - Signed ownership proof. - * @returns Registered wallet record. - */ - async registerSelfHostedWallet( - params: RegisterSelfHostedWalletParams, - ): Promise { - return await this.#walletRegistrationService.registerSelfHostedWallet({ - ...params, - blockchain: 'Monad', - }); - } - /** * Fetches the disclaimers the customer must accept before a session is * created. @@ -946,29 +853,6 @@ export class KycService extends BaseDataService< return bearerToken; } - /** - * Resolves the MetaMask canonical profile id used as MoonPay's partner - * `external_id` for neobank customer lookup. - * - * @returns Canonical profile id. - */ - async #getCanonicalExternalId(): Promise { - const profile = await this.messenger.call( - 'AuthenticationController:getSessionProfile', - ); - const canonical = profile?.canonicalProfileId; - const externalId = - typeof canonical === 'string' && canonical.length > 0 - ? canonical - : profile?.profileId; - if (typeof externalId !== 'string' || externalId.length === 0) { - throw new Error( - 'Unable to resolve MetaMask canonical profile id for MoonPay customer lookup', - ); - } - return externalId; - } - /** * Performs a single JSON request. * diff --git a/packages/kyc-controller/src/index.test.ts b/packages/kyc-controller/src/index.test.ts index 3bbd3b4c258..f986f8847a4 100644 --- a/packages/kyc-controller/src/index.test.ts +++ b/packages/kyc-controller/src/index.test.ts @@ -12,7 +12,6 @@ describe('@metamask/kyc-controller', () => { alpha2ToAlpha3: expect.any(Function), generateKeyPair: expect.any(Function), decryptCredentials: expect.any(Function), - WalletRegistrationError: expect.any(Function), controllerName: 'KycController', serviceName: 'KycService', }); diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 3bd8e5778f6..d6b24b3730a 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -8,7 +8,6 @@ export type { KycControllerEvents, KycControllerGetStateAction, KycControllerMessenger, - MoneyAccountWalletRegistrationResult, KycControllerOptions, KycControllerState, KycControllerStateChangeEvent, @@ -30,7 +29,6 @@ export type { KycControllerLoadDisclaimersAction, KycControllerRefreshKycStatusAction, KycControllerResetAction, - KycControllerRegisterMoneyAccountWalletAction, KycControllerStartSumSubAction, } from './KycController-method-action-types.js'; @@ -42,7 +40,6 @@ export type { CreateSessionParams, CreateUkycSessionParams, GetSessionStatusParams, - GetWalletRegistrationStatusParams, GetWrappingKeyParams, IronCustomerResponse, JwksResponse, @@ -53,7 +50,6 @@ export type { KycServiceInvalidateQueriesAction, KycServiceMessenger, KycServiceOptions, - RegisterSelfHostedWalletParams, SubmitConsentsParams, UkycSessionResponse, WrappedEncryptionKey, @@ -71,11 +67,8 @@ export type { KycServiceFetchJwksAction, KycServiceFetchKycStatusAction, KycServiceGetGeoCountryAction, - KycServiceGetMoonpayCustomerIdAction, KycServiceGetSessionStatusAction, - KycServiceGetWalletRegistrationStatusAction, KycServiceGetWrappingKeyAction, - KycServiceRegisterSelfHostedWalletAction, KycServiceSubmitConsentsAction, } from './KycService-method-action-types.js'; @@ -141,9 +134,3 @@ export type { MintedUkycTestToken, MintUkycTestTokenParams, } from './ukyc/testToken.js'; - -export type { - SelfHostedRegistration, - WalletRegistrationErrorKind, -} from './wallet-registration-service.js'; -export { WalletRegistrationError } from './wallet-registration-service.js'; diff --git a/packages/kyc-controller/tsconfig.build.json b/packages/kyc-controller/tsconfig.build.json index 6f6e3a6ef0d..d355169e16c 100644 --- a/packages/kyc-controller/tsconfig.build.json +++ b/packages/kyc-controller/tsconfig.build.json @@ -10,7 +10,6 @@ { "path": "../base-data-service/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, { "path": "../geolocation-controller/tsconfig.build.json" }, - { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, { "path": "../profile-sync-controller/tsconfig.build.json" } ], diff --git a/packages/kyc-controller/tsconfig.json b/packages/kyc-controller/tsconfig.json index 34495606a6a..1079229158f 100644 --- a/packages/kyc-controller/tsconfig.json +++ b/packages/kyc-controller/tsconfig.json @@ -8,7 +8,6 @@ { "path": "../base-data-service" }, { "path": "../controller-utils" }, { "path": "../geolocation-controller" }, - { "path": "../keyring-controller" }, { "path": "../messenger" }, { "path": "../profile-sync-controller" } ], diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index e14984e756a..324e864aa1a 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -13,10 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add the exported `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` constant listing the other-controller actions (`KycController:getCustomerIdentity`) that hosts must delegate to the `RampsController` messenger to enable autoramp creation. - Add NeoBankService Pix / autoramp quote client methods and messenger actions, targeting the neobank-proxy `/neobank` prefix on the Ramp API host: `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, and `getCustomerByExternalId`. Pix/quote helpers return parsed proxy JSON; `createAutoramp` maps autoramp-shaped responses via `mapNeoBankAutorampToRemoteSnapshot` (same as `getAutoramp`). Optional `Idempotency-Key` is supported on mutating calls. - Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679)) +- Add `RampsController.registerMoneyAccountWallet({ address })` method and the `RampsController:registerMoneyAccountWallet` messenger action (moved from `@metamask/kyc-controller`). Resolves the MoonPay Iron customer id (KYC session identity when available, otherwise the neobank-proxy external-id lookup), signs the Monad ownership message via `KeyringController:signPersonalMessage`, and registers the self-hosted wallet through the neobank-proxy — including `409` disambiguation, transient-failure reconciliation, and UTC date rollover re-signing ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847)) +- Add `NeoBankService.getMoonpayCustomerId`, `NeoBankService.getWalletRegistrationStatus`, and `NeoBankService.registerSelfHostedWallet` methods and messenger actions, targeting the transparent neobank routes (`GET /neobank/customers/{external_id}/external`, `GET /neobank/addresses/crypto/{customer_id}`, `POST /neobank/addresses/crypto/selfhosted`) with client-side Monad filtering, `Idempotency-Key` support, and upstream error bodies mirrored 1:1. +- Export the wallet registration types (`SelfHostedRegistration`, `RegistrationStatus`, `RegistrationOutcome`, `WalletRegistrationError`, `WalletRegistrationErrorKind`, `MoneyAccountWalletRegistrationResult`) and `buildOwnershipMessage` (moved from `@metamask/kyc-controller`). ### Changed -- Add a dependency on `@metamask/kyc-controller` so `RampsController` can resolve the vendor customer identity when creating autoramps. +- Resolve the vendor customer identity for autoramps via a locally declared `KycController:getCustomerIdentity` messenger action type instead of a package dependency on `@metamask/kyc-controller`, keeping the ramps↔kyc packages decoupled in the monorepo build. - Point `NeoBankService.getAutoramp` at `GET /neobank/autoramps/{id}` (neobank-proxy global `/neobank` prefix) instead of `/api/v2/autoramps/{id}`, so Core matches the proxy that ships. ## [20.0.0] diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index 296d1c41405..36b5fafa5e8 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -58,7 +58,6 @@ "dependencies": { "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.3.0", - "@metamask/kyc-controller": "^0.0.0", "@metamask/messenger": "^2.0.0", "@metamask/profile-sync-controller": "^29.0.0", "@metamask/remote-feature-flag-controller": "^5.0.0" diff --git a/packages/ramps-controller/src/NeoBankService-method-action-types.ts b/packages/ramps-controller/src/NeoBankService-method-action-types.ts index 3343ed3ba87..956b7f2b6b4 100644 --- a/packages/ramps-controller/src/NeoBankService-method-action-types.ts +++ b/packages/ramps-controller/src/NeoBankService-method-action-types.ts @@ -94,6 +94,43 @@ export type NeoBankServiceGetCustomerByExternalIdAction = { handler: NeoBankService['getCustomerByExternalId']; }; +/** + * Resolves Iron's internal customer id via neobank-proxy customer lookup, + * using the MetaMask canonical profile id as the partner `external_id`. + * + * @returns Iron's internal customer id. + */ +export type NeoBankServiceGetMoonpayCustomerIdAction = { + type: `NeoBankService:getMoonpayCustomerId`; + handler: NeoBankService['getMoonpayCustomerId']; +}; + +/** + * Checks whether a Monad Money Account address is already registered for the + * given Iron customer. + * + * @param params - Customer id and address to check. + * @param params.customerId - Iron / MoonPay customer UUID. + * @param params.address - Money Account address. + * @returns Active, disabled, or absent registration status. + */ +export type NeoBankServiceGetWalletRegistrationStatusAction = { + type: `NeoBankService:getWalletRegistrationStatus`; + handler: NeoBankService['getWalletRegistrationStatus']; +}; + +/** + * Submits a signed Monad Money Account ownership proof via neobank-proxy + * `POST /neobank/addresses/crypto/selfhosted`. + * + * @param params - Signed ownership proof. + * @returns Registered wallet record. + */ +export type NeoBankServiceRegisterSelfHostedWalletAction = { + type: `NeoBankService:registerSelfHostedWallet`; + handler: NeoBankService['registerSelfHostedWallet']; +}; + /** * Union of all NeoBankService action types. */ @@ -104,4 +141,7 @@ export type NeoBankServiceMethodActions = | NeoBankServiceCreateAutorampAction | NeoBankServiceGetAutorampQuoteForAutorampAction | NeoBankServiceAttachAutorampQuoteAction - | NeoBankServiceGetCustomerByExternalIdAction; + | NeoBankServiceGetCustomerByExternalIdAction + | NeoBankServiceGetMoonpayCustomerIdAction + | NeoBankServiceGetWalletRegistrationStatusAction + | NeoBankServiceRegisterSelfHostedWalletAction; diff --git a/packages/ramps-controller/src/NeoBankService.test.ts b/packages/ramps-controller/src/NeoBankService.test.ts index c2f40d582ac..e30812e3460 100644 --- a/packages/ramps-controller/src/NeoBankService.test.ts +++ b/packages/ramps-controller/src/NeoBankService.test.ts @@ -14,14 +14,20 @@ const STAGING_BASE = 'https://on-ramp.uat-api.cx.metamask.io'; /** * Builds a NeoBankService with AuthenticationController bearer auth stubbed. * - * @param options - Optional constructor overrides. Pass `omitDefaults: true` to - * exercise constructor defaulted parameters (`environment`, `policyOptions`). + * @param options - Optional constructor overrides. + * @param options.environment - Ramp environment for host selection. + * @param options.baseUrlOverride - Overrides the environment-derived host. + * @param options.omitDefaults - Pass `true` to exercise constructor defaulted + * parameters (`environment`, `policyOptions`). + * @param options.canonicalProfileId - Canonical profile id returned by the + * stubbed `AuthenticationController:getSessionProfile` (wallet registration). * @returns Service instance for the test. */ function createService(options?: { environment?: RampsEnvironment; baseUrlOverride?: string; omitDefaults?: boolean; + canonicalProfileId?: string; }): NeoBankService { const rootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE as MockAnyNamespace, @@ -30,6 +36,18 @@ function createService(options?: { 'AuthenticationController:getBearerToken', async () => 'test-token', ); + const canonicalProfileId = + options?.canonicalProfileId ?? 'canonical-profile-1'; + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: canonicalProfileId, + canonicalProfileId, + metaMetricsId: 'mm-1', + }) as never, + ); const messenger = new Messenger({ namespace: 'NeoBankService', @@ -37,7 +55,10 @@ function createService(options?: { }) as unknown as NeoBankServiceMessenger; rootMessenger.delegate({ messenger, - actions: ['AuthenticationController:getBearerToken'], + actions: [ + 'AuthenticationController:getBearerToken', + 'AuthenticationController:getSessionProfile', + ], }); if (options?.omitDefaults) { @@ -342,6 +363,92 @@ describe('NeoBankService', () => { }); }); + describe('Money Account wallet registration', () => { + it('resolves the Iron customer id via neobank customer lookup', async () => { + nock(STAGING_BASE) + .get('/neobank/customers/canonical-profile-1/external') + .matchHeader('authorization', 'Bearer test-token') + .reply(200, { + id: 'iron-customer-1', + external_id: 'canonical-profile-1', + }); + + const service = createService(); + + expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); + }); + + it('checks Monad wallet registration status for a customer', async () => { + nock(STAGING_BASE) + .get('/neobank/addresses/crypto/iron-customer-1') + .query({ filter: 'SelfHosted' }) + .reply(200, []); + + const service = createService(); + + expect( + await service.getWalletRegistrationStatus({ + customerId: 'iron-customer-1', + address: '0xabc', + }), + ).toStrictEqual({ type: 'absent' }); + }); + + it('submits a signed Monad wallet ownership proof with Idempotency-Key', async () => { + nock(STAGING_BASE) + .post( + '/neobank/addresses/crypto/selfhosted', + { + customer_id: 'iron-customer-1', + address: '0xabc', + blockchain: 'Monad', + message: 'ownership message', + signature: '0xsig', + }, + { reqheaders: { 'idempotency-key': 'idem-1' } }, + ) + .reply(200, { + id: 'wallet-1', + address: '0xabc', + disabled: false, + }); + + const service = createService(); + + expect( + await service.registerSelfHostedWallet({ + customerId: 'iron-customer-1', + address: '0xabc', + message: 'ownership message', + signature: '0xsig', + idempotencyKey: 'idem-1', + }), + ).toMatchObject({ + type: 'registered', + registration: { id: 'wallet-1', blockchain: 'Monad' }, + }); + }); + + it('uses the baseUrlOverride host for wallet routes', async () => { + const overrideUrl = 'https://on-ramp.dev-api.cx.metamask.io'; + nock(overrideUrl) + .get('/neobank/customers/canonical-profile-1/external') + .reply(200, { id: 'iron-customer-1' }); + + const service = createService({ baseUrlOverride: overrideUrl }); + + expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); + }); + + it('throws when the session profile has no usable external id', async () => { + const service = createService({ canonicalProfileId: '' }); + + await expect(service.getMoonpayCustomerId()).rejects.toThrow( + /Unable to resolve MetaMask canonical profile id/u, + ); + }); + }); + describe('environments and policy hooks', () => { it.each([ [RampsEnvironment.Production, 'https://on-ramp.api.cx.metamask.io'], diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts index e13d8b1ef1d..832a7ee74a6 100644 --- a/packages/ramps-controller/src/NeoBankService.ts +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -13,6 +13,11 @@ import type { } from './autorampAccount.js'; import type { NeoBankServiceMethodActions } from './NeoBankService-method-action-types.js'; import { RAMPS_SDK_VERSION, RampsEnvironment } from './RampsService.js'; +import { WalletRegistrationService } from './wallet-registration-service.js'; +import type { + RegistrationOutcome, + RegistrationStatus, +} from './wallet-registration-service.js'; /** * Name of the NeoBankService messenger namespace. @@ -59,6 +64,23 @@ export type NeoBankQueryParams = Record< string | number | boolean | undefined | null >; +export type GetWalletRegistrationStatusParams = { + customerId: string; + address: string; +}; + +export type RegisterSelfHostedWalletParams = { + customerId: string; + address: string; + message: string; + signature: string; + /** + * Forwarded as `Idempotency-Key` on the neobank-proxy POST. Prefer a stable + * key across retries of the same ownership body. + */ + idempotencyKey?: string; +}; + const MESSENGER_EXPOSED_METHODS = [ 'getAutoramp', 'registerPixAddress', @@ -67,6 +89,9 @@ const MESSENGER_EXPOSED_METHODS = [ 'getAutorampQuoteForAutoramp', 'attachAutorampQuote', 'getCustomerByExternalId', + 'getMoonpayCustomerId', + 'getWalletRegistrationStatus', + 'registerSelfHostedWallet', ] as const; /** @@ -75,7 +100,8 @@ const MESSENGER_EXPOSED_METHODS = [ export type NeoBankServiceActions = NeoBankServiceMethodActions; type AllowedActions = - AuthenticationController.AuthenticationControllerGetBearerTokenAction; + | AuthenticationController.AuthenticationControllerGetBearerTokenAction + | AuthenticationController.AuthenticationControllerGetSessionProfileAction; export type NeoBankServiceEvents = never; @@ -178,6 +204,8 @@ export class NeoBankService { readonly #baseUrlOverride?: string; + #walletRegistrationService: WalletRegistrationService | undefined; + constructor({ messenger, environment = RampsEnvironment.Staging, @@ -214,6 +242,25 @@ export class NeoBankService { return getBaseUrl(this.#environment); } + /** + * Lazily builds the wallet registration client. Deferred so constructing the + * service never resolves the base URL eagerly (an invalid environment only + * throws when a request is made, matching the other neo-bank methods). + * + * @returns The wallet registration client. + */ + #getWalletRegistrationService(): WalletRegistrationService { + this.#walletRegistrationService ??= new WalletRegistrationService({ + fetch: this.#fetch, + baseUrl: this.#getBaseUrl(), + getAuthToken: async (): Promise => + this.#messenger.call('AuthenticationController:getBearerToken'), + getExternalId: async (): Promise => + this.#getCanonicalExternalId(), + }); + return this.#walletRegistrationService; + } + async #getRequestHeaders( options: NeoBankRequestOptions = {}, ): Promise> { @@ -405,6 +452,75 @@ export class NeoBankService { ); } + /** + * Resolves Iron's internal customer id via neobank-proxy customer lookup, + * using the MetaMask canonical profile id as the partner `external_id`. + * + * @returns Iron's internal customer id. + */ + async getMoonpayCustomerId(): Promise { + return await this.#getWalletRegistrationService().getMoonpayCustomerId(); + } + + /** + * Checks whether a Monad Money Account address is already registered for the + * given Iron customer. + * + * @param params - Customer id and address to check. + * @param params.customerId - Iron / MoonPay customer UUID. + * @param params.address - Money Account address. + * @returns Active, disabled, or absent registration status. + */ + async getWalletRegistrationStatus({ + customerId, + address, + }: GetWalletRegistrationStatusParams): Promise { + return await this.#getWalletRegistrationService().getRegistrationStatus({ + customerId, + address, + blockchain: 'Monad', + }); + } + + /** + * Submits a signed Monad Money Account ownership proof via neobank-proxy + * `POST /neobank/addresses/crypto/selfhosted`. + * + * @param params - Signed ownership proof. + * @returns Registered wallet record. + */ + async registerSelfHostedWallet( + params: RegisterSelfHostedWalletParams, + ): Promise { + return await this.#getWalletRegistrationService().registerSelfHostedWallet({ + ...params, + blockchain: 'Monad', + }); + } + + /** + * Resolves the MetaMask canonical profile id used as MoonPay's partner + * `external_id` for neobank customer lookup. + * + * @returns Canonical profile id. + */ + async #getCanonicalExternalId(): Promise { + const profile = await this.#messenger.call( + 'AuthenticationController:getSessionProfile', + ); + const canonical = profile?.canonicalProfileId; + const externalId = + typeof canonical === 'string' && canonical.length > 0 + ? canonical + : profile?.profileId; + if (typeof externalId !== 'string' || externalId.length === 0) { + throw new Error( + 'Unable to resolve MetaMask canonical profile id for MoonPay customer lookup', + ); + } + return externalId; + } + onRetry( listener: Parameters[0], ): ReturnType { diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 75c4077fa58..d15a6ea8898 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -297,11 +297,10 @@ export type RampsControllerAddAutorampAction = { * Creates an autoramp via the Ramp API neo-bank proxy and applies the * returned snapshot locally. * - * The MoonPay `customer_id` is not accepted from callers: it is resolved from - * the KYC controller's session-scoped identity and injected into the request. - * This keeps the sensitive customer id owned by the KYC controller and avoids - * requiring the UI to know or plumb it. Throws when no verified identity is - * available yet. + * The MoonPay `customer_id` is not accepted from callers: it is resolved via + * {@link RampsController.resolveAutorampCustomerId} and injected into the + * request. This keeps the sensitive customer id owned by KYC / the neo-bank + * proxy and avoids requiring the UI to know or plumb it. * * @param request - CreateAutoramp payload (any `customer_id` is overwritten). * @param options - Optional idempotency key forwarded to the proxy. @@ -313,6 +312,25 @@ export type RampsControllerCreateAutorampAction = { handler: RampsController['createAutoramp']; }; +/** + * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. + * + * Consumers provide only the Monad address. The controller resolves the Iron + * customer id via {@link RampsController.resolveAutorampCustomerId} (KYC + * session identity when available, otherwise the neobank-proxy external-id + * lookup) before the first list/lookup because list requires `customer_id` + * in the path. Message construction, EIP-191 signing, submission, and + * ambiguous-write reconciliation stay internal to this controller. + * + * @param params - Money Account wallet registration parameters. + * @param params.address - Monad Money Account address. + * @returns The successful registration state. + */ +export type RampsControllerRegisterMoneyAccountWalletAction = { + type: `RampsController:registerMoneyAccountWallet`; + handler: RampsController['registerMoneyAccountWallet']; +}; + /** * Removes a local autoramp account by id. * Soft-deletes the remote User Storage entry when sync is available. @@ -790,6 +808,7 @@ export type RampsControllerMethodActions = | RampsControllerRemoveOrderAction | RampsControllerAddAutorampAction | RampsControllerCreateAutorampAction + | RampsControllerRegisterMoneyAccountWalletAction | RampsControllerRemoveAutorampAction | RampsControllerMarkAutorampAsNotifiedAction | RampsControllerApplyAutorampStatusFromPushAction diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index c75195cf098..92843826224 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -50,6 +50,7 @@ import type { } from './RampsService.js'; import { RampsOrderStatus } from './RampsService.js'; import { RequestStatus } from './RequestCache.js'; +import { WalletRegistrationError } from './wallet-registration-service.js'; import type { TransakAccessToken, TransakUserDetails, @@ -9065,12 +9066,25 @@ describe('RampsController', () => { }); }); - it('throws when no KYC customer identity is available', async () => { + it('throws when no KYC identity or mapped external customer is available', async () => { await withController(async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( 'KycController:getCustomerIdentity', () => null, ); + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: 'profile-1', + metaMetricsId: 'mm-1', + }) as never, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + async () => null, + ); const createAutoramp = jest.fn(); rootMessenger.registerActionHandler( 'NeoBankService:createAutoramp', @@ -9078,7 +9092,7 @@ describe('RampsController', () => { ); await expect(controller.createAutoramp({})).rejects.toThrow( - /no verified KYC customer identity/u, + /no MoonPay customer is mapped to external id "profile-1"/u, ); expect(createAutoramp).not.toHaveBeenCalled(); }); @@ -9185,6 +9199,267 @@ describe('RampsController', () => { }); }); + describe('registerMoneyAccountWallet', () => { + const registration = { + id: 'wallet-1', + address: '0xabc', + blockchain: 'Monad' as const, + disabled: false, + isSelf: true, + }; + + type WalletRegistrationHandlers = { + getCustomerIdentity: jest.Mock; + getWalletRegistrationStatus: jest.Mock; + registerSelfHostedWallet: jest.Mock; + signPersonalMessage: jest.Mock; + }; + + /** + * Registers default handlers for every messenger action the wallet + * registration flow calls, returning the mocks for per-test overrides. + * + * @param rootMessenger - The root messenger of the controller under test. + * @returns The registered handler mocks. + */ + function registerWalletRegistrationHandlers( + rootMessenger: RootMessenger, + ): WalletRegistrationHandlers { + const handlers: WalletRegistrationHandlers = { + getCustomerIdentity: jest + .fn() + .mockReturnValue({ vendor: 'iron', id: 'iron-customer-1' }), + getWalletRegistrationStatus: jest + .fn() + .mockResolvedValue({ type: 'absent' }), + registerSelfHostedWallet: jest.fn().mockResolvedValue({ + type: 'registered', + registration, + }), + signPersonalMessage: jest.fn().mockResolvedValue('0xsig'), + }; + rootMessenger.registerActionHandler( + 'KycController:getCustomerIdentity', + handlers.getCustomerIdentity, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getWalletRegistrationStatus', + handlers.getWalletRegistrationStatus, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:registerSelfHostedWallet', + handlers.registerSelfHostedWallet, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signPersonalMessage', + handlers.signPersonalMessage, + ); + return handlers; + } + + it('returns an existing active registration without signing', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'active', + registration, + }); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toStrictEqual({ + type: 'alreadyRegistered', + registration, + }); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'iron-customer-1', + address: '0xabc', + }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('returns an existing disabled registration without signing', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'disabled', + registration: { ...registration, disabled: true }, + }); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toMatchObject({ type: 'registeredDisabled' }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('signs and submits an ownership proof for an absent registration', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toMatchObject({ type: 'registered' }); + + expect(handlers.signPersonalMessage).toHaveBeenCalledWith({ + data: expect.stringContaining('as customer iron-customer-1.'), + from: '0xabc', + }); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0xabc', + customerId: 'iron-customer-1', + signature: '0xsig', + idempotencyKey: expect.any(String), + }), + ); + }); + }); + + it('falls back to the external-id customer lookup when KYC has no identity', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getCustomerIdentity.mockReturnValue(null); + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: 'profile-1', + metaMetricsId: 'mm-1', + }) as never, + ); + const getCustomerByExternalId = jest + .fn() + .mockResolvedValue({ id: 'iron-customer-fallback' }); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + getCustomerByExternalId, + ); + + await controller.registerMoneyAccountWallet({ address: '0xabc' }); + + expect(getCustomerByExternalId).toHaveBeenCalledWith('profile-1'); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'iron-customer-fallback', + address: '0xabc', + }); + }); + }); + + it('reconciles an ambiguous conflict as already registered', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + 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, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + 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, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + 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, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + 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, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + 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, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + 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('addOrder', () => { const mockOrder = { id: '/providers/transak-staging/orders/abc-123', diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index f8fe570ee35..40e8d1b37d9 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -15,7 +15,6 @@ import { isHeadlessAllProvidersEnabled, normalizeHeadlessProviderId, } from './featureFlags.js'; -import type { KycControllerGetCustomerIdentityAction } from '@metamask/kyc-controller'; import type { AutorampAccount, @@ -37,8 +36,23 @@ import type { NeoBankServiceCreateAutorampAction, NeoBankServiceGetAutorampAction, NeoBankServiceGetCustomerByExternalIdAction, + NeoBankServiceGetWalletRegistrationStatusAction, + NeoBankServiceRegisterSelfHostedWalletAction, } from './NeoBankService-method-action-types.js'; import type { NeoBankServiceActions } from './NeoBankService.js'; +import { buildOwnershipMessage } from './ownership-message.js'; +import { + createInitialState as createInitialWalletRegistrationState, + transition as transitionWalletRegistration, +} from './wallet-registration-machine.js'; +import { + createIdempotencyKey, + WalletRegistrationError, +} from './wallet-registration-service.js'; +import type { + RegistrationStatus, + SelfHostedRegistration, +} from './wallet-registration-service.js'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { UserStorageController } from '@metamask/profile-sync-controller'; import { @@ -196,6 +210,8 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ 'NeoBankService:getAutoramp', 'NeoBankService:createAutoramp', 'NeoBankService:getCustomerByExternalId', + 'NeoBankService:getWalletRegistrationStatus', + 'NeoBankService:registerSelfHostedWallet', ] as const satisfies readonly ( | RampsServiceActions['type'] | TransakServiceActions['type'] @@ -206,12 +222,50 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ * Other controller actions RampsController calls via the messenger. * Hosts that enable autoramp creation must delegate these from the root * messenger so the controller can resolve the vendor customer identity. + * `KeyringController:signPersonalMessage` is required for Money Account + * self-hosted wallet registration (EIP-191 ownership proof). */ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ 'KycController:getCustomerIdentity', 'AuthenticationController:getSessionProfile', + 'KeyringController:signPersonalMessage', ] as const; +/** + * Structural type for the KYC controller's `getCustomerIdentity` messenger + * action. Declared locally (mirroring `@metamask/kyc-controller`) so this + * package does not need a dependency on the KYC package; the messenger only + * matches on the action `type` string, so the shapes stay compatible. + */ +export type KycControllerGetCustomerIdentityAction = { + type: 'KycController:getCustomerIdentity'; + handler: () => { vendor: string; id: string } | null; +}; + +/** + * Structural type for the keyring controller's `signPersonalMessage` messenger + * action (EIP-191). Declared locally (mirroring + * `@metamask/keyring-controller`) to avoid a package dependency for a single + * type-only messenger action. + */ +export type KeyringControllerSignPersonalMessageAction = { + type: 'KeyringController:signPersonalMessage'; + handler: (messageParams: { data: string; from: string }) => Promise; +}; + +/** + * Successful outcome of {@link RampsController.registerMoneyAccountWallet}. + */ +export type MoneyAccountWalletRegistrationResult = + | { + type: 'registered' | 'alreadyRegistered'; + registration: SelfHostedRegistration; + } + | { + type: 'registeredDisabled'; + registration: SelfHostedRegistration; + }; + /** * User Storage / auth actions needed for autoramp Backup & Sync. * Hosts that enable `syncAutorampsWithUserStorage` must also delegate these. @@ -706,7 +760,10 @@ type AllowedActions = | NeoBankServiceGetAutorampAction | NeoBankServiceCreateAutorampAction | NeoBankServiceGetCustomerByExternalIdAction + | NeoBankServiceGetWalletRegistrationStatusAction + | NeoBankServiceRegisterSelfHostedWalletAction | KycControllerGetCustomerIdentityAction + | KeyringControllerSignPersonalMessageAction | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction | UserStorageController.UserStorageControllerPerformBatchSetStorageAction @@ -903,6 +960,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'addAutoramp', 'createAutoramp', 'removeAutoramp', + 'registerMoneyAccountWallet', 'markAutorampAsNotified', 'applyAutorampStatusFromPush', 'refreshAutoramp', @@ -2728,6 +2786,182 @@ export class RampsController extends BaseController< return customerId; } + /** + * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. + * + * Consumers provide only the Monad address. The controller resolves the Iron + * customer id via {@link RampsController.resolveAutorampCustomerId} (KYC + * session identity when available, otherwise the neobank-proxy external-id + * lookup) before the first list/lookup because list requires `customer_id` + * in the path. Message construction, EIP-191 signing, submission, and + * ambiguous-write reconciliation stay internal to this controller. + * + * @param params - Money Account wallet registration parameters. + * @param params.address - Monad Money Account address. + * @returns The successful registration state. + */ + async registerMoneyAccountWallet({ + address, + }: { + address: string; + }): Promise { + let machine = transitionWalletRegistration( + createInitialWalletRegistrationState(), + { type: 'START' }, + ); + + const toExistingResult = ( + status: RegistrationStatus, + ): MoneyAccountWalletRegistrationResult | undefined => { + if (status.type === 'active') { + return { type: 'alreadyRegistered', registration: status.registration }; + } + if (status.type === 'disabled') { + return { + type: 'registeredDisabled', + registration: status.registration, + }; + } + return undefined; + }; + + // List requires customer_id in the neobank path, so resolve Iron's id + // before the first lookup. + const customerId = await this.resolveAutorampCustomerId(); + + const lookup = async (): Promise => { + try { + return await this.messenger.call( + 'NeoBankService:getWalletRegistrationStatus', + { customerId, address }, + ); + } catch (error) { + machine = transitionWalletRegistration(machine, { + type: 'LOOKUP_FAILED', + }); + throw error; + } + }; + + const applyLookup = ( + status: RegistrationStatus, + ): MoneyAccountWalletRegistrationResult | undefined => { + let eventType: 'LOOKUP_ACTIVE' | 'LOOKUP_DISABLED' | 'LOOKUP_ABSENT' = + 'LOOKUP_ABSENT'; + if (status.type === 'active') { + eventType = 'LOOKUP_ACTIVE'; + } else if (status.type === 'disabled') { + eventType = 'LOOKUP_DISABLED'; + } + machine = transitionWalletRegistration(machine, { + type: eventType, + }); + return toExistingResult(status); + }; + + const existingStatus = await lookup(); + const existingResult = applyLookup(existingStatus); + if (existingResult) { + return existingResult; + } + + // Stable across transient retries of the same ownership proof; refreshed + // when the UTC-dated message must be rebuilt and re-signed. + let idempotencyKey = createIdempotencyKey(); + let lastMessage: string | undefined; + + while (true) { + const message = buildOwnershipMessage({ + address, + customerId, + now: new Date(), + }); + if (lastMessage !== undefined && message !== lastMessage) { + idempotencyKey = createIdempotencyKey(); + } + lastMessage = message; + + let signature: string; + try { + signature = await this.messenger.call( + 'KeyringController:signPersonalMessage', + { data: message, from: address }, + ); + machine = transitionWalletRegistration(machine, { type: 'SIGN_OK' }); + } catch (error) { + machine = transitionWalletRegistration(machine, { + type: 'SIGN_FAILED', + retryable: false, + }); + throw error; + } + + try { + const result = await this.messenger.call( + 'NeoBankService:registerSelfHostedWallet', + { + address, + customerId, + message, + signature, + idempotencyKey, + }, + ); + machine = transitionWalletRegistration(machine, { type: 'SUBMIT_OK' }); + return result; + } catch (error) { + if (!(error instanceof WalletRegistrationError)) { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TERMINAL', + }); + throw error; + } + + if (error.kind === 'conflict') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_CONFLICT', + }); + } else if (error.kind === 'transient') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TRANSIENT', + }); + } else if (error.kind === 'validation') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_VALIDATION', + utcRollover: + buildOwnershipMessage({ + address, + customerId, + now: new Date(), + }) !== message, + }); + } else if (error.kind === 'rateLimited') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_RATE_LIMITED', + }); + } else { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TERMINAL', + }); + } + + if ( + machine.status === 'disambiguate409' || + machine.status === 'checkThenRetry' + ) { + const reconciledResult = applyLookup(await lookup()); + if (reconciledResult) { + return reconciledResult; + } + } + + if (machine.status !== 'signing') { + throw error; + } + } + } + } + /** * Removes a local autoramp account by id. * Soft-deletes the remote User Storage entry when sync is available. diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index d8f3f827c52..b897a34331e 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -12,6 +12,9 @@ export type { ResourceState, TransakState, NativeProvidersState, + MoneyAccountWalletRegistrationResult, + KycControllerGetCustomerIdentityAction, + KeyringControllerSignPersonalMessageAction, } from './RampsController.js'; export type { RampsControllerExecuteRequestAction, @@ -33,6 +36,7 @@ export type { RampsControllerAddAutorampAction, RampsControllerCreateAutorampAction, RampsControllerRemoveAutorampAction, + RampsControllerRegisterMoneyAccountWalletAction, RampsControllerMarkAutorampAsNotifiedAction, RampsControllerApplyAutorampStatusFromPushAction, RampsControllerRefreshAutorampAction, @@ -216,6 +220,8 @@ export type { NeoBankAutorampResponse, NeoBankRequestOptions, NeoBankQueryParams, + GetWalletRegistrationStatusParams, + RegisterSelfHostedWalletParams, } from './NeoBankService.js'; export type { NeoBankServiceGetAutorampAction, @@ -225,6 +231,9 @@ export type { NeoBankServiceGetAutorampQuoteForAutorampAction, NeoBankServiceAttachAutorampQuoteAction, NeoBankServiceGetCustomerByExternalIdAction, + NeoBankServiceGetMoonpayCustomerIdAction, + NeoBankServiceGetWalletRegistrationStatusAction, + NeoBankServiceRegisterSelfHostedWalletAction, NeoBankServiceMethodActions, } from './NeoBankService-method-action-types.js'; export { @@ -287,3 +296,13 @@ export type { TransakServiceGeneratePaymentWidgetUrlAction, TransakServiceCreateWidgetUrlAction, } from './TransakService-method-action-types.js'; + +export type { + Blockchain, + RegistrationOutcome, + RegistrationStatus, + SelfHostedRegistration, + WalletRegistrationErrorKind, +} from './wallet-registration-service.js'; +export { WalletRegistrationError } from './wallet-registration-service.js'; +export { buildOwnershipMessage } from './ownership-message.js'; diff --git a/packages/kyc-controller/src/ownership-message.test.ts b/packages/ramps-controller/src/ownership-message.test.ts similarity index 100% rename from packages/kyc-controller/src/ownership-message.test.ts rename to packages/ramps-controller/src/ownership-message.test.ts diff --git a/packages/kyc-controller/src/ownership-message.ts b/packages/ramps-controller/src/ownership-message.ts similarity index 100% rename from packages/kyc-controller/src/ownership-message.ts rename to packages/ramps-controller/src/ownership-message.ts diff --git a/packages/kyc-controller/src/wallet-registration-machine.test.ts b/packages/ramps-controller/src/wallet-registration-machine.test.ts similarity index 100% rename from packages/kyc-controller/src/wallet-registration-machine.test.ts rename to packages/ramps-controller/src/wallet-registration-machine.test.ts diff --git a/packages/kyc-controller/src/wallet-registration-machine.ts b/packages/ramps-controller/src/wallet-registration-machine.ts similarity index 100% rename from packages/kyc-controller/src/wallet-registration-machine.ts rename to packages/ramps-controller/src/wallet-registration-machine.ts diff --git a/packages/kyc-controller/src/wallet-registration-service.test.ts b/packages/ramps-controller/src/wallet-registration-service.test.ts similarity index 100% rename from packages/kyc-controller/src/wallet-registration-service.test.ts rename to packages/ramps-controller/src/wallet-registration-service.test.ts diff --git a/packages/kyc-controller/src/wallet-registration-service.ts b/packages/ramps-controller/src/wallet-registration-service.ts similarity index 100% rename from packages/kyc-controller/src/wallet-registration-service.ts rename to packages/ramps-controller/src/wallet-registration-service.ts diff --git a/packages/ramps-controller/tsconfig.build.json b/packages/ramps-controller/tsconfig.build.json index 1c325de88b8..c7f4c2add68 100644 --- a/packages/ramps-controller/tsconfig.build.json +++ b/packages/ramps-controller/tsconfig.build.json @@ -21,9 +21,6 @@ }, { "path": "../remote-feature-flag-controller/tsconfig.build.json" - }, - { - "path": "../kyc-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/ramps-controller/tsconfig.json b/packages/ramps-controller/tsconfig.json index edb3ae546cb..f85e8ef6394 100644 --- a/packages/ramps-controller/tsconfig.json +++ b/packages/ramps-controller/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../controller-utils" - }, - { - "path": "../kyc-controller" } ], "include": ["../../types", "./src"] diff --git a/yarn.lock b/yarn.lock index 6e50507ac38..467ee181753 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7679,7 +7679,7 @@ __metadata: languageName: node linkType: hard -"@metamask/kyc-controller@npm:^0.0.0, @metamask/kyc-controller@workspace:packages/kyc-controller": +"@metamask/kyc-controller@workspace:packages/kyc-controller": version: 0.0.0-use.local resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" dependencies: @@ -7688,7 +7688,6 @@ __metadata: "@metamask/base-data-service": "npm:^0.1.3" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/geolocation-controller": "npm:^1.0.0" - "@metamask/keyring-controller": "npm:^27.1.1" "@metamask/messenger": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/superstruct": "npm:^3.4.1" @@ -8688,7 +8687,6 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/kyc-controller": "npm:^0.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0"