Skip to content

Commit 1124928

Browse files
authored
Merge pull request #2326 from AmbireTech/config/phishing-controller-updates
Config / Make Phishing controller blacklist updates UI-aware and improve update intervals (15m when active, 6h when inactive)
2 parents 1db4551 + 11a483a commit 1124928

6 files changed

Lines changed: 157 additions & 17 deletions

File tree

src/consts/intervals.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ export const VIEW_ONLY_ACCOUNT_IDENTITY_GET_INTERVAL = 300000 // 5 minutes
99
export const ACCOUNT_STATE_STAND_BY_INTERVAL = 300000 // 5 minutes
1010
export const NETWORKS_UPDATE_INTERVAL = 8 * 60 * 60 * 1000 // 8 hrs
1111
export const BLACKLIST_UPDATE_INTERVAL = 8 * 60 * 60 * 1000 // 8 hrs
12-
export const PHISHING_UPDATE_INTERVAL = 3 * 60 * 60 * 1000 // 3 hrs
12+
export const PHISHING_INACTIVE_UPDATE_INTERVAL = 6 * 60 * 60 * 1000 // 6 hrs
13+
export const PHISHING_ACTIVE_UPDATE_INTERVAL = 15 * 60 * 1000 // 15 minutes
1314
export const PHISHING_FAILED_TO_GET_UPDATE_INTERVAL = 600000 // 10 minutes
1415
export const ESTIMATE_UPDATE_INTERVAL = 30000
1516
export const GAS_PRICE_UPDATE_INTERVAL = 12000

src/controllers/main/main.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,8 @@ export class MainController extends EventEmitter implements IMainController {
415415
eventEmitterRegistry,
416416
fetch: this.fetch,
417417
storage: this.storage,
418-
addressBook: this.addressBook
418+
addressBook: this.addressBook,
419+
ui: this.ui
419420
})
420421

421422
this.callRelayer = relayerCall.bind({ url: relayerUrl, fetch: this.fetch })

src/controllers/phishing/phishing.test.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import { expect } from '@jest/globals'
1+
import { expect, jest } from '@jest/globals'
22

33
import { makeMainController } from '../../../test/helpers/mainController'
4+
import {
5+
PHISHING_ACTIVE_UPDATE_INTERVAL,
6+
PHISHING_INACTIVE_UPDATE_INTERVAL
7+
} from '../../consts/intervals'
48

59
const prepareTest = async () => {
610
const { mainCtrl } = await makeMainController(async (storageCtrl) => {
@@ -19,7 +23,13 @@ const prepareTest = async () => {
1923
}
2024
})
2125
})
22-
return { controller: mainCtrl.phishing }
26+
return { controller: mainCtrl.phishing, ui: mainCtrl.ui }
27+
}
28+
29+
const flushMicrotaskQueue = async () => Promise.resolve()
30+
31+
const removeAllViews = (ui: Awaited<ReturnType<typeof prepareTest>>['ui']) => {
32+
ui.views.map((view) => view.id).forEach((viewId) => ui.removeView(viewId))
2333
}
2434

