Is Coin98 Wallet Extension: Crypto & Defi safe?

Medium risk

Coin98 Wallet is medium risk. Coin98 Wallet carries the social/email registration email into its GA helper. On first run, absent a client ID, it POSTs social_new_install using that email as client_id. DA saw a different install POST, not this; registration wasn't done.

98-publisher-groupv10.8.1Chrome Web Store
45Risk
Who publishes it

98-publisher-group - 2 other listings from the same operator, none carrying a finding

What this publisher told the store about itself, and the other listings that told it the same thing.

Publisher
98-publisher-group

Same store account

2 other listings published from this account, 2k+ users between them, none of them carrying a finding.

Shared hosts - 1 hostname

Hostnames hardcoded in this extension that few other listings call. That can mean one operator behind both, and it can equally mean a small shared vendor, so it is context rather than a conclusion. Hosts that many listings call are left out: they are services, not connections.

wallet.ton.org
Also called by 2 other listings: MPCVault, TON Wallet

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

Email wallet registration can use your email as a GA identifier

Coin98 Wallet carries the social/email registration email into its GA helper.

On first run, absent a client ID, it POSTs social_new_install using that email as client_id.

DA saw a different install POST, not this; registration wasn't done.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-359
Source
Dynamic sandbox
What actually happens
You did this

You submit an email address while setting up a social/email wallet.

The form validates the address, sends an invite-link request, and passes the same address into the success route.

The extension did this

The extension prepares a Google Analytics event that can use that email address as the analytics identifier.

This happens in the first-run analytics branch where no client ID is already stored.

Fields prepared for the Google Analytics event
  • Your email address
    alex.rivera@example.com

    This directly identifies you and is placed where Google Analytics expects a client identifier.

  • Registration event
    social_new_install

    This labels the analytics record as a new social/email wallet installation event.

  • Sign-in type
    email

    This says the wallet was registered through the email-based flow.

  • Generated device ID
    7f1c4e8a-33f6-4c52-9b0d-0b59ad3dc6d4

    This lets the analytics record tie the registration event to an extension-generated identifier on the same browser profile.

Captured request
POSThttps://www.google-analytics.com/mp/collect?measurement_id=G-CCPPHL1RJT&api_secret=<redacted>

Dynamic analysis observed a POST to this Google Analytics endpoint for a different install event; the email-registration request body was not recorded.

The code that does this

The form carries the submitted email into the success route

Readable version

Readable form submit handler

WalletSocialEmail.ddb01262.js
const submitEmail = async (formValues) => {  const validationError = validateEmail(formValues.email);  if (typeof validationError === "string") {    return window.openModal({ type: "error", content: t("invalidEmail") });  }  try {    setLoading(true);    const invite = await sendInviteLink(formValues.email);    if (String(invite?.error).toLowerCase().includes("for 1 minute before sending the next request")) {      window.openModal({ type: "error", title: t("alertMessage"), content: t("waitSendEmail") });    } else if (String(invite?.error).toLowerCase().includes("invalid email address")) {      window.openModal({ type: "error", title: t("alertMessage"), content: t("invalidEmail") });    } else if (invite?.error) {      window.openModal({ type: "error", title: t("alertMessage"), content: t("error") });    }    if (!invite.success) return;    const { pendingToken, transactionId, appId } = invite;    history.push(history.location.pathname + "/success", {      pendingToken,      transactionId,      appId,      email: formValues.email    });  } catch (error) {    setLoading(false);  } finally {    setLoading(false);  }};
The code that does this

The success handler adds the email to the GA event

Readable version

Readable success handler

