diff --git a/packages/extension/package.json b/packages/extension/package.json index a2db162be..eee61c340 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -23,6 +23,7 @@ "watch:firefox": "yarn prebuild && cross-env BROWSER='firefox' vite" }, "dependencies": { + "@a16z/helios": "^0.11.1", "@amplitude/analytics-browser": "^2.38.1", "@enkryptcom/extension-bridge": "workspace:^", "@enkryptcom/hw-wallets": "workspace:^", diff --git a/packages/extension/src/providers/ethereum/libs/helios-verifier.ts b/packages/extension/src/providers/ethereum/libs/helios-verifier.ts new file mode 100644 index 000000000..3e6092785 --- /dev/null +++ b/packages/extension/src/providers/ethereum/libs/helios-verifier.ts @@ -0,0 +1,139 @@ + +export interface VerificationResult { + verified: boolean; + tampered: boolean; + message: string; + provenBalance?: string; +} + +const SKIP: VerificationResult = { verified: false, tampered: false, message: '' }; +const MAINNET_CHAIN_ID = '0x1'; +const DEFAULT_CONSENSUS_RPC = 'https://ethereum.operationsolarstorm.org'; +const CHECKPOINT_FETCH_TIMEOUT_MS = 5_000; + +let heliosProvider: any = null; +let initPromise: Promise | null = null; +let syncPromise: Promise | null = null; +let isSynced = false; +let currentExecutionRpc = ''; +let sessionId = 0; + +async function fetchFreshCheckpoint(consensusRpc: string): Promise { + const url = `${consensusRpc.replace(/\/$/, '')}/eth/v1/beacon/headers/finalized`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), CHECKPOINT_FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { signal: controller.signal }); + if (!res.ok) return undefined; + const json = (await res.json()) as { data?: { root?: string } }; + const root = json?.data?.root; + if (typeof root === 'string' && root.startsWith('0x')) return root; + return undefined; + } catch { + return undefined; + } finally { + clearTimeout(timer); + } +} + +export async function initHelios( + executionRpc: string, + consensusRpc: string = DEFAULT_CONSENSUS_RPC, +): Promise { + if (initPromise) return initPromise; + if (heliosProvider && currentExecutionRpc === executionRpc) return; + const session = ++sessionId; + heliosProvider = null; + isSynced = false; + currentExecutionRpc = executionRpc; + initPromise = (async () => { + try { + const checkpoint = await fetchFreshCheckpoint(consensusRpc); + const { createHeliosProvider } = await import('@a16z/helios'); + const config: Record = { + executionRpc, + consensusRpc, + network: 'mainnet', + }; + if (checkpoint) config['checkpoint'] = checkpoint; + const provider = await createHeliosProvider(config, 'ethereum'); + if (session !== sessionId) return; + heliosProvider = provider; + syncPromise = provider.waitSynced().then(() => { + if (session === sessionId && heliosProvider === provider) { + isSynced = true; + console.log('[helios-verifier] synced and ready'); + } + }); + await syncPromise; + } catch (err) { + if (session === sessionId) { + console.warn('[helios-verifier] failed to initialise:', err); + resetHelios(); + } + } finally { + if (session === sessionId) initPromise = null; + } + })(); + return initPromise; +} + +export function resetHelios(): void { + sessionId++; + heliosProvider = null; + isSynced = false; + initPromise = null; + syncPromise = null; + currentExecutionRpc = ''; +} + +function encodeBalanceOf(address: string): string { + const addr = address.toLowerCase().replace(/^0x/, '').padStart(64, '0'); + return `0x70a08231${addr}`; +} + +function decodeUint256(hex: string): bigint { + const clean = hex.startsWith('0x') ? hex.slice(2) : hex; + if (!clean || /^0+$/.test(clean)) return 0n; + return BigInt(`0x${clean}`); +} + +export async function verifyErc20Balance( + contractAddress: string, + walletAddress: string, + rpcBalance: string, + blockTag: string, + chainId: string, + executionRpc: string, +): Promise { + if (chainId.toLowerCase() !== MAINNET_CHAIN_ID) return SKIP; + await initHelios(executionRpc); + if (!isSynced || !heliosProvider) return SKIP; + let heliosBalanceHex: string; + try { + heliosBalanceHex = (await heliosProvider.request({ + method: 'eth_call', + params: [{ to: contractAddress, data: encodeBalanceOf(walletAddress) }, blockTag], + })) as string; + } catch (err) { + console.warn('[helios-verifier] eth_call failed:', err); + return SKIP; + } + const rpcValue = decodeUint256(rpcBalance); + const heliosValue = decodeUint256(heliosBalanceHex); + if (rpcValue === heliosValue) { + return { + verified: true, + tampered: false, + message: 'Balance verified by Helios light client.', + provenBalance: heliosBalanceHex, + }; + } + console.error(`[helios-verifier] MISMATCH: RPC=${rpcValue} Helios=${heliosValue}`); + return { + verified: false, + tampered: true, + message: `Your RPC provider returned ${rpcValue.toString()} but Helios proved the real balance is ${heliosValue.toString()}. Your RPC provider may be lying.`, + provenBalance: heliosBalanceHex, + }; +} \ No newline at end of file diff --git a/packages/extension/src/providers/ethereum/types/erc20-token.ts b/packages/extension/src/providers/ethereum/types/erc20-token.ts index 2d767008d..323ae0e92 100644 --- a/packages/extension/src/providers/ethereum/types/erc20-token.ts +++ b/packages/extension/src/providers/ethereum/types/erc20-token.ts @@ -4,6 +4,10 @@ import erc20 from '../libs/abi/erc20'; import EvmAPI from '../libs/api'; import { NATIVE_TOKEN_ADDRESS } from '../libs/common'; import { BNType } from '@/providers/common/types'; +import { + verifyErc20Balance, + type VerificationResult, +} from '../libs/helios-verifier'; export interface Erc20TokenOptions extends BaseTokenOptions { contract: string; @@ -11,6 +15,7 @@ export interface Erc20TokenOptions extends BaseTokenOptions { export class Erc20Token extends BaseToken { public contract: string; + public heliosVerification: VerificationResult | undefined = undefined; constructor(options: Erc20TokenOptions) { super(options); @@ -20,26 +25,45 @@ export class Erc20Token extends BaseToken { public async getLatestUserBalance( api: EvmAPI, address: string, + chainId?: string, ): Promise { if (this.contract === NATIVE_TOKEN_ADDRESS) return api.getBalance(address.toLowerCase()); - else { - const contract = new api.web3.Contract( - erc20 as any, - this.contract.toLowerCase(), - ); - return contract.methods - .balanceOf(address.toLowerCase()) - .call() - .then((val: BNType) => { - const balance = numberToHex(val); - this.balance = balance; - return balance; - }); + + const contract = new api.web3.Contract( + erc20 as any, + this.contract.toLowerCase(), + ); + + const balance: string = await contract.methods + .balanceOf(address.toLowerCase()) + .call() + .then((val: BNType) => { + const hex = numberToHex(val); + this.balance = hex; + return hex; + }); + + if (chainId) { + // blockTag is 'latest' because the web3 contract call does not expose + verifyErc20Balance( + this.contract, + address.toLowerCase(), + balance, + 'latest', + chainId, + api.node, + ) + .then(result => { + this.heliosVerification = result; + }) + .catch(() => {}); } + + return balance; } public async send(): Promise { throw new Error('EVM-send is not implemented here'); } -} +} \ No newline at end of file diff --git a/packages/extension/src/providers/ethereum/types/evm-network.ts b/packages/extension/src/providers/ethereum/types/evm-network.ts index e395cae9f..c65f68204 100644 --- a/packages/extension/src/providers/ethereum/types/evm-network.ts +++ b/packages/extension/src/providers/ethereum/types/evm-network.ts @@ -113,6 +113,7 @@ export class EvmNetwork extends BaseNetwork { const bTokenOptions: Erc20TokenOptions = { decimals: token.decimals, icon: token.icon, + heliosVerification: token.heliosVerification, name: token.name, symbol: token.symbol, balance: token.balance, @@ -163,7 +164,7 @@ export class EvmNetwork extends BaseNetwork { await Promise.all( this.assets.map(token => - token.getLatestUserBalance(api as API, address).then(balance => { + token.getLatestUserBalance(api as API, address, this.chainID).then(balance => { token.balance = balance; }), ), @@ -175,6 +176,7 @@ export class EvmNetwork extends BaseNetwork { name: token.name, symbol: token.symbol, icon: token.icon, + heliosVerification: token.heliosVerification, balance: token.balance!, balancef: formatFloatingPointValue( fromBase(token.balance!, token.decimals), @@ -228,7 +230,7 @@ export class EvmNetwork extends BaseNetwork { }); const balancePromises = customTokens.map(token => - token.getLatestUserBalance(api as API, address), + token.getLatestUserBalance(api as API, address, this.chainID), ); await Promise.all(balancePromises); @@ -255,6 +257,7 @@ export class EvmNetwork extends BaseNetwork { sparkline: '', priceChangePercentage: 0, icon: token.icon, + heliosVerification: token.heliosVerification, }; const marketInfo = marketInfos[token.contract.toLowerCase()]; diff --git a/packages/extension/src/types/provider.ts b/packages/extension/src/types/provider.ts index b43819ba4..497629c68 100644 --- a/packages/extension/src/types/provider.ts +++ b/packages/extension/src/types/provider.ts @@ -227,4 +227,5 @@ export interface AssetsType { sparkline: string; priceChangePercentage: number; baseToken?: BaseToken; + heliosVerification?: import('@/providers/ethereum/libs/helios-verifier').VerificationResult; } diff --git a/packages/extension/src/ui/action/icons/common/shield-alert-icon.vue b/packages/extension/src/ui/action/icons/common/shield-alert-icon.vue new file mode 100644 index 000000000..a3025e588 --- /dev/null +++ b/packages/extension/src/ui/action/icons/common/shield-alert-icon.vue @@ -0,0 +1,7 @@ + diff --git a/packages/extension/src/ui/action/views/network-assets/components/helios-warning-banner.vue b/packages/extension/src/ui/action/views/network-assets/components/helios-warning-banner.vue new file mode 100644 index 000000000..83a2a792b --- /dev/null +++ b/packages/extension/src/ui/action/views/network-assets/components/helios-warning-banner.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/packages/extension/src/ui/action/views/network-assets/index.vue b/packages/extension/src/ui/action/views/network-assets/index.vue index 85210cf84..61e5f1598 100644 --- a/packages/extension/src/ui/action/views/network-assets/index.vue +++ b/packages/extension/src/ui/action/views/network-assets/index.vue @@ -1,74 +1,27 @@ @@ -80,8 +33,9 @@ import NetworkAssetsItem from './components/network-assets-item.vue'; import NetworkAssetsHeader from './components/network-assets-header.vue'; import NetworkAssetsLoading from './components/network-assets-loading.vue'; import NetworkAssetsError from './components/network-assets-error.vue'; +import HeliosWarningBanner from './components/helios-warning-banner.vue'; import CustomScrollbar from '@action/components/custom-scrollbar/index.vue'; -import { computed, onMounted, type PropType, ref, toRef, watch } from 'vue'; +import { computed, onBeforeUnmount, onMounted, type PropType, ref, toRef, watch } from 'vue'; import type { AssetsType } from '@/types/provider'; import type { AccountsHeaderData } from '../../types/account'; import accountInfoComposable from '@action/composables/account-info'; @@ -95,78 +49,116 @@ import { EvmNetwork } from '@/providers/ethereum/types/evm-network'; import { ProviderName } from '@/types/provider'; import NetworkAssetsSolanaStakingBanner from './components/network-assets-solana-staking-banner.vue'; import BannersState from '@/libs/banners-state'; +import { resetHelios } from '@/providers/ethereum/libs/helios-verifier'; -const showDeposit = ref(false); +interface HeliosWarningInfo { + tokenSymbol: string; + tokenDecimals: number; + rpcBalance: string; + provenBalance: string; +} +const showDeposit = ref(false); const route = useRoute(); const props = defineProps({ - network: { - type: Object as PropType, - default: () => ({}), - }, - subnetwork: { - type: String, - default: '', - }, - accountInfo: { - type: Object as PropType, - default: () => ({}), - }, + network: { type: Object as PropType, default: () => ({}) }, + subnetwork: { type: String, default: '' }, + accountInfo: { type: Object as PropType, default: () => ({}) }, }); + const assets = ref([]); const isLoading = ref(false); const isFetchError = ref(false); +const heliosWarning = ref(null); +const heliosBannerDismissed = ref(false); + +// --- Helios poll helpers --- +const heliosPollId = ref(null); + +function clearHeliosPoll(): void { + if (heliosPollId.value !== null) { + window.clearInterval(heliosPollId.value); + heliosPollId.value = null; + } +} +// -------------------------- -const { - cryptoAmount, - fiatAmount, - tokenPrice, - priceChangePercentage, - sparkline, -} = accountInfoComposable(toRef(props, 'network'), toRef(props, 'accountInfo')); +const { cryptoAmount, fiatAmount, tokenPrice, priceChangePercentage, sparkline } = accountInfoComposable(toRef(props, 'network'), toRef(props, 'accountInfo')); const selected: string = route.params.id as string; +function scheduleHeliosResultCheck(snapshotAssets: AssetsType[]): void { + clearHeliosPoll(); + const MAX_ATTEMPTS = 15; + let attempts = 0; + heliosPollId.value = window.setInterval(() => { + attempts++; + for (const asset of snapshotAssets) { + const v = asset.heliosVerification; + if (v?.tampered && asset.contract && asset.balance) { + heliosWarning.value = { + tokenSymbol: asset.symbol, + tokenDecimals: asset.decimals, + rpcBalance: asset.balance, + provenBalance: v.provenBalance ?? '0x0', + }; + clearHeliosPoll(); + return; + } + } + if (attempts >= MAX_ATTEMPTS) clearHeliosPoll(); + }, 2000); +} + const updateAssets = () => { isFetchError.value = false; isLoading.value = true; assets.value = []; - const currentNetwork = selectedNetworkName.value; - if (props.accountInfo.selectedAccount?.address) { + heliosWarning.value = null; + heliosBannerDismissed.value = false; + const requestKey = `${selectedNetworkName.value}:${selectedSubnetwork.value}:${selectedAddress.value}`; + const address = props.accountInfo.selectedAccount?.address; + if (!address) { + isLoading.value = false; + return; + } + props.network - .getAllTokenInfo(props.accountInfo.selectedAccount?.address || '') + .getAllTokenInfo(address) .then(_assets => { - if (selectedNetworkName.value !== currentNetwork) return; + const activeKey = `${selectedNetworkName.value}:${selectedSubnetwork.value}:${selectedAddress.value}`; + if (activeKey !== requestKey) return; assets.value = _assets; isLoading.value = false; + scheduleHeliosResultCheck(_assets); }) .catch(e => { console.error(e); - if (selectedNetworkName.value !== currentNetwork) return; + const activeKey = `${selectedNetworkName.value}:${selectedSubnetwork.value}:${selectedAddress.value}`; + if (activeKey !== requestKey) return; isFetchError.value = true; isLoading.value = false; assets.value = []; }); } -}; -const selectedAddress = computed( - () => props.accountInfo.selectedAccount?.address || '', -); + + +const selectedAddress = computed(() => props.accountInfo.selectedAccount?.address || ''); const selectedNetworkName = computed(() => props.network.name); const selectedSubnetwork = computed(() => props.subnetwork); const showAddCustomTokens = ref(false); - const isSolanaStakingBanner = ref(false); const bannersState = new BannersState(); +const isEvmNetwork = computed(() => props.network.provider === ProviderName.ethereum); +const isMassaNetwork = computed(() => props.network.provider === ProviderName.massa); + +watch([selectedAddress, selectedNetworkName, selectedSubnetwork], () => { + clearHeliosPoll(); + resetHelios(); + updateAssets(); +}); -// Network type checks -const isEvmNetwork = computed( - () => props.network.provider === ProviderName.ethereum, -); -const isMassaNetwork = computed( - () => props.network.provider === ProviderName.massa, -); +onBeforeUnmount(clearHeliosPoll); -watch([selectedAddress, selectedNetworkName, selectedSubnetwork], updateAssets); onMounted(async () => { updateAssets(); if (await bannersState.showNetworkAssetsSolanaStakingBanner()) { @@ -174,33 +166,12 @@ onMounted(async () => { } }); -const toggleDeposit = () => { - showDeposit.value = !showDeposit.value; -}; - -const toggleShowAddCustomTokens = () => { - showAddCustomTokens.value = !showAddCustomTokens.value; -}; - +const toggleDeposit = () => { showDeposit.value = !showDeposit.value; }; +const toggleShowAddCustomTokens = () => { showAddCustomTokens.value = !showAddCustomTokens.value; }; const addCustomAsset = (asset: AssetsType) => { - const existingAsset = assets.value.find(a => { - if ( - a.contract && - asset.contract && - a.contract.toLowerCase() === asset.contract.toLowerCase() - ) { - return true; - } - - return false; - }); - - if (!existingAsset) { - // refetches assets to update the custom token - updateAssets(); - } + const existingAsset = assets.value.find(a => a.contract && asset.contract && a.contract.toLowerCase() === asset.contract.toLowerCase()); + if (!existingAsset) updateAssets(); }; - const closeSolanaStakingBanner = () => { isSolanaStakingBanner.value = false; bannersState.hideNetworkAssetsSolanaStakingBanner(); @@ -210,58 +181,13 @@ const closeSolanaStakingBanner = () => { +.network-assets__scroll-area { .ps__rail-y { right: 3px !important; margin: 59px 0 !important; } } + \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 81a4bcd7b..87fa85087 100644 --- a/yarn.lock +++ b/yarn.lock @@ -68,6 +68,41 @@ __metadata: languageName: node linkType: hard +"@a16z/helios@npm:^0.11.1": + version: 0.11.1 + resolution: "@a16z/helios@npm:0.11.1" + dependencies: + "@1inch/byte-utils": "npm:3.0.0" + ethers: "npm:6.13.5" + peerDependencies: + assert: ^2.0.0 + axios: ">=1 <2" + peerDependenciesMeta: + assert: + optional: true + axios: + optional: true + checksum: 10/af71246377bfa3f2e60d755ae049691efc80fef59d9d6517b6f300a9e1202294f70a8023bb3559be74ecbfd705c871a74c55f5c71e8ec4b26c695446bb2862fa + languageName: node + linkType: hard + +"@a16z/helios@npm:^0.11.1": + version: 0.11.1 + resolution: "@a16z/helios@npm:0.11.1" + dependencies: + eventemitter3: "npm:^5.0.1" + uuid: "npm:^11.0.5" + checksum: 10/d77ad728fc4968ad5019ddbf3c01f6a56a1373fb05cc5d22a1f84975316855768a0e5a9bf85d4fd4d86dec7857ad274ba4f010ecde0dc56e6bbed032e180879b + languageName: node + linkType: hard + +"@acemir/cssom@npm:^0.9.28": + version: 0.9.28 + resolution: "@acemir/cssom@npm:0.9.28" + checksum: 10/429faa2af7bffdcf3311e984f6b8ced77bf58d936e0e9a22c2fc892a84e54d2c8cbd71ae3a979ef54241509ac7c866cb880d919e3e713e64c865a1cd4ede3a1f + languageName: node + linkType: hard + "@achrinza/node-ipc@npm:^9.2.5": version: 9.2.9 resolution: "@achrinza/node-ipc@npm:9.2.9" @@ -1711,6 +1746,7 @@ __metadata: version: 0.0.0-use.local resolution: "@enkryptcom/extension@workspace:packages/extension" dependencies: + "@a16z/helios": "npm:^0.11.1" "@amplitude/analytics-browser": "npm:^2.38.1" "@crxjs/vite-plugin": "npm:^2.4.0" "@enkryptcom/extension-bridge": "workspace:^" @@ -30635,6 +30671,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^11.0.5": + version: 11.1.0 + resolution: "uuid@npm:11.1.0" + bin: + uuid: dist/esm/bin/uuid + checksum: 10/d2da43b49b154d154574891ced66d0c83fc70caaad87e043400cf644423b067542d6f3eb641b7c819224a7cd3b4c2f21906acbedd6ec9c6a05887aa9115a9cf5 + languageName: node + linkType: hard + "uuid@npm:^3.3.2": version: 3.4.0 resolution: "uuid@npm:3.4.0"