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 src/consts/intervals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const BLACKLIST_UPDATE_INTERVAL = 8 * 60 * 60 * 1000 // 8 hrs
export const PHISHING_INACTIVE_UPDATE_INTERVAL = 6 * 60 * 60 * 1000 // 6 hrs
export const PHISHING_ACTIVE_UPDATE_INTERVAL = 15 * 60 * 1000 // 15 minutes
export const PHISHING_FAILED_TO_GET_UPDATE_INTERVAL = 600000 // 10 minutes
export const TRENDING_TOKENS_UPDATE_INTERVAL = 10 * 60 * 1000 // 10 minutes
export const TRENDING_TOKENS_FAILED_UPDATE_INTERVAL = 60 * 1000 // 1 minute
export const ESTIMATE_UPDATE_INTERVAL = 30000
export const GAS_PRICE_UPDATE_INTERVAL = 12000
export const FETCH_SAFE_TXNS = 3 * 60 * 1000 // 3 minutes
141 changes: 141 additions & 0 deletions src/controllers/dapps/dapps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,55 @@ import { IStorageController } from '../../interfaces/storage'
import { DappConnectRequest } from '../../interfaces/userRequest'
import { PhishingController } from '../phishing/phishing'

const TRENDING_TOKENS_URL = 'https://cena.ambire.com/api/v3/trending/'

// Two valid entries plus one invalid (no price) to exercise normalization + filtering.
const mockTrending = [
{
id: 'bitcoin',
name: 'Bitcoin',
symbol: 'BTC',
market_cap_rank: 1,
thumb: 'https://example.com/btc-thumb.png',
small: 'https://example.com/btc-small.png',
large: 'https://example.com/btc-large.png',
data: {
price: 65000.5,
price_change_percentage_24h: { usd: 1.23, eur: 1.1 },
market_cap: '$1,200,000,000',
total_volume: '$45,000,000',
content: { title: 'About Bitcoin', description: 'The first cryptocurrency.' }
}
},
{
id: 'ethereum',
name: 'Ethereum',
symbol: 'ETH',
market_cap_rank: 2,
thumb: 'https://example.com/eth-thumb.png',
small: 'https://example.com/eth-small.png',
large: 'https://example.com/eth-large.png',
data: {
price: 3200,
price_change_percentage_24h: { usd: -2.5 },
market_cap: '$400,000,000',
total_volume: '$20,000,000',
content: null
}
},
// Invalid: missing data.price → must be filtered out by normalizeTrendingTokens.
{
id: 'no-price-coin',
name: 'No Price Coin',
symbol: 'NPC',
market_cap_rank: 999,
thumb: '',
small: '',
large: '',
data: { price_change_percentage_24h: { usd: 0 } }
}
]

const prepareTest = async (
storageInit?: (storageController: IStorageController) => Promise<void>,
getMockFetchImplementation?: (url: string, ...args: any) => Promise<any>
Expand All @@ -42,6 +91,14 @@ const prepareTest = async (
}
}

if (url === TRENDING_TOKENS_URL) {
return {
ok: true,
status: 200,
json: async () => mockTrending
}
}

return fetch(url, ...args)
})
}
Expand Down Expand Up @@ -1417,4 +1474,88 @@ describe('DappsController', () => {
expect(stored.chainId).toBe(1)
})
})

