Skip to content
Merged
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
168 changes: 168 additions & 0 deletions app/store/migrations/134.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { captureException } from '@sentry/react-native';
import migrate, { migrationVersion } from './134';
import { ensureValidState } from './util';

jest.mock('@sentry/react-native', () => ({
captureException: jest.fn(),
}));

jest.mock('./util', () => ({
ensureValidState: jest.fn(),
}));

const mockedEnsureValidState = jest.mocked(ensureValidState);
const mockedCaptureException = jest.mocked(captureException);

const SEI_MAINNET_CHAIN_ID = '0x531';

interface SeiNetworkConfiguration {
blockExplorerUrls: string[];
chainId: string;
defaultBlockExplorerUrlIndex?: number;
defaultRpcEndpointIndex: number;
name: string;
nativeCurrency: string;
rpcEndpoints: {
networkClientId: string;
url: string;
type: string;
failoverUrls?: string[];
}[];
}

interface TestState {
engine: {
backgroundState: {
NetworkController?: {
networkConfigurationsByChainId?: Record<
string,
SeiNetworkConfiguration
>;
selectedNetworkClientId?: string;
[key: string]: unknown;
};
[key: string]: unknown;
};
};
[key: string]: unknown;
}

function buildSeiConfig(blockExplorerUrls: string[]): SeiNetworkConfiguration {
return {
blockExplorerUrls,
chainId: SEI_MAINNET_CHAIN_ID,
defaultBlockExplorerUrlIndex: 0,
defaultRpcEndpointIndex: 0,
name: 'Sei',
nativeCurrency: 'SEI',
rpcEndpoints: [
{
networkClientId: 'sei-mainnet',
url: 'https://sei-mainnet.infura.io/v3/fake',
type: 'custom',
},
],
};
}

function buildState(seiConfig?: SeiNetworkConfiguration): TestState {
return {
engine: {
backgroundState: {
NetworkController: {
networkConfigurationsByChainId: seiConfig
? { [SEI_MAINNET_CHAIN_ID]: seiConfig }
: {},
selectedNetworkClientId: 'mainnet',
},
},
},
};
}

describe(`Migration ${migrationVersion}: Replace Seitrace with Seiscan for Sei Mainnet`, () => {
beforeEach(() => {
jest.clearAllMocks();
mockedEnsureValidState.mockReturnValue(true);
});

it('reports the expected migration version', () => {
expect(migrationVersion).toBe(134);
});

it('rewrites Seitrace block explorer URL to Seiscan for Sei Mainnet', () => {
const state = buildState(buildSeiConfig(['https://seitrace.com']));

const result = migrate(state) as TestState;

const seiConfig =
result.engine.backgroundState.NetworkController
?.networkConfigurationsByChainId?.[SEI_MAINNET_CHAIN_ID];
expect(seiConfig?.blockExplorerUrls).toStrictEqual(['https://seiscan.io/']);
expect(mockedCaptureException).not.toHaveBeenCalled();
});

it('leaves state unchanged when Sei Mainnet is not configured', () => {
const state: TestState = {
engine: {
backgroundState: {
NetworkController: {
networkConfigurationsByChainId: {
'0x1': buildSeiConfig(['https://etherscan.io']),
},
selectedNetworkClientId: 'mainnet',
},
},
},
};
const snapshotBefore = JSON.stringify(state);

const result = migrate(state);

expect(JSON.stringify(result)).toBe(snapshotBefore);
expect(mockedCaptureException).not.toHaveBeenCalled();
});

it('silently skips when NetworkController is missing (upgrade-from-old-version)', () => {
const state = {
engine: {
backgroundState: {
SomeOtherController: { foo: 'bar' },
},
},
};
const snapshotBefore = JSON.stringify(state);

const result = migrate(state);

expect(JSON.stringify(result)).toBe(snapshotBefore);
expect(mockedCaptureException).not.toHaveBeenCalled();
});

it('does not touch user-customized block explorer URLs', () => {
const state = buildState(buildSeiConfig(['https://seistream.app']));

const result = migrate(state) as TestState;

const seiConfig =
result.engine.backgroundState.NetworkController
?.networkConfigurationsByChainId?.[SEI_MAINNET_CHAIN_ID];
expect(seiConfig?.blockExplorerUrls).toStrictEqual([
'https://seistream.app',
]);
expect(mockedCaptureException).not.toHaveBeenCalled();
});

it('does not rewrite a URL whose host only starts with seitrace.com', () => {
const lookalike = 'https://seitrace.com.attacker.example/path';
const state = buildState(buildSeiConfig([lookalike]));

const result = migrate(state) as TestState;

const seiConfig =
result.engine.backgroundState.NetworkController
?.networkConfigurationsByChainId?.[SEI_MAINNET_CHAIN_ID];
expect(seiConfig?.blockExplorerUrls).toStrictEqual([lookalike]);
expect(mockedCaptureException).not.toHaveBeenCalled();
});
});
176 changes: 176 additions & 0 deletions app/store/migrations/134.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { captureException } from '@sentry/react-native';
import {
getErrorMessage,
hasProperty,
Hex,
isHexString,
isObject,
} from '@metamask/utils';

import { ensureValidState } from './util';

/**
* Migration 134: replace the deprecated Seitrace block explorer URL
* (`seitrace.com`, being decommissioned) with its replacement Seiscan
* (`seiscan.io`) for Sei Mainnet on existing user installs.
*
* Users without Sei Mainnet configured: no-op (silent).
* Users who customized the explorer URL away from Seitrace: no-op
* (only entries that still point at `seitrace.com` are rewritten).
* Users missing `NetworkController` entirely: no-op (silent) — expected
* during upgrade-from-old-version.
*/
export const migrationVersion = 134;

