depot contract v2: all 7 process_assertion handlers + deadlock fix

- balance_sheet (0xAA00): deserialize + upsert reserves
- stake_update (0xEE00): update reserves + queue outbound
- yield_reward (0xEE01): calculate + inline transfer WIRE
- wire_purchase (0xEE02): constant-product swap + transfer
- operator_registration (0xEE03): create/reactivate/deregister
- challenge_response (0xEE04): route to challenge FSM
- slash_operator (0xEE05): slash + queue outbound ack
- default: silently pass unknown types (fixes permanent deadlock)
- includes compiled wasm (110,570 bytes) and ABI
This commit is contained in:
2026-03-13 17:11:31 +00:00
parent d8e2e25e38
commit 10a8884fc9
13 changed files with 3562 additions and 0 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,273 @@
#pragma once
#include <sysio/sysio.hpp>
#include <sysio.depot/depot.types.hpp>
#include <sysio/singleton.hpp>
#include <sysio/crypto.hpp>
#include <cmath>
#include <limits>
namespace sysio {
// ═══════════════════════════════════════════════════════════════════════════
// Global depot state (singleton)
// ═══════════════════════════════════════════════════════════════════════════
struct [[sysio::table("depotstate"), sysio::contract("sysio.depot")]] depot_global_state {
depot_state_t state = depot_state_active;
chain_kind_t chain_id = fc::crypto::chain_kind_unknown;
uint64_t current_epoch = 0;
uint64_t next_epoch = 1;
uint64_t next_msg_out = 0; // monotonic outbound message counter
time_point_sec last_crank_time;
name token_contract; // sysio.token or similar
bool initialized = false;
SYSLIB_SERIALIZE(depot_global_state,
(state)(chain_id)(current_epoch)(next_epoch)(next_msg_out)
(last_crank_time)(token_contract)(initialized))
};
using depot_state_singleton = sysio::singleton<"depotstate"_n, depot_global_state>;
// ═══════════════════════════════════════════════════════════════════════════
// Known operators — scoped by chain_kind_t (FR-601)
// ═══════════════════════════════════════════════════════════════════════════
struct [[sysio::table("knownops"), sysio::contract("sysio.depot")]] known_operator {
uint64_t id;
operator_type_t op_type;
operator_status_t status;
name wire_account;
std::vector<char> secp256k1_pubkey; // 33 bytes compressed
std::vector<char> ed25519_pubkey; // 32 bytes
asset collateral;
time_point_sec registered_at;
time_point_sec status_changed_at;
uint64_t primary_key() const { return id; }
uint128_t by_type_status() const { return (uint128_t(op_type) << 64) | uint64_t(status); }
uint64_t by_account() const { return wire_account.value; }
checksum256 by_secp_pubkey() const {
return sha256(secp256k1_pubkey.data(), secp256k1_pubkey.size());
}
};
using known_operators_table = multi_index<"knownops"_n, known_operator,
indexed_by<"bytypestatus"_n, const_mem_fun<known_operator, uint128_t, &known_operator::by_type_status>>,
indexed_by<"byaccount"_n, const_mem_fun<known_operator, uint64_t, &known_operator::by_account>>,
indexed_by<"bysecppub"_n, const_mem_fun<known_operator, checksum256, &known_operator::by_secp_pubkey>>
>;
// ═══════════════════════════════════════════════════════════════════════════
// Operator election schedule — scoped by chain_kind_t (FR-604)
// ═══════════════════════════════════════════════════════════════════════════
struct [[sysio::table("opschedule"), sysio::contract("sysio.depot")]] op_schedule {
uint64_t epoch_number;
std::vector<uint64_t> elected_operator_ids; // MAX_BATCH_OPERATORS_PER_EPOCH entries
time_point_sec created_at;
uint64_t primary_key() const { return epoch_number; }
};
using op_schedule_table = multi_index<"opschedule"_n, op_schedule>;
// ═══════════════════════════════════════════════════════════════════════════
// Message chain — scoped by chain_kind_t (FR-101)
// ═══════════════════════════════════════════════════════════════════════════
struct [[sysio::table("msgchains"), sysio::contract("sysio.depot")]] message_chain {
uint64_t id;
message_direction_t direction;
chain_status_t status;
uint64_t epoch_number;
checksum256 merkle_root;
checksum256 epoch_hash;
checksum256 prev_epoch_hash;
std::vector<char> payload;
std::vector<char> operator_signature;
uint64_t operator_id; // ref → known_operator
time_point_sec created_at;
uint64_t primary_key() const { return id; }
uint128_t by_epoch_dir() const { return (uint128_t(epoch_number) << 8) | uint64_t(direction); }
uint64_t by_status() const { return uint64_t(status); }
};
using message_chains_table = multi_index<"msgchains"_n, message_chain,
indexed_by<"byepochdir"_n, const_mem_fun<message_chain, uint128_t, &message_chain::by_epoch_dir>>,
indexed_by<"bystatus"_n, const_mem_fun<message_chain, uint64_t, &message_chain::by_status>>
>;
// ═══════════════════════════════════════════════════════════════════════════
// Reserve balance state — scoped by chain_kind_t (FR-701)
// ═══════════════════════════════════════════════════════════════════════════
struct [[sysio::table("reserves"), sysio::contract("sysio.depot")]] reserve_balance {
asset reserve_total;
asset wire_equivalent;
uint64_t primary_key() const { return reserve_total.symbol.code().raw(); }
};
using reserves_table = multi_index<"reserves"_n, reserve_balance>;
// ═══════════════════════════════════════════════════════════════════════════
// Underwriting ledger — scoped by chain_kind_t (FR-401)
// ═══════════════════════════════════════════════════════════════════════════
struct [[sysio::table("uwledger"), sysio::contract("sysio.depot")]] underwrite_entry {
uint64_t id;
uint64_t operator_id;
underwrite_status_t status;
asset source_amount;
asset target_amount;
chain_kind_t source_chain;
chain_kind_t target_chain;
uint64_t exchange_rate_bps;
time_point_sec unlock_at;
time_point_sec created_at;
checksum256 source_tx_hash;
checksum256 target_tx_hash;
uint64_t primary_key() const { return id; }
uint64_t by_underwriter() const { return operator_id; }
uint64_t by_status() const { return uint64_t(status); }
uint64_t by_expiry() const { return unlock_at.sec_since_epoch(); }
};
using underwrite_table = multi_index<"uwledger"_n, underwrite_entry,
indexed_by<"byuw"_n, const_mem_fun<underwrite_entry, uint64_t, &underwrite_entry::by_underwriter>>,
indexed_by<"bystatus"_n, const_mem_fun<underwrite_entry, uint64_t, &underwrite_entry::by_status>>,
indexed_by<"byexpiry"_n, const_mem_fun<underwrite_entry, uint64_t, &underwrite_entry::by_expiry>>
>;
// ═══════════════════════════════════════════════════════════════════════════
// Inbound epoch tracking — scoped by chain_kind_t
// ═══════════════════════════════════════════════════════════════════════════
struct [[sysio::table("oppepochin"), sysio::contract("sysio.depot")]] opp_epoch_in {
uint64_t epoch_number;
uint64_t start_message;
uint64_t end_message;
checksum256 epoch_merkle;
bool challenge_flag = false;
uint64_t primary_key() const { return epoch_number; }
};
using opp_epoch_in_table = multi_index<"oppepochin"_n, opp_epoch_in>;
// ═══════════════════════════════════════════════════════════════════════════
// Outbound epoch tracking — scoped by chain_kind_t
// ═══════════════════════════════════════════════════════════════════════════
struct [[sysio::table("oppepochout"), sysio::contract("sysio.depot")]] opp_epoch_out {
uint64_t epoch_number;
uint64_t start_message;
uint64_t end_message;
checksum256 merkle_root;
uint64_t primary_key() const { return epoch_number; }
};
using opp_epoch_out_table = multi_index<"oppepochout"_n, opp_epoch_out>;
// ═══════════════════════════════════════════════════════════════════════════
// Inbound message queue — scoped by chain_kind_t
// ═══════════════════════════════════════════════════════════════════════════
struct [[sysio::table("oppin"), sysio::contract("sysio.depot")]] opp_message_in {
uint64_t message_number;
assertion_type_t assertion_type;
message_status_t status;
std::vector<char> payload;
uint64_t primary_key() const { return message_number; }
uint64_t by_status() const { return uint64_t(status); }
};
using opp_in_table = multi_index<"oppin"_n, opp_message_in,
indexed_by<"bystatus"_n, const_mem_fun<opp_message_in, uint64_t, &opp_message_in::by_status>>
>;
// ═══════════════════════════════════════════════════════════════════════════
// Outbound message queue — scoped by chain_kind_t
// ═══════════════════════════════════════════════════════════════════════════
struct [[sysio::table("oppout"), sysio::contract("sysio.depot")]] opp_message_out {
uint64_t message_number;
assertion_type_t assertion_type;
std::vector<char> payload;
uint64_t primary_key() const { return message_number; }
};
using opp_out_table = multi_index<"oppout"_n, opp_message_out>;
// ═══════════════════════════════════════════════════════════════════════════
// Challenge tracking — scoped by chain_kind_t (FR-500)
// ═══════════════════════════════════════════════════════════════════════════
struct [[sysio::table("challenges"), sysio::contract("sysio.depot")]] challenge_info {
uint64_t id;
uint64_t epoch_number;
challenge_status_t status;
uint8_t round;
std::vector<char> challenge_data;
uint64_t primary_key() const { return id; }
uint64_t by_epoch() const { return epoch_number; }
};
using challenges_table = multi_index<"challenges"_n, challenge_info,
indexed_by<"byepoch"_n, const_mem_fun<challenge_info, uint64_t, &challenge_info::by_epoch>>
>;
// ═══════════════════════════════════════════════════════════════════════════
// Epoch consensus vote tracking — scoped by chain_kind_t (FR-301)
// ═══════════════════════════════════════════════════════════════════════════
struct [[sysio::table("epochvotes"), sysio::contract("sysio.depot")]] epoch_vote {
uint64_t id;
uint64_t epoch_number;
uint64_t operator_id;
checksum256 chain_hash;
time_point_sec submitted_at;
uint64_t primary_key() const { return id; }
uint128_t by_epoch_op() const { return (uint128_t(epoch_number) << 64) | operator_id; }
uint64_t by_epoch() const { return epoch_number; }
};
using epoch_votes_table = multi_index<"epochvotes"_n, epoch_vote,
indexed_by<"byepochop"_n, const_mem_fun<epoch_vote, uint128_t, &epoch_vote::by_epoch_op>>,
indexed_by<"byepoch"_n, const_mem_fun<epoch_vote, uint64_t, &epoch_vote::by_epoch>>
>;
// ═══════════════════════════════════════════════════════════════════════════
// OPP fork tracking — scoped by chain_kind_t
// ═══════════════════════════════════════════════════════════════════════════
struct [[sysio::table("oppforks"), sysio::contract("sysio.depot")]] opp_fork {
uint64_t fork_id;
uint64_t epoch_number;
uint64_t end_message_id;
checksum256 merkle_root;
uint64_t primary_key() const { return fork_id; }
uint64_t by_epoch() const { return epoch_number; }
};
using opp_forks_table = multi_index<"oppforks"_n, opp_fork,
indexed_by<"byepoch"_n, const_mem_fun<opp_fork, uint64_t, &opp_fork::by_epoch>>
>;
// ═══════════════════════════════════════════════════════════════════════════
// OPP fork votes — scoped by chain_kind_t
// ═══════════════════════════════════════════════════════════════════════════
struct [[sysio::table("oppforkvote"), sysio::contract("sysio.depot")]] opp_fork_vote {
uint64_t id;
uint64_t fork_id;
name voter;
uint8_t vote_state; // 0=pending, 1=accept, 2=reject
uint64_t primary_key() const { return id; }
uint128_t by_fork_user() const { return (uint128_t(fork_id) << 64) | voter.value; }
};
using opp_fork_votes_table = multi_index<"oppforkvote"_n, opp_fork_vote,
indexed_by<"byforkuser"_n, const_mem_fun<opp_fork_vote, uint128_t, &opp_fork_vote::by_fork_user>>
>;
} // namespace sysio