describe('trending tokens', () => {
const seedStorage = async (storageCtrl: IStorageController) => {
await storageCtrl.set('dappsV2', predefinedDapps)
await storageCtrl.set('lastDappsUpdateVersion', '1.0.0')
}

test('fetches, normalizes and filters invalid entries on load', async () => {
const { controller } = await prepareTest(seedStorage)
await controller.continuouslyUpdateTrendingTokens()

// The third fixture entry has no price and must be dropped.
expect(controller.trendingTokens).toHaveLength(2)

const btc = controller.trendingTokens.find((tt) => tt.symbol === 'BTC')!
expect(btc.id).toBe('bitcoin')
expect(btc.priceUSD).toBe(65000.5)
expect(btc.priceChange24hUSD).toBe(1.23)
expect(btc.marketCapRank).toBe(1)
expect(btc.icon).toBe('https://example.com/btc-large.png') // prefers `large`
expect(btc.marketCap).toBe('$1,200,000,000')
expect(btc.totalVolume).toBe('$45,000,000')
expect(btc.description).toBe('The first cryptocurrency.')

const eth = controller.trendingTokens.find((tt) => tt.symbol === 'ETH')!
expect(eth.priceChange24hUSD).toBe(-2.5)
expect(eth.description).toBeNull() // content was null
})

test('persists fetched trending tokens to storage', async () => {
const { controller, mainCtrl } = await prepareTest(seedStorage)
await controller.continuouslyUpdateTrendingTokens()

const stored = await mainCtrl.storage.get('trending', { updatedAt: 0, tokens: [] })
expect(stored.tokens).toHaveLength(2)
expect(typeof stored.updatedAt).toBe('number')
expect(stored.updatedAt).toBeGreaterThan(0)
})

test('restores trending tokens from storage on init', async () => {
const seeded = {
id: 'solana',
name: 'Solana',
symbol: 'SOL',
icon: 'https://example.com/sol.png',
priceUSD: 150,
priceChange24hUSD: 5,
marketCapRank: 5,
marketCap: '$70,000,000',
totalVolume: '$3,000,000',
description: 'A fast L1.'
}
const { controller } = await prepareTest(async (storageCtrl) => {
await seedStorage(storageCtrl)
// A fresh updatedAt keeps the skip-if-fresh guard from refetching over the seed.
await storageCtrl.set('trending', { updatedAt: Date.now(), tokens: [seeded] })
})

expect(controller.trendingTokens).toEqual([seeded])
})

test('keeps trending empty and backs off the interval when the fetch fails', async () => {
const { restore } = suppressConsole()
const { controller } = await prepareTest(seedStorage, async (url: string, ...args: any) => {
if (url === 'https://api.llama.fi/protocols')
return { ok: true, status: 200, json: async () => mockDapps }
if (url === 'https://api.llama.fi/v2/chains')
return { ok: true, status: 200, json: async () => mockChains }
if (url === TRENDING_TOKENS_URL) return { ok: false, status: 500, json: async () => ({}) }
return fetch(url, ...args)
})

try {
await controller.continuouslyUpdateTrendingTokens()
} catch {
// The wrapper rethrows after switching to the failed-retry interval; expected here.
}

expect(controller.trendingTokens).toEqual([])
// 1 minute failed-retry cadence (TRENDING_TOKENS_FAILED_UPDATE_INTERVAL).
expect(controller.updateTrendingTokensInterval.currentTimeout).toBe(60 * 1000)
restore()
})
})
})
113 changes: 109 additions & 4 deletions src/controllers/dapps/dapps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import {
featuredDapps,
predefinedDapps
} from '../../consts/dapps/dapps'
import {
TRENDING_TOKENS_FAILED_UPDATE_INTERVAL,
TRENDING_TOKENS_UPDATE_INTERVAL
} from '../../consts/intervals'
import {
ConnectionSource,
Dapp,
Expand All @@ -27,7 +31,9 @@ import {
GetCurrentDappRes,
HasUnverifiedDappsRes,
IDappsController,
RecentDappEntry
RawTrendingToken,
RecentDappEntry,
TrendingToken
} from '../../interfaces/dapp'
import { IEventEmitterRegistryController } from '../../interfaces/eventEmitter'
import { Fetch } from '../../interfaces/fetch'
Expand All @@ -45,13 +51,16 @@ import {
getDomainFromUrl,
modifyDappPropsIfNeeded,
normalizeDappConnection,
normalizeTrendingTokens,
sortDapps,
unifyDefiLlamaDappUrl
} from '../../libs/dapps/helpers'
import { networkChainIdToHex } from '../../libs/networks/networks'
import { fetchWithTimeout } from '../../utils/fetch'
import EventEmitter from '../eventEmitter/eventEmitter'

const TRENDING_TOKENS_URL = 'https://cena.ambire.com/api/v3/trending/'

const mergeSource = (
existing: ConnectionSource[] | undefined,
source: ConnectionSource
Expand Down Expand Up @@ -102,6 +111,22 @@ export class DappsController extends EventEmitter implements IDappsController {

#selectedAccount: ISelectedAccountController

#trendingTokens: TrendingToken[] = []

#trendingTokensUpdatedAt: number | null = null

#updateTrendingTokensInterval: IRecurringTimeout

#continuouslyUpdateTrendingTokensPromise?: Promise<void>

get trendingTokens(): TrendingToken[] {
return this.#trendingTokens
}

get updateTrendingTokensInterval() {
return this.#updateTrendingTokensInterval
}

get shouldRetryFetchAndUpdate() {
return this.#shouldRetryFetchAndUpdate
}
Expand Down Expand Up @@ -158,6 +183,13 @@ export class DappsController extends EventEmitter implements IDappsController {
5 * 60 * 1000 // 5min.
)

// Trending tokens are refreshed server-side every 10 minutes; poll at the same cadence.
this.#updateTrendingTokensInterval = new RecurringTimeout(
() => this.continuouslyUpdateTrendingTokens(),
TRENDING_TOKENS_UPDATE_INTERVAL,
this.emitError.bind(this)
)

this.#ui.uiEvent.on('addView', () => {
if (this.#retryFetchAndUpdateInterval.running) this.#retryFetchAndUpdateInterval.stop()
})
Expand Down Expand Up @@ -247,14 +279,19 @@ export class DappsController extends EventEmitter implements IDappsController {
await this.#networks.initialLoadPromise
await this.#selectedAccount.initialLoadPromise

const [storedDapps, storedRecentDapps] = await Promise.all([
const [storedDapps, storedRecentDapps, storedTrending] = await Promise.all([
this.#storage.get('dappsV2', predefinedDapps),
this.#storage.get('recentDapps', [] as RecentDappEntry[])
this.#storage.get('recentDapps', [] as RecentDappEntry[]),
this.#storage.get('trending', { updatedAt: 0, tokens: [] as TrendingToken[] })
])
// Normalize on read so a drifted record (e.g. isConnected: true but connectedSources: [])
// can't show a dapp as connected in the UI while permission checks force a reconnect.
this.#dapps = new Map(storedDapps.map((d) => [d.id, normalizeDappConnection(d)]))
this.#recentDapps = storedRecentDapps
this.#trendingTokens = storedTrending.tokens
this.#trendingTokensUpdatedAt = storedTrending.updatedAt || null

this.#updateTrendingTokensInterval.start({ runImmediately: true })

void this.fetchAndUpdateDapps()
}
Expand Down Expand Up @@ -509,6 +546,72 @@ export class DappsController extends EventEmitter implements IDappsController {
void this.#storage.set('dappsV2', Array.from(this.#dapps.values()))
}

/**
* Wrapper around #continuouslyUpdateTrendingTokens that:
* 1) deduplicates concurrent triggers via a shared promise
* 2) switches to the failed-retry interval when the fetch/update flow throws
*/
async continuouslyUpdateTrendingTokens() {
if (this.#continuouslyUpdateTrendingTokensPromise) {
await this.#continuouslyUpdateTrendingTokensPromise

return
}

this.#continuouslyUpdateTrendingTokensPromise = this.#continuouslyUpdateTrendingTokens()
.catch((err) => {
this.#updateTrendingTokensInterval.updateTimeout({
timeout: TRENDING_TOKENS_FAILED_UPDATE_INTERVAL
})
throw err
})
.finally(() => {
this.#continuouslyUpdateTrendingTokensPromise = undefined
})

await this.#continuouslyUpdateTrendingTokensPromise
}

async #continuouslyUpdateTrendingTokens() {
// Skip if the last successful update is still fresh — prevents redundant requests when the
// background reloads multiple times within a short period (e.g. service worker wake-ups).
const timeSinceLastUpdate = this.#trendingTokensUpdatedAt
? Date.now() - this.#trendingTokensUpdatedAt
: null
if (
this.#trendingTokensUpdatedAt &&
timeSinceLastUpdate !== null &&
timeSinceLastUpdate < this.#updateTrendingTokensInterval.currentTimeout
) {
return
}

const res = await fetchWithTimeout(this.#fetch, TRENDING_TOKENS_URL, {}, 30000)

if (!res.ok || res.status !== 200) {
throw new Error(`Failed to update trending tokens (status: ${res.status}, url: ${res.url})`)
}

const raw: RawTrendingToken[] = await res.json()
if (!Array.isArray(raw)) {
throw new Error('Trending tokens response is not an array')
}

this.#trendingTokens = normalizeTrendingTokens(raw)
const updatedAt = Date.now()
this.#trendingTokensUpdatedAt = updatedAt
this.emitUpdate()

await this.#storage.set('trending', { updatedAt, tokens: this.#trendingTokens })

// Recover the normal cadence after a previously failed fetch bumped it down.
if (
this.#updateTrendingTokensInterval.currentTimeout === TRENDING_TOKENS_FAILED_UPDATE_INTERVAL
) {
this.#updateTrendingTokensInterval.updateTimeout({ timeout: TRENDING_TOKENS_UPDATE_INTERVAL })
}
}

async #createDappSession(initProps: SessionInitProps) {
await this.initialLoadPromise
const dappSession = new Session(initProps)
Expand Down Expand Up @@ -1325,9 +1428,11 @@ export class DappsController extends EventEmitter implements IDappsController {
recentDapps: this.recentDapps,
categories: this.categories,
isReady: this.isReady,
trendingTokens: this.trendingTokens,
shouldRetryFetchAndUpdate: this.shouldRetryFetchAndUpdate,
retryFetchAndUpdateInterval: this.retryFetchAndUpdateInterval,
retryFetchAndUpdateAttempts: this.retryFetchAndUpdateAttempts
retryFetchAndUpdateAttempts: this.retryFetchAndUpdateAttempts,
updateTrendingTokensInterval: this.updateTrendingTokensInterval
}
}
}
37 changes: 37 additions & 0 deletions src/interfaces/dapp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,43 @@ export interface RecentDappEntry {
openedAt: number
}

// Raw shape of a single item returned by the cena trending tokens endpoint
// (https://cena.ambire.com/api/v3/trending/). Only the fields the wallet consumes are typed;
// the endpoint returns more (sparkline, btc-denominated values, etc.) that we ignore.
export interface RawTrendingToken {
id: string
name: string
symbol: string
market_cap_rank: number | null
thumb: string
small: string
large: string
data?: {
price?: number
// Percentage change keyed by fiat/crypto currency (usd, eur, btc, ...). We only read `usd`.
price_change_percentage_24h?: { [currency: string]: number }
// Pre-formatted, currency-prefixed strings from the server (e.g. "$74,041,107").
market_cap?: string
total_volume?: string
content?: { title: string; description: string } | null
}
}

// Normalized trending token kept in the DappsController state and rendered by the UI.
export interface TrendingToken {
// CoinGecko id (e.g. 'zignaly'); stable, used as the list key and details-screen lookup id.
id: string
name: string
symbol: string
icon: string
priceUSD: number
priceChange24hUSD: number | null
marketCapRank: number | null
marketCap: string | null
totalVolume: string | null
description: string | null
}

export interface DefiLlamaProtocol {
id: string
name: string
Expand Down
Loading