diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5d08fcf..17c409e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1031,6 +1031,8 @@ function onMessage(event: MessageEvent): void { } 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/stores/crypto.test.ts b/frontend/src/stores/crypto.test.ts index 555cbd3..5799191 100644 --- a/frontend/src/stores/crypto.test.ts +++ b/frontend/src/stores/crypto.test.ts @@ -25,6 +25,7 @@ const bootstrap: CryptoBootstrap = { totalTrades: 12, totalVolume: '18462.80', tradeConfirmations: true, + walletKey: 'VX-7F3A-92C1-44BE-810D', }, } const quote: CryptoQuote = { @@ -195,4 +196,40 @@ describe('crypto store', () => { 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 index f9e21e8..ec1e5e8 100644 --- a/frontend/src/stores/crypto.ts +++ b/frontend/src/stores/crypto.ts @@ -4,6 +4,7 @@ import type { CryptoBootstrap, CryptoMarket, CryptoQuote, + CryptoRecipient, CryptoSide, } from '@/types/crypto' import { nuiCall, type NuiResponse } from '@/utils/nui' @@ -94,6 +95,26 @@ export const useCryptoStore = defineStore('crypto', { 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, diff --git a/frontend/src/types/crypto.ts b/frontend/src/types/crypto.ts index 7dbf9e5..ac231d6 100644 --- a/frontend/src/types/crypto.ts +++ b/frontend/src/types/crypto.ts @@ -32,11 +32,19 @@ export type CryptoHolding = { export type CryptoActivity = { amount: string + counterpartyKey?: string createdAt: number id: string marketId?: string + quantity?: string status: string - type: 'buy' | 'sell' | 'deposit' | 'withdrawal' + type: + | 'buy' + | 'sell' + | 'deposit' + | 'withdrawal' + | 'transfer_in' + | 'transfer_out' } export type CryptoProfile = { @@ -49,6 +57,7 @@ export type CryptoProfile = { totalTrades: number totalVolume: string tradeConfirmations: boolean + walletKey: string } export type CryptoBootstrap = { @@ -59,6 +68,12 @@ export type CryptoBootstrap = { markets: CryptoMarket[] portfolioValue: string profile: CryptoProfile | null + registered?: boolean +} + +export type CryptoRecipient = { + handle: string + walletKey: string } export type CryptoQuote = { diff --git a/frontend/src/views/apps/CryptoApp.contract.test.ts b/frontend/src/views/apps/CryptoApp.contract.test.ts index a4fd7cd..f3b6114 100644 --- a/frontend/src/views/apps/CryptoApp.contract.test.ts +++ b/frontend/src/views/apps/CryptoApp.contract.test.ts @@ -82,6 +82,34 @@ describe('VaultX crypto app contracts', () => { 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('class="portfolio-shell"') expect(source).toContain('class="featured-market"') diff --git a/frontend/src/views/apps/CryptoApp.vue b/frontend/src/views/apps/CryptoApp.vue index 8c48105..35241a9 100644 --- a/frontend/src/views/apps/CryptoApp.vue +++ b/frontend/src/views/apps/CryptoApp.vue @@ -5,14 +5,18 @@ import { BellRing, ChartCandlestick, ChartNoAxesCombined, + CheckCircle2, ChevronRight, + Copy, Eye, EyeOff, Fingerprint, History, + KeyRound, LockKeyhole, LogOut, Settings2, + Send, ShieldCheck, Sparkles, UserRound, @@ -24,11 +28,17 @@ import cryptoHeaderLogo from '@/assets/img/app-icons/crypto-header-logo.png' import CryptoLogo from '@/components/crypto/CryptoLogo.vue' import { useCryptoStore } from '@/stores/crypto' import { usePhoneStore } from '@/stores/phone' -import type { CryptoMarket, CryptoSide } from '@/types/crypto' +import type { + CryptoActivity, + CryptoMarket, + CryptoRecipient, + CryptoSide, +} from '@/types/crypto' import { SkyAppPage, SkyButton, SkyCard, + SkyCheckbox, SkyEmptyState, SkyField, SkyLink, @@ -44,7 +54,7 @@ import { } from '@/ui' type Tab = 'portfolio' | 'markets' | 'activity' | 'profile' -type Sheet = 'trade' | 'deposit' | 'withdraw' | 'profile' | null +type Sheet = 'trade' | 'deposit' | 'withdraw' | 'profile' | 'send' | null type ChartPeriod = '1D' | '1W' | '1M' | '6M' | '1Y' const CHART_PERIODS: ChartPeriod[] = ['1D', '1W', '1M', '6M', '1Y'] @@ -81,6 +91,8 @@ const selectedMarket = ref(null) const side = ref('buy') const handle = ref('') const password = ref('') +const confirmPassword = ref('') +const acceptedTerms = ref(false) const amount = ref('') const financialPassword = ref('') const showPassword = ref(false) @@ -93,6 +105,12 @@ const confirmations = ref(true) const hideBalances = ref(false) const saved = ref(false) const period = ref('1D') +const transferWalletKey = ref('') +const transferMarketId = ref('') +const transferQuantity = ref('') +const transferPassword = ref('') +const transferRecipient = ref(null) +const walletKeyCopied = ref(false) const locale = computed(() => phone.lang || 'de') const authenticated = computed(() => crypto.data?.authenticated === true) @@ -105,9 +123,24 @@ const activities = computed(() => activityFilter.value === 'all' || (activityFilter.value === 'trades' ? ['buy', 'sell'].includes(item.type) - : ['deposit', 'withdrawal'].includes(item.type)), + : ['deposit', 'withdrawal', 'transfer_in', 'transfer_out'].includes( + item.type, + )), ), ) +const passwordChecks = computed(() => ({ + length: password.value.length >= 8, + mixed: /[a-z]/.test(password.value) && /[A-Z]/.test(password.value), + number: /\d/.test(password.value), + special: /[^A-Za-z0-9]/.test(password.value), +})) +const passwordStrength = computed( + () => Object.values(passwordChecks.value).filter(Boolean).length, +) +const transferMarket = computed(() => market(transferMarketId.value)) +const transferHolding = computed(() => + holdings.value.find((item) => item.assetId === transferMarketId.value), +) const investedValue = computed(() => Math.max( 0, @@ -414,6 +447,15 @@ function openSettlement(next: 'deposit' | 'withdraw') { financialPassword.value = '' formError.value = '' } +function openSend() { + transferWalletKey.value = '' + transferMarketId.value = holdings.value[0]?.assetId ?? '' + transferQuantity.value = '' + transferPassword.value = '' + transferRecipient.value = null + formError.value = '' + sheet.value = 'send' +} function openProfileEditor() { profileHandle.value = profile.value?.handle ?? '' profileCurrentPassword.value = '' @@ -427,16 +469,85 @@ function closeSheet() { crypto.pendingQuote = null profileCurrentPassword.value = '' profileNewPassword.value = '' + transferRecipient.value = null + transferPassword.value = '' } async function submitAuth() { formError.value = '' + if ( + authMode.value === 'register' && + (passwordStrength.value < 4 || + password.value !== confirmPassword.value || + !acceptedTerms.value) + ) { + formError.value = t( + passwordStrength.value < 4 + ? 'errors.invalid_password' + : password.value !== confirmPassword.value + ? 'errors.password_mismatch' + : 'errors.accept_terms', + ) + return + } const success = authMode.value === 'register' ? await crypto.register(handle.value.trim(), password.value) : await crypto.login(password.value) if (!success) formError.value = errorText(crypto.error) - else password.value = '' + else { + password.value = '' + confirmPassword.value = '' + } +} +async function resolveTransferRecipient() { + formError.value = '' + transferRecipient.value = null + const recipient = await crypto.resolveRecipient(transferWalletKey.value) + if (!recipient) formError.value = errorText(crypto.error) + else { + transferWalletKey.value = recipient.walletKey + transferRecipient.value = recipient + } +} +async function submitTransfer() { + formError.value = '' + if ( + !transferRecipient.value || + transferRecipient.value.walletKey !== transferWalletKey.value || + !transferMarket.value || + !/^\d+(?:\.\d{1,6})?$/.test(transferQuantity.value) + ) { + formError.value = t('errors.invalid_transfer') + return + } + const success = await crypto.transfer({ + marketId: transferMarket.value.id, + password: transferPassword.value, + quantity: transferQuantity.value, + walletKey: transferRecipient.value.walletKey, + }) + if (!success) formError.value = errorText(crypto.error) + else closeSheet() +} +async function copyWalletKey() { + if (!profile.value?.walletKey) return + try { + await navigator.clipboard.writeText(profile.value.walletKey) + walletKeyCopied.value = true + globalThis.setTimeout(() => (walletKeyCopied.value = false), 1800) + } catch { + walletKeyCopied.value = false + } +} +function activityValue(item: CryptoActivity) { + if (item.type === 'transfer_in' || item.type === 'transfer_out') { + return `${quantity(item.quantity ?? '0')} ${market(item.marketId)?.symbol ?? ''}` + } + return privateMoney(item.amount) +} +function activityIsDebit(item: CryptoActivity) { + return ['buy', 'withdrawal', 'transfer_out'].includes(item.type) } async function submitSettlement() { if (sheet.value !== 'deposit' && sheet.value !== 'withdraw') return @@ -519,6 +630,20 @@ watch(amount, () => { formError.value = '' } }) +watch(transferWalletKey, () => { + if ( + transferRecipient.value?.walletKey !== transferWalletKey.value.toUpperCase() + ) { + transferRecipient.value = null + } +}) +watch( + () => crypto.data?.registered, + (registered) => { + if (!authenticated.value) authMode.value = registered ? 'login' : 'register' + }, + { immediate: true }, +) watch(markets, (value) => { if (detail.value) { detail.value = @@ -598,7 +723,8 @@ onMounted(() => void crypto.load()) padded >
- + + {{ t('auth.network') }}

{{ t('auth.eyebrow') }}

{{ @@ -606,55 +732,115 @@ onMounted(() => void crypto.load()) }}

{{ t('auth.body') }} +
+ {{ t('auth.serverSecured') }} + {{ t('auth.personalKey') }} + {{ t('auth.characterBound') }} +
- {{ t('auth.login') }}{{ t('auth.register') }} -
- - -

{{ t('auth.security') }}

-

{{ formError }}

- {{ - t(authMode === 'login' ? 'auth.loginAction' : 'auth.registerAction') - }} - +
+ {{ t('auth.login') }}{{ t('auth.register') }} +
+ {{ authMode === 'login' ? '01' : '02' }} +
+ {{ t(`auth.${authMode}PanelTitle`) }} + {{ t(`auth.${authMode}PanelBody`) }} +
+
+
+ + + +

{{ t('auth.security') }}

+

{{ formError }}

+ {{ + t(authMode === 'login' ? 'auth.loginAction' : 'auth.registerAction') + }} +
+ +
void crypto.load()) > @@ -1185,6 +1371,8 @@ onMounted(() => void crypto.load()) + + {{ t(`activityTypes.${item.type}`) }} void crypto.load()) }}{{ ['buy', 'withdrawal'].includes(item.type) ? '−' : '+' - }}{{ privateMoney(item.amount) }}{{ activityIsDebit(item) ? '−' : '+' + }}{{ activityValue(item) }}{{ t(`statuses.${item.status}`) }} @@ -1237,6 +1424,18 @@ onMounted(() => void crypto.load()) + + + + {{ t('profile.walletKey') }} + {{ profile?.walletKey }} + {{ t('profile.walletKeyBody') }} + + +
{{ profile?.totalTrades }} void crypto.load()) /> + @@ -1399,7 +1599,9 @@ onMounted(() => void crypto.load()) ? selectedMarket.name : sheet === 'profile' ? t('profile.account') - : t('activity.cash') + : sheet === 'send' + ? t('transfer.subtitle') + : t('activity.cash') }}

{{ @@ -1407,7 +1609,9 @@ onMounted(() => void crypto.load()) ? `${t(`trade.${side}`)} ${selectedMarket?.symbol}` : sheet === 'profile' ? t('profile.editTitle') - : t(`actions.${sheet}`) + : sheet === 'send' + ? t('transfer.title') + : t(`actions.${sheet}`) }}

@@ -1526,6 +1730,97 @@ onMounted(() => void crypto.load()) t(crypto.pendingQuote ? 'trade.confirm' : 'trade.getQuote') }}