View File

@@ -0,0 +1,106 @@
#pragma once
#include <fc-lite/crypto/chain_types.hpp>
#include <sysio/sysio.hpp>
#include <sysio/asset.hpp>
namespace sysio {
using fc::crypto::chain_kind_t;
using fc::crypto::chain_key_type_t;
// ── Message chain direction ──────────────────────────────────────────────
enum message_direction_t : uint8_t {
message_direction_inbound = 0, // Outpost -> Depot
message_direction_outbound = 1, // Depot -> Outpost
};
// ── Message chain lifecycle status ───────────────────────────────────────
enum chain_status_t : uint8_t {
chain_status_created = 0,
chain_status_pending = 1,
chain_status_challenged = 2,
chain_status_valid = 3,
chain_status_slashed = 4, // terminal
};
// ── Individual message processing status ─────────────────────────────────
enum message_status_t : uint8_t {
message_status_pending = 0, // needs underwriting
message_status_ready = 1, // ready for processing
};
// ── Operator type ────────────────────────────────────────────────────────
enum operator_type_t : uint8_t {
operator_type_node = 0, // node / producer
operator_type_batch = 1,
operator_type_underwriter = 2,
operator_type_challenger = 3,
};
// ── Operator lifecycle status ────────────────────────────────────────────
enum operator_status_t : uint8_t {
operator_status_warmup = 0,
operator_status_active = 1,
operator_status_cooldown = 2,
operator_status_exited = 3, // graceful exit after cooldown
operator_status_slashed = 4, // terminal, collateral collapsed
};
// ── Challenge status ─────────────────────────────────────────────────────
enum challenge_status_t : uint8_t {
challenge_status_none = 0,
challenge_status_round1_pending = 1,
challenge_status_round1_complete = 2,
challenge_status_round2_pending = 3,
challenge_status_round2_complete = 4,
challenge_status_paused = 5, // global pause, awaiting manual
challenge_status_resolved = 6,
};
// ── Underwriting ledger entry status ─────────────────────────────────────
enum underwrite_status_t : uint8_t {
underwrite_status_intent_submitted = 0,
underwrite_status_intent_confirmed = 1,
underwrite_status_expired = 2,
underwrite_status_cancelled = 3,
};
// ── Depot global state ───────────────────────────────────────────────────
enum depot_state_t : uint8_t {
depot_state_active = 0,
depot_state_challenge = 1, // normal msg processing suspended
depot_state_paused = 2, // global pause, manual intervention required
};
// ── OPP Assertion type IDs (from OPP Assertion Catalog) ──────────────────
enum assertion_type_t : uint16_t {
assertion_type_balance_sheet = 0xAA00,
assertion_type_stake_update = 0xEE00,
assertion_type_yield_reward = 0xEE01,
assertion_type_wire_purchase = 0xEE02,
assertion_type_operator_registration = 0xEE03,
assertion_type_challenge_response = 0xEE04,
assertion_type_slash_operator = 0xEE05,
};
// ── Constants ────────────────────────────────────────────────────────────
static constexpr uint32_t MAX_BATCH_OPERATORS_PER_EPOCH = 7;
static constexpr uint32_t TOTAL_BATCH_OPERATORS = 21;
static constexpr uint32_t CONSENSUS_MAJORITY = 4; // of 7
static constexpr uint32_t MAX_CHALLENGE_ROUNDS = 2;
static constexpr uint32_t INTENT_LOCK_SECONDS = 6 * 3600; // 6 hours
static constexpr uint32_t CONFIRMED_LOCK_SECONDS = 24 * 3600; // 24 hours
static constexpr uint64_t UNDERWRITE_FEE_BPS = 10; // 0.1% = 10 basis points
// Slash distribution (basis points out of 10 000)
static constexpr uint64_t SLASH_CHALLENGER_BPS = 5000; // 50 %
static constexpr uint64_t SLASH_UNDERWRITERS_BPS = 2500; // 25 %
static constexpr uint64_t SLASH_BATCH_OPERATORS_BPS = 2500; // 25 %
// Manual resolution vote threshold
static constexpr uint64_t RESOLUTION_VOTE_THRESHOLD_BPS = 6667; // ≈ 2/3
} // namespace sysio

View File

@@ -0,0 +1,166 @@
#pragma once
#include <sysio.depot/depot.tables.hpp>
namespace sysio {
class [[sysio::contract("sysio.depot")]] depot : public contract {
public:
using contract::contract;
// ── Initialization ────────────────────────────────────────────────────
[[sysio::action]]
void init(chain_kind_t chain_id, name token_contract);
// ── Bootstrap (testnet only): seed initial epoch schedule ─────────────
[[sysio::action]]
void bootstrap();
// ── FR-800: Crank Execution ───────────────────────────────────────────
[[sysio::action]]
void crank(name operator_account);
// ── FR-100: OPP Inbound ───────────────────────────────────────────────
[[sysio::action]]
void submitchain(name operator_account,
uint64_t epoch_number,
checksum256 epoch_hash,
checksum256 prev_epoch_hash,
checksum256 merkle_root,
std::vector<char> signature);
[[sysio::action]]
void uploadmsgs(name operator_account,
uint64_t epoch_number,
std::vector<char> messages,
std::vector<char> merkle_proofs);
// ── FR-200: OPP Outbound ──────────────────────────────────────────────
[[sysio::action]]
void emitchain(name operator_account, uint64_t epoch_number);
// ── FR-400: Underwriting ──────────────────────────────────────────────
[[sysio::action]]
void uwintent(name underwriter,
uint64_t message_id,
asset source_amount,
asset target_amount,
chain_kind_t source_chain,
chain_kind_t target_chain,
std::vector<char> source_sig,
std::vector<char> target_sig);
[[sysio::action]]
void uwconfirm(name operator_account, uint64_t ledger_entry_id);
[[sysio::action]]
void uwcancel(name operator_account, uint64_t ledger_entry_id, std::string reason);
[[sysio::action]]
void uwexpire();
// ── FR-500: Challenge ─────────────────────────────────────────────────
[[sysio::action]]
void challenge(name challenger, uint64_t epoch_number, std::vector<char> evidence);
[[sysio::action]]
void chalresp(name operator_account, uint64_t challenge_id, std::vector<char> response_data);
[[sysio::action]]
void chalresolve(name proposer,
uint64_t challenge_id,
checksum256 original_hash,
checksum256 round1_hash,
checksum256 round2_hash);
[[sysio::action]]
void chalvote(name voter, uint64_t challenge_id, bool approve);
// ── FR-600: Operator Lifecycle ────────────────────────────────────────
[[sysio::action]]
void regoperator(name wire_account,
operator_type_t op_type,
std::vector<char> secp256k1_pubkey,
std::vector<char> ed25519_pubkey,
asset collateral);
[[sysio::action]]
void unregop(name wire_account);
[[sysio::action]]
void activateop(name wire_account);
[[sysio::action]]
void exitop(name wire_account);
[[sysio::action]]
void slashop(name wire_account, std::string reason);
// ── FR-700: Reserve & Swap ────────────────────────────────────────────
[[sysio::action]]
void setreserve(name authority, asset reserve_total, asset wire_equivalent);
[[sysio::action]]
void updreserve(name operator_account, symbol token_sym, int64_t delta);
// ── FR-900: Swap Quote (read-only) ────────────────────────────────────
[[sysio::action]]
void getquote(symbol source_sym, symbol target_sym, asset amount);
// ── FR-1000: Oneshot Warrant Conversion ───────────────────────────────
[[sysio::action]]
void oneshot(name beneficiary, asset amount);
// ── Action wrappers ───────────────────────────────────────────────────
using init_action = action_wrapper<"init"_n, &depot::init>;
using bootstrap_action = action_wrapper<"bootstrap"_n, &depot::bootstrap>;
using crank_action = action_wrapper<"crank"_n, &depot::crank>;
using submitchain_action = action_wrapper<"submitchain"_n, &depot::submitchain>;
using uploadmsgs_action = action_wrapper<"uploadmsgs"_n, &depot::uploadmsgs>;
using emitchain_action = action_wrapper<"emitchain"_n, &depot::emitchain>;
using uwintent_action = action_wrapper<"uwintent"_n, &depot::uwintent>;
using uwconfirm_action = action_wrapper<"uwconfirm"_n, &depot::uwconfirm>;
using uwcancel_action = action_wrapper<"uwcancel"_n, &depot::uwcancel>;
using uwexpire_action = action_wrapper<"uwexpire"_n, &depot::uwexpire>;
using challenge_action = action_wrapper<"challenge"_n, &depot::challenge>;
using chalresp_action = action_wrapper<"chalresp"_n, &depot::chalresp>;
using chalresolve_action = action_wrapper<"chalresolve"_n, &depot::chalresolve>;
using chalvote_action = action_wrapper<"chalvote"_n, &depot::chalvote>;
using regoperator_action = action_wrapper<"regoperator"_n, &depot::regoperator>;
using unregop_action = action_wrapper<"unregop"_n, &depot::unregop>;
using activateop_action = action_wrapper<"activateop"_n, &depot::activateop>;
using exitop_action = action_wrapper<"exitop"_n, &depot::exitop>;
using slashop_action = action_wrapper<"slashop"_n, &depot::slashop>;
using setreserve_action = action_wrapper<"setreserve"_n, &depot::setreserve>;
using updreserve_action = action_wrapper<"updreserve"_n, &depot::updreserve>;
using getquote_action = action_wrapper<"getquote"_n, &depot::getquote>;
using oneshot_action = action_wrapper<"oneshot"_n, &depot::oneshot>;
private:
// ── Internal helpers ──────────────────────────────────────────────────
depot_global_state get_state();
void set_state(const depot_global_state& s);
void verify_elected(name operator_account, uint64_t epoch_number);
void evaluate_consensus(uint64_t epoch_number);
void process_ready_messages();
void expire_underwriting_locks();
void elect_operators_for_epoch(uint64_t next_epoch);
void process_assertion(uint64_t message_number, assertion_type_t type, const std::vector<char>& payload);
void queue_outbound_message(assertion_type_t type, const std::vector<char>& payload);
void mark_epoch_valid(uint64_t chain_scope, uint64_t epoch_number);
void slash_minority_operators(uint64_t chain_scope, uint64_t epoch_number,
const checksum256& consensus_hash);
void enter_challenge_state(uint64_t epoch_number);
void resume_from_challenge();
void distribute_slash(uint64_t operator_id, name challenger);
uint64_t calculate_swap_rate(symbol source, symbol target, asset amount);
bool check_rate_threshold(uint64_t rate_bps, uint64_t threshold_bps);
uint64_t scope() {
auto s = get_state();
return uint64_t(s.chain_id);
}
};
} // namespace sysio