const SEI_MAINNET_CHAIN_ID: Hex = '0x531'; // 1329
const OLD_HOSTNAME = 'seitrace.com';
const NEW_HOSTNAME = 'seiscan.io';

interface RpcEndpoint {
failoverUrls?: string[];
name?: string;
networkClientId: string;
url: string;
type: string;
}

interface NetworkConfiguration {
blockExplorerUrls: string[];
chainId: Hex;
defaultBlockExplorerUrlIndex?: number;
defaultRpcEndpointIndex: number;
name: string;
nativeCurrency: string;
rpcEndpoints: RpcEndpoint[];
}

const migration = (state: unknown): unknown => {
if (!ensureValidState(state, migrationVersion)) {
return state;
}

try {
const networkControllerState = validateNetworkController(state);
if (networkControllerState === undefined) {
return state;
}

const { networkConfigurationsByChainId } = networkControllerState;
if (!hasProperty(networkConfigurationsByChainId, SEI_MAINNET_CHAIN_ID)) {
return state;
}

const seiConfig = networkConfigurationsByChainId[SEI_MAINNET_CHAIN_ID];
if (!isValidNetworkConfiguration(seiConfig)) {
return state;
}

const rewritten = seiConfig.blockExplorerUrls.map((url) => {
try {
const parsed = new URL(url);
if (parsed.hostname === OLD_HOSTNAME) {
parsed.hostname = NEW_HOSTNAME;
return parsed.toString();
}
} catch {
// not a valid URL, leave as-is
}
return url;
});
const didChange = rewritten.some(
(url, index) => url !== seiConfig.blockExplorerUrls[index],
);

if (didChange) {
seiConfig.blockExplorerUrls = rewritten;
}
} catch (error) {
captureException(
new Error(
`Migration ${migrationVersion}: Failed to rewrite Sei Mainnet block explorer URL: ${getErrorMessage(
error,
)}`,
),
);
}

return state;
};

export default migration;

// Sentry logging is intentionally omitted — expected-missing states
// (NetworkController absent, Sei not configured) are not errors.
function validateNetworkController(state: {
engine: { backgroundState: Record<string, unknown> };
}):
| {
networkConfigurationsByChainId: Record<Hex, unknown>;
selectedNetworkClientId: string;
}
| undefined {
if (!hasProperty(state.engine.backgroundState, 'NetworkController')) {
// Expected during upgrade-from-old-version — don't log.
return undefined;
}

const networkControllerState = state.engine.backgroundState.NetworkController;

if (!isValidNetworkControllerState(networkControllerState)) {
return undefined;
}

return networkControllerState;
}

function isValidNetworkControllerState(value: unknown): value is {
networkConfigurationsByChainId: Record<Hex, unknown>;
selectedNetworkClientId: string;
} {
if (!isObject(value)) {
return false;
}

if (
!hasProperty(value, 'networkConfigurationsByChainId') ||
!isValidNetworkConfigurationsByChainId(value.networkConfigurationsByChainId)
) {
return false;
}

if (
!hasProperty(value, 'selectedNetworkClientId') ||
typeof value.selectedNetworkClientId !== 'string'
) {
return false;
}

return true;
}

function isValidNetworkConfigurationsByChainId(
value: unknown,
): value is Record<Hex, unknown> {
return (
isObject(value) &&
Object.entries(value).every(
([chainId]) => typeof chainId === 'string' && isHexString(chainId),
)
);
}

// Minimal validator — only chainId + blockExplorerUrls need to be sound
// for this migration.
function isValidNetworkConfiguration(
object: unknown,
): object is NetworkConfiguration {
return (
isObject(object) &&
hasProperty(object, 'chainId') &&
typeof object.chainId === 'string' &&
isHexString(object.chainId) &&
hasProperty(object, 'blockExplorerUrls') &&
Array.isArray(object.blockExplorerUrls) &&
object.blockExplorerUrls.every((url) => typeof url === 'string')
);
}
2 changes: 2 additions & 0 deletions app/store/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ import migration130 from './130';
import migration131 from './131';
import migration132 from './132';
import migration133 from './133';
import migration134 from './134';

// Add migrations above this line
import { ControllerStorage } from '../persistConfig';
Expand Down Expand Up @@ -287,6 +288,7 @@ export const migrationList: MigrationsList = {
131: migration131,
132: migration132,
133: migration133,
134: migration134,
};

// Enable both synchronous and asynchronous migrations
Expand Down
2 changes: 1 addition & 1 deletion app/util/networks/customNetworks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export const PopularList = [
ticker: 'SEI',
warning: true,
rpcPrefs: {
blockExplorerUrl: 'https://seitrace.com/',
blockExplorerUrl: 'https://seiscan.io/',
imageUrl: 'SEI',
imageSource: require('../../images/sei.png'),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export const TX_SENTINEL_NETWORKS_MAP = {
decimals: 18,
},
network: 'sei-mainnet',
explorer: 'https://seitrace.com',
explorer: 'https://seiscan.io',
confirmations: true,
smartTransactions: false,
relayTransactions: false,
Expand Down
2 changes: 1 addition & 1 deletion tests/resources/networks.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ const CustomNetworks = {
rpcUrl: 'https://sei-mainnet.infura.io',
nickname: 'Sei Testnet',
ticker: 'SEI',
BlockExplorerUrl: 'https://seitrace.com/',
BlockExplorerUrl: 'https://seiscan.io/',
},
},
};
Expand Down
Loading