ENH - expand VaultX trading experience

This commit is contained in:
smx.pusha
2026-08-18 09:11:24 +02:00
parent d7a140c996
commit 1cf08cbc6b
13 changed files with 1345 additions and 539 deletions
+33 -1
View File
@@ -15,7 +15,17 @@ const bootstrap: CryptoBootstrap = {
holdings: [],
markets: [],
portfolioValue: '25000',
profile: { handle: 'skyline', id: 'profile-1', status: 'active' },
profile: {
createdAt: Date.now() - 86_400_000,
handle: 'skyline',
hideBalances: false,
id: 'profile-1',
priceAlerts: true,
status: 'active',
totalTrades: 12,
totalVolume: '18462.80',
tradeConfirmations: true,
},
}
const quote: CryptoQuote = {
expiresAt: Date.now() + 8000,
@@ -84,4 +94,26 @@ describe('crypto store', () => {
expect(crypto.data).toEqual(bootstrap)
expect(crypto.error).toBe('quote_expired')
})
it('updates profile preferences through the authenticated server endpoint', async () => {
mockNuiCall.mockResolvedValueOnce({ data: bootstrap, success: true })
const crypto = useCryptoStore()
expect(
await crypto.updateProfile({
handle: 'skyline',
hideBalances: true,
password: '',
priceAlerts: false,
tradeConfirmations: true,
}),
).toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('crypto:update-profile', {
handle: 'skyline',
hideBalances: true,
password: '',
priceAlerts: false,
tradeConfirmations: true,
})
})
})
+15
View File
@@ -95,5 +95,20 @@ export const useCryptoStore = defineStore('crypto', {
this.pendingQuote = null
return true
},
async updateProfile(payload: {
handle: string
hideBalances: boolean
password: string
priceAlerts: boolean
tradeConfirmations: boolean
}): Promise<boolean> {
const response = await this.call<CryptoBootstrap>(
'update-profile',
payload,
)
if (!response.success || !response.data) return false
this.data = response.data
return true
},
},
})
+52 -1
View File
@@ -440,7 +440,12 @@ const cryptoFallbackLocales = {
loginAction: 'Open portfolio',
registerAction: 'Create secure profile',
},
tabs: { portfolio: 'Portfolio', markets: 'Markets', activity: 'Activity' },
tabs: {
portfolio: 'Portfolio',
markets: 'Markets',
activity: 'Activity',
profile: 'Profile',
},
portfolio: {
total: 'Total portfolio',
cash: 'Available cash',
@@ -448,7 +453,11 @@ const cryptoFallbackLocales = {
empty: 'No assets yet',
emptyBody: 'Open Markets to request a protected quote.',
avg: 'Avg.',
insightTitle: 'Portfolio insight',
insightBody:
'Your holdings are valued against the latest synthetic server prices.',
},
quick: { trade: 'Trade', more: 'More' },
actions: { deposit: 'Deposit', withdraw: 'Withdraw' },
markets: {
title: 'Fictional markets',
@@ -456,6 +465,22 @@ const cryptoFallbackLocales = {
noticeTitle: 'In-game exchange',
notice:
'Prices are synthetic and have no real-world value. Every trade is checked against finite exchange liquidity.',
eyebrow: 'Market pulse',
movers: 'Top movers',
today: 'Today',
},
marketDetail: {
back: 'Back to markets',
today: 'Today',
description:
'The reference price is generated by the server. Executable prices use a protected short-lived quote.',
investment: 'Your investment',
totalValue: 'Current value',
statistics: 'Market statistics',
high24h: '24h high',
low24h: '24h low',
supply: 'Issued supply',
liquidity: 'Exchange liquidity',
},
trade: {
buy: 'Buy',
@@ -482,6 +507,31 @@ const cryptoFallbackLocales = {
title: 'Financial activity',
empty: 'No activity',
emptyBody: 'Deposits, withdrawals and trades will appear here.',
volume: 'Lifetime trading volume',
completedTrades: 'completed trades',
cash: 'Cash',
filters: { all: 'All', trades: 'Trades', wallet: 'Wallet' },
},
profile: {
verified: 'Character ownership verified',
trades: 'Trades',
volume: 'Volume',
memberSince: 'Member since',
preferences: 'Preferences',
priceAlerts: 'Price alerts',
priceAlertsBody: 'Notify me about notable market moves.',
confirmations: 'Trade confirmations',
confirmationsBody: 'Keep an extra confirmation before execution.',
hideBalances: 'Privacy mode',
hideBalancesBody: 'Hide balances across VaultX.',
identity: 'Profile identity',
passwordForChange: 'Password for handle change',
passwordOptional: 'Only required when changing handle',
saved: 'Profile saved securely.',
save: 'Save profile',
securityTitle: 'Protected profile',
securityBody:
'Your profile stays bound to this framework character and financial session.',
},
activityTypes: {
buy: 'Asset purchase',
@@ -516,6 +566,7 @@ const cryptoFallbackLocales = {
settlement_pending: 'A money transfer is already pending review.',
service_unavailable: 'VaultX is temporarily unavailable.',
request_failed: 'The secure exchange request failed.',
invalid_profile: 'Check your profile settings and handle.',
default: 'VaultX could not complete the request.',
},
}
+10
View File
@@ -4,11 +4,15 @@ export type CryptoMarket = {
changePercent: number
color: string
enabled: boolean
high24h: string
id: string
issuedSupply: string
low24h: string
name: string
price: string
sparkline: number[]
symbol: string
treasuryAvailable: string
}
export type CryptoHolding = {
@@ -28,9 +32,15 @@ export type CryptoActivity = {
}
export type CryptoProfile = {
createdAt: number
handle: string
hideBalances: boolean
id: string
priceAlerts: boolean
status: 'active' | 'frozen' | 'closed'
totalTrades: number
totalVolume: string
tradeConfirmations: boolean
}
export type CryptoBootstrap = {
@@ -36,6 +36,15 @@ describe('VaultX crypto app contracts', () => {
expect(source).toContain('<WalletCards')
expect(source).toContain('<ChartNoAxesCombined')
expect(source).toContain('<History')
expect(source).toContain('<UserRound')
})
it('includes advanced market detail and persistent profile controls', () => {
expect(source).toContain('detail.sparkline')
expect(source).toContain("t('marketDetail.statistics')")
expect(source).toContain('<SkyToggle')
expect(server).toContain('sky_phone:crypto:update-profile')
expect(server).toContain('`price_alerts`')
})
it('keeps all consequential calculations and state transitions on the server', () => {
@@ -71,7 +80,7 @@ describe('VaultX crypto app contracts', () => {
expect(passwordProvider).toContain('scryptSync')
expect(passwordProvider).toContain('timingSafeEqual')
expect(passwordProvider).toContain('randomBytes(16)')
expect(server).not.toContain('data.price')
expect(server).not.toContain('data.fee')
expect(server).not.toMatch(/data\.price\b/)
expect(server).not.toMatch(/data\.fee\b/)
})
})
File diff suppressed because it is too large Load Diff
+67 -10
View File
@@ -211,6 +211,10 @@ const cryptoMarkets = [
price: '128.50',
changePercent: 4.82,
enabled: true,
high24h: '132.80',
low24h: '119.40',
issuedSupply: '1000000',
treasuryAvailable: '849882.5',
sparkline: [0.12, 0.24, 0.2, 0.38, 0.51, 0.46, 0.68, 0.62, 0.81, 0.92],
},
{
@@ -221,6 +225,10 @@ const cryptoMarkets = [
price: '42.75',
changePercent: -1.37,
enabled: true,
high24h: '46.30',
low24h: '40.90',
issuedSupply: '2500000',
treasuryAvailable: '2099914.75',
sparkline: [0.84, 0.72, 0.76, 0.61, 0.69, 0.52, 0.46, 0.41, 0.35, 0.39],
},
{
@@ -231,9 +239,24 @@ const cryptoMarkets = [
price: '9.80',
changePercent: 7.21,
enabled: true,
high24h: '10.24',
low24h: '8.76',
issuedSupply: '8000000',
treasuryAvailable: '6800000',
sparkline: [0.08, 0.11, 0.18, 0.26, 0.23, 0.4, 0.55, 0.64, 0.79, 0.93],
},
]
let cryptoProfile = {
createdAt: Date.now() - 42 * 86400000,
handle: 'skyline',
hideBalances: false,
id: 'crypto-profile-demo',
priceAlerts: true,
status: 'active',
totalTrades: 12,
totalVolume: '18462.80',
tradeConfirmations: true,
}
let cryptoHoldings = [
{
assetId: 'aurora',
@@ -6544,9 +6567,7 @@ app.post('/api/:endpoint', (request, response) => {
0,
),
),
profile: cryptoAuthenticated
? { handle: 'skyline', id: 'crypto-profile-demo', status: 'active' }
: null,
profile: cryptoAuthenticated ? cryptoProfile : null,
})
const billingInvoice = (invoice) => ({
...invoice,
@@ -8044,13 +8065,18 @@ app.post('/api/:endpoint', (request, response) => {
return
}
cryptoAuthenticated = true
response.json({
success: true,
data: {
...cryptoOverview(),
profile: { handle, id: 'crypto-profile-new', status: 'active' },
},
})
cryptoProfile = {
createdAt: Date.now(),
handle,
hideBalances: false,
id: 'crypto-profile-new',
priceAlerts: true,
status: 'active',
totalTrades: 0,
totalVolume: '0',
tradeConfirmations: true,
}
response.json({ success: true, data: cryptoOverview() })
return
}
if (endpoint === 'crypto:logout') {
@@ -8059,6 +8085,37 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true })
return
}
if (endpoint === 'crypto:update-profile') {
const handle = String(request.body.handle ?? '').trim()
if (!/^[A-Za-z0-9][A-Za-z0-9._]{1,18}[A-Za-z0-9]$/.test(handle)) {
response.json({ success: false, error: 'invalid_profile' })
return
}
if (
handle.toLowerCase() !== cryptoProfile.handle.toLowerCase() &&
request.body.password !== cryptoPassword
) {
response.json({ success: false, error: 'invalid_credentials' })
return
}
if (
typeof request.body.priceAlerts !== 'boolean' ||
typeof request.body.tradeConfirmations !== 'boolean' ||
typeof request.body.hideBalances !== 'boolean'
) {
response.json({ success: false, error: 'invalid_profile' })
return
}
cryptoProfile = {
...cryptoProfile,
handle,
hideBalances: request.body.hideBalances,
priceAlerts: request.body.priceAlerts,
tradeConfirmations: request.body.tradeConfirmations,
}
response.json({ success: true, data: cryptoOverview() })
return
}
if (endpoint === 'crypto:deposit' || endpoint === 'crypto:withdraw') {
const amount = Number(request.body.amount)
if (!Number.isSafeInteger(amount) || amount < 10) {
+4
View File
@@ -114,6 +114,10 @@ function verifyBrowserTestData(dataByEndpoint) {
)
expectItems(dataByEndpoint.get('health:overview').days, 'health history', 7)
const crypto = dataByEndpoint.get('crypto:bootstrap')
expectItems(crypto.markets, 'crypto markets', 3)
assert.equal(typeof crypto.profile.priceAlerts, 'boolean')
assert.equal(typeof crypto.markets[0].issuedSupply, 'string')
expectItems(
dataByEndpoint.get('billing:list').invoices,
'billing invoices',
+8 -5
View File
@@ -1054,16 +1054,19 @@ Locales["de"] = {
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 = "320 Buchstaben oder Zahlen", password = "Passwort", passwordPlaceholder = "872 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 = "Ø" },
tabs = { portfolio = "Portfolio", markets = "Märkte", activity = "Aktivität", profile = "Profil" },
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 = "Ø", insightTitle = "Portfolio-Einblick", insightBody = "Deine Bestände werden mit den neuesten synthetischen Serverkursen bewertet." },
quick = { trade = "Handeln", more = "Mehr" },
actions = { deposit = "Einzahlen", withdraw = "Auszahlen" },
markets = { title = "Fiktive Märkte", live = "Serverpreise", noticeTitle = "Ingame-Börse", notice = "Die Kurse sind synthetisch und haben keinen realen Wert. Jeder Handel wird gegen die begrenzte Liquidität der Börse geprüft." },
markets = { title = "Fiktive Märkte", live = "Serverpreise", noticeTitle = "Ingame-Börse", notice = "Die Kurse sind synthetisch und haben keinen realen Wert. Jeder Handel wird gegen die begrenzte Liquidität der Börse geprüft.", eyebrow = "Marktpuls", movers = "Top-Beweger", today = "Heute" },
marketDetail = { back = "Zurück zu den Märkten", today = "Heute", description = "Der Referenzkurs wird vom Server erzeugt. Ausführbare Kurse verwenden ein geschütztes, kurzlebiges Angebot.", investment = "Deine Investition", totalValue = "Aktueller Wert", statistics = "Marktstatistik", high24h = "24h-Hoch", low24h = "24h-Tief", supply = "Ausgegebene Menge", liquidity = "Börsenliquidität" },
trade = { 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." },
activity = { title = "Finanzaktivität", empty = "Keine Aktivität", emptyBody = "Einzahlungen, Auszahlungen und Handel erscheinen hier.", volume = "Handelsvolumen gesamt", completedTrades = "abgeschlossene Trades", cash = "Guthaben", filters = { all = "Alle", trades = "Trades", wallet = "Wallet" } },
profile = { verified = "Charakterbesitz verifiziert", trades = "Trades", volume = "Volumen", memberSince = "Mitglied seit", preferences = "Einstellungen", priceAlerts = "Kurswarnungen", priceAlertsBody = "Bei auffälligen Marktbewegungen benachrichtigen.", confirmations = "Handelsbestätigung", confirmationsBody = "Zusätzliche Bestätigung vor der Ausführung behalten.", hideBalances = "Privatsphäre-Modus", hideBalancesBody = "Beträge in VaultX verbergen.", identity = "Profilidentität", passwordForChange = "Passwort für Benutzernamenänderung", passwordOptional = "Nur bei Änderung des Benutzernamens nötig", saved = "Profil sicher gespeichert.", save = "Profil speichern", securityTitle = "Geschütztes Profil", securityBody = "Dein Profil bleibt an diesen Framework-Charakter und die Finanzsitzung gebunden." },
activityTypes = { buy = "Asset-Kauf", sell = "Asset-Verkauf", deposit = "Bankeinzahlung", withdrawal = "Bankauszahlung" },
statuses = { completed = "Abgeschlossen", pending = "Prüfung ausstehend", failed = "Fehlgeschlagen", manual_review = "Manuelle Prüfung" },
errors = { invalid_handle = "Nutze 320 Buchstaben, Zahlen, Punkte oder Unterstriche.", handle_taken = "Dieser VaultX-Benutzername ist bereits vergeben.", profile_exists = "Dieser Charakter besitzt bereits ein VaultX-Profil.", invalid_password = "Das Passwort muss 872 Zeichen lang sein.", 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." },
errors = { invalid_profile = "Prüfe deine VaultX-Profileinstellungen.", invalid_handle = "Nutze 320 Buchstaben, Zahlen, Punkte oder Unterstriche.", handle_taken = "Dieser VaultX-Benutzername ist bereits vergeben.", profile_exists = "Dieser Charakter besitzt bereits ein VaultX-Profil.", invalid_password = "Das Passwort muss 872 Zeichen lang sein.", 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",
+8 -5
View File
@@ -1054,16 +1054,19 @@ Locales["en"] = {
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 = "320 letters or numbers", password = "Password", passwordPlaceholder = "872 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." },
tabs = { portfolio = "Portfolio", markets = "Markets", activity = "Activity", profile = "Profile" },
portfolio = { total = "Total portfolio", cash = "Available cash", holdings = "Your assets", empty = "No assets yet", emptyBody = "Open Markets to request a protected quote.", avg = "Avg.", insightTitle = "Portfolio insight", insightBody = "Your holdings are valued against the latest synthetic server prices." },
quick = { trade = "Trade", more = "More" },
actions = { deposit = "Deposit", withdraw = "Withdraw" },
markets = { title = "Fictional markets", live = "Server priced", noticeTitle = "In-game exchange", notice = "Prices are synthetic and have no real-world value. Every trade is checked against finite exchange liquidity." },
markets = { title = "Fictional markets", live = "Server priced", noticeTitle = "In-game exchange", notice = "Prices are synthetic and have no real-world value. Every trade is checked against finite exchange liquidity.", eyebrow = "Market pulse", movers = "Top movers", today = "Today" },
marketDetail = { back = "Back to markets", today = "Today", description = "The reference price is generated by the server. Executable prices use a protected short-lived quote.", investment = "Your investment", totalValue = "Current value", statistics = "Market statistics", high24h = "24h high", low24h = "24h low", supply = "Issued supply", liquidity = "Exchange liquidity" },
trade = { 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." },
activity = { title = "Financial activity", empty = "No activity", emptyBody = "Deposits, withdrawals and trades will appear here.", volume = "Lifetime trading volume", completedTrades = "completed trades", cash = "Cash", filters = { all = "All", trades = "Trades", wallet = "Wallet" } },
profile = { verified = "Character ownership verified", trades = "Trades", volume = "Volume", memberSince = "Member since", preferences = "Preferences", priceAlerts = "Price alerts", priceAlertsBody = "Notify me about notable market moves.", confirmations = "Trade confirmations", confirmationsBody = "Keep an extra confirmation before execution.", hideBalances = "Privacy mode", hideBalancesBody = "Hide balances across VaultX.", identity = "Profile identity", passwordForChange = "Password for handle change", passwordOptional = "Only required when changing handle", saved = "Profile saved securely.", save = "Save profile", securityTitle = "Protected profile", securityBody = "Your profile stays bound to this framework character and financial session." },
activityTypes = { buy = "Asset purchase", 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 320 letters, numbers, dots or underscores.", handle_taken = "That VaultX handle is already taken.", profile_exists = "This character already owns a VaultX profile.", invalid_password = "Password must be 872 characters.", 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." },
errors = { invalid_profile = "Check your VaultX profile settings.", invalid_handle = "Use 320 letters, numbers, dots or underscores.", handle_taken = "That VaultX handle is already taken.", profile_exists = "This character already owns a VaultX profile.", invalid_password = "Password must be 872 characters.", 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",
+1
View File
@@ -250,6 +250,7 @@ local server_callbacks = {
"crypto:execute",
"crypto:deposit",
"crypto:withdraw",
"crypto:update-profile",
"billing:overview",
"billing:list",
"billing:detail",
+71 -1
View File
@@ -14,6 +14,9 @@ local function ensure_schema()
`account_id` BIGINT UNSIGNED NULL,
`handle` VARCHAR(20) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`password_hash` VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`price_alerts` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1,
`trade_confirmations` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1,
`hide_balances` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0,
`status` ENUM('active','frozen','closed') NOT NULL DEFAULT 'active',
`failed_logins` TINYINT UNSIGNED NOT NULL DEFAULT 0,
`locked_until` DATETIME NULL,
@@ -138,6 +141,9 @@ local function ensure_schema()
for _, statement in ipairs(statements) do
Bridge.Database.Query(statement, {})
end
Bridge.Database.Query("ALTER TABLE `sky_phone_crypto_profiles` ADD COLUMN IF NOT EXISTS `price_alerts` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1 AFTER `password_hash`", {})
Bridge.Database.Query("ALTER TABLE `sky_phone_crypto_profiles` ADD COLUMN IF NOT EXISTS `trade_confirmations` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1 AFTER `price_alerts`", {})
Bridge.Database.Query("ALTER TABLE `sky_phone_crypto_profiles` ADD COLUMN IF NOT EXISTS `hide_balances` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 AFTER `trade_confirmations`", {})
end
local function new_id()
@@ -282,6 +288,8 @@ end
local function profile_by_owner(identifier)
return Bridge.Database.Query([[
SELECT `id`,`owner_identifier`,`account_id`,`handle`,`status`,`failed_logins`,
`price_alerts`,`trade_confirmations`,`hide_balances`,
UNIX_TIMESTAMP(`created_at`) AS `created_at`,
UNIX_TIMESTAMP(`locked_until`) AS `locked_until`
FROM `sky_phone_crypto_profiles` WHERE `owner_identifier` = ? LIMIT 1
]], { identifier })[1]
@@ -385,6 +393,10 @@ local function market_dtos()
price = decimal_string(price, Config.Crypto.PriceScale),
changePercent = first > 0 and ((price - first) / first) * 100 or 0,
enabled = row.status == "active",
high24h = decimal_string(math.max(table.unpack(prices)), Config.Crypto.PriceScale),
low24h = decimal_string(math.min(table.unpack(prices)), Config.Crypto.PriceScale),
issuedSupply = decimal_string(config.IssuedSupply * Config.Crypto.AssetScale, Config.Crypto.AssetScale),
treasuryAvailable = decimal_string(balance("treasury", market_id), Config.Crypto.AssetScale),
sparkline = sparkline,
}
end
@@ -446,9 +458,24 @@ local function bootstrap(profile)
portfolio = portfolio + value
end
end
local metrics = Bridge.Database.Query([[
SELECT COUNT(*) AS `total_trades`, COALESCE(SUM(`amount`), 0) AS `total_volume`
FROM `sky_phone_crypto_operations`
WHERE `profile_id` = ? AND `type` IN ('buy','sell') AND `status` = 'completed'
]], { profile.id })[1]
return {
authenticated = true,
profile = { id = profile.id, handle = profile.handle, status = profile.status },
profile = {
id = profile.id,
handle = profile.handle,
status = profile.status,
createdAt = (tonumber(profile.created_at) or 0) * 1000,
hideBalances = tonumber(profile.hide_balances) == 1,
priceAlerts = tonumber(profile.price_alerts) == 1,
tradeConfirmations = tonumber(profile.trade_confirmations) == 1,
totalTrades = tonumber(metrics and metrics.total_trades) or 0,
totalVolume = decimal_string(metrics and metrics.total_volume, Config.Crypto.PriceScale),
},
cashBalance = decimal_string(cash, Config.Crypto.PriceScale),
portfolioValue = decimal_string(portfolio, Config.Crypto.PriceScale),
holdings = holdings,
@@ -628,6 +655,49 @@ Bridge.Callbacks.Register("sky_phone:crypto:logout", function(source)
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:crypto:update-profile", function(source, data)
if not SkyPhone.AllowOperation(source, "crypto:update-profile", 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
data = type(data) == "table" and data or {}
local handle = valid_handle(data.handle)
if not handle or type(data.priceAlerts) ~= "boolean"
or type(data.tradeConfirmations) ~= "boolean" or type(data.hideBalances) ~= "boolean"
then
return { success = false, error = "invalid_profile" }
end
if handle:lower() ~= profile.handle:lower() then
if not verify_password(profile.id, data.password) then
audit(profile.id, profile.owner_identifier, "profile_update_reauth_failed", "")
return { success = false, error = "invalid_credentials" }
end
local duplicate = Bridge.Database.Query(
"SELECT 1 FROM `sky_phone_crypto_profiles` WHERE `handle` = ? AND `id` <> ? LIMIT 1",
{ handle, profile.id }
)
if duplicate[1] then
return { success = false, error = "handle_taken" }
end
end
Bridge.Database.Query([[
UPDATE `sky_phone_crypto_profiles`
SET `handle` = ?, `price_alerts` = ?, `trade_confirmations` = ?, `hide_balances` = ?
WHERE `id` = ?
]], {
handle,
data.priceAlerts and 1 or 0,
data.tradeConfirmations and 1 or 0,
data.hideBalances and 1 or 0,
profile.id,
})
audit(profile.id, profile.owner_identifier, "profile_updated", handle)
return { success = true, data = bootstrap(profile_by_owner(profile.owner_identifier)) }
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" }
+3
View File
@@ -1351,6 +1351,9 @@ CREATE TABLE IF NOT EXISTS `sky_phone_crypto_profiles` (
`account_id` BIGINT UNSIGNED NULL,
`handle` VARCHAR(20) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`password_hash` VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`price_alerts` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1,
`trade_confirmations` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1,
`hide_balances` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0,
`status` ENUM('active','frozen','closed') NOT NULL DEFAULT 'active',
`failed_logins` TINYINT UNSIGNED NOT NULL DEFAULT 0,
`locked_until` DATETIME NULL,