View File

@@ -0,0 +1,352 @@
#include <sysio.depot/sysio.depot.hpp>
namespace sysio {
// ── challenge (FR-501/502) ──────────────────────────────────────────────────
//
// A challenger submits a challenge against an epoch.
// Suspends normal message processing, enters CHALLENGE state,
// queues a CHALLENGE_REQUEST outbound message for the Outpost.
void depot::challenge(name challenger, uint64_t epoch_number, std::vector<char> evidence) {
require_auth(challenger);
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
check(s.state == depot_state_active || s.state == depot_state_challenge,
"depot: system is paused, cannot accept challenges");
// Verify the challenger is a registered challenger operator
known_operators_table ops(get_self(), chain_scope);
auto by_account = ops.get_index<"byaccount"_n>();
auto op_it = by_account.find(challenger.value);
check(op_it != by_account.end(), "depot: challenger not registered");
check(op_it->op_type == operator_type_challenger, "depot: account is not a challenger");
check(op_it->status == operator_status_active, "depot: challenger is not active");
// Verify epoch exists and is in a challengeable state
opp_epoch_in_table epochs(get_self(), chain_scope);
auto ep_it = epochs.find(epoch_number);
check(ep_it != epochs.end(), "depot: epoch not found");
check(!ep_it->challenge_flag, "depot: epoch already under challenge");
// Check no active challenge exists for this epoch
challenges_table chals(get_self(), chain_scope);
auto by_epoch = chals.get_index<"byepoch"_n>();
auto chal_it = by_epoch.find(epoch_number);
if (chal_it != by_epoch.end()) {
check(chal_it->status == challenge_status_resolved,
"depot: epoch already has an active challenge");
}
// Create challenge record
chals.emplace(get_self(), [&](auto& c) {
c.id = chals.available_primary_key();
c.epoch_number = epoch_number;
c.status = challenge_status_round1_pending;
c.round = 1;
c.challenge_data = evidence;
});
// Mark epoch as challenged
epochs.modify(ep_it, same_payer, [&](auto& e) {
e.challenge_flag = true;
});
// Enter challenge state
enter_challenge_state(epoch_number);
// FR-502: Queue outbound CHALLENGE_REQUEST to Outpost
queue_outbound_message(assertion_type_challenge_response, evidence);
}
// ── chalresp (FR-503/504) ───────────────────────────────────────────────────
//
// An elected batch operator responds to a challenge.
// If this is round 1, compare with original. If mismatch on round 2, PAUSE.
void depot::chalresp(name operator_account, uint64_t challenge_id,
std::vector<char> response_data) {
require_auth(operator_account);
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
check(s.state == depot_state_challenge, "depot: system is not in challenge state");
// Verify operator is elected
verify_elected(operator_account, s.current_epoch);
// Find the challenge
challenges_table chals(get_self(), chain_scope);
auto chal_it = chals.find(challenge_id);
check(chal_it != chals.end(), "depot: challenge not found");
check(chal_it->status == challenge_status_round1_pending ||
chal_it->status == challenge_status_round2_pending,
"depot: challenge is not awaiting a response");
uint8_t current_round = chal_it->round;
// Compute hash of the response data
checksum256 response_hash = sha256(response_data.data(), response_data.size());
// Compare with the original epoch data
opp_epoch_in_table epochs(get_self(), chain_scope);
auto ep_it = epochs.find(chal_it->epoch_number);
check(ep_it != epochs.end(), "depot: challenged epoch not found");
bool hashes_match = (response_hash == ep_it->epoch_merkle);
if (current_round == 1) {
if (hashes_match) {
// FR-503: Round 1 — response matches original, challenge fails
chals.modify(chal_it, same_payer, [&](auto& c) {
c.status = challenge_status_round1_complete;
});
// Resume normal operations
resume_from_challenge();
} else {
// FR-504: Round 1 — mismatch, escalate to round 2
chals.modify(chal_it, same_payer, [&](auto& c) {
c.status = challenge_status_round2_pending;
c.round = 2;
});
// Queue second challenge request
queue_outbound_message(assertion_type_challenge_response, response_data);
}
} else if (current_round == 2) {
if (hashes_match) {
// FR-505: Round 2 — response matches, original was wrong
chals.modify(chal_it, same_payer, [&](auto& c) {
c.status = challenge_status_round2_complete;
});
// Slash original operators who delivered wrong data
epoch_votes_table votes(get_self(), chain_scope);
auto by_epoch = votes.get_index<"byepoch"_n>();
auto vit = by_epoch.lower_bound(chal_it->epoch_number);
while (vit != by_epoch.end() && vit->epoch_number == chal_it->epoch_number) {
if (vit->chain_hash != response_hash) {
// Slash this operator
known_operators_table ops2(get_self(), chain_scope);
auto op2 = ops2.find(vit->operator_id);
if (op2 != ops2.end() && op2->status != operator_status_slashed) {
ops2.modify(op2, same_payer, [&](auto& o) {
o.status = operator_status_slashed;
o.collateral.amount = 0;
o.status_changed_at = current_time_point();
});
}
}
++vit;
}
// Resume with corrected data
resume_from_challenge();
} else {
// FR-506: Round 2 — second mismatch, enter PAUSE state
chals.modify(chal_it, same_payer, [&](auto& c) {
c.status = challenge_status_paused;
});
// Enter global pause — manual resolution required
auto state = get_state();
state.state = depot_state_paused;
set_state(state);
}
}
}
// ── chalresolve (FR-507) ────────────────────────────────────────────────────
//
// Manual resolution: A T1/T2 assembler proposes a resolution with
// 3 hashes (original, round1, round2). Requires 2/3 supermajority vote
// from active node operators to accept.
void depot::chalresolve(name proposer,
uint64_t challenge_id,
checksum256 original_hash,
checksum256 round1_hash,
checksum256 round2_hash) {
require_auth(proposer);
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
check(s.state == depot_state_paused, "depot: system must be paused for manual resolution");
challenges_table chals(get_self(), chain_scope);
auto chal_it = chals.find(challenge_id);
check(chal_it != chals.end(), "depot: challenge not found");
check(chal_it->status == challenge_status_paused,
"depot: challenge must be in paused state for resolution");
// Create a fork proposal for voting
opp_forks_table forks(get_self(), chain_scope);
uint64_t fork_id = forks.available_primary_key();
// The merkle root here represents the "correct" chain hash as determined
// by the resolver's analysis of original, round1, and round2 hashes
forks.emplace(get_self(), [&](auto& f) {
f.fork_id = fork_id;
f.epoch_number = chal_it->epoch_number;
f.end_message_id = 0; // will be determined after vote passes
f.merkle_root = round2_hash; // the latest re-derivation is proposed as correct
});
}
// ── chalvote (FR-507) ───────────────────────────────────────────────────────
//
// Active node operators vote on a manual resolution proposal.
// If 2/3 supermajority approves, the resolution is applied:
// slash losers, resume operations.
void depot::chalvote(name voter, uint64_t challenge_id, bool approve) {
require_auth(voter);
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
check(s.state == depot_state_paused, "depot: system must be paused for voting");
// Verify voter is an active node operator
known_operators_table ops(get_self(), chain_scope);
auto by_account = ops.get_index<"byaccount"_n>();
auto op_it = by_account.find(voter.value);
check(op_it != by_account.end(), "depot: voter not registered as operator");
check(op_it->op_type == operator_type_node, "depot: only node operators can vote on resolutions");
check(op_it->status == operator_status_active, "depot: voter must be active");
// Find the challenge and associated fork
challenges_table chals(get_self(), chain_scope);
auto chal_it = chals.find(challenge_id);
check(chal_it != chals.end(), "depot: challenge not found");
check(chal_it->status == challenge_status_paused, "depot: challenge not in voting state");
// Find the fork proposal for this epoch
opp_forks_table forks(get_self(), chain_scope);
auto by_epoch = forks.get_index<"byepoch"_n>();
auto fork_it = by_epoch.find(chal_it->epoch_number);
check(fork_it != by_epoch.end(), "depot: no resolution proposal found for this epoch");
// Check voter hasn't already voted on this fork
opp_fork_votes_table votes(get_self(), chain_scope);
auto by_fu = votes.get_index<"byforkuser"_n>();
uint128_t fu_key = (uint128_t(fork_it->fork_id) << 64) | voter.value;
check(by_fu.find(fu_key) == by_fu.end(), "depot: already voted on this resolution");
// Record vote
votes.emplace(get_self(), [&](auto& v) {
v.id = votes.available_primary_key();
v.fork_id = fork_it->fork_id;
v.voter = voter;
v.vote_state = approve ? 1 : 2;
});
// Count votes for this fork
auto vit = by_fu.lower_bound(uint128_t(fork_it->fork_id) << 64);
uint32_t accept_count = 0;
uint32_t total_count = 0;
while (vit != by_fu.end()) {
// Extract fork_id from composite key
uint64_t vid_fork = uint64_t(vit->by_fork_user() >> 64);
if (vid_fork != fork_it->fork_id) break;
++total_count;
if (vit->vote_state == 1) ++accept_count;
++vit;
}
// Count total active node operators for threshold calculation
auto by_ts = ops.get_index<"bytypestatus"_n>();
uint128_t node_key = (uint128_t(operator_type_node) << 64) | uint64_t(operator_status_active);
auto node_it = by_ts.lower_bound(node_key);
uint32_t total_nodes = 0;
while (node_it != by_ts.end() &&
node_it->op_type == operator_type_node &&
node_it->status == operator_status_active) {
++total_nodes;
++node_it;
}
// FR-507: Check if 2/3 supermajority is reached
if (total_nodes > 0 && accept_count * 10000 >= total_nodes * RESOLUTION_VOTE_THRESHOLD_BPS) {
// Resolution accepted — apply it
chals.modify(chal_it, same_payer, [&](auto& c) {
c.status = challenge_status_resolved;
});
// Slash operators who delivered non-consensus data
slash_minority_operators(chain_scope, chal_it->epoch_number, fork_it->merkle_root);
// Resume operations
resume_from_challenge();
}
}
// ── enter_challenge_state (internal) ────────────────────────────────────────
void depot::enter_challenge_state(uint64_t epoch_number) {
auto s = get_state();
if (s.state != depot_state_challenge) {
s.state = depot_state_challenge;
set_state(s);
}
}
// ── resume_from_challenge (internal) ────────────────────────────────────────
void depot::resume_from_challenge() {
auto s = get_state();
s.state = depot_state_active;
set_state(s);
}
// ── distribute_slash (internal, FR-504/DEC-005) ─────────────────────────────
//
// Distributes slashed collateral:
// 50% to the challenger
// 25% to underwriters
// 25% to batch operators
void depot::distribute_slash(uint64_t operator_id, name challenger) {
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
known_operators_table ops(get_self(), chain_scope);
auto op_it = ops.find(operator_id);
if (op_it == ops.end()) return;
int64_t total_collateral = op_it->collateral.amount;
if (total_collateral <= 0) return;
symbol collateral_sym = op_it->collateral.symbol;
// Calculate shares
int64_t challenger_share = (total_collateral * SLASH_CHALLENGER_BPS) / 10000;
int64_t underwriter_share = (total_collateral * SLASH_UNDERWRITERS_BPS) / 10000;
int64_t batch_share = total_collateral - challenger_share - underwriter_share;
// Zero the operator's collateral
ops.modify(op_it, same_payer, [&](auto& o) {
o.collateral.amount = 0;
o.status = operator_status_slashed;
o.status_changed_at = current_time_point();
});
// TODO: Transfer shares via inline actions to sysio.token
// action(permission_level{get_self(), "active"_n},
// s.token_contract, "transfer"_n,
// std::make_tuple(get_self(), challenger,
// asset(challenger_share, collateral_sym),
// std::string("slash distribution - challenger")))
// .send();
//
// Underwriter and batch operator shares would need to be distributed
// proportionally among all active operators of those types.
}
} // namespace sysio

