From 8e7e0ac758b149ffd17f5340f465f7817fcec538 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 12 Aug 2026 12:39:45 -0600 Subject: [PATCH 1/2] feat(kyc-controller): register Money Account wallets Add an address-only KycController action that resolves the MoonPay customer, signs a Monad ownership proof, and registers the wallet through the MetaMask proxy. --- packages/kyc-controller/CHANGELOG.md | 4 +- packages/kyc-controller/package.json | 1 + .../src/KycController-method-action-types.ts | 19 + .../kyc-controller/src/KycController.test.ts | 223 ++++++++ packages/kyc-controller/src/KycController.ts | 190 ++++++- .../src/KycService-method-action-types.ts | 37 ++ .../kyc-controller/src/KycService.test.ts | 55 ++ packages/kyc-controller/src/KycService.ts | 99 +++- packages/kyc-controller/src/index.test.ts | 1 + packages/kyc-controller/src/index.ts | 12 + .../src/ownership-message.test.ts | 66 +++ .../kyc-controller/src/ownership-message.ts | 32 ++ .../src/wallet-registration-machine.test.ts | 297 ++++++++++ .../src/wallet-registration-machine.ts | 221 ++++++++ .../src/wallet-registration-service.test.ts | 530 ++++++++++++++++++ .../src/wallet-registration-service.ts | 372 ++++++++++++ yarn.lock | 109 ++-- 17 files changed, 2205 insertions(+), 63 deletions(-) create mode 100644 packages/kyc-controller/src/ownership-message.test.ts create mode 100644 packages/kyc-controller/src/ownership-message.ts create mode 100644 packages/kyc-controller/src/wallet-registration-machine.test.ts create mode 100644 packages/kyc-controller/src/wallet-registration-machine.ts create mode 100644 packages/kyc-controller/src/wallet-registration-service.test.ts create mode 100644 packages/kyc-controller/src/wallet-registration-service.ts diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 71dc41e7c9..a6c6828a59 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -10,7 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 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:registerMoneyAccountWallet`, an address-only action that resolves the MoonPay customer, signs a Monad Money Account ownership message, and registers the wallet through the MetaMask proxy ([#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 ([#9847](https://github.com/MetaMask/core/pull/9847)) +- 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. - `KycService` extends `BaseDataService` and performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger. - Exposes a vendor-neutral, per-product surface (`ramps`, `card`) plus reselect selectors. diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index 5b0c23f7ff..22967e5661 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -61,6 +61,7 @@ "@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 2586ea9c63..7f27b8c202 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -167,6 +167,24 @@ export type KycControllerGetSessionStatusAction = { handler: KycController['getSessionStatus']; }; +/** + * Registers a Money Account wallet with MoonPay Iron. + * + * 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 from the authenticated MetaMask profile via KycService. + * 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. @@ -192,4 +210,5 @@ export type KycControllerMethodActions = | KycControllerGetKycStatusAction | KycControllerStartSumSubAction | KycControllerGetSessionStatusAction + | KycControllerRegisterMoneyAccountWalletAction | KycControllerResetAction; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 3c3078c27e..2f2fbaf245 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -15,6 +15,7 @@ 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 @@ -1740,6 +1741,189 @@ 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.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.signPersonalMessage).toHaveBeenCalledWith({ + data: expect.stringContaining('as customer frame-customer.'), + from: '0xabc', + }); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0xabc', + customerId: 'frame-customer', + signature: '0xsig', + }), + ); + }, + ); + }); + + it('falls back to resolving the customer id from the proxy', async () => { + await withController(async ({ controller, handlers }) => { + await controller.registerMoneyAccountWallet({ address: '0xabc' }); + + expect(handlers.getMoonpayCustomerId).toHaveBeenCalledTimes(1); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( + expect.objectContaining({ + customerId: 'iron-customer-fallback', + }), + ); + }); + }); + + 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( @@ -1796,6 +1980,10 @@ 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; }; @@ -1826,6 +2014,10 @@ const SERVICE_ACTIONS = [ 'KycService:createUkycSession', 'KycService:createJourney', 'KycService:getSessionStatus', + 'KycService:getMoonpayCustomerId', + 'KycService:getWalletRegistrationStatus', + 'KycService:registerSelfHostedWallet', + 'KeyringController:signPersonalMessage', 'UserStorageController:performGetStorage', 'UserStorageController:performSetStorage', ] as const; @@ -1901,6 +2093,21 @@ 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), }; @@ -1940,6 +2147,22 @@ 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 3abb1eb9dc..39a9e9e7ac 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -4,18 +4,21 @@ 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 { Json } from '@metamask/utils'; +import type { Hex, Json } from '@metamask/utils'; import { x25519 } from '@noble/curves/ed25519'; import { decryptCredentials, generateKeyPair } from './crypto.js'; 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 { KycDisclaimer, KycPhase, @@ -25,12 +28,20 @@ import type { KycSumSubStatus, } from './types.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; -import { toBase64Url } from './encoding.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; 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 type { + RegistrationStatus, + SelfHostedRegistration, +} from './wallet-registration-service.js'; +import { WalletRegistrationError } from './wallet-registration-service.js'; // === GENERAL === @@ -320,6 +331,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getKycStatus', 'startSumSub', 'getSessionStatus', + 'registerMoneyAccountWallet', 'reset', ] as const; @@ -334,6 +346,7 @@ export type KycControllerActions = type AllowedActions = | KycServiceMethodActions + | KeyringControllerSignPersonalMessageAction | UserStorageControllerPerformGetStorageAction | UserStorageControllerPerformSetStorageAction; @@ -371,6 +384,16 @@ export type KycControllerOptions = { sessionStatusPollIntervalMs?: number; }; +export type MoneyAccountWalletRegistrationResult = + | { + type: 'registered' | 'alreadyRegistered'; + registration: SelfHostedRegistration; + } + | { + type: 'registeredDisabled'; + registration: SelfHostedRegistration; + }; + /** * The shape of a message posted by a Check/Auth frame. */ @@ -1388,6 +1411,169 @@ export class KycController extends BaseController< } } + /** + * Registers a Money Account wallet with MoonPay Iron. + * + * 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 from the authenticated MetaMask profile via KycService. + * 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; + }; + + const lookup = async (): Promise => { + try { + return await this.messenger.call( + 'KycService:getWalletRegistrationStatus', + { 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; + } + + const customerId = + this.state.moonpayCustomerId ?? + (await this.messenger.call('KycService:getMoonpayCustomerId')); + + while (true) { + const message = buildOwnershipMessage({ + address, + customerId, + now: new Date(), + }); + 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, + }, + ); + 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 2d642d9ab2..344ae092e5 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -17,6 +17,40 @@ export type KycServiceGetGeoCountryAction = { handler: KycService['getGeoCountry']; }; +/** + * Resolves Iron's internal customer id from the authenticated MetaMask + * profile. + * + * @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. + * + * @param params - The address to check. + * @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. @@ -130,6 +164,9 @@ 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 2b3e57e86a..b537d96fa0 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -59,6 +59,61 @@ describe('KycService', () => { }); }); + describe('Money Account wallet registration', () => { + it('resolves the Iron customer id', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/customer') + .matchHeader('authorization', 'Bearer test-bearer') + .reply(200, { customerId: 'iron-customer-1' }); + + const { service } = getService(); + + expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); + }); + + it('checks Monad wallet registration status', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/self-hosted-wallets') + .reply(200, []); + + const { service } = getService(); + + expect( + await service.getWalletRegistrationStatus({ address: '0xabc' }), + ).toStrictEqual({ type: 'absent' }); + }); + + it('submits a signed Monad wallet ownership proof', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/self-hosted-wallets', { + customer_id: 'iron-customer-1', + address: '0xabc', + blockchain: 'Monad', + message: 'ownership message', + signature: '0xsig', + }) + .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', + }), + ).toMatchObject({ + type: 'registered', + registration: { id: 'wallet-1', blockchain: 'Monad' }, + }); + }); + }); + describe('fetchDisclaimers', () => { it('returns the disclaimers for a country', async () => { const disclaimers = [ diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 460105d4d4..1674a0d3cb 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -29,6 +29,11 @@ import type { KycDisclaimer, KycSessionStatus } from './types.js'; 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 === @@ -49,6 +54,9 @@ const MESSENGER_EXPOSED_METHODS = [ 'createUkycSession', 'createJourney', 'getSessionStatus', + 'getMoonpayCustomerId', + 'getWalletRegistrationStatus', + 'registerSelfHostedWallet', ] as const; /** @@ -74,8 +82,9 @@ type AllowedActions = /** * Published when {@link KycService}'s cache is updated. */ -export type KycServiceCacheUpdatedEvent = - DataServiceCacheUpdatedEvent; +export type KycServiceCacheUpdatedEvent = DataServiceCacheUpdatedEvent< + typeof serviceName +>; /** * Published when a single key within {@link KycService}'s cache is updated. @@ -247,6 +256,13 @@ export type GetSessionStatusParams = { sessionId: string; }; +export type RegisterSelfHostedWalletParams = { + customerId: string; + address: string; + message: string; + signature: string; +}; + // === SERVICE DEFINITION === /** @@ -272,6 +288,8 @@ export class KycService extends BaseDataService< readonly #fractalEncryptionBaseUrl: string; + readonly #walletRegistrationService: WalletRegistrationService; + /** * Constructs a new KycService. * @@ -306,6 +324,11 @@ export class KycService extends BaseDataService< } this.#baseUrl = baseUrl; this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl ?? ''; + this.#walletRegistrationService = new WalletRegistrationService({ + fetch: fetchFunction, + baseUrl, + getAuthToken: async (): Promise => this.#getBearerToken(), + }); this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, @@ -345,6 +368,49 @@ export class KycService extends BaseDataService< return alpha3; } + /** + * Resolves Iron's internal customer id from the authenticated MetaMask + * profile. + * + * @returns Iron's internal customer id. + */ + async getMoonpayCustomerId(): Promise { + return await this.#walletRegistrationService.getMoonpayCustomerId(); + } + + /** + * Checks whether a Monad Money Account address is already registered. + * + * @param params - The address to check. + * @param params.address - Money Account address. + * @returns Active, disabled, or absent registration status. + */ + async getWalletRegistrationStatus({ + address, + }: { + address: string; + }): Promise { + return await this.#walletRegistrationService.getRegistrationStatus({ + 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. @@ -644,6 +710,24 @@ export class KycService extends BaseDataService< } } + /** + * Gets the authenticated wallet bearer token. + * + * @returns The bearer token. + */ + async #getBearerToken(): Promise { + const bearerToken = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + assert(bearerToken, string()); + if (!bearerToken) { + throw new Error( + 'Unable to obtain an authentication bearer token - is the wallet signed in?', + ); + } + return bearerToken; + } + /** * Performs a single JSON request. * @@ -676,16 +760,7 @@ export class KycService extends BaseDataService< } if (authenticated) { - const bearerToken = await this.messenger.call( - 'AuthenticationController:getBearerToken', - ); - assert(bearerToken, string()); - if (!bearerToken) { - throw new Error( - 'Unable to obtain an authentication bearer token — is the wallet signed in?', - ); - } - headers.Authorization = `Bearer ${bearerToken}`; + headers.Authorization = `Bearer ${await this.#getBearerToken()}`; } const response = await this.#fetch(url.toString(), { diff --git a/packages/kyc-controller/src/index.test.ts b/packages/kyc-controller/src/index.test.ts index f986f8847a..3bbd3b4c25 100644 --- a/packages/kyc-controller/src/index.test.ts +++ b/packages/kyc-controller/src/index.test.ts @@ -12,6 +12,7 @@ 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 20830da2c5..f0deafb95b 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -8,6 +8,7 @@ export type { KycControllerEvents, KycControllerGetStateAction, KycControllerMessenger, + MoneyAccountWalletRegistrationResult, KycControllerOptions, KycControllerState, KycControllerStateChangeEvent, @@ -25,6 +26,7 @@ export type { KycControllerInitializeAction, KycControllerLoadDisclaimersAction, KycControllerResetAction, + KycControllerRegisterMoneyAccountWalletAction, KycControllerStartSumSubAction, } from './KycController-method-action-types.js'; @@ -44,6 +46,7 @@ export type { KycServiceInvalidateQueriesAction, KycServiceMessenger, KycServiceOptions, + RegisterSelfHostedWalletParams, UkycSessionResponse, WrappedEncryptionKey, WrappingKeyResponse, @@ -58,6 +61,9 @@ export type { KycServiceGetGeoCountryAction, KycServiceGetSessionStatusAction, KycServiceGetWrappingKeyAction, + KycServiceGetMoonpayCustomerIdAction, + KycServiceGetWalletRegistrationStatusAction, + KycServiceRegisterSelfHostedWalletAction, } from './KycService-method-action-types.js'; export { @@ -119,3 +125,9 @@ 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/src/ownership-message.test.ts b/packages/kyc-controller/src/ownership-message.test.ts new file mode 100644 index 0000000000..071144a464 --- /dev/null +++ b/packages/kyc-controller/src/ownership-message.test.ts @@ -0,0 +1,66 @@ +import { buildOwnershipMessage } from './ownership-message.js'; + +describe('buildOwnershipMessage', () => { + it('builds the exact MoonPay ownership sentence', () => { + const result = buildOwnershipMessage({ + address: '0xAbCdEf1234567890', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toBe( + 'I am verifying ownership of the wallet address 0xAbCdEf1234567890 as customer customer-123. This message was signed on 12/08/2026 to confirm my control over this wallet.', + ); + }); + + it('formats the date in UTC across a local date boundary', () => { + const result = buildOwnershipMessage({ + address: '0x1234', + customerId: 'customer-123', + now: new Date('2027-01-01T00:30:00.000Z'), + }); + + expect(result).toContain('signed on 01/01/2027'); + }); + + it('preserves the exact supplied address casing', () => { + const result = buildOwnershipMessage({ + address: '0xAbCdEf', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toContain('wallet address 0xAbCdEf as customer'); + }); + + it('does not add surrounding whitespace or a trailing newline', () => { + const result = buildOwnershipMessage({ + address: '0x1234', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toBe(result.trim()); + expect(result.endsWith('\n')).toBe(false); + }); + + it('builds a fresh message after UTC midnight', () => { + const request = { + address: '0x1234', + customerId: 'customer-123', + }; + + const beforeMidnight = buildOwnershipMessage({ + ...request, + now: new Date('2026-08-12T23:59:59.999Z'), + }); + const afterMidnight = buildOwnershipMessage({ + ...request, + now: new Date('2026-08-13T00:00:00.000Z'), + }); + + expect(beforeMidnight).toContain('signed on 12/08/2026'); + expect(afterMidnight).toContain('signed on 13/08/2026'); + expect(afterMidnight).not.toBe(beforeMidnight); + }); +}); diff --git a/packages/kyc-controller/src/ownership-message.ts b/packages/kyc-controller/src/ownership-message.ts new file mode 100644 index 0000000000..539d5e3aac --- /dev/null +++ b/packages/kyc-controller/src/ownership-message.ts @@ -0,0 +1,32 @@ +export type BuildOwnershipMessageRequest = { + address: string; + customerId: string; + now: Date; +}; + +/** + * Builds the proof-of-ownership message required to register a self-hosted + * wallet with MoonPay Iron (`POST /addresses/crypto/selfhosted`). + * + * The returned string is the exact sentence that must be both signed (EIP-191 + * `personal_sign`) and sent, byte-for-byte, in the registration request body. + * The date is always formatted as `DD/MM/YYYY` in UTC so a signature produced + * just before UTC midnight is not reused with a stale date after rollover. + * + * @param request - Values embedded in the ownership message. + * @param request.address - Wallet address, kept verbatim (no re-casing). + * @param request.customerId - Iron customer id; must match the request body. + * @param request.now - Reference time used to derive the UTC calendar date. + * @returns The exact message to sign and submit. + */ +export function buildOwnershipMessage({ + address, + customerId, + now, +}: BuildOwnershipMessageRequest): string { + const day = String(now.getUTCDate()).padStart(2, '0'); + const month = String(now.getUTCMonth() + 1).padStart(2, '0'); + const year = now.getUTCFullYear(); + + return `I am verifying ownership of the wallet address ${address} as customer ${customerId}. This message was signed on ${day}/${month}/${year} to confirm my control over this wallet.`; +} diff --git a/packages/kyc-controller/src/wallet-registration-machine.test.ts b/packages/kyc-controller/src/wallet-registration-machine.test.ts new file mode 100644 index 0000000000..de58a81ef1 --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-machine.test.ts @@ -0,0 +1,297 @@ +import { + createInitialState, + transition, +} from './wallet-registration-machine.js'; +import type { + WalletRegistrationEvent, + WalletRegistrationState, +} from './wallet-registration-machine.js'; + +const run = ( + events: WalletRegistrationEvent[], + initial: WalletRegistrationState = createInitialState(), +): WalletRegistrationState => + events.reduce((state, event) => transition(state, event), initial); + +describe('wallet registration machine: lookup', () => { + it('starts idle', () => { + expect(createInitialState().status).toBe('idle'); + }); + + it('start moves idle to preparing', () => { + expect(run([{ type: 'START' }]).status).toBe('preparing'); + }); + + it('an active existing registration skips signing and completes', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_ACTIVE' }]); + expect(state.status).toBe('alreadyRegistered'); + }); + + it('a disabled existing registration enters registeredDisabled', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_DISABLED' }]); + expect(state.status).toBe('registeredDisabled'); + }); + + it('an absent registration proceeds to signing', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + expect(state.status).toBe('signing'); + }); + + it('a failed lookup enters lookupUnavailable and never assumes absent', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_FAILED' }]); + expect(state.status).toBe('lookupUnavailable'); + }); +}); + +describe('wallet registration machine: signing', () => { + const atSigning = (): WalletRegistrationState => + run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + + it('a locked keyring during signing waits then resumes the same attempt', () => { + const locked = transition(atSigning(), { type: 'WALLET_LOCKED' }); + expect(locked.status).toBe('awaitingUnlock'); + + const resumed = transition(locked, { type: 'WALLET_UNLOCKED' }); + expect(resumed.status).toBe('signing'); + }); + + it('successful signing moves to submitting', () => { + expect(transition(atSigning(), { type: 'SIGN_OK' }).status).toBe( + 'submitting', + ); + }); + + it('explicit user rejection reaches cancelled', () => { + expect(transition(atSigning(), { type: 'SIGN_REJECTED' }).status).toBe( + 'cancelled', + ); + }); + + it('classifies signing failures as retryable or terminal', () => { + expect( + transition(atSigning(), { type: 'SIGN_FAILED', retryable: true }).status, + ).toBe('failedRetryable'); + expect( + transition(atSigning(), { type: 'SIGN_FAILED', retryable: false }).status, + ).toBe('failedTerminal'); + }); + + it('cancellation during signing aborts without failing', () => { + expect(transition(atSigning(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: submitting outcomes', () => { + const atSubmitting = (): WalletRegistrationState => + run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }, { type: 'SIGN_OK' }]); + + it('200 reaches registered', () => { + expect(transition(atSubmitting(), { type: 'SUBMIT_OK' }).status).toBe( + 'registered', + ); + }); + + it('any 409 enters disambiguate409', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_CONFLICT', + }).status, + ).toBe('disambiguate409'); + }); + + it('timeout / 5xx enters checkThenRetry', () => { + expect( + transition(atSubmitting(), { type: 'SUBMIT_TRANSIENT' }).status, + ).toBe('checkThenRetry'); + }); + + it('a UTC-rollover 400 rebuilds and re-signs once', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_VALIDATION', + utcRollover: true, + }).status, + ).toBe('signing'); + }); + + it('a non-rollover 400 is terminal', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_VALIDATION', + utcRollover: false, + }).status, + ).toBe('failedTerminal'); + }); + + it('401 / 403 / 404 are terminal', () => { + expect(transition(atSubmitting(), { type: 'SUBMIT_TERMINAL' }).status).toBe( + 'failedTerminal', + ); + }); + + it('429 becomes retryable', () => { + expect( + transition(atSubmitting(), { type: 'SUBMIT_RATE_LIMITED' }).status, + ).toBe('failedRetryable'); + }); + + it('cancellation during submitting aborts without failing', () => { + expect(transition(atSubmitting(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: 409 disambiguation', () => { + const atDisambiguate = (): WalletRegistrationState => + run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_CONFLICT' }, + ]); + + it('an active list match after 409 completes as alreadyRegistered', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_ACTIVE' }).status).toBe( + 'alreadyRegistered', + ); + }); + + it('a disabled list match after 409 enters registeredDisabled', () => { + expect( + transition(atDisambiguate(), { type: 'LOOKUP_DISABLED' }).status, + ).toBe('registeredDisabled'); + }); + + it('a 409 plus GET miss is retryable', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_ABSENT' }).status).toBe( + 'failedRetryable', + ); + }); + + it('a failed GET during disambiguation is lookupUnavailable', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_FAILED' }).status).toBe( + 'lookupUnavailable', + ); + }); + + it('cancellation during disambiguation does not become a failure', () => { + expect(transition(atDisambiguate(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: checkThenRetry after 5xx/timeout', () => { + const atCheck = ( + initial?: WalletRegistrationState, + ): WalletRegistrationState => + run( + [ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_TRANSIENT' }, + ], + initial, + ); + + it('a GET showing the resource completes without another POST', () => { + expect(transition(atCheck(), { type: 'LOOKUP_ACTIVE' }).status).toBe( + 'alreadyRegistered', + ); + }); + + it('a disabled GET result enters registeredDisabled', () => { + expect(transition(atCheck(), { type: 'LOOKUP_DISABLED' }).status).toBe( + 'registeredDisabled', + ); + }); + + it('an absent GET result retries signing within the attempt ceiling', () => { + expect(transition(atCheck(), { type: 'LOOKUP_ABSENT' }).status).toBe( + 'signing', + ); + }); + + it('a failed GET during reconciliation is lookupUnavailable', () => { + expect(transition(atCheck(), { type: 'LOOKUP_FAILED' }).status).toBe( + 'lookupUnavailable', + ); + }); + + it('stops retrying once the attempt ceiling is reached', () => { + let state = createInitialState(); + state = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }], state); + // Loop sign -> transient -> absent until the ceiling flips to retryable. + for (let i = 0; i < 5; i++) { + if (state.status === 'signing') { + state = transition(state, { type: 'SIGN_OK' }); + state = transition(state, { type: 'SUBMIT_TRANSIENT' }); + state = transition(state, { type: 'LOOKUP_ABSENT' }); + } + } + expect(state.status).toBe('failedRetryable'); + }); + + it('cancellation during checkThenRetry does not become a failure', () => { + expect(transition(atCheck(), { type: 'CANCEL' }).status).toBe('cancelled'); + }); +}); + +describe('wallet registration machine: retry, resume, and concurrency', () => { + it('retry from failedRetryable re-checks server state via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_RATE_LIMITED' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('retry from lookupUnavailable re-checks server state via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_FAILED' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('retry from cancelled restarts via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'CANCEL' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('a second START while in-flight is ignored (one operation)', () => { + const inFlight = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + expect(inFlight.status).toBe('signing'); + expect(transition(inFlight, { type: 'START' }).status).toBe('signing'); + }); + + it('ignores events that do not apply to the current state', () => { + const preparing = run([{ type: 'START' }]); + expect(transition(preparing, { type: 'SUBMIT_OK' }).status).toBe( + 'preparing', + ); + }); + + it('terminal success states ignore further events', () => { + const registered = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_OK' }, + ]); + expect(transition(registered, { type: 'RETRY' }).status).toBe('registered'); + }); +}); diff --git a/packages/kyc-controller/src/wallet-registration-machine.ts b/packages/kyc-controller/src/wallet-registration-machine.ts new file mode 100644 index 0000000000..3251c56d02 --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-machine.ts @@ -0,0 +1,221 @@ +/** + * Pure, hand-rolled finite state machine for the MoonPay Iron self-hosted + * wallet registration signing step. It follows the FSM convention used + * elsewhere in `core` (no XState dependency): a single pure `transition` + * reducer plus a data-driven transition table. + * + * Side effects (server lookups, signing, POSTing) live in the interpreter that + * drives this machine; every effect result is fed back in as an event, so the + * machine itself stays deterministic and trivially testable. + */ + +/** Every state in the signing step. */ +export type WalletRegistrationStatus = + | 'idle' + | 'preparing' + | 'awaitingUnlock' + | 'signing' + | 'submitting' + | 'disambiguate409' + | 'checkThenRetry' + | 'lookupUnavailable' + | 'registered' + | 'alreadyRegistered' + | 'registeredDisabled' + | 'failedRetryable' + | 'failedTerminal' + | 'cancelled'; + +/** Machine context carried across transitions. */ +export type WalletRegistrationContext = { + /** Number of sign attempts made so far (used for the retry ceiling). */ + attempts: number; + /** Maximum number of sign attempts before a retryable failure is surfaced. */ + maxAttempts: number; +}; + +export type WalletRegistrationState = { + status: WalletRegistrationStatus; + context: WalletRegistrationContext; +}; + +/** Events the interpreter dispatches into the machine. */ +export type WalletRegistrationEvent = + | { type: 'START' } + | { type: 'WALLET_LOCKED' } + | { type: 'WALLET_UNLOCKED' } + | { type: 'LOOKUP_ACTIVE' } + | { type: 'LOOKUP_DISABLED' } + | { type: 'LOOKUP_ABSENT' } + | { type: 'LOOKUP_FAILED' } + | { type: 'SIGN_OK' } + | { type: 'SIGN_REJECTED' } + | { type: 'SIGN_FAILED'; retryable: boolean } + | { type: 'SUBMIT_OK' } + | { type: 'SUBMIT_CONFLICT' } + | { type: 'SUBMIT_TRANSIENT' } + | { type: 'SUBMIT_VALIDATION'; utcRollover: boolean } + | { type: 'SUBMIT_TERMINAL' } + | { type: 'SUBMIT_RATE_LIMITED' } + | { type: 'RETRY' } + | { type: 'CANCEL' }; + +type EventType = WalletRegistrationEvent['type']; + +type Handler = ( + state: WalletRegistrationState, + event: WalletRegistrationEvent, +) => WalletRegistrationState; + +const DEFAULT_MAX_ATTEMPTS = 3; + +/** + * Creates the initial idle state. + * + * @param maxAttempts - Optional retry ceiling for sign attempts. + * @returns A fresh idle machine state. + */ +export function createInitialState( + maxAttempts: number = DEFAULT_MAX_ATTEMPTS, +): WalletRegistrationState { + return { status: 'idle', context: { attempts: 0, maxAttempts } }; +} + +/** + * Builds a handler that moves to a status while preserving context. + * + * @param status - Target status. + * @returns A handler transitioning to `status`. + */ +function keep(status: WalletRegistrationStatus): Handler { + return (state) => ({ status, context: state.context }); +} + +/** + * Builds a handler that moves to a status and resets the retry context. Used + * when the user (or app resume) starts a fresh attempt from scratch. + * + * @param status - Target status. + * @returns A handler transitioning to `status` with reset context. + */ +function reset(status: WalletRegistrationStatus): Handler { + return (state) => ({ + status, + context: { ...state.context, attempts: 0 }, + }); +} + +/** + * Moves to `signing` and counts this as a new sign attempt. + * + * @param state - Current state. + * @returns The `signing` state with an incremented attempt count. + */ +const toSigning: Handler = (state) => ({ + status: 'signing', + context: { ...state.context, attempts: state.context.attempts + 1 }, +}); + +const toPreparing = reset('preparing'); +const toAlreadyRegistered = keep('alreadyRegistered'); +const toRegisteredDisabled = keep('registeredDisabled'); +const toLookupUnavailable = keep('lookupUnavailable'); +const toCancelled = keep('cancelled'); + +const signFailed: Handler = (state, event) => { + const { retryable } = event as Extract< + WalletRegistrationEvent, + { type: 'SIGN_FAILED' } + >; + return retryable + ? keep('failedRetryable')(state, event) + : keep('failedTerminal')(state, event); +}; + +const submitValidation: Handler = (state, event) => { + const { utcRollover } = event as Extract< + WalletRegistrationEvent, + { type: 'SUBMIT_VALIDATION' } + >; + return utcRollover && state.context.attempts < state.context.maxAttempts + ? toSigning(state, event) + : keep('failedTerminal')(state, event); +}; + +const checkThenRetryAbsent: Handler = (state, event) => + state.context.attempts < state.context.maxAttempts + ? toSigning(state, event) + : keep('failedRetryable')(state, event); + +const TABLE: Partial< + Record>> +> = { + idle: { + START: toPreparing, + }, + preparing: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: toSigning, + LOOKUP_FAILED: toLookupUnavailable, + }, + awaitingUnlock: { + WALLET_UNLOCKED: keep('signing'), + }, + signing: { + SIGN_OK: keep('submitting'), + SIGN_REJECTED: toCancelled, + SIGN_FAILED: signFailed, + WALLET_LOCKED: keep('awaitingUnlock'), + CANCEL: toCancelled, + }, + submitting: { + SUBMIT_OK: keep('registered'), + SUBMIT_CONFLICT: keep('disambiguate409'), + SUBMIT_TRANSIENT: keep('checkThenRetry'), + SUBMIT_VALIDATION: submitValidation, + SUBMIT_TERMINAL: keep('failedTerminal'), + SUBMIT_RATE_LIMITED: keep('failedRetryable'), + CANCEL: toCancelled, + }, + disambiguate409: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: keep('failedRetryable'), + LOOKUP_FAILED: toLookupUnavailable, + CANCEL: toCancelled, + }, + checkThenRetry: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: checkThenRetryAbsent, + LOOKUP_FAILED: toLookupUnavailable, + CANCEL: toCancelled, + }, + failedRetryable: { + RETRY: toPreparing, + }, + lookupUnavailable: { + RETRY: toPreparing, + }, + cancelled: { + RETRY: toPreparing, + }, +}; + +/** + * Pure transition reducer. Unhandled (state, event) pairs are no-ops, which is + * how the machine enforces "one in-flight operation" (a second `START` while + * busy is ignored) and how terminal states stay put. + * + * @param state - Current machine state. + * @param event - Event to apply. + * @returns The next state (or the same state for unhandled events). + */ +export function transition( + state: WalletRegistrationState, + event: WalletRegistrationEvent, +): WalletRegistrationState { + const handler = TABLE[state.status]?.[event.type]; + return handler ? handler(state, event) : state; +} diff --git a/packages/kyc-controller/src/wallet-registration-service.test.ts b/packages/kyc-controller/src/wallet-registration-service.test.ts new file mode 100644 index 0000000000..a8ee7540d2 --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-service.test.ts @@ -0,0 +1,530 @@ +import { + WalletRegistrationError, + WalletRegistrationService, +} from './wallet-registration-service.js'; + +const BASE_URL = 'https://proxy.metamask.test'; +const AUTH_TOKEN = 'session-jwt-abc'; + +type FetchInit = { + method?: string; + headers: Record; + body?: string; +}; + +type HttpResponse = { + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}; + +type FetchLike = ( + url: string, + init?: { + method?: string; + headers?: Record; + body?: string; + signal?: unknown; + }, +) => Promise; + +const jsonResponse = (status: number, body: unknown): HttpResponse => ({ + ok: status >= 200 && status < 300, + status, + json: async (): Promise => body, + text: async (): Promise => JSON.stringify(body), +}); + +const textResponse = (status: number, body: string): HttpResponse => ({ + ok: status >= 200 && status < 300, + status, + json: async (): Promise => JSON.parse(body), + text: async (): Promise => body, +}); + +const invalidJsonResponse = (status: number): HttpResponse => ({ + ok: status >= 200 && status < 300, + status, + json: async (): Promise => { + throw new Error('invalid json'); + }, + text: async (): Promise => 'not json', +}); + +const buildService = (fetchImpl: FetchLike): WalletRegistrationService => + new WalletRegistrationService({ + fetch: fetchImpl, + baseUrl: BASE_URL, + getAuthToken: async (): Promise => AUTH_TOKEN, + }); + +const verifiedAddress = ( + overrides: Record = {}, +): Record => ({ + id: 'addr-1', + wallet_address: '0xAbC0000000000000000000000000000000000001', + blockchain: 'Monad', + address_type: 'SelfHosted', + disabled: false, + is_self: true, + proof_message: 'I am verifying ownership...', + proof_signature: '0xsig', + created_at: '2026-08-12T10:00:00Z', + ...overrides, +}); + +const EVM_ADDRESS = '0xAbC0000000000000000000000000000000000001'; + +describe('WalletRegistrationService.getMoonpayCustomerId', () => { + it('returns the Iron customer id from the authenticated proxy lookup', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, { customerId: 'iron-customer-1' }), + ); + + expect(await buildService(fetchMock).getMoonpayCustomerId()).toBe( + 'iron-customer-1', + ); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/vendors/moonpay/customer`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + authorization: `Bearer ${AUTH_TOKEN}`, + }), + }), + ); + }); + + it('maps a failed customer lookup to a typed HTTP error', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(404, { code: 'iron_error', message: 'not found' }), + ); + + await expect( + buildService(fetchMock).getMoonpayCustomerId(), + ).rejects.toMatchObject({ kind: 'notFound', httpStatus: 404 }); + }); + + it('rejects malformed customer lookup responses', async () => { + await expect( + buildService( + jest.fn(async (): Promise => invalidJsonResponse(200)), + ).getMoonpayCustomerId(), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + + await expect( + buildService( + jest.fn(async (): Promise => jsonResponse(200, {})), + ).getMoonpayCustomerId(), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); +}); + +describe('WalletRegistrationService.getRegistrationStatus', () => { + it('calls the MetaMask proxy list endpoint (not Iron) with the session token', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, []), + ); + const service = buildService(fetchMock); + + await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; + expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`); + expect(url).not.toContain('iron.xyz'); + expect(init.method).toBe('GET'); + expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); + }); + + it('returns an active match parsed from wallet_address', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, [verifiedAddress()]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: '0xabc0000000000000000000000000000000000001', + blockchain: 'Monad', + }); + + expect(status).toMatchObject({ + type: 'active', + registration: { address: EVM_ADDRESS, disabled: false }, + }); + }); + + it('returns a disabled result when the matching address is disabled', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [verifiedAddress({ disabled: true })]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(status.type).toBe('disabled'); + }); + + it('scopes matching per blockchain (same address, different chain is absent)', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [verifiedAddress({ blockchain: 'Ethereum' })]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(status.type).toBe('absent'); + }); + + it('skips entries whose wallet_address is not a string', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [ + { id: 'junk', wallet_address: 12345, blockchain: 'Monad' }, + verifiedAddress(), + ]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(status.type).toBe('active'); + }); + + it('throws malformedResponse when the list body is not valid JSON', async () => { + const fetchMock = jest.fn( + async (): Promise => invalidJsonResponse(200), + ); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('throws a lookupUnavailable error on a non-2xx list response', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(500, 'boom'), + ); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + }); + + it('throws a lookupUnavailable error when the list body is malformed', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, { nope: true }), + ); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toBeInstanceOf(WalletRegistrationError); + }); + + it('never converts a network failure during lookup into "absent"', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue(new Error('network down')); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + }); + + it('handles a non-Error thrown during lookup', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue('string failure'); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + }); +}); + +const registerRequest = { + customerId: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7', + address: EVM_ADDRESS, + blockchain: 'Monad' as const, + message: 'I am verifying ownership ...', + signature: '0xdeadbeef', +}; + +const selfHostedResponse = ( + overrides: Record = {}, +): Record => ({ + id: 'wallet-1', + address: EVM_ADDRESS, + customer_id: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7', + disabled: false, + signature: '0xdeadbeef', + created_at: '2026-08-12T10:00:00Z', + ...overrides, +}); + +const errorEnvelope = (status: number, message: string): HttpResponse => + jsonResponse(status, { + code: 'iron_error', + message, + }); + +describe('WalletRegistrationService.registerSelfHostedWallet', () => { + it('sends the five contract fields via POST and returns registered on 200', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, selfHostedResponse()), + ); + const service = buildService(fetchMock); + + const outcome = await service.registerSelfHostedWallet(registerRequest); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; + expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`); + expect(url).not.toContain('iron.xyz'); + expect(init.method).toBe('POST'); + expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); + expect(JSON.parse(init.body ?? '{}')).toStrictEqual({ + customer_id: registerRequest.customerId, + address: registerRequest.address, + blockchain: 'Monad', + message: registerRequest.message, + signature: registerRequest.signature, + }); + expect(outcome.registration).toMatchObject({ + id: 'wallet-1', + address: registerRequest.address, + disabled: false, + }); + }); + + it('does not send an idempotency key (the backend derives it)', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, selfHostedResponse()), + ); + const service = buildService(fetchMock); + + await service.registerSelfHostedWallet(registerRequest); + + const [, init] = fetchMock.mock.calls[0] as [string, FetchInit]; + const headerKeys = Object.keys(init.headers).map((key) => + key.toLowerCase(), + ); + expect(headerKeys).not.toContain('idempotency-key'); + expect(JSON.parse(init.body ?? '{}')).not.toHaveProperty('idempotencyKey'); + }); + + it('maps any 409 to an ambiguous conflict error carrying the body', async () => { + const fetchMock = jest.fn( + async (): Promise => + errorEnvelope( + 409, + 'A crypto address with this wallet address already exists', + ), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ + kind: 'conflict', + httpStatus: 409, + body: 'A crypto address with this wallet address already exists', + }); + }); + + it('maps 5xx to a transient error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(500, 'internal error'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'transient', httpStatus: 500 }); + }); + + it('maps a network failure / timeout to a transient error', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue(new Error('ETIMEDOUT')); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'transient' }); + }); + + it('maps a non-Error thrown during registration to transient', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue('socket hang up'); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'transient' }); + }); + + it('maps 400 to a validation error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(400, 'bad message'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'validation', httpStatus: 400 }); + }); + + it('maps an unmapped 4xx (422) to a validation error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(422, 'unprocessable'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'validation', httpStatus: 422 }); + }); + + it('maps 401 to unauthorized', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(401, 'session expired'), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'unauthorized' }); + }); + + it('maps 403 to forbidden and 404 to notFound', async () => { + const forbiddenFetch = jest.fn( + async (): Promise => errorEnvelope(403, 'suspended'), + ); + const notFoundFetch = jest.fn( + async (): Promise => errorEnvelope(404, 'not found'), + ); + + const forbidden = await buildService(forbiddenFetch) + .registerSelfHostedWallet(registerRequest) + .catch((error: unknown): WalletRegistrationError => { + return error as WalletRegistrationError; + }); + const notFound = await buildService(notFoundFetch) + .registerSelfHostedWallet(registerRequest) + .catch((error: unknown): WalletRegistrationError => { + return error as WalletRegistrationError; + }); + + expect(forbidden).toMatchObject({ kind: 'forbidden' }); + expect(notFound).toMatchObject({ kind: 'notFound' }); + }); + + it('maps an error envelope without a code', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(403, { message: 'forbidden' }), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'forbidden' }); + }); + + it('maps 429 to a rateLimited error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(429, 'slow down'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'rateLimited' }); + }); + + it('maps a malformed 200 body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, { nope: true }), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('rejects a success body that has an id but no address', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, { id: 'wallet-1', disabled: false }), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('maps a non-JSON success body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => invalidJsonResponse(200), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('maps a non-JSON error body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => invalidJsonResponse(400), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); +}); diff --git a/packages/kyc-controller/src/wallet-registration-service.ts b/packages/kyc-controller/src/wallet-registration-service.ts new file mode 100644 index 0000000000..08d118d3b8 --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-service.ts @@ -0,0 +1,372 @@ +/** The only blockchain supported by the Money Account POC. */ +export type Blockchain = 'Monad'; + +/** Normalized view of a single registered self-hosted address. */ +export type SelfHostedRegistration = { + id: string; + address: string; + blockchain: Blockchain; + disabled: boolean; + isSelf: boolean; +}; + +/** Result of reconciling a wallet against the customer's registered addresses. */ +export type RegistrationStatus = + | { type: 'active'; registration: SelfHostedRegistration } + | { type: 'disabled'; registration: SelfHostedRegistration } + | { type: 'absent' }; + +/** + * Discriminated error kinds surfaced to the state machine. Every non-success + * path maps to exactly one of these so the machine can decide deterministically. + */ +export type WalletRegistrationErrorKind = + | 'validation' + | 'unauthorized' + | 'forbidden' + | 'notFound' + | 'conflict' + | 'rateLimited' + | 'transient' + | 'lookupUnavailable' + | 'malformedResponse'; + +/** Minimal HTTP response shape, so the service is environment-agnostic. */ +type HttpResponse = { + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}; + +/** Minimal `fetch`-like function the service depends on. */ +type FetchLike = ( + url: string, + init?: { + method?: string; + headers?: Record; + body?: string; + }, +) => Promise; + +/** Typed error carrying enough context for state transitions. */ +export class WalletRegistrationError extends Error { + readonly kind: WalletRegistrationErrorKind; + + readonly httpStatus?: number; + + readonly body?: string; + + constructor( + kind: WalletRegistrationErrorKind, + options: { + message?: string; + httpStatus?: number; + body?: string; + }, + ) { + super(options.message ?? `wallet registration failed: ${kind}`); + this.name = 'WalletRegistrationError'; + this.kind = kind; + this.httpStatus = options.httpStatus; + this.body = options.body; + } +} + +export type WalletRegistrationServiceOptions = { + fetch: FetchLike; + baseUrl: string; + getAuthToken: () => Promise; +}; + +export type GetRegistrationStatusRequest = { + address: string; + blockchain: Blockchain; +}; + +export type RegisterSelfHostedWalletRequest = { + customerId: string; + address: string; + blockchain: Blockchain; + message: string; + signature: string; +}; + +/** Successful registration outcome. */ +export type RegistrationOutcome = { + type: 'registered'; + registration: SelfHostedRegistration; +}; + +const SELF_HOSTED_PATH = '/vendors/moonpay/self-hosted-wallets'; +const MOONPAY_CUSTOMER_PATH = '/vendors/moonpay/customer'; + +/** + * Normalizes a Monad EVM address for case-insensitive comparison. + * + * @param address - Raw address string. + * @returns The comparison key for the address. + */ +function normalizeAddress(address: string): string { + return address.toLowerCase(); +} + +/** + * Maps an HTTP status to the typed error kind the state machine reacts to. + * + * @param status - HTTP status code from the proxy/Iron response. + * @returns The corresponding error kind. + */ +function mapStatusToKind(status: number): WalletRegistrationErrorKind { + switch (status) { + case 400: + return 'validation'; + case 401: + return 'unauthorized'; + case 403: + return 'forbidden'; + case 404: + return 'notFound'; + case 409: + return 'conflict'; + case 429: + return 'rateLimited'; + default: + return status >= 500 ? 'transient' : 'validation'; + } +} + +/** + * Data service that talks to the MetaMask backend proxy for MoonPay Iron + * self-hosted wallet registration. It never calls Iron directly, so the Iron + * API key never ships in the client. + */ +export class WalletRegistrationService { + readonly #fetch: FetchLike; + + readonly #baseUrl: string; + + readonly #getAuthToken: () => Promise; + + constructor(options: WalletRegistrationServiceOptions) { + this.#fetch = options.fetch; + this.#baseUrl = options.baseUrl.replace(/\/$/u, ''); + this.#getAuthToken = options.getAuthToken; + } + + /** + * Resolves Iron's internal customer id from the authenticated MetaMask + * profile. Used when the current KYC flow has not already received + * `customer.id` from MoonPay's hosted frame. + * + * @returns Iron's internal customer id. + */ + async getMoonpayCustomerId(): Promise { + const token = await this.#getAuthToken(); + const response = await this.#fetch( + `${this.#baseUrl}${MOONPAY_CUSTOMER_PATH}`, + { + method: 'GET', + headers: { + accept: 'application/json', + authorization: `Bearer ${token}`, + }, + }, + ); + + if (!response.ok) { + throw await this.#toHttpError(response); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new WalletRegistrationError('malformedResponse', { + message: 'MoonPay customer body was not valid JSON', + }); + } + + const { customerId } = payload as { customerId?: unknown }; + if (typeof customerId !== 'string' || customerId.length === 0) { + throw new WalletRegistrationError('malformedResponse', { + message: 'MoonPay customer body missing customerId', + }); + } + return customerId; + } + + /** + * Reconciles a wallet against the customer's registered self-hosted addresses. + * A failed or malformed lookup is reported as `lookupUnavailable` and never + * downgraded to `absent`. + * + * @param request - Monad address to reconcile. + * @returns The active / disabled / absent status for the address. + */ + async getRegistrationStatus( + request: GetRegistrationStatusRequest, + ): Promise { + const { address, blockchain } = request; + + let response: HttpResponse; + try { + const token = await this.#getAuthToken(); + response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, { + method: 'GET', + headers: { + accept: 'application/json', + authorization: `Bearer ${token}`, + }, + }); + } catch (error) { + throw new WalletRegistrationError('lookupUnavailable', { + message: 'self-hosted address lookup failed', + body: error instanceof Error ? error.message : undefined, + }); + } + + if (!response.ok) { + const body = await response.text(); + throw new WalletRegistrationError('lookupUnavailable', { + httpStatus: response.status, + body, + }); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new WalletRegistrationError('malformedResponse', { + message: 'self-hosted address list body was not valid JSON', + }); + } + if (!Array.isArray(payload)) { + throw new WalletRegistrationError('malformedResponse', { + message: 'expected an array of registered addresses', + }); + } + + const target = normalizeAddress(address); + const match = payload.find((entry) => { + const record = entry as Record; + const walletAddress = record.wallet_address; + if (typeof walletAddress !== 'string') { + return false; + } + return ( + normalizeAddress(walletAddress) === target && + record.blockchain === blockchain + ); + }) as Record | undefined; + + if (!match) { + return { type: 'absent' }; + } + + const registration = this.#toRegistration(match); + return registration.disabled + ? { type: 'disabled', registration } + : { type: 'active', registration }; + } + + /** + * Registers a self-hosted wallet through the MetaMask proxy. The proxy + * resolves the customer, derives the idempotency key, and attaches the API + * version, so the client never manages those. Every non-2xx response is + * mapped to a typed error; `409` is deliberately surfaced as an ambiguous + * `conflict` that the caller must reconcile with a follow-up status lookup. + * + * @param request - Customer id, address, blockchain, message, and signature. + * @returns The registered outcome on success. + */ + async registerSelfHostedWallet( + request: RegisterSelfHostedWalletRequest, + ): Promise { + let response: HttpResponse; + try { + const token = await this.#getAuthToken(); + response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + customer_id: request.customerId, + address: request.address, + blockchain: request.blockchain, + message: request.message, + signature: request.signature, + }), + }); + } catch (error) { + throw new WalletRegistrationError('transient', { + message: 'self-hosted registration request failed', + body: error instanceof Error ? error.message : undefined, + }); + } + + if (!response.ok) { + throw await this.#toHttpError(response); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new WalletRegistrationError('malformedResponse', { + message: 'registration success body was not valid JSON', + }); + } + + const record = payload as Record; + if (typeof record.id !== 'string' || typeof record.address !== 'string') { + throw new WalletRegistrationError('malformedResponse', { + message: 'registration success body missing id/address', + }); + } + + return { + type: 'registered', + registration: { + id: record.id, + address: record.address, + blockchain: request.blockchain, + disabled: Boolean(record.disabled), + isSelf: true, + }, + }; + } + + async #toHttpError(response: HttpResponse): Promise { + let envelope: { message?: string }; + try { + envelope = (await response.json()) as { message?: string }; + } catch { + return new WalletRegistrationError('malformedResponse', { + httpStatus: response.status, + message: 'error body was not valid JSON', + }); + } + + const { status } = response; + const kind = mapStatusToKind(status); + return new WalletRegistrationError(kind, { + httpStatus: status, + body: envelope.message, + }); + } + + #toRegistration(record: Record): SelfHostedRegistration { + return { + id: String(record.id), + address: String(record.wallet_address), + blockchain: 'Monad', + disabled: Boolean(record.disabled), + isSelf: Boolean(record.is_self), + }; + } +} diff --git a/yarn.lock b/yarn.lock index 35c6bbed9c..cc94a5d495 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5740,7 +5740,7 @@ __metadata: resolution: "@metamask/account-tree-controller@workspace:packages/account-tree-controller" dependencies: "@metamask/account-api": "npm:^2.0.0" - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/keyring-api": "npm:^24.0.0" @@ -5772,7 +5772,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/accounts-controller@npm:^39.0.7, @metamask/accounts-controller@workspace:packages/accounts-controller": +"@metamask/accounts-controller@npm:^39.1.0, @metamask/accounts-controller@workspace:packages/accounts-controller": version: 0.0.0-use.local resolution: "@metamask/accounts-controller@workspace:packages/accounts-controller" dependencies: @@ -5978,7 +5978,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controller@npm:^13.1.1, @metamask/assets-controller@workspace:packages/assets-controller": +"@metamask/assets-controller@npm:^13.1.2, @metamask/assets-controller@workspace:packages/assets-controller": version: 0.0.0-use.local resolution: "@metamask/assets-controller@workspace:packages/assets-controller" dependencies: @@ -5986,7 +5986,7 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/account-tree-controller": "npm:^7.6.1" - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/assets-controllers": "npm:^111.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" @@ -6008,7 +6008,7 @@ __metadata: "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -6041,7 +6041,7 @@ __metadata: "@metamask/abi-utils": "npm:^2.0.3" "@metamask/account-api": "npm:^2.0.0" "@metamask/account-tree-controller": "npm:^7.6.1" - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/approval-controller": "npm:^9.0.2" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" @@ -6071,7 +6071,7 @@ __metadata: "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/storage-service": "npm:^1.0.2" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" @@ -6251,7 +6251,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^79.0.1, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^79.1.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -6260,8 +6260,8 @@ __metadata: "@ethersproject/constants": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^39.0.7" - "@metamask/assets-controller": "npm:^13.1.1" + "@metamask/accounts-controller": "npm:^39.1.0" + "@metamask/assets-controller": "npm:^13.1.2" "@metamask/assets-controllers": "npm:^111.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" @@ -6278,7 +6278,7 @@ __metadata: "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/superstruct": "npm:^3.4.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -6302,10 +6302,10 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/bridge-status-controller@workspace:packages/bridge-status-controller" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^79.0.1" + "@metamask/bridge-controller": "npm:^79.1.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/gas-fee-controller": "npm:^26.3.1" "@metamask/keyring-controller": "npm:^27.1.1" @@ -6315,7 +6315,7 @@ __metadata: "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/superstruct": "npm:^3.4.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -6410,17 +6410,20 @@ __metadata: languageName: unknown linkType: soft -"@metamask/claims-controller@workspace:packages/claims-controller": +"@metamask/claims-controller@npm:^0.6.0, @metamask/claims-controller@workspace:packages/claims-controller": version: 0.0.0-use.local resolution: "@metamask/claims-controller@workspace:packages/claims-controller" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" + "@metamask/base-data-service": "npm:^0.1.3" "@metamask/controller-utils": "npm:^12.3.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" "@metamask/utils": "npm:^11.11.0" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" @@ -6464,7 +6467,7 @@ __metadata: "@metamask/core-backend": "npm:^8.1.1" "@metamask/keyring-api": "npm:^24.0.0" "@metamask/slip44": "npm:^4.3.0" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -6811,7 +6814,7 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^35.0.1" "@metamask/stake-sdk": "npm:^3.2.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" @@ -6835,7 +6838,7 @@ __metadata: "@metamask/preferences-controller": "npm:^23.1.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.4.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -7420,7 +7423,7 @@ __metadata: "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -7685,6 +7688,7 @@ __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" @@ -7729,14 +7733,14 @@ __metadata: languageName: unknown linkType: soft -"@metamask/logging-controller@npm:^8.0.2, @metamask/logging-controller@workspace:packages/logging-controller": +"@metamask/logging-controller@npm:^9.0.0, @metamask/logging-controller@workspace:packages/logging-controller": version: 0.0.0-use.local resolution: "@metamask/logging-controller@workspace:packages/logging-controller" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" + "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" @@ -7907,7 +7911,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/money-account-controller@workspace:packages/money-account-controller" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/eth-money-keyring": "npm:^4.0.0" @@ -7965,7 +7969,7 @@ __metadata: "@ethersproject/abstract-provider": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@metamask/auto-changelog": "npm:^6.1.0" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -7985,7 +7989,7 @@ __metadata: dependencies: "@ethereumjs/util": "npm:^9.1.0" "@metamask/account-api": "npm:^2.0.0" - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" @@ -8029,7 +8033,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/multichain-api-middleware@workspace:packages/multichain-api-middleware" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/api-specs": "npm:^0.15.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/chain-agnostic-permission": "npm:^1.7.0" @@ -8062,7 +8066,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/multichain-network-controller@workspace:packages/multichain-network-controller" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" @@ -8095,7 +8099,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/multichain-transactions-controller@workspace:packages/multichain-transactions-controller" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/keyring-api": "npm:^24.0.0" @@ -8234,7 +8238,7 @@ __metadata: "@metamask/multichain-network-controller": "npm:^3.2.3" "@metamask/network-controller": "npm:^35.0.1" "@metamask/slip44": "npm:^4.3.0" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -8436,7 +8440,7 @@ __metadata: "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/superstruct": "npm:^3.4.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@myx-trade/sdk": "npm:^0.1.265" "@nktkas/hyperliquid": "npm:^0.33.1" @@ -8469,7 +8473,7 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -8583,7 +8587,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/profile-metrics-controller@workspace:packages/profile-metrics-controller" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" @@ -8596,7 +8600,7 @@ __metadata: "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/superstruct": "npm:^3.4.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -8916,7 +8920,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/shield-controller@workspace:packages/shield-controller": +"@metamask/shield-controller@npm:^6.0.0, @metamask/shield-controller@workspace:packages/shield-controller": version: 0.0.0-use.local resolution: "@metamask/shield-controller@workspace:packages/shield-controller" dependencies: @@ -8925,11 +8929,14 @@ __metadata: "@lavamoat/preinstall-always-fail": "npm:^2.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" + "@metamask/base-data-service": "npm:^0.1.3" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" + "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/signature-controller": "npm:^39.2.9" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" cockatiel: "npm:^3.1.2" @@ -8949,7 +8956,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/signature-controller@workspace:packages/signature-controller" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/approval-controller": "npm:^9.0.2" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" @@ -8957,7 +8964,7 @@ __metadata: "@metamask/eth-sig-util": "npm:^8.2.0" "@metamask/gator-permissions-controller": "npm:^5.0.1" "@metamask/keyring-controller": "npm:^27.1.1" - "@metamask/logging-controller": "npm:^8.0.2" + "@metamask/logging-controller": "npm:^9.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^35.0.1" "@metamask/utils": "npm:^11.11.0" @@ -9012,7 +9019,7 @@ __metadata: "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/superstruct": "npm:^3.4.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" bignumber.js: "npm:^9.1.2" @@ -9261,18 +9268,21 @@ __metadata: languageName: unknown linkType: soft -"@metamask/subscription-controller@workspace:packages/subscription-controller": +"@metamask/subscription-controller@npm:^7.0.0, @metamask/subscription-controller@workspace:packages/subscription-controller": version: 0.0.0-use.local resolution: "@metamask/subscription-controller@workspace:packages/subscription-controller" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" + "@metamask/base-data-service": "npm:^0.1.3" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/polling-controller": "npm:^16.0.9" "@metamask/profile-sync-controller": "npm:^29.0.0" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/superstruct": "npm:^3.4.1" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" bignumber.js: "npm:^9.1.2" @@ -9318,7 +9328,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^69.5.0, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@npm:^69.5.2, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: @@ -9330,7 +9340,7 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/wallet": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/approval-controller": "npm:^9.0.2" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" @@ -9383,7 +9393,7 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^13.1.1" + "@metamask/assets-controller": "npm:^13.1.2" "@metamask/assets-controllers": "npm:^111.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" @@ -9396,7 +9406,7 @@ __metadata: "@metamask/ramps-controller": "npm:^20.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/sentinel-api-service": "npm:^1.0.0" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -9431,7 +9441,7 @@ __metadata: "@metamask/polling-controller": "npm:^16.0.9" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.4.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -9504,7 +9514,7 @@ __metadata: "@metamask/storage-service": "npm:^1.0.2" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" - "@metamask/wallet": "npm:^9.0.0" + "@metamask/wallet": "npm:^10.0.0" "@oclif/core": "npm:^4.10.5" "@ts-bridge/cli": "npm:^0.6.4" "@types/better-sqlite3": "npm:^7.6.13" @@ -9556,16 +9566,17 @@ __metadata: languageName: unknown linkType: soft -"@metamask/wallet@npm:^9.0.0, @metamask/wallet@workspace:packages/wallet": +"@metamask/wallet@npm:^10.0.0, @metamask/wallet@workspace:packages/wallet": version: 0.0.0-use.local resolution: "@metamask/wallet@workspace:packages/wallet" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/address-book-controller": "npm:^7.1.2" "@metamask/approval-controller": "npm:^9.0.2" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/browser-passworder": "npm:^6.0.0" + "@metamask/claims-controller": "npm:^0.6.0" "@metamask/connectivity-controller": "npm:^0.3.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/gas-fee-controller": "npm:^26.3.1" @@ -9576,8 +9587,10 @@ __metadata: "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/seedless-onboarding-controller": "npm:^10.1.1" + "@metamask/shield-controller": "npm:^6.0.0" "@metamask/storage-service": "npm:^1.0.2" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/subscription-controller": "npm:^7.0.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" From ecb94e8faaa1a6632c875ffe36158b11e41b2ded Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 12 Aug 2026 14:02:34 -0600 Subject: [PATCH 2/2] feat(kyc-controller): sync wallet registration with neobank-proxy Retarget Money Account self-hosted wallet registration from kyc-api /vendors/moonpay/* to onramp-api neobank-proxy /neobank/... so Core matches Money Movement ownership and transparent proxy semantics. --- packages/kyc-controller/CHANGELOG.md | 6 +- .../src/KycController-method-action-types.ts | 6 +- .../kyc-controller/src/KycController.test.ts | 17 +- packages/kyc-controller/src/KycController.ts | 32 ++- .../src/KycService-method-action-types.ts | 10 +- .../kyc-controller/src/KycService.test.ts | 77 +++++- packages/kyc-controller/src/KycService.ts | 69 ++++- packages/kyc-controller/src/index.ts | 1 + .../src/wallet-registration-service.test.ts | 260 ++++++++++++++---- .../src/wallet-registration-service.ts | 171 +++++++++--- 10 files changed, 514 insertions(+), 135 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index a6c6828a59..5e8d960832 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -10,8 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 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)) -- `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 proxy ([#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 ([#9847](https://github.com/MetaMask/core/pull/9847)) +- `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. - 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. - `KycService` extends `BaseDataService` and performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger. diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 7f27b8c202..0c6b05f21c 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -168,11 +168,13 @@ export type KycControllerGetSessionStatusAction = { }; /** - * Registers a Money Account wallet with MoonPay Iron. + * 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 from the authenticated MetaMask profile via KycService. + * 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. * diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 2f2fbaf245..8cfecaace3 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1763,6 +1763,11 @@ describe('KycController', () => { type: 'alreadyRegistered', registration, }); + expect(handlers.getMoonpayCustomerId).toHaveBeenCalledTimes(1); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'iron-customer-fallback', + address: '0xabc', + }); expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); }); }); @@ -1790,6 +1795,10 @@ describe('KycController', () => { ).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', @@ -1799,20 +1808,26 @@ describe('KycController', () => { address: '0xabc', customerId: 'frame-customer', signature: '0xsig', + idempotencyKey: expect.any(String), }), ); }, ); }); - it('falls back to resolving the customer id from the proxy', async () => { + 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), }), ); }); diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 39a9e9e7ac..3b3e7b7d88 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -37,11 +37,14 @@ 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'; -import { WalletRegistrationError } from './wallet-registration-service.js'; // === GENERAL === @@ -1412,11 +1415,13 @@ export class KycController extends BaseController< } /** - * Registers a Money Account wallet with MoonPay Iron. + * 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 from the authenticated MetaMask profile via KycService. + * 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. * @@ -1448,11 +1453,17 @@ export class KycController extends BaseController< 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', - { address }, + { customerId, address }, ); } catch (error) { machine = transitionWalletRegistration(machine, { @@ -1484,9 +1495,10 @@ export class KycController extends BaseController< return existingResult; } - const customerId = - this.state.moonpayCustomerId ?? - (await this.messenger.call('KycService:getMoonpayCustomerId')); + // 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({ @@ -1494,6 +1506,11 @@ export class KycController extends BaseController< customerId, now: new Date(), }); + if (lastMessage !== undefined && message !== lastMessage) { + idempotencyKey = createIdempotencyKey(); + } + lastMessage = message; + let signature: string; try { signature = await this.messenger.call( @@ -1517,6 +1534,7 @@ export class KycController extends BaseController< customerId, message, signature, + idempotencyKey, }, ); machine = transitionWalletRegistration(machine, { type: 'SUBMIT_OK' }); diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 344ae092e5..b926752b57 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -18,8 +18,8 @@ export type KycServiceGetGeoCountryAction = { }; /** - * Resolves Iron's internal customer id from the authenticated MetaMask - * profile. + * 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. */ @@ -29,9 +29,11 @@ export type KycServiceGetMoonpayCustomerIdAction = { }; /** - * Checks whether a Monad Money Account address is already registered. + * Checks whether a Monad Money Account address is already registered for the + * given Iron customer. * - * @param params - The address to check. + * @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. */ diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index b537d96fa0..84512afa87 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -60,38 +60,49 @@ describe('KycService', () => { }); describe('Money Account wallet registration', () => { - it('resolves the Iron customer id', async () => { + it('resolves the Iron customer id via neobank customer lookup', async () => { nock(MOCK_API_URL) - .get('/vendors/moonpay/customer') + .get('/neobank/customers/canonical-profile-1/external') .matchHeader('authorization', 'Bearer test-bearer') - .reply(200, { customerId: 'iron-customer-1' }); + .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', async () => { + it('checks Monad wallet registration status for a customer', async () => { nock(MOCK_API_URL) - .get('/vendors/moonpay/self-hosted-wallets') + .get('/neobank/addresses/crypto/iron-customer-1') + .query({ filter: 'SelfHosted' }) .reply(200, []); const { service } = getService(); expect( - await service.getWalletRegistrationStatus({ address: '0xabc' }), + await service.getWalletRegistrationStatus({ + customerId: 'iron-customer-1', + address: '0xabc', + }), ).toStrictEqual({ type: 'absent' }); }); - it('submits a signed Monad wallet ownership proof', async () => { + it('submits a signed Monad wallet ownership proof with Idempotency-Key', async () => { nock(MOCK_API_URL) - .post('/vendors/moonpay/self-hosted-wallets', { - customer_id: 'iron-customer-1', - address: '0xabc', - blockchain: 'Monad', - message: 'ownership message', - signature: '0xsig', - }) + .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', @@ -106,12 +117,32 @@ describe('KycService', () => { 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', () => { @@ -536,8 +567,11 @@ 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({ @@ -545,15 +579,19 @@ 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; @@ -569,6 +607,7 @@ function getService({ rootMessenger.delegate({ actions: [ 'AuthenticationController:getBearerToken', + 'AuthenticationController:getSessionProfile', 'GeolocationController:getGeolocation', ], events: [], @@ -578,6 +617,15 @@ 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, @@ -587,6 +635,7 @@ 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 1674a0d3cb..cf18213cd6 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -8,7 +8,10 @@ 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 } from '@metamask/profile-sync-controller/auth'; +import type { + AuthenticationControllerGetBearerTokenAction, + AuthenticationControllerGetSessionProfileAction, +} from '@metamask/profile-sync-controller/auth'; import type { Infer, Struct } from '@metamask/superstruct'; import { array, @@ -77,6 +80,7 @@ export type KycServiceActions = */ type AllowedActions = | AuthenticationControllerGetBearerTokenAction + | AuthenticationControllerGetSessionProfileAction | GeolocationControllerGetGeolocationAction; /** @@ -124,6 +128,13 @@ 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 @@ -256,11 +267,21 @@ 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 === @@ -297,6 +318,8 @@ 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. @@ -308,6 +331,7 @@ export class KycService extends BaseDataService< messenger, fetch: fetchFunction, baseUrl, + neobankBaseUrl, fractalEncryptionBaseUrl, queryClientConfig = {}, policyOptions = {}, @@ -326,8 +350,10 @@ export class KycService extends BaseDataService< this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl ?? ''; this.#walletRegistrationService = new WalletRegistrationService({ fetch: fetchFunction, - baseUrl, + baseUrl: neobankBaseUrl ?? baseUrl, getAuthToken: async (): Promise => this.#getBearerToken(), + getExternalId: async (): Promise => + this.#getCanonicalExternalId(), }); this.messenger.registerMethodActionHandlers( this, @@ -369,8 +395,8 @@ export class KycService extends BaseDataService< } /** - * Resolves Iron's internal customer id from the authenticated MetaMask - * profile. + * 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. */ @@ -379,18 +405,20 @@ export class KycService extends BaseDataService< } /** - * Checks whether a Monad Money Account address is already registered. + * Checks whether a Monad Money Account address is already registered for the + * given Iron customer. * - * @param params - The address to check. + * @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, - }: { - address: string; - }): Promise { + }: GetWalletRegistrationStatusParams): Promise { return await this.#walletRegistrationService.getRegistrationStatus({ + customerId, address, blockchain: 'Monad', }); @@ -728,6 +756,29 @@ 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.ts b/packages/kyc-controller/src/index.ts index f0deafb95b..60e8e0d904 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -46,6 +46,7 @@ export type { KycServiceInvalidateQueriesAction, KycServiceMessenger, KycServiceOptions, + GetWalletRegistrationStatusParams, RegisterSelfHostedWalletParams, UkycSessionResponse, WrappedEncryptionKey, diff --git a/packages/kyc-controller/src/wallet-registration-service.test.ts b/packages/kyc-controller/src/wallet-registration-service.test.ts index a8ee7540d2..c3be35aede 100644 --- a/packages/kyc-controller/src/wallet-registration-service.test.ts +++ b/packages/kyc-controller/src/wallet-registration-service.test.ts @@ -1,10 +1,14 @@ import { + createIdempotencyKey, + extractErrorBody, WalletRegistrationError, WalletRegistrationService, } from './wallet-registration-service.js'; -const BASE_URL = 'https://proxy.metamask.test'; +const BASE_URL = 'https://on-ramp.dev-api.cx.metamask.io'; const AUTH_TOKEN = 'session-jwt-abc'; +const EXTERNAL_ID = 'canonical-profile-1'; +const CUSTOMER_ID = '019ff69c-3039-77b0-9d5d-e4a3baefd7b7'; type FetchInit = { method?: string; @@ -33,7 +37,8 @@ const jsonResponse = (status: number, body: unknown): HttpResponse => ({ ok: status >= 200 && status < 300, status, json: async (): Promise => body, - text: async (): Promise => JSON.stringify(body), + text: async (): Promise => + typeof body === 'string' ? body : JSON.stringify(body), }); const textResponse = (status: number, body: string): HttpResponse => ({ @@ -57,6 +62,7 @@ const buildService = (fetchImpl: FetchLike): WalletRegistrationService => fetch: fetchImpl, baseUrl: BASE_URL, getAuthToken: async (): Promise => AUTH_TOKEN, + getExternalId: async (): Promise => EXTERNAL_ID, }); const verifiedAddress = ( @@ -76,11 +82,73 @@ const verifiedAddress = ( const EVM_ADDRESS = '0xAbC0000000000000000000000000000000000001'; +describe('createIdempotencyKey', () => { + it('returns a non-empty string', () => { + expect(createIdempotencyKey().length).toBeGreaterThan(0); + }); + + it('falls back when randomUUID is unavailable', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'crypto', + ); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { randomUUID: undefined }, + }); + try { + expect(createIdempotencyKey()).toMatch(/^wallet-reg-/u); + } finally { + if (originalDescriptor) { + Object.defineProperty(globalThis, 'crypto', originalDescriptor); + } + } + }); +}); + +describe('extractErrorBody', () => { + it('returns whitespace-only bodies unchanged', () => { + expect(extractErrorBody(' ')).toBe(' '); + expect(extractErrorBody('')).toBe(''); + }); + + it('unwraps a JSON-encoded string', () => { + expect(extractErrorBody(JSON.stringify('already exists'))).toBe( + 'already exists', + ); + }); + + it('prefers message on a JSON object', () => { + expect(extractErrorBody(JSON.stringify({ message: 'forbidden' }))).toBe( + 'forbidden', + ); + }); + + it('keeps a JSON object without message as raw text', () => { + const raw = JSON.stringify({ code: 'x', detail: 'nope' }); + expect(extractErrorBody(raw)).toBe(raw); + }); + + it('returns plain text that is not JSON', () => { + expect(extractErrorBody('not json at all')).toBe('not json at all'); + }); + + it('returns non-object JSON values as the raw trimmed text', () => { + expect(extractErrorBody('null')).toBe('null'); + expect(extractErrorBody('42')).toBe('42'); + expect(extractErrorBody('true')).toBe('true'); + }); +}); + describe('WalletRegistrationService.getMoonpayCustomerId', () => { - it('returns the Iron customer id from the authenticated proxy lookup', async () => { + it('returns Iron customer id from GET /neobank/customers/{external_id}/external', async () => { const fetchMock = jest.fn( async (): Promise => - jsonResponse(200, { customerId: 'iron-customer-1' }), + jsonResponse(200, { + id: 'iron-customer-1', + external_id: EXTERNAL_ID, + status: 'Active', + }), ); expect(await buildService(fetchMock).getMoonpayCustomerId()).toBe( @@ -88,7 +156,7 @@ describe('WalletRegistrationService.getMoonpayCustomerId', () => { ); expect(fetchMock).toHaveBeenCalledWith( - `${BASE_URL}/vendors/moonpay/customer`, + `${BASE_URL}/neobank/customers/${EXTERNAL_ID}/external`, expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ @@ -98,15 +166,18 @@ describe('WalletRegistrationService.getMoonpayCustomerId', () => { ); }); - it('maps a failed customer lookup to a typed HTTP error', async () => { + it('maps a failed customer lookup to a typed HTTP error with transparent body', async () => { const fetchMock = jest.fn( - async (): Promise => - jsonResponse(404, { code: 'iron_error', message: 'not found' }), + async (): Promise => textResponse(404, 'not found'), ); await expect( buildService(fetchMock).getMoonpayCustomerId(), - ).rejects.toMatchObject({ kind: 'notFound', httpStatus: 404 }); + ).rejects.toMatchObject({ + kind: 'notFound', + httpStatus: 404, + body: 'not found', + }); }); it('rejects malformed customer lookup responses', async () => { @@ -122,35 +193,59 @@ describe('WalletRegistrationService.getMoonpayCustomerId', () => { ).getMoonpayCustomerId(), ).rejects.toMatchObject({ kind: 'malformedResponse' }); }); + + it('rejects an empty external id before calling the network', async () => { + const fetchMock = jest.fn(); + const service = new WalletRegistrationService({ + fetch: fetchMock, + baseUrl: BASE_URL, + getAuthToken: async (): Promise => AUTH_TOKEN, + getExternalId: async (): Promise => '', + }); + + await expect(service.getMoonpayCustomerId()).rejects.toMatchObject({ + kind: 'malformedResponse', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); describe('WalletRegistrationService.getRegistrationStatus', () => { - it('calls the MetaMask proxy list endpoint (not Iron) with the session token', async () => { + it('lists via /neobank/addresses/crypto/{customer_id}?filter=SelfHosted', async () => { const fetchMock = jest.fn( async (): Promise => jsonResponse(200, []), ); const service = buildService(fetchMock); await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; - expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`); + expect(url).toBe( + `${BASE_URL}/neobank/addresses/crypto/${CUSTOMER_ID}?filter=SelfHosted`, + ); expect(url).not.toContain('iron.xyz'); + expect(url).not.toContain('/vendors/moonpay/'); expect(init.method).toBe('GET'); expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); }); - it('returns an active match parsed from wallet_address', async () => { + it('returns an active match parsed from wallet_address (Monad filter client-side)', async () => { const fetchMock = jest.fn( - async (): Promise => jsonResponse(200, [verifiedAddress()]), + async (): Promise => + jsonResponse(200, [ + verifiedAddress({ blockchain: 'Ethereum' }), + verifiedAddress(), + ]), ); const service = buildService(fetchMock); const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: '0xabc0000000000000000000000000000000000001', blockchain: 'Monad', }); @@ -169,6 +264,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { const service = buildService(fetchMock); const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }); @@ -184,6 +280,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { const service = buildService(fetchMock); const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }); @@ -202,6 +299,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { const service = buildService(fetchMock); const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }); @@ -217,6 +315,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { await expect( service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }), @@ -231,10 +330,11 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { await expect( service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }), - ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + ).rejects.toMatchObject({ kind: 'lookupUnavailable', body: 'boom' }); }); it('throws a lookupUnavailable error when the list body is malformed', async () => { @@ -245,6 +345,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { await expect( service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }), @@ -259,6 +360,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { await expect( service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }), @@ -273,6 +375,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { await expect( service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }), @@ -281,7 +384,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { }); const registerRequest = { - customerId: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7', + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad' as const, message: 'I am verifying ownership ...', @@ -293,35 +396,33 @@ const selfHostedResponse = ( ): Record => ({ id: 'wallet-1', address: EVM_ADDRESS, - customer_id: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7', + customer_id: CUSTOMER_ID, disabled: false, signature: '0xdeadbeef', created_at: '2026-08-12T10:00:00Z', ...overrides, }); -const errorEnvelope = (status: number, message: string): HttpResponse => - jsonResponse(status, { - code: 'iron_error', - message, - }); - describe('WalletRegistrationService.registerSelfHostedWallet', () => { - it('sends the five contract fields via POST and returns registered on 200', async () => { + it('posts to /neobank/addresses/crypto/selfhosted with an idempotency key', async () => { const fetchMock = jest.fn( async (): Promise => jsonResponse(200, selfHostedResponse()), ); const service = buildService(fetchMock); - const outcome = await service.registerSelfHostedWallet(registerRequest); + const outcome = await service.registerSelfHostedWallet({ + ...registerRequest, + idempotencyKey: 'idem-wallet-1', + }); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; - expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`); + expect(url).toBe(`${BASE_URL}/neobank/addresses/crypto/selfhosted`); expect(url).not.toContain('iron.xyz'); expect(init.method).toBe('POST'); expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); + expect(init.headers['Idempotency-Key']).toBe('idem-wallet-1'); expect(JSON.parse(init.body ?? '{}')).toStrictEqual({ customer_id: registerRequest.customerId, address: registerRequest.address, @@ -336,7 +437,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { }); }); - it('does not send an idempotency key (the backend derives it)', async () => { + it('generates an Idempotency-Key when the caller omits one', async () => { const fetchMock = jest.fn( async (): Promise => jsonResponse(200, selfHostedResponse()), @@ -346,17 +447,13 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { await service.registerSelfHostedWallet(registerRequest); const [, init] = fetchMock.mock.calls[0] as [string, FetchInit]; - const headerKeys = Object.keys(init.headers).map((key) => - key.toLowerCase(), - ); - expect(headerKeys).not.toContain('idempotency-key'); - expect(JSON.parse(init.body ?? '{}')).not.toHaveProperty('idempotencyKey'); + expect(init.headers['Idempotency-Key']?.length).toBeGreaterThan(0); }); - it('maps any 409 to an ambiguous conflict error carrying the body', async () => { + it('maps a plain-string 409 body to an ambiguous conflict error', async () => { const fetchMock = jest.fn( async (): Promise => - errorEnvelope( + textResponse( 409, 'A crypto address with this wallet address already exists', ), @@ -374,7 +471,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { it('maps 5xx to a transient error', async () => { const fetchMock = jest.fn( - async (): Promise => errorEnvelope(500, 'internal error'), + async (): Promise => textResponse(500, 'internal error'), ); const service = buildService(fetchMock); @@ -407,7 +504,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { it('maps 400 to a validation error', async () => { const fetchMock = jest.fn( - async (): Promise => errorEnvelope(400, 'bad message'), + async (): Promise => textResponse(400, 'bad message'), ); const service = buildService(fetchMock); @@ -418,7 +515,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { it('maps an unmapped 4xx (422) to a validation error', async () => { const fetchMock = jest.fn( - async (): Promise => errorEnvelope(422, 'unprocessable'), + async (): Promise => textResponse(422, 'unprocessable'), ); const service = buildService(fetchMock); @@ -429,7 +526,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { it('maps 401 to unauthorized', async () => { const fetchMock = jest.fn( - async (): Promise => errorEnvelope(401, 'session expired'), + async (): Promise => textResponse(401, 'session expired'), ); await expect( @@ -439,10 +536,10 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { it('maps 403 to forbidden and 404 to notFound', async () => { const forbiddenFetch = jest.fn( - async (): Promise => errorEnvelope(403, 'suspended'), + async (): Promise => textResponse(403, 'suspended'), ); const notFoundFetch = jest.fn( - async (): Promise => errorEnvelope(404, 'not found'), + async (): Promise => textResponse(404, 'not found'), ); const forbidden = await buildService(forbiddenFetch) @@ -460,7 +557,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { expect(notFound).toMatchObject({ kind: 'notFound' }); }); - it('maps an error envelope without a code', async () => { + it('maps a JSON error object with message when present', async () => { const fetchMock = jest.fn( async (): Promise => jsonResponse(403, { message: 'forbidden' }), @@ -469,12 +566,80 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { await expect( service.registerSelfHostedWallet(registerRequest), - ).rejects.toMatchObject({ kind: 'forbidden' }); + ).rejects.toMatchObject({ kind: 'forbidden', body: 'forbidden' }); + }); + + it('maps a JSON-encoded string error body', async () => { + const fetchMock = jest.fn( + async (): Promise => + textResponse(409, JSON.stringify('already exists')), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'conflict', body: 'already exists' }); + }); + + it('keeps a JSON object without message as the raw body', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(400, { code: 'x', detail: 'nope' }), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ + kind: 'validation', + body: JSON.stringify({ code: 'x', detail: 'nope' }), + }); + }); + + it('keeps a whitespace-only error body as-is', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(400, ' '), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'validation', body: ' ' }); + }); + + it('omits Error.message when the upstream body is empty', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(400, ''), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ + kind: 'validation', + body: '', + message: 'wallet registration failed: validation', + }); + }); + + it('maps an unreadable error body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => ({ + ok: false, + status: 500, + json: async (): Promise => { + throw new Error('no json'); + }, + text: async (): Promise => { + throw new Error('no text'); + }, + }), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse', httpStatus: 500 }); }); it('maps 429 to a rateLimited error', async () => { const fetchMock = jest.fn( - async (): Promise => errorEnvelope(429, 'slow down'), + async (): Promise => textResponse(429, 'slow down'), ); const service = buildService(fetchMock); @@ -516,15 +681,4 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { service.registerSelfHostedWallet(registerRequest), ).rejects.toMatchObject({ kind: 'malformedResponse' }); }); - - it('maps a non-JSON error body to malformedResponse', async () => { - const fetchMock = jest.fn( - async (): Promise => invalidJsonResponse(400), - ); - const service = buildService(fetchMock); - - await expect( - service.registerSelfHostedWallet(registerRequest), - ).rejects.toMatchObject({ kind: 'malformedResponse' }); - }); }); diff --git a/packages/kyc-controller/src/wallet-registration-service.ts b/packages/kyc-controller/src/wallet-registration-service.ts index 08d118d3b8..240f672047 100644 --- a/packages/kyc-controller/src/wallet-registration-service.ts +++ b/packages/kyc-controller/src/wallet-registration-service.ts @@ -75,11 +75,21 @@ export class WalletRegistrationError extends Error { export type WalletRegistrationServiceOptions = { fetch: FetchLike; + /** + * Base URL of the Money Movement neobank-proxy host + * (e.g. `https://on-ramp.dev-api.cx.metamask.io`). Paths are under `/neobank`. + */ baseUrl: string; getAuthToken: () => Promise; + /** + * MetaMask profile / partner external id used as MoonPay `external_id` + * (typically `AuthenticationController:getSessionProfile().canonicalProfileId`). + */ + getExternalId: () => Promise; }; export type GetRegistrationStatusRequest = { + customerId: string; address: string; blockchain: Blockchain; }; @@ -90,6 +100,11 @@ export type RegisterSelfHostedWalletRequest = { blockchain: Blockchain; message: string; signature: string; + /** + * Stable key reused across retries of the same ownership proof. Generated + * when omitted. + */ + idempotencyKey?: string; }; /** Successful registration outcome. */ @@ -98,9 +113,6 @@ export type RegistrationOutcome = { registration: SelfHostedRegistration; }; -const SELF_HOSTED_PATH = '/vendors/moonpay/self-hosted-wallets'; -const MOONPAY_CUSTOMER_PATH = '/vendors/moonpay/customer'; - /** * Normalizes a Monad EVM address for case-insensitive comparison. * @@ -137,7 +149,53 @@ function mapStatusToKind(status: number): WalletRegistrationErrorKind { } /** - * Data service that talks to the MetaMask backend proxy for MoonPay Iron + * Builds a client-side Idempotency-Key for MoonPay POSTs. Prefer a stable + * caller-supplied key across retries of the same proof. + * + * @returns A random UUID when available, otherwise a timestamped fallback. + */ +export function createIdempotencyKey(): string { + const cryptoObj = globalThis.crypto as + | { randomUUID?: () => string } + | undefined; + if (typeof cryptoObj?.randomUUID === 'function') { + return cryptoObj.randomUUID(); + } + return `wallet-reg-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +/** + * Extracts a human-readable error body from a transparent neobank-proxy + * response. Upstream may return a plain string or a JSON value; both are + * mirrored 1:1 (no `{ code: 'iron_error' }` envelope). + * + * @param raw - Raw response text. + * @returns Normalized body string for {@link WalletRegistrationError}. + */ +export function extractErrorBody(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return raw; + } + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed === 'string') { + return parsed; + } + if (parsed && typeof parsed === 'object') { + const { message } = parsed as { message?: unknown }; + if (typeof message === 'string') { + return message; + } + } + return trimmed; + } catch { + return trimmed; + } +} + +/** + * Data service that talks to the Money Movement neobank-proxy for MoonPay Iron * self-hosted wallet registration. It never calls Iron directly, so the Iron * API key never ships in the client. */ @@ -148,23 +206,36 @@ export class WalletRegistrationService { readonly #getAuthToken: () => Promise; + readonly #getExternalId: () => Promise; + constructor(options: WalletRegistrationServiceOptions) { this.#fetch = options.fetch; this.#baseUrl = options.baseUrl.replace(/\/$/u, ''); this.#getAuthToken = options.getAuthToken; + this.#getExternalId = options.getExternalId; } /** - * Resolves Iron's internal customer id from the authenticated MetaMask - * profile. Used when the current KYC flow has not already received - * `customer.id` from MoonPay's hosted frame. + * Resolves Iron's internal customer id via + * `GET /neobank/customers/{external_id}/external`, using the MetaMask + * profile/canonical id as `external_id`. Used when the current KYC flow has + * not already received `customer.id` from MoonPay's hosted frame. * * @returns Iron's internal customer id. */ async getMoonpayCustomerId(): Promise { - const token = await this.#getAuthToken(); + const [token, externalId] = await Promise.all([ + this.#getAuthToken(), + this.#getExternalId(), + ]); + if (!externalId) { + throw new WalletRegistrationError('malformedResponse', { + message: 'MetaMask external id (canonical profile id) is empty', + }); + } + const response = await this.#fetch( - `${this.#baseUrl}${MOONPAY_CUSTOMER_PATH}`, + `${this.#baseUrl}/neobank/customers/${encodeURIComponent(externalId)}/external`, { method: 'GET', headers: { @@ -187,32 +258,38 @@ export class WalletRegistrationService { }); } - const { customerId } = payload as { customerId?: unknown }; - if (typeof customerId !== 'string' || customerId.length === 0) { + const { id } = payload as { id?: unknown }; + if (typeof id !== 'string' || id.length === 0) { throw new WalletRegistrationError('malformedResponse', { - message: 'MoonPay customer body missing customerId', + message: 'MoonPay customer body missing id', }); } - return customerId; + return id; } /** - * Reconciles a wallet against the customer's registered self-hosted addresses. - * A failed or malformed lookup is reported as `lookupUnavailable` and never - * downgraded to `absent`. + * Reconciles a wallet against the customer's registered self-hosted addresses + * via `GET /neobank/addresses/crypto/{customer_id}?filter=SelfHosted`. + * Upstream returns all self-hosted chains; Monad filtering stays client-side + * for the POC. A failed or malformed lookup is reported as + * `lookupUnavailable` and never downgraded to `absent`. * - * @param request - Monad address to reconcile. + * @param request - Customer id and Monad address to reconcile. * @returns The active / disabled / absent status for the address. */ async getRegistrationStatus( request: GetRegistrationStatusRequest, ): Promise { - const { address, blockchain } = request; + const { customerId, address, blockchain } = request; let response: HttpResponse; try { const token = await this.#getAuthToken(); - response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, { + const url = new URL( + `${this.#baseUrl}/neobank/addresses/crypto/${encodeURIComponent(customerId)}`, + ); + url.searchParams.set('filter', 'SelfHosted'); + response = await this.#fetch(url.toString(), { method: 'GET', headers: { accept: 'application/json', @@ -230,7 +307,7 @@ export class WalletRegistrationService { const body = await response.text(); throw new WalletRegistrationError('lookupUnavailable', { httpStatus: response.status, - body, + body: extractErrorBody(body), }); } @@ -272,11 +349,12 @@ export class WalletRegistrationService { } /** - * Registers a self-hosted wallet through the MetaMask proxy. The proxy - * resolves the customer, derives the idempotency key, and attaches the API - * version, so the client never manages those. Every non-2xx response is - * mapped to a typed error; `409` is deliberately surfaced as an ambiguous - * `conflict` that the caller must reconcile with a follow-up status lookup. + * Registers a self-hosted wallet through neobank-proxy + * `POST /neobank/addresses/crypto/selfhosted`. The client supplies + * `customer_id` and an `Idempotency-Key` (generated when omitted). Every + * non-2xx response is mapped to a typed error; `409` is deliberately + * surfaced as an ambiguous `conflict` that the caller must reconcile with a + * follow-up status lookup. * * @param request - Customer id, address, blockchain, message, and signature. * @returns The registered outcome on success. @@ -284,24 +362,29 @@ export class WalletRegistrationService { async registerSelfHostedWallet( request: RegisterSelfHostedWalletRequest, ): Promise { + const idempotencyKey = request.idempotencyKey ?? createIdempotencyKey(); let response: HttpResponse; try { const token = await this.#getAuthToken(); - response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, { - method: 'POST', - headers: { - accept: 'application/json', - 'content-type': 'application/json', - authorization: `Bearer ${token}`, + response = await this.#fetch( + `${this.#baseUrl}/neobank/addresses/crypto/selfhosted`, + { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ + customer_id: request.customerId, + address: request.address, + blockchain: request.blockchain, + message: request.message, + signature: request.signature, + }), }, - body: JSON.stringify({ - customer_id: request.customerId, - address: request.address, - blockchain: request.blockchain, - message: request.message, - signature: request.signature, - }), - }); + ); } catch (error) { throw new WalletRegistrationError('transient', { message: 'self-hosted registration request failed', @@ -342,21 +425,23 @@ export class WalletRegistrationService { } async #toHttpError(response: HttpResponse): Promise { - let envelope: { message?: string }; + let raw = ''; try { - envelope = (await response.json()) as { message?: string }; + raw = await response.text(); } catch { return new WalletRegistrationError('malformedResponse', { httpStatus: response.status, - message: 'error body was not valid JSON', + message: 'error body could not be read', }); } const { status } = response; const kind = mapStatusToKind(status); + const body = extractErrorBody(raw); return new WalletRegistrationError(kind, { httpStatus: status, - body: envelope.message, + body, + message: body || undefined, }); }