Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/kyc-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `initialize` and `acceptTermsAndStartSession` now accept an optional `product` (`ramps` | `card`), tracked in new `activeProduct` state.
- When a `product` is set, reaching the `form` phase automatically runs the KYC-required check and, when KYC is required, launches the SumSub document-verification sub-flow — no extra `checkKycRequired` / `startSumSub` calls needed. When no `product` is set, the flow stops at `form` for the consumer to drive manually (unchanged behavior).
- Add optional `baseUrl` option to `KycService` constructor that overrides the base URL derived from `env`, enabling clients to target a custom (e.g. local or staging) KYC API ([#9615](https://github.com/MetaMask/core/pull/9615))
- Add UKYC session-status polling to `KycController` ([#9615](https://github.com/MetaMask/core/pull/9615))
- After the SumSub SDK reports completion, the controller now polls the UKYC backend for the session's final verification decision instead of treating the SDK result as final. Polling stops on a terminal `finalStatus` (`approved`, `completed`, `rejected`, `failed`, `blocked`), resolving the sub-flow to `complete` (for `approved` / `completed`) or `failed` (otherwise). Polling is also cleared on `reset` and when a new sub-flow starts.
- Add a new `polling` value to `KycSumSubStatus` and a new `sumsub.sessionStatus` field (typed as the new `KycSessionStatus`) that holds the latest polled status.
- Add `KycController.getSessionStatus` for a one-off session-status fetch, and add an optional `sessionStatusPollIntervalMs` constructor option (defaults to 15000ms).
- Add `KycService.getSessionStatus`, backed by the `GET /sessions/{id}/status` endpoint.

[Unreleased]: https://github.com/MetaMask/core/
14 changes: 14 additions & 0 deletions packages/kyc-controller/src/KycController-method-action-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,19 @@ export type KycControllerStartSumSubAction = {
handler: KycController['startSumSub'];
};

/**
* Fetches the current UKYC session status for the active sub-flow and records
* it on state. Useful for a one-off refresh outside the automatic polling
* loop that {@link startSumSub} runs.
*
* @returns The fetched session status.
* @throws If there is no active SumSub session to query.
*/
export type KycControllerGetSessionStatusAction = {
type: `KycController:getSessionStatus`;
handler: KycController['getSessionStatus'];
};

/**
* Resets the flow to idle, clearing session tokens and sub-flow state while
* preserving persisted terms acceptance and the per-product cache.
Expand All @@ -165,4 +178,5 @@ export type KycControllerMethodActions =
| KycControllerCheckKycRequiredAction
| KycControllerGetKycStatusAction
| KycControllerStartSumSubAction
| KycControllerGetSessionStatusAction
| KycControllerResetAction;
277 changes: 277 additions & 0 deletions packages/kyc-controller/src/KycController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1369,6 +1369,254 @@ describe('KycController', () => {
});
});

describe('session status polling', () => {
afterEach(() => {
jest.useRealTimers();
});

/**
* Makes the launcher report a successful SDK completion so the sub-flow
* proceeds into session-status polling.
*
* @param launcher - The mocked launcher.
*/
function completeSdk(launcher: Launcher): void {
launcher.launch.mockImplementation(async ({ onStatusChange }) => {
onStatusChange?.('InProgress', 'Completed');
return { ok: true };
});
}

it('polls the session status after completion and completes on an approved status', async () => {
await withController(async ({ controller, handlers, launcher }) => {
completeSdk(launcher);
handlers.getSessionStatus.mockResolvedValue(sessionStatus('approved'));

await controller.startSumSub();

expect(handlers.getSessionStatus).toHaveBeenCalledWith({
sessionId: 'sid',
});
expect(controller.state.sumsub.status).toBe('complete');
expect(controller.state.sumsub.sessionStatus).toStrictEqual(
sessionStatus('approved'),
);
});
});

it('maps a rejected terminal status to a failed sub-flow', async () => {
await withController(async ({ controller, handlers, launcher }) => {
completeSdk(launcher);
handlers.getSessionStatus.mockResolvedValue(sessionStatus('rejected'));

await controller.startSumSub();

expect(controller.state.sumsub.status).toBe('failed');
expect(controller.state.sumsub.sessionStatus).toStrictEqual(
sessionStatus('rejected'),
);
});
});

it('treats SDK completion as final when the UKYC session has no id to poll', async () => {
await withController(async ({ controller, handlers, launcher }) => {
// A session created without an id leaves nothing to poll against.
handlers.createUkycSession.mockResolvedValue({
sessionId: '',
wrappingPublicKey: 'wpk',
idosSessionId: 'idos',
});
completeSdk(launcher);

await controller.startSumSub();

expect(handlers.getSessionStatus).not.toHaveBeenCalled();
expect(controller.state.sumsub.status).toBe('complete');
});
});

it('does not poll when the SDK did not report completion', async () => {
await withController(async ({ controller, handlers, launcher }) => {
// The applicant abandons the flow: `launch` resolves without ever
// reporting a Completed status.
launcher.launch.mockResolvedValue({ ok: false });

await controller.startSumSub();

expect(controller.state.sumsub.status).toBe('failed');
expect(handlers.getSessionStatus).not.toHaveBeenCalled();
});
});

it('keeps polling on a transient error, preserving the last good status', async () => {
jest.useFakeTimers();
await withController(
{ options: { sessionStatusPollIntervalMs: 1000 } },
async ({ controller, handlers, launcher }) => {
completeSdk(launcher);
handlers.getSessionStatus
.mockResolvedValueOnce(sessionStatus('pending'))
.mockRejectedValueOnce(new Error('network blip'))
.mockResolvedValueOnce(sessionStatus('approved'));

await controller.startSumSub();

// First poll: non-terminal, keeps polling.
expect(controller.state.sumsub.status).toBe('polling');
expect(controller.state.sumsub.sessionStatus).toStrictEqual(
sessionStatus('pending'),
);

// Second poll fails transiently: the last good status is preserved
// and the loop keeps going.
await jest.advanceTimersByTimeAsync(1000);
expect(controller.state.sumsub.status).toBe('polling');
expect(controller.state.sumsub.sessionStatus).toStrictEqual(
sessionStatus('pending'),
);

// Third poll reaches a terminal status.
await jest.advanceTimersByTimeAsync(1000);
expect(controller.state.sumsub.status).toBe('complete');
expect(controller.state.sumsub.sessionStatus).toStrictEqual(
sessionStatus('approved'),
);
expect(handlers.getSessionStatus).toHaveBeenCalledTimes(3);
},
);
});

it('stops polling once a terminal status is reached', async () => {
jest.useFakeTimers();
await withController(
{ options: { sessionStatusPollIntervalMs: 1000 } },
async ({ controller, handlers, launcher }) => {
completeSdk(launcher);
handlers.getSessionStatus.mockResolvedValue(sessionStatus('approved'));

await controller.startSumSub();
expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1);

// No further polls after a terminal status.
await jest.advanceTimersByTimeAsync(5000);
expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1);
},
);
});

it('stops polling when reset() is called', async () => {
jest.useFakeTimers();
await withController(
{ options: { sessionStatusPollIntervalMs: 1000 } },
async ({ controller, handlers, launcher }) => {
completeSdk(launcher);
handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending'));

await controller.startSumSub();
expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1);

controller.reset();
await jest.advanceTimersByTimeAsync(5000);

// The scheduled poll was cancelled by reset().
expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1);
expect(controller.state.sumsub.status).toBe('idle');
},
);
});