View File

@@ -0,0 +1,146 @@
#include <sysio.depot/sysio.depot.hpp>
namespace sysio {
// ── evaluate_consensus (FR-301/302/303/304) ──────────────────────────────────
//
// Collects epoch votes from all elected batch operators and determines
// consensus via Option A (all 7 identical) or Option B (4+ majority at
// epoch boundary with all delivered chains matching).
void depot::evaluate_consensus(uint64_t epoch_number) {
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
// Skip if already in challenge or paused state for this epoch
if (s.state == depot_state_paused) return;
// Get the elected schedule for this epoch
op_schedule_table sched(get_self(), chain_scope);
auto sch_it = sched.find(epoch_number);
if (sch_it == sched.end()) return; // no schedule yet
uint32_t total_elected = sch_it->elected_operator_ids.size();
// Collect all votes for this epoch
epoch_votes_table votes(get_self(), chain_scope);
auto by_epoch = votes.get_index<"byepoch"_n>();
auto vit = by_epoch.lower_bound(epoch_number);
// Count votes per distinct hash
struct hash_count {
checksum256 hash;
uint32_t count = 0;
};
std::vector<hash_count> hash_counts;
uint32_t total_votes = 0;
while (vit != by_epoch.end() && vit->epoch_number == epoch_number) {
++total_votes;
bool found = false;
for (auto& hc : hash_counts) {
if (hc.hash == vit->chain_hash) {
++hc.count;
found = true;
break;
}
}
if (!found) {
hash_counts.push_back({vit->chain_hash, 1});
}
++vit;
}
if (total_votes == 0) return; // no votes yet
// ── Option A: All elected operators delivered identical hashes ─────────
if (total_votes == total_elected && hash_counts.size() == 1) {
// Unanimous consensus achieved
mark_epoch_valid(chain_scope, epoch_number);
return;
}
// ── Option B: Majority (4+ of 7) with identical data ─────────────────
// Only viable when epoch boundary is reached without all operators delivering
if (total_votes >= CONSENSUS_MAJORITY) {
for (auto& hc : hash_counts) {
if (hc.count >= CONSENSUS_MAJORITY) {
// Majority consensus — check that this is the only hash that
// was delivered (all delivered chains match)
// Per FR-302 Option B: majority deliver identical AND
// all delivered chains match
if (hash_counts.size() == 1) {
mark_epoch_valid(chain_scope, epoch_number);
return;
}
// If there are multiple distinct hashes but majority agrees,
// we still have consensus but with dissenters to slash
mark_epoch_valid(chain_scope, epoch_number);
// Identify and slash minority operators
slash_minority_operators(chain_scope, epoch_number, hc.hash);
return;
}
}
}
// ── Not enough votes yet — wait for more submissions ──────────────────
// If all elected have voted and no consensus, trigger challenge
if (total_votes == total_elected) {
enter_challenge_state(epoch_number);
}
}
// ── mark_epoch_valid (internal) ──────────────────────────────────────────────
void depot::mark_epoch_valid(uint64_t chain_scope, uint64_t epoch_number) {
message_chains_table chains(get_self(), chain_scope);
auto by_epoch = chains.get_index<"byepochdir"_n>();
uint128_t ek = (uint128_t(epoch_number) << 8) | uint64_t(message_direction_inbound);
auto it = by_epoch.lower_bound(ek);
while (it != by_epoch.end() &&
it->epoch_number == epoch_number &&
it->direction == message_direction_inbound) {
if (it->status == chain_status_pending) {
by_epoch.modify(it, same_payer, [&](auto& c) {
c.status = chain_status_valid;
});
}
++it;
}
}
// ── slash_minority_operators (internal) ──────────────────────────────────────
//
// After consensus, identify operators who delivered non-consensus hashes
// and slash them (FR-504).
void depot::slash_minority_operators(uint64_t chain_scope, uint64_t epoch_number,
const checksum256& consensus_hash) {
epoch_votes_table votes(get_self(), chain_scope);
auto by_epoch = votes.get_index<"byepoch"_n>();
auto vit = by_epoch.lower_bound(epoch_number);
known_operators_table ops(get_self(), chain_scope);
while (vit != by_epoch.end() && vit->epoch_number == epoch_number) {
if (vit->chain_hash != consensus_hash) {
// This operator delivered a non-consensus hash — slash them
auto op_it = ops.find(vit->operator_id);
if (op_it != ops.end() && op_it->status != operator_status_slashed) {
ops.modify(op_it, same_payer, [&](auto& o) {
o.status = operator_status_slashed;
o.collateral.amount = 0; // DEC-006
o.status_changed_at = current_time_point();
});
// Queue outbound slash to Outpost
std::vector<char> slash_payload;
queue_outbound_message(assertion_type_slash_operator, slash_payload);
}
}
++vit;
}
}
} // namespace sysio

View File

@@ -0,0 +1,248 @@
#include <sysio.depot/sysio.depot.hpp>
#include <sysio/transaction.hpp>
namespace sysio {
// ── regoperator (FR-602) ─────────────────────────────────────────────────────
void depot::regoperator(name wire_account,
operator_type_t op_type,
std::vector<char> secp256k1_pubkey,
std::vector<char> ed25519_pubkey,
asset collateral) {
require_auth(wire_account);
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
// Validate key sizes (FR-606)
check(secp256k1_pubkey.size() == 33, "depot: secp256k1 pubkey must be 33 bytes compressed");
check(ed25519_pubkey.size() == 32, "depot: ed25519 pubkey must be 32 bytes");
check(collateral.amount > 0, "depot: collateral must be positive");
check(op_type == operator_type_node || op_type == operator_type_batch ||
op_type == operator_type_underwriter || op_type == operator_type_challenger,
"depot: invalid operator type");
known_operators_table ops(get_self(), chain_scope);
// Check no duplicate registration for this account
auto by_account = ops.get_index<"byaccount"_n>();
auto acct_it = by_account.find(wire_account.value);
check(acct_it == by_account.end(), "depot: operator already registered for this chain");
// Check no duplicate secp256k1 pubkey
auto by_secp = ops.get_index<"bysecppub"_n>();
auto secp_hash = sha256(secp256k1_pubkey.data(), secp256k1_pubkey.size());
check(by_secp.find(secp_hash) == by_secp.end(),
"depot: secp256k1 pubkey already registered");
ops.emplace(get_self(), [&](auto& o) {
o.id = ops.available_primary_key();
o.op_type = op_type;
o.status = operator_status_warmup;
o.wire_account = wire_account;
o.secp256k1_pubkey = secp256k1_pubkey;
o.ed25519_pubkey = ed25519_pubkey;
o.collateral = collateral;
o.registered_at = current_time_point();
o.status_changed_at = current_time_point();
});
}
// ── unregop ──────────────────────────────────────────────────────────────────
void depot::unregop(name wire_account) {
require_auth(wire_account);
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
known_operators_table ops(get_self(), chain_scope);
auto by_account = ops.get_index<"byaccount"_n>();
auto it = by_account.find(wire_account.value);
check(it != by_account.end(), "depot: operator not found");
check(it->status != operator_status_slashed, "depot: operator already slashed");
check(it->status != operator_status_exited, "depot: operator already exited");
check(it->status != operator_status_cooldown, "depot: operator already in cooldown");
check(it->status == operator_status_active || it->status == operator_status_warmup,
"depot: operator must be active or in warmup to unregister");
by_account.modify(it, same_payer, [&](auto& o) {
o.status = operator_status_cooldown;
o.status_changed_at = current_time_point();
});
}
// ── exitop (cooldown -> exited, graceful exit) ───────────────────────────────
void depot::exitop(name wire_account) {
require_auth(wire_account);
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
known_operators_table ops(get_self(), chain_scope);
auto by_account = ops.get_index<"byaccount"_n>();
auto it = by_account.find(wire_account.value);
check(it != by_account.end(), "depot: operator not found");
check(it->status == operator_status_cooldown, "depot: operator must be in cooldown to exit");
// TODO: Verify cooldown duration has elapsed (OQ-010: durations not yet specified)
by_account.modify(it, same_payer, [&](auto& o) {
o.status = operator_status_exited;
o.status_changed_at = current_time_point();
});
// TODO: Return collateral to operator's wire_account
}
// ── activateop ───────────────────────────────────────────────────────────────
void depot::activateop(name wire_account) {
require_auth(get_self()); // governance action
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
known_operators_table ops(get_self(), chain_scope);
auto by_account = ops.get_index<"byaccount"_n>();
auto it = by_account.find(wire_account.value);
check(it != by_account.end(), "depot: operator not found");
check(it->status == operator_status_warmup, "depot: operator must be in warmup state");
by_account.modify(it, same_payer, [&](auto& o) {
o.status = operator_status_active;
o.status_changed_at = current_time_point();
});
}
// ── slashop (FR-605 / FR-504) ────────────────────────────────────────────────
void depot::slashop(name wire_account, std::string reason) {
require_auth(get_self()); // only callable internally or by governance
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
known_operators_table ops(get_self(), chain_scope);
auto by_account = ops.get_index<"byaccount"_n>();
auto it = by_account.find(wire_account.value);
check(it != by_account.end(), "depot: operator not found");
check(it->status != operator_status_slashed, "depot: operator already slashed");
by_account.modify(it, same_payer, [&](auto& o) {
o.status = operator_status_slashed;
o.collateral.amount = 0; // DEC-006: collapse ALL collateral
o.status_changed_at = current_time_point();
});
// Queue outbound slash notification to Outpost (FR-504 step 6)
// Roster update with reason BAD_BEHAVIOUR
std::vector<char> slash_payload;
// Serialize: operator_id + reason
// The exact serialization format will be defined by OPP spec
queue_outbound_message(assertion_type_slash_operator, slash_payload);
}
// ── elect_operators_for_epoch (FR-604) ───────────────────────────────────────
void depot::elect_operators_for_epoch(uint64_t next_epoch) {
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
// Check if schedule already exists for this epoch
op_schedule_table sched(get_self(), chain_scope);
auto existing = sched.find(next_epoch);
if (existing != sched.end()) return; // already elected
// Gather all active batch operators
known_operators_table ops(get_self(), chain_scope);
auto by_ts = ops.get_index<"bytypestatus"_n>();
uint128_t key = (uint128_t(operator_type_batch) << 64) | uint64_t(operator_status_active);
auto it = by_ts.lower_bound(key);
std::vector<uint64_t> active_ids;
while (it != by_ts.end() && it->op_type == operator_type_batch && it->status == operator_status_active) {
active_ids.push_back(it->id);
++it;
}
check(active_ids.size() >= MAX_BATCH_OPERATORS_PER_EPOCH,
"depot: insufficient active batch operators for election");
// Get previous epoch schedule to enforce no-consecutive rule
std::vector<uint64_t> prev_elected;
if (next_epoch > 1) {
auto prev = sched.find(next_epoch - 1);
if (prev != sched.end()) {
prev_elected = prev->elected_operator_ids;
}
}
// Random round-robin selection (FR-604)
// Use block hash as randomness source for deterministic on-chain election
// tapos_block_prefix gives us block-specific entropy
uint32_t seed = sysio::tapos_block_prefix();
std::vector<uint64_t> eligible;
for (auto id : active_ids) {
// No consecutive participation rule
bool was_previous = false;
for (auto prev_id : prev_elected) {
if (prev_id == id) { was_previous = true; break; }
}
if (!was_previous) {
eligible.push_back(id);
}
}
// If not enough eligible due to no-consecutive rule, relax it
if (eligible.size() < MAX_BATCH_OPERATORS_PER_EPOCH) {
eligible = active_ids;
}
// Fisher-Yates shuffle using deterministic seed
for (uint32_t i = eligible.size() - 1; i > 0; --i) {
seed = seed * 1103515245 + 12345; // LCG
uint32_t j = seed % (i + 1);
auto tmp = eligible[i];
eligible[i] = eligible[j];
eligible[j] = tmp;
}
// Take first MAX_BATCH_OPERATORS_PER_EPOCH
std::vector<uint64_t> elected(eligible.begin(),
eligible.begin() + std::min((uint32_t)eligible.size(), MAX_BATCH_OPERATORS_PER_EPOCH));
sched.emplace(get_self(), [&](auto& r) {
r.epoch_number = next_epoch;
r.elected_operator_ids = elected;
r.created_at = current_time_point();
});
}
// ── verify_elected (internal) ────────────────────────────────────────────────
void depot::verify_elected(name operator_account, uint64_t epoch_number) {
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
// Find operator id
known_operators_table ops(get_self(), chain_scope);
auto by_account = ops.get_index<"byaccount"_n>();
auto op_it = by_account.find(operator_account.value);
check(op_it != by_account.end(), "depot: operator not registered");
// Find schedule for this epoch
op_schedule_table sched(get_self(), chain_scope);
auto sch_it = sched.find(epoch_number);
check(sch_it != sched.end(), "depot: no schedule for epoch");
bool found = false;
for (auto id : sch_it->elected_operator_ids) {
if (id == op_it->id) { found = true; break; }
}
check(found, "depot: operator not elected for this epoch (FR-605: non-elected delivery => slash)");
}
} // namespace sysio

