Files
smx.pusha f657d1f63b ENH - add slow VaultX market cycles
Drive server-authoritative prices through alternating global and per-market trend phases with eased transitions, reduced noise, momentum, mean reversion, and rare bounded shocks. Mirror the cycle model in browser mocks and slow development preview ticks so charts show readable highs and downs.
2026-08-18 12:49:35 +02:00

1621 lines
76 KiB
Lua

Bridge.Database.AfterMigration("sky_phone", function()
local sessions = {}
local profile_locks = {}
local exchange_lock = false
local markets = {}
local market_order = {}
local market_dynamics = {}
local market_cursor = 1
local global_market_trend = 0
local global_market_cycle = {
direction = 0,
remaining_ticks = 0,
target = 0,
}
local function ensure_schema()
local statements = {
[[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_profiles` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`account_id` BIGINT UNSIGNED NULL,
`handle` VARCHAR(20) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`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,
`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,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_crypto_owner` (`owner_identifier`),
UNIQUE KEY `uniq_sky_phone_crypto_handle` (`handle`),
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,
`asset_scale` BIGINT UNSIGNED NOT NULL,
`price_scale` BIGINT UNSIGNED NOT NULL,
`issued_supply` DECIMAL(36,0) UNSIGNED NOT NULL,
`price` DECIMAL(36,0) UNSIGNED NOT NULL,
`version` BIGINT UNSIGNED NOT NULL DEFAULT 1,
`status` ENUM('active','halted','stale') NOT NULL DEFAULT 'active',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB]],
[[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_market_ticks` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`version` BIGINT UNSIGNED NOT NULL,
`price` DECIMAL(36,0) UNSIGNED NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_crypto_tick` (`market_id`,`version`),
KEY `idx_sky_phone_crypto_ticks` (`market_id`,`created_at`,`id`)
) ENGINE=InnoDB]],
[[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_balances` (
`account_id` VARCHAR(48) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`asset_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`available` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0,
`locked` DECIMAL(36,0) UNSIGNED NOT NULL DEFAULT 0,
`version` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`account_id`,`asset_id`)
) ENGINE=InnoDB]],
[[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_operations` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`type` ENUM('buy','sell','deposit','withdrawal','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,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_crypto_operation` (`profile_id`,`type`,`idempotency_key`),
KEY `idx_sky_phone_crypto_activity` (`profile_id`,`created_at`,`id`)
) ENGINE=InnoDB]],
[[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_ledger_entries` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`account_id` VARCHAR(48) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`asset_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`delta` DECIMAL(36,0) NOT NULL,
`balance_after` DECIMAL(36,0) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_crypto_ledger_operation` (`operation_id`,`id`),
KEY `idx_sky_phone_crypto_ledger_account` (`account_id`,`created_at`,`id`)
) ENGINE=InnoDB]],
[[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_quotes` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`side` ENUM('buy','sell') NOT NULL,
`quantity` DECIMAL(36,0) UNSIGNED NOT NULL,
`price` DECIMAL(36,0) UNSIGNED NOT NULL,
`gross` DECIMAL(36,0) UNSIGNED NOT NULL,
`fee` DECIMAL(36,0) UNSIGNED NOT NULL,
`net` DECIMAL(36,0) UNSIGNED NOT NULL,
`market_version` BIGINT UNSIGNED NOT NULL,
`expires_at` DATETIME NOT NULL,
`consumed_operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_crypto_quote_profile` (`profile_id`,`expires_at`)
) ENGINE=InnoDB]],
[[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_fills` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`quote_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`market_id` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`side` ENUM('buy','sell') NOT NULL,
`quantity` DECIMAL(36,0) UNSIGNED NOT NULL,
`price` DECIMAL(36,0) UNSIGNED NOT NULL,
`gross` DECIMAL(36,0) UNSIGNED NOT NULL,
`fee` DECIMAL(36,0) UNSIGNED NOT NULL,
`net` DECIMAL(36,0) UNSIGNED NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_crypto_fill_quote` (`quote_id`)
) ENGINE=InnoDB]],
[[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_settlements` (
`operation_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`framework_account` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`amount` DECIMAL(36,0) UNSIGNED NOT NULL,
`state` ENUM('prepared','external_pending','external_applied','ledger_applied','completed','failed','compensation_pending','manual_review','cancelled') NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`operation_id`),
KEY `idx_sky_phone_crypto_settlement_state` (`state`,`updated_at`)
) ENGINE=InnoDB]],
[[CREATE TABLE IF NOT EXISTS `sky_phone_crypto_audit_events` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL,
`event_type` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`detail` VARCHAR(255) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_crypto_audit` (`profile_id`,`created_at`,`id`)
) ENGINE=InnoDB]],
}
for _, statement in ipairs(statements) do
Bridge.Database.Query(statement, {})
end
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()
local row = Bridge.Database.Query("SELECT UUID() AS `id`", {})[1]
if not row or type(row.id) ~= "string" then
error("[sky_phone] Database did not generate a Crypto id.")
end
return row.id
end
local function 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
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function account_id(profile_id)
return "profile:" .. profile_id
end
local function valid_handle(value)
if type(value) ~= "string" then
return nil
end
local handle = value:match("^%s*(.-)%s*$")
local length = utf8.len(handle)
if not length or length < Config.Crypto.HandleMinLength or length > Config.Crypto.HandleMaxLength
or not handle:match("^[A-Za-z0-9][A-Za-z0-9._]*[A-Za-z0-9]$")
then
return nil
end
return handle
end
local function valid_password(value)
local length = type(value) == "string" and utf8.len(value) or nil
return length and length >= Config.Crypto.PasswordMinLength
and length <= Config.Crypto.PasswordMaxLength
end
local function 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
end
local amount = tonumber(value)
if not amount or amount ~= math.floor(amount) or amount < minimum or amount > maximum then
return nil
end
return amount
end
local function parse_quantity(value)
if type(value) ~= "string" then
return nil
end
local whole, decimal = value:match("^(%d+)%.?(%d*)$")
if not whole or #decimal > 6 then
return nil
end
decimal = decimal .. string.rep("0", 6 - #decimal)
local quantity = tonumber(whole) * Config.Crypto.AssetScale + tonumber(decimal)
if quantity <= 0 or quantity > 1000000 * Config.Crypto.AssetScale then
return nil
end
return quantity
end
local function decimal_string(value, scale)
value = math.floor(tonumber(value) or 0)
if scale == 1 then
return tostring(value)
end
local digits = tostring(scale):len() - 1
local whole = math.floor(value / scale)
local fraction = tostring(value % scale)
fraction = string.rep("0", digits - #fraction) .. fraction
fraction = fraction:gsub("0+$", "")
return fraction == "" and tostring(whole) or (tostring(whole) .. "." .. fraction)
end
local function ceil_div(value, divisor)
return math.floor((value + divisor - 1) / divisor)
end
local function initialize_markets()
for _, config in ipairs(Config.Crypto.Markets) do
markets[config.Id] = config
market_order[#market_order + 1] = config.Id
Bridge.Database.Query([[
INSERT INTO `sky_phone_crypto_markets`
(`id`,`asset_scale`,`price_scale`,`issued_supply`,`price`,`version`,`status`)
VALUES (?, ?, ?, ?, ?, 1, 'active') ON DUPLICATE KEY UPDATE `id` = VALUES(`id`)
]], {
config.Id,
Config.Crypto.AssetScale,
Config.Crypto.PriceScale,
config.IssuedSupply * Config.Crypto.AssetScale,
config.InitialPrice,
})
local persisted = Bridge.Database.Query([[
SELECT `asset_scale`,`price_scale`,`issued_supply`
FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1
]], { config.Id })[1]
if not persisted
or tonumber(persisted.asset_scale) ~= Config.Crypto.AssetScale
or tonumber(persisted.price_scale) ~= Config.Crypto.PriceScale
or tonumber(persisted.issued_supply) ~= config.IssuedSupply * Config.Crypto.AssetScale
then
error(("[sky_phone] Crypto market scale or supply changed without a migration: %s"):format(config.Id))
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`)
VALUES ('treasury', ?, ?) ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`)
]], { config.Id, config.TreasuryInventory * Config.Crypto.AssetScale })
Bridge.Database.Query([[
INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`)
VALUES ('reserve', ?, ?) ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`)
]], {
config.Id,
(config.IssuedSupply - config.TreasuryInventory) * Config.Crypto.AssetScale,
})
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`)
VALUES ('treasury', 'CASH', ?) ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`)
]], { Config.Crypto.TreasuryCash * Config.Crypto.PriceScale })
end
local function require_phone(source)
local phone_session, error_response = SkyPhone.RequireSession(source)
if not phone_session then
return nil, nil, error_response
end
local identifier = Bridge.Framework.GetIdentifier(source)
if type(identifier) ~= "string" or identifier == "" then
return nil, nil, { success = false, error = "service_unavailable" }
end
return phone_session, identifier
end
local function profile_by_owner(identifier)
return Bridge.Database.Query([[
SELECT `id`,`owner_identifier`,`account_id`,`handle`,`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`
FROM `sky_phone_crypto_profiles` WHERE `owner_identifier` = ? LIMIT 1
]], { 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
return nil, error_response
end
local session = sessions[source]
if not session or session.identifier ~= identifier or session.imei ~= phone_session.imei
or session.expires_at < os.time()
then
sessions[source] = nil
return nil, { success = false, error = "not_authenticated" }
end
local profile = profile_by_owner(identifier)
if not profile or profile.id ~= session.profile_id or profile.status ~= "active" then
sessions[source] = nil
return nil, { success = false, error = "not_authenticated" }
end
session.expires_at = os.time() + Config.Crypto.SessionSeconds
return profile
end
local function verify_password(profile_id, password)
if not valid_password(password) then
return false
end
local row = Bridge.Database.Query(
"SELECT `password_hash` FROM `sky_phone_crypto_profiles` WHERE `id` = ? LIMIT 1",
{ profile_id }
)[1]
return row and exports[GetCurrentResourceName()]:CryptoVerifyPassword(password, row.password_hash) or false
end
local function set_session(source, phone_session, profile, recently_authenticated)
sessions[source] = {
expires_at = os.time() + Config.Crypto.SessionSeconds,
identifier = profile.owner_identifier,
imei = phone_session.imei,
profile_id = profile.id,
recently_authenticated_at = recently_authenticated and os.time() or 0,
}
end
local function balance(account, asset)
local row = Bridge.Database.Query([[
SELECT `available`,`locked`,`version` FROM `sky_phone_crypto_balances`
WHERE `account_id` = ? AND `asset_id` = ? LIMIT 1
]], { account, asset })[1]
return row and (tonumber(row.available) or 0) or 0,
row and (tonumber(row.locked) or 0) or 0,
row and (tonumber(row.version) or 0) or 0
end
local function market_rows()
local rows = Bridge.Database.Query([[
SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at`
FROM `sky_phone_crypto_markets`
]], {})
local indexed = {}
for _, row in ipairs(rows) do
indexed[row.id] = row
end
return indexed
end
local function market_dtos(selected_market_ids)
local current = market_rows()
local selected = nil
if selected_market_ids then
selected = {}
for _, market_id in ipairs(selected_market_ids) do
selected[market_id] = true
end
end
local daily_rows = Bridge.Database.Query([[
SELECT `market_id`, MIN(`price`) AS `low`, MAX(`price`) AS `high`
FROM `sky_phone_crypto_market_ticks`
WHERE `created_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
GROUP BY `market_id`
]], {})
local daily = {}
for _, daily_row in ipairs(daily_rows) do
daily[daily_row.market_id] = daily_row
end
local result = {}
for _, market_id in ipairs(market_order) do
if not selected or selected[market_id] then
local config = markets[market_id]
local row = current[market_id]
local ticks = Bridge.Database.Query([[
SELECT `price` FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
]], { market_id, Config.Crypto.SparklinePoints })
local prices = {}
for index = #ticks, 1, -1 do
prices[#prices + 1] = tonumber(ticks[index].price) or tonumber(row.price)
end
if #prices == 0 then
prices[1] = tonumber(row.price)
end
local minimum = math.min(table.unpack(prices))
local maximum = math.max(table.unpack(prices))
local span = math.max(1, maximum - minimum)
local sparkline = {}
local price_history = {}
for index, historical_price in ipairs(prices) do
sparkline[index] = (historical_price - minimum) / span
price_history[index] = decimal_string(historical_price, Config.Crypto.PriceScale)
end
local first = prices[1]
local price = tonumber(row.price) or config.InitialPrice
local daily_range = daily[market_id]
result[#result + 1] = {
id = market_id,
symbol = config.Symbol,
name = config.Name,
color = config.Color,
logo = config.Logo,
price = decimal_string(price, Config.Crypto.PriceScale),
changePercent = first > 0 and ((price - first) / first) * 100 or 0,
enabled = row.status == "active",
high24h = decimal_string(daily_range and daily_range.high or maximum, Config.Crypto.PriceScale),
low24h = decimal_string(daily_range and daily_range.low or minimum, Config.Crypto.PriceScale),
issuedSupply = decimal_string(config.IssuedSupply * Config.Crypto.AssetScale, Config.Crypto.AssetScale),
treasuryAvailable = decimal_string(balance("treasury", market_id), Config.Crypto.AssetScale),
priceHistory = price_history,
sparkline = sparkline,
updatedAt = (tonumber(row.updated_at) or os.time()) * 1000,
}
end
end
return result
end
local function activity(profile_id)
local rows = Bridge.Database.Query([[
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 })
local result = {}
for _, row in ipairs(rows) do
result[#result + 1] = {
id = row.id,
type = row.type,
amount = decimal_string(row.amount, Config.Crypto.PriceScale),
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,
}
end
return result
end
local function daily_total(profile_id, operation_type)
local row = Bridge.Database.Query([[
SELECT COALESCE(SUM(`amount`), 0) AS `total`
FROM `sky_phone_crypto_operations`
WHERE `profile_id` = ? AND `type` = ? AND `status` = 'completed'
AND `created_at` >= CURRENT_DATE
]], { profile_id, operation_type })[1]
return tonumber(row and row.total) or 0
end
local function bootstrap(profile)
local cash = balance(account_id(profile.id), "CASH")
local current_markets = market_rows()
local holdings = {}
local portfolio = cash
for _, market_id in ipairs(market_order) do
local available = balance(account_id(profile.id), market_id)
if available > 0 then
local price = tonumber(current_markets[market_id].price) or 0
local value = math.floor(available * price / Config.Crypto.AssetScale)
local fill = Bridge.Database.Query([[
SELECT FLOOR(SUM(fill.`gross`) * ? / NULLIF(SUM(fill.`quantity`), 0)) AS `price`
FROM `sky_phone_crypto_fills` fill
JOIN `sky_phone_crypto_operations` operation ON operation.`id` = fill.`operation_id`
WHERE operation.`profile_id` = ? AND fill.`market_id` = ? AND fill.`side` = 'buy'
]], { Config.Crypto.AssetScale, profile.id, market_id })[1]
holdings[#holdings + 1] = {
assetId = market_id,
quantity = decimal_string(available, Config.Crypto.AssetScale),
value = decimal_string(value, Config.Crypto.PriceScale),
averagePrice = decimal_string(fill and fill.price or price, Config.Crypto.PriceScale),
}
portfolio = portfolio + value
end
end
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,
walletKey = profile.crypto_key,
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,
markets = market_dtos(),
activity = activity(profile.id),
registered = true,
}
end
local function audit(profile_id, identifier, event_type, detail)
Bridge.Database.Query([[
INSERT INTO `sky_phone_crypto_audit_events`
(`profile_id`,`owner_identifier`,`event_type`,`detail`) VALUES (?, ?, ?, ?)
]], { profile_id, identifier, event_type, detail or "" })
end
local function idempotency_key(value)
if type(value) ~= "string" or #value < 8 or #value > 96
or not value:match("^[A-Za-z0-9._-]+$")
then
return nil
end
return value
end
local function with_profile_lock(profile_id, callback)
if profile_locks[profile_id] then
return { success = false, error = "rate_limited" }
end
profile_locks[profile_id] = true
local success, result = pcall(callback)
profile_locks[profile_id] = nil
if not success then
error(result)
end
return result
end
local function with_exchange_lock(callback)
if exchange_lock then
return { success = false, error = "rate_limited" }
end
exchange_lock = true
local success, result = pcall(callback)
exchange_lock = false
if not success then
error(result)
end
return result
end
Bridge.Callbacks.Register("sky_phone:crypto:bootstrap", function(source)
if not Config.Crypto.Enabled then
return { success = false, error = "service_unavailable" }
end
local profile, error_response = authenticated_profile(source)
if profile then
return { success = true, data = bootstrap(profile) }
end
local _, identifier, phone_error = require_phone(source)
if not identifier then
return phone_error
end
local existing = profile_by_owner(identifier)
return {
success = true,
data = {
authenticated = false,
profile = nil,
cashBalance = "0",
portfolioValue = "0",
holdings = {},
markets = market_dtos(),
activity = {},
registered = existing ~= nil,
},
}
end)
Bridge.Callbacks.Register("sky_phone:crypto:register", function(source, data)
if not SkyPhone.AllowOperation(source, "crypto:register", 5, 60) then
return { success = false, error = "rate_limited" }
end
local phone_session, identifier, error_response = require_phone(source)
if not phone_session then
return error_response
end
local account, account_error = SkyPhone.RequireAccount(source)
if not account then
return account_error
end
data = type(data) == "table" and data or {}
local handle = valid_handle(data.handle)
if not handle then
return { success = false, error = "invalid_handle" }
end
if not valid_new_password(data.password) then
return { success = false, error = "invalid_password" }
end
if profile_by_owner(identifier) then
return { success = false, error = "profile_exists" }
end
local duplicate = Bridge.Database.Query(
"SELECT 1 FROM `sky_phone_crypto_profiles` WHERE `handle` = ? LIMIT 1",
{ handle }
)
if duplicate[1] then
return { success = false, error = "handle_taken" }
end
local 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.")
end
local queries = {
{
query = [[INSERT INTO `sky_phone_crypto_profiles`
(`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(profile_id) },
},
}
if not Bridge.Database.Transaction(queries) then
return { success = false, error = "request_failed" }
end
local profile = profile_by_owner(identifier)
set_session(source, phone_session, profile, true)
audit(profile.id, identifier, "profile_registered", "")
return { success = true, data = bootstrap(profile) }
end)
Bridge.Callbacks.Register("sky_phone:crypto:login", function(source, data)
if not SkyPhone.AllowOperation(source, "crypto:login", 10, 60) then
return { success = false, error = "rate_limited" }
end
local phone_session, identifier, error_response = require_phone(source)
if not phone_session then
return error_response
end
local profile = profile_by_owner(identifier)
if not profile then
return { success = false, error = "invalid_credentials" }
end
if tonumber(profile.locked_until) and tonumber(profile.locked_until) > os.time() then
return { success = false, error = "locked" }
end
if not verify_password(profile.id, type(data) == "table" and data.password or nil) then
local failed = (tonumber(profile.failed_logins) or 0) + 1
local lock = failed >= Config.Crypto.LoginAttempts
Bridge.Database.Query([[
UPDATE `sky_phone_crypto_profiles` SET `failed_logins` = ?,
`locked_until` = IF(?, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? SECOND), NULL)
WHERE `id` = ?
]], { lock and 0 or failed, lock and 1 or 0, Config.Crypto.LockoutSeconds, profile.id })
audit(profile.id, identifier, "login_failed", lock and "profile_locked" or "invalid_password")
return { success = false, error = lock and "locked" or "invalid_credentials" }
end
Bridge.Database.Query(
"UPDATE `sky_phone_crypto_profiles` SET `failed_logins` = 0, `locked_until` = NULL WHERE `id` = ?",
{ profile.id }
)
set_session(source, phone_session, profile, true)
audit(profile.id, identifier, "login_succeeded", phone_session.imei)
return { success = true, data = bootstrap(profile) }
end)
Bridge.Callbacks.Register("sky_phone:crypto:logout", function(source)
local session = sessions[source]
if session then
audit(session.profile_id, session.identifier, "logout", "")
end
sessions[source] = nil
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:crypto: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
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_new_password(new_password) then
return { success = false, error = "invalid_password" }
end
if handle_changed or password_changed then
if not verify_password(profile.id, data.currentPassword) then
audit(profile.id, profile.owner_identifier, "profile_update_reauth_failed", "")
return { success = false, error = "invalid_credentials" }
end
sessions[source].recently_authenticated_at = os.time()
end
if handle_changed then
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
if password_changed then
local password_hash = exports[GetCurrentResourceName()]:CryptoHashPassword(new_password)
if type(password_hash) ~= "string" then
return { success = false, error = "service_unavailable" }
end
Bridge.Database.Query([[
UPDATE `sky_phone_crypto_profiles`
SET `handle` = ?, `password_hash` = ?, `price_alerts` = ?,
`trade_confirmations` = ?, `hide_balances` = ?
WHERE `id` = ?
]], {
handle,
password_hash,
data.priceAlerts and 1 or 0,
data.tradeConfirmations and 1 or 0,
data.hideBalances and 1 or 0,
profile.id,
})
else
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,
})
end
audit(
profile.id,
profile.owner_identifier,
"profile_updated",
(handle_changed and "handle" or "preferences") .. (password_changed and ",password" or "")
)
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" }
end
local profile, error_response = authenticated_profile(source)
if not profile then
return error_response
end
data = type(data) == "table" and data or {}
local config = markets[data.marketId]
local side = data.side == "buy" and "buy" or data.side == "sell" and "sell" or nil
local quantity = parse_quantity(data.quantity)
if not config or not side or not quantity then
return { success = false, error = "invalid_quantity" }
end
local market = Bridge.Database.Query(
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
{ config.Id }
)[1]
if not market or market.status ~= "active" then
return { success = false, error = "market_unavailable" }
end
local spread_bps = 40
local reference = tonumber(market.price)
local price = side == "buy"
and ceil_div(reference * (10000 + spread_bps), 10000)
or math.floor(reference * (10000 - spread_bps) / 10000)
local gross = side == "buy"
and ceil_div(quantity * price, Config.Crypto.AssetScale)
or math.floor(quantity * price / Config.Crypto.AssetScale)
local fee = math.max(Config.Crypto.MinimumFee, ceil_div(gross * Config.Crypto.FeeBasisPoints, 10000))
local net = side == "buy" and gross + fee or gross - fee
if gross <= 0 or net <= 0 or gross > Config.Crypto.MaximumTradeNotional * Config.Crypto.PriceScale then
return { success = false, error = "limit_exceeded" }
end
if side == "buy" then
if balance(account_id(profile.id), config.Id) + quantity
> Config.Crypto.MaximumPositionQuantity * Config.Crypto.AssetScale
then
return { success = false, error = "limit_exceeded" }
end
if balance(account_id(profile.id), "CASH") < net then
return { success = false, error = "insufficient_funds" }
end
if balance("treasury", config.Id) < quantity then
return { success = false, error = "insufficient_liquidity" }
end
else
if balance(account_id(profile.id), config.Id) < quantity then
return { success = false, error = "insufficient_funds" }
end
if balance("treasury", "CASH") < net then
return { success = false, error = "insufficient_liquidity" }
end
end
if daily_total(profile.id, side) + net > Config.Crypto.DailyTradeLimit * Config.Crypto.PriceScale then
return { success = false, error = "limit_exceeded" }
end
local quote_id = new_id()
Bridge.Database.Query([[
INSERT INTO `sky_phone_crypto_quotes`
(`id`,`profile_id`,`market_id`,`side`,`quantity`,`price`,`gross`,`fee`,`net`,`market_version`,`expires_at`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? SECOND))
]], {
quote_id, profile.id, config.Id, side, quantity, price, gross, fee, net,
market.version, Config.Crypto.QuoteLifetimeSeconds,
})
return {
success = true,
data = {
id = quote_id,
marketId = config.Id,
side = side,
quantity = decimal_string(quantity, Config.Crypto.AssetScale),
price = decimal_string(price, Config.Crypto.PriceScale),
gross = decimal_string(gross, Config.Crypto.PriceScale),
fee = decimal_string(fee, Config.Crypto.PriceScale),
net = decimal_string(net, Config.Crypto.PriceScale),
expiresAt = (os.time() + Config.Crypto.QuoteLifetimeSeconds) * 1000,
},
}
end)
local function execute_trade(profile, data)
local key = idempotency_key(data.idempotencyKey)
if not key or type(data.quoteId) ~= "string" or #data.quoteId ~= 36 then
return { success = false, error = "quote_unavailable" }
end
local existing = Bridge.Database.Query([[
SELECT `status` FROM `sky_phone_crypto_operations`
WHERE `profile_id` = ? AND `idempotency_key` = ? LIMIT 1
]], { profile.id, key })[1]
if existing then
return existing.status == "completed"
and { success = true, data = bootstrap(profile) }
or { success = false, error = "duplicate_request" }
end
local quote = Bridge.Database.Query([[
SELECT quote.*, market.`status` AS `market_status`, market.`version` AS `current_version`
FROM `sky_phone_crypto_quotes` quote
JOIN `sky_phone_crypto_markets` market ON market.`id` = quote.`market_id`
WHERE quote.`id` = ? AND quote.`profile_id` = ? LIMIT 1
]], { data.quoteId, profile.id })[1]
if not quote or quote.consumed_operation_id then
return { success = false, error = "quote_unavailable" }
end
local expiry = Bridge.Database.Query(
"SELECT UNIX_TIMESTAMP(`expires_at`) AS `expires_at` FROM `sky_phone_crypto_quotes` WHERE `id` = ?",
{ quote.id }
)[1]
if not expiry or tonumber(expiry.expires_at) < os.time() then
return { success = false, error = "quote_expired" }
end
if quote.market_status ~= "active" or tonumber(quote.current_version) ~= tonumber(quote.market_version) then
return { success = false, error = "quote_expired" }
end
local quantity = tonumber(quote.quantity)
local net_currency = tonumber(quote.net)
local player_account = account_id(profile.id)
if daily_total(profile.id, quote.side) + net_currency
> Config.Crypto.DailyTradeLimit * Config.Crypto.PriceScale
then
return { success = false, error = "limit_exceeded" }
end
if quote.side == "buy" then
if balance(player_account, quote.market_id) + quantity
> Config.Crypto.MaximumPositionQuantity * Config.Crypto.AssetScale
then
return { success = false, error = "limit_exceeded" }
end
if balance(player_account, "CASH") < net_currency then
return { success = false, error = "insufficient_funds" }
end
if balance("treasury", quote.market_id) < quantity then
return { success = false, error = "insufficient_liquidity" }
end
else
if balance(player_account, quote.market_id) < quantity then
return { success = false, error = "insufficient_funds" }
end
if balance("treasury", "CASH") < net_currency then
return { success = false, error = "insufficient_liquidity" }
end
end
local operation_id = new_id()
local fill_id = new_id()
local operation_type = quote.side
local request_hash = Bridge.Database.Query(
"SELECT SHA2(CONCAT(?, ':', ?), 256) AS `hash`",
{ quote.id, key }
)[1].hash
local queries = {
{
query = [[INSERT INTO `sky_phone_crypto_operations`
(`id`,`profile_id`,`type`,`idempotency_key`,`request_hash`,`status`,`amount`,`market_id`)
VALUES (?, ?, ?, ?, ?, 'prepared', ?, ?)]],
params = { operation_id, profile.id, operation_type, key, request_hash, net_currency, quote.market_id },
},
}
if quote.side == "buy" then
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` - ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH' AND `available` >= ?]], params = { net_currency, player_account, net_currency } }
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `version` = `version` + 1 WHERE `account_id` = 'treasury' AND `asset_id` = 'CASH']], params = { net_currency } }
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` - ?, `version` = `version` + 1 WHERE `account_id` = 'treasury' AND `asset_id` = ? AND `available` >= ?]], params = { quantity, quote.market_id, quantity } }
queries[#queries + 1] = { query = [[INSERT INTO `sky_phone_crypto_balances` (`account_id`,`asset_id`,`available`,`version`) VALUES (?, ?, ?, 1) ON DUPLICATE KEY UPDATE `available` = `available` + VALUES(`available`), `version` = `version` + 1]], params = { player_account, quote.market_id, quantity } }
else
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` - ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = ? AND `available` >= ?]], params = { quantity, player_account, quote.market_id, quantity } }
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `version` = `version` + 1 WHERE `account_id` = 'treasury' AND `asset_id` = ?]], params = { quantity, quote.market_id } }
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` - ?, `version` = `version` + 1 WHERE `account_id` = 'treasury' AND `asset_id` = 'CASH' AND `available` >= ?]], params = { net_currency, net_currency } }
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH']], params = { net_currency, player_account } }
end
queries[#queries + 1] = { query = [[INSERT INTO `sky_phone_crypto_ledger_entries` (`operation_id`,`account_id`,`asset_id`,`delta`) VALUES (?, ?, 'CASH', ?), (?, 'treasury', 'CASH', ?), (?, ?, ?, ?), (?, 'treasury', ?, ?)]], params = {
operation_id, player_account, quote.side == "buy" and -net_currency or net_currency,
operation_id, quote.side == "buy" and net_currency or -net_currency,
operation_id, player_account, quote.market_id, quote.side == "buy" and quantity or -quantity,
operation_id, quote.market_id, quote.side == "buy" and -quantity or quantity,
} }
queries[#queries + 1] = { query = [[INSERT INTO `sky_phone_crypto_fills` (`id`,`operation_id`,`quote_id`,`market_id`,`side`,`quantity`,`price`,`gross`,`fee`,`net`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)]], params = { fill_id, operation_id, quote.id, quote.market_id, quote.side, quantity, quote.price, quote.gross, quote.fee, quote.net } }
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_quotes` SET `consumed_operation_id` = ? WHERE `id` = ? AND `consumed_operation_id` IS NULL]], params = { operation_id, quote.id } }
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'completed' WHERE `id` = ?]], params = { operation_id } }
if not Bridge.Database.Transaction(queries) then
return { success = false, error = "request_failed" }
end
audit(profile.id, profile.owner_identifier, "trade_completed", operation_id)
return { success = true, data = bootstrap(profile) }
end
Bridge.Callbacks.Register("sky_phone:crypto:execute", function(source, data)
if not SkyPhone.AllowOperation(source, "crypto:trade", Config.Crypto.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local profile, error_response = authenticated_profile(source)
if not profile then
return error_response
end
return with_profile_lock(profile.id, function()
return with_exchange_lock(function()
return execute_trade(profile, type(data) == "table" and data or {})
end)
end)
end)
local function settlement_ledger_queries(operation_id, profile_id, kind, ledger_amount)
local player_account = account_id(profile_id)
local queries = {}
if kind == "deposit" then
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH']], params = { ledger_amount, player_account } }
queries[#queries + 1] = { query = [[INSERT INTO `sky_phone_crypto_ledger_entries` (`operation_id`,`account_id`,`asset_id`,`delta`) VALUES (?, ?, 'CASH', ?), (?, 'external:bank', 'CASH', ?)]], params = { operation_id, player_account, ledger_amount, operation_id, -ledger_amount } }
else
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `locked` = `locked` - ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH' AND `locked` >= ?]], params = { ledger_amount, player_account, ledger_amount } }
queries[#queries + 1] = { query = [[INSERT INTO `sky_phone_crypto_ledger_entries` (`operation_id`,`account_id`,`asset_id`,`delta`) VALUES (?, ?, 'CASH', ?), (?, 'external:bank', 'CASH', ?)]], params = { operation_id, player_account, -ledger_amount, operation_id, ledger_amount } }
end
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'completed' WHERE `id` = ? AND `status` = 'external_applied']], params = { operation_id } }
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_settlements` SET `state` = 'completed' WHERE `operation_id` = ? AND `state` = 'external_applied']], params = { operation_id } }
return queries
end
local function settle(source, profile, kind, data)
local key = idempotency_key(data.idempotencyKey)
local amount = parse_whole(data.amount, Config.Crypto.MinimumSettlement, Config.Crypto.MaximumSettlement)
if not key or not amount then
return { success = false, error = "invalid_amount" }
end
if not verify_password(profile.id, data.password) then
audit(profile.id, profile.owner_identifier, "settlement_reauth_failed", kind)
return { success = false, error = "invalid_credentials" }
end
local existing = Bridge.Database.Query([[
SELECT `status` FROM `sky_phone_crypto_operations`
WHERE `profile_id` = ? AND `type` = ? AND `idempotency_key` = ? LIMIT 1
]], { profile.id, kind, key })[1]
if existing then
return existing.status == "completed"
and { success = true, data = bootstrap(profile) }
or { success = false, error = "settlement_pending" }
end
local ledger_amount = amount * Config.Crypto.PriceScale
local daily_limit = kind == "deposit" and Config.Crypto.DailyDepositLimit
or Config.Crypto.DailyWithdrawalLimit
if daily_total(profile.id, kind) + ledger_amount > daily_limit * Config.Crypto.PriceScale then
return { success = false, error = "limit_exceeded" }
end
if kind == "withdrawal" and balance(account_id(profile.id), "CASH") < ledger_amount then
return { success = false, error = "insufficient_funds" }
end
local operation_id = new_id()
local request_hash = Bridge.Database.Query(
"SELECT SHA2(CONCAT(?, ':', ?, ':', ?), 256) AS `hash`",
{ kind, amount, key }
)[1].hash
local prepared = {
{
query = [[INSERT INTO `sky_phone_crypto_operations`
(`id`,`profile_id`,`type`,`idempotency_key`,`request_hash`,`status`,`amount`)
VALUES (?, ?, ?, ?, ?, 'prepared', ?)]],
params = { operation_id, profile.id, kind, key, request_hash, ledger_amount },
},
{
query = [[INSERT INTO `sky_phone_crypto_settlements`
(`operation_id`,`owner_identifier`,`framework_account`,`amount`,`state`)
VALUES (?, ?, ?, ?, 'prepared')]],
params = { operation_id, profile.owner_identifier, Config.Crypto.BankAccount, amount },
},
}
if kind == "withdrawal" then
prepared[#prepared + 1] = {
query = [[UPDATE `sky_phone_crypto_balances`
SET `available` = `available` - ?, `locked` = `locked` + ?, `version` = `version` + 1
WHERE `account_id` = ? AND `asset_id` = 'CASH' AND `available` >= ?]],
params = { ledger_amount, ledger_amount, account_id(profile.id), ledger_amount },
}
end
if not Bridge.Database.Transaction(prepared) then
return { success = false, error = "request_failed" }
end
Bridge.Database.Query("UPDATE `sky_phone_crypto_operations` SET `status` = 'external_pending' WHERE `id` = ?", { operation_id })
Bridge.Database.Query("UPDATE `sky_phone_crypto_settlements` SET `state` = 'external_pending' WHERE `operation_id` = ?", { operation_id })
local money_success, money_result = pcall(
kind == "deposit" and Bridge.Framework.RemoveMoney or Bridge.Framework.AddMoney,
source,
Config.Crypto.BankAccount,
amount
)
if not money_success then
Bridge.Database.Query("UPDATE `sky_phone_crypto_operations` SET `status` = 'manual_review', `detail` = 'framework_call_ambiguous' WHERE `id` = ?", { operation_id })
Bridge.Database.Query("UPDATE `sky_phone_crypto_settlements` SET `state` = 'manual_review' WHERE `operation_id` = ?", { operation_id })
audit(profile.id, profile.owner_identifier, "settlement_manual_review", operation_id)
return { success = false, error = "settlement_pending" }
end
if not money_result then
local failed = {
{ query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'failed', `detail` = 'framework_rejected' WHERE `id` = ?]], params = { operation_id } },
{ query = [[UPDATE `sky_phone_crypto_settlements` SET `state` = 'failed' WHERE `operation_id` = ?]], params = { operation_id } },
}
if kind == "withdrawal" then
failed[#failed + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `locked` = `locked` - ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH' AND `locked` >= ?]], params = { ledger_amount, ledger_amount, account_id(profile.id), ledger_amount } }
end
Bridge.Database.Transaction(failed)
return { success = false, error = kind == "deposit" and "insufficient_funds" or "request_failed" }
end
Bridge.Database.Query("UPDATE `sky_phone_crypto_operations` SET `status` = 'external_applied' WHERE `id` = ?", { operation_id })
Bridge.Database.Query("UPDATE `sky_phone_crypto_settlements` SET `state` = 'external_applied' WHERE `operation_id` = ?", { operation_id })
local ledger_queries = settlement_ledger_queries(operation_id, profile.id, kind, ledger_amount)
if not Bridge.Database.Transaction(ledger_queries) then
Bridge.Database.Query("UPDATE `sky_phone_crypto_operations` SET `status` = 'manual_review', `detail` = 'ledger_apply_failed' WHERE `id` = ?", { operation_id })
Bridge.Database.Query("UPDATE `sky_phone_crypto_settlements` SET `state` = 'manual_review' WHERE `operation_id` = ?", { operation_id })
audit(profile.id, profile.owner_identifier, "settlement_manual_review", operation_id)
return { success = false, error = "settlement_pending" }
end
sessions[source].recently_authenticated_at = os.time()
audit(profile.id, profile.owner_identifier, "settlement_completed", operation_id)
return { success = true, data = bootstrap(profile) }
end
for _, callback in ipairs({
{ name = "deposit", kind = "deposit" },
{ name = "withdraw", kind = "withdrawal" },
}) do
Bridge.Callbacks.Register("sky_phone:crypto:" .. callback.name, function(source, data)
if not SkyPhone.AllowOperation(source, "crypto:settlement", 8, 60) then
return { success = false, error = "rate_limited" }
end
local profile, error_response = authenticated_profile(source)
if not profile then
return error_response
end
return with_profile_lock(profile.id, function()
return settle(source, profile, callback.kind, type(data) == "table" and data or {})
end)
end)
end
AddEventHandler("playerDropped", function()
sessions[source] = nil
end)
ensure_schema()
migrate_crypto_keys()
initialize_markets()
local function reconcile_settlements(include_recent)
local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
local rows = Bridge.Database.Query([[
SELECT operation.`id`, operation.`profile_id`, operation.`type`, operation.`amount`,
operation.`status`, settlement.`state`
FROM `sky_phone_crypto_operations` operation
JOIN `sky_phone_crypto_settlements` settlement ON settlement.`operation_id` = operation.`id`
WHERE operation.`status` IN ('prepared','external_pending','external_applied')
]] .. age_clause, {})
for _, operation in ipairs(rows) do
local ledger_amount = tonumber(operation.amount)
if operation.status == "prepared" then
local queries = {}
if operation.type == "withdrawal" then
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_balances` SET `available` = `available` + ?, `locked` = `locked` - ?, `version` = `version` + 1 WHERE `account_id` = ? AND `asset_id` = 'CASH' AND `locked` >= ?]], params = { ledger_amount, ledger_amount, account_id(operation.profile_id), ledger_amount } }
end
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'cancelled', `detail` = 'reconciled_before_external_call' WHERE `id` = ? AND `status` = 'prepared']], params = { operation.id } }
queries[#queries + 1] = { query = [[UPDATE `sky_phone_crypto_settlements` SET `state` = 'cancelled' WHERE `operation_id` = ? AND `state` = 'prepared']], params = { operation.id } }
Bridge.Database.Transaction(queries)
elseif operation.status == "external_pending" then
Bridge.Database.Transaction({
{ query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'manual_review', `detail` = 'reconciled_ambiguous_external_call' WHERE `id` = ? AND `status` = 'external_pending']], params = { operation.id } },
{ query = [[UPDATE `sky_phone_crypto_settlements` SET `state` = 'manual_review' WHERE `operation_id` = ? AND `state` = 'external_pending']], params = { operation.id } },
})
elseif operation.status == "external_applied" then
local entries = Bridge.Database.Query(
"SELECT COUNT(*) AS `count` FROM `sky_phone_crypto_ledger_entries` WHERE `operation_id` = ?",
{ operation.id }
)[1]
if tonumber(entries and entries.count) == 0 then
Bridge.Database.Transaction(settlement_ledger_queries(
operation.id,
operation.profile_id,
operation.type,
ledger_amount
))
else
Bridge.Database.Transaction({
{ query = [[UPDATE `sky_phone_crypto_operations` SET `status` = 'manual_review', `detail` = 'unexpected_partial_ledger' WHERE `id` = ?]], params = { operation.id } },
{ query = [[UPDATE `sky_phone_crypto_settlements` SET `state` = 'manual_review' WHERE `operation_id` = ?]], params = { operation.id } },
})
end
end
end
end
reconcile_settlements(true)
local function crypto_random_int(minimum, maximum, failure_message)
local value = exports[GetCurrentResourceName()]:CryptoRandomInt(minimum, maximum)
if type(value) ~= "number" then
error(failure_message)
end
return value
end
local function truncate_integer(value)
if value > 0 then
return math.floor(value)
elseif value < 0 then
return math.ceil(value)
end
return 0
end
local function advance_global_market_cycle()
if global_market_cycle.remaining_ticks <= 0 then
if global_market_cycle.direction == 0 then
global_market_cycle.direction = crypto_random_int(
0,
2,
"[sky_phone] Crypto entropy provider did not return a global market direction."
) == 0 and -1 or 1
else
global_market_cycle.direction = -global_market_cycle.direction
end
global_market_cycle.remaining_ticks = crypto_random_int(
Config.Crypto.GlobalCycleMinimumTicks,
Config.Crypto.GlobalCycleMaximumTicks + 1,
"[sky_phone] Crypto entropy provider did not return a global market cycle duration."
)
global_market_cycle.target = global_market_cycle.direction * crypto_random_int(
Config.Crypto.GlobalTrendMinimumBasisPoints,
Config.Crypto.GlobalTrendMaximumBasisPoints + 1,
"[sky_phone] Crypto entropy provider did not return a global market trend."
)
end
global_market_trend = truncate_integer((
global_market_trend * (10000 - Config.Crypto.CycleTransitionBasisPoints)
+ global_market_cycle.target * Config.Crypto.CycleTransitionBasisPoints
) / 10000)
global_market_cycle.remaining_ticks = global_market_cycle.remaining_ticks - 1
end
local function advance_market_cycle(config, dynamics)
if dynamics.cycle_remaining_ticks <= 0 then
if dynamics.cycle_direction == 0 then
dynamics.cycle_direction = crypto_random_int(
0,
2,
"[sky_phone] Crypto entropy provider did not return a market cycle direction."
) == 0 and -1 or 1
else
dynamics.cycle_direction = -dynamics.cycle_direction
end
dynamics.cycle_remaining_ticks = crypto_random_int(
Config.Crypto.CycleDurationMinimumTicks,
Config.Crypto.CycleDurationMaximumTicks + 1,
"[sky_phone] Crypto entropy provider did not return a market cycle duration."
)
local strength = crypto_random_int(
Config.Crypto.CycleStrengthMinimumBasisPoints,
Config.Crypto.CycleStrengthMaximumBasisPoints + 1,
"[sky_phone] Crypto entropy provider did not return a market cycle strength."
)
dynamics.cycle_target = dynamics.cycle_direction * math.max(
1,
math.floor(config.VolatilityBasisPoints * strength / 10000)
)
end
dynamics.cycle_bias = truncate_integer((
dynamics.cycle_bias * (10000 - Config.Crypto.CycleTransitionBasisPoints)
+ dynamics.cycle_target * Config.Crypto.CycleTransitionBasisPoints
) / 10000)
dynamics.cycle_remaining_ticks = dynamics.cycle_remaining_ticks - 1
return dynamics.cycle_bias
end
CreateThread(function()
while true do
Wait(5 * 60 * 1000)
reconcile_settlements(false)
end
end)
CreateThread(function()
while true do
local tick_seconds = crypto_random_int(
Config.Crypto.PriceTickMinimumSeconds,
Config.Crypto.PriceTickMaximumSeconds + 1,
"[sky_phone] Crypto entropy provider did not return a market tick interval."
)
Wait(tick_seconds * 1000)
with_exchange_lock(function()
local market_count = crypto_random_int(
Config.Crypto.MarketsPerTickMinimum,
Config.Crypto.MarketsPerTickMaximum + 1,
"[sky_phone] Crypto entropy provider did not return a market count."
)
advance_global_market_cycle()
local changed_markets = {}
market_count = math.min(market_count, #market_order)
for offset = 0, market_count - 1 do
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
local market_id = market_order[order_index]
local config = markets[market_id]
local row = Bridge.Database.Query(
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
{ market_id }
)[1]
if row and row.status == "active" then
local price = tonumber(row.price) or config.InitialPrice
local impulse = crypto_random_int(
-config.VolatilityBasisPoints,
config.VolatilityBasisPoints + 1,
"[sky_phone] Crypto entropy provider did not return a market movement."
)
if impulse > 0 then
impulse = math.floor(impulse / Config.Crypto.RandomImpulseDivisor)
elseif impulse < 0 then
impulse = math.ceil(impulse / Config.Crypto.RandomImpulseDivisor)
end
local shock_roll = crypto_random_int(
0,
10000,
"[sky_phone] Crypto entropy provider did not return a market shock roll."
)
local dynamics = market_dynamics[market_id] or {
momentum = 0,
cycle_bias = 0,
cycle_direction = 0,
cycle_remaining_ticks = 0,
cycle_target = 0,
}
dynamics.momentum = truncate_integer((
dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints
+ impulse * Config.Crypto.MomentumImpulseBasisPoints
) / 10000)
local cycle_bias = advance_market_cycle(config, dynamics)
market_dynamics[market_id] = dynamics
local deviation = math.floor(
(config.InitialPrice - price) * 10000 / config.InitialPrice
)
local reversion = truncate_integer(
deviation * Config.Crypto.MeanReversionBasisPoints / 10000
)
local shock = 0
if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then
local multiplier = crypto_random_int(
Config.Crypto.MarketShockMinimumMultiplier,
Config.Crypto.MarketShockMaximumMultiplier + 1,
"[sky_phone] Crypto entropy provider did not return a market shock multiplier."
)
local direction_roll = crypto_random_int(
0,
2,
"[sky_phone] Crypto entropy provider did not return a market shock direction."
)
local direction = direction_roll == 0 and -1 or 1
shock = direction * config.VolatilityBasisPoints * multiplier
end
local maximum_movement = config.VolatilityBasisPoints
* Config.Crypto.MaximumMovementMultiplier
local movement = impulse + dynamics.momentum + cycle_bias
+ global_market_trend + reversion + shock
movement = math.max(-maximum_movement, math.min(maximum_movement, movement))
if movement > 0 then
movement = math.floor(movement / Config.Crypto.TickMovementDivisor)
elseif movement < 0 then
movement = math.ceil(movement / Config.Crypto.TickMovementDivisor)
end
local next_price = math.floor(price * (10000 + movement) / 10000)
if next_price == price and movement ~= 0 then
next_price = price + (movement > 0 and 1 or -1)
end
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
local next_version = (tonumber(row.version) or 0) + 1
if Bridge.Database.Transaction({
{ query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } },
{ query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } },
}) then
changed_markets[#changed_markets + 1] = market_id
Bridge.Database.Query([[
DELETE FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? AND `id` NOT IN (
SELECT `id` FROM (
SELECT `id` FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
) retained
)
]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks })
end
end
end
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
if #changed_markets > 0 then
TriggerClientEvent("sky_phone:crypto:changed", -1, {
markets = market_dtos(changed_markets),
updatedAt = os.time() * 1000,
})
end
end)
end
end)
end)