Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/snap-networks-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add the shared Security Alerts API HTTP client for transaction scans (`SecurityAlertsApiClient` posting a typed body to a single `scanUrl`, `SecurityAlertsHttpError`, `SECURITY_ALERTS_REQUEST_HEADERS`), the common scan request wire type (`SecurityAlertsScanRequestBase`), and the scan vocabulary shared by network snaps (`SecurityAlertsScanOption`, `SecurityAlertsScanStatus`, `SecurityAlertResponse`, `normalizeScanOrigin`)

- Add a shared `EstimatedChanges` Snaps JSX component for transaction confirmations, rendering send/receive asset rows with loading, not-available, and no-changes states ([#369](https://github.com/MetaMask/internal-snaps/pull/369))
- Add `SynchronizationError`, `formatAccountSyncFailures`, and the `AccountSyncFailure` type, for reporting account synchronization failures with per-account failure details embedded in the error message (details must live in the message because `snap_trackError` only serializes `name`, `message`, `stack`, and `cause`). ([#374](https://github.com/MetaMask/internal-snaps/pull/374))
- Add `wrapSnapHandlers` to wrap any Snap entrypoint handlers with `withCatchAndThrowSnapError`, with optional per-handler `logError` overrides ([#341](https://github.com/MetaMask/internal-snaps/pull/341))
Expand Down
14 changes: 14 additions & 0 deletions packages/snap-networks-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ export type {
TransactionFinalizedEventProperties,
WebSocketConnectionClosedEventProperties,
} from './services/analytics/AnalyticsService';
export {
SecurityAlertsApiClient,
SecurityAlertsHttpError,
SECURITY_ALERTS_REQUEST_HEADERS,
} from './services/security-alerts/SecurityAlertsApiClient';
export type { SecurityAlertsApiClientOptions } from './services/security-alerts/SecurityAlertsApiClient';
export {
METAMASK_ORIGIN_URL,
normalizeScanOrigin,
SecurityAlertResponse,
SecurityAlertsScanOption,
SecurityAlertsScanStatus,
} from './services/security-alerts/types';
export type { SecurityAlertsScanRequestBase } from './services/security-alerts/types';
export { safeMerge } from './utils/safeMerge/safeMerge';
export { buildUrl } from './utils/buildUrl/buildUrl';
export type { BuildUrlParams } from './utils/buildUrl/buildUrl';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { string, type } from '@metamask/superstruct';
import type { Infer } from '@metamask/superstruct';

import {
SecurityAlertsApiClient,
SecurityAlertsHttpError,
SECURITY_ALERTS_REQUEST_HEADERS,
} from './SecurityAlertsApiClient';

const SCAN_URL = 'https://security-alerts.example/tron/transaction/scan';

const FakeResponseStruct = type({ verdict: string() });
type FakeResponse = Infer<typeof FakeResponseStruct>;

describe('SecurityAlertsApiClient', () => {
const fetchMock = jest.fn() as jest.MockedFunction<typeof globalThis.fetch>;

const responseWith = (overrides: Partial<Response> = {}): Response =>
({
ok: true,
status: 200,
json: jest.fn().mockResolvedValue({ verdict: 'Benign' }),
text: jest.fn().mockResolvedValue('response body'),
...overrides,
}) as unknown as Response;

const createClient = (): SecurityAlertsApiClient =>
new SecurityAlertsApiClient({ scanUrl: SCAN_URL, fetch: fetchMock });

beforeEach(() => {
jest.resetAllMocks();
});

it('rejects an invalid scan URL', () => {
expect(() => new SecurityAlertsApiClient({ scanUrl: 'not-a-url' })).toThrow(
'Invalid URL format',
);
});

it('posts the body to the scan URL with the shared headers', async () => {
fetchMock.mockResolvedValue(responseWith());
const client = createClient();

await client.scanTransaction(
{ transaction: 'fake-transaction', options: ['validation'] },
FakeResponseStruct,
);

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(SCAN_URL, {
headers: {
...SECURITY_ALERTS_REQUEST_HEADERS,
},
method: 'POST',
body: JSON.stringify({
transaction: 'fake-transaction',
options: ['validation'],
}),
});
});

it('defaults to the global fetch when none is provided', async () => {
const globalFetchSpy = jest
.spyOn(globalThis, 'fetch')
.mockResolvedValue(responseWith());
const client = new SecurityAlertsApiClient({ scanUrl: SCAN_URL });

await client.scanTransaction(
{ transaction: 'fake-transaction', options: ['validation'] },
FakeResponseStruct,
);

expect(globalFetchSpy).toHaveBeenCalledTimes(1);
});

it('returns the response validated by the response struct', async () => {
fetchMock.mockResolvedValue(responseWith());
const client = createClient();

// Annotating proves `ResponseT` is inferred from the struct.
const result: FakeResponse = await client.scanTransaction(
{ transaction: 'fake-transaction', options: ['validation'] },
FakeResponseStruct,
);

expect(result).toStrictEqual({ verdict: 'Benign' });
});

it('throws when the response does not match the response struct', async () => {
fetchMock.mockResolvedValue(responseWith({ json: jest.fn() }));
const client = createClient();

await expect(
client.scanTransaction(
{ transaction: 'fake-transaction', options: ['validation'] },
FakeResponseStruct,
),
).rejects.toThrow('Expected an object, but received: undefined');
});

it('throws a SecurityAlertsHttpError carrying the status and response body on a non-2xx response', async () => {
fetchMock.mockResolvedValue(
responseWith({
ok: false,
status: 503,
text: jest.fn().mockResolvedValue('service unavailable'),
}),
);
const client = createClient();

const error = await client
.scanTransaction(
{ transaction: 'fake-transaction', options: ['validation'] },
FakeResponseStruct,
)
.catch((caught: unknown) => caught);

expect(error).toBeInstanceOf(SecurityAlertsHttpError);
expect(error).toMatchObject({
name: 'SecurityAlertsHttpError',
message: 'Security Alerts API error: 503 - service unavailable',
statusCode: 503,
responseBody: 'service unavailable',
});
});

it('carries a null response body when the error body cannot be read', async () => {
fetchMock.mockResolvedValue(
responseWith({
ok: false,
status: 500,
text: jest.fn().mockRejectedValue(new Error('stream consumed')),
}),
);
const client = createClient();

const error = await client
.scanTransaction(
{ transaction: 'fake-transaction', options: ['validation'] },
FakeResponseStruct,
)
.catch((caught: unknown) => caught);

expect(error).toBeInstanceOf(SecurityAlertsHttpError);
expect(error).toMatchObject({
name: 'SecurityAlertsHttpError',
message: 'Security Alerts API error: 500',
statusCode: 500,
responseBody: null,
});
});
});

describe('SecurityAlertsHttpError', () => {
it('omits the body from the message when there is none', () => {
const error = new SecurityAlertsHttpError(429, null);

expect(error.message).toBe('Security Alerts API error: 429');
expect(error.statusCode).toBe(429);
expect(error.responseBody).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { assert } from '@metamask/superstruct';
import type { Struct } from '@metamask/superstruct';

import { UrlStruct } from '../../utils/urlStruct/urlStruct';

/**
* Headers sent with every Security Alerts API request.
*/
export const SECURITY_ALERTS_REQUEST_HEADERS: Record<string, string> = {
'Content-Type': 'application/json',
accept: 'application/json',
};

/**
* Error thrown when the Security Alerts API responds with a non-2xx status.
*/
export class SecurityAlertsHttpError extends Error {
readonly statusCode: number;

readonly responseBody: string | null;

/**
* Creates a new SecurityAlertsHttpError.
*
* @param statusCode - The HTTP status code of the response.
* @param responseBody - The raw response body, when it could be read.
*/
constructor(statusCode: number, responseBody: string | null) {
super(
`Security Alerts API error: ${statusCode}${
responseBody ? ` - ${responseBody}` : ''
}`,
);

this.name = 'SecurityAlertsHttpError';
this.statusCode = statusCode;
this.responseBody = responseBody;
}
}

/**
* Options for a `SecurityAlertsApiClient` constructor.
*/
export type SecurityAlertsApiClientOptions = {
/**
* The full scan endpoint URL, e.g. `https://security-alerts.api.cx.metamask.io/tron/transaction/scan`.
*/
scanUrl: string;
/** The fetch implementation to use. Defaults to `globalThis.fetch`. */
fetch?: typeof globalThis.fetch;
};

/**
* HTTP client for a Security Alerts API scan endpoint.
*
* Owns the request envelope shared by every chain — POSTing the JSON body,
* reporting non-2xx responses as {@link SecurityAlertsHttpError}, and parsing
* the JSON response — and nothing else.
*
* The request body and response payload are chain-specific Blockaid
* contracts: the caller supplies a typed body (e.g. checked with `satisfies`
* against a per-snap body type) and a Superstruct struct for the response,
* so both are type-checked and validated at the call site.
*/
export class SecurityAlertsApiClient {
readonly #fetch: typeof globalThis.fetch;

readonly #scanUrl: string;

/**
* Creates a new SecurityAlertsApiClient.
*
* @param options - The client options.
* @param options.scanUrl - The full scan endpoint URL. Validated once, so
* an invalid URL fails fast at construction.
* @param options.fetch - Optional fetch implementation; defaults to
* `globalThis.fetch`.
*/
constructor({
scanUrl,
fetch: fetchFn = globalThis.fetch,
}: SecurityAlertsApiClientOptions) {
assert(scanUrl, UrlStruct);

this.#fetch = fetchFn;
this.#scanUrl = scanUrl;
}

/**
* Scans a transaction by posting the request body to the scan endpoint.
*
* @param body - The chain-specific scan request body.
* @param responseStruct - Superstruct struct validating the
* chain-specific response payload.
* @returns The validated scan response.
* @throws SecurityAlertsHttpError if the API responds with a non-2xx status.
*/
async scanTransaction<BodyT extends Record<string, unknown>, ResponseT>(
body: BodyT,
responseStruct: Struct<ResponseT>,
): Promise<ResponseT> {
const response = await this.#fetch(this.#scanUrl, {
headers: { ...SECURITY_ALERTS_REQUEST_HEADERS },
method: 'POST',
body: JSON.stringify(body),
});

if (!response.ok) {
let responseBody: string | null = null;
try {
responseBody = await response.text();
} catch {
responseBody = null;
}

throw new SecurityAlertsHttpError(response.status, responseBody);
}

const data = await response.json();
assert(data, responseStruct);

return data;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
METAMASK_ORIGIN_URL,
normalizeScanOrigin,
SecurityAlertResponse,
SecurityAlertsScanOption,
SecurityAlertsScanStatus,
} from './types';

describe('normalizeScanOrigin', () => {
it('maps the MetaMask in-app origin to its URL', () => {
expect(normalizeScanOrigin('metamask')).toBe(METAMASK_ORIGIN_URL);
});

it('returns other origins unchanged', () => {
expect(normalizeScanOrigin('https://example.com')).toBe(
'https://example.com',
);
});
});

describe('shared scan vocabulary', () => {
it('exposes the Security Alerts API scan options', () => {
expect(SecurityAlertsScanOption).toStrictEqual({
Simulation: 'simulation',
Validation: 'validation',
});
});

it('exposes the scan statuses', () => {
expect(SecurityAlertsScanStatus).toStrictEqual({
SUCCESS: 'SUCCESS',
ERROR: 'ERROR',
});
});

it('exposes the security alert verdicts', () => {
expect(SecurityAlertResponse).toStrictEqual({
Benign: 'Benign',
Warning: 'Warning',
Malicious: 'Malicious',
});
});
});
Loading
Loading