View File

@@ -0,0 +1,219 @@
#include <sysio.depot/sysio.depot.hpp>
namespace sysio {
// ── submitchain (FR-101/103/104/105) ─────────────────────────────────────────
//
// Batch operator submits an epoch envelope + signature.
// The Depot collects these as votes; consensus is evaluated once enough arrive.
void depot::submitchain(name operator_account,
uint64_t epoch_number,
checksum256 epoch_hash,
checksum256 prev_epoch_hash,
checksum256 merkle_root,
std::vector<char> signature) {
require_auth(operator_account);
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
// FR-103: Verify operator is elected for this epoch
verify_elected(operator_account, epoch_number);
// FR-102: Sequential epoch processing
check(epoch_number == s.current_epoch || epoch_number == s.current_epoch + 1,
"depot: epoch out of order (FR-102: must be processed sequentially)");
// Resolve operator id
known_operators_table ops(get_self(), chain_scope);
auto by_account = ops.get_index<"byaccount"_n>();
auto op_it = by_account.find(operator_account.value);
// verify_elected already checks this, but be explicit
check(op_it != by_account.end(), "depot: operator not registered");
// FR-105: Validate previous epoch hash against stored value
if (epoch_number > 0) {
opp_epoch_in_table epochs(get_self(), chain_scope);
auto prev_epoch = epochs.find(epoch_number - 1);
if (prev_epoch != epochs.end()) {
check(prev_epoch_hash == prev_epoch->epoch_merkle,
"depot: prev_epoch_hash does not match stored epoch merkle (FR-105)");
}
}
// FR-104: Validate signature (recover signer, confirm it matches operator)
// The signature is over the epoch_hash using the operator's secp256k1 key.
// On-chain recovery: assert_recover_key(epoch_hash, sig, expected_pubkey)
// For now we store the signature; full recovery requires the CDT
// assert_recover_key intrinsic which takes a digest + sig + pubkey.
// TODO: Uncomment when OPP signature format is finalized
// public_key recovered = recover_key(epoch_hash, signature);
// check(recovered matches op_it->secp256k1_pubkey)
// Store this operator's vote for the epoch
epoch_votes_table votes(get_self(), chain_scope);
auto by_eo = votes.get_index<"byepochop"_n>();
uint128_t eo_key = (uint128_t(epoch_number) << 64) | op_it->id;
check(by_eo.find(eo_key) == by_eo.end(),
"depot: operator already submitted for this epoch");
votes.emplace(get_self(), [&](auto& v) {
v.id = votes.available_primary_key();
v.epoch_number = epoch_number;
v.operator_id = op_it->id;
v.chain_hash = epoch_hash;
v.submitted_at = current_time_point();
});
// Store the message chain record
message_chains_table chains(get_self(), chain_scope);
chains.emplace(get_self(), [&](auto& c) {
c.id = chains.available_primary_key();
c.direction = message_direction_inbound;
c.status = chain_status_pending;
c.epoch_number = epoch_number;
c.merkle_root = merkle_root;
c.epoch_hash = epoch_hash;
c.prev_epoch_hash = prev_epoch_hash;
c.operator_signature = signature;
c.operator_id = op_it->id;
c.created_at = current_time_point();
});
// Attempt consensus evaluation after each submission
evaluate_consensus(epoch_number);
}
// ── uploadmsgs (FR-106/107/108) ──────────────────────────────────────────────
//
// After consensus is reached, a single chosen operator uploads the actual
// messages with merkle proofs linking message IDs to the epoch merkle root.
void depot::uploadmsgs(name operator_account,
uint64_t epoch_number,
std::vector<char> messages,
std::vector<char> merkle_proofs) {
require_auth(operator_account);
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
// Verify operator is elected
verify_elected(operator_account, epoch_number);
// Verify the epoch has reached consensus (status = valid)
message_chains_table chains(get_self(), chain_scope);
auto by_epoch = chains.get_index<"byepochdir"_n>();
uint128_t ek = (uint128_t(epoch_number) << 8) | uint64_t(message_direction_inbound);
auto chain_it = by_epoch.lower_bound(ek);
bool has_valid = false;
while (chain_it != by_epoch.end() &&
chain_it->epoch_number == epoch_number &&
chain_it->direction == message_direction_inbound) {
if (chain_it->status == chain_status_valid) {
has_valid = true;
break;
}
++chain_it;
}
check(has_valid, "depot: epoch has not reached consensus yet");
// FR-106: Unpack message chain attestations
// Messages format: [assertion_type(2) | payload_len(4) | payload(N)] repeated
// Merkle proofs: [proof per message for verification against epoch merkle root]
//
// For each message:
// 1. Recompute message ID from message data (FR-106)
// 2. Evaluate merkle proof against epoch merkle root
// 3. Classify as NORMAL or CHALLENGE (FR-106)
// 4. Assign status PENDING or READY (FR-107)
opp_in_table in_msgs(get_self(), chain_scope);
// Deserialize messages
const char* ptr = messages.data();
const char* end = ptr + messages.size();
uint64_t msg_num = 0;
// Find the next message number
auto last_msg = in_msgs.end();
if (last_msg != in_msgs.begin()) {
--last_msg;
msg_num = last_msg->message_number + 1;
}
while (ptr + 6 <= end) { // minimum: 2 bytes type + 4 bytes length
// Read assertion type (2 bytes, big-endian)
uint16_t atype = (uint16_t(uint8_t(ptr[0])) << 8) | uint8_t(ptr[1]);
ptr += 2;
// Read payload length (4 bytes, big-endian)
uint32_t plen = (uint32_t(uint8_t(ptr[0])) << 24) |
(uint32_t(uint8_t(ptr[1])) << 16) |
(uint32_t(uint8_t(ptr[2])) << 8) |
uint32_t(uint8_t(ptr[3]));
ptr += 4;
check(ptr + plen <= end, "depot: message payload exceeds buffer");
std::vector<char> payload(ptr, ptr + plen);
ptr += plen;
assertion_type_t assertion = static_cast<assertion_type_t>(atype);
// FR-106: Classify message type
bool is_challenge = (assertion == assertion_type_challenge_response);
// FR-107: Messages requiring underwriting -> PENDING; others -> READY
// Challenge messages are always READY (FR-108)
message_status_t msg_status;
if (is_challenge) {
msg_status = message_status_ready;
} else {
// Messages that need underwriting: swaps, purchases
// For now, purchases need underwriting; balance sheets and operator
// registration do not.
switch (assertion) {
case assertion_type_wire_purchase:
case assertion_type_stake_update:
msg_status = message_status_pending;
break;
default:
msg_status = message_status_ready;
break;
}
}
in_msgs.emplace(get_self(), [&](auto& m) {
m.message_number = msg_num++;
m.assertion_type = assertion;
m.status = msg_status;
m.payload = payload;
});
// FR-108: Process challenge messages immediately
if (is_challenge) {
process_assertion(msg_num - 1, assertion, payload);
}
}
// Update inbound epoch tracking
opp_epoch_in_table epoch_tbl(get_self(), chain_scope);
auto ep_it = epoch_tbl.find(epoch_number);
if (ep_it == epoch_tbl.end()) {
epoch_tbl.emplace(get_self(), [&](auto& e) {
e.epoch_number = epoch_number;
e.start_message = msg_num > 0 ? msg_num - 1 : 0; // approximate
e.end_message = msg_num;
e.challenge_flag = (s.state == depot_state_challenge);
});
} else {
epoch_tbl.modify(ep_it, same_payer, [&](auto& e) {
e.end_message = msg_num;
});
}
}
} // namespace sysio

View File