2535
describe('PhishingController', () => {
@@ -48,4 +58,47 @@ describe('PhishingController', () => {
4858
}
4959
)
5060
})
61+
62+
test('should switch phishing update interval to active when an active view is added and back to inactive when all active views are closed', async () => {
63+
const { controller, ui } = await prepareTest()
64+
65+
// Ensure we start from a predictable empty views state.
66+
removeAllViews(ui)
67+
await flushMicrotaskQueue()
68+
69+
expect(controller.updatePhishingInterval.currentTimeout).toBe(PHISHING_INACTIVE_UPDATE_INTERVAL)
70+
71+
ui.addView({
72+
id: 'phishing-test-request-window-1',
73+
type: 'request-window',
74+
currentRoute: 'sign-account-op',
75+
isReady: true
76+
})
77+
await flushMicrotaskQueue()
78+
expect(controller.updatePhishingInterval.currentTimeout).toBe(PHISHING_ACTIVE_UPDATE_INTERVAL)
79+
80+
ui.removeView('phishing-test-request-window-1')
81+
await flushMicrotaskQueue()
82+
expect(controller.updatePhishingInterval.currentTimeout).toBe(PHISHING_INACTIVE_UPDATE_INTERVAL)
83+
})
84+
85+
test('should restart phishing interval immediately when an active view is added', async () => {
86+
const { controller, ui } = await prepareTest()
87+
const restartSpy = jest.spyOn(controller.updatePhishingInterval, 'restart')
88+
89+
// Ensure we start from a predictable empty views state.
90+
removeAllViews(ui)
91+
92+
ui.addView({
93+
id: 'phishing-test-request-window-2',
94+
type: 'request-window',
95+
currentRoute: 'sign-account-op',
96+
isReady: true
97+
})
98+
99+
expect(restartSpy).toHaveBeenCalledWith({
100+
timeout: PHISHING_ACTIVE_UPDATE_INTERVAL,
101+
runImmediately: true
102+
})
103+
})
51104
})

src/controllers/phishing/phishing.ts

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,23 @@ import { zeroAddress } from 'viem'
55

66
import { RecurringTimeout } from '../../classes/recurringTimeout/recurringTimeout'
77
import {
8+
PHISHING_ACTIVE_UPDATE_INTERVAL,
89
PHISHING_FAILED_TO_GET_UPDATE_INTERVAL,
9-
PHISHING_UPDATE_INTERVAL
10+
PHISHING_INACTIVE_UPDATE_INTERVAL
1011
} from '../../consts/intervals'
1112
import { IAddressBookController } from '../../interfaces/addressBook'
1213
import { IEventEmitterRegistryController } from '../../interfaces/eventEmitter'
1314
import { Fetch } from '../../interfaces/fetch'
1415
import { BlacklistedStatus, IPhishingController } from '../../interfaces/phishing'
1516
import { IStorageController } from '../../interfaces/storage'
17+
import { IUiController } from '../../interfaces/ui'
1618
import { getDappIdFromUrl } from '../../libs/dapps/helpers'
1719
/* eslint-disable no-restricted-syntax */
1820
import { fetchWithTimeout } from '../../utils/fetch'
1921
import EventEmitter from '../eventEmitter/eventEmitter'
2022

2123
const SCAMCHECKER_BASE_URL = 'https://cena.ambire.com/api/v3/scamchecker'
24+
const PHISHING_ACTIVE_VIEW_TYPES = new Set(['request-window', 'popup', 'tab'])
2225

2326
export class PhishingController extends EventEmitter implements IPhishingController {
2427
#fetch: Fetch
@@ -27,10 +30,13 @@ export class PhishingController extends EventEmitter implements IPhishingControl
2730

2831
#addressBook: IAddressBookController
2932

33+
#ui: IUiController
34+
3035
#domains = new Set<string>()
3136

3237
#addresses = new Set<string>()
3338

39+
// Local versioning, used for requesting incremental phishing list updates.
3440
#version: number = 0
3541

3642
#updatedAt: number | null = null
@@ -43,6 +49,8 @@ export class PhishingController extends EventEmitter implements IPhishingControl
4349

4450
#shouldSyncDapps: boolean = false
4551

52+
#continuouslyUpdatePhishingPromise?: Promise<void>
53+
4654
get updatePhishingInterval() {
4755
return this.#updatePhishingInterval
4856
}
@@ -62,25 +70,51 @@ export class PhishingController extends EventEmitter implements IPhishingControl
6270
eventEmitterRegistry,
6371
fetch,
6472
storage,
65-
addressBook
73+
addressBook,
74+
ui
6675
}: {
6776
eventEmitterRegistry?: IEventEmitterRegistryController
6877
fetch: Fetch
6978
storage: IStorageController
7079
addressBook: IAddressBookController
80+
ui: IUiController
7181
}) {
7282
super(eventEmitterRegistry)
7383

7484
this.#fetch = fetch
7585
this.#storage = storage
7686
this.#addressBook = addressBook
87+
this.#ui = ui
7788

7889
this.#updatePhishingInterval = new RecurringTimeout(
7990
async () => this.continuouslyUpdatePhishing(),
80-
PHISHING_UPDATE_INTERVAL,
91+
PHISHING_INACTIVE_UPDATE_INTERVAL,
8192
this.emitError.bind(this)
8293
)
8394