it('discards a poll result when reset() runs while the request is in flight', async () => {
await withController(async ({ controller, handlers, launcher }) => {
completeSdk(launcher);
// Simulate a reset() landing while the status request is in flight.
handlers.getSessionStatus.mockImplementation(async () => {
controller.reset();
return sessionStatus('approved');
});

await controller.startSumSub();

expect(controller.state.sumsub.status).toBe('idle');
expect(controller.state.sumsub.sessionStatus).toBeNull();
});
});

it('supersedes a prior polling loop when a new sub-flow starts', async () => {
jest.useFakeTimers();
await withController(
{ options: { sessionStatusPollIntervalMs: 1000 } },
async ({ controller, handlers, launcher }) => {
completeSdk(launcher);
// First sub-flow polls a never-terminal status.
handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending'));

await controller.startSumSub();
expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1);

// A second sub-flow reaches a terminal status on its first poll and
// must cancel the first loop's scheduled poll.
handlers.getSessionStatus.mockResolvedValue(
sessionStatus('approved'),
);
await controller.startSumSub();
expect(controller.state.sumsub.status).toBe('complete');

const callsAfterSecondFlow =
handlers.getSessionStatus.mock.calls.length;
await jest.advanceTimersByTimeAsync(5000);

// No stray polls from the superseded first loop.
expect(handlers.getSessionStatus).toHaveBeenCalledTimes(
callsAfterSecondFlow,
);
},
);
});
});

