Skip to content

Commit 2e4b3d7

Browse files
authored
fix: migrate Sei explorer from Seitrace to Seiscan (#29221)
## **Description** Seitrace (`https://seitrace.com`), the current Sei Mainnet block explorer, is being decommissioned. This PR swaps every hardcoded reference to `seiscan.io` and adds migration **134** to rewrite existing users' persisted `NetworkController` state on upgrade. Hardcoded URL swaps: - `app/util/networks/customNetworks.tsx` — Sei Mainnet `blockExplorerUrl` - `tests/resources/networks.e2e.js` — e2e resource - `tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts` — mock `explorer` Migration 134 rewrites `engine.backgroundState.NetworkController.networkConfigurationsByChainId['0x531'].blockExplorerUrls` from `seitrace.com` to `seiscan.io` for existing installs. It only touches entries still pointing at `seitrace.com` — a user who customized their Sei block explorer (e.g. to `seistream.app`) is left alone. The migration follows mobile's current pattern (sync arrow function with in-place `state` mutation), and is registered in `app/store/migrations/index.ts`. Cross-repo reference for this family of block-explorer-URL migrations is [`metamask-extension/app/scripts/migrations/197.ts`](https://github.com/MetaMask/metamask-extension/blob/main/app/scripts/migrations/197.ts). The `@metamask/controller-utils` bump is deliberately deferred until the sibling PR in `MetaMask/core` releases; this PR stands alone. ## **Changelog** CHANGELOG entry: Fixed Sei Mainnet: replaced deprecated Seitrace explorer with Seiscan (`https://seiscan.io`). Existing installs are migrated via migration 134. ## **Related issues** Fixes: Companion PRs: - [`MetaMask/core#8545`](MetaMask/core#8545) — default `BlockExplorerUrl[SeiMainnet]` - [`MetaMask/metafi-sdk#525`](MetaMask/metafi-sdk#525) — shared SDK `SEI_EXPLORER` - [`MetaMask/metamask-extension#42064`](MetaMask/metamask-extension#42064) — extension migration 207 ## **Manual testing steps** ```gherkin Feature: Sei Mainnet block explorer URL Scenario: fresh install uses Seiscan Given a fresh install of MetaMask Mobile When the user adds the Sei Mainnet network Then the network's block-explorer URL is "https://seiscan.io/" And tapping a Sei transaction's "View on block explorer" opens "https://seiscan.io/tx/<hash>" Scenario: existing user with Seitrace URL is migrated Given a build prior to this change with Sei Mainnet added And the stored "blockExplorerUrls" is ["https://seitrace.com"] When the user upgrades to this build Then migration 134 runs And the stored "blockExplorerUrls" for Sei Mainnet is ["https://seiscan.io"] Scenario: user-customized URL is preserved Given a build prior to this change with Sei Mainnet added And the user has customized "blockExplorerUrls" to ["https://seistream.app"] When the user upgrades to this build Then migration 134 is a no-op for this entry And the stored "blockExplorerUrls" for Sei Mainnet remains ["https://seistream.app"] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds a new persisted-state migration that mutates `NetworkController` network configs for Sei Mainnet; while narrowly scoped, migrations run on upgrade and can affect existing user state if bugs slip through. > > **Overview** > Updates Sei Mainnet’s default block explorer from **Seitrace** to **Seiscan** across the in-app popular network config and test fixtures. > > Adds migration `134` (registered in `app/store/migrations/index.ts`) to rewrite persisted Sei Mainnet `blockExplorerUrls` entries whose URL hostname is exactly `seitrace.com` to `seiscan.io`, while leaving missing/invalid controller state and user-customized/lookalike URLs untouched; includes unit tests covering the rewrite and no-op cases. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 68e91bc. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 794a997 commit 2e4b3d7

6 files changed

Lines changed: 349 additions & 3 deletions

File tree

app/store/migrations/134.test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { captureException } from '@sentry/react-native';
2+
import migrate, { migrationVersion } from './134';
3+
import { ensureValidState } from './util';
4+
5+
jest.mock('@sentry/react-native', () => ({
6+
captureException: jest.fn(),
7+
}));
8+
9+
jest.mock('./util', () => ({
10+
ensureValidState: jest.fn(),
11+
}));
12+
13+
const mockedEnsureValidState = jest.mocked(ensureValidState);
14+
const mockedCaptureException = jest.mocked(captureException);
15+
16+
const SEI_MAINNET_CHAIN_ID = '0x531';
17+
18+
interface SeiNetworkConfiguration {
19+
blockExplorerUrls: string[];
20+
chainId: string;
21+
defaultBlockExplorerUrlIndex?: number;
22+
defaultRpcEndpointIndex: number;
23+
name: string;
24+
nativeCurrency: string;
25+
rpcEndpoints: {
26+
networkClientId: string;
27+
url: string;
28+
type: string;
29+
failoverUrls?: string[];
30+
}[];
31+
}
32+
33+
interface TestState {
34+
engine: {
35+
backgroundState: {
36+
NetworkController?: {
37+
networkConfigurationsByChainId?: Record<
38+
string,
39+
SeiNetworkConfiguration
40+
>;
41+
selectedNetworkClientId?: string;
42+
[key: string]: unknown;
43+
};
44+
[key: string]: unknown;
45+
};
46+
};
47+
[key: string]: unknown;
48+
}
49+
50+
function buildSeiConfig(blockExplorerUrls: string[]): SeiNetworkConfiguration {
51+
return {
52+
blockExplorerUrls,
53+
chainId: SEI_MAINNET_CHAIN_ID,
54+
defaultBlockExplorerUrlIndex: 0,
55+
defaultRpcEndpointIndex: 0,
56+
name: 'Sei',
57+
nativeCurrency: 'SEI',
58+
rpcEndpoints: [
59+
{
60+
networkClientId: 'sei-mainnet',
61+
url: 'https://sei-mainnet.infura.io/v3/fake',
62+
type: 'custom',
63+
},
64+
],
65+
};
66+
}
67+
68+
function buildState(seiConfig?: SeiNetworkConfiguration): TestState {
69+
return {
70+
engine: {
71+
backgroundState: {
72+
NetworkController: {
73+
networkConfigurationsByChainId: seiConfig
74+
? { [SEI_MAINNET_CHAIN_ID]: seiConfig }
75+
: {},
76+
selectedNetworkClientId: 'mainnet',
77+
},
78+
},
79+
},
80+
};
81+
}
82+
83+
describe(`Migration ${migrationVersion}: Replace Seitrace with Seiscan for Sei Mainnet`, () => {
84+
beforeEach(() => {
85+
jest.clearAllMocks();
86+
mockedEnsureValidState.mockReturnValue(true);
87+
});
88+
89+
it('reports the expected migration version', () => {
90+
expect(migrationVersion).toBe(134);
91+
});
92+
93+
it('rewrites Seitrace block explorer URL to Seiscan for Sei Mainnet', () => {
94+
const state = buildState(buildSeiConfig(['https://seitrace.com']));
95+
96+
const result = migrate(state) as TestState;
97+
98+
const seiConfig =
99+
result.engine.backgroundState.NetworkController
100+
?.networkConfigurationsByChainId?.[SEI_MAINNET_CHAIN_ID];
101+
expect(seiConfig?.blockExplorerUrls).toStrictEqual(['https://seiscan.io/']);
102+
expect(mockedCaptureException).not.toHaveBeenCalled();
103+
});
104+
105+
it('leaves state unchanged when Sei Mainnet is not configured', () => {
106+
const state: TestState = {
107+
engine: {
108+
backgroundState: {
109+
NetworkController: {
110+
networkConfigurationsByChainId: {
111+
'0x1': buildSeiConfig(['https://etherscan.io']),
112+
},
113+
selectedNetworkClientId: 'mainnet',
114+
},
115+
},
116+
},
117+
};
118+
const snapshotBefore = JSON.stringify(state);
119+
120+
const result = migrate(state);
121+
122+
expect(JSON.stringify(result)).toBe(snapshotBefore);
123+
expect(mockedCaptureException).not.toHaveBeenCalled();
124+
});
125+
126+
it('silently skips when NetworkController is missing (upgrade-from-old-version)', () => {
127+
const state = {
128+
engine: {
129+
backgroundState: {
130+
SomeOtherController: { foo: 'bar' },
131+
},
132+
},
133+
};
134+
const snapshotBefore = JSON.stringify(state);
135+
136+
const result = migrate(state);
137+
138+
expect(JSON.stringify(result)).toBe(snapshotBefore);
139+
expect(mockedCaptureException).not.toHaveBeenCalled();
140+
});
141+
142+
it('does not touch user-customized block explorer URLs', () => {
143+
const state = buildState(buildSeiConfig(['https://seistream.app']));
144+
145+
const result = migrate(state) as TestState;
146+
147+
const seiConfig =
148+
result.engine.backgroundState.NetworkController
149+
?.networkConfigurationsByChainId?.[SEI_MAINNET_CHAIN_ID];
150+
expect(seiConfig?.blockExplorerUrls).toStrictEqual([
151+
'https://seistream.app',
152+
]);
153+
expect(mockedCaptureException).not.toHaveBeenCalled();
154+
});
155+
156+
it('does not rewrite a URL whose host only starts with seitrace.com', () => {
157+
const lookalike = 'https://seitrace.com.attacker.example/path';
158+
const state = buildState(buildSeiConfig([lookalike]));
159+
160+
const result = migrate(state) as TestState;
161+
162+
const seiConfig =
163+
result.engine.backgroundState.NetworkController
164+
?.networkConfigurationsByChainId?.[SEI_MAINNET_CHAIN_ID];
165+
expect(seiConfig?.blockExplorerUrls).toStrictEqual([lookalike]);
166+
expect(mockedCaptureException).not.toHaveBeenCalled();
167+
});
168+
});

app/store/migrations/134.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { captureException } from '@sentry/react-native';
2+
import {
3+
getErrorMessage,
4+
hasProperty,
5+
Hex,
6+
isHexString,
7+
isObject,
8+
} from '@metamask/utils';
9+
10+
import { ensureValidState } from './util';
11+
12+
/**
13+
* Migration 134: replace the deprecated Seitrace block explorer URL
14+
* (`seitrace.com`, being decommissioned) with its replacement Seiscan
15+
* (`seiscan.io`) for Sei Mainnet on existing user installs.
16+
*
17+
* Users without Sei Mainnet configured: no-op (silent).
18+
* Users who customized the explorer URL away from Seitrace: no-op
19+
* (only entries that still point at `seitrace.com` are rewritten).
20+
* Users missing `NetworkController` entirely: no-op (silent) — expected
21+
* during upgrade-from-old-version.
22+
*/
23+
export const migrationVersion = 134;
24+
25+
const SEI_MAINNET_CHAIN_ID: Hex = '0x531'; // 1329
26+
const OLD_HOSTNAME = 'seitrace.com';
27+
const NEW_HOSTNAME = 'seiscan.io';
28+
29+
interface RpcEndpoint {
30+
failoverUrls?: string[];
31+
name?: string;
32+
networkClientId: string;
33+
url: string;
34+
type: string;
35+
}
36+
37+
interface NetworkConfiguration {
38+
blockExplorerUrls: string[];
39+
chainId: Hex;
40+
defaultBlockExplorerUrlIndex?: number;
41+
defaultRpcEndpointIndex: number;
42+
name: string;
43+
nativeCurrency: string;
44+
rpcEndpoints: RpcEndpoint[];
45+
}
46+
47+
const migration = (state: unknown): unknown => {
48+
if (!ensureValidState(state, migrationVersion)) {
49+
return state;
50+
}
51+
52+
try {
53+
const networkControllerState = validateNetworkController(state);
54+
if (networkControllerState === undefined) {
55+
return state;
56+
}
57+
58+
const { networkConfigurationsByChainId } = networkControllerState;
59+
if (!hasProperty(networkConfigurationsByChainId, SEI_MAINNET_CHAIN_ID)) {
60+
return state;
61+
}
62+
63+
const seiConfig = networkConfigurationsByChainId[SEI_MAINNET_CHAIN_ID];
64+
if (!isValidNetworkConfiguration(seiConfig)) {
65+
return state;
66+
}
67+
68+
const rewritten = seiConfig.blockExplorerUrls.map((url) => {
69+
try {
70+
const parsed = new URL(url);
71+
if (parsed.hostname === OLD_HOSTNAME) {
72+
parsed.hostname = NEW_HOSTNAME;
73+
return parsed.toString();
74+
}
75+
} catch {
76+
// not a valid URL, leave as-is
77+
}
78+
return url;
79+
});
80+
const didChange = rewritten.some(
81+
(url, index) => url !== seiConfig.blockExplorerUrls[index],
82+
);
83+
84+
if (didChange) {
85+
seiConfig.blockExplorerUrls = rewritten;
86+
}
87+
} catch (error) {
88+
captureException(
89+
new Error(
90+
`Migration ${migrationVersion}: Failed to rewrite Sei Mainnet block explorer URL: ${getErrorMessage(
91+
error,
92+
)}`,
93+
),
94+
);
95+
}
96+
97+
return state;
98+
};
99+
100+
export default migration;
101+
102+
// Sentry logging is intentionally omitted — expected-missing states
103+
// (NetworkController absent, Sei not configured) are not errors.
104+
function validateNetworkController(state: {
105+
engine: { backgroundState: Record<string, unknown> };
106+
}):
107+
| {
108+
networkConfigurationsByChainId: Record<Hex, unknown>;
109+
selectedNetworkClientId: string;
110+
}
111+
| undefined {
112+
if (!hasProperty(state.engine.backgroundState, 'NetworkController')) {
113+
// Expected during upgrade-from-old-version — don't log.
114+
return undefined;
115+
}
116+
117+
const networkControllerState = state.engine.backgroundState.NetworkController;
118+
119+
if (!isValidNetworkControllerState(networkControllerState)) {
120+
return undefined;
121+
}
122+
123+
return networkControllerState;
124+
}
125+
126+
function isValidNetworkControllerState(value: unknown): value is {
127+
networkConfigurationsByChainId: Record<Hex, unknown>;
128+
selectedNetworkClientId: string;
129+
} {
130+
if (!isObject(value)) {
131+
return false;
132+
}
133+
134+
if (
135+
!hasProperty(value, 'networkConfigurationsByChainId') ||
136+
!isValidNetworkConfigurationsByChainId(value.networkConfigurationsByChainId)
137+
) {
138+
return false;
139+
}
140+
141+
if (
142+
!hasProperty(value, 'selectedNetworkClientId') ||
143+
typeof value.selectedNetworkClientId !== 'string'
144+
) {
145+
return false;
146+
}
147+
148+
return true;
149+
}
150+
151+
function isValidNetworkConfigurationsByChainId(
152+
value: unknown,
153+
): value is Record<Hex, unknown> {
154+
return (
155+
isObject(value) &&
156+
Object.entries(value).every(
157+
([chainId]) => typeof chainId === 'string' && isHexString(chainId),
158+
)
159+
);
160+
}
161+
162+
// Minimal validator — only chainId + blockExplorerUrls need to be sound
163+
// for this migration.
164+
function isValidNetworkConfiguration(
165+
object: unknown,
166+
): object is NetworkConfiguration {
167+
return (
168+
isObject(object) &&
169+
hasProperty(object, 'chainId') &&
170+
typeof object.chainId === 'string' &&
171+
isHexString(object.chainId) &&
172+
hasProperty(object, 'blockExplorerUrls') &&
173+
Array.isArray(object.blockExplorerUrls) &&
174+
object.blockExplorerUrls.every((url) => typeof url === 'string')
175+
);
176+
}

app/store/migrations/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ import migration130 from './130';
134134
import migration131 from './131';
135135
import migration132 from './132';
136136
import migration133 from './133';
137+
import migration134 from './134';
137138

138139
// Add migrations above this line
139140
import { ControllerStorage } from '../persistConfig';
@@ -287,6 +288,7 @@ export const migrationList: MigrationsList = {
287288
131: migration131,
288289
132: migration132,
289290
133: migration133,
291+
134: migration134,
290292
};
291293

292294
// Enable both synchronous and asynchronous migrations

app/util/networks/customNetworks.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export const PopularList = [
159159
ticker: 'SEI',
160160
warning: true,
161161
rpcPrefs: {
162-
blockExplorerUrl: 'https://seitrace.com/',
162+
blockExplorerUrl: 'https://seiscan.io/',
163163
imageUrl: 'SEI',
164164
imageSource: require('../../images/sei.png'),
165165
},

tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export const TX_SENTINEL_NETWORKS_MAP = {
6464
decimals: 18,
6565
},
6666
network: 'sei-mainnet',
67-
explorer: 'https://seitrace.com',
67+
explorer: 'https://seiscan.io',
6868
confirmations: true,
6969
smartTransactions: false,
7070
relayTransactions: false,

tests/resources/networks.e2e.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ const CustomNetworks = {
192192
rpcUrl: 'https://sei-mainnet.infura.io',
193193
nickname: 'Sei Testnet',
194194
ticker: 'SEI',
195-
BlockExplorerUrl: 'https://seitrace.com/',
195+
BlockExplorerUrl: 'https://seiscan.io/',
196196
},
197197
},
198198
};

0 commit comments

Comments
 (0)