WalletSocialEmailSuccess.66912161.js
const createEmailWallet = async (walletResult) => {  setLoading(true);  mountedRef.current = false;  clearTimeout(firstTimer.current);  clearTimeout(secondTimer.current);  const isCreate = !!get(walletResult, "address", false);  if (!walletResult) return false;  const authProvider = AUTH_PROVIDER.EMAIL;  const routeEmail = routeState.email;  const mnemonic = get(walletResult, "mnemonic");  const email = routeEmail || get(walletResult, "email") || get(walletResult, "displayName", "");  const rawId = routeEmail || get(walletResult, "email") || get(walletResult, "rawId") || generateId();  const socialInfo = {    uid: `${authProvider}_${rawId}`,    email: email.toLowerCase(),    avatar: authProvider,    uidSync: `${authProvider};${rawId}`,    mnemonic  };  const createdWallets = await walletService.createOrRestore({    name: socialInfo.email,    mnemonic: socialInfo.mnemonic  });  const encryptedMnemonic = encryptService.encrypt(socialInfo.mnemonic);  const wallets = (Array.isArray(createdWallets) ? createdWallets : [createdWallets]).map((wallet) => ({    address: wallet.address,    meta: wallet.meta,    nearDomain: wallet.nearDomain,    name: wallet.name,    publicKey: wallet.publicKey  }));  const socialInfoWithoutMnemonic = {    ...socialInfo,    mnemonic: undefined,    version: RAMPER_VERSION.V2  };  const walletRecord = {    name: get(socialInfo, "email", ""),    meta: {      chain: CHAIN_TYPE.multiChain,      isMulti: true,      socialType: "email",      socialInfo: socialInfoWithoutMnemonic    },    wallets,    mnemonic: encryptedMnemonic  };  setStep((step) => step ? 5 : null);  await sleep(1000);  GA4Analytics.fireNewUserAndDevice("social_new_install", {    email: socialInfo.email,    socialType: "email"  });  dispatch(onUpdateWallet(walletRecord));  setWallets([walletRecord]);  airbridgeService.sendAirbridgeWallet(walletRecord);  trackWallet({    hardwareWallet: false,    kind: WALLET_TYPE.social,    name: walletRecord?.name,    wallets: walletRecord?.wallets,    isCreate,    social: "email"  });  if (shortcuts.length) {    history.replace({ pathname: "/shortcuts", state: { title: t("loginEmailSuccess") } });  } else {    history.replace({ pathname: "/shortcuts", state: { isShortcut: true } });  }  return true;};
The code that does this

The GA helper places email into client_id

Readable version

Readable GA helper module

static/background/index_10.6.0.js
const GA_URL = "https://www.google-analytics.com/mp/collect";const GA_DEBUG_URL = "https://www.google-analytics.com/debug/mp/collect";const MEASUREMENT_ID = "G-CCPPHL1RJT";const API_SECRET = "<redacted>";const DEFAULT_ENGAGEMENT_TIME_MSEC = 100;const SESSION_TIMEOUT_MINUTES = 30;class Analytics {  constructor(debug = false) {    this.debug = debug;  }  async getOrCreateClientId() {    const { clientId } = await chrome.storage.local.get("clientId");    if (!clientId) {      const newClientId = self.crypto.randomUUID();      await chrome.storage.local.set({ clientId: newClientId });      return newClientId;    }    return clientId;  }  async getOrCreateSessionId() {    let { sessionData } = await chrome.storage.session.get("sessionData");    const timestamp = Date.now();    if (sessionData && sessionData.timestamp) {      const minutesSinceLastEvent = (timestamp - sessionData.timestamp) / 60000;      if (minutesSinceLastEvent > SESSION_TIMEOUT_MINUTES) {        sessionData = null;      } else {        sessionData.timestamp = timestamp;        await chrome.storage.session.set({ sessionData });      }    }    if (!sessionData) {      sessionData = { session_id: timestamp.toString(), timestamp: timestamp.toString() };      await chrome.storage.session.set({ sessionData });    }    return sessionData.session_id;  }  async fireNewUserAndDevice(eventName, params = {}) {    const { clientId } = await chrome.storage.local.get("clientId");    if (!clientId) {      params.session_id || (params.session_id = await this.getOrCreateSessionId());      params.engagement_time_msec || (params.engagement_time_msec = DEFAULT_ENGAGEMENT_TIME_MSEC);      try {        const generatedClientId = await this.getOrCreateClientId();        await fetch(`${this.debug ? GA_DEBUG_URL : GA_URL}?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SECRET}`, {          method: "POST",          body: JSON.stringify({            client_id: params?.email || generatedClientId,            events: [              {                name: eventName,                params: {                  deviceId: generatedClientId,                  ...params                }              }            ]          })        });        if (!this.debug) return;      } catch (error) {        console.error("Google Analytics request failed with an exception", error);      }    }  }  async firePageViewEvent(title, location, params = {}) {    return this.fireNewUserAndDevice("page_view", { page_title: title, page_location: location, ...params });  }  async fireErrorEvent(error, params = {}) {    return this.fireNewUserAndDevice("extension_error", { ...error, ...params });  }}export default new Analytics();
External analytics destination
    • www.google-analytics.com

    Receives the Google Analytics Measurement Protocol event built by the extension.

Updated 30 September 2026aeachknmefphepccionboohckonoeemg