mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
ADD - implement secure VaultX exchange
Add the VaultX phone UI, stateful browser mocks, localized copy, and generated WebP icon. Implement character-bound authentication, fixed-point balances, finite treasury markets, idempotent quote execution, settlement reconciliation, Scrypt credentials, SQL schema, limits, audit records, and contract tests.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -39,6 +39,13 @@ describe('app registry', () => {
|
||||
route: '/apps/health',
|
||||
})
|
||||
expect(isPhoneAppId('health')).toBe(true)
|
||||
expect(PHONE_APPS.find((app) => app.id === 'crypto')).toMatchObject({
|
||||
category: 'utilities',
|
||||
gridOrder: 7,
|
||||
labelKey: 'Apps.crypto.name',
|
||||
route: '/apps/crypto',
|
||||
})
|
||||
expect(isPhoneAppId('crypto')).toBe(true)
|
||||
expect(PHONE_APPS.find((app) => app.id === 'banking')).toMatchObject({
|
||||
gridOrder: 5,
|
||||
labelKey: 'Apps.banking.name',
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
Building2,
|
||||
Newspaper,
|
||||
HeartPulse,
|
||||
ChartCandlestick,
|
||||
} from 'lucide-vue-next'
|
||||
import { defineAsyncComponent, markRaw, shallowReactive } from 'vue'
|
||||
|
||||
@@ -64,6 +65,7 @@ import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp'
|
||||
import weatherIcon from '@/assets/img/app-icons/weather.webp'
|
||||
import healthIcon from '@/assets/img/app-icons/health.webp'
|
||||
import bankingIcon from '@/assets/img/app-icons/banking.webp'
|
||||
import cryptoIcon from '@/assets/img/app-icons/crypto.webp'
|
||||
import billingIcon from '@/assets/img/app-icons/billing.svg'
|
||||
import garageIcon from '@/assets/img/app-icons/garage.webp'
|
||||
import houseIcon from '@/assets/img/app-icons/house.svg'
|
||||
@@ -89,6 +91,20 @@ import type {
|
||||
} from '@/types/apps'
|
||||
|
||||
export const PHONE_APPS = shallowReactive<PhoneAppDefinition[]>([
|
||||
{
|
||||
category: 'utilities',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/CryptoApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 7,
|
||||
icon: markRaw(ChartCandlestick),
|
||||
iconClass: 'app-icon--crypto',
|
||||
iconImage: cryptoIcon,
|
||||
id: 'crypto',
|
||||
labelKey: 'Apps.crypto.name',
|
||||
route: '/apps/crypto',
|
||||
},
|
||||
{
|
||||
category: 'utilities',
|
||||
component: markRaw(
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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: { handle: 'skyline', id: 'profile-1', status: 'active' },
|
||||
}
|
||||
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('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('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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { CryptoBootstrap, CryptoQuote, 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: {
|
||||
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 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
|
||||
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,
|
||||
})
|
||||
if (!response.success || !response.data) return false
|
||||
this.data = response.data
|
||||
this.pendingQuote = null
|
||||
return true
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -416,8 +416,113 @@ 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 fictional digital assets with your character's in-game funds.",
|
||||
login: 'Log in',
|
||||
register: 'Register',
|
||||
handle: 'VaultX handle',
|
||||
handlePlaceholder: '3–20 letters or numbers',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: '8–72 characters',
|
||||
showPassword: 'Show password',
|
||||
hidePassword: 'Hide password',
|
||||
security: 'Use an in-character password. Never reuse a real password.',
|
||||
loginAction: 'Open portfolio',
|
||||
registerAction: 'Create secure profile',
|
||||
},
|
||||
tabs: { portfolio: 'Portfolio', markets: 'Markets', activity: 'Activity' },
|
||||
portfolio: {
|
||||
total: 'Total portfolio',
|
||||
cash: 'Available cash',
|
||||
holdings: 'Your assets',
|
||||
empty: 'No assets yet',
|
||||
emptyBody: 'Open Markets to request a protected quote.',
|
||||
avg: 'Avg.',
|
||||
},
|
||||
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.',
|
||||
},
|
||||
trade: {
|
||||
buy: 'Buy',
|
||||
sell: 'Sell',
|
||||
quantity: 'Quantity',
|
||||
available: 'Available:',
|
||||
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',
|
||||
},
|
||||
activity: {
|
||||
title: 'Financial activity',
|
||||
empty: 'No activity',
|
||||
emptyBody: 'Deposits, withdrawals and trades will appear here.',
|
||||
},
|
||||
activityTypes: {
|
||||
buy: 'Asset purchase',
|
||||
sell: 'Asset sale',
|
||||
deposit: 'Bank deposit',
|
||||
withdrawal: 'Bank withdrawal',
|
||||
},
|
||||
statuses: {
|
||||
completed: 'Completed',
|
||||
pending: 'Pending review',
|
||||
failed: 'Failed',
|
||||
manual_review: 'Manual review',
|
||||
},
|
||||
errors: {
|
||||
invalid_handle: 'Use 3–20 letters, numbers, dots or underscores.',
|
||||
handle_taken: 'That VaultX handle is already taken.',
|
||||
profile_exists: 'This character already owns a VaultX profile.',
|
||||
invalid_password: 'Password must be 8–72 characters.',
|
||||
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.',
|
||||
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.',
|
||||
},
|
||||
}
|
||||
|
||||
const defaultLocales: LocaleTree = {
|
||||
Apps: {
|
||||
crypto: cryptoFallbackLocales,
|
||||
easyShare: {
|
||||
name: 'EasyShare',
|
||||
incoming: 'Incoming Share',
|
||||
|
||||
@@ -11,6 +11,7 @@ export type BuiltinPhoneAppId =
|
||||
| 'weather'
|
||||
| 'health'
|
||||
| 'banking'
|
||||
| 'crypto'
|
||||
| 'billing'
|
||||
| 'garage'
|
||||
| 'house'
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
export type CryptoSide = 'buy' | 'sell'
|
||||
|
||||
export type CryptoMarket = {
|
||||
changePercent: number
|
||||
color: string
|
||||
enabled: boolean
|
||||
id: string
|
||||
name: string
|
||||
price: string
|
||||
sparkline: number[]
|
||||
symbol: string
|
||||
}
|
||||
|
||||
export type CryptoHolding = {
|
||||
assetId: string
|
||||
averagePrice: string
|
||||
quantity: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type CryptoActivity = {
|
||||
amount: string
|
||||
createdAt: number
|
||||
id: string
|
||||
marketId?: string
|
||||
status: string
|
||||
type: 'buy' | 'sell' | 'deposit' | 'withdrawal'
|
||||
}
|
||||
|
||||
export type CryptoProfile = {
|
||||
handle: string
|
||||
id: string
|
||||
status: 'active' | 'frozen' | 'closed'
|
||||
}
|
||||
|
||||
export type CryptoBootstrap = {
|
||||
activity: CryptoActivity[]
|
||||
authenticated: boolean
|
||||
cashBalance: string
|
||||
holdings: CryptoHolding[]
|
||||
markets: CryptoMarket[]
|
||||
portfolioValue: string
|
||||
profile: CryptoProfile | null
|
||||
}
|
||||
|
||||
export type CryptoQuote = {
|
||||
expiresAt: number
|
||||
fee: string
|
||||
gross: string
|
||||
id: string
|
||||
marketId: string
|
||||
net: string
|
||||
price: string
|
||||
quantity: string
|
||||
side: CryptoSide
|
||||
}
|
||||
@@ -125,6 +125,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
|
||||
calendar: { enabled: true, sounds: true },
|
||||
weather: { enabled: true, sounds: true },
|
||||
banking: { enabled: true, sounds: true },
|
||||
crypto: { enabled: true, sounds: true },
|
||||
billing: { enabled: true, sounds: true },
|
||||
garage: { enabled: true, sounds: true },
|
||||
skyride: { enabled: true, sounds: true },
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./CryptoApp.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',
|
||||
)
|
||||
|
||||
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('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('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.toContain('data.price')
|
||||
expect(server).not.toContain('data.fee')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,831 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowLeftRight,
|
||||
ArrowUpFromLine,
|
||||
ChartCandlestick,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
LockKeyhole,
|
||||
LogOut,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { useCryptoStore } from '@/stores/crypto'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { CryptoMarket, CryptoSide } from '@/types/crypto'
|
||||
import {
|
||||
SkyAppPage,
|
||||
SkyButton,
|
||||
SkyCard,
|
||||
SkyEmptyState,
|
||||
SkyField,
|
||||
SkyLink,
|
||||
SkyNavbar,
|
||||
SkyPillNavigation,
|
||||
SkyScrollArea,
|
||||
SkySegmented,
|
||||
SkySegmentedButton,
|
||||
SkySheet,
|
||||
SkySpinner,
|
||||
SkyStatusCard,
|
||||
} from '@/ui'
|
||||
|
||||
type CryptoTab = 'portfolio' | 'markets' | 'activity'
|
||||
type SheetMode = 'trade' | 'deposit' | 'withdraw' | null
|
||||
|
||||
const crypto = useCryptoStore()
|
||||
const phone = usePhoneStore()
|
||||
const activeTab = ref<CryptoTab>('portfolio')
|
||||
const authMode = ref<'login' | 'register'>('login')
|
||||
const handle = ref('')
|
||||
const password = ref('')
|
||||
const showPassword = ref(false)
|
||||
const sheetMode = ref<SheetMode>(null)
|
||||
const selectedMarket = ref<CryptoMarket | null>(null)
|
||||
const side = ref<CryptoSide>('buy')
|
||||
const amount = ref('')
|
||||
const financialPassword = ref('')
|
||||
const formError = ref('')
|
||||
|
||||
const locale = computed(() => phone.lang || 'de')
|
||||
const authenticated = computed(() => crypto.data?.authenticated === true)
|
||||
const markets = computed(() => crypto.data?.markets ?? [])
|
||||
const holdings = computed(() => crypto.data?.holdings ?? [])
|
||||
const activity = computed(() => crypto.data?.activity ?? [])
|
||||
const selectedHolding = computed(() =>
|
||||
holdings.value.find((item) => item.assetId === selectedMarket.value?.id),
|
||||
)
|
||||
|
||||
function t(key: string): string {
|
||||
return phone.t(`Apps.crypto.${key}`)
|
||||
}
|
||||
|
||||
function money(value: string | number): string {
|
||||
return new Intl.NumberFormat(locale.value, {
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 2,
|
||||
style: 'currency',
|
||||
}).format(Number(value) || 0)
|
||||
}
|
||||
|
||||
function quantity(value: string): string {
|
||||
return new Intl.NumberFormat(locale.value, {
|
||||
maximumFractionDigits: 6,
|
||||
}).format(Number(value) || 0)
|
||||
}
|
||||
|
||||
function errorText(code: string): string {
|
||||
const key = `errors.${code}`
|
||||
const translated = t(key)
|
||||
return translated === `Apps.crypto.${key}` ? t('errors.default') : translated
|
||||
}
|
||||
|
||||
async function submitAuth(): Promise<void> {
|
||||
formError.value = ''
|
||||
const success =
|
||||
authMode.value === 'register'
|
||||
? await crypto.register(handle.value.trim(), password.value)
|
||||
: await crypto.login(password.value)
|
||||
if (!success) formError.value = errorText(crypto.error)
|
||||
else password.value = ''
|
||||
}
|
||||
|
||||
function openSettlement(mode: 'deposit' | 'withdraw'): void {
|
||||
sheetMode.value = mode
|
||||
amount.value = ''
|
||||
financialPassword.value = ''
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function openTrade(market: CryptoMarket, nextSide: CryptoSide = 'buy'): void {
|
||||
selectedMarket.value = market
|
||||
side.value = nextSide
|
||||
amount.value = ''
|
||||
formError.value = ''
|
||||
crypto.pendingQuote = null
|
||||
sheetMode.value = 'trade'
|
||||
}
|
||||
|
||||
function closeSheet(): void {
|
||||
sheetMode.value = null
|
||||
crypto.pendingQuote = null
|
||||
}
|
||||
|
||||
async function submitSettlement(): Promise<void> {
|
||||
if (sheetMode.value !== 'deposit' && sheetMode.value !== 'withdraw') return
|
||||
const normalized = amount.value.trim()
|
||||
if (!/^\d+$/.test(normalized) || Number(normalized) <= 0) {
|
||||
formError.value = t('errors.invalid_amount')
|
||||
return
|
||||
}
|
||||
const success = await crypto.settle(
|
||||
sheetMode.value,
|
||||
normalized,
|
||||
financialPassword.value,
|
||||
)
|
||||
if (!success) formError.value = errorText(crypto.error)
|
||||
else closeSheet()
|
||||
}
|
||||
|
||||
async function requestQuote(): Promise<void> {
|
||||
if (!selectedMarket.value || !/^\d+(?:\.\d{1,6})?$/.test(amount.value)) {
|
||||
formError.value = t('errors.invalid_quantity')
|
||||
return
|
||||
}
|
||||
const response = await crypto.quote(
|
||||
selectedMarket.value.id,
|
||||
side.value,
|
||||
amount.value,
|
||||
)
|
||||
if (!response.success) formError.value = errorText(crypto.error)
|
||||
}
|
||||
|
||||
async function executeQuote(): Promise<void> {
|
||||
const success = await crypto.executeQuote()
|
||||
if (!success) formError.value = errorText(crypto.error)
|
||||
else closeSheet()
|
||||
}
|
||||
|
||||
function activityTitle(type: string): string {
|
||||
return t(`activityTypes.${type}`)
|
||||
}
|
||||
|
||||
onMounted(() => void crypto.load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SkyAppPage
|
||||
class="crypto-app"
|
||||
accent="#20d69b"
|
||||
accent-soft="rgba(32, 214, 155, 0.18)"
|
||||
dark
|
||||
>
|
||||
<SkyNavbar
|
||||
:title="t('name')"
|
||||
:subtitle="
|
||||
authenticated ? `@${crypto.data?.profile?.handle}` : t('subtitle')
|
||||
"
|
||||
large
|
||||
>
|
||||
<template v-if="authenticated" #right>
|
||||
<SkyLink :aria-label="t('logout')" @click="crypto.logout()">
|
||||
<LogOut :size="18" />
|
||||
</SkyLink>
|
||||
</template>
|
||||
</SkyNavbar>
|
||||
|
||||
<SkyScrollArea
|
||||
v-if="crypto.isLoading && !crypto.data"
|
||||
class="crypto-state"
|
||||
padded
|
||||
>
|
||||
<SkySpinner />
|
||||
<p>{{ t('loading') }}</p>
|
||||
</SkyScrollArea>
|
||||
|
||||
<SkyScrollArea v-else-if="!authenticated" class="crypto-auth" padded>
|
||||
<div class="crypto-auth__hero">
|
||||
<span class="crypto-auth__mark"><ChartCandlestick :size="32" /></span>
|
||||
<p class="crypto-eyebrow">{{ t('auth.eyebrow') }}</p>
|
||||
<h2>
|
||||
{{
|
||||
authMode === 'login'
|
||||
? t('auth.loginTitle')
|
||||
: t('auth.registerTitle')
|
||||
}}
|
||||
</h2>
|
||||
<p>{{ t('auth.body') }}</p>
|
||||
</div>
|
||||
<SkySegmented>
|
||||
<SkySegmentedButton
|
||||
:active="authMode === 'login'"
|
||||
@click="authMode = 'login'"
|
||||
>{{ t('auth.login') }}</SkySegmentedButton
|
||||
>
|
||||
<SkySegmentedButton
|
||||
:active="authMode === 'register'"
|
||||
@click="authMode = 'register'"
|
||||
>{{ t('auth.register') }}</SkySegmentedButton
|
||||
>
|
||||
</SkySegmented>
|
||||
<form class="crypto-form" @submit.prevent="submitAuth">
|
||||
<SkyField
|
||||
v-if="authMode === 'register'"
|
||||
v-model="handle"
|
||||
:label="t('auth.handle')"
|
||||
:placeholder="t('auth.handlePlaceholder')"
|
||||
maxlength="20"
|
||||
outline
|
||||
/>
|
||||
<SkyField
|
||||
v-model="password"
|
||||
:label="t('auth.password')"
|
||||
:placeholder="t('auth.passwordPlaceholder')"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
maxlength="72"
|
||||
outline
|
||||
>
|
||||
<template #leading><LockKeyhole :size="18" /></template>
|
||||
<template #trailing>
|
||||
<button
|
||||
class="crypto-visibility"
|
||||
type="button"
|
||||
:aria-label="
|
||||
t(showPassword ? 'auth.hidePassword' : 'auth.showPassword')
|
||||
"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<EyeOff v-if="showPassword" :size="18" /><Eye v-else :size="18" />
|
||||
</button>
|
||||
</template>
|
||||
</SkyField>
|
||||
<p class="crypto-form__hint">
|
||||
<ShieldCheck :size="15" />{{ t('auth.security') }}
|
||||
</p>
|
||||
<p v-if="formError" class="crypto-error" role="alert">
|
||||
{{ formError }}
|
||||
</p>
|
||||
<SkyButton type="submit" :disabled="crypto.isLoading" block>{{
|
||||
t(authMode === 'login' ? 'auth.loginAction' : 'auth.registerAction')
|
||||
}}</SkyButton>
|
||||
</form>
|
||||
</SkyScrollArea>
|
||||
|
||||
<SkyScrollArea v-else with-tabbar padded>
|
||||
<template v-if="activeTab === 'portfolio'">
|
||||
<SkyCard class="crypto-balance">
|
||||
<p>{{ t('portfolio.total') }}</p>
|
||||
<strong>{{ money(crypto.data?.portfolioValue ?? '0') }}</strong>
|
||||
<span
|
||||
>{{ t('portfolio.cash') }} ·
|
||||
{{ money(crypto.data?.cashBalance ?? '0') }}</span
|
||||
>
|
||||
<div class="crypto-balance__actions">
|
||||
<SkyButton @click="openSettlement('deposit')"
|
||||
><ArrowDownToLine :size="17" />{{
|
||||
t('actions.deposit')
|
||||
}}</SkyButton
|
||||
>
|
||||
<SkyButton variant="secondary" @click="openSettlement('withdraw')"
|
||||
><ArrowUpFromLine :size="17" />{{
|
||||
t('actions.withdraw')
|
||||
}}</SkyButton
|
||||
>
|
||||
</div>
|
||||
</SkyCard>
|
||||
<div class="crypto-section-title">
|
||||
<h2>{{ t('portfolio.holdings') }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('refresh')"
|
||||
@click="crypto.load()"
|
||||
>
|
||||
<RefreshCw :size="17" />
|
||||
</button>
|
||||
</div>
|
||||
<SkyEmptyState
|
||||
v-if="!holdings.length"
|
||||
:title="t('portfolio.empty')"
|
||||
:description="t('portfolio.emptyBody')"
|
||||
/>
|
||||
<button
|
||||
v-for="holding in holdings"
|
||||
v-else
|
||||
:key="holding.assetId"
|
||||
class="crypto-row"
|
||||
type="button"
|
||||
@click="
|
||||
openTrade(
|
||||
markets.find((market) => market.id === holding.assetId)!,
|
||||
'sell',
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="crypto-asset-dot"
|
||||
:style="{
|
||||
background: markets.find(
|
||||
(market) => market.id === holding.assetId,
|
||||
)?.color,
|
||||
}"
|
||||
>{{
|
||||
markets
|
||||
.find((market) => market.id === holding.assetId)
|
||||
?.symbol.slice(0, 1)
|
||||
}}</span
|
||||
>
|
||||
<span
|
||||
><strong>{{
|
||||
markets.find((market) => market.id === holding.assetId)?.name
|
||||
}}</strong
|
||||
><small
|
||||
>{{ quantity(holding.quantity) }}
|
||||
{{
|
||||
markets.find((market) => market.id === holding.assetId)?.symbol
|
||||
}}</small
|
||||
></span
|
||||
>
|
||||
<span class="crypto-row__value"
|
||||
><strong>{{ money(holding.value) }}</strong
|
||||
><small
|
||||
>{{ t('portfolio.avg') }} {{ money(holding.averagePrice) }}</small
|
||||
></span
|
||||
>
|
||||
<ChevronRight :size="17" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab === 'markets'">
|
||||
<SkyStatusCard
|
||||
tone="accent"
|
||||
:title="t('markets.noticeTitle')"
|
||||
:subtitle="t('markets.notice')"
|
||||
/>
|
||||
<div class="crypto-section-title">
|
||||
<h2>{{ t('markets.title') }}</h2>
|
||||
<span>{{ t('markets.live') }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="market in markets"
|
||||
:key="market.id"
|
||||
class="crypto-market"
|
||||
type="button"
|
||||
@click="openTrade(market)"
|
||||
>
|
||||
<span
|
||||
class="crypto-asset-dot"
|
||||
:style="{ background: market.color }"
|
||||
>{{ market.symbol.slice(0, 1) }}</span
|
||||
>
|
||||
<span class="crypto-market__name"
|
||||
><strong>{{ market.name }}</strong
|
||||
><small>{{ market.symbol }}</small></span
|
||||
>
|
||||
<svg class="crypto-spark" viewBox="0 0 72 28" aria-hidden="true">
|
||||
<polyline
|
||||
:points="
|
||||
market.sparkline
|
||||
.map(
|
||||
(point, index) =>
|
||||
`${index * (72 / Math.max(1, market.sparkline.length - 1))},${26 - point * 22}`,
|
||||
)
|
||||
.join(' ')
|
||||
"
|
||||
/>
|
||||
</svg>
|
||||
<span class="crypto-market__price"
|
||||
><strong>{{ money(market.price) }}</strong
|
||||
><small :class="market.changePercent >= 0 ? 'is-up' : 'is-down'"
|
||||
>{{ market.changePercent >= 0 ? '+' : ''
|
||||
}}{{ market.changePercent.toFixed(2) }}%</small
|
||||
></span
|
||||
>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<h2 class="crypto-page-title">{{ t('activity.title') }}</h2>
|
||||
<SkyEmptyState
|
||||
v-if="!activity.length"
|
||||
:title="t('activity.empty')"
|
||||
:description="t('activity.emptyBody')"
|
||||
/>
|
||||
<div
|
||||
v-for="item in activity"
|
||||
v-else
|
||||
:key="item.id"
|
||||
class="crypto-row crypto-row--static"
|
||||
>
|
||||
<span class="crypto-activity-icon"
|
||||
><ArrowLeftRight :size="18"
|
||||
/></span>
|
||||
<span
|
||||
><strong>{{ activityTitle(item.type) }}</strong
|
||||
><small>{{
|
||||
new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(item.createdAt)
|
||||
}}</small></span
|
||||
>
|
||||
<span class="crypto-row__value"
|
||||
><strong
|
||||
>{{ item.type === 'buy' || item.type === 'withdrawal' ? '−' : '+'
|
||||
}}{{ money(item.amount) }}</strong
|
||||
><small>{{ t(`statuses.${item.status}`) }}</small></span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</SkyScrollArea>
|
||||
|
||||
<SkyPillNavigation
|
||||
v-if="authenticated"
|
||||
layout="full"
|
||||
:label="t('navigation')"
|
||||
>
|
||||
<SkySegmented navigation>
|
||||
<SkySegmentedButton
|
||||
:active="activeTab === 'portfolio'"
|
||||
@click="activeTab = 'portfolio'"
|
||||
>{{ t('tabs.portfolio') }}</SkySegmentedButton
|
||||
>
|
||||
<SkySegmentedButton
|
||||
:active="activeTab === 'markets'"
|
||||
@click="activeTab = 'markets'"
|
||||
>{{ t('tabs.markets') }}</SkySegmentedButton
|
||||
>
|
||||
<SkySegmentedButton
|
||||
:active="activeTab === 'activity'"
|
||||
@click="activeTab = 'activity'"
|
||||
>{{ t('tabs.activity') }}</SkySegmentedButton
|
||||
>
|
||||
</SkySegmented>
|
||||
</SkyPillNavigation>
|
||||
|
||||
<SkySheet
|
||||
:opened="sheetMode !== null"
|
||||
swipe-to-close
|
||||
@backdropclick="closeSheet"
|
||||
@escape="closeSheet"
|
||||
@swipeclose="closeSheet"
|
||||
>
|
||||
<div v-if="sheetMode" class="crypto-sheet">
|
||||
<div class="crypto-sheet__handle" />
|
||||
<h2 v-if="sheetMode === 'trade'">
|
||||
{{ side === 'buy' ? t('trade.buy') : t('trade.sell') }}
|
||||
{{ selectedMarket?.symbol }}
|
||||
</h2>
|
||||
<h2 v-else>{{ t(`actions.${sheetMode}`) }}</h2>
|
||||
<template v-if="sheetMode === 'trade' && selectedMarket">
|
||||
<p class="crypto-sheet__market">
|
||||
{{ selectedMarket.name }}
|
||||
<strong>{{ money(selectedMarket.price) }}</strong>
|
||||
</p>
|
||||
<SkySegmented>
|
||||
<SkySegmentedButton
|
||||
:active="side === 'buy'"
|
||||
@click="side = 'buy'"
|
||||
>{{ t('trade.buy') }}</SkySegmentedButton
|
||||
>
|
||||
<SkySegmentedButton
|
||||
:active="side === 'sell'"
|
||||
@click="side = 'sell'"
|
||||
>{{ t('trade.sell') }}</SkySegmentedButton
|
||||
>
|
||||
</SkySegmented>
|
||||
<SkyField
|
||||
v-model="amount"
|
||||
:label="t('trade.quantity')"
|
||||
:placeholder="'0.000000'"
|
||||
inputmode="decimal"
|
||||
outline
|
||||
/>
|
||||
<p
|
||||
v-if="side === 'sell' && selectedHolding"
|
||||
class="crypto-form__hint"
|
||||
>
|
||||
{{ t('trade.available') }} {{ quantity(selectedHolding.quantity) }}
|
||||
{{ selectedMarket.symbol }}
|
||||
</p>
|
||||
<SkyCard v-if="crypto.pendingQuote" class="crypto-quote">
|
||||
<div>
|
||||
<span>{{ t('trade.price') }}</span
|
||||
><strong>{{ money(crypto.pendingQuote.price) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('trade.gross') }}</span
|
||||
><strong>{{ money(crypto.pendingQuote.gross) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ t('trade.fee') }}</span
|
||||
><strong>{{ money(crypto.pendingQuote.fee) }}</strong>
|
||||
</div>
|
||||
<div class="crypto-quote__total">
|
||||
<span>{{ t('trade.total') }}</span
|
||||
><strong>{{ money(crypto.pendingQuote.net) }}</strong>
|
||||
</div>
|
||||
<small>{{ t('trade.quoteExpiry') }}</small>
|
||||
</SkyCard>
|
||||
<p v-if="formError" class="crypto-error" role="alert">
|
||||
{{ formError }}
|
||||
</p>
|
||||
<SkyButton
|
||||
v-if="!crypto.pendingQuote"
|
||||
block
|
||||
:disabled="crypto.isLoading"
|
||||
@click="requestQuote"
|
||||
>{{ t('trade.getQuote') }}</SkyButton
|
||||
>
|
||||
<SkyButton
|
||||
v-else
|
||||
block
|
||||
:disabled="crypto.isLoading"
|
||||
@click="executeQuote"
|
||||
>{{ t('trade.confirm') }}</SkyButton
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
sheetMode === 'deposit'
|
||||
? 'settlement.depositBody'
|
||||
: 'settlement.withdrawBody',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<SkyField
|
||||
v-model="amount"
|
||||
:label="t('settlement.amount')"
|
||||
placeholder="0"
|
||||
inputmode="numeric"
|
||||
outline
|
||||
/>
|
||||
<SkyField
|
||||
v-model="financialPassword"
|
||||
:label="t('auth.password')"
|
||||
type="password"
|
||||
outline
|
||||
/>
|
||||
<p v-if="formError" class="crypto-error" role="alert">
|
||||
{{ formError }}
|
||||
</p>
|
||||
<SkyButton
|
||||
block
|
||||
:disabled="crypto.isLoading"
|
||||
@click="submitSettlement"
|
||||
>{{ t('settlement.confirm') }}</SkyButton
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</SkySheet>
|
||||
</SkyAppPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crypto-app {
|
||||
--crypto-muted: rgba(220, 235, 244, 0.62);
|
||||
color: #f7fbff;
|
||||
background: #07151d;
|
||||
}
|
||||
.crypto-state,
|
||||
.crypto-auth {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 18px;
|
||||
min-height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.crypto-auth__hero {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.crypto-auth__hero h2,
|
||||
.crypto-auth__hero p {
|
||||
margin: 0;
|
||||
}
|
||||
.crypto-auth__hero > p:last-child {
|
||||
max-width: 280px;
|
||||
color: var(--crypto-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.crypto-auth__mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
margin-bottom: 5px;
|
||||
border-radius: 21px;
|
||||
color: #07151d;
|
||||
background: linear-gradient(145deg, #67f5c8, #11bbeb);
|
||||
box-shadow: 0 16px 45px rgba(32, 214, 155, 0.25);
|
||||
}
|
||||
.crypto-eyebrow {
|
||||
color: #49e4b2 !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.crypto-form {
|
||||
display: grid;
|
||||
gap: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
.crypto-form__hint {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
margin: -3px 2px 0;
|
||||
color: var(--crypto-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.crypto-visibility {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
.crypto-error {
|
||||
margin: 0;
|
||||
color: #ff8d98;
|
||||
font-size: 12px;
|
||||
}
|
||||
.crypto-balance {
|
||||
margin-bottom: 22px;
|
||||
padding: 21px;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(25, 71, 81, 0.96),
|
||||
rgba(10, 37, 48, 0.96)
|
||||
);
|
||||
}
|
||||
.crypto-balance p,
|
||||
.crypto-balance span {
|
||||
margin: 0;
|
||||
color: var(--crypto-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.crypto-balance > strong {
|
||||
display: block;
|
||||
margin: 6px 0 2px;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.crypto-balance__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 9px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.crypto-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0 2px 10px;
|
||||
}
|
||||
.crypto-section-title h2,
|
||||
.crypto-page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.crypto-section-title button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: #fff;
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
.crypto-section-title > span {
|
||||
color: #49e4b2;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.crypto-row,
|
||||
.crypto-market {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr) auto 18px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 67px;
|
||||
padding: 10px 12px;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: rgba(15, 40, 50, 0.86);
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.crypto-row:first-of-type,
|
||||
.crypto-market:first-of-type {
|
||||
border-radius: var(--sky-radius-card) var(--sky-radius-card) 0 0;
|
||||
}
|
||||
.crypto-row:last-of-type,
|
||||
.crypto-market:last-of-type {
|
||||
border-bottom: 0;
|
||||
border-radius: 0 0 var(--sky-radius-card) var(--sky-radius-card);
|
||||
}
|
||||
.crypto-row span,
|
||||
.crypto-market span {
|
||||
min-width: 0;
|
||||
}
|
||||
.crypto-row strong,
|
||||
.crypto-row small,
|
||||
.crypto-market strong,
|
||||
.crypto-market small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.crypto-row small,
|
||||
.crypto-market small {
|
||||
margin-top: 3px;
|
||||
color: var(--crypto-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.crypto-row__value,
|
||||
.crypto-market__price {
|
||||
text-align: right;
|
||||
}
|
||||
.crypto-asset-dot,
|
||||
.crypto-activity-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 13px;
|
||||
color: #fff;
|
||||
font-weight: 900;
|
||||
box-shadow: inset 0 1px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.crypto-activity-icon {
|
||||
color: #49e4b2;
|
||||
background: rgba(73, 228, 178, 0.12);
|
||||
}
|
||||
.crypto-market {
|
||||
grid-template-columns: 42px minmax(64px, 1fr) 72px auto;
|
||||
}
|
||||
.crypto-spark {
|
||||
width: 72px;
|
||||
height: 28px;
|
||||
overflow: visible;
|
||||
}
|
||||
.crypto-spark polyline {
|
||||
fill: none;
|
||||
stroke: #49e4b2;
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.is-up {
|
||||
color: #49e4b2 !important;
|
||||
}
|
||||
.is-down {
|
||||
color: #ff7d8a !important;
|
||||
}
|
||||
.crypto-row--static {
|
||||
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||
}
|
||||
.crypto-sheet {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 4px 18px calc(var(--sky-safe-area-bottom) + 20px);
|
||||
color: #f7fbff;
|
||||
}
|
||||
.crypto-sheet__handle {
|
||||
width: 38px;
|
||||
height: 5px;
|
||||
margin: 3px auto 2px;
|
||||
border-radius: 9px;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
.crypto-sheet h2,
|
||||
.crypto-sheet p {
|
||||
margin: 0;
|
||||
}
|
||||
.crypto-sheet__market {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--crypto-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.crypto-quote {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
.crypto-quote div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--crypto-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.crypto-quote strong {
|
||||
color: #fff;
|
||||
}
|
||||
.crypto-quote__total {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 14px !important;
|
||||
}
|
||||
.crypto-quote small {
|
||||
color: var(--crypto-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.crypto-auth__mark {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -197,6 +197,90 @@ async function fetchYoutubeMetadata(videoId) {
|
||||
}
|
||||
}
|
||||
let mockBankBalance = 24787
|
||||
let cryptoAuthenticated = true
|
||||
let cryptoCashBalance = 18420
|
||||
let cryptoQuote = null
|
||||
let nextCryptoActivityId = 5
|
||||
const cryptoPassword = 'VaultX123!'
|
||||
const cryptoMarkets = [
|
||||
{
|
||||
id: 'aurora',
|
||||
symbol: 'AUR',
|
||||
name: 'Aurora',
|
||||
color: '#25d9ad',
|
||||
price: '128.50',
|
||||
changePercent: 4.82,
|
||||
enabled: true,
|
||||
sparkline: [0.12, 0.24, 0.2, 0.38, 0.51, 0.46, 0.68, 0.62, 0.81, 0.92],
|
||||
},
|
||||
{
|
||||
id: 'vertex',
|
||||
symbol: 'VTX',
|
||||
name: 'Vertex',
|
||||
color: '#4d8cff',
|
||||
price: '42.75',
|
||||
changePercent: -1.37,
|
||||
enabled: true,
|
||||
sparkline: [0.84, 0.72, 0.76, 0.61, 0.69, 0.52, 0.46, 0.41, 0.35, 0.39],
|
||||
},
|
||||
{
|
||||
id: 'ember',
|
||||
symbol: 'EMB',
|
||||
name: 'Ember',
|
||||
color: '#ff9d54',
|
||||
price: '9.80',
|
||||
changePercent: 7.21,
|
||||
enabled: true,
|
||||
sparkline: [0.08, 0.11, 0.18, 0.26, 0.23, 0.4, 0.55, 0.64, 0.79, 0.93],
|
||||
},
|
||||
]
|
||||
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 +6531,23 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
playerName: 'Alex Morgan',
|
||||
transactions: mockBankTransactions,
|
||||
})
|
||||
const cryptoOverview = () => ({
|
||||
activity: cryptoActivity,
|
||||
authenticated: cryptoAuthenticated,
|
||||
cashBalance: String(cryptoCashBalance),
|
||||
holdings: cryptoHoldings,
|
||||
markets: cryptoMarkets,
|
||||
portfolioValue: String(
|
||||
cryptoCashBalance +
|
||||
cryptoHoldings.reduce(
|
||||
(total, holding) => total + Number(holding.value),
|
||||
0,
|
||||
),
|
||||
),
|
||||
profile: cryptoAuthenticated
|
||||
? { handle: 'skyline', id: 'crypto-profile-demo', status: 'active' }
|
||||
: null,
|
||||
})
|
||||
const billingInvoice = (invoice) => ({
|
||||
...invoice,
|
||||
canDispute: invoice.direction === 'inbox' && invoice.status === 'open',
|
||||
@@ -7918,6 +8019,169 @@ 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: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
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
...cryptoOverview(),
|
||||
profile: { handle, id: 'crypto-profile-new', status: 'active' },
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:logout') {
|
||||
cryptoAuthenticated = false
|
||||
cryptoQuote = null
|
||||
response.json({ success: true })
|
||||
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 +8874,12 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
return
|
||||
}
|
||||
if (endpoint === 'development:bootstrap') {
|
||||
crewLinkAuthenticated = ![
|
||||
'crewlink-login',
|
||||
'crewlink-register',
|
||||
].includes(testScenario)
|
||||
cryptoAuthenticated = !['crypto-login', 'crypto-register'].includes(
|
||||
testScenario,
|
||||
)
|
||||
crewLinkAuthenticated = !['crewlink-login', 'crewlink-register'].includes(
|
||||
testScenario,
|
||||
)
|
||||
authenticated = testScenario !== 'setup-account-unlinked'
|
||||
linkedAccount = authenticated
|
||||
? {
|
||||
|
||||
@@ -7,6 +7,8 @@ const browserDataRequests = [
|
||||
['development:bootstrap', {}],
|
||||
['account:devices', {}],
|
||||
['banking:overview', {}],
|
||||
['crypto:bootstrap', {}],
|
||||
['crypto:quote', { marketId: 'aurora', quantity: '1', side: 'buy' }],
|
||||
['health:overview', {}],
|
||||
['billing:overview', {}],
|
||||
['billing:list', { filter: 'all', limit: 20, offset: 0 }],
|
||||
|
||||
@@ -648,6 +648,73 @@ Config.SkyRide = {
|
||||
},
|
||||
}
|
||||
|
||||
Config.Crypto = {
|
||||
Enabled = true,
|
||||
Currency = "$",
|
||||
BankAccount = "bank",
|
||||
AssetScale = 1000000,
|
||||
PriceScale = 100,
|
||||
QuoteLifetimeSeconds = 8,
|
||||
PriceTickSeconds = 60,
|
||||
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,
|
||||
DailyDepositLimit = 500000,
|
||||
DailyWithdrawalLimit = 250000,
|
||||
DailyTradeLimit = 500000,
|
||||
ActionsPerMinute = 30,
|
||||
LoginAttempts = 5,
|
||||
LockoutSeconds = 5 * 60,
|
||||
TreasuryCash = 5000000,
|
||||
Markets = {
|
||||
{
|
||||
Id = "aurora",
|
||||
Symbol = "AUR",
|
||||
Name = "Aurora",
|
||||
Color = "#25d9ad",
|
||||
InitialPrice = 12850,
|
||||
MinimumPrice = 3000,
|
||||
MaximumPrice = 80000,
|
||||
VolatilityBasisPoints = 180,
|
||||
IssuedSupply = 1000000,
|
||||
TreasuryInventory = 850000,
|
||||
},
|
||||
{
|
||||
Id = "vertex",
|
||||
Symbol = "VTX",
|
||||
Name = "Vertex",
|
||||
Color = "#4d8cff",
|
||||
InitialPrice = 4275,
|
||||
MinimumPrice = 800,
|
||||
MaximumPrice = 30000,
|
||||
VolatilityBasisPoints = 260,
|
||||
IssuedSupply = 2500000,
|
||||
TreasuryInventory = 2100000,
|
||||
},
|
||||
{
|
||||
Id = "ember",
|
||||
Symbol = "EMB",
|
||||
Name = "Ember",
|
||||
Color = "#ff9d54",
|
||||
InitialPrice = 980,
|
||||
MinimumPrice = 100,
|
||||
MaximumPrice = 12000,
|
||||
VolatilityBasisPoints = 340,
|
||||
IssuedSupply = 8000000,
|
||||
TreasuryInventory = 6800000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- =============================================================================
|
||||
-- Server-only configuration
|
||||
-- =============================================================================
|
||||
|
||||
@@ -1049,6 +1049,22 @@ 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 fiktive digitale Assets mit dem Ingame-Geld deines Charakters.", login = "Einloggen", register = "Registrieren", handle = "VaultX-Benutzername", handlePlaceholder = "3–20 Buchstaben oder Zahlen", password = "Passwort", passwordPlaceholder = "8–72 Zeichen", showPassword = "Passwort anzeigen", hidePassword = "Passwort ausblenden", security = "Nutze ein In-Character-Passwort. Verwende niemals ein echtes Passwort erneut.", loginAction = "Portfolio öffnen", registerAction = "Sicheres Profil erstellen",
|
||||
},
|
||||
tabs = { portfolio = "Portfolio", markets = "Märkte", activity = "Aktivität" },
|
||||
portfolio = { total = "Gesamtes Portfolio", cash = "Verfügbares Guthaben", holdings = "Deine Assets", empty = "Noch keine Assets", emptyBody = "Öffne Märkte und fordere ein geschütztes Angebot an.", avg = "Ø" },
|
||||
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." },
|
||||
trade = { buy = "Kaufen", sell = "Verkaufen", quantity = "Menge", available = "Verfügbar:", 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" },
|
||||
activity = { title = "Finanzaktivität", empty = "Keine Aktivität", emptyBody = "Einzahlungen, Auszahlungen und Handel erscheinen hier." },
|
||||
activityTypes = { buy = "Asset-Kauf", sell = "Asset-Verkauf", deposit = "Bankeinzahlung", withdrawal = "Bankauszahlung" },
|
||||
statuses = { completed = "Abgeschlossen", pending = "Prüfung ausstehend", failed = "Fehlgeschlagen", manual_review = "Manuelle Prüfung" },
|
||||
errors = { invalid_handle = "Nutze 3–20 Buchstaben, Zahlen, Punkte oder Unterstriche.", handle_taken = "Dieser VaultX-Benutzername ist bereits vergeben.", profile_exists = "Dieser Charakter besitzt bereits ein VaultX-Profil.", invalid_password = "Das Passwort muss 8–72 Zeichen lang sein.", 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.", insufficient_funds = "Dein verfügbares Guthaben reicht nicht aus.", insufficient_liquidity = "Die Börse kann diese Menge derzeit nicht ausführen.", quote_expired = "Das Angebot ist abgelaufen. Fordere ein neues an.", quote_unavailable = "Das Angebot ist nicht mehr verfügbar.", market_unavailable = "Dieser Markt ist vorübergehend pausiert.", limit_exceeded = "Die Anfrage überschreitet ein Börsenlimit.", duplicate_request = "Diese Anfrage wurde bereits verarbeitet.", rate_limited = "Zu viele Anfragen. Versuche es gleich erneut.", settlement_pending = "Eine Geldbewegung wartet bereits auf Prüfung.", service_unavailable = "VaultX ist vorübergehend nicht verfügbar.", request_failed = "Die sichere Börsenanfrage ist fehlgeschlagen.", default = "VaultX konnte die Anfrage nicht abschließen." },
|
||||
},
|
||||
banking = {
|
||||
name = "Banking", welcome = "Willkommen zurück", totalBalance = "Gesamtsaldo", recentPeriod = "in jüngster Zeit",
|
||||
actions = "Bankgeschäfte", send = "Senden",
|
||||
|
||||
@@ -1049,6 +1049,22 @@ 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 fictional digital assets with your character's in-game funds.", login = "Log in", register = "Register", handle = "VaultX handle", handlePlaceholder = "3–20 letters or numbers", password = "Password", passwordPlaceholder = "8–72 characters", showPassword = "Show password", hidePassword = "Hide password", security = "Use an in-character password. Never reuse a real password.", loginAction = "Open portfolio", registerAction = "Create secure profile",
|
||||
},
|
||||
tabs = { portfolio = "Portfolio", markets = "Markets", activity = "Activity" },
|
||||
portfolio = { total = "Total portfolio", cash = "Available cash", holdings = "Your assets", empty = "No assets yet", emptyBody = "Open Markets to request a protected quote.", avg = "Avg." },
|
||||
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." },
|
||||
trade = { buy = "Buy", sell = "Sell", quantity = "Quantity", available = "Available:", 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" },
|
||||
activity = { title = "Financial activity", empty = "No activity", emptyBody = "Deposits, withdrawals and trades will appear here." },
|
||||
activityTypes = { buy = "Asset purchase", sell = "Asset sale", deposit = "Bank deposit", withdrawal = "Bank withdrawal" },
|
||||
statuses = { completed = "Completed", pending = "Pending review", failed = "Failed", manual_review = "Manual review" },
|
||||
errors = { invalid_handle = "Use 3–20 letters, numbers, dots or underscores.", handle_taken = "That VaultX handle is already taken.", profile_exists = "This character already owns a VaultX profile.", invalid_password = "Password must be 8–72 characters.", 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.", insufficient_funds = "Your available balance is too low.", insufficient_liquidity = "The exchange cannot fill that amount right now.", quote_expired = "This quote expired. Request a new one.", quote_unavailable = "The quote is no longer available.", market_unavailable = "This market is temporarily paused.", limit_exceeded = "This request exceeds an exchange limit.", duplicate_request = "This request was already processed.", rate_limited = "Too many requests. Try again shortly.", settlement_pending = "A money transfer is already pending review.", service_unavailable = "VaultX is temporarily unavailable.", request_failed = "The secure exchange request failed.", default = "VaultX could not complete the request." },
|
||||
},
|
||||
banking = {
|
||||
name = "Banking", welcome = "Welcome back", totalBalance = "Total Balance", recentPeriod = "in recent activity",
|
||||
actions = "Banking actions", send = "Send",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
fx_version 'cerulean'
|
||||
game 'gta5'
|
||||
lua54 'yes'
|
||||
node_version '22'
|
||||
use_experimental_fxv2_oal 'yes'
|
||||
|
||||
author 'Sky-Systems'
|
||||
@@ -49,6 +50,7 @@ client_scripts {
|
||||
|
||||
server_scripts {
|
||||
'@oxmysql/lib/MySQL.lua',
|
||||
'source/server/crypto_password.js',
|
||||
'config/config.lua',
|
||||
'config/media.lua',
|
||||
'config/locales/en.lua',
|
||||
@@ -87,6 +89,7 @@ server_scripts {
|
||||
'source/server/notes.lua',
|
||||
'source/server/mail.lua',
|
||||
'source/server/banking.lua',
|
||||
'source/server/crypto.lua',
|
||||
'source/server/health.lua',
|
||||
'source/server/billing.lua',
|
||||
'source/server/garage.lua',
|
||||
|
||||
@@ -242,6 +242,14 @@ 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",
|
||||
"billing:overview",
|
||||
"billing:list",
|
||||
"billing:detail",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
'use strict'
|
||||
|
||||
const { randomBytes, randomInt, scryptSync, timingSafeEqual } = require('node:crypto')
|
||||
|
||||
const KEY_LENGTH = 32
|
||||
const SCRYPT_COST = 32768
|
||||
const SCRYPT_BLOCK_SIZE = 8
|
||||
const SCRYPT_PARALLELISM = 1
|
||||
const MAX_MEMORY = 64 * 1024 * 1024
|
||||
|
||||
exports('CryptoHashPassword', (password) => {
|
||||
if (typeof password !== 'string' || password.length < 8 || password.length > 72) return null
|
||||
|
||||
const salt = randomBytes(16)
|
||||
const hash = scryptSync(password, salt, KEY_LENGTH, {
|
||||
N: SCRYPT_COST,
|
||||
maxmem: MAX_MEMORY,
|
||||
p: SCRYPT_PARALLELISM,
|
||||
r: SCRYPT_BLOCK_SIZE,
|
||||
})
|
||||
return [
|
||||
'scrypt', 'v=1', `N=${SCRYPT_COST}`, `r=${SCRYPT_BLOCK_SIZE}`,
|
||||
`p=${SCRYPT_PARALLELISM}`, salt.toString('base64'), hash.toString('base64'),
|
||||
].join('$')
|
||||
})
|
||||
|
||||
exports('CryptoVerifyPassword', (password, encoded) => {
|
||||
if (typeof password !== 'string' || typeof encoded !== 'string') return false
|
||||
|
||||
const parts = encoded.split('$')
|
||||
if (
|
||||
parts.length !== 7 || parts[0] !== 'scrypt' || parts[1] !== 'v=1' ||
|
||||
parts[2] !== `N=${SCRYPT_COST}` || parts[3] !== `r=${SCRYPT_BLOCK_SIZE}` ||
|
||||
parts[4] !== `p=${SCRYPT_PARALLELISM}`
|
||||
) return false
|
||||
|
||||
try {
|
||||
const salt = Buffer.from(parts[5], 'base64')
|
||||
const expected = Buffer.from(parts[6], 'base64')
|
||||
if (salt.length !== 16 || expected.length !== KEY_LENGTH) return false
|
||||
const actual = scryptSync(password, salt, KEY_LENGTH, {
|
||||
N: SCRYPT_COST,
|
||||
maxmem: MAX_MEMORY,
|
||||
p: SCRYPT_PARALLELISM,
|
||||
r: SCRYPT_BLOCK_SIZE,
|
||||
})
|
||||
return timingSafeEqual(actual, expected)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
exports('CryptoRandomInt', (minimum, maximum) => {
|
||||
if (!Number.isSafeInteger(minimum) || !Number.isSafeInteger(maximum) || minimum >= maximum) {
|
||||
return null
|
||||
}
|
||||
return randomInt(minimum, maximum)
|
||||
})
|
||||
@@ -37,6 +37,7 @@ local ALLOWED_PERMISSIONS = {
|
||||
local RESERVED_APP_IDS = {
|
||||
["app-store"] = true,
|
||||
banking = true,
|
||||
crypto = true,
|
||||
billing = true,
|
||||
calculator = true,
|
||||
calendar = true,
|
||||
|
||||
@@ -1344,3 +1344,139 @@ 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,
|
||||
`status` ENUM('active','frozen','closed') NOT NULL DEFAULT 'active',
|
||||
`failed_logins` TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`locked_until` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_sky_phone_crypto_owner` (`owner_identifier`),
|
||||
UNIQUE KEY `uniq_sky_phone_crypto_handle` (`handle`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_markets` (
|
||||
`id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`asset_scale` BIGINT UNSIGNED NOT NULL,
|
||||
`price_scale` BIGINT UNSIGNED NOT NULL,
|
||||
`issued_supply` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`price` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`version` BIGINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`status` ENUM('active','halted','stale') NOT NULL DEFAULT 'active',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_market_ticks` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`version` BIGINT UNSIGNED NOT NULL,
|
||||
`price` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_sky_phone_crypto_tick` (`market_id`,`version`),
|
||||
KEY `idx_sky_phone_crypto_ticks` (`market_id`,`created_at`,`id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_balances` (
|
||||
`account_id` VARCHAR(48) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`asset_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`available` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`locked` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`version` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`account_id`,`asset_id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_operations` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`type` ENUM('buy','sell','deposit','withdrawal') NOT NULL,
|
||||
`idempotency_key` VARCHAR(96) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`request_hash` CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`status` ENUM('prepared','external_pending','external_applied','ledger_applied','completed','failed','compensation_pending','manual_review','cancelled') NOT NULL,
|
||||
`amount` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`detail` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_sky_phone_crypto_operation` (`profile_id`,`type`,`idempotency_key`),
|
||||
KEY `idx_sky_phone_crypto_activity` (`profile_id`,`created_at`,`id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_ledger_entries` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`account_id` VARCHAR(48) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`asset_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`delta` DECIMAL(36,0) NOT NULL,
|
||||
`balance_after` DECIMAL(36,0) NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_crypto_ledger_operation` (`operation_id`,`id`),
|
||||
KEY `idx_sky_phone_crypto_ledger_account` (`account_id`,`created_at`,`id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_quotes` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`side` ENUM('buy','sell') NOT NULL,
|
||||
`quantity` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`price` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`gross` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`fee` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`net` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`market_version` BIGINT UNSIGNED NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`consumed_operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_crypto_quote_profile` (`profile_id`,`expires_at`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_fills` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`quote_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`side` ENUM('buy','sell') NOT NULL,
|
||||
`quantity` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`price` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`gross` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`fee` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`net` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_sky_phone_crypto_fill_quote` (`quote_id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_settlements` (
|
||||
`operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`framework_account` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`amount` DECIMAL(36,0) UNSIGNED NOT NULL,
|
||||
`state` ENUM('prepared','external_pending','external_applied','ledger_applied','completed','failed','compensation_pending','manual_review','cancelled') NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`operation_id`),
|
||||
KEY `idx_sky_phone_crypto_settlement_state` (`state`,`updated_at`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crypto_audit_events` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`event_type` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`detail` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_crypto_audit` (`profile_id`,`created_at`,`id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
Reference in New Issue
Block a user