@@ -0,0 +1,120 @@
#include <sysio.depot/sysio.depot.hpp>
namespace sysio {
// ── emitchain (FR-201/202/203) ──────────────────────────────────────────────
//
// An elected batch operator builds the outbound message chain for an epoch.
// Collects all queued outbound messages, computes a merkle root, signs,
// and stores the chain for the Outpost to fetch.
void depot::emitchain(name operator_account, uint64_t epoch_number) {
require_auth(operator_account);
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
// Verify operator is elected for this epoch
verify_elected(operator_account, epoch_number);
// Resolve operator id
known_operators_table ops(get_self(), chain_scope);
auto by_account = ops.get_index<"byaccount"_n>();
auto op_it = by_account.find(operator_account.value);
check(op_it != by_account.end(), "depot: operator not registered");
// Check that outbound epoch hasn't already been emitted
opp_epoch_out_table epoch_tbl(get_self(), chain_scope);
auto ep_it = epoch_tbl.find(epoch_number);
check(ep_it == epoch_tbl.end(), "depot: outbound chain already emitted for this epoch");
// Collect outbound messages for this epoch
// Messages are queued via queue_outbound_message() during crank processing
opp_out_table out(get_self(), chain_scope);
// Determine message range for this epoch
// Use previous epoch's end_message as start, or 0 if first epoch
uint64_t start_msg = 0;
if (epoch_number > 0) {
auto prev_ep = epoch_tbl.find(epoch_number - 1);
if (prev_ep != epoch_tbl.end()) {
start_msg = prev_ep->end_message;
}
}
// FR-202: Build the outbound message chain payload
// Serialize all outbound messages into a single chain envelope
std::vector<char> chain_payload;
uint64_t end_msg = start_msg;
auto it = out.lower_bound(start_msg);
while (it != out.end()) {
// Append: assertion_type (2 bytes BE) + payload_len (4 bytes BE) + payload
uint16_t atype = uint16_t(it->assertion_type);
chain_payload.push_back(char(atype >> 8));
chain_payload.push_back(char(atype & 0xFF));
uint32_t plen = it->payload.size();
chain_payload.push_back(char((plen >> 24) & 0xFF));
chain_payload.push_back(char((plen >> 16) & 0xFF));
chain_payload.push_back(char((plen >> 8) & 0xFF));
chain_payload.push_back(char(plen & 0xFF));
chain_payload.insert(chain_payload.end(), it->payload.begin(), it->payload.end());
end_msg = it->message_number + 1;
++it;
}
// Compute merkle root over the outbound messages
// For a single-element chain, the merkle root is the hash of the payload
checksum256 merkle = sha256(chain_payload.data(), chain_payload.size());
// FR-203: Compute epoch hash (hash of merkle_root + prev_epoch_hash)
checksum256 prev_epoch_hash = checksum256();
if (epoch_number > 0) {
message_chains_table chains(get_self(), chain_scope);
auto by_epoch = chains.get_index<"byepochdir"_n>();
uint128_t ek = (uint128_t(epoch_number - 1) << 8) | uint64_t(message_direction_outbound);
auto chain_it = by_epoch.lower_bound(ek);
if (chain_it != by_epoch.end() &&
chain_it->epoch_number == epoch_number - 1 &&
chain_it->direction == message_direction_outbound) {
prev_epoch_hash = chain_it->epoch_hash;
}
}
// epoch_hash = H(merkle_root || prev_epoch_hash)
std::vector<char> hash_input;
auto merkle_bytes = merkle.extract_as_byte_array();
auto prev_bytes = prev_epoch_hash.extract_as_byte_array();
hash_input.insert(hash_input.end(), merkle_bytes.begin(), merkle_bytes.end());
hash_input.insert(hash_input.end(), prev_bytes.begin(), prev_bytes.end());
checksum256 epoch_hash = sha256(hash_input.data(), hash_input.size());
// Store message chain record
message_chains_table chains(get_self(), chain_scope);
chains.emplace(get_self(), [&](auto& c) {
c.id = chains.available_primary_key();
c.direction = message_direction_outbound;
c.status = chain_status_valid; // outbound is always valid
c.epoch_number = epoch_number;
c.merkle_root = merkle;
c.epoch_hash = epoch_hash;
c.prev_epoch_hash = prev_epoch_hash;
c.payload = chain_payload;
c.operator_signature = {}; // TODO: operator signs with secp256k1 key
c.operator_id = op_it->id;
c.created_at = current_time_point();
});
// Store epoch tracking record
epoch_tbl.emplace(get_self(), [&](auto& e) {
e.epoch_number = epoch_number;
e.start_message = start_msg;
e.end_message = end_msg;
e.merkle_root = merkle;
});
}
} // namespace sysio

View File

@@ -0,0 +1,181 @@
#include <sysio.depot/sysio.depot.hpp>
namespace sysio {
// ── setreserve (FR-701) ─────────────────────────────────────────────────────
//
// Governance / admin action to set the initial reserve state for a token pair.
// Sets the total reserve balance and its $WIRE equivalent value.
void depot::setreserve(name authority, asset reserve_total, asset wire_equivalent) {
require_auth(authority);
// Only the contract itself (governance) can set reserves
check(authority == get_self(), "depot: only contract authority can set reserves");
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
check(reserve_total.amount >= 0, "depot: reserve_total must be non-negative");
check(wire_equivalent.amount >= 0, "depot: wire_equivalent must be non-negative");
reserves_table reserves(get_self(), chain_scope);
auto it = reserves.find(reserve_total.symbol.code().raw());
if (it == reserves.end()) {
reserves.emplace(get_self(), [&](auto& r) {
r.reserve_total = reserve_total;
r.wire_equivalent = wire_equivalent;
});
} else {
reserves.modify(it, same_payer, [&](auto& r) {
r.reserve_total = reserve_total;
r.wire_equivalent = wire_equivalent;
});
}
}
// ── updreserve (FR-701) ─────────────────────────────────────────────────────
//
// An elected batch operator updates a reserve balance by a delta amount.
// Called during message processing when assets are deposited or withdrawn.
void depot::updreserve(name operator_account, symbol token_sym, int64_t delta) {
require_auth(operator_account);
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
// Verify caller is elected
verify_elected(operator_account, s.current_epoch);
reserves_table reserves(get_self(), chain_scope);
auto it = reserves.find(token_sym.code().raw());
check(it != reserves.end(), "depot: reserve not found for token");
int64_t new_amount = it->reserve_total.amount + delta;
check(new_amount >= 0, "depot: reserve would go negative");
reserves.modify(it, same_payer, [&](auto& r) {
r.reserve_total.amount = new_amount;
});
}
// ── getquote (FR-900 / FR-703) ──────────────────────────────────────────────
//
// Read-only action that computes a swap quote based on current reserve state.
// Uses constant-product (x*y=k) pricing with the underwriting fee applied.
void depot::getquote(symbol source_sym, symbol target_sym, asset amount) {
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
check(amount.amount > 0, "depot: amount must be positive");
check(amount.symbol == source_sym, "depot: amount symbol must match source_sym");
reserves_table reserves(get_self(), chain_scope);
auto source_it = reserves.find(source_sym.code().raw());
check(source_it != reserves.end(), "depot: source token reserve not found");
check(source_it->reserve_total.amount > 0, "depot: source reserve is empty");
auto target_it = reserves.find(target_sym.code().raw());
check(target_it != reserves.end(), "depot: target token reserve not found");
check(target_it->reserve_total.amount > 0, "depot: target reserve is empty");
// Constant product: x * y = k
// output = (target_reserve * input) / (source_reserve + input)
// Apply fee: output = output * (10000 - UNDERWRITE_FEE_BPS) / 10000
int64_t source_reserve = source_it->reserve_total.amount;
int64_t target_reserve = target_it->reserve_total.amount;
int64_t input = amount.amount;
// Use 128-bit multiplication to avoid overflow
__int128 numerator = __int128(target_reserve) * __int128(input);
__int128 denominator = __int128(source_reserve) + __int128(input);
int64_t raw_output = int64_t(numerator / denominator);
// Apply fee
int64_t fee_adjusted = int64_t((__int128(raw_output) * (10000 - UNDERWRITE_FEE_BPS)) / 10000);
check(fee_adjusted > 0, "depot: quote results in zero output");
check(fee_adjusted < target_reserve, "depot: insufficient target reserve for this swap");
// This is a read-only action — the quote is returned via check message
// In production, this would use return_value or get_table_rows
// For now, we print the result
print("quote:", fee_adjusted, " ", target_sym);
}
// ── oneshot (FR-1000) ───────────────────────────────────────────────────────
//
// One-time warrant conversion. Burns a warrant token and mints the
// corresponding $WIRE amount to the beneficiary.
// This is a governance-controlled action.
void depot::oneshot(name beneficiary, asset amount) {
require_auth(get_self());
check(is_account(beneficiary), "depot: beneficiary account does not exist");
check(amount.amount > 0, "depot: amount must be positive");
auto s = get_state();
// FR-1000: Issue inline transfer from depot to beneficiary
// The depot contract must hold the $WIRE tokens to distribute
action(
permission_level{get_self(), "active"_n},
s.token_contract,
"transfer"_n,
std::make_tuple(get_self(), beneficiary, amount,
std::string("oneshot warrant conversion"))
).send();
}
// ── calculate_swap_rate (internal, FR-703) ──────────────────────────────────
//
// Computes the effective exchange rate in basis points between two tokens
// based on current reserve balances.
uint64_t depot::calculate_swap_rate(symbol source, symbol target, asset amount) {
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
reserves_table reserves(get_self(), chain_scope);
auto source_it = reserves.find(source.code().raw());
if (source_it == reserves.end() || source_it->reserve_total.amount == 0) return 0;
auto target_it = reserves.find(target.code().raw());
if (target_it == reserves.end() || target_it->reserve_total.amount == 0) return 0;
// Rate in basis points: (target_wire_equivalent / source_wire_equivalent) * 10000
// Using wire_equivalent for cross-token comparison
if (source_it->wire_equivalent.amount == 0) return 0;
uint64_t rate_bps = uint64_t(
(__int128(target_it->wire_equivalent.amount) * 10000) /
__int128(source_it->wire_equivalent.amount)
);
return rate_bps;
}
// ── check_rate_threshold (internal, FR-704) ─────────────────────────────────
//
// Verifies that an exchange rate is within acceptable bounds.
// Returns true if the rate is acceptable (deviation from 1:1 is within
// the fee threshold).
bool depot::check_rate_threshold(uint64_t rate_bps, uint64_t threshold_bps) {
// Rate should be within [10000 - threshold, 10000 + threshold] basis points
// of the expected 1:1 ratio (adjusted for token decimals)
// For a 0.1% fee (10 bps), we allow rates between 9990 and 10010
uint64_t lower = 10000 - threshold_bps * 100; // allow wider range
uint64_t upper = 10000 + threshold_bps * 100;
return rate_bps >= lower && rate_bps <= upper;
}
} // namespace sysio

View File

