Skip to content

Commit

Permalink
feat: ID-2774 Handle 403s from Guardian evaluate endpoint (#2376)
Browse files Browse the repository at this point in the history
  • Loading branch information
imx-mikhala authored Nov 19, 2024
1 parent e230231 commit 6e88a85
Show file tree
Hide file tree
Showing 5 changed files with 190 additions and 37 deletions.
23 changes: 23 additions & 0 deletions packages/passport/sdk/src/confirmation/confirmation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,4 +223,27 @@ describe('confirmation', () => {
});
});
});

describe('showServiceUnavailable', () => {
it('should reject with "Service unavailable" when the unavailable flow is triggered', async () => {
const showConfirmationScreenMock = jest.spyOn(confirmationScreen, 'showConfirmationScreen')
.mockImplementation((href, messageHandler, resolve) => {
resolve();
});

const expectedHref = 'mocked-unavailable-href';
// biome-ignore lint/suspicious/noExplicitAny: test
jest.spyOn(confirmationScreen as any, 'getHref').mockReturnValue(expectedHref);

await expect(confirmationScreen.showServiceUnavailable()).rejects.toThrow('Service unavailable');

expect(showConfirmationScreenMock).toHaveBeenCalledWith(
expectedHref,
expect.any(Function),
expect.any(Function),
);

showConfirmationScreenMock.mockRestore();
});
});
});
13 changes: 13 additions & 0 deletions packages/passport/sdk/src/confirmation/confirmation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,19 @@ export default class ConfirmationScreen {
});
}

showServiceUnavailable(): Promise<void> {
return new Promise((_, reject) => {
this.showConfirmationScreen(
this.getHref('unavailable'),
() => {},
() => {
this.closeWindow();
reject(new Error('Service unavailable'));
},
);
});
}

