depot contract v2: all 7 process_assertion handlers + deadlock fix
- balance_sheet (0xAA00): deserialize + upsert reserves - stake_update (0xEE00): update reserves + queue outbound - yield_reward (0xEE01): calculate + inline transfer WIRE - wire_purchase (0xEE02): constant-product swap + transfer - operator_registration (0xEE03): create/reactivate/deregister - challenge_response (0xEE04): route to challenge FSM - slash_operator (0xEE05): slash + queue outbound ack - default: silently pass unknown types (fixes permanent deadlock) - includes compiled wasm (110,570 bytes) and ABI
This commit is contained in:
273
contracts/sysio.depot/include/sysio.depot/depot.tables.hpp
Normal file
273
contracts/sysio.depot/include/sysio.depot/depot.tables.hpp
Normal file
@@ -0,0 +1,273 @@
|
||||
#pragma once
|
||||
|
||||
#include <sysio/sysio.hpp>
|
||||
|
||||
#include <sysio.depot/depot.types.hpp>
|
||||
#include <sysio/singleton.hpp>
|
||||
#include <sysio/crypto.hpp>
|
||||
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
|
||||
namespace sysio {
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Global depot state (singleton)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
struct [[sysio::table("depotstate"), sysio::contract("sysio.depot")]] depot_global_state {
|
||||
depot_state_t state = depot_state_active;
|
||||
chain_kind_t chain_id = fc::crypto::chain_kind_unknown;
|
||||
uint64_t current_epoch = 0;
|
||||
uint64_t next_epoch = 1;
|
||||
uint64_t next_msg_out = 0; // monotonic outbound message counter
|
||||
time_point_sec last_crank_time;
|
||||
name token_contract; // sysio.token or similar
|
||||
bool initialized = false;
|
||||
|
||||
SYSLIB_SERIALIZE(depot_global_state,
|
||||
(state)(chain_id)(current_epoch)(next_epoch)(next_msg_out)
|
||||
(last_crank_time)(token_contract)(initialized))
|
||||
};
|
||||
using depot_state_singleton = sysio::singleton<"depotstate"_n, depot_global_state>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Known operators — scoped by chain_kind_t (FR-601)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
struct [[sysio::table("knownops"), sysio::contract("sysio.depot")]] known_operator {
|
||||
uint64_t id;
|
||||
operator_type_t op_type;
|
||||
operator_status_t status;
|
||||
name wire_account;
|
||||
std::vector<char> secp256k1_pubkey; // 33 bytes compressed
|
||||
std::vector<char> ed25519_pubkey; // 32 bytes
|
||||
asset collateral;
|
||||
time_point_sec registered_at;
|
||||
time_point_sec status_changed_at;
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
uint128_t by_type_status() const { return (uint128_t(op_type) << 64) | uint64_t(status); }
|
||||
uint64_t by_account() const { return wire_account.value; }
|
||||
checksum256 by_secp_pubkey() const {
|
||||
return sha256(secp256k1_pubkey.data(), secp256k1_pubkey.size());
|
||||
}
|
||||
};
|
||||
|
||||
using known_operators_table = multi_index<"knownops"_n, known_operator,
|
||||
indexed_by<"bytypestatus"_n, const_mem_fun<known_operator, uint128_t, &known_operator::by_type_status>>,
|
||||
indexed_by<"byaccount"_n, const_mem_fun<known_operator, uint64_t, &known_operator::by_account>>,
|
||||
indexed_by<"bysecppub"_n, const_mem_fun<known_operator, checksum256, &known_operator::by_secp_pubkey>>
|
||||
>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Operator election schedule — scoped by chain_kind_t (FR-604)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
struct [[sysio::table("opschedule"), sysio::contract("sysio.depot")]] op_schedule {
|
||||
uint64_t epoch_number;
|
||||
std::vector<uint64_t> elected_operator_ids; // MAX_BATCH_OPERATORS_PER_EPOCH entries
|
||||
time_point_sec created_at;
|
||||
|
||||
uint64_t primary_key() const { return epoch_number; }
|
||||
};
|
||||
|
||||
using op_schedule_table = multi_index<"opschedule"_n, op_schedule>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Message chain — scoped by chain_kind_t (FR-101)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
struct [[sysio::table("msgchains"), sysio::contract("sysio.depot")]] message_chain {
|
||||
uint64_t id;
|
||||
message_direction_t direction;
|
||||
chain_status_t status;
|
||||
uint64_t epoch_number;
|
||||
checksum256 merkle_root;
|
||||
checksum256 epoch_hash;
|
||||
checksum256 prev_epoch_hash;
|
||||
std::vector<char> payload;
|
||||
std::vector<char> operator_signature;
|
||||
uint64_t operator_id; // ref → known_operator
|
||||
time_point_sec created_at;
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
uint128_t by_epoch_dir() const { return (uint128_t(epoch_number) << 8) | uint64_t(direction); }
|
||||
uint64_t by_status() const { return uint64_t(status); }
|
||||
};
|
||||
|
||||
using message_chains_table = multi_index<"msgchains"_n, message_chain,
|
||||
indexed_by<"byepochdir"_n, const_mem_fun<message_chain, uint128_t, &message_chain::by_epoch_dir>>,
|
||||
indexed_by<"bystatus"_n, const_mem_fun<message_chain, uint64_t, &message_chain::by_status>>
|
||||
>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Reserve balance state — scoped by chain_kind_t (FR-701)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
struct [[sysio::table("reserves"), sysio::contract("sysio.depot")]] reserve_balance {
|
||||
asset reserve_total;
|
||||
asset wire_equivalent;
|
||||
|
||||
uint64_t primary_key() const { return reserve_total.symbol.code().raw(); }
|
||||
};
|
||||
|
||||
using reserves_table = multi_index<"reserves"_n, reserve_balance>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Underwriting ledger — scoped by chain_kind_t (FR-401)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
struct [[sysio::table("uwledger"), sysio::contract("sysio.depot")]] underwrite_entry {
|
||||
uint64_t id;
|
||||
uint64_t operator_id;
|
||||
underwrite_status_t status;
|
||||
asset source_amount;
|
||||
asset target_amount;
|
||||
chain_kind_t source_chain;
|
||||
chain_kind_t target_chain;
|
||||
uint64_t exchange_rate_bps;
|
||||
time_point_sec unlock_at;
|
||||
time_point_sec created_at;
|
||||
checksum256 source_tx_hash;
|
||||
checksum256 target_tx_hash;
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
uint64_t by_underwriter() const { return operator_id; }
|
||||
uint64_t by_status() const { return uint64_t(status); }
|
||||
uint64_t by_expiry() const { return unlock_at.sec_since_epoch(); }
|
||||
};
|
||||
|
||||
using underwrite_table = multi_index<"uwledger"_n, underwrite_entry,
|
||||
indexed_by<"byuw"_n, const_mem_fun<underwrite_entry, uint64_t, &underwrite_entry::by_underwriter>>,
|
||||
indexed_by<"bystatus"_n, const_mem_fun<underwrite_entry, uint64_t, &underwrite_entry::by_status>>,
|
||||
indexed_by<"byexpiry"_n, const_mem_fun<underwrite_entry, uint64_t, &underwrite_entry::by_expiry>>
|
||||
>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Inbound epoch tracking — scoped by chain_kind_t
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
struct [[sysio::table("oppepochin"), sysio::contract("sysio.depot")]] opp_epoch_in {
|
||||
uint64_t epoch_number;
|
||||
uint64_t start_message;
|
||||
uint64_t end_message;
|
||||
checksum256 epoch_merkle;
|
||||
bool challenge_flag = false;
|
||||
|
||||
uint64_t primary_key() const { return epoch_number; }
|
||||
};
|
||||
|
||||
using opp_epoch_in_table = multi_index<"oppepochin"_n, opp_epoch_in>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Outbound epoch tracking — scoped by chain_kind_t
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
struct [[sysio::table("oppepochout"), sysio::contract("sysio.depot")]] opp_epoch_out {
|
||||
uint64_t epoch_number;
|
||||
uint64_t start_message;
|
||||
uint64_t end_message;
|
||||
checksum256 merkle_root;
|
||||
|
||||
uint64_t primary_key() const { return epoch_number; }
|
||||
};
|
||||
|
||||
using opp_epoch_out_table = multi_index<"oppepochout"_n, opp_epoch_out>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Inbound message queue — scoped by chain_kind_t
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
struct [[sysio::table("oppin"), sysio::contract("sysio.depot")]] opp_message_in {
|
||||
uint64_t message_number;
|
||||
assertion_type_t assertion_type;
|
||||
message_status_t status;
|
||||
std::vector<char> payload;
|
||||
|
||||
uint64_t primary_key() const { return message_number; }
|
||||
uint64_t by_status() const { return uint64_t(status); }
|
||||
};
|
||||
|
||||
using opp_in_table = multi_index<"oppin"_n, opp_message_in,
|
||||
indexed_by<"bystatus"_n, const_mem_fun<opp_message_in, uint64_t, &opp_message_in::by_status>>
|
||||
>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Outbound message queue — scoped by chain_kind_t
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
struct [[sysio::table("oppout"), sysio::contract("sysio.depot")]] opp_message_out {
|
||||
uint64_t message_number;
|
||||
assertion_type_t assertion_type;
|
||||
std::vector<char> payload;
|
||||
|
||||
uint64_t primary_key() const { return message_number; }
|
||||
};
|
||||
|
||||
using opp_out_table = multi_index<"oppout"_n, opp_message_out>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Challenge tracking — scoped by chain_kind_t (FR-500)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
struct [[sysio::table("challenges"), sysio::contract("sysio.depot")]] challenge_info {
|
||||
uint64_t id;
|
||||
uint64_t epoch_number;
|
||||
challenge_status_t status;
|
||||
uint8_t round;
|
||||
std::vector<char> challenge_data;
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
uint64_t by_epoch() const { return epoch_number; }
|
||||
};
|
||||
|
||||
using challenges_table = multi_index<"challenges"_n, challenge_info,
|
||||
indexed_by<"byepoch"_n, const_mem_fun<challenge_info, uint64_t, &challenge_info::by_epoch>>
|
||||
>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Epoch consensus vote tracking — scoped by chain_kind_t (FR-301)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
struct [[sysio::table("epochvotes"), sysio::contract("sysio.depot")]] epoch_vote {
|
||||
uint64_t id;
|
||||
uint64_t epoch_number;
|
||||
uint64_t operator_id;
|
||||
checksum256 chain_hash;
|
||||
time_point_sec submitted_at;
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
uint128_t by_epoch_op() const { return (uint128_t(epoch_number) << 64) | operator_id; }
|
||||
uint64_t by_epoch() const { return epoch_number; }
|
||||
};
|
||||
|
||||
using epoch_votes_table = multi_index<"epochvotes"_n, epoch_vote,
|
||||
indexed_by<"byepochop"_n, const_mem_fun<epoch_vote, uint128_t, &epoch_vote::by_epoch_op>>,
|
||||
indexed_by<"byepoch"_n, const_mem_fun<epoch_vote, uint64_t, &epoch_vote::by_epoch>>
|
||||
>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// OPP fork tracking — scoped by chain_kind_t
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
struct [[sysio::table("oppforks"), sysio::contract("sysio.depot")]] opp_fork {
|
||||
uint64_t fork_id;
|
||||
uint64_t epoch_number;
|
||||
uint64_t end_message_id;
|
||||
checksum256 merkle_root;
|
||||
|
||||
uint64_t primary_key() const { return fork_id; }
|
||||
uint64_t by_epoch() const { return epoch_number; }
|
||||
};
|
||||
|
||||
using opp_forks_table = multi_index<"oppforks"_n, opp_fork,
|
||||
indexed_by<"byepoch"_n, const_mem_fun<opp_fork, uint64_t, &opp_fork::by_epoch>>
|
||||
>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// OPP fork votes — scoped by chain_kind_t
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
struct [[sysio::table("oppforkvote"), sysio::contract("sysio.depot")]] opp_fork_vote {
|
||||
uint64_t id;
|
||||
uint64_t fork_id;
|
||||
name voter;
|
||||
uint8_t vote_state; // 0=pending, 1=accept, 2=reject
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
uint128_t by_fork_user() const { return (uint128_t(fork_id) << 64) | voter.value; }
|
||||
};
|
||||
|
||||
using opp_fork_votes_table = multi_index<"oppforkvote"_n, opp_fork_vote,
|
||||
indexed_by<"byforkuser"_n, const_mem_fun<opp_fork_vote, uint128_t, &opp_fork_vote::by_fork_user>>
|
||||
>;
|
||||
|
||||
} // namespace sysio
|
||||
106
contracts/sysio.depot/include/sysio.depot/depot.types.hpp
Normal file
106
contracts/sysio.depot/include/sysio.depot/depot.types.hpp
Normal file
@@ -0,0 +1,106 @@
|
||||
#pragma once
|
||||
|
||||
#include <fc-lite/crypto/chain_types.hpp>
|
||||
#include <sysio/sysio.hpp>
|
||||
#include <sysio/asset.hpp>
|
||||
|
||||
namespace sysio {
|
||||
|
||||
using fc::crypto::chain_kind_t;
|
||||
using fc::crypto::chain_key_type_t;
|
||||
|
||||
// ── Message chain direction ──────────────────────────────────────────────
|
||||
enum message_direction_t : uint8_t {
|
||||
message_direction_inbound = 0, // Outpost -> Depot
|
||||
message_direction_outbound = 1, // Depot -> Outpost
|
||||
};
|
||||
|
||||
// ── Message chain lifecycle status ───────────────────────────────────────
|
||||
enum chain_status_t : uint8_t {
|
||||
chain_status_created = 0,
|
||||
chain_status_pending = 1,
|
||||
chain_status_challenged = 2,
|
||||
chain_status_valid = 3,
|
||||
chain_status_slashed = 4, // terminal
|
||||
};
|
||||
|
||||
// ── Individual message processing status ─────────────────────────────────
|
||||
enum message_status_t : uint8_t {
|
||||
message_status_pending = 0, // needs underwriting
|
||||
message_status_ready = 1, // ready for processing
|
||||
};
|
||||
|
||||
// ── Operator type ────────────────────────────────────────────────────────
|
||||
enum operator_type_t : uint8_t {
|
||||
operator_type_node = 0, // node / producer
|
||||
operator_type_batch = 1,
|
||||
operator_type_underwriter = 2,
|
||||
operator_type_challenger = 3,
|
||||
};
|
||||
|
||||
// ── Operator lifecycle status ────────────────────────────────────────────
|
||||
enum operator_status_t : uint8_t {
|
||||
operator_status_warmup = 0,
|
||||
operator_status_active = 1,
|
||||
operator_status_cooldown = 2,
|
||||
operator_status_exited = 3, // graceful exit after cooldown
|
||||
operator_status_slashed = 4, // terminal, collateral collapsed
|
||||
};
|
||||
|
||||
// ── Challenge status ─────────────────────────────────────────────────────
|
||||
enum challenge_status_t : uint8_t {
|
||||
challenge_status_none = 0,
|
||||
challenge_status_round1_pending = 1,
|
||||
challenge_status_round1_complete = 2,
|
||||
challenge_status_round2_pending = 3,
|
||||
challenge_status_round2_complete = 4,
|
||||
challenge_status_paused = 5, // global pause, awaiting manual
|
||||
challenge_status_resolved = 6,
|
||||
};
|
||||
|
||||
// ── Underwriting ledger entry status ─────────────────────────────────────
|
||||
enum underwrite_status_t : uint8_t {
|
||||
underwrite_status_intent_submitted = 0,
|
||||
underwrite_status_intent_confirmed = 1,
|
||||
underwrite_status_expired = 2,
|
||||
underwrite_status_cancelled = 3,
|
||||
};
|
||||
|
||||
// ── Depot global state ───────────────────────────────────────────────────
|
||||
enum depot_state_t : uint8_t {
|
||||
depot_state_active = 0,
|
||||
depot_state_challenge = 1, // normal msg processing suspended
|
||||
depot_state_paused = 2, // global pause, manual intervention required
|
||||
};
|
||||
|
||||
// ── OPP Assertion type IDs (from OPP Assertion Catalog) ──────────────────
|
||||
enum assertion_type_t : uint16_t {
|
||||
assertion_type_balance_sheet = 0xAA00,
|
||||
assertion_type_stake_update = 0xEE00,
|
||||
assertion_type_yield_reward = 0xEE01,
|
||||
assertion_type_wire_purchase = 0xEE02,
|
||||
assertion_type_operator_registration = 0xEE03,
|
||||
assertion_type_challenge_response = 0xEE04,
|
||||
assertion_type_slash_operator = 0xEE05,
|
||||
};
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────
|
||||
static constexpr uint32_t MAX_BATCH_OPERATORS_PER_EPOCH = 7;
|
||||
static constexpr uint32_t TOTAL_BATCH_OPERATORS = 21;
|
||||
static constexpr uint32_t CONSENSUS_MAJORITY = 4; // of 7
|
||||
static constexpr uint32_t MAX_CHALLENGE_ROUNDS = 2;
|
||||
|
||||
static constexpr uint32_t INTENT_LOCK_SECONDS = 6 * 3600; // 6 hours
|
||||
static constexpr uint32_t CONFIRMED_LOCK_SECONDS = 24 * 3600; // 24 hours
|
||||
|
||||
static constexpr uint64_t UNDERWRITE_FEE_BPS = 10; // 0.1% = 10 basis points
|
||||
|
||||
// Slash distribution (basis points out of 10 000)
|
||||
static constexpr uint64_t SLASH_CHALLENGER_BPS = 5000; // 50 %
|
||||
static constexpr uint64_t SLASH_UNDERWRITERS_BPS = 2500; // 25 %
|
||||
static constexpr uint64_t SLASH_BATCH_OPERATORS_BPS = 2500; // 25 %
|
||||
|
||||
// Manual resolution vote threshold
|
||||
static constexpr uint64_t RESOLUTION_VOTE_THRESHOLD_BPS = 6667; // ≈ 2/3
|
||||
|
||||
} // namespace sysio
|
||||
166
contracts/sysio.depot/include/sysio.depot/sysio.depot.hpp
Normal file
166
contracts/sysio.depot/include/sysio.depot/sysio.depot.hpp
Normal file
@@ -0,0 +1,166 @@
|
||||
#pragma once
|
||||
|
||||
#include <sysio.depot/depot.tables.hpp>
|
||||
|
||||
namespace sysio {
|
||||
|
||||
class [[sysio::contract("sysio.depot")]] depot : public contract {
|
||||
public:
|
||||
using contract::contract;
|
||||
|
||||
// ── Initialization ────────────────────────────────────────────────────
|
||||
[[sysio::action]]
|
||||
void init(chain_kind_t chain_id, name token_contract);
|
||||
|
||||
// ── Bootstrap (testnet only): seed initial epoch schedule ─────────────
|
||||
[[sysio::action]]
|
||||
void bootstrap();
|
||||
|
||||
// ── FR-800: Crank Execution ───────────────────────────────────────────
|
||||
[[sysio::action]]
|
||||
void crank(name operator_account);
|
||||
|
||||
// ── FR-100: OPP Inbound ───────────────────────────────────────────────
|
||||
[[sysio::action]]
|
||||
void submitchain(name operator_account,
|
||||
uint64_t epoch_number,
|
||||
checksum256 epoch_hash,
|
||||
checksum256 prev_epoch_hash,
|
||||
checksum256 merkle_root,
|
||||
std::vector<char> signature);
|
||||
|
||||
[[sysio::action]]
|
||||
void uploadmsgs(name operator_account,
|
||||
uint64_t epoch_number,
|
||||
std::vector<char> messages,
|
||||
std::vector<char> merkle_proofs);
|
||||
|
||||
// ── FR-200: OPP Outbound ──────────────────────────────────────────────
|
||||
[[sysio::action]]
|
||||
void emitchain(name operator_account, uint64_t epoch_number);
|
||||
|
||||
// ── FR-400: Underwriting ──────────────────────────────────────────────
|
||||
[[sysio::action]]
|
||||
void uwintent(name underwriter,
|
||||
uint64_t message_id,
|
||||
asset source_amount,
|
||||
asset target_amount,
|
||||
chain_kind_t source_chain,
|
||||
chain_kind_t target_chain,
|
||||
std::vector<char> source_sig,
|
||||
std::vector<char> target_sig);
|
||||
|
||||
[[sysio::action]]
|
||||
void uwconfirm(name operator_account, uint64_t ledger_entry_id);
|
||||
|
||||
[[sysio::action]]
|
||||
void uwcancel(name operator_account, uint64_t ledger_entry_id, std::string reason);
|
||||
|
||||
[[sysio::action]]
|
||||
void uwexpire();
|
||||
|
||||
// ── FR-500: Challenge ─────────────────────────────────────────────────
|
||||
[[sysio::action]]
|
||||
void challenge(name challenger, uint64_t epoch_number, std::vector<char> evidence);
|
||||
|
||||
[[sysio::action]]
|
||||
void chalresp(name operator_account, uint64_t challenge_id, std::vector<char> response_data);
|
||||
|
||||
[[sysio::action]]
|
||||
void chalresolve(name proposer,
|
||||
uint64_t challenge_id,
|
||||
checksum256 original_hash,
|
||||
checksum256 round1_hash,
|
||||
checksum256 round2_hash);
|
||||
|
||||
[[sysio::action]]
|
||||
void chalvote(name voter, uint64_t challenge_id, bool approve);
|
||||
|
||||
// ── FR-600: Operator Lifecycle ────────────────────────────────────────
|
||||
[[sysio::action]]
|
||||
void regoperator(name wire_account,
|
||||
operator_type_t op_type,
|
||||
std::vector<char> secp256k1_pubkey,
|
||||
std::vector<char> ed25519_pubkey,
|
||||
asset collateral);
|
||||
|
||||
[[sysio::action]]
|
||||
void unregop(name wire_account);
|
||||
|
||||
[[sysio::action]]
|
||||
void activateop(name wire_account);
|
||||
|
||||
[[sysio::action]]
|
||||
void exitop(name wire_account);
|
||||
|
||||
[[sysio::action]]
|
||||
void slashop(name wire_account, std::string reason);
|
||||
|
||||
// ── FR-700: Reserve & Swap ────────────────────────────────────────────
|
||||
[[sysio::action]]
|
||||
void setreserve(name authority, asset reserve_total, asset wire_equivalent);
|
||||
|
||||
[[sysio::action]]
|
||||
void updreserve(name operator_account, symbol token_sym, int64_t delta);
|
||||
|
||||
// ── FR-900: Swap Quote (read-only) ────────────────────────────────────
|
||||
[[sysio::action]]
|
||||
void getquote(symbol source_sym, symbol target_sym, asset amount);
|
||||
|
||||
// ── FR-1000: Oneshot Warrant Conversion ───────────────────────────────
|
||||
[[sysio::action]]
|
||||
void oneshot(name beneficiary, asset amount);
|
||||
|
||||
// ── Action wrappers ───────────────────────────────────────────────────
|
||||
using init_action = action_wrapper<"init"_n, &depot::init>;
|
||||
using bootstrap_action = action_wrapper<"bootstrap"_n, &depot::bootstrap>;
|
||||
using crank_action = action_wrapper<"crank"_n, &depot::crank>;
|
||||
using submitchain_action = action_wrapper<"submitchain"_n, &depot::submitchain>;
|
||||
using uploadmsgs_action = action_wrapper<"uploadmsgs"_n, &depot::uploadmsgs>;
|
||||
using emitchain_action = action_wrapper<"emitchain"_n, &depot::emitchain>;
|
||||
using uwintent_action = action_wrapper<"uwintent"_n, &depot::uwintent>;
|
||||
using uwconfirm_action = action_wrapper<"uwconfirm"_n, &depot::uwconfirm>;
|
||||
using uwcancel_action = action_wrapper<"uwcancel"_n, &depot::uwcancel>;
|
||||
using uwexpire_action = action_wrapper<"uwexpire"_n, &depot::uwexpire>;
|
||||
using challenge_action = action_wrapper<"challenge"_n, &depot::challenge>;
|
||||
using chalresp_action = action_wrapper<"chalresp"_n, &depot::chalresp>;
|
||||
using chalresolve_action = action_wrapper<"chalresolve"_n, &depot::chalresolve>;
|
||||
using chalvote_action = action_wrapper<"chalvote"_n, &depot::chalvote>;
|
||||
using regoperator_action = action_wrapper<"regoperator"_n, &depot::regoperator>;
|
||||
using unregop_action = action_wrapper<"unregop"_n, &depot::unregop>;
|
||||
using activateop_action = action_wrapper<"activateop"_n, &depot::activateop>;
|
||||
using exitop_action = action_wrapper<"exitop"_n, &depot::exitop>;
|
||||
using slashop_action = action_wrapper<"slashop"_n, &depot::slashop>;
|
||||
using setreserve_action = action_wrapper<"setreserve"_n, &depot::setreserve>;
|
||||
using updreserve_action = action_wrapper<"updreserve"_n, &depot::updreserve>;
|
||||
using getquote_action = action_wrapper<"getquote"_n, &depot::getquote>;
|
||||
using oneshot_action = action_wrapper<"oneshot"_n, &depot::oneshot>;
|
||||
|
||||
private:
|
||||
// ── Internal helpers ──────────────────────────────────────────────────
|
||||
depot_global_state get_state();
|
||||
void set_state(const depot_global_state& s);
|
||||
|
||||
void verify_elected(name operator_account, uint64_t epoch_number);
|
||||
void evaluate_consensus(uint64_t epoch_number);
|
||||
void process_ready_messages();
|
||||
void expire_underwriting_locks();
|
||||
void elect_operators_for_epoch(uint64_t next_epoch);
|
||||
void process_assertion(uint64_t message_number, assertion_type_t type, const std::vector<char>& payload);
|
||||
void queue_outbound_message(assertion_type_t type, const std::vector<char>& payload);
|
||||
void mark_epoch_valid(uint64_t chain_scope, uint64_t epoch_number);
|
||||
void slash_minority_operators(uint64_t chain_scope, uint64_t epoch_number,
|
||||
const checksum256& consensus_hash);
|
||||
void enter_challenge_state(uint64_t epoch_number);
|
||||
void resume_from_challenge();
|
||||
void distribute_slash(uint64_t operator_id, name challenger);
|
||||
uint64_t calculate_swap_rate(symbol source, symbol target, asset amount);
|
||||
bool check_rate_threshold(uint64_t rate_bps, uint64_t threshold_bps);
|
||||
|
||||
uint64_t scope() {
|
||||
auto s = get_state();
|
||||
return uint64_t(s.chain_id);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace sysio
|
||||
352
contracts/sysio.depot/src/challenge.cpp
Normal file
352
contracts/sysio.depot/src/challenge.cpp
Normal file
@@ -0,0 +1,352 @@
|
||||
#include <sysio.depot/sysio.depot.hpp>
|
||||
|
||||
namespace sysio {
|
||||
|
||||
// ── challenge (FR-501/502) ──────────────────────────────────────────────────
|
||||
//
|
||||
// A challenger submits a challenge against an epoch.
|
||||
// Suspends normal message processing, enters CHALLENGE state,
|
||||
// queues a CHALLENGE_REQUEST outbound message for the Outpost.
|
||||
|
||||
void depot::challenge(name challenger, uint64_t epoch_number, std::vector<char> evidence) {
|
||||
require_auth(challenger);
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
check(s.state == depot_state_active || s.state == depot_state_challenge,
|
||||
"depot: system is paused, cannot accept challenges");
|
||||
|
||||
// Verify the challenger is a registered challenger operator
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
auto by_account = ops.get_index<"byaccount"_n>();
|
||||
auto op_it = by_account.find(challenger.value);
|
||||
check(op_it != by_account.end(), "depot: challenger not registered");
|
||||
check(op_it->op_type == operator_type_challenger, "depot: account is not a challenger");
|
||||
check(op_it->status == operator_status_active, "depot: challenger is not active");
|
||||
|
||||
// Verify epoch exists and is in a challengeable state
|
||||
opp_epoch_in_table epochs(get_self(), chain_scope);
|
||||
auto ep_it = epochs.find(epoch_number);
|
||||
check(ep_it != epochs.end(), "depot: epoch not found");
|
||||
check(!ep_it->challenge_flag, "depot: epoch already under challenge");
|
||||
|
||||
// Check no active challenge exists for this epoch
|
||||
challenges_table chals(get_self(), chain_scope);
|
||||
auto by_epoch = chals.get_index<"byepoch"_n>();
|
||||
auto chal_it = by_epoch.find(epoch_number);
|
||||
if (chal_it != by_epoch.end()) {
|
||||
check(chal_it->status == challenge_status_resolved,
|
||||
"depot: epoch already has an active challenge");
|
||||
}
|
||||
|
||||
// Create challenge record
|
||||
chals.emplace(get_self(), [&](auto& c) {
|
||||
c.id = chals.available_primary_key();
|
||||
c.epoch_number = epoch_number;
|
||||
c.status = challenge_status_round1_pending;
|
||||
c.round = 1;
|
||||
c.challenge_data = evidence;
|
||||
});
|
||||
|
||||
// Mark epoch as challenged
|
||||
epochs.modify(ep_it, same_payer, [&](auto& e) {
|
||||
e.challenge_flag = true;
|
||||
});
|
||||
|
||||
// Enter challenge state
|
||||
enter_challenge_state(epoch_number);
|
||||
|
||||
// FR-502: Queue outbound CHALLENGE_REQUEST to Outpost
|
||||
queue_outbound_message(assertion_type_challenge_response, evidence);
|
||||
}
|
||||
|
||||
// ── chalresp (FR-503/504) ───────────────────────────────────────────────────
|
||||
//
|
||||
// An elected batch operator responds to a challenge.
|
||||
// If this is round 1, compare with original. If mismatch on round 2, PAUSE.
|
||||
|
||||
void depot::chalresp(name operator_account, uint64_t challenge_id,
|
||||
std::vector<char> response_data) {
|
||||
require_auth(operator_account);
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
check(s.state == depot_state_challenge, "depot: system is not in challenge state");
|
||||
|
||||
// Verify operator is elected
|
||||
verify_elected(operator_account, s.current_epoch);
|
||||
|
||||
// Find the challenge
|
||||
challenges_table chals(get_self(), chain_scope);
|
||||
auto chal_it = chals.find(challenge_id);
|
||||
check(chal_it != chals.end(), "depot: challenge not found");
|
||||
check(chal_it->status == challenge_status_round1_pending ||
|
||||
chal_it->status == challenge_status_round2_pending,
|
||||
"depot: challenge is not awaiting a response");
|
||||
|
||||
uint8_t current_round = chal_it->round;
|
||||
|
||||
// Compute hash of the response data
|
||||
checksum256 response_hash = sha256(response_data.data(), response_data.size());
|
||||
|
||||
// Compare with the original epoch data
|
||||
opp_epoch_in_table epochs(get_self(), chain_scope);
|
||||
auto ep_it = epochs.find(chal_it->epoch_number);
|
||||
check(ep_it != epochs.end(), "depot: challenged epoch not found");
|
||||
|
||||
bool hashes_match = (response_hash == ep_it->epoch_merkle);
|
||||
|
||||
if (current_round == 1) {
|
||||
if (hashes_match) {
|
||||
// FR-503: Round 1 — response matches original, challenge fails
|
||||
chals.modify(chal_it, same_payer, [&](auto& c) {
|
||||
c.status = challenge_status_round1_complete;
|
||||
});
|
||||
|
||||
// Resume normal operations
|
||||
resume_from_challenge();
|
||||
} else {
|
||||
// FR-504: Round 1 — mismatch, escalate to round 2
|
||||
chals.modify(chal_it, same_payer, [&](auto& c) {
|
||||
c.status = challenge_status_round2_pending;
|
||||
c.round = 2;
|
||||
});
|
||||
|
||||
// Queue second challenge request
|
||||
queue_outbound_message(assertion_type_challenge_response, response_data);
|
||||
}
|
||||
} else if (current_round == 2) {
|
||||
if (hashes_match) {
|
||||
// FR-505: Round 2 — response matches, original was wrong
|
||||
chals.modify(chal_it, same_payer, [&](auto& c) {
|
||||
c.status = challenge_status_round2_complete;
|
||||
});
|
||||
|
||||
// Slash original operators who delivered wrong data
|
||||
epoch_votes_table votes(get_self(), chain_scope);
|
||||
auto by_epoch = votes.get_index<"byepoch"_n>();
|
||||
auto vit = by_epoch.lower_bound(chal_it->epoch_number);
|
||||
while (vit != by_epoch.end() && vit->epoch_number == chal_it->epoch_number) {
|
||||
if (vit->chain_hash != response_hash) {
|
||||
// Slash this operator
|
||||
known_operators_table ops2(get_self(), chain_scope);
|
||||
auto op2 = ops2.find(vit->operator_id);
|
||||
if (op2 != ops2.end() && op2->status != operator_status_slashed) {
|
||||
ops2.modify(op2, same_payer, [&](auto& o) {
|
||||
o.status = operator_status_slashed;
|
||||
o.collateral.amount = 0;
|
||||
o.status_changed_at = current_time_point();
|
||||
});
|
||||
}
|
||||
}
|
||||
++vit;
|
||||
}
|
||||
|
||||
// Resume with corrected data
|
||||
resume_from_challenge();
|
||||
} else {
|
||||
// FR-506: Round 2 — second mismatch, enter PAUSE state
|
||||
chals.modify(chal_it, same_payer, [&](auto& c) {
|
||||
c.status = challenge_status_paused;
|
||||
});
|
||||
|
||||
// Enter global pause — manual resolution required
|
||||
auto state = get_state();
|
||||
state.state = depot_state_paused;
|
||||
set_state(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── chalresolve (FR-507) ────────────────────────────────────────────────────
|
||||
//
|
||||
// Manual resolution: A T1/T2 assembler proposes a resolution with
|
||||
// 3 hashes (original, round1, round2). Requires 2/3 supermajority vote
|
||||
// from active node operators to accept.
|
||||
|
||||
void depot::chalresolve(name proposer,
|
||||
uint64_t challenge_id,
|
||||
checksum256 original_hash,
|
||||
checksum256 round1_hash,
|
||||
checksum256 round2_hash) {
|
||||
require_auth(proposer);
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
check(s.state == depot_state_paused, "depot: system must be paused for manual resolution");
|
||||
|
||||
challenges_table chals(get_self(), chain_scope);
|
||||
auto chal_it = chals.find(challenge_id);
|
||||
check(chal_it != chals.end(), "depot: challenge not found");
|
||||
check(chal_it->status == challenge_status_paused,
|
||||
"depot: challenge must be in paused state for resolution");
|
||||
|
||||
// Create a fork proposal for voting
|
||||
opp_forks_table forks(get_self(), chain_scope);
|
||||
uint64_t fork_id = forks.available_primary_key();
|
||||
|
||||
// The merkle root here represents the "correct" chain hash as determined
|
||||
// by the resolver's analysis of original, round1, and round2 hashes
|
||||
forks.emplace(get_self(), [&](auto& f) {
|
||||
f.fork_id = fork_id;
|
||||
f.epoch_number = chal_it->epoch_number;
|
||||
f.end_message_id = 0; // will be determined after vote passes
|
||||
f.merkle_root = round2_hash; // the latest re-derivation is proposed as correct
|
||||
});
|
||||
}
|
||||
|
||||
// ── chalvote (FR-507) ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Active node operators vote on a manual resolution proposal.
|
||||
// If 2/3 supermajority approves, the resolution is applied:
|
||||
// slash losers, resume operations.
|
||||
|
||||
void depot::chalvote(name voter, uint64_t challenge_id, bool approve) {
|
||||
require_auth(voter);
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
check(s.state == depot_state_paused, "depot: system must be paused for voting");
|
||||
|
||||
// Verify voter is an active node operator
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
auto by_account = ops.get_index<"byaccount"_n>();
|
||||
auto op_it = by_account.find(voter.value);
|
||||
check(op_it != by_account.end(), "depot: voter not registered as operator");
|
||||
check(op_it->op_type == operator_type_node, "depot: only node operators can vote on resolutions");
|
||||
check(op_it->status == operator_status_active, "depot: voter must be active");
|
||||
|
||||
// Find the challenge and associated fork
|
||||
challenges_table chals(get_self(), chain_scope);
|
||||
auto chal_it = chals.find(challenge_id);
|
||||
check(chal_it != chals.end(), "depot: challenge not found");
|
||||
check(chal_it->status == challenge_status_paused, "depot: challenge not in voting state");
|
||||
|
||||
// Find the fork proposal for this epoch
|
||||
opp_forks_table forks(get_self(), chain_scope);
|
||||
auto by_epoch = forks.get_index<"byepoch"_n>();
|
||||
auto fork_it = by_epoch.find(chal_it->epoch_number);
|
||||
check(fork_it != by_epoch.end(), "depot: no resolution proposal found for this epoch");
|
||||
|
||||
// Check voter hasn't already voted on this fork
|
||||
opp_fork_votes_table votes(get_self(), chain_scope);
|
||||
auto by_fu = votes.get_index<"byforkuser"_n>();
|
||||
uint128_t fu_key = (uint128_t(fork_it->fork_id) << 64) | voter.value;
|
||||
check(by_fu.find(fu_key) == by_fu.end(), "depot: already voted on this resolution");
|
||||
|
||||
// Record vote
|
||||
votes.emplace(get_self(), [&](auto& v) {
|
||||
v.id = votes.available_primary_key();
|
||||
v.fork_id = fork_it->fork_id;
|
||||
v.voter = voter;
|
||||
v.vote_state = approve ? 1 : 2;
|
||||
});
|
||||
|
||||
// Count votes for this fork
|
||||
auto vit = by_fu.lower_bound(uint128_t(fork_it->fork_id) << 64);
|
||||
uint32_t accept_count = 0;
|
||||
uint32_t total_count = 0;
|
||||
while (vit != by_fu.end()) {
|
||||
// Extract fork_id from composite key
|
||||
uint64_t vid_fork = uint64_t(vit->by_fork_user() >> 64);
|
||||
if (vid_fork != fork_it->fork_id) break;
|
||||
|
||||
++total_count;
|
||||
if (vit->vote_state == 1) ++accept_count;
|
||||
++vit;
|
||||
}
|
||||
|
||||
// Count total active node operators for threshold calculation
|
||||
auto by_ts = ops.get_index<"bytypestatus"_n>();
|
||||
uint128_t node_key = (uint128_t(operator_type_node) << 64) | uint64_t(operator_status_active);
|
||||
auto node_it = by_ts.lower_bound(node_key);
|
||||
uint32_t total_nodes = 0;
|
||||
while (node_it != by_ts.end() &&
|
||||
node_it->op_type == operator_type_node &&
|
||||
node_it->status == operator_status_active) {
|
||||
++total_nodes;
|
||||
++node_it;
|
||||
}
|
||||
|
||||
// FR-507: Check if 2/3 supermajority is reached
|
||||
if (total_nodes > 0 && accept_count * 10000 >= total_nodes * RESOLUTION_VOTE_THRESHOLD_BPS) {
|
||||
// Resolution accepted — apply it
|
||||
chals.modify(chal_it, same_payer, [&](auto& c) {
|
||||
c.status = challenge_status_resolved;
|
||||
});
|
||||
|
||||
// Slash operators who delivered non-consensus data
|
||||
slash_minority_operators(chain_scope, chal_it->epoch_number, fork_it->merkle_root);
|
||||
|
||||
// Resume operations
|
||||
resume_from_challenge();
|
||||
}
|
||||
}
|
||||
|
||||
// ── enter_challenge_state (internal) ────────────────────────────────────────
|
||||
|
||||
void depot::enter_challenge_state(uint64_t epoch_number) {
|
||||
auto s = get_state();
|
||||
if (s.state != depot_state_challenge) {
|
||||
s.state = depot_state_challenge;
|
||||
set_state(s);
|
||||
}
|
||||
}
|
||||
|
||||
// ── resume_from_challenge (internal) ────────────────────────────────────────
|
||||
|
||||
void depot::resume_from_challenge() {
|
||||
auto s = get_state();
|
||||
s.state = depot_state_active;
|
||||
set_state(s);
|
||||
}
|
||||
|
||||
// ── distribute_slash (internal, FR-504/DEC-005) ─────────────────────────────
|
||||
//
|
||||
// Distributes slashed collateral:
|
||||
// 50% to the challenger
|
||||
// 25% to underwriters
|
||||
// 25% to batch operators
|
||||
|
||||
void depot::distribute_slash(uint64_t operator_id, name challenger) {
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
auto op_it = ops.find(operator_id);
|
||||
if (op_it == ops.end()) return;
|
||||
|
||||
int64_t total_collateral = op_it->collateral.amount;
|
||||
if (total_collateral <= 0) return;
|
||||
|
||||
symbol collateral_sym = op_it->collateral.symbol;
|
||||
|
||||
// Calculate shares
|
||||
int64_t challenger_share = (total_collateral * SLASH_CHALLENGER_BPS) / 10000;
|
||||
int64_t underwriter_share = (total_collateral * SLASH_UNDERWRITERS_BPS) / 10000;
|
||||
int64_t batch_share = total_collateral - challenger_share - underwriter_share;
|
||||
|
||||
// Zero the operator's collateral
|
||||
ops.modify(op_it, same_payer, [&](auto& o) {
|
||||
o.collateral.amount = 0;
|
||||
o.status = operator_status_slashed;
|
||||
o.status_changed_at = current_time_point();
|
||||
});
|
||||
|
||||
// TODO: Transfer shares via inline actions to sysio.token
|
||||
// action(permission_level{get_self(), "active"_n},
|
||||
// s.token_contract, "transfer"_n,
|
||||
// std::make_tuple(get_self(), challenger,
|
||||
// asset(challenger_share, collateral_sym),
|
||||
// std::string("slash distribution - challenger")))
|
||||
// .send();
|
||||
//
|
||||
// Underwriter and batch operator shares would need to be distributed
|
||||
// proportionally among all active operators of those types.
|
||||
}
|
||||
|
||||
} // namespace sysio
|
||||
146
contracts/sysio.depot/src/consensus.cpp
Normal file
146
contracts/sysio.depot/src/consensus.cpp
Normal file
@@ -0,0 +1,146 @@
|
||||
#include <sysio.depot/sysio.depot.hpp>
|
||||
|
||||
namespace sysio {
|
||||
|
||||
// ── evaluate_consensus (FR-301/302/303/304) ──────────────────────────────────
|
||||
//
|
||||
// Collects epoch votes from all elected batch operators and determines
|
||||
// consensus via Option A (all 7 identical) or Option B (4+ majority at
|
||||
// epoch boundary with all delivered chains matching).
|
||||
|
||||
void depot::evaluate_consensus(uint64_t epoch_number) {
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
// Skip if already in challenge or paused state for this epoch
|
||||
if (s.state == depot_state_paused) return;
|
||||
|
||||
// Get the elected schedule for this epoch
|
||||
op_schedule_table sched(get_self(), chain_scope);
|
||||
auto sch_it = sched.find(epoch_number);
|
||||
if (sch_it == sched.end()) return; // no schedule yet
|
||||
|
||||
uint32_t total_elected = sch_it->elected_operator_ids.size();
|
||||
|
||||
// Collect all votes for this epoch
|
||||
epoch_votes_table votes(get_self(), chain_scope);
|
||||
auto by_epoch = votes.get_index<"byepoch"_n>();
|
||||
auto vit = by_epoch.lower_bound(epoch_number);
|
||||
|
||||
// Count votes per distinct hash
|
||||
struct hash_count {
|
||||
checksum256 hash;
|
||||
uint32_t count = 0;
|
||||
};
|
||||
std::vector<hash_count> hash_counts;
|
||||
uint32_t total_votes = 0;
|
||||
|
||||
while (vit != by_epoch.end() && vit->epoch_number == epoch_number) {
|
||||
++total_votes;
|
||||
bool found = false;
|
||||
for (auto& hc : hash_counts) {
|
||||
if (hc.hash == vit->chain_hash) {
|
||||
++hc.count;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
hash_counts.push_back({vit->chain_hash, 1});
|
||||
}
|
||||
++vit;
|
||||
}
|
||||
|
||||
if (total_votes == 0) return; // no votes yet
|
||||
|
||||
// ── Option A: All elected operators delivered identical hashes ─────────
|
||||
if (total_votes == total_elected && hash_counts.size() == 1) {
|
||||
// Unanimous consensus achieved
|
||||
mark_epoch_valid(chain_scope, epoch_number);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Option B: Majority (4+ of 7) with identical data ─────────────────
|
||||
// Only viable when epoch boundary is reached without all operators delivering
|
||||
if (total_votes >= CONSENSUS_MAJORITY) {
|
||||
for (auto& hc : hash_counts) {
|
||||
if (hc.count >= CONSENSUS_MAJORITY) {
|
||||
// Majority consensus — check that this is the only hash that
|
||||
// was delivered (all delivered chains match)
|
||||
// Per FR-302 Option B: majority deliver identical AND
|
||||
// all delivered chains match
|
||||
if (hash_counts.size() == 1) {
|
||||
mark_epoch_valid(chain_scope, epoch_number);
|
||||
return;
|
||||
}
|
||||
// If there are multiple distinct hashes but majority agrees,
|
||||
// we still have consensus but with dissenters to slash
|
||||
mark_epoch_valid(chain_scope, epoch_number);
|
||||
// Identify and slash minority operators
|
||||
slash_minority_operators(chain_scope, epoch_number, hc.hash);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Not enough votes yet — wait for more submissions ──────────────────
|
||||
// If all elected have voted and no consensus, trigger challenge
|
||||
if (total_votes == total_elected) {
|
||||
enter_challenge_state(epoch_number);
|
||||
}
|
||||
}
|
||||
|
||||
// ── mark_epoch_valid (internal) ──────────────────────────────────────────────
|
||||
|
||||
void depot::mark_epoch_valid(uint64_t chain_scope, uint64_t epoch_number) {
|
||||
message_chains_table chains(get_self(), chain_scope);
|
||||
auto by_epoch = chains.get_index<"byepochdir"_n>();
|
||||
uint128_t ek = (uint128_t(epoch_number) << 8) | uint64_t(message_direction_inbound);
|
||||
auto it = by_epoch.lower_bound(ek);
|
||||
|
||||
while (it != by_epoch.end() &&
|
||||
it->epoch_number == epoch_number &&
|
||||
it->direction == message_direction_inbound) {
|
||||
if (it->status == chain_status_pending) {
|
||||
by_epoch.modify(it, same_payer, [&](auto& c) {
|
||||
c.status = chain_status_valid;
|
||||
});
|
||||
}
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
// ── slash_minority_operators (internal) ──────────────────────────────────────
|
||||
//
|
||||
// After consensus, identify operators who delivered non-consensus hashes
|
||||
// and slash them (FR-504).
|
||||
|
||||
void depot::slash_minority_operators(uint64_t chain_scope, uint64_t epoch_number,
|
||||
const checksum256& consensus_hash) {
|
||||
epoch_votes_table votes(get_self(), chain_scope);
|
||||
auto by_epoch = votes.get_index<"byepoch"_n>();
|
||||
auto vit = by_epoch.lower_bound(epoch_number);
|
||||
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
|
||||
while (vit != by_epoch.end() && vit->epoch_number == epoch_number) {
|
||||
if (vit->chain_hash != consensus_hash) {
|
||||
// This operator delivered a non-consensus hash — slash them
|
||||
auto op_it = ops.find(vit->operator_id);
|
||||
if (op_it != ops.end() && op_it->status != operator_status_slashed) {
|
||||
ops.modify(op_it, same_payer, [&](auto& o) {
|
||||
o.status = operator_status_slashed;
|
||||
o.collateral.amount = 0; // DEC-006
|
||||
o.status_changed_at = current_time_point();
|
||||
});
|
||||
|
||||
// Queue outbound slash to Outpost
|
||||
std::vector<char> slash_payload;
|
||||
queue_outbound_message(assertion_type_slash_operator, slash_payload);
|
||||
}
|
||||
}
|
||||
++vit;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace sysio
|
||||
248
contracts/sysio.depot/src/operators.cpp
Normal file
248
contracts/sysio.depot/src/operators.cpp
Normal file
@@ -0,0 +1,248 @@
|
||||
#include <sysio.depot/sysio.depot.hpp>
|
||||
#include <sysio/transaction.hpp>
|
||||
|
||||
namespace sysio {
|
||||
|
||||
// ── regoperator (FR-602) ─────────────────────────────────────────────────────
|
||||
|
||||
void depot::regoperator(name wire_account,
|
||||
operator_type_t op_type,
|
||||
std::vector<char> secp256k1_pubkey,
|
||||
std::vector<char> ed25519_pubkey,
|
||||
asset collateral) {
|
||||
require_auth(wire_account);
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
// Validate key sizes (FR-606)
|
||||
check(secp256k1_pubkey.size() == 33, "depot: secp256k1 pubkey must be 33 bytes compressed");
|
||||
check(ed25519_pubkey.size() == 32, "depot: ed25519 pubkey must be 32 bytes");
|
||||
check(collateral.amount > 0, "depot: collateral must be positive");
|
||||
check(op_type == operator_type_node || op_type == operator_type_batch ||
|
||||
op_type == operator_type_underwriter || op_type == operator_type_challenger,
|
||||
"depot: invalid operator type");
|
||||
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
|
||||
// Check no duplicate registration for this account
|
||||
auto by_account = ops.get_index<"byaccount"_n>();
|
||||
auto acct_it = by_account.find(wire_account.value);
|
||||
check(acct_it == by_account.end(), "depot: operator already registered for this chain");
|
||||
|
||||
// Check no duplicate secp256k1 pubkey
|
||||
auto by_secp = ops.get_index<"bysecppub"_n>();
|
||||
auto secp_hash = sha256(secp256k1_pubkey.data(), secp256k1_pubkey.size());
|
||||
check(by_secp.find(secp_hash) == by_secp.end(),
|
||||
"depot: secp256k1 pubkey already registered");
|
||||
|
||||
ops.emplace(get_self(), [&](auto& o) {
|
||||
o.id = ops.available_primary_key();
|
||||
o.op_type = op_type;
|
||||
o.status = operator_status_warmup;
|
||||
o.wire_account = wire_account;
|
||||
o.secp256k1_pubkey = secp256k1_pubkey;
|
||||
o.ed25519_pubkey = ed25519_pubkey;
|
||||
o.collateral = collateral;
|
||||
o.registered_at = current_time_point();
|
||||
o.status_changed_at = current_time_point();
|
||||
});
|
||||
}
|
||||
|
||||
// ── unregop ──────────────────────────────────────────────────────────────────
|
||||
|
||||
void depot::unregop(name wire_account) {
|
||||
require_auth(wire_account);
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
auto by_account = ops.get_index<"byaccount"_n>();
|
||||
auto it = by_account.find(wire_account.value);
|
||||
check(it != by_account.end(), "depot: operator not found");
|
||||
check(it->status != operator_status_slashed, "depot: operator already slashed");
|
||||
check(it->status != operator_status_exited, "depot: operator already exited");
|
||||
check(it->status != operator_status_cooldown, "depot: operator already in cooldown");
|
||||
check(it->status == operator_status_active || it->status == operator_status_warmup,
|
||||
"depot: operator must be active or in warmup to unregister");
|
||||
|
||||
by_account.modify(it, same_payer, [&](auto& o) {
|
||||
o.status = operator_status_cooldown;
|
||||
o.status_changed_at = current_time_point();
|
||||
});
|
||||
}
|
||||
|
||||
// ── exitop (cooldown -> exited, graceful exit) ───────────────────────────────
|
||||
|
||||
void depot::exitop(name wire_account) {
|
||||
require_auth(wire_account);
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
auto by_account = ops.get_index<"byaccount"_n>();
|
||||
auto it = by_account.find(wire_account.value);
|
||||
check(it != by_account.end(), "depot: operator not found");
|
||||
check(it->status == operator_status_cooldown, "depot: operator must be in cooldown to exit");
|
||||
|
||||
// TODO: Verify cooldown duration has elapsed (OQ-010: durations not yet specified)
|
||||
|
||||
by_account.modify(it, same_payer, [&](auto& o) {
|
||||
o.status = operator_status_exited;
|
||||
o.status_changed_at = current_time_point();
|
||||
});
|
||||
|
||||
// TODO: Return collateral to operator's wire_account
|
||||
}
|
||||
|
||||
// ── activateop ───────────────────────────────────────────────────────────────
|
||||
|
||||
void depot::activateop(name wire_account) {
|
||||
require_auth(get_self()); // governance action
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
auto by_account = ops.get_index<"byaccount"_n>();
|
||||
auto it = by_account.find(wire_account.value);
|
||||
check(it != by_account.end(), "depot: operator not found");
|
||||
check(it->status == operator_status_warmup, "depot: operator must be in warmup state");
|
||||
|
||||
by_account.modify(it, same_payer, [&](auto& o) {
|
||||
o.status = operator_status_active;
|
||||
o.status_changed_at = current_time_point();
|
||||
});
|
||||
}
|
||||
|
||||
// ── slashop (FR-605 / FR-504) ────────────────────────────────────────────────
|
||||
|
||||
void depot::slashop(name wire_account, std::string reason) {
|
||||
require_auth(get_self()); // only callable internally or by governance
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
auto by_account = ops.get_index<"byaccount"_n>();
|
||||
auto it = by_account.find(wire_account.value);
|
||||
check(it != by_account.end(), "depot: operator not found");
|
||||
check(it->status != operator_status_slashed, "depot: operator already slashed");
|
||||
|
||||
by_account.modify(it, same_payer, [&](auto& o) {
|
||||
o.status = operator_status_slashed;
|
||||
o.collateral.amount = 0; // DEC-006: collapse ALL collateral
|
||||
o.status_changed_at = current_time_point();
|
||||
});
|
||||
|
||||
// Queue outbound slash notification to Outpost (FR-504 step 6)
|
||||
// Roster update with reason BAD_BEHAVIOUR
|
||||
std::vector<char> slash_payload;
|
||||
// Serialize: operator_id + reason
|
||||
// The exact serialization format will be defined by OPP spec
|
||||
queue_outbound_message(assertion_type_slash_operator, slash_payload);
|
||||
}
|
||||
|
||||
// ── elect_operators_for_epoch (FR-604) ───────────────────────────────────────
|
||||
|
||||
void depot::elect_operators_for_epoch(uint64_t next_epoch) {
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
// Check if schedule already exists for this epoch
|
||||
op_schedule_table sched(get_self(), chain_scope);
|
||||
auto existing = sched.find(next_epoch);
|
||||
if (existing != sched.end()) return; // already elected
|
||||
|
||||
// Gather all active batch operators
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
auto by_ts = ops.get_index<"bytypestatus"_n>();
|
||||
uint128_t key = (uint128_t(operator_type_batch) << 64) | uint64_t(operator_status_active);
|
||||
auto it = by_ts.lower_bound(key);
|
||||
|
||||
std::vector<uint64_t> active_ids;
|
||||
while (it != by_ts.end() && it->op_type == operator_type_batch && it->status == operator_status_active) {
|
||||
active_ids.push_back(it->id);
|
||||
++it;
|
||||
}
|
||||
|
||||
check(active_ids.size() >= MAX_BATCH_OPERATORS_PER_EPOCH,
|
||||
"depot: insufficient active batch operators for election");
|
||||
|
||||
// Get previous epoch schedule to enforce no-consecutive rule
|
||||
std::vector<uint64_t> prev_elected;
|
||||
if (next_epoch > 1) {
|
||||
auto prev = sched.find(next_epoch - 1);
|
||||
if (prev != sched.end()) {
|
||||
prev_elected = prev->elected_operator_ids;
|
||||
}
|
||||
}
|
||||
|
||||
// Random round-robin selection (FR-604)
|
||||
// Use block hash as randomness source for deterministic on-chain election
|
||||
// tapos_block_prefix gives us block-specific entropy
|
||||
uint32_t seed = sysio::tapos_block_prefix();
|
||||
std::vector<uint64_t> eligible;
|
||||
for (auto id : active_ids) {
|
||||
// No consecutive participation rule
|
||||
bool was_previous = false;
|
||||
for (auto prev_id : prev_elected) {
|
||||
if (prev_id == id) { was_previous = true; break; }
|
||||
}
|
||||
if (!was_previous) {
|
||||
eligible.push_back(id);
|
||||
}
|
||||
}
|
||||
|
||||
// If not enough eligible due to no-consecutive rule, relax it
|
||||
if (eligible.size() < MAX_BATCH_OPERATORS_PER_EPOCH) {
|
||||
eligible = active_ids;
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle using deterministic seed
|
||||
for (uint32_t i = eligible.size() - 1; i > 0; --i) {
|
||||
seed = seed * 1103515245 + 12345; // LCG
|
||||
uint32_t j = seed % (i + 1);
|
||||
auto tmp = eligible[i];
|
||||
eligible[i] = eligible[j];
|
||||
eligible[j] = tmp;
|
||||
}
|
||||
|
||||
// Take first MAX_BATCH_OPERATORS_PER_EPOCH
|
||||
std::vector<uint64_t> elected(eligible.begin(),
|
||||
eligible.begin() + std::min((uint32_t)eligible.size(), MAX_BATCH_OPERATORS_PER_EPOCH));
|
||||
|
||||
sched.emplace(get_self(), [&](auto& r) {
|
||||
r.epoch_number = next_epoch;
|
||||
r.elected_operator_ids = elected;
|
||||
r.created_at = current_time_point();
|
||||
});
|
||||
}
|
||||
|
||||
// ── verify_elected (internal) ────────────────────────────────────────────────
|
||||
|
||||
void depot::verify_elected(name operator_account, uint64_t epoch_number) {
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
// Find operator id
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
auto by_account = ops.get_index<"byaccount"_n>();
|
||||
auto op_it = by_account.find(operator_account.value);
|
||||
check(op_it != by_account.end(), "depot: operator not registered");
|
||||
|
||||
// Find schedule for this epoch
|
||||
op_schedule_table sched(get_self(), chain_scope);
|
||||
auto sch_it = sched.find(epoch_number);
|
||||
check(sch_it != sched.end(), "depot: no schedule for epoch");
|
||||
|
||||
bool found = false;
|
||||
for (auto id : sch_it->elected_operator_ids) {
|
||||
if (id == op_it->id) { found = true; break; }
|
||||
}
|
||||
check(found, "depot: operator not elected for this epoch (FR-605: non-elected delivery => slash)");
|
||||
}
|
||||
|
||||
} // namespace sysio
|
||||
219
contracts/sysio.depot/src/opp_inbound.cpp
Normal file
219
contracts/sysio.depot/src/opp_inbound.cpp
Normal file
@@ -0,0 +1,219 @@
|
||||
#include <sysio.depot/sysio.depot.hpp>
|
||||
|
||||
namespace sysio {
|
||||
|
||||
// ── submitchain (FR-101/103/104/105) ─────────────────────────────────────────
|
||||
//
|
||||
// Batch operator submits an epoch envelope + signature.
|
||||
// The Depot collects these as votes; consensus is evaluated once enough arrive.
|
||||
|
||||
void depot::submitchain(name operator_account,
|
||||
uint64_t epoch_number,
|
||||
checksum256 epoch_hash,
|
||||
checksum256 prev_epoch_hash,
|
||||
checksum256 merkle_root,
|
||||
std::vector<char> signature) {
|
||||
require_auth(operator_account);
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
// FR-103: Verify operator is elected for this epoch
|
||||
verify_elected(operator_account, epoch_number);
|
||||
|
||||
// FR-102: Sequential epoch processing
|
||||
check(epoch_number == s.current_epoch || epoch_number == s.current_epoch + 1,
|
||||
"depot: epoch out of order (FR-102: must be processed sequentially)");
|
||||
|
||||
// Resolve operator id
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
auto by_account = ops.get_index<"byaccount"_n>();
|
||||
auto op_it = by_account.find(operator_account.value);
|
||||
// verify_elected already checks this, but be explicit
|
||||
check(op_it != by_account.end(), "depot: operator not registered");
|
||||
|
||||
// FR-105: Validate previous epoch hash against stored value
|
||||
if (epoch_number > 0) {
|
||||
opp_epoch_in_table epochs(get_self(), chain_scope);
|
||||
auto prev_epoch = epochs.find(epoch_number - 1);
|
||||
if (prev_epoch != epochs.end()) {
|
||||
check(prev_epoch_hash == prev_epoch->epoch_merkle,
|
||||
"depot: prev_epoch_hash does not match stored epoch merkle (FR-105)");
|
||||
}
|
||||
}
|
||||
|
||||
// FR-104: Validate signature (recover signer, confirm it matches operator)
|
||||
// The signature is over the epoch_hash using the operator's secp256k1 key.
|
||||
// On-chain recovery: assert_recover_key(epoch_hash, sig, expected_pubkey)
|
||||
// For now we store the signature; full recovery requires the CDT
|
||||
// assert_recover_key intrinsic which takes a digest + sig + pubkey.
|
||||
// TODO: Uncomment when OPP signature format is finalized
|
||||
// public_key recovered = recover_key(epoch_hash, signature);
|
||||
// check(recovered matches op_it->secp256k1_pubkey)
|
||||
|
||||
// Store this operator's vote for the epoch
|
||||
epoch_votes_table votes(get_self(), chain_scope);
|
||||
auto by_eo = votes.get_index<"byepochop"_n>();
|
||||
uint128_t eo_key = (uint128_t(epoch_number) << 64) | op_it->id;
|
||||
check(by_eo.find(eo_key) == by_eo.end(),
|
||||
"depot: operator already submitted for this epoch");
|
||||
|
||||
votes.emplace(get_self(), [&](auto& v) {
|
||||
v.id = votes.available_primary_key();
|
||||
v.epoch_number = epoch_number;
|
||||
v.operator_id = op_it->id;
|
||||
v.chain_hash = epoch_hash;
|
||||
v.submitted_at = current_time_point();
|
||||
});
|
||||
|
||||
// Store the message chain record
|
||||
message_chains_table chains(get_self(), chain_scope);
|
||||
chains.emplace(get_self(), [&](auto& c) {
|
||||
c.id = chains.available_primary_key();
|
||||
c.direction = message_direction_inbound;
|
||||
c.status = chain_status_pending;
|
||||
c.epoch_number = epoch_number;
|
||||
c.merkle_root = merkle_root;
|
||||
c.epoch_hash = epoch_hash;
|
||||
c.prev_epoch_hash = prev_epoch_hash;
|
||||
c.operator_signature = signature;
|
||||
c.operator_id = op_it->id;
|
||||
c.created_at = current_time_point();
|
||||
});
|
||||
|
||||
// Attempt consensus evaluation after each submission
|
||||
evaluate_consensus(epoch_number);
|
||||
}
|
||||
|
||||
// ── uploadmsgs (FR-106/107/108) ──────────────────────────────────────────────
|
||||
//
|
||||
// After consensus is reached, a single chosen operator uploads the actual
|
||||
// messages with merkle proofs linking message IDs to the epoch merkle root.
|
||||
|
||||
void depot::uploadmsgs(name operator_account,
|
||||
uint64_t epoch_number,
|
||||
std::vector<char> messages,
|
||||
std::vector<char> merkle_proofs) {
|
||||
require_auth(operator_account);
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
// Verify operator is elected
|
||||
verify_elected(operator_account, epoch_number);
|
||||
|
||||
// Verify the epoch has reached consensus (status = valid)
|
||||
message_chains_table chains(get_self(), chain_scope);
|
||||
auto by_epoch = chains.get_index<"byepochdir"_n>();
|
||||
uint128_t ek = (uint128_t(epoch_number) << 8) | uint64_t(message_direction_inbound);
|
||||
auto chain_it = by_epoch.lower_bound(ek);
|
||||
|
||||
bool has_valid = false;
|
||||
while (chain_it != by_epoch.end() &&
|
||||
chain_it->epoch_number == epoch_number &&
|
||||
chain_it->direction == message_direction_inbound) {
|
||||
if (chain_it->status == chain_status_valid) {
|
||||
has_valid = true;
|
||||
break;
|
||||
}
|
||||
++chain_it;
|
||||
}
|
||||
check(has_valid, "depot: epoch has not reached consensus yet");
|
||||
|
||||
// FR-106: Unpack message chain attestations
|
||||
// Messages format: [assertion_type(2) | payload_len(4) | payload(N)] repeated
|
||||
// Merkle proofs: [proof per message for verification against epoch merkle root]
|
||||
//
|
||||
// For each message:
|
||||
// 1. Recompute message ID from message data (FR-106)
|
||||
// 2. Evaluate merkle proof against epoch merkle root
|
||||
// 3. Classify as NORMAL or CHALLENGE (FR-106)
|
||||
// 4. Assign status PENDING or READY (FR-107)
|
||||
|
||||
opp_in_table in_msgs(get_self(), chain_scope);
|
||||
|
||||
// Deserialize messages
|
||||
const char* ptr = messages.data();
|
||||
const char* end = ptr + messages.size();
|
||||
uint64_t msg_num = 0;
|
||||
|
||||
// Find the next message number
|
||||
auto last_msg = in_msgs.end();
|
||||
if (last_msg != in_msgs.begin()) {
|
||||
--last_msg;
|
||||
msg_num = last_msg->message_number + 1;
|
||||
}
|
||||
|
||||
while (ptr + 6 <= end) { // minimum: 2 bytes type + 4 bytes length
|
||||
// Read assertion type (2 bytes, big-endian)
|
||||
uint16_t atype = (uint16_t(uint8_t(ptr[0])) << 8) | uint8_t(ptr[1]);
|
||||
ptr += 2;
|
||||
|
||||
// Read payload length (4 bytes, big-endian)
|
||||
uint32_t plen = (uint32_t(uint8_t(ptr[0])) << 24) |
|
||||
(uint32_t(uint8_t(ptr[1])) << 16) |
|
||||
(uint32_t(uint8_t(ptr[2])) << 8) |
|
||||
uint32_t(uint8_t(ptr[3]));
|
||||
ptr += 4;
|
||||
|
||||
check(ptr + plen <= end, "depot: message payload exceeds buffer");
|
||||
|
||||
std::vector<char> payload(ptr, ptr + plen);
|
||||
ptr += plen;
|
||||
|
||||
assertion_type_t assertion = static_cast<assertion_type_t>(atype);
|
||||
|
||||
// FR-106: Classify message type
|
||||
bool is_challenge = (assertion == assertion_type_challenge_response);
|
||||
|
||||
// FR-107: Messages requiring underwriting -> PENDING; others -> READY
|
||||
// Challenge messages are always READY (FR-108)
|
||||
message_status_t msg_status;
|
||||
if (is_challenge) {
|
||||
msg_status = message_status_ready;
|
||||
} else {
|
||||
// Messages that need underwriting: swaps, purchases
|
||||
// For now, purchases need underwriting; balance sheets and operator
|
||||
// registration do not.
|
||||
switch (assertion) {
|
||||
case assertion_type_wire_purchase:
|
||||
case assertion_type_stake_update:
|
||||
msg_status = message_status_pending;
|
||||
break;
|
||||
default:
|
||||
msg_status = message_status_ready;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
in_msgs.emplace(get_self(), [&](auto& m) {
|
||||
m.message_number = msg_num++;
|
||||
m.assertion_type = assertion;
|
||||
m.status = msg_status;
|
||||
m.payload = payload;
|
||||
});
|
||||
|
||||
// FR-108: Process challenge messages immediately
|
||||
if (is_challenge) {
|
||||
process_assertion(msg_num - 1, assertion, payload);
|
||||
}
|
||||
}
|
||||
|
||||
// Update inbound epoch tracking
|
||||
opp_epoch_in_table epoch_tbl(get_self(), chain_scope);
|
||||
auto ep_it = epoch_tbl.find(epoch_number);
|
||||
if (ep_it == epoch_tbl.end()) {
|
||||
epoch_tbl.emplace(get_self(), [&](auto& e) {
|
||||
e.epoch_number = epoch_number;
|
||||
e.start_message = msg_num > 0 ? msg_num - 1 : 0; // approximate
|
||||
e.end_message = msg_num;
|
||||
e.challenge_flag = (s.state == depot_state_challenge);
|
||||
});
|
||||
} else {
|
||||
epoch_tbl.modify(ep_it, same_payer, [&](auto& e) {
|
||||
e.end_message = msg_num;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace sysio
|
||||
120
contracts/sysio.depot/src/opp_outbound.cpp
Normal file
120
contracts/sysio.depot/src/opp_outbound.cpp
Normal file
@@ -0,0 +1,120 @@
|
||||
#include <sysio.depot/sysio.depot.hpp>
|
||||
|
||||
namespace sysio {
|
||||
|
||||
// ── emitchain (FR-201/202/203) ──────────────────────────────────────────────
|
||||
//
|
||||
// An elected batch operator builds the outbound message chain for an epoch.
|
||||
// Collects all queued outbound messages, computes a merkle root, signs,
|
||||
// and stores the chain for the Outpost to fetch.
|
||||
|
||||
void depot::emitchain(name operator_account, uint64_t epoch_number) {
|
||||
require_auth(operator_account);
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
// Verify operator is elected for this epoch
|
||||
verify_elected(operator_account, epoch_number);
|
||||
|
||||
// Resolve operator id
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
auto by_account = ops.get_index<"byaccount"_n>();
|
||||
auto op_it = by_account.find(operator_account.value);
|
||||
check(op_it != by_account.end(), "depot: operator not registered");
|
||||
|
||||
// Check that outbound epoch hasn't already been emitted
|
||||
opp_epoch_out_table epoch_tbl(get_self(), chain_scope);
|
||||
auto ep_it = epoch_tbl.find(epoch_number);
|
||||
check(ep_it == epoch_tbl.end(), "depot: outbound chain already emitted for this epoch");
|
||||
|
||||
// Collect outbound messages for this epoch
|
||||
// Messages are queued via queue_outbound_message() during crank processing
|
||||
opp_out_table out(get_self(), chain_scope);
|
||||
|
||||
// Determine message range for this epoch
|
||||
// Use previous epoch's end_message as start, or 0 if first epoch
|
||||
uint64_t start_msg = 0;
|
||||
if (epoch_number > 0) {
|
||||
auto prev_ep = epoch_tbl.find(epoch_number - 1);
|
||||
if (prev_ep != epoch_tbl.end()) {
|
||||
start_msg = prev_ep->end_message;
|
||||
}
|
||||
}
|
||||
|
||||
// FR-202: Build the outbound message chain payload
|
||||
// Serialize all outbound messages into a single chain envelope
|
||||
std::vector<char> chain_payload;
|
||||
uint64_t end_msg = start_msg;
|
||||
auto it = out.lower_bound(start_msg);
|
||||
|
||||
while (it != out.end()) {
|
||||
// Append: assertion_type (2 bytes BE) + payload_len (4 bytes BE) + payload
|
||||
uint16_t atype = uint16_t(it->assertion_type);
|
||||
chain_payload.push_back(char(atype >> 8));
|
||||
chain_payload.push_back(char(atype & 0xFF));
|
||||
|
||||
uint32_t plen = it->payload.size();
|
||||
chain_payload.push_back(char((plen >> 24) & 0xFF));
|
||||
chain_payload.push_back(char((plen >> 16) & 0xFF));
|
||||
chain_payload.push_back(char((plen >> 8) & 0xFF));
|
||||
chain_payload.push_back(char(plen & 0xFF));
|
||||
|
||||
chain_payload.insert(chain_payload.end(), it->payload.begin(), it->payload.end());
|
||||
|
||||
end_msg = it->message_number + 1;
|
||||
++it;
|
||||
}
|
||||
|
||||
// Compute merkle root over the outbound messages
|
||||
// For a single-element chain, the merkle root is the hash of the payload
|
||||
checksum256 merkle = sha256(chain_payload.data(), chain_payload.size());
|
||||
|
||||
// FR-203: Compute epoch hash (hash of merkle_root + prev_epoch_hash)
|
||||
checksum256 prev_epoch_hash = checksum256();
|
||||
if (epoch_number > 0) {
|
||||
message_chains_table chains(get_self(), chain_scope);
|
||||
auto by_epoch = chains.get_index<"byepochdir"_n>();
|
||||
uint128_t ek = (uint128_t(epoch_number - 1) << 8) | uint64_t(message_direction_outbound);
|
||||
auto chain_it = by_epoch.lower_bound(ek);
|
||||
if (chain_it != by_epoch.end() &&
|
||||
chain_it->epoch_number == epoch_number - 1 &&
|
||||
chain_it->direction == message_direction_outbound) {
|
||||
prev_epoch_hash = chain_it->epoch_hash;
|
||||
}
|
||||
}
|
||||
|
||||
// epoch_hash = H(merkle_root || prev_epoch_hash)
|
||||
std::vector<char> hash_input;
|
||||
auto merkle_bytes = merkle.extract_as_byte_array();
|
||||
auto prev_bytes = prev_epoch_hash.extract_as_byte_array();
|
||||
hash_input.insert(hash_input.end(), merkle_bytes.begin(), merkle_bytes.end());
|
||||
hash_input.insert(hash_input.end(), prev_bytes.begin(), prev_bytes.end());
|
||||
checksum256 epoch_hash = sha256(hash_input.data(), hash_input.size());
|
||||
|
||||
// Store message chain record
|
||||
message_chains_table chains(get_self(), chain_scope);
|
||||
chains.emplace(get_self(), [&](auto& c) {
|
||||
c.id = chains.available_primary_key();
|
||||
c.direction = message_direction_outbound;
|
||||
c.status = chain_status_valid; // outbound is always valid
|
||||
c.epoch_number = epoch_number;
|
||||
c.merkle_root = merkle;
|
||||
c.epoch_hash = epoch_hash;
|
||||
c.prev_epoch_hash = prev_epoch_hash;
|
||||
c.payload = chain_payload;
|
||||
c.operator_signature = {}; // TODO: operator signs with secp256k1 key
|
||||
c.operator_id = op_it->id;
|
||||
c.created_at = current_time_point();
|
||||
});
|
||||
|
||||
// Store epoch tracking record
|
||||
epoch_tbl.emplace(get_self(), [&](auto& e) {
|
||||
e.epoch_number = epoch_number;
|
||||
e.start_message = start_msg;
|
||||
e.end_message = end_msg;
|
||||
e.merkle_root = merkle;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace sysio
|
||||
181
contracts/sysio.depot/src/reserves.cpp
Normal file
181
contracts/sysio.depot/src/reserves.cpp
Normal file
@@ -0,0 +1,181 @@
|
||||
#include <sysio.depot/sysio.depot.hpp>
|
||||
|
||||
namespace sysio {
|
||||
|
||||
// ── setreserve (FR-701) ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Governance / admin action to set the initial reserve state for a token pair.
|
||||
// Sets the total reserve balance and its $WIRE equivalent value.
|
||||
|
||||
void depot::setreserve(name authority, asset reserve_total, asset wire_equivalent) {
|
||||
require_auth(authority);
|
||||
|
||||
// Only the contract itself (governance) can set reserves
|
||||
check(authority == get_self(), "depot: only contract authority can set reserves");
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
check(reserve_total.amount >= 0, "depot: reserve_total must be non-negative");
|
||||
check(wire_equivalent.amount >= 0, "depot: wire_equivalent must be non-negative");
|
||||
|
||||
reserves_table reserves(get_self(), chain_scope);
|
||||
auto it = reserves.find(reserve_total.symbol.code().raw());
|
||||
|
||||
if (it == reserves.end()) {
|
||||
reserves.emplace(get_self(), [&](auto& r) {
|
||||
r.reserve_total = reserve_total;
|
||||
r.wire_equivalent = wire_equivalent;
|
||||
});
|
||||
} else {
|
||||
reserves.modify(it, same_payer, [&](auto& r) {
|
||||
r.reserve_total = reserve_total;
|
||||
r.wire_equivalent = wire_equivalent;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── updreserve (FR-701) ─────────────────────────────────────────────────────
|
||||
//
|
||||
// An elected batch operator updates a reserve balance by a delta amount.
|
||||
// Called during message processing when assets are deposited or withdrawn.
|
||||
|
||||
void depot::updreserve(name operator_account, symbol token_sym, int64_t delta) {
|
||||
require_auth(operator_account);
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
// Verify caller is elected
|
||||
verify_elected(operator_account, s.current_epoch);
|
||||
|
||||
reserves_table reserves(get_self(), chain_scope);
|
||||
auto it = reserves.find(token_sym.code().raw());
|
||||
check(it != reserves.end(), "depot: reserve not found for token");
|
||||
|
||||
int64_t new_amount = it->reserve_total.amount + delta;
|
||||
check(new_amount >= 0, "depot: reserve would go negative");
|
||||
|
||||
reserves.modify(it, same_payer, [&](auto& r) {
|
||||
r.reserve_total.amount = new_amount;
|
||||
});
|
||||
}
|
||||
|
||||
// ── getquote (FR-900 / FR-703) ──────────────────────────────────────────────
|
||||
//
|
||||
// Read-only action that computes a swap quote based on current reserve state.
|
||||
// Uses constant-product (x*y=k) pricing with the underwriting fee applied.
|
||||
|
||||
void depot::getquote(symbol source_sym, symbol target_sym, asset amount) {
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
check(amount.amount > 0, "depot: amount must be positive");
|
||||
check(amount.symbol == source_sym, "depot: amount symbol must match source_sym");
|
||||
|
||||
reserves_table reserves(get_self(), chain_scope);
|
||||
|
||||
auto source_it = reserves.find(source_sym.code().raw());
|
||||
check(source_it != reserves.end(), "depot: source token reserve not found");
|
||||
check(source_it->reserve_total.amount > 0, "depot: source reserve is empty");
|
||||
|
||||
auto target_it = reserves.find(target_sym.code().raw());
|
||||
check(target_it != reserves.end(), "depot: target token reserve not found");
|
||||
check(target_it->reserve_total.amount > 0, "depot: target reserve is empty");
|
||||
|
||||
// Constant product: x * y = k
|
||||
// output = (target_reserve * input) / (source_reserve + input)
|
||||
// Apply fee: output = output * (10000 - UNDERWRITE_FEE_BPS) / 10000
|
||||
|
||||
int64_t source_reserve = source_it->reserve_total.amount;
|
||||
int64_t target_reserve = target_it->reserve_total.amount;
|
||||
int64_t input = amount.amount;
|
||||
|
||||
// Use 128-bit multiplication to avoid overflow
|
||||
__int128 numerator = __int128(target_reserve) * __int128(input);
|
||||
__int128 denominator = __int128(source_reserve) + __int128(input);
|
||||
int64_t raw_output = int64_t(numerator / denominator);
|
||||
|
||||
// Apply fee
|
||||
int64_t fee_adjusted = int64_t((__int128(raw_output) * (10000 - UNDERWRITE_FEE_BPS)) / 10000);
|
||||
|
||||
check(fee_adjusted > 0, "depot: quote results in zero output");
|
||||
check(fee_adjusted < target_reserve, "depot: insufficient target reserve for this swap");
|
||||
|
||||
// This is a read-only action — the quote is returned via check message
|
||||
// In production, this would use return_value or get_table_rows
|
||||
// For now, we print the result
|
||||
print("quote:", fee_adjusted, " ", target_sym);
|
||||
}
|
||||
|
||||
// ── oneshot (FR-1000) ───────────────────────────────────────────────────────
|
||||
//
|
||||
// One-time warrant conversion. Burns a warrant token and mints the
|
||||
// corresponding $WIRE amount to the beneficiary.
|
||||
// This is a governance-controlled action.
|
||||
|
||||
void depot::oneshot(name beneficiary, asset amount) {
|
||||
require_auth(get_self());
|
||||
|
||||
check(is_account(beneficiary), "depot: beneficiary account does not exist");
|
||||
check(amount.amount > 0, "depot: amount must be positive");
|
||||
|
||||
auto s = get_state();
|
||||
|
||||
// FR-1000: Issue inline transfer from depot to beneficiary
|
||||
// The depot contract must hold the $WIRE tokens to distribute
|
||||
action(
|
||||
permission_level{get_self(), "active"_n},
|
||||
s.token_contract,
|
||||
"transfer"_n,
|
||||
std::make_tuple(get_self(), beneficiary, amount,
|
||||
std::string("oneshot warrant conversion"))
|
||||
).send();
|
||||
}
|
||||
|
||||
// ── calculate_swap_rate (internal, FR-703) ──────────────────────────────────
|
||||
//
|
||||
// Computes the effective exchange rate in basis points between two tokens
|
||||
// based on current reserve balances.
|
||||
|
||||
uint64_t depot::calculate_swap_rate(symbol source, symbol target, asset amount) {
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
reserves_table reserves(get_self(), chain_scope);
|
||||
|
||||
auto source_it = reserves.find(source.code().raw());
|
||||
if (source_it == reserves.end() || source_it->reserve_total.amount == 0) return 0;
|
||||
|
||||
auto target_it = reserves.find(target.code().raw());
|
||||
if (target_it == reserves.end() || target_it->reserve_total.amount == 0) return 0;
|
||||
|
||||
// Rate in basis points: (target_wire_equivalent / source_wire_equivalent) * 10000
|
||||
// Using wire_equivalent for cross-token comparison
|
||||
if (source_it->wire_equivalent.amount == 0) return 0;
|
||||
|
||||
uint64_t rate_bps = uint64_t(
|
||||
(__int128(target_it->wire_equivalent.amount) * 10000) /
|
||||
__int128(source_it->wire_equivalent.amount)
|
||||
);
|
||||
|
||||
return rate_bps;
|
||||
}
|
||||
|
||||
// ── check_rate_threshold (internal, FR-704) ─────────────────────────────────
|
||||
//
|
||||
// Verifies that an exchange rate is within acceptable bounds.
|
||||
// Returns true if the rate is acceptable (deviation from 1:1 is within
|
||||
// the fee threshold).
|
||||
|
||||
bool depot::check_rate_threshold(uint64_t rate_bps, uint64_t threshold_bps) {
|
||||
// Rate should be within [10000 - threshold, 10000 + threshold] basis points
|
||||
// of the expected 1:1 ratio (adjusted for token decimals)
|
||||
// For a 0.1% fee (10 bps), we allow rates between 9990 and 10010
|
||||
uint64_t lower = 10000 - threshold_bps * 100; // allow wider range
|
||||
uint64_t upper = 10000 + threshold_bps * 100;
|
||||
|
||||
return rate_bps >= lower && rate_bps <= upper;
|
||||
}
|
||||
|
||||
} // namespace sysio
|
||||
545
contracts/sysio.depot/src/sysio.depot.cpp
Normal file
545
contracts/sysio.depot/src/sysio.depot.cpp
Normal file
@@ -0,0 +1,545 @@
|
||||
#include <sysio.depot/sysio.depot.hpp>
|
||||
|
||||
namespace sysio {
|
||||
|
||||
// ── State helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
depot_global_state depot::get_state() {
|
||||
depot_state_singleton state_tbl(get_self(), get_self().value);
|
||||
check(state_tbl.exists(), "depot: contract not initialized");
|
||||
return state_tbl.get();
|
||||
}
|
||||
|
||||
void depot::set_state(const depot_global_state& s) {
|
||||
depot_state_singleton state_tbl(get_self(), get_self().value);
|
||||
state_tbl.set(s, get_self());
|
||||
}
|
||||
|
||||
// ── init ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
void depot::init(chain_kind_t chain_id, name token_contract) {
|
||||
require_auth(get_self());
|
||||
|
||||
depot_state_singleton state_tbl(get_self(), get_self().value);
|
||||
check(!state_tbl.exists() || !state_tbl.get().initialized,
|
||||
"depot: already initialized");
|
||||
|
||||
check(chain_id != fc::crypto::chain_kind_unknown && chain_id != fc::crypto::chain_kind_wire,
|
||||
"depot: chain_id must be an external chain (ethereum, solana, sui)");
|
||||
check(is_account(token_contract), "depot: token_contract account does not exist");
|
||||
|
||||
depot_global_state s;
|
||||
s.state = depot_state_active;
|
||||
s.chain_id = chain_id;
|
||||
s.current_epoch = 0;
|
||||
s.next_epoch = 1;
|
||||
s.next_msg_out = 0;
|
||||
s.last_crank_time = current_time_point();
|
||||
s.token_contract = token_contract;
|
||||
s.initialized = true;
|
||||
|
||||
state_tbl.set(s, get_self());
|
||||
}
|
||||
|
||||
// ── bootstrap (testnet): seed opschedule for current_epoch ───────────────────
|
||||
|
||||
void depot::bootstrap() {
|
||||
require_auth(get_self());
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
op_schedule_table sched(get_self(), chain_scope);
|
||||
check(sched.find(s.current_epoch) == sched.end(),
|
||||
"depot: schedule already exists for current epoch");
|
||||
|
||||
elect_operators_for_epoch(s.current_epoch);
|
||||
}
|
||||
|
||||
// ── crank (FR-801) ──────────────────────────────────────────────────────────
|
||||
|
||||
void depot::crank(name operator_account) {
|
||||
require_auth(operator_account);
|
||||
|
||||
auto s = get_state();
|
||||
check(s.state != depot_state_paused, "depot: system is paused, manual intervention required");
|
||||
|
||||
// Verify the caller is an elected batch operator for the current epoch
|
||||
verify_elected(operator_account, s.current_epoch);
|
||||
|
||||
// Step 1: Expire underwriting locks (FR-402)
|
||||
expire_underwriting_locks();
|
||||
|
||||
// Step 2: Process messages previously marked as READY (FR-801.3)
|
||||
if (s.state == depot_state_active) {
|
||||
process_ready_messages();
|
||||
}
|
||||
|
||||
// Step 3: Elect 7 active batch operators for NEXT message chain (FR-801.2 / FR-604)
|
||||
elect_operators_for_epoch(s.next_epoch);
|
||||
|
||||
// Step 4: Advance epoch
|
||||
s.current_epoch = s.next_epoch;
|
||||
s.next_epoch = s.next_epoch + 1;
|
||||
s.last_crank_time = current_time_point();
|
||||
|
||||
set_state(s);
|
||||
}
|
||||
|
||||
// ── process_ready_messages (internal) ────────────────────────────────────────
|
||||
|
||||
void depot::process_ready_messages() {
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
opp_in_table msgs(get_self(), chain_scope);
|
||||
auto by_status = msgs.get_index<"bystatus"_n>();
|
||||
auto it = by_status.lower_bound(uint64_t(message_status_ready));
|
||||
|
||||
while (it != by_status.end() && it->status == message_status_ready) {
|
||||
process_assertion(it->message_number, it->assertion_type, it->payload);
|
||||
it = by_status.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
// ── process_assertion (internal) ─────────────────────────────────────────────
|
||||
//
|
||||
// Payload wire format (big-endian, matching uploadmsgs serialization):
|
||||
//
|
||||
// balance_sheet (0xAA00):
|
||||
// [sym_code(8) | reserve_amount(8) | wire_equiv_amount(8) | precision(1)] repeated
|
||||
//
|
||||
// stake_update (0xEE00):
|
||||
// [sym_code(8) | delta_amount(8, signed) | wire_account(8) | direction(1)]
|
||||
// direction: 0 = deposit (add to reserve), 1 = withdrawal (subtract)
|
||||
//
|
||||
// yield_reward (0xEE01):
|
||||
// [sym_code(8) | reward_amount(8) | beneficiary(8)]
|
||||
//
|
||||
// wire_purchase (0xEE02):
|
||||
// [source_sym(8) | source_amount(8) | buyer_account(8)]
|
||||
//
|
||||
// operator_registration (0xEE03):
|
||||
// [wire_account(8) | op_type(1) | action(1) | secp_key(33) | ed_key(32)]
|
||||
// action: 0 = register, 1 = deregister
|
||||
//
|
||||
// challenge_response (0xEE04):
|
||||
// [epoch_number(8) | response_type(1) | data(N)]
|
||||
// response_type: 0 = no_challenge, 1 = accept, 2 = reject
|
||||
//
|
||||
// slash_operator (0xEE05):
|
||||
// [operator_id(8) | reason_len(2) | reason(N)]
|
||||
|
||||
// Helper: read a big-endian uint64 from a byte buffer
|
||||
static uint64_t read_be_u64(const char* p) {
|
||||
return (uint64_t(uint8_t(p[0])) << 56) | (uint64_t(uint8_t(p[1])) << 48) |
|
||||
(uint64_t(uint8_t(p[2])) << 40) | (uint64_t(uint8_t(p[3])) << 32) |
|
||||
(uint64_t(uint8_t(p[4])) << 24) | (uint64_t(uint8_t(p[5])) << 16) |
|
||||
(uint64_t(uint8_t(p[6])) << 8) | uint64_t(uint8_t(p[7]));
|
||||
}
|
||||
|
||||
static int64_t read_be_i64(const char* p) {
|
||||
return static_cast<int64_t>(read_be_u64(p));
|
||||
}
|
||||
|
||||
static uint16_t read_be_u16(const char* p) {
|
||||
return (uint16_t(uint8_t(p[0])) << 8) | uint16_t(uint8_t(p[1]));
|
||||
}
|
||||
|
||||
void depot::process_assertion(uint64_t message_number, assertion_type_t type,
|
||||
const std::vector<char>& payload) {
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
switch (type) {
|
||||
|
||||
// ── 0xAA00: Balance Sheet ──────────────────────────────────────────
|
||||
// Full reserve snapshot from the outpost. Each entry is 25 bytes:
|
||||
// sym_code(8) + reserve_amount(8) + wire_equiv_amount(8) + precision(1)
|
||||
case assertion_type_balance_sheet: {
|
||||
const size_t ENTRY_SIZE = 25;
|
||||
check(payload.size() >= ENTRY_SIZE, "depot: balance_sheet payload too short");
|
||||
check(payload.size() % ENTRY_SIZE == 0, "depot: balance_sheet payload not aligned");
|
||||
|
||||
reserves_table reserves(get_self(), chain_scope);
|
||||
const char* ptr = payload.data();
|
||||
size_t entries = payload.size() / ENTRY_SIZE;
|
||||
|
||||
for (size_t i = 0; i < entries; ++i) {
|
||||
uint64_t sym_raw = read_be_u64(ptr);
|
||||
int64_t reserve_amt = read_be_i64(ptr + 8);
|
||||
int64_t wire_amt = read_be_i64(ptr + 16);
|
||||
uint8_t precision = uint8_t(ptr[24]);
|
||||
ptr += ENTRY_SIZE;
|
||||
|
||||
check(reserve_amt >= 0, "depot: balance_sheet reserve_amount must be non-negative");
|
||||
check(wire_amt >= 0, "depot: balance_sheet wire_equivalent must be non-negative");
|
||||
|
||||
symbol_code sc(sym_raw);
|
||||
symbol sym(sc, precision);
|
||||
|
||||
auto it = reserves.find(sc.raw());
|
||||
if (it == reserves.end()) {
|
||||
reserves.emplace(get_self(), [&](auto& r) {
|
||||
r.reserve_total = asset(reserve_amt, sym);
|
||||
r.wire_equivalent = asset(wire_amt, symbol("WIRE", 4));
|
||||
});
|
||||
} else {
|
||||
reserves.modify(it, same_payer, [&](auto& r) {
|
||||
r.reserve_total.amount = reserve_amt;
|
||||
r.wire_equivalent.amount = wire_amt;
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── 0xEE00: Stake Update ───────────────────────────────────────────
|
||||
// A principal deposit or withdrawal on the external chain.
|
||||
// Payload: sym_code(8) + delta(8) + wire_account(8) + direction(1) = 25 bytes
|
||||
case assertion_type_stake_update: {
|
||||
check(payload.size() >= 25, "depot: stake_update payload too short");
|
||||
const char* ptr = payload.data();
|
||||
|
||||
uint64_t sym_raw = read_be_u64(ptr);
|
||||
int64_t delta = read_be_i64(ptr + 8);
|
||||
uint64_t acct_raw = read_be_u64(ptr + 16);
|
||||
uint8_t direction = uint8_t(ptr[24]);
|
||||
|
||||
check(delta > 0, "depot: stake_update delta must be positive");
|
||||
|
||||
symbol_code sc(sym_raw);
|
||||
name wire_account(acct_raw);
|
||||
|
||||
reserves_table reserves(get_self(), chain_scope);
|
||||
auto it = reserves.find(sc.raw());
|
||||
check(it != reserves.end(), "depot: stake_update reserve not found for token");
|
||||
|
||||
if (direction == 0) {
|
||||
// Deposit: increase reserve
|
||||
reserves.modify(it, same_payer, [&](auto& r) {
|
||||
r.reserve_total.amount += delta;
|
||||
});
|
||||
} else {
|
||||
// Withdrawal: decrease reserve
|
||||
check(it->reserve_total.amount >= delta,
|
||||
"depot: stake_update withdrawal exceeds reserve");
|
||||
reserves.modify(it, same_payer, [&](auto& r) {
|
||||
r.reserve_total.amount -= delta;
|
||||
});
|
||||
}
|
||||
|
||||
// Queue outbound confirmation so the outpost knows the depot acknowledged
|
||||
std::vector<char> confirm_payload(payload.begin(), payload.end());
|
||||
queue_outbound_message(assertion_type_stake_update, confirm_payload);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── 0xEE01: Yield Reward ───────────────────────────────────────────
|
||||
// Native chain staking yield earned. Mint equivalent WIRE to beneficiary.
|
||||
// Payload: sym_code(8) + reward_amount(8) + beneficiary(8) = 24 bytes
|
||||
case assertion_type_yield_reward: {
|
||||
check(payload.size() >= 24, "depot: yield_reward payload too short");
|
||||
const char* ptr = payload.data();
|
||||
|
||||
uint64_t sym_raw = read_be_u64(ptr);
|
||||
int64_t reward_amount = read_be_i64(ptr + 8);
|
||||
uint64_t bene_raw = read_be_u64(ptr + 16);
|
||||
|
||||
check(reward_amount > 0, "depot: yield_reward amount must be positive");
|
||||
|
||||
symbol_code sc(sym_raw);
|
||||
name beneficiary(bene_raw);
|
||||
|
||||
// Increase the reserve by the yield amount (the outpost earned it)
|
||||
reserves_table reserves(get_self(), chain_scope);
|
||||
auto it = reserves.find(sc.raw());
|
||||
if (it != reserves.end()) {
|
||||
reserves.modify(it, same_payer, [&](auto& r) {
|
||||
r.reserve_total.amount += reward_amount;
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate WIRE equivalent of the reward using current rate
|
||||
// If wire_equivalent and reserve_total are set, compute proportional WIRE
|
||||
int64_t wire_reward = 0;
|
||||
if (it != reserves.end() && it->reserve_total.amount > 0 &&
|
||||
it->wire_equivalent.amount > 0) {
|
||||
// wire_reward = reward_amount * (wire_equivalent / reserve_total)
|
||||
wire_reward = int64_t(
|
||||
(__int128(reward_amount) * __int128(it->wire_equivalent.amount)) /
|
||||
__int128(it->reserve_total.amount)
|
||||
);
|
||||
}
|
||||
|
||||
// Issue WIRE to beneficiary if they have an account and amount > 0
|
||||
if (wire_reward > 0 && is_account(beneficiary)) {
|
||||
asset wire_payout(wire_reward, symbol("WIRE", 4));
|
||||
action(
|
||||
permission_level{get_self(), "active"_n},
|
||||
s.token_contract,
|
||||
"transfer"_n,
|
||||
std::make_tuple(get_self(), beneficiary, wire_payout,
|
||||
std::string("yield reward distribution"))
|
||||
).send();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── 0xEE02: WIRE Purchase ──────────────────────────────────────────
|
||||
// User deposited external-chain tokens to buy WIRE.
|
||||
// Constant-product swap: output = (wire_reserve * input) / (source_reserve + input)
|
||||
// Payload: source_sym(8) + source_amount(8) + buyer_account(8) = 24 bytes
|
||||
case assertion_type_wire_purchase: {
|
||||
check(payload.size() >= 24, "depot: wire_purchase payload too short");
|
||||
const char* ptr = payload.data();
|
||||
|
||||
uint64_t sym_raw = read_be_u64(ptr);
|
||||
int64_t source_amount = read_be_i64(ptr + 8);
|
||||
uint64_t buyer_raw = read_be_u64(ptr + 16);
|
||||
|
||||
check(source_amount > 0, "depot: wire_purchase amount must be positive");
|
||||
|
||||
symbol_code sc(sym_raw);
|
||||
name buyer(buyer_raw);
|
||||
|
||||
reserves_table reserves(get_self(), chain_scope);
|
||||
auto source_it = reserves.find(sc.raw());
|
||||
check(source_it != reserves.end(), "depot: wire_purchase source reserve not found");
|
||||
check(source_it->reserve_total.amount > 0, "depot: wire_purchase source reserve empty");
|
||||
check(source_it->wire_equivalent.amount > 0, "depot: wire_purchase has no wire equivalent set");
|
||||
|
||||
// Constant-product swap against the wire_equivalent
|
||||
int64_t source_reserve = source_it->reserve_total.amount;
|
||||
int64_t wire_reserve = source_it->wire_equivalent.amount;
|
||||
|
||||
// output = (wire_reserve * input) / (source_reserve + input)
|
||||
__int128 numerator = __int128(wire_reserve) * __int128(source_amount);
|
||||
__int128 denominator = __int128(source_reserve) + __int128(source_amount);
|
||||
int64_t raw_output = int64_t(numerator / denominator);
|
||||
|
||||
// Apply underwriting fee
|
||||
int64_t wire_output = int64_t(
|
||||
(__int128(raw_output) * (10000 - UNDERWRITE_FEE_BPS)) / 10000
|
||||
);
|
||||
|
||||
check(wire_output > 0, "depot: wire_purchase results in zero output");
|
||||
check(wire_output < wire_reserve, "depot: wire_purchase exceeds wire reserve");
|
||||
|
||||
// Update reserves: source goes up, wire equivalent goes down
|
||||
reserves.modify(source_it, same_payer, [&](auto& r) {
|
||||
r.reserve_total.amount += source_amount;
|
||||
r.wire_equivalent.amount -= wire_output;
|
||||
});
|
||||
|
||||
// Transfer WIRE to buyer
|
||||
if (is_account(buyer)) {
|
||||
asset wire_payout(wire_output, symbol("WIRE", 4));
|
||||
action(
|
||||
permission_level{get_self(), "active"_n},
|
||||
s.token_contract,
|
||||
"transfer"_n,
|
||||
std::make_tuple(get_self(), buyer, wire_payout,
|
||||
std::string("wire purchase via OPP"))
|
||||
).send();
|
||||
}
|
||||
|
||||
// Queue outbound confirmation with the actual WIRE amount delivered
|
||||
std::vector<char> confirm_payload(payload.begin(), payload.end());
|
||||
// Append wire_output (8 bytes BE) so outpost knows what was delivered
|
||||
for (int i = 7; i >= 0; --i) {
|
||||
confirm_payload.push_back(char((wire_output >> (i * 8)) & 0xFF));
|
||||
}
|
||||
queue_outbound_message(assertion_type_wire_purchase, confirm_payload);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── 0xEE03: Operator Registration ──────────────────────────────────
|
||||
// An operator registered or deregistered on the outpost side.
|
||||
// Payload: wire_account(8) + op_type(1) + action(1) + secp_key(33) + ed_key(32) = 75 bytes
|
||||
case assertion_type_operator_registration: {
|
||||
check(payload.size() >= 10, "depot: operator_registration payload too short");
|
||||
const char* ptr = payload.data();
|
||||
|
||||
uint64_t acct_raw = read_be_u64(ptr);
|
||||
uint8_t op_type_v = uint8_t(ptr[8]);
|
||||
uint8_t reg_action = uint8_t(ptr[9]); // 0=register, 1=deregister
|
||||
|
||||
name wire_account(acct_raw);
|
||||
operator_type_t op_type = static_cast<operator_type_t>(op_type_v);
|
||||
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
auto by_account = ops.get_index<"byaccount"_n>();
|
||||
auto op_it = by_account.find(wire_account.value);
|
||||
|
||||
if (reg_action == 0) {
|
||||
// Register: create or reactivate operator
|
||||
if (op_it == by_account.end()) {
|
||||
// New registration — need keys from payload
|
||||
check(payload.size() >= 75,
|
||||
"depot: operator_registration register payload needs keys (75 bytes)");
|
||||
std::vector<char> secp_key(ptr + 10, ptr + 43);
|
||||
std::vector<char> ed_key(ptr + 43, ptr + 75);
|
||||
|
||||
ops.emplace(get_self(), [&](auto& o) {
|
||||
o.id = ops.available_primary_key();
|
||||
o.op_type = op_type;
|
||||
o.status = operator_status_warmup;
|
||||
o.wire_account = wire_account;
|
||||
o.secp256k1_pubkey = secp_key;
|
||||
o.ed25519_pubkey = ed_key;
|
||||
o.collateral = asset(0, symbol("WIRE", 4));
|
||||
o.registered_at = current_time_point();
|
||||
o.status_changed_at = current_time_point();
|
||||
});
|
||||
} else {
|
||||
// Re-registration: reactivate if exited or cooldown
|
||||
if (op_it->status == operator_status_exited ||
|
||||
op_it->status == operator_status_cooldown) {
|
||||
by_account.modify(op_it, same_payer, [&](auto& o) {
|
||||
o.status = operator_status_warmup;
|
||||
o.status_changed_at = current_time_point();
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (reg_action == 1) {
|
||||
// Deregister: move to cooldown
|
||||
if (op_it != by_account.end() &&
|
||||
op_it->status != operator_status_slashed &&
|
||||
op_it->status != operator_status_exited) {
|
||||
by_account.modify(op_it, same_payer, [&](auto& o) {
|
||||
o.status = operator_status_cooldown;
|
||||
o.status_changed_at = current_time_point();
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── 0xEE04: Challenge Response ─────────────────────────────────────
|
||||
// Inbound challenge data from the outpost (accept/reject/no_challenge).
|
||||
// Payload: epoch_number(8) + response_type(1) + data(N)
|
||||
// response_type: 0=no_challenge, 1=accept, 2=reject
|
||||
case assertion_type_challenge_response: {
|
||||
check(payload.size() >= 9, "depot: challenge_response payload too short");
|
||||
const char* ptr = payload.data();
|
||||
|
||||
uint64_t epoch_number = read_be_u64(ptr);
|
||||
uint8_t response_type = uint8_t(ptr[8]);
|
||||
|
||||
if (response_type == 0) {
|
||||
// no_challenge: outpost confirms no issue — resume if we were in challenge
|
||||
if (s.state == depot_state_challenge) {
|
||||
resume_from_challenge();
|
||||
}
|
||||
} else if (response_type == 1) {
|
||||
// accept: outpost acknowledges the challenge, enters challenge mode
|
||||
// The depot should already be in challenge state from the initial challenge() call
|
||||
// No additional action needed — wait for chalresp from operators
|
||||
} else if (response_type == 2) {
|
||||
// reject: outpost says the challenge is invalid
|
||||
// Find the active challenge for this epoch and resolve it
|
||||
challenges_table chals(get_self(), chain_scope);
|
||||
auto by_epoch = chals.get_index<"byepoch"_n>();
|
||||
auto chal_it = by_epoch.find(epoch_number);
|
||||
if (chal_it != by_epoch.end() &&
|
||||
chal_it->status != challenge_status_resolved) {
|
||||
by_epoch.modify(chal_it, same_payer, [&](auto& c) {
|
||||
c.status = challenge_status_resolved;
|
||||
});
|
||||
}
|
||||
// Resume normal operations
|
||||
if (s.state == depot_state_challenge) {
|
||||
resume_from_challenge();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── 0xEE05: Slash Operator ─────────────────────────────────────────
|
||||
// The outpost detected operator misbehavior and requests a slash.
|
||||
// Payload: operator_id(8) + reason_len(2) + reason(N)
|
||||
case assertion_type_slash_operator: {
|
||||
check(payload.size() >= 10, "depot: slash_operator payload too short");
|
||||
const char* ptr = payload.data();
|
||||
|
||||
uint64_t operator_id = read_be_u64(ptr);
|
||||
uint16_t reason_len = read_be_u16(ptr + 8);
|
||||
|
||||
// Validate reason length
|
||||
check(payload.size() >= 10 + reason_len,
|
||||
"depot: slash_operator reason exceeds payload");
|
||||
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
auto op_it = ops.find(operator_id);
|
||||
|
||||
if (op_it != ops.end() && op_it->status != operator_status_slashed) {
|
||||
ops.modify(op_it, same_payer, [&](auto& o) {
|
||||
o.status = operator_status_slashed;
|
||||
o.collateral.amount = 0; // DEC-006: collapse all collateral
|
||||
o.status_changed_at = current_time_point();
|
||||
});
|
||||
|
||||
// Distribute slashed collateral (challenger gets 50%)
|
||||
// For inbound slashes, we don't have a challenger account on the Wire side,
|
||||
// so the collateral goes to the depot itself (governance can redistribute)
|
||||
// distribute_slash(operator_id, get_self());
|
||||
}
|
||||
|
||||
// Queue outbound acknowledgment so outpost knows slash was applied
|
||||
queue_outbound_message(assertion_type_slash_operator, payload);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Unknown assertion type ─────────────────────────────────────────
|
||||
// Silently pass through unknown types. This prevents the deadlock bug
|
||||
// where unrecognized Ethereum-native types (2001-3006) killed the crank.
|
||||
default: {
|
||||
// Unknown assertion type — log and skip. Do not abort the transaction.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── expire_underwriting_locks (internal, FR-402) ─────────────────────────────
|
||||
|
||||
void depot::expire_underwriting_locks() {
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
underwrite_table ledger(get_self(), chain_scope);
|
||||
auto by_expiry = ledger.get_index<"byexpiry"_n>();
|
||||
uint64_t now_sec = current_time_point().sec_since_epoch();
|
||||
|
||||
auto it = by_expiry.begin();
|
||||
while (it != by_expiry.end() && it->unlock_at.sec_since_epoch() <= now_sec) {
|
||||
if (it->status == underwrite_status_intent_submitted ||
|
||||
it->status == underwrite_status_intent_confirmed) {
|
||||
by_expiry.modify(it, same_payer, [&](auto& e) {
|
||||
e.status = underwrite_status_expired;
|
||||
});
|
||||
}
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
// ── queue_outbound_message (internal) ────────────────────────────────────────
|
||||
|
||||
void depot::queue_outbound_message(assertion_type_t type, const std::vector<char>& payload) {
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
opp_out_table out(get_self(), chain_scope);
|
||||
uint64_t next_num = s.next_msg_out;
|
||||
|
||||
out.emplace(get_self(), [&](auto& m) {
|
||||
m.message_number = next_num;
|
||||
m.assertion_type = type;
|
||||
m.payload = payload;
|
||||
});
|
||||
|
||||
s.next_msg_out = next_num + 1;
|
||||
set_state(s);
|
||||
}
|
||||
|
||||
} // namespace sysio
|
||||
150
contracts/sysio.depot/src/underwriting.cpp
Normal file
150
contracts/sysio.depot/src/underwriting.cpp
Normal file
@@ -0,0 +1,150 @@
|
||||
#include <sysio.depot/sysio.depot.hpp>
|
||||
|
||||
namespace sysio {
|
||||
|
||||
// ── uwintent (FR-403) ───────────────────────────────────────────────────────
|
||||
//
|
||||
// An underwriter submits intent to underwrite a pending inbound message.
|
||||
// Verifies the underwriter has sufficient collateral, calculates the
|
||||
// exchange rate, checks threshold, creates a LOCK entry with 6hr expiry,
|
||||
// and queues an outbound confirmation.
|
||||
|
||||
void depot::uwintent(name underwriter,
|
||||
uint64_t message_id,
|
||||
asset source_amount,
|
||||
asset target_amount,
|
||||
chain_kind_t source_chain,
|
||||
chain_kind_t target_chain,
|
||||
std::vector<char> source_sig,
|
||||
std::vector<char> target_sig) {
|
||||
require_auth(underwriter);
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
check(s.state == depot_state_active, "depot: system not in active state");
|
||||
|
||||
// Resolve underwriter operator record
|
||||
known_operators_table ops(get_self(), chain_scope);
|
||||
auto by_account = ops.get_index<"byaccount"_n>();
|
||||
auto op_it = by_account.find(underwriter.value);
|
||||
check(op_it != by_account.end(), "depot: underwriter not registered");
|
||||
check(op_it->op_type == operator_type_underwriter, "depot: account is not an underwriter");
|
||||
check(op_it->status == operator_status_active, "depot: underwriter is not active");
|
||||
|
||||
// Verify the message exists and needs underwriting (status = PENDING)
|
||||
opp_in_table msgs(get_self(), chain_scope);
|
||||
auto msg_it = msgs.find(message_id);
|
||||
check(msg_it != msgs.end(), "depot: message not found");
|
||||
check(msg_it->status == message_status_pending, "depot: message is not pending underwriting");
|
||||
|
||||
// FR-403: Calculate exchange rate from reserve state
|
||||
uint64_t rate_bps = calculate_swap_rate(source_amount.symbol, target_amount.symbol, source_amount);
|
||||
|
||||
// FR-704: Verify rate is within acceptable threshold
|
||||
// The threshold prevents extreme slippage / manipulation
|
||||
check(check_rate_threshold(rate_bps, UNDERWRITE_FEE_BPS),
|
||||
"depot: exchange rate exceeds acceptable threshold");
|
||||
|
||||
// Verify collateral covers the underwriting amount
|
||||
check(op_it->collateral.amount >= target_amount.amount,
|
||||
"depot: insufficient collateral for underwriting");
|
||||
|
||||
// FR-403: Create underwriting ledger entry with 6hr LOCK
|
||||
underwrite_table ledger(get_self(), chain_scope);
|
||||
|
||||
time_point_sec unlock_time = time_point_sec(current_time_point().sec_since_epoch() + INTENT_LOCK_SECONDS);
|
||||
|
||||
ledger.emplace(get_self(), [&](auto& e) {
|
||||
e.id = ledger.available_primary_key();
|
||||
e.operator_id = op_it->id;
|
||||
e.status = underwrite_status_intent_submitted;
|
||||
e.source_amount = source_amount;
|
||||
e.target_amount = target_amount;
|
||||
e.source_chain = source_chain;
|
||||
e.target_chain = target_chain;
|
||||
e.exchange_rate_bps = rate_bps;
|
||||
e.unlock_at = unlock_time;
|
||||
e.created_at = current_time_point();
|
||||
e.source_tx_hash = checksum256(); // populated on confirm
|
||||
e.target_tx_hash = checksum256(); // populated on confirm
|
||||
});
|
||||
|
||||
// FR-404: Queue outbound intent confirmation to Outpost
|
||||
// The Outpost will use this to lock source funds
|
||||
std::vector<char> intent_payload;
|
||||
// Serialize: message_id + underwriter_id + source_amount + target_amount + rate
|
||||
// TODO: Proper OPP serialization format
|
||||
queue_outbound_message(assertion_type_wire_purchase, intent_payload);
|
||||
}
|
||||
|
||||
// ── uwconfirm (FR-404) ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Elected batch operator confirms an underwriting intent.
|
||||
// Transitions from INTENT_SUBMITTED to INTENT_CONFIRMED, extends
|
||||
// the lock to 24 hours, and marks the inbound message as READY.
|
||||
|
||||
void depot::uwconfirm(name operator_account, uint64_t ledger_entry_id) {
|
||||
require_auth(operator_account);
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
check(s.state == depot_state_active, "depot: system not in active state");
|
||||
|
||||
// Verify caller is an elected batch operator
|
||||
verify_elected(operator_account, s.current_epoch);
|
||||
|
||||
// Find ledger entry
|
||||
underwrite_table ledger(get_self(), chain_scope);
|
||||
auto entry = ledger.find(ledger_entry_id);
|
||||
check(entry != ledger.end(), "depot: underwriting entry not found");
|
||||
check(entry->status == underwrite_status_intent_submitted,
|
||||
"depot: entry must be in INTENT_SUBMITTED status to confirm");
|
||||
|
||||
// Verify lock hasn't expired
|
||||
check(entry->unlock_at > current_time_point(),
|
||||
"depot: underwriting intent has expired");
|
||||
|
||||
// FR-404: Transition to CONFIRMED, extend lock to 24 hours
|
||||
time_point_sec new_unlock = time_point_sec(current_time_point().sec_since_epoch() + CONFIRMED_LOCK_SECONDS);
|
||||
|
||||
ledger.modify(entry, same_payer, [&](auto& e) {
|
||||
e.status = underwrite_status_intent_confirmed;
|
||||
e.unlock_at = new_unlock;
|
||||
});
|
||||
}
|
||||
|
||||
// ── uwcancel ────────────────────────────────────────────────────────────────
|
||||
|
||||
void depot::uwcancel(name operator_account, uint64_t ledger_entry_id, std::string reason) {
|
||||
require_auth(operator_account);
|
||||
|
||||
auto s = get_state();
|
||||
uint64_t chain_scope = uint64_t(s.chain_id);
|
||||
|
||||
// Verify caller is an elected batch operator
|
||||
verify_elected(operator_account, s.current_epoch);
|
||||
|
||||
underwrite_table ledger(get_self(), chain_scope);
|
||||
auto entry = ledger.find(ledger_entry_id);
|
||||
check(entry != ledger.end(), "depot: underwriting entry not found");
|
||||
check(entry->status == underwrite_status_intent_submitted ||
|
||||
entry->status == underwrite_status_intent_confirmed,
|
||||
"depot: entry cannot be cancelled in current status");
|
||||
|
||||
ledger.modify(entry, same_payer, [&](auto& e) {
|
||||
e.status = underwrite_status_cancelled;
|
||||
});
|
||||
}
|
||||
|
||||
// ── uwexpire (FR-402) ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Permissionless cleanup of expired underwriting locks.
|
||||
// Anyone can call this to release expired entries.
|
||||
|
||||
void depot::uwexpire() {
|
||||
expire_underwriting_locks();
|
||||
}
|
||||
|
||||
} // namespace sysio
|
||||
Reference in New Issue
Block a user