mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:01:31 +00:00
7e2bd38599
Add the VaultX phone UI, stateful browser mocks, localized copy, and generated WebP icon. Implement character-bound authentication, fixed-point balances, finite treasury markets, idempotent quote execution, settlement reconciliation, Scrypt credentials, SQL schema, limits, audit records, and contract tests.
1067 lines
52 KiB
Lua
1067 lines
52 KiB
Lua
Bridge.Database.AfterMigration("sky_phone", function()
|
|
|
|
local sessions = {}
|
|
local profile_locks = {}
|
|
local exchange_lock = false
|
|
local markets = {}
|
|
local market_order = {}
|
|
|
|
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,
|
|
`password_hash` VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
|
`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`)
|
|
) 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') 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,
|
|
`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
|
|
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 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 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`,`status`,`failed_logins`,
|
|
UNIX_TIMESTAMP(`locked_until`) AS `locked_until`
|
|
FROM `sky_phone_crypto_profiles` WHERE `owner_identifier` = ? LIMIT 1
|
|
]], { identifier })[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()
|
|
local current = market_rows()
|
|
local result = {}
|
|
for _, market_id in ipairs(market_order) do
|
|
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 12
|
|
]], { market_id })
|
|
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 = {}
|
|
for index, price in ipairs(prices) do
|
|
sparkline[index] = (price - minimum) / span
|
|
end
|
|
local first = prices[1]
|
|
local price = tonumber(row.price) or config.InitialPrice
|
|
result[#result + 1] = {
|
|
id = market_id,
|
|
symbol = config.Symbol,
|
|
name = config.Name,
|
|
color = config.Color,
|
|
price = decimal_string(price, Config.Crypto.PriceScale),
|
|
changePercent = first > 0 and ((price - first) / first) * 100 or 0,
|
|
enabled = row.status == "active",
|
|
sparkline = sparkline,
|
|
}
|
|
end
|
|
return result
|
|
end
|
|
|
|
local function activity(profile_id)
|
|
local rows = Bridge.Database.Query([[
|
|
SELECT `id`,`type`,`amount`,`market_id`,`status`, UNIX_TIMESTAMP(`created_at`) AS `created_at`
|
|
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),
|
|
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
|
|
return {
|
|
authenticated = true,
|
|
profile = { id = profile.id, handle = profile.handle, status = profile.status },
|
|
cashBalance = decimal_string(cash, Config.Crypto.PriceScale),
|
|
portfolioValue = decimal_string(portfolio, Config.Crypto.PriceScale),
|
|
holdings = holdings,
|
|
markets = market_dtos(),
|
|
activity = activity(profile.id),
|
|
}
|
|
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_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 entropy = Bridge.Database.Query("SELECT UUID() AS `id`", {})[1]
|
|
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`,`password_hash`)
|
|
VALUES (?, ?, ?, ?, ?)]],
|
|
params = { entropy.id, identifier, account.id, handle, password_hash },
|
|
},
|
|
{
|
|
query = [[INSERT INTO `sky_phone_crypto_balances`
|
|
(`account_id`,`asset_id`,`available`) VALUES (?, 'CASH', 0)]],
|
|
params = { account_id(entropy.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: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()
|
|
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)
|
|
|
|
CreateThread(function()
|
|
while true do
|
|
Wait(5 * 60 * 1000)
|
|
reconcile_settlements(false)
|
|
end
|
|
end)
|
|
|
|
CreateThread(function()
|
|
while true do
|
|
Wait(Config.Crypto.PriceTickSeconds * 1000)
|
|
with_exchange_lock(function()
|
|
for _, market_id in ipairs(market_order) do
|
|
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 movement = exports[GetCurrentResourceName()]:CryptoRandomInt(
|
|
-config.VolatilityBasisPoints,
|
|
config.VolatilityBasisPoints + 1
|
|
)
|
|
if type(movement) ~= "number" then
|
|
error("[sky_phone] Crypto entropy provider did not return a market movement.")
|
|
end
|
|
local next_price = math.floor(price * (10000 + movement) / 10000)
|
|
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
|
|
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 1440
|
|
) retained
|
|
)
|
|
]], { market_id, market_id })
|
|
end
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
end)
|
|
|
|
end)
|