From ac0faf8cdcce27e5761dae72d865e973266c1c6a Mon Sep 17 00:00:00 2001 From: onmax Date: Wed, 26 Feb 2025 13:27:47 +0100 Subject: [PATCH 01/31] feat(demo): integrate demo mode --- src/components/modals/BuyOptionsModal.vue | 4 + src/components/modals/demos/DemoModalBuy.vue | 146 ++ .../modals/demos/DemoModalFallback.vue | 35 + src/hub.ts | 3 +- src/main.ts | 50 +- src/network.ts | 9 +- src/stores/Demo.ts | 1242 +++++++++++++++++ 7 files changed, 1469 insertions(+), 20 deletions(-) create mode 100644 src/components/modals/demos/DemoModalBuy.vue create mode 100644 src/components/modals/demos/DemoModalFallback.vue create mode 100644 src/stores/Demo.ts diff --git a/src/components/modals/BuyOptionsModal.vue b/src/components/modals/BuyOptionsModal.vue index a9a0604bf..e3070b727 100644 --- a/src/components/modals/BuyOptionsModal.vue +++ b/src/components/modals/BuyOptionsModal.vue @@ -202,6 +202,7 @@ + + diff --git a/src/components/modals/demos/DemoModalFallback.vue b/src/components/modals/demos/DemoModalFallback.vue new file mode 100644 index 000000000..b9fdfa9de --- /dev/null +++ b/src/components/modals/demos/DemoModalFallback.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/hub.ts b/src/hub.ts index 4fc72bce7..6a7fe89ff 100644 --- a/src/hub.ts +++ b/src/hub.ts @@ -38,6 +38,7 @@ import { WELCOME_MODAL_LOCALSTORAGE_KEY, WELCOME_STAKING_MODAL_LOCALSTORAGE_KEY import { usePwaInstallPrompt } from './composables/usePwaInstallPrompt'; import type { SetupSwapWithKycResult, SWAP_KYC_HANDLER_STORAGE_KEY } from './swap-kyc-handler'; // avoid bundling import type { RelayServerInfo } from './lib/usdc/OpenGSN'; +import { DemoHubApi, checkIfDemoIsActive } from './stores/Demo'; export function shouldUseRedirects(ignoreSettings = false): boolean { if (!ignoreSettings) { @@ -114,7 +115,7 @@ function getBehavior({ // We can't use the reactive config via useConfig() here because that one can only be used after the composition-api // plugin has been registered in Vue 2. -const hubApi = new HubApi(Config.hubEndpoint); +const hubApi = checkIfDemoIsActive() ? DemoHubApi.create() : new HubApi(Config.hubEndpoint); hubApi.on(HubApi.RequestType.ONBOARD, async (accounts) => { const { config } = useConfig(); diff --git a/src/main.ts b/src/main.ts index 79f93398f..9d684e273 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,7 @@ import { launchElectrum } from './electrum'; import { launchPolygon } from './ethers'; import { useAccountStore } from './stores/Account'; import { useFiatStore } from './stores/Fiat'; +import { useDemoStore } from './stores/Demo'; import { useSettingsStore } from './stores/Settings'; import router from './router'; import { i18n, loadLanguage } from './i18n/i18n-setup'; @@ -48,14 +49,21 @@ Vue.use(VuePortal, { name: 'Portal' }); async function start() { initPwa(); // Must be called as soon as possible to catch early browser events related to PWA - await initStorage(); // Must be awaited before starting Vue - initTrials(); // Must be called after storage was initialized, can affect Config - // Must run after VueCompositionApi has been enabled and after storage was initialized. Could potentially run in - // background and in parallel to syncFromHub, but RedirectRpcClient.init does not actually run async code anyways. - await initHubApi(); - syncFromHub(); // Can run parallel to Vue initialization; must be called after storage was initialized. - - serviceWorkerHasUpdate.then((hasUpdate) => useSettingsStore().state.updateAvailable = hasUpdate); + const { isDemoEnabled } = useDemoStore(); + + if (!isDemoEnabled.value) { + await initStorage(); // Must be awaited before starting Vue + initTrials(); // Must be called after storage was initialized, can affect Config + // Must run after VueCompositionApi has been enabled and after storage was initialized. Could potentially run in + // background and in parallel to syncFromHub, but RedirectRpcClient.init does not actually run async code + // anyways. + await initHubApi(); + syncFromHub(); // Can run parallel to Vue initialization; must be called after storage was initialized. + + serviceWorkerHasUpdate.then((hasUpdate) => useSettingsStore().state.updateAvailable = hasUpdate); + } else { + useDemoStore().initialize(router); + } // Update exchange rates every 2 minutes or every 10 minutes, depending on whether the Wallet is currently actively // used. If an update takes longer than that time due to a provider's rate limit, wait until the update succeeds @@ -94,11 +102,15 @@ async function start() { const { language } = useSettingsStore(); loadLanguage(language.value); - startSentry(); + if (!isDemoEnabled.value) { + startSentry(); + } const { config } = useConfig(); - if (config.environment !== ENV_MAIN) { + if (isDemoEnabled.value) { + document.title = 'Nimiq Wallet Demo'; + } else if (config.environment !== ENV_MAIN) { document.title = 'Nimiq Testnet Wallet'; } @@ -107,15 +119,17 @@ async function start() { initFastspotApi(config.fastspot.apiEndpoint, config.fastspot.apiKey); }); - watch(() => { - if (!config.oasis.apiEndpoint) return; - initOasisApi(config.oasis.apiEndpoint); - }); + if (!isDemoEnabled.value) { + watch(() => { + if (!config.oasis.apiEndpoint) return; + initOasisApi(config.oasis.apiEndpoint); + }); - watch(() => { - if (!config.ten31Pass.enabled) return; - initKycConnection(); - }); + watch(() => { + if (!config.ten31Pass.enabled) return; + initKycConnection(); + }); + } // Make reactive config accessible in components Vue.prototype.$config = config; diff --git a/src/network.ts b/src/network.ts index 4d8002bc8..bb150a40d 100644 --- a/src/network.ts +++ b/src/network.ts @@ -12,6 +12,7 @@ import { AddStakeEvent, ApiValidator, RawValidator, useStakingStore } from './st import { ENV_MAIN, STAKING_CONTRACT_ADDRESS } from './lib/Constants'; import { reportToSentry } from './lib/Sentry'; import { useAccountStore } from './stores/Account'; +import { useDemoStore } from './stores/Demo'; let isLaunched = false; let clientPromise: Promise; @@ -102,6 +103,7 @@ export async function launchNetwork() { const transactionsStore = useTransactionsStore(); const addressStore = useAddressStore(); const stakingStore = useStakingStore(); + const demoStore = useDemoStore(); const subscribedAddresses = new Set(); @@ -118,7 +120,7 @@ export async function launchNetwork() { network$.fetchingTxHistory--; async function updateBalances(addresses: string[] = [...balances.keys()]) { - if (!addresses.length) return; + if (!addresses.length || demoStore.isDemoEnabled) return; await client.waitForConsensusEstablished(); const accounts = await retry(() => client.getAccounts(addresses)).catch(reportFor('getAccounts')); if (!accounts) return; @@ -303,6 +305,7 @@ export async function launchNetwork() { })(); function transactionListener(plain: PlainTransactionDetails) { + if (demoStore.isDemoEnabled) return; if (plain.recipient === STAKING_CONTRACT_ADDRESS) { if (plain.data.type === 'add-stake') { if (!balances.has(plain.sender) && 'staker' in plain.data) { @@ -340,6 +343,7 @@ export async function launchNetwork() { } function subscribe(addresses: string[]) { + if (demoStore.isDemoEnabled) return false; client.addTransactionListener(transactionListener, addresses); updateBalances(addresses); updateStakes(addresses); @@ -349,6 +353,7 @@ export async function launchNetwork() { // Subscribe to new addresses (for balance updates and transactions) // Also remove logged out addresses from fetched (so that they get fetched on next login) watch(addressStore.addressInfos, () => { + if (demoStore.isDemoEnabled) return; const newAddresses: string[] = []; const removedAddresses = new Set(subscribedAddresses); @@ -380,6 +385,7 @@ export async function launchNetwork() { // Fetch transactions for active address watch([addressStore.activeAddress, txFetchTrigger], ([activeAddress, trigger]) => { + if (demoStore.isDemoEnabled) return; const address = activeAddress as string | null; if (!address || fetchedAddresses.value.includes(address)) return; addFetchedAddress(address); @@ -428,6 +434,7 @@ export async function launchNetwork() { // Fetch transactions for proxies const proxyStore = useProxyStore(); watch(proxyStore.networkTrigger, () => { + if (demoStore.isDemoEnabled) return; const newProxies: string[] = []; const addressesToSubscribe: string[] = []; for (const proxyAddress of proxyStore.allProxies.value) { diff --git a/src/stores/Demo.ts b/src/stores/Demo.ts new file mode 100644 index 000000000..c9c609184 --- /dev/null +++ b/src/stores/Demo.ts @@ -0,0 +1,1242 @@ +/* eslint-disable max-len */ +import { createStore } from 'pinia'; +import VueRouter from 'vue-router'; +import { TransactionState as ElectrumTransactionState } from '@nimiq/electrum-client'; +import HubApi from '@nimiq/hub-api'; +import { CryptoCurrency, Utf8Tools } from '@nimiq/utils'; +import { KeyPair, PlainTransactionDetails, PrivateKey } from '@nimiq/core'; +import { STAKING_CONTRACT_ADDRESS } from '@/lib/Constants'; +import { AccountType, useAccountStore } from '@/stores/Account'; +import { AddressType, useAddressStore } from '@/stores/Address'; +import { toSecs, useTransactionsStore } from '@/stores/Transactions'; +import { useBtcTransactionsStore } from '@/stores/BtcTransactions'; +import { useUsdtTransactionsStore, TransactionState as UsdtTransactionState } from '@/stores/UsdtTransactions'; +import { useUsdcTransactionsStore, TransactionState as UsdcTransactionState } from '@/stores/UsdcTransactions'; +import { useStakingStore } from '@/stores/Staking'; +import { useAccountSettingsStore } from '@/stores/AccountSettings'; +import { usePolygonAddressStore } from '@/stores/PolygonAddress'; +import Config from 'config'; +import { SwapAsset, SwapStatus } from '@nimiq/fastspot-api'; +import { SwapState, useSwapsStore } from '@/stores/Swaps'; +import { useBtcAddressStore } from './BtcAddress'; +import { useContactsStore } from './Contacts'; +import { useBtcLabelsStore } from './BtcLabels'; +import { useUsdcContactsStore } from './UsdcContacts'; +import { useUsdtContactsStore } from './UsdtContacts'; + +export type DemoState = { + active: boolean, +}; + +// The query param that activates the demo. e.g. https://wallet.nimiq.com/?demo= +const DEMO_PARAM = 'demo'; + +const DemoFallbackModal = () => + import( + /* webpackChunkName: 'demo-hub-fallback-modal' */ + '@/components/modals/demos/DemoModalFallback.vue' + ); + +const DemoPurchaseModal = () => + import( + /* webpackChunkName: 'account-menu-modal' */ + '@/components/modals/demos/DemoModalBuy.vue' + ); + +// Replacing the enum with a simple object to avoid backticks +const DemoModal = { + Fallback: 'demo-fallback', + Buy: 'demo-buy', +}; + +// Addresses for demo: +const demoNimAddress = 'NQ57 2814 7L5B NBBD 0EU7 EL71 HXP8 M7H8 MHKD'; +const demoBtcAddress = '1XYZDemoAddress'; +const demoPolygonAddress = '0xabc123DemoPolygonAddress'; +const buyFromAddress = 'NQ04 JG63 HYXL H3QF PPNA 7ED7 426M 3FQE FHE5'; + +// We keep this as our global/final balance, which should result from the transactions +const nimInitialBalance = 14041800000; // 14,041,800,000 - 14 april, 2018 +const usdtInitialBalance = 5000000000; // 5000 USDT (6 decimals) +const usdcInitialBalance = 3000000000; // 3000 USDC (6 decimals) + +// We keep a reference to the router here. +let demoRouter: VueRouter; + +/** + * Main store for the demo environment. + */ +export const useDemoStore = createStore({ + id: 'demo-store', + state: (): DemoState => ({ + active: checkIfDemoIsActive(), + }), + getters: { + isDemoEnabled: (state) => state.active, + }, + actions: { + /** + * Initializes the demo environment and sets up various routes, data, and watchers. + */ + async initialize(router: VueRouter) { + // eslint-disable-next-line no-console + console.warn('[Demo] Initializing demo environment...'); + + demoRouter = router; + + insertCustomDemoStyles(); + rewriteDemoRoutes(); + setupVisualCues(); + addDemoModalRoutes(); + interceptFetchRequest(); + + setupDemoAddresses(); + setupDemoAccount(); + + generateFakeNimTransactions(); + + const { addTransactions: addBtcTransactions } = useBtcTransactionsStore(); + addBtcTransactions(generateFakeBtcTransactions()); + + attachIframeListeners(); + replaceStakingFlow(); + replaceBuyNimFlow(); + }, + + /** + * Adds a pretend buy transaction to show a deposit coming in. + */ + async buyDemoNim(amount: number) { + const { addTransactions } = useTransactionsStore(); + addTransactions([ + createFakeTransaction({ + value: amount, + recipient: demoNimAddress, + sender: buyFromAddress, + data: { + type: 'raw', + raw: encodeTextToHex('Online Purchase'), + }, + }), + ]); + }, + }, +}); + +/** + * Checks if the 'demo' query param is present in the URL. + */ +export function checkIfDemoIsActive() { + return window.location.search.includes(DEMO_PARAM); +} + +/** + * Creates a style tag to add demo-specific CSS. + */ +function insertCustomDemoStyles() { + const styleElement = document.createElement('style'); + styleElement.innerHTML = demoCSS; + document.head.appendChild(styleElement); +} + +/** + * Sets up a router guard to handle redirects for the demo environment. + */ +function rewriteDemoRoutes() { + demoRouter.beforeEach((to, _from, next) => { + // Redirect certain known paths to the Buy demo modal + if (to.path === '/simplex' || to.path === '/moonpay') { + return next({ + path: `/${DemoModal.Buy}`, + query: { ...to.query, [DEMO_PARAM]: '' }, + }); + } + + // Ensure the ?demo param is in place + if (to.query.demo === '') return next(); + return next({ path: to.path, query: { ...to.query, [DEMO_PARAM]: '' } }); + }); +} + +/** + * Observes the home view to attach a highlight to some buttons for demonstration purposes. + */ +function setupVisualCues() { + const highlightTargets = [ + ['.sidebar .trade-actions button', { top: '-18px', right: '-4px' }], + ['.sidebar .swap-tooltip button', { top: '-18px', right: '-8px' }], + ['.actions .staking-button', { top: '-2px', right: '-2px' }], + ] as const; + + const mutationObserver = new MutationObserver(() => { + if (window.location.pathname !== '/') return; + + highlightTargets.forEach(([selector, position]) => { + const target = document.querySelector(selector); + if (!target || target.querySelector('.demo-highlight-badge')) return; + + const wrapper = document.createElement('div'); + wrapper.classList.add('demo-highlight-badge'); + wrapper.style.top = position.top; + wrapper.style.right = position.right; + const circle = document.createElement('div'); + + wrapper.appendChild(circle); + target.appendChild(wrapper); + }); + }); + + mutationObserver.observe(document.body, { childList: true, subtree: true }); +} + +/** + * Adds routes pointing to our demo modals. + */ +function addDemoModalRoutes() { + demoRouter.addRoute('root', { + name: DemoModal.Fallback, + path: `/${DemoModal.Fallback}`, + components: { modal: DemoFallbackModal }, + props: { modal: true }, + }); + demoRouter.addRoute('root', { + name: DemoModal.Buy, + path: `/${DemoModal.Buy}`, + components: { modal: DemoPurchaseModal }, + props: { modal: true }, + }); +} + +/** + * Setup and initialize the demo data for all currencies. + */ +function setupDemoAddresses() { + const { setAddressInfos } = useAddressStore(); + setAddressInfos([ + { + label: 'Demo Account', + type: AddressType.BASIC, + address: demoNimAddress, + balance: nimInitialBalance, + }, + ]); + + // Setup Polygon addresses and balances + const { setAddressInfos: setPolygonAddressInfos } = usePolygonAddressStore(); + setPolygonAddressInfos([{ + address: demoPolygonAddress, + balanceUsdc: usdcInitialBalance, + balanceUsdcBridged: 0, + balanceUsdtBridged: usdtInitialBalance, + pol: 1, + }]); + + // Setup polygon token transactions + generateFakePolygonTransactions(); +} + +/** + * Creates a fake main account referencing our demo addresses. + */ +function setupDemoAccount() { + const { addAccountInfo, setActiveCurrency } = useAccountStore(); + const { setStablecoin, setKnowsAboutUsdt } = useAccountSettingsStore(); + + // Setup account info with both USDC and USDT addresses + addAccountInfo({ + id: 'demo-account-1', + type: AccountType.BIP39, + label: 'Demo Main Account', + fileExported: true, + wordsExported: true, + addresses: [demoNimAddress], + btcAddresses: { internal: [demoBtcAddress], external: [demoBtcAddress] }, + polygonAddresses: [demoPolygonAddress, demoPolygonAddress], + uid: 'demo-uid-1', + }); + + // Pre-select USDC as the default stablecoin and mark USDT as known + setStablecoin(CryptoCurrency.USDC); + setKnowsAboutUsdt(true); + + setActiveCurrency(CryptoCurrency.NIM); +} + +/** + * Generates fake NIM transactions spanning the last ~4 years. + * They net to the global nimInitialBalance. + * We use a helper function to calculate each transaction value so it doesn't end in 0. + */ +function generateFakeNimTransactions() { + // We'll define total fractions so the net sum is 1. + const txDefinitions = [ + { fraction: -0.14, daysAgo: 1442, description: 'New Designer Backpack for Hiking Trip' }, + { fraction: -0.05, daysAgo: 1390, description: 'Streaming Subscription - Watch Anything Anytime', recipientLabel: 'Stream & Chill Co.' }, + { fraction: 0.1, daysAgo: 1370, description: 'Sold Vintage Camera to Photography Enthusiast', recipientLabel: 'Retro Photo Guy' }, + { fraction: -0.2, daysAgo: 1240, description: 'Groceries at Farmers Market' }, + { fraction: 0.2, daysAgo: 1185, description: 'Birthday Gift from Uncle Bob', recipientLabel: 'Uncle Bob 🎁' }, + { fraction: 0.3, daysAgo: 1120, description: 'Website Design Freelance Project', recipientLabel: 'Digital Nomad Inc.' }, + { fraction: 0.02, daysAgo: 980, description: 'Lunch Money Payback from Alex' }, + { fraction: 0.07, daysAgo: 940, description: 'Community Raffle Prize' }, + { fraction: -0.15, daysAgo: 875, description: 'Car Repair at Thunder Road Garage', recipientLabel: 'FixMyCar Workshop' }, + { fraction: -0.3, daysAgo: 780, description: 'Quarterly Apartment Rent', recipientLabel: 'Skyview Properties' }, + { fraction: -0.1, daysAgo: 720, description: 'Anniversary Dinner at Skyline Restaurant' }, + { fraction: -0.02, daysAgo: 650, description: 'Digital Book: "Blockchain for Beginners"' }, + { fraction: -0.03, daysAgo: 580, description: 'Music Festival Weekend Pass' }, + { fraction: 0.05, daysAgo: 540, description: 'Refund for Cancelled Flight' }, + { fraction: 0.5, daysAgo: 470, description: 'Software Development Project Payment', recipientLabel: 'Tech Solutions Ltd' }, + { fraction: 0.02, daysAgo: 390, description: 'Coffee Shop Reward Program Refund' }, + { fraction: -0.11, daysAgo: 320, description: 'Custom Tailored Suit Purchase' }, + { fraction: 0.06, daysAgo: 270, description: 'Website Testing Gig Payment' }, + { fraction: -0.08, daysAgo: 210, description: 'Electric Scooter Rental for Month' }, + { fraction: -0.12, daysAgo: 180, description: 'Online Course: "Advanced Crypto Trading"' }, + { fraction: 0.05, daysAgo: 120, description: 'Sold Digital Artwork', recipientLabel: 'NFT Collector' }, + { fraction: -0.1, daysAgo: 90, description: 'Quarterly Utility Bills', recipientLabel: 'City Power & Water' }, + { fraction: -0.1, daysAgo: 45, description: 'Winter Wardrobe Shopping' }, + ]; + + // Calculate sum of existing transactions to ensure they add up to exactly 1 + const existingSum = txDefinitions.reduce((sum, def) => sum + def.fraction, 0); + const remainingFraction = 1 - existingSum; + + // Add the final balancing transaction with appropriate description + if (Math.abs(remainingFraction) > 0.001) { // Only add if there's a meaningful amount to balance + txDefinitions.push({ + fraction: remainingFraction, + daysAgo: 14, + description: remainingFraction > 0 + ? 'Blockchain Hackathon Prize!' + : 'Annual Software Subscription Renewal', + recipientLabel: remainingFraction > 0 ? 'Crypto Innovation Fund' : undefined, + }); + } + + const { addTransactions } = useTransactionsStore(); + const { setContact } = useContactsStore(); + + for (const def of txDefinitions) { + let txValue = Math.floor(nimInitialBalance * def.fraction); + // Adjust so it doesn't end in a 0 digit + while (txValue > 0 && txValue % 10 === 0) { + txValue -= 1; + } + + const hex = encodeTextToHex(def.description); + const to32Bytes = (h: string) => h.padStart(64, '0').slice(-64); + const address = KeyPair.derive(PrivateKey.fromHex(to32Bytes(hex))).toAddress().toUserFriendlyAddress(); + const recipient = def.fraction > 0 ? demoNimAddress : address; + const sender = def.fraction > 0 ? address : demoNimAddress; + const data = { type: 'raw', raw: hex } as const; + const value = Math.abs(txValue); + const timestamp = calculateDaysAgo(def.daysAgo); + const tx: Partial = { value, recipient, sender, timestamp, data }; + addTransactions([createFakeTransaction(tx)]); + + // Add contact if a recipientLabel is provided + if (def.recipientLabel && def.fraction < 0) { + setContact(address, def.recipientLabel); + } + } +} + +/** + * Generates fake Bitcoin transactions spanning 5.5 years + */ +function generateFakeBtcTransactions() { + // Define transaction history with a similar structure to other currencies + const txDefinitions = [ + { + daysAgo: 2000, + value: 20000000, // 0.2 BTC + incoming: true, + description: 'Initial BTC purchase from exchange', + address: '1Kj4SNWFCxqvtP8nkJxeBwkXxgY9LW9rGg', + label: 'Satoshi Exchange', + }, + { + daysAgo: 1600, + value: 15000000, // 0.15 BTC + incoming: true, + description: 'Mining pool payout', + address: '1Hz7vQrRjnu3z9k7gxDYhKjEmABqChDvJr', + label: 'Genesis Mining Pool', + }, + { + daysAgo: 1200, + value: 19000000, // 0.19 BTC + incoming: false, + description: 'Purchase from online marketplace', + address: '1LxKe5kKdgGVwXukEgqFxh6DrCXF2Pturc', + label: 'Digital Bazaar Shop', + }, + { + daysAgo: 800, + value: 30000000, // 0.3 BTC + incoming: true, + description: 'Company payment for consulting', + address: '1N7aecJuKGDXzYK8CgpnNRYxdhZvXPxp3B', + label: 'Corporate Treasury', + }, + { + daysAgo: 365, + value: 15000000, // 0.15 BTC + incoming: false, + description: 'Auto-DCA investment program', + address: '12vxjmKJkfL9s5JwqUzEVVJGvKYJgALbsz', + }, + { + daysAgo: 180, + value: 7500000, // 0.075 BTC + incoming: true, + description: 'P2P sale of digital goods', + address: '1MZYS9nvVmFvSK7em5zzAsnvRq82RUcypS', + }, + { + daysAgo: 60, + value: 5000000, // 0.05 BTC + incoming: true, + description: 'Recent purchase from exchange', + address: '1Kj4SNWFCxqvtP8nkJxeBwkXxgY9LW9rGg', + }, + ]; + + const { setSenderLabel } = useBtcLabelsStore(); + + // Convert to BTC transaction format with inputs/outputs + const transactions = []; + const knownUtxos = new Map(); + let txCounter = 1; + + for (const def of txDefinitions) { + // Create a transaction hash for this transaction + const txHash = `btc-tx-${txCounter++}`; + + // Only add labels to select transactions to make the history look realistic + if (def.label) { + setSenderLabel(def.address, def.label); + } + + const tx = { + isCoinbase: false, + inputs: [ + { + address: def.incoming ? def.address : demoBtcAddress, + outputIndex: 0, + index: 0, + script: 'abcd', + sequence: 4294967295, + transactionHash: def.incoming ? txHash : getUTXOToSpend(knownUtxos)?.txHash || txHash, + witness: ['abcd'], + }, + ], + outputs: def.incoming + ? [ + { + value: def.value, + address: demoBtcAddress, + script: 'abcd', + index: 0, + }, + ] + : [ + { + value: def.value, + address: def.address, + script: 'abcd', + index: 0, + }, + { // Change output + value: 900000, + address: demoBtcAddress, + script: 'abcd', + index: 1, + }, + ], + transactionHash: txHash, + version: 1, + vsize: 200, + weight: 800, + locktime: 0, + confirmations: Math.max(1, Math.floor(10 - def.daysAgo / 200)), + replaceByFee: false, + timestamp: toSecs(calculateDaysAgo(def.daysAgo)), + state: ElectrumTransactionState.CONFIRMED, + }; + + // Update UTXOs for this transaction + updateUTXOs(knownUtxos, tx); + + transactions.push(tx); + } + + // Set up the address with current UTXOs + const { addAddressInfos } = useBtcAddressStore(); + addAddressInfos([{ + address: demoBtcAddress, + txoCount: transactions.length + 2, // Total number of outputs received + utxos: Array.from(knownUtxos.values()), + }]); + + return transactions; +} + +/** + * Tracks UTXO changes for BTC transactions + */ +function updateUTXOs(knownUtxos: Map, tx: any) { + // Remove spent inputs + for (const input of tx.inputs) { + if (input.address === demoBtcAddress) { + const utxoKey = `${input.transactionHash}:${input.outputIndex}`; + knownUtxos.delete(utxoKey); + } + } + + // Add new outputs for our address + for (const output of tx.outputs) { + if (output.address === demoBtcAddress) { + const utxoKey = `${tx.transactionHash}:${output.index}`; + knownUtxos.set(utxoKey, { + transactionHash: tx.transactionHash, + index: output.index, + witness: { + script: output.script, + value: output.value, + }, + }); + } + } +} + +/** + * Helper to get a UTXO to spend + */ +function getUTXOToSpend(knownUtxos: Map) { + if (knownUtxos.size === 0) return null; + const utxo = knownUtxos.values().next().value; + return { + txHash: utxo.transactionHash, + index: utxo.index, + value: utxo.witness.value, + }; +} + +/** + * Generates fake transactions for both USDC and USDT on Polygon + */ +function generateFakePolygonTransactions() { + // Generate USDC transactions + const usdcTxDefinitions = [ + { fraction: 0.3, daysAgo: 910, incoming: true, label: 'Yield Farming Protocol' }, + { fraction: -0.05, daysAgo: 840 }, + { fraction: 0.2, daysAgo: 730, incoming: true, label: 'Virtual Worlds Inc.' }, + { fraction: -0.1, daysAgo: 650 }, + { fraction: 0.4, daysAgo: 480, incoming: true, label: 'Innovation DAO' }, + { fraction: -0.15, daysAgo: 360, label: 'MetaGames Marketplace' }, + { fraction: 0.15, daysAgo: 180, incoming: true }, + { fraction: -0.08, daysAgo: 90 }, + ]; + + // Calculate sum of existing transactions and add balancing transaction + const usdcExistingSum = usdcTxDefinitions.reduce((sum, def) => + sum + (def.incoming ? Math.abs(def.fraction) : -Math.abs(def.fraction)), 0); + const usdcRemainingFraction = 1 - usdcExistingSum; + + // Add balancing transaction + if (Math.abs(usdcRemainingFraction) > 0.001) { + usdcTxDefinitions.push({ + fraction: usdcRemainingFraction, + daysAgo: 30, + incoming: usdcRemainingFraction > 0, + label: usdcRemainingFraction > 0 ? 'CryptoCreators Guild' : 'Annual Platform Subscription', + }); + } + + // Generate USDT transactions + const usdtTxDefinitions = [ + { fraction: 0.4, daysAgo: 360, incoming: true, label: 'LaunchPad Exchange' }, + { fraction: -0.1, daysAgo: 320 }, + { fraction: 0.2, daysAgo: 280, incoming: true, label: 'Crypto Consultants LLC' }, + { fraction: -0.15, daysAgo: 210 }, + { fraction: 0.3, daysAgo: 150, incoming: true }, + { fraction: -0.05, daysAgo: 90 }, + { fraction: 0.1, daysAgo: 45, incoming: true, label: 'Chain Testers' }, + { fraction: -0.12, daysAgo: 20 }, + ]; + + // Calculate sum of existing transactions and add balancing transaction + const usdtExistingSum = usdtTxDefinitions.reduce((sum, def) => + sum + (def.incoming ? Math.abs(def.fraction) : -Math.abs(def.fraction)), 0); + const usdtRemainingFraction = 1 - usdtExistingSum; + + // Add balancing transaction + if (Math.abs(usdtRemainingFraction) > 0.001) { + usdtTxDefinitions.push({ + fraction: usdtRemainingFraction, + daysAgo: 7, + incoming: usdtRemainingFraction > 0, + label: usdtRemainingFraction > 0 ? 'Protocol Testing Reward' : 'Annual Service Renewal', + }); + } + + // Generate and add USDC transactions + const { addTransactions: addUsdcTxs } = useUsdcTransactionsStore(); + addUsdcTxs(generateTokenTransactions(usdcTxDefinitions, usdcInitialBalance, Config.polygon.usdc.tokenContract, UsdcTransactionState.CONFIRMED, useUsdcContactsStore)); + + // Generate and add USDT transactions + const { addTransactions: addUsdtTxs } = useUsdtTransactionsStore(); + addUsdtTxs(generateTokenTransactions(usdtTxDefinitions, usdtInitialBalance, Config.polygon.usdt_bridged.tokenContract, UsdtTransactionState.CONFIRMED, useUsdtContactsStore)); +} + +/** + * Shared function to generate token transactions for Polygon tokens + */ +function generateTokenTransactions(txDefinitions: any, initialBalance:number, tokenContract:string, confirmState:any, contactsStore:any) { + const transactions = []; + const { setContact } = contactsStore(); + + for (const def of txDefinitions) { + const value = Math.floor(initialBalance * Math.abs(def.fraction)); + const randomAddress = `0x${Math.random().toString(16).slice(2, 42)}`; + const sender = def.incoming ? randomAddress : demoPolygonAddress; + const recipient = def.incoming ? demoPolygonAddress : randomAddress; + + // Add contacts for select transactions (only if label is provided) + if (def.label) { + const addressToLabel = def.incoming ? randomAddress : randomAddress; + setContact(addressToLabel, def.label); + } + + transactions.push({ + token: tokenContract, + transactionHash: `token-tx-${Math.random().toString(36).substr(2, 9)}`, + logIndex: transactions.length, + sender, + recipient, + value, + state: confirmState, + blockHeight: 1000000 + transactions.length, + timestamp: toSecs(calculateDaysAgo(def.daysAgo)), + }); + } + + return transactions; +} + +enum MessageEventName { + FlowChange = 'FlowChange' +} + +/** + * Listens for messages from iframes (or parent frames) about changes in the user flow. + */ +function attachIframeListeners() { + window.addEventListener('message', (event) => { + if (!event.data || typeof event.data !== 'object') return; + const { kind, data } = event.data as DemoFlowMessage; + if (kind === MessageEventName.FlowChange && demoRoutes[data]) { + useAccountStore().setActiveCurrency(CryptoCurrency.NIM); + demoRouter.push(demoRoutes[data]); + } + }); + + demoRouter.afterEach((to) => { + const match = Object.entries(demoRoutes).find(([, route]) => route === to.path); + if (!match) return; + window.parent.postMessage({ kind: MessageEventName.FlowChange, data: match[0] as DemoFlowType }, '*'); + }); +} + +/** + * Observes the staking modal and prevents from validating the info and instead fakes the staking process. + */ +function replaceBuyNimFlow() { + const targetSelector = '.sidebar .trade-actions'; + let observing = false; + const observer = new MutationObserver(() => { + if (observing) return; + + const target = document.querySelector(targetSelector); + if (!target) return; + observing = true; + + target.innerHTML = ''; + + const btn1 = document.createElement('button'); + btn1.className = 'nq-button-s inverse'; + btn1.style.flex = '1'; + btn1.addEventListener('click', () => { + demoRouter.push('/buy'); + }); + btn1.innerHTML = 'Buy'; + const btn2 = document.createElement('button'); + btn2.className = 'nq-button-s inverse'; + btn2.style.flex = '1'; + btn2.disabled = true; + btn2.innerHTML = 'Sell'; + target.appendChild(btn1); + target.appendChild(btn2); + }); + + demoRouter.afterEach((to) => { + if (to.path.startsWith('/')) { + observer.observe(document.body, { childList: true, subtree: true }); + } else { + observing = false; + observer.disconnect(); + } + }); +} + +/** + * Observes the staking modal and prevents from validating the info and instead fakes the staking process. + */ +function replaceStakingFlow() { + const targetSelector = '.stake-graph-page .stake-button'; + let observing = false; + const observer = new MutationObserver(() => { + if (observing) return; + + const target = document.querySelector(targetSelector); + if (!target) return; + + // remove previous listeners by cloning the element and replacing the original + const newElement = target.cloneNode(true) as HTMLButtonElement; + target.parentNode!.replaceChild(newElement, target); + newElement.removeAttribute('disabled'); + observing = true; + + newElement.addEventListener('click', async () => { + const { setStake } = useStakingStore(); + const { activeValidator } = useStakingStore(); + const amountInput = document.querySelector('.nq-input') as HTMLInputElement; + const amount = Number.parseFloat(amountInput.value.replaceAll(' ', '')) * 1e5; + + const { address: validatorAddress } = activeValidator.value!; + demoRouter.push('/'); + await new Promise((resolve) => { window.setTimeout(resolve, 100); }); + setStake({ + activeBalance: amount, + inactiveBalance: nimInitialBalance - amount, + address: demoNimAddress, + retiredBalance: 0, + validator: validatorAddress, + }); + const { addTransactions } = useTransactionsStore(); + addTransactions([ + createFakeTransaction({ + value: amount, + recipient: STAKING_CONTRACT_ADDRESS, + sender: demoNimAddress, + data: { + type: 'add-stake', + raw: '', + staker: demoNimAddress, + }, + }), + ]); + }); + }); + + demoRouter.afterEach((to) => { + if (to.path === '/staking') { + observer.observe(document.body, { childList: true, subtree: true }); + } else { + observing = false; + observer.disconnect(); + } + }); +} + +/** + * Creates a fake transaction. Each call increments a global counter for the hash and block heights. + */ +let txCounter = 0; +let currentHead = 0; +function createFakeTransaction(tx: Partial) { + return { + network: 'mainnet', + state: 'confirmed', + transactionHash: `0x${(txCounter++).toString(16)}`, + value: 50000000, + recipient: '', + fee: 0, + feePerByte: 0, + format: 'basic', + sender: '', + senderType: 'basic', + recipientType: 'basic', + validityStartHeight: currentHead++, + blockHeight: currentHead++, + flags: 0, + timestamp: Date.now(), + proof: { type: 'raw', raw: '' }, + size: 0, + valid: true, + ...tx, + } as PlainTransactionDetails; +} + +/** + * Returns the hex encoding of a UTF-8 string. + */ +function encodeTextToHex(text: string): string { + const utf8Array = Utf8Tools.stringToUtf8ByteArray(text); + return Array.from(utf8Array) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +// We pick a random but fixed time-of-day offset for each day. +const baseDate = new Date(); +baseDate.setHours(0, 0, 0, 0); +const baseDateMs = baseDate.getTime(); +const oneDayMs = 24 * 60 * 60 * 1000; + +/** + * Generates a past timestamp for a given number of days ago, adding a predictable random offset. + */ +function calculateDaysAgo(days: number): number { + const x = Math.sin(days) * 10000; + const fractionalPart = x - Math.floor(x); + const randomPart = Math.floor(fractionalPart * oneDayMs); + return baseDateMs - days * oneDayMs - randomPart; +} + +/** + * Flow type for our demo environment, e.g. buy, swap, stake. + */ +type DemoFlowType = 'buy' | 'swap' | 'stake'; + +/** + * The expected message structure for flow-change events between frames. + */ +type DemoFlowMessage = { kind: 'FlowChange', data: DemoFlowType }; + +/** + * Maps each flow type to a specific route path in our app. + */ +const demoRoutes: Record = { + buy: '/buy', + swap: '/swap/NIM-BTC', + stake: '/staking', +}; + +/** + * CSS for the special demo elements, stored in a normal string (no backticks). + */ +const demoCSS = ` +.transaction-list .month-label > :where(.fetching, .failed-to-fetch) { + display: none; +} + +#app > div > .wallet-status-button.nq-button-pill { + display: none; +} + +.staking-button .tooltip.staking-feature-tip { + display: none; +} + +.modal.transaction-modal .confirmed .tooltip.info-tooltip { + display: none; +} + +.send-modal-footer .footer-notice { + display: none; +} + +.demo-highlight-badge { + position: absolute; + width: 34px; + height: 34px; + z-index: 5; + pointer-events: none; +} + +.demo-highlight-badge > div { + position: relative; + width: 100%; + height: 100%; + background: rgba(31, 35, 72, 0.1); + border: 1.5px solid rgba(255, 255, 255, 0.5); + border-radius: 50%; + backdrop-filter: blur(3px); +} + +.demo-highlight-badge > div::before { + content: ""; + position: absolute; + inset: 5px; + background: rgba(31, 35, 72, 0.3); + border: 2px solid rgba(255, 255, 255, 0.2); + box-shadow: 0px 0px 30px rgba(0, 0, 0, 0.3); + backdrop-filter: blur(3px); + border-radius: 12px; +} + +.demo-highlight-badge > div::after { + content: ""; + position: absolute; + inset: 11.6px; + background: rgba(255, 255, 255); + border-radius: 50%; +} +`; + +/** + * Intercepts fetch request: + * - fastspot.apiEndpoint will return a mock limit response + * - estimate endpoint will return mock estimate response + * - the rest of requests will be passed through + */ +function interceptFetchRequest() { + const originalFetch = window.fetch; + window.fetch = async (...args: Parameters) => { + if (typeof args[0] !== 'string') return originalFetch(...args); + if (args[0].startsWith('/')) return originalFetch(...args); + + const url = new URL(args[0] as string); + const isFastspotRequest = url.host === Config.fastspot.apiEndpoint; + const isLimitsRequest = url.pathname.includes('/limits'); + const isEstimateRequest = url.pathname.includes('/estimate'); + const isAssetsRequest = url.pathname.includes('/assets'); + + if (!isFastspotRequest) { + return originalFetch(...args); + } + + if (isLimitsRequest) { + // Return mock limits with higher values and proper structure + const json = { + asset: 'USD', + daily: '50000', + dailyRemaining: '49000', + monthly: '100000', + monthlyRemaining: '98000', + swap: '10000', + current: '9800', + }; + return new Response(JSON.stringify(json)); + } + + if (isAssetsRequest) { + // Return mock assets data with fees + const json = { + BTC: { + enabled: true, + fee: 0.0001, // BTC network fee + minAmount: 0.001, + maxAmount: 1, + }, + NIM: { + enabled: true, + fee: 0.01, // NIM network fee + minAmount: 1, + maxAmount: 1000000, + }, + USDC_MATIC: { + enabled: true, + fee: 0.1, // USDC fee + minAmount: 1, + maxAmount: 50000, + }, + USDT_MATIC: { + enabled: true, + fee: 0.1, // USDT fee + minAmount: 1, + maxAmount: 50000, + }, + }; + return new Response(JSON.stringify(json)); + } + + if (isEstimateRequest) { + // Parse the estimate request parameters + const searchParams = new URLSearchParams(url.search); + const from = searchParams.get('from'); + const to = searchParams.get('to'); + const value = Number(searchParams.get('value')); + + // Validate the request + if (!from || !to || !value || Number.isNaN(value)) { + return new Response(JSON.stringify({ + error: 'Invalid request parameters', + status: 400, + }), { status: 400 }); + } + + // Calculate fees based on the value + const networkFee = Math.floor(value * 0.0005); // 0.05% network fee + const escrowFee = Math.floor(value * 0.001); // 0.1% escrow fee + + // Calculate the destination amount using our mock rates + const estimatedAmount = calculateEstimatedAmount(from, to, value); + + // Note: These fee calculations are simplified for demo purposes + const toNetworkFee = Math.floor(estimatedAmount * 0.0005); + const toEscrowFee = Math.floor(estimatedAmount * 0.001); + + // Create mock estimate response with a valid structure + const estimate = { + from: { + asset: from, + amount: value, + fee: 0, // Network-specific fee (e.g., BTC mining fee) + serviceNetworkFee: networkFee, + serviceEscrowFee: escrowFee, + }, + to: { + asset: to, + amount: estimatedAmount, + fee: 0, // Network-specific fee + serviceNetworkFee: toNetworkFee, + serviceEscrowFee: toEscrowFee, + }, + serviceFeePercentage: 0.025, // 2.5% service fee + expires: Date.now() + 30000, // 30 seconds expiry + swapProfit: 0, + }; + + return new Response(JSON.stringify(estimate)); + } + + return originalFetch(...args); + }; +} + +/** + * Calculate mock estimated amount for swaps based on predefined rates + */ +function calculateEstimatedAmount(fromAsset: string, toAsset: string, value: number): number { + // Define mock exchange rates (realistic market rates) + const rates: Record = { + [`${SwapAsset.NIM}-${SwapAsset.BTC}`]: 0.00000004, // 1 NIM = 0.00000004 BTC + [`${SwapAsset.NIM}-${SwapAsset.USDC_MATIC}`]: 0.0012, // 1 NIM = 0.0012 USDC + [`${SwapAsset.NIM}-${SwapAsset.USDT_MATIC}`]: 0.0012, // 1 NIM = 0.0012 USDT + [`${SwapAsset.BTC}-${SwapAsset.NIM}`]: 25000000, // 1 BTC = 25,000,000 NIM + [`${SwapAsset.BTC}-${SwapAsset.USDC_MATIC}`]: 30000, // 1 BTC = 30,000 USDC + [`${SwapAsset.BTC}-${SwapAsset.USDT_MATIC}`]: 30000, // 1 BTC = 30,000 USDT + [`${SwapAsset.USDC_MATIC}-${SwapAsset.NIM}`]: 833.33, // 1 USDC = 833.33 NIM + [`${SwapAsset.USDC_MATIC}-${SwapAsset.BTC}`]: 0.000033, // 1 USDC = 0.000033 BTC + [`${SwapAsset.USDC_MATIC}-${SwapAsset.USDT_MATIC}`]: 1, // 1 USDC = 1 USDT + [`${SwapAsset.USDT_MATIC}-${SwapAsset.NIM}`]: 833.33, // 1 USDT = 833.33 NIM + [`${SwapAsset.USDT_MATIC}-${SwapAsset.BTC}`]: 0.000033, // 1 USDT = 0.000033 BTC + [`${SwapAsset.USDT_MATIC}-${SwapAsset.USDC_MATIC}`]: 1, // 1 USDT = 1 USDC + }; + + const rate = rates[`${fromAsset}-${toAsset}`]; + if (!rate) { + // If no direct rate, check reverse rate + const reverseRate = rates[`${toAsset}-${fromAsset}`]; + if (reverseRate) { + return Math.floor(value * (1 / reverseRate)); + } + return value; // 1:1 if no rate defined + } + + return Math.floor(value * rate); +} + +let swapCounter = 0; + +/** + * Replacement of the Hub API class to capture and redirect calls to our demo modals instead. + */ +export class DemoHubApi extends HubApi { + static create(): DemoHubApi { + const instance = new DemoHubApi(); + return new Proxy(instance, { + get(target, prop: keyof HubApi) { + if (typeof target[prop] === 'function') { + return async (...args: Parameters) => { + const requestName = String(prop); + const [firstArg] = args; + // eslint-disable-next-line no-console + console.warn(`[Demo] Mocking Hub call: ${requestName}("${firstArg}")`); + + if (requestName === 'on') { + return; + } + if (requestName === 'setupSwap') { + const leftColumn = document.querySelector('.swap-amounts .left-column'); + const leftAmountElement = leftColumn!.querySelector('.width-value'); + const leftValue = Number((leftAmountElement as HTMLDivElement).innerText); + const leftAsset = leftColumn!.querySelector('.ticker')!.innerHTML.toUpperCase() as SwapAsset; + + const rightColumn = document.querySelector('.swap-amounts .right-column'); + const rightAmountElement = rightColumn!.querySelector('.width-value'); + const rightValue = Number((rightAmountElement as HTMLDivElement).innerText); + const rightAsset = rightColumn!.querySelector('.ticker')!.innerHTML.toUpperCase() as SwapAsset; + + const swapHash = `0x${(++swapCounter).toString(16)}`; + const { setActiveSwap, setSwap } = useSwapsStore(); + + // Create initial swap state + const swap = { + id: `${swapCounter}`, + contracts: {}, + from: { + asset: leftAsset, + amount: leftValue, + fee: 0, + serviceEscrowFee: Math.floor(leftValue * 0.001), // 0.1% fee + serviceNetworkFee: Math.floor(leftValue * 0.0005), // 0.05% network fee + }, + to: { + asset: rightAsset, + amount: rightValue, + fee: 0, + serviceNetworkFee: Math.floor(rightValue * 0.0005), + serviceEscrowFee: Math.floor(rightValue * 0.001), + }, + serviceFeePercentage: 0.025, + direction: 'forward', + state: SwapState.AWAIT_INCOMING, + stateEnteredAt: Date.now(), + expires: Date.now() + 1000 * 60 * 60 * 24, // 24h expiry + status: SwapStatus.WAITING_FOR_TRANSACTIONS, + hash: swapHash, + watchtowerNotified: true, + }; + + setSwap(swapHash, { + id: swap.id, + fees: { + totalFee: swap.from.serviceEscrowFee + swap.from.serviceNetworkFee, + asset: leftAsset, + }, + }); + setActiveSwap(swap); + + // Simulate swap progression + setTimeout(() => { + // Update to processing state after 2s + swap.state = SwapState.SETTLE_SWAP; + swap.stateEnteredAt = Date.now(); + setActiveSwap(swap); + + // Create transactions for both sides + setTimeout(() => { + const { addTransactions: addNimTx } = useTransactionsStore(); + const { addTransactions: addBtcTx } = useBtcTransactionsStore(); + const { addTransactions: addUsdcTx } = useUsdcTransactionsStore(); + const { addTransactions: addUsdtTx } = useUsdtTransactionsStore(); + + // Create appropriate transactions based on assets + if (leftAsset === SwapAsset.NIM) { + addNimTx([createFakeTransaction({ + value: leftValue, + sender: demoNimAddress, + recipient: `0x${Math.random().toString(16).slice(2)}`, + data: { + type: 'raw', + raw: encodeTextToHex(`Swap: NIM to ${rightAsset}`), + }, + })]); + } + if (rightAsset === SwapAsset.NIM) { + addNimTx([createFakeTransaction({ + value: rightValue, + recipient: demoNimAddress, + sender: `0x${Math.random().toString(16).slice(2)}`, + data: { + type: 'raw', + raw: encodeTextToHex(`Swap: ${leftAsset} to NIM`), + }, + })]); + } + + // Handle BTC transactions + if (leftAsset === SwapAsset.BTC || rightAsset === SwapAsset.BTC) { + const isSending = leftAsset === SwapAsset.BTC; + const btcValue = isSending ? leftValue : rightValue; + addBtcTx([{ + isCoinbase: false, + transactionHash: `btc-swap-${swapCounter}`, + inputs: [{ + address: isSending ? demoBtcAddress : `1${Math.random().toString(36).slice(2)}`, + outputIndex: 0, + index: 0, + script: 'swap', + sequence: 4294967295, + transactionHash: `prev-${swapCounter}`, + witness: ['swap'], + }], + outputs: [{ + value: btcValue, + address: isSending ? `1${Math.random().toString(36).slice(2)}` : demoBtcAddress, + script: 'swap', + index: 0, + }], + version: 1, + vsize: 200, + weight: 800, + locktime: 0, + confirmations: 1, + replaceByFee: false, + timestamp: Math.floor(Date.now() / 1000), + state: ElectrumTransactionState.CONFIRMED, + }]); + } + + // Handle USDC transactions + if (leftAsset === SwapAsset.USDC_MATIC || rightAsset === SwapAsset.USDC_MATIC) { + const isSending = leftAsset === SwapAsset.USDC_MATIC; + const value = isSending ? leftValue : rightValue; + addUsdcTx([{ + token: Config.polygon.usdc.tokenContract, + transactionHash: `usdc-swap-${swapCounter}`, + logIndex: 0, + sender: isSending ? demoPolygonAddress : `0x${Math.random().toString(16).slice(2)}`, + recipient: isSending ? `0x${Math.random().toString(16).slice(2)}` : demoPolygonAddress, + value, + state: UsdcTransactionState.CONFIRMED, + blockHeight: currentHead++, + timestamp: Math.floor(Date.now() / 1000), + }]); + } + + // Handle USDT transactions + if (leftAsset === SwapAsset.USDT_MATIC || rightAsset === SwapAsset.USDT_MATIC) { + const isSending = leftAsset === SwapAsset.USDT_MATIC; + const value = isSending ? leftValue : rightValue; + addUsdtTx([{ + token: Config.polygon.usdt_bridged.tokenContract, + transactionHash: `usdt-swap-${swapCounter}`, + logIndex: 0, + sender: isSending ? demoPolygonAddress : `0x${Math.random().toString(16).slice(2)}`, + recipient: isSending ? `0x${Math.random().toString(16).slice(2)}` : demoPolygonAddress, + value, + state: UsdtTransactionState.CONFIRMED, + blockHeight: currentHead++, + timestamp: Math.floor(Date.now() / 1000), + }]); + } + + // Complete the swap + swap.state = SwapState.COMPLETED; + swap.stateEnteredAt = Date.now(); + swap.status = SwapStatus.SUCCESS; + setActiveSwap(swap); + }, 3000); // Complete after 3 more seconds + }, 2000); // Start processing after 2s + + return; + } + + // Wait for router readiness + await new Promise((resolve) => { + demoRouter.onReady(resolve); + }); + + // eslint-disable-next-line no-console + console.log('[Demo] Redirecting to fallback modal'); + demoRouter.push(`/${DemoModal.Fallback}`); + }; + } + return target[prop]; + }, + }); + } +} From 4abf23881e151bacb5b28055b3eecde217dc3955 Mon Sep 17 00:00:00 2001 From: onmax Date: Wed, 5 Mar 2025 07:27:58 +0100 Subject: [PATCH 02/31] docs(demo): update README to clarify demo mode usage and Nimiq Hub requirements --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fe22f89dd..8a4297388 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ yarn install yarn serve ``` -> You will also need to run the [Nimiq Hub](https://github.com/nimiq/hub#contribute) and [Nimiq Keyguard](https://github.com/nimiq/keyguard/#development) in development, too. +If you need to interact with a real account and data, then you will also need to run the [Nimiq Hub](https://github.com/nimiq/hub#contribute) and [Nimiq Keyguard](https://github.com/nimiq/keyguard/#development) in development, too. + +Otherwise, you can use activate the `demo` mode by appending `?demo` in the URL: `http://localhost:8081/?demo`. ### Compiles and minifies for production ``` From a757134e05a7f9e6ecca04e06fc20b91667bc9b3 Mon Sep 17 00:00:00 2001 From: onmax Date: Wed, 5 Mar 2025 12:34:11 +0100 Subject: [PATCH 03/31] chore(demo): intercept and return swap api results --- src/stores/Demo.ts | 996 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 748 insertions(+), 248 deletions(-) diff --git a/src/stores/Demo.ts b/src/stores/Demo.ts index c9c609184..21994c3dd 100644 --- a/src/stores/Demo.ts +++ b/src/stores/Demo.ts @@ -2,7 +2,6 @@ import { createStore } from 'pinia'; import VueRouter from 'vue-router'; import { TransactionState as ElectrumTransactionState } from '@nimiq/electrum-client'; -import HubApi from '@nimiq/hub-api'; import { CryptoCurrency, Utf8Tools } from '@nimiq/utils'; import { KeyPair, PlainTransactionDetails, PrivateKey } from '@nimiq/core'; import { STAKING_CONTRACT_ADDRESS } from '@/lib/Constants'; @@ -16,13 +15,14 @@ import { useStakingStore } from '@/stores/Staking'; import { useAccountSettingsStore } from '@/stores/AccountSettings'; import { usePolygonAddressStore } from '@/stores/PolygonAddress'; import Config from 'config'; -import { SwapAsset, SwapStatus } from '@nimiq/fastspot-api'; -import { SwapState, useSwapsStore } from '@/stores/Swaps'; +import { AssetList, FastspotAsset, FastspotEstimate, FastspotFee, FastspotLimits, FastspotUserLimits, ReferenceAsset, SwapAsset } from '@nimiq/fastspot-api'; import { useBtcAddressStore } from './BtcAddress'; import { useContactsStore } from './Contacts'; import { useBtcLabelsStore } from './BtcLabels'; import { useUsdcContactsStore } from './UsdcContacts'; import { useUsdtContactsStore } from './UsdtContacts'; +import { useFiatStore } from './Fiat'; +import HubApi from '@nimiq/hub-api'; export type DemoState = { active: boolean, @@ -101,6 +101,7 @@ export const useDemoStore = createStore({ attachIframeListeners(); replaceStakingFlow(); replaceBuyNimFlow(); + enableSendModalInDemoMode(); }, /** @@ -591,7 +592,7 @@ function generateFakePolygonTransactions() { /** * Shared function to generate token transactions for Polygon tokens */ -function generateTokenTransactions(txDefinitions: any, initialBalance:number, tokenContract:string, confirmState:any, contactsStore:any) { +function generateTokenTransactions(txDefinitions: any, initialBalance: number, tokenContract: string, confirmState: any, contactsStore: any) { const transactions = []; const { setContact } = contactsStore(); @@ -882,6 +883,10 @@ const demoCSS = ` background: rgba(255, 255, 255); border-radius: 50%; } + +.send-modal-footer .footer-notice { + display: none; +} `; /** @@ -897,7 +902,7 @@ function interceptFetchRequest() { if (args[0].startsWith('/')) return originalFetch(...args); const url = new URL(args[0] as string); - const isFastspotRequest = url.host === Config.fastspot.apiEndpoint; + const isFastspotRequest = url.host === (new URL(Config.fastspot.apiEndpoint).host); const isLimitsRequest = url.pathname.includes('/limits'); const isEstimateRequest = url.pathname.includes('/estimate'); const isAssetsRequest = url.pathname.includes('/assets'); @@ -906,60 +911,92 @@ function interceptFetchRequest() { return originalFetch(...args); } - if (isLimitsRequest) { - // Return mock limits with higher values and proper structure - const json = { - asset: 'USD', + if (isLimitsRequest) { + const constants = { + current: '9800', daily: '50000', dailyRemaining: '49000', monthly: '100000', monthlyRemaining: '98000', - swap: '10000', - current: '9800', + } as const; + + const [assetOrLimit,_] = url.pathname.split('/').slice(-2) as [SwapAsset | 'limits', string]; + + if (assetOrLimit === 'limits') { + const limits: FastspotUserLimits = { + asset: ReferenceAsset.USD, + swap: `${1000}`, + ...constants, + }; + return new Response(JSON.stringify(limits)); + } + + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + await sleep(1000 + Math.random() * 500); + + const asset = assetOrLimit as SwapAsset; + // const { exchangeRates } = useFiatStore(); + // const rate: number = exchangeRates.value[asset.toLocaleLowerCase().split('_')[0]].usd!; + const json: FastspotLimits = { + asset, + swap: `${1000}`, + referenceAsset: ReferenceAsset.USD, + referenceCurrent: constants.current, + referenceDaily: constants.daily, + referenceDailyRemaining: constants.dailyRemaining, + referenceMonthly: constants.monthly, + referenceMonthlyRemaining: constants.monthlyRemaining, + referenceSwap: `${1000}`, + ...constants, }; + return new Response(JSON.stringify(json)); } if (isAssetsRequest) { - // Return mock assets data with fees - const json = { - BTC: { - enabled: true, - fee: 0.0001, // BTC network fee - minAmount: 0.001, - maxAmount: 1, - }, - NIM: { - enabled: true, - fee: 0.01, // NIM network fee - minAmount: 1, - maxAmount: 1000000, + // Return mock assets data with fees for all supported assets + const json: FastspotAsset[] = [ + { + symbol: SwapAsset.BTC, + name: 'Bitcoin', + feePerUnit: `${getNetworkFeePerUnit(SwapAsset.BTC)}`, + limits: { minimum: '0.0001', maximum: '1' }, }, - USDC_MATIC: { - enabled: true, - fee: 0.1, // USDC fee - minAmount: 1, - maxAmount: 50000, + { + symbol: SwapAsset.NIM, + name: 'Nimiq', + feePerUnit: `${getNetworkFeePerUnit(SwapAsset.NIM)}`, + limits: { minimum: '1', maximum: '100000' }, }, - USDT_MATIC: { - enabled: true, - fee: 0.1, // USDT fee - minAmount: 1, - maxAmount: 50000, + { + symbol: SwapAsset.USDC_MATIC, + name: 'USDC (Polygon)', + feePerUnit: `${getNetworkFeePerUnit(SwapAsset.USDC_MATIC)}`, + limits: { minimum: '1', maximum: '100000' }, }, - }; + { + symbol: SwapAsset.USDT_MATIC, + name: 'USDT (Polygon)', + feePerUnit: `${getNetworkFeePerUnit(SwapAsset.USDT_MATIC)}`, + limits: { minimum: '1', maximum: '100000' }, + } + ] return new Response(JSON.stringify(json)); } if (isEstimateRequest) { - // Parse the estimate request parameters - const searchParams = new URLSearchParams(url.search); - const from = searchParams.get('from'); - const to = searchParams.get('to'); - const value = Number(searchParams.get('value')); + const body = args[1]?.body + if (!body) throw new Error('[Demo] No body found in request') + type EstimateRequest = { + from: Record, + to: SwapAsset, + includedFees: 'required' + }; + const { from: fromObj, to, includedFees: _includedFees } = JSON.parse(body.toString()) as EstimateRequest + const from = Object.keys(fromObj)[0] as SwapAsset // Validate the request - if (!from || !to || !value || Number.isNaN(value)) { + if (!from || !to) { return new Response(JSON.stringify({ error: 'Invalid request parameters', status: 400, @@ -967,36 +1004,61 @@ function interceptFetchRequest() { } // Calculate fees based on the value + + const value = fromObj[from]; const networkFee = Math.floor(value * 0.0005); // 0.05% network fee const escrowFee = Math.floor(value * 0.001); // 0.1% escrow fee // Calculate the destination amount using our mock rates const estimatedAmount = calculateEstimatedAmount(from, to, value); - // Note: These fee calculations are simplified for demo purposes - const toNetworkFee = Math.floor(estimatedAmount * 0.0005); - const toEscrowFee = Math.floor(estimatedAmount * 0.001); - // Create mock estimate response with a valid structure - const estimate = { - from: { - asset: from, - amount: value, - fee: 0, // Network-specific fee (e.g., BTC mining fee) - serviceNetworkFee: networkFee, - serviceEscrowFee: escrowFee, - }, - to: { - asset: to, - amount: estimatedAmount, - fee: 0, // Network-specific fee - serviceNetworkFee: toNetworkFee, - serviceEscrowFee: toEscrowFee, - }, - serviceFeePercentage: 0.025, // 2.5% service fee - expires: Date.now() + 30000, // 30 seconds expiry - swapProfit: 0, - }; + const estimate: FastspotEstimate[] = [{ + from: [{ + amount: `${value}`, + name: from, + symbol: from, + finalizeNetworkFee: { + total: `${networkFee}`, + totalIsIncluded: false, + perUnit: `${getNetworkFeePerUnit(from)}`, + }, + fundingNetworkFee: { + total: `${networkFee}`, + totalIsIncluded: false, + perUnit: `${getNetworkFeePerUnit(from)}`, + }, + operatingNetworkFee: { + total: `${networkFee}`, + totalIsIncluded: false, + perUnit: `${getNetworkFeePerUnit(from)}`, + } + }], + to: [{ + amount: `${estimatedAmount}`, + name: to, + symbol: to, + finalizeNetworkFee: { + total: `${networkFee}`, + totalIsIncluded: false, + perUnit: `${getNetworkFeePerUnit(to)}`, + }, + fundingNetworkFee: { + total: `${networkFee}`, + totalIsIncluded: false, + perUnit: `${getNetworkFeePerUnit(to)}`, + }, + operatingNetworkFee: { + total: `${networkFee}`, + totalIsIncluded: false, + perUnit: `${getNetworkFeePerUnit(to)}`, + } + }], + direction: 'forward', + serviceFeePercentage: 0.01, + }]; + + console.log('[Demo] Mock estimate:', estimate); // eslint-disable no-console return new Response(JSON.stringify(estimate)); } @@ -1005,6 +1067,40 @@ function interceptFetchRequest() { }; } +/** + * Network fee helper function + */ +function getNetworkFee(asset: string): number { + switch (asset) { + case SwapAsset.BTC: + return 10000; // 10k sats + case SwapAsset.NIM: + return 1000; // 1000 luna + case SwapAsset.USDC_MATIC: + case SwapAsset.USDT_MATIC: + return 2000000000000000; // 0.002 MATIC in wei + default: + return 0; + } +} + +/** + * Fee per unit helper function + */ +function getNetworkFeePerUnit(asset: string): number { + switch (asset) { + case SwapAsset.BTC: + return 10; // 10 sats/vbyte + case SwapAsset.NIM: + return 0; // luna per byte + case SwapAsset.USDC_MATIC: + case SwapAsset.USDT_MATIC: + return 1000000000; // 1 Gwei + default: + return 0; + } +} + /** * Calculate mock estimated amount for swaps based on predefined rates */ @@ -1022,7 +1118,7 @@ function calculateEstimatedAmount(fromAsset: string, toAsset: string, value: num [`${SwapAsset.USDC_MATIC}-${SwapAsset.USDT_MATIC}`]: 1, // 1 USDC = 1 USDT [`${SwapAsset.USDT_MATIC}-${SwapAsset.NIM}`]: 833.33, // 1 USDT = 833.33 NIM [`${SwapAsset.USDT_MATIC}-${SwapAsset.BTC}`]: 0.000033, // 1 USDT = 0.000033 BTC - [`${SwapAsset.USDT_MATIC}-${SwapAsset.USDC_MATIC}`]: 1, // 1 USDT = 1 USDC + [`${SwapAsset.USDT_MATIC}-${SwapAsset.USDC_MATIC}`]: 1, // 1 USDT = 1 USDT }; const rate = rates[`${fromAsset}-${toAsset}`]; @@ -1038,7 +1134,44 @@ function calculateEstimatedAmount(fromAsset: string, toAsset: string, value: num return Math.floor(value * rate); } -let swapCounter = 0; +/** + * Ensures the Send button in modals is always enabled in demo mode, regardless of network state. + * This allows users to interact with the send functionality without waiting for network sync. + */ +function enableSendModalInDemoMode() { + let observing = false; + const observer = new MutationObserver(() => { + // Target the send modal footer button + const sendButton = document.querySelector('.send-modal-footer .nq-button'); + if (!sendButton) return; + + if (sendButton.hasAttribute('disabled')) { + sendButton.removeAttribute('disabled'); + + // Also remove any visual indications of being disabled + sendButton.classList.remove('disabled'); + + // Also find and hide any sync message if shown + const footer = document.querySelector('.send-modal-footer'); + if (footer) { + const footerNotice = footer.querySelector('.footer-notice') as HTMLDivElement; + if (footerNotice && footerNotice.textContent && + (footerNotice.textContent.includes('Connecting to Bitcoin') || + footerNotice.textContent.includes('Syncing with Bitcoin'))) { + footerNotice.style.display = 'none'; + } + } + } + }); + + // Observe document for changes - particularly when modals open + observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled'] }); +} + +const ignoreHubRequests = [ + 'addBtcAddresses', + 'on' +] /** * Replacement of the Hub API class to capture and redirect calls to our demo modals instead. @@ -1050,190 +1183,557 @@ export class DemoHubApi extends HubApi { get(target, prop: keyof HubApi) { if (typeof target[prop] === 'function') { return async (...args: Parameters) => { - const requestName = String(prop); - const [firstArg] = args; - // eslint-disable-next-line no-console - console.warn(`[Demo] Mocking Hub call: ${requestName}("${firstArg}")`); - - if (requestName === 'on') { - return; - } - if (requestName === 'setupSwap') { - const leftColumn = document.querySelector('.swap-amounts .left-column'); - const leftAmountElement = leftColumn!.querySelector('.width-value'); - const leftValue = Number((leftAmountElement as HTMLDivElement).innerText); - const leftAsset = leftColumn!.querySelector('.ticker')!.innerHTML.toUpperCase() as SwapAsset; - - const rightColumn = document.querySelector('.swap-amounts .right-column'); - const rightAmountElement = rightColumn!.querySelector('.width-value'); - const rightValue = Number((rightAmountElement as HTMLDivElement).innerText); - const rightAsset = rightColumn!.querySelector('.ticker')!.innerHTML.toUpperCase() as SwapAsset; - - const swapHash = `0x${(++swapCounter).toString(16)}`; - const { setActiveSwap, setSwap } = useSwapsStore(); - - // Create initial swap state - const swap = { - id: `${swapCounter}`, - contracts: {}, - from: { - asset: leftAsset, - amount: leftValue, - fee: 0, - serviceEscrowFee: Math.floor(leftValue * 0.001), // 0.1% fee - serviceNetworkFee: Math.floor(leftValue * 0.0005), // 0.05% network fee - }, - to: { - asset: rightAsset, - amount: rightValue, - fee: 0, - serviceNetworkFee: Math.floor(rightValue * 0.0005), - serviceEscrowFee: Math.floor(rightValue * 0.001), - }, - serviceFeePercentage: 0.025, - direction: 'forward', - state: SwapState.AWAIT_INCOMING, - stateEnteredAt: Date.now(), - expires: Date.now() + 1000 * 60 * 60 * 24, // 24h expiry - status: SwapStatus.WAITING_FOR_TRANSACTIONS, - hash: swapHash, - watchtowerNotified: true, - }; - - setSwap(swapHash, { - id: swap.id, - fees: { - totalFee: swap.from.serviceEscrowFee + swap.from.serviceNetworkFee, - asset: leftAsset, - }, + return new Promise(async (resolve, reject) => { + const requestName = String(prop); + const [firstArg] = args; + // eslint-disable-next-line no-console + console.warn(`[Demo] Mocking Hub call: ${requestName}("${firstArg}")`); + + if (ignoreHubRequests.includes(requestName)) { + return; + } + // Find the setupSwap handler in the DemoHubApi class and replace it with this: + + if (requestName === 'setupSwap') { + console.log({ + firstArg, + args, + }) + return + } + // if (requestName === 'setupSwap') { + // (firstArg as Function)(); + // // Get swap amount and asset details from the DOM + // const leftColumn = document.querySelector('.swap-amounts .left-column'); + // const leftAmountElement = leftColumn?.querySelector('.width-value'); + // const rightColumn = document.querySelector('.swap-amounts .right-column'); + // const rightAmountElement = rightColumn?.querySelector('.width-value'); + + // if (!leftAmountElement || !rightAmountElement || !leftColumn || !rightColumn) { + // console.warn('[Demo] Could not find swap amount elements'); + // return {}; + // } + + // const leftValue = Number((leftAmountElement as HTMLDivElement).innerText.replace(/,/g, '')); + // const leftAsset = leftColumn.querySelector('.ticker')?.innerHTML.toUpperCase().trim() as SwapAsset; + + // const rightValue = Number((rightAmountElement as HTMLDivElement).innerText.replace(/,/g, '')); + // const rightAsset = rightColumn.querySelector('.ticker')?.innerHTML.toUpperCase().trim() as SwapAsset; + + // console.log(`[Demo] Setting up swap: ${leftValue} ${leftAsset} -> ${rightValue} ${rightAsset}`); + + // // Check if we have valid values + // if (!leftValue || !rightValue || !leftAsset || !rightAsset) { + // console.warn('[Demo] Missing swap values'); + // return {}; + // } + + // const direction = leftValue < rightValue ? 'forward' : 'reverse'; + // const fromAsset = direction === 'forward' ? leftAsset : rightAsset; + // // @ts-expect-error Object key not specific enough? + // const toAsset: RequestAsset = direction === 'forward' + // ? { [rightAsset]: rightValue } + // : { [leftAsset]: leftValue }; + + // const swapSuggestion = await createSwap(fromAsset, toAsset); + + // const { config } = useConfig(); + + // let fund: HtlcCreationInstructions | null = null; + // let redeem: HtlcSettlementInstructions | null = null; + + // const { activeAddressInfo } = useAddressStore(); + + // const { availableExternalAddresses } = useBtcAddressStore(); + // const nimAddress = activeAddressInfo.value!.address; + // const btcAddress = availableExternalAddresses.value[0]; + + // if (swapSuggestion.from.asset === SwapAsset.NIM) { + // const nimiqClient = await getNetworkClient(); + // await nimiqClient.waitForConsensusEstablished(); + // const headHeight = await nimiqClient.getHeadHeight(); + // if (headHeight > 100) { + // useNetworkStore().state.height = headHeight; + // } else { + // throw new Error('Invalid network state, try please reloading the app'); + // } + + // fund = { + // type: SwapAsset.NIM, + // sender: nimAddress, + // value: swapSuggestion.from.amount, + // fee: swapSuggestion.from.fee, + // validityStartHeight: useNetworkStore().state.height, + // }; + // } + + // const { accountUtxos, accountBalance: accountBtcBalance, } = useBtcAddressStore(); + // if (swapSuggestion.from.asset === SwapAsset.BTC) { + // const electrumClient = await getElectrumClient(); + // await electrumClient.waitForConsensusEstablished(); + + + // // Assemble BTC inputs + // // Transactions to an HTLC are 46 weight units bigger because of the longer recipient address + // const requiredInputs = selectOutputs( + // accountUtxos.value, swapSuggestion.from.amount, swapSuggestion.from.feePerUnit, 48); + // let changeAddress: string; + // if (requiredInputs.changeAmount > 0) { + // const { nextChangeAddress } = useBtcAddressStore(); + // if (!nextChangeAddress.value) { + // // FIXME: If no unused change address is found, need to request new ones from Hub! + // throw new Error('No more unused change addresses (not yet implemented)'); + // } + // changeAddress = nextChangeAddress.value; + // } + + // fund = { + // type: SwapAsset.BTC, + // inputs: requiredInputs.utxos.map((utxo) => ({ + // address: utxo.address, + // transactionHash: utxo.transactionHash, + // outputIndex: utxo.index, + // outputScript: utxo.witness.script, + // value: utxo.witness.value, + // })), + // output: { + // value: swapSuggestion.from.amount, + // }, + // ...(requiredInputs.changeAmount > 0 ? { + // changeOutput: { + // address: changeAddress!, + // value: requiredInputs.changeAmount, + // }, + // } : {}), + // refundAddress: btcAddress, + // }; + // } + + // const { + // activeAddress: activePolygonAddress, + // accountUsdcBalance, + // accountUsdtBridgedBalance, + // } = usePolygonAddressStore(); + + + // if (swapSuggestion.from.asset === SwapAsset.USDC_MATIC) { + // const [client, htlcContract] = await Promise.all([ + // getPolygonClient(), + // getUsdcHtlcContract(), + // ]); + // const fromAddress = activePolygonAddress.value!; + + // const [ + // usdcNonce, + // forwarderNonce, + // blockHeight, + // ] = await Promise.all([ + // client.usdcToken.nonces(fromAddress) as Promise, + // htlcContract.getNonce(fromAddress) as Promise, + // getPolygonBlockNumber(), + // ]); + + // const { fee, gasLimit, gasPrice, relay, method } = { + // fee: 1, + // gasLimit: 1, + // gasPrice: 1, + // relay: { + // pctRelayFee: 1, + // baseRelayFee: 1, + // relayWorkerAddress: '0x0000000000111111111122222222223333333333', + // }, + // method: 'open' + // }; + // if (method !== 'open' && method !== 'openWithPermit') { + // throw new Error('Wrong USDC contract method'); + // } + + // // Zeroed data fields are replaced by Fastspot's proposed data (passed in from Hub) in + // // Keyguard's SwapIFrameApi. + // const data = htlcContract.interface.encodeFunctionData(method, [ + // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* address token */ config.polygon.usdc.tokenContract, + // /* uint256 amount */ swapSuggestion.from.amount, + // /* address refundAddress */ fromAddress, + // /* address recipientAddress */ '0x0000000000000000000000000000000000000000', + // /* bytes32 hash */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* uint256 timeout */ 0, + // /* uint256 fee */ fee, + // ...(method === 'openWithPermit' ? [ + // // // Approve the maximum possible amount so afterwards we can use the `open` method for + // // // lower fees + // // /* uint256 value */ client.ethers + // // .BigNumber.from('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), + // /* uint256 value */ swapSuggestion.from.amount + fee, + + // /* bytes32 sigR */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* bytes32 sigS */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* uint8 sigV */ 0, + // ] : []), + // ]); + + // const relayRequest: RelayRequest = { + // request: { + // from: fromAddress, + // to: config.polygon.usdc.htlcContract, + // data, + // value: '0', + // nonce: forwarderNonce.toString(), + // gas: gasLimit.toString(), + // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) + // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) + // }, + // relayData: { + // gasPrice: gasPrice.toString(), + // pctRelayFee: relay.pctRelayFee.toString(), + // baseRelayFee: relay.baseRelayFee.toString(), + // relayWorker: relay.relayWorkerAddress, + // paymaster: config.polygon.usdc.htlcContract, + // paymasterData: '0x', + // clientId: Math.floor(Math.random() * 1e6).toString(10), + // forwarder: config.polygon.usdc.htlcContract, + // }, + // }; + + // fund = { + // type: SwapAsset.USDC_MATIC, + // ...relayRequest, + // ...(method === 'openWithPermit' ? { + // permit: { + // tokenNonce: usdcNonce.toNumber(), + // }, + // } : null), + // }; + // } + + // if (swapSuggestion.from.asset === SwapAsset.USDT_MATIC) { + // const [client, htlcContract] = await Promise.all([ + // getPolygonClient(), + // getUsdtBridgedHtlcContract(), + // ]); + // const fromAddress = activePolygonAddress.value!; + + // const [ + // usdtNonce, + // forwarderNonce, + // blockHeight, + // ] = await Promise.all([ + // client.usdtBridgedToken.getNonce(fromAddress) as Promise, + // htlcContract.getNonce(fromAddress) as Promise, + // getPolygonBlockNumber(), + // ]); + + // const { fee, gasLimit, gasPrice, relay, method } = { + // fee: 1, + // gasLimit: 1, + // gasPrice: 1, + // relay: { + // pctRelayFee: 1, + // baseRelayFee: 1, + // relayWorkerAddress: '0x0000000000111111111122222222223333333333', + // }, + // method: 'open' + // }; + // if (method !== 'open' && method !== 'openWithApproval') { + // throw new Error('Wrong USDT contract method'); + // } + + // // Zeroed data fields are replaced by Fastspot's proposed data (passed in from Hub) in + // // Keyguard's SwapIFrameApi. + // const data = htlcContract.interface.encodeFunctionData(method, [ + // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* address token */ config.polygon.usdt_bridged.tokenContract, + // /* uint256 amount */ swapSuggestion.from.amount, + // /* address refundAddress */ fromAddress, + // /* address recipientAddress */ '0x0000000000000000000000000000000000000000', + // /* bytes32 hash */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* uint256 timeout */ 0, + // /* uint256 fee */ fee, + // ...(method === 'openWithApproval' ? [ + // // // Approve the maximum possible amount so afterwards we can use the `open` method for + // // // lower fees + // // /* uint256 approval */ client.ethers + // // .BigNumber.from('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), + // /* uint256 approval */ swapSuggestion.from.amount + fee, + + // /* bytes32 sigR */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* bytes32 sigS */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* uint8 sigV */ 0, + // ] : []), + // ]); + + // const relayRequest: RelayRequest = { + // request: { + // from: fromAddress, + // to: config.polygon.usdt_bridged.htlcContract, + // data, + // value: '0', + // nonce: forwarderNonce.toString(), + // gas: gasLimit.toString(), + // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) + // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) + // }, + // relayData: { + // gasPrice: gasPrice.toString(), + // pctRelayFee: relay.pctRelayFee.toString(), + // baseRelayFee: relay.baseRelayFee.toString(), + // relayWorker: relay.relayWorkerAddress, + // paymaster: config.polygon.usdt_bridged.htlcContract, + // paymasterData: '0x', + // clientId: Math.floor(Math.random() * 1e6).toString(10), + // forwarder: config.polygon.usdt_bridged.htlcContract, + // }, + // }; + + // fund = { + // type: SwapAsset.USDT_MATIC, + // ...relayRequest, + // ...(method === 'openWithApproval' ? { + // approval: { + // tokenNonce: usdtNonce.toNumber(), + // }, + // } : null), + // }; + // } + + // if (swapSuggestion.to.asset === SwapAsset.NIM) { + // const nimiqClient = await getNetworkClient(); + // await nimiqClient.waitForConsensusEstablished(); + // const headHeight = await nimiqClient.getHeadHeight(); + // if (headHeight > 100) { + // useNetworkStore().state.height = headHeight; + // } else { + // throw new Error('Invalid network state, try please reloading the app'); + // } + + // redeem = { + // type: SwapAsset.NIM, + // recipient: nimAddress, // My address, must be redeem address of HTLC + // value: swapSuggestion.to.amount - swapSuggestion.to.fee, // Luna + // fee: swapSuggestion.to.fee, // Luna + // validityStartHeight: useNetworkStore().state.height, + // }; + // } + + // if (swapSuggestion.to.asset === SwapAsset.BTC) { + // const electrumClient = await getElectrumClient(); + // await electrumClient.waitForConsensusEstablished(); + + // redeem = { + // type: SwapAsset.BTC, + // input: { + // // transactionHash: transaction.transactionHash, + // // outputIndex: output.index, + // // outputScript: output.script, + // value: swapSuggestion.to.amount, // Sats + // }, + // output: { + // address: btcAddress, // My address, must be redeem address of HTLC + // value: swapSuggestion.to.amount - swapSuggestion.to.fee, // Sats + // }, + // }; + // } + + // if (swapSuggestion.to.asset === SwapAsset.USDC_MATIC) { + // const htlcContract = await getUsdcHtlcContract(); + // const toAddress = activePolygonAddress.value!; + + // const [ + // forwarderNonce, + // blockHeight, + // ] = await Promise.all([ + // htlcContract.getNonce(toAddress) as Promise, + // getPolygonBlockNumber(), + // ]); + + // const { fee, gasLimit, gasPrice, relay, method } = { + // fee: 1, + // gasLimit: 1, + // gasPrice: 1, + // relay: { + // pctRelayFee: 1, + // baseRelayFee: 1, + // relayWorkerAddress: '0x0000000000111111111122222222223333333333', + // }, + // method: 'open' + // }; + // if (method !== 'redeemWithSecretInData') { + // throw new Error('Wrong USDC contract method'); + // } + + // const data = htlcContract.interface.encodeFunctionData(method, [ + // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* address target */ toAddress, + // /* uint256 fee */ fee, + // ]); + + // const relayRequest: RelayRequest = { + // request: { + // from: toAddress, + // to: config.polygon.usdc.htlcContract, + // data, + // value: '0', + // nonce: forwarderNonce.toString(), + // gas: gasLimit.toString(), + // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) + // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) + // }, + // relayData: { + // gasPrice: gasPrice.toString(), + // pctRelayFee: relay.pctRelayFee.toString(), + // baseRelayFee: relay.baseRelayFee.toString(), + // relayWorker: relay.relayWorkerAddress, + // paymaster: config.polygon.usdc.htlcContract, + // paymasterData: '0x', + // clientId: Math.floor(Math.random() * 1e6).toString(10), + // forwarder: config.polygon.usdc.htlcContract, + // }, + // }; + + // redeem = { + // type: SwapAsset.USDC_MATIC, + // ...relayRequest, + // amount: swapSuggestion.to.amount - swapSuggestion.to.fee, + // }; + // } + + // if (swapSuggestion.to.asset === SwapAsset.USDT_MATIC) { + // const htlcContract = await getUsdtBridgedHtlcContract(); + // const toAddress = activePolygonAddress.value!; + + // const [ + // forwarderNonce, + // blockHeight, + // ] = await Promise.all([ + // htlcContract.getNonce(toAddress) as Promise, + // getPolygonBlockNumber(), + // ]); + + // const { fee, gasLimit, gasPrice, relay, method } = { + // fee: 1, + // gasLimit: 1, + // gasPrice: 1, + // relay: { + // pctRelayFee: 1, + // baseRelayFee: 1, + // relayWorkerAddress: '0x0000000000111111111122222222223333333333', + // }, + // method: 'open' + // }; + // if (method !== 'redeemWithSecretInData') { + // throw new Error('Wrong USDT contract method'); + // } + + // const data = htlcContract.interface.encodeFunctionData(method, [ + // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* address target */ toAddress, + // /* uint256 fee */ fee, + // ]); + + // const relayRequest: RelayRequest = { + // request: { + // from: toAddress, + // to: config.polygon.usdt_bridged.htlcContract, + // data, + // value: '0', + // nonce: forwarderNonce.toString(), + // gas: gasLimit.toString(), + // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) + // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) + // }, + // relayData: { + // gasPrice: gasPrice.toString(), + // pctRelayFee: relay.pctRelayFee.toString(), + // baseRelayFee: relay.baseRelayFee.toString(), + // relayWorker: relay.relayWorkerAddress, + // paymaster: config.polygon.usdt_bridged.htlcContract, + // paymasterData: '0x', + // clientId: Math.floor(Math.random() * 1e6).toString(10), + // forwarder: config.polygon.usdt_bridged.htlcContract, + // }, + // }; + + // redeem = { + // type: SwapAsset.USDT_MATIC, + // ...relayRequest, + // amount: swapSuggestion.to.amount - swapSuggestion.to.fee, + // }; + // } + + // if (!fund || !redeem) { + // reject(new Error('UNEXPECTED: No funding or redeeming data objects')); + // return; + // } + + // const serviceSwapFee = Math.round( + // (swapSuggestion.from.amount - swapSuggestion.from.serviceNetworkFee) + // * swapSuggestion.serviceFeePercentage, + // ); + + // const { addressInfos } = useAddressStore(); + + // const { activeAccountInfo } = useAccountStore(); + + // const { currency, exchangeRates } = useFiatStore(); + + // const { + // activeAddress + + // } = usePolygonAddressStore(); + + // const request: Omit = { + // accountId: activeAccountInfo.value!.id, + // swapId: swapSuggestion.id, + // fund, + // redeem, + + // layout: 'slider', + // direction: direction === 'forward' ? 'left-to-right' : 'right-to-left', + // fiatCurrency: currency.value, + // fundingFiatRate: exchangeRates.value[assetToCurrency( + // fund.type as SupportedSwapAsset, + // )][currency.value]!, + // redeemingFiatRate: exchangeRates.value[assetToCurrency( + // redeem.type as SupportedSwapAsset, + // )][currency.value]!, + // fundFees: { + // processing: 0, + // redeeming: swapSuggestion.from.serviceNetworkFee, + // }, + // redeemFees: { + // funding: swapSuggestion.to.serviceNetworkFee, + // processing: 0, + // }, + // serviceSwapFee, + // nimiqAddresses: addressInfos.value.map((addressInfo) => ({ + // address: addressInfo.address, + // balance: addressInfo.balance || 0, + // })), + // bitcoinAccount: { + // balance: accountBtcBalance.value, + // }, + // polygonAddresses: activePolygonAddress.value ? [{ + // address: activePolygonAddress.value, + // usdcBalance: accountUsdcBalance.value, + // usdtBalance: accountUsdtBridgedBalance.value, + // }] : [], + // }; + + // console.log('[Demo] Faking swap setup with request:', request); + // resolve(request) + // return; + // } + + // Wait for router readiness + await new Promise((resolve) => { + demoRouter.onReady(resolve); }); - setActiveSwap(swap); - - // Simulate swap progression - setTimeout(() => { - // Update to processing state after 2s - swap.state = SwapState.SETTLE_SWAP; - swap.stateEnteredAt = Date.now(); - setActiveSwap(swap); - - // Create transactions for both sides - setTimeout(() => { - const { addTransactions: addNimTx } = useTransactionsStore(); - const { addTransactions: addBtcTx } = useBtcTransactionsStore(); - const { addTransactions: addUsdcTx } = useUsdcTransactionsStore(); - const { addTransactions: addUsdtTx } = useUsdtTransactionsStore(); - - // Create appropriate transactions based on assets - if (leftAsset === SwapAsset.NIM) { - addNimTx([createFakeTransaction({ - value: leftValue, - sender: demoNimAddress, - recipient: `0x${Math.random().toString(16).slice(2)}`, - data: { - type: 'raw', - raw: encodeTextToHex(`Swap: NIM to ${rightAsset}`), - }, - })]); - } - if (rightAsset === SwapAsset.NIM) { - addNimTx([createFakeTransaction({ - value: rightValue, - recipient: demoNimAddress, - sender: `0x${Math.random().toString(16).slice(2)}`, - data: { - type: 'raw', - raw: encodeTextToHex(`Swap: ${leftAsset} to NIM`), - }, - })]); - } - - // Handle BTC transactions - if (leftAsset === SwapAsset.BTC || rightAsset === SwapAsset.BTC) { - const isSending = leftAsset === SwapAsset.BTC; - const btcValue = isSending ? leftValue : rightValue; - addBtcTx([{ - isCoinbase: false, - transactionHash: `btc-swap-${swapCounter}`, - inputs: [{ - address: isSending ? demoBtcAddress : `1${Math.random().toString(36).slice(2)}`, - outputIndex: 0, - index: 0, - script: 'swap', - sequence: 4294967295, - transactionHash: `prev-${swapCounter}`, - witness: ['swap'], - }], - outputs: [{ - value: btcValue, - address: isSending ? `1${Math.random().toString(36).slice(2)}` : demoBtcAddress, - script: 'swap', - index: 0, - }], - version: 1, - vsize: 200, - weight: 800, - locktime: 0, - confirmations: 1, - replaceByFee: false, - timestamp: Math.floor(Date.now() / 1000), - state: ElectrumTransactionState.CONFIRMED, - }]); - } - - // Handle USDC transactions - if (leftAsset === SwapAsset.USDC_MATIC || rightAsset === SwapAsset.USDC_MATIC) { - const isSending = leftAsset === SwapAsset.USDC_MATIC; - const value = isSending ? leftValue : rightValue; - addUsdcTx([{ - token: Config.polygon.usdc.tokenContract, - transactionHash: `usdc-swap-${swapCounter}`, - logIndex: 0, - sender: isSending ? demoPolygonAddress : `0x${Math.random().toString(16).slice(2)}`, - recipient: isSending ? `0x${Math.random().toString(16).slice(2)}` : demoPolygonAddress, - value, - state: UsdcTransactionState.CONFIRMED, - blockHeight: currentHead++, - timestamp: Math.floor(Date.now() / 1000), - }]); - } - - // Handle USDT transactions - if (leftAsset === SwapAsset.USDT_MATIC || rightAsset === SwapAsset.USDT_MATIC) { - const isSending = leftAsset === SwapAsset.USDT_MATIC; - const value = isSending ? leftValue : rightValue; - addUsdtTx([{ - token: Config.polygon.usdt_bridged.tokenContract, - transactionHash: `usdt-swap-${swapCounter}`, - logIndex: 0, - sender: isSending ? demoPolygonAddress : `0x${Math.random().toString(16).slice(2)}`, - recipient: isSending ? `0x${Math.random().toString(16).slice(2)}` : demoPolygonAddress, - value, - state: UsdtTransactionState.CONFIRMED, - blockHeight: currentHead++, - timestamp: Math.floor(Date.now() / 1000), - }]); - } - - // Complete the swap - swap.state = SwapState.COMPLETED; - swap.stateEnteredAt = Date.now(); - swap.status = SwapStatus.SUCCESS; - setActiveSwap(swap); - }, 3000); // Complete after 3 more seconds - }, 2000); // Start processing after 2s - - return; - } - - // Wait for router readiness - await new Promise((resolve) => { - demoRouter.onReady(resolve); - }); - // eslint-disable-next-line no-console - console.log('[Demo] Redirecting to fallback modal'); - demoRouter.push(`/${DemoModal.Fallback}`); - }; + // eslint-disable-next-line no-console + console.log('[Demo] Redirecting to fallback modal'); + demoRouter.push(`/${DemoModal.Fallback}`); + }); + } } return target[prop]; }, From c7a763b6efd5e3a312a01ab682d5a53694955b0d Mon Sep 17 00:00:00 2001 From: Alberto Monterroso <14013679+Albermonte@users.noreply.github.com> Date: Mon, 10 Mar 2025 08:28:46 +0100 Subject: [PATCH 04/31] feat(demo): added swap animation --- src/stores/Demo.ts | 1580 +++++++++++++++++++++++++------------------- 1 file changed, 917 insertions(+), 663 deletions(-) diff --git a/src/stores/Demo.ts b/src/stores/Demo.ts index 21994c3dd..711f3a123 100644 --- a/src/stores/Demo.ts +++ b/src/stores/Demo.ts @@ -15,14 +15,15 @@ import { useStakingStore } from '@/stores/Staking'; import { useAccountSettingsStore } from '@/stores/AccountSettings'; import { usePolygonAddressStore } from '@/stores/PolygonAddress'; import Config from 'config'; -import { AssetList, FastspotAsset, FastspotEstimate, FastspotFee, FastspotLimits, FastspotUserLimits, ReferenceAsset, SwapAsset } from '@nimiq/fastspot-api'; +import { AssetList, FastspotAsset, FastspotEstimate, FastspotFee, FastspotLimits, FastspotUserLimits, ReferenceAsset, SwapAsset, SwapStatus } from '@nimiq/fastspot-api'; +import HubApi, { SetupSwapResult } from '@nimiq/hub-api'; import { useBtcAddressStore } from './BtcAddress'; import { useContactsStore } from './Contacts'; import { useBtcLabelsStore } from './BtcLabels'; import { useUsdcContactsStore } from './UsdcContacts'; import { useUsdtContactsStore } from './UsdtContacts'; import { useFiatStore } from './Fiat'; -import HubApi from '@nimiq/hub-api'; +import { SwapState, useSwapsStore } from './Swaps'; export type DemoState = { active: boolean, @@ -60,6 +61,61 @@ const nimInitialBalance = 14041800000; // 14,041,800,000 - 14 april, 2018 const usdtInitialBalance = 5000000000; // 5000 USDT (6 decimals) const usdcInitialBalance = 3000000000; // 3000 USDC (6 decimals) +// Swaps +const onGoingSwaps = new Map(); + +let swapInterval: NodeJS.Timeout | null = null; +function listenForSwapChanges() { + if (swapInterval) return; + swapInterval = setInterval(() => { + const swap = useSwapsStore().activeSwap.value; + if (!swap) return; + console.log('[Demo] Active swap:', { swap, state: swap.state }); + switch (swap.state) { + case SwapState.AWAIT_INCOMING: + console.log('[Demo] Swap is in AWAIT_INCOMING state'); + useSwapsStore().setActiveSwap({ + ...swap, + state: SwapState.CREATE_OUTGOING, + }); + break; + case SwapState.CREATE_OUTGOING: + console.log('[Demo] Swap is in CREATE_OUTGOING state'); + useSwapsStore().setActiveSwap({ + ...swap, + state: SwapState.AWAIT_SECRET, + }); + break; + case SwapState.AWAIT_SECRET: + console.log('[Demo] Swap is in AWAIT_SECRET state'); + useSwapsStore().setActiveSwap({ + ...swap, + state: SwapState.SETTLE_INCOMING, + }); + break; + case SwapState.SETTLE_INCOMING: + console.log('[Demo] Swap is in SETTLE_INCOMING state'); + useSwapsStore().setActiveSwap({ + ...swap, + state: SwapState.COMPLETE, + }); + break; + case SwapState.COMPLETE: + console.log('[Demo] Swap is in COMPLETE state'); + if (swapInterval)clearInterval(swapInterval); + swapInterval = null; + break; + default: + console.log('[Demo] Swap is in unknown state'); + useSwapsStore().setActiveSwap({ + ...swap, + state: SwapState.AWAIT_INCOMING, + }); + break; + } + }, 15000); +} + // We keep a reference to the router here. let demoRouter: VueRouter; @@ -906,50 +962,60 @@ function interceptFetchRequest() { const isLimitsRequest = url.pathname.includes('/limits'); const isEstimateRequest = url.pathname.includes('/estimate'); const isAssetsRequest = url.pathname.includes('/assets'); + const isSwapRequest = url.pathname.includes('/swaps'); + // return originalFetch(...args); if (!isFastspotRequest) { return originalFetch(...args); } - if (isLimitsRequest) { + console.log('[Demo] Intercepted fetch request:', url.pathname); + + if (isLimitsRequest) { const constants = { current: '9800', daily: '50000', dailyRemaining: '49000', monthly: '100000', monthlyRemaining: '98000', + swap: '10000', } as const; - - const [assetOrLimit,_] = url.pathname.split('/').slice(-2) as [SwapAsset | 'limits', string]; + + const [assetOrLimit] = url.pathname.split('/').slice(-2) as [SwapAsset | 'limits', string]; if (assetOrLimit === 'limits') { const limits: FastspotUserLimits = { asset: ReferenceAsset.USD, - swap: `${1000}`, ...constants, }; return new Response(JSON.stringify(limits)); } - + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); await sleep(1000 + Math.random() * 500); const asset = assetOrLimit as SwapAsset; - // const { exchangeRates } = useFiatStore(); - // const rate: number = exchangeRates.value[asset.toLocaleLowerCase().split('_')[0]].usd!; + + const { exchangeRates, currency } = useFiatStore(); + const rate: number = exchangeRates.value[asset.toLocaleLowerCase().split('_')[0]][currency.value]!; + const json: FastspotLimits = { asset, - swap: `${1000}`, referenceAsset: ReferenceAsset.USD, referenceCurrent: constants.current, referenceDaily: constants.daily, referenceDailyRemaining: constants.dailyRemaining, referenceMonthly: constants.monthly, referenceMonthlyRemaining: constants.monthlyRemaining, - referenceSwap: `${1000}`, - ...constants, + referenceSwap: `${10000}`, + current: `${Number(constants.current) / rate}`, + daily: `${Number(constants.daily) / rate}`, + dailyRemaining: `${Number(constants.dailyRemaining) / rate}`, + monthly: `${Number(constants.monthly) / rate}`, + monthlyRemaining: `${Number(constants.monthlyRemaining) / rate}`, + swap: `${Number(constants.swap) / rate}`, }; - + return new Response(JSON.stringify(json)); } @@ -979,118 +1045,226 @@ function interceptFetchRequest() { name: 'USDT (Polygon)', feePerUnit: `${getNetworkFeePerUnit(SwapAsset.USDT_MATIC)}`, limits: { minimum: '1', maximum: '100000' }, - } - ] + }, + ]; + return new Response(JSON.stringify(json)); } - if (isEstimateRequest) { - const body = args[1]?.body - if (!body) throw new Error('[Demo] No body found in request') - type EstimateRequest = { - from: Record, - to: SwapAsset, - includedFees: 'required' - }; - const { from: fromObj, to, includedFees: _includedFees } = JSON.parse(body.toString()) as EstimateRequest - const from = Object.keys(fromObj)[0] as SwapAsset + // if (isEstimateRequest) { + // const body = args[1]?.body; + // if (!body) throw new Error('[Demo] No body found in request'); + // type EstimateRequest = { + // from: Record, + // to: SwapAsset, + // includedFees: 'required', + // }; + // const { from: fromObj, to } = JSON.parse(body.toString()) as EstimateRequest; + // const from = Object.keys(fromObj)[0] as SwapAsset; + + // // Validate the request + // if (!from || !to) { + // console.error('[Demo] Invalid request parameters:', { from, to }); + // return new Response(JSON.stringify({ + // error: 'Invalid request parameters', + // status: 400, + // }), { status: 400 }); + // } + + // // Calculate fees based on the value + + // const value = fromObj[from]; + // const networkFee = value * 0.0005; // 0.05% network fee + // // const escrowFee = value * 0.001; // 0.1% escrow fee + + // // Calculate the destination amount using our mock rates + // const estimatedAmount = calculateEstimatedAmount(from, to, value); + + // // Create mock estimate response with a valid structure + // const estimate: FastspotEstimate[] = [{ + // from: [{ + // amount: `${value}`, + // name: from, + // symbol: from, + // finalizeNetworkFee: { + // total: `${networkFee}`, + // totalIsIncluded: false, + // perUnit: `${getNetworkFeePerUnit(from)}`, + // }, + // fundingNetworkFee: { + // total: `${networkFee}`, + // totalIsIncluded: false, + // perUnit: `${getNetworkFeePerUnit(from)}`, + // }, + // operatingNetworkFee: { + // total: `${networkFee}`, + // totalIsIncluded: false, + // perUnit: `${getNetworkFeePerUnit(from)}`, + // }, + // }], + // to: [{ + // amount: `${estimatedAmount}`, + // name: to, + // symbol: to, + // finalizeNetworkFee: { + // total: `${networkFee}`, + // totalIsIncluded: false, + // perUnit: `${getNetworkFeePerUnit(to)}`, + // }, + // fundingNetworkFee: { + // total: `${networkFee}`, + // totalIsIncluded: false, + // perUnit: `${getNetworkFeePerUnit(to)}`, + // }, + // operatingNetworkFee: { + // total: `${networkFee}`, + // totalIsIncluded: false, + // perUnit: `${getNetworkFeePerUnit(to)}`, + // }, + // }], + // direction: 'forward', + // serviceFeePercentage: 0.01, + // }]; + + // console.log('[Demo] Mock estimate:', estimate); + + // return new Response(JSON.stringify(estimate)); + // } + + if (isSwapRequest) { + const swapId = url.pathname.split('/').slice(-1)[0]; + + if (swapId === 'swaps') { + const { patchAccount, activeAccountInfo } = useAccountStore(); + const newBtcAddress = demoBtcAddress + Math.random().toString(36).slice(2, 9); + + patchAccount(activeAccountInfo.value?.id, { + ...activeAccountInfo, + btcAddresses: { + external: [...(activeAccountInfo.value?.btcAddresses.external || []), newBtcAddress], + internal: [...(activeAccountInfo.value?.btcAddresses.internal || []), newBtcAddress], + }, + }); + + const { addAddressInfos } = useBtcAddressStore(); + addAddressInfos([{ + address: newBtcAddress, + txoCount: 0, // Total number of outputs received + utxos: [], + }]); + } else { + listenForSwapChanges(); + + console.log('[Demo] Swap request:', swapId); + const swap = onGoingSwaps.get(swapId); + if (!swap) { + return new Response(JSON.stringify({ + error: 'Swap not found', + status: 404, + }), { status: 404 }); + } - // Validate the request - if (!from || !to) { - return new Response(JSON.stringify({ - error: 'Invalid request parameters', - status: 400, - }), { status: 400 }); - } + console.log('[Demo] Swap:', swap); + const expirationTimestamp = Math.floor(Date.now() / 1000) + 3600; - // Calculate fees based on the value - - const value = fromObj[from]; - const networkFee = Math.floor(value * 0.0005); // 0.05% network fee - const escrowFee = Math.floor(value * 0.001); // 0.1% escrow fee - - // Calculate the destination amount using our mock rates - const estimatedAmount = calculateEstimatedAmount(from, to, value); - - // Create mock estimate response with a valid structure - const estimate: FastspotEstimate[] = [{ - from: [{ - amount: `${value}`, - name: from, - symbol: from, - finalizeNetworkFee: { - total: `${networkFee}`, - totalIsIncluded: false, - perUnit: `${getNetworkFeePerUnit(from)}`, - }, - fundingNetworkFee: { - total: `${networkFee}`, - totalIsIncluded: false, - perUnit: `${getNetworkFeePerUnit(from)}`, - }, - operatingNetworkFee: { - total: `${networkFee}`, - totalIsIncluded: false, - perUnit: `${getNetworkFeePerUnit(from)}`, - } - }], - to: [{ - amount: `${estimatedAmount}`, - name: to, - symbol: to, - finalizeNetworkFee: { - total: `${networkFee}`, - totalIsIncluded: false, - perUnit: `${getNetworkFeePerUnit(to)}`, - }, - fundingNetworkFee: { - total: `${networkFee}`, - totalIsIncluded: false, - perUnit: `${getNetworkFeePerUnit(to)}`, + return new Response(JSON.stringify({ + id: swapId, + status: SwapStatus.WAITING_FOR_CONFIRMATION, + expires: expirationTimestamp, + info: { + from: [ + { + symbol: swap.fund.type, + amount: swap.fund.output.value / 1e8, + fundingNetworkFee: { + total: '0.000037', + perUnit: '0.000000240254', + totalIsIncluded: true, + }, + operatingNetworkFee: { + total: '0', + perUnit: '0.000000240254', + totalIsIncluded: false, + }, + finalizeNetworkFee: { + total: '0.0000346', + perUnit: '0.000000240254', + totalIsIncluded: false, + }, + }, + ], + to: [ + { + symbol: swap.redeem.type, + amount: swap.redeem.value / 1e5, + fundingNetworkFee: { + total: '0', + perUnit: '0', + totalIsIncluded: false, + }, + operatingNetworkFee: { + total: '0', + perUnit: '0', + totalIsIncluded: false, + }, + finalizeNetworkFee: { + total: '0', + perUnit: '0', + totalIsIncluded: false, + }, + }, + ], + serviceFeePercentage: 0.0025, + direction: 'reverse', }, - operatingNetworkFee: { - total: `${networkFee}`, - totalIsIncluded: false, - perUnit: `${getNetworkFeePerUnit(to)}`, - } - }], - direction: 'forward', - serviceFeePercentage: 0.01, - }]; - - console.log('[Demo] Mock estimate:', estimate); // eslint-disable no-console - - return new Response(JSON.stringify(estimate)); + hash: '946dc06baf94ee49a1bd026eff8eb4f30d34c9e162211667dbebd5a5282e6294', + contracts: [ + { + asset: swap.fund.type, + refund: { + address: swap.fund.refundAddress, + }, + recipient: { + address: swap.fund.refundAddress, + }, + amount: swap.fund.output.value, + timeout: expirationTimestamp, + direction: 'send', + status: 'pending', + id: '2MzQo4ehDrSEsxX7RnysLL6VePD3tuNyx4M', + intermediary: { }, + }, + { + asset: swap.redeem.type, + refund: { + address: swap.redeem.recipient, + }, + recipient: { + address: swap.redeem.recipient, + }, + amount: swap.redeem.value, + timeout: expirationTimestamp, + direction: 'receive', + status: 'pending', + id: 'eff8a1a5-4f4e-3895-b95c-fd5a40c99001', + intermediary: { }, + }, + ], + })); + } } return originalFetch(...args); }; } -/** - * Network fee helper function - */ -function getNetworkFee(asset: string): number { - switch (asset) { - case SwapAsset.BTC: - return 10000; // 10k sats - case SwapAsset.NIM: - return 1000; // 1000 luna - case SwapAsset.USDC_MATIC: - case SwapAsset.USDT_MATIC: - return 2000000000000000; // 0.002 MATIC in wei - default: - return 0; - } -} - /** * Fee per unit helper function */ function getNetworkFeePerUnit(asset: string): number { switch (asset) { case SwapAsset.BTC: - return 10; // 10 sats/vbyte + return Math.floor(Math.random() * 100) / 1e8; // 1 - 100 sats/vbyte case SwapAsset.NIM: return 0; // luna per byte case SwapAsset.USDC_MATIC: @@ -1139,7 +1313,7 @@ function calculateEstimatedAmount(fromAsset: string, toAsset: string, value: num * This allows users to interact with the send functionality without waiting for network sync. */ function enableSendModalInDemoMode() { - let observing = false; + const observing = false; const observer = new MutationObserver(() => { // Target the send modal footer button const sendButton = document.querySelector('.send-modal-footer .nq-button'); @@ -1155,9 +1329,9 @@ function enableSendModalInDemoMode() { const footer = document.querySelector('.send-modal-footer'); if (footer) { const footerNotice = footer.querySelector('.footer-notice') as HTMLDivElement; - if (footerNotice && footerNotice.textContent && - (footerNotice.textContent.includes('Connecting to Bitcoin') || - footerNotice.textContent.includes('Syncing with Bitcoin'))) { + if (footerNotice && footerNotice.textContent + && (footerNotice.textContent.includes('Connecting to Bitcoin') + || footerNotice.textContent.includes('Syncing with Bitcoin'))) { footerNotice.style.display = 'none'; } } @@ -1170,8 +1344,58 @@ function enableSendModalInDemoMode() { const ignoreHubRequests = [ 'addBtcAddresses', - 'on' -] + 'on', +]; + +interface SetupSwapArgs { + accountId: string; + swapId: string; + fund: { + type: 'BTC' | 'NIM' | 'USDC' | 'USDT', + inputs: { + address: string, + transactionHash: string, + outputIndex: number, + outputScript: string, + value: number, + }[], + output: { + value: number, + }, + changeOutput: { + address: string, + value: number, + }, + refundAddress: string, + }; + redeem: { + type: 'BTC' | 'NIM' | 'USDC' | 'USDT', + recipient: string, + value: number, + fee: number, + validityStartHeight: number, + }; + fundingFiatRate: number; + redeemingFiatRate: number; + fundFees: { + processing: number, + redeeming: number, + }; + redeemFees: { + funding: number, + processing: number, + }; + serviceSwapFee: number; + nimiqAddresses: { + address: string, + balance: number, + }[]; + polygonAddresses: { + address: string, + usdcBalance: number, + usdtBalance: number, + }[]; +} /** * Replacement of the Hub API class to capture and redirect calls to our demo modals instead. @@ -1182,558 +1406,588 @@ export class DemoHubApi extends HubApi { return new Proxy(instance, { get(target, prop: keyof HubApi) { if (typeof target[prop] === 'function') { - return async (...args: Parameters) => { - return new Promise(async (resolve, reject) => { - const requestName = String(prop); - const [firstArg] = args; - // eslint-disable-next-line no-console - console.warn(`[Demo] Mocking Hub call: ${requestName}("${firstArg}")`); - - if (ignoreHubRequests.includes(requestName)) { - return; - } - // Find the setupSwap handler in the DemoHubApi class and replace it with this: - - if (requestName === 'setupSwap') { - console.log({ - firstArg, - args, - }) - return - } - // if (requestName === 'setupSwap') { - // (firstArg as Function)(); - // // Get swap amount and asset details from the DOM - // const leftColumn = document.querySelector('.swap-amounts .left-column'); - // const leftAmountElement = leftColumn?.querySelector('.width-value'); - // const rightColumn = document.querySelector('.swap-amounts .right-column'); - // const rightAmountElement = rightColumn?.querySelector('.width-value'); - - // if (!leftAmountElement || !rightAmountElement || !leftColumn || !rightColumn) { - // console.warn('[Demo] Could not find swap amount elements'); - // return {}; - // } - - // const leftValue = Number((leftAmountElement as HTMLDivElement).innerText.replace(/,/g, '')); - // const leftAsset = leftColumn.querySelector('.ticker')?.innerHTML.toUpperCase().trim() as SwapAsset; - - // const rightValue = Number((rightAmountElement as HTMLDivElement).innerText.replace(/,/g, '')); - // const rightAsset = rightColumn.querySelector('.ticker')?.innerHTML.toUpperCase().trim() as SwapAsset; - - // console.log(`[Demo] Setting up swap: ${leftValue} ${leftAsset} -> ${rightValue} ${rightAsset}`); - - // // Check if we have valid values - // if (!leftValue || !rightValue || !leftAsset || !rightAsset) { - // console.warn('[Demo] Missing swap values'); - // return {}; - // } - - // const direction = leftValue < rightValue ? 'forward' : 'reverse'; - // const fromAsset = direction === 'forward' ? leftAsset : rightAsset; - // // @ts-expect-error Object key not specific enough? - // const toAsset: RequestAsset = direction === 'forward' - // ? { [rightAsset]: rightValue } - // : { [leftAsset]: leftValue }; - - // const swapSuggestion = await createSwap(fromAsset, toAsset); - - // const { config } = useConfig(); - - // let fund: HtlcCreationInstructions | null = null; - // let redeem: HtlcSettlementInstructions | null = null; - - // const { activeAddressInfo } = useAddressStore(); - - // const { availableExternalAddresses } = useBtcAddressStore(); - // const nimAddress = activeAddressInfo.value!.address; - // const btcAddress = availableExternalAddresses.value[0]; - - // if (swapSuggestion.from.asset === SwapAsset.NIM) { - // const nimiqClient = await getNetworkClient(); - // await nimiqClient.waitForConsensusEstablished(); - // const headHeight = await nimiqClient.getHeadHeight(); - // if (headHeight > 100) { - // useNetworkStore().state.height = headHeight; - // } else { - // throw new Error('Invalid network state, try please reloading the app'); - // } - - // fund = { - // type: SwapAsset.NIM, - // sender: nimAddress, - // value: swapSuggestion.from.amount, - // fee: swapSuggestion.from.fee, - // validityStartHeight: useNetworkStore().state.height, - // }; - // } - - // const { accountUtxos, accountBalance: accountBtcBalance, } = useBtcAddressStore(); - // if (swapSuggestion.from.asset === SwapAsset.BTC) { - // const electrumClient = await getElectrumClient(); - // await electrumClient.waitForConsensusEstablished(); - - - // // Assemble BTC inputs - // // Transactions to an HTLC are 46 weight units bigger because of the longer recipient address - // const requiredInputs = selectOutputs( - // accountUtxos.value, swapSuggestion.from.amount, swapSuggestion.from.feePerUnit, 48); - // let changeAddress: string; - // if (requiredInputs.changeAmount > 0) { - // const { nextChangeAddress } = useBtcAddressStore(); - // if (!nextChangeAddress.value) { - // // FIXME: If no unused change address is found, need to request new ones from Hub! - // throw new Error('No more unused change addresses (not yet implemented)'); - // } - // changeAddress = nextChangeAddress.value; - // } - - // fund = { - // type: SwapAsset.BTC, - // inputs: requiredInputs.utxos.map((utxo) => ({ - // address: utxo.address, - // transactionHash: utxo.transactionHash, - // outputIndex: utxo.index, - // outputScript: utxo.witness.script, - // value: utxo.witness.value, - // })), - // output: { - // value: swapSuggestion.from.amount, - // }, - // ...(requiredInputs.changeAmount > 0 ? { - // changeOutput: { - // address: changeAddress!, - // value: requiredInputs.changeAmount, - // }, - // } : {}), - // refundAddress: btcAddress, - // }; - // } - - // const { - // activeAddress: activePolygonAddress, - // accountUsdcBalance, - // accountUsdtBridgedBalance, - // } = usePolygonAddressStore(); - - - // if (swapSuggestion.from.asset === SwapAsset.USDC_MATIC) { - // const [client, htlcContract] = await Promise.all([ - // getPolygonClient(), - // getUsdcHtlcContract(), - // ]); - // const fromAddress = activePolygonAddress.value!; - - // const [ - // usdcNonce, - // forwarderNonce, - // blockHeight, - // ] = await Promise.all([ - // client.usdcToken.nonces(fromAddress) as Promise, - // htlcContract.getNonce(fromAddress) as Promise, - // getPolygonBlockNumber(), - // ]); - - // const { fee, gasLimit, gasPrice, relay, method } = { - // fee: 1, - // gasLimit: 1, - // gasPrice: 1, - // relay: { - // pctRelayFee: 1, - // baseRelayFee: 1, - // relayWorkerAddress: '0x0000000000111111111122222222223333333333', - // }, - // method: 'open' - // }; - // if (method !== 'open' && method !== 'openWithPermit') { - // throw new Error('Wrong USDC contract method'); - // } - - // // Zeroed data fields are replaced by Fastspot's proposed data (passed in from Hub) in - // // Keyguard's SwapIFrameApi. - // const data = htlcContract.interface.encodeFunctionData(method, [ - // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* address token */ config.polygon.usdc.tokenContract, - // /* uint256 amount */ swapSuggestion.from.amount, - // /* address refundAddress */ fromAddress, - // /* address recipientAddress */ '0x0000000000000000000000000000000000000000', - // /* bytes32 hash */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* uint256 timeout */ 0, - // /* uint256 fee */ fee, - // ...(method === 'openWithPermit' ? [ - // // // Approve the maximum possible amount so afterwards we can use the `open` method for - // // // lower fees - // // /* uint256 value */ client.ethers - // // .BigNumber.from('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), - // /* uint256 value */ swapSuggestion.from.amount + fee, - - // /* bytes32 sigR */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* bytes32 sigS */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* uint8 sigV */ 0, - // ] : []), - // ]); - - // const relayRequest: RelayRequest = { - // request: { - // from: fromAddress, - // to: config.polygon.usdc.htlcContract, - // data, - // value: '0', - // nonce: forwarderNonce.toString(), - // gas: gasLimit.toString(), - // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) - // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) - // }, - // relayData: { - // gasPrice: gasPrice.toString(), - // pctRelayFee: relay.pctRelayFee.toString(), - // baseRelayFee: relay.baseRelayFee.toString(), - // relayWorker: relay.relayWorkerAddress, - // paymaster: config.polygon.usdc.htlcContract, - // paymasterData: '0x', - // clientId: Math.floor(Math.random() * 1e6).toString(10), - // forwarder: config.polygon.usdc.htlcContract, - // }, - // }; - - // fund = { - // type: SwapAsset.USDC_MATIC, - // ...relayRequest, - // ...(method === 'openWithPermit' ? { - // permit: { - // tokenNonce: usdcNonce.toNumber(), - // }, - // } : null), - // }; - // } - - // if (swapSuggestion.from.asset === SwapAsset.USDT_MATIC) { - // const [client, htlcContract] = await Promise.all([ - // getPolygonClient(), - // getUsdtBridgedHtlcContract(), - // ]); - // const fromAddress = activePolygonAddress.value!; - - // const [ - // usdtNonce, - // forwarderNonce, - // blockHeight, - // ] = await Promise.all([ - // client.usdtBridgedToken.getNonce(fromAddress) as Promise, - // htlcContract.getNonce(fromAddress) as Promise, - // getPolygonBlockNumber(), - // ]); - - // const { fee, gasLimit, gasPrice, relay, method } = { - // fee: 1, - // gasLimit: 1, - // gasPrice: 1, - // relay: { - // pctRelayFee: 1, - // baseRelayFee: 1, - // relayWorkerAddress: '0x0000000000111111111122222222223333333333', - // }, - // method: 'open' - // }; - // if (method !== 'open' && method !== 'openWithApproval') { - // throw new Error('Wrong USDT contract method'); - // } - - // // Zeroed data fields are replaced by Fastspot's proposed data (passed in from Hub) in - // // Keyguard's SwapIFrameApi. - // const data = htlcContract.interface.encodeFunctionData(method, [ - // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* address token */ config.polygon.usdt_bridged.tokenContract, - // /* uint256 amount */ swapSuggestion.from.amount, - // /* address refundAddress */ fromAddress, - // /* address recipientAddress */ '0x0000000000000000000000000000000000000000', - // /* bytes32 hash */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* uint256 timeout */ 0, - // /* uint256 fee */ fee, - // ...(method === 'openWithApproval' ? [ - // // // Approve the maximum possible amount so afterwards we can use the `open` method for - // // // lower fees - // // /* uint256 approval */ client.ethers - // // .BigNumber.from('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), - // /* uint256 approval */ swapSuggestion.from.amount + fee, - - // /* bytes32 sigR */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* bytes32 sigS */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* uint8 sigV */ 0, - // ] : []), - // ]); - - // const relayRequest: RelayRequest = { - // request: { - // from: fromAddress, - // to: config.polygon.usdt_bridged.htlcContract, - // data, - // value: '0', - // nonce: forwarderNonce.toString(), - // gas: gasLimit.toString(), - // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) - // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) - // }, - // relayData: { - // gasPrice: gasPrice.toString(), - // pctRelayFee: relay.pctRelayFee.toString(), - // baseRelayFee: relay.baseRelayFee.toString(), - // relayWorker: relay.relayWorkerAddress, - // paymaster: config.polygon.usdt_bridged.htlcContract, - // paymasterData: '0x', - // clientId: Math.floor(Math.random() * 1e6).toString(10), - // forwarder: config.polygon.usdt_bridged.htlcContract, - // }, - // }; - - // fund = { - // type: SwapAsset.USDT_MATIC, - // ...relayRequest, - // ...(method === 'openWithApproval' ? { - // approval: { - // tokenNonce: usdtNonce.toNumber(), - // }, - // } : null), - // }; - // } - - // if (swapSuggestion.to.asset === SwapAsset.NIM) { - // const nimiqClient = await getNetworkClient(); - // await nimiqClient.waitForConsensusEstablished(); - // const headHeight = await nimiqClient.getHeadHeight(); - // if (headHeight > 100) { - // useNetworkStore().state.height = headHeight; - // } else { - // throw new Error('Invalid network state, try please reloading the app'); - // } - - // redeem = { - // type: SwapAsset.NIM, - // recipient: nimAddress, // My address, must be redeem address of HTLC - // value: swapSuggestion.to.amount - swapSuggestion.to.fee, // Luna - // fee: swapSuggestion.to.fee, // Luna - // validityStartHeight: useNetworkStore().state.height, - // }; - // } - - // if (swapSuggestion.to.asset === SwapAsset.BTC) { - // const electrumClient = await getElectrumClient(); - // await electrumClient.waitForConsensusEstablished(); - - // redeem = { - // type: SwapAsset.BTC, - // input: { - // // transactionHash: transaction.transactionHash, - // // outputIndex: output.index, - // // outputScript: output.script, - // value: swapSuggestion.to.amount, // Sats - // }, - // output: { - // address: btcAddress, // My address, must be redeem address of HTLC - // value: swapSuggestion.to.amount - swapSuggestion.to.fee, // Sats - // }, - // }; - // } - - // if (swapSuggestion.to.asset === SwapAsset.USDC_MATIC) { - // const htlcContract = await getUsdcHtlcContract(); - // const toAddress = activePolygonAddress.value!; - - // const [ - // forwarderNonce, - // blockHeight, - // ] = await Promise.all([ - // htlcContract.getNonce(toAddress) as Promise, - // getPolygonBlockNumber(), - // ]); - - // const { fee, gasLimit, gasPrice, relay, method } = { - // fee: 1, - // gasLimit: 1, - // gasPrice: 1, - // relay: { - // pctRelayFee: 1, - // baseRelayFee: 1, - // relayWorkerAddress: '0x0000000000111111111122222222223333333333', - // }, - // method: 'open' - // }; - // if (method !== 'redeemWithSecretInData') { - // throw new Error('Wrong USDC contract method'); - // } - - // const data = htlcContract.interface.encodeFunctionData(method, [ - // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* address target */ toAddress, - // /* uint256 fee */ fee, - // ]); - - // const relayRequest: RelayRequest = { - // request: { - // from: toAddress, - // to: config.polygon.usdc.htlcContract, - // data, - // value: '0', - // nonce: forwarderNonce.toString(), - // gas: gasLimit.toString(), - // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) - // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) - // }, - // relayData: { - // gasPrice: gasPrice.toString(), - // pctRelayFee: relay.pctRelayFee.toString(), - // baseRelayFee: relay.baseRelayFee.toString(), - // relayWorker: relay.relayWorkerAddress, - // paymaster: config.polygon.usdc.htlcContract, - // paymasterData: '0x', - // clientId: Math.floor(Math.random() * 1e6).toString(10), - // forwarder: config.polygon.usdc.htlcContract, - // }, - // }; - - // redeem = { - // type: SwapAsset.USDC_MATIC, - // ...relayRequest, - // amount: swapSuggestion.to.amount - swapSuggestion.to.fee, - // }; - // } - - // if (swapSuggestion.to.asset === SwapAsset.USDT_MATIC) { - // const htlcContract = await getUsdtBridgedHtlcContract(); - // const toAddress = activePolygonAddress.value!; - - // const [ - // forwarderNonce, - // blockHeight, - // ] = await Promise.all([ - // htlcContract.getNonce(toAddress) as Promise, - // getPolygonBlockNumber(), - // ]); - - // const { fee, gasLimit, gasPrice, relay, method } = { - // fee: 1, - // gasLimit: 1, - // gasPrice: 1, - // relay: { - // pctRelayFee: 1, - // baseRelayFee: 1, - // relayWorkerAddress: '0x0000000000111111111122222222223333333333', - // }, - // method: 'open' - // }; - // if (method !== 'redeemWithSecretInData') { - // throw new Error('Wrong USDT contract method'); - // } - - // const data = htlcContract.interface.encodeFunctionData(method, [ - // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* address target */ toAddress, - // /* uint256 fee */ fee, - // ]); - - // const relayRequest: RelayRequest = { - // request: { - // from: toAddress, - // to: config.polygon.usdt_bridged.htlcContract, - // data, - // value: '0', - // nonce: forwarderNonce.toString(), - // gas: gasLimit.toString(), - // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) - // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) - // }, - // relayData: { - // gasPrice: gasPrice.toString(), - // pctRelayFee: relay.pctRelayFee.toString(), - // baseRelayFee: relay.baseRelayFee.toString(), - // relayWorker: relay.relayWorkerAddress, - // paymaster: config.polygon.usdt_bridged.htlcContract, - // paymasterData: '0x', - // clientId: Math.floor(Math.random() * 1e6).toString(10), - // forwarder: config.polygon.usdt_bridged.htlcContract, - // }, - // }; - - // redeem = { - // type: SwapAsset.USDT_MATIC, - // ...relayRequest, - // amount: swapSuggestion.to.amount - swapSuggestion.to.fee, - // }; - // } - - // if (!fund || !redeem) { - // reject(new Error('UNEXPECTED: No funding or redeeming data objects')); - // return; - // } - - // const serviceSwapFee = Math.round( - // (swapSuggestion.from.amount - swapSuggestion.from.serviceNetworkFee) - // * swapSuggestion.serviceFeePercentage, - // ); - - // const { addressInfos } = useAddressStore(); - - // const { activeAccountInfo } = useAccountStore(); - - // const { currency, exchangeRates } = useFiatStore(); - - // const { - // activeAddress - - // } = usePolygonAddressStore(); - - // const request: Omit = { - // accountId: activeAccountInfo.value!.id, - // swapId: swapSuggestion.id, - // fund, - // redeem, - - // layout: 'slider', - // direction: direction === 'forward' ? 'left-to-right' : 'right-to-left', - // fiatCurrency: currency.value, - // fundingFiatRate: exchangeRates.value[assetToCurrency( - // fund.type as SupportedSwapAsset, - // )][currency.value]!, - // redeemingFiatRate: exchangeRates.value[assetToCurrency( - // redeem.type as SupportedSwapAsset, - // )][currency.value]!, - // fundFees: { - // processing: 0, - // redeeming: swapSuggestion.from.serviceNetworkFee, - // }, - // redeemFees: { - // funding: swapSuggestion.to.serviceNetworkFee, - // processing: 0, - // }, - // serviceSwapFee, - // nimiqAddresses: addressInfos.value.map((addressInfo) => ({ - // address: addressInfo.address, - // balance: addressInfo.balance || 0, - // })), - // bitcoinAccount: { - // balance: accountBtcBalance.value, - // }, - // polygonAddresses: activePolygonAddress.value ? [{ - // address: activePolygonAddress.value, - // usdcBalance: accountUsdcBalance.value, - // usdtBalance: accountUsdtBridgedBalance.value, - // }] : [], - // }; - - // console.log('[Demo] Faking swap setup with request:', request); - // resolve(request) - // return; - // } - - // Wait for router readiness - await new Promise((resolve) => { - demoRouter.onReady(resolve); + return async (...args: Parameters) => new Promise(async (resolve, reject) => { + const requestName = String(prop); + const [firstArg] = args; + // eslint-disable-next-line no-console + console.warn(`[Demo] Mocking Hub call: ${requestName}("${firstArg}")`); + + if (ignoreHubRequests.includes(requestName)) { + return; + } + // Find the setupSwap handler in the DemoHubApi class and replace it with this: + + if (requestName === 'setupSwap') { + console.log('[DEMO]', { + firstArg, + args, + promise: await firstArg, }); - - // eslint-disable-next-line no-console - console.log('[Demo] Redirecting to fallback modal'); - demoRouter.push(`/${DemoModal.Fallback}`); + const swap = await firstArg as SetupSwapArgs; + const signerTransaction: SetupSwapResult = { + nim: { + transaction: new Uint8Array(), + serializedTx: '0172720036a3b2ca9e0de8b369e6381753ebef945a020091fa7bbddf959616767c50c50962c9e056ade9c400000000000000989680000000000000000000c3e23d0500a60100010366687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f292520000000000000000000000000000000000000000000000000000000000000000000200demoSerializedTx', + // serializedTx: '0172720036a3b2ca9e0de8b369e6381753ebef945a020091fa7bbddf959616767c50c50962c9e056ade9c400000000000000989680000000000000000000c3e23d0500a60100010366687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925200000000000000000000000000000000000000000000000000000000000000000002003b571b3481bec4340ed715f6b59add1947667f2d51a70d05cc7b5778dae6e009a5342d2e62977c877342b1972ebe064e77c473603ac9a6b97ac1bdd0fa543f37487fab37256fad87d79314eee2dff3fc70623057fe17f07aa39f9cb9f99db0a', + hash: '6c58b337a907fe000demoTxHash8a1f4ab4fdc0f69b1e582f', + raw: { + signerPublicKey: new Uint8Array(), + signature: new Uint8Array(), + sender: 'NQ86 D3M0 SW4P NB59 U3F8 NDLX CE0P AFMX Y52S', + senderType: 2, + recipient: swap.redeem.recipient, + recipientType: 0, + value: swap.redeem.value, + fee: 0, + validityStartHeight: swap.redeem.validityStartHeight, + extraData: new Uint8Array(), + flags: 0, + networkId: 5, + proof: new Uint8Array(), + }, + }, + btc: { + serializedTx: '0200000000010168c8952af998f2c68412a848a72d1f9b0b7ff27417df1cb85514c97474b51ba40000000000ffffffff026515000000000000220020bf0ffdd2ffb9a579973455cfe9b56515538b79361d5ae8a4d255dea2519ef77864c501000000000016001428257447efe2d254ce850ea2760274d233d86e5c024730440220792fa932d9d0591e3c5eb03f47d05912a1e21f3e76d169e383af66e47896ac8c02205947df5523490e4138f2da0fc5c9da3039750fe43bd217b68d26730fdcae7fbe012102ef8d4b51d1a075e67d62baa78991d5fc36a658fec28d8b978826058168ed2a1a00000000', + hash: '3090808993a796c26a614f5a4a36a48e0b4af6cd3e28e39f3f006e9a447da2b3', + }, + refundTx: '02000000000101b3a27d449a6e003f9fe3283ecdf64a0b8ea4364a5a4f616ac296a793898090300000000000feffffff011e020000000000001600146d2146bb49f6d1de6b4f14e0a8074c79b887cef50447304402202a7dce2e39cf86ee1d7c1e9cc55f1e0fb26932fd22e5437e5e5804a9e5d220b1022031aa177ea085c10c4d54b2f5aa528aac0013b67f9ee674070aa2fb51894de80e0121025b4d40682bbcb5456a9d658971b725666a3cccaa2fb45d269d2f1486bf85b3c000636382012088a820be8719b9427f1551c4234f8b02d8f8aa055ae282b2e9eef6c155326ae951061f8876a914e546b01d8c9d9bf35f9f115132ce8eab7191a68d88ac67046716ca67b17576a9146d2146bb49f6d1de6b4f14e0a8074c79b887cef588ac686816ca67', + }; + + // Add to onGoingSwaps map + onGoingSwaps.set(swap.swapId, swap); + + resolve(signerTransaction); + } + // if (requestName === 'setupSwap') { + // (firstArg as Function)(); + // // Get swap amount and asset details from the DOM + // const leftColumn = document.querySelector('.swap-amounts .left-column'); + // const leftAmountElement = leftColumn?.querySelector('.width-value'); + // const rightColumn = document.querySelector('.swap-amounts .right-column'); + // const rightAmountElement = rightColumn?.querySelector('.width-value'); + + // if (!leftAmountElement || !rightAmountElement || !leftColumn || !rightColumn) { + // console.warn('[Demo] Could not find swap amount elements'); + // return {}; + // } + + // const leftValue = Number((leftAmountElement as HTMLDivElement).innerText.replace(/,/g, '')); + // const leftAsset = leftColumn.querySelector('.ticker')?.innerHTML.toUpperCase().trim() as SwapAsset; + + // const rightValue = Number((rightAmountElement as HTMLDivElement).innerText.replace(/,/g, '')); + // const rightAsset = rightColumn.querySelector('.ticker')?.innerHTML.toUpperCase().trim() as SwapAsset; + + // console.log(`[Demo] Setting up swap: ${leftValue} ${leftAsset} -> ${rightValue} ${rightAsset}`); + + // // Check if we have valid values + // if (!leftValue || !rightValue || !leftAsset || !rightAsset) { + // console.warn('[Demo] Missing swap values'); + // return {}; + // } + + // const direction = leftValue < rightValue ? 'forward' : 'reverse'; + // const fromAsset = direction === 'forward' ? leftAsset : rightAsset; + // // @ts-expect-error Object key not specific enough? + // const toAsset: RequestAsset = direction === 'forward' + // ? { [rightAsset]: rightValue } + // : { [leftAsset]: leftValue }; + + // const swapSuggestion = await createSwap(fromAsset, toAsset); + + // const { config } = useConfig(); + + // let fund: HtlcCreationInstructions | null = null; + // let redeem: HtlcSettlementInstructions | null = null; + + // const { activeAddressInfo } = useAddressStore(); + + // const { availableExternalAddresses } = useBtcAddressStore(); + // const nimAddress = activeAddressInfo.value!.address; + // const btcAddress = availableExternalAddresses.value[0]; + + // if (swapSuggestion.from.asset === SwapAsset.NIM) { + // const nimiqClient = await getNetworkClient(); + // await nimiqClient.waitForConsensusEstablished(); + // const headHeight = await nimiqClient.getHeadHeight(); + // if (headHeight > 100) { + // useNetworkStore().state.height = headHeight; + // } else { + // throw new Error('Invalid network state, try please reloading the app'); + // } + + // fund = { + // type: SwapAsset.NIM, + // sender: nimAddress, + // value: swapSuggestion.from.amount, + // fee: swapSuggestion.from.fee, + // validityStartHeight: useNetworkStore().state.height, + // }; + // } + + // const { accountUtxos, accountBalance: accountBtcBalance, } = useBtcAddressStore(); + // if (swapSuggestion.from.asset === SwapAsset.BTC) { + // const electrumClient = await getElectrumClient(); + // await electrumClient.waitForConsensusEstablished(); + + // // Assemble BTC inputs + // // Transactions to an HTLC are 46 weight units bigger because of the longer recipient address + // const requiredInputs = selectOutputs( + // accountUtxos.value, swapSuggestion.from.amount, swapSuggestion.from.feePerUnit, 48); + // let changeAddress: string; + // if (requiredInputs.changeAmount > 0) { + // const { nextChangeAddress } = useBtcAddressStore(); + // if (!nextChangeAddress.value) { + // // FIXME: If no unused change address is found, need to request new ones from Hub! + // throw new Error('No more unused change addresses (not yet implemented)'); + // } + // changeAddress = nextChangeAddress.value; + // } + + // fund = { + // type: SwapAsset.BTC, + // inputs: requiredInputs.utxos.map((utxo) => ({ + // address: utxo.address, + // transactionHash: utxo.transactionHash, + // outputIndex: utxo.index, + // outputScript: utxo.witness.script, + // value: utxo.witness.value, + // })), + // output: { + // value: swapSuggestion.from.amount, + // }, + // ...(requiredInputs.changeAmount > 0 ? { + // changeOutput: { + // address: changeAddress!, + // value: requiredInputs.changeAmount, + // }, + // } : {}), + // refundAddress: btcAddress, + // }; + // } + + // const { + // activeAddress: activePolygonAddress, + // accountUsdcBalance, + // accountUsdtBridgedBalance, + // } = usePolygonAddressStore(); + + // if (swapSuggestion.from.asset === SwapAsset.USDC_MATIC) { + // const [client, htlcContract] = await Promise.all([ + // getPolygonClient(), + // getUsdcHtlcContract(), + // ]); + // const fromAddress = activePolygonAddress.value!; + + // const [ + // usdcNonce, + // forwarderNonce, + // blockHeight, + // ] = await Promise.all([ + // client.usdcToken.nonces(fromAddress) as Promise, + // htlcContract.getNonce(fromAddress) as Promise, + // getPolygonBlockNumber(), + // ]); + + // const { fee, gasLimit, gasPrice, relay, method } = { + // fee: 1, + // gasLimit: 1, + // gasPrice: 1, + // relay: { + // pctRelayFee: 1, + // baseRelayFee: 1, + // relayWorkerAddress: '0x0000000000111111111122222222223333333333', + // }, + // method: 'open' + // }; + // if (method !== 'open' && method !== 'openWithPermit') { + // throw new Error('Wrong USDC contract method'); + // } + + // // Zeroed data fields are replaced by Fastspot's proposed data (passed in from Hub) in + // // Keyguard's SwapIFrameApi. + // const data = htlcContract.interface.encodeFunctionData(method, [ + // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* address token */ config.polygon.usdc.tokenContract, + // /* uint256 amount */ swapSuggestion.from.amount, + // /* address refundAddress */ fromAddress, + // /* address recipientAddress */ '0x0000000000000000000000000000000000000000', + // /* bytes32 hash */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* uint256 timeout */ 0, + // /* uint256 fee */ fee, + // ...(method === 'openWithPermit' ? [ + // // // Approve the maximum possible amount so afterwards we can use the `open` method for + // // // lower fees + // // /* uint256 value */ client.ethers + // // .BigNumber.from('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), + // /* uint256 value */ swapSuggestion.from.amount + fee, + + // /* bytes32 sigR */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* bytes32 sigS */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* uint8 sigV */ 0, + // ] : []), + // ]); + + // const relayRequest: RelayRequest = { + // request: { + // from: fromAddress, + // to: config.polygon.usdc.htlcContract, + // data, + // value: '0', + // nonce: forwarderNonce.toString(), + // gas: gasLimit.toString(), + // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) + // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) + // }, + // relayData: { + // gasPrice: gasPrice.toString(), + // pctRelayFee: relay.pctRelayFee.toString(), + // baseRelayFee: relay.baseRelayFee.toString(), + // relayWorker: relay.relayWorkerAddress, + // paymaster: config.polygon.usdc.htlcContract, + // paymasterData: '0x', + // clientId: Math.floor(Math.random() * 1e6).toString(10), + // forwarder: config.polygon.usdc.htlcContract, + // }, + // }; + + // fund = { + // type: SwapAsset.USDC_MATIC, + // ...relayRequest, + // ...(method === 'openWithPermit' ? { + // permit: { + // tokenNonce: usdcNonce.toNumber(), + // }, + // } : null), + // }; + // } + + // if (swapSuggestion.from.asset === SwapAsset.USDT_MATIC) { + // const [client, htlcContract] = await Promise.all([ + // getPolygonClient(), + // getUsdtBridgedHtlcContract(), + // ]); + // const fromAddress = activePolygonAddress.value!; + + // const [ + // usdtNonce, + // forwarderNonce, + // blockHeight, + // ] = await Promise.all([ + // client.usdtBridgedToken.getNonce(fromAddress) as Promise, + // htlcContract.getNonce(fromAddress) as Promise, + // getPolygonBlockNumber(), + // ]); + + // const { fee, gasLimit, gasPrice, relay, method } = { + // fee: 1, + // gasLimit: 1, + // gasPrice: 1, + // relay: { + // pctRelayFee: 1, + // baseRelayFee: 1, + // relayWorkerAddress: '0x0000000000111111111122222222223333333333', + // }, + // method: 'open' + // }; + // if (method !== 'open' && method !== 'openWithApproval') { + // throw new Error('Wrong USDT contract method'); + // } + + // // Zeroed data fields are replaced by Fastspot's proposed data (passed in from Hub) in + // // Keyguard's SwapIFrameApi. + // const data = htlcContract.interface.encodeFunctionData(method, [ + // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* address token */ config.polygon.usdt_bridged.tokenContract, + // /* uint256 amount */ swapSuggestion.from.amount, + // /* address refundAddress */ fromAddress, + // /* address recipientAddress */ '0x0000000000000000000000000000000000000000', + // /* bytes32 hash */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* uint256 timeout */ 0, + // /* uint256 fee */ fee, + // ...(method === 'openWithApproval' ? [ + // // // Approve the maximum possible amount so afterwards we can use the `open` method for + // // // lower fees + // // /* uint256 approval */ client.ethers + // // .BigNumber.from('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), + // /* uint256 approval */ swapSuggestion.from.amount + fee, + + // /* bytes32 sigR */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* bytes32 sigS */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* uint8 sigV */ 0, + // ] : []), + // ]); + + // const relayRequest: RelayRequest = { + // request: { + // from: fromAddress, + // to: config.polygon.usdt_bridged.htlcContract, + // data, + // value: '0', + // nonce: forwarderNonce.toString(), + // gas: gasLimit.toString(), + // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) + // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) + // }, + // relayData: { + // gasPrice: gasPrice.toString(), + // pctRelayFee: relay.pctRelayFee.toString(), + // baseRelayFee: relay.baseRelayFee.toString(), + // relayWorker: relay.relayWorkerAddress, + // paymaster: config.polygon.usdt_bridged.htlcContract, + // paymasterData: '0x', + // clientId: Math.floor(Math.random() * 1e6).toString(10), + // forwarder: config.polygon.usdt_bridged.htlcContract, + // }, + // }; + + // fund = { + // type: SwapAsset.USDT_MATIC, + // ...relayRequest, + // ...(method === 'openWithApproval' ? { + // approval: { + // tokenNonce: usdtNonce.toNumber(), + // }, + // } : null), + // }; + // } + + // if (swapSuggestion.to.asset === SwapAsset.NIM) { + // const nimiqClient = await getNetworkClient(); + // await nimiqClient.waitForConsensusEstablished(); + // const headHeight = await nimiqClient.getHeadHeight(); + // if (headHeight > 100) { + // useNetworkStore().state.height = headHeight; + // } else { + // throw new Error('Invalid network state, try please reloading the app'); + // } + + // redeem = { + // type: SwapAsset.NIM, + // recipient: nimAddress, // My address, must be redeem address of HTLC + // value: swapSuggestion.to.amount - swapSuggestion.to.fee, // Luna + // fee: swapSuggestion.to.fee, // Luna + // validityStartHeight: useNetworkStore().state.height, + // }; + // } + + // if (swapSuggestion.to.asset === SwapAsset.BTC) { + // const electrumClient = await getElectrumClient(); + // await electrumClient.waitForConsensusEstablished(); + + // redeem = { + // type: SwapAsset.BTC, + // input: { + // // transactionHash: transaction.transactionHash, + // // outputIndex: output.index, + // // outputScript: output.script, + // value: swapSuggestion.to.amount, // Sats + // }, + // output: { + // address: btcAddress, // My address, must be redeem address of HTLC + // value: swapSuggestion.to.amount - swapSuggestion.to.fee, // Sats + // }, + // }; + // } + + // if (swapSuggestion.to.asset === SwapAsset.USDC_MATIC) { + // const htlcContract = await getUsdcHtlcContract(); + // const toAddress = activePolygonAddress.value!; + + // const [ + // forwarderNonce, + // blockHeight, + // ] = await Promise.all([ + // htlcContract.getNonce(toAddress) as Promise, + // getPolygonBlockNumber(), + // ]); + + // const { fee, gasLimit, gasPrice, relay, method } = { + // fee: 1, + // gasLimit: 1, + // gasPrice: 1, + // relay: { + // pctRelayFee: 1, + // baseRelayFee: 1, + // relayWorkerAddress: '0x0000000000111111111122222222223333333333', + // }, + // method: 'open' + // }; + // if (method !== 'redeemWithSecretInData') { + // throw new Error('Wrong USDC contract method'); + // } + + // const data = htlcContract.interface.encodeFunctionData(method, [ + // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* address target */ toAddress, + // /* uint256 fee */ fee, + // ]); + + // const relayRequest: RelayRequest = { + // request: { + // from: toAddress, + // to: config.polygon.usdc.htlcContract, + // data, + // value: '0', + // nonce: forwarderNonce.toString(), + // gas: gasLimit.toString(), + // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) + // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) + // }, + // relayData: { + // gasPrice: gasPrice.toString(), + // pctRelayFee: relay.pctRelayFee.toString(), + // baseRelayFee: relay.baseRelayFee.toString(), + // relayWorker: relay.relayWorkerAddress, + // paymaster: config.polygon.usdc.htlcContract, + // paymasterData: '0x', + // clientId: Math.floor(Math.random() * 1e6).toString(10), + // forwarder: config.polygon.usdc.htlcContract, + // }, + // }; + + // redeem = { + // type: SwapAsset.USDC_MATIC, + // ...relayRequest, + // amount: swapSuggestion.to.amount - swapSuggestion.to.fee, + // }; + // } + + // if (swapSuggestion.to.asset === SwapAsset.USDT_MATIC) { + // const htlcContract = await getUsdtBridgedHtlcContract(); + // const toAddress = activePolygonAddress.value!; + + // const [ + // forwarderNonce, + // blockHeight, + // ] = await Promise.all([ + // htlcContract.getNonce(toAddress) as Promise, + // getPolygonBlockNumber(), + // ]); + + // const { fee, gasLimit, gasPrice, relay, method } = { + // fee: 1, + // gasLimit: 1, + // gasPrice: 1, + // relay: { + // pctRelayFee: 1, + // baseRelayFee: 1, + // relayWorkerAddress: '0x0000000000111111111122222222223333333333', + // }, + // method: 'open' + // }; + // if (method !== 'redeemWithSecretInData') { + // throw new Error('Wrong USDT contract method'); + // } + + // const data = htlcContract.interface.encodeFunctionData(method, [ + // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', + // /* address target */ toAddress, + // /* uint256 fee */ fee, + // ]); + + // const relayRequest: RelayRequest = { + // request: { + // from: toAddress, + // to: config.polygon.usdt_bridged.htlcContract, + // data, + // value: '0', + // nonce: forwarderNonce.toString(), + // gas: gasLimit.toString(), + // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) + // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) + // }, + // relayData: { + // gasPrice: gasPrice.toString(), + // pctRelayFee: relay.pctRelayFee.toString(), + // baseRelayFee: relay.baseRelayFee.toString(), + // relayWorker: relay.relayWorkerAddress, + // paymaster: config.polygon.usdt_bridged.htlcContract, + // paymasterData: '0x', + // clientId: Math.floor(Math.random() * 1e6).toString(10), + // forwarder: config.polygon.usdt_bridged.htlcContract, + // }, + // }; + + // redeem = { + // type: SwapAsset.USDT_MATIC, + // ...relayRequest, + // amount: swapSuggestion.to.amount - swapSuggestion.to.fee, + // }; + // } + + // if (!fund || !redeem) { + // reject(new Error('UNEXPECTED: No funding or redeeming data objects')); + // return; + // } + + // const serviceSwapFee = Math.round( + // (swapSuggestion.from.amount - swapSuggestion.from.serviceNetworkFee) + // * swapSuggestion.serviceFeePercentage, + // ); + + // const { addressInfos } = useAddressStore(); + + // const { activeAccountInfo } = useAccountStore(); + + // const { currency, exchangeRates } = useFiatStore(); + + // const { + // activeAddress + + // } = usePolygonAddressStore(); + + // const request: Omit = { + // accountId: activeAccountInfo.value!.id, + // swapId: swapSuggestion.id, + // fund, + // redeem, + + // layout: 'slider', + // direction: direction === 'forward' ? 'left-to-right' : 'right-to-left', + // fiatCurrency: currency.value, + // fundingFiatRate: exchangeRates.value[assetToCurrency( + // fund.type as SupportedSwapAsset, + // )][currency.value]!, + // redeemingFiatRate: exchangeRates.value[assetToCurrency( + // redeem.type as SupportedSwapAsset, + // )][currency.value]!, + // fundFees: { + // processing: 0, + // redeeming: swapSuggestion.from.serviceNetworkFee, + // }, + // redeemFees: { + // funding: swapSuggestion.to.serviceNetworkFee, + // processing: 0, + // }, + // serviceSwapFee, + // nimiqAddresses: addressInfos.value.map((addressInfo) => ({ + // address: addressInfo.address, + // balance: addressInfo.balance || 0, + // })), + // bitcoinAccount: { + // balance: accountBtcBalance.value, + // }, + // polygonAddresses: activePolygonAddress.value ? [{ + // address: activePolygonAddress.value, + // usdcBalance: accountUsdcBalance.value, + // usdtBalance: accountUsdtBridgedBalance.value, + // }] : [], + // }; + + // console.log('[Demo] Faking swap setup with request:', request); + // resolve(request) + // return; + // } + + // Wait for router readiness + await new Promise((resolve) => { + demoRouter.onReady(resolve); }); - } + + // eslint-disable-next-line no-console + console.log('[Demo] Redirecting to fallback modal'); + demoRouter.push(`/${DemoModal.Fallback}`); + }); } return target[prop]; }, From 34c75ba44c23086fb3cdf5d925d9275cca333c37 Mon Sep 17 00:00:00 2001 From: onmax Date: Mon, 10 Mar 2025 12:25:02 +0100 Subject: [PATCH 05/31] chore(demo): streamlined transactions functions --- src/stores/Demo.ts | 1527 +++++++++++++++++--------------------------- 1 file changed, 581 insertions(+), 946 deletions(-) diff --git a/src/stores/Demo.ts b/src/stores/Demo.ts index 711f3a123..c6a6f417c 100644 --- a/src/stores/Demo.ts +++ b/src/stores/Demo.ts @@ -3,14 +3,13 @@ import { createStore } from 'pinia'; import VueRouter from 'vue-router'; import { TransactionState as ElectrumTransactionState } from '@nimiq/electrum-client'; import { CryptoCurrency, Utf8Tools } from '@nimiq/utils'; -import { KeyPair, PlainTransactionDetails, PrivateKey } from '@nimiq/core'; -import { STAKING_CONTRACT_ADDRESS } from '@/lib/Constants'; +import { Address, KeyPair, PlainTransactionDetails, PlainTransactionRecipientData, PrivateKey, TransactionFormat } from '@nimiq/core'; import { AccountType, useAccountStore } from '@/stores/Account'; import { AddressType, useAddressStore } from '@/stores/Address'; -import { toSecs, useTransactionsStore } from '@/stores/Transactions'; -import { useBtcTransactionsStore } from '@/stores/BtcTransactions'; -import { useUsdtTransactionsStore, TransactionState as UsdtTransactionState } from '@/stores/UsdtTransactions'; -import { useUsdcTransactionsStore, TransactionState as UsdcTransactionState } from '@/stores/UsdcTransactions'; +import { toSecs, type Transaction as NimTransaction, useTransactionsStore } from '@/stores/Transactions'; +import { useBtcTransactionsStore, type Transaction as BtcTransaction } from '@/stores/BtcTransactions'; +import { useUsdtTransactionsStore, TransactionState as UsdtTransactionState, type Transaction as UsdtTransaction } from '@/stores/UsdtTransactions'; +import { useUsdcTransactionsStore, TransactionState as UsdcTransactionState, type Transaction as UsdcTransaction } from '@/stores/UsdcTransactions'; import { useStakingStore } from '@/stores/Staking'; import { useAccountSettingsStore } from '@/stores/AccountSettings'; import { usePolygonAddressStore } from '@/stores/PolygonAddress'; @@ -24,6 +23,8 @@ import { useUsdcContactsStore } from './UsdcContacts'; import { useUsdtContactsStore } from './UsdtContacts'; import { useFiatStore } from './Fiat'; import { SwapState, useSwapsStore } from './Swaps'; +import { getUsdcHtlcContract } from '@/ethers'; +import { useConfig } from '@/composables/useConfig'; export type DemoState = { active: boolean, @@ -57,68 +58,19 @@ const demoPolygonAddress = '0xabc123DemoPolygonAddress'; const buyFromAddress = 'NQ04 JG63 HYXL H3QF PPNA 7ED7 426M 3FQE FHE5'; // We keep this as our global/final balance, which should result from the transactions -const nimInitialBalance = 14041800000; // 14,041,800,000 - 14 april, 2018 -const usdtInitialBalance = 5000000000; // 5000 USDT (6 decimals) -const usdcInitialBalance = 3000000000; // 3000 USDC (6 decimals) +const nimInitialBalance = 140_418_00000; // 14,041,800,000 - 14 april, 2018. 5 decimals. +const btcInitialBalance = 1_0000000; // 1 BTC (8 decimals) +const usdtInitialBalance = 5_000_000000; // 5000 USDT (6 decimals) +const usdcInitialBalance = 3_000_000000; // 3000 USDC (6 decimals) // Swaps const onGoingSwaps = new Map(); -let swapInterval: NodeJS.Timeout | null = null; -function listenForSwapChanges() { - if (swapInterval) return; - swapInterval = setInterval(() => { - const swap = useSwapsStore().activeSwap.value; - if (!swap) return; - console.log('[Demo] Active swap:', { swap, state: swap.state }); - switch (swap.state) { - case SwapState.AWAIT_INCOMING: - console.log('[Demo] Swap is in AWAIT_INCOMING state'); - useSwapsStore().setActiveSwap({ - ...swap, - state: SwapState.CREATE_OUTGOING, - }); - break; - case SwapState.CREATE_OUTGOING: - console.log('[Demo] Swap is in CREATE_OUTGOING state'); - useSwapsStore().setActiveSwap({ - ...swap, - state: SwapState.AWAIT_SECRET, - }); - break; - case SwapState.AWAIT_SECRET: - console.log('[Demo] Swap is in AWAIT_SECRET state'); - useSwapsStore().setActiveSwap({ - ...swap, - state: SwapState.SETTLE_INCOMING, - }); - break; - case SwapState.SETTLE_INCOMING: - console.log('[Demo] Swap is in SETTLE_INCOMING state'); - useSwapsStore().setActiveSwap({ - ...swap, - state: SwapState.COMPLETE, - }); - break; - case SwapState.COMPLETE: - console.log('[Demo] Swap is in COMPLETE state'); - if (swapInterval)clearInterval(swapInterval); - swapInterval = null; - break; - default: - console.log('[Demo] Swap is in unknown state'); - useSwapsStore().setActiveSwap({ - ...swap, - state: SwapState.AWAIT_INCOMING, - }); - break; - } - }, 15000); -} - // We keep a reference to the router here. let demoRouter: VueRouter; +// #region Store definition + /** * Main store for the demo environment. */ @@ -149,37 +101,44 @@ export const useDemoStore = createStore({ setupDemoAddresses(); setupDemoAccount(); - generateFakeNimTransactions(); + insertFakeNimTransactions(); + insertFakeBtcTransactions(); - const { addTransactions: addBtcTransactions } = useBtcTransactionsStore(); - addBtcTransactions(generateFakeBtcTransactions()); + if (useConfig().config.polygon.enabled) { + insertFakeUsdcTransactions(); + insertFakeUsdtTransactions(); + } attachIframeListeners(); replaceStakingFlow(); replaceBuyNimFlow(); enableSendModalInDemoMode(); + + listenForSwapChanges(); }, /** * Adds a pretend buy transaction to show a deposit coming in. */ async buyDemoNim(amount: number) { - const { addTransactions } = useTransactionsStore(); - addTransactions([ - createFakeTransaction({ - value: amount, - recipient: demoNimAddress, - sender: buyFromAddress, - data: { - type: 'raw', - raw: encodeTextToHex('Online Purchase'), - }, - }), - ]); + const tx: Partial = { + value: amount, + recipient: demoNimAddress, + sender: buyFromAddress, + data: { + type: 'raw', + raw: encodeTextToHex('Online Purchase'), + }, + }; + insertFakeNimTransactions(transformNimTransaction([tx])); }, }, }); +// #endregion + +// #region App setup + /** * Checks if the 'demo' query param is present in the URL. */ @@ -287,9 +246,6 @@ function setupDemoAddresses() { balanceUsdtBridged: usdtInitialBalance, pol: 1, }]); - - // Setup polygon token transactions - generateFakePolygonTransactions(); } /** @@ -319,37 +275,109 @@ function setupDemoAccount() { setActiveCurrency(CryptoCurrency.NIM); } +enum MessageEventName { + FlowChange = 'FlowChange' +} + +/** + * Listens for messages from iframes (or parent frames) about changes in the user flow. + */ +function attachIframeListeners() { + window.addEventListener('message', (event) => { + if (!event.data || typeof event.data !== 'object') return; + const { kind, data } = event.data as DemoFlowMessage; + if (kind === MessageEventName.FlowChange && demoRoutes[data]) { + useAccountStore().setActiveCurrency(CryptoCurrency.NIM); + demoRouter.push(demoRoutes[data]); + } + }); + + demoRouter.afterEach((to) => { + const match = Object.entries(demoRoutes).find(([, route]) => route === to.path); + if (!match) return; + window.parent.postMessage({ kind: MessageEventName.FlowChange, data: match[0] as DemoFlowType }, '*'); + }); +} + +// #endregion + +// #region NIM txs + +interface NimTransactionDefinition { + fraction: number; + daysAgo: number; + description: string; + recipientLabel?: string; +} + /** - * Generates fake NIM transactions spanning the last ~4 years. - * They net to the global nimInitialBalance. - * We use a helper function to calculate each transaction value so it doesn't end in 0. + * Defines transaction definitions for demo NIM transactions */ -function generateFakeNimTransactions() { - // We'll define total fractions so the net sum is 1. - const txDefinitions = [ - { fraction: -0.14, daysAgo: 1442, description: 'New Designer Backpack for Hiking Trip' }, - { fraction: -0.05, daysAgo: 1390, description: 'Streaming Subscription - Watch Anything Anytime', recipientLabel: 'Stream & Chill Co.' }, - { fraction: 0.1, daysAgo: 1370, description: 'Sold Vintage Camera to Photography Enthusiast', recipientLabel: 'Retro Photo Guy' }, - { fraction: -0.2, daysAgo: 1240, description: 'Groceries at Farmers Market' }, - { fraction: 0.2, daysAgo: 1185, description: 'Birthday Gift from Uncle Bob', recipientLabel: 'Uncle Bob 🎁' }, - { fraction: 0.3, daysAgo: 1120, description: 'Website Design Freelance Project', recipientLabel: 'Digital Nomad Inc.' }, - { fraction: 0.02, daysAgo: 980, description: 'Lunch Money Payback from Alex' }, - { fraction: 0.07, daysAgo: 940, description: 'Community Raffle Prize' }, - { fraction: -0.15, daysAgo: 875, description: 'Car Repair at Thunder Road Garage', recipientLabel: 'FixMyCar Workshop' }, - { fraction: -0.3, daysAgo: 780, description: 'Quarterly Apartment Rent', recipientLabel: 'Skyview Properties' }, - { fraction: -0.1, daysAgo: 720, description: 'Anniversary Dinner at Skyline Restaurant' }, - { fraction: -0.02, daysAgo: 650, description: 'Digital Book: "Blockchain for Beginners"' }, - { fraction: -0.03, daysAgo: 580, description: 'Music Festival Weekend Pass' }, - { fraction: 0.05, daysAgo: 540, description: 'Refund for Cancelled Flight' }, - { fraction: 0.5, daysAgo: 470, description: 'Software Development Project Payment', recipientLabel: 'Tech Solutions Ltd' }, - { fraction: 0.02, daysAgo: 390, description: 'Coffee Shop Reward Program Refund' }, - { fraction: -0.11, daysAgo: 320, description: 'Custom Tailored Suit Purchase' }, - { fraction: 0.06, daysAgo: 270, description: 'Website Testing Gig Payment' }, - { fraction: -0.08, daysAgo: 210, description: 'Electric Scooter Rental for Month' }, - { fraction: -0.12, daysAgo: 180, description: 'Online Course: "Advanced Crypto Trading"' }, - { fraction: 0.05, daysAgo: 120, description: 'Sold Digital Artwork', recipientLabel: 'NFT Collector' }, - { fraction: -0.1, daysAgo: 90, description: 'Quarterly Utility Bills', recipientLabel: 'City Power & Water' }, - { fraction: -0.1, daysAgo: 45, description: 'Winter Wardrobe Shopping' }, +function defineNimFakeTransactions(): Partial[] { + const txDefinitions: NimTransactionDefinition[] = [ + { fraction: -0.05, daysAgo: 0.4, description: 'Local cafe coffee' }, + { fraction: 0.1, daysAgo: 0.6, description: 'Red envelope from neighbor', recipientLabel: 'Neighbor' }, + { fraction: -0.03, daysAgo: 1, description: 'Food truck snack' }, + { fraction: -0.06, daysAgo: 2, description: 'Local store book' }, + { fraction: -0.01, daysAgo: 2, description: 'Chicken waffle', recipientLabel: 'Roberto\'s Waffle' }, + { fraction: -0.04, daysAgo: 3, description: 'Corner shop chai & snack' }, + { fraction: -0.12, daysAgo: 3, description: 'Thai massage session' }, + { fraction: -0.15, daysAgo: 6, description: 'Swedish flat-pack chair', recipientLabel: 'Furniture Mart' }, + { fraction: 0.1, daysAgo: 6, description: 'Red envelope from family', recipientLabel: 'Family' }, + { fraction: -0.08, daysAgo: 7, description: 'Cozy diner dinner', recipientLabel: 'Melissa' }, + { fraction: -0.02, daysAgo: 7, description: 'Coworker snack', recipientLabel: 'Coworker' }, + { fraction: -0.03, daysAgo: 8, description: 'Morning bus fare' }, + { fraction: -0.05, daysAgo: 8, description: 'Local fruit pack' }, + { fraction: 0.02, daysAgo: 10, description: 'Neighbor bill', recipientLabel: 'Neighbor' }, + { fraction: -0.07, daysAgo: 12, description: 'Movie ticket night' }, + { fraction: -0.04, daysAgo: 14, description: 'Trendy cafe coffee', recipientLabel: 'Cafe' }, + { fraction: -0.03, daysAgo: 15, description: 'Market street food snack' }, + { fraction: -0.1, daysAgo: 18, description: '' }, + { fraction: -0.05, daysAgo: 20, description: 'Street vendor souvenir' }, + { fraction: -0.08, daysAgo: 22, description: 'Quick haircut', recipientLabel: 'Barber' }, + { fraction: -0.04, daysAgo: 25, description: 'Local dessert', recipientLabel: 'Jose' }, + { fraction: -0.02, daysAgo: 27, description: 'Mall parking' }, + { fraction: -0.1, daysAgo: 30, description: 'Streaming subscription', recipientLabel: 'StreamCo' }, + { fraction: 0.03, daysAgo: 32, description: 'Mistaken charge refund', recipientLabel: 'Store' }, + { fraction: -0.06, daysAgo: 35, description: 'Taxi fare', recipientLabel: 'Crazy Taxi' }, + { fraction: -0.04, daysAgo: 38, description: 'Local shop mug' }, + { fraction: -0.01, daysAgo: 40, description: 'Local newspaper' }, + { fraction: -0.05, daysAgo: 42, description: 'Coworker ride', recipientLabel: 'Coworker' }, + { fraction: -0.07, daysAgo: 45, description: 'Bistro lunch' }, + { fraction: -0.12, daysAgo: 45, description: 'Weekly market shopping', recipientLabel: 'Market' }, + { fraction: -0.1, daysAgo: 50, description: 'Utility bill', recipientLabel: 'Utility Co.' }, + { fraction: -0.03, daysAgo: 55, description: 'Corner snack pack' }, + { fraction: -0.1, daysAgo: 60, description: 'Streaming subscription', recipientLabel: 'StreamCo' }, + { fraction: 0.05, daysAgo: 62, description: 'Client tip', recipientLabel: 'Client' }, + { fraction: -0.06, daysAgo: 65, description: 'Hair trim', recipientLabel: 'Barber' }, + { fraction: -0.09, daysAgo: 68, description: 'Takeaway meal' }, + { fraction: -0.04, daysAgo: 70, description: 'Stall fresh juice' }, + { fraction: -0.05, daysAgo: 72, description: 'Park picnic' }, + { fraction: -0.03, daysAgo: 75, description: 'Local event fee', recipientLabel: 'Event Org' }, + { fraction: -0.1, daysAgo: 78, description: 'Neighbors dinner', recipientLabel: 'Neighbors' }, + { fraction: -0.12, daysAgo: 80, description: 'New shoes sale' }, + { fraction: 0.1, daysAgo: 85, description: 'Festive cash gift', recipientLabel: 'Family' }, + { fraction: -0.1, daysAgo: 90, description: 'Streaming subscription', recipientLabel: 'StreamCo' }, + { fraction: -0.05, daysAgo: 95, description: 'Bakery fresh bread', recipientLabel: 'Bakery' }, + { fraction: -0.04, daysAgo: 100, description: 'Ice cream treat', recipientLabel: 'Ice Cream' }, + { fraction: -0.08, daysAgo: 110, description: 'Fitness class fee', recipientLabel: 'Gym' }, + { fraction: -0.03, daysAgo: 115, description: 'Meal discount' }, + { fraction: 0.04, daysAgo: 120, description: 'Double charge refund', recipientLabel: 'Store' }, + { fraction: -0.07, daysAgo: 125, description: 'Boutique trendy hat' }, + { fraction: -0.02, daysAgo: 130, description: 'Local cause donation', recipientLabel: 'Charity' }, + { fraction: -0.09, daysAgo: 140, description: 'Neighborhood dinner', recipientLabel: 'Food Joint' }, + { fraction: -0.05, daysAgo: 150, description: 'Gadget repair fee', recipientLabel: 'Repair Shop' }, + { fraction: -0.08, daysAgo: 200, description: 'Local play ticket', recipientLabel: 'Theater' }, + { fraction: -0.1, daysAgo: 250, description: 'Community event bill', recipientLabel: 'Community' }, + { fraction: 0.07, daysAgo: 300, description: 'Work bonus', recipientLabel: 'Employer' }, + { fraction: -0.04, daysAgo: 400, description: 'Local art fair entry' }, + { fraction: -0.06, daysAgo: 500, description: 'Online shop gadget', recipientLabel: 'Online Shop' }, + { fraction: -0.1, daysAgo: 600, description: 'Popular local dinner', recipientLabel: 'Diner' }, + { fraction: -0.12, daysAgo: 800, description: 'Home repair bill', recipientLabel: 'Repair Co.' }, + { fraction: 0.15, daysAgo: 1000, description: 'Freelance project check', recipientLabel: 'Client' }, + { fraction: -0.09, daysAgo: 1200, description: 'Local kitchen gear', recipientLabel: 'Kitchen Shop' }, + { fraction: -0.1, daysAgo: 1442, description: 'Family reunion dinner', recipientLabel: 'Family' }, + ]; // Calculate sum of existing transactions to ensure they add up to exactly 1 @@ -360,7 +388,7 @@ function generateFakeNimTransactions() { if (Math.abs(remainingFraction) > 0.001) { // Only add if there's a meaningful amount to balance txDefinitions.push({ fraction: remainingFraction, - daysAgo: 14, + daysAgo: 1450, description: remainingFraction > 0 ? 'Blockchain Hackathon Prize!' : 'Annual Software Subscription Renewal', @@ -368,8 +396,8 @@ function generateFakeNimTransactions() { }); } - const { addTransactions } = useTransactionsStore(); const { setContact } = useContactsStore(); + const txs: Partial[] = []; for (const def of txDefinitions) { let txValue = Math.floor(nimInitialBalance * def.fraction); @@ -387,125 +415,196 @@ function generateFakeNimTransactions() { const value = Math.abs(txValue); const timestamp = calculateDaysAgo(def.daysAgo); const tx: Partial = { value, recipient, sender, timestamp, data }; - addTransactions([createFakeTransaction(tx)]); - + txs.push(tx); // Add contact if a recipientLabel is provided if (def.recipientLabel && def.fraction < 0) { setContact(address, def.recipientLabel); } } + + return txs.sort((a, b) => a.timestamp! - b.timestamp!); +} + +let head = 0; +let nonce = 0; + +function transformNimTransaction(txs: Partial[]): NimTransaction[] { + return txs.map((tx) => { + head++; + nonce++; + + return { + network: 'mainnet', + state: 'confirmed', + transactionHash: `0x${nonce.toString(16)}`, + sender: '', + senderType: 'basic', + recipient: '', + recipientType: 'basic', + value: 50000000, + fee: 0, + feePerByte: 0, + format: 'basic', + validityStartHeight: head, + blockHeight: head, + flags: 0, + timestamp: Date.now(), + size: 0, + valid: true, + proof: { raw: '', type: 'raw' }, + data: tx.data || { type: 'raw', raw: '' }, + ...tx, + } + }); +} + +/** + * Inserts NIM transactions into the store. If no definitions provided, uses default demo transactions. + */ +function insertFakeNimTransactions(txs = defineNimFakeTransactions()) { + const { addTransactions } = useTransactionsStore(); + addTransactions(transformNimTransaction(txs)); +} + +// #region BTC txs + +interface BtcTransactionDefinition { + fraction: number; + daysAgo: number; + description: string; + recipientLabel?: string; + incoming: boolean; + address: string } /** - * Generates fake Bitcoin transactions spanning 5.5 years + * Defines transaction definitions for demo BTC transactions. + * Note: We add the "address" field so that incoming transactions use the correct sender address. */ -function generateFakeBtcTransactions() { - // Define transaction history with a similar structure to other currencies - const txDefinitions = [ +function defineBtcFakeTransactions(): BtcTransaction[] { + const txDefinitions: BtcTransactionDefinition[] = [ { + fraction: 0.2, daysAgo: 2000, - value: 20000000, // 0.2 BTC - incoming: true, description: 'Initial BTC purchase from exchange', + incoming: true, address: '1Kj4SNWFCxqvtP8nkJxeBwkXxgY9LW9rGg', - label: 'Satoshi Exchange', + recipientLabel: 'Satoshi Exchange' }, { + fraction: 0.15, daysAgo: 1600, - value: 15000000, // 0.15 BTC - incoming: true, description: 'Mining pool payout', + incoming: true, address: '1Hz7vQrRjnu3z9k7gxDYhKjEmABqChDvJr', - label: 'Genesis Mining Pool', + recipientLabel: 'Genesis Mining Pool' }, { + fraction: -0.19, daysAgo: 1200, - value: 19000000, // 0.19 BTC - incoming: false, description: 'Purchase from online marketplace', + incoming: false, address: '1LxKe5kKdgGVwXukEgqFxh6DrCXF2Pturc', - label: 'Digital Bazaar Shop', + recipientLabel: 'Digital Bazaar Shop' }, { + fraction: 0.3, daysAgo: 800, - value: 30000000, // 0.3 BTC - incoming: true, description: 'Company payment for consulting', + incoming: true, address: '1N7aecJuKGDXzYK8CgpnNRYxdhZvXPxp3B', - label: 'Corporate Treasury', + recipientLabel: 'Corporate Treasury' }, { + fraction: -0.15, daysAgo: 365, - value: 15000000, // 0.15 BTC - incoming: false, description: 'Auto-DCA investment program', - address: '12vxjmKJkfL9s5JwqUzEVVJGvKYJgALbsz', + incoming: false, + address: '12vxjmKJkfL9s5JwqUzEVVJGvKYJgALbsz' }, { + fraction: 0.075, daysAgo: 180, - value: 7500000, // 0.075 BTC - incoming: true, description: 'P2P sale of digital goods', - address: '1MZYS9nvVmFvSK7em5zzAsnvRq82RUcypS', + incoming: true, + address: '1MZYS9nvVmFvSK7em5zzAsnvRq82RUcypS' }, { + fraction: 0.05, daysAgo: 60, - value: 5000000, // 0.05 BTC - incoming: true, description: 'Recent purchase from exchange', - address: '1Kj4SNWFCxqvtP8nkJxeBwkXxgY9LW9rGg', + incoming: true, + address: '1Kj4SNWFCxqvtP8nkJxeBwkXxgY9LW9rGg' }, - ]; + ].sort((a, b) => b.daysAgo - a.daysAgo); - const { setSenderLabel } = useBtcLabelsStore(); + // If the sum of fractions does not add up to 1, add a balancing transaction. + const existingSum = txDefinitions.reduce((sum, def) => + sum + (def.incoming ? Math.abs(def.fraction) : -Math.abs(def.fraction)), 0); + const remainingFraction = 1 - existingSum; + if (Math.abs(remainingFraction) > 0.001) { + txDefinitions.push({ + fraction: remainingFraction, + daysAgo: 0, + description: remainingFraction > 0 ? 'Initial purchase' : 'Investment allocation', + incoming: remainingFraction > 0, + address: remainingFraction > 0 ? '1ExampleAddressForInitialPurchase' : '1ExampleAddressForInvestment', + recipientLabel: remainingFraction > 0 ? 'Prime Exchange' : 'Investment Fund', + }); + } + + return transformBtcTransaction(txDefinitions); +} - // Convert to BTC transaction format with inputs/outputs +/** + * Transforms BTC transaction definitions into actual transactions. + */ +function transformBtcTransaction(txDefinitions: BtcTransactionDefinition[]): BtcTransaction[] { const transactions = []; const knownUtxos = new Map(); let txCounter = 1; for (const def of txDefinitions) { - // Create a transaction hash for this transaction const txHash = `btc-tx-${txCounter++}`; + // Compute the value using the initial BTC balance and the fraction. + const value = Math.floor(btcInitialBalance * Math.abs(def.fraction)); - // Only add labels to select transactions to make the history look realistic - if (def.label) { - setSenderLabel(def.address, def.label); - } - - const tx = { + const tx: BtcTransaction = { + addresses: [demoBtcAddress], isCoinbase: false, inputs: [ { + // For incoming tx, use the external address; for outgoing, use our demo address. address: def.incoming ? def.address : demoBtcAddress, outputIndex: 0, index: 0, - script: 'abcd', + script: 'script', sequence: 4294967295, - transactionHash: def.incoming ? txHash : getUTXOToSpend(knownUtxos)?.txHash || txHash, - witness: ['abcd'], + transactionHash: def.incoming ? txHash : (getUTXOToSpend(knownUtxos)?.txHash || txHash), + witness: ['witness'], }, ], outputs: def.incoming ? [ { - value: def.value, + value, address: demoBtcAddress, - script: 'abcd', + script: 'script', index: 0, }, ] : [ { - value: def.value, + value, address: def.address, - script: 'abcd', + script: 'script', index: 0, }, - { // Change output - value: 900000, + { + // Change output. + value: 1000000, address: demoBtcAddress, - script: 'abcd', + script: 'script', index: 1, }, ], @@ -520,17 +619,21 @@ function generateFakeBtcTransactions() { state: ElectrumTransactionState.CONFIRMED, }; - // Update UTXOs for this transaction updateUTXOs(knownUtxos, tx); - transactions.push(tx); + + // Set labels if a recipient label is provided. + if (def.recipientLabel) { + const { setSenderLabel } = useBtcLabelsStore(); + setSenderLabel(def.incoming ? def.address : demoBtcAddress, def.recipientLabel); + } } - // Set up the address with current UTXOs + // Update the address store with the current UTXOs. const { addAddressInfos } = useBtcAddressStore(); addAddressInfos([{ address: demoBtcAddress, - txoCount: transactions.length + 2, // Total number of outputs received + txoCount: transactions.length + 2, // Total number of outputs received. utxos: Array.from(knownUtxos.values()), }]); @@ -538,10 +641,10 @@ function generateFakeBtcTransactions() { } /** - * Tracks UTXO changes for BTC transactions + * Tracks UTXO changes for BTC transactions. */ function updateUTXOs(knownUtxos: Map, tx: any) { - // Remove spent inputs + // Remove spent inputs. for (const input of tx.inputs) { if (input.address === demoBtcAddress) { const utxoKey = `${input.transactionHash}:${input.outputIndex}`; @@ -549,7 +652,7 @@ function updateUTXOs(knownUtxos: Map, tx: any) { } } - // Add new outputs for our address + // Add new outputs for our address. for (const output of tx.outputs) { if (output.address === demoBtcAddress) { const utxoKey = `${tx.transactionHash}:${output.index}`; @@ -566,7 +669,7 @@ function updateUTXOs(knownUtxos: Map, tx: any) { } /** - * Helper to get a UTXO to spend + * Helper to get a UTXO to spend. */ function getUTXOToSpend(knownUtxos: Map) { if (knownUtxos.size === 0) return null; @@ -579,131 +682,177 @@ function getUTXOToSpend(knownUtxos: Map) { } /** - * Generates fake transactions for both USDC and USDT on Polygon + * Insert fake BTC transactions into the store. */ -function generateFakePolygonTransactions() { - // Generate USDC transactions - const usdcTxDefinitions = [ - { fraction: 0.3, daysAgo: 910, incoming: true, label: 'Yield Farming Protocol' }, - { fraction: -0.05, daysAgo: 840 }, - { fraction: 0.2, daysAgo: 730, incoming: true, label: 'Virtual Worlds Inc.' }, - { fraction: -0.1, daysAgo: 650 }, - { fraction: 0.4, daysAgo: 480, incoming: true, label: 'Innovation DAO' }, - { fraction: -0.15, daysAgo: 360, label: 'MetaGames Marketplace' }, - { fraction: 0.15, daysAgo: 180, incoming: true }, - { fraction: -0.08, daysAgo: 90 }, - ]; +function insertFakeBtcTransactions(txs = defineBtcFakeTransactions()) { + const { addTransactions } = useBtcTransactionsStore(); + addTransactions(txs); +} - // Calculate sum of existing transactions and add balancing transaction - const usdcExistingSum = usdcTxDefinitions.reduce((sum, def) => - sum + (def.incoming ? Math.abs(def.fraction) : -Math.abs(def.fraction)), 0); - const usdcRemainingFraction = 1 - usdcExistingSum; - - // Add balancing transaction - if (Math.abs(usdcRemainingFraction) > 0.001) { - usdcTxDefinitions.push({ - fraction: usdcRemainingFraction, - daysAgo: 30, - incoming: usdcRemainingFraction > 0, - label: usdcRemainingFraction > 0 ? 'CryptoCreators Guild' : 'Annual Platform Subscription', - }); - } - // Generate USDT transactions - const usdtTxDefinitions = [ - { fraction: 0.4, daysAgo: 360, incoming: true, label: 'LaunchPad Exchange' }, - { fraction: -0.1, daysAgo: 320 }, - { fraction: 0.2, daysAgo: 280, incoming: true, label: 'Crypto Consultants LLC' }, - { fraction: -0.15, daysAgo: 210 }, - { fraction: 0.3, daysAgo: 150, incoming: true }, - { fraction: -0.05, daysAgo: 90 }, - { fraction: 0.1, daysAgo: 45, incoming: true, label: 'Chain Testers' }, - { fraction: -0.12, daysAgo: 20 }, +// #endregion + +// #region Polygon txs + +const getRandomPolygonHash = () => `0x${Math.random().toString(16).slice(2, 66)}`; +const getRandomPolygonAddress = () => `0x${Math.random().toString(16).slice(2, 42)}`; + +interface UsdcTransactionDefinition { + fraction: number; + daysAgo: number; + description: string; + recipientLabel?: string; + incoming: boolean; +} + +/** + * Defines transaction definitions for demo USDC transactions + */ +function defineUsdcFakeTransactions(): UsdcTransaction[] { + const txDefinitions: UsdcTransactionDefinition[] = [ + { fraction: 0.3, daysAgo: 0, description: 'DeFi yield harvest', incoming: true, recipientLabel: 'Yield Protocol' }, + { fraction: -0.1, daysAgo: 2, description: 'NFT marketplace purchase', incoming: false, recipientLabel: 'NFT Market' }, + { fraction: 0.2, daysAgo: 5, description: 'Bridge transfer', incoming: true, recipientLabel: 'Polygon Bridge' }, + { fraction: -0.15, daysAgo: 10, description: 'DEX liquidity provision', incoming: false }, + { fraction: 0.25, daysAgo: 20, description: 'Staking rewards', incoming: true, recipientLabel: 'Staking Pool' }, + { fraction: -0.12, daysAgo: 30, description: 'GameFi item purchase', incoming: false }, + { fraction: 0.3, daysAgo: 45, description: 'DAO compensation', incoming: true, recipientLabel: 'Treasury DAO' }, + { fraction: -0.2, daysAgo: 60, description: 'Lending deposit', incoming: false, recipientLabel: 'Lending Protocol' }, ]; - // Calculate sum of existing transactions and add balancing transaction - const usdtExistingSum = usdtTxDefinitions.reduce((sum, def) => + const existingSum = txDefinitions.reduce((sum, def) => sum + (def.incoming ? Math.abs(def.fraction) : -Math.abs(def.fraction)), 0); - const usdtRemainingFraction = 1 - usdtExistingSum; - - // Add balancing transaction - if (Math.abs(usdtRemainingFraction) > 0.001) { - usdtTxDefinitions.push({ - fraction: usdtRemainingFraction, - daysAgo: 7, - incoming: usdtRemainingFraction > 0, - label: usdtRemainingFraction > 0 ? 'Protocol Testing Reward' : 'Annual Service Renewal', + const remainingFraction = 1 - existingSum; + + if (Math.abs(remainingFraction) > 0.001) { + txDefinitions.push({ + fraction: remainingFraction, + daysAgo: remainingFraction > 0 ? 90 : 100, + description: remainingFraction > 0 ? 'Initial bridge deposit' : 'Protocol investment', + incoming: remainingFraction > 0, + recipientLabel: remainingFraction > 0 ? 'Bridge Service' : 'DeFi Protocol', }); } - // Generate and add USDC transactions - const { addTransactions: addUsdcTxs } = useUsdcTransactionsStore(); - addUsdcTxs(generateTokenTransactions(usdcTxDefinitions, usdcInitialBalance, Config.polygon.usdc.tokenContract, UsdcTransactionState.CONFIRMED, useUsdcContactsStore)); - - // Generate and add USDT transactions - const { addTransactions: addUsdtTxs } = useUsdtTransactionsStore(); - addUsdtTxs(generateTokenTransactions(usdtTxDefinitions, usdtInitialBalance, Config.polygon.usdt_bridged.tokenContract, UsdtTransactionState.CONFIRMED, useUsdtContactsStore)); + return transformUsdcTransaction(txDefinitions); } /** - * Shared function to generate token transactions for Polygon tokens + * Transform USDC transaction definitions into actual transactions */ -function generateTokenTransactions(txDefinitions: any, initialBalance: number, tokenContract: string, confirmState: any, contactsStore: any) { - const transactions = []; - const { setContact } = contactsStore(); - - for (const def of txDefinitions) { - const value = Math.floor(initialBalance * Math.abs(def.fraction)); - const randomAddress = `0x${Math.random().toString(16).slice(2, 42)}`; - const sender = def.incoming ? randomAddress : demoPolygonAddress; - const recipient = def.incoming ? demoPolygonAddress : randomAddress; - - // Add contacts for select transactions (only if label is provided) - if (def.label) { - const addressToLabel = def.incoming ? randomAddress : randomAddress; - setContact(addressToLabel, def.label); +function transformUsdcTransaction(txDefinitions: UsdcTransactionDefinition[]): UsdcTransaction[] { + return txDefinitions.map((def, index) => { + const value = Math.floor(usdcInitialBalance * Math.abs(def.fraction)); + const randomAddress = def.incoming ? getRandomPolygonAddress() : demoPolygonAddress; + const recipientAddress = def.incoming ? demoPolygonAddress : getRandomPolygonAddress(); + + if (def.recipientLabel) { + const { setContact } = useUsdcContactsStore(); + setContact(def.incoming ? randomAddress : recipientAddress, def.recipientLabel); } - transactions.push({ - token: tokenContract, - transactionHash: `token-tx-${Math.random().toString(36).substr(2, 9)}`, - logIndex: transactions.length, - sender, - recipient, + return { + token: Config.polygon.usdc.tokenContract, + transactionHash: getRandomPolygonHash(), + logIndex: index, + sender: randomAddress, + recipient: recipientAddress, value, - state: confirmState, - blockHeight: 1000000 + transactions.length, + state: UsdcTransactionState.CONFIRMED, + blockHeight: 1000000 + index, timestamp: toSecs(calculateDaysAgo(def.daysAgo)), - }); - } + }; + }); +} - return transactions; +/** + * Insert fake USDC transactions into the store + */ +function insertFakeUsdcTransactions(txs = defineUsdcFakeTransactions()) { + const { addTransactions } = useUsdcTransactionsStore(); + addTransactions(txs); } -enum MessageEventName { - FlowChange = 'FlowChange' +interface UsdtTransactionDefinition { + fraction: number; + daysAgo: number; + description: string; + recipientLabel?: string; + incoming: boolean; } /** - * Listens for messages from iframes (or parent frames) about changes in the user flow. + * Defines transaction definitions for demo USDT transactions */ -function attachIframeListeners() { - window.addEventListener('message', (event) => { - if (!event.data || typeof event.data !== 'object') return; - const { kind, data } = event.data as DemoFlowMessage; - if (kind === MessageEventName.FlowChange && demoRoutes[data]) { - useAccountStore().setActiveCurrency(CryptoCurrency.NIM); - demoRouter.push(demoRoutes[data]); +function defineUsdtFakeTransactions(): UsdtTransaction[] { + const txDefinitions: UsdtTransactionDefinition[] = [ + { fraction: 0.25, daysAgo: 0, description: 'Trading profit', incoming: true, recipientLabel: 'DEX Trading' }, + { fraction: -0.08, daysAgo: 3, description: 'Merchandise payment', incoming: false }, + { fraction: 0.15, daysAgo: 7, description: 'Freelance payment', incoming: true, recipientLabel: 'Client Pay' }, + { fraction: -0.12, daysAgo: 15, description: 'Service subscription', incoming: false, recipientLabel: 'Web3 Service' }, + { fraction: 0.3, daysAgo: 25, description: 'P2P exchange', incoming: true }, + { fraction: -0.18, daysAgo: 35, description: 'Platform fees', incoming: false }, + { fraction: 0.2, daysAgo: 50, description: 'Revenue share', incoming: true, recipientLabel: 'Revenue Pool' }, + { fraction: -0.15, daysAgo: 70, description: 'Marketing campaign', incoming: false, recipientLabel: 'Marketing DAO' }, + ]; + + const existingSum = txDefinitions.reduce((sum, def) => + sum + (def.incoming ? Math.abs(def.fraction) : -Math.abs(def.fraction)), 0); + const remainingFraction = 1 - existingSum; + + if (Math.abs(remainingFraction) > 0.001) { + txDefinitions.push({ + fraction: remainingFraction, + daysAgo: remainingFraction > 0 ? 85 : 95, + description: remainingFraction > 0 ? 'Initial USDT deposit' : 'Portfolio rebalance', + incoming: remainingFraction > 0, + recipientLabel: remainingFraction > 0 ? 'Bridge Protocol' : 'Portfolio Manager', + }); + } + + return transformUsdtTransaction(txDefinitions); +} + +/** + * Transform USDT transaction definitions into actual transactions + */ +function transformUsdtTransaction(txDefinitions: UsdtTransactionDefinition[]): UsdtTransaction[] { + return txDefinitions.map((def, index) => { + const value = Math.floor(usdtInitialBalance * Math.abs(def.fraction)); + const randomAddress = def.incoming ? getRandomPolygonAddress() : demoPolygonAddress; + const recipientAddress = def.incoming ? demoPolygonAddress : getRandomPolygonAddress(); + + if (def.recipientLabel) { + const { setContact } = useUsdtContactsStore(); + setContact(def.incoming ? randomAddress : recipientAddress, def.recipientLabel); } - }); - demoRouter.afterEach((to) => { - const match = Object.entries(demoRoutes).find(([, route]) => route === to.path); - if (!match) return; - window.parent.postMessage({ kind: MessageEventName.FlowChange, data: match[0] as DemoFlowType }, '*'); + return { + token: Config.polygon.usdt_bridged.tokenContract, + transactionHash: getRandomPolygonHash(), + logIndex: index, + sender: randomAddress, + recipient: recipientAddress, + value, + state: UsdtTransactionState.CONFIRMED, + blockHeight: 1000000 + index, + timestamp: toSecs(calculateDaysAgo(def.daysAgo)), + }; }); } +/** + * Insert fake USDT transactions into the store + */ +function insertFakeUsdtTransactions(txs = defineUsdtFakeTransactions()) { + const { addTransactions } = useUsdtTransactionsStore(); + addTransactions(txs); +} + +// #endregion + +// #region Flows + /** * Observes the staking modal and prevents from validating the info and instead fakes the staking process. */ @@ -779,19 +928,14 @@ function replaceStakingFlow() { retiredBalance: 0, validator: validatorAddress, }); - const { addTransactions } = useTransactionsStore(); - addTransactions([ - createFakeTransaction({ - value: amount, - recipient: STAKING_CONTRACT_ADDRESS, - sender: demoNimAddress, - data: { - type: 'add-stake', - raw: '', - staker: demoNimAddress, - }, - }), - ]); + const stakeTx: Partial = { + value: amount, + recipient: validatorAddress, + sender: demoNimAddress, + timestamp: Date.now(), + data: { type: 'add-stake', raw: '', staker: demoNimAddress }, + }; + insertFakeNimTransactions([stakeTx]); }); }); @@ -805,35 +949,6 @@ function replaceStakingFlow() { }); } -/** - * Creates a fake transaction. Each call increments a global counter for the hash and block heights. - */ -let txCounter = 0; -let currentHead = 0; -function createFakeTransaction(tx: Partial) { - return { - network: 'mainnet', - state: 'confirmed', - transactionHash: `0x${(txCounter++).toString(16)}`, - value: 50000000, - recipient: '', - fee: 0, - feePerByte: 0, - format: 'basic', - sender: '', - senderType: 'basic', - recipientType: 'basic', - validityStartHeight: currentHead++, - blockHeight: currentHead++, - flags: 0, - timestamp: Date.now(), - proof: { type: 'raw', raw: '' }, - size: 0, - valid: true, - ...tx, - } as PlainTransactionDetails; -} - /** * Returns the hex encoding of a UTF-8 string. */ @@ -1051,86 +1166,6 @@ function interceptFetchRequest() { return new Response(JSON.stringify(json)); } - // if (isEstimateRequest) { - // const body = args[1]?.body; - // if (!body) throw new Error('[Demo] No body found in request'); - // type EstimateRequest = { - // from: Record, - // to: SwapAsset, - // includedFees: 'required', - // }; - // const { from: fromObj, to } = JSON.parse(body.toString()) as EstimateRequest; - // const from = Object.keys(fromObj)[0] as SwapAsset; - - // // Validate the request - // if (!from || !to) { - // console.error('[Demo] Invalid request parameters:', { from, to }); - // return new Response(JSON.stringify({ - // error: 'Invalid request parameters', - // status: 400, - // }), { status: 400 }); - // } - - // // Calculate fees based on the value - - // const value = fromObj[from]; - // const networkFee = value * 0.0005; // 0.05% network fee - // // const escrowFee = value * 0.001; // 0.1% escrow fee - - // // Calculate the destination amount using our mock rates - // const estimatedAmount = calculateEstimatedAmount(from, to, value); - - // // Create mock estimate response with a valid structure - // const estimate: FastspotEstimate[] = [{ - // from: [{ - // amount: `${value}`, - // name: from, - // symbol: from, - // finalizeNetworkFee: { - // total: `${networkFee}`, - // totalIsIncluded: false, - // perUnit: `${getNetworkFeePerUnit(from)}`, - // }, - // fundingNetworkFee: { - // total: `${networkFee}`, - // totalIsIncluded: false, - // perUnit: `${getNetworkFeePerUnit(from)}`, - // }, - // operatingNetworkFee: { - // total: `${networkFee}`, - // totalIsIncluded: false, - // perUnit: `${getNetworkFeePerUnit(from)}`, - // }, - // }], - // to: [{ - // amount: `${estimatedAmount}`, - // name: to, - // symbol: to, - // finalizeNetworkFee: { - // total: `${networkFee}`, - // totalIsIncluded: false, - // perUnit: `${getNetworkFeePerUnit(to)}`, - // }, - // fundingNetworkFee: { - // total: `${networkFee}`, - // totalIsIncluded: false, - // perUnit: `${getNetworkFeePerUnit(to)}`, - // }, - // operatingNetworkFee: { - // total: `${networkFee}`, - // totalIsIncluded: false, - // perUnit: `${getNetworkFeePerUnit(to)}`, - // }, - // }], - // direction: 'forward', - // serviceFeePercentage: 0.01, - // }]; - - // console.log('[Demo] Mock estimate:', estimate); - - // return new Response(JSON.stringify(estimate)); - // } - if (isSwapRequest) { const swapId = url.pathname.split('/').slice(-1)[0]; @@ -1232,7 +1267,7 @@ function interceptFetchRequest() { direction: 'send', status: 'pending', id: '2MzQo4ehDrSEsxX7RnysLL6VePD3tuNyx4M', - intermediary: { }, + intermediary: {}, }, { asset: swap.redeem.type, @@ -1247,7 +1282,7 @@ function interceptFetchRequest() { direction: 'receive', status: 'pending', id: 'eff8a1a5-4f4e-3895-b95c-fd5a40c99001', - intermediary: { }, + intermediary: {}, }, ], })); @@ -1275,45 +1310,11 @@ function getNetworkFeePerUnit(asset: string): number { } } -/** - * Calculate mock estimated amount for swaps based on predefined rates - */ -function calculateEstimatedAmount(fromAsset: string, toAsset: string, value: number): number { - // Define mock exchange rates (realistic market rates) - const rates: Record = { - [`${SwapAsset.NIM}-${SwapAsset.BTC}`]: 0.00000004, // 1 NIM = 0.00000004 BTC - [`${SwapAsset.NIM}-${SwapAsset.USDC_MATIC}`]: 0.0012, // 1 NIM = 0.0012 USDC - [`${SwapAsset.NIM}-${SwapAsset.USDT_MATIC}`]: 0.0012, // 1 NIM = 0.0012 USDT - [`${SwapAsset.BTC}-${SwapAsset.NIM}`]: 25000000, // 1 BTC = 25,000,000 NIM - [`${SwapAsset.BTC}-${SwapAsset.USDC_MATIC}`]: 30000, // 1 BTC = 30,000 USDC - [`${SwapAsset.BTC}-${SwapAsset.USDT_MATIC}`]: 30000, // 1 BTC = 30,000 USDT - [`${SwapAsset.USDC_MATIC}-${SwapAsset.NIM}`]: 833.33, // 1 USDC = 833.33 NIM - [`${SwapAsset.USDC_MATIC}-${SwapAsset.BTC}`]: 0.000033, // 1 USDC = 0.000033 BTC - [`${SwapAsset.USDC_MATIC}-${SwapAsset.USDT_MATIC}`]: 1, // 1 USDC = 1 USDT - [`${SwapAsset.USDT_MATIC}-${SwapAsset.NIM}`]: 833.33, // 1 USDT = 833.33 NIM - [`${SwapAsset.USDT_MATIC}-${SwapAsset.BTC}`]: 0.000033, // 1 USDT = 0.000033 BTC - [`${SwapAsset.USDT_MATIC}-${SwapAsset.USDC_MATIC}`]: 1, // 1 USDT = 1 USDT - }; - - const rate = rates[`${fromAsset}-${toAsset}`]; - if (!rate) { - // If no direct rate, check reverse rate - const reverseRate = rates[`${toAsset}-${fromAsset}`]; - if (reverseRate) { - return Math.floor(value * (1 / reverseRate)); - } - return value; // 1:1 if no rate defined - } - - return Math.floor(value * rate); -} - /** * Ensures the Send button in modals is always enabled in demo mode, regardless of network state. * This allows users to interact with the send functionality without waiting for network sync. */ function enableSendModalInDemoMode() { - const observing = false; const observer = new MutationObserver(() => { // Target the send modal footer button const sendButton = document.querySelector('.send-modal-footer .nq-button'); @@ -1342,6 +1343,165 @@ function enableSendModalInDemoMode() { observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled'] }); } +let swapInterval: NodeJS.Timeout | null = null; +function listenForSwapChanges() { + if (swapInterval) return; + swapInterval = setInterval(() => { + // Check if there are any active swaps that need to be processed + const { activeSwap } = useSwapsStore(); + if (activeSwap && activeSwap.value?.state === SwapState.COMPLETE) { + completeSwap(activeSwap); + } + }, 2_000); +} + +/** + * Completes an active swap by creating transactions for both sides of the swap + */ +function completeSwap(activeSwap: any) { + const { setActiveSwap } = useSwapsStore(); + const startTime = Date.now(); + + // Create a timeout to simulate processing time + setTimeout(() => { + // Update the swap state to SUCCESS + setActiveSwap({ + ...activeSwap, + state: SwapState.COMPLETE, + stateEnteredAt: startTime, + }); + + // Add transactions for both sides of the swap + const fromAsset = activeSwap.from.asset; + const toAsset = activeSwap.to.asset; + const fromAmount = activeSwap.from.amount; + const toAmount = activeSwap.to.amount; + const fromFee = activeSwap.from.fee || 0; + const toFee = activeSwap.to.fee || 0; + + // Create outgoing transaction (from asset) + switch (fromAsset) { + case 'NIM': { + const tx: Partial = { + value: -fromAmount, + recipient: 'HTLC-ADDRESS', + sender: demoNimAddress, + timestamp: Date.now(), + data: { + type: 'htlc', + hashAlgorithm: '', + hashCount: 0, + hashRoot: '', + raw: '', + recipient: 'HTLC-ADDRESS', + sender: demoNimAddress, + timeout: 0, + }, + } + insertFakeNimTransactions(transformNimTransaction([tx])); + break; + } + case 'BTC': { + const tx: BtcTransactionDefinition = { + address: 'HTLC-ADDRESS', + daysAgo: 0, + description: `Swap ${fromAsset} to ${toAsset}`, + fraction: -fromAmount / btcInitialBalance, + incoming: false, + recipientLabel: 'HTLC-ADDRESS', + } + insertFakeBtcTransactions(transformBtcTransaction([tx])); + break; + } + case 'USDC_MATIC': { + const tx: UsdcTransactionDefinition = { + fraction: 1, + daysAgo: 0, + description: `Swap ${fromAsset} to ${toAsset}`, + incoming: false, + recipientLabel: demoPolygonAddress, + }; + + insertFakeUsdcTransactions(transformUsdcTransaction([tx])); + break; + } + case 'USDT_MATIC': { + const tx: UsdtTransactionDefinition = { + fraction: 1, + daysAgo: 0, + description: `Swap ${fromAsset} to ${toAsset}`, + incoming: false, + recipientLabel: demoPolygonAddress, + }; + + insertFakeUsdtTransactions(transformUsdtTransaction([tx])); + break; + } + } + + // Create incoming transaction (to asset) + switch (toAsset) { + case 'NIM': { + const tx: Partial = { + value: toAmount, + recipient: demoNimAddress, + sender: 'HTLC-ADDRESS', + timestamp: Date.now(), + data: { + type: 'htlc', + hashAlgorithm: '', + hashCount: 0, + hashRoot: '', + raw: '', + recipient: demoNimAddress, + sender: 'HTLC-ADDRESS', + timeout: 0, + }, + } + insertFakeNimTransactions(transformNimTransaction([tx])); + break; + } + case 'BTC': { + const tx: BtcTransactionDefinition = { + address: demoBtcAddress, + daysAgo: 0, + description: `Swap ${fromAsset} to ${toAsset}`, + fraction: toAmount / btcInitialBalance, + incoming: true, + recipientLabel: demoBtcAddress, + } + insertFakeBtcTransactions(transformBtcTransaction([tx])); + break; + } + case 'USDC_MATIC': { + const tx: UsdcTransactionDefinition = { + fraction: 1, + daysAgo: 0, + description: `Swap ${fromAsset} to ${toAsset}`, + incoming: true, + recipientLabel: demoPolygonAddress, + }; + insertFakeUsdcTransactions(transformUsdcTransaction([tx])); + break; + } + case 'USDT_MATIC': { + const tx: UsdtTransactionDefinition = { + fraction: 1, + daysAgo: 0, + description: `Swap ${fromAsset} to ${toAsset}`, + incoming: true, + recipientLabel: demoPolygonAddress, + }; + + insertFakeUsdtTransactions(transformUsdtTransaction([tx])); + break; + } + } + // eslint-disable-next-line no-console + console.log('Demo swap completed:', { fromAsset, toAsset, fromAmount, toAmount }); + }, 3000); // Simulate a delay for the swap to complete +} + const ignoreHubRequests = [ 'addBtcAddresses', 'on', @@ -1415,20 +1575,13 @@ export class DemoHubApi extends HubApi { if (ignoreHubRequests.includes(requestName)) { return; } - // Find the setupSwap handler in the DemoHubApi class and replace it with this: if (requestName === 'setupSwap') { - console.log('[DEMO]', { - firstArg, - args, - promise: await firstArg, - }); const swap = await firstArg as SetupSwapArgs; const signerTransaction: SetupSwapResult = { nim: { transaction: new Uint8Array(), serializedTx: '0172720036a3b2ca9e0de8b369e6381753ebef945a020091fa7bbddf959616767c50c50962c9e056ade9c400000000000000989680000000000000000000c3e23d0500a60100010366687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f292520000000000000000000000000000000000000000000000000000000000000000000200demoSerializedTx', - // serializedTx: '0172720036a3b2ca9e0de8b369e6381753ebef945a020091fa7bbddf959616767c50c50962c9e056ade9c400000000000000989680000000000000000000c3e23d0500a60100010366687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925200000000000000000000000000000000000000000000000000000000000000000002003b571b3481bec4340ed715f6b59add1947667f2d51a70d05cc7b5778dae6e009a5342d2e62977c877342b1972ebe064e77c473603ac9a6b97ac1bdd0fa543f37487fab37256fad87d79314eee2dff3fc70623057fe17f07aa39f9cb9f99db0a', hash: '6c58b337a907fe000demoTxHash8a1f4ab4fdc0f69b1e582f', raw: { signerPublicKey: new Uint8Array(), @@ -1457,527 +1610,8 @@ export class DemoHubApi extends HubApi { onGoingSwaps.set(swap.swapId, swap); resolve(signerTransaction); + return } - // if (requestName === 'setupSwap') { - // (firstArg as Function)(); - // // Get swap amount and asset details from the DOM - // const leftColumn = document.querySelector('.swap-amounts .left-column'); - // const leftAmountElement = leftColumn?.querySelector('.width-value'); - // const rightColumn = document.querySelector('.swap-amounts .right-column'); - // const rightAmountElement = rightColumn?.querySelector('.width-value'); - - // if (!leftAmountElement || !rightAmountElement || !leftColumn || !rightColumn) { - // console.warn('[Demo] Could not find swap amount elements'); - // return {}; - // } - - // const leftValue = Number((leftAmountElement as HTMLDivElement).innerText.replace(/,/g, '')); - // const leftAsset = leftColumn.querySelector('.ticker')?.innerHTML.toUpperCase().trim() as SwapAsset; - - // const rightValue = Number((rightAmountElement as HTMLDivElement).innerText.replace(/,/g, '')); - // const rightAsset = rightColumn.querySelector('.ticker')?.innerHTML.toUpperCase().trim() as SwapAsset; - - // console.log(`[Demo] Setting up swap: ${leftValue} ${leftAsset} -> ${rightValue} ${rightAsset}`); - - // // Check if we have valid values - // if (!leftValue || !rightValue || !leftAsset || !rightAsset) { - // console.warn('[Demo] Missing swap values'); - // return {}; - // } - - // const direction = leftValue < rightValue ? 'forward' : 'reverse'; - // const fromAsset = direction === 'forward' ? leftAsset : rightAsset; - // // @ts-expect-error Object key not specific enough? - // const toAsset: RequestAsset = direction === 'forward' - // ? { [rightAsset]: rightValue } - // : { [leftAsset]: leftValue }; - - // const swapSuggestion = await createSwap(fromAsset, toAsset); - - // const { config } = useConfig(); - - // let fund: HtlcCreationInstructions | null = null; - // let redeem: HtlcSettlementInstructions | null = null; - - // const { activeAddressInfo } = useAddressStore(); - - // const { availableExternalAddresses } = useBtcAddressStore(); - // const nimAddress = activeAddressInfo.value!.address; - // const btcAddress = availableExternalAddresses.value[0]; - - // if (swapSuggestion.from.asset === SwapAsset.NIM) { - // const nimiqClient = await getNetworkClient(); - // await nimiqClient.waitForConsensusEstablished(); - // const headHeight = await nimiqClient.getHeadHeight(); - // if (headHeight > 100) { - // useNetworkStore().state.height = headHeight; - // } else { - // throw new Error('Invalid network state, try please reloading the app'); - // } - - // fund = { - // type: SwapAsset.NIM, - // sender: nimAddress, - // value: swapSuggestion.from.amount, - // fee: swapSuggestion.from.fee, - // validityStartHeight: useNetworkStore().state.height, - // }; - // } - - // const { accountUtxos, accountBalance: accountBtcBalance, } = useBtcAddressStore(); - // if (swapSuggestion.from.asset === SwapAsset.BTC) { - // const electrumClient = await getElectrumClient(); - // await electrumClient.waitForConsensusEstablished(); - - // // Assemble BTC inputs - // // Transactions to an HTLC are 46 weight units bigger because of the longer recipient address - // const requiredInputs = selectOutputs( - // accountUtxos.value, swapSuggestion.from.amount, swapSuggestion.from.feePerUnit, 48); - // let changeAddress: string; - // if (requiredInputs.changeAmount > 0) { - // const { nextChangeAddress } = useBtcAddressStore(); - // if (!nextChangeAddress.value) { - // // FIXME: If no unused change address is found, need to request new ones from Hub! - // throw new Error('No more unused change addresses (not yet implemented)'); - // } - // changeAddress = nextChangeAddress.value; - // } - - // fund = { - // type: SwapAsset.BTC, - // inputs: requiredInputs.utxos.map((utxo) => ({ - // address: utxo.address, - // transactionHash: utxo.transactionHash, - // outputIndex: utxo.index, - // outputScript: utxo.witness.script, - // value: utxo.witness.value, - // })), - // output: { - // value: swapSuggestion.from.amount, - // }, - // ...(requiredInputs.changeAmount > 0 ? { - // changeOutput: { - // address: changeAddress!, - // value: requiredInputs.changeAmount, - // }, - // } : {}), - // refundAddress: btcAddress, - // }; - // } - - // const { - // activeAddress: activePolygonAddress, - // accountUsdcBalance, - // accountUsdtBridgedBalance, - // } = usePolygonAddressStore(); - - // if (swapSuggestion.from.asset === SwapAsset.USDC_MATIC) { - // const [client, htlcContract] = await Promise.all([ - // getPolygonClient(), - // getUsdcHtlcContract(), - // ]); - // const fromAddress = activePolygonAddress.value!; - - // const [ - // usdcNonce, - // forwarderNonce, - // blockHeight, - // ] = await Promise.all([ - // client.usdcToken.nonces(fromAddress) as Promise, - // htlcContract.getNonce(fromAddress) as Promise, - // getPolygonBlockNumber(), - // ]); - - // const { fee, gasLimit, gasPrice, relay, method } = { - // fee: 1, - // gasLimit: 1, - // gasPrice: 1, - // relay: { - // pctRelayFee: 1, - // baseRelayFee: 1, - // relayWorkerAddress: '0x0000000000111111111122222222223333333333', - // }, - // method: 'open' - // }; - // if (method !== 'open' && method !== 'openWithPermit') { - // throw new Error('Wrong USDC contract method'); - // } - - // // Zeroed data fields are replaced by Fastspot's proposed data (passed in from Hub) in - // // Keyguard's SwapIFrameApi. - // const data = htlcContract.interface.encodeFunctionData(method, [ - // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* address token */ config.polygon.usdc.tokenContract, - // /* uint256 amount */ swapSuggestion.from.amount, - // /* address refundAddress */ fromAddress, - // /* address recipientAddress */ '0x0000000000000000000000000000000000000000', - // /* bytes32 hash */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* uint256 timeout */ 0, - // /* uint256 fee */ fee, - // ...(method === 'openWithPermit' ? [ - // // // Approve the maximum possible amount so afterwards we can use the `open` method for - // // // lower fees - // // /* uint256 value */ client.ethers - // // .BigNumber.from('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), - // /* uint256 value */ swapSuggestion.from.amount + fee, - - // /* bytes32 sigR */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* bytes32 sigS */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* uint8 sigV */ 0, - // ] : []), - // ]); - - // const relayRequest: RelayRequest = { - // request: { - // from: fromAddress, - // to: config.polygon.usdc.htlcContract, - // data, - // value: '0', - // nonce: forwarderNonce.toString(), - // gas: gasLimit.toString(), - // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) - // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) - // }, - // relayData: { - // gasPrice: gasPrice.toString(), - // pctRelayFee: relay.pctRelayFee.toString(), - // baseRelayFee: relay.baseRelayFee.toString(), - // relayWorker: relay.relayWorkerAddress, - // paymaster: config.polygon.usdc.htlcContract, - // paymasterData: '0x', - // clientId: Math.floor(Math.random() * 1e6).toString(10), - // forwarder: config.polygon.usdc.htlcContract, - // }, - // }; - - // fund = { - // type: SwapAsset.USDC_MATIC, - // ...relayRequest, - // ...(method === 'openWithPermit' ? { - // permit: { - // tokenNonce: usdcNonce.toNumber(), - // }, - // } : null), - // }; - // } - - // if (swapSuggestion.from.asset === SwapAsset.USDT_MATIC) { - // const [client, htlcContract] = await Promise.all([ - // getPolygonClient(), - // getUsdtBridgedHtlcContract(), - // ]); - // const fromAddress = activePolygonAddress.value!; - - // const [ - // usdtNonce, - // forwarderNonce, - // blockHeight, - // ] = await Promise.all([ - // client.usdtBridgedToken.getNonce(fromAddress) as Promise, - // htlcContract.getNonce(fromAddress) as Promise, - // getPolygonBlockNumber(), - // ]); - - // const { fee, gasLimit, gasPrice, relay, method } = { - // fee: 1, - // gasLimit: 1, - // gasPrice: 1, - // relay: { - // pctRelayFee: 1, - // baseRelayFee: 1, - // relayWorkerAddress: '0x0000000000111111111122222222223333333333', - // }, - // method: 'open' - // }; - // if (method !== 'open' && method !== 'openWithApproval') { - // throw new Error('Wrong USDT contract method'); - // } - - // // Zeroed data fields are replaced by Fastspot's proposed data (passed in from Hub) in - // // Keyguard's SwapIFrameApi. - // const data = htlcContract.interface.encodeFunctionData(method, [ - // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* address token */ config.polygon.usdt_bridged.tokenContract, - // /* uint256 amount */ swapSuggestion.from.amount, - // /* address refundAddress */ fromAddress, - // /* address recipientAddress */ '0x0000000000000000000000000000000000000000', - // /* bytes32 hash */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* uint256 timeout */ 0, - // /* uint256 fee */ fee, - // ...(method === 'openWithApproval' ? [ - // // // Approve the maximum possible amount so afterwards we can use the `open` method for - // // // lower fees - // // /* uint256 approval */ client.ethers - // // .BigNumber.from('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), - // /* uint256 approval */ swapSuggestion.from.amount + fee, - - // /* bytes32 sigR */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* bytes32 sigS */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* uint8 sigV */ 0, - // ] : []), - // ]); - - // const relayRequest: RelayRequest = { - // request: { - // from: fromAddress, - // to: config.polygon.usdt_bridged.htlcContract, - // data, - // value: '0', - // nonce: forwarderNonce.toString(), - // gas: gasLimit.toString(), - // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) - // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) - // }, - // relayData: { - // gasPrice: gasPrice.toString(), - // pctRelayFee: relay.pctRelayFee.toString(), - // baseRelayFee: relay.baseRelayFee.toString(), - // relayWorker: relay.relayWorkerAddress, - // paymaster: config.polygon.usdt_bridged.htlcContract, - // paymasterData: '0x', - // clientId: Math.floor(Math.random() * 1e6).toString(10), - // forwarder: config.polygon.usdt_bridged.htlcContract, - // }, - // }; - - // fund = { - // type: SwapAsset.USDT_MATIC, - // ...relayRequest, - // ...(method === 'openWithApproval' ? { - // approval: { - // tokenNonce: usdtNonce.toNumber(), - // }, - // } : null), - // }; - // } - - // if (swapSuggestion.to.asset === SwapAsset.NIM) { - // const nimiqClient = await getNetworkClient(); - // await nimiqClient.waitForConsensusEstablished(); - // const headHeight = await nimiqClient.getHeadHeight(); - // if (headHeight > 100) { - // useNetworkStore().state.height = headHeight; - // } else { - // throw new Error('Invalid network state, try please reloading the app'); - // } - - // redeem = { - // type: SwapAsset.NIM, - // recipient: nimAddress, // My address, must be redeem address of HTLC - // value: swapSuggestion.to.amount - swapSuggestion.to.fee, // Luna - // fee: swapSuggestion.to.fee, // Luna - // validityStartHeight: useNetworkStore().state.height, - // }; - // } - - // if (swapSuggestion.to.asset === SwapAsset.BTC) { - // const electrumClient = await getElectrumClient(); - // await electrumClient.waitForConsensusEstablished(); - - // redeem = { - // type: SwapAsset.BTC, - // input: { - // // transactionHash: transaction.transactionHash, - // // outputIndex: output.index, - // // outputScript: output.script, - // value: swapSuggestion.to.amount, // Sats - // }, - // output: { - // address: btcAddress, // My address, must be redeem address of HTLC - // value: swapSuggestion.to.amount - swapSuggestion.to.fee, // Sats - // }, - // }; - // } - - // if (swapSuggestion.to.asset === SwapAsset.USDC_MATIC) { - // const htlcContract = await getUsdcHtlcContract(); - // const toAddress = activePolygonAddress.value!; - - // const [ - // forwarderNonce, - // blockHeight, - // ] = await Promise.all([ - // htlcContract.getNonce(toAddress) as Promise, - // getPolygonBlockNumber(), - // ]); - - // const { fee, gasLimit, gasPrice, relay, method } = { - // fee: 1, - // gasLimit: 1, - // gasPrice: 1, - // relay: { - // pctRelayFee: 1, - // baseRelayFee: 1, - // relayWorkerAddress: '0x0000000000111111111122222222223333333333', - // }, - // method: 'open' - // }; - // if (method !== 'redeemWithSecretInData') { - // throw new Error('Wrong USDC contract method'); - // } - - // const data = htlcContract.interface.encodeFunctionData(method, [ - // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* address target */ toAddress, - // /* uint256 fee */ fee, - // ]); - - // const relayRequest: RelayRequest = { - // request: { - // from: toAddress, - // to: config.polygon.usdc.htlcContract, - // data, - // value: '0', - // nonce: forwarderNonce.toString(), - // gas: gasLimit.toString(), - // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) - // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) - // }, - // relayData: { - // gasPrice: gasPrice.toString(), - // pctRelayFee: relay.pctRelayFee.toString(), - // baseRelayFee: relay.baseRelayFee.toString(), - // relayWorker: relay.relayWorkerAddress, - // paymaster: config.polygon.usdc.htlcContract, - // paymasterData: '0x', - // clientId: Math.floor(Math.random() * 1e6).toString(10), - // forwarder: config.polygon.usdc.htlcContract, - // }, - // }; - - // redeem = { - // type: SwapAsset.USDC_MATIC, - // ...relayRequest, - // amount: swapSuggestion.to.amount - swapSuggestion.to.fee, - // }; - // } - - // if (swapSuggestion.to.asset === SwapAsset.USDT_MATIC) { - // const htlcContract = await getUsdtBridgedHtlcContract(); - // const toAddress = activePolygonAddress.value!; - - // const [ - // forwarderNonce, - // blockHeight, - // ] = await Promise.all([ - // htlcContract.getNonce(toAddress) as Promise, - // getPolygonBlockNumber(), - // ]); - - // const { fee, gasLimit, gasPrice, relay, method } = { - // fee: 1, - // gasLimit: 1, - // gasPrice: 1, - // relay: { - // pctRelayFee: 1, - // baseRelayFee: 1, - // relayWorkerAddress: '0x0000000000111111111122222222223333333333', - // }, - // method: 'open' - // }; - // if (method !== 'redeemWithSecretInData') { - // throw new Error('Wrong USDT contract method'); - // } - - // const data = htlcContract.interface.encodeFunctionData(method, [ - // /* bytes32 id */ '0x0000000000000000000000000000000000000000000000000000000000000000', - // /* address target */ toAddress, - // /* uint256 fee */ fee, - // ]); - - // const relayRequest: RelayRequest = { - // request: { - // from: toAddress, - // to: config.polygon.usdt_bridged.htlcContract, - // data, - // value: '0', - // nonce: forwarderNonce.toString(), - // gas: gasLimit.toString(), - // validUntil: (blockHeight + 3000 + 3 * 60 * POLYGON_BLOCKS_PER_MINUTE) - // .toString(10), // 3 hours + 3000 blocks (minimum relay expectancy) - // }, - // relayData: { - // gasPrice: gasPrice.toString(), - // pctRelayFee: relay.pctRelayFee.toString(), - // baseRelayFee: relay.baseRelayFee.toString(), - // relayWorker: relay.relayWorkerAddress, - // paymaster: config.polygon.usdt_bridged.htlcContract, - // paymasterData: '0x', - // clientId: Math.floor(Math.random() * 1e6).toString(10), - // forwarder: config.polygon.usdt_bridged.htlcContract, - // }, - // }; - - // redeem = { - // type: SwapAsset.USDT_MATIC, - // ...relayRequest, - // amount: swapSuggestion.to.amount - swapSuggestion.to.fee, - // }; - // } - - // if (!fund || !redeem) { - // reject(new Error('UNEXPECTED: No funding or redeeming data objects')); - // return; - // } - - // const serviceSwapFee = Math.round( - // (swapSuggestion.from.amount - swapSuggestion.from.serviceNetworkFee) - // * swapSuggestion.serviceFeePercentage, - // ); - - // const { addressInfos } = useAddressStore(); - - // const { activeAccountInfo } = useAccountStore(); - - // const { currency, exchangeRates } = useFiatStore(); - - // const { - // activeAddress - - // } = usePolygonAddressStore(); - - // const request: Omit = { - // accountId: activeAccountInfo.value!.id, - // swapId: swapSuggestion.id, - // fund, - // redeem, - - // layout: 'slider', - // direction: direction === 'forward' ? 'left-to-right' : 'right-to-left', - // fiatCurrency: currency.value, - // fundingFiatRate: exchangeRates.value[assetToCurrency( - // fund.type as SupportedSwapAsset, - // )][currency.value]!, - // redeemingFiatRate: exchangeRates.value[assetToCurrency( - // redeem.type as SupportedSwapAsset, - // )][currency.value]!, - // fundFees: { - // processing: 0, - // redeeming: swapSuggestion.from.serviceNetworkFee, - // }, - // redeemFees: { - // funding: swapSuggestion.to.serviceNetworkFee, - // processing: 0, - // }, - // serviceSwapFee, - // nimiqAddresses: addressInfos.value.map((addressInfo) => ({ - // address: addressInfo.address, - // balance: addressInfo.balance || 0, - // })), - // bitcoinAccount: { - // balance: accountBtcBalance.value, - // }, - // polygonAddresses: activePolygonAddress.value ? [{ - // address: activePolygonAddress.value, - // usdcBalance: accountUsdcBalance.value, - // usdtBalance: accountUsdtBridgedBalance.value, - // }] : [], - // }; - - // console.log('[Demo] Faking swap setup with request:', request); - // resolve(request) - // return; - // } // Wait for router readiness await new Promise((resolve) => { @@ -1994,3 +1628,4 @@ export class DemoHubApi extends HubApi { }); } } + From 93460052464e0d8902e33aef4dcd5ae7ef28ba0c Mon Sep 17 00:00:00 2001 From: onmax Date: Mon, 10 Mar 2025 15:40:22 +0100 Subject: [PATCH 06/31] chore(demo): linter --- src/stores/Demo.ts | 158 ++++++++++++++++++++++----------------------- 1 file changed, 79 insertions(+), 79 deletions(-) diff --git a/src/stores/Demo.ts b/src/stores/Demo.ts index c6a6f417c..b779826c7 100644 --- a/src/stores/Demo.ts +++ b/src/stores/Demo.ts @@ -1,9 +1,9 @@ -/* eslint-disable max-len */ +/* eslint-disable max-len, consistent-return, no-console, no-async-promise-executor */ import { createStore } from 'pinia'; import VueRouter from 'vue-router'; import { TransactionState as ElectrumTransactionState } from '@nimiq/electrum-client'; import { CryptoCurrency, Utf8Tools } from '@nimiq/utils'; -import { Address, KeyPair, PlainTransactionDetails, PlainTransactionRecipientData, PrivateKey, TransactionFormat } from '@nimiq/core'; +import { KeyPair, PlainTransactionDetails, PrivateKey } from '@nimiq/core'; import { AccountType, useAccountStore } from '@/stores/Account'; import { AddressType, useAddressStore } from '@/stores/Address'; import { toSecs, type Transaction as NimTransaction, useTransactionsStore } from '@/stores/Transactions'; @@ -14,8 +14,9 @@ import { useStakingStore } from '@/stores/Staking'; import { useAccountSettingsStore } from '@/stores/AccountSettings'; import { usePolygonAddressStore } from '@/stores/PolygonAddress'; import Config from 'config'; -import { AssetList, FastspotAsset, FastspotEstimate, FastspotFee, FastspotLimits, FastspotUserLimits, ReferenceAsset, SwapAsset, SwapStatus } from '@nimiq/fastspot-api'; +import { FastspotAsset, FastspotLimits, FastspotUserLimits, ReferenceAsset, SwapAsset, SwapStatus } from '@nimiq/fastspot-api'; import HubApi, { SetupSwapResult } from '@nimiq/hub-api'; +import { useConfig } from '@/composables/useConfig'; import { useBtcAddressStore } from './BtcAddress'; import { useContactsStore } from './Contacts'; import { useBtcLabelsStore } from './BtcLabels'; @@ -23,8 +24,6 @@ import { useUsdcContactsStore } from './UsdcContacts'; import { useUsdtContactsStore } from './UsdtContacts'; import { useFiatStore } from './Fiat'; import { SwapState, useSwapsStore } from './Swaps'; -import { getUsdcHtlcContract } from '@/ethers'; -import { useConfig } from '@/composables/useConfig'; export type DemoState = { active: boolean, @@ -87,7 +86,6 @@ export const useDemoStore = createStore({ * Initializes the demo environment and sets up various routes, data, and watchers. */ async initialize(router: VueRouter) { - // eslint-disable-next-line no-console console.warn('[Demo] Initializing demo environment...'); demoRouter = router; @@ -454,7 +452,7 @@ function transformNimTransaction(txs: Partial[]): NimTransaction proof: { raw: '', type: 'raw' }, data: tx.data || { type: 'raw', raw: '' }, ...tx, - } + }; }); } @@ -474,7 +472,7 @@ interface BtcTransactionDefinition { description: string; recipientLabel?: string; incoming: boolean; - address: string + address: string; } /** @@ -489,7 +487,7 @@ function defineBtcFakeTransactions(): BtcTransaction[] { description: 'Initial BTC purchase from exchange', incoming: true, address: '1Kj4SNWFCxqvtP8nkJxeBwkXxgY9LW9rGg', - recipientLabel: 'Satoshi Exchange' + recipientLabel: 'Satoshi Exchange', }, { fraction: 0.15, @@ -497,7 +495,7 @@ function defineBtcFakeTransactions(): BtcTransaction[] { description: 'Mining pool payout', incoming: true, address: '1Hz7vQrRjnu3z9k7gxDYhKjEmABqChDvJr', - recipientLabel: 'Genesis Mining Pool' + recipientLabel: 'Genesis Mining Pool', }, { fraction: -0.19, @@ -505,7 +503,7 @@ function defineBtcFakeTransactions(): BtcTransaction[] { description: 'Purchase from online marketplace', incoming: false, address: '1LxKe5kKdgGVwXukEgqFxh6DrCXF2Pturc', - recipientLabel: 'Digital Bazaar Shop' + recipientLabel: 'Digital Bazaar Shop', }, { fraction: 0.3, @@ -513,28 +511,28 @@ function defineBtcFakeTransactions(): BtcTransaction[] { description: 'Company payment for consulting', incoming: true, address: '1N7aecJuKGDXzYK8CgpnNRYxdhZvXPxp3B', - recipientLabel: 'Corporate Treasury' + recipientLabel: 'Corporate Treasury', }, { fraction: -0.15, daysAgo: 365, description: 'Auto-DCA investment program', incoming: false, - address: '12vxjmKJkfL9s5JwqUzEVVJGvKYJgALbsz' + address: '12vxjmKJkfL9s5JwqUzEVVJGvKYJgALbsz', }, { fraction: 0.075, daysAgo: 180, description: 'P2P sale of digital goods', incoming: true, - address: '1MZYS9nvVmFvSK7em5zzAsnvRq82RUcypS' + address: '1MZYS9nvVmFvSK7em5zzAsnvRq82RUcypS', }, { fraction: 0.05, daysAgo: 60, description: 'Recent purchase from exchange', incoming: true, - address: '1Kj4SNWFCxqvtP8nkJxeBwkXxgY9LW9rGg' + address: '1Kj4SNWFCxqvtP8nkJxeBwkXxgY9LW9rGg', }, ].sort((a, b) => b.daysAgo - a.daysAgo); @@ -689,7 +687,6 @@ function insertFakeBtcTransactions(txs = defineBtcFakeTransactions()) { addTransactions(txs); } - // #endregion // #region Polygon txs @@ -1075,7 +1072,6 @@ function interceptFetchRequest() { const url = new URL(args[0] as string); const isFastspotRequest = url.host === (new URL(Config.fastspot.apiEndpoint).host); const isLimitsRequest = url.pathname.includes('/limits'); - const isEstimateRequest = url.pathname.includes('/estimate'); const isAssetsRequest = url.pathname.includes('/assets'); const isSwapRequest = url.pathname.includes('/swaps'); @@ -1106,6 +1102,7 @@ function interceptFetchRequest() { return new Response(JSON.stringify(limits)); } + // eslint-disable-next-line no-promise-executor-return const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); await sleep(1000 + Math.random() * 500); @@ -1376,8 +1373,8 @@ function completeSwap(activeSwap: any) { const toAsset = activeSwap.to.asset; const fromAmount = activeSwap.from.amount; const toAmount = activeSwap.to.amount; - const fromFee = activeSwap.from.fee || 0; - const toFee = activeSwap.to.fee || 0; + // const fromFee = activeSwap.from.fee || 0; + // const toFee = activeSwap.to.fee || 0; // Create outgoing transaction (from asset) switch (fromAsset) { @@ -1397,7 +1394,7 @@ function completeSwap(activeSwap: any) { sender: demoNimAddress, timeout: 0, }, - } + }; insertFakeNimTransactions(transformNimTransaction([tx])); break; } @@ -1409,7 +1406,7 @@ function completeSwap(activeSwap: any) { fraction: -fromAmount / btcInitialBalance, incoming: false, recipientLabel: 'HTLC-ADDRESS', - } + }; insertFakeBtcTransactions(transformBtcTransaction([tx])); break; } @@ -1437,6 +1434,9 @@ function completeSwap(activeSwap: any) { insertFakeUsdtTransactions(transformUsdtTransaction([tx])); break; } + default: { + console.warn(`Unsupported asset type: ${fromAsset}`); + } } // Create incoming transaction (to asset) @@ -1457,7 +1457,7 @@ function completeSwap(activeSwap: any) { sender: 'HTLC-ADDRESS', timeout: 0, }, - } + }; insertFakeNimTransactions(transformNimTransaction([tx])); break; } @@ -1469,7 +1469,7 @@ function completeSwap(activeSwap: any) { fraction: toAmount / btcInitialBalance, incoming: true, recipientLabel: demoBtcAddress, - } + }; insertFakeBtcTransactions(transformBtcTransaction([tx])); break; } @@ -1496,8 +1496,10 @@ function completeSwap(activeSwap: any) { insertFakeUsdtTransactions(transformUsdtTransaction([tx])); break; } + default: { + console.warn(`Unsupported asset type: ${toAsset}`); + } } - // eslint-disable-next-line no-console console.log('Demo swap completed:', { fromAsset, toAsset, fromAmount, toAmount }); }, 3000); // Simulate a delay for the swap to complete } @@ -1565,67 +1567,65 @@ export class DemoHubApi extends HubApi { const instance = new DemoHubApi(); return new Proxy(instance, { get(target, prop: keyof HubApi) { - if (typeof target[prop] === 'function') { - return async (...args: Parameters) => new Promise(async (resolve, reject) => { - const requestName = String(prop); - const [firstArg] = args; - // eslint-disable-next-line no-console - console.warn(`[Demo] Mocking Hub call: ${requestName}("${firstArg}")`); - - if (ignoreHubRequests.includes(requestName)) { - return; - } - - if (requestName === 'setupSwap') { - const swap = await firstArg as SetupSwapArgs; - const signerTransaction: SetupSwapResult = { - nim: { - transaction: new Uint8Array(), - serializedTx: '0172720036a3b2ca9e0de8b369e6381753ebef945a020091fa7bbddf959616767c50c50962c9e056ade9c400000000000000989680000000000000000000c3e23d0500a60100010366687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f292520000000000000000000000000000000000000000000000000000000000000000000200demoSerializedTx', - hash: '6c58b337a907fe000demoTxHash8a1f4ab4fdc0f69b1e582f', - raw: { - signerPublicKey: new Uint8Array(), - signature: new Uint8Array(), - sender: 'NQ86 D3M0 SW4P NB59 U3F8 NDLX CE0P AFMX Y52S', - senderType: 2, - recipient: swap.redeem.recipient, - recipientType: 0, - value: swap.redeem.value, - fee: 0, - validityStartHeight: swap.redeem.validityStartHeight, - extraData: new Uint8Array(), - flags: 0, - networkId: 5, - proof: new Uint8Array(), - }, - }, - btc: { - serializedTx: '0200000000010168c8952af998f2c68412a848a72d1f9b0b7ff27417df1cb85514c97474b51ba40000000000ffffffff026515000000000000220020bf0ffdd2ffb9a579973455cfe9b56515538b79361d5ae8a4d255dea2519ef77864c501000000000016001428257447efe2d254ce850ea2760274d233d86e5c024730440220792fa932d9d0591e3c5eb03f47d05912a1e21f3e76d169e383af66e47896ac8c02205947df5523490e4138f2da0fc5c9da3039750fe43bd217b68d26730fdcae7fbe012102ef8d4b51d1a075e67d62baa78991d5fc36a658fec28d8b978826058168ed2a1a00000000', - hash: '3090808993a796c26a614f5a4a36a48e0b4af6cd3e28e39f3f006e9a447da2b3', - }, - refundTx: '02000000000101b3a27d449a6e003f9fe3283ecdf64a0b8ea4364a5a4f616ac296a793898090300000000000feffffff011e020000000000001600146d2146bb49f6d1de6b4f14e0a8074c79b887cef50447304402202a7dce2e39cf86ee1d7c1e9cc55f1e0fb26932fd22e5437e5e5804a9e5d220b1022031aa177ea085c10c4d54b2f5aa528aac0013b67f9ee674070aa2fb51894de80e0121025b4d40682bbcb5456a9d658971b725666a3cccaa2fb45d269d2f1486bf85b3c000636382012088a820be8719b9427f1551c4234f8b02d8f8aa055ae282b2e9eef6c155326ae951061f8876a914e546b01d8c9d9bf35f9f115132ce8eab7191a68d88ac67046716ca67b17576a9146d2146bb49f6d1de6b4f14e0a8074c79b887cef588ac686816ca67', - }; + if (typeof target[prop] !== 'function') { + return target[prop]; + } - // Add to onGoingSwaps map - onGoingSwaps.set(swap.swapId, swap); + return async (...args: Parameters) => new Promise(async (resolveInterceptedAction) => { + const requestName = String(prop); + const [firstArg] = args; + console.warn(`[Demo] Mocking Hub call: ${requestName}("${firstArg}")`); + + if (ignoreHubRequests.includes(requestName)) { + return; + } + + if (requestName === 'setupSwap') { + const swap = await firstArg as SetupSwapArgs; + const signerTransaction: SetupSwapResult = { + nim: { + transaction: new Uint8Array(), + serializedTx: '0172720036a3b2ca9e0de8b369e6381753ebef945a020091fa7bbddf959616767c50c50962c9e056ade9c400000000000000989680000000000000000000c3e23d0500a60100010366687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f292520000000000000000000000000000000000000000000000000000000000000000000200demoSerializedTx', + hash: '6c58b337a907fe000demoTxHash8a1f4ab4fdc0f69b1e582f', + raw: { + signerPublicKey: new Uint8Array(), + signature: new Uint8Array(), + sender: 'NQ86 D3M0 SW4P NB59 U3F8 NDLX CE0P AFMX Y52S', + senderType: 2, + recipient: swap.redeem.recipient, + recipientType: 0, + value: swap.redeem.value, + fee: 0, + validityStartHeight: swap.redeem.validityStartHeight, + extraData: new Uint8Array(), + flags: 0, + networkId: 5, + proof: new Uint8Array(), + }, + }, + btc: { + serializedTx: '0200000000010168c8952af998f2c68412a848a72d1f9b0b7ff27417df1cb85514c97474b51ba40000000000ffffffff026515000000000000220020bf0ffdd2ffb9a579973455cfe9b56515538b79361d5ae8a4d255dea2519ef77864c501000000000016001428257447efe2d254ce850ea2760274d233d86e5c024730440220792fa932d9d0591e3c5eb03f47d05912a1e21f3e76d169e383af66e47896ac8c02205947df5523490e4138f2da0fc5c9da3039750fe43bd217b68d26730fdcae7fbe012102ef8d4b51d1a075e67d62baa78991d5fc36a658fec28d8b978826058168ed2a1a00000000', + hash: '3090808993a796c26a614f5a4a36a48e0b4af6cd3e28e39f3f006e9a447da2b3', + }, + refundTx: '02000000000101b3a27d449a6e003f9fe3283ecdf64a0b8ea4364a5a4f616ac296a793898090300000000000feffffff011e020000000000001600146d2146bb49f6d1de6b4f14e0a8074c79b887cef50447304402202a7dce2e39cf86ee1d7c1e9cc55f1e0fb26932fd22e5437e5e5804a9e5d220b1022031aa177ea085c10c4d54b2f5aa528aac0013b67f9ee674070aa2fb51894de80e0121025b4d40682bbcb5456a9d658971b725666a3cccaa2fb45d269d2f1486bf85b3c000636382012088a820be8719b9427f1551c4234f8b02d8f8aa055ae282b2e9eef6c155326ae951061f8876a914e546b01d8c9d9bf35f9f115132ce8eab7191a68d88ac67046716ca67b17576a9146d2146bb49f6d1de6b4f14e0a8074c79b887cef588ac686816ca67', + }; - resolve(signerTransaction); - return - } + // Add to onGoingSwaps map + onGoingSwaps.set(swap.swapId, swap); - // Wait for router readiness - await new Promise((resolve) => { - demoRouter.onReady(resolve); - }); + resolveInterceptedAction(signerTransaction); + return; + } - // eslint-disable-next-line no-console - console.log('[Demo] Redirecting to fallback modal'); - demoRouter.push(`/${DemoModal.Fallback}`); + // Wait for router readiness + await new Promise((resolve) => { + demoRouter.onReady(resolve); }); - } - return target[prop]; + + console.log('[Demo] Redirecting to fallback modal'); + demoRouter.push(`/${DemoModal.Fallback}`); + }); }, }); } } - From b62a75b3253a69bdfa3727e50016d5f4270bd126 Mon Sep 17 00:00:00 2001 From: Alberto Monterroso <14013679+Albermonte@users.noreply.github.com> Date: Mon, 10 Mar 2025 11:34:36 -0400 Subject: [PATCH 07/31] fix(demo): swap not working after bug introduced in refactor --- src/stores/Demo.ts | 313 ++++++++++++++++++++++++--------------------- 1 file changed, 169 insertions(+), 144 deletions(-) diff --git a/src/stores/Demo.ts b/src/stores/Demo.ts index b779826c7..29f848b26 100644 --- a/src/stores/Demo.ts +++ b/src/stores/Demo.ts @@ -1102,10 +1102,6 @@ function interceptFetchRequest() { return new Response(JSON.stringify(limits)); } - // eslint-disable-next-line no-promise-executor-return - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - await sleep(1000 + Math.random() * 500); - const asset = assetOrLimit as SwapAsset; const { exchangeRates, currency } = useFiatStore(); @@ -1345,163 +1341,192 @@ function listenForSwapChanges() { if (swapInterval) return; swapInterval = setInterval(() => { // Check if there are any active swaps that need to be processed - const { activeSwap } = useSwapsStore(); - if (activeSwap && activeSwap.value?.state === SwapState.COMPLETE) { - completeSwap(activeSwap); + const swap = useSwapsStore().activeSwap.value; + if (!swap) return; + console.log('[Demo] Active swap:', { swap, state: swap.state }); + switch (swap.state) { + case SwapState.AWAIT_INCOMING: + console.log('[Demo] Swap is in AWAIT_INCOMING state'); + useSwapsStore().setActiveSwap({ + ...swap, + state: SwapState.CREATE_OUTGOING, + }); + break; + case SwapState.CREATE_OUTGOING: + console.log('[Demo] Swap is in CREATE_OUTGOING state'); + useSwapsStore().setActiveSwap({ + ...swap, + state: SwapState.AWAIT_SECRET, + }); + break; + case SwapState.AWAIT_SECRET: + console.log('[Demo] Swap is in AWAIT_SECRET state'); + useSwapsStore().setActiveSwap({ + ...swap, + state: SwapState.SETTLE_INCOMING, + }); + break; + case SwapState.SETTLE_INCOMING: + console.log('[Demo] Swap is in SETTLE_INCOMING state'); + useSwapsStore().setActiveSwap({ + ...swap, + state: SwapState.COMPLETE, + }); + completeSwap(swap); + break; + case SwapState.COMPLETE: + console.log('[Demo] Swap is in COMPLETE state'); + if (swapInterval)clearInterval(swapInterval); + swapInterval = null; + break; + default: + console.log('[Demo] Swap is in unknown state'); + useSwapsStore().setActiveSwap({ + ...swap, + state: SwapState.AWAIT_INCOMING, + }); + break; } - }, 2_000); + }, 15_000); } /** * Completes an active swap by creating transactions for both sides of the swap */ function completeSwap(activeSwap: any) { - const { setActiveSwap } = useSwapsStore(); - const startTime = Date.now(); - - // Create a timeout to simulate processing time - setTimeout(() => { - // Update the swap state to SUCCESS - setActiveSwap({ - ...activeSwap, - state: SwapState.COMPLETE, - stateEnteredAt: startTime, - }); - - // Add transactions for both sides of the swap - const fromAsset = activeSwap.from.asset; - const toAsset = activeSwap.to.asset; - const fromAmount = activeSwap.from.amount; - const toAmount = activeSwap.to.amount; - // const fromFee = activeSwap.from.fee || 0; - // const toFee = activeSwap.to.fee || 0; - - // Create outgoing transaction (from asset) - switch (fromAsset) { - case 'NIM': { - const tx: Partial = { - value: -fromAmount, + // Add transactions for both sides of the swap + const fromAsset = activeSwap.from.asset; + const toAsset = activeSwap.to.asset; + const fromAmount = activeSwap.from.amount; + const toAmount = activeSwap.to.amount; + // const fromFee = activeSwap.from.fee || 0; + // const toFee = activeSwap.to.fee || 0; + + // Create outgoing transaction (from asset) + switch (fromAsset) { + case 'NIM': { + const tx: Partial = { + value: -fromAmount, + recipient: 'HTLC-ADDRESS', + sender: demoNimAddress, + timestamp: Date.now(), + data: { + type: 'htlc', + hashAlgorithm: '', + hashCount: 0, + hashRoot: '', + raw: '', recipient: 'HTLC-ADDRESS', sender: demoNimAddress, - timestamp: Date.now(), - data: { - type: 'htlc', - hashAlgorithm: '', - hashCount: 0, - hashRoot: '', - raw: '', - recipient: 'HTLC-ADDRESS', - sender: demoNimAddress, - timeout: 0, - }, - }; - insertFakeNimTransactions(transformNimTransaction([tx])); - break; - } - case 'BTC': { - const tx: BtcTransactionDefinition = { - address: 'HTLC-ADDRESS', - daysAgo: 0, - description: `Swap ${fromAsset} to ${toAsset}`, - fraction: -fromAmount / btcInitialBalance, - incoming: false, - recipientLabel: 'HTLC-ADDRESS', - }; - insertFakeBtcTransactions(transformBtcTransaction([tx])); - break; - } - case 'USDC_MATIC': { - const tx: UsdcTransactionDefinition = { - fraction: 1, - daysAgo: 0, - description: `Swap ${fromAsset} to ${toAsset}`, - incoming: false, - recipientLabel: demoPolygonAddress, - }; + timeout: 0, + }, + }; + insertFakeNimTransactions(transformNimTransaction([tx])); + break; + } + case 'BTC': { + const tx: BtcTransactionDefinition = { + address: 'HTLC-ADDRESS', + daysAgo: 0, + description: `Swap ${fromAsset} to ${toAsset}`, + fraction: -fromAmount / btcInitialBalance, + incoming: false, + recipientLabel: 'HTLC-ADDRESS', + }; + insertFakeBtcTransactions(transformBtcTransaction([tx])); + break; + } + case 'USDC_MATIC': { + const tx: UsdcTransactionDefinition = { + fraction: 1, + daysAgo: 0, + description: `Swap ${fromAsset} to ${toAsset}`, + incoming: false, + recipientLabel: demoPolygonAddress, + }; - insertFakeUsdcTransactions(transformUsdcTransaction([tx])); - break; - } - case 'USDT_MATIC': { - const tx: UsdtTransactionDefinition = { - fraction: 1, - daysAgo: 0, - description: `Swap ${fromAsset} to ${toAsset}`, - incoming: false, - recipientLabel: demoPolygonAddress, - }; + insertFakeUsdcTransactions(transformUsdcTransaction([tx])); + break; + } + case 'USDT_MATIC': { + const tx: UsdtTransactionDefinition = { + fraction: 1, + daysAgo: 0, + description: `Swap ${fromAsset} to ${toAsset}`, + incoming: false, + recipientLabel: demoPolygonAddress, + }; - insertFakeUsdtTransactions(transformUsdtTransaction([tx])); - break; - } - default: { - console.warn(`Unsupported asset type: ${fromAsset}`); - } + insertFakeUsdtTransactions(transformUsdtTransaction([tx])); + break; } + default: { + console.warn(`Unsupported asset type: ${fromAsset}`); + } + } - // Create incoming transaction (to asset) - switch (toAsset) { - case 'NIM': { - const tx: Partial = { - value: toAmount, + // Create incoming transaction (to asset) + switch (toAsset) { + case 'NIM': { + const tx: Partial = { + value: toAmount, + recipient: demoNimAddress, + sender: 'HTLC-ADDRESS', + timestamp: Date.now(), + data: { + type: 'htlc', + hashAlgorithm: '', + hashCount: 0, + hashRoot: '', + raw: '', recipient: demoNimAddress, sender: 'HTLC-ADDRESS', - timestamp: Date.now(), - data: { - type: 'htlc', - hashAlgorithm: '', - hashCount: 0, - hashRoot: '', - raw: '', - recipient: demoNimAddress, - sender: 'HTLC-ADDRESS', - timeout: 0, - }, - }; - insertFakeNimTransactions(transformNimTransaction([tx])); - break; - } - case 'BTC': { - const tx: BtcTransactionDefinition = { - address: demoBtcAddress, - daysAgo: 0, - description: `Swap ${fromAsset} to ${toAsset}`, - fraction: toAmount / btcInitialBalance, - incoming: true, - recipientLabel: demoBtcAddress, - }; - insertFakeBtcTransactions(transformBtcTransaction([tx])); - break; - } - case 'USDC_MATIC': { - const tx: UsdcTransactionDefinition = { - fraction: 1, - daysAgo: 0, - description: `Swap ${fromAsset} to ${toAsset}`, - incoming: true, - recipientLabel: demoPolygonAddress, - }; - insertFakeUsdcTransactions(transformUsdcTransaction([tx])); - break; - } - case 'USDT_MATIC': { - const tx: UsdtTransactionDefinition = { - fraction: 1, - daysAgo: 0, - description: `Swap ${fromAsset} to ${toAsset}`, - incoming: true, - recipientLabel: demoPolygonAddress, - }; + timeout: 0, + }, + }; + insertFakeNimTransactions(transformNimTransaction([tx])); + break; + } + case 'BTC': { + const tx: BtcTransactionDefinition = { + address: demoBtcAddress, + daysAgo: 0, + description: `Swap ${fromAsset} to ${toAsset}`, + fraction: toAmount / btcInitialBalance, + incoming: true, + recipientLabel: demoBtcAddress, + }; + insertFakeBtcTransactions(transformBtcTransaction([tx])); + break; + } + case 'USDC_MATIC': { + const tx: UsdcTransactionDefinition = { + fraction: 1, + daysAgo: 0, + description: `Swap ${fromAsset} to ${toAsset}`, + incoming: true, + recipientLabel: demoPolygonAddress, + }; + insertFakeUsdcTransactions(transformUsdcTransaction([tx])); + break; + } + case 'USDT_MATIC': { + const tx: UsdtTransactionDefinition = { + fraction: 1, + daysAgo: 0, + description: `Swap ${fromAsset} to ${toAsset}`, + incoming: true, + recipientLabel: demoPolygonAddress, + }; - insertFakeUsdtTransactions(transformUsdtTransaction([tx])); - break; - } - default: { - console.warn(`Unsupported asset type: ${toAsset}`); - } + insertFakeUsdtTransactions(transformUsdtTransaction([tx])); + break; } - console.log('Demo swap completed:', { fromAsset, toAsset, fromAmount, toAmount }); - }, 3000); // Simulate a delay for the swap to complete + default: { + console.warn(`Unsupported asset type: ${toAsset}`); + } + } + console.log('Demo swap completed:', { fromAsset, toAsset, fromAmount, toAmount }); } const ignoreHubRequests = [ From a4d3ddcacbe2d30683ec17079d1c4e0bf9c6ffa1 Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 11 Mar 2025 09:33:13 +0100 Subject: [PATCH 08/31] feat(demo): enable sell and swap modals in demo mode --- src/stores/Demo.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/stores/Demo.ts b/src/stores/Demo.ts index 29f848b26..e97096807 100644 --- a/src/stores/Demo.ts +++ b/src/stores/Demo.ts @@ -110,7 +110,7 @@ export const useDemoStore = createStore({ attachIframeListeners(); replaceStakingFlow(); replaceBuyNimFlow(); - enableSendModalInDemoMode(); + enableSellAndSwapModals(); listenForSwapChanges(); }, @@ -1058,10 +1058,7 @@ const demoCSS = ` `; /** - * Intercepts fetch request: - * - fastspot.apiEndpoint will return a mock limit response - * - estimate endpoint will return mock estimate response - * - the rest of requests will be passed through + * Intercepts fetch request for swaps */ function interceptFetchRequest() { const originalFetch = window.fetch; @@ -1307,17 +1304,17 @@ function getNetworkFeePerUnit(asset: string): number { * Ensures the Send button in modals is always enabled in demo mode, regardless of network state. * This allows users to interact with the send functionality without waiting for network sync. */ -function enableSendModalInDemoMode() { +function enableSellAndSwapModals() { const observer = new MutationObserver(() => { - // Target the send modal footer button - const sendButton = document.querySelector('.send-modal-footer .nq-button'); - if (!sendButton) return; + // Target the send modal and swap footer button + const bottomButton = document.querySelector('.send-modal-footer .nq-button'); + if (!bottomButton) return; - if (sendButton.hasAttribute('disabled')) { - sendButton.removeAttribute('disabled'); + if (bottomButton.hasAttribute('disabled')) { + bottomButton.removeAttribute('disabled'); // Also remove any visual indications of being disabled - sendButton.classList.remove('disabled'); + bottomButton.classList.remove('disabled'); // Also find and hide any sync message if shown const footer = document.querySelector('.send-modal-footer'); From b9c06c131990870f7699454129ad8f656ee1d6f7 Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 11 Mar 2025 10:02:35 +0100 Subject: [PATCH 09/31] chore(demo): only allow nim-btc swaps --- src/stores/Demo.ts | 122 +++++++++++++++++++++++++++------------------ 1 file changed, 73 insertions(+), 49 deletions(-) diff --git a/src/stores/Demo.ts b/src/stores/Demo.ts index e97096807..5c4111512 100644 --- a/src/stores/Demo.ts +++ b/src/stores/Demo.ts @@ -94,6 +94,7 @@ export const useDemoStore = createStore({ rewriteDemoRoutes(); setupVisualCues(); addDemoModalRoutes(); + disableSwapTriggers(); interceptFetchRequest(); setupDemoAddresses(); @@ -203,6 +204,18 @@ function setupVisualCues() { mutationObserver.observe(document.body, { childList: true, subtree: true }); } +function disableSwapTriggers() { + const mutationObserver = new MutationObserver(() => { + const swapTriggers = document.querySelectorAll( + '.account-grid > :where(.nim-usdc-swap-button, .nim-btc-swap-button, .btc-usdc-swap-button, .account-backgrounds)' + ) as NodeListOf; + swapTriggers.forEach((trigger) => trigger.remove()); + const pairSelection = document.querySelector('.pair-selection'); + if (pairSelection) pairSelection.remove(); + }); + mutationObserver.observe(document.body, { childList: true, subtree: true }); +} + /** * Adds routes pointing to our demo modals. */ @@ -1055,6 +1068,17 @@ const demoCSS = ` .send-modal-footer .footer-notice { display: none; } + +.account-grid > button.reset, +.account-grid > .nimiq-account { + background: #e7e8ea; + border-radius: 8px; + transition: background 0.2s var(--nimiq-ease); +} + +.account-grid > button:where(:hover, :focus-visible) { + background: #dedee2 !important; +} `; /** @@ -1433,30 +1457,30 @@ function completeSwap(activeSwap: any) { insertFakeBtcTransactions(transformBtcTransaction([tx])); break; } - case 'USDC_MATIC': { - const tx: UsdcTransactionDefinition = { - fraction: 1, - daysAgo: 0, - description: `Swap ${fromAsset} to ${toAsset}`, - incoming: false, - recipientLabel: demoPolygonAddress, - }; - - insertFakeUsdcTransactions(transformUsdcTransaction([tx])); - break; - } - case 'USDT_MATIC': { - const tx: UsdtTransactionDefinition = { - fraction: 1, - daysAgo: 0, - description: `Swap ${fromAsset} to ${toAsset}`, - incoming: false, - recipientLabel: demoPolygonAddress, - }; - - insertFakeUsdtTransactions(transformUsdtTransaction([tx])); - break; - } + // case 'USDC_MATIC': { + // const tx: UsdcTransactionDefinition = { + // fraction: 1, + // daysAgo: 0, + // description: `Swap ${fromAsset} to ${toAsset}`, + // incoming: false, + // recipientLabel: demoPolygonAddress, + // }; + + // insertFakeUsdcTransactions(transformUsdcTransaction([tx])); + // break; + // } + // case 'USDT_MATIC': { + // const tx: UsdtTransactionDefinition = { + // fraction: 1, + // daysAgo: 0, + // description: `Swap ${fromAsset} to ${toAsset}`, + // incoming: false, + // recipientLabel: demoPolygonAddress, + // }; + + // insertFakeUsdtTransactions(transformUsdtTransaction([tx])); + // break; + // } default: { console.warn(`Unsupported asset type: ${fromAsset}`); } @@ -1496,29 +1520,29 @@ function completeSwap(activeSwap: any) { insertFakeBtcTransactions(transformBtcTransaction([tx])); break; } - case 'USDC_MATIC': { - const tx: UsdcTransactionDefinition = { - fraction: 1, - daysAgo: 0, - description: `Swap ${fromAsset} to ${toAsset}`, - incoming: true, - recipientLabel: demoPolygonAddress, - }; - insertFakeUsdcTransactions(transformUsdcTransaction([tx])); - break; - } - case 'USDT_MATIC': { - const tx: UsdtTransactionDefinition = { - fraction: 1, - daysAgo: 0, - description: `Swap ${fromAsset} to ${toAsset}`, - incoming: true, - recipientLabel: demoPolygonAddress, - }; - - insertFakeUsdtTransactions(transformUsdtTransaction([tx])); - break; - } + // case 'USDC_MATIC': { + // const tx: UsdcTransactionDefinition = { + // fraction: 1, + // daysAgo: 0, + // description: `Swap ${fromAsset} to ${toAsset}`, + // incoming: true, + // recipientLabel: demoPolygonAddress, + // }; + // insertFakeUsdcTransactions(transformUsdcTransaction([tx])); + // break; + // } + // case 'USDT_MATIC': { + // const tx: UsdtTransactionDefinition = { + // fraction: 1, + // daysAgo: 0, + // description: `Swap ${fromAsset} to ${toAsset}`, + // incoming: true, + // recipientLabel: demoPolygonAddress, + // }; + + // insertFakeUsdtTransactions(transformUsdtTransaction([tx])); + // break; + // } default: { console.warn(`Unsupported asset type: ${toAsset}`); } @@ -1535,7 +1559,7 @@ interface SetupSwapArgs { accountId: string; swapId: string; fund: { - type: 'BTC' | 'NIM' | 'USDC' | 'USDT', + type: 'BTC' | 'NIM' /*| 'USDC' | 'USDT' */, inputs: { address: string, transactionHash: string, @@ -1553,7 +1577,7 @@ interface SetupSwapArgs { refundAddress: string, }; redeem: { - type: 'BTC' | 'NIM' | 'USDC' | 'USDT', + type: 'BTC' | 'NIM' /*| 'USDC' | 'USDT' */, recipient: string, value: number, fee: number, From 7641cbab11d4c1c79c8cc00223d0f928938c42fc Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 11 Mar 2025 10:33:30 +0100 Subject: [PATCH 10/31] chore(demo): lint --- src/stores/Demo.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/stores/Demo.ts b/src/stores/Demo.ts index 5c4111512..8b73bcced 100644 --- a/src/stores/Demo.ts +++ b/src/stores/Demo.ts @@ -207,7 +207,7 @@ function setupVisualCues() { function disableSwapTriggers() { const mutationObserver = new MutationObserver(() => { const swapTriggers = document.querySelectorAll( - '.account-grid > :where(.nim-usdc-swap-button, .nim-btc-swap-button, .btc-usdc-swap-button, .account-backgrounds)' + '.account-grid > :where(.nim-usdc-swap-button, .nim-btc-swap-button, .btc-usdc-swap-button, .account-backgrounds)', ) as NodeListOf; swapTriggers.forEach((trigger) => trigger.remove()); const pairSelection = document.querySelector('.pair-selection'); @@ -1559,7 +1559,7 @@ interface SetupSwapArgs { accountId: string; swapId: string; fund: { - type: 'BTC' | 'NIM' /*| 'USDC' | 'USDT' */, + type: 'BTC' | 'NIM' /* | 'USDC' | 'USDT' */, inputs: { address: string, transactionHash: string, @@ -1577,7 +1577,7 @@ interface SetupSwapArgs { refundAddress: string, }; redeem: { - type: 'BTC' | 'NIM' /*| 'USDC' | 'USDT' */, + type: 'BTC' | 'NIM' /* | 'USDC' | 'USDT' */, recipient: string, value: number, fee: number, From db88ea27162d6249a258ccdc20ef7faeb440548b Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 11 Mar 2025 11:07:57 +0100 Subject: [PATCH 11/31] chore(demo): reverted changes in BuyOptionsModal. Optimized MutationObserver --- src/components/modals/BuyOptionsModal.vue | 4 - src/stores/Demo.ts | 303 ++++++++++++---------- 2 files changed, 173 insertions(+), 134 deletions(-) diff --git a/src/components/modals/BuyOptionsModal.vue b/src/components/modals/BuyOptionsModal.vue index e3070b727..a9a0604bf 100644 --- a/src/components/modals/BuyOptionsModal.vue +++ b/src/components/modals/BuyOptionsModal.vue @@ -202,7 +202,6 @@ + + From f02abf382410960327e8072f7788edb8450a5812 Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 15 Apr 2025 10:16:05 +0200 Subject: [PATCH 20/31] chore: speed up swap process --- src/lib/Demo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/Demo.ts b/src/lib/Demo.ts index 71923f4e2..8f8c3dace 100644 --- a/src/lib/Demo.ts +++ b/src/lib/Demo.ts @@ -1497,7 +1497,7 @@ function listenForSwapChanges() { }); break; } - }, 15_000); + }, 1_800); } /** From 08b974006906a96233a879dac6cbfb44256eddc7 Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 15 Apr 2025 12:48:50 +0200 Subject: [PATCH 21/31] chore(demo): obfuscate addresses --- src/lib/Demo.ts | 87 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/lib/Demo.ts b/src/lib/Demo.ts index 8f8c3dace..94e1863d2 100644 --- a/src/lib/Demo.ts +++ b/src/lib/Demo.ts @@ -153,6 +153,7 @@ function setupSingleMutationObserver() { setupVisualCues(processedElements); disableSwapTriggers(processedElements); enableSellAndSwapModals(processedElements); + obfuscateAddresses(processedElements); }; // Create one mutation observer for all DOM modifications @@ -1117,6 +1118,17 @@ const demoCSS = ` display: none; } +/* Demo address tooltip styling */ +.tooltip.demo-tooltip { + width: max-content; + background: var(--nimiq-orange-bg); + margin-left: -7rem; +} + +.tooltip.demo-tooltip::after { + background: #fc750c; /* Match the red theme for the demo warning */ +} + .demo-highlight-badge { position: absolute; width: 34px; @@ -1843,3 +1855,78 @@ export class DemoHubApi extends HubApi { }); } } + +/** + * Obfuscates addresses in the UI by: + * - Showing only first 3 chunks of addresses (rest are XXXX) for NIM addresses + * - Showing only the first few characters for BTC and polygon addresses + * - Changing the copy tooltip message + * - Changing the copy functionality to provide a demo disclaimer + */ +function obfuscateAddresses(processedElements: WeakSet) { + // Adds the common clipboard click handler to an element. + function addDemoClickHandler(el: HTMLElement) { + el.addEventListener('click', (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + el.classList.add('copied'); + setTimeout(() => el.classList.remove('copied'), 1500); + navigator.clipboard.writeText('This is a demo address - not for actual use'); + }, true); + } + + // Updates the tooltip for an element. + function updateTooltip(el: HTMLElement) { + const tooltip = el.querySelector('.tooltip') as HTMLElement; + if (tooltip && !processedElements.has(tooltip)) { + processedElements.add(tooltip); + tooltip.textContent = 'Demo address'; + tooltip.classList.add('demo-tooltip'); + addDemoClickHandler(tooltip); + } + } + + // Processes an element: marks it as processed, applies any extra changes, updates tooltip, and adds a click handler. + function processElement(el: HTMLElement, extraProcess: ((el: HTMLElement) => void) | null = null) { + if (processedElements.has(el)) return; + processedElements.add(el); + if (extraProcess) extraProcess(el); + updateTooltip(el); + addDemoClickHandler(el); + } + + // Process NIM address displays: obfuscate address chunks beyond the first three. + const nimAddressElements = document.querySelectorAll('.copyable.address-display') as NodeListOf; + nimAddressElements.forEach(el => + processElement(el, (element) => { + const chunks = element.querySelectorAll('.chunk'); + for (let i = 3; i < chunks.length; i++) { + const chunk = chunks[i]; + const space = chunk.querySelector('.space'); + chunk.textContent = 'XXXX'; + if (space) chunk.appendChild(space); + } + }) + ); + + // Process short address displays: change the last chunk of the short address. + const shortAddressElements = document.querySelectorAll('.tooltip.interactive-short-address.is-copyable') as NodeListOf; + shortAddressElements.forEach(el => + processElement(el, (element) => { + const lastChunk = element.querySelector('.short-address .address:last-child'); + if (lastChunk) { + lastChunk.textContent = 'xxxx'; + } + }) + ); + + // Process tooltip boxes inside short address displays. + const tooltipBoxElements = document.querySelectorAll('.tooltip.interactive-short-address.is-copyable .tooltip-box') as NodeListOf; + tooltipBoxElements.forEach(el => { + if (processedElements.has(el)) return; + processedElements.add(el); + el.textContent = 'Demo address'; + el.classList.add('demo-tooltip'); + addDemoClickHandler(el); + }); +} From 84a9b7346a7e0ee2e0f1663160520af7713c9cb7 Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 15 Apr 2025 18:50:39 +0200 Subject: [PATCH 22/31] chore(demo): handle receive modal --- src/lib/Demo.ts | 60 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/src/lib/Demo.ts b/src/lib/Demo.ts index 94e1863d2..a82906f51 100644 --- a/src/lib/Demo.ts +++ b/src/lib/Demo.ts @@ -56,10 +56,10 @@ const demoPolygonAddress = '0xabc123DemoPolygonAddress'; const buyFromAddress = 'NQ04 JG63 HYXL H3QF PPNA 7ED7 426M 3FQE FHE5'; // We keep this as our global/final balance, which should result from the transactions -const nimInitialBalance = 140_418_00000; // 14,041,800,000 - 14 april, 2018. 5 decimals. -const btcInitialBalance = 1_0000000; // 1 BTC (8 decimals) -const usdtInitialBalance = 5_000_000000; // 5000 USDT (6 decimals) -const usdcInitialBalance = 3_000_000000; // 3000 USDC (6 decimals) +const nimInitialBalance = 140_418 * 1e5; // 14,041,800,000 - 14 april, 2018. 5 decimals. +const btcInitialBalance = 0.0025 * 1e8; // 1 BTC (8 decimals) +const usdtInitialBalance = 514.83 * 1e6; // 5000 USDT (6 decimals) +const usdcInitialBalance = 357.38 * 1e6; // 3000 USDC (6 decimals) // Swaps const onGoingSwaps = new Map(); @@ -126,6 +126,12 @@ function insertCustomDemoStyles() { */ function rewriteDemoRoutes() { demoRouter.beforeEach((to, _from, next) => { + if (to.path.startsWith('/receive/') && !to.path.startsWith('/receive/nim')) { + return next({ + path: `/${DemoModal.Fallback}`, + query: { ...to.query, [DEMO_PARAM]: '' }, + }) + } // Redirect certain known paths to the Buy demo modal if (to.path === '/buy') { return next({ @@ -154,6 +160,7 @@ function setupSingleMutationObserver() { disableSwapTriggers(processedElements); enableSellAndSwapModals(processedElements); obfuscateAddresses(processedElements); + observeReceiveModal(processedElements); }; // Create one mutation observer for all DOM modifications @@ -1897,7 +1904,7 @@ function obfuscateAddresses(processedElements: WeakSet) { // Process NIM address displays: obfuscate address chunks beyond the first three. const nimAddressElements = document.querySelectorAll('.copyable.address-display') as NodeListOf; - nimAddressElements.forEach(el => + nimAddressElements.forEach((el) => processElement(el, (element) => { const chunks = element.querySelectorAll('.chunk'); for (let i = 3; i < chunks.length; i++) { @@ -1906,23 +1913,23 @@ function obfuscateAddresses(processedElements: WeakSet) { chunk.textContent = 'XXXX'; if (space) chunk.appendChild(space); } - }) + }), ); // Process short address displays: change the last chunk of the short address. const shortAddressElements = document.querySelectorAll('.tooltip.interactive-short-address.is-copyable') as NodeListOf; - shortAddressElements.forEach(el => + shortAddressElements.forEach((el) => processElement(el, (element) => { const lastChunk = element.querySelector('.short-address .address:last-child'); if (lastChunk) { lastChunk.textContent = 'xxxx'; } - }) + }), ); // Process tooltip boxes inside short address displays. const tooltipBoxElements = document.querySelectorAll('.tooltip.interactive-short-address.is-copyable .tooltip-box') as NodeListOf; - tooltipBoxElements.forEach(el => { + tooltipBoxElements.forEach((el) => { if (processedElements.has(el)) return; processedElements.add(el); el.textContent = 'Demo address'; @@ -1930,3 +1937,38 @@ function obfuscateAddresses(processedElements: WeakSet) { addDemoClickHandler(el); }); } + +/** + * Observes the receive modal and redirects relevant button clicks to the fallback modal + */ +function observeReceiveModal(processedElements: WeakSet) { + // Find the receive modal + const receiveModal = document.querySelector('.receive-modal'); + if (!receiveModal) return; + + // Look for buttons that should redirect to the fallback modal + const buttons = receiveModal.querySelectorAll('.nq-button-s, .qr-button'); + + buttons.forEach(button => { + // Skip if we've already processed this button + if (processedElements.has(button)) return; + + // Mark as processed to avoid adding multiple listeners + processedElements.add(button); + + // Replace the original click handler with our redirect + button.addEventListener('click', (event) => { + // Prevent the default action and stop propagation + event.preventDefault(); + event.stopPropagation(); + + // Redirect to the fallback modal + demoRouter.replace({ + path: `/${DemoModal.Fallback}`, + query: { [DEMO_PARAM]: '' }, + }); + + console.log('[Demo] Redirected receive modal button click to fallback modal'); + }, true); // Use capture to intercept the event before other handlers + }); +} From 9ea22a8237b94f9756d2f0eeb1f5b39022856251 Mon Sep 17 00:00:00 2001 From: onmax Date: Thu, 17 Apr 2025 13:55:10 +0200 Subject: [PATCH 23/31] fix: keep `demo` query after each navigation --- src/lib/Demo.ts | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/lib/Demo.ts b/src/lib/Demo.ts index a82906f51..491d46519 100644 --- a/src/lib/Demo.ts +++ b/src/lib/Demo.ts @@ -125,25 +125,25 @@ function insertCustomDemoStyles() { * Sets up a router guard to handle redirects for the demo environment. */ function rewriteDemoRoutes() { - demoRouter.beforeEach((to, _from, next) => { + demoRouter.beforeResolve(async (to, from, next) => { + // Avoid displaying receive modal if (to.path.startsWith('/receive/') && !to.path.startsWith('/receive/nim')) { - return next({ - path: `/${DemoModal.Fallback}`, - query: { ...to.query, [DEMO_PARAM]: '' }, - }) + return next({ path: `/${DemoModal.Fallback}`, query: { ...to.query, [DEMO_PARAM]: '' } }); } + // Redirect certain known paths to the Buy demo modal if (to.path === '/buy') { - return next({ - path: `/${DemoModal.Buy}`, - query: { ...to.query, [DEMO_PARAM]: '' }, - }); + return next({ path: `/${DemoModal.Buy}`, query: { ...to.query, [DEMO_PARAM]: '' }, replace: true }); } - // Ensure the ?demo param is in place - if (to.query.demo === '') return next(); - return next({ path: to.path, query: { ...to.query, [DEMO_PARAM]: '' } }); + // FIXME: When clicking the hamburger menu, nothing opens + if (to.query[DEMO_PARAM] === undefined) { + next({ path: to.path, query: { ...to.query, [DEMO_PARAM]: '' }, replace: true }); + } + + next(); }); + } /** @@ -1945,29 +1945,29 @@ function observeReceiveModal(processedElements: WeakSet) { // Find the receive modal const receiveModal = document.querySelector('.receive-modal'); if (!receiveModal) return; - + // Look for buttons that should redirect to the fallback modal const buttons = receiveModal.querySelectorAll('.nq-button-s, .qr-button'); - + buttons.forEach(button => { // Skip if we've already processed this button if (processedElements.has(button)) return; - + // Mark as processed to avoid adding multiple listeners processedElements.add(button); - + // Replace the original click handler with our redirect button.addEventListener('click', (event) => { // Prevent the default action and stop propagation event.preventDefault(); event.stopPropagation(); - + // Redirect to the fallback modal demoRouter.replace({ path: `/${DemoModal.Fallback}`, query: { [DEMO_PARAM]: '' }, }); - + console.log('[Demo] Redirected receive modal button click to fallback modal'); }, true); // Use capture to intercept the event before other handlers }); From 3867fd6faefb79ff022adca247b67739b7247a11 Mon Sep 17 00:00:00 2001 From: onmax Date: Thu, 17 Apr 2025 19:24:15 +0200 Subject: [PATCH 24/31] feat(demo): improved buy flow --- src/components/modals/demos/DemoModalBuy.vue | 335 ++++++++++++++---- .../modals/demos/DemoModalFallback.vue | 1 + src/lib/Demo.ts | 60 +++- 3 files changed, 320 insertions(+), 76 deletions(-) diff --git a/src/components/modals/demos/DemoModalBuy.vue b/src/components/modals/demos/DemoModalBuy.vue index 537d70bf1..4dc5d58a3 100644 --- a/src/components/modals/demos/DemoModalBuy.vue +++ b/src/components/modals/demos/DemoModalBuy.vue @@ -1,140 +1,333 @@ diff --git a/src/components/modals/demos/DemoModalFallback.vue b/src/components/modals/demos/DemoModalFallback.vue index f203bdda4..2b44779db 100644 --- a/src/components/modals/demos/DemoModalFallback.vue +++ b/src/components/modals/demos/DemoModalFallback.vue @@ -9,6 +9,7 @@ {{ $t('You don\'t even need your email.') }}

+ {{ $t('Create a wallet simply by setting a password and give the real deal a try. No download, no costs, no personal data.') }}

diff --git a/src/lib/Demo.ts b/src/lib/Demo.ts index 491d46519..b649ce353 100644 --- a/src/lib/Demo.ts +++ b/src/lib/Demo.ts @@ -133,17 +133,16 @@ function rewriteDemoRoutes() { // Redirect certain known paths to the Buy demo modal if (to.path === '/buy') { - return next({ path: `/${DemoModal.Buy}`, query: { ...to.query, [DEMO_PARAM]: '' }, replace: true }); + return next({ path: `/${DemoModal.Buy}`, query: { ...to.query, [DEMO_PARAM]: '' } }); } // FIXME: When clicking the hamburger menu, nothing opens if (to.query[DEMO_PARAM] === undefined) { - next({ path: to.path, query: { ...to.query, [DEMO_PARAM]: '' }, replace: true }); + return next({ path: to.path, query: { ...to.query, [DEMO_PARAM]: '' }, replace: true }); } next(); }); - } /** @@ -160,6 +159,7 @@ function setupSingleMutationObserver() { disableSwapTriggers(processedElements); enableSellAndSwapModals(processedElements); obfuscateAddresses(processedElements); + observeTransactionList(processedElements); observeReceiveModal(processedElements); }; @@ -520,7 +520,7 @@ export function dangerouslyInsertFakeBuyNimTransaction(amount: number) { sender: buyFromAddress, data: { type: 'raw', - raw: encodeTextToHex('Online Purchase'), + raw: encodeTextToHex('NIM Bank purchase'), }, }; @@ -1949,7 +1949,7 @@ function observeReceiveModal(processedElements: WeakSet) { // Look for buttons that should redirect to the fallback modal const buttons = receiveModal.querySelectorAll('.nq-button-s, .qr-button'); - buttons.forEach(button => { + buttons.forEach((button) => { // Skip if we've already processed this button if (processedElements.has(button)) return; @@ -1972,3 +1972,53 @@ function observeReceiveModal(processedElements: WeakSet) { }, true); // Use capture to intercept the event before other handlers }); } + +/** + * Observes the transaction list items to replace the identicon and address. + */ +function observeTransactionList(processedElements: WeakSet) { + const buttons = document.querySelectorAll('.transaction-list button.reset.transaction.confirmed'); + buttons.forEach((button) => { + if (processedElements.has(button)) return; + processedElements.add(button); + + const message = button.querySelector('.message') as HTMLDivElement; + if (!message || message.innerText !== '·NIM Bank purchase') return; + + // Replace identicon with bankSvg + const iconDiv = button.querySelector(':scope > .identicon'); + if (iconDiv) { + iconDiv.innerHTML = bankSvg; + } + + // Replace address text + const addressDiv = button.querySelector(':scope > .data > .address'); + if (addressDiv) { + addressDiv.textContent = 'Demo Bank'; + } + }); + + // Replace the identicon in the transaction modal for the bank + const transactionModal = document.querySelector('.transaction-modal') as HTMLDivElement; + if (!transactionModal) return; + if (processedElements.has(transactionModal)) return; + processedElements.add(transactionModal); + const message = transactionModal.querySelector('.message') as HTMLDivElement; + if (message && message.innerText === 'NIM Bank purchase') { + const iconDiv = transactionModal.querySelector('.identicon > .identicon'); + if (iconDiv) { + iconDiv.innerHTML= bankSvg.replaceAll('="48"', '="72"'); + } + } +} + +const bankSvg = ` + + + + + + + + + `; From 9a8574ca5825bfc2b38ee391379e878882fc5ff0 Mon Sep 17 00:00:00 2001 From: onmax Date: Thu, 15 May 2025 08:13:35 +0200 Subject: [PATCH 25/31] fix: rename `isDemoEnabled` to `isDemoActive` for consistency --- src/components/layouts/Sidebar.vue | 6 +++--- src/main.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/layouts/Sidebar.vue b/src/components/layouts/Sidebar.vue index e28395385..0679cb996 100644 --- a/src/components/layouts/Sidebar.vue +++ b/src/components/layouts/Sidebar.vue @@ -20,7 +20,7 @@ -
+
{{ $t('Demo mode') }}
@@ -380,7 +380,7 @@ export default defineComponent({ : null; }); - const isDemoEnabled = checkIfDemoIsActive(); + const isDemoActive = checkIfDemoIsActive(); return { CryptoCurrency, @@ -408,7 +408,7 @@ export default defineComponent({ openSellModal, nimSellOptions, activeCurrency, - isDemoEnabled, + isDemoActive , }; }, components: { diff --git a/src/main.ts b/src/main.ts index 1efa4984f..7a9eff898 100644 --- a/src/main.ts +++ b/src/main.ts @@ -51,9 +51,9 @@ Vue.use(VuePortal, { name: 'Portal' }); async function start() { initPwa(); // Must be called as soon as possible to catch early browser events related to PWA - const isDemoEnabled = checkIfDemoIsActive(); + const isDemoActive = checkIfDemoIsActive(); - if (!isDemoEnabled) { + if (!isDemoActive ) { await initStorage(); // Must be awaited before starting Vue initTrials(); // Must be called after storage was initialized, can affect Config // Must run after VueCompositionApi has been enabled and after storage was initialized. Could potentially run in @@ -104,13 +104,13 @@ async function start() { const { language } = useSettingsStore(); loadLanguage(language.value); - if (!isDemoEnabled) { + if (!isDemoActive ) { startSentry(); } const { config } = useConfig(); - if (isDemoEnabled) { + if (isDemoActive ) { document.title = 'Nimiq Wallet Demo'; } else if (config.environment !== ENV_MAIN) { document.title = 'Nimiq Testnet Wallet'; @@ -121,7 +121,7 @@ async function start() { initFastspotApi(config.fastspot.apiEndpoint, config.fastspot.apiKey); }); - if (!isDemoEnabled) { + if (!isDemoActive ) { watch(() => { if (!config.oasis.apiEndpoint) return; initOasisApi(config.oasis.apiEndpoint); From 1edee3fc3e9cd64311eb6009ded933cf06d6d662 Mon Sep 17 00:00:00 2001 From: onmax Date: Thu, 15 May 2025 08:40:22 +0200 Subject: [PATCH 26/31] lint --- src/components/modals/demos/DemoModalBuy.vue | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/components/modals/demos/DemoModalBuy.vue b/src/components/modals/demos/DemoModalBuy.vue index 4dc5d58a3..1f8cb4305 100644 --- a/src/components/modals/demos/DemoModalBuy.vue +++ b/src/components/modals/demos/DemoModalBuy.vue @@ -58,16 +58,6 @@ message="This transaction is instant and secure." />
- @@ -326,8 +316,8 @@ export default defineComponent({ ::v-deep .status-screen { height: 100%; - position: absolute; - inset: -6px; - width: calc(100% + 6px); + position: absolute; + inset: -6px; + width: calc(100% + 6px); } From 256fcb5c4f8543cc67823c990af3f50e4e968b0d Mon Sep 17 00:00:00 2001 From: onmax Date: Thu, 15 May 2025 08:40:40 +0200 Subject: [PATCH 27/31] feat(demo): add demo mode configuration and initialization logic --- src/config/config.local.ts | 8 ++- src/config/config.mainnet.ts | 6 ++ src/config/config.testnet.ts | 6 ++ src/lib/Demo.ts | 128 ++++++++++++++++++++++++++--------- 4 files changed, 114 insertions(+), 34 deletions(-) diff --git a/src/config/config.local.ts b/src/config/config.local.ts index 060592cce..ff571b789 100644 --- a/src/config/config.local.ts +++ b/src/config/config.local.ts @@ -20,6 +20,12 @@ export default { enableBitcoin: true, pageVisibilityTxRefreshInterval: 1 * 60e3, // 1 minute + demo: { + // Controls if demo mode is enabled. When set to a string, demo mode is only enabled + // if the current hostname matches this value. When true, demo mode is always enabled. + enabled: false, + }, + staking: { // The block heights determining the on-chain pre-staking window. All transactions inside this window count // for pre-staking. @@ -37,7 +43,7 @@ export default { }, polygon: { - enabled: false, + enabled: true, networkId: 80002, rpcEndpoint: 'wss://polygon-amoy.g.alchemy.com/v2/#ALCHEMY_API_KEY#', rpcMaxBlockRange: 1_296_000, // 30 days - Range not limited, only limited by number of logs returned diff --git a/src/config/config.mainnet.ts b/src/config/config.mainnet.ts index 98a4fcfa9..78b48d003 100644 --- a/src/config/config.mainnet.ts +++ b/src/config/config.mainnet.ts @@ -30,6 +30,12 @@ export default { enableBitcoin: true, pageVisibilityTxRefreshInterval: 5 * 60e3, // 5 minutes + demo: { + // Controls if demo mode is enabled. When set to a string, demo mode is only enabled + // if the current hostname matches this value. When true, demo mode is always enabled. + enabled: 'wallet-demo.nimiq.com', + }, + staking: { // The block heights determining the on-chain pre-staking window. All transactions inside this window count // for pre-staking. diff --git a/src/config/config.testnet.ts b/src/config/config.testnet.ts index c4a71d48e..55c5782bb 100644 --- a/src/config/config.testnet.ts +++ b/src/config/config.testnet.ts @@ -20,6 +20,12 @@ export default { enableBitcoin: true, pageVisibilityTxRefreshInterval: 2 * 60e3, // 2 minutes + demo: { + // Controls if demo mode is enabled. When set to a string, demo mode is only enabled + // if the current hostname matches this value. When true, demo mode is always enabled. + enabled: false, + }, + staking: { prestakingStartBlock: 3_023_730, prestakingEndBlock: 3_028_050, diff --git a/src/lib/Demo.ts b/src/lib/Demo.ts index b649ce353..bffbacfc9 100644 --- a/src/lib/Demo.ts +++ b/src/lib/Demo.ts @@ -1,4 +1,27 @@ /* eslint-disable max-len, consistent-return, no-console, no-async-promise-executor */ + +/** + * Demo Mode for the Nimiq Wallet + * ------------------------------ + * + * This module provides a complete demo environment for the Nimiq Wallet, allowing users to + * try out wallet features without connecting to real blockchain networks. + * + * Demo mode can be activated in three ways: + * 1. By adding the '?demo=' URL parameter to any wallet URL + * 2. Setting config.demo.enabled = true in the configuration (always enabled) + * 3. Setting config.demo.enabled = 'domain.com' to enable only on a specific domain + * + * When active, demo mode: + * - Creates fake accounts with demo balances + * - Generates fake transaction history + * - Simulates blockchain interactions like sending, receiving, and swapping + * - Obfuscates addresses and disables certain features that wouldn't work in demo mode + * - Redirects certain actions to explainer modals + * + * This is intended for demonstration, educational, and testing purposes only. +*/ + import VueRouter from 'vue-router'; import { TransactionState as ElectrumTransactionState } from '@nimiq/electrum-client'; import { CryptoCurrency, Utf8Tools } from '@nimiq/utils'; @@ -31,6 +54,8 @@ export type DemoState = { // The query param that activates the demo. e.g. https://wallet.nimiq.com/?demo= const DEMO_PARAM = 'demo'; +// No additional import needed, Config is already imported above + const DemoFallbackModal = () => import( /* webpackChunkName: 'demo-hub-fallback-modal' */ @@ -39,7 +64,7 @@ const DemoFallbackModal = () => const DemoPurchaseModal = () => import( - /* webpackChunkName: 'account-menu-modal' */ + /* webpackChunkName: 'demo-modal-buy' */ '@/components/modals/demos/DemoModalBuy.vue' ); @@ -73,12 +98,17 @@ let demoRouter: VueRouter; * Initializes the demo environment and sets up various routes, data, and watchers. */ export function dangerouslyInitializeDemo(router: VueRouter) { + // Check if demo is active according to the configuration + if (!checkIfDemoIsActive()) { + console.info('[Demo] Demo mode not enabled in configuration. Skipping initialization.'); + return; + } + console.warn('[Demo] Initializing demo environment...'); demoRouter = router; insertCustomDemoStyles(); - rewriteDemoRoutes(); setupSingleMutationObserver(); addDemoModalRoutes(); interceptFetchRequest(); @@ -106,10 +136,28 @@ export function dangerouslyInitializeDemo(router: VueRouter) { // #region App setup /** - * Checks if the 'demo' query param is present in the URL. + * Checks if the demo mode should be active based on the URL param and configuration. + * Demo mode is active if: + * 1. The demo query param is present in the URL, OR + * 2. The Config.demo.enabled is true, OR + * 3. The Config.demo.enabled is a string that matches the current hostname */ export function checkIfDemoIsActive() { - return window.location.search.includes(DEMO_PARAM); + // Always check URL param first - this allows demo mode to be forced on any instance + if (window.location.search.includes(DEMO_PARAM)) return true; + + // Check configuration - can be boolean or string (hostname) + const demoConfig = Config.demo?.enabled; + + if (typeof demoConfig === 'boolean') { + return demoConfig; + } + + if (typeof demoConfig === 'string') { + return window.location.hostname === demoConfig; + } + + return false; } /** @@ -121,30 +169,6 @@ function insertCustomDemoStyles() { document.head.appendChild(styleElement); } -/** - * Sets up a router guard to handle redirects for the demo environment. - */ -function rewriteDemoRoutes() { - demoRouter.beforeResolve(async (to, from, next) => { - // Avoid displaying receive modal - if (to.path.startsWith('/receive/') && !to.path.startsWith('/receive/nim')) { - return next({ path: `/${DemoModal.Fallback}`, query: { ...to.query, [DEMO_PARAM]: '' } }); - } - - // Redirect certain known paths to the Buy demo modal - if (to.path === '/buy') { - return next({ path: `/${DemoModal.Buy}`, query: { ...to.query, [DEMO_PARAM]: '' } }); - } - - // FIXME: When clicking the hamburger menu, nothing opens - if (to.query[DEMO_PARAM] === undefined) { - return next({ path: to.path, query: { ...to.query, [DEMO_PARAM]: '' }, replace: true }); - } - - next(); - }); -} - /** * Sets up a single mutation observer to handle all DOM-based demo features */ @@ -320,7 +344,16 @@ function attachIframeListeners() { const { kind, data } = event.data as DemoFlowMessage; if (kind === MessageEventName.FlowChange && demoRoutes[data]) { useAccountStore().setActiveCurrency(CryptoCurrency.NIM); - demoRouter.push(demoRoutes[data]); + + // Only include demo parameter in query if it's present in the current URL + const query = window.location.search.includes(DEMO_PARAM) + ? { [DEMO_PARAM]: '' } + : undefined; + + demoRouter.push({ + path: demoRoutes[data], + query, + }); } }); @@ -971,7 +1004,14 @@ function replaceBuyNimFlow() { btn1.className = 'nq-button-s inverse'; btn1.style.flex = '1'; btn1.addEventListener('click', () => { - demoRouter.push('/buy'); + // Only include demo parameter in query if it's present in the current URL + const query = window.location.search.includes(DEMO_PARAM) + ? { [DEMO_PARAM]: '' } + : undefined; + demoRouter.push({ + path: '/buy', + query, + }); }); btn1.innerHTML = 'Buy'; @@ -1021,7 +1061,17 @@ function replaceStakingFlow() { const amount = Number.parseFloat(amountInput.value.replaceAll(/[^\d]/g, '')) * 1e5; const { address: validatorAddress } = activeValidator.value!; - demoRouter.push('/'); + + // Only include demo parameter in query if it's present in the current URL + const query = window.location.search.includes(DEMO_PARAM) + ? { [DEMO_PARAM]: '' } + : undefined; + + demoRouter.push({ + path: '/', + query, + }); + await new Promise((resolve) => { window.setTimeout(resolve, 100); }); setStake({ activeBalance: 0, @@ -1856,7 +1906,14 @@ export class DemoHubApi extends HubApi { }); console.log('[Demo] Redirecting to fallback modal'); - demoRouter.push(`/${DemoModal.Fallback}`); + // Only include demo parameter in query if it's present in the current URL + const query = window.location.search.includes(DEMO_PARAM) + ? { [DEMO_PARAM]: '' } + : undefined; + demoRouter.push({ + path: `/${DemoModal.Fallback}`, + query, + }); }); }, }); @@ -1962,10 +2019,15 @@ function observeReceiveModal(processedElements: WeakSet) { event.preventDefault(); event.stopPropagation(); + // Only include demo parameter in query if it's present in the current URL + const query = window.location.search.includes(DEMO_PARAM) + ? { [DEMO_PARAM]: '' } + : undefined; + // Redirect to the fallback modal demoRouter.replace({ path: `/${DemoModal.Fallback}`, - query: { [DEMO_PARAM]: '' }, + query, }); console.log('[Demo] Redirected receive modal button click to fallback modal'); From 264e250d0aad6c9d201e5d68775cfa6ec0c21733 Mon Sep 17 00:00:00 2001 From: onmax Date: Thu, 15 May 2025 08:42:53 +0200 Subject: [PATCH 28/31] lint --- src/components/layouts/Sidebar.vue | 4 +-- src/lib/Demo.ts | 40 +++++++++++++++--------------- src/main.ts | 10 ++++---- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/components/layouts/Sidebar.vue b/src/components/layouts/Sidebar.vue index 0679cb996..7ae234d01 100644 --- a/src/components/layouts/Sidebar.vue +++ b/src/components/layouts/Sidebar.vue @@ -380,7 +380,7 @@ export default defineComponent({ : null; }); - const isDemoActive = checkIfDemoIsActive(); + const isDemoActive = checkIfDemoIsActive(); return { CryptoCurrency, @@ -408,7 +408,7 @@ export default defineComponent({ openSellModal, nimSellOptions, activeCurrency, - isDemoActive , + isDemoActive, }; }, components: { diff --git a/src/lib/Demo.ts b/src/lib/Demo.ts index bffbacfc9..4ef27d922 100644 --- a/src/lib/Demo.ts +++ b/src/lib/Demo.ts @@ -3,22 +3,22 @@ /** * Demo Mode for the Nimiq Wallet * ------------------------------ - * + * * This module provides a complete demo environment for the Nimiq Wallet, allowing users to * try out wallet features without connecting to real blockchain networks. - * + * * Demo mode can be activated in three ways: * 1. By adding the '?demo=' URL parameter to any wallet URL * 2. Setting config.demo.enabled = true in the configuration (always enabled) * 3. Setting config.demo.enabled = 'domain.com' to enable only on a specific domain - * + * * When active, demo mode: * - Creates fake accounts with demo balances * - Generates fake transaction history * - Simulates blockchain interactions like sending, receiving, and swapping * - Obfuscates addresses and disables certain features that wouldn't work in demo mode * - Redirects certain actions to explainer modals - * + * * This is intended for demonstration, educational, and testing purposes only. */ @@ -103,7 +103,7 @@ export function dangerouslyInitializeDemo(router: VueRouter) { console.info('[Demo] Demo mode not enabled in configuration. Skipping initialization.'); return; } - + console.warn('[Demo] Initializing demo environment...'); demoRouter = router; @@ -145,18 +145,18 @@ export function dangerouslyInitializeDemo(router: VueRouter) { export function checkIfDemoIsActive() { // Always check URL param first - this allows demo mode to be forced on any instance if (window.location.search.includes(DEMO_PARAM)) return true; - + // Check configuration - can be boolean or string (hostname) const demoConfig = Config.demo?.enabled; - + if (typeof demoConfig === 'boolean') { return demoConfig; } - + if (typeof demoConfig === 'string') { return window.location.hostname === demoConfig; } - + return false; } @@ -344,12 +344,12 @@ function attachIframeListeners() { const { kind, data } = event.data as DemoFlowMessage; if (kind === MessageEventName.FlowChange && demoRoutes[data]) { useAccountStore().setActiveCurrency(CryptoCurrency.NIM); - + // Only include demo parameter in query if it's present in the current URL - const query = window.location.search.includes(DEMO_PARAM) + const query = window.location.search.includes(DEMO_PARAM) ? { [DEMO_PARAM]: '' } : undefined; - + demoRouter.push({ path: demoRoutes[data], query, @@ -1005,7 +1005,7 @@ function replaceBuyNimFlow() { btn1.style.flex = '1'; btn1.addEventListener('click', () => { // Only include demo parameter in query if it's present in the current URL - const query = window.location.search.includes(DEMO_PARAM) + const query = window.location.search.includes(DEMO_PARAM) ? { [DEMO_PARAM]: '' } : undefined; demoRouter.push({ @@ -1061,17 +1061,17 @@ function replaceStakingFlow() { const amount = Number.parseFloat(amountInput.value.replaceAll(/[^\d]/g, '')) * 1e5; const { address: validatorAddress } = activeValidator.value!; - + // Only include demo parameter in query if it's present in the current URL - const query = window.location.search.includes(DEMO_PARAM) + const query = window.location.search.includes(DEMO_PARAM) ? { [DEMO_PARAM]: '' } : undefined; - + demoRouter.push({ path: '/', query, }); - + await new Promise((resolve) => { window.setTimeout(resolve, 100); }); setStake({ activeBalance: 0, @@ -1907,7 +1907,7 @@ export class DemoHubApi extends HubApi { console.log('[Demo] Redirecting to fallback modal'); // Only include demo parameter in query if it's present in the current URL - const query = window.location.search.includes(DEMO_PARAM) + const query = window.location.search.includes(DEMO_PARAM) ? { [DEMO_PARAM]: '' } : undefined; demoRouter.push({ @@ -2020,7 +2020,7 @@ function observeReceiveModal(processedElements: WeakSet) { event.stopPropagation(); // Only include demo parameter in query if it's present in the current URL - const query = window.location.search.includes(DEMO_PARAM) + const query = window.location.search.includes(DEMO_PARAM) ? { [DEMO_PARAM]: '' } : undefined; @@ -2069,7 +2069,7 @@ function observeTransactionList(processedElements: WeakSet) { if (message && message.innerText === 'NIM Bank purchase') { const iconDiv = transactionModal.querySelector('.identicon > .identicon'); if (iconDiv) { - iconDiv.innerHTML= bankSvg.replaceAll('="48"', '="72"'); + iconDiv.innerHTML = bankSvg.replaceAll('="48"', '="72"'); } } } diff --git a/src/main.ts b/src/main.ts index 7a9eff898..51bf08065 100644 --- a/src/main.ts +++ b/src/main.ts @@ -51,9 +51,9 @@ Vue.use(VuePortal, { name: 'Portal' }); async function start() { initPwa(); // Must be called as soon as possible to catch early browser events related to PWA - const isDemoActive = checkIfDemoIsActive(); + const isDemoActive = checkIfDemoIsActive(); - if (!isDemoActive ) { + if (!isDemoActive) { await initStorage(); // Must be awaited before starting Vue initTrials(); // Must be called after storage was initialized, can affect Config // Must run after VueCompositionApi has been enabled and after storage was initialized. Could potentially run in @@ -104,13 +104,13 @@ async function start() { const { language } = useSettingsStore(); loadLanguage(language.value); - if (!isDemoActive ) { + if (!isDemoActive) { startSentry(); } const { config } = useConfig(); - if (isDemoActive ) { + if (isDemoActive) { document.title = 'Nimiq Wallet Demo'; } else if (config.environment !== ENV_MAIN) { document.title = 'Nimiq Testnet Wallet'; @@ -121,7 +121,7 @@ async function start() { initFastspotApi(config.fastspot.apiEndpoint, config.fastspot.apiKey); }); - if (!isDemoActive ) { + if (!isDemoActive) { watch(() => { if (!config.oasis.apiEndpoint) return; initOasisApi(config.oasis.apiEndpoint); From a25e044037f127a48bb1d5dfea16df7cd121b10c Mon Sep 17 00:00:00 2001 From: onmax Date: Thu, 15 May 2025 09:06:00 +0200 Subject: [PATCH 29/31] fix(config): disable polygon network by default --- src/config/config.local.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/config.local.ts b/src/config/config.local.ts index ff571b789..c732b8234 100644 --- a/src/config/config.local.ts +++ b/src/config/config.local.ts @@ -43,7 +43,7 @@ export default { }, polygon: { - enabled: true, + enabled: false, networkId: 80002, rpcEndpoint: 'wss://polygon-amoy.g.alchemy.com/v2/#ALCHEMY_API_KEY#', rpcMaxBlockRange: 1_296_000, // 30 days - Range not limited, only limited by number of logs returned From f1852597d26aee97839499792e4f49cbc1471b68 Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 8 Jul 2025 08:27:35 +0200 Subject: [PATCH 30/31] feat(demo): enhance demo build configuration and streamline initialization logic --- package.json | 3 ++ src/lib/Demo.ts | 54 ++------------------ src/main-demo.ts | 128 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 49 +++++++----------- vue.config.js | 61 +++++++++++++++++++--- 5 files changed, 205 insertions(+), 90 deletions(-) create mode 100644 src/main-demo.ts diff --git a/package.json b/package.json index 88e6965e2..2ba8c27c2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "packageManager": "yarn@1.22.22", "scripts": { "serve": "yarn pre && yarn vue-cli-service serve --port 8081", + "serve:demo": "yarn pre && build=demo yarn vue-cli-service serve --port 8081", "build": "yarn pre && yarn vue-cli-service build", + "build:demo": "yarn pre && build=demo NODE_ENV=production yarn vue-cli-service build", + "build:demo-production": "yarn pre && build=demo-production NODE_ENV=production yarn vue-cli-service build", "lint": "vue-cli-service lint --no-fix", "lint:fix": "vue-cli-service lint", "build:bitcoinjs": "yarn --silent browserify bitcoinjs-parts.js -p common-shakeify -s BitcoinJS | yarn terser --compress --mangle --source-map --output public/bitcoin/BitcoinJS.min.js", diff --git a/src/lib/Demo.ts b/src/lib/Demo.ts index 4ef27d922..ac09f80ac 100644 --- a/src/lib/Demo.ts +++ b/src/lib/Demo.ts @@ -51,9 +51,6 @@ export type DemoState = { active: boolean, }; -// The query param that activates the demo. e.g. https://wallet.nimiq.com/?demo= -const DEMO_PARAM = 'demo'; - // No additional import needed, Config is already imported above const DemoFallbackModal = () => @@ -136,28 +133,11 @@ export function dangerouslyInitializeDemo(router: VueRouter) { // #region App setup /** - * Checks if the demo mode should be active based on the URL param and configuration. - * Demo mode is active if: - * 1. The demo query param is present in the URL, OR - * 2. The Config.demo.enabled is true, OR - * 3. The Config.demo.enabled is a string that matches the current hostname + * Checks if the demo mode should be active. + * In demo builds, this always returns true since demo builds are separate deployments. */ export function checkIfDemoIsActive() { - // Always check URL param first - this allows demo mode to be forced on any instance - if (window.location.search.includes(DEMO_PARAM)) return true; - - // Check configuration - can be boolean or string (hostname) - const demoConfig = Config.demo?.enabled; - - if (typeof demoConfig === 'boolean') { - return demoConfig; - } - - if (typeof demoConfig === 'string') { - return window.location.hostname === demoConfig; - } - - return false; + return true; } /** @@ -345,14 +325,8 @@ function attachIframeListeners() { if (kind === MessageEventName.FlowChange && demoRoutes[data]) { useAccountStore().setActiveCurrency(CryptoCurrency.NIM); - // Only include demo parameter in query if it's present in the current URL - const query = window.location.search.includes(DEMO_PARAM) - ? { [DEMO_PARAM]: '' } - : undefined; - demoRouter.push({ path: demoRoutes[data], - query, }); } }); @@ -1004,13 +978,8 @@ function replaceBuyNimFlow() { btn1.className = 'nq-button-s inverse'; btn1.style.flex = '1'; btn1.addEventListener('click', () => { - // Only include demo parameter in query if it's present in the current URL - const query = window.location.search.includes(DEMO_PARAM) - ? { [DEMO_PARAM]: '' } - : undefined; demoRouter.push({ path: '/buy', - query, }); }); btn1.innerHTML = 'Buy'; @@ -1062,14 +1031,8 @@ function replaceStakingFlow() { const { address: validatorAddress } = activeValidator.value!; - // Only include demo parameter in query if it's present in the current URL - const query = window.location.search.includes(DEMO_PARAM) - ? { [DEMO_PARAM]: '' } - : undefined; - demoRouter.push({ path: '/', - query, }); await new Promise((resolve) => { window.setTimeout(resolve, 100); }); @@ -1906,13 +1869,8 @@ export class DemoHubApi extends HubApi { }); console.log('[Demo] Redirecting to fallback modal'); - // Only include demo parameter in query if it's present in the current URL - const query = window.location.search.includes(DEMO_PARAM) - ? { [DEMO_PARAM]: '' } - : undefined; demoRouter.push({ path: `/${DemoModal.Fallback}`, - query, }); }); }, @@ -2019,15 +1977,9 @@ function observeReceiveModal(processedElements: WeakSet) { event.preventDefault(); event.stopPropagation(); - // Only include demo parameter in query if it's present in the current URL - const query = window.location.search.includes(DEMO_PARAM) - ? { [DEMO_PARAM]: '' } - : undefined; - // Redirect to the fallback modal demoRouter.replace({ path: `/${DemoModal.Fallback}`, - query, }); console.log('[Demo] Redirected receive modal button click to fallback modal'); diff --git a/src/main-demo.ts b/src/main-demo.ts new file mode 100644 index 000000000..5a9319daa --- /dev/null +++ b/src/main-demo.ts @@ -0,0 +1,128 @@ +import Vue from 'vue'; +import VueCompositionApi, { watch } from '@vue/composition-api'; +// @ts-expect-error Could not find a declaration file for module 'vue-virtual-scroller'. +import VueVirtualScroller from 'vue-virtual-scroller'; +import { setAssetPublicPath as setVueComponentsAssetPath } from '@nimiq/vue-components'; +import { init as initFastspotApi } from '@nimiq/fastspot-api'; +// @ts-expect-error missing types for this package +import VuePortal from '@linusborg/vue-simple-portal'; + +import App from './App.vue'; +import { dangerouslyInitializeDemo } from './lib/Demo'; +import { useAccountStore } from './stores/Account'; +import { useFiatStore } from './stores/Fiat'; +import { useSettingsStore } from './stores/Settings'; +import router from './router'; +import { i18n, loadLanguage } from './i18n/i18n-setup'; +import { CryptoCurrency } from './lib/Constants'; +import { useConfig } from './composables/useConfig'; +import { initPwa } from './composables/usePwaInstallPrompt'; +import { useInactivityDetection } from './composables/useInactivityDetection'; + +// Side-effects +import './lib/AddressBook'; + +import '@nimiq/style/nimiq-style.min.css'; +import '@nimiq/vue-components/dist/NimiqVueComponents.css'; +import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; +import '@/scss/themes.scss'; + +// Set asset path relative to the public path defined in vue.config.json, +// see https://cli.vuejs.org/guide/mode-and-env.html#using-env-variables-in-client-side-code +setVueComponentsAssetPath(`${process.env.BASE_URL}js/`, `${process.env.BASE_URL}img/`); + +Vue.config.productionTip = false; + +Vue.use(VueCompositionApi); +Vue.use(VueVirtualScroller); +Vue.use(VuePortal, { name: 'Portal' }); + +async function startDemo() { + // eslint-disable-next-line no-console + console.warn('[Demo] Starting Nimiq Wallet in demo mode...'); + + initPwa(); // Must be called as soon as possible to catch early browser events related to PWA + + // Initialize demo environment - this replaces the normal storage/hub initialization + dangerouslyInitializeDemo(router); + + // Update exchange rates every 2 minutes or every 10 minutes, depending on whether the Wallet is currently actively + // used. If an update takes longer than that time due to a provider's rate limit, wait until the update succeeds + // before queueing the next update. If the last update before page load was less than 2 minutes ago, wait the + // remaining time first. + const { timestamp: lastSuccessfulExchangeRateUpdate, updateExchangeRates } = useFiatStore(); + const { isUserInactive } = useInactivityDetection(); + let lastTriedExchangeRateUpdate = lastSuccessfulExchangeRateUpdate.value; + const TWO_MINUTES = 2 * 60 * 1000; + const TEN_MINUTES = 5 * TWO_MINUTES; + let exchangeRateUpdateTimer = -1; + function queueExchangeRateUpdate() { + const interval = isUserInactive.value ? TEN_MINUTES : TWO_MINUTES; + // Update lastTriedExchangeRateUpdate as there might have been other exchange rate updates in the meantime, for + // example on currency change. + lastTriedExchangeRateUpdate = Math.max(lastTriedExchangeRateUpdate, lastSuccessfulExchangeRateUpdate.value); + // Also set interval as upper bound to be immune to the user's system clock being wrong. + const remainingTime = Math.max(0, Math.min(lastTriedExchangeRateUpdate + interval - Date.now(), interval)); + clearTimeout(exchangeRateUpdateTimer); + exchangeRateUpdateTimer = window.setTimeout(async () => { + // Silently ignore errors. If successful, this updates fiatStore.timestamp, which then also triggers price + // chart updates in PriceChart.vue. + await updateExchangeRates(/* failGracefully */ true); + // In contrast to lastSuccessfulExchangeRateUpdate also update lastTriedExchangeRateUpdate on failed + // attempts, to avoid repeated rescheduling on failure. Instead, simply skip the failed attempt and try + // again at the regular interval. We update the time after the update attempt, instead of before it, because + // exchange rates are up-to-date at the time an update successfully finishes, and get old from that point, + // and not from the time the update was started. + lastTriedExchangeRateUpdate = Date.now(); + queueExchangeRateUpdate(); + }, remainingTime); + } + watch(isUserInactive, queueExchangeRateUpdate); // (Re)schedule exchange rate updates at the desired interval. + + // Fetch language file + const { language } = useSettingsStore(); + loadLanguage(language.value); + + const { config } = useConfig(); + + // Set demo-specific document title + document.title = 'Nimiq Wallet Demo'; + + // Initialize Fastspot API for demo + watch(() => { + if (!config.fastspot.apiEndpoint || !config.fastspot.apiKey) return; + initFastspotApi(config.fastspot.apiEndpoint, config.fastspot.apiKey); + }); + + // Make reactive config accessible in components + Vue.prototype.$config = config; + + new Vue({ + router, + i18n, + render: (h) => h(App), + }).$mount('#app'); + + // Note: We don't launch network, electrum, or polygon connections in demo mode + // as the demo uses simulated data instead of real blockchain connections + + // Set active currency to NIM by default for demo + const { state: { activeCurrency } } = useAccountStore(); + if (activeCurrency !== CryptoCurrency.NIM) { + useAccountStore().setActiveCurrency(CryptoCurrency.NIM); + } +} + +startDemo(); + +declare module 'vue/types/vue' { + interface Vue { + $config: ReturnType['config']; + } +} + +declare module '@vue/composition-api/dist/component/component' { + interface SetupContext { + readonly refs: { [key: string]: Vue | Element | Vue[] | Element[] }; + } +} diff --git a/src/main.ts b/src/main.ts index 51bf08065..b5f926fb5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,7 +18,6 @@ import { launchPolygon } from './ethers'; import { initMatomo } from './matomo'; import { useAccountStore } from './stores/Account'; import { useFiatStore } from './stores/Fiat'; -import { checkIfDemoIsActive, dangerouslyInitializeDemo } from './lib/Demo'; import { useSettingsStore } from './stores/Settings'; import router from './router'; import { i18n, loadLanguage } from './i18n/i18n-setup'; @@ -51,21 +50,15 @@ Vue.use(VuePortal, { name: 'Portal' }); async function start() { initPwa(); // Must be called as soon as possible to catch early browser events related to PWA - const isDemoActive = checkIfDemoIsActive(); + await initStorage(); // Must be awaited before starting Vue + initTrials(); // Must be called after storage was initialized, can affect Config + // Must run after VueCompositionApi has been enabled and after storage was initialized. Could potentially run in + // background and in parallel to syncFromHub, but RedirectRpcClient.init does not actually run async code + // anyways. + await initHubApi(); + syncFromHub(); // Can run parallel to Vue initialization; must be called after storage was initialized. - if (!isDemoActive) { - await initStorage(); // Must be awaited before starting Vue - initTrials(); // Must be called after storage was initialized, can affect Config - // Must run after VueCompositionApi has been enabled and after storage was initialized. Could potentially run in - // background and in parallel to syncFromHub, but RedirectRpcClient.init does not actually run async code - // anyways. - await initHubApi(); - syncFromHub(); // Can run parallel to Vue initialization; must be called after storage was initialized. - - serviceWorkerHasUpdate.then((hasUpdate) => useSettingsStore().state.updateAvailable = hasUpdate); - } else { - dangerouslyInitializeDemo(router); - } + serviceWorkerHasUpdate.then((hasUpdate) => useSettingsStore().state.updateAvailable = hasUpdate); // Update exchange rates every 2 minutes or every 10 minutes, depending on whether the Wallet is currently actively // used. If an update takes longer than that time due to a provider's rate limit, wait until the update succeeds @@ -104,15 +97,11 @@ async function start() { const { language } = useSettingsStore(); loadLanguage(language.value); - if (!isDemoActive) { - startSentry(); - } + startSentry(); const { config } = useConfig(); - if (isDemoActive) { - document.title = 'Nimiq Wallet Demo'; - } else if (config.environment !== ENV_MAIN) { + if (config.environment !== ENV_MAIN) { document.title = 'Nimiq Testnet Wallet'; } @@ -121,17 +110,15 @@ async function start() { initFastspotApi(config.fastspot.apiEndpoint, config.fastspot.apiKey); }); - if (!isDemoActive) { - watch(() => { - if (!config.oasis.apiEndpoint) return; - initOasisApi(config.oasis.apiEndpoint); - }); + watch(() => { + if (!config.oasis.apiEndpoint) return; + initOasisApi(config.oasis.apiEndpoint); + }); - watch(() => { - if (!config.ten31Pass.enabled) return; - initKycConnection(); - }); - } + watch(() => { + if (!config.ten31Pass.enabled) return; + initKycConnection(); + }); watch(() => { if (!config.matomo.enabled) return; diff --git a/vue.config.js b/vue.config.js index 436a6df2a..b4ca6d0d7 100644 --- a/vue.config.js +++ b/vue.config.js @@ -13,17 +13,31 @@ crypto.createHash = (alg, opts) => { return origCreateHash(alg === 'md4' ? 'md5' : alg, opts); }; -const buildName = process.env.NODE_ENV === 'production' ? process.env.build : 'local'; +let buildName; +if (process.env.NODE_ENV === 'production') { + buildName = process.env.build; +} else if (process.env.build?.startsWith('demo')) { + buildName = 'demo'; +} else { + buildName = 'local'; +} + if (!buildName) { throw new Error('Please specify the build config with the `build` environment variable'); } +// Log the buildName value to help debugging +console.log('Build name:', buildName); + let release; if (process.env.NODE_ENV !== 'production') { release = 'dev'; } else if (process.env.CI) { // CI environment variables are documented at https://docs.gitlab.com/ee/ci/variables/predefined_variables.html release = `${process.env.CI_COMMIT_BRANCH}-${process.env.CI_PIPELINE_ID}-${process.env.CI_COMMIT_SHORT_SHA}`; +} else if (buildName.startsWith('demo')) { + // For demo builds, use a special release tag format + release = `demo-${new Date().toISOString().split('T')[0]}`; } else { release = child_process.execSync("git tag --points-at HEAD").toString().split('\n')[0]; } @@ -46,11 +60,38 @@ function sri(asset) { process.env.VUE_APP_BITCOIN_JS_INTEGRITY_HASH = sri(fs.readFileSync(path.join(__dirname, 'public/bitcoin/BitcoinJS.min.js'))); process.env.VUE_APP_COPYRIGHT_YEAR = new Date().getUTCFullYear().toString(); // year at build time -console.log('Building for:', buildName, ', release:', `"wallet-${release}"`); +console.log('Building for:', buildName, ', release:', `"wallet-${release}"`, buildName === 'demo' ? '(DEMO MODE)' : ''); + +const configFileMap = { + 'local': 'config.local.ts', + 'testnet': 'config.testnet.ts', + 'mainnet': 'config.mainnet.ts', + 'demo': 'config.local.ts', + 'demo-production': 'config.mainnet.ts', +}; + +const tsConfigFileMap = { + 'local': 'tsconfig.local.json', + 'testnet': 'tsconfig.testnet.json', + 'mainnet': 'tsconfig.mainnet.json', + 'demo': 'tsconfig.local.json', + 'demo-production': 'tsconfig.mainnet.json', +}; + +const configFile = configFileMap[buildName] || 'config.local.ts'; +const tsConfigFile = tsConfigFileMap[buildName] || 'tsconfig.json'; + +const specificConfigPath = path.join(__dirname, 'src/config', configFile); +const configPath = fs.existsSync(specificConfigPath) ? specificConfigPath : path.join(__dirname, 'src/config/config.local.ts'); + +const specificTsConfigPath = path.join(__dirname, tsConfigFile); +const tsConfigPath = fs.existsSync(specificTsConfigPath) ? specificTsConfigPath : path.join(__dirname, 'tsconfig.json'); + +console.log(`Using config: ${configPath}, tsconfig: ${tsConfigPath}`); module.exports = { pages: { - index: 'src/main.ts', + index: buildName === 'demo' ? 'src/main-demo.ts' : 'src/main.ts', 'swap-kyc-handler': { // Unfortunately this includes the complete chunk-vendors and chunk-common, and even their css. Can we // improve this? The `chunks` option doesn't seem to be too useful here. At least the chunks should be @@ -66,6 +107,7 @@ module.exports = { new webpack.DefinePlugin({ 'process.env.SENTRY_RELEASE': `"wallet-${release}"`, 'process.env.VERSION': `"${release}"`, + 'process.env.IS_DEMO_BUILD': JSON.stringify(buildName === 'demo'), }), new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'], @@ -75,7 +117,7 @@ module.exports = { // Resolve config for yarn build resolve: { alias: { - config: path.join(__dirname, `src/config/config.${buildName}.ts`) + config: configPath }, // In Webpack 5, NodeJS polyfills have to be explicitly configured fallback: { @@ -152,8 +194,8 @@ module.exports = { .use('ts-loader') .loader('ts-loader') .tap(options => { - options.configFile = `tsconfig.${buildName}.json` - return options + options.configFile = tsConfigPath; + return options; }); }, // Note: the pwa is only setup on production builds, thus to test this config, a production build has to be built @@ -163,7 +205,8 @@ module.exports = { mainnet: 'Nimiq Wallet', testnet: 'Nimiq Testnet Wallet', local: 'Nimiq Local Wallet', - }[buildName], + demo: 'Nimiq Wallet Demo', + }[buildName] || 'Nimiq Wallet', msTileColor: '#1F2348', manifestOptions: { start_url: '/', @@ -186,7 +229,9 @@ module.exports = { type: "image/png" }, ], - description: "Securely manage your Nimiq and Bitcoin accounts. Send and receive NIM and BTC, view balances, swap between NIM and BTC, and buy and sell with Nimiq OASIS.", + description: buildName === 'demo' + ? "Experience the Nimiq Wallet in demo mode. Try out all features with simulated accounts and transactions." + : "Securely manage your Nimiq and Bitcoin accounts. Send and receive NIM and BTC, view balances, swap between NIM and BTC, and buy and sell with Nimiq OASIS.", screenshots: [ { src: "./img/screenshots/01-Send-and-receive.png", From 90a6fa2994f251e980eddea2ee4cf9473a0e2cce Mon Sep 17 00:00:00 2001 From: onmax Date: Tue, 8 Jul 2025 10:01:59 +0200 Subject: [PATCH 31/31] chore(demo): refactor demo module structure and enhance functionality with new demo accounts and transactions --- README.md | 6 +- src/lib/Demo.ts | 2035 +----------------------------- src/lib/demo/DemoAccounts.ts | 65 + src/lib/demo/DemoConstants.ts | 169 +++ src/lib/demo/DemoDom.ts | 275 ++++ src/lib/demo/DemoFlows.ts | 105 ++ src/lib/demo/DemoHubApi.ts | 122 ++ src/lib/demo/DemoSwaps.ts | 386 ++++++ src/lib/demo/DemoTransactions.ts | 1130 +++++++++++++++++ src/lib/demo/DemoUtils.ts | 37 + src/lib/demo/index.ts | 171 +++ 11 files changed, 2482 insertions(+), 2019 deletions(-) create mode 100644 src/lib/demo/DemoAccounts.ts create mode 100644 src/lib/demo/DemoConstants.ts create mode 100644 src/lib/demo/DemoDom.ts create mode 100644 src/lib/demo/DemoFlows.ts create mode 100644 src/lib/demo/DemoHubApi.ts create mode 100644 src/lib/demo/DemoSwaps.ts create mode 100644 src/lib/demo/DemoTransactions.ts create mode 100644 src/lib/demo/DemoUtils.ts create mode 100644 src/lib/demo/index.ts diff --git a/README.md b/README.md index 1f13e9d5d..694937e3a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,11 @@ yarn serve If you need to interact with a real account and data, then you will also need to run the [Nimiq Hub](https://github.com/nimiq/hub#contribute) and [Nimiq Keyguard](https://github.com/nimiq/keyguard/#development) in development, too. -Otherwise, you can activate demo mode by appending `?demo` to the URL: `http://localhost:8081/?demo`. +For demo mode with simulated accounts and transactions, use: +``` +yarn serve:demo +``` + ### Compiles and minifies for production ``` yarn build diff --git a/src/lib/Demo.ts b/src/lib/Demo.ts index ac09f80ac..2de515659 100644 --- a/src/lib/Demo.ts +++ b/src/lib/Demo.ts @@ -7,10 +7,10 @@ * This module provides a complete demo environment for the Nimiq Wallet, allowing users to * try out wallet features without connecting to real blockchain networks. * - * Demo mode can be activated in three ways: - * 1. By adding the '?demo=' URL parameter to any wallet URL - * 2. Setting config.demo.enabled = true in the configuration (always enabled) - * 3. Setting config.demo.enabled = 'domain.com' to enable only on a specific domain + * Demo mode can only be activated through build commands: + * - yarn serve:demo (development) + * - yarn build:demo (production build) + * - yarn build:demo-production (production build with mainnet config) * * When active, demo mode: * - Creates fake accounts with demo balances @@ -22,2017 +22,16 @@ * This is intended for demonstration, educational, and testing purposes only. */ -import VueRouter from 'vue-router'; -import { TransactionState as ElectrumTransactionState } from '@nimiq/electrum-client'; -import { CryptoCurrency, Utf8Tools } from '@nimiq/utils'; -import { KeyPair, PlainTransactionDetails, PrivateKey } from '@nimiq/core'; -import { AccountType, useAccountStore } from '@/stores/Account'; -import { AddressType, useAddressStore } from '@/stores/Address'; -import { toSecs, type Transaction as NimTransaction, useTransactionsStore } from '@/stores/Transactions'; -import { useBtcTransactionsStore, type Transaction as BtcTransaction } from '@/stores/BtcTransactions'; -import { useUsdtTransactionsStore, TransactionState as UsdtTransactionState, type Transaction as UsdtTransaction } from '@/stores/UsdtTransactions'; -import { useUsdcTransactionsStore, TransactionState as UsdcTransactionState, type Transaction as UsdcTransaction } from '@/stores/UsdcTransactions'; -import { useStakingStore } from '@/stores/Staking'; -import { useAccountSettingsStore } from '@/stores/AccountSettings'; -import { usePolygonAddressStore } from '@/stores/PolygonAddress'; -import Config from 'config'; -import { FastspotAsset, FastspotLimits, FastspotUserLimits, ReferenceAsset, SwapAsset, SwapStatus } from '@nimiq/fastspot-api'; -import HubApi, { SetupSwapResult } from '@nimiq/hub-api'; -import { useConfig } from '@/composables/useConfig'; -import { useBtcAddressStore } from '@/stores/BtcAddress'; -import { useContactsStore } from '@/stores/Contacts'; -import { useBtcLabelsStore } from '@/stores/BtcLabels'; -import { useUsdcContactsStore } from '@/stores/UsdcContacts'; -import { useUsdtContactsStore } from '@/stores/UsdtContacts'; -import { useFiatStore } from '@/stores/Fiat'; -import { SwapState, useSwapsStore } from '@/stores/Swaps'; - -export type DemoState = { - active: boolean, -}; - -// No additional import needed, Config is already imported above - -const DemoFallbackModal = () => - import( - /* webpackChunkName: 'demo-hub-fallback-modal' */ - '@/components/modals/demos/DemoModalFallback.vue' - ); - -const DemoPurchaseModal = () => - import( - /* webpackChunkName: 'demo-modal-buy' */ - '@/components/modals/demos/DemoModalBuy.vue' - ); - -// Replacing the enum with a simple object to avoid backticks -const DemoModal = { - Fallback: 'demo-fallback', - Buy: 'demo-buy', -}; - -// Addresses for demo: -const demoNimAddress = 'NQ57 2814 7L5B NBBD 0EU7 EL71 HXP8 M7H8 MHKD'; -const demoBtcAddress = '1XYZDemoAddress'; -const demoPolygonAddress = '0xabc123DemoPolygonAddress'; -const buyFromAddress = 'NQ04 JG63 HYXL H3QF PPNA 7ED7 426M 3FQE FHE5'; - -// We keep this as our global/final balance, which should result from the transactions -const nimInitialBalance = 140_418 * 1e5; // 14,041,800,000 - 14 april, 2018. 5 decimals. -const btcInitialBalance = 0.0025 * 1e8; // 1 BTC (8 decimals) -const usdtInitialBalance = 514.83 * 1e6; // 5000 USDT (6 decimals) -const usdcInitialBalance = 357.38 * 1e6; // 3000 USDC (6 decimals) - -// Swaps -const onGoingSwaps = new Map(); - -// We keep a reference to the router here. -let demoRouter: VueRouter; - -// #region Main - -/** - * Initializes the demo environment and sets up various routes, data, and watchers. - */ -export function dangerouslyInitializeDemo(router: VueRouter) { - // Check if demo is active according to the configuration - if (!checkIfDemoIsActive()) { - console.info('[Demo] Demo mode not enabled in configuration. Skipping initialization.'); - return; - } - - console.warn('[Demo] Initializing demo environment...'); - - demoRouter = router; - - insertCustomDemoStyles(); - setupSingleMutationObserver(); - addDemoModalRoutes(); - interceptFetchRequest(); - - setupDemoAddresses(); - setupDemoAccount(); - - insertFakeNimTransactions(); - insertFakeBtcTransactions(); - - if (useConfig().config.polygon.enabled) { - insertFakeUsdcTransactions(); - insertFakeUsdtTransactions(); - } - - attachIframeListeners(); - replaceStakingFlow(); - replaceBuyNimFlow(); - - listenForSwapChanges(); -} - -// #endregion - -// #region App setup - -/** - * Checks if the demo mode should be active. - * In demo builds, this always returns true since demo builds are separate deployments. - */ -export function checkIfDemoIsActive() { - return true; -} - -/** - * Creates a style tag to add demo-specific CSS. - */ -function insertCustomDemoStyles() { - const styleElement = document.createElement('style'); - styleElement.innerHTML = demoCSS; - document.head.appendChild(styleElement); -} - -/** - * Sets up a single mutation observer to handle all DOM-based demo features - */ -function setupSingleMutationObserver() { - // Track processed elements to avoid duplicate processing - const processedElements = new WeakSet(); - - // Handler function to process all DOM mutations - const processDomChanges = () => { - // Call each handler with the processed elements set - setupVisualCues(processedElements); - disableSwapTriggers(processedElements); - enableSellAndSwapModals(processedElements); - obfuscateAddresses(processedElements); - observeTransactionList(processedElements); - observeReceiveModal(processedElements); - }; - - // Create one mutation observer for all DOM modifications - const mutationObserver = new MutationObserver(processDomChanges); - - // Observe the entire document with a single observer - mutationObserver.observe(document.body, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['disabled'], - }); - - processDomChanges(); - // Also run checks when routes change to catch elements that appear after navigation - demoRouter.afterEach(() => { - // Wait for Vue to update the DOM after route change - setTimeout(processDomChanges, 100); - }); -} - -/** - * Observes the home view to attach a highlight to some buttons for demonstration purposes. - */ -function setupVisualCues(processedElements: WeakSet) { - const highlightTargets = [ - ['.sidebar .trade-actions button', { top: '-18px', right: '-4px' }], - ['.sidebar .swap-tooltip button', { top: '-18px', right: '-8px' }], - ['.actions .staking-button', { top: '-2px', right: '-2px' }], - ] as const; - - highlightTargets.forEach(([selector, position]) => { - const target = document.querySelector(selector); - if (!target || processedElements.has(target) || target.querySelector('.demo-highlight-badge')) return; - - const wrapper = document.createElement('div'); - wrapper.classList.add('demo-highlight-badge'); - wrapper.style.top = position.top; - wrapper.style.right = position.right; - const circle = document.createElement('div'); - - wrapper.appendChild(circle); - target.appendChild(wrapper); - processedElements.add(target); - }); -} - -/** - * The only swap allowed is NIM-BTC. - * Removes the swap triggers from the account grid so the user does not the - * option to swap or sell assets in the demo environment. - * We also remove the pair selection in the SwapModal. - */ -function disableSwapTriggers(processedElements: WeakSet) { - const swapTriggers = document.querySelectorAll( - '.account-grid > :where(.nim-usdc-swap-button, .nim-btc-swap-button, .btc-usdc-swap-button, .account-backgrounds)', - ) as NodeListOf; - - swapTriggers.forEach((trigger) => { - if (!processedElements.has(trigger)) { - trigger.remove(); - processedElements.add(trigger); - } - }); - - const pairSelection = document.querySelector('.pair-selection'); - if (pairSelection && !processedElements.has(pairSelection)) { - pairSelection.remove(); - processedElements.add(pairSelection); - } -} - -/** - * Adds routes pointing to our demo modals. - */ -function addDemoModalRoutes() { - demoRouter.addRoute('root', { - name: DemoModal.Fallback, - path: `/${DemoModal.Fallback}`, - components: { modal: DemoFallbackModal }, - props: { modal: true }, - }); - demoRouter.addRoute('root', { - name: DemoModal.Buy, - path: `/${DemoModal.Buy}`, - components: { modal: DemoPurchaseModal }, - props: { modal: true }, - }); -} - -// #endregion - -// #region Addresses Setup - -/** - * Setup and initialize the demo data for all currencies. - */ -function setupDemoAddresses() { - const { setAddressInfos } = useAddressStore(); - setAddressInfos([ - { - label: 'Demo Account', - type: AddressType.BASIC, - address: demoNimAddress, - balance: nimInitialBalance, - }, - ]); - - // Setup Polygon addresses and balances - const { setAddressInfos: setPolygonAddressInfos } = usePolygonAddressStore(); - setPolygonAddressInfos([{ - address: demoPolygonAddress, - balanceUsdc: usdcInitialBalance, - balanceUsdcBridged: 0, - balanceUsdtBridged: usdtInitialBalance, - pol: 1, - }]); -} - -/** - * Creates a fake main account referencing our demo addresses. - */ -function setupDemoAccount() { - const { addAccountInfo, setActiveCurrency } = useAccountStore(); - const { setStablecoin, setKnowsAboutUsdt } = useAccountSettingsStore(); - - // Setup account info with both USDC and USDT addresses - addAccountInfo({ - id: 'demo-account-1', - type: AccountType.BIP39, - label: 'Demo Main Account', - fileExported: true, - wordsExported: true, - addresses: [demoNimAddress], - btcAddresses: { internal: [demoBtcAddress], external: [demoBtcAddress] }, - polygonAddresses: [demoPolygonAddress, demoPolygonAddress], - uid: 'demo-uid-1', - }); - - // Pre-select USDC as the default stablecoin and mark USDT as known - setStablecoin(CryptoCurrency.USDC); - setKnowsAboutUsdt(true); - - setActiveCurrency(CryptoCurrency.NIM); -} - -enum MessageEventName { - FlowChange = 'FlowChange' -} - -/** - * Listens for messages from iframes (or parent frames) about changes in the user flow. - */ -function attachIframeListeners() { - window.addEventListener('message', (event) => { - if (!event.data || typeof event.data !== 'object') return; - const { kind, data } = event.data as DemoFlowMessage; - if (kind === MessageEventName.FlowChange && demoRoutes[data]) { - useAccountStore().setActiveCurrency(CryptoCurrency.NIM); - - demoRouter.push({ - path: demoRoutes[data], - }); - } - }); - - demoRouter.afterEach((to) => { - const match = Object.entries(demoRoutes).find(([, route]) => route === to.path); - if (!match) return; - window.parent.postMessage({ kind: MessageEventName.FlowChange, data: match[0] as DemoFlowType }, '*'); - }); -} - -// #endregion - -// #region NIM txs - -interface NimTransactionDefinition { - fraction: number; - daysAgo: number; - description: string; - recipientLabel?: string; -} - -/** - * Defines transaction definitions for demo NIM transactions - */ -function defineNimFakeTransactions(): Partial[] { - const txDefinitions: NimTransactionDefinition[] = [ - { fraction: -0.05, daysAgo: 0.4, description: 'Local cafe coffee' }, - { fraction: 0.1, daysAgo: 0.6, description: 'Red envelope from neighbor', recipientLabel: 'Neighbor' }, - { fraction: -0.03, daysAgo: 1, description: 'Food truck snack' }, - { fraction: -0.06, daysAgo: 2, description: 'Local store book' }, - { fraction: -0.01, daysAgo: 2, description: 'Chicken waffle', recipientLabel: 'Roberto\'s Waffle' }, - { fraction: -0.04, daysAgo: 3, description: 'Corner shop chai & snack' }, - { fraction: -0.12, daysAgo: 3, description: 'Thai massage session' }, - { fraction: -0.15, daysAgo: 6, description: 'Swedish flat-pack chair', recipientLabel: 'Furniture Mart' }, - { fraction: 0.1, daysAgo: 6, description: 'Red envelope from family', recipientLabel: 'Family' }, - { fraction: -0.08, daysAgo: 7, description: 'Cozy diner dinner', recipientLabel: 'Melissa' }, - { fraction: -0.02, daysAgo: 7, description: 'Coworker snack', recipientLabel: 'Coworker' }, - { fraction: -0.03, daysAgo: 8, description: 'Morning bus fare' }, - { fraction: -0.05, daysAgo: 8, description: 'Local fruit pack' }, - { fraction: 0.02, daysAgo: 10, description: 'Neighbor bill', recipientLabel: 'Neighbor' }, - { fraction: -0.07, daysAgo: 12, description: 'Movie ticket night' }, - { fraction: -0.04, daysAgo: 14, description: 'Trendy cafe coffee', recipientLabel: 'Cafe' }, - { fraction: -0.03, daysAgo: 15, description: 'Market street food snack' }, - { fraction: -0.1, daysAgo: 18, description: '' }, - { fraction: -0.05, daysAgo: 20, description: 'Street vendor souvenir' }, - { fraction: -0.08, daysAgo: 22, description: 'Quick haircut', recipientLabel: 'Barber' }, - { fraction: -0.04, daysAgo: 25, description: 'Local dessert', recipientLabel: 'Jose' }, - { fraction: -0.02, daysAgo: 27, description: 'Mall parking' }, - { fraction: -0.1, daysAgo: 30, description: 'Streaming subscription', recipientLabel: 'StreamCo' }, - { fraction: 0.03, daysAgo: 32, description: 'Mistaken charge refund', recipientLabel: 'Store' }, - { fraction: -0.06, daysAgo: 35, description: 'Taxi fare', recipientLabel: 'Crazy Taxi' }, - { fraction: -0.04, daysAgo: 38, description: 'Local shop mug' }, - { fraction: -0.01, daysAgo: 40, description: 'Local newspaper' }, - { fraction: -0.05, daysAgo: 42, description: 'Coworker ride', recipientLabel: 'Coworker' }, - { fraction: -0.07, daysAgo: 45, description: 'Bistro lunch' }, - { fraction: -0.12, daysAgo: 45, description: 'Weekly market shopping', recipientLabel: 'Market' }, - { fraction: -0.1, daysAgo: 50, description: 'Utility bill', recipientLabel: 'Utility Co.' }, - { fraction: -0.03, daysAgo: 55, description: 'Corner snack pack' }, - { fraction: -0.1, daysAgo: 60, description: 'Streaming subscription', recipientLabel: 'StreamCo' }, - { fraction: 0.05, daysAgo: 62, description: 'Client tip', recipientLabel: 'Client' }, - { fraction: -0.06, daysAgo: 65, description: 'Hair trim', recipientLabel: 'Barber' }, - { fraction: -0.09, daysAgo: 68, description: 'Takeaway meal' }, - { fraction: -0.04, daysAgo: 70, description: 'Stall fresh juice' }, - { fraction: -0.05, daysAgo: 72, description: 'Park picnic' }, - { fraction: -0.03, daysAgo: 75, description: 'Local event fee', recipientLabel: 'Event Org' }, - { fraction: -0.1, daysAgo: 78, description: 'Neighbors dinner', recipientLabel: 'Neighbors' }, - { fraction: -0.12, daysAgo: 80, description: 'New shoes sale' }, - { fraction: 0.1, daysAgo: 85, description: 'Festive cash gift', recipientLabel: 'Family' }, - { fraction: -0.1, daysAgo: 90, description: 'Streaming subscription', recipientLabel: 'StreamCo' }, - { fraction: -0.05, daysAgo: 95, description: 'Bakery fresh bread', recipientLabel: 'Bakery' }, - { fraction: -0.04, daysAgo: 100, description: 'Ice cream treat', recipientLabel: 'Ice Cream' }, - { fraction: -0.08, daysAgo: 110, description: 'Fitness class fee', recipientLabel: 'Gym' }, - { fraction: -0.03, daysAgo: 115, description: 'Meal discount' }, - { fraction: 0.04, daysAgo: 120, description: 'Double charge refund', recipientLabel: 'Store' }, - { fraction: -0.07, daysAgo: 125, description: 'Boutique trendy hat' }, - { fraction: -0.02, daysAgo: 130, description: 'Local cause donation', recipientLabel: 'Charity' }, - { fraction: -0.09, daysAgo: 140, description: 'Neighborhood dinner', recipientLabel: 'Food Joint' }, - { fraction: -0.05, daysAgo: 150, description: 'Gadget repair fee', recipientLabel: 'Repair Shop' }, - { fraction: -0.08, daysAgo: 200, description: 'Local play ticket', recipientLabel: 'Theater' }, - { fraction: -0.1, daysAgo: 250, description: 'Community event bill', recipientLabel: 'Community' }, - { fraction: 0.07, daysAgo: 300, description: 'Work bonus', recipientLabel: 'Employer' }, - { fraction: -0.04, daysAgo: 400, description: 'Local art fair entry' }, - { fraction: -0.06, daysAgo: 500, description: 'Online shop gadget', recipientLabel: 'Online Shop' }, - { fraction: -0.1, daysAgo: 600, description: 'Popular local dinner', recipientLabel: 'Diner' }, - { fraction: -0.12, daysAgo: 800, description: 'Home repair bill', recipientLabel: 'Repair Co.' }, - { fraction: 0.15, daysAgo: 1000, description: 'Freelance project check', recipientLabel: 'Client' }, - { fraction: -0.09, daysAgo: 1200, description: 'Local kitchen gear', recipientLabel: 'Kitchen Shop' }, - { fraction: -0.1, daysAgo: 1442, description: 'Family reunion dinner', recipientLabel: 'Family' }, - - ]; - - // Calculate sum of existing transactions to ensure they add up to exactly 1 - const existingSum = txDefinitions.reduce((sum, def) => sum + def.fraction, 0); - const remainingFraction = 1 - existingSum; - - // Add the final balancing transaction with appropriate description - if (Math.abs(remainingFraction) > 0.001) { // Only add if there's a meaningful amount to balance - txDefinitions.push({ - fraction: remainingFraction, - daysAgo: 1450, - description: remainingFraction > 0 - ? 'Blockchain Hackathon Prize!' - : 'Annual Software Subscription Renewal', - recipientLabel: remainingFraction > 0 ? 'Crypto Innovation Fund' : undefined, - }); - } - - const { setContact } = useContactsStore(); - const txs: Partial[] = []; - - for (const def of txDefinitions) { - let txValue = Math.floor(nimInitialBalance * def.fraction); - // Adjust so it doesn't end in a 0 digit - while (txValue > 0 && txValue % 10 === 0) { - txValue -= 1; - } - - const hex = encodeTextToHex(def.description); - const to32Bytes = (h: string) => h.padStart(64, '0').slice(-64); - const address = KeyPair.derive(PrivateKey.fromHex(to32Bytes(hex))).toAddress().toUserFriendlyAddress(); - const recipient = def.fraction > 0 ? demoNimAddress : address; - const sender = def.fraction > 0 ? address : demoNimAddress; - const data = { type: 'raw', raw: hex } as const; - const value = Math.abs(txValue); - const timestamp = calculateDaysAgo(def.daysAgo); - const tx: Partial = { value, recipient, sender, timestamp, data }; - txs.push(tx); - // Add contact if a recipientLabel is provided - if (def.recipientLabel && def.fraction < 0) { - setContact(address, def.recipientLabel); - } - } - - return txs.sort((a, b) => a.timestamp! - b.timestamp!); -} - -let head = 0; -let nonce = 0; - -function transformNimTransaction(txs: Partial[]): NimTransaction[] { - return txs.map((tx) => { - head++; - nonce++; - - return { - network: 'mainnet', - state: 'confirmed', - transactionHash: `0x${nonce.toString(16)}`, - sender: '', - senderType: 'basic', - recipient: '', - recipientType: 'basic', - value: 50000000, - fee: 0, - feePerByte: 0, - format: 'basic', - validityStartHeight: head, - blockHeight: head, - flags: 0, - timestamp: Date.now(), - size: 0, - valid: true, - proof: { raw: '', type: 'raw' }, - data: tx.data || { type: 'raw', raw: '' }, - ...tx, - }; - }); -} - -/** - * Inserts NIM transactions into the store. If no definitions provided, uses default demo transactions. - */ -function insertFakeNimTransactions(txs = defineNimFakeTransactions()) { - const { addTransactions } = useTransactionsStore(); - addTransactions(transformNimTransaction(txs)); -} - -/** - * Updates the NIM balance after a transaction - */ -function updateNimBalance(amount: number): void { - const addressStore = useAddressStore(); - const currentAddressInfo = addressStore.addressInfos.value.find((info) => info.address === demoNimAddress); - - if (currentAddressInfo) { - const newBalance = (currentAddressInfo.balance || 0) + amount; - addressStore.patchAddress(demoNimAddress, { balance: newBalance }); - } else { - console.error('[Demo] Failed to update NIM balance: Address not found'); - } -} - -export function dangerouslyInsertFakeBuyNimTransaction(amount: number) { - const tx: Partial = { - value: amount, - recipient: demoNimAddress, - sender: buyFromAddress, - data: { - type: 'raw', - raw: encodeTextToHex('NIM Bank purchase'), - }, - }; - - setTimeout(() => { - const { addTransactions } = useTransactionsStore(); - addTransactions(transformNimTransaction([tx])); - updateNimBalance(amount); - }, 1_500); -} - -// #region BTC txs - -interface BtcTransactionDefinition { - fraction: number; - daysAgo: number; - description: string; - recipientLabel?: string; - incoming: boolean; - address: string; -} - -/** - * Defines transaction definitions for demo BTC transactions. - * Note: We add the "address" field so that incoming transactions use the correct sender address. - */ -function defineBtcFakeTransactions(): BtcTransaction[] { - const txDefinitions: BtcTransactionDefinition[] = [ - { - fraction: 0.2, - daysAgo: 2000, - description: 'Initial BTC purchase from exchange', - incoming: true, - address: '1Kj4SNWFCxqvtP8nkJxeBwkXxgY9LW9rGg', - recipientLabel: 'Satoshi Exchange', - }, - { - fraction: 0.15, - daysAgo: 1600, - description: 'Mining pool payout', - incoming: true, - address: '1Hz7vQrRjnu3z9k7gxDYhKjEmABqChDvJr', - recipientLabel: 'Genesis Mining Pool', - }, - { - fraction: -0.19, - daysAgo: 1200, - description: 'Purchase from online marketplace', - incoming: false, - address: '1LxKe5kKdgGVwXukEgqFxh6DrCXF2Pturc', - recipientLabel: 'Digital Bazaar Shop', - }, - { - fraction: 0.3, - daysAgo: 800, - description: 'Company payment for consulting', - incoming: true, - address: '1N7aecJuKGDXzYK8CgpnNRYxdhZvXPxp3B', - recipientLabel: 'Corporate Treasury', - }, - { - fraction: -0.15, - daysAgo: 365, - description: 'Auto-DCA investment program', - incoming: false, - address: '12vxjmKJkfL9s5JwqUzEVVJGvKYJgALbsz', - }, - { - fraction: 0.075, - daysAgo: 180, - description: 'P2P sale of digital goods', - incoming: true, - address: '1MZYS9nvVmFvSK7em5zzAsnvRq82RUcypS', - }, - { - fraction: 0.05, - daysAgo: 60, - description: 'Recent purchase from exchange', - incoming: true, - address: '1Kj4SNWFCxqvtP8nkJxeBwkXxgY9LW9rGg', - }, - ].sort((a, b) => b.daysAgo - a.daysAgo); - - // If the sum of fractions does not add up to 1, add a balancing transaction. - const existingSum = txDefinitions.reduce((sum, def) => - sum + (def.incoming ? Math.abs(def.fraction) : -Math.abs(def.fraction)), 0); - const remainingFraction = 1 - existingSum; - if (Math.abs(remainingFraction) > 0.001) { - txDefinitions.push({ - fraction: remainingFraction, - daysAgo: 0, - description: remainingFraction > 0 ? 'Initial purchase' : 'Investment allocation', - incoming: remainingFraction > 0, - address: remainingFraction > 0 ? '1ExampleAddressForInitialPurchase' : '1ExampleAddressForInvestment', - recipientLabel: remainingFraction > 0 ? 'Prime Exchange' : 'Investment Fund', - }); - } - - return transformBtcTransaction(txDefinitions); -} - -/** - * Transforms BTC transaction definitions into actual transactions. - */ -function transformBtcTransaction(txDefinitions: BtcTransactionDefinition[]): BtcTransaction[] { - const transactions = []; - const knownUtxos = new Map(); - let txCounter = 1; - - for (const def of txDefinitions) { - const txHash = `btc-tx-${txCounter++}`; - // Compute the value using the initial BTC balance and the fraction. - const value = Math.floor(btcInitialBalance * Math.abs(def.fraction)); - - const tx: BtcTransaction = { - addresses: [demoBtcAddress], - isCoinbase: false, - inputs: [ - { - // For incoming tx, use the external address; for outgoing, use our demo address. - address: def.incoming ? def.address : demoBtcAddress, - outputIndex: 0, - index: 0, - script: 'script', - sequence: 4294967295, - transactionHash: def.incoming ? txHash : (getUTXOToSpend(knownUtxos)?.txHash || txHash), - witness: ['witness'], - }, - ], - outputs: def.incoming - ? [ - { - value, - address: demoBtcAddress, - script: 'script', - index: 0, - }, - ] - : [ - { - value, - address: def.address, - script: 'script', - index: 0, - }, - { - // Change output. - value: 1000000, - address: demoBtcAddress, - script: 'script', - index: 1, - }, - ], - transactionHash: txHash, - version: 1, - vsize: 200, - weight: 800, - locktime: 0, - confirmations: Math.max(1, Math.floor(10 - def.daysAgo / 200)), - replaceByFee: false, - timestamp: toSecs(calculateDaysAgo(def.daysAgo)), - state: ElectrumTransactionState.CONFIRMED, - }; - - updateUTXOs(knownUtxos, tx); - transactions.push(tx); - - // Set labels if a recipient label is provided. - if (def.recipientLabel) { - const { setSenderLabel } = useBtcLabelsStore(); - setSenderLabel(def.incoming ? def.address : demoBtcAddress, def.recipientLabel); - } - } - - // Update the address store with the current UTXOs. - const { addAddressInfos } = useBtcAddressStore(); - addAddressInfos([{ - address: demoBtcAddress, - txoCount: transactions.length + 2, // Total number of outputs received. - utxos: Array.from(knownUtxos.values()), - }]); - - return transactions; -} - -/** - * Tracks UTXO changes for BTC transactions. - */ -function updateUTXOs(knownUtxos: Map, tx: any) { - // Remove spent inputs. - for (const input of tx.inputs) { - if (input.address === demoBtcAddress) { - const utxoKey = `${input.transactionHash}:${input.outputIndex}`; - knownUtxos.delete(utxoKey); - } - } - - // Add new outputs for our address. - for (const output of tx.outputs) { - if (output.address === demoBtcAddress) { - const utxoKey = `${tx.transactionHash}:${output.index}`; - knownUtxos.set(utxoKey, { - transactionHash: tx.transactionHash, - index: output.index, - witness: { - script: output.script, - value: output.value, - }, - }); - } - } -} - -/** - * Helper to get a UTXO to spend. - */ -function getUTXOToSpend(knownUtxos: Map) { - if (knownUtxos.size === 0) return null; - const utxo = knownUtxos.values().next().value; - return { - txHash: utxo.transactionHash, - index: utxo.index, - value: utxo.witness.value, - }; -} - -/** - * Insert fake BTC transactions into the store. - */ -function insertFakeBtcTransactions(txs = defineBtcFakeTransactions()) { - const { addTransactions } = useBtcTransactionsStore(); - addTransactions(txs); -} - -/** - * Updates the BTC address balance by adding a new UTXO - */ -function updateBtcBalance(amount: number): void { - const btcAddressStore = useBtcAddressStore(); - const addressInfo = btcAddressStore.state.addressInfos[demoBtcAddress]; - - if (!addressInfo) { - console.error('[Demo] Failed to update BTC balance: Address not found'); - return; - } - - // Create a unique transaction hash - const txHash = `btc-tx-swap-${Date.now().toString(16)}`; - - // Create a proper UTXO with the correct format - const newUtxo = { - transactionHash: txHash, - index: 0, - witness: { - script: 'script', - value: amount * 1e-6, - }, - txoValue: amount * 1e-6, - }; - - btcAddressStore.addAddressInfos([{ - address: demoBtcAddress, - utxos: [...(addressInfo.utxos || []), newUtxo], - }]); - - console.log(`[Demo] Updated BTC balance, added UTXO with value: ${amount * 1e-6}`); -} - -// #endregion - -// #region Polygon txs - -const getRandomPolygonHash = () => `0x${Math.random().toString(16).slice(2, 66)}`; -const getRandomPolygonAddress = () => `0x${Math.random().toString(16).slice(2, 42)}`; - -interface UsdcTransactionDefinition { - fraction: number; - daysAgo: number; - description: string; - recipientLabel?: string; - incoming: boolean; -} - -/** - * Defines transaction definitions for demo USDC transactions - */ -function defineUsdcFakeTransactions(): UsdcTransaction[] { - const txDefinitions: UsdcTransactionDefinition[] = [ - { fraction: 0.3, daysAgo: 0, description: 'DeFi yield harvest', incoming: true, recipientLabel: 'Yield Protocol' }, - { fraction: -0.1, daysAgo: 2, description: 'NFT marketplace purchase', incoming: false, recipientLabel: 'NFT Market' }, - { fraction: 0.2, daysAgo: 5, description: 'Bridge transfer', incoming: true, recipientLabel: 'Polygon Bridge' }, - { fraction: -0.15, daysAgo: 10, description: 'DEX liquidity provision', incoming: false }, - { fraction: 0.25, daysAgo: 20, description: 'Staking rewards', incoming: true, recipientLabel: 'Staking Pool' }, - { fraction: -0.12, daysAgo: 30, description: 'GameFi item purchase', incoming: false }, - { fraction: 0.3, daysAgo: 45, description: 'DAO compensation', incoming: true, recipientLabel: 'Treasury DAO' }, - { fraction: -0.2, daysAgo: 60, description: 'Lending deposit', incoming: false, recipientLabel: 'Lending Protocol' }, - ]; - - const existingSum = txDefinitions.reduce((sum, def) => - sum + (def.incoming ? Math.abs(def.fraction) : -Math.abs(def.fraction)), 0); - const remainingFraction = 1 - existingSum; - - if (Math.abs(remainingFraction) > 0.001) { - txDefinitions.push({ - fraction: remainingFraction, - daysAgo: remainingFraction > 0 ? 90 : 100, - description: remainingFraction > 0 ? 'Initial bridge deposit' : 'Protocol investment', - incoming: remainingFraction > 0, - recipientLabel: remainingFraction > 0 ? 'Bridge Service' : 'DeFi Protocol', - }); - } - - return transformUsdcTransaction(txDefinitions); -} - -/** - * Transform USDC transaction definitions into actual transactions - */ -function transformUsdcTransaction(txDefinitions: UsdcTransactionDefinition[]): UsdcTransaction[] { - return txDefinitions.map((def, index) => { - const value = Math.floor(usdcInitialBalance * Math.abs(def.fraction)); - const randomAddress = def.incoming ? getRandomPolygonAddress() : demoPolygonAddress; - const recipientAddress = def.incoming ? demoPolygonAddress : getRandomPolygonAddress(); - - if (def.recipientLabel) { - const { setContact } = useUsdcContactsStore(); - setContact(def.incoming ? randomAddress : recipientAddress, def.recipientLabel); - } - - return { - token: Config.polygon.usdc.tokenContract, - transactionHash: getRandomPolygonHash(), - logIndex: index, - sender: randomAddress, - recipient: recipientAddress, - value, - state: UsdcTransactionState.CONFIRMED, - blockHeight: 1000000 + index, - timestamp: toSecs(calculateDaysAgo(def.daysAgo)), - }; - }); -} - -/** - * Insert fake USDC transactions into the store - */ -function insertFakeUsdcTransactions(txs = defineUsdcFakeTransactions()) { - const { addTransactions } = useUsdcTransactionsStore(); - addTransactions(txs); -} - -interface UsdtTransactionDefinition { - fraction: number; - daysAgo: number; - description: string; - recipientLabel?: string; - incoming: boolean; -} - -/** - * Defines transaction definitions for demo USDT transactions - */ -function defineUsdtFakeTransactions(): UsdtTransaction[] { - const txDefinitions: UsdtTransactionDefinition[] = [ - { fraction: 0.25, daysAgo: 0, description: 'Trading profit', incoming: true, recipientLabel: 'DEX Trading' }, - { fraction: -0.08, daysAgo: 3, description: 'Merchandise payment', incoming: false }, - { fraction: 0.15, daysAgo: 7, description: 'Freelance payment', incoming: true, recipientLabel: 'Client Pay' }, - { fraction: -0.12, daysAgo: 15, description: 'Service subscription', incoming: false, recipientLabel: 'Web3 Service' }, - { fraction: 0.3, daysAgo: 25, description: 'P2P exchange', incoming: true }, - { fraction: -0.18, daysAgo: 35, description: 'Platform fees', incoming: false }, - { fraction: 0.2, daysAgo: 50, description: 'Revenue share', incoming: true, recipientLabel: 'Revenue Pool' }, - { fraction: -0.15, daysAgo: 70, description: 'Marketing campaign', incoming: false, recipientLabel: 'Marketing DAO' }, - ]; - - const existingSum = txDefinitions.reduce((sum, def) => - sum + (def.incoming ? Math.abs(def.fraction) : -Math.abs(def.fraction)), 0); - const remainingFraction = 1 - existingSum; - - if (Math.abs(remainingFraction) > 0.001) { - txDefinitions.push({ - fraction: remainingFraction, - daysAgo: remainingFraction > 0 ? 85 : 95, - description: remainingFraction > 0 ? 'Initial USDT deposit' : 'Portfolio rebalance', - incoming: remainingFraction > 0, - recipientLabel: remainingFraction > 0 ? 'Bridge Protocol' : 'Portfolio Manager', - }); - } - - return transformUsdtTransaction(txDefinitions); -} - -/** - * Transform USDT transaction definitions into actual transactions - */ -function transformUsdtTransaction(txDefinitions: UsdtTransactionDefinition[]): UsdtTransaction[] { - return txDefinitions.map((def, index) => { - const value = Math.floor(usdtInitialBalance * Math.abs(def.fraction)); - const randomAddress = def.incoming ? getRandomPolygonAddress() : demoPolygonAddress; - const recipientAddress = def.incoming ? demoPolygonAddress : getRandomPolygonAddress(); - - if (def.recipientLabel) { - const { setContact } = useUsdtContactsStore(); - setContact(def.incoming ? randomAddress : recipientAddress, def.recipientLabel); - } - - return { - token: Config.polygon.usdt_bridged.tokenContract, - transactionHash: getRandomPolygonHash(), - logIndex: index, - sender: randomAddress, - recipient: recipientAddress, - value, - state: UsdtTransactionState.CONFIRMED, - blockHeight: 1000000 + index, - timestamp: toSecs(calculateDaysAgo(def.daysAgo)), - }; - }); -} - -/** - * Insert fake USDT transactions into the store - */ -function insertFakeUsdtTransactions(txs = defineUsdtFakeTransactions()) { - const { addTransactions } = useUsdtTransactionsStore(); - addTransactions(txs); -} - -// #endregion - -// #region Flows - -/** - * Observes the staking modal and prevents from validating the info and instead fakes the staking process. - */ -function replaceBuyNimFlow() { - let observedTarget: HTMLDivElement | undefined; - - demoRouter.afterEach((to) => { - if (to.path.startsWith('/')) { - const targetSelector = '.sidebar .trade-actions'; - const checkForTradeActions = setInterval(() => { - const target = document.querySelector(targetSelector) as HTMLDivElement; - if (!target || target === observedTarget) return; - observedTarget = target; - - target.innerHTML = ''; - - const btn1 = document.createElement('button'); - btn1.className = 'nq-button-s inverse'; - btn1.style.flex = '1'; - btn1.addEventListener('click', () => { - demoRouter.push({ - path: '/buy', - }); - }); - btn1.innerHTML = 'Buy'; - - const btn2 = document.createElement('button'); - btn2.className = 'nq-button-s inverse'; - btn2.style.flex = '1'; - btn2.disabled = true; - btn2.innerHTML = 'Sell'; - - target.appendChild(btn1); - target.appendChild(btn2); - }, 500); - - // Clear interval when navigating away - demoRouter.afterEach((nextTo) => { - if (!nextTo.path.startsWith('/')) { - clearInterval(checkForTradeActions); - observedTarget = undefined; - } - }); - } - }); -} - -/** - * Observes the staking modal and prevents from validating the info and instead fakes the staking process. - */ -function replaceStakingFlow() { - let lastProcessedButton: HTMLButtonElement; - - demoRouter.afterEach((to) => { - if (to.path === '/staking') { - const checkForStakeButton = setInterval(() => { - const target = document.querySelector('.stake-graph-page .stake-button'); - if (!target || target === lastProcessedButton) return; - - // remove previous listeners by cloning the element and replacing the original - const newElement = target.cloneNode(true) as HTMLButtonElement; - target.parentNode!.replaceChild(newElement, target); - newElement.removeAttribute('disabled'); - lastProcessedButton = newElement; - - newElement.addEventListener('click', async () => { - const { setStake } = useStakingStore(); - const { activeValidator } = useStakingStore(); - const amountInput = document.querySelector('.nq-input') as HTMLInputElement; - const amount = Number.parseFloat(amountInput.value.replaceAll(/[^\d]/g, '')) * 1e5; - - const { address: validatorAddress } = activeValidator.value!; - - demoRouter.push({ - path: '/', - }); - - await new Promise((resolve) => { window.setTimeout(resolve, 100); }); - setStake({ - activeBalance: 0, - inactiveBalance: amount, - address: demoNimAddress, - retiredBalance: 0, - validator: validatorAddress, - }); - const stakeTx: Partial = { - value: amount, - recipient: validatorAddress, - sender: demoNimAddress, - timestamp: Date.now(), - data: { type: 'add-stake', raw: '', staker: demoNimAddress }, - }; - insertFakeNimTransactions([stakeTx]); - }); - }, 500); - - // Clear interval when navigating away - demoRouter.afterEach((nextTo) => { - if (nextTo.path !== '/staking') { - clearInterval(checkForStakeButton); - } - }); - } - }); -} - -/** - * Returns the hex encoding of a UTF-8 string. - */ -function encodeTextToHex(text: string): string { - const utf8Array = Utf8Tools.stringToUtf8ByteArray(text); - return Array.from(utf8Array) - .map((byte) => byte.toString(16).padStart(2, '0')) - .join(''); -} - -// We pick a random but fixed time-of-day offset for each day. -const baseDate = new Date(); -baseDate.setHours(0, 0, 0, 0); -const baseDateMs = baseDate.getTime(); -const oneDayMs = 24 * 60 * 60 * 1000; - -/** - * Generates a past timestamp for a given number of days ago, adding a predictable random offset. - */ -function calculateDaysAgo(days: number): number { - const x = Math.sin(days) * 10000; - const fractionalPart = x - Math.floor(x); - const randomPart = Math.floor(fractionalPart * oneDayMs); - return baseDateMs - days * oneDayMs - randomPart; -} - -/** - * Flow type for our demo environment, e.g. buy, swap, stake. - */ -type DemoFlowType = 'buy' | 'swap' | 'stake'; - -/** - * The expected message structure for flow-change events between frames. - */ -type DemoFlowMessage = { kind: 'FlowChange', data: DemoFlowType }; - -/** - * Maps each flow type to a specific route path in our app. - */ -const demoRoutes: Record = { - buy: '/buy', - swap: '/swap/NIM-BTC', - stake: '/staking', -}; - -/** - * CSS for the special demo elements, stored in a normal string (no backticks). - */ -const demoCSS = ` -.transaction-list .month-label > :where(.fetching, .failed-to-fetch) { - display: none; -} - -/* Hide address */ -.active-address .meta .copyable { - display: none !important; -} - -#app > div > .wallet-status-button.nq-button-pill { - display: none; -} - -.staking-button .tooltip.staking-feature-tip { - display: none; -} - -.modal.transaction-modal .confirmed .tooltip.info-tooltip { - display: none; -} - -.send-modal-footer .footer-notice { - display: none; -} - -/* Demo address tooltip styling */ -.tooltip.demo-tooltip { - width: max-content; - background: var(--nimiq-orange-bg); - margin-left: -7rem; -} - -.tooltip.demo-tooltip::after { - background: #fc750c; /* Match the red theme for the demo warning */ -} - -.demo-highlight-badge { - position: absolute; - width: 34px; - height: 34px; - z-index: 5; - pointer-events: none; -} - -.demo-highlight-badge > div { - position: relative; - width: 100%; - height: 100%; - background: rgba(31, 35, 72, 0.1); - border: 1.5px solid rgba(255, 255, 255, 0.5); - border-radius: 50%; - backdrop-filter: blur(3px); -} - -.demo-highlight-badge > div::before { - content: ""; - position: absolute; - inset: 5px; - background: rgba(31, 35, 72, 0.3); - border: 2px solid rgba(255, 255, 255, 0.2); - box-shadow: 0px 0px 30px rgba(0, 0, 0, 0.3); - backdrop-filter: blur(3px); - border-radius: 12px; -} - -.demo-highlight-badge > div::after { - content: ""; - position: absolute; - inset: 11.6px; - background: rgba(255, 255, 255); - border-radius: 50%; -} - -.send-modal-footer .footer-notice { - display: none; -} - -.account-grid > button.reset, -.account-grid > .nimiq-account { - background: #e7e8ea; - border-radius: 8px; - transition: background 0.2s var(--nimiq-ease); -} - -.account-grid > button:where(:hover, :focus-visible) { - background: #dedee2 !important; -} - -/* Hide links and addresses to block explorer in the swap animation */ -.swap-animation :where(.short-address, .blue-link.nq-link) { - display: none !important; -} -`; - -/** - * Intercepts fetch request for swaps - */ -function interceptFetchRequest() { - const originalFetch = window.fetch; - window.fetch = async (...args: Parameters) => { - if (typeof args[0] !== 'string') return originalFetch(...args); - if (args[0].startsWith('/')) return originalFetch(...args); - - const url = new URL(args[0] as string); - const isFastspotRequest = url.host === (new URL(Config.fastspot.apiEndpoint).host); - const isLimitsRequest = url.pathname.includes('/limits'); - const isAssetsRequest = url.pathname.includes('/assets'); - const isSwapRequest = url.pathname.includes('/swaps'); - - // return originalFetch(...args); - if (!isFastspotRequest) { - return originalFetch(...args); - } - - console.log('[Demo] Intercepted fetch request:', url.pathname); - - if (isLimitsRequest) { - const constants = { - current: '9800', - daily: '50000', - dailyRemaining: '49000', - monthly: '100000', - monthlyRemaining: '98000', - swap: '10000', - } as const; - - const [assetOrLimit] = url.pathname.split('/').slice(-2) as [SwapAsset | 'limits', string]; - - if (assetOrLimit === 'limits') { - const limits: FastspotUserLimits = { - asset: ReferenceAsset.USD, - ...constants, - }; - return new Response(JSON.stringify(limits)); - } - - const asset = assetOrLimit as SwapAsset; - - const { exchangeRates, currency } = useFiatStore(); - const rate: number = exchangeRates.value[asset.toLocaleLowerCase().split('_')[0]][currency.value]!; - - const json: FastspotLimits = { - asset, - referenceAsset: ReferenceAsset.USD, - referenceCurrent: constants.current, - referenceDaily: constants.daily, - referenceDailyRemaining: constants.dailyRemaining, - referenceMonthly: constants.monthly, - referenceMonthlyRemaining: constants.monthlyRemaining, - referenceSwap: `${10000}`, - current: `${Number(constants.current) / rate}`, - daily: `${Number(constants.daily) / rate}`, - dailyRemaining: `${Number(constants.dailyRemaining) / rate}`, - monthly: `${Number(constants.monthly) / rate}`, - monthlyRemaining: `${Number(constants.monthlyRemaining) / rate}`, - swap: `${Number(constants.swap) / rate}`, - }; - - return new Response(JSON.stringify(json)); - } - - if (isAssetsRequest) { - // Return mock assets data with fees for all supported assets - const json: FastspotAsset[] = [ - { - symbol: SwapAsset.BTC, - name: 'Bitcoin', - feePerUnit: `${getNetworkFeePerUnit(SwapAsset.BTC)}`, - limits: { minimum: '0.0001', maximum: '1' }, - }, - { - symbol: SwapAsset.NIM, - name: 'Nimiq', - feePerUnit: `${getNetworkFeePerUnit(SwapAsset.NIM)}`, - limits: { minimum: '1', maximum: '100000' }, - }, - { - symbol: SwapAsset.USDC_MATIC, - name: 'USDC (Polygon)', - feePerUnit: `${getNetworkFeePerUnit(SwapAsset.USDC_MATIC)}`, - limits: { minimum: '1', maximum: '100000' }, - }, - { - symbol: SwapAsset.USDT_MATIC, - name: 'USDT (Polygon)', - feePerUnit: `${getNetworkFeePerUnit(SwapAsset.USDT_MATIC)}`, - limits: { minimum: '1', maximum: '100000' }, - }, - ]; - - return new Response(JSON.stringify(json)); - } - - if (isSwapRequest) { - const swapId = url.pathname.split('/').slice(-1)[0]; - - if (swapId === 'swaps') { - const { patchAccount, activeAccountInfo } = useAccountStore(); - const newBtcAddress = demoBtcAddress + Math.random().toString(36).slice(2, 9); - - patchAccount(activeAccountInfo.value?.id, { - ...activeAccountInfo, - btcAddresses: { - external: [...(activeAccountInfo.value?.btcAddresses.external || []), newBtcAddress], - internal: [...(activeAccountInfo.value?.btcAddresses.internal || []), newBtcAddress], - }, - }); - - const { addAddressInfos } = useBtcAddressStore(); - addAddressInfos([{ - address: newBtcAddress, - txoCount: 0, // Total number of outputs received - utxos: [], - }]); - } else { - listenForSwapChanges(); - - console.log('[Demo] Swap request:', swapId); - const swap = onGoingSwaps.get(swapId); - if (!swap) { - return new Response(JSON.stringify({ - error: 'Swap not found', - status: 404, - }), { status: 404 }); - } - - console.log('[Demo] Swap:', swap); - const expirationTimestamp = Math.floor(Date.now() / 1000) + 3600; - - return new Response(JSON.stringify({ - id: swapId, - status: SwapStatus.WAITING_FOR_CONFIRMATION, - expires: expirationTimestamp, - info: { - from: [ - { - symbol: swap.fund.type, - amount: swap.fund.output.value / 1e8, - fundingNetworkFee: { - total: '0.000037', - perUnit: '0.000000240254', - totalIsIncluded: true, - }, - operatingNetworkFee: { - total: '0', - perUnit: '0.000000240254', - totalIsIncluded: false, - }, - finalizeNetworkFee: { - total: '0.0000346', - perUnit: '0.000000240254', - totalIsIncluded: false, - }, - }, - ], - to: [ - { - symbol: swap.redeem.type, - amount: swap.redeem.value / 1e5, - fundingNetworkFee: { - total: '0', - perUnit: '0', - totalIsIncluded: false, - }, - operatingNetworkFee: { - total: '0', - perUnit: '0', - totalIsIncluded: false, - }, - finalizeNetworkFee: { - total: '0', - perUnit: '0', - totalIsIncluded: false, - }, - }, - ], - serviceFeePercentage: 0.0025, - direction: 'reverse', - }, - hash: '946dc06baf94ee49a1bd026eff8eb4f30d34c9e162211667dbebd5a5282e6294', - contracts: [ - { - asset: swap.fund.type, - refund: { - address: swap.fund.refundAddress, - }, - recipient: { - address: swap.fund.refundAddress, - }, - amount: swap.fund.output.value, - timeout: expirationTimestamp, - direction: 'send', - status: 'pending', - id: '2MzQo4ehDrSEsxX7RnysLL6VePD3tuNyx4M', - intermediary: {}, - }, - { - asset: swap.redeem.type, - refund: { - address: swap.redeem.recipient, - }, - recipient: { - address: swap.redeem.recipient, - }, - amount: swap.redeem.value, - timeout: expirationTimestamp, - direction: 'receive', - status: 'pending', - id: 'eff8a1a5-4f4e-3895-b95c-fd5a40c99001', - intermediary: {}, - }, - ], - })); - } - } - - return originalFetch(...args); - }; -} - -/** - * Fee per unit helper function - */ -function getNetworkFeePerUnit(asset: string): number { - switch (asset) { - case SwapAsset.BTC: - return Math.floor(Math.random() * 100) / 1e8; // 1 - 100 sats/vbyte - case SwapAsset.NIM: - return 0; // luna per byte - case SwapAsset.USDC_MATIC: - case SwapAsset.USDT_MATIC: - return 1000000000; // 1 Gwei - default: - return 0; - } -} - -/** - * Ensures the Send button in modals is always enabled in demo mode, regardless of network state. - * This allows users to interact with the send functionality without waiting for network sync. - */ -function enableSellAndSwapModals(processedElements: WeakSet) { - // Target the send modal and swap footer button - const bottomButton = document.querySelector('.send-modal-footer .nq-button'); - if (!bottomButton || processedElements.has(bottomButton)) return; - - if (bottomButton.hasAttribute('disabled')) { - bottomButton.removeAttribute('disabled'); - bottomButton.classList.remove('disabled'); - processedElements.add(bottomButton); - - // Also find and hide any sync message if shown - const footer = document.querySelector('.send-modal-footer'); - if (footer) { - const footerNotice = footer.querySelector('.footer-notice') as HTMLDivElement; - if (footerNotice && footerNotice.textContent - && (footerNotice.textContent.includes('Connecting to Bitcoin') - || footerNotice.textContent.includes('Syncing with Bitcoin'))) { - footerNotice.style.display = 'none'; - processedElements.add(footerNotice); - } - } - } -} - -let swapInterval: NodeJS.Timeout | null = null; -function listenForSwapChanges() { - if (swapInterval) return; - swapInterval = setInterval(() => { - // Check if there are any active swaps that need to be processed - const swap = useSwapsStore().activeSwap.value; - if (!swap) return; - console.log('[Demo] Active swap:', { swap, state: swap.state }); - switch (swap.state) { - case SwapState.AWAIT_INCOMING: - console.log('[Demo] Swap is in AWAIT_INCOMING state'); - useSwapsStore().setActiveSwap({ - ...swap, - state: SwapState.CREATE_OUTGOING, - }); - break; - case SwapState.CREATE_OUTGOING: - console.log('[Demo] Swap is in CREATE_OUTGOING state'); - useSwapsStore().setActiveSwap({ - ...swap, - state: SwapState.AWAIT_SECRET, - }); - break; - case SwapState.AWAIT_SECRET: - console.log('[Demo] Swap is in AWAIT_SECRET state'); - useSwapsStore().setActiveSwap({ - ...swap, - state: SwapState.SETTLE_INCOMING, - }); - break; - case SwapState.SETTLE_INCOMING: - console.log('[Demo] Swap is in SETTLE_INCOMING state'); - useSwapsStore().setActiveSwap({ - ...swap, - state: SwapState.COMPLETE, - }); - completeSwap(swap); - break; - case SwapState.COMPLETE: - console.log('[Demo] Swap is in COMPLETE state'); - if (swapInterval) clearInterval(swapInterval); - swapInterval = null; - break; - default: - console.log('[Demo] Swap is in unknown state'); - useSwapsStore().setActiveSwap({ - ...swap, - state: SwapState.AWAIT_INCOMING, - }); - break; - } - }, 1_800); -} - -/** - * Completes an active swap by creating transactions for both sides of the swap - */ -function completeSwap(activeSwap: any) { - // Generate a unique hash for this swap to connect both sides - const swapHash = `swap-${Math.random().toString(36).slice(2, 10)}`; - - // Add transactions for both sides of the swap - const fromAsset = activeSwap.from.asset; - const toAsset = activeSwap.to.asset; - const fromAmount = activeSwap.from.amount; - const toAmount = activeSwap.to.amount; - - const { setSwap } = useSwapsStore(); - const now = Date.now(); - const nowSecs = Math.floor(now / 1000); - - // Create outgoing transaction (from asset) - switch (fromAsset) { - case 'NIM': { - // Create a unique transaction hash for the NIM transaction - const nimTxHash = `nim-swap-${Math.random().toString(16).slice(2, 10)}`; - - // Create HTLC data that would be needed for a real swap - const nimHtlcAddress = `NQ${Math.random().toString(36).slice(2, 34)}`; - const btcAddress = `1${Math.random().toString(36).slice(2, 34)}`; - - // Register the swap with the Swaps store first - setSwap(swapHash, { - id: swapHash, - in: { - asset: SwapAsset.NIM, - transactionHash: nimTxHash, - htlc: { - address: nimHtlcAddress, - refundAddress: demoNimAddress, - redeemAddress: btcAddress, - timeoutMs: now + 3600000, - }, - }, - out: { - asset: SwapAsset.BTC, - transactionHash: `btc-swap-${Math.random().toString(16).slice(2, 10)}`, - outputIndex: 0, - htlc: { - address: `1HTLC${Math.random().toString(36).slice(2, 30)}`, - script: 'btc-htlc-script', - refundAddress: btcAddress, - redeemAddress: demoBtcAddress, - timeoutTimestamp: nowSecs + 7200, - }, - }, - }); - - // Create the NIM transaction with proper HTLC data - const tx: Partial = { - value: fromAmount, - recipient: nimHtlcAddress, - sender: demoNimAddress, - timestamp: now, - transactionHash: nimTxHash, - data: { - type: 'htlc', - hashAlgorithm: 'sha256', - hashCount: 1, - hashRoot: swapHash, - raw: encodeTextToHex(`Swap ${fromAsset}-${toAsset}`), - recipient: nimHtlcAddress, - sender: demoNimAddress, - timeout: nowSecs + 3600, - }, - }; - - insertFakeNimTransactions(transformNimTransaction([tx])); - updateNimBalance(-fromAmount); - break; - } - case 'BTC': { - // Create a unique transaction hash for the BTC transaction - const btcTxHash = `btc-swap-${Math.random().toString(16).slice(2, 10)}`; - - // Create HTLC data structures - const btcHtlcAddress = `1HTLC${Math.random().toString(36).slice(2, 30)}`; - const nimAddress = `NQ${Math.random().toString(36).slice(2, 34)}`; - - // Register the swap with the Swaps store - setSwap(swapHash, { - id: swapHash, - in: { - asset: SwapAsset.BTC, - transactionHash: btcTxHash, - outputIndex: 0, - htlc: { - address: btcHtlcAddress, - script: 'btc-htlc-script', - refundAddress: demoBtcAddress, - redeemAddress: nimAddress, - timeoutTimestamp: nowSecs + 7200, - }, - }, - out: { - asset: SwapAsset.NIM, - transactionHash: `nim-swap-${Math.random().toString(16).slice(2, 10)}`, - htlc: { - address: nimAddress, - refundAddress: nimAddress, - redeemAddress: demoNimAddress, - timeoutMs: now + 3600000, - }, - }, - }); - - // Create the BTC transaction definition - const tx: BtcTransactionDefinition = { - address: btcHtlcAddress, - daysAgo: 0, - description: `Swap ${fromAsset} to ${toAsset}`, - fraction: fromAmount / btcInitialBalance, - incoming: false, - recipientLabel: 'Bitcoin HTLC', - }; - - insertFakeBtcTransactions(transformBtcTransaction([tx])); - updateBtcBalance(-fromAmount); - break; - } - default: { - console.warn(`Unsupported asset type for swap: ${fromAsset}`); - } - } - - // Create incoming transaction (to asset) - switch (toAsset) { - case 'NIM': { - // Create a unique transaction hash for the NIM settlement transaction - const nimSettlementTxHash = `nim-settle-${Math.random().toString(16).slice(2, 10)}`; - - // Get the existing swap data to ensure we're using the same addresses - const existingSwap = useSwapsStore().getSwap.value(swapHash); - if (!existingSwap || !existingSwap.out || existingSwap.out.asset !== SwapAsset.NIM) { - console.error('[Demo] Existing swap not found or incorrect structure'); - return; - } - - // Update the swap to include the settlement transaction hash - setSwap(swapHash, { - ...existingSwap, - out: { - ...existingSwap.out, - transactionHash: nimSettlementTxHash, - }, - }); - - // Create the NIM settlement transaction - const tx: Partial = { - value: toAmount, - recipient: demoNimAddress, - sender: existingSwap.out.htlc?.address || 'HTLC-ADDRESS', - timestamp: now + 1000, // Slightly after the funding tx - transactionHash: nimSettlementTxHash, - data: { - type: 'htlc', - hashAlgorithm: 'sha256', - hashCount: 1, - hashRoot: swapHash, - raw: encodeTextToHex(`Swap ${fromAsset}-${toAsset}`), - recipient: demoNimAddress, - sender: existingSwap.out.htlc?.address || 'HTLC-ADDRESS', - timeout: nowSecs + 3600, - }, - }; - - insertFakeNimTransactions(transformNimTransaction([tx])); - updateNimBalance(toAmount); - break; - } - case 'BTC': { - // Create a unique transaction hash for the BTC settlement transaction - const btcSettlementTxHash = `btc-settle-${Math.random().toString(16).slice(2, 10)}`; - - // Get the existing swap data - const existingSwap = useSwapsStore().getSwap.value(swapHash); - if (!existingSwap || !existingSwap.out || existingSwap.out.asset !== SwapAsset.BTC) { - console.error('[Demo] Existing swap not found or incorrect structure'); - return; - } - - // Update the swap to include the settlement transaction hash - setSwap(swapHash, { - ...existingSwap, - out: { - ...existingSwap.out, - transactionHash: btcSettlementTxHash, - }, - }); - - // Create the BTC settlement transaction - const tx: BtcTransactionDefinition = { - address: demoBtcAddress, - daysAgo: 0, - description: `Swap ${fromAsset} to ${toAsset}`, - fraction: toAmount / btcInitialBalance, - incoming: true, - recipientLabel: 'BTC Settlement', - }; - - insertFakeBtcTransactions(transformBtcTransaction([tx])); - updateBtcBalance(toAmount); - break; - } - default: { - console.warn(`Unsupported asset type for swap: ${toAsset}`); - } - } - - console.log('[Demo] Swap completed:', { swapHash, fromAsset, toAsset, fromAmount, toAmount }); -} - -const ignoreHubRequests = [ - 'addBtcAddresses', - 'on', -]; - -interface SetupSwapArgs { - accountId: string; - swapId: string; - fund: { - type: 'BTC' | 'NIM' /* | 'USDC' | 'USDT' */, - inputs: { - address: string, - transactionHash: string, - outputIndex: number, - outputScript: string, - value: number, - }[], - output: { - value: number, - }, - changeOutput: { - address: string, - value: number, - }, - refundAddress: string, - }; - redeem: { - type: 'BTC' | 'NIM' /* | 'USDC' | 'USDT' */, - recipient: string, - value: number, - fee: number, - validityStartHeight: number, - }; - fundingFiatRate: number; - redeemingFiatRate: number; - fundFees: { - processing: number, - redeeming: number, - }; - redeemFees: { - funding: number, - processing: number, - }; - serviceSwapFee: number; - nimiqAddresses: { - address: string, - balance: number, - }[]; - polygonAddresses: { - address: string, - usdcBalance: number, - usdtBalance: number, - }[]; -} - -/** - * Replacement of the Hub API class to capture and redirect calls to our demo modals instead. - */ -export class DemoHubApi extends HubApi { - static create(): DemoHubApi { - const instance = new DemoHubApi(); - return new Proxy(instance, { - get(target, prop: keyof HubApi) { - if (typeof target[prop] !== 'function') { - return target[prop]; - } - - return async (...args: Parameters) => new Promise(async (resolveInterceptedAction) => { - const requestName = String(prop); - const [firstArg] = args; - console.warn(`[Demo] Mocking Hub call: ${requestName}("${firstArg}")`); - - if (ignoreHubRequests.includes(requestName)) { - return; - } - - if (requestName === 'setupSwap') { - const swap = await firstArg as SetupSwapArgs; - const signerTransaction: SetupSwapResult = { - nim: { - transaction: new Uint8Array(), - serializedTx: '0172720036a3b2ca9e0de8b369e6381753ebef945a020091fa7bbddf959616767c50c50962c9e056ade9c400000000000000989680000000000000000000c3e23d0500a60100010366687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f292520000000000000000000000000000000000000000000000000000000000000000000200demoSerializedTx', - hash: '6c58b337a907fe000demoTxHash8a1f4ab4fdc0f69b1e582f', - raw: { - signerPublicKey: new Uint8Array(), - signature: new Uint8Array(), - sender: 'NQ86 D3M0 SW4P NB59 U3F8 NDLX CE0P AFMX Y52S', - senderType: 2, - recipient: swap.redeem.recipient, - recipientType: 0, - value: swap.redeem.value, - fee: 0, - validityStartHeight: swap.redeem.validityStartHeight, - extraData: new Uint8Array(), - flags: 0, - networkId: 5, - proof: new Uint8Array(), - }, - }, - btc: { - serializedTx: '0200000000010168c8952af998f2c68412a848a72d1f9b0b7ff27417df1cb85514c97474b51ba40000000000ffffffff026515000000000000220020bf0ffdd2ffb9a579973455cfe9b56515538b79361d5ae8a4d255dea2519ef77864c501000000000016001428257447efe2d254ce850ea2760274d233d86e5c024730440220792fa932d9d0591e3c5eb03f47d05912a1e21f3e76d169e383af66e47896ac8c02205947df5523490e4138f2da0fc5c9da3039750fe43bd217b68d26730fdcae7fbe012102ef8d4b51d1a075e67d62baa78991d5fc36a658fec28d8b978826058168ed2a1a00000000', - hash: '3090808993a796c26a614f5a4a36a48e0b4af6cd3e28e39f3f006e9a447da2b3', - }, - refundTx: '02000000000101b3a27d449a6e003f9fe3283ecdf64a0b8ea4364a5a4f616ac296a793898090300000000000feffffff011e020000000000001600146d2146bb49f6d1de6b4f14e0a8074c79b887cef50447304402202a7dce2e39cf86ee1d7c1e9cc55f1e0fb26932fd22e5437e5e5804a9e5d220b1022031aa177ea085c10c4d54b2f5aa528aac0013b67f9ee674070aa2fb51894de80e0121025b4d40682bbcb5456a9d658971b725666a3cccaa2fb45d269d2f1486bf85b3c000636382012088a820be8719b9427f1551c4234f8b02d8f8aa055ae282b2e9eef6c155326ae951061f8876a914e546b01d8c9d9bf35f9f115132ce8eab7191a68d88ac67046716ca67b17576a9146d2146bb49f6d1de6b4f14e0a8074c79b887cef588ac686816ca67', - }; - - // Add to onGoingSwaps map - onGoingSwaps.set(swap.swapId, swap); - - resolveInterceptedAction(signerTransaction); - return; - } - - // Wait for router readiness - await new Promise((resolve) => { - demoRouter.onReady(resolve); - }); - - console.log('[Demo] Redirecting to fallback modal'); - demoRouter.push({ - path: `/${DemoModal.Fallback}`, - }); - }); - }, - }); - } -} - -/** - * Obfuscates addresses in the UI by: - * - Showing only first 3 chunks of addresses (rest are XXXX) for NIM addresses - * - Showing only the first few characters for BTC and polygon addresses - * - Changing the copy tooltip message - * - Changing the copy functionality to provide a demo disclaimer - */ -function obfuscateAddresses(processedElements: WeakSet) { - // Adds the common clipboard click handler to an element. - function addDemoClickHandler(el: HTMLElement) { - el.addEventListener('click', (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - el.classList.add('copied'); - setTimeout(() => el.classList.remove('copied'), 1500); - navigator.clipboard.writeText('This is a demo address - not for actual use'); - }, true); - } - - // Updates the tooltip for an element. - function updateTooltip(el: HTMLElement) { - const tooltip = el.querySelector('.tooltip') as HTMLElement; - if (tooltip && !processedElements.has(tooltip)) { - processedElements.add(tooltip); - tooltip.textContent = 'Demo address'; - tooltip.classList.add('demo-tooltip'); - addDemoClickHandler(tooltip); - } - } - - // Processes an element: marks it as processed, applies any extra changes, updates tooltip, and adds a click handler. - function processElement(el: HTMLElement, extraProcess: ((el: HTMLElement) => void) | null = null) { - if (processedElements.has(el)) return; - processedElements.add(el); - if (extraProcess) extraProcess(el); - updateTooltip(el); - addDemoClickHandler(el); - } - - // Process NIM address displays: obfuscate address chunks beyond the first three. - const nimAddressElements = document.querySelectorAll('.copyable.address-display') as NodeListOf; - nimAddressElements.forEach((el) => - processElement(el, (element) => { - const chunks = element.querySelectorAll('.chunk'); - for (let i = 3; i < chunks.length; i++) { - const chunk = chunks[i]; - const space = chunk.querySelector('.space'); - chunk.textContent = 'XXXX'; - if (space) chunk.appendChild(space); - } - }), - ); - - // Process short address displays: change the last chunk of the short address. - const shortAddressElements = document.querySelectorAll('.tooltip.interactive-short-address.is-copyable') as NodeListOf; - shortAddressElements.forEach((el) => - processElement(el, (element) => { - const lastChunk = element.querySelector('.short-address .address:last-child'); - if (lastChunk) { - lastChunk.textContent = 'xxxx'; - } - }), - ); - - // Process tooltip boxes inside short address displays. - const tooltipBoxElements = document.querySelectorAll('.tooltip.interactive-short-address.is-copyable .tooltip-box') as NodeListOf; - tooltipBoxElements.forEach((el) => { - if (processedElements.has(el)) return; - processedElements.add(el); - el.textContent = 'Demo address'; - el.classList.add('demo-tooltip'); - addDemoClickHandler(el); - }); -} - -/** - * Observes the receive modal and redirects relevant button clicks to the fallback modal - */ -function observeReceiveModal(processedElements: WeakSet) { - // Find the receive modal - const receiveModal = document.querySelector('.receive-modal'); - if (!receiveModal) return; - - // Look for buttons that should redirect to the fallback modal - const buttons = receiveModal.querySelectorAll('.nq-button-s, .qr-button'); - - buttons.forEach((button) => { - // Skip if we've already processed this button - if (processedElements.has(button)) return; - - // Mark as processed to avoid adding multiple listeners - processedElements.add(button); - - // Replace the original click handler with our redirect - button.addEventListener('click', (event) => { - // Prevent the default action and stop propagation - event.preventDefault(); - event.stopPropagation(); - - // Redirect to the fallback modal - demoRouter.replace({ - path: `/${DemoModal.Fallback}`, - }); - - console.log('[Demo] Redirected receive modal button click to fallback modal'); - }, true); // Use capture to intercept the event before other handlers - }); -} - -/** - * Observes the transaction list items to replace the identicon and address. - */ -function observeTransactionList(processedElements: WeakSet) { - const buttons = document.querySelectorAll('.transaction-list button.reset.transaction.confirmed'); - buttons.forEach((button) => { - if (processedElements.has(button)) return; - processedElements.add(button); - - const message = button.querySelector('.message') as HTMLDivElement; - if (!message || message.innerText !== '·NIM Bank purchase') return; - - // Replace identicon with bankSvg - const iconDiv = button.querySelector(':scope > .identicon'); - if (iconDiv) { - iconDiv.innerHTML = bankSvg; - } - - // Replace address text - const addressDiv = button.querySelector(':scope > .data > .address'); - if (addressDiv) { - addressDiv.textContent = 'Demo Bank'; - } - }); - - // Replace the identicon in the transaction modal for the bank - const transactionModal = document.querySelector('.transaction-modal') as HTMLDivElement; - if (!transactionModal) return; - if (processedElements.has(transactionModal)) return; - processedElements.add(transactionModal); - const message = transactionModal.querySelector('.message') as HTMLDivElement; - if (message && message.innerText === 'NIM Bank purchase') { - const iconDiv = transactionModal.querySelector('.identicon > .identicon'); - if (iconDiv) { - iconDiv.innerHTML = bankSvg.replaceAll('="48"', '="72"'); - } - } -} - -const bankSvg = ` - - - - - - - - - `; +// Re-export everything from the refactored demo modules for backward compatibility +export { + dangerouslyInitializeDemo, + checkIfDemoIsActive, + dangerouslyInsertFakeBuyNimTransaction, + DemoHubApi, + type DemoState, + type DemoFlowType, + type DemoFlowMessage, + DemoModal, + MessageEventName, + demoRoutes, +} from './demo/index'; diff --git a/src/lib/demo/DemoAccounts.ts b/src/lib/demo/DemoAccounts.ts new file mode 100644 index 000000000..2abc1e0b4 --- /dev/null +++ b/src/lib/demo/DemoAccounts.ts @@ -0,0 +1,65 @@ +import { CryptoCurrency } from '@nimiq/utils'; +import { AccountType, useAccountStore } from '@/stores/Account'; +import { AddressType, useAddressStore } from '@/stores/Address'; +import { useAccountSettingsStore } from '@/stores/AccountSettings'; +import { usePolygonAddressStore } from '@/stores/PolygonAddress'; +import { + demoNimAddress, + demoBtcAddress, + demoPolygonAddress, + nimInitialBalance, + usdcInitialBalance, + usdtInitialBalance, +} from './DemoConstants'; + +/** + * Setup and initialize the demo data for all currencies. + */ +export function setupDemoAddresses(): void { + const { setAddressInfos } = useAddressStore(); + setAddressInfos([ + { + label: 'Demo Account', + type: AddressType.BASIC, + address: demoNimAddress, + balance: nimInitialBalance, + }, + ]); + + // Setup Polygon addresses and balances + const { setAddressInfos: setPolygonAddressInfos } = usePolygonAddressStore(); + setPolygonAddressInfos([{ + address: demoPolygonAddress, + balanceUsdc: usdcInitialBalance, + balanceUsdcBridged: 0, + balanceUsdtBridged: usdtInitialBalance, + pol: 1, + }]); +} + +/** + * Creates a fake main account referencing our demo addresses. + */ +export function setupDemoAccount(): void { + const { addAccountInfo, setActiveCurrency } = useAccountStore(); + const { setStablecoin, setKnowsAboutUsdt } = useAccountSettingsStore(); + + // Setup account info with both USDC and USDT addresses + addAccountInfo({ + id: 'demo-account-1', + type: AccountType.BIP39, + label: 'Demo Main Account', + fileExported: true, + wordsExported: true, + addresses: [demoNimAddress], + btcAddresses: { internal: [demoBtcAddress], external: [demoBtcAddress] }, + polygonAddresses: [demoPolygonAddress, demoPolygonAddress], + uid: 'demo-uid-1', + }); + + // Pre-select USDC as the default stablecoin and mark USDT as known + setStablecoin(CryptoCurrency.USDC); + setKnowsAboutUsdt(true); + + setActiveCurrency(CryptoCurrency.NIM); +} diff --git a/src/lib/demo/DemoConstants.ts b/src/lib/demo/DemoConstants.ts new file mode 100644 index 000000000..8c53bfcfe --- /dev/null +++ b/src/lib/demo/DemoConstants.ts @@ -0,0 +1,169 @@ +// Demo state type +export type DemoState = { + active: boolean, +}; + +// Demo modal types +export const DemoModal = { + Fallback: 'demo-fallback', + Buy: 'demo-buy', +} as const; + +// Demo addresses +export const demoNimAddress = 'NQ57 2814 7L5B NBBD 0EU7 EL71 HXP8 M7H8 MHKD'; +export const demoBtcAddress = '1XYZDemoAddress'; +export const demoPolygonAddress = '0xabc123DemoPolygonAddress'; +export const buyFromAddress = 'NQ04 JG63 HYXL H3QF PPNA 7ED7 426M 3FQE FHE5'; + +// Initial balances +export const nimInitialBalance = 140_418 * 1e5; // 14,041,800,000 - 14 april, 2018. 5 decimals. +export const btcInitialBalance = 0.0025 * 1e8; // 1 BTC (8 decimals) +export const usdtInitialBalance = 514.83 * 1e6; // 5000 USDT (6 decimals) +export const usdcInitialBalance = 357.38 * 1e6; // 3000 USDC (6 decimals) + +// Message event types +export enum MessageEventName { + FlowChange = 'FlowChange' +} + +// Flow types +export type DemoFlowType = 'buy' | 'swap' | 'stake'; + +// Flow message structure +export type DemoFlowMessage = { kind: 'FlowChange', data: DemoFlowType }; + +// Demo routes mapping +export const demoRoutes: Record = { + buy: '/buy', + swap: '/swap/NIM-BTC', + stake: '/staking', +}; + +// Hub API requests to ignore +export const ignoreHubRequests = [ + 'addBtcAddresses', + 'on', +]; + +// Demo CSS styles +export const demoCSS = ` +.transaction-list .month-label > :where(.fetching, .failed-to-fetch) { + display: none; +} + +/* Hide address */ +.active-address .meta .copyable { + display: none !important; +} + +#app > div > .wallet-status-button.nq-button-pill { + display: none; +} + +.staking-button .tooltip.staking-feature-tip { + display: none; +} + +.modal.transaction-modal .confirmed .tooltip.info-tooltip { + display: none; +} + +.send-modal-footer .footer-notice { + display: none; +} + +/* Demo address tooltip styling */ +.tooltip.demo-tooltip { + width: max-content; + background: var(--nimiq-orange-bg); + margin-left: -7rem; +} + +.tooltip.demo-tooltip::after { + background: #fc750c; /* Match the red theme for the demo warning */ +} + +.demo-highlight-badge { + position: absolute; + width: 34px; + height: 34px; + z-index: 5; + pointer-events: none; +} + +.demo-highlight-badge > div { + position: relative; + width: 100%; + height: 100%; + background: rgba(31, 35, 72, 0.1); + border: 1.5px solid rgba(255, 255, 255, 0.5); + border-radius: 50%; + backdrop-filter: blur(3px); +} + +.demo-highlight-badge > div::before { + content: ""; + position: absolute; + inset: 5px; + background: rgba(31, 35, 72, 0.3); + border: 2px solid rgba(255, 255, 255, 0.2); + box-shadow: 0px 0px 30px rgba(0, 0, 0, 0.3); + backdrop-filter: blur(3px); + border-radius: 12px; +} + +.demo-highlight-badge > div::after { + content: ""; + position: absolute; + inset: 11.6px; + background: rgba(255, 255, 255); + border-radius: 50%; +} + +.send-modal-footer .footer-notice { + display: none; +} + +.account-grid > button.reset, +.account-grid > .nimiq-account { + background: #e7e8ea; + border-radius: 8px; + transition: background 0.2s var(--nimiq-ease); +} + +.account-grid > button:where(:hover, :focus-visible) { + background: #dedee2 !important; +} + +/* Hide links and addresses to block explorer in the swap animation */ +.swap-animation :where(.short-address, .blue-link.nq-link) { + display: none !important; +} +`; + +// Bank SVG icon +export const bankSvg = ` + + + + + + + + + `; + +/** + * Checks if the demo mode should be active. + * Demo mode is only activated through build commands: + * - yarn serve:demo + * - yarn build:demo + * - yarn build:demo-production + * This ensures demo builds are separate deployments with no runtime activation. + */ +export function checkIfDemoIsActive(): boolean { + return true; +} diff --git a/src/lib/demo/DemoDom.ts b/src/lib/demo/DemoDom.ts new file mode 100644 index 000000000..cc99f97bd --- /dev/null +++ b/src/lib/demo/DemoDom.ts @@ -0,0 +1,275 @@ +import VueRouter from 'vue-router'; +import { demoCSS, DemoModal, bankSvg } from './DemoConstants'; + +/** + * Creates a style tag to add demo-specific CSS. + */ +export function insertCustomDemoStyles(): void { + const styleElement = document.createElement('style'); + styleElement.innerHTML = demoCSS; + document.head.appendChild(styleElement); +} + +/** + * Sets up a single mutation observer to handle all DOM-based demo features + */ +export function setupSingleMutationObserver(demoRouter: VueRouter): void { + // Track processed elements to avoid duplicate processing + const processedElements = new WeakSet(); + + // Handler function to process all DOM mutations + const processDomChanges = () => { + // Call each handler with the processed elements set + setupVisualCues(processedElements); + disableSwapTriggers(processedElements); + enableSellAndSwapModals(processedElements); + obfuscateAddresses(processedElements); + observeTransactionList(processedElements); + observeReceiveModal(processedElements, demoRouter); + }; + + // Create one mutation observer for all DOM modifications + const mutationObserver = new MutationObserver(processDomChanges); + + // Observe the entire document with a single observer + mutationObserver.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['disabled'], + }); + + processDomChanges(); + // Also run checks when routes change to catch elements that appear after navigation + demoRouter.afterEach(() => { + // Wait for Vue to update the DOM after route change + setTimeout(processDomChanges, 100); + }); +} + +/** + * Observes the home view to attach a highlight to some buttons for demonstration purposes. + */ +function setupVisualCues(processedElements: WeakSet): void { + const highlightTargets = [ + ['.sidebar .trade-actions button', { top: '-18px', right: '-4px' }], + ['.sidebar .swap-tooltip button', { top: '-18px', right: '-8px' }], + ['.actions .staking-button', { top: '-2px', right: '-2px' }], + ] as const; + + highlightTargets.forEach(([selector, position]) => { + const target = document.querySelector(selector); + if (!target || processedElements.has(target) || target.querySelector('.demo-highlight-badge')) return; + + const wrapper = document.createElement('div'); + wrapper.classList.add('demo-highlight-badge'); + wrapper.style.top = position.top; + wrapper.style.right = position.right; + const circle = document.createElement('div'); + + wrapper.appendChild(circle); + target.appendChild(wrapper); + processedElements.add(target); + }); +} + +/** + * The only swap allowed is NIM-BTC. + * Removes the swap triggers from the account grid so the user does not the + * option to swap or sell assets in the demo environment. + * We also remove the pair selection in the SwapModal. + */ +function disableSwapTriggers(processedElements: WeakSet): void { + const swapTriggers = document.querySelectorAll( + '.account-grid > :where(.nim-usdc-swap-button, .nim-btc-swap-button, .btc-usdc-swap-button, .account-backgrounds)', + ) as NodeListOf; + + swapTriggers.forEach((trigger) => { + if (!processedElements.has(trigger)) { + trigger.remove(); + processedElements.add(trigger); + } + }); + + const pairSelection = document.querySelector('.pair-selection'); + if (pairSelection && !processedElements.has(pairSelection)) { + pairSelection.remove(); + processedElements.add(pairSelection); + } +} + +/** + * Ensures the Send button in modals is always enabled in demo mode, regardless of network state. + * This allows users to interact with the send functionality without waiting for network sync. + */ +function enableSellAndSwapModals(processedElements: WeakSet): void { + // Target the send modal and swap footer button + const bottomButton = document.querySelector('.send-modal-footer .nq-button'); + if (!bottomButton || processedElements.has(bottomButton)) return; + + if (bottomButton.hasAttribute('disabled')) { + bottomButton.removeAttribute('disabled'); + bottomButton.classList.remove('disabled'); + processedElements.add(bottomButton); + + // Also find and hide any sync message if shown + const footer = document.querySelector('.send-modal-footer'); + if (footer) { + const footerNotice = footer.querySelector('.footer-notice') as HTMLDivElement; + if (footerNotice && footerNotice.textContent + && (footerNotice.textContent.includes('Connecting to Bitcoin') + || footerNotice.textContent.includes('Syncing with Bitcoin'))) { + footerNotice.style.display = 'none'; + processedElements.add(footerNotice); + } + } + } +} + +/** + * Obfuscates addresses in the UI by: + * - Showing only first 3 chunks of addresses (rest are XXXX) for NIM addresses + * - Showing only the first few characters for BTC and polygon addresses + * - Changing the copy tooltip message + * - Changing the copy functionality to provide a demo disclaimer + */ +function obfuscateAddresses(processedElements: WeakSet): void { + // Adds the common clipboard click handler to an element. + function addDemoClickHandler(el: HTMLElement) { + el.addEventListener('click', (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + el.classList.add('copied'); + setTimeout(() => el.classList.remove('copied'), 1500); + navigator.clipboard.writeText('This is a demo address - not for actual use'); + }, true); + } + + // Updates the tooltip for an element. + function updateTooltip(el: HTMLElement) { + const tooltip = el.querySelector('.tooltip') as HTMLElement; + if (tooltip && !processedElements.has(tooltip)) { + processedElements.add(tooltip); + tooltip.textContent = 'Demo address'; + tooltip.classList.add('demo-tooltip'); + addDemoClickHandler(tooltip); + } + } + + // Processes an element: marks it as processed, applies any extra changes, updates tooltip, and adds a click handler. + function processElement(el: HTMLElement, extraProcess: ((el: HTMLElement) => void) | null = null) { + if (processedElements.has(el)) return; + processedElements.add(el); + if (extraProcess) extraProcess(el); + updateTooltip(el); + addDemoClickHandler(el); + } + + // Process NIM address displays: obfuscate address chunks beyond the first three. + const nimAddressElements = document.querySelectorAll('.copyable.address-display') as NodeListOf; + nimAddressElements.forEach((el) => + processElement(el, (element) => { + const chunks = element.querySelectorAll('.chunk'); + for (let i = 3; i < chunks.length; i++) { + const chunk = chunks[i]; + const space = chunk.querySelector('.space'); + chunk.textContent = 'XXXX'; + if (space) chunk.appendChild(space); + } + }), + ); + + // Process short address displays: change the last chunk of the short address. + const shortAddressElements = document.querySelectorAll('.tooltip.interactive-short-address.is-copyable') as NodeListOf; + shortAddressElements.forEach((el) => + processElement(el, (element) => { + const lastChunk = element.querySelector('.short-address .address:last-child'); + if (lastChunk) { + lastChunk.textContent = 'xxxx'; + } + }), + ); + + // Process tooltip boxes inside short address displays. + const tooltipBoxElements = document.querySelectorAll('.tooltip.interactive-short-address.is-copyable .tooltip-box') as NodeListOf; + tooltipBoxElements.forEach((el) => { + if (processedElements.has(el)) return; + processedElements.add(el); + el.textContent = 'Demo address'; + el.classList.add('demo-tooltip'); + addDemoClickHandler(el); + }); +} + +/** + * Observes the receive modal and redirects relevant button clicks to the fallback modal + */ +function observeReceiveModal(processedElements: WeakSet, demoRouter: VueRouter): void { + // Find the receive modal + const receiveModal = document.querySelector('.receive-modal'); + if (!receiveModal) return; + + // Look for buttons that should redirect to the fallback modal + const buttons = receiveModal.querySelectorAll('.nq-button-s, .qr-button'); + + buttons.forEach((button) => { + // Skip if we've already processed this button + if (processedElements.has(button)) return; + + // Mark as processed to avoid adding multiple listeners + processedElements.add(button); + + // Replace the original click handler with our redirect + button.addEventListener('click', (event) => { + // Prevent the default action and stop propagation + event.preventDefault(); + event.stopPropagation(); + + // Redirect to the fallback modal + demoRouter.replace({ + path: `/${DemoModal.Fallback}`, + }); + + console.log('[Demo] Redirected receive modal button click to fallback modal'); + }, true); // Use capture to intercept the event before other handlers + }); +} + +/** + * Observes the transaction list items to replace the identicon and address. + */ +function observeTransactionList(processedElements: WeakSet): void { + const buttons = document.querySelectorAll('.transaction-list button.reset.transaction.confirmed'); + buttons.forEach((button) => { + if (processedElements.has(button)) return; + processedElements.add(button); + + const message = button.querySelector('.message') as HTMLDivElement; + if (!message || message.innerText !== '·NIM Bank purchase') return; + + // Replace identicon with bankSvg + const iconDiv = button.querySelector(':scope > .identicon'); + if (iconDiv) { + iconDiv.innerHTML = bankSvg; + } + + // Replace address text + const addressDiv = button.querySelector(':scope > .data > .address'); + if (addressDiv) { + addressDiv.textContent = 'Demo Bank'; + } + }); + + // Replace the identicon in the transaction modal for the bank + const transactionModal = document.querySelector('.transaction-modal') as HTMLDivElement; + if (!transactionModal) return; + if (processedElements.has(transactionModal)) return; + processedElements.add(transactionModal); + const message = transactionModal.querySelector('.message') as HTMLDivElement; + if (message && message.innerText === 'NIM Bank purchase') { + const iconDiv = transactionModal.querySelector('.identicon > .identicon'); + if (iconDiv) { + iconDiv.innerHTML = bankSvg.replaceAll('="48"', '="72"'); + } + } +} diff --git a/src/lib/demo/DemoFlows.ts b/src/lib/demo/DemoFlows.ts new file mode 100644 index 000000000..a5fbba4b6 --- /dev/null +++ b/src/lib/demo/DemoFlows.ts @@ -0,0 +1,105 @@ +import VueRouter from 'vue-router'; +import { useStakingStore } from '@/stores/Staking'; +import { demoNimAddress } from './DemoConstants'; +import { createStakeTransaction, transformNimTransaction, insertFakeNimTransactions } from './DemoTransactions'; + +/** + * Observes the staking modal and prevents from validating the info and instead fakes the staking process. + */ +export function replaceBuyNimFlow(demoRouter: VueRouter): void { + let observedTarget: HTMLDivElement | undefined; + + demoRouter.afterEach((to) => { + if (to.path.startsWith('/')) { + const targetSelector = '.sidebar .trade-actions'; + const checkForTradeActions = setInterval(() => { + const target = document.querySelector(targetSelector) as HTMLDivElement; + if (!target || target === observedTarget) return; + observedTarget = target; + + target.innerHTML = ''; + + const btn1 = document.createElement('button'); + btn1.className = 'nq-button-s inverse'; + btn1.style.flex = '1'; + btn1.addEventListener('click', () => { + demoRouter.push({ + path: '/buy', + }); + }); + btn1.innerHTML = 'Buy'; + + const btn2 = document.createElement('button'); + btn2.className = 'nq-button-s inverse'; + btn2.style.flex = '1'; + btn2.disabled = true; + btn2.innerHTML = 'Sell'; + + target.appendChild(btn1); + target.appendChild(btn2); + }, 500); + + // Clear interval when navigating away + demoRouter.afterEach((nextTo) => { + if (!nextTo.path.startsWith('/')) { + clearInterval(checkForTradeActions); + observedTarget = undefined; + } + }); + } + }); +} + +/** + * Observes the staking modal and prevents from validating the info and instead fakes the staking process. + */ +export function replaceStakingFlow(demoRouter: VueRouter): void { + let lastProcessedButton: HTMLButtonElement; + + demoRouter.afterEach((to) => { + if (to.path === '/staking') { + const checkForStakeButton = setInterval(() => { + const target = document.querySelector('.stake-graph-page .stake-button'); + if (!target || target === lastProcessedButton) return; + + // remove previous listeners by cloning the element and replacing the original + const newElement = target.cloneNode(true) as HTMLButtonElement; + target.parentNode!.replaceChild(newElement, target); + newElement.removeAttribute('disabled'); + lastProcessedButton = newElement; + + newElement.addEventListener('click', async () => { + const { setStake } = useStakingStore(); + const { activeValidator } = useStakingStore(); + const amountInput = document.querySelector('.nq-input') as HTMLInputElement; + const amount = Number.parseFloat(amountInput.value.replaceAll(/[^\d]/g, '')) * 1e5; + + const { address: validatorAddress } = activeValidator.value!; + + demoRouter.push({ + path: '/', + }); + + await new Promise((resolve) => { window.setTimeout(resolve, 100); }); + setStake({ + activeBalance: 0, + inactiveBalance: amount, + address: demoNimAddress, + retiredBalance: 0, + validator: validatorAddress, + }); + + const stakeTx = createStakeTransaction(amount, validatorAddress); + insertFakeNimTransactions(transformNimTransaction([stakeTx])); + }); + }, 500); + + // Clear interval when navigating away + demoRouter.afterEach((nextTo) => { + if (nextTo.path !== '/staking') { + clearInterval(checkForStakeButton); + } + }); + } + }); +} diff --git a/src/lib/demo/DemoHubApi.ts b/src/lib/demo/DemoHubApi.ts new file mode 100644 index 000000000..be22ee873 --- /dev/null +++ b/src/lib/demo/DemoHubApi.ts @@ -0,0 +1,122 @@ +import HubApi, { SetupSwapResult } from '@nimiq/hub-api'; +import { ignoreHubRequests } from './DemoConstants'; +import { addOngoingSwap } from './DemoSwaps'; + +interface SetupSwapArgs { + accountId: string; + swapId: string; + fund: { + type: 'BTC' | 'NIM' /* | 'USDC' | 'USDT' */, + inputs: { + address: string, + transactionHash: string, + outputIndex: number, + outputScript: string, + value: number, + }[], + output: { + value: number, + }, + changeOutput: { + address: string, + value: number, + }, + refundAddress: string, + }; + redeem: { + type: 'BTC' | 'NIM' /* | 'USDC' | 'USDT' */, + recipient: string, + value: number, + fee: number, + validityStartHeight: number, + }; + fundingFiatRate: number; + redeemingFiatRate: number; + fundFees: { + processing: number, + redeeming: number, + }; + redeemFees: { + funding: number, + processing: number, + }; + serviceSwapFee: number; + nimiqAddresses: { + address: string, + balance: number, + }[]; + polygonAddresses: { + address: string, + usdcBalance: number, + usdtBalance: number, + }[]; +} + +/** + * Replacement of the Hub API class to capture and redirect calls to our demo modals instead. + */ +export class DemoHubApi extends HubApi { + static create(): DemoHubApi { + const instance = new DemoHubApi(); + return new Proxy(instance, { + get(target, prop: keyof HubApi) { + if (typeof target[prop] !== 'function') { + return target[prop]; + } + + return async (...args: Parameters) => new Promise(async (resolveInterceptedAction) => { + const requestName = String(prop); + const [firstArg] = args; + console.warn(`[Demo] Mocking Hub call: ${requestName}("${firstArg}")`); + + if (ignoreHubRequests.includes(requestName)) { + return; + } + + if (requestName === 'setupSwap') { + const swap = await firstArg as SetupSwapArgs; + const signerTransaction: SetupSwapResult = { + nim: { + transaction: new Uint8Array(), + serializedTx: '0172720036a3b2ca9e0de8b369e6381753ebef945a020091fa7bbddf959616767c50c50962c9e056ade9c400000000000000989680000000000000000000c3e23d0500a60100010366687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f292520000000000000000000000000000000000000000000000000000000000000000000200demoSerializedTx', + hash: '6c58b337a907fe000demoTxHash8a1f4ab4fdc0f69b1e582f', + raw: { + signerPublicKey: new Uint8Array(), + signature: new Uint8Array(), + sender: 'NQ86 D3M0 SW4P NB59 U3F8 NDLX CE0P AFMX Y52S', + senderType: 2, + recipient: swap.redeem.recipient, + recipientType: 0, + value: swap.redeem.value, + fee: 0, + validityStartHeight: swap.redeem.validityStartHeight, + extraData: new Uint8Array(), + flags: 0, + networkId: 5, + proof: new Uint8Array(), + }, + }, + btc: { + serializedTx: '0200000000010168c8952af998f2c68412a848a72d1f9b0b7ff27417df1cb85514c97474b51ba40000000000ffffffff026515000000000000220020bf0ffdd2ffb9a579973455cfe9b56515538b79361d5ae8a4d255dea2519ef77864c501000000000016001428257447efe2d254ce850ea2760274d233d86e5c024730440220792fa932d9d0591e3c5eb03f47d05912a1e21f3e76d169e383af66e47896ac8c02205947df5523490e4138f2da0fc5c9da3039750fe43bd217b68d26730fdcae7fbe012102ef8d4b51d1a075e67d62baa78991d5fc36a658fec28d8b978826058168ed2a1a00000000', + hash: '3090808993a796c26a614f5a4a36a48e0b4af6cd3e28e39f3f006e9a447da2b3', + }, + refundTx: '02000000000101b3a27d449a6e003f9fe3283ecdf64a0b8ea4364a5a4f616ac296a793898090300000000000feffffff011e020000000000001600146d2146bb49f6d1de6b4f14e0a8074c79b887cef50447304402202a7dce2e39cf86ee1d7c1e9cc55f1e0fb26932fd22e5437e5e5804a9e5d220b1022031aa177ea085c10c4d54b2f5aa528aac0013b67f9ee674070aa2fb51894de80e0121025b4d40682bbcb5456a9d658971b725666a3cccaa2fb45d269d2f1486bf85b3c000636382012088a820be8719b9427f1551c4234f8b02d8f8aa055ae282b2e9eef6c155326ae951061f8876a914e546b01d8c9d9bf35f9f115132ce8eab7191a68d88ac67046716ca67b17576a9146d2146bb49f6d1de6b4f14e0a8074c79b887cef588ac686816ca67', + }; + + // Add to onGoingSwaps map + addOngoingSwap(swap.swapId, swap); + + resolveInterceptedAction(signerTransaction); + return; + } + + // For any other Hub API method, redirect to the demo fallback modal + console.log('[Demo] Redirecting to fallback modal'); + // This would need access to the router, which would be passed in during initialization + // For now, we'll resolve with undefined and let the UI handle it + resolveInterceptedAction(undefined); + }); + }, + }); + } +} diff --git a/src/lib/demo/DemoSwaps.ts b/src/lib/demo/DemoSwaps.ts new file mode 100644 index 000000000..4eaf9700b --- /dev/null +++ b/src/lib/demo/DemoSwaps.ts @@ -0,0 +1,386 @@ +import { + FastspotAsset, + FastspotLimits, + FastspotUserLimits, + ReferenceAsset, + SwapAsset, + SwapStatus, +} from '@nimiq/fastspot-api'; +import Config from 'config'; +import { useAccountStore } from '@/stores/Account'; +import { SwapState, useSwapsStore } from '@/stores/Swaps'; +import { useFiatStore } from '@/stores/Fiat'; +import { useBtcAddressStore } from '@/stores/BtcAddress'; +import { completeSwapTransactions } from './DemoTransactions'; +import { demoBtcAddress } from './DemoConstants'; + +// Track ongoing swaps +const onGoingSwaps = new Map(); + +interface SetupSwapArgs { + accountId: string; + swapId: string; + fund: { + type: 'BTC' | 'NIM' /* | 'USDC' | 'USDT' */, + inputs: { + address: string, + transactionHash: string, + outputIndex: number, + outputScript: string, + value: number, + }[], + output: { + value: number, + }, + changeOutput: { + address: string, + value: number, + }, + refundAddress: string, + }; + redeem: { + type: 'BTC' | 'NIM' /* | 'USDC' | 'USDT' */, + recipient: string, + value: number, + fee: number, + validityStartHeight: number, + }; + fundingFiatRate: number; + redeemingFiatRate: number; + fundFees: { + processing: number, + redeeming: number, + }; + redeemFees: { + funding: number, + processing: number, + }; + serviceSwapFee: number; + nimiqAddresses: { + address: string, + balance: number, + }[]; + polygonAddresses: { + address: string, + usdcBalance: number, + usdtBalance: number, + }[]; +} + +/** + * Intercepts fetch request for swaps + */ +export function interceptFetchRequest(): void { + const originalFetch = window.fetch; + window.fetch = async (...args: Parameters) => { + if (typeof args[0] !== 'string') return originalFetch(...args); + if (args[0].startsWith('/')) return originalFetch(...args); + + const url = new URL(args[0] as string); + const isFastspotRequest = url.host === (new URL(Config.fastspot.apiEndpoint).host); + const isLimitsRequest = url.pathname.includes('/limits'); + const isAssetsRequest = url.pathname.includes('/assets'); + const isSwapRequest = url.pathname.includes('/swaps'); + + // return originalFetch(...args); + if (!isFastspotRequest) { + return originalFetch(...args); + } + + console.log('[Demo] Intercepted fetch request:', url.pathname); + + if (isLimitsRequest) { + const constants = { + current: '9800', + daily: '50000', + dailyRemaining: '49000', + monthly: '100000', + monthlyRemaining: '98000', + swap: '10000', + } as const; + + const [assetOrLimit] = url.pathname.split('/').slice(-2) as [SwapAsset | 'limits', string]; + + if (assetOrLimit === 'limits') { + const limits: FastspotUserLimits = { + asset: ReferenceAsset.USD, + ...constants, + }; + return new Response(JSON.stringify(limits)); + } + + const asset = assetOrLimit as SwapAsset; + + const { exchangeRates, currency } = useFiatStore(); + const rate: number = exchangeRates.value[asset.toLocaleLowerCase().split('_')[0]][currency.value]!; + + const json: FastspotLimits = { + asset, + referenceAsset: ReferenceAsset.USD, + referenceCurrent: constants.current, + referenceDaily: constants.daily, + referenceDailyRemaining: constants.dailyRemaining, + referenceMonthly: constants.monthly, + referenceMonthlyRemaining: constants.monthlyRemaining, + referenceSwap: `${10000}`, + current: `${Number(constants.current) / rate}`, + daily: `${Number(constants.daily) / rate}`, + dailyRemaining: `${Number(constants.dailyRemaining) / rate}`, + monthly: `${Number(constants.monthly) / rate}`, + monthlyRemaining: `${Number(constants.monthlyRemaining) / rate}`, + swap: `${Number(constants.swap) / rate}`, + }; + + return new Response(JSON.stringify(json)); + } + + if (isAssetsRequest) { + // Return mock assets data with fees for all supported assets + const json: FastspotAsset[] = [ + { + symbol: SwapAsset.BTC, + name: 'Bitcoin', + feePerUnit: `${getNetworkFeePerUnit(SwapAsset.BTC)}`, + limits: { minimum: '0.0001', maximum: '1' }, + }, + { + symbol: SwapAsset.NIM, + name: 'Nimiq', + feePerUnit: `${getNetworkFeePerUnit(SwapAsset.NIM)}`, + limits: { minimum: '1', maximum: '100000' }, + }, + { + symbol: SwapAsset.USDC_MATIC, + name: 'USDC (Polygon)', + feePerUnit: `${getNetworkFeePerUnit(SwapAsset.USDC_MATIC)}`, + limits: { minimum: '1', maximum: '100000' }, + }, + { + symbol: SwapAsset.USDT_MATIC, + name: 'USDT (Polygon)', + feePerUnit: `${getNetworkFeePerUnit(SwapAsset.USDT_MATIC)}`, + limits: { minimum: '1', maximum: '100000' }, + }, + ]; + + return new Response(JSON.stringify(json)); + } + + if (isSwapRequest) { + const swapId = url.pathname.split('/').slice(-1)[0]; + + if (swapId === 'swaps') { + const { patchAccount, activeAccountInfo } = useAccountStore(); + const newBtcAddress = demoBtcAddress + Math.random().toString(36).slice(2, 9); + + patchAccount(activeAccountInfo.value?.id, { + ...activeAccountInfo, + btcAddresses: { + external: [...(activeAccountInfo.value?.btcAddresses.external || []), newBtcAddress], + internal: [...(activeAccountInfo.value?.btcAddresses.internal || []), newBtcAddress], + }, + }); + + const { addAddressInfos } = useBtcAddressStore(); + addAddressInfos([{ + address: newBtcAddress, + txoCount: 0, // Total number of outputs received + utxos: [], + }]); + } else { + listenForSwapChanges(); + + console.log('[Demo] Swap request:', swapId); + const swap = onGoingSwaps.get(swapId); + if (!swap) { + return new Response(JSON.stringify({ + error: 'Swap not found', + status: 404, + }), { status: 404 }); + } + + console.log('[Demo] Swap:', swap); + const expirationTimestamp = Math.floor(Date.now() / 1000) + 3600; + + return new Response(JSON.stringify({ + id: swapId, + status: SwapStatus.WAITING_FOR_CONFIRMATION, + expires: expirationTimestamp, + info: { + from: [ + { + symbol: swap.fund.type, + amount: swap.fund.output.value / 1e8, + fundingNetworkFee: { + total: '0.000037', + perUnit: '0.000000240254', + totalIsIncluded: true, + }, + operatingNetworkFee: { + total: '0', + perUnit: '0.000000240254', + totalIsIncluded: false, + }, + finalizeNetworkFee: { + total: '0.0000346', + perUnit: '0.000000240254', + totalIsIncluded: false, + }, + }, + ], + to: [ + { + symbol: swap.redeem.type, + amount: swap.redeem.value / 1e5, + fundingNetworkFee: { + total: '0', + perUnit: '0', + totalIsIncluded: false, + }, + operatingNetworkFee: { + total: '0', + perUnit: '0', + totalIsIncluded: false, + }, + finalizeNetworkFee: { + total: '0', + perUnit: '0', + totalIsIncluded: false, + }, + }, + ], + serviceFeePercentage: 0.0025, + direction: 'reverse', + }, + hash: '946dc06baf94ee49a1bd026eff8eb4f30d34c9e162211667dbebd5a5282e6294', + contracts: [ + { + asset: swap.fund.type, + refund: { + address: swap.fund.refundAddress, + }, + recipient: { + address: swap.fund.refundAddress, + }, + amount: swap.fund.output.value, + timeout: expirationTimestamp, + direction: 'send', + status: 'pending', + id: '2MzQo4ehDrSEsxX7RnysLL6VePD3tuNyx4M', + intermediary: {}, + }, + { + asset: swap.redeem.type, + refund: { + address: swap.redeem.recipient, + }, + recipient: { + address: swap.redeem.recipient, + }, + amount: swap.redeem.value, + timeout: expirationTimestamp, + direction: 'receive', + status: 'pending', + id: 'eff8a1a5-4f4e-3895-b95c-fd5a40c99001', + intermediary: {}, + }, + ], + })); + } + } + + return originalFetch(...args); + }; +} + +/** + * Fee per unit helper function + */ +function getNetworkFeePerUnit(asset: string): number { + switch (asset) { + case SwapAsset.BTC: + return Math.floor(Math.random() * 100) / 1e8; // 1 - 100 sats/vbyte + case SwapAsset.NIM: + return 0; // luna per byte + case SwapAsset.USDC_MATIC: + case SwapAsset.USDT_MATIC: + return 1000000000; // 1 Gwei + default: + return 0; + } +} + +let swapInterval: NodeJS.Timeout | null = null; + +export function listenForSwapChanges(): void { + if (swapInterval) return; + swapInterval = setInterval(() => { + // Check if there are any active swaps that need to be processed + const swap = useSwapsStore().activeSwap.value; + if (!swap) return; + console.log('[Demo] Active swap:', { swap, state: swap.state }); + switch (swap.state) { + case SwapState.AWAIT_INCOMING: + console.log('[Demo] Swap is in AWAIT_INCOMING state'); + useSwapsStore().setActiveSwap({ + ...swap, + state: SwapState.CREATE_OUTGOING, + }); + break; + case SwapState.CREATE_OUTGOING: + console.log('[Demo] Swap is in CREATE_OUTGOING state'); + useSwapsStore().setActiveSwap({ + ...swap, + state: SwapState.AWAIT_SECRET, + }); + break; + case SwapState.AWAIT_SECRET: + console.log('[Demo] Swap is in AWAIT_SECRET state'); + useSwapsStore().setActiveSwap({ + ...swap, + state: SwapState.SETTLE_INCOMING, + }); + break; + case SwapState.SETTLE_INCOMING: + console.log('[Demo] Swap is in SETTLE_INCOMING state'); + useSwapsStore().setActiveSwap({ + ...swap, + state: SwapState.COMPLETE, + }); + completeSwap(swap); + break; + case SwapState.COMPLETE: + console.log('[Demo] Swap is in COMPLETE state'); + if (swapInterval) clearInterval(swapInterval); + swapInterval = null; + break; + default: + console.log('[Demo] Swap is in unknown state'); + useSwapsStore().setActiveSwap({ + ...swap, + state: SwapState.AWAIT_INCOMING, + }); + break; + } + }, 1_800); +} + +/** + * Completes an active swap by creating transactions for both sides of the swap + */ +function completeSwap(activeSwap: any): void { + // Add transactions for both sides of the swap + const fromAsset = activeSwap.from.asset; + const toAsset = activeSwap.to.asset; + const fromAmount = activeSwap.from.amount; + const toAmount = activeSwap.to.amount; + + // Use the consolidated transaction creation function + completeSwapTransactions(fromAsset, fromAmount, toAsset, toAmount); + + console.log('[Demo] Swap completed:', { fromAsset, toAsset, fromAmount, toAmount }); +} + +export function addOngoingSwap(swapId: string, swapArgs: SetupSwapArgs): void { + onGoingSwaps.set(swapId, swapArgs); +} diff --git a/src/lib/demo/DemoTransactions.ts b/src/lib/demo/DemoTransactions.ts new file mode 100644 index 000000000..73e8518bf --- /dev/null +++ b/src/lib/demo/DemoTransactions.ts @@ -0,0 +1,1130 @@ +import { KeyPair, PlainTransactionDetails, PrivateKey } from '@nimiq/core'; +import { TransactionState as ElectrumTransactionState } from '@nimiq/electrum-client'; +import { + type Transaction as NimTransaction, + useTransactionsStore, + toSecs, +} from '@/stores/Transactions'; +import { + useBtcTransactionsStore, + type Transaction as BtcTransaction, +} from '@/stores/BtcTransactions'; +import { + useUsdtTransactionsStore, + TransactionState as UsdtTransactionState, + type Transaction as UsdtTransaction, +} from '@/stores/UsdtTransactions'; +import { + useUsdcTransactionsStore, + TransactionState as UsdcTransactionState, + type Transaction as UsdcTransaction, +} from '@/stores/UsdcTransactions'; +import { useAddressStore } from '@/stores/Address'; +import { useContactsStore } from '@/stores/Contacts'; +import { useBtcAddressStore } from '@/stores/BtcAddress'; +import { useBtcLabelsStore } from '@/stores/BtcLabels'; +import { useUsdcContactsStore } from '@/stores/UsdcContacts'; +import { useUsdtContactsStore } from '@/stores/UsdtContacts'; +import Config from 'config'; +import { + nimInitialBalance, + btcInitialBalance, + usdcInitialBalance, + usdtInitialBalance, + demoNimAddress, + demoBtcAddress, + demoPolygonAddress, + buyFromAddress, +} from './DemoConstants'; +import { encodeTextToHex, calculateDaysAgo, getRandomPolygonHash, getRandomPolygonAddress } from './DemoUtils'; + +// #region NIM Transactions + +interface NimTransactionDefinition { + fraction: number; + daysAgo: number; + description: string; + recipientLabel?: string; +} + +/** + * Defines transaction definitions for demo NIM transactions + * Story: A crypto enthusiast who made money with BTC, converted to NIM, and travels the world + */ +function defineNimFakeTransactions(): Partial[] { + const txDefinitions: NimTransactionDefinition[] = [ + // Recent transactions - Currently in Europe + { fraction: -0.015, daysAgo: 0.2, description: 'Coffee in Saarsbrücken downtown', recipientLabel: 'Café am Markt' }, + { fraction: -0.03, daysAgo: 0.8, description: 'Train ticket to Saarsbrücken', recipientLabel: 'Deutsche Bahn' }, + { fraction: -0.025, daysAgo: 1.2, description: 'Hostel in Frankfurt', recipientLabel: 'Frankfurt Backpackers' }, + + // Europe - Western & Central + { fraction: -0.04, daysAgo: 3, description: 'Dinner in Berlin', recipientLabel: 'Local Bistro' }, + { fraction: -0.02, daysAgo: 4, description: 'Museum entry in Berlin', recipientLabel: 'Museum Island' }, + { fraction: -0.035, daysAgo: 6, description: 'Night train to Prague', recipientLabel: 'Czech Railways' }, + { fraction: -0.03, daysAgo: 8, description: 'Traditional meal in Prague', recipientLabel: 'Old Town Restaurant' }, + { fraction: -0.045, daysAgo: 12, description: 'Bus to Vienna', recipientLabel: 'FlixBus' }, + + // Europe - Mediterranean + { fraction: -0.06, daysAgo: 18, description: 'Ferry to Greek Islands', recipientLabel: 'Aegean Sea Lines' }, + { fraction: -0.04, daysAgo: 22, description: 'Santorini sunset tour', recipientLabel: 'Island Tours' }, + { fraction: -0.055, daysAgo: 28, description: 'Flight Athens to Istanbul', recipientLabel: 'Turkish Airlines' }, + + // Asia - Middle East & Central + { fraction: -0.03, daysAgo: 32, description: 'Turkish bath in Istanbul', recipientLabel: 'Galata Hammam' }, + { fraction: -0.07, daysAgo: 38, description: 'Overland bus to Uzbekistan', recipientLabel: 'Central Asia Transit' }, + { fraction: -0.035, daysAgo: 45, description: 'Silk Road tour in Samarkand', recipientLabel: 'Heritage Tours' }, + + // Asia - South & Southeast + { fraction: -0.08, daysAgo: 52, description: 'Flight to Delhi', recipientLabel: 'Air India' }, + { fraction: -0.025, daysAgo: 58, description: 'Train ticket to Rajasthan', recipientLabel: 'Indian Railways' }, + { fraction: -0.04, daysAgo: 65, description: 'Camel safari in Jaisalmer', recipientLabel: 'Desert Adventures' }, + { fraction: -0.06, daysAgo: 72, description: 'Flight to Bangkok', recipientLabel: 'Thai Airways' }, + { fraction: -0.02, daysAgo: 78, description: 'Bus to Bangkok city center', recipientLabel: 'Bangkok Bus' }, + { fraction: -0.03, daysAgo: 82, description: 'Street food tour Bangkok', recipientLabel: 'Local Food Guide' }, + { fraction: -0.045, daysAgo: 88, description: 'Train to Vietnam border', recipientLabel: 'Thai State Railways' }, + + // Asia - Vietnam (Nimiq community location) + { fraction: -0.035, daysAgo: 95, description: 'Motorbike rental in Ho Chi Minh', recipientLabel: 'Saigon Bikes' }, + { fraction: -0.025, daysAgo: 98, description: 'Pho lunch in District 1', recipientLabel: 'Pho 2000' }, + { fraction: -0.04, daysAgo: 105, description: 'Halong Bay cruise', recipientLabel: 'Heritage Cruises' }, + { fraction: -0.03, daysAgo: 112, description: 'Night bus to Hanoi', recipientLabel: 'Vietnam Bus Lines' }, + + // Asia - East + { fraction: -0.08, daysAgo: 125, description: 'Flight Hanoi to Tokyo', recipientLabel: 'Vietnam Airlines' }, + { fraction: -0.05, daysAgo: 132, description: 'JR Pass for bullet trains', recipientLabel: 'JR Central' }, + { fraction: -0.035, daysAgo: 138, description: 'Sushi dinner in Shibuya', recipientLabel: 'Tokyo Sushi Bar' }, + + // Oceania + { fraction: -0.12, daysAgo: 148, description: 'Flight Tokyo to Sydney', recipientLabel: 'Qantas Airways' }, + { fraction: -0.04, daysAgo: 155, description: 'Hostel in Bondi Beach', recipientLabel: 'Bondi Backpackers' }, + { fraction: -0.045, daysAgo: 162, description: 'Great Barrier Reef tour', recipientLabel: 'Reef Tours Australia' }, + { fraction: -0.06, daysAgo: 175, description: 'Flight Sydney to Auckland', recipientLabel: 'Air New Zealand' }, + { fraction: -0.035, daysAgo: 182, description: 'Hobbiton movie set tour', recipientLabel: 'NZ Movie Tours' }, + + // Africa - East & South + { fraction: -0.15, daysAgo: 195, description: 'Flight Auckland to Nairobi', recipientLabel: 'Kenya Airways' }, + { fraction: -0.055, daysAgo: 205, description: 'Safari in Masai Mara', recipientLabel: 'African Safaris' }, + { fraction: -0.04, daysAgo: 215, description: 'Bus to Tanzania border', recipientLabel: 'East Africa Shuttle' }, + { fraction: -0.06, daysAgo: 225, description: 'Kilimanjaro base camp trek', recipientLabel: 'Mountain Guides' }, + { fraction: -0.08, daysAgo: 238, description: 'Flight to Cape Town', recipientLabel: 'South African Airways' }, + { fraction: -0.035, daysAgo: 248, description: 'Table Mountain cable car', recipientLabel: 'SA Tourism' }, + + // Africa - West (The Gambia - Nimiq community location) + { fraction: -0.1, daysAgo: 265, description: 'Flight Cape Town to Banjul', recipientLabel: 'Brussels Airlines' }, + { fraction: -0.03, daysAgo: 272, description: 'River cruise in The Gambia', recipientLabel: 'Gambia River Tours' }, + { fraction: -0.025, daysAgo: 278, description: 'Local market in Banjul', recipientLabel: 'Banjul Market' }, + { fraction: -0.04, daysAgo: 285, description: 'Cultural tour in Juffureh', recipientLabel: 'Heritage Gambia' }, + + // North America - Central America (Costa Rica - Nimiq community location) + { fraction: -0.11, daysAgo: 298, description: 'Flight Banjul to San José', recipientLabel: 'TAP Air Portugal' }, + { fraction: -0.045, daysAgo: 308, description: 'Zip-lining in Monteverde', recipientLabel: 'Costa Rica Adventures' }, + { fraction: -0.035, daysAgo: 315, description: 'Eco-lodge in Manuel Antonio', recipientLabel: 'Rainforest Lodge' }, + { fraction: -0.055, daysAgo: 325, description: 'Volcano tour near San José', recipientLabel: 'Volcano Expeditions' }, + + // North America - USA & Canada + { fraction: -0.08, daysAgo: 345, description: 'Flight San José to Miami', recipientLabel: 'American Airlines' }, + { fraction: -0.04, daysAgo: 358, description: 'Road trip car rental', recipientLabel: 'Enterprise Rent-A-Car' }, + { fraction: -0.035, daysAgo: 368, description: 'Grand Canyon entrance fee', recipientLabel: 'National Park Service' }, + { fraction: -0.06, daysAgo: 385, description: 'Train to Vancouver', recipientLabel: 'Via Rail Canada' }, + { fraction: -0.04, daysAgo: 398, description: 'Whale watching in Victoria', recipientLabel: 'Pacific Marine Tours' }, + + // South America + { fraction: -0.12, daysAgo: 418, description: 'Flight Vancouver to Lima', recipientLabel: 'LATAM Airlines' }, + { fraction: -0.045, daysAgo: 432, description: 'Inca Trail permit', recipientLabel: 'Peru National Parks' }, + { fraction: -0.06, daysAgo: 445, description: 'Machu Picchu guided tour', recipientLabel: 'Ancient Paths Peru' }, + { fraction: -0.08, daysAgo: 465, description: 'Bus to La Paz Bolivia', recipientLabel: 'Cruz del Sur' }, + { fraction: -0.055, daysAgo: 485, description: 'Salt flats tour Uyuni', recipientLabel: 'Uyuni Expeditions' }, + { fraction: -0.1, daysAgo: 505, description: 'Flight La Paz to Rio', recipientLabel: 'GOL Airlines' }, + { fraction: -0.04, daysAgo: 518, description: 'Christ the Redeemer visit', recipientLabel: 'Rio Tourism' }, + { fraction: -0.035, daysAgo: 535, description: 'Copacabana beach day', recipientLabel: 'Beach Vendors' }, + + // Antarctica (Research station visit) + { fraction: -0.2, daysAgo: 558, description: 'Antarctic expedition cruise', recipientLabel: 'Polar Expeditions' }, + { fraction: -0.03, daysAgo: 578, description: 'Research station donation', recipientLabel: 'Antarctic Foundation' }, + + // Early journey setup - BTC profits and conversion + { fraction: 0.15, daysAgo: 600, description: 'Sold BTC at peak for travel fund', recipientLabel: 'Crypto Exchange' }, + { fraction: 0.18, daysAgo: 650, description: 'Bitcoin mining rewards', recipientLabel: 'Mining Pool' }, + { fraction: 0.12, daysAgo: 720, description: 'Early BTC investment profit', recipientLabel: 'Digital Assets' }, + { fraction: 0.25, daysAgo: 800, description: 'BTC sale for world trip', recipientLabel: 'Binance Exchange' }, + + // Initial crypto journey + { fraction: 0.08, daysAgo: 900, description: 'First successful BTC trade', recipientLabel: 'Trading Platform' }, + { fraction: 0.2, daysAgo: 1200, description: 'Early Bitcoin purchase gains', recipientLabel: 'Crypto Broker' }, + { fraction: 0.1, daysAgo: 1400, description: 'Freelance payment in crypto', recipientLabel: 'Tech Client' }, + ]; + + // Calculate sum of existing transactions to ensure they add up to exactly 1 + const existingSum = txDefinitions.reduce((sum, def) => sum + def.fraction, 0); + const remainingFraction = 1 - existingSum; + + // Add the final balancing transaction if needed + if (Math.abs(remainingFraction) > 0.001) { + txDefinitions.push({ + fraction: remainingFraction, + daysAgo: 1450, + description: remainingFraction > 0 + ? 'Initial Bitcoin investment' + : 'Final trip preparation expense', + recipientLabel: remainingFraction > 0 ? 'Genesis Block' : 'Travel Prep', + }); + } + + const { setContact } = useContactsStore(); + const txs: Partial[] = []; + + for (const def of txDefinitions) { + let txValue = Math.floor(nimInitialBalance * def.fraction); + // Adjust so it doesn't end in a 0 digit + while (txValue > 0 && txValue % 10 === 0) { + txValue -= 1; + } + + const hex = encodeTextToHex(def.description); + const to32Bytes = (h: string) => h.padStart(64, '0').slice(-64); + const address = KeyPair.derive(PrivateKey.fromHex(to32Bytes(hex))).toAddress().toUserFriendlyAddress(); + const recipient = def.fraction > 0 ? demoNimAddress : address; + const sender = def.fraction > 0 ? address : demoNimAddress; + const data = { type: 'raw', raw: hex } as const; + const value = Math.abs(txValue); + const timestamp = calculateDaysAgo(def.daysAgo); + const tx: Partial = { value, recipient, sender, timestamp, data }; + txs.push(tx); + // Add contact if a recipientLabel is provided + if (def.recipientLabel && def.fraction < 0) { + setContact(address, def.recipientLabel); + } + } + + return txs.sort((a, b) => a.timestamp! - b.timestamp!); +} + +let head = 0; +let nonce = 0; + +export function transformNimTransaction(txs: Partial[]): NimTransaction[] { + return txs.map((tx) => { + head++; + nonce++; + + return { + network: 'mainnet', + state: 'confirmed', + transactionHash: `0x${nonce.toString(16)}`, + sender: '', + senderType: 'basic', + recipient: '', + recipientType: 'basic', + value: 50000000, + fee: 0, + feePerByte: 0, + format: 'basic', + validityStartHeight: head, + blockHeight: head, + flags: 0, + timestamp: Date.now(), + size: 0, + valid: true, + proof: { raw: '', type: 'raw' }, + data: tx.data || { type: 'raw', raw: '' }, + ...tx, + }; + }); +} + +/** + * Inserts NIM transactions into the store. If no definitions provided, uses default demo transactions. + */ +export function insertFakeNimTransactions(txs = defineNimFakeTransactions()): void { + const { addTransactions } = useTransactionsStore(); + addTransactions(transformNimTransaction(txs)); +} + +/** + * Updates the NIM balance after a transaction + */ +export function updateNimBalance(amount: number): void { + const addressStore = useAddressStore(); + const currentAddressInfo = addressStore.addressInfos.value.find((info) => info.address === demoNimAddress); + + if (currentAddressInfo) { + const newBalance = (currentAddressInfo.balance || 0) + amount; + addressStore.patchAddress(demoNimAddress, { balance: newBalance }); + } else { + console.error('[Demo] Failed to update NIM balance: Address not found'); + } +} + +export function dangerouslyInsertFakeBuyNimTransaction(amount: number): void { + const tx: Partial = { + value: amount, + recipient: demoNimAddress, + sender: buyFromAddress, + data: { + type: 'raw', + raw: encodeTextToHex('NIM Bank purchase'), + }, + }; + + setTimeout(() => { + const { addTransactions } = useTransactionsStore(); + addTransactions(transformNimTransaction([tx])); + updateNimBalance(amount); + }, 1_500); +} + +// #endregion + +// #region BTC Transactions + +interface BtcTransactionDefinition { + fraction: number; + daysAgo: number; + description: string; + recipientLabel?: string; + incoming: boolean; + address: string; +} + +/** + * Defines transaction definitions for demo BTC transactions. + * Story: The BTC journey that funded the world travel adventure + */ +function defineBtcFakeTransactions(): BtcTransaction[] { + const txDefinitions: BtcTransactionDefinition[] = [ + // Recent BTC usage during travel + { + fraction: -0.05, + daysAgo: 50, + description: 'Emergency flight change in Bangkok', + incoming: false, + address: '1TravelEmergencyBangkok5KJH9x2mN8qP', + recipientLabel: 'Bangkok Airways', + }, + { + fraction: -0.08, + daysAgo: 150, + description: 'Luxury hotel in Sydney with BTC', + incoming: false, + address: '1SydneyLuxuryHotel7P8qR3mK5nB2', + recipientLabel: 'Sydney Grand Hotel', + }, + { + fraction: -0.1, + daysAgo: 280, + description: 'Private safari booking in Kenya', + incoming: false, + address: '1KenyaSafariLodge9M4tX6pL8cQ7', + recipientLabel: 'Mara Safari Lodge', + }, + + // Major BTC sales that funded the trip + { + fraction: -0.3, + daysAgo: 590, + description: 'BTC to NIM conversion for world trip', + incoming: false, + address: '1NimiqExchangeConvert12Kx8B9mP', + recipientLabel: 'NIM Exchange', + }, + { + fraction: -0.25, + daysAgo: 620, + description: 'Sold BTC for travel emergency fund', + incoming: false, + address: '1TravelFundExchange45mQ7pK8Nx', + recipientLabel: 'Crypto Travel Fund', + }, + + // BTC accumulation period + { + fraction: 0.2, + daysAgo: 800, + description: 'Mining pool rewards from home setup', + incoming: true, + address: '1MiningPoolRewards78pL3nK9Mx', + recipientLabel: 'Home Mining Pool', + }, + { + fraction: 0.15, + daysAgo: 1000, + description: 'DeFi yield farming profits', + incoming: true, + address: '1DeFiYieldPlatform56nB2mL8Kx', + recipientLabel: 'Compound Finance', + }, + { + fraction: 0.12, + daysAgo: 1200, + description: 'Freelance web development payment', + incoming: true, + address: '1FreelanceClientBTC89pM3nK7', + recipientLabel: 'Tech Startup Client', + }, + { + fraction: 0.18, + daysAgo: 1400, + description: 'Bitcoin bull run profits', + incoming: true, + address: '1BullRunGains2021mK8pL5nQ3', + recipientLabel: 'Trading Exchange', + }, + { + fraction: 0.25, + daysAgo: 1600, + description: 'Early Bitcoin investment from 2017', + incoming: true, + address: '1EarlyBTCInvestment34mN9pK8', + recipientLabel: 'Coinbase Pro', + }, + { + fraction: 0.08, + daysAgo: 1800, + description: 'First Bitcoin purchase', + incoming: true, + address: '1FirstBTCPurchase567pL2nM8', + recipientLabel: 'Local Bitcoin ATM', + }, + ].sort((a, b) => b.daysAgo - a.daysAgo); + + // If the sum of fractions does not add up to 1, add a balancing transaction. + const existingSum = txDefinitions.reduce((sum, def) => + sum + (def.incoming ? Math.abs(def.fraction) : -Math.abs(def.fraction)), 0); + const remainingFraction = 1 - existingSum; + if (Math.abs(remainingFraction) > 0.001) { + txDefinitions.push({ + fraction: Math.abs(remainingFraction), + daysAgo: 2000, + description: remainingFraction > 0 ? 'Genesis block mining reward' : 'Final preparation', + incoming: remainingFraction > 0, + address: remainingFraction > 0 ? '1GenesisMining2009Satoshi12' : '1FinalPrep2024Ready567', + recipientLabel: remainingFraction > 0 ? 'Genesis Mining' : 'Preparation Fund', + }); + } + + return transformBtcTransaction(txDefinitions); +} + +/** + * Transforms BTC transaction definitions into actual transactions. + */ +export function transformBtcTransaction(txDefinitions: BtcTransactionDefinition[]): BtcTransaction[] { + const transactions = []; + const knownUtxos = new Map(); + let txCounter = 1; + + for (const def of txDefinitions) { + const txHash = `btc-tx-${txCounter++}`; + // Compute the value using the initial BTC balance and the fraction. + const value = Math.floor(btcInitialBalance * Math.abs(def.fraction)); + + const tx: BtcTransaction = { + addresses: [demoBtcAddress], + isCoinbase: false, + inputs: [ + { + // For incoming tx, use the external address; for outgoing, use our demo address. + address: def.incoming ? def.address : demoBtcAddress, + outputIndex: 0, + index: 0, + script: 'script', + sequence: 4294967295, + transactionHash: def.incoming ? txHash : (getUTXOToSpend(knownUtxos)?.txHash || txHash), + witness: ['witness'], + }, + ], + outputs: def.incoming + ? [ + { + value, + address: demoBtcAddress, + script: 'script', + index: 0, + }, + ] + : [ + { + value, + address: def.address, + script: 'script', + index: 0, + }, + { + // Change output. + value: 1000000, + address: demoBtcAddress, + script: 'script', + index: 1, + }, + ], + transactionHash: txHash, + version: 1, + vsize: 200, + weight: 800, + locktime: 0, + confirmations: Math.max(1, Math.floor(10 - def.daysAgo / 200)), + replaceByFee: false, + timestamp: toSecs(calculateDaysAgo(def.daysAgo)), + state: ElectrumTransactionState.CONFIRMED, + }; + + updateUTXOs(knownUtxos, tx); + transactions.push(tx); + + // Set labels if a recipient label is provided. + if (def.recipientLabel) { + const { setSenderLabel } = useBtcLabelsStore(); + setSenderLabel(def.incoming ? def.address : demoBtcAddress, def.recipientLabel); + } + } + + // Update the address store with the current UTXOs. + const { addAddressInfos } = useBtcAddressStore(); + addAddressInfos([{ + address: demoBtcAddress, + txoCount: transactions.length + 2, // Total number of outputs received. + utxos: Array.from(knownUtxos.values()), + }]); + + return transactions; +} + +/** + * Tracks UTXO changes for BTC transactions. + */ +function updateUTXOs(knownUtxos: Map, tx: any): void { + // Remove spent inputs. + for (const input of tx.inputs) { + if (input.address === demoBtcAddress) { + const utxoKey = `${input.transactionHash}:${input.outputIndex}`; + knownUtxos.delete(utxoKey); + } + } + + // Add new outputs for our address. + for (const output of tx.outputs) { + if (output.address === demoBtcAddress) { + const utxoKey = `${tx.transactionHash}:${output.index}`; + knownUtxos.set(utxoKey, { + transactionHash: tx.transactionHash, + index: output.index, + witness: { + script: output.script, + value: output.value, + }, + }); + } + } +} + +/** + * Helper to get a UTXO to spend. + */ +function getUTXOToSpend(knownUtxos: Map): any { + if (knownUtxos.size === 0) return null; + const utxo = knownUtxos.values().next().value; + return { + txHash: utxo.transactionHash, + index: utxo.index, + value: utxo.witness.value, + }; +} + +/** + * Insert fake BTC transactions into the store. + */ +export function insertFakeBtcTransactions(txs = defineBtcFakeTransactions()): void { + const { addTransactions } = useBtcTransactionsStore(); + addTransactions(txs); +} + +/** + * Updates the BTC address balance by adding a new UTXO + */ +export function updateBtcBalance(amount: number): void { + const btcAddressStore = useBtcAddressStore(); + const addressInfo = btcAddressStore.state.addressInfos[demoBtcAddress]; + + if (!addressInfo) { + console.error('[Demo] Failed to update BTC balance: Address not found'); + return; + } + + // Create a unique transaction hash + const txHash = `btc-tx-swap-${Date.now().toString(16)}`; + + // Create a proper UTXO with the correct format + const newUtxo = { + transactionHash: txHash, + index: 0, + witness: { + script: 'script', + value: amount * 1e-6, + }, + txoValue: amount * 1e-6, + }; + + btcAddressStore.addAddressInfos([{ + address: demoBtcAddress, + utxos: [...(addressInfo.utxos || []), newUtxo], + }]); + + console.log(`[Demo] Updated BTC balance, added UTXO with value: ${amount * 1e-6}`); +} + +// #endregion + +// #region Polygon Transactions + +interface UsdcTransactionDefinition { + fraction: number; + daysAgo: number; + description: string; + recipientLabel?: string; + incoming: boolean; +} + +/** + * Defines transaction definitions for demo USDC transactions + * Story: Strategic stablecoin usage during world travel for specific situations + */ +function defineUsdcFakeTransactions(): UsdcTransaction[] { + const txDefinitions: UsdcTransactionDefinition[] = [ + // Recent stablecoin usage for stability + { + fraction: -0.12, + daysAgo: 25, + description: 'Hotel deposit in Berlin (stable value needed)', + incoming: false, + recipientLabel: 'Berlin Luxury Hotel', + }, + { + fraction: -0.08, + daysAgo: 85, + description: 'Travel insurance premium', + incoming: false, + recipientLabel: 'Global Travel Insurance', + }, + + // Asia - Used for larger expenses where stability matters + { + fraction: -0.15, + daysAgo: 120, + description: 'Long-term accommodation in Tokyo', + incoming: false, + recipientLabel: 'Tokyo Monthly Rental', + }, + { + fraction: -0.1, + daysAgo: 180, + description: 'Medical check-up in Singapore', + incoming: false, + recipientLabel: 'Singapore General Hospital', + }, + + // Africa - Stable value for major bookings + { + fraction: -0.18, + daysAgo: 250, + description: 'Adventure tour package in Cape Town', + incoming: false, + recipientLabel: 'African Adventure Co', + }, + + // Americas - Large bookings and official payments + { + fraction: -0.14, + daysAgo: 320, + description: 'National park tour package Costa Rica', + incoming: false, + recipientLabel: 'Costa Rica Nature Tours', + }, + { + fraction: -0.16, + daysAgo: 380, + description: 'Car rental deposit for US road trip', + incoming: false, + recipientLabel: 'US Car Rental Deposit', + }, + + // Initial funding and DeFi activities + { + fraction: 0.25, + daysAgo: 450, + description: 'Liquidity mining rewards', + incoming: true, + recipientLabel: 'Uniswap LP Rewards', + }, + { + fraction: 0.2, + daysAgo: 550, + description: 'Stable income from yield farming', + incoming: true, + recipientLabel: 'Aave Lending Pool', + }, + { + fraction: 0.18, + daysAgo: 650, + description: 'Converted BTC profits to stablecoin', + incoming: true, + recipientLabel: 'Crypto Conversion', + }, + { + fraction: 0.3, + daysAgo: 800, + description: 'Initial stablecoin purchase for travel', + incoming: true, + recipientLabel: 'Coinbase USDC', + }, + ]; + + const existingSum = txDefinitions.reduce((sum, def) => + sum + (def.incoming ? Math.abs(def.fraction) : -Math.abs(def.fraction)), 0); + const remainingFraction = 1 - existingSum; + + if (Math.abs(remainingFraction) > 0.001) { + txDefinitions.push({ + fraction: Math.abs(remainingFraction), + daysAgo: remainingFraction > 0 ? 900 : 950, + description: remainingFraction > 0 ? 'DeFi protocol rewards' : 'Travel preparation funds', + incoming: remainingFraction > 0, + recipientLabel: remainingFraction > 0 ? 'DeFi Platform' : 'Preparation Expense', + }); + } + + return transformUsdcTransaction(txDefinitions); +} + +/** + * Transform USDC transaction definitions into actual transactions + */ +function transformUsdcTransaction(txDefinitions: UsdcTransactionDefinition[]): UsdcTransaction[] { + return txDefinitions.map((def, index) => { + const value = Math.floor(usdcInitialBalance * Math.abs(def.fraction)); + const randomAddress = def.incoming ? getRandomPolygonAddress() : demoPolygonAddress; + const recipientAddress = def.incoming ? demoPolygonAddress : getRandomPolygonAddress(); + + if (def.recipientLabel) { + const { setContact } = useUsdcContactsStore(); + setContact(def.incoming ? randomAddress : recipientAddress, def.recipientLabel); + } + + return { + token: Config.polygon.usdc.tokenContract, + transactionHash: getRandomPolygonHash(), + logIndex: index, + sender: randomAddress, + recipient: recipientAddress, + value, + state: UsdcTransactionState.CONFIRMED, + blockHeight: 1000000 + index, + timestamp: toSecs(calculateDaysAgo(def.daysAgo)), + }; + }); +} + +/** + * Insert fake USDC transactions into the store + */ +export function insertFakeUsdcTransactions(txs = defineUsdcFakeTransactions()): void { + const { addTransactions } = useUsdcTransactionsStore(); + addTransactions(txs); +} + +interface UsdtTransactionDefinition { + fraction: number; + daysAgo: number; + description: string; + recipientLabel?: string; + incoming: boolean; +} + +/** + * Defines transaction definitions for demo USDT transactions + * Story: Additional stablecoin for trading and specific travel situations + */ +function defineUsdtFakeTransactions(): UsdtTransaction[] { + const txDefinitions: UsdtTransactionDefinition[] = [ + // Recent trading and travel activities + { + fraction: 0.18, + daysAgo: 15, + description: 'Arbitrage trading profits Europe', + incoming: true, + recipientLabel: 'European DEX', + }, + { + fraction: -0.09, + daysAgo: 45, + description: 'Emergency funds transfer to family', + incoming: false, + recipientLabel: 'Family Support', + }, + + // Asia - Trading and remittances + { + fraction: 0.15, + daysAgo: 110, + description: 'Crypto trading gains in Vietnam', + incoming: true, + recipientLabel: 'Vietnam Crypto Exchange', + }, + { + fraction: -0.12, + daysAgo: 135, + description: 'Online course payment from Japan', + incoming: false, + recipientLabel: 'Japanese Language Course', + }, + + // Oceania - Stable payments for services + { + fraction: -0.08, + daysAgo: 170, + description: 'Work visa processing fee Australia', + incoming: false, + recipientLabel: 'Australian Immigration', + }, + + // Africa - Remittances and local payments + { + fraction: -0.06, + daysAgo: 290, + description: 'Local guide payment in The Gambia', + incoming: false, + recipientLabel: 'Local Gambian Guide', + }, + { + fraction: 0.1, + daysAgo: 310, + description: 'Freelance consulting payment', + incoming: true, + recipientLabel: 'African Tech Startup', + }, + + // Americas - Cross-border payments + { + fraction: -0.14, + daysAgo: 350, + description: 'University course enrollment Costa Rica', + incoming: false, + recipientLabel: 'Universidad de Costa Rica', + }, + { + fraction: 0.12, + daysAgo: 420, + description: 'Remote work payment from US client', + incoming: true, + recipientLabel: 'US Tech Company', + }, + + // South America - Local services + { + fraction: -0.07, + daysAgo: 480, + description: 'Altitude sickness treatment Bolivia', + incoming: false, + recipientLabel: 'Bolivian Medical Center', + }, + + // Initial funding and DeFi + { + fraction: 0.2, + daysAgo: 580, + description: 'Lending protocol rewards', + incoming: true, + recipientLabel: 'Compound Protocol', + }, + { + fraction: 0.15, + daysAgo: 720, + description: 'Stable arbitrage trading profits', + incoming: true, + recipientLabel: 'Cross-DEX Arbitrage', + }, + { + fraction: 0.16, + daysAgo: 850, + description: 'Initial USDT for trading capital', + incoming: true, + recipientLabel: 'Binance USDT', + }, + ]; + + const existingSum = txDefinitions.reduce((sum, def) => + sum + (def.incoming ? Math.abs(def.fraction) : -Math.abs(def.fraction)), 0); + const remainingFraction = 1 - existingSum; + + if (Math.abs(remainingFraction) > 0.001) { + txDefinitions.push({ + fraction: Math.abs(remainingFraction), + daysAgo: remainingFraction > 0 ? 950 : 1000, + description: remainingFraction > 0 ? 'Initial trading capital' : 'Trading preparation', + incoming: remainingFraction > 0, + recipientLabel: remainingFraction > 0 ? 'Trading Platform' : 'Trading Preparation', + }); + } + + return transformUsdtTransaction(txDefinitions); +} + +/** + * Transform USDT transaction definitions into actual transactions + */ +function transformUsdtTransaction(txDefinitions: UsdtTransactionDefinition[]): UsdtTransaction[] { + return txDefinitions.map((def, index) => { + const value = Math.floor(usdtInitialBalance * Math.abs(def.fraction)); + const randomAddress = def.incoming ? getRandomPolygonAddress() : demoPolygonAddress; + const recipientAddress = def.incoming ? demoPolygonAddress : getRandomPolygonAddress(); + + if (def.recipientLabel) { + const { setContact } = useUsdtContactsStore(); + setContact(def.incoming ? randomAddress : recipientAddress, def.recipientLabel); + } + + return { + token: Config.polygon.usdt_bridged.tokenContract, + transactionHash: getRandomPolygonHash(), + logIndex: index, + sender: randomAddress, + recipient: recipientAddress, + value, + state: UsdtTransactionState.CONFIRMED, + blockHeight: 1000000 + index, + timestamp: toSecs(calculateDaysAgo(def.daysAgo)), + }; + }); +} + +/** + * Insert fake USDT transactions into the store + */ +export function insertFakeUsdtTransactions(txs = defineUsdtFakeTransactions()): void { + const { addTransactions } = useUsdtTransactionsStore(); + addTransactions(txs); +} + +// #endregion + +// #region Staking Transactions + +/** + * Creates a staking transaction for the demo environment + */ +export function createStakeTransaction(amount: number, validatorAddress: string): Partial { + return { + value: amount, + recipient: validatorAddress, + sender: demoNimAddress, + timestamp: Date.now(), + data: { type: 'add-stake', raw: '', staker: demoNimAddress }, + }; +} + +// #endregion + +// #region Swap Transactions + +/** + * Creates the outgoing transaction for a swap operation + */ +export function createSwapOutgoingTransaction( + fromAsset: string, + fromAmount: number, + toAsset: string, + swapHash: string, +): { + transaction: Partial | any, + updateBalance: () => void, +} { + const now = Date.now(); + const nowSecs = Math.floor(now / 1000); + + switch (fromAsset) { + case 'NIM': { + // Create a unique transaction hash for the NIM transaction + const nimTxHash = `nim-swap-${Math.random().toString(16).slice(2, 10)}`; + + // Create HTLC data that would be needed for a real swap + const nimHtlcAddress = `NQ${Math.random().toString(36).slice(2, 34)}`; + + // Create the NIM transaction with proper HTLC data + const transaction: Partial = { + value: fromAmount, + recipient: nimHtlcAddress, + sender: demoNimAddress, + timestamp: now, + transactionHash: nimTxHash, + data: { + type: 'htlc', + hashAlgorithm: 'sha256', + hashCount: 1, + hashRoot: swapHash, + raw: encodeTextToHex(`Swap ${fromAsset}-${toAsset}`), + recipient: nimHtlcAddress, + sender: demoNimAddress, + timeout: nowSecs + 3600, + }, + }; + + return { + transaction, + updateBalance: () => updateNimBalance(-fromAmount), + }; + } + case 'BTC': { + // Create the BTC transaction definition + const transaction = { + address: `1HTLC${Math.random().toString(36).slice(2, 30)}`, + daysAgo: 0, + description: `Swap ${fromAsset} to ${toAsset}`, + fraction: fromAmount / btcInitialBalance, + incoming: false, + recipientLabel: 'Bitcoin HTLC', + }; + + return { + transaction, + updateBalance: () => updateBtcBalance(-fromAmount), + }; + } + default: { + throw new Error(`Unsupported asset type for swap: ${fromAsset}`); + } + } +} + +/** + * Creates the incoming (settlement) transaction for a swap operation + */ +export function createSwapIncomingTransaction( + toAsset: string, + toAmount: number, + fromAsset: string, + swapHash: string, + htlcAddress?: string, +): { + transaction: Partial | any, + updateBalance: () => void, +} { + const now = Date.now(); + const nowSecs = Math.floor(now / 1000); + + switch (toAsset) { + case 'NIM': { + // Create a unique transaction hash for the NIM settlement transaction + const nimSettlementTxHash = `nim-settle-${Math.random().toString(16).slice(2, 10)}`; + + // Create the NIM settlement transaction + const transaction: Partial = { + value: toAmount, + recipient: demoNimAddress, + sender: htlcAddress || 'HTLC-ADDRESS', + timestamp: now + 1000, // Slightly after the funding tx + transactionHash: nimSettlementTxHash, + data: { + type: 'htlc', + hashAlgorithm: 'sha256', + hashCount: 1, + hashRoot: swapHash, + raw: encodeTextToHex(`Swap ${fromAsset}-${toAsset}`), + recipient: demoNimAddress, + sender: htlcAddress || 'HTLC-ADDRESS', + timeout: nowSecs + 3600, + }, + }; + + return { + transaction, + updateBalance: () => updateNimBalance(toAmount), + }; + } + case 'BTC': { + // Create the BTC settlement transaction + const transaction = { + address: demoBtcAddress, + daysAgo: 0, + description: `Swap ${fromAsset} to ${toAsset}`, + fraction: toAmount / btcInitialBalance, + incoming: true, + recipientLabel: 'BTC Settlement', + }; + + return { + transaction, + updateBalance: () => updateBtcBalance(toAmount), + }; + } + default: { + throw new Error(`Unsupported asset type for swap: ${toAsset}`); + } + } +} + +/** + * Completes a swap by creating transactions for both sides and updating balances + */ +export function completeSwapTransactions( + fromAsset: string, + fromAmount: number, + toAsset: string, + toAmount: number, +): void { + // Generate a unique hash for this swap to connect both sides + const swapHash = `swap-${Math.random().toString(36).slice(2, 10)}`; + + // Create outgoing transaction (from asset) + const outgoing = createSwapOutgoingTransaction( + fromAsset, + fromAmount, + toAsset, + swapHash, + ); + + if (fromAsset === 'NIM') { + insertFakeNimTransactions( + transformNimTransaction([ + outgoing.transaction as Partial, + ]), + ); + } else if (fromAsset === 'BTC') { + const transformedBtcTx = transformBtcTransaction([outgoing.transaction]); + const { addTransactions } = useBtcTransactionsStore(); + addTransactions(transformedBtcTx); + } + outgoing.updateBalance(); + + // Create incoming transaction (to asset) - with a slight delay for realism + setTimeout(() => { + const incoming = createSwapIncomingTransaction( + toAsset, + toAmount, + fromAsset, + swapHash, + ); + + if (toAsset === 'NIM') { + insertFakeNimTransactions( + transformNimTransaction([ + incoming.transaction as Partial, + ]), + ); + } else if (toAsset === 'BTC') { + const transformedBtcTx = transformBtcTransaction([incoming.transaction]); + const { addTransactions } = useBtcTransactionsStore(); + addTransactions(transformedBtcTx); + } + incoming.updateBalance(); + }, 1000); + + console.log('[Demo] Swap completed:', { + swapHash, + fromAsset, + toAsset, + fromAmount, + toAmount, + }); +} + +// #endregion diff --git a/src/lib/demo/DemoUtils.ts b/src/lib/demo/DemoUtils.ts new file mode 100644 index 000000000..51ff77325 --- /dev/null +++ b/src/lib/demo/DemoUtils.ts @@ -0,0 +1,37 @@ +import { Utf8Tools } from '@nimiq/utils'; + +/** + * Returns the hex encoding of a UTF-8 string. + */ +export function encodeTextToHex(text: string): string { + const utf8Array = Utf8Tools.stringToUtf8ByteArray(text); + return Array.from(utf8Array) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +// We pick a random but fixed time-of-day offset for each day. +const baseDate = new Date(); +baseDate.setHours(0, 0, 0, 0); +const baseDateMs = baseDate.getTime(); +const oneDayMs = 24 * 60 * 60 * 1000; + +/** + * Generates a past timestamp for a given number of days ago, adding a predictable random offset. + */ +export function calculateDaysAgo(days: number): number { + const x = Math.sin(days) * 10000; + const fractionalPart = x - Math.floor(x); + const randomPart = Math.floor(fractionalPart * oneDayMs); + return baseDateMs - days * oneDayMs - randomPart; +} + +/** + * Generates a random Polygon transaction hash + */ +export const getRandomPolygonHash = () => `0x${Math.random().toString(16).slice(2, 66)}`; + +/** + * Generates a random Polygon address + */ +export const getRandomPolygonAddress = () => `0x${Math.random().toString(16).slice(2, 42)}`; diff --git a/src/lib/demo/index.ts b/src/lib/demo/index.ts new file mode 100644 index 000000000..18c38b824 --- /dev/null +++ b/src/lib/demo/index.ts @@ -0,0 +1,171 @@ +/* eslint-disable max-len, consistent-return, no-console, no-async-promise-executor */ + +/** + * Demo Mode for the Nimiq Wallet + * ------------------------------ + * + * This module provides a complete demo environment for the Nimiq Wallet, allowing users to + * try out wallet features without connecting to real blockchain networks. + * + * Demo mode can only be activated through build commands: + * - yarn serve:demo (development) + * - yarn build:demo (production build) + * - yarn build:demo-production (production build with mainnet config) + * + * When active, demo mode: + * - Creates fake accounts with demo balances + * - Generates fake transaction history + * - Simulates blockchain interactions like sending, receiving, and swapping + * - Obfuscates addresses and disables certain features that wouldn't work in demo mode + * - Redirects certain actions to explainer modals + * + * This is intended for demonstration, educational, and testing purposes only. +*/ + +import VueRouter from 'vue-router'; +import { useConfig } from '@/composables/useConfig'; +import { checkIfDemoIsActive, DemoState, DemoModal, MessageEventName, demoRoutes, type DemoFlowMessage, type DemoFlowType } from './DemoConstants'; +import { insertCustomDemoStyles, setupSingleMutationObserver } from './DemoDom'; +import { setupDemoAddresses, setupDemoAccount } from './DemoAccounts'; +import { + insertFakeNimTransactions, + dangerouslyInsertFakeBuyNimTransaction, + insertFakeBtcTransactions, + insertFakeUsdcTransactions, + insertFakeUsdtTransactions, + createStakeTransaction, + createSwapOutgoingTransaction, + createSwapIncomingTransaction, + completeSwapTransactions, + transformNimTransaction, + transformBtcTransaction, + updateNimBalance, + updateBtcBalance, +} from './DemoTransactions'; +import { replaceBuyNimFlow, replaceStakingFlow } from './DemoFlows'; +import { interceptFetchRequest, listenForSwapChanges } from './DemoSwaps'; + +// Keep a reference to the router here +let demoRouter: VueRouter; + +// Demo modal imports for dynamic loading +const DemoFallbackModal = () => + import( + /* webpackChunkName: 'demo-hub-fallback-modal' */ + '@/components/modals/demos/DemoModalFallback.vue' + ); + +const DemoPurchaseModal = () => + import( + /* webpackChunkName: 'demo-modal-buy' */ + '@/components/modals/demos/DemoModalBuy.vue' + ); + +/** + * Initializes the demo environment and sets up various routes, data, and watchers. + */ +export function dangerouslyInitializeDemo(router: VueRouter): void { + // Check if demo is active according to the configuration + if (!checkIfDemoIsActive()) { + console.info('[Demo] Demo mode not enabled in configuration. Skipping initialization.'); + return; + } + + console.warn('[Demo] Initializing demo environment...'); + + demoRouter = router; + + insertCustomDemoStyles(); + setupSingleMutationObserver(demoRouter); + addDemoModalRoutes(); + interceptFetchRequest(); + + setupDemoAddresses(); + setupDemoAccount(); + + insertFakeNimTransactions(); + insertFakeBtcTransactions(); + + if (useConfig().config.polygon.enabled) { + insertFakeUsdcTransactions(); + insertFakeUsdtTransactions(); + } + + attachIframeListeners(); + replaceStakingFlow(demoRouter); + replaceBuyNimFlow(demoRouter); + + listenForSwapChanges(); +} + +/** + * Adds routes pointing to our demo modals. + */ +function addDemoModalRoutes(): void { + demoRouter.addRoute('root', { + name: DemoModal.Fallback, + path: `/${DemoModal.Fallback}`, + components: { modal: DemoFallbackModal }, + props: { modal: true }, + }); + demoRouter.addRoute('root', { + name: DemoModal.Buy, + path: `/${DemoModal.Buy}`, + components: { modal: DemoPurchaseModal }, + props: { modal: true }, + }); +} + +/** + * Listens for messages from iframes (or parent frames) about changes in the user flow. + */ +function attachIframeListeners(): void { + window.addEventListener('message', (event) => { + if (!event.data || typeof event.data !== 'object') return; + const { kind, data } = event.data as DemoFlowMessage; + if (kind === MessageEventName.FlowChange && demoRoutes[data]) { + // Dynamic import to avoid circular dependencies + import('@/stores/Account').then(({ useAccountStore }) => { + import('@nimiq/utils').then(({ CryptoCurrency }) => { + useAccountStore().setActiveCurrency(CryptoCurrency.NIM); + }); + }); + + demoRouter.push({ + path: demoRoutes[data], + }); + } + }); + + demoRouter.afterEach((to) => { + const match = Object.entries(demoRoutes).find(([, route]) => route === to.path); + if (!match) return; + window.parent.postMessage({ kind: MessageEventName.FlowChange, data: match[0] as DemoFlowType }, '*'); + }); +} + +// Export types and constants for backward compatibility +export type { DemoState, DemoFlowType, DemoFlowMessage }; +export { checkIfDemoIsActive, DemoModal, MessageEventName, demoRoutes }; + +// Export the main transaction insertion function that was exposed in the original module +export { dangerouslyInsertFakeBuyNimTransaction }; + +// Export transaction creation functions +export { + createStakeTransaction, + createSwapOutgoingTransaction, + createSwapIncomingTransaction, + completeSwapTransactions, + transformNimTransaction, + transformBtcTransaction, + insertFakeNimTransactions, + insertFakeBtcTransactions, + insertFakeUsdcTransactions, + insertFakeUsdtTransactions, + updateNimBalance, + updateBtcBalance, +} from './DemoTransactions'; + +// Export the DemoHubApi class +export { DemoHubApi } from './DemoHubApi';