diff --git a/artifacts/sysio.depot.v2-handlers.abi b/artifacts/sysio.depot.v2-handlers.abi new file mode 100644 index 0000000..a61f7c5 --- /dev/null +++ b/artifacts/sysio.depot.v2-handlers.abi @@ -0,0 +1,1056 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [ + { + "new_type_name": "assertion_type_t", + "type": "uint16" + }, + { + "new_type_name": "chain_kind_t", + "type": "uint8" + }, + { + "new_type_name": "chain_status_t", + "type": "uint8" + }, + { + "new_type_name": "challenge_status_t", + "type": "uint8" + }, + { + "new_type_name": "depot_state_t", + "type": "uint8" + }, + { + "new_type_name": "message_direction_t", + "type": "uint8" + }, + { + "new_type_name": "message_status_t", + "type": "uint8" + }, + { + "new_type_name": "operator_status_t", + "type": "uint8" + }, + { + "new_type_name": "operator_type_t", + "type": "uint8" + }, + { + "new_type_name": "underwrite_status_t", + "type": "uint8" + } + ], + "structs": [ + { + "name": "activateop", + "base": "", + "fields": [ + { + "name": "wire_account", + "type": "name" + } + ] + }, + { + "name": "bootstrap", + "base": "", + "fields": [] + }, + { + "name": "challenge", + "base": "", + "fields": [ + { + "name": "challenger", + "type": "name" + }, + { + "name": "epoch_number", + "type": "uint64" + }, + { + "name": "evidence", + "type": "bytes" + } + ] + }, + { + "name": "challenge_info", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + }, + { + "name": "epoch_number", + "type": "uint64" + }, + { + "name": "status", + "type": "challenge_status_t" + }, + { + "name": "round", + "type": "uint8" + }, + { + "name": "challenge_data", + "type": "bytes" + } + ] + }, + { + "name": "chalresolve", + "base": "", + "fields": [ + { + "name": "proposer", + "type": "name" + }, + { + "name": "challenge_id", + "type": "uint64" + }, + { + "name": "original_hash", + "type": "checksum256" + }, + { + "name": "round1_hash", + "type": "checksum256" + }, + { + "name": "round2_hash", + "type": "checksum256" + } + ] + }, + { + "name": "chalresp", + "base": "", + "fields": [ + { + "name": "operator_account", + "type": "name" + }, + { + "name": "challenge_id", + "type": "uint64" + }, + { + "name": "response_data", + "type": "bytes" + } + ] + }, + { + "name": "chalvote", + "base": "", + "fields": [ + { + "name": "voter", + "type": "name" + }, + { + "name": "challenge_id", + "type": "uint64" + }, + { + "name": "approve", + "type": "bool" + } + ] + }, + { + "name": "crank", + "base": "", + "fields": [ + { + "name": "operator_account", + "type": "name" + } + ] + }, + { + "name": "depot_global_state", + "base": "", + "fields": [ + { + "name": "state", + "type": "depot_state_t" + }, + { + "name": "chain_id", + "type": "chain_kind_t" + }, + { + "name": "current_epoch", + "type": "uint64" + }, + { + "name": "next_epoch", + "type": "uint64" + }, + { + "name": "next_msg_out", + "type": "uint64" + }, + { + "name": "last_crank_time", + "type": "time_point_sec" + }, + { + "name": "token_contract", + "type": "name" + }, + { + "name": "initialized", + "type": "bool" + } + ] + }, + { + "name": "emitchain", + "base": "", + "fields": [ + { + "name": "operator_account", + "type": "name" + }, + { + "name": "epoch_number", + "type": "uint64" + } + ] + }, + { + "name": "epoch_vote", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + }, + { + "name": "epoch_number", + "type": "uint64" + }, + { + "name": "operator_id", + "type": "uint64" + }, + { + "name": "chain_hash", + "type": "checksum256" + }, + { + "name": "submitted_at", + "type": "time_point_sec" + } + ] + }, + { + "name": "exitop", + "base": "", + "fields": [ + { + "name": "wire_account", + "type": "name" + } + ] + }, + { + "name": "getquote", + "base": "", + "fields": [ + { + "name": "source_sym", + "type": "symbol" + }, + { + "name": "target_sym", + "type": "symbol" + }, + { + "name": "amount", + "type": "asset" + } + ] + }, + { + "name": "init", + "base": "", + "fields": [ + { + "name": "chain_id", + "type": "chain_kind_t" + }, + { + "name": "token_contract", + "type": "name" + } + ] + }, + { + "name": "known_operator", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + }, + { + "name": "op_type", + "type": "operator_type_t" + }, + { + "name": "status", + "type": "operator_status_t" + }, + { + "name": "wire_account", + "type": "name" + }, + { + "name": "secp256k1_pubkey", + "type": "bytes" + }, + { + "name": "ed25519_pubkey", + "type": "bytes" + }, + { + "name": "collateral", + "type": "asset" + }, + { + "name": "registered_at", + "type": "time_point_sec" + }, + { + "name": "status_changed_at", + "type": "time_point_sec" + } + ] + }, + { + "name": "message_chain", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + }, + { + "name": "direction", + "type": "message_direction_t" + }, + { + "name": "status", + "type": "chain_status_t" + }, + { + "name": "epoch_number", + "type": "uint64" + }, + { + "name": "merkle_root", + "type": "checksum256" + }, + { + "name": "epoch_hash", + "type": "checksum256" + }, + { + "name": "prev_epoch_hash", + "type": "checksum256" + }, + { + "name": "payload", + "type": "bytes" + }, + { + "name": "operator_signature", + "type": "bytes" + }, + { + "name": "operator_id", + "type": "uint64" + }, + { + "name": "created_at", + "type": "time_point_sec" + } + ] + }, + { + "name": "oneshot", + "base": "", + "fields": [ + { + "name": "beneficiary", + "type": "name" + }, + { + "name": "amount", + "type": "asset" + } + ] + }, + { + "name": "op_schedule", + "base": "", + "fields": [ + { + "name": "epoch_number", + "type": "uint64" + }, + { + "name": "elected_operator_ids", + "type": "uint64[]" + }, + { + "name": "created_at", + "type": "time_point_sec" + } + ] + }, + { + "name": "opp_epoch_in", + "base": "", + "fields": [ + { + "name": "epoch_number", + "type": "uint64" + }, + { + "name": "start_message", + "type": "uint64" + }, + { + "name": "end_message", + "type": "uint64" + }, + { + "name": "epoch_merkle", + "type": "checksum256" + }, + { + "name": "challenge_flag", + "type": "bool" + } + ] + }, + { + "name": "opp_epoch_out", + "base": "", + "fields": [ + { + "name": "epoch_number", + "type": "uint64" + }, + { + "name": "start_message", + "type": "uint64" + }, + { + "name": "end_message", + "type": "uint64" + }, + { + "name": "merkle_root", + "type": "checksum256" + } + ] + }, + { + "name": "opp_fork", + "base": "", + "fields": [ + { + "name": "fork_id", + "type": "uint64" + }, + { + "name": "epoch_number", + "type": "uint64" + }, + { + "name": "end_message_id", + "type": "uint64" + }, + { + "name": "merkle_root", + "type": "checksum256" + } + ] + }, + { + "name": "opp_fork_vote", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + }, + { + "name": "fork_id", + "type": "uint64" + }, + { + "name": "voter", + "type": "name" + }, + { + "name": "vote_state", + "type": "uint8" + } + ] + }, + { + "name": "opp_message_in", + "base": "", + "fields": [ + { + "name": "message_number", + "type": "uint64" + }, + { + "name": "assertion_type", + "type": "assertion_type_t" + }, + { + "name": "status", + "type": "message_status_t" + }, + { + "name": "payload", + "type": "bytes" + } + ] + }, + { + "name": "opp_message_out", + "base": "", + "fields": [ + { + "name": "message_number", + "type": "uint64" + }, + { + "name": "assertion_type", + "type": "assertion_type_t" + }, + { + "name": "payload", + "type": "bytes" + } + ] + }, + { + "name": "regoperator", + "base": "", + "fields": [ + { + "name": "wire_account", + "type": "name" + }, + { + "name": "op_type", + "type": "operator_type_t" + }, + { + "name": "secp256k1_pubkey", + "type": "bytes" + }, + { + "name": "ed25519_pubkey", + "type": "bytes" + }, + { + "name": "collateral", + "type": "asset" + } + ] + }, + { + "name": "reserve_balance", + "base": "", + "fields": [ + { + "name": "reserve_total", + "type": "asset" + }, + { + "name": "wire_equivalent", + "type": "asset" + } + ] + }, + { + "name": "setreserve", + "base": "", + "fields": [ + { + "name": "authority", + "type": "name" + }, + { + "name": "reserve_total", + "type": "asset" + }, + { + "name": "wire_equivalent", + "type": "asset" + } + ] + }, + { + "name": "slashop", + "base": "", + "fields": [ + { + "name": "wire_account", + "type": "name" + }, + { + "name": "reason", + "type": "string" + } + ] + }, + { + "name": "submitchain", + "base": "", + "fields": [ + { + "name": "operator_account", + "type": "name" + }, + { + "name": "epoch_number", + "type": "uint64" + }, + { + "name": "epoch_hash", + "type": "checksum256" + }, + { + "name": "prev_epoch_hash", + "type": "checksum256" + }, + { + "name": "merkle_root", + "type": "checksum256" + }, + { + "name": "signature", + "type": "bytes" + } + ] + }, + { + "name": "underwrite_entry", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + }, + { + "name": "operator_id", + "type": "uint64" + }, + { + "name": "status", + "type": "underwrite_status_t" + }, + { + "name": "source_amount", + "type": "asset" + }, + { + "name": "target_amount", + "type": "asset" + }, + { + "name": "source_chain", + "type": "chain_kind_t" + }, + { + "name": "target_chain", + "type": "chain_kind_t" + }, + { + "name": "exchange_rate_bps", + "type": "uint64" + }, + { + "name": "unlock_at", + "type": "time_point_sec" + }, + { + "name": "created_at", + "type": "time_point_sec" + }, + { + "name": "source_tx_hash", + "type": "checksum256" + }, + { + "name": "target_tx_hash", + "type": "checksum256" + } + ] + }, + { + "name": "unregop", + "base": "", + "fields": [ + { + "name": "wire_account", + "type": "name" + } + ] + }, + { + "name": "updreserve", + "base": "", + "fields": [ + { + "name": "operator_account", + "type": "name" + }, + { + "name": "token_sym", + "type": "symbol" + }, + { + "name": "delta", + "type": "int64" + } + ] + }, + { + "name": "uploadmsgs", + "base": "", + "fields": [ + { + "name": "operator_account", + "type": "name" + }, + { + "name": "epoch_number", + "type": "uint64" + }, + { + "name": "messages", + "type": "bytes" + }, + { + "name": "merkle_proofs", + "type": "bytes" + } + ] + }, + { + "name": "uwcancel", + "base": "", + "fields": [ + { + "name": "operator_account", + "type": "name" + }, + { + "name": "ledger_entry_id", + "type": "uint64" + }, + { + "name": "reason", + "type": "string" + } + ] + }, + { + "name": "uwconfirm", + "base": "", + "fields": [ + { + "name": "operator_account", + "type": "name" + }, + { + "name": "ledger_entry_id", + "type": "uint64" + } + ] + }, + { + "name": "uwexpire", + "base": "", + "fields": [] + }, + { + "name": "uwintent", + "base": "", + "fields": [ + { + "name": "underwriter", + "type": "name" + }, + { + "name": "message_id", + "type": "uint64" + }, + { + "name": "source_amount", + "type": "asset" + }, + { + "name": "target_amount", + "type": "asset" + }, + { + "name": "source_chain", + "type": "chain_kind_t" + }, + { + "name": "target_chain", + "type": "chain_kind_t" + }, + { + "name": "source_sig", + "type": "bytes" + }, + { + "name": "target_sig", + "type": "bytes" + } + ] + } + ], + "actions": [ + { + "name": "activateop", + "type": "activateop", + "ricardian_contract": "" + }, + { + "name": "bootstrap", + "type": "bootstrap", + "ricardian_contract": "" + }, + { + "name": "challenge", + "type": "challenge", + "ricardian_contract": "" + }, + { + "name": "chalresolve", + "type": "chalresolve", + "ricardian_contract": "" + }, + { + "name": "chalresp", + "type": "chalresp", + "ricardian_contract": "" + }, + { + "name": "chalvote", + "type": "chalvote", + "ricardian_contract": "" + }, + { + "name": "crank", + "type": "crank", + "ricardian_contract": "" + }, + { + "name": "emitchain", + "type": "emitchain", + "ricardian_contract": "" + }, + { + "name": "exitop", + "type": "exitop", + "ricardian_contract": "" + }, + { + "name": "getquote", + "type": "getquote", + "ricardian_contract": "" + }, + { + "name": "init", + "type": "init", + "ricardian_contract": "" + }, + { + "name": "oneshot", + "type": "oneshot", + "ricardian_contract": "" + }, + { + "name": "regoperator", + "type": "regoperator", + "ricardian_contract": "" + }, + { + "name": "setreserve", + "type": "setreserve", + "ricardian_contract": "" + }, + { + "name": "slashop", + "type": "slashop", + "ricardian_contract": "" + }, + { + "name": "submitchain", + "type": "submitchain", + "ricardian_contract": "" + }, + { + "name": "unregop", + "type": "unregop", + "ricardian_contract": "" + }, + { + "name": "updreserve", + "type": "updreserve", + "ricardian_contract": "" + }, + { + "name": "uploadmsgs", + "type": "uploadmsgs", + "ricardian_contract": "" + }, + { + "name": "uwcancel", + "type": "uwcancel", + "ricardian_contract": "" + }, + { + "name": "uwconfirm", + "type": "uwconfirm", + "ricardian_contract": "" + }, + { + "name": "uwexpire", + "type": "uwexpire", + "ricardian_contract": "" + }, + { + "name": "uwintent", + "type": "uwintent", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "challenges", + "type": "challenge_info", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "depotstate", + "type": "depot_global_state", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "epochvotes", + "type": "epoch_vote", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "knownops", + "type": "known_operator", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "msgchains", + "type": "message_chain", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "oppepochin", + "type": "opp_epoch_in", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "oppepochout", + "type": "opp_epoch_out", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "oppforks", + "type": "opp_fork", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "oppforkvote", + "type": "opp_fork_vote", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "oppin", + "type": "opp_message_in", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "oppout", + "type": "opp_message_out", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "opschedule", + "type": "op_schedule", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "reserves", + "type": "reserve_balance", + "index_type": "i64", + "key_names": [], + "key_types": [] + }, + { + "name": "uwledger", + "type": "underwrite_entry", + "index_type": "i64", + "key_names": [], + "key_types": [] + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [] +} \ No newline at end of file diff --git a/artifacts/sysio.depot.v2-handlers.wasm b/artifacts/sysio.depot.v2-handlers.wasm new file mode 100755 index 0000000..f663502 Binary files /dev/null and b/artifacts/sysio.depot.v2-handlers.wasm differ diff --git a/contracts/sysio.depot/include/sysio.depot/depot.tables.hpp b/contracts/sysio.depot/include/sysio.depot/depot.tables.hpp new file mode 100644 index 0000000..dacc399 --- /dev/null +++ b/contracts/sysio.depot/include/sysio.depot/depot.tables.hpp @@ -0,0 +1,273 @@ +#pragma once + +#include + +#include +#include +#include + +#include +#include + + +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 secp256k1_pubkey; // 33 bytes compressed + std::vector 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>, + indexed_by<"byaccount"_n, const_mem_fun>, + indexed_by<"bysecppub"_n, const_mem_fun> + >; + + // ═══════════════════════════════════════════════════════════════════════════ + // 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 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 payload; + std::vector 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>, + indexed_by<"bystatus"_n, const_mem_fun> + >; + + // ═══════════════════════════════════════════════════════════════════════════ + // 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>, + indexed_by<"bystatus"_n, const_mem_fun>, + indexed_by<"byexpiry"_n, const_mem_fun> + >; + + // ═══════════════════════════════════════════════════════════════════════════ + // 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 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> + >; + + // ═══════════════════════════════════════════════════════════════════════════ + // 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 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 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> + >; + + // ═══════════════════════════════════════════════════════════════════════════ + // 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>, + indexed_by<"byepoch"_n, const_mem_fun> + >; + + // ═══════════════════════════════════════════════════════════════════════════ + // 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 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> + >; + +} // namespace sysio diff --git a/contracts/sysio.depot/include/sysio.depot/depot.types.hpp b/contracts/sysio.depot/include/sysio.depot/depot.types.hpp new file mode 100644 index 0000000..aad9992 --- /dev/null +++ b/contracts/sysio.depot/include/sysio.depot/depot.types.hpp @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include + +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 diff --git a/contracts/sysio.depot/include/sysio.depot/sysio.depot.hpp b/contracts/sysio.depot/include/sysio.depot/sysio.depot.hpp new file mode 100644 index 0000000..3728637 --- /dev/null +++ b/contracts/sysio.depot/include/sysio.depot/sysio.depot.hpp @@ -0,0 +1,166 @@ +#pragma once + +#include + +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 signature); + + [[sysio::action]] + void uploadmsgs(name operator_account, + uint64_t epoch_number, + std::vector messages, + std::vector 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 source_sig, + std::vector 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 evidence); + + [[sysio::action]] + void chalresp(name operator_account, uint64_t challenge_id, std::vector 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 secp256k1_pubkey, + std::vector 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& payload); + void queue_outbound_message(assertion_type_t type, const std::vector& 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 diff --git a/contracts/sysio.depot/src/challenge.cpp b/contracts/sysio.depot/src/challenge.cpp new file mode 100644 index 0000000..b83ba69 --- /dev/null +++ b/contracts/sysio.depot/src/challenge.cpp @@ -0,0 +1,352 @@ +#include + +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 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 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 diff --git a/contracts/sysio.depot/src/consensus.cpp b/contracts/sysio.depot/src/consensus.cpp new file mode 100644 index 0000000..de99612 --- /dev/null +++ b/contracts/sysio.depot/src/consensus.cpp @@ -0,0 +1,146 @@ +#include + +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_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 slash_payload; + queue_outbound_message(assertion_type_slash_operator, slash_payload); + } + } + ++vit; + } +} + +} // namespace sysio diff --git a/contracts/sysio.depot/src/operators.cpp b/contracts/sysio.depot/src/operators.cpp new file mode 100644 index 0000000..14d74ef --- /dev/null +++ b/contracts/sysio.depot/src/operators.cpp @@ -0,0 +1,248 @@ +#include +#include + +namespace sysio { + +// ── regoperator (FR-602) ───────────────────────────────────────────────────── + +void depot::regoperator(name wire_account, + operator_type_t op_type, + std::vector secp256k1_pubkey, + std::vector 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 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 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 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 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 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 diff --git a/contracts/sysio.depot/src/opp_inbound.cpp b/contracts/sysio.depot/src/opp_inbound.cpp new file mode 100644 index 0000000..403413d --- /dev/null +++ b/contracts/sysio.depot/src/opp_inbound.cpp @@ -0,0 +1,219 @@ +#include + +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 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 messages, + std::vector 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 payload(ptr, ptr + plen); + ptr += plen; + + assertion_type_t assertion = static_cast(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 diff --git a/contracts/sysio.depot/src/opp_outbound.cpp b/contracts/sysio.depot/src/opp_outbound.cpp new file mode 100644 index 0000000..86ba947 --- /dev/null +++ b/contracts/sysio.depot/src/opp_outbound.cpp @@ -0,0 +1,120 @@ +#include + +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 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 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 diff --git a/contracts/sysio.depot/src/reserves.cpp b/contracts/sysio.depot/src/reserves.cpp new file mode 100644 index 0000000..1c8f322 --- /dev/null +++ b/contracts/sysio.depot/src/reserves.cpp @@ -0,0 +1,181 @@ +#include + +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 diff --git a/contracts/sysio.depot/src/sysio.depot.cpp b/contracts/sysio.depot/src/sysio.depot.cpp new file mode 100644 index 0000000..3770d8b --- /dev/null +++ b/contracts/sysio.depot/src/sysio.depot.cpp @@ -0,0 +1,545 @@ +#include + +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(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& 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 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 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(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 secp_key(ptr + 10, ptr + 43); + std::vector 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& 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 diff --git a/contracts/sysio.depot/src/underwriting.cpp b/contracts/sysio.depot/src/underwriting.cpp new file mode 100644 index 0000000..999e78b --- /dev/null +++ b/contracts/sysio.depot/src/underwriting.cpp @@ -0,0 +1,150 @@ +#include + +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 source_sig, + std::vector 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 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