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 0000000..0cb3129 Binary files /dev/null and b/frontend/src/assets/img/app-icons/crypto.webp differ diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index b7fe0a7..0eefc08 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -39,6 +39,13 @@ describe('app registry', () => { route: '/apps/health', }) expect(isPhoneAppId('health')).toBe(true) + expect(PHONE_APPS.find((app) => app.id === 'crypto')).toMatchObject({ + category: 'utilities', + gridOrder: 7, + labelKey: 'Apps.crypto.name', + route: '/apps/crypto', + }) + expect(isPhoneAppId('crypto')).toBe(true) expect(PHONE_APPS.find((app) => app.id === 'banking')).toMatchObject({ gridOrder: 5, labelKey: 'Apps.banking.name', 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;