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:
smx.pusha
2026-08-18 13:57:19 +02:00
27 changed files with 9228 additions and 4 deletions
+9
View File
@@ -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>
+7
View File
@@ -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',
+16
View File
@@ -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(
+279
View File
@@ -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-/),
}),
)
})
})
+194
View File
@@ -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)
+12
View File
@@ -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({
+243
View File
@@ -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: '320 letters or numbers',
password: 'Password',
passwordPlaceholder: '872 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 320 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 872 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',
+1
View File
@@ -11,6 +11,7 @@ export type BuiltinPhoneAppId =
| 'weather'
| 'health'
| 'banking'
| 'crypto'
| 'billing'
| 'garage'
| 'house'
+89
View File
@@ -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
}
+1
View File
@@ -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
+769 -4
View File
@@ -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
? {
+68
View File
@@ -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',
+367
View File
@@ -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
-- =============================================================================
+20
View File
@@ -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 = "320 Buchstaben oder Zahlen", password = "Passwort", passwordPlaceholder = "872 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 320 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 872 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",
+20
View File
@@ -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 = "320 letters or numbers", password = "Password", passwordPlaceholder = "872 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 320 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 872 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",
+3
View File
@@ -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',
+23
View File
@@ -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)
})
+1
View File
@@ -37,6 +37,7 @@ local ALLOWED_PERMISSIONS = {
local RESERVED_APP_IDS = {
["app-store"] = true,
banking = true,
crypto = true,
billing = true,
calculator = true,
calendar = true,
+139
View File
@@ -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;