diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 00039f7..17c409e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -33,6 +33,7 @@ import { useClockStore } from '@/stores/clock' import { useGamesStore } from '@/features/games/store' import { useCallsStore } from '@/stores/calls' import { useBankingStore } from '@/stores/banking' +import { useCryptoStore } from '@/stores/crypto' import { useBillingStore } from '@/stores/billing' import { useCompaniesStore } from '@/stores/companies' import { useAccountStore } from '@/stores/account' @@ -69,6 +70,7 @@ import type { } from '@/types/companies' import type { PhoneCall } from '@/types/phone' import type { EasyShareEvent } from '@/types/easyshare' +import type { CryptoMarketChangedData } from '@/types/crypto' import { nuiCall } from '@/utils/nui' import { formatTimer } from '@/utils/clock' import { parsePhonePreferences } from '@/utils/preferences' @@ -92,6 +94,7 @@ type AppMessage = { | PicstagramNotificationData | FeatherNotificationData | BankingChangedData + | CryptoMarketChangedData | BillingNotificationData | EasyShareEvent | PhoneCall @@ -274,6 +277,7 @@ const clock = useClockStore() const games = useGamesStore() const calls = useCallsStore() const banking = useBankingStore() +const crypto = useCryptoStore() const billing = useBillingStore() const companies = useCompaniesStore() const mail = useMailStore() @@ -1024,6 +1028,11 @@ function onMessage(event: MessageEvent): void { title: phone.t('Apps.banking.notifications.receivedTitle'), }) } + } else if (event.data?.type === 'crypto:changed' && event.data.data) { + const data = event.data.data as CryptoMarketChangedData + crypto.applyMarketUpdate(data.markets) + } else if (event.data?.type === 'crypto:account-changed') { + if (crypto.data?.authenticated) void crypto.load() } else if (event.data?.type === 'billing:changed') { void billing.loadOverview() } else if (event.data?.type === 'billing:new' && event.data.data) { diff --git a/frontend/src/assets/img/app-icons/crypto-header-logo.png b/frontend/src/assets/img/app-icons/crypto-header-logo.png new file mode 100644 index 0000000..734bf52 Binary files /dev/null and b/frontend/src/assets/img/app-icons/crypto-header-logo.png differ diff --git a/frontend/src/assets/img/app-icons/crypto.webp b/frontend/src/assets/img/app-icons/crypto.webp new file mode 100644 index 0000000..e63cabe Binary files /dev/null and b/frontend/src/assets/img/app-icons/crypto.webp differ diff --git a/frontend/src/components/crypto/CryptoLogo.vue b/frontend/src/components/crypto/CryptoLogo.vue new file mode 100644 index 0000000..84874c0 --- /dev/null +++ b/frontend/src/components/crypto/CryptoLogo.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index b7fe0a7..0eefc08 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -39,6 +39,13 @@ describe('app registry', () => { route: '/apps/health', }) expect(isPhoneAppId('health')).toBe(true) + expect(PHONE_APPS.find((app) => app.id === 'crypto')).toMatchObject({ + category: 'utilities', + gridOrder: 7, + labelKey: 'Apps.crypto.name', + route: '/apps/crypto', + }) + expect(isPhoneAppId('crypto')).toBe(true) expect(PHONE_APPS.find((app) => app.id === 'banking')).toMatchObject({ gridOrder: 5, labelKey: 'Apps.banking.name', diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 36e622b..6dbeeae 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -36,6 +36,7 @@ import { Building2, Newspaper, HeartPulse, + ChartCandlestick, } from 'lucide-vue-next' import { defineAsyncComponent, markRaw, shallowReactive } from 'vue' @@ -64,6 +65,7 @@ import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp' import weatherIcon from '@/assets/img/app-icons/weather.webp' import healthIcon from '@/assets/img/app-icons/health.webp' import bankingIcon from '@/assets/img/app-icons/banking.webp' +import cryptoIcon from '@/assets/img/app-icons/crypto.webp' import billingIcon from '@/assets/img/app-icons/billing.svg' import garageIcon from '@/assets/img/app-icons/garage.webp' import houseIcon from '@/assets/img/app-icons/house.svg' @@ -89,6 +91,20 @@ import type { } from '@/types/apps' export const PHONE_APPS = shallowReactive([ + { + category: 'utilities', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/CryptoApp.vue')), + ), + dockOrder: null, + gridOrder: 7, + icon: markRaw(ChartCandlestick), + iconClass: 'app-icon--crypto', + iconImage: cryptoIcon, + id: 'crypto', + labelKey: 'Apps.crypto.name', + route: '/apps/crypto', + }, { category: 'utilities', component: markRaw( diff --git a/frontend/src/stores/crypto.test.ts b/frontend/src/stores/crypto.test.ts new file mode 100644 index 0000000..ae0d7f9 --- /dev/null +++ b/frontend/src/stores/crypto.test.ts @@ -0,0 +1,279 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useCryptoStore } from '@/stores/crypto' +import type { CryptoBootstrap, CryptoQuote } from '@/types/crypto' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) + +const mockNuiCall = vi.mocked(nuiCall) +const bootstrap: CryptoBootstrap = { + activity: [], + authenticated: true, + cashBalance: '25000', + holdings: [], + markets: [], + portfolioValue: '25000', + profile: { + createdAt: Date.now() - 86_400_000, + handle: 'skyline', + hideBalances: false, + id: 'profile-1', + priceAlerts: true, + status: 'active', + totalTrades: 12, + totalVolume: '18462.80', + tradeConfirmations: true, + walletKey: 'VX-7F3A-92C1-44BE-810D', + }, +} +const quote: CryptoQuote = { + expiresAt: Date.now() + 8000, + fee: '0.97', + gross: '128.50', + id: 'quote-1', + marketId: 'aurora', + net: '129.47', + price: '128.50', + quantity: '1', + side: 'buy', +} + +describe('crypto store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('loads the server-authoritative portfolio', async () => { + mockNuiCall.mockResolvedValueOnce({ data: bootstrap, success: true }) + const crypto = useCryptoStore() + + expect(await crypto.load()).toBe(true) + expect(crypto.data).toEqual(bootstrap) + expect(mockNuiCall).toHaveBeenCalledWith('crypto:bootstrap', {}) + }) + + it('applies live server prices to holdings and portfolio profit or loss', () => { + const crypto = useCryptoStore() + crypto.data = { + ...bootstrap, + cashBalance: '100.00', + holdings: [ + { + assetId: 'aurora', + averagePrice: '100.00', + quantity: '2.000000', + value: '200.00', + }, + ], + markets: [ + { + changePercent: 0, + color: '#25d9ad', + enabled: true, + high24h: '100.00', + id: 'aurora', + issuedSupply: '1000000.000000', + logo: '◈', + low24h: '100.00', + name: 'Aurora', + price: '100.00', + sparkline: [0, 1], + symbol: 'AUR', + treasuryAvailable: '850000.000000', + }, + ], + portfolioValue: '300.00', + } + crypto.pendingQuote = quote + + crypto.applyMarketUpdate([ + { + ...crypto.data.markets[0], + changePercent: 25, + high24h: '125.00', + price: '125.00', + priceHistory: ['100.00', '125.00'], + }, + ]) + + expect(crypto.data.holdings[0].value).toBe('250.00') + expect(crypto.data.portfolioValue).toBe('350.00') + expect(crypto.data.markets[0].priceHistory).toEqual(['100.00', '125.00']) + expect(crypto.pendingQuote).toBeNull() + }) + + it('applies a browser preview tick without replacing account state', async () => { + const crypto = useCryptoStore() + crypto.data = { + ...bootstrap, + cashBalance: '100.00', + holdings: [ + { + assetId: 'aurora', + averagePrice: '100.00', + quantity: '2.000000', + value: '200.00', + }, + ], + markets: [ + { + changePercent: 0, + color: '#25d9ad', + enabled: true, + high24h: '100.00', + id: 'aurora', + issuedSupply: '1000000.000000', + logo: '◈', + low24h: '100.00', + name: 'Aurora', + price: '100.00', + sparkline: [0, 1], + symbol: 'AUR', + treasuryAvailable: '850000.000000', + }, + ], + portfolioValue: '300.00', + } + mockNuiCall.mockResolvedValueOnce({ + data: [{ ...crypto.data.markets[0], price: '101.25' }], + success: true, + }) + + expect(await crypto.previewMarketTick()).toBe(true) + expect(mockNuiCall).toHaveBeenCalledWith('crypto:market-tick', {}) + expect(crypto.data.holdings[0].value).toBe('202.50') + expect(crypto.data.portfolioValue).toBe('302.50') + expect(crypto.data.cashBalance).toBe('100.00') + }) + + it('sends only market, side and quantity when requesting a quote', async () => { + mockNuiCall.mockResolvedValueOnce({ data: quote, success: true }) + const crypto = useCryptoStore() + + await crypto.quote('aurora', 'buy', '1') + + expect(crypto.pendingQuote).toEqual(quote) + expect(mockNuiCall).toHaveBeenCalledWith('crypto:quote', { + marketId: 'aurora', + quantity: '1', + side: 'buy', + }) + }) + + it('executes a quote with an opaque id and generated idempotency key', async () => { + mockNuiCall.mockResolvedValueOnce({ data: bootstrap, success: true }) + const crypto = useCryptoStore() + crypto.pendingQuote = quote + + expect(await crypto.executeQuote()).toBe(true) + expect(mockNuiCall).toHaveBeenCalledWith( + 'crypto:execute', + expect.objectContaining({ quoteId: 'quote-1' }), + ) + expect(crypto.pendingQuote).toBeNull() + }) + + it('removes an expired quote so the trade form can request a new one', async () => { + vi.useFakeTimers() + const expiringQuote = { ...quote, expiresAt: Date.now() + 1000 } + mockNuiCall.mockResolvedValueOnce({ data: expiringQuote, success: true }) + const crypto = useCryptoStore() + + await crypto.quote('aurora', 'buy', '1') + expect(crypto.pendingQuote).toEqual(expiringQuote) + + await vi.advanceTimersByTimeAsync(1000) + expect(crypto.pendingQuote).toBeNull() + vi.useRealTimers() + }) + + it('clears a rejected execution so a fresh quote can be requested', async () => { + mockNuiCall.mockResolvedValueOnce({ + error: 'quote_expired', + success: false, + }) + const crypto = useCryptoStore() + crypto.pendingQuote = quote + + expect(await crypto.executeQuote()).toBe(false) + expect(crypto.pendingQuote).toBeNull() + expect(crypto.error).toBe('quote_expired') + }) + + it('keeps server errors and does not replace portfolio state', async () => { + mockNuiCall.mockResolvedValueOnce({ + error: 'quote_expired', + success: false, + }) + const crypto = useCryptoStore() + crypto.data = bootstrap + + await crypto.quote('aurora', 'buy', '1') + + expect(crypto.data).toEqual(bootstrap) + expect(crypto.error).toBe('quote_expired') + }) + + it('updates profile preferences through the authenticated server endpoint', async () => { + mockNuiCall.mockResolvedValueOnce({ data: bootstrap, success: true }) + const crypto = useCryptoStore() + + expect( + await crypto.updateProfile({ + handle: 'skyline', + hideBalances: true, + currentPassword: '', + newPassword: '', + priceAlerts: false, + tradeConfirmations: true, + }), + ).toBe(true) + expect(mockNuiCall).toHaveBeenCalledWith('crypto:update-profile', { + handle: 'skyline', + hideBalances: true, + currentPassword: '', + newPassword: '', + priceAlerts: false, + tradeConfirmations: true, + }) + }) + + it('resolves a public key and sends only crypto with an idempotency key', async () => { + mockNuiCall + .mockResolvedValueOnce({ + data: { + handle: 'receiver', + walletKey: 'VX-DEAD-BEEF-C0DE-2026', + }, + success: true, + }) + .mockResolvedValueOnce({ data: bootstrap, success: true }) + const crypto = useCryptoStore() + + expect(await crypto.resolveRecipient('VX-DEAD-BEEF-C0DE-2026')).toEqual({ + handle: 'receiver', + walletKey: 'VX-DEAD-BEEF-C0DE-2026', + }) + expect( + await crypto.transfer({ + marketId: 'aurora', + password: 'VaultX123!', + quantity: '0.5', + walletKey: 'VX-DEAD-BEEF-C0DE-2026', + }), + ).toBe(true) + expect(mockNuiCall).toHaveBeenLastCalledWith( + 'crypto:transfer', + expect.objectContaining({ + marketId: 'aurora', + password: 'VaultX123!', + quantity: '0.5', + walletKey: 'VX-DEAD-BEEF-C0DE-2026', + idempotencyKey: expect.stringMatching(/^transfer-/), + }), + ) + }) +}) diff --git a/frontend/src/stores/crypto.ts b/frontend/src/stores/crypto.ts new file mode 100644 index 0000000..c7648eb --- /dev/null +++ b/frontend/src/stores/crypto.ts @@ -0,0 +1,194 @@ +import { defineStore } from 'pinia' + +import type { + CryptoBootstrap, + CryptoMarket, + CryptoQuote, + CryptoRecipient, + CryptoSide, +} from '@/types/crypto' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +function requestKey(prefix: string): string { + const random = Math.random().toString(36).slice(2) + return `${prefix}-${Date.now()}-${random}` +} + +export const useCryptoStore = defineStore('crypto', { + state: () => ({ + data: null as CryptoBootstrap | null, + error: '', + isLoading: false, + pendingQuote: null as CryptoQuote | null, + }), + actions: { + applyMarketUpdate(markets: CryptoMarket[]): void { + if (!this.data || markets.length === 0) return + const changed = new Map(markets.map((market) => [market.id, market])) + const nextMarkets = this.data.markets.map( + (market) => changed.get(market.id) ?? market, + ) + const prices = new Map( + nextMarkets.map((market) => [market.id, Number(market.price)]), + ) + const holdings = this.data.holdings.map((holding) => ({ + ...holding, + value: ( + Number(holding.quantity) * (prices.get(holding.assetId) ?? 0) + ).toFixed(2), + })) + const portfolioValue = holdings + .reduce( + (total, holding) => total + Number(holding.value), + Number(this.data.cashBalance), + ) + .toFixed(2) + + this.data = { + ...this.data, + holdings, + markets: nextMarkets, + portfolioValue, + } + if (this.pendingQuote && changed.has(this.pendingQuote.marketId)) { + this.pendingQuote = null + } + }, + async previewMarketTick(): Promise { + const response = await nuiCall('crypto:market-tick', {}) + if (!response.success || !response.data) return false + this.applyMarketUpdate(response.data) + return true + }, + async call(endpoint: string, payload: Record = {}) { + this.isLoading = true + this.error = '' + const response = await nuiCall(`crypto:${endpoint}`, payload).finally( + () => { + this.isLoading = false + }, + ) + if (!response.success) this.error = response.error ?? 'request_failed' + return response + }, + async load(): Promise { + const response = await this.call('bootstrap') + if (!response.success || !response.data) return false + this.data = response.data + return true + }, + async register(handle: string, password: string): Promise { + const response = await this.call('register', { + handle, + password, + }) + if (!response.success || !response.data) return false + this.data = response.data + return true + }, + async login(password: string): Promise { + const response = await this.call('login', { password }) + if (!response.success || !response.data) return false + this.data = response.data + return true + }, + async logout(): Promise { + const response = await this.call('logout') + if (response.success) { + this.data = this.data + ? { ...this.data, authenticated: false, profile: null } + : null + this.pendingQuote = null + } + }, + async resolveRecipient(walletKey: string): Promise { + const response = await this.call('recipient', { + walletKey, + }) + return response.success ? (response.data ?? null) : null + }, + async transfer(payload: { + marketId: string + password: string + quantity: string + walletKey: string + }): Promise { + const response = await this.call('transfer', { + ...payload, + idempotencyKey: requestKey('transfer'), + }) + if (!response.success || !response.data) return false + this.data = response.data + return true + }, + async settle( + kind: 'deposit' | 'withdraw', + amount: string, + password: string, + ): Promise { + const response = await this.call(kind, { + amount, + idempotencyKey: requestKey(kind), + password, + }) + if (!response.success || !response.data) return false + this.data = response.data + return true + }, + async quote( + marketId: string, + side: CryptoSide, + quantity: string, + ): Promise> { + const response = await this.call('quote', { + marketId, + quantity, + side, + }) + this.pendingQuote = response.success ? (response.data ?? null) : null + if (this.pendingQuote) { + const quoteId = this.pendingQuote.id + const expiresAt = this.pendingQuote.expiresAt + globalThis.setTimeout( + () => { + if ( + this.pendingQuote?.id === quoteId && + this.pendingQuote.expiresAt <= Date.now() + ) { + this.pendingQuote = null + } + }, + Math.max(0, expiresAt - Date.now()), + ) + } + return response + }, + async executeQuote(): Promise { + if (!this.pendingQuote) return false + const response = await this.call('execute', { + idempotencyKey: requestKey('trade'), + quoteId: this.pendingQuote.id, + }) + this.pendingQuote = null + if (!response.success || !response.data) return false + this.data = response.data + return true + }, + async updateProfile(payload: { + handle: string + hideBalances: boolean + currentPassword: string + newPassword: string + priceAlerts: boolean + tradeConfirmations: boolean + }): Promise { + const response = await this.call( + 'update-profile', + payload, + ) + if (!response.success || !response.data) return false + this.data = response.data + return true + }, + }, +}) diff --git a/frontend/src/stores/phone-locale-contract.test.ts b/frontend/src/stores/phone-locale-contract.test.ts index de5e679..e15f3b9 100644 --- a/frontend/src/stores/phone-locale-contract.test.ts +++ b/frontend/src/stores/phone-locale-contract.test.ts @@ -224,6 +224,24 @@ describe('phone locale contract', () => { expect(missing).toEqual([]) }) + it('keeps the VaultX browser fallback complete for auth and transfers', () => { + const fallbackPaths = collectDefaultLocalePaths(phoneStoreSource) + + for (const path of [ + 'Apps.crypto.auth.network', + 'Apps.crypto.auth.confirmPassword', + 'Apps.crypto.quick.send', + 'Apps.crypto.transfer.walletKey', + 'Apps.crypto.profile.copyKey', + 'Apps.crypto.profile.shareKey', + 'Apps.crypto.activityTypes.transfer_in', + 'Apps.crypto.activityTypes.transfer_out', + 'Apps.crypto.errors.invalid_wallet_key', + ]) { + expect(fallbackPaths.has(path), path).toBe(true) + } + }) + it('keeps German structurally aligned with English', () => { const germanPaths = collectLuaLocalePaths(germanLocaleSource) diff --git a/frontend/src/stores/phone-locales.test.ts b/frontend/src/stores/phone-locales.test.ts index 5b061be..24039e1 100644 --- a/frontend/src/stores/phone-locales.test.ts +++ b/frontend/src/stores/phone-locales.test.ts @@ -70,6 +70,18 @@ describe('phone locale fallback', () => { expect(phone.t('Apps.messages.seekAudio')).toBe('Seek Audio') }) + it('keeps VaultX wallet key actions translated with a partial server locale', () => { + const phone = usePhoneStore() + phone.open({ locales: { Apps: { crypto: { name: 'VaultX' } } } }) + + expect(phone.t('Apps.crypto.profile.walletKey')).toBe('Public crypto key') + expect(phone.t('Apps.crypto.profile.walletKeyBody')).toBe( + 'Share this key to receive crypto. It cannot access your account.', + ) + expect(phone.t('Apps.crypto.profile.copyKey')).toBe('Copy') + expect(phone.t('Apps.crypto.profile.shareKey')).toBe('Share') + }) + it('uses the English Lua payload before the bundled emergency fallback', () => { const phone = usePhoneStore() phone.open({ diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 39e8630..126133a 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -416,8 +416,251 @@ const healthFallbackLocales = { }, } +const cryptoFallbackLocales = { + name: 'VaultX', + subtitle: 'Fictional asset exchange', + loading: 'Opening secure exchange…', + logout: 'Sign out', + refresh: 'Refresh portfolio', + navigation: 'VaultX navigation', + auth: { + eyebrow: 'Character-bound exchange', + loginTitle: 'Welcome back', + registerTitle: 'Create your VaultX profile', + body: 'Trade and transfer fictional digital assets through a protected, server-controlled account.', + network: 'VAULT NETWORK ONLINE', + serverSecured: 'Server secured', + personalKey: 'Personal crypto key', + characterBound: 'Character bound', + login: 'Log in', + register: 'Register', + loginPanelTitle: 'Secure account access', + loginPanelBody: 'Your character identity is detected automatically.', + registerPanelTitle: 'Set up your private vault', + registerPanelBody: 'Choose a profile identity and secure access phrase.', + handle: 'VaultX handle', + handlePlaceholder: '3–20 letters or numbers', + password: 'Password', + passwordPlaceholder: '8–72 characters', + confirmPassword: 'Confirm password', + confirmPasswordPlaceholder: 'Enter the same password again', + showPassword: 'Show password', + hidePassword: 'Hide password', + security: 'Use an in-character password. Never reuse a real password.', + ruleLength: '8+ characters', + ruleMixed: 'Upper & lowercase', + ruleNumber: 'Contains a number', + ruleSpecial: 'Special character', + strength0: 'Start entering a secure password', + strength1: 'Weak protection', + strength2: 'Basic protection', + strength3: 'Strong protection', + strength4: 'Excellent protection', + acceptTitle: 'Create a fictional wallet', + acceptBody: 'I understand that VaultX assets have no real-world value.', + footer: 'Encrypted session · Rate-limited access · Memory-hard password', + loginAction: 'Unlock VaultX', + registerAction: 'Create secure profile', + }, + tabs: { + portfolio: 'Portfolio', + markets: 'Markets', + activity: 'Activity', + profile: 'Profile', + }, + portfolio: { + total: 'Total portfolio', + performance: 'Profit / loss', + return: 'Total return', + current: 'Current position', + history: 'Performance', + forecast: 'Forecast', + chartLabel: 'Profit and loss performance with dashed forecast', + cash: 'Available cash', + invested: 'Invested assets', + allocation: 'Portfolio allocation', + inMarket: 'in markets', + assets: 'assets', + holdings: 'Your assets', + empty: 'No assets yet', + emptyBody: 'Open Markets to request a protected quote.', + avg: 'Avg.', + insightTitle: 'Portfolio insight', + insightBody: + 'Your holdings are valued against the latest synthetic server prices.', + }, + quick: { trade: 'Trade', send: 'Send', more: 'More' }, + actions: { deposit: 'Deposit', withdraw: 'Withdraw' }, + markets: { + title: 'Fictional markets', + live: 'Server priced', + noticeTitle: 'In-game exchange', + notice: + 'Prices are synthetic and have no real-world value. Every trade is checked against finite exchange liquidity.', + eyebrow: 'Market pulse', + movers: 'Top movers', + today: 'Today', + }, + marketDetail: { + back: 'Back to markets', + today: 'Today', + description: + 'The reference price is generated by the server. Executable prices use a protected short-lived quote.', + investment: 'Your investment', + totalValue: 'Current value', + statistics: 'Market statistics', + high24h: '24h high', + low24h: '24h low', + supply: 'Issued supply', + liquidity: 'Exchange liquidity', + }, + trade: { + actions: 'Trade actions', + buy: 'Buy', + buyBody: 'Add', + sell: 'Sell', + sellBody: 'Reduce', + quantity: 'Quantity', + available: 'Available:', + estimated: 'Estimated value', + marketPrice: 'Market price', + protected: 'Protected server quote', + protectedBody: + 'VaultX validates price, liquidity, balance and limits before execution.', + review: 'Order review', + price: 'Quote price', + gross: 'Gross', + fee: 'Exchange fee', + total: 'Total', + quoteExpiry: + 'This server quote expires after 8 seconds and can only be used once.', + getQuote: 'Get protected quote', + confirm: 'Confirm trade', + }, + settlement: { + depositBody: 'Move bank funds into your settled VaultX cash balance.', + withdrawBody: + "Return settled VaultX cash to your character's bank account.", + amount: 'Whole bank amount', + confirm: 'Confirm securely', + }, + transfer: { + title: 'Send crypto', + subtitle: 'VaultX transfer', + keyTitle: 'Public receiving key', + keyBody: + 'Verify the recipient before selecting an asset. Cash can never be sent here.', + walletKey: 'Recipient crypto key', + verify: 'Verify', + verifiedRecipient: 'Verified VaultX recipient', + asset: 'Choose asset', + quantity: 'Crypto amount', + available: 'Available balance', + password: 'Confirm with password', + protected: 'Atomic wallet transfer', + protectedBody: + 'VaultX rechecks recipient, ownership, quantity and balance on the server, then books both wallets together.', + confirm: 'Send crypto securely', + }, + activity: { + title: 'Financial activity', + empty: 'No activity', + emptyBody: + 'Deposits, withdrawals, trades and crypto transfers will appear here.', + volume: 'Lifetime trading volume', + completedTrades: 'completed trades', + cash: 'Cash', + filters: { all: 'All', trades: 'Trades', wallet: 'Wallet' }, + }, + profile: { + verified: 'Character ownership verified', + walletKey: 'Public crypto key', + walletKeyBody: + 'Share this key to receive crypto. It cannot access your account.', + copyKey: 'Copy', + copied: 'Copied', + shareKey: 'Share', + shareTitle: 'My VaultX crypto key', + shareText: 'Send crypto to my public VaultX key:', + trades: 'Trades', + volume: 'Volume', + memberSince: 'Member since', + preferences: 'Preferences', + priceAlerts: 'Price alerts', + priceAlertsBody: 'Notify me about notable market moves.', + confirmations: 'Trade confirmations', + confirmationsBody: 'Keep an extra confirmation before execution.', + hideBalances: 'Privacy mode', + hideBalancesBody: 'Hide balances across VaultX.', + identity: 'Profile identity', + edit: 'Edit', + account: 'Account & security', + editTitle: 'Edit profile', + editBody: + 'Update your handle or set a new password. Your current password confirms identity changes.', + currentPassword: 'Current password', + currentPasswordPlaceholder: 'Required for handle or password changes', + newPassword: 'New password', + newPasswordPlaceholder: 'Leave blank to keep your current password', + passwordSecurity: + 'Passwords use memory-hard hashing and are never shown again.', + saved: 'Profile saved securely.', + save: 'Save profile', + saveChanges: 'Save changes', + securityTitle: 'Protected profile', + securityBody: + 'Your profile stays bound to this framework character and financial session.', + }, + activityTypes: { + buy: 'Asset purchase', + sell: 'Asset sale', + deposit: 'Bank deposit', + withdrawal: 'Bank withdrawal', + transfer_in: 'Crypto received', + transfer_out: 'Crypto sent', + }, + statuses: { + completed: 'Completed', + pending: 'Pending review', + failed: 'Failed', + manual_review: 'Manual review', + }, + errors: { + invalid_handle: 'Use 3–20 letters, numbers, dots or underscores.', + handle_taken: 'That VaultX handle is already taken.', + profile_exists: 'This character already owns a VaultX profile.', + invalid_password: 'Password must be 8–72 characters.', + password_mismatch: 'The passwords do not match.', + accept_terms: 'Confirm that this is a fictional in-game wallet.', + invalid_credentials: 'The password is incorrect.', + locked: 'Too many attempts. Try again later.', + not_authenticated: 'Sign in to VaultX first.', + invalid_amount: 'Enter a positive whole amount.', + invalid_quantity: 'Enter a quantity with up to six decimals.', + invalid_wallet_key: 'Enter a complete VX receiving key.', + recipient_not_found: 'No active VaultX account uses this key.', + self_transfer: 'You cannot send crypto to your own key.', + invalid_transfer: 'Verify a recipient, asset and valid crypto amount.', + recipient_limit_exceeded: 'The recipient wallet cannot hold that amount.', + insufficient_funds: 'Your available balance is too low.', + insufficient_liquidity: 'The exchange cannot fill that amount right now.', + quote_expired: 'This quote expired. Request a new one.', + quote_unavailable: 'The quote is no longer available.', + market_unavailable: 'This market is temporarily paused.', + limit_exceeded: 'This request exceeds an exchange limit.', + duplicate_request: 'This request was already processed.', + rate_limited: 'Too many requests. Try again shortly.', + settlement_pending: 'A money transfer is already pending review.', + service_unavailable: 'VaultX is temporarily unavailable.', + request_failed: 'The secure exchange request failed.', + invalid_profile: 'Check your profile settings and handle.', + default: 'VaultX could not complete the request.', + }, +} + const defaultLocales: LocaleTree = { Apps: { + crypto: cryptoFallbackLocales, easyShare: { name: 'EasyShare', incoming: 'Incoming Share', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 51d903e..388590c 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -11,6 +11,7 @@ export type BuiltinPhoneAppId = | 'weather' | 'health' | 'banking' + | 'crypto' | 'billing' | 'garage' | 'house' diff --git a/frontend/src/types/crypto.ts b/frontend/src/types/crypto.ts new file mode 100644 index 0000000..ac231d6 --- /dev/null +++ b/frontend/src/types/crypto.ts @@ -0,0 +1,89 @@ +export type CryptoSide = 'buy' | 'sell' + +export type CryptoMarket = { + changePercent: number + color: string + enabled: boolean + high24h: string + id: string + issuedSupply: string + logo: string + low24h: string + name: string + price: string + priceHistory?: string[] + sparkline: number[] + symbol: string + treasuryAvailable: string + updatedAt?: number +} + +export type CryptoMarketChangedData = { + markets: CryptoMarket[] + updatedAt: number +} + +export type CryptoHolding = { + assetId: string + averagePrice: string + quantity: string + value: string +} + +export type CryptoActivity = { + amount: string + counterpartyKey?: string + createdAt: number + id: string + marketId?: string + quantity?: string + status: string + type: + | 'buy' + | 'sell' + | 'deposit' + | 'withdrawal' + | 'transfer_in' + | 'transfer_out' +} + +export type CryptoProfile = { + createdAt: number + handle: string + hideBalances: boolean + id: string + priceAlerts: boolean + status: 'active' | 'frozen' | 'closed' + totalTrades: number + totalVolume: string + tradeConfirmations: boolean + walletKey: string +} + +export type CryptoBootstrap = { + activity: CryptoActivity[] + authenticated: boolean + cashBalance: string + holdings: CryptoHolding[] + markets: CryptoMarket[] + portfolioValue: string + profile: CryptoProfile | null + registered?: boolean +} + +export type CryptoRecipient = { + handle: string + walletKey: string +} + +export type CryptoQuote = { + expiresAt: number + fee: string + gross: string + id: string + marketId: string + net: string + price: string + quantity: string + side: CryptoSide +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index ce56b9f..5a4c3af 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -125,6 +125,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< calendar: { enabled: true, sounds: true }, weather: { enabled: true, sounds: true }, banking: { enabled: true, sounds: true }, + crypto: { enabled: true, sounds: true }, billing: { enabled: true, sounds: true }, garage: { enabled: true, sounds: true }, skyride: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/CryptoApp.contract.test.ts b/frontend/src/views/apps/CryptoApp.contract.test.ts new file mode 100644 index 0000000..06d5aa9 --- /dev/null +++ b/frontend/src/views/apps/CryptoApp.contract.test.ts @@ -0,0 +1,323 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync(new URL('./CryptoApp.vue', import.meta.url), 'utf8') +const logoSource = readFileSync( + new URL('../../components/crypto/CryptoLogo.vue', import.meta.url), + 'utf8', +) +const server = readFileSync( + new URL('../../../../sky_phone/source/server/crypto.lua', import.meta.url), + 'utf8', +) +const passwordProvider = readFileSync( + new URL( + '../../../../sky_phone/source/server/crypto_password.js', + import.meta.url, + ), + 'utf8', +) +const config = readFileSync( + new URL('../../../../sky_phone/config/config.lua', import.meta.url), + 'utf8', +) +const testServer = readFileSync( + new URL('../../../testserver/index.cjs', import.meta.url), + 'utf8', +) + +describe('VaultX crypto app contracts', () => { + it('uses Sky UI without introducing Konsta components', () => { + expect(source).not.toContain("from 'konsta/vue'") + for (const component of [ + 'SkyAppPage', + 'SkyNavbar', + 'SkyScrollArea', + 'SkyField', + 'SkyButton', + 'SkySheet', + 'SkyPillNavigation', + 'SkySegmented', + ]) { + expect(source).toContain(`<${component}`) + } + }) + + it('shows distinct icons in every primary navigation item', () => { + expect(source).toContain(' { + expect(source).toContain('selected.priceHistory') + expect(source).toContain('const detailChart = computed') + expect(source).toContain('CHART_PERIOD_CONFIG[period.value]') + expect(source).toContain('class="detail-chart__marker"') + expect(source).toContain('v-for="tick in detailChart.yTicks"') + expect(source).toContain('v-for="value in CHART_PERIODS"') + expect(source).toContain(':aria-pressed="period === value"') + expect(source).toContain("t('marketDetail.statistics')") + expect(source).toContain('class="detail-trade-dock"') + expect(source).toContain('class="vault-view vault-detail-scroll"') + expect(source).toContain('class="trade-side-selector"') + expect(source).toContain('class="trade-entry-card"') + expect(source).toContain('class="trade-protection"') + expect(source).toContain('crypto.pendingQuote = null') + expect(source).toContain(' { + expect(source).toContain('class="profile-signout"') + expect(source).toContain("{{ t('logout') }}") + expect(source).not.toMatch(/icon-only\s+class="profile-signout"/) + expect(source).toContain('class="profile-edit-button"') + expect(source).toContain("sheet.value = 'profile'") + expect(source).toContain('v-model="profileCurrentPassword"') + expect(source).toContain('v-model="profileNewPassword"') + expect(server).toContain( + 'verify_password(profile.id, data.currentPassword)', + ) + expect(server).toContain('CryptoHashPassword(new_password)') + }) + + it('provides detailed registration and server-authoritative crypto-key transfers', () => { + const transferServer = server.slice( + server.indexOf('local function execute_transfer'), + server.indexOf('Bridge.Callbacks.Register("sky_phone:crypto:quote"'), + ) + + expect(source).toContain('class="password-strength"') + expect(source).toContain('v-model="confirmPassword"') + expect(source).toContain(' { + expect(source).toContain("import { copyText } from '@/utils/clipboard'") + expect(source).toContain( + "import { useEasyShareStore } from '@/stores/easyshare'", + ) + expect(source).toContain('class="wallet-key-card__actions"') + expect(source).toContain('@click="copyWalletKey"') + expect(source).toContain('@click="shareWalletKey"') + expect(source).toContain("appId: 'crypto'") + expect(source).toContain("kind: 'text'") + }) + + it('uses the premium dashboard hierarchy without manual refresh controls', () => { + expect(source).toContain('class="portfolio-shell"') + expect(source).toContain('class="featured-market"') + expect(source).toContain('class="activity-status"') + expect(source).toContain('class="profile-card"') + expect(source).not.toContain('RefreshCw') + expect(source).not.toContain(':aria-label="t(\'refresh\')"') + }) + + it('keeps portfolio quick actions concise and interactive', () => { + const quickActions = source.slice( + source.indexOf('
'), + source.indexOf(''), + ) + + expect(quickActions).not.toContain('') + expect(source).toContain('@media (hover: hover) and (pointer: fine)') + expect(source).toContain('.quick button:hover') + expect(source).toContain('.quick button:focus-visible') + }) + + it('anchors portfolio performance history and forecast at the current position', () => { + expect(source).toContain('const portfolioProfitLoss = computed') + expect(source).toContain('class="portfolio-history"') + expect(source).toContain('class="portfolio-forecast"') + expect(source).toContain(':cx="portfolioChart.currentX"') + expect(source).toContain(':cy="portfolioChart.currentY"') + expect(source).toContain("t('portfolio.current')") + expect(source).toContain('stroke-dasharray: 7 7') + }) + + it('uses the compact Flare-style navigation and polished overlay transitions', () => { + const navbarTag = source.match(//)?.[0] ?? '' + + expect(source).toContain('class="vault-navbar"') + expect(source).toContain('class="vault-header-logo"') + expect(source).toContain( + '