Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface ISimpleDBAppStatus {
fixHardwareLtcXPubMigrated?: boolean;
btcFreshAddressSettingMigrated?: boolean;
removeDeviceHomeScreenMigrated?: boolean;
lastWalletProfileAnalyticsAt?: number;
walletAssetStatusAnalytics?: IWalletAssetStatusAnalyticsState;
// OneKey IDs (onekeyUserId) that have already seen the KYT intro dialog.
// Scoped per Prime user so each account is prompted once.
Expand Down
44 changes: 44 additions & 0 deletions packages/kit-bg/src/services/ServiceAccount/ServiceAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import type {
} from '@onekeyhq/core/src/types';
import { ECoreApiExportedSecretKeyType } from '@onekeyhq/core/src/types';
import type { IAllNetworkAccountInfo } from '@onekeyhq/kit-bg/src/services/ServiceAllNetwork/ServiceAllNetwork';
import { analytics } from '@onekeyhq/shared/src/analytics';
import type { IAnalyticsUserProfile } from '@onekeyhq/shared/src/analytics/type';
import type { IPbkdf2KdfParams } from '@onekeyhq/shared/src/appCrypto/modules/pbkdf2';
import {
clearPbkdf2InvocationByProbeId,
Expand Down Expand Up @@ -197,6 +199,10 @@ import keylessSyncCredentialStorage from '../ServiceKeylessWallet/utils/keylessS
import { isBotWalletInCurrentKeylessSyncScope } from '../ServicePrimeCloudSync/botWalletCloudSyncUtils';
import keylessCloudSyncUtils from '../ServicePrimeCloudSync/keylessCloudSyncUtils';

import {
buildAnalyticsWalletProfile,
shouldReportAnalyticsWalletProfile,
} from './analyticsWalletProfile';
import {
buildDefaultBotWalletName,
isDefaultBotWalletName,
Expand Down Expand Up @@ -716,6 +722,44 @@ class ServiceAccount extends ServiceBase {
};
}

private async getWalletProfileForAnalytics(): Promise<
IAnalyticsUserProfile | undefined
> {
const [{ wallets }, botWalletMetadata] = await Promise.all([
this.getWallets({
nestedHiddenWallets: true,
ignoreEmptySingletonWalletAccounts: true,
}),
simpleDb.botWallet.getMetadataMap(),
]);
return buildAnalyticsWalletProfile({ wallets, botWalletMetadata });
}

@backgroundMethod()
async reportWalletProfileAnalyticsIfNeeded() {
const now = Date.now();
const appStatus = await simpleDb.appStatus.getRawData();
if (
!shouldReportAnalyticsWalletProfile({
lastReportedAt: appStatus?.lastWalletProfileAnalyticsAt,
now,
})
) {
return;
}

const walletProfile = await this.getWalletProfileForAnalytics();
if (walletProfile) {
analytics.updateUserProfile(walletProfile);
}
await simpleDb.appStatus.setRawData(
Comment thread
huhuanming marked this conversation as resolved.
(value): ISimpleDBAppStatus => ({
...value,
lastWalletProfileAnalyticsAt: now,
}),
);
}

@backgroundMethod()
async getAllHdHwQrWallets(options?: IDBGetWalletsParams) {
const r = await this.getWallets(options);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import type { IDBWallet } from '@onekeyhq/kit-bg/src/dbs/local/types';
import {
BOT_WALLET_STATUS_ACTIVE,
BOT_WALLET_STATUS_DEACTIVATED,
} from '@onekeyhq/shared/src/consts/dbConsts';
import type { IBotWalletMetadataMap } from '@onekeyhq/shared/types/botWallet';
import { EHardwareVendor } from '@onekeyhq/shared/types/device';

import {
WALLET_PROFILE_ANALYTICS_INTERVAL_MS,
buildAnalyticsWalletProfile,
computeHwVendorProfile,
shouldReportAnalyticsWalletProfile,
} from './analyticsWalletProfile';

function createWallet({
id,
type = 'hd',
isKeyless = false,
vendor,
}: {
id: string;
type?: IDBWallet['type'];
isKeyless?: boolean;
vendor?: EHardwareVendor;
}): IDBWallet {
return {
id,
name: id,
type,
isKeyless,
backuped: true,
accounts: [],
nextIds: {},
walletNo: 1,
associatedDeviceInfo: vendor
? ({ vendor } as IDBWallet['associatedDeviceInfo'])
: undefined,
} as IDBWallet;
}

function createBotMetadata(
entries: Array<[string, boolean]>,
): IBotWalletMetadataMap {
return Object.fromEntries(
entries.map(([walletId, visible], index) => [
walletId,
{
index,
name: walletId,
visible,
status: visible
? BOT_WALLET_STATUS_ACTIVE
: BOT_WALLET_STATUS_DEACTIVATED,
createdAt: 1,
},
]),
);
}

describe('buildAnalyticsWalletProfile', () => {
it('returns no profile when there are no visible wallets', () => {
expect(
buildAnalyticsWalletProfile({
wallets: [],
botWalletMetadata: {},
}),
).toBeUndefined();
});

it('aggregates the startup profile and excludes hidden bot wallets', () => {
const visibleBotId = 'hd-bot--hd-keyless-parent-1--0';
const hiddenBotId = 'hd-bot--hd-keyless-parent-1--1';
const profile = buildAnalyticsWalletProfile({
wallets: [
createWallet({
id: 'hd-keyless-parent-1',
isKeyless: true,
}),
createWallet({ id: visibleBotId, isKeyless: true }),
createWallet({ id: hiddenBotId, isKeyless: true }),
createWallet({ id: 'imported-1', type: 'imported' }),
createWallet({ id: 'hw-onekey', type: 'hw' }),
createWallet({
id: 'hw-ledger',
type: 'hw',
vendor: EHardwareVendor.ledger,
}),
],
botWalletMetadata: createBotMetadata([
[visibleBotId, true],
[hiddenBotId, false],
]),
});

expect(profile).toEqual({
walletCount: 5,
hwWalletCount: 2,
appWalletCount: 3,
keylessWalletCount: 1,
hwVendors: ['ledger', 'onekey'],
primaryHwVendor: 'ledger',
});
});

it('keeps a visible orphan bot wallet in the keyless count', () => {
const orphanBotId = 'hd-bot--missing-parent--0';

expect(
buildAnalyticsWalletProfile({
wallets: [createWallet({ id: orphanBotId, isKeyless: true })],
botWalletMetadata: createBotMetadata([[orphanBotId, true]]),
}),
).toEqual({
walletCount: 1,
hwWalletCount: 0,
appWalletCount: 1,
keylessWalletCount: 1,
hwVendors: [],
primaryHwVendor: undefined,
});
});
});

describe('computeHwVendorProfile', () => {
it('returns an empty vendor profile without hardware wallets', () => {
expect(computeHwVendorProfile([createWallet({ id: 'hd-1' })])).toEqual({
hwVendors: [],
primaryHwVendor: undefined,
});
});

it('falls back to OneKey for legacy hardware wallet records', () => {
expect(
computeHwVendorProfile([createWallet({ id: 'hw-legacy', type: 'hw' })]),
).toEqual({
hwVendors: ['onekey'],
primaryHwVendor: 'onekey',
});
});

it('returns sorted vendors and selects the majority vendor', () => {
const profile = computeHwVendorProfile([
createWallet({ id: 'hw-legacy-1', type: 'hw' }),
createWallet({ id: 'hw-legacy-2', type: 'hw' }),
createWallet({
id: 'hw-ledger',
type: 'hw',
vendor: EHardwareVendor.ledger,
}),
]);

expect(profile).toEqual({
hwVendors: ['ledger', 'onekey'],
primaryHwVendor: 'onekey',
});
});

it('breaks equal vendor counts by lexical order', () => {
const profile = computeHwVendorProfile([
createWallet({ id: 'hw-onekey', type: 'hw' }),
createWallet({
id: 'hw-ledger',
type: 'hw',
vendor: EHardwareVendor.ledger,
}),
]);

expect(profile.primaryHwVendor).toBe('ledger');
});
});

describe('shouldReportAnalyticsWalletProfile', () => {
const now = 2 * WALLET_PROFILE_ANALYTICS_INTERVAL_MS;

it('allows the first report', () => {
expect(
shouldReportAnalyticsWalletProfile({
lastReportedAt: undefined,
now,
}),
).toBe(true);
});

it('limits reports to once per 24 hours', () => {
expect(
shouldReportAnalyticsWalletProfile({
lastReportedAt: now - WALLET_PROFILE_ANALYTICS_INTERVAL_MS + 1,
now,
}),
).toBe(false);
expect(
shouldReportAnalyticsWalletProfile({
lastReportedAt: now - WALLET_PROFILE_ANALYTICS_INTERVAL_MS,
now,
}),
).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { IDBWallet } from '@onekeyhq/kit-bg/src/dbs/local/types';
import type { IAnalyticsUserProfile } from '@onekeyhq/shared/src/analytics/type';
import { WALLET_TYPE_HW } from '@onekeyhq/shared/src/consts/dbConsts';
import accountUtils from '@onekeyhq/shared/src/utils/accountUtils';
import type { IBotWalletMetadataMap } from '@onekeyhq/shared/types/botWallet';
import { EHardwareVendor } from '@onekeyhq/shared/types/device';

type IHwVendorProfileWallet = Pick<IDBWallet, 'type' | 'associatedDeviceInfo'>;

export const WALLET_PROFILE_ANALYTICS_INTERVAL_MS = 24 * 60 * 60 * 1000;

export function shouldReportAnalyticsWalletProfile({
lastReportedAt,
now,
}: {
lastReportedAt: number | undefined;
now: number;
}) {
return (
lastReportedAt === undefined ||
now - lastReportedAt >= WALLET_PROFILE_ANALYTICS_INTERVAL_MS
);
}

export function computeHwVendorProfile(
wallets: readonly IHwVendorProfileWallet[],
): {
hwVendors: string[];
primaryHwVendor: string | undefined;
} {
const hwWallets = wallets.filter((wallet) => wallet.type === WALLET_TYPE_HW);
if (hwWallets.length === 0) {
return { hwVendors: [], primaryHwVendor: undefined };
}
const counts = hwWallets.reduce<Record<string, number>>((acc, wallet) => {
const vendor =
wallet.associatedDeviceInfo?.vendor ?? EHardwareVendor.onekey;
acc[vendor] = (acc[vendor] ?? 0) + 1;
return acc;
}, {});
const hwVendors = Object.keys(counts).toSorted();
const primaryHwVendor = hwVendors.reduce((leader, vendor) =>
counts[vendor] > counts[leader] ? vendor : leader,
);
return { hwVendors, primaryHwVendor };
}

export function buildAnalyticsWalletProfile({
wallets,
botWalletMetadata,
}: {
wallets: readonly IDBWallet[];
botWalletMetadata: IBotWalletMetadataMap;
}): IAnalyticsUserProfile | undefined {
const visibleWallets = wallets.filter((wallet) => {
if (!accountUtils.isBotWallet({ walletId: wallet.id })) {
return true;
}
return botWalletMetadata[wallet.id]?.visible === true;
});
const walletCount = visibleWallets.length;
if (walletCount === 0) {
return undefined;
}

const hwWalletCount = visibleWallets.filter(
(wallet) => wallet.type === WALLET_TYPE_HW,
).length;
const visibleWalletMap = new Map(
visibleWallets.map((wallet) => [wallet.id, wallet]),
);
const keylessWalletCount = visibleWallets.filter((wallet) => {
if (!wallet.isKeyless) {
return false;
}
const parentWalletId = accountUtils.parseBotWalletId(wallet.id)?.parentId;
return !parentWalletId || !visibleWalletMap.get(parentWalletId)?.isKeyless;
}).length;
const { hwVendors, primaryHwVendor } = computeHwVendorProfile(visibleWallets);

return {
walletCount,
hwWalletCount,
appWalletCount: walletCount - hwWalletCount,
keylessWalletCount,
hwVendors,
primaryHwVendor,
};
}
19 changes: 16 additions & 3 deletions packages/kit-bg/src/services/ServiceBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ import systemTimeUtils from '@onekeyhq/shared/src/utils/systemTimeUtils';
import localDb from '../dbs/local/localDb';

import ServiceBase from './ServiceBase';
import { scheduleWalletProfileAnalyticsChecks } from './walletProfileAnalyticsScheduler';

@backgroundClass()
class ServiceBootstrap extends ServiceBase {
private walletProfileAnalyticsChecksScheduled = false;

constructor({ backgroundApi }: { backgroundApi: any }) {
super({ backgroundApi });
}
Expand Down Expand Up @@ -139,9 +142,19 @@ class ServiceBootstrap extends ServiceBase {
timedDeferred('serviceContextMenu.init', () =>
this.backgroundApi.serviceContextMenu.init(),
),
timedDeferred('serviceDevSetting.initAnalytics', () =>
this.backgroundApi.serviceDevSetting.initAnalytics(),
),
timedDeferred('serviceDevSetting.initAnalytics', async () => {
await this.backgroundApi.serviceDevSetting.initAnalytics();
if (!this.walletProfileAnalyticsChecksScheduled) {
this.walletProfileAnalyticsChecksScheduled = true;
scheduleWalletProfileAnalyticsChecks(() =>
timedDeferred(
'serviceAccount.reportWalletProfileAnalyticsIfNeeded',
() =>
this.backgroundApi.serviceAccount.reportWalletProfileAnalyticsIfNeeded(),
),
);
}
}),
// ext MV3 only: re-warm providers of already-connected dapps after a
// service-worker restart so notifyDApp* can reach them. Native/desktop
// rebuild their webviews on restart (dapp reconnects), so no warmup
Expand Down
Loading
Loading