mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 05:02:32 +00:00
f657d1f63b
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.
1621 lines
76 KiB
Lua
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)
|