95+
this.#ui.uiEvent.on('addView', (view) => {
96+
const isActiveViewType = PHISHING_ACTIVE_VIEW_TYPES.has(view.type)
97+
const isAlreadyUsingActiveUpdateInterval =
98+
this.#updatePhishingInterval.currentTimeout === PHISHING_ACTIVE_UPDATE_INTERVAL
99+
100+
const shouldSwitchToActiveUpdateInterval =
101+
isActiveViewType && !isAlreadyUsingActiveUpdateInterval
102+
if (shouldSwitchToActiveUpdateInterval)
103+
this.#updatePhishingInterval.restart({
104+
timeout: PHISHING_ACTIVE_UPDATE_INTERVAL,
105+
runImmediately: true
106+
})
107+
})
108+
this.#ui.uiEvent.on('removeView', () => {
109+
const hasAtLeastOneActiveViewOpen = this.#ui.views.some((view) =>
110+
PHISHING_ACTIVE_VIEW_TYPES.has(view.type)
111+
)
112+
113+
const shouldSwitchToInactiveUpdateInterval = !hasAtLeastOneActiveViewOpen
114+
if (shouldSwitchToInactiveUpdateInterval)
115+
this.#updatePhishingInterval.restart({ timeout: PHISHING_INACTIVE_UPDATE_INTERVAL })
116+
})
117+
84118
// eslint-disable-next-line @typescript-eslint/no-floating-promises
85119
this.initialLoadPromise = this.#load().finally(() => {
86120
this.initialLoadPromise = undefined
@@ -105,17 +139,56 @@ export class PhishingController extends EventEmitter implements IPhishingControl
105139
this.emitUpdate()
106140
}
107141

142+
/**
143+
* Wrapper around #continuouslyUpdatePhishing that:
144+
* 1) deduplicates concurrent triggers via a shared promise
145+
* 2) switches to the failed-retry interval when the fetch/update flow throws
146+
*/
108147
async continuouslyUpdatePhishing() {
109-
await this.#continuouslyUpdatePhishing().catch(() => {
110-
this.updatePhishingInterval.updateTimeout({ timeout: PHISHING_FAILED_TO_GET_UPDATE_INTERVAL })
111-
})
148+
if (this.#continuouslyUpdatePhishingPromise) {
149+
await this.#continuouslyUpdatePhishingPromise
150+
151+
return
152+
}
153+
154+
this.#continuouslyUpdatePhishingPromise = this.#continuouslyUpdatePhishing()
155+
.catch((err) => {
156+
this.updatePhishingInterval.updateTimeout({
157+
timeout: PHISHING_FAILED_TO_GET_UPDATE_INTERVAL
158+
})
159+
throw err
160+
})
161+
.finally(() => {
162+
this.#continuouslyUpdatePhishingPromise = undefined
163+
})
164+
165+
await this.#continuouslyUpdatePhishingPromise
112166
}
113167

