mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
ADD - enable secure VaultX crypto transfers
Give every VaultX profile a unique public receiving key and migrate existing profiles safely. Add server-authoritative, password-confirmed, idempotent crypto-only transfers with atomic balance and ledger updates, recipient refresh events, detailed auth flows, profile key display, localized transfer UI, and browser mock coverage.
This commit is contained in:
@@ -1031,6 +1031,8 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
} else if (event.data?.type === 'crypto:changed' && event.data.data) {
|
||||
const data = event.data.data as CryptoMarketChangedData
|
||||
crypto.applyMarketUpdate(data.markets)
|
||||
} else if (event.data?.type === 'crypto:account-changed') {
|
||||
if (crypto.data?.authenticated) void crypto.load()
|
||||
} else if (event.data?.type === 'billing:changed') {
|
||||
void billing.loadOverview()
|
||||
} else if (event.data?.type === 'billing:new' && event.data.data) {
|
||||
|
||||
@@ -25,6 +25,7 @@ const bootstrap: CryptoBootstrap = {
|
||||
totalTrades: 12,
|
||||
totalVolume: '18462.80',
|
||||
tradeConfirmations: true,
|
||||
walletKey: 'VX-7F3A-92C1-44BE-810D',
|
||||
},
|
||||
}
|
||||
const quote: CryptoQuote = {
|
||||
@@ -195,4 +196,40 @@ describe('crypto store', () => {
|
||||
tradeConfirmations: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a public key and sends only crypto with an idempotency key', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
handle: 'receiver',
|
||||
walletKey: 'VX-DEAD-BEEF-C0DE-2026',
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ data: bootstrap, success: true })
|
||||
const crypto = useCryptoStore()
|
||||
|
||||
expect(await crypto.resolveRecipient('VX-DEAD-BEEF-C0DE-2026')).toEqual({
|
||||
handle: 'receiver',
|
||||
walletKey: 'VX-DEAD-BEEF-C0DE-2026',
|
||||
})
|
||||
expect(
|
||||
await crypto.transfer({
|
||||
marketId: 'aurora',
|
||||
password: 'VaultX123!',
|
||||
quantity: '0.5',
|
||||
walletKey: 'VX-DEAD-BEEF-C0DE-2026',
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(mockNuiCall).toHaveBeenLastCalledWith(
|
||||
'crypto:transfer',
|
||||
expect.objectContaining({
|
||||
marketId: 'aurora',
|
||||
password: 'VaultX123!',
|
||||
quantity: '0.5',
|
||||
walletKey: 'VX-DEAD-BEEF-C0DE-2026',
|
||||
idempotencyKey: expect.stringMatching(/^transfer-/),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CryptoBootstrap,
|
||||
CryptoMarket,
|
||||
CryptoQuote,
|
||||
CryptoRecipient,
|
||||
CryptoSide,
|
||||
} from '@/types/crypto'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
@@ -94,6 +95,26 @@ export const useCryptoStore = defineStore('crypto', {
|
||||
this.pendingQuote = null
|
||||
}
|
||||
},
|
||||
async resolveRecipient(walletKey: string): Promise<CryptoRecipient | null> {
|
||||
const response = await this.call<CryptoRecipient>('recipient', {
|
||||
walletKey,
|
||||
})
|
||||
return response.success ? (response.data ?? null) : null
|
||||
},
|
||||
async transfer(payload: {
|
||||
marketId: string
|
||||
password: string
|
||||
quantity: string
|
||||
walletKey: string
|
||||
}): Promise<boolean> {
|
||||
const response = await this.call<CryptoBootstrap>('transfer', {
|
||||
...payload,
|
||||
idempotencyKey: requestKey('transfer'),
|
||||
})
|
||||
if (!response.success || !response.data) return false
|
||||
this.data = response.data
|
||||
return true
|
||||
},
|
||||
async settle(
|
||||
kind: 'deposit' | 'withdraw',
|
||||
amount: string,
|
||||
|
||||
@@ -32,11 +32,19 @@ export type CryptoHolding = {
|
||||
|
||||
export type CryptoActivity = {
|
||||
amount: string
|
||||
counterpartyKey?: string
|
||||
createdAt: number
|
||||
id: string
|
||||
marketId?: string
|
||||
quantity?: string
|
||||
status: string
|
||||
type: 'buy' | 'sell' | 'deposit' | 'withdrawal'
|
||||
type:
|
||||
| 'buy'
|
||||
| 'sell'
|
||||
| 'deposit'
|
||||
| 'withdrawal'
|
||||
| 'transfer_in'
|
||||
| 'transfer_out'
|
||||
}
|
||||
|
||||
export type CryptoProfile = {
|
||||
@@ -49,6 +57,7 @@ export type CryptoProfile = {
|
||||
totalTrades: number
|
||||
totalVolume: string
|
||||
tradeConfirmations: boolean
|
||||
walletKey: string
|
||||
}
|
||||
|
||||
export type CryptoBootstrap = {
|
||||
@@ -59,6 +68,12 @@ export type CryptoBootstrap = {
|
||||
markets: CryptoMarket[]
|
||||
portfolioValue: string
|
||||
profile: CryptoProfile | null
|
||||
registered?: boolean
|
||||
}
|
||||
|
||||
export type CryptoRecipient = {
|
||||
handle: string
|
||||
walletKey: string
|
||||
}
|
||||
|
||||
export type CryptoQuote = {
|
||||
|
||||
@@ -82,6 +82,34 @@ describe('VaultX crypto app contracts', () => {
|
||||
expect(server).toContain('CryptoHashPassword(new_password)')
|
||||
})
|
||||
|
||||
it('provides detailed registration and server-authoritative crypto-key transfers', () => {
|
||||
const transferServer = server.slice(
|
||||
server.indexOf('local function execute_transfer'),
|
||||
server.indexOf('Bridge.Callbacks.Register("sky_phone:crypto:quote"'),
|
||||
)
|
||||
|
||||
expect(source).toContain('class="password-strength"')
|
||||
expect(source).toContain('v-model="confirmPassword"')
|
||||
expect(source).toContain('<SkyCheckbox')
|
||||
expect(source).toContain("sheet.value = 'send'")
|
||||
expect(source).toContain('v-model="transferWalletKey"')
|
||||
expect(source).toContain('profile?.walletKey')
|
||||
expect(server).toContain('`crypto_key` CHAR(22)')
|
||||
expect(server).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:crypto:recipient"',
|
||||
)
|
||||
expect(server).toContain(
|
||||
'Bridge.Callbacks.Register("sky_phone:crypto:transfer"',
|
||||
)
|
||||
expect(server).toContain("'transfer_out'")
|
||||
expect(server).toContain("'transfer_in'")
|
||||
expect(server).toContain('Bridge.Database.Transaction(queries)')
|
||||
expect(server).toContain('verify_password(profile.id, data.password)')
|
||||
expect(transferServer).not.toContain('"CASH"')
|
||||
expect(transferServer).not.toContain('Bridge.Framework.AddMoney')
|
||||
expect(transferServer).not.toContain('Bridge.Framework.RemoveMoney')
|
||||
})
|
||||
|
||||
it('uses the premium dashboard hierarchy without manual refresh controls', () => {
|
||||
expect(source).toContain('class="portfolio-shell"')
|
||||
expect(source).toContain('class="featured-market"')
|
||||
|
||||
@@ -5,14 +5,18 @@ import {
|
||||
BellRing,
|
||||
ChartCandlestick,
|
||||
ChartNoAxesCombined,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Fingerprint,
|
||||
History,
|
||||
KeyRound,
|
||||
LockKeyhole,
|
||||
LogOut,
|
||||
Settings2,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
@@ -24,11 +28,17 @@ import cryptoHeaderLogo from '@/assets/img/app-icons/crypto-header-logo.png'
|
||||
import CryptoLogo from '@/components/crypto/CryptoLogo.vue'
|
||||
import { useCryptoStore } from '@/stores/crypto'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { CryptoMarket, CryptoSide } from '@/types/crypto'
|
||||
import type {
|
||||
CryptoActivity,
|
||||
CryptoMarket,
|
||||
CryptoRecipient,
|
||||
CryptoSide,
|
||||
} from '@/types/crypto'
|
||||
import {
|
||||
SkyAppPage,
|
||||
SkyButton,
|
||||
SkyCard,
|
||||
SkyCheckbox,
|
||||
SkyEmptyState,
|
||||
SkyField,
|
||||
SkyLink,
|
||||
@@ -44,7 +54,7 @@ import {
|
||||
} from '@/ui'
|
||||
|
||||
type Tab = 'portfolio' | 'markets' | 'activity' | 'profile'
|
||||
type Sheet = 'trade' | 'deposit' | 'withdraw' | 'profile' | null
|
||||
type Sheet = 'trade' | 'deposit' | 'withdraw' | 'profile' | 'send' | null
|
||||
type ChartPeriod = '1D' | '1W' | '1M' | '6M' | '1Y'
|
||||
|
||||
const CHART_PERIODS: ChartPeriod[] = ['1D', '1W', '1M', '6M', '1Y']
|
||||
@@ -81,6 +91,8 @@ const selectedMarket = ref<CryptoMarket | null>(null)
|
||||
const side = ref<CryptoSide>('buy')
|
||||
const handle = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const acceptedTerms = ref(false)
|
||||
const amount = ref('')
|
||||
const financialPassword = ref('')
|
||||
const showPassword = ref(false)
|
||||
@@ -93,6 +105,12 @@ const confirmations = ref(true)
|
||||
const hideBalances = ref(false)
|
||||
const saved = ref(false)
|
||||
const period = ref<ChartPeriod>('1D')
|
||||
const transferWalletKey = ref('')
|
||||
const transferMarketId = ref('')
|
||||
const transferQuantity = ref('')
|
||||
const transferPassword = ref('')
|
||||
const transferRecipient = ref<CryptoRecipient | null>(null)
|
||||
const walletKeyCopied = ref(false)
|
||||
|
||||
const locale = computed(() => phone.lang || 'de')
|
||||
const authenticated = computed(() => crypto.data?.authenticated === true)
|
||||
@@ -105,9 +123,24 @@ const activities = computed(() =>
|
||||
activityFilter.value === 'all' ||
|
||||
(activityFilter.value === 'trades'
|
||||
? ['buy', 'sell'].includes(item.type)
|
||||
: ['deposit', 'withdrawal'].includes(item.type)),
|
||||
: ['deposit', 'withdrawal', 'transfer_in', 'transfer_out'].includes(
|
||||
item.type,
|
||||
)),
|
||||
),
|
||||
)
|
||||
const passwordChecks = computed(() => ({
|
||||
length: password.value.length >= 8,
|
||||
mixed: /[a-z]/.test(password.value) && /[A-Z]/.test(password.value),
|
||||
number: /\d/.test(password.value),
|
||||
special: /[^A-Za-z0-9]/.test(password.value),
|
||||
}))
|
||||
const passwordStrength = computed(
|
||||
() => Object.values(passwordChecks.value).filter(Boolean).length,
|
||||
)
|
||||
const transferMarket = computed(() => market(transferMarketId.value))
|
||||
const transferHolding = computed(() =>
|
||||
holdings.value.find((item) => item.assetId === transferMarketId.value),
|
||||
)
|
||||
const investedValue = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
@@ -414,6 +447,15 @@ function openSettlement(next: 'deposit' | 'withdraw') {
|
||||
financialPassword.value = ''
|
||||
formError.value = ''
|
||||
}
|
||||
function openSend() {
|
||||
transferWalletKey.value = ''
|
||||
transferMarketId.value = holdings.value[0]?.assetId ?? ''
|
||||
transferQuantity.value = ''
|
||||
transferPassword.value = ''
|
||||
transferRecipient.value = null
|
||||
formError.value = ''
|
||||
sheet.value = 'send'
|
||||
}
|
||||
function openProfileEditor() {
|
||||
profileHandle.value = profile.value?.handle ?? ''
|
||||
profileCurrentPassword.value = ''
|
||||
@@ -427,16 +469,85 @@ function closeSheet() {
|
||||
crypto.pendingQuote = null
|
||||
profileCurrentPassword.value = ''
|
||||
profileNewPassword.value = ''
|
||||
transferRecipient.value = null
|
||||
transferPassword.value = ''
|
||||
}
|
||||
|
||||
async function submitAuth() {
|
||||
formError.value = ''
|
||||
if (
|
||||
authMode.value === 'register' &&
|
||||
(passwordStrength.value < 4 ||
|
||||
password.value !== confirmPassword.value ||
|
||||
!acceptedTerms.value)
|
||||
) {
|
||||
formError.value = t(
|
||||
passwordStrength.value < 4
|
||||
? 'errors.invalid_password'
|
||||
: password.value !== confirmPassword.value
|
||||
? 'errors.password_mismatch'
|
||||
: 'errors.accept_terms',
|
||||
)
|
||||
return
|
||||
}
|
||||
const success =
|
||||
authMode.value === 'register'
|
||||
? await crypto.register(handle.value.trim(), password.value)
|
||||
: await crypto.login(password.value)
|
||||
if (!success) formError.value = errorText(crypto.error)
|
||||
else password.value = ''
|
||||
else {
|
||||
password.value = ''
|
||||
confirmPassword.value = ''
|
||||
}
|
||||
}
|
||||
async function resolveTransferRecipient() {
|
||||
formError.value = ''
|
||||
transferRecipient.value = null
|
||||
const recipient = await crypto.resolveRecipient(transferWalletKey.value)
|
||||
if (!recipient) formError.value = errorText(crypto.error)
|
||||
else {
|
||||
transferWalletKey.value = recipient.walletKey
|
||||
transferRecipient.value = recipient
|
||||
}
|
||||
}
|
||||
async function submitTransfer() {
|
||||
formError.value = ''
|
||||
if (
|
||||
!transferRecipient.value ||
|
||||
transferRecipient.value.walletKey !== transferWalletKey.value ||
|
||||
!transferMarket.value ||
|
||||
!/^\d+(?:\.\d{1,6})?$/.test(transferQuantity.value)
|
||||
) {
|
||||
formError.value = t('errors.invalid_transfer')
|
||||
return
|
||||
}
|
||||
const success = await crypto.transfer({
|
||||
marketId: transferMarket.value.id,
|
||||
password: transferPassword.value,
|
||||
quantity: transferQuantity.value,
|
||||
walletKey: transferRecipient.value.walletKey,
|
||||
})
|
||||
if (!success) formError.value = errorText(crypto.error)
|
||||
else closeSheet()
|
||||
}
|
||||
async function copyWalletKey() {
|
||||
if (!profile.value?.walletKey) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(profile.value.walletKey)
|
||||
walletKeyCopied.value = true
|
||||
globalThis.setTimeout(() => (walletKeyCopied.value = false), 1800)
|
||||
} catch {
|
||||
walletKeyCopied.value = false
|
||||
}
|
||||
}
|
||||
function activityValue(item: CryptoActivity) {
|
||||
if (item.type === 'transfer_in' || item.type === 'transfer_out') {
|
||||
return `${quantity(item.quantity ?? '0')} ${market(item.marketId)?.symbol ?? ''}`
|
||||
}
|
||||
return privateMoney(item.amount)
|
||||
}
|
||||
function activityIsDebit(item: CryptoActivity) {
|
||||
return ['buy', 'withdrawal', 'transfer_out'].includes(item.type)
|
||||
}
|
||||
async function submitSettlement() {
|
||||
if (sheet.value !== 'deposit' && sheet.value !== 'withdraw') return
|
||||
@@ -519,6 +630,20 @@ watch(amount, () => {
|
||||
formError.value = ''
|
||||
}
|
||||
})
|
||||
watch(transferWalletKey, () => {
|
||||
if (
|
||||
transferRecipient.value?.walletKey !== transferWalletKey.value.toUpperCase()
|
||||
) {
|
||||
transferRecipient.value = null
|
||||
}
|
||||
})
|
||||
watch(
|
||||
() => crypto.data?.registered,
|
||||
(registered) => {
|
||||
if (!authenticated.value) authMode.value = registered ? 'login' : 'register'
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
watch(markets, (value) => {
|
||||
if (detail.value) {
|
||||
detail.value =
|
||||
@@ -598,7 +723,8 @@ onMounted(() => void crypto.load())
|
||||
padded
|
||||
>
|
||||
<div class="auth-hero">
|
||||
<span><ChartCandlestick :size="32" /></span>
|
||||
<span class="auth-hero__mark"><ChartCandlestick :size="30" /></span>
|
||||
<span class="auth-status"><i />{{ t('auth.network') }}</span>
|
||||
<p>{{ t('auth.eyebrow') }}</p>
|
||||
<h2>
|
||||
{{
|
||||
@@ -606,55 +732,115 @@ onMounted(() => void crypto.load())
|
||||
}}
|
||||
</h2>
|
||||
<small>{{ t('auth.body') }}</small>
|
||||
<div class="auth-benefits">
|
||||
<span><ShieldCheck :size="16" />{{ t('auth.serverSecured') }}</span>
|
||||
<span><KeyRound :size="16" />{{ t('auth.personalKey') }}</span>
|
||||
<span><Fingerprint :size="16" />{{ t('auth.characterBound') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SkySegmented
|
||||
strong
|
||||
:active-index="authMode === 'login' ? 0 : 1"
|
||||
:item-count="2"
|
||||
><SkySegmentedButton
|
||||
:active="authMode === 'login'"
|
||||
@click="authMode = 'login'"
|
||||
>{{ t('auth.login') }}</SkySegmentedButton
|
||||
><SkySegmentedButton
|
||||
:active="authMode === 'register'"
|
||||
@click="authMode = 'register'"
|
||||
>{{ t('auth.register') }}</SkySegmentedButton
|
||||
></SkySegmented
|
||||
>
|
||||
<form class="form" @submit.prevent="submitAuth">
|
||||
<SkyField
|
||||
v-if="authMode === 'register'"
|
||||
v-model="handle"
|
||||
:label="t('auth.handle')"
|
||||
:placeholder="t('auth.handlePlaceholder')"
|
||||
maxlength="20"
|
||||
outline
|
||||
/>
|
||||
<SkyField
|
||||
v-model="password"
|
||||
:label="t('auth.password')"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:placeholder="t('auth.passwordPlaceholder')"
|
||||
maxlength="72"
|
||||
outline
|
||||
><template #leading><LockKeyhole :size="18" /></template
|
||||
><template #trailing
|
||||
><button
|
||||
class="visibility"
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<EyeOff v-if="showPassword" :size="18" /><Eye
|
||||
v-else
|
||||
:size="18"
|
||||
/></button></template
|
||||
></SkyField>
|
||||
<p class="hint"><ShieldCheck :size="15" />{{ t('auth.security') }}</p>
|
||||
<p v-if="formError" class="error">{{ formError }}</p>
|
||||
<SkyButton block type="submit">{{
|
||||
t(authMode === 'login' ? 'auth.loginAction' : 'auth.registerAction')
|
||||
}}</SkyButton>
|
||||
</form>
|
||||
<section class="auth-panel">
|
||||
<SkySegmented
|
||||
class="auth-mode"
|
||||
strong
|
||||
:active-index="authMode === 'login' ? 0 : 1"
|
||||
:item-count="2"
|
||||
><SkySegmentedButton
|
||||
:active="authMode === 'login'"
|
||||
@click="authMode = 'login'"
|
||||
>{{ t('auth.login') }}</SkySegmentedButton
|
||||
><SkySegmentedButton
|
||||
:active="authMode === 'register'"
|
||||
@click="authMode = 'register'"
|
||||
>{{ t('auth.register') }}</SkySegmentedButton
|
||||
></SkySegmented
|
||||
>
|
||||
<div class="auth-panel__heading">
|
||||
<span>{{ authMode === 'login' ? '01' : '02' }}</span>
|
||||
<div>
|
||||
<b>{{ t(`auth.${authMode}PanelTitle`) }}</b>
|
||||
<small>{{ t(`auth.${authMode}PanelBody`) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<form class="form auth-form" @submit.prevent="submitAuth">
|
||||
<SkyField
|
||||
v-if="authMode === 'register'"
|
||||
v-model="handle"
|
||||
:label="t('auth.handle')"
|
||||
:placeholder="t('auth.handlePlaceholder')"
|
||||
maxlength="20"
|
||||
outline
|
||||
><template #leading><UserRound :size="18" /></template
|
||||
></SkyField>
|
||||
<SkyField
|
||||
v-model="password"
|
||||
:label="t('auth.password')"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:placeholder="t('auth.passwordPlaceholder')"
|
||||
maxlength="72"
|
||||
outline
|
||||
><template #leading><LockKeyhole :size="18" /></template
|
||||
><template #trailing
|
||||
><button
|
||||
class="visibility"
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<EyeOff v-if="showPassword" :size="18" /><Eye
|
||||
v-else
|
||||
:size="18"
|
||||
/></button></template
|
||||
></SkyField>
|
||||
<template v-if="authMode === 'register'">
|
||||
<SkyField
|
||||
v-model="confirmPassword"
|
||||
:label="t('auth.confirmPassword')"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:placeholder="t('auth.confirmPasswordPlaceholder')"
|
||||
maxlength="72"
|
||||
outline
|
||||
><template #leading><Fingerprint :size="18" /></template
|
||||
></SkyField>
|
||||
<div class="password-strength">
|
||||
<div>
|
||||
<i
|
||||
v-for="index in 4"
|
||||
:key="index"
|
||||
:class="{ active: passwordStrength >= index }"
|
||||
/>
|
||||
</div>
|
||||
<span>{{ t(`auth.strength${passwordStrength}`) }}</span>
|
||||
</div>
|
||||
<div class="password-rules">
|
||||
<span :class="{ done: passwordChecks.length }"
|
||||
><CheckCircle2 :size="13" />{{ t('auth.ruleLength') }}</span
|
||||
>
|
||||
<span :class="{ done: passwordChecks.mixed }"
|
||||
><CheckCircle2 :size="13" />{{ t('auth.ruleMixed') }}</span
|
||||
>
|
||||
<span :class="{ done: passwordChecks.number }"
|
||||
><CheckCircle2 :size="13" />{{ t('auth.ruleNumber') }}</span
|
||||
>
|
||||
<span :class="{ done: passwordChecks.special }"
|
||||
><CheckCircle2 :size="13" />{{ t('auth.ruleSpecial') }}</span
|
||||
>
|
||||
</div>
|
||||
<SkyCheckbox v-model="acceptedTerms" class="auth-consent">
|
||||
<span
|
||||
><b>{{ t('auth.acceptTitle') }}</b
|
||||
><small>{{ t('auth.acceptBody') }}</small></span
|
||||
>
|
||||
</SkyCheckbox>
|
||||
</template>
|
||||
<p class="hint"><ShieldCheck :size="15" />{{ t('auth.security') }}</p>
|
||||
<p v-if="formError" class="error">{{ formError }}</p>
|
||||
<SkyButton block large class="auth-submit" type="submit">{{
|
||||
t(authMode === 'login' ? 'auth.loginAction' : 'auth.registerAction')
|
||||
}}</SkyButton>
|
||||
</form>
|
||||
<div class="auth-footer">
|
||||
<LockKeyhole :size="13" />{{ t('auth.footer') }}
|
||||
</div>
|
||||
</section>
|
||||
</SkyScrollArea>
|
||||
|
||||
<SkyScrollArea
|
||||
@@ -939,9 +1125,9 @@ onMounted(() => void crypto.load())
|
||||
><button @click="openSettlement('withdraw')">
|
||||
<span><ArrowUpRight :size="20" /></span>
|
||||
<b>{{ t('actions.withdraw') }}</b></button
|
||||
><button @click="setTab('profile')">
|
||||
<span><Settings2 :size="20" /></span>
|
||||
<b>{{ t('quick.more') }}</b>
|
||||
><button @click="openSend">
|
||||
<span><Send :size="20" /></span>
|
||||
<b>{{ t('quick.send') }}</b>
|
||||
</button>
|
||||
</div>
|
||||
<SkyCard class="allocation-card">
|
||||
@@ -1185,6 +1371,8 @@ onMounted(() => void crypto.load())
|
||||
<span :class="['activity-icon', `activity-icon--${item.type}`]">
|
||||
<ArrowDownLeft v-if="item.type === 'deposit'" :size="18" />
|
||||
<ArrowUpRight v-else-if="item.type === 'withdrawal'" :size="18" />
|
||||
<Send v-else-if="item.type === 'transfer_out'" :size="18" />
|
||||
<KeyRound v-else-if="item.type === 'transfer_in'" :size="18" />
|
||||
<ChartNoAxesCombined v-else :size="18" /> </span
|
||||
><span
|
||||
><b>{{ t(`activityTypes.${item.type}`) }}</b
|
||||
@@ -1198,10 +1386,9 @@ onMounted(() => void crypto.load())
|
||||
}}</small
|
||||
></span
|
||||
><span
|
||||
><b
|
||||
:class="['buy', 'withdrawal'].includes(item.type) ? 'down' : 'up'"
|
||||
>{{ ['buy', 'withdrawal'].includes(item.type) ? '−' : '+'
|
||||
}}{{ privateMoney(item.amount) }}</b
|
||||
><b :class="activityIsDebit(item) ? 'down' : 'up'"
|
||||
>{{ activityIsDebit(item) ? '−' : '+'
|
||||
}}{{ activityValue(item) }}</b
|
||||
><small>{{ t(`statuses.${item.status}`) }}</small></span
|
||||
>
|
||||
<i class="timeline-dot" />
|
||||
@@ -1237,6 +1424,18 @@ onMounted(() => void crypto.load())
|
||||
<Fingerprint :size="26" />
|
||||
</div>
|
||||
</section>
|
||||
<SkyCard class="wallet-key-card" :content-wrap="false">
|
||||
<span class="wallet-key-card__icon"><KeyRound :size="20" /></span>
|
||||
<span>
|
||||
<small>{{ t('profile.walletKey') }}</small>
|
||||
<b>{{ profile?.walletKey }}</b>
|
||||
<em>{{ t('profile.walletKeyBody') }}</em>
|
||||
</span>
|
||||
<button :aria-label="t('profile.copyKey')" @click="copyWalletKey">
|
||||
<CheckCircle2 v-if="walletKeyCopied" :size="17" />
|
||||
<Copy v-else :size="17" />
|
||||
</button>
|
||||
</SkyCard>
|
||||
<div class="profile-stats profile-stats--premium">
|
||||
<div>
|
||||
<b>{{ profile?.totalTrades }}</b
|
||||
@@ -1391,6 +1590,7 @@ onMounted(() => void crypto.load())
|
||||
/>
|
||||
<ArrowDownLeft v-else-if="sheet === 'deposit'" :size="19" />
|
||||
<ArrowUpRight v-else-if="sheet === 'withdraw'" :size="19" />
|
||||
<Send v-else-if="sheet === 'send'" :size="19" />
|
||||
<UserRound v-else :size="19" />
|
||||
</span>
|
||||
<span>
|
||||
@@ -1399,7 +1599,9 @@ onMounted(() => void crypto.load())
|
||||
? selectedMarket.name
|
||||
: sheet === 'profile'
|
||||
? t('profile.account')
|
||||
: t('activity.cash')
|
||||
: sheet === 'send'
|
||||
? t('transfer.subtitle')
|
||||
: t('activity.cash')
|
||||
}}</small>
|
||||
<h2>
|
||||
{{
|
||||
@@ -1407,7 +1609,9 @@ onMounted(() => void crypto.load())
|
||||
? `${t(`trade.${side}`)} ${selectedMarket?.symbol}`
|
||||
: sheet === 'profile'
|
||||
? t('profile.editTitle')
|
||||
: t(`actions.${sheet}`)
|
||||
: sheet === 'send'
|
||||
? t('transfer.title')
|
||||
: t(`actions.${sheet}`)
|
||||
}}
|
||||
</h2>
|
||||
</span>
|
||||
@@ -1526,6 +1730,97 @@ onMounted(() => void crypto.load())
|
||||
t(crypto.pendingQuote ? 'trade.confirm' : 'trade.getQuote')
|
||||
}}</SkyButton
|
||||
> </template
|
||||
><template v-else-if="sheet === 'send'">
|
||||
<div class="transfer-intro">
|
||||
<span><KeyRound :size="19" /></span>
|
||||
<div>
|
||||
<b>{{ t('transfer.keyTitle') }}</b
|
||||
><small>{{ t('transfer.keyBody') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transfer-key-entry">
|
||||
<SkyField
|
||||
v-model="transferWalletKey"
|
||||
:label="t('transfer.walletKey')"
|
||||
placeholder="VX-0000-0000-0000-0000"
|
||||
maxlength="22"
|
||||
autocomplete="off"
|
||||
outline
|
||||
><template #leading><KeyRound :size="17" /></template
|
||||
></SkyField>
|
||||
<SkyButton
|
||||
class="transfer-resolve"
|
||||
@click="resolveTransferRecipient"
|
||||
>
|
||||
{{ t('transfer.verify') }}
|
||||
</SkyButton>
|
||||
</div>
|
||||
<div v-if="transferRecipient" class="transfer-recipient">
|
||||
<span><CheckCircle2 :size="20" /></span>
|
||||
<div>
|
||||
<small>{{ t('transfer.verifiedRecipient') }}</small
|
||||
><b>@{{ transferRecipient.handle }}</b
|
||||
><em>{{ transferRecipient.walletKey }}</em>
|
||||
</div>
|
||||
</div>
|
||||
<div class="transfer-assets">
|
||||
<small>{{ t('transfer.asset') }}</small>
|
||||
<div>
|
||||
<button
|
||||
v-for="holding in holdings"
|
||||
:key="holding.assetId"
|
||||
:class="{ active: transferMarketId === holding.assetId }"
|
||||
@click="transferMarketId = holding.assetId"
|
||||
>
|
||||
<CryptoLogo :market="market(holding.assetId)!" />
|
||||
<span
|
||||
><b>{{ market(holding.assetId)?.symbol }}</b
|
||||
><small>{{ quantity(holding.quantity) }}</small></span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SkyField
|
||||
v-model="transferQuantity"
|
||||
:label="t('transfer.quantity')"
|
||||
placeholder="0.000000"
|
||||
inputmode="decimal"
|
||||
outline
|
||||
><template #trailing
|
||||
><b>{{ transferMarket?.symbol }}</b></template
|
||||
></SkyField
|
||||
>
|
||||
<div class="transfer-balance">
|
||||
<span>{{ t('transfer.available') }}</span>
|
||||
<b
|
||||
>{{ quantity(transferHolding?.quantity ?? '0') }}
|
||||
{{ transferMarket?.symbol }}</b
|
||||
>
|
||||
</div>
|
||||
<SkyField
|
||||
v-model="transferPassword"
|
||||
:label="t('transfer.password')"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
outline
|
||||
><template #leading><LockKeyhole :size="17" /></template
|
||||
></SkyField>
|
||||
<div class="trade-protection">
|
||||
<ShieldCheck :size="17" />
|
||||
<span
|
||||
><b>{{ t('transfer.protected') }}</b
|
||||
><small>{{ t('transfer.protectedBody') }}</small></span
|
||||
>
|
||||
</div>
|
||||
<p v-if="formError" class="error">{{ formError }}</p>
|
||||
<SkyButton
|
||||
block
|
||||
large
|
||||
class="transfer-submit"
|
||||
@click="submitTransfer"
|
||||
>
|
||||
<Send :size="17" />{{ t('transfer.confirm') }}
|
||||
</SkyButton> </template
|
||||
><template v-else-if="sheet === 'profile'">
|
||||
<p class="sheet-copy">{{ t('profile.editBody') }}</p>
|
||||
<form class="profile-edit-sheet" @submit.prevent="saveProfile">
|
||||
@@ -1619,7 +1914,7 @@ onMounted(() => void crypto.load())
|
||||
justify-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.auth-hero > span,
|
||||
.auth-hero__mark,
|
||||
.profile-head > span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -1647,6 +1942,184 @@ onMounted(() => void crypto.load())
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.auth {
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
padding-top: 12px !important;
|
||||
padding-bottom: 28px !important;
|
||||
}
|
||||
.auth-status {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
padding: 5px 9px;
|
||||
color: var(--vault-mint);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
background: rgba(101, 251, 210, 0.08);
|
||||
border: 1px solid rgba(101, 251, 210, 0.16);
|
||||
border-radius: var(--sky-radius-pill);
|
||||
}
|
||||
.auth-status i {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 7px currentColor;
|
||||
}
|
||||
.auth-benefits {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.auth-benefits span {
|
||||
display: grid;
|
||||
min-height: 57px;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 5px;
|
||||
padding: 7px 4px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 9px;
|
||||
line-height: 1.15;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border: 1px solid rgba(255, 255, 255, 0.055);
|
||||
border-radius: 13px;
|
||||
}
|
||||
.auth-benefits svg {
|
||||
color: var(--vault-mint);
|
||||
}
|
||||
.auth-panel {
|
||||
padding: 9px;
|
||||
text-align: left;
|
||||
background: linear-gradient(145deg, #111923, #080d13);
|
||||
border: 1px solid rgba(255, 255, 255, 0.075);
|
||||
border-radius: 25px;
|
||||
box-shadow:
|
||||
0 24px 54px rgba(0, 0, 0, 0.34),
|
||||
inset 0 1px rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
.auth-mode {
|
||||
min-height: 46px;
|
||||
margin-bottom: 13px;
|
||||
padding: 3px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
border-radius: 15px;
|
||||
}
|
||||
.auth-mode :deep(.sky-segmented-button) {
|
||||
min-height: 40px;
|
||||
color: rgba(255, 255, 255, 0.52);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.auth-mode :deep(.sky-segmented-button--active) {
|
||||
color: #fff;
|
||||
}
|
||||
.auth-panel__heading {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin: 0 4px 12px;
|
||||
}
|
||||
.auth-panel__heading > span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
color: #07100e;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(145deg, var(--vault-mint), var(--vault-blue));
|
||||
border-radius: 11px;
|
||||
}
|
||||
.auth-panel__heading > div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.auth-panel__heading b {
|
||||
font-size: 13px;
|
||||
}
|
||||
.auth-panel__heading small {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.auth-form {
|
||||
padding: 0 4px 4px;
|
||||
}
|
||||
.password-strength > div {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.password-strength i {
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.password-strength i.active {
|
||||
background: linear-gradient(90deg, var(--vault-mint), var(--vault-blue));
|
||||
}
|
||||
.password-strength span {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.password-rules {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
.password-rules span {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-size: 9px;
|
||||
}
|
||||
.password-rules span.done {
|
||||
color: var(--vault-mint);
|
||||
}
|
||||
.auth-consent {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: flex-start;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border: 1px solid rgba(255, 255, 255, 0.055);
|
||||
border-radius: 13px;
|
||||
}
|
||||
.auth-consent span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.auth-consent b {
|
||||
font-size: 10px;
|
||||
}
|
||||
.auth-consent small {
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.auth-submit {
|
||||
min-height: 50px;
|
||||
color: #06110e;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, #7dffe0, #4da3ff);
|
||||
border-radius: 15px;
|
||||
}
|
||||
.auth-footer {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 9px 4px 2px;
|
||||
color: rgba(255, 255, 255, 0.36);
|
||||
font-size: 9px;
|
||||
}
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 13px;
|
||||
@@ -2261,7 +2734,7 @@ onMounted(() => void crypto.load())
|
||||
.crypto-app :deep(.sky-pill-navigation .sky-segmented-button--active) {
|
||||
color: #fff;
|
||||
}
|
||||
.auth-hero > span {
|
||||
.auth-hero__mark {
|
||||
position: relative;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
@@ -2274,7 +2747,7 @@ onMounted(() => void crypto.load())
|
||||
0 22px 70px rgba(60, 224, 184, 0.22),
|
||||
inset 0 0 24px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.auth-hero > span::after {
|
||||
.auth-hero__mark::after {
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
@@ -3176,6 +3649,62 @@ onMounted(() => void crypto.load())
|
||||
grid-row: 1 / span 2;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.wallet-key-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 11px;
|
||||
align-items: center;
|
||||
margin-top: 11px;
|
||||
padding: 13px;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 92% 0%,
|
||||
rgba(101, 251, 210, 0.14),
|
||||
transparent 36%
|
||||
),
|
||||
linear-gradient(145deg, #151e28, #0a1017);
|
||||
border: 1px solid rgba(101, 251, 210, 0.13);
|
||||
border-radius: 19px;
|
||||
}
|
||||
.wallet-key-card__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
color: var(--vault-mint);
|
||||
background: rgba(101, 251, 210, 0.09);
|
||||
border: 1px solid rgba(101, 251, 210, 0.13);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.wallet-key-card > span:nth-child(2) {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
.wallet-key-card small,
|
||||
.wallet-key-card em {
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
font-style: normal;
|
||||
}
|
||||
.wallet-key-card b {
|
||||
overflow: hidden;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.035em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wallet-key-card button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--vault-mint);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.profile-stats--premium {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -3606,6 +4135,144 @@ onMounted(() => void crypto.load())
|
||||
.trade-submit--sell {
|
||||
background: linear-gradient(135deg, #d75268, #a9334b);
|
||||
}
|
||||
.transfer-intro,
|
||||
.transfer-recipient,
|
||||
.transfer-balance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.transfer-intro {
|
||||
gap: 11px;
|
||||
padding: 13px;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(101, 251, 210, 0.09),
|
||||
rgba(104, 167, 255, 0.055)
|
||||
);
|
||||
border: 1px solid rgba(101, 251, 210, 0.13);
|
||||
border-radius: 17px;
|
||||
}
|
||||
.transfer-intro > span,
|
||||
.transfer-recipient > span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--vault-mint);
|
||||
background: rgba(101, 251, 210, 0.09);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.transfer-intro > div,
|
||||
.transfer-recipient > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
.transfer-intro b,
|
||||
.transfer-recipient b {
|
||||
font-size: 12px;
|
||||
}
|
||||
.transfer-intro small,
|
||||
.transfer-recipient small,
|
||||
.transfer-recipient em {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.transfer-key-entry {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 7px;
|
||||
align-items: end;
|
||||
}
|
||||
.transfer-resolve {
|
||||
width: auto;
|
||||
min-width: 72px;
|
||||
min-height: 48px;
|
||||
padding: 0 11px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.transfer-recipient {
|
||||
gap: 10px;
|
||||
padding: 11px;
|
||||
background: rgba(101, 251, 210, 0.055);
|
||||
border: 1px solid rgba(101, 251, 210, 0.15);
|
||||
border-radius: 15px;
|
||||
}
|
||||
.transfer-recipient em {
|
||||
overflow: hidden;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.transfer-assets {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
.transfer-assets > small {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.transfer-assets > div {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.transfer-assets button {
|
||||
display: flex;
|
||||
min-width: 105px;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
text-align: left;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.transfer-assets button.active {
|
||||
color: #fff;
|
||||
background: rgba(101, 251, 210, 0.09);
|
||||
border-color: rgba(101, 251, 210, 0.27);
|
||||
}
|
||||
.transfer-assets button :deep(.crypto-logo) {
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
}
|
||||
.transfer-assets button span {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
.transfer-assets button b {
|
||||
font-size: 11px;
|
||||
}
|
||||
.transfer-assets button small {
|
||||
font-size: 9px;
|
||||
}
|
||||
.transfer-balance {
|
||||
justify-content: space-between;
|
||||
margin-top: -7px;
|
||||
padding: 0 3px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.transfer-balance b {
|
||||
color: #fff;
|
||||
}
|
||||
.transfer-submit {
|
||||
min-height: 52px;
|
||||
gap: 8px;
|
||||
color: #06110e;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, #70f7d2, #4b9eff);
|
||||
border-radius: 16px;
|
||||
}
|
||||
.sheet-copy {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -506,6 +506,11 @@ let cryptoProfile = {
|
||||
totalTrades: 12,
|
||||
totalVolume: '18462.80',
|
||||
tradeConfirmations: true,
|
||||
walletKey: 'VX-7F3A-92C1-44BE-810D',
|
||||
}
|
||||
const cryptoRecipient = {
|
||||
handle: 'receiver',
|
||||
walletKey: 'VX-DEAD-BEEF-C0DE-2026',
|
||||
}
|
||||
let cryptoHoldings = [
|
||||
{
|
||||
@@ -6818,6 +6823,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
),
|
||||
),
|
||||
profile: cryptoAuthenticated ? cryptoProfile : null,
|
||||
registered: true,
|
||||
})
|
||||
const billingInvoice = (invoice) => ({
|
||||
...invoice,
|
||||
@@ -8325,6 +8331,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
totalTrades: 0,
|
||||
totalVolume: '0',
|
||||
tradeConfirmations: true,
|
||||
walletKey: 'VX-31AF-4D92-882E-C104',
|
||||
}
|
||||
response.json({ success: true, data: cryptoOverview() })
|
||||
return
|
||||
@@ -8366,6 +8373,69 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
response.json({ success: true, data: cryptoOverview() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:recipient') {
|
||||
const walletKey = String(request.body.walletKey ?? '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
if (!/^VX-(?:[A-F0-9]{4}-){3}[A-F0-9]{4}$/.test(walletKey)) {
|
||||
response.json({ success: false, error: 'invalid_wallet_key' })
|
||||
return
|
||||
}
|
||||
if (walletKey === cryptoProfile.walletKey) {
|
||||
response.json({ success: false, error: 'self_transfer' })
|
||||
return
|
||||
}
|
||||
if (walletKey !== cryptoRecipient.walletKey) {
|
||||
response.json({ success: false, error: 'recipient_not_found' })
|
||||
return
|
||||
}
|
||||
response.json({ success: true, data: cryptoRecipient })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:transfer') {
|
||||
const walletKey = String(request.body.walletKey ?? '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
const market = cryptoMarkets.find(
|
||||
(item) => item.id === request.body.marketId,
|
||||
)
|
||||
const holding = cryptoHoldings.find(
|
||||
(item) => item.assetId === request.body.marketId,
|
||||
)
|
||||
const quantity = Number(request.body.quantity)
|
||||
if (request.body.password !== cryptoPassword) {
|
||||
response.json({ success: false, error: 'invalid_credentials' })
|
||||
return
|
||||
}
|
||||
if (
|
||||
walletKey !== cryptoRecipient.walletKey ||
|
||||
!market ||
|
||||
!holding ||
|
||||
!Number.isFinite(quantity) ||
|
||||
quantity <= 0
|
||||
) {
|
||||
response.json({ success: false, error: 'invalid_transfer' })
|
||||
return
|
||||
}
|
||||
if (Number(holding.quantity) < quantity) {
|
||||
response.json({ success: false, error: 'insufficient_funds' })
|
||||
return
|
||||
}
|
||||
holding.quantity = (Number(holding.quantity) - quantity).toFixed(6)
|
||||
holding.value = (Number(holding.quantity) * Number(market.price)).toFixed(2)
|
||||
cryptoActivity.unshift({
|
||||
amount: '0',
|
||||
counterpartyKey: walletKey,
|
||||
createdAt: Date.now(),
|
||||
id: `crypto-${nextCryptoActivityId++}`,
|
||||
marketId: market.id,
|
||||
quantity: quantity.toFixed(6),
|
||||
status: 'completed',
|
||||
type: 'transfer_out',
|
||||
})
|
||||
response.json({ success: true, data: cryptoOverview() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crypto:deposit' || endpoint === 'crypto:withdraw') {
|
||||
const amount = Number(request.body.amount)
|
||||
if (!Number.isSafeInteger(amount) || amount < 10) {
|
||||
|
||||
@@ -8,6 +8,7 @@ const browserDataRequests = [
|
||||
['account:devices', {}],
|
||||
['banking:overview', {}],
|
||||
['crypto:bootstrap', {}],
|
||||
['crypto:recipient', { walletKey: 'VX-DEAD-BEEF-C0DE-2026' }],
|
||||
['crypto:quote', { marketId: 'aurora', quantity: '1', side: 'buy' }],
|
||||
['health:overview', {}],
|
||||
['billing:overview', {}],
|
||||
@@ -117,6 +118,7 @@ function verifyBrowserTestData(dataByEndpoint) {
|
||||
const crypto = dataByEndpoint.get('crypto:bootstrap')
|
||||
expectItems(crypto.markets, 'crypto markets', 24)
|
||||
assert.equal(typeof crypto.profile.priceAlerts, 'boolean')
|
||||
assert.match(crypto.profile.walletKey, /^VX-(?:[A-F0-9]{4}-){3}[A-F0-9]{4}$/)
|
||||
assert.equal(typeof crypto.markets[0].issuedSupply, 'string')
|
||||
assert(crypto.markets.every((market) => typeof market.logo === 'string'))
|
||||
assert(
|
||||
@@ -212,6 +214,40 @@ function verifyBrowserTestData(dataByEndpoint) {
|
||||
}
|
||||
|
||||
async function verifyStatefulActions(baseUrl) {
|
||||
const cryptoBeforeTransfer = await expectSuccess(
|
||||
baseUrl,
|
||||
'crypto:bootstrap',
|
||||
{},
|
||||
true,
|
||||
)
|
||||
const auroraBeforeTransfer = cryptoBeforeTransfer.holdings.find(
|
||||
(holding) => holding.assetId === 'aurora',
|
||||
)
|
||||
const cryptoAfterTransfer = await expectSuccess(
|
||||
baseUrl,
|
||||
'crypto:transfer',
|
||||
{
|
||||
idempotencyKey: 'smoke-transfer-1',
|
||||
marketId: 'aurora',
|
||||
password: 'VaultX123!',
|
||||
quantity: '0.5',
|
||||
walletKey: 'VX-DEAD-BEEF-C0DE-2026',
|
||||
},
|
||||
true,
|
||||
)
|
||||
const auroraAfterTransfer = cryptoAfterTransfer.holdings.find(
|
||||
(holding) => holding.assetId === 'aurora',
|
||||
)
|
||||
assert.equal(
|
||||
Number(auroraAfterTransfer.quantity),
|
||||
Number(auroraBeforeTransfer.quantity) - 0.5,
|
||||
)
|
||||
assert.equal(cryptoAfterTransfer.activity[0].type, 'transfer_out')
|
||||
assert.equal(
|
||||
cryptoAfterTransfer.activity[0].counterpartyKey,
|
||||
'VX-DEAD-BEEF-C0DE-2026',
|
||||
)
|
||||
|
||||
const companyCall = await expectSuccess(
|
||||
baseUrl,
|
||||
'companies:dial-service-line',
|
||||
|
||||
@@ -681,6 +681,7 @@ Config.Crypto = {
|
||||
MaximumSettlement = 250000,
|
||||
MaximumTradeNotional = 100000,
|
||||
MaximumPositionQuantity = 100000,
|
||||
MaximumTransferQuantity = 100000,
|
||||
DailyDepositLimit = 500000,
|
||||
DailyWithdrawalLimit = 250000,
|
||||
DailyTradeLimit = 500000,
|
||||
|
||||
@@ -1052,21 +1052,22 @@ Locales["de"] = {
|
||||
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",
|
||||
eyebrow = "Charaktergebundene Börse", loginTitle = "Willkommen zurück", registerTitle = "Erstelle dein VaultX-Profil", body = "Handle und übertrage fiktive digitale Assets über ein geschütztes, servergesteuertes Konto.", network = "VAULT-NETZWERK ONLINE", serverSecured = "Servergeschützt", personalKey = "Eigener Crypto-Key", characterBound = "Charaktergebunden", login = "Einloggen", register = "Registrieren", loginPanelTitle = "Sicherer Kontozugang", loginPanelBody = "Deine Charakteridentität wird automatisch erkannt.", registerPanelTitle = "Richte deinen privaten Vault ein", registerPanelBody = "Wähle deine Profilidentität und sichere Zugangsphrase.", handle = "VaultX-Benutzername", handlePlaceholder = "3–20 Buchstaben oder Zahlen", password = "Passwort", passwordPlaceholder = "8–72 Zeichen", confirmPassword = "Passwort bestätigen", confirmPasswordPlaceholder = "Dasselbe Passwort erneut eingeben", showPassword = "Passwort anzeigen", hidePassword = "Passwort ausblenden", security = "Nutze ein In-Character-Passwort. Verwende niemals ein echtes Passwort erneut.", ruleLength = "Mindestens 8 Zeichen", ruleMixed = "Groß- & Kleinbuchstaben", ruleNumber = "Enthält eine Zahl", ruleSpecial = "Sonderzeichen", strength0 = "Beginne mit einem sicheren Passwort", strength1 = "Schwacher Schutz", strength2 = "Grundlegender Schutz", strength3 = "Starker Schutz", strength4 = "Sehr starker Schutz", acceptTitle = "Fiktives Wallet erstellen", acceptBody = "Ich verstehe, dass VaultX-Assets keinen realen Wert besitzen.", footer = "Verschlüsselte Sitzung · Begrenzte Zugriffe · Speicherintensiver Passwortschutz", loginAction = "VaultX entsperren", registerAction = "Sicheres Profil erstellen",
|
||||
},
|
||||
tabs = { portfolio = "Portfolio", markets = "Märkte", activity = "Aktivität", profile = "Profil" },
|
||||
portfolio = { total = "Gesamtes Portfolio", performance = "Gewinn / Verlust", return = "Gesamtrendite", current = "Jetziger Stand", history = "Entwicklung", forecast = "Prognose", chartLabel = "Gewinn- und Verlustentwicklung mit gestrichelter Prognose", cash = "Verfügbares Guthaben", invested = "Investierte Assets", allocation = "Portfolio-Verteilung", inMarket = "in Märkten", assets = "Assets", holdings = "Deine Assets", empty = "Noch keine Assets", emptyBody = "Öffne Märkte und fordere ein geschütztes Angebot an.", avg = "Ø", insightTitle = "Portfolio-Einblick", insightBody = "Deine Bestände werden mit den neuesten synthetischen Serverkursen bewertet." },
|
||||
quick = { trade = "Handeln", more = "Mehr" },
|
||||
quick = { trade = "Handeln", send = "Krypto senden", more = "Mehr" },
|
||||
actions = { deposit = "Einzahlen", withdraw = "Auszahlen" },
|
||||
markets = { title = "Fiktive Märkte", live = "Serverpreise", noticeTitle = "Ingame-Börse", notice = "Die Kurse sind synthetisch und haben keinen realen Wert. Jeder Handel wird gegen die begrenzte Liquidität der Börse geprüft.", eyebrow = "Marktpuls", movers = "Top-Beweger", today = "Heute" },
|
||||
marketDetail = { back = "Zurück zu den Märkten", today = "Heute", description = "Der Referenzkurs wird vom Server erzeugt. Ausführbare Kurse verwenden ein geschütztes, kurzlebiges Angebot.", investment = "Deine Investition", totalValue = "Aktueller Wert", statistics = "Marktstatistik", high24h = "24h-Hoch", low24h = "24h-Tief", supply = "Ausgegebene Menge", liquidity = "Börsenliquidität" },
|
||||
trade = { actions = "Handelsaktionen", buy = "Kaufen", buyBody = "Aufbauen", sell = "Verkaufen", sellBody = "Reduzieren", quantity = "Menge", available = "Verfügbar:", estimated = "Geschätzter Wert", marketPrice = "Marktpreis", protected = "Geschütztes Serverangebot", protectedBody = "VaultX prüft Preis, Liquidität, Guthaben und Limits vor der Ausführung.", review = "Order prüfen", price = "Angebotspreis", gross = "Brutto", fee = "Börsengebühr", total = "Gesamt", quoteExpiry = "Dieses Serverangebot läuft nach 8 Sekunden ab und kann nur einmal genutzt werden.", getQuote = "Geschütztes Angebot", confirm = "Handel bestätigen" },
|
||||
settlement = { depositBody = "Übertrage Bankguthaben in dein verbuchtes VaultX-Guthaben.", withdrawBody = "Zahle verbuchtes VaultX-Guthaben auf das Bankkonto deines Charakters aus.", amount = "Ganzer Bankbetrag", confirm = "Sicher bestätigen" },
|
||||
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", edit = "Bearbeiten", account = "Konto & Sicherheit", editTitle = "Profil bearbeiten", editBody = "Ändere deinen Benutzernamen oder lege ein neues Passwort fest. Dein aktuelles Passwort bestätigt Identitätsänderungen.", currentPassword = "Aktuelles Passwort", currentPasswordPlaceholder = "Für Namens- oder Passwortänderungen erforderlich", newPassword = "Neues Passwort", newPasswordPlaceholder = "Leer lassen, um das aktuelle Passwort zu behalten", passwordSecurity = "Passwörter werden speicherintensiv gehasht und nie wieder angezeigt.", saved = "Profil sicher gespeichert.", save = "Profil speichern", saveChanges = "Änderungen speichern", securityTitle = "Geschütztes Profil", securityBody = "Dein Profil bleibt an diesen Framework-Charakter und die Finanzsitzung gebunden." },
|
||||
activityTypes = { buy = "Asset-Kauf", sell = "Asset-Verkauf", deposit = "Bankeinzahlung", withdrawal = "Bankauszahlung" },
|
||||
transfer = { title = "Krypto senden", subtitle = "VaultX-Übertragung", keyTitle = "Öffentlicher Empfangsschlüssel", keyBody = "Prüfe den Empfänger, bevor du ein Asset auswählst. Bargeld kann hier niemals gesendet werden.", walletKey = "Crypto-Key des Empfängers", verify = "Prüfen", verifiedRecipient = "Verifizierter VaultX-Empfänger", asset = "Asset auswählen", quantity = "Krypto-Menge", available = "Verfügbarer Bestand", password = "Mit Passwort bestätigen", protected = "Atomare Wallet-Übertragung", protectedBody = "VaultX prüft Empfänger, Besitz, Menge und Bestand erneut auf dem Server und verbucht beide Wallets gemeinsam.", confirm = "Krypto sicher senden" },
|
||||
activity = { title = "Finanzaktivität", empty = "Keine Aktivität", emptyBody = "Einzahlungen, Auszahlungen, Handel und Krypto-Übertragungen erscheinen hier.", volume = "Handelsvolumen gesamt", completedTrades = "abgeschlossene Trades", cash = "Guthaben", filters = { all = "Alle", trades = "Trades", wallet = "Wallet" } },
|
||||
profile = { verified = "Charakterbesitz verifiziert", walletKey = "Öffentlicher Crypto-Key", walletKeyBody = "Teile diesen Key, um Krypto zu empfangen. Er gewährt keinen Kontozugriff.", copyKey = "Crypto-Key kopieren", trades = "Trades", volume = "Volumen", memberSince = "Mitglied seit", preferences = "Einstellungen", priceAlerts = "Kurswarnungen", priceAlertsBody = "Bei auffälligen Marktbewegungen benachrichtigen.", confirmations = "Handelsbestätigung", confirmationsBody = "Zusätzliche Bestätigung vor der Ausführung behalten.", hideBalances = "Privatsphäre-Modus", hideBalancesBody = "Beträge in VaultX verbergen.", identity = "Profilidentität", edit = "Bearbeiten", account = "Konto & Sicherheit", editTitle = "Profil bearbeiten", editBody = "Ändere deinen Benutzernamen oder lege ein neues Passwort fest. Dein aktuelles Passwort bestätigt Identitätsänderungen.", currentPassword = "Aktuelles Passwort", currentPasswordPlaceholder = "Für Namens- oder Passwortänderungen erforderlich", newPassword = "Neues Passwort", newPasswordPlaceholder = "Leer lassen, um das aktuelle Passwort zu behalten", passwordSecurity = "Passwörter werden speicherintensiv gehasht und nie wieder angezeigt.", saved = "Profil sicher gespeichert.", save = "Profil speichern", saveChanges = "Änderungen speichern", securityTitle = "Geschütztes Profil", securityBody = "Dein Profil bleibt an diesen Framework-Charakter und die Finanzsitzung gebunden." },
|
||||
activityTypes = { buy = "Asset-Kauf", sell = "Asset-Verkauf", deposit = "Bankeinzahlung", withdrawal = "Bankauszahlung", transfer_in = "Krypto empfangen", transfer_out = "Krypto gesendet" },
|
||||
statuses = { completed = "Abgeschlossen", pending = "Prüfung ausstehend", failed = "Fehlgeschlagen", manual_review = "Manuelle Prüfung" },
|
||||
errors = { invalid_profile = "Prüfe deine VaultX-Profileinstellungen.", invalid_handle = "Nutze 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." },
|
||||
errors = { invalid_profile = "Prüfe deine VaultX-Profileinstellungen.", 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.", password_mismatch = "Die Passwörter stimmen nicht überein.", accept_terms = "Bestätige, dass dies ein fiktives Ingame-Wallet ist.", invalid_credentials = "Das Passwort ist falsch.", locked = "Zu viele Versuche. Versuche es später erneut.", not_authenticated = "Logge dich zuerst bei VaultX ein.", invalid_amount = "Gib einen positiven ganzen Betrag ein.", invalid_quantity = "Gib eine Menge mit bis zu sechs Nachkommastellen ein.", invalid_wallet_key = "Gib einen vollständigen VX-Empfangsschlüssel ein.", recipient_not_found = "Zu diesem Key wurde kein aktives VaultX-Konto gefunden.", self_transfer = "Du kannst keine Krypto an deinen eigenen Key senden.", invalid_transfer = "Prüfe Empfänger, Asset und Krypto-Menge.", recipient_limit_exceeded = "Das Empfänger-Wallet kann diese Menge nicht aufnehmen.", insufficient_funds = "Dein verfügbarer Bestand reicht nicht aus.", insufficient_liquidity = "Die Börse kann diese Menge derzeit nicht ausführen.", quote_expired = "Das Angebot ist abgelaufen. Fordere ein neues an.", quote_unavailable = "Das Angebot ist nicht mehr verfügbar.", market_unavailable = "Dieser Markt ist vorübergehend pausiert.", limit_exceeded = "Die Anfrage überschreitet ein Börsenlimit.", duplicate_request = "Diese Anfrage wurde bereits verarbeitet.", rate_limited = "Zu viele Anfragen. Versuche es gleich erneut.", settlement_pending = "Eine Geldbewegung wartet bereits auf Prüfung.", service_unavailable = "VaultX ist vorübergehend nicht verfügbar.", request_failed = "Die sichere Börsenanfrage ist fehlgeschlagen.", default = "VaultX konnte die Anfrage nicht abschließen." },
|
||||
},
|
||||
banking = {
|
||||
name = "Banking", welcome = "Willkommen zurück", totalBalance = "Gesamtsaldo", recentPeriod = "in jüngster Zeit",
|
||||
|
||||
@@ -1052,21 +1052,22 @@ Locales["en"] = {
|
||||
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",
|
||||
eyebrow = "Character-bound exchange", loginTitle = "Welcome back", registerTitle = "Create your VaultX profile", body = "Trade and transfer fictional digital assets through a protected, server-controlled account.", network = "VAULT NETWORK ONLINE", serverSecured = "Server secured", personalKey = "Personal crypto key", characterBound = "Character bound", login = "Log in", register = "Register", loginPanelTitle = "Secure account access", loginPanelBody = "Your character identity is detected automatically.", registerPanelTitle = "Set up your private vault", registerPanelBody = "Choose a profile identity and secure access phrase.", handle = "VaultX handle", handlePlaceholder = "3–20 letters or numbers", password = "Password", passwordPlaceholder = "8–72 characters", confirmPassword = "Confirm password", confirmPasswordPlaceholder = "Enter the same password again", showPassword = "Show password", hidePassword = "Hide password", security = "Use an in-character password. Never reuse a real password.", ruleLength = "8+ characters", ruleMixed = "Upper & lowercase", ruleNumber = "Contains a number", ruleSpecial = "Special character", strength0 = "Start entering a secure password", strength1 = "Weak protection", strength2 = "Basic protection", strength3 = "Strong protection", strength4 = "Excellent protection", acceptTitle = "Create a fictional wallet", acceptBody = "I understand that VaultX assets have no real-world value.", footer = "Encrypted session · Rate-limited access · Memory-hard password", loginAction = "Unlock VaultX", registerAction = "Create secure profile",
|
||||
},
|
||||
tabs = { portfolio = "Portfolio", markets = "Markets", activity = "Activity", profile = "Profile" },
|
||||
portfolio = { total = "Total portfolio", performance = "Profit / loss", return = "Total return", current = "Current position", history = "Performance", forecast = "Forecast", chartLabel = "Profit and loss performance with dashed forecast", cash = "Available cash", invested = "Invested assets", allocation = "Portfolio allocation", inMarket = "in markets", assets = "assets", holdings = "Your assets", empty = "No assets yet", emptyBody = "Open Markets to request a protected quote.", avg = "Avg.", insightTitle = "Portfolio insight", insightBody = "Your holdings are valued against the latest synthetic server prices." },
|
||||
quick = { trade = "Trade", more = "More" },
|
||||
quick = { trade = "Trade", send = "Send crypto", more = "More" },
|
||||
actions = { deposit = "Deposit", withdraw = "Withdraw" },
|
||||
markets = { title = "Fictional markets", live = "Server priced", noticeTitle = "In-game exchange", notice = "Prices are synthetic and have no real-world value. Every trade is checked against finite exchange liquidity.", eyebrow = "Market pulse", movers = "Top movers", today = "Today" },
|
||||
marketDetail = { back = "Back to markets", today = "Today", description = "The reference price is generated by the server. Executable prices use a protected short-lived quote.", investment = "Your investment", totalValue = "Current value", statistics = "Market statistics", high24h = "24h high", low24h = "24h low", supply = "Issued supply", liquidity = "Exchange liquidity" },
|
||||
trade = { actions = "Trade actions", buy = "Buy", buyBody = "Add", sell = "Sell", sellBody = "Reduce", quantity = "Quantity", available = "Available:", estimated = "Estimated value", marketPrice = "Market price", protected = "Protected server quote", protectedBody = "VaultX validates price, liquidity, balance and limits before execution.", review = "Order review", price = "Quote price", gross = "Gross", fee = "Exchange fee", total = "Total", quoteExpiry = "This server quote expires after 8 seconds and can only be used once.", getQuote = "Get protected quote", confirm = "Confirm trade" },
|
||||
settlement = { depositBody = "Move bank funds into your settled VaultX cash balance.", withdrawBody = "Return settled VaultX cash to your character's bank account.", amount = "Whole bank amount", confirm = "Confirm securely" },
|
||||
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", edit = "Edit", account = "Account & security", editTitle = "Edit profile", editBody = "Update your handle or set a new password. Your current password confirms identity changes.", currentPassword = "Current password", currentPasswordPlaceholder = "Required for handle or password changes", newPassword = "New password", newPasswordPlaceholder = "Leave blank to keep your current password", passwordSecurity = "Passwords use memory-hard hashing and are never shown again.", saved = "Profile saved securely.", save = "Save profile", saveChanges = "Save changes", securityTitle = "Protected profile", securityBody = "Your profile stays bound to this framework character and financial session." },
|
||||
activityTypes = { buy = "Asset purchase", sell = "Asset sale", deposit = "Bank deposit", withdrawal = "Bank withdrawal" },
|
||||
transfer = { title = "Send crypto", subtitle = "VaultX transfer", keyTitle = "Public receiving key", keyBody = "Verify the recipient before selecting an asset. Cash can never be sent here.", walletKey = "Recipient crypto key", verify = "Verify", verifiedRecipient = "Verified VaultX recipient", asset = "Choose asset", quantity = "Crypto amount", available = "Available balance", password = "Confirm with password", protected = "Atomic wallet transfer", protectedBody = "VaultX rechecks recipient, ownership, quantity and balance on the server, then books both wallets together.", confirm = "Send crypto securely" },
|
||||
activity = { title = "Financial activity", empty = "No activity", emptyBody = "Deposits, withdrawals, trades and crypto transfers will appear here.", volume = "Lifetime trading volume", completedTrades = "completed trades", cash = "Cash", filters = { all = "All", trades = "Trades", wallet = "Wallet" } },
|
||||
profile = { verified = "Character ownership verified", walletKey = "Public crypto key", walletKeyBody = "Share this key to receive crypto. It cannot access your account.", copyKey = "Copy crypto key", trades = "Trades", volume = "Volume", memberSince = "Member since", preferences = "Preferences", priceAlerts = "Price alerts", priceAlertsBody = "Notify me about notable market moves.", confirmations = "Trade confirmations", confirmationsBody = "Keep an extra confirmation before execution.", hideBalances = "Privacy mode", hideBalancesBody = "Hide balances across VaultX.", identity = "Profile identity", edit = "Edit", account = "Account & security", editTitle = "Edit profile", editBody = "Update your handle or set a new password. Your current password confirms identity changes.", currentPassword = "Current password", currentPasswordPlaceholder = "Required for handle or password changes", newPassword = "New password", newPasswordPlaceholder = "Leave blank to keep your current password", passwordSecurity = "Passwords use memory-hard hashing and are never shown again.", saved = "Profile saved securely.", save = "Save profile", saveChanges = "Save changes", securityTitle = "Protected profile", securityBody = "Your profile stays bound to this framework character and financial session." },
|
||||
activityTypes = { buy = "Asset purchase", sell = "Asset sale", deposit = "Bank deposit", withdrawal = "Bank withdrawal", transfer_in = "Crypto received", transfer_out = "Crypto sent" },
|
||||
statuses = { completed = "Completed", pending = "Pending review", failed = "Failed", manual_review = "Manual review" },
|
||||
errors = { invalid_profile = "Check your VaultX profile settings.", invalid_handle = "Use 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." },
|
||||
errors = { invalid_profile = "Check your VaultX profile settings.", 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.", password_mismatch = "The passwords do not match.", accept_terms = "Confirm that this is a fictional in-game wallet.", invalid_credentials = "The password is incorrect.", locked = "Too many attempts. Try again later.", not_authenticated = "Sign in to VaultX first.", invalid_amount = "Enter a positive whole amount.", invalid_quantity = "Enter a quantity with up to six decimals.", invalid_wallet_key = "Enter a complete VX receiving key.", recipient_not_found = "No active VaultX account uses this key.", self_transfer = "You cannot send crypto to your own key.", invalid_transfer = "Verify a recipient, asset and valid crypto amount.", recipient_limit_exceeded = "The recipient wallet cannot hold that amount.", insufficient_funds = "Your available balance is too low.", insufficient_liquidity = "The exchange cannot fill that amount right now.", quote_expired = "This quote expired. Request a new one.", quote_unavailable = "The quote is no longer available.", market_unavailable = "This market is temporarily paused.", limit_exceeded = "This request exceeds an exchange limit.", duplicate_request = "This request was already processed.", rate_limited = "Too many requests. Try again shortly.", settlement_pending = "A money transfer is already pending review.", service_unavailable = "VaultX is temporarily unavailable.", request_failed = "The secure exchange request failed.", default = "VaultX could not complete the request." },
|
||||
},
|
||||
banking = {
|
||||
name = "Banking", welcome = "Welcome back", totalBalance = "Total Balance", recentPeriod = "in recent activity",
|
||||
|
||||
@@ -251,6 +251,8 @@ local server_callbacks = {
|
||||
"crypto:deposit",
|
||||
"crypto:withdraw",
|
||||
"crypto:update-profile",
|
||||
"crypto:recipient",
|
||||
"crypto:transfer",
|
||||
"billing:overview",
|
||||
"billing:list",
|
||||
"billing:detail",
|
||||
@@ -935,6 +937,10 @@ RegisterNetEvent("sky_phone:crypto:changed", function(data)
|
||||
SendNUIMessage({ type = "crypto:changed", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:crypto:account-changed", function()
|
||||
SendNUIMessage({ type = "crypto:account-changed" })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:billing:changed", function()
|
||||
SendNUIMessage({ type = "billing:changed" })
|
||||
end)
|
||||
|
||||
@@ -16,6 +16,7 @@ local function ensure_schema()
|
||||
`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,
|
||||
`crypto_key` CHAR(22) CHARACTER SET ascii COLLATE ascii_bin 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,
|
||||
@@ -27,7 +28,8 @@ local function ensure_schema()
|
||||
`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`)
|
||||
UNIQUE KEY `uniq_sky_phone_crypto_handle` (`handle`),
|
||||
UNIQUE KEY `uniq_sky_phone_crypto_key` (`crypto_key`)
|
||||
) 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,
|
||||
@@ -62,12 +64,14 @@ local function ensure_schema()
|
||||
[[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,
|
||||
`type` ENUM('buy','sell','deposit','withdrawal','transfer_in','transfer_out') 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,
|
||||
`quantity` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`counterparty_key` CHAR(22) 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,
|
||||
@@ -147,6 +151,12 @@ local function ensure_schema()
|
||||
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`", {})
|
||||
Bridge.Database.Query("ALTER TABLE `sky_phone_crypto_profiles` ADD COLUMN IF NOT EXISTS `crypto_key` CHAR(22) CHARACTER SET ascii COLLATE ascii_bin NULL AFTER `handle`", {})
|
||||
Bridge.Database.Query([[ALTER TABLE `sky_phone_crypto_operations`
|
||||
MODIFY COLUMN `type` ENUM('buy','sell','deposit','withdrawal','transfer_in','transfer_out') NOT NULL]], {})
|
||||
Bridge.Database.Query("ALTER TABLE `sky_phone_crypto_operations` ADD COLUMN IF NOT EXISTS `quantity` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0 AFTER `market_id`", {})
|
||||
Bridge.Database.Query("ALTER TABLE `sky_phone_crypto_operations` ADD COLUMN IF NOT EXISTS `counterparty_key` CHAR(22) CHARACTER SET ascii COLLATE ascii_bin NULL AFTER `quantity`", {})
|
||||
Bridge.Database.Query("ALTER TABLE `sky_phone_crypto_operations` MODIFY COLUMN `counterparty_key` CHAR(22) CHARACTER SET ascii COLLATE ascii_bin NULL", {})
|
||||
end
|
||||
|
||||
local function new_id()
|
||||
@@ -157,6 +167,58 @@ local function new_id()
|
||||
return row.id
|
||||
end
|
||||
|
||||
local function crypto_key_from_entropy(entropy)
|
||||
local compact = entropy:gsub("-", ""):upper()
|
||||
return ("VX-%s-%s-%s-%s"):format(
|
||||
compact:sub(1, 4),
|
||||
compact:sub(5, 8),
|
||||
compact:sub(9, 12),
|
||||
compact:sub(13, 16)
|
||||
)
|
||||
end
|
||||
|
||||
local function new_crypto_key()
|
||||
for _ = 1, 5 do
|
||||
local candidate = crypto_key_from_entropy(new_id())
|
||||
local duplicate = Bridge.Database.Query(
|
||||
"SELECT 1 FROM `sky_phone_crypto_profiles` WHERE `crypto_key` = ? LIMIT 1",
|
||||
{ candidate }
|
||||
)[1]
|
||||
if not duplicate then
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
error("[sky_phone] Database did not generate a unique Crypto key.")
|
||||
end
|
||||
|
||||
local function migrate_crypto_keys()
|
||||
local rows = Bridge.Database.Query(
|
||||
"SELECT `id` FROM `sky_phone_crypto_profiles` WHERE `crypto_key` IS NULL OR `crypto_key` = ''",
|
||||
{}
|
||||
)
|
||||
for _, row in ipairs(rows) do
|
||||
Bridge.Database.Query(
|
||||
"UPDATE `sky_phone_crypto_profiles` SET `crypto_key` = ? WHERE `id` = ? AND (`crypto_key` IS NULL OR `crypto_key` = '')",
|
||||
{ new_crypto_key(), row.id }
|
||||
)
|
||||
end
|
||||
Bridge.Database.Query(
|
||||
"ALTER TABLE `sky_phone_crypto_profiles` MODIFY COLUMN `crypto_key` CHAR(22) CHARACTER SET ascii COLLATE ascii_bin NOT NULL",
|
||||
{}
|
||||
)
|
||||
local index = Bridge.Database.Query([[
|
||||
SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'sky_phone_crypto_profiles'
|
||||
AND index_name = 'uniq_sky_phone_crypto_key' LIMIT 1
|
||||
]], {})[1]
|
||||
if not index then
|
||||
Bridge.Database.Query(
|
||||
"ALTER TABLE `sky_phone_crypto_profiles` ADD UNIQUE KEY `uniq_sky_phone_crypto_key` (`crypto_key`)",
|
||||
{}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
local function affected_rows(result)
|
||||
if type(result) == "number" then
|
||||
return result
|
||||
@@ -188,6 +250,22 @@ local function valid_password(value)
|
||||
and length <= Config.Crypto.PasswordMaxLength
|
||||
end
|
||||
|
||||
local function valid_new_password(value)
|
||||
return valid_password(value)
|
||||
and value:match("%l")
|
||||
and value:match("%u")
|
||||
and value:match("%d")
|
||||
and value:match("[^%w]")
|
||||
end
|
||||
|
||||
local function valid_crypto_key(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local key = value:upper():match("^%s*(.-)%s*$")
|
||||
return key:match("^VX%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x$") and key or nil
|
||||
end
|
||||
|
||||
local function parse_whole(value, minimum, maximum)
|
||||
if type(value) ~= "string" or not value:match("^%d+$") then
|
||||
return nil
|
||||
@@ -290,7 +368,7 @@ end
|
||||
|
||||
local function profile_by_owner(identifier)
|
||||
return Bridge.Database.Query([[
|
||||
SELECT `id`,`owner_identifier`,`account_id`,`handle`,`status`,`failed_logins`,
|
||||
SELECT `id`,`owner_identifier`,`account_id`,`handle`,`crypto_key`,`status`,`failed_logins`,
|
||||
`price_alerts`,`trade_confirmations`,`hide_balances`,
|
||||
UNIX_TIMESTAMP(`created_at`) AS `created_at`,
|
||||
UNIX_TIMESTAMP(`locked_until`) AS `locked_until`
|
||||
@@ -298,6 +376,13 @@ local function profile_by_owner(identifier)
|
||||
]], { identifier })[1]
|
||||
end
|
||||
|
||||
local function profile_by_crypto_key(wallet_key)
|
||||
return Bridge.Database.Query([[
|
||||
SELECT `id`,`owner_identifier`,`handle`,`crypto_key`,`status`
|
||||
FROM `sky_phone_crypto_profiles` WHERE `crypto_key` = ? LIMIT 1
|
||||
]], { wallet_key })[1]
|
||||
end
|
||||
|
||||
local function authenticated_profile(source)
|
||||
local phone_session, identifier, error_response = require_phone(source)
|
||||
if not phone_session then
|
||||
@@ -433,7 +518,8 @@ 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`
|
||||
SELECT `id`,`type`,`amount`,`market_id`,`quantity`,`counterparty_key`,`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 })
|
||||
@@ -443,6 +529,8 @@ local function activity(profile_id)
|
||||
id = row.id,
|
||||
type = row.type,
|
||||
amount = decimal_string(row.amount, Config.Crypto.PriceScale),
|
||||
quantity = decimal_string(row.quantity, Config.Crypto.AssetScale),
|
||||
counterpartyKey = row.counterparty_key,
|
||||
marketId = row.market_id,
|
||||
status = row.status,
|
||||
createdAt = (tonumber(row.created_at) or 0) * 1000,
|
||||
@@ -496,6 +584,7 @@ local function bootstrap(profile)
|
||||
profile = {
|
||||
id = profile.id,
|
||||
handle = profile.handle,
|
||||
walletKey = profile.crypto_key,
|
||||
status = profile.status,
|
||||
createdAt = (tonumber(profile.created_at) or 0) * 1000,
|
||||
hideBalances = tonumber(profile.hide_balances) == 1,
|
||||
@@ -509,6 +598,7 @@ local function bootstrap(profile)
|
||||
holdings = holdings,
|
||||
markets = market_dtos(),
|
||||
activity = activity(profile.id),
|
||||
registered = true,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -599,7 +689,7 @@ Bridge.Callbacks.Register("sky_phone:crypto:register", function(source, data)
|
||||
if not handle then
|
||||
return { success = false, error = "invalid_handle" }
|
||||
end
|
||||
if not valid_password(data.password) then
|
||||
if not valid_new_password(data.password) then
|
||||
return { success = false, error = "invalid_password" }
|
||||
end
|
||||
if profile_by_owner(identifier) then
|
||||
@@ -612,7 +702,8 @@ Bridge.Callbacks.Register("sky_phone:crypto:register", function(source, data)
|
||||
if duplicate[1] then
|
||||
return { success = false, error = "handle_taken" }
|
||||
end
|
||||
local entropy = Bridge.Database.Query("SELECT UUID() AS `id`", {})[1]
|
||||
local profile_id = new_id()
|
||||
local wallet_key = new_crypto_key()
|
||||
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.")
|
||||
@@ -620,14 +711,14 @@ Bridge.Callbacks.Register("sky_phone:crypto:register", function(source, data)
|
||||
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 },
|
||||
(`id`,`owner_identifier`,`account_id`,`handle`,`crypto_key`,`password_hash`)
|
||||
VALUES (?, ?, ?, ?, ?, ?)]],
|
||||
params = { profile_id, identifier, account.id, handle, wallet_key, password_hash },
|
||||
},
|
||||
{
|
||||
query = [[INSERT INTO `sky_phone_crypto_balances`
|
||||
(`account_id`,`asset_id`,`available`) VALUES (?, 'CASH', 0)]],
|
||||
params = { account_id(entropy.id) },
|
||||
params = { account_id(profile_id) },
|
||||
},
|
||||
}
|
||||
if not Bridge.Database.Transaction(queries) then
|
||||
@@ -701,7 +792,7 @@ Bridge.Callbacks.Register("sky_phone:crypto:update-profile", function(source, da
|
||||
local handle_changed = handle:lower() ~= profile.handle:lower()
|
||||
local new_password = type(data.newPassword) == "string" and data.newPassword or ""
|
||||
local password_changed = new_password ~= ""
|
||||
if password_changed and not valid_password(new_password) then
|
||||
if password_changed and not valid_new_password(new_password) then
|
||||
return { success = false, error = "invalid_password" }
|
||||
end
|
||||
if handle_changed or password_changed then
|
||||
@@ -760,6 +851,168 @@ Bridge.Callbacks.Register("sky_phone:crypto:update-profile", function(source, da
|
||||
return { success = true, data = bootstrap(profile_by_owner(profile.owner_identifier)) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:crypto:recipient", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "crypto:recipient", 20, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local profile, error_response = authenticated_profile(source)
|
||||
if not profile then
|
||||
return error_response
|
||||
end
|
||||
local wallet_key = valid_crypto_key(type(data) == "table" and data.walletKey or nil)
|
||||
if not wallet_key then
|
||||
return { success = false, error = "invalid_wallet_key" }
|
||||
end
|
||||
local recipient = profile_by_crypto_key(wallet_key)
|
||||
if not recipient or recipient.status ~= "active" then
|
||||
return { success = false, error = "recipient_not_found" }
|
||||
end
|
||||
if recipient.id == profile.id then
|
||||
return { success = false, error = "self_transfer" }
|
||||
end
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
handle = recipient.handle,
|
||||
walletKey = recipient.crypto_key,
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
local function execute_transfer(profile, data)
|
||||
local key = idempotency_key(data.idempotencyKey)
|
||||
local wallet_key = valid_crypto_key(data.walletKey)
|
||||
local quantity = parse_quantity(data.quantity)
|
||||
local market = markets[data.marketId]
|
||||
if not key or not wallet_key or not quantity or not market then
|
||||
return { success = false, error = "invalid_transfer" }
|
||||
end
|
||||
if quantity > Config.Crypto.MaximumTransferQuantity * Config.Crypto.AssetScale then
|
||||
return { success = false, error = "limit_exceeded" }
|
||||
end
|
||||
if not verify_password(profile.id, data.password) then
|
||||
audit(profile.id, profile.owner_identifier, "transfer_reauth_failed", "")
|
||||
return { success = false, error = "invalid_credentials" }
|
||||
end
|
||||
local recipient = profile_by_crypto_key(wallet_key)
|
||||
if not recipient or recipient.status ~= "active" then
|
||||
return { success = false, error = "recipient_not_found" }
|
||||
end
|
||||
if recipient.id == profile.id then
|
||||
return { success = false, error = "self_transfer" }
|
||||
end
|
||||
local existing = Bridge.Database.Query([[
|
||||
SELECT `status` FROM `sky_phone_crypto_operations`
|
||||
WHERE `profile_id` = ? AND `type` = 'transfer_out' 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 sender_account = account_id(profile.id)
|
||||
local recipient_account = account_id(recipient.id)
|
||||
if balance(sender_account, market.Id) < quantity then
|
||||
return { success = false, error = "insufficient_funds" }
|
||||
end
|
||||
if balance(recipient_account, market.Id) + quantity
|
||||
> Config.Crypto.MaximumPositionQuantity * Config.Crypto.AssetScale
|
||||
then
|
||||
return { success = false, error = "recipient_limit_exceeded" }
|
||||
end
|
||||
local sender_operation = new_id()
|
||||
local recipient_operation = new_id()
|
||||
local request_hash = Bridge.Database.Query(
|
||||
"SELECT SHA2(CONCAT(?, ':', ?, ':', ?, ':', ?), 256) AS `hash`",
|
||||
{ profile.id, recipient.id, market.Id, quantity }
|
||||
)[1].hash
|
||||
local queries = {
|
||||
{
|
||||
query = [[INSERT INTO `sky_phone_crypto_operations`
|
||||
(`id`,`profile_id`,`type`,`idempotency_key`,`request_hash`,`status`,`market_id`,`quantity`,`counterparty_key`)
|
||||
VALUES (?, ?, 'transfer_out', ?, ?, 'completed', ?, ?, ?)]],
|
||||
params = {
|
||||
sender_operation,
|
||||
profile.id,
|
||||
key,
|
||||
request_hash,
|
||||
market.Id,
|
||||
quantity,
|
||||
recipient.crypto_key,
|
||||
},
|
||||
},
|
||||
{
|
||||
query = [[INSERT INTO `sky_phone_crypto_operations`
|
||||
(`id`,`profile_id`,`type`,`idempotency_key`,`request_hash`,`status`,`market_id`,`quantity`,`counterparty_key`)
|
||||
VALUES (?, ?, 'transfer_in', ?, ?, 'completed', ?, ?, ?)]],
|
||||
params = {
|
||||
recipient_operation,
|
||||
recipient.id,
|
||||
"incoming-" .. sender_operation,
|
||||
request_hash,
|
||||
market.Id,
|
||||
quantity,
|
||||
profile.crypto_key,
|
||||
},
|
||||
},
|
||||
{
|
||||
query = [[UPDATE `sky_phone_crypto_balances`
|
||||
SET `available` = `available` - ?, `version` = `version` + 1
|
||||
WHERE `account_id` = ? AND `asset_id` = ? AND `available` >= ?]],
|
||||
params = { quantity, sender_account, market.Id, quantity },
|
||||
},
|
||||
{
|
||||
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 = { recipient_account, market.Id, quantity },
|
||||
},
|
||||
{
|
||||
query = [[INSERT INTO `sky_phone_crypto_ledger_entries`
|
||||
(`operation_id`,`account_id`,`asset_id`,`delta`)
|
||||
VALUES (?, ?, ?, ?), (?, ?, ?, ?)]],
|
||||
params = {
|
||||
sender_operation,
|
||||
sender_account,
|
||||
market.Id,
|
||||
-quantity,
|
||||
sender_operation,
|
||||
recipient_account,
|
||||
market.Id,
|
||||
quantity,
|
||||
},
|
||||
},
|
||||
}
|
||||
if not Bridge.Database.Transaction(queries) then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
sessions[source].recently_authenticated_at = os.time()
|
||||
audit(profile.id, profile.owner_identifier, "transfer_sent", sender_operation)
|
||||
audit(recipient.id, recipient.owner_identifier, "transfer_received", sender_operation)
|
||||
for target_source, session in pairs(sessions) do
|
||||
if session.profile_id == recipient.id then
|
||||
TriggerClientEvent("sky_phone:crypto:account-changed", target_source)
|
||||
end
|
||||
end
|
||||
return { success = true, data = bootstrap(profile) }
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:crypto:transfer", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "crypto:transfer", 12, 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_transfer(profile, type(data) == "table" and data or {})
|
||||
end)
|
||||
end)
|
||||
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" }
|
||||
@@ -1096,6 +1349,7 @@ AddEventHandler("playerDropped", function()
|
||||
end)
|
||||
|
||||
ensure_schema()
|
||||
migrate_crypto_keys()
|
||||
initialize_markets()
|
||||
|
||||
local function reconcile_settlements(include_recent)
|
||||
|
||||
Reference in New Issue
Block a user