@@ -0,0 +1,545 @@
#include <sysio.depot/sysio.depot.hpp>
namespace sysio {
// ── State helpers ────────────────────────────────────────────────────────────
depot_global_state depot::get_state() {
depot_state_singleton state_tbl(get_self(), get_self().value);
check(state_tbl.exists(), "depot: contract not initialized");
return state_tbl.get();
}
void depot::set_state(const depot_global_state& s) {
depot_state_singleton state_tbl(get_self(), get_self().value);
state_tbl.set(s, get_self());
}
// ── init ─────────────────────────────────────────────────────────────────────
void depot::init(chain_kind_t chain_id, name token_contract) {
require_auth(get_self());
depot_state_singleton state_tbl(get_self(), get_self().value);
check(!state_tbl.exists() || !state_tbl.get().initialized,
"depot: already initialized");
check(chain_id != fc::crypto::chain_kind_unknown && chain_id != fc::crypto::chain_kind_wire,
"depot: chain_id must be an external chain (ethereum, solana, sui)");
check(is_account(token_contract), "depot: token_contract account does not exist");
depot_global_state s;
s.state = depot_state_active;
s.chain_id = chain_id;
s.current_epoch = 0;
s.next_epoch = 1;
s.next_msg_out = 0;
s.last_crank_time = current_time_point();
s.token_contract = token_contract;
s.initialized = true;
state_tbl.set(s, get_self());
}
// ── bootstrap (testnet): seed opschedule for current_epoch ───────────────────
void depot::bootstrap() {
require_auth(get_self());
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
op_schedule_table sched(get_self(), chain_scope);
check(sched.find(s.current_epoch) == sched.end(),
"depot: schedule already exists for current epoch");
elect_operators_for_epoch(s.current_epoch);
}
// ── crank (FR-801) ──────────────────────────────────────────────────────────
void depot::crank(name operator_account) {
require_auth(operator_account);
auto s = get_state();
check(s.state != depot_state_paused, "depot: system is paused, manual intervention required");
// Verify the caller is an elected batch operator for the current epoch
verify_elected(operator_account, s.current_epoch);
// Step 1: Expire underwriting locks (FR-402)
expire_underwriting_locks();
// Step 2: Process messages previously marked as READY (FR-801.3)
if (s.state == depot_state_active) {
process_ready_messages();
}
// Step 3: Elect 7 active batch operators for NEXT message chain (FR-801.2 / FR-604)
elect_operators_for_epoch(s.next_epoch);
// Step 4: Advance epoch
s.current_epoch = s.next_epoch;
s.next_epoch = s.next_epoch + 1;
s.last_crank_time = current_time_point();
set_state(s);
}
// ── process_ready_messages (internal) ────────────────────────────────────────
void depot::process_ready_messages() {
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
opp_in_table msgs(get_self(), chain_scope);
auto by_status = msgs.get_index<"bystatus"_n>();
auto it = by_status.lower_bound(uint64_t(message_status_ready));
while (it != by_status.end() && it->status == message_status_ready) {
process_assertion(it->message_number, it->assertion_type, it->payload);
it = by_status.erase(it);
}
}
// ── process_assertion (internal) ─────────────────────────────────────────────
//
// Payload wire format (big-endian, matching uploadmsgs serialization):
//
// balance_sheet (0xAA00):
// [sym_code(8) | reserve_amount(8) | wire_equiv_amount(8) | precision(1)] repeated
//
// stake_update (0xEE00):
// [sym_code(8) | delta_amount(8, signed) | wire_account(8) | direction(1)]
// direction: 0 = deposit (add to reserve), 1 = withdrawal (subtract)
//
// yield_reward (0xEE01):
// [sym_code(8) | reward_amount(8) | beneficiary(8)]
//
// wire_purchase (0xEE02):
// [source_sym(8) | source_amount(8) | buyer_account(8)]
//
// operator_registration (0xEE03):
// [wire_account(8) | op_type(1) | action(1) | secp_key(33) | ed_key(32)]
// action: 0 = register, 1 = deregister
//
// challenge_response (0xEE04):
// [epoch_number(8) | response_type(1) | data(N)]
// response_type: 0 = no_challenge, 1 = accept, 2 = reject
//
// slash_operator (0xEE05):
// [operator_id(8) | reason_len(2) | reason(N)]
// Helper: read a big-endian uint64 from a byte buffer
static uint64_t read_be_u64(const char* p) {
return (uint64_t(uint8_t(p[0])) << 56) | (uint64_t(uint8_t(p[1])) << 48) |
(uint64_t(uint8_t(p[2])) << 40) | (uint64_t(uint8_t(p[3])) << 32) |
(uint64_t(uint8_t(p[4])) << 24) | (uint64_t(uint8_t(p[5])) << 16) |
(uint64_t(uint8_t(p[6])) << 8) | uint64_t(uint8_t(p[7]));
}
static int64_t read_be_i64(const char* p) {
return static_cast<int64_t>(read_be_u64(p));
}
static uint16_t read_be_u16(const char* p) {
return (uint16_t(uint8_t(p[0])) << 8) | uint16_t(uint8_t(p[1]));
}
void depot::process_assertion(uint64_t message_number, assertion_type_t type,
const std::vector<char>& payload) {
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
switch (type) {
// ── 0xAA00: Balance Sheet ──────────────────────────────────────────
// Full reserve snapshot from the outpost. Each entry is 25 bytes:
// sym_code(8) + reserve_amount(8) + wire_equiv_amount(8) + precision(1)
case assertion_type_balance_sheet: {
const size_t ENTRY_SIZE = 25;
check(payload.size() >= ENTRY_SIZE, "depot: balance_sheet payload too short");
check(payload.size() % ENTRY_SIZE == 0, "depot: balance_sheet payload not aligned");
reserves_table reserves(get_self(), chain_scope);
const char* ptr = payload.data();
size_t entries = payload.size() / ENTRY_SIZE;
for (size_t i = 0; i < entries; ++i) {
uint64_t sym_raw = read_be_u64(ptr);
int64_t reserve_amt = read_be_i64(ptr + 8);
int64_t wire_amt = read_be_i64(ptr + 16);
uint8_t precision = uint8_t(ptr[24]);
ptr += ENTRY_SIZE;
check(reserve_amt >= 0, "depot: balance_sheet reserve_amount must be non-negative");
check(wire_amt >= 0, "depot: balance_sheet wire_equivalent must be non-negative");
symbol_code sc(sym_raw);
symbol sym(sc, precision);
auto it = reserves.find(sc.raw());
if (it == reserves.end()) {
reserves.emplace(get_self(), [&](auto& r) {
r.reserve_total = asset(reserve_amt, sym);
r.wire_equivalent = asset(wire_amt, symbol("WIRE", 4));
});
} else {
reserves.modify(it, same_payer, [&](auto& r) {
r.reserve_total.amount = reserve_amt;
r.wire_equivalent.amount = wire_amt;
});
}
}
break;
}
// ── 0xEE00: Stake Update ───────────────────────────────────────────
// A principal deposit or withdrawal on the external chain.
// Payload: sym_code(8) + delta(8) + wire_account(8) + direction(1) = 25 bytes
case assertion_type_stake_update: {
check(payload.size() >= 25, "depot: stake_update payload too short");
const char* ptr = payload.data();
uint64_t sym_raw = read_be_u64(ptr);
int64_t delta = read_be_i64(ptr + 8);
uint64_t acct_raw = read_be_u64(ptr + 16);
uint8_t direction = uint8_t(ptr[24]);
check(delta > 0, "depot: stake_update delta must be positive");
symbol_code sc(sym_raw);
name wire_account(acct_raw);
reserves_table reserves(get_self(), chain_scope);
auto it = reserves.find(sc.raw());
check(it != reserves.end(), "depot: stake_update reserve not found for token");
if (direction == 0) {
// Deposit: increase reserve
reserves.modify(it, same_payer, [&](auto& r) {
r.reserve_total.amount += delta;
});
} else {
// Withdrawal: decrease reserve
check(it->reserve_total.amount >= delta,
"depot: stake_update withdrawal exceeds reserve");
reserves.modify(it, same_payer, [&](auto& r) {
r.reserve_total.amount -= delta;
});
}
// Queue outbound confirmation so the outpost knows the depot acknowledged
std::vector<char> confirm_payload(payload.begin(), payload.end());
queue_outbound_message(assertion_type_stake_update, confirm_payload);
break;
}
// ── 0xEE01: Yield Reward ───────────────────────────────────────────
// Native chain staking yield earned. Mint equivalent WIRE to beneficiary.
// Payload: sym_code(8) + reward_amount(8) + beneficiary(8) = 24 bytes
case assertion_type_yield_reward: {
check(payload.size() >= 24, "depot: yield_reward payload too short");
const char* ptr = payload.data();
uint64_t sym_raw = read_be_u64(ptr);
int64_t reward_amount = read_be_i64(ptr + 8);
uint64_t bene_raw = read_be_u64(ptr + 16);
check(reward_amount > 0, "depot: yield_reward amount must be positive");
symbol_code sc(sym_raw);
name beneficiary(bene_raw);
// Increase the reserve by the yield amount (the outpost earned it)
reserves_table reserves(get_self(), chain_scope);
auto it = reserves.find(sc.raw());
if (it != reserves.end()) {
reserves.modify(it, same_payer, [&](auto& r) {
r.reserve_total.amount += reward_amount;
});
}
// Calculate WIRE equivalent of the reward using current rate
// If wire_equivalent and reserve_total are set, compute proportional WIRE
int64_t wire_reward = 0;
if (it != reserves.end() && it->reserve_total.amount > 0 &&
it->wire_equivalent.amount > 0) {
// wire_reward = reward_amount * (wire_equivalent / reserve_total)
wire_reward = int64_t(
(__int128(reward_amount) * __int128(it->wire_equivalent.amount)) /
__int128(it->reserve_total.amount)
);
}
// Issue WIRE to beneficiary if they have an account and amount > 0
if (wire_reward > 0 && is_account(beneficiary)) {
asset wire_payout(wire_reward, symbol("WIRE", 4));
action(
permission_level{get_self(), "active"_n},
s.token_contract,
"transfer"_n,
std::make_tuple(get_self(), beneficiary, wire_payout,
std::string("yield reward distribution"))
).send();
}
break;
}
// ── 0xEE02: WIRE Purchase ──────────────────────────────────────────
// User deposited external-chain tokens to buy WIRE.
// Constant-product swap: output = (wire_reserve * input) / (source_reserve + input)
// Payload: source_sym(8) + source_amount(8) + buyer_account(8) = 24 bytes
case assertion_type_wire_purchase: {
check(payload.size() >= 24, "depot: wire_purchase payload too short");
const char* ptr = payload.data();
uint64_t sym_raw = read_be_u64(ptr);
int64_t source_amount = read_be_i64(ptr + 8);
uint64_t buyer_raw = read_be_u64(ptr + 16);
check(source_amount > 0, "depot: wire_purchase amount must be positive");
symbol_code sc(sym_raw);
name buyer(buyer_raw);
reserves_table reserves(get_self(), chain_scope);
auto source_it = reserves.find(sc.raw());
check(source_it != reserves.end(), "depot: wire_purchase source reserve not found");
check(source_it->reserve_total.amount > 0, "depot: wire_purchase source reserve empty");
check(source_it->wire_equivalent.amount > 0, "depot: wire_purchase has no wire equivalent set");
// Constant-product swap against the wire_equivalent
int64_t source_reserve = source_it->reserve_total.amount;
int64_t wire_reserve = source_it->wire_equivalent.amount;
// output = (wire_reserve * input) / (source_reserve + input)
__int128 numerator = __int128(wire_reserve) * __int128(source_amount);
__int128 denominator = __int128(source_reserve) + __int128(source_amount);
int64_t raw_output = int64_t(numerator / denominator);
// Apply underwriting fee
int64_t wire_output = int64_t(
(__int128(raw_output) * (10000 - UNDERWRITE_FEE_BPS)) / 10000
);
check(wire_output > 0, "depot: wire_purchase results in zero output");
check(wire_output < wire_reserve, "depot: wire_purchase exceeds wire reserve");
// Update reserves: source goes up, wire equivalent goes down
reserves.modify(source_it, same_payer, [&](auto& r) {
r.reserve_total.amount += source_amount;
r.wire_equivalent.amount -= wire_output;
});
// Transfer WIRE to buyer
if (is_account(buyer)) {
asset wire_payout(wire_output, symbol("WIRE", 4));
action(
permission_level{get_self(), "active"_n},
s.token_contract,
"transfer"_n,
std::make_tuple(get_self(), buyer, wire_payout,
std::string("wire purchase via OPP"))
).send();
}
// Queue outbound confirmation with the actual WIRE amount delivered
std::vector<char> confirm_payload(payload.begin(), payload.end());
// Append wire_output (8 bytes BE) so outpost knows what was delivered
for (int i = 7; i >= 0; --i) {
confirm_payload.push_back(char((wire_output >> (i * 8)) & 0xFF));
}
queue_outbound_message(assertion_type_wire_purchase, confirm_payload);
break;
}
// ── 0xEE03: Operator Registration ──────────────────────────────────
// An operator registered or deregistered on the outpost side.
// Payload: wire_account(8) + op_type(1) + action(1) + secp_key(33) + ed_key(32) = 75 bytes
case assertion_type_operator_registration: {
check(payload.size() >= 10, "depot: operator_registration payload too short");
const char* ptr = payload.data();
uint64_t acct_raw = read_be_u64(ptr);
uint8_t op_type_v = uint8_t(ptr[8]);
uint8_t reg_action = uint8_t(ptr[9]); // 0=register, 1=deregister
name wire_account(acct_raw);
operator_type_t op_type = static_cast<operator_type_t>(op_type_v);
known_operators_table ops(get_self(), chain_scope);
auto by_account = ops.get_index<"byaccount"_n>();
auto op_it = by_account.find(wire_account.value);
if (reg_action == 0) {
// Register: create or reactivate operator
if (op_it == by_account.end()) {
// New registration — need keys from payload
check(payload.size() >= 75,
"depot: operator_registration register payload needs keys (75 bytes)");
std::vector<char> secp_key(ptr + 10, ptr + 43);
std::vector<char> ed_key(ptr + 43, ptr + 75);
ops.emplace(get_self(), [&](auto& o) {
o.id = ops.available_primary_key();
o.op_type = op_type;
o.status = operator_status_warmup;
o.wire_account = wire_account;
o.secp256k1_pubkey = secp_key;
o.ed25519_pubkey = ed_key;
o.collateral = asset(0, symbol("WIRE", 4));
o.registered_at = current_time_point();
o.status_changed_at = current_time_point();
});
} else {
// Re-registration: reactivate if exited or cooldown
if (op_it->status == operator_status_exited ||
op_it->status == operator_status_cooldown) {
by_account.modify(op_it, same_payer, [&](auto& o) {
o.status = operator_status_warmup;
o.status_changed_at = current_time_point();
});
}
}
} else if (reg_action == 1) {
// Deregister: move to cooldown
if (op_it != by_account.end() &&
op_it->status != operator_status_slashed &&
op_it->status != operator_status_exited) {
by_account.modify(op_it, same_payer, [&](auto& o) {
o.status = operator_status_cooldown;
o.status_changed_at = current_time_point();
});
}
}
break;
}
// ── 0xEE04: Challenge Response ─────────────────────────────────────
// Inbound challenge data from the outpost (accept/reject/no_challenge).
// Payload: epoch_number(8) + response_type(1) + data(N)
// response_type: 0=no_challenge, 1=accept, 2=reject
case assertion_type_challenge_response: {
check(payload.size() >= 9, "depot: challenge_response payload too short");
const char* ptr = payload.data();
uint64_t epoch_number = read_be_u64(ptr);
uint8_t response_type = uint8_t(ptr[8]);
if (response_type == 0) {
// no_challenge: outpost confirms no issue — resume if we were in challenge
if (s.state == depot_state_challenge) {
resume_from_challenge();
}
} else if (response_type == 1) {
// accept: outpost acknowledges the challenge, enters challenge mode
// The depot should already be in challenge state from the initial challenge() call
// No additional action needed — wait for chalresp from operators
} else if (response_type == 2) {
// reject: outpost says the challenge is invalid
// Find the active challenge for this epoch and resolve it
challenges_table chals(get_self(), chain_scope);
auto by_epoch = chals.get_index<"byepoch"_n>();
auto chal_it = by_epoch.find(epoch_number);
if (chal_it != by_epoch.end() &&
chal_it->status != challenge_status_resolved) {
by_epoch.modify(chal_it, same_payer, [&](auto& c) {
c.status = challenge_status_resolved;
});
}
// Resume normal operations
if (s.state == depot_state_challenge) {
resume_from_challenge();
}
}
break;
}
// ── 0xEE05: Slash Operator ─────────────────────────────────────────
// The outpost detected operator misbehavior and requests a slash.
// Payload: operator_id(8) + reason_len(2) + reason(N)
case assertion_type_slash_operator: {
check(payload.size() >= 10, "depot: slash_operator payload too short");
const char* ptr = payload.data();
uint64_t operator_id = read_be_u64(ptr);
uint16_t reason_len = read_be_u16(ptr + 8);
// Validate reason length
check(payload.size() >= 10 + reason_len,
"depot: slash_operator reason exceeds payload");
known_operators_table ops(get_self(), chain_scope);
auto op_it = ops.find(operator_id);
if (op_it != ops.end() && op_it->status != operator_status_slashed) {
ops.modify(op_it, same_payer, [&](auto& o) {
o.status = operator_status_slashed;
o.collateral.amount = 0; // DEC-006: collapse all collateral
o.status_changed_at = current_time_point();
});
// Distribute slashed collateral (challenger gets 50%)
// For inbound slashes, we don't have a challenger account on the Wire side,
// so the collateral goes to the depot itself (governance can redistribute)
// distribute_slash(operator_id, get_self());
}
// Queue outbound acknowledgment so outpost knows slash was applied
queue_outbound_message(assertion_type_slash_operator, payload);
break;
}
// ── Unknown assertion type ─────────────────────────────────────────
// Silently pass through unknown types. This prevents the deadlock bug
// where unrecognized Ethereum-native types (2001-3006) killed the crank.
default: {
// Unknown assertion type — log and skip. Do not abort the transaction.
break;
}
}
}
// ── expire_underwriting_locks (internal, FR-402) ─────────────────────────────
void depot::expire_underwriting_locks() {
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
underwrite_table ledger(get_self(), chain_scope);
auto by_expiry = ledger.get_index<"byexpiry"_n>();
uint64_t now_sec = current_time_point().sec_since_epoch();
auto it = by_expiry.begin();
while (it != by_expiry.end() && it->unlock_at.sec_since_epoch() <= now_sec) {
if (it->status == underwrite_status_intent_submitted ||
it->status == underwrite_status_intent_confirmed) {
by_expiry.modify(it, same_payer, [&](auto& e) {
e.status = underwrite_status_expired;
});
}
++it;
}
}
// ── queue_outbound_message (internal) ────────────────────────────────────────
void depot::queue_outbound_message(assertion_type_t type, const std::vector<char>& payload) {
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
opp_out_table out(get_self(), chain_scope);
uint64_t next_num = s.next_msg_out;
out.emplace(get_self(), [&](auto& m) {
m.message_number = next_num;
m.assertion_type = type;
m.payload = payload;
});
s.next_msg_out = next_num + 1;
set_state(s);
}
} // namespace sysio