describe('getSessionStatus', () => {
it('fetches and records the session status on demand', async () => {
await withController(
{
options: {
state: {
sumsub: {
status: 'complete',
result: null,
sessionId: 'sid',
applicantAccessToken: null,
sessionStatus: null,
},
},
},
},
async ({ controller, handlers }) => {
handlers.getSessionStatus.mockResolvedValue(
sessionStatus('approved'),
);

const result = await controller.getSessionStatus();

expect(handlers.getSessionStatus).toHaveBeenCalledWith({
sessionId: 'sid',
});
expect(result).toStrictEqual(sessionStatus('approved'));
expect(controller.state.sumsub.sessionStatus).toStrictEqual(
sessionStatus('approved'),
);
},
);
});

it('throws when there is no active SumSub session', async () => {
await withController(async ({ controller }) => {
await expect(controller.getSessionStatus()).rejects.toThrow(
/no active SumSub session/u,
);
});
});
});

describe('reset', () => {
it('clears session state but preserves persisted terms', async () => {
await withController(
Expand Down Expand Up @@ -1422,6 +1670,7 @@ type ServiceHandlers = {
checkKycRequired: jest.Mock;
createUkycSession: jest.Mock;
submitWrappedKey: jest.Mock;
getSessionStatus: jest.Mock;
};

type Launcher = {
Expand All @@ -1447,8 +1696,31 @@ const SERVICE_ACTIONS = [
'KycService:checkKycRequired',
'KycService:createUkycSession',
'KycService:submitWrappedKey',
'KycService:getSessionStatus',
] as const;

/**
* Builds a UKYC session status payload with a given `finalStatus`.
*
* @param finalStatus - The overall session status.
* @returns A complete session status object.
*/
function sessionStatus(finalStatus: string): {
finalStatus: string;
externalUserId: string;
kycStatus: string;
vendor: string;
vendorStatus: string;
} {
return {
finalStatus,
externalUserId: 'ext-1',
kycStatus: finalStatus,
vendor: 'sumsub',
vendorStatus: finalStatus,
};
}

/**
* Wraps a test with a fully-wired controller, mocked service handlers, and a
* mocked SumSub launcher.
Expand Down Expand Up @@ -1491,6 +1763,7 @@ function withController<ReturnValue>(
submitWrappedKey: jest
.fn()
.mockResolvedValue({ status: 'ok', applicantAccessToken: 'aat' }),
getSessionStatus: jest.fn().mockResolvedValue(sessionStatus('approved')),
};
rootMessenger.registerActionHandler(
'KycService:getGeoCountry',
Expand All @@ -1516,6 +1789,10 @@ function withController<ReturnValue>(
'KycService:submitWrappedKey',
handlers.submitWrappedKey,
);
rootMessenger.registerActionHandler(
'KycService:getSessionStatus',
handlers.getSessionStatus,
);

const launcher: Launcher = {
isAvailable: jest.fn().mockReturnValue(true),
Expand Down
Loading
Loading