diff --git a/src/lib/demo/session.ts b/src/lib/demo/session.ts new file mode 100644 index 0000000..ca7caf1 --- /dev/null +++ b/src/lib/demo/session.ts @@ -0,0 +1,202 @@ +import { networkRegistry } from '@/lib/networks'; +import { VDL_DENOM } from '@/lib/swap/vdlSwapConfig'; +import type { Balance } from '@/types/wallet'; +import type { Delegation, UnbondingDelegation, Validator } from '@/lib/cosmos/staking'; + +let active = false; +const spent = new Map(); +const extraDelegations: Delegation[] = []; + +export const DEMO_MNEMONIC = 'demo demo demo demo demo demo demo demo demo demo demo demo'; + +export function isDemoSession(): boolean { + return active; +} + +export function setDemoSession(next: boolean): void { + active = next; + if (!next) { + spent.clear(); + extraDelegations.length = 0; + } +} + +/** Not a real address. DEMO is visible at both ends after the wallet truncates it. */ +export function demoAddressForPrefix(prefix: string, accountIndex = 0): string { + const n = String(accountIndex + 1).padStart(2, '0'); + return `${prefix}1DEMO-NOT-REAL-${n}-DEMO`; +} + +export function demoTxHash(): string { + const salt = Math.floor(Math.random() * 0xffffff) + .toString(16) + .padStart(6, '0'); + return `DEMO${salt}${'0'.repeat(54)}`.slice(0, 64); +} + +export function demoChainAddress(networkId: string, accountIndex = 0): string { + const n = String(accountIndex + 1).padStart(2, '0'); + const network = networkRegistry.get(networkId); + if (network?.type === 'bitcoin') return `bc1DEMO-NOT-A-REAL-BITCOIN-${n}-DEMO`; + if (network?.type === 'evm') return `0xDEMO-NOT-A-REAL-ADDRESS-${n}-DEMO`; + if (network?.type === 'svm') return `DEMO-SOL-NOT-A-REAL-ADDRESS-${n}-DEMO`; + const prefix = networkRegistry.getCosmos(networkId)?.bech32Prefix ?? 'bze'; + return demoAddressForPrefix(prefix, accountIndex); +} + +function baseAmount(networkId: string, denom: string): bigint { + if (networkId === 'beezee-1' && denom === 'ubze') return 2_500_000_000n; + if (networkId === 'beezee-1' && denom === VDL_DENOM) return 1_800_000_000n; + if (denom === 'sat') return 1_500_000n; + if (denom === 'wei') return 420_000_000_000_000_000n; + if (denom === 'lamports') return 2_500_000_000n; + return 12_500_000n; +} + +export function demoBalances(networkId: string): Balance[] { + const network = networkRegistry.get(networkId); + let rows: Balance[] = []; + + if (network?.type === 'bitcoin') { + rows = [{ denom: 'sat', amount: baseAmount(networkId, 'sat').toString() }]; + } else if (network?.type === 'evm') { + rows = [{ denom: 'wei', amount: baseAmount(networkId, 'wei').toString() }]; + } else if (network?.type === 'svm') { + rows = [{ denom: 'lamports', amount: baseAmount(networkId, 'lamports').toString() }]; + } else { + const denom = networkRegistry.getCosmos(networkId)?.feeDenom ?? 'ustake'; + rows = [{ denom, amount: baseAmount(networkId, denom).toString() }]; + if (networkId === 'beezee-1') { + rows.push({ denom: VDL_DENOM, amount: baseAmount(networkId, VDL_DENOM).toString() }); + } + } + + return rows.map((row) => { + const used = spent.get(`${networkId}:${row.denom}`) ?? 0n; + const left = baseAmount(networkId, row.denom) - used; + return { denom: row.denom, amount: (left > 0n ? left : 0n).toString() }; + }); +} + +export function spendDemo(networkId: string, denom: string, amount: string): void { + const value = BigInt(amount); + if (value <= 0n) return; + const key = `${networkId}:${denom}`; + spent.set(key, (spent.get(key) ?? 0n) + value); +} + +export function creditDemo(networkId: string, denom: string, amount: string): void { + const value = BigInt(amount); + if (value <= 0n) return; + const key = `${networkId}:${denom}`; + const next = (spent.get(key) ?? 0n) - value; + if (next > 0n) spent.set(key, next); + else spent.delete(key); +} + +function demoValidator(chainId: string, index: number, moniker: string, commission: string): Validator { + const prefix = `${networkRegistry.getCosmos(chainId)?.bech32Prefix ?? 'bze'}valoper`; + return { + operatorAddress: demoAddressForPrefix(prefix, index), + consensusPubkey: 'demo', + jailed: false, + status: 'BOND_STATUS_BONDED', + tokens: '1000000000000', + delegatorShares: '1000000000000', + description: { + moniker, + identity: '', + website: '', + securityContact: '', + details: 'Demo validator. Staking here does not leave this browser.', + }, + commission: { + commissionRates: { + rate: commission, + maxRate: '0.200000000000000000', + maxChangeRate: '0.010000000000000000', + }, + updateTime: '2026-01-01T00:00:00Z', + }, + minSelfDelegation: '1', + }; +} + +function initialDelegation(delegatorAddress: string, denom: string, validatorAddress: string): Delegation { + return { + delegation: { + delegatorAddress, + validatorAddress, + shares: '500000000', + }, + balance: { denom, amount: '500000000' }, + }; +} + +export function demoStakeSnapshot(chainId: string, delegatorAddress: string) { + const denom = networkRegistry.getCosmos(chainId)?.feeDenom ?? 'ubze'; + const validators = [ + demoValidator(chainId, 1, 'Demo Validator', '0.050000000000000000'), + demoValidator(chainId, 2, 'Sample Node', '0.080000000000000000'), + ]; + const seeded = initialDelegation(delegatorAddress, denom, validators[0].operatorAddress); + const delegations = [ + seeded, + ...extraDelegations.filter((item) => item.delegation.delegatorAddress === delegatorAddress), + ]; + const available = demoBalances(chainId).find((row) => row.denom === denom)?.amount ?? '0'; + const unbonding: UnbondingDelegation[] = []; + + return { + validators, + delegations, + rewards: [{ denom, amount: '12000000' }], + unbonding, + available, + apr: 12, + }; +} + +type DemoMessage = { + typeUrl?: string; + value?: { + amount?: { denom?: string; amount?: string }; + validatorAddress?: string; + delegatorAddress?: string; + }; +}; + +export function applyDemoMessages(chainId: string, messages: DemoMessage[]): void { + for (const message of messages) { + const amount = message.value?.amount; + if (!amount?.denom || !amount.amount) continue; + const typeUrl = message.typeUrl ?? ''; + const delegator = message.value?.delegatorAddress ?? ''; + const validator = message.value?.validatorAddress ?? ''; + + if (typeUrl.includes('MsgUndelegate')) { + creditDemo(chainId, amount.denom, amount.amount); + const match = extraDelegations.find( + (item) => item.delegation.validatorAddress === validator && item.balance.denom === amount.denom + ); + if (match) { + const left = BigInt(match.balance.amount) - BigInt(amount.amount); + match.balance.amount = (left > 0n ? left : 0n).toString(); + match.delegation.shares = match.balance.amount; + } + continue; + } + + spendDemo(chainId, amount.denom, amount.amount); + if (typeUrl.includes('MsgDelegate') && delegator && validator) { + extraDelegations.push({ + delegation: { + delegatorAddress: delegator, + validatorAddress: validator, + shares: amount.amount, + }, + balance: { denom: amount.denom, amount: amount.amount }, + }); + } + } +} diff --git a/src/popup/App.tsx b/src/popup/App.tsx index 8684936..f4c3862 100644 --- a/src/popup/App.tsx +++ b/src/popup/App.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import { Box } from '@chakra-ui/react'; +import { Box, Button, HStack, Text } from '@chakra-ui/react'; import browser from 'webextension-polyfill'; import { useWalletStore } from '@/store/walletStore'; import { MessageType } from '@/types/messages'; @@ -54,7 +54,7 @@ const App: React.FC = () => { const [minTimeElapsed, setMinTimeElapsed] = useState(false); const [approvalId, setApprovalId] = useState(null); const resetToImport = useRef(false); - const { isInitialized, isLocked, initialize } = useWalletStore(); + const { isDemo, isInitialized, isLocked, initialize, startDemo, exitDemo } = useWalletStore(); // Minimum display time for smooth loading animation useEffect(() => { @@ -130,6 +130,17 @@ const App: React.FC = () => { setView('import'); }; + const handleTryDemo = () => { + startDemo(); + setView('dashboard'); + }; + + const handleExitDemo = () => { + exitDemo(); + resetToImport.current = false; + setView('create'); + }; + const switchToImport = () => { setView('import'); }; @@ -189,6 +200,23 @@ const App: React.FC = () => { bg="#0a0a0a" color="white" > + {isDemo && ( + + + Demo. Nothing is saved or sent. + + + + )} {view === 'loading' && ( { )} {view === 'create' && ( - + )} {view === 'import' && ( - + )} {view === 'unlock' && ( diff --git a/src/popup/components/DemoSwap.tsx b/src/popup/components/DemoSwap.tsx new file mode 100644 index 0000000..e1c580d --- /dev/null +++ b/src/popup/components/DemoSwap.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import { Box, Button, Input, Text, VStack } from '@chakra-ui/react'; +import type { VdlSwapMode } from '@/lib/swap/vdlSwapConfig'; +import { demoTxHash } from '@/lib/demo/session'; + +interface Props { + mode: VdlSwapMode; +} + +const DemoSwap: React.FC = ({ mode }) => { + const [amount, setAmount] = useState('1'); + const [done, setDone] = useState<{ hash: string; paid: string; received: string } | null>(null); + + const pay = mode === 'buy' ? 'BZE' : 'VDL'; + const receive = mode === 'buy' ? 'VDL' : 'BZE'; + const quoted = (Number(amount) * (mode === 'buy' ? 0.89 : 1.19)).toFixed(4); + + const finish = () => { + if (!amount || Number(amount) <= 0) return; + setDone({ + hash: demoTxHash(), + paid: `${amount} ${pay}`, + received: `${quoted} ${receive}`, + }); + }; + + if (done) { + return ( + + Demo swap complete + + Paid {done.paid}. Received {done.received}. Nothing was sent. + + + {done.hash} + + + + ); + } + + return ( + + + + Pay {pay} + + setAmount(event.target.value)} + bg="#0a0a0a" + border="none" + borderRadius="xl" + size="lg" + /> + + + + Receive {receive} + + {quoted} + + on BeeZee. Demo quote only. + + + + + ); +}; + +export default DemoSwap; diff --git a/src/popup/components/SendModal.tsx b/src/popup/components/SendModal.tsx index acbb7cc..cc0544b 100644 --- a/src/popup/components/SendModal.tsx +++ b/src/popup/components/SendModal.tsx @@ -29,6 +29,7 @@ import { } from '@chakra-ui/react'; import { ChevronDownIcon } from '@chakra-ui/icons'; import { useWalletStore } from '@/store/walletStore'; +import { demoTxHash, isDemoSession, spendDemo } from '@/lib/demo/session'; import { useChainStore } from '@/store/chainStore'; import { ChainInfo } from '@/types/wallet'; import { fetchChainAssets, RegistryAsset, parseBeeZeeLPToken } from '@/lib/assets/chainRegistry'; @@ -393,6 +394,7 @@ const SendModal: React.FC = ({ }, [tokensWithBalance, selectedDenom, isBitcoin, isEvm, registryAssets]); const validateAddress = (address: string): boolean => { + if (isDemoSession() && address.trim().length > 0) return true; if (isBitcoin) { const network = chainId === 'bitcoin-mainnet' ? 'mainnet' : 'testnet'; return isValidBitcoinAddress(address, network); @@ -570,6 +572,26 @@ const SendModal: React.FC = ({ setLoading(true); try { + if (isDemoSession()) { + const hash = demoTxHash(); + if (selectedToken && amount) { + const smallest = Math.floor( + parseFloat(amount) * Math.pow(10, selectedToken.decimals) + ).toString(); + if (/^\d+$/.test(smallest)) spendDemo(chainId, selectedDenom, smallest); + } + toast({ + title: 'Transaction sent!', + description: `Demo only. TX: ${hash.slice(0, 16)}...`, + status: 'success', + duration: 5000, + }); + await fetchBalance(chainId, chainAddress); + onSuccess?.(); + onClose(); + return; + } + if (isBitcoin) { // UTXO transaction signing and sending const btcNetwork = networkRegistry.getBitcoin(chainId); diff --git a/src/popup/components/SwapModal.tsx b/src/popup/components/SwapModal.tsx index c14edae..b5f84a4 100644 --- a/src/popup/components/SwapModal.tsx +++ b/src/popup/components/SwapModal.tsx @@ -27,6 +27,7 @@ import { } from '@chakra-ui/react'; import { ChevronDownIcon, RepeatIcon } from '@chakra-ui/icons'; import { useWalletStore } from '@/store/walletStore'; +import { demoTxHash, isDemoSession, spendDemo } from '@/lib/demo/session'; import { useChainStore } from '@/store/chainStore'; import { ChainInfo } from '@/types/wallet'; import { fetchChainAssets } from '@/lib/assets/chainRegistry'; @@ -375,6 +376,21 @@ const SwapModal: React.FC = ({ return; } + if (isDemoSession()) { + const out = (parseFloat(fromAmount) * 0.98).toFixed(6); + setToAmount(out); + setSelectedRoute({ + path: [fromToken.denom, toToken.denom], + pools: ['demo'], + estimatedOutput: BigInt(Math.floor(parseFloat(out) * 10 ** toToken.decimals)), + priceImpact: 0.2, + totalFee: 0.003, + hops: 1, + }); + setFetchingQuote(false); + return; + } + const currentFromToken = fromToken; const calculateQuote = async () => { setFetchingQuote(true); @@ -721,6 +737,21 @@ const SwapModal: React.FC = ({ return; } + if (isDemoSession()) { + const smallest = Math.floor(parseFloat(fromAmount) * Math.pow(10, fromToken.decimals)).toString(); + if (/^\d+$/.test(smallest)) spendDemo(chainId, fromToken.denom, smallest); + const hash = demoTxHash(); + toast({ + title: 'Swap complete', + description: `Demo only. TX: ${hash.slice(0, 16)}...`, + status: 'success', + duration: 5000, + }); + onSuccess?.(); + onClose(); + return; + } + // Osmosis with imported account or when mnemonic not in memory: require password const isImportedAccount = selectedAccount?.id?.startsWith('imported-'); const needsPassword = diff --git a/src/popup/pages/CreateWallet.tsx b/src/popup/pages/CreateWallet.tsx index bee8bdf..09ef7b2 100644 --- a/src/popup/pages/CreateWallet.tsx +++ b/src/popup/pages/CreateWallet.tsx @@ -18,9 +18,10 @@ import { useWalletStore } from '@/store/walletStore'; interface Props { onSuccess: () => void; onSwitchToImport: () => void; + onTryDemo: () => void; } -const CreateWallet: React.FC = ({ onSuccess, onSwitchToImport }) => { +const CreateWallet: React.FC = ({ onSuccess, onSwitchToImport, onTryDemo }) => { const [step, setStep] = useState<'password' | 'mnemonic'>('password'); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); @@ -166,6 +167,19 @@ const CreateWallet: React.FC = ({ onSuccess, onSwitchToImport }) => { Create Wallet + + + +