From 7e2bd38599f621c8995e86c24a9615ba6a7640dd Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:47:05 +0200 Subject: [PATCH 01/46] 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. --- frontend/src/assets/img/app-icons/crypto.webp | Bin 0 -> 20330 bytes frontend/src/config/apps.test.ts | 7 + frontend/src/config/apps.ts | 16 + frontend/src/stores/crypto.test.ts | 87 ++ frontend/src/stores/crypto.ts | 99 ++ frontend/src/stores/phone.ts | 105 ++ frontend/src/types/apps.ts | 1 + frontend/src/types/crypto.ts | 56 + frontend/src/utils/preferences.ts | 1 + .../src/views/apps/CryptoApp.contract.test.ts | 71 ++ frontend/src/views/apps/CryptoApp.vue | 831 +++++++++++++ frontend/testserver/index.cjs | 274 ++++- frontend/testserver/smoke.cjs | 2 + sky_phone/config/config.lua | 67 ++ sky_phone/config/locales/de.lua | 16 + sky_phone/config/locales/en.lua | 16 + sky_phone/fxmanifest.lua | 3 + sky_phone/source/client/main.lua | 8 + sky_phone/source/server/crypto.lua | 1066 +++++++++++++++++ sky_phone/source/server/crypto_password.js | 58 + sky_phone/source/shared/custom_apps.lua | 1 + sky_phone/sql/install.sql | 136 +++ 22 files changed, 2917 insertions(+), 4 deletions(-) create mode 100644 frontend/src/assets/img/app-icons/crypto.webp create mode 100644 frontend/src/stores/crypto.test.ts create mode 100644 frontend/src/stores/crypto.ts create mode 100644 frontend/src/types/crypto.ts create mode 100644 frontend/src/views/apps/CryptoApp.contract.test.ts create mode 100644 frontend/src/views/apps/CryptoApp.vue create mode 100644 sky_phone/source/server/crypto.lua create mode 100644 sky_phone/source/server/crypto_password.js diff --git a/frontend/src/assets/img/app-icons/crypto.webp b/frontend/src/assets/img/app-icons/crypto.webp new file mode 100644 index 0000000000000000000000000000000000000000..0cb312955ce08dfede4cc37937d211ae78aa1ff3 GIT binary patch literal 20330 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 { route: '/apps/health', }) expect(isPhoneAppId('health')).toBe(true) + expect(PHONE_APPS.find((app) => app.id === 'crypto')).toMatchObject({ + category: 'utilities', + gridOrder: 7, + labelKey: 'Apps.crypto.name', + route: '/apps/crypto', + }) + expect(isPhoneAppId('crypto')).toBe(true) expect(PHONE_APPS.find((app) => app.id === 'banking')).toMatchObject({ gridOrder: 5, labelKey: 'Apps.banking.name', diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 36e622b..6dbeeae 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -36,6 +36,7 @@ import { Building2, Newspaper, HeartPulse, + ChartCandlestick, } from 'lucide-vue-next' import { defineAsyncComponent, markRaw, shallowReactive } from 'vue' @@ -64,6 +65,7 @@ import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp' import weatherIcon from '@/assets/img/app-icons/weather.webp' import healthIcon from '@/assets/img/app-icons/health.webp' import bankingIcon from '@/assets/img/app-icons/banking.webp' +import cryptoIcon from '@/assets/img/app-icons/crypto.webp' import billingIcon from '@/assets/img/app-icons/billing.svg' import garageIcon from '@/assets/img/app-icons/garage.webp' import houseIcon from '@/assets/img/app-icons/house.svg' @@ -89,6 +91,20 @@ import type { } from '@/types/apps' export const PHONE_APPS = shallowReactive([ + { + category: 'utilities', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/CryptoApp.vue')), + ), + dockOrder: null, + gridOrder: 7, + icon: markRaw(ChartCandlestick), + iconClass: 'app-icon--crypto', + iconImage: cryptoIcon, + id: 'crypto', + labelKey: 'Apps.crypto.name', + route: '/apps/crypto', + }, { category: 'utilities', component: markRaw( diff --git a/frontend/src/stores/crypto.test.ts b/frontend/src/stores/crypto.test.ts new file mode 100644 index 0000000..07b1987 --- /dev/null +++ b/frontend/src/stores/crypto.test.ts @@ -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') + }) +}) diff --git a/frontend/src/stores/crypto.ts b/frontend/src/stores/crypto.ts new file mode 100644 index 0000000..19e0f22 --- /dev/null +++ b/frontend/src/stores/crypto.ts @@ -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(endpoint: string, payload: Record = {}) { + this.isLoading = true + this.error = '' + const response = await nuiCall(`crypto:${endpoint}`, payload).finally( + () => { + this.isLoading = false + }, + ) + if (!response.success) this.error = response.error ?? 'request_failed' + return response + }, + async load(): Promise { + const response = await this.call('bootstrap') + if (!response.success || !response.data) return false + this.data = response.data + return true + }, + async register(handle: string, password: string): Promise { + const response = await this.call('register', { + handle, + password, + }) + if (!response.success || !response.data) return false + this.data = response.data + return true + }, + async login(password: string): Promise { + const response = await this.call('login', { password }) + if (!response.success || !response.data) return false + this.data = response.data + return true + }, + async logout(): Promise { + const response = await this.call('logout') + if (response.success) { + this.data = this.data + ? { ...this.data, authenticated: false, profile: null } + : null + this.pendingQuote = null + } + }, + async settle( + kind: 'deposit' | 'withdraw', + amount: string, + password: string, + ): Promise { + const response = await this.call(kind, { + amount, + idempotencyKey: requestKey(kind), + password, + }) + if (!response.success || !response.data) return false + this.data = response.data + return true + }, + async quote( + marketId: string, + side: CryptoSide, + quantity: string, + ): Promise> { + const response = await this.call('quote', { + marketId, + quantity, + side, + }) + this.pendingQuote = response.success ? (response.data ?? null) : null + return response + }, + async executeQuote(): Promise { + if (!this.pendingQuote) return false + const response = await this.call('execute', { + idempotencyKey: requestKey('trade'), + quoteId: this.pendingQuote.id, + }) + if (!response.success || !response.data) return false + this.data = response.data + this.pendingQuote = null + return true + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 6cab93c..ce6df27 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 51d903e..388590c 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -11,6 +11,7 @@ export type BuiltinPhoneAppId = | 'weather' | 'health' | 'banking' + | 'crypto' | 'billing' | 'garage' | 'house' diff --git a/frontend/src/types/crypto.ts b/frontend/src/types/crypto.ts new file mode 100644 index 0000000..ddb5cf9 --- /dev/null +++ b/frontend/src/types/crypto.ts @@ -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 +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index ce56b9f..5a4c3af 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -125,6 +125,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< calendar: { enabled: true, sounds: true }, weather: { enabled: true, sounds: true }, banking: { enabled: true, sounds: true }, + crypto: { enabled: true, sounds: true }, billing: { enabled: true, sounds: true }, garage: { enabled: true, sounds: true }, skyride: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/CryptoApp.contract.test.ts b/frontend/src/views/apps/CryptoApp.contract.test.ts new file mode 100644 index 0000000..59d3b09 --- /dev/null +++ b/frontend/src/views/apps/CryptoApp.contract.test.ts @@ -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') + }) +}) diff --git a/frontend/src/views/apps/CryptoApp.vue b/frontend/src/views/apps/CryptoApp.vue new file mode 100644 index 0000000..53bf1c7 --- /dev/null +++ b/frontend/src/views/apps/CryptoApp.vue @@ -0,0 +1,831 @@ + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index ec2f20e..b21a912 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -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 ? { diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs index aaec0a8..c1c1608 100644 --- a/frontend/testserver/smoke.cjs +++ b/frontend/testserver/smoke.cjs @@ -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 }], diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index c1f4d6d..032f60f 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -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 -- ============================================================================= diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua index 1bf64d3..486a3f9 100644 --- a/sky_phone/config/locales/de.lua +++ b/sky_phone/config/locales/de.lua @@ -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", diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index cffabfe..44b8e99 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -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", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index f58cd71..a5283e1 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -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', diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index fc393c1..d3f3c16 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.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", diff --git a/sky_phone/source/server/crypto.lua b/sky_phone/source/server/crypto.lua new file mode 100644 index 0000000..d9807e4 --- /dev/null +++ b/sky_phone/source/server/crypto.lua @@ -0,0 +1,1066 @@ +Bridge.Database.AfterMigration("sky_phone", function() + +local sessions = {} +local profile_locks = {} +local exchange_lock = false +local markets = {} +local market_order = {} + +local function ensure_schema() + local statements = { + [[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]], + } + for _, statement in ipairs(statements) do + Bridge.Database.Query(statement, {}) + end +end + +local function new_id() + local row = Bridge.Database.Query("SELECT UUID() AS `id`", {})[1] + if not row or type(row.id) ~= "string" then + error("[sky_phone] Database did not generate a Crypto id.") + end + return row.id +end + +local function affected_rows(result) + if type(result) == "number" then + return result + end + return type(result) == "table" and tonumber(result.affectedRows) or 0 +end + +local function account_id(profile_id) + return "profile:" .. profile_id +end + +local function valid_handle(value) + if type(value) ~= "string" then + return nil + end + local handle = value:match("^%s*(.-)%s*$") + local length = utf8.len(handle) + if not length or length < Config.Crypto.HandleMinLength or length > Config.Crypto.HandleMaxLength + or not handle:match("^[A-Za-z0-9][A-Za-z0-9._]*[A-Za-z0-9]$") + then + return nil + end + return handle +end + +local function valid_password(value) + local length = type(value) == "string" and utf8.len(value) or nil + return length and length >= Config.Crypto.PasswordMinLength + and length <= Config.Crypto.PasswordMaxLength +end + +local function parse_whole(value, minimum, maximum) + if type(value) ~= "string" or not value:match("^%d+$") then + return nil + end + local amount = tonumber(value) + if not amount or amount ~= math.floor(amount) or amount < minimum or amount > maximum then + return nil + end + return amount +end + +local function parse_quantity(value) + if type(value) ~= "string" then + return nil + end + local whole, decimal = value:match("^(%d+)%.?(%d*)$") + if not whole or #decimal > 6 then + return nil + end + decimal = decimal .. string.rep("0", 6 - #decimal) + local quantity = tonumber(whole) * Config.Crypto.AssetScale + tonumber(decimal) + if quantity <= 0 or quantity > 1000000 * Config.Crypto.AssetScale then + return nil + end + return quantity +end + +local function decimal_string(value, scale) + value = math.floor(tonumber(value) or 0) + if scale == 1 then + return tostring(value) + end + local digits = tostring(scale):len() - 1 + local whole = math.floor(value / scale) + local fraction = tostring(value % scale) + fraction = string.rep("0", digits - #fraction) .. fraction + fraction = fraction:gsub("0+$", "") + return fraction == "" and tostring(whole) or (tostring(whole) .. "." .. fraction) +end + +local function ceil_div(value, divisor) + return math.floor((value + divisor - 1) / divisor) +end + +local function initialize_markets() + for _, config in ipairs(Config.Crypto.Markets) do + markets[config.Id] = config + market_order[#market_order + 1] = config.Id + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crypto_markets` + (`id`,`asset_scale`,`price_scale`,`issued_supply`,`price`,`version`,`status`) + VALUES (?, ?, ?, ?, ?, 1, 'active') ON DUPLICATE KEY UPDATE `id` = VALUES(`id`) + ]], { + config.Id, + Config.Crypto.AssetScale, + Config.Crypto.PriceScale, + config.IssuedSupply * Config.Crypto.AssetScale, + config.InitialPrice, + }) + local persisted = Bridge.Database.Query([[ + SELECT `asset_scale`,`price_scale`,`issued_supply` + FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1 + ]], { config.Id })[1] + if not persisted + or tonumber(persisted.asset_scale) ~= Config.Crypto.AssetScale + or tonumber(persisted.price_scale) ~= Config.Crypto.PriceScale + or tonumber(persisted.issued_supply) ~= config.IssuedSupply * Config.Crypto.AssetScale + then + error(("[sky_phone] Crypto market scale or supply changed without a migration: %s"):format(config.Id)) + end + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`) + VALUES ('treasury', ?, ?) ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`) + ]], { config.Id, config.TreasuryInventory * Config.Crypto.AssetScale }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`) + VALUES ('reserve', ?, ?) ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`) + ]], { + config.Id, + (config.IssuedSupply - config.TreasuryInventory) * Config.Crypto.AssetScale, + }) + end + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`) + VALUES ('treasury', 'CASH', ?) ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`) + ]], { Config.Crypto.TreasuryCash * Config.Crypto.PriceScale }) +end + +local function require_phone(source) + local phone_session, error_response = SkyPhone.RequireSession(source) + if not phone_session then + return nil, nil, error_response + end + local identifier = Bridge.Framework.GetIdentifier(source) + if type(identifier) ~= "string" or identifier == "" then + return nil, nil, { success = false, error = "service_unavailable" } + end + return phone_session, identifier +end + +local function profile_by_owner(identifier) + return Bridge.Database.Query([[ + SELECT `id`,`owner_identifier`,`account_id`,`handle`,`status`,`failed_logins`, + UNIX_TIMESTAMP(`locked_until`) AS `locked_until` + FROM `sky_phone_crypto_profiles` WHERE `owner_identifier` = ? LIMIT 1 + ]], { identifier })[1] +end + +local function authenticated_profile(source) + local phone_session, identifier, error_response = require_phone(source) + if not phone_session then + return nil, error_response + end + local session = sessions[source] + if not session or session.identifier ~= identifier or session.imei ~= phone_session.imei + or session.expires_at < os.time() + then + sessions[source] = nil + return nil, { success = false, error = "not_authenticated" } + end + local profile = profile_by_owner(identifier) + if not profile or profile.id ~= session.profile_id or profile.status ~= "active" then + sessions[source] = nil + return nil, { success = false, error = "not_authenticated" } + end + session.expires_at = os.time() + Config.Crypto.SessionSeconds + return profile +end + +local function verify_password(profile_id, password) + if not valid_password(password) then + return false + end + local row = Bridge.Database.Query( + "SELECT `password_hash` FROM `sky_phone_crypto_profiles` WHERE `id` = ? LIMIT 1", + { profile_id } + )[1] + return row and exports[GetCurrentResourceName()]:CryptoVerifyPassword(password, row.password_hash) or false +end + +local function set_session(source, phone_session, profile, recently_authenticated) + sessions[source] = { + expires_at = os.time() + Config.Crypto.SessionSeconds, + identifier = profile.owner_identifier, + imei = phone_session.imei, + profile_id = profile.id, + recently_authenticated_at = recently_authenticated and os.time() or 0, + } +end + +local function balance(account, asset) + local row = Bridge.Database.Query([[ + SELECT `available`,`locked`,`version` FROM `sky_phone_crypto_balances` + WHERE `account_id` = ? AND `asset_id` = ? LIMIT 1 + ]], { account, asset })[1] + return row and (tonumber(row.available) or 0) or 0, + row and (tonumber(row.locked) or 0) or 0, + row and (tonumber(row.version) or 0) or 0 +end + +local function market_rows() + local rows = Bridge.Database.Query([[ + SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at` + FROM `sky_phone_crypto_markets` + ]], {}) + local indexed = {} + for _, row in ipairs(rows) do + indexed[row.id] = row + end + return indexed +end + +local function market_dtos() + local current = market_rows() + local result = {} + for _, market_id in ipairs(market_order) do + local config = markets[market_id] + local row = current[market_id] + local ticks = Bridge.Database.Query([[ + SELECT `price` FROM `sky_phone_crypto_market_ticks` + WHERE `market_id` = ? ORDER BY `id` DESC LIMIT 12 + ]], { market_id }) + local prices = {} + for index = #ticks, 1, -1 do + prices[#prices + 1] = tonumber(ticks[index].price) or tonumber(row.price) + end + if #prices == 0 then + prices[1] = tonumber(row.price) + end + local minimum = math.min(table.unpack(prices)) + local maximum = math.max(table.unpack(prices)) + local span = math.max(1, maximum - minimum) + local sparkline = {} + for index, price in ipairs(prices) do + sparkline[index] = (price - minimum) / span + end + local first = prices[1] + local price = tonumber(row.price) or config.InitialPrice + result[#result + 1] = { + id = market_id, + symbol = config.Symbol, + name = config.Name, + color = config.Color, + price = decimal_string(price, Config.Crypto.PriceScale), + changePercent = first > 0 and ((price - first) / first) * 100 or 0, + enabled = row.status == "active", + sparkline = sparkline, + } + end + return result +end + +local function activity(profile_id) + local rows = Bridge.Database.Query([[ + SELECT `id`,`type`,`amount`,`market_id`,`status`, UNIX_TIMESTAMP(`created_at`) AS `created_at` + FROM `sky_phone_crypto_operations` WHERE `profile_id` = ? + ORDER BY `created_at` DESC, `id` DESC LIMIT 50 + ]], { profile_id }) + local result = {} + for _, row in ipairs(rows) do + result[#result + 1] = { + id = row.id, + type = row.type, + amount = decimal_string(row.amount, Config.Crypto.PriceScale), + marketId = row.market_id, + status = row.status, + createdAt = (tonumber(row.created_at) or 0) * 1000, + } + end + return result +end + +local function daily_total(profile_id, operation_type) + local row = Bridge.Database.Query([[ + SELECT COALESCE(SUM(`amount`), 0) AS `total` + FROM `sky_phone_crypto_operations` + WHERE `profile_id` = ? AND `type` = ? AND `status` = 'completed' + AND `created_at` >= CURRENT_DATE + ]], { profile_id, operation_type })[1] + return tonumber(row and row.total) or 0 +end + +local function bootstrap(profile) + local cash = balance(account_id(profile.id), "CASH") + local current_markets = market_rows() + local holdings = {} + local portfolio = cash + for _, market_id in ipairs(market_order) do + local available = balance(account_id(profile.id), market_id) + if available > 0 then + local price = tonumber(current_markets[market_id].price) or 0 + local value = math.floor(available * price / Config.Crypto.AssetScale) + local fill = Bridge.Database.Query([[ + SELECT FLOOR(SUM(fill.`gross`) * ? / NULLIF(SUM(fill.`quantity`), 0)) AS `price` + FROM `sky_phone_crypto_fills` fill + JOIN `sky_phone_crypto_operations` operation ON operation.`id` = fill.`operation_id` + WHERE operation.`profile_id` = ? AND fill.`market_id` = ? AND fill.`side` = 'buy' + ]], { Config.Crypto.AssetScale, profile.id, market_id })[1] + holdings[#holdings + 1] = { + assetId = market_id, + quantity = decimal_string(available, Config.Crypto.AssetScale), + value = decimal_string(value, Config.Crypto.PriceScale), + averagePrice = decimal_string(fill and fill.price or price, Config.Crypto.PriceScale), + } + portfolio = portfolio + value + end + end + return { + authenticated = true, + profile = { id = profile.id, handle = profile.handle, status = profile.status }, + cashBalance = decimal_string(cash, Config.Crypto.PriceScale), + portfolioValue = decimal_string(portfolio, Config.Crypto.PriceScale), + holdings = holdings, + markets = market_dtos(), + activity = activity(profile.id), + } +end + +local function audit(profile_id, identifier, event_type, detail) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crypto_audit_events` + (`profile_id`,`owner_identifier`,`event_type`,`detail`) VALUES (?, ?, ?, ?) + ]], { profile_id, identifier, event_type, detail or "" }) +end + +local function idempotency_key(value) + if type(value) ~= "string" or #value < 8 or #value > 96 + or not value:match("^[A-Za-z0-9._-]+$") + then + return nil + end + return value +end + +local function with_profile_lock(profile_id, callback) + if profile_locks[profile_id] then + return { success = false, error = "rate_limited" } + end + profile_locks[profile_id] = true + local success, result = pcall(callback) + profile_locks[profile_id] = nil + if not success then + error(result) + end + return result +end + +local function with_exchange_lock(callback) + if exchange_lock then + return { success = false, error = "rate_limited" } + end + exchange_lock = true + local success, result = pcall(callback) + exchange_lock = false + if not success then + error(result) + end + return result +end + +Bridge.Callbacks.Register("sky_phone:crypto:bootstrap", function(source) + if not Config.Crypto.Enabled then + return { success = false, error = "service_unavailable" } + end + local profile, error_response = authenticated_profile(source) + if profile then + return { success = true, data = bootstrap(profile) } + end + local _, identifier, phone_error = require_phone(source) + if not identifier then + return phone_error + end + local existing = profile_by_owner(identifier) + return { + success = true, + data = { + authenticated = false, + profile = nil, + cashBalance = "0", + portfolioValue = "0", + holdings = {}, + markets = market_dtos(), + activity = {}, + registered = existing ~= nil, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:crypto:register", function(source, data) + if not SkyPhone.AllowOperation(source, "crypto:register", 5, 60) then + return { success = false, error = "rate_limited" } + end + local phone_session, identifier, error_response = require_phone(source) + if not phone_session then + return error_response + end + local account, account_error = SkyPhone.RequireAccount(source) + if not account then + return account_error + end + data = type(data) == "table" and data or {} + local handle = valid_handle(data.handle) + if not handle then + return { success = false, error = "invalid_handle" } + end + if not valid_password(data.password) then + return { success = false, error = "invalid_password" } + end + if profile_by_owner(identifier) then + return { success = false, error = "profile_exists" } + end + local duplicate = Bridge.Database.Query( + "SELECT 1 FROM `sky_phone_crypto_profiles` WHERE `handle` = ? LIMIT 1", + { handle } + ) + if duplicate[1] then + return { success = false, error = "handle_taken" } + end + local entropy = Bridge.Database.Query("SELECT UUID() AS `id`", {})[1] + local password_hash = exports[GetCurrentResourceName()]:CryptoHashPassword(data.password) + if type(password_hash) ~= "string" then + error("[sky_phone] Crypto password provider did not return a password hash.") + end + local queries = { + { + query = [[INSERT INTO `sky_phone_crypto_profiles` + (`id`,`owner_identifier`,`account_id`,`handle`,`password_hash`) + VALUES (?, ?, ?, ?, ?)]], + params = { entropy.id, identifier, account.id, handle, password_hash }, + }, + { + query = [[INSERT INTO `sky_phone_crypto_balances` + (`account_id`,`asset_id`,`available`) VALUES (?, 'CASH', 0)]], + params = { account_id(entropy.id) }, + }, + } + if not Bridge.Database.Transaction(queries) then + return { success = false, error = "request_failed" } + end + local profile = profile_by_owner(identifier) + set_session(source, phone_session, profile, true) + audit(profile.id, identifier, "profile_registered", "") + return { success = true, data = bootstrap(profile) } +end) + +Bridge.Callbacks.Register("sky_phone:crypto:login", function(source, data) + if not SkyPhone.AllowOperation(source, "crypto:login", 10, 60) then + return { success = false, error = "rate_limited" } + end + local phone_session, identifier, error_response = require_phone(source) + if not phone_session then + return error_response + end + local profile = profile_by_owner(identifier) + if not profile then + return { success = false, error = "invalid_credentials" } + end + if tonumber(profile.locked_until) and tonumber(profile.locked_until) > os.time() then + return { success = false, error = "locked" } + end + if not verify_password(profile.id, type(data) == "table" and data.password or nil) then + local failed = (tonumber(profile.failed_logins) or 0) + 1 + local lock = failed >= Config.Crypto.LoginAttempts + Bridge.Database.Query([[ + UPDATE `sky_phone_crypto_profiles` SET `failed_logins` = ?, + `locked_until` = IF(?, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? SECOND), NULL) + WHERE `id` = ? + ]], { lock and 0 or failed, lock and 1 or 0, Config.Crypto.LockoutSeconds, profile.id }) + audit(profile.id, identifier, "login_failed", lock and "profile_locked" or "invalid_password") + return { success = false, error = lock and "locked" or "invalid_credentials" } + end + Bridge.Database.Query( + "UPDATE `sky_phone_crypto_profiles` SET `failed_logins` = 0, `locked_until` = NULL WHERE `id` = ?", + { profile.id } + ) + set_session(source, phone_session, profile, true) + audit(profile.id, identifier, "login_succeeded", phone_session.imei) + return { success = true, data = bootstrap(profile) } +end) + +Bridge.Callbacks.Register("sky_phone:crypto:logout", function(source) + local session = sessions[source] + if session then + audit(session.profile_id, session.identifier, "logout", "") + end + sessions[source] = nil + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:crypto:quote", function(source, data) + if not SkyPhone.AllowOperation(source, "crypto:quote", Config.Crypto.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = authenticated_profile(source) + if not profile then + return error_response + end + data = type(data) == "table" and data or {} + local config = markets[data.marketId] + local side = data.side == "buy" and "buy" or data.side == "sell" and "sell" or nil + local quantity = parse_quantity(data.quantity) + if not config or not side or not quantity then + return { success = false, error = "invalid_quantity" } + end + local market = Bridge.Database.Query( + "SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1", + { config.Id } + )[1] + if not market or market.status ~= "active" then + return { success = false, error = "market_unavailable" } + end + local spread_bps = 40 + local reference = tonumber(market.price) + local price = side == "buy" + and ceil_div(reference * (10000 + spread_bps), 10000) + or math.floor(reference * (10000 - spread_bps) / 10000) + local gross = side == "buy" + and ceil_div(quantity * price, Config.Crypto.AssetScale) + or math.floor(quantity * price / Config.Crypto.AssetScale) + local fee = math.max(Config.Crypto.MinimumFee, ceil_div(gross * Config.Crypto.FeeBasisPoints, 10000)) + local net = side == "buy" and gross + fee or gross - fee + if gross <= 0 or net <= 0 or gross > Config.Crypto.MaximumTradeNotional * Config.Crypto.PriceScale then + return { success = false, error = "limit_exceeded" } + end + if side == "buy" then + if balance(account_id(profile.id), config.Id) + quantity + > Config.Crypto.MaximumPositionQuantity * Config.Crypto.AssetScale + then + return { success = false, error = "limit_exceeded" } + end + if balance(account_id(profile.id), "CASH") < net then + return { success = false, error = "insufficient_funds" } + end + if balance("treasury", config.Id) < quantity then + return { success = false, error = "insufficient_liquidity" } + end + else + if balance(account_id(profile.id), config.Id) < quantity then + return { success = false, error = "insufficient_funds" } + end + if balance("treasury", "CASH") < net then + return { success = false, error = "insufficient_liquidity" } + end + end + if daily_total(profile.id, side) + net > Config.Crypto.DailyTradeLimit * Config.Crypto.PriceScale then + return { success = false, error = "limit_exceeded" } + end + local quote_id = new_id() + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crypto_quotes` + (`id`,`profile_id`,`market_id`,`side`,`quantity`,`price`,`gross`,`fee`,`net`,`market_version`,`expires_at`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? SECOND)) + ]], { + quote_id, profile.id, config.Id, side, quantity, price, gross, fee, net, + market.version, Config.Crypto.QuoteLifetimeSeconds, + }) + return { + success = true, + data = { + id = quote_id, + marketId = config.Id, + side = side, + quantity = decimal_string(quantity, Config.Crypto.AssetScale), + price = decimal_string(price, Config.Crypto.PriceScale), + gross = decimal_string(gross, Config.Crypto.PriceScale), + fee = decimal_string(fee, Config.Crypto.PriceScale), + net = decimal_string(net, Config.Crypto.PriceScale), + expiresAt = (os.time() + Config.Crypto.QuoteLifetimeSeconds) * 1000, + }, + } +end) + +local function execute_trade(profile, data) + local key = idempotency_key(data.idempotencyKey) + if not key or type(data.quoteId) ~= "string" or #data.quoteId ~= 36 then + return { success = false, error = "quote_unavailable" } + end + local existing = Bridge.Database.Query([[ + SELECT `status` FROM `sky_phone_crypto_operations` + WHERE `profile_id` = ? AND `idempotency_key` = ? LIMIT 1 + ]], { profile.id, key })[1] + if existing then + return existing.status == "completed" + and { success = true, data = bootstrap(profile) } + or { success = false, error = "duplicate_request" } + end + local quote = Bridge.Database.Query([[ + SELECT quote.*, market.`status` AS `market_status`, market.`version` AS `current_version` + FROM `sky_phone_crypto_quotes` quote + JOIN `sky_phone_crypto_markets` market ON market.`id` = quote.`market_id` + WHERE quote.`id` = ? AND quote.`profile_id` = ? LIMIT 1 + ]], { data.quoteId, profile.id })[1] + if not quote or quote.consumed_operation_id then + return { success = false, error = "quote_unavailable" } + end + local expiry = Bridge.Database.Query( + "SELECT UNIX_TIMESTAMP(`expires_at`) AS `expires_at` FROM `sky_phone_crypto_quotes` WHERE `id` = ?", + { quote.id } + )[1] + if not expiry or tonumber(expiry.expires_at) < os.time() then + return { success = false, error = "quote_expired" } + end + if quote.market_status ~= "active" or tonumber(quote.current_version) ~= tonumber(quote.market_version) then + return { success = false, error = "quote_expired" } + end + local quantity = tonumber(quote.quantity) + local net_currency = tonumber(quote.net) + local player_account = account_id(profile.id) + if daily_total(profile.id, quote.side) + net_currency + > Config.Crypto.DailyTradeLimit * Config.Crypto.PriceScale + then + return { success = false, error = "limit_exceeded" } + end + if quote.side == "buy" then + if balance(player_account, quote.market_id) + quantity + > Config.Crypto.MaximumPositionQuantity * Config.Crypto.AssetScale + then + return { success = false, error = "limit_exceeded" } + end + if balance(player_account, "CASH") < net_currency then + return { success = false, error = "insufficient_funds" } + end + if balance("treasury", quote.market_id) < quantity then + return { success = false, error = "insufficient_liquidity" } + end + else + if balance(player_account, quote.market_id) < quantity then + return { success = false, error = "insufficient_funds" } + end + if balance("treasury", "CASH") < net_currency then + return { success = false, error = "insufficient_liquidity" } + end + end + local operation_id = new_id() + local fill_id = new_id() + local operation_type = quote.side + local request_hash = Bridge.Database.Query( + "SELECT SHA2(CONCAT(?, ':', ?), 256) AS `hash`", + { quote.id, key } + )[1].hash + local queries = { + { + query = [[INSERT INTO `sky_phone_crypto_operations` + (`id`,`profile_id`,`type`,`idempotency_key`,`request_hash`,`status`,`amount`,`market_id`) + VALUES (?, ?, ?, ?, ?, 'prepared', ?, ?)]], + params = { operation_id, profile.id, operation_type, key, request_hash, net_currency, quote.market_id }, + }, + } + if quote.side == "buy" then + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` - ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH' AND `available` >= ?]], params = { net_currency, player_account, net_currency } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `version` = `version` + 1 WHERE `account_id` = 'treasury' AND `asset_id` = 'CASH']], params = { net_currency } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` - ?, `version` = `version` + 1 WHERE `account_id` = 'treasury' AND `asset_id` = ? AND `available` >= ?]], params = { quantity, quote.market_id, quantity } } + queries[#queries + 1] = { query = [[INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`,`version`) VALUES (?, ?, ?, 1) ON DUPLICATE KEY UPDATE `available` = `available` + VALUES(`available`), `version` = `version` + 1]], params = { player_account, quote.market_id, quantity } } + else + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` - ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = ? AND `available` >= ?]], params = { quantity, player_account, quote.market_id, quantity } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `version` = `version` + 1 WHERE `account_id` = 'treasury' AND `asset_id` = ?]], params = { quantity, quote.market_id } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` - ?, `version` = `version` + 1 WHERE `account_id` = 'treasury' AND `asset_id` = 'CASH' AND `available` >= ?]], params = { net_currency, net_currency } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH']], params = { net_currency, player_account } } + end + queries[#queries + 1] = { query = [[INSERT INTO `sky_phone_crypto_ledger_entries` (`operation_id`,`account_id`,`asset_id`,`delta`) VALUES (?, ?, 'CASH', ?), (?, 'treasury', 'CASH', ?), (?, ?, ?, ?), (?, 'treasury', ?, ?)]], params = { + operation_id, player_account, quote.side == "buy" and -net_currency or net_currency, + operation_id, quote.side == "buy" and net_currency or -net_currency, + operation_id, player_account, quote.market_id, quote.side == "buy" and quantity or -quantity, + operation_id, quote.market_id, quote.side == "buy" and -quantity or quantity, + } } + queries[#queries + 1] = { query = [[INSERT INTO `sky_phone_crypto_fills` (`id`,`operation_id`,`quote_id`,`market_id`,`side`,`quantity`,`price`,`gross`,`fee`,`net`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)]], params = { fill_id, operation_id, quote.id, quote.market_id, quote.side, quantity, quote.price, quote.gross, quote.fee, quote.net } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_quotes` SET `consumed_operation_id` = ? WHERE `id` = ? AND `consumed_operation_id` IS NULL]], params = { operation_id, quote.id } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'completed' WHERE `id` = ?]], params = { operation_id } } + if not Bridge.Database.Transaction(queries) then + return { success = false, error = "request_failed" } + end + audit(profile.id, profile.owner_identifier, "trade_completed", operation_id) + return { success = true, data = bootstrap(profile) } +end + +Bridge.Callbacks.Register("sky_phone:crypto:execute", function(source, data) + if not SkyPhone.AllowOperation(source, "crypto:trade", Config.Crypto.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = authenticated_profile(source) + if not profile then + return error_response + end + return with_profile_lock(profile.id, function() + return with_exchange_lock(function() + return execute_trade(profile, type(data) == "table" and data or {}) + end) + end) +end) + +local function settlement_ledger_queries(operation_id, profile_id, kind, ledger_amount) + local player_account = account_id(profile_id) + local queries = {} + if kind == "deposit" then + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH']], params = { ledger_amount, player_account } } + queries[#queries + 1] = { query = [[INSERT INTO `sky_phone_crypto_ledger_entries` (`operation_id`,`account_id`,`asset_id`,`delta`) VALUES (?, ?, 'CASH', ?), (?, 'external:bank', 'CASH', ?)]], params = { operation_id, player_account, ledger_amount, operation_id, -ledger_amount } } + else + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `locked` = `locked` - ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH' AND `locked` >= ?]], params = { ledger_amount, player_account, ledger_amount } } + queries[#queries + 1] = { query = [[INSERT INTO `sky_phone_crypto_ledger_entries` (`operation_id`,`account_id`,`asset_id`,`delta`) VALUES (?, ?, 'CASH', ?), (?, 'external:bank', 'CASH', ?)]], params = { operation_id, player_account, -ledger_amount, operation_id, ledger_amount } } + end + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'completed' WHERE `id` = ? AND `status` = 'external_applied']], params = { operation_id } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_settlements` SET `state` = 'completed' WHERE `operation_id` = ? AND `state` = 'external_applied']], params = { operation_id } } + return queries +end + +local function settle(source, profile, kind, data) + local key = idempotency_key(data.idempotencyKey) + local amount = parse_whole(data.amount, Config.Crypto.MinimumSettlement, Config.Crypto.MaximumSettlement) + if not key or not amount then + return { success = false, error = "invalid_amount" } + end + if not verify_password(profile.id, data.password) then + audit(profile.id, profile.owner_identifier, "settlement_reauth_failed", kind) + return { success = false, error = "invalid_credentials" } + end + local existing = Bridge.Database.Query([[ + SELECT `status` FROM `sky_phone_crypto_operations` + WHERE `profile_id` = ? AND `type` = ? AND `idempotency_key` = ? LIMIT 1 + ]], { profile.id, kind, key })[1] + if existing then + return existing.status == "completed" + and { success = true, data = bootstrap(profile) } + or { success = false, error = "settlement_pending" } + end + local ledger_amount = amount * Config.Crypto.PriceScale + local daily_limit = kind == "deposit" and Config.Crypto.DailyDepositLimit + or Config.Crypto.DailyWithdrawalLimit + if daily_total(profile.id, kind) + ledger_amount > daily_limit * Config.Crypto.PriceScale then + return { success = false, error = "limit_exceeded" } + end + if kind == "withdrawal" and balance(account_id(profile.id), "CASH") < ledger_amount then + return { success = false, error = "insufficient_funds" } + end + local operation_id = new_id() + local request_hash = Bridge.Database.Query( + "SELECT SHA2(CONCAT(?, ':', ?, ':', ?), 256) AS `hash`", + { kind, amount, key } + )[1].hash + local prepared = { + { + query = [[INSERT INTO `sky_phone_crypto_operations` + (`id`,`profile_id`,`type`,`idempotency_key`,`request_hash`,`status`,`amount`) + VALUES (?, ?, ?, ?, ?, 'prepared', ?)]], + params = { operation_id, profile.id, kind, key, request_hash, ledger_amount }, + }, + { + query = [[INSERT INTO `sky_phone_crypto_settlements` + (`operation_id`,`owner_identifier`,`framework_account`,`amount`,`state`) + VALUES (?, ?, ?, ?, 'prepared')]], + params = { operation_id, profile.owner_identifier, Config.Crypto.BankAccount, amount }, + }, + } + if kind == "withdrawal" then + prepared[#prepared + 1] = { + query = [[UPDATE `sky_phone_crypto_balances` + SET `available` = `available` - ?, `locked` = `locked` + ?, `version` = `version` + 1 + WHERE `account_id` = ? AND `asset_id` = 'CASH' AND `available` >= ?]], + params = { ledger_amount, ledger_amount, account_id(profile.id), ledger_amount }, + } + end + if not Bridge.Database.Transaction(prepared) then + return { success = false, error = "request_failed" } + end + Bridge.Database.Query("UPDATE `sky_phone_crypto_operations` SET `status` = 'external_pending' WHERE `id` = ?", { operation_id }) + Bridge.Database.Query("UPDATE `sky_phone_crypto_settlements` SET `state` = 'external_pending' WHERE `operation_id` = ?", { operation_id }) + local money_success, money_result = pcall( + kind == "deposit" and Bridge.Framework.RemoveMoney or Bridge.Framework.AddMoney, + source, + Config.Crypto.BankAccount, + amount + ) + if not money_success then + Bridge.Database.Query("UPDATE `sky_phone_crypto_operations` SET `status` = 'manual_review', `detail` = 'framework_call_ambiguous' WHERE `id` = ?", { operation_id }) + Bridge.Database.Query("UPDATE `sky_phone_crypto_settlements` SET `state` = 'manual_review' WHERE `operation_id` = ?", { operation_id }) + audit(profile.id, profile.owner_identifier, "settlement_manual_review", operation_id) + return { success = false, error = "settlement_pending" } + end + if not money_result then + local failed = { + { query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'failed', `detail` = 'framework_rejected' WHERE `id` = ?]], params = { operation_id } }, + { query = [[UPDATE `sky_phone_crypto_settlements` SET `state` = 'failed' WHERE `operation_id` = ?]], params = { operation_id } }, + } + if kind == "withdrawal" then + failed[#failed + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `locked` = `locked` - ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH' AND `locked` >= ?]], params = { ledger_amount, ledger_amount, account_id(profile.id), ledger_amount } } + end + Bridge.Database.Transaction(failed) + return { success = false, error = kind == "deposit" and "insufficient_funds" or "request_failed" } + end + Bridge.Database.Query("UPDATE `sky_phone_crypto_operations` SET `status` = 'external_applied' WHERE `id` = ?", { operation_id }) + Bridge.Database.Query("UPDATE `sky_phone_crypto_settlements` SET `state` = 'external_applied' WHERE `operation_id` = ?", { operation_id }) + local ledger_queries = settlement_ledger_queries(operation_id, profile.id, kind, ledger_amount) + if not Bridge.Database.Transaction(ledger_queries) then + Bridge.Database.Query("UPDATE `sky_phone_crypto_operations` SET `status` = 'manual_review', `detail` = 'ledger_apply_failed' WHERE `id` = ?", { operation_id }) + Bridge.Database.Query("UPDATE `sky_phone_crypto_settlements` SET `state` = 'manual_review' WHERE `operation_id` = ?", { operation_id }) + audit(profile.id, profile.owner_identifier, "settlement_manual_review", operation_id) + return { success = false, error = "settlement_pending" } + end + sessions[source].recently_authenticated_at = os.time() + audit(profile.id, profile.owner_identifier, "settlement_completed", operation_id) + return { success = true, data = bootstrap(profile) } +end + +for _, callback in ipairs({ + { name = "deposit", kind = "deposit" }, + { name = "withdraw", kind = "withdrawal" }, +}) do + Bridge.Callbacks.Register("sky_phone:crypto:" .. callback.name, function(source, data) + if not SkyPhone.AllowOperation(source, "crypto:settlement", 8, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = authenticated_profile(source) + if not profile then + return error_response + end + return with_profile_lock(profile.id, function() + return settle(source, profile, callback.kind, type(data) == "table" and data or {}) + end) + end) +end + +AddEventHandler("playerDropped", function() + sessions[source] = nil +end) + +ensure_schema() +initialize_markets() + +local function reconcile_settlements(include_recent) + local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)" + local rows = Bridge.Database.Query([[ + SELECT operation.`id`, operation.`profile_id`, operation.`type`, operation.`amount`, + operation.`status`, settlement.`state` + FROM `sky_phone_crypto_operations` operation + JOIN `sky_phone_crypto_settlements` settlement ON settlement.`operation_id` = operation.`id` + WHERE operation.`status` IN ('prepared','external_pending','external_applied') + ]] .. age_clause, {}) + for _, operation in ipairs(rows) do + local ledger_amount = tonumber(operation.amount) + if operation.status == "prepared" then + local queries = {} + if operation.type == "withdrawal" then + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `locked` = `locked` - ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH' AND `locked` >= ?]], params = { ledger_amount, ledger_amount, account_id(operation.profile_id), ledger_amount } } + end + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'cancelled', `detail` = 'reconciled_before_external_call' WHERE `id` = ? AND `status` = 'prepared']], params = { operation.id } } + queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_settlements` SET `state` = 'cancelled' WHERE `operation_id` = ? AND `state` = 'prepared']], params = { operation.id } } + Bridge.Database.Transaction(queries) + elseif operation.status == "external_pending" then + Bridge.Database.Transaction({ + { query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'manual_review', `detail` = 'reconciled_ambiguous_external_call' WHERE `id` = ? AND `status` = 'external_pending']], params = { operation.id } }, + { query = [[UPDATE `sky_phone_crypto_settlements` SET `state` = 'manual_review' WHERE `operation_id` = ? AND `state` = 'external_pending']], params = { operation.id } }, + }) + elseif operation.status == "external_applied" then + local entries = Bridge.Database.Query( + "SELECT COUNT(*) AS `count` FROM `sky_phone_crypto_ledger_entries` WHERE `operation_id` = ?", + { operation.id } + )[1] + if tonumber(entries and entries.count) == 0 then + Bridge.Database.Transaction(settlement_ledger_queries( + operation.id, + operation.profile_id, + operation.type, + ledger_amount + )) + else + Bridge.Database.Transaction({ + { query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'manual_review', `detail` = 'unexpected_partial_ledger' WHERE `id` = ?]], params = { operation.id } }, + { query = [[UPDATE `sky_phone_crypto_settlements` SET `state` = 'manual_review' WHERE `operation_id` = ?]], params = { operation.id } }, + }) + end + end + end +end + +reconcile_settlements(true) + +CreateThread(function() + while true do + Wait(5 * 60 * 1000) + reconcile_settlements(false) + end +end) + +CreateThread(function() + while true do + Wait(Config.Crypto.PriceTickSeconds * 1000) + with_exchange_lock(function() + for _, market_id in ipairs(market_order) do + local config = markets[market_id] + local row = Bridge.Database.Query( + "SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1", + { market_id } + )[1] + if row and row.status == "active" then + local price = tonumber(row.price) or config.InitialPrice + local movement = exports[GetCurrentResourceName()]:CryptoRandomInt( + -config.VolatilityBasisPoints, + config.VolatilityBasisPoints + 1 + ) + if type(movement) ~= "number" then + error("[sky_phone] Crypto entropy provider did not return a market movement.") + end + local next_price = math.floor(price * (10000 + movement) / 10000) + next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price)) + local next_version = (tonumber(row.version) or 0) + 1 + if Bridge.Database.Transaction({ + { query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } }, + { query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } }, + }) then + Bridge.Database.Query([[ + DELETE FROM `sky_phone_crypto_market_ticks` + WHERE `market_id` = ? AND `id` NOT IN ( + SELECT `id` FROM ( + SELECT `id` FROM `sky_phone_crypto_market_ticks` + WHERE `market_id` = ? ORDER BY `id` DESC LIMIT 1440 + ) retained + ) + ]], { market_id, market_id }) + end + end + end + end) + end +end) + +end) diff --git a/sky_phone/source/server/crypto_password.js b/sky_phone/source/server/crypto_password.js new file mode 100644 index 0000000..47dd12d --- /dev/null +++ b/sky_phone/source/server/crypto_password.js @@ -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) +}) diff --git a/sky_phone/source/shared/custom_apps.lua b/sky_phone/source/shared/custom_apps.lua index 826cf09..d78a916 100644 --- a/sky_phone/source/shared/custom_apps.lua +++ b/sky_phone/source/shared/custom_apps.lua @@ -37,6 +37,7 @@ local ALLOWED_PERMISSIONS = { local RESERVED_APP_IDS = { ["app-store"] = true, banking = true, + crypto = true, billing = true, calculator = true, calendar = true, diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 9c9bf76..5962d63 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -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; From 94c9ec132913e8b5622447562cbf3ea01a833259 Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:49:04 +0200 Subject: [PATCH 02/46] FIX - simplify VaultX app icon Replace the futuristic space-styled artwork with a restrained finance symbol that remains legible at phone icon size. --- frontend/src/assets/img/app-icons/crypto.webp | Bin 20330 -> 6622 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/frontend/src/assets/img/app-icons/crypto.webp b/frontend/src/assets/img/app-icons/crypto.webp index 0cb312955ce08dfede4cc37937d211ae78aa1ff3..ec42db6c139c553ab1ff1ceab74881b24e2dba85 100644 GIT binary patch literal 6622 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 Date: Tue, 18 Aug 2026 08:51:26 +0200 Subject: [PATCH 03/46] FIX - refine VaultX icon background Restore the premium faceted coin artwork while replacing the heavy sci-fi scene with a restrained blue gradient and subtle market line. --- frontend/src/assets/img/app-icons/crypto.webp | Bin 6622 -> 16116 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/frontend/src/assets/img/app-icons/crypto.webp b/frontend/src/assets/img/app-icons/crypto.webp index ec42db6c139c553ab1ff1ceab74881b24e2dba85..e63cabeeac1f327466801f0bc81702c61e8fec57 100644 GIT binary patch literal 16116 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 G0002_T--$f literal 6622 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 c$ Date: Tue, 18 Aug 2026 08:54:19 +0200 Subject: [PATCH 04/46] ENH - add VaultX navigation icons Give portfolio, markets, and activity distinct Lucide icons while retaining localized labels and accessible button names. --- frontend/src/views/apps/CryptoApp.contract.test.ts | 6 ++++++ frontend/src/views/apps/CryptoApp.vue | 12 +++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/apps/CryptoApp.contract.test.ts b/frontend/src/views/apps/CryptoApp.contract.test.ts index 59d3b09..9ab8bb9 100644 --- a/frontend/src/views/apps/CryptoApp.contract.test.ts +++ b/frontend/src/views/apps/CryptoApp.contract.test.ts @@ -32,6 +32,12 @@ describe('VaultX crypto app contracts', () => { } }) + it('shows distinct icons in every primary navigation item', () => { + expect(source).toContain(' { expect(server).toContain( 'Bridge.Callbacks.Register("sky_phone:crypto:quote"', diff --git a/frontend/src/views/apps/CryptoApp.vue b/frontend/src/views/apps/CryptoApp.vue index 53bf1c7..e654a27 100644 --- a/frontend/src/views/apps/CryptoApp.vue +++ b/frontend/src/views/apps/CryptoApp.vue @@ -4,13 +4,16 @@ import { ArrowLeftRight, ArrowUpFromLine, ChartCandlestick, + ChartNoAxesCombined, ChevronRight, Eye, EyeOff, + History, LockKeyhole, LogOut, RefreshCw, ShieldCheck, + WalletCards, } from 'lucide-vue-next' import { computed, onMounted, ref } from 'vue' @@ -431,17 +434,20 @@ onMounted(() => void crypto.load()) {{ t('tabs.portfolio') }} + {{ t('tabs.portfolio') }} {{ t('tabs.markets') }} + {{ t('tabs.markets') }} {{ t('tabs.activity') }} + {{ t('tabs.activity') }} From 1cf08cbc6b628ff866114e11265a00362d26f3ea Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:11:24 +0200 Subject: [PATCH 05/46] ENH - expand VaultX trading experience --- frontend/src/stores/crypto.test.ts | 34 +- frontend/src/stores/crypto.ts | 15 + frontend/src/stores/phone.ts | 53 +- frontend/src/types/crypto.ts | 10 + .../src/views/apps/CryptoApp.contract.test.ts | 13 +- frontend/src/views/apps/CryptoApp.vue | 1576 +++++++++++------ frontend/testserver/index.cjs | 77 +- frontend/testserver/smoke.cjs | 4 + sky_phone/config/locales/de.lua | 13 +- sky_phone/config/locales/en.lua | 13 +- sky_phone/source/client/main.lua | 1 + sky_phone/source/server/crypto.lua | 72 +- sky_phone/sql/install.sql | 3 + 13 files changed, 1345 insertions(+), 539 deletions(-) diff --git a/frontend/src/stores/crypto.test.ts b/frontend/src/stores/crypto.test.ts index 07b1987..cc5d02b 100644 --- a/frontend/src/stores/crypto.test.ts +++ b/frontend/src/stores/crypto.test.ts @@ -15,7 +15,17 @@ const bootstrap: CryptoBootstrap = { holdings: [], markets: [], portfolioValue: '25000', - profile: { handle: 'skyline', id: 'profile-1', status: 'active' }, + 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, + }, } const quote: CryptoQuote = { expiresAt: Date.now() + 8000, @@ -84,4 +94,26 @@ describe('crypto store', () => { 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, + password: '', + priceAlerts: false, + tradeConfirmations: true, + }), + ).toBe(true) + expect(mockNuiCall).toHaveBeenCalledWith('crypto:update-profile', { + handle: 'skyline', + hideBalances: true, + password: '', + priceAlerts: false, + tradeConfirmations: true, + }) + }) }) diff --git a/frontend/src/stores/crypto.ts b/frontend/src/stores/crypto.ts index 19e0f22..3162195 100644 --- a/frontend/src/stores/crypto.ts +++ b/frontend/src/stores/crypto.ts @@ -95,5 +95,20 @@ export const useCryptoStore = defineStore('crypto', { this.pendingQuote = null return true }, + async updateProfile(payload: { + handle: string + hideBalances: boolean + password: string + priceAlerts: boolean + tradeConfirmations: boolean + }): Promise { + const response = await this.call( + 'update-profile', + payload, + ) + if (!response.success || !response.data) return false + this.data = response.data + return true + }, }, }) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index ce6df27..e1d67f1 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -440,7 +440,12 @@ const cryptoFallbackLocales = { loginAction: 'Open portfolio', registerAction: 'Create secure profile', }, - tabs: { portfolio: 'Portfolio', markets: 'Markets', activity: 'Activity' }, + tabs: { + portfolio: 'Portfolio', + markets: 'Markets', + activity: 'Activity', + profile: 'Profile', + }, portfolio: { total: 'Total portfolio', cash: 'Available cash', @@ -448,7 +453,11 @@ const cryptoFallbackLocales = { 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', more: 'More' }, actions: { deposit: 'Deposit', withdraw: 'Withdraw' }, markets: { title: 'Fictional markets', @@ -456,6 +465,22 @@ const cryptoFallbackLocales = { 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: { buy: 'Buy', @@ -482,6 +507,31 @@ const cryptoFallbackLocales = { title: 'Financial activity', empty: 'No activity', emptyBody: 'Deposits, withdrawals and trades will appear here.', + volume: 'Lifetime trading volume', + completedTrades: 'completed trades', + cash: 'Cash', + filters: { all: 'All', trades: 'Trades', wallet: 'Wallet' }, + }, + profile: { + verified: 'Character ownership verified', + 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', + passwordForChange: 'Password for handle change', + passwordOptional: 'Only required when changing handle', + saved: 'Profile saved securely.', + save: 'Save profile', + securityTitle: 'Protected profile', + securityBody: + 'Your profile stays bound to this framework character and financial session.', }, activityTypes: { buy: 'Asset purchase', @@ -516,6 +566,7 @@ const cryptoFallbackLocales = { 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.', }, } diff --git a/frontend/src/types/crypto.ts b/frontend/src/types/crypto.ts index ddb5cf9..2e2bcae 100644 --- a/frontend/src/types/crypto.ts +++ b/frontend/src/types/crypto.ts @@ -4,11 +4,15 @@ export type CryptoMarket = { changePercent: number color: string enabled: boolean + high24h: string id: string + issuedSupply: string + low24h: string name: string price: string sparkline: number[] symbol: string + treasuryAvailable: string } export type CryptoHolding = { @@ -28,9 +32,15 @@ export type CryptoActivity = { } export type CryptoProfile = { + createdAt: number handle: string + hideBalances: boolean id: string + priceAlerts: boolean status: 'active' | 'frozen' | 'closed' + totalTrades: number + totalVolume: string + tradeConfirmations: boolean } export type CryptoBootstrap = { diff --git a/frontend/src/views/apps/CryptoApp.contract.test.ts b/frontend/src/views/apps/CryptoApp.contract.test.ts index 9ab8bb9..bb44bd3 100644 --- a/frontend/src/views/apps/CryptoApp.contract.test.ts +++ b/frontend/src/views/apps/CryptoApp.contract.test.ts @@ -36,6 +36,15 @@ describe('VaultX crypto app contracts', () => { expect(source).toContain(' { + expect(source).toContain('detail.sparkline') + expect(source).toContain("t('marketDetail.statistics')") + expect(source).toContain(' { @@ -71,7 +80,7 @@ describe('VaultX crypto app contracts', () => { 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') + expect(server).not.toMatch(/data\.price\b/) + expect(server).not.toMatch(/data\.fee\b/) }) }) diff --git a/frontend/src/views/apps/CryptoApp.vue b/frontend/src/views/apps/CryptoApp.vue index e654a27..80bfb6c 100644 --- a/frontend/src/views/apps/CryptoApp.vue +++ b/frontend/src/views/apps/CryptoApp.vue @@ -1,22 +1,27 @@