mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
ADD - merge VaultX crypto app into dev
Integrate the complete VaultX application, including account flows, server-authoritative trading, live market cycles, wallet transfers, profile management, and the refined mobile interface.
This commit is contained in:
@@ -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<AppMessage>): 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) {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { CryptoMarket } from '@/types/crypto'
|
||||
|
||||
type LogoMark = {
|
||||
d: string
|
||||
fill?: boolean
|
||||
opacity?: number
|
||||
width?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
market: Pick<CryptoMarket, 'color' | 'id' | 'name' | 'symbol'>
|
||||
}>()
|
||||
|
||||
const logoMarks: Record<string, LogoMark[]> = {
|
||||
atlas: [
|
||||
{ d: 'M10 30 20 8l10 22M14 23h12' },
|
||||
{ d: 'm17 17 3-6 3 6', fill: true, opacity: 0.5 },
|
||||
],
|
||||
aurora: [
|
||||
{ d: 'M20 5 33 20 20 35 7 20Z' },
|
||||
{ d: 'm7 20 13-5 13 5-13 5Z' },
|
||||
{ d: 'M20 5v10m0 10v10' },
|
||||
],
|
||||
celestium: [
|
||||
{ d: 'M27 9a12 12 0 1 0 4 18 11 11 0 1 1-4-18Z', fill: true },
|
||||
{ d: 'm29 10 .9 2.1L32 13l-2.1.9L29 16l-.9-2.1L26 13l2.1-.9Z', fill: true },
|
||||
],
|
||||
crown: [
|
||||
{ d: 'm8 13 7 7 5-11 5 11 7-7-3 17H11Z', fill: true },
|
||||
{ d: 'M11 30h18', width: 2.8 },
|
||||
],
|
||||
drift: [
|
||||
{
|
||||
d: 'M6 14c5-5 9 5 14 0s9 5 14 0M6 21c5-5 9 5 14 0s9 5 14 0M8 28c4-4 8 4 12 0s8 4 12 0',
|
||||
},
|
||||
],
|
||||
dust: [
|
||||
{
|
||||
d: 'M12 12a3 3 0 1 0 0 .1M25 10a2 2 0 1 0 0 .1M29 22a3.5 3.5 0 1 0 0 .1M15 27a2.5 2.5 0 1 0 0 .1M20 19a1.5 1.5 0 1 0 0 .1',
|
||||
},
|
||||
],
|
||||
ember: [
|
||||
{
|
||||
d: 'M22 5c2 8-5 9-2 15 2-4 7-5 7-11 6 6 7 12 4 18-3 7-15 8-20 0-4-6-1-12-7-8-15-2-21 5Z',
|
||||
fill: true,
|
||||
},
|
||||
{ d: 'M20 21c4 3 3 8 0 11-4-2-5-7 0-11Z', fill: true, opacity: 0.45 },
|
||||
],
|
||||
flux: [{ d: 'm23 4-13 19h9l-2 13 13-20h-9Z', fill: true }],
|
||||
helix: [
|
||||
{
|
||||
d: 'M11 7c16 7 16 19 0 26M29 7c-16 7-16 19 0 26M13 12h14M11 20h18M13 28h14',
|
||||
},
|
||||
],
|
||||
moss: [
|
||||
{ d: 'M32 8C18 8 9 14 9 25c0 6 5 8 10 5 7-4 9-12 13-22Z', fill: true },
|
||||
{ d: 'M10 31c5-8 11-13 19-19M18 22c-1-3-3-5-6-6m9 3c2 0 5 1 7 3' },
|
||||
],
|
||||
nano: [
|
||||
{ d: 'M9 27 16 13l8 14 7-14' },
|
||||
{
|
||||
d: 'M9 27a3 3 0 1 0 0 .1M16 13a3 3 0 1 0 0 .1M24 27a3 3 0 1 0 0 .1M31 13a3 3 0 1 0 0 .1',
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
nova: [
|
||||
{
|
||||
d: 'm20 4 3.2 10.8L34 12l-7.8 8 7.8 8-10.8-2.8L20 36l-3.2-10.8L6 28l7.8-8L6 12l10.8 2.8Z',
|
||||
fill: true,
|
||||
},
|
||||
{ d: 'M20 14a6 6 0 1 0 0 12 6 6 0 0 0 0-12Z', fill: true, opacity: 0.45 },
|
||||
],
|
||||
orbit: [
|
||||
{ d: 'M20 9a11 11 0 1 0 0 22 11 11 0 0 0 0-22Z' },
|
||||
{ d: 'M5 25c4 5 15 3 23-2s11-10 7-13c-3-2-10 0-16 4' },
|
||||
{ d: 'M31 10a2.5 2.5 0 1 0 0 .1', fill: true },
|
||||
],
|
||||
pebble: [
|
||||
{
|
||||
d: 'M10 25c0-5 4-9 9-9 6 0 11 4 11 9 0 6-5 9-11 9-5 0-9-3-9-9Z',
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
d: 'M13 14c1-4 4-7 8-7 3 0 6 2 7 5-5-1-10 0-15 2Z',
|
||||
fill: true,
|
||||
opacity: 0.55,
|
||||
},
|
||||
],
|
||||
pixel: [
|
||||
{
|
||||
d: 'M8 8h7v7H8Zm9 0h7v7h-7Zm9 0h6v7h-6ZM8 17h7v7H8Zm9 0h7v7h-7Zm9 0h6v7h-6ZM8 26h7v6H8Zm9 0h7v6h-7Zm9 0h6v6h-6Z',
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
prism: [
|
||||
{ d: 'M20 6 34 32H6Z' },
|
||||
{ d: 'm20 6 1 17 13 9M21 23 6 32' },
|
||||
{ d: 'm21 23 6-5', opacity: 0.55 },
|
||||
],
|
||||
pulse: [
|
||||
{ d: 'M5 21h8l3-8 5 16 4-11 3 3h7' },
|
||||
{ d: 'M20 7a13 13 0 1 0 0 26', opacity: 0.35 },
|
||||
],
|
||||
quantum: [
|
||||
{ d: 'M20 20a3 3 0 1 0 0 .1', fill: true },
|
||||
{
|
||||
d: 'M7 20c0-5 6-9 13-9s13 4 13 9-6 9-13 9S7 25 7 20Zm6-11c4-2 10 3 14 9s5 13 1 15-10-3-14-9S9 11 13 9Zm14 0c4 2 3 9-1 15s-10 11-14 9-1-9 3-15 8-11 12-9Z',
|
||||
},
|
||||
],
|
||||
shard: [
|
||||
{ d: 'M20 4 32 15l-5 19H13L7 15Z', fill: true },
|
||||
{ d: 'm20 4-2 17 9 13m-9-13L7 15m11 6 14-6', opacity: 0.55 },
|
||||
],
|
||||
spark: [
|
||||
{
|
||||
d: 'm20 4 3 11 9-7-5 10 9 2-10 4 7 8-10-5-2 7-2-12-8 7 5-10-9 3 10-6Z',
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
tide: [
|
||||
{
|
||||
d: 'M5 23c4-10 10-13 17-8 5 4 8 3 13 0-2 9-9 16-18 15-5 0-9-3-12-7Z',
|
||||
fill: true,
|
||||
},
|
||||
{ d: 'M9 23c6 3 12 2 17-4', opacity: 0.6 },
|
||||
],
|
||||
titan: [
|
||||
{ d: 'm20 5 13 7v16l-13 7-13-7V12Z' },
|
||||
{ d: 'M12 13h16M20 13v17', width: 3.2 },
|
||||
],
|
||||
vertex: [
|
||||
{ d: 'm7 9 13 26L33 9h-8l-5 13-5-13Z', fill: true },
|
||||
{ d: 'm15 9 5 13 5-13', opacity: 0.45 },
|
||||
],
|
||||
zenith: [
|
||||
{ d: 'M6 30 20 7l14 23-14-7Z', fill: true },
|
||||
{ d: 'm20 7v16L6 30', opacity: 0.5 },
|
||||
],
|
||||
}
|
||||
|
||||
const marketIds = Object.keys(logoMarks)
|
||||
const variant = computed(() =>
|
||||
Math.max(0, marketIds.indexOf(props.market.id) % 6),
|
||||
)
|
||||
const marks = computed(
|
||||
() =>
|
||||
logoMarks[props.market.id] ?? [
|
||||
{ d: 'M8 20a12 12 0 1 0 24 0 12 12 0 0 0-24 0Z' },
|
||||
{ d: 'M14 20h12' },
|
||||
],
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="crypto-logo"
|
||||
:class="`crypto-logo--${variant}`"
|
||||
:style="{ '--crypto-logo-color': market.color }"
|
||||
role="img"
|
||||
:aria-label="`${market.name} (${market.symbol})`"
|
||||
>
|
||||
<svg aria-hidden="true" viewBox="0 0 40 40">
|
||||
<path
|
||||
v-for="(mark, index) in marks"
|
||||
:key="index"
|
||||
:d="mark.d"
|
||||
:fill="mark.fill ? 'currentColor' : 'none'"
|
||||
:opacity="mark.opacity ?? 1"
|
||||
:stroke="mark.fill ? 'none' : 'currentColor'"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:stroke-width="mark.width ?? 2.2"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crypto-logo {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
overflow: hidden;
|
||||
color: color-mix(in srgb, var(--crypto-logo-color) 82%, #fff);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 28% 22%,
|
||||
rgba(255, 255, 255, 0.22),
|
||||
transparent 34%
|
||||
),
|
||||
color-mix(in srgb, var(--crypto-logo-color) 20%, #091019);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--crypto-logo-color) 42%, transparent);
|
||||
border-radius: 13px;
|
||||
box-shadow:
|
||||
0 8px 18px color-mix(in srgb, var(--crypto-logo-color) 16%, transparent),
|
||||
inset 0 1px rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
.crypto-logo::after {
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: inherit;
|
||||
}
|
||||
.crypto-logo svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 72%;
|
||||
height: 72%;
|
||||
}
|
||||
.crypto-logo--1 {
|
||||
color: #fff;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--crypto-logo-color),
|
||||
color-mix(in srgb, var(--crypto-logo-color) 54%, #07101a)
|
||||
);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.crypto-logo--2 {
|
||||
color: color-mix(in srgb, var(--crypto-logo-color) 68%, #fff);
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.08) 0 40%,
|
||||
transparent 40% 100%
|
||||
),
|
||||
#0b1119;
|
||||
border-radius: 10px 18px 10px 18px;
|
||||
}
|
||||
.crypto-logo--3 {
|
||||
color: #071018;
|
||||
background: linear-gradient(
|
||||
155deg,
|
||||
color-mix(in srgb, var(--crypto-logo-color) 68%, #fff),
|
||||
var(--crypto-logo-color)
|
||||
);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.crypto-logo--4 {
|
||||
color: #fff;
|
||||
background:
|
||||
radial-gradient(circle, var(--crypto-logo-color) 0 3px, transparent 4px),
|
||||
conic-gradient(
|
||||
from 25deg,
|
||||
color-mix(in srgb, var(--crypto-logo-color) 74%, #fff),
|
||||
#111827,
|
||||
var(--crypto-logo-color)
|
||||
);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.crypto-logo--5 {
|
||||
color: color-mix(in srgb, var(--crypto-logo-color) 75%, #fff);
|
||||
background: #080d13;
|
||||
border: 2px solid color-mix(in srgb, var(--crypto-logo-color) 72%, #fff);
|
||||
border-radius: 50% 50% 50% 12px;
|
||||
box-shadow:
|
||||
0 0 18px color-mix(in srgb, var(--crypto-logo-color) 24%, transparent),
|
||||
inset 0 0 14px color-mix(in srgb, var(--crypto-logo-color) 13%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -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',
|
||||
|
||||
@@ -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<PhoneAppDefinition[]>([
|
||||
{
|
||||
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(
|
||||
|
||||
@@ -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-/),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<boolean> {
|
||||
const response = await nuiCall<CryptoMarket[]>('crypto:market-tick', {})
|
||||
if (!response.success || !response.data) return false
|
||||
this.applyMarketUpdate(response.data)
|
||||
return true
|
||||
},
|
||||
async call<T>(endpoint: string, payload: Record<string, unknown> = {}) {
|
||||
this.isLoading = true
|
||||
this.error = ''
|
||||
const response = await nuiCall<T>(`crypto:${endpoint}`, payload).finally(
|
||||
() => {
|
||||
this.isLoading = false
|
||||
},
|
||||
)
|
||||
if (!response.success) this.error = response.error ?? 'request_failed'
|
||||
return response
|
||||
},
|
||||
async load(): Promise<boolean> {
|
||||
const response = await this.call<CryptoBootstrap>('bootstrap')
|
||||
if (!response.success || !response.data) return false
|
||||
this.data = response.data
|
||||
return true
|
||||
},
|
||||
async register(handle: string, password: string): Promise<boolean> {
|
||||
const response = await this.call<CryptoBootstrap>('register', {
|
||||
handle,
|
||||
password,
|
||||
})
|
||||
if (!response.success || !response.data) return false
|
||||
this.data = response.data
|
||||
return true
|
||||
},
|
||||
async login(password: string): Promise<boolean> {
|
||||
const response = await this.call<CryptoBootstrap>('login', { password })
|
||||
if (!response.success || !response.data) return false
|
||||
this.data = response.data
|
||||
return true
|
||||
},
|
||||
async logout(): Promise<void> {
|
||||
const response = await this.call<null>('logout')
|
||||
if (response.success) {
|
||||
this.data = this.data
|
||||
? { ...this.data, authenticated: false, profile: null }
|
||||
: null
|
||||
this.pendingQuote = null
|
||||
}
|
||||
},
|
||||
async resolveRecipient(walletKey: string): Promise<CryptoRecipient | null> {
|
||||
const response = await this.call<CryptoRecipient>('recipient', {
|
||||
walletKey,
|
||||
})
|
||||
return response.success ? (response.data ?? null) : null
|
||||
},
|
||||
async transfer(payload: {
|
||||
marketId: string
|
||||
password: string
|
||||
quantity: string
|
||||
walletKey: string
|
||||
}): Promise<boolean> {
|
||||
const response = await this.call<CryptoBootstrap>('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<boolean> {
|
||||
const response = await this.call<CryptoBootstrap>(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<NuiResponse<CryptoQuote>> {
|
||||
const response = await this.call<CryptoQuote>('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<boolean> {
|
||||
if (!this.pendingQuote) return false
|
||||
const response = await this.call<CryptoBootstrap>('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<boolean> {
|
||||
const response = await this.call<CryptoBootstrap>(
|
||||
'update-profile',
|
||||
payload,
|
||||
)
|
||||
if (!response.success || !response.data) return false
|
||||
this.data = response.data
|
||||
return true
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -11,6 +11,7 @@ export type BuiltinPhoneAppId =
|
||||
| 'weather'
|
||||
| 'health'
|
||||
| 'banking'
|
||||
| 'crypto'
|
||||
| 'billing'
|
||||
| 'garage'
|
||||
| 'house'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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('<WalletCards')
|
||||
expect(source).toContain('<ChartNoAxesCombined')
|
||||
expect(source).toContain('<History')
|
||||
expect(source).toContain('<UserRound')
|
||||
expect(source).toMatch(
|
||||
/\.sky-pill-navigation \.sky-segmented-button--active\)\s*\{\s*color:\s*#fff;/,
|
||||
)
|
||||
})
|
||||
|
||||
it('includes advanced market detail and persistent profile controls', () => {
|
||||
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('<SkyToggle')
|
||||
expect(server).toContain('sky_phone:crypto:update-profile')
|
||||
expect(server).toContain('`price_alerts`')
|
||||
})
|
||||
|
||||
it('supports secure profile editing and header sign out', () => {
|
||||
expect(source).toContain('class="profile-signout"')
|
||||
expect(source).toContain("<span>{{ t('logout') }}</span>")
|
||||
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('<SkyCheckbox')
|
||||
expect(source).toContain("sheet.value = 'send'")
|
||||
expect(source).toContain('v-model="transferWalletKey"')
|
||||
expect(source).toContain('profile?.walletKey')
|
||||
expect(server).toContain('`crypto_key` CHAR(22)')
|
||||
expect(server).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:crypto:recipient"',
|
||||
)
|
||||
expect(server).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:crypto:transfer"',
|
||||
)
|
||||
expect(server).toContain("'transfer_out'")
|
||||
expect(server).toContain("'transfer_in'")
|
||||
expect(server).toContain('Bridge.Database.Transaction(queries)')
|
||||
expect(server).toContain('verify_password(profile.id, data.password)')
|
||||
expect(transferServer).not.toContain('"CASH"')
|
||||
expect(transferServer).not.toContain('Bridge.Framework.AddMoney')
|
||||
expect(transferServer).not.toContain('Bridge.Framework.RemoveMoney')
|
||||
})
|
||||
|
||||
it('copies and shares the public account key through phone-owned flows', () => {
|
||||
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('<div class="quick">'),
|
||||
source.indexOf('<SkyCard class="allocation-card">'),
|
||||
)
|
||||
|
||||
expect(quickActions).not.toContain('<small>')
|
||||
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(/<SkyNavbar[\s\S]*?>/)?.[0] ?? ''
|
||||
|
||||
expect(source).toContain('class="vault-navbar"')
|
||||
expect(source).toContain('class="vault-header-logo"')
|
||||
expect(source).toContain(
|
||||
'<CryptoLogo class="vault-detail-logo" :market="detail" />',
|
||||
)
|
||||
expect(source).toContain('.vault-detail-title > span:last-child')
|
||||
expect(source).not.toMatch(/\.vault-detail-title > span\s*\{/)
|
||||
expect(source).not.toContain('vault-header-logo--detail')
|
||||
expect(source).not.toContain('class="coin-detail-logo"')
|
||||
expect(source).not.toContain('.vault-header-brand > i')
|
||||
expect(source).toContain(':show-back="Boolean(detail)"')
|
||||
expect(navbarTag).not.toMatch(/\slarge(?:\s|=|>)/)
|
||||
expect(source).toContain('class="vault-view"')
|
||||
expect(source).toContain('@keyframes vault-view-in')
|
||||
expect(source).toContain('class="sheet-header"')
|
||||
expect(source).not.toContain('class="sheet-close"')
|
||||
expect(source).toContain('class="sheet-market-summary"')
|
||||
expect(source).toContain('grabber-clickable')
|
||||
expect(source).toContain('@grabberclick="closeSheet"')
|
||||
expect(source).toContain('.crypto-app :deep(.sky-sheet__grabber::after)')
|
||||
})
|
||||
|
||||
it('uses a wide close-free settlement form with composed fields', () => {
|
||||
expect(source).toContain(
|
||||
"'sheet--settlement': sheet === 'deposit' || sheet === 'withdraw'",
|
||||
)
|
||||
expect(source).toContain('class="settlement-form"')
|
||||
expect(source.match(/class="sheet-field settlement-field"/g)).toHaveLength(
|
||||
2,
|
||||
)
|
||||
expect(source).toContain('class="settlement-submit"')
|
||||
expect(source).toContain('.sheet-field :deep(.sky-field__border)')
|
||||
})
|
||||
|
||||
it('gives profile editing a full-width field and save treatment', () => {
|
||||
expect(source).toContain("'sheet--profile': sheet === 'profile'")
|
||||
expect(source).toContain('class="profile-edit-fields"')
|
||||
expect(
|
||||
source.match(/class="sheet-field profile-edit-field"/g),
|
||||
).toHaveLength(3)
|
||||
expect(source).toContain('autocomplete="current-password"')
|
||||
expect(source).toContain('autocomplete="new-password"')
|
||||
expect(source).toContain('class="profile-save-button"')
|
||||
expect(source.match(/class="sheet-field(?: [^"]+)?"/g)).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('keeps app typography readable on the scaled phone surface', () => {
|
||||
expect(source).not.toMatch(/font-size:\s*[6-8]px/)
|
||||
expect(source).toContain(':content-wrap="false"')
|
||||
})
|
||||
|
||||
it('keeps the profile sign-out action free of a second navbar surface', () => {
|
||||
expect(source).toContain("'vault-navbar--profile'")
|
||||
expect(source).toContain('.crypto-app :deep(.sky-navbar__right)')
|
||||
expect(source).toContain('.crypto-app :deep(.vault-navbar--profile)')
|
||||
expect(source).toContain(
|
||||
'.crypto-app :deep(.vault-navbar--profile .sky-navbar__background)',
|
||||
)
|
||||
expect(source).toContain('background: transparent;')
|
||||
expect(source).toContain('backdrop-filter: none;')
|
||||
})
|
||||
|
||||
it('groups the activity summary and icon filters into one VaultX hub', () => {
|
||||
expect(source).toContain('class="activity-overview"')
|
||||
expect(source).toContain('class="activity-filter-panel"')
|
||||
expect(source).toContain('class="activity-filters"')
|
||||
expect(source).toContain('class="activity-status__icon"')
|
||||
expect(source.match(/class="activity-filter-content"/g)).toHaveLength(3)
|
||||
expect(source).toContain('<WalletCards :size="15" />')
|
||||
expect(source).not.toContain('inset-inline-start: 10px;')
|
||||
expect(source).toContain('transform: translateY(1px);')
|
||||
})
|
||||
|
||||
it('exposes a broad fictional market with dedicated logo marks', () => {
|
||||
const cryptoConfig = config.slice(
|
||||
config.indexOf('Config.Crypto = {'),
|
||||
config.indexOf('-- Server-only configuration'),
|
||||
)
|
||||
expect(cryptoConfig.match(/\n\s+Id = "/g)).toHaveLength(24)
|
||||
expect(cryptoConfig.match(/\n\s+Logo = "/g)).toHaveLength(24)
|
||||
expect(server).toContain('logo = config.Logo')
|
||||
expect(source).toContain('<CryptoLogo')
|
||||
expect(logoSource.match(/^ [a-z]+: \[/gm)).toHaveLength(24)
|
||||
for (const mark of [
|
||||
'ember',
|
||||
'crown',
|
||||
'orbit',
|
||||
'quantum',
|
||||
'moss',
|
||||
'flux',
|
||||
'tide',
|
||||
'shard',
|
||||
'pixel',
|
||||
]) {
|
||||
expect(logoSource).toContain(` ${mark}: [`)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps all consequential calculations and state transitions on the server', () => {
|
||||
expect(server).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:crypto:quote"',
|
||||
)
|
||||
expect(server).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:crypto:execute"',
|
||||
)
|
||||
expect(server).toContain('`consumed_operation_id`')
|
||||
expect(server).toContain('`idempotency_key`')
|
||||
expect(server).toContain("`status` = 'manual_review'")
|
||||
expect(server).toContain('Bridge.Framework.RemoveMoney')
|
||||
expect(server).toContain('Bridge.Framework.AddMoney')
|
||||
expect(server).toContain('local function with_exchange_lock')
|
||||
expect(server).toContain('local function reconcile_settlements')
|
||||
expect(server).toContain('settlement_ledger_queries')
|
||||
})
|
||||
|
||||
it('streams server-driven market movement into live portfolio values', () => {
|
||||
expect(config).toContain('PriceTickMinimumSeconds')
|
||||
expect(config).toContain('MarketsPerTickMaximum')
|
||||
expect(config).toContain('TickMovementDivisor')
|
||||
expect(config).toContain('CycleDurationMinimumTicks')
|
||||
expect(config).toContain('CycleStrengthMaximumBasisPoints')
|
||||
expect(config).toContain('GlobalCycleMinimumTicks')
|
||||
expect(config).toContain('MarketShockChanceBasisPoints')
|
||||
expect(server).toContain('local market_dynamics = {}')
|
||||
expect(server).toContain('global_market_trend')
|
||||
expect(server).toContain('local function advance_market_cycle')
|
||||
expect(server).toContain(
|
||||
'dynamics.cycle_direction = -dynamics.cycle_direction',
|
||||
)
|
||||
expect(server).toContain('local function advance_global_market_cycle')
|
||||
expect(server).toContain('Config.Crypto.MeanReversionBasisPoints')
|
||||
expect(server).toContain('TriggerClientEvent("sky_phone:crypto:changed"')
|
||||
expect(server).toContain('priceHistory = price_history')
|
||||
expect(source).toContain('selected.priceHistory')
|
||||
expect(source).toContain('4500 + Math.random() * 2500')
|
||||
expect(testServer).toContain('const cryptoMarketDynamics = new Map()')
|
||||
expect(testServer).toContain('function advanceCryptoCycle(')
|
||||
})
|
||||
|
||||
it('stores cash in price-scale minor units throughout the ledger', () => {
|
||||
expect(server).toContain(
|
||||
'local ledger_amount = amount * Config.Crypto.PriceScale',
|
||||
)
|
||||
expect(server).toContain(
|
||||
'Config.Crypto.TreasuryCash * Config.Crypto.PriceScale',
|
||||
)
|
||||
expect(server).toContain(
|
||||
'amount = decimal_string(row.amount, Config.Crypto.PriceScale)',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a memory-hard password provider with constant-time verification', () => {
|
||||
expect(passwordProvider).toContain('scryptSync')
|
||||
expect(passwordProvider).toContain('timingSafeEqual')
|
||||
expect(passwordProvider).toContain('randomBytes(16)')
|
||||
expect(server).not.toMatch(/data\.price\b/)
|
||||
expect(server).not.toMatch(/data\.fee\b/)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,475 @@ async function fetchYoutubeMetadata(videoId) {
|
||||
}
|
||||
}
|
||||
let mockBankBalance = 24787
|
||||
let cryptoAuthenticated = true
|
||||
let cryptoCashBalance = 18420
|
||||
let cryptoQuote = null
|
||||
let nextCryptoActivityId = 5
|
||||
const cryptoPassword = 'VaultX123!'
|
||||
function createCryptoMarket({
|
||||
changePercent,
|
||||
color,
|
||||
id,
|
||||
logo,
|
||||
name,
|
||||
price,
|
||||
supply,
|
||||
symbol,
|
||||
}) {
|
||||
const numericPrice = Number(price)
|
||||
const direction = changePercent >= 0 ? 1 : -1
|
||||
const rawSparkline = Array.from({ length: 12 }, (_, index) => {
|
||||
const wave = Math.sin((index + id.length) * 1.17) * 0.13
|
||||
return 0.28 + index * 0.045 * direction + wave
|
||||
})
|
||||
const minimum = Math.min(...rawSparkline)
|
||||
const maximum = Math.max(...rawSparkline)
|
||||
const span = Math.max(0.01, maximum - minimum)
|
||||
const normalizedSparkline = rawSparkline.map(
|
||||
(value) => (value - minimum) / span,
|
||||
)
|
||||
const startPrice = numericPrice / (1 + changePercent / 100)
|
||||
const priceHistory = normalizedSparkline.map((value, index) => {
|
||||
const progress = index / (normalizedSparkline.length - 1)
|
||||
const trend = startPrice + (numericPrice - startPrice) * progress
|
||||
const fluctuation =
|
||||
numericPrice * 0.018 * (value - 0.5) * Math.sin(Math.PI * progress)
|
||||
return Math.max(0.01, trend + fluctuation).toFixed(2)
|
||||
})
|
||||
priceHistory[priceHistory.length - 1] = numericPrice.toFixed(2)
|
||||
return {
|
||||
changePercent,
|
||||
color,
|
||||
enabled: true,
|
||||
high24h: (numericPrice * (1.025 + Math.abs(changePercent) / 100)).toFixed(
|
||||
2,
|
||||
),
|
||||
id,
|
||||
issuedSupply: String(supply),
|
||||
logo,
|
||||
low24h: (
|
||||
numericPrice * Math.max(0.1, 0.975 - Math.abs(changePercent) / 100)
|
||||
).toFixed(2),
|
||||
name,
|
||||
price,
|
||||
priceHistory,
|
||||
sparkline: normalizedSparkline,
|
||||
symbol,
|
||||
treasuryAvailable: String(Math.floor(supply * 0.82)),
|
||||
}
|
||||
}
|
||||
const cryptoMarkets = [
|
||||
createCryptoMarket({
|
||||
id: 'aurora',
|
||||
symbol: 'AUR',
|
||||
name: 'Aurora',
|
||||
logo: '◈',
|
||||
color: '#25d9ad',
|
||||
price: '128.50',
|
||||
changePercent: 4.82,
|
||||
supply: 1000000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'vertex',
|
||||
symbol: 'VTX',
|
||||
name: 'Vertex',
|
||||
logo: '◆',
|
||||
color: '#4d8cff',
|
||||
price: '42.75',
|
||||
changePercent: -1.37,
|
||||
supply: 2500000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'ember',
|
||||
symbol: 'EMB',
|
||||
name: 'Ember',
|
||||
logo: '✦',
|
||||
color: '#ff9d54',
|
||||
price: '9.80',
|
||||
changePercent: 7.21,
|
||||
supply: 8000000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'titan',
|
||||
symbol: 'TTN',
|
||||
name: 'Titan',
|
||||
logo: '⬢',
|
||||
color: '#8f7cff',
|
||||
price: '4250000.00',
|
||||
changePercent: 1.18,
|
||||
supply: 21000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'crown',
|
||||
symbol: 'CRN',
|
||||
name: 'Crown',
|
||||
logo: '♛',
|
||||
color: '#f6c453',
|
||||
price: '1280000.00',
|
||||
changePercent: -0.42,
|
||||
supply: 64000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'atlas',
|
||||
symbol: 'ATL',
|
||||
name: 'Atlas',
|
||||
logo: '▲',
|
||||
color: '#e66f9e',
|
||||
price: '120000.00',
|
||||
changePercent: 2.66,
|
||||
supply: 250000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'zenith',
|
||||
symbol: 'ZNT',
|
||||
name: 'Zenith',
|
||||
logo: '✥',
|
||||
color: '#6b8cff',
|
||||
price: '680000.00',
|
||||
changePercent: 0.84,
|
||||
supply: 125000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'celestium',
|
||||
symbol: 'CLS',
|
||||
name: 'Celestium',
|
||||
logo: '✧',
|
||||
color: '#8ad8ff',
|
||||
price: '84500.00',
|
||||
changePercent: -2.14,
|
||||
supply: 420000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'orbit',
|
||||
symbol: 'ORB',
|
||||
name: 'Orbit',
|
||||
logo: '◎',
|
||||
color: '#57c7ff',
|
||||
price: '12440.00',
|
||||
changePercent: 3.06,
|
||||
supply: 900000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'helix',
|
||||
symbol: 'HLX',
|
||||
name: 'Helix',
|
||||
logo: '⌁',
|
||||
color: '#c47cff',
|
||||
price: '5820.00',
|
||||
changePercent: 5.42,
|
||||
supply: 1400000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'nova',
|
||||
symbol: 'NVA',
|
||||
name: 'Nova',
|
||||
logo: '✺',
|
||||
color: '#ff6f91',
|
||||
price: '3250.00',
|
||||
changePercent: -4.72,
|
||||
supply: 2200000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'prism',
|
||||
symbol: 'PRM',
|
||||
name: 'Prism',
|
||||
logo: '◇',
|
||||
color: '#5ee1c4',
|
||||
price: '745.00',
|
||||
changePercent: 6.18,
|
||||
supply: 3800000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'quantum',
|
||||
symbol: 'QTM',
|
||||
name: 'Quantum',
|
||||
logo: '◉',
|
||||
color: '#7a9cff',
|
||||
price: '214.00',
|
||||
changePercent: -3.22,
|
||||
supply: 5200000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'drift',
|
||||
symbol: 'DRF',
|
||||
name: 'Drift',
|
||||
logo: '≋',
|
||||
color: '#4ecdc4',
|
||||
price: '76.00',
|
||||
changePercent: 2.47,
|
||||
supply: 12000000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'pulse',
|
||||
symbol: 'PLS',
|
||||
name: 'Pulse',
|
||||
logo: '◍',
|
||||
color: '#ff5f7f',
|
||||
price: '18.40',
|
||||
changePercent: 8.84,
|
||||
supply: 18000000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'moss',
|
||||
symbol: 'MOS',
|
||||
name: 'Moss',
|
||||
logo: '♧',
|
||||
color: '#64c987',
|
||||
price: '4.60',
|
||||
changePercent: -5.06,
|
||||
supply: 36000000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'flux',
|
||||
symbol: 'FLX',
|
||||
name: 'Flux',
|
||||
logo: 'ϟ',
|
||||
color: '#a777e3',
|
||||
price: '2.38',
|
||||
changePercent: 11.24,
|
||||
supply: 44000000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'tide',
|
||||
symbol: 'TDE',
|
||||
name: 'Tide',
|
||||
logo: '≈',
|
||||
color: '#46a6ff',
|
||||
price: '1.25',
|
||||
changePercent: 3.78,
|
||||
supply: 60000000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'spark',
|
||||
symbol: 'SPK',
|
||||
name: 'Spark',
|
||||
logo: '✹',
|
||||
color: '#ffb84d',
|
||||
price: '0.74',
|
||||
changePercent: 14.62,
|
||||
supply: 85000000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'pebble',
|
||||
symbol: 'PBL',
|
||||
name: 'Pebble',
|
||||
logo: '●',
|
||||
color: '#9aa8b8',
|
||||
price: '0.22',
|
||||
changePercent: -7.36,
|
||||
supply: 150000000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'nano',
|
||||
symbol: 'NNO',
|
||||
name: 'Nano',
|
||||
logo: 'η',
|
||||
color: '#5bd6ff',
|
||||
price: '0.09',
|
||||
changePercent: 18.44,
|
||||
supply: 260000000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'shard',
|
||||
symbol: 'SHD',
|
||||
name: 'Shard',
|
||||
logo: '△',
|
||||
color: '#70e0d0',
|
||||
price: '0.05',
|
||||
changePercent: -12.18,
|
||||
supply: 420000000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'pixel',
|
||||
symbol: 'PXL',
|
||||
name: 'Pixel',
|
||||
logo: '▦',
|
||||
color: '#ff78c8',
|
||||
price: '0.03',
|
||||
changePercent: 21.52,
|
||||
supply: 700000000,
|
||||
}),
|
||||
createCryptoMarket({
|
||||
id: 'dust',
|
||||
symbol: 'DST',
|
||||
name: 'Dust',
|
||||
logo: '·',
|
||||
color: '#c4b5a5',
|
||||
price: '0.01',
|
||||
changePercent: -9.74,
|
||||
supply: 1200000000,
|
||||
}),
|
||||
]
|
||||
const cryptoMarketDynamics = new Map()
|
||||
const cryptoGlobalCycle = {
|
||||
bias: 0,
|
||||
direction: 0,
|
||||
remainingTicks: 0,
|
||||
target: 0,
|
||||
}
|
||||
function advanceCryptoCycle(
|
||||
cycle,
|
||||
minimumTicks,
|
||||
maximumTicks,
|
||||
minimumStrength,
|
||||
maximumStrength,
|
||||
) {
|
||||
if (cycle.remainingTicks <= 0) {
|
||||
cycle.direction =
|
||||
cycle.direction === 0 ? (Math.random() < 0.5 ? -1 : 1) : -cycle.direction
|
||||
cycle.remainingTicks = Math.floor(
|
||||
minimumTicks + Math.random() * (maximumTicks - minimumTicks + 1),
|
||||
)
|
||||
cycle.target =
|
||||
cycle.direction *
|
||||
(minimumStrength + Math.random() * (maximumStrength - minimumStrength))
|
||||
}
|
||||
cycle.bias += (cycle.target - cycle.bias) * 0.18
|
||||
cycle.remainingTicks -= 1
|
||||
return cycle.bias
|
||||
}
|
||||
function advanceCryptoMarkets() {
|
||||
const updatedAt = Date.now()
|
||||
const globalBias = advanceCryptoCycle(
|
||||
cryptoGlobalCycle,
|
||||
24,
|
||||
54,
|
||||
0.00008,
|
||||
0.00024,
|
||||
)
|
||||
for (const market of cryptoMarkets) {
|
||||
const currentPrice = Number(market.price)
|
||||
const fractionDigits = currentPrice < 1 ? 4 : 2
|
||||
const minimumStep = 10 ** -fractionDigits
|
||||
const volatility =
|
||||
currentPrice >= 100_000
|
||||
? 0.00018
|
||||
: currentPrice >= 100
|
||||
? 0.00055
|
||||
: currentPrice >= 1
|
||||
? 0.0012
|
||||
: 0.004
|
||||
const dynamics = cryptoMarketDynamics.get(market.id) ?? {
|
||||
bias: 0,
|
||||
direction: 0,
|
||||
momentum: 0,
|
||||
remainingTicks: 0,
|
||||
target: 0,
|
||||
}
|
||||
const cycleBias = advanceCryptoCycle(
|
||||
dynamics,
|
||||
14,
|
||||
30,
|
||||
volatility * 0.28,
|
||||
volatility * 0.52,
|
||||
)
|
||||
const impulse = (Math.random() * 2 - 1) * volatility * 0.2
|
||||
dynamics.momentum = dynamics.momentum * 0.8 + impulse * 0.2
|
||||
cryptoMarketDynamics.set(market.id, dynamics)
|
||||
const movement = Math.max(
|
||||
-volatility * 0.9,
|
||||
Math.min(
|
||||
volatility * 0.9,
|
||||
cycleBias + globalBias + dynamics.momentum + impulse,
|
||||
),
|
||||
)
|
||||
const direction = movement < 0 ? -1 : 1
|
||||
let nextPrice = Number(
|
||||
Math.max(minimumStep, currentPrice * (1 + movement)).toFixed(
|
||||
fractionDigits,
|
||||
),
|
||||
)
|
||||
if (nextPrice === currentPrice) {
|
||||
nextPrice = Math.max(minimumStep, currentPrice + direction * minimumStep)
|
||||
}
|
||||
|
||||
const formattedPrice = nextPrice.toFixed(fractionDigits)
|
||||
const priceHistory = [...(market.priceHistory ?? []), formattedPrice].slice(
|
||||
-48,
|
||||
)
|
||||
const numericHistory = priceHistory.map(Number)
|
||||
const minimum = Math.min(...numericHistory)
|
||||
const maximum = Math.max(...numericHistory)
|
||||
const span = Math.max(minimumStep, maximum - minimum)
|
||||
|
||||
market.changePercent =
|
||||
((nextPrice - numericHistory[0]) / numericHistory[0]) * 100
|
||||
market.high24h = Math.max(Number(market.high24h), nextPrice).toFixed(
|
||||
fractionDigits,
|
||||
)
|
||||
market.low24h = Math.min(Number(market.low24h), nextPrice).toFixed(
|
||||
fractionDigits,
|
||||
)
|
||||
market.price = formattedPrice
|
||||
market.priceHistory = priceHistory
|
||||
market.sparkline = numericHistory.map((price) => (price - minimum) / span)
|
||||
market.updatedAt = updatedAt
|
||||
}
|
||||
return cryptoMarkets
|
||||
}
|
||||
let cryptoProfile = {
|
||||
createdAt: Date.now() - 42 * 86400000,
|
||||
handle: 'skyline',
|
||||
hideBalances: false,
|
||||
id: 'crypto-profile-demo',
|
||||
priceAlerts: true,
|
||||
status: 'active',
|
||||
totalTrades: 12,
|
||||
totalVolume: '18462.80',
|
||||
tradeConfirmations: true,
|
||||
walletKey: 'VX-7F3A-92C1-44BE-810D',
|
||||
}
|
||||
const cryptoRecipient = {
|
||||
handle: 'receiver',
|
||||
walletKey: 'VX-DEAD-BEEF-C0DE-2026',
|
||||
}
|
||||
let cryptoHoldings = [
|
||||
{
|
||||
assetId: 'aurora',
|
||||
averagePrice: '112.30',
|
||||
quantity: '32.500000',
|
||||
value: '4176.25',
|
||||
},
|
||||
{
|
||||
assetId: 'vertex',
|
||||
averagePrice: '46.10',
|
||||
quantity: '85.250000',
|
||||
value: '3644.44',
|
||||
},
|
||||
]
|
||||
let cryptoActivity = [
|
||||
{
|
||||
id: 'crypto-4',
|
||||
type: 'buy',
|
||||
amount: '3650',
|
||||
marketId: 'vertex',
|
||||
status: 'completed',
|
||||
createdAt: Date.now() - 2 * 3600000,
|
||||
},
|
||||
{
|
||||
id: 'crypto-3',
|
||||
type: 'sell',
|
||||
amount: '920',
|
||||
marketId: 'ember',
|
||||
status: 'completed',
|
||||
createdAt: Date.now() - 26 * 3600000,
|
||||
},
|
||||
{
|
||||
id: 'crypto-2',
|
||||
type: 'buy',
|
||||
amount: '4020',
|
||||
marketId: 'aurora',
|
||||
status: 'completed',
|
||||
createdAt: Date.now() - 3 * 86400000,
|
||||
},
|
||||
{
|
||||
id: 'crypto-1',
|
||||
type: 'deposit',
|
||||
amount: '25000',
|
||||
status: 'completed',
|
||||
createdAt: Date.now() - 4 * 86400000,
|
||||
},
|
||||
]
|
||||
let mockCashBalance = 2350
|
||||
let nextBankTransactionId = 7
|
||||
let mockMapMarkers = [
|
||||
@@ -6447,6 +6916,33 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
playerName: 'Alex Morgan',
|
||||
transactions: mockBankTransactions,
|
||||
})
|
||||
const cryptoOverview = () => {
|
||||
const currentHoldings = cryptoHoldings.map((holding) => {
|
||||
const market = cryptoMarkets.find((item) => item.id === holding.assetId)
|
||||
return {
|
||||
...holding,
|
||||
value: (Number(holding.quantity) * Number(market?.price ?? 0)).toFixed(
|
||||
2,
|
||||
),
|
||||
}
|
||||
})
|
||||
return {
|
||||
activity: cryptoActivity,
|
||||
authenticated: cryptoAuthenticated,
|
||||
cashBalance: String(cryptoCashBalance),
|
||||
holdings: currentHoldings,
|
||||
markets: cryptoMarkets,
|
||||
portfolioValue: String(
|
||||
cryptoCashBalance +
|
||||
currentHoldings.reduce(
|
||||
(total, holding) => total + Number(holding.value),
|
||||
0,
|
||||
),
|
||||
),
|
||||
profile: cryptoAuthenticated ? cryptoProfile : null,
|
||||
registered: true,
|
||||
}
|
||||
}
|
||||
const billingInvoice = (invoice) => ({
|
||||
...invoice,
|
||||
canDispute: invoice.direction === 'inbox' && invoice.status === 'open',
|
||||
@@ -7918,6 +8414,273 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
response.json({ success: true, data: bankingOverview() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:bootstrap') {
|
||||
response.json({ success: true, data: cryptoOverview() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:market-tick') {
|
||||
response.json({ success: true, data: advanceCryptoMarkets() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:login') {
|
||||
if (request.body.password !== cryptoPassword) {
|
||||
response.json({ success: false, error: 'invalid_credentials' })
|
||||
return
|
||||
}
|
||||
cryptoAuthenticated = true
|
||||
response.json({ success: true, data: cryptoOverview() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:register') {
|
||||
const handle = String(request.body.handle ?? '').trim()
|
||||
const password = String(request.body.password ?? '')
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._]{1,18}[A-Za-z0-9]$/.test(handle)) {
|
||||
response.json({ success: false, error: 'invalid_handle' })
|
||||
return
|
||||
}
|
||||
if (password.length < 8 || password.length > 72) {
|
||||
response.json({ success: false, error: 'invalid_password' })
|
||||
return
|
||||
}
|
||||
cryptoAuthenticated = true
|
||||
cryptoProfile = {
|
||||
createdAt: Date.now(),
|
||||
handle,
|
||||
hideBalances: false,
|
||||
id: 'crypto-profile-new',
|
||||
priceAlerts: true,
|
||||
status: 'active',
|
||||
totalTrades: 0,
|
||||
totalVolume: '0',
|
||||
tradeConfirmations: true,
|
||||
walletKey: 'VX-31AF-4D92-882E-C104',
|
||||
}
|
||||
response.json({ success: true, data: cryptoOverview() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:logout') {
|
||||
cryptoAuthenticated = false
|
||||
cryptoQuote = null
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:update-profile') {
|
||||
const handle = String(request.body.handle ?? '').trim()
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._]{1,18}[A-Za-z0-9]$/.test(handle)) {
|
||||
response.json({ success: false, error: 'invalid_profile' })
|
||||
return
|
||||
}
|
||||
if (
|
||||
handle.toLowerCase() !== cryptoProfile.handle.toLowerCase() &&
|
||||
request.body.password !== cryptoPassword
|
||||
) {
|
||||
response.json({ success: false, error: 'invalid_credentials' })
|
||||
return
|
||||
}
|
||||
if (
|
||||
typeof request.body.priceAlerts !== 'boolean' ||
|
||||
typeof request.body.tradeConfirmations !== 'boolean' ||
|
||||
typeof request.body.hideBalances !== 'boolean'
|
||||
) {
|
||||
response.json({ success: false, error: 'invalid_profile' })
|
||||
return
|
||||
}
|
||||
cryptoProfile = {
|
||||
...cryptoProfile,
|
||||
handle,
|
||||
hideBalances: request.body.hideBalances,
|
||||
priceAlerts: request.body.priceAlerts,
|
||||
tradeConfirmations: request.body.tradeConfirmations,
|
||||
}
|
||||
response.json({ success: true, data: cryptoOverview() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:recipient') {
|
||||
const walletKey = String(request.body.walletKey ?? '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
if (!/^VX-(?:[A-F0-9]{4}-){3}[A-F0-9]{4}$/.test(walletKey)) {
|
||||
response.json({ success: false, error: 'invalid_wallet_key' })
|
||||
return
|
||||
}
|
||||
if (walletKey === cryptoProfile.walletKey) {
|
||||
response.json({ success: false, error: 'self_transfer' })
|
||||
return
|
||||
}
|
||||
if (walletKey !== cryptoRecipient.walletKey) {
|
||||
response.json({ success: false, error: 'recipient_not_found' })
|
||||
return
|
||||
}
|
||||
response.json({ success: true, data: cryptoRecipient })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:transfer') {
|
||||
const walletKey = String(request.body.walletKey ?? '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
const market = cryptoMarkets.find(
|
||||
(item) => item.id === request.body.marketId,
|
||||
)
|
||||
const holding = cryptoHoldings.find(
|
||||
(item) => item.assetId === request.body.marketId,
|
||||
)
|
||||
const quantity = Number(request.body.quantity)
|
||||
if (request.body.password !== cryptoPassword) {
|
||||
response.json({ success: false, error: 'invalid_credentials' })
|
||||
return
|
||||
}
|
||||
if (
|
||||
walletKey !== cryptoRecipient.walletKey ||
|
||||
!market ||
|
||||
!holding ||
|
||||
!Number.isFinite(quantity) ||
|
||||
quantity <= 0
|
||||
) {
|
||||
response.json({ success: false, error: 'invalid_transfer' })
|
||||
return
|
||||
}
|
||||
if (Number(holding.quantity) < quantity) {
|
||||
response.json({ success: false, error: 'insufficient_funds' })
|
||||
return
|
||||
}
|
||||
holding.quantity = (Number(holding.quantity) - quantity).toFixed(6)
|
||||
holding.value = (Number(holding.quantity) * Number(market.price)).toFixed(2)
|
||||
cryptoActivity.unshift({
|
||||
amount: '0',
|
||||
counterpartyKey: walletKey,
|
||||
createdAt: Date.now(),
|
||||
id: `crypto-${nextCryptoActivityId++}`,
|
||||
marketId: market.id,
|
||||
quantity: quantity.toFixed(6),
|
||||
status: 'completed',
|
||||
type: 'transfer_out',
|
||||
})
|
||||
response.json({ success: true, data: cryptoOverview() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:deposit' || endpoint === 'crypto:withdraw') {
|
||||
const amount = Number(request.body.amount)
|
||||
if (!Number.isSafeInteger(amount) || amount < 10) {
|
||||
response.json({ success: false, error: 'invalid_amount' })
|
||||
return
|
||||
}
|
||||
if (request.body.password !== cryptoPassword) {
|
||||
response.json({ success: false, error: 'invalid_credentials' })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:deposit') {
|
||||
if (mockBankBalance < amount) {
|
||||
response.json({ success: false, error: 'insufficient_funds' })
|
||||
return
|
||||
}
|
||||
mockBankBalance -= amount
|
||||
cryptoCashBalance += amount
|
||||
} else {
|
||||
if (cryptoCashBalance < amount) {
|
||||
response.json({ success: false, error: 'insufficient_funds' })
|
||||
return
|
||||
}
|
||||
cryptoCashBalance -= amount
|
||||
mockBankBalance += amount
|
||||
}
|
||||
cryptoActivity.unshift({
|
||||
amount: String(amount),
|
||||
createdAt: Date.now(),
|
||||
id: `crypto-${nextCryptoActivityId++}`,
|
||||
status: 'completed',
|
||||
type: endpoint === 'crypto:deposit' ? 'deposit' : 'withdrawal',
|
||||
})
|
||||
response.json({ success: true, data: cryptoOverview() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:quote') {
|
||||
const market = cryptoMarkets.find(
|
||||
(item) => item.id === request.body.marketId,
|
||||
)
|
||||
const quantity = Number(request.body.quantity)
|
||||
if (!market || !Number.isFinite(quantity) || quantity <= 0) {
|
||||
response.json({ success: false, error: 'invalid_quantity' })
|
||||
return
|
||||
}
|
||||
const side = request.body.side === 'sell' ? 'sell' : 'buy'
|
||||
const gross = quantity * Number(market.price)
|
||||
const fee = Math.max(0.01, Math.ceil(gross * 0.0075 * 100) / 100)
|
||||
const net = side === 'buy' ? gross + fee : gross - fee
|
||||
cryptoQuote = {
|
||||
expiresAt: Date.now() + 8000,
|
||||
fee: fee.toFixed(2),
|
||||
gross: gross.toFixed(2),
|
||||
id: `crypto-quote-${Date.now()}`,
|
||||
marketId: market.id,
|
||||
net: net.toFixed(2),
|
||||
price: market.price,
|
||||
quantity: quantity.toFixed(6),
|
||||
side,
|
||||
}
|
||||
response.json({ success: true, data: cryptoQuote })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:execute') {
|
||||
if (
|
||||
!cryptoQuote ||
|
||||
cryptoQuote.id !== request.body.quoteId ||
|
||||
cryptoQuote.expiresAt < Date.now()
|
||||
) {
|
||||
response.json({ success: false, error: 'quote_expired' })
|
||||
return
|
||||
}
|
||||
const market = cryptoMarkets.find(
|
||||
(item) => item.id === cryptoQuote.marketId,
|
||||
)
|
||||
const holding = cryptoHoldings.find(
|
||||
(item) => item.assetId === cryptoQuote.marketId,
|
||||
)
|
||||
const quantity = Number(cryptoQuote.quantity)
|
||||
const net = Number(cryptoQuote.net)
|
||||
if (cryptoQuote.side === 'buy') {
|
||||
if (cryptoCashBalance < net) {
|
||||
response.json({ success: false, error: 'insufficient_funds' })
|
||||
return
|
||||
}
|
||||
cryptoCashBalance -= net
|
||||
if (holding)
|
||||
holding.quantity = (Number(holding.quantity) + quantity).toFixed(6)
|
||||
else
|
||||
cryptoHoldings.push({
|
||||
assetId: market.id,
|
||||
averagePrice: market.price,
|
||||
quantity: quantity.toFixed(6),
|
||||
value: '0',
|
||||
})
|
||||
} else {
|
||||
if (!holding || Number(holding.quantity) < quantity) {
|
||||
response.json({ success: false, error: 'insufficient_funds' })
|
||||
return
|
||||
}
|
||||
holding.quantity = (Number(holding.quantity) - quantity).toFixed(6)
|
||||
cryptoCashBalance += net
|
||||
cryptoHoldings = cryptoHoldings.filter(
|
||||
(item) => Number(item.quantity) > 0,
|
||||
)
|
||||
}
|
||||
for (const item of cryptoHoldings) {
|
||||
const itemMarket = cryptoMarkets.find(
|
||||
(marketItem) => marketItem.id === item.assetId,
|
||||
)
|
||||
item.value = (Number(item.quantity) * Number(itemMarket.price)).toFixed(2)
|
||||
}
|
||||
cryptoActivity.unshift({
|
||||
amount: String(Math.round(net)),
|
||||
createdAt: Date.now(),
|
||||
id: `crypto-${nextCryptoActivityId++}`,
|
||||
marketId: cryptoQuote.marketId,
|
||||
status: 'completed',
|
||||
type: cryptoQuote.side,
|
||||
})
|
||||
cryptoQuote = null
|
||||
response.json({ success: true, data: cryptoOverview() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'health:overview') {
|
||||
response.json({ success: true, data: healthOverview() })
|
||||
return
|
||||
@@ -8610,10 +9373,12 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
return
|
||||
}
|
||||
if (endpoint === 'development:bootstrap') {
|
||||
crewLinkAuthenticated = ![
|
||||
'crewlink-login',
|
||||
'crewlink-register',
|
||||
].includes(testScenario)
|
||||
cryptoAuthenticated = !['crypto-login', 'crypto-register'].includes(
|
||||
testScenario,
|
||||
)
|
||||
crewLinkAuthenticated = !['crewlink-login', 'crewlink-register'].includes(
|
||||
testScenario,
|
||||
)
|
||||
authenticated = testScenario !== 'setup-account-unlinked'
|
||||
linkedAccount = authenticated
|
||||
? {
|
||||
|
||||
@@ -7,6 +7,10 @@ const browserDataRequests = [
|
||||
['development:bootstrap', {}],
|
||||
['account:devices', {}],
|
||||
['banking:overview', {}],
|
||||
['crypto:bootstrap', {}],
|
||||
['crypto:market-tick', {}],
|
||||
['crypto:recipient', { walletKey: 'VX-DEAD-BEEF-C0DE-2026' }],
|
||||
['crypto:quote', { marketId: 'aurora', quantity: '1', side: 'buy' }],
|
||||
['health:overview', {}],
|
||||
['billing:overview', {}],
|
||||
['billing:list', { filter: 'all', limit: 20, offset: 0 }],
|
||||
@@ -112,6 +116,36 @@ function verifyBrowserTestData(dataByEndpoint) {
|
||||
)
|
||||
|
||||
expectItems(dataByEndpoint.get('health:overview').days, 'health history', 7)
|
||||
const crypto = dataByEndpoint.get('crypto:bootstrap')
|
||||
expectItems(crypto.markets, 'crypto markets', 24)
|
||||
assert.equal(typeof crypto.profile.priceAlerts, 'boolean')
|
||||
assert.match(crypto.profile.walletKey, /^VX-(?:[A-F0-9]{4}-){3}[A-F0-9]{4}$/)
|
||||
assert.equal(typeof crypto.markets[0].issuedSupply, 'string')
|
||||
assert(crypto.markets.every((market) => typeof market.logo === 'string'))
|
||||
assert(
|
||||
crypto.markets.every(
|
||||
(market) =>
|
||||
Array.isArray(market.priceHistory) &&
|
||||
market.priceHistory.length >= 2 &&
|
||||
market.priceHistory.at(-1) === Number(market.price).toFixed(2),
|
||||
),
|
||||
)
|
||||
assert(
|
||||
Math.max(...crypto.markets.map((market) => Number(market.price))) >=
|
||||
1000000,
|
||||
)
|
||||
assert(
|
||||
Math.min(...crypto.markets.map((market) => Number(market.price))) <= 0.01,
|
||||
)
|
||||
const cryptoTick = dataByEndpoint.get('crypto:market-tick')
|
||||
expectItems(cryptoTick, 'live crypto market tick', 24)
|
||||
assert(
|
||||
cryptoTick.every(
|
||||
(market) =>
|
||||
typeof market.updatedAt === 'number' &&
|
||||
market.priceHistory.at(-1) === market.price,
|
||||
),
|
||||
)
|
||||
expectItems(
|
||||
dataByEndpoint.get('billing:list').invoices,
|
||||
'billing invoices',
|
||||
@@ -190,6 +224,40 @@ function verifyBrowserTestData(dataByEndpoint) {
|
||||
}
|
||||
|
||||
async function verifyStatefulActions(baseUrl) {
|
||||
const cryptoBeforeTransfer = await expectSuccess(
|
||||
baseUrl,
|
||||
'crypto:bootstrap',
|
||||
{},
|
||||
true,
|
||||
)
|
||||
const auroraBeforeTransfer = cryptoBeforeTransfer.holdings.find(
|
||||
(holding) => holding.assetId === 'aurora',
|
||||
)
|
||||
const cryptoAfterTransfer = await expectSuccess(
|
||||
baseUrl,
|
||||
'crypto:transfer',
|
||||
{
|
||||
idempotencyKey: 'smoke-transfer-1',
|
||||
marketId: 'aurora',
|
||||
password: 'VaultX123!',
|
||||
quantity: '0.5',
|
||||
walletKey: 'VX-DEAD-BEEF-C0DE-2026',
|
||||
},
|
||||
true,
|
||||
)
|
||||
const auroraAfterTransfer = cryptoAfterTransfer.holdings.find(
|
||||
(holding) => holding.assetId === 'aurora',
|
||||
)
|
||||
assert.equal(
|
||||
Number(auroraAfterTransfer.quantity),
|
||||
Number(auroraBeforeTransfer.quantity) - 0.5,
|
||||
)
|
||||
assert.equal(cryptoAfterTransfer.activity[0].type, 'transfer_out')
|
||||
assert.equal(
|
||||
cryptoAfterTransfer.activity[0].counterpartyKey,
|
||||
'VX-DEAD-BEEF-C0DE-2026',
|
||||
)
|
||||
|
||||
const companyCall = await expectSuccess(
|
||||
baseUrl,
|
||||
'companies:dial-service-line',
|
||||
|
||||
@@ -648,6 +648,373 @@ Config.SkyRide = {
|
||||
},
|
||||
}
|
||||
|
||||
Config.Crypto = {
|
||||
Enabled = true,
|
||||
Currency = "$",
|
||||
BankAccount = "bank",
|
||||
AssetScale = 1000000,
|
||||
PriceScale = 100,
|
||||
QuoteLifetimeSeconds = 8,
|
||||
PriceTickMinimumSeconds = 7,
|
||||
PriceTickMaximumSeconds = 12,
|
||||
MarketsPerTickMinimum = 8,
|
||||
MarketsPerTickMaximum = 12,
|
||||
TickMovementDivisor = 10,
|
||||
RandomImpulseDivisor = 3,
|
||||
MomentumDecayBasisPoints = 8000,
|
||||
MomentumImpulseBasisPoints = 2000,
|
||||
MeanReversionBasisPoints = 55,
|
||||
CycleDurationMinimumTicks = 14,
|
||||
CycleDurationMaximumTicks = 30,
|
||||
CycleStrengthMinimumBasisPoints = 2800,
|
||||
CycleStrengthMaximumBasisPoints = 5200,
|
||||
CycleTransitionBasisPoints = 1800,
|
||||
GlobalCycleMinimumTicks = 24,
|
||||
GlobalCycleMaximumTicks = 54,
|
||||
GlobalTrendMinimumBasisPoints = 8,
|
||||
GlobalTrendMaximumBasisPoints = 24,
|
||||
MarketShockChanceBasisPoints = 100,
|
||||
MarketShockMinimumMultiplier = 2,
|
||||
MarketShockMaximumMultiplier = 3,
|
||||
MaximumMovementMultiplier = 3,
|
||||
HistoryRetentionTicks = 4096,
|
||||
SparklinePoints = 48,
|
||||
SessionSeconds = 30 * 60,
|
||||
RecentAuthenticationSeconds = 5 * 60,
|
||||
PasswordMinLength = 8,
|
||||
PasswordMaxLength = 72,
|
||||
HandleMinLength = 3,
|
||||
HandleMaxLength = 20,
|
||||
FeeBasisPoints = 75,
|
||||
MinimumFee = 1,
|
||||
MinimumSettlement = 10,
|
||||
MaximumSettlement = 250000,
|
||||
MaximumTradeNotional = 100000,
|
||||
MaximumPositionQuantity = 100000,
|
||||
MaximumTransferQuantity = 100000,
|
||||
DailyDepositLimit = 500000,
|
||||
DailyWithdrawalLimit = 250000,
|
||||
DailyTradeLimit = 500000,
|
||||
ActionsPerMinute = 30,
|
||||
LoginAttempts = 5,
|
||||
LockoutSeconds = 5 * 60,
|
||||
TreasuryCash = 5000000,
|
||||
Markets = {
|
||||
{
|
||||
Id = "aurora",
|
||||
Symbol = "AUR",
|
||||
Name = "Aurora",
|
||||
Color = "#25d9ad",
|
||||
Logo = "◈",
|
||||
InitialPrice = 12850,
|
||||
MinimumPrice = 3000,
|
||||
MaximumPrice = 80000,
|
||||
VolatilityBasisPoints = 180,
|
||||
IssuedSupply = 1000000,
|
||||
TreasuryInventory = 850000,
|
||||
},
|
||||
{
|
||||
Id = "vertex",
|
||||
Symbol = "VTX",
|
||||
Name = "Vertex",
|
||||
Color = "#4d8cff",
|
||||
Logo = "◆",
|
||||
InitialPrice = 4275,
|
||||
MinimumPrice = 800,
|
||||
MaximumPrice = 30000,
|
||||
VolatilityBasisPoints = 260,
|
||||
IssuedSupply = 2500000,
|
||||
TreasuryInventory = 2100000,
|
||||
},
|
||||
{
|
||||
Id = "ember",
|
||||
Symbol = "EMB",
|
||||
Name = "Ember",
|
||||
Color = "#ff9d54",
|
||||
Logo = "✦",
|
||||
InitialPrice = 980,
|
||||
MinimumPrice = 100,
|
||||
MaximumPrice = 12000,
|
||||
VolatilityBasisPoints = 340,
|
||||
IssuedSupply = 8000000,
|
||||
TreasuryInventory = 6800000,
|
||||
},
|
||||
{
|
||||
Id = "titan",
|
||||
Symbol = "TTN",
|
||||
Name = "Titan",
|
||||
Color = "#8f7cff",
|
||||
Logo = "⬢",
|
||||
InitialPrice = 425000000,
|
||||
MinimumPrice = 100000000,
|
||||
MaximumPrice = 900000000,
|
||||
VolatilityBasisPoints = 65,
|
||||
IssuedSupply = 21000,
|
||||
TreasuryInventory = 17500,
|
||||
},
|
||||
{
|
||||
Id = "crown",
|
||||
Symbol = "CRN",
|
||||
Name = "Crown",
|
||||
Color = "#f6c453",
|
||||
Logo = "♛",
|
||||
InitialPrice = 128000000,
|
||||
MinimumPrice = 25000000,
|
||||
MaximumPrice = 350000000,
|
||||
VolatilityBasisPoints = 85,
|
||||
IssuedSupply = 64000,
|
||||
TreasuryInventory = 51000,
|
||||
},
|
||||
{
|
||||
Id = "atlas",
|
||||
Symbol = "ATL",
|
||||
Name = "Atlas",
|
||||
Color = "#e66f9e",
|
||||
Logo = "▲",
|
||||
InitialPrice = 12000000,
|
||||
MinimumPrice = 3000000,
|
||||
MaximumPrice = 30000000,
|
||||
VolatilityBasisPoints = 110,
|
||||
IssuedSupply = 250000,
|
||||
TreasuryInventory = 205000,
|
||||
},
|
||||
{
|
||||
Id = "zenith",
|
||||
Symbol = "ZNT",
|
||||
Name = "Zenith",
|
||||
Color = "#6b8cff",
|
||||
Logo = "✥",
|
||||
InitialPrice = 68000000,
|
||||
MinimumPrice = 18000000,
|
||||
MaximumPrice = 160000000,
|
||||
VolatilityBasisPoints = 95,
|
||||
IssuedSupply = 125000,
|
||||
TreasuryInventory = 99000,
|
||||
},
|
||||
{
|
||||
Id = "celestium",
|
||||
Symbol = "CLS",
|
||||
Name = "Celestium",
|
||||
Color = "#8ad8ff",
|
||||
Logo = "✧",
|
||||
InitialPrice = 8450000,
|
||||
MinimumPrice = 1500000,
|
||||
MaximumPrice = 22000000,
|
||||
VolatilityBasisPoints = 125,
|
||||
IssuedSupply = 420000,
|
||||
TreasuryInventory = 340000,
|
||||
},
|
||||
{
|
||||
Id = "orbit",
|
||||
Symbol = "ORB",
|
||||
Name = "Orbit",
|
||||
Color = "#57c7ff",
|
||||
Logo = "◎",
|
||||
InitialPrice = 1244000,
|
||||
MinimumPrice = 250000,
|
||||
MaximumPrice = 3500000,
|
||||
VolatilityBasisPoints = 145,
|
||||
IssuedSupply = 900000,
|
||||
TreasuryInventory = 735000,
|
||||
},
|
||||
{
|
||||
Id = "helix",
|
||||
Symbol = "HLX",
|
||||
Name = "Helix",
|
||||
Color = "#c47cff",
|
||||
Logo = "⌁",
|
||||
InitialPrice = 582000,
|
||||
MinimumPrice = 90000,
|
||||
MaximumPrice = 1600000,
|
||||
VolatilityBasisPoints = 175,
|
||||
IssuedSupply = 1400000,
|
||||
TreasuryInventory = 1140000,
|
||||
},
|
||||
{
|
||||
Id = "nova",
|
||||
Symbol = "NVA",
|
||||
Name = "Nova",
|
||||
Color = "#ff6f91",
|
||||
Logo = "✺",
|
||||
InitialPrice = 325000,
|
||||
MinimumPrice = 50000,
|
||||
MaximumPrice = 900000,
|
||||
VolatilityBasisPoints = 210,
|
||||
IssuedSupply = 2200000,
|
||||
TreasuryInventory = 1800000,
|
||||
},
|
||||
{
|
||||
Id = "prism",
|
||||
Symbol = "PRM",
|
||||
Name = "Prism",
|
||||
Color = "#5ee1c4",
|
||||
Logo = "◇",
|
||||
InitialPrice = 74500,
|
||||
MinimumPrice = 12000,
|
||||
MaximumPrice = 240000,
|
||||
VolatilityBasisPoints = 195,
|
||||
IssuedSupply = 3800000,
|
||||
TreasuryInventory = 3100000,
|
||||
},
|
||||
{
|
||||
Id = "quantum",
|
||||
Symbol = "QTM",
|
||||
Name = "Quantum",
|
||||
Color = "#7a9cff",
|
||||
Logo = "◉",
|
||||
InitialPrice = 21400,
|
||||
MinimumPrice = 3500,
|
||||
MaximumPrice = 85000,
|
||||
VolatilityBasisPoints = 230,
|
||||
IssuedSupply = 5200000,
|
||||
TreasuryInventory = 4250000,
|
||||
},
|
||||
{
|
||||
Id = "drift",
|
||||
Symbol = "DRF",
|
||||
Name = "Drift",
|
||||
Color = "#4ecdc4",
|
||||
Logo = "≋",
|
||||
InitialPrice = 7600,
|
||||
MinimumPrice = 900,
|
||||
MaximumPrice = 30000,
|
||||
VolatilityBasisPoints = 280,
|
||||
IssuedSupply = 12000000,
|
||||
TreasuryInventory = 9800000,
|
||||
},
|
||||
{
|
||||
Id = "pulse",
|
||||
Symbol = "PLS",
|
||||
Name = "Pulse",
|
||||
Color = "#ff5f7f",
|
||||
Logo = "◍",
|
||||
InitialPrice = 1840,
|
||||
MinimumPrice = 200,
|
||||
MaximumPrice = 9000,
|
||||
VolatilityBasisPoints = 320,
|
||||
IssuedSupply = 18000000,
|
||||
TreasuryInventory = 14800000,
|
||||
},
|
||||
{
|
||||
Id = "moss",
|
||||
Symbol = "MOS",
|
||||
Name = "Moss",
|
||||
Color = "#64c987",
|
||||
Logo = "♧",
|
||||
InitialPrice = 460,
|
||||
MinimumPrice = 60,
|
||||
MaximumPrice = 2400,
|
||||
VolatilityBasisPoints = 360,
|
||||
IssuedSupply = 36000000,
|
||||
TreasuryInventory = 29500000,
|
||||
},
|
||||
{
|
||||
Id = "flux",
|
||||
Symbol = "FLX",
|
||||
Name = "Flux",
|
||||
Color = "#a777e3",
|
||||
Logo = "ϟ",
|
||||
InitialPrice = 238,
|
||||
MinimumPrice = 35,
|
||||
MaximumPrice = 1400,
|
||||
VolatilityBasisPoints = 390,
|
||||
IssuedSupply = 44000000,
|
||||
TreasuryInventory = 36000000,
|
||||
},
|
||||
{
|
||||
Id = "tide",
|
||||
Symbol = "TDE",
|
||||
Name = "Tide",
|
||||
Color = "#46a6ff",
|
||||
Logo = "≈",
|
||||
InitialPrice = 125,
|
||||
MinimumPrice = 20,
|
||||
MaximumPrice = 700,
|
||||
VolatilityBasisPoints = 410,
|
||||
IssuedSupply = 60000000,
|
||||
TreasuryInventory = 49000000,
|
||||
},
|
||||
{
|
||||
Id = "spark",
|
||||
Symbol = "SPK",
|
||||
Name = "Spark",
|
||||
Color = "#ffb84d",
|
||||
Logo = "✹",
|
||||
InitialPrice = 74,
|
||||
MinimumPrice = 12,
|
||||
MaximumPrice = 420,
|
||||
VolatilityBasisPoints = 430,
|
||||
IssuedSupply = 85000000,
|
||||
TreasuryInventory = 70000000,
|
||||
},
|
||||
{
|
||||
Id = "pebble",
|
||||
Symbol = "PBL",
|
||||
Name = "Pebble",
|
||||
Color = "#9aa8b8",
|
||||
Logo = "●",
|
||||
InitialPrice = 22,
|
||||
MinimumPrice = 4,
|
||||
MaximumPrice = 160,
|
||||
VolatilityBasisPoints = 470,
|
||||
IssuedSupply = 150000000,
|
||||
TreasuryInventory = 124000000,
|
||||
},
|
||||
{
|
||||
Id = "nano",
|
||||
Symbol = "NNO",
|
||||
Name = "Nano",
|
||||
Color = "#5bd6ff",
|
||||
Logo = "η",
|
||||
InitialPrice = 9,
|
||||
MinimumPrice = 1,
|
||||
MaximumPrice = 85,
|
||||
VolatilityBasisPoints = 520,
|
||||
IssuedSupply = 260000000,
|
||||
TreasuryInventory = 214000000,
|
||||
},
|
||||
{
|
||||
Id = "shard",
|
||||
Symbol = "SHD",
|
||||
Name = "Shard",
|
||||
Color = "#70e0d0",
|
||||
Logo = "△",
|
||||
InitialPrice = 5,
|
||||
MinimumPrice = 1,
|
||||
MaximumPrice = 55,
|
||||
VolatilityBasisPoints = 560,
|
||||
IssuedSupply = 420000000,
|
||||
TreasuryInventory = 345000000,
|
||||
},
|
||||
{
|
||||
Id = "pixel",
|
||||
Symbol = "PXL",
|
||||
Name = "Pixel",
|
||||
Color = "#ff78c8",
|
||||
Logo = "▦",
|
||||
InitialPrice = 3,
|
||||
MinimumPrice = 1,
|
||||
MaximumPrice = 32,
|
||||
VolatilityBasisPoints = 610,
|
||||
IssuedSupply = 700000000,
|
||||
TreasuryInventory = 580000000,
|
||||
},
|
||||
{
|
||||
Id = "dust",
|
||||
Symbol = "DST",
|
||||
Name = "Dust",
|
||||
Color = "#c4b5a5",
|
||||
Logo = "·",
|
||||
InitialPrice = 1,
|
||||
MinimumPrice = 1,
|
||||
MaximumPrice = 18,
|
||||
VolatilityBasisPoints = 680,
|
||||
IssuedSupply = 1200000000,
|
||||
TreasuryInventory = 990000000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- =============================================================================
|
||||
-- Server-only configuration
|
||||
-- =============================================================================
|
||||
|
||||
@@ -1049,6 +1049,26 @@ Locales["de"] = {
|
||||
request_failed = "Die Funkanfrage ist fehlgeschlagen.", default = "Die Funkanfrage ist fehlgeschlagen.",
|
||||
},
|
||||
},
|
||||
crypto = {
|
||||
name = "VaultX", subtitle = "Börse für fiktive Assets", loading = "Sichere Börse wird geöffnet…", logout = "Abmelden", refresh = "Portfolio aktualisieren", navigation = "VaultX Navigation",
|
||||
auth = {
|
||||
eyebrow = "Charaktergebundene Börse", loginTitle = "Willkommen zurück", registerTitle = "Erstelle dein VaultX-Profil", body = "Handle und übertrage fiktive digitale Assets über ein geschütztes, servergesteuertes Konto.", network = "VAULT-NETZWERK ONLINE", serverSecured = "Servergeschützt", personalKey = "Eigener Crypto-Key", characterBound = "Charaktergebunden", login = "Einloggen", register = "Registrieren", loginPanelTitle = "Sicherer Kontozugang", loginPanelBody = "Deine Charakteridentität wird automatisch erkannt.", registerPanelTitle = "Richte deinen privaten Vault ein", registerPanelBody = "Wähle deine Profilidentität und sichere Zugangsphrase.", handle = "VaultX-Benutzername", handlePlaceholder = "3–20 Buchstaben oder Zahlen", password = "Passwort", passwordPlaceholder = "8–72 Zeichen", confirmPassword = "Passwort bestätigen", confirmPasswordPlaceholder = "Dasselbe Passwort erneut eingeben", showPassword = "Passwort anzeigen", hidePassword = "Passwort ausblenden", security = "Nutze ein In-Character-Passwort. Verwende niemals ein echtes Passwort erneut.", ruleLength = "Mindestens 8 Zeichen", ruleMixed = "Groß- & Kleinbuchstaben", ruleNumber = "Enthält eine Zahl", ruleSpecial = "Sonderzeichen", strength0 = "Beginne mit einem sicheren Passwort", strength1 = "Schwacher Schutz", strength2 = "Grundlegender Schutz", strength3 = "Starker Schutz", strength4 = "Sehr starker Schutz", acceptTitle = "Fiktives Wallet erstellen", acceptBody = "Ich verstehe, dass VaultX-Assets keinen realen Wert besitzen.", footer = "Verschlüsselte Sitzung · Begrenzte Zugriffe · Speicherintensiver Passwortschutz", loginAction = "VaultX entsperren", registerAction = "Sicheres Profil erstellen",
|
||||
},
|
||||
tabs = { portfolio = "Portfolio", markets = "Märkte", activity = "Aktivität", profile = "Profil" },
|
||||
portfolio = { total = "Gesamtes Portfolio", performance = "Gewinn / Verlust", return = "Gesamtrendite", current = "Jetziger Stand", history = "Entwicklung", forecast = "Prognose", chartLabel = "Gewinn- und Verlustentwicklung mit gestrichelter Prognose", cash = "Verfügbares Guthaben", invested = "Investierte Assets", allocation = "Portfolio-Verteilung", inMarket = "in Märkten", assets = "Assets", holdings = "Deine Assets", empty = "Noch keine Assets", emptyBody = "Öffne Märkte und fordere ein geschütztes Angebot an.", avg = "Ø", insightTitle = "Portfolio-Einblick", insightBody = "Deine Bestände werden mit den neuesten synthetischen Serverkursen bewertet." },
|
||||
quick = { trade = "Handeln", send = "Senden", more = "Mehr" },
|
||||
actions = { deposit = "Einzahlen", withdraw = "Auszahlen" },
|
||||
markets = { title = "Fiktive Märkte", live = "Serverpreise", noticeTitle = "Ingame-Börse", notice = "Die Kurse sind synthetisch und haben keinen realen Wert. Jeder Handel wird gegen die begrenzte Liquidität der Börse geprüft.", eyebrow = "Marktpuls", movers = "Top-Beweger", today = "Heute" },
|
||||
marketDetail = { back = "Zurück zu den Märkten", today = "Heute", description = "Der Referenzkurs wird vom Server erzeugt. Ausführbare Kurse verwenden ein geschütztes, kurzlebiges Angebot.", investment = "Deine Investition", totalValue = "Aktueller Wert", statistics = "Marktstatistik", high24h = "24h-Hoch", low24h = "24h-Tief", supply = "Ausgegebene Menge", liquidity = "Börsenliquidität" },
|
||||
trade = { actions = "Handelsaktionen", buy = "Kaufen", buyBody = "Aufbauen", sell = "Verkaufen", sellBody = "Reduzieren", quantity = "Menge", available = "Verfügbar:", estimated = "Geschätzter Wert", marketPrice = "Marktpreis", protected = "Geschütztes Serverangebot", protectedBody = "VaultX prüft Preis, Liquidität, Guthaben und Limits vor der Ausführung.", review = "Order prüfen", price = "Angebotspreis", gross = "Brutto", fee = "Börsengebühr", total = "Gesamt", quoteExpiry = "Dieses Serverangebot läuft nach 8 Sekunden ab und kann nur einmal genutzt werden.", getQuote = "Geschütztes Angebot", confirm = "Handel bestätigen" },
|
||||
settlement = { depositBody = "Übertrage Bankguthaben in dein verbuchtes VaultX-Guthaben.", withdrawBody = "Zahle verbuchtes VaultX-Guthaben auf das Bankkonto deines Charakters aus.", amount = "Ganzer Bankbetrag", confirm = "Sicher bestätigen" },
|
||||
transfer = { title = "Krypto senden", subtitle = "VaultX-Übertragung", keyTitle = "Öffentlicher Empfangsschlüssel", keyBody = "Prüfe den Empfänger, bevor du ein Asset auswählst. Bargeld kann hier niemals gesendet werden.", walletKey = "Crypto-Key des Empfängers", verify = "Prüfen", verifiedRecipient = "Verifizierter VaultX-Empfänger", asset = "Asset auswählen", quantity = "Krypto-Menge", available = "Verfügbarer Bestand", password = "Mit Passwort bestätigen", protected = "Atomare Wallet-Übertragung", protectedBody = "VaultX prüft Empfänger, Besitz, Menge und Bestand erneut auf dem Server und verbucht beide Wallets gemeinsam.", confirm = "Krypto sicher senden" },
|
||||
activity = { title = "Finanzaktivität", empty = "Keine Aktivität", emptyBody = "Einzahlungen, Auszahlungen, Handel und Krypto-Übertragungen erscheinen hier.", volume = "Handelsvolumen gesamt", completedTrades = "abgeschlossene Trades", cash = "Guthaben", filters = { all = "Alle", trades = "Trades", wallet = "Wallet" } },
|
||||
profile = { verified = "Charakterbesitz verifiziert", walletKey = "Öffentlicher Crypto-Key", walletKeyBody = "Teile diesen Key, um Krypto zu empfangen. Er gewährt keinen Kontozugriff.", copyKey = "Kopieren", copied = "Kopiert", shareKey = "Teilen", shareTitle = "Mein VaultX Crypto-Key", shareText = "Sende Krypto an meinen öffentlichen VaultX-Key:", trades = "Trades", volume = "Volumen", memberSince = "Mitglied seit", preferences = "Einstellungen", priceAlerts = "Kurswarnungen", priceAlertsBody = "Bei auffälligen Marktbewegungen benachrichtigen.", confirmations = "Handelsbestätigung", confirmationsBody = "Zusätzliche Bestätigung vor der Ausführung behalten.", hideBalances = "Privatsphäre-Modus", hideBalancesBody = "Beträge in VaultX verbergen.", identity = "Profilidentität", edit = "Bearbeiten", account = "Konto & Sicherheit", editTitle = "Profil bearbeiten", editBody = "Ändere deinen Benutzernamen oder lege ein neues Passwort fest. Dein aktuelles Passwort bestätigt Identitätsänderungen.", currentPassword = "Aktuelles Passwort", currentPasswordPlaceholder = "Für Namens- oder Passwortänderungen erforderlich", newPassword = "Neues Passwort", newPasswordPlaceholder = "Leer lassen, um das aktuelle Passwort zu behalten", passwordSecurity = "Passwörter werden speicherintensiv gehasht und nie wieder angezeigt.", saved = "Profil sicher gespeichert.", save = "Profil speichern", saveChanges = "Änderungen speichern", securityTitle = "Geschütztes Profil", securityBody = "Dein Profil bleibt an diesen Framework-Charakter und die Finanzsitzung gebunden." },
|
||||
activityTypes = { buy = "Asset-Kauf", sell = "Asset-Verkauf", deposit = "Bankeinzahlung", withdrawal = "Bankauszahlung", transfer_in = "Krypto empfangen", transfer_out = "Krypto gesendet" },
|
||||
statuses = { completed = "Abgeschlossen", pending = "Prüfung ausstehend", failed = "Fehlgeschlagen", manual_review = "Manuelle Prüfung" },
|
||||
errors = { invalid_profile = "Prüfe deine VaultX-Profileinstellungen.", invalid_handle = "Nutze 3–20 Buchstaben, Zahlen, Punkte oder Unterstriche.", handle_taken = "Dieser VaultX-Benutzername ist bereits vergeben.", profile_exists = "Dieser Charakter besitzt bereits ein VaultX-Profil.", invalid_password = "Das Passwort muss 8–72 Zeichen lang sein.", password_mismatch = "Die Passwörter stimmen nicht überein.", accept_terms = "Bestätige, dass dies ein fiktives Ingame-Wallet ist.", invalid_credentials = "Das Passwort ist falsch.", locked = "Zu viele Versuche. Versuche es später erneut.", not_authenticated = "Logge dich zuerst bei VaultX ein.", invalid_amount = "Gib einen positiven ganzen Betrag ein.", invalid_quantity = "Gib eine Menge mit bis zu sechs Nachkommastellen ein.", invalid_wallet_key = "Gib einen vollständigen VX-Empfangsschlüssel ein.", recipient_not_found = "Zu diesem Key wurde kein aktives VaultX-Konto gefunden.", self_transfer = "Du kannst keine Krypto an deinen eigenen Key senden.", invalid_transfer = "Prüfe Empfänger, Asset und Krypto-Menge.", recipient_limit_exceeded = "Das Empfänger-Wallet kann diese Menge nicht aufnehmen.", insufficient_funds = "Dein verfügbarer Bestand reicht nicht aus.", insufficient_liquidity = "Die Börse kann diese Menge derzeit nicht ausführen.", quote_expired = "Das Angebot ist abgelaufen. Fordere ein neues an.", quote_unavailable = "Das Angebot ist nicht mehr verfügbar.", market_unavailable = "Dieser Markt ist vorübergehend pausiert.", limit_exceeded = "Die Anfrage überschreitet ein Börsenlimit.", duplicate_request = "Diese Anfrage wurde bereits verarbeitet.", rate_limited = "Zu viele Anfragen. Versuche es gleich erneut.", settlement_pending = "Eine Geldbewegung wartet bereits auf Prüfung.", service_unavailable = "VaultX ist vorübergehend nicht verfügbar.", request_failed = "Die sichere Börsenanfrage ist fehlgeschlagen.", default = "VaultX konnte die Anfrage nicht abschließen." },
|
||||
},
|
||||
banking = {
|
||||
name = "Banking", welcome = "Willkommen zurück", totalBalance = "Gesamtsaldo", recentPeriod = "in jüngster Zeit",
|
||||
actions = "Bankgeschäfte", send = "Senden",
|
||||
|
||||
@@ -1049,6 +1049,26 @@ Locales["en"] = {
|
||||
request_failed = "The radio request failed.", default = "The radio request failed.",
|
||||
},
|
||||
},
|
||||
crypto = {
|
||||
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_profile = "Check your VaultX profile settings.", 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.", default = "VaultX could not complete the request." },
|
||||
},
|
||||
banking = {
|
||||
name = "Banking", welcome = "Welcome back", totalBalance = "Total Balance", recentPeriod = "in recent activity",
|
||||
actions = "Banking actions", send = "Send",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
lua54 'yes'
|
||||
node_version '22'
|
||||
use_experimental_fxv2_oal 'yes'
|
||||
|
||||
author 'Sky-Systems'
|
||||
@@ -49,6 +50,7 @@ client_scripts {
|
||||
|
||||
server_scripts {
|
||||
'@oxmysql/lib/MySQL.lua',
|
||||
'source/server/crypto_password.js',
|
||||
'config/config.lua',
|
||||
'config/media.lua',
|
||||
'config/locales/en.lua',
|
||||
@@ -87,6 +89,7 @@ server_scripts {
|
||||
'source/server/notes.lua',
|
||||
'source/server/mail.lua',
|
||||
'source/server/banking.lua',
|
||||
'source/server/crypto.lua',
|
||||
'source/server/health.lua',
|
||||
'source/server/billing.lua',
|
||||
'source/server/garage.lua',
|
||||
|
||||
@@ -242,6 +242,17 @@ local server_callbacks = {
|
||||
"calls:block",
|
||||
"banking:overview",
|
||||
"banking:transfer",
|
||||
"crypto:bootstrap",
|
||||
"crypto:register",
|
||||
"crypto:login",
|
||||
"crypto:logout",
|
||||
"crypto:quote",
|
||||
"crypto:execute",
|
||||
"crypto:deposit",
|
||||
"crypto:withdraw",
|
||||
"crypto:update-profile",
|
||||
"crypto:recipient",
|
||||
"crypto:transfer",
|
||||
"billing:overview",
|
||||
"billing:list",
|
||||
"billing:detail",
|
||||
@@ -918,6 +929,18 @@ RegisterNetEvent("sky_phone:banking:changed", function(data)
|
||||
SendNUIMessage({ type = "banking:changed", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:crypto:changed", function(data)
|
||||
if type(data) ~= "table" or type(data.markets) ~= "table" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid crypto market data.")
|
||||
return
|
||||
end
|
||||
SendNUIMessage({ type = "crypto:changed", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:crypto:account-changed", function()
|
||||
SendNUIMessage({ type = "crypto:account-changed" })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:billing:changed", function()
|
||||
SendNUIMessage({ type = "billing:changed" })
|
||||
end)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
'use strict'
|
||||
|
||||
const { randomBytes, randomInt, scryptSync, timingSafeEqual } = require('node:crypto')
|
||||
|
||||
const KEY_LENGTH = 32
|
||||
const SCRYPT_COST = 32768
|
||||
const SCRYPT_BLOCK_SIZE = 8
|
||||
const SCRYPT_PARALLELISM = 1
|
||||
const MAX_MEMORY = 64 * 1024 * 1024
|
||||
|
||||
exports('CryptoHashPassword', (password) => {
|
||||
if (typeof password !== 'string' || password.length < 8 || password.length > 72) return null
|
||||
|
||||
const salt = randomBytes(16)
|
||||
const hash = scryptSync(password, salt, KEY_LENGTH, {
|
||||
N: SCRYPT_COST,
|
||||
maxmem: MAX_MEMORY,
|
||||
p: SCRYPT_PARALLELISM,
|
||||
r: SCRYPT_BLOCK_SIZE,
|
||||
})
|
||||
return [
|
||||
'scrypt', 'v=1', `N=${SCRYPT_COST}`, `r=${SCRYPT_BLOCK_SIZE}`,
|
||||
`p=${SCRYPT_PARALLELISM}`, salt.toString('base64'), hash.toString('base64'),
|
||||
].join('$')
|
||||
})
|
||||
|
||||
exports('CryptoVerifyPassword', (password, encoded) => {
|
||||
if (typeof password !== 'string' || typeof encoded !== 'string') return false
|
||||
|
||||
const parts = encoded.split('$')
|
||||
if (
|
||||
parts.length !== 7 || parts[0] !== 'scrypt' || parts[1] !== 'v=1' ||
|
||||
parts[2] !== `N=${SCRYPT_COST}` || parts[3] !== `r=${SCRYPT_BLOCK_SIZE}` ||
|
||||
parts[4] !== `p=${SCRYPT_PARALLELISM}`
|
||||
) return false
|
||||
|
||||
try {
|
||||
const salt = Buffer.from(parts[5], 'base64')
|
||||
const expected = Buffer.from(parts[6], 'base64')
|
||||
if (salt.length !== 16 || expected.length !== KEY_LENGTH) return false
|
||||
const actual = scryptSync(password, salt, KEY_LENGTH, {
|
||||
N: SCRYPT_COST,
|
||||
maxmem: MAX_MEMORY,
|
||||
p: SCRYPT_PARALLELISM,
|
||||
r: SCRYPT_BLOCK_SIZE,
|
||||
})
|
||||
return timingSafeEqual(actual, expected)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
exports('CryptoRandomInt', (minimum, maximum) => {
|
||||
if (!Number.isSafeInteger(minimum) || !Number.isSafeInteger(maximum) || minimum >= maximum) {
|
||||
return null
|
||||
}
|
||||
return randomInt(minimum, maximum)
|
||||
})
|
||||
@@ -37,6 +37,7 @@ local ALLOWED_PERMISSIONS = {
|
||||
local RESERVED_APP_IDS = {
|
||||
["app-store"] = true,
|
||||
banking = true,
|
||||
crypto = true,
|
||||
billing = true,
|
||||
calculator = true,
|
||||
calendar = true,
|
||||
|
||||
@@ -1344,3 +1344,142 @@ CREATE TABLE IF NOT EXISTS `sky_phone_weazel_article_media` (
|
||||
FOREIGN KEY (`article_id`) REFERENCES `sky_phone_weazel_articles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_profiles` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`account_id` BIGINT UNSIGNED NULL,
|
||||
`handle` VARCHAR(20) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`password_hash` VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`price_alerts` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1,
|
||||
`trade_confirmations` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1,
|
||||
`hide_balances` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`status` ENUM('active','frozen','closed') NOT NULL DEFAULT 'active',
|
||||
`failed_logins` TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`locked_until` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_sky_phone_crypto_owner` (`owner_identifier`),
|
||||
UNIQUE KEY `uniq_sky_phone_crypto_handle` (`handle`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_markets` (
|
||||
`id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`asset_scale` BIGINT UNSIGNED NOT NULL,
|
||||
`price_scale` BIGINT UNSIGNED NOT NULL,
|
||||
`issued_supply` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`price` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`version` BIGINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`status` ENUM('active','halted','stale') NOT NULL DEFAULT 'active',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_market_ticks` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`version` BIGINT UNSIGNED NOT NULL,
|
||||
`price` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_sky_phone_crypto_tick` (`market_id`,`version`),
|
||||
KEY `idx_sky_phone_crypto_ticks` (`market_id`,`created_at`,`id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_balances` (
|
||||
`account_id` VARCHAR(48) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`asset_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`available` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`locked` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`version` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`account_id`,`asset_id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_operations` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`type` ENUM('buy','sell','deposit','withdrawal') NOT NULL,
|
||||
`idempotency_key` VARCHAR(96) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`request_hash` CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`status` ENUM('prepared','external_pending','external_applied','ledger_applied','completed','failed','compensation_pending','manual_review','cancelled') NOT NULL,
|
||||
`amount` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`detail` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_sky_phone_crypto_operation` (`profile_id`,`type`,`idempotency_key`),
|
||||
KEY `idx_sky_phone_crypto_activity` (`profile_id`,`created_at`,`id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_ledger_entries` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`account_id` VARCHAR(48) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`asset_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`delta` DECIMAL(36,0) NOT NULL,
|
||||
`balance_after` DECIMAL(36,0) NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_crypto_ledger_operation` (`operation_id`,`id`),
|
||||
KEY `idx_sky_phone_crypto_ledger_account` (`account_id`,`created_at`,`id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_quotes` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`side` ENUM('buy','sell') NOT NULL,
|
||||
`quantity` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`price` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`gross` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`fee` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`net` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`market_version` BIGINT UNSIGNED NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`consumed_operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_crypto_quote_profile` (`profile_id`,`expires_at`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_fills` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`quote_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`side` ENUM('buy','sell') NOT NULL,
|
||||
`quantity` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`price` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`gross` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`fee` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`net` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_sky_phone_crypto_fill_quote` (`quote_id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_settlements` (
|
||||
`operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`framework_account` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`amount` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`state` ENUM('prepared','external_pending','external_applied','ledger_applied','completed','failed','compensation_pending','manual_review','cancelled') NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`operation_id`),
|
||||
KEY `idx_sky_phone_crypto_settlement_state` (`state`,`updated_at`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_audit_events` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`event_type` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`detail` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_crypto_audit` (`profile_id`,`created_at`,`id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
Reference in New Issue
Block a user