Files
sky_phone-sky-systems/sky_phone/source/server/crypto.lua
T
Leon.Schmidt edd9677f43 ADD - add in-game phone administration and configuration (#21)
* ADD - build in-game admin command center

* ADD - open admin panel by command

* ENH - rebuild admin panel as standalone editor

* ENH - compact admin workspace navigation

* ENH - expand admin device controls

* ENH - refine admin panel navigation style

* ADD - add SQL phone configurator

* FIX - expose complete phone configuration

* FIX - make company jobs freely configurable

* FIX - align configurator tables to left

* ENH - organize configurator collection controls

* ENH - organize nested configurator sections

* ENH - add configurator field descriptions

* FIX - hide fixed configurator add controls

* ENH - refine configurator editing experience

* ENH - refine admin configurator controls

* FIX - apply configurator settings instantly

* FIX - close live activity command registration
2026-08-21 17:28:30 +02:00

1634 lines
77 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()
local next_markets = {}
local next_market_order = {}
local next_market_dynamics = {}
for _, config in ipairs(Config.Crypto.Markets) do
if next_markets[config.Id] then
error(("[sky_phone] Config.Crypto.Markets contains duplicate id '%s'."):format(config.Id))
end
next_markets[config.Id] = config
next_market_order[#next_market_order + 1] = config.Id
next_market_dynamics[config.Id] = market_dynamics[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 })
markets = next_markets
market_order = next_market_order
market_dynamics = next_market_dynamics
market_cursor = math.min(market_cursor, math.max(#market_order, 1))
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()
AddEventHandler("sky_phone:configurator:serverUpdated", 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)