View File

@@ -0,0 +1,150 @@
#include <sysio.depot/sysio.depot.hpp>
namespace sysio {
// ── uwintent (FR-403) ───────────────────────────────────────────────────────
//
// An underwriter submits intent to underwrite a pending inbound message.
// Verifies the underwriter has sufficient collateral, calculates the
// exchange rate, checks threshold, creates a LOCK entry with 6hr expiry,
// and queues an outbound confirmation.
void depot::uwintent(name underwriter,
uint64_t message_id,
asset source_amount,
asset target_amount,
chain_kind_t source_chain,
chain_kind_t target_chain,
std::vector<char> source_sig,
std::vector<char> target_sig) {
require_auth(underwriter);
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
check(s.state == depot_state_active, "depot: system not in active state");
// Resolve underwriter operator record
known_operators_table ops(get_self(), chain_scope);
auto by_account = ops.get_index<"byaccount"_n>();
auto op_it = by_account.find(underwriter.value);
check(op_it != by_account.end(), "depot: underwriter not registered");
check(op_it->op_type == operator_type_underwriter, "depot: account is not an underwriter");
check(op_it->status == operator_status_active, "depot: underwriter is not active");
// Verify the message exists and needs underwriting (status = PENDING)
opp_in_table msgs(get_self(), chain_scope);
auto msg_it = msgs.find(message_id);
check(msg_it != msgs.end(), "depot: message not found");
check(msg_it->status == message_status_pending, "depot: message is not pending underwriting");
// FR-403: Calculate exchange rate from reserve state
uint64_t rate_bps = calculate_swap_rate(source_amount.symbol, target_amount.symbol, source_amount);
// FR-704: Verify rate is within acceptable threshold
// The threshold prevents extreme slippage / manipulation
check(check_rate_threshold(rate_bps, UNDERWRITE_FEE_BPS),
"depot: exchange rate exceeds acceptable threshold");
// Verify collateral covers the underwriting amount
check(op_it->collateral.amount >= target_amount.amount,
"depot: insufficient collateral for underwriting");
// FR-403: Create underwriting ledger entry with 6hr LOCK
underwrite_table ledger(get_self(), chain_scope);
time_point_sec unlock_time = time_point_sec(current_time_point().sec_since_epoch() + INTENT_LOCK_SECONDS);
ledger.emplace(get_self(), [&](auto& e) {
e.id = ledger.available_primary_key();
e.operator_id = op_it->id;
e.status = underwrite_status_intent_submitted;
e.source_amount = source_amount;
e.target_amount = target_amount;
e.source_chain = source_chain;
e.target_chain = target_chain;
e.exchange_rate_bps = rate_bps;
e.unlock_at = unlock_time;
e.created_at = current_time_point();
e.source_tx_hash = checksum256(); // populated on confirm
e.target_tx_hash = checksum256(); // populated on confirm
});
// FR-404: Queue outbound intent confirmation to Outpost
// The Outpost will use this to lock source funds
std::vector<char> intent_payload;
// Serialize: message_id + underwriter_id + source_amount + target_amount + rate
// TODO: Proper OPP serialization format
queue_outbound_message(assertion_type_wire_purchase, intent_payload);
}
// ── uwconfirm (FR-404) ─────────────────────────────────────────────────────
//
// Elected batch operator confirms an underwriting intent.
// Transitions from INTENT_SUBMITTED to INTENT_CONFIRMED, extends
// the lock to 24 hours, and marks the inbound message as READY.
void depot::uwconfirm(name operator_account, uint64_t ledger_entry_id) {
require_auth(operator_account);
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
check(s.state == depot_state_active, "depot: system not in active state");
// Verify caller is an elected batch operator
verify_elected(operator_account, s.current_epoch);
// Find ledger entry
underwrite_table ledger(get_self(), chain_scope);
auto entry = ledger.find(ledger_entry_id);
check(entry != ledger.end(), "depot: underwriting entry not found");
check(entry->status == underwrite_status_intent_submitted,
"depot: entry must be in INTENT_SUBMITTED status to confirm");
// Verify lock hasn't expired
check(entry->unlock_at > current_time_point(),
"depot: underwriting intent has expired");
// FR-404: Transition to CONFIRMED, extend lock to 24 hours
time_point_sec new_unlock = time_point_sec(current_time_point().sec_since_epoch() + CONFIRMED_LOCK_SECONDS);
ledger.modify(entry, same_payer, [&](auto& e) {
e.status = underwrite_status_intent_confirmed;
e.unlock_at = new_unlock;
});
}
// ── uwcancel ────────────────────────────────────────────────────────────────
void depot::uwcancel(name operator_account, uint64_t ledger_entry_id, std::string reason) {
require_auth(operator_account);
auto s = get_state();
uint64_t chain_scope = uint64_t(s.chain_id);
// Verify caller is an elected batch operator
verify_elected(operator_account, s.current_epoch);
underwrite_table ledger(get_self(), chain_scope);
auto entry = ledger.find(ledger_entry_id);
check(entry != ledger.end(), "depot: underwriting entry not found");
check(entry->status == underwrite_status_intent_submitted ||
entry->status == underwrite_status_intent_confirmed,
"depot: entry cannot be cancelled in current status");
ledger.modify(entry, same_payer, [&](auto& e) {
e.status = underwrite_status_cancelled;
});
}
// ── uwexpire (FR-402) ───────────────────────────────────────────────────────
//
// Permissionless cleanup of expired underwriting locks.
// Anyone can call this to release expired entries.
void depot::uwexpire() {
expire_underwriting_locks();
}
} // namespace sysio