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
1 change: 1 addition & 0 deletions packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
139 changes: 139 additions & 0 deletions packages/extension/src/providers/ethereum/libs/helios-verifier.ts
Original file line number Diff line number Diff line change
@@ -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<void> | null = null;
let syncPromise: Promise<void> | null = null;
let isSynced = false;
let currentExecutionRpc = '';
let sessionId = 0;

async function fetchFreshCheckpoint(consensusRpc: string): Promise<string | undefined> {
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<void> {
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<string, string> = {
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<VerificationResult> {
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,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
52 changes: 38 additions & 14 deletions packages/extension/src/providers/ethereum/types/erc20-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ 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;
}

export class Erc20Token extends BaseToken {
public contract: string;
public heliosVerification: VerificationResult | undefined = undefined;

constructor(options: Erc20TokenOptions) {
super(options);
Expand All @@ -20,26 +25,45 @@ export class Erc20Token extends BaseToken {
public async getLatestUserBalance(
api: EvmAPI,
address: string,
chainId?: string,
): Promise<string> {
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(() => {});
Comment thread
pheobeayo marked this conversation as resolved.
}

return balance;
}

public async send(): Promise<any> {
throw new Error('EVM-send is not implemented here');
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}),
),
Expand All @@ -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),
Expand Down Expand Up @@ -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);
Expand All @@ -255,6 +257,7 @@ export class EvmNetwork extends BaseNetwork {
sparkline: '',
priceChangePercentage: 0,
icon: token.icon,
heliosVerification: token.heliosVerification,
};

const marketInfo = marketInfos[token.contract.toLowerCase()];
Expand Down
1 change: 1 addition & 0 deletions packages/extension/src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,4 +227,5 @@ export interface AssetsType {
sparkline: string;
priceChangePercentage: number;
baseToken?: BaseToken;
heliosVerification?: import('@/providers/ethereum/libs/helios-verifier').VerificationResult;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<template>
<transition name="helios-banner-slide">
<div v-if="show" class="helios-warning-banner" role="alert">
<div class="helios-warning-banner__icon">
<shield-alert-icon />
</div>
<div class="helios-warning-banner__body">
<p class="helios-warning-banner__title">RPC provider may be lying</p>
<p class="helios-warning-banner__detail">
The Helios light client cryptographically verified that the balance
shown for <strong>{{ tokenSymbol }}</strong> differs from what your RPC provider reported.
</p>
<p class="helios-warning-banner__values">
RPC reported:&nbsp;<code>{{ rpcBalanceFormatted }}</code>&nbsp;·&nbsp;Proven on-chain:&nbsp;<code>{{ provenBalanceFormatted }}</code>
</p>
<a class="helios-warning-banner__learn-more" href="https://walletbeat.eth.limo/docs/chain-verification" target="_blank" rel="noopener noreferrer">
Learn more about chain verification →
</a>
</div>
<button class="helios-warning-banner__dismiss" aria-label="Dismiss warning" @click="$emit('dismiss')">✕</button>
</div>
</transition>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import ShieldAlertIcon from '@action/icons/common/shield-alert-icon.vue';
import { fromBase } from '@enkryptcom/utils';
import { formatFloatingPointValue } from '@/libs/utils/number-formatter';

const props = defineProps<{
show: boolean;
tokenSymbol: string;
tokenDecimals: number;
rpcBalance: string;
provenBalance: string;
}>();

defineEmits<{ (e: 'dismiss'): void }>();

function hexToDecimalDisplay(hex: string, decimals: number): string {
try {
const raw = BigInt(hex).toString();
return formatFloatingPointValue(fromBase(raw, decimals)).value;
} catch {
return hex;
}
}

const rpcBalanceFormatted = computed(() => hexToDecimalDisplay(props.rpcBalance, props.tokenDecimals));
const provenBalanceFormatted = computed(() => hexToDecimalDisplay(props.provenBalance, props.tokenDecimals));
</script>

<style lang="less" scoped>
@import '@action/styles/theme.less';
.helios-warning-banner {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 8px 12px;
padding: 12px 14px;
border-radius: 12px;
background: rgba(239, 68, 68, 0.07);
border: 1.5px solid rgba(239, 68, 68, 0.35);
&__icon { flex-shrink: 0; margin-top: 1px; color: #dc2626; svg { width: 20px; height: 20px; } }
&__body { flex: 1; min-width: 0; }
&__title { font-size: 13px; font-weight: 700; color: #b91c1c; margin: 0 0 4px 0; }
&__detail { font-size: 12px; color: @primaryLabel; margin: 0 0 4px 0; line-height: 1.5; }
&__values { font-size: 11px; color: @secondaryLabel; margin: 0 0 6px 0; code { font-family: monospace; background: rgba(0,0,0,0.05); padding: 1px 4px; border-radius: 4px; } }
&__learn-more { font-size: 11px; color: #7559d1; text-decoration: none; font-weight: 600; &:hover { text-decoration: underline; } }
&__dismiss { flex-shrink: 0; background: none; border: none; cursor: pointer; font-size: 14px; color: @tertiaryLabel; padding: 0 0 0 4px; &:hover { color: @primaryLabel; } }
}
.helios-banner-slide-enter-active, .helios-banner-slide-leave-active { transition: opacity 0.25s ease, transform 0.25s ease; }
.helios-banner-slide-enter-from, .helios-banner-slide-leave-to { opacity: 0; transform: translateY(-6px); }
</style>
Loading