loading(popupOptions?: { width: number; height: number }) {
if (this.config.crossSdkBridgeEnabled) {
// There is no need to open a confirmation window if cross-sdk bridge is enabled
Expand Down
5 changes: 5 additions & 0 deletions packages/passport/sdk/src/errors/passportError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export enum PassportErrorType {
LINK_WALLET_VALIDATION_ERROR = 'LINK_WALLET_VALIDATION_ERROR',
LINK_WALLET_DUPLICATE_NONCE_ERROR = 'LINK_WALLET_DUPLICATE_NONCE_ERROR',
LINK_WALLET_GENERIC_ERROR = 'LINK_WALLET_GENERIC_ERROR',
SERVICE_UNAVAILABLE_ERROR = 'SERVICE_UNAVAILABLE_ERROR',
}

export function isAPIError(error: any): error is imx.APIError {
Expand All @@ -46,6 +47,10 @@ export const withPassportError = async <T>(
} catch (error) {
let errorMessage: string;

if (error instanceof PassportError && error.type === PassportErrorType.SERVICE_UNAVAILABLE_ERROR) {
throw new PassportError(error.message, error.type);
}

if (isAxiosError(error) && error.response?.data && isAPIError(error.response.data)) {
errorMessage = error.response.data.message;
} else {
Expand Down
94 changes: 94 additions & 0 deletions packages/passport/sdk/src/guardian/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { mockUser, mockUserImx, mockUserZkEvm } from '../test/mocks';
import { JsonRpcError, RpcErrorCode } from '../zkEvm/JsonRpcError';
import { PassportConfiguration } from '../config';
import { ChainId } from '../network/chains';
import { PassportError, PassportErrorType } from '../errors/passportError';

jest.mock('../confirmation/confirmation');

Expand Down Expand Up @@ -102,6 +103,33 @@ describe('Guardian', () => {
await expect(getGuardianClient().evaluateImxTransaction({ payloadHash: 'hash' })).rejects.toThrow('Transaction rejected by user');
});

it('should throw PassportError with SERVICE_UNAVAILABLE_ERROR when evaluateTransaction returns 403', async () => {
mockEvaluateTransaction.mockRejectedValueOnce({
isAxiosError: true,
response: {
status: 403,
},
message: 'Request failed with status code 403',
config: {},
});

mockGetTransactionByID.mockResolvedValueOnce({ data: { id: '1234' } });

// biome-ignore lint/suspicious/noExplicitAny: test
let caughtError: any;
try {
await getGuardianClient().evaluateImxTransaction({ payloadHash: 'hash' });
} catch (err) {
caughtError = err;
}

expect(caughtError).toBeInstanceOf(PassportError);
expect(caughtError.type).toBe(PassportErrorType.SERVICE_UNAVAILABLE_ERROR);
expect(caughtError.message).toBe('Service unavailable');

expect(mockConfirmationScreen.requestConfirmation).not.toHaveBeenCalled();
});

describe('crossSdkBridgeEnabled', () => {
it('throws an error if confirmation is required and the cross sdk bridge flag is enabled', async () => {
mockGetTransactionByID.mockResolvedValueOnce({ data: { id: '1234' } });
Expand Down Expand Up @@ -203,6 +231,49 @@ describe('Guardian', () => {
});
});

it('should throw PassportError with SERVICE_UNAVAILABLE_ERROR when evaluateTransaction returns 403', async () => {
mockEvaluateTransaction.mockRejectedValueOnce({
isAxiosError: true,
response: {
status: 403,
},
message: 'Request failed with status code 403',
config: {},
});

const transactionRequest: TransactionRequest = {
to: mockUserZkEvm.zkEvm.ethAddress,
data: '0x456',
value: '0x',
};

// biome-ignore lint/suspicious/noExplicitAny: test
let caughtError: any;
try {
await getGuardianClient().validateEVMTransaction({
chainId: 'epi123',
nonce: '5',
metaTransactions: [
{
data: transactionRequest.data,
revertOnError: true,
to: mockUserZkEvm.zkEvm.ethAddress,
value: '0x00',
nonce: 5,
},
],
});
} catch (err) {
caughtError = err;
}

expect(caughtError).toBeInstanceOf(PassportError);
expect(caughtError.type).toBe(PassportErrorType.SERVICE_UNAVAILABLE_ERROR);
expect(caughtError.message).toBe('Service unavailable');

expect(mockConfirmationScreen.requestConfirmation).toBeCalledTimes(0);
});

describe('crossSdkBridgeEnabled', () => {
it('throws an error if confirmation is required and the cross sdk bridge flag is enabled', async () => {
mockEvaluateTransaction.mockResolvedValue({ data: { confirmationRequired: true } });
Expand Down Expand Up @@ -261,6 +332,29 @@ describe('Guardian', () => {
expect(mockConfirmationScreen.closeWindow).toBeCalledTimes(0);
});

it('should call showServiceUnavailable and not closeWindow when task errors with SERVICE_UNAVAILABLE_ERROR', async () => {
const mockTask = jest.fn().mockRejectedValueOnce(
new PassportError('Service unavailable', PassportErrorType.SERVICE_UNAVAILABLE_ERROR),
);

const wrappedTask = getGuardianClient().withConfirmationScreenTask()(mockTask);

// biome-ignore lint/suspicious/noExplicitAny: test
let caughtError: any;
try {
await wrappedTask();
} catch (err) {
caughtError = err;
}

expect(caughtError).toBeInstanceOf(PassportError);
expect(caughtError.type).toBe(PassportErrorType.SERVICE_UNAVAILABLE_ERROR);
expect(caughtError.message).toBe('Service unavailable');

expect(mockConfirmationScreen.showServiceUnavailable).toHaveBeenCalled();
expect(mockConfirmationScreen.closeWindow).not.toHaveBeenCalled();
});

describe('withConfirmationScreen', () => {
it('should call the task and close the confirmation screen if the task fails', async () => {
const mockTask = jest.fn().mockRejectedValueOnce(new Error('Task failed'));
Expand Down
92 changes: 55 additions & 37 deletions packages/passport/sdk/src/guardian/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import * as GeneratedClients from '@imtbl/generated-clients';
import { BigNumber, ethers } from 'ethers';
import axios from 'axios';
import AuthManager from '../authManager';
import { ConfirmationScreen } from '../confirmation';
import { retryWithDelay } from '../network/retry';
import { JsonRpcError, ProviderErrorCode, RpcErrorCode } from '../zkEvm/JsonRpcError';
import { MetaTransaction, TypedDataPayload } from '../zkEvm/types';
import { PassportConfiguration } from '../config';
import { getEip155ChainId } from '../zkEvm/walletHelpers';
import { PassportError, PassportErrorType } from '../errors/passportError';

export type GuardianClientParams = {
confirmationScreen: ConfirmationScreen;
Expand Down Expand Up @@ -102,6 +104,11 @@ export default class GuardianClient {
try {
return await task();
} catch (err) {
if (err instanceof PassportError && err.type === PassportErrorType.SERVICE_UNAVAILABLE_ERROR) {
await this.confirmationScreen.showServiceUnavailable();
throw err;
}

this.confirmationScreen.closeWindow();
throw err;
}
Expand All @@ -113,48 +120,55 @@ export default class GuardianClient {
}

public async evaluateImxTransaction({ payloadHash }: GuardianEvaluateImxTransactionParams): Promise<void> {
const finallyFn = () => {
this.confirmationScreen.closeWindow();
};
const user = await this.authManager.getUserImx();

const headers = { Authorization: `Bearer ${user.accessToken}` };
const transactionRes = await retryWithDelay(
async () => this.guardianApi.getTransactionByID({
transactionID: payloadHash,
chainType: 'starkex',
}, { headers }),
{ finallyFn },
);

if (!transactionRes.data.id) {
throw new Error("Transaction doesn't exists");
}
try {
const finallyFn = () => {
this.confirmationScreen.closeWindow();
};
const user = await this.authManager.getUserImx();

const headers = { Authorization: `Bearer ${user.accessToken}` };
const transactionRes = await retryWithDelay(
async () => this.guardianApi.getTransactionByID({
transactionID: payloadHash,
chainType: 'starkex',
}, { headers }),
{ finallyFn },
);

const evaluateImxRes = await this.guardianApi.evaluateTransaction({
id: payloadHash,
transactionEvaluationRequest: {
chainType: 'starkex',
},
}, { headers });

const { confirmationRequired } = evaluateImxRes.data;
if (confirmationRequired) {
if (this.crossSdkBridgeEnabled) {
throw new Error(transactionRejectedCrossSdkBridgeError);
if (!transactionRes.data.id) {
throw new Error("Transaction doesn't exists");
}

const confirmationResult = await this.confirmationScreen.requestConfirmation(
payloadHash,
user.imx.ethAddress,
GeneratedClients.mr.TransactionApprovalRequestChainTypeEnum.Starkex,
);
const evaluateImxRes = await this.guardianApi.evaluateTransaction({
id: payloadHash,
transactionEvaluationRequest: {
chainType: 'starkex',
},
}, { headers });

const { confirmationRequired } = evaluateImxRes.data;
if (confirmationRequired) {
if (this.crossSdkBridgeEnabled) {
throw new Error(transactionRejectedCrossSdkBridgeError);
}

const confirmationResult = await this.confirmationScreen.requestConfirmation(
payloadHash,
user.imx.ethAddress,
GeneratedClients.mr.TransactionApprovalRequestChainTypeEnum.Starkex,
);

if (!confirmationResult.confirmed) {
throw new Error('Transaction rejected by user');
if (!confirmationResult.confirmed) {
throw new Error('Transaction rejected by user');
}
} else {
this.confirmationScreen.closeWindow();
}
} else {
this.confirmationScreen.closeWindow();
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 403) {
throw new PassportError('Service unavailable', PassportErrorType.SERVICE_UNAVAILABLE_ERROR);
}
throw error;
}
}

Expand Down Expand Up @@ -185,6 +199,10 @@ export default class GuardianClient {

return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 403) {
throw new PassportError('Service unavailable', PassportErrorType.SERVICE_UNAVAILABLE_ERROR);
}

const errorMessage = error instanceof Error ? error.message : String(error);
throw new JsonRpcError(
RpcErrorCode.INTERNAL_ERROR,
Expand Down

0 comments on commit 6e88a85

Please sign in to comment.