114168
async #continuouslyUpdatePhishing() {
115169
// This prevents redundant requests to the relayer
116170
// when the extension reloads multiple times within a short period.
117-
if (this.#updatedAt && Date.now() - this.#updatedAt < PHISHING_UPDATE_INTERVAL) return
171+
const timeSinceLastUpdate = this.#updatedAt ? Date.now() - this.#updatedAt : null
172+
if (
173+
this.#updatedAt &&
174+
timeSinceLastUpdate !== null &&
175+
timeSinceLastUpdate < this.updatePhishingInterval.currentTimeout
176+
) {
177+
// NOTE: used for debugging only
178+
// console.log(
179+
// `[PhishingController] Skip update (sinceLastUpdate=${Math.floor(timeSinceLastUpdate / 1000)}s, timeout=${Math.floor(this.updatePhishingInterval.currentTimeout / 1000)}s)`
180+
// )
118181

182+
return
183+
}
184+
185+
// NOTE: used for debugging only
186+
// console.log(
187+
// `[PhishingController] Fetch update (version=${this.#version}, timeout=${Math.floor(this.updatePhishingInterval.currentTimeout / 1000)}s)`
188+
// )
189+
190+
// version=0 means no local snapshot yet -> fetch full data.
191+
// version>0 means we have a checkpoint -> fetch only the delta since that version.
119192
const res = await fetchWithTimeout(
120193
this.#fetch,
121194
this.#version
@@ -132,6 +205,7 @@ export class PhishingController extends EventEmitter implements IPhishingControl
132205
const phishing = await res.json()
133206

134207
if (this.#version) {
208+
// Incremental update: apply add/remove operations on top of local sets.
135209
this.#version = phishing.toVersion || 0
136210
;(phishing.domains || []).forEach(
137211
({ op, domain }: { op: 'add' | 'remove'; domain: string }) => {
@@ -146,6 +220,7 @@ export class PhishingController extends EventEmitter implements IPhishingControl
146220
}
147221
)
148222
} else {
223+
// Initial/full update: replace local sets with the server snapshot.
149224
this.#version = phishing.version || 0
150225
this.#domains = new Set(phishing.domains || [])
151226
this.#addresses = new Set(phishing.addresses || [])
@@ -154,16 +229,24 @@ export class PhishingController extends EventEmitter implements IPhishingControl
154229
this.#shouldSyncDapps = true
155230
this.emitUpdate()
156231

232+
const updatedAt = Date.now()
233+
this.#updatedAt = updatedAt
234+
157235
await this.#storage.set('phishing', {
158236
version: this.#version,
159-
updatedAt: Date.now(),
237+
updatedAt,
160238
domains: [...this.#domains],
161239
addresses: [...this.#addresses]
162240
})
163241

164-
if (this.updatePhishingInterval.currentTimeout !== PHISHING_UPDATE_INTERVAL) {
165-
this.updatePhishingInterval.updateTimeout({ timeout: PHISHING_UPDATE_INTERVAL })
242+
if (this.updatePhishingInterval.currentTimeout === PHISHING_FAILED_TO_GET_UPDATE_INTERVAL) {
243+
this.updatePhishingInterval.updateTimeout({ timeout: PHISHING_INACTIVE_UPDATE_INTERVAL })
166244
}
245+
246+
// NOTE: used for debugging only
247+
// console.log(
248+
// `[PhishingController] Update applied (version=${this.#version}, domains=${this.#domains.size}, addresses=${this.#addresses.size})`
249+
// )
167250
}
168251

169252
/**

src/controllers/signAccountOp/signAccountOp.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,8 @@ const init = async (
453453
const phishing = new PhishingController({
454454
fetch,
455455
storage: storageCtrl,
456-
addressBook: addressBookCtrl
456+
addressBook: addressBookCtrl,
457+
ui: uiCtrl
457458
})
458459
const { op } = accountOp
459460
const network = networksCtrl.networks.find((x) => x.chainId === op.chainId)!

src/controllers/swapAndBridge/swapAndBridge.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,8 @@ const activityCtrl = new ActivityController(
185185
const phishingCtrl = new PhishingController({
186186
fetch,
187187
storage: storageCtrl,
188-
addressBook: addressBookCtrl
188+
addressBook: addressBookCtrl,
189+
ui: uiCtrl
189190
})
190191

191192
const socketAPIMock = new SocketAPIMock({ fetch, apiKey: '' })
@@ -489,7 +490,7 @@ describe('SwapAndBridge Controller', () => {
489490
jest.useFakeTimers()
490491
const { restore } = suppressConsole()
491492

492-
// Navigate to dashboard
493+
// Navigate to dashboard
493494
uiCtrl.updateView('swap-and-bridge', {
494495
currentRoute: 'dashboard',
495496
isReady: true,

0 commit comments

Comments
 (0)