From df12b24e9fa3eef58f9cc2966c3f74c960522b8f Mon Sep 17 00:00:00 2001 From: David J Parrott Date: Tue, 6 Oct 2026 20:28:18 -0400 Subject: [PATCH 1/5] Add the native-authority Andon contract Provide a depot emergency-stop cord controlled by native updateauth and linkauth permissions. Keep pull and clear independently delegatable, idempotent, and independent of epoch advancement. Add authorization, delegation, revocation, and state-transition tests with the operational playbook. Custody consumers adopt the cord in the chapters that introduce their respective policies. Change-Id: I4836335073b57887c78883609fcc25562131a9d1 --- contracts/CMakeLists.txt | 1 + contracts/sysio.andon/CMakeLists.txt | 34 + .../include/sysio.andon/sysio.andon.hpp | 56 ++ contracts/sysio.andon/src/sysio.andon.cpp | 27 + contracts/sysio.andon/sysio.andon.abi | 70 ++ contracts/sysio.andon/sysio.andon.wasm | Bin 0 -> 5967 bytes contracts/tests/contract_test_support.hpp | 48 ++ contracts/tests/contracts.hpp.in | 2 + contracts/tests/sysio.andon_tests.cpp | 120 +++ docs/emergency-stop-playbook.md | 741 ++++++++++++++++++ 10 files changed, 1099 insertions(+) create mode 100644 contracts/sysio.andon/CMakeLists.txt create mode 100644 contracts/sysio.andon/include/sysio.andon/sysio.andon.hpp create mode 100644 contracts/sysio.andon/src/sysio.andon.cpp create mode 100644 contracts/sysio.andon/sysio.andon.abi create mode 100755 contracts/sysio.andon/sysio.andon.wasm create mode 100644 contracts/tests/sysio.andon_tests.cpp create mode 100644 docs/emergency-stop-playbook.md diff --git a/contracts/CMakeLists.txt b/contracts/CMakeLists.txt index e9ea5c905e..309f30f18f 100644 --- a/contracts/CMakeLists.txt +++ b/contracts/CMakeLists.txt @@ -56,6 +56,7 @@ add_subdirectory(sysio.councl) add_subdirectory(sysio.reserv) add_subdirectory(sysio.dclaim) +add_subdirectory(sysio.andon) add_subdirectory(sysio.token) add_subdirectory(sysio.wrap) add_subdirectory(sysio.swap) diff --git a/contracts/sysio.andon/CMakeLists.txt b/contracts/sysio.andon/CMakeLists.txt new file mode 100644 index 0000000000..005ed1bdb4 --- /dev/null +++ b/contracts/sysio.andon/CMakeLists.txt @@ -0,0 +1,34 @@ +set(contract_name sysio.andon) +bootstrap_contract(${contract_name}) + +if(BUILD_SYSTEM_CONTRACTS) + file(GLOB_RECURSE SOURCES src/*.cpp) + file(GLOB_RECURSE HEADERS include/*.hpp) + add_contract(${contract_name} ${contract_name} ${SOURCES}) + set(targets ${contract_name}) + + if("native-module" IN_LIST SYSIO_WASM_RUNTIMES) + list(APPEND targets ${contract_name}_native) + add_native_contract( + TARGET ${contract_name}_native + SOURCES ${SOURCES} + INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/include + CONTRACT_CLASS "sysio::andon" + HEADERS ${HEADERS} + ABI_FILE ${CMAKE_BINARY_DIR}/contracts/${contract_name}/${contract_name}.abi + ) + endif() + + foreach(target ${targets}) + if(NOT TARGET ${target}) + message(WARNING "Target ${target} not found, skipping include directory setup") + continue() + endif() + + target_include_directories(${target} + PUBLIC + $ + ) + + endforeach() +endif() diff --git a/contracts/sysio.andon/include/sysio.andon/sysio.andon.hpp b/contracts/sysio.andon/include/sysio.andon/sysio.andon.hpp new file mode 100644 index 0000000000..b7144ceacd --- /dev/null +++ b/contracts/sysio.andon/include/sysio.andon/sysio.andon.hpp @@ -0,0 +1,56 @@ +#pragma once +/** + * @file sysio.andon.hpp + * @brief Depot emergency stop, authorized through native account permissions. + * + * Bootstrap links pull and clear to sysio.andon permissions whose authorities + * delegate to governance and the panic account. Readers gate custody exits; + * intake and OPP consensus continue. Privileged sysio.synd pulls automatically + * on a custody shortfall with sysio.andon@active authorization. + */ +#include +#include +#include +#include +#include +#include + +namespace sysio { + class [[sysio::contract("sysio.andon")]] andon : public contract { + public: + using contract::contract; + /// Deployment account shared by every freeze reader. + static constexpr name ANDON_ACCOUNT = "sysio.andon"_n; + /// Governance pays for this privileged contract's singleton. + static constexpr name SYSTEM_ACCOUNT = "sysio"_n; + /// Custody destinations remain available for deposits during a freeze. + static constexpr std::array CUSTODY_ACCOUNTS{"sysio.bond"_n, "sysio.synd"_n, "sysio.opreg"_n}; + /// Maximum stored reason/note length in bytes. + static constexpr uint32_t MAX_TEXT_BYTES = 256; + /// Whether an account may receive a custody deposit while frozen. + static constexpr bool is_custody(name account) { + return std::find(CUSTODY_ACCOUNTS.begin(), CUSTODY_ACCOUNTS.end(), account) != CUSTODY_ACCOUNTS.end(); + } + /// Pull the cord; repeated pulls preserve the first reason and timestamp. Auth=get_self(). + [[sysio::action]] void pull(std::string reason); + /// Clear the cord; repeated clears are no-ops. Auth=get_self(). + [[sysio::action]] void clear(std::string note); + /// Shared layout: redeploy Andon and its readers together on a fresh deployment. + struct [[sysio::table("cord")]] cord_state { + bool pulled = false; ///< Whether custody exits are stopped. + time_point when{}; ///< Time of the last state transition. + std::string reason; ///< Reason or recovery note of that transition. + SYSLIB_SERIALIZE(cord_state, (pulled)(when)(reason)) + }; + /// Depot emergency-stop singleton. + using cord_t = kv::global<"cord"_n, cord_state>; + /// An absent row reads as clear. + static cord_state cord_of(name account) { return cord_t(account).get_or_default(cord_state{}); } + /// The common freeze check used by custody readers. + static bool pulled(name account) { return cord_of(account).pulled; } + /// Refusal returned by signed actions that would move funds out of custody. + static constexpr const char* FROZEN_MESSAGE = "the andon cord is pulled: funds cannot leave custody"; + /// Refuse a custody exit while the cord is pulled. + static void check_clear(name account) { check(!pulled(account), FROZEN_MESSAGE); } + }; +} // namespace sysio diff --git a/contracts/sysio.andon/src/sysio.andon.cpp b/contracts/sysio.andon/src/sysio.andon.cpp new file mode 100644 index 0000000000..e8fb98ec1e --- /dev/null +++ b/contracts/sysio.andon/src/sysio.andon.cpp @@ -0,0 +1,27 @@ +#include +#include +#include + +namespace sysio { +void andon::pull(std::string reason) { + require_auth(get_self()); + cord_t cord(get_self()); + if (cord.get_or_default(cord_state{}).pulled) { + print("sysio.andon::pull: the cord is already pulled; nothing changes\n"); + return; + } + reason.resize(std::min(reason.size(), size_t{MAX_TEXT_BYTES})); + cord.set(cord_state{true, current_time_point(), std::move(reason)}, SYSTEM_ACCOUNT); +} + +void andon::clear(std::string note) { + require_auth(get_self()); + cord_t cord(get_self()); + if (!cord.get_or_default(cord_state{}).pulled) { + print("sysio.andon::clear: the cord is not pulled; nothing changes\n"); + return; + } + note.resize(std::min(note.size(), size_t{MAX_TEXT_BYTES})); + cord.set(cord_state{false, current_time_point(), std::move(note)}, SYSTEM_ACCOUNT); +} +} // namespace sysio diff --git a/contracts/sysio.andon/sysio.andon.abi b/contracts/sysio.andon/sysio.andon.abi new file mode 100644 index 0000000000..2cf8978df2 --- /dev/null +++ b/contracts/sysio.andon/sysio.andon.abi @@ -0,0 +1,70 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "clear", + "base": "", + "fields": [ + { + "name": "note", + "type": "string" + } + ] + }, + { + "name": "cord_state", + "base": "", + "fields": [ + { + "name": "pulled", + "type": "bool" + }, + { + "name": "when", + "type": "time_point" + }, + { + "name": "reason", + "type": "string" + } + ] + }, + { + "name": "pull", + "base": "", + "fields": [ + { + "name": "reason", + "type": "string" + } + ] + } + ], + "actions": [ + { + "name": "clear", + "type": "clear", + "ricardian_contract": "" + }, + { + "name": "pull", + "type": "pull", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "cord", + "type": "cord_state", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 28648 + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [] +} \ No newline at end of file diff --git a/contracts/sysio.andon/sysio.andon.wasm b/contracts/sysio.andon/sysio.andon.wasm new file mode 100755 index 0000000000000000000000000000000000000000..4f8e9d82c2f94215796a9c9708e9a5caaa8f5fab GIT binary patch literal 5967 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 +void deploy(Tester& tester, abi_serializer& ser, const std::vector& wasm, const std::vector& abi) { + if (tester.control->find_account(account) == nullptr) tester.create_accounts({account}); + tester.set_code(account, wasm); + tester.set_abi(account, abi.data()); + tester.set_privileged(account); + // Match bootstrap: governance controls the Andon account's active authority. + tester.set_authority(account, config::active_name, + authority{permission_level{system_account, config::active_name}}, config::owner_name); + tester.produce_blocks(); + load_account_abi(tester, account, ser); +} + +/// Pull with Andon active authorization, signed by its delegated actor. +template +typename Tester::action_result pull(Tester& tester, abi_serializer& ser, name actor = system_account, + const std::string& reason = "test") { + return push_contract_action_and_produce_block(tester, account, ser, actor, "pull"_n, + fc::mutable_variant_object()("reason", reason), {{account, config::active_name}}); +} + +/// Clear with Andon active authorization, signed by its delegated actor. +template +typename Tester::action_result clear(Tester& tester, abi_serializer& ser, name actor = system_account, + const std::string& note = "test") { + return push_contract_action_and_produce_block(tester, account, ser, actor, "clear"_n, + fc::mutable_variant_object()("note", note), {{account, config::active_name}}); +} + +/// The message of the refusal every frozen signed action raises (`andon::FROZEN_MESSAGE`); a suite +/// compares against `wasm_assert_msg(frozen_message)`. +inline constexpr const char* frozen_message = "the andon cord is pulled: funds cannot leave custody"; + +} // namespace andon + } // namespace sysio_system::test_support diff --git a/contracts/tests/contracts.hpp.in b/contracts/tests/contracts.hpp.in index 0915cab7c6..973c6fbcd5 100644 --- a/contracts/tests/contracts.hpp.in +++ b/contracts/tests/contracts.hpp.in @@ -42,6 +42,8 @@ struct contracts { static std::vector swap_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.swap/sysio.swap.abi"); } static std::vector liq_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.liq/sysio.liq.wasm"); } static std::vector liq_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.liq/sysio.liq.abi"); } + static std::vector andon_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.andon/sysio.andon.wasm"); } + static std::vector andon_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.andon/sysio.andon.abi"); } /// The checked-in bootstrap configs (`dex-config.*.json`) the launch-ingestion tests replay. static std::string dex_config_dir() { return "${CMAKE_SOURCE_DIR}/etc/config/dex"; } diff --git a/contracts/tests/sysio.andon_tests.cpp b/contracts/tests/sysio.andon_tests.cpp new file mode 100644 index 0000000000..d12631aeee --- /dev/null +++ b/contracts/tests/sysio.andon_tests.cpp @@ -0,0 +1,120 @@ +#include +#include "contracts.hpp" +#include "contract_test_support.hpp" + +using namespace sysio; +using namespace sysio::chain; +using namespace sysio::testing; +using mvo = fc::mutable_variant_object; +namespace support = sysio_system::test_support; + +/// Exercise native updateauth/linkauth with distinct signer keys, rather than bypassing authorization. +class sysio_andon_tester : public tester { +public: + static constexpr auto ANDON = "sysio.andon"_n; + static constexpr auto PANIC = "panic"_n; + static constexpr auto PULLER = "puller"_n; + static constexpr auto ALICE = "alice"_n; + abi_serializer ser; + + sysio_andon_tester() { + create_accounts({ANDON, PANIC, PULLER, ALICE}); + support::andon::deploy(*this, ser, contracts::andon_wasm(), contracts::andon_abi()); + } + + /// Replace one permission's delegates; updateauth is signed by the account owner. + void grant(name permission, std::vector delegates) { + std::sort(delegates.begin(), delegates.end()); + authority auth{1, {}, {}}; + for (auto delegate : delegates) auth.accounts.push_back({{delegate, config::active_name}, 1}); + set_authority(ANDON, permission, auth, config::active_name); + produce_block(); + } + + /// Link each action independently using the account owner's native authority. + void configure() { + grant("pull"_n, {PANIC, PULLER, config::system_account_name}); + grant("clear"_n, {PANIC, config::system_account_name}); + signed_transaction trx; + for (auto permission : {"pull"_n, "clear"_n}) + trx.actions.emplace_back(std::vector{{ANDON, config::owner_name}}, + linkauth{ANDON, ANDON, permission, permission}); + set_transaction_headers(trx); + trx.sign(get_private_key(ANDON, "owner"), control->get_chain_id()); + push_transaction(trx); + produce_block(); + } + + /// Declare an Andon permission while signing with the delegated account's key. + action_result call(name action_name, name signer, name permission, const std::string& text = "test") { + return support::push_contract_action_and_produce_block(*this, ANDON, ser, signer, action_name, + mvo()(action_name == "pull"_n ? "reason" : "note", text), {{ANDON, permission}}); + } + + /// Raw singleton bytes make idempotence checks exact. + std::vector bytes() { return get_row_by_account(ANDON, ANDON, "cord"_n, "cord"_n); } + /// Decode the public three-field emergency-stop state. + fc::variant cord() { + return ser.binary_to_variant("cord_state", bytes(), abi_serializer::create_yield_function(abi_serializer_max_time)); + } +}; + +BOOST_AUTO_TEST_SUITE(sysio_andon_tests) + +/// Before custom links exist, only the account's governance delegate can call the actions. +BOOST_FIXTURE_TEST_CASE(default_authority_is_governance_only, sysio_andon_tester) { + BOOST_REQUIRE_NE(success(), call("pull"_n, PANIC, config::active_name)); + BOOST_REQUIRE_NE(success(), call("pull"_n, ALICE, config::active_name)); + BOOST_REQUIRE(bytes().empty()); + BOOST_REQUIRE_EQUAL(success(), call("pull"_n, config::system_account_name, config::active_name)); + BOOST_REQUIRE(cord()["pulled"].as_bool()); + BOOST_REQUIRE_NE(success(), call("clear"_n, PANIC, config::active_name)); + BOOST_REQUIRE_EQUAL(success(), call("clear"_n, config::system_account_name, config::active_name)); + BOOST_REQUIRE(!cord()["pulled"].as_bool()); +} + +/// Native authority enforces separate pull/clear delegates and rejects actor-only declarations. +BOOST_FIXTURE_TEST_CASE(linked_permissions_separate_pull_from_clear, sysio_andon_tester) { + configure(); + BOOST_REQUIRE_NE(success(), call("pull"_n, ALICE, "pull"_n)); + BOOST_REQUIRE_EQUAL(success(), call("pull"_n, PULLER, "pull"_n)); + BOOST_REQUIRE_NE(success(), call("clear"_n, PULLER, "clear"_n)); + BOOST_REQUIRE_NE(success(), call("clear"_n, PULLER, "pull"_n)); + BOOST_REQUIRE_EQUAL(success(), call("clear"_n, PANIC, "clear"_n)); + BOOST_REQUIRE_EQUAL(success(), call("pull"_n, PANIC, "pull"_n)); + BOOST_REQUIRE_EQUAL(success(), call("clear"_n, config::system_account_name, "clear"_n)); + BOOST_REQUIRE_NE(success(), support::push_contract_action_and_produce_block(*this, ANDON, ser, + PANIC, "pull"_n, mvo()("reason", "wrong declared account"))); +} + +/// Updating native authorities rotates panic keys/accounts and revokes pullers without a contract list. +BOOST_FIXTURE_TEST_CASE(updateauth_rotates_and_revokes_delegates, sysio_andon_tester) { + configure(); + grant("pull"_n, {ALICE, config::system_account_name}); + grant("clear"_n, {ALICE, config::system_account_name}); + BOOST_REQUIRE_NE(success(), call("pull"_n, PANIC, "pull"_n)); + BOOST_REQUIRE_NE(success(), call("pull"_n, PULLER, "pull"_n)); + BOOST_REQUIRE_EQUAL(success(), call("pull"_n, ALICE, "pull"_n)); + BOOST_REQUIRE_NE(success(), call("clear"_n, PANIC, "clear"_n)); + BOOST_REQUIRE_EQUAL(success(), call("clear"_n, ALICE, "clear"_n)); +} + +/// Repeated requests do not change timestamps or notes, and recorded text is bounded. +BOOST_FIXTURE_TEST_CASE(transitions_are_idempotent_and_text_is_bounded, sysio_andon_tester) { + configure(); + BOOST_REQUIRE_EQUAL(success(), call("clear"_n, PANIC, "clear"_n)); + BOOST_REQUIRE(bytes().empty()); + BOOST_REQUIRE_EQUAL(success(), call("pull"_n, PANIC, "pull"_n, std::string(300, 'p'))); + BOOST_REQUIRE_EQUAL(std::string(256, 'p'), cord()["reason"].as_string()); + BOOST_REQUIRE_EQUAL(3u, cord().get_object().size()); + const auto pulled = bytes(); + BOOST_REQUIRE_EQUAL(success(), call("pull"_n, PULLER, "pull"_n, "ignored")); + BOOST_REQUIRE(pulled == bytes()); + BOOST_REQUIRE_EQUAL(success(), call("clear"_n, PANIC, "clear"_n, std::string(300, 'c'))); + BOOST_REQUIRE(!cord()["pulled"].as_bool()); + BOOST_REQUIRE_EQUAL(std::string(256, 'c'), cord()["reason"].as_string()); + const auto cleared = bytes(); + BOOST_REQUIRE_EQUAL(success(), call("clear"_n, PANIC, "clear"_n, "ignored")); + BOOST_REQUIRE(cleared == bytes()); +} +BOOST_AUTO_TEST_SUITE_END() diff --git a/docs/emergency-stop-playbook.md b/docs/emergency-stop-playbook.md new file mode 100644 index 0000000000..a510901aad --- /dev/null +++ b/docs/emergency-stop-playbook.md @@ -0,0 +1,741 @@ +# Emergency stop playbook + +The depot and each outpost carry a freeze of their own (the Andon cord). While a chain is frozen, no +funds leave protocol custody on it; OPP consensus, envelope delivery and epoch advance keep running on +every chain, so funds keep arriving and stay held, and nothing that falls due is skipped. This playbook +is §5.4 of the syndication underwriting specification written out with the exact actions for each +chain. The contracts behind it are described in [`sysio-synd.md`](sysio-synd.md) (The emergency stop), +[`sysio-bond-integration.md`](sysio-bond-integration.md) (The emergency stop) and +[`contract-upgrade-order.md`](contract-upgrade-order.md); the Solana side in wire-solana's +`opp-outpost-technical-spec.md` §7.19 and §8.10; the Ethereum side in wire-ethereum's `docs/deploy.md` +(Syndication pool, Pause). + +| Chain | The freeze | Pulled by | Cleared by | +|---|---|---|---| +| Depot | `sysio.andon` `cord` row, `pulled` | `sysio.andon::pull`: delegates of `sysio.andon@pull` | `sysio.andon::clear`: delegates of `sysio.andon@clear` | +| Solana | liqsol-core `GlobalState.frozen` | `set_frozen(true)`: `GlobalConfig.panic` or `GlobalConfig.admin` | `set_frozen(false)`: the same two | +| Ethereum | OpenZeppelin `paused()` on `SyndicationPool`, `LiqEthToken`, `StakingModule` | `pause()`: any holder of the `panic` role (the deploy's `panicAccount` and the admin) | `unpause()`: the same | + +The three are independent: pulling one changes nothing on the others. When unsure whether a fault +crossed chains, pull all three. + +## Values used below + +Every command reads these shell variables. Fill them in before launch and keep them with this +playbook; they do not change between incidents unless an account is replaced. + +| Variable | Value | +|---|---| +| `DEPOT_URL` | HTTP endpoint of a depot producer node (`clio -u`) | +| `PANIC` | the depot panic account, delegated by native `updateauth` | +| `SOL_RPC` | Solana RPC URL of the outpost's cluster | +| `SOL_PANIC_KEYPAIR` | keypair file of the Solana panic account (`GlobalConfig.panic`) | +| `SOL_ADMIN_KEYPAIR` | keypair file of the Solana admin (`GlobalConfig.admin`) | +| `SOL_CRANK_KEYPAIR` | any funded Solana keypair: it signs `pay_pending_desyndication` | +| `ETH_RPC_URL` | Ethereum RPC URL of the outpost's network | +| `SYNDICATION_POOL`, `OPP_INBOUND`, `OUTPOST_AUTHORITY`, `OUTPOST_MANAGER` | the `SyndicationPool`, `OPPInbound`, `OutpostManagerAuthority` and `OutpostManager` entries of the outpost deploy's `addressFile` | +| `LIQETH_TOKEN`, `STAKING_MODULE`, `LIQETH_AUTHORITY` | the `LiqEthToken`, `StakingModule` and `LiqEthAuthority` entries of the liqEth deploy's `addressFile` | + +A few values belong to the incident itself and are set when it happens: `REASON` (why the cord was +pulled, one line), `NOTE` (what was found, one line), `NEW_PANIC` / `NEW_SOL_PANIC` / `NEW_ETH_PANIC` +and `OLD_ETH_PANIC` (when the panic account is replaced), `PAUSE_BLOCK` (the Ethereum block of the +first `pause()`), `REQUEST_ID` (a deferred desyndication being paid), `RELAY_SIGNER` (a Solana +relay signer's public key), and, for an envelope in doubt, `CHALLENGER`, `CHAIN_CODE`, `TOKEN_CODE`, +`EPOCH_INDEX` and `BOND_REQUEST_ID` (see [Holding and ruling](#holding-and-ruling-steps-5-and-6)), and, +for a skipped desyndication being recredited, `HOLDER` and `QUANTITY` (see +[Reconciling](#reconciling-a-desyndication-the-outpost-did-not-pay)). + +Ethereum transactions below are sent with Foundry's `cast` and a keystore named with `--account` +(`--ledger` works the same way). `SyndicationPool` is not deployed in any environment yet +(wire-ethereum `docs/deploy.md`, Rollout); until it is, skip its lines. + +Every `clio get table` is written in the v6 form `clio get table -S `. The tables +read here (`sysio.andon` `cord` and `andonconfig`, `sysio.synd` `envelopes`, `ledger`, `syndcursors` and +`desyndlog`, `sysio.epoch` `epochstate`, `sysio.msgch` `envlog`) are unscoped KV tables stored under +their contract's own account, so the scope is the contract account; rows come back as `{key, value}`. + +Depot actions signed by `sysio` are shown as `-p sysio@active`. Where `sysio` is held by a multisig, +propose the same action and data with `clio multisig propose` and execute it once approved. + +## Before an incident + +Each item is a deploy or upgrade step. An item not done is a freeze that cannot be pulled. + +1. **Depot.** `sysio.andon` is deployed privileged before `sysio.swap`, `sysio.liq`, `sysio.bond` and + `sysio.synd` ([`contract-upgrade-order.md`](contract-upgrade-order.md)); until it exists every + reader sees a clear cord. Fund permission storage through a RAM-only ROA policy + from an existing registered node owner (`ROA_ISSUER`), then delegate and link the actions: + ```bash + clio -u "$DEPOT_URL" push action sysio.roa addpolicy "[\"sysio.andon\",\"$ROA_ISSUER\",\"0.0000 SYS\",\"0.0000 SYS\",\"0.0100 SYS\",0,0]" -p "$ROA_ISSUER@active" + AUTH=$(jq -cn --arg panic "$PANIC" '{threshold:1,keys:[],accounts:[{permission:{actor:$panic,permission:"active"},weight:1},{permission:{actor:"sysio",permission:"active"},weight:1}]} | .accounts |= sort_by(.permission.actor)') + clio -u "$DEPOT_URL" set account permission sysio.andon pull "$AUTH" active -p sysio.andon@active + clio -u "$DEPOT_URL" set account permission sysio.andon clear "$AUTH" active -p sysio.andon@active + clio -u "$DEPOT_URL" set action permission sysio.andon sysio.andon pull pull -p sysio.andon@active + clio -u "$DEPOT_URL" set action permission sysio.andon sysio.andon clear clear -p sysio.andon@active + clio -u "$DEPOT_URL" get account sysio.andon + ``` + Governance controls the account's active authority before these links exist. The panic + account signs using its own key but declares `sysio.andon@pull` or `sysio.andon@clear`. + Privileged `sysio.synd` automatically pulls with `sysio.andon@active` on a custody shortfall; + that declared permission must exist. Deploy Andon before syndication carries traffic. +2. **Solana.** Upgrade liqsol-core and run the GlobalState migration, + `bash-scripts/feature-flags.sh migrate-wire-global-state-liq-fields` (menu entry `mf`, instruction + `migrate_global_state_liq_fields`; called "the GlobalState migration" below), in the same + maintenance window (see [Upgrade window](#upgrade-window)). Then name the panic account with the admin keypair + (`set-panic`, [Solana commands](#solana-commands)) and read it back with `status`. +3. **Ethereum.** Set `panicAccount` in the deploy (or upgrade) config. A fresh deploy maps `pause` and + `unpause` to the `panic` role from `permissions/outpost.yaml` and `permissions/liqeth.yaml` and + grants the role to `panicAccount` and to the applying admin. On the outpost only `SyndicationPool` + is pausable, and since it is not deployed anywhere yet its mapping always comes from + `permissions/outpost.yaml`. A liqEth environment deployed earlier gets the mapping from the + permissions-only step `liqeth_20260930.01_permissions` (wire-ethereum `docs/deploy.md`, Rolling the + panic role out). +4. **Solana relay funding.** Fund the Solana key each `outpost_solana_client` relay signs + `dispatch_attestations` with (the public key in its `--signature-provider`, logged at start as + `Signer public key:`) for the rent of the payouts the outpost may store: on a freeze, a custody + shortfall or a refused settlement, frozen or not (see [Funding](#funding-the-solana-relay-signer)). +5. **Per-transfer maximum.** Set it on every outpost (see [Per-transfer maximum](#per-transfer-maximum)). + It is a limit, not a switch: the halt is the freeze. + +## What stops and what keeps running + +### Depot + +| While the cord is pulled | Refused or deferred | Still runs | +|---|---|---| +| `sysio.swap` | every action that moves tokens (among them `exchange`, `addliquidity`, `remliquidity`, `withdraw`, `closeext` of a funded deposit, `tickyield` and `accrueyield` with yield owed, `inittoken`) | reads, and actions that move nothing (`sync`, `changefee`, `openext`, `setyield`) | +| `sysio.liq` | `transfer` to anything but a custody contract (`sysio.bond`, `sysio.synd`, `sysio.opreg`), so holders cannot move shadow LIQ between themselves; `claim` by anyone but a custody contract; `queueyield` | `mint`; transfers into a custody contract; `claim` by a custody contract (it moves WIRE into custody) | +| `sysio.bond` | `claim` | `request`, `addbounty`, `accept`, `hold`, `approve`, `rslvvalid`, `rslvinvalid`, `sweepyield`, `prune` | +| `sysio.synd` | the queue step's releases, delivery from `parked`, burns and the forward of a hold share (deferred to the first step after the clear); `desyndicate`, `sweep`, `dropenv`, `sweepyield` (refused: `the andon cord is pulled: funds cannot leave custody`); `linkswept` delivers nothing and returns | intake (`onsynd`, `onyield`, `closeenv`); the queue step's refresh, outcome recording and request issuing; `challenge`; `setconfig`; `importsynd` | +| `sysio.msgch`, `sysio.epoch` | nothing | everything | + +A frozen queue step prints `sysio.synd::queue: the andon cord is pulled; releases, deliveries and burns +wait for the clear` and spends none of its budget. On the first tick after clear, +buckets refill for all elapsed epochs, including the freeze, up to their burst cap. +Challenge windows keep counting (they do not extend). + +**What the freeze does not cover.** The depot freeze covers syndication and swap custody. Operator +collateral and the other depot payouts are a separate concern and are not frozen: + +- `sysio.opreg::claimremit(account, WIRE)` pays through `sysio.token` and runs during a freeze. +- `sysio.opreg::claimremit(account, token_code)` for a shadow LIQ `token_code` pays through `sysio.liq::transfer` to the + operator, which is not a custody contract, so it IS refused while the cord is pulled; the + `remitclaims` row stays and is claimed after the clear. +- The WIRE payouts of `sysio.system` (`claimpay`, `claimnodedis`) and `sysio.dclaim` (`claim`) read no + cord and run. + +If an incident involves one of these, the andon cord does not stop it; the owning contract's own +controls (or a contract redeploy) do. + +### Solana outpost + +| While `frozen` | Refused or deferred | Still runs | +|---|---|---| +| liqsol-core | `synd` (`syndicate_liqsol`), `desynd` (`desyndicate_liqsol`) and `report_liq_yield` refuse with `OutpostFrozen` (6087); a depot `DESYNDICATE_LIQ` is stored as a `PendingPayout` (reason `OutpostFrozen`) instead of paid; `pay_pending_desyndication` refuses | `epoch_in`, `dispatch_attestations` and every other attestation; every instruction outside the syndication surface | + +The outpost also stores a `DESYNDICATE_LIQ` while it is not frozen, for three more reasons +(`PendingPayoutReason`, wire-solana `programs/liqsol-core/src/states/pending_payout.rs:33-52` at +bb134198): `CustodyShortfall`, which also freezes the outpost +([A shortfall-triggered pull](#a-shortfall-triggered-pull)); `LegacyUserRecord`; and `SettlementRefused`, +any other chain-state refusal once the recipient resolved, logged as `DesyndicateLIQSettlementRefused +... err_data=` (`instructions/opp/inbound.rs:5296-5305`). Every one is paid the same way +([Deferred desyndications](#deferred-desyndications)). + +`GlobalState.paused` is a different flag with its own meaning; `set_frozen` does not touch it and it is +not the emergency stop. A `DESYNDICATE_LIQ` with request id 0 cannot key a `PendingPayout` and is +recorded unpaid; `sysio.synd` numbers desyndications from 1, so this only happens when the depot and +the outpost have diverged. The relay skips the yield report of a frozen outpost and logs it at info. + +### Ethereum outpost + +| Contract | `pause()` stops | Keeps running | +|---|---|---| +| `SyndicationPool` | `syndicate` and `realizeYield` (`EnforcedPause`); a depot `DESYNDICATE_LIQ` is stored as a pending desyndication (`DesyndicationDeferred`, reason `OUTPOST_FROZEN`); `payPendingDesyndication` | OPP delivery; views | +| `LiqEthToken` | every liqETH transfer, mint and burn | views | +| `StakingModule` | `batchDeposit`, `topUpValidator` | the rest | + +The relay reads `EnforcedPause` from `realizeYield` as expected and logs it at info every epoch until +the pool is unpaused. A `DESYNDICATE_LIQ` with request id 0, or with a request id already stored, is +dropped (`DesyndicationDropped`). While the pool is paused a release is stored with no solvency +comparison (wire-ethereum `contracts/outpost/SyndicationPool.sol:620-627` at d4fbb8f8). + +**Order on Ethereum.** `SyndicationPool` pays a desyndication with a liqETH transfer. A release the +token or the pool refuses while the pool itself is unpaused (liqETH paused, the transfer returning false +or reverting, an amount above the pool's principal) is stored as a pending desyndication with reason +`SETTLEMENT_REFUSED` (`DesyndicationDeferred`), not paid, and is paid by `payPendingDesyndication` once +the cause clears (for liqETH, once `LiqEthToken` is unpaused). A pool that would hold less than the +depot's outstanding after the payout, a pool below the payout included, is a custody shortfall: the pool +pauses itself and stores the release with reason `CUSTODY_SHORTFALL` +([A shortfall-triggered pull](#a-shortfall-triggered-pull)). Pausing +`SyndicationPool` first and unpausing it last is still the practice: it stores every release under +`OUTPOST_FROZEN` for the whole freeze instead of attempting transfers against a paused token. + +**Pause `LiqEthToken` only when the token itself is at fault.** A paused liqETH blocks every transfer, +mint and burn, including the liqETH staking flows; desyndications arriving while only the token is +paused are stored as `SETTLEMENT_REFUSED` and paid by `payPendingDesyndication` after the unpause. + +## Pulling the cord + +### Depot + +```bash +clio -u "$DEPOT_URL" push action sysio.andon pull "[\"$REASON\"]" -p sysio.andon@pull +clio -u "$DEPOT_URL" get table sysio.andon cord -S sysio.andon # pulled = true, when, reason +``` + +`sysio` may pull with `-p sysio@active` and its own name as the actor. The reason is cut to 256 bytes. +Pulling a pulled cord changes nothing and prints `sysio.andon::pull: the cord is already pulled; +nothing changes`. + +### Solana + +```bash +ANCHOR_PROVIDER_URL="$SOL_RPC" ANCHOR_WALLET="$SOL_PANIC_KEYPAIR" npx ts-node scripts/wire-config/emergencyStop.ts freeze +ANCHOR_PROVIDER_URL="$SOL_RPC" ANCHOR_WALLET="$SOL_PANIC_KEYPAIR" npx ts-node scripts/wire-config/emergencyStop.ts status +``` + +(The script is described in [Solana commands](#solana-commands).) The admin keypair works the same way. +The program logs `set_frozen: frozen=true (was false) by `; setting it again changes nothing. + +### Ethereum + +Pull `SyndicationPool` first: + +```bash +cast send --rpc-url "$ETH_RPC_URL" --account panic "$SYNDICATION_POOL" 'pause()' +cast send --rpc-url "$ETH_RPC_URL" --account panic "$STAKING_MODULE" 'pause()' +for c in "$SYNDICATION_POOL" "$STAKING_MODULE"; do + cast call --rpc-url "$ETH_RPC_URL" "$c" 'paused()(bool)' +done +``` + +Only when liqETH itself is at fault (see "Pause `LiqEthToken` only when the token itself is at fault" +above), also: + +```bash +cast send --rpc-url "$ETH_RPC_URL" --account panic "$LIQETH_TOKEN" 'pause()' +cast call --rpc-url "$ETH_RPC_URL" "$LIQETH_TOKEN" 'paused()(bool)' +``` + +Pausing a paused contract reverts (`EnforcedPause`), which is harmless: it was already frozen. + +## The playbook + +| Step | Real incident | False alarm | +|---|---|---| +| 1 | Pull the cord on the affected chain ([Pulling the cord](#pulling-the-cord)). Pull the others if the fault may have crossed. | Same. When unsure, pull. | +| 2 | Replace the panic account in case its key is the fault ([Replacing the panic account](#replacing-the-panic-account)). | Skip. | +| 3 | Record the block, the epoch and the last accepted envelope digest on each chain ([Recording](#recording-the-state-of-each-chain)). | Same. | +| 4 | Read the depot's `mismatch` table and each outpost's shortfall records, and compare each outpost's custody with the depot's outstanding shadow ([Comparing totals](#comparing-totals), [A shortfall-triggered pull](#a-shortfall-triggered-pull)). | Same. No new `mismatch` row; custody covers the outstanding. | +| 5 | For each envelope in doubt, place a hold (`sysio.synd::challenge`), or rule it invalid (`sysio.bond::rslvinvalid`) if it is unbonded. | None. | +| 6 | `sysio` rules each held request (`rslvvalid` / `rslvinvalid`). Invalid envelopes burn as in §4.7 once the cord clears. | None. | +| 7 | Fix and redeploy the faulty contract while still frozen, in the order of [`contract-upgrade-order.md`](contract-upgrade-order.md). | None. | +| 8 | Repair custody and clear the outposts first. Have each repaired outpost emit a post-repair message (or wait for its next one); wait for depot admission, verify its sequence in `syndcursors` and no `mismatch` row for it, then clear the depot cord and do the work that waited ([Clearing](#clearing-outposts-first-then-the-depot)). | Same admission check before clearing the depot. | +| 9 | Continue monitoring admitted messages from each outpost for `mismatch` rows and `SHORTFALL` ([Comparing totals](#comparing-totals)). Record earlier pre-repair sequences reporting the known shortfall as part of this incident. | Same. | +| 10 | Write up the cause and what was burned or returned ([What to record](#what-to-record)). | Write up what triggered the pull. | + +Challenge windows keep counting during a freeze and challenges stay open, so step 5 can be done while +frozen. A frozen queue step still issues requests, so `crank` during the freeze gives every WAITING +envelope a request to hold or rule. + +### Holding and ruling (steps 5 and 6) + +```bash +clio -u "$DEPOT_URL" get table sysio.synd envelopes -S sysio.synd -r -l 50 # chain_code, token_code, epoch_index, state, request_id +clio -u "$DEPOT_URL" push action sysio.synd crank '[256]' -p "$PANIC@active" # issues the requests of WAITING envelopes +clio -u "$DEPOT_URL" push action sysio.synd challenge \ + "{\"challenger\": \"$CHALLENGER\", \"chain_code\": $CHAIN_CODE, \"token_code\": $TOKEN_CODE, \"epoch_index\": $EPOCH_INDEX}" \ + -p "$CHALLENGER@active" +clio -u "$DEPOT_URL" push action sysio.bond rslvinvalid "[$BOND_REQUEST_ID]" -p sysio@active +clio -u "$DEPOT_URL" push action sysio.bond rslvvalid "[$BOND_REQUEST_ID]" -p sysio@active +``` + +`CHAIN_CODE` and `TOKEN_CODE` are the `chain_code` and `token_code` objects exactly as the `envelopes` +row prints them, `EPOCH_INDEX` its `epoch_index`, and `BOND_REQUEST_ID` its `request_id`. `challenge` +accepts only an envelope in state REQUESTED, RELEASABLE or DONE whose request is OPEN or BONDED: a +WAITING envelope has no request yet, so run `crank` first. `CHALLENGER` must be an account with no +contract code, and not `sysio.synd` or `sysio.bond`. The challenger pays the request's hold bond plus the pair's `challenge_extra`; both transfers go into +custody, so the challenge runs while the depot is frozen. `rslvinvalid` rules a request bonded or not, +so an unbonded envelope in doubt is ruled invalid directly. The queue records each ruling on its next +step; while the cord is pulled the burn of an INVALID envelope and the forward of a VALID ruling's hold +share wait for the clear. + +### Replacing the panic account + +- Depot: rerun both `set account permission` commands above with `PANIC="$NEW_PANIC"`. + Replacing the authorities revokes the previous panic account. Keep the governance delegate. +- Solana, as the admin: + ```bash + ANCHOR_PROVIDER_URL="$SOL_RPC" ANCHOR_WALLET="$SOL_ADMIN_KEYPAIR" npx ts-node scripts/wire-config/emergencyStop.ts set-panic "$NEW_SOL_PANIC" + ``` +- Ethereum, on both access managers. The `panic` role id is read from each manager. On the outpost, + the deploy's `managerHandoff` moves `ADMIN_ROLE` of `OutpostManagerAuthority` to `OutpostManager` and + leaves the deployer only the configuration role, so a `grantRole` or `revokeRole` sent straight to + `OutpostManagerAuthority` reverts. Go through `OutpostManager` instead, signed by the configuration + role holder: its `grantRole(uint64,address)` grants, and its `execute(address,bytes)` forwards the + `revokeRole` call to the authority. The liqEth side has no handoff, so its admin calls + `LiqEthAuthority` directly: + ```bash + # Outpost access manager (SyndicationPool), through OutpostManager + ROLE=$(cast call --rpc-url "$ETH_RPC_URL" "$OUTPOST_AUTHORITY" 'getTargetFunctionRole(address,bytes4)(uint64)' "$SYNDICATION_POOL" "$(cast sig 'pause()')") + cast send --rpc-url "$ETH_RPC_URL" --account admin "$OUTPOST_MANAGER" 'grantRole(uint64,address)' "$ROLE" "$NEW_ETH_PANIC" + cast send --rpc-url "$ETH_RPC_URL" --account admin "$OUTPOST_MANAGER" 'execute(address,bytes)' "$OUTPOST_AUTHORITY" \ + "$(cast calldata 'revokeRole(uint64,address)' "$ROLE" "$OLD_ETH_PANIC")" + # liqEth access manager (LiqEthToken, StakingModule), direct + ROLE=$(cast call --rpc-url "$ETH_RPC_URL" "$LIQETH_AUTHORITY" 'getTargetFunctionRole(address,bytes4)(uint64)' "$LIQETH_TOKEN" "$(cast sig 'pause()')") + cast send --rpc-url "$ETH_RPC_URL" --account admin "$LIQETH_AUTHORITY" 'grantRole(uint64,address,uint32)' "$ROLE" "$NEW_ETH_PANIC" 0 + cast send --rpc-url "$ETH_RPC_URL" --account admin "$LIQETH_AUTHORITY" 'revokeRole(uint64,address)' "$ROLE" "$OLD_ETH_PANIC" + ``` + Then put the new address in `panicAccount` in the deploy config, so the next permissions apply + grants it rather than the old one. + +### Recording the state of each chain + +```bash +# Depot: head and LIB, the epoch, the last envelopes accepted per outpost, the cord +clio -u "$DEPOT_URL" get info +clio -u "$DEPOT_URL" get table sysio.epoch epochstate -S sysio.epoch +clio -u "$DEPOT_URL" get table sysio.msgch envlog -S sysio.msgch -r -l 20 +clio -u "$DEPOT_URL" get table sysio.andon cord -S sysio.andon + +# Solana: slot, next inbound epoch and the last accepted envelope hash, the flags and totals +solana -u "$SOL_RPC" slot +ANCHOR_PROVIDER_URL="$SOL_RPC" ANCHOR_WALLET="$SOL_CRANK_KEYPAIR" npx ts-node scripts/wire-config/emergencyStop.ts status + +# Ethereum: block, next inbound epoch and the last accepted envelope hash +cast block-number --rpc-url "$ETH_RPC_URL" +cast call --rpc-url "$ETH_RPC_URL" "$OPP_INBOUND" 'nextEpochIndex()(uint32)' +cast call --rpc-url "$ETH_RPC_URL" "$OPP_INBOUND" 'previousEpochHash()(bytes32)' +``` + +### Comparing totals + +The depot compares for you. Every `SYNDICATE_LIQ` and `LIQ_YIELD` an outpost sends carries its live +custody balance (`total_syndicated`), and `sysio.synd` checks it against the depot's outstanding shadow +of the token (the `sysio.liq` supply plus the yield in `liqpending`) as it admits the message +([`sysio-synd.md`](sysio-synd.md), The solvency check). Custody below the outstanding is a shortfall: +it is written to `mismatch` and pulls the cord. Custody above it is normal and only printed as `EXCESS`. + +```bash +clio -u "$DEPOT_URL" get table sysio.synd mismatch -S sysio.synd -l 100 # every shortfall: chain_code, token_code, epoch_index, sequence, kind, reported, expected, at +clio -u "$DEPOT_URL" get table sysio.liq stat -S sysio.liq # supply per shadow symbol +clio -u "$DEPOT_URL" get table sysio.liq liqpending -S sysio.liq # released yield not yet queued +clio -u "$DEPOT_URL" get table sysio.synd syndcursors -S sysio.synd # last_sequence admitted per outpost +clio -u "$DEPOT_URL" get table sysio.synd desyndlog -S sysio.synd -r -l 20 # total_syndicated each desyndication carried +cast call --rpc-url "$ETH_RPC_URL" "$SYNDICATION_POOL" 'poolBalanceDepot()(uint64)' +cast call --rpc-url "$ETH_RPC_URL" "$SYNDICATION_POOL" 'liqSequence()(uint64)' +``` + +and `status` on Solana (`liq_sequence`, the pool ATA balance). What to check: + +- Each `mismatch` row names the outpost, token and sequence of the message that reported the shortfall, + the custody it reported and the outstanding it was compared with (`expected`). Match the sequence to + the outpost's `synd` call (Solana) or `Syndicated` / yield event (Ethereum) to find the envelope. + Rows are never pruned; note the last one read, so the next reading shows only new rows. +- Now: each outpost's custody (the Solana pool ATA, `poolBalanceDepot`) is at least the depot's + outstanding for its token (`supply` plus `liqpending`). Messages in flight only widen the margin: an + unpaid or pending desyndication, an INVALID burn and a `recredit` all leave custody above the + outstanding. +- `syndcursors.last_sequence` for an outpost is at most the outpost's sequence (`liqSequence` / + `liq_sequence`); the gap is messages the depot has not yet admitted, and so not yet compared. +- Every `desyndlog` request id the outpost has not paid is still in flight on the depot, stored as a + pending payout on the outpost ([Deferred desyndications](#deferred-desyndications)), or skipped by the + outpost; a skipped one is reconciled by the recredit rule + ([Reconciling](#reconciling-a-desyndication-the-outpost-did-not-pay)). +- Between two readings during a freeze, no outpost custody balance falls. + + +### Reconciling a desyndication the outpost did not pay + +Match every `desyndlog` request id with the outpost's record of it. Each one is paid, stored or skipped +([`sysio-synd.md`](sysio-synd.md), Desyndication): + +- **Paid**: Solana logs `opp_outpost: desyndicate_liq: paid ... request_id=` (wire-solana + `programs/liqsol-core/src/instructions/opp/inbound.rs:5759-5760` at bb134198), and after a stored + record also `pay_pending_desyndication: request_id= paid`; + Ethereum emits `Desyndicated(recipient, nativeAmount, depotAmount, requestId)` and + `desyndicationsPaid(requestId)` is true. +- **Stored**: a `PendingPayout` exists at `["pending_desyndication", request_id_le8]` (`npx ts-node scripts/wire-config/emergencyStop.ts + pending`), or `pendingDesyndications(requestId)` has a non-zero `depotAmount`. Pay it + ([Deferred desyndications](#deferred-desyndications)). Never recredit it. +- **Skipped**: Solana logged `opp_outpost: DesyndicateLIQError ... request_id= err_data=` + (`inbound.rs:4986-4994`) for a content or configuration refusal; Ethereum emitted + `DesyndicationDropped(requestId, depotAmount, reason)` with `wrong chain`, + `wrong token`, `amount out of range` or `unresolvable recipient` (wire-ethereum + `contracts/outpost/SyndicationPool.sol:594-618` at d4fbb8f8). + +**The recredit rule.** Governance returns the shadow of a skipped desyndication with +`sysio.liq::recredit`, and only after confirming the outpost holds no record of the request and never +paid it: + +```bash +# Solana: the PendingPayout PDA of REQUEST_ID must not exist +ANCHOR_PROVIDER_URL="$SOL_RPC" ANCHOR_WALLET="$SOL_CRANK_KEYPAIR" npx ts-node scripts/wire-config/emergencyStop.ts pending # REQUEST_ID not listed +# Ethereum: nothing stored (depotAmount 0) and never paid (false) +cast call --rpc-url "$ETH_RPC_URL" "$SYNDICATION_POOL" 'pendingDesyndications(uint64)(address,uint64,uint8)' "$REQUEST_ID" +cast call --rpc-url "$ETH_RPC_URL" "$SYNDICATION_POOL" 'desyndicationsPaid(uint64)(bool)' "$REQUEST_ID" +# The depot: the net amount of the request, then the recredit to the holder who signed the desyndicate +clio -u "$DEPOT_URL" get table sysio.synd desyndlog -S sysio.synd -r -l 50 # the row whose request_id is REQUEST_ID +clio -u "$DEPOT_URL" push action sysio.liq recredit "[\"$HOLDER\", \"$QUANTITY\"]" -p sysio.liq@active +``` + +`recredit` is signed by `sysio.liq` itself (`contracts/sysio.liq/src/sysio.liq.cpp:86`); `QUANTITY` is the +`desyndlog` row's `amount` in the shadow symbol. A stored release is paid by the crank: recrediting it as +well pays the holder twice, and the crank's payment then takes custody down by the amount the recredit +added to the outstanding. The Ethereum drops `already paid` and `already pending` +(`SyndicationPool.sol:609-612`, `:638-641`, `:687-690`) and Solana's `DesyndicateLIQAlreadyPending` +(`inbound.rs:5361-5378`) are +re-emits of an id already paid or stored: never recredit them either. A recredit writes nothing in +`sysio.synd`; the next comparison reads the grown supply, and the custody the outpost never paid out +covers it. + +### A shortfall-triggered pull + +Each chain names the two readings differently. Custody: the depot's `reported`, Solana's +`pool_balance`, Ethereum's `poolBalanceDepot`. Outstanding: the depot's `expected`, Solana's +`depot_outstanding`, Ethereum's `totalSyndicated`. The depot's `expected` includes the message's own +syndication; the outposts' custody is read before the payout leaves. + +A custody shortfall pulls the cord of the chain that found it: the depot finds one when an outpost's +`SYNDICATE_LIQ` or `LIQ_YIELD` reports less custody than the depot's outstanding; an outpost finds one +when a `DESYNDICATE_LIQ` would leave it holding less than the outstanding the depot carried. A shortfall +is not proof of loss: either reading may be wrong. Compare the live balances of +[Comparing totals](#comparing-totals) before ruling envelopes, and pull the other chains if the fault +may have crossed. + +**Depot.** A cord pulled by `sysio.synd` is identifiable in the action trace and has a reason of the form +`custody shortfall seq `. Start at step 3 of [the playbook](#the-playbook): the +`mismatch` row for `(, )` has the figures (`reported`, `expected`). The message that reported +it was still held and minted, and every later message is compared too: each further shortfall adds its +own row while the cord stays pulled (`CORD ALREADY PULLED -- the shortfall is recorded` on the console). + +Repairing custody does not change a `SyndicateLIQ` or `LIQYield` already built: it still carries the +pre-repair custody. If admitted after the depot cord is cleared, that message can write a `mismatch` +row and pull the cord again. Repair custody, have the repaired outpost emit a message after the repair +(or wait for its next one), then wait for the depot to admit that message and confirm no `mismatch` +row for its sequence before clearing the depot cord. Use the `syndcursors` check under +[Clearing](#clearing-outposts-first-then-the-depot). Mismatch rows for earlier pre-repair sequences +reporting the known shortfall are expected: record them under this incident, not as a new incident. + +Andon must be deployed before processing envelopes. A failed inline pull aborts the +transaction atomically when the account or declared permission is missing. Bootstrap must +deploy the Andon code as well as create its account before admitting traffic. + +**Solana.** The outpost sets its own `GlobalState.frozen`, moves nothing and stores the payout +(`handle_desyndicate_liq`, `inbound.rs:5269-5289`). The program log of the `dispatch_attestations` +transaction carries the evidence: + +``` +opp_outpost: DesyndicateLIQCustodyShortfall user= token_code= amount= request_id= pool_balance= depot_outstanding= -- outpost frozen, payout stored +opp_outpost: DesyndicateLIQDeferred ... reason=CustodyShortfall { pool_balance: , depot_outstanding: } -- ... +``` + +`npx ts-node scripts/wire-config/emergencyStop.ts status` shows `frozen: true` and the pool balance; `pending` lists the record with +both figures. `b - a < o` is the shortfall. From then on every syndication instruction refuses with +`OutpostFrozen` and every later `DESYNDICATE_LIQ` is stored as `OutpostFrozen` without being compared +(`inbound.rs:5244`), as in any freeze. Clearing the flag is the operator's decision: +`pay_pending_desyndication` applies no floor (`inbound.rs:5822-5833`), so the stored payout is paid as +soon as the flag clears. Clear only once the pool covers the depot's current outstanding plus every +payout still stored. + +**Ethereum.** `SyndicationPool` emits `CustodyShortfall(requestId, poolBalanceDepot, totalSyndicated)` +(`SyndicationPool.sol:236`), pauses itself if it is not paused already, moves no liqETH and stores the +release with reason `CUSTODY_SHORTFALL` (`_recordCustodyShortfall`, `:660-675`). `_handleDesyndicateLIQ` +checks, in order (`:591-652`): + +1. content (`wrong chain`, `wrong token`, `amount out of range`), then `already paid`, then the recipient + (`unresolvable recipient`): each dropped; +2. paused: stored as `OUTPOST_FROZEN` with no comparison (request id 0 dropped); +3. the solvency check: a shortfall emits the event, pauses the pool and stores the release. A request id + 0 still emits the event and pauses the pool, then is dropped (`request id 0 cannot be deferred`); a + request id already stored still emits the event and pauses the pool, then is dropped as + `already pending`, the first record kept; +4. `already pending`: dropped; +5. settlement: paid, or a refusal stored as `SETTLEMENT_REFUSED` (request id 0 dropped). + +Read the evidence: + +```bash +cast logs --rpc-url "$ETH_RPC_URL" --address "$SYNDICATION_POOL" \ + 'CustodyShortfall(uint64 requestId, uint64 poolBalanceDepot, uint64 totalSyndicated)' +cast call --rpc-url "$ETH_RPC_URL" "$SYNDICATION_POOL" 'paused()(bool)' # true +``` + +Only `SyndicationPool` is paused; pause `StakingModule` or `LiqEthToken` by hand if the +fault may reach them ([Pulling the cord](#pulling-the-cord)). `payPendingDesyndication` pays the stored +release after `unpause`, with the same rule as Solana: unpause only once the pool covers the depot's +outstanding plus every release still stored. + +**A negative liqETH rebase** (`Yield.sol` calls `liqEth.decreaseIndex`, wire-ethereum +`contracts/liqEth/Yield.sol:514-518`) lowers the pool's custody with no depot burn: a true shortfall, +raised on both sides. Recovery at v1 is a top-up. Send liqETH to the pool until `poolBalanceDepot()` +covers the depot's outstanding plus every stored release (a plain transfer: a donation only raises +custody), then unpause the pool and pay the stored releases. Have the repaired outpost emit a +post-repair message (or wait for its next one), wait for depot admission and confirm no `mismatch` row +for it as below; only then clear the depot cord. A governance +write-down of the depot's outstanding instead is pending the owner's ruling. + +## Clearing: outposts first, then the depot + +Clear the outposts first: each one then pays the desyndications it stored and reports its yield again, +while the depot, cleared last, still releases nothing. Clearing the depot last means no release or new +desyndication is made against an outpost that is still frozen. After a custody shortfall, keep the depot +cord pulled until each repaired outpost's post-repair message has been admitted with no mismatch, +as checked in step 3 below; an older message in flight can still carry the pre-repair shortfall. + +### 1. Solana + +```bash +ANCHOR_PROVIDER_URL="$SOL_RPC" ANCHOR_WALLET="$SOL_PANIC_KEYPAIR" npx ts-node scripts/wire-config/emergencyStop.ts clear +ANCHOR_PROVIDER_URL="$SOL_RPC" ANCHOR_WALLET="$SOL_CRANK_KEYPAIR" npx ts-node scripts/wire-config/emergencyStop.ts pending +ANCHOR_PROVIDER_URL="$SOL_RPC" ANCHOR_WALLET="$SOL_CRANK_KEYPAIR" npx ts-node scripts/wire-config/emergencyStop.ts pay-all +ANCHOR_PROVIDER_URL="$SOL_RPC" ANCHOR_WALLET="$SOL_CRANK_KEYPAIR" npx ts-node scripts/wire-config/emergencyStop.ts pending # empty, or only records that cannot be paid yet +``` + +### 2. Ethereum + +Unpause `SyndicationPool` last, so the liqETH it pays out can move: + +```bash +cast send --rpc-url "$ETH_RPC_URL" --account panic "$LIQETH_TOKEN" 'unpause()' # only if it was paused +cast send --rpc-url "$ETH_RPC_URL" --account panic "$STAKING_MODULE" 'unpause()' +cast send --rpc-url "$ETH_RPC_URL" --account panic "$SYNDICATION_POOL" 'unpause()' +``` + +Then pay every stored desyndication (see [Paying on Ethereum](#paying-on-ethereum)). + +### 3. Depot + +After repairing custody and clearing the outposts, have each repaired outpost emit a `SyndicateLIQ` +or `LIQYield` built after the repair (or wait for its next one). Record its `chain_code`, `sequence` +and evidence of emission after the repair. Keep the depot cord pulled while waiting for admission: +intake continues while frozen. Read the per-chain cursor and the mismatch records with clio v6: + +```bash +clio -u "$DEPOT_URL" get table sysio.synd syndcursors -S sysio.synd +clio -u "$DEPOT_URL" get table sysio.synd mismatch -S sysio.synd +``` + +For the repaired outpost's `chain_code`, wait until `syndcursors.last_sequence` reaches the post-repair +message's `sequence`, and confirm there is no `mismatch` row with that `(chain_code, sequence)`. +Read all result pages when checking these tables. Confirm the message was actually admitted: if the +cursor has already advanced past it, a lower-sequence message can be dropped as a replay, so the +cursor alone plus an absent mismatch is not proof that this message passed the custody check. +Use its admission trace, or observe a later post-repair message's admission and check that sequence. +A mismatch on the post-repair message means recovery is not complete; investigate before clearing. +Earlier pre-repair sequences can add mismatch rows while waiting: retain and record them as evidence +of this incident, not as a new incident. Once the post-repair admission check passes for every repaired +outpost, clear the depot cord: + +```bash +clio -u "$DEPOT_URL" push action sysio.andon clear "[\"$NOTE\"]" -p sysio.andon@clear +clio -u "$DEPOT_URL" get table sysio.andon cord -S sysio.andon # pulled = false, when, reason +``` + +Clearing a clear cord changes nothing and prints `sysio.andon::clear: the cord is not pulled; nothing +changes`. Then drain the queue. Every step that waited during the freeze is done by the first queue +step after the clear: releases, deliveries from `parked`, the INVALID burns and the hold-share +forwards. `closeenv` runs a step inline on each inbound envelope, but only within +`CLOSEENV_QUEUE_LIMIT` (16), so crank it: + +```bash +clio -u "$DEPOT_URL" push action sysio.synd crank '[256]' -p "$PANIC@active" +``` + +`crank` is permissionless (any account may sign it) and never throws. Repeat it until a step releases +nothing more: every `envelopes` row is DONE or INVALID, or waits only on its bucket (`syndication bucket +is empty until it refills`), which refills for every elapsed depot epoch up to its burst cap. A backlog longer than one step +sees needs a crank with a large limit before `sysio.bond`'s prune retention (7 days after a ruling) +elapses (see [`sysio-synd.md`](sysio-synd.md), Operational rule). If the transaction runs out of CPU, +use a smaller limit. After the clear, signed payouts refused during the freeze (`sysio.bond::claim`, +`sysio.liq::claim`, `sysio.synd::sweep`, `sysio.opreg::claimremit` of shadow LIQ) are retried by +whoever is owed; nothing they are owed was lost. + +## Deferred desyndications + +A desyndication an outpost stored instead of paying (while it was frozen, on a custody shortfall, or +on a refused settlement) was burned on the depot and is held on the outpost. Neither relay pays it: +paying it is this step, once its cause clears (for a freeze or a shortfall, after the outpost clears). +Each record is paid exactly once; a second payment fails. Match each one against the depot's +`desyndlog` by request id. + +### Paying on Solana + +The record is a `PendingPayout` account at seeds `["pending_desyndication", request_id as 8 +little-endian bytes]` under liqsol-core, holding `request_id`, `user`, `token_code`, `amount`, +`rent_payer` and `reason`, 114 bytes in all (`PendingPayout::SPACE`, wire-solana +`programs/liqsol-core/src/states/pending_payout.rs:82`, pinned by its test `:121-128`). The reason is one +of four (`:33-52`): + +| Reason | Stored when | Payable once | +|---|---|---| +| `OutpostFrozen` | `GlobalState.frozen` was set | the flag is cleared | +| `LegacyUserRecord` | a share record (pool or user side) was still the legacy layout | the record is migrated (below) | +| `CustodyShortfall { pool_balance, depot_outstanding }` | the solvency check failed; the outpost froze itself ([A shortfall-triggered pull](#a-shortfall-triggered-pull)) | the flag is cleared | +| `SettlementRefused` | any other chain-state refusal once the recipient resolved; the reason is in the `DesyndicateLIQSettlementRefused ... err_data=` log | that state is repaired | + +The tags are per chain: Solana's order is `OutpostFrozen` 0, `LegacyUserRecord` 1, `CustodyShortfall` +2, `SettlementRefused` 3, and Ethereum's differs (below: `SETTLEMENT_REFUSED` is 1 there). Never read +a reason by its number across chains; read it by name. + +The settlement logs `DesyndicateLIQDeferred user=... token_code=... amount=... request_id=... +pending= reason=` (`instructions/opp/inbound.rs:5403-5411`). `pending` lists every +record (by account type, so none is missed); `pay ` pays one; `pay-all` pays each in turn +and reports the ones that refuse. `pay_pending_desyndication` refuses, and keeps the record, while the +outpost is frozen (`OutpostFrozen`), before the GlobalState migration has run (`GlobalStateMigrationRequired`), while a share +record is legacy (`LegacyUserRecordMigrationRequired`), outside PostLaunch, and on any chain-state +refusal (`PendingDesyndicationNotPayable` 6089, reason logged). On payment the account closes and its +rent returns to `rent_payer`. + +**Legacy share records.** A payout whose pool-side or user-side `UserRecord` still has the legacy +layout is stored with reason `LegacyUserRecord`, frozen or not. Migrate the record, then pay: + +- the holder signs `register_user` for their liqSOL ATA, or +- the admin runs `migrate_user_record`, which accepts only a legacy record with no shares and an empty + ATA (`AmbiguousLegacyState` otherwise); `anchor run migrate-user-records -- --execute` does it in + batches for every such record. + +A legacy record that still carries shares is refused by both (`AmbiguousLegacyState`); its payout stays +stored until that record can be migrated. + +### Paying on Ethereum + +The record is `pendingDesyndications(requestId)` on `SyndicationPool` (`recipient`, `depotAmount`, +`reason`); a stored record has a non-zero `depotAmount`. Its `reason` (`PendingPayoutReason`, wire-ethereum +`contracts/outpost/SyndicationPool.sol:155-159` at d4fbb8f8, `uint8` in the event and the getter) is one +of three: + +| `reason` | Value | Stored when | +|---|---|---| +| `OUTPOST_FROZEN` | 0 | the pool was paused | +| `SETTLEMENT_REFUSED` | 1 | the pool was unpaused but the payout was refused: liqETH paused, the transfer returning false or reverting, or an amount above the pool's principal | +| `CUSTODY_SHORTFALL` | 2 | the solvency check failed and the pool paused itself ([A shortfall-triggered pull](#a-shortfall-triggered-pull)) | + +These values are Ethereum's own; Solana numbers its reasons differently (`SettlementRefused` is 3 +there). All three are found and paid the same way. A request id is outstanding when it has a +`DesyndicationDeferred` event and no later `Desyndicated`. From the block of the pause (or of the +`CustodyShortfall` event): + +```bash +cast logs --rpc-url "$ETH_RPC_URL" --from-block "$PAUSE_BLOCK" --address "$SYNDICATION_POOL" \ + 'DesyndicationDeferred(uint64 indexed requestId, address indexed recipient, uint64 depotAmount, uint8 reason)' +# for each requestId (topic 1), as REQUEST_ID: +cast call --rpc-url "$ETH_RPC_URL" "$SYNDICATION_POOL" 'pendingDesyndications(uint64)(address,uint64,uint8)' "$REQUEST_ID" +cast send --rpc-url "$ETH_RPC_URL" --account crank "$SYNDICATION_POOL" 'payPendingDesyndication(uint64)' "$REQUEST_ID" +``` + +`payPendingDesyndication` is permissionless. It reverts while paused, with `WIRE_NoPendingDesyndication` +once paid (or when nothing is stored), and with `WIRE_PendingDesyndicationUnpayable` when the pool +cannot pay now (the record is kept; retry after the pool's state changes). There is no cancel. + +## Funding the Solana relay signer + +Storing a payout creates a `PendingPayout` account (114 bytes) whose rent the relay's Solana signer +pays in the `dispatch_attestations` transaction that settles the `DESYNDICATE_LIQ` +(`rent_payer: payer.key()`, `instructions/opp/inbound.rs:5398`). That happens for every stored reason, +frozen or not: a freeze, a custody shortfall, a legacy share record, a refused settlement. It is +refunded to that signer when the payout is paid. A signer that cannot fund the rent aborts the dispatch +window; the epoch waits (nothing is skipped) until the signer is funded and the relay retries. The +relay supplies the record's PDA writable for every non-zero request id +(`plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp:1416`), so any desyndication may +need the rent. Keep each relay signer funded for the desyndications in flight, and more during a freeze: + +```bash +solana -u "$SOL_RPC" rent 114 # lamports per stored payout +solana -u "$SOL_RPC" balance "$RELAY_SIGNER" +``` + +## Upgrade window + +- **Solana.** The `frozen` flag is appended to `GlobalState` (8+131 to 8+132 bytes). Run the GlobalState + migration in the same maintenance window as the liqsol-core upgrade: + ```bash + bash-scripts/feature-flags.sh migrate-wire-global-state-liq-fields # dry-run + bash-scripts/feature-flags.sh migrate-wire-global-state-liq-fields --execute + ``` + (wire-solana `runbooks/post-launch-cleanup.md`). Until it runs, every typed `GlobalState` + instruction refuses the short account, `set_frozen` included, so the outpost cannot be frozen; a + `DESYNDICATE_LIQ` aborts its dispatch with `GlobalStateMigrationRequired` (retried after the GlobalState migration, not + skipped), so the epoch waits; and the relay treats the yield report as not due and logs a warning. +- **Depot.** Deploy `sysio.andon` and configure native pull/clear permissions before the four contracts that read the cord; + redeploy all five together whenever the `cord` row layout changes + ([`contract-upgrade-order.md`](contract-upgrade-order.md)). +- **Ethereum.** Run the permissions steps above with `panicAccount` set. + +## Pullers + +Manage delegates with native `updateauth` and inspect them with `clio get account sysio.andon`. +Pull and clear have separate authorities, so a delegated puller need not be able to clear. +Privileged contracts bypass inline authorization checks: revoking a permission delegate alone +cannot constrain privileged `sysio.synd`. If that privilege is removed, change its inline +permission to `sysio.andon@pull` and grant `sysio.synd@sysio.code` in that authority. + +## Per-transfer maximum + +Each outpost refuses a single syndication above its maximum. The halt is the freeze, never a maximum of +0: 0 is the fail-closed value of an outpost nobody configured. + +- Solana: `GlobalConfig.max_syndication_per_transfer`, in the smallest liqSOL unit (9 decimals), set + by the admin only (the panic account cannot): + ```bash + MAX_SYNDICATION_PER_TRANSFER=250000000000 anchor run set-max-syndication # 250 liqSOL + ``` + (wire-solana `runbooks/syndication-maximum.md`). A refused `synd` is `SyndicationExceedsMaximum` + (6090), or `SyndicationMaximumUnset` (6091) at 0. +- Ethereum: `SyndicationPool.maxSyndicationPerTransfer`, in wei, set through the `config` role (the + `panic` role cannot): + ```bash + cast send --rpc-url "$ETH_RPC_URL" --account admin "$SYNDICATION_POOL" 'setMaxSyndicationPerTransfer(uint256)' 250000000000000000000 + cast call --rpc-url "$ETH_RPC_URL" "$SYNDICATION_POOL" 'maxSyndicationPerTransfer()(uint256)' + ``` + +## What to record + +Keep one record per incident, real or false alarm: + +- who pulled each cord, when, and the reason given (action traces and transaction signatures identify actors; the depot `cord` row keeps only + `pulled`, `when`, and the latest transition’s `reason`); +- per chain at the pull and at the clear: the block or slot, the epoch, the last accepted envelope + digest ([Recording](#recording-the-state-of-each-chain)); +- the `mismatch` rows and the custody and outstanding readings of [Comparing totals](#comparing-totals), at the pull and after the + first envelope from each outpost following the clear; for a custody repair, also the post-repair + message's chain and sequence, emission and admission evidence, the observed `syndcursors.last_sequence`, + the absence of its `mismatch` row before clearing, and earlier pre-repair mismatch rows; +- for a real incident: each envelope held or ruled, its request id and outcome, what was burned + (`envelopes.burned`) and what was returned; each redeploy; +- every deferred desyndication: request id, amount, reason, the transaction that paid it, and any still + unpaid with its refusal; for a `CustodyShortfall` / `CUSTODY_SHORTFALL` record, the two balances it + carries; +- every skipped desyndication recredited: request id, holder, quantity, and the reads that showed no + record and no payment; +- whether the panic account was replaced, and the new one; +- for a false alarm: what triggered the pull and what, if anything, should change so it does not recur. + +## Solana commands + +The freeze instructions are packaged as wire-solana `scripts/wire-config/emergencyStop.ts`, run from the +root of the wire-solana checkout whose `target/idl/liqsol_core.json` and `target/types/liqsol_core.ts` +match the deployed program (an `anchor build` of the deployed commit writes both). Run it directly with +`npx ts-node`, never with `anchor run`: `anchor run` overrides `ANCHOR_PROVIDER_URL` and `ANCHOR_WALLET` +from `Anchor.toml` (a remote cluster and the deployment wallet), which would send a freeze to the wrong +cluster. The script refuses to run unless both variables are set explicitly: + +```bash +ANCHOR_PROVIDER_URL="$SOL_RPC" ANCHOR_WALLET="$SOL_PANIC_KEYPAIR" npx ts-node scripts/wire-config/emergencyStop.ts [arg] +``` + +Before each command it prints the RPC URL, the cluster's genesis hash and the signer's public key. Check +all three (the genesis hash against the one recorded for the outpost's cluster, the signer against the +keypair you meant) before confirming a write. Even `status` and `pending` need `ANCHOR_WALLET` set, to any +keypair file, because the provider requires one; they sign nothing. Keypair per command: +`$SOL_PANIC_KEYPAIR` (or `$SOL_ADMIN_KEYPAIR`) for `freeze` and `clear`, `$SOL_ADMIN_KEYPAIR` for `set-panic`, +`$SOL_CRANK_KEYPAIR` for `pay`, `pay-all`, `status` and `pending`. + +| Command | What it does | Signer | +|---|---|---| +| `status` | flags, panic account, maximum, epoch, sequence, totals and the pool balance | any | +| `freeze` / `clear` | `set_frozen(true)` / `set_frozen(false)` | the panic account or the admin | +| `set-panic ` | `set_panic` | the admin | +| `pending` | every stored `PendingPayout`, with a shortfall record's two balances | any | +| `pay ` | `pay_pending_desyndication` for one request id | any | +| `pay-all` | `pay_pending_desyndication` for every stored record, reporting each refusal | any | From a8512c19ffde359719082dd200b2bfb97ead0857 Mon Sep 17 00:00:00 2001 From: David J Parrott Date: Tue, 6 Oct 2026 20:36:17 -0400 Subject: [PATCH 2/5] Bond operator collateral in depot-native tokens Allow operator-signed deposits and withdrawals in WIRE or registered shadow LIQ, with eligibility measured against every configured token minimum. Resolve custody through one shared native-token lookup. Attribute a custodian's shadow yield to its individual bonded positions and defer remits into pull-based claims so hostile recipients cannot halt epoch advancement. Carry bounded yield accounting through withdrawal, termination, slashing, and re-registration. Include collateral, rounding, claim, and reentrancy coverage with the operator and shared-custody documentation. Keep the existing external collateral interfaces available until the dedicated retirement chapter. Keep multisig review coverage independent of production WASM size with a fixed-size valid module. Verify two-chunk storage, exact payloads, and reassembled transaction size and hash within the normal NET limit. Co-authored-by: Jonathan Glanz Change-Id: I94a9be9479c2f4378fd08c952b72a6d6234b5c3d --- contracts/sysio.chalg/sysio.chalg.wasm | Bin 67609 -> 67636 bytes contracts/sysio.epoch/sysio.epoch.wasm | Bin 79839 -> 79865 bytes .../sysio.liq/include/sysio.liq/sysio.liq.hpp | 24 +- contracts/sysio.liq/sysio.liq.wasm | Bin 62338 -> 62305 bytes contracts/sysio.msgch/sysio.msgch.wasm | Bin 172091 -> 172116 bytes .../include/sysio.opp.common/claimable.hpp | 7 +- .../sysio.opp.common/depot_native_token.hpp | 91 ++ .../sysio.opp.common/shadow_custody.hpp | 256 ++++ .../sysio.opp.common/shadow_custody_types.hpp | 37 + .../include/sysio.opp.common/shadow_yield.hpp | 38 +- contracts/sysio.opreg/CMakeLists.txt | 1 + .../include/sysio.opreg/sysio.opreg.hpp | 212 +++- contracts/sysio.opreg/src/sysio.opreg.cpp | 373 ++++-- contracts/sysio.opreg/sysio.opreg.abi | 116 +- contracts/sysio.opreg/sysio.opreg.wasm | Bin 96094 -> 106138 bytes contracts/sysio.swap/sysio.swap.wasm | Bin 48065 -> 48057 bytes contracts/sysio.system/sysio.system.wasm | Bin 203343 -> 203370 bytes contracts/sysio.uwrit/sysio.uwrit.wasm | Bin 161007 -> 161033 bytes .../reenter_deposit/CMakeLists.txt | 5 + .../reenter_deposit/reenter_deposit.cpp | 5 +- .../reenter_deposit/reenter_deposit.wasm | Bin 5525 -> 5555 bytes contracts/tests/emissions_tests.cpp | 3 +- contracts/tests/sysio.dispatch_tests.cpp | 3 +- contracts/tests/sysio.opreg_tests.cpp | 1037 ++++++++++++++++- docs/becoming-a-block-producer.md | 35 +- docs/shadow-custody-integration.md | 137 +++ tests/multisig_review_test.py | 66 +- 27 files changed, 2252 insertions(+), 194 deletions(-) create mode 100644 contracts/sysio.opp.common/include/sysio.opp.common/depot_native_token.hpp create mode 100644 contracts/sysio.opp.common/include/sysio.opp.common/shadow_custody.hpp create mode 100644 contracts/sysio.opp.common/include/sysio.opp.common/shadow_custody_types.hpp create mode 100644 docs/shadow-custody-integration.md diff --git a/contracts/sysio.chalg/sysio.chalg.wasm b/contracts/sysio.chalg/sysio.chalg.wasm index c9ba212d2748cc8b046cf55ebbd0b170fabc8a73..092e0798680408f114ac3d8d5c5ef572f19dd690 100755 GIT binary patch delta 1459 zcmY*ZYfx2H6h3R8b1(ODL5{q+407%PwW(;|ZC=XNG`vQ&qID0+zIeUHU`_?}D zi=_G@skT@Yt4S4>h}XdbKawilE)(~|Lt+*6Gg!Ug3C8%w!6;4l?KHr1>aa>~Foqji z17Vz|*!*B7z!cjuY;TRCB#anLlb{lLmQBR$&-_ufFA|-^g0{6`kd01zG;tEFwTE)k zVb6gay6Pow3C3t@l!={3H%4y(D8i9fzl7a1GsfxxC0H020bB6+rq!?&y%(Q@y|iQT z0RY7G_(g!UB3`#ZDU}l!xRWM9xpx7KnB1tEjhXsFB3UepFsE^zp8}eq%a4HB7+pRJmKp#9 zb{`0(zcsEfNF`({nxh%B6j<&lhz>>41g%MsMJt0T*onjf92%SRzA;NRDxj;9=5#uh zBu~bM=AkR1Vn|a(&5)%U6iH!EVz4$jIT^}YZ(`PMd9Y$s1Wv;UsG^;iY|;xw_~DV) zg2EmHaVUIO1#To&c+-=y$0%pCr)lxJ=D>B=!f~?Hmv|-Ok$nMvvMl`vQRY1>f+ZDC zoDYwg!3UZ#Q=V&+EbI_zAz|$>goGt^Ra{6+u8_Zjwmu(hZ=S~(d^;kQrd(bIFo9K9 z>fkA^ZkY$qF}uYH|KP!*U<|qHfM+!2sw~dTG~M1RO%1re(IAh5V!$1(2{`ckW|+jZ zw!Kh@q`fR?2T{$6*-iuv7+66uGfD0Wy1Ww`+LxGf2@8F7c^kTS)WqiT=nC6(Iql5Z z&NH`}^A&gaclq4uXHG7$39Ktc4;L`$R3IjFI&euRg#tR(=>xEfdfadW*onS3TOgly z-)sOV#?o8ukca7A>tGMP(NzHq04EdVeKhBfK7jo=+#P~>x69xg`tqITsYD|SPX3us z-~h(m`@&qx5qRkGLA-EpD;&b5_aBhM1P>Ki;ahyC=M6Z*rqvItdNQC4gZ_F5N71c! zy{VkvfeK>9!P0OnY>2{^-g2nKPx>;Tir($>6QLR%{UOxn?^gh-siVIZpazE@&bNNg z3x?}*`SdtXaJxX4j}e?zABd|4yqRFB1En56ux8lg@X;SdufYJwqY;DAAk`6zf{AUR zn&+FC z1TN=)dbwn}F(VV#E0^Hx>#x%dk3+!y3_n2T^70&ZJy{727&&S+pPSyegc}DOSTMSW wUH-7KrEExJ{Uay-g-fheN58)Cr)@b2G9)V^W@Qhgg1-R?%PXGV_ delta 1572 zcma)6e@seII-=t0Ss$A;XJx>Du4|aR2f0GH?1d$rt3~@ z=A(7dq7fF|1NuQgv(4aFs?uywhbe8bNw!P|sY4Vsg=y23BvoOWIQkILzmfd`Qkdc* zn#r)TZFH6`(+FT-8Jd}$on5UwKSb2V46&HXZ0Uw-YMF+OX-s@+qs+mbk-dpDNNchG$}L#W(^TF7k+_h>E;;A zT$~|FVb|rZVrVnPu11}@h?H4zN~mZyO;YfWr!4F^J62S0uJcvC`nx#*J`DT$1sqnp z1o|}LLdn96E4=mX70Oq8ekh9Z*G7*+|;rRkaxE1gP>d@hM57~ z&WnG!4)7s{v_@m;rBZFDpgz{O=JUqOQ|{}lU?=~vHJ^}=2&Q?b;DXdx-0!7$<|+-^ zB?AeuXc1n%nhVAF%CEnXj|rA|9QNJ9gKD;j8S{kW+R71Fbd5p@9=etXpI~C!?@)?H z`|^N2a#r@@w)S4whnufwKpDS#Jwkx|8(fsWj2yg&HZYDSmN{?|C-3vla zw5U(YM2mh(kRBU>uiqT1_e+&5(|*9MJCgB-n=i&4648MFEu%f)Fg1M{Kvt|`(sYBw z3C4?3*`^AG0dme<{9 diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 3583ddc36b03401edcaecef5ff887efea340647a..acb01b1f33855e350fba3113974094c8f3db2628 100755 GIT binary patch delta 894 zcmZ{iT}TvB7>4KlW_H?bT%6P*S2vH7q?t)nSa&%WiDHn@RbE(86pUSDBoY*u{a{fS zd61F9uq(s$FUJ}+wJpo+$4uPaexSOFpaQ#aizu+!U5g63IOjVrob!FpdwAc@O5bLs zURe}JY1s0J9>B{}q|9tnf`92wwJfH8Tr^MBkG^>q|gQgPaW6d2Bo>{q6E6>Ijg3iLI# zZbC2L+`0iX*1^`cWsdOn5244J=x-2V@q)qSeD6Re`g#38Ag(u}-`X=cmx>UtXj~?S ze?dp$(4jbVDPo8R$-_T)Ioer)Ni$izidF2dBuZL3JUgn4=MoJ z+^j(J$VZGKk`!eVul%|yl|g5Q+D=YgXQ&^cbb#-g)9h{qXGtI>4c7+#Vhlz0WF9h||a*0&gXG2a%= z;dqyZWV6PDr?``Sgi2{xZ4> za?_Us_Lfv669V?0)mRF2+I9K(1YdxBI+A1Q5uB_z8#A1zv8Pk70Wv3qJ^B_I(Wv5~j`4T%19VJ(Y`#KxhFmynq<`A7VIx7)c<8YzHv{ytL03 HVu$A^6DSe^ delta 650 zcmZ|LT}V@L7zS{kcSmtsxviag1QpvN+_y}2qL4EVd9Ov z#NP^`Zc@(X=imCPOS4SN>SXIUDCeD3-psXR5_{URMjgyi#s;Z)xaDeHz7y|9+Se9&CS=S&CUDYBI!$NwT*E9BXK z$g{F0_KxsMR?HlRc*s*--_qNVvp4C$f2eVk;To z+QonxbD}z(Ly0JL5bs{9rcRPAOI1*Zyl@c=b!H=SfUs)agn7Ua4Q0qn4^u%9eB8|U zMV6Dx8Pxrm$$8U~VccUy3n`VbN29ZHTJ(CHP5rpymPyX?P*(N^uF@>SHNd0c({x bv&ytqm@|Kv@&7U{m1NEWuhp$BIAH$`?H1%< diff --git a/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp index db49a7214e..25a745669c 100644 --- a/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp +++ b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp @@ -197,6 +197,12 @@ namespace sysio { using symbol_key = opp::shadow::symbol_key; /// One shadow symbol: its supply and the registry rows it mirrors. + /// + /// LAYOUT IS SHARED STATE. Other contracts deserialize this row on consensus paths: + /// `sysio.opreg` resolves a depot-native collateral token through the `bytoken` index on its + /// never-throw remit paths (withdraw flush, lock release, termination) as well as its signed + /// actions, and `sysio.chalg` prices shadow collateral from it. Changing the layout means + /// `sysio.liq` and every one of those readers redeploy together. struct [[sysio::table("stat")]] currency_stats { asset supply; sysio::slug_name chain_code; ///< the outpost whose liq this shadow mirrors @@ -208,8 +214,24 @@ namespace sysio { SYSLIB_SERIALIZE(currency_stats, (supply)(chain_code)(token_code)(pair_symbol)) }; + /// Secondary index of `stat` keyed on each shadow symbol's registry `token_code` -- the one + /// declaration of its name; `find_stat_by_token` is the one lookup through it. + static constexpr name STAT_BY_TOKEN_INDEX = "bytoken"_n; + using stats = kv::table<"stat"_n, symbol_key, currency_stats, - kv::index<"bytoken"_n, const_mem_fun>>; + kv::index>>; + + /// The `stat` row of the shadow symbol registered under registry `token_code` in the + /// `sysio.liq` deployed at `liq_account`, or `std::nullopt` when none is. Never throws. The ONE + /// stat-by-token lookup: `sysio.liq` itself, `sysio.opreg`'s depot-native token resolver + /// (including its never-throw remit paths) and `sysio.chalg`'s collateral pricing all use it. + static std::optional find_stat_by_token(name liq_account, sysio::slug_name token_code) { + stats statstable(liq_account); + auto by_token = statstable.get_index(); + auto it = by_token.find(token_code.value); + if (it == by_token.end()) return std::nullopt; + return *it; + } /// Holder rows (scope = holder, key = symbol code) and the per-symbol /// index, laid out by sysio.opp.common/shadow_yield.hpp. diff --git a/contracts/sysio.liq/sysio.liq.wasm b/contracts/sysio.liq/sysio.liq.wasm index aa7bb79d50906bbd45853944aac2ed5f67958b58..fc2b0827fe9b3b7d52f3007496eb22ec5692206e 100755 GIT binary patch delta 876 zcmY*XOGs2v7{32G_s*ShX0A5{$406%7a>P8)P-3Q%}6O^i%bn^Q`923i?Btth|y3e z+T`Q+F+M7-%pTaoyAl!&ij1I^`I;Oz*&?G_gwdw|%oNq)aL)O@|M8vg{H?2e6`@8=TY!X2OOFXAlxVI^e2hT7emo{z7%vWkhRK>7Tr5UUHxocP=8$uKdtEN{ z;_Iqr*2i$Fx&)`Iyn37k1&f-vVIkWYuI^%46S^B(pqbN>JG7|Iz5^y4(S#c2>#R}{ zzC6ol6q+z<^ZZIwi4MJ0Oz=wjBBTV<>A02oM{uq!{E}?4%$P7lMoO(;m{M&fKB_6? zhLC!YwkF1rCK+ zx_F7D|5IF(T*Pvd^OQKNq(4IwZdqNY>S18crv}b_o^qy8$dGCakMpxJ;f5X@Y^#N9 zPSJ^X*`Dj^kFfJ`X0FWHbMCuOAO8C{I9%<=qiqqmnaa86thF&8wBwidDrk3hbPNMs z<~nQXQrP9vV8AKu8lsp9;_&IqMzs+reF2|e?0BI;#_zK+^TQJ?F1IsW{Rw^O-V{G(cy>7yxXXfM jIxAK(_tWsp)Fh>j;P~i5#TC|AtNp31A z^-blgEPv9p-iDg{-i1N<0h<4)Iw;^eb%PMy?4&cd^6wqX71`2aq+8o znleN?PNW-z>612mzJspxu=H$gGvxA(T=7|nzB9=uB4ZxX(9DG-X{v?+`FzdED zy}Kz7uH(zv&fpE6FPY@U+^~|^S6i(v*A4Tm=UyKqpi|JJzmO2By554eur#9L7q4M5 zSYVs2GAzx;f)I0GCt@Xu++N1%-8XqwclBS|IQkoj5gCX4HVx={3 zX-RBNEXbgnhZb5ND4=p3<;b;#i=X{zYTGnvY-)c@T0fe`Un8E2iRni_PS#mxovgjr z&c2<@`Z<|3StHCr(I31m{sI>HZ(5z0^=GVo;*Vol{lU`o7iuuzudS`eV9-d_aA||MPCH{~!{^so-%WKOO9c+qQs%eQmSp80IAjtns&ZUc^Z7PiJWXF8e*T z;o!BQ_$FKm?)`KZ_}ahrOFq6)EgtfoGahNu`pBMWWwF~;MgIL{TMID|2! z@4Y4_RK+-ofeF^wtsYLGFB@@=O~b==emIR%1PjSDt4cs>ct3&FxWU~C?8Z&?Ac5Bf z61*mfJoV}<1n{lW@!0E?-CoJ=d~Z6FZ}ZRFWXTAKGr zd%O9by@1lQdxYleRCNPI({Rcke&;1_X`}37t5sr0j$!J&uHhyP+booZG|EV5!pjcP zvOQL-V@Y_9aAUEUtj>WXH`|k@OlxvXH?7r4lS1mVv&fyFpMP1JZc=0Vjv{GE!_DyQ zlfp5}D5FIDr%KBGy*esb-kX|rN_LO};TT(U3~dhG2AbST&#mIW>uCjF`5rG~F<0L~ ze%Q7%p0{bIa!VuB$oF^SmCS_(;aXAu$HCksdMb{&fikpNY5-Q0;~!G4UpcT6mftYY zSiBMF&<$WAn;wKT>~%&cRXg>$zL|>D=4M)HqF)`1P^pQpxI0SgaaPSm=?CB(pFB(> zdJW1yqG`GyxPCts(nZ0QCKa%Agvv0gb{?TQ!nn%$gq8zW)cT{;4|7Zu@=QBLxvHI7 z^ajP-X%#T3e(#_PdbUB#2McKt&wWab&mM|+%V#t!;-Ul`n?aa&)soHGU9^p+bnU&^ q3&q#EXcwl{lP$-9Jw6hMxjIQz{hz delta 979 zcma)4ZAg`87=Ew&ecq$T4|WJN&CJJ_*{9J5Y&OSsdac$>NFmBu7>>%(Yik}j2a|?+ zT44=_AFp+1X60t7>GIfHJ!vh^zl|t@wGjlt{*6ID1{-684fh<}`qhtq-PiqG5BGCj z*K_}n%(#=xNYn_kU$DPJ`~vTtKpgkH*}!h`ZD$7ig=q_FaMoK}+kk$*r}k;SuwtQJ z?~}+rc;4g43iSE^MDo&tXM{8ylpD?5&_;#c+pR@7~f}VGfVr5XO|oy(Y$0cpQbmIP2P_o=>1J3ki--!^IRopGFB?pEs#nX9kj{ zgm)-j3ZJ`YunRZT&od|$nC3M}~Dt|xK02YfzvV&IC zeUn6H<}yx(X)#wdQieLvNX-@=dY5)C6%tqAOrfh)7?M(3gVc2h_#oEfm66T-8F;G%WbTdRm0bTzrHo zaD}T6P>yOnLK76cnsTt%4gJoO`zT*+JxT;jsBNFpS(syD5kG08DA%=7v)-ZmZL|uQ zRDZP7yEMN;%ny!HY#tYI{bzJhBt$Vdeh)cZT}vS@IZj*Yrj}5LI^5##j?;GBRtry% eO;D=5llp)eesq#Xa96dQqB!u4`s;Jrlk+bR2q`xJ diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/claimable.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/claimable.hpp index 0c9380ad5b..cb8114b322 100644 --- a/contracts/sysio.opp.common/include/sysio.opp.common/claimable.hpp +++ b/contracts/sysio.opp.common/include/sysio.opp.common/claimable.hpp @@ -33,8 +33,9 @@ * `sysio.reserv::rewards_bucket`). The helpers below are templated over the table instead, and * require only that the row expose: * - * * `uint64_t balance` -- required, the claimable amount in atomic units of THAT - * contract's token (currently WIRE for every consumer) -- + * * `uint64_t balance` -- required, the claimable amount in atomic units of the + * row's token (WIRE for `payclaims` and `wireclaims`; the + * row's own `token_code` for `sysio.opreg::remitclaims`) -- * these helpers never name a symbol; `pay_out`'s caller * supplies it * * `uint32_t expires_at_sec` -- optional; when present it is maintained by `credit` and @@ -118,7 +119,7 @@ void credit(Table& tbl, sysio::name payer, const Key& key, Row fresh, uint64_t a }); } -/// Drain a claimable row and emit the single `sysio.token::transfer` that pays it out. +/// Drain a claimable row and emit the single `token_account::transfer` that pays it out. /// /// This is the ONLY place a claimable balance becomes a transfer, and it is reached only from an /// action carrying the claimant's own authority. A recipient whose notify handler aborts therefore diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/depot_native_token.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/depot_native_token.hpp new file mode 100644 index 0000000000..8324db7e05 --- /dev/null +++ b/contracts/sysio.opp.common/include/sysio.opp.common/depot_native_token.hpp @@ -0,0 +1,91 @@ +#pragma once +/** + * @file depot_native_token.hpp + * @brief Resolve a depot-native token code to the custody contract that holds it and its symbol, and + * move such a token by inline transfer. + * + * Shared by every depot contract that moves depot-native tokens on behalf of a registry code + * (`sysio.opreg` for operator collateral, `sysio.bond` for underwriting bonds), so the lookup and + * the transfer exist once. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace sysio::opp::custody { + +/// Where a depot-native token lives: the contract whose `transfer` moves it and the symbol its +/// amounts are denominated in. +struct depot_native_token { + name contract; ///< Custody contract whose `transfer` moves the token: `sysio.token` or `sysio.liq`. + symbol sym; ///< Symbol amounts of the token are denominated in on that contract. +}; + +/// Resolve a depot-native `token_code` to its custody contract and symbol -- the ONE lookup every +/// transfer into or out of a depot contract's custody goes through. +/// +/// Two custody contracts qualify: +/// * `token_account` (`sysio.token`) for WIRE (`opp::wire::token_code`), denominated in +/// `opp::wire::asset_symbol`; +/// * `liq_account` (`sysio.liq`) for a shadow LIQ symbol, found through the `stat` table's `bytoken` +/// index on the liq token's REGISTRY code (LIQETH, LIQSOL, ...), denominated in that row's supply +/// symbol. The registry code, not the symbol code, is what names the token everywhere else. +/// +/// Every depot-native row is keyed `(opp::wire::chain_code, token_code)`: the chain names where the +/// token is custodied, and for a shadow symbol that is the depot, not the outpost whose liq it +/// mirrors. +/// +/// @param liq_account account of the `sysio.liq` contract +/// @param token_account account of the `sysio.token` contract +/// @param token_code registry code of the token +/// @return the custody contract and symbol, or `std::nullopt` for any other code +inline std::optional +resolve_depot_native_token(name liq_account, name token_account, sysio::slug_name token_code) { + if (token_code == opp::wire::token_code) { + return depot_native_token{.contract = token_account, .sym = opp::wire::asset_symbol}; + } + const auto stat = liq::find_stat_by_token(liq_account, token_code); + if (!stat) return std::nullopt; + return depot_native_token{.contract = liq_account, .sym = stat->supply.symbol}; +} + +/// Action every custody contract (`sysio.token`, `sysio.liq`) exposes to move a balance. +inline constexpr name TRANSFER_ACTION = "transfer"_n; + +/// Permission an inline depot-native transfer is sent under: the sender's own `active`. +inline constexpr name TRANSFER_PERMISSION = "active"_n; + +/// Move `amount` base units of `token` from `from` to `to` by an inline `transfer` on its custody +/// contract, under `from@TRANSFER_PERMISSION`. The sending contract must be privileged (as +/// `sysio.opreg` and `sysio.bond` are), or `from` must be the sending contract itself with its own +/// `sysio.code` permission. The caller has checked `amount` fits an `asset`. +/// +/// Queue it after the caller's rows are committed: the custody contract notifies both parties, so a +/// notification handler that re-enters the caller observes the committed state. +inline void transfer_depot_native(name from, name to, const depot_native_token& token, uint64_t amount, + std::string_view memo) { + action(permission_level{from, TRANSFER_PERMISSION}, token.contract, TRANSFER_ACTION, + std::make_tuple(from, to, asset(static_cast(amount), token.sym), std::string(memo))) + .send(); +} + +/// Pull `amount` base units of `token` from `from` into `self`'s custody: the one inbound transfer a +/// depot contract sends under the signer's authority (`sysio.opreg::deposit`, `sysio.bond`'s +/// request, bounty, accept and hold). +inline void pull_depot_native(name self, name from, const depot_native_token& token, uint64_t amount, + std::string_view memo) { + transfer_depot_native(from, self, token, amount, memo); +} + +} // namespace sysio::opp::custody diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/shadow_custody.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/shadow_custody.hpp new file mode 100644 index 0000000000..f788020798 --- /dev/null +++ b/contracts/sysio.opp.common/include/sysio.opp.common/shadow_custody.hpp @@ -0,0 +1,256 @@ +#pragma once +/** + * @file shadow_custody.hpp + * @brief Custodian-side shadow yield: attribute the yield a custodian's single `sysio.liq` holder + * row earns to the sub-holders whose shadow it holds, strictly within the WIRE it has pulled. + * + * Integration guide: `docs/shadow-custody-integration.md`. The row types are in + * `shadow_custody_types.hpp`, so a contract header can declare its tables with those alone. + * + * ## Model + * + * A custodian contract holds shadow LIQ on behalf of sub-holders -- `sysio.opreg` holds operators' + * bonded collateral. `sysio.liq` sees ONE holder, the custodian, and pays that row's WIRE yield + * through the cumulative per-symbol index `shadow_yield.hpp` describes. This header divides that + * yield among the sub-holders by the same index, one level down: + * + * * each sub-holder row EMBEDS a `position`, which checkpoints `sysio.liq`'s LIVE index for the + * symbol and banks what was earned. Every change to the sub-holder's balance goes through + * `settle_and_adjust`, which settles first -- a new position settles at balance 0 and so starts + * at the current index. A sub-holder unit therefore earns exactly what a unit of the + * custodian's liq row earns, from the moment it is held until it leaves, however the custodian + * times its pulls; + * * the custodian keeps one `yield_pool` per symbol, in its own table under its own key. `pull` + * records on it exactly what the `sysio.liq::claim` it sends will pay, in the same transaction; + * every credit to a sub-holder goes through `settle_and_take`, which never credits more than + * the pool has received and not yet credited. + * + * ## Entry points + * + * Three composites are THE documented way to use this header. Each reads `sysio.liq`'s live index + * itself and fuses a sequence a custodian must never split: + * + * * `settle_and_adjust(position&, balance&, delta, liq_account, sym)` -- settle, then change the + * balance (the shape of `sysio.liq`'s own `settle_and_adjust`); returns the change applied; + * * `settle_and_take(position&, balance, yield_pool&, liq_account, sym)` -- settle, then take + * what the pool covers; returns the amount to credit; + * * `pull(yield_pool&, liq_account, custodian, sym)` -- record what `sysio.liq` owes the + * custodian's row AND send the claim that pays it, or do neither. + * + * The primitives they are built from (`live_index`, `settle`, the index-taking `settle_and_adjust`, + * `take`, `custodian_owed`, `record_pull`, `claim_action`) stay public as building blocks for a + * custodian with unusual needs, which then owns the sequencing the composites guarantee. + * + * ## The custodian's obligations + * + * 0. **Hold what you attribute.** For each symbol, the sum of sub-holder balances never exceeds + * the custodian's own `sysio.liq` holding, and a sub-holder is credited a balance before the + * shadow arrives only within the same action that brings it in. Otherwise positions accrue on + * shadow the custodian's liq row does not hold, the row is owed less than the positions, and + * because the pool is first-come-first-served (see Solvency) the first sub-holders to claim + * take yield that the others earned. + * 1. Embed a `position` in each sub-holder row and change that row's balance only through + * `settle_and_adjust` (a new row: create it at balance 0, then adjust). + * 2. Pull only through `pull`, from a caller-signed action, and at most ONCE per symbol per + * action. `pull` records a PREDICTED payout -- the claim it sends executes after the calling + * action returns -- so a second `pull` in the same action would see the same owed amount + * again. `pull` refuses that case itself: when the live index and the custodian's owed both + * equal its last recorded pull, that claim has not executed yet, and it records and sends + * nothing. + * 3. Queue nothing that touches the custodian's `sysio.liq` row or the symbol's index ahead of + * `pull` in the same action (a shadow transfer, an `addyield`, a claim of your own): an inline + * action queued earlier executes before `pull`'s claim, the claim then pays a different + * amount than was recorded, and the difference is stranded in the custodian's account. + * 4. Credit sub-holders only with what `settle_and_take` returns, into a ledger the sub-holder + * pulls from (`claimable.hpp`), never by pushing a transfer from a never-throw path. + * 5. Decide, as its own policy, what happens to yield `settle_and_take` could not yet cover and + * to yield left unclaimed when a sub-holder row is erased. + * + * ## Never-throw contract + * + * `live_index`, `settle`, both `settle_and_adjust`, `settle_and_take`, `record_pull` and `take` + * read at most `sysio.liq`'s `yieldidx` row and otherwise do saturating arithmetic on the + * custodian's own values: no `check()`, safe on a never-throw path. `pull` and `custodian_owed` + * also read the custodian's `sysio.liq` `accounts` row with the CHECKED `opp::shadow::owed` (it + * must match what `sysio.liq::claim` computes), and the claim `pull` / `claim_action` push can throw + * inside `sysio.liq`: those belong on caller-signed actions only. Every custodian deserializes + * `sysio.liq`'s `yieldidx` and `accounts` rows, so a change to either layout must ship with a + * redeploy of every custodian together with `sysio.liq`. + * + * ## Authority + * + * `pull` pushes `sysio.liq::claim(custodian, sym)` under `custodian@active` + * (`CLAIM_PERMISSION`). A privileged custodian (`sysio.opreg`) may do so as is; any other custodian + * needs its own `sysio.code` permission on its `active` authority. + * + * ## Solvency + * + * Credits never exceed the WIRE actually pulled from `sysio.liq`: `take` caps every credit at the + * pool's `received - credited`, and `received` grows only by what a pushed claim pays. So a + * sub-holder credit is always backed by WIRE the custodian holds, never by other balances in the + * same account. The attribution itself can ask for slightly more than was pulled: `sysio.liq` + * floors the custodian's row at every one of its settles (each claim, each shadow transfer in or + * out), while a position floors once over its whole interval, so positions can be owed up to one + * atomic WIRE per custodian-row settle more than was received. That dust stays banked on the + * position until slack covers it -- yield on shadow the custodian holds for no sub-holder, which no + * position claims -- and what becomes of dust still owed is the custodian's policy (obligation 5). + * The pool is first-come-first-served across sub-holders: a dust shortfall falls on whoever takes + * last, not on the position whose flooring created it. + */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +namespace sysio::opp::shadow::custody { + +/// The permission a custodian pushes its `sysio.liq::claim` under: its own `active`. +inline constexpr name CLAIM_PERMISSION = "active"_n; + +/// `actor@CLAIM_PERMISSION`, the authority of a custodian's pushed claim (`sysio.liq`'s +/// `active_of` shape). +inline permission_level claim_authority(name actor) { return permission_level{actor, CLAIM_PERMISSION}; } + +/// Largest yield a position banks: the `asset` magnitude limit, so the WIRE credit it becomes can +/// always be carried by an `asset`. `settle` clamps here instead of `check()`-ing. +inline constexpr uint64_t MAX_OWED_WIRE = static_cast(asset::max_amount); + +// --------------------------------------------------------------------------------------------- +// Primitives +// --------------------------------------------------------------------------------------------- + +/// `sysio.liq`'s cumulative index for `sym` -- the raw `yieldidx.index` its own `claim` settles +/// with -- or 0 before the symbol's first distribution. Never throws. +inline u128 live_index(name liq_account, symbol_code sym) { + yield_index_table indexes(liq_account); + const auto row = indexes.try_get(symbol_key{sym.raw()}); + return row ? row->index : u128{0}; +} + +/// Settle `pos`, holding `balance` units, at `index`: bank what it earned since its checkpoint +/// (`opp::shadow::owed`) and move the checkpoint to `index`. The result saturates at MAX_OWED_WIRE, +/// including when `balance * (index - checkpoint)` itself would pass 2^128, so nothing wraps. +/// `sysio.liq`'s index only grows; should it ever read below the checkpoint (a reset), the position +/// keeps what it banked, accrues nothing, and re-checkpoints. Never throws. +inline void settle(position& pos, uint64_t balance, u128 index) { + if (index >= pos.index_checkpoint) { + const u128 delta = index - pos.index_checkpoint; + if (balance != 0 && delta > ~u128{0} / balance) { + pos.owed_wire = MAX_OWED_WIRE; // the product alone passes 2^128 + } else { + const u128 total = owed(balance, pos.owed_wire, pos.index_checkpoint, index); + pos.owed_wire = total > MAX_OWED_WIRE ? MAX_OWED_WIRE : static_cast(total); + } + } + pos.index_checkpoint = index; +} + +/// Settle `pos` at `index` against the balance it held, then apply `delta` to `balance`. Returns +/// the change actually applied, which differs from `delta` only where a credit saturates at +/// 2^64 - 1 or a debit past zero clamps at 0. Underflow is the CALLER's policy: check +/// `balance >= -delta` before calling where a violation must abort. Never throws. +inline int64_t settle_and_adjust(position& pos, uint64_t& balance, int64_t delta, u128 index) { + settle(pos, balance, index); + const uint64_t before = balance; + if (delta >= 0) { + balance = safe::add_sat_u64(balance, static_cast(delta)); + return static_cast(balance - before); + } + const uint64_t debit = static_cast(-(delta + 1)) + 1; // |delta| without overflow at INT64_MIN + balance = balance > debit ? balance - debit : 0; + return -static_cast(before - balance); +} + +/// Take what an already-settled `pos` is owed, up to what `pool` still covers: returns +/// `min(pos.owed_wire, received - credited)`, removes it from the position and adds it to +/// `credited`. What the pool cannot cover stays banked on the position. Never throws. +inline uint64_t take(position& pos, yield_pool& pool) { + const uint64_t available = pool.received > pool.credited ? pool.received - pool.credited : 0; + const uint64_t taken = std::min(pos.owed_wire, available); + pos.owed_wire -= taken; + pool.credited += taken; + return taken; +} + +/// What `sysio.liq` owes `custodian`'s own holder row for `sym` now, or 0 without a row: exactly +/// what `sysio.liq::claim(custodian, sym)` pays later in the same transaction, because it reads the +/// same rows with the same, CHECKED, formula. Caller-signed actions only: throws on corrupt state. +inline uint64_t custodian_owed(name liq_account, name custodian, symbol_code sym) { + accounts_table holdings(liq_account, custodian.value); + const auto holding = holdings.try_get(symbol_key{sym.raw()}); + return holding ? owed(*holding, live_index(liq_account, sym)) : 0; +} + +/// Record on `pool` a pull of `amount` WIRE predicted at `index`: add it to `received` and remember +/// both as the last recorded pull, which `pull` uses to refuse a second pull before the first +/// claim has executed. Saturating; never throws. +inline void record_pull(yield_pool& pool, uint64_t amount, u128 index) { + pool.received = safe::add_sat_u64(pool.received, amount); + pool.pulled_index = index; + pool.pulled_owed = amount; +} + +/// The inline `sysio.liq::claim(custodian, sym)` a custodian pushes under `claim_authority` to pull +/// what `custodian_owed` reported. The claim can throw inside `sysio.liq`: caller-signed actions +/// only. +inline action claim_action(name custodian, name liq_account, symbol_code sym) { + return action(claim_authority(custodian), liq_account, CLAIM_ACTION, std::make_tuple(custodian, sym)); +} + +// --------------------------------------------------------------------------------------------- +// Entry points +// --------------------------------------------------------------------------------------------- + +/// Settle `pos` at `sysio.liq`'s live index for `sym`, then apply `delta` to `balance`: the one call +/// a custodian makes for every change to a sub-holder's balance, so accrual at the old balance is +/// banked before the new balance starts earning. Returns the change applied (see the index-taking +/// form). Never throws; underflow is the caller's policy. +inline int64_t settle_and_adjust(position& pos, uint64_t& balance, int64_t delta, name liq_account, + symbol_code sym) { + return settle_and_adjust(pos, balance, delta, live_index(liq_account, sym)); +} + +/// Settle `pos`, holding `balance`, at `sysio.liq`'s live index for `sym`, then take what it is owed +/// up to what `pool` covers. Returns the amount taken, for the custodian to credit to the +/// sub-holder; the uncovered rest stays banked on the position. Never throws. +inline uint64_t settle_and_take(position& pos, uint64_t balance, yield_pool& pool, name liq_account, + symbol_code sym) { + settle(pos, balance, live_index(liq_account, sym)); + return take(pos, pool); +} + +/// Pull into the custodian what `sysio.liq` owes its holder row for `sym`: when that is non-zero, +/// record it on `pool` and send the `sysio.liq::claim` that pays exactly it later in this +/// transaction; otherwise do neither. Returns the amount pulled. Recording and sending are one +/// step, so `received` never grows by an amount no claim delivers. +/// +/// Refuses (returns 0, records and sends nothing) when the live index and the custodian's owed +/// both equal the last recorded pull: the claim that pull sent has not executed yet, so recording +/// again would count the same WIRE twice (obligation 2). A claim that did execute leaves the row +/// owed nothing at that index, so the refusal never withholds a genuine pull. The caller must also +/// queue nothing that touches `sysio.liq` ahead of this in the same action (obligation 3). +/// +/// Caller-signed actions only: throws through the checked `owed`, and the claim can throw inside +/// `sysio.liq`. +inline uint64_t pull(yield_pool& pool, name liq_account, name custodian, symbol_code sym) { + const uint64_t owed_now = custodian_owed(liq_account, custodian, sym); + if (owed_now == 0) return 0; + const u128 index = live_index(liq_account, sym); + if (index == pool.pulled_index && owed_now == pool.pulled_owed) return 0; + record_pull(pool, owed_now, index); + claim_action(custodian, liq_account, sym).send(); + return owed_now; +} + +} // namespace sysio::opp::shadow::custody diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/shadow_custody_types.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/shadow_custody_types.hpp new file mode 100644 index 0000000000..f635a52272 --- /dev/null +++ b/contracts/sysio.opp.common/include/sysio.opp.common/shadow_custody_types.hpp @@ -0,0 +1,37 @@ +#pragma once +/** + * @file shadow_custody_types.hpp + * @brief The two row types of the shadow-custody library, on their own so a custodian's contract + * header can declare its tables without pulling in the library's functions. + * + * A custodian embeds a `position` in each sub-holder row and stores one `yield_pool` per shadow + * symbol in a table of its own. The functions that operate on them, and the model, obligations and + * solvency argument that govern them, are in `shadow_custody.hpp`. + */ + +#include + +#include + +namespace sysio::opp::shadow::custody { + +/// One sub-holder's yield state, embedded in the custodian's own row next to the balance it tracks. +struct position { + uint128_t index_checkpoint = 0; ///< `sysio.liq`'s index for the symbol at the last settle + uint64_t owed_wire = 0; ///< WIRE banked by earlier settles, not yet taken + SYSLIB_SERIALIZE(position, (index_checkpoint)(owed_wire)) +}; + +/// One symbol's solvency pool, stored by the custodian in its own table under its own key. +/// +/// Named `yield_pool` rather than a bare `pool`, which elsewhere in this codebase names the swap's +/// liquidity pools. +struct yield_pool { + uint64_t received = 0; ///< WIRE pulled from `sysio.liq` for the symbol (`pull`) + uint64_t credited = 0; ///< WIRE taken out of `received` for sub-holders (`take`); never above it + uint128_t pulled_index = 0; ///< `sysio.liq`'s index for the symbol at the last recorded pull + uint64_t pulled_owed = 0; ///< what that pull recorded: the custodian's owed at `pulled_index` + SYSLIB_SERIALIZE(yield_pool, (received)(credited)(pulled_index)(pulled_owed)) +}; + +} // namespace sysio::opp::shadow::custody diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/shadow_yield.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/shadow_yield.hpp index afd863e125..06bbc77f36 100644 --- a/contracts/sysio.opp.common/include/sysio.opp.common/shadow_yield.hpp +++ b/contracts/sysio.opp.common/include/sysio.opp.common/shadow_yield.hpp @@ -27,6 +27,8 @@ #include #include +#include +#include #include #include @@ -61,6 +63,11 @@ struct symbol_key { }; /// A holder's row for one shadow symbol. +/// +/// LAYOUT IS SHARED STATE. Beyond `sysio.liq`, every custodian built on `shadow_custody.hpp` reads +/// its own holder row through `custody::custodian_owed` (`sysio.opreg` in `sweepyield` and +/// `claimyield`), and `sysio.swap` reads its pools' rows. Changing the layout means `sysio.liq` and +/// every custodian redeploy together. struct account { asset balance; uint128_t index_checkpoint = 0; ///< index value at the last settle of this row @@ -69,6 +76,11 @@ struct account { }; /// One shadow symbol's distribution state. +/// +/// LAYOUT IS SHARED STATE. Beyond `sysio.liq`, every custodian built on `shadow_custody.hpp` reads +/// this row through `custody::live_index` -- `sysio.opreg` on its never-throw remit paths (withdraw +/// flush, lock release, termination) as well as its signed actions -- and `sysio.swap` reads it +/// for its pools. Changing the layout means `sysio.liq` and every custodian redeploy together. struct yield_index { uint128_t index = 0; ///< cumulative WIRE per shadow unit, scaled by YIELD_INDEX_SCALE uint64_t pot = 0; ///< WIRE held for holders and not yet claimed @@ -76,6 +88,29 @@ struct yield_index { SYSLIB_SERIALIZE(yield_index, (index)(pot)(carry)) }; +/// The token's holder table as any contract reads it: code = the token contract, scope = holder. +/// Declared here, outside every contract class, so the ABI generator of a contract that merely +/// READS it (`sysio.swap`, every `shadow_custody.hpp` custodian) does not list it as its own table; +/// `sysio.liq` aliases it inside its class to declare it as its own. +using accounts_table = kv::scoped_table; +/// The token's per-symbol index table, as any contract reads it: code = scope = the token contract. +/// Declared outside every contract class for the same reason as `accounts_table`. +using yield_index_table = kv::table; + +/// The WIRE a position of `balance` units is owed at index `index`: `banked` plus +/// `balance * (index - index_checkpoint) / YIELD_INDEX_SCALE`, the product in 128 +/// bits and the division floored. Generic over whose position it is: `sysio.liq` +/// settles its holder rows with it (through the `account` overload below), and a +/// holder that attributes its own holding among sub-positions (`sysio.opreg` over +/// the shadow operators bond to it) settles each sub-position against the SAME +/// token index with the same formula, from its own checkpoint and banked amount. +/// +/// UNCHECKED, so a never-throw caller can use it: the caller guarantees +/// `index >= index_checkpoint` (an index only grows) and bounds the result itself. +inline u128 owed(uint64_t balance, uint64_t banked, u128 index_checkpoint, u128 index) { + return static_cast(banked) + static_cast(balance) * (index - index_checkpoint) / YIELD_INDEX_SCALE; +} + /// The WIRE a holder row is owed now, at index `index`: the banked amount plus /// the accrual since its checkpoint, floored. Pure, so the token's settle and a /// holder's pre-claim computation are one function. The index only grows and a @@ -83,8 +118,7 @@ struct yield_index { inline uint64_t owed(const account& row, u128 index) { check( index >= row.index_checkpoint, "index precedes the row checkpoint" ); if (row.balance.amount <= 0) return row.owed_wire; - const u128 accrued = static_cast(row.balance.amount) * (index - row.index_checkpoint) / YIELD_INDEX_SCALE; - const u128 total = static_cast(row.owed_wire) + accrued; + const u128 total = owed(static_cast(row.balance.amount), row.owed_wire, row.index_checkpoint, index); check( total <= static_cast(asset::max_amount), "owed yield exceeds the asset range" ); return static_cast(total); } diff --git a/contracts/sysio.opreg/CMakeLists.txt b/contracts/sysio.opreg/CMakeLists.txt index 0b03285299..611db605ba 100644 --- a/contracts/sysio.opreg/CMakeLists.txt +++ b/contracts/sysio.opreg/CMakeLists.txt @@ -35,6 +35,7 @@ if(BUILD_SYSTEM_CONTRACTS) $ $ $ + $ $ $ ) diff --git a/contracts/sysio.opreg/include/sysio.opreg/sysio.opreg.hpp b/contracts/sysio.opreg/include/sysio.opreg/sysio.opreg.hpp index 020a4d5c02..bd4ded126e 100644 --- a/contracts/sysio.opreg/include/sysio.opreg/sysio.opreg.hpp +++ b/contracts/sysio.opreg/include/sysio.opreg/sysio.opreg.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace sysio { @@ -24,9 +25,9 @@ namespace sysio { * * - One aggregate balance per (operator, chain, token_kind) — NOT a * vector of collateral entries, NOT a locked/available/amount trio. - * - `deposit` (operator-callable, WIRE-direct) and `depositinle` + * - `deposit` (operator-callable, depot-native WIRE or shadow LIQ) and `depositinle` * (msgch-dispatched from outposts) credit the matching balance row; - * `withdraw` (operator-callable, WIRE-direct) and `withdrawinle` + * `withdraw` (operator-callable, depot-native) and `withdrawinle` * (msgch-dispatched from outposts) enqueue a delayed subtraction; * `slash` zeros the unlocked portion immediately and leaves locks * for `sysio.uwrit::release` to slash deferred. @@ -51,11 +52,9 @@ namespace sysio { static constexpr name CHALG_ACCOUNT = "sysio.chalg"_n; static constexpr name AUTHEX_ACCOUNT = "sysio.authex"_n; static constexpr name TOKEN_ACCOUNT = "sysio.token"_n; + static constexpr name LIQ_ACCOUNT = "sysio.liq"_n; static constexpr name SYSTEM_ACCOUNT = "sysio"_n; - /// WIRE collateral asset held by the depot-side operator registry. - static constexpr symbol WIRE_SYM = opp::wire::asset_symbol; - // 2-epoch wait between `queue_withdraw` and `flushwithdraws` releasing // funds. Long enough that an operator who would drop below the role // minimum is demoted before the funds physically leave. @@ -215,19 +214,37 @@ namespace sysio { opp::types::OperatorType type, bool is_bootstrapped); - /// Operator-callable: lock WIRE tokens directly as the operator's - /// WIRE-side collateral. The tokens transfer in the same transaction; - /// the corresponding (operator, opp::wire::chain_code, - /// opp::wire::token_code) balance row is - /// credited. Reverts on validation failure (no escrow exists yet — - /// failure surfaces in the operator's signing tx so they can retry). + /// Operator-callable: bond depot-native collateral held on WIRE itself rather than + /// escrowed on an outpost. Two kinds of token qualify: + /// + /// * WIRE, custodied by `sysio.token` (`token_code == opp::wire::token_code`); + /// * a shadow LIQ symbol (LIQETH, LIQSOL, ...), custodied by `sysio.liq`, named by the + /// liq token's registry code — the `token_code` of its `sysio.liq` `stat` row, never + /// the symbol code. + /// + /// Any other `token_code` reverts ("unsupported depot-native collateral token"). The tokens + /// move from `account` to this contract in the same transaction, under the operator's own + /// authority, and credit the `(opp::wire::chain_code, token_code)` balance row: the chain + /// is the depot for every depot-native row, because custody is on the depot whichever + /// outpost the shadow mirrors. Reverts on any validation failure — nothing is escrowed + /// yet, so the failure surfaces in the operator's signing transaction and they retry. + /// + /// A bonded shadow balance sits on this registry's holder row in `sysio.liq`, so the WIRE + /// yield it earns accrues to that one row. Each bonded row checkpoints `sysio.liq`'s own + /// index for the symbol (see `balance_entry`), so every bonded unit is attributed exactly + /// what one unit of the registry's row earns, for as long as it stays bonded. The operator + /// collects it with `claimyield`, then pulls the WIRE with `claimremit(account, WIRE)`. [[sysio::action]] - void deposit(name account, uint64_t amount); + void deposit(name account, sysio::slug_name token_code, uint64_t amount); /// Internal: credit an outpost-side collateral row. Called by /// `sysio.msgch` when it dispatches an `OPERATOR_ACTION(DEPOSIT_REQUEST)` /// attestation that came in from an outpost. /// + /// A credit naming the depot chain (`chain_code == opp::wire::chain_code`) is dropped and + /// logged, never credited: depot-native collateral is bonded only through `deposit`, whose + /// custody transfer funds it, and no outpost holds anything to revert. + /// /// Validation failures (unknown account, slashed/terminated operator, /// zero amount) DO NOT revert — they are recorded in the operator's /// `recent_actions` log (when an entry exists) and trigger an outbound @@ -251,15 +268,19 @@ namespace sysio { std::vector actor_address, checksum256 original_message_id); - /// Operator-callable: queue a WIRE-direct collateral withdrawal subject - /// to the WITHDRAW_WAIT_EPOCHS wait. Outpost-held collateral is - /// withdrawn by calling the holding outpost's withdraw entry point — - /// the outpost emits an OPERATOR_ACTION(WITHDRAW_REQUEST) inbound that - /// reaches `withdrawinle` instead. Validation failures DO NOT revert — - /// they are appended to the operator's `recent_actions` ring buffer - /// (matching the OPP-dispatched path's failure semantics). + /// Operator-callable: queue a withdrawal of depot-native collateral from the + /// `(opp::wire::chain_code, token_code)` row (WIRE or a shadow LIQ symbol; see `deposit`), + /// subject to the WITHDRAW_WAIT_EPOCHS wait. When it matures, `flushwtdw` credits a + /// `remitclaims` row in `token_code` that the operator pulls with `claimremit`. + /// Outpost-held collateral is withdrawn by calling the holding outpost's withdraw entry + /// point — the outpost emits an OPERATOR_ACTION(WITHDRAW_REQUEST) inbound that reaches + /// `withdrawinle` instead. + /// + /// A `token_code` this contract cannot custody reverts, like `deposit`. Every other + /// validation failure DOES NOT revert — it is appended to the operator's `recent_actions` + /// ring buffer (matching the OPP-dispatched path's failure semantics). [[sysio::action]] - void withdraw(name account, uint64_t amount); + void withdraw(name account, sysio::slug_name token_code, uint64_t amount); /// Internal: queue an outpost-side collateral withdrawal. Called by /// `sysio.msgch` when it dispatches an `OPERATOR_ACTION(WITHDRAW_REQUEST)` @@ -304,6 +325,11 @@ namespace sysio { /// the matching LP on each chain via OPERATOR_ACTION(SLASH) attestations. /// The locked portion stays in opreg's balance and is slashed at lock- /// release time by `sysio.uwrit::release` (deferred-slash). + /// + /// A depot-native shadow row is settled before the subtraction, so the yield it earned up to + /// the slash stays on the row, claimable with `claimyield`. Its locked remainder keeps earning + /// until `releaselock` performs the deferred slash, consistent with the deferred-slash + /// semantics: until then that shadow is still the operator's balance. [[sysio::action]] void slash(name account, std::string reason); @@ -339,11 +365,25 @@ namespace sysio { /// destination via `OPERATOR_ACTION(WITHDRAW_REMIT)` attestations. /// Locks remain alive — `sysio.uwrit::release` will deferred-remit /// each lock at its natural release time. + /// + /// Earned shadow yield is settled and credited to the operator's WIRE claim alongside the + /// principal, but only the part `yieldpool` covers: this path is never-throw and cannot pull + /// from `sysio.liq`. The uncovered remainder STAYS on the row, and the terminated operator + /// collects it with `claimyield`, which pulls first. Each later `releaselock` on the TERMINATED + /// operator pays the same way. + /// + /// Claim window: banked yield never keeps a row from `prune` (or from the operator's own + /// re-registration), so whatever is still unclaimed when the row is erased is forfeited to the + /// registry. The `terminate_prune_delay_ms` window is therefore the time a terminated operator + /// has to call `claimyield` -- or have anyone call it for them, since it is permissionless. [[sysio::action]] void terminate(name account, std::string reason); - /// Auth = the claiming operator. Pull WIRE collateral credited by a WIRE-chain remit - /// (withdraw flush, deferred lock release, or termination payout) in a single transfer. + /// Auth = the claiming operator. Pull the depot-native `token_code` collateral credited by + /// a WIRE-chain remit (withdraw flush, deferred lock release, or termination payout) in a + /// single transfer from the token's custody contract: `sysio.token` for WIRE, `sysio.liq` + /// for a shadow LIQ symbol (see `deposit`). Each token is claimed separately; a + /// `token_code` this contract cannot custody reverts before any claim row is read. /// /// The remit paths credit rather than transfer because they are reachable from /// `sysio.epoch::advance`, which must never abort; see `remitclaims`. This is the only place @@ -352,8 +392,64 @@ namespace sysio { /// /// Independent of the operator row: `prune` may have erased a settled TERMINATED operator, /// and the collateral remains claimable regardless. + /// + /// Shadow yield credited to a WIRE claim is always backed by WIRE the registry has already + /// pulled from `sysio.liq` (see `yieldpool`), so paying it never draws on WIRE collateral. + [[sysio::action]] + void claimremit(name account, sysio::slug_name token_code); + + /// Permissionless crank: claim the WIRE yield `sysio.liq` owes this registry's holder row for + /// the shadow symbol of depot-native `token_code` into this contract. + /// + /// `W` = what the registry's row is owed now (the checked `opp::shadow::owed` over that row + /// and `sysio.liq`'s index for the symbol); the action checks `W > 0` and pushes + /// `sysio.liq::claim(self, symbol)` under this contract's `active` authority, which reads the + /// same row and index with the same formula in the same transaction and so transfers exactly + /// `W`; `W` is added to `yieldpool[token_code].received` at the push, in that same + /// transaction. Attribution to operators is fixed by their rows' checkpoints against that + /// same index, so it does not depend on when this runs. What the sweep changes is coverage: + /// yield is credited only out of WIRE already received, so an operator whose `claimyield` + /// reports uncovered yield, or a termination payout that should cover everything, needs the + /// registry's owed WIRE pulled in first. + /// + /// No authority is required, because the action moves nothing but yield owed to the registry + /// into the registry. Reverts for a token this contract cannot custody, for WIRE (which earns + /// no shadow yield), and when nothing is owed ("no yield to sweep"). + [[sysio::action]] + void sweepyield(sysio::slug_name token_code); + + /// Permissionless: no authority is required, because the credit can only land in `account`'s + /// own `remitclaims{account, WIRE}` row -- a keeper may crank it, e.g. to rescue a TERMINATED + /// operator's yield before `prune` erases the row. First, when the registry's own `sysio.liq` + /// row is owed anything, pull it in (`opp::shadow::custody::pull`: push + /// `sysio.liq::claim(self, symbol)` and add it to `yieldpool[token_code].received`); a failure + /// in that claim reaches only whoever signed this action. Then settle the operator's + /// `(opp::wire::chain_code, token_code)` row at `sysio.liq`'s current index and credit + /// `min(owed, received - credited)` to + /// `remitclaims{account, opp::wire::token_code}`, taking it off the row; the operator pulls + /// the WIRE with `claimremit(account, WIRE)`. Works in every status, including SLASHED (yield + /// earned before a slash stays claimable) and TERMINATED -- a terminated operator must claim + /// before `prune` erases its row, when anything still owed is forfeited (see `terminate`). + /// + /// Reverts with "no yield owed" when the row has earned nothing (or is not a shadow row), and + /// with a rounding-dust message when all it is owed is dust the pool does not cover -- the + /// action has just pulled everything `sysio.liq` owed the registry, so only later slack can + /// cover it. The remainder stays on the row either way. + /// + /// Solvency: yield credits never exceed the WIRE actually received from `sysio.liq` for the + /// symbol (`yieldpool`), so a WIRE claim holding yield is always backed by swept WIRE and never + /// by WIRE collateral. Each bonded unit is attributed `Δindex / SCALE` of `sysio.liq`'s own + /// index, the rate the registry's holder row earns. But `sysio.liq` floors the registry row at + /// every one of its settles, while an operator row floors once over its whole interval, so the + /// operator rows can be owed up to one atomic WIRE per registry settle more than was received. + /// That dust stays owed until slack covers it: yield on shadow the registry holds for no bond + /// (seized by a slash, awaiting `claimremit`), which is credited to no operator. The pool is + /// first-come-first-served across operators, so a dust shortfall falls on whoever claims last, + /// not on the row whose flooring created it. The mechanism + /// is the `opp::shadow::custody` library; this contract supplies only the policy (which rows + /// earn, where credits go, and the claim window). [[sysio::action]] - void claimremit(name account); + void claimyield(name account, sysio::slug_name token_code); /// Prune terminated operator rows, plus delivery-log rows that have aged /// out of the rolling termination window. Permissionless. @@ -365,6 +461,10 @@ namespace sysio { /// `releaselock` to settle later, and `releaselock` no-ops once the row is /// gone; erasing on the delay alone therefore stranded that collateral, /// and any caller could trigger it. + /// + /// Banked shadow yield is NOT part of "settled": it never delays the erase, and whatever a + /// terminated operator has not claimed with `claimyield` by then is forfeited to the registry. + /// The prune delay is its claim window (see `terminate`). [[sysio::action]] void prune(); @@ -376,13 +476,22 @@ namespace sysio { /// is implied by `sysio.uwrit::locks` (consulted by `available()`); the /// pending-withdraw portion is implied by this contract's /// `withdraw_queue` (also consulted by `available()`). + /// + /// `shadow_yield` is meaningful only on a depot-native shadow row (`chain_code == + /// opp::wire::chain_code`, `token_code != opp::wire::token_code`), whose bonded shadow earns + /// WIRE yield on this registry's `sysio.liq` holder row; it stays zero on every other row. It + /// is the `opp::shadow::custody` position of this row's balance: settled at `sysio.liq`'s live + /// index for the symbol before every change to `balance` (a new row settles at balance 0, so it + /// starts at the current index and is never credited earlier distributions), so a bonded unit + /// earns exactly what a unit of the registry's holder row earns while it is bonded. struct balance_entry { - sysio::slug_name chain_code; - sysio::slug_name token_code; - uint64_t balance = 0; - uint64_t last_updated_ms = 0; + sysio::slug_name chain_code; + sysio::slug_name token_code; + uint64_t balance = 0; + uint64_t last_updated_ms = 0; + opp::shadow::custody::position shadow_yield; ///< yield state of `balance` (shadow rows only) - SYSLIB_SERIALIZE(balance_entry, (chain_code)(token_code)(balance)(last_updated_ms)) + SYSLIB_SERIALIZE(balance_entry, (chain_code)(token_code)(balance)(last_updated_ms)(shadow_yield)) }; /// Operators primary key: account name value. @@ -536,9 +645,10 @@ namespace sysio { sysio::const_mem_fun> >; - /// Claimable WIRE collateral owed to an operator by a WIRE-chain remit: a withdraw + /// Claimable depot-native collateral owed to an operator by a WIRE-chain remit: a withdraw /// flush (`flushwtdw`), a deferred lock release on a TERMINATED operator, or the - /// termination payout itself. + /// termination payout itself. One row per `(account, token_code)`: WIRE and each shadow + /// LIQ symbol an operator bonded are owed, and claimed, separately (see `deposit`). /// /// All three are reachable from `sysio.epoch::advance` (flush by epoch, termination via /// `termcheck`), which must never abort. A pushed `sysio.token::transfer` notifies the @@ -566,9 +676,12 @@ namespace sysio { /// NOTHING EXPIRES TODAY: no `claimable::sweep_expired` call is wired against this table. /// `REMIT_CLAIM_WINDOW_SEC` is the provisional window the stamp is computed from, not a /// commitment — whether returned collateral should be forfeited at all is open in WIRE-339. + /// + /// Primary key: the owed operator and the depot-native token it is owed in. struct remitclaim_key { - uint64_t account; - SYSLIB_SERIALIZE(remitclaim_key, (account)) + uint64_t account; + sysio::slug_name token_code; + SYSLIB_SERIALIZE(remitclaim_key, (account)(token_code)) }; /// Provisional retention window used only to compute `remit_claim::expires_at_sec`. Mirrors @@ -577,19 +690,27 @@ namespace sysio { static constexpr uint32_t REMIT_CLAIM_WINDOW_SEC = 365 * 24 * 60 * 60; struct [[sysio::table("remitclaims")]] remit_claim { - sysio::name account; - uint64_t balance = 0; ///< Atomic WIRE units owed, not yet claimed. - uint32_t expires_at_sec = 0; // recorded by `credit`; read by nothing yet (WIRE-339) + sysio::name account; + sysio::slug_name token_code; ///< Depot-native token owed: WIRE or a shadow LIQ code. + uint64_t balance = 0; ///< Atomic units of `token_code` owed, not yet claimed. + uint32_t expires_at_sec = 0; // recorded by `credit`; read by nothing yet (WIRE-339) /// Expiry-major composite so the secondary index orders by expiry and a future retention - /// sweep can stop at the first live row. The account tail only breaks ties, keeping the - /// key unique when many rows share an expiry second. (Same shape as - /// `sysio.reserv::wire_claim`.) + /// sweep can stop at the first live row. (Same shape as `sysio.reserv::wire_claim`.) + /// + /// The account tail breaks ties between operators, but NOT between one operator's + /// tokens: a remit that credits WIRE and a shadow token in the same second (termination + /// pays every depot-native row at once) gives both rows the same composite. The token is + /// not folded in because expiry, account and token need 160 bits and the index key is + /// 128. The tie is harmless: the host stores each index entry as `(composite, primary + /// key)` and keeps that pair unique, and the primary key carries the token, so tied rows + /// are distinct entries ordered by token; a sweep reads only the expiry prefix, so + /// visiting tied rows in either order changes nothing. uint128_t by_expiry() const { return (static_cast(expires_at_sec) << 64) | account.value; } - SYSLIB_SERIALIZE(remit_claim, (account)(balance)(expires_at_sec)) + SYSLIB_SERIALIZE(remit_claim, (account)(token_code)(balance)(expires_at_sec)) }; using remitclaims_t = sysio::kv::table<"remitclaims"_n, remitclaim_key, remit_claim, @@ -597,6 +718,19 @@ namespace sysio { sysio::const_mem_fun> >; + /// Key of a `yieldpool` row: the depot-native shadow token whose yield the row accounts for. + struct yield_pool_key { + sysio::slug_name token_code; + SYSLIB_SERIALIZE(yield_pool_key, (token_code)) + }; + + /// Per depot-native shadow token: the `opp::shadow::custody::yield_pool` capping every yield + /// credit at the WIRE this registry has actually pulled from `sysio.liq` for it (`received`, + /// recorded at each pushed claim by `sweepyield` / `claimyield`) minus what it has already + /// credited to operators' WIRE claims (`credited`). The cap keeps yield credits from ever + /// drawing on the WIRE collateral held in the same `sysio.token` balance. + using yieldpool_t = sysio::kv::table<"yieldpool"_n, yield_pool_key, opp::shadow::custody::yield_pool>; + /// Singleton holding the next-issued `request_id` / `log_id`. Keeps /// the auto-increment monotonic across action calls. struct [[sysio::table("opcounters")]] op_counters { diff --git a/contracts/sysio.opreg/src/sysio.opreg.cpp b/contracts/sysio.opreg/src/sysio.opreg.cpp index 6d331c8392..7af73e6684 100644 --- a/contracts/sysio.opreg/src/sysio.opreg.cpp +++ b/contracts/sysio.opreg/src/sysio.opreg.cpp @@ -3,13 +3,17 @@ #include #include #include +#include #include #include #include +#include +#include #include #include #include #include +#include #include #include @@ -35,16 +39,63 @@ void reevaluate_eligibility(opreg::operators_t& ops, // model, as sysio.token uses): the account stays finite at code+abi size; growth draws from the pool. constexpr name ram_payer = "sysio"_n; -/// Credit a WIRE-chain remit to the operator's claimable row instead of transferring it. +/// Memo on the transfer that moves a `deposit` into the registry's custody. +constexpr std::string_view deposit_transfer_memo = "opreg::deposit"; + +/// Memo on the transfer that pays a `claimremit` out of the registry's custody. +constexpr std::string_view claimremit_transfer_memo = "opreg::claimremit collateral payout"; + +/// Message of the `check` a depot-native action raises for a token it cannot custody. +constexpr std::string_view unsupported_token_msg = "unsupported depot-native collateral token"; + +/// Message of the `check` `sweepyield` raises when the registry's `sysio.liq` row is owed nothing. +constexpr std::string_view no_yield_to_sweep_msg = "no yield to sweep"; + +/// Message of the `check` `claimyield` raises when the operator's row has earned nothing. +constexpr std::string_view no_yield_owed_msg = "no yield owed"; + +/// Message of the `check` `claimyield` raises when the row is owed yield the pool cannot yet cover. +constexpr std::string_view yield_not_covered_msg = + "owed yield is rounding dust the WIRE received from sysio.liq does not cover; only later slack can cover it"; + +/// Recent-actions reason `depositinle` records when it drops a credit naming the depot chain. +constexpr std::string_view depot_chain_credit_msg = + "depot-chain collateral is bonded through deposit, never credited from an outpost"; + +/// Message of the `check` `sweepyield` raises for WIRE, which is not a shadow token. +constexpr std::string_view wire_earns_no_yield_msg = "WIRE collateral earns no shadow yield"; + +/// Resolve a depot-native collateral `token_code` through the shared resolver, against this +/// registry's custody contracts (`sysio.token` for WIRE, `sysio.liq` for a shadow LIQ symbol). +/// +/// Returns `std::nullopt` for any other code; callers are the operator-signed actions +/// (`deposit`, `withdraw`, `claimremit`), which `check()` on it. The never-throw remit paths do +/// not resolve: they credit a claim row by the `token_code` they already hold. +std::optional resolve_depot_native_token(sysio::slug_name token_code) { + return opp::custody::resolve_depot_native_token(opreg::LIQ_ACCOUNT, opreg::TOKEN_ACCOUNT, token_code); +} + +/// True iff the `(chain_code, token_code)` balance row is bonded depot-native shadow, and so earns +/// WIRE yield on the registry's `sysio.liq` holder row: the depot chain and any token but WIRE. The ONE +/// place that decides it; every yield path (settle, sweep, claim, termination payout) asks here. +bool earns_shadow_yield(sysio::slug_name chain_code, sysio::slug_name token_code) { + return chain_code == opp::wire::chain_code && token_code != opp::wire::token_code; +} + +/// Credit a WIRE-chain remit of `token_code` to the operator's claimable row instead of +/// transferring it. /// /// Every caller (withdraw flush, deferred lock release, termination payout) is reachable from -/// `sysio.epoch::advance`, which must never abort. `sysio.token::transfer` notifies the operator, -/// and the chain runs notified receivers with no exception isolation, so a pushed remit would let -/// an operator's notify handler abort `advance` and halt epoch advancement chain-wide. In the -/// termination case the operator would be blocking its own removal, so the retry never converges. +/// `sysio.epoch::advance`, which must never abort. A custody contract's `transfer` notifies the +/// operator, and the chain runs notified receivers with no exception isolation, so a pushed remit +/// would let an operator's notify handler abort `advance` and halt epoch advancement chain-wide. +/// In the termination case the operator would be blocking its own removal, so the retry never +/// converges. /// -/// Never throws: the credit saturates rather than overflowing. -void credit_remit_claim(name self, name account, uint64_t amount) { +/// Never throws: the credit saturates rather than overflowing, and `token_code` is taken as the +/// caller holds it — it is NOT resolved here, so an unresolvable code cannot abort `advance`. +/// Resolution happens when the operator claims. +void credit_remit_claim(name self, name account, sysio::slug_name token_code, uint64_t amount) { if (amount == 0) return; // The expiry stamp is RECORDED, not acted on: no sweep is wired against `remitclaims` (see the @@ -54,8 +105,8 @@ void credit_remit_claim(name self, name account, uint64_t amount) { const uint32_t now_sec = current_time_point().sec_since_epoch(); opreg::remitclaims_t claims(self); - sysio::opp::claimable::credit(claims, ram_payer, opreg::remitclaim_key{account.value}, - opreg::remit_claim{.account = account}, amount, + sysio::opp::claimable::credit(claims, ram_payer, opreg::remitclaim_key{account.value, token_code}, + opreg::remit_claim{.account = account, .token_code = token_code}, amount, now_sec + opreg::REMIT_CLAIM_WINDOW_SEC); } @@ -188,7 +239,9 @@ bool has_active_locks(name account) { /// could ever free it. /// /// Balance rows are checked by VALUE, not by `balances.empty()` — -/// `subtract_balance` zeroes a bucket in place and never erases the entry. +/// `subtract_balance` zeroes a bucket in place and never erases the entry. Banked shadow yield does +/// NOT count: it never blocks the erase, and whatever is still unclaimed then is forfeited -- the +/// prune delay is the terminated operator's window to `claimyield` (see `terminate`). /// /// A row that never settles is never erased, and that is the intended outcome: /// an unprunable row costs some RAM, while an erased one costs the operator @@ -574,41 +627,128 @@ uint64_t balance_of(const opreg::operator_entry& o, return 0; } +namespace custody = opp::shadow::custody; + +/// The `sysio.liq` shadow symbol behind depot-native `token_code`, or `std::nullopt` when the +/// resolver finds none (WIRE, or an unknown code). Never throws, so the never-throw paths skip on +/// `std::nullopt` -- impossible for a row that was bonded through the resolver. +std::optional shadow_symbol_of(sysio::slug_name token_code) { + const auto custody_token = resolve_depot_native_token(token_code); + if (!custody_token || custody_token->contract != opreg::LIQ_ACCOUNT) return std::nullopt; + return custody_token->sym.code(); +} + +/// The `sysio.liq` shadow symbol a balance row earns yield on, or `std::nullopt` for a row that earns +/// none (or whose token the resolver no longer finds). Never throws. The never-throw paths that +/// call it -- and the `custody` calls that follow, which read `yieldidx` -- therefore deserialize +/// `sysio.liq`'s `stat` and `yieldidx` rows: a change to either row layout must ship together with +/// a redeploy of this contract. +std::optional earning_symbol(const opreg::balance_entry& b) { + if (!earns_shadow_yield(b.chain_code, b.token_code)) return std::nullopt; + return shadow_symbol_of(b.token_code); +} + +/// Settle a shadow row and take what it has earned from its token's `yieldpool`, as far as the pool +/// covers (`custody::settle_and_take`); the uncovered rest stays banked on the row. Returns the +/// amount taken, which the caller credits to the operator's WIRE claim; 0 for any other row. Never +/// throws. +uint64_t take_yield(name self, opreg::balance_entry& b) { + const auto sym = earning_symbol(b); + if (!sym) return 0; + opreg::yieldpool_t pools(self); + const opreg::yield_pool_key key{b.token_code}; + auto pool = pools.try_get(key).value_or(custody::yield_pool{}); + const uint64_t taken = custody::settle_and_take(b.shadow_yield, b.balance, pool, opreg::LIQ_ACCOUNT, *sym); + if (taken > 0) pools.upsert(ram_payer, key, pool); + return taken; +} + +/// Settle every shadow row the operator holds and take what each has earned, as far as each pool +/// covers, for the TERMINATED payouts (termination itself, then each later lock release). Returns +/// the amount taken per row, for the caller to credit to the operator's WIRE claim alongside the +/// principal; what a pool could not cover stays banked for `claimyield`. Never throws. +std::vector take_all_earned_yield(name self, opreg::operator_entry& o) { + std::vector taken; + for (auto& b : o.balances) { + if (earns_shadow_yield(b.chain_code, b.token_code)) taken.push_back(take_yield(self, b)); + } + return taken; +} + +/// Credit each amount in `yield_credits` to `account`'s WIRE claim row. Never throws. +void credit_yield(name self, name account, const std::vector& yield_credits) { + for (const uint64_t amount : yield_credits) { + credit_remit_claim(self, account, opp::wire::token_code, amount); + } +} + +/// Pull what `sysio.liq` owes the registry's holder row for `sym` into `token_code`'s `yieldpool`: +/// `custody::pull` records it and sends the claim that pays it, or does nothing when nothing is +/// owed; this wrapper is the pool table's I/O around it. Returns the amount pulled. +/// Caller-signed actions only (the checked `owed` and the pushed claim can throw). +uint64_t pull_registry_yield(name self, sysio::slug_name token_code, symbol_code sym) { + opreg::yieldpool_t pools(self); + const opreg::yield_pool_key key{token_code}; + auto pool = pools.try_get(key).value_or(custody::yield_pool{}); + const uint64_t pulled = custody::pull(pool, opreg::LIQ_ACCOUNT, self, sym); + if (pulled > 0) pools.upsert(ram_payer, key, pool); + return pulled; +} + +/// Apply a balance change of `delta` to `b`. A shadow row goes through `custody::settle_and_adjust`, +/// which settles its position at `sysio.liq`'s live index before the change, so accrual at the old +/// balance is banked; every other row changes directly. `delta` fits `int64_t`: every balance is +/// capped at `MAX_COLLATERAL_AMOUNT` (2^62 - 1). The caller has already enforced underflow. +void adjust_balance(opreg::balance_entry& b, int64_t delta) { + if (const auto sym = earning_symbol(b)) { + custody::settle_and_adjust(b.shadow_yield, b.balance, delta, opreg::LIQ_ACCOUNT, *sym); + } else if (delta >= 0) { + b.balance += static_cast(delta); + } else { + b.balance -= static_cast(-delta); + } + b.last_updated_ms = current_time_ms(); +} + /// Add `amount` to the (chain_code, token_code) balance row, creating the row /// if it doesn't exist. Mutates the operator entry in place — caller is /// expected to be inside an `ops.modify(...)` lambda. Callers MUST first verify /// the credit keeps the row within `MAX_COLLATERAL_AMOUNT` (see `balance_of`); /// `add_balance` itself does not cap, mirroring the unchecked `subtract_balance`. +/// +/// A shadow row is settled first (`adjust_balance` → `custody::settle_and_adjust`). A row created +/// here settles at balance 0, so its checkpoint starts at `sysio.liq`'s current index and it earns +/// nothing distributed before it was bonded. void add_balance(opreg::operator_entry& o, sysio::slug_name chain_code, sysio::slug_name token_code, uint64_t amount) { - for (auto& b : o.balances) { - if (b.chain_code == chain_code && b.token_code == token_code) { - b.balance += amount; - b.last_updated_ms = current_time_ms(); - return; - } - } - o.balances.push_back(opreg::balance_entry{ - .chain_code = chain_code, - .token_code = token_code, - .balance = amount, - .last_updated_ms = current_time_ms(), + auto it = std::find_if(o.balances.begin(), o.balances.end(), [&](const opreg::balance_entry& b) { + return b.chain_code == chain_code && b.token_code == token_code; }); + if (it == o.balances.end()) { + it = o.balances.insert(o.balances.end(), + opreg::balance_entry{.chain_code = chain_code, .token_code = token_code}); + } + adjust_balance(*it, static_cast(amount)); } /// Subtract `amount` from the (chain_code, token_code) balance row. Caller /// must have already validated the available balance via `available_inline`. /// Mutates the operator entry in place — caller is expected to be inside an /// `ops.modify(...)` lambda. +/// +/// The underflow check stays here, before the change: a shadow row reaches `custody` only with a +/// debit its balance covers. A shadow row is then settled BEFORE the subtraction (`adjust_balance` → +/// `custody::settle_and_adjust`): what the row earned up to now stays banked on it, and only the +/// remaining balance earns from here. The slash and withdraw-flush paths rely on this to keep the +/// yield earned while bonded claimable. void subtract_balance(opreg::operator_entry& o, sysio::slug_name chain_code, sysio::slug_name token_code, uint64_t amount) { for (auto& b : o.balances) { if (b.chain_code == chain_code && b.token_code == token_code) { check(b.balance >= amount, "balance underflow"); - b.balance -= amount; - b.last_updated_ms = current_time_ms(); + adjust_balance(b, -static_cast(amount)); return; } } @@ -889,7 +1029,7 @@ struct enqueue_result { /// Validate + insert a `wtdwqueue` row. Does NOT throw on validation /// failure — returns the diagnostic in `enqueue_result`. Used by both /// `withdrawinle` (msgch-dispatched, log-don't-revert) and `withdraw` -/// (operator-callable WIRE-direct, also log-don't-revert). +/// (operator-callable depot-native, also log-don't-revert). enqueue_result try_enqueue_withdraw(name account, sysio::slug_name chain_code, sysio::slug_name token_code, @@ -1010,24 +1150,27 @@ OperatorAction build_deposit_action(const opp::types::ChainAddress& op_address, } // anonymous namespace // --------------------------------------------------------------------------- -// withdraw — operator-callable WIRE-direct collateral withdraw (queued) +// withdraw — operator-callable depot-native collateral withdraw (queued) // --------------------------------------------------------------------------- // -// Operator-authorized; queues a (chain=WIRE, token=WIRE) row in the -// withdraw queue subject to WITHDRAW_WAIT_EPOCHS maturation. Validation -// failures DO NOT revert — they append a failure entry to the operator's -// `recent_actions` ring buffer, matching the OPP-dispatched path -// (`withdrawinle`). The operator reads the outcome via WIRE JSON-RPC. -// Outpost-held collateral is withdrawn by calling the holding outpost's -// withdraw entry point — that path arrives at `withdrawinle` instead. -void opreg::withdraw(name account, uint64_t amount) { +// Operator-authorized; queues a (chain=WIRE, token=token_code) row in the +// withdraw queue subject to WITHDRAW_WAIT_EPOCHS maturation. A token this +// registry cannot custody reverts, so a request that could never be paid out +// is never queued. Every other validation failure DOES NOT revert — it +// appends a failure entry to the operator's `recent_actions` ring buffer, +// matching the OPP-dispatched path (`withdrawinle`). The operator reads the +// outcome via WIRE JSON-RPC. Outpost-held collateral is withdrawn by calling +// the holding outpost's withdraw entry point — that path arrives at +// `withdrawinle` instead. +void opreg::withdraw(name account, sysio::slug_name token_code, uint64_t amount) { require_auth(account); + check(resolve_depot_native_token(token_code).has_value(), unsupported_token_msg); operators_t ops(get_self()); auto op_pk = operator_key{account.value}; - auto result = try_enqueue_withdraw(account, opp::wire::chain_code, opp::wire::token_code, amount); - auto action = build_withdraw_request_action(account, opp::wire::chain_code, opp::wire::token_code, amount, + auto result = try_enqueue_withdraw(account, opp::wire::chain_code, token_code, amount); + auto action = build_withdraw_request_action(account, opp::wire::chain_code, token_code, amount, result.request_id); append_action_log(ops, op_pk, action, result.success, std::move(result.error_message)); if (result.success) { @@ -1150,7 +1293,7 @@ void notify_producer_standing(name self, const opreg::operator_entry& op) { } // anonymous namespace // --------------------------------------------------------------------------- -// deposit — operator-callable WIRE-direct collateral deposit +// deposit — operator-callable depot-native collateral deposit // --------------------------------------------------------------------------- // // Operator-authorized; reverts on validation failure so the operator's @@ -1160,9 +1303,11 @@ void notify_producer_standing(name self, const opreg::operator_entry& op) { // re-bootstrap their authex links). On success the matching balance row // is credited, the action is appended to the operator's `recent_actions` // ring buffer, and the eligibility transition (if any) is fanned out. -void opreg::deposit(name account, uint64_t amount) { +void opreg::deposit(name account, sysio::slug_name token_code, uint64_t amount) { require_auth(account); check(amount > 0, "amount must be positive"); + const auto custody = resolve_depot_native_token(token_code); + check(custody.has_value(), unsupported_token_msg); operators_t ops(get_self()); auto op_pk = operator_key{account.value}; @@ -1172,45 +1317,40 @@ void opreg::deposit(name account, uint64_t amount) { "operator not in a deposit-eligible state"); // Bootstrapped operators are ACTIVE by fiat and bypass `meets_role_min` entirely, so collateral // credited to one can never affect its eligibility -- the deposit would be accepted into a - // balance that does nothing. `depositinle` already rejects them; this closes the WIRE-direct + // balance that does nothing. `depositinle` already rejects them; this closes the depot-native // path. There is deliberately no way to collateralise a bootstrap: an operator who wants a // collateralised producer registers a new account. check(!op.is_bootstrapped, "bootstrapped operators cannot deposit collateral"); - // Credit collateral BEFORE the outbound WIRE transfer, with the cap check + // Credit collateral BEFORE the inbound custody transfer, with the cap check // performed ATOMICALLY inside the same `modify` as the credit — reading the // live row, not the `op` copy read above. This closes a reentrancy window on - // operator accounts that carry contract code: `sysio.token::transfer` notifies - // `from` (the operator), and that notification handler could re-enter - // `deposit`. A separate pre-read-then-check-then-credit could let two credits - // pass against the same stale balance and push the WIRE row past + // operator accounts that carry contract code: the custody contract's + // `transfer` notifies `from` (the operator), and that notification handler + // could re-enter `deposit`. A separate pre-read-then-check-then-credit could + // let two credits pass against the same stale balance and push the row past // `asset::max_amount` — recreating the withdraw/terminate remit abort - // (`asset(balance, WIRE_SYM)` aborts above the limit). Checking inside the + // (`asset(balance, symbol)` aborts above the limit). Checking inside the // modify makes check+credit indivisible, and crediting before the transfer // means any re-entry observes the committed balance. A direct user deposit may // legitimately `check()`-throw here (unlike the never-throw OPP `depositinle`); - // if the transfer below aborts (operator lacks the WIRE) the whole + // if the transfer below aborts (operator lacks the tokens) the whole // transaction — including this credit — rolls back. (SEC-103; PR #449 review.) ops.modify(same_payer, op_pk, [&](auto& o) { check(amount <= MAX_COLLATERAL_AMOUNT && - balance_of(o, opp::wire::chain_code, opp::wire::token_code) <= MAX_COLLATERAL_AMOUNT - amount, + balance_of(o, opp::wire::chain_code, token_code) <= MAX_COLLATERAL_AMOUNT - amount, "deposit would exceed max collateral"); - add_balance(o, opp::wire::chain_code, opp::wire::token_code, amount); + add_balance(o, opp::wire::chain_code, token_code, amount); }); - // Direct WIRE token transfer from operator -> opreg, sent after the credit so - // a transfer-notification re-entry observes the already-committed balance. - action( - permission_level{account, "active"_n}, - TOKEN_ACCOUNT, "transfer"_n, - std::make_tuple(account, get_self(), - asset(static_cast(amount), WIRE_SYM), - std::string("opreg::deposit")) - ).send(); + // Custody transfer from operator -> opreg on the token's own contract, under the + // operator's authority, sent after the credit so a transfer-notification + // re-entry observes the already-committed balance. + opp::custody::pull_depot_native(get_self(), account, *custody, amount, deposit_transfer_memo); auto deposit_action = build_deposit_action( operator_chain_address(account, opp::wire::chain_code), - opp::wire::chain_code, opp::wire::token_code, amount); + opp::wire::chain_code, token_code, amount); append_action_log(ops, op_pk, deposit_action, /*success*/ true, ""); reevaluate_eligibility(ops, op_pk, get_self(), account); @@ -1254,6 +1394,16 @@ void opreg::depositinle(name account, auto deposit_action = build_deposit_action(actor, chain_code, token_code, amount); + // A credit naming the depot chain would create a depot-native row no custody transfer ever + // funded: depot-native collateral is bonded only through `deposit`, whose inbound transfer pays + // for it. `sysio.msgch` never dispatches such a credit today; refuse it here too. Dropped, not + // reverted -- there is no outpost holding anything to refund -- and never thrown, because this + // runs inside an envelope's dispatch. + if (chain_code == opp::wire::chain_code) { + append_action_log(ops, op_pk, deposit_action, false, std::string(depot_chain_credit_msg)); + return; + } + // The balance map is keyed by (chain_code, token_code), so an uncanonical token // code must not be persisted: rendering is total and will not complain, but the // code can render a valid spelling that re-parses as a DIFFERENT code, aliasing @@ -1302,8 +1452,8 @@ void opreg::depositinle(name account, return; } // SEC-103 (WSA-028 follow-up): the credited collateral must stay within the - // asset magnitude range so the WIRE-direct remit path's `asset(balance, - // WIRE_SYM)` can never abort — an abort on this OPP-inbound path would stall + // asset magnitude range so the depot-native claim payout's `asset(balance, + // symbol)` can never abort — an abort on this OPP-inbound path would stall // consensus. The msgch ingress gate already bounds a single `amount` to // `asset::max_amount`; this additionally bounds the running sum. Fail closed // by refunding via DEPOSIT_REVERT — never `check()`. @@ -1404,13 +1554,14 @@ void opreg::flushwtdw(uint32_t current_epoch) { subtract_balance(o, row.chain_code, row.token_code, row.amount); }); - // For WIRE-direct: CREDIT the operator's `remitclaims` row -- no transfer happens here, and - // the operator receives nothing until it calls `claimremit`. This path runs inline from + // For depot-native rows: CREDIT the operator's `remitclaims` row in the row's own token -- no + // transfer happens here, and the operator receives nothing until it calls `claimremit`. The + // row's `token_code` is carried as-is, never resolved here. This path runs inline from // `sysio.epoch::advance`, where a pushed transfer would let the operator's notify handler // abort epoch advancement chain-wide. For outpost chains: queue an // OPERATOR_ACTION(WITHDRAW_REMIT) to the outpost so it can release the escrow on its end. if (row.chain_code == opp::wire::chain_code) { - credit_remit_claim(get_self(), row.account, row.amount); + credit_remit_claim(get_self(), row.account, row.token_code, row.amount); } else { emit_withdraw_remit(get_self(), row.account, op.type, row.chain_code, row.token_code, row.amount, row.request_id); @@ -1594,8 +1745,17 @@ void opreg::releaselock(name account, return; } + // A TERMINATED operator's shadow rows kept earning on their locked remainder since the + // termination payout; pay that yield with the principal, as the payout did -- the part the pool + // covers is credited, the rest stays banked for `claimyield` within the claim window. A SLASHED + // operator keeps it all on the row, claimable through `claimyield` (its locked remainder earned + // until this deferred slash). + std::vector yield_credits; ops.modify(same_payer, op_pk, [&](auto& o) { subtract_balance(o, chain_code, token_code, settle_amount); + if (op.status == OperatorStatus::OPERATOR_STATUS_TERMINATED) { + yield_credits = take_all_earned_yield(get_self(), o); + } }); if (op.status == OperatorStatus::OPERATOR_STATUS_SLASHED) { @@ -1605,19 +1765,20 @@ void opreg::releaselock(name account, emit_slash_attestation(get_self(), slash_action); append_action_log(ops, op_pk, slash_action, /*success*/ true, ""); } else { - // TERMINATED — for WIRE-direct, CREDIT the operator's `remitclaims` row; it is paid only when - // it calls `claimremit`. The termination itself already committed in an earlier epoch, so a + // TERMINATED — for depot-native, CREDIT the operator's `remitclaims` row in this lock's token; + // it is paid only when it calls `claimremit`. The termination itself already committed in an earlier epoch, so a // pushed transfer here could not undo it — what it WOULD do is abort this lock release, which // `chklocks` runs inline from `sysio.epoch::advance`, stalling epoch advancement chain-wide // and leaving the lock unreleased on every retry. (Aborting the termination itself is the // `terminate_inline` case below.) Otherwise queue WITHDRAW_REMIT so the outpost can transfer // to the authex destination. request_id == 0 (this remit isn't queued in wtdwqueue). if (chain_code == opp::wire::chain_code) { - credit_remit_claim(get_self(), account, settle_amount); + credit_remit_claim(get_self(), account, token_code, settle_amount); } else { emit_withdraw_remit(get_self(), op.account, op.type, chain_code, token_code, settle_amount, /*request_id*/ 0); } + credit_yield(get_self(), account, yield_credits); } } @@ -1654,6 +1815,13 @@ void terminate_inline(name self, name account, const std::string& reason) { } } + // Every shadow row is settled (the subtraction settles the remitted ones; the collection below + // settles the rest, e.g. a row whose balance is all locked) and its banked yield is paid with + // the principal to the WIRE claim row, up to what `yieldpool` covers; this path pushes nothing + // and so cannot pull more from sysio.liq. The uncovered rest stays banked for `claimyield` + // within the claim window (see `terminate`). Yield on a locked remainder keeps accruing until + // `releaselock` settles that remainder and pays it the same way. + std::vector yield_credits; ops.modify(same_payer, op_pk, [&](auto& o) { o.status = OperatorStatus::OPERATOR_STATUS_TERMINATED; o.terminated_at = now; @@ -1661,11 +1829,12 @@ void terminate_inline(name self, name account, const std::string& reason) { for (const auto& rp : to_remit) { subtract_balance(o, rp.chain_code, rp.token_code, rp.amount); } + yield_credits = take_all_earned_yield(self, o); }); - // Remit each (chain_code, token_code). For WIRE-chain: CREDIT `remitclaims`, which the operator - // pulls with `claimremit` -- nothing is transferred from here. For outpost chains: queue - // WITHDRAW_REMIT. + // Remit each (chain_code, token_code). For WIRE-chain: CREDIT `remitclaims` in the row's token, + // which the operator pulls with `claimremit` -- nothing is transferred from here. For outpost + // chains: queue WITHDRAW_REMIT. // // After each remit, append a WITHDRAW_REMIT entry to the operator's // `recent_actions` ring buffer so the audit trail mirrors the @@ -1677,7 +1846,7 @@ void terminate_inline(name self, name account, const std::string& reason) { // (which are transient — the rows drain on the next `buildenv`). for (const auto& rp : to_remit) { if (rp.chain_code == opp::wire::chain_code) { - credit_remit_claim(self, account, rp.amount); + credit_remit_claim(self, account, rp.token_code, rp.amount); } else { emit_withdraw_remit(self, account, op.type, rp.chain_code, rp.token_code, rp.amount, /*request_id*/ 0); @@ -1687,6 +1856,8 @@ void terminate_inline(name self, name account, const std::string& reason) { append_action_log(ops, op_pk, remit_action, /*success*/ true, std::string("terminate-remit")); } + // No OperatorAction names a yield payout, so it is credited without a recent-actions entry. + credit_yield(self, account, yield_credits); notify_producer_standing(self, op); } @@ -1698,7 +1869,7 @@ void opreg::terminate(name account, std::string reason) { terminate_inline(get_self(), account, reason); } -// claimremit - pull collateral credited by a WIRE-chain remit. +// claimremit - pull depot-native collateral credited by a WIRE-chain remit, one token at a time. // // The remit paths (withdraw flush, deferred lock release, termination payout) credit rather than // transfer because every one of them is reachable from `sysio.epoch::advance`, which must never @@ -1708,16 +1879,70 @@ void opreg::terminate(name account, std::string reason) { // The row is erased before the transfer is queued (inside pay_out), so a notify handler that // re-enters claimremit finds no row and cannot double spend -- the same ordering guard `deposit` // applies by crediting before it transfers. -void opreg::claimremit(name account) { +void opreg::claimremit(name account, sysio::slug_name token_code) { require_auth(account); + const auto custody = resolve_depot_native_token(token_code); + check(custody.has_value(), unsupported_token_msg); remitclaims_t claims(get_self()); sysio::opp::claimable::pay_out( - claims, remitclaim_key{account.value}, get_self(), TOKEN_ACCOUNT, - account, WIRE_SYM, std::string("opreg::claimremit collateral payout"), + claims, remitclaim_key{account.value, token_code}, get_self(), custody->contract, + account, custody->sym, std::string(claimremit_transfer_memo), "no claimable remit for this account"); } +// sweepyield - claim the WIRE yield sysio.liq owes the registry's holder row into the registry. +// +// Permissionless: it only moves WIRE the registry is already owed into the registry. Attribution +// does not depend on it -- every bonded row checkpoints sysio.liq's own index -- so when it runs +// changes no operator's entitlement; it changes only what `yieldpool` can cover. +// `custody::pull` computes the amount from the same rows with the same formula `sysio.liq::claim` +// settles with, records it on the pool and sends the claim, in the same transaction, so the claim +// pays exactly `swept`. With nothing owed it does neither, and the check below reverts. +void opreg::sweepyield(sysio::slug_name token_code) { + const auto custody_token = resolve_depot_native_token(token_code); + check(custody_token.has_value(), unsupported_token_msg); + check(earns_shadow_yield(opp::wire::chain_code, token_code), wire_earns_no_yield_msg); + + const uint64_t swept = pull_registry_yield(get_self(), token_code, custody_token->sym.code()); + check(swept > 0, no_yield_to_sweep_msg); +} + +// claimyield - credit an operator's earned shadow yield to its WIRE claim row. +// +// Permissionless: the credit can only land in `account`'s own `remitclaims{account, WIRE}` row, so +// anyone may crank it -- a keeper can rescue a TERMINATED operator's yield before `prune` erases the +// row. First pulls whatever sysio.liq owes the registry's row into the pool, then credits the +// row's earned yield up to what the pool covers. Credits rather than transfers, like every other +// payout here, so the WIRE leaves only through `claimremit` under the operator's own authority. +// Allowed in any status: yield earned before a slash stays the operator's. +void opreg::claimyield(name account, sysio::slug_name token_code) { + operators_t ops(get_self()); + const auto op_pk = operator_key{account.value}; + check(ops.contains(op_pk), "operator not found"); + + // WIRE and unknown codes have no shadow symbol and fall through to "no yield owed" below. + if (const auto sym = shadow_symbol_of(token_code)) { + pull_registry_yield(get_self(), token_code, *sym); + } + + uint64_t credited = 0; + uint64_t remaining = 0; + ops.modify(same_payer, op_pk, [&](auto& o) { + for (auto& b : o.balances) { + if (b.chain_code == opp::wire::chain_code && b.token_code == token_code) { + credited = take_yield(get_self(), b); + remaining = b.shadow_yield.owed_wire; + } + } + }); + check(credited > 0 || remaining > 0, no_yield_owed_msg); + check(credited > 0, yield_not_covered_msg); + + // No OperatorAction names a yield claim, so none is appended to `recent_actions`. + credit_remit_claim(get_self(), account, opp::wire::token_code, credited); +} + void opreg::recorddel(name account, uint32_t epoch, bool delivered) { require_auth(EPOCH_ACCOUNT); diff --git a/contracts/sysio.opreg/sysio.opreg.abi b/contracts/sysio.opreg/sysio.opreg.abi index 11fbb230aa..49d1ac1c69 100644 --- a/contracts/sysio.opreg/sysio.opreg.abi +++ b/contracts/sysio.opreg/sysio.opreg.abi @@ -141,6 +141,10 @@ { "name": "last_updated_ms", "type": "uint64" + }, + { + "name": "shadow_yield", + "type": "position" } ] }, @@ -187,6 +191,24 @@ { "name": "account", "type": "name" + }, + { + "name": "token_code", + "type": "slug_name" + } + ] + }, + { + "name": "claimyield", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "token_code", + "type": "slug_name" } ] }, @@ -234,6 +256,10 @@ "name": "account", "type": "name" }, + { + "name": "token_code", + "type": "slug_name" + }, { "name": "amount", "type": "uint64" @@ -404,6 +430,20 @@ } ] }, + { + "name": "position", + "base": "", + "fields": [ + { + "name": "index_checkpoint", + "type": "uint128" + }, + { + "name": "owed_wire", + "type": "uint64" + } + ] + }, { "name": "processbatch", "base": "", @@ -529,6 +569,10 @@ "name": "account", "type": "name" }, + { + "name": "token_code", + "type": "slug_name" + }, { "name": "balance", "type": "uint64" @@ -546,6 +590,10 @@ { "name": "account", "type": "uint64" + }, + { + "name": "token_code", + "type": "slug_name" } ] }, @@ -609,6 +657,16 @@ } ] }, + { + "name": "sweepyield", + "base": "", + "fields": [ + { + "name": "token_code", + "type": "slug_name" + } + ] + }, { "name": "termcheck", "base": "", @@ -661,6 +719,10 @@ "name": "account", "type": "name" }, + { + "name": "token_code", + "type": "slug_name" + }, { "name": "amount", "type": "uint64" @@ -732,6 +794,38 @@ "type": "uint64" } ] + }, + { + "name": "yield_pool", + "base": "", + "fields": [ + { + "name": "received", + "type": "uint64" + }, + { + "name": "credited", + "type": "uint64" + }, + { + "name": "pulled_index", + "type": "uint128" + }, + { + "name": "pulled_owed", + "type": "uint64" + } + ] + }, + { + "name": "yield_pool_key", + "base": "", + "fields": [ + { + "name": "token_code", + "type": "slug_name" + } + ] } ], "actions": [ @@ -750,6 +844,11 @@ "type": "claimremit", "ricardian_contract": "" }, + { + "name": "claimyield", + "type": "claimyield", + "ricardian_contract": "" + }, { "name": "deposit", "type": "deposit", @@ -810,6 +909,11 @@ "type": "slash", "ricardian_contract": "" }, + { + "name": "sweepyield", + "type": "sweepyield", + "ricardian_contract": "" + }, { "name": "termcheck", "type": "termcheck", @@ -887,8 +991,8 @@ "name": "remitclaims", "type": "remit_claim", "index_type": "i64", - "key_names": ["account"], - "key_types": ["uint64"], + "key_names": ["account","token_code"], + "key_types": ["uint64","slug_name"], "table_id": 45499, "secondary_indexes": [ { @@ -917,6 +1021,14 @@ "table_id": 34233 } ] + }, + { + "name": "yieldpool", + "type": "yield_pool", + "index_type": "i64", + "key_names": ["token_code"], + "key_types": ["slug_name"], + "table_id": 51955 } ], "ricardian_clauses": [], diff --git a/contracts/sysio.opreg/sysio.opreg.wasm b/contracts/sysio.opreg/sysio.opreg.wasm index a3ba0bffe820ec8cea6e74f146243ee6ddea1de5..2f57a38438539d4732283a062fe21853facf5ebc 100755 GIT binary patch delta 27619 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 delta 18400 zcmbtc2Vhji*1l)%ZnDX;NKq|99r@h9JK8H0lBjx` zMo@k4E!W&QdgPcJZy0mqHHMJF+%AurGeRfDXTpdjBQ};OmVEA5cY@ECNIqX<)1Bn@ z2@y}OsN`5v2w|r9gpdEE`qE;}SP|`KFy>)@VuuYrnw(;%E zG&8kLx-fm|@g~EDS$(E2Hban)+A|awhrB)hnxZ}b5dvTM<^&m;W=qV>ll#(qCZ&f5 z2|y!|aD@m7Q&3ZG`O6=@Qxki`AG0Zsjb#`x6IXD;0PuhKKSOXekf8q(VQG{9rlUHR z%rsv*y5#4(+%%3FHgub0P*kEBBD*rK+VF-9d;4LX)f|=yQ3auyW=vBuZ1Ohvo&KOd z=?qnhsiMUx@vX@JPJAy;iyy>K;%D)Th^^V_{!F+GtEu;9GDD)&Z}<$`i1XS=bH&DC ze#5qI4^S{5ity#st{)aK{YKD^3)vJ0vqkMk<9SsUm|C{cU+OY1P$gUssyUyEF)#!$g5p}4FjxQi8ksT5wb*kE(a13?w z$;K}u9i23iI>|SZuB0|qS;_TD29i@`pOp7RHBg(HSGc6wu*l1Y!l!93el7fI>d$SF zPgj{SSl9eZR;I=`SFeY=McJXC{i$)0Zrd5DjgIvG^>w*5H8tUa3w(y(Yu6Y-q@JMt z$Qy>$Q+}PAG4Mr{$m{pm^WQYA3;i+n3Z2K=RXTU-dxh(J`JMV+;rn{q|Uu1Q! zx+2XY>RokXdXT7(e6PuG)K?zNxR?6LEByDz^bf??Q{OeLF7mYh8+=9E+xV;MtIRZ_ z0Wv1*j+B7`!>$(uS=I|$1DTkT_#+7)a#_|(G)NB49zlcUy6ndik^?T==;bqjJ(nHd zQ{&)uIV$I-CW{Fh=26(Kp2|Ztz8sj>F;pY`#Ng^2kk*I* z`6*Wzb~aec@RN5ixC$Wb33t>vI_f+-XuZYtx#7HJI4=lVUt$`&L-@OMI0v7h!TWeU z5gno_S%xh=&709SSWtY|eD>DE>)JXfUD zGrV4_yZpA*KHt%|4eQD}>s~}7WlBLyDwh`)^rfrh!h#FvYWaRaFMknJ%WlxPi&JY< zir;08Mq(c~g4u?5n=C8rNPaoFa7cq|AfL!S0qH{a&mw4DD?cslL6^zgHWT89@KJ%& zJwfX_WCt+SaCv2?6!}h@OKF<=HjdI#y?(=+L=?iAUY{xS0O1!_+y&N{s*!EuMQk}n zf{I;Mxy&4xC}sPHC@2ey3Zg1_=vZ0!V*oqYdsU)bQPiAnkh_ZJQ*U{B@nE+;C0SK` z7mbyzIvnxbhz(dl>n2&hV+R^8FYNe~n7K!u?szSgtHNQR!&^#HDOt`dDM;AIkZ8d7 zLkyn!k8!(ivz7}vD6dfd(W#IglC3-E(Zg~?XI}q- z&Q}2N%z^1LqnkyIstUTbBJnfH^3tyKlzgPLo!r*7Sqg98C^24PJZ>hI;jd88ChHga z4?~AED%TJJZ)UET!w5YgOMgHM)LRW&BQOHo@oF)b?pIcsTvqCazc+`u!!Vy7c`ci z_eg8X^N6qMz2JulJ#^(>`VJENe6-iQ^h(vZi>?lT&5`AOI@3(~ zP@nD@j1}^`Z5PfiS4D z0(7fOqZhzM!oC)V24;IiYwgW?1)vP2zwgrce*F<6!}V3W9V6RRqjz zY{>#lWk#{N81$==S!~YdA{Co6nWRqwQYx#gtX#YOfyR*>=)N6o*55Yy%{+5lfi)qr z*GdY4a9BCuX!6WEicMH=nJ_T&jq5YrW~sA?8aaXzL#91nv(QYPVtzLtm2F%Igx-OZ z!x&hQ<25F67*-x1Cr{+y_UvGXEUb~?_JNT>d`y5QJP=xwY1-wCP7CJyT@0tIcHTQ` z$2q$|1HfHDOpUwbb3@v@S8g|~{_@KqZRrwOGbAzdryclCsRu*DZG!-jvTgzen_=>h zLx<+3e6uIq3*bBbG|Bi~m+~a;B{e*0)zD6$XE!=9J-h9-P)E1OGed``UFOhnG|o6m z>l-%{cbKAY8I}oJUNkH#MyS!F$@y3 zdPFx`_Q0i=!c)8a(hcwSm@8=A3!#JxF3yy(Ba5-uvXLE;P8!*rY*{^W6Wu2#mZ#8S`C$2-=UQu4 z#9GU~YMoYur4V)vuO27PyeX$&{R>@QHDy#^qUx$$qu~*LDi2))JjtJ~X-1D!Wn4Sm z#J*OJ8BZ%@Vz5I(IfNHro+*&Cf@=pRD3_yxBP>?bEC|76E^Pi$xjWd5mdew?OKF)L zP|-W#c5Hw-Hqr%LYE6*ORFu;lGVX@9Vx(7gIg%`|xM5P_H#pnb#H^KPT7DX&DQlMe z=7z?}EaGOfU~oiO2bN%+H(?Zewg%Gs)ib9!?CEdg@%g3``)*n=9s=XY}xU{_>eyQ%vh|$2PVqyduG)G2R+t&%48(- z!E9tnof*Sl&^BSDLOG#gJt|^=Wt@qlw_?xLiuy>xIx{9ufXLR8ZDe}1GQ-?{Pun1) zkk876bFu5;T$nPUM>igvvFEdX#pVj^xU6#btd&*On~q$F$xJzNePRfdsv!X0`3M7A zA({3%d^<(n?^-^E7x-xSEUX2LW1!Qz$-&(VVcdb3c$x@)oW`}0Ms8SVYRqvtcoiz%qkOPKOA0ebj4uX0>OOjl%JhHOAqApk%SId%qSOD0O z%^Rv0wBP0<3V5{Y_nZa9Qwzx1ff0r8Y-KyP;_Sq((dI{3qqFwIp*o~x+6(#MBj~}B zCyXI5*E9v@8^dd-I9}Ig$Q3Ok!wb$GT@%G-!D>Xv;H+vZ%U=<2WgDVYZn>ph$YecO zYrL}4S5`f}OGxu)+}&j-qo=TB@FT#L1OwNw(}DqQaeD@^e)dNfn@PybVn}4@!D16C zj^&!+$A1`usf$Dsm><;!bZ7vTMj)obcHb6=Mc^wGaN8iiYGVKleiwUe5Em7oP1kLK z7>vQ+KB+c@MK=)Mapch#rTSyRQ=f zAcN@)6?i4qh(dEzg`E+!%g3`u2CNLE`vTbKZYvK4z1C4!DvD*7If=LD=7`97xIzmX zS%zhUv0S!aPC3zyHp#6gn#4%Q68dtV{Ktt-^`77`onHV9YXz+OjAX&y1tG4G;wM-b zQ0c#;i?A)Z{kCV4*a(3%E;e(K0oi^YTx@nw2Zzuax>lC-(JLlNZMmPgND=dBwTm0*}+EI~fxd>WX zAvfOP4=skZ8C%T4%kM4{^SLdiNGwEdZ`6_!-k4)VXx0{`XsCv-r6W-udpk7ib}d_R z+*$>porge=0VJ0{8bq~(P`Q>X?G-x3%r;iE*HKIm?L`5@xMwgQ)`18 zcIo+3ml^+gY8LZ#W|jl*bhoPo!MX%ocK)0iTxVfz*+YRZk+bj0sHhzni8V1Aa>XU~ zl<|l+LEHn}&Ow_^iT-~J?SF>|2$yq#bq$0}(khWBo(CoVf15b_+=;hLY>8OZKPP4* zUSv&b(Sxmn|J%f{uFgW-`eJxLMWP3YM^i@;K;g*#CWuTANMaMGVs zY=o0aLoZXM{53S!^9X5?S}f;P_J~-$L4AMhNLsGqxkW^&YO@bq|lqzL!VMxc6zKZD%^= zG4hX@*W>%z`@TRra2EGoGwU$Y(CpDjPt3luei)|A7%vopS3Wv(xLiBuQZ$-#GwD&; zWqmW*cW!CSV*nijAECNMbN7bhcop}vQ}@dK6{(K#3uh^8{KEO;5*xqLo|oA#VqwnV zTZTDc|FHL^EzC;xVCuMI+A!ow?T#Tp;J9OK0Xgp2v-8*;`+Od|W0~`pXY4nEEe*r! z3iIl&NdY9VnY~``YTRFi>^1x4(fKJu_ZyjH|9(JgPJ<1L6ssV>v0KcF>Ix2vIgObH z+u(NPetdGrrs#+puKnD{PpK$Fa0#er`;Yyy?}BVPAjdDbnfA*Q3!a3BS2h2EL+sRD zx3CMGx>XCa5n9^0@B-9Wi|zwjmo92r?^Q=pu<`Jr+`Z^(S}9vUSWTVw8A&gOlF~^yM0}a70kE`6X{meO3Pfk(E_54A5 z?yg9edW-9H;i(pvKoHDY+%9HIZJ)R6_GvtawI*9|8X8AtTJZ8RmPCLkSz?0{CM~gh z7G*Yq(s#q@iAVS9XjrWNIDdU>4bK53x;Yy8W;W3C%##EsL)Wf{eWT+M;aX`f&16R+ zT*>pm4R5!U&;5p_ZIP~6Iv}OKwU*cc*y z*$#{f$dkFt-ZL8oTyoIBF>>GX(e$J&ey9Tia^oJlILV2yJxPCwup$1@OJ+UXP{%-Q zv5@4Q4|fU$SXKpK)?TXJ$(;w`f@#!cYI_3>liAjB{X1I+K|B~aa0mPbyAtfi9pyQ^ zuaHU5{lucF#?XE-wk`(x468c_*;vyj!L0Tpx0tioaMYO}nM<3XYwXI;r^g|zS|kck ziL3`QSPs27JL^E!Enkw#CK7Yu8ys9=Y@4i~S-5j#knMk1n{aLL=_zDa#m@<@cGh9b zIz|A3e;|u22E7{b$Y_YwXzhEM*2D7MN3I46+pHJ`E_rZ85z=>7bV3^QD5n?bbn2sA zzwyy+Nbh=#<=NiHUO;-!l*WPpsl{m(}Z#{&O{#yRYGN`I?bP ze_zAW^2xO9>pyd1`mMsz@(d@I54^#kf+E>0IF|q8bA1pxS09y7(?~F1 z`8!bpg7QnBzpeQqx63(leI>@h5H~QVV_Ca(ybks)I0x}8+`2Tn(thPrs4}$gMm_9p z-3Rs>w4;@r8SQt6jX|Bm64VoIgb9yBjSgQr>SCq9Jvy4%mUFYk2fuuqe5I=CsIxPh zsKf1x(}_Q}abOcJJi?b)5)LgE~g zq^|}Iw*vZtz}cdogI;wm3?krRHS0Cu#9^J4L34H2lpns39ExoBznhb1dtG}c0GT`M zC3PImZO0e!4USU<$4i?Ikq)q$BSjd}b%A?Bq}UDkXc+uj1j==Nl6=vuh@u^=)yTVF zEQ*G4%7GhM{bEAAbIE{%Yze#4ZGQzfV7UC^#V*ucHhamDY(wO28(A5@xv`V{=%qGL z$c**%VTJ6rXP%8l$(9@1BdVw|yMiGEmjdS;Rh;s2MjWKBQ^lhpbl1JyLhjj648`_u%`(S~KVNKnDj6gujEoZL};|%gOMC1 z14YCKEnC)nkR09s!gxTNYs*-mOh<+sM4@9Ofaie?GiOVHisiFgTG1N$;g+n#amc!S(+Z%#nSZA-B%zz zvzzVQx8LE`X77HDG;t40(;N5vg!I}PmZrOESk|`O`w7yxeHoCZt@n*)X{z5y+$5Lp z`wmye;g1LRmv~lroY0FrvOfz()bIOSrs{ySj(fW~*p00h>Apio%Eg z<^ewZgo71B9e&}51RTA^m@vP@z>tZ>H(DUCp+4D=(X2CvxqJ?cBJj@juw!&(B}=qQ z5QI}HtwWk1!gV|gILbcMqM?@=-pdRh>x7@8<&;Bh%oxyr)u9sk&7s_w$3V>eVL`8# z{=lUEHW>Z4 zY=&sLPnI67NG}3LO`8L0P7jB?DM{nFll* zgZq{vHfAr!2{_z`kN-JKwvg{0i#6kb?hlTor~5c2rGw8p8ebZKN2Yr|m zt%K~=S~=sx06ihsB3%s@PV)dK{R6S!;BP*>B-U>L8hA_3lbrQY3Rrp7N1d*`ORy4m z97@3sCE_xIRdFs(LR*VE`UL&J2Ap8I%%om7XbdNG{SJ&`A9Nu<{57zSNYh7M*gS0+Q8RR@*0hVZDrc<7-9KgoSe=vgrwFh+77H)BPu(m>onAm&KeFV~0aW*k5u%;d)xcT4)^;xsKp1;;@E2Hz74 zzKae^(Q&^#iaj;_^&;%=@?R_HHM#xQrG&R>zfF(74-d`yXTpczwc(R(6jnF++iz|0 z7OnN~W$tDAg<6L{3RATr({x-rg!{uB?j~RRo!_o~_q*+WHr&4}US>@A<6AJ&m_NsP zS^Lg`kaZp)r8oHIE8Z;EKv2ywE$2x^^D}G28~k=BPyT+UAw9JyjxLUNT(~zDjlol5 z=dI16Ba{?*$&$Jbq++jw-J>7ebifw;u+i7?dmEKS)Sr#?TZn33+^83cQk*9@IZ?a7 z#dd+pjHP%LC+M;`n8g4Lr1zJW9QC}Qj)AwFsP!w@BU8Cu%~5J?sqquWx5?_R@Z7gc z!|mh%0kjgwW33(Pa+BuMW$IrhCD9QT<)WrET;;oHIKJ+25uO96wJyr-7k&@mwT3bW z>d{ECK>$YfDcHC>$=VezhiBhSEPb)M0L3q2U{LdT5oPr_?wvP`>z(TH>XibhSF}r2(CtkP~YVVRK-76l`O`VCgwQ z4;)B0@yZS261z-6Y|pt;L`+A0Je(3#Wj%UH>>;%vmM&BwA1$UDl~feFENQ4p)yfqHBCiM9iQ}NBcIQ*HAjAM~d2#0GJch zp9#QmqH2{$t*<(O{W_OjnlG%n-+X^q_nW26!8-ka2Qurni{+tbw@OG5OxaWOa|vXsd@>-k7$FC41GNRLWMNzF?S#lPH>N zUhRgOT}hPYJIqJPGNGPz1W)10z-aHOxMchiV3F#dOt;Z=^+qxUiWqjLZOFMu5lZSH z6E&`p4wAP6lmSz;a~XyUe~_E4(o!%}E!JkIP+^LLg?0>TcF;Pial&8LF*UX|aD1_P zwl$?IcR3|Icq>JPH_(~$dAt!{7T7UCy_-$#aS#pKP+?r1DgBJL^#ODk+lDgeeziA? zny8o3KpHt2LO;H=A(B^*1b`;W`u~ z2G&kegVvALH zqVlln;ioxdtG#|~;Qc~7tho4O6$MwW!fLFx=FkxR?l8j>xuH~@n$nN-qH5C& zz}ad=H)^ax%_xfIse78$-sf=CV1%sIwJLp$HZD<=$-9$ZH zKxt>U_+Y8(kSufAbg(h;0RTe-+{Dc~oKCYc0!C!E;E(@u(+KmJH`;}gF zR=T!bUA}su9YA?gZx2rFYm*gl1}fX7NuN3h||X&M=FA&d{pk0oDdk5VILA&<;As;T+?7+{n)n{CEPF zT8_#>5#Z7dkqVgdF6X$&ei{4c zXogb_JkD`hI)v+D@3Dl5mEV%s4G`Mo58|lVTHwg3=65GfHG6qaFiu&uKPkK+$M3Q# z0&sBgA`9-xmtoXD#@+BclE+0REHXdk8&-)Chb{s?;IdY7D;8PBIpn1t1+lO5g0nvs z(cir4YVOFZt`V8+FI{t+>J7zPq#o`~?L<+E zx*?AGt3V&%bDA2|huZ$lyUTi@@O~qyOnuOY8b{;xWfbls6_rEc%~P4>l;c=3BL`D- zor+YHm-U529;vqVrIh;a+loXzy=@?4ojTQz(!?`DS285oi#r5zK zBU&$ti?o&D+B~zIuZH*pz#4cQLL4LjX6qufnLcR!c82Ky9r+mz7r{pQG{8pq#m>w4 zwII9P&e3xR>$w3uB@E){ImgH<&T+_^6fi783VEi#4j^BWLm<%l+9^K^p&CDk5{e@2 zd1i-mMqzG;Fr4A~*>LUHFV*HjlncB-jpnG>!Qjq+cV=@2Q;vy4P_r|rNpycL2&j(kB9mtMlMF`9oN>!>H0i9Be+)^;~l~|AJW!yXbcBtGr{PE zu&~ZSA3BF6eu}(US)*Go%t0HgTE{r}=X9W}6x&pfc7rml!+ecK*Pa&t?!jPDs77|B z#MmSR5jAK@D%6#ldf#NB%XdZDhWhqOYEcMX2Igjw8LdkgiQotax^S)HJi~4;oKEn} z(1+#12(Tq?72AyD1~&(*qEH}v>fwm6MHS0h;mBGYs7j8o7dqA5=P2)#rM$_~VM}sRt|hCuYLV-=#jjp2j+vt>%xRcCPIRm&v`QS?7nk z-if@>{{6Imp{??RbX~{YXOZL`REt@TWAf3R52mISaKSP4P7w0xp!!FUitCpnKE`OU z%56k#)PUKPa!L62gNMRor*oXX&T;yh40FSI%Wz(YsB}9nnRU*@1()wX$lASOSqmn* zAtNHoH0sY6YiimJ^b8%?P&gJ&Qfg$6N5g;~WQFL^y?XjaYTWJ+v$Q{aR)AO!r~|3~ zW(YN5v+_DD)RsZyS7&Zf>!Urk7cwH(;KsMo59@G4NRQ6p>FSj z+DdiP&6MB`%Qc>J!@Qem0!18QdPn8oN?EG)98%&Fp|;PVKH^iM66V6H)Am+#HEk~K zQ5|omOo-vjZl^pd-!S8LxarispG_YiG@(D_GcFx%!Bxjt_qDZ???UZAg?juhx~U0EV1Wlph_7Yx zRSD)d?R;oxj_saO1ry=UR;&IK={E5d4t631c(YFQViFdqopsbC%=#~(?wh37+QN<1 z>f|K4QhY5`ugO#>z7cBTWcWhWYVl;s^-dG~BPdwSHHXx$$&`(`PEDpvvH6f{G=<8d z(GC?fOMyghj@Ww%|8e@=m|q*HjcOKh+*J*;zHUa;@MYS3Pfw-VJ{SO@37 zWgTunP|43R5M$;zF?s==;U9^H)-m7c{vavtfNun6xdVFPno-}EaIh88TKlwq-p)@L zdHq~$0spK*HZpGH!Ms#zkzl^k674r!N#SQf+&hhZ00&S!E34!=@H0BANjPYn1~{E( zBcz97bvy_$_&R{i9zXysoQQv`LNy;l^|RCTaExXc8056S@7^TN*@g~U!$(=DR^5l7 zclcMr$3F3>U+$xZamiR+$CaV&ON=D-!8Mdz-_MBBKoHm2v#4zrn?yK3Z55$OTSYcE z15_PQ@dVYQv#3!TI)$%>z)6lwVOs%jBiL97QhJi1FMarK%CQL|3QqNzO$Dw0YWBS) zSPZcH6aQED2jcBZ_2g_SZgGzNz_%L?<^GNRP)9<+KYx1;^@V>PKR4nJI`S!ct{Y@y zm8s$PBl0%keoBZw^T>gHr}u4F@_s79j|6d6)>o?Le)5L~`(pLyj}+$>rG7jHdv)gA zMPIGmyD2mT)W;XgEA-e+p+P;f|G+-yhoX*n4Gz zd@x4&g1WM@=Ht~p2K!?4ir?^xYVtfP7=TBDXs_P+&KEll_l`hxIy`M<tu#7cQtd^C>oMf28{R zNZ~}J@DKIte9EbRFW#vfZ0t7d%4)-Vk;-2H%YMiLY7?~s-%~wn?q5Kzm}?Dt=XS$8 z&7;&Jx;8Op*hPacFbw0SaW`LcT}jF4YsTGl%a~g?#63t8N$p%t*&7xqTn-6beSEc; z3e>nIl&e-Oq4pm9qXIRC_jr^#xP>fda0A>XyP38(E)(8b=1=c`mUIs@7 z$4%E61Q@wN5)O)Bc?BglC&pl21_dTZfh+|Ukc@#6d#wU1P{1NflbN9wD99eNwpM{{ ivYEF!P@K6&iP@Ah>V|&HK#8}TV+0<)ID60aKCUb=nbA$q`0&}1=FM}h4 z$x$FnfdwRBpu|?Izzh_y$kJqHs09kL dg{-YrV4ZB}tUy-#Gojvx}mNz6lCltzoPDMby{R*PGuN{6G`>OfmVgP0mqoXQFz zk;JTOIMLmrB!-GPF{VX{p+b?wbWy|iy!)u$@BY5~{d3;E_dBh%-nE`*t@j+r3b~XO zvZk)fdD(Tz*TA)&NcWuz2w=+aUI=`z{(a{I=R@Z>ZM-%?%hVU?i}j`YGJUzeLSLzW ztFO|((^u<3SN8;8@_iBXt4S+;uXJnfL^HK#8={rosXZHbOJBT;50`sg51s@2UQY#7Qo1Vc&4?={Gh|vM*)8k{Hg{yu$}ad%I5x_l+9=Gx5p4 z-kbX7#LpouH@WSzFTw3F|D&7TO|)B`T>uveJAF2 zBhGeV;a2|VJpc36`8~+HF5T^WJ@r*59kZuhO!pF9@Qumb<)q{G>@9U|7zSQq7w51ciNmpw&q%* zdA2%Lp*hxT)yeHDJ4Gw&Fdc3-9KpfMCB7yNrHN8fld94rE2I|r?3vlM=~O7KlZCPL z5v{XATT-A#8?36W=~;5k(_+X-u?DoEsya=UQSE6Jn))5+0wqi6bC@yL8vh)vrADb* zh#u^AxE*FakDJUs9wSYQ4ACBca+rCJM;!}gKqrc#bQ#-;YEhb%*@@;-`E)HhAcWNT zK(}V3$qSw74N8?2ov96)qRun`P3JBYizcoMRSeD0qJz1eTgQemfo?J~WN{Ztqg1P7 zSNe=7$=dh=btV!^>rPJ-Es)YTIMV9VgSu#Z(P_|Onl-OC6%xH6>4j8BGG3z?@0nqhtzTZF)v5SFQjHel=v3C>#(WrvhMH|XW;^elqFP?>yA`Rtm;k{K5l=k9{_9LAdtWpXlIlJ_RPPuHc* zAeulol-BQ6xTWL`@fdbnMYZ0|Q=_EcThxL6CDaF=bX&H(Mv-#(Evij-91;(2a6+8~4^~u@Ql<1b zRf7kYJ3)~V3$=SNZRFi8A&Q2FCbR*yo^FO=L- z6jd!9QAfcDHmD1QR5aS57lW9xcr>{*S|ovSWOi5#Te-D*e3{j62^))*K4ONHjjS$oBkm+&sd6>OWpc<~h$Uqpt`yIwI>&`fOh3Gr!F@av9)sjAe#+F^KL1ye=mhl)Xq)|M);aiD|r>?Y0_QcbV z;r^wZ7=gu{e=WA$N|{Kz35vZvnU2#UtNj!zN3_Q3HIt`Z>zn5z>=``(;L$fH8evpxgbOcLupG^;(Whdgqux}L) z&eCV~Nush;VG@?D$JL@WbcFajMhe{2wGkagmb92lEoqZ{FqhiXPqKC{MblN7@@&bO<~^htXroB5H2PbXNk_B7XUBAGH4KOm%I!Jc3ny`sGv07enG(Qeg>L>0dHo z35}s5p`|pCF3ROXVASZP6o);;~9X{yz4%&WqU_7Ag{e6)>#I@ zOz=bh!R%K!0ncPJ`;fypptYybG0wjSwRaf%5tYiFY9ThH=@<@^b<620zeRS7LoMH1fJLx{N!( z9fa_i;!;6W;`ErO&Nz%CvdIT1o|u#cTDmR`vdB$G<%KNp*d!U91;4v4>$9jk9kZP4 zktxtxfgk^9wYO-5lM3X=4YYxNkxw_$BKlP#HqmA}E(M$DLpmXyf5OLjx$+au1b2?v zjOq8pxdp&}$$D`MC|`f5nUfF7S2@&PI_FTR7N@&)`6!dC(=i#9L!Z!(Qk+ATpscJBC8OOB2 z8uT+{;Tukgm_RX7DVJ)7{|MiK<2M1_ZC9{R#kmSKK;{{1n zd?`IZ@7KV==-BQY0+kg0DMZ4c^J+93g)%yi*28Me@~Hz))TP%=swC6$VVR<&gVZPB zBIaR=WWPlf<&%T(+eA5cka8ORp}Ae=LV_w-?eUAt+ysID?E~V^_(Sp!QKf$$1g(tA za_bOnjZ#ygDMqP>0F(R=F0;g;#vDBis=p%rj!+A_YRx)A0ZzOo7a)DQVwL*^cm>Sp z^((n3w)npgC@|?i2~-VzM4-if0xkYO3AE(D5NPrLN1(-i0xk9vXz70;&|*J<7K=@w z#bOg^u_T|MC1H^G5rIBNQR#IO1X?PdlN1$x%_dMBpz zyiBHm2eua&a(pONmBB@Dq;a~Jp6g5FrKpH{(gbOF5q_U2xfc=F1#;&id^J&;T>_p? zlChWYRv^|T`jVzdvp)cFQ^ofO)pmWYTr)v_`vdejP3m8!1T^a}(<>3v!C{KQ@99r* z7V;XWS)!@1nISP(5QH8kc}8uJ!E&4j9J z81r*7miQ{oR?oS?bL=T*EtFc)&dE)X0uFa_XHM4@hf;=Ic5(z#4wuG*k!uXqxUYCL z4)GQZGvu_!+h~ckNaqQRwq6+?LBQR#K<-ICDGuaDwBD)~#N$xlN@_6og}k?exdTAG zbqIfkw{;=B4xl`wET2`Xbt%t#F{QG>;F%n2ekgaO`deXjB(~9n{J>#rWv5+u0|e`4 zOXM~n4dg9evt1i`K8z=aZdV_GVW<*bMGzYMSuggw%; zE*$4rMK&o%A}aB=u%F@bY6F!u;Rlu0PARU$(X>rMD|0j-(PdmboV9!+2Uxu-b1a4K zf@?X=bMPLgxfe&aTjqxI=%8%nBK17R9$nrft}d;s@Sd`HI_dyYnSjH{mx>WQ8Cl8v z2!1zchZ>FoZ;$SMb5a$l6Uj}24(KZFML35DRpeS$d?Xhz9g_alIh+sc-o&nzB%wOr z3OouUne>dqEqT|dx-5<28es9AQM~4&XyM>SJ4tO3{C4gXUCl1^xmzS=F4#9eyu zi^N}H#Bo_wgFEWK>cPSLWls-|kq9>r=i?yfZ5(5L=jO&l=j3EGcl9?>R;L(lNybT~ zMII|tWIH`ZfiI#&bmOpfM6zLIu3t*&T>E43h zD_f$gL$f=O8gFaC{V_fIDeg?y1i|(@vXEY}c)KC&?J?4@@ZK3f^+18e8bBfEKRy=IW@1p>J zza0;$s_e)n>{z5SRvZ|955taZT$0P}cp6=_zAzEsDAAX+=Q4y8WpxK0r`^|$2h#jG zUV?Ku{u~dZhtj1Zzsqqh%h!?T5G|H2&vQPFld7FL4g9pRGe1e=tzz|t&Y4}ghcN*e zhq(s3X=a|wV<3a<${DzKn)L$jX+BAHDuGaO#-Y?TC&+Z;5ho{Mh+qkCX>B&>5%yjI5#b?Of7x`n5*|1)i>$SYS z*dSiwvPpTVJba0J{s;ZKLGNPq?|&@^y}+;Q83w#zyn7Y`MUZGO|Bc#Qnvz{sSKXPdR9M=Buf{Y!=3lIU--sIsRff;Y|_kLXzrFR>|$r0mRk6pP)L7kDR zR8`&g2XTs#rZ%_DuYQr_E_;h}vH0-82;dCShXD7NDT~&Y4~BqKmix`R!f)2)ezPuD zX8nvrK7E@lnk$Xo;ePP2$?xz>P{8{2U4DZ&^O3vu^zax;xAuA1>7>P0{xC?6g1y6s zd>=Q&O3h4vQ#I)m{wFraG1>O&s>i#`yyg;@LyZD~hxUcZx7LGCIGb^suw?{?(`xJ3 z2p;SN(YF2);k?%3Q9Ki6fXp6^1I>~jMk9FF%jwZvr^@#(bw_IMolYpZxE&%z)qI9! zMUCMYC+3)O{11@c@HkF!Zg5qVcF9QAYmVbt$ZxX7fe$vx(Q(`mJj>&`9&NT7j)!N` zPZr0cGjNL?#Go@Nsl#>U<%!%ZV5ck6yd3A07wd8tIWm#!H1)aQ)`}@(owLZ6ViwLJ z%P(8{UAr7)$cC@x=8Fzo6f~Ly!v9hFPXh96m2s1}7f9~VByJqG-Gv$!7f{KJ#7M1a z?2w9+*{r@5o0w}*K(cw-q0hj@xg`i`{T*H5!H3$Df~RD>k(7zRvYWc(#7`BEnKFfp{nnUi2z@xadj=@& zfYojWEE70Az)yEg{B+lP7W$^k>$5loh%#mlA`|r7g=g9~0myX{68Y_*TvrU4@ly%B z`)49I#|=W0*@)C2V z0oMUNH(kIDE1kAidqmHg|7Z$j_yTT5XJpX=UN0Y~a)lr=KUWMfCm>Q1&p@&yNlb+f zB1=;_HZ&<9I?zshU`=GV#y3`RDvzYF#avGV-&J10KTyym zo12gq?3RL+IEw;n=(pTVLr#&phTnG5WlPTj`_U=u+x1*cN0P8>1AwVWT5aSOVOLzn z)kg~e&U1rQdNE-m-=z{6y9r)cB8xV05>BSoPn=w#)a68dWUhhc{=wJe{7>9oyY4b> zSPeHTQ2ay2Zs8a(dHNP)47XgtGO#s}>6R6m&0QInSg&uzL2Q@Lw{jo2dj3|R{7r>~ z@l`G=K!-u9sZ0kq^ymF}ep`aKvy`*(+zv(Lq-SN#c5YsFlM8nhnzEqN$d%IVyiWy! zraTzt$lbwvYTx+_s~o|R>Y|&ByRK;aq8#YN#*-b5KV6Zss|Podb3X%c?%L!jeRp!7 z^7kGIgypcoeaYU*Z9rcSc5+#joQ3QH8_$!?yEsAy??N?UKVL;&uxdBALvwXE??E}c zVGj>Nty6t3_ds>^{$4&>PGvKsvKiDw!Aqqna%uT=A2$zJ5D;l@$Yno_-rfhKPnFI4 zxgn^0KzfxI7e2953n)Ouv^p=OG)xR;t1>LagHLW-u)yumYOHIzW;d}n$X|46HT=G z{Kf@@T%+k}@Yfv4IgOdcAepT zKI@Vi*N`@3o#p3wUVscd$I%jXj$7kj#DAXSzD`P$^%qbDq{-C_xSU*Qr4{ijIx>$N nS9li~Ve?gP6tq{j|H!}}CvxK|PISKYWHF)-Oy0GG^#=a~KJ8k^ delta 7534 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 delta 2481 zcma)7Ygm<47C!IV-{E#ZKNXcg;Nz$$Au8&K2B-%@^M0wxO3lh=YCO(7qY_ikWFvVk zOolwdl1fFinamt<4!WIC^Ez62NgJoeD>|tt8j>c7BC|de>)*_ebJk_=Z|}9&df&C) zx*S|{DY&eoQ{7T^{&;5={J#lsVseQQ{3IM6xO%*=ma7$Nr7BUY)Jb(pomOX5r8=vg z9RJkQ&rFy&>DlL=e_{BCYONaiky@udR_oOURi-wo-D;28tNx+(sr~9d>RWYQT~t-- zk_tId99-xBGB7U+#l8t0FQeE$xKjth-~3bJR}=F6feAGR)hjNpM2xRp_ujUCwG!=p zFLd9Nkf$tog1qJi5^3I-5ni*&-{HQrMEXbfI6M}&{jqN>dK%yP*B#F>f*O?Nu#|b% zSAAxh&v#}-s5qQv@&o5)tX!J*E*v&Zs|_(nCC>jUYm%?z-=9z1gF7P zZ#Z?EWH4}-*Y!dU?&!GQ$PU0xF6@t0*u^Q2;BgU)9>H#0=FzFB#vD#f!=%o0mD}h_ zK^B>FlpT=ho^Kr9zTyh740E11&G~v;8bW}DygwatsFC!D0Vs3g8aHGj6$^C#M=^%@ zP9~D^Jy#5dg}3{cRsc;eMbl5cHII)=LPRFn0 zg_ovd2vESuGw~YU(d%a-9MF1KHqt|}q$n3|`0T}pI4u{0uvEXEi~In*!!t6dJ?||< zr0A7}7>Om^y$D_9sFDgq>cb0hG!#X;bSYf;pW9is56~NmFpjWHQ!#!YMeiEE8FON} zxEyQn9uL@r8F*ipZ9=BA`8bzcBzk-W#>nljR5af{5e?i>fvqUkpKQe!cwn-ar?j;L zy$H+o>g*h1b0SXl1E5bBu=ke+32=Sdk@!^qYbyb zg?P#3*($sg(+`+UgJiMkfs!c8VUBXx=QQoFA&Sz+Zs7>vExfhf?=+<|f5 zw@j9PY(OsoYvNthpopUyaaxF6-zenp7e>)Ulo!5AzYWG^*LqS?y>q@K^1Mpmaj19vUehV3RJ0qMg9s`F1qL8lNavXO4-HI2T7tO}6ru zXv)C$rbVugrV#EFL+!AGQ)7suKL4qZQ4WEY@X3+YgsXFWHHOCHGdtW=&W)wmm@ky; zAsekQSJ)*ryygMBEHau7D%T`Fl1B0LrQ*@&q}v=R5`fU5`_^H8)}nzp$}24Dg|GOC zMZdtmI53XZxQgvb1y3KZ`4w-Dlb8IHUv*2!$8?385(r=O`A(D+@Qspj_HT~pOgNxWR*x=y_b%rAhRjbblVulU{6w#nzrpu=u(s4Z{-cPL|JX!t|lV`ib83 zff!nhJA^#0OQF|f$YuAVNuWlhkM^gVKrVllDtLH2wuy`QQMW8Xz;yY0l@gf%NOpg+`=IDTg#1GoQ-_QhTAu{(+PXEaLh> zr0?-9&1=4GcaI&-bUPqQ-p@G0Ei>euO;agZ#Oe$hB2cf+AP@36DU<$iPek+HOxi5H zV%DQnFRoXzXe#pc*(^E-6zV-gs4c+y_+#|b&}takl%|MbcA9v~Fv^s9(qp7_hm!4x tvial*M3de1nz1FY0LpP + ) endif() diff --git a/contracts/test_contracts/reenter_deposit/reenter_deposit.cpp b/contracts/test_contracts/reenter_deposit/reenter_deposit.cpp index 4a0cad5722..dc0afc9db2 100644 --- a/contracts/test_contracts/reenter_deposit/reenter_deposit.cpp +++ b/contracts/test_contracts/reenter_deposit/reenter_deposit.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -25,7 +26,7 @@ class [[sysio::contract]] reenter_deposit : public sysio::contract { using contract::contract; /// On the deposit's outgoing WIRE transfer, re-enter `opreg::deposit` with a - /// `+1` credit. Guards: only react to OUR OWN outgoing transfer + /// `+1` WIRE credit. Guards: only react to OUR OWN outgoing transfer /// (`from == get_self()`), and only when the moved quantity exceeds the `+1` /// re-deposit, so the re-entrant deposit's own transfer cannot recurse. [[sysio::on_notify("sysio.token::transfer")]] @@ -35,7 +36,7 @@ class [[sysio::contract]] reenter_deposit : public sysio::contract { sysio::action( sysio::permission_level{get_self(), sysio::name("active")}, sysio::name("sysio.opreg"), sysio::name("deposit"), - std::make_tuple(get_self(), static_cast(1)) + std::make_tuple(get_self(), sysio::opp::wire::token_code, static_cast(1)) ).send(); } }; diff --git a/contracts/test_contracts/reenter_deposit/reenter_deposit.wasm b/contracts/test_contracts/reenter_deposit/reenter_deposit.wasm index a8d27f48b70e3bced3ff2e595ca5873ea0dc8933..694d68884d73b85098a216aac2a7fbe5e4ce1c94 100755 GIT binary patch delta 277 zcmbQLy;*z0P9_cYiMy32-VI~b;rgwrzS)BD4+rC;$);Sc+yV-$jt3aBfTY0WRxVX8 z4j{_{#O0X0g-c0`0mw1{as?z6*%erw7|oeAfK)?6!-^A=#Lbx%6j&7)G?^L9nI$HF z;|gSynC!v5m{D``8}40<{*%3VrCAvqB?ROr*YnD;GAb}RvP}NYQ_HA4xs+F*QDyT| z-d-koc?JbW1qJ~nDG=qzkp*K3fLM;qlZym;CL0MjPW~q#&8R+EN-#i7Nr4fp4oCqN zsRAjPECIF24T74C8k1KEN^>YFFoP9LJ}YR)sIi$x=m;aD*5r%APK?@{MMN4I0n$}K AZ~y=R delta 273 zcmdn2Jym%tnY8jO#7xU^fDsNuQ+sich zwt(a00D*7(w=dD1j^i6(A*(C7?Qao}i|Rx)hUx0;2{KgAy}{ zVo_pr<7HrWP+(AC2C8QUYn%L9(2hx6YBD2_+-6UqrHqW4liv$FF=}l#5@}=v0F_ld AZvX%Q diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index a824ee2575..8184971450 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -7035,7 +7035,8 @@ BOOST_FIXTURE_TEST_CASE( deposit_rejects_a_bootstrapped_operator, producer_score auto names = setup_ranked_producers(1); BOOST_REQUIRE_EQUAL( wasm_assert_msg("bootstrapped operators cannot deposit collateral"), - push_opreg_action(names[0], "deposit"_n, mvo()("account", names[0])("amount", uint64_t{1'000})) ); + push_opreg_action(names[0], "deposit"_n, mvo()("account", names[0])("token_code", "WIRE") + ("amount", uint64_t{1'000})) ); } FC_LOG_AND_RETHROW() // --------------------------------------------------------------------------- diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index 21d35bbc2a..45a2411623 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -6323,7 +6323,8 @@ BOOST_FIXTURE_TEST_CASE(wire_direct_deposit_adds_exact_units_to_uwchalbond, "transfer"_n, mvo()("from", "sysio")("to", UWRIT_OP.to_string()) ("quantity", direct_deposit)("memo", "fund native collateral"))); BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, UWRIT_OP, "deposit"_n, mvo() - ("account", UWRIT_OP.to_string())("amount", static_cast(direct_deposit.get_amount())))); + ("account", UWRIT_OP.to_string())("token_code", codename_mvo("WIRE")) + ("amount", static_cast(direct_deposit.get_amount())))); const uint64_t after = uwchalbond(ATT_ID, UWRIT_OP); BOOST_REQUIRE_EQUAL(before + static_cast(direct_deposit.get_amount()), after); diff --git a/contracts/tests/sysio.opreg_tests.cpp b/contracts/tests/sysio.opreg_tests.cpp index 401749a03f..be3f0c6921 100644 --- a/contracts/tests/sysio.opreg_tests.cpp +++ b/contracts/tests/sysio.opreg_tests.cpp @@ -5,9 +5,12 @@ #include #include +#include #include #include "contracts.hpp" +#include "contract_test_support.hpp" +#include "shadow_yield_reference.hpp" #include using namespace sysio::testing; @@ -17,6 +20,7 @@ using namespace fc; using namespace sysio::opp::types; using mvo = fc::mutable_variant_object; +using sysio_system::test_support::codename_mvo; namespace { @@ -126,6 +130,36 @@ constexpr std::string_view kWireFundingQuantity = "10.000000000 WIRE"; /// Maximum WIRE quantity representable by an Antelope asset. constexpr std::string_view kWireMaximumSupply = "4611686018.427387903 WIRE"; +/// Liq token whose `sysio.liq` shadow symbol backs depot-native shadow collateral. The +/// collateral row's `token_code` is this registry code, not the symbol code. +constexpr std::string_view kLiqEthCodename = "LIQETH"; + +/// Shadow symbol `sysio.liq` mints for kLiqEthCodename, at the token's depot precision. +const symbol kLiqEthSymbol{9, "LIQETH"}; + +/// Token code with no `sysio.liq` shadow symbol and no WIRE identity. +constexpr std::string_view kUnknownTokenCodename = "NOPE"; + +/// External chain id the shadow fixture registers its ETH outpost under. +constexpr uint32_t kEthExternalChainId = 1; + +/// Byte width of an EVM contract address. +constexpr size_t kEvmAddressBytes = 20; + +/// Filler byte of the placeholder LIQETH contract address on the ETH outpost. +constexpr char kPlaceholderAddressByte = 0x5a; + +/// Shadow minted to each shadow-collateral operator, in atomic LIQETH units. +constexpr uint64_t kShadowFunding = 10 * kWireUnit; + +/// Error raised by the depot-native token resolver for a token it cannot custody. +constexpr std::string_view kUnsupportedTokenError = + "assertion failure with message: unsupported depot-native collateral token"; + +/// Error raised by `claimremit` when no claim row exists for the requested token. +constexpr std::string_view kNoClaimableRemitError = + "assertion failure with message: no claimable remit for this account"; + /// Punishment reason used by terminal-status eligibility regressions. constexpr std::string_view kTestSlashReason = "test slash"; @@ -139,9 +173,11 @@ constexpr auto cancel = "cancelwtdw"_n; constexpr auto flush = "flushwtdw"_n; } // namespace withdrawal_action -/// Contract action identifiers used by direct collateral deposit helpers. +/// Contract action identifiers used by direct collateral deposit and claim helpers. namespace collateral_action { -constexpr auto deposit = "deposit"_n; +constexpr auto deposit = "deposit"_n; +constexpr auto claimremit = "claimremit"_n; +constexpr auto available = "available"_n; } // namespace collateral_action /// Contract field identifiers used by withdrawal lifecycle helpers. @@ -150,8 +186,20 @@ constexpr char account[] = "account"; constexpr char amount[] = "amount"; constexpr char request_id[] = "request_id"; constexpr char current_epoch[] = "current_epoch"; +constexpr char chain_code[] = "chain_code"; +constexpr char token_code[] = "token_code"; } // namespace withdrawal_field +/// Registry and shadow-token actions the shadow-collateral fixture drives. +namespace shadow_action { +constexpr auto regchain = "regchain"_n; +constexpr auto regtoken = "regtoken"_n; +constexpr auto regctok = "regctok"_n; +constexpr auto create = "create"_n; +constexpr auto mint = "recredit"_n; +constexpr auto queueout = "queueout"_n; +} // namespace shadow_action + /// Result field identifiers inspected by focused eligibility tests. namespace eligibility_field { constexpr char status[] = "status"; @@ -189,6 +237,77 @@ constexpr uint32_t kDellogPrunePerWrite = 4; /// Mirrors opreg's MAX_DELLOG_PRUNE_PER_CRANK. constexpr uint32_t kDellogPrunePerCrank = 64; +/// Shadow-yield forwarding: actions opreg and sysio.liq expose for it. +namespace yield_action { +constexpr auto sweepyield = "sweepyield"_n; +constexpr auto claimyield = "claimyield"_n; +constexpr auto addyield = "addyield"_n; +} // namespace yield_action + +/// First shadow-yield bonder: holds a quarter of the bonded shadow. +constexpr auto kYieldBonderA = "uwrit.alice"_n; + +/// Second shadow-yield bonder: holds three quarters of the bonded shadow. +constexpr auto kYieldBonderB = "uwrit.bob"_n; + +/// Shadow holder that never bonds, so the registry holds only part of the supply and the +/// registry's share of a distribution is not a round number. +constexpr auto kYieldBystander = "batchop.b"_n; + +/// WIRE holder that funds `sysio.liq::addyield` distributions. +constexpr auto kYieldDonor = "batchop.c"_n; + +/// Account that cranks the permissionless `sweepyield`: neither a bonder nor the registry. +constexpr auto kYieldCranker = "producer.a"_n; + +/// WIRE issued to kYieldDonor, enough for every distribution a yield case makes. +constexpr std::string_view kYieldDonorFunding = "1000.000000000 WIRE"; + +/// Shadow kYieldBonderA bonds, in atomic LIQETH units. +constexpr uint64_t kYieldBondA = 1 * kWireUnit; + +/// Shadow kYieldBonderB bonds: three times kYieldBondA. +constexpr uint64_t kYieldBondB = 3 * kWireUnit; + +/// Shadow kYieldBystander holds unbonded: an uneven third of a unit. +constexpr uint64_t kYieldBystanderHolding = kWireUnit / 3; + +/// WIRE distributed to the LIQETH holders by one `addyield`, in atomic units. +constexpr int64_t kYieldDistribution = 40 * static_cast(kWireUnit); + +/// Error raised by `sweepyield` when the registry's `sysio.liq` row is owed nothing. +constexpr std::string_view kNoYieldToSweepError = "assertion failure with message: no yield to sweep"; + +/// Error raised by `claimyield` when the operator's row has earned nothing. +constexpr std::string_view kNoYieldOwedError = "assertion failure with message: no yield owed"; + +/// Error raised by `claimyield` when the row is owed yield the registry's pool cannot yet cover. +constexpr std::string_view kYieldNotCoveredError = + "assertion failure with message: owed yield is rounding dust the WIRE received from sysio.liq does not " + "cover; only later slack can cover it"; + +/// Recent-actions reason `depositinle` records when it drops a credit naming the depot chain. +constexpr std::string_view kDepotChainCreditReason = + "depot-chain collateral is bonded through deposit, never credited from an outpost"; + +/// Account with no stake in the yield cases that cranks `claimyield` for someone else. +constexpr auto kYieldKeeper = "uwrit.a"_n; + +/// Operator holding WIRE collateral in the dust cases, so the registry's `sysio.token` balance mixes +/// collateral principal with swept yield. +constexpr auto kYieldPrincipalHolder = "batchop.a"_n; + +/// WIRE collateral kYieldPrincipalHolder bonds in the dust cases. +constexpr uint64_t kYieldPrincipal = 5 * kWireUnit; + +/// Distribute-and-sweep rounds in the dust cases: enough for the registry row's per-settle flooring +/// to fall at least one atomic WIRE behind the operator row's single floor. +constexpr int kDustRounds = 3; + +/// Error raised by `sweepyield` for WIRE, which earns no shadow yield. +constexpr std::string_view kWireEarnsNoYieldError = + "assertion failure with message: WIRE collateral earns no shadow yield"; + } // namespace /// Data model: per-chain identity has moved from `ChainKind` enums to @@ -204,12 +323,18 @@ class sysio_opreg_tester : public tester { static constexpr auto CHALG_ACCOUNT = "sysio.chalg"_n; static constexpr auto MSGCH_ACCOUNT = "sysio.msgch"_n; static constexpr auto TOKEN_ACCOUNT = "sysio.token"_n; + static constexpr auto LIQ_ACCOUNT = "sysio.liq"_n; + /// The shadow ledger's only minter; an account is enough to sign as it here. + static constexpr auto SYND_ACCOUNT = "sysio.synd"_n; + static constexpr auto TOKENS_ACCOUNT = "sysio.tokens"_n; + static constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; sysio_opreg_tester() { produce_blocks(2); create_accounts({ OPREG_ACCOUNT, EPOCH_ACCOUNT, CHALG_ACCOUNT, MSGCH_ACCOUNT, TOKEN_ACCOUNT, + LIQ_ACCOUNT, SYND_ACCOUNT, TOKENS_ACCOUNT, CHAINS_ACCOUNT, "batchop.a"_n, "batchop.b"_n, "batchop.c"_n, "uwrit.a"_n, "producer.a"_n, "uwrit.alice"_n, "uwrit.bob"_n, // for Task 2 deposit/withdraw/cancel tests @@ -419,18 +544,33 @@ class sysio_opreg_tester : public tester { ("amount", amount)); } - /// `withdraw`: operator-authorized WIRE-direct withdrawal request. - action_result withdraw(name account, uint64_t amount) { + /// `withdraw`: operator-authorized request against the depot-native `(WIRE, token_code)` row. + action_result withdraw(name account, std::string_view token_code, uint64_t amount) { return push_opreg_action(account, withdrawal_action::withdraw, mvo() - (withdrawal_field::account, account) - (withdrawal_field::amount, amount)); + (withdrawal_field::account, account) + (withdrawal_field::token_code, token_code) + (withdrawal_field::amount, amount)); } - /// `deposit`: operator-authorized WIRE-direct collateral deposit. - action_result deposit(name account, uint64_t amount) { + /// `deposit`: operator-authorized depot-native collateral deposit of `token_code`. + action_result deposit(name account, std::string_view token_code, uint64_t amount) { return push_opreg_action(account, collateral_action::deposit, mvo() - (withdrawal_field::account, account) - (withdrawal_field::amount, amount)); + (withdrawal_field::account, account) + (withdrawal_field::token_code, token_code) + (withdrawal_field::amount, amount)); + } + + /// Read-only `available(account, chain_code, token_code)`, taken from the action's return value. + /// A block is produced after each read so the same query can be repeated later in a case + /// without being rejected as a duplicate transaction. + uint64_t available(name account, std::string_view chain_code, std::string_view token_code) { + auto trace = base_tester::push_action(OPREG_ACCOUNT, collateral_action::available, OPREG_ACCOUNT, mvo() + (withdrawal_field::account, account) + (withdrawal_field::chain_code, chain_code) + (withdrawal_field::token_code, token_code)); + BOOST_REQUIRE(trace && !trace->action_traces.empty()); + produce_blocks(); + return fc::raw::unpack(trace->action_traces[0].return_value); } /// Cancel an operator-owned queued withdrawal request. @@ -510,16 +650,22 @@ class sysio_opreg_tester : public tester { return log.empty() ? fc::variant() : log.back(); } - /// Claimable WIRE row credited by a WIRE-chain remit. Empty variant when absent. - fc::variant get_remitclaim(name account) { - auto data = get_row_by_account(OPREG_ACCOUNT, OPREG_ACCOUNT, "remitclaims"_n, account); + /// Claimable `token_code` row credited by a WIRE-chain remit. Empty variant when absent. + /// + /// The row key `{account, token_code}` is two big-endian words, which is exactly the + /// `[scope][id]` layout `get_row_by_id` probes first — so the account rides as the scope. + fc::variant get_remitclaim(name account, std::string_view token_code) { + auto data = get_row_by_id(OPREG_ACCOUNT, account, "remitclaims"_n, cn(token_code).value); return data.empty() ? fc::variant() : opreg_abi_ser.binary_to_variant( "remit_claim", data, abi_serializer::create_yield_function(abi_serializer_max_time)); } - action_result claimremit(name account) { - return push_opreg_action(account, "claimremit"_n, mvo()("account", account)); + /// Operator-authorized pull of the `token_code` claim row. + action_result claimremit(name account, std::string_view token_code) { + return push_opreg_action(account, collateral_action::claimremit, mvo() + (withdrawal_field::account, account) + (withdrawal_field::token_code, token_code)); } /// Stand up sysio.token with the opreg WIRE asset and fund `who`, so a @@ -549,8 +695,206 @@ class sysio_opreg_tester : public tester { produce_blocks(); } + // ── Shadow LIQ collateral (sysio.liq) ── + + /// Deploy a privileged registry or token contract and load its ABI serializer. + void deploy_privileged(name account, const std::vector& wasm, const std::vector& abi, + abi_serializer& ser) { + set_code(account, wasm); + set_abi(account, abi.data()); + set_privileged(account); + produce_blocks(); + sysio_system::test_support::load_account_abi(*this, account, ser); + } + + /// Push an ABI-encoded action on a contract other than opreg and produce a block. + action_result push_contract(name code, abi_serializer& ser, name signer, name action_name, + const variant_object& data) { + return sysio_system::test_support::push_contract_action_and_produce_block(*this, code, ser, signer, + action_name, data); + } + + /// Stand up what a shadow-collateral case needs: the ETH outpost in `sysio.chains`, its + /// LIQETH liq token in `sysio.tokens`, and the LIQETH shadow symbol in `sysio.liq` (the + /// same registrations `sysio.liq_tests` seeds). Called per case rather than from the + /// constructor: an active ETH chain row would turn the ETH-bucket slashes and remits of the + /// other cases into real `sysio.msgch` queueouts and change what those cases exercise. + void setup_shadow_liq() { + deploy_privileged(CHAINS_ACCOUNT, contracts::chains_wasm(), contracts::chains_abi(), chains_abi_ser); + deploy_privileged(TOKENS_ACCOUNT, contracts::tokens_wasm(), contracts::tokens_abi(), tokens_abi_ser); + deploy_privileged(LIQ_ACCOUNT, contracts::liq_wasm(), contracts::liq_abi(), liq_abi_ser); + + // Registered inside the epoch-0 bootstrap window, so the rows land ACTIVE. + BOOST_REQUIRE_EQUAL(success(), push_contract(CHAINS_ACCOUNT, chains_abi_ser, CHAINS_ACCOUNT, + shadow_action::regchain, mvo() + ("kind", ChainKind::CHAIN_KIND_EVM)("code", codename_mvo(kEthCodename)) + ("external_chain_id", kEthExternalChainId)("name", std::string(kEthCodename)) + ("description", std::string{})("outpost", sysio_system::test_support::no_outpost_mvo()))); + + const std::vector address(kEvmAddressBytes, kPlaceholderAddressByte); + BOOST_REQUIRE_EQUAL(success(), push_contract(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, + shadow_action::regtoken, mvo() + ("kind", TokenKind::TOKEN_KIND_LIQ)("code", codename_mvo(kLiqEthCodename)) + ("symbol_name", std::string(kLiqEthCodename))("description", std::string{}) + ("precision", kLiqEthSymbol.decimals()) + ("address", mvo()("kind", ChainKind::CHAIN_KIND_EVM)("address", address)))); + BOOST_REQUIRE_EQUAL(success(), push_contract(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, + shadow_action::regctok, mvo() + ("chain_code", codename_mvo(kEthCodename))("token_code", codename_mvo(kLiqEthCodename)) + ("contract_addr", address)("is_native", false))); + + BOOST_REQUIRE_EQUAL(success(), push_contract(LIQ_ACCOUNT, liq_abi_ser, LIQ_ACCOUNT, shadow_action::create, + mvo()("sym", kLiqEthSymbol)("chain_code", codename_mvo(kEthCodename)) + ("token_code", codename_mvo(kLiqEthCodename)))); + } + + /// Seed `holder` with shadow LIQ through the ledger's governance credit action. + void mint_shadow(name holder, uint64_t amount) { + const int64_t before = shadow_balance(holder); + BOOST_REQUIRE_EQUAL(success(), push_contract(LIQ_ACCOUNT, liq_abi_ser, LIQ_ACCOUNT, shadow_action::mint, + mvo()("holder", holder)("quantity", asset(static_cast(amount), kLiqEthSymbol)))); + BOOST_REQUIRE_EQUAL(before + static_cast(amount), shadow_balance(holder)); + } + + /// Read one account's LIQETH shadow balance in atomic units. + int64_t shadow_balance(name account) { + return get_currency_balance(LIQ_ACCOUNT, kLiqEthSymbol, account).get_amount(); + } + + /// Slash through the chalg-authorized surface and report whether the transaction queued any + /// `sysio.msgch::queueout`, i.e. whether a slash attestation left for an outpost. + bool slash_queues_outbound(name account, std::string_view reason) { + auto trace = base_tester::push_action(OPREG_ACCOUNT, "slash"_n, CHALG_ACCOUNT, mvo() + ("account", account) + ("reason", std::string(reason))); + BOOST_REQUIRE(trace); + produce_blocks(); + return std::any_of(trace->action_traces.begin(), trace->action_traces.end(), [](const action_trace& at) { + return at.act.account == MSGCH_ACCOUNT && at.act.name == shadow_action::queueout; + }); + } + + // ── Shadow-yield forwarding ── + + /// Register the two underwriters, fund kYieldDonor with WIRE, and mint kYieldBondA / kYieldBondB + /// LIQETH to the bonders and kYieldBystanderHolding to kYieldBystander -- nothing bonded yet. + void setup_yield_holders() { + BOOST_REQUIRE_EQUAL(success(), setconfig()); + BOOST_REQUIRE_EQUAL(success(), regoperator(kYieldBonderA, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); + BOOST_REQUIRE_EQUAL(success(), regoperator(kYieldBonderB, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); + setup_wire_token_and_fund(kYieldDonor, kYieldDonorFunding); + setup_shadow_liq(); + mint_shadow(kYieldBonderA, kYieldBondA); + mint_shadow(kYieldBonderB, kYieldBondB); + mint_shadow(kYieldBystander, kYieldBystanderHolding); + } + + /// `setup_yield_holders`, then both underwriters bond everything they were minted. + void setup_yield_bonders() { + setup_yield_holders(); + BOOST_REQUIRE_EQUAL(success(), deposit(kYieldBonderA, kLiqEthCodename, kYieldBondA)); + BOOST_REQUIRE_EQUAL(success(), deposit(kYieldBonderB, kLiqEthCodename, kYieldBondB)); + } + + /// `sysio.liq::addyield`: kYieldDonor distributes `amount` WIRE to every LIQETH holder. + action_result addyield(int64_t amount) { + return push_contract(LIQ_ACCOUNT, liq_abi_ser, kYieldDonor, yield_action::addyield, mvo() + ("from", kYieldDonor)("quantity", asset(amount, kWireSymbol))("target", kLiqEthSymbol.to_symbol_code())); + } + + /// Permissionless `sweepyield(token_code)`, cranked by kYieldCranker. + action_result sweepyield(std::string_view token_code) { + return push_opreg_action(kYieldCranker, yield_action::sweepyield, mvo() + (withdrawal_field::token_code, token_code)); + } + + /// Operator-authorized `claimyield(account, token_code)`. + action_result claimyield(name account, std::string_view token_code) { + return push_opreg_action(account, yield_action::claimyield, mvo() + (withdrawal_field::account, account) + (withdrawal_field::token_code, token_code)); + } + + /// `sysio.liq`'s cumulative LIQETH index, 0 before the first distribution. Every bonded unit's + /// checkpoint tracks this index. + fc::uint128_t liq_index() { + const auto data = get_row_by_id(LIQ_ACCOUNT, LIQ_ACCOUNT, "yieldidx"_n, kLiqEthSymbol.to_symbol_code().value); + return data.empty() ? fc::uint128_t{0} + : liq_abi_ser.binary_to_variant("yield_index", data, + abi_serializer::create_yield_function(abi_serializer_max_time))["index"].as_uint128(); + } + + /// `account`'s depot-native `(WIRE, token_code)` balance row; empty variant when absent. + fc::variant depot_native_row(name account, std::string_view token_code) { + const auto op = get_operator(account); // held: the loop below references into it + for (const auto& bal : op["balances"].get_array()) { + if (bal["chain_code"].as().value == cn(kWireCodename).value && + bal["token_code"].as().value == cn(token_code).value) { + return bal; + } + } + return fc::variant(); + } + + /// What `sysio.liq` owes the registry's LIQETH holder row now, by the shadow yield spec. + int64_t registry_liq_owed() { + const auto sym_code = kLiqEthSymbol.to_symbol_code().value; + const auto holder = liq_abi_ser.binary_to_variant("account", + get_row_by_id(LIQ_ACCOUNT, OPREG_ACCOUNT, "accounts"_n, sym_code), + abi_serializer::create_yield_function(abi_serializer_max_time)); + return yield_reference::owed(holder["balance"].as().get_amount(), liq_index(), + holder["index_checkpoint"].as_uint128(), holder["owed_wire"].as_uint64()); + } + + /// Sweep LIQETH and return the WIRE it moved into the registry, asserting that the same + /// amount left `sysio.liq` and that it equals what the spec says the registry was owed. + int64_t sweep_liqeth() { + const int64_t owed = registry_liq_owed(); + const int64_t opreg_before = wire_balance(OPREG_ACCOUNT); + const int64_t liq_before = wire_balance(LIQ_ACCOUNT); + BOOST_REQUIRE_EQUAL(success(), sweepyield(kLiqEthCodename)); + const int64_t swept = wire_balance(OPREG_ACCOUNT) - opreg_before; + BOOST_REQUIRE_EQUAL(owed, swept); + BOOST_REQUIRE_EQUAL(liq_before - swept, wire_balance(LIQ_ACCOUNT)); + return swept; + } + + /// The dust setup: kYieldPrincipalHolder bonds kYieldPrincipal WIRE collateral, A is the only + /// shadow bonder so the registry's `sysio.liq` row holds exactly A's shadow, then kDustRounds + /// rounds of addyield + sweep. Returns the WIRE the registry received from `sysio.liq`, having + /// asserted after every sweep that the registry's WIRE is exactly principal plus receipts. + int64_t setup_yield_dust() { + setup_yield_holders(); + BOOST_REQUIRE_EQUAL(success(), + regoperator(kYieldPrincipalHolder, OPERATOR_TYPE_BATCH, /*is_bootstrapped=*/false)); + base_tester::push_action(TOKEN_ACCOUNT, "transfer"_n, kYieldDonor, mvo() + ("from", kYieldDonor)("to", kYieldPrincipalHolder) + ("quantity", asset(static_cast(kYieldPrincipal), kWireSymbol))("memo", std::string{})); + BOOST_REQUIRE_EQUAL(success(), deposit(kYieldPrincipalHolder, kWireCodename, kYieldPrincipal)); + BOOST_REQUIRE_EQUAL(success(), deposit(kYieldBonderA, kLiqEthCodename, kYieldBondA)); + + int64_t received = 0; + for (int round = 0; round < kDustRounds; ++round) { + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + received += sweep_liqeth(); + BOOST_REQUIRE_EQUAL(static_cast(kYieldPrincipal) + received, wire_balance(OPREG_ACCOUNT)); + } + return received; + } + + /// `claimyield` and return what it credited to the operator's WIRE claim row. + int64_t claim_yield_credit(name account) { + const auto before = get_remitclaim(account, kWireCodename); + const int64_t claimed_before = before.is_null() ? 0 : static_cast(before["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(success(), claimyield(account, kLiqEthCodename)); + return static_cast(get_remitclaim(account, kWireCodename)["balance"].as_uint64()) - claimed_before; + } + abi_serializer opreg_abi_ser; abi_serializer epoch_abi_ser; + abi_serializer chains_abi_ser; + abi_serializer tokens_abi_ser; + abi_serializer liq_abi_ser; }; // ---- Tests ---- @@ -989,7 +1333,7 @@ BOOST_FIXTURE_TEST_CASE(deposit_aggregates_into_existing_balance_row, sysio_opre // SEC-103 (WSA-028 follow-up): operator collateral must never accumulate past // asset::max_amount (2^62-1). A balance above the asset range would abort the -// WIRE-direct remit path's asset(balance, WIRE_SYM); on the never-throw +// depot-native claim payout's asset(balance, symbol); on the never-throw // depositinle (OPP-inbound) path that abort would stall consensus. depositinle // gates the RUNNING SUM: a credit that would push the balance over the cap is // refunded via DEPOSIT_REVERT (the action still succeeds — never throws) and the @@ -1053,7 +1397,7 @@ BOOST_FIXTURE_TEST_CASE(deposit_custodies_and_credits_wire_units, sysio_opreg_te const int64_t operator_before = wire_balance(OPERATOR); const int64_t opreg_before = wire_balance(OPREG_ACCOUNT); - BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, DEPOSIT)); + BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, kWireCodename, DEPOSIT)); BOOST_REQUIRE_EQUAL(operator_before - static_cast(DEPOSIT), wire_balance(OPERATOR)); BOOST_REQUIRE_EQUAL(opreg_before + static_cast(DEPOSIT), wire_balance(OPREG_ACCOUNT)); @@ -1104,7 +1448,7 @@ BOOST_FIXTURE_TEST_CASE(deposit_reentrancy_cannot_exceed_max_collateral, sysio_o // outgoing WIRE transfer notifies the operator, and its handler re-enters // deposit(+1). The +1 would push the row to 2^62 — its in-modify cap check trips // and aborts the whole transaction. No over-credit is possible. - auto r = deposit(OPERATOR, MAX_COLLATERAL); + auto r = deposit(OPERATOR, kWireCodename, MAX_COLLATERAL); BOOST_REQUIRE_MESSAGE(r != success(), "re-entrant over-cap deposit unexpectedly succeeded"); BOOST_REQUIRE_MESSAGE(r.find("deposit would exceed max collateral") != std::string::npos, "unexpected failure reason: " + r); @@ -1225,12 +1569,12 @@ BOOST_FIXTURE_TEST_CASE(withdraw_rejects_when_inline_request_is_outstanding_for_ sysio_opreg_tester) { try { BOOST_REQUIRE_EQUAL(success(), setconfig()); BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.alice"_n, OPERATOR_TYPE_UNDERWRITER, false)); - BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "WIRE", "WIRE", 1000)); + setup_wire_token_and_fund("uwrit.alice"_n, kWireFundingQuantity); + BOOST_REQUIRE_EQUAL(success(), deposit("uwrit.alice"_n, kWireCodename, 1000)); BOOST_REQUIRE_EQUAL(success(), withdrawinle("uwrit.alice"_n, "WIRE", "WIRE", 400)); - BOOST_REQUIRE_EQUAL(success(), withdraw("uwrit.alice"_n, 400)); + BOOST_REQUIRE_EQUAL(success(), withdraw("uwrit.alice"_n, kWireCodename, 400)); auto entry = latest_action_log("uwrit.alice"_n); BOOST_REQUIRE(!entry.is_null()); @@ -1273,12 +1617,12 @@ BOOST_FIXTURE_TEST_CASE(withdraw_rechecks_eligibility_after_enqueue, sysio_opreg /*req_uw_collat=*/{})); BOOST_REQUIRE_EQUAL(success(), regoperator(kEligibilityProducer, OPERATOR_TYPE_PRODUCER, /*is_bootstrapped=*/false)); - BOOST_REQUIRE_EQUAL(success(), - depositinle(kEligibilityProducer, kWireCodename, kWireCodename, kTestMinBond)); + setup_wire_token_and_fund(kEligibilityProducer, kWireFundingQuantity); + BOOST_REQUIRE_EQUAL(success(), deposit(kEligibilityProducer, kWireCodename, kTestMinBond)); BOOST_REQUIRE(OperatorStatus::OPERATOR_STATUS_ACTIVE == get_operator(kEligibilityProducer)[eligibility_field::status].as()); - BOOST_REQUIRE_EQUAL(success(), withdraw(kEligibilityProducer, kTestMinBond)); + BOOST_REQUIRE_EQUAL(success(), withdraw(kEligibilityProducer, kWireCodename, kTestMinBond)); auto op = get_operator(kEligibilityProducer); BOOST_REQUIRE(OperatorStatus::OPERATOR_STATUS_UNKNOWN == op[eligibility_field::status].as()); @@ -1460,7 +1804,7 @@ BOOST_FIXTURE_TEST_CASE(terminate_survives_operator_blocking_its_own_remit, sysi // Real WIRE collateral on the WIRE chain, so termination takes the WIRE-direct remit // branch rather than emitting a WITHDRAW_REMIT attestation to an outpost. setup_wire_token_and_fund(OPERATOR, kWireFundingQuantity); - BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, DEPOSIT)); + BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, kWireCodename, DEPOSIT)); // Arm the operator AFTER the deposit: `deposit`'s escrow leg is an OUTGOING transfer, which the // blocking contract deliberately ignores, but deploying afterwards keeps the setup honest about @@ -1477,18 +1821,18 @@ BOOST_FIXTURE_TEST_CASE(terminate_survives_operator_blocking_its_own_remit, sysi BOOST_REQUIRE_EQUAL(0u, balances[0]["balance"].as_uint64()); // debited, not stranded // The collateral is owed, in full, in the claim ledger. - auto claim = get_remitclaim(OPERATOR); + auto claim = get_remitclaim(OPERATOR, kWireCodename); BOOST_REQUIRE(!claim.is_null()); BOOST_REQUIRE_EQUAL(DEPOSIT, claim["balance"].as_uint64()); // ...and the blocker's reach ends there: its own claim is the one thing it breaks. - auto r = claimremit(OPERATOR); + auto r = claimremit(OPERATOR, kWireCodename); BOOST_REQUIRE_MESSAGE(r != success(), "blocking operator unexpectedly claimed its own remit"); BOOST_REQUIRE_MESSAGE(r.find("block_transfer: rejecting incoming transfer") != std::string::npos, "unexpected failure reason: " + r); // The failed claim rolled back whole -- the balance is still owed, not burned. - BOOST_REQUIRE_EQUAL(DEPOSIT, get_remitclaim(OPERATOR)["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(DEPOSIT, get_remitclaim(OPERATOR, kWireCodename)["balance"].as_uint64()); } FC_LOG_AND_RETHROW() } // Positive control: a cooperative operator pulls its terminated collateral normally, and @@ -1502,14 +1846,14 @@ BOOST_FIXTURE_TEST_CASE(claimremit_pays_terminated_operator_and_clears_row, sysi BOOST_REQUIRE_EQUAL(success(), regoperator(OPERATOR, OPERATOR_TYPE_BATCH, /*is_bootstrapped=*/false)); setup_wire_token_and_fund(OPERATOR, kWireFundingQuantity); - BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, DEPOSIT)); + BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, kWireCodename, DEPOSIT)); BOOST_REQUIRE_EQUAL(success(), terminate(OPERATOR, "rolling-24h miss")); - BOOST_REQUIRE_EQUAL(DEPOSIT, get_remitclaim(OPERATOR)["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(DEPOSIT, get_remitclaim(OPERATOR, kWireCodename)["balance"].as_uint64()); const int64_t balance_before_claim = wire_balance(OPERATOR); - BOOST_REQUIRE_EQUAL(success(), claimremit(OPERATOR)); + BOOST_REQUIRE_EQUAL(success(), claimremit(OPERATOR, kWireCodename)); BOOST_REQUIRE_EQUAL(balance_before_claim + static_cast(DEPOSIT), wire_balance(OPERATOR)); - BOOST_REQUIRE(get_remitclaim(OPERATOR).is_null()); // row erased by the payout + BOOST_REQUIRE(get_remitclaim(OPERATOR, kWireCodename).is_null()); // row erased by the payout // Double-claim finds nothing -- the erase happens before the transfer, closing the re-entrancy // window a notify handler would otherwise use to drain the row twice. Advance a block first: @@ -1518,7 +1862,262 @@ BOOST_FIXTURE_TEST_CASE(claimremit_pays_terminated_operator_and_clears_row, sysi produce_blocks(); BOOST_REQUIRE_EQUAL( error("assertion failure with message: no claimable remit for this account"), - claimremit(OPERATOR)); + claimremit(OPERATOR, kWireCodename)); +} FC_LOG_AND_RETHROW() } + +// ── depot-native shadow LIQ collateral (sysio.liq) ── + +/// A shadow LIQ deposit custodies the shadow in `sysio.liq` and credits the depot-native +/// `(WIRE, LIQETH)` row: the chain is the depot, the token is the liq token's registry code. +BOOST_FIXTURE_TEST_CASE(deposit_shadow_liq_credits_wire_chain_row, sysio_opreg_tester) { try { + const auto OPERATOR = "uwrit.alice"_n; + constexpr uint64_t DEPOSIT = 2 * kWireUnit; + + BOOST_REQUIRE_EQUAL(success(), setconfig()); + BOOST_REQUIRE_EQUAL(success(), regoperator(OPERATOR, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); + setup_wire_token_and_fund(OPERATOR, kWireFundingQuantity); + setup_shadow_liq(); + mint_shadow(OPERATOR, kShadowFunding); + + const int64_t operator_shadow_before = shadow_balance(OPERATOR); + const int64_t opreg_shadow_before = shadow_balance(OPREG_ACCOUNT); + const int64_t operator_wire_before = wire_balance(OPERATOR); + const int64_t opreg_wire_before = wire_balance(OPREG_ACCOUNT); + + BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, kLiqEthCodename, DEPOSIT)); + + BOOST_REQUIRE_EQUAL(operator_shadow_before - static_cast(DEPOSIT), shadow_balance(OPERATOR)); + BOOST_REQUIRE_EQUAL(opreg_shadow_before + static_cast(DEPOSIT), shadow_balance(OPREG_ACCOUNT)); + BOOST_REQUIRE_EQUAL(operator_wire_before, wire_balance(OPERATOR)); + BOOST_REQUIRE_EQUAL(opreg_wire_before, wire_balance(OPREG_ACCOUNT)); + + auto balances = get_operator(OPERATOR)["balances"].get_array(); + BOOST_REQUIRE_EQUAL(1u, balances.size()); + BOOST_REQUIRE_EQUAL(cn(kWireCodename).value, balances[0]["chain_code"].as().value); + BOOST_REQUIRE_EQUAL(cn(kLiqEthCodename).value, balances[0]["token_code"].as().value); + BOOST_REQUIRE_EQUAL(DEPOSIT, balances[0]["balance"].as_uint64()); + + // The recent-actions entry names the token actually deposited, not the WIRE constant. + auto entry = latest_action_log(OPERATOR); + BOOST_REQUIRE(!entry.is_null()); + BOOST_REQUIRE_EQUAL(true, entry[eligibility_field::success].as_bool()); + BOOST_REQUIRE_EQUAL(cn(kLiqEthCodename).value, entry["action"]["amount"]["token_code"].as_uint64()); +} FC_LOG_AND_RETHROW() } + +/// A token with no `sysio.liq` shadow symbol that is not WIRE cannot be custodied: the +/// deposit reverts with the resolver's message and moves nothing. +BOOST_FIXTURE_TEST_CASE(deposit_unknown_token_is_rejected, sysio_opreg_tester) { try { + const auto OPERATOR = "uwrit.alice"_n; + + BOOST_REQUIRE_EQUAL(success(), setconfig()); + BOOST_REQUIRE_EQUAL(success(), regoperator(OPERATOR, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); + setup_shadow_liq(); + mint_shadow(OPERATOR, kShadowFunding); + + const int64_t operator_shadow_before = shadow_balance(OPERATOR); + BOOST_REQUIRE_EQUAL(error(std::string(kUnsupportedTokenError)), + deposit(OPERATOR, kUnknownTokenCodename, kWireUnit)); + + BOOST_REQUIRE_EQUAL(0u, get_operator(OPERATOR)["balances"].get_array().size()); + BOOST_REQUIRE_EQUAL(operator_shadow_before, shadow_balance(OPERATOR)); + BOOST_REQUIRE_EQUAL(0, shadow_balance(OPREG_ACCOUNT)); +} FC_LOG_AND_RETHROW() } + +/// A withdraw of a token opreg cannot custody reverts with the resolver's message instead of +/// logging: it queues no request and leaves no `recent_actions` entry, so a request that could +/// never be paid out never exists. +BOOST_FIXTURE_TEST_CASE(withdraw_unknown_token_is_rejected, sysio_opreg_tester) { try { + const auto OPERATOR = "uwrit.alice"_n; + + BOOST_REQUIRE_EQUAL(success(), setconfig()); + BOOST_REQUIRE_EQUAL(success(), regoperator(OPERATOR, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); + setup_shadow_liq(); + const auto actions_before = get_operator(OPERATOR)["recent_actions"].get_array().size(); + + BOOST_REQUIRE_EQUAL(error(std::string(kUnsupportedTokenError)), + withdraw(OPERATOR, kUnknownTokenCodename, kWireUnit)); + + BOOST_REQUIRE(get_wtdw(kFirstWithdrawalRequestId).is_null()); + BOOST_REQUIRE_EQUAL(actions_before, get_operator(OPERATOR)["recent_actions"].get_array().size()); +} FC_LOG_AND_RETHROW() } + +/// WIRE and shadow deposits by one operator land in two rows, and `available()` rolls each up +/// on its own: a pending shadow withdraw reserves shadow only. +BOOST_FIXTURE_TEST_CASE(deposit_keeps_wire_and_shadow_rows_separate, sysio_opreg_tester) { try { + const auto OPERATOR = "uwrit.alice"_n; + constexpr uint64_t WIRE_DEPOSIT = 3 * kWireUnit; + constexpr uint64_t SHADOW_DEPOSIT = 2 * kWireUnit; + constexpr uint64_t SHADOW_WITHDRAW = kWireUnit; + + BOOST_REQUIRE_EQUAL(success(), setconfig()); + BOOST_REQUIRE_EQUAL(success(), regoperator(OPERATOR, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); + setup_wire_token_and_fund(OPERATOR, kWireFundingQuantity); + setup_shadow_liq(); + mint_shadow(OPERATOR, kShadowFunding); + + BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, kWireCodename, WIRE_DEPOSIT)); + BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, kLiqEthCodename, SHADOW_DEPOSIT)); + + auto balances = get_operator(OPERATOR)["balances"].get_array(); + BOOST_REQUIRE_EQUAL(2u, balances.size()); + BOOST_REQUIRE_EQUAL(WIRE_DEPOSIT, available(OPERATOR, kWireCodename, kWireCodename)); + BOOST_REQUIRE_EQUAL(SHADOW_DEPOSIT, available(OPERATOR, kWireCodename, kLiqEthCodename)); + + BOOST_REQUIRE_EQUAL(success(), withdraw(OPERATOR, kLiqEthCodename, SHADOW_WITHDRAW)); + BOOST_REQUIRE_EQUAL(true, latest_action_log(OPERATOR)[eligibility_field::success].as_bool()); + BOOST_REQUIRE_EQUAL(WIRE_DEPOSIT, available(OPERATOR, kWireCodename, kWireCodename)); + BOOST_REQUIRE_EQUAL(SHADOW_DEPOSIT - SHADOW_WITHDRAW, available(OPERATOR, kWireCodename, kLiqEthCodename)); +} FC_LOG_AND_RETHROW() } + +/// A `(WIRE, LIQETH)` role minimum is met by the shadow row alone: a WIRE deposit of the same +/// amount leaves the batch operator ineligible, and the shadow deposit activates it. +/// +/// The fixture registers through the privileged path, like every eligibility case here, so the +/// self-registration AuthX link check never runs; eligibility itself reads only balances. +BOOST_FIXTURE_TEST_CASE(shadow_row_satisfies_role_minimum, sysio_opreg_tester) { try { + constexpr uint64_t MIN_BOND = kWireUnit; + + BOOST_REQUIRE_EQUAL(success(), setconfig( + /*max_prod=*/kTestMaxProducers, + /*max_batch=*/kTestMaxBatchOperators, + /*max_uw=*/kTestMaxUnderwriters, + /*prune_delay=*/kDefaultPruneDelayMs, + /*max_consec_misses=*/kDefaultMaxConsecutiveMisses, + /*max_pct_misses_24h=*/kMaxAcceptedPctMisses24h, + /*terminate_window_ms=*/kTerminateWindowMs, + /*req_prod_collat=*/{}, + /*req_batchop_collat=*/{ + make_chain_min_bond(kWireCodename, kLiqEthCodename, MIN_BOND), + }, + /*req_uw_collat=*/{})); + BOOST_REQUIRE_EQUAL(success(), + regoperator(kEligibilityBatchOperator, OPERATOR_TYPE_BATCH, /*is_bootstrapped=*/false)); + setup_wire_token_and_fund(kEligibilityBatchOperator, kWireFundingQuantity); + setup_shadow_liq(); + mint_shadow(kEligibilityBatchOperator, kShadowFunding); + + BOOST_REQUIRE_EQUAL(success(), deposit(kEligibilityBatchOperator, kWireCodename, MIN_BOND)); + BOOST_REQUIRE(OperatorStatus::OPERATOR_STATUS_UNKNOWN == + get_operator(kEligibilityBatchOperator)[eligibility_field::status].as()); + + BOOST_REQUIRE_EQUAL(success(), deposit(kEligibilityBatchOperator, kLiqEthCodename, MIN_BOND)); + BOOST_REQUIRE(OperatorStatus::OPERATOR_STATUS_ACTIVE == + get_operator(kEligibilityBatchOperator)[eligibility_field::status].as()); +} FC_LOG_AND_RETHROW() } + +/// A shadow withdraw matures into a `(operator, LIQETH)` claim, and `claimremit` pays it back +/// through `sysio.liq`; the WIRE claim key stays empty. +BOOST_FIXTURE_TEST_CASE(withdraw_shadow_flush_credits_token_claim_and_claimremit_pays_liq, + sysio_opreg_tester) { try { + const auto OPERATOR = "uwrit.alice"_n; + constexpr uint64_t DEPOSIT = 2 * kWireUnit; + + BOOST_REQUIRE_EQUAL(success(), setconfig()); + BOOST_REQUIRE_EQUAL(success(), regoperator(OPERATOR, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); + setup_shadow_liq(); + mint_shadow(OPERATOR, kShadowFunding); + BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, kLiqEthCodename, DEPOSIT)); + + BOOST_REQUIRE_EQUAL(success(), withdraw(OPERATOR, kLiqEthCodename, DEPOSIT)); + auto request = get_wtdw(kFirstWithdrawalRequestId); + BOOST_REQUIRE(!request.is_null()); + BOOST_REQUIRE_EQUAL(cn(kWireCodename).value, request["chain_code"].as().value); + BOOST_REQUIRE_EQUAL(cn(kLiqEthCodename).value, request["token_code"].as().value); + + BOOST_REQUIRE_EQUAL(success(), flushwtdw(kFlushAllMaturedEpoch)); + BOOST_REQUIRE(get_wtdw(kFirstWithdrawalRequestId).is_null()); + + auto claim = get_remitclaim(OPERATOR, kLiqEthCodename); + BOOST_REQUIRE(!claim.is_null()); + BOOST_REQUIRE_EQUAL(cn(kLiqEthCodename).value, claim["token_code"].as().value); + BOOST_REQUIRE_EQUAL(DEPOSIT, claim["balance"].as_uint64()); + BOOST_REQUIRE(get_remitclaim(OPERATOR, kWireCodename).is_null()); + + const int64_t operator_shadow_before = shadow_balance(OPERATOR); + const int64_t opreg_shadow_before = shadow_balance(OPREG_ACCOUNT); + BOOST_REQUIRE_EQUAL(success(), claimremit(OPERATOR, kLiqEthCodename)); + BOOST_REQUIRE_EQUAL(operator_shadow_before + static_cast(DEPOSIT), shadow_balance(OPERATOR)); + BOOST_REQUIRE_EQUAL(opreg_shadow_before - static_cast(DEPOSIT), shadow_balance(OPREG_ACCOUNT)); + BOOST_REQUIRE(get_remitclaim(OPERATOR, kLiqEthCodename).is_null()); + + BOOST_REQUIRE_EQUAL(error(std::string(kNoClaimableRemitError)), claimremit(OPERATOR, kWireCodename)); +} FC_LOG_AND_RETHROW() } + +/// WIRE and shadow claims outstanding at once are independent rows: each `claimremit` pays its +/// own token only and leaves the other claim whole. +BOOST_FIXTURE_TEST_CASE(remit_claims_for_two_tokens_coexist, sysio_opreg_tester) { try { + const auto OPERATOR = "batchop.a"_n; + constexpr uint64_t WIRE_DEPOSIT = 3 * kWireUnit; + constexpr uint64_t SHADOW_DEPOSIT = 2 * kWireUnit; + + BOOST_REQUIRE_EQUAL(success(), setconfig()); + BOOST_REQUIRE_EQUAL(success(), regoperator(OPERATOR, OPERATOR_TYPE_BATCH, /*is_bootstrapped=*/false)); + setup_wire_token_and_fund(OPERATOR, kWireFundingQuantity); + setup_shadow_liq(); + mint_shadow(OPERATOR, kShadowFunding); + BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, kWireCodename, WIRE_DEPOSIT)); + BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, kLiqEthCodename, SHADOW_DEPOSIT)); + + // Termination remits both depot-native rows into their own claim rows. + BOOST_REQUIRE_EQUAL(success(), terminate(OPERATOR, std::string(kTestTerminationReason))); + BOOST_REQUIRE_EQUAL(WIRE_DEPOSIT, get_remitclaim(OPERATOR, kWireCodename)["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(SHADOW_DEPOSIT, get_remitclaim(OPERATOR, kLiqEthCodename)["balance"].as_uint64()); + + const int64_t wire_before = wire_balance(OPERATOR); + const int64_t shadow_before = shadow_balance(OPERATOR); + BOOST_REQUIRE_EQUAL(success(), claimremit(OPERATOR, kWireCodename)); + BOOST_REQUIRE_EQUAL(wire_before + static_cast(WIRE_DEPOSIT), wire_balance(OPERATOR)); + BOOST_REQUIRE_EQUAL(shadow_before, shadow_balance(OPERATOR)); + BOOST_REQUIRE(get_remitclaim(OPERATOR, kWireCodename).is_null()); + BOOST_REQUIRE_EQUAL(SHADOW_DEPOSIT, get_remitclaim(OPERATOR, kLiqEthCodename)["balance"].as_uint64()); + + BOOST_REQUIRE_EQUAL(success(), claimremit(OPERATOR, kLiqEthCodename)); + BOOST_REQUIRE_EQUAL(shadow_before + static_cast(SHADOW_DEPOSIT), shadow_balance(OPERATOR)); + BOOST_REQUIRE_EQUAL(wire_before + static_cast(WIRE_DEPOSIT), wire_balance(OPERATOR)); + BOOST_REQUIRE(get_remitclaim(OPERATOR, kLiqEthCodename).is_null()); +} FC_LOG_AND_RETHROW() } + +/// Slashing a `(WIRE, LIQETH)` row subtracts the balance and sends nothing to an outpost: the +/// shadow is depot-native, so it stays in `sysio.opreg`'s `sysio.liq` account. The control +/// operator's ETH-bucket slash does queue an attestation, so the trace probe is live. +BOOST_FIXTURE_TEST_CASE(slash_shadow_row_emits_no_attestation, sysio_opreg_tester) { try { + const auto OPERATOR = "uwrit.alice"_n; + const auto CONTROL = "uwrit.bob"_n; + constexpr uint64_t DEPOSIT = 2 * kWireUnit; + + BOOST_REQUIRE_EQUAL(success(), setconfig()); + BOOST_REQUIRE_EQUAL(success(), regoperator(OPERATOR, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); + BOOST_REQUIRE_EQUAL(success(), regoperator(CONTROL, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); + setup_shadow_liq(); + mint_shadow(OPERATOR, kShadowFunding); + BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, kLiqEthCodename, DEPOSIT)); + BOOST_REQUIRE_EQUAL(success(), depositinle(CONTROL, kEthCodename, kEthCodename, DEPOSIT)); + + // The row under test is the shadow row, custodied as shadow. + const auto bonded = get_operator(OPERATOR)["balances"].get_array(); + BOOST_REQUIRE_EQUAL(1u, bonded.size()); + BOOST_REQUIRE_EQUAL(cn(kLiqEthCodename).value, bonded[0]["token_code"].as().value); + BOOST_REQUIRE_EQUAL(static_cast(DEPOSIT), shadow_balance(OPREG_ACCOUNT)); + + BOOST_REQUIRE(!slash_queues_outbound(OPERATOR, kTestSlashReason)); + + auto op = get_operator(OPERATOR); + BOOST_REQUIRE(OperatorStatus::OPERATOR_STATUS_SLASHED == op[eligibility_field::status].as()); + auto balances = op["balances"].get_array(); + BOOST_REQUIRE_EQUAL(1u, balances.size()); + BOOST_REQUIRE_EQUAL(0u, balances[0]["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(static_cast(DEPOSIT), shadow_balance(OPREG_ACCOUNT)); + + BOOST_REQUIRE(slash_queues_outbound(CONTROL, kTestSlashReason)); +} FC_LOG_AND_RETHROW() } + +/// `claimremit` resolves its token before touching any claim row, so a token opreg cannot +/// custody is refused with the resolver's message. +BOOST_FIXTURE_TEST_CASE(claimremit_unknown_token_is_rejected, sysio_opreg_tester) { try { + setup_shadow_liq(); + + BOOST_REQUIRE_EQUAL(error(std::string(kUnsupportedTokenError)), + claimremit("uwrit.alice"_n, kUnknownTokenCodename)); } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(terminate_rejects_already_slashed_operator, sysio_opreg_tester) { try { @@ -1952,4 +2551,376 @@ BOOST_FIXTURE_TEST_CASE(flushwtdw_bounds_rows_per_epoch, sysio_opreg_tester) { t BOOST_REQUIRE_EQUAL(0u, count_pending()); } FC_LOG_AND_RETHROW() } +// ── shadow-bond yield forwarding ── +// +// A bonded row's checkpoint tracks sysio.liq's own LIQETH index: a bonded unit earns exactly what a +// unit on the registry's liq holder row earns, from the moment it is bonded until it leaves the row. + +/// A sweep claims exactly what `sysio.liq` owes the registry's holder row into the registry, and +/// leaves the registry owed nothing. +BOOST_FIXTURE_TEST_CASE(sweepyield_claims_the_registry_yield, sysio_opreg_tester) { try { + setup_yield_bonders(); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + const int64_t swept = sweep_liqeth(); + BOOST_REQUIRE_LT(0, swept); + BOOST_REQUIRE_LT(swept, kYieldDistribution); // the bystander's share stayed in sysio.liq + BOOST_REQUIRE_EQUAL(yield_reference::owed(kYieldBondA + kYieldBondB, liq_index(), 0), swept); + BOOST_REQUIRE_EQUAL(0, registry_liq_owed()); +} FC_LOG_AND_RETHROW() } + +/// Each bonder is credited its own units' share of the distribution, even with no sweep first: +/// `claimyield` pulls the registry's owed WIRE itself, so `claimremit(WIRE)` can pay at once. A +/// second claim with nothing new earned is refused. +BOOST_FIXTURE_TEST_CASE(claimyield_credits_pro_rata_and_claimremit_pays_wire, sysio_opreg_tester) { try { + setup_yield_bonders(); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + const fc::uint128_t index = liq_index(); + const int64_t registry_owed = registry_liq_owed(); + const int64_t opreg_before = wire_balance(OPREG_ACCOUNT); + + const int64_t a_credit = claim_yield_credit(kYieldBonderA); + BOOST_REQUIRE_EQUAL(yield_reference::owed(kYieldBondA, index, 0), a_credit); + BOOST_REQUIRE_EQUAL(opreg_before + registry_owed, wire_balance(OPREG_ACCOUNT)); // pulled in by the claim + BOOST_REQUIRE_EQUAL(0, registry_liq_owed()); + + const int64_t b_credit = claim_yield_credit(kYieldBonderB); + BOOST_REQUIRE_EQUAL(yield_reference::owed(kYieldBondB, index, 0), b_credit); + BOOST_REQUIRE_LE(a_credit + b_credit, registry_owed); // attribution never exceeds the registry's claim + + const auto a_row = depot_native_row(kYieldBonderA, kLiqEthCodename); + BOOST_REQUIRE_EQUAL(0u, a_row["shadow_yield"]["owed_wire"].as_uint64()); + BOOST_REQUIRE_EQUAL(index, a_row["shadow_yield"]["index_checkpoint"].as_uint128()); + + const int64_t a_wire_before = wire_balance(kYieldBonderA); + BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kWireCodename)); + BOOST_REQUIRE_EQUAL(a_wire_before + a_credit, wire_balance(kYieldBonderA)); + BOOST_REQUIRE(get_remitclaim(kYieldBonderA, kWireCodename).is_null()); + + produce_blocks(); + BOOST_REQUIRE_EQUAL(error(std::string(kNoYieldOwedError)), claimyield(kYieldBonderA, kLiqEthCodename)); +} FC_LOG_AND_RETHROW() } + +/// A balance change settles the row first: A's claim is its first-distribution share at its old +/// balance plus its second-distribution share at the new one, and B is paid on one balance throughout. +BOOST_FIXTURE_TEST_CASE(yield_settles_before_a_balance_change, sysio_opreg_tester) { try { + setup_yield_bonders(); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + const fc::uint128_t first_index = liq_index(); + + mint_shadow(kYieldBonderA, kYieldBondA); + BOOST_REQUIRE_EQUAL(success(), deposit(kYieldBonderA, kLiqEthCodename, kYieldBondA)); + const auto a_row = depot_native_row(kYieldBonderA, kLiqEthCodename); + BOOST_REQUIRE_EQUAL(first_index, a_row["shadow_yield"]["index_checkpoint"].as_uint128()); + BOOST_REQUIRE_EQUAL(static_cast(yield_reference::owed(kYieldBondA, first_index, 0)), + a_row["shadow_yield"]["owed_wire"].as_uint64()); + + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + const fc::uint128_t second_index = liq_index(); + + BOOST_REQUIRE_EQUAL(yield_reference::owed(kYieldBondA, first_index, 0) + + yield_reference::owed(2 * kYieldBondA, second_index, first_index), + claim_yield_credit(kYieldBonderA)); + BOOST_REQUIRE_EQUAL(yield_reference::owed(kYieldBondB, second_index, 0), claim_yield_credit(kYieldBonderB)); +} FC_LOG_AND_RETHROW() } + +/// A sweep with nothing owed is refused -- before any distribution, and again right after a sweep +/// has drained the registry's row. WIRE, which earns no shadow yield, is refused too. +BOOST_FIXTURE_TEST_CASE(sweepyield_with_nothing_owed_is_rejected, sysio_opreg_tester) { try { + setup_yield_bonders(); + BOOST_REQUIRE_EQUAL(error(std::string(kNoYieldToSweepError)), sweepyield(kLiqEthCodename)); + + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + sweep_liqeth(); + produce_blocks(); + BOOST_REQUIRE_EQUAL(error(std::string(kNoYieldToSweepError)), sweepyield(kLiqEthCodename)); + + BOOST_REQUIRE_EQUAL(error(std::string(kWireEarnsNoYieldError)), sweepyield(kWireCodename)); + BOOST_REQUIRE_EQUAL(error(std::string(kUnsupportedTokenError)), sweepyield(kUnknownTokenCodename)); +} FC_LOG_AND_RETHROW() } + +/// Termination pays the yield a bond earned alongside its principal: WIRE yield in the WIRE claim +/// row, the shadow in the LIQETH claim row. Termination cannot pull from sysio.liq, so the yield is +/// swept first and the pool covers it in full. +BOOST_FIXTURE_TEST_CASE(terminate_credits_earned_yield_with_the_principal, sysio_opreg_tester) { try { + setup_yield_bonders(); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + const int64_t a_yield = yield_reference::owed(kYieldBondA, liq_index(), 0); + sweep_liqeth(); + + BOOST_REQUIRE_EQUAL(success(), terminate(kYieldBonderA, std::string(kTestTerminationReason))); + BOOST_REQUIRE_EQUAL(static_cast(a_yield), + get_remitclaim(kYieldBonderA, kWireCodename)["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(kYieldBondA, get_remitclaim(kYieldBonderA, kLiqEthCodename)["balance"].as_uint64()); + const auto a_row = depot_native_row(kYieldBonderA, kLiqEthCodename); + BOOST_REQUIRE_EQUAL(0u, a_row["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, a_row["shadow_yield"]["owed_wire"].as_uint64()); + + const int64_t wire_before = wire_balance(kYieldBonderA); + const int64_t shadow_before = shadow_balance(kYieldBonderA); + BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kWireCodename)); + BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kLiqEthCodename)); + BOOST_REQUIRE_EQUAL(wire_before + a_yield, wire_balance(kYieldBonderA)); + BOOST_REQUIRE_EQUAL(shadow_before + static_cast(kYieldBondA), shadow_balance(kYieldBonderA)); + BOOST_REQUIRE_EQUAL(error(std::string(kNoYieldOwedError)), claimyield(kYieldBonderA, kLiqEthCodename)); +} FC_LOG_AND_RETHROW() } + +/// A slash settles before it seizes: the yield earned up to the slash stays claimable, and the +/// seized principal earns the operator nothing after it. +BOOST_FIXTURE_TEST_CASE(slash_keeps_yield_earned_before_the_slash, sysio_opreg_tester) { try { + setup_yield_bonders(); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + const fc::uint128_t first_index = liq_index(); + + BOOST_REQUIRE_EQUAL(success(), slash(kYieldBonderA, std::string(kTestSlashReason))); + BOOST_REQUIRE_EQUAL(0u, depot_native_row(kYieldBonderA, kLiqEthCodename)["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(yield_reference::owed(kYieldBondA, first_index, 0), claim_yield_credit(kYieldBonderA)); + + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + produce_blocks(); + BOOST_REQUIRE_EQUAL(error(std::string(kNoYieldOwedError)), claimyield(kYieldBonderA, kLiqEthCodename)); + BOOST_REQUIRE_EQUAL(yield_reference::owed(kYieldBondB, liq_index(), 0), claim_yield_credit(kYieldBonderB)); +} FC_LOG_AND_RETHROW() } + +/// With every bond slashed the registry still holds the seized shadow and still earns on it: the +/// sweep claims that WIRE into the registry, and no operator is owed any of it. +BOOST_FIXTURE_TEST_CASE(sweepyield_with_no_bonded_balance_keeps_wire_in_registry, sysio_opreg_tester) { try { + setup_yield_bonders(); + BOOST_REQUIRE_EQUAL(success(), slash(kYieldBonderA, std::string(kTestSlashReason))); + BOOST_REQUIRE_EQUAL(success(), slash(kYieldBonderB, std::string(kTestSlashReason))); + BOOST_REQUIRE_EQUAL(static_cast(kYieldBondA + kYieldBondB), shadow_balance(OPREG_ACCOUNT)); + + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + BOOST_REQUIRE_LT(0, sweep_liqeth()); + BOOST_REQUIRE_EQUAL(error(std::string(kNoYieldOwedError)), claimyield(kYieldBonderA, kLiqEthCodename)); + BOOST_REQUIRE_EQUAL(error(std::string(kNoYieldOwedError)), claimyield(kYieldBonderB, kLiqEthCodename)); +} FC_LOG_AND_RETHROW() } + +/// WIRE collateral earns no shadow yield: depositing and withdrawing it leaves the row's yield +/// fields at zero, and there is nothing to claim on it. +BOOST_FIXTURE_TEST_CASE(wire_rows_never_earn_shadow_yield, sysio_opreg_tester) { try { + const auto OPERATOR = kYieldBonderA; + constexpr uint64_t DEPOSIT = 3 * kWireUnit; + + BOOST_REQUIRE_EQUAL(success(), setconfig()); + BOOST_REQUIRE_EQUAL(success(), regoperator(OPERATOR, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); + setup_wire_token_and_fund(OPERATOR, kWireFundingQuantity); + BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, kWireCodename, DEPOSIT)); + BOOST_REQUIRE_EQUAL(success(), withdraw(OPERATOR, kWireCodename, kWireUnit)); + BOOST_REQUIRE_EQUAL(success(), flushwtdw(kFlushAllMaturedEpoch)); + + const auto row = depot_native_row(OPERATOR, kWireCodename); + BOOST_REQUIRE_EQUAL(DEPOSIT - kWireUnit, row["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(fc::uint128_t{0}, row["shadow_yield"]["index_checkpoint"].as_uint128()); + BOOST_REQUIRE_EQUAL(0u, row["shadow_yield"]["owed_wire"].as_uint64()); + BOOST_REQUIRE_EQUAL(error(std::string(kNoYieldOwedError)), claimyield(OPERATOR, kWireCodename)); +} FC_LOG_AND_RETHROW() } + +/// A bond made after a distribution starts at the index that distribution left: it cannot capture +/// yield earned before it existed, however the sweep is timed. A, bonded throughout, keeps its +/// whole share -- which is everything the registry earned. +BOOST_FIXTURE_TEST_CASE(late_depositor_cannot_capture_earlier_yield, sysio_opreg_tester) { try { + setup_yield_holders(); + BOOST_REQUIRE_EQUAL(success(), deposit(kYieldBonderA, kLiqEthCodename, kYieldBondA)); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + const fc::uint128_t index = liq_index(); + + BOOST_REQUIRE_EQUAL(success(), deposit(kYieldBonderB, kLiqEthCodename, kYieldBondB)); + BOOST_REQUIRE_EQUAL(index, + depot_native_row(kYieldBonderB, kLiqEthCodename)["shadow_yield"]["index_checkpoint"].as_uint128()); + const int64_t swept = sweep_liqeth(); + + BOOST_REQUIRE_EQUAL(error(std::string(kNoYieldOwedError)), claimyield(kYieldBonderB, kLiqEthCodename)); + const int64_t a_credit = claim_yield_credit(kYieldBonderA); + BOOST_REQUIRE_EQUAL(yield_reference::owed(kYieldBondA, index, 0), a_credit); + BOOST_REQUIRE_EQUAL(swept, a_credit); +} FC_LOG_AND_RETHROW() } + +/// Units withdrawn before any sweep keep the yield they earned while bonded: the flush settles the +/// row before debiting it, so the timing of the sweep changes nothing. +BOOST_FIXTURE_TEST_CASE(withdrawn_units_keep_yield_earned_while_bonded, sysio_opreg_tester) { try { + setup_yield_bonders(); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + const fc::uint128_t index = liq_index(); + + BOOST_REQUIRE_EQUAL(success(), withdraw(kYieldBonderA, kLiqEthCodename, kYieldBondA)); + BOOST_REQUIRE_EQUAL(success(), flushwtdw(kFlushAllMaturedEpoch)); + BOOST_REQUIRE_EQUAL(0u, depot_native_row(kYieldBonderA, kLiqEthCodename)["balance"].as_uint64()); + sweep_liqeth(); + + BOOST_REQUIRE_EQUAL(yield_reference::owed(kYieldBondA, index, 0), claim_yield_credit(kYieldBonderA)); + BOOST_REQUIRE_EQUAL(yield_reference::owed(kYieldBondB, index, 0), claim_yield_credit(kYieldBonderB)); +} FC_LOG_AND_RETHROW() } + +/// Shadow the registry holds for no bond -- here A's seized shadow -- earns for the registry, not +/// for the remaining bonders: B is credited its own units' share only, and the rest of the sweep +/// stays in the registry. +BOOST_FIXTURE_TEST_CASE(registry_keeps_yield_on_unbonded_holdings, sysio_opreg_tester) { try { + setup_yield_bonders(); + BOOST_REQUIRE_EQUAL(success(), slash(kYieldBonderA, std::string(kTestSlashReason))); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + const fc::uint128_t index = liq_index(); + + const int64_t swept = sweep_liqeth(); + const int64_t b_credit = claim_yield_credit(kYieldBonderB); + BOOST_REQUIRE_EQUAL(yield_reference::owed(kYieldBondB, index, 0), b_credit); + + // What stays in the registry is the seized units' share, up to one unit of per-row flooring. + const int64_t seized_share = yield_reference::owed(kYieldBondA, index, 0); + BOOST_REQUIRE_EQUAL(swept, wire_balance(OPREG_ACCOUNT)); + BOOST_REQUIRE_GE(wire_balance(OPREG_ACCOUNT) - b_credit, seized_share); + BOOST_REQUIRE_LE(wire_balance(OPREG_ACCOUNT) - b_credit, seized_share + 1); + BOOST_REQUIRE_EQUAL(error(std::string(kNoYieldOwedError)), claimyield(kYieldBonderA, kLiqEthCodename)); +} FC_LOG_AND_RETHROW() } + +/// Strict solvency: an operator's yield is credited only out of WIRE the registry actually received +/// from sysio.liq. With A the only bonder, the registry row floors at each of its settles while A's +/// row floors once, so A is owed a little more than was received: the claim credits exactly what +/// was received, the dust stays on A's row, and paying the claim leaves the WIRE collateral whole. +BOOST_FIXTURE_TEST_CASE(yield_credit_never_exceeds_wire_received_from_liq, sysio_opreg_tester) { try { + const int64_t received = setup_yield_dust(); + const int64_t a_owed = yield_reference::owed(kYieldBondA, liq_index(), 0); + const int64_t dust = a_owed - received; + BOOST_REQUIRE_LT(0, dust); // the case exists only if flooring left A owed more than received + + BOOST_REQUIRE_EQUAL(received, claim_yield_credit(kYieldBonderA)); + BOOST_REQUIRE_EQUAL(static_cast(dust), + depot_native_row(kYieldBonderA, kLiqEthCodename)["shadow_yield"]["owed_wire"].as_uint64()); + + BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kWireCodename)); + BOOST_REQUIRE_EQUAL(static_cast(kYieldPrincipal), wire_balance(OPREG_ACCOUNT)); + produce_blocks(); // a repeat of the same claim in the same block is a duplicate transaction + BOOST_REQUIRE_EQUAL(error(std::string(kYieldNotCoveredError)), claimyield(kYieldBonderA, kLiqEthCodename)); +} FC_LOG_AND_RETHROW() } + +/// Termination can pay only what the pool covers, since it cannot pull from sysio.liq. The rest -- +/// here a whole unswept distribution plus flooring dust -- stays banked on the terminated row, and +/// the operator collects it with `claimyield`, which pulls first. Banked yield never makes the row +/// unsettled: `prune` erases it with dust still owed once the claim window (the prune delay) ends, +/// and that remainder is forfeited to the registry. +BOOST_FIXTURE_TEST_CASE(terminated_operator_claims_uncovered_yield_before_prune, sysio_opreg_tester) { try { + const int64_t received = setup_yield_dust(); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); // not swept before termination + const int64_t a_owed = yield_reference::owed(kYieldBondA, liq_index(), 0); + + BOOST_REQUIRE_EQUAL(success(), terminate(kYieldBonderA, std::string(kTestTerminationReason))); + BOOST_REQUIRE_EQUAL(static_cast(received), + get_remitclaim(kYieldBonderA, kWireCodename)["balance"].as_uint64()); + const auto terminated_row = depot_native_row(kYieldBonderA, kLiqEthCodename); + BOOST_REQUIRE_EQUAL(0u, terminated_row["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(static_cast(a_owed - received), + terminated_row["shadow_yield"]["owed_wire"].as_uint64()); + + // The claim pulls the unswept distribution and credits it; flooring dust stays banked. + const int64_t registry_owed = registry_liq_owed(); + BOOST_REQUIRE_EQUAL(registry_owed, claim_yield_credit(kYieldBonderA)); + const int64_t dust = a_owed - received - registry_owed; + BOOST_REQUIRE_LT(0, dust); + BOOST_REQUIRE_EQUAL(static_cast(dust), + depot_native_row(kYieldBonderA, kLiqEthCodename)["shadow_yield"]["owed_wire"].as_uint64()); + + // The dust does not hold the row: prune erases it after the delay and the dust is forfeited. + // Commit the claim first: a transaction left pending across the time jump would expire. + produce_blocks(); + produce_block(fc::milliseconds(kDefaultPruneDelayMs)); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), prune()); + BOOST_REQUIRE(get_operator(kYieldBonderA).is_null()); + + BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kWireCodename)); + BOOST_REQUIRE_EQUAL(static_cast(kYieldPrincipal), wire_balance(OPREG_ACCOUNT)); +} FC_LOG_AND_RETHROW() } + +/// `depositinle` never credits the depot chain: depot-native collateral is bonded only through +/// `deposit`. The credit is dropped and logged, not thrown, since it runs inside a dispatch. +BOOST_FIXTURE_TEST_CASE(depositinle_drops_a_depot_chain_credit, sysio_opreg_tester) { try { + BOOST_REQUIRE_EQUAL(success(), setconfig()); + BOOST_REQUIRE_EQUAL(success(), regoperator(kYieldBonderA, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); + + BOOST_REQUIRE_EQUAL(success(), depositinle(kYieldBonderA, kWireCodename, kWireCodename, kWireUnit)); + BOOST_REQUIRE(depot_native_row(kYieldBonderA, kWireCodename).is_null()); + const auto entry = latest_action_log(kYieldBonderA); + BOOST_REQUIRE(!entry.is_null()); + BOOST_REQUIRE_EQUAL(false, entry[eligibility_field::success].as_bool()); + BOOST_REQUIRE_EQUAL(std::string(kDepotChainCreditReason), entry["error_message"].as_string()); +} FC_LOG_AND_RETHROW() } + +/// `claimyield` is permissionless: a keeper with no stake cranks it for a TERMINATED operator whose +/// yield termination could not cover, and the credit lands in the operator's own WIRE claim. +BOOST_FIXTURE_TEST_CASE(keeper_claims_terminated_operators_yield, sysio_opreg_tester) { try { + setup_yield_bonders(); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); // not swept before termination + const int64_t a_owed = yield_reference::owed(kYieldBondA, liq_index(), 0); + + BOOST_REQUIRE_EQUAL(success(), terminate(kYieldBonderA, std::string(kTestTerminationReason))); + BOOST_REQUIRE(get_remitclaim(kYieldBonderA, kWireCodename).is_null()); // nothing was covered yet + BOOST_REQUIRE(get_remitclaim(kYieldKeeper, kWireCodename).is_null()); + + BOOST_REQUIRE_EQUAL(success(), push_opreg_action(kYieldKeeper, yield_action::claimyield, mvo() + (withdrawal_field::account, kYieldBonderA)(withdrawal_field::token_code, kLiqEthCodename))); + BOOST_REQUIRE_EQUAL(static_cast(a_owed), + get_remitclaim(kYieldBonderA, kWireCodename)["balance"].as_uint64()); + BOOST_REQUIRE(get_remitclaim(kYieldKeeper, kWireCodename).is_null()); // the keeper gains nothing + + const int64_t wire_before = wire_balance(kYieldBonderA); + BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kWireCodename)); + BOOST_REQUIRE_EQUAL(wire_before + a_owed, wire_balance(kYieldBonderA)); +} FC_LOG_AND_RETHROW() } + +/// A row that withdrew everything earns nothing while empty: re-bonding later settles the gap at a +/// balance of 0, so the claim is the first bond's share plus the re-bond's share, and nothing for +/// the distribution in between. +BOOST_FIXTURE_TEST_CASE(rebond_after_full_withdrawal_earns_nothing_for_the_gap, sysio_opreg_tester) { try { + setup_yield_bonders(); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + const fc::uint128_t first_index = liq_index(); + + BOOST_REQUIRE_EQUAL(success(), withdraw(kYieldBonderA, kLiqEthCodename, kYieldBondA)); + BOOST_REQUIRE_EQUAL(success(), flushwtdw(kFlushAllMaturedEpoch)); + BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kLiqEthCodename)); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); // the gap: A holds nothing bonded + const fc::uint128_t gap_index = liq_index(); + + BOOST_REQUIRE_EQUAL(success(), deposit(kYieldBonderA, kLiqEthCodename, kYieldBondA)); + const auto rebonded = depot_native_row(kYieldBonderA, kLiqEthCodename); + BOOST_REQUIRE_EQUAL(gap_index, rebonded["shadow_yield"]["index_checkpoint"].as_uint128()); + BOOST_REQUIRE_EQUAL(static_cast(yield_reference::owed(kYieldBondA, first_index, 0)), + rebonded["shadow_yield"]["owed_wire"].as_uint64()); + + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + BOOST_REQUIRE_EQUAL(yield_reference::owed(kYieldBondA, first_index, 0) + + yield_reference::owed(kYieldBondA, liq_index(), gap_index), + claim_yield_credit(kYieldBonderA)); +} FC_LOG_AND_RETHROW() } + +/// Re-registering a settled TERMINATED operator replaces its row, and with it any yield still +/// banked on it -- the claim window closes -- while the claim rows termination already credited +/// live in `remitclaims`, independent of the operator row, and still pay. +BOOST_FIXTURE_TEST_CASE(reregistration_forfeits_banked_yield_but_keeps_remit_claims, sysio_opreg_tester) { try { + // A is the only bonder, so the first sweep's pool covers exactly A's first share and the unswept + // second distribution stays banked on A's row at termination. + setup_yield_holders(); + BOOST_REQUIRE_EQUAL(success(), deposit(kYieldBonderA, kLiqEthCodename, kYieldBondA)); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); + const int64_t first_swept = sweep_liqeth(); + BOOST_REQUIRE_EQUAL(success(), addyield(kYieldDistribution)); // not swept: stays banked on A's row + + BOOST_REQUIRE_EQUAL(success(), terminate(kYieldBonderA, std::string(kTestTerminationReason))); + const uint64_t covered = get_remitclaim(kYieldBonderA, kWireCodename)["balance"].as_uint64(); + BOOST_REQUIRE_LT(0u, covered); + BOOST_REQUIRE_LE(covered, static_cast(first_swept)); + BOOST_REQUIRE_LT(0u, depot_native_row(kYieldBonderA, kLiqEthCodename)["shadow_yield"]["owed_wire"].as_uint64()); + + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), regoperator(kYieldBonderA, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); + BOOST_REQUIRE(depot_native_row(kYieldBonderA, kLiqEthCodename).is_null()); // banked yield gone with the row + BOOST_REQUIRE_EQUAL(error(std::string(kNoYieldOwedError)), claimyield(kYieldBonderA, kLiqEthCodename)); + + BOOST_REQUIRE_EQUAL(covered, get_remitclaim(kYieldBonderA, kWireCodename)["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(kYieldBondA, get_remitclaim(kYieldBonderA, kLiqEthCodename)["balance"].as_uint64()); + const int64_t wire_before = wire_balance(kYieldBonderA); + BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kWireCodename)); + BOOST_REQUIRE_EQUAL(wire_before + static_cast(covered), wire_balance(kYieldBonderA)); + BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kLiqEthCodename)); +} FC_LOG_AND_RETHROW() } + BOOST_AUTO_TEST_SUITE_END() diff --git a/docs/becoming-a-block-producer.md b/docs/becoming-a-block-producer.md index ed89d5031a..ed6918cdd0 100644 --- a/docs/becoming-a-block-producer.md +++ b/docs/becoming-a-block-producer.md @@ -62,9 +62,11 @@ Each deposit travels to WIRE over the cross-chain protocol and credits your bala `sysio.opreg`. If the configured requirements include the native WIRE pair, separately call -`sysio.opreg::deposit(account, amount)` on WIRE, signed by your WIRE account. This native action -requires an existing non-bootstrapped operator row, transfers WIRE inline, and credits only the -WIRE-chain pair; it does not replace the Ethereum or Solana outpost deposits. +`sysio.opreg::deposit(account, token_code, amount)` on WIRE with `token_code` `WIRE`, signed by +your WIRE account. This native action requires an existing non-bootstrapped operator row, +transfers the token inline, and credits only the matching WIRE-chain pair; it does not replace +the Ethereum or Solana outpost deposits. The same action bonds a shadow LIQ symbol such as +`LIQETH` or `LIQSOL` held on WIRE, one `token_code` per call. When every required chain is at or above its minimum, your operator status flips to `ACTIVE` on its own. No producer rank exists yet: the first score is written only after the producer row and @@ -266,12 +268,31 @@ under it. It is not applied only to rounds missed from that point on. top-up first. - **Withdraw** from the chain that holds the bond. An outpost bond is released through that outpost's own withdrawal entry point, the counterpart of the deposit you made in step 3, which - travels to WIRE and settles against your registry balance. `sysio.opreg::withdraw` is **not** - that path: it takes only an account and an amount and applies to your WIRE-native balance, so - calling it for an Ethereum or Solana bond fails for insufficient balance and leaves the outpost - collateral untouched. Either way the request is queued rather than immediate, and `cancelwtdw` + travels to WIRE and settles against your registry balance. + `sysio.opreg::withdraw(account, token_code, amount)` is **not** that path: it applies only to + your WIRE-native balance in `token_code` (`WIRE` or a shadow LIQ symbol), so calling it for an + Ethereum or Solana bond fails for insufficient balance + and leaves the outpost collateral untouched. Once a WIRE-native withdrawal flushes, the funds + are credited to a per-token claim that you pull with `sysio.opreg::claimremit(account, + token_code)`. Either way the request is queued rather than immediate, and `cancelwtdw` cancels it before it flushes. Once your balance falls below the minimum on any required chain you leave `ACTIVE` and the schedule drops you at the next rebuild. +- **Collect shadow yield.** Bonded shadow LIQ keeps earning WIRE yield: each bonded unit earns + what one unit of that shadow token earns in `sysio.liq`, from the moment it is bonded until it + leaves your balance. `sysio.opreg::claimyield(account, token_code)` credits what you have + earned to your WIRE claim, which you pull with `sysio.opreg::claimremit(account, WIRE)`. Yield + earned before a slash or a withdrawal stays claimable. Yield credits are always backed by WIRE + the registry has already pulled from `sysio.liq`. If `claimyield` reports yield not yet + covered, anyone can call `sysio.opreg::sweepyield(token_code)` to pull more in once more yield + is distributed. On termination the covered part is credited to your WIRE claim alongside the + returned principal. The rest stays owed to you: call `claimyield`, which pulls it in, before the + registry prunes your terminated operator row after `terminate_prune_delay_ms`. Anything still + unclaimed when the row is pruned is forfeited. `claimyield` needs no signature, so anyone, + such as a keeper, can call it for you; the credit only ever lands in your own WIRE claim. + Shadow stops earning for you when it leaves your bonded balance. After a withdrawal flushes, + the shadow waiting in your claim row earns for the registry until you call `claimremit`. + Yield is shared first come, first served: rounding can leave the last claimant up to a few + atomic WIRE short, and that dust is paid only when later slack covers it. - **Slashing** is punitive and permanent. A slashed operator's row is never pruned and the registry refuses to re-register it, so a slashed account cannot come back. diff --git a/docs/shadow-custody-integration.md b/docs/shadow-custody-integration.md new file mode 100644 index 0000000000..61463c1f86 --- /dev/null +++ b/docs/shadow-custody-integration.md @@ -0,0 +1,137 @@ +# Integrating shadow custody + +This guide is for a contract that holds shadow LIQ on behalf of other accounts and wants to pass +the WIRE yield that shadow earns on to them. `sysio.opreg` does this for bonded operator +collateral. The library is header-only: + +- `contracts/sysio.opp.common/include/sysio.opp.common/shadow_custody.hpp` holds the functions. +- `shadow_custody_types.hpp` holds the two row types. + +## The model + +1. `sysio.liq` sees one holder, your contract (the custodian), and pays that row's yield through a + cumulative per-symbol index. +2. Each of your sub-holder rows embeds a `position`. The position checkpoints that same live index, + so a sub-holder unit earns exactly what a unit of your liq row earns. +3. Before every change to a sub-holder's balance, the position is settled at the live index. A new + row starts at the current index and never earns earlier distributions. +4. The WIRE comes in only when you `pull`, which records the amount on a per-symbol `yield_pool` + and sends the `sysio.liq::claim` that pays it. +5. A sub-holder is credited only out of that pool, so credits never exceed WIRE you actually + received. + +## What to embed, what to store + +- **In each sub-holder row:** a `custody::position` next to the balance it tracks, for example + `balance_entry::shadow_yield` in `sysio.opreg`. +- **In a table of your own:** one `custody::yield_pool` per symbol, under your own key. `sysio.opreg` + uses `yieldpool`, keyed by token code. +- **In your contract header:** include `shadow_custody_types.hpp` only. +- **In your implementation file:** include `shadow_custody.hpp`. + +## Entry points + +Use the three composites. Each one reads the live index itself. + +| Call | Use it for | Throws? | +|---|---|---| +| `settle_and_adjust(pos, balance, delta, liq, sym)` | every change to a sub-holder's balance; returns the change applied | never | +| `settle_and_take(pos, balance, pool, liq, sym)` | crediting a sub-holder; returns what the pool covers, the rest stays banked | never | +| `pull(pool, liq, self, sym)` | bringing the WIRE in; returns what it recorded and claimed, or 0 | caller-signed actions only | + +The primitives are `live_index`, `settle`, the index-taking `settle_and_adjust`, `take`, +`custodian_owed`, `record_pull` and `claim_action`. They stay public for unusual needs. If you use +them directly, you own the sequencing the composites guarantee. + +A worked custodian: + +```cpp +#include +namespace custody = sysio::opp::shadow::custody; + +// Bond `amount` shadow for `holder`: the shadow transfer into this contract is sent in this same action. +void vault::bond(name holder, uint64_t amount) { + require_auth(holder); + holders_t rows(get_self()); + auto row = rows.get_or_create(holder); // balance 0, position {} + custody::settle_and_adjust(row.pos, row.balance, int64_t(amount), LIQ, SYM); + rows.set(holder, row); + action({holder, "active"_n}, LIQ, "transfer"_n, // the shadow arrives + std::make_tuple(holder, get_self(), asset(amount, SHADOW), std::string{})).send(); +} + +// Credit what `holder` has earned into a ledger it pulls from; permissionless is fine. +void vault::claim(name holder) { + pools_t pools(get_self()); + auto pool = pools.get_or_default(SYM); + custody::pull(pool, LIQ, get_self(), SYM); // at most once per action + holders_t rows(get_self()); + auto row = rows.get(holder); + const uint64_t credit = custody::settle_and_take(row.pos, row.balance, pool, LIQ, SYM); + check(credit > 0, "nothing to claim"); + rows.set(holder, row); + pools.set(SYM, pool); + sysio::opp::claimable::credit(claims, payer, holder, {}, credit); // holder pulls it later +} +``` + +In `vault::bond`, the position is settled and the balance credited before the shadow arrives. This +is allowed only because the transfer is sent in the same action (obligation 0). + +## Obligations + +0. **Hold what you attribute.** For each symbol, the sum of sub-holder balances never exceeds your + own `sysio.liq` holding. A balance may be credited before the shadow arrives only within the + same action that brings the shadow in. Otherwise positions accrue on shadow your liq row does not + hold, so your row is owed less than the positions. Because the pool is first-come-first-served, + the first sub-holders to claim then take yield the others earned. +1. **Change balances only through `settle_and_adjust`.** For a new row, create it at balance 0 and + then adjust. +2. **Pull only through `pull`, from a caller-signed action, at most once per symbol per action.** + `pull` records a predicted payout, because the claim it sends executes after your action + returns. It refuses a second pull at the same index and owed amount itself, since that state + means the first claim has not executed yet. +3. **Queue nothing ahead of `pull` that touches `sysio.liq`.** This covers a shadow transfer, an + `addyield`, or a claim of your own, if sent in the same action before `pull`. That inline action + executes first, the claim then pays a different amount than was recorded, and WIRE is stranded + in your account. +4. **Credit sub-holders only with what `settle_and_take` returns**, into a ledger they pull from + (`claimable.hpp`). Never push a transfer from a path that must not throw. +5. **Decide your policy** for yield the pool cannot yet cover, and for yield left unclaimed when a + row is erased. `sysio.opreg` keeps uncovered yield on the row, and forfeits whatever is unclaimed + when `prune` erases a terminated operator. + +## What never throws + +These calls read at most `sysio.liq`'s `yieldidx` row and never `check()`: + +- `live_index`, `settle`, `settle_and_adjust`, `settle_and_take`, `take` and `record_pull`. + +They are safe on paths that must not abort, such as `sysio.opreg`'s inline paths from +`sysio.epoch::advance`. + +`pull`, `custodian_owed` and `claim_action` are for caller-signed actions only. They use the +checked yield formula, and the claim can throw inside `sysio.liq`. + +## Authority + +`pull` pushes `sysio.liq::claim(self, sym)` under `self@active`. A privileged custodian can do this +as is. Any other custodian needs its own `sysio.code` permission added to its `active` authority. + +## Dust and first-come-first-served + +`sysio.liq` floors your row's yield at every settle of that row: each claim, and each shadow +transfer in or out. A position floors once over its whole interval. So positions can be owed up to +one atomic WIRE per settle of your row more than you received. That dust stays banked until slack +covers it; slack is yield on shadow you hold for nobody. The pool pays first come, first served, so +a shortfall falls on whoever claims last, not on the position whose rounding caused it. + +## Upgrades + +Your contract deserializes three `sysio.liq` rows: + +- `yieldidx` on every settle; +- `accounts` in `pull`; +- if you resolve symbols the way `sysio.opreg` does, `stat`. + +Any change to those layouts means `sysio.liq` and every custodian redeploy together. diff --git a/tests/multisig_review_test.py b/tests/multisig_review_test.py index bc933ffee5..ca6dc1ff93 100755 --- a/tests/multisig_review_test.py +++ b/tests/multisig_review_test.py @@ -14,10 +14,10 @@ # /v1/chain/get_table_rows — it now always invokes `getproposal` over the # read-only RPC. This test exercises the full end-to-end path: # -# 1. Stand up a single-node cluster with the new sysio.msig wasm. +# 1. Stand up a producer and read-only API node with sysio.msig. # 2. Create alice and bob accounts. # 3. Build a JSON inner transaction containing two `setcode` actions, each -# carrying the full sysio.system wasm (~134 KiB). Total ≈ 270 KiB, +# carrying a fixed-size WASM fixture (~128 KiB). Total ≈ 256 KiB, # which forces the contract to chunk the proposal across two rows. # 4. Submit it via `clio multisig propose_trx`. # 5. Run `clio multisig review` and verify the JSON output reflects the @@ -34,8 +34,8 @@ ############################################################### import copy +import hashlib import json -import os import shlex import tempfile from pathlib import Path @@ -121,23 +121,27 @@ errorExit("Failed to publish sysio.msig contract.") # ----- Build a chunked-size inner transaction. - # Two setcode actions stacked together, each carrying the full sysio.system - # wasm (~134 KiB), gives a serialized inner trx of ~270 KiB. The contract's - # chunk threshold is 200 KiB, so this is guaranteed to take the chunked - # storage path. - # - # `multisig propose_trx` now accepts the natural JSON shape (structured - # `action.data` objects, recursively encoded against each contract's ABI) - # in addition to the legacy pre-hex form — the same fallback that - # `clio push transaction` uses. So we just hand it the trx as JSON. - system_wasm_path = cluster.contractsPath / 'sysio.system' / 'sysio.system.wasm' - if not system_wasm_path.exists(): - errorExit(f"sysio.system.wasm not found at {system_wasm_path}; build BUILD_SYSTEM_CONTRACTS=ON") - - with open(system_wasm_path, 'rb') as f: - system_wasm_hex = f.read().hex() - Print(f"system wasm size = {len(system_wasm_hex) // 2} bytes") - + # A valid empty WASM module with a padding custom section makes the two + # actions exceed the msig chunk threshold while staying comfortably below + # the transaction NET limit. Do not use a production contract here: its + # growth can make the outer propose transaction exceed that limit before + # the chunked storage/review path is exercised. + fixture_payload_size = 128 * 1024 + proposal_chunk_size = 200 * 1024 + # Custom sections contain a length-prefixed name followed by arbitrary + # bytes. An empty name is encoded by the initial zero byte. + custom_section = bytes(1 + fixture_payload_size) + section_size = len(custom_section) + encoded_size = bytearray() + while section_size >= 0x80: + encoded_size.append((section_size & 0x7f) | 0x80) + section_size >>= 7 + encoded_size.append(section_size) + fixture_wasm = b"\x00asm\x01\x00\x00\x00" + b"\x00" + encoded_size + custom_section + fixture_wasm_hex = fixture_wasm.hex() + Print(f"fixture wasm size = {len(fixture_wasm)} bytes") + + # Structured action.data objects exercise propose_trx's ABI encoding too. test_workdir = Path(tempfile.mkdtemp(prefix='msig_review_test_')) inner_trx = { @@ -158,7 +162,7 @@ "account": "alice", "vmtype": 0, "vmversion": 0, - "code": system_wasm_hex, + "code": fixture_wasm_hex, }, }, { @@ -169,7 +173,7 @@ "account": "bob", "vmtype": 0, "vmversion": 0, - "code": system_wasm_hex, + "code": fixture_wasm_hex, }, }, ], @@ -247,18 +251,21 @@ assert a["data"]["account"] == expected_target, \ f"action[{i}] target is {a['data']['account']}, expected {expected_target}" - # Chunked-storage metadata: chunk_count > 0 proves the contract took the + assert a["data"]["code"] == fixture_wasm_hex, \ + f"action[{i}] WASM payload did not round-trip intact" + + # Chunked-storage metadata: two chunks prove the contract took the # chunked path, total_size matches the expected packed inner trx, and # trx_hash is populated. If clio were silently using a stale get_table_rows # path, packed_transaction would be empty and any of these would catch it. assert "chunk_count" in review_result, "review missing chunk_count metadata" chunk_count = int(review_result["chunk_count"]) - assert chunk_count > 0, f"expected chunk_count > 0, got {chunk_count}" + assert chunk_count == 2, f"expected 2 chunks, got {chunk_count}" assert "total_size" in review_result, "review missing total_size metadata" total_size = int(review_result["total_size"]) - assert total_size > 200 * 1024, \ - f"expected total_size > 200 KiB, got {total_size}" + assert proposal_chunk_size < total_size < 2 * proposal_chunk_size, \ + f"expected an inner transaction spanning two chunks, got {total_size} bytes" assert "trx_hash" in review_result and len(review_result["trx_hash"]) == 64, \ f"trx_hash missing or wrong length: {review_result.get('trx_hash')}" @@ -270,9 +277,10 @@ # populated. We confirm by checking that the field is non-empty in the # review output (i.e. clio is not echoing the empty inline field). assert "packed_transaction" in review_result, "packed_transaction missing in review" - assert len(review_result["packed_transaction"]) > 200 * 1024 * 2, \ - ("packed_transaction in review is empty or too small — clio may not be " - "going through the read-only getproposal path") + packed_transaction = bytes.fromhex(review_result["packed_transaction"]) + assert len(packed_transaction) == total_size, "reassembled transaction size mismatch" + assert hashlib.sha256(packed_transaction).hexdigest() == review_result["trx_hash"], \ + "reassembled transaction hash mismatch" # ----- Same call with --show-approvals exercises the approvals branches. Print("Run `multisig review --show-approvals` against the API node") From 25134e3851c91c2c3726b902f87a8ea99af857ab Mon Sep 17 00:00:00 2001 From: David J Parrott Date: Tue, 6 Oct 2026 20:40:27 -0400 Subject: [PATCH 3/5] Add generic underwriting with sysio.bond Let an issuer request underwriting of an opaque statement in a depot-native token. Track partial bonds, bounties, challenge holds, approval, and explicit governance rulings independently of any particular external chain. Settle resolved obligations through individual pull claims. Preserve shadow yield and durable outcomes until the issuer acknowledges them, and prevent a rejecting recipient from blocking another party's principal or yield. Introduce notification-free LIQ custody settlement and apply the Andon cord to custody exits while leaving bonds and dispute resolution available. Include the full bond state-machine, rounding, pruning, freeze, and hostile recipient tests with the issuer integration guide. Fixtures use the existing LIQ funding interface until sysio.synd takes ownership of supply. Change-Id: I3a1fd657de9edd2cd31a67f737d782acaadeb1d7 --- contracts/CMakeLists.txt | 1 + contracts/sysio.bond/CMakeLists.txt | 47 + .../include/sysio.bond/sysio.bond.hpp | 453 ++++ contracts/sysio.bond/src/sysio.bond.cpp | 773 +++++++ contracts/sysio.bond/sysio.bond.abi | 697 ++++++ contracts/sysio.bond/sysio.bond.wasm | Bin 0 -> 45576 bytes contracts/sysio.liq/CMakeLists.txt | 1 + .../sysio.liq/include/sysio.liq/sysio.liq.hpp | 6 + contracts/sysio.liq/src/sysio.liq.cpp | 40 +- contracts/sysio.liq/sysio.liq.abi | 23 + contracts/sysio.liq/sysio.liq.wasm | Bin 62305 -> 64239 bytes contracts/sysio.swap/CMakeLists.txt | 3 +- .../include/sysio.swap/sysio.swap.hpp | 8 + contracts/sysio.swap/sysio.swap.cpp | 23 +- contracts/sysio.swap/sysio.swap.wasm | Bin 48057 -> 49037 bytes contracts/sysio.swap/token_functions.cpp | 1 + .../block_transfer/block_transfer.cpp | 15 +- .../block_transfer/block_transfer.wasm | Bin 2792 -> 3172 bytes contracts/tests/contract_test_support.hpp | 20 +- contracts/tests/contracts.hpp.in | 2 + contracts/tests/sysio.bond_tests.cpp | 1901 +++++++++++++++++ docs/sysio-bond-integration.md | 196 ++ 22 files changed, 4193 insertions(+), 17 deletions(-) create mode 100644 contracts/sysio.bond/CMakeLists.txt create mode 100644 contracts/sysio.bond/include/sysio.bond/sysio.bond.hpp create mode 100644 contracts/sysio.bond/src/sysio.bond.cpp create mode 100644 contracts/sysio.bond/sysio.bond.abi create mode 100755 contracts/sysio.bond/sysio.bond.wasm create mode 100644 contracts/tests/sysio.bond_tests.cpp create mode 100644 docs/sysio-bond-integration.md diff --git a/contracts/CMakeLists.txt b/contracts/CMakeLists.txt index 309f30f18f..ecaa249ebe 100644 --- a/contracts/CMakeLists.txt +++ b/contracts/CMakeLists.txt @@ -61,4 +61,5 @@ add_subdirectory(sysio.token) add_subdirectory(sysio.wrap) add_subdirectory(sysio.swap) add_subdirectory(sysio.liq) +add_subdirectory(sysio.bond) add_subdirectory(test_contracts) diff --git a/contracts/sysio.bond/CMakeLists.txt b/contracts/sysio.bond/CMakeLists.txt new file mode 100644 index 0000000000..3a6cf42808 --- /dev/null +++ b/contracts/sysio.bond/CMakeLists.txt @@ -0,0 +1,47 @@ +set(contract_name sysio.bond) +bootstrap_contract(${contract_name}) + +if(BUILD_SYSTEM_CONTRACTS) + find_cdt_magic_enum() + file(GLOB_RECURSE SOURCES src/*.cpp) + file(GLOB_RECURSE HEADERS include/*.hpp) + add_contract(${contract_name} ${contract_name} ${SOURCES}) + set(targets ${contract_name}) + + if("native-module" IN_LIST SYSIO_WASM_RUNTIMES) + list(APPEND targets ${contract_name}_native) + add_native_contract( + TARGET ${contract_name}_native + SOURCES ${SOURCES} + INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/include + CONTRACT_CLASS "sysio::bond" + HEADERS ${HEADERS} + ABI_FILE ${CMAKE_BINARY_DIR}/contracts/${contract_name}/${contract_name}.abi + ) + endif() + + foreach(target ${targets}) + if(NOT TARGET ${target}) + message(WARNING "Target ${target} not found, skipping include directory setup") + continue() + endif() + + target_include_directories(${target} + PUBLIC + $ + $ + $ + $ + $ + $ + $ + $ + $ + ) + + target_link_libraries(${target} + INTERFACE + magic_enum::magic_enum + ) + endforeach() +endif() diff --git a/contracts/sysio.bond/include/sysio.bond/sysio.bond.hpp b/contracts/sysio.bond/include/sysio.bond/sysio.bond.hpp new file mode 100644 index 0000000000..9020bbedff --- /dev/null +++ b/contracts/sysio.bond/include/sysio.bond/sysio.bond.hpp @@ -0,0 +1,453 @@ +#pragma once +/** + * @file sysio.bond.hpp + * @brief sysio.bond: generic underwriting of provable statements on the depot. + * + * An issuer registers a statement (a schema name and its bytes) together with a covered amount of a + * depot-native token and an optional bounty. Underwriters bond the statement in increments of 0.01 + * token until the bonded amount reaches the covered amount; a challenge window then runs. When it + * passes with no hold the request is APPROVED. The issuer may instead hold the request by posting a + * hold bond, and `sysio` rules it VALID or INVALID. Approval and the rulings only change state and + * record what each party may claim; they send no transfer and notify no one. Every payout is pulled + * one account at a time through `claim`, so no party can block another's. + * + * Depot-native tokens only: WIRE on `sysio.token`, or a shadow LIQ symbol on `sysio.liq`. Every bond + * and escrow of a shadow token embeds a shadow-custody `position`, so the WIRE yield the contract's + * single `sysio.liq` holder row earns is attributed to the sub-holders whose shadow it holds. + * + * Emergency stop: while the `sysio.andon` cord is pulled, `claim` is refused -- every payout waits for + * the cord to clear -- and everything else runs: requests, bonds, holds, approval and rulings, and every + * transfer into this contract. + * + * Privileged: an action signed by an account pulls that account's tokens by an inline `transfer` + * under its `active` authority. The issuer pays the RAM of its `requests` and `escrows` rows and an + * underwriter that of its `bonds` row -- unless that account is itself privileged: a system contract + * (`sysio.synd` issuing for an envelope) has no RAM quota of its own, so its rows bill the `sysio` RAM + * pool instead. Later writes by other signers keep the row's payer. The contract's own singletons and + * yield pools bill the `sysio` RAM pool. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace sysio { + +namespace bond_validation { +inline constexpr std::string_view bond_increment_msg = "amount must be a positive multiple of the bond increment"; +inline constexpr std::string_view precision_msg = "token precision is below the bond increment"; +inline constexpr std::string_view unsupported_token_msg = "unsupported bond token"; +inline constexpr std::string_view statement_len_msg = "statement exceeds the maximum length"; +inline constexpr std::string_view duplicate_msg = "a request for this statement already exists"; +inline constexpr std::string_view window_msg = "window must be positive"; +inline constexpr std::string_view amount_range_msg = "amount exceeds the asset range"; +} // namespace bond_validation + + + class [[sysio::contract("sysio.bond")]] bond : public contract { + public: + using contract::contract; + + /// Ruling and configuration authority; council proposals execute as it. + static constexpr name SYSTEM_ACCOUNT = "sysio"_n; + /// Custody contract of WIRE. + static constexpr name TOKEN_ACCOUNT = "sysio.token"_n; + /// Custody contract of the shadow LIQ symbols, and the source of their yield index. + static constexpr name LIQ_ACCOUNT = "sysio.liq"_n; + + /// Bonds move in steps of 10^-BOND_INCREMENT_DECIMALS token: the increment of a token with + /// precision `p` is `10^(p - BOND_INCREMENT_DECIMALS)` base units, and a token with a precision + /// below this is refused. + static constexpr uint8_t BOND_INCREMENT_DECIMALS = 2; + /// Longest statement a request carries, in bytes. + static constexpr uint32_t MAX_STATEMENT_BYTES = 1024; + /// Hold bond, in basis points of the covered amount, until `setconfig` sets another. + static constexpr uint32_t DEFAULT_HOLD_BPS = 1000; + /// Minimum retention for ordinary requests. Durable requests additionally require issuer acknowledgement. + static constexpr uint32_t PRUNE_RETENTION_SEC = 7 * 24 * 60 * 60; + /// One whole, in basis points. + static constexpr uint32_t BPS_DENOMINATOR = 10000; + /// Base of the decimal increment arithmetic. + static constexpr uint64_t DECIMAL_BASE = 10; + + /// The bond increment of a token denominated in `sym`: `10^(precision - BOND_INCREMENT_DECIMALS)` + /// base units, or `std::nullopt` when the precision is below BOND_INCREMENT_DECIMALS. A symbol's + /// precision is at most 18, so the power fits in 64 bits. The ONE increment computation: this + /// contract's `request` and `accept`, and every issuer that must know a covered amount is + /// acceptable before it sends `request`, use it. + static std::optional increment_of(symbol sym) { + if (sym.precision() < BOND_INCREMENT_DECIMALS) return std::nullopt; + uint64_t increment = 1; + for (uint8_t d = BOND_INCREMENT_DECIMALS; d < sym.precision(); ++d) increment *= DECIMAL_BASE; + return increment; + } + + /// sha256 over `schema` (its 8-byte value) then the statement bytes: a request's statement digest. + /// The ONE digest computation, shared with issuers that check for a duplicate before `request`. + static checksum256 statement_digest_of(name schema, const std::vector& statement) { + std::vector buf(sizeof(uint64_t) + statement.size()); + const uint64_t schema_value = schema.value; + std::memcpy(buf.data(), &schema_value, sizeof(schema_value)); + if (!statement.empty()) std::memcpy(buf.data() + sizeof(schema_value), statement.data(), statement.size()); + return sha256(buf.data(), buf.size()); + } + + /// `amount * pot / covered` in 128 bits, floored: the share of `pot` a stake of `amount` out of + /// `covered` earns. `amount <= covered` and `covered > 0` (a request refuses zero), so the share is + /// at most `pot`. The ONE share computation: every payout of a bounty or a hold bond, and every + /// issuer that must know what a ruling awards it, use it. + static uint64_t share_of(uint64_t amount, uint64_t pot, uint64_t covered) { + return static_cast(static_cast(amount) * pot / covered); + } + + /// `covered * hold_bps / BPS_DENOMINATOR` in 128 bits, floored: the hold bond of a request covering + /// `covered` base units at `hold_bps`. `hold_bps <= BPS_DENOMINATOR`, so it is at most `covered`. + /// The ONE hold-bond computation, shared with an issuer that charges its own challenger for it. + static uint64_t hold_bond_of(uint64_t covered, uint32_t hold_bps) { + return static_cast(static_cast(covered) * hold_bps / BPS_DENOMINATOR); + } + + /// Lifecycle of a request. APPROVED, VALID and INVALID are terminal. + enum class request_state : uint8_t { + OPEN, ///< created; bonded below covered + BONDED, ///< bonded equals covered; the challenge window runs from `bonded_at` + APPROVED, ///< the window passed with no hold + HELD, ///< the issuer posted a hold bond; awaiting a ruling + VALID, ///< `sysio` ruled the statement true + INVALID ///< `sysio` ruled the statement false + }; + + /// What an escrow row holds for a request. + enum class escrow_kind : uint8_t { + BOUNTY, ///< the issuer's bounty, paid to the underwriters or back per the ruling + HOLD_BOND ///< the issuer's hold bond, posted by `hold` + }; + + // ----------------------------------------------------------------------- + // Configuration + // ----------------------------------------------------------------------- + + /// Set the hold bond, in basis points of the covered amount, for holds from now on. Above 0 and + /// at most BPS_DENOMINATOR. Auth=sysio. + [[sysio::action]] void setconfig(uint32_t hold_bps); + + // ----------------------------------------------------------------------- + // Requests + // ----------------------------------------------------------------------- + + /// Create an OPEN request for `statement` under `schema`, covering `covered` base units of + /// `token_code` (a positive multiple of the token's increment), with a challenge window of + /// `window_sec` seconds once fully bonded. Pulls `bounty` of the token from `issuer` into + /// escrow. One request per issuer and statement. Auth=issuer. + [[sysio::action]] void request(name issuer, name schema, std::vector statement, + sysio::slug_name token_code, uint64_t covered, uint64_t bounty, + uint32_t window_sec); + + /// Issue a request with durable terminal delivery. Identical to `request`, except pruning also + /// requires the issuer's `ack`. Rulings and claims remain independent of acknowledgement. + [[sysio::action]] void requestkeep(name issuer, name schema, std::vector statement, + sysio::slug_name token_code, uint64_t covered, uint64_t bounty, + uint32_t window_sec); + + /// Acknowledge a terminal outcome after durably consuming it. Auth=issuer; idempotent. + [[sysio::action]] void ack(uint64_t request_id); + + /// Raise the bounty of request `request_id`, not yet terminal, by `amount`, pulled from the + /// issuer. Auth=issuer. + [[sysio::action]] void addbounty(uint64_t request_id, uint64_t amount); + + /// Bond `amount` of request `request_id`'s token (a positive multiple of the increment) as + /// `underwriter`, reduced to what remains uncovered. The request becomes BONDED when bonded + /// reaches covered. Auth=underwriter. + [[sysio::action]] void accept(name underwriter, uint64_t request_id, uint64_t amount); + + /// Hold request `request_id`, OPEN or BONDED, naming `beneficiary`, an existing account other + /// than this contract, for a forfeit: pulls `covered * hold_bps / BPS_DENOMINATOR` from the + /// issuer. The request becomes HELD. Auth=issuer. + [[sysio::action]] void hold(uint64_t request_id, name beneficiary); + + /// Approve request `request_id`, BONDED with its window passed: a state change only, with no + /// transfer and no notification. Permissionless. + [[sysio::action]] void approve(uint64_t request_id); + + /// Rule request `request_id`, not terminal and bonded or not, VALID. Sends nothing: the bonds + /// stay for their underwriters to claim with their stake's share of the bounty and the hold + /// bond, and the escrows record the unbonded share of the bounty for `sysio` and the unbonded + /// share of the hold bond for the issuer, each to claim. Auth=sysio. + [[sysio::action]] void rslvvalid(uint64_t request_id); + + /// Rule request `request_id`, not terminal and bonded or not, INVALID. Sends nothing: the + /// request records the whole bonded amount as the issuer's forfeit, and the escrows record the + /// hold bond and the bounty for the hold beneficiary after a hold, otherwise the bounty for the + /// issuer, each to claim. Auth=sysio. + [[sysio::action]] void rslvinvalid(uint64_t request_id); + + // ----------------------------------------------------------------------- + // Yield and payouts + // ----------------------------------------------------------------------- + + /// Pull the WIRE this contract's `sysio.liq` holder row has earned for the shadow token + /// `token_code` into its yield pool. WIRE is refused. Permissionless. + [[sysio::action]] void sweepyield(sysio::slug_name token_code); + + /// Pay `account` what terminal request `request_id` owes it, in one call: + /// - to the issuer of an INVALID request, the forfeit: the whole bonded amount in ONE + /// LIQ settlement (a `transfer` with memo `sysio.bond::forfeit` for WIRE); + /// - for its bond on an APPROVED or VALID request, the bond plus its stake's share of the + /// bounty and the hold bond, and the WIRE the bond earned; for its bond on an INVALID + /// request, nothing -- the WIRE the forfeited bond earned is paid into `sysio.liq` as bonus + /// yield of the token, or kept by the contract while the token has no supply; + /// - what a ruling awarded it out of an escrow (`escrow_row::payout`); + /// - for an escrow it funded, the WIRE the escrow earned. + /// LIQ uses callback-free settlement; earned WIRE is recorded for separate `claimwire`. + /// WIRE-denominated requests retain ordinary transfers. Residual unpaid yield prevents pruning. + /// Refused when nothing is owed, and while the `sysio.andon` cord is pulled (what is owed stays + /// recorded until the cord clears). Permissionless. + [[sysio::action]] void claim(uint64_t request_id, name account); + + /// Withdraw the backed WIRE yield balance recorded by LIQ claims. Permissionless, fixed payee; + /// a rejecting recipient leaves this balance intact without blocking LIQ settlement. + [[sysio::action]] void claimwire(name account); + + struct wire_key { + name account; + SYSLIB_SERIALIZE(wire_key, (account)) + }; + struct [[sysio::table("wireclaims")]] wire_claim { + name account; + uint64_t amount = 0; + SYSLIB_SERIALIZE(wire_claim, (account)(amount)) + }; + using wireclaims_t = kv::table<"wireclaims"_n, wire_key, wire_claim>; + + /// Walk the terminal requests from id `from_id` up and act on at most `limit` of them. A request + /// whose rows are all paid or owe nothing, and that was ruled at least PRUNE_RETENTION_SEC ago, is + /// erased with its bond and escrow rows. Any other terminal request keeps its row, but the bond rows + /// already paid out are erased, so an unclaimed payout holds no other underwriter's RAM. A request + /// whose token no longer resolves is skipped, never touched. Permissionless. + [[sysio::action]] void prune(uint64_t from_id, uint32_t limit); + + // ----------------------------------------------------------------------- + // Tables + // ----------------------------------------------------------------------- + + /// sha256 over `issuer` (its 8-byte value) then `statement_digest`: the key of the `bystatement` + /// index. The ONE computation, shared with issuers that check for a duplicate before `request`. + static checksum256 statement_key_of(name issuer, const checksum256& statement_digest) { + std::array buf{}; + const uint64_t issuer_value = issuer.value; + const auto digest = statement_digest.extract_as_byte_array(); + std::memcpy(buf.data(), &issuer_value, sizeof(issuer_value)); + std::memcpy(buf.data() + sizeof(issuer_value), digest.data(), digest.size()); + return sha256(buf.data(), buf.size()); + } + + /// Key of `requests`. + struct request_key { + uint64_t id; ///< request id, from `bondcounters` + SYSLIB_SERIALIZE(request_key, (id)) + }; + + /// One underwriting request. + struct [[sysio::table("requests")]] request_row { + uint64_t id; ///< request id + name issuer; ///< account that registered the statement + name schema; ///< how `statement` is to be read + std::vector statement; ///< the statement's bytes, at most MAX_STATEMENT_BYTES + checksum256 statement_digest; ///< sha256 over schema then statement + sysio::slug_name token_code; ///< registry code of the bond token + uint64_t covered; ///< base units the underwriters must bond in total + uint64_t bonded; ///< base units bonded so far; never above `covered` + uint64_t bounty; ///< base units of bounty in escrow + uint32_t window_sec; ///< challenge window after full bonding, in seconds + request_state state; ///< lifecycle state + time_point created_at; ///< when `request` ran + time_point bonded_at; ///< when bonded reached covered; zero until then + uint64_t hold_bond; ///< 0 until `hold` + name hold_beneficiary; ///< who a forfeit pays after a hold; empty until `hold` + time_point held_at; ///< when `hold` ran; zero until then + time_point resolved_at; ///< when the request became terminal; zero until then + /// `sysio.liq`'s yield index of the token when `rslvinvalid` ruled the request; 0 otherwise + /// and for WIRE. A forfeited bond became the issuer's at that ruling, so its position is + /// settled at this index, never later; what the forfeit earns while it waits for the issuer's + /// claim is slack. Returned bonds and escrows are still their owners' and settle at the live + /// index when claimed. + uint128_t resolved_index; + /// Base units the issuer may claim as the forfeit of an INVALID request: the bonded amount + /// from `rslvinvalid` until the issuer's `claim` pays it in one transfer, then 0. 0 in every + /// other state. + uint64_t forfeit_pending; + + bool outcome_acknowledged = true; ///< false for durable requests until the issuer acknowledges + + /// The key of the `bystatement` index, which admits one request per issuer and statement. + checksum256 by_statement() const { return statement_key_of(issuer, statement_digest); } + + SYSLIB_SERIALIZE(request_row, (id)(issuer)(schema)(statement)(statement_digest)(token_code)(covered)(bonded) + (bounty)(window_sec)(state)(created_at)(bonded_at)(hold_bond)(hold_beneficiary)(held_at) + (resolved_at)(resolved_index)(forfeit_pending)(outcome_acknowledged)) + }; + + /// Secondary index of `requests` on `request_row::by_statement` -- the one declaration of its name. + static constexpr name STATEMENT_INDEX = "bystatement"_n; + + /// Requests by id, with the issuer-and-statement index. + using requests_t = kv::table<"requests"_n, request_key, request_row, + kv::index>>; + + /// Resolve the stable issuer-and-statement identity without exposing index layout to clients. + static std::optional find_request(name code, name issuer, name schema, + const std::vector& statement) { + requests_t requests(code); + const auto index = requests.get_index(); + const auto it = index.find(statement_key_of(issuer, statement_digest_of(schema, statement))); + return it == index.end() ? std::nullopt : std::optional{*it}; + } + + /// Shared admission checks. Empty means admissible; the refusal is safe for intake to report + /// without throwing. Transfer authority and available bounty remain the caller's responsibility. + static std::string_view request_refusal(name code, name issuer, name schema, + const std::vector& statement, sysio::slug_name token_code, + uint64_t covered, uint64_t bounty, uint32_t window_sec) { + if (statement.size() > MAX_STATEMENT_BYTES) return bond_validation::statement_len_msg; + const auto token = opp::custody::resolve_depot_native_token(LIQ_ACCOUNT, TOKEN_ACCOUNT, token_code); + if (!token) return bond_validation::unsupported_token_msg; + const auto increment = increment_of(token->sym); + if (!increment) return bond_validation::precision_msg; + if (covered == 0 || covered % *increment != 0) + return bond_validation::bond_increment_msg; + if (covered > static_cast(asset::max_amount) || bounty > static_cast(asset::max_amount)) + return bond_validation::amount_range_msg; + if (window_sec == 0) return bond_validation::window_msg; + if (find_request(code, issuer, schema, statement)) return bond_validation::duplicate_msg; + return {}; + } + + /// Key of `bonds`. + struct bond_key { + uint64_t request_id; ///< the request bonded + name underwriter; ///< the bonding account + SYSLIB_SERIALIZE(bond_key, (request_id)(underwriter)) + }; + + /// One underwriter's bond on one request. + struct [[sysio::table("bonds")]] bond_row { + uint64_t request_id; ///< the request bonded + name underwriter; ///< the bonding account + uint64_t amount; ///< base units bonded + opp::shadow::custody::position yield; ///< shadow yield of `amount`; untouched for WIRE + /// Set once `claim` paid this bond out, or paid the WIRE a forfeited bond earned into + /// `sysio.liq`. + bool paid = false; + SYSLIB_SERIALIZE(bond_row, (request_id)(underwriter)(amount)(yield)(paid)) + }; + + /// Bonds by request and underwriter. + using bonds_t = kv::table<"bonds"_n, bond_key, bond_row>; + + /// Key of `escrows`. + struct escrow_key { + uint64_t request_id; ///< the request the escrow belongs to + uint8_t kind; ///< magic_enum::enum_integer of the escrow_kind; the row keeps the enum + SYSLIB_SERIALIZE(escrow_key, (request_id)(kind)) + }; + + /// Tokens the issuer placed in escrow for one request. + struct [[sysio::table("escrows")]] escrow_row { + uint64_t request_id; ///< the request the escrow belongs to + escrow_kind kind; ///< bounty or hold bond + name funder; ///< account the tokens came from + /// Base units held: lowered as a claim pays a share or the `payout` out, and what is left + /// after every share and the payout are paid is the division remainder, which stays in the + /// contract as slack. + uint64_t amount; + opp::shadow::custody::position yield; ///< shadow yield of `amount`; untouched for WIRE + /// Set by the funder's `claim` that pays it the yield the escrow earned once no bond is owed a + /// share of the escrow any more (every bond row of an APPROVED or VALID request is paid; an + /// INVALID request pays no shares) and the payout is claimed: what is left, the division + /// remainder, is slack, even when not zero. Until then the funder may claim again as what is + /// still held keeps earning. + bool paid = false; + /// Account a ruling awarded `payout` of this escrow to: `sysio` or the issuer on VALID, the + /// hold beneficiary or the issuer on INVALID. Empty until a ruling awards any. + name payee; + /// Base units of `amount` `payee` may claim; 0 until a ruling awards it and once claimed. + uint64_t payout = 0; + SYSLIB_SERIALIZE(escrow_row, (request_id)(kind)(funder)(amount)(yield)(paid)(payee)(payout)) + }; + + /// Escrows by request and kind. + using escrows_t = kv::table<"escrows"_n, escrow_key, escrow_row>; + + /// The key of request `request_id`'s escrow of `kind`. + static escrow_key escrow_key_of(uint64_t request_id, escrow_kind kind) { + return escrow_key{.request_id = request_id, .kind = magic_enum::enum_integer(kind)}; + } + + /// Principal still awarded to an account by settlement, excluding its underwriting stake and + /// escrow yield. Clients need not interpret escrow rows or their paid flags. + static uint64_t settlement_due(name code, uint64_t request_id, name account) { + requests_t requests(code); + const auto req = requests.try_get(request_key{request_id}); + if (!req) return 0; + uint64_t due = req->issuer == account ? req->forfeit_pending : 0; + escrows_t escrows(code); + for (const auto kind : magic_enum::enum_values()) { + const auto escrow = escrows.try_get(escrow_key_of(request_id, kind)); + if (escrow && escrow->payee == account) due += escrow->payout; + } + return due; + } + + /// Key of `yieldpool`. + struct pool_key { + uint64_t token_code; ///< the shadow token's registry code value + SYSLIB_SERIALIZE(pool_key, (token_code)) + }; + + /// The shadow-custody solvency pool of one shadow token. + struct [[sysio::table("yieldpool")]] pool_row { + sysio::slug_name token_code; ///< registry code of the shadow token + opp::shadow::custody::yield_pool pool; ///< WIRE pulled from `sysio.liq` and credited out + SYSLIB_SERIALIZE(pool_row, (token_code)(pool)) + }; + + /// Yield pools by token code. + using yieldpools_t = kv::table<"yieldpool"_n, pool_key, pool_row>; + + /// Governance-set configuration. + struct [[sysio::table("bondconfig")]] bond_config { + uint32_t hold_bps = DEFAULT_HOLD_BPS; ///< hold bond, in basis points of the covered amount + SYSLIB_SERIALIZE(bond_config, (hold_bps)) + }; + + /// The configuration singleton. + using bondconfig_t = kv::global<"bondconfig"_n, bond_config>; + + /// Id allocation. + struct [[sysio::table("bondcounters")]] bond_counters { + uint64_t next_request_id = 1; ///< id the next `request` takes + SYSLIB_SERIALIZE(bond_counters, (next_request_id)) + }; + + /// The counters singleton. + using bondcounters_t = kv::global<"bondcounters"_n, bond_counters>; + }; + +} // namespace sysio diff --git a/contracts/sysio.bond/src/sysio.bond.cpp b/contracts/sysio.bond/src/sysio.bond.cpp new file mode 100644 index 0000000000..dd5e992142 --- /dev/null +++ b/contracts/sysio.bond/src/sysio.bond.cpp @@ -0,0 +1,773 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace sysio { + +namespace { + +constexpr name settle_action = "settle"_n; +constexpr auto request_is_not_resolved_msg = "request is not resolved"; + + +using opp::custody::depot_native_token; +using opp::custody::pull_depot_native; +using opp::custody::transfer_depot_native; +using opp::shadow::u128; + +namespace custody = opp::shadow::custody; + +// The contract's own rows -- `bondconfig`, `bondcounters`, `yieldpool` -- bill the sysio RAM pool, +// not this contract account (privileged-contract model, as sysio.opreg uses): the account stays +// finite at code+abi size. A request's rows bill the account that created them (`payer_for`): the +// issuer its `requests` and `escrows` rows, an underwriter its `bonds` row. Later writes by any signer +// keep the row's payer (`same_payer`), which a privileged contract may do. +constexpr name ram_payer = bond::SYSTEM_ACCOUNT; + +/// Permission this contract sends its own `sysio.liq::addyield` under. +constexpr name active_permission = "active"_n; + +/// Memo on the transfer that pulls a `request`'s bounty into escrow. +constexpr std::string_view request_bounty_memo = "sysio.bond::request bounty"; + +/// Memo on the transfer that pulls an `addbounty` raise into escrow. +constexpr std::string_view addbounty_memo = "sysio.bond::addbounty bounty"; + +/// Memo on the transfer that pulls an `accept` bond into custody. +constexpr std::string_view accept_memo = "sysio.bond::accept"; + +/// Memo on the transfer that pulls a `hold` bond into escrow. +constexpr std::string_view hold_memo = "sysio.bond::hold"; + +/// Memo on the ONE transfer `claim` pays the issuer an INVALID request's bonds in. Part of the +/// interface: an issuer contract recognizes the forfeit by it. +constexpr std::string_view forfeit_memo = "sysio.bond::forfeit"; + +/// Memo on the transfer that pays a claim in the request's token: a bond and its shares, and what a +/// ruling awarded the account out of the escrows. +constexpr std::string_view claim_memo = "sysio.bond::claim"; + +/// Memo on the transfer that pays the WIRE a claimed bond or escrow earned. +constexpr std::string_view claim_yield_memo = "sysio.bond::claim yield"; + +/// Message of the `check` a covered or bonded amount off the token's increment raises. +using bond_validation::bond_increment_msg; + +/// Message of the `check` a token with fewer than BOND_INCREMENT_DECIMALS decimals raises. +using bond_validation::precision_msg; + +/// Message of the `check` a token code that is neither WIRE nor a shadow LIQ symbol raises. +using bond_validation::unsupported_token_msg; + +/// Message of the `check` a statement longer than MAX_STATEMENT_BYTES raises. +using bond_validation::statement_len_msg; + +/// Message of the `check` a second request for the same issuer and statement raises. +using bond_validation::duplicate_msg; + +/// Message of the `check` a zero challenge window raises. +using bond_validation::window_msg; + +/// Message of the `check` `setconfig` raises for a hold percentage of zero or above one whole. +constexpr std::string_view hold_bps_range_msg = "hold_bps must be positive and at most the basis-point denominator"; + +/// Message of the `check` a zero amount raises where a positive one is required. +constexpr std::string_view amount_positive_msg = "amount must be positive"; + +/// Message of the `check` an amount no `asset` can carry raises. +using bond_validation::amount_range_msg; + +/// Message of the `check` an action naming an unknown request raises. +constexpr std::string_view request_missing_msg = "request not found"; + +/// Message of the `check` an action that needs a live request raises on a terminal one. +constexpr std::string_view terminal_msg = "request is already resolved"; + +/// Message of the `check` `claim` raises when the request owes the account nothing, or is not +/// terminal yet. +constexpr std::string_view nothing_msg = "nothing to claim"; + +/// Message of the `check` `sweepyield` raises for WIRE, which is not a shadow token. +constexpr std::string_view wire_no_yield_msg = "WIRE collateral earns no shadow yield"; + +/// Message of the `check` `accept` raises on a request in any state but OPEN and BONDED. +constexpr std::string_view not_accepting_msg = "request is not accepting bonds"; + +/// Message of the `check` `accept` raises once bonded has reached covered. +constexpr std::string_view fully_bonded_msg = "request is fully bonded"; + +/// Message of the `check` `approve` raises on a request in any state but BONDED. +constexpr std::string_view not_bonded_msg = "request is not bonded"; + +/// Message of the `check` `approve` raises while the challenge window still runs. +constexpr std::string_view window_open_msg = "challenge window has not passed"; + +/// Message of the `check` `hold` raises for a beneficiary that is not an account. +constexpr std::string_view beneficiary_msg = "hold beneficiary is not an account"; + +/// Message of the `check` `hold` raises for this contract named as the beneficiary. +constexpr std::string_view beneficiary_self_msg = "hold beneficiary cannot be the contract"; + +/// Message of the `check` `hold` raises on a request in any state but OPEN and BONDED. +constexpr std::string_view hold_state_msg = "hold is only possible while the request is OPEN or BONDED"; + +/// Largest amount an `asset` carries, as the unsigned base units every row holds. +constexpr uint64_t max_asset_amount = static_cast(asset::max_amount); + +/// The account a row created on behalf of `account` bills: `account` itself, unless it is privileged. +/// A privileged account is a system contract (`sysio.synd` issuing requests for its envelopes), and a +/// system contract has no RAM quota of its own -- it lives on what `sysio.roa` gifts it for its code +/// and abi -- so its rows bill the `sysio` RAM pool, as every system contract's own rows do. The ONE +/// place that decides the payer of a request, escrow or bond row. +name payer_for(name account) { + return is_privileged(account) ? ram_payer : account; +} + +/// Resolve `token_code` against this contract's custody contracts: `sysio.token` for WIRE, +/// `sysio.liq` for a shadow LIQ symbol. `std::nullopt` for any other code. +std::optional resolve_bond_token(sysio::slug_name token_code) { + return opp::custody::resolve_depot_native_token(bond::LIQ_ACCOUNT, bond::TOKEN_ACCOUNT, token_code); +} + +/// True iff `amount` is a positive multiple of `increment`. +bool is_increment_multiple(uint64_t amount, uint64_t increment) { + return amount > 0 && amount % increment == 0; +} + +/// True iff `state` is APPROVED, VALID or INVALID: no action moves a request out of it. +bool is_terminal(bond::request_state state) { + return state == bond::request_state::APPROVED || state == bond::request_state::VALID || + state == bond::request_state::INVALID; +} + +/// True iff a sub-holder balance of `token_code` earns shadow yield: any bond token but WIRE. The +/// ONE place that decides it; every yield path asks here. +bool earns_shadow_yield(sysio::slug_name token_code) { + return token_code != opp::wire::token_code; +} + +/// Credit `amount` to a sub-holder `balance` of `token_code` that carries `pos`: through the +/// shadow-custody funnel for a shadow token, so the position settles at the old balance first; a +/// plain saturating add for WIRE, which earns nothing. The caller has checked the result stays +/// within the asset range. +void credit_holding(opp::shadow::custody::position& pos, uint64_t& balance, uint64_t amount, + sysio::slug_name token_code, const depot_native_token& custody) { + if (earns_shadow_yield(token_code)) { + opp::shadow::custody::settle_and_adjust(pos, balance, static_cast(amount), bond::LIQ_ACCOUNT, + custody.sym.code()); + } else { + balance = opp::safe::add_sat_u64(balance, amount); + } +} + +/// The custody of WIRE, the token every yield payout is made in. +depot_native_token wire_custody() { + return depot_native_token{.contract = bond::TOKEN_ACCOUNT, .sym = opp::wire::asset_symbol}; +} + +/// `sysio.liq`'s live yield index for the shadow token of `custody`; 0 for WIRE, which earns +/// nothing. +u128 live_index_of(sysio::slug_name token_code, const depot_native_token& custody) { + return earns_shadow_yield(token_code) ? custody::live_index(bond::LIQ_ACCOUNT, custody.sym.code()) : u128{0}; +} + +/// Lower a sub-holder `balance` of `token_code` by `amount`: for a shadow token its position is +/// settled at `index` first, so it keeps what the old balance earned up to that index; for WIRE a +/// plain subtraction. Clamps at zero; callers never debit more than the balance holds. +void debit_holding(custody::position& pos, uint64_t& balance, uint64_t amount, sysio::slug_name token_code, + u128 index) { + if (earns_shadow_yield(token_code)) { + custody::settle_and_adjust(pos, balance, -static_cast(amount), index); + } else { + balance = balance > amount ? balance - amount : 0; + } +} + +/// Lower request `request_id`'s escrow of `kind` by `amount`, settled at `index`. No row -- no bounty +/// was posted, or a hold bond rounded to zero -- or a zero amount changes nothing. +void debit_escrow(name self, uint64_t request_id, bond::escrow_kind kind, uint64_t amount, + sysio::slug_name token_code, u128 index) { + if (amount == 0) return; + bond::escrows_t escrows(self); + const bond::escrow_key key = bond::escrow_key_of(request_id, kind); + auto row = escrows.try_get(key); + if (!row) return; + debit_holding(row->yield, row->amount, amount, token_code, index); + escrows.set(same_payer, key, *row); +} + +/// Record that a ruling awards `payout` base units of request `request_id`'s escrow of `kind` to +/// `payee`, to claim. No row, or a zero payout, records nothing. +void award_escrow(name self, uint64_t request_id, bond::escrow_kind kind, name payee, uint64_t payout) { + if (payout == 0) return; + bond::escrows_t escrows(self); + const bond::escrow_key key = bond::escrow_key_of(request_id, kind); + auto row = escrows.try_get(key); + if (!row) return; + row->payee = payee; + row->payout = payout; + escrows.set(same_payer, key, *row); +} + +/// Add `amount` to the running total `total` of one claim transfer, refusing a total no `asset` can +/// carry. +void add_to_transfer(uint64_t& total, uint64_t amount) { + check(amount <= max_asset_amount - total, amount_range_msg); + total += amount; +} + +/// The WIRE a position of `balance` units is owed once settled at `index`, without changing it; 0 +/// for WIRE, which earns nothing. +uint64_t owed_at(custody::position pos, uint64_t balance, sysio::slug_name token_code, u128 index) { + if (!earns_shadow_yield(token_code)) return 0; + custody::settle(pos, balance, index); + return pos.owed_wire; +} + +/// True iff the shadow token `token_code` has a positive supply on `sysio.liq`: `addyield` refuses a +/// symbol without one. +bool has_supply(sysio::slug_name token_code) { + const auto stat = liq::find_stat_by_token(bond::LIQ_ACCOUNT, token_code); + return stat.has_value() && stat->supply.amount > 0; +} + +/// The yield pool row of `token_code`, or a fresh one before its first pull. +bond::pool_row pool_of(name self, sysio::slug_name token_code) { + bond::yieldpools_t pools(self); + return pools.try_get(bond::pool_key{token_code.value}) + .value_or(bond::pool_row{.token_code = token_code, .pool = custody::yield_pool{}}); +} + +/// True iff no bond of terminal request `req` is still owed a share of its escrows: an INVALID +/// request pays the underwriters no share, and on an APPROVED or VALID one every bond row is paid. +bool shares_settled(name self, const bond::request_row& req) { + if (req.state == bond::request_state::INVALID) return true; + bond::bonds_t bonds(self); + for (auto it = bonds.lower_bound(bond::bond_key{.request_id = req.id, .underwriter = name{}}); + it != bonds.end() && it->request_id == req.id; ++it) { + if (!it->paid) return false; + } + return true; +} + +/// True iff nothing is left to claim on terminal request `req`, `live_index` being the token's live +/// yield index. The forfeit must be claimed, and every bond row settled: a returned bond (APPROVED, +/// VALID) is owed its principal until claimed, a forfeited bond only the WIRE it earned up to +/// `resolved_index`. An escrow must have its payout claimed; after that it holds at most the division +/// remainder, and it is done once paid or owed nothing now. What that remainder earns after the prune +/// is slack. +bool fully_paid(name self, const bond::request_row& req, u128 live_index) { + if (req.forfeit_pending > 0) return false; + const bool returns_bonds = req.state != bond::request_state::INVALID; + bond::bonds_t bonds(self); + for (auto it = bonds.lower_bound(bond::bond_key{.request_id = req.id, .underwriter = name{}}); + it != bonds.end() && it->request_id == req.id; ++it) { + if (it->yield.owed_wire > 0) return false; + if (it->paid) continue; + if (returns_bonds || owed_at(it->yield, it->amount, req.token_code, req.resolved_index) > 0) return false; + } + bond::escrows_t escrows(self); + for (const auto kind : magic_enum::enum_values()) { + const auto row = escrows.try_get(bond::escrow_key_of(req.id, kind)); + if (!row) continue; + if (row->payout > 0 || row->yield.owed_wire > 0) return false; + if (!row->paid && owed_at(row->yield, row->amount, req.token_code, live_index) > 0) return false; + } + return true; +} + +/// Place `amount` of `token_code` from `funder`, the signing issuer, in request `request_id`'s escrow +/// of `kind`: create the row at balance 0 or raise the existing one through `credit_holding`, then +/// pull the tokens. The row bills `payer_for(funder)`. +void escrow_tokens(name self, uint64_t request_id, bond::escrow_kind kind, name funder, uint64_t amount, + sysio::slug_name token_code, const depot_native_token& custody, std::string_view memo) { + bond::escrows_t escrows(self); + const bond::escrow_key key = bond::escrow_key_of(request_id, kind); + bond::escrow_row row = escrows.try_get(key).value_or( + bond::escrow_row{.request_id = request_id, .kind = kind, .funder = funder, .amount = 0}); + check(amount <= max_asset_amount - row.amount, amount_range_msg); + credit_holding(row.yield, row.amount, amount, token_code, custody); + escrows.set(payer_for(funder), key, row); + pull_depot_native(self, funder, custody, amount, memo); +} + +} // anonymous namespace + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +void bond::setconfig(uint32_t hold_bps) { + require_auth(SYSTEM_ACCOUNT); + check(hold_bps > 0 && hold_bps <= BPS_DENOMINATOR, hold_bps_range_msg); + bondconfig_t config(get_self()); + bond_config cfg = config.get_or_default(bond_config{}); + cfg.hold_bps = hold_bps; + config.set(cfg, ram_payer); +} + +// --------------------------------------------------------------------------- +// Requests +// --------------------------------------------------------------------------- + +void bond::request(name issuer, name schema, std::vector statement, sysio::slug_name token_code, + uint64_t covered, uint64_t bounty, uint32_t window_sec) { + require_auth(issuer); + const auto refusal = request_refusal(get_self(), issuer, schema, statement, token_code, covered, bounty, window_sec); + check(refusal.empty(), refusal); + const auto custody = resolve_bond_token(token_code); + + request_row row{ + .id = 0, + .issuer = issuer, + .schema = schema, + .statement = std::move(statement), + .statement_digest = checksum256{}, + .token_code = token_code, + .covered = covered, + .bonded = 0, + .bounty = bounty, + .window_sec = window_sec, + .state = request_state::OPEN, + .created_at = current_time_point(), + .bonded_at = time_point{}, + .hold_bond = 0, + .hold_beneficiary = name{}, + .held_at = time_point{}, + .resolved_at = time_point{}, + .resolved_index = 0, + .forfeit_pending = 0, + }; + row.statement_digest = statement_digest_of(schema, row.statement); + + requests_t requests(get_self()); + const auto by_statement = requests.get_index(); + check(by_statement.find(row.by_statement()) == by_statement.end(), duplicate_msg); + + bondcounters_t counters(get_self()); + bond_counters c = counters.get_or_default(bond_counters{}); + row.id = c.next_request_id++; + counters.set(c, ram_payer); + + requests.emplace(payer_for(issuer), request_key{row.id}, row); + + if (bounty > 0) { + escrow_tokens(get_self(), row.id, escrow_kind::BOUNTY, issuer, bounty, token_code, *custody, + request_bounty_memo); + } +} + +void bond::requestkeep(name issuer, name schema, std::vector statement, sysio::slug_name token_code, + uint64_t covered, uint64_t bounty, uint32_t window_sec) { + request(issuer, schema, statement, token_code, covered, bounty, window_sec); + const auto row = find_request(get_self(), issuer, schema, statement); + requests_t(get_self()).modify(same_payer, request_key{row->id}, [](request_row& r) { + r.outcome_acknowledged = false; + }); +} + +void bond::ack(uint64_t request_id) { + requests_t requests(get_self()); + const auto row = requests.try_get(request_key{request_id}); + check(row.has_value(), request_missing_msg); + require_auth(row->issuer); + check(is_terminal(row->state), request_is_not_resolved_msg); + requests.modify(same_payer, request_key{request_id}, [](request_row& r) { r.outcome_acknowledged = true; }); +} + +void bond::addbounty(uint64_t request_id, uint64_t amount) { + requests_t requests(get_self()); + const auto req = requests.try_get(request_key{request_id}); + check(req.has_value(), request_missing_msg); + require_auth(req->issuer); + check(amount > 0, amount_positive_msg); + check(!is_terminal(req->state), terminal_msg); + check(amount <= max_asset_amount - req->bounty, amount_range_msg); + + const auto custody = resolve_bond_token(req->token_code); + check(custody.has_value(), unsupported_token_msg); + + requests.modify(same_payer, request_key{request_id}, [&](request_row& r) { r.bounty += amount; }); + escrow_tokens(get_self(), request_id, escrow_kind::BOUNTY, req->issuer, amount, req->token_code, *custody, + addbounty_memo); +} + +void bond::accept(name underwriter, uint64_t request_id, uint64_t amount) { + require_auth(underwriter); + requests_t requests(get_self()); + const auto req = requests.try_get(request_key{request_id}); + check(req.has_value(), request_missing_msg); + // A BONDED request is exactly one whose bonded amount reached covered; every other state but + // OPEN has left the bonding phase. + check(req->state != request_state::BONDED, fully_bonded_msg); + check(req->state == request_state::OPEN, not_accepting_msg); + + const auto custody = resolve_bond_token(req->token_code); + check(custody.has_value(), unsupported_token_msg); + const auto increment = increment_of(custody->sym); + check(increment.has_value(), precision_msg); + check(is_increment_multiple(amount, *increment), bond_increment_msg); + + // covered and bonded are both multiples of the increment, so the remainder is too: the reduced + // amount stays on the increment. + const uint64_t remainder = req->covered - req->bonded; + check(remainder > 0, fully_bonded_msg); + const uint64_t bonded_amount = std::min(amount, remainder); + + // bonded_amount <= covered <= max_asset_amount, and a row never exceeds its request's bonded, + // so the credited balance stays within the asset range. + bonds_t bonds(get_self()); + const bond_key key{.request_id = request_id, .underwriter = underwriter}; + bond_row row = bonds.try_get(key).value_or( + bond_row{.request_id = request_id, .underwriter = underwriter, .amount = 0}); + credit_holding(row.yield, row.amount, bonded_amount, req->token_code, *custody); + bonds.set(payer_for(underwriter), key, row); + + const bool fully_bonded = bonded_amount == remainder; + requests.modify(same_payer, request_key{request_id}, [&](request_row& r) { + r.bonded += bonded_amount; + if (fully_bonded) { + r.state = request_state::BONDED; + r.bonded_at = current_time_point(); + } + }); + + pull_depot_native(get_self(), underwriter, *custody, bonded_amount, accept_memo); +} + +void bond::hold(uint64_t request_id, name beneficiary) { + requests_t requests(get_self()); + const auto req = requests.try_get(request_key{request_id}); + check(req.has_value(), request_missing_msg); + require_auth(req->issuer); + // A HELD request fails here too, so a second hold is refused before it charges anything. + check(req->state == request_state::OPEN || req->state == request_state::BONDED, hold_state_msg); + check(is_account(beneficiary), beneficiary_msg); + check(beneficiary != get_self(), beneficiary_self_msg); + + const auto custody = resolve_bond_token(req->token_code); + check(custody.has_value(), unsupported_token_msg); + + // With no config row the default carries DEFAULT_HOLD_BPS. hold_bps <= BPS_DENOMINATOR, so the + // bond is at most `covered` and fits the asset range. + bondconfig_t config(get_self()); + const uint32_t hold_bps = config.get_or_default(bond_config{}).hold_bps; + const uint64_t hold_bond = hold_bond_of(req->covered, hold_bps); + + requests.modify(same_payer, request_key{request_id}, [&](request_row& r) { + r.hold_bond = hold_bond; + r.hold_beneficiary = beneficiary; + r.held_at = current_time_point(); + r.state = request_state::HELD; + }); + // A hold bond rounds to zero on a covered amount below BPS_DENOMINATOR / hold_bps base units: + // there is nothing to escrow or pull, and no HOLD_BOND row exists. + if (hold_bond > 0) { + escrow_tokens(get_self(), request_id, escrow_kind::HOLD_BOND, req->issuer, hold_bond, req->token_code, + *custody, hold_memo); + } +} + +void bond::approve(uint64_t request_id) { + requests_t requests(get_self()); + const auto req = requests.try_get(request_key{request_id}); + check(req.has_value(), request_missing_msg); + check(req->state == request_state::BONDED, not_bonded_msg); + const time_point now = current_time_point(); + check(now >= req->bonded_at + seconds(req->window_sec), window_open_msg); + + requests.modify(same_payer, request_key{request_id}, [&](request_row& r) { + r.state = request_state::APPROVED; + r.resolved_at = now; + }); +} + +// rslvvalid - the statement is true. Every bond is returned through `claim` with its stake's share +// of the bounty and of the hold bond. The shares no stake covers are recorded on the escrows for +// their payees to claim: the bounty's for `sysio`, the hold bond's for the issuer who posted it. +// Nothing is sent and no one is notified, so no party can make the ruling fail. +void bond::rslvvalid(uint64_t request_id) { + require_auth(SYSTEM_ACCOUNT); + requests_t requests(get_self()); + const auto req = requests.try_get(request_key{request_id}); + check(req.has_value(), request_missing_msg); + check(!is_terminal(req->state), terminal_msg); + + requests.modify(same_payer, request_key{request_id}, [&](request_row& r) { + r.state = request_state::VALID; + r.resolved_at = current_time_point(); + }); + + const uint64_t unbonded = req->covered - req->bonded; + award_escrow(get_self(), request_id, escrow_kind::BOUNTY, SYSTEM_ACCOUNT, + bond::share_of(unbonded, req->bounty, req->covered)); + award_escrow(get_self(), request_id, escrow_kind::HOLD_BOND, req->issuer, + bond::share_of(unbonded, req->hold_bond, req->covered)); +} + +// rslvinvalid - the statement is false. Every bond becomes the issuer's forfeit, recorded on the +// request as ONE amount the issuer claims in one transfer, whatever the number of underwriters. +// After a hold the challenger named as beneficiary is awarded the hold bond and the bounty; without +// one the bounty is awarded back to the issuer. Nothing is sent and no one is notified. The bonds' +// positions stop at the ruling's index: the forfeit is no longer theirs. +void bond::rslvinvalid(uint64_t request_id) { + require_auth(SYSTEM_ACCOUNT); + requests_t requests(get_self()); + const auto req = requests.try_get(request_key{request_id}); + check(req.has_value(), request_missing_msg); + check(!is_terminal(req->state), terminal_msg); + const auto custody = resolve_bond_token(req->token_code); + check(custody.has_value(), unsupported_token_msg); + + const u128 index = live_index_of(req->token_code, *custody); + const bool held = req->state == request_state::HELD; + requests.modify(same_payer, request_key{request_id}, [&](request_row& r) { + r.state = request_state::INVALID; + r.resolved_at = current_time_point(); + r.resolved_index = index; + r.forfeit_pending = r.bonded; + }); + + const name bounty_payee = held ? req->hold_beneficiary : req->issuer; + award_escrow(get_self(), request_id, escrow_kind::BOUNTY, bounty_payee, req->bounty); + if (held) award_escrow(get_self(), request_id, escrow_kind::HOLD_BOND, req->hold_beneficiary, req->hold_bond); +} + +// --------------------------------------------------------------------------- +// Yield and payouts +// --------------------------------------------------------------------------- + +// sweepyield - pull the WIRE sysio.liq owes this contract's holder row into the token's pool. +// +// Permissionless: it only moves WIRE the contract is already owed into the contract, and changes no +// position's entitlement -- only what the pool can cover. `custody::pull` records exactly what the +// claim it sends pays, or with nothing owed does neither. +void bond::sweepyield(sysio::slug_name token_code) { + const auto custody_token = resolve_bond_token(token_code); + check(custody_token.has_value(), unsupported_token_msg); + check(earns_shadow_yield(token_code), wire_no_yield_msg); + + pool_row pool = pool_of(get_self(), token_code); + if (custody::pull(pool.pool, LIQ_ACCOUNT, get_self(), custody_token->sym.code()) > 0) { + yieldpools_t(get_self()).set(ram_payer, pool_key{token_code.value}, pool); + } +} + +// claim - pay `account` what terminal request `request_id` owes it. +// +// Each account settles independently. LIQ credits invoke no recipient code. Earned WIRE is +// recorded into a backed claim balance and withdrawn separately; WIRE-denominated requests keep +// ordinary transfer claims. +// +// A returned bond and an escrow are still held here, so they settle at sysio.liq's LIVE index: what +// they earn up to the claim is their owner's. A forfeited bond settles at the request's +// `resolved_index`. The pool is pulled first, before anything else this action queues touches +// sysio.liq (the custody library's obligation 3), so what the rows take is covered by WIRE that +// arrives ahead of the payouts queued after it. The rows are marked paid before any transfer is +// queued, so a transfer-notify handler that re-enters `claim` finds nothing left. What the pool +// cannot cover stays owed on the original row and prevents pruning until paid. +// +// An escrow keeps earning while it holds shares or a payout not yet claimed, so its funder may claim +// more than once; the funder's claim marks it paid once no bond is owed a share of it and its payout +// is claimed, whatever division remainder it still holds. +void bond::claim(uint64_t request_id, name account) { + // Every payout leaves custody: deferred, by refusal, until the cord clears. Nothing a ruling awards is + // lost -- it stays recorded until claimed. + andon::check_clear(andon::ANDON_ACCOUNT); + requests_t requests(get_self()); + const auto req = requests.try_get(request_key{request_id}); + check(req.has_value(), request_missing_msg); + check(is_terminal(req->state), nothing_msg); + const auto custody_token = resolve_bond_token(req->token_code); + check(custody_token.has_value(), unsupported_token_msg); + + const bool shadow = earns_shadow_yield(req->token_code); + const bool forfeit = req->state == request_state::INVALID; + const u128 live = live_index_of(req->token_code, *custody_token); + const symbol_code sym = custody_token->sym.code(); + pool_row pool = pool_of(get_self(), req->token_code); + if (shadow) custody::pull(pool.pool, LIQ_ACCOUNT, get_self(), sym); + + bool found = false; + uint64_t forfeit_out = 0; // an INVALID request's bonds, to its issuer + uint64_t token_out = 0; // bond, shares and escrow payouts, in the request's token + uint64_t wire_out = 0; // yield of the bond and of escrows the account funded + uint64_t forfeit_wire = 0; // yield of a forfeited bond, paid into sysio.liq + + if (forfeit && account == req->issuer && req->forfeit_pending > 0) { + forfeit_out = req->forfeit_pending; + requests.modify(same_payer, request_key{request_id}, [](request_row& r) { r.forfeit_pending = 0; }); + found = true; + } + + bonds_t bonds(get_self()); + const bond_key bkey{.request_id = request_id, .underwriter = account}; + if (auto row = bonds.try_get(bkey); row && (!row->paid || row->yield.owed_wire > 0)) { + uint64_t earned = 0; + bool earns = false; + if (shadow) { + if (!row->paid) custody::settle(row->yield, row->amount, forfeit ? req->resolved_index : live); + earns = row->yield.owed_wire > 0; + earned = custody::take(row->yield, pool.pool); + } + bool paid = false; + if (row->paid) { + if (forfeit) forfeit_wire = earned; + else wire_out = earned; + paid = earned > 0; + } else if (!forfeit) { + const uint64_t bounty_share = bond::share_of(row->amount, req->bounty, req->covered); + const uint64_t hold_bond_share = bond::share_of(row->amount, req->hold_bond, req->covered); + add_to_transfer(token_out, row->amount); + add_to_transfer(token_out, bounty_share); + add_to_transfer(token_out, hold_bond_share); + wire_out = earned; + debit_escrow(get_self(), request_id, escrow_kind::BOUNTY, bounty_share, req->token_code, live); + debit_escrow(get_self(), request_id, escrow_kind::HOLD_BOND, hold_bond_share, req->token_code, live); + paid = true; + } else if (earns) { + forfeit_wire = earned; + paid = true; + } + if (paid) { + row->paid = true; + bonds.set(same_payer, bkey, *row); + found = true; + } + } + + // A ruling's award out of an escrow is paid to its payee; an escrow owes its funder only the + // yield it earned, nothing for WIRE. Read after the bond above, so its own claim counts toward + // `shares_settled`. + escrows_t escrows(get_self()); + for (const auto kind : magic_enum::enum_values()) { + const escrow_key ekey = bond::escrow_key_of(request_id, kind); + auto row = escrows.try_get(ekey); + if (!row) continue; + bool changed = false; + if (row->payee == account && row->payout > 0) { + add_to_transfer(token_out, row->payout); + debit_holding(row->yield, row->amount, row->payout, req->token_code, live); + row->payout = 0; + changed = true; + } + if (shadow && (!row->paid || row->yield.owed_wire > 0) && row->funder == account) { + if (!row->paid) custody::settle(row->yield, row->amount, live); + if (row->yield.owed_wire > 0) { + add_to_transfer(wire_out, custody::take(row->yield, pool.pool)); + row->paid = row->payout == 0 && shares_settled(get_self(), *req); + changed = true; + } + } + if (changed) { + escrows.set(same_payer, ekey, *row); + found = true; + } + } + check(found, nothing_msg); + if (shadow) yieldpools_t(get_self()).set(ram_payer, pool_key{req->token_code.value}, pool); + + const auto pay = [&](uint64_t amount, std::string_view memo) { + if (amount == 0) return; + if (shadow) { + action(permission_level{get_self(), active_permission}, LIQ_ACCOUNT, settle_action, + std::make_tuple(get_self(), account, asset{static_cast(amount), custody_token->sym})).send(); + } else { + transfer_depot_native(get_self(), account, *custody_token, amount, memo); + } + }; + pay(forfeit_out, forfeit_memo); + pay(token_out, claim_memo); + if (wire_out > 0) { + wireclaims_t claims(get_self()); + const wire_key key{account}; + auto balance = claims.try_get(key).value_or(wire_claim{account, 0}); + add_to_transfer(balance.amount, wire_out); + claims.set(ram_payer, key, balance); + } + // sysio.liq refuses a distribution to a symbol with no supply: the WIRE then stays in the contract. + if (forfeit_wire > 0 && has_supply(req->token_code)) { + action(permission_level{get_self(), active_permission}, LIQ_ACCOUNT, opp::shadow::ADDYIELD_ACTION, + std::make_tuple(get_self(), asset(static_cast(forfeit_wire), opp::wire::asset_symbol), sym)) + .send(); + } +} + +void bond::claimwire(name account) { + andon::check_clear(andon::ANDON_ACCOUNT); + wireclaims_t claims(get_self()); + const wire_key key{account}; + const auto balance = claims.try_get(key); + check(balance && balance->amount > 0, nothing_msg); + claims.erase(key); + transfer_depot_native(get_self(), account, wire_custody(), balance->amount, claim_yield_memo); +} + +// prune - erase terminal requests nothing is owed on, with their rows, and paid bond rows. +// +// Walks the requests in id order from `from_id` and acts on up to `limit` of them. A request still owed +// a claim, or ruled less than PRUNE_RETENTION_SEC ago, keeps its row -- an issuer that polls the row +// (sysio.bond notifies no one) always sees the ruling -- but its bond rows already paid out are erased: +// a paid row carries nothing any claim or check reads, and an underwriter's RAM must not wait on +// another party's unclaimed payout. A request whose token no longer resolves (so nothing about it can +// be settled) is skipped and left whole. What an erased escrow still holds is the division remainder, +// which stays in the contract. +void bond::prune(uint64_t from_id, uint32_t limit) { + requests_t requests(get_self()); + bonds_t bonds(get_self()); + const time_point now = current_time_point(); + std::vector prunable; + std::vector paid_bonds; + uint32_t acted = 0; + for (auto it = requests.lower_bound(request_key{from_id}); it != requests.end() && acted < limit; ++it) { + if (!is_terminal(it->state)) continue; + const auto custody_token = resolve_bond_token(it->token_code); + if (!custody_token) continue; + const bool retained = now < it->resolved_at + seconds(PRUNE_RETENTION_SEC); + if (!retained && it->outcome_acknowledged && fully_paid(get_self(), *it, live_index_of(it->token_code, *custody_token))) { + prunable.push_back(it->id); + ++acted; + continue; + } + const size_t before = paid_bonds.size(); + for (auto bit = bonds.lower_bound(bond_key{.request_id = it->id, .underwriter = name{}}); + bit != bonds.end() && bit->request_id == it->id; ++bit) { + if (bit->paid && bit->yield.owed_wire == 0) paid_bonds.push_back(bond_key{.request_id = it->id, .underwriter = bit->underwriter}); + } + if (paid_bonds.size() != before) ++acted; + } + + for (const bond_key& key : paid_bonds) bonds.erase(key); + escrows_t escrows(get_self()); + for (const uint64_t id : prunable) { + for (auto it = bonds.lower_bound(bond_key{.request_id = id, .underwriter = name{}}); + it != bonds.end() && it->request_id == id;) { + it = bonds.erase(it); + } + for (const auto kind : magic_enum::enum_values()) { + const escrow_key ekey = bond::escrow_key_of(id, kind); + if (escrows.contains(ekey)) escrows.erase(ekey); + } + requests.erase(request_key{id}); + } +} + +} // namespace sysio diff --git a/contracts/sysio.bond/sysio.bond.abi b/contracts/sysio.bond/sysio.bond.abi new file mode 100644 index 0000000000..e221e9a621 --- /dev/null +++ b/contracts/sysio.bond/sysio.bond.abi @@ -0,0 +1,697 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "accept", + "base": "", + "fields": [ + { + "name": "underwriter", + "type": "name" + }, + { + "name": "request_id", + "type": "uint64" + }, + { + "name": "amount", + "type": "uint64" + } + ] + }, + { + "name": "ack", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + } + ] + }, + { + "name": "addbounty", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + }, + { + "name": "amount", + "type": "uint64" + } + ] + }, + { + "name": "approve", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + } + ] + }, + { + "name": "bond_config", + "base": "", + "fields": [ + { + "name": "hold_bps", + "type": "uint32" + } + ] + }, + { + "name": "bond_counters", + "base": "", + "fields": [ + { + "name": "next_request_id", + "type": "uint64" + } + ] + }, + { + "name": "bond_key", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + }, + { + "name": "underwriter", + "type": "name" + } + ] + }, + { + "name": "bond_row", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + }, + { + "name": "underwriter", + "type": "name" + }, + { + "name": "amount", + "type": "uint64" + }, + { + "name": "yield", + "type": "position" + }, + { + "name": "paid", + "type": "bool" + } + ] + }, + { + "name": "claim", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + }, + { + "name": "account", + "type": "name" + } + ] + }, + { + "name": "claimwire", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + } + ] + }, + { + "name": "escrow_key", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + }, + { + "name": "kind", + "type": "uint8" + } + ] + }, + { + "name": "escrow_row", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + }, + { + "name": "kind", + "type": "escrow_kind" + }, + { + "name": "funder", + "type": "name" + }, + { + "name": "amount", + "type": "uint64" + }, + { + "name": "yield", + "type": "position" + }, + { + "name": "paid", + "type": "bool" + }, + { + "name": "payee", + "type": "name" + }, + { + "name": "payout", + "type": "uint64" + } + ] + }, + { + "name": "hold", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + }, + { + "name": "beneficiary", + "type": "name" + } + ] + }, + { + "name": "pool_key", + "base": "", + "fields": [ + { + "name": "token_code", + "type": "uint64" + } + ] + }, + { + "name": "pool_row", + "base": "", + "fields": [ + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "pool", + "type": "yield_pool" + } + ] + }, + { + "name": "position", + "base": "", + "fields": [ + { + "name": "index_checkpoint", + "type": "uint128" + }, + { + "name": "owed_wire", + "type": "uint64" + } + ] + }, + { + "name": "prune", + "base": "", + "fields": [ + { + "name": "from_id", + "type": "uint64" + }, + { + "name": "limit", + "type": "uint32" + } + ] + }, + { + "name": "request", + "base": "", + "fields": [ + { + "name": "issuer", + "type": "name" + }, + { + "name": "schema", + "type": "name" + }, + { + "name": "statement", + "type": "bytes" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "covered", + "type": "uint64" + }, + { + "name": "bounty", + "type": "uint64" + }, + { + "name": "window_sec", + "type": "uint32" + } + ] + }, + { + "name": "request_key", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + } + ] + }, + { + "name": "request_row", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + }, + { + "name": "issuer", + "type": "name" + }, + { + "name": "schema", + "type": "name" + }, + { + "name": "statement", + "type": "bytes" + }, + { + "name": "statement_digest", + "type": "checksum256" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "covered", + "type": "uint64" + }, + { + "name": "bonded", + "type": "uint64" + }, + { + "name": "bounty", + "type": "uint64" + }, + { + "name": "window_sec", + "type": "uint32" + }, + { + "name": "state", + "type": "request_state" + }, + { + "name": "created_at", + "type": "time_point" + }, + { + "name": "bonded_at", + "type": "time_point" + }, + { + "name": "hold_bond", + "type": "uint64" + }, + { + "name": "hold_beneficiary", + "type": "name" + }, + { + "name": "held_at", + "type": "time_point" + }, + { + "name": "resolved_at", + "type": "time_point" + }, + { + "name": "resolved_index", + "type": "uint128" + }, + { + "name": "forfeit_pending", + "type": "uint64" + }, + { + "name": "outcome_acknowledged", + "type": "bool" + } + ] + }, + { + "name": "requestkeep", + "base": "", + "fields": [ + { + "name": "issuer", + "type": "name" + }, + { + "name": "schema", + "type": "name" + }, + { + "name": "statement", + "type": "bytes" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "covered", + "type": "uint64" + }, + { + "name": "bounty", + "type": "uint64" + }, + { + "name": "window_sec", + "type": "uint32" + } + ] + }, + { + "name": "rslvinvalid", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + } + ] + }, + { + "name": "rslvvalid", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + } + ] + }, + { + "name": "setconfig", + "base": "", + "fields": [ + { + "name": "hold_bps", + "type": "uint32" + } + ] + }, + { + "name": "sweepyield", + "base": "", + "fields": [ + { + "name": "token_code", + "type": "slug_name" + } + ] + }, + { + "name": "wire_claim", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "amount", + "type": "uint64" + } + ] + }, + { + "name": "wire_key", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + } + ] + }, + { + "name": "yield_pool", + "base": "", + "fields": [ + { + "name": "received", + "type": "uint64" + }, + { + "name": "credited", + "type": "uint64" + }, + { + "name": "pulled_index", + "type": "uint128" + }, + { + "name": "pulled_owed", + "type": "uint64" + } + ] + } + ], + "actions": [ + { + "name": "accept", + "type": "accept", + "ricardian_contract": "" + }, + { + "name": "ack", + "type": "ack", + "ricardian_contract": "" + }, + { + "name": "addbounty", + "type": "addbounty", + "ricardian_contract": "" + }, + { + "name": "approve", + "type": "approve", + "ricardian_contract": "" + }, + { + "name": "claim", + "type": "claim", + "ricardian_contract": "" + }, + { + "name": "claimwire", + "type": "claimwire", + "ricardian_contract": "" + }, + { + "name": "hold", + "type": "hold", + "ricardian_contract": "" + }, + { + "name": "prune", + "type": "prune", + "ricardian_contract": "" + }, + { + "name": "request", + "type": "request", + "ricardian_contract": "" + }, + { + "name": "requestkeep", + "type": "requestkeep", + "ricardian_contract": "" + }, + { + "name": "rslvinvalid", + "type": "rslvinvalid", + "ricardian_contract": "" + }, + { + "name": "rslvvalid", + "type": "rslvvalid", + "ricardian_contract": "" + }, + { + "name": "setconfig", + "type": "setconfig", + "ricardian_contract": "" + }, + { + "name": "sweepyield", + "type": "sweepyield", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "bondconfig", + "type": "bond_config", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 62572 + }, + { + "name": "bondcounters", + "type": "bond_counters", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 27899 + }, + { + "name": "bonds", + "type": "bond_row", + "index_type": "i64", + "key_names": ["request_id","underwriter"], + "key_types": ["uint64","name"], + "table_id": 51812 + }, + { + "name": "escrows", + "type": "escrow_row", + "index_type": "i64", + "key_names": ["request_id","kind"], + "key_types": ["uint64","uint8"], + "table_id": 13593 + }, + { + "name": "requests", + "type": "request_row", + "index_type": "i64", + "key_names": ["id"], + "key_types": ["uint64"], + "table_id": 25292, + "secondary_indexes": [ + { + "name": "bystatement", + "key_type": "checksum256", + "table_id": 20108 + } + ] + }, + { + "name": "wireclaims", + "type": "wire_claim", + "index_type": "i64", + "key_names": ["account"], + "key_types": ["name"], + "table_id": 22082 + }, + { + "name": "yieldpool", + "type": "pool_row", + "index_type": "i64", + "key_names": ["token_code"], + "key_types": ["uint64"], + "table_id": 51955 + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [], + "enums": [ + { + "name": "escrow_kind", + "type": "uint8", + "values": [ + { + "name": "BOUNTY", + "value": 0 + }, + { + "name": "HOLD_BOND", + "value": 1 + } + ] + }, + { + "name": "request_state", + "type": "uint8", + "values": [ + { + "name": "OPEN", + "value": 0 + }, + { + "name": "BONDED", + "value": 1 + }, + { + "name": "APPROVED", + "value": 2 + }, + { + "name": "HELD", + "value": 3 + }, + { + "name": "VALID", + "value": 4 + }, + { + "name": "INVALID", + "value": 5 + } + ] + } + ] +} \ No newline at end of file diff --git a/contracts/sysio.bond/sysio.bond.wasm b/contracts/sysio.bond/sysio.bond.wasm new file mode 100755 index 0000000000000000000000000000000000000000..faa5a02060d2d6c14ed64916b1f67a6c46e3815b GIT binary patch literal 45576 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 literal 0 HcmV?d00001 diff --git a/contracts/sysio.liq/CMakeLists.txt b/contracts/sysio.liq/CMakeLists.txt index a7b849c0e6..8de5702df7 100644 --- a/contracts/sysio.liq/CMakeLists.txt +++ b/contracts/sysio.liq/CMakeLists.txt @@ -29,6 +29,7 @@ if(BUILD_SYSTEM_CONTRACTS) target_include_directories(${target} PUBLIC $ + $ $ $ $ diff --git a/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp index 25a745669c..503d4fa049 100644 --- a/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp +++ b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp @@ -58,6 +58,8 @@ namespace sysio { static constexpr name SWAP_ACCOUNT = "sysio.swap"_n; /// Governance executes approved proposals as `sysio`; it is also the T5 /// treasury the bootstrap drains and the holder of the protocol's pool shares. + /// The syndication custodian permitted to settle its own LIQ balance. + static constexpr name SYND_ACCOUNT = "sysio.synd"_n; static constexpr name SYSTEM_ACCOUNT = "sysio"_n; /// The yield asset. The kicker arrives in it from sysio.system, so every @@ -130,6 +132,10 @@ namespace sysio { // ----------------------------------------------------------------------- [[sysio::action]] void transfer(name from, name to, asset quantity, string memo); + /// Credit an existing custody obligation without notifying either account. Only sysio.synd + /// and sysio.bond may debit their own balance. Settles both yield positions, preserves supply, + /// and requires the Andon cord clear, including self-settlement. + [[sysio::action]] void settle(name custodian, name beneficiary, asset quantity); [[sysio::action]] void open(name owner, symbol symbol, name ram_payer); /// Erase `owner`'s empty row for `symbol`. Refused while the row is still /// owed yield, so closing never discards WIRE. diff --git a/contracts/sysio.liq/src/sysio.liq.cpp b/contracts/sysio.liq/src/sysio.liq.cpp index cf7f96cf66..9418ea4901 100644 --- a/contracts/sysio.liq/src/sysio.liq.cpp +++ b/contracts/sysio.liq/src/sysio.liq.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -16,6 +17,15 @@ namespace sysio { namespace { +constexpr auto unsupported_custodian_msg = "unsupported custodian"; +constexpr auto beneficiary_account_does_not_exist_msg = "beneficiary account does not exist"; +constexpr auto invalid_quantity_msg = "invalid quantity"; +constexpr auto must_settle_positive_quantity_msg = "must settle positive quantity"; +constexpr auto symbol_precision_mismatch_msg = "symbol precision mismatch"; +constexpr auto overdrawn_balance_msg = "overdrawn balance"; +constexpr name bond_account = "sysio.bond"_n; + + using opp::types::AttestationType; using opp::types::ChainKind; using opp::types::TokenKind; @@ -198,6 +208,7 @@ void liq::mintyield(sysio::slug_name chain_code, uint64_t sequence, uint64_t epo // --------------------------------------------------------------------------- void liq::queueyield(symbol_code sym) { + andon::check_clear(andon::ANDON_ACCOUNT); liqpendings pendings(get_self()); const symbol_key key{ sym.raw() }; const auto pending = pendings.try_get(key); @@ -236,20 +247,42 @@ void liq::transfer(name from, name to, asset quantity, string memo) { check(from != to, "cannot transfer to self"); require_auth(from); check(is_account(to), "to account does not exist"); + // While the cord is pulled only a transfer INTO a custody contract passes: bonds, challenges, holds and + // desyndications keep flowing in, and nothing leaves. + if (!andon::is_custody(to)) andon::check_clear(andon::ANDON_ACCOUNT); const currency_stats st = stat_of(quantity.symbol.code()); require_recipient(from); require_recipient(to); - check(quantity.is_valid(), "invalid quantity"); + check(quantity.is_valid(), invalid_quantity_msg); check(quantity.amount > 0, "must transfer positive quantity"); - check(quantity.symbol == st.supply.symbol, "symbol precision mismatch"); + check(quantity.symbol == st.supply.symbol, symbol_precision_mismatch_msg); check(memo.size() <= MAX_MEMO_BYTES, "memo has more than 256 bytes"); adjust_account(from, -quantity, ram_payer); adjust_account(to, quantity, ram_payer); } +void liq::settle(name custodian, name beneficiary, asset quantity) { + check(custodian == SYND_ACCOUNT || custodian == bond_account, unsupported_custodian_msg); + require_auth(custodian); + andon::check_clear(andon::ANDON_ACCOUNT); + check(is_account(beneficiary), beneficiary_account_does_not_exist_msg); + check(quantity.is_valid(), invalid_quantity_msg); + check(quantity.amount > 0, must_settle_positive_quantity_msg); + const currency_stats st = stat_of(quantity.symbol.code()); + check(quantity.symbol == st.supply.symbol, symbol_precision_mismatch_msg); + if (custodian == beneficiary) { + const auto row = accounts(get_self(), custodian.value).try_get(symbol_key{quantity.symbol.code().raw()}); + check(row && row->balance.amount >= quantity.amount, overdrawn_balance_msg); + adjust_account(custodian, asset{0, quantity.symbol}, ram_payer); + } else { + adjust_account(custodian, -quantity, ram_payer); + adjust_account(beneficiary, quantity, ram_payer); + } +} + void liq::open(name owner, symbol symbol, name ram_payer_) { require_auth(ram_payer_); check(is_account(owner), "owner account does not exist"); @@ -274,6 +307,9 @@ void liq::close(name owner, symbol symbol) { void liq::claim(name holder, symbol_code sym) { require_auth(holder); + // A custody contract pulling what its own row earned keeps the WIRE in custody; anyone else is refused + // while the cord is pulled. + if (!andon::is_custody(holder)) andon::check_clear(andon::ANDON_ACCOUNT); accounts holdings(get_self(), holder.value); const symbol_key key{ sym.raw() }; const auto row = holdings.try_get(key); diff --git a/contracts/sysio.liq/sysio.liq.abi b/contracts/sysio.liq/sysio.liq.abi index 00443bc1b3..0b0564aa37 100644 --- a/contracts/sysio.liq/sysio.liq.abi +++ b/contracts/sysio.liq/sysio.liq.abi @@ -472,6 +472,24 @@ } ] }, + { + "name": "settle", + "base": "", + "fields": [ + { + "name": "custodian", + "type": "name" + }, + { + "name": "beneficiary", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + } + ] + }, { "name": "sweep", "base": "", @@ -623,6 +641,11 @@ "type": "setkicker", "ricardian_contract": "" }, + { + "name": "settle", + "type": "settle", + "ricardian_contract": "" + }, { "name": "sweep", "type": "sweep", diff --git a/contracts/sysio.liq/sysio.liq.wasm b/contracts/sysio.liq/sysio.liq.wasm index fc2b0827fe9b3b7d52f3007496eb22ec5692206e..13332a5d176a67cbbbee289866bbcc6faf32325d 100755 GIT binary patch delta 7698 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 fjaK98x9UU|(MNo+t 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 diff --git a/contracts/sysio.swap/CMakeLists.txt b/contracts/sysio.swap/CMakeLists.txt index 0293a16cc4..30457d9b97 100644 --- a/contracts/sysio.swap/CMakeLists.txt +++ b/contracts/sysio.swap/CMakeLists.txt @@ -3,5 +3,6 @@ if(BUILD_SYSTEM_CONTRACTS) add_contract(sysio.swap sysio.swap sysio.swap.cpp token_functions.cpp) target_include_directories(sysio.swap PUBLIC $ - $) + $ + $) endif() diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index 4fc420c307..a868f191d2 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,10 @@ using namespace std; namespace sysio { + /// The depot AMM. Emergency stop: while the `sysio.andon` cord is pulled every action that moves + /// tokens is refused -- deposits (`ontransfer`), `withdraw`, `closeext` of a non-empty balance, + /// `addliquidity`, `remliquidity`, `exchange`, `inittoken`, the pair-token `transfer`, a `tickyield` + /// that would sell and an accrual that would claim yield -- and everything else runs. class [[sysio::contract("sysio.swap")]] swap : public contract { public: const int64_t MAX = sysio::asset::max_amount; @@ -158,6 +163,9 @@ namespace sysio { private: + /// Refuse while the `sysio.andon` cord is pulled: the gate of every action that moves tokens. + static void require_clear() { andon::check_clear(andon::ANDON_ACCOUNT); } + // --- Keys --- /// A pair's rows in `stat` and `priceaccum`: keyed by the LP token's symbol code. diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index 6fcf1f2720..8f67276c9a 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -3,14 +3,6 @@ #include namespace { - // The shadow token's tables, read in place (sysio.opp.common/shadow_yield.hpp): - // constructed with the token contract as code, the holder table scoped by the - // holder. Declared outside the contract class on purpose: the ABI generator - // lists every kv table alias it finds inside the class as the contract's own. - using shadow_accounts = sysio::kv::scoped_table; - using shadow_indexes = sysio::kv::table; /// Whether a delivered extended asset is exactly the expected one: same /// contract, same symbol, same amount. (asset's own == asserts on a symbol @@ -42,6 +34,7 @@ void swap::closeext( const name& user, const name& to, const extended_symbol& ex check( row.has_value(), "User does not have such token" ); const extended_asset ext_balance = row->balance; if (ext_balance.quantity.amount > 0) { + require_clear(); action(permission_level{ get_self(), "active"_n }, ext_balance.contract, "transfer"_n, std::make_tuple( get_self(), to, ext_balance.quantity, memo) ).send(); } @@ -53,6 +46,7 @@ void swap::ontransfer(name from, name to, asset quantity, string memo) { constexpr string_view EXCHANGE = "exchange:"; if (from == get_self()) return; + require_clear(); check(to == get_self(), "This transfer is not for sysio.swap"); check(quantity.amount >= 0, "quantity must be positive"); @@ -98,6 +92,7 @@ void swap::ontransfer(name from, name to, asset quantity, string memo) { void swap::withdraw(name user, name to, extended_asset to_withdraw, string memo){ require_auth( user ); + require_clear(); check(to_withdraw.quantity.amount > 0, "quantity must be positive"); add_signed_ext_balance(user, -to_withdraw); action(permission_level{ get_self(), "active"_n }, to_withdraw.contract, "transfer"_n, @@ -107,6 +102,7 @@ void swap::withdraw(name user, name to, extended_asset to_withdraw, string memo) void swap::addliquidity(name user, asset to_buy, asset max_asset1, asset max_asset2) { require_auth(user); + require_clear(); check( (to_buy.amount > 0), "to_buy amount must be positive"); check( (max_asset1.amount >= 0) && (max_asset2.amount >= 0), "assets must be nonnegative"); add_signed_liq(user, to_buy, true, max_asset1, max_asset2); @@ -115,6 +111,7 @@ void swap::addliquidity(name user, asset to_buy, void swap::remliquidity(name user, asset to_sell, asset min_asset1, asset min_asset2) { require_auth(user); + require_clear(); check(to_sell.amount > 0, "to_sell amount must be positive"); check( (min_asset1.amount >= 0) && (min_asset2.amount >= 0), "assets must be nonnegative"); add_signed_liq(user, -to_sell, false, -min_asset1, -min_asset2); @@ -187,6 +184,7 @@ void swap::add_signed_liq(name user, asset to_add, bool is_buying, void swap::exchange( name user, symbol_code pair_token, extended_asset ext_asset_in, asset min_expected) { require_auth(user); + require_clear(); check( ext_asset_in.quantity.amount > 0, "ext_asset_in must be positive" ); check( min_expected.amount >= 0, "min_expected must be nonnegative" ); auto ext_asset_out = process_exch(pair_token, ext_asset_in, min_expected); @@ -306,6 +304,7 @@ std::optional yield_leg) { require_auth( user ); require_auth( get_self() ); + require_clear(); check((initial_pool1.quantity.amount > 0) && (initial_pool2.quantity.amount > 0), "Both assets must be positive"); check((initial_pool1.quantity.amount < INIT_MAX) && (initial_pool2.quantity.amount < INIT_MAX), "Initial amounts must be less than 10^15"); uint8_t new_precision = ( initial_pool1.quantity.symbol.precision() + initial_pool2.quantity.symbol.precision() ) / 2; @@ -408,10 +407,10 @@ const extended_asset& swap::other_pool(const currency_stats& token, const extend uint64_t swap::owed_yield(const extended_symbol& shadow) const { const opp::shadow::symbol_key key{ shadow.get_symbol().code().raw() }; - shadow_indexes indexes( shadow.get_contract() ); + sysio::opp::shadow::yield_index_table indexes( shadow.get_contract() ); const auto index = indexes.try_get( key ); if (!index || index->index == 0) return 0; - shadow_accounts holdings( shadow.get_contract(), get_self().value ); + sysio::opp::shadow::accounts_table holdings( shadow.get_contract(), get_self().value ); const auto row = holdings.try_get( key ); return row ? opp::shadow::owed( *row, index->index ) : 0; } @@ -421,6 +420,8 @@ swap::currency_stats swap::accrue(const pair_key& key, const currency_stats& tok const extended_symbol& shadow = *token.yield_leg; const uint64_t owed = owed_yield( shadow ); if (owed == 0) return token; + // The claim below moves the pool's WIRE yield out of the shadow token's custody. + require_clear(); check( owed <= uint64_t(MAX), "yield payout overflows" ); const extended_symbol payout_symbol = other_pool( token, shadow ).get_extended_symbol(); const extended_asset payout{ asset{ int64_t(owed), payout_symbol.get_symbol() }, payout_symbol.get_contract() }; @@ -564,6 +565,8 @@ void swap::tickyield(symbol_code pair_token) { other_pool( token, shadow ).quantity.amount, int64_t(clip), token.fee ) < min_fee_bearing_output( token.fee )) return; + // Everything above only measured; the sale moves tokens. + require_clear(); const extended_asset selling{ asset{ int64_t(clip), shadow.get_symbol() }, shadow.get_contract() }; const symbol proceeds_symbol = other_pool( token, shadow ).quantity.symbol; const extended_asset proceeds = process_exch( pair_token, selling, asset{ 0, proceeds_symbol } ); diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index a3173875b9f70ac30175c260939a31dd834a447d..bb31c06f05ac9aa9baa2fe191f00b33defa802ce 100755 GIT binary patch delta 6022 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 delta 5351 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 r^ZjQ|tC+T^qP%3ac3%Mi%RpuptR(9p2sYU}xD zzCpYUAaSRLGhItIAJ}!n9V8FrHXQi62*eHI1?oX?1w{C{L425M0X}YS9tHtc1)wGc LCIP9*30xfjV5>C{ delta 127 zcmaDN@j`UMEJ1NG239s8Vq<1wX9j_ZYa&^txNZuGZRTS1WaAc*QeaeI(qQ5d5S<*y z9ypnkb2_8g=7pThjEv%wmvWuewP8?T5RhPU04ie$wT{ql=fLtc0 ahK7b6S6k0N^96DzAK;c|l$?Bry8{5dZXjy_ diff --git a/contracts/tests/contract_test_support.hpp b/contracts/tests/contract_test_support.hpp index 521fd992ea..b1b2a68638 100644 --- a/contracts/tests/contract_test_support.hpp +++ b/contracts/tests/contract_test_support.hpp @@ -76,20 +76,34 @@ typename Tester::action_result push_contract_action(Tester& tester, name contrac } } -/// Push one ABI-encoded action and land it in its own block for stable TaPoS. +/// Push one ABI-encoded action and land it in its own block for stable TaPoS, handing back its +/// trace in `trace` (null after a failure) so a test can inspect inline actions and notifications. template typename Tester::action_result push_contract_action_and_produce_block( Tester& tester, name contract, abi_serializer& serializer, name signer, name action_name, - const fc::variant_object& data, std::vector authorization = {}) { + const fc::variant_object& data, transaction_trace_ptr& trace, std::vector authorization = {}) { + trace.reset(); try { - push_contract_action_trace(tester, contract, serializer, signer, action_name, data, std::move(authorization)); + trace = push_contract_action_trace(tester, contract, serializer, signer, action_name, data, + std::move(authorization)); tester.produce_block(); return Tester::success(); } catch (const fc::exception& ex) { + trace.reset(); return Tester::error(ex.top_message()); } } +/// Push one ABI-encoded action and land it in its own block for stable TaPoS. +template +typename Tester::action_result push_contract_action_and_produce_block( + Tester& tester, name contract, abi_serializer& serializer, name signer, name action_name, + const fc::variant_object& data, std::vector authorization = {}) { + transaction_trace_ptr trace; + return push_contract_action_and_produce_block(tester, contract, serializer, signer, action_name, data, trace, + std::move(authorization)); +} + // --------------------------------------------------------------------------- // sysio.chains::outpost_addrs builders // diff --git a/contracts/tests/contracts.hpp.in b/contracts/tests/contracts.hpp.in index 973c6fbcd5..43df21c61c 100644 --- a/contracts/tests/contracts.hpp.in +++ b/contracts/tests/contracts.hpp.in @@ -44,6 +44,8 @@ struct contracts { static std::vector liq_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.liq/sysio.liq.abi"); } static std::vector andon_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.andon/sysio.andon.wasm"); } static std::vector andon_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.andon/sysio.andon.abi"); } + static std::vector bond_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.bond/sysio.bond.wasm"); } + static std::vector bond_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.bond/sysio.bond.abi"); } /// The checked-in bootstrap configs (`dex-config.*.json`) the launch-ingestion tests replay. static std::string dex_config_dir() { return "${CMAKE_SOURCE_DIR}/etc/config/dex"; } diff --git a/contracts/tests/sysio.bond_tests.cpp b/contracts/tests/sysio.bond_tests.cpp new file mode 100644 index 0000000000..d83fa0d7f9 --- /dev/null +++ b/contracts/tests/sysio.bond_tests.cpp @@ -0,0 +1,1901 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "contracts.hpp" +#include "contract_test_support.hpp" +#include "liq_test_support.hpp" +#include + +#include +#include +#include +#include + +using namespace sysio::testing; +using namespace sysio; +using namespace sysio::chain; +using namespace sysio::opp::types; +using namespace fc; + +using mvo = fc::mutable_variant_object; +using sysio_system::test_support::codename_mvo; + +namespace { + +// The contract's `check` messages, spelled as `sysio.bond.cpp` spells them. +constexpr std::string_view bond_increment_msg = "amount must be a positive multiple of the bond increment"; +constexpr std::string_view precision_msg = "token precision is below the bond increment"; +constexpr std::string_view unsupported_token_msg = "unsupported bond token"; +constexpr std::string_view statement_len_msg = "statement exceeds the maximum length"; +constexpr std::string_view duplicate_msg = "a request for this statement already exists"; +constexpr std::string_view window_msg = "window must be positive"; +constexpr std::string_view hold_bps_range_msg = "hold_bps must be positive and at most the basis-point denominator"; +constexpr std::string_view amount_positive_msg = "amount must be positive"; +constexpr std::string_view request_missing_msg = "request not found"; +constexpr std::string_view fully_bonded_msg = "request is fully bonded"; +constexpr std::string_view not_accepting_msg = "request is not accepting bonds"; +constexpr std::string_view terminal_msg = "request is already resolved"; +constexpr std::string_view not_bonded_msg = "request is not bonded"; +constexpr std::string_view window_open_msg = "challenge window has not passed"; +constexpr std::string_view hold_state_msg = "hold is only possible while the request is OPEN or BONDED"; +constexpr std::string_view beneficiary_msg = "hold beneficiary is not an account"; +constexpr std::string_view beneficiary_self_msg = "hold beneficiary cannot be the contract"; +constexpr std::string_view nothing_msg = "nothing to claim"; +constexpr std::string_view wire_no_yield_msg = "WIRE collateral earns no shadow yield"; + +/// The memo of the one transfer that pays the issuer an INVALID request's bonds. +constexpr std::string_view forfeit_memo = "sysio.bond::forfeit"; +/// The memo of the transfer that pays a claim in the request's token. +constexpr std::string_view claim_memo = "sysio.bond::claim"; + +/// `request_state` names as the ABI renders them. +constexpr std::string_view STATE_OPEN = "OPEN"; +constexpr std::string_view STATE_BONDED = "BONDED"; +constexpr std::string_view STATE_APPROVED = "APPROVED"; +constexpr std::string_view STATE_HELD = "HELD"; +constexpr std::string_view STATE_VALID = "VALID"; +constexpr std::string_view STATE_INVALID = "INVALID"; +/// `escrow_kind` names as the ABI renders them, and the key byte of each. +constexpr std::string_view KIND_BOUNTY = "BOUNTY"; +constexpr uint8_t KIND_BOUNTY_BYTE = 0; +constexpr std::string_view KIND_HOLD_BOND = "HOLD_BOND"; +constexpr uint8_t KIND_HOLD_BOND_BYTE = 1; + +/// Mirrors the contract's MAX_STATEMENT_BYTES. +constexpr size_t MAX_STATEMENT_BYTES = 1024; + +/// Mirrors the contract's PRUNE_RETENTION_SEC: seven days. +constexpr uint32_t PRUNE_RETENTION_SEC = 604'800; + +/// `sysio.liq`'s fixed-point scale of the yield index (`YIELD_INDEX_SCALE`). +constexpr uint64_t YIELD_INDEX_SCALE = 1'000'000'000'000; + +} // anonymous namespace + +/// A shadow-custody `position` as sysio.bond's rows embed it, read raw so the 128-bit checkpoint +/// compares exactly. +struct raw_position { + fc::uint128_t index_checkpoint = 0; + uint64_t owed_wire = 0; +}; +FC_REFLECT(raw_position, (index_checkpoint)(owed_wire)) + +/// A `bonds` row, raw. +struct raw_bond_row { + uint64_t request_id = 0; + name underwriter; + uint64_t amount = 0; + raw_position yield; + bool paid = false; +}; +FC_REFLECT(raw_bond_row, (request_id)(underwriter)(amount)(yield)(paid)) + +/// An `escrows` row, raw; `kind` is the escrow_kind's byte. +struct raw_escrow_row { + uint64_t request_id = 0; + uint8_t kind = 0; + name funder; + uint64_t amount = 0; + raw_position yield; + bool paid = false; + name payee; + uint64_t payout = 0; +}; +FC_REFLECT(raw_escrow_row, (request_id)(kind)(funder)(amount)(yield)(paid)(payee)(payout)) + +/// A `sysio.liq` holder row, raw. +struct raw_liq_account { + asset balance; + fc::uint128_t index_checkpoint = 0; + uint64_t owed_wire = 0; +}; +FC_REFLECT(raw_liq_account, (balance)(index_checkpoint)(owed_wire)) + +/// A `sysio.liq` `yieldidx` row, raw. +struct raw_yield_index { + fc::uint128_t index = 0; + uint64_t pot = 0; + uint64_t carry = 0; +}; +FC_REFLECT(raw_yield_index, (index)(pot)(carry)) + +/// sysio.bond on the depot: the registries and custody contracts it resolves tokens through, +/// sysio.liq as the shadow custody contract and yield source, with governance funding fixture balances. The treasury supply of WIRE sits on `sysio`, as the bootstrap leaves it. +class sysio_bond_tester : public tester { +public: + static constexpr auto BOND_ACCOUNT = "sysio.bond"_n; + static constexpr auto LIQ_ACCOUNT = "sysio.liq"_n; + static constexpr auto SYND_ACCOUNT = "sysio.synd"_n; + static constexpr auto TOKEN_ACCOUNT = "sysio.token"_n; + static constexpr auto TOKENS_ACCOUNT = "sysio.tokens"_n; + static constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; + static constexpr auto SYSIO_ACCOUNT = "sysio"_n; + + static inline const symbol WIRE_SYM = symbol::from_string("9,WIRE"); + static inline const symbol LIQSOL_SYM = symbol::from_string("9,LIQSOL"); + static constexpr uint64_t UNIT = 1'000'000'000; // one whole token of a precision-9 symbol + + static constexpr std::string_view SOLANA = "SOLANA"; + static constexpr std::string_view LIQSOL = "LIQSOL"; + static constexpr std::string_view WIRE = "WIRE"; + + /// A challenge window of three hours. + static constexpr uint32_t WINDOW_SEC = 10800; + + sysio_bond_tester() { + produce_blocks(2); + create_accounts({ BOND_ACCOUNT, LIQ_ACCOUNT, SYND_ACCOUNT, TOKEN_ACCOUNT, TOKENS_ACCOUNT, CHAINS_ACCOUNT, + "issuer"_n, "alice"_n, "bob"_n, "carol"_n }); + produce_blocks(2); + + deploy(CHAINS_ACCOUNT, contracts::chains_wasm(), contracts::chains_abi(), chains_abi_ser, true); + deploy(TOKENS_ACCOUNT, contracts::tokens_wasm(), contracts::tokens_abi(), tokens_abi_ser, true); + deploy(TOKEN_ACCOUNT, contracts::token_wasm(), contracts::token_abi(), token_abi_ser, true); + deploy(LIQ_ACCOUNT, contracts::liq_wasm(), contracts::liq_abi(), liq_abi_ser, true); + deploy(BOND_ACCOUNT, contracts::bond_wasm(), contracts::bond_abi(), bond_abi_ser, true); + + // WIRE: the treasury supply on sysio, and working balances for the issuer and underwriters. + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi_ser, TOKEN_ACCOUNT, "create"_n, mvo() + ("issuer", SYSIO_ACCOUNT)("maximum_supply", asset(1'000'000'000 * UNIT, WIRE_SYM)))); + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi_ser, SYSIO_ACCOUNT, "issue"_n, mvo() + ("to", SYSIO_ACCOUNT)("quantity", asset(1'000'000'000 * UNIT, WIRE_SYM))("memo", ""))); + for (auto funder : { "issuer"_n, "alice"_n, "bob"_n, "carol"_n }) + BOOST_REQUIRE_EQUAL(success(), transfer_wire(SYSIO_ACCOUNT, funder, 1'000'000 * UNIT)); + + // The SOLANA outpost, its liq token and that token's shadow on the depot. + BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_SVM, SOLANA, 2)); + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, LIQSOL, 9)); + BOOST_REQUIRE_EQUAL(success(), create_shadow(LIQSOL_SYM, LIQSOL)); + + // Shadow LIQSOL for the issuer and two underwriters, funded through governance credit. + for (auto holder : { "alice"_n, "bob"_n, "issuer"_n }) { + BOOST_REQUIRE_EQUAL(success(), mint(holder, 1'000 * UNIT)); + BOOST_REQUIRE_EQUAL(1'000 * UNIT, liq_balance(holder)); + } + } + + // --- deployment and pushing --- + + void deploy(name account, const std::vector& wasm, const std::vector& abi, + abi_serializer& ser, bool privileged) { + set_code(account, wasm); + set_abi(account, abi.data()); + if (privileged) set_privileged(account); + produce_blocks(); + sysio_system::test_support::load_account_abi(*this, account, ser); + } + + /// Push one action in its own block, keeping its trace in `last_trace` (null after a failure). + action_result push(name code, abi_serializer& ser, name signer, name action_name, const variant_object& data) { + return sysio_system::test_support::push_contract_action_and_produce_block(*this, code, ser, signer, + action_name, data, last_trace); + } + action_result push_bond(name signer, name action_name, const variant_object& data) { + return push(BOND_ACCOUNT, bond_abi_ser, signer, action_name, data); + } + + // --- registries and sysio.liq --- + + // Registrations inside the epoch-0 bootstrap window land ACTIVE, as the launch bootstrap's do. + action_result regchain(ChainKind kind, std::string_view code, uint32_t external_chain_id) { + return push(CHAINS_ACCOUNT, chains_abi_ser, CHAINS_ACCOUNT, "regchain"_n, mvo() + ("kind", kind)("code", codename_mvo(code))("external_chain_id", external_chain_id) + ("name", std::string("outpost"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo())); + } + /// Register a token of the SOLANA outpost with `precision` decimals and bind it to the chain. + action_result regtoken(TokenKind kind, std::string_view code, uint32_t precision) { + const std::vector address(32, char(0x5a)); + auto r = push(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, "regtoken"_n, mvo() + ("kind", kind)("code", codename_mvo(code))("symbol_name", std::string(code))("description", std::string{}) + ("precision", precision)("address", mvo()("kind", ChainKind::CHAIN_KIND_SVM)("address", address))); + if (r != success()) return r; + return push(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, "regctok"_n, mvo() + ("chain_code", codename_mvo(SOLANA))("token_code", codename_mvo(code))("contract_addr", address) + ("is_native", false)); + } + /// Register `sym` on sysio.liq as the shadow of the SOLANA liq token `token_code`. + action_result create_shadow(symbol sym, std::string_view token_code) { + return push(LIQ_ACCOUNT, liq_abi_ser, LIQ_ACCOUNT, "create"_n, mvo() + ("sym", sym)("chain_code", codename_mvo(SOLANA))("token_code", codename_mvo(token_code))); + } + /// Seed a nine-decimal fixture token through the ledger's governance credit action. + action_result mint(name account, uint64_t amount, std::string_view token_code = LIQSOL) { + const symbol sym{LIQSOL_SYM.decimals(), std::string(token_code)}; + return push(LIQ_ACCOUNT, liq_abi_ser, LIQ_ACCOUNT, "recredit"_n, mvo() + ("holder", account)("quantity", asset(static_cast(amount), sym))); + } + /// Distribute `amount` WIRE from `from` to the holders of `sym` (LIQSOL by default) through + /// sysio.liq, raising its index. + action_result addyield(name from, uint64_t amount, symbol sym = LIQSOL_SYM) { + return push(LIQ_ACCOUNT, liq_abi_ser, from, "addyield"_n, mvo() + ("from", from)("quantity", asset(static_cast(amount), WIRE_SYM)) + ("target", sym.to_symbol_code())); + } + /// Burn the fixture holder's shadow through the ledger's desyndication action. + /// A trusted SVM link supplies the outbound destination for the supply-drain case. + action_result burn_shadow(name holder, uint64_t amount, symbol sym, std::string_view token_code) { + constexpr auto authex_account = "sysio.authex"_n; + constexpr auto msgch_account = "sysio.msgch"_n; + for (auto account : {authex_account, msgch_account}) + if (control->find_account(account) == nullptr) create_accounts({account}); + abi_serializer authex_ser, msgch_ser; + deploy(authex_account, contracts::authex_wasm(), contracts::authex_abi(), authex_ser, true); + deploy(msgch_account, contracts::msgch_wasm(), contracts::msgch_abi(), msgch_ser, true); + const auto pubkey = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::ed).get_public_key(); + auto result = push(authex_account, authex_ser, authex_account, "recordlink"_n, mvo() + ("account", holder)("chain_kind", ChainKind::CHAIN_KIND_SVM)("pub_key", pubkey) + ("native_address", sysio_liq::test_support::native_address_of(pubkey))); + if (result != success()) return result; + return push(LIQ_ACCOUNT, liq_abi_ser, holder, "desyndicate"_n, mvo() + ("holder", holder)("quantity", asset(static_cast(amount), sym))); + } + /// sysio.liq pays `holder` the WIRE its LIQSOL row is owed. + action_result liq_claim(name holder) { + return push(LIQ_ACCOUNT, liq_abi_ser, holder, "claim"_n, mvo()("holder", holder) + ("sym", LIQSOL_SYM.to_symbol_code())); + } + action_result transfer_wire(name from, name to, uint64_t amount) { + return push(TOKEN_ACCOUNT, token_abi_ser, from, "transfer"_n, mvo() + ("from", from)("to", to)("quantity", asset(static_cast(amount), WIRE_SYM))("memo", "")); + } + + // --- sysio.bond actions --- + + action_result request(name issuer, name schema, const std::vector& statement, std::string_view token_code, + uint64_t covered, uint64_t bounty, uint32_t window_sec = WINDOW_SEC) { + return push_bond(issuer, "request"_n, mvo() + ("issuer", issuer)("schema", schema)("statement", statement)("token_code", codename_mvo(token_code)) + ("covered", covered)("bounty", bounty)("window_sec", window_sec)); + } + action_result addbounty(name signer, uint64_t request_id, uint64_t amount) { + return push_bond(signer, "addbounty"_n, mvo()("request_id", request_id)("amount", amount)); + } + action_result setconfig(name signer, uint32_t hold_bps) { + return push_bond(signer, "setconfig"_n, mvo()("hold_bps", hold_bps)); + } + action_result accept(name underwriter, uint64_t request_id, uint64_t amount) { + return push_bond(underwriter, "accept"_n, mvo()("underwriter", underwriter)("request_id", request_id) + ("amount", amount)); + } + action_result hold(name signer, uint64_t request_id, name beneficiary) { + return push_bond(signer, "hold"_n, mvo()("request_id", request_id)("beneficiary", beneficiary)); + } + /// `approve` is permissionless; `carol`, a bystander, signs it. + action_result approve(uint64_t request_id) { + return push_bond("carol"_n, "approve"_n, mvo()("request_id", request_id)); + } + action_result rslvvalid(name signer, uint64_t request_id) { + return push_bond(signer, "rslvvalid"_n, mvo()("request_id", request_id)); + } + action_result rslvinvalid(name signer, uint64_t request_id) { + return push_bond(signer, "rslvinvalid"_n, mvo()("request_id", request_id)); + } + /// `claim` is permissionless; `signer` defaults to the account paid. + action_result claim(uint64_t request_id, name account, std::optional signer = std::nullopt) { + return push_bond(signer.value_or(account), "claim"_n, mvo()("request_id", request_id)("account", account)); + } + /// `sweepyield` is permissionless; `carol` signs it. + action_result sweepyield(std::string_view token_code) { + return push_bond("carol"_n, "sweepyield"_n, mvo()("token_code", codename_mvo(token_code))); + } + /// `prune` is permissionless; `carol` signs it. + action_result prune(uint64_t from_id, uint32_t limit) { + return push_bond("carol"_n, "prune"_n, mvo()("from_id", from_id)("limit", limit)); + } + /// Request `statement_fill`'s statement in LIQSOL, covering 10 UNIT with a window of `window_sec`, and + /// have alice bond all of it: request `request_id` is BONDED. + void bonded_request(uint64_t request_id, char statement_fill, uint32_t window_sec = WINDOW_SEC) { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, statement_fill), LIQSOL, + 10 * UNIT, 0, window_sec)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, request_id, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(STATE_BONDED, request_state_of(request_id)); + } + /// Let `window_sec` and one more second pass on the chain clock. + void pass_window(uint32_t window_sec) { produce_block(fc::seconds(window_sec + 1)); } + /// Let the retention period `prune` keeps a ruled request for pass on the chain clock. + void pass_retention() { produce_block(fc::seconds(PRUNE_RETENTION_SEC)); } + + static std::vector bytes(size_t size, char fill) { return std::vector(size, fill); } + + // --- rows --- + + fc::variant decode(abi_serializer& ser, const char* type, const std::vector& data) { + return data.empty() + ? fc::variant() + : ser.binary_to_variant(type, data, abi_serializer::create_yield_function(abi_serializer_max_time)); + } + fc::variant request_row_of(uint64_t id) { + return decode(bond_abi_ser, "request_row", get_row_by_id(BOND_ACCOUNT, BOND_ACCOUNT, "requests"_n, id)); + } + std::string request_state_of(uint64_t id) { return request_row_of(id)["state"].as_string(); } + /// Append `value` to a kv key as the big-endian word the CDT key serializer writes. + static void append_key_word(std::string& key, uint64_t value) { + for (int shift = 56; shift >= 0; shift -= 8) key.push_back(char((value >> shift) & 0xff)); + } + /// sysio.bond's kv rows of `table` in key order whose key starts with `prefix`, as raw values. + std::vector> kv_rows(name table, const std::string& prefix) { + const auto& kv_idx = control->db().get_index(); + const auto table_id = compute_table_id(table.to_uint64_t()); + std::vector> rows; + for (auto itr = kv_idx.lower_bound(boost::make_tuple(BOND_ACCOUNT, table_id, std::string_view(prefix))); + itr != kv_idx.end() && itr->code == BOND_ACCOUNT && itr->table_id == table_id && + itr->key_view().starts_with(prefix); + ++itr) + rows.emplace_back(itr->value.data(), itr->value.data() + itr->value.size()); + return rows; + } + /// The kv row of sysio.bond's `table` whose key is exactly `key`; null when there is none. + const kv_object* kv_row(name table, const std::string& key) { + const auto& kv_idx = control->db().get_index(); + const auto it = kv_idx.find( + boost::make_tuple(BOND_ACCOUNT, compute_table_id(table.to_uint64_t()), std::string_view(key))); + return it == kv_idx.end() ? nullptr : &*it; + } + /// The kv key of request `id`: the id as a big-endian word. + static std::string request_key_of(uint64_t id) { + std::string key; + append_key_word(key, id); + return key; + } + /// The account that pays the RAM of the kv row of `table` keyed `key`; the test fails without one. + name payer_of(name table, const std::string& key) { + const auto* row = kv_row(table, key); + BOOST_REQUIRE_MESSAGE(row != nullptr, "no " << table << " row"); + return row->payer; + } + /// The RAM `account` uses, in bytes. + int64_t ram_usage(name account) { return control->get_resource_limits_manager().get_account_ram_usage(account); } + /// Rewrite request `id`'s stored token code to `token_code`, bypassing the contract, to stand for a + /// token that no longer resolves. + void rewrite_request_token(uint64_t id, std::string_view token_code) { + mvo row(request_row_of(id).get_object()); + row("token_code", codename_mvo(token_code)); + const auto bytes = bond_abi_ser.variant_to_binary("request_row", row, + abi_serializer::create_yield_function(abi_serializer_max_time)); + const auto* obj = kv_row("requests"_n, request_key_of(id)); + BOOST_REQUIRE(obj != nullptr); + auto& db = const_cast(control->db()); + db.modify(*obj, [&](auto& r) { r.value.assign(bytes.data(), bytes.size()); }); + } + /// The `escrows` row of `request_id` and `kind`: its kv key is the request id as a big-endian + /// word followed by the kind byte. + fc::variant escrow_row_of(uint64_t request_id, uint8_t kind) { + std::string key; + append_key_word(key, request_id); + key.push_back(char(kind)); + const auto rows = kv_rows("escrows"_n, key); + return rows.empty() ? fc::variant() : decode(bond_abi_ser, "escrow_row", rows.front()); + } + /// The `bonds` rows of `request_id`: their kv keys start with the request id as a big-endian word. + std::vector bond_rows_of(uint64_t request_id) { + std::string prefix; + append_key_word(prefix, request_id); + std::vector rows; + for (const auto& value : kv_rows("bonds"_n, prefix)) rows.push_back(decode(bond_abi_ser, "bond_row", value)); + return rows; + } + /// The amount `underwriter` has bonded on `request_id`; 0 with no bond row. + uint64_t bond_amount_of(uint64_t request_id, name underwriter) { + for (const auto& row : bond_rows_of(request_id)) + if (row["underwriter"].as() == underwriter) return row["amount"].as_uint64(); + return 0; + } + fc::variant config_row() { + return decode(bond_abi_ser, "bond_config", + get_row_by_account(BOND_ACCOUNT, BOND_ACCOUNT, "bondconfig"_n, "bondconfig"_n)); + } + uint64_t wire_balance(name holder) { + const auto row = decode(token_abi_ser, "account", + get_row_by_id(TOKEN_ACCOUNT, holder, "accounts"_n, WIRE_SYM.to_symbol_code().value)); + return row.is_null() ? 0 : row["balance"].as().get_amount(); + } + uint64_t liq_balance(name holder, symbol sym = LIQSOL_SYM) { + const auto row = decode(liq_abi_ser, "account", + get_row_by_id(LIQ_ACCOUNT, holder, "accounts"_n, sym.to_symbol_code().value)); + return row.is_null() ? 0 : row["balance"].as().get_amount(); + } + + /// The `bonds` row of `request_id` and `underwriter`, raw; the test fails without one. + raw_bond_row raw_bond_of(uint64_t request_id, name underwriter) { + std::string key; + append_key_word(key, request_id); + append_key_word(key, underwriter.to_uint64_t()); + const auto rows = kv_rows("bonds"_n, key); + BOOST_REQUIRE_MESSAGE(!rows.empty(), "no bond row for " << request_id << " " << underwriter); + return fc::raw::unpack(rows.front()); + } + /// The `escrows` row of `request_id` and `kind`, raw; the test fails without one. + raw_escrow_row raw_escrow_of(uint64_t request_id, uint8_t kind) { + std::string key; + append_key_word(key, request_id); + key.push_back(char(kind)); + const auto rows = kv_rows("escrows"_n, key); + BOOST_REQUIRE_MESSAGE(!rows.empty(), "no escrow row for " << request_id << " " << int(kind)); + return fc::raw::unpack(rows.front()); + } + /// sysio.liq's cumulative yield index of LIQSOL; 0 before its first distribution. + fc::uint128_t liq_index() { + const auto data = get_row_by_id(LIQ_ACCOUNT, LIQ_ACCOUNT, "yieldidx"_n, LIQSOL_SYM.to_symbol_code().value); + return data.empty() ? fc::uint128_t{0} : fc::raw::unpack(data).index; + } + /// The WIRE `holder`'s LIQSOL row is owed now: `sysio.liq`'s own formula over the live index, what + /// `custody::custodian_owed` reports for a custodian. + uint64_t liq_owed(name holder) { + const auto data = get_row_by_id(LIQ_ACCOUNT, holder, "accounts"_n, LIQSOL_SYM.to_symbol_code().value); + if (data.empty()) return 0; + const auto row = fc::raw::unpack(data); + if (row.balance.get_amount() <= 0) return row.owed_wire; + return static_cast(row.owed_wire + static_cast(row.balance.get_amount()) * + (liq_index() - row.index_checkpoint) / YIELD_INDEX_SCALE); + } + /// The `yieldpool` row of `token_code` (LIQSOL by default) as the ABI renders it; null before its + /// first pull. + fc::variant pool_row_of(std::string_view token_code = LIQSOL) { + return decode(bond_abi_ser, "pool_row", + get_row_by_id(BOND_ACCOUNT, BOND_ACCOUNT, "yieldpool"_n, fc::slug_name{token_code}.value)); + } + + // --- the last pushed transaction's trace --- + + /// True iff the last pushed transaction delivered sysio.bond's `action_name` to `receiver`: the + /// notification copy `require_recipient(receiver)` makes. + bool notified(name receiver, name action_name) const { + if (!last_trace) return false; + for (const auto& at : last_trace->action_traces) + if (at.act.account == BOND_ACCOUNT && at.act.name == action_name && at.receiver == receiver) return true; + return false; + } + /// One `transfer` the last pushed transaction executed. + struct seen_transfer { + name from; + name to; + asset quantity; + std::string memo; + }; + /// Every `transfer` the last pushed transaction executed on `token_contract` (the contract's own + /// execution, not the notification copies), in execution order. + std::vector transfers_on(name token_contract, abi_serializer& ser) { + std::vector seen; + if (!last_trace) return seen; + for (const auto& at : last_trace->action_traces) { + if (at.act.account != token_contract || at.act.name != "transfer"_n || at.receiver != token_contract) continue; + const auto v = ser.binary_to_variant("transfer", at.act.data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + seen.push_back({v["from"].as(), v["to"].as(), v["quantity"].as(), v["memo"].as_string()}); + } + return seen; + } + std::vector settlements() { + std::vector seen; + if (!last_trace) return seen; + for (const auto& at : last_trace->action_traces) { + if (at.act.account != LIQ_ACCOUNT || at.act.name != "settle"_n || at.receiver != LIQ_ACCOUNT) continue; + const auto v = liq_abi_ser.binary_to_variant("settle", at.act.data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + seen.push_back({v["custodian"].as(), v["beneficiary"].as(), v["quantity"].as(), {}}); + } + return seen; + } + action_result claimwire(name account) { + return push_bond(account, "claimwire"_n, mvo()("account", account)); + } + /// The `sysio.liq::addyield` actions the last pushed transaction executed, as `(from, quantity)`. + std::vector> addyields() { + std::vector> seen; + if (!last_trace) return seen; + for (const auto& at : last_trace->action_traces) { + if (at.act.account != LIQ_ACCOUNT || at.act.name != "addyield"_n || at.receiver != LIQ_ACCOUNT) continue; + const auto v = liq_abi_ser.binary_to_variant("addyield", at.act.data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + seen.emplace_back(v["from"].as(), v["quantity"].as()); + } + return seen; + } + + abi_serializer bond_abi_ser, liq_abi_ser, token_abi_ser, tokens_abi_ser, chains_abi_ser; + transaction_trace_ptr last_trace; ///< trace of the last successful push; null after a failure +}; + +BOOST_AUTO_TEST_SUITE(sysio_bond_tests) + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(config_defaults_to_ten_percent_and_sysio_sets_it, sysio_bond_tester) try { + BOOST_REQUIRE(config_row().is_null()); // no row: `hold` reads DEFAULT_HOLD_BPS + + BOOST_REQUIRE_EQUAL(error("missing authority of sysio"), setconfig("alice"_n, 500)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(hold_bps_range_msg)), setconfig(SYSIO_ACCOUNT, 10001)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(hold_bps_range_msg)), setconfig(SYSIO_ACCOUNT, 0)); + BOOST_REQUIRE(config_row().is_null()); + + BOOST_REQUIRE_EQUAL(success(), setconfig(SYSIO_ACCOUNT, 500)); + BOOST_REQUIRE_EQUAL(500u, config_row()["hold_bps"].as_uint64()); + BOOST_REQUIRE_EQUAL(success(), setconfig(SYSIO_ACCOUNT, 10000)); + BOOST_REQUIRE_EQUAL(10000u, config_row()["hold_bps"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// request +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(request_in_shadow_liq_escrows_the_bounty, sysio_bond_tester) try { + const auto before = liq_balance("issuer"_n); + const auto bond_before = liq_balance(BOND_ACCOUNT); + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(before - 1 * UNIT, liq_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(bond_before + 1 * UNIT, liq_balance(BOND_ACCOUNT)); + + const auto row = request_row_of(1); + BOOST_REQUIRE(!row.is_null()); + BOOST_REQUIRE_EQUAL(STATE_OPEN, request_state_of(1)); + BOOST_REQUIRE_EQUAL("issuer", row["issuer"].as_string()); + BOOST_REQUIRE_EQUAL("oppenvelope", row["schema"].as_string()); + BOOST_REQUIRE_EQUAL(10 * UNIT, row["covered"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, row["bonded"].as_uint64()); + BOOST_REQUIRE_EQUAL(1 * UNIT, row["bounty"].as_uint64()); + BOOST_REQUIRE_EQUAL(WINDOW_SEC, row["window_sec"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, row["hold_bond"].as_uint64()); + + const auto escrow = escrow_row_of(1, KIND_BOUNTY_BYTE); + BOOST_REQUIRE(!escrow.is_null()); + BOOST_REQUIRE_EQUAL(KIND_BOUNTY, escrow["kind"].as_string()); + BOOST_REQUIRE_EQUAL("issuer", escrow["funder"].as_string()); + BOOST_REQUIRE_EQUAL(1 * UNIT, escrow["amount"].as_uint64()); + BOOST_REQUIRE_EQUAL(false, escrow["paid"].as_bool()); + + // Ids come from the counter: the next request is 2. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'b'), LIQSOL, 10 * UNIT, 0)); + BOOST_REQUIRE(!request_row_of(2).is_null()); + BOOST_REQUIRE(escrow_row_of(2, KIND_BOUNTY_BYTE).is_null()); // no bounty, no escrow +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(request_in_wire_is_accepted, sysio_bond_tester) try { + const auto before = wire_balance("issuer"_n); + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), WIRE, 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL(before, wire_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(STATE_OPEN, request_state_of(1)); + BOOST_REQUIRE(escrow_row_of(1, KIND_BOUNTY_BYTE).is_null()); + + // A WIRE bounty is pulled from sysio.token. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'b'), WIRE, 10 * UNIT, 3 * UNIT)); + BOOST_REQUIRE_EQUAL(before - 3 * UNIT, wire_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(3 * UNIT, wire_balance(BOND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(3 * UNIT, escrow_row_of(2, KIND_BOUNTY_BYTE)["amount"].as_uint64()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(request_refuses_a_covered_amount_off_the_increment, sysio_bond_tester) try { + // Precision 9 with two bond decimals: the increment is 10'000'000 base units. + const uint64_t increment = 10'000'000; + const auto refused = wasm_assert_msg(std::string(bond_increment_msg)); + BOOST_REQUIRE_EQUAL(refused, request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT + 1, 0)); + BOOST_REQUIRE_EQUAL(refused, request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, increment - 1, 0)); + BOOST_REQUIRE_EQUAL(refused, request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 0, 0)); + BOOST_REQUIRE(request_row_of(1).is_null()); + + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, increment, 0)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(request_refuses_a_token_below_two_decimals, sysio_bond_tester) try { + constexpr std::string_view LIQX = "LIQX"; + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, LIQX, 1)); + BOOST_REQUIRE_EQUAL(success(), create_shadow(symbol::from_string("1,LIQX"), LIQX)); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(precision_msg)), + request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQX, 100, 0)); + BOOST_REQUIRE(request_row_of(1).is_null()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(request_refuses_an_unknown_token_and_a_long_statement, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(unsupported_token_msg)), + request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), "NOPE", 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(statement_len_msg)), + request("issuer"_n, "oppenvelope"_n, bytes(MAX_STATEMENT_BYTES + 1, 'a'), LIQSOL, + 10 * UNIT, 0)); + BOOST_REQUIRE(request_row_of(1).is_null()); + + // Exactly the maximum is accepted. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(MAX_STATEMENT_BYTES, 'a'), LIQSOL, + 10 * UNIT, 0)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(request_refuses_a_zero_window_and_another_signer, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(window_msg)), + request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0, 0)); + BOOST_REQUIRE_EQUAL(error("missing authority of issuer"), + push_bond("alice"_n, "request"_n, mvo() + ("issuer", "issuer"_n)("schema", "oppenvelope"_n)("statement", bytes(32, 'a')) + ("token_code", codename_mvo(LIQSOL))("covered", 10 * UNIT)("bounty", 0) + ("window_sec", WINDOW_SEC))); + BOOST_REQUIRE(request_row_of(1).is_null()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(request_refuses_a_duplicate_statement_for_the_same_issuer, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(duplicate_msg)), + request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 20 * UNIT, 0)); + // The digest covers the token nowhere: a different token is still the same statement. + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(duplicate_msg)), + request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), WIRE, 10 * UNIT, 0)); + + // Another issuer, or another schema, is another statement. + BOOST_REQUIRE_EQUAL(success(), request("alice"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "otherschema"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL("alice", request_row_of(2)["issuer"].as_string()); + BOOST_REQUIRE_EQUAL("otherschema", request_row_of(3)["schema"].as_string()); +} FC_LOG_AND_RETHROW() + +// A request's rows bill the issuer that created them, never the sysio pool: only the id counter, +// written once, is the contract's own. +BOOST_FIXTURE_TEST_CASE(request_rows_bill_the_issuer, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT)); + BOOST_REQUIRE_EQUAL("issuer"_n, payer_of("requests"_n, request_key_of(1))); + std::string bounty_key = request_key_of(1); + bounty_key.push_back(char(KIND_BOUNTY_BYTE)); + BOOST_REQUIRE_EQUAL("issuer"_n, payer_of("escrows"_n, bounty_key)); + + // With the counter in place, a second request adds nothing to sysio's RAM and grows the issuer's. + const auto sysio_ram = ram_usage(SYSIO_ACCOUNT); + const auto issuer_ram = ram_usage("issuer"_n); + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'b'), LIQSOL, 10 * UNIT, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(sysio_ram, ram_usage(SYSIO_ACCOUNT)); + BOOST_REQUIRE_GT(ram_usage("issuer"_n), issuer_ram); + + // A raise by the issuer keeps the rows its own. + BOOST_REQUIRE_EQUAL(success(), addbounty("issuer"_n, 2, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(sysio_ram, ram_usage(SYSIO_ACCOUNT)); + BOOST_REQUIRE_EQUAL("issuer"_n, payer_of("requests"_n, request_key_of(2))); +} FC_LOG_AND_RETHROW() + +// A privileged account -- a system contract, as sysio.synd is when it issues for its envelopes -- has +// no RAM quota of its own, so the rows of its request bill the sysio RAM pool instead. +BOOST_FIXTURE_TEST_CASE(a_privileged_issuer_bills_sysio, sysio_bond_tester) try { + // sysio.tokens, the token registry, is a privileged system contract of the fixture. + constexpr auto system_contract = TOKENS_ACCOUNT; + BOOST_REQUIRE_EQUAL(success(), transfer_wire(SYSIO_ACCOUNT, system_contract, 100 * UNIT)); + const auto issuer_ram = ram_usage(system_contract); + const auto sysio_ram = ram_usage(SYSIO_ACCOUNT); + + BOOST_REQUIRE_EQUAL(success(), + request(system_contract, "oppenvelope"_n, bytes(32, 'a'), WIRE, 10 * UNIT, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(SYSIO_ACCOUNT, payer_of("requests"_n, request_key_of(1))); + std::string bounty_key = request_key_of(1); + bounty_key.push_back(char(KIND_BOUNTY_BYTE)); + BOOST_REQUIRE_EQUAL(SYSIO_ACCOUNT, payer_of("escrows"_n, bounty_key)); + BOOST_REQUIRE_EQUAL(issuer_ram, ram_usage(system_contract)); + BOOST_REQUIRE_GT(ram_usage(SYSIO_ACCOUNT), sysio_ram); + + // A raise keeps the rows on the pool. + BOOST_REQUIRE_EQUAL(success(), addbounty(system_contract, 1, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(SYSIO_ACCOUNT, payer_of("escrows"_n, bounty_key)); + BOOST_REQUIRE_EQUAL(issuer_ram, ram_usage(system_contract)); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// addbounty +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(addbounty_raises_and_escrows, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT)); + const auto before = liq_balance("issuer"_n); + + BOOST_REQUIRE_EQUAL(success(), addbounty("issuer"_n, 1, 2 * UNIT)); + BOOST_REQUIRE_EQUAL(before - 2 * UNIT, liq_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(3 * UNIT, liq_balance(BOND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(3 * UNIT, request_row_of(1)["bounty"].as_uint64()); + BOOST_REQUIRE_EQUAL(3 * UNIT, escrow_row_of(1, KIND_BOUNTY_BYTE)["amount"].as_uint64()); + BOOST_REQUIRE_EQUAL(STATE_OPEN, request_state_of(1)); + + BOOST_REQUIRE_EQUAL(error("missing authority of issuer"), addbounty("alice"_n, 1, 2 * UNIT)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(amount_positive_msg)), addbounty("issuer"_n, 1, 0)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(request_missing_msg)), addbounty("issuer"_n, 99, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(3 * UNIT, request_row_of(1)["bounty"].as_uint64()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(addbounty_opens_the_escrow_of_a_request_without_one, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), WIRE, 10 * UNIT, 0)); + BOOST_REQUIRE(escrow_row_of(1, KIND_BOUNTY_BYTE).is_null()); + const auto before = wire_balance("issuer"_n); + + BOOST_REQUIRE_EQUAL(success(), addbounty("issuer"_n, 1, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(before - 5 * UNIT, wire_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(5 * UNIT, request_row_of(1)["bounty"].as_uint64()); + const auto escrow = escrow_row_of(1, KIND_BOUNTY_BYTE); + BOOST_REQUIRE_EQUAL("issuer", escrow["funder"].as_string()); + BOOST_REQUIRE_EQUAL(5 * UNIT, escrow["amount"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// accept +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(accept_pulls_the_bond_and_records_it, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0)); + const auto before = liq_balance("alice"_n); + const auto bond_before = liq_balance(BOND_ACCOUNT); + + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(before - 4 * UNIT, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(bond_before + 4 * UNIT, liq_balance(BOND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(4 * UNIT, bond_amount_of(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(4 * UNIT, request_row_of(1)["bonded"].as_uint64()); + BOOST_REQUIRE_EQUAL(STATE_OPEN, request_state_of(1)); + BOOST_REQUIRE(request_row_of(1)["bonded_at"].as() == time_point{}); + + const auto rows = bond_rows_of(1); + BOOST_REQUIRE_EQUAL(1u, rows.size()); + BOOST_REQUIRE_EQUAL(false, rows.front()["paid"].as_bool()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(accept_refuses_off_increment_and_zero, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0)); + const auto before = liq_balance("alice"_n); + const auto refused = wasm_assert_msg(std::string(bond_increment_msg)); + + BOOST_REQUIRE_EQUAL(refused, accept("alice"_n, 1, 4 * UNIT + 1)); + BOOST_REQUIRE_EQUAL(refused, accept("alice"_n, 1, 0)); + BOOST_REQUIRE_EQUAL(before, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(0u, request_row_of(1)["bonded"].as_uint64()); + BOOST_REQUIRE(bond_rows_of(1).empty()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(accept_is_reduced_to_the_remainder_and_pulls_only_that, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 4 * UNIT)); + BOOST_REQUIRE(!notified("issuer"_n, "accept"_n)); + const auto before = liq_balance("bob"_n); + const auto bond_before = liq_balance(BOND_ACCOUNT); + + BOOST_REQUIRE_EQUAL(success(), accept("bob"_n, 1, 100 * UNIT)); + BOOST_REQUIRE(!notified("issuer"_n, "accept"_n)); // OPEN -> BONDED notifies no one + BOOST_REQUIRE_EQUAL(before - 6 * UNIT, liq_balance("bob"_n)); + BOOST_REQUIRE_EQUAL(bond_before + 6 * UNIT, liq_balance(BOND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(6 * UNIT, bond_amount_of(1, "bob"_n)); + + const auto row = request_row_of(1); + BOOST_REQUIRE_EQUAL(10 * UNIT, row["bonded"].as_uint64()); + BOOST_REQUIRE_EQUAL(STATE_BONDED, request_state_of(1)); + BOOST_REQUIRE(row["bonded_at"].as() != time_point{}); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(accept_refuses_once_fully_bonded, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(STATE_BONDED, request_state_of(1)); + const auto bonded_at = request_row_of(1)["bonded_at"].as(); + const auto before = liq_balance("bob"_n); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(fully_bonded_msg)), accept("bob"_n, 1, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(fully_bonded_msg)), accept("alice"_n, 1, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(before, liq_balance("bob"_n)); + BOOST_REQUIRE_EQUAL(0u, bond_amount_of(1, "bob"_n)); + BOOST_REQUIRE_EQUAL(10 * UNIT, bond_amount_of(1, "alice"_n)); + BOOST_REQUIRE(bonded_at == request_row_of(1)["bonded_at"].as()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(accept_twice_by_one_underwriter_adds_to_one_row, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0)); + const auto before = liq_balance("alice"_n); + + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 2 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 3 * UNIT)); + BOOST_REQUIRE_EQUAL(before - 5 * UNIT, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(1u, bond_rows_of(1).size()); + BOOST_REQUIRE_EQUAL(5 * UNIT, bond_amount_of(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(5 * UNIT, request_row_of(1)["bonded"].as_uint64()); + BOOST_REQUIRE_EQUAL(STATE_OPEN, request_state_of(1)); +} FC_LOG_AND_RETHROW() + +// The positions of accept and request checkpoint sysio.liq's live index, once a distribution has +// raised it above zero, and a second accept banks what the first bond earned in between. +BOOST_FIXTURE_TEST_CASE(accept_and_request_checkpoint_the_live_index, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 100 * UNIT)); + const auto first_index = liq_index(); + BOOST_REQUIRE(first_index > 0); + + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT)); + const auto bounty = raw_escrow_of(1, KIND_BOUNTY_BYTE); + BOOST_REQUIRE(bounty.yield.index_checkpoint == first_index); + BOOST_REQUIRE_EQUAL(0u, bounty.yield.owed_wire); + + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 2 * UNIT)); + auto bond = raw_bond_of(1, "alice"_n); + BOOST_REQUIRE(bond.yield.index_checkpoint == first_index); + BOOST_REQUIRE_EQUAL(0u, bond.yield.owed_wire); + + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 100 * UNIT)); + const auto second_index = liq_index(); + BOOST_REQUIRE(second_index > first_index); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 3 * UNIT)); + bond = raw_bond_of(1, "alice"_n); + BOOST_REQUIRE(bond.yield.index_checkpoint == second_index); + // The 2 UNIT bonded between the two distributions earned their share of the second one. + const auto earned = static_cast(static_cast(2 * UNIT) * (second_index - first_index) / + YIELD_INDEX_SCALE); + BOOST_REQUIRE(earned > 0); + BOOST_REQUIRE_EQUAL(earned, bond.yield.owed_wire); + BOOST_REQUIRE_EQUAL(5 * UNIT, bond.amount); + + // A WIRE bond earns no shadow yield: its position stays default. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'b'), WIRE, 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 2, 4 * UNIT)); + const auto wire_bond = raw_bond_of(2, "alice"_n); + BOOST_REQUIRE(wire_bond.yield.index_checkpoint == 0); + BOOST_REQUIRE_EQUAL(0u, wire_bond.yield.owed_wire); +} FC_LOG_AND_RETHROW() + +// A bond row bills its underwriter; the request row the accept updates stays the issuer's. +BOOST_FIXTURE_TEST_CASE(accept_bills_the_underwriter, sysio_bond_tester) try { + // The bounty opens sysio.bond's own LIQSOL holder row on sysio.liq first, which sysio.liq bills. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT)); + const auto sysio_ram = ram_usage(SYSIO_ACCOUNT); + const auto issuer_ram = ram_usage("issuer"_n); + const auto alice_ram = ram_usage("alice"_n); + + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 10 * UNIT)); + std::string bond_key = request_key_of(1); + append_key_word(bond_key, "alice"_n.to_uint64_t()); + BOOST_REQUIRE_EQUAL("alice"_n, payer_of("bonds"_n, bond_key)); + BOOST_REQUIRE_EQUAL("issuer"_n, payer_of("requests"_n, request_key_of(1))); + BOOST_REQUIRE_GT(ram_usage("alice"_n), alice_ram); + BOOST_REQUIRE_EQUAL(issuer_ram, ram_usage("issuer"_n)); + BOOST_REQUIRE_EQUAL(sysio_ram, ram_usage(SYSIO_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(accept_in_wire_pulls_wire, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), WIRE, 10 * UNIT, 0)); + const auto before = wire_balance("alice"_n); + const auto liq_before = liq_balance("alice"_n); + + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(before - 10 * UNIT, wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(10 * UNIT, wire_balance(BOND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(liq_before, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(10 * UNIT, bond_amount_of(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_BONDED, request_state_of(1)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(accept_requires_the_underwriter_authority, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0)); + const auto before = liq_balance("alice"_n); + + BOOST_REQUIRE_EQUAL(error("missing authority of alice"), + push_bond("bob"_n, "accept"_n, mvo() + ("underwriter", "alice"_n)("request_id", 1)("amount", 4 * UNIT))); + BOOST_REQUIRE_EQUAL(before, liq_balance("alice"_n)); + BOOST_REQUIRE(bond_rows_of(1).empty()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(request_missing_msg)), accept("alice"_n, 99, 4 * UNIT)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(accept_refuses_a_held_request, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL(success(), hold("issuer"_n, 1, "carol"_n)); + const auto before = liq_balance("alice"_n); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(not_accepting_msg)), accept("alice"_n, 1, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(before, liq_balance("alice"_n)); + BOOST_REQUIRE(bond_rows_of(1).empty()); + BOOST_REQUIRE_EQUAL(STATE_HELD, request_state_of(1)); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// approve +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(approve_refuses_before_the_window_and_when_unbonded, sysio_bond_tester) try { + constexpr uint32_t window_sec = 3600; + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0, + window_sec)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(not_bonded_msg)), approve(1)); + + // Partly bonded is still OPEN. + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(not_bonded_msg)), approve(1)); + + BOOST_REQUIRE_EQUAL(success(), accept("bob"_n, 1, 6 * UNIT)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(window_open_msg)), approve(1)); + produce_block(fc::seconds(window_sec / 2)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(window_open_msg)), approve(1)); + BOOST_REQUIRE_EQUAL(STATE_BONDED, request_state_of(1)); + BOOST_REQUIRE(request_row_of(1)["resolved_at"].as() == time_point{}); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(request_missing_msg)), approve(99)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(approve_after_the_window, sysio_bond_tester) try { + constexpr uint32_t window_sec = 3600; + bonded_request(1, 'a', window_sec); + const auto bonded_at = request_row_of(1)["bonded_at"].as(); + pass_window(window_sec); + + BOOST_REQUIRE_EQUAL(success(), approve(1)); + BOOST_REQUIRE(!notified("issuer"_n, "approve"_n)); // a state change only + BOOST_REQUIRE(settlements().empty()); + const auto row = request_row_of(1); + BOOST_REQUIRE_EQUAL(STATE_APPROVED, request_state_of(1)); + const auto resolved_at = row["resolved_at"].as(); + BOOST_REQUIRE(resolved_at >= bonded_at + fc::seconds(window_sec)); + + // Approval is terminal: a second approve is refused and leaves the row as it was. + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(not_bonded_msg)), approve(1)); + BOOST_REQUIRE(resolved_at == request_row_of(1)["resolved_at"].as()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(approve_refuses_a_held_request, sysio_bond_tester) try { + constexpr uint32_t window_sec = 3600; + bonded_request(1, 'a', window_sec); + BOOST_REQUIRE_EQUAL(success(), hold("issuer"_n, 1, "carol"_n)); + pass_window(window_sec); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(not_bonded_msg)), approve(1)); + BOOST_REQUIRE_EQUAL(STATE_HELD, request_state_of(1)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(addbounty_refuses_a_terminal_request, sysio_bond_tester) try { + constexpr uint32_t window_sec = 3600; + bonded_request(1, 'a', window_sec); + pass_window(window_sec); + BOOST_REQUIRE_EQUAL(success(), approve(1)); + const auto before = liq_balance("issuer"_n); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(terminal_msg)), addbounty("issuer"_n, 1, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(before, liq_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(0u, request_row_of(1)["bounty"].as_uint64()); + BOOST_REQUIRE(escrow_row_of(1, KIND_BOUNTY_BYTE).is_null()); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// hold +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(hold_in_open_charges_the_issuer_the_configured_share, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0)); + const auto before = liq_balance("issuer"_n); + const auto bond_before = liq_balance(BOND_ACCOUNT); + + BOOST_REQUIRE_EQUAL(success(), hold("issuer"_n, 1, "carol"_n)); + BOOST_REQUIRE(!notified("issuer"_n, "hold"_n)); + BOOST_REQUIRE_EQUAL(before - 1 * UNIT, liq_balance("issuer"_n)); // 10% of 10 UNIT + BOOST_REQUIRE_EQUAL(bond_before + 1 * UNIT, liq_balance(BOND_ACCOUNT)); + + const auto row = request_row_of(1); + BOOST_REQUIRE_EQUAL(STATE_HELD, request_state_of(1)); + BOOST_REQUIRE_EQUAL(1 * UNIT, row["hold_bond"].as_uint64()); + BOOST_REQUIRE_EQUAL("carol", row["hold_beneficiary"].as_string()); + BOOST_REQUIRE(row["held_at"].as() != time_point{}); + BOOST_REQUIRE(row["resolved_at"].as() == time_point{}); + + const auto escrow = escrow_row_of(1, KIND_HOLD_BOND_BYTE); + BOOST_REQUIRE(!escrow.is_null()); + BOOST_REQUIRE_EQUAL(KIND_HOLD_BOND, escrow["kind"].as_string()); + BOOST_REQUIRE_EQUAL("issuer", escrow["funder"].as_string()); + BOOST_REQUIRE_EQUAL(1 * UNIT, escrow["amount"].as_uint64()); + BOOST_REQUIRE_EQUAL(false, escrow["paid"].as_bool()); + BOOST_REQUIRE(escrow_row_of(1, KIND_BOUNTY_BYTE).is_null()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(hold_in_bonded_and_after_setconfig, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), setconfig(SYSIO_ACCOUNT, 2500)); + bonded_request(1, 'a'); + const auto before = liq_balance("issuer"_n); + + BOOST_REQUIRE_EQUAL(success(), hold("issuer"_n, 1, "carol"_n)); + BOOST_REQUIRE_EQUAL(before - 2 * UNIT - UNIT / 2, liq_balance("issuer"_n)); // 25% of 10 UNIT + BOOST_REQUIRE_EQUAL(STATE_HELD, request_state_of(1)); + BOOST_REQUIRE_EQUAL(2 * UNIT + UNIT / 2, request_row_of(1)["hold_bond"].as_uint64()); + BOOST_REQUIRE_EQUAL(2 * UNIT + UNIT / 2, escrow_row_of(1, KIND_HOLD_BOND_BYTE)["amount"].as_uint64()); + // The bond stays where it was. + BOOST_REQUIRE_EQUAL(10 * UNIT, bond_amount_of(1, "alice"_n)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(hold_in_wire_pulls_wire, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), WIRE, 10 * UNIT, 0)); + const auto before = wire_balance("issuer"_n); + const auto liq_before = liq_balance("issuer"_n); + + BOOST_REQUIRE_EQUAL(success(), hold("issuer"_n, 1, "carol"_n)); + BOOST_REQUIRE_EQUAL(before - 1 * UNIT, wire_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(1 * UNIT, wire_balance(BOND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(liq_before, liq_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(1 * UNIT, escrow_row_of(1, KIND_HOLD_BOND_BYTE)["amount"].as_uint64()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(hold_refuses_after_approval_and_twice, sysio_bond_tester) try { + constexpr uint32_t window_sec = 3600; + bonded_request(1, 'a', window_sec); + pass_window(window_sec); + BOOST_REQUIRE_EQUAL(success(), approve(1)); + const auto before = liq_balance("issuer"_n); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(hold_state_msg)), hold("issuer"_n, 1, "carol"_n)); + BOOST_REQUIRE_EQUAL(before, liq_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(STATE_APPROVED, request_state_of(1)); + BOOST_REQUIRE_EQUAL(0u, request_row_of(1)["hold_bond"].as_uint64()); + BOOST_REQUIRE(escrow_row_of(1, KIND_HOLD_BOND_BYTE).is_null()); + + // A second hold on a HELD request is refused and charges nothing. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'b'), LIQSOL, 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL(success(), hold("issuer"_n, 2, "carol"_n)); + const auto held = liq_balance("issuer"_n); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(hold_state_msg)), hold("issuer"_n, 2, "bob"_n)); + BOOST_REQUIRE_EQUAL(held, liq_balance("issuer"_n)); + const auto row = request_row_of(2); + BOOST_REQUIRE_EQUAL(1 * UNIT, row["hold_bond"].as_uint64()); + BOOST_REQUIRE_EQUAL("carol", row["hold_beneficiary"].as_string()); + BOOST_REQUIRE_EQUAL(1 * UNIT, escrow_row_of(2, KIND_HOLD_BOND_BYTE)["amount"].as_uint64()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(hold_requires_the_issuer, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0)); + const auto before = liq_balance("issuer"_n); + + BOOST_REQUIRE_EQUAL(error("missing authority of issuer"), hold("alice"_n, 1, "alice"_n)); + BOOST_REQUIRE_EQUAL(before, liq_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(STATE_OPEN, request_state_of(1)); + BOOST_REQUIRE(escrow_row_of(1, KIND_HOLD_BOND_BYTE).is_null()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(request_missing_msg)), hold("issuer"_n, 99, "carol"_n)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(hold_refuses_a_beneficiary_that_is_not_an_account, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0)); + const auto before = liq_balance("issuer"_n); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(beneficiary_msg)), hold("issuer"_n, 1, "nobody"_n)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(beneficiary_msg)), hold("issuer"_n, 1, name{})); + // The contract is an account, but a forfeit awarded to it could never be claimed out. + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(beneficiary_self_msg)), hold("issuer"_n, 1, BOND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(before, liq_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(STATE_OPEN, request_state_of(1)); + BOOST_REQUIRE(escrow_row_of(1, KIND_HOLD_BOND_BYTE).is_null()); + BOOST_REQUIRE_EQUAL(success(), hold("issuer"_n, 1, "carol"_n)); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Rulings and claims: the payout table +// --------------------------------------------------------------------------- + +// Shares throughout: covered 10 UNIT, alice 4, bob 6, bounty 1 UNIT, hold_bps 1000 -> hold bond 1 UNIT. + +BOOST_FIXTURE_TEST_CASE(approved_returns_bonds_and_splits_the_bounty_by_stake, sysio_bond_tester) try { + constexpr uint32_t window_sec = 3600; + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT, + window_sec)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), accept("bob"_n, 1, 6 * UNIT)); + pass_window(window_sec); + BOOST_REQUIRE_EQUAL(success(), approve(1)); + const auto alice = liq_balance("alice"_n); + const auto bob = liq_balance("bob"_n); + + // Permissionless: a bystander claims for alice, and alice is paid. + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n, "carol"_n)); + BOOST_REQUIRE_EQUAL(alice + 4 * UNIT + 4 * UNIT / 10, liq_balance("alice"_n)); + BOOST_REQUIRE(raw_bond_of(1, "alice"_n).paid); + BOOST_REQUIRE_EQUAL(6 * UNIT / 10, raw_escrow_of(1, KIND_BOUNTY_BYTE).amount); + + BOOST_REQUIRE_EQUAL(success(), claim(1, "bob"_n)); + BOOST_REQUIRE_EQUAL(bob + 6 * UNIT + 6 * UNIT / 10, liq_balance("bob"_n)); + const auto paid = settlements(); + BOOST_REQUIRE_EQUAL(1u, paid.size()); // bond and bounty share in one transfer + BOOST_REQUIRE_EQUAL("bob", paid.front().to.to_string()); + BOOST_REQUIRE_EQUAL(6 * UNIT + 6 * UNIT / 10, paid.front().quantity.get_amount()); + BOOST_REQUIRE_EQUAL(0u, raw_escrow_of(1, KIND_BOUNTY_BYTE).amount); + BOOST_REQUIRE_EQUAL(0u, liq_balance(BOND_ACCOUNT)); + + // Paid once only; an account with neither bond nor escrow, and a funder whose escrow earned no + // yield, are owed nothing. + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "bob"_n)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "carol"_n)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "issuer"_n)); + BOOST_REQUIRE_EQUAL(alice + 4 * UNIT + 4 * UNIT / 10, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(request_missing_msg)), claim(99, "alice"_n)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(valid_after_a_hold_pays_the_hold_bond_to_the_underwriters, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), accept("bob"_n, 1, 6 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), hold("issuer"_n, 1, "carol"_n)); + const auto issuer = liq_balance("issuer"_n); + + BOOST_REQUIRE_EQUAL(success(), rslvvalid(SYSIO_ACCOUNT, 1)); + BOOST_REQUIRE(!notified("issuer"_n, "rslvvalid"_n)); + BOOST_REQUIRE_EQUAL(STATE_VALID, request_state_of(1)); + BOOST_REQUIRE(request_row_of(1)["resolved_at"].as() != time_point{}); + BOOST_REQUIRE(settlements().empty()); // a ruling sends nothing + BOOST_REQUIRE_EQUAL(issuer, liq_balance("issuer"_n)); + // Fully bonded: no escrow awards anything outside the underwriters. + BOOST_REQUIRE_EQUAL(0u, raw_escrow_of(1, KIND_BOUNTY_BYTE).payout); + BOOST_REQUIRE_EQUAL(0u, raw_escrow_of(1, KIND_HOLD_BOND_BYTE).payout); + + const auto alice = liq_balance("alice"_n); + const auto bob = liq_balance("bob"_n); + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(alice + 4 * UNIT + 4 * UNIT / 10 + 4 * UNIT / 10, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(success(), claim(1, "bob"_n)); + BOOST_REQUIRE_EQUAL(bob + 6 * UNIT + 6 * UNIT / 10 + 6 * UNIT / 10, liq_balance("bob"_n)); + BOOST_REQUIRE_EQUAL(0u, raw_escrow_of(1, KIND_BOUNTY_BYTE).amount); + BOOST_REQUIRE_EQUAL(0u, raw_escrow_of(1, KIND_HOLD_BOND_BYTE).amount); + + // The challenger who held a valid statement is owed nothing. + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "carol"_n)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(valid_while_partly_bonded_pays_the_unbonded_share_to_sysio_and_issuer, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 4 * UNIT)); + const auto held_from = liq_balance("issuer"_n); + BOOST_REQUIRE_EQUAL(success(), hold("issuer"_n, 1, "carol"_n)); + BOOST_REQUIRE_EQUAL(held_from - 1 * UNIT, liq_balance("issuer"_n)); + const auto issuer = liq_balance("issuer"_n); + const auto sysio = liq_balance(SYSIO_ACCOUNT); + + // The ruling sends nothing: it awards the unbonded 6/10 of each pot, the bounty's to sysio and the + // hold bond's to the issuer. + BOOST_REQUIRE_EQUAL(success(), rslvvalid(SYSIO_ACCOUNT, 1)); + BOOST_REQUIRE(!notified("issuer"_n, "rslvvalid"_n)); + BOOST_REQUIRE(settlements().empty()); + BOOST_REQUIRE_EQUAL(sysio, liq_balance(SYSIO_ACCOUNT)); + BOOST_REQUIRE_EQUAL(issuer, liq_balance("issuer"_n)); + auto bounty = raw_escrow_of(1, KIND_BOUNTY_BYTE); + BOOST_REQUIRE_EQUAL(SYSIO_ACCOUNT, bounty.payee); + BOOST_REQUIRE_EQUAL(6 * UNIT / 10, bounty.payout); + BOOST_REQUIRE_EQUAL(1 * UNIT, bounty.amount); + auto hold_bond = raw_escrow_of(1, KIND_HOLD_BOND_BYTE); + BOOST_REQUIRE_EQUAL("issuer"_n, hold_bond.payee); + BOOST_REQUIRE_EQUAL(6 * UNIT / 10, hold_bond.payout); + + // Each payee pulls its award. + BOOST_REQUIRE_EQUAL(success(), claim(1, SYSIO_ACCOUNT, "carol"_n)); + BOOST_REQUIRE_EQUAL(sysio + 6 * UNIT / 10, liq_balance(SYSIO_ACCOUNT)); + BOOST_REQUIRE_EQUAL(success(), claim(1, "issuer"_n)); + BOOST_REQUIRE_EQUAL(issuer + 6 * UNIT / 10, liq_balance("issuer"_n)); + const auto paid = settlements(); + BOOST_REQUIRE_EQUAL(1u, paid.size()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, SYSIO_ACCOUNT, "carol"_n)); + // The escrows keep what remains for the underwriters. + BOOST_REQUIRE_EQUAL(4 * UNIT / 10, raw_escrow_of(1, KIND_BOUNTY_BYTE).amount); + BOOST_REQUIRE_EQUAL(0u, raw_escrow_of(1, KIND_BOUNTY_BYTE).payout); + BOOST_REQUIRE_EQUAL(4 * UNIT / 10, raw_escrow_of(1, KIND_HOLD_BOND_BYTE).amount); + + const auto alice = liq_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(alice + 4 * UNIT + 4 * UNIT / 10 + 4 * UNIT / 10, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(0u, raw_escrow_of(1, KIND_BOUNTY_BYTE).amount); + BOOST_REQUIRE_EQUAL(0u, raw_escrow_of(1, KIND_HOLD_BOND_BYTE).amount); + BOOST_REQUIRE_EQUAL(0u, liq_balance(BOND_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(valid_with_nothing_bonded, sysio_bond_tester) try { + // No hold: the whole bounty is sysio's to claim, and nothing else is owed. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT)); + const auto issuer = liq_balance("issuer"_n); + const auto sysio = liq_balance(SYSIO_ACCOUNT); + BOOST_REQUIRE_EQUAL(success(), rslvvalid(SYSIO_ACCOUNT, 1)); + BOOST_REQUIRE_EQUAL(STATE_VALID, request_state_of(1)); + BOOST_REQUIRE(settlements().empty()); + BOOST_REQUIRE_EQUAL(success(), claim(1, SYSIO_ACCOUNT)); + BOOST_REQUIRE_EQUAL(sysio + 1 * UNIT, liq_balance(SYSIO_ACCOUNT)); + BOOST_REQUIRE_EQUAL(issuer, liq_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(1u, settlements().size()); + BOOST_REQUIRE_EQUAL(0u, raw_escrow_of(1, KIND_BOUNTY_BYTE).amount); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "issuer"_n)); + + // After a hold: the whole hold bond is the issuer's to claim back. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'b'), LIQSOL, 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL(success(), hold("issuer"_n, 2, "carol"_n)); + const auto held = liq_balance("issuer"_n); + BOOST_REQUIRE_EQUAL(success(), rslvvalid(SYSIO_ACCOUNT, 2)); + BOOST_REQUIRE_EQUAL(held, liq_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(success(), claim(2, "issuer"_n)); + BOOST_REQUIRE_EQUAL(held + 1 * UNIT, liq_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(0u, liq_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(2, "carol"_n)); + + // In WIRE the bounty is claimed through sysio.token. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'c'), WIRE, 10 * UNIT, 1 * UNIT)); + const auto sysio_wire = wire_balance(SYSIO_ACCOUNT); + BOOST_REQUIRE_EQUAL(success(), rslvvalid(SYSIO_ACCOUNT, 3)); + BOOST_REQUIRE_EQUAL(sysio_wire, wire_balance(SYSIO_ACCOUNT)); + BOOST_REQUIRE_EQUAL(success(), claim(3, SYSIO_ACCOUNT)); + BOOST_REQUIRE_EQUAL(sysio_wire + 1 * UNIT, wire_balance(SYSIO_ACCOUNT)); + BOOST_REQUIRE_EQUAL(0u, wire_balance(BOND_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(invalid_after_a_hold_forfeits_bonds_to_the_issuer_and_pays_the_challenger, + sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), accept("bob"_n, 1, 6 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), hold("issuer"_n, 1, "carol"_n)); + const auto issuer = liq_balance("issuer"_n); + const auto carol = liq_balance("carol"_n); + + // The ruling sends nothing: it records the forfeit and awards both escrows to the challenger. + BOOST_REQUIRE_EQUAL(success(), rslvinvalid(SYSIO_ACCOUNT, 1)); + BOOST_REQUIRE(!notified("issuer"_n, "rslvinvalid"_n)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, request_state_of(1)); + BOOST_REQUIRE(settlements().empty()); + BOOST_REQUIRE_EQUAL(issuer, liq_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(10 * UNIT, request_row_of(1)["forfeit_pending"].as_uint64()); + BOOST_REQUIRE_EQUAL("carol"_n, raw_escrow_of(1, KIND_BOUNTY_BYTE).payee); + BOOST_REQUIRE_EQUAL(1 * UNIT, raw_escrow_of(1, KIND_BOUNTY_BYTE).payout); + BOOST_REQUIRE_EQUAL("carol"_n, raw_escrow_of(1, KIND_HOLD_BOND_BYTE).payee); + BOOST_REQUIRE_EQUAL(1 * UNIT, raw_escrow_of(1, KIND_HOLD_BOND_BYTE).payout); + + // Every bond reaches the issuer's claim as ONE transfer under the forfeit memo. + BOOST_REQUIRE_EQUAL(success(), claim(1, "issuer"_n, "carol"_n)); + BOOST_REQUIRE_EQUAL(issuer + 10 * UNIT, liq_balance("issuer"_n)); + auto sent = settlements(); + BOOST_REQUIRE_EQUAL(1u, sent.size()); + BOOST_REQUIRE_EQUAL("issuer"_n, sent.front().to); + BOOST_REQUIRE_EQUAL(10 * UNIT, sent.front().quantity.get_amount()); + BOOST_REQUIRE_EQUAL(0u, request_row_of(1)["forfeit_pending"].as_uint64()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "issuer"_n)); + + // The challenger pulls the hold bond and the bounty in one transfer. + BOOST_REQUIRE_EQUAL(success(), claim(1, "carol"_n)); + BOOST_REQUIRE_EQUAL(carol + 1 * UNIT + 1 * UNIT, liq_balance("carol"_n)); + sent = settlements(); + BOOST_REQUIRE_EQUAL(1u, sent.size()); + BOOST_REQUIRE_EQUAL(0u, liq_balance(BOND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(0u, raw_escrow_of(1, KIND_BOUNTY_BYTE).amount); + BOOST_REQUIRE_EQUAL(0u, raw_escrow_of(1, KIND_HOLD_BOND_BYTE).amount); + + // Forfeited bonds that earned no yield leave the underwriters nothing to claim. + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "bob"_n)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "carol"_n)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(invalid_on_a_partly_bonded_request_forfeits_the_partial_bonds, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 4 * UNIT)); + const auto issuer = liq_balance("issuer"_n); + + BOOST_REQUIRE_EQUAL(success(), rslvinvalid(SYSIO_ACCOUNT, 1)); + BOOST_REQUIRE(!notified("issuer"_n, "rslvinvalid"_n)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, request_state_of(1)); + BOOST_REQUIRE(settlements().empty()); + BOOST_REQUIRE_EQUAL(success(), claim(1, "issuer"_n)); + BOOST_REQUIRE_EQUAL(issuer + 4 * UNIT + 1 * UNIT, liq_balance("issuer"_n)); // forfeit and bounty returned + + // The forfeit and the returned bounty arrive as two transfers; only the forfeit carries its memo. + const auto sent = settlements(); + BOOST_REQUIRE_EQUAL(2u, sent.size()); + BOOST_REQUIRE_EQUAL(4 * UNIT, sent.front().quantity.get_amount()); + BOOST_REQUIRE_EQUAL(1 * UNIT, sent.back().quantity.get_amount()); + BOOST_REQUIRE_EQUAL(0u, liq_balance(BOND_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(rulings_require_sysio_and_refuse_terminal_requests, sysio_bond_tester) try { + constexpr uint32_t window_sec = 3600; + bonded_request(1, 'a', window_sec); + BOOST_REQUIRE_EQUAL(error("missing authority of sysio"), rslvvalid("alice"_n, 1)); + BOOST_REQUIRE_EQUAL(error("missing authority of sysio"), rslvinvalid("alice"_n, 1)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(request_missing_msg)), rslvvalid(SYSIO_ACCOUNT, 99)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(request_missing_msg)), rslvinvalid(SYSIO_ACCOUNT, 99)); + // Not terminal yet: nothing to claim. + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_BONDED, request_state_of(1)); + + // Approval is terminal: no ruling follows it. + pass_window(window_sec); + BOOST_REQUIRE_EQUAL(success(), approve(1)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(terminal_msg)), rslvvalid(SYSIO_ACCOUNT, 1)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(terminal_msg)), rslvinvalid(SYSIO_ACCOUNT, 1)); + BOOST_REQUIRE_EQUAL(STATE_APPROVED, request_state_of(1)); + + // Nor does a ruling follow a ruling. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'b'), LIQSOL, 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL(success(), rslvvalid(SYSIO_ACCOUNT, 2)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(terminal_msg)), rslvinvalid(SYSIO_ACCOUNT, 2)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(terminal_msg)), rslvvalid(SYSIO_ACCOUNT, 2)); + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'c'), LIQSOL, 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL(success(), rslvinvalid(SYSIO_ACCOUNT, 3)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(terminal_msg)), rslvvalid(SYSIO_ACCOUNT, 3)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, request_state_of(3)); +} FC_LOG_AND_RETHROW() + +// A HOLD_BOND escrow exists only when the hold bond is positive; a hold whose bond rounds to zero +// leaves none, and both rulings read that as "no hold bond". +BOOST_FIXTURE_TEST_CASE(a_missing_hold_bond_row_is_no_hold_bond, sysio_bond_tester) try { + constexpr std::string_view LIQX = "LIQX"; + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, LIQX, 2)); + BOOST_REQUIRE_EQUAL(success(), create_shadow(symbol::from_string("2,LIQX"), LIQX)); + + // Precision 2: the increment is one base unit, and 1 * 1000 / 10000 rounds to zero. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQX, 1, 0)); + BOOST_REQUIRE_EQUAL(success(), hold("issuer"_n, 1, "carol"_n)); + BOOST_REQUIRE_EQUAL(0u, request_row_of(1)["hold_bond"].as_uint64()); + BOOST_REQUIRE(escrow_row_of(1, KIND_HOLD_BOND_BYTE).is_null()); + BOOST_REQUIRE_EQUAL(success(), rslvvalid(SYSIO_ACCOUNT, 1)); + BOOST_REQUIRE_EQUAL(STATE_VALID, request_state_of(1)); + BOOST_REQUIRE(settlements().empty()); + + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'b'), LIQX, 1, 0)); + BOOST_REQUIRE_EQUAL(success(), hold("issuer"_n, 2, "carol"_n)); + BOOST_REQUIRE_EQUAL(success(), rslvinvalid(SYSIO_ACCOUNT, 2)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, request_state_of(2)); + BOOST_REQUIRE(settlements().empty()); + + pass_retention(); + BOOST_REQUIRE_EQUAL(success(), prune(0, 10)); + BOOST_REQUIRE(request_row_of(1).is_null()); + BOOST_REQUIRE(request_row_of(2).is_null()); +} FC_LOG_AND_RETHROW() + +// An issuer that rejects every notification cannot block approval, a ruling or an underwriter's +// claim: none of them sends it anything. Only its own claim fails, and its forfeit stays recorded. +BOOST_FIXTURE_TEST_CASE(a_hostile_issuer_blocks_only_its_own_claim, sysio_bond_tester) try { + constexpr uint32_t window_sec = 3600; + // 1: approved. 2: held, then VALID while partly bonded, which awards the issuer part of its hold + // bond. 3: INVALID with no hold, which forfeits a bond to the issuer and awards its bounty back. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT, + window_sec)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'b'), LIQSOL, 10 * UNIT, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 2, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), hold("issuer"_n, 2, "carol"_n)); + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'c'), LIQSOL, 10 * UNIT, 1 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), accept("bob"_n, 3, 6 * UNIT)); + + set_code("issuer"_n, contracts::util::reject_all_wasm()); + produce_block(); + pass_window(window_sec); + + BOOST_REQUIRE_EQUAL(success(), approve(1)); + BOOST_REQUIRE_EQUAL(STATE_APPROVED, request_state_of(1)); + BOOST_REQUIRE_EQUAL(success(), rslvvalid(SYSIO_ACCOUNT, 2)); + BOOST_REQUIRE_EQUAL(STATE_VALID, request_state_of(2)); + BOOST_REQUIRE_EQUAL(success(), rslvinvalid(SYSIO_ACCOUNT, 3)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, request_state_of(3)); + + // The underwriters are paid. + const auto alice = liq_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(alice + 10 * UNIT + 1 * UNIT, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(success(), claim(2, "alice"_n)); + BOOST_REQUIRE_EQUAL(alice + 11 * UNIT + 4 * UNIT + 4 * UNIT / 10 + 4 * UNIT / 10, liq_balance("alice"_n)); + const auto sysio = liq_balance(SYSIO_ACCOUNT); + BOOST_REQUIRE_EQUAL(success(), claim(2, SYSIO_ACCOUNT, "carol"_n)); + BOOST_REQUIRE_EQUAL(sysio + 6 * UNIT / 10, liq_balance(SYSIO_ACCOUNT)); + + // LIQ credits cannot invoke the issuer's rejecting code. + const auto issuer = liq_balance("issuer"_n); + BOOST_REQUIRE_EQUAL(success(), claim(3, "issuer"_n, "carol"_n)); + BOOST_REQUIRE(!notified("issuer"_n, "settle"_n)); + BOOST_REQUIRE_EQUAL(success(), claim(2, "issuer"_n, "carol"_n)); + BOOST_REQUIRE(!notified("issuer"_n, "settle"_n)); + BOOST_REQUIRE_EQUAL(issuer + 7 * UNIT + 6 * UNIT / 10, liq_balance("issuer"_n)); + BOOST_REQUIRE_EQUAL(0u, request_row_of(3)["forfeit_pending"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Yield +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(unbacked_yield_survives_pruning_and_never_repays_principal, sysio_bond_tester) try { + bonded_request(1, 'a', 3600); + const auto start = liq_index(); + uint64_t earned = 0, received = 0; + for (unsigned i = 0; i < 20 && earned <= received; ++i) { + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, UNIT)); + BOOST_REQUIRE_EQUAL(success(), sweepyield(LIQSOL)); + earned = static_cast(static_cast(10 * UNIT) * (liq_index() - start) / YIELD_INDEX_SCALE); + received = pool_row_of()["pool"]["received"].as_uint64(); + } + BOOST_REQUIRE_GT(earned, received); + pass_window(3600); + BOOST_REQUIRE_EQUAL(success(), approve(1)); + const auto principal_before = liq_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(principal_before + 10 * UNIT, liq_balance("alice"_n)); + const auto residual = raw_bond_of(1, "alice"_n).yield.owed_wire; + BOOST_REQUIRE_GT(residual, 0u); + BOOST_REQUIRE(raw_bond_of(1, "alice"_n).paid); + pass_retention(); + BOOST_REQUIRE_EQUAL(success(), prune(0, 100)); + BOOST_REQUIRE(!request_row_of(1).is_null()); + BOOST_REQUIRE_EQUAL(residual, raw_bond_of(1, "alice"_n).yield.owed_wire); + // Unattributed custody earns slack that can back the already-recorded residual. + BOOST_REQUIRE_EQUAL(success(), mint(BOND_ACCOUNT, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, UNIT)); + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(principal_before + 10 * UNIT, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(0u, raw_bond_of(1, "alice"_n).yield.owed_wire); + const auto wire_before = wire_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), claimwire("alice"_n)); + BOOST_REQUIRE_EQUAL(wire_before + earned, wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(success(), prune(0, 100)); + BOOST_REQUIRE(request_row_of(1).is_null()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(rejected_wire_cannot_block_liq_or_expire_with_request, sysio_bond_tester) try { + bonded_request(1, 'a', 3600); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 100 * UNIT)); + pass_window(3600); + BOOST_REQUIRE_EQUAL(success(), approve(1)); + const auto before_liq = liq_balance("alice"_n); + const auto before_wire = wire_balance("alice"_n); + const auto earned = liq_owed(BOND_ACCOUNT); + set_code("alice"_n, contracts::util::block_transfer_wasm()); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(before_liq + 10 * UNIT, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(before_wire, wire_balance("alice"_n)); + BOOST_REQUIRE(!notified("alice"_n, "settle"_n)); + BOOST_REQUIRE(!notified("alice"_n, "transfer"_n)); + BOOST_REQUIRE(claimwire("alice"_n) != success()); + pass_retention(); + BOOST_REQUIRE_EQUAL(success(), prune(0, 100)); + set_code("alice"_n, std::vector{}); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), claimwire("alice"_n)); + BOOST_REQUIRE_EQUAL(before_wire + earned, wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claimwire("alice"_n)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(bond_yield_is_paid_with_the_bond, sysio_bond_tester) try { + constexpr uint32_t window_sec = 3600; + bonded_request(1, 'a', window_sec); // alice bonds 10 UNIT of LIQSOL, no bounty + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 100 * UNIT)); + pass_window(window_sec); + BOOST_REQUIRE_EQUAL(success(), approve(1)); + + // What sysio.liq owes sysio.bond's row -- custody::custodian_owed's formula over the accounts row. + const uint64_t owed = liq_owed(BOND_ACCOUNT); + BOOST_REQUIRE(owed > 0); + const auto bond_wire = wire_balance(BOND_ACCOUNT); + BOOST_REQUIRE_EQUAL(success(), sweepyield(LIQSOL)); + BOOST_REQUIRE_EQUAL(bond_wire + owed, wire_balance(BOND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(owed, pool_row_of()["pool"]["received"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, liq_owed(BOND_ACCOUNT)); + + const auto alice_liq = liq_balance("alice"_n); + const auto alice_wire = wire_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(alice_liq + 10 * UNIT, liq_balance("alice"_n)); + // alice is the only bond: she is paid exactly what was pulled. + BOOST_REQUIRE_EQUAL(success(), claimwire("alice"_n)); + BOOST_REQUIRE_EQUAL(alice_wire + owed, wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(bond_wire, wire_balance(BOND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(owed, pool_row_of()["pool"]["credited"].as_uint64()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "alice"_n)); +} FC_LOG_AND_RETHROW() + +// With no sweep, `claim` pulls the pool itself, and a returned bond, still held until it is claimed, +// keeps earning up to the claim: alice, the only holder, receives what sysio.bond's row is owed then. +BOOST_FIXTURE_TEST_CASE(claim_pulls_the_yield_itself_up_to_the_claim, sysio_bond_tester) try { + constexpr uint32_t window_sec = 3600; + bonded_request(1, 'a', window_sec); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 100 * UNIT)); + pass_window(window_sec); + BOOST_REQUIRE_EQUAL(success(), approve(1)); + const uint64_t at_ruling = liq_owed(BOND_ACCOUNT); + BOOST_REQUIRE(at_ruling > 0); + + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 100 * UNIT)); + const uint64_t at_claim = liq_owed(BOND_ACCOUNT); + BOOST_REQUIRE(at_claim > at_ruling); + + const auto alice_wire = wire_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(success(), claimwire("alice"_n)); + BOOST_REQUIRE_EQUAL(alice_wire + at_claim, wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(0u, liq_owed(BOND_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +// An escrow keeps earning after an APPROVED ruling while it holds shares its underwriters have not +// claimed; its funder is paid that yield, and may claim again until the escrow holds nothing. +BOOST_FIXTURE_TEST_CASE(escrow_yield_after_approval_reaches_the_funder, sysio_bond_tester) try { + constexpr uint32_t window_sec = 3600; + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT, + window_sec)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 10 * UNIT)); + pass_window(window_sec); + BOOST_REQUIRE_EQUAL(success(), approve(1)); + const auto start = liq_index(); + // Nothing distributed yet: the funder is owed nothing. + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "issuer"_n)); + + // A distribution after the ruling reaches the escrow's 1 UNIT. + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 100 * UNIT)); + const auto mid = liq_index(); + const auto first = static_cast(static_cast(1 * UNIT) * (mid - start) / YIELD_INDEX_SCALE); + BOOST_REQUIRE(first > 0); + auto issuer_wire = wire_balance("issuer"_n); + BOOST_REQUIRE_EQUAL(success(), claim(1, "issuer"_n)); + BOOST_REQUIRE_EQUAL(success(), claimwire("issuer"_n)); + BOOST_REQUIRE_EQUAL(issuer_wire + first, wire_balance("issuer"_n)); + auto escrow = raw_escrow_of(1, KIND_BOUNTY_BYTE); + BOOST_REQUIRE_EQUAL(1 * UNIT, escrow.amount); // alice's share is still held + BOOST_REQUIRE(!escrow.paid); + + // It keeps earning until alice claims her share out of it. + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 100 * UNIT)); + const auto late = liq_index(); + const auto second = static_cast(static_cast(1 * UNIT) * (late - mid) / YIELD_INDEX_SCALE); + BOOST_REQUIRE(second > 0); + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + escrow = raw_escrow_of(1, KIND_BOUNTY_BYTE); + BOOST_REQUIRE_EQUAL(0u, escrow.amount); + BOOST_REQUIRE_EQUAL(second, escrow.yield.owed_wire); + + issuer_wire = wire_balance("issuer"_n); + BOOST_REQUIRE_EQUAL(success(), claim(1, "issuer"_n)); + // The pool is first-come-first-served; the last taker may be short one atomic unit of rounding. + BOOST_REQUIRE_EQUAL(success(), claimwire("issuer"_n)); + const auto second_paid = wire_balance("issuer"_n) - issuer_wire; + BOOST_REQUIRE(second_paid <= second && second_paid + 1 >= second); + BOOST_REQUIRE(raw_escrow_of(1, KIND_BOUNTY_BYTE).paid); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "issuer"_n)); + + pass_retention(); + BOOST_REQUIRE_EQUAL(success(), prune(0, 10)); + BOOST_REQUIRE(request_row_of(1).is_null()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(forfeited_bond_yield_returns_to_the_ledger_as_bonus_yield, sysio_bond_tester) try { + bonded_request(1, 'a'); // alice bonds 10 UNIT of LIQSOL + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), rslvinvalid(SYSIO_ACCOUNT, 1)); + BOOST_REQUIRE_EQUAL(success(), sweepyield(LIQSOL)); + const uint64_t pulled = pool_row_of()["pool"]["received"].as_uint64(); + BOOST_REQUIRE(pulled > 0); + + const auto alice_wire = wire_balance("alice"_n); + const auto alice_liq = liq_balance("alice"_n); + const auto bob_owed = liq_owed("bob"_n); + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + // The underwriter receives nothing; the forfeited bond's WIRE goes to sysio.liq for LIQSOL. + BOOST_REQUIRE_EQUAL(alice_wire, wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(alice_liq, liq_balance("alice"_n)); + BOOST_REQUIRE(settlements().empty()); + const auto added = addyields(); + BOOST_REQUIRE_EQUAL(1u, added.size()); + BOOST_REQUIRE_EQUAL("sysio.bond", added.front().first.to_string()); + BOOST_REQUIRE_EQUAL(pulled, added.front().second.get_amount()); + BOOST_REQUIRE(raw_bond_of(1, "alice"_n).paid); + + // bob, a LIQSOL holder outside the contract, earns from it. + const auto bob_after = liq_owed("bob"_n); + BOOST_REQUIRE(bob_after > bob_owed); + const auto bob_wire = wire_balance("bob"_n); + BOOST_REQUIRE_EQUAL(success(), liq_claim("bob"_n)); + BOOST_REQUIRE_EQUAL(bob_wire + bob_after, wire_balance("bob"_n)); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "alice"_n)); +} FC_LOG_AND_RETHROW() + +// Once every bond is paid, the funder's claim marks its escrow paid even though the division +// remainder is still in it: that remainder is slack, and the request can be pruned. +BOOST_FIXTURE_TEST_CASE(escrow_is_paid_once_every_bond_is_paid_despite_a_remainder, sysio_bond_tester) try { + constexpr uint32_t window_sec = 3600; + // A bounty of 1 UNIT and one base unit splits 3:7 with one base unit left over. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT + 1, + window_sec)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 3 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), accept("bob"_n, 1, 7 * UNIT)); + pass_window(window_sec); + BOOST_REQUIRE_EQUAL(success(), approve(1)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 100 * UNIT)); + + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(success(), claim(1, "bob"_n)); + auto escrow = raw_escrow_of(1, KIND_BOUNTY_BYTE); + BOOST_REQUIRE_EQUAL(1u, escrow.amount); + BOOST_REQUIRE(!escrow.paid); + + const auto issuer_wire = wire_balance("issuer"_n); + BOOST_REQUIRE_EQUAL(success(), claim(1, "issuer"_n)); + BOOST_REQUIRE_EQUAL(success(), claimwire("issuer"_n)); + BOOST_REQUIRE_GT(wire_balance("issuer"_n), issuer_wire); + escrow = raw_escrow_of(1, KIND_BOUNTY_BYTE); + BOOST_REQUIRE_EQUAL(1u, escrow.amount); + BOOST_REQUIRE(escrow.paid); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "issuer"_n)); + + pass_retention(); + BOOST_REQUIRE_EQUAL(success(), prune(0, 10)); + BOOST_REQUIRE(request_row_of(1).is_null()); + BOOST_REQUIRE(escrow_row_of(1, KIND_BOUNTY_BYTE).is_null()); +} FC_LOG_AND_RETHROW() + +// sysio.liq refuses a distribution to a symbol with no supply. A forfeited bond's WIRE claimed then +// stays in the contract, and the bond is still marked paid. +BOOST_FIXTURE_TEST_CASE(forfeited_bond_yield_stays_when_the_symbol_has_no_supply, sysio_bond_tester) try { + constexpr std::string_view LIQX = "LIQX"; + const symbol LIQX_SYM = symbol::from_string("9,LIQX"); + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, LIQX, 9)); + BOOST_REQUIRE_EQUAL(success(), create_shadow(LIQX_SYM, LIQX)); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 10 * UNIT, LIQX)); + + // The only LIQX there is backs alice's bond, earns yield, and is forfeited. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQX, 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 100 * UNIT, LIQX_SYM)); + BOOST_REQUIRE_EQUAL(success(), rslvinvalid(SYSIO_ACCOUNT, 1)); + + // The issuer takes the forfeit and burns it: LIQX has no supply left. + BOOST_REQUIRE_EQUAL(success(), claim(1, "issuer"_n)); + BOOST_REQUIRE_EQUAL(10 * UNIT, liq_balance("issuer"_n, LIQX_SYM)); + BOOST_REQUIRE_EQUAL(success(), burn_shadow("issuer"_n, 10 * UNIT, LIQX_SYM, LIQX)); + BOOST_REQUIRE_EQUAL(0u, liq_balance("issuer"_n, LIQX_SYM)); + + // The issuer's claim already pulled the WIRE the forfeited bond earned into the pool. + const auto bond_wire = wire_balance(BOND_ACCOUNT); + const auto earned = pool_row_of(LIQX)["pool"]["received"].as_uint64(); + BOOST_REQUIRE_GT(earned, 0u); + BOOST_REQUIRE_EQUAL(earned, bond_wire); + BOOST_REQUIRE_EQUAL(0u, pool_row_of(LIQX)["pool"]["credited"].as_uint64()); + + const auto alice_wire = wire_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + BOOST_REQUIRE(addyields().empty()); + BOOST_REQUIRE_EQUAL(alice_wire, wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(bond_wire, wire_balance(BOND_ACCOUNT)); // kept in the contract + BOOST_REQUIRE_EQUAL(earned, pool_row_of(LIQX)["pool"]["credited"].as_uint64()); + BOOST_REQUIRE(raw_bond_of(1, "alice"_n).paid); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "alice"_n)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(sweepyield_refuses_wire, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(wire_no_yield_msg)), sweepyield(WIRE)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(unsupported_token_msg)), sweepyield("NOPE")); + // Nothing owed yet: a sweep is a no-op. + BOOST_REQUIRE_EQUAL(success(), sweepyield(LIQSOL)); + + // A WIRE request's claim pays the bond and its share, and no yield on top, even while LIQSOL earns. + constexpr uint32_t window_sec = 3600; + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), WIRE, 10 * UNIT, 1 * UNIT, + window_sec)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 100 * UNIT)); + pass_window(window_sec); + BOOST_REQUIRE_EQUAL(success(), approve(1)); + BOOST_REQUIRE_EQUAL("0", request_row_of(1)["resolved_index"].as_string()); + const auto alice = wire_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(alice + 11 * UNIT, wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(1u, transfers_on(TOKEN_ACCOUNT, token_abi_ser).size()); + BOOST_REQUIRE_EQUAL(0u, wire_balance(BOND_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// prune +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(prune_erases_only_paid_out_terminal_requests, sysio_bond_tester) try { + constexpr uint32_t window_sec = 3600; + // 1: approved and fully claimed. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT, + window_sec)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 10 * UNIT)); + // 2: approved, its bond not claimed yet. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'b'), LIQSOL, 10 * UNIT, 1 * UNIT, + window_sec)); + BOOST_REQUIRE_EQUAL(success(), accept("bob"_n, 2, 10 * UNIT)); + pass_window(window_sec); + BOOST_REQUIRE_EQUAL(success(), approve(1)); + BOOST_REQUIRE_EQUAL(success(), approve(2)); + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + // 3: forfeited with no yield earned: once the issuer has claimed the forfeit, nothing is owed. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'c'), LIQSOL, 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 3, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), rslvinvalid(SYSIO_ACCOUNT, 3)); + // 4: still OPEN. + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'd'), LIQSOL, 10 * UNIT, 0)); + + // The forfeit not yet claimed keeps request 3. + pass_retention(); + BOOST_REQUIRE_EQUAL(success(), prune(0, 10)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, request_state_of(3)); + BOOST_REQUIRE_EQUAL(success(), claim(3, "issuer"_n)); + + BOOST_REQUIRE_EQUAL(success(), prune(0, 10)); + BOOST_REQUIRE(request_row_of(1).is_null()); + BOOST_REQUIRE(bond_rows_of(1).empty()); + BOOST_REQUIRE(escrow_row_of(1, KIND_BOUNTY_BYTE).is_null()); + BOOST_REQUIRE(request_row_of(3).is_null()); + BOOST_REQUIRE(bond_rows_of(3).empty()); + BOOST_REQUIRE_EQUAL(STATE_APPROVED, request_state_of(2)); + BOOST_REQUIRE_EQUAL(1u, bond_rows_of(2).size()); + BOOST_REQUIRE(!escrow_row_of(2, KIND_BOUNTY_BYTE).is_null()); + BOOST_REQUIRE_EQUAL(STATE_OPEN, request_state_of(4)); + + // Once bob claims, request 2 goes too; the OPEN one stays. + BOOST_REQUIRE_EQUAL(success(), claim(2, "bob"_n)); + BOOST_REQUIRE_EQUAL(success(), prune(0, 10)); + BOOST_REQUIRE(request_row_of(2).is_null()); + BOOST_REQUIRE(bond_rows_of(2).empty()); + BOOST_REQUIRE(escrow_row_of(2, KIND_BOUNTY_BYTE).is_null()); + BOOST_REQUIRE_EQUAL(STATE_OPEN, request_state_of(4)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(prune_stops_at_its_limit, sysio_bond_tester) try { + for (char fill : {'a', 'b', 'c'}) { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, fill), LIQSOL, 10 * UNIT, 0)); + } + for (uint64_t id = 1; id <= 3; ++id) BOOST_REQUIRE_EQUAL(success(), rslvinvalid(SYSIO_ACCOUNT, id)); + pass_retention(); + BOOST_REQUIRE_EQUAL(success(), prune(0, 2)); + BOOST_REQUIRE(request_row_of(1).is_null()); + BOOST_REQUIRE(request_row_of(2).is_null()); + BOOST_REQUIRE_EQUAL(STATE_INVALID, request_state_of(3)); + BOOST_REQUIRE_EQUAL(success(), prune(0, 2)); + BOOST_REQUIRE(request_row_of(3).is_null()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(prune_starts_at_from_id, sysio_bond_tester) try { + for (char fill : {'a', 'b', 'c'}) { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, fill), LIQSOL, 10 * UNIT, 0)); + } + for (uint64_t id = 1; id <= 3; ++id) BOOST_REQUIRE_EQUAL(success(), rslvinvalid(SYSIO_ACCOUNT, id)); + pass_retention(); + + // From id 2: request 1, prunable, is left; 2 and 3 go. + BOOST_REQUIRE_EQUAL(success(), prune(2, 10)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, request_state_of(1)); + BOOST_REQUIRE(request_row_of(2).is_null()); + BOOST_REQUIRE(request_row_of(3).is_null()); + // Past every request: nothing is erased. + BOOST_REQUIRE_EQUAL(success(), prune(4, 10)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, request_state_of(1)); + BOOST_REQUIRE_EQUAL(success(), prune(1, 10)); + BOOST_REQUIRE(request_row_of(1).is_null()); +} FC_LOG_AND_RETHROW() + +// A request whose token no longer resolves cannot be settled: prune skips it instead of failing, +// and still erases the prunable requests after it. +BOOST_FIXTURE_TEST_CASE(prune_skips_a_request_whose_token_no_longer_resolves, sysio_bond_tester) try { + for (char fill : {'a', 'b'}) { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, fill), LIQSOL, 10 * UNIT, 0)); + } + for (uint64_t id = 1; id <= 2; ++id) BOOST_REQUIRE_EQUAL(success(), rslvinvalid(SYSIO_ACCOUNT, id)); + pass_retention(); + rewrite_request_token(1, "NOPE"); + produce_block(); + + BOOST_REQUIRE_EQUAL(success(), prune(0, 10)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, request_state_of(1)); + BOOST_REQUIRE_EQUAL("NOPE", request_row_of(1)["token_code"].as_string()); + BOOST_REQUIRE(request_row_of(2).is_null()); +} FC_LOG_AND_RETHROW() + +// A ruled request stays PRUNE_RETENTION_SEC after its ruling even when nothing is owed on it, so an +// issuer that polls the row rather than being notified always sees the ruling. +BOOST_FIXTURE_TEST_CASE(prune_keeps_a_ruling_for_the_retention_period, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 0)); + BOOST_REQUIRE_EQUAL(success(), rslvinvalid(SYSIO_ACCOUNT, 1)); + + // Nothing is owed on it at once -- nothing was bonded -- but the ruling is kept. + BOOST_REQUIRE_EQUAL(success(), prune(0, 10)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, request_state_of(1)); + produce_block(fc::seconds(PRUNE_RETENTION_SEC - 10)); + BOOST_REQUIRE_EQUAL(success(), prune(0, 10)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, request_state_of(1)); + + // Once the retention period has passed, it goes. + produce_block(fc::seconds(10)); + BOOST_REQUIRE_EQUAL(success(), prune(0, 10)); + BOOST_REQUIRE(request_row_of(1).is_null()); +} FC_LOG_AND_RETHROW() + +/// Durable results survive a fully paid settlement and arbitrary delay until the authenticated issuer +/// acknowledges; acknowledgement itself neither settles a request nor blocks independent claims. +BOOST_FIXTURE_TEST_CASE(durable_outcome_requires_issuer_acknowledgement, sysio_bond_tester) try { + BOOST_REQUIRE_EQUAL(success(), push_bond("issuer"_n, "requestkeep"_n, mvo() + ("issuer", "issuer"_n)("schema", "oppenvelope"_n)("statement", bytes(32, 'a')) + ("token_code", codename_mvo(LIQSOL))("covered", 10 * UNIT)("bounty", 0)("window_sec", WINDOW_SEC))); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("request is not resolved"), + push_bond("issuer"_n, "ack"_n, mvo()("request_id", 1))); + BOOST_REQUIRE_EQUAL(success(), rslvinvalid(SYSIO_ACCOUNT, 1)); + pass_retention(); + BOOST_REQUIRE_EQUAL(success(), prune(0, 10)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, request_state_of(1)); + BOOST_REQUIRE_NE(success(), push_bond("alice"_n, "ack"_n, mvo()("request_id", 1))); + BOOST_REQUIRE_EQUAL(success(), push_bond("issuer"_n, "ack"_n, mvo()("request_id", 1))); + BOOST_REQUIRE_EQUAL(success(), push_bond("issuer"_n, "ack"_n, mvo()("request_id", 1))); + BOOST_REQUIRE_EQUAL(success(), prune(0, 10)); + BOOST_REQUIRE(request_row_of(1).is_null()); +} FC_LOG_AND_RETHROW() + +// A request still owed a claim keeps its row, but the bond rows already paid out go: one unclaimed +// payout holds no other underwriter's RAM, and the unpaid rows claim exactly as before. +BOOST_FIXTURE_TEST_CASE(prune_erases_the_paid_bonds_of_a_request_it_keeps, sysio_bond_tester) try { + constexpr uint32_t window_sec = 3600; + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, 1 * UNIT, + window_sec)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), accept("bob"_n, 1, 6 * UNIT)); + pass_window(window_sec); + BOOST_REQUIRE_EQUAL(success(), approve(1)); + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + + // Within the retention period: alice's paid row goes at once, bob's stays, and so does the request. + const auto alice_ram = ram_usage("alice"_n); + BOOST_REQUIRE_EQUAL(success(), prune(0, 10)); + BOOST_REQUIRE_EQUAL(1u, bond_rows_of(1).size()); + BOOST_REQUIRE_EQUAL(6 * UNIT, bond_amount_of(1, "bob"_n)); + BOOST_REQUIRE_EQUAL(STATE_APPROVED, request_state_of(1)); + BOOST_REQUIRE_LT(ram_usage("alice"_n), alice_ram); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(nothing_msg)), claim(1, "alice"_n)); + + // Past it, bob's unclaimed bond still keeps the request. + pass_retention(); + BOOST_REQUIRE_EQUAL(success(), prune(0, 10)); + BOOST_REQUIRE_EQUAL(STATE_APPROVED, request_state_of(1)); + + // Bob is paid his bond and his stake's share of the bounty as if nothing had been erased. + const auto bob_before = liq_balance("bob"_n); + BOOST_REQUIRE_EQUAL(success(), claim(1, "bob"_n)); + BOOST_REQUIRE_EQUAL(bob_before + 6 * UNIT + 6 * UNIT / 10, liq_balance("bob"_n)); + BOOST_REQUIRE_EQUAL(success(), prune(0, 10)); + BOOST_REQUIRE(request_row_of(1).is_null()); + BOOST_REQUIRE(bond_rows_of(1).empty()); + BOOST_REQUIRE(escrow_row_of(1, KIND_BOUNTY_BYTE).is_null()); +} FC_LOG_AND_RETHROW() + +// Review focus 2, sysio.bond: while the sysio.andon cord is pulled a bond is paid in -- request, accept, +// hold and the rulings run -- and every payout waits: `claim` is refused, and what it owes stays recorded. +// After the clear the same claim pays in full. +BOOST_FIXTURE_TEST_CASE(a_freeze_takes_bonds_in_and_defers_every_claim, sysio_bond_tester) try { + abi_serializer andon_abi_ser; + sysio_system::test_support::andon::deploy(*this, andon_abi_ser, contracts::andon_wasm(), contracts::andon_abi()); + BOOST_REQUIRE_EQUAL(success(), sysio_system::test_support::andon::pull(*this, andon_abi_ser)); + const auto frozen = wasm_assert_msg(sysio_system::test_support::andon::frozen_message); + + constexpr uint32_t window_sec = 3600; + const auto alice = liq_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), request("issuer"_n, "oppenvelope"_n, bytes(32, 'a'), LIQSOL, 10 * UNIT, UNIT, + window_sec)); + BOOST_REQUIRE_EQUAL(success(), accept("alice"_n, 1, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(alice - 10 * UNIT, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(11 * UNIT, liq_balance(BOND_ACCOUNT)); // the bond and the bounty, both in + pass_window(window_sec); + BOOST_REQUIRE_EQUAL(success(), approve(1)); + + BOOST_REQUIRE_EQUAL(frozen, claim(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(frozen, claim(1, "alice"_n, "carol"_n)); + BOOST_REQUIRE(!raw_bond_of(1, "alice"_n).paid); + BOOST_REQUIRE_EQUAL(11 * UNIT, liq_balance(BOND_ACCOUNT)); + + BOOST_REQUIRE_EQUAL(success(), sysio_system::test_support::andon::clear(*this, andon_abi_ser)); + BOOST_REQUIRE_EQUAL(success(), claim(1, "alice"_n)); + BOOST_REQUIRE_EQUAL(alice + UNIT, liq_balance("alice"_n)); + BOOST_REQUIRE(raw_bond_of(1, "alice"_n).paid); +} FC_LOG_AND_RETHROW() + +BOOST_AUTO_TEST_SUITE_END() diff --git a/docs/sysio-bond-integration.md b/docs/sysio-bond-integration.md new file mode 100644 index 0000000000..985f663bf5 --- /dev/null +++ b/docs/sysio-bond-integration.md @@ -0,0 +1,196 @@ +# Integrating with `sysio.bond` + +`sysio.bond` underwrites statements on the depot. An issuer registers a statement and the amount of a +depot-native token that must stand behind it. Underwriters bond that amount. A challenge window runs, +and the request is either approved or ruled by `sysio`. This guide is for a contract, or an account, +that issues requests: what it calls, how it learns what happened, and what it is paid. The contract is +`contracts/sysio.bond`; the specification is §3 of the syndication underwriting spec. + +## Registering a request + +`request(issuer, schema, statement, token_code, covered, bounty, window_sec)`, signed by the issuer. + +- `schema` names how `statement` is to be read. `statement` is at most 1024 bytes. The issuer can + register a given schema and statement once. +- `token_code` is WIRE or a shadow LIQ symbol registered on `sysio.liq`. Other tokens are refused. +- `covered` is what the underwriters must bond in total. It is a positive multiple of the increment. +- `bounty`, which may be zero, is pulled from the issuer into escrow. `addbounty` raises it later. +- `window_sec` is the challenge window. It starts when the request is fully bonded. + +The contract is privileged: it pulls the issuer's tokens by an inline `transfer` under the issuer's +`active` authority. An issuer contract needs nothing extra. + +An issuer contract that sends `request` inline, where an abort would take its own action down, can +check beforehand everything `request` checks, with the helpers `sysio.bond.hpp` exports: +`bond::increment_of(symbol)` for the increment, `bond::statement_digest_of(schema, statement)` and +`bond::statement_key_of(issuer, digest)` for the key its `bystatement` index refuses a duplicate on. +What a ruling awards an issuer can be computed the same way: `bond::hold_bond_of(covered, hold_bps)` is +the hold bond `hold` pulls (at `bondconfig.hold_bps`), `bond::share_of(amount, pot, covered)` the share +of a bounty or hold bond a stake earns, and `bond::escrow_key_of(request_id, kind)` the key of the +`escrows` row whose `payee` and `payout` record an award. +The id the request will take is `bondcounters.next_request_id`, read before sending; requests sent +from one action take consecutive ids from there. An issuer that must not depend on that ordering finds +its request afterwards through the `bystatement` key instead, as the syndication contract's queue does. + +The issuer pays the RAM of its request's `requests` and `escrows` rows, and each underwriter the RAM +of its `bonds` row. A privileged issuer or underwriter is the exception: a system contract (the +syndication contract, issuing for its envelopes) has no RAM quota of its own, so the rows created on +its behalf bill the `sysio` RAM pool instead. Later writes by other signers (a ruling, a claim) keep +those payers. `prune` releases the RAM. + +## The increment + +Bonds move in steps of 0.01 token. For a token of precision `p` that is `10^(p - 2)` base units. A +token with a precision below 2 is refused. `covered` and every `accept` amount must be positive +multiples of it. An `accept` larger than what remains uncovered is reduced to the remainder, and only +the reduced amount is pulled. + +## Following a request + +`sysio.bond` notifies no one and sends nothing when a request changes state: approval and the +rulings only change the `requests` row and record what each party may claim. An account that could +refuse a notification or a transfer (a contract whose handler fails) can therefore block only its +own claim, never an approval, a ruling or another party's payout. + +An issuer polls its `requests` rows (the syndication contract's crank does) and acts on `state`: +on INVALID it pulls its forfeit with `claim(request_id, issuer)`; on VALID after a hold of a request +that was only partly bonded it pulls the unbonded share of the hold bond the same way (a fully bonded +request's hold bond goes wholly to its underwriters, and the issuer is owed none of it). `prune` keeps +a ruled request for seven days (`PRUNE_RETENTION_SEC`), so an issuer that polls at least that often +sees every ruling. Anyone may send that `claim`, so an issuer accounts for what it is owed from +the row (on INVALID the forfeit is `bonded`), not from the transfer, and sends `claim` only while +`forfeit_pending` or an escrow's `payout` to it is not zero, since a claim with nothing owed is refused. +The syndication contract's queue does this. + +## States + +| State | Entered when | Leaves to | +|---|---|---| +| OPEN | `request` | BONDED, HELD, VALID, INVALID | +| BONDED | bonded reaches covered; the window starts | APPROVED, HELD, VALID, INVALID | +| APPROVED | `approve` after the window, with no hold | terminal | +| HELD | `hold(request_id, beneficiary)` by the issuer; posts the hold bond | VALID, INVALID | +| VALID | `rslvvalid` by `sysio` | terminal | +| INVALID | `rslvinvalid` by `sysio` | terminal | + +The hold bond is `covered * hold_bps / 10000` (`hold_bps` is 1000 until `sysio` sets another). When +that rounds to zero, the request is held with no hold bond. + +## Who is paid what + +`s` is an underwriter's bond divided by `covered`. A share is `amount * pot / covered`, computed in +128 bits and floored; division remainders stay in the contract. + +| Outcome | Bond | Bounty | Hold bond | +|---|---|---|---| +| APPROVED | returned | `s` of it to each underwriter | none | +| VALID, fully bonded | returned | `s` of it to each underwriter | `s` of it to each underwriter | +| VALID, partly bonded | returned | `s` to each underwriter; the unbonded share to `sysio` | `s` to each underwriter; the unbonded share to the issuer | +| VALID, nothing bonded | none | all to `sysio` | all to the issuer | +| INVALID after a hold | to the issuer | to the hold beneficiary | to the hold beneficiary | +| INVALID with no hold | to the issuer | returned to the issuer | none | + +Every payment is pulled by `claim(request_id, account)`, one account per call, so no action loops +over the underwriters and no party's refusal blocks another. Anyone may call it; the payment goes to +`account`. A claim with nothing owed is refused with `nothing to claim`. + +- **Underwriters** settle the bond and its shares with `claim`, then withdraw earned WIRE with `claimwire`. +- **The issuer** of an INVALID request claims the forfeit, in its own transfer (below), and the + bounty back when there was no hold. On VALID after a hold of a partly bonded request it claims the + unbonded share of the hold bond; the hold bond of a fully bonded request is all the underwriters'. +- **The hold beneficiary** of an INVALID request that was held claims the hold bond and the bounty. +- **`sysio`** claims the unbonded share of the bounty of a VALID request. + +The rulings record these awards: the forfeit as `forfeit_pending` on the `requests` row, and the +rest as `payee` and `payout` on the `escrows` row of the bounty or the hold bond. Each drops to 0 +when claimed. + +## The emergency stop + +While the depot's `sysio.andon` cord is pulled, `claim` and `claimwire` are refused (`the andon cord is pulled: funds +cannot leave custody`): every payout waits for the clear. Everything else runs -- `request`, +`addbounty`, `accept`, `hold`, `approve`, the rulings, `sweepyield` and `prune` -- and every transfer +into `sysio.bond` (a bounty, a bond, a hold bond) succeeds, so bonds can be placed and requests held +during a freeze. Nothing a ruling awards is lost: it stays recorded (`forfeit_pending`, `payout`, the +unpaid bond rows) until claimed, and `prune` never erases a request that still owes a claim. An issuer +contract that claims from a path that must not throw reads the cord first with +`andon::pulled(andon::ANDON_ACCOUNT)` (`sysio.andon.hpp`) and waits while it is pulled, as the +syndication contract does. + +## The forfeit payment + +The issuer's `claim` on an INVALID request pays its bonded forfeit through a LIQ ledger settlement, +or through an ordinary transfer with memo `sysio.bond::forfeit` for WIRE-denominated requests. +Clients read the request's recorded outcome; LIQ invokes no incoming-transfer handler. + +What a client does with a forfeit depends on the token: + +- a shadow LIQ symbol can be burned through `sysio.liq`: the syndication contract's burn (Part B of + the syndication underwriting spec), which the syndication contract owns; +- WIRE cannot be burned by a client: `sysio.token::retire` needs the token's issuer, `sysio`. A + client keeps forfeited WIRE or forwards it. + +## Yield + +Shadow LIQ held by `sysio.bond` earns WIRE on `sysio.liq`. Each bond and escrow row carries a custody +position (`docs/shadow-custody-integration.md`). `sweepyield(token_code)` pulls what the contract is +owed; `claim` also pulls before it pays. + +- A returned bond is held until it is claimed, and earns until then: its position settles at the + live index at the claim, and its WIRE is banked for the underwriter to withdraw with `claimwire`. +- A forfeited bond becomes the issuer's at `rslvinvalid`, which records the index in + `resolved_index`; its position settles there. What the forfeit earns while it waits for the + issuer's claim is slack. The bond's WIRE never goes to the underwriter: claiming it pays the WIRE + into `sysio.liq` as bonus yield of the bond's symbol, through `addyield`. When the symbol has no + supply left, `sysio.liq` cannot distribute, and the WIRE stays in `sysio.bond`. +- An escrow earns on what it still holds, including the shares and awards not claimed yet, and its + WIRE goes to its funder, the issuer. The issuer may claim more than once. Its claim marks the + escrow paid once no bond is owed a share of it and its award is claimed; the division remainder + left in it is slack. +- A request in WIRE earns nothing. + +## Pruning + +`prune(from_id, limit)` walks the requests from id `from_id` up and acts on at most `limit` terminal +requests. It erases a request with its rows once they are all paid or owe nothing -- the forfeit and +every award claimed, with no residual owed yield -- and its ruling is at least `PRUNE_RETENTION_SEC` +(seven days) old. Durable requests also require issuer acknowledgement. Paid bond rows with no +residual yield may be erased independently; an unrelated unclaimed payout does not hold their RAM. A request whose token no longer resolves is skipped. +Anyone may call it. + +## Durable outcome consumption + +Polling contract issuers should use `requestkeep` (same arguments as `request`) and call `ack(request_id)` +after storing a terminal result. Only the issuer may acknowledge; acknowledgement is idempotent and +requires a terminal request. Rulings never notify the issuer, and claims do not require acknowledgement. +`prune` requires acknowledgement in addition to its existing retention and fully-paid checks for these +requests. Ordinary `request` retains its existing retention behavior. + +The public `find_request`, `request_refusal`, and `settlement_due` accessors provide stable statement +lookup, shared admission rules, and outstanding settlement principal without client knowledge of counters +or escrow representation. Statement deduplication lasts while the request exists; durable delivery does +not add a permanent replay archive after acknowledged requests are pruned. Issuers must enforce their +own replay policy (syndication retains its envelope/sequence guards). + +The durable request flag and syndication's independent cursors change table layouts. A deployment with +existing rows needs a coordinated table migration; fresh prelaunch state needs none. This change does +not provide a migration action or recover outcomes already pruned under the old contract. + +## Ledger settlement and independent WIRE claims + +LIQ principal, awards and forfeits use `sysio.liq::settle(custodian, beneficiary, quantity)`. +Only `sysio.synd` and `sysio.bond` can debit their own existing LIQ through this action. +It settles both yield positions, leaves supply unchanged, invokes neither account, and requires +Andon clear even for a custody recipient or self-settlement. Ordinary token transfers keep their +notifications and existing behavior. + +`claim(request_id, account)` credits LIQ immediately and records earned WIRE in the account's +backed `wireclaims` balance. `claimwire(account)` withdraws that WIRE separately to the named +account. It is permissionless and respects Andon; a failed recipient transfer rolls back only the +WIRE withdrawal. The WIRE balance survives request pruning and never expires. Checked additions +reject overflow atomically. Yield the pool cannot cover remains on the original bond or escrow row; +that residual prevents pruning and can be retried without paying principal twice or earning yield +on returned principal. WIRE-denominated requests retain their ordinary, per-recipient claims. + +Clients must read the recorded outcome and balances rather than infer a LIQ forfeit from a transfer +memo. LIQ settlement has no memo or transfer notification. From 65c2a6b9e45604816ba0cf246b15f320d52e71c7 Mon Sep 17 00:00:00 2001 From: David J Parrott Date: Tue, 6 Oct 2026 20:46:00 -0400 Subject: [PATCH 4/5] Gate syndication through sysio.synd Make the depot the authority for inbound envelopes, custody reconciliation, underwriting requests, and final wallet delivery. Give sysio.synd sole supply authority over LIQ and retain explicit recovery for failed returns. Enforce envelope ordering, backing, fee rounding, and emergency controls across dispatch, linking, settlement, and outbound desyndication. Model external custody with a test-only generic chain simulator. Exercise NTA/EC1 and NTB/EC2 through generated action sequences, destination delivery, parked claims, failures, and governance/provider resolution parity. Keep asset-specific producer registration tests and map every enabled flow to its native coverage and external-only responsibilities. Include the contract guide, bootstrap configuration, client pause handling, and integration tests. Retain the dormant legacy settlement surfaces here so their removal remains a separate review decision. Co-authored-by: Jonathan Glanz Change-Id: I57c6c3589b0a73598a51e2f651c0da7bba5eb15c --- OVERVIEW.md | 124 +- PLUGINS.md | 3 +- contracts/CMakeLists.txt | 1 + contracts/README.md | 6 + contracts/sysio.authex/src/sysio.authex.cpp | 18 +- contracts/sysio.authex/sysio.authex.wasm | Bin 36689 -> 36689 bytes .../include/sysio.chains/sysio.chains.hpp | 13 + contracts/sysio.epoch/README.md | 61 +- .../include/sysio.epoch/sysio.epoch.hpp | 15 + contracts/sysio.liq/CMakeLists.txt | 2 +- contracts/sysio.liq/README.md | 74 +- .../sysio.liq/include/sysio.liq/sysio.liq.hpp | 221 +- contracts/sysio.liq/src/sysio.liq.cpp | 330 +- contracts/sysio.liq/sysio.liq.abi | 315 +- contracts/sysio.liq/sysio.liq.wasm | Bin 64239 -> 42688 bytes contracts/sysio.msgch/README.md | 74 +- contracts/sysio.msgch/src/sysio.msgch.cpp | 162 +- contracts/sysio.msgch/sysio.msgch.wasm | Bin 172116 -> 173853 bytes .../sysio.opp.common/envelope_statement.hpp | 82 + .../sysio.opp.common/require_privileged.hpp | 31 + contracts/sysio.reserv/src/sysio.reserv.cpp | 9 +- contracts/sysio.reserv/sysio.reserv.wasm | Bin 88676 -> 89303 bytes contracts/sysio.synd/CMakeLists.txt | 51 + .../include/sysio.synd/sysio.synd.hpp | 704 +++ contracts/sysio.synd/src/sysio.synd.cpp | 1642 ++++++ contracts/sysio.synd/sysio.synd.abi | 1286 +++++ contracts/sysio.synd/sysio.synd.wasm | Bin 0 -> 103713 bytes contracts/test_contracts/CMakeLists.txt | 1 + .../test_contracts/epoch_stub/CMakeLists.txt | 15 + .../test_contracts/epoch_stub/epoch_stub.abi | 28 + .../test_contracts/epoch_stub/epoch_stub.cpp | 25 + .../test_contracts/epoch_stub/epoch_stub.wasm | Bin 0 -> 6307 bytes contracts/tests/CMakeLists.txt | 9 + contracts/tests/DEPOT_FLOW_TESTING.md | 171 + contracts/tests/contract_test_support.hpp | 46 + contracts/tests/contracts.hpp.in | 8 +- contracts/tests/depot_flow_coverage.json | 169 + contracts/tests/external_chain_simulator.hpp | 146 + .../tests/external_chain_simulator_tests.cpp | 74 + contracts/tests/liq_test_support.hpp | 54 - contracts/tests/sysio.authex_tests.cpp | 20 +- contracts/tests/sysio.bond_tests.cpp | 38 +- contracts/tests/sysio.dispatch_tests.cpp | 804 ++- contracts/tests/sysio.dispute_tests.cpp | 3 + contracts/tests/sysio.liq_tests.cpp | 545 +- contracts/tests/sysio.msig_tests.cpp | 23 +- contracts/tests/sysio.opreg_tests.cpp | 140 +- .../tests/sysio.snapshot_attest_tests.cpp | 23 +- contracts/tests/sysio.swap_tests.cpp | 98 +- contracts/tests/sysio.synd_tests.cpp | 4519 +++++++++++++++++ .../tests/test_verify_depot_flow_coverage.py | 58 + contracts/tools/verify_depot_flow_coverage.py | 72 + docs/becoming-a-block-producer.md | 63 +- docs/opp-two-phase-dispatch-design.md | 20 +- docs/platform-bootstrap-config.md | 198 +- docs/sysio-synd.md | 592 +++ .../sysio/opp/attestations/attestations.proto | 16 + .../proto/sysio/opp/bootstrap/bootstrap.proto | 2 +- plugins/batch_operator_plugin/README.md | 34 +- plugins/chain_interface/README.md | 14 +- plugins/chain_plugin/README.md | 6 +- plugins/cron_plugin/README.md | 10 +- plugins/external_debugging_plugin/README.md | 2 - plugins/http_plugin/README.md | 7 +- plugins/outpost_client_plugin/README.md | 6 +- .../outpost_ethereum_client_plugin/README.md | 46 +- .../outpost_ethereum_client.hpp | 14 +- .../src/outpost_ethereum_client.cpp | 20 +- .../test_outpost_ethereum_client_plugin.cpp | 14 +- .../outpost_solana_client_plugin/README.md | 36 +- .../outpost_solana_client.hpp | 74 +- .../src/outpost_solana_client.cpp | 71 +- .../test_outpost_solana_client_plugin.cpp | 227 +- .../README.md | 8 +- 74 files changed, 11963 insertions(+), 1830 deletions(-) create mode 100644 contracts/sysio.opp.common/include/sysio.opp.common/envelope_statement.hpp create mode 100644 contracts/sysio.opp.common/include/sysio.opp.common/require_privileged.hpp create mode 100644 contracts/sysio.synd/CMakeLists.txt create mode 100644 contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp create mode 100644 contracts/sysio.synd/src/sysio.synd.cpp create mode 100644 contracts/sysio.synd/sysio.synd.abi create mode 100755 contracts/sysio.synd/sysio.synd.wasm create mode 100644 contracts/test_contracts/epoch_stub/CMakeLists.txt create mode 100644 contracts/test_contracts/epoch_stub/epoch_stub.abi create mode 100644 contracts/test_contracts/epoch_stub/epoch_stub.cpp create mode 100755 contracts/test_contracts/epoch_stub/epoch_stub.wasm create mode 100644 contracts/tests/DEPOT_FLOW_TESTING.md create mode 100644 contracts/tests/depot_flow_coverage.json create mode 100644 contracts/tests/external_chain_simulator.hpp create mode 100644 contracts/tests/external_chain_simulator_tests.cpp delete mode 100644 contracts/tests/liq_test_support.hpp create mode 100644 contracts/tests/sysio.synd_tests.cpp create mode 100644 contracts/tools/tests/test_verify_depot_flow_coverage.py create mode 100644 contracts/tools/verify_depot_flow_coverage.py create mode 100644 docs/sysio-synd.md diff --git a/OVERVIEW.md b/OVERVIEW.md index 88d9644cd9..3f38fc3b21 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -2,16 +2,16 @@ ## Wire Network -Wire Network is a multi-chain consensus and interoperability platform built on the AntelopeIO protocol. It enables secure, trustless cross-chain communication through a specialized consensus mechanism and unified proof protocol. The network connects the Wire blockchain (the coordination layer) with external chains — Ethereum, Solana, and Sui — via on-chain **Outpost** contracts deployed on each supported chain. +Wire Network is a multi-chain consensus and interoperability platform built on the AntelopeIO protocol. It enables secure, trustless cross-chain communication through a specialized consensus mechanism and unified proof protocol. The network connects the Wire blockchain (the coordination layer, or **depot**) with external chains — Ethereum (EVM) and Solana (SVM) — via on-chain **Outpost** contracts deployed on each supported chain. ### Architecture at a Glance | Layer | Components | |-------|-----------| -| **Wire Blockchain (Depot)** | Block producers, system contracts (`sysio.msgch`, `sysio.epoch`, `sysio.uwrit`, `sysio.chalg`) | -| **Operator Network** | 21 batch operators (3 groups of 7), underwriters | -| **External Chains** | Outpost smart contracts on Ethereum, Solana, Sui | -| **Protocol Layer** | OPP — protobuf-encoded messages, merkle proofs, multi-sig consensus | +| **Wire Blockchain (Depot)** | Block producers, system contracts (`sysio.msgch`, `sysio.epoch`, `sysio.opreg`, `sysio.chalg`, `sysio.synd`, `sysio.bond`, `sysio.liq`, `sysio.swap`, `sysio.dclaim`, `sysio.andon`, …) | +| **Operator Network** | Batch operators relaying OPP envelopes in rotating groups (default 21 operators, 3 groups of 7); bond providers underwriting syndication statements on the depot | +| **External Chains** | Outpost contracts on Ethereum and Solana | +| **Protocol Layer** | OPP — protobuf-encoded envelopes, per-outpost envelope hash chains, group consensus | --- @@ -20,23 +20,23 @@ Wire Network is a multi-chain consensus and interoperability platform built on t OPP is the cryptographic wire protocol that enables deterministic, consensus-backed proof transmission across blockchains. It uses Protocol Buffers for serialization and defines a hierarchical message structure: ``` -Envelope (epoch-spanning container) - └─ Message (individual message within an epoch) +Envelope (one per outpost per epoch, per direction) + └─ Message (individual message within an envelope) └─ Attestation (typed data entry within a message) ``` ### Key Concepts -- **Epochs**: Time-bounded intervals managed by `sysio.epoch`. Each epoch assigns one group of 7 batch operators as active relayers. -- **Envelopes**: Epoch-level containers carrying a merkle root, epoch index, timestamp, and the range of messages they cover. -- **Messages**: Individual cross-chain payloads containing a header (message ID, checksum, encoding flags) and a payload (an array of attestation entries). -- **Attestations**: Typed data units that represent cross-chain actions — swaps, stake updates, operator actions, underwriting intents, and more. +- **Epochs**: Time-bounded intervals managed by `sysio.epoch` — one global epoch clock for every chain. Each epoch has one group of batch operators on duty as relayers. +- **Envelopes**: Epoch-level containers. Each envelope carries the epoch index and timestamp, its route endpoints, and `previous_envelope_hash` — the digest of the previous envelope on the same (depot, outpost) stream — so each direction forms a hash chain. An envelope is capped at 32,768 bytes on every chain. +- **Messages**: Individual cross-chain payloads containing a header (message ID, sequence, checksum, encoding flags) and a payload (an array of attestation entries). +- **Attestations**: Typed data units that represent cross-chain actions — operator rosters, liquidity syndication and desyndication, yield reports, node-owner registrations, staking rewards. ### Chain Addressing OPP uses a universal chain-agnostic address system: -- **ChainKind** enum: `WIRE`, `ETHEREUM`, `SOLANA`, `SUI` +- **ChainKind** enum: `WIRE`, `EVM`, `SVM` - **ChainAddress**: `(ChainKind, raw_bytes_address)` — no translation or mapping needed - **ChainSignature**: `(actor, key_type, signature_bytes)` — supports native chain signatures @@ -48,53 +48,55 @@ This allows a single unified message format across all supported chains. ### Inbound (External Chain → Wire) -1. A user action on an external chain (e.g., depositing ETH on the Ethereum Outpost) triggers an `OPPMessage` event containing protobuf-encoded attestations. -2. All 7 active batch operators independently observe the event via chain-specific client plugins (`outpost_ethereum_client_plugin`, `outpost_solana_client_plugin`). +1. A user action on an external chain (e.g. syndicating liqETH into the Ethereum `SyndicationPool`) queues a protobuf-encoded attestation in the outpost's outbound buffer, which the outpost seals into its envelope for the epoch. +2. Every batch operator in the active group independently reads that envelope through its chain-specific client plugin (`outpost_ethereum_client_plugin`, `outpost_solana_client_plugin`). 3. Each operator delivers the envelope to Wire's `sysio.msgch` contract via the `deliver()` action. -4. `sysio.msgch` evaluates consensus — when all operators in the active group submit identical envelope checksums, the messages are accepted. -5. Attestations are extracted, parsed, and routed to the appropriate system contract handlers. +4. `sysio.msgch` evaluates consensus: every eligible operator delivering an identical envelope is accepted immediately; otherwise a strict majority is accepted once the epoch boundary has passed. A split with no strict majority opens a `sysio.chalg` dispute. +5. Attestations are extracted, validated, and routed to the appropriate system contract handlers. ### Outbound (Wire → External Chain) 1. System contracts queue attestations for a target outpost via `sysio.msgch::queueout()`. -2. When the epoch advances, `buildenv()` packs all `READY` attestations into a protobuf-encoded outbound envelope with a merkle root. -3. Batch operators read the outbound envelope and submit it to the target chain's Outpost contract. -4. The Outpost contract (`OPPInbound.sol` on Ethereum) verifies consensus, checks merkle proofs, and routes attestations to registered handler contracts. +2. When the epoch advances, `buildenv()` packs the `READY` attestations into a protobuf-encoded outbound envelope; anything that does not fit stays queued for the next epoch. +3. Batch operators read the outbound envelope and submit it to the target chain's Outpost — chunked on both chains (`OPPInbound.epochIn` on Ethereum, `epoch_in` on Solana). +4. The Outpost verifies the envelope chain and group consensus, then routes attestations to their registered handlers. An unhandled type is skipped, never reverted. --- ## Key Attestation Types -| Category | Types | Purpose | -|----------|-------|---------| -| **Operator Management** | `OPERATOR_ACTION`, `BATCH_OPERATOR_NEXT_GROUP`, `SLASH_OPERATOR` | Operator registration, rotation, penalties | -| **Staking** | `STAKING_REWARD` | Per-staker cross-chain yield distribution (validator-staking lifecycle types are reserved but deferred post-launch) | -| **Swaps & Liquidity** | `SWAP`, `UNDERWRITE_INTENT`, `UNDERWRITE_CONFIRM`, `REMIT` | Cross-chain swaps backed by underwriter collateral | -| **Governance** | `CHALLENGE_REQUEST`, `CHALLENGE_RESPONSE`, `EPOCH_SYNC`, `ROSTER_UPDATE` | Dispute resolution and epoch synchronization | -| **Reserves** | `RESERVE_BALANCE_SHEET` | Collateral snapshots across chains | +| Category | Types | Direction | Purpose | +|----------|-------|-----------|---------| +| **Operator Rosters** | `OPERATORS`, `BATCH_OPERATOR_GROUPS` | depot → outpost | Full operator roster and the next epoch's batch-operator group, sent every epoch | +| **Liquidity Syndication** | `SYNDICATE_LIQ`, `LIQ_YIELD` | outpost → depot | A user syndicating liq tokens into outpost custody; realized custody yield | +| **Liquidity Desyndication** | `DESYNDICATE_LIQ` | depot → outpost | Return syndicated liq tokens to a holder on the outpost | +| **Node Owners** | `NODE_OWNER_REG` | outpost → depot | Node-owner NFT committed on Ethereum, registering the owner on the depot | +| **Staking** | `STAKING_REWARD` | outpost → depot | Per-staker reward credited to the `sysio.dclaim` claim ledger (validator-staking lifecycle types are reserved but deferred) | + +Cross-chain swaps routed through outposts, outpost reserves and outpost-side operator collateral have been removed. Swaps are a depot-local AMM (`sysio.swap`) with no outpost participation; operator collateral is held on the depot by `sysio.opreg`. --- ## Participant Roles ### Block Producers -Elected via Appointed Proof of Stake (APoS). They validate transactions and produce blocks on the Wire blockchain — the standard Antelope producer role. +Elected via Appointed Proof of Stake (APoS). They validate transactions and produce blocks on the Wire blockchain — the standard Antelope producer role. Producers register with `sysio.opreg` and post depot-native collateral there. ### Batch Operators -21 operators split into 3 groups of 7. One group is active per epoch. They run the `batch_operator_plugin` and are responsible for: -- Observing events on external chain Outposts -- Delivering inbound envelopes to `sysio.msgch` -- Building and submitting outbound envelopes to external chains -- Responding to challenges if disputed +Operators scheduled in groups of `operators_per_epoch` (default 21 operators in 3 groups of 7); one group is on duty per epoch, and the schedule slides forward every epoch. They run the `batch_operator_plugin` and are responsible for: +- Reading each outpost's outbound envelope and delivering it to `sysio.msgch` +- Relaying the depot's outbound envelopes to each outpost +- Cranking the depot: `sysio.msgch::chkcons` (epoch advance), `sysio.chalg::chkdispute`, `sysio.swap::tickyield`, `sysio.liq::queueyield` + +Batch operators post depot-native collateral on `sysio.opreg`; a non-canonical delivery is slashed and missed deliveries lead to termination. Signing keys for outbound submissions can be held locally (`KEY:`) or in a `kiod` daemon (`KIOD:`) — both built into `signature_provider_manager_plugin`. AWS KMS support (`KMS:` — secp256k1/ethereum keys only, remote signing so the key never leaves KMS) is provided by `signature_provider_kms_plugin`, enabled per config with `plugin = sysio::signature_provider_kms_plugin`. See `plugins/signature_provider_kms_plugin/test/README.md` for KMS key setup, IAM requirements, the `KMS:` spec format, and operational notes. AWS SSM Parameter Store support (`SSM:` — all key types) is `signature_provider_ssm_plugin`, enabled with `plugin = sysio::signature_provider_ssm_plugin`: the private key is fetched from a KMS-encrypted `SecureString` parameter once at startup and signing is local thereafter, fast enough for every signing path including block production. See `plugins/signature_provider_ssm_plugin/test/README.md` for parameter setup, IAM requirements, the `SSM:` spec format, and rotation notes. A `KMS:`/`SSM:` spec whose plugin is not enabled fails the boot with an error naming the `plugin =` line to add; host applications that embed the manager without the plugins can still register handlers from `main()` via `register_spec_handler()`. -### Underwriters -Independent operators running the `underwriter_plugin`. They provide collateral-backed liquidity for cross-chain swaps: -1. Monitor `sysio.msgch` for `SWAP` attestations -2. Verify deposits on external chains -3. Submit underwriting intent to `sysio.uwrit` with locked collateral -4. Earn fees on successful settlement; face slashing on failure +### Bond Providers (Underwriters) +Depot accounts that underwrite provable statements on `sysio.bond` with depot-native tokens (WIRE or shadow LIQ). Today the issuer is `sysio.synd`: each syndication envelope is released only after its statement is bonded. A bond provider accepts a share of a request, earns a pro-rata share of the bounty when the statement is approved, and forfeits the bond to the issuer if the statement is resolved invalid. No plugin or outpost interaction is involved. + +### Node Owners +Holders of a node-owner NFT who commit it on the Ethereum outpost (`NODE_OWNER_REG`). They are registered on `sysio.roa`, receive node-owner WIRE distributions from `sysio.system`, and form the Tier-1 electorate that votes on `sysio.chalg` envelope disputes. --- @@ -102,10 +104,20 @@ Independent operators running the `underwriter_plugin`. They provide collateral- | Contract | Role | |----------|------| -| **sysio.msgch** | Central message router — receives inbound envelopes, evaluates consensus, extracts attestations, queues outbound messages | -| **sysio.epoch** | Manages epoch cycles, assigns operator groups, registers outposts | -| **sysio.uwrit** | Underwriting ledger — tracks collateral, locks/releases funds, distributes fees, handles slashing | -| **sysio.chalg** | Challenge handler — manages dispute resolution with economic penalties | +| **sysio.msgch** | OPP channel — receives inbound envelopes, evaluates consensus, routes attestations, queues and builds outbound envelopes, cranks the epoch | +| **sysio.epoch** | Global epoch clock, sliding-window batch-operator schedule, emissions gate | +| **sysio.opreg** | Operator registry and depot-native collateral ledger (deposit, withdraw queue, pull claims, slashing, termination) | +| **sysio.chalg** | Envelope-dispute vote by Tier-1 node owners; slash execution | +| **sysio.synd** | Liquidity syndication ledger: syndication envelopes, bonded release, desyndication, rate limits | +| **sysio.bond** | Generic bonded underwriting of provable statements | +| **sysio.liq** | Shadow liq tokens (`LIQETH`, `LIQSOL`) minted against outpost custody, with a WIRE yield index | +| **sysio.swap** | Depot-local constant-product AMM; every pair's second leg is WIRE | +| **sysio.dclaim** | WIRE claim ledger (launch import, staking rewards) | +| **sysio.andon** | Emergency stop: one cord freezing depot fund egress | +| **sysio.roa** | Resources and node-owner registry | +| **sysio.authex** | Links a depot account to its external-chain keys | +| **sysio.chains** / **sysio.tokens** | Chain (outpost) and token registries | +| **sysio.system** | Emissions and producer / batch-operator / node-owner pay | --- @@ -113,20 +125,24 @@ Independent operators running the `underwriter_plugin`. They provide collateral- | Contract | Role | |----------|------| -| **OPP.sol** | Message sender — collects attestations in send mode, builds merkle trees, emits `OPPMessage` / `OPPEpoch` events | -| **OPPInbound.sol** | Message receiver — validates consensus, verifies merkle proofs, routes attestations to handlers | -| **OperatorRegistry.sol** | Maps operators to roles, keys, and status for identity validation | -| **BAR.sol** (Bond Agreement Registry) | Records validator / node-owner bonds; operator-collateral `OPERATOR_ACTION` emission has moved to `OperatorRegistry.sol` | -| **IOPPReceiver implementations** | Domain-specific handlers (Depositor, Pool, Pretoken) that process individual attestation types | +| **OPP.sol** | Outbound — queues attestations, seals and emits the outpost's per-epoch envelope | +| **OPPInbound.sol** | Inbound — accepts chunked `epochIn` deliveries, validates group consensus and the envelope chain, routes attestations to handlers | +| **SyndicationPool.sol** | liqETH custody: `syndicate` (`SYNDICATE_LIQ`), `realizeYield` (`LIQ_YIELD`), `DESYNDICATE_LIQ` payout | +| **BAR.sol** | Node-owner NFT escrow emitting `NODE_OWNER_REG` | +| **OutpostManager.sol** | OPP endpoint and role wiring | +| **StakingManager.sol** | Placeholder for the deferred staking track; its entry points revert | + +On Solana the outpost lives in the `liqsol-core` program (`instructions/opp/`), alongside liqSOL staking and syndication. --- ## Consensus & Security -- **Multi-signature consensus**: All 7 active operators must deliver identical envelope checksums for acceptance. -- **Merkle proofs**: Each message is a leaf in the epoch merkle tree; inbound processors verify proofs before accepting. -- **Challenge mechanism**: Any party can challenge a disputed epoch via `sysio.chalg`. Operators must respond with correct hashes or face slashing. -- **Collateralized underwriting**: Swaps are backed by locked collateral with a full lifecycle (intent → submit → confirm → release/slash). +- **Group consensus**: An envelope is accepted when every eligible operator in the active group delivers it identically, or by strict majority once the epoch boundary has passed. +- **Envelope hash chains**: Each envelope carries the digest of its predecessor on the same stream, and messages carry sequence numbers, so envelopes and messages cannot be reordered or replayed. +- **Disputes**: A post-boundary split with no strict majority opens a `sysio.chalg` dispute that pauses the epoch until Tier-1 node owners vote a canonical envelope. Operators that delivered a non-canonical envelope are slashed. +- **Bonded syndication**: Syndicated value is released on the depot only after `sysio.bond` underwriters bond the syndication statement; anyone may challenge it during the challenge window. +- **Emergency stop**: `sysio.andon` freezes depot fund egress; outposts pause independently (`SyndicationPool` pause on Ethereum, `frozen` on Solana). --- @@ -136,6 +152,6 @@ Wire Network implements a trustless, consensus-backed omnichain infrastructure w 1. **OPP** provides a single deterministic message encoding across all blockchains 2. **Batch operators** collectively relay and validate messages with no single point of failure -3. **Underwriters** provide collateral-backed liquidity for cross-chain swaps -4. **On-chain governance** handles scheduling (epochs), routing (message channel), and economic enforcement (underwriting + challenges) -5. **Atomic cross-chain settlement** — no bridging overhead, no wrapped tokens, no trusted intermediaries +3. **Bond providers** stand behind every syndication statement with slashable bonds +4. **On-chain governance** handles scheduling (epochs), routing (message channel), and economic enforcement (collateral, bonds, disputes) +5. **Atomic cross-chain settlement** — no bridging overhead, no trusted intermediaries diff --git a/PLUGINS.md b/PLUGINS.md index 4fce634a4b..78a5bf0c58 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -31,7 +31,7 @@ documents what it does, how it works, and how to configure it. The plugin lifecy | `query_engine_plugin` | Opt-in SQL queries over the node's current chain state, answered by an in-process C++ service and, when HTTP is enabled, `POST /v1/query/execute`; runs only on a non-producing node in head or irreversible read mode | [README](plugins/query_engine_plugin/README.md) | | `resource_monitor_plugin` | Watches free space on every file system nodeop writes to and gracefully shuts the node down before it runs out | [README](plugins/resource_monitor_plugin/README.md) | | `signature_provider_kms_plugin` | Adds the `KMS:` signature-provider scheme, signing Ethereum keys remotely through AWS KMS so the key never leaves AWS | [README](plugins/signature_provider_kms_plugin/README.md) | -| `signature_provider_manager_plugin` | Owns every signing key the node uses, parsing each `--signature-provider` spec and publishing the providers to the chain, producer, net, outpost, and underwriter plugins | [README](plugins/signature_provider_manager_plugin/README.md) | +| `signature_provider_manager_plugin` | Owns every signing key the node uses, parsing each `--signature-provider` spec and publishing the providers to the chain, producer, net, and outpost plugins | [README](plugins/signature_provider_manager_plugin/README.md) | | `signature_provider_ssm_plugin` | Adds the `SSM:` signature-provider scheme, fetching a private key once from an AWS SSM Parameter Store SecureString and signing locally thereafter | [README](plugins/signature_provider_ssm_plugin/README.md) | | `snapshot_api_plugin` | Public, read-only HTTP endpoints for snapshot discovery and download, so nodes can bootstrap from each other | [README](plugins/snapshot_api_plugin/README.md) | | `state_history_plugin` | Records per-block traces, chain-state deltas, and finality data to append-only logs and streams them to clients over a binary websocket protocol | [README](plugins/state_history_plugin/README.md) | @@ -39,7 +39,6 @@ documents what it does, how it works, and how to configure it. The plugin lifecy | `test_control_api_plugin` | HTTP surface for `test_control_plugin`, registering the `/v1/test_control/*` write endpoints in their own `test_control` API category | [README](plugins/test_control_api_plugin/README.md) | | `test_control_plugin` | Test-only plugin that arms deliberate node misbehavior: shut down inside a named producer's round, throw from a controller signal handler, or republish the next block with one action swapped | [README](plugins/test_control_plugin/README.md) | | `trace_api_plugin` | Full-history action trace store with HTTP endpoints for querying traces, transactions, actions, and token transfers | [trace_api_plugin.md](plugins/trace_api_plugin/trace_api_plugin.md) | -| `underwriter_plugin` | Autonomous underwriter daemon that polls pending swaps and submits signed underwrite-intent commits for the outpost legs that still lack one | [README](plugins/underwriter_plugin/README.md) | | `wallet_api_plugin` | Publishes the `kiod` key store as the `/v1/wallet/*` HTTP endpoints that `clio` and a `KIOD:` signature provider call. Linked and initialized by `kiod`, not `nodeop` | [README](plugins/wallet_api_plugin/README.md) | | `wallet_plugin` | The `kiod` key store: a locked directory of AES-encrypted wallet files with inactivity auto-lock and transaction and digest signing. Linked and initialized by `kiod`, not `nodeop` | [README](plugins/wallet_plugin/README.md) | diff --git a/contracts/CMakeLists.txt b/contracts/CMakeLists.txt index ecaa249ebe..1dcd1ff0d5 100644 --- a/contracts/CMakeLists.txt +++ b/contracts/CMakeLists.txt @@ -62,4 +62,5 @@ add_subdirectory(sysio.wrap) add_subdirectory(sysio.swap) add_subdirectory(sysio.liq) add_subdirectory(sysio.bond) +add_subdirectory(sysio.synd) add_subdirectory(test_contracts) diff --git a/contracts/README.md b/contracts/README.md index a00df5d5c0..8bb8f13fff 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -20,6 +20,12 @@ The collection of system contracts consists of the following individual contract * [multisig contract](contracts/sysio.msig/include/sysio.msig/sysio.msig.hpp): A contract that enables proposing transactions on the blockchain, collecting authorization approvals for many accounts, and then executing the actions within the transaction after authorization requirements of the transaction have been reached. (Note: this contract must be deployed to a privileged account.) * [wrap contract](contracts/sysio.wrap/include/sysio.wrap/sysio.wrap.hpp): A contract that wraps around any transaction and allows for executing its actions without needing to satisfy the authorization requirements of the transaction. If used, the permissions of the account hosting this contract should be configured to only allow highly trusted parties (e.g. the operators of the blockchain) to have the ability to execute its actions. (Note: this contract must be deployed to a privileged account.) +## Depot flow tests + +See [Depot flow coverage](tests/DEPOT_FLOW_TESTING.md) for the native mirrors of +all enabled cluster flows, the generic external-chain simulator, deterministic +action sequences, and focused/full validation commands. + ## Branches The `master` branch contains the latest stable branch diff --git a/contracts/sysio.authex/src/sysio.authex.cpp b/contracts/sysio.authex/src/sysio.authex.cpp index 2e80f0f8c0..24661ff3bc 100644 --- a/contracts/sysio.authex/src/sysio.authex.cpp +++ b/contracts/sysio.authex/src/sysio.authex.cpp @@ -10,7 +10,7 @@ using namespace sysio; // sysio funds the RAM for every link row (system-paid) -- createlink and recordlink alike. constexpr name link_row_payer = "sysio"_n; constexpr name dclaim_account = "sysio.dclaim"_n; -constexpr name liq_account = "sysio.liq"_n; +constexpr name synd_account = "sysio.synd"_n; constexpr name linkswept_action = "linkswept"_n; constexpr auto dclaim_not_ready_message = "sysio.dclaim must be deployed and privileged before creating a link"; @@ -67,24 +67,24 @@ void try_send_linked_rewards_sweep(const name self, const name account, if (dclaim_ready()) send_linked_rewards_sweep(self, account, chain_kind, native_address); } -/** Return whether the shadow-liq ledger can receive a system-paid link sweep. */ -[[nodiscard]] bool liq_ready() { - return is_account(liq_account) && is_privileged(liq_account); +/** Return whether the syndication contract can receive a system-paid link sweep. */ +[[nodiscard]] bool synd_ready() { + return is_account(synd_account) && is_privileged(synd_account); } /** * Best-effort sweep of the shadow liq parked against a user-created link's key. * - * sysio.liq parks by the key bytes an outpost reports (the 33-byte EM key or the 32-byte ED + * sysio.synd parks by the key bytes an outpost reports (the 33-byte EM key or the 32-byte ED * key), so it receives the verified key, not the derived address the DClaim sweep takes. The - * sweep is optional by design: nothing is parked before sysio.liq exists, and a link created - * while it was absent is served by the permissionless `sysio.liq::sweep`. + * sweep is optional by design: nothing is parked before sysio.synd exists, and a link created + * while it was absent is served by the permissionless `sysio.synd::sweep`. */ void try_send_parked_liq_sweep(const name self, const name account, const opp::types::ChainKind chain_kind, const std::vector& pubkey) { - if (!liq_ready()) return; - action(permission_level{self, "active"_n}, liq_account, linkswept_action, + if (!synd_ready()) return; + action(permission_level{self, "active"_n}, synd_account, linkswept_action, std::make_tuple(account, chain_kind, pubkey)).send(); } diff --git a/contracts/sysio.authex/sysio.authex.wasm b/contracts/sysio.authex/sysio.authex.wasm index 92f6be06b5c4853b04158bfa80ea23d8011adf2d..28f7a317bec0c75339c8ffff0e1539dd003c49ff 100755 GIT binary patch delta 41 vcmcaOkLlt(rVSU3g #include +#include + namespace sysio { /** @@ -152,6 +154,17 @@ namespace sysio { sysio::kv::index<"byextid"_n, sysio::const_mem_fun>, sysio::kv::index<"byactive"_n, sysio::const_mem_fun> >; + + /// The chain family of `chain_code` in the registry deployed at `chains_account` when it is an + /// active outpost -- a registered, active row that is not the depot -- else `std::nullopt`. Never + /// throws. The ONE outpost-family lookup: `sysio.liq` (behind a checked wrapper), `sysio.msgch`'s + /// syndication dispatch and `sysio.synd` all use it. + static std::optional outpost_kind_of(name chains_account, sysio::slug_name chain_code) { + chains_t chains_tbl(chains_account); + const auto row = chains_tbl.try_get(chain_key{chain_code}); + if (!row || !row->active || row->is_depot) return std::nullopt; + return row->kind; + } }; } // namespace sysio diff --git a/contracts/sysio.epoch/README.md b/contracts/sysio.epoch/README.md index 803acc918a..011fbdec97 100644 --- a/contracts/sysio.epoch/README.md +++ b/contracts/sysio.epoch/README.md @@ -1,41 +1,58 @@ # sysio.epoch -Epoch lifecycle and batch operator scheduling contract for the OPP (Outpost Protocol) system. +Global epoch clock and batch operator scheduling contract for the OPP (Outpost Protocol) system. ## Responsibility -- Manages the synthetic epoch clock (default: 6-minute epochs) -- Maintains the batch operator roster (registration, warmup, cooldown, blacklist) -- Assigns operators into 3 rotation groups of 7 via `schbatchgps` -- Advances epochs with deterministic group rotation (`epoch_index % 3`) -- Supports in-place operator replacement when an operator is slashed or deregistered -- Registers external chain outposts (ETH, SOL, etc.) -- Provides global pause/unpause (controlled by `sysio.chalg`) +- Owns the single global epoch clock (`epoch_duration_sec`, default 360 s, bounded to 60 s .. 30 days) +- Builds the initial batch operator schedule with `schbatchgps` and maintains it as a sliding window + of `batch_op_groups` groups of `operators_per_epoch` members; the group on duty is always the front + of the window +- Advances epochs (`advance`), crank-driven by `sysio.msgch::chkcons` after genesis +- Gates every advance on emissions readiness: if `sysio.system` cannot pay the epoch, the advance is + recorded in `blocklog` and skipped (never thrown), and retried on the next `chkcons` +- Provides the global pause/unpause used by `sysio.chalg` while an envelope dispute is open + +Operator registration, collateral and status live on `sysio.opreg`; the outpost set lives on +`sysio.chains`. This contract reads both and mirrors neither. ## Tables | Table | Type | Description | |-------|------|-------------| -| `epochcfg` | Singleton | Epoch duration, group count, warmup/cooldown config | -| `epochstate` | Singleton | Current epoch index, active group, 3 groups of 7, pause flag | -| `operators` | Multi-index | Operator roster with type, status, collateral, group assignment | -| `outposts` | Multi-index | Registered external chain outposts | +| `epochcfg` | `kv::global` | `epoch_duration_sec`, `operators_per_epoch` (default 7), `batch_operator_minimum_active` (default 21), `batch_op_groups` (default 3), `epoch_retention_envelope_log_count` (default 200) | +| `epochstate` | `kv::global` | Current epoch index, current / next epoch start, active group cursor, the resident group window, last consensus hash, pause flag | +| `blocklog` | `kv::table` | One row per epoch the emissions gate blocked: reason, attempted emission, treasury and balance figures, first/last retry and retry count. Pruned when the gate passes | ## Actions | Action | Auth | Description | |--------|------|-------------| -| `setconfig` | `sysio.epoch` | Set epoch configuration | -| `regoperator` | `sysio.epoch` | Register an operator | -| `unregoper` | operator | Begin deregistration (cooldown) | -| `advance` | permissionless | Advance epoch if duration elapsed | -| `schbatchgps` | `sysio.epoch` | One-time group assignment (21 operators) | -| `replaceop` | `sysio.epoch` | Replace operator in-place within group | -| `regoutpost` | `sysio.epoch` | Register an outpost chain | -| `pause` | `sysio.chalg` | Set global pause | -| `unpause` | `sysio.chalg` | Clear global pause | +| `setconfig` | `sysio.epoch` | Set the epoch configuration. Requires `operators_per_epoch * batch_op_groups == batch_operator_minimum_active` and enforces the size caps (`MAX_OPERATORS_PER_EPOCH` 100, `MAX_BATCH_OP_GROUPS` 255, `MAX_SCHEDULED_BATCH_OPERATORS` 1000) | +| `schbatchgps` | `sysio.epoch` | Build the initial window from the `ACTIVE` batch operators on `sysio.opreg` (non-bootstrapped first, then bootstrapped, trimmed to `batch_operator_minimum_active`) | +| `advance` | permissionless at genesis (epoch 0 → 1); afterwards `sysio.msgch` or `sysio.epoch` | Advance the epoch once the wall-clock boundary has passed and the contract is not paused | +| `pause` | `sysio.chalg` | Set the global pause | +| `unpause` | `sysio.chalg` | Clear the global pause | + +## What `advance` does + +1. Emissions readiness gate (block-and-retry via `blocklog`, never a throw). +2. Delivery audit of the expiring group for every active outpost: every operator that delivered a + non-canonical envelope is slashed through `sysio.chalg::slashop`; each member's delivery result is + recorded with `sysio.opreg::recorddel`, followed by `sysio.opreg::termcheck`. +3. Bump the epoch index, drop the expired group from the front of the window and append a new tail + group drawn from `ACTIVE` batch operators that are not already resident (non-bootstrapped first). + The resident groups stay disjoint, so an operator serves at most once every `batch_op_groups` + epochs. +4. `sysio.opreg::flushwtdw` matures queued collateral withdrawals. +5. Queue `OPERATORS` and `BATCH_OPERATOR_GROUPS` to every active outpost through + `sysio.msgch::queueout` (an empty group is withheld), then `sysio.msgch::buildenv` per outpost. +6. `sysio.system::accrueepoch` and `rcrdbatch`, plus `payepoch` on pay epochs. ## Dependencies -- Notifies `sysio.msgch` on epoch advancement +- Advanced by `sysio.msgch` (`chkcons`, and `bootstrap` at epoch 0) +- Reads operators from `sysio.opreg`, authex links from `sysio.authex`, outposts from `sysio.chains` +- Calls `sysio.chalg::slashop`, `sysio.opreg::{recorddel,termcheck,flushwtdw}`, + `sysio.msgch::{queueout,buildenv}` and `sysio.system::{accrueepoch,rcrdbatch,payepoch}` - Pause/unpause controlled by `sysio.chalg` diff --git a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp index 4f39e90c27..3d98850aff 100644 --- a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp +++ b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp @@ -128,6 +128,21 @@ namespace sysio { static constexpr name UWRIT_ACCOUNT = "sysio.uwrit"_n; static constexpr name RESERV_ACCOUNT = "sysio.reserv"_n; + /// True while the depot is in its epoch-0 bootstrap window: no epoch state yet, or the + /// current epoch index is still 0. Never throws. The one window test `sysio.liq`'s + /// `regliqpool` and `sysio.synd`'s `importsynd` gate their launch ingestion on. + static bool in_bootstrap_window() { + epochstate_t es(EPOCH_ACCOUNT); + return !es.exists() || es.get().current_epoch_index == 0; + } + + /// The depot's current epoch index, or 0 before the first epoch state is written. Never + /// throws. The one reader of the index alone: `sysio.msgch` and `sysio.synd`'s bucket ticks. + static uint32_t current_epoch_index() { + epochstate_t es(EPOCH_ACCOUNT); + return es.exists() ? es.get().current_epoch_index : 0; + } + /// Bounds on `epoch_duration_sec`. Floor is a typo-guard: below it a pay /// period holds fewer block slots than one producer rotation, so the /// per-block pay in sysio.system::payepoch degenerates to a handful of diff --git a/contracts/sysio.liq/CMakeLists.txt b/contracts/sysio.liq/CMakeLists.txt index 8de5702df7..f2e0c6ff41 100644 --- a/contracts/sysio.liq/CMakeLists.txt +++ b/contracts/sysio.liq/CMakeLists.txt @@ -29,9 +29,9 @@ if(BUILD_SYSTEM_CONTRACTS) target_include_directories(${target} PUBLIC $ - $ $ $ + $ $ $ $ diff --git a/contracts/sysio.liq/README.md b/contracts/sysio.liq/README.md index ab400baab3..be42397847 100644 --- a/contracts/sysio.liq/README.md +++ b/contracts/sysio.liq/README.md @@ -12,19 +12,23 @@ RAM pool, and every inline action carries the authority it needs, so no ## Flows -**Syndication (SYNDICATE_LIQ, inbound).** `sysio.msgch` resolves the user's pubkey -through `sysio.authex` and calls `mintsynd` (linked) or `park` (not linked). A -parked row accrues like any holder; `linkswept` (inline from `createlink`) or the -permissionless `sweep` delivers it, accrued WIRE included, to the account the -pubkey later links. The inbound actions never throw: a replayed `sequence`, an -unknown token, a token of another chain or an out-of-range amount is dropped with -a diagnostic. - -**Yield (LIQ_YIELD, inbound).** `mintyield` holds the reported yield in the -symbol's pending balance, outside supply but reserved against the asset range -beside it: every supply-growing path measures its headroom net of what is pending, -a report past that headroom is dropped before its sequence is consumed, and -queueing always fits. The permissionless `queueyield` mints it +**Supply (sysio.synd only).** `sysio.synd` holds every inbound SYNDICATE_LIQ and +is the only account that grows or shrinks the supply: `mint(to, token_code, +amount)` mints into a holder row and `burn(token_code, amount)` burns out of +`sysio.synd`'s own row, settled first. Both refuse an unknown token, a zero +amount, and a mint past the headroom (the asset range net of the supply and the +pending yield, `headroom_of`, which `sysio.synd` reads before it mints). The +supply plus the pending yield is the depot's outstanding shadow of the symbol, +`outstanding_of`, which `headroom_of` is the complement of and which +`sysio.synd` carries on every DESYNDICATE_LIQ as `total_syndicated`. The +parked hold, its sweeps, de-syndication and the pre-launch import all live in +`sysio.synd` and reach this ledger only through `mint`, `burn` and `transfer`. + +**Yield (LIQ_YIELD).** `mintyield`, signed by `sysio.synd` when it releases a +report, holds the yield in the symbol's pending balance, outside supply but +reserved against the asset range beside it: every supply-growing path measures its +headroom net of what is pending, a report past that headroom is dropped whole, and +queueing always fits. It never throws; replay is `sysio.synd`'s to refuse. The permissionless `queueyield` mints it to this contract, announces it to `sysio.swap` with `fundyield` and transfers it, in one transaction, so it lands in the pool's reservoir and never accrues to this contract. The swap sells it in clips through the pool and pays the proceeds in @@ -40,18 +44,15 @@ up what is not yield, or a near-sole holder could donate, claim it back with the kicker on top, and repeat. A short T5 reduces the kicker only. `setkicker` (auth `sysio`, the account council proposals execute as) changes the next intake. -**De-syndication (DESYNDICATE_LIQ, outbound).** `desyndicate` requires the holder -to be AuthX-linked for the token's chain, settles and burns the shadow, and queues -`DesyndicateLIQ{chain_code, user = linked pubkey, amount, request_id}` through -`sysio.msgch::queueout`. Request ids start at 1. The burn is final: an outpost -refusal is reconciled from its log by governance through `recredit`. +**De-syndication reconciliation.** `sysio.synd::desyndicate` burns through `burn`; +the burn is final. An outpost refusal is reconciled from its log by governance +through `recredit`. **Launch ingestion (epoch-0 bootstrap window, privileged caller).** `regliqpool` mints the LCO liq to `sysio`, deposits it with the T5 dex earmark WIRE into `sysio.swap`, creates the pair (`sysio` fee authority, the shadow as yield leg) -and sets the tick parameters. `importsynd` replays pre-launch positions in -batches (the LCO yield already folded into each amount); `importdone` closes the -import. +and sets the tick parameters. The pre-launch positions are replayed by +`sysio.synd::importsynd`. ## Tables @@ -60,11 +61,8 @@ import. | `stat` | symbol code | `supply`, `chain_code`, `token_code`, `pair_symbol` (empty until `regliqpool`); index `bytoken` | | `accounts` | holder / symbol code | `balance`, `index_checkpoint` (uint128), `owed_wire` | | `yieldidx` | symbol code | `index` (uint128), `pot`, `carry` | -| `parked` | symbol code, chain kind, pubkey | `chain_kind`, `pubkey`, `holding` (an account row) | | `liqpending` | symbol code | `quantity` minted by LIQ_YIELD and not yet queued; counts against the asset range beside supply | -| `liqcursors` | chain code | `last_sequence`, `last_epoch` | -| `liqconfig` | singleton | `kicker_bps`, `import_complete` | -| `liqcounters` | singleton | `next_request_id` | +| `liqconfig` | singleton | `kicker_bps` | ## Actions @@ -73,27 +71,31 @@ import. | `create(sym, chain_code, token_code)` | self | Register a shadow for an active `TOKEN_KIND_LIQ` token bound to an active outpost | | `setkicker(bps)` | `sysio` | Kicker for the intakes from now on | | `recredit(holder, quantity)` | self | Mint back after an outpost refused a de-syndication | -| `mintsynd(chain_code, sequence, account, token_code, amount)` | `sysio.msgch` | SYNDICATE_LIQ, linked user | -| `park(chain_code, sequence, chain_kind, pubkey, token_code, amount)` | `sysio.msgch` | SYNDICATE_LIQ, unlinked user | -| `mintyield(chain_code, sequence, epoch, token_code, amount)` | `sysio.msgch` | LIQ_YIELD into the pending balance | +| `mint(to, token_code, amount)` | `sysio.synd` | Mint shadow into a holder row | +| `burn(token_code, amount)` | `sysio.synd` | Burn shadow out of `sysio.synd`'s own row | +| `mintyield(chain_code, token_code, amount)` | `sysio.synd` | LIQ_YIELD into the pending balance | | `queueyield(sym)` | none | Pending yield into the swap's reservoir | -| `sweep(account, chain_kind)` | none | Deliver parked rows for an existing link | | `transfer`, `open`, `close`, `claim` | holder | `sysio.token`'s shape; `close` refuses while yield is owed | | `addyield(from, quantity, target)` | `from` | Distribute WIRE to `target`'s holders | | `addkicker(sym, base_balance, requested)` | self | Inline from `addyield` | -| `linkswept(account, chain_kind, pubkey)` | `sysio.authex` | Deliver parked rows on link | -| `desyndicate(holder, quantity)` | holder | Burn and queue DESYNDICATE_LIQ | -| `regliqpool(...)`, `importsynd(...)`, `importdone()` | privileged caller, epoch 0 | Launch ingestion | +| `regliqpool(...)` | privileged caller, epoch 0 | Seed a shadow's yield pool | ## Deployment In order: `sysio.roa::setsyscode` for `sysio.liq`; the chain and its liq token registered and active in `sysio.chains` / `sysio.tokens`; `create` per shadow -symbol; `setkicker` if the default is not wanted; `regliqpool` per pool; -`importsynd` batches; `importdone`. `sysio.swap` must be configured +symbol; `setkicker` if the default is not wanted; `regliqpool` per pool; then +`sysio.synd`'s `importsynd` batches and `importdone`. `sysio.swap` must be configured (`setconfig`) before `regliqpool`, and the batch-operator crank pushes `queueyield` per symbol and `sysio.swap::tickyield` per pair. -The Solana relay must carry the `DESYNDICATE_LIQ` effect shape before this -contract is deployed: a delivered `DesyndicateLIQ` without its accounts aborts +The Solana relay must carry the `DESYNDICATE_LIQ` effect shape before +`sysio.synd` is deployed: a delivered `DesyndicateLIQ` without its accounts aborts the outpost's handler and wedges the epoch. + +### Custody settlement + +`settle(custodian, beneficiary, quantity)` lets only `sysio.synd` and `sysio.bond`, under their own +authority, credit existing LIQ to a beneficiary without sender or recipient notifications. Both yield +positions settle before balances change; supply is unchanged. Self-settlement checks the balance and +settles yield once. Andon must be clear in all cases. Ordinary `transfer` keeps its notification behavior. diff --git a/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp index 503d4fa049..38fd9b9672 100644 --- a/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp +++ b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp @@ -9,15 +9,20 @@ * sysio.opp.common/shadow_yield.hpp: every balance move settles the row first, * and `claim` pays what `shadow::owed` says. * - * Inbound, dispatched by sysio.msgch and therefore never throwing: - * SYNDICATE_LIQ -> `mintsynd` for an AuthX-linked user, `park` for one without a link; - * LIQ_YIELD -> `mintyield`, into a pending balance outside supply that the - * permissionless `queueyield` hands to sysio.swap's reservoir. - * Outbound: `desyndicate` burns and queues DESYNDICATE_LIQ; the burn is final. + * Supply is sysio.synd's to grow and shrink: every inbound SYNDICATE_LIQ is held by + * sysio.synd, which mints it into its own holder row with `mint`, and every + * de-syndication and pre-launch import goes through sysio.synd too, which burns with + * `burn` and mints the replayed positions; a LIQ_YIELD report it releases lands through + * `mintyield` in a pending balance outside supply that the permissionless `queueyield` + * hands to sysio.swap's reservoir. Return recovery is request-keyed in sysio.synd; + * `recredit` remains exceptional privileged supply repair. * Yield intake: sysio.swap's tick sells reservoir shadow and pays the proceeds in * through `addyield`, which also draws the kicker from T5 (sysio.system::fundclaim). - * Launch: `regliqpool` seeds the swap's yield pool and `importsynd` / `importdone` - * replay the pre-launch positions, all inside the epoch-0 bootstrap window. + * Launch: `regliqpool` seeds the swap's yield pool inside the epoch-0 bootstrap window. + * + * Emergency stop: while the `sysio.andon` cord is pulled, `transfer` to anything but a + * custody contract (sysio.bond, sysio.synd, sysio.opreg), `claim` by anyone but a custody + * contract, and `queueyield` are refused; `mint`, `burn` and transfers into custody run. * * Privileged (roa::setsyscode): holder rows bill the `sysio` RAM pool, and every * inline action carries the authority it needs, so no `sysio.code` grant exists. @@ -33,12 +38,9 @@ #include #include -#include - #include #include #include -#include namespace sysio { @@ -49,17 +51,14 @@ namespace sysio { using contract::contract; // Well-known accounts. - static constexpr name MSGCH_ACCOUNT = "sysio.msgch"_n; - static constexpr name AUTHEX_ACCOUNT = "sysio.authex"_n; static constexpr name TOKEN_ACCOUNT = "sysio.token"_n; static constexpr name TOKENS_ACCOUNT = "sysio.tokens"_n; static constexpr name CHAINS_ACCOUNT = "sysio.chains"_n; - static constexpr name EPOCH_ACCOUNT = "sysio.epoch"_n; static constexpr name SWAP_ACCOUNT = "sysio.swap"_n; + /// The syndication contract: the only account that mints, burns and reports yield. + static constexpr name SYND_ACCOUNT = "sysio.synd"_n; /// Governance executes approved proposals as `sysio`; it is also the T5 /// treasury the bootstrap drains and the holder of the protocol's pool shares. - /// The syndication custodian permitted to settle its own LIQ balance. - static constexpr name SYND_ACCOUNT = "sysio.synd"_n; static constexpr name SYSTEM_ACCOUNT = "sysio"_n; /// The yield asset. The kicker arrives in it from sysio.system, so every @@ -76,6 +75,9 @@ namespace sysio { /// Register the shadow symbol `sym` for the liq token `token_code` of the /// outpost `chain_code`. Both must be active registry rows, the token a /// TOKEN_KIND_LIQ whose depot precision is `sym`'s; one shadow per token. + /// A precision below two decimals is accepted, but sysio.bond cannot bond + /// such a token, so no syndication of it can ever be underwritten and + /// every envelope of it waits in sysio.synd until `sysio.synd::dropenv`. /// Requires this contract's authority. [[sysio::action]] void create(symbol sym, sysio::slug_name chain_code, sysio::slug_name token_code); @@ -86,32 +88,43 @@ namespace sysio { /// Governance: mint `quantity` back to `holder` after its outpost refused /// a de-syndication (reconciled from the outpost log). Requires this /// contract's authority. + /// Legacy exceptional supply repair, still requiring this contract's authority. Normal external + /// return recovery must use sysio.synd::refundreturn so request identity and exactly-once handling + /// are enforced. This action is not evidence that an external return was rejected. [[sysio::action]] void recredit(name holder, asset quantity); // ----------------------------------------------------------------------- - // Inbound OPP effects (sysio.msgch dispatch; never throw) + // Supply (sysio.synd only) // ----------------------------------------------------------------------- - /// SYNDICATE_LIQ for an AuthX-linked user: mint `amount` of `token_code`'s - /// shadow to `account`. Auth=sysio.msgch. A replayed `sequence`, an - /// unknown token, a token of another chain or an out-of-range amount is - /// dropped with a diagnostic, never an abort. - [[sysio::action]] void mintsynd(sysio::slug_name chain_code, uint64_t sequence, name account, - sysio::slug_name token_code, uint64_t amount); - - /// SYNDICATE_LIQ for a user with no AuthX link yet: mint to a parked row - /// keyed by the user's native pubkey, which accrues like any holder until - /// `linkswept` or `sweep` delivers it. Same contract as `mintsynd`. - [[sysio::action]] void park(sysio::slug_name chain_code, uint64_t sequence, - opp::types::ChainKind chain_kind, std::vector pubkey, - sysio::slug_name token_code, uint64_t amount); - - /// LIQ_YIELD: the outpost claimed `amount` of yield for its syndicated - /// pool. Held in the symbol's pending balance, outside supply, until - /// `queueyield` moves it; `epoch` is kept on the cursor for forensics. - /// Same contract as `mintsynd`. - [[sysio::action]] void mintyield(sysio::slug_name chain_code, uint64_t sequence, uint64_t epoch, - sysio::slug_name token_code, uint64_t amount); + /// Mint `amount` base units of `token_code`'s shadow to `to`, growing the + /// supply. Refused for a token with no shadow, a zero amount or an amount + /// past the symbol's headroom (the asset range net of the supply and of the + /// pending yield). Auth=sysio.synd. + [[sysio::action]] void mint(name to, sysio::slug_name token_code, uint64_t amount); + + /// Burn `amount` base units of `token_code`'s shadow out of sysio.synd's own + /// holder row, settled first, shrinking the supply. Refused for a token with + /// no shadow, a zero amount or more than the row holds. Auth=sysio.synd. + [[sysio::action]] void burn(sysio::slug_name token_code, uint64_t amount); + + /// LIQ_YIELD released by sysio.synd: `amount` of yield the outpost + /// `chain_code` claimed for its syndicated pool. Held in the symbol's + /// pending balance, outside supply, until `queueyield` moves it. Replay is + /// sysio.synd's to refuse. Never throws: an unknown token, a token of + /// another chain or an out-of-range amount is dropped with a diagnostic. + /// Auth=sysio.synd. + [[sysio::action]] void mintyield(sysio::slug_name chain_code, sysio::slug_name token_code, uint64_t amount); + + /// Credit `holder` the `wire` WIRE sysio.synd holds for it -- the yield a held or parked position + /// banked -- as owed yield of its `sym` row, claimable with `claim` like any yield: the row is + /// settled first (created at balance 0 when there is none), `wire` joins its banked `owed_wire` + /// and the symbol's pot, and the WIRE moves from sysio.synd into this contract by an inline + /// `sysio.token::transfer` in the same action. Nothing is pushed to `holder` and it is not + /// notified, so a holder whose transfer handler would refuse cannot fail the caller. Refused for + /// a missing account, a zero amount, an unknown symbol, or a banked total past the asset range. + /// Auth=sysio.synd. + [[sysio::action]] void creditowed(name holder, symbol_code sym, uint64_t wire); // ----------------------------------------------------------------------- // Cranks @@ -119,19 +132,18 @@ namespace sysio { /// Hand `sym`'s pending yield to sysio.swap's reservoir for its pool: mint /// it to this contract, announce it with `fundyield` and transfer it, all - /// in one transaction. Permissionless; a no-op with nothing pending. + /// in one transaction. Permissionless; a no-op with nothing pending. Refused + /// while the `sysio.andon` cord is pulled. [[sysio::action]] void queueyield(symbol_code sym); - /// Deliver every parked row of the pubkey `account` has linked for - /// `chain_kind`, for a link that already exists: late arrivals, and the - /// node-owner path that records links without `createlink`. Permissionless. - [[sysio::action]] void sweep(name account, opp::types::ChainKind chain_kind); - // ----------------------------------------------------------------------- // The token // ----------------------------------------------------------------------- + /// Move `quantity` of a shadow from `from` to `to`, settling both rows first. While the + /// `sysio.andon` cord is pulled, refused unless `to` is a custody contract. Auth=from. [[sysio::action]] void transfer(name from, name to, asset quantity, string memo); + /// Credit an existing custody obligation without notifying either account. Only sysio.synd /// and sysio.bond may debit their own balance. Settles both yield positions, preserves supply, /// and requires the Andon cord clear, including self-settlement. @@ -141,7 +153,8 @@ namespace sysio { /// owed yield, so closing never discards WIRE. [[sysio::action]] void close(name owner, symbol symbol); /// Pay `holder` the WIRE its row for `sym` is owed and settle the row. - /// Holder's authority; sends nothing when nothing is owed. + /// Holder's authority; sends nothing when nothing is owed. While the + /// `sysio.andon` cord is pulled, refused unless `holder` is a custody contract. [[sysio::action]] void claim(name holder, symbol_code sym); /// Distribute `quantity` WIRE to `target`'s holders: the index advances by /// quantity / supply with the remainder carried and the WIRE is pulled from @@ -153,16 +166,6 @@ namespace sysio { /// `requested`. Inline from `addyield`; this contract's authority. [[sysio::action]] void addkicker(symbol_code sym, int64_t base_balance, uint64_t requested); - /// AuthX link completed for `account` on `chain_kind`: deliver every parked - /// row of that pubkey to `account`, accrued WIRE included. Auth=sysio.authex. - [[sysio::action]] void linkswept(name account, opp::types::ChainKind chain_kind, std::vector pubkey); - - /// Burn `quantity` of `holder`'s shadow and queue DESYNDICATE_LIQ to the - /// symbol's outpost, paying the pubkey `holder` has linked for that chain. - /// The burn is final: an outpost refusal is reconciled by governance through - /// `recredit`. Holder's authority. - [[sysio::action]] void desyndicate(name holder, asset quantity); - // ----------------------------------------------------------------------- // Launch ingestion (privileged caller, epoch-0 bootstrap window) // ----------------------------------------------------------------------- @@ -178,24 +181,6 @@ namespace sysio { int64_t locked_shares, uint32_t conversion_horizon_sec, uint32_t depth_cap_bps, int64_t clip_floor); - /// One pre-launch position: the holder's native pubkey (32-byte Ed25519 on - /// SVM, 33-byte compressed secp256k1 on EVM) and its shadow amount in - /// subunits, the LCO yield already folded in. - struct import_credit { - std::vector pubkey; - uint64_t amount = 0; - SYSLIB_SERIALIZE(import_credit, (pubkey)(amount)) - }; - - /// Replay pre-launch positions of `token_code` on `chain_code`: each credit - /// mints to the account its pubkey has linked, or to a parked row. Batched; - /// the same pubkey across batches sums. Refused once `importdone` ran. - [[sysio::action]] void importsynd(sysio::slug_name chain_code, sysio::slug_name token_code, - std::vector credits); - - /// Close the import: every later `importsynd` is refused. - [[sysio::action]] void importdone(); - // ----------------------------------------------------------------------- // Tables // ----------------------------------------------------------------------- @@ -241,27 +226,8 @@ namespace sysio { /// Holder rows (scope = holder, key = symbol code) and the per-symbol /// index, laid out by sysio.opp.common/shadow_yield.hpp. - using accounts = kv::scoped_table<"accounts"_n, symbol_key, opp::shadow::account>; - using yieldidxs = kv::table<"yieldidx"_n, symbol_key, opp::shadow::yield_index>; - - /// A parked row: the symbol, the chain family and the holder's native pubkey. - struct parked_key { - uint64_t symbol_code; - uint64_t chain_kind; ///< magic_enum::enum_integer of the ChainKind; the row keeps the enum - std::vector pubkey; - SYSLIB_SERIALIZE(parked_key, (symbol_code)(chain_kind)(pubkey)) - }; - - /// Shadow minted for a pubkey with no AuthX link yet. `holding` accrues - /// exactly as a holder row does, so linking late costs no yield. - struct [[sysio::table("parked")]] parked_row { - opp::types::ChainKind chain_kind; - std::vector pubkey; - opp::shadow::account holding; - SYSLIB_SERIALIZE(parked_row, (chain_kind)(pubkey)(holding)) - }; - - using parkeds = kv::table<"parked"_n, parked_key, parked_row>; + using accounts = opp::shadow::accounts_table; + using yieldidxs = opp::shadow::yield_index_table; /// Yield minted by LIQ_YIELD and not yet queued. Outside supply, so it /// earns nothing while it waits and nothing is stranded when it leaves; @@ -273,47 +239,45 @@ namespace sysio { using liqpendings = kv::table<"liqpending"_n, symbol_key, pending_yield>; - struct cursor_key { - uint64_t chain_code; - SYSLIB_SERIALIZE(cursor_key, (chain_code)) - }; - - /// Per-outpost replay guard over the sequence SYNDICATE_LIQ and LIQ_YIELD share. - struct [[sysio::table("liqcursors")]] liq_cursor { - sysio::slug_name chain_code; - uint64_t last_sequence = 0; ///< highest sequence admitted; anything at or below it is a replay - uint64_t last_epoch = 0; ///< outpost epoch of the last LIQ_YIELD, for forensics - SYSLIB_SERIALIZE(liq_cursor, (chain_code)(last_sequence)(last_epoch)) - }; + /// Base units of shadow the depot has committed for `st`, a `stat` row of the + /// `sysio.liq` deployed at `liq_account`: its supply plus the yield parked in + /// `liqpending`, which is committed once parked because the permissionless + /// `queueyield` mints it into supply. This is the depot's outstanding shadow that an + /// outpost's custody of the token must cover. Never throws: supply and pending are + /// each non-negative and their sum stays within the asset range (`headroom_of`). + static uint64_t outstanding_of(name liq_account, const currency_stats& st) { + liqpendings pendings(liq_account); + const auto pending = pendings.try_get(symbol_key{ st.supply.symbol.code().raw() }); + const uint64_t parked = pending ? static_cast(pending->quantity.amount) : 0; + return static_cast(st.supply.amount) + parked; + } - using liqcursors = kv::table<"liqcursors"_n, cursor_key, liq_cursor>; + /// Base units the supply of `st`, a `stat` row of the `sysio.liq` deployed at + /// `liq_account`, can still grow by: the asset range net of `outstanding_of`, the + /// supply and the yield parked in `liqpending`, which mints when queued. Never throws. + /// The ONE headroom computation: `sysio.liq`'s own mints and `sysio.synd`'s intake, + /// which must know a `mint` will fit before it sends one, both use it. + static uint64_t headroom_of(name liq_account, const currency_stats& st) { + const uint64_t range = static_cast(asset::max_amount); + const uint64_t outstanding = outstanding_of(liq_account, st); + return outstanding < range ? range - outstanding : 0; + } struct [[sysio::table("liqconfig")]] liq_config { - uint32_t kicker_bps = DEFAULT_KICKER_BPS; - bool import_complete = false; - SYSLIB_SERIALIZE(liq_config, (kicker_bps)(import_complete)) + uint32_t kicker_bps = DEFAULT_KICKER_BPS; + SYSLIB_SERIALIZE(liq_config, (kicker_bps)) }; using liqconfig_t = kv::global<"liqconfig"_n, liq_config>; - struct [[sysio::table("liqcounters")]] liq_counters { - uint64_t next_request_id = 1; ///< DESYNDICATE_LIQ ids; the outpost reads 0 as "no id" - SYSLIB_SERIALIZE(liq_counters, (next_request_id)) - }; - - using liqcounters_t = kv::global<"liqcounters"_n, liq_counters>; - private: using ChainKind = opp::types::ChainKind; using u128 = opp::shadow::u128; /// The stat row of `sym`, or a check failure. currency_stats stat_of(symbol_code sym) const; - /// The stat row bound to `token_code`, if any. - std::optional stat_by_token(sysio::slug_name token_code) const; - /// Base units `st`'s supply can still grow by: the asset range net of the supply - /// and of the yield parked in `liqpending`, which mints when queued. - uint64_t headroom(const currency_stats& st) const; + /// The stat row of the shadow registered under `token_code`, or a check failure. + currency_stats stat_by_token(sysio::slug_name token_code) const; /// The chain family of the registered outpost `chain_code`, or a check failure. ChainKind kind_of_chain(sysio::slug_name chain_code) const; /// `sym`'s index now; zero before the first distribution. @@ -327,24 +291,13 @@ namespace sysio { /// Apply `delta` to `owner`'s row for its symbol through the funnel. A row /// created here is stamped at the current index, so no history is credited. void adjust_account(name owner, const asset& delta, name payer); - /// The same for a parked row. - void adjust_parked(const parked_key& key, ChainKind chain_kind, const std::vector& pubkey, - const asset& delta); /// Grow `sym`'s supply by `quantity`; false (and no change) past its headroom. - bool mint(symbol_code sym, uint64_t quantity); + bool grow_supply(symbol_code sym, uint64_t quantity); /// Advance `sym`'s index by `quantity` WIRE over its supply, carrying the /// remainder, and grow its pot by the same. void distribute(symbol_code sym, uint64_t quantity); - /// Admit `sequence` for `chain_code` and advance its cursor; false on a replay. - bool admit_sequence(sysio::slug_name chain_code, uint64_t sequence, uint64_t epoch); - /// Move every parked row of `(chain_kind, pubkey)` into `account`'s rows. - void deliver_parked(name account, ChainKind chain_kind, const std::vector& pubkey); - /// Credit `amount` of `sym` to the account `pubkey` has linked, else to its parked row. - void credit_by_pubkey(symbol_code sym, ChainKind chain_kind, const std::vector& pubkey, uint64_t amount); - /// The `mintsynd` / `park` / `mintyield` preamble: the symbol for `token_code` - /// on `chain_code`, with `amount` in range and room in the supply, or nullopt - /// after a diagnostic. Touches no cursor: the caller admits the sequence only - /// once every check has passed, so a dropped attestation consumes nothing. + /// The `mintyield` preamble: the symbol for `token_code` on `chain_code`, with + /// `amount` in range and room in the supply, or nullopt after a diagnostic. std::optional resolve_inbound(const char* path, sysio::slug_name chain_code, sysio::slug_name token_code, uint64_t amount); }; diff --git a/contracts/sysio.liq/src/sysio.liq.cpp b/contracts/sysio.liq/src/sysio.liq.cpp index 9418ea4901..7785dca26c 100644 --- a/contracts/sysio.liq/src/sysio.liq.cpp +++ b/contracts/sysio.liq/src/sysio.liq.cpp @@ -1,15 +1,13 @@ #include #include -#include #include #include #include #include #include +#include #include -#include #include -#include #include @@ -26,7 +24,6 @@ constexpr auto overdrawn_balance_msg = "overdrawn balance"; constexpr name bond_account = "sysio.bond"_n; -using opp::types::AttestationType; using opp::types::ChainKind; using opp::types::TokenKind; using u128 = opp::shadow::u128; @@ -37,52 +34,16 @@ constexpr name ram_payer = "sysio"_n; constexpr size_t MAX_MEMO_BYTES = 256; -constexpr std::string_view YIELD_MEMO = "sysio.liq yield"; -constexpr std::string_view CLAIM_MEMO = "sysio.liq claim"; +constexpr std::string_view YIELD_MEMO = "sysio.liq yield"; +constexpr std::string_view CLAIM_MEMO = "sysio.liq claim"; +/// Memo of the transfer that brings the WIRE `creditowed` credits in from sysio.synd. +constexpr std::string_view CREDIT_MEMO = "sysio.liq creditowed"; /// The `sysio.payer` seat: an inline action to an unprivileged contract that bills a /// row to `actor` must carry it beside `actor`'s active permission. permission_level payer_of(name actor) { return permission_level{ actor, "sysio.payer"_n }; } permission_level active_of(name actor) { return permission_level{ actor, "active"_n }; } -uint32_t current_epoch_index() { - sysio::epoch::epochstate_t es(liq::EPOCH_ACCOUNT); - if (!es.exists()) return 0; - return es.get().current_epoch_index; -} - -bool is_bootstrap_window() { - return current_epoch_index() == 0; -} - -void require_priv_caller() { - require_auth(current_receiver()); - check(is_privileged(current_receiver()), "sysio.liq: privileged account required"); -} - -liq::parked_key parked_key_of(symbol_code sym, ChainKind kind, const std::vector& pubkey) { - return liq::parked_key{ sym.raw(), static_cast(magic_enum::enum_integer(kind)), pubkey }; -} - -/// The account `pubkey` has linked on `kind`, or an empty name. -name linked_account(ChainKind kind, const std::vector& pubkey) { - const auto pk = public_key_from_op_address(kind, pubkey); - if (!pk) return name{}; // no link could hold these bytes - sysio::authex::links_t links(liq::AUTHEX_ACCOUNT); - auto by_pubkey = links.get_index<"bypubkey"_n>(); - auto it = by_pubkey.find(pubkey_to_checksum256(*pk)); - return it == by_pubkey.end() ? name{} : it->username; -} - -/// The pubkey `account` has linked on `kind`, or nullopt. -std::optional> linked_pubkey(name account, ChainKind kind) { - sysio::authex::links_t links(liq::AUTHEX_ACCOUNT); - auto by_namechain = links.get_index<"bynamechain"_n>(); - auto it = by_namechain.find(to_namechain_key(account, kind)); - if (it == by_namechain.end()) return std::nullopt; - return pubkey_to_bytes(it->pub_key); -} - void drop(const char* path, const char* reason) { sysio::print("sysio.liq::", path, ": DROP -- ", reason, "\n"); } @@ -112,7 +73,7 @@ void liq::create(symbol sym, sysio::slug_name chain_code, sysio::slug_name token check(binding.has_value() && binding->active, "token_code is not bound to chain_code"); stats statstable(get_self()); - check(!stat_by_token(token_code).has_value(), "token_code already has a shadow symbol"); + check(!find_stat_by_token(get_self(), token_code).has_value(), "token_code already has a shadow symbol"); statstable.emplace(ram_payer, symbol_key{ sym.code().raw() }, currency_stats{ .supply = asset{ 0, sym }, .chain_code = chain_code, @@ -135,64 +96,58 @@ void liq::recredit(name holder, asset quantity) { check(is_account(holder), "holder account does not exist"); check(quantity.is_valid() && quantity.amount > 0, "quantity must be positive"); const currency_stats st = stat_of(quantity.symbol.code()); - check(quantity.symbol == st.supply.symbol, "symbol precision mismatch"); - check(mint(quantity.symbol.code(), static_cast(quantity.amount)), "supply exceeds the asset range"); + check(quantity.symbol == st.supply.symbol, symbol_precision_mismatch_msg); + check(grow_supply(quantity.symbol.code(), static_cast(quantity.amount)), "supply exceeds the asset range"); adjust_account(holder, quantity, ram_payer); } // --------------------------------------------------------------------------- -// Inbound OPP effects +// Supply // --------------------------------------------------------------------------- +void liq::mint(name to, sysio::slug_name token_code, uint64_t amount) { + require_auth(SYND_ACCOUNT); + check(is_account(to), "to account does not exist"); + check(amount > 0, "amount must be positive"); + const currency_stats st = stat_by_token(token_code); + const symbol_code sym = st.supply.symbol.code(); + check(grow_supply(sym, amount), "supply exceeds the asset range"); + adjust_account(to, asset{ static_cast(amount), st.supply.symbol }, ram_payer); +} + +void liq::burn(sysio::slug_name token_code, uint64_t amount) { + require_auth(SYND_ACCOUNT); + check(amount > 0 && amount <= static_cast(asset::max_amount), "amount out of range"); + const currency_stats st = stat_by_token(token_code); + const asset quantity{ static_cast(amount), st.supply.symbol }; + // Settle, then burn: the row keeps every subunit of yield accrued to now. + adjust_account(SYND_ACCOUNT, -quantity, ram_payer); + stats statstable(get_self()); + statstable.modify(ram_payer, symbol_key{ st.supply.symbol.code().raw() }, [&](currency_stats& s) { + s.supply -= quantity; + }); +} + std::optional liq::resolve_inbound(const char* path, sysio::slug_name chain_code, sysio::slug_name token_code, uint64_t amount) { - const auto st = stat_by_token(token_code); + const auto st = find_stat_by_token(get_self(), token_code); if (!st) { drop(path, "token_code has no shadow symbol"); return std::nullopt; } if (st->chain_code != chain_code) { drop(path, "token_code belongs to another chain"); return std::nullopt; } if (amount == 0 || amount > static_cast(opp::safe::depot_amount_max)) { drop(path, "amount out of range"); return std::nullopt; } - if (amount > headroom(*st)) { + if (amount > headroom_of(get_self(), *st)) { drop(path, "supply exceeds the asset range"); return std::nullopt; } return st; } -void liq::mintsynd(sysio::slug_name chain_code, uint64_t sequence, name account, - sysio::slug_name token_code, uint64_t amount) { - require_auth(MSGCH_ACCOUNT); - if (!is_account(account)) { drop("mintsynd", "account does not exist"); return; } - const auto st = resolve_inbound("mintsynd", chain_code, token_code, amount); - if (!st) return; - // Every check is behind us: the sequence is consumed only by a credit that lands. - if (!admit_sequence(chain_code, sequence, 0)) { drop("mintsynd", "replayed sequence"); return; } - const symbol_code sym = st->supply.symbol.code(); - check(mint(sym, amount), "supply exceeds the asset range"); // resolve_inbound bounded it - adjust_account(account, asset{ static_cast(amount), st->supply.symbol }, ram_payer); -} - -void liq::park(sysio::slug_name chain_code, uint64_t sequence, ChainKind chain_kind, std::vector pubkey, - sysio::slug_name token_code, uint64_t amount) { - require_auth(MSGCH_ACCOUNT); - if (!pubkey_fits(chain_kind, pubkey)) { drop("park", "pubkey does not fit the chain family"); return; } - const auto st = resolve_inbound("park", chain_code, token_code, amount); - if (!st) return; - if (kind_of_chain(st->chain_code) != chain_kind) { drop("park", "chain_kind is not the token's chain"); return; } - if (!admit_sequence(chain_code, sequence, 0)) { drop("park", "replayed sequence"); return; } - const symbol_code sym = st->supply.symbol.code(); - check(mint(sym, amount), "supply exceeds the asset range"); - adjust_parked(parked_key_of(sym, chain_kind, pubkey), chain_kind, pubkey, - asset{ static_cast(amount), st->supply.symbol }); -} - -void liq::mintyield(sysio::slug_name chain_code, uint64_t sequence, uint64_t epoch, - sysio::slug_name token_code, uint64_t amount) { - require_auth(MSGCH_ACCOUNT); +void liq::mintyield(sysio::slug_name chain_code, sysio::slug_name token_code, uint64_t amount) { + require_auth(SYND_ACCOUNT); const auto st = resolve_inbound("mintyield", chain_code, token_code, amount); if (!st) return; - if (!admit_sequence(chain_code, sequence, epoch)) { drop("mintyield", "replayed sequence"); return; } const symbol_key key{ st->supply.symbol.code().raw() }; liqpendings pendings(get_self()); const pending_yield fresh{ asset{ static_cast(amount), st->supply.symbol } }; @@ -203,11 +158,44 @@ void liq::mintyield(sysio::slug_name chain_code, uint64_t sequence, uint64_t epo }); } +void liq::creditowed(name holder, symbol_code sym, uint64_t wire) { + require_auth(SYND_ACCOUNT); + check(is_account(holder), "holder account does not exist"); + check(wire > 0 && wire <= static_cast(asset::max_amount), "amount out of range"); + const currency_stats st = stat_of(sym); + const symbol_key key{ sym.raw() }; + const u128 index = current_index(sym); + + // Settle first, so the row keeps what its balance earned to now, then bank the credit on top. + accounts holdings(get_self(), holder.value); + const auto existing = holdings.try_get(key); + opp::shadow::account row = existing.value_or(opp::shadow::account{ asset{ 0, st.supply.symbol }, index, 0 }); + settle_and_adjust(row, index, asset{ 0, st.supply.symbol }); + check(wire <= static_cast(asset::max_amount) - row.owed_wire, "owed yield exceeds the asset range"); + row.owed_wire += wire; + if (existing) { + holdings.modify(ram_payer, key, [&](opp::shadow::account& a) { a = row; }); + } else { + holdings.emplace(ram_payer, key, row); + } + + // The pot backs every claim; the WIRE that backs this credit arrives by the transfer queued below, + // ahead of any claim a later action sends. + yieldidxs indexes(get_self()); + opp::shadow::yield_index idx = indexes.try_get(key).value_or(opp::shadow::yield_index{}); + idx.pot = opp::safe::add_sat_u64(idx.pot, wire); + indexes.upsert(ram_payer, key, idx); + action(active_of(SYND_ACCOUNT), TOKEN_ACCOUNT, "transfer"_n, + std::make_tuple(SYND_ACCOUNT, get_self(), asset{ static_cast(wire), WIRE_SYM }, + std::string{ CREDIT_MEMO })).send(); +} + // --------------------------------------------------------------------------- // Cranks // --------------------------------------------------------------------------- void liq::queueyield(symbol_code sym) { + // Queueing hands shadow to sysio.swap, which is no custody contract: refused while the cord is pulled. andon::check_clear(andon::ANDON_ACCOUNT); liqpendings pendings(get_self()); const symbol_key key{ sym.raw() }; @@ -223,7 +211,7 @@ void liq::queueyield(symbol_code sym) { // Minted to this contract and handed on in the same transaction, so its row is // settled at one index and accrues nothing on the way through. - check(mint(sym, static_cast(quantity.amount)), "supply exceeds the asset range"); + check(grow_supply(sym, static_cast(quantity.amount)), "supply exceeds the asset range"); adjust_account(get_self(), quantity, ram_payer); action(std::vector{ payer_of(get_self()), active_of(get_self()) }, @@ -233,12 +221,6 @@ void liq::queueyield(symbol_code sym) { std::make_tuple(get_self(), SWAP_ACCOUNT, quantity, std::string{})).send(); } -void liq::sweep(name account, ChainKind chain_kind) { - const auto pubkey = linked_pubkey(account, chain_kind); - check(pubkey.has_value(), "account has no link for this chain"); - deliver_parked(account, chain_kind, *pubkey); -} - // --------------------------------------------------------------------------- // The token // --------------------------------------------------------------------------- @@ -287,7 +269,7 @@ void liq::open(name owner, symbol symbol, name ram_payer_) { require_auth(ram_payer_); check(is_account(owner), "owner account does not exist"); const currency_stats st = stat_of(symbol.code()); - check(st.supply.symbol == symbol, "symbol precision mismatch"); + check(st.supply.symbol == symbol, symbol_precision_mismatch_msg); accounts holdings(get_self(), owner.value); if (!holdings.contains(symbol_key{ symbol.code().raw() })) { adjust_account(owner, asset{ 0, symbol }, ram_payer_); @@ -371,49 +353,6 @@ void liq::addkicker(symbol_code sym, int64_t base_balance, uint64_t requested) { distribute(sym, std::min(static_cast(received), requested)); } -void liq::linkswept(name account, ChainKind chain_kind, std::vector pubkey) { - require_auth(AUTHEX_ACCOUNT); - if (!is_account(account) || !pubkey_fits(chain_kind, pubkey)) return; - deliver_parked(account, chain_kind, pubkey); -} - -void liq::desyndicate(name holder, asset quantity) { - require_auth(holder); - check(quantity.is_valid() && quantity.amount > 0, "quantity must be positive"); - const currency_stats st = stat_of(quantity.symbol.code()); - check(quantity.symbol == st.supply.symbol, "symbol precision mismatch"); - - const ChainKind kind = kind_of_chain(st.chain_code); - const auto pubkey = linked_pubkey(holder, kind); - check(pubkey.has_value(), "holder is not AuthX-linked for the token's chain"); - - // Settle, then burn: the row keeps every subunit of yield accrued to now. - adjust_account(holder, -quantity, ram_payer); - stats statstable(get_self()); - statstable.modify(ram_payer, symbol_key{ quantity.symbol.code().raw() }, [&](currency_stats& s) { - s.supply -= quantity; - }); - - liqcounters_t counters(get_self()); - liq_counters c = counters.get_or_default(liq_counters{}); - const uint64_t request_id = c.next_request_id++; - counters.set(c, ram_payer); - - opp::attestations::DesyndicateLIQ msg; - msg.chain_code = st.chain_code.value; - msg.user = opp::types::ChainAddress{ kind, *pubkey }; - msg.amount = opp::types::TokenAmount{ st.token_code.value, quantity.amount }; - msg.request_id = request_id; - // `no_size{}`: raw protobuf bytes, the form the outpost decodes the attestation - // `data` field as (the same encoding sysio.opreg's emitters use). - std::vector encoded; - auto out = zpp::bits::out{ encoded, zpp::bits::no_size{} }; - (void)out(msg); - - action(active_of(get_self()), MSGCH_ACCOUNT, "queueout"_n, - std::make_tuple(st.chain_code.value, AttestationType::ATTESTATION_TYPE_DESYNDICATE_LIQ, encoded)).send(); -} - // --------------------------------------------------------------------------- // Launch ingestion // --------------------------------------------------------------------------- @@ -422,9 +361,9 @@ void liq::regliqpool(sysio::slug_name chain_code, sysio::slug_name token_code, s uint64_t initial_chain_amount, uint64_t initial_wire_amount, int32_t fee, int64_t locked_shares, uint32_t conversion_horizon_sec, uint32_t depth_cap_bps, int64_t clip_floor) { - require_priv_caller(); - check(is_bootstrap_window(), "regliqpool is bootstrap-window only"); - const auto st = stat_by_token(token_code); + opp::require_privileged_self(); + check(epoch::in_bootstrap_window(), "regliqpool is bootstrap-window only"); + const auto st = find_stat_by_token(get_self(), token_code); check(st.has_value(), "token_code has no shadow symbol"); check(st->chain_code == chain_code, "token_code belongs to another chain"); check(st->pair_symbol == symbol_code{}, "the shadow already has a yield pool"); @@ -441,7 +380,7 @@ void liq::regliqpool(sysio::slug_name chain_code, sysio::slug_name token_code, s // The LCO liq is protocol-owned and already in outpost custody: its shadow is // minted to sysio, which seeds the pool with it and holds the pool's shares. - check(mint(sym.code(), initial_chain_amount), "supply exceeds the asset range"); + check(grow_supply(sym.code(), initial_chain_amount), "supply exceeds the asset range"); adjust_account(SYSTEM_ACCOUNT, shadow, ram_payer); stats statstable(get_self()); statstable.modify(ram_payer, symbol_key{ sym.code().raw() }, [&](currency_stats& s) { @@ -469,34 +408,6 @@ void liq::regliqpool(sysio::slug_name chain_code, sysio::slug_name token_code, s std::make_tuple(pair_symbol.code(), conversion_horizon_sec, depth_cap_bps, clip_floor)).send(); } -void liq::importsynd(sysio::slug_name chain_code, sysio::slug_name token_code, std::vector credits) { - require_priv_caller(); - check(is_bootstrap_window(), "importsynd is bootstrap-window only"); - liqconfig_t config(get_self()); - check(!config.get_or_default(liq_config{}).import_complete, "import already finalized"); - const auto st = stat_by_token(token_code); - check(st.has_value(), "token_code has no shadow symbol"); - check(st->chain_code == chain_code, "token_code belongs to another chain"); - const ChainKind kind = kind_of_chain(chain_code); - const symbol_code sym = st->supply.symbol.code(); - - for (const auto& credit : credits) { - check(pubkey_fits(kind, credit.pubkey), "pubkey does not fit the chain family"); - if (credit.amount == 0) continue; - check(mint(sym, credit.amount), "supply exceeds the asset range"); - credit_by_pubkey(sym, kind, credit.pubkey, credit.amount); - } -} - -void liq::importdone() { - require_priv_caller(); - liqconfig_t config(get_self()); - liq_config cfg = config.get_or_default(liq_config{}); - check(!cfg.import_complete, "import already finalized"); - cfg.import_complete = true; - config.set(cfg, ram_payer); -} - // --------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------- @@ -506,19 +417,16 @@ liq::currency_stats liq::stat_of(symbol_code sym) const { return statstable.get(symbol_key{ sym.raw() }, "shadow symbol does not exist"); } -std::optional liq::stat_by_token(sysio::slug_name token_code) const { - stats statstable(get_self()); - auto by_token = statstable.get_index<"bytoken"_n>(); - auto it = by_token.find(token_code.value); - if (it == by_token.end()) return std::nullopt; - return *it; +liq::currency_stats liq::stat_by_token(sysio::slug_name token_code) const { + const auto st = find_stat_by_token(get_self(), token_code); + check(st.has_value(), "token_code has no shadow symbol"); + return *st; } ChainKind liq::kind_of_chain(sysio::slug_name chain_code) const { - sysio::chains::chains_t chains(CHAINS_ACCOUNT); - const auto row = chains.try_get(sysio::chains::chain_key{ chain_code }); - check(row.has_value() && row->active && !row->is_depot, "chain_code is not an active outpost"); - return row->kind; + const auto kind = sysio::chains::outpost_kind_of(CHAINS_ACCOUNT, chain_code); + check(kind.has_value(), "chain_code is not an active outpost"); + return *kind; } u128 liq::current_index(symbol_code sym) const { @@ -537,7 +445,7 @@ void liq::settle_and_adjust(opp::shadow::account& row, u128 index, const asset& row.owed_wire = opp::shadow::owed(row, index); // settle before mutate row.index_checkpoint = index; row.balance += delta; - check(row.balance.amount >= 0, "overdrawn balance"); + check(row.balance.amount >= 0, overdrawn_balance_msg); } void liq::adjust_account(name owner, const asset& delta, name payer) { @@ -555,34 +463,11 @@ void liq::adjust_account(name owner, const asset& delta, name payer) { holdings.modify(payer, key, [&](opp::shadow::account& a) { a = updated; }); } -void liq::adjust_parked(const parked_key& key, ChainKind chain_kind, const std::vector& pubkey, - const asset& delta) { - parkeds parked(get_self()); - const u128 index = current_index(delta.symbol.code()); - const auto row = parked.try_get(key); - if (!row) { - check(delta.amount >= 0, "no parked row found"); - parked.emplace(ram_payer, key, parked_row{ chain_kind, pubkey, opp::shadow::account{ delta, index, 0 } }); - return; - } - parked_row updated = *row; - settle_and_adjust(updated.holding, index, delta); - parked.modify(ram_payer, key, [&](parked_row& p) { p = updated; }); -} - -uint64_t liq::headroom(const currency_stats& st) const { - liqpendings pendings(get_self()); - const auto pending = pendings.try_get(symbol_key{ st.supply.symbol.code().raw() }); - const int64_t reserved = pending ? pending->quantity.amount : 0; - const int64_t room = asset::max_amount - st.supply.amount - reserved; - return room > 0 ? static_cast(room) : 0; -} - -bool liq::mint(symbol_code sym, uint64_t quantity) { +bool liq::grow_supply(symbol_code sym, uint64_t quantity) { stats statstable(get_self()); const symbol_key key{ sym.raw() }; const currency_stats st = statstable.get(key, "shadow symbol does not exist"); - if (quantity > headroom(st)) return false; + if (quantity > headroom_of(get_self(), st)) return false; statstable.modify(ram_payer, key, [&](currency_stats& s) { s.supply.amount += static_cast(quantity); }); return true; } @@ -600,49 +485,4 @@ void liq::distribute(symbol_code sym, uint64_t quantity) { indexes.upsert(ram_payer, key, idx); } -bool liq::admit_sequence(sysio::slug_name chain_code, uint64_t sequence, uint64_t epoch) { - liqcursors cursors(get_self()); - const cursor_key key{ chain_code.value }; - const auto cursor = cursors.try_get(key); - if (cursor && sequence <= cursor->last_sequence) return false; - cursors.upsert(ram_payer, key, liq_cursor{ chain_code, sequence, epoch }, [&](liq_cursor& c) { - c.last_sequence = sequence; - if (epoch != 0) c.last_epoch = epoch; - }); - return true; -} - -void liq::deliver_parked(name account, ChainKind chain_kind, const std::vector& pubkey) { - stats statstable(get_self()); - parkeds parked(get_self()); - for (auto it = statstable.begin(); it != statstable.end(); ++it) { - const symbol_code sym = it->supply.symbol.code(); - const parked_key key = parked_key_of(sym, chain_kind, pubkey); - const auto row = parked.try_get(key); - if (!row) continue; - // Settle the parked row at the current index, then move both its balance - // and its banked WIRE into the account's row, itself settled at that index. - const u128 index = current_index(sym); - const uint64_t banked = opp::shadow::owed(row->holding, index); - const asset balance = row->holding.balance; - parked.erase(key); - adjust_account(account, balance, ram_payer); - if (banked == 0) continue; - accounts holdings(get_self(), account.value); - holdings.modify(ram_payer, symbol_key{ sym.raw() }, [&](opp::shadow::account& a) { - a.owed_wire = opp::safe::add_sat_u64(a.owed_wire, banked); - }); - } -} - -void liq::credit_by_pubkey(symbol_code sym, ChainKind chain_kind, const std::vector& pubkey, uint64_t amount) { - const asset quantity{ static_cast(amount), stat_of(sym).supply.symbol }; - const name account = linked_account(chain_kind, pubkey); - if (account != name{}) { - adjust_account(account, quantity, ram_payer); - } else { - adjust_parked(parked_key_of(sym, chain_kind, pubkey), chain_kind, pubkey, quantity); - } -} - } // namespace sysio diff --git a/contracts/sysio.liq/sysio.liq.abi b/contracts/sysio.liq/sysio.liq.abi index 0b0564aa37..2edc2235ed 100644 --- a/contracts/sysio.liq/sysio.liq.abi +++ b/contracts/sysio.liq/sysio.liq.abi @@ -57,6 +57,20 @@ } ] }, + { + "name": "burn", + "base": "", + "fields": [ + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "amount", + "type": "uint64" + } + ] + }, { "name": "claim", "base": "", @@ -104,74 +118,31 @@ ] }, { - "name": "currency_stats", + "name": "creditowed", "base": "", "fields": [ { - "name": "supply", - "type": "asset" - }, - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" + "name": "holder", + "type": "name" }, { - "name": "pair_symbol", + "name": "sym", "type": "symbol_code" - } - ] - }, - { - "name": "cursor_key", - "base": "", - "fields": [ + }, { - "name": "chain_code", + "name": "wire", "type": "uint64" } ] }, { - "name": "desyndicate", + "name": "currency_stats", "base": "", "fields": [ { - "name": "holder", - "type": "name" - }, - { - "name": "quantity", + "name": "supply", "type": "asset" - } - ] - }, - { - "name": "import_credit", - "base": "", - "fields": [ - { - "name": "pubkey", - "type": "bytes" }, - { - "name": "amount", - "type": "uint64" - } - ] - }, - { - "name": "importdone", - "base": "", - "fields": [] - }, - { - "name": "importsynd", - "base": "", - "fields": [ { "name": "chain_code", "type": "slug_name" @@ -181,26 +152,8 @@ "type": "slug_name" }, { - "name": "credits", - "type": "import_credit[]" - } - ] - }, - { - "name": "linkswept", - "base": "", - "fields": [ - { - "name": "account", - "type": "name" - }, - { - "name": "chain_kind", - "type": "ChainKind" - }, - { - "name": "pubkey", - "type": "bytes" + "name": "pair_symbol", + "type": "symbol_code" } ] }, @@ -211,55 +164,15 @@ { "name": "kicker_bps", "type": "uint32" - }, - { - "name": "import_complete", - "type": "bool" - } - ] - }, - { - "name": "liq_counters", - "base": "", - "fields": [ - { - "name": "next_request_id", - "type": "uint64" } ] }, { - "name": "liq_cursor", + "name": "mint", "base": "", "fields": [ { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "last_sequence", - "type": "uint64" - }, - { - "name": "last_epoch", - "type": "uint64" - } - ] - }, - { - "name": "mintsynd", - "base": "", - "fields": [ - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "sequence", - "type": "uint64" - }, - { - "name": "account", + "name": "to", "type": "name" }, { @@ -280,14 +193,6 @@ "name": "chain_code", "type": "slug_name" }, - { - "name": "sequence", - "type": "uint64" - }, - { - "name": "epoch", - "type": "uint64" - }, { "name": "token_code", "type": "slug_name" @@ -316,72 +221,6 @@ } ] }, - { - "name": "park", - "base": "", - "fields": [ - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "sequence", - "type": "uint64" - }, - { - "name": "chain_kind", - "type": "ChainKind" - }, - { - "name": "pubkey", - "type": "bytes" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "amount", - "type": "uint64" - } - ] - }, - { - "name": "parked_key", - "base": "", - "fields": [ - { - "name": "symbol_code", - "type": "uint64" - }, - { - "name": "chain_kind", - "type": "uint64" - }, - { - "name": "pubkey", - "type": "bytes" - } - ] - }, - { - "name": "parked_row", - "base": "", - "fields": [ - { - "name": "chain_kind", - "type": "ChainKind" - }, - { - "name": "pubkey", - "type": "bytes" - }, - { - "name": "holding", - "type": "account" - } - ] - }, { "name": "pending_yield", "base": "", @@ -490,20 +329,6 @@ } ] }, - { - "name": "sweep", - "base": "", - "fields": [ - { - "name": "account", - "type": "name" - }, - { - "name": "chain_kind", - "type": "ChainKind" - } - ] - }, { "name": "symbol_key", "base": "", @@ -566,6 +391,11 @@ "type": "addyield", "ricardian_contract": "" }, + { + "name": "burn", + "type": "burn", + "ricardian_contract": "" + }, { "name": "claim", "type": "claim", @@ -582,28 +412,13 @@ "ricardian_contract": "" }, { - "name": "desyndicate", - "type": "desyndicate", - "ricardian_contract": "" - }, - { - "name": "importdone", - "type": "importdone", - "ricardian_contract": "" - }, - { - "name": "importsynd", - "type": "importsynd", + "name": "creditowed", + "type": "creditowed", "ricardian_contract": "" }, { - "name": "linkswept", - "type": "linkswept", - "ricardian_contract": "" - }, - { - "name": "mintsynd", - "type": "mintsynd", + "name": "mint", + "type": "mint", "ricardian_contract": "" }, { @@ -616,11 +431,6 @@ "type": "open", "ricardian_contract": "" }, - { - "name": "park", - "type": "park", - "ricardian_contract": "" - }, { "name": "queueyield", "type": "queueyield", @@ -646,11 +456,6 @@ "type": "settle", "ricardian_contract": "" }, - { - "name": "sweep", - "type": "sweep", - "ricardian_contract": "" - }, { "name": "transfer", "type": "transfer", @@ -674,22 +479,6 @@ "key_types": ["name"], "table_id": 57729 }, - { - "name": "liqcounters", - "type": "liq_counters", - "index_type": "i64", - "key_names": ["name"], - "key_types": ["name"], - "table_id": 41070 - }, - { - "name": "liqcursors", - "type": "liq_cursor", - "index_type": "i64", - "key_names": ["chain_code"], - "key_types": ["uint64"], - "table_id": 19002 - }, { "name": "liqpending", "type": "pending_yield", @@ -698,14 +487,6 @@ "key_types": ["uint64"], "table_id": 38824 }, - { - "name": "parked", - "type": "parked_row", - "index_type": "i64", - "key_names": ["symbol_code","chain_kind","pubkey"], - "key_types": ["uint64","uint64","bytes"], - "table_id": 38854 - }, { "name": "stat", "type": "currency_stats", @@ -732,29 +513,5 @@ ], "ricardian_clauses": [], "variants": [], - "action_results": [], - "enums": [ - { - "name": "ChainKind", - "type": "int32", - "values": [ - { - "name": "CHAIN_KIND_UNKNOWN", - "value": 0 - }, - { - "name": "CHAIN_KIND_WIRE", - "value": 1 - }, - { - "name": "CHAIN_KIND_EVM", - "value": 2 - }, - { - "name": "CHAIN_KIND_SVM", - "value": 3 - } - ] - } - ] + "action_results": [] } \ No newline at end of file diff --git a/contracts/sysio.liq/sysio.liq.wasm b/contracts/sysio.liq/sysio.liq.wasm index 13332a5d176a67cbbbee289866bbcc6faf32325d..57c00f23ac6fe3fc13e351990f02b588c1135043 100755 GIT binary patch delta 16643 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 literal 64239 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 D;*?9a diff --git a/contracts/sysio.msgch/README.md b/contracts/sysio.msgch/README.md index 368ea8e699..2c93121160 100644 --- a/contracts/sysio.msgch/README.md +++ b/contracts/sysio.msgch/README.md @@ -4,41 +4,65 @@ Inbound/outbound OPP message chain management and consensus tracking contract. ## Responsibility -- Manages inbound chain requests from external outposts -- Collects deliveries from all 7 elected batch operators per epoch -- Evaluates consensus (Option A: unanimous 7/7, Option B: 4+ majority at epoch boundary) -- Stores and routes individual messages by attestation type -- Queues outbound messages and builds OPP envelopes for delivery to outposts +- Accepts inbound OPP envelopes delivered by the batch operators of the active group, one delivery per + operator per outpost per epoch (`MAX_ENVELOPE_BYTES` = 32,768; larger deliveries are rejected) +- Evaluates consensus per (outpost, epoch): every eligible operator delivers an identical envelope + (fast path), or a strict majority of the group agrees once the epoch boundary has passed. A + post-boundary split with no strict majority opens a `sysio.chalg` dispute +- Validates the per-outpost envelope chain and message sequence, stores the accepted messages and + attestations, and routes each attestation to its depot handler +- Queues outbound attestations and packs them into one OPP envelope per outpost per epoch; whatever + does not fit under the envelope cap stays queued for the next epoch +- Drives the epoch: `chkcons` advances `sysio.epoch` once every active outpost has reached + consensus and the wall-clock boundary has passed ## Tables | Table | Type | Description | |-------|------|-------------| -| `inchainreq` | Multi-index | Inbound chain requests per outpost/epoch | -| `deliveries` | Multi-index | Per-operator delivery records with chain hashes | -| `messages` | Multi-index | Individual OPP messages (inbound + outbound) | -| `outenvelopes` | Multi-index | Built outbound envelopes pending delivery | +| `envelopes` | `kv::table` | Inbound deliveries, one row per batch operator per outpost per epoch (working state, pruned to the current and previous epoch) | +| `messages` | `kv::table` | Individual OPP messages extracted from consensus-verified envelopes | +| `attestations` | `kv::table` | Attestations extracted inbound, and outbound attestations queued (`READY`) for envelope packing | +| `outenvelopes` | `kv::table` | Built outbound envelopes awaiting relay, with their canonical epoch digest | +| `outpcons` | `kv::table` | Per-outpost consensus record: accepted envelope digest and inbound message-chain tip | +| `attseq` | `kv::table` | Attestation sequence counter | +| `envlog` | `kv::table` | Metadata-only envelope audit log, capped at `active_outposts * 2 * epoch_retention_envelope_log_count` rows | ## Actions | Action | Auth | Description | |--------|------|-------------| -| `crank` | permissionless | Main depot crank, advances processing | -| `createreq` | `sysio.msgch` | Create inbound chain request for an outpost | -| `deliver` | operator | Batch operator delivers a chain with hash + messages | -| `evalcons` | `sysio.msgch` | Evaluate consensus on a chain request | -| `processmsg` | `sysio.msgch` | Process a READY message (unpack, route attestations) | -| `queueout` | `sysio.epoch`, `sysio.opreg`, `sysio.uwrit`, `sysio.reserv`, `sysio.liq`, or `sysio.msgch` | Queue an outbound message to an outpost | -| `buildenv` | `sysio.msgch` | Build outbound envelope from queued messages | +| `bootstrap` | `sysio.msgch` | Epoch-0 only: asserts the `sysio.system` emissions config is set, then triggers the first `sysio.epoch::advance` | +| `deliver` | batch operator (resident active group, `ACTIVE` in `sysio.opreg`) | Deliver an inbound envelope for an outpost; calls `evalcons` inline | +| `evalcons` | `sysio.msgch` | Evaluate consensus for an (outpost, epoch); on consensus store and dispatch the attestations; on an eligible split open a `sysio.chalg` dispute | +| `chkcons` | permissionless | If no dispute is open, every active outpost has consensus and the boundary has passed, call `sysio.epoch::advance` | +| `resolvedisp` | `sysio.chalg` | Dispatch the winning envelope of a resolved dispute | +| `queueout` | `sysio.epoch`, `sysio.opreg`, `sysio.synd`, or `sysio.msgch` | Queue an outbound attestation for an outpost | +| `buildenv` | `sysio.epoch` | Pack `READY` attestations into the outpost's outbound envelope for the epoch | + +## Attestation routing + +Inbound (outpost → depot): + +- `SYNDICATE_LIQ` → `sysio.synd::onsynd`, `LIQ_YIELD` → `sysio.synd::onyield`. Each carries the + envelope's epoch and canonical digest, and is forwarded only once the payload's token is an active + `TOKEN_KIND_LIQ` row on `sysio.tokens` bound to the proven outpost and, for a syndication, the + user's key family is the outpost's own. After the envelope's last attestation it sends + `sysio.synd::closeenv`, only when at least one of them reached `sysio.synd`. +- `NODE_OWNER_REG` → `sysio.roa` (account creation and node-owner registration, recording the + outpost link). +- `STAKING_REWARD` → `sysio.dclaim::onreward`, after binding the reward's chain to the proven + delivering outpost. +- Every other type is dropped. Every refusal is a logged drop, never an abort. + +Outbound (depot → outpost), queued through `queueout`: + +- `OPERATORS` and `BATCH_OPERATOR_GROUPS` from `sysio.epoch::advance`, to every active outpost +- `DESYNDICATE_LIQ` from `sysio.synd::desyndicate` ## Dependencies -- Reads epoch state from `sysio.epoch` -- Notifies `sysio.chalg` on consensus failure -- Routes attestations to `sysio.epoch`, `sysio.uwrit`, `sysio.chalg` -- Routes `SYNDICATE_LIQ` and `LIQ_YIELD` to `sysio.liq` (`mintsynd` for an AuthX-linked - user, `park` for an unlinked pubkey, `mintyield` for a yield report) once the payload's - token is an active `TOKEN_KIND_LIQ` row on `sysio.tokens` bound to the proven outpost - and, for a syndication, the user's key family is the outpost's own. - Every refusal is a logged drop, never an abort. `DESYNDICATE_LIQ` is outbound only: - `sysio.liq::desyndicate` queues it through `queueout`. +- Reads epoch state from `sysio.epoch` and operator status from `sysio.opreg` +- Opens disputes on `sysio.chalg` (`opendispute`); `sysio.chalg::chkdispute` calls back into `resolvedisp` +- Triggers `sysio.epoch::advance` from `chkcons` (and from `bootstrap` at epoch 0) +- Routes inbound attestations to `sysio.synd`, `sysio.roa` and `sysio.dclaim` diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index fc570f9724..4123b65eb6 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -42,7 +42,19 @@ constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; constexpr auto TOKENS_ACCOUNT = "sysio.tokens"_n; constexpr auto RESERV_ACCOUNT = "sysio.reserv"_n; constexpr auto ROA_ACCOUNT = "sysio.roa"_n; -constexpr auto LIQ_ACCOUNT = "sysio.liq"_n; + +/// The syndication contract and the intake actions an accepted envelope's syndication value is +/// routed to. +namespace synd { +/// The syndication contract: holds every inbound syndication and yield report until it is underwritten. +constexpr auto account = "sysio.synd"_n; +/// One SYNDICATE_LIQ of the envelope, with the envelope's identity (outpost, epoch, digest). +constexpr auto action_onsynd = "onsynd"_n; +/// One LIQ_YIELD of the envelope, with the same identity. +constexpr auto action_onyield = "onyield"_n; +/// Sent once after the envelope's last attestation, only when one of them reached `sysio.synd`. +constexpr auto action_closeenv = "closeenv"_n; +} // namespace synd // System-owned rows bill to the sysio RAM pool, not this contract account (privileged-contract // model, as sysio.token uses): the account stays finite at code+abi size; growth draws from the pool. @@ -121,11 +133,6 @@ constexpr const char* DISPUTE_NO_TIER_ONE_ELECTORATE_LOG = "msgch::maybe_open_dispute: no dispute for (chain=%llu, epoch=%u): " "no registered tier-1 node owners\n"; -uint32_t current_epoch_index() { - epoch::epochstate_t tbl(EPOCH_ACCOUNT); - return tbl.exists() ? tbl.get().current_epoch_index : 0; -} - /// Mint the next attestation id from the `attseq` singleton. /// /// Replaces a `std::max(1, atts.available_primary_key())` call @@ -361,13 +368,11 @@ name resolve_account_from_op_address(const opp::types::ChainAddress& op_address) /// The syndicating user's key family must be the proven outpost's own: an outpost of family F /// verifies and emits F-family keys only, so a key of another family is a forgery whatever it -/// resolves to. `sysio.liq::park` refuses the other family for an unlinked key; this refuses it -/// for every key, before the AuthX lookup could credit a linked one. Print + false, never a -/// throw, for the reason `source_chain_binding_ok` gives. +/// resolves to, before `sysio.synd` holds any of it. Print + false, never a throw, for the reason +/// `source_chain_binding_ok` gives. [[nodiscard]] bool user_kind_matches_chain(uint64_t proven_chain_code, ChainKind user_kind, const char* path) { - sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); - const auto row = chains_tbl.try_get(sysio::chains::chain_key{sysio::slug_name{proven_chain_code}}); - if (row && row->kind == user_kind) return true; + const auto kind = sysio::chains::outpost_kind_of(CHAINS_ACCOUNT, sysio::slug_name{proven_chain_code}); + if (kind && *kind == user_kind) return true; sysio::print("msgch::", path, ": DROP attestation -- user kind ", std::string(magic_enum::enum_name(user_kind)), " is not the proven source outpost's chain family\n"); return false; @@ -669,61 +674,64 @@ bool is_active_liq_token(sysio::slug_name chain_code, sysio::slug_name token_cod return binding && binding->active; } -/// SYNDICATE_LIQ: a user syndicated liq on the outpost. The pubkey resolves through authex; a -/// linked user is credited by `sysio.liq::mintsynd`, an unlinked one parked by `sysio.liq::park` -/// until the link exists. sysio.liq re-checks the token, the amount and the sequence and drops -/// (never aborts) what it cannot credit. Never-throw: every refusal here is a print + return. -void dispatch_syndicate_liq(name self, const std::vector& data, uint64_t chain_code) { - opp::attestations::SyndicateLIQ synd; +/// SYNDICATE_LIQ: a user syndicated liq on the outpost. After the source-chain binding, the +/// user-key family, the amount range and the active-liq-token checks, the syndication goes to +/// `sysio.synd::onsynd` with the envelope's identity -- `epoch_index` and its canonical +/// `envelope_digest` -- which holds it, linked user or not, until its envelope is underwritten. +/// sysio.synd re-checks the token, the amount and the sequence and drops (never aborts) what it +/// cannot hold. The outpost's live custody total, `total_syndicated`, is forwarded verbatim for +/// sysio.synd to compare. Never-throw: every refusal here is a print + return. Returns true iff +/// `onsynd` was sent. +bool dispatch_syndicate_liq(name self, const std::vector& data, uint64_t chain_code, uint32_t epoch_index, + const checksum256& envelope_digest) { + opp::attestations::SyndicateLIQ syndication; { auto in = zpp::bits::in{std::span{data.data(), data.size()}, zpp::bits::no_size{}}; - if (in(synd) != zpp::bits::errc{}) return; + if (in(syndication) != zpp::bits::errc{}) return false; } - if (!source_chain_binding_ok(chain_code, synd.chain_code, "dispatch_syndicate_liq")) return; - if (!user_kind_matches_chain(chain_code, synd.user.kind, "dispatch_syndicate_liq")) return; + if (!source_chain_binding_ok(chain_code, syndication.chain_code, "dispatch_syndicate_liq")) return false; + if (!user_kind_matches_chain(chain_code, syndication.user.kind, "dispatch_syndicate_liq")) return false; const std::optional amount = - sysio::opp::safe::to_depot_amount(static_cast(synd.amount.amount)); - if (!amount) return; + sysio::opp::safe::to_depot_amount(static_cast(syndication.amount.amount)); + if (!amount) return false; const sysio::slug_name chain_code_slug{chain_code}; - const sysio::slug_name token_code{synd.amount.token_code}; + const sysio::slug_name token_code{syndication.amount.token_code}; if (!is_active_liq_token(chain_code_slug, token_code)) { sysio::print("msgch::dispatch_syndicate_liq: DROP attestation -- token is not an active liq token\n"); - return; - } - const uint64_t sequence = synd.sequence; - const name account = resolve_account_from_op_address(synd.user); - if (account != name{}) { - action(permission_level{self, "active"_n}, LIQ_ACCOUNT, "mintsynd"_n, - std::make_tuple(chain_code_slug, sequence, account, token_code, *amount)).send(); - } else { - action(permission_level{self, "active"_n}, LIQ_ACCOUNT, "park"_n, - std::make_tuple(chain_code_slug, sequence, synd.user.kind, synd.user.address, token_code, *amount)).send(); + return false; } + action(permission_level{self, "active"_n}, synd::account, synd::action_onsynd, + std::make_tuple(chain_code_slug, epoch_index, envelope_digest, syndication.sequence, syndication.user.kind, + syndication.user.address, token_code, *amount, syndication.total_syndicated)).send(); + return true; } -/// LIQ_YIELD: the outpost claimed yield for its syndicated pool since its last report. It lands -/// in sysio.liq's pending balance (`mintyield`); the permissionless `queueyield` hands it to the -/// swap later, so nothing that can throw sits on this path. -void dispatch_liq_yield(name self, const std::vector& data, uint64_t chain_code) { +/// LIQ_YIELD: the outpost claimed yield for its syndicated pool since its last report. After the +/// same checks as a syndication it goes to `sysio.synd::onyield` with the envelope's identity, +/// which holds the amount as a number until the envelope is underwritten; nothing is minted on this +/// path. The outpost's live custody total, `total_syndicated`, is forwarded verbatim. Never-throw. +/// Returns true iff `onyield` was sent. +bool dispatch_liq_yield(name self, const std::vector& data, uint64_t chain_code, uint32_t epoch_index, + const checksum256& envelope_digest) { opp::attestations::LIQYield report; { auto in = zpp::bits::in{std::span{data.data(), data.size()}, zpp::bits::no_size{}}; - if (in(report) != zpp::bits::errc{}) return; + if (in(report) != zpp::bits::errc{}) return false; } - if (!source_chain_binding_ok(chain_code, report.chain_code, "dispatch_liq_yield")) return; + if (!source_chain_binding_ok(chain_code, report.chain_code, "dispatch_liq_yield")) return false; const std::optional amount = sysio::opp::safe::to_depot_amount(static_cast(report.amount.amount)); - if (!amount) return; + if (!amount) return false; const sysio::slug_name chain_code_slug{chain_code}; const sysio::slug_name token_code{report.amount.token_code}; if (!is_active_liq_token(chain_code_slug, token_code)) { sysio::print("msgch::dispatch_liq_yield: DROP attestation -- token is not an active liq token\n"); - return; + return false; } - const uint64_t sequence = report.sequence; - const uint64_t epoch = report.epoch; - action(permission_level{self, "active"_n}, LIQ_ACCOUNT, "mintyield"_n, - std::make_tuple(chain_code_slug, sequence, epoch, token_code, *amount)).send(); + action(permission_level{self, "active"_n}, synd::account, synd::action_onyield, + std::make_tuple(chain_code_slug, epoch_index, envelope_digest, report.sequence, report.epoch, + token_code, *amount, report.total_syndicated)).send(); + return true; } /// Dispatch a RESERVE_CREATE attestation to sysio.reserv::oncrtreserve. @@ -950,11 +958,19 @@ void dispatch_node_owner_reg(const std::vector& data, uint64_t chain_code) /// inbound stream can keep flowing even when the depot hasn't yet wired up /// every handler (e.g. the deferred STAKE / UNSTAKE / STAKE_UPDATE staking /// lifecycle types). -void dispatch_attestation(name self, uint64_t attestation_id, - AttestationType type, - const std::vector& data, - uint64_t chain_code, - const checksum256& original_message_id) { +/// +/// `epoch_index` and `envelope_digest` identify the accepted envelope the attestation came in; the +/// syndication paths forward them to `sysio.synd`. Returns true iff the attestation was routed to +/// `sysio.synd` as syndication value (`onsynd` or `onyield` sent), which obliges the caller to close +/// the envelope there once its last attestation is dispatched. +[[nodiscard]] bool dispatch_attestation(name self, uint64_t attestation_id, + AttestationType type, + const std::vector& data, + uint64_t chain_code, + const checksum256& original_message_id, + uint32_t epoch_index, + const checksum256& envelope_digest) { + bool carried_syndication_value = false; switch (type) { case AttestationType::ATTESTATION_TYPE_OPERATOR_ACTION: dispatch_operator_action(self, data, chain_code, original_message_id); @@ -1035,15 +1051,15 @@ void dispatch_attestation(name self, uint64_t attestation_id, break; case AttestationType::ATTESTATION_TYPE_SYNDICATE_LIQ: - dispatch_syndicate_liq(self, data, chain_code); + carried_syndication_value = dispatch_syndicate_liq(self, data, chain_code, epoch_index, envelope_digest); break; case AttestationType::ATTESTATION_TYPE_LIQ_YIELD: - dispatch_liq_yield(self, data, chain_code); + carried_syndication_value = dispatch_liq_yield(self, data, chain_code, epoch_index, envelope_digest); break; case AttestationType::ATTESTATION_TYPE_DESYNDICATE_LIQ: - // Depot -> outpost outbound-only (sysio.liq::desyndicate queues it). An + // Depot -> outpost outbound-only (sysio.synd::desyndicate queues it). An // outpost echoing one inbound is a benign no-op. break; @@ -1095,14 +1111,14 @@ void dispatch_attestation(name self, uint64_t attestation_id, // task alongside liqEth / liqsol-token wiring. break; - // Outbound-only types (depot emits these, never receives them inbound) - // and deprecated pre-launch types are dropped silently. SLASH was - // formerly its own attestation type; it now rides on OPERATOR_ACTION - // with action_type=SLASH and is gated inside `dispatch_operator_action`. + // Outbound-only types (depot emits these, never receives them inbound) are dropped silently; + // an outpost relaying one back is a benign no-op. case AttestationType::ATTESTATION_TYPE_SWAP_REVERT: case AttestationType::ATTESTATION_TYPE_DEPOSIT_REVERT: case AttestationType::ATTESTATION_TYPE_OPERATORS: case AttestationType::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS: + break; + case AttestationType::ATTESTATION_TYPE_NODE_OWNER_REG: // NFT node-owner claim: create the account + register + record the ETH link. Self-contained // (decodes NodeOwnerRegistration, inline-sends sysio.roa); soft-drops a malformed envelope. @@ -1117,6 +1133,7 @@ void dispatch_attestation(name self, uint64_t attestation_id, default: break; } + return carried_syndication_value; } /// Validate an inbound envelope against the per-outpost chains for `chain_code` at `epoch_index`: @@ -1262,6 +1279,9 @@ void dispatch_attestation(name self, uint64_t attestation_id, // where the payload carries one, and (for NodeOwnerRegistration, which carries no chain code) // against the fixed Ethereum source outpost `NODE_OWNER_SRC_CHAIN` in `dispatch_node_owner_reg`. msgch::attestations_t atts(self); + // Whether any attestation reached sysio.synd as syndication value: only then is the envelope + // closed there, so an envelope without syndication value touches sysio.synd not at all. + bool carried_syndication_value = false; for (auto& msg : envelope.messages) { for (auto& entry : msg.payload.attestations) { uint64_t att_id = mint_att_id(self); @@ -1285,10 +1305,18 @@ void dispatch_attestation(name self, uint64_t attestation_id, for (size_t i = 0; i < n; ++i) rawid[i] = static_cast(mid[i]); m_id = checksum256{rawid}; } - dispatch_attestation(self, att_id, entry.type, entry.data, - chain_code, m_id); + if (dispatch_attestation(self, att_id, entry.type, entry.data, chain_code, m_id, epoch_index, + envelope_digest)) { + carried_syndication_value = true; + } } } + // After the envelope's last attestation: sysio.synd moves what it held for this envelope to + // WAITING. It runs after every onsynd / onyield queued above, as inline actions execute in order. + if (carried_syndication_value) { + action(permission_level{self, "active"_n}, synd::account, synd::action_closeenv, + std::make_tuple(sysio::slug_name{chain_code}, epoch_index, envelope_digest)).send(); + } // Audit log + inline cleanup of working state. { @@ -1425,7 +1453,7 @@ void maybe_open_dispute(name self, uint64_t chain_code, uint32_t epoch_index, // --------------------------------------------------------------------------- void msgch::bootstrap() { require_auth(get_self()); - uint32_t epoch = current_epoch_index(); + uint32_t epoch = epoch::current_epoch_index(); check(epoch == 0, "bootstrap can only be called at epoch 0"); // Missing emissions config is a bootstrap defect, not an operational state: @@ -1493,7 +1521,7 @@ void msgch::deliver(name batch_op_name, uint64_t chain_code, std::vector d // Decode envelope to validate epoch_index matches current WIRE epoch. The decoded envelope is // re-used by the semantic-header/chain validation below (after the duplicate check). - uint32_t epoch = current_epoch_index(); + uint32_t epoch = epoch::current_epoch_index(); opp::Envelope env_check; { auto in = zpp::bits::in{std::span{data.data(), data.size()}, zpp::bits::no_size{}}; @@ -1713,7 +1741,7 @@ void msgch::evalcons(uint64_t chain_code, uint32_t epoch_index) { // chkcons — check all-outpost consensus + time gate, trigger advance // --------------------------------------------------------------------------- void msgch::chkcons() { - uint32_t epoch = current_epoch_index(); + uint32_t epoch = epoch::current_epoch_index(); // Open-dispute gate: if any OPP dispute for the current epoch is still OPEN, hold advancement. // Two overlapping holds protect a disputed epoch: `sysio.epoch::is_paused` (set by opendispute) @@ -1869,10 +1897,10 @@ void msgch::queueout(uint64_t chain_code, // call it directly and inject a forged attestation that buildenv() then packs into the // depot's group-signed outbound envelope — a forged SWAP_REMIT / WITHDRAW_REMIT / SLASH that // the outpost authenticates by the group signature and executes. The intended callers - // (sysio.epoch / .opreg / .uwrit / .reserv / .liq) each send under their own {self, active} + // (sysio.epoch / .opreg / .uwrit / .reserv / .synd) each send under their own {self, active} // authority; get_self() permits msgch's own inline use and governance. check(has_auth(EPOCH_ACCOUNT) || has_auth(OPREG_ACCOUNT) || has_auth(UWRIT_ACCOUNT) || - has_auth(RESERV_ACCOUNT) || has_auth(LIQ_ACCOUNT) || has_auth(get_self()), + has_auth(RESERV_ACCOUNT) || has_auth(synd::account) || has_auth(get_self()), "queueout: caller not authorized to queue outbound attestations"); // The chains registry is the ONLY authority on which chain codes exist. @@ -1903,7 +1931,7 @@ void msgch::queueout(uint64_t chain_code, atts.emplace(ram_payer, id_key{att_id}, attestation_entry{ .id = att_id, .chain_code = chain_code, - .epoch_index = current_epoch_index(), + .epoch_index = epoch::current_epoch_index(), .type = attest_type, .status = AttestationStatus::ATTESTATION_STATUS_READY, .data = data, @@ -1939,7 +1967,7 @@ void msgch::queueout(uint64_t chain_code, void msgch::buildenv(uint64_t chain_code) { require_auth(EPOCH_ACCOUNT); - uint32_t epoch = current_epoch_index(); + uint32_t epoch = epoch::current_epoch_index(); attestations_t atts(get_self()); auto now_sec = static_cast(current_time_point().sec_since_epoch()); // OPP wire timestamps (`MessageHeader.timestamp`, `Envelope.epoch_timestamp`) are milliseconds diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index 22252976358881419238b24874003bb7f1e110e4..2dda93da3da2ea7abdad34335ea3c520320817d7 100755 GIT binary patch delta 40586 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 delta 38855 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 +#include + +#include + +#include +#include +#include +#include +#include + +namespace sysio::opp::envelope_statement { + +/// Schema name every envelope statement is registered under on `sysio.bond`. +inline constexpr name schema = "oppenvelope"_n; + +/// One envelope statement. +struct statement { + uint64_t chain_code = 0; ///< registry code value of the outpost that sent the envelope + uint32_t epoch_index = 0; ///< depot epoch the envelope was accepted for + checksum256 digest; ///< the envelope's canonical epoch digest, as `sysio.msgch` accepted it + uint64_t token_code = 0; ///< registry code value of the liq token the statement covers +}; + +/// Size of a digest's raw bytes. +inline constexpr size_t DIGEST_BYTES = 32; + +/// The serialized form of a `statement`: every field a plain integer or byte array, so the +/// encoding is fixed-width and needs no custom serializer for `checksum256`. +struct wire_statement { + uint64_t chain_code = 0; ///< see `statement::chain_code` + uint32_t epoch_index = 0; ///< see `statement::epoch_index` + std::array digest{}; ///< `statement::digest` as raw bytes + uint64_t token_code = 0; ///< see `statement::token_code` +}; + +/// Encode `s` as the statement bytes `sysio.bond::request` receives: zpp::bits, no size prefix. +inline std::vector pack(const statement& s) { + const wire_statement wire{ + .chain_code = s.chain_code, + .epoch_index = s.epoch_index, + .digest = s.digest.extract_as_byte_array(), + .token_code = s.token_code, + }; + std::vector bytes; + auto out = zpp::bits::out{bytes, zpp::bits::no_size{}}; + (void)out(wire); + return bytes; +} + +/// Decode statement bytes packed by `pack`, or `std::nullopt` when `bytes` is not exactly one encoded +/// statement. Never throws. +inline std::optional unpack(const std::vector& bytes) { + wire_statement wire; + auto in = zpp::bits::in{std::span{bytes.data(), bytes.size()}, zpp::bits::no_size{}}; + if (in(wire) != zpp::bits::errc{}) return std::nullopt; + if (in.position() != bytes.size()) return std::nullopt; + return statement{ + .chain_code = wire.chain_code, + .epoch_index = wire.epoch_index, + .digest = checksum256{wire.digest}, + .token_code = wire.token_code, + }; +} + +} // namespace sysio::opp::envelope_statement diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/require_privileged.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/require_privileged.hpp new file mode 100644 index 0000000000..b5c921b1fc --- /dev/null +++ b/contracts/sysio.opp.common/include/sysio.opp.common/require_privileged.hpp @@ -0,0 +1,31 @@ +#pragma once +/** + * @file require_privileged.hpp + * @brief The privileged-caller gate a system contract puts on the actions only it may run for itself. + */ +#include +#include +#include +#include +#include + +#include +#include + +namespace sysio::opp { + +/// What follows the contract's name in the refusal `require_privileged_self` raises. +inline constexpr std::string_view PRIVILEGED_REQUIRED_SUFFIX = ": privileged account required"; + +/// Refuse unless the contract now executing (`current_receiver()`) signed the action and is +/// privileged: the gate of the launch-ingestion and registry-seeding actions a system contract runs +/// under its own authority (`sysio.liq::regliqpool`, `sysio.synd::importsynd`, +/// `sysio.reserv::regreserve`). The refusal names the contract: `: privileged account +/// required`. The ONE such gate; every system contract that needs it calls this. +inline void require_privileged_self() { + const name self = current_receiver(); + require_auth(self); + check(is_privileged(self), self.to_string() + std::string(PRIVILEGED_REQUIRED_SUFFIX)); +} + +} // namespace sysio::opp diff --git a/contracts/sysio.reserv/src/sysio.reserv.cpp b/contracts/sysio.reserv/src/sysio.reserv.cpp index 2c1021a3f0..9efcdcbe85 100644 --- a/contracts/sysio.reserv/src/sysio.reserv.cpp +++ b/contracts/sysio.reserv/src/sysio.reserv.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -46,12 +47,6 @@ bool is_bootstrap_window() { return get_current_epoch_index() == 0; } -void require_priv_caller() { - require_auth(current_receiver()); - sysio::check(sysio::is_privileged(current_receiver()), - "sysio.reserv: privileged account required"); -} - /// Saturating uint64 credit for reserve balances / rewards-bucket counters. These accumulate from /// operator-relayed external-chain amounts (no on-chain supply cap), and the credit sites run /// inside the consensus dispatch chain (applyswap / applyfromwire / paywire inline from @@ -374,7 +369,7 @@ void reserve::regreserve(sysio::slug_name chain_code, uint32_t connector_weight_bps, bool is_private, sysio::name owner) { - require_priv_caller(); + opp::require_privileged_self(); sysio::check(is_bootstrap_window(), "regreserve is bootstrap-window only; post-bootstrap reserves go through create_reserve"); sysio::check(source_token_precision <= WIRE_PRECISION, diff --git a/contracts/sysio.reserv/sysio.reserv.wasm b/contracts/sysio.reserv/sysio.reserv.wasm index 0a6727a897991ce322535aa253864d4be3dc04fa..36074312f93e208ad600480eb466ed2c9d95c4d1 100755 GIT binary patch delta 13061 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 L&=F`~o}&Ce5@xKT delta 12512 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 diff --git a/contracts/sysio.synd/CMakeLists.txt b/contracts/sysio.synd/CMakeLists.txt new file mode 100644 index 0000000000..a571fb2485 --- /dev/null +++ b/contracts/sysio.synd/CMakeLists.txt @@ -0,0 +1,51 @@ +set(contract_name sysio.synd) +bootstrap_contract(${contract_name}) + +if(BUILD_SYSTEM_CONTRACTS) + find_cdt_magic_enum() + file(GLOB_RECURSE SOURCES src/*.cpp) + file(GLOB_RECURSE HEADERS include/*.hpp) + add_contract(${contract_name} ${contract_name} ${SOURCES}) + set(targets ${contract_name}) + + if("native-module" IN_LIST SYSIO_WASM_RUNTIMES) + list(APPEND targets ${contract_name}_native) + add_native_contract( + TARGET ${contract_name}_native + SOURCES ${SOURCES} + INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/include + CONTRACT_CLASS "sysio::synd" + HEADERS ${HEADERS} + ABI_FILE ${CMAKE_BINARY_DIR}/contracts/${contract_name}/${contract_name}.abi + ) + endif() + + foreach(target ${targets}) + if(NOT TARGET ${target}) + message(WARNING "Target ${target} not found, skipping include directory setup") + continue() + endif() + + target_include_directories(${target} + PUBLIC + $ + $ + $ + $ + $ + $ + $ + $ + $ + $ + $ + $ + $ + ) + + target_link_libraries(${target} + INTERFACE + magic_enum::magic_enum + ) + endforeach() +endif() diff --git a/contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp b/contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp new file mode 100644 index 0000000000..75d780109c --- /dev/null +++ b/contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp @@ -0,0 +1,704 @@ +#pragma once +/** + * @file sysio.synd.hpp + * @brief sysio.synd: the depot's syndication contract. It owns every rule between an outpost's + * SYNDICATE_LIQ / LIQ_YIELD report and the shadow LIQ a holder receives. + * + * Intake, on the envelope path. `sysio.msgch` decodes an accepted inbound envelope, binds each + * syndication and yield report to the proven outpost, checks the user's key family, the amount range + * and that the token is an active liq token, then sends `onsynd` for each syndication, `onyield` for + * each yield report and, after the last of them, `closeenv`. All three carry the outpost, the depot + * epoch and the envelope's canonical digest. Nothing is paid out at intake: + * - a syndication is minted into THIS contract's own `sysio.liq` holder row and held as an `items` + * row for the syndicating pubkey, linked or not, with a shadow-custody position started at the + * live yield index, so held funds earn WIRE from the moment they arrive; + * - a yield report is held as an `items` row carrying a number only; nothing is minted; + * - both add to the `(outpost, token, epoch)` `envelopes` row, which `closeenv` moves from OPEN to + * WAITING, and to the `(outpost, token)` running sums in `ledger`. + * An envelope that carried no syndication or yield report writes nothing here. + * + * The queue. `closeenv` and permissionless `crank(limit)` run three independently bounded phases: + * 1. `sync(limit)` advances a persistent envelope cursor, including DONE envelopes awaiting finality. + * `syncenv` targets one envelope. Terminal outcomes and settlement snapshots are recorded before + * acknowledging bond. This phase moves no funds and operates while the cord is pulled. + * 2. FIFO release advances from `queue_epoch`. BONDED makes an envelope RELEASABLE; HELD pauses it. + * Syndications pass through the bucket and fee into linked accounts or parked balances. Yield + * reports use mintyield. INVALID burns from the snapshotted settlement; VALID forwards a pending + * challenger share. Neither payout path runs while frozen. Empty item sets become DONE. + * 3. Underwriting advances from each pair's `underwriting_epoch`, past fully bonded or ruled requests, + * and issues its oldest WAITING envelope. Shared bond validation protects non-throwing intake; + * requestkeep retains outcomes until acknowledged. A queued syncenv records the real assigned ID. + * Each of phases 2 and 3 has its own `limit` work budget and `4 * limit` examination cap, with separate + * pair cursors. Outcome consumption therefore does not require a large release budget or a polling + * deadline. Late settlement discovered after DONE rewinds the release cursor to that envelope. + * + * Challenge. Any account may `challenge` an envelope that is REQUESTED, RELEASABLE or DONE while its + * request is OPEN or BONDED: it pays the request's hold bond plus the pair's `challenge_extra` in the + * envelope's token (a charge of zero is refused), the extra stays in `feepot`, and this contract, the + * issuer, holds the request through `sysio.bond::hold` naming the challenger as the beneficiary. The + * envelope is HELD and releases nothing more until `sysio` rules, and the pair's queue is rewound to + * it. One challenge per request. + * + * An envelope ruled INVALID. The step that first sees the ruling snapshots the forfeit (the bonded + * amount) and the bounty returned for want of a hold on the envelope. It burns every unreleased + * syndication item of the envelope with `sysio.liq::burn` (the WIRE those items earned stays in the + * pool as slack) and drops its yield items, within its budget; once none is left it pulls what + * `sysio.bond` still owes it with `sysio.bond::claim`, burns out of the forfeit what the envelope + * already released, puts the rest and the returned bounty into `feepot`, marks the envelope INVALID and + * moves the queue on. Holders already paid keep what they received; supply is whole again. + * + * An envelope that can never be requested (a shadow too coarse to bond) is dropped by `sysio` with + * `dropenv`: its held syndications are burned, its yield reports dropped, and it is INVALID. + * + * The parked hold. Shadow credited to a pubkey with no AuthX link is kept in `parked`, in this + * contract's own `sysio.liq` holder row, with a shadow-custody position, so a parked wallet keeps + * earning WIRE. `linkswept` (inline from `sysio.authex::createlink`) and the permissionless `sweep` + * deliver it: the balance by a `sysio.liq::transfer` from this contract, the banked WIRE out of the + * token's `yieldpool` by `sysio.liq::creditowed`, as owed yield of the account's own `sysio.liq` row + * that `sysio.liq::claim` pays. WIRE the pool cannot cover yet stays banked on the parked row, kept at + * balance 0, for a later delivery. + * + * Desyndication. `desyndicate` ticks the pair's desyndication bucket and refuses a quantity above its + * level (and any desyndication of a pair with no `syndconfig` row), then moves the holder's shadow here. + * The fee, `quantity * desynd_fee_bps / 10000` floored, stays in `feepot`; the rest is burned with + * `sysio.liq::burn`, queued as DESYNDICATE_LIQ to the token's outpost with the next request id, and + * retained in `returns` until governance establishes its external outcome. The bucket drops by the whole quantity. + * The attestation carries `total_syndicated`, the depot's outstanding shadow of the symbol + * after the burn (`liq::outstanding_of`), for the outpost's custody check. + * + * The solvency check. Each SYNDICATE_LIQ and LIQ_YIELD carries `total_syndicated`, the outpost's live + * custody balance of the token, read after the syndication was locked or the yield claimed. Once a message + * is admitted -- its sequence consumed; a dropped or replayed message is never compared, since it may carry + * a stale, lower total -- `onsynd` and `onyield` compare it with the depot's outstanding shadow of the + * symbol, `liq::outstanding_of` (supply plus the yield parked in `liqpending`): `onsynd` with the + * outstanding after its own mint, since the carried custody already holds this syndication, `onyield` with + * the outstanding as it is, since the report is held and mints nothing yet. `sysio.msgch` sends an + * envelope's messages as inline actions, which run depth-first, so each message sees the mints of the ones + * before it. The check is one-sided, `custody >= outstanding`, because every message in flight moves + * custody up or the outstanding down: a syndication is locked before it is minted, a desyndication burned + * before it is paid (or while its payout is stored as pending), yield claimed before it is reported; an + * INVALID envelope is burned while the outpost keeps the custody, a request-keyed `refundreturn` after + * definitive external cancellation restores supply the outpost still holds, and anyone may send tokens to a custody account. An + * excess is printed (`EXCESS`) and nothing else. A shortfall writes a `mismatch` row, pulls the + * `sysio.andon` cord using the Andon account's active permission when the cord is clear, + * prints why when it does not, and the message is then held, minted and added to its envelope exactly as + * without it. Andon must already be deployed for the inline pull to succeed. The check reads live balances + * only. Each pair retains its latest unresolved incident, cleared only by explicit governance reconciliation. + * No lifetime reporting totals or completed-return archive are retained. + * + * Emergency stop. While the `sysio.andon` cord is pulled nothing leaves this contract, and nothing it + * holds is burned: the queue step still refreshes requests, records outcomes and issues requests, but + * releases no item, forwards no hold share and burns no INVALID envelope -- it prints that it waits, and + * the first step after the cord clears does that work. A VALID ruling is recorded with its hold-share + * snapshot during the freeze, so the forward survives a prune of the request and is paid once after the + * clear. `linkswept` keeps the parked + * balance and returns (the user's link stands; `sweep` delivers after the clear); `sweep`, `desyndicate`, + * `dropenv` and `sweepyield` are refused. Intake, `challenge` and `setconfig` run. A bucket never refills + * beyond its burst cap; elapsed frozen epochs refill normally after clear. Challenge windows run on. + * + * Launch. `importsynd` replays pre-launch positions inside the epoch-0 bootstrap window, minting + * straight to a linked account or into `parked`; `importdone` closes the import for good. + * + * Never-throw: `onsynd`, `onyield` and `closeenv` run inside `sysio.msgch`'s consensus transaction. + * They `check` nothing on their input; a message that fails a rule is dropped with a `DROP` + * diagnostic on the console and consumes no sequence. The one inline action they send, the + * `sysio.liq::mint` of a held syndication, is sent only after every condition that `mint` checks has + * been verified here; the queue step `closeenv` runs keeps the same discipline for every action it + * sends. + * + * Privileged (roa::setsyscode): every row bills the `sysio` RAM pool -- there is no signer to bill on + * the envelope path -- and inline actions carry this contract's own authority without a + * `sysio.code` grant. Being privileged, the rows `sysio.bond` creates for its requests bill the + * `sysio` pool too. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace sysio { + + class [[sysio::contract("sysio.synd")]] synd : public contract { + public: + using contract::contract; + + /// Configuration and ruling authority; council proposals execute as it. + static constexpr name SYSTEM_ACCOUNT = "sysio"_n; + /// The message channel: the only signer of the envelope-path actions. + static constexpr name MSGCH_ACCOUNT = "sysio.msgch"_n; + /// The shadow LIQ ledger this contract mints into and holds in. + static constexpr name LIQ_ACCOUNT = "sysio.liq"_n; + /// The chain registry, for an outpost's chain family. + static constexpr name CHAINS_ACCOUNT = "sysio.chains"_n; + /// The AuthX link registry: the only signer of `linkswept`, and the source of every link. + static constexpr name AUTHEX_ACCOUNT = "sysio.authex"_n; + /// The WIRE token contract. + static constexpr name TOKEN_ACCOUNT = "sysio.token"_n; + /// The underwriting contract each envelope's statement is registered with. + static constexpr name BOND_ACCOUNT = "sysio.bond"_n; + + /// Challenge window of a request, in seconds, for a pair with no `syndconfig` row. + static constexpr uint32_t DEFAULT_WINDOW_SEC = 10800; + /// Units of work of the queue step `closeenv` runs inline: small and fixed, so intake stays cheap; + /// `crank` does the rest. + static constexpr uint32_t CLOSEENV_QUEUE_LIMIT = 16; + + /// Lifecycle of one `(outpost, token, epoch)` envelope row. + enum class envelope_state : uint8_t { + OPEN, ///< intake in progress: the envelope's messages are still arriving + WAITING, ///< closed by `closeenv`; waiting for its underwriting request + REQUESTED, ///< its `sysio.bond` request is issued and not yet bonded or ruled + RELEASABLE, ///< the request is BONDED, APPROVED or VALID: items may be released + HELD, ///< the request is held; nothing releases until `sysio` rules + INVALID, ///< the request was ruled INVALID: the envelope is burned and its forfeit pulled + DONE ///< every item is released + }; + + /// What an `items` row holds. + enum class item_kind : uint8_t { + SYNDICATION, ///< shadow minted into this contract's holder row for a beneficiary pubkey + YIELD ///< a LIQ_YIELD amount: a number only, minted nowhere until released + }; + + /// The terminal outcome of an envelope's `sysio.bond` request, recorded on the envelope row the first + /// time the queue sees it. Once recorded the request row is never read again: `sysio.bond` may prune + /// it once its ruling is PRUNE_RETENTION_SEC old and nothing is owed on it. + enum class request_outcome : uint8_t { + PENDING, ///< not seen terminal yet: the request row is read on every step + APPROVED, ///< the challenge window passed with no hold + VALID, ///< `sysio` ruled the statement true + INVALID ///< `sysio` ruled the statement false + }; + + /// Which flow a leaky bucket limits. + enum class bucket_direction : uint8_t { + SYNDICATION, ///< releases of held syndications to their beneficiaries + DESYNDICATION ///< desyndications queued back to the outpost + }; + + // ----------------------------------------------------------------------- + // Intake (sysio.msgch dispatch; never throw) + // ----------------------------------------------------------------------- + + /// SYNDICATE_LIQ of `amount` base units of the liq token `token_code`, by the holder of + /// `pubkey` (a `chain_kind` key), in the envelope of outpost `chain_code` accepted for depot + /// epoch `epoch_index` with canonical digest `digest`, at outpost sequence `sequence`. Mints the + /// amount into this contract's `sysio.liq` holder row and holds it as a new SYNDICATION item, + /// whether the pubkey has an AuthX link or not. A pubkey that does not fit `chain_kind`, a token + /// with no shadow or of another outpost, a `chain_kind` that is not the outpost's, an amount + /// out of range or past the shadow's headroom, an envelope already closed, an amount that would + /// take the envelope's total, rounded up to the bond increment, past the asset range (so the + /// envelope could never be underwritten), or a replayed sequence is dropped with a diagnostic, + /// never an abort. `total_syndicated` is the outpost's live custody balance of the token, read + /// after this syndication was locked, as the SYNDICATE_LIQ carried it; once the message is admitted it + /// is compared with the depot's outstanding shadow after this mint (the file comment's solvency + /// check), and a shortfall is recorded without changing how the message is held. Auth=sysio.msgch. + [[sysio::action]] void onsynd(sysio::slug_name chain_code, uint32_t epoch_index, checksum256 digest, + uint64_t sequence, opp::types::ChainKind chain_kind, std::vector pubkey, + sysio::slug_name token_code, uint64_t amount, uint64_t total_syndicated); + + /// LIQ_YIELD of `amount` base units of `token_code`, claimed by outpost `chain_code` at its own + /// epoch `outpost_epoch`, in the envelope identified as for `onsynd`, at outpost sequence + /// `sequence` (shared with SYNDICATE_LIQ). Holds the amount as a YIELD item; mints nothing. The + /// same refusals as `onsynd` apply to the token, the amount, the envelope and the sequence, and + /// a report that would take the envelope's yield total past the shadow's headroom is dropped too. + /// `total_syndicated` is the outpost's live custody balance of the token, read after the yield + /// was claimed into custody, as the LIQ_YIELD carried it; once the report is admitted it is compared + /// with the depot's outstanding shadow as it stands (the file comment's solvency check), and a shortfall + /// is recorded without changing how the report is held. Auth=sysio.msgch. + [[sysio::action]] void onyield(sysio::slug_name chain_code, uint32_t epoch_index, checksum256 digest, + uint64_t sequence, uint64_t outpost_epoch, sysio::slug_name token_code, + uint64_t amount, uint64_t total_syndicated); + + /// Close the envelope of outpost `chain_code` for depot epoch `epoch_index` with canonical digest + /// `digest`: every OPEN envelope row of that outpost and epoch, one per token, becomes WAITING, + /// then one step of the queue runs within CLOSEENV_QUEUE_LIMIT. Sent by `sysio.msgch` after the + /// envelope's last message, only when the envelope carried a syndication or yield report; with no + /// row it writes nothing. Never throws. Auth=sysio.msgch. + [[sysio::action]] void closeenv(sysio::slug_name chain_code, uint32_t epoch_index, checksum256 digest); + + // ----------------------------------------------------------------------- + // Configuration and the queue + // ----------------------------------------------------------------------- + + /// Set the rules of outpost `chain_code` and its liq token `token_code`: the syndication and + /// desyndication fees in basis points (at most `bond::BPS_DENOMINATOR`), the size and per-epoch + /// refill of each direction's bucket in base units, the challenge window of each request in seconds + /// (above 0), the bounty posted on each request from collected fees, and what a challenger pays on + /// top of the hold bond. A pair with no row issues requests with DEFAULT_WINDOW_SEC and no bounty, + /// releases no syndication and accepts no desyndication: its buckets are unset. Two settings stall + /// a pair for good, and nothing but a new configuration moves it: no row, or a syndication bucket + /// of zero burst, releases no syndication of the pair; and a shadow whose precision is below + /// `bond::BOND_INCREMENT_DECIMALS` can never be underwritten, so every envelope of it waits (only + /// `dropenv` clears one). Auth=sysio. + [[sysio::action]] void setconfig(sysio::slug_name chain_code, sysio::slug_name token_code, + uint32_t synd_fee_bps, uint32_t desynd_fee_bps, uint64_t synd_burst, + uint64_t synd_refill, uint64_t desynd_burst, uint64_t desynd_refill, + uint32_t window_sec, uint64_t bounty, uint64_t challenge_extra); + + /// Run bounded outcome synchronization, FIFO release, and underwriting, in that order. Sync + /// visits at most `limit` envelopes; underwriting and release each have `limit` work units and + /// `4 * limit` examinations, with independent cursors. Permissionless. Blocked work waits; while + /// frozen only synchronization and request issuance run. Durable outcomes have no polling deadline. + [[sysio::action]] void crank(uint32_t limit); + + /// Synchronize one issued envelope's request identity and terminal outcome without releasing, + /// burning or claiming funds. Permissionless; safe while frozen and independent of FIFO release. + [[sysio::action]] void syncenv(sysio::slug_name chain_code, sysio::slug_name token_code, + uint32_t epoch_index); + + /// Synchronize at most `limit` envelopes from a persistent cursor, including DONE envelopes + /// still awaiting a ruling. Permissionless; no recipient code or funds movement. + [[sysio::action]] void sync(uint32_t limit); + + /// Erase at most `limit` settled prefix envelopes, retaining a replay floor. Stops at the first + /// unfinished row; DONE before finality, unacknowledged outcomes, items and pending shares stay. + /// Permissionless, counts every examined envelope, and never moves funds. + [[sysio::action]] void pruneenv(sysio::slug_name chain_code, sysio::slug_name token_code, uint32_t limit); + + /// Governance attests that custody is reconciled at `reported` base units, which must cover + /// current outstanding supply. Erases this pair's incident, leaving the global Andon cord alone. + /// This is a trusted governance statement, not an on-chain measurement of external custody. + [[sysio::action]] void reconcile(sysio::slug_name chain_code, sysio::slug_name token_code, uint64_t reported); + + /// Governance confirms an outstanding return was externally paid/completed. Erases the pending + /// obligation without depot credit. External finality is established off-chain; auth=sysio. + [[sysio::action]] void finishreturn(uint64_t request_id); + + /// Governance attests irreversible external rejection/cancellation: nothing was paid and no + /// payable external obligation remains. Restore exactly the net burned amount to the original + /// holder, atomically erase the outstanding row, and reject a duplicate. Auth=sysio. No timeout + /// or mere queued/pending/duplicate-request observation is sufficient evidence for this action. + [[sysio::action]] void refundreturn(uint64_t request_id); + + /// Drop the envelope of outpost `chain_code`, token `token_code` and depot epoch `epoch_index`, OPEN or + /// WAITING with no request issued: burn its SYNDICATION items out of this contract's `sysio.liq` row + /// with `sysio.liq::burn` (the WIRE they earned stays in the pool as slack), drop its YIELD items, + /// and mark it INVALID, so the queue moves past it. The one escape hatch for an envelope the queue + /// can never request (a shadow too coarse to bond, a statement `sysio.bond` refuses). Refused while the + /// `sysio.andon` cord is pulled. Auth=sysio. + [[sysio::action]] void dropenv(sysio::slug_name chain_code, sysio::slug_name token_code, uint32_t epoch_index); + + /// Challenge the envelope of outpost `chain_code`, token `token_code` and depot epoch `epoch_index`, + /// REQUESTED, RELEASABLE or DONE with its request OPEN or BONDED: `challenger` pays the request's hold + /// bond (`sysio.bond::hold_bond_of` at `sysio.bond`'s `hold_bps`) plus the pair's `challenge_extra` in + /// the envelope's token; the extra goes to `feepot`, and `sysio.bond::hold` holds the request, + /// pulling the hold bond back out of this contract and naming `challenger` as the beneficiary of an + /// INVALID ruling. The envelope becomes HELD, and the pair's `queue_epoch` is rewound to it so the + /// queue processes the ruling even for an envelope it had finished. A charge of zero (a hold bond + /// that rounds to zero and no `challenge_extra`) is refused. One challenge per request. + /// `challenger` may not be this contract or `sysio.bond`. Contract accounts receive refunds + /// through ledger settlement without recipient execution. + /// Challenger's authority; any row the challenge creates bills the `sysio` RAM pool. + [[sysio::action]] void challenge(name challenger, sysio::slug_name chain_code, sysio::slug_name token_code, + uint32_t epoch_index); + + /// Pull the WIRE `sysio.liq` owes this contract's holder row of the shadow `token_code` into the + /// token's `yieldpool`, so held and parked positions can be paid out of it, and pay `sysio` what the + /// token's fee pot earned, as far as the pool covers it: the pot is protocol revenue, and so is its + /// yield. Refused while the `sysio.andon` cord is pulled. Permissionless. + [[sysio::action]] void sweepyield(sysio::slug_name token_code); + + // ----------------------------------------------------------------------- + // The parked hold + // ----------------------------------------------------------------------- + + /// Deliver every parked row of the pubkey `account` has linked for `chain_kind`, for a link that + /// already exists: late arrivals, and the node-owner path that records links without + /// `createlink`. Refused when `account` has no link for `chain_kind`, and while the `sysio.andon` + /// cord is pulled. Permissionless. + [[sysio::action]] void sweep(name account, opp::types::ChainKind chain_kind); + + /// AuthX link completed: deliver parked principal by callback-free LIQ settlement and banked + /// WIRE by creditowed. A missing account or malformed key returns quietly. Contract accounts, + /// including this custodian, can receive without executing code. While Andon is pulled, the + /// link stands and sweep delivers after the clear. + /// Auth=sysio.authex. + [[sysio::action]] void linkswept(name account, opp::types::ChainKind chain_kind, std::vector pubkey); + + // ----------------------------------------------------------------------- + // Desyndication + // ----------------------------------------------------------------------- + + /// Desyndicate `quantity` of `holder`'s shadow: queue DESYNDICATE_LIQ to the symbol's outpost, + /// paying the pubkey `holder` has linked for that chain. The pair's desyndication bucket is ticked + /// to the depot's current epoch; a quantity above its level is refused, as is any desyndication of + /// a pair with no `syndconfig` row. The quantity moves from the holder's own `sysio.liq` row (never + /// a held or parked balance) to this contract. The fee, `quantity * desynd_fee_bps / + /// BPS_DENOMINATOR` floored, stays in `feepot`; the net rest is burned with `sysio.liq::burn`, and + /// is what the attestation, `returns` carry. The bucket drops + /// by the whole quantity. A fee that rounds to zero burns and queues the whole quantity; a fee of + /// the whole quantity is refused. The request takes the next id from `syndcounters`. The attestation + /// carries `total_syndicated`, the depot's outstanding shadow of the symbol after + /// this burn (`liq::outstanding_of`: supply plus parked yield, net of the burned amount). The burn is + /// final: an outpost refusal is reconciled by governance through request-keyed `refundreturn`. Refused while + /// the `sysio.andon` cord is pulled. Holder's authority. + [[sysio::action]] void desyndicate(name holder, asset quantity); + + // ----------------------------------------------------------------------- + // Launch ingestion (privileged caller, epoch-0 bootstrap window) + // ----------------------------------------------------------------------- + + /// One pre-launch position: the holder's native pubkey (32-byte Ed25519 on SVM, 33-byte + /// compressed secp256k1 on EVM) and its shadow amount in subunits, the LCO yield already folded in. + struct import_credit { + std::vector pubkey; ///< the holder's native pubkey + uint64_t amount = 0; ///< shadow subunits; a zero credit is skipped + SYSLIB_SERIALIZE(import_credit, (pubkey)(amount)) + }; + + /// Replay pre-launch positions of `token_code` on `chain_code`: each credit mints to the account + /// its pubkey has linked, or into this contract's holder row and a parked row. Batched; the same + /// pubkey across batches sums. Refused outside the bootstrap window and once `importdone` ran. + /// Requires this contract's authority, and the contract must be privileged. + [[sysio::action]] void importsynd(sysio::slug_name chain_code, sysio::slug_name token_code, + std::vector credits); + + /// Close the import: every later `importsynd` is refused. Requires this contract's authority, and + /// the contract must be privileged. + [[sysio::action]] void importdone(); + + // ----------------------------------------------------------------------- + // Tables + // ----------------------------------------------------------------------- + + /// Key of `envelopes`: one row per outpost, token and depot epoch. + struct envelope_key { + uint64_t chain_code; ///< the outpost's registry code value + uint64_t token_code; ///< the liq token's registry code value + uint32_t epoch_index; ///< the depot epoch the envelope was accepted for + SYSLIB_SERIALIZE(envelope_key, (chain_code)(token_code)(epoch_index)) + }; + + /// What one token of one accepted envelope brought in, and where it stands. + struct [[sysio::table("envelopes")]] envelope_row { + sysio::slug_name chain_code; ///< the outpost that sent the envelope + sysio::slug_name token_code; ///< the liq token + uint32_t epoch_index = 0; ///< the depot epoch the envelope was accepted for + checksum256 digest; ///< the envelope's canonical epoch digest + uint64_t synd_total = 0; ///< base units of every SYNDICATION item of this row + uint64_t yield_total = 0; ///< base units of every YIELD item of this row + uint32_t item_count = 0; ///< items added for this row + envelope_state state = envelope_state::OPEN; ///< lifecycle state + /// The `sysio.bond` request covering the row: the id it was issued under, re-read through the + /// request's `bystatement` key on every refresh; 0 until issued. + uint64_t request_id = 0; + uint64_t released = 0; ///< base units released to beneficiaries so far + /// Base units burned after an INVALID ruling: the unreleased syndications, then the released + /// amount out of the forfeit. + uint64_t burned = 0; + /// The request's terminal outcome once the queue has seen one; PENDING until then. + request_outcome outcome = request_outcome::PENDING; + /// Base units the request forfeits to this contract, its bonded amount, snapshotted when the queue + /// first sees it INVALID; 0 otherwise. The burn that completes the envelope uses it, not the + /// request row, which `sysio.bond` may prune once it is paid. + uint64_t forfeit = 0; + /// Base units of bounty `sysio.bond` returns to this contract on that INVALID ruling -- the whole + /// bounty when the request was never held -- snapshotted with `forfeit`; 0 otherwise. + uint64_t bounty_returned = 0; + /// Base units of the hold bond a challenger is owed back on a VALID ruling -- the share no bond + /// covered, which `sysio.bond` awards this contract, the issuer -- snapshotted with the VALID + /// outcome; 0 otherwise. The forward pays it from this snapshot, not the request row, which + /// `sysio.bond` may prune once it is paid. + uint64_t hold_share = 0; + /// The challenger `hold_share` is forwarded to, snapshotted with it; empty when there is none. + name hold_beneficiary; + /// True from the step that records a VALID outcome with a positive `hold_share` until the step + /// that forwards it: the first step while the `sysio.andon` cord is clear. + bool share_pending = false; + SYSLIB_SERIALIZE(envelope_row, (chain_code)(token_code)(epoch_index)(digest)(synd_total)(yield_total) + (item_count)(state)(request_id)(released)(burned)(outcome)(forfeit)(bounty_returned) + (hold_share)(hold_beneficiary)(share_pending)) + }; + + /// Envelope rows by outpost, token and epoch. + using envelopes_t = kv::table<"envelopes"_n, envelope_key, envelope_row>; + + /// Key of `items`: the envelope row an item belongs to, then its id, so the items of one envelope + /// are contiguous and, within it, ascending id is arrival order. + struct item_key { + uint64_t chain_code; ///< the outpost's registry code value + uint64_t token_code; ///< the liq token's registry code value + uint32_t epoch_index; ///< the depot epoch of its envelope + uint64_t id; ///< item id, from `syndcounters` + SYSLIB_SERIALIZE(item_key, (chain_code)(token_code)(epoch_index)(id)) + }; + + /// One held syndication or yield report. + struct [[sysio::table("items")]] item_row { + uint64_t id = 0; ///< item id + sysio::slug_name chain_code; ///< the outpost that reported it + sysio::slug_name token_code; ///< the liq token + uint32_t epoch_index = 0; ///< the depot epoch of its envelope + item_kind kind = item_kind::SYNDICATION; ///< syndication or yield + /// The beneficiary's key family; CHAIN_KIND_UNKNOWN for a YIELD item, which has none. + opp::types::ChainKind chain_kind = opp::types::ChainKind::CHAIN_KIND_UNKNOWN; + std::vector pubkey; ///< the beneficiary's native pubkey; empty for YIELD + uint64_t amount = 0; ///< base units reported + uint64_t remaining = 0; ///< base units not yet released + /// Shadow yield of `remaining` for a SYNDICATION item, started at the live index at intake. A + /// YIELD item holds no shadow and earns nothing: its position is checkpointed at the live index + /// at intake and never settled again, so it stays at zero owed, and every walk over items + /// checks `kind` before it touches a position. + opp::shadow::custody::position position; + SYSLIB_SERIALIZE(item_row, (id)(chain_code)(token_code)(epoch_index)(kind)(chain_kind)(pubkey)(amount) + (remaining)(position)) + }; + + /// Items by envelope and id. + using items_t = kv::table<"items"_n, item_key, item_row>; + + /// Key of `ledger`: one row per outpost and token. + struct ledger_key { + uint64_t chain_code; ///< the outpost's registry code value + uint64_t token_code; ///< the liq token's registry code value + SYSLIB_SERIALIZE(ledger_key, (chain_code)(token_code)) + }; + + /// Operational queue positions and replay floor for one outpost/token; no lifetime totals. + struct [[sysio::table("ledger")]] ledger_row { + sysio::slug_name chain_code; ///< the outpost + sysio::slug_name token_code; ///< the liq token + /// The queue's cursor: no envelope of an earlier epoch is left to refresh or release, so the + /// queue step starts here. + uint32_t queue_epoch = 0; + uint32_t underwriting_epoch = 0; ///< oldest envelope still gating issuance, independent of release + uint64_t retained_from_epoch = 0; ///< epochs below this floor were compacted and cannot reopen + SYSLIB_SERIALIZE(ledger_row, (chain_code)(token_code)(queue_epoch)(underwriting_epoch)(retained_from_epoch)) + }; + + /// Ledger rows by outpost and token. + using ledger_t = kv::table<"ledger"_n, ledger_key, ledger_row>; + + /// Key of `syndcursors`: one row per outpost. + struct cursor_key { + uint64_t chain_code; ///< the outpost's registry code value + SYSLIB_SERIALIZE(cursor_key, (chain_code)) + }; + + /// Per-outpost replay guard over the sequence SYNDICATE_LIQ and LIQ_YIELD share. + struct [[sysio::table("syndcursors")]] synd_cursor { + sysio::slug_name chain_code; ///< the outpost + uint64_t last_sequence = 0; ///< highest sequence admitted; anything at or below it is a replay + uint64_t last_epoch = 0; ///< outpost epoch of the last LIQ_YIELD admitted, for forensics + SYSLIB_SERIALIZE(synd_cursor, (chain_code)(last_sequence)(last_epoch)) + }; + + /// Cursors by outpost. + using syndcursors_t = kv::table<"syndcursors"_n, cursor_key, synd_cursor>; + + /// Id allocation. + struct [[sysio::table("syndcounters")]] synd_counters { + uint64_t next_item_id = 1; ///< id the next `items` row takes + uint64_t next_request_id = 1; ///< DESYNDICATE_LIQ request id; the outpost reads 0 as "no id" + SYSLIB_SERIALIZE(synd_counters, (next_item_id)(next_request_id)) + }; + + /// The counters singleton. + using syndcounters_t = kv::global<"syndcounters"_n, synd_counters>; + + /// Key of the per-token tables, `yieldpool` and `feepot`. + struct token_key { + uint64_t token_code; ///< the shadow token's registry code value + SYSLIB_SERIALIZE(token_key, (token_code)) + }; + + /// The shadow-custody solvency pool of one shadow token: the WIRE this contract's holder row + /// earned, pulled from `sysio.liq` and credited out to held and parked positions. + struct [[sysio::table("yieldpool")]] pool_row { + sysio::slug_name token_code; ///< registry code of the shadow token + opp::shadow::custody::yield_pool pool; ///< WIRE pulled and credited out + SYSLIB_SERIALIZE(pool_row, (token_code)(pool)) + }; + + /// Yield pools by token code. + using yieldpools_t = kv::table<"yieldpool"_n, token_key, pool_row>; + + /// Key of `parked`: the shadow token, the chain family and the holder's native pubkey. + struct parked_key { + uint64_t token_code; ///< the liq token's registry code value + uint64_t chain_kind; ///< magic_enum::enum_integer of the ChainKind; the row keeps the enum + std::vector pubkey; ///< the holder's native pubkey + SYSLIB_SERIALIZE(parked_key, (token_code)(chain_kind)(pubkey)) + }; + + /// Shadow credited to a pubkey with no AuthX link yet, held in this contract's own `sysio.liq` + /// holder row. The position accrues exactly as a holder row does, so linking late costs no yield. + struct [[sysio::table("parked")]] parked_row { + sysio::slug_name token_code; ///< the liq token + opp::types::ChainKind chain_kind = opp::types::ChainKind::CHAIN_KIND_UNKNOWN; ///< key family + std::vector pubkey; ///< native pubkey + uint64_t balance = 0; ///< shadow base units held for the pubkey + opp::shadow::custody::position position; ///< the shadow yield of `balance` + SYSLIB_SERIALIZE(parked_row, (token_code)(chain_kind)(pubkey)(balance)(position)) + }; + + /// Parked rows by token, chain family and pubkey. + using parkeds_t = kv::table<"parked"_n, parked_key, parked_row>; + + /// Stable identity of an outstanding external return. IDs are never reused. + struct return_key { + uint64_t request_id; + SYSLIB_SERIALIZE(return_key, (request_id)) + }; + + /// A burned return awaiting an externally established outcome. Contains only what governance + /// needs to identify the obligation and restore its original holder if it is definitively rejected. + struct [[sysio::table("returns")]] return_row { + uint64_t request_id = 0; ///< DESYNDICATE_LIQ identity + name holder; ///< original depot holder; later wallet linking cannot redirect a refund + sysio::slug_name chain_code; ///< destination outpost + sysio::slug_name token_code; ///< burned token + opp::types::ChainKind chain_kind; ///< destination key family + std::vector pubkey; ///< destination captured when queued + uint64_t amount = 0; ///< exact net burned amount; the fee is not refundable here + SYSLIB_SERIALIZE(return_row, (request_id)(holder)(chain_code)(token_code)(chain_kind)(pubkey)(amount)) + }; + /// Outstanding returns only; completed/refunded rows are erased atomically. + using returns_t = kv::table<"returns"_n, return_key, return_row>; + + /// Contract-wide state. + struct [[sysio::table("syndstate")]] synd_state { + bool import_complete = false; ///< set by `importdone`; every later `importsynd` is refused + /// The shadow symbol (its `symbol_code` value) whose pair the next queue step starts at: the pair + /// the last step ran out on (budget or examination cap), or the one after it when that pair + /// moved nothing. + uint64_t queue_cursor = 0; + uint64_t underwriting_cursor = 0; ///< independent pair cursor for request issuance + envelope_key sync_cursor{}; ///< next envelope inspected by the independent outcome sweep + SYSLIB_SERIALIZE(synd_state, (import_complete)(queue_cursor)(underwriting_cursor)(sync_cursor)) + }; + + /// The state singleton. + using syndstate_t = kv::global<"syndstate"_n, synd_state>; + + /// Key of `syndconfig`: one row per outpost and token. + struct config_key { + uint64_t chain_code; ///< the outpost's registry code value + uint64_t token_code; ///< the liq token's registry code value + SYSLIB_SERIALIZE(config_key, (chain_code)(token_code)) + }; + + /// The rules of one outpost and token, as `setconfig` set them. + struct [[sysio::table("syndconfig")]] synd_config { + sysio::slug_name chain_code; ///< the outpost + sysio::slug_name token_code; ///< the liq token + uint32_t synd_fee_bps = 0; ///< fee on a released syndication, in basis points + uint32_t desynd_fee_bps = 0; ///< fee on a desyndication, in basis points + uint64_t synd_burst = 0; ///< size of the syndication bucket, in base units + uint64_t synd_refill = 0; ///< syndication bucket refill per depot epoch + uint64_t desynd_burst = 0; ///< size of the desyndication bucket, in base units + uint64_t desynd_refill = 0; ///< desyndication bucket refill per depot epoch + uint32_t window_sec = DEFAULT_WINDOW_SEC; ///< challenge window of each request + uint64_t bounty = 0; ///< bounty posted on each request, paid from `feepot` + uint64_t challenge_extra = 0; ///< charged to a challenger on top of the hold bond + SYSLIB_SERIALIZE(synd_config, (chain_code)(token_code)(synd_fee_bps)(desynd_fee_bps)(synd_burst) + (synd_refill)(desynd_burst)(desynd_refill)(window_sec)(bounty)(challenge_extra)) + }; + + /// Rules by outpost and token. + using syndconfig_t = kv::table<"syndconfig"_n, config_key, synd_config>; + + /// Key of `buckets`: one row per outpost, token and direction. + struct bucket_key { + uint64_t chain_code; ///< the outpost's registry code value + uint64_t token_code; ///< the liq token's registry code value + uint8_t direction; ///< magic_enum::enum_integer of the bucket_direction; the row keeps the enum + SYSLIB_SERIALIZE(bucket_key, (chain_code)(token_code)(direction)) + }; + + /// A leaky bucket: `level` base units may pass now, and it refills lazily by the configured + /// refill per depot epoch, including frozen epochs, up to the burst. + struct [[sysio::table("buckets")]] bucket_row { + sysio::slug_name chain_code; ///< the outpost + sysio::slug_name token_code; ///< the liq token + bucket_direction direction = bucket_direction::SYNDICATION; ///< the flow it limits + uint64_t level = 0; ///< base units that may pass now; never above the burst + uint32_t last_epoch = 0; ///< the depot epoch it was last ticked at + SYSLIB_SERIALIZE(bucket_row, (chain_code)(token_code)(direction)(level)(last_epoch)) + }; + + /// Buckets by outpost, token and direction. + using buckets_t = kv::table<"buckets"_n, bucket_key, bucket_row>; + + /// The fees collected in one shadow token: a sub-balance of this contract's own `sysio.liq` + /// holder row, with its own shadow-custody position. Request bounties are paid out of it. + struct [[sysio::table("feepot")]] fee_row { + sysio::slug_name token_code; ///< the liq token + uint64_t balance = 0; ///< shadow base units collected and not spent + opp::shadow::custody::position position; ///< the shadow yield of `balance` + SYSLIB_SERIALIZE(fee_row, (token_code)(balance)(position)) + }; + + /// Fee pots by token code. + using feepots_t = kv::table<"feepot"_n, token_key, fee_row>; + + /// One unresolved custody incident per outpost/token. + struct mismatch_key { + uint64_t chain_code; ///< the outpost's registry code value + uint64_t token_code; ///< the affected token + SYSLIB_SERIALIZE(mismatch_key, (chain_code)(token_code)) + }; + + /// Latest shortfall evidence for an unresolved incident. A healthy report does not implicitly + /// clear it; governance explicitly reconciles it against current outstanding supply. + struct [[sysio::table("mismatch")]] mismatch_row { + sysio::slug_name chain_code; ///< the outpost that reported it + sysio::slug_name token_code; ///< the liq token + uint32_t epoch_index = 0; ///< the depot epoch of its envelope + uint64_t sequence = 0; ///< the outpost sequence of the message + item_kind kind = item_kind::SYNDICATION; ///< a syndication or a yield report + uint64_t reported = 0; ///< the outpost custody the message carried, in base units + /// The depot's outstanding shadow it was compared with, in base units: supply plus parked yield, + /// after this message's own mint for a syndication. + uint64_t expected = 0; + time_point_sec at; ///< when the shortfall was recorded + SYSLIB_SERIALIZE(mismatch_row, (chain_code)(token_code)(epoch_index)(sequence)(kind)(reported)(expected) + (at)) + }; + + /// Current custody incidents by outpost and token. + using mismatches_t = kv::table<"mismatch"_n, mismatch_key, mismatch_row>; + + private: + /// Admit `sequence` for outpost `chain_code` and advance its cursor, recording `outpost_epoch` + /// when it is not zero; false, and nothing written, on a replay. Never throws. + bool admit_sequence(sysio::slug_name chain_code, uint64_t sequence, uint64_t outpost_epoch); + + /// The solvency check of one admitted message on path `path`: `reported`, the outpost custody of the + /// shadow `st` the message carried, against `expected`, the depot's outstanding shadow of it. At or + /// above: nothing, but an excess prints `EXCESS`. Below: a `mismatch` row keyed `(chain_code, + /// token_code)` for the message of `kind` in the envelope of depot epoch `epoch_index`, and a + /// `sysio.andon::pull` with Andon active authorization when the cord is clear. + /// Requires Andon to be deployed before processing envelopes. + void check_custody(std::string_view path, const liq::currency_stats& st, sysio::slug_name chain_code, + uint32_t epoch_index, uint64_t sequence, item_kind kind, uint64_t reported, + uint64_t expected); + + /// Move every parked row of `(chain_kind, pubkey)` to `account`: for each shadow token, pull + /// what `sysio.liq` owes this contract's row, take the row's banked WIRE out of the token's + /// `yieldpool`, transfer its balance to `account` and credit that WIRE to `account`'s `sysio.liq` + /// row with `sysio.liq::creditowed`. The row is erased, or kept at balance 0 with the WIRE the + /// pool could not cover yet still banked. Throws only on corrupt `sysio.liq` state, through the + /// checked `custody::pull`. + void deliver_parked(name account, opp::types::ChainKind chain_kind, const std::vector& pubkey); + + /// Credit `amount` of `token_code`'s shadow, symbol `sym`, to the account `pubkey` has linked on + /// `chain_kind`, or to its parked row when it has none, and send the `sysio.liq::mint` that + /// brings it in. + void credit_by_pubkey(sysio::slug_name token_code, symbol_code sym, opp::types::ChainKind chain_kind, + const std::vector& pubkey, uint64_t amount); + }; + +} // namespace sysio diff --git a/contracts/sysio.synd/src/sysio.synd.cpp b/contracts/sysio.synd/src/sysio.synd.cpp new file mode 100644 index 0000000000..d62a4e7dc6 --- /dev/null +++ b/contracts/sysio.synd/src/sysio.synd.cpp @@ -0,0 +1,1642 @@ +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace sysio { + +namespace { + +constexpr name ack_action = "ack"_n; +constexpr name settle_action = "settle"_n; +constexpr name requestkeep_action = "requestkeep"_n; +constexpr name syncenv_action = "syncenv"_n; +constexpr auto outpost_token_not_found_msg = "outpost token not found"; +constexpr auto custody_incident_not_found_msg = "custody incident not found"; +constexpr auto custody_still_below_outstanding_msg = "custody still below outstanding"; +constexpr auto outstanding_return_not_found_msg = "outstanding return not found"; +constexpr auto return_request_id_space_exhausted_msg = "return request id space exhausted"; + + +using opp::types::AttestationType; +using opp::types::ChainKind; + +namespace custody = opp::shadow::custody; + +/// The payer of every row this contract writes: the `sysio` RAM pool (privileged-contract model, as +/// sysio.liq uses). The envelope path has no signer whose RAM a held item could draw on, and this +/// contract account has no RAM quota of its own beyond what its code and abi take. +constexpr name ram_payer = synd::SYSTEM_ACCOUNT; + +/// Permission this contract sends its own inline actions under. +constexpr name active_permission = "active"_n; + +/// `sysio.liq`'s mint, burn and creditowed, which this contract alone may call, and its transfer. +constexpr name mint_action = "mint"_n; +constexpr name burn_action = "burn"_n; +constexpr name creditowed_action = "creditowed"_n; +constexpr name transfer_action = "transfer"_n; +/// `sysio.msgch`'s outbound queue. +constexpr name queueout_action = "queueout"_n; +/// `sysio.liq::mintyield`, which a released yield item sends. +constexpr name mintyield_action = "mintyield"_n; +/// `sysio.bond::hold`, which a challenge sends, and `sysio.bond::claim`, which pulls what a ruling awards +/// this contract as the issuer. +constexpr name hold_action = "hold"_n; +constexpr name claim_action = "claim"_n; +/// `sysio.andon::pull`, sent with Andon active authorization on a custody shortfall. +constexpr name pull_action = "pull"_n; + +/// Largest amount an `asset` carries, as the unsigned base units every row holds. +constexpr uint64_t max_asset_amount = static_cast(asset::max_amount); + +/// Memos of the transfers a parked delivery and a desyndication send. +constexpr std::string_view desyndicate_memo = "sysio.synd desyndicate"; +/// Memo of the transfers a release to a linked account sends. +/// Memo of the transfer that pulls a challenger's charge, and of the one that forwards a challenger +/// the share of the hold bond a VALID ruling returns. +constexpr std::string_view challenge_memo = "sysio.synd challenge"; +/// Memo of the transfer that pays `sysio` the WIRE the fee pot earned. +constexpr std::string_view fee_yield_memo = "sysio.synd fee pot yield"; + +/// Refusals of the signed actions. +constexpr std::string_view no_link_message = "account has no link for this chain"; +constexpr std::string_view holder_unlinked_msg = "holder is not AuthX-linked for the token's chain"; +constexpr std::string_view not_outpost_message = "chain_code is not an active outpost"; +constexpr std::string_view quantity_message = "quantity must be positive"; +constexpr std::string_view precision_message = "symbol precision mismatch"; +constexpr std::string_view no_symbol_message = "shadow symbol does not exist"; +constexpr std::string_view window_message = "importsynd is bootstrap-window only"; +constexpr std::string_view finalized_message = "import already finalized"; +constexpr std::string_view fee_bps_message = "fee must be at most 10000 basis points"; +constexpr std::string_view window_sec_message = "window_sec must be positive"; +constexpr std::string_view config_range_message = "bucket, bounty and challenge amounts must fit an asset"; +constexpr std::string_view challenger_role_msg = "the challenger cannot be sysio.synd or sysio.bond"; +constexpr std::string_view no_envelope_message = "envelope not found"; +constexpr std::string_view challenged_message = "the envelope's request is already challenged"; +constexpr std::string_view unchallengeable_msg = + "only a REQUESTED, RELEASABLE or DONE envelope can be challenged"; +constexpr std::string_view zero_charge_message = "challenge charge is zero; configure challenge_extra"; +constexpr std::string_view dropenv_state_message = + "only an OPEN or WAITING envelope with no request issued can be dropped"; +constexpr std::string_view no_request_message = "the envelope's request is not in sysio.bond"; +constexpr std::string_view request_final_message = + "the envelope's request is approved or ruled; it cannot be challenged"; +constexpr std::string_view charge_range_message = "hold bond plus challenge extra exceeds the asset range"; +constexpr std::string_view desynd_unset_message = "desyndication budget is not configured"; +constexpr std::string_view desynd_budget_message = "desyndication exceeds the current budget"; +constexpr std::string_view desynd_net_message = "desyndication net of the fee must be positive"; + +/// Diagnostic path names of the intake actions. +constexpr std::string_view onsynd_path = "onsynd"; +constexpr std::string_view onyield_path = "onyield"; +constexpr std::string_view closeenv_path = "closeenv"; + +/// Drop reasons, one per rule an intake message can fail. +constexpr std::string_view pubkey_misfit_reason = "pubkey does not fit the chain family"; +constexpr std::string_view no_shadow_reason = "token_code has no shadow symbol"; +constexpr std::string_view other_chain_reason = "token_code belongs to another chain"; +constexpr std::string_view chain_kind_reason = "chain_kind is not the outpost's chain family"; +constexpr std::string_view amount_range_reason = "amount out of range"; +constexpr std::string_view headroom_reason = "supply exceeds the asset range"; +constexpr std::string_view envelope_range_reason = + "the envelope's total, rounded up to the bond increment, would exceed the asset range"; +constexpr std::string_view envelope_yield_reason = "the envelope's yield would exceed the shadow's headroom"; +constexpr std::string_view envelope_closed_reason = "envelope already closed or of another digest"; +constexpr std::string_view replay_reason = "replayed sequence"; +constexpr std::string_view digest_reason = "envelope digest does not match; left OPEN"; + +/// Why one step of the queue left an envelope or an item where it was. +constexpr std::string_view no_bond_reason = "sysio.bond is not deployed; the envelope stays WAITING"; +constexpr std::string_view bond_token_reason = "sysio.bond cannot bond the token; the envelope stays WAITING"; +constexpr std::string_view covered_range_reason = "covered exceeds the asset range; the envelope stays WAITING"; +constexpr std::string_view missing_request_reason = "request row not found in sysio.bond; the pair waits"; +constexpr std::string_view unknown_state_reason = "request in a state the queue does not know; the pair waits"; +constexpr std::string_view dropped_reason = + "dropped by sysio: its items are burned and the envelope is INVALID"; +constexpr std::string_view invalid_burning_reason = + "request ruled INVALID; burning the envelope's items continues next step, the pair waits"; +constexpr std::string_view invalid_burned_reason = "request ruled INVALID; the envelope is burned and INVALID"; +constexpr std::string_view unset_bucket_reason = + "no syndconfig row: the syndication bucket is unset, nothing releases"; +constexpr std::string_view empty_bucket_reason = "syndication bucket is empty until it refills"; +constexpr std::string_view yield_headroom_reason = "yield exceeds the shadow's headroom; the item waits"; +constexpr std::string_view frozen_share_reason = + "the challenger's hold share waits for the andon cord to clear"; +constexpr std::string_view frozen_burn_reason = + "request ruled INVALID while the andon cord is pulled; the burn waits for the clear, the pair waits"; + +/// What a queue step prints once while the `sysio.andon` cord is pulled. +constexpr std::string_view frozen_step_note = + "sysio.synd::queue: the andon cord is pulled; releases, deliveries and burns wait for the clear\n"; +/// The solvency check's console verdicts and the reason a shortfall's pull carries. The pull reason is +/// `custody shortfall seq `, far below `andon::MAX_TEXT_BYTES`. +constexpr std::string_view excess_verdict = ": EXCESS -- reported "; +constexpr std::string_view shortfall_verdict = ": SHORTFALL -- reported "; +constexpr std::string_view outstanding_label = " outstanding "; +constexpr std::string_view shortfall_reason = "custody shortfall "; +constexpr std::string_view sequence_label = " seq "; +constexpr std::string_view reason_separator = " "; +constexpr std::string_view cord_pulled_note = ": CORD PULLED -- "; +constexpr std::string_view cord_already_note = ": CORD ALREADY PULLED -- the shortfall is recorded"; + + +/// What `linkswept` prints when the cord keeps it from delivering. +constexpr std::string_view frozen_linkswept_note = + "sysio.synd::linkswept: the andon cord is pulled; the parked balance waits for sweep after the clear\n"; + +/// Record that message `path` broke `reason` and was dropped. Prints, never throws. +void drop(std::string_view path, std::string_view reason) { + sysio::print("sysio.synd::", std::string(path), ": DROP -- ", std::string(reason), "\n"); +} + +/// The chain family of `chain_code` when it is an active outpost, else `std::nullopt`. Never throws. +std::optional outpost_kind(sysio::slug_name chain_code) { + return sysio::chains::outpost_kind_of(synd::CHAINS_ACCOUNT, chain_code); +} + +/// The checks every intake message shares: `token_code` has a shadow symbol on `sysio.liq`, it +/// mirrors a token of `chain_code`, and `amount` is positive, within the depot amount range and +/// within the shadow's headroom. Returns the shadow's `stat` row, or `std::nullopt` after a drop +/// diagnostic. Touches no row, so a dropped message consumes no sequence. Never throws. +std::optional resolve_intake(std::string_view path, sysio::slug_name chain_code, + sysio::slug_name token_code, uint64_t amount) { + const auto st = liq::find_stat_by_token(synd::LIQ_ACCOUNT, token_code); + if (!st) { + drop(path, no_shadow_reason); + return std::nullopt; + } + if (st->chain_code != chain_code) { + drop(path, other_chain_reason); + return std::nullopt; + } + if (amount == 0 || amount > static_cast(opp::safe::depot_amount_max)) { + drop(path, amount_range_reason); + return std::nullopt; + } + if (amount > liq::headroom_of(synd::LIQ_ACCOUNT, *st)) { + drop(path, headroom_reason); + return std::nullopt; + } + return st; +} + +/// The key of the envelope row of `(chain_code, token_code, epoch_index)`. +synd::envelope_key envelope_key_of(sysio::slug_name chain_code, sysio::slug_name token_code, uint32_t epoch_index) { + return synd::envelope_key{.chain_code = chain_code.value, .token_code = token_code.value, + .epoch_index = epoch_index}; +} + +/// True iff a message of the envelope `(chain_code, epoch_index, digest)` may still add to the row of +/// `token_code`: the row does not exist yet, or it is OPEN with the same digest. Never throws. +bool envelope_accepts(name self, sysio::slug_name chain_code, sysio::slug_name token_code, uint32_t epoch_index, + const checksum256& digest) { + const auto state = synd::ledger_t(self).try_get(synd::ledger_key{chain_code.value, token_code.value}); + if (state && epoch_index < state->retained_from_epoch) return false; + synd::envelopes_t envelopes(self); + const auto row = envelopes.try_get(envelope_key_of(chain_code, token_code, epoch_index)); + return !row || (row->state == synd::envelope_state::OPEN && row->digest == digest); +} + +/// `total` rounded up to a multiple of `increment`, or `std::nullopt` past the asset range. +std::optional round_up_to(uint128_t total, uint64_t increment) { + const uint128_t rounded = (total + increment - 1) / increment * increment; + if (rounded > max_asset_amount) return std::nullopt; + return static_cast(rounded); +} + +/// True iff the envelope row of `(chain_code, token_code, epoch_index)` can take one more item of +/// `synd_amount` syndicated and `yield_amount` yield base units of the shadow `st` and stay underwritable: +/// - its syndicated plus yield total, rounded up to the token's bond increment (`bond::increment_of`; +/// a token too coarse to bond is bounded unrounded), fits an `asset`, so `sysio.bond` can be asked +/// to cover it; +/// - for a yield report, its yield total fits the shadow's headroom now (`liq::headroom_of`), so the +/// yield is releasable unless the supply grows meanwhile (a release that finds it grown waits). +/// A message that fails is dropped with a diagnostic under `path`. Reads only; never throws. +bool envelope_fits(std::string_view path, name self, const liq::currency_stats& st, sysio::slug_name chain_code, + uint32_t epoch_index, uint64_t synd_amount, uint64_t yield_amount) { + synd::envelopes_t envelopes(self); + const auto row = envelopes.try_get(envelope_key_of(chain_code, st.token_code, epoch_index)); + const uint64_t synd_total = row ? row->synd_total : 0; + const uint64_t yield_total = row ? row->yield_total : 0; + const uint128_t total = static_cast(synd_total) + yield_total + synd_amount + yield_amount; + if (!round_up_to(total, bond::increment_of(st.supply.symbol).value_or(1))) { + drop(path, envelope_range_reason); + return false; + } + if (yield_amount > 0 && + static_cast(yield_total) + yield_amount > liq::headroom_of(synd::LIQ_ACCOUNT, st)) { + drop(path, envelope_yield_reason); + return false; + } + return true; +} + +/// Add one item of `synd_amount` syndicated and `yield_amount` yield base units to the envelope row of +/// `(chain_code, token_code, epoch_index)`, creating it OPEN with `digest` on the first. Saturating; +/// never throws. +void add_to_envelope(name self, sysio::slug_name chain_code, sysio::slug_name token_code, uint32_t epoch_index, + const checksum256& digest, uint64_t synd_amount, uint64_t yield_amount) { + synd::envelopes_t envelopes(self); + const synd::envelope_key key = envelope_key_of(chain_code, token_code, epoch_index); + synd::envelope_row row = envelopes.try_get(key).value_or(synd::envelope_row{ + .chain_code = chain_code, + .token_code = token_code, + .epoch_index = epoch_index, + .digest = digest, + }); + row.synd_total = opp::safe::add_sat_u64(row.synd_total, synd_amount); + row.yield_total = opp::safe::add_sat_u64(row.yield_total, yield_amount); + ++row.item_count; + envelopes.set(ram_payer, key, row); +} + +/// Ensure the pair has operational queue positions; later intake leaves existing cursors untouched. +void ensure_ledger(name self, sysio::slug_name chain_code, sysio::slug_name token_code) { + synd::ledger_t ledger(self); + const synd::ledger_key key{chain_code.value, token_code.value}; + if (!ledger.contains(key)) + ledger.set(ram_payer, key, synd::ledger_row{.chain_code = chain_code, .token_code = token_code}); +} + +/// The key of `item` in `items`: its envelope row, then its id. +synd::item_key item_key_of(const synd::item_row& item) { + return synd::item_key{.chain_code = item.chain_code.value, + .token_code = item.token_code.value, + .epoch_index = item.epoch_index, + .id = item.id}; +} + +/// Store `item` under the next item id, which it is given. Never throws. +void add_item(name self, synd::item_row item) { + synd::syndcounters_t counters(self); + synd::synd_counters c = counters.get_or_default(synd::synd_counters{}); + item.id = c.next_item_id++; + counters.set(c, ram_payer); + synd::items_t items(self); + items.set(ram_payer, item_key_of(item), item); +} + +/// The key of the parked row of `(token_code, chain_kind, pubkey)`. +synd::parked_key parked_key_of(sysio::slug_name token_code, ChainKind chain_kind, const std::vector& pubkey) { + return synd::parked_key{.token_code = token_code.value, + .chain_kind = static_cast(magic_enum::enum_integer(chain_kind)), + .pubkey = pubkey}; +} + +/// The account `pubkey` has linked on `chain_kind`, or an empty name. Never throws. +name linked_account(ChainKind chain_kind, const std::vector& pubkey) { + const auto pk = public_key_from_op_address(chain_kind, pubkey); + if (!pk) return name{}; // no link could hold these bytes + sysio::authex::links_t links(synd::AUTHEX_ACCOUNT); + auto by_pubkey = links.get_index<"bypubkey"_n>(); + const auto it = by_pubkey.find(pubkey_to_checksum256(*pk)); + return it == by_pubkey.end() ? name{} : it->username; +} + +/// The pubkey `account` has linked on `chain_kind`, or `std::nullopt`. Never throws. +std::optional> linked_pubkey(name account, ChainKind chain_kind) { + sysio::authex::links_t links(synd::AUTHEX_ACCOUNT); + auto by_namechain = links.get_index<"bynamechain"_n>(); + const auto it = by_namechain.find(to_namechain_key(account, chain_kind)); + if (it == by_namechain.end()) return std::nullopt; + return pubkey_to_bytes(it->pub_key); +} + +/// `account`'s `active` authority: this contract's own on every inline action it sends, and the +/// holder's on the transfer a desyndication makes on the holder's behalf. +permission_level active_of(name account) { return permission_level{account, active_permission}; } + +/// Credit `wire` WIRE this contract holds for `account` -- what a held or parked position banked and +/// the token's pool covered -- to `account`'s `sysio.liq` row of `sym` with `sysio.liq::creditowed`, +/// which moves the WIRE into `sysio.liq` in the same action. The account claims it through +/// `sysio.liq::claim`; no transfer is pushed to it, so an account that would refuse one cannot fail +/// the caller. Nothing for zero. +void credit_owed(name self, name account, symbol_code sym, uint64_t wire) { + if (wire == 0) return; + action(active_of(self), synd::LIQ_ACCOUNT, creditowed_action, std::make_tuple(account, sym, wire)).send(); +} + +/// True iff `account` exists and has contract code. Never throws. +bool has_code(name account) { return is_account(account) && get_code_hash(account) != checksum256{}; } + +/// Hold `amount` of the shadow `token_code` (symbol `sym`), already in this contract's own `sysio.liq` +/// row, in the parked row of `(chain_kind, pubkey)`, with `banked` WIRE the pubkey earned elsewhere in +/// this contract added to its position. The position settles at the live index before the balance +/// grows. Nothing is written when both are zero. Never throws. +void park_shadow(name self, sysio::slug_name token_code, symbol_code sym, ChainKind chain_kind, + const std::vector& pubkey, uint64_t amount, uint64_t banked) { + if (amount == 0 && banked == 0) return; + synd::parkeds_t parked(self); + const synd::parked_key key = parked_key_of(token_code, chain_kind, pubkey); + synd::parked_row row = parked.try_get(key).value_or(synd::parked_row{ + .token_code = token_code, + .chain_kind = chain_kind, + .pubkey = pubkey, + }); + custody::settle_and_adjust(row.position, row.balance, static_cast(amount), synd::LIQ_ACCOUNT, sym); + row.position.owed_wire = std::min(custody::MAX_OWED_WIRE, opp::safe::add_sat_u64(row.position.owed_wire, banked)); + parked.set(ram_payer, key, row); +} + +/// Add `amount` of the shadow `token_code` (symbol `sym`) to the token's fee pot, settling its position +/// first. Never throws. +void add_to_feepot(name self, sysio::slug_name token_code, symbol_code sym, uint64_t amount) { + if (amount == 0) return; + synd::feepots_t pots(self); + const synd::token_key key{token_code.value}; + synd::fee_row row = pots.try_get(key).value_or(synd::fee_row{.token_code = token_code}); + custody::settle_and_adjust(row.position, row.balance, static_cast(amount), synd::LIQ_ACCOUNT, sym); + pots.set(ram_payer, key, row); +} + +/// Take a bounty of at most `cap` base units of the shadow `token_code` (symbol `sym`) out of its fee +/// pot: what the pot holds when that is less, 0 when it is empty. Returns the bounty. Never throws. +uint64_t take_bounty(name self, sysio::slug_name token_code, symbol_code sym, uint64_t cap) { + if (cap == 0) return 0; + synd::feepots_t pots(self); + const synd::token_key key{token_code.value}; + auto pot = pots.try_get(key); + if (!pot || pot->balance == 0) return 0; + const uint64_t bounty = std::min(cap, pot->balance); + custody::settle_and_adjust(pot->position, pot->balance, -static_cast(bounty), synd::LIQ_ACCOUNT, sym); + pots.set(same_payer, key, *pot); + return bounty; +} + +/// `amount * bps / bond::BPS_DENOMINATOR` in 128 bits, floored: the fee on `amount`. At most `amount`, +/// because `setconfig` bounds `bps` by the denominator. +uint64_t fee_of(uint64_t amount, uint32_t bps) { + return static_cast(static_cast(amount) * bps / bond::BPS_DENOMINATOR); +} + +/// The key of the bucket of `(chain_code, token_code, direction)`. +synd::bucket_key bucket_key_of(sysio::slug_name chain_code, sysio::slug_name token_code, + synd::bucket_direction direction) { + return synd::bucket_key{.chain_code = chain_code.value, + .token_code = token_code.value, + .direction = magic_enum::enum_integer(direction)}; +} + +/// Refill by elapsed depot epochs, including freezes, capped at burst. Frozen +/// queue steps never spend capacity; the first step after clearing ticks lazily. +synd::bucket_row tick_bucket(name self, sysio::slug_name chain_code, sysio::slug_name token_code, + synd::bucket_direction direction, uint64_t burst, uint64_t refill, uint32_t epoch_index) { + synd::buckets_t buckets(self); + synd::bucket_row row = buckets.try_get(bucket_key_of(chain_code, token_code, direction)) + .value_or(synd::bucket_row{ + .chain_code = chain_code, + .token_code = token_code, + .direction = direction, + .level = burst, + .last_epoch = epoch_index, + }); + if (epoch_index > row.last_epoch) { + const uint64_t elapsed = epoch_index - row.last_epoch; + const uint128_t refilled = static_cast(row.level) + static_cast(refill) * elapsed; + row.level = refilled < burst ? static_cast(refilled) : burst; + row.last_epoch = epoch_index; + } + row.level = std::min(row.level, burst); + return row; +} + +/// Linked accounts receive a ledger credit, including contracts and this custodian itself. +/// No recipient code executes; only genuinely unlinked identities need parked principal. +name deliverable_account(name self, ChainKind chain_kind, const std::vector& pubkey) { + const name account = linked_account(chain_kind, pubkey); + return account != name{} && is_account(account) ? account : name{}; +} + +/// The statement `sysio.bond` underwrites for envelope `row`: its outpost, epoch, digest and token. +std::vector statement_of(const synd::envelope_row& row) { + return opp::envelope_statement::pack(opp::envelope_statement::statement{ + .chain_code = row.chain_code.value, + .epoch_index = row.epoch_index, + .digest = row.digest, + .token_code = row.token_code.value, + }); +} + +/// Resolve an envelope's stable issuer-and-statement identity through bond's public accessor. +std::optional find_request(name self, const synd::envelope_row& row) { + return bond::find_request(synd::BOND_ACCOUNT, self, opp::envelope_statement::schema, statement_of(row)); +} + +/// Snapshot a terminal outcome before acknowledging it. This function moves no funds and includes +/// DONE envelopes whose release finished before challenge finality. Settlement work rewinds release. +void synchronize(name self, synd::envelope_row& row) { + if (row.state == synd::envelope_state::OPEN || row.state == synd::envelope_state::WAITING || + row.outcome != synd::request_outcome::PENDING) return; + const auto req = find_request(self, row); + if (!req) return; + row.request_id = req->id; + using state = bond::request_state; + using outcome = synd::request_outcome; + if (req->state == state::APPROVED) row.outcome = outcome::APPROVED; + else if (req->state == state::VALID) { + row.outcome = outcome::VALID; + row.hold_share = bond::share_of(req->covered - req->bonded, req->hold_bond, req->covered); + row.hold_beneficiary = req->hold_beneficiary; + row.share_pending = row.hold_share > 0 && row.hold_beneficiary != name{}; + } else if (req->state == state::INVALID) { + row.outcome = outcome::INVALID; + row.forfeit = req->bonded; + row.bounty_returned = req->hold_beneficiary == name{} ? req->bounty : 0; + } else return; + if (row.outcome == outcome::INVALID || row.share_pending) { + if (row.state == synd::envelope_state::DONE) row.state = synd::envelope_state::HELD; + synd::ledger_t ledger(self); + const synd::ledger_key key{row.chain_code.value, row.token_code.value}; + auto lrow = ledger.try_get(key); + if (lrow && lrow->queue_epoch > row.epoch_index) { + lrow->queue_epoch = row.epoch_index; + ledger.set(ram_payer, key, *lrow); + } + } + action(active_of(self), synd::BOND_ACCOUNT, ack_action, std::make_tuple(req->id)).send(); +} + +/// Record that the queue step left `(chain_code, token_code, epoch_index)` where it was, for `reason`. +/// Prints, never throws. +void note(sysio::slug_name chain_code, sysio::slug_name token_code, uint32_t epoch_index, std::string_view reason) { + sysio::print("sysio.synd::queue: ", chain_code.to_string(), " ", token_code.to_string(), " epoch ", epoch_index, + " -- ", std::string(reason), "\n"); +} + +/// One bounded underwriting or release pass. Each owns its work/examination budget and pair cursor; +/// underwriting also uses a separate per-pair epoch cursor so release backlogs cannot stall issuance. +/// Both refresh reached outcomes before decisions. Only the release pass may move funds, and it waits +/// while frozen. Independent synchronization additionally covers envelopes already marked DONE. +class queue_step { +public: + /// Examinations a step may make per work unit of its budget. + static constexpr uint32_t EXAMINE_FACTOR = 4; + + /// A step of at most `limit` work units and `EXAMINE_FACTOR * limit` examinations, run by the contract + /// `self`. + queue_step(name self, uint32_t limit, bool underwriting = false) + : underwriting_(underwriting), self_(self), left_(limit), looks_(static_cast(std::min( + uint64_t{EXAMINE_FACTOR} * limit, std::numeric_limits::max()))), + epoch_(epoch::current_epoch_index()), frozen_(andon::pulled(andon::ANDON_ACCOUNT)) {} + + /// Run every shadow's pair, in shadow-symbol order from the pair the last step ran out on, wrapping + /// around, until the budget or the examination cap is spent. Every pair visited costs one examination + /// for its fixed reads, idle or not, so a step's reads are bounded by the cap whatever the number of + /// pairs. The next step starts at the pair this step runs out on when that pair released or burned + /// something, or when the cap ran out before it could look at any of its envelopes, and at the pair + /// after it otherwise: a pair that keeps moving holds the cursor until its backlog drains, and a pair + /// that moves nothing never holds it. + void run() { + if (frozen_) sysio::print(std::string(frozen_step_note)); + synd::syndstate_t state(self_); + synd::synd_state stored = state.get_or_default(synd::synd_state{}); + const uint64_t start = underwriting_ ? stored.underwriting_cursor : stored.queue_cursor; + liq::stats statstable(synd::LIQ_ACCOUNT); + std::optional stopped_at; + bool hold = false; // the next step starts at the pair it ran out on + for (auto it = statstable.lower_bound(liq::symbol_key{start}); it != statstable.end() && !spent(); ++it) { + const bool reached = run_pair(*it); + if (spent()) { + stopped_at = it.key().symbol_code; + hold = pair_.moved || !reached; + } + } + for (auto it = statstable.begin(); it != statstable.end() && !spent(); ++it) { + if (it.key().symbol_code >= start) break; // walked above + const bool reached = run_pair(*it); + if (spent()) { + stopped_at = it.key().symbol_code; + hold = pair_.moved || !reached; + } + } + if (!stopped_at) return; + uint64_t cursor = *stopped_at; + if (!hold) { + // The pair after it, or the first pair when it was the last: cursor 0 starts at the first. + const auto next = statstable.lower_bound(liq::symbol_key{cursor + 1}); + cursor = next != statstable.end() ? next.key().symbol_code : 0; + } + if (cursor != start) { + if (underwriting_) stored.underwriting_cursor = cursor; + else stored.queue_cursor = cursor; + state.set(stored, ram_payer); + } + } + +private: + /// What one pair's walk has found so far. + struct pair_state { + bool synd_stopped = false; ///< no further syndication of the pair releases + bool yield_stopped = false; ///< no further yield of the pair releases + bool moved = false; ///< an item of the pair was released or burned + uint64_t yield_reserved = 0; ///< headroom taken by this step's yield mints + std::optional pool; ///< the token's yield pool, once pulled + }; + + /// Spend one work unit; false when none is left. + bool spend() { + if (left_ == 0) return false; + --left_; + return true; + } + + /// Count one examination -- a pair visited, an envelope looked at, an item row read; false when the + /// cap is reached. + /// Separate from the budget, which it never touches. + bool look() { + if (looks_ == 0) return false; + --looks_; + return true; + } + + /// The step can do nothing more: its budget or its examination cap is spent. + bool spent() const { return left_ == 0 || looks_ == 0; } + + /// How many rows a walk may still collect: no more than it can pay for or look at. + uint32_t collectable() const { return std::min(left_, looks_); } + + /// Walk the envelopes of the pair of `st` from its ledger's `queue_epoch`, at most as many as the + /// budget and the examination cap leave: refresh each issued request, release RELEASABLE envelopes, + /// issue the oldest WAITING envelope's request when no earlier one is unbonded and unruled, and move + /// `queue_epoch` past the finished prefix. Each envelope looked at and each item row read counts one + /// examination. The refresh reads state only and spends no budget, so every issued request the walk + /// reaches has its outcome recorded, whatever the pair's flows; releasing, burning and requesting + /// spend the budget. Entry costs one examination, for the ledger and config rows and the first + /// envelope lookup. False when the cap ran out before the walk could look at an envelope the pair + /// has; true otherwise, an idle pair included. + bool run_pair(const liq::currency_stats& st) { + pair_ = pair_state{}; + look(); // run() visits a pair only while the cap has room, so this entry look always succeeds + synd::ledger_t ledger(self_); + const synd::ledger_key lkey{.chain_code = st.chain_code.value, .token_code = st.token_code.value}; + auto lrow = ledger.try_get(lkey); + if (!lrow) return true; // nothing was ever admitted for this pair + synd::syndconfig_t configs(self_); + const auto config = + configs.try_get(synd::config_key{.chain_code = lkey.chain_code, .token_code = lkey.token_code}); + + // Collected first: the walk rewrites the rows it visits. Nothing after the first WAITING + // envelope can move -- the rest is WAITING or still OPEN. + synd::envelopes_t envelopes(self_); + std::vector keys; + const uint32_t start_epoch = underwriting_ ? lrow->underwriting_epoch : lrow->queue_epoch; + auto first = envelopes.lower_bound(envelope_key_of(st.chain_code, st.token_code, start_epoch)); + const bool has_envelope = first != envelopes.end() && first.key().chain_code == lkey.chain_code && + first.key().token_code == lkey.token_code; + if (has_envelope && collectable() == 0) return false; // the cap ran out on the entry look + for (auto it = first; it != envelopes.end() && keys.size() < collectable(); ++it) { + const synd::envelope_key& key = it.key(); + if (key.chain_code != lkey.chain_code || key.token_code != lkey.token_code) break; + keys.push_back(key); + if (it->state == synd::envelope_state::WAITING) break; + } + + uint32_t queue_epoch = underwriting_ ? lrow->underwriting_epoch : lrow->queue_epoch; + bool finished = true; // every envelope walked so far is DONE or INVALID + bool unbonded = false; // an earlier request is neither bonded nor ruled: nothing moves past it + for (const auto& key : keys) { + if (!look()) break; + auto row = envelopes.try_get(key); + if (!row) continue; + const synd::envelope_state before = row->state; + const synd::request_outcome outcome = row->outcome; + const uint64_t request_id = row->request_id; + const uint64_t released = row->released; + const uint64_t burned = row->burned; + const bool pending = row->share_pending; + if (row->state == synd::envelope_state::REQUESTED || row->state == synd::envelope_state::RELEASABLE || + row->state == synd::envelope_state::HELD) { + refresh(*row, unbonded, st); + } + if (!underwriting_ && row->state == synd::envelope_state::RELEASABLE && !unbonded && !frozen_) { + release_items(*row, config, st); + } + if (underwriting_ && row->state == synd::envelope_state::WAITING && !unbonded && spend()) + issue_request(*row, config); + // The outcome's snapshots (`forfeit`, `bounty_returned`, `hold_share`, `hold_beneficiary`) are only + // ever written with the outcome; `share_pending` clears on its own when the forward is paid. + if (row->state != before || row->outcome != outcome || row->request_id != request_id || + row->released != released || row->burned != burned || row->share_pending != pending) { + envelopes.set(ram_payer, key, *row); + } + + const bool done = underwriting_ + ? (!unbonded && (row->outcome != synd::request_outcome::PENDING || + (row->state != synd::envelope_state::WAITING && + row->state != synd::envelope_state::OPEN && + row->state != synd::envelope_state::REQUESTED))) + : (row->state == synd::envelope_state::DONE || row->state == synd::envelope_state::INVALID); + if (finished && done) { + queue_epoch = key.epoch_index + 1; + } else { + finished = false; + } + } + if (queue_epoch != (underwriting_ ? lrow->underwriting_epoch : lrow->queue_epoch)) { + if (underwriting_) lrow->underwriting_epoch = queue_epoch; + else lrow->queue_epoch = queue_epoch; + ledger.set(ram_payer, lkey, *lrow); + } + return true; + } + + /// Refresh `row` from its `sysio.bond` request, found through the request's `bystatement` key. OPEN + /// keeps it REQUESTED; BONDED makes it RELEASABLE; HELD makes it HELD. The first time the request is + /// seen APPROVED, VALID or INVALID the outcome is recorded on `row` and the request row is never read + /// again: APPROVED and VALID make it RELEASABLE (VALID forwarding a challenger its share of the hold + /// bond), INVALID burns it (`invalidate`). Sets `unbonded` when the request is neither bonded nor + /// ruled -- OPEN, or HELD before it was fully bonded -- when no request row is found for an envelope + /// with no recorded outcome, while an INVALID envelope's burn is unfinished, and on any state it + /// does not know, where the items are left untouched. + void refresh(synd::envelope_row& row, bool& unbonded, const liq::currency_stats& st) { + using request_state = bond::request_state; + using outcome = synd::request_outcome; + synchronize(self_, row); + if (row.outcome == outcome::INVALID) { + if (!underwriting_) invalidate(row, st, unbonded); + return; + } + if (row.outcome != outcome::PENDING) { + if (!underwriting_ && row.share_pending) forward_hold_share(row, st); + row.state = synd::envelope_state::RELEASABLE; + return; + } + const auto req = find_request(self_, row); + if (!req) { + note(row.chain_code, row.token_code, row.epoch_index, missing_request_reason); + unbonded = true; + return; + } + row.request_id = req->id; + if (req->state == request_state::OPEN) { + row.state = synd::envelope_state::REQUESTED; + unbonded = true; + } else if (req->state == request_state::BONDED) { + row.state = synd::envelope_state::RELEASABLE; + } else if (req->state == request_state::HELD) { + row.state = synd::envelope_state::HELD; + if (req->bonded < req->covered) unbonded = true; + } else { + note(row.chain_code, row.token_code, row.epoch_index, unknown_state_reason); + unbonded = true; + } + } + + /// Forward the challenger the hold share snapshotted on VALID envelope `row` (`hold_share` to + /// `hold_beneficiary`), once, and clear `share_pending`: the share of the hold bond no bond covered, + /// which `sysio.bond` awards this contract, the issuer, and which the challenger paid. While the + /// `sysio.andon` cord is pulled it waits, pending, for the first step after the clear. The award is + /// claimed first while `sysio.bond` still owes it (a claim with nothing owed would throw; anyone may have + /// claimed it for this contract already, and a request pruned since was fully paid, so the share sits in + /// this contract's row either way), then credited by `sysio.liq::settle` without recipient execution. + void forward_hold_share(synd::envelope_row& row, const liq::currency_stats& st) { + if (frozen_) { + note(row.chain_code, row.token_code, row.epoch_index, frozen_share_reason); + return; + } + pull_first(st); + if (bond::settlement_due(synd::BOND_ACCOUNT, row.request_id, self_) > 0) send_claim(row.request_id); + action(active_of(self_), synd::LIQ_ACCOUNT, settle_action, + std::make_tuple(self_, row.hold_beneficiary, + asset{static_cast(row.hold_share), st.supply.symbol})).send(); + row.share_pending = false; + } + + /// Burn envelope `row`, whose request was ruled INVALID, from the forfeit and bounty snapshotted on it. + /// Every item of the envelope is erased in id order, one work unit each: a syndication's remainder is + /// burned with `sysio.liq::burn` (its position settled; the WIRE it earned stays in the pool as slack), + /// a yield report is dropped. While items are left the pair waits. Once none is, the forfeit -- every + /// bond, which `rslvinvalid` records for the issuer -- and the bounty returned when the request was + /// never held are pulled with `sysio.bond::claim` while sysio.bond still owes them to this contract + /// (a claim with nothing owed would throw; anyone may have claimed them for this contract already, + /// and a request row pruned since the snapshot was fully paid, so its tokens sit in this contract's + /// row). What the envelope released is burned out of the forfeit, and the rest, with the returned + /// bounty, goes to the fee pot. The envelope is INVALID and the pair moves on. The claimed tokens + /// arrive by the claim's inline transfer, which runs before the burn sent after it. + void invalidate(synd::envelope_row& row, const liq::currency_stats& st, bool& unbonded) { + // Burns and the claim that pulls the forfeit wait for the cord to clear; the outcome and its snapshot + // are already recorded, so nothing is lost if sysio.bond prunes the request meanwhile. + if (frozen_) { + note(row.chain_code, row.token_code, row.epoch_index, frozen_burn_reason); + unbonded = true; + return; + } + const symbol_code code = st.supply.symbol.code(); + pull_first(st); + synd::items_t items(self_); + bool emptied = true; + uint64_t unreleased = 0; + for (const auto& key : item_keys(row, emptied)) { + if (!look() || !spend()) { + emptied = false; + break; + } + auto item = items.try_get(key); + if (!item) continue; + if (item->kind == synd::item_kind::SYNDICATION) { + const uint64_t remaining = item->remaining; + custody::settle_and_adjust(item->position, item->remaining, -static_cast(remaining), + synd::LIQ_ACCOUNT, code); + unreleased = opp::safe::add_sat_u64(unreleased, remaining); + } + items.erase(key); + pair_.moved = true; + } + burn(row, unreleased); + if (!emptied) { + note(row.chain_code, row.token_code, row.epoch_index, invalid_burning_reason); + unbonded = true; + return; + } + + if (bond::settlement_due(synd::BOND_ACCOUNT, row.request_id, self_) > 0) send_claim(row.request_id); + const uint64_t repaid = std::min(row.released, row.forfeit); + burn(row, repaid); + add_to_feepot(self_, row.token_code, code, opp::safe::add_sat_u64(row.forfeit - repaid, row.bounty_returned)); + row.state = synd::envelope_state::INVALID; + note(row.chain_code, row.token_code, row.epoch_index, invalid_burned_reason); + } + + /// Burn `amount` of `row`'s token out of this contract's `sysio.liq` row, which holds it, and count + /// it in `row.burned`. Nothing for zero. + void burn(synd::envelope_row& row, uint64_t amount) { + if (amount == 0) return; + action(active_of(self_), synd::LIQ_ACCOUNT, burn_action, std::make_tuple(row.token_code, amount)).send(); + row.burned = opp::safe::add_sat_u64(row.burned, amount); + } + + /// Pull what `sysio.bond` owes this contract, as the issuer, on terminal request `request_id`. + void send_claim(uint64_t request_id) { + action(active_of(self_), synd::BOND_ACCOUNT, claim_action, std::make_tuple(request_id, self_)).send(); + } + + /// Ask `sysio.bond` to underwrite WAITING envelope `row`: every condition `request` checks is + /// verified first, so the inline action cannot fail; on a failure the envelope stays WAITING and the + /// reason is printed. The queued `syncenv` resolves the assigned id after request creation using the stable statement identity. + void issue_request(synd::envelope_row& row, const std::optional& config) { + if (!has_code(synd::BOND_ACCOUNT)) { + note(row.chain_code, row.token_code, row.epoch_index, no_bond_reason); + return; + } + const auto custody_token = + opp::custody::resolve_depot_native_token(synd::LIQ_ACCOUNT, synd::TOKEN_ACCOUNT, row.token_code); + const auto increment = custody_token ? bond::increment_of(custody_token->sym) : std::nullopt; + if (!increment) { + note(row.chain_code, row.token_code, row.epoch_index, bond_token_reason); + return; + } + const auto covered = + round_up_to(static_cast(row.synd_total) + static_cast(row.yield_total), *increment); + if (!covered) { + note(row.chain_code, row.token_code, row.epoch_index, covered_range_reason); + return; + } + std::vector statement = statement_of(row); + const uint32_t window_sec = config ? config->window_sec : synd::DEFAULT_WINDOW_SEC; + const auto refusal = bond::request_refusal(synd::BOND_ACCOUNT, self_, opp::envelope_statement::schema, + statement, row.token_code, *covered, 0, window_sec); + if (!refusal.empty()) { + note(row.chain_code, row.token_code, row.epoch_index, refusal); + return; + } + const uint64_t bounty = + take_bounty(self_, row.token_code, custody_token->sym.code(), config ? config->bounty : uint64_t{0}); + // sysio.bond pulls the bounty from this contract's liq row, where the fee pot's share of it sat. + action(active_of(self_), synd::BOND_ACCOUNT, requestkeep_action, + std::make_tuple(self_, opp::envelope_statement::schema, std::move(statement), row.token_code, *covered, + bounty, window_sec)) + .send(); + row.state = synd::envelope_state::REQUESTED; + action(active_of(self_), self_, syncenv_action, + std::make_tuple(row.chain_code, row.token_code, row.epoch_index)).send(); + } + + /// Release the items of RELEASABLE envelope `row` in id order while the budget lasts; the envelope + /// is DONE once none is left. An item of a flow that has stopped for this step is passed over + /// without spending: it cannot move until a later step. + void release_items(synd::envelope_row& row, const std::optional& config, + const liq::currency_stats& st) { + synd::items_t items(self_); + bool emptied = true; // every item of the envelope is gone + for (const auto& key : item_keys(row, emptied)) { + if (!look()) { + emptied = false; + break; + } + auto item = items.try_get(key); + if (!item) continue; + // The kind is checked first: a YIELD item holds no shadow and is never settled. + const bool is_yield = item->kind == synd::item_kind::YIELD; + if (is_yield ? pair_.yield_stopped : pair_.synd_stopped) { + emptied = false; + continue; + } + if (!spend()) { + emptied = false; + break; + } + const uint64_t remaining = item->remaining; + if (is_yield) { + release_yield(*item, row, st); + } else { + release_syndication(*item, row, config, st); + } + if (item->remaining != remaining) pair_.moved = true; + if (item->remaining == 0) { + items.erase(key); + } else { + emptied = false; + if (item->remaining != remaining) items.set(ram_payer, key, *item); + } + } + if (emptied) row.state = synd::envelope_state::DONE; + } + + /// The keys of envelope `row`'s items in id order, at most the budget and examinations left: collected + /// first, because the walk rewrites or erases the rows it visits. Clears `emptied` when items are left + /// past them. + std::vector item_keys(const synd::envelope_row& row, bool& emptied) { + synd::items_t items(self_); + const synd::item_key first{.chain_code = row.chain_code.value, + .token_code = row.token_code.value, + .epoch_index = row.epoch_index, + .id = 0}; + std::vector keys; + for (auto it = items.lower_bound(first); it != items.end(); ++it) { + const synd::item_key& key = it.key(); + if (key.chain_code != first.chain_code || key.token_code != first.token_code || + key.epoch_index != first.epoch_index) { + break; + } + if (keys.size() >= collectable()) { + emptied = false; + break; + } + keys.push_back(key); + } + return keys; + } + + /// Release one tranche of syndication `item`: tick the pair's bucket, take `min(remaining, level)`, + /// move its fee to the fee pot and the rest to the account the pubkey has linked now, with the WIRE + /// the item banked, or into `parked`. Stops the pair's syndications when the bucket is unset or + /// empty. + void release_syndication(synd::item_row& item, synd::envelope_row& row, + const std::optional& config, const liq::currency_stats& st) { + if (pair_.synd_stopped) return; + if (!config) { + note(item.chain_code, item.token_code, item.epoch_index, unset_bucket_reason); + pair_.synd_stopped = true; + return; + } + synd::bucket_row bucket = tick_bucket(self_, item.chain_code, item.token_code, + synd::bucket_direction::SYNDICATION, config->synd_burst, + config->synd_refill, epoch_); + const uint64_t tranche = std::min(item.remaining, bucket.level); + bucket.level -= tranche; + synd::buckets_t(self_).set(ram_payer, + bucket_key_of(item.chain_code, item.token_code, synd::bucket_direction::SYNDICATION), + bucket); + if (tranche == 0) { + note(item.chain_code, item.token_code, item.epoch_index, empty_bucket_reason); + pair_.synd_stopped = true; + return; + } + + const uint64_t fee = fee_of(tranche, config->synd_fee_bps); + const uint64_t net = tranche - fee; + const symbol sym = st.supply.symbol; + const symbol_code code = sym.code(); + const name account = deliverable_account(self_, item.chain_kind, item.pubkey); + if (account != name{}) { + // Pull first -- nothing this release queues may touch this contract's liq row ahead of the + // claim (custody obligation 3) -- then take the item's banked WIRE out of what the pool covers. + synd::pool_row& pool = pulled_pool(st); + const uint64_t banked = custody::settle_and_take(item.position, item.remaining, pool.pool, + synd::LIQ_ACCOUNT, code); + synd::yieldpools_t(self_).set(ram_payer, synd::token_key{st.token_code.value}, pool); + custody::settle_and_adjust(item.position, item.remaining, -static_cast(tranche), synd::LIQ_ACCOUNT, + code); + // What the pool could not cover stays banked on the item for its next tranche; once the item is + // released whole it would be erased with it, so it moves to the pubkey's parked row instead, + // which a later `sweep` pays once the pool covers it. + if (item.remaining == 0 && item.position.owed_wire > 0) { + park_shadow(self_, item.token_code, code, item.chain_kind, item.pubkey, 0, item.position.owed_wire); + item.position.owed_wire = 0; + } + if (net > 0) { + action(active_of(self_), synd::LIQ_ACCOUNT, settle_action, + std::make_tuple(self_, account, asset{static_cast(net), sym})) + .send(); + } + credit_owed(self_, account, code, banked); + } else { + // Parked: the shadow stays in this contract's row, and the WIRE the item banked moves with it. + custody::settle_and_adjust(item.position, item.remaining, -static_cast(tranche), synd::LIQ_ACCOUNT, + code); + const uint64_t banked = item.position.owed_wire; + item.position.owed_wire = 0; + park_shadow(self_, item.token_code, code, item.chain_kind, item.pubkey, net, banked); + } + add_to_feepot(self_, item.token_code, code, fee); + row.released = opp::safe::add_sat_u64(row.released, tranche); + } + + /// Release yield `item` whole into `sysio.liq`'s pending yield by `mintyield`: no fee and no + /// budget. When the shadow's headroom, net of this step's earlier yield, cannot take it, the item + /// stays and the pair's yields stop until a later step. + void release_yield(synd::item_row& item, synd::envelope_row& row, const liq::currency_stats& st) { + if (pair_.yield_stopped) return; + const uint64_t room = liq::headroom_of(synd::LIQ_ACCOUNT, st); + const uint64_t free = room > pair_.yield_reserved ? room - pair_.yield_reserved : 0; + if (item.remaining > free) { + note(item.chain_code, item.token_code, item.epoch_index, yield_headroom_reason); + pair_.yield_stopped = true; + return; + } + action(active_of(self_), synd::LIQ_ACCOUNT, mintyield_action, + std::make_tuple(item.chain_code, item.token_code, item.remaining)) + .send(); + pair_.yield_reserved += item.remaining; + row.released = opp::safe::add_sat_u64(row.released, item.remaining); + item.remaining = 0; + } + + /// Pull the pair's yield pool, once per step, and store it: before a path that sends nothing else + /// to the pool queues an action that touches this contract's liq row of the token (custody + /// obligation 3). + void pull_first(const liq::currency_stats& st) { + synd::yieldpools_t(self_).set(ram_payer, synd::token_key{st.token_code.value}, pulled_pool(st)); + } + + /// The yield pool of the pair's token, pulled once per step before anything the step queues + /// touches this contract's liq row of the token. + synd::pool_row& pulled_pool(const liq::currency_stats& st) { + if (!pair_.pool) { + synd::pool_row pool = synd::yieldpools_t(self_) + .try_get(synd::token_key{st.token_code.value}) + .value_or(synd::pool_row{.token_code = st.token_code}); + custody::pull(pool.pool, synd::LIQ_ACCOUNT, self_, st.supply.symbol.code()); + pair_.pool = pool; + } + return *pair_.pool; + } + + bool underwriting_; ///< issuance pass independent of release budget and cursor + name self_; ///< this contract + uint32_t left_; ///< work units left + uint32_t looks_; ///< examinations left + uint32_t epoch_; ///< the depot epoch the buckets tick to + bool frozen_; ///< the `sysio.andon` cord is pulled: nothing leaves or burns + pair_state pair_; ///< the pair being walked + +}; + +} // anonymous namespace + +// --------------------------------------------------------------------------- +// Intake +// --------------------------------------------------------------------------- + +void synd::onsynd(sysio::slug_name chain_code, uint32_t epoch_index, checksum256 digest, uint64_t sequence, + ChainKind chain_kind, std::vector pubkey, sysio::slug_name token_code, uint64_t amount, + uint64_t total_syndicated) { + require_auth(MSGCH_ACCOUNT); + if (!pubkey_fits(chain_kind, pubkey)) { + drop(onsynd_path, pubkey_misfit_reason); + return; + } + const auto st = resolve_intake(onsynd_path, chain_code, token_code, amount); + if (!st) return; + const auto kind = outpost_kind(chain_code); + if (!kind || *kind != chain_kind) { + drop(onsynd_path, chain_kind_reason); + return; + } + if (!envelope_accepts(get_self(), chain_code, token_code, epoch_index, digest)) { + drop(onsynd_path, envelope_closed_reason); + return; + } + if (!envelope_fits(onsynd_path, get_self(), *st, chain_code, epoch_index, amount, 0)) return; + // Every check is behind us: the sequence is consumed only by a message that is held. + if (!admit_sequence(chain_code, sequence, 0)) { + drop(onsynd_path, replay_reason); + return; + } + // The carried custody already holds this syndication: compare it with the outstanding after its mint. + // resolve_intake bounded the amount by the headroom, so the sum stays within the asset range. + check_custody(onsynd_path, *st, chain_code, epoch_index, sequence, item_kind::SYNDICATION, total_syndicated, + liq::outstanding_of(LIQ_ACCOUNT, *st) + amount); + + // The position is settled at balance 0 first, so it starts at the live index: the item earns + // exactly what the shadow minted into this contract's row below earns, from now on. + item_row item{ + .chain_code = chain_code, + .token_code = token_code, + .epoch_index = epoch_index, + .kind = item_kind::SYNDICATION, + .chain_kind = chain_kind, + .pubkey = std::move(pubkey), + .amount = amount, + }; + custody::settle_and_adjust(item.position, item.remaining, static_cast(amount), LIQ_ACCOUNT, + st->supply.symbol.code()); + add_item(get_self(), std::move(item)); + add_to_envelope(get_self(), chain_code, token_code, epoch_index, digest, amount, 0); + ensure_ledger(get_self(), chain_code, token_code); + + // resolve_intake verified every condition `mint` checks: the shadow exists and the amount is + // positive and within its headroom. This contract is an account, so the mint cannot fail. + action(active_of(get_self()), LIQ_ACCOUNT, mint_action, std::make_tuple(get_self(), token_code, amount)).send(); +} + +void synd::onyield(sysio::slug_name chain_code, uint32_t epoch_index, checksum256 digest, uint64_t sequence, + uint64_t outpost_epoch, sysio::slug_name token_code, uint64_t amount, + uint64_t total_syndicated) { + require_auth(MSGCH_ACCOUNT); + const auto st = resolve_intake(onyield_path, chain_code, token_code, amount); + if (!st) return; + if (!envelope_accepts(get_self(), chain_code, token_code, epoch_index, digest)) { + drop(onyield_path, envelope_closed_reason); + return; + } + if (!envelope_fits(onyield_path, get_self(), *st, chain_code, epoch_index, 0, amount)) return; + if (!admit_sequence(chain_code, sequence, outpost_epoch)) { + drop(onyield_path, replay_reason); + return; + } + // The report is held and mints nothing yet: compare with the outstanding as it stands. + check_custody(onyield_path, *st, chain_code, epoch_index, sequence, item_kind::YIELD, total_syndicated, + liq::outstanding_of(LIQ_ACCOUNT, *st)); + + // A number only: no shadow is minted for it. Its position still checkpoints the live index, settled + // at balance 0, so a walk that settled it by mistake would credit nothing rather than the index's + // whole history; every item walk checks the kind first all the same. + item_row item{ + .chain_code = chain_code, + .token_code = token_code, + .epoch_index = epoch_index, + .kind = item_kind::YIELD, + .amount = amount, + .remaining = amount, + }; + uint64_t no_shadow = 0; + custody::settle_and_adjust(item.position, no_shadow, 0, LIQ_ACCOUNT, st->supply.symbol.code()); + add_item(get_self(), std::move(item)); + add_to_envelope(get_self(), chain_code, token_code, epoch_index, digest, 0, amount); + ensure_ledger(get_self(), chain_code, token_code); +} + +void synd::closeenv(sysio::slug_name chain_code, uint32_t epoch_index, checksum256 digest) { + require_auth(MSGCH_ACCOUNT); + // One envelope row per token at most: walk the outpost's shadow tokens, a set bounded by the + // registry, rather than every envelope row the outpost ever produced. + liq::stats statstable(LIQ_ACCOUNT); + envelopes_t envelopes(get_self()); + for (auto it = statstable.begin(); it != statstable.end(); ++it) { + if (it->chain_code != chain_code) continue; + const envelope_key key = envelope_key_of(chain_code, it->token_code, epoch_index); + auto row = envelopes.try_get(key); + if (!row || row->state != envelope_state::OPEN) continue; + if (row->digest != digest) { + drop(closeenv_path, digest_reason); + continue; + } + row->state = envelope_state::WAITING; + envelopes.set(ram_payer, key, *row); + } + crank(CLOSEENV_QUEUE_LIMIT); +} + +// --------------------------------------------------------------------------- +// Configuration and the queue +// --------------------------------------------------------------------------- + +void synd::setconfig(sysio::slug_name chain_code, sysio::slug_name token_code, uint32_t synd_fee_bps, + uint32_t desynd_fee_bps, uint64_t synd_burst, uint64_t synd_refill, uint64_t desynd_burst, + uint64_t desynd_refill, uint32_t window_sec, uint64_t bounty, uint64_t challenge_extra) { + require_auth(SYSTEM_ACCOUNT); + check(outpost_kind(chain_code).has_value(), not_outpost_message.data()); + const auto st = liq::find_stat_by_token(LIQ_ACCOUNT, token_code); + check(st.has_value(), no_shadow_reason.data()); + check(st->chain_code == chain_code, other_chain_reason.data()); + check(synd_fee_bps <= bond::BPS_DENOMINATOR && desynd_fee_bps <= bond::BPS_DENOMINATOR, fee_bps_message.data()); + check(window_sec > 0, window_sec_message.data()); + for (const uint64_t amount : { synd_burst, synd_refill, desynd_burst, desynd_refill, bounty, challenge_extra }) { + check(amount <= max_asset_amount, config_range_message.data()); + } + + syndconfig_t configs(get_self()); + configs.set(ram_payer, config_key{.chain_code = chain_code.value, .token_code = token_code.value}, + synd_config{ + .chain_code = chain_code, + .token_code = token_code, + .synd_fee_bps = synd_fee_bps, + .desynd_fee_bps = desynd_fee_bps, + .synd_burst = synd_burst, + .synd_refill = synd_refill, + .desynd_burst = desynd_burst, + .desynd_refill = desynd_refill, + .window_sec = window_sec, + .bounty = bounty, + .challenge_extra = challenge_extra, + }); +} + +void synd::crank(uint32_t limit) { + // Permissionless and never throwing: a step only moves what the rules already allow. + sync(limit); + queue_step(get_self(), limit).run(); + queue_step(get_self(), limit, true).run(); +} + +void synd::syncenv(sysio::slug_name chain_code, sysio::slug_name token_code, uint32_t epoch_index) { + envelopes_t envelopes(get_self()); + const auto key = envelope_key_of(chain_code, token_code, epoch_index); + auto row = envelopes.try_get(key); + if (!row) return; + synchronize(get_self(), *row); + envelopes.set(ram_payer, key, *row); +} + +void synd::sync(uint32_t limit) { + if (limit == 0) return; + syndstate_t state(get_self()); + auto stored = state.get_or_default(synd_state{}); + envelopes_t envelopes(get_self()); + std::vector keys; + auto it = envelopes.lower_bound(stored.sync_cursor); + if (it == envelopes.end()) it = envelopes.begin(); + for (; it != envelopes.end() && keys.size() < limit; ++it) keys.push_back(it.key()); + const auto next = it == envelopes.end() ? envelope_key{} : it.key(); + if (next.chain_code != stored.sync_cursor.chain_code || next.token_code != stored.sync_cursor.token_code || + next.epoch_index != stored.sync_cursor.epoch_index) { + stored.sync_cursor = next; + state.set(stored, ram_payer); + } + for (const auto& key : keys) syncenv(sysio::slug_name{key.chain_code}, sysio::slug_name{key.token_code}, key.epoch_index); +} + +void synd::pruneenv(sysio::slug_name chain_code, sysio::slug_name token_code, uint32_t limit) { + ledger_t ledger(get_self()); + const ledger_key lkey{chain_code.value, token_code.value}; + auto state = ledger.try_get(lkey); + if (!state || limit == 0) return; + envelopes_t envelopes(get_self()); + items_t items(get_self()); + bond::requests_t requests(BOND_ACCOUNT); + auto it = envelopes.lower_bound(envelope_key_of(chain_code, token_code, 0)); + uint32_t examined = 0; + while (it != envelopes.end() && examined++ < limit) { + const auto row = *it; + if (row.chain_code != chain_code || row.token_code != token_code) break; + if (row.state != envelope_state::DONE && row.state != envelope_state::INVALID) break; + if (row.share_pending) break; + const bool dropped = row.state == envelope_state::INVALID && row.request_id == 0; + if (!dropped && row.outcome == request_outcome::PENDING) break; + const auto item = items.lower_bound(item_key{chain_code.value, token_code.value, row.epoch_index, 0}); + if (item != items.end() && item->chain_code == chain_code && item->token_code == token_code && + item->epoch_index == row.epoch_index) break; + const auto request = requests.try_get(bond::request_key{row.request_id}); + if (request && !request->outcome_acknowledged) break; + state->retained_from_epoch = uint64_t{row.epoch_index} + 1; + it = envelopes.erase(it); + } + ledger.set(ram_payer, lkey, *state); +} + +void synd::reconcile(sysio::slug_name chain_code, sysio::slug_name token_code, uint64_t reported) { + require_auth(SYSTEM_ACCOUNT); + const auto st = liq::find_stat_by_token(LIQ_ACCOUNT, token_code); + check(st.has_value() && st->chain_code == chain_code, outpost_token_not_found_msg); + mismatches_t incidents(get_self()); + const mismatch_key key{chain_code.value, token_code.value}; + check(incidents.contains(key), custody_incident_not_found_msg); + check(reported >= liq::outstanding_of(LIQ_ACCOUNT, *st), custody_still_below_outstanding_msg); + incidents.erase(key); +} + +void synd::finishreturn(uint64_t request_id) { + require_auth(SYSTEM_ACCOUNT); + returns_t pending(get_self()); + const return_key key{request_id}; + check(pending.contains(key), outstanding_return_not_found_msg); + pending.erase(key); +} + +void synd::refundreturn(uint64_t request_id) { + require_auth(SYSTEM_ACCOUNT); + returns_t pending(get_self()); + const return_key key{request_id}; + const auto row = pending.try_get(key); + check(row.has_value(), outstanding_return_not_found_msg); + // The erase and mint share one transaction: any mint failure restores the outstanding obligation. + pending.erase(key); + action(active_of(get_self()), LIQ_ACCOUNT, mint_action, + std::make_tuple(row->holder, row->token_code, row->amount)).send(); +} + +// dropenv - the escape hatch for an envelope the queue can never request. +// +// Every SYNDICATION item's remainder is burned out of this contract's liq row in one burn (the WIRE the +// items earned stays in the pool as slack), every item is erased, and the envelope +// is INVALID: the queue's next step counts it finished and moves `queue_epoch` past it. Nothing is +// pulled here, so the burn may touch this contract's liq row freely. +void synd::dropenv(sysio::slug_name chain_code, sysio::slug_name token_code, uint32_t epoch_index) { + require_auth(SYSTEM_ACCOUNT); + andon::check_clear(andon::ANDON_ACCOUNT); // a burn: waits for the cord to clear + envelopes_t envelopes(get_self()); + const envelope_key key = envelope_key_of(chain_code, token_code, epoch_index); + auto row = envelopes.try_get(key); + check(row.has_value(), no_envelope_message.data()); + check((row->state == envelope_state::OPEN || row->state == envelope_state::WAITING) && row->request_id == 0, + dropenv_state_message.data()); + check(liq::find_stat_by_token(LIQ_ACCOUNT, token_code).has_value(), no_shadow_reason.data()); + + items_t items(get_self()); + const item_key first{.chain_code = chain_code.value, .token_code = token_code.value, .epoch_index = epoch_index, + .id = 0}; + uint64_t unreleased = 0; + for (auto it = items.lower_bound(first); it != items.end();) { + const item_key& ikey = it.key(); + if (ikey.chain_code != first.chain_code || ikey.token_code != first.token_code || + ikey.epoch_index != first.epoch_index) { + break; + } + if (it->kind == item_kind::SYNDICATION) unreleased = opp::safe::add_sat_u64(unreleased, it->remaining); + it = items.erase(it); + } + if (unreleased > 0) { + action(active_of(get_self()), LIQ_ACCOUNT, burn_action, std::make_tuple(token_code, unreleased)).send(); + } + row->burned = opp::safe::add_sat_u64(row->burned, unreleased); + row->state = envelope_state::INVALID; + envelopes.set(same_payer, key, *row); + note(chain_code, token_code, epoch_index, dropped_reason); +} + +void synd::challenge(name challenger, sysio::slug_name chain_code, sysio::slug_name token_code, + uint32_t epoch_index) { + require_auth(challenger); + check(challenger != get_self() && challenger != BOND_ACCOUNT, challenger_role_msg.data()); + + envelopes_t envelopes(get_self()); + const envelope_key key = envelope_key_of(chain_code, token_code, epoch_index); + auto row = envelopes.try_get(key); + check(row.has_value(), no_envelope_message.data()); + check(row->state != envelope_state::HELD, challenged_message.data()); + // A DONE envelope is challengeable too: its request may still be only BONDED, its window open, and + // the bond exists exactly so that what was released can be taken back. + check(row->state == envelope_state::REQUESTED || row->state == envelope_state::RELEASABLE || + row->state == envelope_state::DONE, + unchallengeable_msg.data()); + + // sysio.bond holds only an OPEN or BONDED request, so a second hold fails its state check; mirrored + // here, before anything is charged, in this contract's words. + const auto req = find_request(get_self(), *row); + check(req.has_value(), no_request_message.data()); + check(req->state != bond::request_state::HELD, challenged_message.data()); + check(req->state == bond::request_state::OPEN || req->state == bond::request_state::BONDED, + request_final_message.data()); + + const auto st = liq::find_stat_by_token(LIQ_ACCOUNT, token_code); + check(st.has_value(), no_shadow_reason.data()); + const uint32_t hold_bps = bond::bondconfig_t(BOND_ACCOUNT).get_or_default(bond::bond_config{}).hold_bps; + const uint64_t hold_bond = bond::hold_bond_of(req->covered, hold_bps); + syndconfig_t configs(get_self()); + const auto config = configs.try_get(config_key{.chain_code = chain_code.value, .token_code = token_code.value}); + const uint64_t extra = config ? config->challenge_extra : 0; + check(extra <= max_asset_amount - hold_bond, charge_range_message.data()); + const uint64_t charge = hold_bond + extra; + check(charge > 0, zero_charge_message.data()); + + // The extra stays in the fee pot; its position settles before the charge that backs it arrives. + add_to_feepot(get_self(), token_code, st->supply.symbol.code(), extra); + action(active_of(challenger), LIQ_ACCOUNT, transfer_action, + std::make_tuple(challenger, get_self(), asset{static_cast(charge), st->supply.symbol}, + std::string{challenge_memo})) + .send(); + // sysio.bond pulls the hold bond back out of this contract's row, where the charge put it. + action(active_of(get_self()), BOND_ACCOUNT, hold_action, std::make_tuple(req->id, challenger)).send(); + + row->state = envelope_state::HELD; + envelopes.set(same_payer, key, *row); + + // The queue may already have moved past a DONE envelope: rewind it, so the next step refreshes the + // held envelope and processes the ruling. + ledger_t ledger(get_self()); + const ledger_key lkey{.chain_code = chain_code.value, .token_code = token_code.value}; + auto lrow = ledger.try_get(lkey); + if (lrow && lrow->queue_epoch > epoch_index) { + lrow->queue_epoch = epoch_index; + ledger.set(same_payer, lkey, *lrow); + } +} + +// sweepyield - pull what sysio.liq owes this contract's row into the token's pool, and pay out the fee +// pot's share. +// +// The fee pot is protocol revenue, so the WIRE its position earned is the protocol's: it is taken out of +// the pool, as far as the pool covers it, and sent to `sysio`. The pull is queued first (custody +// obligation 3), so the WIRE the transfer sends has arrived by the time it runs. Caller-signed, so the +// checked pull and a plain transfer are allowed here. +void synd::sweepyield(sysio::slug_name token_code) { + andon::check_clear(andon::ANDON_ACCOUNT); // pays the fee pot's WIRE out to sysio + const auto st = liq::find_stat_by_token(LIQ_ACCOUNT, token_code); + check(st.has_value(), no_shadow_reason.data()); + const symbol_code code = st->supply.symbol.code(); + yieldpools_t pools(get_self()); + const token_key key{token_code.value}; + pool_row pool = pools.try_get(key).value_or(pool_row{.token_code = token_code}); + const bool pulled = custody::pull(pool.pool, LIQ_ACCOUNT, get_self(), code) > 0; + + feepots_t pots(get_self()); + auto pot = pots.try_get(key); + uint64_t fee_wire = 0; + if (pot) { + fee_wire = custody::settle_and_take(pot->position, pot->balance, pool.pool, LIQ_ACCOUNT, code); + pots.set(same_payer, key, *pot); + } + if (pulled || fee_wire > 0) pools.set(ram_payer, key, pool); + if (fee_wire > 0) { + action(active_of(get_self()), TOKEN_ACCOUNT, transfer_action, + std::make_tuple(get_self(), SYSTEM_ACCOUNT, asset{static_cast(fee_wire), liq::WIRE_SYM}, + std::string{fee_yield_memo})) + .send(); + } +} + +// --------------------------------------------------------------------------- +// The parked hold +// --------------------------------------------------------------------------- + +void synd::sweep(name account, ChainKind chain_kind) { + andon::check_clear(andon::ANDON_ACCOUNT); + const auto pubkey = linked_pubkey(account, chain_kind); + check(pubkey.has_value(), no_link_message.data()); + deliver_parked(account, chain_kind, *pubkey); +} + +void synd::linkswept(name account, ChainKind chain_kind, std::vector pubkey) { + require_auth(AUTHEX_ACCOUNT); + // Inline from the user's own createlink: anything short of a deliverable pubkey returns quietly + // rather than abort the link. + if (!is_account(account) || !pubkey_fits(chain_kind, pubkey)) return; + // Never abort the link: while the cord is pulled the parked balance stays for `sweep` after the clear. + if (andon::pulled(andon::ANDON_ACCOUNT)) { + sysio::print(std::string(frozen_linkswept_note)); + return; + } + deliver_parked(account, chain_kind, pubkey); +} + +// --------------------------------------------------------------------------- +// Desyndication +// --------------------------------------------------------------------------- + +void synd::desyndicate(name holder, asset quantity) { + require_auth(holder); + andon::check_clear(andon::ANDON_ACCOUNT); + check(quantity.is_valid() && quantity.amount > 0, quantity_message.data()); + liq::stats statstable(LIQ_ACCOUNT); + const liq::currency_stats st = statstable.get(liq::symbol_key{quantity.symbol.code().raw()}, + no_symbol_message.data()); + check(quantity.symbol == st.supply.symbol, precision_message.data()); + + const auto kind = outpost_kind(st.chain_code); + check(kind.has_value(), not_outpost_message.data()); + const auto pubkey = linked_pubkey(holder, *kind); + check(pubkey.has_value(), holder_unlinked_msg.data()); + + // The pair's desyndication bucket, ticked to the depot's current epoch, must hold the whole quantity; + // it drops by the whole quantity, the fee included. + syndconfig_t configs(get_self()); + const auto config = configs.try_get(config_key{.chain_code = st.chain_code.value, + .token_code = st.token_code.value}); + check(config.has_value(), desynd_unset_message.data()); + const uint64_t quantity_units = static_cast(quantity.amount); + bucket_row bucket = tick_bucket(get_self(), st.chain_code, st.token_code, bucket_direction::DESYNDICATION, + config->desynd_burst, config->desynd_refill, epoch::current_epoch_index()); + check(quantity_units <= bucket.level, desynd_budget_message.data()); + bucket.level -= quantity_units; + buckets_t(get_self()).set(ram_payer, bucket_key_of(st.chain_code, st.token_code, bucket_direction::DESYNDICATION), + bucket); + + // The fee stays in this contract as part of the fee pot; only the rest is burned and released on the + // outpost. A fee that rounds to zero leaves the whole quantity to burn. + const uint64_t fee = fee_of(quantity_units, config->desynd_fee_bps); + const uint64_t amount = quantity_units - fee; + check(amount > 0, desynd_net_message.data()); + // The depot's outstanding shadow once the burn below has run, read from `st` before it: the burn is + // inline, so nothing has moved supply yet. The net was part of the holder's balance, so it is within + // the supply and the difference cannot underflow. + const uint64_t total_syndicated = liq::outstanding_of(LIQ_ACCOUNT, st) - amount; + + // The fee pot's position settles before the transfer that backs the fee arrives; no distribution runs + // between this action and its inline transfer, so both settle at one index. The quantity moves to this + // contract under the holder's authority and the net is burned out of this contract's row. sysio.liq + // settles both rows first, so the holder keeps the yield it earned. + add_to_feepot(get_self(), st.token_code, st.supply.symbol.code(), fee); + action(active_of(holder), LIQ_ACCOUNT, transfer_action, + std::make_tuple(holder, get_self(), quantity, std::string{desyndicate_memo})).send(); + action(active_of(get_self()), LIQ_ACCOUNT, burn_action, std::make_tuple(st.token_code, amount)).send(); + + syndcounters_t counters(get_self()); + synd_counters c = counters.get_or_default(synd_counters{}); + check(c.next_request_id < std::numeric_limits::max(), return_request_id_space_exhausted_msg); + const uint64_t request_id = c.next_request_id++; + counters.set(c, ram_payer); + + returns_t(get_self()).set(ram_payer, return_key{request_id}, return_row{ + .request_id = request_id, + .holder = holder, + .chain_code = st.chain_code, + .token_code = st.token_code, + .chain_kind = *kind, + .pubkey = *pubkey, + .amount = amount, + }); + + opp::attestations::DesyndicateLIQ msg; + msg.chain_code = st.chain_code.value; + msg.user = opp::types::ChainAddress{*kind, *pubkey}; + msg.amount = opp::types::TokenAmount{st.token_code.value, static_cast(amount)}; + msg.request_id = request_id; + msg.total_syndicated = total_syndicated; + // `no_size{}`: raw protobuf bytes, the form the outpost decodes the attestation `data` field as + // (the same encoding sysio.opreg's emitters use). + std::vector encoded; + auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; + (void)out(msg); + + action(active_of(get_self()), MSGCH_ACCOUNT, queueout_action, + std::make_tuple(st.chain_code.value, AttestationType::ATTESTATION_TYPE_DESYNDICATE_LIQ, encoded)) + .send(); +} + +// --------------------------------------------------------------------------- +// Launch ingestion +// --------------------------------------------------------------------------- + +void synd::importsynd(sysio::slug_name chain_code, sysio::slug_name token_code, std::vector credits) { + opp::require_privileged_self(); + check(epoch::in_bootstrap_window(), window_message.data()); + syndstate_t state(get_self()); + check(!state.get_or_default(synd_state{}).import_complete, finalized_message.data()); + const auto st = liq::find_stat_by_token(LIQ_ACCOUNT, token_code); + check(st.has_value(), no_shadow_reason.data()); + check(st->chain_code == chain_code, other_chain_reason.data()); + const auto kind = outpost_kind(chain_code); + check(kind.has_value(), not_outpost_message.data()); + + for (const auto& credit : credits) { + check(pubkey_fits(*kind, credit.pubkey), pubkey_misfit_reason.data()); + if (credit.amount == 0) continue; + check(credit.amount <= static_cast(opp::safe::depot_amount_max), amount_range_reason.data()); + credit_by_pubkey(token_code, st->supply.symbol.code(), *kind, credit.pubkey, credit.amount); + } +} + +void synd::importdone() { + opp::require_privileged_self(); + syndstate_t state(get_self()); + synd_state st = state.get_or_default(synd_state{}); + check(!st.import_complete, finalized_message.data()); + st.import_complete = true; + state.set(st, ram_payer); +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +bool synd::admit_sequence(sysio::slug_name chain_code, uint64_t sequence, uint64_t outpost_epoch) { + syndcursors_t cursors(get_self()); + const cursor_key key{chain_code.value}; + const auto cursor = cursors.try_get(key); + if (cursor && sequence <= cursor->last_sequence) return false; + synd_cursor updated = cursor.value_or(synd_cursor{.chain_code = chain_code}); + updated.last_sequence = sequence; + if (outpost_epoch != 0) updated.last_epoch = outpost_epoch; + cursors.set(ram_payer, key, updated); + return true; +} + +void synd::check_custody(std::string_view path, const liq::currency_stats& st, sysio::slug_name chain_code, + uint32_t epoch_index, uint64_t sequence, item_kind kind, uint64_t reported, + uint64_t expected) { + if (reported >= expected) { + if (reported > expected) + sysio::print("sysio.synd::", std::string(path), std::string(excess_verdict), reported, + std::string(outstanding_label), expected, "\n"); + return; + } + + sysio::print("sysio.synd::", std::string(path), std::string(shortfall_verdict), reported, + std::string(outstanding_label), expected, "\n"); + // Replace current evidence for this pair; never accumulate historical shortfall rows. + mismatches_t(get_self()).set(ram_payer, mismatch_key{.chain_code = chain_code.value, .token_code = st.token_code.value}, + mismatch_row{ + .chain_code = chain_code, + .token_code = st.token_code, + .epoch_index = epoch_index, + .sequence = sequence, + .kind = kind, + .reported = reported, + .expected = expected, + .at = time_point_sec(current_time_point()), + }); + + // Privileged inline execution bypasses authority checks, but the declared + // permission must exist. Andon's active permission exists from account creation. + if (andon::pulled(andon::ANDON_ACCOUNT)) { + sysio::print("sysio.synd::", std::string(path), std::string(cord_already_note), "\n"); + return; + } + const std::string reason = std::string(shortfall_reason) + chain_code.to_string() + std::string(reason_separator) + + st.token_code.to_string() + std::string(sequence_label) + std::to_string(sequence); + sysio::print("sysio.synd::", std::string(path), std::string(cord_pulled_note), reason, "\n"); + action(active_of(andon::ANDON_ACCOUNT), andon::ANDON_ACCOUNT, pull_action, std::make_tuple(reason)).send(); +} + +void synd::deliver_parked(name account, ChainKind chain_kind, const std::vector& pubkey) { + liq::stats statstable(LIQ_ACCOUNT); + parkeds_t parked(get_self()); + yieldpools_t pools(get_self()); + for (auto it = statstable.begin(); it != statstable.end(); ++it) { + const parked_key key = parked_key_of(it->token_code, chain_kind, pubkey); + const auto row = parked.try_get(key); + if (!row) continue; + const symbol sym = it->supply.symbol; + const symbol_code code = sym.code(); + + // Pull first -- nothing this delivery queues may touch this contract's liq row ahead of the + // claim (custody obligation 3) -- then take the row's banked WIRE out of what the pool covers. + const token_key pkey{it->token_code.value}; + pool_row pool = pools.try_get(pkey).value_or(pool_row{.token_code = it->token_code}); + custody::pull(pool.pool, LIQ_ACCOUNT, get_self(), code); + parked_row settled = *row; + const uint64_t banked = custody::settle_and_take(settled.position, settled.balance, pool.pool, LIQ_ACCOUNT, + code); + pools.set(ram_payer, pkey, pool); + // Whatever the pool could not cover (flooring dust of this contract's liq row, custody + // obligation 5) stays banked: the row is kept at balance 0 with it, and a later delivery pays it + // once the pool covers it. With nothing left the row goes. + const uint64_t balance = settled.balance; + if (settled.position.owed_wire > 0) { + settled.balance = 0; + parked.set(ram_payer, key, settled); + } else { + parked.erase(key); + } + + if (balance > 0) { + action(active_of(get_self()), LIQ_ACCOUNT, settle_action, + std::make_tuple(get_self(), account, asset{static_cast(balance), sym})) + .send(); + } + credit_owed(get_self(), account, code, banked); + } +} + +void synd::credit_by_pubkey(sysio::slug_name token_code, symbol_code sym, ChainKind chain_kind, + const std::vector& pubkey, uint64_t amount) { + const name account = linked_account(chain_kind, pubkey); + if (account != name{}) { + action(active_of(get_self()), LIQ_ACCOUNT, mint_action, std::make_tuple(account, token_code, amount)).send(); + return; + } + // Parked: the position settles at the live index before the balance grows, and the shadow it + // attributes arrives in this contract's own row by the mint sent right after (custody + // obligation 0). + park_shadow(get_self(), token_code, sym, chain_kind, pubkey, amount, 0); + action(active_of(get_self()), LIQ_ACCOUNT, mint_action, std::make_tuple(get_self(), token_code, amount)).send(); +} + +} // namespace sysio diff --git a/contracts/sysio.synd/sysio.synd.abi b/contracts/sysio.synd/sysio.synd.abi new file mode 100644 index 0000000000..8139333a09 --- /dev/null +++ b/contracts/sysio.synd/sysio.synd.abi @@ -0,0 +1,1286 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "bucket_key", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "uint64" + }, + { + "name": "token_code", + "type": "uint64" + }, + { + "name": "direction", + "type": "uint8" + } + ] + }, + { + "name": "bucket_row", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "direction", + "type": "bucket_direction" + }, + { + "name": "level", + "type": "uint64" + }, + { + "name": "last_epoch", + "type": "uint32" + } + ] + }, + { + "name": "challenge", + "base": "", + "fields": [ + { + "name": "challenger", + "type": "name" + }, + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "epoch_index", + "type": "uint32" + } + ] + }, + { + "name": "closeenv", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "epoch_index", + "type": "uint32" + }, + { + "name": "digest", + "type": "checksum256" + } + ] + }, + { + "name": "config_key", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "uint64" + }, + { + "name": "token_code", + "type": "uint64" + } + ] + }, + { + "name": "crank", + "base": "", + "fields": [ + { + "name": "limit", + "type": "uint32" + } + ] + }, + { + "name": "cursor_key", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "uint64" + } + ] + }, + { + "name": "desyndicate", + "base": "", + "fields": [ + { + "name": "holder", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + } + ] + }, + { + "name": "dropenv", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "epoch_index", + "type": "uint32" + } + ] + }, + { + "name": "envelope_key", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "uint64" + }, + { + "name": "token_code", + "type": "uint64" + }, + { + "name": "epoch_index", + "type": "uint32" + } + ] + }, + { + "name": "envelope_row", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "epoch_index", + "type": "uint32" + }, + { + "name": "digest", + "type": "checksum256" + }, + { + "name": "synd_total", + "type": "uint64" + }, + { + "name": "yield_total", + "type": "uint64" + }, + { + "name": "item_count", + "type": "uint32" + }, + { + "name": "state", + "type": "envelope_state" + }, + { + "name": "request_id", + "type": "uint64" + }, + { + "name": "released", + "type": "uint64" + }, + { + "name": "burned", + "type": "uint64" + }, + { + "name": "outcome", + "type": "request_outcome" + }, + { + "name": "forfeit", + "type": "uint64" + }, + { + "name": "bounty_returned", + "type": "uint64" + }, + { + "name": "hold_share", + "type": "uint64" + }, + { + "name": "hold_beneficiary", + "type": "name" + }, + { + "name": "share_pending", + "type": "bool" + } + ] + }, + { + "name": "fee_row", + "base": "", + "fields": [ + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "balance", + "type": "uint64" + }, + { + "name": "position", + "type": "position" + } + ] + }, + { + "name": "finishreturn", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + } + ] + }, + { + "name": "import_credit", + "base": "", + "fields": [ + { + "name": "pubkey", + "type": "bytes" + }, + { + "name": "amount", + "type": "uint64" + } + ] + }, + { + "name": "importdone", + "base": "", + "fields": [] + }, + { + "name": "importsynd", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "credits", + "type": "import_credit[]" + } + ] + }, + { + "name": "item_key", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "uint64" + }, + { + "name": "token_code", + "type": "uint64" + }, + { + "name": "epoch_index", + "type": "uint32" + }, + { + "name": "id", + "type": "uint64" + } + ] + }, + { + "name": "item_row", + "base": "", + "fields": [ + { + "name": "id", + "type": "uint64" + }, + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "epoch_index", + "type": "uint32" + }, + { + "name": "kind", + "type": "item_kind" + }, + { + "name": "chain_kind", + "type": "ChainKind" + }, + { + "name": "pubkey", + "type": "bytes" + }, + { + "name": "amount", + "type": "uint64" + }, + { + "name": "remaining", + "type": "uint64" + }, + { + "name": "position", + "type": "position" + } + ] + }, + { + "name": "ledger_key", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "uint64" + }, + { + "name": "token_code", + "type": "uint64" + } + ] + }, + { + "name": "ledger_row", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "queue_epoch", + "type": "uint32" + }, + { + "name": "underwriting_epoch", + "type": "uint32" + }, + { + "name": "retained_from_epoch", + "type": "uint64" + } + ] + }, + { + "name": "linkswept", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "chain_kind", + "type": "ChainKind" + }, + { + "name": "pubkey", + "type": "bytes" + } + ] + }, + { + "name": "mismatch_key", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "uint64" + }, + { + "name": "token_code", + "type": "uint64" + } + ] + }, + { + "name": "mismatch_row", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "epoch_index", + "type": "uint32" + }, + { + "name": "sequence", + "type": "uint64" + }, + { + "name": "kind", + "type": "item_kind" + }, + { + "name": "reported", + "type": "uint64" + }, + { + "name": "expected", + "type": "uint64" + }, + { + "name": "at", + "type": "time_point_sec" + } + ] + }, + { + "name": "onsynd", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "epoch_index", + "type": "uint32" + }, + { + "name": "digest", + "type": "checksum256" + }, + { + "name": "sequence", + "type": "uint64" + }, + { + "name": "chain_kind", + "type": "ChainKind" + }, + { + "name": "pubkey", + "type": "bytes" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "amount", + "type": "uint64" + }, + { + "name": "total_syndicated", + "type": "uint64" + } + ] + }, + { + "name": "onyield", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "epoch_index", + "type": "uint32" + }, + { + "name": "digest", + "type": "checksum256" + }, + { + "name": "sequence", + "type": "uint64" + }, + { + "name": "outpost_epoch", + "type": "uint64" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "amount", + "type": "uint64" + }, + { + "name": "total_syndicated", + "type": "uint64" + } + ] + }, + { + "name": "parked_key", + "base": "", + "fields": [ + { + "name": "token_code", + "type": "uint64" + }, + { + "name": "chain_kind", + "type": "uint64" + }, + { + "name": "pubkey", + "type": "bytes" + } + ] + }, + { + "name": "parked_row", + "base": "", + "fields": [ + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "chain_kind", + "type": "ChainKind" + }, + { + "name": "pubkey", + "type": "bytes" + }, + { + "name": "balance", + "type": "uint64" + }, + { + "name": "position", + "type": "position" + } + ] + }, + { + "name": "pool_row", + "base": "", + "fields": [ + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "pool", + "type": "yield_pool" + } + ] + }, + { + "name": "position", + "base": "", + "fields": [ + { + "name": "index_checkpoint", + "type": "uint128" + }, + { + "name": "owed_wire", + "type": "uint64" + } + ] + }, + { + "name": "pruneenv", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "limit", + "type": "uint32" + } + ] + }, + { + "name": "reconcile", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "reported", + "type": "uint64" + } + ] + }, + { + "name": "refundreturn", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + } + ] + }, + { + "name": "return_key", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + } + ] + }, + { + "name": "return_row", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + }, + { + "name": "holder", + "type": "name" + }, + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "chain_kind", + "type": "ChainKind" + }, + { + "name": "pubkey", + "type": "bytes" + }, + { + "name": "amount", + "type": "uint64" + } + ] + }, + { + "name": "setconfig", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "synd_fee_bps", + "type": "uint32" + }, + { + "name": "desynd_fee_bps", + "type": "uint32" + }, + { + "name": "synd_burst", + "type": "uint64" + }, + { + "name": "synd_refill", + "type": "uint64" + }, + { + "name": "desynd_burst", + "type": "uint64" + }, + { + "name": "desynd_refill", + "type": "uint64" + }, + { + "name": "window_sec", + "type": "uint32" + }, + { + "name": "bounty", + "type": "uint64" + }, + { + "name": "challenge_extra", + "type": "uint64" + } + ] + }, + { + "name": "sweep", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "chain_kind", + "type": "ChainKind" + } + ] + }, + { + "name": "sweepyield", + "base": "", + "fields": [ + { + "name": "token_code", + "type": "slug_name" + } + ] + }, + { + "name": "sync", + "base": "", + "fields": [ + { + "name": "limit", + "type": "uint32" + } + ] + }, + { + "name": "syncenv", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "epoch_index", + "type": "uint32" + } + ] + }, + { + "name": "synd_config", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "synd_fee_bps", + "type": "uint32" + }, + { + "name": "desynd_fee_bps", + "type": "uint32" + }, + { + "name": "synd_burst", + "type": "uint64" + }, + { + "name": "synd_refill", + "type": "uint64" + }, + { + "name": "desynd_burst", + "type": "uint64" + }, + { + "name": "desynd_refill", + "type": "uint64" + }, + { + "name": "window_sec", + "type": "uint32" + }, + { + "name": "bounty", + "type": "uint64" + }, + { + "name": "challenge_extra", + "type": "uint64" + } + ] + }, + { + "name": "synd_counters", + "base": "", + "fields": [ + { + "name": "next_item_id", + "type": "uint64" + }, + { + "name": "next_request_id", + "type": "uint64" + } + ] + }, + { + "name": "synd_cursor", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "last_sequence", + "type": "uint64" + }, + { + "name": "last_epoch", + "type": "uint64" + } + ] + }, + { + "name": "synd_state", + "base": "", + "fields": [ + { + "name": "import_complete", + "type": "bool" + }, + { + "name": "queue_cursor", + "type": "uint64" + }, + { + "name": "underwriting_cursor", + "type": "uint64" + }, + { + "name": "sync_cursor", + "type": "envelope_key" + } + ] + }, + { + "name": "token_key", + "base": "", + "fields": [ + { + "name": "token_code", + "type": "uint64" + } + ] + }, + { + "name": "yield_pool", + "base": "", + "fields": [ + { + "name": "received", + "type": "uint64" + }, + { + "name": "credited", + "type": "uint64" + }, + { + "name": "pulled_index", + "type": "uint128" + }, + { + "name": "pulled_owed", + "type": "uint64" + } + ] + } + ], + "actions": [ + { + "name": "challenge", + "type": "challenge", + "ricardian_contract": "" + }, + { + "name": "closeenv", + "type": "closeenv", + "ricardian_contract": "" + }, + { + "name": "crank", + "type": "crank", + "ricardian_contract": "" + }, + { + "name": "desyndicate", + "type": "desyndicate", + "ricardian_contract": "" + }, + { + "name": "dropenv", + "type": "dropenv", + "ricardian_contract": "" + }, + { + "name": "finishreturn", + "type": "finishreturn", + "ricardian_contract": "" + }, + { + "name": "importdone", + "type": "importdone", + "ricardian_contract": "" + }, + { + "name": "importsynd", + "type": "importsynd", + "ricardian_contract": "" + }, + { + "name": "linkswept", + "type": "linkswept", + "ricardian_contract": "" + }, + { + "name": "onsynd", + "type": "onsynd", + "ricardian_contract": "" + }, + { + "name": "onyield", + "type": "onyield", + "ricardian_contract": "" + }, + { + "name": "pruneenv", + "type": "pruneenv", + "ricardian_contract": "" + }, + { + "name": "reconcile", + "type": "reconcile", + "ricardian_contract": "" + }, + { + "name": "refundreturn", + "type": "refundreturn", + "ricardian_contract": "" + }, + { + "name": "setconfig", + "type": "setconfig", + "ricardian_contract": "" + }, + { + "name": "sweep", + "type": "sweep", + "ricardian_contract": "" + }, + { + "name": "sweepyield", + "type": "sweepyield", + "ricardian_contract": "" + }, + { + "name": "sync", + "type": "sync", + "ricardian_contract": "" + }, + { + "name": "syncenv", + "type": "syncenv", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "buckets", + "type": "bucket_row", + "index_type": "i64", + "key_names": ["chain_code","token_code","direction"], + "key_types": ["uint64","uint64","uint8"], + "table_id": 59616 + }, + { + "name": "envelopes", + "type": "envelope_row", + "index_type": "i64", + "key_names": ["chain_code","token_code","epoch_index"], + "key_types": ["uint64","uint64","uint32"], + "table_id": 45555 + }, + { + "name": "feepot", + "type": "fee_row", + "index_type": "i64", + "key_names": ["token_code"], + "key_types": ["uint64"], + "table_id": 22642 + }, + { + "name": "items", + "type": "item_row", + "index_type": "i64", + "key_names": ["chain_code","token_code","epoch_index","id"], + "key_types": ["uint64","uint64","uint32","uint64"], + "table_id": 37244 + }, + { + "name": "ledger", + "type": "ledger_row", + "index_type": "i64", + "key_names": ["chain_code","token_code"], + "key_types": ["uint64","uint64"], + "table_id": 50082 + }, + { + "name": "mismatch", + "type": "mismatch_row", + "index_type": "i64", + "key_names": ["chain_code","token_code"], + "key_types": ["uint64","uint64"], + "table_id": 57982 + }, + { + "name": "parked", + "type": "parked_row", + "index_type": "i64", + "key_names": ["token_code","chain_kind","pubkey"], + "key_types": ["uint64","uint64","bytes"], + "table_id": 38854 + }, + { + "name": "returns", + "type": "return_row", + "index_type": "i64", + "key_names": ["request_id"], + "key_types": ["uint64"], + "table_id": 25676 + }, + { + "name": "syndconfig", + "type": "synd_config", + "index_type": "i64", + "key_names": ["chain_code","token_code"], + "key_types": ["uint64","uint64"], + "table_id": 5686 + }, + { + "name": "syndcounters", + "type": "synd_counters", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 36549 + }, + { + "name": "syndcursors", + "type": "synd_cursor", + "index_type": "i64", + "key_names": ["chain_code"], + "key_types": ["uint64"], + "table_id": 42845 + }, + { + "name": "syndstate", + "type": "synd_state", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 53531 + }, + { + "name": "yieldpool", + "type": "pool_row", + "index_type": "i64", + "key_names": ["token_code"], + "key_types": ["uint64"], + "table_id": 51955 + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [], + "enums": [ + { + "name": "ChainKind", + "type": "int32", + "values": [ + { + "name": "CHAIN_KIND_UNKNOWN", + "value": 0 + }, + { + "name": "CHAIN_KIND_WIRE", + "value": 1 + }, + { + "name": "CHAIN_KIND_EVM", + "value": 2 + }, + { + "name": "CHAIN_KIND_SVM", + "value": 3 + } + ] + }, + { + "name": "bucket_direction", + "type": "uint8", + "values": [ + { + "name": "SYNDICATION", + "value": 0 + }, + { + "name": "DESYNDICATION", + "value": 1 + } + ] + }, + { + "name": "envelope_state", + "type": "uint8", + "values": [ + { + "name": "OPEN", + "value": 0 + }, + { + "name": "WAITING", + "value": 1 + }, + { + "name": "REQUESTED", + "value": 2 + }, + { + "name": "RELEASABLE", + "value": 3 + }, + { + "name": "HELD", + "value": 4 + }, + { + "name": "INVALID", + "value": 5 + }, + { + "name": "DONE", + "value": 6 + } + ] + }, + { + "name": "item_kind", + "type": "uint8", + "values": [ + { + "name": "SYNDICATION", + "value": 0 + }, + { + "name": "YIELD", + "value": 1 + } + ] + }, + { + "name": "request_outcome", + "type": "uint8", + "values": [ + { + "name": "PENDING", + "value": 0 + }, + { + "name": "APPROVED", + "value": 1 + }, + { + "name": "VALID", + "value": 2 + }, + { + "name": "INVALID", + "value": 3 + } + ] + } + ] +} \ No newline at end of file diff --git a/contracts/sysio.synd/sysio.synd.wasm b/contracts/sysio.synd/sysio.synd.wasm new file mode 100755 index 0000000000000000000000000000000000000000..d7380406ee86515ec5d4c8f98a68e082e1349362 GIT binary patch literal 103713 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 literal 0 HcmV?d00001 diff --git a/contracts/test_contracts/CMakeLists.txt b/contracts/test_contracts/CMakeLists.txt index cedf3b4139..f264d3e9db 100644 --- a/contracts/test_contracts/CMakeLists.txt +++ b/contracts/test_contracts/CMakeLists.txt @@ -2,4 +2,5 @@ add_subdirectory(blockinfo_tester) add_subdirectory(sendinline) add_subdirectory(reenter_deposit) add_subdirectory(block_transfer) +add_subdirectory(epoch_stub) add_subdirectory(badtoken) diff --git a/contracts/test_contracts/epoch_stub/CMakeLists.txt b/contracts/test_contracts/epoch_stub/CMakeLists.txt new file mode 100644 index 0000000000..dad5874804 --- /dev/null +++ b/contracts/test_contracts/epoch_stub/CMakeLists.txt @@ -0,0 +1,15 @@ +# epoch_stub.wasm / .abi are committed and read from ${CMAKE_SOURCE_DIR} by the test embed +# (contracts/tests/contracts.hpp.in), like block_transfer: contract-test wasms are not reliably placed in +# the build tree under CI's ExternalProject layout, so the committed artifact is the source of truth. +# add_contract still compiles the source as a build-time check; if epoch_stub.cpp or the epoch state it +# writes changes, rebuild and re-commit the wasm/abi. +if(BUILD_SYSTEM_CONTRACTS) + add_contract(epoch_stub epoch_stub epoch_stub.cpp) + target_include_directories(epoch_stub + PUBLIC + $ + $ + $ + $ + ) +endif() diff --git a/contracts/test_contracts/epoch_stub/epoch_stub.abi b/contracts/test_contracts/epoch_stub/epoch_stub.abi new file mode 100644 index 0000000000..b46ace1e3f --- /dev/null +++ b/contracts/test_contracts/epoch_stub/epoch_stub.abi @@ -0,0 +1,28 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "setindex", + "base": "", + "fields": [ + { + "name": "index", + "type": "uint32" + } + ] + } + ], + "actions": [ + { + "name": "setindex", + "type": "setindex", + "ricardian_contract": "" + } + ], + "tables": [], + "ricardian_clauses": [], + "variants": [], + "action_results": [] +} \ No newline at end of file diff --git a/contracts/test_contracts/epoch_stub/epoch_stub.cpp b/contracts/test_contracts/epoch_stub/epoch_stub.cpp new file mode 100644 index 0000000000..770f8300c3 --- /dev/null +++ b/contracts/test_contracts/epoch_stub/epoch_stub.cpp @@ -0,0 +1,25 @@ +/** + * @file epoch_stub.cpp + * @brief Test-only stand-in deployed on `sysio.epoch`. It writes the real `epochstate` singleton, so a + * suite can move the depot's epoch index without the operators, emissions and consensus that + * `sysio.epoch::advance` requires. Readers of the index -- `sysio::epoch::current_epoch_index` + * -- see exactly what the real contract would have written. + */ + +#include + +/// Writes `sysio.epoch`'s epoch state under the account it is deployed on. +class [[sysio::contract("epoch_stub")]] epoch_stub : public sysio::contract { +public: + using contract::contract; + + /// Set the current epoch index to `index`, keeping every other field of the epoch state. + /// Auth=the account the stub is deployed on. + [[sysio::action]] void setindex(uint32_t index) { + require_auth(get_self()); + sysio::epoch::epochstate_t state(get_self()); + sysio::epoch::epoch_state current = state.get_or_default(sysio::epoch::epoch_state{}); + current.current_epoch_index = index; + state.set(current, get_self()); + } +}; diff --git a/contracts/test_contracts/epoch_stub/epoch_stub.wasm b/contracts/test_contracts/epoch_stub/epoch_stub.wasm new file mode 100755 index 0000000000000000000000000000000000000000..61fd606263590844d8130de80a463c2e78ed2999 GIT binary patch literal 6307 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 literal 0 HcmV?d00001 diff --git a/contracts/tests/CMakeLists.txt b/contracts/tests/CMakeLists.txt index d689e596b8..d46394f9da 100644 --- a/contracts/tests/CMakeLists.txt +++ b/contracts/tests/CMakeLists.txt @@ -19,6 +19,15 @@ add_test(NAME contract_type_generator_test -s ${CMAKE_SOURCE_DIR}/contracts/tools/tests -p test_generate_sysio_contract_types.py) +# Keep the reviewed flow/native boundary map from silently losing its tests. +add_test(NAME depot_flow_coverage_map + COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/contracts/tools/verify_depot_flow_coverage.py) +add_test(NAME depot_flow_coverage_map_tests + COMMAND ${Python3_EXECUTABLE} -m unittest discover + -s ${CMAKE_SOURCE_DIR}/contracts/tools/tests + -p test_verify_depot_flow_coverage.py) +set_tests_properties(depot_flow_coverage_map depot_flow_coverage_map_tests PROPERTIES LABELS "contract;depot-flow") + # build unit test executable file(GLOB UNIT_TESTS CONFIGURE_DEPENDS "*.cpp" "*.hpp") # find all unit test suites set(CONTRACT_UNIT_TEST_EXE contracts_unit_test) diff --git a/contracts/tests/DEPOT_FLOW_TESTING.md b/contracts/tests/DEPOT_FLOW_TESTING.md new file mode 100644 index 0000000000..9f5a245798 --- /dev/null +++ b/contracts/tests/DEPOT_FLOW_TESTING.md @@ -0,0 +1,171 @@ +# Depot flow coverage + +The reviewed coverage inventory is [depot_flow_coverage.json](depot_flow_coverage.json). +It maps every enabled `wire-tools-ts` flow at the recorded feature-branch head to +specific native contract cases, describes its WIRE obligations, and states the +behaviors that still require a cluster. The map is a reviewable traceability +record, not a line-coverage percentage or a proof that every possible sequence is covered. + +## Test layers + +1. **Contract boundary tests** exercise production WASM, authorization, arithmetic, + rejection, replay, pagination and individual state transitions. The existing + bond, syndication, collateral, emissions and producer suites remain essential. +2. **Depot integration tests** exercise inline calls between the real contracts. + New generic scenarios cover backed import, held intake, bonding, partial + release, challenge adjudication, freeze/repair, fee rounding, link delivery, + yield conversion, reward funding and return instructions. +3. **Cluster flows** retain responsibility for external contract execution, + operator daemons, networking, finality, actual producer participation and soak + stability. Native virtual-time tests cannot establish those properties. + +`sysio_dispatch_tests/generic_external_simulator_drives_production_dispatch` +feeds canonical protobuf envelopes into real `sysio.msgch`, its epoch consensus +path, `sysio.synd` and `sysio.liq` for both transport families. The reward dispatch +case additionally exercises `sysio.dclaim -> sysio.system -> sysio.token`. +Ethereum NFT registration remains covered by the Ethereum-specific dispatch and +ROA tests: changing that source chain would hide an actual protocol constraint. + +The focused syndication sequences enter at the **authenticated downstream +msgch action boundary** and use the existing `epoch_stub` to control bucket epochs. +They deploy real syndication, bond, LIQ, AuthEx, swap and Andon contracts. They do +not claim to test consensus or epoch maintenance; production dispatch and epoch +suites cover those separately. The generic collateral test isolates opreg's role +minimum and withdrawal policy; the producer eligibility/score suites additionally +cover producer registration, finalizer keys and schedule selection. + +## Generic external-chain model + +[external_chain_simulator.hpp](external_chain_simulator.hpp) is test-only host +code. EC1/NTA uses the EVM key encoding; EC2/NTB uses the SVM encoding. The +9-decimal symbols identify WIRE shadow units. They are not assertions that every +external asset has nine decimals. + +The simulator tracks external custody, the shared deposit/yield sequence, +canonical message/envelope hash chaining, pending returns and idempotent payouts. +It builds protobuf reports and consumes the depot's actual `DesyndicateLIQ` +message. `lose()` is explicit fault injection; `donate()` backs bootstrap liquidity +or repairs a deficit. Freeze is explicitly controlled by the test, not delivered +by a simulated daemon. This deliberately small model implements no depot fee, +bond, governance, eligibility, token-minting or AMM policy. There is no modified +production OPP contract, socket, daemon, external validator or external SDK. + +The generic syndication fixture imports real backed balances for both the +underwriter and challenger. Accepting a request spends those balances; it does +not mint collateral. This differs intentionally from narrow unit fixtures that +mint balances to isolate a single contract. + +## Generated sequences and invariants + +`generated_generic_custody_bond_challenge_and_return_sequences` runs eight linked +rounds for each of three fixed seeds: `0x51a7`, `0xc011a7`, `0xb0ad`. Each round +uses prior balances and cursors. Choices vary the chain, amount, challenge +outcome, freeze timing and return amount; queue epochs advance explicitly. A +failure includes its seed and step in Boost's context. Re-run the named test to +reproduce the schedule. Keys are fresh fixture identities; economic choices are +seeded. This is a deterministic regression campaign, not an exhaustive fuzzer. + +Assertions include: + +- Supply equals the sum of all fixture holder balances, including contract custody. +- Supply plus pending yield never exceeds simulated custody in healthy scenarios. +- Replayed intake cannot mint twice; replayed returns cannot pay twice. +- Invalid adjudication restores pre-intake supply while retaining released user funds. +- Partial release stops during challenge and resumes only after a valid ruling. +- Frozen user redemptions have no supply side effect. +- Each required collateral asset must independently meet its minimum. Surplus in + one cannot substitute for the other, and withdrawal reservation removes eligibility + before maturity moves funds. +- Claims return the right asset and exact amount, preserving the other asset's balance. +- Generic yield is held before bonding, converted by the real AMM, and pays the exact + indexed WIRE claim; syndication/redemption fees are independently floor-rounded. +- A reported custody deficit pulls the cord and retains evidence; repairing custody + and clearing the cord resumes release without erasing the incident. + +The map also selects focused edge cases for FIFO/budget saturation, nonzero +challenge fees/bounties, retained outcomes after pruning, frozen budget accounting, +producer scoring and authorization. Add small named regressions for failures found +by larger campaigns rather than relying only on a random seed. + +## Running + +From `wire-sysio`, build the default target using the repository's configured +build procedure, for example: + +```sh +cmake --build build/release -- -j12 +python3 contracts/tools/verify_depot_flow_coverage.py +python3 contracts/tools/verify_depot_flow_coverage.py --tools-root ../wire-tools-ts +``` + +Use the correct feature checkout for `--tools-root` (a separate worktree may have a +different path). Flow enablement uses the same rule as `scripts/run-flow.mjs`: a +`packages/flow-*/package.json` with a nonempty `scripts.test`. Newly enabled, +disabled or renamed flows require a reviewed map update. Standalone sysio CI +validates the committed map and native references without requiring a sibling repo. +This check detects inventory drift; reviewers must still assess changed assertions. + +Run the focused native mirror set: + +```sh +flow_filter=$(python3 contracts/tools/verify_depot_flow_coverage.py --filter) +build/release/contracts/tests/contracts_unit_test \ + --run_test="$flow_filter" --report_level=detailed -- --sys-vm +``` + +Run the broader contract gate before publishing: + +```sh +build/release/contracts/tests/contracts_unit_test --report_level=detailed -- --sys-vm +ctest --test-dir build/release -R '^depot_flow_coverage_map' --output-on-failure +``` + +Run tests only after the build completes, with no concurrent build in that directory: the +default build can clean and replace contract WASM while regenerating dependencies. +These commands use the WASM/ABI under the selected build directory. A passing old +binary or stale contract artifact is not evidence for the edited source. Record +the branch head, dirty test changes, build completion, runtime, case/assertion +counts and any artifact differences with the validation result. No fresh external +cluster is needed for these native tests. + +## Validation record — 2026-10-02 + +Validated on `feature/syndication-underwriting`, based on +`5bbb0c5e4d57e6133181a3d9dc4b58a0ddef14ed` plus the test changes in this patch. +The default all-target build completed, and tests used the freshly built WASM/ABI. +The final full run used a stable build directory with no concurrent build. + +| Gate | Result | Elapsed | +| --- | --- | --- | +| All native contract cases (`--sys-vm`) | 1,087 cases; 248,033 assertions passed | 10m 37s | +| Eleven new simulator/integration cases | 2,911 assertions passed | 3.64s | +| Coverage-map CTest checks | Both passed, including five validator regression tests | 0.11s | +| Enabled-flow comparison against recorded tools head | All 13 flows mapped to 67 existing native cases | — | + +Broad validation also exposed two old fixture assumptions. Dispute deployment now +produces a block between contracts to avoid exhausting cumulative block NET. +Multisig chunk tests now use bounded valid WASM padding instead of depending on +production `sysio.system` size. Their complete suites passed in the final gate. +No production contract changes were needed for these fixture repairs. + +The external cluster flows were not rerun for this test-only patch. The generated +campaign covers 24 linked rounds across three seeds; this result does not claim +exhaustive exploration or a measured production line-coverage percentage. + +## Governance shortcut parity + +`generic_governance_and_provider_release_have_identical_settlement` compares +unbonded `sysio.bond::rslvvalid` with real funded `accept`, followed by ordinary +window-gated `approve` and collateral claim. Both EC1/NTA and EC2/NTB run with +linked and initially unlinked recipients. The test compares the economic trace +through frozen release, successive partial releases, same-epoch no-refill, fee +rounding, principal/link delivery, pending yield, and actual return instructions +paid exactly once by the external model. It also rejects unauthorized governance +and early provider approval. All 1,824 assertions passed; the complete syndication +and bond suites passed 152 cases and 12,311 assertions. + +Only the downstream settlement trace is equivalent. Bond state, escrow ownership, +challenge timing, provider returns and bounty allocation deliberately differ. +Provider, challenge and INVALID native tests remain essential, as do the live +flows specifically exercising providers. The governance shortcut in transport +flows cannot stand in for their coverage. The parity regression is included in the regular `sysio_synd_tests` suite. diff --git a/contracts/tests/contract_test_support.hpp b/contracts/tests/contract_test_support.hpp index b1b2a68638..3d9b8ebc48 100644 --- a/contracts/tests/contract_test_support.hpp +++ b/contracts/tests/contract_test_support.hpp @@ -2,12 +2,25 @@ #include #include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include +#include + +#include + namespace sysio_system::test_support { using namespace sysio::chain; @@ -151,6 +164,39 @@ inline fc::mutable_variant_object svm_outpost_mvo(std::string_view program_id) { ("source_deposit_addr", std::string{}); } +/// The user-link signature domain for EM (hex compressed key) and ED (base58 +/// raw key), followed by account, chain kind, nonce and the fixed suffix. +inline std::string build_link_message(const fc::crypto::public_key& pub_key, const std::string& account, + sysio::opp::types::ChainKind chain_kind, uint64_t nonce) { + std::string pub_key_str; + if (pub_key.type() == fc::crypto::public_key::key_type::ed) { + const auto raw = pub_key.get().serialize(); + pub_key_str = "PUB_ED_" + fc::to_base58(reinterpret_cast(raw.data()), raw.size(), [] {}); + } else { + const auto compressed = pub_key.get().serialize(); + pub_key_str = "PUB_EM_" + fc::to_hex(compressed.data(), compressed.size()); + } + return pub_key_str + "|" + account + "|" + std::to_string(magic_enum::enum_integer(chain_kind)) + "|" + + std::to_string(nonce) + "|createlink auth"; +} + +/// Sign the contract's curve-specific digest: EM uses keccak/EIP-191; ED uses +/// SHA-256 mapped to printable ASCII before the ED digest signing operation. +inline fc::crypto::signature sign_createlink(const fc::crypto::private_key& priv, const std::string& account, + sysio::opp::types::ChainKind chain_kind, uint64_t nonce) { + const auto message = build_link_message(priv.get_public_key(), account, chain_kind, nonce); + if (priv.get_public_key().type() == fc::crypto::public_key::key_type::ed) { + constexpr unsigned char PrintableStart = 33, PrintableCount = 94; + const auto raw = fc::sha256::hash(message); + std::array mapped; + for (size_t i = 0; i < mapped.size(); ++i) + mapped[i] = char((static_cast(raw.data()[i]) % PrintableCount) + PrintableStart); + return priv.sign(fc::sha256(mapped.data(), mapped.size())); + } + const auto msg_hash = fc::crypto::keccak256::hash(message); + return priv.sign(fc::sha256(reinterpret_cast(msg_hash.data()), 32)); +} + /// Mirrors of sysio.roa's `nodeownerreg` audit values (`reg_status` / `reject_reason` in sysio.roa.hpp). namespace nodeownerreg { inline constexpr uint64_t status_confirmed = 0; diff --git a/contracts/tests/contracts.hpp.in b/contracts/tests/contracts.hpp.in index 43df21c61c..2506f42ba1 100644 --- a/contracts/tests/contracts.hpp.in +++ b/contracts/tests/contracts.hpp.in @@ -42,10 +42,12 @@ struct contracts { static std::vector swap_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.swap/sysio.swap.abi"); } static std::vector liq_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.liq/sysio.liq.wasm"); } static std::vector liq_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.liq/sysio.liq.abi"); } - static std::vector andon_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.andon/sysio.andon.wasm"); } - static std::vector andon_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.andon/sysio.andon.abi"); } static std::vector bond_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.bond/sysio.bond.wasm"); } static std::vector bond_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.bond/sysio.bond.abi"); } + static std::vector synd_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.synd/sysio.synd.wasm"); } + static std::vector synd_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.synd/sysio.synd.abi"); } + static std::vector andon_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.andon/sysio.andon.wasm"); } + static std::vector andon_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.andon/sysio.andon.abi"); } /// The checked-in bootstrap configs (`dex-config.*.json`) the launch-ingestion tests replay. static std::string dex_config_dir() { return "${CMAKE_SOURCE_DIR}/etc/config/dex"; } @@ -57,6 +59,8 @@ struct contracts { static std::vector reenter_deposit_abi() { return read_abi("${CMAKE_SOURCE_DIR}/contracts/test_contracts/reenter_deposit/reenter_deposit.abi"); } static std::vector block_transfer_wasm() { return read_wasm("${CMAKE_SOURCE_DIR}/contracts/test_contracts/block_transfer/block_transfer.wasm"); } static std::vector block_transfer_abi() { return read_abi("${CMAKE_SOURCE_DIR}/contracts/test_contracts/block_transfer/block_transfer.abi"); } + static std::vector epoch_stub_wasm() { return read_wasm("${CMAKE_SOURCE_DIR}/contracts/test_contracts/epoch_stub/epoch_stub.wasm"); } + static std::vector epoch_stub_abi() { return read_abi("${CMAKE_SOURCE_DIR}/contracts/test_contracts/epoch_stub/epoch_stub.abi"); } }; }; diff --git a/contracts/tests/depot_flow_coverage.json b/contracts/tests/depot_flow_coverage.json new file mode 100644 index 0000000000..bc13700c4c --- /dev/null +++ b/contracts/tests/depot_flow_coverage.json @@ -0,0 +1,169 @@ +{ + "tools_branch": "feature/syndication-underwriting", + "tools_head": "686f09cdaf0463873a4d6c80c72bd47cc96a0f1d", + "flows": [ + { + "name": "flow-batch-operator-slashing", + "wire_behavior": "Conflicting canonical deliveries open a dispute and pause epochs; electorate adjudication selects the winner, slashes losers, excludes them from scheduling, and resumes consensus. Depot shadow slashes remain on WIRE.", + "cluster_only": "Daemon submission and real external envelope transport.", + "tests": [ + "sysio_dispute_tests/opendispute_writes_row_and_pauses", + "sysio_dispute_tests/chkdispute_fast_path_resolves", + "sysio_dispute_tests/slashed_op_excluded_from_scheduling_no_double_slash", + "sysio_msgch_chain_tests/noncanonical_delivery_slashes_before_termination", + "sysio_msgch_chain_tests/short_group_after_slash_still_reaches_consensus", + "sysio_opreg_tests/slash_shadow_row_emits_no_attestation" + ] + }, + { + "name": "flow-batch-operator-termination", + "wire_behavior": "Missed duties accumulate at rotation cadence; threshold crossing terminates the operator, excludes future duty, and preserves claimable collateral/yield until paid.", + "cluster_only": "Stopping real daemons and observing wall-clock missed delivery.", + "tests": [ + "sysio_msgch_chain_tests/terminate_at_duty_rotation_via_advance", + "sysio_opreg_tests/termcheck_terminates_after_default_consecutive_boundary", + "sysio_opreg_tests/claimremit_pays_terminated_operator_and_clears_row", + "sysio_opreg_tests/terminate_credits_earned_yield_with_the_principal" + ] + }, + { + "name": "flow-emergency-stop", + "wire_behavior": "Freeze permits incoming custody and bonding but blocks releases, withdrawals and conversion; elapsed epochs refill budget up to its burst cap after clear. Clear resumes pending work. Honest shortfall records evidence and pulls the cord.", + "cluster_only": "Real external freeze propagation, transaction refusal and payout retry.", + "tests": [ + "sysio_synd_tests/a_freeze_stops_release_and_refill_is_capped_at_burst", + "sysio_synd_tests/a_freeze_admits_what_comes_in_and_refuses_what_leaves", + "sysio_synd_tests/a_freeze_defers_the_parked_delivery_of_a_new_link", + "sysio_synd_tests/a_freeze_defers_the_invalid_burn", + "sysio_synd_tests/a_freeze_still_issues_requests_and_takes_bonds", + "sysio_synd_tests/a_syndication_shortfall_records_pulls_and_still_holds", + "sysio_synd_tests/generated_generic_custody_bond_challenge_and_return_sequences", + "sysio_synd_tests/generic_custody_shortfall_freezes_and_repair_resumes", + "sysio_dispatch_tests/a_custody_shortfall_pulls_the_cord_and_the_epoch_advances", + "sysio_dispatch_tests/a_shortfall_freezes_the_same_envelopes_queue_step" + ] + }, + { + "name": "flow-emissions-soak", + "wire_behavior": "Epoch accrual, category distribution, floor/ceiling bounds and imported prelink rewards becoming claimable after authenticated linking.", + "cluster_only": "Multi-hour daemon/node stability, forks and external chain synchronization; native virtual time is not a soak.", + "tests": [ + "t5_emissions_tests/subsequent_epochs_apply_decay", + "t5_emissions_tests/emission_capped_at_distributable_ceiling", + "t5_emissions_tests/category_split_matches_basis_points", + "t5_emissions_tests/governance_gets_remainder_no_dust_loss", + "sysio_dclaim_tests/importseed_accepts_credit_batch", + "sysio_authex_tests/createlink_eth_sweeps_prelink_dclaim_rewards", + "sysio_dispatch_tests/generic_staking_rewards_fund_claim_and_dedupe_through_dispatch" + ] + }, + { + "name": "flow-liq-syndication", + "wire_behavior": "Canonical generic custody reports enter production msgch consensus, mint held shadow, retain held yield outside supply, close envelopes, suppress sequence replays and keep custody checks healthy.", + "cluster_only": "Real SOL staking/minting and permissionless yield reports, operator relay and liveness.", + "tests": [ + "sysio_dispatch_tests/generic_external_simulator_drives_production_dispatch", + "sysio_synd_tests/onsynd_holds_linked_and_unlinked_alike", + "sysio_synd_tests/custody_equal_to_the_outstanding_records_nothing" + ] + }, + { + "name": "flow-liq-yield", + "wire_behavior": "Custody-backed syndication releases to parked credit, linking delivers once, bonded yield enters pending, queueyield/tickyield sell through the real AMM, the index pays exact WIRE owed, and fee-rounded redemption queues the net amount.", + "cluster_only": "Real outpost native deposit, yield emission and payout execution.", + "tests": [ + "sysio_synd_tests/generic_parked_credit_yield_conversion_and_fee_rounded_return", + "sysio_liq_tests/queued_yield_sells_through_the_pool_and_pays_holders", + "sysio_synd_tests/createlink_sweeps_parked_from_synd" + ] + }, + { + "name": "flow-node-owner-nft", + "wire_behavior": "Ethereum-bound registration creates the node owner, resource policy and tier counts; mismatched keys, malformed ETH identity, duplicates and full tiers reject without side effects.", + "cluster_only": "Ethereum NFT mint/ownership and upstream claim emission.", + "tests": [ + "sysio_roa_tests/nodeownreg_happy_path", + "sysio_roa_tests/nodeownreg_rejects_malformed_eth_address_lengths", + "sysio_roa_tests/nodeownreg_account_key_mismatch", + "sysio_roa_tests/nodeownreg_full_tier_has_no_registration_side_effects", + "sysio_emissions_tests/addnodeowner_increments_node_count_per_tier", + "sysio_dispatch_tests/dispatch_routes_node_owner_reg_to_roa" + ] + }, + { + "name": "flow-operator-collateral-deposit", + "wire_behavior": "Depot-native collateral moves into opreg custody, withdrawal reservation updates eligibility, maturity credits token-specific remit claims, and claims return exact principal. Generic dual shadow coverage supplements WIRE coverage.", + "cluster_only": "Node provisioning and live batch daemon duty while collateral changes.", + "tests": [ + "sysio_opreg_tests/withdraw_rechecks_eligibility_after_enqueue", + "sysio_opreg_tests/withdraw_shadow_flush_credits_token_claim_and_claimremit_pays_liq", + "sysio_opreg_tests/two_generic_depot_assets_are_both_required_and_withdrawals_preserve_identity", + "sysio_opreg_tests/native_wire_partial_withdrawal_stays_active_and_claims_exactly" + ] + }, + { + "name": "flow-producer-registration", + "wire_behavior": "Both required depot shadow assets must meet their own minimum; surplus cannot substitute, either withdrawal loses eligibility immediately, maturity/claim preserves asset identity. Producer registration, scoring, finalizer keys and scheduling enforce eligibility.", + "cluster_only": "Ethereum NFT provisioning and live producer process/block production. Generic NTA/NTB mirror the 2 LIQETH plus 2 LIQSOL policy; NFT tests remain Ethereum-specific.", + "tests": [ + "sysio_opreg_tests/two_generic_depot_assets_are_both_required_and_withdrawals_preserve_identity", + "sysio_producer_eligibility_tests/noncollateralized_account_cannot_register_then_can_join", + "sysio_producer_score_tests/collateral_is_minimum_across_required_pairs", + "sysio_producer_score_tests/a_bonded_producer_without_a_finalizer_key_holds_no_rank", + "sysio_producer_score_tests/stale_active_status_cannot_bypass_raised_collateral_minimum", + "sysio_producer_score_tests/collateralised_producers_displace_the_bootstrap_at_the_boundary" + ] + }, + { + "name": "flow-syndication-challenge", + "wire_behavior": "Partial release then challenge freezes progress; INVALID burns held intake and released value from the forfeited bond, preserves recipient funds, pots surplus and pays challenger. VALID resumes. Generated backed sequences check conservation after transitions.", + "cluster_only": "Real external custody excess after invalidation and transport.", + "tests": [ + "sysio_synd_tests/generated_generic_custody_bond_challenge_and_return_sequences", + "sysio_synd_tests/invalid_burns_held_items_and_the_released_part_of_the_forfeit", + "sysio_synd_tests/challenge_charges_bond_plus_extra_and_holds", + "sysio_synd_tests/bounty_comes_from_the_fee_pot_or_is_zero", + "sysio_synd_tests/invalid_after_a_yield_release_keeps_the_supply_whole" + ] + }, + { + "name": "flow-syndication-rate-limit", + "wire_behavior": "FIFO partial release, same-epoch no refill, epoch-bounded refill, oversized item draining and atomic over-budget redemption refusal.", + "cluster_only": "Live epoch-boundary synchronization and irreversible confirmation timing.", + "tests": [ + "sysio_synd_tests/bucket_limits_a_tranche_and_refills_per_epoch", + "sysio_synd_tests/oversize_item_releases_in_tranches", + "sysio_synd_tests/desyndicate_refuses_over_budget", + "sysio_synd_tests/generated_generic_custody_bond_challenge_and_return_sequences" + ] + }, + { + "name": "flow-syndication-underwriting", + "wire_behavior": "Held intake needs collateral before release, exact fees accrue, unlinked credits park, authenticated links deliver, approval/claim obey windows, and pruned outcomes remain usable.", + "cluster_only": "Actual outpost deposits and operator transport.", + "tests": [ + "sysio_synd_tests/closeenv_issues_one_request_per_token", + "sysio_synd_tests/bonded_request_makes_the_envelope_releasable", + "sysio_synd_tests/release_pays_the_fee_and_credits_the_account", + "sysio_synd_tests/generic_parked_credit_yield_conversion_and_fee_rounded_return", + "sysio_synd_tests/a_pruned_request_keeps_its_pair_releasing", + "sysio_bond_tests/approve_refuses_before_the_window_and_when_unbonded", + "sysio_bond_tests/approved_returns_bonds_and_splits_the_bounty_by_stake", + "sysio_synd_tests/second_envelope_waits_until_the_first_is_bonded" + ] + }, + { + "name": "flow-yield-distribution", + "wire_behavior": "Staking reward credits linked/unmapped WIRE claims, suppresses stale external references, funds through accounting and liquid balance caps, and records shortfall.", + "cluster_only": "Ethereum mock emitter and real STAKING_REWARD transport.", + "tests": [ + "sysio_dclaim_tests/onreward_linked_credits_pending_claims", + "sysio_dclaim_tests/onreward_unlinked_parks_unmapped_then_linkswept", + "sysio_dclaim_tests/onreward_dedupes_stale_external_ref", + "t5_emissions_tests/fundclaim_transfers_and_tracks_distributed", + "t5_emissions_tests/fundclaim_caps_to_remaining_pool_and_records_shortfall", + "sysio_dispatch_tests/generic_staking_rewards_fund_claim_and_dedupe_through_dispatch" + ] + } + ] +} diff --git a/contracts/tests/external_chain_simulator.hpp b/contracts/tests/external_chain_simulator.hpp new file mode 100644 index 0000000000..b5f5da3f2d --- /dev/null +++ b/contracts/tests/external_chain_simulator.hpp @@ -0,0 +1,146 @@ +#pragma once + +#include "opp_envelope_oracle.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sysio::testing::external { +using namespace sysio::opp; +using namespace sysio::opp::types; + +/// Chain identifiers are deliberately unrelated to production assets. ChainKind is +/// retained because public-key encodings really are transport-family-specific. +struct asset_spec { + const char* chain; + const char* token; + ChainKind kind; + uint32_t id; +}; +inline constexpr asset_spec First{"EC1", "NTA", ChainKind::CHAIN_KIND_EVM, 101}; +inline constexpr asset_spec Second{"EC2", "NTB", ChainKind::CHAIN_KIND_SVM, 202}; +inline constexpr std::array Assets{First, Second}; +inline constexpr uint64_t Unit = 1'000'000'000; +inline constexpr uint32_t Precision = 9; +inline constexpr uint64_t EnvelopeTimestamp = 1'775'612'516'983ULL; +using attestation = std::pair; + +/// Test-only external custody and message source. It does NOT implement any +/// depot policy, bond adjudication, fee arithmetic, or token minting. Tests feed +/// these protobuf messages to production sysio.msgch, or its authenticated +/// downstream action boundary in focused contract suites. No sockets or daemons. +class chain { +public: + explicit chain(asset_spec asset) : asset_(asset) {} + const asset_spec& asset() const { return asset_; } + uint64_t custody() const { return custody_; } + uint64_t sequence() const { return sequence_; } + uint64_t paid() const { return paid_; } + size_t pending_count() const { return pending_.size(); } + void freeze() { frozen_ = true; } + void clear() { frozen_ = false; } + + /// Back a bootstrap import or repair a custody deficit without reporting yield. + void donate(uint64_t amount) { + FC_ASSERT(amount <= std::numeric_limits::max() - custody_, "custody overflow"); + custody_ += amount; + } + /// Explicit fault injection, used to prove the depot notices an honest report + /// of a shortfall. This is never an ordinary outpost operation. + void lose(uint64_t amount) { + FC_ASSERT(amount <= custody_, "custody underflow"); + custody_ -= amount; + } + attestation deposit(const std::vector& public_key, uint64_t amount) { + FC_ASSERT(amount > 0 && amount <= uint64_t(std::numeric_limits::max()), "invalid deposit amount"); + FC_ASSERT(sequence_ != std::numeric_limits::max(), "sequence overflow"); + donate(amount); + attestations::SyndicateLIQ message; + message.set_chain_code(fc::slug_name{asset_.chain}.value); + message.mutable_user()->set_kind(asset_.kind); + message.mutable_user()->set_address(public_key.data(), public_key.size()); + message.mutable_amount()->set_token_code(fc::slug_name{asset_.token}.value); + message.mutable_amount()->set_amount(amount); + message.set_sequence(++sequence_); + message.set_total_syndicated(custody_); + return {AttestationType::ATTESTATION_TYPE_SYNDICATE_LIQ, message.SerializeAsString()}; + } + attestation yield(uint64_t amount) { + FC_ASSERT(amount > 0 && amount <= uint64_t(std::numeric_limits::max()), "invalid yield amount"); + FC_ASSERT(sequence_ != std::numeric_limits::max(), "sequence overflow"); + FC_ASSERT(yield_epoch_ != std::numeric_limits::max(), "yield epoch overflow"); + donate(amount); + attestations::LIQYield message; + message.set_chain_code(fc::slug_name{asset_.chain}.value); + message.mutable_amount()->set_token_code(fc::slug_name{asset_.token}.value); + message.mutable_amount()->set_amount(amount); + message.set_sequence(++sequence_); + message.set_epoch(++yield_epoch_); + message.set_total_syndicated(custody_); + return {AttestationType::ATTESTATION_TYPE_LIQ_YIELD, message.SerializeAsString()}; + } + + /// Build a canonical message chain. Calling this commits the simulator's + /// transport cursor; fault/replay tests retain and redeliver the returned bytes. + std::vector envelope(uint32_t epoch, const std::vector& entries) { + Envelope result; + result.set_epoch_index(epoch); + result.set_epoch_envelope_index(1); + result.set_epoch_timestamp(EnvelopeTimestamp); + result.set_previous_envelope_hash(previous_envelope_); + auto* message = result.add_messages(); + for (const auto& [type, data] : entries) { + auto* entry = message->mutable_payload()->add_attestations(); + entry->set_type(type); + entry->set_data(data); + entry->set_data_size(data.size()); + } + oracle::finalize_header(*message, previous_message_, EnvelopeTimestamp); + previous_envelope_ = oracle::digest_bytes(oracle::epoch_digest(result)); + previous_message_ = message->header().message_id(); + const auto bytes = result.SerializeAsString(); + return {bytes.begin(), bytes.end()}; + } + + /// Consume a real depot-emitted return instruction. Replays cannot move + /// custody twice; a frozen outpost retains a pending obligation until cleared. + bool receive(const attestations::DesyndicateLIQ& message) { + FC_ASSERT(message.chain_code() == fc::slug_name(asset_.chain).value, "wrong return chain"); + FC_ASSERT(message.amount().token_code() == fc::slug_name(asset_.token).value, "wrong return token"); + FC_ASSERT(message.request_id() != 0 && message.amount().amount() > 0, "invalid return"); + const auto id = message.request_id(); + const auto [seen, fresh] = received_.emplace(id, message.SerializeAsString()); + FC_ASSERT(fresh || seen->second == message.SerializeAsString(), "conflicting return replay"); + if (paid_requests_.contains(id)) return false; + const auto amount = message.amount().amount(); + const auto [it, inserted] = pending_.emplace(id, amount); + FC_ASSERT(inserted || it->second == amount, "conflicting return replay"); + return settle(id); + } + bool settle(uint64_t id) { + const auto it = pending_.find(id); + if (frozen_ || it == pending_.end()) return false; + FC_ASSERT(it->second <= custody_, "insufficient external custody"); + custody_ -= it->second; + paid_ += it->second; + pending_.erase(it); + paid_requests_.insert(id); + return true; + } +private: + asset_spec asset_; + uint64_t custody_ = 0, sequence_ = 0, yield_epoch_ = 0, paid_ = 0; + bool frozen_ = false; + std::map pending_; + std::set paid_requests_; + std::map received_; + std::string previous_envelope_, previous_message_; +}; +} // namespace sysio::testing::external diff --git a/contracts/tests/external_chain_simulator_tests.cpp b/contracts/tests/external_chain_simulator_tests.cpp new file mode 100644 index 0000000000..e7ae640558 --- /dev/null +++ b/contracts/tests/external_chain_simulator_tests.cpp @@ -0,0 +1,74 @@ +#include +#include "external_chain_simulator.hpp" + +using namespace sysio::testing::external; +using namespace sysio::opp; + +BOOST_AUTO_TEST_SUITE(external_chain_simulator_tests) + +BOOST_AUTO_TEST_CASE(custody_reports_share_a_monotonic_sequence_and_keep_chains_independent) { + chain first(First), second(Second); + const std::vector key(33, 'a'); + attestations::SyndicateLIQ deposit; + BOOST_REQUIRE(deposit.ParseFromString(first.deposit(key, Unit).second)); + attestations::LIQYield yield; + BOOST_REQUIRE(yield.ParseFromString(first.yield(Unit).second)); + BOOST_CHECK_EQUAL(deposit.total_syndicated(), Unit); + BOOST_CHECK_EQUAL(yield.total_syndicated(), 2 * Unit); + BOOST_CHECK_EQUAL(deposit.sequence(), 1); + BOOST_CHECK_EQUAL(yield.sequence(), 2); + BOOST_CHECK_EQUAL(second.custody(), 0); + BOOST_CHECK_EQUAL(second.sequence(), 0); + BOOST_CHECK_THROW(second.lose(Unit), fc::exception); +} + +BOOST_AUTO_TEST_CASE(envelopes_continue_both_canonical_hash_chains) { + chain outpost(First); + const auto one = outpost.envelope(1, {outpost.yield(Unit)}); + const auto two = outpost.envelope(2, {outpost.yield(Unit)}); + Envelope a, b; + BOOST_REQUIRE(a.ParseFromArray(one.data(), one.size())); + BOOST_REQUIRE(b.ParseFromArray(two.data(), two.size())); + BOOST_CHECK_EQUAL(b.previous_envelope_hash(), oracle::digest_bytes(oracle::epoch_digest(a))); + BOOST_CHECK_EQUAL(b.messages(0).header().previous_message_id(), a.messages(0).header().message_id()); +} + +BOOST_AUTO_TEST_CASE(frozen_returns_are_pending_and_settle_exactly_once) { + chain outpost(First); + outpost.donate(3 * Unit); + attestations::DesyndicateLIQ message; + message.set_request_id(1); + message.set_chain_code(fc::slug_name{First.chain}.value); + message.mutable_amount()->set_token_code(fc::slug_name{First.token}.value); + message.mutable_amount()->set_amount(Unit); + outpost.freeze(); + BOOST_CHECK(!outpost.receive(message)); + BOOST_CHECK(!outpost.receive(message)); + BOOST_CHECK_EQUAL(outpost.pending_count(), 1); + BOOST_CHECK_EQUAL(outpost.custody(), 3 * Unit); + auto conflict = message; + conflict.mutable_amount()->set_amount(2 * Unit); + BOOST_CHECK_THROW(outpost.receive(conflict), fc::exception); + outpost.clear(); + BOOST_CHECK(outpost.settle(1)); + BOOST_CHECK(!outpost.receive(message)); + BOOST_CHECK_EQUAL(outpost.custody(), 2 * Unit); + BOOST_CHECK_EQUAL(outpost.paid(), Unit); + BOOST_CHECK_EQUAL(outpost.pending_count(), 0); + BOOST_CHECK_THROW(outpost.receive(conflict), fc::exception); + message.set_chain_code(fc::slug_name{Second.chain}.value); + BOOST_CHECK_THROW(outpost.receive(message), fc::exception); +} +BOOST_AUTO_TEST_CASE(invalid_reports_do_not_mutate_custody_or_cursors) { + chain outpost(First); + const std::vector key(33, 'a'); + BOOST_CHECK_THROW(outpost.deposit(key, 0), fc::exception); + BOOST_CHECK_THROW(outpost.yield(std::numeric_limits::max()), fc::exception); + BOOST_CHECK_EQUAL(outpost.custody(), 0); + BOOST_CHECK_EQUAL(outpost.sequence(), 0); + outpost.donate(std::numeric_limits::max()); + BOOST_CHECK_THROW(outpost.deposit(key, Unit), fc::exception); + BOOST_CHECK_EQUAL(outpost.custody(), std::numeric_limits::max()); + BOOST_CHECK_EQUAL(outpost.sequence(), 0); +} +BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/liq_test_support.hpp b/contracts/tests/liq_test_support.hpp deleted file mode 100644 index d21fa28da8..0000000000 --- a/contracts/tests/liq_test_support.hpp +++ /dev/null @@ -1,54 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include -#include - -/// Test support for sysio.liq's kv tables, shared by the liq and dispatch suites. -namespace sysio_liq::test_support { - -/// The kv key of a `sysio.liq::parked` row: the symbol code and the chain kind as -/// big-endian words, then the pubkey NUL-escaped and NUL-NUL terminated (the kv key -/// encoding of the contract's `parked_key`). -inline std::string parked_key(sysio::chain::symbol_code sym, sysio::opp::types::ChainKind kind, - const std::vector& pubkey) { - std::string key; - for (uint64_t word : { sym.value, static_cast(magic_enum::enum_integer(kind)) }) - for (int shift = 56; shift >= 0; shift -= 8) key.push_back(char((word >> shift) & 0xff)); - for (char c : pubkey) { - key.push_back(c); - if (c == '\0') key.push_back('\x01'); - } - key.push_back('\0'); - key.push_back('\0'); - return key; -} - -/// The serialized `parked` row stored under `key` by `liq_account`, empty when absent. -inline std::vector parked_row_bytes(const sysio::chain::controller& control, sysio::chain::name liq_account, - const std::string& key) { - const auto& kv_idx = control.db().get_index(); - const auto itr = kv_idx.find(boost::make_tuple( - liq_account, sysio::chain::compute_table_id(sysio::chain::name{ "parked" }.to_uint64_t()), std::string_view(key))); - if (itr == kv_idx.end()) return {}; - return std::vector(itr->value.data(), itr->value.data() + itr->value.size()); -} - -/// The chain-native address `sysio.authex::recordlink` carries beside a linked key. On SVM -/// that is the ED key's own 32 bytes -- the form `sysio.liq` parks against and sweeps by. -inline std::vector native_address_of(const fc::crypto::public_key& pub_key) { - const auto raw = pub_key.get().serialize(); - return std::vector(raw.begin(), raw.end()); -} - -} // namespace sysio_liq::test_support diff --git a/contracts/tests/sysio.authex_tests.cpp b/contracts/tests/sysio.authex_tests.cpp index 336a08a822..aaf088c08e 100644 --- a/contracts/tests/sysio.authex_tests.cpp +++ b/contracts/tests/sysio.authex_tests.cpp @@ -29,25 +29,7 @@ using namespace std; using mvo = fc::mutable_variant_object; using ChainKind = sysio::opp::types::ChainKind; -// Replicate the contract's pubkey_to_string for EM keys: -// "PUB_EM_" + hex(compressed_33_bytes) -static std::string contract_pubkey_to_string(const fc::crypto::public_key& pk) { - const auto& shim = pk.get(); - auto compressed = shim.serialize(); // std::array - return "PUB_EM_" + fc::to_hex(compressed.data(), compressed.size()); -} - -// Build the message string exactly as the contract does -static std::string build_link_message( - const fc::crypto::public_key& pub_key, - const std::string& account, - ChainKind chain_kind, - uint64_t nonce -) { - auto pub_key_str = contract_pubkey_to_string(pub_key); - auto chain_kind_str = std::to_string(magic_enum::enum_integer(chain_kind)); - return pub_key_str + "|" + account + "|" + chain_kind_str + "|" + std::to_string(nonce) + "|createlink auth"; -} +using sysio_system::test_support::build_link_message; // ——— Tester class ——— class sysio_authex_tester : public tester { diff --git a/contracts/tests/sysio.bond_tests.cpp b/contracts/tests/sysio.bond_tests.cpp index d83fa0d7f9..1a7907fe9d 100644 --- a/contracts/tests/sysio.bond_tests.cpp +++ b/contracts/tests/sysio.bond_tests.cpp @@ -11,8 +11,6 @@ #include "contracts.hpp" #include "contract_test_support.hpp" -#include "liq_test_support.hpp" -#include #include #include @@ -128,7 +126,8 @@ struct raw_yield_index { FC_REFLECT(raw_yield_index, (index)(pot)(carry)) /// sysio.bond on the depot: the registries and custody contracts it resolves tokens through, -/// sysio.liq as the shadow custody contract and yield source, with governance funding fixture balances. The treasury supply of WIRE sits on `sysio`, as the bootstrap leaves it. +/// sysio.liq as the shadow custody contract and yield source, and sysio.synd as the signer that +/// mints shadow LIQ (an account only). The treasury supply of WIRE sits on `sysio`, as the bootstrap leaves it. class sysio_bond_tester : public tester { public: static constexpr auto BOND_ACCOUNT = "sysio.bond"_n; @@ -175,7 +174,7 @@ class sysio_bond_tester : public tester { BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, LIQSOL, 9)); BOOST_REQUIRE_EQUAL(success(), create_shadow(LIQSOL_SYM, LIQSOL)); - // Shadow LIQSOL for the issuer and two underwriters, funded through governance credit. + // Shadow LIQSOL for the issuer and two underwriters, minted by sysio.synd, the only minter. for (auto holder : { "alice"_n, "bob"_n, "issuer"_n }) { BOOST_REQUIRE_EQUAL(success(), mint(holder, 1'000 * UNIT)); BOOST_REQUIRE_EQUAL(1'000 * UNIT, liq_balance(holder)); @@ -227,11 +226,11 @@ class sysio_bond_tester : public tester { return push(LIQ_ACCOUNT, liq_abi_ser, LIQ_ACCOUNT, "create"_n, mvo() ("sym", sym)("chain_code", codename_mvo(SOLANA))("token_code", codename_mvo(token_code))); } - /// Seed a nine-decimal fixture token through the ledger's governance credit action. + /// Mint `amount` of the shadow of `token_code` (LIQSOL by default) to `account`, signed as + /// sysio.synd. action_result mint(name account, uint64_t amount, std::string_view token_code = LIQSOL) { - const symbol sym{LIQSOL_SYM.decimals(), std::string(token_code)}; - return push(LIQ_ACCOUNT, liq_abi_ser, LIQ_ACCOUNT, "recredit"_n, mvo() - ("holder", account)("quantity", asset(static_cast(amount), sym))); + return push(LIQ_ACCOUNT, liq_abi_ser, SYND_ACCOUNT, "mint"_n, mvo() + ("to", account)("token_code", codename_mvo(token_code))("amount", amount)); } /// Distribute `amount` WIRE from `from` to the holders of `sym` (LIQSOL by default) through /// sysio.liq, raising its index. @@ -240,23 +239,14 @@ class sysio_bond_tester : public tester { ("from", from)("quantity", asset(static_cast(amount), WIRE_SYM)) ("target", sym.to_symbol_code())); } - /// Burn the fixture holder's shadow through the ledger's desyndication action. - /// A trusted SVM link supplies the outbound destination for the supply-drain case. + /// Burn `amount` of `holder`'s shadow `sym` (the shadow of registry token `token_code`) the way a + /// de-syndication does: the holder moves it to sysio.synd, which burns it out of its own row. action_result burn_shadow(name holder, uint64_t amount, symbol sym, std::string_view token_code) { - constexpr auto authex_account = "sysio.authex"_n; - constexpr auto msgch_account = "sysio.msgch"_n; - for (auto account : {authex_account, msgch_account}) - if (control->find_account(account) == nullptr) create_accounts({account}); - abi_serializer authex_ser, msgch_ser; - deploy(authex_account, contracts::authex_wasm(), contracts::authex_abi(), authex_ser, true); - deploy(msgch_account, contracts::msgch_wasm(), contracts::msgch_abi(), msgch_ser, true); - const auto pubkey = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::ed).get_public_key(); - auto result = push(authex_account, authex_ser, authex_account, "recordlink"_n, mvo() - ("account", holder)("chain_kind", ChainKind::CHAIN_KIND_SVM)("pub_key", pubkey) - ("native_address", sysio_liq::test_support::native_address_of(pubkey))); - if (result != success()) return result; - return push(LIQ_ACCOUNT, liq_abi_ser, holder, "desyndicate"_n, mvo() - ("holder", holder)("quantity", asset(static_cast(amount), sym))); + auto r = push(LIQ_ACCOUNT, liq_abi_ser, holder, "transfer"_n, mvo() + ("from", holder)("to", SYND_ACCOUNT)("quantity", asset(static_cast(amount), sym))("memo", "")); + if (r != success()) return r; + return push(LIQ_ACCOUNT, liq_abi_ser, SYND_ACCOUNT, "burn"_n, mvo() + ("token_code", codename_mvo(token_code))("amount", amount)); } /// sysio.liq pays `holder` the WIRE its LIQSOL row is owed. action_result liq_claim(name holder) { diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index 45a2411623..d7b9620273 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -45,8 +45,8 @@ #include #include "contracts.hpp" +#include "external_chain_simulator.hpp" #include "contract_test_support.hpp" -#include "liq_test_support.hpp" #include "test_symbol.hpp" // Canonical-encoding + header-derivation oracle: inbound envelopes must carry // spec-derived semantic headers or apply_consensus drops them before dispatch. @@ -59,6 +59,7 @@ using namespace sysio::opp::types; using mvo = fc::mutable_variant_object; using sysio_system::test_support::codename_mvo; +using sysio_system::test_support::sign_createlink; namespace { @@ -67,6 +68,10 @@ constexpr uint32_t PROTOBUF_VARINT_PAYLOAD_BITS = 7u; constexpr uint8_t PROTOBUF_VARINT_CONTINUATION_BIT = 0x80u; constexpr uint32_t PROTOBUF_FIELD_TAG_SHIFT = 3u; +/// The outpost custody a SyndicateLIQ or LIQYield fixture carries by default: the whole asset range, at +/// or above any outstanding shadow a dispatch test can reach. +constexpr uint64_t CUSTODY_COVERING_ANY_SUPPLY = static_cast(sysio::chain::asset::max_amount); + /** Append one unsigned protobuf varint to a hostile-wire-format fixture. */ void append_proto_varint(std::vector& out, uint64_t value) { do { @@ -195,28 +200,6 @@ std::vector encode_envelope_padded_to(uint32_t epoch_index, size_t target_ return padded; } -/// Render an EM public key into its canonical contract string — -/// "PUB_EM_" + hex(compressed_33_bytes). -std::string contract_em_pubkey_to_string(const fc::crypto::public_key& pk) { - const auto& shim = pk.get(); - auto compressed = shim.serialize(); // std::array - return "PUB_EM_" + fc::to_hex(compressed.data(), compressed.size()); -} - -/// Build the createlink message string exactly as `sysio.authex::createlink` -/// composes it on-chain. -std::string build_link_message( - const fc::crypto::public_key& pub_key, - const std::string& account, - sysio::opp::types::ChainKind chain_kind, - uint64_t nonce) -{ - auto pub_key_str = contract_em_pubkey_to_string(pub_key); - auto chain_kind_str = std::to_string(magic_enum::enum_integer(chain_kind)); - return pub_key_str + "|" + account + "|" + chain_kind_str + "|" + - std::to_string(nonce) + "|createlink auth"; -} - /// Extract the raw 33-byte compressed pubkey from an EM `public_key`. std::vector em_pubkey_bytes(const fc::crypto::public_key& pk) { const auto& shim = pk.get(); @@ -332,11 +315,13 @@ std::string encode_swap_request( } /// Encode a SyndicateLIQ attestation payload: the emitting outpost, the syndicating user's -/// native pubkey (kind + bytes), the liq TokenAmount, and the per-outpost sequence. +/// native pubkey (kind + bytes), the liq TokenAmount, the per-outpost sequence and the outpost's +/// live custody total. std::string encode_syndicate_liq(uint64_t chain_code_v, sysio::opp::types::ChainKind user_kind, const std::vector& user_pubkey, - uint64_t token_code_v, int64_t amount, uint64_t sequence) + uint64_t token_code_v, int64_t amount, uint64_t sequence, + uint64_t total_syndicated = CUSTODY_COVERING_ANY_SUPPLY) { sysio::opp::attestations::SyndicateLIQ synd; synd.set_chain_code(chain_code_v); @@ -347,6 +332,7 @@ std::string encode_syndicate_liq(uint64_t chain_code_v, amt->set_token_code(token_code_v); amt->set_amount(amount); synd.set_sequence(sequence); + synd.set_total_syndicated(total_syndicated); std::string out; synd.SerializeToString(&out); @@ -354,9 +340,11 @@ std::string encode_syndicate_liq(uint64_t chain_code_v, } /// Encode a LIQYield attestation payload: the outpost's claimed yield in its liq token, -/// the per-outpost sequence it shares with SyndicateLIQ, and the outpost epoch of the report. +/// the per-outpost sequence it shares with SyndicateLIQ, the outpost epoch of the report and the +/// outpost's live custody total. std::string encode_liq_yield(uint64_t chain_code_v, uint64_t token_code_v, int64_t amount, - uint64_t sequence, uint64_t epoch) + uint64_t sequence, uint64_t epoch, + uint64_t total_syndicated = CUSTODY_COVERING_ANY_SUPPLY) { sysio::opp::attestations::LIQYield report; report.set_chain_code(chain_code_v); @@ -365,6 +353,7 @@ std::string encode_liq_yield(uint64_t chain_code_v, uint64_t token_code_v, int64 amt->set_amount(amount); report.set_sequence(sequence); report.set_epoch(epoch); + report.set_total_syndicated(total_syndicated); std::string out; report.SerializeToString(&out); @@ -516,12 +505,7 @@ class sysio_dispatch_tester : public tester { auto pub = priv.get_public_key(); const uint64_t nonce = control->head().block_time().time_since_epoch().count() / 1000; - - auto msg = build_link_message(pub, account.to_string(), - ChainKind::CHAIN_KIND_EVM, nonce); - auto msg_hash = keccak256::hash(msg); - auto sig = priv.sign(fc::sha256(reinterpret_cast(msg_hash.data()), - 32)); + auto sig = sign_createlink(priv, account.to_string(), ChainKind::CHAIN_KIND_EVM, nonce); BOOST_REQUIRE_EQUAL(success(), push(AUTHEX_ACCOUNT, authex_abi, account, "createlink"_n, mvo() @@ -1486,43 +1470,48 @@ class sysio_dispatch_tester : public tester { push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "advance"_n, mvo())); } - // ── sysio.liq inbound routing (SYNDICATE_LIQ / LIQ_YIELD) ───────────────── + // ── sysio.synd inbound routing (SYNDICATE_LIQ / LIQ_YIELD) ──────────────── static constexpr auto TOKENS_ACCOUNT = "sysio.tokens"_n; static constexpr auto LIQ_ACCOUNT = "sysio.liq"_n; + static constexpr auto SYND_ACCOUNT = "sysio.synd"_n; static inline const symbol LIQETH_SYM = symbol::from_string("9,LIQETH"); /// One whole liq token in the depot's 9-decimal frame. static constexpr int64_t LIQ_UNIT = 1'000'000'000; /// Register `code` on sysio.tokens as `kind` at the depot's 9-decimal precision and bind /// it to `chain_code` (EVM address bytes; the registries only check the length). - action_result regtoken(TokenKind kind, std::string_view code, std::string_view chain_code) { - const std::vector addr(20, '\x5a'); + action_result regtoken(TokenKind kind, std::string_view code, std::string_view chain_code, + ChainKind family = ChainKind::CHAIN_KIND_EVM) { + const std::vector addr(family == ChainKind::CHAIN_KIND_SVM ? 32 : 20, '\x5a'); auto r = push(TOKENS_ACCOUNT, tokens_abi, TOKENS_ACCOUNT, "regtoken"_n, mvo() ("kind", kind)("code", codename_mvo(code))("symbol_name", std::string(code)) ("description", std::string{})("precision", 9) - ("address", mvo()("kind", ChainKind::CHAIN_KIND_EVM)("address", addr))); + ("address", mvo()("kind", family)("address", addr))); if (r != success()) return r; return push(TOKENS_ACCOUNT, tokens_abi, TOKENS_ACCOUNT, "regctok"_n, mvo() ("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(code)) ("contract_addr", addr)("is_native", false)); } - /// Deploy sysio.tokens + sysio.liq and register the bootstrapped outpost's liq token - /// ("LIQETH" on ETH) with its shadow, plus two tokens the shadow ledger must refuse: a + /// Deploy sysio.tokens, sysio.liq and sysio.synd and register the bootstrapped outpost's liq + /// token ("LIQETH" on ETH) with its shadow, plus two tokens the syndication intake must refuse: a /// plain ERC20 ("USDCETH") and a liq token nobody opened a shadow for ("LIQTWO"). /// `bootstrap_for_dispatch` must have run first — registrations inside the epoch-0 /// bootstrap window land ACTIVE. - void setup_liq_for_dispatch() { - create_accounts({TOKENS_ACCOUNT, LIQ_ACCOUNT}); + void setup_liq_for_dispatch(std::string_view chain = "ETH", std::string_view token = "LIQETH", + ChainKind family = ChainKind::CHAIN_KIND_EVM) { + create_accounts({TOKENS_ACCOUNT, LIQ_ACCOUNT, SYND_ACCOUNT}); produce_blocks(); deploy(TOKENS_ACCOUNT, contracts::tokens_wasm(), contracts::tokens_abi(), tokens_abi); deploy(LIQ_ACCOUNT, contracts::liq_wasm(), contracts::liq_abi(), liq_abi); - BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, "LIQETH", "ETH")); - BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, "LIQTWO", "ETH")); - BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_ERC20, "USDCETH", "ETH")); + deploy(SYND_ACCOUNT, contracts::synd_wasm(), contracts::synd_abi(), synd_abi); + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, token, chain, family)); + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, "LIQTWO", chain, family)); + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_ERC20, "USDCETH", chain, family)); BOOST_REQUIRE_EQUAL(success(), push(LIQ_ACCOUNT, liq_abi, LIQ_ACCOUNT, "create"_n, mvo() - ("sym", LIQETH_SYM)("chain_code", codename_mvo("ETH"))("token_code", codename_mvo("LIQETH")))); + ("sym", symbol::from_string("9," + std::string(token)))("chain_code", codename_mvo(chain)) + ("token_code", codename_mvo(token)))); produce_blocks(); } @@ -1533,36 +1522,92 @@ class sysio_dispatch_tester : public tester { } /// `holder`'s LIQETH shadow balance; 0 without a row. - int64_t liq_balance(name holder) { - const auto row = liq_row("accounts"_n, "account", holder, LIQETH_SYM.to_symbol_code().value); + int64_t liq_balance(name holder, symbol token = LIQETH_SYM) { + const auto row = liq_row("accounts"_n, "account", holder, token.to_symbol_code().value); return row.is_null() ? 0 : row["balance"].as().get_amount(); } /// The LIQETH shadow supply; 0 without a stat row. - int64_t liq_supply() { - const auto row = liq_row("stat"_n, "currency_stats", LIQ_ACCOUNT, LIQETH_SYM.to_symbol_code().value); + int64_t liq_supply(symbol token = LIQETH_SYM) { + const auto row = liq_row("stat"_n, "currency_stats", LIQ_ACCOUNT, token.to_symbol_code().value); return row.is_null() ? 0 : row["supply"].as().get_amount(); } /// LIQETH yield reported by the outpost and not yet queued to the swap; 0 without a row. - int64_t liq_pending() { - const auto row = liq_row("liqpending"_n, "pending_yield", LIQ_ACCOUNT, LIQETH_SYM.to_symbol_code().value); + int64_t liq_pending(symbol token = LIQETH_SYM) { + const auto row = liq_row("liqpending"_n, "pending_yield", LIQ_ACCOUNT, token.to_symbol_code().value); return row.is_null() ? 0 : row["quantity"].as().get_amount(); } - /// The per-outpost inbound cursor (`last_sequence`, `last_epoch`); null before any credit lands. - fc::variant liq_cursor(std::string_view chain_code) { - return liq_row("liqcursors"_n, "liq_cursor", LIQ_ACCOUNT, fc::slug_name{chain_code}.value); + /// Mint `amount` LIQETH shadow to `holder`, signed as sysio.synd, the ledger's only minter. + action_result mint_shadow(name holder, uint64_t amount) { + return push(LIQ_ACCOUNT, liq_abi, SYND_ACCOUNT, "mint"_n, mvo() + ("to", holder)("token_code", codename_mvo("LIQETH"))("amount", amount)); } - /// The LIQETH balance parked against an unlinked `pubkey` of `kind`; 0 without a row. - int64_t liq_parked(ChainKind kind, const std::vector& pubkey) { - using namespace sysio_liq::test_support; - const auto data = parked_row_bytes(*control, LIQ_ACCOUNT, parked_key(LIQETH_SYM.to_symbol_code(), kind, pubkey)); - if (data.empty()) return 0; - const auto row = liq_abi.binary_to_variant( - "parked_row", data, abi_serializer::create_yield_function(abi_serializer_max_time)); - return row["holding"]["balance"].as().get_amount(); + fc::variant synd_decode(const char* type, const std::vector& data) { + return data.empty() ? fc::variant() : synd_abi.binary_to_variant( + type, data, abi_serializer::create_yield_function(abi_serializer_max_time)); + } + + /// sysio.synd's kv rows of `table`, in key order, as raw values. + std::vector> synd_rows(name table) { + const auto& kv_idx = control->db().get_index(); + const auto table_id = compute_table_id(table.to_uint64_t()); + std::vector> rows; + for (auto itr = kv_idx.lower_bound(boost::make_tuple(SYND_ACCOUNT, table_id, std::string_view{})); + itr != kv_idx.end() && itr->code == SYND_ACCOUNT && itr->table_id == table_id; ++itr) + rows.emplace_back(itr->value.data(), itr->value.data() + itr->value.size()); + return rows; + } + + /// Every held item, in arrival order. + std::vector synd_items() { + std::vector items; + for (const auto& value : synd_rows("items"_n)) items.push_back(synd_decode("item_row", value)); + return items; + } + + /// The sysio.synd envelope row of `(chain_code, token_code, epoch)`; null when there is none. + fc::variant synd_envelope(std::string_view chain_code, std::string_view token_code, uint32_t epoch) { + for (const auto& value : synd_rows("envelopes"_n)) { + const auto row = synd_decode("envelope_row", value); + if (row["chain_code"].as_string() == chain_code && row["token_code"].as_string() == token_code && + row["epoch_index"].as() == epoch) + return row; + } + return fc::variant(); + } + + /// The per-outpost inbound cursor (`last_sequence`, `last_epoch`); null before any message is held. + fc::variant synd_cursor(std::string_view chain_code) { + return synd_decode("synd_cursor", get_row_by_id(SYND_ACCOUNT, SYND_ACCOUNT, "syndcursors"_n, + fc::slug_name{chain_code}.value)); + } + + /// The canonical digest of the last envelope sysio.msgch accepted from `chain_code`. + fc::sha256 accepted_envelope_digest(uint64_t chain_code) { + const auto data = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "outpcons"_n, chain_code); + BOOST_REQUIRE(!data.empty()); + return msgch_abi.binary_to_variant("outpost_consensus_entry", data, + abi_serializer::create_yield_function(abi_serializer_max_time))["envelope_digest"].as(); + } + + /// The data of every sysio.synd `action_name` action `trace` executed, decoded, in execution order. + std::vector synd_action_data(const transaction_trace_ptr& trace, name action_name) { + std::vector decoded; + for (const auto& at : trace->action_traces) + if (at.receiver == SYND_ACCOUNT && at.act.account == SYND_ACCOUNT && at.act.name == action_name) + decoded.push_back(synd_decode(action_name.to_string().c_str(), at.act.data)); + return decoded; + } + + /// The sysio.synd actions `trace` executed, in execution order. + static std::vector synd_actions(const transaction_trace_ptr& trace) { + std::vector actions; + for (const auto& at : trace->action_traces) + if (at.receiver == SYND_ACCOUNT && at.act.account == SYND_ACCOUNT) actions.push_back(at.act.name); + return actions; } /// Every action's console in `trace`, inline actions included: msgch's own drops print on @@ -1576,7 +1621,7 @@ class sysio_dispatch_tester : public tester { } abi_serializer msgch_abi, opreg_abi, uwrit_abi, epoch_abi, reserv_abi, authex_abi, dclaim_abi, - chains_abi, roa_abi, token_abi, tokens_abi, liq_abi; + chains_abi, roa_abi, token_abi, tokens_abi, liq_abi, synd_abi; std::vector uwrit_op_eth_pubkey; }; @@ -2271,6 +2316,36 @@ BOOST_FIXTURE_TEST_CASE(dispatch_routes_node_owner_reg_to_roa, sysio_dispatch_te BOOST_REQUIRE_EQUAL(pending["balance"].as().get_amount(), 4321); } FC_LOG_AND_RETHROW() } +// Only ATTESTATION_TYPE_NODE_OWNER_REG reaches the node-owner handler. The outbound-only types +// (OPERATORS, BATCH_OPERATOR_GROUPS, SWAP_REVERT, DEPOSIT_REVERT) are dropped on receipt even when +// the payload is a well-formed NodeOwnerRegistration from the node-owner source outpost: a payload +// that would register CLAIM_ACCOUNT under the real type must leave no owner row and no audit row. +BOOST_FIXTURE_TEST_CASE(outbound_only_types_never_reach_node_owner_dispatch, sysio_dispatch_tester) { try { + bootstrap_for_dispatch("ETHEREUM"); + + const auto eth_code = fc::slug_name{"ETHEREUM"}.value; + auto wire_key = k1_pubkey_bytes(get_public_key(CLAIM_ACCOUNT, "active")); + auto eth_pub = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em).get_public_key(); + auto eth_bytes = em_uncompressed_pubkey_bytes(eth_pub); + auto eth_address = fc::crypto::ethereum::address_to_bytes(eth_pub); + const auto payload = encode_node_owner_registration( + CLAIM_ACCOUNT.to_string(), /*tier=*/2, + sysio::opp::types::WIRE_KEY_TYPE_K1, wire_key, eth_bytes, eth_address); + + std::vector entries; + for (auto type : {ATTESTATION_TYPE_OPERATORS, ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS, + ATTESTATION_TYPE_SWAP_REVERT, ATTESTATION_TYPE_DEPOSIT_REVERT}) { + entries.emplace_back(type, payload); + } + const auto trace = deliver_trace(eth_code, + encode_envelope_with_mixed_attestations(current_epoch(), entries)); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + + BOOST_CHECK(get_nodeowner(CLAIM_ACCOUNT).is_null()); + BOOST_CHECK(get_nodeownerreg(CLAIM_ACCOUNT).is_null()); +} FC_LOG_AND_RETHROW() } + /// One slot and two fresh-name claims must commit the first, reject the second, and keep epochs moving. BOOST_FIXTURE_TEST_CASE(dispatch_node_owner_tier_cap_preserves_epoch_progress, sysio_dispatch_tester) { try { namespace owners = sysio_system::test_support::nodeowners; @@ -6937,14 +7012,15 @@ BOOST_FIXTURE_TEST_CASE(operator_action_with_a_malformed_address_is_dropped, BOOST_CHECK_EQUAL(0u, op["balances"].get_array().size()); } FC_LOG_AND_RETHROW() } -// ── SYNDICATE_LIQ / LIQ_YIELD -> sysio.liq ────────────────────────────────── +// ── SYNDICATE_LIQ / LIQ_YIELD -> sysio.synd ───────────────────────────────── -// SYNDICATE_LIQ routes on the AuthX link: a linked user's syndication credits their shadow -// balance through sysio.liq::mintsynd, an unlinked user's is parked against the pubkey through -// sysio.liq::park, and the per-outpost sequence is consumed only by a credit that lands — a -// replay is dropped, a gap is admitted. A malformed payload and an echoed DESYNDICATE_LIQ are -// dropped too, and nothing aborts the envelope. -BOOST_FIXTURE_TEST_CASE(syndicate_liq_credits_a_linked_user_and_parks_an_unlinked_one, +// SYNDICATE_LIQ routes to sysio.synd::onsynd with the envelope's identity, whether the user's key is +// AuthX-linked or not: sysio.synd holds every syndication as an item of the accepted envelope, +// minted into its own sysio.liq row, and no account is credited at intake. The per-outpost sequence +// is consumed only by a message that is held -- a replay is dropped, a gap is admitted. A malformed +// payload and an echoed DESYNDICATE_LIQ are dropped too, nothing aborts the envelope, and after the +// last attestation sysio.msgch closes the envelope with one `closeenv`. +BOOST_FIXTURE_TEST_CASE(msgch_routes_syndicate_liq_to_synd, sysio_dispatch_tester) { try { bootstrap_for_dispatch(); setup_liq_for_dispatch(); @@ -6955,11 +7031,12 @@ BOOST_FIXTURE_TEST_CASE(syndicate_liq_credits_a_linked_user_and_parks_an_unlinke const auto stranger_key = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); const auto stranger = em_pubkey_bytes(stranger_key.get_public_key()); constexpr auto EVM = ChainKind::CHAIN_KIND_EVM; + const uint32_t epoch = current_epoch(); - const auto env = encode_envelope_with_mixed_attestations(current_epoch(), { + const auto env = encode_envelope_with_mixed_attestations(epoch, { {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 5 * LIQ_UNIT, 1)}, {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, stranger, liqeth, 3 * LIQ_UNIT, 2)}, - // sequence 2 again: a replay, dropped by sysio.liq + // sequence 2 again: a replay, dropped by sysio.synd {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 4 * LIQ_UNIT, 2)}, // not a SyndicateLIQ at all {ATTESTATION_TYPE_SYNDICATE_LIQ, std::string(1, '\x0a')}, @@ -6973,30 +7050,98 @@ BOOST_FIXTURE_TEST_CASE(syndicate_liq_credits_a_linked_user_and_parks_an_unlinke BOOST_REQUIRE(!trace->except); const auto console = all_console(trace); - BOOST_CHECK_EQUAL(6 * LIQ_UNIT, liq_balance(UWRIT_OP)); - BOOST_CHECK_EQUAL(3 * LIQ_UNIT, liq_parked(EVM, stranger)); + // Four onsynd (the malformed one never leaves msgch), then exactly one closeenv, last. + const auto actions = synd_actions(trace); + BOOST_REQUIRE_EQUAL(5u, actions.size()); + for (size_t i = 0; i < 4; ++i) BOOST_CHECK_EQUAL("onsynd"_n, actions[i]); + BOOST_CHECK_EQUAL("closeenv"_n, actions[4]); + BOOST_CHECK_NE(std::string::npos, console.find("sysio.synd::onsynd: DROP -- replayed sequence")); + + // The linked and the unlinked key are held alike; nobody is credited. + const auto items = synd_items(); + BOOST_REQUIRE_EQUAL(3u, items.size()); + BOOST_CHECK(uwrit_op_eth_pubkey == items[0]["pubkey"].as>()); + BOOST_CHECK(stranger == items[1]["pubkey"].as>()); + BOOST_CHECK(uwrit_op_eth_pubkey == items[2]["pubkey"].as>()); + BOOST_CHECK_EQUAL(5 * LIQ_UNIT, items[0]["amount"].as()); + BOOST_CHECK_EQUAL(3 * LIQ_UNIT, items[1]["amount"].as()); + BOOST_CHECK_EQUAL(1 * LIQ_UNIT, items[2]["amount"].as()); + for (const auto& item : items) { + BOOST_CHECK_EQUAL("SYNDICATION", item["kind"].as_string()); + BOOST_CHECK_EQUAL("CHAIN_KIND_EVM", item["chain_kind"].as_string()); + BOOST_CHECK_EQUAL(epoch, item["epoch_index"].as()); + } + BOOST_CHECK_EQUAL(9 * LIQ_UNIT, liq_balance(SYND_ACCOUNT)); + BOOST_CHECK_EQUAL(0, liq_balance(UWRIT_OP)); BOOST_CHECK_EQUAL(9 * LIQ_UNIT, liq_supply()); BOOST_CHECK_EQUAL(0, liq_pending()); - const auto cursor = liq_cursor("ETH"); + const auto cursor = synd_cursor("ETH"); BOOST_REQUIRE(!cursor.is_null()); BOOST_CHECK_EQUAL(9u, cursor["last_sequence"].as()); BOOST_CHECK_EQUAL(0u, cursor["last_epoch"].as()); - BOOST_CHECK_NE(std::string::npos, console.find("sysio.liq::mintsynd: DROP -- replayed sequence")); - // The stranger links the key later: createlink sweeps the parked shadow into the new - // account inline, so nothing is left parked and no permissionless sweep is needed. + // The envelope carries the digest msgch accepted, and closeenv left it WAITING. + const auto envelope = synd_envelope("ETH", "LIQETH", epoch); + BOOST_REQUIRE(!envelope.is_null()); + BOOST_CHECK_EQUAL("WAITING", envelope["state"].as_string()); + BOOST_CHECK_EQUAL(9 * LIQ_UNIT, envelope["synd_total"].as()); + BOOST_CHECK_EQUAL(3u, envelope["item_count"].as()); + BOOST_CHECK_EQUAL(accepted_envelope_digest(eth), envelope["digest"].as()); + + // Linking the stranger's key later credits nothing at intake: the item stays held. create_accounts({"stranger"_n}); produce_blocks(); create_eth_authex_link("stranger"_n, stranger_key); - BOOST_CHECK_EQUAL(3 * LIQ_UNIT, liq_balance("stranger"_n)); - BOOST_CHECK_EQUAL(0, liq_parked(EVM, stranger)); - BOOST_CHECK_EQUAL(9 * LIQ_UNIT, liq_supply()); + BOOST_CHECK_EQUAL(0, liq_balance("stranger"_n)); + BOOST_CHECK_EQUAL(3u, synd_items().size()); + BOOST_CHECK_EQUAL(9 * LIQ_UNIT, liq_balance(SYND_ACCOUNT)); +} FC_LOG_AND_RETHROW() } + +// sysio.msgch forwards each message's outpost custody total verbatim as the last argument of +// `onsynd` and `onyield`: every syndication and yield report of one envelope carries its own reading. +BOOST_FIXTURE_TEST_CASE(msgch_forwards_the_custody_total_to_synd, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + setup_liq_for_dispatch(); + + const auto eth = fc::slug_name{"ETH"}.value; + const auto liqeth = fc::slug_name{"LIQETH"}.value; + constexpr auto EVM = ChainKind::CHAIN_KIND_EVM; + // The custody the outpost read after each lock and after the claim, 1 base unit apart from any + // amount so a forwarded amount cannot pass for the total. + const uint64_t first_total = 5 * LIQ_UNIT + 1; + const uint64_t second_total = 8 * LIQ_UNIT + 1; + const uint64_t yield_total = 10 * LIQ_UNIT + 1; + + const auto env = encode_envelope_with_mixed_attestations(current_epoch(), { + {ATTESTATION_TYPE_SYNDICATE_LIQ, + encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 5 * LIQ_UNIT, 1, first_total)}, + {ATTESTATION_TYPE_SYNDICATE_LIQ, + encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 3 * LIQ_UNIT, 2, second_total)}, + {ATTESTATION_TYPE_LIQ_YIELD, encode_liq_yield(eth, liqeth, 2 * LIQ_UNIT, 3, 42, yield_total)}, + }); + const auto trace = deliver_trace(/*proven=*/ eth, env); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + + const auto syndications = synd_action_data(trace, "onsynd"_n); + BOOST_REQUIRE_EQUAL(2u, syndications.size()); + BOOST_CHECK_EQUAL(5 * LIQ_UNIT, syndications[0]["amount"].as()); + BOOST_CHECK_EQUAL(first_total, syndications[0]["total_syndicated"].as_uint64()); + BOOST_CHECK_EQUAL(3 * LIQ_UNIT, syndications[1]["amount"].as()); + BOOST_CHECK_EQUAL(second_total, syndications[1]["total_syndicated"].as_uint64()); + const auto reports = synd_action_data(trace, "onyield"_n); + BOOST_REQUIRE_EQUAL(1u, reports.size()); + BOOST_CHECK_EQUAL(2 * LIQ_UNIT, reports[0]["amount"].as()); + BOOST_CHECK_EQUAL(yield_total, reports[0]["total_syndicated"].as_uint64()); + // Forwarding changes nothing about intake: both syndications and the report are held. + BOOST_CHECK_EQUAL(3u, synd_items().size()); + BOOST_CHECK_EQUAL(8 * LIQ_UNIT, liq_supply()); } FC_LOG_AND_RETHROW() } // The user's key family must be the proven outpost's own. A Solana-family key inside an -// Ethereum envelope is dropped by msgch before the AuthX lookup, whether an account has linked -// it (mintsynd receives no family and would have credited that account) or not (park would -// have refused it); the EVM credit beside them still lands and is the only sequence consumed. +// Ethereum envelope is dropped by msgch before it reaches sysio.synd, whether an account has linked +// it or not; the EVM syndication beside them is still held and is the only sequence consumed. BOOST_FIXTURE_TEST_CASE(syndicate_liq_refuses_a_key_of_another_chain_family, sysio_dispatch_tester) { try { bootstrap_for_dispatch(); @@ -7028,26 +7173,32 @@ BOOST_FIXTURE_TEST_CASE(syndicate_liq_refuses_a_key_of_another_chain_family, BOOST_REQUIRE(!trace->except); const auto console = all_console(trace); - BOOST_CHECK_EQUAL(2 * LIQ_UNIT, liq_balance(UWRIT_OP)); - BOOST_CHECK_EQUAL(0, liq_parked(SVM, linked_sol)); - BOOST_CHECK_EQUAL(0, liq_parked(SVM, stranger_sol)); + const auto actions = synd_actions(trace); + BOOST_REQUIRE_EQUAL(2u, actions.size()); + BOOST_CHECK_EQUAL("onsynd"_n, actions[0]); + BOOST_CHECK_EQUAL("closeenv"_n, actions[1]); + const auto items = synd_items(); + BOOST_REQUIRE_EQUAL(1u, items.size()); + BOOST_CHECK(uwrit_op_eth_pubkey == items[0]["pubkey"].as>()); + BOOST_CHECK_EQUAL(2 * LIQ_UNIT, liq_balance(SYND_ACCOUNT)); + BOOST_CHECK_EQUAL(0, liq_balance(UWRIT_OP)); BOOST_CHECK_EQUAL(2 * LIQ_UNIT, liq_supply()); - const auto cursor = liq_cursor("ETH"); + const auto cursor = synd_cursor("ETH"); BOOST_REQUIRE(!cursor.is_null()); BOOST_CHECK_EQUAL(3u, cursor["last_sequence"].as()); const std::string dropped = "msgch::dispatch_syndicate_liq: DROP attestation -- user kind CHAIN_KIND_SVM"; const auto first = console.find(dropped); BOOST_REQUIRE_NE(std::string::npos, first); BOOST_CHECK_NE(std::string::npos, console.find(dropped, first + dropped.size())); - BOOST_CHECK_EQUAL(std::string::npos, console.find("sysio.liq::park")); } FC_LOG_AND_RETHROW() } -// LIQ_YIELD lands in sysio.liq's pending balance (no per-user routing) and stamps the report's -// epoch on the outpost cursor. Every refusal is dropped at the boundary without aborting the -// envelope: a payload claiming another chain, a token that is not an active liq token (a plain -// ERC20, an unregistered code), a liq token with no shadow, a non-positive or oversized amount, -// an unlinked pubkey of the wrong shape, a replayed sequence, and a malformed payload. -BOOST_FIXTURE_TEST_CASE(liq_yield_lands_in_pending_and_refusals_are_dropped, +// LIQ_YIELD routes to sysio.synd::onyield, which holds the amount as a number: nothing is minted and +// nothing pends, and the report's outpost epoch is stamped on the cursor. Every refusal is dropped +// without aborting the envelope: by msgch, a payload claiming another chain, a token that is not an +// active liq token (a plain ERC20, an unregistered code), a non-positive or oversized amount and a +// malformed payload; by sysio.synd, a liq token with no shadow, an unlinked pubkey of the wrong +// shape and a replayed sequence. +BOOST_FIXTURE_TEST_CASE(liq_yield_is_held_by_synd_and_refusals_are_dropped, sysio_dispatch_tester) { try { bootstrap_for_dispatch(); setup_liq_for_dispatch(); @@ -7060,9 +7211,10 @@ BOOST_FIXTURE_TEST_CASE(liq_yield_lands_in_pending_and_refusals_are_dropped, const auto liqnone = fc::slug_name{"LIQNONE"}.value; constexpr auto EVM = ChainKind::CHAIN_KIND_EVM; constexpr int64_t oversized = std::numeric_limits::max(); - const std::vector evm_address(20, '\x0c'); // an address, not the 33-byte pubkey the link holds + const std::vector evm_address(20, '\x0c'); // an address, not the 33-byte pubkey a link holds + const uint32_t epoch = current_epoch(); - const auto env = encode_envelope_with_mixed_attestations(current_epoch(), { + const auto env = encode_envelope_with_mixed_attestations(epoch, { {ATTESTATION_TYPE_LIQ_YIELD, encode_liq_yield(eth, liqeth, 7 * LIQ_UNIT, 1, 42)}, // claims another chain than the proven outpost {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(solana, EVM, uwrit_op_eth_pubkey, liqeth, 1 * LIQ_UNIT, 2)}, @@ -7073,7 +7225,7 @@ BOOST_FIXTURE_TEST_CASE(liq_yield_lands_in_pending_and_refusals_are_dropped, // amounts the fail-closed gate refuses {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, -1, 6)}, {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, oversized, 7)}, - // unlinked, and not a pubkey the chain family could ever link + // not a pubkey the chain family could ever link {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, evm_address, liqeth, 1 * LIQ_UNIT, 8)}, // yield refusals: another chain, not a liq token, a replayed sequence, malformed {ATTESTATION_TYPE_LIQ_YIELD, encode_liq_yield(solana, liqeth, 1 * LIQ_UNIT, 9, 43)}, @@ -7086,26 +7238,64 @@ BOOST_FIXTURE_TEST_CASE(liq_yield_lands_in_pending_and_refusals_are_dropped, BOOST_REQUIRE(!trace->except); const auto console = all_console(trace); - BOOST_CHECK_EQUAL(7 * LIQ_UNIT, liq_pending()); + const auto items = synd_items(); + BOOST_REQUIRE_EQUAL(1u, items.size()); + BOOST_CHECK_EQUAL("YIELD", items[0]["kind"].as_string()); + BOOST_CHECK_EQUAL(7 * LIQ_UNIT, items[0]["amount"].as()); + BOOST_CHECK_EQUAL(0, liq_pending()); BOOST_CHECK_EQUAL(0, liq_supply()); + BOOST_CHECK_EQUAL(0, liq_balance(SYND_ACCOUNT)); BOOST_CHECK_EQUAL(0, liq_balance(UWRIT_OP)); - BOOST_CHECK_EQUAL(0, liq_parked(EVM, evm_address)); - const auto cursor = liq_cursor("ETH"); + const auto cursor = synd_cursor("ETH"); BOOST_REQUIRE(!cursor.is_null()); BOOST_CHECK_EQUAL(1u, cursor["last_sequence"].as()); BOOST_CHECK_EQUAL(42u, cursor["last_epoch"].as()); + const auto envelope = synd_envelope("ETH", "LIQETH", epoch); + BOOST_REQUIRE(!envelope.is_null()); + BOOST_CHECK_EQUAL("WAITING", envelope["state"].as_string()); + BOOST_CHECK_EQUAL(7 * LIQ_UNIT, envelope["yield_total"].as()); + BOOST_CHECK_EQUAL(0u, envelope["synd_total"].as()); BOOST_CHECK_NE(std::string::npos, console.find( "msgch::dispatch_syndicate_liq: DROP attestation -- payload chain_code=")); BOOST_CHECK_NE(std::string::npos, console.find( "msgch::dispatch_syndicate_liq: DROP attestation -- token is not an active liq token")); - BOOST_CHECK_NE(std::string::npos, console.find("sysio.liq::mintsynd: DROP -- token_code has no shadow symbol")); - BOOST_CHECK_NE(std::string::npos, console.find("sysio.liq::park: DROP -- pubkey does not fit the chain family")); + BOOST_CHECK_NE(std::string::npos, console.find("sysio.synd::onsynd: DROP -- token_code has no shadow symbol")); + BOOST_CHECK_NE(std::string::npos, console.find("sysio.synd::onsynd: DROP -- pubkey does not fit the chain family")); BOOST_CHECK_NE(std::string::npos, console.find( "msgch::dispatch_liq_yield: DROP attestation -- payload chain_code=")); BOOST_CHECK_NE(std::string::npos, console.find( "msgch::dispatch_liq_yield: DROP attestation -- token is not an active liq token")); - BOOST_CHECK_NE(std::string::npos, console.find("sysio.liq::mintyield: DROP -- replayed sequence")); + BOOST_CHECK_NE(std::string::npos, console.find("sysio.synd::onyield: DROP -- replayed sequence")); +} FC_LOG_AND_RETHROW() } + +// Review Focus 3: an envelope whose attestations carry no syndication value -- here an echoed +// DESYNDICATE_LIQ and syndication messages msgch itself drops -- sends sysio.synd nothing, not even +// a `closeenv`, and leaves no row there. +BOOST_FIXTURE_TEST_CASE(an_envelope_without_syndication_value_touches_synd_not_at_all, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + setup_liq_for_dispatch(); + + const auto eth = fc::slug_name{"ETH"}.value; + const auto solana = fc::slug_name{"SOLANA"}.value; + const auto liqeth = fc::slug_name{"LIQETH"}.value; + const auto usdceth = fc::slug_name{"USDCETH"}.value; + constexpr auto EVM = ChainKind::CHAIN_KIND_EVM; + const auto env = encode_envelope_with_mixed_attestations(current_epoch(), { + {ATTESTATION_TYPE_DESYNDICATE_LIQ, + encode_desyndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 1 * LIQ_UNIT, 77)}, + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, usdceth, 1 * LIQ_UNIT, 1)}, + {ATTESTATION_TYPE_LIQ_YIELD, encode_liq_yield(solana, liqeth, 1 * LIQ_UNIT, 2, 1)}, + }); + const auto trace = deliver_trace(/*proven=*/ eth, env); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + + BOOST_CHECK(synd_actions(trace).empty()); + for (auto table : { "envelopes"_n, "items"_n, "ledger"_n, "syndcursors"_n }) + BOOST_CHECK(synd_rows(table).empty()); + BOOST_CHECK(get_row_by_account(SYND_ACCOUNT, SYND_ACCOUNT, "syndcounters"_n, "syndcounters"_n).empty()); } FC_LOG_AND_RETHROW() } // Without a token registry there is no active liq token, so the envelope is delivered and the @@ -7131,4 +7321,410 @@ BOOST_FIXTURE_TEST_CASE(liq_attestations_are_dropped_without_a_token_registry, "msgch::dispatch_liq_yield: DROP attestation -- token is not an active liq token")); } FC_LOG_AND_RETHROW() } +// ── The emergency stop (sysio.andon) ───────────────────────────────────────── + +// A pulled sysio.andon cord freezes nothing on the envelope path: sysio.msgch accepts the envelope, routes +// every syndication to sysio.synd, which holds it exactly as it does unfrozen, and closes the envelope with +// `closeenv`, whose queue step prints that it waits and aborts nothing. +BOOST_FIXTURE_TEST_CASE(a_pulled_andon_cord_leaves_deliveries_running, sysio_dispatch_tester) { try { + namespace andon = sysio_system::test_support::andon; + bootstrap_for_dispatch(); + setup_liq_for_dispatch(); + abi_serializer andon_abi; + andon::deploy(*this, andon_abi, contracts::andon_wasm(), contracts::andon_abi()); + BOOST_REQUIRE_EQUAL(success(), andon::pull(*this, andon_abi)); + + const auto eth = fc::slug_name{"ETH"}.value; + const auto liqeth = fc::slug_name{"LIQETH"}.value; + constexpr auto EVM = ChainKind::CHAIN_KIND_EVM; + const uint32_t epoch = current_epoch(); + const auto env = encode_envelope_with_mixed_attestations(epoch, { + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 5 * LIQ_UNIT, 1)}, + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 3 * LIQ_UNIT, 2)}, + }); + const auto trace = deliver_trace(/*proven=*/ eth, env); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + const auto actions = synd_actions(trace); + BOOST_REQUIRE_EQUAL(3u, actions.size()); + BOOST_CHECK_EQUAL("closeenv"_n, actions[2]); + BOOST_CHECK_NE(std::string::npos, + all_console(trace).find("sysio.synd::queue: the andon cord is pulled; releases, deliveries and " + "burns wait for the clear")); + BOOST_REQUIRE_EQUAL(2u, synd_items().size()); + BOOST_CHECK_EQUAL(8 * LIQ_UNIT, liq_balance(SYND_ACCOUNT)); + BOOST_CHECK_EQUAL(8 * LIQ_UNIT, liq_supply()); + BOOST_CHECK_EQUAL("WAITING", synd_envelope("ETH", "LIQETH", epoch)["state"].as_string()); +} FC_LOG_AND_RETHROW() } + +// A pulled sysio.andon cord leaves `sysio.epoch::advance` running: the genesis advance inline from +// `sysio.msgch::bootstrap` moves the epoch from 0 to 1 exactly as it does unfrozen. +BOOST_FIXTURE_TEST_CASE(a_pulled_andon_cord_leaves_advance_running, sysio_dispatch_tester) { try { + namespace andon = sysio_system::test_support::andon; + bootstrap_for_dispatch(); + // Deployed before sysio.system is set up, while a new account's code needs no RAM grant. + abi_serializer andon_abi; + andon::deploy(*this, andon_abi, contracts::andon_wasm(), contracts::andon_abi()); + BOOST_REQUIRE_EQUAL(success(), andon::pull(*this, andon_abi)); + setup_wire_token_and_reserves(); + enable_epoch_advancement(); + + BOOST_REQUIRE_EQUAL(current_epoch(), 0u); + BOOST_REQUIRE_EQUAL(success(), push(MSGCH_ACCOUNT, msgch_abi, MSGCH_ACCOUNT, "bootstrap"_n, mvo())); + produce_blocks(); + BOOST_REQUIRE_EQUAL(current_epoch(), 1u); + BOOST_REQUIRE(get_blocklog(1).is_null()); +} FC_LOG_AND_RETHROW() } + + +// ── The solvency check (sysio.synd) through a delivered envelope ───────────── + +// Review focus 3: sysio.msgch sends an envelope's messages to sysio.synd as inline actions, and each +// `onsynd`'s inline mint runs before the next message (depth-first), so each syndication is compared with +// the outstanding after its own mint. Two syndications carrying the custody read after each lock are both +// exact; the yield report after them carries the custody after its claim and reads as an excess of the +// claimed yield. Nothing is recorded and the cord stays clear. +BOOST_FIXTURE_TEST_CASE(each_syndication_of_an_envelope_is_compared_after_its_own_mint, + sysio_dispatch_tester) { try { + namespace andon = sysio_system::test_support::andon; + bootstrap_for_dispatch(); + abi_serializer andon_abi; + andon::deploy(*this, andon_abi, contracts::andon_wasm(), contracts::andon_abi()); + setup_liq_for_dispatch(); + + const auto eth = fc::slug_name{"ETH"}.value; + const auto liqeth = fc::slug_name{"LIQETH"}.value; + constexpr auto EVM = ChainKind::CHAIN_KIND_EVM; + const auto env = encode_envelope_with_mixed_attestations(current_epoch(), { + {ATTESTATION_TYPE_SYNDICATE_LIQ, + encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 5 * LIQ_UNIT, 1, 5 * LIQ_UNIT)}, + {ATTESTATION_TYPE_SYNDICATE_LIQ, + encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 3 * LIQ_UNIT, 2, 8 * LIQ_UNIT)}, + {ATTESTATION_TYPE_LIQ_YIELD, encode_liq_yield(eth, liqeth, 2 * LIQ_UNIT, 3, 42, 10 * LIQ_UNIT)}, + }); + const auto trace = deliver_trace(/*proven=*/ eth, env); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + const auto console = all_console(trace); + BOOST_CHECK_EQUAL(std::string::npos, console.find("sysio.synd::onsynd: EXCESS")); + BOOST_CHECK_EQUAL(std::string::npos, console.find("SHORTFALL")); + BOOST_CHECK_NE(std::string::npos, console.find("sysio.synd::onyield: EXCESS -- reported " + + std::to_string(10 * LIQ_UNIT) + " outstanding " + + std::to_string(8 * LIQ_UNIT))); + BOOST_CHECK(synd_rows("mismatch"_n).empty()); + BOOST_CHECK(get_row_by_account(andon::account, andon::account, "cord"_n, "cord"_n).empty()); + BOOST_CHECK_EQUAL(3u, synd_items().size()); + BOOST_CHECK_EQUAL(8 * LIQ_UNIT, liq_supply()); +} FC_LOG_AND_RETHROW() } + +// Review focus 4: a custody shortfall in a delivered envelope writes its `mismatch` row and pulls the depot +// cord from inside the consensus transaction; the envelope is accepted, both syndications are held, and +// the epoch still advances through the consensus crank. +BOOST_FIXTURE_TEST_CASE(a_custody_shortfall_pulls_the_cord_and_the_epoch_advances, sysio_dispatch_tester) { try { + namespace andon = sysio_system::test_support::andon; + bootstrap_for_dispatch(); + // Deployed before sysio.system is set up, while a new account's code needs no RAM grant. + abi_serializer andon_abi; + andon::deploy(*this, andon_abi, contracts::andon_wasm(), contracts::andon_abi()); + setup_liq_for_dispatch(); + setup_wire_token_and_reserves(); + enable_epoch_advancement(); + + const auto eth = fc::slug_name{"ETH"}.value; + const auto liqeth = fc::slug_name{"LIQETH"}.value; + constexpr auto EVM = ChainKind::CHAIN_KIND_EVM; + const uint32_t epoch = current_epoch(); + // The second syndication carries 7 against 8 outstanding after its mint. + const auto env = encode_envelope_with_mixed_attestations(epoch, { + {ATTESTATION_TYPE_SYNDICATE_LIQ, + encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 5 * LIQ_UNIT, 1, 5 * LIQ_UNIT)}, + {ATTESTATION_TYPE_SYNDICATE_LIQ, + encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 3 * LIQ_UNIT, 2, 7 * LIQ_UNIT)}, + }); + const auto trace = deliver_trace(/*proven=*/ eth, env); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + produce_block(); // land the delivery before the clock is moved past its expiration + const auto console = all_console(trace); + BOOST_CHECK_NE(std::string::npos, console.find("sysio.synd::onsynd: SHORTFALL -- reported " + + std::to_string(7 * LIQ_UNIT) + " outstanding " + + std::to_string(8 * LIQ_UNIT))); + BOOST_CHECK_NE(std::string::npos, + console.find("sysio.synd::onsynd: CORD PULLED -- custody shortfall ETH LIQETH seq 2")); + + const auto rows = synd_rows("mismatch"_n); + BOOST_REQUIRE_EQUAL(1u, rows.size()); + const auto row = synd_decode("mismatch_row", rows[0]); + BOOST_CHECK_EQUAL("ETH", row["chain_code"].as_string()); + BOOST_CHECK_EQUAL("LIQETH", row["token_code"].as_string()); + BOOST_CHECK_EQUAL(epoch, row["epoch_index"].as()); + BOOST_CHECK_EQUAL(2u, row["sequence"].as_uint64()); + BOOST_CHECK_EQUAL("SYNDICATION", row["kind"].as_string()); + BOOST_CHECK_EQUAL(7 * LIQ_UNIT, row["reported"].as()); + BOOST_CHECK_EQUAL(8 * LIQ_UNIT, row["expected"].as()); + + const auto cord = andon_abi.binary_to_variant("cord_state", + get_row_by_account(andon::account, andon::account, "cord"_n, "cord"_n), + abi_serializer::create_yield_function(abi_serializer_max_time)); + BOOST_REQUIRE(cord["pulled"].as_bool()); + BOOST_CHECK_EQUAL("custody shortfall ETH LIQETH seq 2", cord["reason"].as_string()); + + BOOST_CHECK_EQUAL(2u, synd_items().size()); + BOOST_CHECK_EQUAL(8 * LIQ_UNIT, liq_supply()); + BOOST_CHECK_EQUAL(8 * LIQ_UNIT, liq_balance(SYND_ACCOUNT)); + BOOST_CHECK_EQUAL("WAITING", synd_envelope("ETH", "LIQETH", epoch)["state"].as_string()); + BOOST_REQUIRE(!get_envelope(1).is_null()); + + // The frozen depot still reaches consensus and advances. + produce_block(fc::seconds(120)); + produce_block(); + BOOST_REQUIRE_EQUAL(success(), chkcons()); + produce_block(); + BOOST_CHECK_EQUAL(current_epoch(), epoch + 1); +} FC_LOG_AND_RETHROW() } + + +// Review focus 4 with work pending: the cord a shortfall pulls inline from `onsynd` freezes the very +// queue step `closeenv` runs later in the same delivered envelope. The earlier envelope of the pair was +// partly bonded, challenged and ruled VALID, so the next step would record the ruling, claim the hold-bond +// award from sysio.bond, forward the challenger's share and release the syndication to its linked account. +// Frozen, that step releases nothing, sends no claim and only records VALID with the share pending; the +// epoch still advances. After the clear, one crank releases the syndication and forwards the share. +BOOST_FIXTURE_TEST_CASE(a_shortfall_freezes_the_same_envelopes_queue_step, sysio_dispatch_tester) { try { + namespace andon = sysio_system::test_support::andon; + constexpr auto BOND_ACCOUNT = "sysio.bond"_n; + constexpr auto USER = "synd.user"_n; + constexpr auto UNDERWRITER = "synd.bonder"_n; + constexpr auto CHALLENGER = "synd.chal"_n; + bootstrap_for_dispatch(); + // Deployed before sysio.system is set up, while a new account's code needs no RAM grant. + abi_serializer andon_abi, bond_abi; + andon::deploy(*this, andon_abi, contracts::andon_wasm(), contracts::andon_abi()); + setup_liq_for_dispatch(); + create_accounts({BOND_ACCOUNT, USER, UNDERWRITER, CHALLENGER}); + produce_blocks(); + deploy(BOND_ACCOUNT, contracts::bond_wasm(), contracts::bond_abi(), bond_abi); + const auto user_key = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); + create_eth_authex_link(USER, user_key); + const auto user_pubkey = em_pubkey_bytes(user_key.get_public_key()); + // Buckets wide open, so only the cord stops a release. + BOOST_REQUIRE_EQUAL(success(), push(SYND_ACCOUNT, synd_abi, config::system_account_name, "setconfig"_n, mvo() + ("chain_code", codename_mvo("ETH"))("token_code", codename_mvo("LIQETH"))("synd_fee_bps", 0) + ("desynd_fee_bps", 0)("synd_burst", 1'000'000 * LIQ_UNIT)("synd_refill", 1'000'000 * LIQ_UNIT) + ("desynd_burst", 0)("desynd_refill", 0)("window_sec", 10800)("bounty", 0)("challenge_extra", 0))); + setup_wire_token_and_reserves(); + enable_epoch_advancement(); + + const auto eth = fc::slug_name{"ETH"}.value; + const auto liqeth = fc::slug_name{"LIQETH"}.value; + constexpr auto EVM = ChainKind::CHAIN_KIND_EVM; + const auto executed = [](const transaction_trace_ptr& trace, name code, name action_name) { + return std::any_of(trace->action_traces.begin(), trace->action_traces.end(), [&](const auto& at) { + return at.receiver == code && at.act.account == code && at.act.name == action_name; + }); + }; + + // Envelope A: 10 for the linked user, exact custody. Its closeenv step requests the underwriting. + const uint32_t first_epoch = current_epoch(); + const auto first = encode_envelope_with_mixed_attestations(first_epoch, { + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, user_pubkey, liqeth, 10 * LIQ_UNIT, 1, + 10 * LIQ_UNIT)}, + }); + BOOST_REQUIRE_EQUAL(success(), deliver(eth, first)); + produce_block(); + const uint64_t id = synd_envelope("ETH", "LIQETH", first_epoch)["request_id"].as_uint64(); + BOOST_REQUIRE_GT(id, 0u); + BOOST_REQUIRE_EQUAL("REQUESTED", synd_envelope("ETH", "LIQETH", first_epoch)["state"].as_string()); + + // 4 of the 10 bonded, then a challenge and a VALID ruling: the hold bond's unbonded 6/10 is owed back. + BOOST_REQUIRE_EQUAL(success(), mint_shadow(UNDERWRITER, 4 * LIQ_UNIT)); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi, UNDERWRITER, "accept"_n, mvo() + ("underwriter", UNDERWRITER)("request_id", id)("amount", 4 * LIQ_UNIT))); + BOOST_REQUIRE_EQUAL(success(), mint_shadow(CHALLENGER, 5 * LIQ_UNIT)); + BOOST_REQUIRE_EQUAL(success(), push(SYND_ACCOUNT, synd_abi, CHALLENGER, "challenge"_n, mvo() + ("challenger", CHALLENGER)("chain_code", codename_mvo("ETH"))("token_code", codename_mvo("LIQETH")) + ("epoch_index", first_epoch))); + const int64_t hold_bond = 10 * LIQ_UNIT / 10; // 1000 bps of the 10 covered + const int64_t share = hold_bond * 6 / 10; // the 6 of 10 no bond covered + const int64_t challenger_before = liq_balance(CHALLENGER); + BOOST_REQUIRE_EQUAL(5 * LIQ_UNIT - hold_bond, challenger_before); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi, config::system_account_name, "rslvvalid"_n, + mvo()("request_id", id))); + produce_block(); // land the pushes above before the clock is moved past their expiration + BOOST_REQUIRE_EQUAL("HELD", synd_envelope("ETH", "LIQETH", first_epoch)["state"].as_string()); + + produce_block(fc::seconds(120)); + produce_block(); + BOOST_REQUIRE_EQUAL(success(), chkcons()); + produce_block(); + const uint32_t second_epoch = current_epoch(); + BOOST_REQUIRE_EQUAL(first_epoch + 1, second_epoch); + + // Envelope B: 1 more, carrying 15 against 10 + 4 + 5 + 1 = 20 outstanding. + sysio::opp::Envelope accepted; + BOOST_REQUIRE(accepted.ParseFromArray(first.data(), static_cast(first.size()))); + const auto second = encode_envelope_with_mixed_attestations(second_epoch, { + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, user_pubkey, liqeth, 1 * LIQ_UNIT, 2, + 15 * LIQ_UNIT)}, + }, oracle::digest_bytes(oracle::epoch_digest(accepted)), accepted.messages(0).header().message_id()); + const auto trace = deliver_trace(eth, second); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + produce_block(); // land the delivery before the clock is moved past its expiration + const auto console = all_console(trace); + BOOST_CHECK_NE(std::string::npos, console.find("sysio.synd::onsynd: SHORTFALL -- reported " + + std::to_string(15 * LIQ_UNIT) + " outstanding " + + std::to_string(20 * LIQ_UNIT))); + BOOST_CHECK(executed(trace, andon::account, "pull"_n)); + BOOST_CHECK(executed(trace, SYND_ACCOUNT, "closeenv"_n)); + BOOST_CHECK_NE(std::string::npos, + console.find("sysio.synd::queue: the andon cord is pulled; releases, deliveries and burns wait " + "for the clear")); + // Nothing left custody: no release, no claim, no forward. + BOOST_CHECK(!executed(trace, BOND_ACCOUNT, "claim"_n)); + BOOST_CHECK(!executed(trace, LIQ_ACCOUNT, "transfer"_n)); + BOOST_CHECK_EQUAL(0, liq_balance(USER)); + BOOST_CHECK_EQUAL(challenger_before, liq_balance(CHALLENGER)); + auto envelope = synd_envelope("ETH", "LIQETH", first_epoch); + BOOST_CHECK_EQUAL("VALID", envelope["outcome"].as_string()); + BOOST_CHECK(envelope["share_pending"].as_bool()); + BOOST_CHECK_EQUAL(share, envelope["hold_share"].as()); + BOOST_CHECK_EQUAL(0u, envelope["released"].as_uint64()); + BOOST_CHECK_EQUAL(1u, synd_rows("mismatch"_n).size()); + const auto cord_of = [&] { + return andon_abi.binary_to_variant("cord_state", + get_row_by_account(andon::account, andon::account, "cord"_n, "cord"_n), + abi_serializer::create_yield_function(abi_serializer_max_time)); + }; + BOOST_REQUIRE(cord_of()["pulled"].as_bool()); + + // The frozen depot still reaches consensus and advances. + produce_block(fc::seconds(120)); + produce_block(); + BOOST_REQUIRE_EQUAL(success(), chkcons()); + produce_block(); + BOOST_CHECK_EQUAL(second_epoch + 1, current_epoch()); + + // Cleared: one crank records nothing new, claims the award, forwards the share and releases the 10. + BOOST_REQUIRE_EQUAL(success(), andon::clear(*this, andon_abi)); + BOOST_REQUIRE(!cord_of()["pulled"].as_bool()); + const auto crank = push_trace(SYND_ACCOUNT, synd_abi, CHALLENGER, "crank"_n, mvo()("limit", 100)); + BOOST_REQUIRE(crank != nullptr); + BOOST_REQUIRE(!crank->except); + produce_block(); + BOOST_CHECK(executed(crank, BOND_ACCOUNT, "claim"_n)); + BOOST_CHECK_EQUAL(10 * LIQ_UNIT, liq_balance(USER)); + BOOST_CHECK_EQUAL(challenger_before + share, liq_balance(CHALLENGER)); + envelope = synd_envelope("ETH", "LIQETH", first_epoch); + BOOST_CHECK_EQUAL("DONE", envelope["state"].as_string()); + BOOST_CHECK(!envelope["share_pending"].as_bool()); +} FC_LOG_AND_RETHROW() } + + +// The host-side simulator feeds canonical protobuf envelopes into the production +// msgch -> epoch consensus -> synd -> liq path. No patched OPP or dispatch stub. +BOOST_AUTO_TEST_CASE(generic_external_simulator_drives_production_dispatch) { + for (const auto& a : sysio::testing::external::Assets) { + BOOST_TEST_CONTEXT("chain=" << a.chain << " token=" << a.token) { + sysio_dispatch_tester t; + t.bootstrap_for_dispatch(a.chain, a.kind); + t.setup_liq_for_dispatch(a.chain, a.token, a.kind); + sysio::testing::external::chain outpost(a); + const auto token = symbol::from_string(std::string("9,") + a.token); + const auto key = a.kind == ChainKind::CHAIN_KIND_EVM ? t.uwrit_op_eth_pubkey : std::vector(32, '\x42'); + const auto deposit = outpost.deposit(key, 9 * t.LIQ_UNIT); + const auto yield = outpost.yield(2 * t.LIQ_UNIT); + const auto epoch = t.current_epoch(); + const auto bytes = outpost.envelope(epoch, {deposit, yield, deposit}); + const auto trace = t.deliver_trace(fc::slug_name{a.chain}.value, bytes); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + BOOST_REQUIRE_EQUAL(2u, t.synd_items().size()); + BOOST_CHECK_EQUAL(9 * t.LIQ_UNIT, t.liq_supply(token)); + BOOST_CHECK_EQUAL(9 * t.LIQ_UNIT, t.liq_balance(t.SYND_ACCOUNT, token)); + BOOST_CHECK_EQUAL(0, t.liq_pending(token)); + BOOST_CHECK_EQUAL(2u, t.synd_cursor(a.chain)["last_sequence"].as_uint64()); + const auto envelope = t.synd_envelope(a.chain, a.token, epoch); + BOOST_REQUIRE(!envelope.is_null()); + BOOST_CHECK_EQUAL("WAITING", envelope["state"].as_string()); + BOOST_CHECK_EQUAL(t.accepted_envelope_digest(fc::slug_name{a.chain}.value), + envelope["digest"].as()); + BOOST_CHECK(t.synd_rows("mismatch"_n).empty()); + } + } +} + + +// A real staking-reward envelope crosses msgch -> dclaim -> system -> token. +// Small treasury liquidity deliberately reaches the second fundclaim cap. +BOOST_FIXTURE_TEST_CASE(generic_staking_rewards_fund_claim_and_dedupe_through_dispatch, sysio_dispatch_tester) { + using namespace sysio::testing::external; + bootstrap_for_dispatch(First.chain, First.kind); + deploy(TOKEN_ACCOUNT, contracts::token_wasm(), contracts::token_abi(), token_abi); + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, TOKEN_ACCOUNT, "create"_n, + mvo()("issuer", "sysio")("maximum_supply", "100.000000000 WIRE"))); + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, "issue"_n, + mvo()("to", "sysio")("quantity", "100.000000000 WIRE")("memo", "reward funding"))); + enable_epoch_advancement(); + external::chain outpost(First); + constexpr uint64_t Reward = 10 * Unit; + sysio::opp::attestations::StakingReward reward; + reward.set_chain_code(fc::slug_name{First.chain}.value); + reward.mutable_staker_wire_account()->set_name(UWRIT_OP.to_string()); + reward.set_share_bps(10'000); + reward.set_reward_epoch_index(current_epoch()); + reward.set_external_epoch_ref(1); + reward.mutable_reward_amount()->set_token_code(fc::slug_name{"WIRE"}.value); + reward.mutable_reward_amount()->set_amount(Reward); + reward.mutable_staker_native_address()->set_kind(First.kind); + const std::vector address(20, '\x31'); + reward.mutable_staker_native_address()->set_address(address.data(), address.size()); + const auto body = reward.SerializeAsString(); + const auto bytes = outpost.envelope(current_epoch(), { + {ATTESTATION_TYPE_STAKING_REWARD, body}, {ATTESTATION_TYPE_STAKING_REWARD, body}}); + const auto trace = deliver_trace(fc::slug_name{First.chain}.value, bytes); + BOOST_REQUIRE(trace && !trace->except); + const auto wire = symbol::from_string("9,WIRE"); + const auto balance = [&](name account) { return get_currency_balance(TOKEN_ACCOUNT, wire, account).get_amount(); }; + BOOST_REQUIRE_EQUAL(Reward, get_dclaim_row("pclaims"_n, "pending_claim", UWRIT_OP.to_uint64_t()) + ["balance"].as().get_amount()); + BOOST_REQUIRE_EQUAL(Reward, balance(DCLAIM_ACCOUNT)); + BOOST_REQUIRE_EQUAL(90 * Unit, balance(config::system_account_name)); + BOOST_REQUIRE_EQUAL(success(), push(DCLAIM_ACCOUNT, dclaim_abi, UWRIT_OP, "claim"_n, + mvo()("wire_account", UWRIT_OP))); + BOOST_REQUIRE_EQUAL(Reward, balance(UWRIT_OP)); + BOOST_REQUIRE_EQUAL(0, balance(DCLAIM_ACCOUNT)); + BOOST_REQUIRE(get_dclaim_row("pclaims"_n, "pending_claim", UWRIT_OP.to_uint64_t()).is_null()); + // The soak's launch import has its own funding path: pre-fund, import an + // unlinked identity, authenticate the link, then claim the exact WIRE credit. + constexpr uint64_t Imported = 5 * Unit; + const auto private_key = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); + const auto native = fc::crypto::ethereum::address_to_bytes(private_key.get_public_key()); + const std::vector native_address(native.begin(), native.end()); + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, "transfer"_n, + mvo()("from", "sysio")("to", DCLAIM_ACCOUNT)("quantity", asset(Imported, wire))("memo", "import backing"))); + BOOST_REQUIRE_EQUAL(success(), push(DCLAIM_ACCOUNT, dclaim_abi, DCLAIM_ACCOUNT, "importseed"_n, + mvo()("chain", First.kind)("credits", fc::variants{ + mvo()("native_address", native_address)("wire_atomic", int64_t(Imported))}))); + BOOST_REQUIRE(!get_dclaim_row("unmapped"_n, "unmapped_token", 1).is_null()); + create_eth_authex_link(CLAIM_ACCOUNT, private_key); + BOOST_REQUIRE(get_dclaim_row("unmapped"_n, "unmapped_token", 1).is_null()); + BOOST_REQUIRE_EQUAL(Imported, get_dclaim_row("pclaims"_n, "pending_claim", CLAIM_ACCOUNT.to_uint64_t()) + ["balance"].as().get_amount()); + BOOST_REQUIRE_EQUAL(success(), push(DCLAIM_ACCOUNT, dclaim_abi, CLAIM_ACCOUNT, "claim"_n, + mvo()("wire_account", CLAIM_ACCOUNT))); + BOOST_REQUIRE_EQUAL(Imported, balance(CLAIM_ACCOUNT)); + BOOST_REQUIRE_EQUAL(0, balance(DCLAIM_ACCOUNT)); + const auto funding_left = balance(config::system_account_name); + // A new reward at the authenticated downstream boundary exceeds liquid + // treasury funds. Credit is retained, but funding cannot overspend custody. + BOOST_REQUIRE_EQUAL(success(), push(DCLAIM_ACCOUNT, dclaim_abi, MSGCH_ACCOUNT, "onreward"_n, + mvo()("chain_code", fc::slug_name{First.chain}.value)("staker_wire_account", UWRIT_OP.to_string()) + ("reward_chain", First.kind)("staker_native_addr", address)("reward_amount", 100 * Unit) + ("reward_epoch_index", current_epoch())("external_epoch_ref", 2)("share_bps", 10'000))); + BOOST_REQUIRE_EQUAL(funding_left, balance(DCLAIM_ACCOUNT)); + BOOST_REQUIRE_EQUAL(0, balance(config::system_account_name)); + BOOST_REQUIRE_EQUAL(100 * Unit, get_dclaim_row("pclaims"_n, "pending_claim", UWRIT_OP.to_uint64_t()) + ["balance"].as().get_amount()); +} BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.dispute_tests.cpp b/contracts/tests/sysio.dispute_tests.cpp index ab760df361..0c73b93940 100644 --- a/contracts/tests/sysio.dispute_tests.cpp +++ b/contracts/tests/sysio.dispute_tests.cpp @@ -164,6 +164,9 @@ class sysio_dispute_tester : public tester { set_code(account, wasm); set_abi(account, abi.data()); set_privileged(account); + // Each deployment gets its own block; fixture setup must not depend on + // the combined WASM sizes fitting the remaining block NET budget. + produce_blocks(); load_abi(account, out_ser); } diff --git a/contracts/tests/sysio.liq_tests.cpp b/contracts/tests/sysio.liq_tests.cpp index 0fa00acb23..8719902f44 100644 --- a/contracts/tests/sysio.liq_tests.cpp +++ b/contracts/tests/sysio.liq_tests.cpp @@ -3,27 +3,16 @@ #include #include #include -#include -#include #include -#include #include #include -#include -#include -#include -#include #include "contracts.hpp" #include "contract_test_support.hpp" -#include "liq_test_support.hpp" #include "shadow_yield_reference.hpp" -#include #include -#include -#include using namespace sysio::testing; using namespace sysio; @@ -47,18 +36,18 @@ struct fundclaim_args { FC_REFLECT( fundclaim_args, (recipient)(amount) ) /// sysio.liq end to end on the depot: the registries it validates against, the -/// swap it feeds, the authex links it resolves, and sysio.msgch as both the -/// inbound signer and the outbound queue. The treasury supply of WIRE sits on -/// `sysio`, as the bootstrap leaves it. +/// swap it feeds, and sysio.synd as the signer of every mint and burn (an account +/// only; sysio.msgch is an account too, a signer that is not sysio.synd). The +/// treasury supply of WIRE sits on `sysio`, as the bootstrap leaves it. class sysio_liq_tester : public tester { public: static constexpr auto LIQ_ACCOUNT = "sysio.liq"_n; static constexpr auto MSGCH_ACCOUNT = "sysio.msgch"_n; - static constexpr auto AUTHEX_ACCOUNT = "sysio.authex"_n; static constexpr auto TOKEN_ACCOUNT = "sysio.token"_n; static constexpr auto TOKENS_ACCOUNT = "sysio.tokens"_n; static constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; static constexpr auto SWAP_ACCOUNT = "sysio.swap"_n; + static constexpr auto SYND_ACCOUNT = "sysio.synd"_n; static constexpr auto SYSIO_ACCOUNT = "sysio"_n; static inline const symbol WIRE_SYM = symbol::from_string("9,WIRE"); @@ -72,21 +61,17 @@ class sysio_liq_tester : public tester { static constexpr std::string_view LIQSOL = "LIQSOL"; static constexpr std::string_view LIQETH = "LIQETH"; - /// `seed_registries` registers the two outposts, their liq tokens and the LIQSOL shadow - /// every ledger case starts from; the launch-ingestion case replays a bootstrap config - /// into empty registries instead. - explicit sysio_liq_tester(bool seed_registries = true) { + /// Registers the two outposts, their liq tokens and the LIQSOL shadow every case starts from. + sysio_liq_tester() { produce_blocks(2); - // sysio.authex is pre-created by the tester boot. + // sysio.synd is the ledger's only minter; an account is enough to sign as it here. create_accounts({ LIQ_ACCOUNT, MSGCH_ACCOUNT, TOKEN_ACCOUNT, TOKENS_ACCOUNT, CHAINS_ACCOUNT, SWAP_ACCOUNT, - "alice"_n, "bob"_n, "carol"_n, "dave"_n }); + SYND_ACCOUNT, "alice"_n, "bob"_n, "carol"_n, "dave"_n }); produce_blocks(2); - deploy(AUTHEX_ACCOUNT, contracts::authex_wasm(), contracts::authex_abi(), authex_abi_ser, true); deploy(CHAINS_ACCOUNT, contracts::chains_wasm(), contracts::chains_abi(), chains_abi_ser, true); deploy(TOKENS_ACCOUNT, contracts::tokens_wasm(), contracts::tokens_abi(), tokens_abi_ser, true); deploy(TOKEN_ACCOUNT, contracts::token_wasm(), contracts::token_abi(), token_abi_ser, true); - deploy(MSGCH_ACCOUNT, contracts::msgch_wasm(), contracts::msgch_abi(), msgch_abi_ser, true); deploy(SWAP_ACCOUNT, contracts::swap_wasm(), contracts::swap_abi(), swap_abi_ser, false); deploy(LIQ_ACCOUNT, contracts::liq_wasm(), contracts::liq_abi(), liq_abi_ser, true); @@ -102,8 +87,6 @@ class sysio_liq_tester : public tester { BOOST_REQUIRE_EQUAL(success(), push(SWAP_ACCOUNT, swap_abi_ser, SWAP_ACCOUNT, "setconfig"_n, mvo() ("fee_authority", SYSIO_ACCOUNT)("system_token", extended_symbol{ WIRE_SYM, TOKEN_ACCOUNT }))); - if (!seed_registries) return; - // Two outposts, each with an active liq token, plus a plain SPL token. BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_SVM, SOLANA, 2)); BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_EVM, ETH, 1)); @@ -138,18 +121,6 @@ class sysio_liq_tester : public tester { return r.find(text) != std::string::npos; } - // --- slugs and keys --- - - static uint64_t slug_value(std::string_view s) { return fc::slug_name{ s }.value; } - - static fc::crypto::public_key ed_key() { - return fc::crypto::private_key::generate(fc::crypto::private_key::key_type::ed).get_public_key(); - } - static std::vector ed_bytes(const fc::crypto::public_key& pk) { - const auto& raw = pk.get()._data; - return std::vector(raw.begin(), raw.end()); - } - // --- registries --- // Registrations inside the epoch-0 bootstrap window land ACTIVE, as the launch bootstrap's do. @@ -174,53 +145,27 @@ class sysio_liq_tester : public tester { return regtoken(kind, code, 9, chain_kind, chain_code, std::vector(chain_kind == ChainKind::CHAIN_KIND_SVM ? 32 : 20, char(0x5a))); } - /// The bytes behind a bootstrap config's display-form address or pubkey: `0x`-hex on - /// EVM, base58 on SVM. - static std::vector address_bytes(ChainKind kind, const std::string& display) { - if (kind == ChainKind::CHAIN_KIND_SVM) return fc::from_base58(display); - BOOST_REQUIRE_MESSAGE(display.starts_with("0x"), "not a 0x-hex address: " << display); - std::vector out((display.size() - 2) / 2); - out.resize(fc::from_hex(std::string_view(display).substr(2), out.data(), out.size())); - return out; - } - /// A link recorded the depot's way, signed as sysio.authex. - action_result recordlink(name account, ChainKind kind, const fc::crypto::public_key& pub_key) { - // The trusted path carries the chain-native address alongside the key; for the ED keys - // these tests link on SVM that is the key's own 32 bytes. - return push(AUTHEX_ACCOUNT, authex_abi_ser, AUTHEX_ACCOUNT, "recordlink"_n, mvo() - ("account", account)("chain_kind", kind)("pub_key", pub_key) - ("native_address", sysio_liq::test_support::native_address_of(pub_key))); - } // --- sysio.liq actions --- action_result create(symbol sym, std::string_view chain_code, std::string_view token_code, name signer = LIQ_ACCOUNT) { return push_liq(signer, "create"_n, mvo()("sym", sym)("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code))); } - action_result mintsynd(std::string_view chain_code, uint64_t sequence, name account, std::string_view token_code, - uint64_t amount, name signer = MSGCH_ACCOUNT) { - return push_liq(signer, "mintsynd"_n, mvo()("chain_code", codename_mvo(chain_code))("sequence", sequence) - ("account", account)("token_code", codename_mvo(token_code))("amount", amount)); + /// Mint `amount` of the shadow of `token_code` (LIQSOL by default) to `account`, signed as + /// sysio.synd, the only minter. + action_result mint(name account, uint64_t amount, std::string_view token_code = LIQSOL, + name signer = SYND_ACCOUNT) { + return push_liq(signer, "mint"_n, mvo()("to", account)("token_code", codename_mvo(token_code))("amount", amount)); } - action_result park(std::string_view chain_code, uint64_t sequence, ChainKind kind, const std::vector& pubkey, - std::string_view token_code, uint64_t amount, name signer = MSGCH_ACCOUNT) { - return push_liq(signer, "park"_n, mvo()("chain_code", codename_mvo(chain_code))("sequence", sequence) - ("chain_kind", kind)("pubkey", pubkey)("token_code", codename_mvo(token_code))("amount", amount)); - } - action_result mintyield(std::string_view chain_code, uint64_t sequence, uint64_t epoch, std::string_view token_code, - uint64_t amount, name signer = MSGCH_ACCOUNT) { - return push_liq(signer, "mintyield"_n, mvo()("chain_code", codename_mvo(chain_code))("sequence", sequence) - ("epoch", epoch)("token_code", codename_mvo(token_code))("amount", amount)); + /// LIQ_YIELD released by sysio.synd: `amount` into the pending balance of `token_code`'s shadow. + action_result mintyield(std::string_view chain_code, std::string_view token_code, uint64_t amount, + name signer = SYND_ACCOUNT) { + return push_liq(signer, "mintyield"_n, mvo()("chain_code", codename_mvo(chain_code)) + ("token_code", codename_mvo(token_code))("amount", amount)); } action_result queueyield(symbol sym, name signer = "alice"_n) { return push_liq(signer, "queueyield"_n, mvo()("sym", sym.to_symbol_code())); } - action_result sweep(name account, ChainKind kind, name signer = "alice"_n) { - return push_liq(signer, "sweep"_n, mvo()("account", account)("chain_kind", kind)); - } - action_result linkswept(name account, ChainKind kind, const std::vector& pubkey, name signer = AUTHEX_ACCOUNT) { - return push_liq(signer, "linkswept"_n, mvo()("account", account)("chain_kind", kind)("pubkey", pubkey)); - } action_result transfer_shadow(name from, name to, int64_t amount, const std::string& memo = "") { return push_liq(from, "transfer"_n, mvo()("from", from)("to", to)("quantity", asset(amount, LIQSOL_SYM))("memo", memo)); } @@ -244,9 +189,6 @@ class sysio_liq_tester : public tester { action_result setkicker(name signer, uint32_t bps) { return push_liq(signer, "setkicker"_n, mvo()("bps", bps)); } - action_result desyndicate(name holder, int64_t amount, symbol sym = LIQSOL_SYM) { - return push_liq(holder, "desyndicate"_n, mvo()("holder", holder)("quantity", asset(amount, sym))); - } action_result recredit(name holder, int64_t amount, name signer = LIQ_ACCOUNT) { return push_liq(signer, "recredit"_n, mvo()("holder", holder)("quantity", asset(amount, LIQSOL_SYM))); } @@ -259,18 +201,6 @@ class sysio_liq_tester : public tester { ("initial_wire_amount", initial_wire_amount)("fee", fee)("locked_shares", locked_shares) ("conversion_horizon_sec", horizon_sec)("depth_cap_bps", depth_cap_bps)("clip_floor", clip_floor)); } - static mvo credit(const std::vector& pubkey, uint64_t amount) { - return mvo()("pubkey", pubkey)("amount", amount); - } - action_result importsynd(std::string_view chain_code, std::string_view token_code, const fc::variants& credits, - name signer = LIQ_ACCOUNT) { - return push_liq(signer, "importsynd"_n, mvo()("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code)) - ("credits", credits)); - } - action_result importdone(name signer = LIQ_ACCOUNT) { - return push_liq(signer, "importdone"_n, mvo()); - } - // --- other contracts --- action_result transfer_wire(name from, name to, int64_t amount) { @@ -322,21 +252,9 @@ class sysio_liq_tester : public tester { fc::variant pending_row(symbol sym = LIQSOL_SYM) { return liq_row("liqpending"_n, "pending_yield", LIQ_ACCOUNT, sym.to_symbol_code().value); } - fc::variant cursor_row(std::string_view chain_code) { - return liq_row("liqcursors"_n, "liq_cursor", LIQ_ACCOUNT, slug_value(chain_code)); - } fc::variant config_row() { return decode(liq_abi_ser, "liq_config", get_row_by_account(LIQ_ACCOUNT, LIQ_ACCOUNT, "liqconfig"_n, "liqconfig"_n)); } - fc::variant counters_row() { - return decode(liq_abi_ser, "liq_counters", get_row_by_account(LIQ_ACCOUNT, LIQ_ACCOUNT, "liqcounters"_n, "liqcounters"_n)); - } - /// A parked row by its composite key (see `liq_test_support::parked_key`). - fc::variant parked_row(symbol sym, ChainKind kind, const std::vector& pubkey) { - using namespace sysio_liq::test_support; - return decode(liq_abi_ser, "parked_row", - parked_row_bytes(*control, LIQ_ACCOUNT, parked_key(sym.to_symbol_code(), kind, pubkey))); - } int64_t wire_balance(name holder) { const auto row = decode(token_abi_ser, "account", get_row_by_id(TOKEN_ACCOUNT, holder, "accounts"_n, WIRE_SYM.to_symbol_code().value)); @@ -373,43 +291,45 @@ class sysio_liq_tester : public tester { fc::variant swap_pool_row(symbol pair) { return swap_row("stat"_n, "currency_stats", SWAP_ACCOUNT, pair.to_symbol_code().value); } - fc::variant attestation_row(uint64_t id) { - return decode(msgch_abi_ser, "attestation_entry", get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "attestations"_n, id)); - } - - abi_serializer liq_abi_ser, token_abi_ser, tokens_abi_ser, chains_abi_ser, swap_abi_ser, authex_abi_ser, msgch_abi_ser; -}; - -/// The contracts deployed and WIRE issued, but no registry row and no shadow: the state -/// the launch bootstrap finds when it replays a config. -struct sysio_liq_config_tester : sysio_liq_tester { - sysio_liq_config_tester() : sysio_liq_tester(false) {} + abi_serializer liq_abi_ser, token_abi_ser, tokens_abi_ser, chains_abi_ser, swap_abi_ser; }; -namespace { - -/// The dev bootstrap config, parsed as strictly as the bootstrap tool parses it. -sysio::opp::bootstrap::BootstrapPlatformConfig load_dev_config() { - const auto path = contracts::dex_config_dir() + "/dex-config.dev.json"; - std::ifstream in(path); - BOOST_REQUIRE_MESSAGE(in.good(), "cannot open " << path); - std::stringstream json; - json << in.rdbuf(); - sysio::opp::bootstrap::BootstrapPlatformConfig cfg; - google::protobuf::util::JsonParseOptions opts; // an unknown field is an error - const auto status = google::protobuf::util::JsonStringToMessage(json.str(), &cfg, opts); - BOOST_REQUIRE_MESSAGE(status.ok(), status.ToString()); - return cfg; -} - -} // anonymous namespace - BOOST_AUTO_TEST_SUITE(sysio_liq_tests) // --------------------------------------------------------------------------- // Registration // --------------------------------------------------------------------------- +BOOST_FIXTURE_TEST_CASE(custody_settlement_preserves_supply_yield_and_ignores_callbacks, sysio_liq_tester) try { + BOOST_REQUIRE_EQUAL(success(), mint(SYND_ACCOUNT, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mint("bob"_n, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 10 * UNIT)); + const auto custody_owed = owed(SYND_ACCOUNT); + const auto bob_owed = owed("bob"_n); + const auto before_supply = supply(); + auto settle = [&](name signer, name custodian, name beneficiary, asset quantity) { + return push_liq(signer, "settle"_n, mvo()("custodian", custodian)("beneficiary", beneficiary)("quantity", quantity)); + }; + BOOST_REQUIRE(mentions(settle("alice"_n, "alice"_n, "bob"_n, asset(UNIT, LIQSOL_SYM)), "unsupported custodian")); + BOOST_REQUIRE(mentions(settle("alice"_n, SYND_ACCOUNT, "bob"_n, asset(UNIT, LIQSOL_SYM)), "missing authority")); + BOOST_REQUIRE(mentions(settle(SYND_ACCOUNT, SYND_ACCOUNT, "bob"_n, asset(0, LIQSOL_SYM)), "positive")); + BOOST_REQUIRE(mentions(settle(SYND_ACCOUNT, SYND_ACCOUNT, "bob"_n, asset(101 * UNIT, LIQSOL_SYM)), "overdrawn")); + BOOST_REQUIRE(mentions(settle(SYND_ACCOUNT, SYND_ACCOUNT, "bob"_n, asset(UNIT, symbol::from_string("4,LIQSOL"))), "precision")); + BOOST_REQUIRE_EQUAL(success(), settle(SYND_ACCOUNT, SYND_ACCOUNT, SYND_ACCOUNT, asset(100 * UNIT, LIQSOL_SYM))); + BOOST_REQUIRE_EQUAL(100 * UNIT, shadow_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(custody_owed, owed(SYND_ACCOUNT)); + BOOST_REQUIRE(mentions(transfer_shadow(SYND_ACCOUNT, SYND_ACCOUNT, UNIT), "cannot transfer to self")); + set_code("bob"_n, contracts::util::block_transfer_wasm()); + produce_blocks(); + BOOST_REQUIRE(transfer_shadow(SYND_ACCOUNT, "bob"_n, UNIT) != success()); + BOOST_REQUIRE_EQUAL(success(), settle(SYND_ACCOUNT, SYND_ACCOUNT, "bob"_n, asset(40 * UNIT, LIQSOL_SYM))); + BOOST_REQUIRE_EQUAL(60 * UNIT, shadow_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(140 * UNIT, shadow_balance("bob"_n)); + BOOST_REQUIRE_EQUAL(before_supply, supply()); + BOOST_REQUIRE_EQUAL(custody_owed, owed(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(bob_owed, owed("bob"_n)); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE(create_binds_an_active_liq_token, sysio_liq_tester) try { const auto st = stat_row(); BOOST_REQUIRE_EQUAL(0, st["supply"].as().get_amount()); @@ -436,86 +356,22 @@ BOOST_FIXTURE_TEST_CASE(create_binds_an_active_liq_token, sysio_liq_tester) try // Inbound: SYNDICATE_LIQ // --------------------------------------------------------------------------- -BOOST_FIXTURE_TEST_CASE(mintsynd_credits_a_holder_and_drops_what_it_must, sysio_liq_tester) try { - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); +BOOST_FIXTURE_TEST_CASE(mint_credits_a_holder_and_refuses_what_it_must, sysio_liq_tester) try { + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 100 * UNIT)); auto row = account_row("alice"_n); BOOST_REQUIRE_EQUAL(100 * UNIT, row["balance"].as().get_amount()); BOOST_REQUIRE_EQUAL(fc::uint128_t{ 0 }, row["index_checkpoint"].as_uint128()); BOOST_REQUIRE_EQUAL(0u, row["owed_wire"].as_uint64()); BOOST_REQUIRE_EQUAL(100 * UNIT, supply()); - BOOST_REQUIRE_EQUAL(1u, cursor_row(SOLANA)["last_sequence"].as_uint64()); - - // Every drop returns success and changes nothing: a replay, the sequence - // before it, an unknown token, a token of another chain, an out-of-range - // amount and an account that does not exist. - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 5 * UNIT)); - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 0, "alice"_n, LIQSOL, 5 * UNIT)); - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 2, "alice"_n, "NOPE", 5 * UNIT)); - BOOST_REQUIRE_EQUAL(success(), mintsynd(ETH, 2, "alice"_n, LIQSOL, 5 * UNIT)); - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 2, "alice"_n, LIQSOL, 0)); - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 2, "alice"_n, LIQSOL, (uint64_t{ 1 } << 62))); - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 2, "nobody"_n, LIQSOL, 5 * UNIT)); - BOOST_REQUIRE_EQUAL(100 * UNIT, shadow_balance("alice"_n)); - BOOST_REQUIRE_EQUAL(100 * UNIT, supply()); - // A drop consumes no sequence: the same sequence still admits a valid credit. - BOOST_REQUIRE_EQUAL(1u, cursor_row(SOLANA)["last_sequence"].as_uint64()); - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 2, "bob"_n, LIQSOL, 50 * UNIT)); - BOOST_REQUIRE_EQUAL(50 * UNIT, shadow_balance("bob"_n)); - BOOST_REQUIRE_EQUAL(150 * UNIT, supply()); - BOOST_REQUIRE_EQUAL(2u, cursor_row(SOLANA)["last_sequence"].as_uint64()); - - BOOST_REQUIRE(mentions(mintsynd(SOLANA, 3, "alice"_n, LIQSOL, UNIT, "alice"_n), "missing authority of sysio.msgch")); -} FC_LOG_AND_RETHROW() -BOOST_FIXTURE_TEST_CASE(parked_rows_accrue_and_are_delivered_on_link, sysio_liq_tester) try { - const auto key = ed_key(); - const auto pubkey = ed_bytes(key); - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "bob"_n, LIQSOL, 50 * UNIT)); - BOOST_REQUIRE_EQUAL(success(), park(SOLANA, 2, ChainKind::CHAIN_KIND_SVM, pubkey, LIQSOL, 50 * UNIT)); - BOOST_REQUIRE_EQUAL(100 * UNIT, supply()); - auto parked = parked_row(LIQSOL_SYM, ChainKind::CHAIN_KIND_SVM, pubkey); - BOOST_REQUIRE(!parked.is_null()); - BOOST_REQUIRE_EQUAL(50 * UNIT, parked["holding"]["balance"].as().get_amount()); - BOOST_REQUIRE(pubkey == parked["pubkey"].as>()); - - // A pubkey of the wrong width, or a chain kind that is not the token's, is dropped. - BOOST_REQUIRE_EQUAL(success(), park(SOLANA, 3, ChainKind::CHAIN_KIND_SVM, std::vector(20, 'x'), LIQSOL, UNIT)); - BOOST_REQUIRE_EQUAL(success(), park(SOLANA, 3, ChainKind::CHAIN_KIND_EVM, std::vector(33, 'x'), LIQSOL, UNIT)); + // Every refusal aborts and changes nothing: an unknown token, a zero amount, an account that + // does not exist, and any signer but sysio.synd. + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code has no shadow symbol"), mint("alice"_n, 5 * UNIT, "NOPE")); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("amount must be positive"), mint("alice"_n, 0)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("to account does not exist"), mint("nobody"_n, 5 * UNIT)); + BOOST_REQUIRE(mentions(mint("alice"_n, UNIT, LIQSOL, MSGCH_ACCOUNT), "missing authority of sysio.synd")); + BOOST_REQUIRE_EQUAL(100 * UNIT, shadow_balance("alice"_n)); BOOST_REQUIRE_EQUAL(100 * UNIT, supply()); - - // Yield lands while the row is parked: half the supply, half the WIRE. - BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 10 * UNIT)); - const auto first = yield_reference::distribute(10 * UNIT, 100 * UNIT, 0); - BOOST_REQUIRE_EQUAL(first.index_delta, yield_reference::wide(index())); - - // Nothing to sweep before the link exists, and nothing links a stranger. - BOOST_REQUIRE_EQUAL(wasm_assert_msg("account has no link for this chain"), sweep("carol"_n, ChainKind::CHAIN_KIND_SVM)); - BOOST_REQUIRE_EQUAL(success(), recordlink("carol"_n, ChainKind::CHAIN_KIND_SVM, key)); - BOOST_REQUIRE_EQUAL(success(), sweep("carol"_n, ChainKind::CHAIN_KIND_SVM)); - BOOST_REQUIRE(parked_row(LIQSOL_SYM, ChainKind::CHAIN_KIND_SVM, pubkey).is_null()); - auto carol = account_row("carol"_n); - BOOST_REQUIRE_EQUAL(50 * UNIT, carol["balance"].as().get_amount()); - BOOST_REQUIRE_EQUAL(uint64_t(5 * UNIT), carol["owed_wire"].as_uint64()); // banked at the sweep - BOOST_REQUIRE_EQUAL(index(), carol["index_checkpoint"].as_uint128()); - BOOST_REQUIRE_EQUAL(5 * UNIT, owed("carol"_n)); - BOOST_REQUIRE_EQUAL(5 * UNIT, owed("bob"_n)); - - // Sweeping again finds nothing; claiming pays exactly the banked WIRE. - BOOST_REQUIRE_EQUAL(success(), sweep("carol"_n, ChainKind::CHAIN_KIND_SVM)); - const int64_t before = wire_balance("carol"_n); - BOOST_REQUIRE_EQUAL(success(), claim("carol"_n)); - BOOST_REQUIRE_EQUAL(before + 5 * UNIT, wire_balance("carol"_n)); - BOOST_REQUIRE_EQUAL(uint64_t(5 * UNIT), pot()); // bob's share remains - - // The inline path from sysio.authex delivers the same way. - const auto key2 = ed_key(); - const auto pubkey2 = ed_bytes(key2); - BOOST_REQUIRE_EQUAL(success(), park(SOLANA, 3, ChainKind::CHAIN_KIND_SVM, pubkey2, LIQSOL, 20 * UNIT)); - BOOST_REQUIRE(mentions(linkswept("dave"_n, ChainKind::CHAIN_KIND_SVM, pubkey2, "dave"_n), "missing authority of sysio.authex")); - BOOST_REQUIRE_EQUAL(success(), linkswept("dave"_n, ChainKind::CHAIN_KIND_SVM, pubkey2)); - BOOST_REQUIRE_EQUAL(20 * UNIT, shadow_balance("dave"_n)); - BOOST_REQUIRE_EQUAL(index(), account_row("dave"_n)["index_checkpoint"].as_uint128()); - BOOST_REQUIRE_EQUAL(0, owed("dave"_n)); // stamped at the current index: no history credited } FC_LOG_AND_RETHROW() // --------------------------------------------------------------------------- @@ -523,8 +379,8 @@ BOOST_FIXTURE_TEST_CASE(parked_rows_accrue_and_are_delivered_on_link, sysio_liq_ // --------------------------------------------------------------------------- BOOST_FIXTURE_TEST_CASE(settle_before_mutate_conserves_every_subunit, sysio_liq_tester) try { - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 2, "bob"_n, LIQSOL, 300 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mint("bob"_n, 300 * UNIT)); BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 100 * UNIT)); BOOST_REQUIRE_EQUAL(25 * UNIT, owed("alice"_n)); BOOST_REQUIRE_EQUAL(75 * UNIT, owed("bob"_n)); @@ -560,7 +416,7 @@ BOOST_FIXTURE_TEST_CASE(settle_before_mutate_conserves_every_subunit, sysio_liq_ const int64_t dave_before = wire_balance("dave"_n); BOOST_REQUIRE_EQUAL(success(), claim("dave"_n)); BOOST_REQUIRE_EQUAL(dave_before, wire_balance("dave"_n)); - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 3, "dave"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mint("dave"_n, 100 * UNIT)); BOOST_REQUIRE_EQUAL(0, owed("dave"_n)); BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 50 * UNIT)); // 500 supply: dave holds a fifth BOOST_REQUIRE_EQUAL(10 * UNIT, owed("dave"_n)); @@ -573,7 +429,7 @@ BOOST_FIXTURE_TEST_CASE(settle_before_mutate_conserves_every_subunit, sysio_liq_ } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE(a_one_unit_distribution_carries_its_remainder, sysio_liq_tester) try { - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 3)); // three subunits + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 3)); // three subunits BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 1)); BOOST_REQUIRE_EQUAL(1u, index_row()["carry"].as_uint64()); BOOST_REQUIRE_EQUAL(0, owed("alice"_n)); @@ -588,7 +444,7 @@ BOOST_FIXTURE_TEST_CASE(a_one_unit_distribution_carries_its_remainder, sysio_liq } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE(claim_with_nothing_owed_sends_nothing, sysio_liq_tester) try { - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 100 * UNIT)); const int64_t before = wire_balance("alice"_n); BOOST_REQUIRE_EQUAL(0, owed("alice"_n)); BOOST_REQUIRE_EQUAL(success(), claim("alice"_n)); @@ -598,7 +454,7 @@ BOOST_FIXTURE_TEST_CASE(claim_with_nothing_owed_sends_nothing, sysio_liq_tester) } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE(close_refuses_while_yield_is_owed, sysio_liq_tester) try { - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 100 * UNIT)); BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 10 * UNIT)); BOOST_REQUIRE_EQUAL(wasm_assert_msg("Cannot close because the balance is not zero."), close("alice"_n, LIQSOL_SYM)); BOOST_REQUIRE_EQUAL(success(), transfer_shadow("alice"_n, "bob"_n, 100 * UNIT)); @@ -613,7 +469,7 @@ BOOST_FIXTURE_TEST_CASE(close_refuses_while_yield_is_owed, sysio_liq_tester) try BOOST_FIXTURE_TEST_CASE(the_index_grows_past_64_bits, sysio_liq_tester) try { // One subunit of supply and 2e7 subunits of yield move the index by 2e19. - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 1)); const int64_t donation = 20'000'000; BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, donation)); BOOST_REQUIRE_LT(yield_reference::wide(std::numeric_limits::max()), yield_reference::wide(index())); @@ -631,7 +487,7 @@ BOOST_FIXTURE_TEST_CASE(the_index_grows_past_64_bits, sysio_liq_tester) try { // --------------------------------------------------------------------------- BOOST_FIXTURE_TEST_CASE(addyield_requests_the_kicker_from_t5, sysio_liq_tester) try { - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 100 * UNIT)); BOOST_REQUIRE_EQUAL(uint32_t(200), config_row().is_null() ? 200u : config_row()["kicker_bps"].as()); // The swap's intake is the one that is yield, so it is the one that earns the @@ -679,7 +535,7 @@ BOOST_FIXTURE_TEST_CASE(addyield_requests_the_kicker_from_t5, sysio_liq_tester) } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE(addkicker_folds_only_what_landed, sysio_liq_tester) try { - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 100 * UNIT)); // Stand in for T5: 5 WIRE arrive at the contract. BOOST_REQUIRE_EQUAL(success(), transfer_wire("carol"_n, LIQ_ACCOUNT, 5 * UNIT)); const int64_t balance = wire_balance(LIQ_ACCOUNT); @@ -704,13 +560,12 @@ BOOST_FIXTURE_TEST_CASE(addkicker_folds_only_what_landed, sysio_liq_tester) try BOOST_FIXTURE_TEST_CASE(queued_yield_sells_through_the_pool_and_pays_holders, sysio_liq_tester) try { BOOST_REQUIRE_EQUAL(success(), setkicker(SYSIO_ACCOUNT, 0)); - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 100 * UNIT)); // Nothing pending: queueing is a no-op, and the pool does not exist yet. BOOST_REQUIRE_EQUAL(success(), queueyield(LIQSOL_SYM)); - BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, 2, 7, LIQSOL, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, LIQSOL, 10 * UNIT)); BOOST_REQUIRE_EQUAL(10 * UNIT, pending_row()["quantity"].as().get_amount()); - BOOST_REQUIRE_EQUAL(7u, cursor_row(SOLANA)["last_epoch"].as_uint64()); BOOST_REQUIRE_EQUAL(100 * UNIT, supply()); // pending yield is outside supply BOOST_REQUIRE_EQUAL(wasm_assert_msg("no yield pool registered for this shadow"), queueyield(LIQSOL_SYM)); @@ -743,12 +598,11 @@ BOOST_FIXTURE_TEST_CASE(queued_yield_sells_through_the_pool_and_pays_holders, sy BOOST_REQUIRE_EQUAL(success(), queueyield(LIQSOL_SYM)); // nothing left: a no-op BOOST_REQUIRE_EQUAL(10 * UNIT, reservoir()); - // A replayed report is dropped; a later one queues on top. - BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, 2, 7, LIQSOL, 10 * UNIT)); + // A later report pends again; one of another chain is dropped. + BOOST_REQUIRE_EQUAL(success(), mintyield(ETH, LIQSOL, 10 * UNIT)); BOOST_REQUIRE(pending_row().is_null()); - BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, 3, 8, LIQSOL, 2 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, LIQSOL, 2 * UNIT)); BOOST_REQUIRE_EQUAL(2 * UNIT, pending_row()["quantity"].as().get_amount()); - BOOST_REQUIRE_EQUAL(8u, cursor_row(SOLANA)["last_epoch"].as_uint64()); // The tick sells a clip of the reservoir through the pool and pays the // proceeds in through addyield: the index moves, and alice, a holder, is @@ -767,34 +621,30 @@ BOOST_FIXTURE_TEST_CASE(queued_yield_sells_through_the_pool_and_pays_holders, sy // Supply plus pending yield never exceeds the asset range: every supply-growing path // measures its headroom net of what is parked in liqpending, so queueyield can always -// mint it. A report past that headroom is dropped before its sequence is consumed, so -// no value is clipped behind an acknowledged sequence. +// mint it. A report past that headroom is dropped whole, so no value is clipped. BOOST_FIXTURE_TEST_CASE(pending_yield_is_reserved_against_the_asset_range, sysio_liq_tester) try { BOOST_REQUIRE_EQUAL(success(), setkicker(SYSIO_ACCOUNT, 0)); // The pool first, while the range is open: regliqpool mints the LCO seed. Then one // syndication takes supply to 100 base units under the range. BOOST_REQUIRE_EQUAL(success(), regliqpool(SOLANA, LIQSOL, POOL_SYM, 1000 * UNIT, 1000 * UNIT)); const uint64_t range = static_cast(asset::max_amount); - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, range - 100 - 1000 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, range - 100 - 1000 * UNIT)); BOOST_REQUIRE_EQUAL(asset::max_amount - 100, supply()); - // 60 fits and parks. A second 60 exceeds the 40 left and is dropped, its sequence - // unconsumed; 40 under the same sequence then fills the range exactly. - BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, 2, 7, LIQSOL, 60)); + // 60 fits and parks. A second 60 exceeds the 40 left and is dropped; 40 then fills + // the range exactly. + BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, LIQSOL, 60)); BOOST_REQUIRE_EQUAL(60, pending_row()["quantity"].as().get_amount()); - BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, 3, 8, LIQSOL, 60)); + BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, LIQSOL, 60)); BOOST_REQUIRE_EQUAL(60, pending_row()["quantity"].as().get_amount()); - BOOST_REQUIRE_EQUAL(2u, cursor_row(SOLANA)["last_sequence"].as_uint64()); - BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, 3, 8, LIQSOL, 40)); + BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, LIQSOL, 40)); BOOST_REQUIRE_EQUAL(100, pending_row()["quantity"].as().get_amount()); - BOOST_REQUIRE_EQUAL(3u, cursor_row(SOLANA)["last_sequence"].as_uint64()); BOOST_REQUIRE_EQUAL(asset::max_amount - 100, supply()); // pending stays outside supply - // The reservation binds every other supply-growing path: a credit of one base unit - // is dropped, and the governance mint is refused. - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 4, "bob"_n, LIQSOL, 1)); + // The reservation binds every other supply-growing path: a mint of one base unit and + // the governance recredit are both refused. + BOOST_REQUIRE_EQUAL(wasm_assert_msg("supply exceeds the asset range"), mint("bob"_n, 1)); BOOST_REQUIRE_EQUAL(0, shadow_balance("bob"_n)); - BOOST_REQUIRE_EQUAL(3u, cursor_row(SOLANA)["last_sequence"].as_uint64()); BOOST_REQUIRE_EQUAL(wasm_assert_msg("supply exceeds the asset range"), recredit("alice"_n, 1)); // Queueing always fits: the pending yield mints to exactly the range. @@ -803,9 +653,8 @@ BOOST_FIXTURE_TEST_CASE(pending_yield_is_reserved_against_the_asset_range, sysio BOOST_REQUIRE_EQUAL(asset::max_amount, supply()); BOOST_REQUIRE_EQUAL(100, reservoir()); // Nothing can grow the supply now, and a report says why before it is dropped. - BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, 5, 9, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, LIQSOL, 1)); BOOST_REQUIRE(pending_row().is_null()); - BOOST_REQUIRE_EQUAL(3u, cursor_row(SOLANA)["last_sequence"].as_uint64()); } FC_LOG_AND_RETHROW() // The other exit: a holder sells shadow into the yield pool for WIRE on the depot, no @@ -815,7 +664,7 @@ BOOST_FIXTURE_TEST_CASE(pending_yield_is_reserved_against_the_asset_range, sysio // lost; the shadow supply is untouched, since it moved and nothing burned; the fee and the // price impact keep her proceeds below par. BOOST_FIXTURE_TEST_CASE(a_holder_can_sell_shadow_into_the_yield_pool_for_wire, sysio_liq_tester) try { - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 100 * UNIT)); BOOST_REQUIRE_EQUAL(success(), regliqpool(SOLANA, LIQSOL, POOL_SYM, 1000 * UNIT, 1000 * UNIT)); const extended_symbol shadow_ext{ LIQSOL_SYM, LIQ_ACCOUNT }; @@ -858,186 +707,74 @@ BOOST_FIXTURE_TEST_CASE(regliqpool_refusals, sysio_liq_tester) try { } FC_LOG_AND_RETHROW() // --------------------------------------------------------------------------- -// Outbound: DESYNDICATE_LIQ +// Governance reconciliation // --------------------------------------------------------------------------- -BOOST_FIXTURE_TEST_CASE(desyndicate_burns_and_queues_the_attestation, sysio_liq_tester) try { - const auto key = ed_key(); - const auto pubkey = ed_bytes(key); - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); - BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 2, "bob"_n, LIQSOL, 100 * UNIT)); - BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 20 * UNIT)); - - BOOST_REQUIRE_EQUAL(wasm_assert_msg("holder is not AuthX-linked for the token's chain"), desyndicate("alice"_n, 40 * UNIT)); - BOOST_REQUIRE_EQUAL(success(), recordlink("alice"_n, ChainKind::CHAIN_KIND_SVM, key)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("overdrawn balance"), desyndicate("alice"_n, 101 * UNIT)); - BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 40 * UNIT)); - - // The burn settled the row first: the yield earned on the whole balance stays. - BOOST_REQUIRE_EQUAL(60 * UNIT, shadow_balance("alice"_n)); - BOOST_REQUIRE_EQUAL(160 * UNIT, supply()); - BOOST_REQUIRE_EQUAL(10 * UNIT, owed("alice"_n)); - - const auto att = attestation_row(1); - BOOST_REQUIRE(!att.is_null()); - BOOST_REQUIRE_EQUAL("ATTESTATION_TYPE_DESYNDICATE_LIQ", att["type"].as_string()); - BOOST_REQUIRE_EQUAL(slug_value(SOLANA), att["chain_code"].as_uint64()); - const auto data = att["data"].as>(); - sysio::opp::attestations::DesyndicateLIQ msg; - BOOST_REQUIRE(msg.ParseFromArray(data.data(), static_cast(data.size()))); - BOOST_REQUIRE_EQUAL(slug_value(SOLANA), msg.chain_code()); - BOOST_REQUIRE(msg.user().kind() == ChainKind::CHAIN_KIND_SVM); - BOOST_REQUIRE_EQUAL(std::string(pubkey.begin(), pubkey.end()), msg.user().address()); - BOOST_REQUIRE_EQUAL(slug_value(LIQSOL), msg.amount().token_code()); - BOOST_REQUIRE_EQUAL(40 * UNIT, msg.amount().amount()); - BOOST_REQUIRE_EQUAL(1u, msg.request_id()); - - BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 10 * UNIT)); - BOOST_REQUIRE_EQUAL(2u, counters_row()["next_request_id"].as_uint64() - 1); - BOOST_REQUIRE_EQUAL(50 * UNIT, shadow_balance("alice"_n)); +// An outpost that refused a de-syndication is reconciled by governance: `recredit` mints the burned +// amount back to the holder, growing the supply by exactly that, and only this contract may sign it. +BOOST_FIXTURE_TEST_CASE(recredit_mints_a_refused_desyndication_back, sysio_liq_tester) try { + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 60 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 6 * UNIT)); - // Governance puts a refused de-syndication back. BOOST_REQUIRE(mentions(recredit("alice"_n, 10 * UNIT, "alice"_n), "missing authority of sysio.liq")); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("quantity must be positive"), recredit("alice"_n, 0)); BOOST_REQUIRE_EQUAL(success(), recredit("alice"_n, 10 * UNIT)); - BOOST_REQUIRE_EQUAL(60 * UNIT, shadow_balance("alice"_n)); - BOOST_REQUIRE_EQUAL(160 * UNIT, supply()); -} FC_LOG_AND_RETHROW() - -// --------------------------------------------------------------------------- -// Launch ingestion -// --------------------------------------------------------------------------- - -BOOST_FIXTURE_TEST_CASE(importsynd_parks_or_credits_and_importdone_closes, sysio_liq_tester) try { - const auto key_a = ed_key(), key_b = ed_key(), key_c = ed_key(); - const auto a = ed_bytes(key_a), b = ed_bytes(key_b), c = ed_bytes(key_c); - BOOST_REQUIRE_EQUAL(success(), recordlink("carol"_n, ChainKind::CHAIN_KIND_SVM, key_c)); - - BOOST_REQUIRE_EQUAL(success(), importsynd(SOLANA, LIQSOL, { credit(a, 30 * UNIT), credit(b, 70 * UNIT), credit(c, 5 * UNIT) })); - BOOST_REQUIRE_EQUAL(30 * UNIT, parked_row(LIQSOL_SYM, ChainKind::CHAIN_KIND_SVM, a)["holding"]["balance"].as().get_amount()); - BOOST_REQUIRE_EQUAL(70 * UNIT, parked_row(LIQSOL_SYM, ChainKind::CHAIN_KIND_SVM, b)["holding"]["balance"].as().get_amount()); - BOOST_REQUIRE_EQUAL(5 * UNIT, shadow_balance("carol"_n)); // already linked: straight to the account - BOOST_REQUIRE_EQUAL(105 * UNIT, supply()); - - // The same pubkey across batches sums; zero credits are skipped. - BOOST_REQUIRE_EQUAL(success(), importsynd(SOLANA, LIQSOL, { credit(a, 10 * UNIT), credit(b, 0) })); - BOOST_REQUIRE_EQUAL(40 * UNIT, parked_row(LIQSOL_SYM, ChainKind::CHAIN_KIND_SVM, a)["holding"]["balance"].as().get_amount()); - BOOST_REQUIRE_EQUAL(115 * UNIT, supply()); - - BOOST_REQUIRE_EQUAL(wasm_assert_msg("pubkey does not fit the chain family"), - importsynd(SOLANA, LIQSOL, { credit(std::vector(33, 'x'), UNIT) })); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code belongs to another chain"), importsynd(ETH, LIQSOL, { credit(a, UNIT) })); - BOOST_REQUIRE(mentions(importsynd(SOLANA, LIQSOL, { credit(a, UNIT) }, "alice"_n), "missing authority of sysio.liq")); - BOOST_REQUIRE(mentions(importdone("alice"_n), "missing authority of sysio.liq")); - - BOOST_REQUIRE_EQUAL(success(), importdone()); - BOOST_REQUIRE(config_row()["import_complete"].as_bool()); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("import already finalized"), importsynd(SOLANA, LIQSOL, { credit(a, UNIT) })); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("import already finalized"), importdone()); - - // The imported positions earn from the first distribution on. - BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 23 * UNIT)); // 115 supply: one WIRE per five shadow - BOOST_REQUIRE_EQUAL(success(), recordlink("alice"_n, ChainKind::CHAIN_KIND_SVM, key_a)); - BOOST_REQUIRE_EQUAL(success(), sweep("alice"_n, ChainKind::CHAIN_KIND_SVM)); - BOOST_REQUIRE_EQUAL(40 * UNIT, shadow_balance("alice"_n)); - BOOST_REQUIRE_EQUAL(8 * UNIT, owed("alice"_n)); - BOOST_REQUIRE_EQUAL(UNIT, owed("carol"_n)); + BOOST_REQUIRE_EQUAL(70 * UNIT, shadow_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(70 * UNIT, supply()); + // The row settled before it grew: the yield earned on the 60 stays, nothing is credited for the 10. + BOOST_REQUIRE_EQUAL(6 * UNIT, owed("alice"_n)); } FC_LOG_AND_RETHROW() // --------------------------------------------------------------------------- -// Launch ingestion: the dev bootstrap config replays into the shadow-liq system +// The emergency stop (sysio.andon) // --------------------------------------------------------------------------- -// The bootstrap tool replays the config row by row: the chains, the LIQ tokens with their -// bindings, one `create` per shadow, one `regliqpool` per pool, `importsynd` per -// syndication, then `importdone`. Replaying the checked-in dev config here proves the -// values the validator accepts are values the contracts accept, and pins the accounting: -// each pool holds exactly its two seeds, the shadow supply is the LCO seed plus the parked -// syndications, every index starts at 0, the WIRE drained is the sum of the pools' WIRE -// seeds within the dex earmark, and the import closes for good. -BOOST_FIXTURE_TEST_CASE(the_dev_config_replays_into_the_shadow_liq_system, sysio_liq_config_tester) try { - const auto cfg = load_dev_config(); - BOOST_REQUIRE(!cfg.liq_pools().empty()); - BOOST_REQUIRE(!cfg.syndications().empty()); - - std::map kind_of_chain; - for (const auto& chain : cfg.chains()) { - kind_of_chain[chain.code()] = chain.kind(); - BOOST_REQUIRE_EQUAL(success(), regchain(chain.kind(), chain.code(), chain.external_chain_id())); - } - std::map shadow_of_token; - for (const auto& token : cfg.tokens()) { - if (token.kind() != TokenKind::TOKEN_KIND_LIQ) continue; // the shadow ledger binds only liq tokens - const auto chain_kind = kind_of_chain.at(token.chain_code()); - shadow_of_token.emplace(token.code(), symbol(static_cast(token.precision()), token.code())); - BOOST_REQUIRE_EQUAL(success(), regtoken(token.kind(), token.code(), token.precision(), chain_kind, - token.chain_code(), address_bytes(chain_kind, token.contract_address()))); - } - - const int64_t treasury_before = wire_balance(SYSIO_ACCOUNT); - uint64_t wire_drained = 0; - for (const auto& pool : cfg.liq_pools()) { - const symbol shadow = shadow_of_token.at(pool.token_code()); - const symbol pair(9, pool.pair_symbol()); - BOOST_REQUIRE_EQUAL(success(), create(shadow, pool.chain_code(), pool.token_code())); - BOOST_REQUIRE_EQUAL(success(), regliqpool(pool.chain_code(), pool.token_code(), pair, - pool.initial_chain_amount(), pool.initial_wire_amount(), - static_cast(pool.fee()), static_cast(pool.locked_shares()), - pool.conversion_horizon_sec(), pool.depth_cap_bps(), - static_cast(pool.clip_floor()))); - wire_drained += pool.initial_wire_amount(); - - const auto pool_row = swap_pool_row(pair); - BOOST_REQUIRE_MESSAGE(!pool_row.is_null(), "no swap pair " << pool.pair_symbol()); - BOOST_REQUIRE_EQUAL(static_cast(pool.initial_chain_amount()), - pool_row["pool1"]["quantity"].as().get_amount()); - BOOST_REQUIRE_EQUAL(static_cast(pool.initial_wire_amount()), - pool_row["pool2"]["quantity"].as().get_amount()); - BOOST_REQUIRE_EQUAL(pool.pair_symbol(), stat_row(shadow)["pair_symbol"].as_string()); - // The index row is materialized by the first distribution; until then an absent - // row reads as index 0 (`current_index`), which is what every seeded holder is stamped at. - const auto idx_row = index_row(shadow); - BOOST_REQUIRE(idx_row.is_null() || idx_row["index"].as_uint128() == fc::uint128_t{ 0 }); - } - - // Nobody is AuthX-linked at bootstrap, so every syndication parks against its pubkey; - // repeated pubkeys sum. - std::map, uint64_t> parked_of; // (token_code, pubkey) -> amount - for (const auto& synd : cfg.syndications()) { - const auto chain_kind = kind_of_chain.at(synd.chain_code()); - BOOST_REQUIRE_EQUAL(success(), importsynd(synd.chain_code(), synd.token_code(), - { credit(address_bytes(chain_kind, synd.pubkey()), synd.amount()) })); - parked_of[{ synd.token_code(), synd.pubkey() }] += synd.amount(); - } - BOOST_REQUIRE_EQUAL(success(), importdone()); - { - const auto& first = cfg.syndications(0); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("import already finalized"), - importsynd(first.chain_code(), first.token_code(), - { credit(address_bytes(kind_of_chain.at(first.chain_code()), first.pubkey()), - first.amount()) })); - } - - for (const auto& pool : cfg.liq_pools()) { - const symbol shadow = shadow_of_token.at(pool.token_code()); - const auto chain_kind = kind_of_chain.at(pool.chain_code()); - uint64_t syndicated = 0; - for (const auto& [key, amount] : parked_of) { - if (key.first != pool.token_code()) continue; - syndicated += amount; - const auto parked = parked_row(shadow, chain_kind, address_bytes(chain_kind, key.second)); - BOOST_REQUIRE_MESSAGE(!parked.is_null(), "no parked row for " << key.second); - BOOST_REQUIRE_EQUAL(static_cast(amount), parked["holding"]["balance"].as().get_amount()); - } - BOOST_REQUIRE_EQUAL(static_cast(pool.initial_chain_amount() + syndicated), - stat_row(shadow)["supply"].as().get_amount()); - // The config's custody cross-check is the contract's truth: the outpost custodies - // exactly what the depot minted against. - if (pool.custody_total() != 0) - BOOST_REQUIRE_EQUAL(pool.custody_total(), pool.initial_chain_amount() + syndicated); +// Review focus 2, sysio.liq: while the cord is pulled a transfer INTO a custody contract succeeds and one to +// anyone else is refused; a holder's `claim` and `queueyield` are refused, a custody contract's own claim +// runs, and `mint` runs. With no sysio.andon deployed (every other case here) the cord reads clear. After +// the clear everything refused succeeds. +BOOST_FIXTURE_TEST_CASE(a_freeze_admits_transfers_into_custody_and_refuses_the_rest, sysio_liq_tester) try { + namespace andon = sysio_system::test_support::andon; + const auto frozen = wasm_assert_msg(andon::frozen_message); + create_accounts({ "sysio.bond"_n, "sysio.opreg"_n }); + BOOST_REQUIRE_EQUAL(success(), mint("alice"_n, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, LIQSOL, UNIT)); + + abi_serializer andon_abi_ser; + andon::deploy(*this, andon_abi_ser, contracts::andon_wasm(), contracts::andon_abi()); + BOOST_REQUIRE_EQUAL(success(), andon::pull(*this, andon_abi_ser)); + + // In: into sysio.bond, sysio.synd and sysio.opreg. + BOOST_REQUIRE_EQUAL(success(), transfer_shadow("alice"_n, "sysio.bond"_n, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), transfer_shadow("alice"_n, SYND_ACCOUNT, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), transfer_shadow("alice"_n, "sysio.opreg"_n, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(70 * UNIT, shadow_balance("alice"_n)); + // Out: to a holder, and back out of a custody contract to a holder. + BOOST_REQUIRE_EQUAL(frozen, transfer_shadow("alice"_n, "bob"_n, UNIT)); + BOOST_REQUIRE_EQUAL(frozen, transfer_shadow("sysio.bond"_n, "bob"_n, UNIT)); + BOOST_REQUIRE_EQUAL(frozen, transfer_shadow("alice"_n, SWAP_ACCOUNT, UNIT)); + BOOST_REQUIRE_EQUAL(0, shadow_balance("bob"_n)); + for (auto beneficiary : {"bob"_n, SYND_ACCOUNT, "sysio.bond"_n}) { + BOOST_REQUIRE_EQUAL(frozen, push_liq(SYND_ACCOUNT, "settle"_n, + mvo()("custodian", SYND_ACCOUNT)("beneficiary", beneficiary)("quantity", asset(UNIT, LIQSOL_SYM)))); } - - BOOST_REQUIRE_EQUAL(treasury_before - static_cast(wire_drained), wire_balance(SYSIO_ACCOUNT)); - BOOST_REQUIRE_LE(wire_drained, cfg.t5_dex_allocation()); + // A holder's claim and queueyield are refused; a custody contract claims its own row. + const int64_t alice_owed = owed("alice"_n); + BOOST_REQUIRE_LT(0, alice_owed); + BOOST_REQUIRE_EQUAL(frozen, claim("alice"_n)); + BOOST_REQUIRE_EQUAL(frozen, queueyield(LIQSOL_SYM)); + BOOST_REQUIRE_EQUAL(success(), claim("sysio.bond"_n)); + // Mint runs. + BOOST_REQUIRE_EQUAL(success(), mint("bob"_n, UNIT)); + BOOST_REQUIRE_EQUAL(UNIT, shadow_balance("bob"_n)); + + BOOST_REQUIRE_EQUAL(success(), andon::clear(*this, andon_abi_ser)); + BOOST_REQUIRE_EQUAL(success(), transfer_shadow("alice"_n, "bob"_n, UNIT)); + BOOST_REQUIRE_EQUAL(2 * UNIT, shadow_balance("bob"_n)); + const int64_t alice_wire = wire_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), claim("alice"_n)); + BOOST_REQUIRE_EQUAL(alice_wire + alice_owed, wire_balance("alice"_n)); } FC_LOG_AND_RETHROW() BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.msig_tests.cpp b/contracts/tests/sysio.msig_tests.cpp index d47019a97c..0734b067a7 100644 --- a/contracts/tests/sysio.msig_tests.cpp +++ b/contracts/tests/sysio.msig_tests.cpp @@ -1053,16 +1053,15 @@ BOOST_FIXTURE_TEST_CASE( sendinline, sysio_msig_tester ) try { // threshold inside the contract is `proposal_chunk_size = 200 * 1024`, so // every test below builds an inner trx larger than that. // -// We construct the large trx by stacking two `setcode` actions whose `code` -// field carries the full sysio.system wasm (~134 KiB). Two of those puts the -// serialized inner trx well above 200 KiB, while keeping each individual -// dispatched action under `max_inline_action_size` so `exec` succeeds. +// Two setcode actions carry a minimal valid WASM with bounded custom-section +// padding. This tests chunking independently of production sysio.system's size: +// two current system WASMs can exceed the outer transaction's NET limit. // --------------------------------------------------------------------------- namespace { // Build an inner transaction whose serialized form is larger than the contract's - // chunk threshold. Two setcode actions to two different accounts, each carrying - // the full sysio.system wasm. Total ≈ 270 KiB, which forces chunking. + // chunk threshold. Two setcode actions total approximately 280 KiB, above + // the KV row limit but below the outer transaction NET limit. // // `expiration_iso` controls the wrapped trx's expiration; defaults to far-future so // the common happy-path tests don't have to reason about block time. Tests that @@ -1072,7 +1071,15 @@ namespace { sysio_msig_tester& t, const std::string& expiration_iso = "2025-01-01T00:30") { - auto wasm = contracts::system_wasm(); + constexpr uint32_t PaddingBytes = 140 * 1024; + constexpr size_t ChunkThreshold = 200 * 1024; + constexpr size_t FixtureTransactionLimit = 320 * 1024; + auto wasm = wast_to_wasm(R"((module (func (export "apply") (param i64 i64 i64))))"); + wasm.push_back(0); // WASM custom section. + const auto section_size = fc::raw::pack(fc::unsigned_int(PaddingBytes + 1)); + wasm.insert(wasm.end(), section_size.begin(), section_size.end()); + wasm.push_back(0); // Empty section name; the remaining bytes are padding. + wasm.resize(wasm.size() + PaddingBytes, 0); auto make_setcode = [&](const char* account_name) { return fc::mutable_variant_object() @@ -1098,6 +1105,8 @@ namespace { transaction trx; abi_serializer::from_variant(pretty_trx, trx, t.get_resolver(), abi_serializer::create_yield_function(base_tester::abi_serializer_max_time)); + BOOST_REQUIRE_GT(fc::raw::pack_size(trx), ChunkThreshold); + BOOST_REQUIRE_LT(fc::raw::pack_size(trx), FixtureTransactionLimit); return trx; } } diff --git a/contracts/tests/sysio.opreg_tests.cpp b/contracts/tests/sysio.opreg_tests.cpp index be3f0c6921..c86d3ff13f 100644 --- a/contracts/tests/sysio.opreg_tests.cpp +++ b/contracts/tests/sysio.opreg_tests.cpp @@ -9,6 +9,7 @@ #include #include "contracts.hpp" +#include "external_chain_simulator.hpp" #include "contract_test_support.hpp" #include "shadow_yield_reference.hpp" #include @@ -196,7 +197,7 @@ constexpr auto regchain = "regchain"_n; constexpr auto regtoken = "regtoken"_n; constexpr auto regctok = "regctok"_n; constexpr auto create = "create"_n; -constexpr auto mint = "recredit"_n; +constexpr auto mint = "mint"_n; constexpr auto queueout = "queueout"_n; } // namespace shadow_action @@ -719,40 +720,45 @@ class sysio_opreg_tester : public tester { /// same registrations `sysio.liq_tests` seeds). Called per case rather than from the /// constructor: an active ETH chain row would turn the ETH-bucket slashes and remits of the /// other cases into real `sysio.msgch` queueouts and change what those cases exercise. - void setup_shadow_liq() { + void setup_shadow_liq(const std::vector& assets = + {{"ETH", "LIQETH", ChainKind::CHAIN_KIND_EVM, kEthExternalChainId}}) { deploy_privileged(CHAINS_ACCOUNT, contracts::chains_wasm(), contracts::chains_abi(), chains_abi_ser); deploy_privileged(TOKENS_ACCOUNT, contracts::tokens_wasm(), contracts::tokens_abi(), tokens_abi_ser); deploy_privileged(LIQ_ACCOUNT, contracts::liq_wasm(), contracts::liq_abi(), liq_abi_ser); - // Registered inside the epoch-0 bootstrap window, so the rows land ACTIVE. - BOOST_REQUIRE_EQUAL(success(), push_contract(CHAINS_ACCOUNT, chains_abi_ser, CHAINS_ACCOUNT, - shadow_action::regchain, mvo() - ("kind", ChainKind::CHAIN_KIND_EVM)("code", codename_mvo(kEthCodename)) - ("external_chain_id", kEthExternalChainId)("name", std::string(kEthCodename)) - ("description", std::string{})("outpost", sysio_system::test_support::no_outpost_mvo()))); - - const std::vector address(kEvmAddressBytes, kPlaceholderAddressByte); - BOOST_REQUIRE_EQUAL(success(), push_contract(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, - shadow_action::regtoken, mvo() - ("kind", TokenKind::TOKEN_KIND_LIQ)("code", codename_mvo(kLiqEthCodename)) - ("symbol_name", std::string(kLiqEthCodename))("description", std::string{}) - ("precision", kLiqEthSymbol.decimals()) - ("address", mvo()("kind", ChainKind::CHAIN_KIND_EVM)("address", address)))); - BOOST_REQUIRE_EQUAL(success(), push_contract(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, - shadow_action::regctok, mvo() - ("chain_code", codename_mvo(kEthCodename))("token_code", codename_mvo(kLiqEthCodename)) - ("contract_addr", address)("is_native", false))); - - BOOST_REQUIRE_EQUAL(success(), push_contract(LIQ_ACCOUNT, liq_abi_ser, LIQ_ACCOUNT, shadow_action::create, - mvo()("sym", kLiqEthSymbol)("chain_code", codename_mvo(kEthCodename)) - ("token_code", codename_mvo(kLiqEthCodename)))); + for (const auto& a : assets) { + const auto sym = symbol::from_string(std::string("9,") + a.token); + // Registered inside the epoch-0 bootstrap window, so the rows land ACTIVE. + BOOST_REQUIRE_EQUAL(success(), push_contract(CHAINS_ACCOUNT, chains_abi_ser, CHAINS_ACCOUNT, + shadow_action::regchain, mvo() + ("kind", a.kind)("code", codename_mvo(a.chain)) + ("external_chain_id", a.id)("name", std::string(a.chain)) + ("description", std::string{})("outpost", sysio_system::test_support::no_outpost_mvo()))); + + const std::vector address(a.kind == ChainKind::CHAIN_KIND_SVM ? 32 : kEvmAddressBytes, kPlaceholderAddressByte); + BOOST_REQUIRE_EQUAL(success(), push_contract(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, + shadow_action::regtoken, mvo() + ("kind", TokenKind::TOKEN_KIND_LIQ)("code", codename_mvo(a.token)) + ("symbol_name", std::string(a.token))("description", std::string{}) + ("precision", sym.decimals()) + ("address", mvo()("kind", a.kind)("address", address)))); + BOOST_REQUIRE_EQUAL(success(), push_contract(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, + shadow_action::regctok, mvo() + ("chain_code", codename_mvo(a.chain))("token_code", codename_mvo(a.token)) + ("contract_addr", address)("is_native", false))); + + BOOST_REQUIRE_EQUAL(success(), push_contract(LIQ_ACCOUNT, liq_abi_ser, LIQ_ACCOUNT, shadow_action::create, + mvo()("sym", sym)("chain_code", codename_mvo(a.chain)) + ("token_code", codename_mvo(a.token)))); + } } - /// Seed `holder` with shadow LIQ through the ledger's governance credit action. + /// Put `amount` LIQETH shadow in `holder`'s hands the way the depot mints it: `sysio.synd`, the + /// ledger's only minter, calling `mint`. void mint_shadow(name holder, uint64_t amount) { const int64_t before = shadow_balance(holder); - BOOST_REQUIRE_EQUAL(success(), push_contract(LIQ_ACCOUNT, liq_abi_ser, LIQ_ACCOUNT, shadow_action::mint, - mvo()("holder", holder)("quantity", asset(static_cast(amount), kLiqEthSymbol)))); + BOOST_REQUIRE_EQUAL(success(), push_contract(LIQ_ACCOUNT, liq_abi_ser, SYND_ACCOUNT, shadow_action::mint, + mvo()("to", holder)("token_code", codename_mvo(kLiqEthCodename))("amount", amount))); BOOST_REQUIRE_EQUAL(before + static_cast(amount), shadow_balance(holder)); } @@ -2923,4 +2929,84 @@ BOOST_FIXTURE_TEST_CASE(reregistration_forfeits_banked_yield_but_keeps_remit_cla BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kLiqEthCodename)); } FC_LOG_AND_RETHROW() } + +// This is the dual-shadow producer flow's depot policy with generic symbols. +// Registration of the Ethereum node NFT itself remains in the ETH-specific suites. +BOOST_FIXTURE_TEST_CASE(two_generic_depot_assets_are_both_required_and_withdrawals_preserve_identity, + sysio_opreg_tester) { + using namespace sysio::testing::external; + constexpr uint64_t Minimum = 2 * Unit; + constexpr uint64_t Funding = 4 * Unit; + const auto account = kEligibilityProducer; + BOOST_REQUIRE_EQUAL(success(), setconfig(kTestMaxProducers, kTestMaxBatchOperators, kTestMaxUnderwriters, + kDefaultPruneDelayMs, kDefaultMaxConsecutiveMisses, kMaxAcceptedPctMisses24h, kTerminateWindowMs, + {make_chain_min_bond(kWireCodename, First.token, Minimum), + make_chain_min_bond(kWireCodename, Second.token, Minimum)}, {}, {})); + BOOST_REQUIRE_EQUAL(success(), regoperator(account, OPERATOR_TYPE_PRODUCER, false)); + setup_shadow_liq({First, Second}); + for (const auto& a : Assets) { + BOOST_REQUIRE_EQUAL(success(), push_contract(LIQ_ACCOUNT, liq_abi_ser, SYND_ACCOUNT, shadow_action::mint, + mvo()("to", account)("token_code", codename_mvo(a.token))("amount", Funding))); + } + const auto active = [&] { + return get_operator(account)[eligibility_field::status].as() == + OperatorStatus::OPERATOR_STATUS_ACTIVE; + }; + BOOST_REQUIRE(!active()); + // Surplus of the first token never substitutes for the missing second token. + BOOST_REQUIRE_EQUAL(success(), deposit(account, First.token, Funding)); + BOOST_REQUIRE(!active()); + BOOST_REQUIRE_EQUAL(success(), deposit(account, Second.token, Minimum - 1)); + BOOST_REQUIRE(!active()); + BOOST_REQUIRE_EQUAL(success(), deposit(account, Second.token, 1)); + BOOST_REQUIRE(active()); + // Exercise either required token independently. Reservations must affect + // eligibility immediately, before epoch maintenance moves any custody. + for (const auto& a : Assets) { + const auto sym = symbol::from_string(std::string("9,") + a.token); + const auto other = symbol::from_string(std::string("9,") + (a.id == First.id ? Second.token : First.token)); + const auto other_before = get_currency_balance(LIQ_ACCOUNT, other, account).get_amount(); + const uint64_t withdrawn = a.id == First.id ? Funding : Minimum; + const auto before = get_currency_balance(LIQ_ACCOUNT, sym, account).get_amount(); + BOOST_REQUIRE_EQUAL(success(), withdraw(account, a.token, withdrawn)); + BOOST_REQUIRE(!active()); + BOOST_REQUIRE_EQUAL(before, get_currency_balance(LIQ_ACCOUNT, sym, account).get_amount()); + produce_block(); // Distinct TAPOS for repeated maintenance actions. + BOOST_REQUIRE_EQUAL(success(), flushwtdw(kFlushAllMaturedEpoch)); + const auto claim = get_remitclaim(account, a.token); + BOOST_REQUIRE(!claim.is_null()); + BOOST_REQUIRE_EQUAL(withdrawn, claim["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(success(), claimremit(account, a.token)); + BOOST_REQUIRE_EQUAL(before + int64_t(withdrawn), get_currency_balance(LIQ_ACCOUNT, sym, account).get_amount()); + BOOST_REQUIRE_EQUAL(other_before, get_currency_balance(LIQ_ACCOUNT, other, account).get_amount()); + BOOST_REQUIRE(get_remitclaim(account, a.token).is_null()); + BOOST_REQUIRE_EQUAL(success(), deposit(account, a.token, Minimum)); + BOOST_REQUIRE(active()); + } +} + +BOOST_FIXTURE_TEST_CASE(native_wire_partial_withdrawal_stays_active_and_claims_exactly, sysio_opreg_tester) { + constexpr uint64_t Minimum = 2 * kWireUnit; + constexpr uint64_t Bond = 2 * Minimum; + const auto account = kEligibilityBatchOperator; + BOOST_REQUIRE_EQUAL(success(), setconfig(kTestMaxProducers, kTestMaxBatchOperators, kTestMaxUnderwriters, + kDefaultPruneDelayMs, kDefaultMaxConsecutiveMisses, kMaxAcceptedPctMisses24h, kTerminateWindowMs, + {}, {make_chain_min_bond(kWireCodename, kWireCodename, Minimum)}, {})); + BOOST_REQUIRE_EQUAL(success(), regoperator(account, OPERATOR_TYPE_BATCH, false)); + setup_wire_token_and_fund(account, kWireFundingQuantity); + const auto before = wire_balance(account); + BOOST_REQUIRE_EQUAL(success(), deposit(account, kWireCodename, Bond)); + BOOST_REQUIRE_EQUAL(before - int64_t(Bond), wire_balance(account)); + BOOST_REQUIRE_EQUAL(success(), withdraw(account, kWireCodename, Minimum)); + BOOST_REQUIRE(get_operator(account)[eligibility_field::status].as() == + OperatorStatus::OPERATOR_STATUS_ACTIVE); + BOOST_REQUIRE_EQUAL(before - int64_t(Bond), wire_balance(account)); + BOOST_REQUIRE_EQUAL(success(), flushwtdw(kFlushAllMaturedEpoch)); + BOOST_REQUIRE_EQUAL(Minimum, get_remitclaim(account, kWireCodename)["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(success(), claimremit(account, kWireCodename)); + BOOST_REQUIRE_EQUAL(before - int64_t(Minimum), wire_balance(account)); + BOOST_REQUIRE(get_remitclaim(account, kWireCodename).is_null()); + BOOST_REQUIRE(get_operator(account)[eligibility_field::status].as() == + OperatorStatus::OPERATOR_STATUS_ACTIVE); +} BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.snapshot_attest_tests.cpp b/contracts/tests/sysio.snapshot_attest_tests.cpp index 04a364fc84..4500b2ec29 100644 --- a/contracts/tests/sysio.snapshot_attest_tests.cpp +++ b/contracts/tests/sysio.snapshot_attest_tests.cpp @@ -168,8 +168,11 @@ class snapshot_attest_tester : public sysio_system_tester { } /// Chain and token of the one producer collateral pair `set_producer_min_bond` requires. - static constexpr std::string_view bond_chain = "ETH"; - static constexpr std::string_view bond_token = "ETH"; + static constexpr std::string_view bond_chain = "WIRE"; + static constexpr std::string_view bond_token = "WIRE"; + + /// The depot-native collateral denomination, separate from the fixture's SYS resource token. + static inline const symbol bond_symbol = symbol(9, "WIRE"); /// The producer minimum `register_bonded_producer_operators` installs, and the bond each bonded producer posts. static constexpr uint64_t producer_min_bond = 1'000'000; @@ -196,17 +199,14 @@ class snapshot_attest_tester : public sysio_system_tester { produce_block(); } - /// Credit `amount` to `producer`'s bond on the required pair, as sysio.msgch delivers an inbound deposit. opreg + /// Fund and deposit real depot-native collateral under the producer's authority. opreg /// re-evaluates the operator's status on the balance change. void credit_producer_bond(name producer, uint64_t amount) { - base_tester::push_action("sysio.opreg"_n, "depositinle"_n, "sysio.opreg"_n, mvo() - ("account", producer) - ("chain_code", bond_chain) - ("token_code", bond_token) - ("amount", amount) - ("actor_chain", sysio::opp::types::ChainKind::CHAIN_KIND_EVM) - ("actor_address", std::vector(20, '\x06')) - ("original_message_id", fc::sha256())); + issue_and_transfer(producer, asset(static_cast(amount), bond_symbol)); + base_tester::push_action("sysio.opreg"_n, "deposit"_n, producer, mvo() + ("account", producer) + ("token_code", bond_token) + ("amount", amount)); produce_block(); } @@ -214,6 +214,7 @@ class snapshot_attest_tester : public sysio_system_tester { /// that clears the minimum is what makes opreg promote it to ACTIVE, which `regproducer` then admits. void register_bonded_producer_operators(const std::vector& names) { if (names.empty()) return; + create_currency("sysio.token"_n, config::system_account_name, asset(asset::max_amount, bond_symbol)); set_producer_min_bond(producer_min_bond); for (const auto& p : names) { base_tester::push_action("sysio.opreg"_n, "regoperator"_n, "sysio.opreg"_n, mvo() diff --git a/contracts/tests/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index e17cb693fd..e2c31ddf11 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -96,7 +96,8 @@ class sysio_swap_tester : public tester { produce_blocks( 2 ); create_accounts( { "alice"_n, "bob"_n, "carol"_n, "sysio.token"_n, "sysio.swap"_n, - "badtoken"_n, "anothertoken"_n, "sysio.chains"_n, "sysio.tokens"_n, "sysio.liq"_n, "sysio.msgch"_n } ); + "badtoken"_n, "anothertoken"_n, "sysio.chains"_n, "sysio.tokens"_n, "sysio.liq"_n, "sysio.msgch"_n, + "sysio.synd"_n } ); produce_blocks( 2 ); // sysio.token bills every row to the system account (ram_payer = "sysio"), @@ -417,14 +418,12 @@ class sysio_swap_tester : public tester { ( "sym", SHD4 )( "chain_code", codename_mvo( ShadowChainCodename ) ) ( "token_code", codename_mvo( ShadowTokenCodename ) ) ) ); } - // Shadow enters supply the way the depot mints it: sysio.msgch credits a - // syndication the outpost reported, one sequence per credit. - uint64_t liq_sequence = 0; + // Shadow enters supply the way the depot mints it: sysio.synd, the only minter, + // mints it on sysio.liq. action_result shadow_mint( name to, asset quantity ) { using sysio_system::test_support::codename_mvo; - return push_shadow_action( "sysio.msgch"_n, "mintsynd"_n, mvo() - ( "chain_code", codename_mvo( ShadowChainCodename ) )( "sequence", ++liq_sequence ) - ( "account", to )( "token_code", codename_mvo( ShadowTokenCodename ) )( "amount", quantity.get_amount() ) ); + return push_shadow_action( "sysio.synd"_n, "mint"_n, mvo() + ( "to", to )( "token_code", codename_mvo( ShadowTokenCodename ) )( "amount", quantity.get_amount() ) ); } action_result shadow_transfer( name from, name to, asset quantity, string memo ) { return push_shadow_action( from, "transfer"_n, mvo() @@ -3109,4 +3108,89 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { BOOST_REQUIRE( tables == expected_tables ); } FC_LOG_AND_RETHROW() +// Review focus 2, sysio.swap: while the sysio.andon cord is pulled every action that moves tokens is refused +// -- a deposit, a withdrawal, an exchange, adding and removing liquidity, closing a funded deposit and a +// pair-token transfer -- and what moves nothing runs. After the clear the exchange goes through. +BOOST_FIXTURE_TEST_CASE( a_freeze_refuses_every_token_move, sysio_swap_tester ) try { + namespace andon = sysio_system::test_support::andon; + const abi_def swap_abi = swap_abi_def(); + create_tokens_and_issue(); + transfer( "anothertoken"_n, "bob"_n, "alice"_n, asset::from_string("500000000.0000 VOICE"), ""); + abi_ser.set_abi(swap_abi, abi_serializer::create_yield_function(abi_serializer_max_time)); + many_openext(); + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, + asset::from_string("10000000.0000 EOS"), "") ); + BOOST_REQUIRE_EQUAL( success(), seed_transfer( "anothertoken"_n, "alice"_n, + asset::from_string("200000000.0000 VOICE"), "") ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, extend(asset::from_string("100000000.0000 VOICE")), + extend(asset::from_string("1000000.0000 EOS")), 10, name{}) ); + const int64_t alice_eos = balance("alice"_n, EOS4); + + abi_serializer andon_abi_ser; + andon::deploy(*this, andon_abi_ser, contracts::andon_wasm(), contracts::andon_abi()); + BOOST_REQUIRE_EQUAL( success(), andon::pull(*this, andon_abi_ser) ); + const auto frozen = wasm_assert_msg(andon::frozen_message); + + BOOST_REQUIRE_EQUAL( frozen, exchange( "alice"_n, EVO, extend(asset::from_string("1.0000 EOS")), + asset::from_string("0.0000 VOICE")) ); + BOOST_REQUIRE_EQUAL( frozen, addliquidity( "alice"_n, asset::from_string("1.0000 EVO"), + asset::from_string("1000.0000 VOICE"), asset::from_string("10.0000 EOS")) ); + BOOST_REQUIRE_EQUAL( frozen, remliquidity( "alice"_n, asset::from_string("1.0000 EVO"), + asset::from_string("0.0000 VOICE"), asset::from_string("0.0000 EOS")) ); + BOOST_REQUIRE_EQUAL( frozen, withdraw( "alice"_n, "alice"_n, extend(asset::from_string("1.0000 EOS"))) ); + BOOST_REQUIRE_EQUAL( frozen, closeext( "alice"_n, "alice"_n, system_token ) ); + BOOST_REQUIRE_EQUAL( frozen, transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, + asset::from_string("1.0000 EOS"), "") ); + BOOST_REQUIRE_EQUAL( frozen, push_action( "sysio.swap"_n, "alice"_n, "transfer"_n, mvo() + ( "from", "alice"_n )( "to", "bob"_n )( "quantity", asset::from_string("1.0000 EVO") )( "memo", "" )) ); + BOOST_REQUIRE_EQUAL( alice_eos, balance("alice"_n, EOS4) ); + + // What moves nothing runs. + BOOST_REQUIRE_EQUAL( success(), sync( EVO ) ); + BOOST_REQUIRE_EQUAL( success(), changefee( EVO, 20 ) ); + BOOST_REQUIRE_EQUAL( success(), openext( "carol"_n, "alice"_n, system_token ) ); + + BOOST_REQUIRE_EQUAL( success(), andon::clear(*this, andon_abi_ser) ); + BOOST_REQUIRE_EQUAL( success(), exchange( "alice"_n, EVO, extend(asset::from_string("1.0000 EOS")), + asset::from_string("0.0000 VOICE")) ); + BOOST_REQUIRE_EQUAL( alice_eos - 1'0000, balance("alice"_n, EOS4) ); +} FC_LOG_AND_RETHROW() + +// The sysio.swap gates that sit where tokens move, not at the top of the action: while the cord is pulled +// `inittoken` (the seeds leave the deposits for the pool), an accrual with yield owed (it claims WIRE out +// of sysio.liq) and a tick with a clip to sell are refused; what only reads or configures runs. After the +// clear all three go through. +BOOST_FIXTURE_TEST_CASE( a_freeze_refuses_inittoken_accrual_and_a_selling_tick, sysio_swap_yield_tester ) try { + namespace andon = sysio_system::test_support::andon; + setup_yield_pool(); + BOOST_REQUIRE_EQUAL( success(), setyield( SHEO, 3600, 1, 1000 ) ); + fund_yield_in_one_transaction( "alice"_n, SHEO, asset( 1000'0000, SHD4 ) ); + BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( 100'0000, WIRE9 ), SHD ) ); + produce_blocks( 10 ); + + abi_serializer andon_abi_ser; + andon::deploy(*this, andon_abi_ser, contracts::andon_wasm(), contracts::andon_abi()); + BOOST_REQUIRE_EQUAL( success(), andon::pull(*this, andon_abi_ser) ); + const auto frozen = wasm_assert_msg(andon::frozen_message); + const auto pool = system_balance( SHEO.value ); + + BOOST_REQUIRE_EQUAL( frozen, tickyield( SHEO ) ); + BOOST_REQUIRE_EQUAL( frozen, accrueyield( SHEO ) ); + BOOST_REQUIRE_EQUAL( frozen, inittoken( "alice"_n, ETUSD5, + extend(asset::from_string("1.00 TUSD")), extend( asset( 1'0000, WIRE9 ) ), 10, name{}) ); + BOOST_REQUIRE( pool == system_balance( SHEO.value ) ); + BOOST_REQUIRE_EQUAL( 1000'0000, reservoir_of( SHEO ) ); + BOOST_REQUIRE_EQUAL( success(), setyield( SHEO, 3600, 1, 1000 ) ); // configures, moves nothing + BOOST_REQUIRE_EQUAL( success(), sync( SHEO ) ); + + BOOST_REQUIRE_EQUAL( success(), andon::clear(*this, andon_abi_ser) ); + produce_blocks( 10 ); + BOOST_REQUIRE_EQUAL( success(), accrueyield( SHEO ) ); + BOOST_REQUIRE_LT( pool.at(1), system_balance( SHEO.value ).at(1) ); // the WIRE side took its yield + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + BOOST_REQUIRE_LT( reservoir_of( SHEO ), 1000'0000 ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD5, + extend(asset::from_string("1.00 TUSD")), extend( asset( 1'0000, WIRE9 ) ), 10, name{}) ); +} FC_LOG_AND_RETHROW() + BOOST_AUTO_TEST_SUITE_END() \ No newline at end of file diff --git a/contracts/tests/sysio.synd_tests.cpp b/contracts/tests/sysio.synd_tests.cpp new file mode 100644 index 0000000000..3705f145ea --- /dev/null +++ b/contracts/tests/sysio.synd_tests.cpp @@ -0,0 +1,4519 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "contracts.hpp" +#include "contract_test_support.hpp" +#include "shadow_yield_reference.hpp" +#include "external_chain_simulator.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace sysio::testing; +using namespace sysio; +using namespace sysio::chain; +using namespace sysio::opp::types; +using namespace fc; + +using mvo = fc::mutable_variant_object; +using sysio_system::test_support::codename_mvo; + +namespace { + +/// `envelope_state` names as the ABI renders them. +constexpr std::string_view STATE_OPEN = "OPEN"; +constexpr std::string_view STATE_WAITING = "WAITING"; +constexpr std::string_view STATE_REQUESTED = "REQUESTED"; +constexpr std::string_view STATE_RELEASABLE = "RELEASABLE"; +constexpr std::string_view STATE_HELD = "HELD"; +constexpr std::string_view STATE_INVALID = "INVALID"; +constexpr std::string_view STATE_DONE = "DONE"; +/// `sysio.bond` request states as the ABI renders them. +constexpr std::string_view REQUEST_OPEN = "OPEN"; +constexpr std::string_view REQUEST_BONDED = "BONDED"; +constexpr std::string_view REQUEST_APPROVED = "APPROVED"; +constexpr std::string_view REQUEST_HELD = "HELD"; +/// The `total_syndicated` an intake helper carries by default: the whole asset range, at or above any +/// outstanding shadow (supply plus parked yield) a test can reach, so an outpost custody of this size +/// always covers the depot. +constexpr uint64_t CUSTODY_COVERING_ANY_SUPPLY = static_cast(asset::max_amount); +/// `item_kind` names as the ABI renders them. +constexpr std::string_view KIND_SYNDICATION = "SYNDICATION"; +constexpr std::string_view KIND_YIELD = "YIELD"; + +/// Console fragments of sysio.synd's drop diagnostics. +constexpr std::string_view DROP_REPLAY = "sysio.synd::onsynd: DROP -- replayed sequence"; +constexpr std::string_view DROP_YIELD_REPLAY = "sysio.synd::onyield: DROP -- replayed sequence"; +constexpr std::string_view DROP_NO_SHADOW = "sysio.synd::onsynd: DROP -- token_code has no shadow symbol"; +constexpr std::string_view DROP_OTHER_CHAIN = "sysio.synd::onsynd: DROP -- token_code belongs to another chain"; +constexpr std::string_view DROP_RANGE = "sysio.synd::onsynd: DROP -- amount out of range"; +constexpr std::string_view DROP_HEADROOM = "sysio.synd::onsynd: DROP -- supply exceeds the asset range"; +constexpr std::string_view DROP_PUBKEY = "sysio.synd::onsynd: DROP -- pubkey does not fit the chain family"; +constexpr std::string_view DROP_ENVELOPE_RANGE = "sysio.synd::onsynd: DROP -- the envelope's total, rounded up " + "to the bond increment, would exceed the asset range"; +constexpr std::string_view DROP_YIELD_ENVELOPE_RANGE = "sysio.synd::onyield: DROP -- the envelope's total, rounded " + "up to the bond increment, would exceed the asset range"; +constexpr std::string_view DROP_YIELD_HEADROOM = + "sysio.synd::onyield: DROP -- the envelope's yield would exceed the shadow's headroom"; +constexpr std::string_view DROP_KIND = + "sysio.synd::onsynd: DROP -- chain_kind is not the outpost's chain family"; +constexpr std::string_view DROP_CLOSED = + "sysio.synd::onsynd: DROP -- envelope already closed or of another digest"; +constexpr std::string_view DROP_DIGEST = + "sysio.synd::closeenv: DROP -- envelope digest does not match; left OPEN"; +/// Console fragments of the queue step's diagnostics. +constexpr std::string_view QUEUE_UNSET_BUCKET = + "-- no syndconfig row: the syndication bucket is unset, nothing releases"; +constexpr std::string_view QUEUE_EMPTY_BUCKET = "-- syndication bucket is empty until it refills"; +constexpr std::string_view QUEUE_YIELD_HEADROOM = "-- yield exceeds the shadow's headroom; the item waits"; +constexpr std::string_view QUEUE_INVALID_BURNED = "-- request ruled INVALID; the envelope is burned and INVALID"; +constexpr std::string_view QUEUE_INVALID_BURNING = + "-- request ruled INVALID; burning the envelope's items continues next step, the pair waits"; +/// Console fragments of the queue step and `linkswept` while the andon cord is pulled. +constexpr std::string_view QUEUE_FROZEN = + "sysio.synd::queue: the andon cord is pulled; releases, deliveries and burns wait for the clear"; +constexpr std::string_view QUEUE_FROZEN_SHARE = "-- the challenger's hold share waits for the andon cord to clear"; +constexpr std::string_view QUEUE_FROZEN_BURN = + "-- request ruled INVALID while the andon cord is pulled; the burn waits for the clear, the pair waits"; +constexpr std::string_view LINKSWEPT_FROZEN = + "sysio.synd::linkswept: the andon cord is pulled; the parked balance waits for sweep after the clear"; +/// Console fragments of the solvency check (every verdict line carries one of the two words). +constexpr std::string_view SHORTFALL = ": SHORTFALL -- reported "; +constexpr std::string_view EXCESS = ": EXCESS -- reported "; +constexpr std::string_view CORD_PULLED = ": CORD PULLED -- custody shortfall "; +constexpr std::string_view CORD_ALREADY = ": CORD ALREADY PULLED -- the shortfall is recorded"; +/// The intake paths as the verdicts name them. +constexpr std::string_view ONSYND_PATH = "onsynd"; +constexpr std::string_view ONYIELD_PATH = "onyield"; +/// `mismatch` kinds as the ABI renders them. +constexpr std::string_view MISMATCH_SYNDICATION = "SYNDICATION"; +constexpr std::string_view MISMATCH_YIELD = "YIELD"; + +/// The whole verdict line of the solvency check on `path`: `word` (SHORTFALL or EXCESS) with the carried +/// custody and the outstanding it was compared with. +std::string verdict(std::string_view path, std::string_view word, uint64_t reported, uint64_t outstanding) { + return "sysio.synd::" + std::string(path) + std::string(word) + std::to_string(reported) + " outstanding " + + std::to_string(outstanding); +} +/// The line a pull prints: the reason it carries. +std::string pulled_line(std::string_view path, std::string_view chain_code, std::string_view token_code, + uint64_t sequence) { + return "sysio.synd::" + std::string(path) + std::string(CORD_PULLED) + std::string(chain_code) + " " + + std::string(token_code) + " seq " + std::to_string(sequence); +} +/// The reason a shortfall's pull records on the cord. +std::string pull_reason(std::string_view chain_code, std::string_view token_code, uint64_t sequence) { + return "custody shortfall " + std::string(chain_code) + " " + std::string(token_code) + " seq " + + std::to_string(sequence); +} + +/// Refusals of `challenge`. +constexpr std::string_view CHALLENGE_TWICE = "the envelope's request is already challenged"; +constexpr std::string_view CHALLENGE_STATE = "only a REQUESTED, RELEASABLE or DONE envelope can be challenged"; +constexpr std::string_view CHALLENGE_ZERO = "challenge charge is zero; configure challenge_extra"; +/// Refusal of `dropenv`. +constexpr std::string_view DROPENV_STATE = "only an OPEN or WAITING envelope with no request issued can be dropped"; +constexpr std::string_view CHALLENGE_FINAL = "the envelope's request is approved or ruled; it cannot be challenged"; +constexpr std::string_view CHALLENGE_NO_ROW = "envelope not found"; +constexpr std::string_view CHALLENGE_ROLE = "the challenger cannot be sysio.synd or sysio.bond"; +/// sysio.bond's hold bond, in basis points of the covered amount, while no `setconfig` sets another. +constexpr uint64_t HOLD_BPS = 1000; +constexpr uint64_t BPS_DENOMINATOR = 10000; + +/// The schema every envelope statement is registered under on sysio.bond. +constexpr std::string_view STATEMENT_SCHEMA = "oppenvelope"; +/// sysio.synd's default challenge window, for a pair with no `syndconfig` row. +constexpr uint32_t DEFAULT_WINDOW_SEC = 10800; +/// A crank budget large enough for every case here. +constexpr uint32_t CRANK_LIMIT = 100; + +/// sysio.bond's PRUNE_RETENTION_SEC: a ruled request stays seven days. +constexpr uint32_t PRUNE_RETENTION_SEC = 604'800; + +/// The largest amount the depot accepts: `opp::safe::depot_amount_max`. +constexpr uint64_t DEPOT_AMOUNT_MAX = (uint64_t{1} << 62) - 1; + +/// The chain-native address `sysio.authex::recordlink` carries beside a linked key. On SVM that is the +/// ED key's own 32 bytes; on EVM it is the derived 20-byte address. +std::vector native_address_of(const fc::crypto::public_key& pub_key) { + if (pub_key.type() == fc::crypto::public_key::key_type::em) { + const auto address = fc::crypto::ethereum::address_to_bytes(pub_key); + return {address.begin(), address.end()}; + } + const auto raw = pub_key.get().serialize(); + return std::vector(raw.begin(), raw.end()); +} + +/// The dev bootstrap config, parsed as strictly as the bootstrap tool parses it. +sysio::opp::bootstrap::BootstrapPlatformConfig load_dev_config() { + const auto path = contracts::dex_config_dir() + "/dex-config.dev.json"; + std::ifstream in(path); + BOOST_REQUIRE_MESSAGE(in.good(), "cannot open " << path); + std::stringstream json; + json << in.rdbuf(); + sysio::opp::bootstrap::BootstrapPlatformConfig cfg; + google::protobuf::util::JsonParseOptions opts; // an unknown field is an error + const auto status = google::protobuf::util::JsonStringToMessage(json.str(), &cfg, opts); + BOOST_REQUIRE_MESSAGE(status.ok(), status.ToString()); + return cfg; +} + +} // anonymous namespace + +/// sysio.synd on the depot: the registries it validates against, sysio.liq as the ledger it mints into +/// and holds in, sysio.authex for the links a parked hold is delivered on (with sysio.dclaim, which +/// `createlink` requires), sysio.msgch as the signer of the envelope path and the outbound queue a +/// desyndication lands in (the routing through its code is exercised by `sysio_dispatch_tests`), and +/// sysio.swap for the launch replay's yield pools, sysio.bond as the underwriter of every envelope, and a +/// stand-in on sysio.epoch that moves the depot epoch index the buckets tick to. +class sysio_synd_tester : public tester { +public: + static constexpr auto SYND_ACCOUNT = "sysio.synd"_n; + static constexpr auto LIQ_ACCOUNT = "sysio.liq"_n; + static constexpr auto MSGCH_ACCOUNT = "sysio.msgch"_n; + static constexpr auto AUTHEX_ACCOUNT = "sysio.authex"_n; + static constexpr auto TOKEN_ACCOUNT = "sysio.token"_n; + static constexpr auto TOKENS_ACCOUNT = "sysio.tokens"_n; + static constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; + static constexpr auto DCLAIM_ACCOUNT = "sysio.dclaim"_n; + static constexpr auto SWAP_ACCOUNT = "sysio.swap"_n; + static constexpr auto BOND_ACCOUNT = "sysio.bond"_n; + static constexpr auto EPOCH_ACCOUNT = "sysio.epoch"_n; + static constexpr auto SYSIO_ACCOUNT = "sysio"_n; + + static inline const symbol WIRE_SYM = symbol::from_string("9,WIRE"); + static inline const symbol LIQSOL_SYM = symbol::from_string("9,LIQSOL"); + static inline const symbol LIQTWO_SYM = symbol::from_string("9,LIQTWO"); + static inline const symbol LIQETH_SYM = symbol::from_string("9,LIQETH"); + static constexpr uint64_t UNIT = 1'000'000'000; // one whole token of a precision-9 symbol + /// `synd::bucket_direction` values, one byte each in a `buckets` key. + static constexpr char SYNDICATION_DIRECTION = 0; + static constexpr char DESYNDICATION_DIRECTION = 1; + + static constexpr std::string_view SOLANA = "SOLANA"; + static constexpr std::string_view ETH = "ETH"; + static constexpr std::string_view LIQSOL = "LIQSOL"; + static constexpr std::string_view LIQTWO = "LIQTWO"; + static constexpr std::string_view LIQETH = "LIQETH"; + static constexpr std::string_view LIQNONE = "LIQNONE"; ///< a liq token nobody opened a shadow for + + /// How the fixture sets up the emergency stop on sysio.andon. + enum class andon_setup { + deployed, ///< Andon deployed with governance authority, without custom links + absent ///< no sysio.andon account at all: the cord reads clear and nobody may pull it + }; + + /// `seed_registries` registers the two outposts, their liq tokens and the shadows every case + /// starts from; the launch-replay case replays a bootstrap config into empty registries instead. + /// `cord` sets up sysio.andon. + explicit sysio_synd_tester(bool seed_registries = true, andon_setup cord = andon_setup::deployed) { + produce_blocks(2); + // sysio.authex is pre-created by the tester boot. + create_accounts({ SYND_ACCOUNT, LIQ_ACCOUNT, MSGCH_ACCOUNT, TOKEN_ACCOUNT, TOKENS_ACCOUNT, CHAINS_ACCOUNT, + DCLAIM_ACCOUNT, SWAP_ACCOUNT, BOND_ACCOUNT, EPOCH_ACCOUNT, "alice"_n, "bob"_n, "carol"_n, + "dave"_n }); + produce_blocks(2); + + deploy(AUTHEX_ACCOUNT, contracts::authex_wasm(), contracts::authex_abi(), authex_abi_ser); + deploy(DCLAIM_ACCOUNT, contracts::dclaim_wasm(), contracts::dclaim_abi(), dclaim_abi_ser); + deploy(CHAINS_ACCOUNT, contracts::chains_wasm(), contracts::chains_abi(), chains_abi_ser); + deploy(TOKENS_ACCOUNT, contracts::tokens_wasm(), contracts::tokens_abi(), tokens_abi_ser); + deploy(TOKEN_ACCOUNT, contracts::token_wasm(), contracts::token_abi(), token_abi_ser); + deploy(MSGCH_ACCOUNT, contracts::msgch_wasm(), contracts::msgch_abi(), msgch_abi_ser); + // The swap is unprivileged, as it is deployed; it bills a user's rows to the user. + deploy(SWAP_ACCOUNT, contracts::swap_wasm(), contracts::swap_abi(), swap_abi_ser, false); + deploy(LIQ_ACCOUNT, contracts::liq_wasm(), contracts::liq_abi(), liq_abi_ser); + deploy(SYND_ACCOUNT, contracts::synd_wasm(), contracts::synd_abi(), synd_abi_ser); + deploy(BOND_ACCOUNT, contracts::bond_wasm(), contracts::bond_abi(), bond_abi_ser); + deploy(EPOCH_ACCOUNT, contracts::util::epoch_stub_wasm(), contracts::util::epoch_stub_abi(), epoch_abi_ser, + false); + if (cord != andon_setup::absent) { + sysio_system::test_support::andon::deploy(*this, andon_abi_ser, contracts::andon_wasm(), + contracts::andon_abi()); + } + + // WIRE: the treasury supply on sysio and a funder for yield distributions. + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi_ser, TOKEN_ACCOUNT, "create"_n, mvo() + ("issuer", SYSIO_ACCOUNT)("maximum_supply", asset(1'000'000'000 * UNIT, WIRE_SYM)))); + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi_ser, SYSIO_ACCOUNT, "issue"_n, mvo() + ("to", SYSIO_ACCOUNT)("quantity", asset(1'000'000'000 * UNIT, WIRE_SYM))("memo", ""))); + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi_ser, SYSIO_ACCOUNT, "transfer"_n, mvo() + ("from", SYSIO_ACCOUNT)("to", "carol"_n)("quantity", asset(1'000'000 * UNIT, WIRE_SYM))("memo", ""))); + + // The swap: governance is sysio, the system token WIRE. + BOOST_REQUIRE_EQUAL(success(), push(SWAP_ACCOUNT, swap_abi_ser, SWAP_ACCOUNT, "setconfig"_n, mvo() + ("fee_authority", SYSIO_ACCOUNT)("system_token", extended_symbol{WIRE_SYM, TOKEN_ACCOUNT}))); + + if (!seed_registries) return; + + // Two outposts; SOLANA carries two shadowed liq tokens and one without a shadow. + BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_SVM, SOLANA, 2)); + BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_EVM, ETH, 1)); + BOOST_REQUIRE_EQUAL(success(), regtoken(LIQSOL, ChainKind::CHAIN_KIND_SVM, SOLANA)); + BOOST_REQUIRE_EQUAL(success(), regtoken(LIQTWO, ChainKind::CHAIN_KIND_SVM, SOLANA)); + BOOST_REQUIRE_EQUAL(success(), regtoken(LIQNONE, ChainKind::CHAIN_KIND_SVM, SOLANA)); + BOOST_REQUIRE_EQUAL(success(), regtoken(LIQETH, ChainKind::CHAIN_KIND_EVM, ETH)); + BOOST_REQUIRE_EQUAL(success(), create_shadow(LIQSOL_SYM, SOLANA, LIQSOL)); + BOOST_REQUIRE_EQUAL(success(), create_shadow(LIQTWO_SYM, SOLANA, LIQTWO)); + BOOST_REQUIRE_EQUAL(success(), create_shadow(LIQETH_SYM, ETH, LIQETH)); + } + + // --- deployment and pushing --- + + /// Deploy `account`, privileged as every depot system contract is unless `privileged` is false, and + /// load its ABI. + void deploy(name account, const std::vector& wasm, const std::vector& abi, abi_serializer& ser, + bool privileged = true) { + set_code(account, wasm); + set_abi(account, abi.data()); + if (privileged) set_privileged(account); + produce_blocks(); + sysio_system::test_support::load_account_abi(*this, account, ser); + } + + /// Push one action in its own block, keeping its trace in `last_trace` (null after a failure). + action_result push(name code, abi_serializer& ser, name signer, name action_name, const variant_object& data) { + return sysio_system::test_support::push_contract_action_and_produce_block(*this, code, ser, signer, + action_name, data, last_trace); + } + static bool mentions(const action_result& r, std::string_view text) { return r.find(text) != std::string::npos; } + /// Every action's console of the last pushed transaction, inline actions included. + std::string console() const { + std::string out; + if (!last_trace) return out; + for (const auto& at : last_trace->action_traces) out += at.console; + return out; + } + bool console_has(std::string_view text) const { return console().find(text) != std::string::npos; } + + // --- slugs and keys --- + + static uint64_t slug_value(std::string_view s) { return fc::slug_name{s}.value; } + + static fc::crypto::public_key ed_key() { + return fc::crypto::private_key::generate(fc::crypto::private_key::key_type::ed).get_public_key(); + } + static std::vector ed_bytes(const fc::crypto::public_key& pk) { + const auto raw = pk.get().serialize(); + return std::vector(raw.begin(), raw.end()); + } + static fc::crypto::public_key em_key() { + return fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em).get_public_key(); + } + /// The 33-byte compressed secp256k1 form an EVM outpost reports a user's key in. + static std::vector em_bytes(const fc::crypto::public_key& pk) { + const auto raw = pk.get().serialize(); + return std::vector(raw.begin(), raw.end()); + } + /// A canonical envelope digest standing in for the one `sysio.msgch` accepted. + static fc::sha256 digest_of(std::string_view seed) { return fc::sha256::hash(std::string(seed)); } + + // --- registries --- + + // Registrations inside the epoch-0 bootstrap window land ACTIVE, as the launch bootstrap's do. + action_result regchain(ChainKind kind, std::string_view code, uint32_t external_chain_id) { + return push(CHAINS_ACCOUNT, chains_abi_ser, CHAINS_ACCOUNT, "regchain"_n, mvo() + ("kind", kind)("code", codename_mvo(code))("external_chain_id", external_chain_id) + ("name", std::string("outpost"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo())); + } + /// Register a liq token of `chain_code` with `precision` at `address` and bind it to the chain, as the + /// bootstrap does from a `TokenSpec`. + action_result regtoken(std::string_view code, uint32_t precision, ChainKind chain_kind, + std::string_view chain_code, const std::vector& address) { + auto r = push(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, "regtoken"_n, mvo() + ("kind", TokenKind::TOKEN_KIND_LIQ)("code", codename_mvo(code))("symbol_name", std::string(code)) + ("description", std::string{})("precision", precision) + ("address", mvo()("kind", chain_kind)("address", address))); + if (r != success()) return r; + return push(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, "regctok"_n, mvo() + ("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(code))("contract_addr", address) + ("is_native", false)); + } + /// Register a 9-decimal liq token of `chain_code` at a placeholder address and bind it to the chain. + action_result regtoken(std::string_view code, ChainKind chain_kind, std::string_view chain_code) { + return regtoken(code, 9, chain_kind, chain_code, + std::vector(chain_kind == ChainKind::CHAIN_KIND_SVM ? 32 : 20, char(0x5a))); + } + /// The bytes behind a bootstrap config's display-form address or pubkey: `0x`-hex on EVM, base58 on + /// SVM. + static std::vector address_bytes(ChainKind kind, const std::string& display) { + if (kind == ChainKind::CHAIN_KIND_SVM) return fc::from_base58(display); + BOOST_REQUIRE_MESSAGE(display.starts_with("0x"), "not a 0x-hex address: " << display); + std::vector out((display.size() - 2) / 2); + out.resize(fc::from_hex(std::string_view(display).substr(2), out.data(), out.size())); + return out; + } + action_result create_shadow(symbol sym, std::string_view chain_code, std::string_view token_code) { + return push(LIQ_ACCOUNT, liq_abi_ser, LIQ_ACCOUNT, "create"_n, mvo() + ("sym", sym)("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code))); + } + /// Link `account` to `key` on SVM through sysio.authex's trusted path. + action_result link_svm(name account, const fc::crypto::public_key& key) { + return push(AUTHEX_ACCOUNT, authex_abi_ser, AUTHEX_ACCOUNT, "recordlink"_n, mvo() + ("account", account)("chain_kind", ChainKind::CHAIN_KIND_SVM)("pub_key", key) + ("native_address", native_address_of(key))); + } + /// Link `account` to the key behind `priv` through a user-signed `sysio.authex::createlink`, + /// which sends `linkswept` to sysio.synd inline. + action_result createlink(name account, const fc::crypto::private_key& priv, + ChainKind kind = ChainKind::CHAIN_KIND_EVM) { + const uint64_t nonce = control->head().block_time().time_since_epoch().count() / 1000; + return push(AUTHEX_ACCOUNT, authex_abi_ser, account, "createlink"_n, mvo() + ("chain_kind", kind)("account", account.to_string()) + ("sig", sysio_system::test_support::sign_createlink(priv, account.to_string(), kind, + nonce)) + ("pub_key", priv.get_public_key())("nonce", nonce)); + } + + // --- sysio.synd actions --- + + /// `total_syndicated` is the outpost custody the message carries. + action_result onsynd(std::string_view chain_code, uint32_t epoch, const fc::sha256& digest, uint64_t sequence, + ChainKind kind, const std::vector& pubkey, std::string_view token_code, + uint64_t amount, name signer = MSGCH_ACCOUNT, + uint64_t total_syndicated = CUSTODY_COVERING_ANY_SUPPLY) { + return push(SYND_ACCOUNT, synd_abi_ser, signer, "onsynd"_n, mvo() + ("chain_code", codename_mvo(chain_code))("epoch_index", epoch)("digest", digest)("sequence", sequence) + ("chain_kind", kind)("pubkey", pubkey)("token_code", codename_mvo(token_code))("amount", amount) + ("total_syndicated", total_syndicated)); + } + /// `total_syndicated` is the outpost custody the report carries. + action_result onyield(std::string_view chain_code, uint32_t epoch, const fc::sha256& digest, uint64_t sequence, + uint64_t outpost_epoch, std::string_view token_code, uint64_t amount, + name signer = MSGCH_ACCOUNT, uint64_t total_syndicated = CUSTODY_COVERING_ANY_SUPPLY) { + return push(SYND_ACCOUNT, synd_abi_ser, signer, "onyield"_n, mvo() + ("chain_code", codename_mvo(chain_code))("epoch_index", epoch)("digest", digest)("sequence", sequence) + ("outpost_epoch", outpost_epoch)("token_code", codename_mvo(token_code))("amount", amount) + ("total_syndicated", total_syndicated)); + } + action_result closeenv(std::string_view chain_code, uint32_t epoch, const fc::sha256& digest, + name signer = MSGCH_ACCOUNT) { + return push(SYND_ACCOUNT, synd_abi_ser, signer, "closeenv"_n, mvo() + ("chain_code", codename_mvo(chain_code))("epoch_index", epoch)("digest", digest)); + } + + // --- the parked hold, desyndication and launch ingestion --- + + action_result sweep(name account, ChainKind kind, name signer = "alice"_n) { + return push(SYND_ACCOUNT, synd_abi_ser, signer, "sweep"_n, mvo()("account", account)("chain_kind", kind)); + } + action_result linkswept(name account, ChainKind kind, const std::vector& pubkey, + name signer = AUTHEX_ACCOUNT) { + return push(SYND_ACCOUNT, synd_abi_ser, signer, "linkswept"_n, mvo() + ("account", account)("chain_kind", kind)("pubkey", pubkey)); + } + action_result desyndicate(name holder, uint64_t amount, symbol sym = LIQSOL_SYM, name signer = name{}) { + return push(SYND_ACCOUNT, synd_abi_ser, signer == name{} ? holder : signer, "desyndicate"_n, mvo() + ("holder", holder)("quantity", asset(static_cast(amount), sym))); + } + static mvo credit(const std::vector& pubkey, uint64_t amount) { + return mvo()("pubkey", pubkey)("amount", amount); + } + action_result importsynd(std::string_view chain_code, std::string_view token_code, const fc::variants& credits, + name signer = SYND_ACCOUNT) { + return push(SYND_ACCOUNT, synd_abi_ser, signer, "importsynd"_n, mvo() + ("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code))("credits", credits)); + } + action_result importdone(name signer = SYND_ACCOUNT) { + return push(SYND_ACCOUNT, synd_abi_ser, signer, "importdone"_n, mvo()); + } + + // --- the queue --- + + /// The rules of one pair; the defaults are the spec's, with a bucket wide open. + struct pair_config { + uint32_t synd_fee_bps = 0; + uint32_t desynd_fee_bps = 0; + uint64_t synd_burst = 1'000'000 * UNIT; + uint64_t synd_refill = 1'000'000 * UNIT; + uint64_t desynd_burst = 1'000'000 * UNIT; + uint64_t desynd_refill = 1'000'000 * UNIT; + uint32_t window_sec = DEFAULT_WINDOW_SEC; + uint64_t bounty = 0; + uint64_t challenge_extra = 0; + }; + action_result setconfig(std::string_view chain_code, std::string_view token_code, const pair_config& cfg, + name signer = SYSIO_ACCOUNT) { + return push(SYND_ACCOUNT, synd_abi_ser, signer, "setconfig"_n, mvo() + ("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code)) + ("synd_fee_bps", cfg.synd_fee_bps)("desynd_fee_bps", cfg.desynd_fee_bps)("synd_burst", cfg.synd_burst) + ("synd_refill", cfg.synd_refill)("desynd_burst", cfg.desynd_burst)("desynd_refill", cfg.desynd_refill) + ("window_sec", cfg.window_sec)("bounty", cfg.bounty)("challenge_extra", cfg.challenge_extra)); + } + action_result dropenv(std::string_view chain_code, std::string_view token_code, uint32_t epoch, + name signer = SYSIO_ACCOUNT) { + return push(SYND_ACCOUNT, synd_abi_ser, signer, "dropenv"_n, mvo() + ("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code))("epoch_index", epoch)); + } + action_result crank(uint32_t limit = CRANK_LIMIT, name signer = "carol"_n) { + return push(SYND_ACCOUNT, synd_abi_ser, signer, "crank"_n, mvo()("limit", limit)); + } + /// Move the depot's current epoch index, through the stand-in on sysio.epoch. + action_result set_epoch(uint32_t index) { + return push(EPOCH_ACCOUNT, epoch_abi_ser, EPOCH_ACCOUNT, "setindex"_n, mvo()("index", index)); + } + + // --- sysio.andon --- + + /// The sysio.andon `cord` row; null when sysio.andon is absent or has never been pulled. + fc::variant cord_row() { + if (control->find_account(sysio_system::test_support::andon::account) == nullptr) return fc::variant(); + return decode(andon_abi_ser, "cord_state", + get_row_by_account(sysio_system::test_support::andon::account, + sysio_system::test_support::andon::account, "cord"_n, "cord"_n)); + } + /// Whether the andon cord is pulled now. + bool cord_pulled() { + const auto row = cord_row(); + return !row.is_null() && row["pulled"].as_bool(); + } + /// Every `mismatch` row, in key order: by outpost, then sequence. + std::vector mismatch_rows() { + std::vector rows; + for (const auto& value : kv_rows("mismatch"_n)) rows.push_back(decode(synd_abi_ser, "mismatch_row", value)); + return rows; + } + /// The solvency check left no trace: no `mismatch` row and a clear cord. + void require_no_shortfall() { + BOOST_REQUIRE(mismatch_rows().empty()); + BOOST_REQUIRE(!cord_pulled()); + BOOST_REQUIRE(!console_has(SHORTFALL)); + } + /// A `mismatch` row reports what it was compared with: `(chain_code, sequence)` of an item of `kind` of + /// `token_code` in the envelope of `epoch`, `reported` custody below the `expected` outstanding, recorded at + /// the block of the last pushed transaction. + void require_mismatch(const fc::variant& row, std::string_view chain_code, std::string_view token_code, + uint32_t epoch, uint64_t sequence, std::string_view kind, uint64_t reported, + uint64_t expected) { + BOOST_REQUIRE_EQUAL(chain_code, row["chain_code"].as_string()); + BOOST_REQUIRE_EQUAL(token_code, row["token_code"].as_string()); + BOOST_REQUIRE_EQUAL(epoch, row["epoch_index"].as()); + BOOST_REQUIRE_EQUAL(sequence, row["sequence"].as_uint64()); + BOOST_REQUIRE_EQUAL(kind, row["kind"].as_string()); + BOOST_REQUIRE_EQUAL(reported, row["reported"].as_uint64()); + BOOST_REQUIRE_EQUAL(expected, row["expected"].as_uint64()); + BOOST_REQUIRE(last_trace != nullptr); + BOOST_REQUIRE_EQUAL(time_point_sec(last_trace->block_time.to_time_point()), row["at"].as()); + } + /// Pull the cord as sysio, keeping the trace in `last_trace`. + action_result andon_pull() { + return sysio_system::test_support::push_contract_action_and_produce_block(*this, + sysio_system::test_support::andon::account, andon_abi_ser, SYSIO_ACCOUNT, "pull"_n, + mvo()("reason", "incident"), last_trace, {{"sysio.andon"_n, config::active_name}}); + } + /// Clear the cord as sysio, keeping the trace in `last_trace`. + action_result andon_clear() { + return sysio_system::test_support::push_contract_action_and_produce_block(*this, + sysio_system::test_support::andon::account, andon_abi_ser, SYSIO_ACCOUNT, "clear"_n, + mvo()("note", "resolved"), last_trace, {{"sysio.andon"_n, config::active_name}}); + } + /// The refusal of a frozen signed action. + static action_result frozen() { return wasm_assert_msg(sysio_system::test_support::andon::frozen_message); } + /// `challenger` challenges the envelope of `(chain_code, token_code, epoch)`, signed by `signer` + /// (the challenger by default). + action_result challenge(name challenger, std::string_view chain_code, std::string_view token_code, uint32_t epoch, + name signer = name{}) { + return push(SYND_ACCOUNT, synd_abi_ser, signer == name{} ? challenger : signer, "challenge"_n, mvo() + ("challenger", challenger)("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code)) + ("epoch_index", epoch)); + } + /// Hold `amount` of `pubkey`'s syndication of `token_code` in a one-item envelope of SOLANA at `epoch`, + /// and close it. `sequence` must be past the outpost's cursor. + void syndicate_and_close(uint32_t epoch, uint64_t sequence, const std::vector& pubkey, + std::string_view token_code, uint64_t amount) { + const auto digest = digest_of("envelope-" + std::to_string(epoch)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, epoch, digest, sequence, ChainKind::CHAIN_KIND_SVM, pubkey, + token_code, amount)); + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, epoch, digest)); + } + /// The statement sysio.synd registers for one token of one envelope: the chain code and token code as + /// 8-byte little-endian words, the epoch as a 4-byte one and the digest's 32 raw bytes, in the order + /// chain, epoch, digest, token. + static std::vector statement_of(std::string_view chain_code, uint32_t epoch, const fc::sha256& digest, + std::string_view token_code) { + std::vector out; + const auto append_le = [&](uint64_t value, size_t bytes) { + for (size_t i = 0; i < bytes; ++i) out.push_back(char((value >> (8 * i)) & 0xff)); + }; + append_le(slug_value(chain_code), sizeof(uint64_t)); + append_le(epoch, sizeof(uint32_t)); + out.insert(out.end(), digest.data(), digest.data() + digest.data_size()); + append_le(slug_value(token_code), sizeof(uint64_t)); + return out; + } + + // --- sysio.bond --- + + fc::variant bond_request(uint64_t id) { + return decode(bond_abi_ser, "request_row", get_row_by_id(BOND_ACCOUNT, BOND_ACCOUNT, "requests"_n, id)); + } + uint64_t next_bond_request_id() { + const auto row = decode(bond_abi_ser, "bond_counters", + get_row_by_account(BOND_ACCOUNT, BOND_ACCOUNT, "bondcounters"_n, "bondcounters"_n)); + return row.is_null() ? 1 : row["next_request_id"].as_uint64(); + } + /// Bond `amount` of request `request_id` as `underwriter`, minting it the shadow first. + action_result underwrite(name underwriter, uint64_t request_id, uint64_t amount, std::string_view token_code) { + const auto minted = liq_mint(underwriter, token_code, amount); + if (minted != success()) return minted; + return push(BOND_ACCOUNT, bond_abi_ser, underwriter, "accept"_n, mvo() + ("underwriter", underwriter)("request_id", request_id)("amount", amount)); + } + /// Rule request `id` as sysio: VALID when `valid`, INVALID otherwise. + action_result rule(uint64_t id, bool valid) { + return push(BOND_ACCOUNT, bond_abi_ser, SYSIO_ACCOUNT, valid ? "rslvvalid"_n : "rslvinvalid"_n, + mvo()("request_id", id)); + } + /// sysio.bond's permissionless `prune`, signed by carol. + action_result bond_prune(uint64_t from_id, uint32_t limit) { + return push(BOND_ACCOUNT, bond_abi_ser, "carol"_n, "prune"_n, mvo()("from_id", from_id)("limit", limit)); + } + /// sysio.bond's `hold` of request `id` naming `beneficiary`, signed by `signer` (the issuer, sysio.synd). + action_result bond_hold(uint64_t id, name beneficiary, name signer = SYND_ACCOUNT) { + return push(BOND_ACCOUNT, bond_abi_ser, signer, "hold"_n, mvo()("request_id", id)("beneficiary", beneficiary)); + } + /// `account`'s claim on request `id`, signed by it. + action_result bond_claim(uint64_t id, name account) { + return push(BOND_ACCOUNT, bond_abi_ser, account, "claim"_n, mvo()("request_id", id)("account", account)); + } + /// Bond the whole of the request of envelope `(chain_code, token_code, epoch)` as dave. + void underwrite_envelope(std::string_view chain_code, std::string_view token_code, uint32_t epoch) { + const auto id = envelope_row(chain_code, token_code, epoch)["request_id"].as_uint64(); + const auto covered = bond_request(id)["covered"].as_uint64(); + BOOST_REQUIRE_EQUAL(success(), underwrite("dave"_n, id, covered, token_code)); + } + + // --- sysio.liq actions --- + + action_result liq_mint(name to, std::string_view token_code, uint64_t amount, name signer = SYND_ACCOUNT) { + return push(LIQ_ACCOUNT, liq_abi_ser, signer, "mint"_n, mvo() + ("to", to)("token_code", codename_mvo(token_code))("amount", amount)); + } + action_result liq_burn(std::string_view token_code, uint64_t amount, name signer = SYND_ACCOUNT) { + return push(LIQ_ACCOUNT, liq_abi_ser, signer, "burn"_n, mvo() + ("token_code", codename_mvo(token_code))("amount", amount)); + } + action_result liq_mintyield(std::string_view chain_code, std::string_view token_code, uint64_t amount, + name signer = SYND_ACCOUNT) { + return push(LIQ_ACCOUNT, liq_abi_ser, signer, "mintyield"_n, mvo() + ("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code))("amount", amount)); + } + action_result transfer_wire(name from, name to, uint64_t amount) { + return push(TOKEN_ACCOUNT, token_abi_ser, from, "transfer"_n, mvo() + ("from", from)("to", to)("quantity", asset(static_cast(amount), WIRE_SYM))("memo", "")); + } + /// sysio.liq pays `holder` the WIRE its row of `sym` is owed. + action_result liq_claim(name holder, symbol sym = LIQSOL_SYM) { + return push(LIQ_ACCOUNT, liq_abi_ser, holder, "claim"_n, mvo()("holder", holder)("sym", sym.to_symbol_code())); + } + /// sysio.liq's `creditowed`, signed as sysio.synd unless `signer` says otherwise. + action_result liq_creditowed(name holder, uint64_t wire, symbol sym = LIQSOL_SYM, name signer = SYND_ACCOUNT) { + return push(LIQ_ACCOUNT, liq_abi_ser, signer, "creditowed"_n, mvo() + ("holder", holder)("sym", sym.to_symbol_code())("wire", wire)); + } + action_result addyield(name from, uint64_t amount, symbol target = LIQSOL_SYM) { + return push(LIQ_ACCOUNT, liq_abi_ser, from, "addyield"_n, mvo() + ("from", from)("quantity", asset(static_cast(amount), WIRE_SYM))("target", target.to_symbol_code())); + } + action_result create_pool(std::string_view chain_code, std::string_view token_code, symbol pair_symbol, + uint64_t initial_chain_amount, uint64_t initial_wire_amount, int32_t fee, + int64_t locked_shares, uint32_t horizon_sec, uint32_t depth_cap_bps, int64_t clip_floor) { + return push(LIQ_ACCOUNT, liq_abi_ser, LIQ_ACCOUNT, "regliqpool"_n, mvo() + ("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code)) + ("pair_symbol", pair_symbol)("initial_chain_amount", initial_chain_amount) + ("initial_wire_amount", initial_wire_amount)("fee", fee)("locked_shares", locked_shares) + ("conversion_horizon_sec", horizon_sec)("depth_cap_bps", depth_cap_bps)("clip_floor", clip_floor)); + } + + // --- rows --- + + fc::variant decode(abi_serializer& ser, const char* type, const std::vector& data) { + return data.empty() + ? fc::variant() + : ser.binary_to_variant(type, data, abi_serializer::create_yield_function(abi_serializer_max_time)); + } + /// Append `value` to a kv key as the big-endian word the CDT key serializer writes. + static void append_key_word(std::string& key, uint64_t value) { + for (int shift = 56; shift >= 0; shift -= 8) key.push_back(char((value >> shift) & 0xff)); + } + /// sysio.synd's kv rows of `table` in key order whose key starts with `prefix`, as raw values. + std::vector> kv_rows(name table, const std::string& prefix = {}) { + const auto& kv_idx = control->db().get_index(); + const auto table_id = compute_table_id(table.to_uint64_t()); + std::vector> rows; + for (auto itr = kv_idx.lower_bound(boost::make_tuple(SYND_ACCOUNT, table_id, std::string_view(prefix))); + itr != kv_idx.end() && itr->code == SYND_ACCOUNT && itr->table_id == table_id && + itr->key_view().starts_with(prefix); + ++itr) + rows.emplace_back(itr->value.data(), itr->value.data() + itr->value.size()); + return rows; + } + /// The `envelopes` row of `(chain_code, token_code, epoch)`; null when there is none. + fc::variant envelope_row(std::string_view chain_code, std::string_view token_code, uint32_t epoch) { + std::string prefix; + append_key_word(prefix, slug_value(chain_code)); + append_key_word(prefix, slug_value(token_code)); + for (const auto& value : kv_rows("envelopes"_n, prefix)) { + const auto row = decode(synd_abi_ser, "envelope_row", value); + if (row["epoch_index"].as() == epoch) return row; + } + return fc::variant(); + } + /// The `items` row of `id`; null when there is none. Items are keyed by their envelope first. + fc::variant item_row(uint64_t id) { + for (const auto& value : kv_rows("items"_n)) { + const auto row = decode(synd_abi_ser, "item_row", value); + if (row["id"].as_uint64() == id) return row; + } + return fc::variant(); + } + fc::variant cursor_row(std::string_view chain_code) { + return decode(synd_abi_ser, "synd_cursor", + get_row_by_id(SYND_ACCOUNT, SYND_ACCOUNT, "syndcursors"_n, slug_value(chain_code))); + } + fc::variant ledger_row(std::string_view chain_code, std::string_view token_code) { + std::string key; + append_key_word(key, slug_value(chain_code)); + append_key_word(key, slug_value(token_code)); + const auto rows = kv_rows("ledger"_n, key); + return rows.empty() ? fc::variant() : decode(synd_abi_ser, "ledger_row", rows.front()); + } + /// Rows in every sysio.synd table: zero means the contract holds no state at all. + size_t synd_row_count() { + size_t count = 0; + for (auto table : { "envelopes"_n, "items"_n, "ledger"_n, "syndcursors"_n, "yieldpool"_n }) + count += kv_rows(table).size(); + return count + get_row_by_account(SYND_ACCOUNT, SYND_ACCOUNT, "syndcounters"_n, "syndcounters"_n).size(); + } + fc::variant liq_row(name table, const char* type, name scope, uint64_t id) { + return decode(liq_abi_ser, type, get_row_by_id(LIQ_ACCOUNT, scope, table, id)); + } + int64_t liq_balance(name holder, symbol sym = LIQSOL_SYM) { + const auto row = liq_row("accounts"_n, "account", holder, sym.to_symbol_code().value); + return row.is_null() ? 0 : row["balance"].as().get_amount(); + } + int64_t supply(symbol sym = LIQSOL_SYM) { + return liq_row("stat"_n, "currency_stats", LIQ_ACCOUNT, sym.to_symbol_code().value)["supply"].as() + .get_amount(); + } + int64_t pending(symbol sym = LIQSOL_SYM) { + const auto row = liq_row("liqpending"_n, "pending_yield", LIQ_ACCOUNT, sym.to_symbol_code().value); + return row.is_null() ? 0 : row["quantity"].as().get_amount(); + } + fc::uint128_t liq_index(symbol sym = LIQSOL_SYM) { + const auto row = liq_row("yieldidx"_n, "yield_index", LIQ_ACCOUNT, sym.to_symbol_code().value); + return row.is_null() ? fc::uint128_t{0} : row["index"].as_uint128(); + } + /// What `holder`'s sysio.liq row of `sym` is owed now, by the token's own formula. + int64_t liq_owed(name holder, symbol sym = LIQSOL_SYM) { + const auto row = liq_row("accounts"_n, "account", holder, sym.to_symbol_code().value); + if (row.is_null()) return 0; + return yield_reference::owed(row["balance"].as().get_amount(), liq_index(sym), + row["index_checkpoint"].as_uint128(), row["owed_wire"].as_uint64()); + } + /// The `parked` row of `(token_code, kind, pubkey)`; null when there is none. The key is the token and + /// the chain kind as big-endian words, then the pubkey NUL-escaped and NUL-NUL terminated (the kv key + /// encoding of the contract's `parked_key`). + fc::variant parked_row(std::string_view token_code, ChainKind kind, const std::vector& pubkey) { + std::string key; + append_key_word(key, slug_value(token_code)); + append_key_word(key, static_cast(magic_enum::enum_integer(kind))); + for (char c : pubkey) { + key.push_back(c); + if (c == '\0') key.push_back('\x01'); + } + key.push_back('\0'); + key.push_back('\0'); + const auto& kv_idx = control->db().get_index(); + const auto itr = kv_idx.find(boost::make_tuple(SYND_ACCOUNT, compute_table_id("parked"_n.to_uint64_t()), + std::string_view(key))); + if (itr == kv_idx.end()) return fc::variant(); + return decode(synd_abi_ser, "parked_row", + std::vector(itr->value.data(), itr->value.data() + itr->value.size())); + } + uint64_t parked_balance(std::string_view token_code, ChainKind kind, const std::vector& pubkey) { + const auto row = parked_row(token_code, kind, pubkey); + return row.is_null() ? 0 : row["balance"].as_uint64(); + } + fc::variant return_row(uint64_t request_id) { + return decode(synd_abi_ser, "return_row", get_row_by_id(SYND_ACCOUNT, SYND_ACCOUNT, "returns"_n, request_id)); + } + fc::variant pool_row(std::string_view token_code) { + return decode(synd_abi_ser, "pool_row", + get_row_by_id(SYND_ACCOUNT, SYND_ACCOUNT, "yieldpool"_n, slug_value(token_code))); + } + fc::variant state_row() { + return decode(synd_abi_ser, "synd_state", + get_row_by_account(SYND_ACCOUNT, SYND_ACCOUNT, "syndstate"_n, "syndstate"_n)); + } + /// The `syndconfig` row of `(chain_code, token_code)`; null when there is none. + fc::variant config_row(std::string_view chain_code, std::string_view token_code) { + std::string key; + append_key_word(key, slug_value(chain_code)); + append_key_word(key, slug_value(token_code)); + const auto rows = kv_rows("syndconfig"_n, key); + return rows.empty() ? fc::variant() : decode(synd_abi_ser, "synd_config", rows.front()); + } + /// The `buckets` row of `(chain_code, token_code)` for the direction whose one-byte enum value is + /// `direction`; null when there is none. + fc::variant bucket_row(std::string_view chain_code, std::string_view token_code, char direction) { + std::string key; + append_key_word(key, slug_value(chain_code)); + append_key_word(key, slug_value(token_code)); + key.push_back(direction); + const auto rows = kv_rows("buckets"_n, key); + return rows.empty() ? fc::variant() : decode(synd_abi_ser, "bucket_row", rows.front()); + } + /// The syndication `buckets` row of `(chain_code, token_code)`; null when there is none. + fc::variant synd_bucket_row(std::string_view chain_code, std::string_view token_code) { + return bucket_row(chain_code, token_code, SYNDICATION_DIRECTION); + } + /// The desyndication `buckets` row of `(chain_code, token_code)`; null when there is none. + fc::variant desynd_bucket_row(std::string_view chain_code, std::string_view token_code) { + return bucket_row(chain_code, token_code, DESYNDICATION_DIRECTION); + } + /// Shadow base units in the `feepot` of `token_code`. + /// The `feepot` row of `token_code`; null when there is none. + fc::variant feepot_row(std::string_view token_code) { + return decode(synd_abi_ser, "fee_row", + get_row_by_id(SYND_ACCOUNT, SYND_ACCOUNT, "feepot"_n, slug_value(token_code))); + } + uint64_t feepot_balance(std::string_view token_code) { + const auto row = feepot_row(token_code); + return row.is_null() ? 0 : row["balance"].as_uint64(); + } + fc::variant counters_row() { + return decode(synd_abi_ser, "synd_counters", + get_row_by_account(SYND_ACCOUNT, SYND_ACCOUNT, "syndcounters"_n, "syndcounters"_n)); + } + fc::variant attestation_row(uint64_t id) { + return decode(msgch_abi_ser, "attestation_entry", + get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "attestations"_n, id)); + } + fc::variant swap_pool_row(symbol pair) { + return decode(swap_abi_ser, "currency_stats", + get_row_by_id(SWAP_ACCOUNT, SWAP_ACCOUNT, "stat"_n, pair.to_symbol_code().value)); + } + int64_t wire_balance(name holder) { + const auto row = decode(token_abi_ser, "account", + get_row_by_id(TOKEN_ACCOUNT, holder, "accounts"_n, WIRE_SYM.to_symbol_code().value)); + return row.is_null() ? 0 : row["balance"].as().get_amount(); + } + /// What held item `id` is owed now: its position settled at the live index over what it holds. + int64_t item_owed(uint64_t id, symbol sym = LIQSOL_SYM) { + const auto row = item_row(id); + return yield_reference::owed(static_cast(row["remaining"].as_uint64()), liq_index(sym), + row["position"]["index_checkpoint"].as_uint128(), + row["position"]["owed_wire"].as_uint64()); + } + + /// Whether the last pushed transaction executed `code::action_name` (as its own receiver). + bool executed(name code, name action_name) const { + if (!last_trace) return false; + return std::any_of(last_trace->action_traces.begin(), last_trace->action_traces.end(), [&](const auto& at) { + return at.act.account == code && at.act.name == action_name && at.receiver == code; + }); + } + + abi_serializer synd_abi_ser, liq_abi_ser, token_abi_ser, tokens_abi_ser, chains_abi_ser, authex_abi_ser, + dclaim_abi_ser, msgch_abi_ser, swap_abi_ser, bond_abi_ser, epoch_abi_ser, andon_abi_ser; + transaction_trace_ptr last_trace; +}; + +/// The contracts deployed and WIRE issued, but no registry row and no shadow: the state the launch +/// bootstrap finds when it replays a config. +struct sysio_synd_config_tester : sysio_synd_tester { + sysio_synd_config_tester() : sysio_synd_tester(false) {} +}; + +/// The registries seeded, and no sysio.andon at all. +struct sysio_synd_no_andon_tester : sysio_synd_tester { + sysio_synd_no_andon_tester() : sysio_synd_tester(true, andon_setup::absent) {} +}; + +BOOST_AUTO_TEST_SUITE(sysio_synd_tests) + +// --------------------------------------------------------------------------- +// Intake: SYNDICATE_LIQ +// --------------------------------------------------------------------------- + +// A linked and an unlinked pubkey are held the same way: each becomes a SYNDICATION item for its +// pubkey, the shadow is minted into sysio.synd's own holder row, and no account is credited -- not +// even the one the linked pubkey belongs to. +BOOST_FIXTURE_TEST_CASE(onsynd_holds_linked_and_unlinked_alike, sysio_synd_tester) try { + const auto linked_key = ed_key(); + const auto linked = ed_bytes(linked_key); + const auto unlinked = ed_bytes(ed_key()); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, linked_key)); + const auto digest = digest_of("envelope-1"); + + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, ChainKind::CHAIN_KIND_SVM, linked, LIQSOL, 40 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 2, ChainKind::CHAIN_KIND_SVM, unlinked, LIQSOL, 60 * UNIT)); + + const auto first = item_row(1); + const auto second = item_row(2); + BOOST_REQUIRE(!first.is_null()); + BOOST_REQUIRE(!second.is_null()); + BOOST_REQUIRE_EQUAL(KIND_SYNDICATION, first["kind"].as_string()); + BOOST_REQUIRE_EQUAL(KIND_SYNDICATION, second["kind"].as_string()); + BOOST_REQUIRE(linked == first["pubkey"].as>()); + BOOST_REQUIRE(unlinked == second["pubkey"].as>()); + BOOST_REQUIRE_EQUAL("CHAIN_KIND_SVM", first["chain_kind"].as_string()); + BOOST_REQUIRE_EQUAL(SOLANA, first["chain_code"].as_string()); + BOOST_REQUIRE_EQUAL(LIQSOL, first["token_code"].as_string()); + BOOST_REQUIRE_EQUAL(1u, first["epoch_index"].as()); + BOOST_REQUIRE_EQUAL(40 * UNIT, first["amount"].as_uint64()); + BOOST_REQUIRE_EQUAL(40 * UNIT, first["remaining"].as_uint64()); + BOOST_REQUIRE_EQUAL(60 * UNIT, second["remaining"].as_uint64()); + + // The shadow sits in sysio.synd's own row; the linked account holds none of it. + BOOST_REQUIRE_EQUAL(static_cast(100 * UNIT), liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(static_cast(100 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(0, liq_balance("alice"_n)); + + const auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE(!envelope.is_null()); + BOOST_REQUIRE_EQUAL(STATE_OPEN, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(100 * UNIT, envelope["synd_total"].as_uint64()); + BOOST_REQUIRE_EQUAL(2u, envelope["item_count"].as()); + BOOST_REQUIRE_EQUAL(digest, envelope["digest"].as()); + BOOST_REQUIRE_EQUAL(2u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + + BOOST_REQUIRE(mentions(onsynd(SOLANA, 1, digest, 3, ChainKind::CHAIN_KIND_SVM, linked, LIQSOL, UNIT, "alice"_n), + "missing authority of sysio.msgch")); +} FC_LOG_AND_RETHROW() + +// A replayed or earlier sequence is recorded and dropped: the action succeeds, nothing is held, and +// the cursor stays where it was. A later sequence, gaps included, is admitted. +BOOST_FIXTURE_TEST_CASE(onsynd_refuses_a_replayed_sequence, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 5, ChainKind::CHAIN_KIND_SVM, pubkey, LIQSOL, UNIT)); + + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 5, ChainKind::CHAIN_KIND_SVM, pubkey, LIQSOL, 2 * UNIT)); + BOOST_REQUIRE(console_has(DROP_REPLAY)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 4, ChainKind::CHAIN_KIND_SVM, pubkey, LIQSOL, 2 * UNIT)); + BOOST_REQUIRE(console_has(DROP_REPLAY)); + // LIQ_YIELD shares the sequence. + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest, 5, 9, LIQSOL, UNIT)); + BOOST_REQUIRE(console_has(DROP_YIELD_REPLAY)); + + BOOST_REQUIRE(item_row(2).is_null()); + BOOST_REQUIRE_EQUAL(static_cast(UNIT), supply()); + BOOST_REQUIRE_EQUAL(5u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + BOOST_REQUIRE_EQUAL(1u, envelope_row(SOLANA, LIQSOL, 1)["item_count"].as()); + + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 9, ChainKind::CHAIN_KIND_SVM, pubkey, LIQSOL, 2 * UNIT)); + BOOST_REQUIRE_EQUAL(2 * UNIT, item_row(2)["amount"].as_uint64()); + BOOST_REQUIRE_EQUAL(9u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + // The cursor is per outpost: another outpost starts from nothing. + BOOST_REQUIRE(cursor_row(ETH).is_null()); +} FC_LOG_AND_RETHROW() + +// Every message sysio.synd cannot hold is dropped with its reason and consumes no sequence: a liq +// token without a shadow, a token of another outpost, a pubkey that does not fit the chain family, +// a chain family that is not the outpost's, an amount out of range or past the shadow's headroom. +BOOST_FIXTURE_TEST_CASE(onsynd_drops_what_it_cannot_hold_without_consuming_a_sequence, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + const auto digest = digest_of("envelope-1"); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQNONE, UNIT)); + BOOST_REQUIRE(console_has(DROP_NO_SHADOW)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, "NOPE", UNIT)); + BOOST_REQUIRE(console_has(DROP_NO_SHADOW)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQETH, UNIT)); + BOOST_REQUIRE(console_has(DROP_OTHER_CHAIN)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, std::vector(20, 'x'), LIQSOL, UNIT)); + BOOST_REQUIRE(console_has(DROP_PUBKEY)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, ChainKind::CHAIN_KIND_EVM, em_bytes(em_key()), LIQSOL, + UNIT)); + BOOST_REQUIRE(console_has(DROP_KIND)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQSOL, 0)); + BOOST_REQUIRE(console_has(DROP_RANGE)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQSOL, DEPOT_AMOUNT_MAX + 1)); + BOOST_REQUIRE(console_has(DROP_RANGE)); + + // Past the headroom: supply is taken to 10 base units under the asset range first. + const uint64_t range = static_cast(asset::max_amount); + BOOST_REQUIRE_EQUAL(success(), liq_mint("bob"_n, LIQSOL, range - 10)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQSOL, 11)); + BOOST_REQUIRE(console_has(DROP_HEADROOM)); + + // Nothing was held and no sequence was consumed: the same sequence still admits a message. + BOOST_REQUIRE(item_row(1).is_null()); + BOOST_REQUIRE(envelope_row(SOLANA, LIQSOL, 1).is_null()); + BOOST_REQUIRE(cursor_row(SOLANA).is_null()); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQSOL, 10)); + BOOST_REQUIRE_EQUAL(10u, item_row(1)["amount"].as_uint64()); + BOOST_REQUIRE_EQUAL(1u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + BOOST_REQUIRE_EQUAL(asset::max_amount, supply()); +} FC_LOG_AND_RETHROW() + +// A message that would leave its envelope impossible to underwrite is dropped at intake, before it +// consumes a sequence: a total that, rounded up to the bond increment, passes the asset range (the +// request's covered amount could never be carried), and a yield total past the shadow's headroom. +BOOST_FIXTURE_TEST_CASE(intake_drops_what_would_make_the_envelope_unrequestable, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + const auto digest = digest_of("envelope-1"); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + constexpr uint64_t INCREMENT = 10'000'000; // 0.01 of a precision-9 token: sysio.bond's increment + const uint64_t range = static_cast(asset::max_amount); + + // The whole asset range as one yield report fits the headroom, but rounds up past the range. + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest, 1, 7, LIQSOL, range)); + BOOST_REQUIRE(console_has(DROP_YIELD_ENVELOPE_RANGE)); + BOOST_REQUIRE(envelope_row(SOLANA, LIQSOL, 1).is_null()); + BOOST_REQUIRE(cursor_row(SOLANA).is_null()); + + // The largest multiple of the increment is held; a syndication on top of it would pass the range. + const uint64_t largest = range / INCREMENT * INCREMENT; + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest, 1, 7, LIQSOL, largest)); + BOOST_REQUIRE_EQUAL(largest, envelope_row(SOLANA, LIQSOL, 1)["yield_total"].as_uint64()); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 2, SVM, pubkey, LIQSOL, UNIT)); + BOOST_REQUIRE(console_has(DROP_ENVELOPE_RANGE)); + BOOST_REQUIRE_EQUAL(0, supply()); + BOOST_REQUIRE_EQUAL(1u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + + // Another envelope: with the supply 10 UNIT under the range, 6 UNIT of yield fit and 5 more do not. + const auto digest2 = digest_of("envelope-2"); + BOOST_REQUIRE_EQUAL(success(), liq_mint("bob"_n, LIQSOL, range - 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 2, digest2, 2, 8, LIQSOL, 6 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 2, digest2, 3, 8, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE(console_has(DROP_YIELD_HEADROOM)); + BOOST_REQUIRE_EQUAL(6 * UNIT, envelope_row(SOLANA, LIQSOL, 2)["yield_total"].as_uint64()); + BOOST_REQUIRE_EQUAL(2u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + // A syndication still fits the envelope, so long as the supply has room for it. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 2, digest2, 3, SVM, pubkey, LIQSOL, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(4 * UNIT, envelope_row(SOLANA, LIQSOL, 2)["synd_total"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Intake: LIQ_YIELD +// --------------------------------------------------------------------------- + +// A yield report is held as a number: a YIELD item and the envelope's yield total grow, while the +// supply, the pending yield and sysio.synd's holder row stay where they were. +BOOST_FIXTURE_TEST_CASE(onyield_holds_the_amount_without_minting, sysio_synd_tester) try { + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest, 1, 42, LIQSOL, 7 * UNIT)); + + const auto item = item_row(1); + BOOST_REQUIRE(!item.is_null()); + BOOST_REQUIRE_EQUAL(KIND_YIELD, item["kind"].as_string()); + BOOST_REQUIRE_EQUAL("CHAIN_KIND_UNKNOWN", item["chain_kind"].as_string()); + BOOST_REQUIRE(item["pubkey"].as>().empty()); + BOOST_REQUIRE_EQUAL(7 * UNIT, item["amount"].as_uint64()); + BOOST_REQUIRE_EQUAL(7 * UNIT, item["remaining"].as_uint64()); + + BOOST_REQUIRE_EQUAL(0, supply()); + BOOST_REQUIRE_EQUAL(0, pending()); + BOOST_REQUIRE_EQUAL(0, liq_balance(SYND_ACCOUNT)); + + const auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(7 * UNIT, envelope["yield_total"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, envelope["synd_total"].as_uint64()); + const auto cursor = cursor_row(SOLANA); + BOOST_REQUIRE_EQUAL(1u, cursor["last_sequence"].as_uint64()); + BOOST_REQUIRE_EQUAL(42u, cursor["last_epoch"].as_uint64()); + + // A syndication after it keeps the outpost epoch the yield recorded. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 2, ChainKind::CHAIN_KIND_SVM, ed_bytes(ed_key()), LIQSOL, + UNIT)); + BOOST_REQUIRE_EQUAL(42u, cursor_row(SOLANA)["last_epoch"].as_uint64()); + BOOST_REQUIRE(mentions(onyield(SOLANA, 1, digest, 3, 43, LIQSOL, UNIT, "alice"_n), + "missing authority of sysio.msgch")); +} FC_LOG_AND_RETHROW() + +// A yield report holds no shadow, yet its position checkpoints the live index at intake: settled by +// mistake it would owe nothing, never the index's whole history. +BOOST_FIXTURE_TEST_CASE(a_yield_item_checkpoints_the_live_index, sysio_synd_tester) try { + BOOST_REQUIRE_EQUAL(success(), liq_mint("bob"_n, LIQSOL, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 3 * UNIT)); + BOOST_REQUIRE(liq_index() > 0); + + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest_of("envelope-1"), 1, 42, LIQSOL, 7 * UNIT)); + const auto position = item_row(1)["position"]; + BOOST_REQUIRE(position["index_checkpoint"].as_uint128() == liq_index()); + BOOST_REQUIRE_EQUAL(0u, position["owed_wire"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Closing an envelope +// --------------------------------------------------------------------------- + +// `closeenv` closes every OPEN row of the outpost's envelope, one per token, with the totals intake +// recorded -- and the queue step it runs inline asks sysio.bond to underwrite each of them at once -- +// and leaves other epochs and other outposts alone. A message arriving for a closed envelope is dropped +// without consuming its sequence. +BOOST_FIXTURE_TEST_CASE(closeenv_closes_the_envelope_and_records_totals, sysio_synd_tester) try { + const auto digest = digest_of("envelope-3"); + const auto pubkey = ed_bytes(ed_key()); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 3, digest, 1, SVM, pubkey, LIQSOL, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 3, digest, 2, SVM, pubkey, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 3, digest, 3, 17, LIQSOL, 2 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 3, digest, 4, SVM, pubkey, LIQTWO, 8 * UNIT)); + // Another epoch of the same outpost, and another outpost's envelope of the same epoch. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 4, digest_of("envelope-4"), 5, SVM, pubkey, LIQSOL, UNIT)); + BOOST_REQUIRE_EQUAL(success(), onyield(ETH, 3, digest_of("eth-3"), 1, 1, LIQETH, UNIT)); + + // A wrong digest closes nothing. + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 3, digest_of("forged"))); + BOOST_REQUIRE(console_has(DROP_DIGEST)); + BOOST_REQUIRE_EQUAL(STATE_OPEN, envelope_row(SOLANA, LIQSOL, 3)["state"].as_string()); + + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 3, digest)); + const auto liqsol = envelope_row(SOLANA, LIQSOL, 3); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, liqsol["state"].as_string()); + BOOST_REQUIRE_EQUAL(15 * UNIT, liqsol["synd_total"].as_uint64()); + BOOST_REQUIRE_EQUAL(2 * UNIT, liqsol["yield_total"].as_uint64()); + BOOST_REQUIRE_EQUAL(3u, liqsol["item_count"].as()); + BOOST_REQUIRE_EQUAL(1u, liqsol["request_id"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, liqsol["released"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, liqsol["burned"].as_uint64()); + const auto liqtwo = envelope_row(SOLANA, LIQTWO, 3); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, liqtwo["state"].as_string()); + BOOST_REQUIRE_EQUAL(2u, liqtwo["request_id"].as_uint64()); + BOOST_REQUIRE_EQUAL(8 * UNIT, liqtwo["synd_total"].as_uint64()); + BOOST_REQUIRE_EQUAL(1u, liqtwo["item_count"].as()); + BOOST_REQUIRE_EQUAL(STATE_OPEN, envelope_row(SOLANA, LIQSOL, 4)["state"].as_string()); + BOOST_REQUIRE_EQUAL(STATE_OPEN, envelope_row(ETH, LIQETH, 3)["state"].as_string()); + + // The ledger sums every envelope of the outpost and token. + const auto ledger = ledger_row(SOLANA, LIQSOL); + + // Closed: a late message for it is dropped, its sequence left for the next. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 3, digest, 6, SVM, pubkey, LIQSOL, UNIT)); + BOOST_REQUIRE(console_has(DROP_CLOSED)); + BOOST_REQUIRE_EQUAL(15 * UNIT, envelope_row(SOLANA, LIQSOL, 3)["synd_total"].as_uint64()); + BOOST_REQUIRE_EQUAL(5u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + // Closing again changes nothing: no second request. + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 3, digest)); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, 3)["state"].as_string()); + BOOST_REQUIRE_EQUAL(3u, next_bond_request_id()); + + BOOST_REQUIRE(mentions(closeenv(SOLANA, 4, digest_of("envelope-4"), "alice"_n), "missing authority of sysio.msgch")); +} FC_LOG_AND_RETHROW() + +// Review Focus 3: an envelope that brought no syndication value leaves no trace in sysio.synd. +BOOST_FIXTURE_TEST_CASE(closeenv_without_items_writes_nothing, sysio_synd_tester) try { + BOOST_REQUIRE_EQUAL(0u, synd_row_count()); + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 5, digest_of("empty"))); + BOOST_REQUIRE_EQUAL(0u, synd_row_count()); + BOOST_REQUIRE(envelope_row(SOLANA, LIQSOL, 5).is_null()); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Custody of held funds +// --------------------------------------------------------------------------- + +// A held item earns the yield its shadow earns in sysio.synd's row: its position is checkpointed at +// the live index at intake, and after a distribution it is owed exactly what the contract's row is. +BOOST_FIXTURE_TEST_CASE(held_items_accrue_yield, sysio_synd_tester) try { + // Some supply and a first distribution, so the index is not zero at intake. + BOOST_REQUIRE_EQUAL(success(), liq_mint("bob"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 10 * UNIT)); + const auto index_at_intake = liq_index(); + BOOST_REQUIRE(index_at_intake > fc::uint128_t{0}); + + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, ChainKind::CHAIN_KIND_SVM, ed_bytes(ed_key()), LIQSOL, + 100 * UNIT)); + const auto item = item_row(1); + BOOST_REQUIRE_EQUAL(index_at_intake, item["position"]["index_checkpoint"].as_uint128()); + BOOST_REQUIRE_EQUAL(0u, item["position"]["owed_wire"].as_uint64()); + BOOST_REQUIRE_EQUAL(0, item_owed(1)); + BOOST_REQUIRE_EQUAL(0, liq_owed(SYND_ACCOUNT)); + + // Half the supply is held: half of the next distribution is the item's. + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 20 * UNIT)); + BOOST_REQUIRE(liq_index() > index_at_intake); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), item_owed(1)); + BOOST_REQUIRE_EQUAL(liq_owed(SYND_ACCOUNT), item_owed(1)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(static_cast(12 * UNIT), item_owed(1)); + BOOST_REQUIRE_EQUAL(liq_owed(SYND_ACCOUNT), item_owed(1)); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// sysio.liq as the ledger +// --------------------------------------------------------------------------- + +// Supply is sysio.synd's alone to grow and shrink, and LIQ_YIELD reaches the pending balance only +// through it. +BOOST_FIXTURE_TEST_CASE(liq_mint_and_burn_require_synd, sysio_synd_tester) try { + BOOST_REQUIRE(mentions(liq_mint("alice"_n, LIQSOL, UNIT, "alice"_n), "missing authority of sysio.synd")); + BOOST_REQUIRE(mentions(liq_mint("alice"_n, LIQSOL, UNIT, MSGCH_ACCOUNT), "missing authority of sysio.synd")); + BOOST_REQUIRE(mentions(liq_burn(LIQSOL, UNIT, "alice"_n), "missing authority of sysio.synd")); + BOOST_REQUIRE(mentions(liq_mintyield(SOLANA, LIQSOL, UNIT, MSGCH_ACCOUNT), "missing authority of sysio.synd")); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code has no shadow symbol"), liq_mint("alice"_n, LIQNONE, UNIT)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("amount must be positive"), liq_mint("alice"_n, LIQSOL, 0)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("to account does not exist"), liq_mint("nobody"_n, LIQSOL, UNIT)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("supply exceeds the asset range"), + liq_mint("alice"_n, LIQSOL, static_cast(asset::max_amount) + 1)); + + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 30 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), liq_mint(SYND_ACCOUNT, LIQSOL, 20 * UNIT)); + BOOST_REQUIRE_EQUAL(static_cast(30 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(static_cast(50 * UNIT), supply()); + + // Burn settles sysio.synd's row first: what it earned before the burn stays owed. + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 5 * UNIT)); + const int64_t owed_before = liq_owed(SYND_ACCOUNT); + BOOST_REQUIRE_EQUAL(static_cast(2 * UNIT), owed_before); + BOOST_REQUIRE_EQUAL(success(), liq_burn(LIQSOL, 15 * UNIT)); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT), liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(static_cast(35 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(owed_before, liq_owed(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("overdrawn balance"), liq_burn(LIQSOL, 5 * UNIT + 1)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("amount out of range"), liq_burn(LIQSOL, 0)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code has no shadow symbol"), liq_burn(LIQNONE, UNIT)); + + // mintyield never throws: a drop is a diagnostic, a valid report lands in pending. + BOOST_REQUIRE_EQUAL(success(), liq_mintyield(ETH, LIQSOL, UNIT)); + BOOST_REQUIRE(console_has("sysio.liq::mintyield: DROP -- token_code belongs to another chain")); + BOOST_REQUIRE_EQUAL(success(), liq_mintyield(SOLANA, LIQSOL, 3 * UNIT)); + BOOST_REQUIRE_EQUAL(static_cast(3 * UNIT), pending()); + BOOST_REQUIRE_EQUAL(static_cast(35 * UNIT), supply()); +} FC_LOG_AND_RETHROW() + +// The syndication entry points and their replay guard are sysio.synd's now: sysio.liq's ABI has no +// `mintsynd`, no `park` and no cursor table, and has `mint` and `burn`. +BOOST_FIXTURE_TEST_CASE(liq_no_longer_has_mintsynd_or_park, sysio_synd_tester) try { + abi_def abi; + BOOST_REQUIRE(abi_serializer::to_abi(control->find_account_metadata(LIQ_ACCOUNT)->abi, abi)); + const auto has_action = [&](name n) { + return std::any_of(abi.actions.begin(), abi.actions.end(), [&](const auto& a) { return a.name == n; }); + }; + const auto has_table = [&](name n) { + return std::any_of(abi.tables.begin(), abi.tables.end(), [&](const auto& t) { return t.name == n.to_string(); }); + }; + BOOST_REQUIRE(!has_action("mintsynd"_n)); + BOOST_REQUIRE(!has_action("park"_n)); + BOOST_REQUIRE(!has_table("liqcursors"_n)); + BOOST_REQUIRE(has_action("mint"_n)); + BOOST_REQUIRE(has_action("burn"_n)); + BOOST_REQUIRE(has_action("mintyield"_n)); +} FC_LOG_AND_RETHROW() + +// The moved surface is sysio.synd's alone: sysio.liq's ABI has no parked hold, no sweeps, no +// desyndication, no import and no desyndication counter, and sysio.synd's has all of them. +BOOST_FIXTURE_TEST_CASE(liq_has_none_of_the_moved_actions, sysio_synd_tester) try { + const auto abi_of = [&](name account) { + abi_def abi; + BOOST_REQUIRE(abi_serializer::to_abi(control->find_account_metadata(account)->abi, abi)); + return abi; + }; + const abi_def liq_abi = abi_of(LIQ_ACCOUNT); + const abi_def synd_abi = abi_of(SYND_ACCOUNT); + const auto has_action = [](const abi_def& abi, name n) { + return std::any_of(abi.actions.begin(), abi.actions.end(), [&](const auto& a) { return a.name == n; }); + }; + const auto has_table = [](const abi_def& abi, name n) { + return std::any_of(abi.tables.begin(), abi.tables.end(), [&](const auto& t) { return t.name == n.to_string(); }); + }; + for (auto action : { "sweep"_n, "linkswept"_n, "desyndicate"_n, "importsynd"_n, "importdone"_n }) { + BOOST_TEST_CONTEXT(action.to_string()) { + BOOST_REQUIRE(!has_action(liq_abi, action)); + BOOST_REQUIRE(has_action(synd_abi, action)); + } + } + BOOST_REQUIRE(!has_table(liq_abi, "parked"_n)); + BOOST_REQUIRE(!has_table(liq_abi, "liqcounters"_n)); + BOOST_REQUIRE(has_table(synd_abi, "parked"_n)); + BOOST_REQUIRE(!has_table(synd_abi, "desyndlog"_n)); + BOOST_REQUIRE(has_table(synd_abi, "returns"_n)); + BOOST_REQUIRE(has_table(synd_abi, "syndconfig"_n)); + BOOST_REQUIRE(has_table(synd_abi, "syndstate"_n)); + BOOST_REQUIRE(has_table(synd_abi, "buckets"_n)); + BOOST_REQUIRE(has_table(synd_abi, "feepot"_n)); + for (auto action : { "setconfig"_n, "crank"_n, "dropenv"_n, "sweepyield"_n }) + BOOST_TEST_CONTEXT(action.to_string()) { BOOST_REQUIRE(has_action(synd_abi, action)); } + // The import flag left sysio.liq's config with the import. + const auto liq_config = std::find_if(liq_abi.structs.begin(), liq_abi.structs.end(), + [](const auto& st) { return st.name == "liq_config"; }); + BOOST_REQUIRE(liq_config != liq_abi.structs.end()); + BOOST_REQUIRE(std::none_of(liq_config->fields.begin(), liq_config->fields.end(), + [](const auto& f) { return f.name == "import_complete"; })); + // What stays in the ledger stays. + for (auto action : { "mint"_n, "burn"_n, "mintyield"_n, "creditowed"_n, "transfer"_n, "claim"_n, "recredit"_n, + "regliqpool"_n }) + BOOST_TEST_CONTEXT(action.to_string()) { BOOST_REQUIRE(has_action(liq_abi, action)); } +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// The parked hold +// --------------------------------------------------------------------------- + +// A parked row holds its balance in sysio.synd's own sysio.liq row and earns through its position. A +// sweep for an existing link delivers the balance with a sysio.liq transfer and credits the banked WIRE, +// out of the token's yield pool, to the account's own sysio.liq row -- exactly what the row earned, +// claimable there; `linkswept` from sysio.authex does the same and returns quietly on what it cannot +// deliver. +BOOST_FIXTURE_TEST_CASE(parked_rows_accrue_and_are_delivered_on_link, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), liq_mint("bob"_n, LIQSOL, 50 * UNIT)); + // The import parks a position whose pubkey has no link yet. + BOOST_REQUIRE_EQUAL(success(), importsynd(SOLANA, LIQSOL, { credit(pubkey, 50 * UNIT) })); + BOOST_REQUIRE_EQUAL(static_cast(100 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(static_cast(50 * UNIT), liq_balance(SYND_ACCOUNT)); + const auto parked = parked_row(LIQSOL, ChainKind::CHAIN_KIND_SVM, pubkey); + BOOST_REQUIRE(!parked.is_null()); + BOOST_REQUIRE_EQUAL(LIQSOL, parked["token_code"].as_string()); + BOOST_REQUIRE(parked["chain_kind"].as() == ChainKind::CHAIN_KIND_SVM); + BOOST_REQUIRE(pubkey == parked["pubkey"].as>()); + BOOST_REQUIRE_EQUAL(50 * UNIT, parked["balance"].as_uint64()); + + // Yield lands while the row is parked: half the supply, half the WIRE. + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT), liq_owed(SYND_ACCOUNT)); + + // Nothing to sweep before the link exists. + BOOST_REQUIRE_EQUAL(wasm_assert_msg("account has no link for this chain"), + sweep("carol"_n, ChainKind::CHAIN_KIND_SVM)); + BOOST_REQUIRE_EQUAL(success(), link_svm("carol"_n, key)); + const int64_t carol_wire = wire_balance("carol"_n); + BOOST_REQUIRE_EQUAL(success(), sweep("carol"_n, ChainKind::CHAIN_KIND_SVM)); + BOOST_REQUIRE(parked_row(LIQSOL, ChainKind::CHAIN_KIND_SVM, pubkey).is_null()); + BOOST_REQUIRE_EQUAL(static_cast(50 * UNIT), liq_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(0, liq_balance(SYND_ACCOUNT)); + // The banked WIRE, pulled from sysio.liq into the pool and taken out of it, is credited back to + // sysio.liq as carol's owed yield: nothing is pushed to her, she claims it. + BOOST_REQUIRE_EQUAL(carol_wire, wire_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT), liq_owed("carol"_n)); + BOOST_REQUIRE_EQUAL(0, liq_owed(SYND_ACCOUNT)); // the pull claimed it + BOOST_REQUIRE_EQUAL(0, wire_balance(SYND_ACCOUNT)); // nothing stranded in the contract + BOOST_REQUIRE_EQUAL(success(), liq_claim("carol"_n)); + BOOST_REQUIRE_EQUAL(carol_wire + static_cast(5 * UNIT), wire_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(0, liq_owed("carol"_n)); + BOOST_REQUIRE_EQUAL(5 * UNIT, pool_row(LIQSOL)["pool"]["received"].as_uint64()); + BOOST_REQUIRE_EQUAL(5 * UNIT, pool_row(LIQSOL)["pool"]["credited"].as_uint64()); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT), liq_owed("bob"_n)); + + // Sweeping again finds nothing. + BOOST_REQUIRE_EQUAL(success(), sweep("carol"_n, ChainKind::CHAIN_KIND_SVM)); + BOOST_REQUIRE_EQUAL(carol_wire + static_cast(5 * UNIT), wire_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(static_cast(50 * UNIT), liq_balance("carol"_n)); + + // The inline path from sysio.authex delivers the same way; nothing is owed yet, so no WIRE moves. + const auto key2 = ed_key(); + const auto pubkey2 = ed_bytes(key2); + BOOST_REQUIRE_EQUAL(success(), importsynd(SOLANA, LIQSOL, { credit(pubkey2, 20 * UNIT) })); + BOOST_REQUIRE(mentions(linkswept("dave"_n, ChainKind::CHAIN_KIND_SVM, pubkey2, "dave"_n), + "missing authority of sysio.authex")); + // What cannot be delivered returns quietly and leaves the row parked: a pubkey that does not fit + // the chain family, an account that does not exist, and sysio.synd itself. + BOOST_REQUIRE_EQUAL(success(), linkswept("dave"_n, ChainKind::CHAIN_KIND_EVM, pubkey2)); + BOOST_REQUIRE_EQUAL(success(), linkswept("nobody"_n, ChainKind::CHAIN_KIND_SVM, pubkey2)); + BOOST_REQUIRE_EQUAL(20 * UNIT, parked_balance(LIQSOL, ChainKind::CHAIN_KIND_SVM, pubkey2)); + const int64_t dave_wire = wire_balance("dave"_n); + BOOST_REQUIRE_EQUAL(success(), linkswept("dave"_n, ChainKind::CHAIN_KIND_SVM, pubkey2)); + BOOST_REQUIRE_EQUAL(static_cast(20 * UNIT), liq_balance("dave"_n)); + BOOST_REQUIRE_EQUAL(0, liq_owed("dave"_n)); + BOOST_REQUIRE_EQUAL(dave_wire, wire_balance("dave"_n)); + BOOST_REQUIRE(parked_row(LIQSOL, ChainKind::CHAIN_KIND_SVM, pubkey2).is_null()); +} FC_LOG_AND_RETHROW() + +// A user-signed `sysio.authex::createlink` sends `linkswept` to sysio.synd, which delivers the shadow +// parked against the linked EM key -- and the WIRE it earned while parked -- in the same transaction. +BOOST_FIXTURE_TEST_CASE(createlink_sweeps_parked_from_synd, sysio_synd_tester) try { + const auto priv = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); + const auto pubkey = em_bytes(priv.get_public_key()); + BOOST_REQUIRE_EQUAL(success(), importsynd(ETH, LIQETH, { credit(pubkey, 30 * UNIT) })); + BOOST_REQUIRE_EQUAL(30 * UNIT, parked_balance(LIQETH, ChainKind::CHAIN_KIND_EVM, pubkey)); + // The parked row is all of LIQETH's supply: every WIRE of this distribution is its. + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 3 * UNIT, LIQETH_SYM)); + + const int64_t alice_wire = wire_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), createlink("alice"_n, priv)); + BOOST_REQUIRE(executed(SYND_ACCOUNT, "linkswept"_n)); + BOOST_REQUIRE(parked_row(LIQETH, ChainKind::CHAIN_KIND_EVM, pubkey).is_null()); + BOOST_REQUIRE_EQUAL(static_cast(30 * UNIT), liq_balance("alice"_n, LIQETH_SYM)); + BOOST_REQUIRE_EQUAL(0, liq_balance(SYND_ACCOUNT, LIQETH_SYM)); + BOOST_REQUIRE_EQUAL(alice_wire, wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(static_cast(3 * UNIT), liq_owed("alice"_n, LIQETH_SYM)); + BOOST_REQUIRE_EQUAL(success(), liq_claim("alice"_n, LIQETH_SYM)); + BOOST_REQUIRE_EQUAL(alice_wire + static_cast(3 * UNIT), wire_balance("alice"_n)); +} FC_LOG_AND_RETHROW() + +// sysio.liq's `creditowed` is sysio.synd's alone: it moves WIRE from sysio.synd into sysio.liq and +// banks it on the holder's row, which claims it like any yield -- a row it has to create included. +BOOST_FIXTURE_TEST_CASE(liq_creditowed_banks_wire_for_the_holder_and_requires_synd, sysio_synd_tester) try { + BOOST_REQUIRE_EQUAL(success(), transfer_wire(SYSIO_ACCOUNT, SYND_ACCOUNT, 10 * UNIT)); + BOOST_REQUIRE(mentions(liq_creditowed("dave"_n, UNIT, LIQSOL_SYM, "dave"_n), "missing authority of sysio.synd")); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("amount out of range"), liq_creditowed("dave"_n, 0)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("holder account does not exist"), liq_creditowed("nobody"_n, UNIT)); + + const int64_t liq_wire = wire_balance(LIQ_ACCOUNT); + const int64_t dave_wire = wire_balance("dave"_n); + BOOST_REQUIRE_EQUAL(success(), liq_creditowed("dave"_n, 3 * UNIT)); + BOOST_REQUIRE_EQUAL(0, liq_balance("dave"_n)); + BOOST_REQUIRE_EQUAL(static_cast(3 * UNIT), liq_owed("dave"_n)); + BOOST_REQUIRE_EQUAL(static_cast(7 * UNIT), wire_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(liq_wire + static_cast(3 * UNIT), wire_balance(LIQ_ACCOUNT)); + BOOST_REQUIRE_EQUAL(dave_wire, wire_balance("dave"_n)); + + BOOST_REQUIRE_EQUAL(success(), liq_claim("dave"_n)); + BOOST_REQUIRE_EQUAL(dave_wire + static_cast(3 * UNIT), wire_balance("dave"_n)); + BOOST_REQUIRE_EQUAL(0, liq_owed("dave"_n)); + BOOST_REQUIRE_EQUAL(liq_wire, wire_balance(LIQ_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +// WIRE a parked row banked that the pool cannot cover at delivery is not lost: the row stays at balance +// 0 with it banked, and a later delivery pays it once the pool covers it. Here sysio.synd's own row is +// claimed around the pool, so the first delivery finds the pool empty. +BOOST_FIXTURE_TEST_CASE(a_parked_delivery_keeps_what_the_pool_cannot_cover, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + const auto other = ed_bytes(ed_key()); + BOOST_REQUIRE_EQUAL(success(), importsynd(SOLANA, LIQSOL, { credit(pubkey, 50 * UNIT), credit(other, 50 * UNIT) })); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), liq_claim(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(0, liq_owed(SYND_ACCOUNT)); + + BOOST_REQUIRE_EQUAL(success(), link_svm("dave"_n, key)); + BOOST_REQUIRE_EQUAL(success(), sweep("dave"_n, ChainKind::CHAIN_KIND_SVM)); + BOOST_REQUIRE_EQUAL(static_cast(50 * UNIT), liq_balance("dave"_n)); + BOOST_REQUIRE_EQUAL(0, liq_owed("dave"_n)); + const auto kept = parked_row(LIQSOL, ChainKind::CHAIN_KIND_SVM, pubkey); + BOOST_REQUIRE(!kept.is_null()); + BOOST_REQUIRE_EQUAL(0u, kept["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(5 * UNIT, kept["position"]["owed_wire"].as_uint64()); + + // Yield the pool receives later covers it: the next sweep pays it and the row goes. + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 20 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), sweep("dave"_n, ChainKind::CHAIN_KIND_SVM)); + BOOST_REQUIRE(parked_row(LIQSOL, ChainKind::CHAIN_KIND_SVM, pubkey).is_null()); + // Dave is owed 10 WIRE as a holder of half the supply, plus the 5 his parked row had banked. + BOOST_REQUIRE_EQUAL(static_cast(15 * UNIT), liq_owed("dave"_n)); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Desyndication +// --------------------------------------------------------------------------- + +// A linked holder's shadow moves to sysio.synd and is burned there; DESYNDICATE_LIQ is queued to the +// token's outpost for the holder's linked pubkey with the next request id. The holder keeps the yield +// its row earned before the burn. +BOOST_FIXTURE_TEST_CASE(desyndicate_burns_and_queues_the_attestation, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), liq_mint("bob"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 20 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg("holder is not AuthX-linked for the token's chain"), + desyndicate("alice"_n, 40 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE(mentions(desyndicate("alice"_n, 40 * UNIT, LIQSOL_SYM, "bob"_n), "missing authority of alice")); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("quantity must be positive"), desyndicate("alice"_n, 0)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("shadow symbol does not exist"), + desyndicate("alice"_n, UNIT, symbol::from_string("9,NOPE"))); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("symbol precision mismatch"), + desyndicate("alice"_n, UNIT, symbol::from_string("6,LIQSOL"))); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("overdrawn balance"), desyndicate("alice"_n, 101 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 40 * UNIT)); + + // The transfer settled the row first: the yield earned on the whole balance stays. + BOOST_REQUIRE_EQUAL(static_cast(60 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(static_cast(160 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_owed("alice"_n)); + BOOST_REQUIRE_EQUAL(0, liq_balance(SYND_ACCOUNT)); // passed through and burned + + const auto att = attestation_row(1); + BOOST_REQUIRE(!att.is_null()); + BOOST_REQUIRE_EQUAL("ATTESTATION_TYPE_DESYNDICATE_LIQ", att["type"].as_string()); + BOOST_REQUIRE_EQUAL(slug_value(SOLANA), att["chain_code"].as_uint64()); + const auto data = att["data"].as>(); + sysio::opp::attestations::DesyndicateLIQ msg; + BOOST_REQUIRE(msg.ParseFromArray(data.data(), static_cast(data.size()))); + BOOST_REQUIRE_EQUAL(slug_value(SOLANA), msg.chain_code()); + BOOST_REQUIRE(msg.user().kind() == ChainKind::CHAIN_KIND_SVM); + BOOST_REQUIRE_EQUAL(std::string(pubkey.begin(), pubkey.end()), msg.user().address()); + BOOST_REQUIRE_EQUAL(slug_value(LIQSOL), msg.amount().token_code()); + BOOST_REQUIRE_EQUAL(static_cast(40 * UNIT), msg.amount().amount()); + BOOST_REQUIRE_EQUAL(1u, msg.request_id()); + + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(3u, counters_row()["next_request_id"].as_uint64()); + BOOST_REQUIRE_EQUAL(static_cast(50 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(static_cast(150 * UNIT), supply()); +} FC_LOG_AND_RETHROW() + +/// Outstanding returns retain the original beneficiary and exact obligation, without lifetime totals. +BOOST_FIXTURE_TEST_CASE(outstanding_returns_record_the_original_obligation, sysio_synd_tester) try { + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQTWO, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_key())); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQTWO, {})); + + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 5 * UNIT, LIQTWO_SYM)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("overdrawn balance"), desyndicate("alice"_n, 1'000 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 7 * UNIT)); + + const auto check_log = [&](uint64_t id, std::string_view token, uint64_t amount) { + const auto row = return_row(id); + BOOST_TEST_CONTEXT("request " << id) { + BOOST_REQUIRE(!row.is_null()); + BOOST_REQUIRE_EQUAL(id, row["request_id"].as_uint64()); + BOOST_REQUIRE_EQUAL(SOLANA, row["chain_code"].as_string()); + BOOST_REQUIRE_EQUAL(token, row["token_code"].as_string()); + BOOST_REQUIRE_EQUAL(amount, row["amount"].as_uint64()); + BOOST_REQUIRE_EQUAL("alice", row["holder"].as_string()); + BOOST_REQUIRE_EQUAL(32u, row["pubkey"].as>().size()); + } + }; + check_log(1, LIQSOL, 10 * UNIT); + check_log(2, LIQTWO, 5 * UNIT); + check_log(3, LIQSOL, 7 * UNIT); + BOOST_REQUIRE(return_row(4).is_null()); + + BOOST_REQUIRE(ledger_row(SOLANA, LIQSOL).is_null()); + + // Only intake needs operational queue positions. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest_of("envelope-1"), 1, ChainKind::CHAIN_KIND_SVM, + ed_bytes(ed_key()), LIQSOL, 3 * UNIT)); +} FC_LOG_AND_RETHROW() + +/// Governance closes externally settled returns or refunds a definitively cancelled obligation. +/// Both consume the pending record exactly once; fees stay charged and refund destinations are immutable. +BOOST_FIXTURE_TEST_CASE(return_resolution_is_authorized_exact_and_once, sysio_synd_tester) try { + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_key())); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.desynd_fee_bps = 1000})); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 10 * UNIT)); + const auto resolve = [&](name signer, name action, uint64_t id) { + return push(SYND_ACCOUNT, synd_abi_ser, signer, action, mvo()("request_id", id)); + }; + BOOST_REQUIRE(mentions(resolve("alice"_n, "refundreturn"_n, 1), "missing authority of sysio")); + BOOST_REQUIRE(mentions(resolve("alice"_n, "finishreturn"_n, 1), "missing authority of sysio")); + BOOST_REQUIRE(!return_row(1).is_null()); + BOOST_REQUIRE_EQUAL(success(), resolve(SYSIO_ACCOUNT, "refundreturn"_n, 1)); + BOOST_REQUIRE_EQUAL(static_cast(99 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(UNIT, feepot_balance(LIQSOL)); + BOOST_REQUIRE(return_row(1).is_null()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("outstanding return not found"), resolve(SYSIO_ACCOUNT, "refundreturn"_n, 1)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("outstanding return not found"), resolve(SYSIO_ACCOUNT, "finishreturn"_n, 1)); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 10 * UNIT)); + const auto before = liq_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), resolve(SYSIO_ACCOUNT, "finishreturn"_n, 2)); + BOOST_REQUIRE_EQUAL(before, liq_balance("alice"_n)); + BOOST_REQUIRE(return_row(2).is_null()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("outstanding return not found"), resolve(SYSIO_ACCOUNT, "refundreturn"_n, 2)); + BOOST_REQUIRE_EQUAL(3u, counters_row()["next_request_id"].as_uint64()); +} FC_LOG_AND_RETHROW() + +/// A failed inline restoration leaves the outstanding record available for later recovery. +BOOST_FIXTURE_TEST_CASE(return_refund_failure_preserves_obligation, sysio_synd_tester) try { + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_key())); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), liq_mint("bob"_n, LIQSOL, static_cast(asset::max_amount))); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("supply exceeds the asset range"), + push(SYND_ACCOUNT, synd_abi_ser, SYSIO_ACCOUNT, "refundreturn"_n, mvo()("request_id", 1))); + BOOST_REQUIRE(!return_row(1).is_null()); + BOOST_REQUIRE_EQUAL(0, liq_balance("alice"_n)); +} FC_LOG_AND_RETHROW() + +/// The DESYNDICATE_LIQ attestation `id` queued, decoded. +sysio::opp::attestations::DesyndicateLIQ queued_desyndication(sysio_synd_tester& t, uint64_t id) { + const auto att = t.attestation_row(id); + BOOST_REQUIRE(!att.is_null()); + BOOST_REQUIRE_EQUAL("ATTESTATION_TYPE_DESYNDICATE_LIQ", att["type"].as_string()); + const auto data = att["data"].as>(); + sysio::opp::attestations::DesyndicateLIQ msg; + BOOST_REQUIRE(msg.ParseFromArray(data.data(), static_cast(data.size()))); + return msg; +} + +// Review Focus 6: a DESYNDICATE_LIQ carries the depot's outstanding shadow after its own burn -- the +// supply plus the yield parked in liqpending, net of the burned amount -- and `desyndlog` records the +// same total. The fee stays outstanding: it is transferred into the fee pot, not burned. The carried +// total and the headroom add up to the asset range: after the burn the ledger admits a mint of exactly +// the range net of that total, and not one base unit more. +BOOST_FIXTURE_TEST_CASE(desyndicate_carries_the_outstanding_shadow_after_its_burn, sysio_synd_tester) try { + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), liq_mintyield(SOLANA, LIQSOL, 7 * UNIT)); + BOOST_REQUIRE_EQUAL(static_cast(7 * UNIT), pending()); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_key())); + BOOST_REQUIRE_EQUAL(success(), + setconfig(SOLANA, LIQSOL, {.desynd_fee_bps = 250, .desynd_burst = 100 * UNIT})); + + // 40 leave alice: a fee of 1 stays in the pot and 39 burn, leaving 61 supply and 7 pending. + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 40 * UNIT)); + BOOST_REQUIRE_EQUAL(static_cast(61 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(static_cast(7 * UNIT), pending()); + const uint64_t first_total = 68 * UNIT; + BOOST_REQUIRE_EQUAL(first_total, queued_desyndication(*this, 1).total_syndicated()); + + // The next one carries the total after its own burn: 20 with a fee of 0.5 burns 19.5. + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 20 * UNIT)); + const uint64_t second_total = first_total - (19 * UNIT + UNIT / 2); + BOOST_REQUIRE_EQUAL(static_cast(second_total - 7 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(second_total, queued_desyndication(*this, 2).total_syndicated()); + + // Outstanding plus headroom is the asset range. + const uint64_t range = static_cast(asset::max_amount); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("supply exceeds the asset range"), + liq_mint("alice"_n, LIQSOL, range - second_total + 1)); + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, range - second_total)); + BOOST_REQUIRE_EQUAL(static_cast(range - 7 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("supply exceeds the asset range"), liq_mint("alice"_n, LIQSOL, 1)); +} FC_LOG_AND_RETHROW() + +// The fee, `quantity * desynd_fee_bps / 10000` floored, stays in `feepot` in sysio.synd's own row; only +// the net rest is burned, queued, logged and summed, and the bucket drops by the whole quantity. A fee of +// the whole quantity leaves nothing to release on the outpost and is refused. +BOOST_FIXTURE_TEST_CASE(desyndicate_charges_the_fee_and_queues_the_net_amount, sysio_synd_tester) try { + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_key())); + BOOST_REQUIRE_EQUAL(success(), + setconfig(SOLANA, LIQSOL, {.desynd_fee_bps = 250, .desynd_burst = 100 * UNIT})); + + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 40 * UNIT)); + // 2.5% of 40 is 1: the holder pays 40, the fee pot keeps 1, 39 are burned and queued to the outpost. + BOOST_REQUIRE_EQUAL(static_cast(60 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(UNIT, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(UNIT), liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(static_cast(61 * UNIT), supply()); + + const auto msg = queued_desyndication(*this, 1); + BOOST_REQUIRE_EQUAL(slug_value(LIQSOL), msg.amount().token_code()); + BOOST_REQUIRE_EQUAL(static_cast(39 * UNIT), msg.amount().amount()); + BOOST_REQUIRE_EQUAL(1u, msg.request_id()); + const auto log = return_row(1); + BOOST_REQUIRE_EQUAL(39 * UNIT, log["amount"].as_uint64()); + BOOST_REQUIRE_EQUAL(60 * UNIT, desynd_bucket_row(SOLANA, LIQSOL)["level"].as_uint64()); + // The syndication direction is untouched. + BOOST_REQUIRE(synd_bucket_row(SOLANA, LIQSOL).is_null()); + + // A second desyndication adds its fee to the same pot. + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 20 * UNIT)); + BOOST_REQUIRE_EQUAL(UNIT + UNIT / 2, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(UNIT + UNIT / 2), liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(static_cast(19 * UNIT + UNIT / 2), queued_desyndication(*this, 2).amount().amount()); + BOOST_REQUIRE_EQUAL(40 * UNIT, desynd_bucket_row(SOLANA, LIQSOL)["level"].as_uint64()); + + // The whole quantity as a fee is refused, and changes nothing. + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.desynd_fee_bps = 10000, .desynd_burst = 100 * UNIT})); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("desyndication net of the fee must be positive"), + desyndicate("alice"_n, UNIT)); + BOOST_REQUIRE_EQUAL(static_cast(40 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(3u, counters_row()["next_request_id"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// A pair with no `syndconfig` row has no desyndication budget; a quantity above the bucket's level is +// refused, however much the holder holds, and a refusal changes nothing. +BOOST_FIXTURE_TEST_CASE(desyndicate_refuses_over_budget, sysio_synd_tester) try { + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_key())); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("desyndication budget is not configured"), desyndicate("alice"_n, UNIT)); + + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.desynd_burst = 10 * UNIT, .desynd_refill = 4 * UNIT})); + BOOST_REQUIRE_EQUAL(success(), set_epoch(5)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("desyndication exceeds the current budget"), + desyndicate("alice"_n, 10 * UNIT + 1)); + BOOST_REQUIRE(desynd_bucket_row(SOLANA, LIQSOL).is_null()); + BOOST_REQUIRE_EQUAL(static_cast(100 * UNIT), liq_balance("alice"_n)); + + // A new bucket starts full: the whole burst passes, then nothing until it refills. + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 10 * UNIT)); + const auto bucket = desynd_bucket_row(SOLANA, LIQSOL); + BOOST_REQUIRE_EQUAL(0u, bucket["level"].as_uint64()); + BOOST_REQUIRE_EQUAL(5u, bucket["last_epoch"].as()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("desyndication exceeds the current budget"), desyndicate("alice"_n, 1)); + BOOST_REQUIRE_EQUAL(static_cast(90 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(2u, counters_row()["next_request_id"].as_uint64()); + BOOST_REQUIRE(return_row(2).is_null()); +} FC_LOG_AND_RETHROW() + +// The bucket refills by `desynd_refill` per depot epoch up to the burst, and drops by the whole quantity +// a desyndication moves, its fee included. +BOOST_FIXTURE_TEST_CASE(passes_after_refill, sysio_synd_tester) try { + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_key())); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.desynd_fee_bps = 1000, .desynd_burst = 10 * UNIT, + .desynd_refill = 4 * UNIT})); + BOOST_REQUIRE_EQUAL(success(), set_epoch(5)); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 10 * UNIT)); + // 9 queued, 1 kept as the fee, and the bucket emptied by all 10. + BOOST_REQUIRE_EQUAL(static_cast(9 * UNIT), queued_desyndication(*this, 1).amount().amount()); + BOOST_REQUIRE_EQUAL(0u, desynd_bucket_row(SOLANA, LIQSOL)["level"].as_uint64()); + + // One epoch later 4 have refilled. + BOOST_REQUIRE_EQUAL(success(), set_epoch(6)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("desyndication exceeds the current budget"), + desyndicate("alice"_n, 4 * UNIT + 1)); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(0u, desynd_bucket_row(SOLANA, LIQSOL)["level"].as_uint64()); + BOOST_REQUIRE_EQUAL(6u, desynd_bucket_row(SOLANA, LIQSOL)["last_epoch"].as()); + + // Three epochs refill 12, capped at the burst of 10. + BOOST_REQUIRE_EQUAL(success(), set_epoch(9)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("desyndication exceeds the current budget"), + desyndicate("alice"_n, 10 * UNIT + 1)); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(static_cast(76 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(static_cast(24 * UNIT) / 10, liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(24 * UNIT / 10, feepot_balance(LIQSOL)); +} FC_LOG_AND_RETHROW() + +// Review Focus 5: a fee that rounds down to zero keeps nothing; the whole quantity is burned, queued, +// logged and summed, and no fee pot row appears. +BOOST_FIXTURE_TEST_CASE(desyndicate_fee_rounding_to_zero, sysio_synd_tester) try { + // 333 base units at 30 bps is 0.999 of a unit: the fee floors to 0. + constexpr uint64_t quantity = 333; + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, UNIT)); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_key())); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.desynd_fee_bps = 30})); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, quantity)); + + BOOST_REQUIRE_EQUAL(static_cast(UNIT - quantity), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(static_cast(UNIT - quantity), supply()); + BOOST_REQUIRE_EQUAL(0, liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE(feepot_row(LIQSOL).is_null()); + BOOST_REQUIRE_EQUAL(static_cast(quantity), queued_desyndication(*this, 1).amount().amount()); + BOOST_REQUIRE_EQUAL(quantity, return_row(1)["amount"].as_uint64()); + + // One more base unit makes the fee 1: 334 * 30 / 10000 is 1.002, floored to 1. + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, quantity + 1)); + BOOST_REQUIRE_EQUAL(1u, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(quantity), queued_desyndication(*this, 2).amount().amount()); +} FC_LOG_AND_RETHROW() + +// Only a balance in the holder's own sysio.liq row can be desyndicated: a syndication held for the +// holder's linked pubkey and a balance parked for another pubkey sit in sysio.synd's row and cannot be +// drawn, and a refusal leaves both where they were. +BOOST_FIXTURE_TEST_CASE(desyndicate_refuses_held_or_parked_funds, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + const auto parked_pubkey = ed_bytes(ed_key()); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + BOOST_REQUIRE_EQUAL(success(), importsynd(SOLANA, LIQSOL, { credit(parked_pubkey, 20 * UNIT) })); + syndicate_and_close(1, 1, pubkey, LIQSOL, 50 * UNIT); + BOOST_REQUIRE_EQUAL(50 * UNIT, item_row(1)["remaining"].as_uint64()); + BOOST_REQUIRE_EQUAL(static_cast(70 * UNIT), liq_balance(SYND_ACCOUNT)); + + // Alice has no row of her own: nothing to desyndicate. + BOOST_REQUIRE_EQUAL(wasm_assert_msg("no balance object found"), desyndicate("alice"_n, UNIT)); + // With 5 of her own, 6 is overdrawn. + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("overdrawn balance"), desyndicate("alice"_n, 6 * UNIT)); + BOOST_REQUIRE_EQUAL(50 * UNIT, item_row(1)["remaining"].as_uint64()); + BOOST_REQUIRE_EQUAL(20 * UNIT, parked_balance(LIQSOL, ChainKind::CHAIN_KIND_SVM, parked_pubkey)); + BOOST_REQUIRE_EQUAL(static_cast(70 * UNIT), liq_balance(SYND_ACCOUNT)); + + // Her own 5 pass; the held and parked balances do not move. + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(0, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(50 * UNIT, item_row(1)["remaining"].as_uint64()); + BOOST_REQUIRE_EQUAL(20 * UNIT, parked_balance(LIQSOL, ChainKind::CHAIN_KIND_SVM, parked_pubkey)); + BOOST_REQUIRE_EQUAL(static_cast(70 * UNIT), liq_balance(SYND_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Launch ingestion +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(importsynd_parks_or_credits_and_importdone_closes, sysio_synd_tester) try { + const auto key_a = ed_key(), key_b = ed_key(), key_c = ed_key(); + const auto a = ed_bytes(key_a), b = ed_bytes(key_b), c = ed_bytes(key_c); + BOOST_REQUIRE_EQUAL(success(), link_svm("carol"_n, key_c)); + + BOOST_REQUIRE_EQUAL(success(), importsynd(SOLANA, LIQSOL, { credit(a, 30 * UNIT), credit(b, 70 * UNIT), + credit(c, 5 * UNIT) })); + BOOST_REQUIRE_EQUAL(30 * UNIT, parked_balance(LIQSOL, ChainKind::CHAIN_KIND_SVM, a)); + BOOST_REQUIRE_EQUAL(70 * UNIT, parked_balance(LIQSOL, ChainKind::CHAIN_KIND_SVM, b)); + BOOST_REQUIRE_EQUAL(static_cast(100 * UNIT), liq_balance(SYND_ACCOUNT)); // what is parked + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT), liq_balance("carol"_n)); // already linked: straight in + BOOST_REQUIRE_EQUAL(static_cast(105 * UNIT), supply()); + + // The same pubkey across batches sums; zero credits are skipped. + BOOST_REQUIRE_EQUAL(success(), importsynd(SOLANA, LIQSOL, { credit(a, 10 * UNIT), credit(b, 0) })); + BOOST_REQUIRE_EQUAL(40 * UNIT, parked_balance(LIQSOL, ChainKind::CHAIN_KIND_SVM, a)); + BOOST_REQUIRE_EQUAL(70 * UNIT, parked_balance(LIQSOL, ChainKind::CHAIN_KIND_SVM, b)); + BOOST_REQUIRE_EQUAL(static_cast(115 * UNIT), supply()); + + // Every refusal aborts the whole batch. + BOOST_REQUIRE_EQUAL(wasm_assert_msg("pubkey does not fit the chain family"), + importsynd(SOLANA, LIQSOL, { credit(std::vector(33, 'x'), UNIT) })); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code belongs to another chain"), + importsynd(ETH, LIQSOL, { credit(a, UNIT) })); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code has no shadow symbol"), + importsynd(SOLANA, LIQNONE, { credit(a, UNIT) })); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("amount out of range"), + importsynd(SOLANA, LIQSOL, { credit(a, DEPOT_AMOUNT_MAX + 1) })); + BOOST_REQUIRE(mentions(importsynd(SOLANA, LIQSOL, { credit(a, UNIT) }, "alice"_n), + "missing authority of sysio.synd")); + BOOST_REQUIRE(mentions(importdone("alice"_n), "missing authority of sysio.synd")); + BOOST_REQUIRE_EQUAL(static_cast(115 * UNIT), supply()); + + BOOST_REQUIRE_EQUAL(success(), importdone()); + BOOST_REQUIRE(state_row()["import_complete"].as_bool()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("import already finalized"), importsynd(SOLANA, LIQSOL, { credit(a, UNIT) })); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("import already finalized"), importdone()); + + // The imported positions earn from the first distribution on. + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 23 * UNIT)); // 115 supply: one WIRE per five shadow + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key_a)); + const int64_t alice_wire = wire_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), sweep("alice"_n, ChainKind::CHAIN_KIND_SVM)); + BOOST_REQUIRE_EQUAL(static_cast(40 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(static_cast(8 * UNIT), liq_owed("alice"_n)); + BOOST_REQUIRE_EQUAL(success(), liq_claim("alice"_n)); + BOOST_REQUIRE_EQUAL(alice_wire + static_cast(8 * UNIT), wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(static_cast(UNIT), liq_owed("carol"_n)); + // b's row still waits in sysio.synd's row, owed the rest of what the pool pulled. + BOOST_REQUIRE_EQUAL(static_cast(70 * UNIT), liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(22 * UNIT, pool_row(LIQSOL)["pool"]["received"].as_uint64()); + BOOST_REQUIRE_EQUAL(8 * UNIT, pool_row(LIQSOL)["pool"]["credited"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// The bootstrap tool replays the config row by row: the chains, the LIQ tokens with their bindings, +// one `create` per shadow, one `regliqpool` per pool, `importsynd` per syndication, then `importdone`. +// Replaying the checked-in dev config proves the values the validator accepts are values the +// contracts accept, and pins the accounting: each pool holds exactly its two seeds, the shadow supply +// is the LCO seed plus the parked syndications, all of which sit in sysio.synd's own row, every index +// starts at 0, the WIRE drained is the sum of the pools' WIRE seeds within the dex earmark, and the +// import closes for good. +BOOST_FIXTURE_TEST_CASE(the_dev_config_replays_into_the_shadow_liq_system, sysio_synd_config_tester) try { + const auto cfg = load_dev_config(); + BOOST_REQUIRE(!cfg.liq_pools().empty()); + BOOST_REQUIRE(!cfg.syndications().empty()); + + std::map kind_of_chain; + for (const auto& chain : cfg.chains()) { + kind_of_chain[chain.code()] = chain.kind(); + BOOST_REQUIRE_EQUAL(success(), regchain(chain.kind(), chain.code(), chain.external_chain_id())); + } + std::map shadow_of_token; + for (const auto& token : cfg.tokens()) { + if (token.kind() != TokenKind::TOKEN_KIND_LIQ) continue; // the shadow ledger binds only liq tokens + const auto chain_kind = kind_of_chain.at(token.chain_code()); + shadow_of_token.emplace(token.code(), symbol(static_cast(token.precision()), token.code())); + BOOST_REQUIRE_EQUAL(success(), regtoken(token.code(), token.precision(), chain_kind, token.chain_code(), + address_bytes(chain_kind, token.contract_address()))); + } + + const int64_t treasury_before = wire_balance(SYSIO_ACCOUNT); + uint64_t wire_drained = 0; + for (const auto& pool : cfg.liq_pools()) { + const symbol shadow = shadow_of_token.at(pool.token_code()); + const symbol pair(9, pool.pair_symbol()); + BOOST_REQUIRE_EQUAL(success(), create_shadow(shadow, pool.chain_code(), pool.token_code())); + BOOST_REQUIRE_EQUAL(success(), create_pool(pool.chain_code(), pool.token_code(), pair, + pool.initial_chain_amount(), pool.initial_wire_amount(), + static_cast(pool.fee()), + static_cast(pool.locked_shares()), + pool.conversion_horizon_sec(), pool.depth_cap_bps(), + static_cast(pool.clip_floor()))); + wire_drained += pool.initial_wire_amount(); + + const auto pool_stat = swap_pool_row(pair); + BOOST_REQUIRE_MESSAGE(!pool_stat.is_null(), "no swap pair " << pool.pair_symbol()); + BOOST_REQUIRE_EQUAL(static_cast(pool.initial_chain_amount()), + pool_stat["pool1"]["quantity"].as().get_amount()); + BOOST_REQUIRE_EQUAL(static_cast(pool.initial_wire_amount()), + pool_stat["pool2"]["quantity"].as().get_amount()); + // The index row is materialized by the first distribution; until then it reads as 0, which is + // what every seeded holder and parked position is stamped at. + BOOST_REQUIRE_EQUAL(fc::uint128_t{0}, liq_index(shadow)); + } + + // Nobody is AuthX-linked at bootstrap, so every syndication parks against its pubkey; repeated + // pubkeys sum. + std::map, uint64_t> parked_of; // (token_code, pubkey) -> amount + for (const auto& synd : cfg.syndications()) { + const auto chain_kind = kind_of_chain.at(synd.chain_code()); + BOOST_REQUIRE_EQUAL(success(), importsynd(synd.chain_code(), synd.token_code(), + { credit(address_bytes(chain_kind, synd.pubkey()), synd.amount()) })); + parked_of[{ synd.token_code(), synd.pubkey() }] += synd.amount(); + } + BOOST_REQUIRE_EQUAL(success(), importdone()); + { + const auto& first = cfg.syndications(0); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("import already finalized"), + importsynd(first.chain_code(), first.token_code(), + { credit(address_bytes(kind_of_chain.at(first.chain_code()), first.pubkey()), + first.amount()) })); + } + + for (const auto& pool : cfg.liq_pools()) { + const symbol shadow = shadow_of_token.at(pool.token_code()); + const auto chain_kind = kind_of_chain.at(pool.chain_code()); + uint64_t syndicated = 0; + for (const auto& [key, amount] : parked_of) { + if (key.first != pool.token_code()) continue; + syndicated += amount; + BOOST_REQUIRE_MESSAGE(parked_balance(pool.token_code(), chain_kind, address_bytes(chain_kind, key.second)) == + amount, + "parked row of " << key.second); + } + BOOST_REQUIRE_EQUAL(static_cast(pool.initial_chain_amount() + syndicated), supply(shadow)); + BOOST_REQUIRE_EQUAL(static_cast(syndicated), liq_balance(SYND_ACCOUNT, shadow)); + // The config's custody cross-check is the contract's truth: the outpost custodies exactly what the + // depot minted against. + if (pool.custody_total() != 0) + BOOST_REQUIRE_EQUAL(pool.custody_total(), pool.initial_chain_amount() + syndicated); + } + + BOOST_REQUIRE_EQUAL(treasury_before - static_cast(wire_drained), wire_balance(SYSIO_ACCOUNT)); + BOOST_REQUIRE_LE(wire_drained, cfg.t5_dex_allocation()); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// The queue: underwriting requests +// --------------------------------------------------------------------------- + +// Review Focus 2: an envelope that carried two tokens is closed into one sysio.bond request per token, +// each for its own statement and covering the token's syndicated plus yield total rounded UP to the bond +// increment. Bonding one of them releases it while the other still waits, and neither blocks the other. +BOOST_FIXTURE_TEST_CASE(closeenv_issues_one_request_per_token, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + const auto digest = digest_of("envelope-1"); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + constexpr uint32_t LIQTWO_WINDOW_SEC = 3600; + constexpr uint64_t INCREMENT = 10'000'000; // 0.01 token at precision 9 + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQTWO, {.window_sec = LIQTWO_WINDOW_SEC})); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQSOL, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest, 2, 7, LIQSOL, 2 * UNIT)); + // Three base units past a whole token: covered rounds up to the next increment. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 3, SVM, pubkey, LIQTWO, 5 * UNIT + 3)); + BOOST_REQUIRE_EQUAL(1u, next_bond_request_id()); + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 1, digest)); + BOOST_REQUIRE_EQUAL(3u, next_bond_request_id()); + + const auto liqsol = envelope_row(SOLANA, LIQSOL, 1); + const auto liqtwo = envelope_row(SOLANA, LIQTWO, 1); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, liqsol["state"].as_string()); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, liqtwo["state"].as_string()); + const uint64_t sol_id = liqsol["request_id"].as_uint64(); + const uint64_t two_id = liqtwo["request_id"].as_uint64(); + BOOST_REQUIRE_NE(sol_id, two_id); + + const auto sol_request = bond_request(sol_id); + BOOST_REQUIRE_EQUAL(SYND_ACCOUNT.to_string(), sol_request["issuer"].as_string()); + BOOST_REQUIRE_EQUAL(STATEMENT_SCHEMA, sol_request["schema"].as_string()); + BOOST_REQUIRE(statement_of(SOLANA, 1, digest, LIQSOL) == sol_request["statement"].as>()); + BOOST_REQUIRE_EQUAL(LIQSOL, sol_request["token_code"].as_string()); + BOOST_REQUIRE_EQUAL(12 * UNIT, sol_request["covered"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, sol_request["bounty"].as_uint64()); + BOOST_REQUIRE_EQUAL(DEFAULT_WINDOW_SEC, sol_request["window_sec"].as()); + BOOST_REQUIRE_EQUAL(REQUEST_OPEN, sol_request["state"].as_string()); + const auto two_request = bond_request(two_id); + BOOST_REQUIRE(statement_of(SOLANA, 1, digest, LIQTWO) == two_request["statement"].as>()); + BOOST_REQUIRE_EQUAL(LIQTWO, two_request["token_code"].as_string()); + BOOST_REQUIRE_EQUAL(5 * UNIT + INCREMENT, two_request["covered"].as_uint64()); + BOOST_REQUIRE_EQUAL(LIQTWO_WINDOW_SEC, two_request["window_sec"].as()); + + // LIQTWO is bonded and LIQSOL is not: LIQTWO releases, LIQSOL keeps waiting for its own request. + underwrite_envelope(SOLANA, LIQTWO, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQTWO, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT + 3), liq_balance("alice"_n, LIQTWO_SYM)); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(0, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(0, pending()); + + // Bonding LIQSOL releases it too: the syndication to the account, the yield into pending yield. + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(static_cast(2 * UNIT), pending()); + BOOST_REQUIRE_EQUAL(3u, next_bond_request_id()); +} FC_LOG_AND_RETHROW() + +// One request at a time per outpost and token, in envelope order: a later envelope stays WAITING while +// an earlier request is neither bonded nor ruled -- a request held before it was bonded included -- and +// is requested once the earlier one is bonded or ruled. A hold placed after bonding does not stop the +// queue. +BOOST_FIXTURE_TEST_CASE(second_envelope_waits_until_the_first_is_bonded, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + syndicate_and_close(2, 2, pubkey, LIQSOL, 4 * UNIT); + syndicate_and_close(3, 3, pubkey, LIQSOL, 6 * UNIT); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(1u, envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64()); + BOOST_REQUIRE_EQUAL(STATE_WAITING, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); + BOOST_REQUIRE_EQUAL(0u, envelope_row(SOLANA, LIQSOL, 2)["request_id"].as_uint64()); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_WAITING, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); + BOOST_REQUIRE_EQUAL(2u, next_bond_request_id()); + + // Held before it was bonded: the queue waits for sysio's ruling. + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, SYND_ACCOUNT, "hold"_n, mvo() + ("request_id", 1)("beneficiary", "carol"_n))); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_HELD, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(STATE_WAITING, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); + BOOST_REQUIRE_EQUAL(2u, next_bond_request_id()); + + // Ruled VALID: the first becomes RELEASABLE and the second is requested. + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, SYSIO_ACCOUNT, "rslvvalid"_n, mvo() + ("request_id", 1))); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); + BOOST_REQUIRE_EQUAL(2u, envelope_row(SOLANA, LIQSOL, 2)["request_id"].as_uint64()); + BOOST_REQUIRE(statement_of(SOLANA, 2, digest_of("envelope-2"), LIQSOL) == + bond_request(2)["statement"].as>()); + BOOST_REQUIRE_EQUAL(STATE_WAITING, envelope_row(SOLANA, LIQSOL, 3)["state"].as_string()); + + // Bonded, then held: the hold stops the second envelope's release but not the queue. + underwrite_envelope(SOLANA, LIQSOL, 2); + BOOST_REQUIRE_EQUAL(REQUEST_BONDED, bond_request(2)["state"].as_string()); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, SYND_ACCOUNT, "hold"_n, mvo() + ("request_id", 2)("beneficiary", "carol"_n))); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_HELD, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, 3)["state"].as_string()); + BOOST_REQUIRE_EQUAL(3u, envelope_row(SOLANA, LIQSOL, 3)["request_id"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// A request that is only partly bonded keeps its envelope REQUESTED. Fully bonded, the envelope is +// RELEASABLE; a VALID ruling keeps it so. +BOOST_FIXTURE_TEST_CASE(bonded_request_makes_the_envelope_releasable, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + + BOOST_REQUIRE_EQUAL(success(), underwrite("dave"_n, id, 4 * UNIT, LIQSOL)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(REQUEST_OPEN, bond_request(id)["state"].as_string()); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + + BOOST_REQUIRE_EQUAL(success(), underwrite("bob"_n, id, 6 * UNIT, LIQSOL)); + BOOST_REQUIRE_EQUAL(REQUEST_BONDED, bond_request(id)["state"].as_string()); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, SYSIO_ACCOUNT, "rslvvalid"_n, mvo() + ("request_id", id))); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); +} FC_LOG_AND_RETHROW() + +// The bounty posted on a request is the configured one, or what the fee pot holds when that is less: +// 0 while the pot is empty. sysio.bond pulls it out of sysio.synd's row, where the pot kept it. +BOOST_FIXTURE_TEST_CASE(bounty_comes_from_the_fee_pot_or_is_zero, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_fee_bps = 1000, .bounty = UNIT})); + syndicate_and_close(1, 1, pubkey, LIQSOL, 20 * UNIT); + syndicate_and_close(2, 2, pubkey, LIQSOL, 10 * UNIT); + BOOST_REQUIRE_EQUAL(0u, bond_request(1)["bounty"].as_uint64()); + + // The first release pays 2 into the pot; the second request takes its configured 1 out of it. + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(18 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); + BOOST_REQUIRE_EQUAL(UNIT, bond_request(2)["bounty"].as_uint64()); + BOOST_REQUIRE_EQUAL(UNIT, feepot_balance(LIQSOL)); + // What sysio.synd still holds: the second envelope's 10 and the pot's 1. + BOOST_REQUIRE_EQUAL(static_cast(11 * UNIT), liq_balance(SYND_ACCOUNT)); + + // Configured above what the pot holds: the bounty is the whole pot. + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_fee_bps = 1000, .bounty = 5 * UNIT})); + syndicate_and_close(3, 3, pubkey, LIQSOL, 4 * UNIT); + underwrite_envelope(SOLANA, LIQSOL, 2); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(27 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(2 * UNIT, bond_request(3)["bounty"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(4 * UNIT), liq_balance(SYND_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// The queue: release +// --------------------------------------------------------------------------- + +// A bonded syndication pays its fee into the fee pot and the rest to the linked account, and credits +// the WIRE the item earned while it was held to the account's own sysio.liq row; the item is erased, +// the envelope is DONE and the pair's queue cursor moves past it. +BOOST_FIXTURE_TEST_CASE(release_pays_the_fee_and_credits_the_account, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_fee_bps = 250})); + BOOST_REQUIRE_EQUAL(success(), liq_mint("bob"_n, LIQSOL, 40 * UNIT)); + syndicate_and_close(1, 1, pubkey, LIQSOL, 40 * UNIT); + // Yield lands while the item is held: half the supply is the item's. + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 10 * UNIT)); + const int64_t owed = item_owed(1); + BOOST_REQUIRE_GT(owed, 0); + const int64_t alice_wire = wire_balance("alice"_n); + + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(item_row(1).is_null()); + const auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(40 * UNIT, envelope["released"].as_uint64()); + // 2.5% of 40 is 1. + BOOST_REQUIRE_EQUAL(static_cast(39 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(UNIT, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(UNIT), liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(alice_wire, wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(owed, liq_owed("alice"_n)); + BOOST_REQUIRE_EQUAL(0, liq_owed(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(success(), liq_claim("alice"_n)); + BOOST_REQUIRE_EQUAL(alice_wire + owed, wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(2u, ledger_row(SOLANA, LIQSOL)["queue_epoch"].as()); + BOOST_REQUIRE(parked_row(LIQSOL, ChainKind::CHAIN_KIND_SVM, pubkey).is_null()); +} FC_LOG_AND_RETHROW() + +// A syndication whose pubkey has no link at release goes into `parked`, with the WIRE it earned while +// held; the link, when it comes, delivers both. +BOOST_FIXTURE_TEST_CASE(release_parks_an_unlinked_pubkey, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + BOOST_REQUIRE_EQUAL(success(), liq_mint("bob"_n, LIQSOL, 30 * UNIT)); + syndicate_and_close(1, 1, pubkey, LIQSOL, 30 * UNIT); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 6 * UNIT)); + const int64_t owed = item_owed(1); + BOOST_REQUIRE_GT(owed, 0); + + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(item_row(1).is_null()); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + const auto parked = parked_row(LIQSOL, ChainKind::CHAIN_KIND_SVM, pubkey); + BOOST_REQUIRE(!parked.is_null()); + BOOST_REQUIRE_EQUAL(30 * UNIT, parked["balance"].as_uint64()); + BOOST_REQUIRE_EQUAL(static_cast(owed), parked["position"]["owed_wire"].as_uint64()); + BOOST_REQUIRE_EQUAL(static_cast(30 * UNIT), liq_balance(SYND_ACCOUNT)); + + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + const int64_t alice_wire = wire_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), sweep("alice"_n, ChainKind::CHAIN_KIND_SVM)); + BOOST_REQUIRE_EQUAL(static_cast(30 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(owed, liq_owed("alice"_n)); + BOOST_REQUIRE_EQUAL(0, liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(success(), liq_claim("alice"_n)); + BOOST_REQUIRE_EQUAL(alice_wire + owed, wire_balance("alice"_n)); +} FC_LOG_AND_RETHROW() + +// Review Focus 1: the link is resolved at release, not at intake: a pubkey that gains its link while +// its syndication is held is paid to the account, and nothing is parked. +BOOST_FIXTURE_TEST_CASE(a_link_gained_while_held_releases_to_the_account, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + syndicate_and_close(1, 1, pubkey, LIQSOL, 12 * UNIT); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(12 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE(parked_row(LIQSOL, ChainKind::CHAIN_KIND_SVM, pubkey).is_null()); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(0, liq_balance(SYND_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +// Linked contracts receive spendable LIQ without invoking their rejecting transfer handler. +BOOST_FIXTURE_TEST_CASE(a_linked_contract_account_receives_without_notifications, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("bob"_n, key)); + set_code("bob"_n, contracts::util::block_transfer_wasm()); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + syndicate_and_close(1, 1, pubkey, LIQSOL, 9 * UNIT); + + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + for (const auto& trace : last_trace->action_traces) BOOST_REQUIRE(trace.receiver != "bob"_n); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(9 * UNIT, liq_balance("bob"_n)); + BOOST_REQUIRE_EQUAL(0, parked_balance(LIQSOL, ChainKind::CHAIN_KIND_SVM, pubkey)); +} FC_LOG_AND_RETHROW() + +// The syndication bucket starts full at its burst, refills by its refill per depot epoch up to the +// burst, and a tranche is the smaller of an item's remainder and the level. Nothing refills within an +// epoch. +BOOST_FIXTURE_TEST_CASE(bucket_limits_a_tranche_and_refills_per_epoch, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_burst = 10 * UNIT, .synd_refill = 4 * UNIT})); + BOOST_REQUIRE_EQUAL(success(), set_epoch(5)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, ChainKind::CHAIN_KIND_SVM, pubkey, LIQSOL, 8 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 2, ChainKind::CHAIN_KIND_SVM, pubkey, LIQSOL, 8 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 1, digest)); + underwrite_envelope(SOLANA, LIQSOL, 1); + + // A full bucket of 10: the first item whole and 2 of the second. + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE(item_row(1).is_null()); + BOOST_REQUIRE_EQUAL(6 * UNIT, item_row(2)["remaining"].as_uint64()); + auto bucket = synd_bucket_row(SOLANA, LIQSOL); + BOOST_REQUIRE_EQUAL(0u, bucket["level"].as_uint64()); + BOOST_REQUIRE_EQUAL(5u, bucket["last_epoch"].as()); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(10 * UNIT, envelope_row(SOLANA, LIQSOL, 1)["released"].as_uint64()); + + // The same epoch: nothing refills and nothing moves. + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(console_has(QUEUE_EMPTY_BUCKET)); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + + // One epoch refills 4. + BOOST_REQUIRE_EQUAL(success(), set_epoch(6)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(14 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(2 * UNIT, item_row(2)["remaining"].as_uint64()); + + // Three epochs would refill 12, capped at the burst of 10: the last 2 pass and 8 remain. + BOOST_REQUIRE_EQUAL(success(), set_epoch(9)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(16 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + bucket = synd_bucket_row(SOLANA, LIQSOL); + BOOST_REQUIRE_EQUAL(8 * UNIT, bucket["level"].as_uint64()); + BOOST_REQUIRE_EQUAL(9u, bucket["last_epoch"].as()); +} FC_LOG_AND_RETHROW() + +// An item larger than the burst never passes whole: it leaves in tranches of at most the burst, one +// bucketful per epoch, until it is gone. +BOOST_FIXTURE_TEST_CASE(oversize_item_releases_in_tranches, sysio_synd_tester) try { + constexpr uint64_t BURST = 3 * UNIT; + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_burst = BURST, .synd_refill = BURST})); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + underwrite_envelope(SOLANA, LIQSOL, 1); + + const std::vector expected{ BURST, BURST, BURST, UNIT }; + for (uint32_t epoch = 1; epoch <= expected.size(); ++epoch) { + BOOST_TEST_CONTEXT("epoch " << epoch) { + BOOST_REQUIRE_EQUAL(success(), set_epoch(epoch)); + const int64_t before = liq_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(success(), crank()); // a second step in the same epoch moves nothing + const int64_t moved = liq_balance("alice"_n) - before; + BOOST_REQUIRE_EQUAL(static_cast(expected[epoch - 1]), moved); + BOOST_REQUIRE_LE(moved, static_cast(BURST)); + } + } + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(10 * UNIT, envelope_row(SOLANA, LIQSOL, 1)["released"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// A yield report releases whole into sysio.liq's pending yield, once: no fee, and no budget -- it +// passes while the pair's syndication bucket is shut. +BOOST_FIXTURE_TEST_CASE(yield_item_mints_pending_without_fee_or_budget, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_fee_bps = 1000, .synd_burst = 0, + .synd_refill = 0})); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, ChainKind::CHAIN_KIND_SVM, pubkey, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest, 2, 3, LIQSOL, 7 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 1, digest)); + underwrite_envelope(SOLANA, LIQSOL, 1); + + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(7 * UNIT), pending()); + BOOST_REQUIRE_EQUAL(0u, feepot_balance(LIQSOL)); + BOOST_REQUIRE(item_row(2).is_null()); + // The syndication waits for a bucket that does not open. + BOOST_REQUIRE(console_has(QUEUE_EMPTY_BUCKET)); + BOOST_REQUIRE_EQUAL(5 * UNIT, item_row(1)["remaining"].as_uint64()); + const auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(7 * UNIT, envelope["released"].as_uint64()); + + // Released once: the next step mints nothing more. + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(7 * UNIT), pending()); +} FC_LOG_AND_RETHROW() + +// With no `syndconfig` row the syndication bucket is unset: the step succeeds, prints why, and releases +// nothing. A zero budget is a no-op. Once the pair is configured, the same item releases. +BOOST_FIXTURE_TEST_CASE(crank_never_throws_on_an_unset_bucket, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + underwrite_envelope(SOLANA, LIQSOL, 1); + + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(console_has(QUEUE_UNSET_BUCKET)); + BOOST_REQUIRE_EQUAL(0, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(10 * UNIT, item_row(1)["remaining"].as_uint64()); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE(synd_bucket_row(SOLANA, LIQSOL).is_null()); + BOOST_REQUIRE_EQUAL(success(), crank(0)); + + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Configuration and the yield pool +// --------------------------------------------------------------------------- + +// `setconfig` is sysio's, for an active outpost and one of its shadowed liq tokens, with fees within a +// whole, a positive window and amounts that fit an asset. +BOOST_FIXTURE_TEST_CASE(setconfig_is_sysio_only_and_validated, sysio_synd_tester) try { + const pair_config cfg{.synd_fee_bps = 30, .desynd_fee_bps = 40, .synd_burst = 7 * UNIT, .synd_refill = UNIT, + .desynd_burst = 8 * UNIT, .desynd_refill = 2 * UNIT, .window_sec = 600, + .bounty = 3 * UNIT, .challenge_extra = 4 * UNIT}; + BOOST_REQUIRE(mentions(setconfig(SOLANA, LIQSOL, cfg, "alice"_n), "missing authority of sysio")); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("chain_code is not an active outpost"), setconfig("NOPE", LIQSOL, cfg)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code has no shadow symbol"), setconfig(SOLANA, LIQNONE, cfg)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code belongs to another chain"), setconfig(SOLANA, LIQETH, cfg)); + auto bad = cfg; + bad.synd_fee_bps = 10001; + BOOST_REQUIRE_EQUAL(wasm_assert_msg("fee must be at most 10000 basis points"), setconfig(SOLANA, LIQSOL, bad)); + bad = cfg; + bad.desynd_fee_bps = 10001; + BOOST_REQUIRE_EQUAL(wasm_assert_msg("fee must be at most 10000 basis points"), setconfig(SOLANA, LIQSOL, bad)); + bad = cfg; + bad.window_sec = 0; + BOOST_REQUIRE_EQUAL(wasm_assert_msg("window_sec must be positive"), setconfig(SOLANA, LIQSOL, bad)); + bad = cfg; + bad.bounty = static_cast(asset::max_amount) + 1; + BOOST_REQUIRE_EQUAL(wasm_assert_msg("bucket, bounty and challenge amounts must fit an asset"), + setconfig(SOLANA, LIQSOL, bad)); + BOOST_REQUIRE(config_row(SOLANA, LIQSOL).is_null()); + + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, cfg)); + const auto row = config_row(SOLANA, LIQSOL); + BOOST_REQUIRE_EQUAL(30u, row["synd_fee_bps"].as()); + BOOST_REQUIRE_EQUAL(40u, row["desynd_fee_bps"].as()); + BOOST_REQUIRE_EQUAL(7 * UNIT, row["synd_burst"].as_uint64()); + BOOST_REQUIRE_EQUAL(UNIT, row["synd_refill"].as_uint64()); + BOOST_REQUIRE_EQUAL(8 * UNIT, row["desynd_burst"].as_uint64()); + BOOST_REQUIRE_EQUAL(2 * UNIT, row["desynd_refill"].as_uint64()); + BOOST_REQUIRE_EQUAL(600u, row["window_sec"].as()); + BOOST_REQUIRE_EQUAL(3 * UNIT, row["bounty"].as_uint64()); + BOOST_REQUIRE_EQUAL(4 * UNIT, row["challenge_extra"].as_uint64()); + // Another pair keeps no row. + BOOST_REQUIRE(config_row(SOLANA, LIQTWO).is_null()); +} FC_LOG_AND_RETHROW() + +// `sweepyield` pulls what sysio.liq owes sysio.synd's row into the token's pool: held items can be paid +// out of it later. Permissionless; a token with no shadow is refused. +BOOST_FIXTURE_TEST_CASE(sweepyield_pulls_what_the_contract_row_earned, sysio_synd_tester) try { + BOOST_REQUIRE_EQUAL(success(), liq_mint("bob"_n, LIQSOL, 10 * UNIT)); + syndicate_and_close(1, 1, ed_bytes(ed_key()), LIQSOL, 10 * UNIT); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 4 * UNIT)); + const int64_t owed = liq_owed(SYND_ACCOUNT); + BOOST_REQUIRE_GT(owed, 0); + + BOOST_REQUIRE_EQUAL(success(), push(SYND_ACCOUNT, synd_abi_ser, "alice"_n, "sweepyield"_n, mvo() + ("token_code", codename_mvo(LIQSOL)))); + BOOST_REQUIRE_EQUAL(static_cast(owed), pool_row(LIQSOL)["pool"]["received"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, pool_row(LIQSOL)["pool"]["credited"].as_uint64()); + BOOST_REQUIRE_EQUAL(owed, wire_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(0, liq_owed(SYND_ACCOUNT)); + // The item is still owed what it earned: the pool now covers it. + BOOST_REQUIRE_EQUAL(owed, item_owed(1)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code has no shadow symbol"), + push(SYND_ACCOUNT, synd_abi_ser, "alice"_n, "sweepyield"_n, mvo() + ("token_code", codename_mvo(LIQNONE)))); +} FC_LOG_AND_RETHROW() + +// The fee pot is protocol revenue, and so is the WIRE it earns: `sweepyield` pays `sysio` what the pot's +// position banked, out of the pool it has just pulled, and nothing is left owed to the pot. +BOOST_FIXTURE_TEST_CASE(sweepyield_pays_the_fee_pot_yield_to_sysio, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_fee_bps = 1000})); + syndicate_and_close(1, 1, pubkey, LIQSOL, 20 * UNIT); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(2 * UNIT, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(2 * UNIT), liq_balance(SYND_ACCOUNT)); + + // Supply 40 (the syndication 20 and dave's bond 20): 4 WIRE is 0.1 per unit, 0.2 for the pot's 2. + BOOST_REQUIRE_EQUAL(static_cast(40 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 4 * UNIT)); + const int64_t sysio_wire = wire_balance(SYSIO_ACCOUNT); + BOOST_REQUIRE_EQUAL(success(), push(SYND_ACCOUNT, synd_abi_ser, "alice"_n, "sweepyield"_n, mvo() + ("token_code", codename_mvo(LIQSOL)))); + BOOST_REQUIRE_EQUAL(sysio_wire + static_cast(UNIT / 5), wire_balance(SYSIO_ACCOUNT)); + BOOST_REQUIRE_EQUAL(0, wire_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(UNIT / 5, pool_row(LIQSOL)["pool"]["credited"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, feepot_row(LIQSOL)["position"]["owed_wire"].as_uint64()); + + // Nothing more is owed: a second sweep pays nothing. + BOOST_REQUIRE_EQUAL(success(), push(SYND_ACCOUNT, synd_abi_ser, "alice"_n, "sweepyield"_n, mvo() + ("token_code", codename_mvo(LIQSOL)))); + BOOST_REQUIRE_EQUAL(sysio_wire + static_cast(UNIT / 5), wire_balance(SYSIO_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +// The queue records a request's terminal outcome the first time it sees it and never reads the request +// row again, so sysio.bond may prune the row once its ruling is old enough: the pair keeps releasing. +BOOST_FIXTURE_TEST_CASE(a_pruned_request_keeps_its_pair_releasing, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + constexpr uint32_t window_sec = 3600; + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_burst = 4 * UNIT, + .synd_refill = 4 * UNIT, + .window_sec = window_sec})); + BOOST_REQUIRE_EQUAL(success(), set_epoch(1)); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(4 * UNIT), liq_balance("alice"_n)); + + // Approved, then seen by a step, which records the outcome. + produce_block(fc::seconds(window_sec + 1)); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, "carol"_n, "approve"_n, mvo()("request_id", id))); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL("APPROVED", envelope_row(SOLANA, LIQSOL, 1)["outcome"].as_string()); + + // Dave takes his bond back; once the ruling is a week old nothing keeps the request row. + BOOST_REQUIRE_EQUAL(success(), bond_claim(id, "dave"_n)); + produce_block(fc::seconds(PRUNE_RETENTION_SEC)); + BOOST_REQUIRE_EQUAL(success(), bond_prune(0, 10)); + BOOST_REQUIRE(bond_request(id).is_null()); + + // The envelope goes on releasing from the recorded outcome. + BOOST_REQUIRE_EQUAL(success(), set_epoch(2)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(8 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(success(), set_epoch(3)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); +} FC_LOG_AND_RETHROW() + +/// A DONE envelope still consumes a later ruling; a fully paid bond cannot prune that ruling before +/// targeted synchronization, even after the ordinary retention deadline. +BOOST_FIXTURE_TEST_CASE(done_envelope_durably_consumes_late_outcome, sysio_synd_tester) try { + const auto key = ed_key(); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_burst = 10 * UNIT, .synd_refill = 10 * UNIT, + .window_sec = 1})); + syndicate_and_close(1, 1, ed_bytes(key), LIQSOL, UNIT); + const auto id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + produce_block(fc::seconds(2)); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, "carol"_n, "approve"_n, mvo()("request_id", id))); + BOOST_REQUIRE_EQUAL(success(), bond_claim(id, "dave"_n)); + produce_block(fc::seconds(PRUNE_RETENTION_SEC + 1)); + BOOST_REQUIRE_EQUAL(success(), bond_prune(id, 1)); + BOOST_REQUIRE(!bond_request(id).is_null()); + BOOST_REQUIRE_EQUAL(success(), push(SYND_ACCOUNT, synd_abi_ser, "carol"_n, "syncenv"_n, mvo() + ("chain_code", codename_mvo(SOLANA))("token_code", codename_mvo(LIQSOL))("epoch_index", 1))); + BOOST_REQUIRE_EQUAL("APPROVED", envelope_row(SOLANA, LIQSOL, 1)["outcome"].as_string()); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(success(), bond_prune(id, 1)); + BOOST_REQUIRE(bond_request(id).is_null()); +} FC_LOG_AND_RETHROW() + +/// Governance may invalidate a fully released envelope without a prior challenge. Synchronization +/// must rewind settlement so the forfeit covers the released amount exactly once. +BOOST_FIXTURE_TEST_CASE(late_invalid_outcome_rewinds_done_envelope, sysio_synd_tester) try { + const auto key = ed_key(); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + syndicate_and_close(1, 1, ed_bytes(key), LIQSOL, UNIT); + const auto id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(success(), rule(id, false)); + BOOST_REQUIRE_EQUAL(success(), push(SYND_ACCOUNT, synd_abi_ser, "carol"_n, "syncenv"_n, mvo() + ("chain_code", codename_mvo(SOLANA))("token_code", codename_mvo(LIQSOL))("epoch_index", 1))); + BOOST_REQUIRE_EQUAL(1u, ledger_row(SOLANA, LIQSOL)["queue_epoch"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, envelope_row(SOLANA, LIQSOL, 1)["burned"].as_uint64()); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(UNIT, envelope_row(SOLANA, LIQSOL, 1)["burned"].as_uint64()); + BOOST_REQUIRE_EQUAL(static_cast(UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(UNIT, envelope_row(SOLANA, LIQSOL, 1)["burned"].as_uint64()); +} FC_LOG_AND_RETHROW() + +/// Fully released is not final. Pruning requires consumed finality, then retains a replay floor +/// that rejects both old and fresh sequences at erased epochs, even with a different digest. +BOOST_FIXTURE_TEST_CASE(prune_requires_finality_and_preserves_replay_floor, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + syndicate_and_close(1, 1, pubkey, LIQSOL, UNIT); + const auto id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + const auto prune = [&] { return push(SYND_ACCOUNT, synd_abi_ser, "carol"_n, "pruneenv"_n, mvo() + ("chain_code", codename_mvo(SOLANA))("token_code", codename_mvo(LIQSOL))("limit", 1)); }; + BOOST_REQUIRE_EQUAL(success(), prune()); + BOOST_REQUIRE(!envelope_row(SOLANA, LIQSOL, 1).is_null()); + BOOST_REQUIRE_EQUAL(success(), rule(id, true)); + BOOST_REQUIRE_EQUAL(success(), prune()); // final bond outcome has not been consumed yet + BOOST_REQUIRE(!envelope_row(SOLANA, LIQSOL, 1).is_null()); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(success(), prune()); + BOOST_REQUIRE(envelope_row(SOLANA, LIQSOL, 1).is_null()); + BOOST_REQUIRE_EQUAL(2u, ledger_row(SOLANA, LIQSOL)["retained_from_epoch"].as_uint64()); + const auto before = supply(); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest_of("changed"), 2, ChainKind::CHAIN_KIND_SVM, + pubkey, LIQSOL, UNIT)); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest_of("changed"), 2, 2, LIQSOL, UNIT)); + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 1, digest_of("changed"))); + BOOST_REQUIRE(envelope_row(SOLANA, LIQSOL, 1).is_null()); + BOOST_REQUIRE_EQUAL(before, supply()); + BOOST_REQUIRE_EQUAL(1u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 2, digest_of("new"), 2, ChainKind::CHAIN_KIND_SVM, + pubkey, LIQSOL, UNIT)); + BOOST_REQUIRE(!envelope_row(SOLANA, LIQSOL, 2).is_null()); +} FC_LOG_AND_RETHROW() + +/// Compaction is bounded and prefix-only; unresolved earlier envelopes protect later replay markers. +BOOST_FIXTURE_TEST_CASE(prune_stops_at_open_prefix_and_respects_limit, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + for (uint32_t n = 1; n <= 3; ++n) + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, n, digest_of(std::to_string(n)), n, + ChainKind::CHAIN_KIND_SVM, pubkey, LIQSOL, UNIT)); + BOOST_REQUIRE_EQUAL(success(), dropenv(SOLANA, LIQSOL, 2)); + BOOST_REQUIRE_EQUAL(success(), dropenv(SOLANA, LIQSOL, 3)); + const auto prune = [&](uint32_t limit) { return push(SYND_ACCOUNT, synd_abi_ser, "carol"_n, "pruneenv"_n, mvo() + ("chain_code", codename_mvo(SOLANA))("token_code", codename_mvo(LIQSOL))("limit", limit)); }; + BOOST_REQUIRE_EQUAL(success(), prune(10)); + BOOST_REQUIRE(!envelope_row(SOLANA, LIQSOL, 2).is_null()); + BOOST_REQUIRE_EQUAL(success(), dropenv(SOLANA, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(success(), prune(0)); + BOOST_REQUIRE(!envelope_row(SOLANA, LIQSOL, 1).is_null()); + BOOST_REQUIRE_EQUAL(success(), prune(1)); + BOOST_REQUIRE(envelope_row(SOLANA, LIQSOL, 1).is_null()); + BOOST_REQUIRE(!envelope_row(SOLANA, LIQSOL, 2).is_null()); + BOOST_REQUIRE_EQUAL(success(), prune(2)); + BOOST_REQUIRE(envelope_row(SOLANA, LIQSOL, 2).is_null()); + BOOST_REQUIRE(envelope_row(SOLANA, LIQSOL, 3).is_null()); + BOOST_REQUIRE_EQUAL(4u, ledger_row(SOLANA, LIQSOL)["retained_from_epoch"].as_uint64()); +} FC_LOG_AND_RETHROW() + +/// Tiny independent synchronization sweeps reach the tail of an empty-bucket backlog without a +/// larger release budget. Issuance progresses past each fully bonded request. +BOOST_FIXTURE_TEST_CASE(small_sync_sweeps_progress_past_blocked_release, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_burst = 0, .synd_refill = 0})); + for (uint32_t n = 1; n <= 20; ++n) { + syndicate_and_close(n, n, pubkey, LIQSOL, UNIT); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, n)["state"].as_string()); + underwrite_envelope(SOLANA, LIQSOL, n); + } + const auto id = envelope_row(SOLANA, LIQSOL, 20)["request_id"].as_uint64(); + BOOST_REQUIRE_EQUAL(success(), rule(id, false)); + for (unsigned n = 0; n < 21; ++n) + BOOST_REQUIRE_EQUAL(success(), push(SYND_ACCOUNT, synd_abi_ser, "carol"_n, "sync"_n, mvo()("limit", 1))); + BOOST_REQUIRE_EQUAL("INVALID", envelope_row(SOLANA, LIQSOL, 20)["outcome"].as_string()); + BOOST_REQUIRE_EQUAL(0u, envelope_row(SOLANA, LIQSOL, 20)["burned"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// A pair that can move nothing more costs the rest of the step nothing: 17 held syndications of a token +// with no syndconfig row do not starve the other token of closeenv's small inline step, which still +// requests it and, once bonded, releases it. +BOOST_FIXTURE_TEST_CASE(a_stopped_pair_leaves_the_budget_to_the_others, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQTWO, {})); + const auto digest1 = digest_of("envelope-1"); + for (uint64_t sequence = 1; sequence <= 17; ++sequence) { + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest1, sequence, ChainKind::CHAIN_KIND_SVM, pubkey, LIQSOL, + UNIT)); + } + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 1, digest1)); + underwrite_envelope(SOLANA, LIQSOL, 1); + + // Epoch 2 carries LIQTWO: closeenv's step walks LIQSOL first, finds its bucket unset and leaves it. + syndicate_and_close(2, 18, pubkey, LIQTWO, 3 * UNIT); + BOOST_REQUIRE(console_has(QUEUE_UNSET_BUCKET)); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQTWO, 2)["state"].as_string()); + + // Bonded, it is released by the next envelope's inline step, the 17 LIQSOL items still held. + underwrite_envelope(SOLANA, LIQTWO, 2); + syndicate_and_close(3, 19, pubkey, LIQSOL, UNIT); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQTWO, 2)["state"].as_string()); + BOOST_REQUIRE_EQUAL(static_cast(3 * UNIT), liq_balance("alice"_n, LIQTWO_SYM)); + BOOST_REQUIRE_EQUAL(0, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(17u, envelope_row(SOLANA, LIQSOL, 1)["item_count"].as()); + BOOST_REQUIRE(!item_row(17).is_null()); +} FC_LOG_AND_RETHROW() + +// Envelopes stuck behind an empty bucket cost closeenv's small inline step nothing: 17 RELEASABLE LIQSOL +// envelopes, one syndication each and no yield, do not stop the same step requesting a LIQTWO envelope +// and, once it is bonded, releasing it. +BOOST_FIXTURE_TEST_CASE(envelopes_behind_an_empty_bucket_leave_closeenv_its_budget, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_burst = 0, .synd_refill = 0})); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQTWO, {})); + constexpr uint32_t stuck = 17; + for (uint32_t epoch = 1; epoch <= stuck; ++epoch) { + syndicate_and_close(epoch, epoch, pubkey, LIQSOL, UNIT); + BOOST_REQUIRE_EQUAL(success(), crank()); + underwrite_envelope(SOLANA, LIQSOL, epoch); + } + BOOST_REQUIRE_EQUAL(success(), crank()); + for (uint32_t epoch = 1; epoch <= stuck; ++epoch) { + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, epoch)["state"].as_string()); + } + BOOST_REQUIRE(console_has(QUEUE_EMPTY_BUCKET)); + + // One closeenv step walks LIQSOL first and still requests the LIQTWO envelope. + syndicate_and_close(stuck + 1, stuck + 1, pubkey, LIQTWO, 3 * UNIT); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQTWO, stuck + 1)["state"].as_string()); + + // Bonded, it is released by the next envelope's inline step. + underwrite_envelope(SOLANA, LIQTWO, stuck + 1); + syndicate_and_close(stuck + 2, stuck + 2, pubkey, LIQSOL, UNIT); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQTWO, stuck + 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(static_cast(3 * UNIT), liq_balance("alice"_n, LIQTWO_SYM)); + BOOST_REQUIRE_EQUAL(0, liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, stuck)["state"].as_string()); + + // A step whose budget runs out on LIQSOL, which moves nothing, leaves the next step to start past it. + BOOST_REQUIRE_EQUAL(success(), crank(1)); + BOOST_REQUIRE_EQUAL(LIQTWO_SYM.to_symbol_code().value, state_row()["queue_cursor"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// The examination cap bounds a step whatever the pairs hold: six pairs (LIQA-LIQE, LIQSOL), each with a +// A blocked release pass retains its examination cap and cursor. Underwriting has an independent +// budget and reaches a later pair immediately, even when release exhausts its own budget first. +BOOST_FIXTURE_TEST_CASE(the_examination_cap_bounds_a_step_over_many_blocked_pairs, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + // In queue order: the four-letter shadows sort before every six-letter one, LIQTWO last. + const std::vector blocked{"LIQA", "LIQB", "LIQC", "LIQD", "LIQE", std::string(LIQSOL)}; + constexpr uint32_t items = 16; + uint64_t sequence = 0; + uint32_t epoch = 0; + for (size_t i = 0; i < blocked.size(); ++i) { + const std::string& code = blocked[i]; + if (code != LIQSOL) { + BOOST_REQUIRE_EQUAL(success(), regtoken(code, 9, ChainKind::CHAIN_KIND_SVM, SOLANA, + std::vector(32, char(0x60 + i)))); + BOOST_REQUIRE_EQUAL(success(), create_shadow(symbol::from_string("9," + code), SOLANA, code)); + } + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, code, {.synd_burst = 0, .synd_refill = 0})); + ++epoch; + const auto digest = digest_of("envelope-" + std::to_string(epoch)); + for (uint32_t item = 0; item < items; ++item) { + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, epoch, digest, ++sequence, ChainKind::CHAIN_KIND_SVM, pubkey, + code, UNIT)); + } + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, epoch, digest)); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, code, epoch)["state"].as_string()); + } + // Bonded only now: while requested, a pair costs the setup steps one examination, so none ran out + // and the cursor was never written: steps start at the first pair. + for (uint32_t e = 1; e <= epoch; ++e) underwrite_envelope(SOLANA, blocked[e - 1], e); + BOOST_REQUIRE(state_row().is_null()); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQTWO, {})); + + // Release stops in LIQD, while the independent issuance pass reaches LIQTWO. + const uint32_t two = ++epoch; + syndicate_and_close(two, ++sequence, pubkey, LIQTWO, 3 * UNIT); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQTWO, two)["state"].as_string()); + BOOST_REQUIRE_EQUAL(symbol::from_string("9,LIQE").to_symbol_code().value, + state_row()["queue_cursor"].as_uint64()); + underwrite_envelope(SOLANA, LIQTWO, two); + // Repeated bounded steps eventually deliver that pair without draining any empty-bucket pair. + for (unsigned step = 0; step < 8 && + envelope_row(SOLANA, LIQTWO, two)["state"].as_string() != STATE_DONE; ++step) + BOOST_REQUIRE_EQUAL(success(), crank(16)); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQTWO, two)["state"].as_string()); + BOOST_REQUIRE_EQUAL(static_cast(3 * UNIT), liq_balance("alice"_n, LIQTWO_SYM)); + for (uint32_t e = 1; e <= blocked.size(); ++e) { + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, blocked[e - 1], e)["state"].as_string()); + BOOST_REQUIRE_EQUAL(UNIT, item_row((e - 1) * items + 1)["remaining"].as_uint64()); + } +} FC_LOG_AND_RETHROW() + +// Idle pairs cost the step too: with nothing admitted for seven shadows ahead of it (LIQA-LIQE, LIQETH and +// LIQSOL), a crank(1) -- budget 1, cap 4 -- pays one entry look per pair. From the start of the queue it +// ends on its cap at LIQD without reaching the bonded LIQTWO envelope; the next ends on LIQTWO's own entry +// look (LIQE, LIQETH, LIQSOL, LIQTWO) before it can look at the envelope, so the cursor stays on LIQTWO; +// the third starts there and releases it (entry, envelope, item: three looks and the one unit). +BOOST_FIXTURE_TEST_CASE(a_step_over_many_idle_pairs_stops_on_the_cap, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + const std::vector idle{"LIQA", "LIQB", "LIQC", "LIQD", "LIQE"}; + for (size_t i = 0; i < idle.size(); ++i) { + BOOST_REQUIRE_EQUAL(success(), regtoken(idle[i], 9, ChainKind::CHAIN_KIND_SVM, SOLANA, + std::vector(32, char(0x60 + i)))); + BOOST_REQUIRE_EQUAL(success(), create_shadow(symbol::from_string("9," + idle[i]), SOLANA, idle[i])); + } + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQTWO, {})); + syndicate_and_close(1, 1, pubkey, LIQTWO, 3 * UNIT); + underwrite_envelope(SOLANA, LIQTWO, 1); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQTWO, 1)["state"].as_string()); + BOOST_REQUIRE(state_row().is_null()); + + BOOST_REQUIRE_EQUAL(success(), crank(1)); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQTWO, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(symbol::from_string("9,LIQE").to_symbol_code().value, + state_row()["queue_cursor"].as_uint64()); + + BOOST_REQUIRE_EQUAL(success(), crank(1)); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQTWO, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(LIQTWO_SYM.to_symbol_code().value, state_row()["queue_cursor"].as_uint64()); + + BOOST_REQUIRE_EQUAL(success(), crank(1)); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQTWO, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(static_cast(3 * UNIT), liq_balance("alice"_n, LIQTWO_SYM)); +} FC_LOG_AND_RETHROW() + +// A pair both of whose flows have stopped -- its syndications behind an empty bucket, its yield behind a +// full headroom -- still has a later envelope requested and its request's outcome recorded, so a prune of +// the request cannot strand the envelope. +BOOST_FIXTURE_TEST_CASE(a_pair_with_both_flows_stopped_records_a_later_outcome, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + constexpr uint32_t window_sec = 3600; + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_burst = 0, .synd_refill = 0, + .window_sec = window_sec})); + // Envelope 1: a syndication the empty bucket holds. + syndicate_and_close(1, 1, pubkey, LIQSOL, UNIT); + underwrite_envelope(SOLANA, LIQSOL, 1); + // Envelope 2: a yield report, requested past envelope 1 and bonded. + const auto digest2 = digest_of("envelope-2"); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 2, digest2, 2, 2, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 2, digest2)); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); + underwrite_envelope(SOLANA, LIQSOL, 2); + + // The supply is taken to 4 UNIT under the range: once envelope 3 mints one more, the 5 of yield no + // longer fit, and both flows of the pair stop at envelopes 1 and 2. + const uint64_t range = static_cast(asset::max_amount); + BOOST_REQUIRE_EQUAL(success(), liq_mint(SYND_ACCOUNT, LIQSOL, range - static_cast(supply()) - 4 * UNIT)); + syndicate_and_close(3, 3, pubkey, LIQSOL, UNIT); + BOOST_REQUIRE(console_has(QUEUE_EMPTY_BUCKET)); + BOOST_REQUIRE(console_has(QUEUE_YIELD_HEADROOM)); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, 3)["state"].as_string()); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 3)["request_id"].as_uint64(); + underwrite_envelope(SOLANA, LIQSOL, 3); + + // Approved; a step with both flows still stopped records the outcome. + produce_block(fc::seconds(window_sec + 1)); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, "carol"_n, "approve"_n, mvo()("request_id", id))); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(console_has(QUEUE_EMPTY_BUCKET)); + BOOST_REQUIRE(console_has(QUEUE_YIELD_HEADROOM)); + BOOST_REQUIRE_EQUAL("APPROVED", envelope_row(SOLANA, LIQSOL, 3)["outcome"].as_string()); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, 3)["state"].as_string()); + + // Pruned a week later, the request no longer holds the envelope: it stays RELEASABLE on its record. + BOOST_REQUIRE_EQUAL(success(), bond_claim(id, "dave"_n)); + produce_block(fc::seconds(PRUNE_RETENTION_SEC)); + BOOST_REQUIRE_EQUAL(success(), bond_prune(0, 10)); + BOOST_REQUIRE(bond_request(id).is_null()); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, 3)["state"].as_string()); + BOOST_REQUIRE_EQUAL(0, liq_balance("alice"_n)); +} FC_LOG_AND_RETHROW() + +// A HELD envelope whose request was fully bonded holds only itself: a later envelope of the pair is +// requested and released past it, and the pair's queue cursor stays on the held one. +BOOST_FIXTURE_TEST_CASE(a_held_envelope_is_skipped_while_a_later_one_releases, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + const uint64_t first = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + underwrite_envelope(SOLANA, LIQSOL, 1); + // Held by its issuer before any step saw the bond. + BOOST_REQUIRE_EQUAL(success(), bond_hold(first, "carol"_n)); + BOOST_REQUIRE_EQUAL(REQUEST_HELD, bond_request(first)["state"].as_string()); + + syndicate_and_close(2, 2, pubkey, LIQSOL, 4 * UNIT); + BOOST_REQUIRE_EQUAL(STATE_HELD, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); + underwrite_envelope(SOLANA, LIQSOL, 2); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_HELD, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); + BOOST_REQUIRE_EQUAL(static_cast(4 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(10 * UNIT, item_row(1)["remaining"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, ledger_row(SOLANA, LIQSOL)["queue_epoch"].as()); +} FC_LOG_AND_RETHROW() + +// A yield release the shadow's headroom cannot take waits, and a later step releases it once the headroom +// has room again. +BOOST_FIXTURE_TEST_CASE(a_yield_release_waits_for_headroom_and_retries, sysio_synd_tester) try { + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest, 1, 3, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 1, digest)); + underwrite_envelope(SOLANA, LIQSOL, 1); + + // The supply is taken to 4 UNIT under the range: the 5 of yield no longer fit. + const uint64_t range = static_cast(asset::max_amount); + BOOST_REQUIRE_EQUAL(success(), liq_mint(SYND_ACCOUNT, LIQSOL, range - static_cast(supply()) - 4 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(console_has(QUEUE_YIELD_HEADROOM)); + BOOST_REQUIRE_EQUAL(0, pending()); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(5 * UNIT, item_row(1)["remaining"].as_uint64()); + + // One more unit of room: the next step releases it whole. + BOOST_REQUIRE_EQUAL(success(), liq_burn(LIQSOL, UNIT)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT), pending()); + BOOST_REQUIRE(item_row(1).is_null()); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); +} FC_LOG_AND_RETHROW() + +// `dropenv` is sysio's escape hatch for an envelope with no request: an OPEN or WAITING one has its held +// syndications burned and its yield reports dropped, is INVALID, and the queue moves past it. A requested +// envelope, a missing one and another signer are refused. +BOOST_FIXTURE_TEST_CASE(dropenv_burns_an_unrequested_envelope_and_the_queue_moves_on, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + // Epoch 1 is requested; epoch 2 waits behind it; epoch 3 is still OPEN. + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + const auto digest2 = digest_of("envelope-2"); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 2, digest2, 2, SVM, pubkey, LIQSOL, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 2, digest2, 3, 7, LIQSOL, 2 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 2, digest2)); + const auto digest3 = digest_of("envelope-3"); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 3, digest3, 4, SVM, pubkey, LIQSOL, 3 * UNIT)); + BOOST_REQUIRE_EQUAL(STATE_WAITING, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); + BOOST_REQUIRE_EQUAL(static_cast(17 * UNIT), supply()); + + BOOST_REQUIRE(mentions(dropenv(SOLANA, LIQSOL, 2, "carol"_n), "missing authority of sysio")); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(std::string(DROPENV_STATE)), dropenv(SOLANA, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("envelope not found"), dropenv(SOLANA, LIQSOL, 9)); + + BOOST_REQUIRE_EQUAL(success(), dropenv(SOLANA, LIQSOL, 2)); + auto envelope = envelope_row(SOLANA, LIQSOL, 2); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(4 * UNIT, envelope["burned"].as_uint64()); + BOOST_REQUIRE(item_row(2).is_null()); + BOOST_REQUIRE(item_row(3).is_null()); + BOOST_REQUIRE_EQUAL(static_cast(13 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(static_cast(13 * UNIT), liq_balance(SYND_ACCOUNT)); + + BOOST_REQUIRE_EQUAL(success(), dropenv(SOLANA, LIQSOL, 3)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope_row(SOLANA, LIQSOL, 3)["state"].as_string()); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), supply()); + // A late message of the dropped OPEN envelope is dropped too. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 3, digest3, 5, SVM, pubkey, LIQSOL, UNIT)); + BOOST_REQUIRE(console_has(DROP_CLOSED)); + + // Once epoch 1 is released the queue moves past both dropped envelopes. + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(4u, ledger_row(SOLANA, LIQSOL)["queue_epoch"].as()); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Challenges and the invalid envelope +// --------------------------------------------------------------------------- + +// A challenge charges the challenger the request's hold bond plus the pair's extra: the extra stays in +// the fee pot, sysio.bond pulls the hold bond back out of sysio.synd as the issuer's hold naming the +// challenger, and the envelope is HELD. It is the challenger's own action, and one per request. +BOOST_FIXTURE_TEST_CASE(challenge_charges_bond_plus_extra_and_holds, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.challenge_extra = 2 * UNIT})); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQSOL, 5 * UNIT)); + + BOOST_REQUIRE(mentions(challenge("carol"_n, SOLANA, LIQSOL, 1, "bob"_n), "missing authority of carol")); + + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + const uint64_t hold_bond = 10 * UNIT * HOLD_BPS / BPS_DENOMINATOR; + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT - hold_bond - 2 * UNIT), liq_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(2 * UNIT, feepot_balance(LIQSOL)); + // sysio.synd keeps the held syndication and the extra; sysio.bond holds the hold bond. + BOOST_REQUIRE_EQUAL(static_cast(12 * UNIT), liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(static_cast(hold_bond), liq_balance(BOND_ACCOUNT)); + const auto req = bond_request(id); + BOOST_REQUIRE_EQUAL(REQUEST_HELD, req["state"].as_string()); + BOOST_REQUIRE_EQUAL(hold_bond, req["hold_bond"].as_uint64()); + BOOST_REQUIRE_EQUAL("carol", req["hold_beneficiary"].as_string()); + BOOST_REQUIRE_EQUAL(STATE_HELD, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + + // One challenge per request: a second is refused and charges nothing. + BOOST_REQUIRE(mentions(challenge("carol"_n, SOLANA, LIQSOL, 1), CHALLENGE_TWICE)); + BOOST_REQUIRE_EQUAL(static_cast(2 * UNIT), liq_balance("carol"_n)); + + // The step keeps the envelope HELD and nothing is released. + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_HELD, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(10 * UNIT, item_row(1)["remaining"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// Review Focus 4: a request that is APPROVED can no longer be challenged, even while its envelope is +// still RELEASABLE (here its bucket is shut) or not yet refreshed; nothing is charged. +BOOST_FIXTURE_TEST_CASE(challenge_refused_on_an_approved_request, sysio_synd_tester) try { + constexpr uint32_t WINDOW_SEC = 1; + const auto pubkey = ed_bytes(ed_key()); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_burst = 0, .synd_refill = 0, + .window_sec = WINDOW_SEC})); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQSOL, 5 * UNIT)); + produce_block(fc::seconds(WINDOW_SEC + 1)); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, "carol"_n, "approve"_n, mvo()("request_id", id))); + BOOST_REQUIRE_EQUAL(REQUEST_APPROVED, bond_request(id)["state"].as_string()); + + // Not yet refreshed: the envelope is REQUESTED, the request APPROVED. + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE(mentions(challenge("carol"_n, SOLANA, LIQSOL, 1), CHALLENGE_FINAL)); + + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE(mentions(challenge("carol"_n, SOLANA, LIQSOL, 1), CHALLENGE_FINAL)); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT), liq_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(REQUEST_APPROVED, bond_request(id)["state"].as_string()); +} FC_LOG_AND_RETHROW() + +// Review Focus 4: an envelope with no request issued yet (WAITING behind an unbonded one), or with no +// row at all, cannot be challenged; nor can sysio.synd, sysio.bond or an account with contract code +// challenge. +BOOST_FIXTURE_TEST_CASE(challenge_refused_on_an_unrequested_envelope, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + syndicate_and_close(2, 2, pubkey, LIQSOL, 4 * UNIT); + BOOST_REQUIRE_EQUAL(STATE_WAITING, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQSOL, 5 * UNIT)); + + BOOST_REQUIRE(mentions(challenge("carol"_n, SOLANA, LIQSOL, 2), CHALLENGE_STATE)); + BOOST_REQUIRE(mentions(challenge("carol"_n, SOLANA, LIQSOL, 9), CHALLENGE_NO_ROW)); + BOOST_REQUIRE(mentions(challenge(SYND_ACCOUNT, SOLANA, LIQSOL, 1), CHALLENGE_ROLE)); + BOOST_REQUIRE_EQUAL(success(), liq_mint("bob"_n, LIQSOL, 5 * UNIT)); + set_code("bob"_n, contracts::util::block_transfer_wasm()); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), challenge("bob"_n, SOLANA, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT), liq_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(REQUEST_HELD, bond_request(1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(0u, feepot_balance(LIQSOL)); +} FC_LOG_AND_RETHROW() + +/// Half-released envelope: alice is linked, the bucket passes 4 per epoch, 10 are syndicated at epoch 1, +/// bonded, and one step releases 4. Carol holds 5 to challenge with. +struct sysio_synd_released_tester : sysio_synd_tester { + sysio_synd_released_tester() { + const auto key = ed_key(); + pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_burst = 4 * UNIT, .synd_refill = 4 * UNIT})); + BOOST_REQUIRE_EQUAL(success(), set_epoch(1)); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(4 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQSOL, 5 * UNIT)); + } + std::vector pubkey; + uint64_t id = 0; +}; + +// Review Focus 4: a RELEASABLE envelope whose request is still BONDED can be challenged after part of it +// was released; the challenge stops the rest, even with the bucket refilled. +BOOST_FIXTURE_TEST_CASE(a_challenge_after_partial_release_stops_further_release, sysio_synd_released_tester) try { + BOOST_REQUIRE_EQUAL(REQUEST_BONDED, bond_request(id)["state"].as_string()); + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(STATE_HELD, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + + BOOST_REQUIRE_EQUAL(success(), set_epoch(2)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(4 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(6 * UNIT, item_row(1)["remaining"].as_uint64()); + const auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_HELD, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(4 * UNIT, envelope["released"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// A VALID ruling on a challenged, fully bonded request makes the envelope RELEASABLE again: release +// resumes where it stopped. Every bond covered the hold bond, so nothing is forwarded to the challenger +// and nothing is claimed. +BOOST_FIXTURE_TEST_CASE(valid_ruling_resumes_release, sysio_synd_released_tester) try { + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + const int64_t carol = liq_balance("carol"_n); + BOOST_REQUIRE_EQUAL(success(), rule(id, true)); + + BOOST_REQUIRE_EQUAL(success(), set_epoch(2)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(!executed(BOND_ACCOUNT, "claim"_n)); + BOOST_REQUIRE_EQUAL(static_cast(8 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + + BOOST_REQUIRE_EQUAL(success(), set_epoch(3)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(carol, liq_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(0, liq_balance(SYND_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +// INVALID after 4 of 10 were released: the 6 still held are burned, the forfeit of 10 is pulled from +// sysio.bond, the 4 released are burned out of it and the other 6 go to the fee pot. Supply is whole: +// what alice kept, the challenger's remainder, the hold bond in sysio.bond and the pot. The hold bond +// is the challenger's to claim. +BOOST_FIXTURE_TEST_CASE(invalid_burns_held_items_and_the_released_part_of_the_forfeit, + sysio_synd_released_tester) try { + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + const uint64_t hold_bond = 10 * UNIT * HOLD_BPS / BPS_DENOMINATOR; + // Minted: the syndication 10, dave's bond 10 and carol's 5. + BOOST_REQUIRE_EQUAL(static_cast(25 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(success(), rule(id, false)); + + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(executed(BOND_ACCOUNT, "claim"_n)); + BOOST_REQUIRE(console_has(QUEUE_INVALID_BURNED)); + BOOST_REQUIRE(item_row(1).is_null()); + const auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(4 * UNIT, envelope["released"].as_uint64()); + BOOST_REQUIRE_EQUAL(10 * UNIT, envelope["burned"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, bond_request(id)["forfeit_pending"].as_uint64()); + BOOST_REQUIRE_EQUAL(6 * UNIT, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(6 * UNIT), liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(static_cast(4 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(static_cast(15 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(supply(), liq_balance("alice"_n) + liq_balance("carol"_n) + liq_balance(BOND_ACCOUNT) + + liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(2u, ledger_row(SOLANA, LIQSOL)["queue_epoch"].as()); + + // The challenger takes the hold bond back from sysio.bond. + BOOST_REQUIRE_EQUAL(success(), bond_claim(id, "carol"_n)); + BOOST_REQUIRE_EQUAL(static_cast(4 * UNIT + hold_bond), liq_balance("carol"_n)); + + // Finished: the next step touches nothing. + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(!executed(BOND_ACCOUNT, "claim"_n)); + BOOST_REQUIRE_EQUAL(6 * UNIT, feepot_balance(LIQSOL)); +} FC_LOG_AND_RETHROW() + +// INVALID before anything was released: the held syndication is burned, the yield report is dropped +// without a mint, and the whole forfeit -- the syndication and the yield it covered -- goes to the fee +// pot, none of it burned. +BOOST_FIXTURE_TEST_CASE(invalid_with_nothing_released_burns_only_held_items_and_pots_the_forfeit, + sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, ChainKind::CHAIN_KIND_SVM, pubkey, LIQSOL, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest, 2, 3, LIQSOL, 2 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 1, digest)); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(REQUEST_BONDED, bond_request(id)["state"].as_string()); + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQSOL, 5 * UNIT)); + + // Challenged before any step saw the bond: the envelope is still REQUESTED. + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(success(), rule(id, false)); + // Minted: the syndication 10, dave's bond 12 and carol's 5. + BOOST_REQUIRE_EQUAL(static_cast(27 * UNIT), supply()); + + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(item_row(1).is_null()); + BOOST_REQUIRE(item_row(2).is_null()); + BOOST_REQUIRE_EQUAL(0, pending()); + const auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(0u, envelope["released"].as_uint64()); + BOOST_REQUIRE_EQUAL(10 * UNIT, envelope["burned"].as_uint64()); + BOOST_REQUIRE_EQUAL(12 * UNIT, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(12 * UNIT), liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(static_cast(17 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(supply(), liq_balance("carol"_n) + liq_balance(BOND_ACCOUNT) + liq_balance(SYND_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +// A request ruled INVALID before anyone bonded it, with no hold and no bounty, owes sysio.synd nothing: +// no claim is sent (it would throw), the held items are burned, and the pair's queue moves on to the +// next envelope, which is requested at once. +BOOST_FIXTURE_TEST_CASE(invalid_unbonded_request_burns_and_moves_on, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + syndicate_and_close(2, 2, pubkey, LIQSOL, 4 * UNIT); + BOOST_REQUIRE_EQUAL(STATE_WAITING, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); + BOOST_REQUIRE_EQUAL(success(), rule(1, false)); + + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(!executed(BOND_ACCOUNT, "claim"_n)); + BOOST_REQUIRE(item_row(1).is_null()); + const auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(10 * UNIT, envelope["burned"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(4 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(static_cast(4 * UNIT), liq_balance(SYND_ACCOUNT)); + + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); + BOOST_REQUIRE_EQUAL(2u, envelope_row(SOLANA, LIQSOL, 2)["request_id"].as_uint64()); + BOOST_REQUIRE_EQUAL(2u, ledger_row(SOLANA, LIQSOL)["queue_epoch"].as()); +} FC_LOG_AND_RETHROW() + +// The burn of an INVALID envelope spends the step's budget item by item: a step too small to finish it +// keeps the pair waiting, and the forfeit is pulled only by the step that erases the last item. +BOOST_FIXTURE_TEST_CASE(invalid_burn_spans_steps_within_the_budget, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + const auto digest = digest_of("envelope-1"); + for (uint64_t sequence = 1; sequence <= 3; ++sequence) { + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, sequence, ChainKind::CHAIN_KIND_SVM, pubkey, LIQSOL, + 2 * UNIT)); + } + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 1, digest)); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), rule(1, false)); + + // One unit, for the first item: refreshing the envelope is free. + BOOST_REQUIRE_EQUAL(success(), crank(1)); + BOOST_REQUIRE(console_has(QUEUE_INVALID_BURNING)); + BOOST_REQUIRE(!executed(BOND_ACCOUNT, "claim"_n)); + BOOST_REQUIRE(item_row(1).is_null()); + BOOST_REQUIRE(!item_row(2).is_null()); + auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(2 * UNIT, envelope["burned"].as_uint64()); + // The ruling clears issuance even while the independent release pass still has items to burn. + BOOST_REQUIRE_EQUAL(2u, ledger_row(SOLANA, LIQSOL)["underwriting_epoch"].as_uint64()); + + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(executed(BOND_ACCOUNT, "claim"_n)); + envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(6 * UNIT, envelope["burned"].as_uint64()); + BOOST_REQUIRE_EQUAL(6 * UNIT, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(6 * UNIT), supply()); +} FC_LOG_AND_RETHROW() + +// INVALID with no hold returns the bounty to the issuer: sysio.synd claims it with the forfeit and puts +// it back into the fee pot it came from. +BOOST_FIXTURE_TEST_CASE(invalid_without_a_hold_returns_the_bounty_to_the_fee_pot, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_fee_bps = 1000, .bounty = UNIT})); + syndicate_and_close(1, 1, pubkey, LIQSOL, 20 * UNIT); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(2 * UNIT, feepot_balance(LIQSOL)); + syndicate_and_close(2, 2, pubkey, LIQSOL, 10 * UNIT); + BOOST_REQUIRE_EQUAL(UNIT, bond_request(2)["bounty"].as_uint64()); + BOOST_REQUIRE_EQUAL(UNIT, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(success(), rule(2, false)); + + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(executed(BOND_ACCOUNT, "claim"_n)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); + BOOST_REQUIRE_EQUAL(2 * UNIT, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(2 * UNIT), liq_balance(SYND_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +// VALID after a challenge of a partly bonded request: sysio.bond awards the issuer the share of the hold +// bond no bond covered; sysio.synd claims it and forwards it to the challenger, who paid it, once. The +// envelope then releases. +BOOST_FIXTURE_TEST_CASE(valid_after_challenge_forwards_the_unbonded_hold_share, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + BOOST_REQUIRE_EQUAL(success(), underwrite("dave"_n, id, 4 * UNIT, LIQSOL)); + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + const uint64_t hold_bond = 10 * UNIT * HOLD_BPS / BPS_DENOMINATOR; + const uint64_t share = hold_bond * 6 / 10; // the 6 of 10 no bond covered + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT - hold_bond), liq_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(success(), rule(id, true)); + + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(executed(BOND_ACCOUNT, "claim"_n)); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT - hold_bond + share), liq_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(0, liq_balance(SYND_ACCOUNT)); + + // Forwarded once; dave takes his bond and his stake's share of the hold bond. + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT - hold_bond + share), liq_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(success(), bond_claim(id, "dave"_n)); + BOOST_REQUIRE_EQUAL(static_cast(4 * UNIT + hold_bond - share), liq_balance("dave"_n)); +} FC_LOG_AND_RETHROW() + +// Anyone may claim sysio.synd's forfeit for it before a step sees the ruling: the forfeit is then already +// in sysio.synd's row, the step sends no claim (it would be refused), and accounts for the forfeit from +// the request's bonded amount all the same. +BOOST_FIXTURE_TEST_CASE(invalid_accounts_for_a_forfeit_claimed_by_someone_else, sysio_synd_released_tester) try { + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(success(), rule(id, false)); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, "bob"_n, "claim"_n, mvo() + ("request_id", id)("account", SYND_ACCOUNT))); + BOOST_REQUIRE_EQUAL(0u, bond_request(id)["forfeit_pending"].as_uint64()); + + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(!executed(BOND_ACCOUNT, "claim"_n)); + const auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(10 * UNIT, envelope["burned"].as_uint64()); + BOOST_REQUIRE_EQUAL(6 * UNIT, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(6 * UNIT), liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(static_cast(15 * UNIT), supply()); +} FC_LOG_AND_RETHROW() + +// Contract challengers receive their refund directly without recipient execution. +BOOST_FIXTURE_TEST_CASE(valid_after_challenge_credits_a_contract_challenger, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + BOOST_REQUIRE_EQUAL(success(), liq_mint("bob"_n, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), challenge("bob"_n, SOLANA, LIQSOL, 1)); + set_code("bob"_n, contracts::util::block_transfer_wasm()); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), rule(id, true)); + + // Nothing was bonded: the whole hold bond is the issuer's award. + BOOST_REQUIRE_EQUAL(success(), crank()); + for (const auto& trace : last_trace->action_traces) BOOST_REQUIRE(trace.receiver != "bob"_n); + BOOST_REQUIRE(executed(BOND_ACCOUNT, "claim"_n)); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT), liq_balance("bob"_n)); + BOOST_REQUIRE_EQUAL(0u, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); +} FC_LOG_AND_RETHROW() + +// An envelope released whole while its request is still only BONDED can be challenged: the bond exists +// for exactly this. The challenge rewinds the pair's queue to it, and an INVALID ruling burns everything +// it released out of the forfeit. +BOOST_FIXTURE_TEST_CASE(invalid_after_done_challenge_burns_the_released_amount, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(2u, ledger_row(SOLANA, LIQSOL)["queue_epoch"].as()); + BOOST_REQUIRE_EQUAL(REQUEST_BONDED, bond_request(id)["state"].as_string()); + + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(STATE_HELD, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(1u, ledger_row(SOLANA, LIQSOL)["queue_epoch"].as()); + // Minted: the syndication 10, dave's bond 10 and carol's 5. + BOOST_REQUIRE_EQUAL(static_cast(25 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(success(), rule(id, false)); + + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(executed(BOND_ACCOUNT, "claim"_n)); + const auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(10 * UNIT, envelope["burned"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(static_cast(15 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(supply(), liq_balance("alice"_n) + liq_balance("carol"_n) + liq_balance(BOND_ACCOUNT) + + liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(2u, ledger_row(SOLANA, LIQSOL)["queue_epoch"].as()); +} FC_LOG_AND_RETHROW() + +// A challenge that would cost nothing -- a hold bond that rounds to zero and no challenge_extra -- is +// refused: it would freeze the pair for free. The pair's extra makes it possible. +BOOST_FIXTURE_TEST_CASE(a_challenge_of_zero_charge_is_refused, sysio_synd_tester) try { + constexpr std::string_view LIQPT = "LIQPT"; + const symbol LIQPT_SYM = symbol::from_string("2,LIQPT"); + BOOST_REQUIRE_EQUAL(success(), regtoken(LIQPT, 2, ChainKind::CHAIN_KIND_SVM, SOLANA, + std::vector(32, char(0x5b)))); + BOOST_REQUIRE_EQUAL(success(), create_shadow(LIQPT_SYM, SOLANA, LIQPT)); + // 5 base units cover a hold bond of 5 * 1000 / 10000 = 0. + syndicate_and_close(1, 1, ed_bytes(ed_key()), LIQPT, 5); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQPT, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQPT, 100)); + BOOST_REQUIRE(mentions(challenge("carol"_n, SOLANA, LIQPT, 1), CHALLENGE_ZERO)); + + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQPT, {.challenge_extra = 1})); + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQPT, 1)); + BOOST_REQUIRE_EQUAL(static_cast(99), liq_balance("carol"_n, LIQPT_SYM)); + BOOST_REQUIRE_EQUAL(1u, feepot_balance(LIQPT)); + BOOST_REQUIRE_EQUAL(STATE_HELD, envelope_row(SOLANA, LIQPT, 1)["state"].as_string()); +} FC_LOG_AND_RETHROW() + +// A third party may claim sysio.synd's award of the hold bond for it before a step sees the VALID +// ruling: the share is then already in sysio.synd's row, the step sends no claim and still forwards it. +BOOST_FIXTURE_TEST_CASE(valid_forwards_a_hold_share_someone_else_claimed_first, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + BOOST_REQUIRE_EQUAL(success(), underwrite("dave"_n, id, 4 * UNIT, LIQSOL)); + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + const uint64_t hold_bond = 10 * UNIT * HOLD_BPS / BPS_DENOMINATOR; + const uint64_t share = hold_bond * 6 / 10; // the 6 of 10 no bond covered + BOOST_REQUIRE_EQUAL(success(), rule(id, true)); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, "bob"_n, "claim"_n, mvo() + ("request_id", id)("account", SYND_ACCOUNT))); + + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(!executed(BOND_ACCOUNT, "claim"_n)); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT - hold_bond + share), liq_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(0, liq_balance(SYND_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +// INVALID after the envelope's yield report was released: the yield sits in sysio.liq's pending balance, +// outside supply, and is burned out of the forfeit like any release, so supply plus pending is exactly +// what the envelope's holders and the other parties hold. +BOOST_FIXTURE_TEST_CASE(invalid_after_a_yield_release_keeps_the_supply_whole, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_burst = 0, .synd_refill = 0})); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest, 1, 3, LIQSOL, 2 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 2, ChainKind::CHAIN_KIND_SVM, pubkey, LIQSOL, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 1, digest)); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + underwrite_envelope(SOLANA, LIQSOL, 1); + // The yield goes out; the syndication waits behind an empty bucket. + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(2 * UNIT), pending()); + BOOST_REQUIRE_EQUAL(2 * UNIT, envelope_row(SOLANA, LIQSOL, 1)["released"].as_uint64()); + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + // Minted: the syndication 10, dave's bond 12 and carol's 5; 2 more pending. + BOOST_REQUIRE_EQUAL(static_cast(27 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(success(), rule(id, false)); + + BOOST_REQUIRE_EQUAL(success(), crank()); + const auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope["state"].as_string()); + // The held 10 and the released 2 out of the forfeit of 12. + BOOST_REQUIRE_EQUAL(12 * UNIT, envelope["burned"].as_uint64()); + BOOST_REQUIRE_EQUAL(10 * UNIT, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(15 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(static_cast(2 * UNIT), pending()); + BOOST_REQUIRE_EQUAL(supply(), liq_balance("carol"_n) + liq_balance(BOND_ACCOUNT) + liq_balance(SYND_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +// The forfeit and the bounty returned are snapshotted the first time a step sees INVALID. With the burn +// spread over steps, a third party may claim both for sysio.synd and sysio.bond may prune the request in +// between: the step that completes the burn sends no claim and pots them from the snapshot. +BOOST_FIXTURE_TEST_CASE(invalid_completes_from_the_snapshot_after_a_claim_and_a_prune, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + const auto digest = digest_of("envelope-2"); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_fee_bps = 1000, .bounty = UNIT})); + // A first envelope fills the fee pot with 2 of fees. + syndicate_and_close(1, 1, pubkey, LIQSOL, 20 * UNIT); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(2 * UNIT, feepot_balance(LIQSOL)); + // The second envelope, three items, takes a bounty of 1 from the pot. + for (uint64_t sequence = 2; sequence <= 4; ++sequence) { + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 2, digest, sequence, ChainKind::CHAIN_KIND_SVM, pubkey, LIQSOL, + 2 * UNIT)); + } + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 2, digest)); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 2)["request_id"].as_uint64(); + BOOST_REQUIRE_EQUAL(UNIT, bond_request(id)["bounty"].as_uint64()); + BOOST_REQUIRE_EQUAL(UNIT, feepot_balance(LIQSOL)); + underwrite_envelope(SOLANA, LIQSOL, 2); + BOOST_REQUIRE_EQUAL(success(), rule(id, false)); + + // One step sees INVALID and burns one item: the snapshot is taken. + BOOST_REQUIRE_EQUAL(success(), crank(1)); + BOOST_REQUIRE(console_has(QUEUE_INVALID_BURNING)); + auto envelope = envelope_row(SOLANA, LIQSOL, 2); + BOOST_REQUIRE_EQUAL("INVALID", envelope["outcome"].as_string()); + BOOST_REQUIRE_EQUAL(6 * UNIT, envelope["forfeit"].as_uint64()); + BOOST_REQUIRE_EQUAL(UNIT, envelope["bounty_returned"].as_uint64()); + + // Bob claims the forfeit and the bounty for sysio.synd; a week later the request is pruned. + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, "bob"_n, "claim"_n, mvo() + ("request_id", id)("account", SYND_ACCOUNT))); + produce_block(fc::seconds(PRUNE_RETENTION_SEC)); + BOOST_REQUIRE_EQUAL(success(), bond_prune(id, 1)); + BOOST_REQUIRE(bond_request(id).is_null()); + + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(!executed(BOND_ACCOUNT, "claim"_n)); + envelope = envelope_row(SOLANA, LIQSOL, 2); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(6 * UNIT, envelope["burned"].as_uint64()); + // The pot: 1 left, the forfeit of 6 and the bounty of 1 back. + BOOST_REQUIRE_EQUAL(8 * UNIT, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(8 * UNIT), liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(3u, ledger_row(SOLANA, LIQSOL)["queue_epoch"].as()); +} FC_LOG_AND_RETHROW() + +// INVALID after part of the envelope was released into `parked` (its pubkey unlinked): the parked row +// keeps what it received, like any released holder, and the part released is burned out of the forfeit. +BOOST_FIXTURE_TEST_CASE(invalid_after_a_partial_park_burns_the_parked_part_out_of_the_forfeit, + sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_burst = 4 * UNIT, .synd_refill = 4 * UNIT})); + BOOST_REQUIRE_EQUAL(success(), set_epoch(1)); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(4 * UNIT, parked_balance(LIQSOL, ChainKind::CHAIN_KIND_SVM, pubkey)); + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(success(), rule(id, false)); + + BOOST_REQUIRE_EQUAL(success(), crank()); + const auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(10 * UNIT, envelope["burned"].as_uint64()); + BOOST_REQUIRE_EQUAL(6 * UNIT, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(4 * UNIT, parked_balance(LIQSOL, ChainKind::CHAIN_KIND_SVM, pubkey)); + // sysio.synd's row holds the parked 4 and the pot's 6. + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(static_cast(15 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(supply(), liq_balance("carol"_n) + liq_balance(BOND_ACCOUNT) + liq_balance(SYND_ACCOUNT)); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// The emergency stop (sysio.andon) +// --------------------------------------------------------------------------- + +// Frozen queue steps move nothing. Clearing refills by elapsed epochs, capped at burst. +BOOST_FIXTURE_TEST_CASE(a_freeze_stops_release_and_refill_is_capped_at_burst, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_burst = 20 * UNIT, .synd_refill = 4 * UNIT})); + BOOST_REQUIRE_EQUAL(success(), set_epoch(1)); + syndicate_and_close(1, 1, pubkey, LIQSOL, 60 * UNIT); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(20 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(0u, synd_bucket_row(SOLANA, LIQSOL)["level"].as_uint64()); + + BOOST_REQUIRE_EQUAL(success(), set_epoch(3)); + BOOST_REQUIRE_EQUAL(success(), andon_pull()); + + // Frozen: the crank runs, prints why it waits, and moves nothing -- not the item, not the bucket. + BOOST_REQUIRE_EQUAL(success(), set_epoch(4)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(console_has(QUEUE_FROZEN)); + BOOST_REQUIRE(!executed(LIQ_ACCOUNT, "transfer"_n)); + BOOST_REQUIRE_EQUAL(static_cast(20 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(40 * UNIT, item_row(1)["remaining"].as_uint64()); + auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(20 * UNIT, envelope["released"].as_uint64()); + auto bucket = synd_bucket_row(SOLANA, LIQSOL); + BOOST_REQUIRE_EQUAL(0u, bucket["level"].as_uint64()); + BOOST_REQUIRE_EQUAL(1u, bucket["last_epoch"].as()); + BOOST_REQUIRE_EQUAL(static_cast(40 * UNIT), liq_balance(SYND_ACCOUNT)); + + BOOST_REQUIRE_EQUAL(success(), set_epoch(5)); + BOOST_REQUIRE_EQUAL(success(), andon_clear()); + BOOST_REQUIRE_EQUAL(success(), set_epoch(7)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(!console_has(QUEUE_FROZEN)); + BOOST_REQUIRE_EQUAL(static_cast(40 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(20 * UNIT, item_row(1)["remaining"].as_uint64()); + bucket = synd_bucket_row(SOLANA, LIQSOL); + BOOST_REQUIRE_EQUAL(0u, bucket["level"].as_uint64()); + BOOST_REQUIRE_EQUAL(7u, bucket["last_epoch"].as()); + + // From here the bucket refills as before: one epoch, 4 more. + BOOST_REQUIRE_EQUAL(success(), set_epoch(8)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(44 * UNIT), liq_balance("alice"_n)); +} FC_LOG_AND_RETHROW() + +// Review focus 2, sysio.synd: while the cord is pulled a signed transfer INTO sysio.synd succeeds -- a +// challenge pays its charge in and holds the request -- and everything that would move funds out or burn +// them is refused: desyndicate, sweep, sweepyield and dropenv. Intake still holds new syndications. After +// the clear each refused action succeeds. +BOOST_FIXTURE_TEST_CASE(a_freeze_admits_what_comes_in_and_refuses_what_leaves, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.challenge_extra = UNIT})); + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQSOL, 5 * UNIT)); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + BOOST_REQUIRE_EQUAL(REQUEST_OPEN, bond_request(id)["state"].as_string()); + + BOOST_REQUIRE_EQUAL(success(), andon_pull()); + + // In: the challenger's charge moves into sysio.synd and the hold bond on into sysio.bond. + const uint64_t hold_bond = 10 * UNIT * HOLD_BPS / BPS_DENOMINATOR; + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT - hold_bond - UNIT), liq_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(STATE_HELD, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(REQUEST_HELD, bond_request(id)["state"].as_string()); + BOOST_REQUIRE_EQUAL(UNIT, feepot_balance(LIQSOL)); + + // In: intake of a new envelope, left OPEN. + const auto digest2 = digest_of("envelope-2"); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 2, digest2, 2, SVM, pubkey, LIQSOL, 3 * UNIT)); + BOOST_REQUIRE_EQUAL(3 * UNIT, item_row(2)["amount"].as_uint64()); + + // Out: refused, and nothing moved. + BOOST_REQUIRE_EQUAL(frozen(), desyndicate("alice"_n, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(frozen(), sweep("alice"_n, SVM)); + BOOST_REQUIRE_EQUAL(frozen(), push(SYND_ACCOUNT, synd_abi_ser, "alice"_n, "sweepyield"_n, + mvo()("token_code", codename_mvo(LIQSOL)))); + BOOST_REQUIRE_EQUAL(frozen(), dropenv(SOLANA, LIQSOL, 2)); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_OPEN, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); + + BOOST_REQUIRE_EQUAL(success(), andon_clear()); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(static_cast(6 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(success(), sweep("alice"_n, SVM)); + BOOST_REQUIRE_EQUAL(success(), push(SYND_ACCOUNT, synd_abi_ser, "alice"_n, "sweepyield"_n, + mvo()("token_code", codename_mvo(LIQSOL)))); + BOOST_REQUIRE_EQUAL(success(), dropenv(SOLANA, LIQSOL, 2)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope_row(SOLANA, LIQSOL, 2)["state"].as_string()); +} FC_LOG_AND_RETHROW() + +// A delivery from `parked` waits for the clear: a link made while the cord is pulled stands, `linkswept` +// delivers nothing and says so, and `sweep` after the clear delivers the balance and the WIRE it banked. +BOOST_FIXTURE_TEST_CASE(a_freeze_defers_the_parked_delivery_of_a_new_link, sysio_synd_tester) try { + const auto priv = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); + const auto pubkey = em_bytes(priv.get_public_key()); + BOOST_REQUIRE_EQUAL(success(), importsynd(ETH, LIQETH, { credit(pubkey, 30 * UNIT) })); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 3 * UNIT, LIQETH_SYM)); + + BOOST_REQUIRE_EQUAL(success(), andon_pull()); + BOOST_REQUIRE_EQUAL(success(), createlink("alice"_n, priv)); + BOOST_REQUIRE(executed(SYND_ACCOUNT, "linkswept"_n)); + BOOST_REQUIRE(console_has(LINKSWEPT_FROZEN)); + BOOST_REQUIRE_EQUAL(30 * UNIT, parked_balance(LIQETH, ChainKind::CHAIN_KIND_EVM, pubkey)); + BOOST_REQUIRE_EQUAL(0, liq_balance("alice"_n, LIQETH_SYM)); + BOOST_REQUIRE_EQUAL(frozen(), sweep("alice"_n, ChainKind::CHAIN_KIND_EVM)); + + BOOST_REQUIRE_EQUAL(success(), andon_clear()); + BOOST_REQUIRE_EQUAL(success(), sweep("alice"_n, ChainKind::CHAIN_KIND_EVM)); + BOOST_REQUIRE(parked_row(LIQETH, ChainKind::CHAIN_KIND_EVM, pubkey).is_null()); + BOOST_REQUIRE_EQUAL(static_cast(30 * UNIT), liq_balance("alice"_n, LIQETH_SYM)); + BOOST_REQUIRE_EQUAL(static_cast(3 * UNIT), liq_owed("alice"_n, LIQETH_SYM)); +} FC_LOG_AND_RETHROW() + +// An INVALID ruling seen while the cord is pulled is recorded with its snapshot, but nothing is burned and +// nothing claimed from sysio.bond: the pair waits. The first step after the clear burns exactly what the +// unfrozen step would have: the 6 held, then the 4 released out of the forfeit, the rest to the fee pot. +BOOST_FIXTURE_TEST_CASE(a_freeze_defers_the_invalid_burn, sysio_synd_released_tester) try { + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(success(), rule(id, false)); + BOOST_REQUIRE_EQUAL(success(), andon_pull()); + + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(console_has(QUEUE_FROZEN_BURN)); + BOOST_REQUIRE(!executed(BOND_ACCOUNT, "claim"_n)); + BOOST_REQUIRE(!executed(LIQ_ACCOUNT, "burn"_n)); + auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_HELD, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL("INVALID", envelope["outcome"].as_string()); + BOOST_REQUIRE_EQUAL(10 * UNIT, envelope["forfeit"].as_uint64()); + BOOST_REQUIRE_EQUAL(0u, envelope["burned"].as_uint64()); + BOOST_REQUIRE_EQUAL(6 * UNIT, item_row(1)["remaining"].as_uint64()); + BOOST_REQUIRE_EQUAL(static_cast(25 * UNIT), supply()); + + BOOST_REQUIRE_EQUAL(success(), andon_clear()); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(executed(BOND_ACCOUNT, "claim"_n)); + BOOST_REQUIRE(console_has(QUEUE_INVALID_BURNED)); + envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(10 * UNIT, envelope["burned"].as_uint64()); + BOOST_REQUIRE(item_row(1).is_null()); + BOOST_REQUIRE_EQUAL(6 * UNIT, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(15 * UNIT), supply()); +} FC_LOG_AND_RETHROW() + +// A VALID ruling seen while the cord is pulled is recorded with the challenger's hold share snapshotted and +// pending, but nothing is claimed or forwarded; the first step after the clear forwards the share once and +// releases the envelope. +BOOST_FIXTURE_TEST_CASE(a_freeze_defers_the_hold_share_of_a_valid_ruling, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + BOOST_REQUIRE_EQUAL(success(), underwrite("dave"_n, id, 4 * UNIT, LIQSOL)); + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + const uint64_t hold_bond = 10 * UNIT * HOLD_BPS / BPS_DENOMINATOR; + const uint64_t share = hold_bond * 6 / 10; // the 6 of 10 no bond covered + BOOST_REQUIRE_EQUAL(success(), rule(id, true)); + BOOST_REQUIRE_EQUAL(success(), andon_pull()); + + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(console_has(QUEUE_FROZEN_SHARE)); + BOOST_REQUIRE(!executed(BOND_ACCOUNT, "claim"_n)); + auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL("VALID", envelope["outcome"].as_string()); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(share, envelope["hold_share"].as_uint64()); + BOOST_REQUIRE_EQUAL("carol"_n, envelope["hold_beneficiary"].as()); + BOOST_REQUIRE(envelope["share_pending"].as_bool()); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT - hold_bond), liq_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(0, liq_balance("alice"_n)); + + BOOST_REQUIRE_EQUAL(success(), andon_clear()); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT - hold_bond + share), liq_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE(!envelope_row(SOLANA, LIQSOL, 1)["share_pending"].as_bool()); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT - hold_bond + share), liq_balance("carol"_n)); +} FC_LOG_AND_RETHROW() + +// A request is still issued while the cord is pulled: closeenv requests the next envelope and a bond can be +// placed on it -- the bond moves INTO sysio.bond -- while nothing releases until the clear. +BOOST_FIXTURE_TEST_CASE(a_freeze_still_issues_requests_and_takes_bonds, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + BOOST_REQUIRE_EQUAL(success(), andon_pull()); + + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + BOOST_REQUIRE(console_has(QUEUE_FROZEN)); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(0, liq_balance("alice"_n)); + + BOOST_REQUIRE_EQUAL(success(), andon_clear()); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); +} FC_LOG_AND_RETHROW() + +// The forward survives a prune: every claim on a VALID request is made before the pull (the underwriter's, +// and sysio.synd's award claimed for it by a bystander), the frozen step records VALID with its snapshot, +// and sysio.bond prunes the request past its retention while the cord is still pulled. The first step after +// the clear forwards the challenger's share from the snapshot, once, and releases the envelope. +BOOST_FIXTURE_TEST_CASE(a_valid_forward_survives_a_prune_during_the_freeze, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + const uint64_t id = envelope_row(SOLANA, LIQSOL, 1)["request_id"].as_uint64(); + BOOST_REQUIRE_EQUAL(success(), underwrite("dave"_n, id, 4 * UNIT, LIQSOL)); + BOOST_REQUIRE_EQUAL(success(), liq_mint("carol"_n, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + const uint64_t hold_bond = 10 * UNIT * HOLD_BPS / BPS_DENOMINATOR; + const uint64_t share = hold_bond * 6 / 10; // the 6 of 10 no bond covered + BOOST_REQUIRE_EQUAL(success(), rule(id, true)); + BOOST_REQUIRE_EQUAL(success(), bond_claim(id, "dave"_n)); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, "bob"_n, "claim"_n, mvo() + ("request_id", id)("account", SYND_ACCOUNT))); + + BOOST_REQUIRE_EQUAL(success(), andon_pull()); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL("VALID", envelope_row(SOLANA, LIQSOL, 1)["outcome"].as_string()); + BOOST_REQUIRE(envelope_row(SOLANA, LIQSOL, 1)["share_pending"].as_bool()); + + produce_block(fc::seconds(PRUNE_RETENTION_SEC + 1)); + BOOST_REQUIRE_EQUAL(success(), bond_prune(id, 10)); + BOOST_REQUIRE(bond_request(id).is_null()); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT - hold_bond), liq_balance("carol"_n)); + + BOOST_REQUIRE_EQUAL(success(), andon_clear()); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(!executed(BOND_ACCOUNT, "claim"_n)); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT - hold_bond + share), liq_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + const auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope["state"].as_string()); + BOOST_REQUIRE(!envelope["share_pending"].as_bool()); + BOOST_REQUIRE_EQUAL(0, liq_balance(SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(5 * UNIT - hold_bond + share), liq_balance("carol"_n)); +} FC_LOG_AND_RETHROW() + +// A bucket holding a positive level at the pull keeps it through a frozen crank -- nothing ticks and +// nothing is spent. After clear, elapsed epochs refill the 40-unit burst before the queued 30 +// units are released, leaving 10 units of capacity. +BOOST_FIXTURE_TEST_CASE(a_freeze_keeps_a_positive_bucket_level, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_burst = 40 * UNIT, .synd_refill = 2 * UNIT})); + BOOST_REQUIRE_EQUAL(success(), set_epoch(1)); + syndicate_and_close(1, 1, pubkey, LIQSOL, 10 * UNIT); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(30 * UNIT, synd_bucket_row(SOLANA, LIQSOL)["level"].as_uint64()); + + BOOST_REQUIRE_EQUAL(success(), set_epoch(3)); + BOOST_REQUIRE_EQUAL(success(), andon_pull()); + syndicate_and_close(3, 2, pubkey, LIQSOL, 30 * UNIT); + underwrite_envelope(SOLANA, LIQSOL, 3); + BOOST_REQUIRE_EQUAL(success(), set_epoch(4)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE(console_has(QUEUE_FROZEN)); + BOOST_REQUIRE_EQUAL(STATE_RELEASABLE, envelope_row(SOLANA, LIQSOL, 3)["state"].as_string()); + auto bucket = synd_bucket_row(SOLANA, LIQSOL); + BOOST_REQUIRE_EQUAL(30 * UNIT, bucket["level"].as_uint64()); + BOOST_REQUIRE_EQUAL(1u, bucket["last_epoch"].as()); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), liq_balance("alice"_n)); + + BOOST_REQUIRE_EQUAL(success(), set_epoch(5)); + BOOST_REQUIRE_EQUAL(success(), andon_clear()); + BOOST_REQUIRE_EQUAL(success(), set_epoch(7)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(40 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(STATE_DONE, envelope_row(SOLANA, LIQSOL, 3)["state"].as_string()); + bucket = synd_bucket_row(SOLANA, LIQSOL); + BOOST_REQUIRE_EQUAL(10 * UNIT, bucket["level"].as_uint64()); + BOOST_REQUIRE_EQUAL(7u, bucket["last_epoch"].as()); +} FC_LOG_AND_RETHROW() + +// Repeated clears and pulls do not multiply capacity beyond the configured burst. +BOOST_FIXTURE_TEST_CASE(repeated_freezes_do_not_exceed_the_burst_cap, sysio_synd_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, key)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.synd_burst = 20 * UNIT, .synd_refill = 4 * UNIT})); + BOOST_REQUIRE_EQUAL(success(), set_epoch(1)); + syndicate_and_close(1, 1, pubkey, LIQSOL, 60 * UNIT); + underwrite_envelope(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(0u, synd_bucket_row(SOLANA, LIQSOL)["level"].as_uint64()); + + BOOST_REQUIRE_EQUAL(success(), set_epoch(2)); + BOOST_REQUIRE_EQUAL(success(), andon_pull()); + BOOST_REQUIRE_EQUAL(success(), andon_clear()); + BOOST_REQUIRE_EQUAL(success(), andon_pull()); + BOOST_REQUIRE_EQUAL(success(), set_epoch(3)); + BOOST_REQUIRE_EQUAL(success(), andon_clear()); + BOOST_REQUIRE_EQUAL(success(), set_epoch(5)); + BOOST_REQUIRE_EQUAL(success(), andon_pull()); + BOOST_REQUIRE_EQUAL(success(), set_epoch(6)); + BOOST_REQUIRE_EQUAL(success(), andon_clear()); + + BOOST_REQUIRE_EQUAL(success(), set_epoch(8)); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(static_cast(40 * UNIT), liq_balance("alice"_n)); + const auto bucket = synd_bucket_row(SOLANA, LIQSOL); + BOOST_REQUIRE_EQUAL(0u, bucket["level"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// The solvency check: outpost custody >= depot outstanding +// --------------------------------------------------------------------------- + +// Custody equal to the outstanding: nothing is recorded or printed, the cord stays clear. A syndication is +// compared after its own mint, a yield report with the outstanding as it stands. +BOOST_FIXTURE_TEST_CASE(custody_equal_to_the_outstanding_records_nothing, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, + 10 * UNIT)); + BOOST_REQUIRE(!console_has(EXCESS)); + require_no_shortfall(); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest, 2, 7, LIQSOL, 2 * UNIT, MSGCH_ACCOUNT, 10 * UNIT)); + BOOST_REQUIRE(!console_has(EXCESS)); + require_no_shortfall(); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), supply()); +} FC_LOG_AND_RETHROW() + +// Custody above the outstanding -- a donation to the custody account -- is normal: it is printed as EXCESS +// and nothing else happens. +BOOST_FIXTURE_TEST_CASE(an_excess_is_printed_and_nothing_else, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + const auto digest = digest_of("envelope-1"); + // 10 locked, and 3 donated to the pool account before the outpost read it. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, + 13 * UNIT)); + BOOST_REQUIRE(console_has(verdict(ONSYND_PATH, EXCESS, 13 * UNIT, 10 * UNIT))); + require_no_shortfall(); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest, 2, 7, LIQSOL, 2 * UNIT, MSGCH_ACCOUNT, 15 * UNIT)); + BOOST_REQUIRE(console_has(verdict(ONYIELD_PATH, EXCESS, 15 * UNIT, 10 * UNIT))); + require_no_shortfall(); +} FC_LOG_AND_RETHROW() + +// A syndication whose carried custody is below the outstanding after its own mint writes one `mismatch` row +// and pulls the cord, and the syndication is still held, minted and added to its envelope and the ledger. +BOOST_FIXTURE_TEST_CASE(a_syndication_shortfall_records_pulls_and_still_holds, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, + 10 * UNIT)); + require_no_shortfall(); + + // 5 more locked, but the outpost holds 12 against 15 outstanding after this mint. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 2, SVM, pubkey, LIQSOL, 5 * UNIT, MSGCH_ACCOUNT, + 12 * UNIT)); + BOOST_REQUIRE(console_has(verdict(ONSYND_PATH, SHORTFALL, 12 * UNIT, 15 * UNIT))); + BOOST_REQUIRE(console_has(pulled_line(ONSYND_PATH, SOLANA, LIQSOL, 2))); + const auto rows = mismatch_rows(); + BOOST_REQUIRE_EQUAL(1u, rows.size()); + require_mismatch(rows[0], SOLANA, LIQSOL, 1, 2, MISMATCH_SYNDICATION, 12 * UNIT, 15 * UNIT); + + BOOST_REQUIRE(executed(sysio_system::test_support::andon::account, "pull"_n)); + const auto cord = cord_row(); + BOOST_REQUIRE(cord["pulled"].as_bool()); + BOOST_REQUIRE_EQUAL(pull_reason(SOLANA, LIQSOL, 2), cord["reason"].as_string()); + + // Held and minted as usual. + BOOST_REQUIRE(executed(LIQ_ACCOUNT, "mint"_n)); + BOOST_REQUIRE_EQUAL(5 * UNIT, item_row(2)["amount"].as_uint64()); + BOOST_REQUIRE_EQUAL(static_cast(15 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(static_cast(15 * UNIT), liq_balance(SYND_ACCOUNT)); + const auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(15 * UNIT, envelope["synd_total"].as_uint64()); + BOOST_REQUIRE_EQUAL(2u, envelope["item_count"].as()); + BOOST_REQUIRE_EQUAL(2u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + + // The envelope closes as usual; the frozen queue step waits. + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 1, digest)); + BOOST_REQUIRE(console_has(QUEUE_FROZEN)); + BOOST_REQUIRE_NE(STATE_OPEN, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); +} FC_LOG_AND_RETHROW() + +// A yield report whose carried custody is below the outstanding is a shortfall the same way; the report is +// still held as a YIELD item and mints nothing. +BOOST_FIXTURE_TEST_CASE(a_yield_shortfall_records_pulls_and_still_holds, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, + 10 * UNIT)); + // 3 of yield claimed, but the outpost holds 9 against 10 outstanding. + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest, 2, 7, LIQSOL, 3 * UNIT, MSGCH_ACCOUNT, 9 * UNIT)); + BOOST_REQUIRE(console_has(verdict(ONYIELD_PATH, SHORTFALL, 9 * UNIT, 10 * UNIT))); + BOOST_REQUIRE(console_has(pulled_line(ONYIELD_PATH, SOLANA, LIQSOL, 2))); + const auto rows = mismatch_rows(); + BOOST_REQUIRE_EQUAL(1u, rows.size()); + require_mismatch(rows[0], SOLANA, LIQSOL, 1, 2, MISMATCH_YIELD, 9 * UNIT, 10 * UNIT); + BOOST_REQUIRE(cord_pulled()); + BOOST_REQUIRE_EQUAL(pull_reason(SOLANA, LIQSOL, 2), cord_row()["reason"].as_string()); + + BOOST_REQUIRE_EQUAL(KIND_YIELD, item_row(2)["kind"].as_string()); + BOOST_REQUIRE_EQUAL(3 * UNIT, item_row(2)["remaining"].as_uint64()); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(0, pending()); + BOOST_REQUIRE_EQUAL(3 * UNIT, envelope_row(SOLANA, LIQSOL, 1)["yield_total"].as_uint64()); + BOOST_REQUIRE_EQUAL(7u, cursor_row(SOLANA)["last_epoch"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// Review focus 3: two syndications of one envelope each carry the custody the outpost read after locking +// it, and each is compared with the outstanding after its own mint -- so both are exact, and so is the +// yield report after them, which carries the custody after its claim. +BOOST_FIXTURE_TEST_CASE(each_syndication_of_an_envelope_passes_on_its_own_custody, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, + 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 2, SVM, pubkey, LIQSOL, 20 * UNIT, MSGCH_ACCOUNT, + 30 * UNIT)); + BOOST_REQUIRE(!console_has(EXCESS)); + // Yield of 4 claimed into custody: 34 held against 30 outstanding, the report minting nothing yet. + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest, 3, 7, LIQSOL, 4 * UNIT, MSGCH_ACCOUNT, 34 * UNIT)); + BOOST_REQUIRE(console_has(verdict(ONYIELD_PATH, EXCESS, 34 * UNIT, 30 * UNIT))); + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 1, digest)); + require_no_shortfall(); + BOOST_REQUIRE_EQUAL(static_cast(30 * UNIT), supply()); +} FC_LOG_AND_RETHROW() + +// No false alarm in flight, a desyndication: it is burned on the depot before the outpost pays it (or +// while the outpost keeps its payout stored as pending), so custody the outpost has not paid out yet only +// adds to the margin. Syndicated 100 and released to alice; she desyndicates 40, leaving 60 outstanding. +// The next syndication of 10 carries 110 while the 40 is unpaid (or pending), and the one after carries 80 +// once it is paid: both pass. +BOOST_FIXTURE_TEST_CASE(a_desyndication_in_flight_leaves_the_check_passing, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); // the released syndication + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_key())); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.desynd_burst = 100 * UNIT})); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 40 * UNIT)); + BOOST_REQUIRE_EQUAL(static_cast(60 * UNIT), supply()); + + const auto digest = digest_of("envelope-1"); + // Unpaid: the pool still holds the 40, plus the 10 just locked. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, + 110 * UNIT)); + BOOST_REQUIRE(console_has(verdict(ONSYND_PATH, EXCESS, 110 * UNIT, 70 * UNIT))); + require_no_shortfall(); + // Paid: 110 - 40 = 70, plus the next 10 locked. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 2, SVM, pubkey, LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, + 80 * UNIT)); + BOOST_REQUIRE(!console_has(EXCESS)); + require_no_shortfall(); +} FC_LOG_AND_RETHROW() + +// No false alarm in flight, a desyndication charged a fee: the fee stays in the fee pot as shadow that is +// still outstanding, and only the net is burned and paid. Alice desyndicates 40 at 250 bps: 1 stays in the +// pot and 39 burn, leaving 61 outstanding. The next syndication of 10 carries 110 while the 39 is unpaid, +// and the one after carries 71 + 10 once it is paid: both pass. +BOOST_FIXTURE_TEST_CASE(a_desyndication_with_a_fee_in_flight_leaves_the_check_passing, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); // the released syndication + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_key())); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.desynd_fee_bps = 250, .desynd_burst = 100 * UNIT})); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 40 * UNIT)); + BOOST_REQUIRE_EQUAL(UNIT, feepot_balance(LIQSOL)); + BOOST_REQUIRE_EQUAL(static_cast(61 * UNIT), supply()); + + const auto digest = digest_of("envelope-1"); + // Unpaid: the pool still holds the 39, plus the 10 just locked. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, + 110 * UNIT)); + BOOST_REQUIRE(console_has(verdict(ONSYND_PATH, EXCESS, 110 * UNIT, 71 * UNIT))); + require_no_shortfall(); + // Paid: 110 - 39 = 71, plus the next 10 locked -- the fee is still covered. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 2, SVM, pubkey, LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, + 81 * UNIT)); + BOOST_REQUIRE(!console_has(EXCESS)); + require_no_shortfall(); +} FC_LOG_AND_RETHROW() + +// No false alarm in flight, an INVALID ruling after a partial release: the queue burns the unreleased 6 +// and, out of the forfeit, the 4 already released, while the outpost keeps the whole custody. Custody +// here is 25: the envelope's 10 plus the 15 behind dave's bond and carol's shadow, syndicated earlier. +// After the burn 15 is outstanding, and the next syndication of 1 carrying 26 passes. +BOOST_FIXTURE_TEST_CASE(an_invalid_ruling_after_a_partial_release_leaves_the_check_passing, + sysio_synd_released_tester) try { + BOOST_REQUIRE_EQUAL(success(), challenge("carol"_n, SOLANA, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(static_cast(25 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(success(), rule(id, false)); + BOOST_REQUIRE_EQUAL(success(), crank()); + const auto envelope = envelope_row(SOLANA, LIQSOL, 1); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope["state"].as_string()); + BOOST_REQUIRE_EQUAL(4 * UNIT, envelope["released"].as_uint64()); + BOOST_REQUIRE_EQUAL(10 * UNIT, envelope["burned"].as_uint64()); + BOOST_REQUIRE_EQUAL(static_cast(15 * UNIT), supply()); + + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 2, digest_of("envelope-2"), 2, ChainKind::CHAIN_KIND_SVM, pubkey, + LIQSOL, UNIT, MSGCH_ACCOUNT, 26 * UNIT)); + BOOST_REQUIRE(console_has(verdict(ONSYND_PATH, EXCESS, 26 * UNIT, 16 * UNIT))); + require_no_shortfall(); +} FC_LOG_AND_RETHROW() + +// No false alarm in flight, an INVALID burn: `dropenv` burns a held envelope on the depot while the outpost +// keeps the custody it locked, so the unchanged custody passes against the smaller outstanding. +BOOST_FIXTURE_TEST_CASE(custody_left_behind_by_a_burned_envelope_passes, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest_of("envelope-1"), 1, SVM, pubkey, LIQSOL, 100 * UNIT, + MSGCH_ACCOUNT, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), dropenv(SOLANA, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(0, supply()); + + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 2, digest_of("envelope-2"), 2, SVM, pubkey, LIQSOL, 10 * UNIT, + MSGCH_ACCOUNT, 110 * UNIT)); + BOOST_REQUIRE(console_has(verdict(ONSYND_PATH, EXCESS, 110 * UNIT, 10 * UNIT))); + require_no_shortfall(); +} FC_LOG_AND_RETHROW() + +// A released yield is parked in `liqpending` outside the supply until `queueyield`, and the outstanding +// includes it: the outpost claimed it into custody before reporting it. A message whose custody leaves it +// out is a shortfall; one that includes it passes. +BOOST_FIXTURE_TEST_CASE(a_released_yield_counts_before_it_is_queued, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, + 10 * UNIT)); + // A yield of 4 released by the queue: pending, not yet in the supply. + BOOST_REQUIRE_EQUAL(success(), liq_mintyield(SOLANA, LIQSOL, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(static_cast(4 * UNIT), pending()); + + // 5 locked; custody without the yield is 15, against 10 + 4 + 5 outstanding. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 2, SVM, pubkey, LIQSOL, 5 * UNIT, MSGCH_ACCOUNT, + 15 * UNIT)); + BOOST_REQUIRE(console_has(verdict(ONSYND_PATH, SHORTFALL, 15 * UNIT, 19 * UNIT))); + auto rows = mismatch_rows(); + BOOST_REQUIRE_EQUAL(1u, rows.size()); + require_mismatch(rows[0], SOLANA, LIQSOL, 1, 2, MISMATCH_SYNDICATION, 15 * UNIT, 19 * UNIT); + + // 1 more locked with the yield counted: 10 + 4 + 5 + 1, exact. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 3, SVM, pubkey, LIQSOL, UNIT, MSGCH_ACCOUNT, + 20 * UNIT)); + BOOST_REQUIRE(!console_has(SHORTFALL)); + BOOST_REQUIRE(!console_has(EXCESS)); + BOOST_REQUIRE_EQUAL(1u, mismatch_rows().size()); +} FC_LOG_AND_RETHROW() + +// No false alarm after a refusal: governance returns a desyndication the outpost refused with +// `sysio.liq::recredit`, which grows the supply directly, and the outpost still holds what it never paid. +// The next comparison sees the recredit with nothing recorded in `sysio.synd`: custody 20 matches +// 16 + 4 exactly, where a check that missed the recredit would read an excess of 4. +BOOST_FIXTURE_TEST_CASE(a_recredit_is_seen_by_the_next_comparison, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 20 * UNIT)); // released: custody 20 + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_key())); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.desynd_burst = 100 * UNIT})); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 4 * UNIT)); + BOOST_REQUIRE_EQUAL(static_cast(16 * UNIT), supply()); + // The outpost refused it; governance returns the 4 to alice. + BOOST_REQUIRE_EQUAL(success(), push(SYND_ACCOUNT, synd_abi_ser, SYSIO_ACCOUNT, "refundreturn"_n, + mvo()("request_id", 1))); + BOOST_REQUIRE_EQUAL(static_cast(20 * UNIT), supply()); + const auto ledger_before = ledger_row(SOLANA, LIQSOL); + + // 1 locked: custody 21 against 20 + 1. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest_of("envelope-1"), 1, SVM, pubkey, LIQSOL, UNIT, + MSGCH_ACCOUNT, 21 * UNIT)); + BOOST_REQUIRE(!console_has(EXCESS)); + require_no_shortfall(); + // The recredit wrote nothing to the ledger's running sums: the check does not read them. +} FC_LOG_AND_RETHROW() + +// Review focus 2: only an admitted message is compared. A replayed sequence, or a message dropped for any +// other reason, may carry a stale, lower total; it writes no `mismatch` row and pulls nothing. +BOOST_FIXTURE_TEST_CASE(a_message_not_admitted_is_never_compared, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 5, SVM, pubkey, LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, + 10 * UNIT)); + // Replays of sequences at or below 5, each carrying a custody of 0. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 5, SVM, pubkey, LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, 0)); + BOOST_REQUIRE(console_has(DROP_REPLAY)); + require_no_shortfall(); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest, 3, 7, LIQSOL, UNIT, MSGCH_ACCOUNT, 0)); + BOOST_REQUIRE(console_has(DROP_YIELD_REPLAY)); + require_no_shortfall(); + // A fresh sequence dropped for another reason: a token of another outpost. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 6, SVM, pubkey, LIQETH, UNIT, MSGCH_ACCOUNT, 0)); + BOOST_REQUIRE(console_has(DROP_OTHER_CHAIN)); + require_no_shortfall(); + BOOST_REQUIRE_EQUAL(5u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), supply()); +} FC_LOG_AND_RETHROW() + +/// Healthy reports do not erase an incident. Only governance may reconcile it, against live +/// outstanding supply, and reconciliation does not implicitly clear the global emergency stop. +BOOST_FIXTURE_TEST_CASE(incident_reconciliation_is_explicit_and_pair_local, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest_of("one"), 1, ChainKind::CHAIN_KIND_SVM, + pubkey, LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, UNIT)); + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest_of("one"), 2, 2, LIQSOL, UNIT, + MSGCH_ACCOUNT, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(1u, mismatch_rows().size()); + const auto reconcile = [&](name signer, uint64_t reported) { return push(SYND_ACCOUNT, synd_abi_ser, + signer, "reconcile"_n, mvo()("chain_code", codename_mvo(SOLANA)) + ("token_code", codename_mvo(LIQSOL))("reported", reported)); }; + BOOST_REQUIRE(mentions(reconcile("alice"_n, 10 * UNIT), "missing authority of sysio")); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("custody still below outstanding"), reconcile(SYSIO_ACCOUNT, 9 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), reconcile(SYSIO_ACCOUNT, 10 * UNIT)); + BOOST_REQUIRE(mismatch_rows().empty()); + BOOST_REQUIRE(cord_pulled()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("custody incident not found"), reconcile(SYSIO_ACCOUNT, 10 * UNIT)); +} FC_LOG_AND_RETHROW() + +// A missing emergency-stop deployment must fail atomically, not accept an unprotected shortfall. +BOOST_FIXTURE_TEST_CASE(a_shortfall_requires_sysio_andon_deployment, sysio_synd_no_andon_tester) try { + const auto result = onsynd(SOLANA, 1, digest_of("missing-andon"), 1, ChainKind::CHAIN_KIND_SVM, + ed_bytes(ed_key()), LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, 9 * UNIT); + BOOST_REQUIRE_NE(success(), result); + BOOST_REQUIRE_EQUAL(0, supply()); + BOOST_REQUIRE(mismatch_rows().empty()); +} FC_LOG_AND_RETHROW() + +// Privileged syndication pulls with the existing Andon active permission before custom links exist. +BOOST_FIXTURE_TEST_CASE(a_privileged_shortfall_pulls_without_custom_permissions, + sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, + 9 * UNIT)); + BOOST_REQUIRE(console_has(verdict(ONSYND_PATH, SHORTFALL, 9 * UNIT, 10 * UNIT))); + BOOST_REQUIRE(executed(sysio_system::test_support::andon::account, "pull"_n)); + BOOST_REQUIRE(cord_pulled()); + const auto rows = mismatch_rows(); + BOOST_REQUIRE_EQUAL(1u, rows.size()); + require_mismatch(rows[0], SOLANA, LIQSOL, 1, 1, MISMATCH_SYNDICATION, 9 * UNIT, 10 * UNIT); + BOOST_REQUIRE_EQUAL(static_cast(10 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 1, digest)); + BOOST_REQUIRE_EQUAL(STATE_REQUESTED, envelope_row(SOLANA, LIQSOL, 1)["state"].as_string()); +} FC_LOG_AND_RETHROW() + +// Review focus 4: a shortfall on a cord already pulled writes its own row, sends no second pull and aborts +// nothing; the cord keeps its first pull. +BOOST_FIXTURE_TEST_CASE(a_shortfall_on_a_pulled_cord_updates_the_incident, sysio_synd_tester) try { + const auto pubkey = ed_bytes(ed_key()); + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + const auto digest = digest_of("envelope-1"); + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 1, SVM, pubkey, LIQSOL, 10 * UNIT, MSGCH_ACCOUNT, + 9 * UNIT)); + BOOST_REQUIRE(cord_pulled()); + const auto cord_bytes = [&] { + return get_row_by_account(sysio_system::test_support::andon::account, + sysio_system::test_support::andon::account, "cord"_n, "cord"_n); + }; + const std::vector first_pull = cord_bytes(); + + BOOST_REQUIRE_EQUAL(success(), onyield(SOLANA, 1, digest, 2, 7, LIQSOL, UNIT, MSGCH_ACCOUNT, 8 * UNIT)); + BOOST_REQUIRE(console_has(verdict(ONYIELD_PATH, SHORTFALL, 8 * UNIT, 10 * UNIT))); + BOOST_REQUIRE(console_has("sysio.synd::onyield" + std::string(CORD_ALREADY))); + BOOST_REQUIRE(!executed(sysio_system::test_support::andon::account, "pull"_n)); + const auto rows = mismatch_rows(); + BOOST_REQUIRE_EQUAL(1u, rows.size()); + require_mismatch(rows[0], SOLANA, LIQSOL, 1, 2, MISMATCH_YIELD, 8 * UNIT, 10 * UNIT); + BOOST_REQUIRE_EQUAL(2u, rows[0]["sequence"].as_uint64()); + BOOST_REQUIRE(first_pull == cord_bytes()); + + // A later syndication short again records its own row too, and the cord still holds its first pull. + BOOST_REQUIRE_EQUAL(success(), onsynd(SOLANA, 1, digest, 3, SVM, pubkey, LIQSOL, UNIT, MSGCH_ACCOUNT, UNIT)); + BOOST_REQUIRE(console_has("sysio.synd::onsynd" + std::string(CORD_ALREADY))); + BOOST_REQUIRE_EQUAL(1u, mismatch_rows().size()); + BOOST_REQUIRE(first_pull == cord_bytes()); + BOOST_REQUIRE_EQUAL(static_cast(11 * UNIT), supply()); + BOOST_REQUIRE_EQUAL(success(), closeenv(SOLANA, 1, digest)); + BOOST_REQUIRE(console_has(QUEUE_FROZEN)); +} FC_LOG_AND_RETHROW() + + +// These scenarios start with custody-backed bootstrap credits. Unlike the narrow +// bond fixture's underwrite(), accepting a request never creates extra collateral. +struct generic_synd_tester : sysio_synd_tester { + static constexpr uint64_t Bootstrap = 1'000 * UNIT; + static constexpr uint32_t Window = 3600; + std::array outposts{external::chain(external::First), external::chain(external::Second)}; + std::array, 2> users; + + generic_synd_tester() : sysio_synd_tester(false) { + for (size_t i = 0; i < outposts.size(); ++i) { + const auto& a = outposts[i].asset(); + BOOST_REQUIRE_EQUAL(success(), regchain(a.kind, a.chain, a.id)); + BOOST_REQUIRE_EQUAL(success(), regtoken(a.token, a.kind, a.chain)); + BOOST_REQUIRE_EQUAL(success(), create_shadow(sym(i), a.chain, a.token)); + for (const name account : {"alice"_n, "carol"_n, "dave"_n}) { + const auto key = a.kind == ChainKind::CHAIN_KIND_EVM ? em_key() : ed_key(); + const auto bytes = a.kind == ChainKind::CHAIN_KIND_EVM ? em_bytes(key) : ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), push(AUTHEX_ACCOUNT, authex_abi_ser, AUTHEX_ACCOUNT, "recordlink"_n, + mvo()("account", account)("chain_kind", a.kind)("pub_key", key) + ("native_address", native_address_of(key)))); + if (account == "alice"_n) users[i] = bytes; + else { + outposts[i].donate(Bootstrap); + BOOST_REQUIRE_EQUAL(success(), importsynd(a.chain, a.token, {credit(bytes, Bootstrap)})); + } + } + BOOST_REQUIRE_EQUAL(success(), setconfig(a.chain, a.token, + {.synd_burst = 3 * UNIT, .synd_refill = 3 * UNIT, .window_sec = Window})); + } + BOOST_REQUIRE_EQUAL(success(), importdone()); + } + symbol sym(size_t i) const { return symbol::from_string(std::string("9,") + outposts[i].asset().token); } + void conservation() { + for (size_t i = 0; i < outposts.size(); ++i) { + const auto token = sym(i); + BOOST_REQUIRE_EQUAL(supply(token), liq_balance("alice"_n, token) + liq_balance("carol"_n, token) + + liq_balance("dave"_n, token) + liq_balance("bob"_n, token) + liq_balance(SWAP_ACCOUNT, token) + + liq_balance(LIQ_ACCOUNT, token) + liq_balance(SYND_ACCOUNT, token) + liq_balance(BOND_ACCOUNT, token)); + BOOST_REQUIRE_LE(uint64_t(supply(token) + pending(token)), outposts[i].custody()); + } + require_no_shortfall(); + } + void accept(uint64_t request) { + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, "dave"_n, "accept"_n, + mvo()("underwriter", "dave"_n)("request_id", request)("amount", bond_request(request)["covered"]))); + conservation(); + } +}; + +// Fixed seeds make failures reproducible; generated choices vary chain order, +// amount, challenge outcome, freeze timing, release count, and return amount. +// Each round consumes the preceding round's balances and sequence cursors. +BOOST_AUTO_TEST_CASE(generated_generic_custody_bond_challenge_and_return_sequences) { + constexpr std::array Seeds{0x51a7, 0xc011a7, 0xb0ad}; + constexpr size_t Rounds = 8; + for (const auto seed : Seeds) { + generic_synd_tester t; + std::mt19937 random(seed); + uint32_t epoch = 1; + uint64_t return_id = 0; + for (size_t step = 0; step < Rounds; ++step) { + BOOST_TEST_CONTEXT("seed=" << seed << " step=" << step) { + const size_t i = random() % t.outposts.size(); + auto& external = t.outposts[i]; + const auto& a = external.asset(); + const auto token = t.sym(i); + const auto other_token = t.sym(1 - i); + const auto other_state = [&] { + return std::array{t.supply(other_token), t.liq_balance("alice"_n, other_token), + t.liq_balance("carol"_n, other_token), t.liq_balance("dave"_n, other_token), + t.liq_balance(t.SYND_ACCOUNT, other_token), t.liq_balance(t.BOND_ACCOUNT, other_token)}; + }; + const auto untouched = other_state(); + const uint64_t amount = (7 + random() % 9) * t.UNIT; + const bool invalid = step % 3 == 0; + const bool challenged = invalid || (random() % 2 == 0); + const auto before = t.liq_balance("alice"_n, token); + const auto supply_before = t.supply(token); + BOOST_REQUIRE_EQUAL(t.success(), t.set_epoch(epoch)); + const auto report = external.deposit(t.users[i], amount); + sysio::opp::attestations::SyndicateLIQ deposit; + BOOST_REQUIRE(deposit.ParseFromString(report.second)); + const auto digest = t.digest_of(std::to_string(seed) + ":" + std::to_string(step)); + const auto ingest = [&] { + return t.onsynd(a.chain, epoch, digest, deposit.sequence(), a.kind, t.users[i], a.token, + amount, t.MSGCH_ACCOUNT, deposit.total_syndicated()); + }; + BOOST_REQUIRE_EQUAL(t.success(), ingest()); + t.conservation(); + // Authenticated duplicate delivery cannot mint a second time. + BOOST_REQUIRE_EQUAL(t.success(), ingest()); + BOOST_REQUIRE_EQUAL(supply_before + int64_t(amount), t.supply(token)); + BOOST_REQUIRE_EQUAL(t.success(), t.closeenv(a.chain, epoch, digest)); + const auto request = t.envelope_row(a.chain, a.token, epoch)["request_id"].as_uint64(); + t.accept(request); + BOOST_REQUIRE_EQUAL(t.success(), t.crank()); + const auto released = t.liq_balance("alice"_n, token) - before; + BOOST_REQUIRE_EQUAL(int64_t(3 * t.UNIT), released); + t.conservation(); + if (random() % 2 == 0) { + BOOST_REQUIRE_EQUAL(t.success(), t.andon_pull()); + const auto frozen_supply = t.supply(token); + BOOST_REQUIRE_EQUAL(t.frozen(), t.desyndicate("alice"_n, t.UNIT, token)); + BOOST_REQUIRE_EQUAL(frozen_supply, t.supply(token)); + BOOST_REQUIRE_EQUAL(t.success(), t.andon_clear()); + } + if (challenged) { + BOOST_REQUIRE_EQUAL(t.success(), t.challenge("carol"_n, a.chain, a.token, epoch)); + BOOST_REQUIRE_EQUAL(t.success(), t.crank()); + BOOST_REQUIRE_EQUAL(before + released, t.liq_balance("alice"_n, token)); + BOOST_REQUIRE_EQUAL(t.success(), t.rule(request, !invalid)); + } else { + t.produce_block(fc::seconds(t.Window + 1)); + BOOST_REQUIRE_EQUAL(t.success(), t.push(t.BOND_ACCOUNT, t.bond_abi_ser, "carol"_n, "approve"_n, + mvo()("request_id", request))); + } + if (invalid) { + BOOST_REQUIRE_EQUAL(t.success(), t.crank()); + BOOST_REQUIRE_EQUAL(supply_before, t.supply(token)); + BOOST_REQUIRE_EQUAL(STATE_INVALID, t.envelope_row(a.chain, a.token, epoch)["state"].as_string()); + BOOST_REQUIRE_EQUAL(t.success(), t.bond_claim(request, "carol"_n)); + } else { + // Drain by advancing real queue budgets; bounded iteration catches stalls. + for (size_t drain = 0; drain < 6; ++drain) { + BOOST_REQUIRE_EQUAL(t.success(), t.set_epoch(++epoch)); + BOOST_REQUIRE_EQUAL(t.success(), t.crank()); + t.conservation(); + } + BOOST_REQUIRE_EQUAL(before + int64_t(amount), t.liq_balance("alice"_n, token)); + BOOST_REQUIRE_EQUAL(t.success(), t.bond_claim(request, "dave"_n)); + } + t.conservation(); + const uint64_t returned = (1 + random() % 3) * t.UNIT; + BOOST_REQUIRE_EQUAL(t.success(), t.desyndicate("alice"_n, returned, token)); + const auto outbound = queued_desyndication(t, ++return_id); + BOOST_REQUIRE_EQUAL(returned, outbound.amount().amount()); + external.freeze(); + BOOST_REQUIRE(!external.receive(outbound)); + BOOST_REQUIRE(!external.receive(outbound)); + external.clear(); + BOOST_REQUIRE(external.settle(outbound.request_id())); + BOOST_REQUIRE(!external.receive(outbound)); + t.conservation(); + BOOST_REQUIRE(untouched == other_state()); + ++epoch; + } + } + } +} + + +BOOST_FIXTURE_TEST_CASE(generic_parked_credit_yield_conversion_and_fee_rounded_return, generic_synd_tester) { + constexpr uint64_t Principal = 101 * UNIT + 17; + constexpr uint64_t Yield = 10 * UNIT; + constexpr uint32_t FeeBps = 37; + constexpr uint64_t PoolSeed = 1'000 * UNIT; + constexpr uint32_t Horizon = 86'400, DepthBps = 300; + constexpr int64_t ClipFloor = 1000; + BOOST_REQUIRE_EQUAL(success(), push(LIQ_ACCOUNT, liq_abi_ser, SYSIO_ACCOUNT, "setkicker"_n, mvo()("bps", 0))); + for (size_t i = 0; i < outposts.size(); ++i) { + BOOST_TEST_CONTEXT("chain=" << outposts[i].asset().chain) { + auto& external = outposts[i]; + const auto& a = external.asset(); + const auto token = sym(i); + const auto pair = symbol::from_string(i == 0 ? "9,POOLA" : "9,POOLB"); + external.donate(PoolSeed); + BOOST_REQUIRE_EQUAL(success(), create_pool(a.chain, a.token, pair, PoolSeed, PoolSeed, + 30, 0, Horizon, DepthBps, ClipFloor)); + BOOST_REQUIRE_EQUAL(success(), setconfig(a.chain, a.token, + {.synd_fee_bps = FeeBps, .desynd_fee_bps = FeeBps, .window_sec = Window})); + const auto private_key = fc::crypto::private_key::generate(a.kind == ChainKind::CHAIN_KIND_EVM + ? fc::crypto::private_key::key_type::em : fc::crypto::private_key::key_type::ed); + const auto key = private_key.get_public_key(); + const auto bytes = a.kind == ChainKind::CHAIN_KIND_EVM ? em_bytes(key) : ed_bytes(key); + sysio::opp::attestations::SyndicateLIQ deposit; + BOOST_REQUIRE(deposit.ParseFromString(external.deposit(bytes, Principal).second)); + sysio::opp::attestations::LIQYield report; + BOOST_REQUIRE(report.ParseFromString(external.yield(Yield).second)); + const auto digest = digest_of(a.chain); + constexpr uint32_t Epoch = 1; + BOOST_REQUIRE_EQUAL(success(), onsynd(a.chain, Epoch, digest, deposit.sequence(), a.kind, bytes, + a.token, Principal, MSGCH_ACCOUNT, deposit.total_syndicated())); + BOOST_REQUIRE_EQUAL(success(), onyield(a.chain, Epoch, digest, report.sequence(), report.epoch(), + a.token, Yield, MSGCH_ACCOUNT, report.total_syndicated())); + BOOST_REQUIRE_EQUAL(success(), closeenv(a.chain, Epoch, digest)); + BOOST_REQUIRE_EQUAL(0, pending(token)); + BOOST_REQUIRE_EQUAL(0u, parked_balance(a.token, a.kind, bytes)); + accept(envelope_row(a.chain, a.token, Epoch)["request_id"].as_uint64()); + BOOST_REQUIRE_EQUAL(success(), crank()); + const uint64_t fee = Principal * FeeBps / BPS_DENOMINATOR; + BOOST_REQUIRE_EQUAL(Principal - fee, parked_balance(a.token, a.kind, bytes)); + BOOST_REQUIRE_EQUAL(fee, feepot_balance(a.token)); + BOOST_REQUIRE_EQUAL(int64_t(Yield), pending(token)); + conservation(); + // Link notification crosses authex -> synd -> liq and consumes the parked row once. + BOOST_REQUIRE_EQUAL(success(), createlink("bob"_n, private_key, a.kind)); + BOOST_REQUIRE_EQUAL(int64_t(Principal - fee), liq_balance("bob"_n, token)); + BOOST_REQUIRE_EQUAL(0u, parked_balance(a.token, a.kind, bytes)); + BOOST_REQUIRE_EQUAL(success(), sweep("bob"_n, a.kind)); + BOOST_REQUIRE_EQUAL(int64_t(Principal - fee), liq_balance("bob"_n, token)); + BOOST_REQUIRE_EQUAL(success(), push(LIQ_ACCOUNT, liq_abi_ser, "alice"_n, "queueyield"_n, + mvo()("sym", token.to_symbol_code()))); + BOOST_REQUIRE_EQUAL(0, pending(token)); + conservation(); + produce_blocks(40); + BOOST_REQUIRE_EQUAL(success(), push(SWAP_ACCOUNT, swap_abi_ser, "alice"_n, "tickyield"_n, + mvo()("pair_token", pair.to_symbol_code()))); + const auto owed = liq_owed("bob"_n, token); + BOOST_REQUIRE_GT(owed, 0); + const auto wire_before = wire_balance("bob"_n); + BOOST_REQUIRE_EQUAL(success(), liq_claim("bob"_n, token)); + BOOST_REQUIRE_EQUAL(wire_before + owed, wire_balance("bob"_n)); + BOOST_REQUIRE_EQUAL(0, liq_owed("bob"_n, token)); + constexpr uint64_t Return = 3 * UNIT + 29; + BOOST_REQUIRE_EQUAL(success(), desyndicate("bob"_n, Return, token)); + const auto outbound = queued_desyndication(*this, i + 1); + BOOST_REQUIRE_EQUAL(Return - Return * FeeBps / BPS_DENOMINATOR, outbound.amount().amount()); + BOOST_REQUIRE(external.receive(outbound)); + BOOST_REQUIRE(!external.receive(outbound)); + conservation(); + } + } +} + +BOOST_FIXTURE_TEST_CASE(generic_custody_shortfall_freezes_and_repair_resumes, generic_synd_tester) { + auto& external = outposts.front(); + const auto& a = external.asset(); + external.lose(3 * UNIT); + sysio::opp::attestations::LIQYield report; + BOOST_REQUIRE(report.ParseFromString(external.yield(UNIT).second)); + const auto digest = digest_of("generic shortfall"); + BOOST_REQUIRE_EQUAL(success(), onyield(a.chain, 1, digest, report.sequence(), report.epoch(), a.token, + UNIT, MSGCH_ACCOUNT, report.total_syndicated())); + BOOST_REQUIRE(cord_pulled()); + BOOST_REQUIRE_EQUAL(1u, mismatch_rows().size()); + BOOST_REQUIRE_EQUAL(report.total_syndicated(), mismatch_rows().front()["reported"].as_uint64()); + BOOST_REQUIRE_EQUAL(uint64_t(supply(sym(0))), mismatch_rows().front()["expected"].as_uint64()); + BOOST_REQUIRE_EQUAL(success(), closeenv(a.chain, 1, digest)); + const auto id = envelope_row(a.chain, a.token, 1)["request_id"].as_uint64(); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi_ser, "dave"_n, "accept"_n, + mvo()("underwriter", "dave"_n)("request_id", id)("amount", UNIT))); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(0, pending(sym(0))); + external.donate(3 * UNIT); + BOOST_REQUIRE_EQUAL(success(), andon_clear()); + BOOST_REQUIRE_EQUAL(success(), crank()); + BOOST_REQUIRE_EQUAL(int64_t(UNIT), pending(sym(0))); + BOOST_REQUIRE_EQUAL(uint64_t(supply(sym(0)) + pending(sym(0))), external.custody()); + BOOST_REQUIRE(!cord_pulled()); + // Clearing the cord does not erase incident evidence or touch the other chain. + BOOST_REQUIRE_EQUAL(1u, mismatch_rows().size()); + BOOST_REQUIRE_EQUAL(0, pending(sym(1))); +} + +// Governance resolves truth without collateral; providers supply collateral and +// retain their challenge window. Compare the downstream economic trace, not the +// deliberately different bond state or escrow owner. +BOOST_AUTO_TEST_CASE(generic_governance_and_provider_release_have_identical_settlement) { + constexpr uint64_t Principal = 8 * external::Unit + 17; + constexpr uint64_t Yield = 2 * external::Unit; + constexpr uint64_t Return = external::Unit + 29; + constexpr uint32_t FeeBps = 37; + constexpr uint64_t Tranche = 3 * external::Unit; + constexpr uint64_t PrincipalFee = 2 * (Tranche * FeeBps / BPS_DENOMINATOR) + + (Principal - 2 * Tranche) * FeeBps / BPS_DENOMINATOR; + for (size_t i = 0; i < external::Assets.size(); ++i) { + for (const bool parked : {false, true}) { + std::vector> provider_trace; + for (const bool governance : {false, true}) { + BOOST_TEST_CONTEXT("chain=" << external::Assets[i].chain << " parked=" << parked + << " governance=" << governance) { + generic_synd_tester t; + auto& outpost = t.outposts[i]; + const auto& a = outpost.asset(); + const auto token = t.sym(i); + const auto private_key = fc::crypto::private_key::generate(a.kind == ChainKind::CHAIN_KIND_EVM + ? fc::crypto::private_key::key_type::em : fc::crypto::private_key::key_type::ed); + const auto key = private_key.get_public_key(); + const auto bytes = parked ? (a.kind == ChainKind::CHAIN_KIND_EVM ? t.em_bytes(key) : t.ed_bytes(key)) + : t.users[i]; + const auto holder = parked ? "bob"_n : "alice"_n; + BOOST_REQUIRE_EQUAL(t.success(), t.setconfig(a.chain, a.token, + {.synd_fee_bps = FeeBps, .desynd_fee_bps = FeeBps, + .synd_burst = 3 * external::Unit, .synd_refill = 3 * external::Unit, .window_sec = t.Window})); + const auto digest = t.digest_of("resolution parity"); + sysio::opp::attestations::SyndicateLIQ deposit; + sysio::opp::attestations::LIQYield yield; + BOOST_REQUIRE(deposit.ParseFromString(outpost.deposit(bytes, Principal).second)); + BOOST_REQUIRE(yield.ParseFromString(outpost.yield(Yield).second)); + BOOST_REQUIRE_EQUAL(t.success(), t.set_epoch(1)); + BOOST_REQUIRE_EQUAL(t.success(), t.onsynd(a.chain, 1, digest, deposit.sequence(), a.kind, + bytes, a.token, Principal, t.MSGCH_ACCOUNT, deposit.total_syndicated())); + BOOST_REQUIRE_EQUAL(t.success(), t.onyield(a.chain, 1, digest, yield.sequence(), yield.epoch(), + a.token, Yield, t.MSGCH_ACCOUNT, yield.total_syndicated())); + BOOST_REQUIRE_EQUAL(t.success(), t.closeenv(a.chain, 1, digest)); + const auto request = t.envelope_row(a.chain, a.token, 1)["request_id"].as_uint64(); + BOOST_REQUIRE_EQUAL(t.success(), t.crank()); + BOOST_REQUIRE_EQUAL(0, t.liq_balance(holder, token)); + BOOST_REQUIRE_EQUAL(0u, t.parked_balance(a.token, a.kind, bytes)); + BOOST_REQUIRE_EQUAL(0u, t.envelope_row(a.chain, a.token, 1)["released"].as_uint64()); + if (governance) { + BOOST_REQUIRE_NE(t.success(), t.push(t.BOND_ACCOUNT, t.bond_abi_ser, "alice"_n, + "rslvvalid"_n, mvo()("request_id", request))); + BOOST_REQUIRE_EQUAL(t.success(), t.rule(request, true)); + BOOST_REQUIRE_EQUAL(0u, t.bond_request(request)["bonded"].as_uint64()); + } else { + t.accept(request); + BOOST_REQUIRE_NE(t.success(), t.push(t.BOND_ACCOUNT, t.bond_abi_ser, "alice"_n, + "approve"_n, mvo()("request_id", request))); + } + std::vector> trace; + const auto capture = [&] { + t.conservation(); + trace.push_back({t.supply(token), t.pending(token), t.liq_balance(holder, token), + int64_t(t.parked_balance(a.token, a.kind, bytes)), int64_t(t.feepot_balance(a.token)), + int64_t(t.envelope_row(a.chain, a.token, 1)["released"].as_uint64()), + int64_t(outpost.custody())}); + }; + BOOST_REQUIRE_EQUAL(t.success(), t.andon_pull()); + BOOST_REQUIRE_EQUAL(t.success(), t.crank()); + BOOST_REQUIRE_EQUAL(0u, t.envelope_row(a.chain, a.token, 1)["released"].as_uint64()); + // conservation() also requires the cord clear, so capture after clear. + BOOST_REQUIRE_EQUAL(t.success(), t.andon_clear()); + for (uint32_t epoch = 2; epoch <= 6; ++epoch) { + BOOST_REQUIRE_EQUAL(t.success(), t.set_epoch(epoch)); + BOOST_REQUIRE_EQUAL(t.success(), t.crank()); + capture(); + BOOST_REQUIRE_EQUAL(t.success(), t.crank()); + capture(); // same epoch must not replenish the bucket + } + BOOST_REQUIRE_EQUAL(STATE_DONE, t.envelope_row(a.chain, a.token, 1)["state"].as_string()); + BOOST_REQUIRE_EQUAL(Principal + Yield, + t.envelope_row(a.chain, a.token, 1)["released"].as_uint64()); + if (!governance) { + t.produce_block(fc::seconds(t.Window + 1)); + BOOST_REQUIRE_EQUAL(t.success(), t.push(t.BOND_ACCOUNT, t.bond_abi_ser, "alice"_n, + "approve"_n, mvo()("request_id", request))); + BOOST_REQUIRE_EQUAL(t.success(), t.bond_claim(request, "dave"_n)); + } + if (parked) BOOST_REQUIRE_EQUAL(t.success(), t.createlink(holder, private_key, a.kind)); + BOOST_REQUIRE_EQUAL(0u, t.parked_balance(a.token, a.kind, bytes)); + BOOST_REQUIRE_EQUAL(int64_t(Principal - PrincipalFee), t.liq_balance(holder, token)); + BOOST_REQUIRE_EQUAL(PrincipalFee, t.feepot_balance(a.token)); + BOOST_REQUIRE_EQUAL(int64_t(Yield), t.pending(token)); + BOOST_REQUIRE_EQUAL(int64_t(t.Bootstrap), t.liq_balance("dave"_n, token)); + capture(); + BOOST_REQUIRE_EQUAL(t.success(), t.desyndicate(holder, Return, token)); + const auto outbound = queued_desyndication(t, 1); + BOOST_REQUIRE_EQUAL(a.kind, outbound.user().kind()); + BOOST_REQUIRE_EQUAL(std::string(bytes.begin(), bytes.end()), outbound.user().address()); + BOOST_REQUIRE_EQUAL(Return - Return * FeeBps / BPS_DENOMINATOR, outbound.amount().amount()); + BOOST_REQUIRE(outpost.receive(outbound)); + BOOST_REQUIRE(!outpost.receive(outbound)); + capture(); + if (governance) { + constexpr std::array Fields{ + "supply", "pending yield", "recipient balance", "parked credit", + "fee pot", "released", "external custody"}; + BOOST_REQUIRE_EQUAL(provider_trace.size(), trace.size()); + for (size_t checkpoint = 0; checkpoint < trace.size(); ++checkpoint) + for (size_t field = 0; field < Fields.size(); ++field) + BOOST_TEST_CONTEXT("checkpoint=" << checkpoint << " field=" << Fields[field]) { + BOOST_REQUIRE_EQUAL(provider_trace[checkpoint][field], trace[checkpoint][field]); + } + } else provider_trace = trace; + } + } + } + } +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tools/tests/test_verify_depot_flow_coverage.py b/contracts/tools/tests/test_verify_depot_flow_coverage.py new file mode 100644 index 0000000000..cfec64c5da --- /dev/null +++ b/contracts/tools/tests/test_verify_depot_flow_coverage.py @@ -0,0 +1,58 @@ +import importlib.util +from pathlib import Path +import tempfile +import unittest + +MODULE = Path(__file__).resolve().parents[1] / "verify_depot_flow_coverage.py" +spec = importlib.util.spec_from_file_location("coverage_map", MODULE) +coverage = importlib.util.module_from_spec(spec) +spec.loader.exec_module(coverage) + + +class CoverageMapTests(unittest.TestCase): + def data(self): + return {"flows": [{"name": "flow-example", "wire_behavior": "real depot actions", + "cluster_only": "external execution", "tests": ["suite/case"]}]} + + def test_missing_native_case_fails(self): + with self.assertRaisesRegex(ValueError, "missing native test"): + coverage.validate(self.data(), {}) + + def test_new_enabled_flow_requires_a_map_entry(self): + with self.assertRaisesRegex(ValueError, "unmapped=.*flow-new"): + coverage.validate(self.data(), {"suite/case": "tests.cpp"}, ["flow-example", "flow-new"]) + + def test_disabled_flow_cannot_remain_claimed_as_enabled(self): + with self.assertRaisesRegex(ValueError, "no longer enabled"): + coverage.validate(self.data(), {"suite/case": "tests.cpp"}, []) + + def test_boundary_and_duplicate_flow_are_rejected(self): + data = self.data() + data["flows"][0]["cluster_only"] = "" + with self.assertRaisesRegex(ValueError, "incomplete boundary"): + coverage.validate(data, {"suite/case": "tests.cpp"}) + data = self.data() + data["flows"] *= 2 + with self.assertRaisesRegex(ValueError, "duplicate flow"): + coverage.validate(data, {"suite/case": "tests.cpp"}) + + def test_inventory_respects_suite_boundaries_and_ignores_comments(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "contracts/tests" + source.mkdir(parents=True) + (source / "example.cpp").write_text(""" + // BOOST_AUTO_TEST_CASE(commented) + BOOST_AUTO_TEST_SUITE(first) + BOOST_FIXTURE_TEST_CASE(one, fixture) {} + BOOST_AUTO_TEST_SUITE_END() + /* BOOST_AUTO_TEST_CASE(commented_again) */ + BOOST_AUTO_TEST_SUITE(second) + BOOST_AUTO_TEST_CASE(two) {} + BOOST_AUTO_TEST_SUITE_END() + """) + self.assertEqual(set(coverage.inventory(root)), {"first/one", "second/two"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/contracts/tools/verify_depot_flow_coverage.py b/contracts/tools/verify_depot_flow_coverage.py new file mode 100644 index 0000000000..5c016a1d5f --- /dev/null +++ b/contracts/tools/verify_depot_flow_coverage.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Validate the reviewed flow/native-test map; optionally detect sibling flow drift.""" +import argparse +import json +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[2] +MAP = ROOT / "contracts/tests/depot_flow_coverage.json" +MACRO = re.compile(r"BOOST_(?:AUTO|FIXTURE|DATA)_TEST_(SUITE_END|SUITE|CASE)\s*\(\s*([A-Za-z_][A-Za-z_0-9]*)?") + + +def inventory(root): + result = {} + for source in sorted((root / "contracts/tests").glob("*.cpp")): + # Ignore commented-out declarations. This is an inventory check, not a C++ parser. + text = re.sub(r"/\*.*?\*/|//[^\n]*", "", source.read_text(), flags=re.S) + stack = [] + for kind, name in MACRO.findall(text): + if kind == "SUITE_END": + stack.pop() + elif kind == "SUITE": + stack.append(name) + elif stack: + test = "/".join([*stack, name]) + if test in result: + raise ValueError(f"duplicate test: {test}") + result[test] = str(source.relative_to(root)) + return result + + +def validate(data, tests, enabled=None): + flows = data["flows"] + names = [flow["name"] for flow in flows] + if len(names) != len(set(names)): + raise ValueError("duplicate flow entry") + if enabled is not None and set(names) != set(enabled): + raise ValueError(f"flow drift: unmapped={sorted(set(enabled) - set(names))}, " + f"no longer enabled={sorted(set(names) - set(enabled))}") + selected = set() + for flow in flows: + if not flow.get("wire_behavior") or not flow.get("cluster_only") or not flow.get("tests"): + raise ValueError(f"incomplete boundary description: {flow['name']}") + for test in flow["tests"]: + if test not in tests: + raise ValueError(f"{flow['name']}: missing native test {test}") + selected.add(test) + return sorted(selected) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tools-root", type=Path, help="wire-tools-ts checkout to compare enabled test scripts") + parser.add_argument("--filter", action="store_true", help="print Boost --run_test value only") + args = parser.parse_args() + enabled = None + if args.tools_root: + packages = list((args.tools_root / "packages").glob("flow-*/package.json")) + if not packages: + parser.error("no flow package manifests found") + enabled = [p.parent.name for p in packages if json.loads(p.read_text()).get("scripts", {}).get("test")] + try: + data = json.loads(MAP.read_text()) + selected = validate(data, inventory(ROOT), enabled) + except (ValueError, KeyError) as error: + parser.exit(1, f"{error}\n") + print(":".join(selected) if args.filter else + f"{len(data['flows'])} flows mapped to {len(selected)} native cases; references and boundaries valid") + + +if __name__ == "__main__": + main() diff --git a/docs/becoming-a-block-producer.md b/docs/becoming-a-block-producer.md index ed6918cdd0..caf87df47a 100644 --- a/docs/becoming-a-block-producer.md +++ b/docs/becoming-a-block-producer.md @@ -9,21 +9,21 @@ There are two ways onto the schedule, and this guide covers the second: - **Genesis producers** are placed in the schedule when a chain is bootstrapped. They are registered as *bootstrapped* operators, which only the registry contract itself can do. -- **Collateral-backed producers** post a bond on the outpost chains and earn a schedule position - by rank. That is the open path, and it is the one described here. +- **Collateral-backed producers** post a bond in `sysio.opreg` on WIRE and earn a schedule + position by rank. That is the open path, and it is the one described here. ## What you need before you start | | | |---|---| | A WIRE account | The account that will produce blocks. Its `active` permission signs everything below. | -| An Ethereum wallet | Funded with the required bond plus gas. | -| A Solana keypair | Funded with the required bond plus fees. | +| An Ethereum key and a Solana key | Used only to link your WIRE account to each active outpost chain (step 1). No collateral is posted on those chains. | +| The required collateral, held on WIRE | The tokens `sysio.opreg`'s configuration lists, typically shadow LIQ (`LIQETH`, `LIQSOL`) and/or `WIRE`. | | A machine running `nodeop` | Reachable by the peer network, with your signing keys available to it. | -You must bond on **every** chain the network requires, not just one. The requirement lives in -`sysio.opreg`'s configuration as a per-chain minimum bond, and eligibility takes the **minimum** -across all of them. Posting extra on the cheapest chain buys you nothing. +You must bond **every** token the network requires, not just one. The requirement lives in +`sysio.opreg`'s configuration (`opconfig.req_prod_collat`) as a minimum per token, and +eligibility needs every one of them met. Posting extra of one token buys you nothing for another. ## Step 1 — Link your outpost addresses @@ -31,9 +31,9 @@ across all of them. Posting extra on the cheapest chain buys you nothing. sysio.authex::createlink(chain_kind, account, sig, pub_key, nonce) ``` -Sign this once per chain, with your Ethereum and Solana keys respectively. The link is what makes -a deposit you send on an outpost attributable to your WIRE account; without it the chain has no way -to know the bond is yours. +Sign this once per active outpost chain, with your Ethereum and Solana keys respectively. +`regoperator` refuses an account that lacks a link for any active outpost chain, and the links are +what the depot publishes for you in the `OPERATORS` roster it sends each outpost. The `nonce` is a millisecond timestamp and is rejected if it is more than ten minutes old, so generate it at signing time. @@ -51,24 +51,21 @@ the bootstrap process explicitly pushes those privileged `regoperator` actions a You are now registered but not yet eligible. Your status stays `UNKNOWN` until the bond arrives. -## Step 3 — Post your collateral on each outpost +## Step 3 — Post your collateral on WIRE -Deposit on the outpost chains themselves, signed by the wallets you linked in step 1: +Read `opconfig.req_prod_collat` for the tokens and minimums, hold those tokens on WIRE, and bond +each one: -- **Ethereum** — `OperatorRegistry.deposit(...)` -- **Solana** — the outpost program's `deposit` instruction - -Each deposit travels to WIRE over the cross-chain protocol and credits your balance in -`sysio.opreg`. +``` +sysio.opreg::deposit(account, token_code, amount) +``` -If the configured requirements include the native WIRE pair, separately call -`sysio.opreg::deposit(account, token_code, amount)` on WIRE with `token_code` `WIRE`, signed by -your WIRE account. This native action requires an existing non-bootstrapped operator row, -transfers the token inline, and credits only the matching WIRE-chain pair; it does not replace -the Ethereum or Solana outpost deposits. The same action bonds a shadow LIQ symbol such as -`LIQETH` or `LIQSOL` held on WIRE, one `token_code` per call. +Signed by your WIRE account, one `token_code` per call: `WIRE` or a shadow LIQ symbol such as +`LIQETH` or `LIQSOL`. The action requires an existing non-bootstrapped operator row, transfers the +token into `sysio.opreg` inline, and credits your balance. Collateral is held only on WIRE; +nothing is deposited on the outpost chains. -When every required chain is at or above its minimum, your operator status flips to `ACTIVE` on +When every required token is at or above its minimum, your operator status flips to `ACTIVE` on its own. No producer rank exists yet: the first score is written only after the producer row and active finalizer key are created in steps 4 and 5. @@ -176,7 +173,7 @@ arrive. Nobody has to vote them out, and there is no flag day. | Factor | What it measures | |---|---| -| Collateral | Your bond divided by the required minimum, taken as the **minimum** across every required chain. Linear and uncapped, so more collateral always outranks less. | +| Collateral | Your bond divided by the required minimum, taken as the **minimum** across every required token. Linear and uncapped, so more collateral always outranks less. | | Participation | Falls with each consecutive unserved round and recovers when you serve one. | | Snapshot service | Snapshot attestations that reached quorum in the current pay period. Weighted at a tenth of collateral, so it separates producers the bond has left tied rather than outranking a larger bond. | @@ -266,17 +263,11 @@ under it. It is not applied only to rounds missed from that point on. simply hold no schedule position. `regproducer` brings you back at the position your collateral earns after rechecking current admission; a minimum raised while you were parked requires a top-up first. -- **Withdraw** from the chain that holds the bond. An outpost bond is released through that - outpost's own withdrawal entry point, the counterpart of the deposit you made in step 3, which - travels to WIRE and settles against your registry balance. - `sysio.opreg::withdraw(account, token_code, amount)` is **not** that path: it applies only to - your WIRE-native balance in `token_code` (`WIRE` or a shadow LIQ symbol), so calling it for an - Ethereum or Solana bond fails for insufficient balance - and leaves the outpost collateral untouched. Once a WIRE-native withdrawal flushes, the funds - are credited to a per-token claim that you pull with `sysio.opreg::claimremit(account, - token_code)`. Either way the request is queued rather than immediate, and `cancelwtdw` - cancels it before it flushes. Once your balance falls below the minimum on any required chain you - leave `ACTIVE` and the schedule drops you at the next rebuild. +- **Withdraw** with `sysio.opreg::withdraw(account, token_code, amount)`. The request is queued + rather than immediate (`cancelwtdw` cancels it before it flushes); once it flushes at an epoch + advance, the funds are credited to a per-token claim that you pull with + `sysio.opreg::claimremit(account, token_code)`. Once your balance falls below the minimum for + any required token you leave `ACTIVE` and the schedule drops you at the next rebuild. - **Collect shadow yield.** Bonded shadow LIQ keeps earning WIRE yield: each bonded unit earns what one unit of that shadow token earns in `sysio.liq`, from the moment it is bonded until it leaves your balance. `sysio.opreg::claimyield(account, token_code)` credits what you have diff --git a/docs/opp-two-phase-dispatch-design.md b/docs/opp-two-phase-dispatch-design.md index b87924974c..90ffe689e8 100644 --- a/docs/opp-two-phase-dispatch-design.md +++ b/docs/opp-two-phase-dispatch-design.md @@ -3,12 +3,18 @@ Status: design, approved 2026-08-10. Supersedes the fused terminal-call design currently on `feat/resumable-opp-dispatch` (wire-sysio #552, wire-solana #419). +Cross-chain swaps through outposts (`SWAP_REMIT` / `SWAP_REVERT`) and outpost-side +operator collateral (`OPERATOR_ACTION` remits) have since been removed. The +remit-class inbound effect on Solana today is the `DESYNDICATE_LIQ` payout; the +dispatch mechanics below are unchanged by that. + ## Problem A Solana transaction cannot carry the effect accounts for every attestation in an OPP envelope. The packet limit is 1232 bytes serialized and every dynamic account -costs 32 bytes in the message, so a large envelope — many SPL swap remits across -distinct reserves — needs more accounts than one transaction can declare. Only +costs 32 bytes in the message, so a large envelope — many attestations whose +effects each touch their own accounts (desyndication payouts to distinct +recipients, for example) — needs more accounts than one transaction can declare. Only the relay can size a batch, because only the relay supplies the accounts and therefore knows the transaction's real byte cost. @@ -260,10 +266,9 @@ deliberately deleted the SEC-94 estimator allow-list, which was correct; the scaffolding to learn `chain.kind`, so it fell out as collateral rather than by decision. Restore the bare registration assert with no estimator and no `kind` branch. Separately, `buildenv`'s chains fetch now uses the bare `.get()` overload -and aborts with `"key not found"`; give it the message overload. Three stale -references describe the deleted guard and must be corrected or a future reader -will conclude msgch is the backstop: `sysio.reserv.cpp:93-95`, -`sysio.msgch_tests.cpp:164-170`, `sysio.reserv_tests.cpp:411-414`. +and aborts with `"key not found"`; give it the message overload. Any remaining +comment or test that describes the deleted guard (`sysio.msgch_tests.cpp` carried +one) must be corrected, or a future reader will conclude msgch is the backstop. Any change under `contracts/**` runs `contracts_unit_test -- --sys-vm` before the commit lands, and follows the rebuild + WASM/ABI copy sequence @@ -308,7 +313,8 @@ Order: nonzero cursor, stall break, round exhaustion alarming, and the terminal-shape packet-limit measurement. - Contracts: `contracts_unit_test -- --sys-vm` for the `queueout` change. -- Flow: a SOL swap-remit flow whose envelope needs ≥ 2 dispatch rounds, asserting +- Flow: a SOL flow whose inbound envelope needs ≥ 2 dispatch rounds (for example + many desyndication payouts in one epoch), asserting the epoch does not advance until the cursor drains. Run via the canonical pair (`run-flow.mjs` + `flow-heartbeat-monitor.mjs`) per `run-flows-via-canonical-scripts.md`. diff --git a/docs/platform-bootstrap-config.md b/docs/platform-bootstrap-config.md index ae1fbca36f..95444d6ed4 100644 --- a/docs/platform-bootstrap-config.md +++ b/docs/platform-bootstrap-config.md @@ -1,22 +1,14 @@ # Platform bootstrap configuration -Launch-day chains, tokens, reserves, and swap settings are described by a -single human-authored JSON file validated against a protobuf schema. The -bootstrap tool replays that file onto the chain inside the epoch-0 bootstrap -window, replacing the dataset that was previously hard-coded in the cluster -tooling. - -> This is the WIRE **platform** bootstrap config — the launch-day registry -> state (chains, tokens, reserves, global swap settings). The swap reserves it -> seeds are one part of that state; it is not itself a DEX. (Earlier drafts -> called the top-level message `DexConfig`; it is now `BootstrapPlatformConfig`.) - -This addresses three launch needs: +Launch-day chains, tokens, shadow-liq yield pools and pre-launch syndicated +positions are described by a single human-authored JSON file validated against +a protobuf schema. The bootstrap tool replays that file onto the chain inside +the epoch-0 bootstrap window (`sysio.epoch` `current_epoch_index == 0`), where +every registration is privilege-gated and lands active inline. | Need | Artifact | |---|---| -| A T5 allocation set aside to back the WIRE side of reserves | `BootstrapPlatformConfig.t5_reserve_allocation` — a genesis-config WIRE amount; config + bootstrap arithmetic, **no contract change** | -| A T5 allocation set aside for launch DEX integration | `BootstrapPlatformConfig.t5_dex_allocation` — reserved earmark (default 0); the on-chain DEX-seeding mechanism is TBD (launch total set by Kyle / Ken) | +| A T5 allocation set aside to back the WIRE side of the launch yield pools | `BootstrapPlatformConfig.t5_dex_allocation` — drained by `sysio.liq::regliqpool` | | A file the bootstrap tool reads for these settings | `etc/config/dex/dex-config.*.json` | | A defined input shape for that file | `libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto` → message `BootstrapPlatformConfig` | @@ -31,29 +23,30 @@ canonical protobuf-JSON encoding of `BootstrapPlatformConfig`. - `etc/config/dex/dex-config.launch.example.json` — strawman mainnet launch config (placeholder economics; mainnet token addresses marked VERIFY-BEFORE-LAUNCH). -- `etc/config/dex/dex-config.dev.json` — 1:1 mirror of the dev-cluster dataset - (9 tokens, 8 reserves), proving the schema carries the full set and driving - the test. +- `etc/config/dex/dex-config.dev.json` — the dev-cluster dataset (3 chains, + 9 tokens, 2 liq pools, 3 syndications). - `libraries/opp/test/test_bootstrap_platform_config.cpp` — strict-parse + invariant test in the `test_opp` binary. +- `contracts/tests/sysio.synd_tests.cpp` — parses the dev config the same + strict way to drive its launch-replay cases. ## Schema -`BootstrapPlatformConfig` holds the schema version, a deployment label, the two -T5 earmarks (`t5_reserve_allocation`, `t5_dex_allocation`), and repeated -`ChainSpec` / `TokenSpec` / `ReserveSpec` plus a single `UwritConfig`. +`BootstrapPlatformConfig` holds the schema version, a deployment label, the T5 +earmarks, and repeated `ChainSpec` / `TokenSpec` / `LiqPoolSpec` / +`SyndicationSpec`. The spec messages deliberately differ from the registry carriers in -`sysio/opp/types/types.proto` (`Chain` / `Token` / `ChainToken` / `Reserve`), -which are wire messages with packed-uint64 codes, raw `bytes` addresses, and -lifecycle fields that are outputs. A hand-authored config wants the opposite: +`sysio/opp/types/types.proto` (`Chain` / `Token` / `ChainToken`), which are wire +messages with packed-uint64 codes, raw `bytes` addresses, and lifecycle fields +that are outputs. A hand-authored config wants the opposite: -- **Codes are strings** (`"ETHEREUM"`, `"USDC"`, `"PRIMARY"`); the tool packs +- **Codes are strings** (`"ETHEREUM"`, `"USDC"`, `"LIQETH"`); the tool packs them via `slug_name` (`[A-Z][A-Z0-9_]{0,7}` -- a code must START with a letter, ≤ 8 chars). -- **Addresses are strings** in chain-native display form (`0x`-hex for EVM, - base58 for SVM) so each is verifiable against a block explorer. `bytes` would - render as base64 in JSON. +- **Addresses and pubkeys are strings** in chain-native display form (`0x`-hex + for EVM, base58 for SVM) so each is verifiable against a block explorer. + `bytes` would render as base64 in JSON. - **Amounts are uint64 subunits** (9-decimal unless `precision` says otherwise). Proto3 canonical JSON renders 64-bit integers as **quoted strings** — amounts must be quoted in authored JSON, since launch-scale @@ -71,108 +64,74 @@ lifecycle fields that are outputs. A hand-authored config wants the opposite: |---|---| | `ChainSpec` | `sysio.chains::regchain(kind, code, external_chain_id, name, description)` | | `TokenSpec` | `sysio.tokens::regtoken(kind, code, symbol_name, description, precision, address)` then `sysio.tokens::regctok(chain_code, token_code, contract_addr, is_native)` | -| `ReserveSpec` | `sysio.reserv::regreserve(chain_code, token_code, reserve_code, name, description, initial_chain_amount, initial_wire_amount, source_token_precision, connector_weight_bps, is_private, owner)` — `source_token_precision` is **not** a `ReserveSpec` field: it comes from the referenced `TokenSpec.precision` (see below) | -| `UwritConfig` | `sysio.uwrit::setconfig(fee_bps, collateral_lock_duration_ms, min_fromwire_amount, fromwire_revert_fee_bps, uwreq_pending_timeout_epochs, uwreq_retention_epochs)` — the spec pins the first two; the caller supplies the rest (see below) | -| `t5_reserve_allocation` | none — feeds the `setemitcfg` arithmetic below | -| `t5_dex_allocation` | none yet — reserved earmark; carved out of T5 alongside `t5_reserve_allocation` once the DEX-seeding path lands | - -The `regreserve` signature (with `is_private` + `owner`) and the ms-based -`setconfig` are the reserve-and-swap-beta surface; this config slots directly -onto them. `owner` is a WIRE account name (`sysio::name`): empty for public -reserves, the owning account for private ones. - -`regreserve`'s eleven-argument signature includes `source_token_precision` as its -**eighth** argument, which no `ReserveSpec` field supplies. It is the -**depot-frame precision of the paired -token** — i.e. the referenced `TokenSpec.precision`, which is itself -`min(native precision, 9)`. So the mapping is a lookup, not a new config field: -resolve `(chain_code, token_code)` to its `TokenSpec` and pass that spec's -`precision`. `regreserve` rejects a value above 9 (`WIRE_PRECISION`) with -*"source_token_precision exceeds the depot frame (9) — the outpost must downscale -to min(native, 9)"*, which is why V4 bounds `precision` at 9 rather than 18: -a token declared at, say, 18 would satisfy a wider validator and then abort the -irreversible bootstrap at `regtoken`/`regreserve`. Tokens whose native precision -exceeds the frame (ETH at 18) declare the frame value and are downscaled at the -outpost boundary. - -`UwritConfig` carries only `fee_bps` and `collateral_lock_duration_ms`, while -`setconfig` takes six arguments. The remaining four — -`min_fromwire_amount`, `fromwire_revert_fee_bps`, `uwreq_pending_timeout_epochs`, -`uwreq_retention_epochs` — are **not part of this spec**: a bootstrap caller -passes the contract's `uw_config` in-struct defaults (5 WIRE floor, 500 bps -revert fee, and the two uwreq lifecycle windows) unless it has a reason to -override them. - -`fee_bps` is the **network** fee and not the whole effective swap fee. Each -participating non-WIRE leg's reserve independently charges its own -`owner_fee_bps` off the same WIRE leg; that rate is **not** in `ReserveSpec` and -is set post-bootstrap via `sysio.reserv::setrsvfee`. The network fee itself -splits 50/50 between the winning underwriter and a rewards pool -(`sysio.reserv::FEE_UNDERWRITER_SHARE_BPS`), and that pool splits again by the -optional `reservcfg` dial `fee_emissions_share_bps` — which nothing seeds, so it -reads zero until `sysio.reserv::setconfig` first persists a row. - -## T5 reserve earmark - -With `A` = the launch T5 allotment, `E` = `t5_reserve_allocation`, and -`W` = Σ `reserves[].initial_wire_amount`: - -`regreserve` drains each reserve's WIRE side from the `sysio` emissions -treasury at registration (a real inline `sysio.token::transfer` authorized by -`sysio@active`). The emissions formula gates on +| `LiqPoolSpec` | `sysio.liq::regliqpool(chain_code, token_code, pair_symbol, initial_chain_amount, initial_wire_amount, fee, locked_shares, conversion_horizon_sec, depth_cap_bps, clip_floor)` — mints the LCO shadow to `sysio`, deposits it with the WIRE side from the `sysio` treasury into `sysio.swap`, and creates the pair with the shadow as its yield leg. The shadow symbol must already exist; exactly one pool per shadow | +| `SyndicationSpec` | `sysio.synd::importsynd(chain_code, token_code, credits[] {pubkey, amount})`, batched by the tool; `sysio.synd::importdone` closes the import | +| `t5_dex_allocation` | none — feeds the `setemitcfg` arithmetic below | + +`TokenSpec.precision` is the **depot-frame precision**, `min(native precision, +9)`. `sysio.tokens::regtoken` rejects anything above 9, which is why V4 bounds +`precision` at 9 rather than 18: a token declared at, say, 18 would satisfy a +wider validator and then abort the irreversible bootstrap at `regtoken`. Tokens +whose native precision exceeds the frame (ETH at 18) declare the frame value and +are downscaled at the outpost boundary. + +`SyndicationSpec.pubkey` is the identity `SyndicateLIQ.user` carries: base58 of +the 32-byte Ed25519 key on SVM, 0x-hex of the 33-byte **compressed** secp256k1 +point on EVM (never the 20-byte address). Each credit mints to the account that +pubkey has linked through `sysio.authex`, or parks it until the link is made. + +### Defined but not consumed + +`bootstrap.proto` still defines `ReserveSpec`, `UwritConfig`, +`BootstrapPlatformConfig.reserves`, `BootstrapPlatformConfig.uwrit` and +`t5_reserve_allocation`, and both JSON files still carry values for them. The +contracts they targeted (`sysio.reserv`, `sysio.uwrit`) have been removed, so no +bootstrap action consumes them; only `test_bootstrap_platform_config.cpp` still +parses and validates them (V6–V9). + +## T5 DEX earmark + +With `A` = the launch T5 allotment, `D` = `t5_dex_allocation`, and +`W` = Σ `liq_pools[].initial_wire_amount`: + +`regliqpool` drains each pool's WIRE side from the `sysio` emissions treasury at +registration. The emissions formula gates on `t5_distributable − t5_floor − total_distributed`, and the per-epoch readiness gate independently checks the real treasury balance, so the earmark must sit **outside** the distributable pool: -1. Config invariant: `0 < W ≤ E`. -2. Bootstrap sets `setemitcfg.t5_distributable = A − E` (`t5_floor` unchanged — +1. Config invariant: `W ≤ D`, and `D > 0` whenever any pool is seeded. +2. Bootstrap sets `setemitcfg.t5_distributable = A − D` (`t5_floor` unchanged — the floor is inside the distributable pool). -3. End-of-bootstrap `sysio` balance ≥ `t5_distributable + E`; after reserves +3. End-of-bootstrap `sysio` balance ≥ `t5_distributable + D`; after the pools drain `W`, balance ≥ `t5_distributable`, so the readiness gate never trips - because of reserve funding. -4. Remainder `E − W` stays in the treasury, inert and outside emissions + because of pool funding. +4. Remainder `D − W` stays in the treasury, inert and outside emissions accounting. -Putting `E` *inside* `t5_distributable` instead would make the emissions math +Putting `D` *inside* `t5_distributable` instead would make the emissions math count WIRE that has physically left the treasury — the readiness gate blocks at launch scale, and effective emissions headroom silently shrinks by `W`. Hence the outside-the-pool earmark. -Each bootstrap `regreserve` transaction must carry `sysio@active` (for the -treasury drain) alongside `sysio.reserv@active`. - -## T5 DEX earmark - -`t5_dex_allocation` (`D`) is a second, parallel carve-out of the T5 allotment, -reserved for launch DEX integration — at least one DEX is seeded at launch, with -more added later by council vote. It follows the same outside-the-pool rule as -the reserve earmark, so the full T5 carve-out is `E + D` and -`t5_distributable = A − E − D`. - -It is **reserved**: the on-chain DEX-seeding mechanism is not finalized, so the -bootstrap tool does not yet drain `D` — the field is carried for forward -compatibility and defaults to `0` (earmark disabled). The launch total is a -per-deployment economics decision (Kyle / Ken own the figure). When the seeding -path lands, the `A − E` arithmetic above generalizes to `A − E − D` with no -shape change to this config. - ## Validation Parsing is **strict**: unknown / misspelled keys are rejected, not dropped, -because the file is hand-authored and drives irreversible actions. A validator -(reproduced in the test, and to be shared with the bootstrap tool) enforces: +because the file is hand-authored and drives irreversible actions. The +validator in `test_bootstrap_platform_config.cpp` enforces: | # | Invariant | |---|---| | V1 | `schema_version == 1`; `network` non-empty | | V2 | every code is a valid slug: `[A-Z][A-Z0-9_]{0,7}` -- leading character must be a letter, ≤ 8 chars | | V3 | chain codes unique; exactly one `CHAIN_KIND_WIRE` chain, code `WIRE` | -| V4 | token codes unique; `chain_code` declared; `precision` ∈ 1..9 (the depot frame — `sysio.tokens::regtoken` rejects anything higher, so a wider bound here would pass validation and then fail mid-bootstrap); native ⇔ kind `NATIVE` + empty address; non-native address well-formed for the chain kind (EVM `0x`+40 hex; SVM base58 → 32 bytes) | +| V4 | token codes unique; `chain_code` declared; `precision` ∈ 1..9 (the depot frame); native ⇔ kind `NATIVE` + empty address; non-native address well-formed for the chain kind (EVM `0x`+40 hex; SVM base58 → 32 bytes) | | V5 | exactly one native token per non-depot chain | -| V6 | reserve `(chain, token, code)` unique; references a declared binding; not on the depot; `0 < connector_weight_bps ≤ 9999`; amounts > 0 | -| V7 | Σ `initial_wire_amount` ≤ `t5_reserve_allocation` > 0 | -| V8 | `is_private` ⇒ `owner` a valid account name; else `owner` empty | -| V9 | `uwrit` present; `fee_bps ≤ 9999`; lock duration > 0 | +| V6–V9 | checks on the unconsumed `reserves` / `uwrit` / `t5_reserve_allocation` fields (see [Defined but not consumed](#defined-but-not-consumed)) | +| V10 | `t5_dex_allocation > 0` when any liq pool is seeded; Σ `liq_pools[].initial_wire_amount` ≤ `t5_dex_allocation` | +| V11 | each liq pool references a declared `TOKEN_KIND_LIQ` token on its chain and is unique; `pair_symbol` 1..7 characters `[A-Z]`; seeds > 0; `fee ≤ 9999`; `conversion_horizon_sec > 0`; `depth_cap_bps` ∈ 1..10000; `clip_floor > 0` | +| V12 | each syndication references a declared liq token on its chain; `pubkey` fits the chain family; `amount > 0` | +| V13 | a non-zero `custody_total` equals the pool's `initial_chain_amount` plus the sum of that token's `syndications` | Run the test: @@ -183,24 +142,17 @@ ninja -C build test_opp ## Follow-ups -- Wire the bootstrap tool to consume `BootstrapPlatformConfig` (the cluster phases become a - loop over the parsed config, same order: chains → tokens → bindings → - reserves → uwrit), injecting per-deployment contract addresses from - deployment artifacts into the dev config. -- Regenerate the TS/Solidity/Solana model bundles so the tool imports the - generated `BootstrapPlatformConfig` type. -- Fill in launch economics (`t5_reserve_allocation` + per-reserve amounts) and - verified mainnet addresses; freeze `dex-config.mainnet.json`. +- Wire the bootstrap tool to consume `BootstrapPlatformConfig` (the cluster + phases become a loop over the parsed config, in order: chains → tokens → + bindings → liq pools → syndication import), injecting per-deployment contract + addresses from deployment artifacts into the dev config. +- Fill in launch economics (`t5_dex_allocation` + per-pool amounts) and verified + mainnet addresses; freeze `dex-config.mainnet.json`. ## Open questions -- The earmark `E` and per-reserve amounts are economics decisions - (placeholders today). -- Launch token set: Solana SPL stables? Liquid-staking tokens (addresses exist - only once those contracts deploy)? -- Whether to seed any private reserves directly at launch, or create them all - post-launch via the authex-gated outpost flow (default assumption: all - post-launch). +- The earmark `D` and per-pool amounts are economics decisions (placeholders + today). - Whether to unify the emissions genesis numbers (`A`, `t5_distributable`, - `t5_floor`) with this config so the `A − E` arithmetic is checkable in one + `t5_floor`) with this config so the `A − D` arithmetic is checkable in one place. diff --git a/docs/sysio-synd.md b/docs/sysio-synd.md new file mode 100644 index 0000000000..0cbbd4e735 --- /dev/null +++ b/docs/sysio-synd.md @@ -0,0 +1,592 @@ +# `sysio.synd` + +`sysio.synd` is the depot's syndication contract. It owns every rule between an outpost's +`SYNDICATE_LIQ` or `LIQ_YIELD` report and the shadow LIQ a holder receives, and every rule for +sending shadow LIQ back to an outpost with `DESYNDICATE_LIQ`. It is the only account that may mint and +burn in `sysio.liq`. The contract is `contracts/sysio.synd`; the specification is §4 of the +syndication underwriting spec. Underwriting itself is `sysio.bond`'s job (see +[`sysio-bond-integration.md`](sysio-bond-integration.md)). + +## The queue + +Nothing an outpost reports is paid out on arrival. Every syndication waits in a queue until the +envelope that carried it has been underwritten. + +### Intake + +`sysio.msgch` decodes an accepted inbound envelope and, for each syndication and yield report in it, +sends `onsynd` or `onyield`, then `closeenv` after the last one. All three carry the outpost, the depot +epoch and the envelope's canonical digest. + +- `onsynd` mints the amount into `sysio.synd`'s own `sysio.liq` holder row and adds a SYNDICATION item + for the syndicating pubkey, linked or not. The item starts earning WIRE at once. +- `onyield` adds a YIELD item. It mints nothing. +- Both add to the `(outpost, token, epoch)` row in `envelopes` and to the `(outpost, token)` running + sums in `ledger`. +- Both compare the outpost custody the message carries with the depot's outstanding shadow first + ([The solvency check](#the-solvency-check)); a shortfall is recorded and pulls the cord, and the + message is held all the same. +- `closeenv` moves the envelope's rows from OPEN to WAITING and runs one step of the queue, with a + small fixed budget (`CLOSEENV_QUEUE_LIMIT`, 16 units of work). + +These three run inside `sysio.msgch`'s consensus transaction and never throw. A message that breaks a +rule (a pubkey of the wrong key family, a token with no shadow or of another outpost, an amount out of +range or past the shadow's headroom, a closed envelope, a replayed sequence) is dropped with a `DROP` +line on the console and consumes no sequence. So is a message that would leave its envelope impossible +to underwrite: one that takes the envelope's syndicated plus yield total, rounded up to `sysio.bond`'s +increment, past the asset range, and a yield report that takes the envelope's yield total past the +shadow's headroom. An envelope with no syndication or yield report writes nothing. + +### One step + +`closeenv` runs a bounded step inline; anyone may run `crank(limit)`. Each crank runs three +independent bounded phases: + +1. **Synchronize outcomes.** `sync(limit)` visits at most `limit` envelopes from a persistent cursor, + including DONE envelopes whose funds released before finality. `syncenv(chain_code, token_code, + epoch_index)` targets one envelope directly. Both record the terminal result and its settlement + amounts before acknowledging bond; neither releases, burns nor claims funds. They work while frozen. +2. **Release and settle.** The FIFO release walk retains `ledger.queue_epoch`, its independent pair + cursor, `limit` work budget and `4 * limit` examination cap. Existing release gates, yield accounting, + burns and challenge settlements apply. The phase refreshes reached requests before moving funds. +3. **Advance underwriting.** A separate per-pair `underwriting_epoch` and global pair cursor advance + past fully bonded or ruled requests. The oldest WAITING envelope is issued only after those before + it meet that gate. This phase has its own `limit` work budget and `4 * limit` examination cap; an + empty release bucket never consumes it. + Running issuance after settlement preserves the existing rule that fees earned in this crank can + fund the next request's bounty. Its budget and cursor remain independent of release. + +Syndication uses bond's durable `requestkeep` interface. A terminal request cannot be pruned before +syndication records and acknowledges its outcome, even when all payouts were claimed and seven days +have passed. There is no polling deadline. Delayed synchronization delays progress, not recoverability. +An INVALID outcome or pending challenger share discovered after DONE rewinds settlement to that envelope. + +The stable request identity is `(issuer, schema, statement)`, resolved through bond's public accessor. +An inline `syncenv` after creation records the actual numeric ID; syndication never predicts bond's +counter. Admission validation is shared with bond. The statement remains the packed +`(chain_code, epoch_index, digest, token_code)` under `oppenvelope`; coverage is the envelope's totals +rounded to the bond increment and the bounty is capped by the fee pot. + +### The gates of a syndication + +In this order: envelope consensus, then held, then its request's turn, then BONDED, APPROVED or VALID, +then the bucket in item id order, then the fee, then release. Nothing releases from an envelope whose +request is OPEN, HELD or INVALID. + +- **Bucket.** The pair's syndication bucket is ticked lazily against the depot's epoch: + `level = min(burst, level + refill * epochs elapsed)`, not counting an epoch during which the + `sysio.andon` cord was pulled (see The emergency stop); a new bucket starts full. The tranche is + `min(remaining, level)`, so no single release exceeds the burst. An empty bucket stops the pair's + syndications until it refills; an item larger than the level releases in tranches over several + epochs. +- **Fee.** `tranche * synd_fee_bps / 10000`, floored, into the fee pot. +- **Release.** The rest goes to the account the pubkey has AuthX-linked at release time. The WIRE the + item earned while held is credited to that account's own `sysio.liq` row with `sysio.liq::creditowed` + and claimed there with `sysio.liq::claim`; nothing is pushed. WIRE the yield pool cannot cover yet + stays on the item, and once the item is released whole it moves to the pubkey's parked row, which a + later `sweep` pays. LIQ is credited by `sysio.liq::settle` without notifications, including to contract accounts. + Only a pubkey with no link needs parked principal. + +A YIELD item is released whole into `sysio.liq`'s pending yield by `mintyield`. It pays no fee and uses +no budget; it waits only when the shadow's headroom cannot take it. + +### Challenge and ruling + +`challenge(challenger, chain_code, token_code, epoch_index)` holds a REQUESTED, RELEASABLE or DONE +envelope whose request is OPEN or BONDED: an envelope released whole while its request is still only +bonded can be challenged, since the bond exists for exactly that case. The challenger pays the +request's hold bond plus the pair's `challenge_extra`; a charge of zero is refused (`challenge charge is +zero; configure challenge_extra`). The extra stays in the fee pot, and `sysio.synd` holds the request +through `sysio.bond::hold` naming the challenger as beneficiary. The envelope is HELD until `sysio` +rules, and the pair's `queue_epoch` is rewound to it so the ruling is processed. One challenge per +request; contract accounts may challenge. Any row the challenge creates bills `sysio`. + +A challenge of a request that is not yet fully bonded freezes the whole pair until `sysio` rules: a +held request is neither bonded nor ruled, so no later envelope of the pair is requested. That costs the +challenger only the hold bond and `challenge_extra`. Launch every pair with a non-zero +`challenge_extra` sized to make such a freeze expensive. + +- **VALID:** release resumes, and the share of the hold bond `sysio.bond` awards the issuer is + forwarded to the challenger. The step that first sees the ruling snapshots that share and the + challenger on the envelope (`hold_share`, `hold_beneficiary`, `share_pending`) and forwards it from the + snapshot, so the forward does not depend on the request row `sysio.bond` may prune. +- **INVALID:** the step that first sees the ruling snapshots the forfeit (the bonded amount) and the + bounty returned for want of a hold on the envelope (`forfeit`, `bounty_returned`). Every unreleased + syndication of the envelope is burned and its yield items dropped, within the step's budget. The + step that finishes pulls what `sysio.bond` still owes with `sysio.bond::claim` (anyone may have + claimed it for `sysio.synd` already, and a request pruned since was fully paid), burns the amount + already released out of the forfeit, puts the rest and the returned bounty in the fee pot, and marks + the envelope INVALID. Holders already paid keep what they received; supply is whole again. + +### Dropping an envelope + +`dropenv(chain_code, token_code, epoch_index)`, signed by `sysio`, is the escape hatch for an envelope +the queue can never request. It takes an OPEN or WAITING envelope with no request issued, burns its +held syndications out of `sysio.synd`'s row with `sysio.liq::burn`, drops its yield reports and marks +it INVALID; the next step moves `queue_epoch` past it. A late message of a dropped OPEN envelope is +refused as closed. + +### The parked hold + +A parked balance sits in `sysio.synd`'s own `sysio.liq` row with a shadow-custody position, so it keeps +earning WIRE. It is delivered, balance and banked WIRE together, when the pubkey is linked +(`sysio.authex::createlink` sends `linkswept` inline) or by the permissionless `sweep(account, +chain_kind)` for a link that already exists. The balance arrives by `sysio.liq::settle`, which invokes no recipient code; +a rejecting transfer handler cannot block linking or delivery. The banked WIRE is credited with `sysio.liq::creditowed`, which +notifies no one. WIRE the yield pool cannot cover yet is kept: the parked row stays at balance 0 with it +banked, and a later delivery pays it. Parked funds have already paid the fee and used the budget. + +## Desyndication + +`desyndicate(holder, quantity)`, signed by the holder, sends shadow LIQ back to its outpost. + +1. The holder must be AuthX-linked for the token's chain; the outpost pays that pubkey. +2. The pair must have a `syndconfig` row (`desyndication budget is not configured`). +3. The pair's desyndication bucket is ticked, as above. A quantity above its level is refused + (`desyndication exceeds the current budget`). The bucket drops by the whole quantity. +4. The quantity moves from the holder's own `sysio.liq` row to `sysio.synd`. Only that row can be + desyndicated: shadow held in an item or in `parked` sits in `sysio.synd`'s row and is not the + holder's to send. +5. The fee, `quantity * desynd_fee_bps / 10000` floored, stays in the fee pot. A fee that rounds to + zero leaves the whole quantity to burn; a fee of the whole quantity (10000 bps) is refused, since + nothing would reach the outpost. +6. The net rest is burned with `sysio.liq::burn` and queued to the outpost as `DESYNDICATE_LIQ`, with + the next request id from `syndcounters`. `returns` retains the original holder, destination key, + outpost, token and exact net amount until its external outcome is established. The attestation + carries `total_syndicated`, the live outstanding shadow after the burn; no historical total is stored. + +The burn is final. An outpost either pays the release, stores it and pays it later, or skips it: + +- **Stored, paid later.** Once the recipient resolves, an outpost never loses a release. Solana stores it + as a `PendingPayout` at `["pending_desyndication", request_id_le8]` for any of four reasons + (wire-solana `programs/liqsol-core/src/states/pending_payout.rs:33-52` at bb134198): `OutpostFrozen`, + `LegacyUserRecord`, `CustodyShortfall` and `SettlementRefused`. Ethereum stores it in + `SyndicationPool.pendingDesyndications(requestId)` with reason `OUTPOST_FROZEN`, `SETTLEMENT_REFUSED` + or `CUSTODY_SHORTFALL` (wire-ethereum `contracts/outpost/SyndicationPool.sol:155-159`, `:182` at + d4fbb8f8). The permissionless crank (`pay_pending_desyndication` on Solana, + `payPendingDesyndication` on Ethereum) pays each stored release exactly once, after its cause clears. +- **Skipped.** A release the outpost refuses on its content or configuration is logged and dropped. On + Solana these are: the chain code, an unresolvable user, a non-positive amount, a user that is one of + the pool's own custody authorities, an absent `GlobalState` or `DistributionState`, an outpost that is + not PostLaunch, and a token code that does not map to the pool's mint (wire-solana + `programs/liqsol-core/src/instructions/opp/inbound.rs:5121-5241`, + `desyndication_config_refusal` `:5449-5459`). Each logs `opp_outpost: DesyndicateLIQError ... + err_data=` (`:4986-4994`). On Ethereum they are `wrong chain`, `wrong token`, + `amount out of range` and `unresolvable recipient` (`SyndicationPool.sol:594-618`), each a + `DesyndicationDropped(requestId, depotAmount, reason)` event (`:220`). + +**Return resolution.** Both actions below require `sysio` and an existing outstanding request: + +- `finishreturn(request_id)` attests the external obligation was paid/completed and removes it without + any depot credit. +- `refundreturn(request_id)` attests irreversible external rejection/cancellation: nothing was paid and + no payable external obligation remains. It restores the exact stored net amount to the original + holder, then consumes the obligation atomically. The fee stays charged. A failed mint rolls back the + removal. Repeating either action on an already resolved ID fails; IDs are never reused. + +These are trusted governance determinations based on external evidence, not on-chain verification of +external finality. A queued or pending payout, timeout, transient refusal, or duplicate-request response +never justifies a refund: the external obligation may still pay. Governance must establish irreversible +rejection/cancellation, including no stored payout and no prior payment. In particular an `already paid` +or `already pending` response is not rejection of the underlying obligation. + +No receipt protocol, automatic timeout refund, or permanent return history is introduced. The original +holder and destination are retained while needed; governance does not reconstruct them from old traces. +`sysio.liq::recredit(holder, quantity)` remains its existing exceptional privileged supply-repair facility, +with unchanged authorization. It is not the normal return recovery interface and has no request-level +deduplication; operators must use `refundreturn` for these outstanding obligations. + +## The solvency check + +The invariant is that each outpost holds at least what the depot has issued against it. No side keeps a +counter for it: each side reads the balance it already holds at the moment it builds or checks a +message. + +Line numbers below are at wire-sysio c85c05118b, wire-solana bb134198 (`programs/liqsol-core/src/`) and +wire-ethereum d4fbb8f8 (`contracts/outpost/SyndicationPool.sol`). The function or symbol is named with +each, so a cite survives a line shift. + +### The readings + +| Reading | Where it is read | +|---|---| +| **Depot outstanding** of a shadow symbol | `liq::outstanding_of`: the `sysio.liq` supply plus the yield parked in `liqpending` (`contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp:240-245`). Pending yield counts because the permissionless `queueyield` mints it into supply later (`contracts/sysio.liq/src/sysio.liq.cpp:188-210`): it is committed once `mintyield` parks it (`:138-150`). `headroom_of` is the asset range net of the same figure (`sysio.liq.hpp:252-256`). | +| **Solana custody** | The `liqsol_pool_ata` balance. `SYNDICATE_LIQ` reads it after the syndication's transfer (`syndicate_post_launch`, `instructions/wire_syndication/syndicate_liqsol.rs:338-339`); `LIQ_YIELD` after the claim into the pool (`report_liq_yield`, `instructions/opp/report_liq_yield.rs:181`, `:188`). The typed account is reloaded first because it is stale after the CPI. On a `DESYNDICATE_LIQ` it is the balance `settle_desyndication` reads before anything moves (`instructions/opp/inbound.rs:5580`). liqSOL amounts cross the wire unscaled, so both sides count in one unit. | +| **Ethereum custody** | `SyndicationPool.poolBalanceDepot()`, the pool's liqETH balance floored into the depot frame (`SyndicationPool.sol:801-803`), the frame `syndicate` credits in. `SYNDICATE_LIQ` reads it after the transfer (`syndicate`, `:451`); `LIQ_YIELD` carries the balance the fold measured (`realizeYield`, `:497`, `:510`). | +| **`DESYNDICATE_LIQ.total_syndicated`** | The depot outstanding after this burn: `outstanding_of` read before the inline burn, less the net amount (`contracts/sysio.synd/src/sysio.synd.cpp:1424`). | + +Every `SYNDICATE_LIQ` and `LIQ_YIELD` carries the outpost's custody reading in `total_syndicated`; +every `DESYNDICATE_LIQ` carries the depot's outstanding in the same field. + +### The two inequalities + +**On the depot**, as `sysio.synd` admits a `SYNDICATE_LIQ` or `LIQ_YIELD`: + +``` +onsynd: total_syndicated >= outstanding_of(st) + amount (sysio.synd.cpp:1106-1107) +onyield: total_syndicated >= outstanding_of(st) (sysio.synd.cpp:1147-1148) +``` + +- **Only an admitted message is compared**, after `admit_sequence` has consumed its sequence + (`sysio.synd.cpp:1100`, `:1142`). A replayed or dropped message can carry a stale, lower total, and + comparing it would be a false alarm. +- **`onsynd` compares with the outstanding after its own mint** (`+ amount`): the outpost locked the + syndication before it read its custody. `sysio.msgch` sends an envelope's messages as inline actions, + which run depth-first, so each message sees the mints of the ones before it. +- **`onyield` compares with the outstanding as it stands**: the report is held and mints nothing yet. + +**On an outpost**, as it settles a `DESYNDICATE_LIQ` from the envelope: + +``` +pool_balance - amount >= total_syndicated (a pool below amount is a shortfall too) +``` + +- Solana: `settle_desyndication` with `solvency_floor = Some(total_syndicated)` (`inbound.rs:5580-5591`). + It runs after every account the payout needs is present and before every chain-state refusal, so a + payout that would also be refused still raises the alarm. `pay_pending_desyndication` passes `None` + (`inbound.rs:5822-5833`): the total stored with a shortfall record is a snapshot, and clearing the + cord is the operator's decision. +- Ethereum: the same rule in `_handleDesyndicateLIQ` (`SyndicationPool.sol:629-633`). It runs after + the content checks, `already paid` and the recipient, and after the pause check: a paused pool stores + the release as `OUTPOST_FROZEN` with no comparison (`:620-627`), as a frozen Solana outpost does. The + `already pending` check comes after it (`:638-641`), so a re-emit of a stored id still raises the + alarm. `payPendingDesyndication` applies no floor (`:751-773`). + +The check is one-sided, custody >= outstanding. Every state in flight moves custody up or the +outstanding down, so none of them raises a false alarm: + +- a syndication is locked on the outpost before the depot mints it; +- a desyndication is burned on the depot before the outpost pays it, or while the outpost stores the + payout as pending (the tokens stay in the custody account until paid); +- yield is claimed into custody before it is reported, and reported before it is released; +- an envelope ruled INVALID or dropped with `dropenv` is burned on the depot (`sysio.synd.cpp:819`, + `:1260`) while the outpost keeps the custody; +- a `recredit` of a skipped desyndication returns shadow the outpost never paid out (see + [The recredit rule](#desyndication)); +- anyone can send tokens to a custody account. + +Every writer of the depot outstanding is matched by custody, or is a launch or governance act: + +| Writer | Line (`sysio.liq.cpp`) | Matched by | +|---|---|---| +| `mint` (from `sysio.synd::onsynd` and `importsynd`) | `:105` | the syndication the outpost locked before it sent the message; the pre-launch import (below) | +| `mintyield` (pending) | `:138-150` | the yield the outpost claimed into custody before it reported it | +| `queueyield` | `:205` | moves pending into supply; the outstanding is unchanged | +| `burn` | `:118` | lowers the outstanding: a desyndication, an INVALID ruling, `dropenv` | +| `recredit` | `:91` | governance: custody the outpost never paid out (the recredit rule) | +| `regliqpool` | `:355` | launch: the LCO liquidity, "already in outpost custody" (`:353-354`) | + +### What happens on each side + +| Depot result | What happens | +|---|---| +| custody = outstanding | Nothing. | +| custody > outstanding | `sysio.synd::: EXCESS -- reported outstanding ` is printed, and nothing else. | +| custody < outstanding | Record a `mismatch` and send `sysio.andon::pull` with `sysio.andon@active` if the cord is clear; otherwise record the shortfall without repeating the pull. | + +With the required Andon deployment in place, intake is still held and minted and the envelope +continues. Privileged syndication bypasses inline authorization but declares the existing +Andon active permission. There is no contract-maintained puller list. A missing Andon account or incompatible deployed action fails the transaction atomically. A total is not a slashable fact. + +On an outpost a shortfall moves nothing, pulls that outpost's own cord and stores the payout: + +- Solana: `handle_desyndicate_liq` sets `GlobalState.frozen`, logs `opp_outpost: + DesyndicateLIQCustodyShortfall ... pool_balance= depot_outstanding= -- outpost frozen, payout + stored`, and stores a `PendingPayout` with reason `CustodyShortfall { pool_balance, depot_outstanding }` + (`inbound.rs:5269-5289`, `:5307-5314`; `states/pending_payout.rs:42-47`). +- Ethereum: `_recordCustodyShortfall` emits `CustodyShortfall(requestId, poolBalanceDepot, + totalSyndicated)` (`SyndicationPool.sol:236`), pauses the pool if it is not paused already, and stores + the payout in `pendingDesyndications` with reason `CUSTODY_SHORTFALL` (`:660-675`; enum `:155-159`). A + release with request id 0 still emits the event and pauses the pool, then is dropped + (`request id 0 cannot be deferred`, `:670-673`). A request id already stored still emits the event and + pauses the pool, then is dropped as `already pending`, keeping the first record (`:687-690`). + +The runbook for a shortfall-triggered pull on each chain is +[`emergency-stop-playbook.md`](emergency-stop-playbook.md) (A shortfall-triggered pull). + +**Recovery must wait for post-repair admission.** A `SyndicateLIQ` or `LIQYield` built before a custody +repair still carries the pre-repair custody. `check_custody` checks every admitted message, even while +frozen; clearing the depot cord before those messages arrive lets an old shortfall pull it again. +Repair custody, clear the repaired outpost when safe, and have it emit a message after the repair (or +wait for its next one). Keep the depot cord pulled until the depot admits that message. Read: + +```bash +clio -u "$DEPOT_URL" get table sysio.synd syndcursors -S sysio.synd +clio -u "$DEPOT_URL" get table sysio.synd mismatch -S sysio.synd +``` + +For that `chain_code`, `syndcursors.last_sequence` must reach the post-repair message's `sequence`, +and the latest `(chain_code, token_code)` incident must be reconciled explicitly; read all result pages. Record the +message's emission after repair and its actual admission. `admit_sequence` drops any sequence at or +below the cursor, so a cursor already past the message plus no incident does not alone prove that +message was admitted: verify its admission trace or observe a later post-repair message being admitted. +Only after this check passes for every repaired outpost may the depot cord clear. Mismatch rows for +earlier pre-repair sequences reporting the known shortfall are expected and must be recorded as part +of the existing incident, not treated as a new incident. A mismatch on the post-repair message still +requires investigation before clearing. + +### What counts as slack + +Custody above the outstanding is slack, and slack hides a shortfall up to its size. The real slack is: + +- **Yield spent on pretokens** (Solana, PreLaunch only). `purchase_pretokens_from_yield` decrements + `yield_accumulated_liqsol` (`instructions/wire_pretokens/purchase_pretokens_from_yield.rs:193-198`) + but moves no liqSOL out of the pool, and no counter records it. +- **Flooring dust** (Ethereum). `poolBalanceDepot()` floors the pool balance into the depot frame + (`SyndicationPool.sol:801-803`); the remainder below one depot unit stays in the pool. Solana has none: + its custody reading is passed unscaled (`syndicate_liqsol.rs:339-346`). +- **Donations**: anyone can send tokens to a custody account. +- **Custody left behind by burned envelopes**: an INVALID ruling or `dropenv` burns shadow on the depot + (`sysio.synd.cpp:819`, `:1260`) while the outpost keeps the tokens. +- **Custody behind a dropped report, permanently.** A `SYNDICATE_LIQ` or `LIQ_YIELD` the depot drops at + intake mints nothing, but the outpost has already counted it: Solana has taken the syndication into the + pool (`syndicate_liqsol.rs:254`, `:282-287`) or advanced its yield watermark + (`report_liq_yield.rs:213-214`); Ethereum has added the syndication to principal + (`SyndicationPool.sol:446`) or folded the yield into it (`:505`). Neither side re-reports it, so that + custody stays in the pool unowned and is slack for good. The depot's `sysio.synd::: DROP -- + ` console line (`drop`, `sysio.synd.cpp:174-176`) is its only audit trail. + +The pretoken purchase proceeds and the claimed pool yield are not slack: they back the LCO shadow +(below), and the yield unreported at the flip is reported as `LIQ_YIELD` later. + +The check has two further limits. It does not detect an outpost that locked funds the depot never +released; that shows up as held envelopes, not as a `mismatch` row. And a shortfall on the depot is +found only when the outpost next sends a `SYNDICATE_LIQ` or `LIQ_YIELD`; a shortfall on an outpost only +when the depot next sends it a `DESYNDICATE_LIQ`. + +**A negative liqETH rebase is a true alarm.** liqETH rebases: when its yield oracle finalizes a negative +delta, `Yield.sol` calls `liqEth.decreaseIndex` (wire-ethereum `contracts/liqEth/Yield.sol:514-518`, +`contracts/liqEth/liqEth.sol:68-71`), which lowers the pool's balance with no depot burn behind it. The +custody no longer covers the outstanding, and both sides say so: the depot on the next `SYNDICATE_LIQ` +or `LIQ_YIELD` (a `mismatch` row and the cord), the pool on the next `DESYNDICATE_LIQ` (it pauses itself +and stores the release as `CUSTODY_SHORTFALL`). `realizeYield` sends nothing meanwhile: it reverts with +`WIRE_PoolUnderbacked` while the balance is below principal (`SyndicationPool.sol:499`). Recovery at v1 +is a top-up: send liqETH to the pool until `poolBalanceDepot()` covers the outstanding plus every stored +release, then unpause the pool and pay the stored releases. Keep the depot cord pulled until a message +built after the repair has been admitted without a mismatch, as described above +([`emergency-stop-playbook.md`](emergency-stop-playbook.md), A shortfall-triggered pull). A governance +write-down of the depot's outstanding instead is pending the owner's ruling; nothing performs one at v1. + +### Launch reconciliation + +At the Solana flip the depot mints two things against the `liqsol_pool_ata`: + +- the LCO shadow, `initial_chain_amount`, minted to `sysio` by `regliqpool` (`sysio.liq.cpp:332-356`, + bootstrap window only `:337`); +- each pre-launch syndicated position, minted verbatim by `importsynd` (`sysio.synd.cpp:1473-1490`, + through `credit_by_pubkey` `:1599-1611`). + +At the flip the pool holds: + +| Holding | Source | +|---|---| +| syndicated principal, `total_staked_liqsol` | `syndicate_liqsol.rs:254` (transfer), `:282-287` (counter) | +| pretoken purchase proceeds, `total_purchased_liqsol` | `instructions/wire_pretokens/purchase_pretoken.rs:247-254` (transfer), `:298-303` (counter); PreLaunch only (`:176-180`) | +| claimed pool yield not converted to pretokens, `yield_accumulated_liqsol` | the claims into the pool; all of it is unreported at the flip, since `liq_yield_reported` is 0 on every pre-launch path (`instructions/wire_config/admin_instructions.rs:110-116`) | +| yield spent on pretokens | stays in the pool with no counter (`purchase_pretokens_from_yield.rs:193-198`) | +| donations | anyone | + +The pretoken proceeds and the yield are the protocol proceeds +(`instructions/opp/init_liqsol_reserve.rs:208-214`). They back the LCO shadow `regliqpool` mints +("already in outpost custody", `sysio.liq.cpp:353-354`); this is an inference from those two comments, +not a check the code makes. + +**The trap.** The first PostLaunch `report_liq_yield` reports the whole unreported yield, +`yield_accumulated_liqsol - liq_yield_reported` (`unreported_liq_yield`, +`states/wire_deposit_state.rs:193-196`; `report_liq_yield.rs:163`), and advances the watermark +(`:213-214`). That yield is liqSOL the pool already held at the flip. If `initial_chain_amount` and the +import credits are sized from the raw pool balance, the remainder is minted twice, once at the import and +once as yield, and the depot reports a shortfall at the first message after that yield is released. + +**The rule.** Read at the flip, while the outpost is Launching (nothing but a donation can move the pool +then: `admin_instructions.rs:134-139`): + +``` +initial_chain_amount + Σ import credits <= pool balance − (yield_accumulated_liqsol − liq_yield_reported) +``` + +The only cross-check the tooling makes is the optional `LiqPoolSpec.custody_total` +(`libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto:191-195`): validation V13 requires it to equal +`initial_chain_amount` plus the pool's `syndications` when it is non-zero, and skips it at 0 +(`libraries/opp/test/test_bootstrap_platform_config.cpp:273-280`). V13 compares the config with itself, +not with the chain: it cannot tell a `custody_total` copied from the raw pool balance from a correct one. +Check the left-hand side against the right-hand side read from the chain first, then declare +`custody_total` as the left-hand side; never copy the raw pool balance into it. The snapshot tool that +fills the config is not in any repo. + +**The Ethereum launch rule.** `SyndicationPool` seeds its principal once, at go-live, with +`initializeSyndication(..., _initialPrincipal)` (wire-ethereum `SyndicationPool.sol:288-309`), and the +depot mints the same two things against it. The seed must equal the depot's mint and fit the pool: + +``` +_initialPrincipal == initial_chain_amount + Σ import credits <= poolBalanceDepot() +``` + +A seed above the balance reverts (`WIRE_InitialPrincipalExceedsBalance`, `:303-306`). A seed below the +depot's mint is reported by the next `realizeYield` as yield (`:494-511`) and minted a second time; a +seed above it hides real yield. The `cast call` check is in wire-ethereum `docs/deploy.md` (Go-live +reconciliation). + +**After the flip.** The only PostLaunch outflow from the pool is the depot-ordered desyndication: +`settle_desyndication`'s transfer (`inbound.rs:5739`), reached from the envelope and from +`pay_pending_desyndication`. The others are closed: + +- `refund` requires the Refund state (`instructions/wire_config/refund.rs:131-134`), which is reachable + only from PreLaunch (`WireState::can_transition_to`, `states/wire_deposit_state.rs:34-40`); +- the local `desynd` refuses PostLaunch (`instructions/wire_syndication/desyndicate_liqsol.rs:192-195`); +- `init_liqsol_reserve` is hard-disabled: `require_reserve_and_swap_enabled` always refuses + (`lib.rs:982`, `instructions/opp/mod.rs:56-58`). + +**Precondition on re-enabling `init_liqsol_reserve`.** It moves the protocol proceeds, +`total_purchased_liqsol + yield_accumulated_liqsol` (`init_liqsol_reserve.rs:209-214`), out of the pool +into a reserve vault, while the depot supply they back stays. Re-enabled without a matching depot burn, +the next `DESYNDICATE_LIQ` finds the pool short and freezes the Solana outpost, and the next +`SYNDICATE_LIQ` or `LIQ_YIELD` pulls the depot cord. + +## The emergency stop + +`sysio.andon` holds one flag for the whole depot (the Andon cord). While it is pulled nothing leaves +`sysio.synd` and nothing it holds is burned; everything that brings funds in, and every step that only +records state, runs. Nothing is skipped: work that falls due while the cord is pulled is done by the +first queue step (`crank` or `closeenv`) after it clears. + +The runbook for pulling and clearing the cord on every chain, and for the work that waits on the +clear, is [`emergency-stop-playbook.md`](emergency-stop-playbook.md). + +| While the cord is pulled | Behaviour | +|---|---| +| Intake (`onsynd`, `onyield`, `closeenv`) | Runs as usual; `sysio.msgch` accepts every envelope. | +| Queue step: refresh, outcomes, requests | Runs: requests are issued and bonded, APPROVED and INVALID outcomes are recorded with their snapshots. | +| Queue step: releases | None. The step prints `the andon cord is pulled; releases, deliveries and burns wait for the clear` once and leaves every item where it is; no bucket is ticked. | +| An INVALID ruling | Recorded, with its forfeit snapshot; the burn and the claim of the forfeit wait (`... the burn waits for the clear, the pair waits`). | +| A VALID ruling after a challenge | Recorded, with the challenger's hold share snapshotted on the envelope (`hold_share`, `hold_beneficiary`) and marked `share_pending`; the forward waits (`the challenger's hold share waits for the andon cord to clear`). The first step after the clear claims the award if `sysio.bond` still owes it and pays the share from the snapshot, once -- even if `sysio.bond` pruned the request meanwhile. | +| `challenge` | Runs: the charge moves into `sysio.synd` and the hold bond into `sysio.bond`. Challenge windows keep counting. | +| `linkswept` | Delivers nothing and returns, so the user's `createlink` still succeeds; `sweep` delivers after the clear. | +| `sweep`, `desyndicate`, `dropenv`, `sweepyield` | Refused: `the andon cord is pulled: funds cannot leave custody`. | +| `setconfig`, `importsynd` | Run. | + +Frozen queue steps never spend capacity or release funds. The first tick after clear refills +for all elapsed depot epochs, including the freeze, capped at the configured burst. +Repeated freezes cannot raise capacity beyond that cap. + +## Tables + +| Table | Key | What it holds | +|---|---|---| +| `syndconfig` | outpost, token | the pair's rules (below) | +| `envelopes` | outpost, token, depot epoch | digest, syndicated and yield totals, item count, state, `sysio.bond` request id, released, burned, the request's recorded outcome, the forfeit and returned bounty snapshotted on INVALID, and the challenger's hold share, its beneficiary and whether its forward is pending, snapshotted on VALID | +| `items` | envelope, then id (arrival order) | kind, beneficiary key family and pubkey, amount, remaining, yield position | +| `parked` | token, key family, pubkey | balance and yield position of an unlinked pubkey | +| `buckets` | outpost, token, direction | level and the depot epoch last ticked, one per direction (SYNDICATION, DESYNDICATION) | +| `ledger` | outpost, token | release and underwriting cursors; `retained_from_epoch` replay floor | +| `returns` | request id | original holder, destination family/key, outpost, token and exact net burned amount; erased upon explicit resolution | +| `feepot` | token | collected fees, with a yield position; bounties are paid from it, and its yield goes to `sysio` | +| `yieldpool` | token | the WIRE `sysio.synd`'s holder row earned, pulled from `sysio.liq` and credited out to held, parked and fee positions | +| `syndcursors` | outpost | the highest sequence admitted (replay guard) | +| `syndcounters` | singleton | the next item id and the next desyndication request id | +| `syndstate` | singleton | whether the launch import is closed; the pair the next queue step starts at | +| `mismatch` | outpost, token | latest unresolved shortfall evidence: epoch, sequence, kind, reported custody, expected outstanding, and time | + +Envelope states: OPEN, WAITING, REQUESTED, RELEASABLE, HELD, INVALID, DONE. + +## Configuration + +`setconfig(chain_code, token_code, synd_fee_bps, desynd_fee_bps, synd_burst, synd_refill, +desynd_burst, desynd_refill, window_sec, bounty, challenge_extra)`, signed by `sysio`. It replaces the +pair's whole row. + +| Knob | Default (no row) | Meaning | +|---|---|---| +| `synd_fee_bps` | 0 | fee on each released syndication tranche, at most 10000 | +| `desynd_fee_bps` | 0 | fee on each desyndication, at most 10000 | +| `synd_burst`, `synd_refill` | unset: nothing releases | syndication bucket size, and refill per depot epoch, in base units | +| `desynd_burst`, `desynd_refill` | unset: nothing desyndicates | desyndication bucket size and refill | +| `window_sec` | 10800 | challenge window of each request; above 0 | +| `bounty` | 0 | bounty posted on each request, paid from the fee pot | +| `challenge_extra` | 0 | charged to a challenger on top of the hold bond; set it above 0 at launch (see Challenge and ruling) | + +The bucket, bounty and challenge amounts must each fit an asset. A pair with no row still has its +envelopes requested (with the default window and no bounty), but releases nothing and accepts no +desyndication. The row must exist, with the buckets set, before the pair carries traffic. + +## What a holder sees + +- A syndication from the outpost does not arrive at once. It waits for its envelope's request to be + bonded, then for its turn in the bucket. Until then it earns WIRE in `items`, and that WIRE is + credited to the account when the syndication arrives. +- It arrives in the account the pubkey is linked to at release, less the syndication fee. With no + link it is parked, still earning, and arrives the moment the pubkey is linked (or on `sweep`). +- A large syndication may arrive in several tranches, one per bucket refill. +- An envelope ruled INVALID burns what had not yet been released; what had been released stays. +- The WIRE a held or parked syndication earned is owed on the holder's own `sysio.liq` row once it is + delivered, and `sysio.liq::claim` pays it. +- `desyndicate` takes the whole quantity from the holder's balance and releases the quantity less the + desyndication fee on the outpost. It is refused when the pair's desyndication budget is too low now; + it refills every depot epoch. + +## What an operator sees + +- The console lines: `DROP` for a refused intake message, `sysio.synd::queue:` for anything a step + left where it was, with the reason. +- `envelopes` shows where each envelope stands; `ledger.queue_epoch` is where each pair's queue + starts; `buckets` shows each pair's remaining budget. +- `mismatch` lists the current unresolved incident for each affected outpost/token; a `SHORTFALL` line on the console + goes with each row, and an `EXCESS` line with each message that reported more custody than the depot + has outstanding. +- `crank(limit)` moves the queue when no envelope is arriving. It is permissionless and never throws. +- `sweepyield(token_code)` pulls the WIRE `sysio.synd`'s holder row has earned into the token's + `yieldpool`, so held and parked positions can be paid out of it, and sends `sysio` what the fee pot + earned: the pot is protocol revenue, and so is its yield. +- `dropenv` clears an envelope the queue can never request (below). + +## Deployment order + +- `sysio.synd` is deployed, privileged, before any liq token is activated: once a token is active, + `sysio.msgch` routes its syndications to `sysio.synd`. +- `sysio.msgch`, `sysio.synd`, `sysio.liq`, `sysio.bond` and `sysio.authex` call each other's actions + and read each other's rows. They deploy as one set, from the same commit (see + [`contract-upgrade-order.md`](contract-upgrade-order.md)). +- `setconfig` for every pair before the pair carries traffic (see Configuration). +- Deploy `sysio.andon` privileged before `sysio.synd` carries traffic and configure its native + pull/clear permissions (see the emergency-stop playbook). Its active permission exists from + account creation and is declared by syndication's privileged inline pull. Redeploy Andon, + swap, liq, bond and synd together when the shared cord layout changes. The simplified cord + and bucket layouts require a fresh deployment; no in-place state migration is included. + +## Misconfigurations that stall a pair for good + +The queue never throws, so a pair that can never move just waits, printing its reason each step. +Later envelopes of the same pair wait behind it; other pairs are not affected. + +- **A shadow token with a precision below 2.** `sysio.bond` bonds in increments of + `10^(precision - 2)` base units and cannot bond such a token. Its envelopes stay WAITING + (`sysio.bond cannot bond the token`), and nothing of the pair is ever released. `sysio` clears them + one by one with `dropenv`, which burns what they hold. +- **An unset syndication bucket.** With no `syndconfig` row, nothing releases + (`no syndconfig row: the syndication bucket is unset`). A row with `synd_burst` or `synd_refill` of + 0 behaves the same once the bucket is empty (`syndication bucket is empty until it refills`). Setting + the row lifts the stall; the queue resumes on the next step. +- **An unset desyndication bucket.** With no row, or a zero `desynd_burst`, every `desyndicate` of the + pair is refused. This refuses the signed action; it does not stall the queue. + +## Operational retention and reconciliation + +No historical reporting API or archive is maintained. Lifetime ledger totals are removed. Replay +sequences, counters, import completion, active custody/yield obligations and unresolved returns remain. + +`pruneenv(chain_code, token_code, limit)` is permissionless and examines at most `limit` envelopes from +the pair's oldest retained row, stopping at the first unfinished one. It erases only DONE/INVALID rows +with a consumed terminal outcome (or a never-issued governance drop), no items or pending challenger +share, and an acknowledged bond result if that request still exists. DONE before finality is retained. +Settled rows behind a live earlier envelope remain temporarily; this prefix rule avoids a permanent +per-envelope tombstone table. The widened `retained_from_epoch` floor rejects all future admission below +it, including fresh sequences, different digests, and previously absent epochs in that prefix. + +A later shortfall replaces the same pair's incident; a healthy report does not erase it. +`reconcile(chain_code, token_code, reported)` requires `sysio` and an existing incident, verifies the +attested custody covers current live outstanding supply, and erases only that incident. `reported` is a +governance attestation, not a fresh oracle measurement. The global Andon cord must be cleared separately +after every relevant incident and other cause has been resolved. + +Existing deployed tables require a coordinated migration for the changed layouts and renamed return +table; no automatic migration or recovery from previously discarded data is included. diff --git a/libraries/opp/proto/sysio/opp/attestations/attestations.proto b/libraries/opp/proto/sysio/opp/attestations/attestations.proto index ff665ddce9..ab0e071780 100644 --- a/libraries/opp/proto/sysio/opp/attestations/attestations.proto +++ b/libraries/opp/proto/sysio/opp/attestations/attestations.proto @@ -562,6 +562,12 @@ message SyndicateLIQ { sysio.opp.types.TokenAmount amount = 3; // Per-outpost monotonic sequence shared with LIQYield; dedupe key. uint64 sequence = 4; + // The OUTPOST's live custody balance of the liq token, read when the + // message is built and after this syndication is locked, in the depot + // frame of `amount`: the liqsol_pool token account on Solana, + // SyndicationPool.poolBalanceDepot() on Ethereum. The depot checks it is + // at least its outstanding shadow of the token. + uint64 total_syndicated = 5; } // Outpost -> Depot. Yield the outpost claimed for the syndicated liq pool since @@ -576,6 +582,11 @@ message LIQYield { uint64 sequence = 3; // Outpost-chain epoch at which the report was taken. Informational. uint64 epoch = 4; + // The OUTPOST's live custody balance of the liq token, read when the + // report is built and after the yield is claimed into custody, in the + // depot frame of `amount` (the same reading SyndicateLIQ carries). The + // depot checks it is at least its outstanding shadow of the token. + uint64 total_syndicated = 5; } // Depot -> Outpost. Release `amount` liq tokens from the outpost's syndicated @@ -597,4 +608,9 @@ message DesyndicateLIQ { sysio.opp.types.TokenAmount amount = 3; // Depot-side request id for correlation in outpost logs. uint64 request_id = 4; + // The DEPOT's live outstanding shadow of the liq token after this burn: + // the sysio.liq supply of the shadow symbol plus its yield parked in + // liqpending, in base units of `amount`. The outpost checks its custody + // after this payout is at least this total. + uint64 total_syndicated = 5; } diff --git a/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto b/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto index d1228c6498..b46e5df1af 100644 --- a/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto +++ b/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto @@ -196,7 +196,7 @@ message LiqPoolSpec { } // A pre-launch syndicated position to replay via -// `sysio.liq::importsynd(chain_code, token_code, credits[] {pubkey, amount})` +// `sysio.synd::importsynd(chain_code, token_code, credits[] {pubkey, amount})` // (batched by the tool; `importdone` closes the import). Filled from the same // launch export that produces the WPT credits. message SyndicationSpec { diff --git a/plugins/batch_operator_plugin/README.md b/plugins/batch_operator_plugin/README.md index 94edd7a820..0453c9441e 100644 --- a/plugins/batch_operator_plugin/README.md +++ b/plugins/batch_operator_plugin/README.md @@ -4,27 +4,34 @@ Cranks Depot and Outpost contracts, ferrying OPP message chains between the WIRE ## Overview -All 21 batch operators run this plugin in perpetuity. The epoch scheduler (`sysio.epoch`) assigns operators into 3 fixed groups of 7. Each epoch (every 6 minutes), one group is elected; those 7 execute the full epoch cycle. +Every batch operator runs this plugin. `sysio.epoch` keeps a sliding window of +`batch_op_groups` groups of `operators_per_epoch` operators (default 3 groups of 7, epochs +of 6 minutes); the group at the front of the window is elected for the current epoch and +its members execute the epoch cycle. Each poll the plugin also reads its own +`sysio.opreg` status and stops relaying once the operator is `SLASHED` or `TERMINATED`. ## Epoch Cycle -**Phase 1 — Outbound (WIRE → Outposts):** -1. Crank Depot (`sysio.msgch::crank`) to produce outbound OPP Message Chain -2. Read the produced chain from Depot tables -3. Deliver chain to Outpost contract (ETH: `OPPInbound.epochIn()`, SOL: `epoch_in()`) -4. All 7 independently verify the delivered chain +Run by every member of the elected group, for every active outpost chain: -**Phase 2 — Inbound (Outposts → WIRE):** +**Outbound (WIRE → Outposts):** +1. Read the outpost's outbound envelope that `sysio.epoch::advance` built + (`sysio.msgch::buildenv`) from the depot's `outenvelopes` table +2. Deliver it to the Outpost contract, chunked (ETH: `OPPInbound.epochIn()`, SOL: `epoch_in`) +3. The outpost reaches consensus once enough group members have delivered identical bytes + +**Inbound (Outposts → WIRE):** 1. The consensus-reaching delivery emits the outpost's outbound envelope 2. Read the latest outbound envelope from Outpost storage 3. Deliver its raw protobuf bytes to Depot (`sysio.msgch::deliver`) -4. Depot evaluates consensus across all 7 deliveries +4. Depot evaluates consensus across the group's deliveries ## Depot cranks Every epoch poll (`--batch-epoch-poll-ms`) also pushes the depot actions nothing on -chain schedules. `sysio.msgch::chkcons` comes only from the elected operator; the -rest come from every ACTIVE operator, because the elected one may be the operator +chain schedules. `sysio.msgch::chkcons` — which advances the epoch once every active +outpost has consensus and the boundary has passed — comes only from elected operators; +the rest come from every ACTIVE operator, because the elected one may be the operator that is offline, and each is a cheap no-op once its work is done: | Action | When | Why nothing else drives it | @@ -34,7 +41,8 @@ that is offline, and each is a cheap no-op once its work is done: | `sysio.liq::queueyield(sym)` | every shadow with yield pending from an outpost `LIQ_YIELD` report | the report lands in `sysio.liq`'s pending balance; queuing it into the swap is a separate, permissionless step | The two yield cranks stay idle, without logging a read failure per poll, until both -`sysio.swap` and `sysio.liq` are deployed on the depot. +`sysio.swap` and `sysio.liq` are deployed on the depot. The plugin does not crank +`sysio.synd::crank`; syndication release is driven by keepers. ## Configuration @@ -79,5 +87,5 @@ address rather than left pointing at the old one. - `chain_plugin` — blockchain state access - `cron_plugin` — irreversible block event subscription - `signature_provider_manager_plugin` — signing key management -- `outpost_ethereum_client_plugin` — ETH RPC calls (future) -- `outpost_solana_client_plugin` — SOL RPC calls (future) +- `outpost_ethereum_client_plugin` — Ethereum outpost relay +- `outpost_solana_client_plugin` — Solana outpost relay diff --git a/plugins/chain_interface/README.md b/plugins/chain_interface/README.md index ef0644b968..c4fbe6dd8b 100644 --- a/plugins/chain_interface/README.md +++ b/plugins/chain_interface/README.md @@ -39,9 +39,9 @@ returned `handle` for the lifetime of its subscription or registration. | `channels::applied_transaction` | `transaction_trace_ptr` | `chain_plugin`, relaying `controller::applied_transaction` | `low` | | `compat::channels::transaction_ack` | `std::pair` | `producer_plugin` after a speculative transaction is applied or rejected, and `chain_plugin`'s transaction-retry database | `low` | -`irreversible_block` is the one most non-chain plugins consume: `batch_operator_plugin` and -`underwriter_plugin` each subscribe to it as their sync gate, arming their work only once the node has caught -up. `net_plugin` subscribes to `compat::channels::transaction_ack` so it can relay or drop a peer's +`irreversible_block` is the one most non-chain plugins consume: `batch_operator_plugin` subscribes to it as +its sync gate, arming its work only once the node has caught up, and `status_monitor_plugin` renders one +document per irreversible block. `net_plugin` subscribes to `compat::channels::transaction_ack` so it can relay or drop a peer's transaction according to the result. ### Methods @@ -81,9 +81,8 @@ sync_gate_subscription = ``` [`usage_pattern.md`](../usage_pattern.md) lists connecting to other plugins' signals and registering -provider methods among the typical `plugin_initialize` actions; the sync-gate consumers in this tree -(`batch_operator_plugin`, `underwriter_plugin`) subscribe from `plugin_startup` instead, after their own state -is built. Either way, keep the returned handle — dropping it cancels the subscription. +provider methods among the typical `plugin_initialize` actions; the sync-gate consumer in this tree +(`batch_operator_plugin`) subscribes from `plugin_startup` instead, after its own state is built. Either way, keep the returned handle — dropping it cancels the subscription. ## Options @@ -113,6 +112,7 @@ to the ack channel. Both run under their own plugin's test target. this include path public to the rest of the tree. - `net_plugin` — subscribes to `transaction_ack`, and feeds every transaction it accepts from a peer into `transaction_async` via `chain_plugin::accept_transaction`. -- `batch_operator_plugin`, `underwriter_plugin` — subscribe to `irreversible_block` as their sync gate. +- `batch_operator_plugin` — subscribes to `irreversible_block` as its sync gate. +- `status_monitor_plugin` — subscribes to `irreversible_block` to ship one snapshot per irreversible block. - [`plugins/usage_pattern.md`](../usage_pattern.md) — when to connect, register, and release these handles across the appbase lifecycle. diff --git a/plugins/chain_plugin/README.md b/plugins/chain_plugin/README.md index f2546e0e78..84b21966dc 100644 --- a/plugins/chain_plugin/README.md +++ b/plugins/chain_plugin/README.md @@ -3,9 +3,9 @@ `chain_plugin` owns the blockchain itself. It constructs and configures the `controller` — block log, chain state database, fork database, WASM runtime, protocol features, whitelists and blacklists — brings it up from genesis, a snapshot, or existing state, and relays the controller's signals onto the appbase channels -declared in `chain_interface`. Those channels have few subscribers: `batch_operator_plugin` and -`underwriter_plugin` take `irreversible_block` as their sync gate, and nothing else subscribes to any of -them — `net_plugin` connects to the controller's signals directly instead. `nodeop` initializes it on every +declared in `chain_interface`. Those channels have few subscribers: `batch_operator_plugin` takes +`irreversible_block` as its sync gate and `status_monitor_plugin` ships one snapshot per irreversible block; +nothing else subscribes to any of them — `net_plugin` connects to the controller's signals directly instead. `nodeop` initializes it on every run alongside `resource_monitor_plugin`, `net_plugin`, and `producer_plugin`, so there is no `plugin =` line to add; it pulls in `signature_provider_manager_plugin` through `APPBASE_PLUGIN_REQUIRES`, which in turn pulls in `http_client_plugin`. It registers no HTTP endpoints of its own — `chain_api_plugin` publishes the diff --git a/plugins/cron_plugin/README.md b/plugins/cron_plugin/README.md index a8f4430203..bb9c39eba4 100644 --- a/plugins/cron_plugin/README.md +++ b/plugins/cron_plugin/README.md @@ -2,9 +2,9 @@ `cron_plugin` provides `nodeop` with an in-process cron scheduler: other plugins register functions against cron-style schedules and the plugin fires them on a worker pool. It is infrastructure, not an operator-facing -feature -- it exposes no HTTP endpoint and does nothing on its own. Operators rarely name it: both -`batch_operator_plugin` and `underwriter_plugin` declare it in `APPBASE_PLUGIN_REQUIRES`, so enabling either one -loads it automatically. It has no dependencies of its own (`APPBASE_PLUGIN_REQUIRES()` is empty). +feature -- it exposes no HTTP endpoint and does nothing on its own. Operators rarely name it: +`batch_operator_plugin` declares it in `APPBASE_PLUGIN_REQUIRES`, so enabling the batch operator loads it +automatically. It has no dependencies of its own (`APPBASE_PLUGIN_REQUIRES()` is empty). ## How it works @@ -74,7 +74,7 @@ discovered, so its OPP polling jobs do not contend with anything else on the sha ## Enabling / configuration -No `plugin =` line is normally needed -- `batch_operator_plugin` and `underwriter_plugin` both require it. +No `plugin =` line is normally needed -- `batch_operator_plugin` requires it. Naming it explicitly is harmless. `config.ini`: @@ -147,7 +147,5 @@ trigger pre-computation, the scheduler waking when a new job is added, and day-o - [`batch_operator_plugin`](../batch_operator_plugin) -- requires this plugin, and additionally creates its own private `cron_service` sized from the discovered outpost count for OPP polling jobs. -- [`underwriter_plugin`](../underwriter_plugin) -- requires this plugin and schedules its scan job on the shared - service. - [`resource_monitor_plugin`](../resource_monitor_plugin) -- a plugin with its own dedicated timer thread rather than a cron job; the two are independent. diff --git a/plugins/external_debugging_plugin/README.md b/plugins/external_debugging_plugin/README.md index f3468ecf4c..dcea6b8e11 100644 --- a/plugins/external_debugging_plugin/README.md +++ b/plugins/external_debugging_plugin/README.md @@ -175,5 +175,3 @@ it. No test dials a real server. plugin's `ext-debugging-*` triple layers over. - [`prometheus_plugin`](../prometheus_plugin) -- its `nodeop_outbound_http_*` counters include this plugin's outbound traffic, since all outbound callers share one transport. -- [`underwriter_plugin`](../underwriter_plugin) -- the other OPP-side plugin; it has its own - `/v1/underwriter/*` diagnostics rather than an external sink. diff --git a/plugins/http_plugin/README.md b/plugins/http_plugin/README.md index 90be83c18a..81c8df36b0 100644 --- a/plugins/http_plugin/README.md +++ b/plugins/http_plugin/README.md @@ -97,7 +97,6 @@ be named in a `plugin` option, or startup fails with `plugin_config_exception`. | `trace_api` | `sysio::trace_api_plugin` | | `prometheus` | `sysio::prometheus_plugin` | | `test_control` | `sysio::test_control_api_plugin` | -| `underwriter` | `sysio::underwriter_plugin` | `node` is the category of endpoints served on every listener; it is what `http-server-address` and `unix-socket-path` bind, and it is not a value accepted by `--http-category-address`. @@ -165,7 +164,7 @@ All of `http_plugin`'s options are config-file options, so each is equally valid |---|---|---| | `http-server-address` | `127.0.0.1:8888` | Local IP and port to listen on for incoming HTTP connections. Set to the literal `http-category-address` to enable the `http-category-address` option; leave blank to disable. Under `kiod` the option is registered with no default. | | `unix-socket-path` | unset | Filename, relative to the data dir, of a unix socket for HTTP RPC; blank disables it. Must not be set when `http-category-address` is used. Under `kiod` it defaults to `kiod.sock`. | -| `http-category-address` | unset | `category,address` pair binding one API category to one listen address; may be repeated. The address is `:port`, `:port`, or a unix socket path starting with `/`, `./`, or `../`. Valid categories are `chain_ro`, `chain_rw`, `db_size`, `net_ro`, `net_rw`, `producer_ro`, `producer_rw`, `snapshot`, `trace_api`, `prometheus`, `test_control`, `snapshot_ro`, and `underwriter`. `kiod` does not register this option. | +| `http-category-address` | unset | `category,address` pair binding one API category to one listen address; may be repeated. The address is `:port`, `:port`, or a unix socket path starting with `/`, `./`, or `../`. Valid categories are `chain_ro`, `chain_rw`, `db_size`, `net_ro`, `net_rw`, `producer_ro`, `producer_rw`, `snapshot`, `trace_api`, `prometheus`, `test_control`, and `snapshot_ro`. `kiod` does not register this option. | ### Limits and threads @@ -238,5 +237,5 @@ failures), the single answer per request, and the API-error mapping of raw handl - `producer_api_plugin` — registers the `producer_ro`, `producer_rw`, and `snapshot` endpoints. - [`snapshot_api_plugin`](../snapshot_api_plugin/README.md) — registers the `snapshot_ro` endpoints, and is the consumer of `add_raw_handler` for file downloads. -- `db_size_api_plugin`, `trace_api_plugin`, `prometheus_plugin`, `test_control_api_plugin`, - `underwriter_plugin` — the remaining category owners. +- `db_size_api_plugin`, `trace_api_plugin`, `prometheus_plugin`, `test_control_api_plugin` — the remaining + category owners. diff --git a/plugins/outpost_client_plugin/README.md b/plugins/outpost_client_plugin/README.md index 556684f25c..c3b385af5c 100644 --- a/plugins/outpost_client_plugin/README.md +++ b/plugins/outpost_client_plugin/README.md @@ -20,7 +20,7 @@ before a chain-specific client tries to resolve one. ### `sysio/outpost_client/outpost_client.hpp` — the SPI `outpost_client` is the abstract interface between the plugins that orchestrate OPP work -(`batch_operator_plugin`, `underwriter_plugin`) and the chain-specific concretes. An orchestrator holds an +(`batch_operator_plugin`) and the chain-specific concretes. An orchestrator holds an `outpost_client_ptr` (a `std::shared_ptr`) and calls only these virtuals; it never interprets an EVM address, a Solana public key, a PDA, or a signature-provider format. @@ -33,7 +33,7 @@ an EVM address, a Solana public key, a PDA, or a signature-provider format. | `to_string()` | `{chain_code}:{ChainKind_Name}:{chain_id}`. The default prints `chain_code()` as the raw packed integer, so the row registered as `ETH` against a local chain renders `23373212024832:CHAIN_KIND_EVM:31337`. Virtual, with a default derived from the three getters | | `deliver_outbound_envelope(epoch_index, envelope_bytes, deadline)` | OPP outbound — submit one envelope to the remote chain; returns the chain-native transaction id | | `read_inbound_envelope(epoch_index, deadline)` | OPP inbound — return the envelope the remote chain produced for this epoch, or an empty vector when the latest slot does not match | -| `uw_commit(uw_request_id, uic_bytes, deadline)` | Underwriter commit — relay a signed `UnderwriteIntentCommit` through the outpost; returns only after on-chain confirmation | +| `crank_outpost(epoch_index, deadline)` | Drive the outpost's permissionless per-epoch instructions right after this operator's delivery lands; best-effort, retried next epoch. The default cranks nothing; Solana overrides it with `report_liq_yield`, Ethereum with `SyndicationPool.realizeYield` | Two rules bind every implementation. Each RPC-bound call must enforce the `deadline` it is passed, so a hung remote chain cannot starve a cron worker; the protected helper `throw_if_past_deadline(deadline_abs, op)` @@ -136,4 +136,4 @@ so the base class's default implementation is not under test. Higher-fidelity mo - [`outpost_solana_client_plugin`](../outpost_solana_client_plugin/README.md) — the Solana concrete. - `signature_provider_manager_plugin` — required dependency; supplies the signers the concretes resolve by name. - `http_client_plugin` — owns the process-wide `--outbound-http-*` transport options this plugin's RPC policy reads. -- `batch_operator_plugin`, `underwriter_plugin` — the SPI's consumers. +- `batch_operator_plugin` — the SPI's consumer. diff --git a/plugins/outpost_ethereum_client_plugin/README.md b/plugins/outpost_ethereum_client_plugin/README.md index b61a67d27a..65b6f47c29 100644 --- a/plugins/outpost_ethereum_client_plugin/README.md +++ b/plugins/outpost_ethereum_client_plugin/README.md @@ -3,10 +3,10 @@ `outpost_ethereum_client_plugin` owns this node's connections to EVM chains. It builds one policy-enforcing `ethereum_client` per configured endpoint — each bound to a named Ethereum signature provider and to a verified numeric chain id — loads the contract ABIs those connections are driven through, and hands out -`outpost_client` concretes that speak the chain-agnostic OPP SPI on top of `OPP.sol`, `OPPInbound.sol`, and -`OperatorRegistry.sol`. An operator enables it whenever the node runs an OPP daemon against an EVM outpost: -`batch_operator_plugin` and `underwriter_plugin` both name it in `APPBASE_PLUGIN_REQUIRES`, so either of -those pulls it in, and it can also be loaded alone (as the bundled RPC tool does) to talk to an EVM endpoint +`outpost_client` concretes that speak the chain-agnostic OPP SPI on top of `OPP.sol` and `OPPInbound.sol` +(plus the outpost's `SyndicationPool.sol` for the per-epoch crank). An operator enables it whenever the node +runs an OPP daemon against an EVM outpost: `batch_operator_plugin` names it in `APPBASE_PLUGIN_REQUIRES`, so +the batch operator pulls it in, and it can also be loaded alone (as the bundled RPC tool does) to talk to an EVM endpoint directly. ## How it works @@ -27,19 +27,19 @@ plugin_initialize v publish the client map (only after every client succeeded) -batch_operator_plugin / underwriter_plugin - | create_outpost_client(client-id, chain_code, chain_id, opp, oppInbound, operatorRegistry) +batch_operator_plugin + | create_outpost_client(client-id, chain_code, chain_id, opp, oppInbound) v -outpost_ethereum_client -- deliver_outbound_envelope / read_inbound_envelope / uw_commit +outpost_ethereum_client -- deliver_outbound_envelope / read_inbound_envelope / crank_outpost ``` ### Endpoints, signers, and chain ids Clients come from exactly one of two sources. The check is an exclusive-or, so configuring both **and** configuring neither are rejected with the same message, `Configure exactly one of ---outpost-ethereum-client-config-file or --outpost-ethereum-client`. Because `batch_operator_plugin` and -`underwriter_plugin` name this plugin in `APPBASE_PLUGIN_REQUIRES`, a node that loads either of them must -configure an Ethereum client source or it will not start, even if it only intends to serve a Solana +--outpost-ethereum-client-config-file or --outpost-ethereum-client`. Because `batch_operator_plugin` names +this plugin in `APPBASE_PLUGIN_REQUIRES`, a node that loads it must configure an Ethereum client source or it +will not start, even if it only intends to serve a Solana outpost. A **command-line spec** is `,,[,]`. With three fields @@ -87,9 +87,9 @@ endpoint cannot occupy a cron worker past its budget. ### The contract clients -Three typed wrappers are built over the shared connection, each only when its address was supplied to -`create_outpost_client`; passing an empty string for the others is normal, and calling an SPI method whose -wrapper was not provisioned asserts with a message naming the missing address. A fourth, the syndication +Two typed wrappers are built over the shared connection, each only when its address was supplied to +`create_outpost_client`; passing an empty string for the other is normal, and calling an SPI method whose +wrapper was not provisioned asserts with a message naming the missing address. A third, the syndication pool's, is bound by the crank once the outpost names the pool (see [Outpost cranks](#outpost-cranks)). State-changing calls go through `create_tx_and_confirm`, which returns only after on-chain inclusion plus confirmations — OPP writes are consensus-critical and must not silently drop. @@ -98,7 +98,6 @@ confirmations — OPP writes are consensus-critical and must not silently drop. |---|---|---| | `opp_contract_client` | `OPP.sol` | `emitOutboundEnvelope(uint32)` (recovery-only write; no in-tree steady-state caller), `getLatestOutboundEnvelope()` view | | `opp_inbound_contract_client` | `OPPInbound.sol` | `epochIn(uint32,uint16,uint16,uint32,bytes)`, `discardEnvelopeChunks()`, `nextEpochIndex()` view, `envelopeChunkState(address)` view, `attestationHandlers(uint16)` view | -| `operator_registry_contract_client` | `OperatorRegistry.sol` | `commit(bytes)` | | `syndication_pool_contract_client` | `SyndicationPool.sol` (Wire-Network/wire-ethereum#207) | `realizeYield()` | ### Outbound delivery and chunking @@ -127,7 +126,7 @@ malformed state decodes as all-zero and degrades to starting fresh, which the co no-ops. A mid-sequence failure simply abandons the tick; the next one restarts from the on-chain high-water mark. -### Inbound reads and underwriter commits +### Inbound reads `read_inbound_envelope` makes one `getLatestOutboundEnvelope` view call at the **`finalized`** block tag, not `latest`. WIRE consensus on inbound is committed forward against this read, so an operator that read at @@ -135,11 +134,6 @@ mark. compared with the requested one and a mismatch returns an empty vector at debug level, since observing the preceding epoch is normal until the consensus-reaching delivery overwrites the slot. -`uw_commit` hex-encodes the canonical `UnderwriteIntentCommit` bytes and calls `OperatorRegistry.commit`, -returning the transaction hash only after confirmation. The outpost binds the signed EVM caller and the -claimed ACTIVE roster identity before queuing the unchanged bytes; the WIRE depot remains authoritative for -the embedded permission signature and bond. - ### Outpost cranks Once per epoch, right after this operator's envelope delivery lands, the outbound relay job calls @@ -153,12 +147,14 @@ comes back, and re-binds if it changes. `address(0)` and `ATTESTATION_BLACKHOLE` without `realizeYield` means an outpost deployment that predates the pool; both leave the crank idle at debug level. -`realizeYield()` refuses at estimate time, before any gas is spent, and the relay reads the pool's own three +`realizeYield()` refuses at estimate time, before any gas is spent, and the relay reads the pool's own refusals as outcomes rather than failures: `WIRE_NoYield()` and `WIRE_YieldBelowDeadband(uint64,uint64)` are the quiet steady state (debug), `WIRE_PoolUnderbacked(uint64,uint64)` is a warning (the loss path is not in -that contract). Any other revert — a signer without the pool's `yield_operator` role, a paused endpoint, a -foreign implementation — and any transport failure propagate to the job, which logs the failed crank and -retries with the next epoch's delivery. +that contract), and `EnforcedPause()` — raised only by `SyndicationPool`'s own pause, when its panic role has +frozen the pool (the OPP endpoint has no pause) — is logged at info every epoch until it is unpaused. Any other +revert — a signer without the pool's `yield_operator` role, a foreign implementation — and any transport failure +propagate to the job, which logs the failed crank and retries with the next epoch's delivery. The relay never cranks `payPendingDesyndication`; paying a +desyndication stored while the pool was paused is the operator playbook's step. ## Enabling / configuration @@ -262,7 +258,6 @@ Per-client runtime lines are prefixed with the SPI label `outpost_ethereum_clien - `resuming epoch=... delivery at chunk ...`, `discarded a superseded epoch=... staging header`, and `skipping epoch=... delivery — the outpost has ...` on the chunk-resume paths. - `read inbound envelope epoch=... bytes=...` after a successful inbound read. -- `uw_commit confirmed uwreq=... tx_hash=... bytes=...` after a confirmed underwriter commit. - Warning-level lines for every malformed view result (`envelopeChunkState returned non-string variant`, `latestOutboundEnvelope data_ not a string`, and siblings); an epoch mismatch on the inbound slot stays at debug level so steady-state polling is not noisy. @@ -311,4 +306,3 @@ counter address. - `signature_provider_manager_plugin` — required dependency; supplies the Ethereum signer resolved by name. - `http_client_plugin` — owns the process-wide `--outbound-http-*` transport fallbacks. - `batch_operator_plugin` — calls `create_outpost_client` for the OPP envelope path (`OPP.sol` + `OPPInbound.sol`). -- `underwriter_plugin` — calls `create_outpost_client` for the `OperatorRegistry.sol` commit relay. diff --git a/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin/outpost_ethereum_client.hpp b/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin/outpost_ethereum_client.hpp index f200c75947..f3beeb355d 100644 --- a/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin/outpost_ethereum_client.hpp +++ b/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin/outpost_ethereum_client.hpp @@ -133,13 +133,19 @@ enum class realize_yield_refusal { below_deadband, /// `WIRE_PoolUnderbacked(uint64 balanceDepot, uint64 principal)`: the balance /// fell below the principal, a loss the `LIQYield` carrier cannot express. - underbacked + underbacked, + /// `EnforcedPause()`: OpenZeppelin `Pausable`'s refusal. `realizeYield()` is + /// `whenNotPaused`, so a pool its panic role has frozen refuses every crank + /// until it is unpaused -- an expected state, not a failed crank. + paused }; /// Classify `realizeYield()`'s revert bytes. `std::nullopt` for anything that is -/// not one of the pool's own three refusals, exactly shaped (the selector alone, -/// or the selector plus two words): a role error, a paused endpoint or a foreign -/// implementation must not pass as a quiet no-op. +/// not one of the pool's own refusals, exactly shaped (the selector alone, or the +/// selector plus two words): a role error or a foreign implementation must not +/// pass as a quiet no-op. `EnforcedPause()` is one of the pool's refusals: it +/// comes only from `SyndicationPool`'s own pause (`whenNotPaused`), since the +/// OPP endpoint has no pause of its own. /// /// @param revert_data `json_rpc_error::data`, the node's revert bytes as `0x`-hex. std::optional classify_realize_yield_revert(std::string_view revert_data); diff --git a/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client.cpp b/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client.cpp index 83dd6df90c..e480104ef4 100644 --- a/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client.cpp +++ b/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client.cpp @@ -49,6 +49,9 @@ constexpr auto chunk_buffer_missing_signature = "OPP_ChunkBufferMissing(address) constexpr auto no_yield_signature = "WIRE_NoYield()"; constexpr auto yield_below_deadband_signature = "WIRE_YieldBelowDeadband(uint64,uint64)"; constexpr auto pool_underbacked_signature = "WIRE_PoolUnderbacked(uint64,uint64)"; +/// OpenZeppelin `Pausable`'s refusal, raised by `realizeYield()`'s `whenNotPaused` while the +/// pool's panic role has frozen it (wire-ethereum `SyndicationPool.sol`). +constexpr auto enforced_pause_signature = "EnforcedPause()"; /// `ATTESTATION_BLACKHOLE` in wire-ethereum's `OPPCommon.sol`: the handler governance /// registers to drop an attestation type on purpose. Mirror duty, like @@ -236,6 +239,7 @@ std::optional classify_realize_yield_revert(std::string_v if (is(no_yield_signature, 0)) return realize_yield_refusal::no_yield; if (is(yield_below_deadband_signature, 2)) return realize_yield_refusal::below_deadband; if (is(pool_underbacked_signature, 2)) return realize_yield_refusal::underbacked; + if (is(enforced_pause_signature, 0)) return realize_yield_refusal::paused; return std::nullopt; } @@ -782,10 +786,11 @@ void outpost_ethereum_client::crank_outpost(uint32_t epoch_index, fc::microsecon ilog("outpost_ethereum_client[{}]: realizeYield sent for epoch {} tx={}", to_string(), epoch_index, result.as_string()); } catch (const fc::network::json_rpc::json_rpc_error& e) { - // A revert at estimate time costs no gas. Only the pool's own three refusals are - // outcomes of the crank rather than failures of it; anything else -- a signer without - // the `yield_operator` role, a paused endpoint, a foreign implementation -- is the job's - // to log as a failed crank, exactly like a transport failure. + // A revert at estimate time costs no gas. Only the pool's own refusals -- its three + // yield outcomes and `EnforcedPause()`, which comes only from SyndicationPool's own pause + // (the OPP endpoint has none) -- are outcomes of the crank rather than failures of it; + // anything else -- a signer without the `yield_operator` role, a foreign implementation -- + // is the job's to log as a failed crank, exactly like a transport failure. const auto refusal = e.code == ethereum_execution_reverted_code ? detail::classify_realize_yield_revert(e.data.is_string() ? e.data.as_string() : std::string{}) @@ -802,6 +807,13 @@ void outpost_ethereum_client::crank_outpost(uint32_t epoch_index, fc::microsecon "refused for epoch {} (the loss path is not in that contract)", to_string(), _syndication_pool_addr, epoch_index); return; + case detail::realize_yield_refusal::paused: + // The emergency stop: an expected state the andon playbook clears, reported every + // epoch at info so it stays visible without reading as a failed crank. + ilog("outpost_ethereum_client[{}]: syndication pool {} is paused; realizeYield not reported " + "for epoch {}", + to_string(), _syndication_pool_addr, epoch_index); + return; } } } diff --git a/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp b/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp index 75f2b351e8..6ac2d7f0a6 100644 --- a/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp +++ b/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp @@ -623,6 +623,9 @@ constexpr std::string_view test_moved_syndication_pool_address = "0xDc64a140Aa3E constexpr std::string_view no_yield_selector = "053716d1"; constexpr std::string_view yield_below_deadband_selector = "45441430"; constexpr std::string_view pool_underbacked_selector = "358cc7e9"; +/// `keccak256("EnforcedPause()")[0..4]`: OpenZeppelin `Pausable`'s refusal, which +/// `realizeYield()`'s `whenNotPaused` raises while the pool's panic role has frozen it. +constexpr std::string_view enforced_pause_selector = "d93c0665"; /// `AccessManagedUnauthorized(address)`: what a signer without the `yield_operator` role gets. constexpr std::string_view access_managed_unauthorized_selector = "068ca9d8"; constexpr uint64_t test_yield_delta = 5; @@ -1660,6 +1663,11 @@ BOOST_AUTO_TEST_CASE(realize_yield_refusal_selectors_are_pinned) try { BOOST_CHECK(crank::classify_realize_yield_revert(encode_two_word_revert( pool_underbacked_selector, test_yield_delta, test_yield_deadband)) == refusal::underbacked); + BOOST_CHECK(crank::classify_realize_yield_revert(std::string(hex_prefix) + + std::string(enforced_pause_selector)) == refusal::paused); + // `EnforcedPause()` takes no arguments; one with a word attached is not it. + BOOST_CHECK(!crank::classify_realize_yield_revert( + std::string(hex_prefix) + std::string(enforced_pause_selector) + abi_word(1))); BOOST_CHECK(!crank::classify_realize_yield_revert( std::string(hex_prefix) + std::string(no_yield_selector) + abi_word(1))); @@ -1767,8 +1775,9 @@ BOOST_AUTO_TEST_CASE(crank_outpost_rebinds_when_the_registered_pool_moves) try { fixture->outpost->syndication_pool_address(), test_moved_syndication_pool_address)); } FC_LOG_AND_RETHROW(); -/// The pool's own three refusals are outcomes of the crank, not failures: nothing to report is -/// debug-quiet, an underbacked pool is a warning, and none of them propagate. +/// The pool's own refusals are outcomes of the crank, not failures: nothing to report is +/// debug-quiet, an underbacked pool is a warning, a paused (frozen) pool is info, and none of +/// them propagate. BOOST_AUTO_TEST_CASE(crank_outpost_reads_the_pools_own_refusals_as_outcomes) try { const std::vector> refusals{ {"WIRE_NoYield()", std::string(hex_prefix) + std::string(no_yield_selector)}, @@ -1776,6 +1785,7 @@ BOOST_AUTO_TEST_CASE(crank_outpost_reads_the_pools_own_refusals_as_outcomes) try encode_two_word_revert(yield_below_deadband_selector, test_yield_delta, test_yield_deadband)}, {"WIRE_PoolUnderbacked(uint64,uint64)", encode_two_word_revert(pool_underbacked_selector, test_yield_delta, test_yield_deadband)}, + {"EnforcedPause()", std::string(hex_prefix) + std::string(enforced_pause_selector)}, }; for (const auto& [description, revert_data] : refusals) { BOOST_TEST_CONTEXT(description) { diff --git a/plugins/outpost_solana_client_plugin/README.md b/plugins/outpost_solana_client_plugin/README.md index c1f91ffca1..01855b4d67 100644 --- a/plugins/outpost_solana_client_plugin/README.md +++ b/plugins/outpost_solana_client_plugin/README.md @@ -94,9 +94,10 @@ Loads one or more Anchor IDL JSON files for use with `solana_program_client`: Anchor IDL program name of the Solana OPP outpost. When an outpost client is constructed, the loaded `--solana-idl-file` set is filtered to programs whose -IDL name matches this value. The default targets the standalone `opp_outpost` -program; the clean-room outpost implementation is hosted inside the -`liqsol-core` program, whose generated IDL is named `liqsol_core`: +IDL name matches this value. The default names a standalone `opp_outpost` +program, which wire-solana no longer builds: the OPP outpost is hosted inside the +`liqsol-core` program, whose generated IDL is named `liqsol_core`, so deployments +pass: ``` --solana-outpost-program-name liqsol_core @@ -451,25 +452,23 @@ Settlement of a consensus envelope is a separate instruction (`dispatch_attestat driven from the on-chain cursor, and every effect account a handler resolves out of `remaining_accounts` must be derived by this relay. `extract_inbound_effects` walks the envelope once, in dispatch order, and `build_dispatch_manifests` derives one manifest per -attestation. The shapes and what each derives: +attestation. Attestations that need no effect account (`OPERATORS`, `BATCH_OPERATOR_GROUPS`) +keep an empty manifest so the indices stay aligned with the on-chain cursor. The one shape +that needs effect accounts, and what it derives: | `effect_shape` | Attestation | Accounts derived | |---|---|---| -| `withdraw_remit`, `slash`, `deposit_revert` | `OPERATOR_ACTION` | the operator / depositor, their `CollateralPosition` PDA, and under SPL custody the collateral vault, the destination ATA and the token program | -| `swap_remit`, `swap_revert` | `SWAP_REMIT`, `SWAP_REVERT` | the `Reserve` PDA, and under SPL custody the reserve vault, the recipient's ATA, the custody mint, its token program and any transfer-hook metas | -| `reserve_ready`, `reserve_create_cancelled` | `RESERVE_READY`, `RESERVE_CREATE_CANCELLED` | the `Reserve` PDA, plus the creator's refund accounts for the cancel | -| `desyndicate_liq` | `DESYNDICATE_LIQ` | the liqSOL pool's `GlobalState` and `DistributionState` singletons, the pool authority, the pool and user Token-2022 ATAs with their `UserRecord`s, the bucket ATA, the liqSOL mint, Token-2022, the bucket authority, the mint's transfer-hook program and extra-metas PDA, and liqsol-core itself | - -Custody is read from the account the on-chain handler branches on (`Reserve`, -`CollateralPosition`, `DistributionState`), never from the mutable `OutpostConfig` token -map. An absent account degrades to the accounts the handler needs to log-and-skip; a +| `desyndicate_liq` | `DESYNDICATE_LIQ` | the liqSOL pool's `GlobalState` and `DistributionState` singletons, the pool authority, the pool and user Token-2022 ATAs with their `UserRecord`s, the bucket ATA, the liqSOL mint, Token-2022, the bucket authority, the mint's transfer-hook program and extra-metas PDA, and liqsol-core itself; plus, for every non-zero request id and even when `DistributionState` cannot be read, the writable `PendingPayout` PDA (`["pending_desyndication", request_id_le8]`) the handler stores the payout at whenever it cannot pay inline: the outpost is frozen, the solvency check finds a custody shortfall, a share record is still the legacy layout, or the settlement meets any other chain-state refusal (wire-solana `PendingPayoutReason`: `OutpostFrozen`, `CustodyShortfall`, `LegacyUserRecord`, `SettlementRefused`). Without it any of these aborts the dispatch with `EffectAccountMissing` | + +Custody is read from the account the on-chain handler branches on (`DistributionState`), +never from the mutable `OutpostConfig` token map. An absent account degrades to the accounts the handler needs to log-and-skip; a present but unreadable one throws, because a guessed manifest is one the program is guaranteed to abort on. Each shape's account list is in lock-step with the program's `require_remaining_account` calls in wire-solana `inbound.rs`: a program-side change to what a handler requires and the matching shape here must move together. The relay -boot-checks the declarations it decodes (`Reserve`, `CollateralPosition`, -`EpochDeliveries`, `LatestOutboundEnvelope`, and `DistributionState` on a program that -declares it) so a drifted IDL fails at startup rather than on the first drain. +boot-checks the declarations it decodes (`EpochDeliveries`, `LatestOutboundEnvelope`, and +`DistributionState` on a program that declares it) so a drifted IDL fails at startup rather +than on the first drain. ## Outpost Cranks @@ -477,8 +476,11 @@ Once per epoch, right after this operator's envelope delivery lands, the outboun job calls `crank_outpost`. On Solana that is liqsol-core's permissionless `report_liq_yield`: it claims the syndicated pool's pending rewards and reports the delta since the previous report as one `LIQ_YIELD` attestation (a no-op on chain when nothing -new was claimed). The relay reads `GlobalState.wire_state` first and submits only -PostLaunch, derives every account of the instruction (`report_liq_yield_overrides`), +new was claimed). The relay reads `GlobalState` first and submits only when it is +PostLaunch and its emergency stop (`frozen`) is clear -- a frozen outpost is logged at +info and skipped, and an account still at the pre-freeze layout (8 + 131 bytes, not yet +grown by `migrate_global_state_liq_fields`) is logged and treated as not due. It derives +every account of the instruction (`report_liq_yield_overrides`), signs with the operator's Solana key, and skips on a program that does not declare the instruction. A failed crank is logged by the job and retried with the next epoch's delivery. diff --git a/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin/outpost_solana_client.hpp b/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin/outpost_solana_client.hpp index 5db20c8cde..996dace4fd 100644 --- a/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin/outpost_solana_client.hpp +++ b/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin/outpost_solana_client.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -483,6 +484,18 @@ namespace global_state { /// The `WireState` variant in which the syndicated pool is outpost property /// and `report_liq_yield` is accepted. constexpr auto post_launch = "PostLaunch"; + /// The emergency-stop flag (wire-solana `GlobalState.frozen`, set by + /// `set_frozen`). While it is set the program refuses `report_liq_yield` + /// and stores every `DESYNDICATE_LIQ` as a pending payout instead of paying it. + constexpr auto field_frozen = "frozen"; + /// Byte width of an Anchor account discriminator, which precedes the Borsh body. + inline constexpr size_t anchor_discriminator_bytes = 8; + /// `GlobalState::INIT_SPACE` before `frozen` was appended -- a byte-exact mirror + /// of wire-solana's `GLOBAL_STATE_PRE_FREEZE_INIT_SPACE`. An account whose body is + /// no longer than this predates the freeze and has not been grown by + /// `migrate_global_state_liq_fields`; the program refuses every handler that reads + /// the current layout on it. + inline constexpr size_t pre_freeze_init_space = 131; } // namespace global_state /// The key libfc's IDL decoder renders an enum field's variant name under. @@ -514,11 +527,31 @@ namespace report_liq_yield_accounts { constexpr auto system_program = "system_program"; } // namespace report_liq_yield_accounts -/// True iff a decoded `GlobalState` says the outpost is PostLaunch -- the one -/// state `report_liq_yield` accepts. Anything else (another state, a missing or -/// misshaped field) reads as "not due", so the crank never pays for a refused tx. +/// True iff a decoded `GlobalState` says the outpost's emergency stop is set. +/// `frozen` absent from the row means the loaded IDL declares no emergency stop +/// (a program that predates it), which is "not frozen"; `frozen` present but not +/// a bool is a misdecoded row and reads as frozen, so the crank never pays for a +/// tx the program could refuse. +bool liq_outpost_frozen(const fc::variant_object& global_state); + +/// True iff a decoded `GlobalState` says `report_liq_yield` would be accepted: +/// the outpost is PostLaunch and not frozen (`liq_outpost_frozen`). Anything +/// else (another state, a missing or misshaped field, the emergency stop) reads +/// as "not due", so the crank never pays for a refused tx. bool liq_yield_report_due(const fc::variant_object& global_state); +/// True iff the loaded IDL declares `GlobalState.frozen` but the on-chain account +/// is too short to carry it: `account_bytes` is no longer than the discriminator +/// plus `global_state::pre_freeze_init_space`. Such an account has not been grown +/// by `migrate_global_state_liq_fields`; the IDL-driven decode would run past its +/// end, and the program refuses the crank on it anyway, so the crank treats it as +/// "not due". An IDL without `frozen` never predates the freeze -- its program has +/// no emergency stop and its own layout is the current one. +/// +/// @param program the loaded outpost program IDL. +/// @param account_bytes the `GlobalState` account's data length, discriminator included. +bool global_state_predates_freeze(const fc::network::solana::idl::program& program, size_t account_bytes); + /// The `report_liq_yield` account overrides: every named account of the /// instruction except the signer, derived from the program id, the pool's /// liqSOL mint, the mint's transfer-hook program and the relay's own config / @@ -532,7 +565,8 @@ fc::network::solana::account_overrides_t report_liq_yield_overrides( const fc::network::solana::solana_public_key& outbound_message_buffer_pda); /// Assert the loaded IDL declares `GlobalState` with a `wire_state` whose enum -/// type carries the `PostLaunch` variant the yield crank gates on. Boot-checked +/// type carries the `PostLaunch` variant the yield crank gates on, and -- when it +/// declares the `frozen` emergency stop at all -- declares it as a bool. Boot-checked /// only on a program that declares `report_liq_yield`. void assert_global_state_shape(const fc::network::solana::idl::program& program); @@ -774,6 +808,25 @@ derive_liqsol_pool_authority_pda(const fc::network::solana::solana_public_key& p fc::network::solana::solana_public_key derive_liqsol_bucket_authority_pda(const fc::network::solana::solana_public_key& program_id); +/// Seed prefix of liqsol-core's `PendingPayout` PDA -- a byte-exact mirror of +/// wire-solana's `PENDING_PAYOUT_SEED` (`states/pending_payout.rs`). Exported so +/// the derivation and its tests spell the seed through ONE constant. +inline constexpr std::string_view PENDING_DESYNDICATION_SEED = "pending_desyndication"; + +/// The `PendingPayout` PDA a `DESYNDICATE_LIQ` is stored at when the outpost +/// cannot pay it inline (the emergency stop, or a legacy `UserRecord`): seeds +/// `["pending_desyndication", request_id.to_le_bytes()]`, byte-exact with +/// wire-solana's `PendingPayout::find_address` and `defer_desyndication`. A seed +/// drift derives a well-formed WRONG address; the handler then aborts the +/// dispatch window with `EffectAccountMissing` on every retry. +/// +/// @param program_id the liqsol-core program id. +/// @param request_id the depot's `DesyndicateLIQ.request_id` (never 0 on a real +/// desyndication; the manifest omits the PDA for 0). +fc::network::solana::solana_public_key +derive_pending_desyndication_pda(const fc::network::solana::solana_public_key& program_id, + uint64_t request_id); + /// The `UserRecord` PDA of one liqSOL token account: seeds /// `["user_record", token_account.as_ref()]`. fc::network::solana::solana_public_key @@ -849,6 +902,10 @@ struct inbound_effect { /// `deposit_revert`): the `token_code` keying the `CollateralPosition` /// PDA and its pinned custody lookup. std::optional collateral_token_code; + /// Set for `desyndicate_liq`: the depot's `DesyndicateLIQ.request_id`, which + /// keys the `PendingPayout` PDA the handler stores the payout at when it + /// cannot pay inline (`derive_pending_desyndication_pda`). + std::optional desyndication_request_id; }; /// Walk `envelope_bytes` ONCE and return every attestation that needs effect @@ -913,8 +970,13 @@ uint32_t count_inbound_attestations(const std::vector& envelope_bytes); /// * `desyndicate_liq` derives everything from the user, the program's fixed /// pool PDAs and the mint on `DistributionState`, read through /// `read_liq_pool` at most ONCE per build. A degraded (empty) read costs -/// the attestation everything but the two state singletons, which is what -/// lets the handler log-and-skip an uninitialized pool instead of aborting. +/// the attestation everything but the two state singletons and the +/// pending-payout PDA, which is what lets the handler log-and-skip an +/// uninitialized pool instead of aborting. The pending-payout PDA +/// (`derive_pending_desyndication_pda`, writable) rides right after the +/// singletons for every non-zero request id, pool read or not: the handler's +/// frozen-outpost deferral needs it before any pool account, and its +/// legacy-`UserRecord` deferral after them; the paid path never touches it. /// /// @param program_id outpost program id, for PDA derivation. /// @param effects account-needing attestations, in dispatch order. diff --git a/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp b/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp index f5c48361ea..27d417bd4d 100644 --- a/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp +++ b/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp @@ -685,6 +685,16 @@ fc::network::solana::solana_public_key derive_liqsol_bucket_authority_pda( return literal_seed_pda(program_id, LIQSOL_BUCKET_SEED); } +/// The `PendingPayout` PDA of one depot request id. Full contract on the header +/// declaration. +fc::network::solana::solana_public_key derive_pending_desyndication_pda( + const fc::network::solana::solana_public_key& program_id, uint64_t request_id) { + return fc::network::solana::system::find_program_address( + {std::vector(PENDING_DESYNDICATION_SEED.begin(), PENDING_DESYNDICATION_SEED.end()), + u64_seed(request_id)}, + program_id).first; +} + /// The `UserRecord` PDA of one liqSOL token account. Full contract on the /// header declaration. fc::network::solana::solana_public_key derive_liqsol_user_record_pda( @@ -720,6 +730,14 @@ void assert_distribution_state_shape(const fc::network::solana::idl::program& pr void assert_global_state_shape(const fc::network::solana::idl::program& program) { namespace idl = fc::network::solana::idl; const auto& fields = declared_account_fields(program, global_state::account_name); + // The emergency stop is optional -- a program that predates it declares none -- + // but a declared one is read as a bool, so any other type is a drifted IDL. + for (const auto& field : fields) { + if (field.name != global_state::field_frozen) continue; + FC_ASSERT(field.type.is_primitive() && field.type.primitive == idl::primitive_type::bool_t, + "GlobalState '{}' must be declared bool, got '{}'; the yield crank gates on it", + global_state::field_frozen, describe_idl_type(field.type)); + } for (const auto& field : fields) { if (field.name != global_state::field_wire_state) continue; FC_ASSERT(field.type.is_defined(), @@ -742,7 +760,14 @@ void assert_global_state_shape(const fc::network::solana::idl::program& program) global_state::field_wire_state); } +bool liq_outpost_frozen(const fc::variant_object& global_state_row) { + auto frozen = global_state_row.find(global_state::field_frozen); + if (frozen == global_state_row.end()) return false; // the IDL declares no emergency stop + return !frozen->value().is_bool() || frozen->value().as_bool(); +} + bool liq_yield_report_due(const fc::variant_object& global_state_row) { + if (liq_outpost_frozen(global_state_row)) return false; auto state = global_state_row.find(global_state::field_wire_state); if (state == global_state_row.end() || !state->value().is_object()) return false; const auto& state_obj = state->value().get_object(); @@ -751,6 +776,16 @@ bool liq_yield_report_due(const fc::variant_object& global_state_row) { variant->value().as_string() == global_state::post_launch; } +bool global_state_predates_freeze(const fc::network::solana::idl::program& program, size_t account_bytes) { + if (!program.find_account(global_state::account_name)) return false; + const auto& fields = declared_account_fields(program, global_state::account_name); + const bool declares_frozen = std::any_of(fields.begin(), fields.end(), [](const auto& field) { + return field.name == global_state::field_frozen; + }); + return declares_frozen && + account_bytes <= global_state::anchor_discriminator_bytes + global_state::pre_freeze_init_space; +} + fc::network::solana::account_overrides_t report_liq_yield_overrides( const fc::network::solana::solana_public_key& program_id, const fc::network::solana::solana_public_key& liqsol_mint, @@ -1166,7 +1201,7 @@ extract_inbound_effects(const std::vector& envelope_bytes) { if (!dl.ParseFromString(entry.data())) continue; if (auto pk = sol_pubkey_from_chain_address(dl.user())) { effects.push_back(inbound_effect{ - at, effect_shape::desyndicate_liq, *pk, std::nullopt, std::nullopt}); + at, effect_shape::desyndicate_liq, *pk, std::nullopt, std::nullopt, dl.request_id()}); } break; } @@ -1365,10 +1400,21 @@ std::vector> build_dispatch_manif // first because the handler loads them before anything else and turns an // uninitialized pool into a logged skip; every later account aborts the // window when missing, which is the caller-fixable retry the program wants. + // + // The request's `PendingPayout` PDA follows them, writable, whether or not + // the pool reads: `defer_desyndication` creates it when the outpost is + // frozen (checked right after the singletons, before any pool account) or a + // share record is still the legacy layout (after them), and aborts the + // window with `EffectAccountMissing` when it is absent. The paid path never + // touches it. Request id 0 cannot key one -- the handler records that payout + // as unpaid before asking for the account -- so it gets none. if (effect.shape == effect_shape::desyndicate_liq) { if (!effect.recipient) continue; add(derive_liqsol_global_state_pda(program_id), true); add(derive_liqsol_distribution_state_pda(program_id), true); + if (effect.desyndication_request_id.value_or(0) != 0) { + add(derive_pending_desyndication_pda(program_id, *effect.desyndication_request_id), true); + } const auto& pool_opt = liq_pool(); if (!pool_opt.has_value()) { @@ -2135,9 +2181,28 @@ void outpost_solana_client::crank_outpost(uint32_t epoch_index, fc::microseconds to_string(), global_state_pda.to_string(fc::yield_function_t{})); return; } - const auto global_v = _program_client->decode_account_info_data( + // A GlobalState still at the pre-freeze layout cannot carry the `frozen` the + // loaded IDL declares, so decoding it would run past its end; the program + // refuses the crank on it until `migrate_global_state_liq_fields` grows it. + if (outpost_solana_client_detail::global_state_predates_freeze(*_program_client->get_program(), + global_info->data.size())) { + wlog("outpost_solana_client[{}]: GlobalState at {} is {} bytes, the layout before the emergency " + "stop; report_liq_yield is not due until migrate_global_state_liq_fields grows it", + to_string(), global_state_pda.to_string(fc::yield_function_t{}), global_info->data.size()); + return; + } + const auto global_v = _program_client->decode_account_info_data( outpost_solana_client_detail::global_state::account_name, global_info->data); - if (!outpost_solana_client_detail::liq_yield_report_due(global_v.get_object())) { + const auto& global_row = global_v.get_object(); + // The emergency stop: the program refuses `report_liq_yield` while it is set. + // An expected state (the andon playbook clears it), not a failed crank. + if (outpost_solana_client_detail::liq_outpost_frozen(global_row)) { + ilog("outpost_solana_client[{}]: outpost is frozen (GlobalState.frozen) -- report_liq_yield is " + "not sent for epoch {}", + to_string(), epoch_index); + return; + } + if (!outpost_solana_client_detail::liq_yield_report_due(global_row)) { dlog("outpost_solana_client[{}]: outpost is not PostLaunch -- no liq yield to report", to_string()); return; diff --git a/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp b/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp index 6634a17750..bc5b922d37 100644 --- a/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp +++ b/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp @@ -1159,17 +1159,21 @@ BOOST_AUTO_TEST_CASE(extract_effects_slash_carries_collateral_position_key) try namespace { -/// Build a `DESYNDICATE_LIQ` entry releasing `token_code` to `user`. The decoder -/// reads only `user` -- the pool mint comes from `DistributionState`, not the -/// payload -- so the other fields stay neutral. +/// The depot request id `desyndicate_liq_entry` stamps unless a case names one. +constexpr uint64_t default_desyndication_request_id = 1; + +/// Build a `DESYNDICATE_LIQ` entry releasing `token_code` to `user` under +/// `request_id`. The decoder reads `user` and `request_id` -- the pool mint comes +/// from `DistributionState`, not the payload -- so the other fields stay neutral. sysio::opp::AttestationEntry desyndicate_liq_entry(uint64_t token_code, - const sysio::opp::types::ChainAddress& user) { + const sysio::opp::types::ChainAddress& user, + uint64_t request_id = default_desyndication_request_id) { sysio::opp::attestations::DesyndicateLIQ dl; dl.set_chain_code(900); *dl.mutable_user() = user; dl.mutable_amount()->set_token_code(token_code); dl.mutable_amount()->set_amount(777); - dl.set_request_id(1); + dl.set_request_id(request_id); std::string body; dl.SerializeToString(&body); @@ -1187,9 +1191,10 @@ BOOST_AUTO_TEST_CASE(extract_effects_desyndicate_liq_carries_the_user) try { // singletons, the transfer's accounts and the hook's accounts out of // remaining_accounts, and a manifest that omits them aborts the dispatch // call and pins the cursor on this attestation for every retry. + constexpr uint64_t request_id = 0x0102030405060708; auto user = filled_pubkey(0xAB); auto envelope = envelope_with_entries({ - desyndicate_liq_entry(700, make_sol_addr(user)), + desyndicate_liq_entry(700, make_sol_addr(user), request_id), // Not an SVM pubkey: dropped here and logged-and-skipped on chain, while // the flat index still advances past it. desyndicate_liq_entry(700, make_eth_addr_32(user)), @@ -1205,9 +1210,13 @@ BOOST_AUTO_TEST_CASE(extract_effects_desyndicate_liq_carries_the_user) try { BOOST_CHECK(effects[0].recipient->serialize() == user); BOOST_CHECK(!effects[0].reserve.has_value()); BOOST_CHECK(!effects[0].collateral_token_code.has_value()); + // The request id keys the pending-payout PDA a frozen outpost stores the payout at. + BOOST_REQUIRE(effects[0].desyndication_request_id.has_value()); + BOOST_CHECK_EQUAL(*effects[0].desyndication_request_id, request_id); BOOST_CHECK_EQUAL(effects[1].attestation_index, 2u); BOOST_CHECK(effects[1].shape == detail::effect_shape::withdraw_remit); + BOOST_CHECK(!effects[1].desyndication_request_id.has_value()); } FC_LOG_AND_RETHROW(); BOOST_AUTO_TEST_CASE(extract_effects_keeps_distinct_collateral_token_codes) try { @@ -1651,10 +1660,12 @@ BOOST_AUTO_TEST_CASE(build_manifests_follows_reserve_custody_per_reserve) try { namespace { -/// One DESYNDICATE_LIQ effect at `index` releasing syndicated liqSOL to `user`. -manifest_detail::inbound_effect desyndicate_liq_effect(size_t index, const solana_public_key& user) { +/// One DESYNDICATE_LIQ effect at `index` releasing syndicated liqSOL to `user` +/// under the depot's `request_id`. +manifest_detail::inbound_effect desyndicate_liq_effect(size_t index, const solana_public_key& user, + uint64_t request_id) { return manifest_detail::inbound_effect{ - index, manifest_detail::effect_shape::desyndicate_liq, user, std::nullopt, std::nullopt}; + index, manifest_detail::effect_shape::desyndicate_liq, user, std::nullopt, std::nullopt, request_id}; } /// Whether `key` rides `metas` as WRITABLE. @@ -1672,8 +1683,9 @@ bool manifest_writes(const std::vector& metas, const solana_public // singletons (writable), the pool authority, the pool and user Token-2022 ATAs // with their `UserRecord`s (all writable), the bucket ATA, the mint, Token-2022, // and the hook's accounts -- the bucket authority, the mint's extra-metas PDA, -// the hook program and liqsol-core. One DistributionState read serves every -// desyndication in the envelope. +// the hook program and liqsol-core -- plus the request's pending-payout PDA +// (writable, right after the singletons) a frozen outpost stores the payout at. +// One DistributionState read serves every desyndication in the envelope. BOOST_AUTO_TEST_CASE(build_manifests_desyndicate_liq_derives_the_pool_accounts) try { const auto liqsol_mint = measurement_pubkey(90); const auto hook_program = measurement_pubkey(91); @@ -1684,8 +1696,9 @@ BOOST_AUTO_TEST_CASE(build_manifests_desyndicate_liq_derives_the_pool_accounts) harness.put_liq_pool(liqsol_mint); harness.put_hook(liqsol_mint, hook_program, {}); + const std::array request_ids{7, 0x0102030405060708}; const auto manifests = harness.build( - {desyndicate_liq_effect(0, user_a), desyndicate_liq_effect(1, user_b)}, 2); + {desyndicate_liq_effect(0, user_a, request_ids[0]), desyndicate_liq_effect(1, user_b, request_ids[1])}, 2); BOOST_REQUIRE_EQUAL(manifests.size(), 2u); BOOST_CHECK_EQUAL(harness.liq_pool_reads, 1u); @@ -1702,10 +1715,18 @@ BOOST_AUTO_TEST_CASE(build_manifests_desyndicate_liq_derives_the_pool_accounts) const auto& m = manifests[i]; const auto& user = i == 0 ? user_a : user_b; const auto user_ata = system::get_associated_token_address(user, liqsol_mint, token_2022); + const auto pending = manifest_detail::derive_pending_desyndication_pda(program_id, request_ids[i]); BOOST_TEST_CONTEXT("attestation " << i) { - BOOST_CHECK_EQUAL(m.size(), 14u); + BOOST_REQUIRE_EQUAL(m.size(), 15u); BOOST_CHECK(manifest_writes(m, global_state)); BOOST_CHECK(manifest_writes(m, distribution)); + // The pending-payout PDA follows the two singletons, writable: the + // handler's frozen deferral creates it before touching any pool account. + BOOST_CHECK(m[0].key == global_state && m[1].key == distribution && m[2].key == pending); + BOOST_CHECK(manifest_writes(m, pending)); + // Each request keys its own PDA. + BOOST_CHECK(!manifest_has(m, manifest_detail::derive_pending_desyndication_pda( + program_id, request_ids[1 - i]))); BOOST_CHECK(manifest_has(m, pool_authority) && !manifest_writes(m, pool_authority)); BOOST_CHECK(manifest_writes(m, pool_ata)); BOOST_CHECK(manifest_writes(m, user_ata)); @@ -1729,16 +1750,87 @@ BOOST_AUTO_TEST_CASE(build_manifests_desyndicate_liq_derives_the_pool_accounts) // singletons only: the handler loads them first and turns an uninitialized // pool into a logged skip, so nothing behind them is derivable or required. BOOST_AUTO_TEST_CASE(build_manifests_desyndicate_liq_degrades_to_the_state_singletons) try { + constexpr uint64_t request_id = 11; manifest_build_harness harness; // no pool seeded - const auto manifests = harness.build({desyndicate_liq_effect(0, measurement_pubkey(94))}, 1); + const auto manifests = harness.build({desyndicate_liq_effect(0, measurement_pubkey(94), request_id)}, 1); BOOST_REQUIRE_EQUAL(manifests.size(), 1u); const auto& m = manifests[0]; - BOOST_CHECK_EQUAL(m.size(), 2u); + BOOST_CHECK_EQUAL(m.size(), 3u); BOOST_CHECK(manifest_writes(m, manifest_detail::derive_liqsol_global_state_pda(harness.program_id))); BOOST_CHECK(manifest_writes(m, manifest_detail::derive_liqsol_distribution_state_pda(harness.program_id))); + // A frozen outpost defers before it reads the pool, so the pending-payout PDA + // rides even when the pool read degrades. + BOOST_CHECK(manifest_writes(m, manifest_detail::derive_pending_desyndication_pda(harness.program_id, request_id))); BOOST_CHECK_EQUAL(harness.hook_reads, 0u); } FC_LOG_AND_RETHROW(); +// Request id 0 is "no id": it cannot key a PendingPayout, and the handler records +// such a payout as unpaid before it asks for the pending account, so the manifest +// carries no pending-payout PDA for it -- and nothing else changes. +BOOST_AUTO_TEST_CASE(build_manifests_desyndicate_liq_omits_the_pending_pda_for_request_id_zero) try { + const auto liqsol_mint = measurement_pubkey(90); + const auto hook_program = measurement_pubkey(91); + const auto user = measurement_pubkey(92); + + manifest_build_harness harness; + harness.put_liq_pool(liqsol_mint); + harness.put_hook(liqsol_mint, hook_program, {}); + + const auto manifests = harness.build({desyndicate_liq_effect(0, user, 0), + // An effect built without an id at all behaves the same. + manifest_detail::inbound_effect{ + 1, manifest_detail::effect_shape::desyndicate_liq, user, + std::nullopt, std::nullopt, std::nullopt}}, + 2); + BOOST_REQUIRE_EQUAL(manifests.size(), 2u); + for (size_t i = 0; i < manifests.size(); ++i) { + BOOST_TEST_CONTEXT("attestation " << i) { + BOOST_CHECK_EQUAL(manifests[i].size(), 14u); + BOOST_CHECK(!manifest_has(manifests[i], + manifest_detail::derive_pending_desyndication_pda(harness.program_id, 0))); + } + } + + manifest_build_harness degraded; // no pool seeded + const auto bare = degraded.build({desyndicate_liq_effect(0, user, 0)}, 1); + BOOST_REQUIRE_EQUAL(bare.size(), 1u); + BOOST_CHECK_EQUAL(bare[0].size(), 2u); +} FC_LOG_AND_RETHROW(); + +// The pending-payout PDA is derived exactly as wire-solana's +// `PendingPayout::find_address` does: `["pending_desyndication", +// request_id.to_le_bytes()]` under liqsol-core. The expected addresses were +// computed independently with `@solana/web3.js`'s `findProgramAddressSync` +// against liqsol-core's declared program id; a multi-byte id pins the +// little-endian encoding. +BOOST_AUTO_TEST_CASE(pending_desyndication_pda_matches_the_program_derivation) try { + const auto liqsol_core = solana_public_key::from_base58_string("5nBtmutQLrRKBUxNfHJPDjiW5u8id6QM9Hhjg1D1g1XH"); + const std::vector> expected{ + {1, "6nDs1ohHKjjBjDp9KD8BDvjjqs2ZEYeR3eTHyF7ZNA8T"}, + {7, "BV578hrPb772NQkcMxVUS55QFsJpZw9Xq9hAQXiJioJd"}, + {0x0102030405060708, "5K37mePygNUJ3W9nwD4fkFH9ehUxMVKNyseipwmMrfED"}, + }; + for (const auto& [request_id, address] : expected) { + BOOST_TEST_CONTEXT("request_id " << request_id) { + BOOST_CHECK_EQUAL(manifest_detail::derive_pending_desyndication_pda(liqsol_core, request_id) + .to_string(fc::yield_function_t{}), + address); + } + } + // The seed constant is the program's `PENDING_PAYOUT_SEED`, and the derivation + // spells it through that constant. + BOOST_CHECK_EQUAL(manifest_detail::PENDING_DESYNDICATION_SEED, "pending_desyndication"); + std::vector id_le(sizeof(uint64_t)); + for (size_t i = 0; i < id_le.size(); ++i) id_le[i] = static_cast(uint64_t{7} >> (8 * i)); + BOOST_CHECK(manifest_detail::derive_pending_desyndication_pda(liqsol_core, 7) == + system::find_program_address( + {std::vector(manifest_detail::PENDING_DESYNDICATION_SEED.begin(), + manifest_detail::PENDING_DESYNDICATION_SEED.end()), + id_le}, + liqsol_core) + .first); +} FC_LOG_AND_RETHROW(); + namespace { /// Pack a seed list into an `ExtraAccountMeta::address_config`, exactly as @@ -1984,6 +2076,38 @@ BOOST_AUTO_TEST_CASE(hook_manifest_worst_case_fits_the_dispatch_budget) try { BOOST_CHECK_EQUAL(manifests[1].size(), 13u); } FC_LOG_AND_RETHROW(); +/// The DESYNDICATE_LIQ manifest under the REAL liqSOL hook declaration, pending- +/// payout PDA included. Every meta the hook declares resolves to an account the +/// handler already requires (liqsol-core, the two `UserRecord`s, +/// `DistributionState`, the bucket ATA), so the hook adds only its program and +/// extra-metas PDA and one desyndication costs 15 of the 16 dynamic slots. The +/// packer therefore carries one desyndication per dispatch window (two would +/// need 18). Pinned exactly so the next program-side account spends the last +/// slot visibly. +BOOST_AUTO_TEST_CASE(desyndicate_liq_manifest_under_the_real_hook_fits_the_dispatch_budget) try { + const auto liqsol_mint = measurement_pubkey(110); + const auto hook_program = measurement_pubkey(111); + + manifest_build_harness harness; + const auto bucket_ata = system::get_associated_token_address( + manifest_detail::derive_liqsol_bucket_authority_pda(harness.program_id), liqsol_mint, + system::program_ids::TOKEN_2022_PROGRAM); + harness.put_liq_pool(liqsol_mint); + harness.put_hook(liqsol_mint, hook_program, + {literal_meta(harness.program_id, false), + external_pda_meta(5, {seed_literal{"user_record"}, seed_account_key{0}}, true), + external_pda_meta(5, {seed_literal{"user_record"}, seed_account_key{2}}, true), + external_pda_meta(5, {seed_literal{"distribution_state"}}, true), + literal_meta(bucket_ata, false)}); + + const auto manifests = harness.build({desyndicate_liq_effect(0, measurement_pubkey(112), 9)}, 1); + BOOST_REQUIRE_EQUAL(manifests.size(), 1u); + BOOST_CHECK_LE(manifests[0].size(), sysio::MAX_TERMINAL_DYNAMIC_ACCOUNTS); + BOOST_CHECK_EQUAL(manifests[0].size(), 15u); + BOOST_CHECK(manifest_has(manifests[0], + manifest_detail::derive_pending_desyndication_pda(harness.program_id, 9))); +} FC_LOG_AND_RETHROW(); + /// A `ExtraAccountMeta` may legally demand a signature. The dispatch /// transaction is signed by the batch operator alone, so the resolver cannot /// satisfy one -- and dropping the flag silently (keeping only key+writable) @@ -3121,10 +3245,13 @@ BOOST_AUTO_TEST_CASE(distribution_state_shape_accepts_a_pubkey_mint_and_rejects_ namespace { /// A synthetic program declaring `GlobalState` with `wire_state` of enum type -/// `WireState` carrying `variants`. -idl::program global_state_program(std::vector variants, bool fields_in_types_section) { - auto prog = named_account_program( - "GlobalState", {{"wire_state", idl::idl_type::make_defined("WireState")}}, fields_in_types_section); +/// `WireState` carrying `variants`, and -- when `frozen_type` is set -- a +/// trailing `frozen` field of that type. +idl::program global_state_program(std::vector variants, bool fields_in_types_section, + std::optional frozen_type = std::nullopt) { + std::vector fields{{"wire_state", idl::idl_type::make_defined("WireState")}}; + if (frozen_type) fields.push_back({"frozen", *frozen_type}); + auto prog = named_account_program("GlobalState", std::move(fields), fields_in_types_section); idl::type_def wire_state; wire_state.name = "WireState"; wire_state.enum_variants = std::vector{}; @@ -3156,6 +3283,13 @@ BOOST_AUTO_TEST_CASE(global_state_shape_requires_a_post_launch_variant) try { BOOST_CHECK_THROW(assert_global_state_shape(named_account_program( "GlobalState", {{"wire_state", prim(idl::primitive_type::u8)}}, in_types)), fc::assert_exception); + // The emergency stop is optional (a program predating it declares none), + // but when declared it must be the bool the crank reads. + BOOST_CHECK_NO_THROW(assert_global_state_shape( + global_state_program({"PostLaunch"}, in_types, prim(idl::primitive_type::bool_t)))); + BOOST_CHECK_THROW(assert_global_state_shape( + global_state_program({"PostLaunch"}, in_types, prim(idl::primitive_type::u8))), + fc::assert_exception); } } } FC_LOG_AND_RETHROW(); @@ -3175,6 +3309,61 @@ BOOST_AUTO_TEST_CASE(liq_yield_report_is_due_only_post_launch) try { BOOST_CHECK(!liq_yield_report_due(fc::mutable_variant_object()("wire_state", "PostLaunch"))); } FC_LOG_AND_RETHROW(); +// The emergency stop: while `GlobalState.frozen` is set the program refuses +// `report_liq_yield`, so the crank does not fire. A row without `frozen` comes +// from an IDL that declares no emergency stop and stays due; a `frozen` of any +// other shape is a misdecoded row and never due. +BOOST_AUTO_TEST_CASE(liq_yield_report_is_not_due_while_the_outpost_is_frozen) try { + using sysio::outpost_solana_client_detail::liq_outpost_frozen; + using sysio::outpost_solana_client_detail::liq_yield_report_due; + auto state = [](const char* variant, std::optional frozen) { + fc::mutable_variant_object row; + row("wire_state", fc::mutable_variant_object()("variant", variant)); + if (frozen) row("frozen", *frozen); + return fc::variant_object(row); + }; + BOOST_CHECK(!liq_outpost_frozen(state("PostLaunch", fc::variant(false)))); + BOOST_CHECK(liq_yield_report_due(state("PostLaunch", fc::variant(false)))); + + BOOST_CHECK(liq_outpost_frozen(state("PostLaunch", fc::variant(true)))); + BOOST_CHECK(!liq_yield_report_due(state("PostLaunch", fc::variant(true)))); + BOOST_CHECK(!liq_yield_report_due(state("PreLaunch", fc::variant(true)))); + + BOOST_CHECK(!liq_outpost_frozen(state("PostLaunch", std::nullopt))); + BOOST_CHECK(liq_yield_report_due(state("PostLaunch", std::nullopt))); + + BOOST_CHECK(liq_outpost_frozen(state("PostLaunch", fc::variant(uint64_t{0})))); + BOOST_CHECK(!liq_yield_report_due(state("PostLaunch", fc::variant("false")))); +} FC_LOG_AND_RETHROW(); + +// A `GlobalState` still at the pre-freeze layout (8 + 131 bytes, wire-solana +// `GLOBAL_STATE_PRE_FREEZE_INIT_SPACE`) cannot carry the `frozen` the loaded IDL +// declares: the crank treats it as not due instead of decoding past its end. The +// grown account (one byte longer) is read normally, and an IDL that declares no +// emergency stop never gates on the length. +BOOST_AUTO_TEST_CASE(global_state_predates_freeze_only_under_an_idl_that_declares_it) try { + namespace detail = sysio::outpost_solana_client_detail; + using detail::global_state_predates_freeze; + constexpr size_t pre_freeze_bytes = + detail::global_state::anchor_discriminator_bytes + detail::global_state::pre_freeze_init_space; + BOOST_CHECK_EQUAL(pre_freeze_bytes, 139u); + + for (bool in_types : {false, true}) { + BOOST_TEST_CONTEXT("fields in types section: " << in_types) { + const auto with_freeze = + global_state_program({"PostLaunch"}, in_types, prim(idl::primitive_type::bool_t)); + BOOST_CHECK(global_state_predates_freeze(with_freeze, pre_freeze_bytes)); + // The layout before the liq fields (8 + 115) is older still. + BOOST_CHECK(global_state_predates_freeze(with_freeze, pre_freeze_bytes - 16)); + BOOST_CHECK(!global_state_predates_freeze(with_freeze, pre_freeze_bytes + 1)); + + const auto without_freeze = global_state_program({"PostLaunch"}, in_types); + BOOST_CHECK(!global_state_predates_freeze(without_freeze, pre_freeze_bytes)); + } + } + BOOST_CHECK(!global_state_predates_freeze(idl::program{}, 0)); +} FC_LOG_AND_RETHROW(); + // The overrides cover every non-signer account `report_liq_yield` declares, and // derive each the way the program's `#[derive(Accounts)]` constraints do: the // Token-2022 ATAs of the pool and bucket authorities, the `UserRecord`s seeded diff --git a/plugins/signature_provider_manager_plugin/README.md b/plugins/signature_provider_manager_plugin/README.md index 215616e7fa..cbce5bd6a9 100644 --- a/plugins/signature_provider_manager_plugin/README.md +++ b/plugins/signature_provider_manager_plugin/README.md @@ -3,7 +3,7 @@ `signature_provider_manager_plugin` owns every signing key the node uses. It parses each `--signature-provider` spec, builds the matching signer, and publishes the whole set through one lookup API (by name, by public key, by chain kind, by key type) that `chain_plugin`, `producer_plugin`, -`net_plugin`, the outpost clients, and `underwriter_plugin` query. An operator never enables it by hand: +`net_plugin`, and the outpost clients query. An operator never enables it by hand: every one of those plugins names it in `APPBASE_PLUGIN_REQUIRES`, so appbase loads and initializes it before any consumer. It requires `http_client_plugin`, which it uses for the `KIOD:` scheme. @@ -59,7 +59,7 @@ there is no flag to skip it. The provider set is immutable once the node is running. Every mutator asserts it was called before startup, and all mutation happens on the main thread during the sequential initialize/startup phases, so runtime -lookups from producer, batch-operator, underwriter, and outpost threads need no synchronization. +lookups from producer, batch-operator, and outpost threads need no synchronization. ### Generated default providers @@ -171,6 +171,6 @@ on the generated defaults file. The AWS-backed schemes are tested with their own the `kiod` side of a `KIOD:` provider. - `http_client_plugin` — the HTTP client a `KIOD:` provider signs through; required by this plugin. - `chain_plugin`, `producer_plugin`, `net_plugin`, `outpost_client_plugin`, - `outpost_ethereum_client_plugin`, `outpost_solana_client_plugin`, `underwriter_plugin` — consumers that - require this plugin and therefore load it automatically. + `outpost_ethereum_client_plugin`, `outpost_solana_client_plugin` — consumers that require this plugin and + therefore load it automatically. - `docs/signature-provider-manager-plugin.md` — the spec reference with worked examples. From e9ac87c194cfa07a692c013fd826670ba43050c2 Mon Sep 17 00:00:00 2001 From: David J Parrott Date: Tue, 6 Oct 2026 20:52:08 -0400 Subject: [PATCH 5/5] Remove retired external settlement paths The depot-native collateral and syndication contracts own the live custody and underwriting lifecycle. Remove the dormant outpost- collateral, reserve/swap settlement, and legacy underwriting paths that duplicate that responsibility and leave misleading APIs available. Delete the underwriter plugin, sysio.uwrit, retired reserve actions and ledgers, external collateral dispatch, old challenge integration, reserve fee emissions, and their protocol/client adapters. Remove their exclusive tests, fixtures, configuration, and build wiring together. Keep depot-side LIQ custody, yield, and swaps and update the deployment guidance. Keep the generic flow coverage, final destination assertions, and native producer collateral tests intact. Refresh affected checked-in contract artifacts with their source removals so deployed interfaces match the smaller supported surface. Co-authored-by: Jonathan Glanz Change-Id: I542ab89dd2ac4824cad6c1d0774aa47d984f50bc --- CLAUDE.md | 2 +- cmake/chain-tools.cmake | 1 - contracts/CMakeLists.txt | 2 - contracts/sysio.chalg/CMakeLists.txt | 3 +- contracts/sysio.chalg/README.md | 5 +- .../include/sysio.chalg/sysio.chalg.hpp | 181 - contracts/sysio.chalg/src/sysio.chalg.cpp | 462 -- contracts/sysio.chalg/sysio.chalg.abi | 327 +- contracts/sysio.chalg/sysio.chalg.wasm | Bin 67636 -> 26819 bytes contracts/sysio.dclaim/CMakeLists.txt | 1 - contracts/sysio.epoch/CMakeLists.txt | 2 - .../include/sysio.epoch/sysio.epoch.hpp | 2 - contracts/sysio.epoch/src/sysio.epoch.cpp | 87 - contracts/sysio.epoch/sysio.epoch.wasm | Bin 79865 -> 79006 bytes contracts/sysio.msgch/src/sysio.msgch.cpp | 297 +- contracts/sysio.msgch/sysio.msgch.wasm | Bin 173853 -> 148850 bytes contracts/sysio.opreg/CMakeLists.txt | 1 - .../include/sysio.opreg/sysio.opreg.hpp | 207 +- contracts/sysio.opreg/src/sysio.opreg.cpp | 603 +- contracts/sysio.opreg/sysio.opreg.abi | 93 - contracts/sysio.opreg/sysio.opreg.wasm | Bin 106138 -> 84766 bytes contracts/sysio.reserv/CMakeLists.txt | 47 - .../include/sysio.reserv/sysio.reserv.hpp | 741 -- contracts/sysio.reserv/src/sysio.reserv.cpp | 1268 ---- contracts/sysio.reserv/sysio.reserv.abi | 909 --- contracts/sysio.reserv/sysio.reserv.wasm | Bin 89303 -> 0 bytes contracts/sysio.system/EMISSIONS.md | 29 +- contracts/sysio.system/src/emissions.cpp | 93 +- contracts/sysio.system/sysio.system.wasm | Bin 203370 -> 202678 bytes contracts/sysio.uwrit/CMakeLists.txt | 50 - contracts/sysio.uwrit/README.md | 101 - .../include/sysio.uwrit/sysio.uwrit.hpp | 1007 --- contracts/sysio.uwrit/src/sysio.uwrit.cpp | 2783 -------- contracts/sysio.uwrit/sysio.uwrit.abi | 940 --- contracts/sysio.uwrit/sysio.uwrit.wasm | Bin 161033 -> 0 bytes contracts/tests/contracts.hpp.in | 4 - contracts/tests/depot_flow_coverage.json | 4 +- contracts/tests/emissions_tests.cpp | 456 +- contracts/tests/sysio.dispatch_tests.cpp | 6080 +---------------- contracts/tests/sysio.dispute_tests.cpp | 41 +- .../tests/sysio.epoch_flushwtdw_tests.cpp | 190 +- contracts/tests/sysio.msgch_chain_tests.cpp | 40 +- contracts/tests/sysio.opreg_tests.cpp | 259 +- contracts/tests/sysio.reserv_tests.cpp | 1935 ------ contracts/tests/sysio.uwrit_tests.cpp | 471 -- docs/contract-upgrade-order.md | 145 +- .../sysio/opp/uic_signature_canonical.hpp | 148 - .../opp/test/uic_signature_test_utils.hpp | 147 - plugins/CMakeLists.txt | 1 - .../test/mocks/mock_outpost_client.hpp | 24 +- .../sysio/http_plugin/api_category.hpp | 7 +- plugins/http_plugin/src/http_plugin.cpp | 28 +- plugins/http_plugin/test/unit_tests.cpp | 43 +- .../sysio/outpost_client/outpost_client.hpp | 37 - .../test/test_outpost_client_interface.cpp | 3 - .../sysio/outpost_ethereum_client_plugin.hpp | 28 +- .../outpost_ethereum_client.hpp | 10 +- .../src/outpost_ethereum_client.cpp | 42 +- .../src/outpost_ethereum_client_plugin.cpp | 4 +- .../test/test_ethereum_transaction_policy.cpp | 9 - .../test_outpost_ethereum_client_plugin.cpp | 6 - .../sysio/outpost_solana_client_plugin.hpp | 83 +- .../outpost_solana_client.hpp | 339 +- .../src/outpost_solana_client.cpp | 650 +- .../test_outpost_solana_client_plugin.cpp | 1370 +--- plugins/underwriter_plugin/CMakeLists.txt | 20 - plugins/underwriter_plugin/README.md | 276 - .../underwriter_plugin/routing_detail.hpp | 175 - .../solana_source_deposit_scanner.hpp | 324 - .../source_deposit_constants.hpp | 113 - .../source_deposit_hash_detail.hpp | 62 - .../sysio/underwriter_plugin/sync_detail.hpp | 149 - .../uic_construction_detail.hpp | 53 - .../uic_signature_detail.hpp | 279 - .../underwriter_plugin/underwriter_plugin.hpp | 72 - .../variant_enum_detail.hpp | 51 - .../src/underwriter_plugin.cpp | 3054 --------- plugins/underwriter_plugin/test/main.cpp | 2 - .../test/test_underwriter_plugin.cpp | 1238 ---- .../test/test_underwriter_routing.cpp | 233 - .../test/test_underwriter_sync.cpp | 147 - programs/nodeop/main.cpp | 2 - .../fixtures/solana-idl-opp-outpost-stub.json | 199 +- 83 files changed, 858 insertions(+), 28439 deletions(-) delete mode 100644 contracts/sysio.reserv/CMakeLists.txt delete mode 100644 contracts/sysio.reserv/include/sysio.reserv/sysio.reserv.hpp delete mode 100644 contracts/sysio.reserv/src/sysio.reserv.cpp delete mode 100644 contracts/sysio.reserv/sysio.reserv.abi delete mode 100755 contracts/sysio.reserv/sysio.reserv.wasm delete mode 100644 contracts/sysio.uwrit/CMakeLists.txt delete mode 100644 contracts/sysio.uwrit/README.md delete mode 100644 contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp delete mode 100644 contracts/sysio.uwrit/src/sysio.uwrit.cpp delete mode 100644 contracts/sysio.uwrit/sysio.uwrit.abi delete mode 100755 contracts/sysio.uwrit/sysio.uwrit.wasm delete mode 100644 contracts/tests/sysio.reserv_tests.cpp delete mode 100644 contracts/tests/sysio.uwrit_tests.cpp delete mode 100644 libraries/opp/include/sysio/opp/uic_signature_canonical.hpp delete mode 100644 libraries/opp/test/include/sysio/opp/test/uic_signature_test_utils.hpp delete mode 100644 plugins/underwriter_plugin/CMakeLists.txt delete mode 100644 plugins/underwriter_plugin/README.md delete mode 100644 plugins/underwriter_plugin/include/sysio/underwriter_plugin/routing_detail.hpp delete mode 100644 plugins/underwriter_plugin/include/sysio/underwriter_plugin/solana_source_deposit_scanner.hpp delete mode 100644 plugins/underwriter_plugin/include/sysio/underwriter_plugin/source_deposit_constants.hpp delete mode 100644 plugins/underwriter_plugin/include/sysio/underwriter_plugin/source_deposit_hash_detail.hpp delete mode 100644 plugins/underwriter_plugin/include/sysio/underwriter_plugin/sync_detail.hpp delete mode 100644 plugins/underwriter_plugin/include/sysio/underwriter_plugin/uic_construction_detail.hpp delete mode 100644 plugins/underwriter_plugin/include/sysio/underwriter_plugin/uic_signature_detail.hpp delete mode 100644 plugins/underwriter_plugin/include/sysio/underwriter_plugin/underwriter_plugin.hpp delete mode 100644 plugins/underwriter_plugin/include/sysio/underwriter_plugin/variant_enum_detail.hpp delete mode 100644 plugins/underwriter_plugin/src/underwriter_plugin.cpp delete mode 100644 plugins/underwriter_plugin/test/main.cpp delete mode 100644 plugins/underwriter_plugin/test/test_underwriter_plugin.cpp delete mode 100644 plugins/underwriter_plugin/test/test_underwriter_routing.cpp delete mode 100644 plugins/underwriter_plugin/test/test_underwriter_sync.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 6c3c147795..5c7b5538b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -405,7 +405,7 @@ find contracts/ unittests/test-contracts/ -name "*.actions.cpp" -o -name "*.disp After building contracts, you MUST copy the compiled `.wasm` and `.abi` files from the build directory back to the source tree. This is required before generating system contract types and before testing. ```bash -for c in epoch opreg msgch uwrit chalg authex; do +for c in epoch opreg msgch chalg authex synd bond; do cp "$BUILD_DIR/contracts/sysio.$c/sysio.$c.wasm" "contracts/sysio.$c/" 2>/dev/null cp "$BUILD_DIR/contracts/sysio.$c/sysio.$c.abi" "contracts/sysio.$c/" 2>/dev/null done diff --git a/cmake/chain-tools.cmake b/cmake/chain-tools.cmake index a1c26ea906..c62891999d 100644 --- a/cmake/chain-tools.cmake +++ b/cmake/chain-tools.cmake @@ -12,7 +12,6 @@ macro(chain_target TARGET) PRIVATE batch_operator_plugin external_debugging_plugin - underwriter_plugin chain_api_plugin db_size_api_plugin net_api_plugin diff --git a/contracts/CMakeLists.txt b/contracts/CMakeLists.txt index 1dcd1ff0d5..ab2759f5ea 100644 --- a/contracts/CMakeLists.txt +++ b/contracts/CMakeLists.txt @@ -50,10 +50,8 @@ add_subdirectory(sysio.tokens) add_subdirectory(sysio.epoch) add_subdirectory(sysio.opreg) add_subdirectory(sysio.msgch) -add_subdirectory(sysio.uwrit) add_subdirectory(sysio.chalg) add_subdirectory(sysio.councl) -add_subdirectory(sysio.reserv) add_subdirectory(sysio.dclaim) add_subdirectory(sysio.andon) diff --git a/contracts/sysio.chalg/CMakeLists.txt b/contracts/sysio.chalg/CMakeLists.txt index f68d36e68a..d96f0ac10e 100644 --- a/contracts/sysio.chalg/CMakeLists.txt +++ b/contracts/sysio.chalg/CMakeLists.txt @@ -35,9 +35,8 @@ if(BUILD_SYSTEM_CONTRACTS) $ $ $ - $ - $ $ + $ $ $ $ diff --git a/contracts/sysio.chalg/README.md b/contracts/sysio.chalg/README.md index 89a10a1c91..4131b70e4f 100644 --- a/contracts/sysio.chalg/README.md +++ b/contracts/sysio.chalg/README.md @@ -53,6 +53,7 @@ OPP envelope dispute resolution and slash-execution contract. - Triggered by `sysio.msgch::evalcons`; dispatches the winning envelope via `sysio.msgch::resolvedisp` - Tier-1 electorate snapshot from `sysio.roa::nodeowners`; the active `network_gen` is read through the shared canonical ROA accessor -- Slashes via `sysio.opreg::slash` (opreg routes the unlocked bond to the matching LP and defers the - locked portion through `sysio.uwrit::release`) +- Slashes via `sysio.opreg::slash`: opreg marks the operator `SLASHED` and removes its whole + depot-native collateral balance from the operator's row; the slashed tokens stay in + `sysio.opreg`'s custody - Pauses / unpauses via `sysio.epoch::pause` / `sysio.epoch::unpause` diff --git a/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp b/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp index 55b9f43220..0850d96710 100644 --- a/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp +++ b/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp @@ -103,112 +103,13 @@ namespace sysio { /// The fault a challenger alleges against the winning underwriter's commit. The council /// adjudicates the allegation against source-chain state (not visible to the depot) and /// votes; the enum classifies the case for the audit row and indexers. - enum class underwrite_fault_reason : uint8_t { - SOURCE_DEPOSIT_MISSING = 0, ///< the committed source deposit does not exist on the source chain - SOURCE_DEPOSIT_MISMATCH = 1, ///< the deposit exists but its parameters do not match the commit - NON_CANONICAL_ENVELOPE = 2, ///< the commit rides an envelope later adjudicated non-canonical - }; /// A Tier-1 voter's ballot in an underwriter challenge. The two reject flavours let the /// council separate an honest mistake (bond refunded to the challenger) from a frivolous or /// malicious challenge (bond forfeited to the wrongly-challenged underwriter) — forfeiture /// only ever happens by explicit council judgment, never by default. - enum class uwchal_ballot : uint8_t { - UPHOLD = 0, ///< fault proven — slash the underwriter; bond returns to the challenger - REJECT_REFUND = 1, ///< no fault; honest mistake — bond returns to the challenger - REJECT_FORFEIT = 2, ///< no fault; frivolous or malicious — bond goes to the underwriter - }; /// Terminal verdict of a challenge (NONE while it is OPEN). - enum class uwchal_verdict : uint8_t { - NONE = 0, ///< challenge still open - UPHELD = 1, ///< underwriter slashed; bond returned to the challenger - REJECTED_REFUND = 2, ///< council rejected — honest mistake; bond returned to the challenger - REJECTED_FORFEIT = 3, ///< council rejected — frivolous; bond forfeited to the underwriter - LAPSED = 4, ///< lock window expired with no quorum — bond returned; nobody punished - }; - - /// Open a challenge against the WINNING underwriter of a CONFIRMED underwrite request, - /// alleging its commit was faulty. Permissionless — anyone may file — but time-gated to the - /// commitment's collateral-lock window and priced: the challenger escrows the WIRE value of - /// the challenged locks as a bond (`uwchalbond` quotes it; both run one shared formula, so - /// the quote can never drift from the charge — the `split_wire_fee` principle). - /// - /// Effects, in order: validates the commitment (uwreq CONFIRMED, `underwriter` is its - /// winner, no prior challenge for this commitment — a verdict is final per commitment; - /// other commitments by the same underwriter are independently challengeable), snapshots - /// the Tier-1 electorate + quorum exactly as `opendispute` does, escrows the bond (inline - /// `sysio.token::transfer` under the challenger's own authority), records the OPEN row, and - /// marks the locks via `sysio.uwrit::holdlocks` — a held lock is NOT released at expiry - /// while its challenge is open. uwrit re-validates lock liveness authoritatively, so a - /// stale filing aborts whole, bond escrow included. - /// - /// `reason` is the numeric `underwrite_fault_reason` value (checked through - /// `magic_enum::enum_cast` at this trust boundary); `detail` carries the challenger's - /// free-text evidence context for the council (source tx ids, observed amounts, notes). - [[sysio::action]] - void openuwchal(name challenger, - uint64_t uwreq_id, - name underwriter, - uint8_t reason, - std::string detail); - - /// Cast a Tier-1 ballot in an open underwriter challenge. `ballot` is the numeric - /// `uwchal_ballot` value. Same eligibility rules as `votedispute`: the voter must belong to - /// the challenge's snapshotted electorate, one vote per owner. Record-only — the tally - /// lives in `chkuwchal`, mirroring the votedispute/chkdispute split. - [[sysio::action]] - void voteuwchal(name owner, uint64_t chal_id, uint8_t ballot); - - /// Permissionless tally crank — `chkdispute`'s sibling. Nobody has to run it on a cadence: - /// `sysio.uwrit::chklocks` (inlined from every `sysio.epoch::advance`) pokes it for any - /// expired-but-challenged lock, which works precisely because the chain is NOT paused - /// during an underwriter challenge. A manual call after the deciding vote resolves sooner. - /// - /// Resolution rules (N/Q = the snapshotted electorate size / quorum, fixed at open): - /// * UPHOLD ballots reach Q, any time — UPHELD: `slashop` the underwriter, then - /// `sysio.uwrit::sweeplocks` releases the now-deferred-slash locks (the slash lands - /// first — inline actions run depth-first in send order — so `opreg::releaselock` - /// takes its SLASHED branch, debiting the locked collateral and emitting the outbound - /// SLASH attestations); the bond is credited back to the challenger. - /// * REJECT ballots (both flavours combined) reach Q, any time — REJECTED: disposition by - /// majority among the rejectors, a tie favouring refund; `sysio.uwrit::freelocks` - /// clears the hold and the locks live out their natural window. - /// * `deadline_ms` (the locks' own expiry) passes with neither threshold met — LAPSED: - /// bond credited back to the challenger, holds cleared, the locks release on the next - /// normal sweep. No verdict, nobody punished. - /// - /// The bond is CREDITED to a claimable balance, never transferred from here — see - /// `claimbond`. Resolution also compacts the challenge row and erases its ballots. - [[sysio::action]] - void chkuwchal(uint64_t chal_id); - - /// Withdraw the WIRE this contract owes `account` from resolved underwriter challenges — - /// a refunded challenger bond, or one forfeited to a wrongly-challenged underwriter. - /// Erases the credit row and transfers the whole balance. Auth: `account` (the - /// `sysio.dclaim::claim` pattern). - /// - /// Why the payout is a PULL and not a push: `chkuwchal` can run inline under the epoch tick - /// (`sysio.epoch::advance` -> `sysio.uwrit::chklocks` -> `chkuwchal`), and - /// `sysio.token::transfer` notifies its recipient — `require_recipient(to)` runs whatever - /// code that account has deployed. A challenger contract that allows its outgoing escrow - /// and asserts on the incoming refund would abort the entire `advance` transaction; the - /// rollback leaves the challenge OPEN with its locks still held, so every later sweep - /// re-pokes the same failing transfer and epoch advancement stalls chain-wide - /// (`epoch-stall-is-fatal`, the same hazard `opreg::releaselock` clamps for). Crediting a - /// balance keeps every account the crank touches system-owned: the only transaction a - /// hostile recipient can abort is its own claim, and only its own funds are stranded. - [[sysio::action]] - void claimbond(name account); - - /// Read-only: the WIRE bond `openuwchal` would require RIGHT NOW to challenge this - /// commitment — the winning underwriter's live lock amounts, each valued through its own - /// reserve's current books (`opp::amm::token_to_wire`), summed. Returns 0 when the - /// commitment is not currently challengeable (no CONFIRMED uwreq, `underwriter` is not its - /// winner, a challenge already exists, or no live unheld locks) or when a leg cannot be - /// priced. Shares its formula helper with `openuwchal`. - [[sysio::action, sysio::read_only]] - uint64_t uwchalbond(uint64_t uwreq_id, name underwriter); // ----------------------------------------------------------------------- // Tables @@ -296,11 +197,6 @@ namespace sysio { using chalgstate_t = sysio::kv::global<"chalgstate"_n, chalg_state>; /// Challenge primary key (auto-incrementing id). - struct uwchal_key { - uint64_t id; - uint64_t primary_key() const { return id; } - SYSLIB_SERIALIZE(uwchal_key, (id)) - }; /// An underwriter-fault challenge. Opened permissionlessly against one CONFIRMED /// commitment; resolved by a Tier-1 vote or lapsed at the lock window's end. The row @@ -316,87 +212,20 @@ namespace sysio { /// live lock set and an escrowed bond — rather than by every challenge ever filed. The full /// filing and every ballot stay permanently readable in the action-trace history that /// indexers consume. - struct [[sysio::table("uwchals")]] uwchal_entry { - uint64_t id; - uint64_t uwreq_id; ///< the challenged commitment's uwreq - name underwriter; ///< the CONFIRMED winner under challenge - name challenger; ///< who filed and posted the bond - underwrite_fault_reason reason; ///< alleged fault class - /// Challenger's free-text evidence context, capped at `max_uwchal_detail_bytes` on - /// filing and CLEARED on resolution (it is council input, not a permanent record). - std::string detail; - opp::types::DisputeStatus status; ///< OPEN / RESOLVED — same lifecycle enum as `disputes` - uwchal_verdict verdict; ///< NONE while OPEN; the terminal outcome after - uint64_t bond_amount = 0; ///< WIRE the challenger escrowed (9-decimal units) - /// The challenged locks' `expires_at_ms` — the vote deadline. Past it an unresolved - /// challenge LAPSES (bond refunded); there is no after-deadline relaxed tally. - uint64_t deadline_ms = 0; - time_point opened_at{}; - uint8_t network_gen = 0; ///< roa network generation the electorate was drawn from - /// The Tier-1 electorate + quorum, snapshotted at open — same discipline as - /// `dispute_entry`: voter eligibility (`voteuwchal`) and the tally denominator - /// (`chkuwchal`) come from one list frozen when the challenge opened, so registrations - /// after open can neither join nor dilute it. CLEARED on resolution together with the - /// ballots it gated; `quorum` is kept as the fixed-width record of the threshold used. - std::vector electorate; - uint32_t quorum = 0; - - /// Uniqueness: one challenge EVER per (uwreq, underwriter) commitment. - uint128_t by_uwreq_underwriter() const { - return (static_cast(uwreq_id) << 64) | underwriter.value; - } - - SYSLIB_SERIALIZE(uwchal_entry, - (id)(uwreq_id)(underwriter)(challenger)(reason)(detail)(status)(verdict) - (bond_amount)(deadline_ms)(opened_at)(network_gen)(electorate)(quorum)) - }; - - using uwchals_t = sysio::kv::table<"uwchals"_n, uwchal_key, uwchal_entry, - sysio::kv::index<"byuwrequw"_n, - sysio::const_mem_fun> - >; /// Challenge-vote primary key (the voting Tier-1 owner). The vote table is scoped by /// `chal_id`, so the owner alone is unique within a challenge. - struct uwchal_vote_key { - uint64_t owner; - uint64_t primary_key() const { return owner; } - SYSLIB_SERIALIZE(uwchal_vote_key, (owner)) - }; /// One Tier-1 ballot in an underwriter challenge. Scoped by `chal_id`. Live only while the /// challenge is OPEN — it is the tally input and the one-vote-per-owner gate, and neither /// applies to a resolved challenge, so `chkuwchal` erases the scope on resolution. - struct [[sysio::table("uwchalvote")]] uwchal_vote { - name owner; - uwchal_ballot ballot; - time_point voted_at{}; - - SYSLIB_SERIALIZE(uwchal_vote, (owner)(ballot)(voted_at)) - }; - - using uwchalvotes_t = - sysio::kv::scoped_table<"uwchalvote"_n, uwchal_vote_key, uwchal_vote>; /// Claimable-bond primary key (the account the payout is owed to). - struct bond_credit_key { - uint64_t account; - uint64_t primary_key() const { return account; } - SYSLIB_SERIALIZE(bond_credit_key, (account)) - }; /// WIRE this contract holds on an account's behalf out of a resolved challenge's escrow. /// `chkuwchal` credits it; `claimbond` pays it out and erases the row. Credits ACCUMULATE /// per account, so the table is bounded by the number of accounts with an unclaimed payout /// — not by the number of challenges ever resolved. - struct [[sysio::table("bondcredits")]] bond_credit { - name account; - uint64_t amount = 0; ///< unclaimed WIRE, 9-decimal units - - SYSLIB_SERIALIZE(bond_credit, (account)(amount)) - }; - - using bondcredits_t = sysio::kv::table<"bondcredits"_n, bond_credit_key, bond_credit>; private: // Well-known accounts @@ -404,9 +233,6 @@ namespace sysio { static constexpr name MSGCH_ACCOUNT = "sysio.msgch"_n; static constexpr name OPREG_ACCOUNT = "sysio.opreg"_n; static constexpr name ROA_ACCOUNT = "sysio.roa"_n; - static constexpr name UWRIT_ACCOUNT = "sysio.uwrit"_n; - static constexpr name RESERV_ACCOUNT = "sysio.reserv"_n; - static constexpr name TOKEN_ACCOUNT = "sysio.token"_n; using DisputeStatus = opp::types::DisputeStatus; using NodeOwnerTier = opp::types::NodeOwnerTier; @@ -416,13 +242,6 @@ namespace sysio { /// relaxes to a quorum of cast votes plus a strict majority of cast votes. static constexpr uint32_t dispute_deadline_sec = 24 * 60 * 60; - /// Upper bound on `openuwchal`'s caller-controlled `detail` note. Filing is permissionless - /// and the bond returns on every non-forfeit outcome, so recycled bond capital could - /// otherwise pin near-arbitrary payloads in RAM billed to `sysio`. This caps ONE note; - /// `chkuwchal`'s compaction of the resolved row is what caps the total (the two together - /// bound retained challenge bytes at `max_uwchal_detail_bytes` x concurrently-open - /// challenges). 1 KiB keeps the allegation note useful for the council. - static constexpr size_t max_uwchal_detail_bytes = 1024; }; } // namespace sysio diff --git a/contracts/sysio.chalg/src/sysio.chalg.cpp b/contracts/sysio.chalg/src/sysio.chalg.cpp index 3d43bccc8f..0924d72d2f 100644 --- a/contracts/sysio.chalg/src/sysio.chalg.cpp +++ b/contracts/sysio.chalg/src/sysio.chalg.cpp @@ -1,11 +1,7 @@ #include #include // authoritative T1 electorate snapshot at open -#include // uwreq + lock reads for the underwriter challenge -#include // reserve books that price the challenge bond #include // operator status guard before slashing -#include // token_to_wire — the bond's WIRE valuation -#include #include #include @@ -16,7 +12,6 @@ namespace sysio { using opp::types::DisputeStatus; using opp::types::NodeOwnerTier; using opp::types::OperatorStatus; -using opp::types::UnderwriteRequestStatus; // System-owned rows bill to the sysio RAM pool, not this contract account (privileged-contract // model, as sysio.token uses): the account stays finite at code+abi size; growth draws from the pool. @@ -28,29 +23,6 @@ namespace { constexpr const char* DISPUTE_REQUIRES_TWO_CANDIDATES = "a dispute requires at least two candidate envelope versions"; -/// Floor valuation, in WIRE atomic units, for a NONZERO collateral bucket the depot cannot price — -/// its `(chain_code, token_code)` pair carries no ACTIVE reserve, or the pair's books floor the -/// conversion to zero (dust). -/// -/// Pricing such a bucket at this floor rather than refusing the whole quote is deliberate, and the -/// direction matters. Collateral ingress (`opreg::depositinle`) accepts any positive amount for any -/// pair without requiring one that quotes, so refusing made unpriceability an ATTACKER-CONTROLLED -/// switch: an underwriter could seed a single dust or unreserved bucket and permanently immunize -/// every later CONFIRMED commitment against challenge. An understated stake weakens the deterrent -/// for one filing; an unchallengeable commitment defeats the mechanism outright and cannot be -/// recovered from. The floor is the strictly safer failure, and it is deterministic — the same -/// bucket always contributes the same amount, so `uwchalbond` and `openuwchal` still agree. -/// -/// Shares the provisional-pricing caveat on `compute_uwchal_bond`: acceptable while collateral is -/// small, and a real valuation for unreserved pairs is part of that same pre-launch revisit. -constexpr uint64_t MIN_UWCHAL_BUCKET_WIRE = 1; - -/// Wall-clock now in ms — the clock `sysio.uwrit`'s lock window runs on -/// (`lock_entry.expires_at_ms`), so challenge deadlines compare like-for-like. -uint64_t current_time_ms() { - return static_cast(current_time_point().sec_since_epoch()) * 1000; -} - /// Snapshot the Tier-1 electorate: the Tier-1 rows of `sysio.roa::nodeowners` for `network_gen`, /// walked via the `bytier` index (bounded by the Tier-1 registration cap). Shared by /// `opendispute` and `openuwchal` so both case types freeze eligibility and quorum from the SAME @@ -67,148 +39,6 @@ std::vector snapshot_t1_electorate(name roa_account, uint8_t network_gen) return electorate; } -/// The bond quote for challenging one commitment — see `compute_uwchal_bond`. -struct uwchal_bond_quote { - uint64_t bond = 0; ///< Σ the underwriter's collateral quoted to WIRE; 0 = unquotable - uint64_t deadline_ms = 0; ///< the locks' shared `expires_at_ms` — becomes the vote deadline - uint32_t live_locks = 0; ///< locks counted into the bond -}; - -/// THE bond formula — one function behind both the read-only quote (`uwchalbond`) and the charge -/// (`openuwchal`), so the two can never drift (the `split_wire_fee` principle). -/// -/// The bond is the underwriter's ENTIRE collateral, quoted to WIRE (Jonathan, 2026-08-11: -/// *"sum their outpost deposits based on current quotes to convert TO WIRE … and use that amount -/// for now"*). That is what an upheld verdict actually costs them: `opreg::slash` drains every -/// bucket's slashable-now portion and `sweeplocks` deferred-slashes the locked remainder, so the -/// challenger stakes what the underwriter stands to lose. -/// -/// Deposits are per-outpost and per-token — native and ERC-20/SPL alike — so each bucket is -/// quoted on its pair's live reserve books and the quotes summed. A WIRE-denominated bucket, if -/// one exists, contributes directly. -/// -/// **REVISIT (Jonathan, 2026-08-11 — "make a note to revisit").** Two provisional choices here: -/// 1. A (chain, token) pair may carry SEVERAL active reserves — PRIMARY plus private ones — at -/// different depths and therefore different prices. This takes the first ACTIVE row from the -/// `bychaintok` index, which is arbitrary when more than one qualifies. -/// 2. The quote is a spot read of pool depth, so the bond a challenger is quoted can move -/// before they file. A configured WIRE figure would be stable; a curve quote is not. -/// Both are acceptable while collateral is small and pairs carry one public reserve; neither -/// survives a deep private-reserve market. Revisit before launch. -/// -/// The lock walk that remains is the LIVENESS gate, not the amount: filing is time-boxed to the -/// live lock window, a commitment already under challenge is not re-challengeable, and the locks -/// carry the shared `expires_at_ms` that becomes the vote deadline. -/// -/// Returns a zeroed quote (unchallengeable / unquotable) when: the winner has no locks for the -/// uwreq, any lock is already held by another challenge or already expired (filing is time-gated -/// to the live window), the operator row is gone, the operator holds no collateral at all, or the -/// sum exceeds `asset::max_amount`. -/// -/// A bucket the depot cannot price does NOT void the quote — it contributes -/// `MIN_UWCHAL_BUCKET_WIRE`. Voiding made unpriceability an underwriter-controlled immunity -/// switch, since collateral ingress never required a quoteable pair; that constant carries the -/// full argument. -/// -/// The bound is the TRANSFERABLE asset range (2^62-1), not `uint64_t`'s: `openuwchal` escrows the -/// quote as `asset(static_cast(quote.bond), opp::wire::asset_symbol)`, and `asset`'s range -/// check -/// rejects anything above `max_amount`. Bounding at uint64 instead let a two-leg quote land in -/// (`asset::max_amount`, `uint64_t::max`] — `uwchalbond` would advertise a nonzero bond that -/// `openuwchal` could never escrow, so filing reverted before any lock was held and the -/// commitment became unchallengeable. Each leg is independently valid there; the SUM is what -/// overflows (source and destination may price against the same imbalanced reserve). Quoting zero -/// keeps the advertised bond and what filing can actually escrow in agreement — the same range -/// discipline `credit_bond` applies on the payout side. -uwchal_bond_quote compute_uwchal_bond(name uwrit_account, name reserv_account, name opreg_account, - uint64_t uwreq_id, name underwriter) { - uwchal_bond_quote quote; - const uint64_t now_ms = current_time_ms(); - - uwrit::locks_t locks(uwrit_account); - reserve::reserves_t reserves(reserv_account); - - // The lock walk is the LIVENESS gate, not the amount: filing is time-boxed to the live lock - // window, and a commitment already under challenge is not re-challengeable. It also carries - // the shared `expires_at_ms` that becomes the vote deadline. - auto by_uwreq = locks.get_index<"byuwreq"_n>(); - for (auto it = by_uwreq.lower_bound(uwreq_id); - it != by_uwreq.end() && it->uwreq_id == uwreq_id; ++it) { - if (it->underwriter != underwriter) continue; - if (it->challenge_id != 0 || now_ms >= it->expires_at_ms) return uwchal_bond_quote{}; - quote.deadline_ms = it->expires_at_ms; - ++quote.live_locks; - } - if (quote.live_locks == 0) return uwchal_bond_quote{}; - - // The bond is EVERYTHING the underwriter stands to lose: an upheld verdict drains every - // collateral bucket (`opreg::slash` takes each bucket's slashable-now portion, and - // `sweeplocks` deferred-slashes the locked remainder). Buckets are denominated per outpost - // in that leg's own token — native and ERC-20/SPL alike — so each is quoted to WIRE on its - // pair's live books and the quotes are summed. - opreg::operators_t ops(opreg_account); - auto op = ops.find(opreg::operator_key{underwriter.value}); - if (op == ops.end()) return uwchal_bond_quote{}; - - auto by_chain_token = reserves.template get_index<"bychaintok"_n>(); - opp::amm::u128 total = 0; - for (const auto& bal : op->balances) { - if (bal.balance == 0) continue; - if (opp::wire::is_native_asset(bal.chain_code, bal.token_code)) { - total += bal.balance; // already WIRE — no curve to ride - continue; - } - // FIRST ACTIVE reserve for the pair. A pair may carry several (PRIMARY plus private - // ones) at different depths, so this pricing is deliberately provisional — see the - // revisit note on the declaration. - const uint128_t ck = (static_cast(bal.chain_code.value) << 64) | bal.token_code.value; - uint64_t bucket_wire = 0; - for (auto it = by_chain_token.lower_bound(ck); - it != by_chain_token.end() && it->by_chain_token() == ck; ++it) { - if (it->status != opp::types::RESERVE_STATUS_ACTIVE) continue; - bucket_wire = opp::amm::token_to_wire(it->reserve_chain_amount, it->reserve_wire_amount, - it->connector_weight_bps, bal.balance); - break; - } - // A bucket the depot cannot price (no ACTIVE reserve for the pair, or books that floor the - // conversion to zero) contributes its floor instead of voiding the whole quote. Refusing - // here understated nothing but handed the underwriter a switch: seed one dust or unreserved - // bucket at ingress and every later commitment becomes unchallengeable. See - // `MIN_UWCHAL_BUCKET_WIRE` for why the understatement is the safer of the two. - total += (bucket_wire > 0 ? bucket_wire : MIN_UWCHAL_BUCKET_WIRE); - } - if (total == 0) return uwchal_bond_quote{}; - if (total > static_cast(asset::max_amount)) return uwchal_bond_quote{}; - quote.bond = static_cast(total); - return quote; -} - -/// Credit `amount` WIRE to `account`'s claimable-bond balance, accumulating onto an existing row. -/// -/// This is how a resolved challenge's escrow leaves `chkuwchal` — deliberately NOT a transfer. -/// `chkuwchal` can run inline under `sysio.epoch::advance`, and `sysio.token::transfer` runs the -/// recipient's code through `require_recipient(to)`; an asserting recipient there would abort the -/// whole advance and, with the challenge rolled back to OPEN and its locks still held, stall epoch -/// advancement chain-wide. Crediting touches only this contract's own table. See `chalg::claimbond`. -/// -/// Saturates at `asset::max_amount` (the `sysio.dclaim::add_wire_capped` discipline): the credit -/// is paid out as an `asset`, whose amount is a signed 62-bit quantity, so an unclamped sum could -/// build a row that `claimbond` can never construct a payout for. -void credit_bond(name self, name account, uint64_t amount) { - chalg::bondcredits_t credits(self); - const auto pk = chalg::bond_credit_key{account.value}; - auto it = credits.find(pk); - if (it == credits.end()) { - const uint64_t seed = std::min(amount, static_cast(asset::max_amount)); - credits.emplace(ram_payer, pk, chalg::bond_credit{ .account = account, .amount = seed }); - return; - } - credits.modify(same_payer, pk, [&](auto& r) { - const uint64_t room = static_cast(asset::max_amount) - r.amount; - r.amount += (amount <= room ? amount : room); - }); -} - } // anonymous namespace // --------------------------------------------------------------------------- @@ -421,296 +251,4 @@ void chalg::chkdispute(uint64_t dispute_id) { } } -// --------------------------------------------------------------------------- -// openuwchal — file an underwriter-fault challenge (WIRE-297) -// --------------------------------------------------------------------------- -void chalg::openuwchal(name challenger, uint64_t uwreq_id, name underwriter, - uint8_t reason, std::string detail) { - require_auth(challenger); - - // Trust boundary: the ABI carries the numeric value; the checked cast is the validation - // (never static_cast — out-of-range would be UB and hide bad input). - const auto fault = magic_enum::enum_cast(reason); - check(fault.has_value(), "openuwchal: unknown fault reason"); - check(detail.size() <= max_uwchal_detail_bytes, - "openuwchal: detail exceeds max_uwchal_detail_bytes"); - - // The challenged commitment: a CONFIRMED uwreq whose recorded winner is `underwriter`. - uwrit::uwreqs_t reqs(UWRIT_ACCOUNT); - auto rq = reqs.find(uwrit::id_key{uwreq_id}); - check(rq != reqs.end(), "openuwchal: underwrite request not found"); - check(rq->status == UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_CONFIRMED, - "openuwchal: underwrite request is not CONFIRMED"); - check(rq->winner == underwriter, "openuwchal: underwriter is not this request's winner"); - - // A verdict is final per commitment: at most one challenge EVER per (uwreq, underwriter). - // Other commitments — other swaps, any epoch — are independently challengeable. - uwchals_t chals(get_self()); - auto uq_idx = chals.get_index<"byuwrequw"_n>(); - const uint128_t composite = (static_cast(uwreq_id) << 64) | underwriter.value; - check(uq_idx.find(composite) == uq_idx.end(), - "openuwchal: this commitment has already been challenged"); - - // Price the bond off the SAME formula `uwchalbond` quotes. Zero live locks means the window - // has closed (or never opened); an unpriceable bond refuses the filing rather than guessing. - const auto quote = compute_uwchal_bond(UWRIT_ACCOUNT, RESERV_ACCOUNT, OPREG_ACCOUNT, uwreq_id, underwriter); - check(quote.live_locks > 0, - "openuwchal: no live collateral locks to challenge (the lock window has closed)"); - check(quote.bond > 0, "openuwchal: challenge bond cannot be priced"); - - const uint8_t network_gen = roa::current_network_gen(ROA_ACCOUNT); - auto electorate = snapshot_t1_electorate(ROA_ACCOUNT, network_gen); - // Same refusal as opendispute: an electorate that cannot vote could never resolve the - // challenge — it would only lapse at the window's end, having held the bond for nothing. - check(!electorate.empty(), "cannot open a challenge with no registered tier-1 node owners"); - const uint32_t quorum = static_cast(electorate.size()) / 2 + 1; - - // Escrow the bond under the challenger's OWN authority (the swapfromwire escrow pattern) — - // this contract holds it until resolution routes it per the verdict. - action( - permission_level{challenger, "active"_n}, - TOKEN_ACCOUNT, "transfer"_n, - std::make_tuple(challenger, get_self(), - asset(static_cast(quote.bond), opp::wire::asset_symbol), - std::string("sysio.chalg::openuwchal challenge bond")) - ).send(); - - const auto now = current_time_point(); - uint64_t next_id = std::max(1, chals.available_primary_key()); - - chals.emplace(ram_payer, uwchal_key{next_id}, uwchal_entry{ - .id = next_id, - .uwreq_id = uwreq_id, - .underwriter = underwriter, - .challenger = challenger, - .reason = *fault, - .detail = std::move(detail), - .status = DisputeStatus::DISPUTE_STATUS_OPEN, - .verdict = uwchal_verdict::NONE, - .bond_amount = quote.bond, - .deadline_ms = quote.deadline_ms, - .opened_at = now, - .network_gen = network_gen, - .electorate = std::move(electorate), - .quorum = quorum, - }); - - // Mark the locks held. uwrit owns the lock data and re-validates liveness authoritatively — - // a stale filing aborts the WHOLE open, bond escrow included. A held lock is skipped by - // chklocks until the challenge resolves or lapses. NOTE: deliberately NO epoch pause — an - // unresolved challenge is survivable, so the chain keeps advancing. - action( - permission_level{get_self(), "active"_n}, - UWRIT_ACCOUNT, "holdlocks"_n, - std::make_tuple(uwreq_id, underwriter, next_id) - ).send(); -} - -// --------------------------------------------------------------------------- -// voteuwchal — Tier-1 ballot in an open challenge (record-only, like votedispute) -// --------------------------------------------------------------------------- -void chalg::voteuwchal(name owner, uint64_t chal_id, uint8_t ballot) { - require_auth(owner); - - const auto cast_ballot = magic_enum::enum_cast(ballot); - check(cast_ballot.has_value(), "voteuwchal: unknown ballot value"); - - uwchals_t chals(get_self()); - auto c = chals.get(uwchal_key{chal_id}, "challenge not found"); - check(c.status == DisputeStatus::DISPUTE_STATUS_OPEN, "challenge is not open"); - - // Ballots are valid only inside the challenge window. Past deadline_ms the sole lawful - // outcome is LAPSED (there is deliberately no after-deadline relaxed tally), so a late - // quorum must not be assemblable for a manual chkuwchal crank in the gap between expiry - // and the next epoch-tick poke. - check(current_time_ms() < c.deadline_ms, "voteuwchal: the challenge window has expired"); - - // Voter eligibility: membership in the challenge's snapshotted Tier-1 electorate — the same - // list chkuwchal's quorum is measured against, so a voter the tally would not count can never - // cast a ballot. - check(std::find(c.electorate.begin(), c.electorate.end(), owner) != c.electorate.end(), - "voter is not in the challenge's tier-1 electorate"); - - // One ballot per owner (the vote table is scoped by chal_id). - uwchalvotes_t votes(get_self(), chal_id); - auto v_pk = uwchal_vote_key{owner.value}; - check(!votes.contains(v_pk), "owner has already voted in this challenge"); - - votes.emplace(ram_payer, v_pk, uwchal_vote{ - .owner = owner, - .ballot = *cast_ballot, - .voted_at = current_time_point(), - }); -} - -// --------------------------------------------------------------------------- -// chkuwchal — tally an open challenge; resolve or lapse (chkdispute's sibling) -// --------------------------------------------------------------------------- -void chalg::chkuwchal(uint64_t chal_id) { - // Permissionless crank — poked inline by `sysio.uwrit::chklocks` for any expired-but- - // challenged lock (the epoch tick IS the cadence; possible because the chain is not paused), - // or called manually right after the deciding vote for a faster resolution. - uwchals_t chals(get_self()); - auto c_pk = uwchal_key{chal_id}; - auto c = chals.get(c_pk, "challenge not found"); - check(c.status == DisputeStatus::DISPUTE_STATUS_OPEN, "challenge is not open"); - - const uint32_t N = static_cast(c.electorate.size()); - const uint32_t Q = c.quorum; - check(Q > 0 && Q <= N, "challenge has no electorate snapshot"); - - // Tally the cast ballots. - uint32_t uphold = 0, reject_refund = 0, reject_forfeit = 0; - uwchalvotes_t votes(get_self(), chal_id); - for (auto it = votes.begin(); it != votes.end(); ++it) { - switch (it->ballot) { - case uwchal_ballot::UPHOLD: ++uphold; break; - case uwchal_ballot::REJECT_REFUND: ++reject_refund; break; - case uwchal_ballot::REJECT_FORFEIT: ++reject_forfeit; break; - } - } - - // Resolution rules — quorum thresholds any time; the deadline only LAPSES (there is - // deliberately no after-deadline relaxed tally: a challenge must be voted before the lock - // window expires; envelope disputes relax only because a paused chain MUST resolve). - uwchal_verdict verdict = uwchal_verdict::NONE; - if (uphold >= Q) { - verdict = uwchal_verdict::UPHELD; - } else if (reject_refund + reject_forfeit >= Q) { - // Disposition by majority among the rejectors; a tie favours the refund — forfeiture - // only ever happens by explicit council judgment. - verdict = (reject_forfeit > reject_refund) ? uwchal_verdict::REJECTED_FORFEIT - : uwchal_verdict::REJECTED_REFUND; - } else if (current_time_ms() >= c.deadline_ms) { - verdict = uwchal_verdict::LAPSED; - } - if (verdict == uwchal_verdict::NONE) return; // keep waiting for ballots - - chals.modify(same_payer, c_pk, [&](auto& r) { - r.status = DisputeStatus::DISPUTE_STATUS_RESOLVED; - r.verdict = verdict; - // Compact to a fixed-width tombstone. Past resolution the row has exactly two on-chain - // jobs — the `byuwrequw` uniqueness gate (a verdict is final per commitment) and the - // verdict record — and neither needs the variable-length fields. `detail` was - // caller-controlled evidence FOR the council; `electorate` was the ballot roll. Both stay - // permanently readable in the filing's action trace, and dropping them here is what bounds - // this contract's retained variable-length state by the number of CONCURRENTLY OPEN - // challenges (each backed by a live lock set and an escrowed bond) rather than by every - // challenge ever filed — the per-note cap alone cannot do that, because filing is - // permissionless and the bond comes back on every non-forfeit outcome. - r.detail.clear(); - r.electorate.clear(); - }); - - // The ballots go with it: they were the tally input above and the one-vote-per-owner gate, - // neither of which applies once the row leaves OPEN. Collect first, erase second (an erase - // invalidates the iterator) — bounded by the Tier-1 electorate this challenge snapshotted, the - // same bound the tally walk above already runs under. - std::vector ballot_owners; - ballot_owners.reserve(uphold + reject_refund + reject_forfeit); - for (auto it = votes.begin(); it != votes.end(); ++it) { - ballot_owners.push_back(it->owner.value); - } - for (uint64_t owner : ballot_owners) { - votes.erase(uwchal_vote_key{owner}); - } - - if (verdict == uwchal_verdict::UPHELD) { - // Slash FIRST, then sweep: inline actions run depth-first in send order, so the SLASHED - // status flip lands before sweeplocks' releaselock calls — which then take the - // deferred-slash branch, debiting the locked collateral and emitting the outbound SLASH - // attestations. Guard on live status: this can run inline from the epoch tick, where an - // aborted slash (already SLASHED/TERMINATED out-of-band) must not stall advancement. - opreg::operators_t ops(OPREG_ACCOUNT); - auto op = ops.find(opreg::operator_key{c.underwriter.value}); - const bool slashable = op != ops.end() && - op->status != OperatorStatus::OPERATOR_STATUS_SLASHED && - op->status != OperatorStatus::OPERATOR_STATUS_TERMINATED; - if (slashable) { - action( - permission_level{get_self(), "active"_n}, - get_self(), "slashop"_n, - std::make_tuple(c.underwriter, - std::string("underwriter fault upheld (") - + std::string(magic_enum::enum_name(c.reason)) - + "): challenge " + std::to_string(chal_id)) - ).send(); - } - action( - permission_level{get_self(), "active"_n}, - UWRIT_ACCOUNT, "sweeplocks"_n, - std::make_tuple(c.uwreq_id, c.underwriter) - ).send(); - } else { - // REJECTED or LAPSED: clear the hold; the locks live out their natural window and release - // on the next normal sweep (a healthy release — no collateral moves). - action( - permission_level{get_self(), "active"_n}, - UWRIT_ACCOUNT, "freelocks"_n, - std::make_tuple(c.uwreq_id, c.underwriter) - ).send(); - } - - // Route the bond per the verdict: forfeiture to the wrongly-challenged underwriter ONLY on an - // explicit REJECT_FORFEIT majority; every other outcome returns it to the challenger. - // - // CREDITED, never transferred. This whole function can run inline under - // `sysio.epoch::advance -> sysio.uwrit::chklocks`, where `sysio.token::transfer`'s - // `require_recipient(to)` would run the recipient's own code and let it abort epoch - // advancement — see `chalg::claimbond` for the full argument. The escrow stays in this - // contract's custody until the recipient pulls it. - if (c.bond_amount > 0) { - const bool forfeited = (verdict == uwchal_verdict::REJECTED_FORFEIT); - credit_bond(get_self(), forfeited ? c.underwriter : c.challenger, c.bond_amount); - } -} - -// --------------------------------------------------------------------------- -// claimbond — pull a resolved challenge's bond out of this contract's custody -// --------------------------------------------------------------------------- -void chalg::claimbond(name account) { - // The recipient's own authority, as `sysio.dclaim::claim` requires it: the transfer below - // notifies `account`, so only `account` can trigger code execution on its own behalf here. - require_auth(account); - - bondcredits_t credits(get_self()); - const auto pk = bond_credit_key{account.value}; - auto it = credits.find(pk); - check(it != credits.end(), "claimbond: no claimable bond"); - const uint64_t amount = it->amount; - check(amount > 0, "claimbond: zero claimable balance"); - - // Erase BEFORE sending: the inline transfer runs after this action returns and notifies - // `account`, which may re-enter `claimbond`. The row must already be gone when it does. - credits.erase(pk); - - action( - permission_level{get_self(), "active"_n}, - TOKEN_ACCOUNT, "transfer"_n, - std::make_tuple(get_self(), account, - asset(static_cast(amount), opp::wire::asset_symbol), - std::string("sysio.chalg challenge bond payout")) - ).send(); -} - -// --------------------------------------------------------------------------- -// uwchalbond — read-only quote of the bond openuwchal would charge right now -// --------------------------------------------------------------------------- -uint64_t chalg::uwchalbond(uint64_t uwreq_id, name underwriter) { - // Soft gating — a quote, not a filing: every not-currently-challengeable state answers 0 - // (callers treat 0 as "no quote"), mirroring `sysio.reserv::swapquote`'s contract. - uwrit::uwreqs_t reqs(UWRIT_ACCOUNT); - auto rq = reqs.find(uwrit::id_key{uwreq_id}); - if (rq == reqs.end()) return 0; - if (rq->status != UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_CONFIRMED) return 0; - if (rq->winner != underwriter) return 0; - - uwchals_t chals(get_self()); - auto uq_idx = chals.get_index<"byuwrequw"_n>(); - const uint128_t composite = (static_cast(uwreq_id) << 64) | underwriter.value; - if (uq_idx.find(composite) != uq_idx.end()) return 0; // already challenged — a verdict is final - - return compute_uwchal_bond(UWRIT_ACCOUNT, RESERV_ACCOUNT, OPREG_ACCOUNT, uwreq_id, underwriter).bond; -} - } // namespace sysio diff --git a/contracts/sysio.chalg/sysio.chalg.abi b/contracts/sysio.chalg/sysio.chalg.abi index e9b03b249a..a67f9a100b 100644 --- a/contracts/sysio.chalg/sysio.chalg.abi +++ b/contracts/sysio.chalg/sysio.chalg.abi @@ -3,30 +3,6 @@ "version": "sysio::abi/1.2", "types": [], "structs": [ - { - "name": "bond_credit", - "base": "", - "fields": [ - { - "name": "account", - "type": "name" - }, - { - "name": "amount", - "type": "uint64" - } - ] - }, - { - "name": "bond_credit_key", - "base": "", - "fields": [ - { - "name": "account", - "type": "uint64" - } - ] - }, { "name": "chalg_state", "base": "", @@ -47,26 +23,6 @@ } ] }, - { - "name": "chkuwchal", - "base": "", - "fields": [ - { - "name": "chal_id", - "type": "uint64" - } - ] - }, - { - "name": "claimbond", - "base": "", - "fields": [ - { - "name": "account", - "type": "name" - } - ] - }, { "name": "dispute_candidate", "base": "", @@ -187,32 +143,6 @@ } ] }, - { - "name": "openuwchal", - "base": "", - "fields": [ - { - "name": "challenger", - "type": "name" - }, - { - "name": "uwreq_id", - "type": "uint64" - }, - { - "name": "underwriter", - "type": "name" - }, - { - "name": "reason", - "type": "uint8" - }, - { - "name": "detail", - "type": "string" - } - ] - }, { "name": "slashop", "base": "", @@ -227,120 +157,6 @@ } ] }, - { - "name": "uwchal_entry", - "base": "", - "fields": [ - { - "name": "id", - "type": "uint64" - }, - { - "name": "uwreq_id", - "type": "uint64" - }, - { - "name": "underwriter", - "type": "name" - }, - { - "name": "challenger", - "type": "name" - }, - { - "name": "reason", - "type": "underwrite_fault_reason" - }, - { - "name": "detail", - "type": "string" - }, - { - "name": "status", - "type": "DisputeStatus" - }, - { - "name": "verdict", - "type": "uwchal_verdict" - }, - { - "name": "bond_amount", - "type": "uint64" - }, - { - "name": "deadline_ms", - "type": "uint64" - }, - { - "name": "opened_at", - "type": "time_point" - }, - { - "name": "network_gen", - "type": "uint8" - }, - { - "name": "electorate", - "type": "name[]" - }, - { - "name": "quorum", - "type": "uint32" - } - ] - }, - { - "name": "uwchal_key", - "base": "", - "fields": [ - { - "name": "id", - "type": "uint64" - } - ] - }, - { - "name": "uwchal_vote", - "base": "", - "fields": [ - { - "name": "owner", - "type": "name" - }, - { - "name": "ballot", - "type": "uwchal_ballot" - }, - { - "name": "voted_at", - "type": "time_point" - } - ] - }, - { - "name": "uwchal_vote_key", - "base": "", - "fields": [ - { - "name": "owner", - "type": "uint64" - } - ] - }, - { - "name": "uwchalbond", - "base": "", - "fields": [ - { - "name": "uwreq_id", - "type": "uint64" - }, - { - "name": "underwriter", - "type": "name" - } - ] - }, { "name": "votedispute", "base": "", @@ -358,24 +174,6 @@ "type": "checksum256" } ] - }, - { - "name": "voteuwchal", - "base": "", - "fields": [ - { - "name": "owner", - "type": "name" - }, - { - "name": "chal_id", - "type": "uint64" - }, - { - "name": "ballot", - "type": "uint8" - } - ] } ], "actions": [ @@ -384,56 +182,23 @@ "type": "chkdispute", "ricardian_contract": "" }, - { - "name": "chkuwchal", - "type": "chkuwchal", - "ricardian_contract": "" - }, - { - "name": "claimbond", - "type": "claimbond", - "ricardian_contract": "" - }, { "name": "opendispute", "type": "opendispute", "ricardian_contract": "" }, - { - "name": "openuwchal", - "type": "openuwchal", - "ricardian_contract": "" - }, { "name": "slashop", "type": "slashop", "ricardian_contract": "" }, - { - "name": "uwchalbond", - "type": "uwchalbond", - "ricardian_contract": "" - }, { "name": "votedispute", "type": "votedispute", "ricardian_contract": "" - }, - { - "name": "voteuwchal", - "type": "voteuwchal", - "ricardian_contract": "" } ], "tables": [ - { - "name": "bondcredits", - "type": "bond_credit", - "index_type": "i64", - "key_names": ["account"], - "key_types": ["uint64"], - "table_id": 30792 - }, { "name": "chalgstate", "type": "chalg_state", @@ -469,39 +234,11 @@ "key_names": ["scope","owner"], "key_types": ["name","uint64"], "table_id": 51768 - }, - { - "name": "uwchals", - "type": "uwchal_entry", - "index_type": "i64", - "key_names": ["id"], - "key_types": ["uint64"], - "table_id": 10118, - "secondary_indexes": [ - { - "name": "byuwrequw", - "key_type": "uint128", - "table_id": 26153 - } - ] - }, - { - "name": "uwchalvote", - "type": "uwchal_vote", - "index_type": "i64", - "key_names": ["scope","owner"], - "key_types": ["name","uint64"], - "table_id": 12100 } ], "ricardian_clauses": [], "variants": [], - "action_results": [ - { - "name": "uwchalbond", - "result_type": "uint64" - } - ], + "action_results": [], "enums": [ { "name": "DisputeStatus", @@ -520,68 +257,6 @@ "value": 2 } ] - }, - { - "name": "underwrite_fault_reason", - "type": "uint8", - "values": [ - { - "name": "SOURCE_DEPOSIT_MISSING", - "value": 0 - }, - { - "name": "SOURCE_DEPOSIT_MISMATCH", - "value": 1 - }, - { - "name": "NON_CANONICAL_ENVELOPE", - "value": 2 - } - ] - }, - { - "name": "uwchal_ballot", - "type": "uint8", - "values": [ - { - "name": "UPHOLD", - "value": 0 - }, - { - "name": "REJECT_REFUND", - "value": 1 - }, - { - "name": "REJECT_FORFEIT", - "value": 2 - } - ] - }, - { - "name": "uwchal_verdict", - "type": "uint8", - "values": [ - { - "name": "NONE", - "value": 0 - }, - { - "name": "UPHELD", - "value": 1 - }, - { - "name": "REJECTED_REFUND", - "value": 2 - }, - { - "name": "REJECTED_FORFEIT", - "value": 3 - }, - { - "name": "LAPSED", - "value": 4 - } - ] } ] } \ No newline at end of file diff --git a/contracts/sysio.chalg/sysio.chalg.wasm b/contracts/sysio.chalg/sysio.chalg.wasm index 092e0798680408f114ac3d8d5c5ef572f19dd690..cd1d8715eb28f071315bb12904e27f1799845cca 100755 GIT binary patch delta 6679 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 Av;Y7A diff --git a/contracts/sysio.dclaim/CMakeLists.txt b/contracts/sysio.dclaim/CMakeLists.txt index 1b3aa88450..fd88acc848 100644 --- a/contracts/sysio.dclaim/CMakeLists.txt +++ b/contracts/sysio.dclaim/CMakeLists.txt @@ -33,7 +33,6 @@ if(BUILD_SYSTEM_CONTRACTS) $ $ $ - $ $ $ ) diff --git a/contracts/sysio.epoch/CMakeLists.txt b/contracts/sysio.epoch/CMakeLists.txt index 99adff7dd7..533340f93c 100644 --- a/contracts/sysio.epoch/CMakeLists.txt +++ b/contracts/sysio.epoch/CMakeLists.txt @@ -36,8 +36,6 @@ if(BUILD_SYSTEM_CONTRACTS) $ $ $ - $ - $ $ $ ) diff --git a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp index 3d98850aff..02c1216c82 100644 --- a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp +++ b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp @@ -125,8 +125,6 @@ namespace sysio { static constexpr name OPREG_ACCOUNT = "sysio.opreg"_n; static constexpr name AUTHEX_ACCOUNT = "sysio.authex"_n; static constexpr name CHAINS_ACCOUNT = "sysio.chains"_n; - static constexpr name UWRIT_ACCOUNT = "sysio.uwrit"_n; - static constexpr name RESERV_ACCOUNT = "sysio.reserv"_n; /// True while the depot is in its epoch-0 bootstrap window: no epoch state yet, or the /// current epoch index is still 0. Never throws. The one window test `sysio.liq`'s diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index e8711f0935..fa82d89db2 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -4,14 +4,6 @@ #include #include #include -// For uwrit::MAX_LOCK_RELEASE_PER_EPOCH and uwrit::MAX_UWREQ_PRUNE_PER_EPOCH — -// the per-epoch budgets advance hands to the inline `chklocks` and -// `pruneuwreqs` sweeps (both constants are owned by sysio.uwrit). -#include -// For reserve::MAX_CLAIM_SWEEP_PER_EPOCH — the per-epoch budget advance hands -// to the inline `sweepclaims` retention sweep (the constant is owned by -// sysio.reserv). -#include // Canonical sysio.system emissions types + compute_epoch_emission. The // [[sysio::contract("sysio.system")]] attribute on emission_config / t5_state // pins them to sysio.system's ABI; no readonly mirror needed here. @@ -391,44 +383,6 @@ void epoch::advance() { // eventually passes on a subsequent chkcons retry. const uint32_t target_epoch = state.current_epoch_index + 1; - // Bounded retention sweep of expired `sysio.reserv::wireclaims`: erase rows whose one-year - // window closed and return their WIRE to the treasury. - // - // Runs BEFORE the emissions gate, deliberately. This is maintenance, not economics, and the - // WIRE it reclaims lands in the very balance the gate measures -- so ordering it after the gate - // creates a deadlock: an epoch blocked as BALANCE_INSUFFICIENT returns below without ever - // reclaiming forfeited WIRE that could cover the shortfall, and every retry takes the same - // path. The contract would be sitting on the funds needed to unblock itself. `sweepclaims` - // takes only epoch or reserv authority, so no ordinary keeper could break that cycle either. - // - // Safe to run ahead of the gate because it is bounded, never-throwing past its auth check, and - // touches no epoch state -- a blocked epoch that sweeps and still cannot pay is exactly as - // blocked as before, minus some expired rows. - // - // `sysio.reserv` also sweeps opportunistically when crediting a new claim, but that only fires - // while settlement traffic arrives; this call is what makes the deadline hold when swaps stop. - // - // GUARDED on the account existing. Dispatching an inline action to an absent code account is a - // hard `action_validate_exception`, which inside `advance` is a chain-wide epoch stall. - // `sysio.reserv` is not a precondition for advancing an epoch -- a chain can advance before - // reserves are ever deployed -- and nothing can have accrued a wireclaim in that state, so - // skipping is precisely correct rather than merely defensive. - // - // That guard covers an ABSENT account, not a STALE one: an old `sysio.reserv` build has the - // account and not the action, and the CDT dispatcher asserts on an action it does not - // implement -- so deploying `sysio.epoch` ahead of `sysio.reserv` aborts every advance. The - // deploy is therefore one atomic `sysio.msig` transaction, or `sysio.reserv` first. Both this - // edge and the `sysio.epoch`-before-`sysio.system` one the emissions gate creates are written - // up in `docs/contract-upgrade-order.md`. - if (is_account(RESERV_ACCOUNT)) { - action( - permission_level{get_self(), "owner"_n}, - RESERV_ACCOUNT, - "sweepclaims"_n, - std::make_tuple(reserve::MAX_CLAIM_SWEEP_PER_EPOCH) - ).send(); - } - const auto gate = check_emissions_ready( cfg.epoch_duration_sec, cfg.operators_per_epoch, target_epoch); if (!gate.ready) { @@ -448,34 +402,6 @@ void epoch::advance() { // attempt blocked and we're now succeeding) and proceed. clear_gate_block(get_self(), target_epoch); - // FIRST post-gate step: sweep expired underwriter collateral locks. - // Locks are a wall-clock challenge window (12h default; see - // sysio.uwrit::uwconfig.collateral_lock_duration_ms) — they are never - // released by delivery, only by this sweep. Running it before the - // delivery evaluation + withdraw flushing below means collateral freed - // by the closing window is visible to this same advance's - // `available()`-gated paths (flushwtdw, eligibility). - action( - permission_level{get_self(), "owner"_n}, - UWRIT_ACCOUNT, - "chklocks"_n, - std::make_tuple(uwrit::MAX_LOCK_RELEASE_PER_EPOCH) - ).send(); - - // Bounded UWREQ lifecycle sweep (SEC-129 / WSA-223): erase terminal - // uwreqs whose retention window elapsed; expire + refund PENDING uwreqs - // whose race never resolved inside the pending timeout. Runs after - // chklocks so both uwrit maintenance sweeps stay adjacent, and before - // buildenv so any SWAP_REVERT the expiry path emits rides THIS epoch's - // outbound envelopes. Budget-bounded (never throws) — a backlog simply - // drains across subsequent epochs. - action( - permission_level{get_self(), "owner"_n}, - UWRIT_ACCOUNT, - "pruneuwreqs"_n, - std::make_tuple(uwrit::MAX_UWREQ_PRUNE_PER_EPOCH) - ).send(); - // Before incrementing: evaluate per-op delivery state for the EXPIRING // epoch. The active group of the expiring epoch (`current_batch_op_group` // BEFORE the increment) is the set of ops responsible for delivering @@ -970,19 +896,6 @@ void epoch::advance() { } } - // Drain the swap-from-WIRE queue: each row queued via - // `sysio.uwrit::swapfromwire` since the last advance is re-validated - // (target reserve ACTIVE + public, variance) and either becomes a - // PENDING uwreq for the single-leg underwriter race or is refunded. - // Runs before `buildenv` so this epoch's envelopes reflect any state - // the drain produced; never throws (refund-and-drop semantics). - action( - permission_level{get_self(), "owner"_n}, - UWRIT_ACCOUNT, - "drainfwq"_n, - std::make_tuple() - ).send(); - // Build outbound envelopes for each outpost { sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index acb01b1f33855e350fba3113974094c8f3db2628..18c986d39bbde1543afa95059bba2f869795ad4c 100755 GIT binary patch delta 25493 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 to_checksum256_exact(const std::vector& bytes) return true; } -/// Decode an OperatorAction sub-message and dispatch to the appropriate -/// sysio.opreg action. Called from the inbound dispatch loop in `evalcons`. -/// -/// Sub-type routing (post data-model refactor — codenames everywhere): -/// * DEPOSIT_REQUEST → opreg::depositinle(account, chain_code, token_code, -/// amount, actor_chain, actor_addr, -/// msg_id) -/// * WITHDRAW_REQUEST → opreg::withdrawinle(account, chain_code, token_code, -/// amount) -/// * WITHDRAW_REMIT → outbound-only (depot → outpost); silently dropped if seen inbound -/// * SLASH → depot-internal; rejected if seen inbound. Slash decisions -/// originate from sysio.chalg → opreg::slash and never re-enter -/// the depot via OPP. A slash arriving inbound here is either an -/// outpost replaying its own outbound (no-op), or a malformed -/// attestation from a misbehaving operator (drop). -/// * UNKNOWN → no-op -/// -/// `chain_code` (the proven source outpost from `deliver`) is the escrow-holding chain the -/// inline `depositinle` / `withdrawinle` is dispatched against. The payload's own -/// `OperatorAction.chain_code` must equal it (WSA-005): the proto documents field 7 as "the -/// outpost holding the escrow", which for a DEPOSIT_REQUEST / WITHDRAW_REQUEST is exactly the -/// outpost that relayed this attestation. We dispatch using the proven `chain_code`, never the -/// payload's, and drop the attestation when they diverge. -/// -/// `original_message_id` is the OPP message id of the attestation's parent -/// Message — opreg::depositinle uses it to populate DEPOSIT_REVERT correlation -/// when refunding an unaccepted deposit. -void dispatch_operator_action(name self, const std::vector& data, - uint64_t chain_code, - const checksum256& original_message_id) { - opp::attestations::OperatorAction oa; - { - auto in = zpp::bits::in{std::span{data.data(), data.size()}, zpp::bits::no_size{}}; - auto rc = in(oa); - if (rc != zpp::bits::errc{}) return; // malformed; skip silently - } - - // WSA-005: bind the payload's escrow chain to the proven source outpost before crediting or - // debiting any operator collateral. A consensus envelope proven from outpost A must not be able - // to deposit/withdraw against a different chain B's operator ledger. - if (!source_chain_binding_ok(chain_code, oa.chain_code, "dispatch_operator_action")) return; - - // Resolve the operator's WIRE account from `op_address` via authex's - // bypubkey index. Outposts emit the full chain pubkey (33 bytes for - // secp256k1, 32 for Ed25519); the depot's authex link table is the - // single source of truth that maps it back to a WIRE name. On miss - // (no authex link, malformed bytes, unsupported chain kind), drop — - // the OperatorRegistry that originated this deposit will see no - // corresponding state update on the depot side and can re-emit after - // the operator completes their authex registration. - name account = resolve_account_from_op_address(oa.op_address); - if (account == name{}) return; - - using AT = opp::attestations::OperatorAction; - // Dispatch against the PROVEN source chain (equal to `oa.chain_code`, enforced by the WSA-005 - // binding check above), never the payload's own copy. TokenAmount + ChainAddress get split into - // (chain_code, token_code, amount) / (kind, address) on the inline-action tuples per the - // no-proto-messages-in-actions rule. - const sysio::slug_name chain_code_slug{chain_code}; - const sysio::slug_name token_code{oa.amount.token_code}; - // A DEPOSIT_REQUEST carries outpost custody, so an unspellable token code must be - // REFUNDED, not dropped: opreg::depositinle rejects it with DEPOSIT_REVERT before - // touching the balance map. Every other action type is a state transition with no - // escrow to return, so dropping stays correct there. - if (oa.action_type != AT::ACTION_TYPE_DEPOSIT_REQUEST && - !payload_codes_canonical({token_code}, "dispatch_operator_action")) return; - // WSA-028: TokenAmount.amount is signed on the wire. Gate it through the - // shared fail-closed parser before any unsigned use — a negative or - // out-of-range amount is dropped here, never wrapped into a huge collateral - // credit. An impossible deposit/withdraw has no escrow to refund, so a silent - // drop (not a revert) is the correct fail-closed action; the originating - // OperatorRegistry sees no matching depot state change and can re-emit a - // well-formed action. Never-throw: drop, do not check(). - const std::optional amount_opt = - sysio::opp::safe::to_depot_amount(static_cast(oa.amount.amount)); - if (!amount_opt) return; - const uint64_t raw_amount = *amount_opt; - switch (oa.action_type) { - case AT::ACTION_TYPE_DEPOSIT_REQUEST: { - // opreg::depositinle checks require_auth(get_self()=opreg). msgch - // must therefore declare opreg's own permission on the inline action. - // Privileged sysio.msgch may declare the target contract's active - // permission on an inline action without a cross-contract active - // grant. Deployment must preserve msgch's privileged status. - action( - permission_level{OPREG_ACCOUNT, "active"_n}, - OPREG_ACCOUNT, "depositinle"_n, - std::make_tuple(account, chain_code_slug, token_code, raw_amount, - oa.op_address.kind, oa.op_address.address, - original_message_id) - ).send(); - break; - } - case AT::ACTION_TYPE_WITHDRAW_REQUEST: { - // Same privileged-system-contract boundary as DEPOSIT_REQUEST: no - // msgch@sysio.code grant on opreg.active is required. - action( - permission_level{OPREG_ACCOUNT, "active"_n}, - OPREG_ACCOUNT, "withdrawinle"_n, - std::make_tuple(account, chain_code_slug, token_code, raw_amount) - ).send(); - break; - } - case AT::ACTION_TYPE_WITHDRAW_REMIT: // outbound-only — never expected inbound - case AT::ACTION_TYPE_SLASH: // depot-internal; never accepted inbound - case AT::ACTION_TYPE_UNKNOWN: - default: - break; - } -} - -/// Dispatch an UNDERWRITE_INTENT_COMMIT to sysio.uwrit::rcrdcommit. -/// -/// The full UIC bytes are forwarded verbatim so the depot can reconstruct -/// the digest and verify the underwriter's signature at race resolution -/// time. We decode here to extract the routing scalars (uwreq id, -/// uw_account, chain_code, token_code, reserve_code — the latter triple -/// disambiguates same-chain swap legs and points at the precise reserve -/// covering this leg); the authoritative copy for verification is the -/// bytes themselves, stored on `commit_entry.{source,dest}_uic_bytes`. -/// -/// After the refactor: identity scalars on UIC are codenames (uint64). `chain_code` is the proven source -/// outpost from `deliver`; `uic.chain_code` is the leg this commit covers. WSA-005 requires the two -/// to be identical — each leg's underwrite commit is emitted on, and relayed by, that leg's own -/// outpost (a source-leg UIC rides the source outpost's envelope, a dest-leg UIC the dest outpost's; -/// see `sysio.dispatch_tests`). `rcrdcommit` routes the commit to its src/dst slot by the leg chain -/// code, so a forged `uic.chain_code` could misroute the commit onto the wrong leg; we bind it to the -/// proven outpost and drop on divergence. -void dispatch_underwrite_commit(name self, const std::vector& data, uint64_t chain_code) { - opp::attestations::UnderwriteIntentCommit uic; - { - auto in = zpp::bits::in{std::span{data.data(), data.size()}, zpp::bits::no_size{}}; - auto rc = in(uic); - if (rc != zpp::bits::errc{}) { - sysio::print(UIC_DISPATCH_REJECTED_LOG_PREFIX, - ": chain_code=", chain_code, - ", reason=malformed_uic\n"); - return; - } - } - // Pre-validate the relayed account string before constructing `name` below. The CDT `name` - // ctor aborts on an empty, overlong, or out-of-charset string, and an abort here would revert - // the whole evalcons/apply_consensus delivery; a malformed name is dropped instead. - const auto underwriter = sysio::opp::safe::parse_wire_account_name(uic.uw_account.name); - if (!underwriter) { - sysio::print(UIC_DISPATCH_REJECTED_LOG_PREFIX, - ": chain_code=", chain_code, - ", reason=invalid_wire_account\n"); - return; - } - - // WSA-005: the leg's chain (uic.chain_code) must be the proven delivering outpost before the - // commit is recorded against a swap leg. - if (!source_chain_binding_ok(chain_code, uic.chain_code, "dispatch_underwrite_commit")) return; - - const sysio::slug_name uic_token_code{uic.token_code}; - const sysio::slug_name uic_reserve_code{uic.reserve_code}; - if (!payload_codes_canonical({uic_token_code, uic_reserve_code}, - "dispatch_underwrite_commit")) return; - - // Route with the proven `chain_code` (equal to `uic.chain_code`, enforced above) so the leg slot - // is keyed off provenance, not the payload's self-asserted chain. - action( - permission_level{self, "active"_n}, - UWRIT_ACCOUNT, "rcrdcommit"_n, - std::make_tuple(uic.uw_request_id, *underwriter, chain_code, - sysio::slug_name{chain_code}, - uic_token_code, - uic_reserve_code, - data) - ).send(); -} - /// True iff `(chain_code, token_code)` is an active TOKEN_KIND_LIQ registry row bound to an /// active chaintokens row: the only tokens the shadow ledger mints against. bool is_active_liq_token(sysio::slug_name chain_code, sysio::slug_name token_code) { @@ -734,95 +561,6 @@ bool dispatch_liq_yield(name self, const std::vector& data, uint64_t chain return true; } -/// Dispatch a RESERVE_CREATE attestation to sysio.reserv::oncrtreserve. -/// Inserts a PENDING reserve row on the depot. Per -/// `feedback_opp_handlers_never_throw`, decode failures silently no-op. -/// The downstream `oncrtreserve` is itself a never-throw handler — duplicate -/// reserves are logged + dropped on the depot side. -void dispatch_reserve_create(name self, const std::vector& data, uint64_t chain_code) { - opp::attestations::ReserveCreate rc; - { - auto in = zpp::bits::in{std::span{data.data(), data.size()}, zpp::bits::no_size{}}; - auto err = in(rc); - if (err != zpp::bits::errc{}) return; - } - - // Reserve identity + the custodied amount travel together in - // `external_amount` (a `ReserveAmount`): the amount is ALREADY in the - // depot's canonical 9-decimal frame (the outpost converts chain-native - // units at the boundary, exactly like the swap paths). A negative OR - // out-of-range TokenAmount collapses to 0, which `oncrtreserve` routes into its - // cancel/refund flow — inserting a CANCELLED row and queueing - // RESERVE_CREATE_CANCELLED so the outpost releases the creator's escrow — - // instead of wrapping into a huge custodied amount. - const auto& ext = rc.external_amount; - - // WSA-005: `external_amount.chain_code` is documented in the proto as "the outpost's own chain". - // Bind it to the proven delivering outpost so an envelope proven from outpost A cannot register a - // reserve whose external custody is claimed against a different chain B. - if (!source_chain_binding_ok(chain_code, ext.chain_code, "dispatch_reserve_create")) return; - - // No canonicality drop here: the creator's escrow is already in outpost custody, so - // an unspellable token/reserve code must be REFUNDED. reserv::oncrtreserve rejects it - // with RESERVE_CREATE_CANCELLED before persisting anything. - const sysio::slug_name ext_token_code{ext.amount.token_code}; - const sysio::slug_name ext_reserve_code{ext.reserve_code}; - - const uint64_t ext_amount = - sysio::opp::safe::to_depot_amount(static_cast(ext.amount.amount)).value_or(0); - - action( - permission_level{self, "active"_n}, - RESERV_ACCOUNT, "oncrtreserve"_n, - std::make_tuple(sysio::slug_name{ext.chain_code}, - ext_token_code, - ext_reserve_code, - rc.name, - rc.description, - ext_amount, - rc.requested_wire_amount, - rc.source_token_precision, - rc.connector_weight_bps, - rc.creator_addr.kind, - rc.creator_addr.address, - rc.is_private, - rc.creator_pub_key) - ).send(); -} - -/// Dispatch a RESERVE_CREATE_CANCEL attestation to sysio.reserv::oncnclrsv. -/// The depot decides whether the creator won or lost the race against any -/// `matchreserve` call — see `oncnclrsv`. Per -/// `feedback_opp_handlers_never_throw`, decode failures silently no-op -/// and downstream race-loss is also a silent no-op on the reserv side. -void dispatch_reserve_create_cancel(name self, const std::vector& data, uint64_t chain_code) { - opp::attestations::ReserveCreateCancel cancel; - { - auto in = zpp::bits::in{std::span{data.data(), data.size()}, zpp::bits::no_size{}}; - auto err = in(cancel); - if (err != zpp::bits::errc{}) return; - } - - // WSA-005: the cancel targets the reserve on `cancel.chain_code`; bind it to the proven - // delivering outpost so an envelope proven from outpost A cannot cancel a reserve on chain B. - if (!source_chain_binding_ok(chain_code, cancel.chain_code, "dispatch_reserve_create_cancel")) return; - - const sysio::slug_name cancel_token_code{cancel.token_code}; - const sysio::slug_name cancel_reserve_code{cancel.reserve_code}; - if (!payload_codes_canonical({cancel_token_code, cancel_reserve_code}, - "dispatch_reserve_create_cancel")) return; - - action( - permission_level{self, "active"_n}, - RESERV_ACCOUNT, "oncnclrsv"_n, - std::make_tuple(sysio::slug_name{cancel.chain_code}, - cancel_token_code, - cancel_reserve_code, - cancel.creator_addr.kind, - cancel.creator_addr.address) - ).send(); -} - /// Validate and parse `s` (from `WireAccount.name`) into a sysio::name. The name string /// constructor aborts on an out-of-charset character, so we pre-validate here to keep the depot /// dispatch non-throwing. Returns nullopt for an empty, over-long (>12), or out-of-charset name. @@ -973,19 +711,9 @@ void dispatch_node_owner_reg(const std::vector& data, uint64_t chain_code) bool carried_syndication_value = false; switch (type) { case AttestationType::ATTESTATION_TYPE_OPERATOR_ACTION: - dispatch_operator_action(self, data, chain_code, original_message_id); - break; - case AttestationType::ATTESTATION_TYPE_SWAP_REQUEST: - action( - permission_level{self, "active"_n}, - UWRIT_ACCOUNT, "createuwreq"_n, - std::make_tuple(attestation_id, type, chain_code, data) - ).send(); - break; - case AttestationType::ATTESTATION_TYPE_UNDERWRITE_INTENT_COMMIT: - dispatch_underwrite_commit(self, data, chain_code); + // Retired external collateral and dual-commit swap ingress. break; case AttestationType::ATTESTATION_TYPE_SWAP_REMIT: @@ -1064,20 +792,8 @@ void dispatch_node_owner_reg(const std::vector& data, uint64_t chain_code) break; case AttestationType::ATTESTATION_TYPE_RESERVE_CREATE: - // Outpost-initiated reserve creation. Insert a PENDING row on - // `sysio.reserv` awaiting a depot-side `matchreserve` call. The - // creator's outpost-side custody is locked on the originating - // outpost; refund (on RESERVE_CREATE_CANCELLED) targets - // `creator_addr`. - dispatch_reserve_create(self, data, chain_code); - break; - case AttestationType::ATTESTATION_TYPE_RESERVE_CREATE_CANCEL: - // Creator cancellation of a still-PENDING reserve. If the race - // against `matchreserve` is lost the reserv contract no-ops; if - // won it flips status to CANCELLED + queues a RESERVE_CREATE_CANCELLED - // back to the originating outpost so the local custody is released. - dispatch_reserve_create_cancel(self, data, chain_code); + // Retired external reserve lifecycle. break; case AttestationType::ATTESTATION_TYPE_RESERVE_CREATE_CANCELLED: @@ -1899,8 +1615,8 @@ void msgch::queueout(uint64_t chain_code, // the outpost authenticates by the group signature and executes. The intended callers // (sysio.epoch / .opreg / .uwrit / .reserv / .synd) each send under their own {self, active} // authority; get_self() permits msgch's own inline use and governance. - check(has_auth(EPOCH_ACCOUNT) || has_auth(OPREG_ACCOUNT) || has_auth(UWRIT_ACCOUNT) || - has_auth(RESERV_ACCOUNT) || has_auth(synd::account) || has_auth(get_self()), + check(has_auth(EPOCH_ACCOUNT) || has_auth(OPREG_ACCOUNT) || + has_auth(synd::account) || has_auth(get_self()), "queueout: caller not authorized to queue outbound attestations"); // The chains registry is the ONLY authority on which chain codes exist. @@ -2217,5 +1933,4 @@ void msgch::buildenv(uint64_t chain_code) { } } - } // namespace sysio diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index 2dda93da3da2ea7abdad34335ea3c520320817d7..f7ab14b0232fa7893a27ed7dadec86fe7021a813 100755 GIT binary patch delta 28268 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 Pm8Gl1B~EuMhvWYNq$$9q delta 51921 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 F{vZ5 $ $ - $ $ $ $ diff --git a/contracts/sysio.opreg/include/sysio.opreg/sysio.opreg.hpp b/contracts/sysio.opreg/include/sysio.opreg/sysio.opreg.hpp index bd4ded126e..f370b51596 100644 --- a/contracts/sysio.opreg/include/sysio.opreg/sysio.opreg.hpp +++ b/contracts/sysio.opreg/include/sysio.opreg/sysio.opreg.hpp @@ -18,28 +18,11 @@ namespace sysio { /** * @brief sysio.opreg — operator registry on WIRE. * - * Holds the **authoritative** collateral ledger for every operator type - * (producers, batch operators, underwriters, plus their standby tiers). - * Per the corrected ledger model in - * `CLAUDE-WIRE-OPERATOR-COLLATERAL-IMPL-PLAN.md`: - * - * - One aggregate balance per (operator, chain, token_kind) — NOT a - * vector of collateral entries, NOT a locked/available/amount trio. - * - `deposit` (operator-callable, depot-native WIRE or shadow LIQ) and `depositinle` - * (msgch-dispatched from outposts) credit the matching balance row; - * `withdraw` (operator-callable, depot-native) and `withdrawinle` - * (msgch-dispatched from outposts) enqueue a delayed subtraction; - * `slash` zeros the unlocked portion immediately and leaves locks - * for `sysio.uwrit::release` to slash deferred. - * - Underwriter locks live entirely in `sysio.uwrit::locks` — opreg - * reads them directly via the public `sysio::uwrit::locks_t` table - * type to compute `available()`. - * - Pending withdraws are also subtracted by `available()` so an - * operator can't double-use queued funds; `cancelwtdw` lets them - * walk back a queued withdraw before it executes. - * - Termination is administrative (status -> TERMINATED, balance - * remitted to the operator's authex destination); slashing is - * punitive (status -> SLASHED, balance routed to the matching LP). + * Authoritative depot-native operator collateral ledger. + * Deposits custody WIRE or shadow LIQ; withdrawals reserve balances until + * maturity, then credit pull claims. Termination returns the remaining + * balance through claims. Slashing removes the balance and retains custody. + * Pending withdrawals reduce availability; shadow collateral earns yield. */ class [[sysio::contract("sysio.opreg")]] opreg : public contract { public: @@ -48,7 +31,7 @@ namespace sysio { // Well-known accounts static constexpr name EPOCH_ACCOUNT = "sysio.epoch"_n; static constexpr name MSGCH_ACCOUNT = "sysio.msgch"_n; - static constexpr name UWRIT_ACCOUNT = "sysio.uwrit"_n; + static constexpr name CHALG_ACCOUNT = "sysio.chalg"_n; static constexpr name AUTHEX_ACCOUNT = "sysio.authex"_n; static constexpr name TOKEN_ACCOUNT = "sysio.token"_n; @@ -140,11 +123,6 @@ namespace sysio { static constexpr uint32_t MAX_DELLOG_PRUNE_PER_WRITE = 4; static constexpr uint32_t MAX_DELLOG_PRUNE_PER_CRANK = 64; - /// Bounded sweep size for TERMINATED operator rows in `prune`, counted in - /// rows EXAMINED (not rows erased). Every candidate costs a - /// `sysio.uwrit::locks` index probe whether or not it turns out to be - /// prunable, so the cap must bound the scan itself to keep this - /// permissionless crank inside its transaction CPU deadline. static constexpr uint32_t MAX_OPERATOR_PRUNE_PER_CRANK = 20; // Per-operator audit log: ring-buffer cap (newest-in / oldest-out) and @@ -237,60 +215,9 @@ namespace sysio { [[sysio::action]] void deposit(name account, sysio::slug_name token_code, uint64_t amount); - /// Internal: credit an outpost-side collateral row. Called by - /// `sysio.msgch` when it dispatches an `OPERATOR_ACTION(DEPOSIT_REQUEST)` - /// attestation that came in from an outpost. - /// - /// A credit naming the depot chain (`chain_code == opp::wire::chain_code`) is dropped and - /// logged, never credited: depot-native collateral is bonded only through `deposit`, whose - /// custody transfer funds it, and no outpost holds anything to revert. - /// - /// Validation failures (unknown account, slashed/terminated operator, - /// zero amount) DO NOT revert — they are recorded in the operator's - /// `recent_actions` log (when an entry exists) and trigger an outbound - /// `DEPOSIT_REVERT` attestation back to the source outpost so escrowed - /// funds get refunded to the depositor (minus the outpost-side gas - /// penalty). Reverting would abort the entire envelope's dispatch. - /// - /// `actor_chain` + `actor_address` form the depositor's source-chain - /// `ChainAddress` (refund target on DEPOSIT_REVERT). They're split - /// here per the no-proto-messages-in-actions rule — - /// `opp::types::ChainAddress` would leak `bytes` typedefs into the - /// ABI. `original_message_id` is the OPP message id of the inbound - /// DEPOSIT_REQUEST attestation — outposts match on it to scope the - /// refund to one specific in-flight deposit. - [[sysio::action]] - void depositinle(name account, - sysio::slug_name chain_code, - sysio::slug_name token_code, - uint64_t amount, - opp::types::ChainKind actor_chain, - std::vector actor_address, - checksum256 original_message_id); - - /// Operator-callable: queue a withdrawal of depot-native collateral from the - /// `(opp::wire::chain_code, token_code)` row (WIRE or a shadow LIQ symbol; see `deposit`), - /// subject to the WITHDRAW_WAIT_EPOCHS wait. When it matures, `flushwtdw` credits a - /// `remitclaims` row in `token_code` that the operator pulls with `claimremit`. - /// Outpost-held collateral is withdrawn by calling the holding outpost's withdraw entry - /// point — the outpost emits an OPERATOR_ACTION(WITHDRAW_REQUEST) inbound that reaches - /// `withdrawinle` instead. - /// - /// A `token_code` this contract cannot custody reverts, like `deposit`. Every other - /// validation failure DOES NOT revert — it is appended to the operator's `recent_actions` - /// ring buffer (matching the OPP-dispatched path's failure semantics). [[sysio::action]] void withdraw(name account, sysio::slug_name token_code, uint64_t amount); - /// Internal: queue an outpost-side collateral withdrawal. Called by - /// `sysio.msgch` when it dispatches an `OPERATOR_ACTION(WITHDRAW_REQUEST)` - /// attestation that came in from an outpost. Subject to the - /// WITHDRAW_WAIT_EPOCHS wait. Validation failures are logged on the - /// operator's `recent_actions` ring buffer; the dispatch tx commits - /// so other attestations in the same envelope still apply. - [[sysio::action]] - void withdrawinle(name account, sysio::slug_name chain_code, sysio::slug_name token_code, uint64_t amount); - /// Operator-callable: cancel a previously-queued withdrawal before it /// flushes. The reserved amount rejoins the operator's `available()`. [[sysio::action]] @@ -304,7 +231,7 @@ namespace sysio { /// Read-only rollup of the operator's spendable balance for a given /// (chain, token_kind). Returns 0 if the operator is SLASHED / /// TERMINATED, or if no balance row exists. Otherwise returns - /// `balance - sum(active locks on uwrit) - sum(pending withdraws)`. + /// `balance - sum(pending withdraws)`. [[sysio::action, sysio::read_only]] uint64_t available(name account, sysio::slug_name chain_code, sysio::slug_name token_code); @@ -320,31 +247,9 @@ namespace sysio { [[sysio::action]] void processuw(name account, bool was_eligible, bool is_eligible); - /// Slash an operator. Permanent. Called by `sysio.chalg`. Routes the - /// **immediately slashable** portion (`balance - sum(active locks)`) to - /// the matching LP on each chain via OPERATOR_ACTION(SLASH) attestations. - /// The locked portion stays in opreg's balance and is slashed at lock- - /// release time by `sysio.uwrit::release` (deferred-slash). - /// - /// A depot-native shadow row is settled before the subtraction, so the yield it earned up to - /// the slash stays on the row, claimable with `claimyield`. Its locked remainder keeps earning - /// until `releaselock` performs the deferred slash, consistent with the deferred-slash - /// semantics: until then that shadow is still the operator's balance. [[sysio::action]] void slash(name account, std::string reason); - /// Called by `sysio.uwrit::release` when an underwriter lock resolves. - /// Opreg consults its own current status for the operator and routes - /// the released amount appropriately: - /// * SLASHED — decrement balance, emit OPERATOR_ACTION(SLASH) (deferred-slash). - /// * TERMINATED — decrement balance, emit WITHDRAW_REMIT (deferred-remit - /// to the operator's authex destination). - /// * else — no-op (balance was never decremented at lock time; - /// the freed amount naturally reappears in `available()` - /// the moment uwrit erases the lock row). - [[sysio::action]] - void releaselock(name account, sysio::slug_name chain_code, sysio::slug_name token_code, uint64_t amount); - /// Record per-batch-op delivery hit/miss for the rolling 24h buffer. /// Called inline from `sysio.epoch::advance` after each delivery cycle. /// Each write also sweeps up to `MAX_DELLOG_PRUNE_PER_WRITE` rows that @@ -360,22 +265,6 @@ namespace sysio { [[sysio::action]] void termcheck(name account); - /// Administratively terminate an operator. Status -> TERMINATED. Each - /// (chain, token_kind) balance is remitted to the operator's authex - /// destination via `OPERATOR_ACTION(WITHDRAW_REMIT)` attestations. - /// Locks remain alive — `sysio.uwrit::release` will deferred-remit - /// each lock at its natural release time. - /// - /// Earned shadow yield is settled and credited to the operator's WIRE claim alongside the - /// principal, but only the part `yieldpool` covers: this path is never-throw and cannot pull - /// from `sysio.liq`. The uncovered remainder STAYS on the row, and the terminated operator - /// collects it with `claimyield`, which pulls first. Each later `releaselock` on the TERMINATED - /// operator pays the same way. - /// - /// Claim window: banked yield never keeps a row from `prune` (or from the operator's own - /// re-registration), so whatever is still unclaimed when the row is erased is forfeited to the - /// registry. The `terminate_prune_delay_ms` window is therefore the time a terminated operator - /// has to call `claimyield` -- or have anyone call it for them, since it is permissionless. [[sysio::action]] void terminate(name account, std::string reason); @@ -451,20 +340,6 @@ namespace sysio { [[sysio::action]] void claimyield(name account, sysio::slug_name token_code); - /// Prune terminated operator rows, plus delivery-log rows that have aged - /// out of the rolling termination window. Permissionless. - /// - /// A terminated row is erased only once BOTH the prune delay has elapsed - /// AND the operator is completely settled — every balance bucket drained - /// and no `sysio.uwrit` lock still naming the account (WNS-01). - /// `terminate` deliberately retains the locked portion of each balance for - /// `releaselock` to settle later, and `releaselock` no-ops once the row is - /// gone; erasing on the delay alone therefore stranded that collateral, - /// and any caller could trigger it. - /// - /// Banked shadow yield is NOT part of "settled": it never delays the erase, and whatever a - /// terminated operator has not claimed with `claimyield` by then is forfeited to the registry. - /// The prune delay is its claim window (see `terminate`). [[sysio::action]] void prune(); @@ -472,18 +347,6 @@ namespace sysio { // Tables // ----------------------------------------------------------------------- - /// Per-(chain_code, token_code) aggregate balance row. The locked portion - /// is implied by `sysio.uwrit::locks` (consulted by `available()`); the - /// pending-withdraw portion is implied by this contract's - /// `withdraw_queue` (also consulted by `available()`). - /// - /// `shadow_yield` is meaningful only on a depot-native shadow row (`chain_code == - /// opp::wire::chain_code`, `token_code != opp::wire::token_code`), whose bonded shadow earns - /// WIRE yield on this registry's `sysio.liq` holder row; it stays zero on every other row. It - /// is the `opp::shadow::custody` position of this row's balance: settled at `sysio.liq`'s live - /// index for the symbol before every change to `balance` (a new row settles at balance 0, so it - /// starts at the current index and is never credited earlier distributions), so a bonded unit - /// earns exactly what a unit of the registry's holder row earns while it is bonded. struct balance_entry { sysio::slug_name chain_code; sysio::slug_name token_code; @@ -600,15 +463,6 @@ namespace sysio { (eligible_at_epoch)(requested_at_epoch)) }; - // Per plan §B.2 (mirrors sysio.uwrit::locks): split-index approach. - // Antelope KV secondary indexes use fixed-width integer keys; the - // 3-uint64 (account, chain_code, token_code) composite is computed on - // the row as `by_account_ck()` for cross-contract comparisons but is - // NOT a table-managed secondary index. Callers scan `byaccount` - // (uint64) and filter (chain_code, token_code) in memory — cheap - // because pending-withdraw counts per account are bounded by the number - // of its collateral buckets times - // MAX_OUTSTANDING_WITHDRAWS_PER_COLLATERAL_BUCKET. using wtdwqueue_t = sysio::kv::table<"wtdwqueue"_n, withdraw_key, withdraw_request, sysio::kv::index<"byeligible"_n, sysio::const_mem_fun>, @@ -645,48 +499,12 @@ namespace sysio { sysio::const_mem_fun> >; - /// Claimable depot-native collateral owed to an operator by a WIRE-chain remit: a withdraw - /// flush (`flushwtdw`), a deferred lock release on a TERMINATED operator, or the - /// termination payout itself. One row per `(account, token_code)`: WIRE and each shadow - /// LIQ symbol an operator bonded are owed, and claimed, separately (see `deposit`). - /// - /// All three are reachable from `sysio.epoch::advance` (flush by epoch, termination via - /// `termcheck`), which must never abort. A pushed `sysio.token::transfer` notifies the - /// operator, so an operator carrying a hostile notify handler could abort `advance` and halt - /// epoch advancement chain-wide -- and in the termination case would be blocking its own - /// removal, so the retry never converges. Crediting here and paying out from `claimremit` - /// puts the transfer under the operator's own authority instead. - /// - /// No expiry: this is returned collateral, held until claimed. - /// - /// The operator set is registered and bounded only CONCURRENTLY, not across this table's - /// lifetime — a terminated operator that never calls `claimremit` leaves a row billed to the - /// sysio RAM pool even after `prune` removes its operator record, so storage grows with - /// historical operators rather than with the live set. Accepted here for the same reason as - /// `sysio.system::payclaims`: forfeiting returned collateral is an economic decision, not a - /// RAM one. (Contrast `sysio.reserv::wireclaims`, whose recipient set is unbounded AND - /// caller-influenced, and which pays for its sweep with forfeiture.) - /// - /// The row therefore carries `expires_at_sec` and an expiry-ordered index NOW, even though - /// nothing reads them yet. Landing the schema is free before launch and expensive after it; - /// wiring the sweep is the part that needs a decision, so the two are deliberately - /// separated. `credit` maintains the stamp, so WIRE-339 inherits real age data rather than a - /// table that starts counting from the day the field is added. - /// - /// NOTHING EXPIRES TODAY: no `claimable::sweep_expired` call is wired against this table. - /// `REMIT_CLAIM_WINDOW_SEC` is the provisional window the stamp is computed from, not a - /// commitment — whether returned collateral should be forfeited at all is open in WIRE-339. - /// - /// Primary key: the owed operator and the depot-native token it is owed in. struct remitclaim_key { uint64_t account; sysio::slug_name token_code; SYSLIB_SERIALIZE(remitclaim_key, (account)(token_code)) }; - /// Provisional retention window used only to compute `remit_claim::expires_at_sec`. Mirrors - /// `sysio.reserv::WIRE_CLAIM_WINDOW_SEC` so the claimable tables age on one scale; revisited - /// when (and if) a sweep is wired. static constexpr uint32_t REMIT_CLAIM_WINDOW_SEC = 365 * 24 * 60 * 60; struct [[sysio::table("remitclaims")]] remit_claim { @@ -695,17 +513,6 @@ namespace sysio { uint64_t balance = 0; ///< Atomic units of `token_code` owed, not yet claimed. uint32_t expires_at_sec = 0; // recorded by `credit`; read by nothing yet (WIRE-339) - /// Expiry-major composite so the secondary index orders by expiry and a future retention - /// sweep can stop at the first live row. (Same shape as `sysio.reserv::wire_claim`.) - /// - /// The account tail breaks ties between operators, but NOT between one operator's - /// tokens: a remit that credits WIRE and a shadow token in the same second (termination - /// pays every depot-native row at once) gives both rows the same composite. The token is - /// not folded in because expiry, account and token need 160 bits and the index key is - /// 128. The tie is harmless: the host stores each index entry as `(composite, primary - /// key)` and keeps that pair unique, and the primary key carries the token, so tied rows - /// are distinct entries ordered by token; a sweep reads only the expiry prefix, so - /// visiting tied rows in either order changes nothing. uint128_t by_expiry() const { return (static_cast(expires_at_sec) << 64) | account.value; } diff --git a/contracts/sysio.opreg/src/sysio.opreg.cpp b/contracts/sysio.opreg/src/sysio.opreg.cpp index 7af73e6684..53ca4422ea 100644 --- a/contracts/sysio.opreg/src/sysio.opreg.cpp +++ b/contracts/sysio.opreg/src/sysio.opreg.cpp @@ -2,7 +2,6 @@ #include #include #include -#include #include #include #include @@ -24,7 +23,6 @@ using opp::types::OperatorStatus; using opp::types::AttestationType; using opp::attestations::OperatorAction; using opp::attestations::OperatorActionLog; -using opp::attestations::DepositRevert; namespace { @@ -58,10 +56,6 @@ constexpr std::string_view no_yield_owed_msg = "no yield owed"; constexpr std::string_view yield_not_covered_msg = "owed yield is rounding dust the WIRE received from sysio.liq does not cover; only later slack can cover it"; -/// Recent-actions reason `depositinle` records when it drops a credit naming the depot chain. -constexpr std::string_view depot_chain_credit_msg = - "depot-chain collateral is bonded through deposit, never credited from an outpost"; - /// Message of the `check` `sweepyield` raises for WIRE, which is not a shadow token. constexpr std::string_view wire_earns_no_yield_msg = "WIRE collateral earns no shadow yield"; @@ -85,7 +79,7 @@ bool earns_shadow_yield(sysio::slug_name chain_code, sysio::slug_name token_code /// Credit a WIRE-chain remit of `token_code` to the operator's claimable row instead of /// transferring it. /// -/// Every caller (withdraw flush, deferred lock release, termination payout) is reachable from +/// Every caller (withdraw flush and termination payout) is reachable from /// `sysio.epoch::advance`, which must never abort. A custody contract's `transfer` notifies the /// operator, and the chain runs notified receivers with no exception isolation, so a pushed remit /// would let an operator's notify handler abort `advance` and halt epoch advancement chain-wide. @@ -114,45 +108,6 @@ uint64_t current_time_ms() { return static_cast(current_time_point().sec_since_epoch()) * 1000; } -/// Compute the composite key matching `withdraw_request::by_account_ck` / -/// `sysio::uwrit::lock_entry::by_underwriter_ck`. Centralized so both the -/// indexer and the lookups stay in lockstep. -/// -/// Phase 6 layout: three uint64s — `account.value`, `chain_code.value`, -/// `token_code.value` — packed in that order into a 24-byte buffer and -/// hashed with sha256, producing a `checksum256`. The previous uint128 -/// composite layout (account<<64 | chain<<32 | token) is gone; the wider -/// slug_name values no longer fit in 32 bits each. -checksum256 make_account_chain_token_key(name account, - sysio::slug_name chain_code, - sysio::slug_name token_code) { - std::array buf{}; - uint64_t acc_v = account.value; - std::memcpy(buf.data() + 0, &acc_v, 8); - std::memcpy(buf.data() + 8, &chain_code.value, 8); - std::memcpy(buf.data() + 16, &token_code.value, 8); - return sysio::sha256(reinterpret_cast(buf.data()), buf.size()); -} - -/// Find the outpost id registered with sysio.chains for a given chain. Returns -/// `std::nullopt` if no matching chain row exists (the caller is responsible -/// for handling that case — typically by skipping the queueout for chains -/// without an outpost, e.g. WIRE-direct flows). -/// -/// After the cross-contract realignment: chain rows live in -/// `sysio.chains::chains` keyed by `code` (slug_name); the legacy -/// `sysio.epoch::outposts` table is gone. The "outpost id" returned here is -/// the chain's `code.value` (uint64) — callers that still expect a small -/// numeric id should use the slug_name value instead. -std::optional find_outpost_id_for_chain(sysio::slug_name chain_code) { - sysio::chains::chains_t chains_tbl(name{"sysio.chains"_n}); - sysio::chains::chain_key pk{chain_code}; - if (!chains_tbl.contains(pk)) return std::nullopt; - const auto row = chains_tbl.get(pk); - if (row.is_depot) return std::nullopt; // WIRE-direct flows don't queueout - return chain_code.value; -} - /// Resolve a `sysio::slug_name` chain identifier to its `ChainKind` enum by /// reading the `sysio.chains::chains` registry row. Returns `std::nullopt` /// when no chain row exists for the code — callers treat that as "chain @@ -213,46 +168,11 @@ void require_canonical_codes(const std::vector& v, } } -/// True iff `sysio.uwrit::locks` still holds ANY row for `account`, on any -/// `(chain_code, token_code)` pair. Existence-only, so it stops at the first row -/// instead of summing like `sum_locks_inline` — the settlement gate needs to know -/// whether deferred settlement is still outstanding, not how much. -bool has_active_locks(name account) { - uwrit::locks_t locks(opreg::UWRIT_ACCOUNT); - auto idx = locks.template get_index<"byuw"_n>(); - return idx.lower_bound(account.value) != idx.upper_bound(account.value); -} - -/// True iff a TERMINATED operator's collateral is completely settled — every -/// balance bucket drained AND no `sysio.uwrit` lock still naming the account. -/// -/// This is the precondition for erasing a terminated `operators` row, on EVERY -/// path that erases one — `prune` and the re-registration replace in -/// `regoperator` (WNS-01). `terminate_inline` remits only the immediately- -/// unlocked portion and deliberately LEAVES the locked remainder in the row, so -/// `releaselock` can emit its deferred WITHDRAW_REMIT when -/// `sysio.uwrit::chklocks` later releases each lock. `releaselock` settles only -/// SLASHED / TERMINATED rows and returns early when the row is missing, so -/// destroying or resetting it before that settlement lands permanently strands -/// the retained collateral: WIRE-side custody keeps it with no ledger entry to -/// release against, and the outpost keeps its escrow with no attestation that -/// could ever free it. -/// -/// Balance rows are checked by VALUE, not by `balances.empty()` — -/// `subtract_balance` zeroes a bucket in place and never erases the entry. Banked shadow yield does -/// NOT count: it never blocks the erase, and whatever is still unclaimed then is forfeited -- the -/// prune delay is the terminated operator's window to `claimyield` (see `terminate`). -/// -/// A row that never settles is never erased, and that is the intended outcome: -/// an unprunable row costs some RAM, while an erased one costs the operator -/// their bond. In correct operation the wait is bounded — `chklocks` releases -/// every lock at its wall-clock expiry, and each `releaselock` drains the -/// matching bucket, so the predicate goes true on its own. bool is_fully_settled(const opreg::operator_entry& op) { for (const auto& bal : op.balances) { if (bal.balance > 0) return false; } - return !has_active_locks(op.account); + return true; } } // anonymous namespace @@ -385,19 +305,9 @@ void opreg::regoperator(name account, auto existing = ops.get(op_pk); check(existing.status == OperatorStatus::OPERATOR_STATUS_TERMINATED, "operator already registered"); - // Re-registration REPLACES the terminated row, so it is an erase path and - // carries the same settlement precondition as `prune` (WNS-01). Without - // it, a terminated underwriter could re-register while its locks are still - // live: the retained balance row is replaced by a fresh healthy one, and - // when `chklocks` later fans out `releaselock` it sees a non-terminated - // operator and no-ops — stranding the old collateral exactly as pruning - // early would have. Re-registration stays blocked until settlement lands; - // `chklocks` releases every lock at its wall-clock expiry, so the wait is - // bounded. check(is_fully_settled(existing), "operator has unsettled collateral: a terminated operator may only " - "re-register once its balances are drained and no underwriting locks " - "remain"); + "re-register once its balances are drained"); ops.erase(op_pk); } @@ -444,40 +354,11 @@ void opreg::regoperator(name account, } // --------------------------------------------------------------------------- -// Internal helpers — balance / lock / withdraw rollup +// Internal helpers — balance / withdraw rollup // --------------------------------------------------------------------------- namespace { -/// The active lock total on `sysio.uwrit` for a given (op, chain, token) — -/// an O(1) read of uwrit's `locksums` rollup. Returns 0 when the operator -/// holds no live locks on that chain/token (the rollup erases a bucket's row -/// once it empties, so an absent row IS zero). -/// -/// This used to scan `sysio.uwrit::locks` through its `byuw` index and filter -/// `(chain_code, token_code)` in memory, on the stated assumption that -/// per-underwriter lock counts are "O(1)-ish in steady state so the scan is -/// cheap". That assumption does not hold: uwrit locks are held for the full -/// wall-clock challenge window and are never released by delivery, so a -/// bucket's live lock count is (settlement rate × lock duration). uwrit now -/// maintains the total at the THREE sites that can change it: -/// -/// * `try_select_winner` — ADDS, one lock per required leg, on a win. -/// * `chklocks` — DECREMENTS, releasing locks at expiry. -/// * `sweeplocks` — DECREMENTS, erasing the held locks of a -/// commitment whose underwriter-fault challenge was -/// UPHELD (WIRE-297), outside `chklocks`. -/// -/// That third path is the one this PR's stale-cache bug came from, so the -/// count is worth keeping exact here: this reader trusts the rollup -/// completely, and a bucket left positive after its last lock row is gone -/// suppresses that collateral forever. See `uwrit::lock_sum`. -uint64_t sum_locks_inline(name account, sysio::slug_name chain_code, sysio::slug_name token_code) { - uwrit::locksums_t sums(opreg::UWRIT_ACCOUNT); - uwrit::lock_sum_key pk{account, chain_code, token_code}; - return sums.contains(pk) ? sums.get(pk).amount : 0; -} - /// Sum the pending (not-yet-flushed) withdraws on this contract for a given /// (op, chain, token). Subtracted by `available()` so a queued withdraw /// effectively reserves the funds for its 2-epoch wait. @@ -522,7 +403,7 @@ bool has_terminal_status(OperatorStatus status) { } /// Compute available balance for a given (op, chain, token). The single -/// rollup formula: balance - sum(active locks) - sum(pending withdraws), +/// rollup formula: balance - sum(pending withdraws), /// gated by status. Slashed / terminated operators read as zero. uint64_t available_inline(const opreg::operator_entry& op, sysio::slug_name chain_code, sysio::slug_name token_code) { @@ -532,30 +413,23 @@ uint64_t available_inline(const opreg::operator_entry& op, const auto* bal = find_balance(op, chain_code, token_code); if (!bal) return 0; - uint64_t locked = sum_locks_inline(op.account, chain_code, token_code); uint64_t pending = sum_pending_withdraws(op.account, chain_code, token_code); - // Saturating: a wrap of locked+pending would understate `reserved` and - // overstate availability — the direction that admits an overcommit. The - // cap is unreachable for real amounts. - uint64_t reserved = opp::safe::add_sat_u64(locked, pending); - return bal->balance > reserved ? bal->balance - reserved : 0; + return bal->balance > pending ? bal->balance - pending : 0; } -/// Balance minus active locks (NOT pending withdraws). Used by `slash()` to -/// determine how much can be slashed immediately — pending withdraws of a +/// The full balance is slashable, including queued withdrawals. Withdrawals of a /// slashed operator are forfeit (silently dropped at flush time). uint64_t slashable_now(const opreg::operator_entry& op, sysio::slug_name chain_code, sysio::slug_name token_code) { const auto* bal = find_balance(op, chain_code, token_code); if (!bal) return 0; - uint64_t locked = sum_locks_inline(op.account, chain_code, token_code); - return bal->balance > locked ? bal->balance - locked : 0; + return bal->balance; } /// Check whether the operator's available balance on (chain_code, token_code) /// covers the role's minimum bond on that pair. /// -/// Bootstrapped operators are ACTIVE-by-fiat and bypass the per-outpost +/// Bootstrapped operators are ACTIVE-by-fiat and bypass the per-token /// bond check regardless of how `req_*_collat` is configured — they /// represent system-installed operators that the depot trusts without /// requiring collateral. Non-bootstrapped operators must satisfy every @@ -664,7 +538,7 @@ uint64_t take_yield(name self, opreg::balance_entry& b) { } /// Settle every shadow row the operator holds and take what each has earned, as far as each pool -/// covers, for the TERMINATED payouts (termination itself, then each later lock release). Returns +/// covers, for the TERMINATED payout. Returns /// the amount taken per row, for the caller to credit to the operator's WIRE claim alongside the /// principal; what a pool could not cover stays banked for `claimyield`. Never throws. std::vector take_all_earned_yield(name self, opreg::operator_entry& o) { @@ -842,12 +716,7 @@ opp::types::ChainAddress operator_chain_address(name account, sysio::slug_name c /// ready for either logging on the operator's row or queueing as an /// outbound OPERATOR_ACTION attestation. Pure — no side effects. /// -/// LP routing for the slashed funds is depot-side concern resolved via -/// `sysio.reserve::resolve_lp` at slash-handler time; outposts on receipt -/// only need to seize their share, so the attestation does not encode it. -/// The `OperatorActionLog.timestamp` covers the audit trail; once an -/// operator is slashed all subsequent OperatorActions are dropped+logged -/// as failures, so per-action epoch is redundant on the message itself. +/// This payload records a depot-local slash in the operator audit trail. OperatorAction build_slash_action(name account, OperatorType type, sysio::slug_name chain_code, @@ -867,69 +736,6 @@ OperatorAction build_slash_action(name account, return oa; } -/// Queue an OPERATOR_ACTION(SLASH) attestation outbound to the outpost -/// matching `chain_code`. No-op if the chain is WIRE (slashed funds stay on -/// the WIRE chain) or has no registered outpost. -void emit_slash_attestation(name self, const OperatorAction& slash_action) { - const sysio::slug_name chain_code{slash_action.chain_code}; - if (chain_code == opp::wire::chain_code) return; - auto resolved = find_outpost_id_for_chain(chain_code); - if (!resolved) return; // no outpost on this chain — nothing to slash through - - // `no_size{}` — raw protobuf bytes, no 4-byte zpp length prefix. The - // outpost decodes the attestation `data` field as a pure protobuf - // message; a size prefix would corrupt the first field tag. - std::vector encoded; - auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; - (void)out(slash_action); - - action( - permission_level{self, "active"_n}, - opreg::MSGCH_ACCOUNT, "queueout"_n, - std::make_tuple(*resolved, - AttestationType::ATTESTATION_TYPE_OPERATOR_ACTION, encoded) - ).send(); -} - -/// Queue a DEPOSIT_REVERT attestation outbound to the source outpost so -/// escrowed funds get refunded to the depositor (minus the outpost-side -/// gas penalty, computed locally on the outpost when the revert is -/// processed). Called by `opreg::depositinle` whenever validation rejects -/// an inbound DEPOSIT_REQUEST. -void emit_deposit_revert(name self, - sysio::slug_name source_chain_code, - const opp::types::ChainAddress& depositor, - sysio::slug_name token_code, - uint64_t amount, - const checksum256& original_message_id, - const std::string& reason) { - auto chain_code = find_outpost_id_for_chain(source_chain_code); - if (!chain_code) return; // no outpost on this chain — nothing to refund through - - opp::attestations::DepositRevert dr; - dr.depositor = depositor; - opp::types::TokenAmount ta; - ta.token_code = token_code.value; - ta.amount = zpp::bits::vint64_t{static_cast(amount)}; - dr.refund_amount = ta; - const auto& mh = original_message_id.extract_as_byte_array(); - dr.original_deposit_message_id.assign(mh.begin(), mh.end()); - dr.reason = reason; - dr.chain_code = source_chain_code.value; - - // `no_size{}` — see emit_slash_attestation for the rationale. - std::vector encoded; - auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; - (void)out(dr); - - action( - permission_level{self, "active"_n}, - opreg::MSGCH_ACCOUNT, "queueout"_n, - std::make_tuple(*chain_code, - AttestationType::ATTESTATION_TYPE_DEPOSIT_REVERT, encoded) - ).send(); -} - /// Append an OperatorActionLog entry to the operator's `recent_actions` /// ring buffer (newest at the back). When the buffer is at MAX_RECENT_ACTIONS, /// drops the oldest (front) before appending. Caps `error_message` at @@ -965,43 +771,6 @@ void append_action_log(opreg::operators_t& ops, }); } -/// Encode + queue an OPERATOR_ACTION(WITHDRAW_REMIT) attestation to the -/// outpost matching `chain_code`. `op_address` carries the operator's authex- -/// linked chain pubkey so the outpost can derive the destination address. -void emit_withdraw_remit(name self, - name account, - OperatorType type, - sysio::slug_name chain_code, - sysio::slug_name token_code, - uint64_t amount, - uint64_t request_id) { - auto resolved = find_outpost_id_for_chain(chain_code); - if (!resolved) return; - - OperatorAction oa; - oa.action_type = OperatorAction::ACTION_TYPE_WITHDRAW_REMIT; - oa.op_address = operator_chain_address(account, chain_code); - oa.type = type; - opp::types::TokenAmount ta; - ta.token_code = token_code.value; - ta.amount = zpp::bits::vint64_t{static_cast(amount)}; - oa.amount = ta; - oa.request_id = request_id; - oa.chain_code = chain_code.value; - - // `no_size{}` — see emit_slash_attestation for the rationale. - std::vector encoded; - auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; - (void)out(oa); - - action( - permission_level{self, "active"_n}, - opreg::MSGCH_ACCOUNT, "queueout"_n, - std::make_tuple(*resolved, - AttestationType::ATTESTATION_TYPE_OPERATOR_ACTION, encoded) - ).send(); -} - } // anonymous namespace // --------------------------------------------------------------------------- @@ -1017,19 +786,12 @@ void reevaluate_eligibility(opreg::operators_t& ops, name self, name account); -/// Result of `try_enqueue_withdraw` — non-throwing variant for the -/// msgch-dispatched `withdrawinle` path so failures get logged on the -/// operator's row instead of reverting the inbound dispatch tx. struct enqueue_result { bool success; uint64_t request_id; // valid only when success == true std::string error_message; }; -/// Validate + insert a `wtdwqueue` row. Does NOT throw on validation -/// failure — returns the diagnostic in `enqueue_result`. Used by both -/// `withdrawinle` (msgch-dispatched, log-don't-revert) and `withdraw` -/// (operator-callable depot-native, also log-don't-revert). enqueue_result try_enqueue_withdraw(name account, sysio::slug_name chain_code, sysio::slug_name token_code, @@ -1158,10 +920,7 @@ OperatorAction build_deposit_action(const opp::types::ChainAddress& op_address, // registry cannot custody reverts, so a request that could never be paid out // is never queued. Every other validation failure DOES NOT revert — it // appends a failure entry to the operator's `recent_actions` ring buffer, -// matching the OPP-dispatched path (`withdrawinle`). The operator reads the -// outcome via WIRE JSON-RPC. Outpost-held collateral is withdrawn by calling -// the holding outpost's withdraw entry point — that path arrives at -// `withdrawinle` instead. +// The operator reads the recorded result through WIRE JSON-RPC. void opreg::withdraw(name account, sysio::slug_name token_code, uint64_t amount) { require_auth(account); check(resolve_depot_native_token(token_code).has_value(), unsupported_token_msg); @@ -1178,35 +937,6 @@ void opreg::withdraw(name account, sysio::slug_name token_code, uint64_t amount) } } -// --------------------------------------------------------------------------- -// withdrawinle — internal: outpost-driven withdraw request (msgch-inline) -// --------------------------------------------------------------------------- -// -// Inline-dispatched from `sysio.msgch::evalcons` for inbound -// OPERATOR_ACTION(WITHDRAW_REQUEST) attestations. Validation failures DO -// NOT revert (revert would kill the entire envelope's dispatch); the -// outcome is appended to the operator's `recent_actions` ring so they -// can read why their request was dropped via WIRE JSON-RPC. Escrowed -// funds stay in outpost custody on rejection — the operator re-issues -// once the underlying condition resolves. -void opreg::withdrawinle(name account, - sysio::slug_name chain_code, - sysio::slug_name token_code, - uint64_t amount) { - require_auth(get_self()); - - operators_t ops(get_self()); - auto op_pk = operator_key{account.value}; - - auto result = try_enqueue_withdraw(account, chain_code, token_code, amount); - auto action = build_withdraw_request_action(account, chain_code, token_code, amount, - result.request_id); - append_action_log(ops, op_pk, action, result.success, std::move(result.error_message)); - if (result.success) { - reevaluate_eligibility(ops, op_pk, get_self(), account); - } -} - // --------------------------------------------------------------------------- // cancelwtdw — operator cancels a queued withdraw before it flushes // --------------------------------------------------------------------------- @@ -1315,27 +1045,8 @@ void opreg::deposit(name account, sysio::slug_name token_code, uint64_t amount) check(op.status != OperatorStatus::OPERATOR_STATUS_SLASHED && op.status != OperatorStatus::OPERATOR_STATUS_TERMINATED, "operator not in a deposit-eligible state"); - // Bootstrapped operators are ACTIVE by fiat and bypass `meets_role_min` entirely, so collateral - // credited to one can never affect its eligibility -- the deposit would be accepted into a - // balance that does nothing. `depositinle` already rejects them; this closes the depot-native - // path. There is deliberately no way to collateralise a bootstrap: an operator who wants a - // collateralised producer registers a new account. check(!op.is_bootstrapped, "bootstrapped operators cannot deposit collateral"); - // Credit collateral BEFORE the inbound custody transfer, with the cap check - // performed ATOMICALLY inside the same `modify` as the credit — reading the - // live row, not the `op` copy read above. This closes a reentrancy window on - // operator accounts that carry contract code: the custody contract's - // `transfer` notifies `from` (the operator), and that notification handler - // could re-enter `deposit`. A separate pre-read-then-check-then-credit could - // let two credits pass against the same stale balance and push the row past - // `asset::max_amount` — recreating the withdraw/terminate remit abort - // (`asset(balance, symbol)` aborts above the limit). Checking inside the - // modify makes check+credit indivisible, and crediting before the transfer - // means any re-entry observes the committed balance. A direct user deposit may - // legitimately `check()`-throw here (unlike the never-throw OPP `depositinle`); - // if the transfer below aborts (operator lacks the tokens) the whole - // transaction — including this credit — rolls back. (SEC-103; PR #449 review.) ops.modify(same_payer, op_pk, [&](auto& o) { check(amount <= MAX_COLLATERAL_AMOUNT && balance_of(o, opp::wire::chain_code, token_code) <= MAX_COLLATERAL_AMOUNT - amount, @@ -1356,124 +1067,6 @@ void opreg::deposit(name account, sysio::slug_name token_code, uint64_t amount) reevaluate_eligibility(ops, op_pk, get_self(), account); } -// --------------------------------------------------------------------------- -// depositinle — internal: outpost-driven collateral credit (msgch-inline) -// --------------------------------------------------------------------------- -// -// Inline-dispatched from `sysio.msgch::evalcons` for inbound -// OPERATOR_ACTION(DEPOSIT_REQUEST) attestations. Validation failures DO -// NOT revert — reverting from inside the inline dispatch would abort the -// entire envelope. Instead, the failure is logged on the operator's -// `recent_actions` ring (when an entry exists) and a `DEPOSIT_REVERT` -// attestation is queued outbound to the source outpost so the escrowed -// funds can be refunded to the depositor (minus the outpost-side gas -// penalty, computed locally on the outpost when the revert is processed). -// -// `actor_chain` is retained as `opp::types::ChainKind` per the -// ChainAddress flattening pattern — the depositor's source-chain -// `ChainAddress.kind` field is still ChainKind on the wire and is not -// part of the slug_name refactor. -void opreg::depositinle(name account, - sysio::slug_name chain_code, - sysio::slug_name token_code, - uint64_t amount, - opp::types::ChainKind actor_chain, - std::vector actor_address, - checksum256 original_message_id) { - require_auth(get_self()); - - operators_t ops(get_self()); - auto op_pk = operator_key{account.value}; - - // Reconstruct the depositor's ChainAddress locally — the proto message - // type stays out of the ABI but is fine to use inside the contract for - // building OPERATOR_ACTION logs and DEPOSIT_REVERT correlation. - opp::types::ChainAddress actor; - actor.kind = actor_chain; - actor.address = std::move(actor_address); - - auto deposit_action = build_deposit_action(actor, chain_code, token_code, amount); - - // A credit naming the depot chain would create a depot-native row no custody transfer ever - // funded: depot-native collateral is bonded only through `deposit`, whose inbound transfer pays - // for it. `sysio.msgch` never dispatches such a credit today; refuse it here too. Dropped, not - // reverted -- there is no outpost holding anything to refund -- and never thrown, because this - // runs inside an envelope's dispatch. - if (chain_code == opp::wire::chain_code) { - append_action_log(ops, op_pk, deposit_action, false, std::string(depot_chain_credit_msg)); - return; - } - - // The balance map is keyed by (chain_code, token_code), so an uncanonical token - // code must not be persisted: rendering is total and will not complain, but the - // code can render a valid spelling that re-parses as a DIFFERENT code, aliasing - // one operator's balance onto another's key. The outpost has already taken - // custody, so refund rather than drop. - if (!token_code.is_canonical()) { - const std::string err = "token code has no canonical slug_name spelling"; - emit_deposit_revert(get_self(), chain_code, actor, token_code, amount, - original_message_id, err); - append_action_log(ops, op_pk, deposit_action, false, err); - return; - } - if (amount == 0) { - const std::string err = "amount must be positive"; - emit_deposit_revert(get_self(), chain_code, actor, token_code, amount, - original_message_id, err); - append_action_log(ops, op_pk, deposit_action, false, err); - return; - } - if (!ops.contains(op_pk)) { - // No entry to log to. The DEPOSIT_REVERT IS the audit record for the - // outpost — outpost emits a local refund event the depositor reads. - emit_deposit_revert(get_self(), chain_code, actor, token_code, amount, - original_message_id, "operator not registered"); - return; - } - auto op = ops.get(op_pk); - if (op.status == OperatorStatus::OPERATOR_STATUS_SLASHED || - op.status == OperatorStatus::OPERATOR_STATUS_TERMINATED) { - const std::string err = "operator not in a deposit-eligible state"; - emit_deposit_revert(get_self(), chain_code, actor, token_code, amount, - original_message_id, err); - append_action_log(ops, op_pk, deposit_action, false, err); - return; - } - // Bootstrapped operators are bonded by fiat — no deposit is ever - // permitted for them. Reject via DEPOSIT_REVERT (NOT a throw, per - // the no-throws-in-OPP-handlers rule — a throw here would halt the - // entire envelope's evalcons → chain stalls). The outpost refunds - // the depositor when it processes the revert. - if (op.is_bootstrapped) { - const std::string err = "bootstrapped operator cannot accept deposits"; - emit_deposit_revert(get_self(), chain_code, actor, token_code, amount, - original_message_id, err); - append_action_log(ops, op_pk, deposit_action, false, err); - return; - } - // SEC-103 (WSA-028 follow-up): the credited collateral must stay within the - // asset magnitude range so the depot-native claim payout's `asset(balance, - // symbol)` can never abort — an abort on this OPP-inbound path would stall - // consensus. The msgch ingress gate already bounds a single `amount` to - // `asset::max_amount`; this additionally bounds the running sum. Fail closed - // by refunding via DEPOSIT_REVERT — never `check()`. - if (amount > MAX_COLLATERAL_AMOUNT || - balance_of(op, chain_code, token_code) > MAX_COLLATERAL_AMOUNT - amount) { - const std::string err = "deposit would exceed max collateral"; - emit_deposit_revert(get_self(), chain_code, actor, token_code, amount, - original_message_id, err); - append_action_log(ops, op_pk, deposit_action, false, err); - return; - } - - ops.modify(same_payer, op_pk, [&](auto& o) { - add_balance(o, chain_code, token_code, amount); - }); - append_action_log(ops, op_pk, deposit_action, true, ""); - - reevaluate_eligibility(ops, op_pk, get_self(), account); -} - // --------------------------------------------------------------------------- // flushwtdw — drain matured rows from the withdraw queue // --------------------------------------------------------------------------- @@ -1516,7 +1109,7 @@ void opreg::flushwtdw(uint32_t current_epoch) { auto op = ops.get(op_pk); if (op.status == OperatorStatus::OPERATOR_STATUS_SLASHED) { - // Slashed during the wait — funds went to the LP via the slash flow. + // Slashed during the wait; the operator no longer owns the collateral. append_action_log(ops, op_pk, remit_action, false, "operator slashed during withdraw-wait window"); queue.erase(wkey); @@ -1524,11 +1117,6 @@ void opreg::flushwtdw(uint32_t current_epoch) { } if (op.status == OperatorStatus::OPERATOR_STATUS_TERMINATED) { - // Terminated during the wait — terminate_inline already remitted the operator's full - // unlocked balance (which covered this queued amount), so the row is moot. Erase without - // subtracting; otherwise subtract_balance would underflow and abort this epoch-inline - // action, permanently stalling epoch advancement (the balance was reduced to sum(locks) - // by terminate_inline, leaving nothing to cover the matured withdraw). append_action_log(ops, op_pk, remit_action, false, "operator terminated during withdraw-wait window"); queue.erase(wkey); @@ -1558,14 +1146,8 @@ void opreg::flushwtdw(uint32_t current_epoch) { // transfer happens here, and the operator receives nothing until it calls `claimremit`. The // row's `token_code` is carried as-is, never resolved here. This path runs inline from // `sysio.epoch::advance`, where a pushed transfer would let the operator's notify handler - // abort epoch advancement chain-wide. For outpost chains: queue an - // OPERATOR_ACTION(WITHDRAW_REMIT) to the outpost so it can release the escrow on its end. - if (row.chain_code == opp::wire::chain_code) { - credit_remit_claim(get_self(), row.account, row.token_code, row.amount); - } else { - emit_withdraw_remit(get_self(), row.account, op.type, - row.chain_code, row.token_code, row.amount, row.request_id); - } + // abort epoch advancement chain-wide. + credit_remit_claim(get_self(), row.account, row.token_code, row.amount); append_action_log(ops, op_pk, remit_action, true, ""); // Remove the matured reservation before eligibility is recomputed. The @@ -1643,7 +1225,7 @@ void opreg::processuw(name account, bool was_eligible, bool is_eligible) { } // --------------------------------------------------------------------------- -// slash — punitive removal; routes unlocked funds to LP, defers locked +// slash — punitive removal of the depot collateral balance // --------------------------------------------------------------------------- void opreg::slash(name account, std::string reason) { require_auth(CHALG_ACCOUNT); @@ -1658,9 +1240,7 @@ void opreg::slash(name account, std::string reason) { auto now = current_time_ms(); - // Snapshot the slashable amounts per (chain_code, token_code) BEFORE - // marking SLASHED (the status flip would zero `slashable_now` via - // available()). + // Snapshot the full collateral balance for the slash and audit log. struct slash_pair { sysio::slug_name chain_code; sysio::slug_name token_code; uint64_t amount; }; std::vector to_slash; for (const auto& bal : op.balances) { @@ -1670,9 +1250,7 @@ void opreg::slash(name account, std::string reason) { } } - // Flip status + decrement balances by the slashable_now portion. - // Locked portion (== sum_locks) remains in `balance`; sysio.uwrit::release - // will deferred-slash it as each lock resolves. + // Mark SLASHED and remove each collateral balance; custody remains here. ops.modify(same_payer, op_pk, [&](auto& o) { o.status = OperatorStatus::OPERATOR_STATUS_SLASHED; o.updated_at = now; @@ -1689,99 +1267,12 @@ void opreg::slash(name account, std::string reason) { auto slash_action = build_slash_action(op.account, op.type, sp.chain_code, sp.token_code, sp.amount, reason); - emit_slash_attestation(get_self(), slash_action); append_action_log(ops, op_pk, slash_action, /*success*/ true, ""); } notify_producer_standing(get_self(), op); } -// --------------------------------------------------------------------------- -// releaselock — deferred-slash / deferred-remit / no-op on lock release -// --------------------------------------------------------------------------- -void opreg::releaselock(name account, - sysio::slug_name chain_code, - sysio::slug_name token_code, - uint64_t amount) { - require_auth(UWRIT_ACCOUNT); - check(amount > 0, "amount must be positive"); - - operators_t ops(get_self()); - auto op_pk = operator_key{account.value}; - if (!ops.contains(op_pk)) return; - auto op = ops.get(op_pk); - - if (op.status != OperatorStatus::OPERATOR_STATUS_SLASHED && - op.status != OperatorStatus::OPERATOR_STATUS_TERMINATED) { - // Healthy underwriter: balance was never decremented at lock time. - // uwrit::release just erases the lock row; opreg has no work. - return; - } - - // SLASHED or TERMINATED — decrement opreg balance and emit the matching - // outbound attestation (deferred-slash to LP or deferred-remit to authex). - // - // Clamp the settled amount to the live balance bucket. In correct - // operation sum(active locks) <= balance for every (underwriter, - // chain_code, token_code) — the sysio.uwrit winner check reserves the - // aggregate per collateral bucket before writing locks — so `amount` - // never exceeds the remaining balance and this clamp is a no-op. - // It is the safety net for any residual/over-committed lock set: - // releaselock runs INLINE inside `sysio.uwrit::chklocks` at - // `sysio.epoch::advance`, and a `subtract_balance` underflow `check()` - // there would abort the advance and permanently stall epoch advancement - // chain-wide (`epoch-stall-is-fatal`). Settle — and attest — only what the - // balance can actually back. - uint64_t settle_amount = amount; - if (const auto* bal = find_balance(op, chain_code, token_code)) { - if (settle_amount > bal->balance) settle_amount = bal->balance; - } else { - settle_amount = 0; // no balance row for this bucket — nothing to settle - } - if (settle_amount == 0) { - // Bucket already fully drained (e.g. by prior releases of an - // over-committed set) — the caller has erased the lock row; emit no - // zero-value slash/remit attestation. - return; - } - - // A TERMINATED operator's shadow rows kept earning on their locked remainder since the - // termination payout; pay that yield with the principal, as the payout did -- the part the pool - // covers is credited, the rest stays banked for `claimyield` within the claim window. A SLASHED - // operator keeps it all on the row, claimable through `claimyield` (its locked remainder earned - // until this deferred slash). - std::vector yield_credits; - ops.modify(same_payer, op_pk, [&](auto& o) { - subtract_balance(o, chain_code, token_code, settle_amount); - if (op.status == OperatorStatus::OPERATOR_STATUS_TERMINATED) { - yield_credits = take_all_earned_yield(get_self(), o); - } - }); - - if (op.status == OperatorStatus::OPERATOR_STATUS_SLASHED) { - auto slash_action = build_slash_action(op.account, op.type, - chain_code, token_code, settle_amount, - /*reason*/ "deferred slash on lock release"); - emit_slash_attestation(get_self(), slash_action); - append_action_log(ops, op_pk, slash_action, /*success*/ true, ""); - } else { - // TERMINATED — for depot-native, CREDIT the operator's `remitclaims` row in this lock's token; - // it is paid only when it calls `claimremit`. The termination itself already committed in an earlier epoch, so a - // pushed transfer here could not undo it — what it WOULD do is abort this lock release, which - // `chklocks` runs inline from `sysio.epoch::advance`, stalling epoch advancement chain-wide - // and leaving the lock unreleased on every retry. (Aborting the termination itself is the - // `terminate_inline` case below.) Otherwise queue WITHDRAW_REMIT so the outpost can transfer - // to the authex destination. request_id == 0 (this remit isn't queued in wtdwqueue). - if (chain_code == opp::wire::chain_code) { - credit_remit_claim(get_self(), account, token_code, settle_amount); - } else { - emit_withdraw_remit(get_self(), op.account, op.type, - chain_code, token_code, settle_amount, /*request_id*/ 0); - } - credit_yield(get_self(), account, yield_credits); - } -} - // --------------------------------------------------------------------------- // terminate / termcheck / recorddel — administrative removal // --------------------------------------------------------------------------- @@ -1791,7 +1282,7 @@ namespace { /// Internal terminate body — used by both the operator-removal path /// (`termcheck` -> `terminate` inline) and the slashing-equivalent path for /// completeness. Marks status TERMINATED and remits each -/// (chain_code, token_code) balance back to the operator via WITHDRAW_REMIT. +/// depot-native token balance to a pull claim for the operator. void terminate_inline(name self, name account, const std::string& reason) { opreg::operators_t ops(self); auto op_pk = opreg::operator_key{account.value}; @@ -1807,20 +1298,14 @@ void terminate_inline(name self, name account, const std::string& reason) { std::vector to_remit; for (const auto& bal : op.balances) { uint64_t amt = slashable_now(op, bal.chain_code, bal.token_code); - // For termination we route the unlocked portion. The locked portion - // gets remitted at lock-release time by sysio.uwrit::release (deferred- - // remit, symmetric with deferred-slash). + // Termination returns the full remaining balance through pull claims. if (amt > 0) { to_remit.push_back({bal.chain_code, bal.token_code, amt}); } } - // Every shadow row is settled (the subtraction settles the remitted ones; the collection below - // settles the rest, e.g. a row whose balance is all locked) and its banked yield is paid with - // the principal to the WIRE claim row, up to what `yieldpool` covers; this path pushes nothing - // and so cannot pull more from sysio.liq. The uncovered rest stays banked for `claimyield` - // within the claim window (see `terminate`). Yield on a locked remainder keeps accruing until - // `releaselock` settles that remainder and pays it the same way. + // Settle earned shadow yield alongside principal, up to the yield pool balance. + // Uncovered yield stays banked for claimyield during the termination claim window. std::vector yield_credits; ops.modify(same_payer, op_pk, [&](auto& o) { o.status = OperatorStatus::OPERATOR_STATUS_TERMINATED; @@ -1832,25 +1317,10 @@ void terminate_inline(name self, name account, const std::string& reason) { yield_credits = take_all_earned_yield(self, o); }); - // Remit each (chain_code, token_code). For WIRE-chain: CREDIT `remitclaims` in the row's token, - // which the operator pulls with `claimremit` -- nothing is transferred from here. For outpost - // chains: queue WITHDRAW_REMIT. - // - // After each remit, append a WITHDRAW_REMIT entry to the operator's - // `recent_actions` ring buffer so the audit trail mirrors the - // operator-initiated withdraw flow (`flushwtdw` does the same). Without - // this entry, downstream consumers polling `operators[op].recent_actions` - // for proof of remit emission see only the prior DEPOSIT_REQUESTs and - // miss the termination payout — same semantic gap on TERMINATED ops as on - // a normal queued withdraw, only resolvable by querying msgch internals - // (which are transient — the rows drain on the next `buildenv`). + // Credit each token's pull claim and append its local withdrawal audit entry. + // No recipient notification can interrupt epoch-driven termination. for (const auto& rp : to_remit) { - if (rp.chain_code == opp::wire::chain_code) { - credit_remit_claim(self, account, rp.token_code, rp.amount); - } else { - emit_withdraw_remit(self, account, op.type, - rp.chain_code, rp.token_code, rp.amount, /*request_id*/ 0); - } + credit_remit_claim(self, account, rp.token_code, rp.amount); OperatorAction remit_action = build_withdraw_remit_action( account, rp.chain_code, rp.token_code, rp.amount, /*request_id*/ 0); append_action_log(ops, op_pk, remit_action, /*success*/ true, @@ -1871,7 +1341,7 @@ void opreg::terminate(name account, std::string reason) { // claimremit - pull depot-native collateral credited by a WIRE-chain remit, one token at a time. // -// The remit paths (withdraw flush, deferred lock release, termination payout) credit rather than +// The remit paths (withdraw flush and termination payout) credit rather than // transfer because every one of them is reachable from `sysio.epoch::advance`, which must never // abort. This is the only place such a balance becomes a transfer, and it carries the operator's // own authority, so a hostile transfer-notify handler blocks nothing but this caller's own claim. @@ -2077,25 +1547,8 @@ void opreg::prune() { operators_t ops(get_self()); auto status_idx = ops.get_index<"bystatus"_n>(); - // Erase a TERMINATED row only when BOTH gates pass: the prune delay has - // elapsed AND the operator's collateral is completely settled (WNS-01 — see - // `is_fully_settled`). The delay alone is not sufficient: `terminate_inline` - // intentionally retains the locked portion of every balance for - // `releaselock` to settle when `sysio.uwrit::chklocks` frees the lock, and - // `releaselock` no-ops the moment the operator row is gone. Since `prune` is - // permissionless, gating on the delay alone let ANY caller strand a - // terminated operator's locked collateral by cranking it first. - // - // The budget counts rows EXAMINED, not rows erased — same convention as - // `flushwtdw`'s `flushed` and `drainfwq`'s `drained`. Each candidate costs a - // `sysio.uwrit::locks` index probe whether or not it ends up erased, so - // counting only erases would let a long prefix of delay-elapsed but unsettled - // rows do unbounded work and blow this permissionless crank's CPU deadline. - // - // A capped scan means rows beyond the budget wait for a later crank. That - // converges rather than starving: every unsettled row is settled by - // `chklocks` at its locks' wall-clock expiry and then erased, so the - // unprunable prefix drains on its own. + // Wait for both the retention delay and complete principal/yield settlement. + // Count examined rows so unsettled entries cannot make this crank unbounded. uint32_t examined = 0; for (auto it = status_idx.lower_bound( magic_enum::enum_integer(OperatorStatus::OPERATOR_STATUS_TERMINATED)); diff --git a/contracts/sysio.opreg/sysio.opreg.abi b/contracts/sysio.opreg/sysio.opreg.abi index 49d1ac1c69..397c5339e9 100644 --- a/contracts/sysio.opreg/sysio.opreg.abi +++ b/contracts/sysio.opreg/sysio.opreg.abi @@ -266,40 +266,6 @@ } ] }, - { - "name": "depositinle", - "base": "", - "fields": [ - { - "name": "account", - "type": "name" - }, - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "amount", - "type": "uint64" - }, - { - "name": "actor_chain", - "type": "ChainKind" - }, - { - "name": "actor_address", - "type": "bytes" - }, - { - "name": "original_message_id", - "type": "checksum256" - } - ] - }, { "name": "flushwtdw", "base": "", @@ -539,28 +505,6 @@ } ] }, - { - "name": "releaselock", - "base": "", - "fields": [ - { - "name": "account", - "type": "name" - }, - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "amount", - "type": "uint64" - } - ] - }, { "name": "remit_claim", "base": "", @@ -773,28 +717,6 @@ } ] }, - { - "name": "withdrawinle", - "base": "", - "fields": [ - { - "name": "account", - "type": "name" - }, - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "amount", - "type": "uint64" - } - ] - }, { "name": "yield_pool", "base": "", @@ -854,11 +776,6 @@ "type": "deposit", "ricardian_contract": "" }, - { - "name": "depositinle", - "type": "depositinle", - "ricardian_contract": "" - }, { "name": "flushwtdw", "type": "flushwtdw", @@ -894,11 +811,6 @@ "type": "regoperator", "ricardian_contract": "" }, - { - "name": "releaselock", - "type": "releaselock", - "ricardian_contract": "" - }, { "name": "setconfig", "type": "setconfig", @@ -928,11 +840,6 @@ "name": "withdraw", "type": "withdraw", "ricardian_contract": "" - }, - { - "name": "withdrawinle", - "type": "withdrawinle", - "ricardian_contract": "" } ], "tables": [ diff --git a/contracts/sysio.opreg/sysio.opreg.wasm b/contracts/sysio.opreg/sysio.opreg.wasm index 2f57a38438539d4732283a062fe21853facf5ebc..bd09c28a129a200586245d299e2b509e9313c4b8 100755 GIT binary patch delta 15461 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 - $ - $ - $ - $ - $ - $ - $ - $ - ) - - target_link_libraries(${target} - INTERFACE - magic_enum::magic_enum - ) - endforeach() -endif() diff --git a/contracts/sysio.reserv/include/sysio.reserv/sysio.reserv.hpp b/contracts/sysio.reserv/include/sysio.reserv/sysio.reserv.hpp deleted file mode 100644 index 5acf341aee..0000000000 --- a/contracts/sysio.reserv/include/sysio.reserv/sysio.reserv.hpp +++ /dev/null @@ -1,741 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include - -namespace sysio { - - /** - * @brief sysio.reserv — reserve registry with create→match→ready handshake. - * - * Per the data-model refactor: - * - * - Reserve primary key is the triple `(chain_code, token_code, code)` - * (all codenames). Composite stored as `checksum256(chain || token || code)`. - * - * - `ReserveStatus` proto enum (`PENDING` / `ACTIVE` / `CANCELLED`) replaces - * the prior `active: bool`. - * - * - **Bootstrap path** (`current_epoch_index == 0`): `regreserve(...)` is - * priv-gated and inserts a row with `status=ACTIVE` inline. No `matchreserve` - * needed. - * - * - **Post-bootstrap path**: users call `create_reserve(...)` on outposts; the - * outpost queues a `RESERVE_CREATE` attestation; sysio.msgch dispatches - * `oncrtreserve(...)` which inserts a row with `status=PENDING`. Any WIRE - * account then calls `matchreserve(...)` putting up `requested_wire_amount` - * WIRE — `sysio.reserv` takes custody, status flips to `ACTIVE`, and a - * `RESERVE_READY` is queued back to the outpost. - * - * - **Cancel path**: creator calls `cancel_create_reserve(...)` on the outpost. - * `RESERVE_CREATE_CANCEL` flows; sysio.msgch dispatches `oncnclrsv(...)`. If - * `status == PENDING`, set `CANCELLED` + queue `RESERVE_CREATE_CANCELLED`. - * Else silent no-op (race lost — match landed first; - * feedback_opp_handlers_never_throw applies). - */ - class [[sysio::contract("sysio.reserv")]] reserve : public contract { - public: - using contract::contract; - - // Well-known accounts - static constexpr name MSGCH_ACCOUNT = "sysio.msgch"_n; - static constexpr name UWRIT_ACCOUNT = "sysio.uwrit"_n; - static constexpr name TOKEN_ACCOUNT = "sysio.token"_n; - static constexpr name EPOCH_ACCOUNT = "sysio.epoch"_n; - static constexpr name AUTHEX_ACCOUNT = "sysio.authex"_n; - static constexpr name CHAINS_ACCOUNT = "sysio.chains"_n; - /// The `sysio` account holds the WIRE emissions treasury; bootstrap - /// `regreserve` drains the reserve's WIRE backing from it. - static constexpr name TREASURY_ACCOUNT = "sysio"_n; - - // Bancor connector_weight is stored in basis points (10000 = 100%) and is - // the WIRE-side weight of the token/WIRE pool; the token side gets the - // remainder. weight = 5000 is the symmetric 50/50 case (pure constant - // product). The weighted swap curve lives in `sysio.opp.common/amm_math.hpp`. - // - // Max is 9999, not 10000: both pool-side weights must be positive. At cw = 10000 the - // token-side weight (10000 - cw) is 0, so amm_math::out_given_in returns 0 for every - // token<->WIRE swap — a permanently unswappable (dead) reserve. Capping below the total - // keeps the token side >= 1 bps. - static constexpr uint32_t MAX_CONNECTOR_WEIGHT_BPS = 9999; - static constexpr uint32_t DEFAULT_CONNECTOR_WEIGHT_BPS = 5000; - - // The WIRE token's decimal precision — the depot frame's reference side, - // and the cap for any source-token-side depot precision. WIRE custody is - // an `asset{"WIRE", 9}`. The WIRE/target side is always this value, so it - // is never carried per-reserve (only `source_token_precision` is stored); - // this constant is the write/validation precision for every reserve. - static constexpr uint32_t WIRE_PRECISION = 9; - - // Swap-fee split, stage 1. Every swap charges sysio.uwrit's `fee_bps` out - // of the WIRE leg; this contract gives half of the collected fee to the - // swap's WINNING UNDERWRITER (the party whose collateral lock let the swap - // settle) and puts the other half into a REWARDS POOL. The fee RATE - // (`fee_bps`) is owned by sysio.uwrit. - static constexpr uint32_t FEE_UNDERWRITER_SHARE_BPS = 5000; // 50% underwriter / 50% rewards pool - static constexpr uint32_t FEE_SPLIT_TOTAL_BPS = 10000; - - // Swap-fee split, stage 2 — the rewards pool's own division, GOVERNANCE - // CONFIGURABLE (`reserve_config.fee_emissions_share_bps`). This share of - // the pool is transferred to the `sysio` emissions treasury; the remainder - // accrues to `rewards_bucket` for batch operators via - // `sysio.system::payepoch`. - // - // Nothing seeds the `reservcfg` row: bootstrap does not write it, and every - // read goes through `get_or_default(reserve_config{})`, so the dial reads - // as zero until the self-authorized `setconfig` first persists a row. - // - // The DEFAULT IS ZERO: the whole pool is allocated to the batch-operator - // distribution and no part of a swap fee leaves this contract's custody at - // settlement. (`payepoch` then pays only eligible shares — see - // `drainrewards`.) A non-zero share re-opens a treasury inflow without - // touching the underwriter half. - static constexpr uint32_t DEFAULT_FEE_EMISSIONS_SHARE_BPS = 0; - - // Reserve OWNER fee bounds (WIRE-281). A reserve's owner fee is a second, - // independent fee on the WIRE leg — it is NOT a share of the network fee. - // - // 0 means "this reserve charges nothing" and is always legal (every - // reserve starts there, and an owner-less bootstrap reserve can never be - // anything else — `setrsvfee` requires the owner's authority). A reserve - // that DOES charge must land in [MIN_OWNER_FEE_BPS, MAX_OWNER_FEE_BPS]: - // below the floor the fee floors to zero on ordinary amounts and is just a - // misconfiguration, and the 99% ceiling keeps a positive remainder on the - // leg (Jonathan, 2026-08-04). - static constexpr uint32_t MIN_OWNER_FEE_BPS = 1; // 0.01% - static constexpr uint32_t MAX_OWNER_FEE_BPS = 9900; // 99% - - // Claimable-WIRE retention. `refundwire` / `paywire` credit an unbounded, caller-influenced - // set of accounts, and the rows bill to the sysio RAM pool, so an abandoned dust row would - // otherwise occupy system-paid RAM forever. A row not pulled within this window is swept and - // its balance returns to the emissions treasury. - // - // One year is deliberately far longer than any plausible claim latency: forfeiture is a RAM - // backstop, not an economic lever. (Contrast `sysio.dclaim::claim_window_sec`, which is - // governance-tunable because staker windows ARE policy.) Promote this to config if that ever - // changes; a constant keeps the swap-settlement surface unchanged in the meantime. - static constexpr uint32_t WIRE_CLAIM_WINDOW_SEC = 365 * 24 * 60 * 60; - - // Rows swept per credit. Bounds the on-write retention sweep so a settlement action reached - // from `sysio.epoch::advance` (via `drainfwq`) stays inside its CPU deadline, the same shape - // `sysio.opreg::prune_dellog` uses. - static constexpr uint32_t MAX_CLAIM_SWEEP_PER_CREDIT = 4; - - // Rows swept per epoch by `sweepclaims`, which `sysio.epoch::advance` inlines. Larger than - // the per-credit budget because this is the trigger that has to actually drain a backlog: - // the on-write sweep only fires while settlement traffic arrives, so if swaps stop, this is - // the only thing that revisits an aged-out row. Sized like the other advance-inlined bounds - // (`MAX_LOCK_RELEASE_PER_EPOCH`, `MAX_WTDW_FLUSH_PER_EPOCH`) to stay well inside advance's - // hard, uncatchable CPU deadline; an oversized backlog simply drains across later epochs. - static constexpr uint32_t MAX_CLAIM_SWEEP_PER_EPOCH = 32; - - // ----------------------------------------------------------------------- - // Actions - // ----------------------------------------------------------------------- - - /// Bootstrap-window only. Insert a reserve row directly with - /// `status=ACTIVE`. Priv-gated; rejects when `current_epoch_index > 0`. - /// - /// Custody: drains `initial_wire_amount` real WIRE from the `sysio` - /// emissions treasury into this contract's balance so the row's - /// `reserve_wire_amount` is backed from the moment it is usable. - /// `is_private` / `owner` let the bootstrap seed privately-owned - /// reserves directly (post-bootstrap creation derives ownership from - /// the authex-linked matcher instead). - [[sysio::action]] - void regreserve(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code, - std::string name, - std::string description, - uint64_t initial_chain_amount, - uint64_t initial_wire_amount, - uint32_t source_token_precision, - uint32_t connector_weight_bps, - bool is_private, - sysio::name owner); - - /// Dispatched by sysio.msgch when a RESERVE_CREATE attestation arrives. - /// Inserts a row with `status=PENDING`. **NEVER throws** — - /// per feedback_opp_handlers_never_throw: duplicate / malformed records - /// are silently logged + skipped. - /// - /// `external_token_amount` arrives in the depot's canonical 9-decimal - /// frame — msgch unpacks it from the attestation's `ReserveAmount` - /// carrier; the outpost converted its chain-native escrow units at the - /// boundary (EVM `PrecisionLib.toDepot`, SVM `precision::to_depot`) - /// exactly like the swap paths. - /// - /// Create gating: the creator must be `authex`-linked to a WIRE - /// account. The creator's pubkey (EVM: `creator_pub_key`, 33-byte - /// compressed, outpost-verified against msg.sender; SVM: - /// `creator_chain_addr` already IS the 32-byte ed25519 key) is - /// resolved against `sysio.authex::links.bypubkey`. No link → the - /// request is rejected by queueing RESERVE_CREATE_CANCELLED back to - /// the outpost (which refunds the creator) and NO row is inserted. - [[sysio::action]] - void oncrtreserve(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code, - std::string name, - std::string description, - uint64_t external_token_amount, - uint64_t requested_wire_amount, - uint32_t source_token_precision, - uint32_t connector_weight_bps, - opp::types::ChainKind creator_chain_kind, - std::vector creator_chain_addr, - bool is_private, - std::vector creator_pub_key); - - /// Auth = matcher. Takes REAL WIRE custody of `wire_amount` (must - /// exactly equal `reserve.requested_wire_amount`) via an inline - /// `sysio.token::transfer(matcher → sysio.reserv)`, flips status to - /// ACTIVE, records `owner = matcher`, queues RESERVE_READY outbound. - /// - /// Match gating: `matcher` MUST be the WIRE account `authex`-linked - /// to the reserve's creator — `links.bynamechain[(matcher, chain - /// kind)]`'s pubkey bytes must equal the creator pubkey recorded at - /// create time. The match IS the WIRE deposit; the matcher becomes - /// the reserve's owner (the ownership a private reserve's swap - /// gating compares against). - [[sysio::action]] - void matchreserve(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code, - name matcher, - uint64_t wire_amount); - - /// Dispatched by sysio.msgch when a RESERVE_CREATE_CANCEL attestation - /// arrives. If `status==PENDING`, flip to CANCELLED + queue - /// RESERVE_CREATE_CANCELLED. Else: silent no-op (match won the race). - /// **NEVER throws.** - [[sysio::action]] - void oncnclrsv(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code, - opp::types::ChainKind creator_chain_kind, - std::vector creator_chain_addr); - - /// Read-only swap quote. Prices `from_amount` of the source reserve into - /// the destination reserve along the depot's live curve — the SAME - /// weighted-Bancor math (each reserve's `connector_weight_bps`) and the - /// SAME post-fee reduction that settlement uses, so the quote equals what a - /// swap would deliver. - /// - /// The fee priced here is the network fee (`sysio.uwrit::fee_bps`) PLUS - /// every participating non-WIRE leg's reserve `owner_fee_bps` — both sides - /// on a chain-to-chain swap, one side against a WIRE endpoint (a WIRE - /// endpoint has no reserve and charges no owner fee). Callers drive - /// ingestion and race-time variance checks off this value, so it must - /// account for every rate settlement will charge; a quote that priced only - /// `fee_bps` would drift from the books by each owner fee. - /// Handles WIRE endpoints: a WIRE source/destination skips that leg's - /// reserve (the depot IS the WIRE side). Returns 0 when a required reserve - /// is missing or not ACTIVE (callers treat 0 as "no quote"). - [[sysio::action, sysio::read_only]] - uint64_t swapquote(sysio::slug_name from_chain_code, - sysio::slug_name from_token_code, - sysio::slug_name from_reserve_code, - uint64_t from_amount, - sysio::slug_name to_chain_code, - sysio::slug_name to_token_code, - sysio::slug_name to_reserve_code); - - /// Read-only: current rewards-bucket WIRE balance (the batch-operator share - /// of collected swap fees, held in this contract's custody until - /// `drainrewards` sweeps it to the emissions treasury for distribution to - /// batch operators). - [[sysio::action, sysio::read_only]] - uint64_t rewardbal(); - - /// Auth = `sysio` (the emissions treasury / system account). Sweep `amount` - /// WIRE of accrued swap-fee rewards out of this contract's custody to the - /// `sysio` treasury, where `sysio.system::payepoch` allocates it EXCLUSIVELY - /// to the batch-operator distribution, on top of their emission share. - /// Producers are not paid out of swap fees — the fee compensates the parties - /// that carry an individual swap (the winning underwriter, whose share never - /// enters this bucket, and the batch operators that relay it). Allocated is - /// not the same as paid: payepoch pays only ELIGIBLE shares, and whatever it - /// skips (no groups at all, an EMPTY group holding positive epochs, - /// non-ACTIVE members, or the remainders of its two integer divisions) stays - /// in the treasury. Called - /// inline by payepoch with the amount it read from `rewardbal()`, so the - /// swept WIRE lands in the treasury before payepoch's payout transfers - /// execute (inline actions run depth-first, drain queued before payouts). - /// - /// Decrements `rewards_bucket.balance` by `amount`; `lifetime_accrued` - /// (an audit total) is left untouched. A non-positive `amount` is REJECTED - /// (throws — an internal sweep asking for <= 0 is a caller bug, not a - /// no-op), as is an `amount` exceeding the live balance. - [[sysio::action]] - void drainrewards(int64_t amount); - - /// Auth=sysio.uwrit. Inline-debit at SWAP_REMIT emit time. Asserts the - /// reserve is ACTIVE and balance is sufficient. - [[sysio::action]] - void debit(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code, - uint64_t amount); - - // onreject was removed: no SwapRejected attestation exists — every - // depot-initiated REMIT is paid by the destination outpost (no rejection, - // so no reserve-ledger reconciliation is needed). - - // onreward was removed: the STAKING_REWARD path credits the per-staker - // reward to sysio.dclaim directly (already WIRE-denominated), so there is - // no reserve leg and no reserve-side reward crediting. - - /// Auth=sysio.uwrit. Emit-time apply for a normal (outpost ↔ outpost) - /// swap, fired from `try_select_winner` BEFORE the SWAP_REMIT is queued so - /// every intervening quote prices post-swap. Computes the GROSS WIRE - /// intermediate internally from the pre-mutation source row on the weighted - /// curve (the source reserve's own `connector_weight_bps`) — - /// `w_gross = amm::token_to_wire(src.chain, src.wire, src.cw, src_amount)` - /// — takes the swap fee out of that WIRE leg, then: - /// src: chain += src_amount, wire -= w_gross - /// dst: wire += w_net, chain -= dst_amount (w_net = w_gross - fee) - /// `fee` is the TOTAL taken off that leg: BOTH reserves' `owner_fee_bps` - /// (each accrued to its own reserve row's `owner_fee_accrued`) PLUS the - /// network fee. Only the NETWORK component is split 50/50 to - /// `underwriter`'s claimable accrual / the rewards pool — and any - /// configured `fee_emissions_share_bps` of that pool is TRANSFERRED to the - /// `sysio` treasury rather than accruing to `rewards_bucket`. - /// Balances are checked BEFORE any mutation; a failed check - /// aborts the surrounding race-resolution transaction (no half-state). `Σ - /// reserve_wire_amount` drops by the whole fee; all of it stays in this - /// contract's custody as the three accruals EXCEPT that emissions share, - /// which is the only part that leaves. - /// - /// `underwriter` is the uwreq's winning underwriter, forwarded by - /// `sysio.uwrit::try_select_winner`. - [[sysio::action]] - void applyswap(sysio::slug_name src_chain_code, - sysio::slug_name src_token_code, - sysio::slug_name src_reserve_code, - uint64_t src_amount, - sysio::slug_name dst_chain_code, - sysio::slug_name dst_token_code, - sysio::slug_name dst_reserve_code, - uint64_t dst_amount, - sysio::name underwriter); - - /// Auth=sysio.uwrit. Emit-time apply for a swap-FROM-WIRE (the depot - /// is the source; only the target outpost leg exists). The user's - /// escrowed WIRE (already held by this contract since `swapfromwire`) has - /// the swap fee taken out of it; only the post-fee remainder becomes the - /// target reserve's WIRE-side liquidity: - /// dst: wire += w_net, chain -= dst_amount (w_net = wire_in - fee) - /// `fee` is the TOTAL taken off the leg: the DESTINATION reserve's - /// `owner_fee_bps` (accrued to that row; there is no source reserve) PLUS - /// the network fee. Only the NETWORK component is split 50/50 to - /// `underwriter`'s claimable accrual / the rewards pool, and any configured - /// `fee_emissions_share_bps` of that pool is TRANSFERRED to the `sysio` - /// treasury. The escrowed `wire_in` splits into the new liquidity plus the - /// routed fee; every part stays in custody except that emissions share, so - /// custody balances once it is accounted for. - /// - /// `underwriter` is the uwreq's winning underwriter, forwarded by - /// `sysio.uwrit::try_select_winner`. - [[sysio::action]] - void applyfromwire(sysio::slug_name dst_chain_code, - sysio::slug_name dst_token_code, - sysio::slug_name dst_reserve_code, - uint64_t wire_in, - uint64_t dst_amount, - sysio::name underwriter); - - /// Auth=sysio.uwrit. Settlement for a swap-TO-WIRE (the depot is the - /// target; only the source outpost leg exists). Pays the recipient exactly - /// `wire_out` REAL WIRE from custody, and charges the swap fee on the gross - /// weighted WIRE leg the source produces: - /// src: chain += src_amount, wire -= (wire_out + fee) - /// credit `wire_out` to the recipient's `wireclaims` row (pulled via `claimwire`) - /// `fee` is the TOTAL taken off the leg: the SOURCE reserve's - /// `owner_fee_bps` (accrued to that row; the recipient is paid in WIRE, so - /// there is no destination reserve) PLUS the network fee. Only the NETWORK - /// component is split 50/50 to `underwriter`'s claimable accrual / the - /// rewards pool, and any configured `fee_emissions_share_bps` of that pool - /// is TRANSFERRED to the `sysio` treasury. The source reserve keeps any - /// surplus when the user targeted below the post-fee quote. `Σ - /// reserve_wire_amount` drops by `wire_out + fee`; what leaves custody is - /// that emissions share alone — `wire_out` stays in custody as a claim. - /// - /// `underwriter` is the uwreq's winning underwriter, forwarded by - /// `sysio.uwrit::try_select_winner`. - /// - /// The payout is CREDITED, not transferred: this action is inlined from - /// `sysio.uwrit::try_select_winner` inside the never-throw - /// `deliver -> evalcons -> dispatch` chain, and `sysio.token::transfer` - /// notifies `recipient`. A recipient whose notify handler asserts (or burns - /// CPU) would otherwise abort the consensus-tipping delivery and stall - /// dispatch. The WIRE stays in this contract's custody until claimed. - [[sysio::action]] - void paywire(sysio::slug_name src_chain_code, - sysio::slug_name src_token_code, - sysio::slug_name src_reserve_code, - uint64_t src_amount, - sysio::name recipient, - uint64_t wire_out, - sysio::name underwriter); - - /// Auth=sysio.uwrit. Refund escrowed WIRE to a swap-FROM-WIRE user - /// whose queued request failed drain-time validation (reserve - /// missing / not ACTIVE / private / variance drift) or whose uwreq was - /// rejected. Touches no reserve row — the escrow was never credited to - /// any `reserve_wire_amount`. - /// - /// `revert_fee_bps` is the caller-fault revert fee: the recipient gets - /// `wire_amount` minus the fee, and the fee routes through the standard - /// `route_wire_fee` path. A revert has no winning underwriter — nobody - /// locked collateral for a swap that never settled — so the underwriter - /// share is zero and the WHOLE revert fee becomes the rewards POOL. That - /// pool is then split by `fee_emissions_share_bps` exactly like a - /// settlement fee's: under the default zero dial the entire revert fee - /// accrues to `rewards_bucket`, and with a configured dial that share is - /// transferred to the `sysio` emissions treasury instead. Pass 0 for - /// no-fault refunds (whole fee path no-ops). Callers keep it below 100% - /// (`sysio.uwrit::MAX_FEE_BPS`) so the post-fee refund stays positive. - /// - /// The refund is CREDITED to the recipient's `wireclaims` row, not - /// transferred. This action is inlined from the never-throw - /// `sysio.uwrit::drainfwq` drain, which itself runs inline from - /// `sysio.epoch::advance`; a pushed transfer notifies `recipient`, so a - /// hostile notify handler would abort `advance` and halt epoch - /// advancement chain-wide. Worse, the queue row erase would roll back - /// with it, making the block permanent and retried every epoch. - [[sysio::action]] - void refundwire(sysio::name recipient, - uint64_t wire_amount, - uint32_t revert_fee_bps); - - /// Auth = the claiming account. Pull the caller's credited WIRE - /// (swap-to-WIRE payouts and swap-from-WIRE refunds) out of this - /// contract's custody in a single transfer. - /// - /// This is the ONLY place a `wireclaims` balance becomes a transfer to the - /// claimant, and it carries the claimant's own authority — so a recipient - /// whose transfer-notify handler aborts blocks nothing but its own claim. - /// Throws when there is nothing to claim, which reaches only the caller. - /// - /// Also refuses a row past `expires_at_sec`. The sweep is bounded and - /// best-effort, so an expired row can wait many epochs for its turn; - /// without this check the retention deadline would mean "swept eventually" - /// rather than "claimable until", and a forfeit balance would still pay - /// out in the meantime. - [[sysio::action]] - void claimwire(sysio::name account); - - /// Auth = `sysio.epoch` or self. Erase up to `max_rows` `wireclaims` rows - /// whose one-year retention window has closed and push their total to the - /// emissions treasury. - /// - /// `sysio.epoch::advance` inlines this every epoch with - /// `MAX_CLAIM_SWEEP_PER_EPOCH`. That is what makes the retention deadline - /// real: `credit_wire_claim` also sweeps, but only as a side effect of a - /// later credit, so with settlement traffic stopped no action would ever - /// revisit an aged-out row — leaving an unbounded, system-funded table and - /// the WIRE it reserves outstanding indefinitely. - /// - /// Bounded and never-throwing past the auth gate for the usual reason: it - /// runs inline inside `advance`, where an abort stalls epoch progress - /// chain-wide. An oversized backlog drains across later epochs. - [[sysio::action]] - void sweepclaims(uint32_t max_rows); - - /// Auth = self (`sysio.reserv`). Set the contract's fee-routing config. - /// - /// * `fee_emissions_share_bps` — the share of each fee's REWARDS POOL - /// (the half left after the winning underwriter's cut) transferred to - /// the `sysio` emissions treasury; the remainder accrues to - /// `rewards_bucket` for batch operators. 0 (the default) allocates the - /// whole pool to the batch-operator distribution and keeps every fee - /// inside this contract's custody at settlement. Capped at - /// `FEE_SPLIT_TOTAL_BPS`. - [[sysio::action]] - void setconfig(uint32_t fee_emissions_share_bps); - - /// Auth = the reserve's `owner`. Set this reserve's owner fee — the - /// independent, per-reserve fee its liquidity earns on every swap that - /// draws from it (WIRE-281). `owner_fee_bps` is either 0 (charge nothing) - /// or in `[MIN_OWNER_FEE_BPS, MAX_OWNER_FEE_BPS]`. - /// - /// Requires an ACTIVE reserve with a resolved `owner`: a bootstrap-seeded - /// public reserve has no owner, so no account can authorize a fee on it - /// and none can be stranded with no claimant. Re-callable — the fee is a - /// live parameter, not a create-time constant. - [[sysio::action]] - void setrsvfee(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code, - uint32_t owner_fee_bps); - - /// Read-only: one reserve's unclaimed owner-fee WIRE balance, held in this - /// contract's custody until `claimrsvfee`. Zero for a reserve that has - /// never charged. - [[sysio::action, sysio::read_only]] - uint64_t rsvfeebal(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code); - - /// Auth = the reserve's `owner`. Pay out this reserve's entire accrued - /// owner fee as REAL WIRE from custody and zero the accrual; - /// `owner_fee_lifetime` (an audit total) is untouched. - /// - /// Owner-authenticated and self-serve, exactly like `claimuwfee` — the - /// depot never pushes these payouts. Throws when the reserve is missing, - /// has no owner, or has nothing accrued: a claim with nothing to pay is a - /// caller mistake, not a silent no-op. - [[sysio::action]] - void claimrsvfee(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code); - - /// Read-only: `underwriter`'s unclaimed swap-fee WIRE balance (the - /// underwriter half of every fee their winning commits settled), held in - /// this contract's custody until `claimuwfee`. Zero for an underwriter - /// with no accrual row. - [[sysio::action, sysio::read_only]] - uint64_t uwfeebal(sysio::name underwriter); - - /// Auth = `underwriter` (the earner). Pay out the caller's entire accrued - /// swap-fee balance as REAL WIRE from this contract's custody and zero the - /// accrual. `lifetime_claimed` (an audit total) accumulates instead. - /// - /// Owner-authenticated and self-serve — the depot never pushes these - /// payouts, so an underwriter claims on their own schedule and a dormant - /// underwriter costs the chain no per-epoch inline transfers. Throws when - /// the caller has no accrual row or a zero balance: a claim with nothing to - /// pay is a caller mistake, not a silent no-op. - [[sysio::action]] - void claimuwfee(sysio::name underwriter); - - // ----------------------------------------------------------------------- - // Tables - // ----------------------------------------------------------------------- - - /// Triple-slug_name primary key. Composite encoded as - /// `checksum256(chain_code || token_code || reserve_code)`. - struct reserve_key { - sysio::slug_name chain_code; - sysio::slug_name token_code; - sysio::slug_name reserve_code; - checksum256 primary_key() const { - std::array buf{}; - std::memcpy(buf.data() + 0, &chain_code.value, 8); - std::memcpy(buf.data() + 8, &token_code.value, 8); - std::memcpy(buf.data() + 16, &reserve_code.value, 8); - return sysio::sha256(reinterpret_cast(buf.data()), buf.size()); - } - SYSLIB_SERIALIZE(reserve_key, (chain_code)(token_code)(reserve_code)) - }; - - struct [[sysio::table("reserves")]] reserve_row { - sysio::slug_name chain_code; - sysio::slug_name token_code; - sysio::slug_name reserve_code; - std::string name; - std::string description; - opp::types::ReserveStatus status = opp::types::RESERVE_STATUS_UNKNOWN; - uint64_t reserve_chain_amount = 0; - uint64_t reserve_wire_amount = 0; - /// Depot-frame decimal precision of the source token side, recorded at - /// creation so the reserve is self-describing — nothing assumes a - /// precision. `source_token_precision = min(token native precision, 9)` - /// (the outpost downscales anything above the 9-dec frame cap at its - /// boundary). The WIRE/target side is always `WIRE_PRECISION` (9), so it - /// is not carried (there is no target_token_precision). The AMM curve is - /// precision-homogeneous, so this does NOT enter swap math — it exists - /// for unambiguous amount interpretation off the curve. - uint32_t source_token_precision = WIRE_PRECISION; - uint32_t connector_weight_bps = DEFAULT_CONNECTOR_WEIGHT_BPS; - opp::types::ChainAddress creator_addr; - uint64_t requested_wire_amount = 0; - uint64_t external_token_amount = 0; - uint64_t registered_at_ms = 0; - uint64_t activated_at_ms = 0; - uint64_t cancelled_at_ms = 0; - /// Private reserves only swap against counterpart reserves owned - /// by the same WIRE account (and are excluded from WIRE-endpoint - /// swaps entirely). Immutable after create. - bool is_private = false; - /// The WIRE account that matched (and therefore owns) this - /// reserve — the authex-linked account of the creator. Empty for - /// bootstrap-seeded public reserves unless `regreserve` named one. - sysio::name owner; - /// Canonical raw pubkey bytes of the creator (33-byte compressed - /// secp256k1 for EVM, 32-byte ed25519 for SVM), normalized at - /// create time. `matchreserve` compares the matcher's authex-link - /// key against this. - std::vector creator_pub_key; - - /// The owner's fee on this reserve's WIRE leg, in basis points — the - /// reserve's own revenue, INDEPENDENT of the network `fee_bps`. Every - /// swap that draws liquidity from this reserve pays it, so a - /// chain-to-chain swap between two fee-charging reserves pays both plus - /// the network fee. Set by the owner via `setrsvfee`; `0` (the default) - /// means the reserve charges nothing. See `MAX_OWNER_FEE_BPS`. - uint32_t owner_fee_bps = 0; - /// Unclaimed WIRE this reserve has earned from `owner_fee_bps`, held in - /// this contract's custody until the owner calls `claimrsvfee`. Part of - /// the custody invariant documented on `rewards_bucket`. - uint64_t owner_fee_accrued = 0; - /// Audit total: WIRE this reserve has earned from `owner_fee_bps`. - /// Monotonic — never decremented by a claim. - /// - /// SATURATES at `UINT64_MAX` (~18.45e9 WIRE at 9dp) rather than - /// wrapping. Unlike `owner_fee_accrued` — a balance, bounded by what is - /// actually in custody — this is an unbounded running total, so the - /// ceiling is reachable in principle: it needs roughly 370 turnovers of - /// the entire launch supply through THIS ONE reserve at a 5% owner fee - /// (~19 at the 99% maximum). Past that the counter stops advancing - /// while accrual and claims continue to work normally; only the audit - /// history is truncated, never a balance. - uint64_t owner_fee_lifetime = 0; - - uint128_t by_chain_token() const { - return (static_cast(chain_code.value) << 64) | token_code.value; - } - uint64_t by_status() const { return magic_enum::enum_integer(status); } - - SYSLIB_SERIALIZE(reserve_row, - (chain_code)(token_code)(reserve_code)(name)(description) - (status)(reserve_chain_amount)(reserve_wire_amount) - (source_token_precision)(connector_weight_bps) - (creator_addr)(requested_wire_amount)(external_token_amount) - (registered_at_ms)(activated_at_ms)(cancelled_at_ms) - (is_private)(owner)(creator_pub_key) - (owner_fee_bps)(owner_fee_accrued)(owner_fee_lifetime)) - }; - - using reserves_t = sysio::kv::table<"reserves"_n, reserve_key, reserve_row, - sysio::kv::index<"bychaintok"_n, sysio::const_mem_fun>, - sysio::kv::index<"bystatus"_n, sysio::const_mem_fun> - >; - - /// Singleton accumulator for the rewards (batch-operator) half of swap - /// fees. The WIRE stays in this contract's custody — it is NOT transferred - /// out — so the custody invariant is `token_balance == Σ - /// reserve_wire_amount + rewards.balance + Σ uw_fee_row.balance + - /// Σ reserve_row.owner_fee_accrued + in-flight escrow + - /// Σ wireclaims.balance`. A collected owner fee leaves - /// `reserve_wire_amount` but stays in this contract until `claimrsvfee`, - /// so it is a term in the equation, not an outflow; the last term likewise - /// covers settled-but-unclaimed payouts and refunds, whose WIRE stays in - /// custody until the recipient pulls it. `balance` is the portion - /// earmarked for distribution (swept by `drainrewards` and folded into - /// `sysio.system::payepoch`); `lifetime_accrued` is an audit total. (The - /// underwriter half of each fee accrues to `uwfees` instead and is - /// therefore not tracked here.) - /// - /// NOTE: sysio.system reads this row through a layout-compatible local - /// definition (a `[[sysio::table]]`-attributed struct cannot be shared - /// into sysio.system's translation unit — it corrupts that contract's - /// read-only-action return codegen). The cross-contract read is exercised - /// end-to-end by t5_emissions_tests/payepoch_folds_swap_fee_rewards, which - /// fails if the two layouts ever diverge. - struct [[sysio::table("rewardbkt")]] rewards_bucket { - uint64_t balance = 0; // claimable WIRE held for distribution - uint64_t lifetime_accrued = 0; // audit: total WIRE ever routed to rewards - SYSLIB_SERIALIZE(rewards_bucket, (balance)(lifetime_accrued)) - }; - using rewardbkt_t = sysio::kv::global<"rewardbkt"_n, rewards_bucket>; - - /// Claimable WIRE owed to an account by `paywire` (swap-to-WIRE settlement) or - /// `refundwire` (swap-from-WIRE revert). Both credit here instead of transferring, because - /// both are reached from never-throw paths where a recipient's transfer-notify handler could - /// otherwise abort the enclosing transaction — stalling consensus dispatch in `paywire`'s - /// case and halting epoch advancement chain-wide in `refundwire`'s. - /// - /// The backing WIRE never leaves this contract's custody at credit time, so it is a term of - /// the custody invariant above. - struct wireclaim_key { - uint64_t account; - SYSLIB_SERIALIZE(wireclaim_key, (account)) - }; - - struct [[sysio::table("wireclaims")]] wire_claim { - sysio::name account; - uint64_t balance = 0; // atomic WIRE units owed, not yet claimed - uint32_t expires_at_sec = 0; // swept back to the treasury once past - - /// Expiry-major composite so the secondary index orders by expiry and the retention sweep - /// can stop at the first live row. The account tail only breaks ties, keeping the key - /// unique when many rows share an expiry second. - uint128_t by_expiry() const { - return (static_cast(expires_at_sec) << 64) | account.value; - } - - SYSLIB_SERIALIZE(wire_claim, (account)(balance)(expires_at_sec)) - }; - - using wireclaims_t = sysio::kv::table<"wireclaims"_n, wireclaim_key, wire_claim, - sysio::kv::index<"byexpiry"_n, - sysio::const_mem_fun> - >; - - /// Key for `uwfees` — one row per earning underwriter account. - struct uw_fee_key { - sysio::name underwriter; - uint64_t primary_key() const { return underwriter.value; } - SYSLIB_SERIALIZE(uw_fee_key, (underwriter)) - }; - - /// Per-underwriter accrual of the underwriter half of swap fees. Credited - /// by `route_wire_fee` at settlement (`applyswap` / `applyfromwire` / - /// `paywire`) to the uwreq's winning underwriter, and drained by the - /// owner-authenticated `claimuwfee`. The WIRE never leaves this contract's - /// custody until a claim, so these balances are part of the custody - /// invariant documented on `rewards_bucket`. - /// - /// `balance` is unclaimed WIRE. `lifetime_accrued` / `lifetime_claimed` - /// are monotonic audit totals; a row is created on first accrual and - /// RETAINED at zero balance after a claim so the audit trail survives. - /// The two `lifetime_*` counters SATURATE at `UINT64_MAX` (~18.45e9 WIRE - /// at 9dp) rather than wrapping — see `reserve_row::owner_fee_lifetime` - /// for the reachability arithmetic. They are unbounded running totals, so - /// unlike `balance` (bounded by custody) the ceiling is reachable in - /// principle; past it the audit history is truncated while accrual and - /// `claimuwfee` continue to work normally. - struct [[sysio::table("uwfees")]] uw_fee_row { - sysio::name underwriter; - uint64_t balance = 0; // unclaimed WIRE held in custody - uint64_t lifetime_accrued = 0; // audit: WIRE accrued (saturating) - uint64_t lifetime_claimed = 0; // audit: WIRE paid out (saturating) - SYSLIB_SERIALIZE(uw_fee_row, (underwriter)(balance)(lifetime_accrued)(lifetime_claimed)) - }; - using uwfees_t = sysio::kv::table<"uwfees"_n, uw_fee_key, uw_fee_row>; - - /// Fee-routing configuration singleton. Holds only the governance dial - /// that stage 2 of the fee split consults — the stage-1 underwriter share - /// is the fixed `FEE_UNDERWRITER_SHARE_BPS` constant, and the fee RATE - /// itself lives on `sysio.uwrit::uwconfig`. - struct [[sysio::table("reservcfg")]] reserve_config { - /// Share of each fee's rewards pool routed to the `sysio` emissions - /// treasury; the remainder goes to `rewards_bucket`. Default 0. - uint32_t fee_emissions_share_bps = DEFAULT_FEE_EMISSIONS_SHARE_BPS; - SYSLIB_SERIALIZE(reserve_config, (fee_emissions_share_bps)) - }; - using reservcfg_t = sysio::kv::global<"reservcfg"_n, reserve_config>; - - private: - using ReserveStatus = opp::types::ReserveStatus; - using ChainKind = opp::types::ChainKind; - }; - -} // namespace sysio diff --git a/contracts/sysio.reserv/src/sysio.reserv.cpp b/contracts/sysio.reserv/src/sysio.reserv.cpp deleted file mode 100644 index 9efcdcbe85..0000000000 --- a/contracts/sysio.reserv/src/sysio.reserv.cpp +++ /dev/null @@ -1,1268 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include -#include -#include - -namespace sysio { - -namespace { - -// System-owned rows bill to the sysio RAM pool, not this contract account (privileged-contract -// model, as sysio.token uses): the account stays finite at code+abi size; growth draws from the pool. -constexpr name ram_payer = "sysio"_n; - -/// Canonical raw address widths for the creator-address domains accepted by -/// reserve creation. -constexpr std::size_t evm_creator_address_bytes = 20; -constexpr std::size_t svm_creator_address_bytes = 32; - -uint64_t current_time_ms() { - return static_cast(current_time_point().sec_since_epoch()) * 1000; -} - -uint32_t get_current_epoch_index() { - sysio::epoch::epochstate_t es(reserve::EPOCH_ACCOUNT); - if (!es.exists()) return 0; - return es.get().current_epoch_index; -} - -bool is_bootstrap_window() { - return get_current_epoch_index() == 0; -} - -/// Saturating uint64 credit for reserve balances / rewards-bucket counters. These accumulate from -/// operator-relayed external-chain amounts (no on-chain supply cap), and the credit sites run -/// inside the consensus dispatch chain (applyswap / applyfromwire / paywire inline from -/// uwrit::try_select_winner). A raw `+=` could wrap the uint64 and corrupt the -/// weighted-AMM curve and the `>=` sufficiency checks; cap at UINT64_MAX instead — never wrap, -/// never throw on the consensus path. Delegates to the shared -/// `sysio::opp::safe::add_sat_u64` so the never-wrap rule lives in one place. -/// -/// The cap is unreachable for any real BALANCE — a balance is bounded by what is -/// actually in custody. That reasoning does NOT carry to the monotonic -/// `lifetime_*` audit counters this helper also credits: those are unbounded -/// running totals and CAN saturate, which is documented at each field -/// (`reserve_row::owner_fee_lifetime`, `uw_fee_row::lifetime_*`). Saturation -/// there truncates audit history only — never a balance, never a payout. -inline void add_capped_u64(uint64_t& balance, uint64_t amt) { - balance = sysio::opp::safe::add_sat_u64(balance, amt); -} - -/// Live per-spoke swap fee (basis points) — owned by `sysio.uwrit`. Read fresh -/// so the read-only quote and settlement charge one and the same rate. Falls -/// back to the `uw_config` default when uwrit has not been configured yet. -uint32_t uwrit_fee_bps() { - sysio::uwrit::uwconfig_t cfg(reserve::UWRIT_ACCOUNT); - return cfg.get_or_default(sysio::uwrit::uw_config{}).fee_bps; -} - -/// Stage-2 governance dial: the share of each fee's REWARDS POOL routed to the -/// `sysio` emissions treasury. Read fresh per settlement so a `setconfig` takes -/// effect immediately; defaults to 0 (the whole pool is allocated to the -/// batch-operator distribution and no fee leaves custody) until configured. -uint32_t fee_emissions_share_bps(name self) { - reserve::reservcfg_t cfg(self); - return cfg.get_or_default(reserve::reserve_config{}).fee_emissions_share_bps; -} - -/// Credit a reserve's owner-fee revenue — the claimable balance AND the -/// monotonic audit total — from inside an open `modify` lambda. No-op at zero, -/// so a fee-free reserve costs nothing. -void accrue_owner_fee(reserve::reserve_row& row, uint64_t amount) { - if (amount == 0) return; - add_capped_u64(row.owner_fee_accrued, amount); - add_capped_u64(row.owner_fee_lifetime, amount); -} - -reserve::reserve_key make_key(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code) { - return reserve::reserve_key{chain_code, token_code, reserve_code}; -} - -/// Resolve a `chain_code` to its `ChainKind` via the `sysio.chains` -/// registry (mirrors `sysio.uwrit`'s helper of the same name). Returns -/// `std::nullopt` when the chain is unregistered. -std::optional chain_kind_for_code(sysio::slug_name chain_code) { - sysio::chains::chains_t tbl(reserve::CHAINS_ACCOUNT); - sysio::chains::chain_key pk{chain_code}; - if (!tbl.contains(pk)) return std::nullopt; - return tbl.get(pk).kind; -} - -/// Resolve the authoritative kind while soft-gating never-throw msgch handlers -/// before they can emit a queueout to an unregistered destination chain. -/// `sysio.msgch::queueout` fails loudly for direct callers, but dispatch -/// callbacks must log-and-skip instead of aborting the consensus-tipping -/// delivery transaction. -std::optional -registered_chain_kind_or_skip(sysio::slug_name chain_code, const char* handler) { - auto kind = chain_kind_for_code(chain_code); - if (kind.has_value()) return kind; - sysio::print(handler, ": chain_code is not registered; skipping\n"); - return std::nullopt; -} - -/// Validate the raw creator address against the authoritative registry kind. -/// User-created reserves originate on supported external outposts only: EVM -/// addresses are 20 bytes and SVM addresses are 32 bytes. Unknown/depot kinds -/// are not valid creator-address domains for this handler. -bool creator_address_matches_kind(opp::types::ChainKind kind, - const std::vector& address) { - using opp::types::ChainKind; - switch (kind) { - case ChainKind::CHAIN_KIND_UNKNOWN: - case ChainKind::CHAIN_KIND_WIRE: - return false; - case ChainKind::CHAIN_KIND_EVM: - return address.size() == evm_creator_address_bytes; - case ChainKind::CHAIN_KIND_SVM: - return address.size() == svm_creator_address_bytes; - } - - // Fail closed for an invalid numeric enum value while leaving the switch - // exhaustive, so adding a declared ChainKind produces a compiler warning. - return false; -} - -/// Reconstruct a `sysio::public_key` variant from the raw creator-key -/// bytes carried on a ReserveCreate attestation. EVM chains require the -/// 33-byte compressed secp256k1 key in `creator_pub_key` (the EM variant, -/// index 3); SVM chains use the 32-byte ed25519 key (ED variant, index 4), -/// falling back to `creator_addr.address` (which on Solana IS the signer -/// pubkey) when `creator_pub_key` was left empty. Returns `std::nullopt` -/// on any size/kind mismatch — callers treat that as "creator not linkable" -/// (never throw; this runs inside the msgch dispatch chain). -std::optional pubkey_from_raw(opp::types::ChainKind kind, - const std::vector& pub_bytes, - const std::vector& addr_bytes) { - using opp::types::ChainKind; - if (kind == ChainKind::CHAIN_KIND_EVM) { - if (pub_bytes.size() != 33) return std::nullopt; - using em_t = std::variant_alternative_t<3, sysio::public_key>; - em_t em{}; - std::memcpy(em.data(), pub_bytes.data(), pub_bytes.size()); - return sysio::public_key{std::in_place_index<3>, em}; - } - if (kind == ChainKind::CHAIN_KIND_SVM) { - const std::vector& src = pub_bytes.size() == 32 ? pub_bytes : addr_bytes; - if (src.size() != 32) return std::nullopt; - using ed_t = std::variant_alternative_t<4, sysio::public_key>; - ed_t ed{}; - std::memcpy(ed.data(), src.data(), src.size()); - return sysio::public_key{std::in_place_index<4>, ed}; - } - return std::nullopt; -} - -/// Encode + queue a depot→outpost attestation back to the reserve's owning -/// chain. Mirrors `sysio.uwrit::emit_swap_remit` / `emit_swap_revert`: -/// -/// * `zpp::bits::no_size{}` — raw protobuf bytes for the outpost decoder -/// (the default `zpp::bits::data_out` form prepends a 4-byte LE length -/// prefix that corrupts the first field tag on the receiving side). -/// * The destination `chain_code` is the reserve's `chain_code.value` -/// itself (per the convention recorded in `sysio.msgch.hpp`: -/// "the outpost id IS the chain's slug_name value"). -template -void queue_attestation_out(name self, - sysio::slug_name owning_chain, - opp::types::AttestationType attest_type, - const ProtoMessage& message) { - std::vector encoded; - auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; - (void)out(message); - - action( - permission_level{self, "active"_n}, - reserve::MSGCH_ACCOUNT, "queueout"_n, - std::make_tuple(owning_chain.value, attest_type, encoded) - ).send(); -} - -/// Route the NETWORK COMPONENT of a collected WIRE swap fee to its three -/// destinations. The reserve-owner shares carried in `fee` are NOT routed here — -/// the settlement actions accrue those to their own reserve rows before calling -/// this, so this function only ever moves the network fee's own split: -/// * the underwriter share accrues to `underwriter`'s `uwfees` row, payable to -/// that account on its own `claimuwfee` call — stays in custody; -/// * the rewards share accrues to the singleton `rewards_bucket`, swept by -/// `drainrewards` into `sysio.system::payepoch` and paid to batch operators -/// — stays in custody; -/// * the emissions share (zero unless `reserve_config` sets one) is -/// TRANSFERRED to the `sysio` treasury — the only part that leaves custody. -/// No-op when there is no fee. -/// -/// At the default config the emissions share is 0, so everything THIS function -/// routes moves into two earmarked accumulators in the SAME custody and the call -/// never changes `token_balance`. With a non-zero share, custody drops by exactly -/// that share. Note the total `wire_fee::fee` is larger than what is routed here -/// whenever a participating reserve charges an owner fee: at the default dial the -/// whole fee ends up spread across the owner accrual(s) the caller already made, -/// `uwfees`, and `rewards_bucket` — not wholly into the two accumulators below. -/// -/// A fee with no winning underwriter (a revert refund) passes an unset -/// `underwriter` together with a zero underwriter share. Should a caller ever -/// pair an unset account with a non-zero share, the share falls through to the -/// rewards bucket rather than stranding WIRE in custody with no claimant. -void route_wire_fee(name self, const opp::amm::wire_fee& fee, name underwriter) { - uint64_t reward_share = fee.reward_share; - - if (fee.underwriter_share > 0 && underwriter.value != 0) { - reserve::uwfees_t uwf(self); - reserve::uw_fee_key key{underwriter}; - auto it = uwf.find(key); - if (it == uwf.end()) { - uwf.emplace(ram_payer, key, reserve::uw_fee_row{ - .underwriter = underwriter, - .balance = fee.underwriter_share, - .lifetime_accrued = fee.underwriter_share, - .lifetime_claimed = 0, - }); - } else { - uwf.modify(ram_payer, key, [&](auto& row) { - add_capped_u64(row.balance, fee.underwriter_share); - add_capped_u64(row.lifetime_accrued, fee.underwriter_share); - }); - } - } else { - reward_share += fee.underwriter_share; - } - - if (reward_share > 0) { - reserve::rewardbkt_t bkt(self); - auto rb = bkt.get_or_default(reserve::rewards_bucket{}); - add_capped_u64(rb.balance, reward_share); - add_capped_u64(rb.lifetime_accrued, reward_share); - bkt.set(rb, ram_payer); - } - - // The ONLY part of a fee that leaves this contract's custody, and only when - // governance has configured a non-zero emissions share. - if (fee.emissions_share > 0) { - action( - permission_level{self, "active"_n}, - reserve::TOKEN_ACCOUNT, "transfer"_n, - std::make_tuple(self, reserve::TREASURY_ACCOUNT, - asset(static_cast(fee.emissions_share), opp::wire::asset_symbol), - std::string("sysio.reserv::swap fee -> emissions")) - ).send(); - } -} - -/// Credit `amount` WIRE to `recipient`'s claimable row, then run the bounded retention sweep. -/// -/// Both callers (`paywire`, `refundwire`) sit on never-throw paths, so nothing here may abort: the -/// credit saturates rather than overflowing, and the sweep is bounded and non-throwing. -/// -/// Crediting instead of transferring is the whole point. `sysio.token::transfer` notifies the -/// recipient, and the chain runs notified receivers with no exception isolation, so a pushed payout -/// hands the recipient an abort switch over the enclosing transaction -- stalling consensus -/// dispatch from `paywire`, and halting epoch advancement chain-wide from `refundwire`. -/// -/// Reclaimed WIRE from expired rows is pushed to the emissions treasury. That push is safe for the -/// same reason `route_wire_fee`'s emissions share is: `sysio` is a system account under protocol -/// control, not a counterparty that can install a hostile notify handler. -/// THE retention sweep — one implementation behind both triggers, so the on-write path and the -/// epoch path can never drift on what "expired" means or where the reclaimed WIRE goes. -/// -/// Erases up to `max_rows` rows whose `expires_at_sec` has passed and pushes their total to the -/// emissions treasury. Never throws: both callers sit on never-throw paths (`credit_wire_claim` -/// runs under consensus dispatch and epoch advance; `sweepclaims` is inlined from `advance`). -/// -/// @return atomic WIRE units reclaimed. -uint64_t sweep_expired_wire_claims(name self, uint32_t now_sec, uint32_t max_rows) { - reserve::wireclaims_t claims(self); - auto by_expiry = claims.get_index<"byexpiry"_n>(); - const uint64_t reclaimed = sysio::opp::claimable::sweep_expired( - claims, by_expiry, - [](const reserve::wire_claim& r) { return reserve::wireclaim_key{r.account.value}; }, - now_sec, max_rows); - - if (reclaimed > 0) { - action( - permission_level{self, "active"_n}, - reserve::TOKEN_ACCOUNT, "transfer"_n, - std::make_tuple(self, reserve::TREASURY_ACCOUNT, - asset(static_cast(reclaimed), opp::wire::asset_symbol), - std::string("sysio.reserv::expired WIRE claim -> emissions")) - ).send(); - } - return reclaimed; -} - -void credit_wire_claim(name self, name recipient, uint64_t amount) { - if (amount == 0) return; - - const uint32_t now_sec = static_cast(current_time_point().sec_since_epoch()); - - reserve::wireclaims_t claims(self); - const auto pk = reserve::wireclaim_key{recipient.value}; - - // Settle an ALREADY-FORFEITED row before accumulating onto it. - // - // `credit` upserts: it adds to whatever balance is there and refreshes the stamp. If this - // recipient's row is past its window but still queued behind the bounded sweep, that would - // resurrect a balance `claimwire` has already been refusing -- the old amount becomes claimable - // again, and a trickle of small credits could keep a system-funded row (and its forfeited WIRE) - // alive indefinitely. Reclaiming first means a credit can only ever start a FRESH claim. - // - // Expiry exists so abandoned rows do not hold RAM forever; an account still being credited is - // not abandoned, so its new claim legitimately gets a full window. What that must not do is - // un-forfeit the balance the last window already closed on. - auto it = claims.find(pk); - if (it != claims.end() && it->expires_at_sec != 0 && now_sec >= it->expires_at_sec) { - const uint64_t forfeited = it->balance; - claims.erase(pk); - if (forfeited > 0) { - action( - permission_level{self, "active"_n}, - reserve::TOKEN_ACCOUNT, "transfer"_n, - std::make_tuple(self, reserve::TREASURY_ACCOUNT, - asset(static_cast(forfeited), opp::wire::asset_symbol), - std::string("sysio.reserv::expired WIRE claim -> emissions")) - ).send(); - } - } - - sysio::opp::claimable::credit( - claims, ram_payer, pk, - reserve::wire_claim{.account = recipient}, amount, - now_sec + reserve::WIRE_CLAIM_WINDOW_SEC); - - // On-write retention: sweep a bounded number of rows that aged out before adding more. Runs - // after the credit so the row just refreshed above is never the one collected. - // - // This is an OPPORTUNISTIC trigger, not the guarantee: it only fires while swap traffic keeps - // arriving. `sweepclaims`, inlined from `sysio.epoch::advance`, is what makes the retention - // deadline hold when settlement stops. - sweep_expired_wire_claims(self, now_sec, reserve::MAX_CLAIM_SWEEP_PER_CREDIT); -} - -} // namespace - -void reserve::regreserve(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code, - std::string name, - std::string description, - uint64_t initial_chain_amount, - uint64_t initial_wire_amount, - uint32_t source_token_precision, - uint32_t connector_weight_bps, - bool is_private, - sysio::name owner) { - opp::require_privileged_self(); - sysio::check(is_bootstrap_window(), - "regreserve is bootstrap-window only; post-bootstrap reserves go through create_reserve"); - sysio::check(source_token_precision <= WIRE_PRECISION, - "source_token_precision exceeds the depot frame (9) — the outpost must downscale to min(native, 9)"); - sysio::check(connector_weight_bps > 0 && connector_weight_bps <= MAX_CONNECTOR_WEIGHT_BPS, - "connector_weight_bps must be in (0, 10000) — both pool-side weights must be positive"); - sysio::check(initial_chain_amount > 0 && initial_wire_amount > 0, - "bootstrap reserve must seed both chain_amount and wire_amount > 0"); - sysio::check(!is_private || owner != sysio::name{}, - "a private bootstrap reserve must name an owner"); - // The three codes form this row's COMPOSITE PRIMARY KEY and are rendered as strings by - // every reader -- refuse any that does not round-trip before the row becomes permanent. - opp::registry::check_codes({chain_code, token_code, reserve_code}, "sysio.reserv"); - // Both strings persist into a `sysio`-billed row -- bound them before emplace. - opp::registry::check_metadata(name, description, "sysio.reserv"); - - reserves_t tbl(get_self()); - auto pk = make_key(chain_code, token_code, reserve_code); - sysio::check(tbl.find(pk) == tbl.end(), "reserve already registered"); - - // Real-WIRE backing: drain the seed from the `sysio` emissions treasury - // into this contract's custody so `reserve_wire_amount` is never an - // unbacked accounting number. This contract is privileged, so the - // inline action may carry the treasury's authorization. - action( - permission_level{TREASURY_ACCOUNT, "active"_n}, - TOKEN_ACCOUNT, "transfer"_n, - std::make_tuple(TREASURY_ACCOUNT, get_self(), - asset(static_cast(initial_wire_amount), opp::wire::asset_symbol), - std::string("sysio.reserv::regreserve bootstrap WIRE backing")) - ).send(); - - const auto now = current_time_ms(); - tbl.emplace(ram_payer, pk, reserve_row{ - .chain_code = chain_code, - .token_code = token_code, - .reserve_code = reserve_code, - .name = std::move(name), - .description = std::move(description), - .status = opp::types::RESERVE_STATUS_ACTIVE, - .reserve_chain_amount = initial_chain_amount, - .reserve_wire_amount = initial_wire_amount, - .source_token_precision = source_token_precision, - .connector_weight_bps = connector_weight_bps, - .creator_addr = {}, - .requested_wire_amount = initial_wire_amount, - .external_token_amount = initial_chain_amount, - .registered_at_ms = now, - .activated_at_ms = now, - .cancelled_at_ms = 0, - .is_private = is_private, - .owner = owner, - .creator_pub_key = {}, - }); -} - -void reserve::oncrtreserve(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code, - std::string name, - std::string description, - uint64_t external_token_amount, - uint64_t requested_wire_amount, - uint32_t source_token_precision, - uint32_t connector_weight_bps, - opp::types::ChainKind creator_chain_kind, - std::vector creator_chain_addr, - bool is_private, - std::vector creator_pub_key) { - require_auth(MSGCH_ACCOUNT); - const auto expected_chain_kind = - registered_chain_kind_or_skip(chain_code, "oncrtreserve"); - if (!expected_chain_kind.has_value()) return; - - // The reserve row is keyed by (chain, token, reserve) codes, so an unspellable one - // cannot be persisted. The creator's escrow is already in outpost custody, so refund - // rather than drop. No CANCELLED tombstone (unlike the rejections below): the row's - // key IS the unspellable triple, and a code with no canonical spelling can be neither squatted - // nor reclaimed, so the tombstone has nothing to protect. - if (!token_code.is_canonical() || !reserve_code.is_canonical()) { - sysio::print("oncrtreserve: rejecting with RESERVE_CREATE_CANCELLED " - "(token or reserve code has no canonical slug_name spelling)\n"); - opp::attestations::ReserveCreateCancelled cancelled; - cancelled.chain_code = chain_code.value; - cancelled.token_code = token_code.value; - cancelled.reserve_code = reserve_code.value; - queue_attestation_out(get_self(), chain_code, - opp::types::AttestationType::ATTESTATION_TYPE_RESERVE_CREATE_CANCELLED, - cancelled); - return; - } - - // Soft-validate; silent skip per feedback_opp_handlers_never_throw. - if (connector_weight_bps == 0 || connector_weight_bps > MAX_CONNECTOR_WEIGHT_BPS) { - sysio::print("oncrtreserve: bad connector_weight_bps; skipping\n"); - return; - } - // An invalid amount — a zero deposit / requested amount, OR an inbound - // TokenAmount that sysio.msgch clamped to 0 because it was negative or out of - // asset range (WSA-028) — cannot create a reserve. It must NOT be silently - // dropped: the creator's outpost escrow still has to be released. Route it into - // the SAME cancel/refund path as an unlinked creator below (insert a CANCELLED - // row + queue RESERVE_CREATE_CANCELLED), idempotently. - const bool invalid_amount = (external_token_amount == 0 || requested_wire_amount == 0); - // `chain_code` is registry-owned and authoritative. The attestation also - // carries a creator kind, but that redundant, externally supplied value - // must not select a different public-key variant or produce a reserve that - // `matchreserve` can never match against the registered chain kind. - const bool creator_chain_kind_mismatch = creator_chain_kind != *expected_chain_kind; - const bool invalid_creator_address = - !creator_address_matches_kind(*expected_chain_kind, creator_chain_addr); - // Same `sysio`-billed metadata bound the privileged registrations enforce with - // `check_metadata`, asked the non-throwing way: this handler must never abort, so an - // over-bound string joins the reject/refund path below rather than reverting dispatch. - const bool oversized_metadata = opp::registry::metadata_exceeds_bounds(name, description); - // The outpost downscales to min(native, 9) at its boundary, so a - // source_token_precision above the depot frame means a malformed attestation. - if (source_token_precision > WIRE_PRECISION) { - sysio::print("oncrtreserve: source_token_precision exceeds depot frame (9); skipping\n"); - return; - } - - reserves_t tbl(get_self()); - auto pk = make_key(chain_code, token_code, reserve_code); - // Existence guard, status-aware. A live row (PENDING/ACTIVE) — or any - // non-CANCELLED row — is immutable here; skip idempotently. A CANCELLED - // row (a prior no-link rejection or an outpost-side cancel) is *reclaimable* - // by a later, properly-linked creator: fall through so the link probe below - // decides whether to overwrite it. Without this carve-out a CANCELLED row - // would permanently burn the (chain, token, reserve_code) identity for its - // rightful owner (namespace squatting), since no path ever erases a row. - auto existing = tbl.find(pk); - const bool reclaimable_cancelled = - existing != tbl.end() && - existing->status == opp::types::RESERVE_STATUS_CANCELLED; - if (existing != tbl.end() && !reclaimable_cancelled) { - sysio::print("oncrtreserve: reserve already exists; skipping\n"); - return; - } - - opp::types::ChainAddress creator; - creator.kind = *expected_chain_kind; - creator.address = std::move(creator_chain_addr); - - // Create gating: the creator must already be authex-linked to a WIRE - // account ("the only requirement to create a reserve"). Reconstruct the - // creator's key variant and probe `sysio.authex::links.bypubkey`. On - // any failure — a chain-kind mismatch, malformed address/key bytes, no - // link, an invalid amount, OR over-bound metadata — reject by inserting a - // CANCELLED row (for refund idempotency) and queueing - // RESERVE_CREATE_CANCELLED so the outpost refunds the creator's escrow. - // The CANCELLED row does NOT permanently burn the identity: a later, - // properly-linked creator reclaims it via the reclaim branch below - // (prevents namespace squatting). Never throws. - std::vector canonical_creator_key; - { - auto pk_variant = pubkey_from_raw(*expected_chain_kind, creator_pub_key, creator.address); - bool linked = false; - if (pk_variant) { - sysio::authex::links_t links(AUTHEX_ACCOUNT); - auto idx = links.get_index<"bypubkey"_n>(); - linked = idx.find(sysio::pubkey_to_checksum256(*pk_variant)) != idx.end(); - if (linked) { - canonical_creator_key = sysio::pubkey_to_bytes(*pk_variant); - } - } - if (creator_chain_kind_mismatch || invalid_creator_address || !linked || - invalid_amount || oversized_metadata) { - // A CANCELLED row already standing means this is a re-relay of the same - // rejected create (an unlinked squatter OR an invalid amount). Leave it - // and do NOT queue a second refund — the refund was queued when the row - // was first inserted, and the outpost refunds per (chain,token,reserve_code). - // The row stays reclaimable by a future linked creator with a valid amount. - if (reclaimable_cancelled) { - sysio::print("oncrtreserve: re-relay of an already-CANCELLED reserve; " - "leaving it (no double refund)\n"); - return; - } - sysio::print("oncrtreserve: rejecting with RESERVE_CREATE_CANCELLED " - "(creator chain-kind/address mismatch, invalid amount, over-bound " - "metadata, or unlinked / malformed creator key)\n"); - // Do not persist attacker-sized address bytes in the sysio-billed - // cancellation tombstone. The outpost refunds by the reserve triple, - // so a malformed creator address is unnecessary for that handshake. - if (invalid_creator_address) creator.address.clear(); - const auto now = current_time_ms(); - tbl.emplace(ram_payer, pk, reserve_row{ - .chain_code = chain_code, - .token_code = token_code, - .reserve_code = reserve_code, - // The tombstone is itself a `sysio`-billed row, so over-bound strings are NOT - // carried onto it — storing them verbatim would persist exactly the state the - // bound exists to prevent. Only THAT rejection reason substitutes the marker; a - // row rejected for an unlinked creator or an invalid amount keeps its (already - // in-bounds) metadata. The creator's originals stay in the inbound OPP envelope - // artifact either way. - .name = oversized_metadata - ? std::string(opp::registry::rejected_label) - : std::move(name), - .description = oversized_metadata ? std::string{} : std::move(description), - .status = opp::types::RESERVE_STATUS_CANCELLED, - .reserve_chain_amount = 0, - .reserve_wire_amount = 0, - .source_token_precision = source_token_precision, - .connector_weight_bps = connector_weight_bps, - .creator_addr = std::move(creator), - .requested_wire_amount = requested_wire_amount, - .external_token_amount = external_token_amount, - .registered_at_ms = now, - .activated_at_ms = 0, - .cancelled_at_ms = now, - .is_private = is_private, - .owner = {}, - .creator_pub_key = {}, - }); - opp::attestations::ReserveCreateCancelled cancelled; - cancelled.chain_code = chain_code.value; - cancelled.token_code = token_code.value; - cancelled.reserve_code = reserve_code.value; - queue_attestation_out(get_self(), chain_code, - opp::types::AttestationType::ATTESTATION_TYPE_RESERVE_CREATE_CANCELLED, - cancelled); - return; - } - } - - const auto now = current_time_ms(); - reserve_row fresh{ - .chain_code = chain_code, - .token_code = token_code, - .reserve_code = reserve_code, - .name = std::move(name), - .description = std::move(description), - .status = opp::types::RESERVE_STATUS_PENDING, - .reserve_chain_amount = external_token_amount, - .reserve_wire_amount = 0, - .source_token_precision = source_token_precision, - .connector_weight_bps = connector_weight_bps, - .creator_addr = std::move(creator), - .requested_wire_amount = requested_wire_amount, - .external_token_amount = external_token_amount, - .registered_at_ms = now, - .activated_at_ms = 0, - .cancelled_at_ms = 0, - .is_private = is_private, - .owner = {}, - .creator_pub_key = std::move(canonical_creator_key), - }; - if (reclaimable_cancelled) { - // A properly-linked creator reclaims a previously-CANCELLED identity: - // overwrite the dead row in place (re-indexing `bystatus` - // CANCELLED → PENDING). Every field is reset to this create — this is - // what makes the slot non-squattable. - tbl.modify(ram_payer, pk, [&](auto& row) { row = std::move(fresh); }); - } else { - tbl.emplace(ram_payer, pk, std::move(fresh)); - } -} - -void reserve::matchreserve(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code, - name matcher, - uint64_t wire_amount) { - require_auth(matcher); - - reserves_t tbl(get_self()); - auto pk = make_key(chain_code, token_code, reserve_code); - auto it = tbl.find(pk); - sysio::check(it != tbl.end(), "matchreserve: reserve not found"); - sysio::check(it->status == opp::types::RESERVE_STATUS_PENDING, - "matchreserve: reserve is not PENDING"); - sysio::check(wire_amount == it->requested_wire_amount, - "matchreserve: wire_amount must equal requested_wire_amount exactly"); - - // Match gating: the matcher MUST be the WIRE account authex-linked to - // the reserve's creator. Resolve the matcher's link for the reserve - // chain's kind and compare its canonical key bytes against the creator - // pubkey normalized at create time. (recordlink permits one key → - // many accounts; ANY account linked to the creator's key may match — - // that is "the linked WIRE account".) - sysio::check(!it->creator_pub_key.empty(), - "matchreserve: reserve carries no creator pubkey (not matchable)"); - auto kind_opt = chain_kind_for_code(chain_code); - sysio::check(kind_opt.has_value(), - "matchreserve: reserve chain not registered in sysio.chains"); - { - sysio::authex::links_t links(AUTHEX_ACCOUNT); - auto idx = links.get_index<"bynamechain"_n>(); - auto lit = idx.find(sysio::to_namechain_key(matcher, *kind_opt)); - sysio::check(lit != idx.end(), - "matchreserve: matcher has no authex link for the reserve's chain"); - sysio::check(sysio::pubkey_to_bytes(lit->pub_key) == it->creator_pub_key, - "matchreserve: matcher is not the authex-linked account of the reserve's creator"); - } - - // The match IS a WIRE deposit: take REAL custody of the WIRE side so - // `reserve_wire_amount` is backed the moment the reserve activates. - action( - permission_level{matcher, "active"_n}, - TOKEN_ACCOUNT, "transfer"_n, - std::make_tuple(matcher, get_self(), - asset(static_cast(wire_amount), opp::wire::asset_symbol), - std::string("sysio.reserv::matchreserve WIRE escrow")) - ).send(); - - tbl.modify(ram_payer, pk, [&](auto& row) { - row.status = opp::types::RESERVE_STATUS_ACTIVE; - row.reserve_wire_amount = wire_amount; - row.activated_at_ms = current_time_ms(); - row.owner = matcher; - }); - - // Reserve is now ACTIVE on the depot. Notify the owning outpost so its - // local reserve record can flip to ACTIVE and become usable for swap - // routing. The destination `chain_code` is the reserve's `chain_code` - // (per the `sysio.msgch::queueout` convention — the outpost id is - // the chain slug_name's packed uint64 value). - opp::attestations::ReserveReady ready; - ready.chain_code = chain_code.value; - ready.token_code = token_code.value; - ready.reserve_code = reserve_code.value; - queue_attestation_out(get_self(), chain_code, - opp::types::AttestationType::ATTESTATION_TYPE_RESERVE_READY, - ready); -} - -void reserve::oncnclrsv(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code, - opp::types::ChainKind creator_chain_kind, - std::vector creator_chain_addr) { - require_auth(MSGCH_ACCOUNT); - const auto expected_chain_kind = - registered_chain_kind_or_skip(chain_code, "oncnclrsv"); - if (!expected_chain_kind.has_value()) return; - - reserves_t tbl(get_self()); - auto pk = make_key(chain_code, token_code, reserve_code); - auto it = tbl.find(pk); - if (it == tbl.end()) { - sysio::print("oncnclrsv: reserve not found; silently skipping\n"); - return; - } - - if (it->status != opp::types::RESERVE_STATUS_PENDING) { - sysio::print("oncnclrsv: status != PENDING; race lost, silent no-op\n"); - return; - } - - const bool addr_matches = - creator_chain_kind == *expected_chain_kind && - it->creator_addr.kind == *expected_chain_kind && - it->creator_addr.address == creator_chain_addr; - if (!addr_matches) { - sysio::print("oncnclrsv: creator_addr mismatch; silently skipping\n"); - return; - } - - tbl.modify(ram_payer, pk, [&](auto& row) { - row.status = opp::types::RESERVE_STATUS_CANCELLED; - row.cancelled_at_ms = current_time_ms(); - }); - - // Race won — depot accepted the cancel before any matchreserve. Notify - // the outpost so it refunds the creator's `external_token_amount`. The - // destination `chain_code` is the reserve's owning `chain_code`. Per - // `feedback_opp_handlers_never_throw.md` this handler still cannot - // throw; the early chain-registration guard and the remaining - // soft-validation checks must all pass before queueout is sent. - opp::attestations::ReserveCreateCancelled cancelled; - cancelled.chain_code = chain_code.value; - cancelled.token_code = token_code.value; - cancelled.reserve_code = reserve_code.value; - queue_attestation_out(get_self(), chain_code, - opp::types::AttestationType::ATTESTATION_TYPE_RESERVE_CREATE_CANCELLED, - cancelled); -} - -uint64_t reserve::swapquote(sysio::slug_name from_chain_code, - sysio::slug_name from_token_code, - sysio::slug_name from_reserve_code, - uint64_t from_amount, - sysio::slug_name to_chain_code, - sysio::slug_name to_token_code, - sysio::slug_name to_reserve_code) { - if (from_amount == 0) return 0; - - const bool src_is_wire = opp::wire::is_native_asset(from_chain_code, from_token_code); - const bool dst_is_wire = opp::wire::is_native_asset(to_chain_code, to_token_code); - if (src_is_wire && dst_is_wire) return from_amount; // WIRE->WIRE is a plain transfer - - reserves_t tbl(get_self()); - - // Resolve only the non-WIRE side(s); a WIRE endpoint has no token/WIRE pool - // (the depot IS the WIRE side). Any required reserve missing or not ACTIVE - // yields a 0 quote. - uint64_t src_chain = 0, src_wire = 0; uint32_t src_cw = 0, src_fee_bps = 0; - if (!src_is_wire) { - auto it = tbl.find(make_key(from_chain_code, from_token_code, from_reserve_code)); - if (it == tbl.end() || it->status != opp::types::RESERVE_STATUS_ACTIVE) return 0; - src_chain = it->reserve_chain_amount; - src_wire = it->reserve_wire_amount; - src_cw = it->connector_weight_bps; - src_fee_bps = it->owner_fee_bps; - } - uint64_t dst_chain = 0, dst_wire = 0; uint32_t dst_cw = 0, dst_fee_bps = 0; - if (!dst_is_wire) { - auto it = tbl.find(make_key(to_chain_code, to_token_code, to_reserve_code)); - if (it == tbl.end() || it->status != opp::types::RESERVE_STATUS_ACTIVE) return 0; - dst_chain = it->reserve_chain_amount; - dst_wire = it->reserve_wire_amount; - dst_cw = it->connector_weight_bps; - dst_fee_bps = it->owner_fee_bps; - } - - // Each participating reserve's owner fee rides the quote alongside the - // network fee, so the quote prices exactly what settlement will charge. - return opp::amm::quote_swap(src_is_wire, src_chain, src_wire, src_cw, - dst_is_wire, dst_chain, dst_wire, dst_cw, - from_amount, uwrit_fee_bps(), - src_fee_bps, dst_fee_bps); -} - -uint64_t reserve::rewardbal() { - rewardbkt_t bkt(get_self()); - return bkt.get_or_default(rewards_bucket{}).balance; -} - -void reserve::drainrewards(int64_t amount) { - // Only the system treasury (where sysio.system::payepoch runs) may sweep the - // rewards bucket. The swept WIRE is allocated exclusively to the - // batch-operator distribution at the next pay-epoch — producers are not paid - // out of swap fees. payepoch pays only eligible shares; what it skips stays in - // the treasury. - require_auth(TREASURY_ACCOUNT); - - // Internal treasury sweep: a non-positive amount means the caller's - // integration logic is wrong (payepoch only calls with a positive amount), - // so fail loudly rather than silently no-op. - sysio::check(amount > 0, "drainrewards: amount must be positive"); - - const uint64_t req = static_cast(amount); - - rewardbkt_t bkt(get_self()); - auto rb = bkt.get_or_default(rewards_bucket{}); - sysio::check(req <= rb.balance, "drainrewards: amount exceeds rewards bucket balance"); - - // Drop the accounting balance first, then move the backing WIRE out of - // custody. lifetime_accrued is a cumulative audit total — never decremented. - rb.balance -= req; - bkt.set(rb, ram_payer); - - action( - permission_level{get_self(), "active"_n}, - TOKEN_ACCOUNT, "transfer"_n, - std::make_tuple(get_self(), TREASURY_ACCOUNT, - asset(static_cast(req), opp::wire::asset_symbol), - std::string("sysio.reserv::swap-fee rewards -> emissions payepoch")) - ).send(); -} - -void reserve::debit(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code, - uint64_t amount) { - require_auth(UWRIT_ACCOUNT); - sysio::check(amount > 0, "amount must be positive"); - - reserves_t tbl(get_self()); - auto pk = make_key(chain_code, token_code, reserve_code); - auto it = tbl.find(pk); - sysio::check(it != tbl.end(), "debit: reserve not found"); - sysio::check(it->status == opp::types::RESERVE_STATUS_ACTIVE, - "debit: reserve not ACTIVE"); - sysio::check(it->reserve_chain_amount >= amount, - "insufficient reserve_chain_amount for SWAP_REMIT debit"); - - tbl.modify(ram_payer, pk, [&](auto& row) { - row.reserve_chain_amount -= amount; - }); -} - -// onreject was removed — no SwapRejected attestation exists (every depot-initiated -// REMIT is paid by the destination outpost; reserves need no rejection reconciliation). - -// onreward was removed: the STAKING_REWARD path credits the per-staker reward to -// sysio.dclaim directly (already WIRE-denominated), so there is no reserve leg. - -// --------------------------------------------------------------------------- -// Emit-time swap settlement (auth = sysio.uwrit) -// -// All `check()`s below are defense-in-depth: `sysio.uwrit::try_select_winner` -// pre-validates the same conditions against its reserve mirror BEFORE -// sending these inline actions, so within the single race-resolution -// transaction they are unreachable. A check firing here therefore signals -// a depot bug (mirror drift), not a runtime condition — halting that -// transaction is the correct response. -// --------------------------------------------------------------------------- - -void reserve::applyswap(sysio::slug_name src_chain_code, - sysio::slug_name src_token_code, - sysio::slug_name src_reserve_code, - uint64_t src_amount, - sysio::slug_name dst_chain_code, - sysio::slug_name dst_token_code, - sysio::slug_name dst_reserve_code, - uint64_t dst_amount, - sysio::name underwriter) { - require_auth(UWRIT_ACCOUNT); - sysio::check(src_amount > 0 && dst_amount > 0, "applyswap: amounts must be positive"); - - reserves_t tbl(get_self()); - auto src_pk = make_key(src_chain_code, src_token_code, src_reserve_code); - auto dst_pk = make_key(dst_chain_code, dst_token_code, dst_reserve_code); - auto src_it = tbl.find(src_pk); - sysio::check(src_it != tbl.end(), "applyswap: source reserve not found"); - sysio::check(src_it->status == opp::types::RESERVE_STATUS_ACTIVE, - "applyswap: source reserve not ACTIVE"); - auto dst_it = tbl.find(dst_pk); - sysio::check(dst_it != tbl.end(), "applyswap: destination reserve not found"); - sysio::check(dst_it->status == opp::types::RESERVE_STATUS_ACTIVE, - "applyswap: destination reserve not ACTIVE"); - - // The gross WIRE intermediate is derived from the PRE-mutation source row on - // the weighted curve (the source reserve's own `connector_weight_bps`) — the - // same definition `sysio.uwrit::swap_quote` uses, so the depot's books and - // its quotes share one curve. The fee is then taken OUT of this WIRE leg: the - // source side gives up the full gross WIRE and only `net` continues to the - // destination side. The TOTAL `fee` spreads across BOTH reserves' owner - // accruals (made in the modifies below), the winning underwriter's accrual, - // the batch-operator rewards bucket, and — only when - // `fee_emissions_share_bps` is set — a transfer of that share to `sysio`. - const uint64_t w_gross = opp::amm::token_to_wire(src_it->reserve_chain_amount, - src_it->reserve_wire_amount, - src_it->connector_weight_bps, - src_amount); - sysio::check(w_gross > 0, "applyswap: WIRE intermediate is zero"); - // BOTH reserves supply liquidity for this swap, so both charge their own - // owner fee on the same WIRE leg — on top of the network fee (WIRE-281). - const auto fee = opp::amm::split_wire_fee(w_gross, uwrit_fee_bps(), FEE_UNDERWRITER_SHARE_BPS, - fee_emissions_share_bps(get_self()), - src_it->owner_fee_bps, dst_it->owner_fee_bps); - // SEC-26 / WSA-042 settlement backstop: a zero post-fee WIRE leg credits no - // WIRE to the destination reserve below while still debiting its chain side - // — draining it at an arbitrary price. This is a LIVE path, not unreachable - // defense-in-depth: the caps bound each rate INDEPENDENTLY, so a network fee - // at `sysio.uwrit::MAX_FEE_BPS` (9999) plus either reserve's owner fee at - // MIN_OWNER_FEE_BPS (1) already totals 100%. Such a combination is an - // intentionally rejected configuration — refuse the swap here rather than - // settle it at an arbitrary price. - sysio::check(fee.net > 0, "applyswap: zero post-fee WIRE would credit no destination liquidity"); - sysio::check(src_it->reserve_wire_amount >= w_gross, - "applyswap: insufficient source reserve WIRE for intermediate"); - // WNS-02 settlement bound: the destination side may never give up more token - // than its own curve produces for the post-fee WIRE it is about to receive. - // `sysio.uwrit` derives `dst_amount` from exactly this expression (via - // `swap_quote` -> `opp::amm::quote_swap`) against the same pre-mutation rows - // in the same transaction, so equality holds and this cannot fire on the - // live path. It exists so the reserve is self-defending: `dst_amount` arrives - // as a caller-supplied parameter, and the vulnerability this replaces was - // precisely a caller-chosen amount being paid out verbatim. A reserve that - // trusts its caller for a payout size has no floor of its own. - const uint64_t curve_out = opp::amm::wire_to_token(dst_it->reserve_wire_amount, - dst_it->reserve_chain_amount, - dst_it->connector_weight_bps, - fee.net); - sysio::check(dst_amount <= curve_out, - "applyswap: destination amount exceeds the curve output for the post-fee WIRE"); - sysio::check(dst_it->reserve_chain_amount >= dst_amount, - "applyswap: insufficient destination reserve balance"); - - tbl.modify(ram_payer, src_pk, [&](auto& row) { - add_capped_u64(row.reserve_chain_amount, src_amount); - row.reserve_wire_amount -= w_gross; - accrue_owner_fee(row, fee.src_reserve_share); - }); - // Same-row swaps (identical triples) compose correctly: the second - // modify reads the post-first-modify state. The destination receives only - // the post-fee WIRE. - tbl.modify(ram_payer, dst_pk, [&](auto& row) { - add_capped_u64(row.reserve_wire_amount, fee.net); - row.reserve_chain_amount -= dst_amount; - accrue_owner_fee(row, fee.dst_reserve_share); - }); - - // Route the NETWORK component (the two owner shares already accrued to their - // reserve rows above): underwriter half to `uwfees`, rewards half to - // `rewards_bucket` — both custody-internal — except any configured - // `fee_emissions_share_bps`, the only part that leaves for the treasury. - route_wire_fee(get_self(), fee, underwriter); -} - -void reserve::applyfromwire(sysio::slug_name dst_chain_code, - sysio::slug_name dst_token_code, - sysio::slug_name dst_reserve_code, - uint64_t wire_in, - uint64_t dst_amount, - sysio::name underwriter) { - require_auth(UWRIT_ACCOUNT); - sysio::check(wire_in > 0 && dst_amount > 0, "applyfromwire: amounts must be positive"); - - reserves_t tbl(get_self()); - auto pk = make_key(dst_chain_code, dst_token_code, dst_reserve_code); - auto it = tbl.find(pk); - sysio::check(it != tbl.end(), "applyfromwire: destination reserve not found"); - sysio::check(it->status == opp::types::RESERVE_STATUS_ACTIVE, - "applyfromwire: destination reserve not ACTIVE"); - sysio::check(it->reserve_chain_amount >= dst_amount, - "applyfromwire: insufficient destination reserve balance"); - - // Fee out of the user's escrowed input WIRE: only the post-fee remainder - // becomes destination-reserve liquidity. The TOTAL fee spreads across the - // DESTINATION reserve's owner accrual, the winning underwriter's accrual, and - // the rewards bucket. The full `wire_in` was escrowed in this contract at - // `swapfromwire` time; the ONLY part that leaves here is a configured - // `fee_emissions_share_bps` of the rewards pool. At the default-zero dial - // nothing leaves and custody balances as net -> Σwire, fee -> the accruals. - // The source is the depot's own WIRE — there is no source reserve — so only - // the DESTINATION reserve charges an owner fee here. - const auto fee = opp::amm::split_wire_fee(wire_in, uwrit_fee_bps(), FEE_UNDERWRITER_SHARE_BPS, - fee_emissions_share_bps(get_self()), - /*src_reserve_fee_bps*/ 0, it->owner_fee_bps); - // SEC-26 / WSA-042 settlement backstop — see applyswap. A zero post-fee WIRE - // leg would debit the destination reserve below while crediting zero WIRE. - // Reachable under valid configuration — the network fee at MAX_FEE_BPS (9999) - // plus this reserve's owner fee at MIN_OWNER_FEE_BPS (1) totals 100% — so - // this rejects a configured combination, not an impossible one. - sysio::check(fee.net > 0, "applyfromwire: zero post-fee WIRE would credit no destination liquidity"); - // WNS-02 settlement bound — see applyswap. The from-WIRE shape feeds the - // user's escrowed WIRE straight into the WIRE leg, so the curve output is - // `wire_to_token` of the post-fee remainder. - const uint64_t curve_out = opp::amm::wire_to_token(it->reserve_wire_amount, - it->reserve_chain_amount, - it->connector_weight_bps, - fee.net); - sysio::check(dst_amount <= curve_out, - "applyfromwire: destination amount exceeds the curve output for the post-fee WIRE"); - - tbl.modify(ram_payer, pk, [&](auto& row) { - add_capped_u64(row.reserve_wire_amount, fee.net); - row.reserve_chain_amount -= dst_amount; - accrue_owner_fee(row, fee.dst_reserve_share); - }); - - route_wire_fee(get_self(), fee, underwriter); -} - -void reserve::paywire(sysio::slug_name src_chain_code, - sysio::slug_name src_token_code, - sysio::slug_name src_reserve_code, - uint64_t src_amount, - sysio::name recipient, - uint64_t wire_out, - sysio::name underwriter) { - require_auth(UWRIT_ACCOUNT); - sysio::check(src_amount > 0 && wire_out > 0, "paywire: amounts must be positive"); - sysio::check(is_account(recipient), "paywire: recipient account does not exist"); - - reserves_t tbl(get_self()); - auto pk = make_key(src_chain_code, src_token_code, src_reserve_code); - auto it = tbl.find(pk); - sysio::check(it != tbl.end(), "paywire: source reserve not found"); - sysio::check(it->status == opp::types::RESERVE_STATUS_ACTIVE, - "paywire: source reserve not ACTIVE"); - - // Swap-to-WIRE: the recipient receives `wire_out`, and the fee is charged on - // the gross WIRE the source side produces — the same WIRE leg the quote - // priced. The source reserve gives up `wire_out + fee`. - const uint64_t w_gross = opp::amm::token_to_wire(it->reserve_chain_amount, - it->reserve_wire_amount, - it->connector_weight_bps, - src_amount); - sysio::check(w_gross > 0, "paywire: WIRE leg is zero"); - // The recipient is paid in WIRE — there is no destination reserve — so only - // the SOURCE reserve charges an owner fee here. - const auto fee = opp::amm::split_wire_fee(w_gross, uwrit_fee_bps(), FEE_UNDERWRITER_SHARE_BPS, - fee_emissions_share_bps(get_self()), - it->owner_fee_bps, /*dst_reserve_fee_bps*/ 0); - // WNS-02 settlement bound — see applyswap. For a WIRE destination the curve - // output IS the post-fee WIRE leg, so the payout may never exceed `fee.net`. - // `sysio.uwrit` passes exactly `fee.net` (its `swap_quote` returns the - // post-fee WIRE for a WIRE endpoint), so equality holds on the live path. - // `fee.net` now also nets out the source reserve owner's share, and - // `quote_swap` charges that share through this same `split_wire_fee`, so the - // quote and this bound still agree by construction. - sysio::check(wire_out <= fee.net, - "paywire: payout exceeds the post-fee WIRE the source leg produced"); - const uint64_t wire_leaving = wire_out + fee.fee; - sysio::check(it->reserve_wire_amount >= wire_leaving, - "paywire: insufficient source reserve WIRE for payout + fee"); - - tbl.modify(ram_payer, pk, [&](auto& row) { - add_capped_u64(row.reserve_chain_amount, src_amount); - row.reserve_wire_amount -= wire_leaving; - accrue_owner_fee(row, fee.src_reserve_share); - }); - - // The payout is credited, not pushed: this action is inlined from - // `sysio.uwrit::try_select_winner` inside the never-throw consensus dispatch chain, and a - // transfer would let `recipient`'s notify handler abort the whole delivery. - // - // The fee stays behind as three accruals — the source reserve's owner accrual (above), the - // underwriter accrual, and the rewards bucket — except any configured - // `fee_emissions_share_bps`, which `route_wire_fee` transfers to the treasury. - // - // Custody bookkeeping: `Σ reserve_wire_amount` drops by `wire_out + fee`, but only that - // emissions share actually leaves the contract's token balance. `wire_out` moves sideways - // into `Σ wireclaims.balance` and the rest of the fee into the accruals -- all still in - // custody, so the invariant balances. - credit_wire_claim(get_self(), recipient, wire_out); - route_wire_fee(get_self(), fee, underwriter); -} - -void reserve::refundwire(sysio::name recipient, - uint64_t wire_amount, - uint32_t revert_fee_bps) { - require_auth(UWRIT_ACCOUNT); - sysio::check(wire_amount > 0, "refundwire: amount must be positive"); - sysio::check(is_account(recipient), "refundwire: recipient account does not exist"); - - // Caller-fault revert fee, routed through the same path as a settlement fee. - // A revert has NO winning underwriter — no collateral was locked for a swap - // that never settled — so the underwriter share is zero and the whole revert - // fee becomes the rewards POOL. `fee_emissions_share_bps` then splits that - // pool exactly as it does a settlement fee's: the whole revert fee lands in - // the rewards bucket only under the default zero dial; a configured dial - // diverts that share to the emissions treasury. Zero bps (no-fault refund) - // makes both the split and the routing no-ops. - // - // Unlike `applyswap` / `applyfromwire`, the backstop below really IS - // unreachable here, and for a reason specific to this path: a refund charges - // NO reserve owner fee (the trailing rates are left at 0), so the total is - // the network fee alone, which `sysio.uwrit::setconfig` caps at MAX_FEE_BPS - // (9999). Floor division then leaves `net >= 1` for any positive amount. - // There is no second rate to stack on top and reach 100%. It must hold: this - // action is inlined from the never-throw `drainfwq` drain. - const auto fee = opp::amm::split_wire_fee(wire_amount, revert_fee_bps, - /*underwriter_share_bps*/ 0, - fee_emissions_share_bps(get_self())); - sysio::check(fee.net > 0, "refundwire: revert fee must be below 100%"); - - // Credited, not pushed. This runs inline from the never-throw `sysio.uwrit::drainfwq` drain, - // which itself runs inline from `sysio.epoch::advance`: a transfer here would let the refund - // recipient's notify handler abort `advance`, and because the drain's `q.erase()` rolls back - // with the transaction the offending queue row would survive and re-block every later epoch. - credit_wire_claim(get_self(), recipient, fee.net); - route_wire_fee(get_self(), fee, /*underwriter*/ name{}); -} - -void reserve::setconfig(uint32_t fee_emissions_share_bps) { - require_auth(get_self()); - sysio::check(fee_emissions_share_bps <= FEE_SPLIT_TOTAL_BPS, - "setconfig: fee_emissions_share_bps must be <= 10000 (100% of the rewards pool)"); - - reservcfg_t cfg(get_self()); - auto row = cfg.get_or_default(reserve_config{}); - row.fee_emissions_share_bps = fee_emissions_share_bps; - cfg.set(row, ram_payer); -} - -void reserve::setrsvfee(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code, - uint32_t owner_fee_bps) { - reserves_t tbl(get_self()); - auto pk = make_key(chain_code, token_code, reserve_code); - auto it = tbl.find(pk); - sysio::check(it != tbl.end(), "setrsvfee: reserve not found"); - sysio::check(it->status == opp::types::RESERVE_STATUS_ACTIVE, - "setrsvfee: reserve is not ACTIVE"); - // A bootstrap-seeded public reserve has no owner, so nobody can authorize a - // fee on it — which is exactly the guard that stops WIRE accruing with no - // claimant. `require_auth(name{})` would abort with a confusing message, so - // say why first. - sysio::check(it->owner.value != 0, "setrsvfee: reserve has no owner"); - require_auth(it->owner); - - // 0 disables the fee; anything else must clear the dust floor and stay below - // the 99% ceiling that keeps a positive remainder on the WIRE leg. - sysio::check(owner_fee_bps == 0 || - (owner_fee_bps >= MIN_OWNER_FEE_BPS && owner_fee_bps <= MAX_OWNER_FEE_BPS), - "setrsvfee: owner_fee_bps must be 0 or within [1, 9900]"); - - tbl.modify(ram_payer, pk, [&](auto& row) { row.owner_fee_bps = owner_fee_bps; }); -} - -uint64_t reserve::rsvfeebal(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code) { - reserves_t tbl(get_self()); - auto it = tbl.find(make_key(chain_code, token_code, reserve_code)); - return it == tbl.end() ? 0 : it->owner_fee_accrued; -} - -void reserve::claimrsvfee(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code) { - reserves_t tbl(get_self()); - auto pk = make_key(chain_code, token_code, reserve_code); - auto it = tbl.find(pk); - sysio::check(it != tbl.end(), "claimrsvfee: reserve not found"); - sysio::check(it->owner.value != 0, "claimrsvfee: reserve has no owner"); - require_auth(it->owner); - - const uint64_t amount = it->owner_fee_accrued; - const name owner = it->owner; - sysio::check(amount > 0, "claimrsvfee: no unclaimed balance"); - - // Zero the accounting balance first, then move the backing WIRE out of - // custody. `owner_fee_lifetime` is a cumulative audit total — never reduced. - tbl.modify(ram_payer, pk, [&](auto& row) { row.owner_fee_accrued = 0; }); - - action( - permission_level{get_self(), "active"_n}, - TOKEN_ACCOUNT, "transfer"_n, - std::make_tuple(get_self(), owner, - asset(static_cast(amount), opp::wire::asset_symbol), - std::string("sysio.reserv::reserve owner fee claim")) - ).send(); -} - -uint64_t reserve::uwfeebal(sysio::name underwriter) { - uwfees_t uwf(get_self()); - auto it = uwf.find(uw_fee_key{underwriter}); - return it == uwf.end() ? 0 : it->balance; -} - -void reserve::claimuwfee(sysio::name underwriter) { - // Only the earner may sweep their own accrual. There is no depot-side push: - // an underwriter claims when they choose, and a dormant one costs the chain - // nothing. - require_auth(underwriter); - - uwfees_t uwf(get_self()); - uw_fee_key key{underwriter}; - auto it = uwf.find(key); - sysio::check(it != uwf.end(), "claimuwfee: no accrued swap fees for this underwriter"); - - const uint64_t amount = it->balance; - sysio::check(amount > 0, "claimuwfee: no unclaimed balance"); - - // Zero the accounting balance first, then move the backing WIRE out of - // custody. The row is retained at zero so the lifetime audit totals survive. - uwf.modify(ram_payer, key, [&](auto& row) { - row.balance = 0; - add_capped_u64(row.lifetime_claimed, amount); - }); - - action( - permission_level{get_self(), "active"_n}, - TOKEN_ACCOUNT, "transfer"_n, - std::make_tuple(get_self(), underwriter, - asset(static_cast(amount), opp::wire::asset_symbol), - std::string("sysio.reserv::underwriter swap-fee claim")) - ).send(); -} - -void reserve::claimwire(sysio::name account) { - require_auth(account); - - reserve::wireclaims_t claims(get_self()); - const auto pk = reserve::wireclaim_key{account.value}; - - // Enforce the retention deadline HERE, not just in the sweep. The sweep is bounded and - // best-effort, so a row can outlive its expiry by an arbitrary number of epochs while it waits - // its turn; without this check the deadline would mean "swept eventually" rather than - // "claimable until". Refusing tells the caller the row is forfeit instead of paying a claim the - // retention policy already extinguished, and the sweep still reclaims the WIRE. - auto it = claims.find(pk); - if (it != claims.end() && it->expires_at_sec != 0) { - const uint32_t now_sec = static_cast(current_time_point().sec_since_epoch()); - sysio::check(now_sec < it->expires_at_sec, - "claimwire: this claim expired and is pending sweep to the treasury"); - } - - sysio::opp::claimable::pay_out( - claims, pk, get_self(), TOKEN_ACCOUNT, - account, opp::wire::asset_symbol, std::string("sysio.reserv::claimwire payout"), - "no claimable WIRE for this account"); -} - -void reserve::sweepclaims(uint32_t max_rows) { - // Two valid callers, mirroring `sysio.uwrit::chklocks`: - // * sysio.epoch::advance — inlined every epoch with MAX_CLAIM_SWEEP_PER_EPOCH. - // * sysio.reserv — manual drain with a caller-chosen budget. - sysio::check(has_auth(EPOCH_ACCOUNT) || has_auth(get_self()), - "sweepclaims requires sysio.epoch or sysio.reserv authority"); - if (max_rows == 0) return; - - sweep_expired_wire_claims(get_self(), - static_cast(current_time_point().sec_since_epoch()), - max_rows); -} - -} // namespace sysio diff --git a/contracts/sysio.reserv/sysio.reserv.abi b/contracts/sysio.reserv/sysio.reserv.abi deleted file mode 100644 index 1336ae2214..0000000000 --- a/contracts/sysio.reserv/sysio.reserv.abi +++ /dev/null @@ -1,909 +0,0 @@ -{ - "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", - "version": "sysio::abi/1.2", - "types": [], - "structs": [ - { - "name": "ChainAddress", - "base": "", - "fields": [ - { - "name": "kind", - "type": "ChainKind" - }, - { - "name": "address", - "type": "bytes" - } - ] - }, - { - "name": "applyfromwire", - "base": "", - "fields": [ - { - "name": "dst_chain_code", - "type": "slug_name" - }, - { - "name": "dst_token_code", - "type": "slug_name" - }, - { - "name": "dst_reserve_code", - "type": "slug_name" - }, - { - "name": "wire_in", - "type": "uint64" - }, - { - "name": "dst_amount", - "type": "uint64" - }, - { - "name": "underwriter", - "type": "name" - } - ] - }, - { - "name": "applyswap", - "base": "", - "fields": [ - { - "name": "src_chain_code", - "type": "slug_name" - }, - { - "name": "src_token_code", - "type": "slug_name" - }, - { - "name": "src_reserve_code", - "type": "slug_name" - }, - { - "name": "src_amount", - "type": "uint64" - }, - { - "name": "dst_chain_code", - "type": "slug_name" - }, - { - "name": "dst_token_code", - "type": "slug_name" - }, - { - "name": "dst_reserve_code", - "type": "slug_name" - }, - { - "name": "dst_amount", - "type": "uint64" - }, - { - "name": "underwriter", - "type": "name" - } - ] - }, - { - "name": "claimrsvfee", - "base": "", - "fields": [ - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "reserve_code", - "type": "slug_name" - } - ] - }, - { - "name": "claimuwfee", - "base": "", - "fields": [ - { - "name": "underwriter", - "type": "name" - } - ] - }, - { - "name": "claimwire", - "base": "", - "fields": [ - { - "name": "account", - "type": "name" - } - ] - }, - { - "name": "debit", - "base": "", - "fields": [ - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "reserve_code", - "type": "slug_name" - }, - { - "name": "amount", - "type": "uint64" - } - ] - }, - { - "name": "drainrewards", - "base": "", - "fields": [ - { - "name": "amount", - "type": "int64" - } - ] - }, - { - "name": "matchreserve", - "base": "", - "fields": [ - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "reserve_code", - "type": "slug_name" - }, - { - "name": "matcher", - "type": "name" - }, - { - "name": "wire_amount", - "type": "uint64" - } - ] - }, - { - "name": "oncnclrsv", - "base": "", - "fields": [ - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "reserve_code", - "type": "slug_name" - }, - { - "name": "creator_chain_kind", - "type": "ChainKind" - }, - { - "name": "creator_chain_addr", - "type": "bytes" - } - ] - }, - { - "name": "oncrtreserve", - "base": "", - "fields": [ - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "reserve_code", - "type": "slug_name" - }, - { - "name": "name", - "type": "string" - }, - { - "name": "description", - "type": "string" - }, - { - "name": "external_token_amount", - "type": "uint64" - }, - { - "name": "requested_wire_amount", - "type": "uint64" - }, - { - "name": "source_token_precision", - "type": "uint32" - }, - { - "name": "connector_weight_bps", - "type": "uint32" - }, - { - "name": "creator_chain_kind", - "type": "ChainKind" - }, - { - "name": "creator_chain_addr", - "type": "bytes" - }, - { - "name": "is_private", - "type": "bool" - }, - { - "name": "creator_pub_key", - "type": "bytes" - } - ] - }, - { - "name": "paywire", - "base": "", - "fields": [ - { - "name": "src_chain_code", - "type": "slug_name" - }, - { - "name": "src_token_code", - "type": "slug_name" - }, - { - "name": "src_reserve_code", - "type": "slug_name" - }, - { - "name": "src_amount", - "type": "uint64" - }, - { - "name": "recipient", - "type": "name" - }, - { - "name": "wire_out", - "type": "uint64" - }, - { - "name": "underwriter", - "type": "name" - } - ] - }, - { - "name": "refundwire", - "base": "", - "fields": [ - { - "name": "recipient", - "type": "name" - }, - { - "name": "wire_amount", - "type": "uint64" - }, - { - "name": "revert_fee_bps", - "type": "uint32" - } - ] - }, - { - "name": "regreserve", - "base": "", - "fields": [ - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "reserve_code", - "type": "slug_name" - }, - { - "name": "name", - "type": "string" - }, - { - "name": "description", - "type": "string" - }, - { - "name": "initial_chain_amount", - "type": "uint64" - }, - { - "name": "initial_wire_amount", - "type": "uint64" - }, - { - "name": "source_token_precision", - "type": "uint32" - }, - { - "name": "connector_weight_bps", - "type": "uint32" - }, - { - "name": "is_private", - "type": "bool" - }, - { - "name": "owner", - "type": "name" - } - ] - }, - { - "name": "reserve_config", - "base": "", - "fields": [ - { - "name": "fee_emissions_share_bps", - "type": "uint32" - } - ] - }, - { - "name": "reserve_key", - "base": "", - "fields": [ - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "reserve_code", - "type": "slug_name" - } - ] - }, - { - "name": "reserve_row", - "base": "", - "fields": [ - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "reserve_code", - "type": "slug_name" - }, - { - "name": "name", - "type": "string" - }, - { - "name": "description", - "type": "string" - }, - { - "name": "status", - "type": "ReserveStatus" - }, - { - "name": "reserve_chain_amount", - "type": "uint64" - }, - { - "name": "reserve_wire_amount", - "type": "uint64" - }, - { - "name": "source_token_precision", - "type": "uint32" - }, - { - "name": "connector_weight_bps", - "type": "uint32" - }, - { - "name": "creator_addr", - "type": "ChainAddress" - }, - { - "name": "requested_wire_amount", - "type": "uint64" - }, - { - "name": "external_token_amount", - "type": "uint64" - }, - { - "name": "registered_at_ms", - "type": "uint64" - }, - { - "name": "activated_at_ms", - "type": "uint64" - }, - { - "name": "cancelled_at_ms", - "type": "uint64" - }, - { - "name": "is_private", - "type": "bool" - }, - { - "name": "owner", - "type": "name" - }, - { - "name": "creator_pub_key", - "type": "bytes" - }, - { - "name": "owner_fee_bps", - "type": "uint32" - }, - { - "name": "owner_fee_accrued", - "type": "uint64" - }, - { - "name": "owner_fee_lifetime", - "type": "uint64" - } - ] - }, - { - "name": "rewardbal", - "base": "", - "fields": [] - }, - { - "name": "rewards_bucket", - "base": "", - "fields": [ - { - "name": "balance", - "type": "uint64" - }, - { - "name": "lifetime_accrued", - "type": "uint64" - } - ] - }, - { - "name": "rsvfeebal", - "base": "", - "fields": [ - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "reserve_code", - "type": "slug_name" - } - ] - }, - { - "name": "setconfig", - "base": "", - "fields": [ - { - "name": "fee_emissions_share_bps", - "type": "uint32" - } - ] - }, - { - "name": "setrsvfee", - "base": "", - "fields": [ - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "reserve_code", - "type": "slug_name" - }, - { - "name": "owner_fee_bps", - "type": "uint32" - } - ] - }, - { - "name": "swapquote", - "base": "", - "fields": [ - { - "name": "from_chain_code", - "type": "slug_name" - }, - { - "name": "from_token_code", - "type": "slug_name" - }, - { - "name": "from_reserve_code", - "type": "slug_name" - }, - { - "name": "from_amount", - "type": "uint64" - }, - { - "name": "to_chain_code", - "type": "slug_name" - }, - { - "name": "to_token_code", - "type": "slug_name" - }, - { - "name": "to_reserve_code", - "type": "slug_name" - } - ] - }, - { - "name": "sweepclaims", - "base": "", - "fields": [ - { - "name": "max_rows", - "type": "uint32" - } - ] - }, - { - "name": "uw_fee_key", - "base": "", - "fields": [ - { - "name": "underwriter", - "type": "name" - } - ] - }, - { - "name": "uw_fee_row", - "base": "", - "fields": [ - { - "name": "underwriter", - "type": "name" - }, - { - "name": "balance", - "type": "uint64" - }, - { - "name": "lifetime_accrued", - "type": "uint64" - }, - { - "name": "lifetime_claimed", - "type": "uint64" - } - ] - }, - { - "name": "uwfeebal", - "base": "", - "fields": [ - { - "name": "underwriter", - "type": "name" - } - ] - }, - { - "name": "wire_claim", - "base": "", - "fields": [ - { - "name": "account", - "type": "name" - }, - { - "name": "balance", - "type": "uint64" - }, - { - "name": "expires_at_sec", - "type": "uint32" - } - ] - }, - { - "name": "wireclaim_key", - "base": "", - "fields": [ - { - "name": "account", - "type": "uint64" - } - ] - } - ], - "actions": [ - { - "name": "applyfromwire", - "type": "applyfromwire", - "ricardian_contract": "" - }, - { - "name": "applyswap", - "type": "applyswap", - "ricardian_contract": "" - }, - { - "name": "claimrsvfee", - "type": "claimrsvfee", - "ricardian_contract": "" - }, - { - "name": "claimuwfee", - "type": "claimuwfee", - "ricardian_contract": "" - }, - { - "name": "claimwire", - "type": "claimwire", - "ricardian_contract": "" - }, - { - "name": "debit", - "type": "debit", - "ricardian_contract": "" - }, - { - "name": "drainrewards", - "type": "drainrewards", - "ricardian_contract": "" - }, - { - "name": "matchreserve", - "type": "matchreserve", - "ricardian_contract": "" - }, - { - "name": "oncnclrsv", - "type": "oncnclrsv", - "ricardian_contract": "" - }, - { - "name": "oncrtreserve", - "type": "oncrtreserve", - "ricardian_contract": "" - }, - { - "name": "paywire", - "type": "paywire", - "ricardian_contract": "" - }, - { - "name": "refundwire", - "type": "refundwire", - "ricardian_contract": "" - }, - { - "name": "regreserve", - "type": "regreserve", - "ricardian_contract": "" - }, - { - "name": "rewardbal", - "type": "rewardbal", - "ricardian_contract": "" - }, - { - "name": "rsvfeebal", - "type": "rsvfeebal", - "ricardian_contract": "" - }, - { - "name": "setconfig", - "type": "setconfig", - "ricardian_contract": "" - }, - { - "name": "setrsvfee", - "type": "setrsvfee", - "ricardian_contract": "" - }, - { - "name": "swapquote", - "type": "swapquote", - "ricardian_contract": "" - }, - { - "name": "sweepclaims", - "type": "sweepclaims", - "ricardian_contract": "" - }, - { - "name": "uwfeebal", - "type": "uwfeebal", - "ricardian_contract": "" - } - ], - "tables": [ - { - "name": "reservcfg", - "type": "reserve_config", - "index_type": "i64", - "key_names": ["name"], - "key_types": ["name"], - "table_id": 11698 - }, - { - "name": "reserves", - "type": "reserve_row", - "index_type": "i64", - "key_names": ["chain_code","token_code","reserve_code"], - "key_types": ["slug_name","slug_name","slug_name"], - "table_id": 52863, - "secondary_indexes": [ - { - "name": "bychaintok", - "key_type": "uint128", - "table_id": 35544 - }, - { - "name": "bystatus", - "key_type": "uint64", - "table_id": 46081 - } - ] - }, - { - "name": "rewardbkt", - "type": "rewards_bucket", - "index_type": "i64", - "key_names": ["name"], - "key_types": ["name"], - "table_id": 15326 - }, - { - "name": "uwfees", - "type": "uw_fee_row", - "index_type": "i64", - "key_names": ["underwriter"], - "key_types": ["name"], - "table_id": 43959 - }, - { - "name": "wireclaims", - "type": "wire_claim", - "index_type": "i64", - "key_names": ["account"], - "key_types": ["uint64"], - "table_id": 22082, - "secondary_indexes": [ - { - "name": "byexpiry", - "key_type": "uint128", - "table_id": 59144 - } - ] - } - ], - "ricardian_clauses": [], - "variants": [], - "action_results": [ - { - "name": "rewardbal", - "result_type": "uint64" - }, - { - "name": "rsvfeebal", - "result_type": "uint64" - }, - { - "name": "swapquote", - "result_type": "uint64" - }, - { - "name": "uwfeebal", - "result_type": "uint64" - } - ], - "enums": [ - { - "name": "ChainKind", - "type": "int32", - "values": [ - { - "name": "CHAIN_KIND_UNKNOWN", - "value": 0 - }, - { - "name": "CHAIN_KIND_WIRE", - "value": 1 - }, - { - "name": "CHAIN_KIND_EVM", - "value": 2 - }, - { - "name": "CHAIN_KIND_SVM", - "value": 3 - } - ] - }, - { - "name": "ReserveStatus", - "type": "int32", - "values": [ - { - "name": "RESERVE_STATUS_UNKNOWN", - "value": 0 - }, - { - "name": "RESERVE_STATUS_PENDING", - "value": 1 - }, - { - "name": "RESERVE_STATUS_ACTIVE", - "value": 2 - }, - { - "name": "RESERVE_STATUS_CANCELLED", - "value": 3 - } - ] - } - ] -} \ No newline at end of file diff --git a/contracts/sysio.reserv/sysio.reserv.wasm b/contracts/sysio.reserv/sysio.reserv.wasm deleted file mode 100755 index 36074312f93e208ad600480eb466ed2c9d95c4d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 89303 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 diff --git a/contracts/sysio.system/EMISSIONS.md b/contracts/sysio.system/EMISSIONS.md index 9f17581828..ddddb3ec8c 100644 --- a/contracts/sysio.system/EMISSIONS.md +++ b/contracts/sysio.system/EMISSIONS.md @@ -53,8 +53,7 @@ the CATEGORY, not the trace: carrying neither category. And `epochlog` does not recover the split either. It records top-level PERIOD aggregates -— `total_emission`, `compute_amount`, `capex_amount`, `governance_amount`, -`fee_distributed` — where `compute_amount` is the COMBINED producer + batch-operator +— `total_emission`, `compute_amount`, `capex_amount`, `governance_amount` — where `compute_amount` is the COMBINED producer + batch-operator pool. Neither operator category's actually-credited total is stored, so once both kinds of credit accumulate in one `payclaims` row, **the producer-versus-batch-operator split is not available anywhere on chain**. What you can attribute: period totals per top-level @@ -79,21 +78,16 @@ by the full group size). Equal rosters are coalesced before their weighted slice is calculated, preserving one group-level rounding step per roster. A credit is made only for members that are opreg-ACTIVE, so the slices of skipped (inactive / slashed / terminated) members stay in the treasury rather than being redistributed -to the active ones. When roster history is complete and includes at least one -non-empty roster, swap-fee rewards from `sysio.reserv`'s `rewards_bucket` are -swept in (`drainrewards`) and allocated -**exclusively to the batch-operator -distribution**, on top of their emission share and weighted by that same -historical active-epoch count. Producers are not paid out of swap fees, so -`producer_bps` / `batch_op_bps` govern the emission split only. +to the active ones. Batch operators are paid from emission only; no swap-fee +pool is folded into the distribution, so `producer_bps` / `batch_op_bps` split +the whole compute share. Allocated is not the same as paid: only **eligible** shares are actually credited. Emission WIRE stays in the treasury when an **empty historical roster** owns an accrued epoch, when a **member is not opreg-ACTIVE**, as the **remainder** of the two integer divisions (per-roster weighting, then the even per-member split), or when roster history is incomplete and the whole batch -emission slice takes the bounded recovery path. Incomplete or entirely empty -history leaves swap fees in `sysio.reserv` as described below. Every +emission slice takes the bounded recovery path. Every `batchepochs` row represents one accrued epoch, so a zero-epoch historical roster cannot arise. @@ -127,8 +121,7 @@ largest accepted period, so even a malformed gapped table cannot turn recovery into an unbounded transaction; stale history drains monotonically. If `payepoch` sees missing, stale, non-contiguous, or over-cap history, it does not guess a roster or abort the enclosing `advance`: it retains that period's -batch-emission slice in the treasury, leaves the swap-fee bucket in -`sysio.reserv` for the next complete period, drains the unusable history within +batch-emission slice in the treasury, drains the unusable history within the cleanup bound, and begins clean history after stale rows are gone. A complete history remains required to credit batch-operator rewards. Every `epochlog` row records whether history was complete and the exact batch-emission amount @@ -137,12 +130,9 @@ retained, making recovery distinguishable from an ordinary zero-eligible payout. `epoch_log_retention_count` counts payment rows, not elapsed epoch indexes, so cadence values greater than one retain the configured number of audit records. -`epochlog.fee_distributed` records what was actually paid, while -`batch_fee_retained` records a swept amount left in treasury because an empty -roster alongside a non-empty roster, an inactive member, or a division remainder -prevented its distribution. It is zero for incomplete or entirely empty history -because those fees remain in `sysio.reserv` for a later payable period. The -analogous `batch_emission_retained` field records undistributed batch emission. +`epochlog.batch_emission_retained` records undistributed batch emission. +`epochlog.fee_distributed` and `epochlog.batch_fee_retained` are reserved +fields that `payepoch` always writes as zero; no swap-fee sweep feeds them. ## Retrieved via a claim action (pulled by recipient) @@ -249,5 +239,4 @@ period_emission - Driven inline by `sysio.epoch::advance` (`accrueepoch` + `rcrdbatch` + `payepoch`). - Reads producer eligibility and operator status from `sysio.opreg`. - Reads the canonical epoch duration from `sysio.epoch::epochcfg`. -- Folds swap-fee rewards from `sysio.reserv` (`drainrewards`). - Funds `sysio.dclaim` (staking rewards) and `sysio.liq` (the yield kicker) on demand via `fundclaim`. diff --git a/contracts/sysio.system/src/emissions.cpp b/contracts/sysio.system/src/emissions.cpp index c9f9040af8..9559066077 100644 --- a/contracts/sysio.system/src/emissions.cpp +++ b/contracts/sysio.system/src/emissions.cpp @@ -58,9 +58,6 @@ constexpr sysio::name GOVERNANCE_ACCOUNT = "sysio.gov"_n; constexpr sysio::name CAPEX_OPERATIONS_ACCOUNT = "sysio.ops"_n; constexpr sysio::name TOKEN_CONTRACT = "sysio.token"_n; constexpr sysio::name ROA_CONTRACT = "sysio.roa"_n; -// sysio.reserv holds the swap-fee rewards bucket that payepoch folds into the -// per-epoch batch-operator distribution. -constexpr sysio::name RESERV_CONTRACT = "sysio.reserv"_n; namespace memo { constexpr std::string_view capital = "T5 capital"; @@ -144,34 +141,6 @@ int64_t get_wire_balance(name account) { return acct_tbl.get(key).balance.amount; } -// Local layout-compatible view of sysio.reserv's rewards_bucket singleton. A -// [[sysio::table]]-attributed struct cannot be shared into sysio.system's -// translation unit -- doing so corrupts this contract's read-only-action return -// codegen (getpeerkeys) -- so the layout is mirrored here. The kv row is keyed -// by table name ("rewardbkt") + the reserv account scope, so this reads the -// exact bytes reserv wrote. MUST stay in lockstep with sysio.reserv.hpp's -// rewards_bucket (balance, lifetime_accrued); the cross-contract read is -// exercised end-to-end by t5_emissions_tests/payepoch_folds_swap_fee_rewards, -// which fails if the two layouts ever diverge. -struct reserv_rewards_bucket { - uint64_t balance = 0; - uint64_t lifetime_accrued = 0; - SYSLIB_SERIALIZE(reserv_rewards_bucket, (balance)(lifetime_accrued)) -}; -using reserv_rewardbkt_t = sysio::kv::global<"rewardbkt"_n, reserv_rewards_bucket>; - -// Read the live swap-fee rewards balance held in sysio.reserv's custody. -// Returns 0 when never accrued. Clamps to asset::max_amount because the value is -// later carried as a sysio::asset (drained + transferred as WIRE); the bucket is -// backed by real WIRE so the clamp is only a defensive guard against accounting -// drift constructing an out-of-range asset. -int64_t get_reserv_rewards_balance() { - reserv_rewardbkt_t bkt(RESERV_CONTRACT); - const uint64_t bal = bkt.get_or_default(reserv_rewards_bucket{}).balance; - constexpr uint64_t max_amt = static_cast(sysio::asset::max_amount); - return bal > max_amt ? sysio::asset::max_amount : static_cast(bal); -} - // --------------------------------------------------------------------------- // Payout helpers // --------------------------------------------------------------------------- @@ -826,7 +795,6 @@ void system_contract::payepoch(uint32_t epoch_index, batchepochs_t batch_history(get_self()); std::vector recorded_batch_groups; bool batch_history_complete = accrued_epochs > 0; - bool has_nonempty_batch_roster = false; int64_t recorded_epochs = 0; uint32_t batch_payout_credits = 0; uint64_t expected_epoch_index = state.period_start_epoch; @@ -863,8 +831,6 @@ void system_contract::payepoch(uint32_t epoch_index, batch_history_complete = false; } else { batch_payout_credits = static_cast(credits_with_group); - has_nonempty_batch_roster = - has_nonempty_batch_roster || !it->members.empty(); recorded_batch_groups.push_back(recorded_batch_group{ .members = it->members, .active_epochs = 1, @@ -880,58 +846,11 @@ void system_contract::payepoch(uint32_t epoch_index, && recorded_epochs == accrued_epochs && expected_epoch_index == static_cast(epoch_index) + 1; - // ----- Swap-fee rewards fold-in ----- - // The BATCH-OPERATOR half of collected swap fees accrues in sysio.reserv's - // rewards_bucket. The other half accrues per-underwriter in sysio.reserv and - // is drawn by that account's own `claimuwfee` — it never passes through this - // treasury. (When reserv's `fee_emissions_share_bps` dial is non-zero, that - // configured share of the batch-op half is transferred straight to this - // account at collection time and never enters the bucket; the dial defaults - // to zero, leaving the whole half here.) Fold the whole bucket into THIS - // period's batch-operator distribution so batch ops receive it alongside - // emissions, weighted identically by active-epoch count. - // - // Producers are NOT paid out of swap fees: the fee compensates the parties - // that carry an individual swap — the underwriter who locks collateral for it - // and the batch operators who relay it — while producers earn emissions for - // securing the chain. So `producer_bps` / `batch_op_bps` govern the emission - // `compute_amount` split only, and the entire drained fee pool goes to the - // batch-op distribution below. - // - // The fee WIRE lives in sysio.reserv's custody. Sweep it only when immutable - // roster history is complete and at least one roster can receive a share; - // otherwise leave the bucket in reserv so a later payable period can - // distribute it. When swept, drainrewards is queued FIRST - // (ahead of every payout transfer): inline actions execute depth-first, so the - // drain -- and the reserv->sysio transfer it queues -- run to completion before - // any sibling payout queued after it, landing the WIRE in this account's balance - // first. MUST remain ahead of the first send_wire_transfer below. - // - // Fees are funded by that transfer, NOT the T5 treasury, so fee payouts are - // tracked in `fee_paid` and excluded from total_distributed (which governs - // the emission curve). After a sweep, any amount skipped for an empty roster - // alongside a non-empty one, non-ACTIVE members, or integer-division - // remainders stays in this treasury. Incomplete or all-empty history leaves - // the entire bucket in reserv. - int64_t fee_batch_pool = 0; - if (batch_history_complete && has_nonempty_batch_roster) { - fee_batch_pool = get_reserv_rewards_balance(); - if (fee_batch_pool > 0) { - sysio::action( - {get_self(), "active"_n}, - RESERV_CONTRACT, - "drainrewards"_n, - std::make_tuple(fee_batch_pool) - ).send(); - } - } - // "paid" here means DISTRIBUTED -- credited to `payclaims` for producers / standbys / // batch operators, transferred for the category buckets. Both leave the treasury's // spendable position, which is what these counters feed. int64_t actual_paid = 0; // emission actually distributed (counts toward total_distributed) int64_t batch_emission_paid = 0; - int64_t fee_paid = 0; // swap-fee rewards actually distributed (does NOT count toward treasury) // ======================================================================= // Producer + standby pay. @@ -1128,18 +1047,13 @@ void system_contract::payepoch(uint32_t epoch_index, const int64_t members = static_cast(group.size()); const int64_t group_pool = static_cast( static_cast<__int128>(batch_pool) * active_epochs / accrued_epochs); - const int64_t fee_group_pool = static_cast( - static_cast<__int128>(fee_batch_pool) * active_epochs / accrued_epochs); const int64_t per_member = group_pool / members; - const int64_t fee_per_member = fee_group_pool / members; for (const auto& m : group) { if (!is_op_active(m, OperatorType::OPERATOR_TYPE_BATCH)) continue; - // One credit carries both the emission and the fee share. - credit_pay(get_self(), m, per_member + fee_per_member, memo::batch_op_reward); + credit_pay(get_self(), m, per_member, memo::batch_op_reward); actual_paid += per_member; batch_emission_paid += per_member; - fee_paid += fee_per_member; } }; @@ -1155,7 +1069,6 @@ void system_contract::payepoch(uint32_t epoch_index, } const int64_t batch_emission_retained = batch_pool - batch_emission_paid; - const int64_t batch_fee_retained = fee_batch_pool - fee_paid; // A pay period is the history lifetime. Clear only after an actual accrued // period: on the defensive zero-accrual path the history is preserved rather @@ -1240,10 +1153,10 @@ void system_contract::payepoch(uint32_t epoch_index, .compute_amount = compute_amount, .capex_amount = capex_amount, .governance_amount = governance_amount, - .fee_distributed = fee_paid, + .fee_distributed = 0, // Reserved legacy audit field. .batch_history_complete = batch_history_complete, .batch_emission_retained = batch_emission_retained, - .batch_fee_retained = batch_fee_retained, + .batch_fee_retained = 0, // Reserved legacy audit field. }); // Head-first prune of the audit log past its retention cap. Rows are added diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index e6aee464b8b8b2bd96627ba9884a3dff7545f4f3..26a5ad5f60e02a1eba179fa8ed238147506fdfc9 100755 GIT binary patch delta 7075 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 delta 7694 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 diff --git a/contracts/sysio.uwrit/CMakeLists.txt b/contracts/sysio.uwrit/CMakeLists.txt deleted file mode 100644 index 840bca2357..0000000000 --- a/contracts/sysio.uwrit/CMakeLists.txt +++ /dev/null @@ -1,50 +0,0 @@ -set(contract_name sysio.uwrit) -bootstrap_contract(${contract_name}) - -if(BUILD_SYSTEM_CONTRACTS) - find_cdt_magic_enum() - file(GLOB_RECURSE SOURCES src/*.cpp) - file(GLOB_RECURSE HEADERS include/*.hpp) - add_contract(${contract_name} ${contract_name} ${SOURCES}) - set(targets ${contract_name}) - - if("native-module" IN_LIST SYSIO_WASM_RUNTIMES) - list(APPEND targets ${contract_name}_native) - add_native_contract( - TARGET ${contract_name}_native - SOURCES ${SOURCES} - INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/include - CONTRACT_CLASS "sysio::uwrit" - HEADERS ${HEADERS} - ABI_FILE ${CMAKE_BINARY_DIR}/contracts/${contract_name}/${contract_name}.abi - ) - endif() - - foreach(target ${targets}) - if(NOT TARGET ${target}) - message(WARNING "Target ${target} not found, skipping include directory setup") - continue() - endif() - - target_include_directories(${target} - PUBLIC - $ - $ - $ - $ - $ - $ - $ - $ - $ - $ - $ - $ - ) - - target_link_libraries(${target} - INTERFACE - magic_enum::magic_enum - ) - endforeach() -endif() diff --git a/contracts/sysio.uwrit/README.md b/contracts/sysio.uwrit/README.md deleted file mode 100644 index 5a51475a5b..0000000000 --- a/contracts/sysio.uwrit/README.md +++ /dev/null @@ -1,101 +0,0 @@ -# sysio.uwrit - -Underwriting ledger and swap lifecycle contract. Owns the underwriter COMMIT -race, the collateral lock vector, the swap-from-WIRE escrow queue, and the fee -rate every swap is charged. - -## Responsibility - -- Ingests `SWAP_REQUEST` attestations from outposts and opens an underwrite - request (`uwreqs`) for each. -- Resolves the underwriter COMMIT race: verifies each candidate's signature - against their WIRE account permissions, re-runs the LP variance check, - re-checks available collateral, then picks a winner. -- Writes one collateral lock per required leg and holds it for the full - wall-clock challenge window. **Locks are never released by delivery** — on the - healthy path only `chklocks` sweeps them, once `expires_at_ms` passes. An - UPHELD underwriter-fault challenge ends the window early instead: `sweeplocks` - erases that commitment's locks where they stand, which can be well before - `expires_at_ms`. -- Settles the winning swap against `sysio.reserv` and queues the outbound - `SWAP_REMIT`. -- Escrows and drains swap-from-WIRE requests (`fwqueue`), charging a revert fee - on caller-fault drain failures. -- Owns the swap fee RATE (`uwconfig.fee_bps`). The fee is charged and - distributed by `sysio.reserv` — see "Fees" below. - -## Fees - -`uwconfig.fee_bps` (default 10 = 0.1%) is the **network fee**, taken out of the -**WIRE leg** of every swap. It is not the whole effective fee: each participating -non-WIRE leg's reserve independently charges its own `owner_fee_bps` -(`sysio.reserv::setrsvfee`, in `[MIN_OWNER_FEE_BPS, MAX_OWNER_FEE_BPS]` or zero). -A chain-to-chain swap therefore pays two owner fees plus the network fee; a swap -against a WIRE endpoint pays one plus the network fee. All of them come off the -same WIRE leg, so together they reduce what the recipient receives. - -`sysio.reserv` routes each part as follows — the owner fees to their reserves, -and the network fee through a **two-stage** split: - -| Part | Recipient | Path | -|---|---|---| -| Each reserve's `owner_fee_bps` | That **reserve's owner** | Accrues to the reserve row's `owner_fee_accrued`; drawn by `sysio.reserv::claimrsvfee` | -| 50% of the **network fee** | The swap's **winning underwriter** | Accrues to `sysio.reserv::uwfees`; drawn by that account's own `sysio.reserv::claimuwfee` | -| The other 50% — the **rewards pool** — less `fee_emissions_share_bps` | **Batch operators** | Accrues to `sysio.reserv::rewardbkt`; swept by `sysio.system::payepoch` into the batch-op distribution | -| `fee_emissions_share_bps` of the **rewards pool** | The `sysio` **emissions treasury** | Transferred out at settlement by `route_wire_fee` | - -The 50/50 network split is the fixed `sysio.reserv::FEE_UNDERWRITER_SHARE_BPS`; -the stage-2 share is the governance dial `reserve_config.fee_emissions_share_bps`, -which **defaults to zero**. At that default every part stays in `sysio.reserv`'s -WIRE custody until claimed or drained and no part of a swap fee reaches the -emissions treasury — but a non-zero dial diverts that share of the rewards pool -to the treasury at settlement, so the custody statement holds only at the -default. Producers are never paid out of swap fees at any setting. - -`uwconfig.fromwire_revert_fee_bps` is charged on the refunded escrow when a -queued from-WIRE swap reverts at drain for a cause the caller controls -(unpriceable target, variance tolerance exceeded). A revert has no winning -underwriter, so the whole revert fee becomes the rewards pool — reaching the -rewards bucket in full under the default zero `fee_emissions_share_bps`, less the -configured emissions share when that dial is set. Reverts caused -by system state changes after enqueue (reserve deactivated, flipped private, -chain deregistered) refund in full. - -## Tables - -| Table | Row type | Description | -|-------|----------|-------------| -| `uwconfig` | `uw_config` | Singleton: `fee_bps`, `collateral_lock_duration_ms`, `min_fromwire_amount`, `fromwire_revert_fee_bps`, `uwreq_pending_timeout_epochs`, `uwreq_retention_epochs` | -| `uwreqs` | `uw_request_t` | One row per swap intent — race state in `commits_by`, `winner`, lifecycle status, mirrored `variance_tolerance_bps`. Retained for `uwreq_retention_epochs` after ANY terminal transition — `COMPLETED` (once the last collateral lock is gone — normally when `chklocks` sweeps it at expiry, but earlier when `sweeplocks` erases it on an UPHELD challenge; both run the same `finalize_settled_uwreqs` tail. The reserve settlement itself already happened at winner selection, which is what made the row CONFIRMED), `REJECTED` (immediate failure via `reject_and_refund`), or `EXPIRED` (pending timeout, same path) — then erased by `pruneuwreqs` | -| `locks` | `lock_entry` | Flat per-leg lock vector — the AUTHORITY for locked collateral. `sysio.opreg::available()` does not scan it; it reads the `locksums` cache below. The `byexpire` secondary index lets `chklocks` sweep expired locks oldest-first, up to its per-epoch budget | -| `locksums` | `lock_sum` | Materialized Σ `lock_entry.amount` per `(underwriter, chain_code, token_code)` bucket — the "locked" half of `sysio.opreg::available()`, read O(1) instead of scanning `locks`. Written by exactly three paths, all in this contract: `try_select_winner` ADDS (on a win), `chklocks` DECREMENTS (healthy release at expiry), and `sweeplocks` DECREMENTS (erasing a commitment's held locks on an UPHELD challenge). A bucket's row is erased once its total reaches zero, so an absent row reads as zero. Any new erase path must decrement too — the rollup is authoritative for `available()`, so a bucket left positive after its last row is gone suppresses that collateral permanently | -| `fwqueue` | `fromwire_q` | Escrowed swap-from-WIRE requests awaiting drain. `byepoch` secondary index | -| `uwcounters` | `uw_counters` | Monotonic id allocators (uwreq ids, lock ids) | - -## Actions - -| Action | Auth | Description | -|--------|------|-------------| -| `setconfig` | `sysio.uwrit` | Set the fee rate, lock duration, from-WIRE floor, revert fee, and uwreq lifecycle windows | -| `createuwreq` | `sysio.msgch` | Open an underwrite request from an inbound `SWAP_REQUEST` attestation | -| `rcrdcommit` | `sysio.msgch` | Record an underwriter's per-leg `UNDERWRITE_INTENT_COMMIT` bytes; resolves the race once both legs are present | -| `swapfromwire` | `user` | Escrow WIRE and enqueue a swap-FROM-WIRE request | -| `drainfwq` | `sysio.epoch` or self | Drain the from-WIRE queue: settle what prices, revert the rest (charging the revert fee on caller-fault causes) | -| `chklocks` | `sysio.epoch` or self | Sweep collateral locks whose wall-clock window has expired, oldest-first, EXAMINING at most `max_rows` rows per call (`advance` passes `MAX_LOCK_RELEASE_PER_EPOCH`) — the budget counts rows examined rather than locks released, so held locks and the challenge pokes they generate are bounded by it too; an oversized expiry burst drains across later epochs rather than aborting `advance` | -| `pruneuwreqs` | `sysio.epoch` or self | Expire timed-out PENDING uwreqs and erase terminal rows past their retention window | -| `holdlocks` | `sysio.chalg` | Mark a commitment's winning locks as held by an OPEN underwriter-fault challenge (WIRE-297); held locks are skipped by `chklocks` instead of released | -| `freelocks` | `sysio.chalg` | Clear the hold after a REJECTED or LAPSED challenge, so the next `chklocks` releases the locks normally | -| `sweeplocks` | `sysio.chalg` | Erase a commitment's held locks after an UPHELD challenge — the underwriter is already SLASHED, so each `releaselock` takes its deferred-slash branch. Decrements `locksums` like `chklocks` does | -| `sumlocks` | read-only | Sum an underwriter's active locks for a `(chain, token)` bucket — the lock half of `sysio.opreg::available()` | - -## Dependencies - -- Receives inbound attestations via `sysio.msgch`; queues outbound `SWAP_REMIT` - / `SWAP_REVERT` through it. -- Settles against `sysio.reserv` (`applyswap` / `applyfromwire` / `paywire` / - `refundwire`), forwarding the winning underwriter so the fee accrues to them. -- Reads collateral from `sysio.opreg`; its `locksums` rollup — the O(1) cache - over the authoritative `locks` table — is the lock half of that contract's - `available()`. -- Lock sweeping and queue draining are inlined from `sysio.epoch::advance`. -- The off-chain counterpart is `wire-sysio/plugins/underwriter_plugin/`. diff --git a/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp b/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp deleted file mode 100644 index f8b85cccae..0000000000 --- a/contracts/sysio.uwrit/include/sysio.uwrit/sysio.uwrit.hpp +++ /dev/null @@ -1,1007 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace sysio { - - /** - * @brief sysio.uwrit — underwriter race resolver + flat lock vector. - * - * Per the data-model refactor (`load-context-and-follow-smooth-flame.md` - * §3.13, §4.5, §4.6): - * - * - opreg owns the bond ledger (per-(operator, chain_code, token_code) aggregate - * balance). uwrit owns the **lock vector** — one row per leg of every - * in-flight UWREQ, plus `locksums` — the per-(underwriter, chain_code, - * token_code) materialized total of those rows. `locks` is the - * authority; `locksums` is the O(1) read cache, and it is what opreg's - * `available()` reads cross-contract to subtract active locks from the - * operator's spendable balance. - * - * - Identity has been rekeyed onto `sysio::slug_name` (uint64). Each - * `lock_entry` carries `(chain_code, token_code, reserve_code)`; the - * `reserve_code` records which specific reserve this leg is bound to - * so a slash-to-reserve hop on a same-(chain, token) pair with - * multiple reserves can route unambiguously. `uw_request_t` carries - * `src_*` and `dst_*` slug_name triples for the same reason. - * - * - The per-underwriter composite lock key can no longer fit in a - * `uint128_t` (3 × uint64 = 192 bits), so per the plan's B.2 design the - * table carries only uint64 secondary indexes and the composite lives - * outside them: - * * `compose_account_chain_token_ck` — `checksum256(account || - * chain_code || token_code)`, computed on the row - * by `lock_entry::by_underwriter_ck()` and used - * as the `locksums` primary key. NOT a - * table-managed secondary index. - * * `byuw` — uint64 split-index keyed on `underwriter.value` - * for cheap per-operator scans (in-memory filter - * on chain_code / token_code / reserve_code). - * * `byuwreq`, `byexpire` — uint64 indexes on `uwreq_id` and - * `expires_at_ms`; the latter is the window - * `chklocks` sweeps. - * - * - On `UNDERWRITE_INTENT_COMMIT` arrival (one per outpost; underwriters - * call `commit(...)` JSON-RPC on each side), `record_commit` registers - * the per-leg arrival in `uwreqs.commits_by`. When BOTH legs land for - * the same underwriter, `try_select_winner` checks the underwriter's - * `sysio.opreg::available(...)` for each chain; if both legs are - * covered, the underwriter wins, two rows are pushed onto `locks`, and - * a `REMIT` is queued to the destination outpost. - * - * - opreg.balance is **not** mutated when a lock is added — the lock - * simply reduces what `available()` rolls up. When a lock releases: - * * SLASHED underwriter — opreg::releaselock decrements balance - * and emits SLASH_OPERATOR (deferred-slash). - * * TERMINATED underwriter — opreg::releaselock decrements balance - * and emits WITHDRAW_REMIT to authex - * destination (deferred-remit). - * * Healthy underwriter — no opreg call; freed amount naturally - * reappears in `available()` once the lock - * row is erased. - */ - class [[sysio::contract("sysio.uwrit")]] uwrit : public contract { - public: - using contract::contract; - - // Well-known accounts - static constexpr name EPOCH_ACCOUNT = "sysio.epoch"_n; - static constexpr name MSGCH_ACCOUNT = "sysio.msgch"_n; - static constexpr name AUTHEX_ACCOUNT = "sysio.authex"_n; - static constexpr name OPREG_ACCOUNT = "sysio.opreg"_n; - static constexpr name CHAINS_ACCOUNT = "sysio.chains"_n; - static constexpr name CHALG_ACCOUNT = "sysio.chalg"_n; - static constexpr name RESERVE_ACCOUNT = "sysio.reserv"_n; - static constexpr name TOKEN_ACCOUNT = "sysio.token"_n; - - // Default number of epochs a TERMINAL (COMPLETED / REJECTED / EXPIRED) - // UWREQ row lives before `pruneuwreqs` erases it. 10 epochs matches the - // bootstrap doc's "losers retained 10 epochs for debugging" requirement. - // Live value is `uwconfig.uwreq_retention_epochs` (retunable via - // `setconfig`); this constant is only the fresh-chain default. - static constexpr uint32_t DEFAULT_UWREQ_RETENTION_EPOCHS = 10; - - // Default number of epochs a PENDING UWREQ may wait for its underwriter - // race to resolve before `pruneuwreqs` expires it (refund/revert + flip - // to UNDERWRITE_REQUEST_STATUS_EXPIRED). A pending request only resolves - // if underwriter commits arrive — an external, optional event — so - // without this deadline an abandoned request is indistinguishable from a - // slow one forever and its row (plus the user's escrowed/deposited - // funds) lingers indefinitely (SEC-129 / WSA-223). Live value is - // `uwconfig.uwreq_pending_timeout_epochs` (retunable via `setconfig`). - static constexpr uint32_t DEFAULT_UWREQ_PENDING_TIMEOUT_EPOCHS = 10; - - // Ceiling `setconfig` accepts for BOTH epoch-count lifecycle knobs - // (pending timeout + retention). Prevents a misconfigured value near - // UINT32_MAX from wrapping the `current_epoch + knob` deadline stamp to - // a tiny epoch index (instant expiry/prune). One million epochs is ~2 - // years at the 60s test cadence and far beyond any real retention need, - // while `current_epoch + 1'000'000` stays astronomically below the - // uint32 wrap point for any reachable epoch index. - static constexpr uint32_t MAX_UWREQ_LIFECYCLE_EPOCHS = 1'000'000; - - // Rows `sysio.epoch::advance` passes to `pruneuwreqs` each epoch. Sized - // like MAX_FWQ_DRAIN_PER_EPOCH (and for the same reason): the sweep runs - // inline inside advance's hard, uncatchable transaction CPU deadline, so - // per-epoch work must be bounded; a backlog simply drains across - // subsequent epochs. - static constexpr uint32_t MAX_UWREQ_PRUNE_PER_EPOCH = 32; - - // Locks `sysio.epoch::advance` passes to `chklocks` each epoch. Sized - // like MAX_UWREQ_PRUNE_PER_EPOCH / MAX_FWQ_DRAIN_PER_EPOCH, and for the - // same reason — but the exposure here is sharper than either, because - // lock EXPIRY is inherently bursty. Every lock is stamped - // `now + collateral_lock_duration_ms` at creation, so a burst of - // settlements inside one epoch produces a burst of expiries inside one - // epoch, exactly one lock-duration later. Sustained swap traffic - // therefore presents `chklocks` with a whole epoch's settlements at - // once, and per expired lock the sweep does an inline - // `opreg::releaselock` dispatch plus an erase — all inside advance's - // hard, uncatchable transaction CPU deadline. - // - // Unbounded, a large enough expiry burst aborts `advance`; and because - // those same locks are still expired at the next advance, it aborts - // identically every epoch thereafter — a PERMANENT chain-wide epoch - // stall rather than a transient one. Bounded, an oversized burst is - // just release latency that drains across subsequent epochs, which is - // harmless: the challenge window has already closed, so a lock freed an - // epoch or two late costs only a brief overstatement of the - // underwriter's reserved collateral. - static constexpr uint32_t MAX_LOCK_RELEASE_PER_EPOCH = 32; - - // ── UWREQ row-growth rails (SEC-129 / WSA-223) ───────────────────────── - // Every uwreqs `modify` re-serializes the whole row inside the - // never-throw evalcons / advance dispatch surfaces, so per-field byte - // caps + a total-row guard keep the serialized row from approaching - // database value limits (where a later consensus-dispatched modify - // would abort and stall the chain). All are protocol backstops sized - // generously above legitimate traffic — violations log + skip, never - // check(). - - // Per-candidate cap on `uw_request_t.commits_by`. rcrdcommit already - // gates entries on ACTIVE underwriters; this bounds the row even if the - // ACTIVE roster grows large. - static constexpr uint32_t MAX_UWREQ_CANDIDATES = 32; - - // Per-leg cap on a stored verbatim `UnderwriteIntentCommit` payload - // (`commit_entry.source_uic_bytes` / `dest_uic_bytes`). A real UIC is a - // handful of ids + codes + a signature `verify_uic_signature` already - // bounds to 1024 bytes — 2 KiB is ~2x the largest legitimate encoding. - static constexpr uint32_t MAX_UIC_LEG_BYTES = 2048; - - // Cap on the stored inbound SwapRequest payload - // (`uw_request_t.attestation_inbound_data`) at createuwreq. - static constexpr uint32_t MAX_ATTESTATION_INBOUND_DATA_BYTES = 4096; - - // Cap on `SwapRequest.source_tx_id` (largest real id today is a 64-byte - // Solana signature; 2x headroom for future chains). - static constexpr uint32_t MAX_SOURCE_TX_ID_BYTES = 128; - - // Cap on the depositor address bytes (`SwapRequest.actor.address`). - // Mirrors swapfromwire's existing 64-byte `recipient_addr` cap — the - // platform's notion of a maximum chain address. - static constexpr uint32_t MAX_DEPOSITOR_BYTES = 64; - - // Belt-and-braces ceiling on the projected serialized uwreqs row before - // any growing `modify`. Structural worst case under the caps above - // (32 candidates x 2 x 2 KiB legs + 4 KiB attestation + fixed fields) - // is ~135 KiB, comfortably inside this guard; the guard exists so no - // future field addition can silently push a row toward chain KV value - // limits. - static constexpr uint32_t MAX_UWREQ_ROW_BYTES = 262'144; - - // Safety rails on the depot-originated swap-from-WIRE queue (SEC-77 / - // WSA-165). `swapfromwire` is public and escrows only the caller's WIRE, - // so without bounds a caller could split real WIRE into an unbounded - // number of system-paid `fwqueue` rows and force `drainfwq` to process - // them all inside one `sysio.epoch::advance` transaction. That - // transaction's CPU budget (~150 ms) is a hard, uncatchable deadline, so - // an oversized queue would abort every advance and permanently stall - // epoch progress chain-wide. - // - // MAX_FWQ_DRAIN_PER_EPOCH bounds the rows drained per advance; undrained - // rows stay queued (escrow safe in reserve custody) and drain a later - // epoch, converting a potential halt into bounded drain latency. - // Ingress is already throttled economically — every `swapfromwire` is a - // `require_auth(user)` transaction that bills the caller CPU/NET and - // escrows real WIRE per row — so the drain bound alone is the liveness - // rail; there is no per-caller row cap. Conservatively sized to stay well - // under the transaction CPU ceiling shared with chklocks / buildenv / - // emissions; raise it (contract upgrade) only if legitimate from-WIRE - // throughput approaches the bound. - static constexpr uint32_t MAX_FWQ_DRAIN_PER_EPOCH = 32; - - // Economic ingress rails on `swapfromwire`, complementing the drain - // bound above. Both are live config (`uwconfig`) retunable via - // `setconfig` without a contract upgrade; the values here are only the - // defaults a fresh chain starts from. - // - // DEFAULT_MIN_FROMWIRE_AMOUNT floors the escrow per queued row (5 WIRE - // at 9 decimals). Dust rows are what made queue spam nearly free: the - // escrow comes back in full at drain, so 1-atomic rows could hold drain - // slots while locking no meaningful capital — and sat below the Bancor - // kernel's pricing floor, guaranteeing zero-quote refunds. With the - // floor, sustaining a D-row backlog keeps D x floor WIRE locked for the - // full queue latency the spammer themselves creates. - // - // DEFAULT_FROMWIRE_REVERT_FEE_BPS is charged on the refunded escrow - // when a queued row reverts at drain for a cause the caller controls - // (unpriceable target / variance tolerance exceeded). The fee is routed - // through the same rewards/emissions split as settlement fees - // (`opp::amm::split_wire_fee` + `sysio.reserv::route_wire_fee`), so - // revert churn pays the system instead of recycling for free. Reverts - // caused by system state changes after enqueue (reserve deactivated, - // flipped private, chain deregistered) refund in full — the caller did - // nothing wrong. Successful swaps are unaffected; they already pay - // `fee_bps` at settlement. - // - // 500 bps (5%) is the launch default (Jonathan, 2026-08-04), replacing the - // 0.1% placeholder that merely mirrored `fee_bps`. It has to be large - // enough that cycling escrows through the queue is not free. It stays - // governance-tunable via `setconfig` (capped at MAX_FEE_BPS), so this is a - // starting point, not a commitment. - static constexpr uint64_t DEFAULT_MIN_FROMWIRE_AMOUNT = 5'000'000'000; // 5 WIRE @ 9 decimals - static constexpr uint32_t DEFAULT_FROMWIRE_REVERT_FEE_BPS = 500; // 5% - - // Maximum accepted swap fee, in basis points. A 100% fee (10000 bps) - // would zero the post-fee WIRE leg of every swap (`net == 0` in - // `opp::amm::split_wire_fee`), which let a from-WIRE or token-to-token - // swap debit destination reserve liquidity while crediting zero WIRE — - // draining the reserve at an arbitrary price (SEC-26 / WSA-042). Any fee - // below 100% leaves a positive remainder for every positive input, so the - // cap is 9999 (mirrors `sysio.reserv::MAX_CONNECTOR_WEIGHT_BPS`). - // - // This bounds THIS rate only. Reserve owner fees are charged off the same - // WIRE leg and capped independently, so the TOTAL can still reach 100% — - // 9999 here plus one owner fee at `sysio.reserv::MIN_OWNER_FEE_BPS` (1) - // does exactly that. The settlement paths' `net > 0` checks are therefore - // live rejections of a configured combination, not dead defense-in-depth; - // see `opp::amm::split_wire_fee`. - static constexpr uint32_t MAX_FEE_BPS = 9999; - - // Upper bound on the collateral lock duration. try_select_winner locks a - // winning underwriter's collateral until `now_ms + collateral_lock_duration_ms`; - // an unbounded duration near UINT64_MAX wraps that sum to a past timestamp, - // releasing the lock immediately. 365 days is far beyond any real settlement - // window yet leaves ~18 orders of magnitude of headroom below the wrap point. - static constexpr uint64_t MAX_COLLATERAL_LOCK_DURATION_MS = - 365ull * 24ull * 60ull * 60ull * 1000ull; - - // ----------------------------------------------------------------------- - // Actions - // ----------------------------------------------------------------------- - - /// Set underwriting fee + lock config. Fields: - /// * `fee_bps` — the NETWORK swap fee charged by the depot per spoke, - /// taken out of the WIRE leg of every swap (so the ETH/SOL the - /// recipient can receive is reduced). It is NOT the whole effective - /// fee: each participating non-WIRE leg's reserve independently charges - /// its own `owner_fee_bps` off the same leg (`sysio.reserv::setrsvfee`). - /// `sysio.reserv` splits the NETWORK component 50/50 between the swap's - /// winning underwriter (claimable via `sysio.reserv::claimuwfee`) and a - /// rewards pool (see `sysio.reserv::FEE_UNDERWRITER_SHARE_BPS`). That - /// pool then splits again by `reserve_config.fee_emissions_share_bps`: - /// that share is transferred to the `sysio` emissions treasury and the - /// remainder accrues to the rewards bucket, which - /// `sysio.system::payepoch` allocates to the batch-operator - /// distribution (paying only eligible shares; the rest stays in the - /// treasury). The dial defaults to zero, so by default the whole pool - /// is allocated to batch operators and no part of a fee leaves - /// `sysio.reserv`'s custody at settlement. - /// * `collateral_lock_duration_ms` — wall-clock milliseconds after - /// `lock_entry.created_at_ms` that the lock auto-expires (swept by - /// `sysio.epoch::advance -> chklocks`). This is the challenge - /// window: collateral stays locked for its full duration — it is - /// never released by delivery. Default 43,200,000 (12 hours); - /// test clusters shorten it via this action. - /// * `min_fromwire_amount` — minimum WIRE escrow (9-decimal atomic - /// units) `swapfromwire` accepts; rows below the floor are rejected - /// at enqueue. Must be positive. Default 5 WIRE. - /// * `fromwire_revert_fee_bps` — fee on the refunded escrow when a - /// queued from-WIRE swap reverts at drain for a caller-controlled - /// cause (see `drainfwq`); routed exactly like the settlement fee. - /// Capped at MAX_FEE_BPS so the post-fee refund stays positive. - /// * `uwreq_pending_timeout_epochs` — epochs a PENDING uwreq may wait - /// for its race to resolve before `pruneuwreqs` expires it - /// (refund/revert + EXPIRED). 1..MAX_UWREQ_LIFECYCLE_EPOCHS. - /// * `uwreq_retention_epochs` — epochs a terminal (COMPLETED / - /// REJECTED / EXPIRED) uwreq row is retained for audit before - /// `pruneuwreqs` erases it. 1..MAX_UWREQ_LIFECYCLE_EPOCHS. - [[sysio::action]] - void setconfig(uint32_t fee_bps, - uint64_t collateral_lock_duration_ms, - uint64_t min_fromwire_amount, - uint32_t fromwire_revert_fee_bps, - uint32_t uwreq_pending_timeout_epochs, - uint32_t uwreq_retention_epochs); - - /// Called inline from `sysio.msgch::dispatch` when a SWAP attestation - /// arrives. Decodes the SwapRequest, prices the swap on the depot's live - /// curve (`sysio.reserve::swapquote`'s kernel), runs the - /// variance-tolerance check, and either: - /// * creates an OPEN UWREQ whose `dst_amount` is that QUOTE — never the - /// caller's `target_amount` (WNS-02) — or - /// * emits a SWAP_REVERT back to `chain_code` and skips UWREQ creation - /// when the gap between `target_amount` and the depot's current quote - /// exceeds `target_tolerance_bps` of that quote, or when every - /// required reserve is ACTIVE yet the curve cannot price the swap. - /// - /// `chain_code` is the source outpost the SWAP came from — needed so - /// the SWAP_REVERT routes back to the user's depositing outpost on - /// variance failure. - [[sysio::action]] - void createuwreq(uint64_t attestation_id, - opp::types::AttestationType type, - uint64_t chain_code, - std::vector data); - - /// Called inline from `sysio.msgch::dispatch` when an - /// UNDERWRITE_INTENT_COMMIT attestation arrives. Pre-validates the - /// claimed underwriter's fixed-size recoverable signature before - /// changing candidate evidence. Invalid claims are logged and ignored: - /// they cannot replace stored bytes, change status/reason, or refresh - /// arrival timestamps. A valid replay of an already-recorded leg never - /// replaces the stored bytes: candidate legs are write-once. Once both - /// legs are stored, an exact replay revalidates the preserved evidence - /// and retries winner selection against current mutable depot state. - /// When both legs - /// land for the same underwriter, runs `try_select_winner` to resolve the - /// race. For a dual-outpost request it revalidates only the older stored - /// leg so a WIRE permission-key change between arrivals cannot authorize - /// stale evidence; the just-verified incoming leg is not recovered twice. - /// A complete candidate is evaluated immediately. Recoverable conditions - /// (temporarily unavailable collateral, identity links, or reserves) keep - /// its evidence on the PENDING row until an outpost replays one of the - /// exact stored UICs; malformed or no-longer-authorized stored evidence is - /// durably disqualified. Replayed UIC bytes never replace stored legs. - /// - /// `(from_chain_code, from_token_code, reserve_code)` together identify - /// which leg of the swap this UIC covers. Same-chain swaps with - /// multiple reserves of the same `(chain, token)` need all three - /// codes to disambiguate src vs dst. - /// - /// `uic_bytes` is the raw zpp_bits-encoded `UnderwriteIntentCommit` - /// payload — the action signature carries bytes, not the proto - /// message itself, per `feedback_no_proto_messages_in_actions.md`. - [[sysio::action]] - void rcrdcommit(uint64_t uwreq_id, - name underwriter, - uint64_t chain_code, - sysio::slug_name from_chain_code, - sysio::slug_name from_token_code, - sysio::slug_name reserve_code, - std::vector uic_bytes); - - /// Sweep all `locks` rows whose `expires_at_ms` has elapsed. Inlined - /// from `sysio.epoch::advance` (as one of its FIRST steps — freshly - /// freed collateral must be visible to the same advance's withdraw - /// flushing); can also be invoked by `sysio.uwrit` itself for manual - /// cleanup. The `byexpire` secondary index walks rows in ascending - /// expiry, so the loop stops at the first unexpired row. - /// - /// For every expired lock the sweep inlines `opreg::releaselock` - /// (deferred-slash for SLASHED underwriters, deferred-remit for - /// TERMINATED, no-op for healthy) and erases the row. When the last - /// lock of a CONFIRMED uwreq is swept, the uwreq flips to COMPLETED - /// (delivery itself is implicit — there is no SWAP_REMIT ack; the - /// lock window expiring IS the settlement horizon). - /// - /// This sweep is the only HEALTHY lock-release path: locks are a - /// wall-clock challenge window (12h default) and are never released by - /// delivery. It is not the only path that ERASES a lock — `sweeplocks` - /// does too, on an UPHELD challenge (see `lock_sum`, whose rollup both - /// must maintain). - /// - /// EXCEPTION (WIRE-297): a lock whose `challenge_id` is non-zero — an - /// underwriter-fault challenge is OPEN against its commitment — is NOT - /// released at expiry. The sweep skips it and instead pokes - /// `sysio.chalg::chkuwchal`, whose resolution either sweeps the locks - /// with the underwriter slashed (`sweeplocks`) or clears the hold - /// (`freelocks`) so the NEXT sweep releases them normally. The epoch - /// tick is thereby the challenge system's only cadence. - /// - /// Budget-bounded, mirroring `pruneuwreqs` / `drainfwq`: walks the - /// `byexpire` index in ascending `expires_at_ms` and EXAMINES at most - /// `max_rows` rows (`max_rows == 0` is a no-op). Inlined from - /// `sysio.epoch::advance` with `MAX_LOCK_RELEASE_PER_EPOCH`; also - /// invocable by `sysio.uwrit` itself with a caller-chosen budget for a - /// manual backlog drain. NEVER throws past the auth gate: it runs inline - /// inside `advance`, where an abort stalls epoch progress chain-wide. - /// - /// The budget counts rows EXAMINED, not locks released — held locks and - /// the challenge pokes they generate cost real work too. Bounding only - /// releases would let an arbitrary number of held rows be scanned and an - /// arbitrary number of distinct challenges fan out an inline - /// `chkuwchal` each, which is exactly the unbounded `advance` work this - /// bound exists to remove. `open_challenges` is therefore bounded by - /// `max_rows` as a consequence of the same counter. - /// - /// Ascending-expiry order makes the bound a FIFO drain, so an oversized - /// burst simply spreads across subsequent epochs. Held locks are the one - /// thing that can sit at the head of that queue without leaving it: they - /// are skipped, not erased, so while more than `max_rows` challenges are - /// open the rows behind them wait. That is bounded and self-clearing - /// rather than a stall — each sweep pokes the challenges it can see, and - /// a resolved challenge removes its locks (`sweeplocks`) or clears their - /// hold (`freelocks`), letting the window advance on the next tick. - - [[sysio::action]] - void chklocks(uint32_t max_rows); - - /// Mark the winning underwriter's locks for `uwreq_id` as held by the OPEN underwriter- - /// fault challenge `chal_id` (WIRE-297). Auth: `sysio.chalg`, inlined from `openuwchal`. - /// This is the AUTHORITATIVE liveness validation for a filing — every lock must still be - /// inside its window and free of any other challenge, and at least one lock must exist — - /// so a stale challenge aborts whole here, bond escrow included. - [[sysio::action]] - void holdlocks(uint64_t uwreq_id, name underwriter, uint64_t chal_id); - - /// Clear the challenge hold on the underwriter's locks for `uwreq_id` — the challenge was - /// REJECTED by the council or LAPSED at the window's end. The locks then release on their - /// next normal `chklocks` sweep: a healthy release, no collateral moves. Auth: - /// `sysio.chalg`. Deliberately a silent no-op when nothing is held: it runs inline from - /// the epoch tick (`chklocks` -> `chkuwchal` -> here), where an abort would stall epoch - /// advancement chain-wide. - [[sysio::action]] - void freelocks(uint64_t uwreq_id, name underwriter); - - /// Release + erase the underwriter's locks for `uwreq_id` after an UPHELD challenge. The - /// underwriter is already SLASHED (`chkuwchal` slashes before sweeping — inline actions - /// run depth-first in send order), so every inlined `opreg::releaselock` takes its - /// deferred-slash branch: the locked collateral is debited and the outbound SLASH - /// attestation queued. Ends with the same COMPLETED-flip / evidence-clear / retention - /// tail `chklocks` runs when a uwreq's last lock leaves. Auth: `sysio.chalg`; silent - /// no-op when no locks remain (same never-throw-inside-advance reasoning as `freelocks`). - [[sysio::action]] - void sweeplocks(uint64_t uwreq_id, name underwriter); - - /// Bounded UWREQ lifecycle sweep (SEC-129 / WSA-223). Inlined from - /// `sysio.epoch::advance` each epoch with `MAX_UWREQ_PRUNE_PER_EPOCH`; - /// also invocable by `sysio.uwrit` itself with a caller-chosen budget - /// for manual backlog drains. Walks the `byexpire` index over rows whose - /// `expires_at_epoch` is non-zero and has elapsed, handling at most - /// `max_rows` rows (`max_rows == 0` is a no-op): - /// - /// * PENDING past its deadline — the underwriter race never resolved - /// inside `uwconfig.uwreq_pending_timeout_epochs`. Refund the source - /// side (SWAP_REVERT to the source outpost, or a full `refundwire` - /// of the from-WIRE escrow), flip the row to EXPIRED, clear its - /// heavy payloads, and re-stamp `expires_at_epoch` for the terminal - /// retention window. - /// * COMPLETED / REJECTED / EXPIRED past retention — erase the row. - /// * CONFIRMED never appears: winner selection zeroes the deadline - /// (the wall-clock lock window owns the row until whichever eraser - /// takes its last lock terminalizes it — `chklocks` at expiry, or - /// `sweeplocks` earlier on an UPHELD challenge). - /// - /// NEVER throws past the auth gate — it runs inline inside `advance`, - /// where an abort stalls epoch progress chain-wide. There are no timers - /// anywhere in this lifecycle: `expires_at_epoch` is passive row data, - /// and this action — a trigger fired by the epoch machinery — is the - /// only thing that ever evaluates it. - [[sysio::action]] - void pruneuwreqs(uint32_t max_rows); - - /// Swap FROM WIRE (the depot is the source chain). `user` escrows - /// `wire_amount` REAL WIRE into `sysio.reserv` custody NOW and the - /// request is QUEUED — no uwreq is created in this transaction. The - /// next `sysio.epoch::advance` drains the queue (`drainfwq`): the - /// target reserve + variance are re-validated authoritatively and a - /// PENDING uwreq with `src = (WIRE, WIRE)` is created for the normal - /// single-leg underwriter race (target leg only). - /// - /// `wire_amount` must meet the configured `min_fromwire_amount` floor - /// (default 5 WIRE) — the floor prices queue slots in locked escrow so - /// dust rows cannot hold the drain hostage for free. Drain-time reverts - /// the caller controls (unpriceable target, variance exceeded) refund - /// the escrow minus `fromwire_revert_fee_bps`; reverts caused by system - /// state changes after enqueue refund in full. - /// - /// `recipient_kind` / `recipient_addr` name the payout address on the - /// target chain (flattened ChainAddress — proto messages never appear - /// in action ABIs). The target reserve must be public: private - /// reserves are excluded from WIRE-endpoint swaps. - [[sysio::action]] - void swapfromwire(name user, - uint64_t wire_amount, - sysio::slug_name dst_chain_code, - sysio::slug_name dst_token_code, - sysio::slug_name dst_reserve_code, - uint64_t target_amount, - uint32_t target_tolerance_bps, - opp::types::ChainKind recipient_kind, - std::vector recipient_addr); - - /// Drain the swap-from-WIRE queue. Inlined from `sysio.epoch::advance` - /// each epoch (after the roster attestations are queued, before - /// `buildenv`). Per row: re-validate the target reserve (exists, - /// ACTIVE, not private) and the variance tolerance against the live - /// quote — on failure, refund the user's escrowed WIRE via - /// `reserv::refundwire` (minus `fromwire_revert_fee_bps` when the - /// revert cause is caller-controlled) and drop the row; on success, - /// emplace the - /// PENDING uwreq (id = the queue row's depot-origin id) carrying a - /// synthetic SwapRequest payload so the settlement tail - /// (`emit_swap_remit`) can decode the recipient. NEVER throws. - [[sysio::action]] - void drainfwq(); - - /// Read-only rollup of an underwriter's active lock total on a given - /// `(chain_code, token_code)`. Used by off-chain consumers + (eventually) - /// other contracts that don't rely on opreg's mirror. - [[sysio::action, sysio::read_only]] - uint64_t sumlocks(name underwriter, - sysio::slug_name chain_code, - sysio::slug_name token_code); - - // ----------------------------------------------------------------------- - // Tables - // ----------------------------------------------------------------------- - - /// Auto-incrementing id-keyed primary key used by `uwreqs`. - struct id_key { - uint64_t id; - uint64_t primary_key() const { return id; } - SYSLIB_SERIALIZE(id_key, (id)) - }; - - /// Per-leg lock row. Rows are pushed by `try_select_winner` and erased - /// by `chklocks` (natural expiry) or `sweeplocks` (an UPHELD - /// underwriter-fault challenge's slash-sweep, before expiry). Each of - /// those erase sites must also decrement `locksums` — see `lock_sum`. - /// - /// The `(underwriter, chain_code, token_code)` triple is the collateral - /// bucket a row rolls up into. 3 × uint64 = 192 bits exceeds - /// `uint128_t`, so the composite is hashed into a `checksum256` via - /// `by_underwriter_ck` — the same digest `locksums` uses to address - /// that bucket's materialized total, which is the O(1) surface opreg's - /// `available()` actually reads cross-contract. A separate - /// `by_underwriter` split-index (uint64 keyed on `underwriter.value`) - /// provides the cheap per-operator scan path for consumers that filter - /// on chain / token / reserve in-memory (per plan §B.2). - /// - /// `reserve_code` records which specific reserve this leg covers; on - /// a slash, the outpost routes seized collateral to that reserve via - /// `ReserveAmount`, even when multiple reserves exist for the same - /// `(chain_code, token_code)` pair. - /// The `(account, chain_code, token_code)` collateral-bucket digest: - /// the three uint64 identities packed little-endian into 24 bytes and - /// hashed. 3 × uint64 = 192 bits does not fit `uint128_t`, so the triple - /// is hashed to land in a `checksum256`. - /// - /// SINGLE SOURCE for that encoding, and it must stay that way. - /// `lock_entry::by_underwriter_ck()` says which bucket a lock row - /// belongs to; `lock_sum_key::primary_key()` addresses that bucket's - /// materialized total. If the two derivations ever diverged, the rollup - /// would be keyed differently from the rows it summarizes and every - /// reader would silently observe zero locked — collateral already - /// committed to a live lock would look spendable. Both call this, so - /// they cannot diverge. - static checksum256 compose_account_chain_token_ck(name account, - sysio::slug_name chain_code, - sysio::slug_name token_code) { - std::array buf{}; - uint64_t acc_v = account.value; - std::memcpy(buf.data() + 0, &acc_v, 8); - std::memcpy(buf.data() + 8, &chain_code.value, 8); - std::memcpy(buf.data() + 16, &token_code.value, 8); - return sysio::sha256(reinterpret_cast(buf.data()), buf.size()); - } - - struct lock_key { - uint64_t lock_id; - uint64_t primary_key() const { return lock_id; } - SYSLIB_SERIALIZE(lock_key, (lock_id)) - }; - - struct [[sysio::table("locks")]] lock_entry { - uint64_t lock_id = 0; - uint64_t uwreq_id = 0; - name underwriter; - sysio::slug_name chain_code; - sysio::slug_name token_code; - sysio::slug_name reserve_code; - uint64_t amount = 0; - uint64_t created_at_ms = 0; - /// `created_at_ms + uwconfig.collateral_lock_duration_ms`, computed - /// at insert time in `try_select_winner`. Wall-clock — the lock is - /// the 12h challenge window and is independent of epoch cadence - /// (epochs can stretch when `advance` gate-blocks). Indexed via - /// `byexpire` so `chklocks` sweeps expired locks in ascending order. - uint64_t expires_at_ms = 0; - - /// Which collateral bucket this lock belongs to — see - /// `compose_account_chain_token_ck`, the single source of that - /// encoding, shared with `lock_sum_key::primary_key()`. - checksum256 by_underwriter_ck() const { - return compose_account_chain_token_ck(underwriter, chain_code, token_code); - } - /// Non-zero while an underwriter-fault challenge (the `sysio.chalg::uwchals` row id) is - /// OPEN against this lock's commitment (WIRE-297). A held lock is NOT released at - /// `expires_at_ms`: `chklocks` skips it and instead pokes the challenge's tally crank, - /// so the collateral stays at risk until the council resolves or the challenge lapses. - /// Stamped by `holdlocks`, cleared by `freelocks` (reject/lapse); an UPHELD challenge - /// erases the row through `sweeplocks` instead. - uint64_t challenge_id = 0; - - /// Split-index for cheap per-operator scans (plan §B.2). Callers - /// pull all rows for a given underwriter and filter on - /// chain_code / token_code / reserve_code in memory. - uint64_t by_underwriter() const { return underwriter.value; } - uint64_t by_uwreq() const { return uwreq_id; } - uint64_t by_expires_at_ms() const { return expires_at_ms; } - - SYSLIB_SERIALIZE(lock_entry, - (lock_id)(uwreq_id)(underwriter)(chain_code)(token_code)(reserve_code) - (amount)(created_at_ms)(expires_at_ms)(challenge_id)) - }; - - // Per plan §B.2: split-index approach — keep only uint64 secondary - // indexes. `by_underwriter_ck` (checksum256) is computed on the row - // when needed for cross-contract composite comparisons, but is NOT a - // table-managed secondary index (Antelope KV's secondary-index - // templates expect fixed-width integer keys). - // - // This table is the AUTHORITY for locked collateral — one row per live - // lock leg, and the only thing that can settle what is actually locked. - // `locksums` is the O(1) read cache derived from it, one row per - // (underwriter, chain_code, token_code) bucket; `sysio.opreg`'s - // `available()` reads THAT cross-contract, not this table. - // - // It used to scan `byuw` (uint64) and filter (chain_code, token_code) - // in memory, on the assumption that underwriters hold O(1) concurrent - // locks. That assumption is false: locks are held for the full - // wall-clock challenge window and are never released by delivery, so a - // bucket's live count is (settlement rate × lock duration). See - // `lock_sum` for the three sites that maintain the cache, and why a - // missed one is permanent rather than merely stale. - using locks_t = sysio::kv::table<"locks"_n, lock_key, lock_entry, - sysio::kv::index<"byuw"_n, - sysio::const_mem_fun>, - sysio::kv::index<"byuwreq"_n, - sysio::const_mem_fun>, - sysio::kv::index<"byexpire"_n, - sysio::const_mem_fun> - >; - - /// Primary key of `locksums`: one (underwriter, chain_code, token_code) - /// collateral bucket, addressed by the SAME digest - /// `lock_entry::by_underwriter_ck()` uses to say which bucket a lock row - /// belongs to — both call `compose_account_chain_token_ck`. - struct lock_sum_key { - name underwriter; - sysio::slug_name chain_code; - sysio::slug_name token_code; - checksum256 primary_key() const { - return compose_account_chain_token_ck(underwriter, chain_code, token_code); - } - SYSLIB_SERIALIZE(lock_sum_key, (underwriter)(chain_code)(token_code)) - }; - - /// Materialized Σ `lock_entry.amount` for one (underwriter, chain_code, - /// token_code) bucket — the "locked" half of `sysio.opreg::available()`. - /// - /// A CACHE of the `locks` table with exactly ONE writer: every code path - /// that can change a bucket's total lives in this contract. There are - /// THREE, and any new one inherits the same obligation — nothing - /// structural enforces it: - /// - /// * `try_select_winner` — ADDS, one lock per required leg, on a win. - /// * `chklocks` — DECREMENTS, releasing locks at expiry. - /// * `sweeplocks` — DECREMENTS, erasing the held locks of a commitment - /// whose underwriter-fault challenge was UPHELD (WIRE-297). This one - /// runs OUTSIDE `chklocks`, which is exactly why it was missed once: - /// this block previously said `chklocks` was the sole erase path, and - /// `sweeplocks` erased rows without decrementing. - /// - /// Getting that wrong is permanent and silent rather than merely stale: - /// the rollup is authoritative for `sysio.opreg::available()`, so a bucket - /// left positive after its last row is gone suppresses that collateral - /// forever — nothing decrements it again, because the rows that would - /// have are already erased. - /// - /// A row is erased when its total reaches zero, so an absent row reads as - /// zero and the table holds only live buckets. - /// - /// It exists because the derivation it replaces does not scale. Both - /// `sum_locks_inline` rollups (here and in sysio.opreg) previously - /// walked every lock row an underwriter held, each documenting the - /// assumption that "per-underwriter lock counts are O(1)-ish so the scan - /// is cheap". That is false under sustained swap traffic: locks are held - /// for the full wall-clock challenge window - /// (`collateral_lock_duration_ms`, 12h default) and are NEVER released - /// by delivery, so a bucket's live lock count is - /// (settlement rate × lock duration) — unbounded within the window. The - /// scan ran per candidate inside `try_select_winner` (up to - /// MAX_UWREQ_CANDIDATES of them per uwreq), i.e. inside the same - /// consensus-dispatch CPU budget whose overrun stalls the chain. - /// - /// `sumlocks` reads this rollup, so it stays the cheap external answer - /// to "how much of this bucket is locked"; the authoritative recompute - /// is the `locks` table itself, which the contract tests scan and - /// compare against this total. - struct [[sysio::table("locksums")]] lock_sum { - name underwriter; - sysio::slug_name chain_code; - sysio::slug_name token_code; - uint64_t amount = 0; - SYSLIB_SERIALIZE(lock_sum, (underwriter)(chain_code)(token_code)(amount)) - }; - - using locksums_t = sysio::kv::table<"locksums"_n, lock_sum_key, lock_sum>; - - /// Per-underwriter race entry inside an UWREQ row. Tracks when each - /// leg of a dual-COMMIT pair arrived so `try_select_winner` can - /// resolve the race deterministically. Each leg's COMMIT is an - /// independent attestation with its own chain_code + uw_ext_chain_addr - /// (signed external-chain signer metadata for that leg) + signature over - /// the whole UIC. The depot stores the full UIC bytes per leg so - /// `try_select_winner` can reconstruct the signed digest verbatim and - /// verify a canonical fixed-size recoverable K1, R1, EM, or ED signature - /// against the underwriter's WIRE account `active` or `owner` permission. - /// - /// `commit_entry` does NOT carry codenames — the per-leg - /// `(chain_code, token_code, reserve_code)` identity is on the - /// surrounding `uw_request_t::src_*` / `dst_*` fields; the - /// commit_entry slot is solely a race-tracker. - struct commit_entry { - name underwriter; - /// Source-leg COMMIT. `source_uic_bytes` is the verbatim zpp_bits - /// serialization of the `UnderwriteIntentCommit` proto received from - /// the source-side outpost; the bytes include the underwriter's - /// signature in the `signature` field. Empty until the source-leg - /// arrives. - uint64_t source_received_at_ms = 0; - uint64_t source_outpost_id = 0; - std::vector source_uic_bytes; - /// Destination-leg COMMIT. Same shape, populated when the dest-side - /// outpost's relay arrives. - uint64_t dest_received_at_ms = 0; - uint64_t dest_outpost_id = 0; - std::vector dest_uic_bytes; - /// Race outcome — INTENT_SUBMITTED (initial/retryable), - /// INTENT_CONFIRMED (winner), DISQUALIFIED (durably invalid stored - /// evidence, such as a signature invalidated by key rotation), or - /// RELEASED (clean loser, retained for audit). A new matching - /// `rcrdcommit` cannot rewrite or re-arm a DISQUALIFIED entry. An - /// exact replay can re-evaluate only an INTENT_SUBMITTED entry. The - /// reused protobuf enum also contains SLASHED, but commit entries - /// never write that value; economic slash state belongs to lock and - /// operator settlement. - opp::types::UnderwriteStatus status = opp::types::UNDERWRITE_STATUS_INTENT_SUBMITTED; - std::string reason; - - SYSLIB_SERIALIZE(commit_entry, - (underwriter) - (source_received_at_ms)(source_outpost_id)(source_uic_bytes) - (dest_received_at_ms)(dest_outpost_id)(dest_uic_bytes) - (status)(reason)) - }; - - /// UWREQ row — one per inbound SWAP attestation. Tracks the swap's - /// src/dst pairs, the underwriter race, and the eventual settlement. - /// - /// Each side of the swap carries a full `(chain_code, token_code, - /// reserve_code)` triple per the data-model refactor: identity - /// is slug_name-keyed throughout, and `reserve_code` lets a same- - /// `(chain, token)` swap target a specific reserve when multiple - /// reserves exist for that pair. - struct [[sysio::table("uwreqs")]] uw_request_t { - uint64_t id; - opp::types::AttestationType type; - opp::types::UnderwriteRequestStatus status; - - /// Src / dst of the cross-chain swap. Populated by `createuwreq` - /// from the decoded SwapRequest. Used by `try_select_winner` to - /// validate per-leg bond coverage. - /// - /// `dst_amount` IS the AMM quote — the destination amount the - /// underwriter must deliver and the amount `sysio.reserv` debits at - /// settlement. It is **never** the caller's `SwapRequest.target_amount` - /// (WNS-02): the target is an unauthenticated expectation carried over - /// OPP, and paying it out let a caller name any figure and drain the - /// destination reserve. `createuwreq` / `drainfwq` seed it with the - /// quote at ingestion; `try_select_winner` re-prices on the live curve - /// and overwrites it with the price the books actually move at, after - /// checking the drift against `variance_tolerance_bps`. - sysio::slug_name src_chain_code; - sysio::slug_name src_token_code; - sysio::slug_name src_reserve_code; - uint64_t src_amount = 0; - sysio::slug_name dst_chain_code; - sysio::slug_name dst_token_code; - sysio::slug_name dst_reserve_code; - uint64_t dst_amount = 0; - /// The destination amount the caller ASKED for — `SwapRequest.target_amount` - /// (outpost-originated) or `fromwire_q::target_amount` (swap-from-WIRE). - /// - /// This is an expectation, never an instruction: it is the fixed - /// reference the slippage check measures against, and it is NEVER paid - /// out. Settlement uses `dst_amount` (the AMM quote) exclusively. - /// - /// It is retained on the row precisely so `try_select_winner` can - /// compare the LIVE settlement quote against the user's ORIGINAL bound. - /// Comparing instead against the previous quote (what `dst_amount` - /// holds before the race re-prices) would compound the tolerance across - /// the two checkpoints — a 10% tolerance accepting a 91 quote at - /// ingestion and an 83 quote at settlement, 17% below a target of 100 — - /// and would skip the bound entirely on a row created while no LP was - /// provisioned (`dst_amount == 0`). - uint64_t target_amount = 0; - /// Variance tolerance the user accepted at SWAP_REQUEST time, in - /// basis points (50 = 0.5%). The allowance it produces is a fraction - /// of the **AMM quote**, never of the user's `target_amount` (WNS-02) - /// — see `variance_allowance` in the implementation — and is clamped - /// to 100%. The depot's createuwreq path validates the ingestion quote - /// against it; `try_select_winner` re-validates the live quote at - /// race-resolution time so drift between ingestion and race doesn't - /// silently move the settlement price past what the user accepted. - uint32_t variance_tolerance_bps = 0; - - /// Source-chain id of the deposit transaction that funded this - /// swap (ETH: 32-byte tx hash; SOL: 64-byte signature). Used by - /// the off-chain underwriter plugin's `verify_source_deposit` - /// step to confirm a real on-chain deposit backs the swap - /// before committing collateral. `createuwreq` rejects any - /// SwapRequest with an empty `source_tx_id` (emits SwapRevert - /// for refund) — every outpost must populate this field at - /// swap-emit time. - std::vector source_tx_id; - - /// Depositor's address on the source chain (decoded from - /// `SwapRequest.actor.address`). ETH = 20 bytes (left-padded in - /// 32-byte ABI slots when matched); SOL = 32-byte Ed25519 - /// pubkey. The underwriter plugin matches this against the - /// `tx.from` (ETH) / fee-payer (SOL) of the source-deposit tx - /// during verification. - std::vector depositor; - - /// Race state. - std::vector commits_by; - name winner; - uint64_t committed_at_ms = 0; - uint64_t settled_at_ms = 0; - /// The row's lifecycle deadline, evaluated (lazily, at trigger time) - /// by `pruneuwreqs` via the `byexpire` index. Semantics by status: - /// * PENDING — `creation_epoch + uwconfig.uwreq_pending_timeout_epochs`; - /// past it the race is abandoned (refund + EXPIRED). - /// * CONFIRMED — 0 (no deadline): the wall-clock lock window owns - /// the row; `chklocks` always terminalizes it. - /// * terminal — `terminal_epoch + uwconfig.uwreq_retention_epochs`; - /// past it the row is erased. - /// 0 means "no deadline, never swept" (same zero-sentinel predicate - /// as `sysio.dclaim::flushexpired`). - uint32_t expires_at_epoch = 0; - - /// Inbound attestation payload (zpp_bits-encoded protobuf). - std::vector attestation_inbound_data; - - /// Outbound attestation payload reserved for future flows where - /// uwrit emits its own response (e.g. underwriter intent acks). - /// Empty until that flow lands. - std::vector attestation_outbound_data; - - /// Non-zero while an underwriter-fault challenge (the `sysio.chalg::uwchals` row id) - /// is OPEN against this request's commitment — stamped by `holdlocks`, cleared by - /// `freelocks`, alongside the identical marker on each held lock. - /// - /// `pruneuwreqs` refuses to erase a row carrying one. The lock marker alone is not - /// enough: the locks hold the COLLATERAL, but this row holds the EVIDENCE a challenge - /// adjudicates against — `attestation_inbound_data` (the stored SwapRequest), - /// `source_tx_id`, and `commits_by` with the per-leg UIC bytes. Terminal rows are - /// erased at `terminal_epoch + uwreq_retention_epochs`, which is far shorter than the - /// challenge window, so without this a challenge could outlive the record it exists - /// to adjudicate and resolve against nothing. - uint64_t challenge_id = 0; - - uint64_t by_status() const { return magic_enum::enum_integer(status); } - uint64_t by_winner() const { return winner.value; } - uint64_t by_expires_at_epoch() const { return expires_at_epoch; } - - SYSLIB_SERIALIZE(uw_request_t, - (id)(type)(status) - (src_chain_code)(src_token_code)(src_reserve_code)(src_amount) - (dst_chain_code)(dst_token_code)(dst_reserve_code)(dst_amount) - (target_amount)(variance_tolerance_bps)(source_tx_id)(depositor) - (commits_by)(winner)(committed_at_ms)(settled_at_ms)(expires_at_epoch) - (attestation_inbound_data)(attestation_outbound_data)(challenge_id)) - }; - - using uwreqs_t = sysio::kv::table<"uwreqs"_n, id_key, uw_request_t, - sysio::kv::index<"bystatus"_n, - sysio::const_mem_fun>, - sysio::kv::index<"bywinner"_n, - sysio::const_mem_fun>, - sysio::kv::index<"byexpire"_n, - sysio::const_mem_fun> - >; - - /// Singleton holding the next-issued `lock_id` + the depot-origin - /// swap-from-WIRE sequence. Keeps both auto-increments monotonic - /// across action calls. - struct [[sysio::table("uwcounters")]] uw_counters { - uint64_t next_lock_id = 1; - /// Sequence for depot-originated (swap-from-WIRE) uwreq ids. The - /// issued id is `0x8000000000000000 | seq` — the high bit - /// partitions the depot-origin id space away from inbound - /// attestation ids (msgch's `mint_att_id` counts monotonically - /// from 1 and can never reach 2^63), so `fwqueue` row ids double - /// as collision-free uwreq ids. - uint64_t next_fromwire_seq = 0; - SYSLIB_SERIALIZE(uw_counters, (next_lock_id)(next_fromwire_seq)) - }; - - using uwcounters_t = sysio::kv::global<"uwcounters"_n, uw_counters>; - - /// Queued swap-from-WIRE request. Written by `swapfromwire` (which - /// escrows the user's WIRE in the same transaction), drained by - /// `drainfwq` on the next `sysio.epoch::advance`. The row id is the - /// depot-origin uwreq id the drained request will be created under. - struct fw_key { - uint64_t id; - uint64_t primary_key() const { return id; } - SYSLIB_SERIALIZE(fw_key, (id)) - }; - - struct [[sysio::table("fwqueue")]] fromwire_q { - uint64_t id = 0; - name user; - uint64_t wire_amount = 0; - sysio::slug_name dst_chain_code; - sysio::slug_name dst_token_code; - sysio::slug_name dst_reserve_code; - uint64_t target_amount = 0; - uint32_t variance_tolerance_bps = 0; - opp::types::ChainKind recipient_kind = opp::types::CHAIN_KIND_UNKNOWN; - std::vector recipient_addr; - uint32_t created_at_epoch = 0; - - uint64_t by_epoch() const { return created_at_epoch; } - - SYSLIB_SERIALIZE(fromwire_q, - (id)(user)(wire_amount) - (dst_chain_code)(dst_token_code)(dst_reserve_code) - (target_amount)(variance_tolerance_bps) - (recipient_kind)(recipient_addr)(created_at_epoch)) - }; - - using fwqueue_t = sysio::kv::table<"fwqueue"_n, fw_key, fromwire_q, - sysio::kv::index<"byepoch"_n, - sysio::const_mem_fun> - >; - - /// Fee + lock-duration configuration singleton. `fee_bps` is the per-spoke - /// NETWORK swap fee, charged out of the WIRE leg — the reserve owner fees - /// charged alongside it live on their own reserve rows - /// (`sysio.reserv::reserve::owner_fee_bps`), not here. No fee-DISTRIBUTION - /// shares live here either: the underwriter/rewards split of the network - /// fee is fixed in `sysio.reserv` (FEE_UNDERWRITER_SHARE_BPS), and the - /// rewards pool's own emissions share is that contract's governance dial - /// (`reserve_config.fee_emissions_share_bps`). - struct [[sysio::table("uwconfig")]] uw_config { - uint32_t fee_bps = 10; // 0.1% per spoke - /// Wall-clock collateral lock duration — the challenge window. - /// Locks are NEVER released by delivery; they expire this many ms - /// after creation and are swept by `chklocks` at epoch advance. An - /// UPHELD challenge cuts the window short — `sweeplocks` erases the - /// commitment's locks where they stand. Default 12 hours. - uint64_t collateral_lock_duration_ms = 43'200'000; - /// Minimum WIRE escrow accepted by `swapfromwire` (atomic units, - /// 9 decimals) — the queue-slot price floor. - uint64_t min_fromwire_amount = DEFAULT_MIN_FROMWIRE_AMOUNT; - /// Fee (bps of the escrow) on caller-controlled drain-time reverts, - /// routed like the settlement fee. <= MAX_FEE_BPS. - uint32_t fromwire_revert_fee_bps = DEFAULT_FROMWIRE_REVERT_FEE_BPS; - /// Epochs a PENDING uwreq may wait for its underwriter race before - /// `pruneuwreqs` expires it (refund/revert + EXPIRED). The deadline - /// is stamped per row at creation; retuning is not retroactive. - uint32_t uwreq_pending_timeout_epochs = DEFAULT_UWREQ_PENDING_TIMEOUT_EPOCHS; - /// Epochs a terminal (COMPLETED / REJECTED / EXPIRED) uwreq row is - /// retained for audit before `pruneuwreqs` erases it. Stamped per - /// row at the terminal transition; retuning is not retroactive. - uint32_t uwreq_retention_epochs = DEFAULT_UWREQ_RETENTION_EPOCHS; - SYSLIB_SERIALIZE(uw_config, (fee_bps)(collateral_lock_duration_ms) - (min_fromwire_amount)(fromwire_revert_fee_bps) - (uwreq_pending_timeout_epochs)(uwreq_retention_epochs)) - }; - - using uwconfig_t = sysio::kv::global<"uwconfig"_n, uw_config>; - - private: - - using UnderwriteRequestStatus = opp::types::UnderwriteRequestStatus; - using UnderwriteStatus = opp::types::UnderwriteStatus; - using ChainKind = opp::types::ChainKind; - using AttestationType = opp::types::AttestationType; - }; - -} // namespace sysio diff --git a/contracts/sysio.uwrit/src/sysio.uwrit.cpp b/contracts/sysio.uwrit/src/sysio.uwrit.cpp deleted file mode 100644 index ef84b1b387..0000000000 --- a/contracts/sysio.uwrit/src/sysio.uwrit.cpp +++ /dev/null @@ -1,2783 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace sysio { - -using opp::types::AttestationType; -using opp::types::UnderwriteRequestStatus; -using opp::types::UnderwriteStatus; -using opp::types::OperatorStatus; -using opp::types::OperatorType; -using opp::types::ReserveStatus; -using opp::types::ChainKind; -using opp::attestations::SwapRequest; - -namespace { - -// System-owned rows bill to the sysio RAM pool, not this contract account (privileged-contract -// model, as sysio.token uses): the account stays finite at code+abi size; growth draws from the pool. -constexpr name ram_payer = "sysio"_n; - -/// `reserv::refundwire` fee argument for full refunds — refunds whose cause -/// the caller does not control (system state changed after enqueue, uwreq -/// rejected at resolution) forfeit nothing. -constexpr uint32_t REFUND_FEE_EXEMPT_BPS = 0; - -/// High bit partitions depot-originated (swap-from-WIRE) uwreq ids from -/// inbound attestation ids — msgch's `mint_att_id` counts monotonically -/// from 1 and can never reach 2^63, so the two id spaces are disjoint. -constexpr uint64_t DEPOT_ORIGIN_ID_BASE = 0x8000000000000000ULL; - -/// Fixed-size, recoverable `sysio::signature` variants accepted at the UIC -/// protocol boundary. WebAuthn is deliberately excluded because its variable -/// fields require a hostile-byte parser; BLS is fixed-size but not recoverable. -/// The enum values are the packed variant tags and `sysio::signature` indices. -enum class uic_signature_variant : uint8_t { - k1 = 0, - r1 = 1, - wa = 2, - em = 3, - ed = 4, - bls = 5, -}; - -static_assert(std::variant_size_v == 6); -static_assert(std::is_same_v, - sysio::webauthn_signature>); -static_assert(std::is_same_v, - sysio::ed_signature>); -static_assert(std::is_same_v, - sysio::bls_signature>); -// K1, R1, and EM intentionally share ecc_signature as their alternative type, -// so type-identity assertions cannot detect a permutation of those indices. -// sysio.dispatch_tests pins their concrete packed tags and exercises real -// contract recovery/permission authorization for all four accepted variants. - -constexpr size_t K1_SIGNATURE_VARIANT_INDEX = - magic_enum::enum_integer(uic_signature_variant::k1); -constexpr size_t R1_SIGNATURE_VARIANT_INDEX = - magic_enum::enum_integer(uic_signature_variant::r1); -constexpr size_t EM_SIGNATURE_VARIANT_INDEX = - magic_enum::enum_integer(uic_signature_variant::em); -constexpr size_t ED_SIGNATURE_VARIANT_INDEX = - magic_enum::enum_integer(uic_signature_variant::ed); - -/// Every accepted variant index is encoded as one varuint byte. -constexpr size_t PACKED_SIGNATURE_TAG_SIZE = 1; - -/// Exact packed sizes of the one-byte variant tag plus each fixed signature -/// body. K1, R1, and EM share the 65-byte ECC representation; ED embeds its -/// public key in a 96-byte body. -constexpr size_t PACKED_ECC_SIGNATURE_SIZE = - PACKED_SIGNATURE_TAG_SIZE + std::tuple_size_v; -constexpr size_t PACKED_ED_SIGNATURE_SIZE = - PACKED_SIGNATURE_TAG_SIZE + std::tuple_size_v; - -/// Stable action-trace marker for malformed or unauthorized UIC signatures. -constexpr const char* UIC_SIGNATURE_REJECTED_LOG_PREFIX = "UIC_SIGNATURE_REJECTED"; - -/// Prefix used in the durable commit-entry rejection reason. -constexpr const char* UIC_SIGNATURE_REJECTED_REASON_PREFIX = - "invalid underwrite-intent-commit "; - -/// Separates the rejected leg from its typed verification result. -constexpr const char* UIC_SIGNATURE_REJECTED_REASON_SEPARATOR = " signature: "; - -/// Fixed serialized-size allowance for a NEW `commit_entry`'s non-vector -/// fields (underwriter name, two timestamp/outpost-id pairs, status, empty -/// reason + the vectors' length prefixes) in rcrdcommit's projected-row-size -/// guard. The true packed size is ~50 bytes; 128 over-counts on purpose — -/// the guard may only ever be tighter than reality, never looser. -constexpr size_t COMMIT_ENTRY_PACK_ALLOWANCE_BYTES = 128; - -uint64_t current_time_ms() { - return static_cast(current_time_point().sec_since_epoch()) * 1000; -} - -uint32_t get_current_epoch() { - sysio::epoch::epochstate_t es(uwrit::EPOCH_ACCOUNT); - if (!es.exists()) return 0; - return es.get().current_epoch_index; -} - -/// Sum the underwriter's pending withdraws on opreg for the given -/// `(chain_code, token_code)`. Per the split-index design: -/// `opreg::wtdwqueue_t` exposes only uint64 secondary indexes. The `byaccount` -/// index keys on `account.value`; rows are filtered on `(chain_code, -/// token_code)` in memory. Per-account pending-withdraw counts are O(1)-ish -/// so the scan is cheap. -uint64_t opreg_pending_withdraws(name underwriter, - sysio::slug_name chain_code, - sysio::slug_name token_code) { - opreg::wtdwqueue_t queue(uwrit::OPREG_ACCOUNT); - auto idx = queue.template get_index<"byaccount"_n>(); - - uint64_t total = 0; - auto it = idx.lower_bound(underwriter.value); - auto end = idx.upper_bound(underwriter.value); - for (; it != end; ++it) { - if (it->chain_code != chain_code || it->token_code != token_code) continue; - // Saturating: amounts are uncapped uint64 (external-chain values); a - // wrapped subtotal would understate `reserved` and overstate availability. - total = opp::safe::add_sat_u64(total, it->amount); - } - return total; -} - -/// This contract's active lock total for the given -/// `(underwriter, chain_code, token_code)` — an O(1) read of the `locksums` -/// rollup maintained by `add_locked_total` / `sub_locked_total`. An absent row -/// means the bucket holds no live locks. -/// -/// This used to walk every lock row the underwriter held, on the assumption -/// that per-underwriter lock counts stay small. They do not: locks are held -/// for the whole wall-clock challenge window and are never released by -/// delivery, so the count is (settlement rate × lock duration). See the -/// `lock_sum` docs for why that mattered on this particular call path. -uint64_t sum_locks_inline(name self, - name underwriter, - sysio::slug_name chain_code, - sysio::slug_name token_code) { - uwrit::locksums_t sums(self); - uwrit::lock_sum_key pk{underwriter, chain_code, token_code}; - return sums.contains(pk) ? sums.get(pk).amount : 0; -} - -/// Add `amount` to the `(underwriter, chain_code, token_code)` bucket, -/// creating the row when the bucket was empty. Called once per lock row -/// written by `try_select_winner`. -/// -/// Saturating, matching the scan it replaced: amounts are uncapped uint64 -/// external-chain values, and a wrapped total would UNDERSTATE what is -/// reserved and so OVERSTATE availability — the one direction that lets an -/// overcommit through. Saturation instead overstates `locked`, which fails -/// closed (a swap is refused, never over-collateralized). -void add_locked_total(name self, name underwriter, sysio::slug_name chain_code, - sysio::slug_name token_code, uint64_t amount) { - uwrit::locksums_t sums(self); - uwrit::lock_sum_key pk{underwriter, chain_code, token_code}; - if (sums.contains(pk)) { - sums.modify(ram_payer, pk, [&](auto& row) { - row.amount = opp::safe::add_sat_u64(row.amount, amount); - }); - return; - } - sums.emplace(ram_payer, pk, uwrit::lock_sum{ - .underwriter = underwriter, - .chain_code = chain_code, - .token_code = token_code, - .amount = amount, - }); -} - -/// Subtract `amount` from the bucket, erasing the row once it reaches zero so -/// the table holds only live buckets. -/// -/// Clamped at zero rather than wrapping. A wrap here would leave a colossal -/// `locked` on the bucket and zero the underwriter's `available()` for the -/// rest of the chain's life — and this runs inside `chklocks`, which is inline -/// in `sysio.epoch::advance` and must never throw. Clamping keeps a -/// hypothetical accounting slip local and self-healing instead. -void sub_locked_total(name self, name underwriter, sysio::slug_name chain_code, - sysio::slug_name token_code, uint64_t amount) { - uwrit::locksums_t sums(self); - uwrit::lock_sum_key pk{underwriter, chain_code, token_code}; - if (!sums.contains(pk)) return; - const uint64_t current = sums.get(pk).amount; - const uint64_t next = current > amount ? current - amount : 0; - if (next == 0) { - sums.erase(pk); - return; - } - sums.modify(ram_payer, pk, [&](auto& row) { row.amount = next; }); -} - -/// Look up an underwriter's balance on opreg for the given -/// `(chain_code, token_code)`. Returns the raw stored balance — caller -/// subtracts active locks + pending withdraws to get the spendable amount. -uint64_t opreg_balance(name underwriter, - sysio::slug_name chain_code, - sysio::slug_name token_code, - OperatorStatus& out_status) { - opreg::operators_t ops(uwrit::OPREG_ACCOUNT); - opreg::operator_key op_pk{underwriter.value}; - if (!ops.contains(op_pk)) { - out_status = OperatorStatus::OPERATOR_STATUS_UNKNOWN; - return 0; - } - auto op = ops.get(op_pk); - out_status = op.status; - for (const auto& b : op.balances) { - if (b.chain_code == chain_code && b.token_code == token_code) { - return b.balance; - } - } - return 0; -} - -/// Compute the underwriter's spendable balance on -/// `(chain_code, token_code)`. Mirrors the sysio.opreg::available() formula: -/// balance - sum(active locks here in uwrit) - sum(pending withdraws on opreg) -/// gated by status (SLASHED / TERMINATED -> 0). -uint64_t available_via_mirrors(name self, - name underwriter, - sysio::slug_name chain_code, - sysio::slug_name token_code) { - OperatorStatus status; - uint64_t balance = opreg_balance(underwriter, chain_code, token_code, status); - if (status == OperatorStatus::OPERATOR_STATUS_SLASHED || - status == OperatorStatus::OPERATOR_STATUS_TERMINATED) { - return 0; - } - uint64_t locked = sum_locks_inline(self, underwriter, chain_code, token_code); - uint64_t pending = opreg_pending_withdraws(underwriter, chain_code, token_code); - // Saturating: a wrap of locked+pending would understate `reserved` and - // overstate availability — the exact direction that lets an overcommit - // through. The cap is unreachable for real amounts. - uint64_t reserved = opp::safe::add_sat_u64(locked, pending); - return balance > reserved ? balance - reserved : 0; -} - -/// True iff `candidate` is currently an ACTIVE underwriter on opreg: an -/// operator row that EXISTS, is typed `OPERATOR_TYPE_UNDERWRITER`, AND is in -/// `OPERATOR_STATUS_ACTIVE`. This is the authorization the race resolver -/// requires before a candidate may win and drive value settlement. -/// -/// opreg's eligibility model registers every non-bootstrapped operator as -/// `UNKNOWN`, forbids bootstrapping an underwriter active at registration, and -/// only promotes an underwriter to `ACTIVE` through `opreg::processuw` once it -/// clears the role's `req_uw_collat` minimum. The balance mirror -/// (`available_via_mirrors`) zeroes only SLASHED / TERMINATED rows and ignores -/// `op.type`, so on its own it would admit a non-underwriter (PRODUCER / BATCH -/// / CHALLENGER) or a pre-activation underwriter (UNKNOWN / WARMUP / COOLDOWN) -/// that merely holds enough mirrored balance. Reads the opreg `operators` row -/// directly — the authoritative type/status source, mirrored nowhere else in -/// uwrit. -bool is_active_underwriter(name candidate) { - opreg::operators_t ops(uwrit::OPREG_ACCOUNT); - opreg::operator_key op_pk{candidate.value}; - if (!ops.contains(op_pk)) return false; - const auto op = ops.get(op_pk); - return op.type == OperatorType::OPERATOR_TYPE_UNDERWRITER - && op.status == OperatorStatus::OPERATOR_STATUS_ACTIVE; -} - -/// Live `uwconfig` snapshot (defaults when `setconfig` has never run). -/// `self` is the uwrit contract account (where the singleton lives). -uwrit::uw_config read_config(name self) { - uwrit::uwconfig_t cfg(self); - return cfg.get_or_default(uwrit::uw_config{}); -} - -/// Live per-spoke swap fee (basis points) from `uwconfig`, read fresh so the -/// ingestion variance check, the race-time recheck, and settlement all charge -/// one rate. -uint32_t current_fee_bps(name self) { - return read_config(self).fee_bps; -} - -/// Find a reserve by its triple key, returning the row pointer-equivalent -/// optional. Mirrors sysio.reserv's primary-key access. Returns -/// `std::nullopt` when no such reserve exists (the variance check then -/// treats the quote as 0 — implicit skip). -std::optional find_reserve(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code) { - reserve::reserves_t reserves(uwrit::RESERVE_ACCOUNT); - reserve::reserve_key pk{chain_code, token_code, reserve_code}; - if (!reserves.contains(pk)) return std::nullopt; - return reserves.get(pk); -} - -/// Quote `src_amount` of (src_chain, src_token, src_reserve) into -/// (dst_chain, dst_token, dst_reserve) along the depot's live curve. Mirrors -/// `sysio.reserv::swapquote` exactly — the shared weighted-Bancor kernel (each -/// reserve's `connector_weight_bps`) and the SAME post-fee reduction: the -/// network `fee_bps` PLUS each participating reserve's own `owner_fee_bps`, all -/// out of the WIRE leg — so the variance check at SWAP_REQUEST receipt time -/// matches what settlement will deliver, without an inline action call into -/// reserv. Returns 0 if any required reserve is missing or not ACTIVE (caller -/// treats 0 as "no quote available, skip variance check"). -/// -/// A WIRE endpoint skips that leg's reserve (the depot IS the WIRE side); -/// WIRE->WIRE is a plain `src_amount` passthrough. -uint64_t swap_quote(sysio::slug_name src_chain_code, - sysio::slug_name src_token_code, - sysio::slug_name src_reserve_code, - sysio::slug_name dst_chain_code, - sysio::slug_name dst_token_code, - sysio::slug_name dst_reserve_code, - uint64_t src_amount, - uint32_t fee_bps) { - if (src_amount == 0) return 0; - const bool src_is_wire = opp::wire::is_native_asset(src_chain_code, src_token_code); - const bool dst_is_wire = opp::wire::is_native_asset(dst_chain_code, dst_token_code); - if (src_is_wire && dst_is_wire) return src_amount; - - auto active = [](std::optional&& r) - -> std::optional { - if (!r || r->status != ReserveStatus::RESERVE_STATUS_ACTIVE) return std::nullopt; - return r; - }; - - uint64_t sc = 0, sw = 0; uint32_t scw = 0, sfee = 0; - if (!src_is_wire) { - auto r = active(find_reserve(src_chain_code, src_token_code, src_reserve_code)); - if (!r) return 0; - sc = r->reserve_chain_amount; sw = r->reserve_wire_amount; scw = r->connector_weight_bps; - sfee = r->owner_fee_bps; - } - uint64_t dc = 0, dw = 0; uint32_t dcw = 0, dfee = 0; - if (!dst_is_wire) { - auto r = active(find_reserve(dst_chain_code, dst_token_code, dst_reserve_code)); - if (!r) return 0; - dc = r->reserve_chain_amount; dw = r->reserve_wire_amount; dcw = r->connector_weight_bps; - dfee = r->owner_fee_bps; - } - return opp::amm::quote_swap(src_is_wire, sc, sw, scw, - dst_is_wire, dc, dw, dcw, - src_amount, fee_bps, sfee, dfee); -} - -/// True iff every NON-WIRE leg of a swap has an ACTIVE reserve row. Lets callers -/// distinguish a zero `swap_quote` caused by an unprovisioned / not-ACTIVE -/// reserve (dev & smoke clusters — the variance check is intentionally skipped) -/// from one caused by a *degenerate but ACTIVE* reserve: a side drained to zero, -/// extreme connector weights, or a WIRE leg too small for the weighted-Bancor -/// kernel to price (it floors the output to 0). In that case a zero quote is a -/// real pricing failure and the caller MUST fail closed — otherwise the -/// user-supplied `target_amount` bypasses the variance check and settlement -/// over-debits the reserve (WSA-041). A WIRE endpoint has no reserve and is not -/// required (the depot IS the WIRE side), mirroring `swap_quote`. -bool required_reserves_active(sysio::slug_name src_chain_code, - sysio::slug_name src_token_code, - sysio::slug_name src_reserve_code, - sysio::slug_name dst_chain_code, - sysio::slug_name dst_token_code, - sysio::slug_name dst_reserve_code) { - auto leg_active = [](sysio::slug_name c, sysio::slug_name t, sysio::slug_name r) { - auto row = find_reserve(c, t, r); - return row && row->status == ReserveStatus::RESERVE_STATUS_ACTIVE; - }; - if (!opp::wire::is_native_asset(src_chain_code, src_token_code) - && !leg_active(src_chain_code, src_token_code, src_reserve_code)) return false; - if (!opp::wire::is_native_asset(dst_chain_code, dst_token_code) - && !leg_active(dst_chain_code, dst_token_code, dst_reserve_code)) return false; - return true; -} - -/// Slippage allowance for a variance check, in absolute destination units: -/// `tolerance_bps` of the **AMM quote**. -/// -/// The reference is deliberately the quote and NEVER the caller-supplied -/// `target_amount` (WNS-02). A target crosses the OPP boundary unauthenticated, -/// so deriving the allowance from it made the bound scale with the very number -/// it was meant to constrain: `|quote - target| <= target * tolerance_bps / 10000` -/// holds for ANY target above the quote once `tolerance_bps` reaches 10000, which -/// is what let a caller name an arbitrary destination amount and have it settle. -/// Measured against the quote, the check means what it says — "the price I got is -/// within N% of the price I expected". -/// -/// `tolerance_bps` is clamped to `BPS_TOTAL` (100%): a wider tolerance is -/// meaningless (100% already admits every target from zero to twice the quote) -/// and only inflates the allowance. Computed in `uint128_t` so the product cannot -/// overflow for any (quote, tolerance) pair. -uint128_t variance_allowance(uint64_t quote, uint32_t tolerance_bps) { - const uint32_t bounded_bps = tolerance_bps > opp::amm::BPS_TOTAL ? opp::amm::BPS_TOTAL - : tolerance_bps; - return (static_cast(quote) * bounded_bps) / opp::amm::BPS_TOTAL; -} - -/// Encode + queue a SWAP_REVERT attestation back to the source outpost when -/// the variance check fails. The outpost matches the original SWAP_REQUEST -/// via `original_swap_message_id` (low 8 bytes carry the depot's -/// attestation_id; see msgch's SWAP_REMIT dispatch for the matching decode -/// convention). -/// -/// The slug_name pair `(source_chain_code, source_reserve_code)` is included -/// so the outpost can locate the matching local reserve when refunding. -void emit_swap_revert(name self, - uint64_t chain_code, - uint64_t attestation_id, - const opp::attestations::SwapRequest& sr, - sysio::slug_name source_chain_code, - sysio::slug_name source_reserve_code, - const std::string& reason) { - opp::attestations::SwapRevert rev; - rev.original_swap_message_id.assign(32, 0); - for (size_t i = 0; i < 8; ++i) { - rev.original_swap_message_id[i] = static_cast((attestation_id >> (i * 8)) & 0xff); - } - rev.depositor = sr.actor; - rev.refund_amount = sr.source_amount; - rev.reason = reason; - rev.source_chain_code = source_chain_code.value; - rev.source_reserve_code = source_reserve_code.value; - - // `no_size{}` — raw protobuf bytes for the outpost decoder; the default - // `zpp::bits::data_out` form prepends a 4-byte LE length prefix that - // corrupts the first field tag on the receiving side. - std::vector encoded; - auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; - (void)out(rev); - - action( - permission_level{self, "active"_n}, - uwrit::MSGCH_ACCOUNT, "queueout"_n, - std::make_tuple(chain_code, - opp::types::AttestationType::ATTESTATION_TYPE_SWAP_REVERT, encoded) - ).send(); -} - -/// Resolve a `sysio::slug_name` chain identifier to its `ChainKind` by -/// reading the `sysio.chains::chains` registry row. Returns `std::nullopt` -/// when no chain row exists for the code — callers treat that as "no -/// outpost for this chain, skip the queueout". -std::optional chain_kind_for_code(sysio::slug_name chain_code) { - sysio::chains::chains_t tbl(uwrit::CHAINS_ACCOUNT); - sysio::chains::chain_key pk{chain_code}; - if (!tbl.contains(pk)) return std::nullopt; - return tbl.get(pk).kind; -} - -/// Look up the depot's outpost id for `chain_code` via the chains registry. -/// Returns `std::nullopt` when no chain row is registered (per -/// `feedback_no_zero_sentinels` — outpost id 0 is a real id, so 0 must not -/// double as "missing"). -/// -/// After the cross-contract realignment: chain rows live in -/// `sysio.chains::chains` keyed by `code` (slug_name); the legacy -/// `sysio.epoch::outposts` table is gone. The "outpost id" returned here is -/// the chain's `code.value` (uint64). The depot-self row is filtered out so -/// WIRE-direct flows skip queueouts cleanly. -std::optional find_outpost_id_for_chain(sysio::slug_name chain_code) { - sysio::chains::chains_t chains_tbl(uwrit::CHAINS_ACCOUNT); - sysio::chains::chain_key pk{chain_code}; - if (!chains_tbl.contains(pk)) return std::nullopt; - const auto row = chains_tbl.get(pk); - if (row.is_depot) return std::nullopt; // WIRE-direct flows don't queueout - return chain_code.value; -} - -/// True iff `chain_code` is a REGISTERED chain row flagged `is_depot`. -/// An unregistered chain is NOT the depot — `createuwreq` rejects swaps on -/// unregistered chains outright rather than mistaking them for WIRE legs -/// (which would silently waive that leg's signature/bond/lock). -bool leg_is_depot(sysio::slug_name chain_code) { - sysio::chains::chains_t chains_tbl(uwrit::CHAINS_ACCOUNT); - sysio::chains::chain_key pk{chain_code}; - if (!chains_tbl.contains(pk)) return false; - return chains_tbl.get(pk).is_depot; -} - -/// True iff the chain is registered AND active in sysio.chains. -bool chain_registered_active(sysio::slug_name chain_code) { - sysio::chains::chains_t chains_tbl(uwrit::CHAINS_ACCOUNT); - sysio::chains::chain_key pk{chain_code}; - if (!chains_tbl.contains(pk)) return false; - return chains_tbl.get(pk).active; -} - -/// The depot's own chain code (the singleton `is_depot` row in -/// sysio.chains — `code = "WIRE"` by protocol convention). Scans the -/// registry, which holds a handful of rows. Returns nullopt before the -/// depot row is registered (pre-bootstrap). -std::optional depot_chain_code() { - sysio::chains::chains_t tbl(uwrit::CHAINS_ACCOUNT); - for (auto it = tbl.begin(); it != tbl.end(); ++it) { - if (it->is_depot) return it->code; - } - return std::nullopt; -} - -/// Find a reserve and require ACTIVE status — the shape every settlement -/// pre-check needs. Returns nullopt when missing or not ACTIVE. -std::optional find_active_reserve(sysio::slug_name chain_code, - sysio::slug_name token_code, - sysio::slug_name reserve_code) { - auto row = find_reserve(chain_code, token_code, reserve_code); - if (!row) return std::nullopt; - if (row->status != ReserveStatus::RESERVE_STATUS_ACTIVE) return std::nullopt; - return row; -} - -/// Parse a WIRE account name from its string-spelling bytes (the canonical -/// `ChainAddress.address` encoding for CHAIN_KIND_WIRE). Delegates to the shared -/// `sysio::opp::safe::parse_wire_account_name`, which validates charset, length, -/// and final-symbol bounds BEFORE constructing the `name`, so this never throws -/// inside the evalcons dispatch chain. The full CDT name domain is accepted — -/// including a legitimate 13-byte name whose final symbol fits the 4-bit final -/// slot. An empty address is not a valid principal here. -std::optional parse_wire_name(const std::vector& bytes) { - return sysio::opp::safe::parse_wire_account_name(std::string_view{bytes.data(), bytes.size()}); -} - -/// A WIRE account name as its string-spelling bytes — the inverse of -/// `parse_wire_name`, used to stamp WIRE-side principals into -/// ChainAddress-shaped fields (depositor, actor). -std::vector wire_name_bytes(name n) { - std::string s = n.to_string(); - return {s.begin(), s.end()}; -} - -/// Disposition of a pre-settlement attempt to build the outbound SWAP_REMIT -/// for a winning candidate. Lets `try_select_winner` unwind a race -/// non-throwing instead of letting a `check()` abort the evalcons dispatch -/// chain and stall OPP consensus chain-wide. -enum class swap_remit_disp { - ok, ///< envelope built — proceed to settle + `queue_swap_remit` - terminal, ///< no underwriter can EVER remit this uwreq — REJECT + refund - disqualified, ///< THIS candidate cannot remit — skip it, leave uwreq PENDING -}; - -/// Pre-validate + build (but do NOT send) the outbound SWAP_REMIT envelope -/// for `candidate` winning `req`, mutating no state. -/// -/// **Non-throwing.** The former `emit_swap_remit` ran AFTER the caller's -/// `reserv::applyswap` / `applyfromwire` reserve mutation and resolved the -/// recipient / destination outpost / underwriter identity with `check()`. -/// Inside the synchronous evalcons → dispatch → rcrdcommit → try_select_winner -/// chain, any such throw aborts consensus application for the whole outpost -/// envelope and stalls OPP epoch advancement chain-wide. This function instead -/// reports a `swap_remit_disp` so the caller can disqualify the candidate (or -/// reject the uwreq) cleanly. It is called BEFORE any lock / CONFIRMED / -/// reserve write; `queue_swap_remit` (below) only ships the pre-built envelope, -/// AFTER the reserve books have moved (so every intervening quote prices the -/// post-swap books). -/// -/// On `ok`, `dst_outpost_id` + `encoded` carry the ready-to-send envelope. -/// Failure classification: -/// * stored-request decode / dst outpost / dst chain-kind — uwreq-wide and -/// identical for every candidate ⇒ `terminal` (REJECT + refund/revert). -/// * winner's destination-chain authex link / pubkey — candidate-specific -/// (another underwriter may hold a valid link) ⇒ `disqualified`. -swap_remit_disp try_build_swap_remit(const uwrit::uw_request_t& req, - name candidate, - uint64_t& dst_outpost_id, - std::vector& encoded) { - // Decode the stored SwapRequest for its `recipient` (the row keeps only - // chain/kind/amount summaries). Same bytes for every candidate ⇒ terminal. - opp::attestations::SwapRequest sr; - { - auto in = zpp::bits::in{ - std::span{req.attestation_inbound_data.data(), - req.attestation_inbound_data.size()}, - zpp::bits::no_size{}}; - if (in(sr) != zpp::bits::errc{}) return swap_remit_disp::terminal; - } - - // Destination outpost id + ChainKind from the `sysio.chains` registry — - // chain-level config; if unresolved, no winner can ever remit ⇒ terminal. - auto dst_outpost_opt = find_outpost_id_for_chain(req.dst_chain_code); - if (!dst_outpost_opt) return swap_remit_disp::terminal; - auto dst_kind_opt = chain_kind_for_code(req.dst_chain_code); - if (!dst_kind_opt) return swap_remit_disp::terminal; - const ChainKind dst_kind = *dst_kind_opt; - - // Resolve the winning underwriter's destination-chain pubkey from - // `sysio.authex::links` (`bynamechain`) so the SwapRemit carries the - // underwriter's auditable destination-chain settlement key. This is not - // the UIC's signed transaction-signer metadata: on EVM the remit carries a - // 33-byte compressed EM key while `uw_ext_chain_addr` carries the 20-byte - // address, so the two fields are deliberately not byte-compared. - // A winner without a dst-chain link cannot ship a SwapRemit with a - // populated underwriter, but this is candidate-specific: disqualify them - // so the race can resolve for another underwriter (NOT terminal). - sysio::authex::links_t links(uwrit::AUTHEX_ACCOUNT); - auto idx = links.get_index<"bynamechain"_n>(); - auto it = idx.find(sysio::to_namechain_key(candidate, dst_kind)); - if (it == idx.end()) return swap_remit_disp::disqualified; - std::vector uw_addr = sysio::pubkey_to_bytes(it->pub_key); - if (uw_addr.empty()) return swap_remit_disp::disqualified; - - // All identities resolved — build the envelope. - opp::attestations::SwapRemit remit; - remit.recipient = sr.recipient; - // FORCE recipient.kind to the dst chain's actual ChainKind. The ETH - // outpost ships SwapRequest with `recipient.kind = CHAIN_KIND_UNKNOWN` - // ("depot routes by chain_code, outposts decode by their own kind"), but - // the SOL off-chain cranker (`extract_inbound_recipient_pubkeys`) forwards - // the recipient pubkey only when `kind == CHAIN_KIND_SVM`; UNKNOWN → dropped - // → on-chain `handle_swap_remit` rejects "recipient not in - // remaining_accounts". Per the project rule against 0-as-sentinel enums. - remit.recipient.kind = dst_kind; - remit.amount = opp::types::TokenAmount{ - .token_code = req.dst_token_code.value, - .amount = static_cast(req.dst_amount), - }; - // `original_message_id` low 8 bytes encode uwreq_id; the reflected - // SWAP_REMIT envelope back to msgch's dispatch uses this for the - // release-trigger decode (see sysio.msgch.cpp's SWAP_REMIT case). - remit.original_message_id.assign(32, 0); - for (size_t i = 0; i < 8; ++i) { - remit.original_message_id[i] = - static_cast((req.id >> (i * 8)) & 0xff); - } - remit.chain_code = req.dst_chain_code.value; - remit.reserve_code = req.dst_reserve_code.value; - remit.underwriter.kind = dst_kind; - remit.underwriter.address = std::move(uw_addr); - remit.unlock_timestamp = 0; - - // `no_size{}` — see emit_swap_revert for the rationale. - encoded.clear(); - auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; - (void)out(remit); - dst_outpost_id = *dst_outpost_opt; - return swap_remit_disp::ok; -} - -/// Queue a pre-built SWAP_REMIT envelope (from `try_build_swap_remit`) to the -/// destination outpost. Sent by `try_select_winner` AFTER the reserve mutation -/// in the same transaction, so every intervening quote prices the post-swap -/// books. The destination outpost's ReserveManager (ETH) / reserve PDA (SOL) -/// pays the recipient inline via `_handleSwapRemit` / `handle_swap_remit`. -/// Non-throwing. -void queue_swap_remit(name self, uint64_t dst_outpost_id, - const std::vector& encoded) { - action( - permission_level{self, "active"_n}, - uwrit::MSGCH_ACCOUNT, "queueout"_n, - std::make_tuple(dst_outpost_id, - opp::types::AttestationType::ATTESTATION_TYPE_SWAP_REMIT, encoded) - ).send(); -} - -/// Typed outcome of verifying one required UIC leg. -enum class uic_signature_result { - valid, - empty_uic, - malformed_uic, - non_canonical_uic, - missing_signature, - invalid_signature_length, - unsupported_signature_type, - non_canonical_signature, - digest_encoding_failed, - recovery_failed, - unauthorized_key, -}; - -/// Identifies which required UIC leg failed verification. -enum class uic_leg { - source, - destination, -}; - -/// True only when one incoming side already carries complete stored evidence. -bool required_uic_leg_populated(const uwrit::commit_entry& entry, uic_leg leg) { - return leg == uic_leg::source - ? !entry.source_uic_bytes.empty() && entry.source_received_at_ms != 0 - : !entry.dest_uic_bytes.empty() && entry.dest_received_at_ms != 0; -} - -/// True when every outpost-backed side has complete stored UIC evidence. -bool required_uic_legs_complete(const uwrit::uw_request_t& request, - const uwrit::commit_entry& entry) { - const bool source_complete = leg_is_depot(request.src_chain_code) || - required_uic_leg_populated(entry, uic_leg::source); - const bool destination_complete = leg_is_depot(request.dst_chain_code) || - required_uic_leg_populated(entry, uic_leg::destination); - return source_complete && destination_complete; -} - -/** - * @brief Validate and construct one fixed-size ECDSA UIC signature variant. - * @tparam VariantIndex Exact `sysio::signature` alternative to construct. - * @param packed Packed bytes including the one-byte variant tag. - * @param curve Curve order governing `r` and `s`. - * @param recovery_position Position of the compact recovery byte. - * @param recovery_encoding Required numeric recovery-byte representation. - * @param parsed_sig Destination signature variant. - * @return Typed length/canonicality outcome, or `valid` after construction. - */ -template -uic_signature_result parse_uic_ecc_signature( - std::span packed, - opp::uic_ecdsa_curve curve, - opp::uic_ecdsa_recovery_position recovery_position, - opp::uic_ecdsa_recovery_encoding recovery_encoding, - sysio::signature& parsed_sig) { - if (packed.size() != PACKED_ECC_SIGNATURE_SIZE) { - return uic_signature_result::invalid_signature_length; - } - const auto body = packed.subspan(PACKED_SIGNATURE_TAG_SIZE); - if (!opp::is_canonical_uic_ecdsa_signature( - body, curve, recovery_position, recovery_encoding)) { - return uic_signature_result::non_canonical_signature; - } - - sysio::ecc_signature compact_sig{}; - std::copy_n(body.begin(), compact_sig.size(), compact_sig.begin()); - parsed_sig.emplace(std::move(compact_sig)); - return uic_signature_result::valid; -} - -/// Verify the embedded signature in `uic_bytes` was produced by a key on -/// EITHER the `underwriter` account's `active` OR `owner` permission, over -/// the digest `sha256(serialize(uic_with_signature_blanked))`. -/// -/// The accepted wire shapes are the fixed-size recoverable variants already -/// supported by WIRE providers: K1, R1, and EM (one tag byte plus a 65-byte ECC -/// body), and ED (one tag byte plus a 96-byte body). Each body is copied into -/// its known variant directly instead of routing attacker-controlled bytes -/// through the generic variant deserializer, whose fixed-array underrun calls -/// `check()` and would abort the enclosing evalcons transaction. Variable-size -/// WebAuthn and unrecoverable BLS signatures are rejected before construction. -/// -/// Per `feedback_opp_handlers_never_throw.md`, every attacker-controlled -/// failure is returned as a typed result. Other permissions (custom names) -/// are intentionally not checked: the plugin is pinned to `active` or -/// `owner`, and accepting a custom permission would create an unconfigured -/// authorization surface. -uic_signature_result verify_uic_signature(name underwriter, - const std::vector& uic_bytes) { - if (uic_bytes.empty()) return uic_signature_result::empty_uic; - - // Decode the UIC payload. - opp::attestations::UnderwriteIntentCommit uic; - { - auto in = zpp::bits::in{ - std::span{uic_bytes.data(), uic_bytes.size()}, - zpp::bits::no_size{}}; - if (in(uic) != zpp::bits::errc{}) return uic_signature_result::malformed_uic; - } - - // Require the exact canonical protobuf encoding before hashing or recovery. - // zpp decode normalizes duplicate/default/unknown-field representations; - // re-encoding the complete message (with its original signature intact) - // makes every alternate byte sequence fail closed without throwing. - std::vector canonical_uic; - auto canonical_out = zpp::bits::out{canonical_uic, zpp::bits::no_size{}}; - if (canonical_out(uic) != zpp::bits::errc{}) { - return uic_signature_result::digest_encoding_failed; - } - if (canonical_uic != uic_bytes) { - return uic_signature_result::non_canonical_uic; - } - - // Validate the exact fixed-size shape before doing any hashing or - // constructing the signature variant. This both bounds work on malformed - // inputs and makes the former generic-deserializer underrun unreachable. - const std::vector sig_bytes_view{uic.signature.begin(), uic.signature.end()}; - if (sig_bytes_view.empty()) return uic_signature_result::missing_signature; - const auto signature_variant = magic_enum::enum_cast( - static_cast(sig_bytes_view.front())); - if (!signature_variant) { - return uic_signature_result::unsupported_signature_type; - } - - sysio::signature parsed_sig; - switch (*signature_variant) { - case uic_signature_variant::k1: - if (const auto result = parse_uic_ecc_signature( - sig_bytes_view, opp::uic_ecdsa_curve::secp256k1, - opp::uic_ecdsa_recovery_position::prefix, - opp::uic_ecdsa_recovery_encoding::compact, parsed_sig); - result != uic_signature_result::valid) return result; - break; - case uic_signature_variant::r1: - if (const auto result = parse_uic_ecc_signature( - sig_bytes_view, opp::uic_ecdsa_curve::p256, - opp::uic_ecdsa_recovery_position::prefix, - opp::uic_ecdsa_recovery_encoding::compact, parsed_sig); - result != uic_signature_result::valid) return result; - break; - case uic_signature_variant::em: - if (const auto result = parse_uic_ecc_signature( - sig_bytes_view, opp::uic_ecdsa_curve::secp256k1, - opp::uic_ecdsa_recovery_position::suffix, - opp::uic_ecdsa_recovery_encoding::ethereum, parsed_sig); - result != uic_signature_result::valid) return result; - break; - case uic_signature_variant::ed: { - if (sig_bytes_view.size() != PACKED_ED_SIGNATURE_SIZE) { - return uic_signature_result::invalid_signature_length; - } - sysio::ed_signature compact_sig{}; - std::copy_n(sig_bytes_view.begin() + PACKED_SIGNATURE_TAG_SIZE, - compact_sig.size(), compact_sig.begin()); - parsed_sig.emplace(std::move(compact_sig)); - break; - } - case uic_signature_variant::wa: - case uic_signature_variant::bls: - default: - return uic_signature_result::unsupported_signature_type; - } - - // Blank the signature and recompute the signed digest. - uic.signature.clear(); - - std::vector blanked; - auto out = zpp::bits::out{blanked, zpp::bits::no_size{}}; - if (out(uic) != zpp::bits::errc{}) { - return uic_signature_result::digest_encoding_failed; - } - - sysio::checksum256 digest = - sysio::sha256(blanked.data(), blanked.size()); - - // Recover the public key through the rc-returning intrinsic. Exact - // fixed-size construction above makes the subjective variable-size - // signature guard unreachable, while bad recovery bytes or curve values - // return nullopt. - auto recovered_opt = sysio::try_recover_key(digest, parsed_sig); - if (!recovered_opt) return uic_signature_result::recovery_failed; - const sysio::public_key& recovered = *recovered_opt; - - // Only `owner` and `active` permissions are considered. The - // underwriter_plugin's signature_provider_manager_plugin config is - // pinned to one of those two (see plugin docs); accepting a custom - // permission would open a separate authorization surface that nothing - // else on the platform validates. - constexpr sysio::name OWNER_PERM = "owner"_n; - constexpr sysio::name ACTIVE_PERM = "active"_n; - for (auto perm : { ACTIVE_PERM, OWNER_PERM }) { - auto rec_opt = sysio::get_permission(underwriter, perm); - if (!rec_opt) continue; - for (const auto& kw : rec_opt->auth.keys) { - // A UIC carries exactly one signature. A direct key therefore - // authorizes it only when that key's weight alone reaches this - // permission's threshold; mere membership in a multisig authority - // is not sufficient. - if (kw.key == recovered && kw.weight >= rec_opt->auth.threshold) { - return uic_signature_result::valid; - } - } - } - return uic_signature_result::unauthorized_key; -} - -/// Create the durable rejection reason from typed leg and verification values. -std::string create_uic_signature_rejection_reason(uic_leg leg, - uic_signature_result result) { - const auto leg_name = magic_enum::enum_name(leg); - const auto result_name = magic_enum::enum_name(result); - - std::string reason{UIC_SIGNATURE_REJECTED_REASON_PREFIX}; - reason.append(leg_name.data(), leg_name.size()); - reason.append(UIC_SIGNATURE_REJECTED_REASON_SEPARATOR); - reason.append(result_name.data(), result_name.size()); - return reason; -} - -/// Allocate a fresh `lock_id` from the uwcounters singleton. -uint64_t next_lock_id(name self) { - uwrit::uwcounters_t ctr_tbl(self); - auto ctr = ctr_tbl.get_or_default(uwrit::uw_counters{}); - uint64_t id = ctr.next_lock_id; - ctr.next_lock_id = id + 1; - ctr_tbl.set(ctr, ram_payer); - return id; -} - -/// Allocate a depot-origin (swap-from-WIRE) id: the high-bit-tagged -/// sequence that doubles as both the fwqueue row id and the eventual -/// uwreq id. Disjoint from msgch's inbound attestation-id space. -uint64_t next_fromwire_id(name self) { - uwrit::uwcounters_t ctr_tbl(self); - auto ctr = ctr_tbl.get_or_default(uwrit::uw_counters{}); - uint64_t id = DEPOT_ORIGIN_ID_BASE | ctr.next_fromwire_seq; - ctr.next_fromwire_seq += 1; - ctr_tbl.set(ctr, ram_payer); - return id; -} - -} // anonymous namespace - -// --------------------------------------------------------------------------- -// setconfig -// --------------------------------------------------------------------------- -void uwrit::setconfig(uint32_t fee_bps, - uint64_t collateral_lock_duration_ms, - uint64_t min_fromwire_amount, - uint32_t fromwire_revert_fee_bps, - uint32_t uwreq_pending_timeout_epochs, - uint32_t uwreq_retention_epochs) { - require_auth(get_self()); - // Reject a 100% (or higher) fee: it zeroes the post-fee WIRE leg - // (`net == 0`), which let a swap debit destination reserve liquidity while - // crediting zero WIRE (SEC-26 / WSA-042). MAX_FEE_BPS == 9999 keeps the - // remainder positive for every positive input — for THIS rate alone. Reserve - // owner fees stack on the same leg under their own cap, so the total can - // still reach 100%; `sysio.reserv`'s settlement `net > 0` checks reject that - // configured combination. - check(fee_bps <= MAX_FEE_BPS, - "fee_bps must be below 10000 (100%): a 100% fee zeroes the post-fee WIRE leg"); - check(collateral_lock_duration_ms > 0, - "collateral_lock_duration_ms must be positive"); - // Reject a duration so large that `now_ms + collateral_lock_duration_ms` - // would wrap past UINT64_MAX and yield a lock that expires in the past, - // releasing collateral the instant it is placed. - check(collateral_lock_duration_ms <= MAX_COLLATERAL_LOCK_DURATION_MS, - "collateral_lock_duration_ms exceeds the one-year ceiling"); - // A zero floor would reopen free dust rows in the swap-from-WIRE queue — - // the floor is the queue-slot price (see DEFAULT_MIN_FROMWIRE_AMOUNT). - check(min_fromwire_amount > 0, "min_fromwire_amount must be positive"); - // Same 100% rationale as fee_bps, and on this path it is also a liveness - // rail: a 100% revert fee would zero the post-fee refund transfer inside - // the never-throw drainfwq drain (`refundwire` rejects zero transfers). - check(fromwire_revert_fee_bps <= MAX_FEE_BPS, - "fromwire_revert_fee_bps must be below 10000 (100%): a 100% revert fee zeroes the refund"); - // A zero pending timeout would expire every uwreq the epoch it is created — - // no race could ever resolve; a zero retention would erase terminal rows - // before the audit window they exist for. The shared ceiling prevents a - // near-UINT32_MAX knob from wrapping the `current_epoch + knob` deadline - // stamp to a tiny epoch index (instant expiry). - check(uwreq_pending_timeout_epochs > 0, - "uwreq_pending_timeout_epochs must be positive"); - check(uwreq_pending_timeout_epochs <= MAX_UWREQ_LIFECYCLE_EPOCHS, - "uwreq_pending_timeout_epochs exceeds the lifecycle ceiling"); - check(uwreq_retention_epochs > 0, - "uwreq_retention_epochs must be positive"); - check(uwreq_retention_epochs <= MAX_UWREQ_LIFECYCLE_EPOCHS, - "uwreq_retention_epochs exceeds the lifecycle ceiling"); - - uwconfig_t cfg_tbl(get_self()); - uw_config cfg = cfg_tbl.get_or_default(uw_config{}); - cfg.fee_bps = fee_bps; - cfg.collateral_lock_duration_ms = collateral_lock_duration_ms; - cfg.min_fromwire_amount = min_fromwire_amount; - cfg.fromwire_revert_fee_bps = fromwire_revert_fee_bps; - cfg.uwreq_pending_timeout_epochs = uwreq_pending_timeout_epochs; - cfg.uwreq_retention_epochs = uwreq_retention_epochs; - cfg_tbl.set(cfg, ram_payer); -} - -// --------------------------------------------------------------------------- -// createuwreq — called inline from sysio.msgch when SWAP arrives -// --------------------------------------------------------------------------- -void uwrit::createuwreq(uint64_t attestation_id, - opp::types::AttestationType type, - uint64_t chain_code, - std::vector data) { - require_auth(MSGCH_ACCOUNT); - - uwreqs_t reqs(get_self()); - auto pk = id_key{attestation_id}; - // Duplicate-delivery is the protocol's normal idempotency case — every - // batch op re-relays the same envelope on each cron tick until the - // depot advances the epoch, so the second, third, ... batch op's - // `deliver → evalcons → dispatch → createuwreq` call lands on a row - // that's already present. Per `feedback_opp_handlers_never_throw.md` - // a `check()` here halts `evalcons` and stalls consensus across the - // chain. Silently no-op the duplicate and let the relay continue. - if (reqs.contains(pk)) { - sysio::print("createuwreq: uwreq ", attestation_id, - " already exists, skipping idempotent re-delivery\n"); - return; - } - - // Only SWAP_REQUEST attestations create UWREQs — msgch's dispatch routes - // other types directly to their handlers, not through createuwreq. A - // non-SWAP type can only arrive from a future/buggy dispatcher; per - // `feedback_opp_handlers_never_throw.md` a check() here would halt evalcons - // and stall consensus, so log + skip instead of asserting. - if (type != AttestationType::ATTESTATION_TYPE_SWAP_REQUEST) { - sysio::print("createuwreq: non-SWAP_REQUEST attestation ", attestation_id, - " routed to createuwreq, skipping\n"); - return; - } - - // Decode the operator-supplied SwapRequest. A malformed payload cannot be - // refunded (emit_swap_revert needs the decoded actor / source_amount to - // tell the outpost which deposit to reverse — exactly what failed to - // decode), and a check() here would abort the consensus-tipping delivery - // before any uwreq row exists, stalling epoch advancement chain-wide. Log + - // skip: no row is created, so no state is corrupted and the relay - // continues. Per `feedback_opp_handlers_never_throw.md`. - SwapRequest sr; - { - auto in = zpp::bits::in{std::span{data.data(), data.size()}, zpp::bits::no_size{}}; - if (in(sr) != zpp::bits::errc{}) { - sysio::print("createuwreq: failed to decode SwapRequest for attestation ", - attestation_id, ", skipping (undecodable — cannot revert)\n"); - return; - } - } - - // One config snapshot for the whole ingestion: the variance quote and the - // PENDING deadline stamp below must read one consistent config. - const uw_config cfg = read_config(get_self()); - - // Pull the slug_name triples + amounts out of the decoded SwapRequest. - // The source token's code lives on the TokenAmount; the source chain - // and source reserve are top-level fields. Destination has all three - // top-level. - const sysio::slug_name src_chain_code{sr.source_chain_code}; - const sysio::slug_name src_token_code{sr.source_amount.token_code}; - const sysio::slug_name src_reserve_code{sr.source_reserve_code}; - const sysio::slug_name dst_chain_code{sr.target_chain_code}; - const sysio::slug_name dst_token_code{sr.target_token_code}; - const sysio::slug_name dst_reserve_code{sr.target_reserve_code}; - // WSA-028: source_amount is signed on the wire. Gate it through the shared - // fail-closed parser; a non-positive or out-of-range value yields nullopt and - // is reverted (refund on the proven outpost) by the positivity guard below, - // never wrapped into a huge src_amount that would corrupt the swap quote and - // reserve settlement. Never-throw: revert, do not check(). - const std::optional src_amount_opt = - opp::safe::to_depot_amount(static_cast(sr.source_amount.amount)); - - // WSA-005 source-chain binding. `chain_code` is the source outpost `sysio.msgch::dispatch` - // proved via `deliver` (an active, non-depot `sysio.chains` row); `sr.source_chain_code` is the - // payload's self-asserted source. They MUST be identical — a SwapRequest about a deposit on - // chain X is always relayed by outpost X. A divergence means an envelope proven from outpost A is - // claiming the swap was funded on a different chain B; settling it would lock/draw against B's - // source reserve while the user's funds are escrowed on A — a cross-chain settlement forgery. - // Refund on the PROVEN outpost (A, == `chain_code`, where the deposit actually sits) and create - // no uwreq. Never throw: we are inside the evalcons dispatch chain, where a `check()` stalls - // consensus chain-wide (`feedback_opp_handlers_never_throw`). Checked before the amount/registry - // guards because provenance is the most fundamental precondition. - if (sr.source_chain_code != chain_code) { - emit_swap_revert(get_self(), chain_code, attestation_id, sr, - sysio::slug_name{chain_code}, src_reserve_code, - "SwapRequest rejected: source chain does not match the proven " - "delivering outpost (cross-chain provenance mismatch)"); - return; - } - - // The four payload-controlled codes must have a canonical string spelling, because a - // uwreq row IS created from them below on a path that no registry lookup gates: the - // zero-quote guard fails closed only when `required_reserves_active` holds, so a - // MISSING reserve (the unprovisioned-LP case) falls through to `reqs.emplace` with - // these codes stored verbatim. Rendering is total, so an uncanonical code does not - // make the row unreadable — it makes it WRONG: the rendered text either fails - // validation on the way back, or re-parses as a DIFFERENT reserve than the uwreq was - // created from. Refund rather than drop: the user's deposit is escrowed on the source - // outpost, so a silent skip would strand it. Never `check()` — we are inside the - // evalcons dispatch chain (`feedback_opp_handlers_never_throw`). - // - // The two CHAIN codes are deliberately absent: `src_chain_code` was just proven equal - // to the delivering outpost's `chain_code`, and `dst_chain_code` must pass - // `chain_registered_active` below. Both therefore name a `sysio.chains` row, and - // `sysio.chains::regchain` refuses a code with no canonical spelling — so the registry itself - // carries that guarantee. - if (!src_token_code.is_canonical() || !src_reserve_code.is_canonical() || - !dst_token_code.is_canonical() || !dst_reserve_code.is_canonical()) { - emit_swap_revert(get_self(), chain_code, attestation_id, sr, - src_chain_code, src_reserve_code, - "SwapRequest rejected: a token or reserve code has no canonical " - "slug_name spelling"); - return; - } - - // An exact source/destination reserve identity has only one outpost leg. - // Admitting it would leave the ordinary two-leg request waiting forever - // for a second distinct commitment. Refund on the proven source outpost - // and create no UWREQ; same-asset cross-chain swaps and different reserve - // codes remain distinct triples and continue through the normal path. - if (src_chain_code == dst_chain_code && - src_token_code == dst_token_code && - src_reserve_code == dst_reserve_code) { - emit_swap_revert(get_self(), chain_code, attestation_id, sr, - src_chain_code, src_reserve_code, - "SwapRequest rejected: source and destination reserve " - "identities must differ"); - return; - } - - // Structural guards — refund via SwapRevert, never throw (we are inside - // the evalcons dispatch chain). Zero amounts are rejected up front so - // every downstream lock/settlement amount is provably positive (a - // zero-amount lock would trip `opreg::releaselock`'s amount check from - // inside `chklocks` at epoch advance — a consensus stall). - if (!src_amount_opt || sr.target_amount == 0) { - emit_swap_revert(get_self(), chain_code, attestation_id, sr, - src_chain_code, src_reserve_code, - "SwapRequest rejected: source and target amounts must be positive"); - return; - } - const uint64_t src_amount = *src_amount_opt; - if (!chain_registered_active(src_chain_code) || !chain_registered_active(dst_chain_code)) { - emit_swap_revert(get_self(), chain_code, attestation_id, sr, - src_chain_code, src_reserve_code, - "SwapRequest rejected: unregistered or inactive chain"); - return; - } - const bool src_depot = leg_is_depot(src_chain_code); - const bool dst_depot = leg_is_depot(dst_chain_code); - if (src_depot && dst_depot) { - emit_swap_revert(get_self(), chain_code, attestation_id, sr, - src_chain_code, src_reserve_code, - "SwapRequest rejected: WIRE->WIRE swap is degenerate — " - "use sysio.token::transfer"); - return; - } - if (src_depot) { - // Swap-from-WIRE originates ON the depot (swapfromwire -> drainfwq); - // an outpost claiming WIRE as its source chain is bogus. - emit_swap_revert(get_self(), chain_code, attestation_id, sr, - src_chain_code, src_reserve_code, - "SwapRequest rejected: swap-from-WIRE cannot originate " - "from an outpost"); - return; - } - - // Hard-fail any SwapRequest without a populated `source_tx_id`. The - // off-chain underwriter verify path uses this id to confirm a real - // on-chain deposit backs the swap before committing collateral; a - // SwapRequest without it can't be verified, and an outpost is - // required to populate the field at swap-emit time. Per - // `feedback_opp_handlers_never_throw.md` we cannot `check()`/throw - // here (we're inside the evalcons dispatch chain — a throw stalls - // consensus); instead emit a SwapRevert back to the source outpost so - // the user's deposit is refunded and the run continues. - if (sr.source_tx_id.empty()) { - emit_swap_revert(get_self(), chain_code, attestation_id, sr, - src_chain_code, src_reserve_code, - "SwapRequest rejected: source_tx_id is required " - "(no SwapRequest may be emitted without a " - "populated source-chain transaction id)"); - return; - } - - // Row-growth rails (SEC-129 / WSA-223): the row stores these payloads - // verbatim and every later `modify` re-serializes the whole row inside the - // never-throw dispatch surfaces, so oversized inputs are refused at the - // door. All caps sit far above legitimate traffic; a violation is a - // malformed/hostile outpost payload — refund it like every other - // structural rejection, never check(). - if (data.size() > MAX_ATTESTATION_INBOUND_DATA_BYTES || - sr.source_tx_id.size() > MAX_SOURCE_TX_ID_BYTES || - sr.actor.address.size() > MAX_DEPOSITOR_BYTES) { - emit_swap_revert(get_self(), chain_code, attestation_id, sr, - src_chain_code, src_reserve_code, - "SwapRequest rejected: payload exceeds a size cap " - "(attestation data / source_tx_id / depositor address)"); - return; - } - - // Privacy gate — a private reserve only swaps against a counterpart - // reserve owned by the same WIRE account (the authex-linked matcher - // recorded at match time), and is excluded from WIRE-endpoint swaps - // entirely. Ownership is immutable while a reserve is ACTIVE, so this - // ingestion-time gate needs no race-time recheck. - { - const auto src_r = find_reserve(src_chain_code, src_token_code, src_reserve_code); - if (dst_depot) { - // Swap-to-WIRE: only the source reserve exists — it must be public. - if (src_r && src_r->is_private) { - emit_swap_revert(get_self(), chain_code, attestation_id, sr, - src_chain_code, src_reserve_code, - "SwapRequest rejected: private reserves are " - "excluded from WIRE-endpoint swaps"); - return; - } - } else { - const auto dst_r = find_reserve(dst_chain_code, dst_token_code, dst_reserve_code); - const bool src_priv = src_r && src_r->is_private; - const bool dst_priv = dst_r && dst_r->is_private; - if (src_priv || dst_priv) { - const bool same_owner = src_r && dst_r - && src_r->owner != name{} - && src_r->owner == dst_r->owner; - if (!same_owner) { - emit_swap_revert(get_self(), chain_code, attestation_id, sr, - src_chain_code, src_reserve_code, - "SwapRequest rejected: private reserve pairing " - "violation — counterpart reserve must be owned " - "by the same WIRE account"); - return; - } - } - } - } - - // Price the swap on the depot's live curve. This quote — NOT the caller's - // `target_amount` — is what the row carries as `dst_amount` (WNS-02). - // `target_amount` is the caller's *expectation*, an unauthenticated number - // that arrives over OPP, while `dst_amount` is paid out verbatim by - // `sysio.reserv::applyswap` / `applyfromwire` / `paywire`. Settling on the - // caller's figure let a small source deposit name an arbitrary destination - // amount and drain the destination reserve. - const uint64_t current_quote = swap_quote(src_chain_code, src_token_code, src_reserve_code, - dst_chain_code, dst_token_code, dst_reserve_code, - src_amount, cfg.fee_bps); - // Zero quote with every required reserve ACTIVE = an unpriceable / degenerate - // reserve (a side drained to zero, extreme connector weights, or a leg too - // small for the weighted-Bancor kernel to price). Fail closed (WSA-041) — - // otherwise the row would be created with `dst_amount == 0`, which - // `applyswap` and its siblings assert against, aborting inside evalcons. - // The gate is no longer nested under `target_amount != 0`: it protects - // `dst_amount`, which has nothing to do with the caller's target, so it must - // not depend on the positive-amounts gate above to be reached. - // - // A zero quote from a MISSING / not-ACTIVE reserve is the dev & smoke cluster - // case: no LP is provisioned, so the row is created (the underwriter race - // still runs) but cannot settle until a reserve exists — `try_select_winner` - // re-quotes and only ever settles against ACTIVE reserves. - if (current_quote == 0 && - required_reserves_active(src_chain_code, src_token_code, src_reserve_code, - dst_chain_code, dst_token_code, dst_reserve_code)) { - emit_swap_revert(get_self(), chain_code, attestation_id, sr, - src_chain_code, src_reserve_code, - "unpriceable reserve: zero quote from an ACTIVE reserve " - "(degenerate weights/balances) — fail closed"); - return; // no UWREQ created - } - // Variance tolerance — the caller's slippage bound on the quote they will be - // given, measured as a fraction of that quote (see `variance_allowance`). - // A zero `target_amount` was already refused by the positive-amounts gate - // above, so that term is defensive; the live skip is `current_quote == 0`, - // the unprovisioned-LP case, where there is no price to compare against. - if (sr.target_amount != 0 && current_quote != 0) { - const uint64_t target = sr.target_amount; - const uint64_t diff = current_quote > target ? current_quote - target - : target - current_quote; - const uint128_t allowed = variance_allowance(current_quote, sr.target_tolerance_bps); - if (static_cast(diff) > allowed) { - emit_swap_revert(get_self(), chain_code, attestation_id, sr, - src_chain_code, src_reserve_code, - "variance exceeded tolerance: target=" + std::to_string(target) - + " current=" + std::to_string(current_quote) - + " tolerance_bps=" + std::to_string(sr.target_tolerance_bps)); - return; // no UWREQ created - } - } - - reqs.emplace(ram_payer, pk, uw_request_t{ - .id = attestation_id, - .type = type, - .status = UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_PENDING, - .src_chain_code = src_chain_code, - .src_token_code = src_token_code, - .src_reserve_code = src_reserve_code, - .src_amount = src_amount, - .dst_chain_code = dst_chain_code, - .dst_token_code = dst_token_code, - .dst_reserve_code = dst_reserve_code, - // The AMM quote, never `sr.target_amount` (WNS-02). Re-quoted and - // overwritten with the live price at race resolution before anything - // settles against it. - .dst_amount = current_quote, - // The caller's expectation, retained as the FIXED reference the - // race-time slippage check measures against. Never paid out. - .target_amount = sr.target_amount, - .variance_tolerance_bps = sr.target_tolerance_bps, - .source_tx_id = sr.source_tx_id, - .depositor = sr.actor.address, - .commits_by = {}, - .winner = name{}, - .committed_at_ms = 0, - .settled_at_ms = 0, - // The PENDING deadline (SEC-129 / WSA-223): if no underwriter wins the - // race within this many epochs, `pruneuwreqs` expires the row and - // refunds the source side. No trigger ever announces "no winner is - // coming" — commits are external and optional — so the deadline is what - // converts a later epoch advance into the recovery trigger. - .expires_at_epoch = get_current_epoch() + cfg.uwreq_pending_timeout_epochs, - .attestation_inbound_data = std::move(data), - .attestation_outbound_data = {}, - }); -} - -// --------------------------------------------------------------------------- -// Internal: try_select_winner — race resolver -// --------------------------------------------------------------------------- - -namespace { - -/// Helper: find or create the commit_entry for `underwriter` inside an -/// uw_request_t. Returns iterator-like reference into the in-place vector. -uwrit::commit_entry* find_or_create_commit(uwrit::uw_request_t& req, name underwriter) { - for (auto& c : req.commits_by) { - if (c.underwriter == underwriter) return &c; - } - req.commits_by.push_back(uwrit::commit_entry{ - .underwriter = underwriter, - }); - return &req.commits_by.back(); -} - -/// Mark a single candidate's commit_entry as race-disqualified and leave the -/// uwreq PENDING so the race can still resolve for another valid underwriter. -/// Non-throwing. -/// -/// Uses the dedicated `UNDERWRITE_STATUS_DISQUALIFIED` status — a -/// candidate-specific, durable pre-settlement invalidity (for example, an -/// older stored UIC invalidated by permission-key rotation or a missing -/// destination-authex identity link). It is -/// deliberately distinct from `UNDERWRITE_STATUS_SLASHED` (an economic -/// punishment that actually burned collateral) and from -/// `UNDERWRITE_STATUS_RELEASED` (a clean race loser whose commit was valid): -/// conflating the three would mislead downstream indexing and audits. A -/// disqualified entry is durable for this request: later records naming the -/// candidate cannot rewrite its evidence or re-arm it for winner selection. -/// Raw UIC payloads are cleared because terminal candidates have no remaining -/// signature-validation reader; timestamps, outpost ids, status, and reason -/// remain as the compact audit record. -void disqualify_candidate(uwrit::uwreqs_t& reqs, const uwrit::id_key& pk, - name candidate, const std::string& reason) { - reqs.modify(same_payer, pk, [&](auto& r) { - auto* c = find_or_create_commit(r, candidate); - c->status = UnderwriteStatus::UNDERWRITE_STATUS_DISQUALIFIED; - c->reason = reason; - c->source_uic_bytes.clear(); - c->dest_uic_bytes.clear(); - }); -} - -/// Emit the stable rejection marker for an invalid claimed candidate UIC. -/// `chain_code` is the provenance-bound slug supplied by msgch, not an outpost -/// table id. Logging is the only effect when pre-storage validation fails. -void log_uic_signature_rejection(uint64_t uwreq_id, - name candidate, - uic_leg leg, - uint64_t chain_code, - uic_signature_result result) { - const std::string leg_name{magic_enum::enum_name(leg)}; - const std::string result_name{magic_enum::enum_name(result)}; - sysio::print(UIC_SIGNATURE_REJECTED_LOG_PREFIX, - ": uwreq=", uwreq_id, - ", claimed_underwriter=", candidate, - ", leg=", leg_name, - ", chain_code=", chain_code, - ", reason=", result_name, "\n"); -} - -/// Revalidate an older stored leg when its matching leg arrives. This protects -/// the race from WIRE permission-key rotation between leg arrivals: a key that -/// authorized the first leg may no longer be authorized when the pair becomes -/// eligible to win. Returning false is a normal consensus-path outcome. -bool validate_candidate_uic(uwrit::uwreqs_t& reqs, - const uwrit::id_key& pk, - uint64_t uwreq_id, - name candidate, - uic_leg leg, - uint64_t chain_code, - const std::vector& uic_bytes) { - const auto result = verify_uic_signature(candidate, uic_bytes); - if (result == uic_signature_result::valid) return true; - - const auto reason = create_uic_signature_rejection_reason(leg, result); - log_uic_signature_rejection(uwreq_id, candidate, leg, chain_code, result); - disqualify_candidate(reqs, pk, candidate, reason); - return false; -} - -/// Terminally close a uwreq that can never settle: refund the source side and -/// mark the row with `terminal_status` (REJECTED for active rejections — the -/// default; EXPIRED when `pruneuwreqs` abandons a PENDING race past its -/// deadline), releasing any in-flight commits. **Non-throwing** — safe inside -/// the evalcons dispatch chain (a `check()` here would stall OPP consensus -/// chain-wide). The refund routes by source-leg kind: -/// * outpost source (`src_needed`): best-effort SWAP_REVERT back to the -/// source outpost (requires the stored SwapRequest to decode; a depot -/// source has no outpost to route to, so `find_outpost_id_for_chain` -/// returns nullopt and the revert is skipped). -/// * depot source (from-WIRE): refund the escrowed WIRE to the -/// depositor via `reserv::refundwire`. -/// The terminal modify also stamps the retention deadline -/// (`uwconfig.uwreq_retention_epochs`) and clears the row's heavy payloads -/// (inbound attestation copy + every stored UIC byte blob) — they were only -/// needed to reach this decision; the compact audit metadata (ids, codes, -/// amounts, statuses, reasons, timestamps) is what retention keeps. -void reject_and_refund(name self, uwrit::uwreqs_t& reqs, const uwrit::id_key& pk, - const uwrit::uw_request_t& req, bool src_needed, - const std::string& revert_reason, - const std::string& commit_reason, - UnderwriteRequestStatus terminal_status = - UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_REJECTED) { - if (src_needed) { - opp::attestations::SwapRequest sr; - auto in = zpp::bits::in{ - std::span{req.attestation_inbound_data.data(), - req.attestation_inbound_data.size()}, - zpp::bits::no_size{}}; - if (in(sr) == zpp::bits::errc{}) { - if (auto src_outpost_opt = find_outpost_id_for_chain(req.src_chain_code)) { - emit_swap_revert(self, *src_outpost_opt, req.id, sr, - req.src_chain_code, req.src_reserve_code, revert_reason); - } - } - } else if (auto user = parse_wire_name(req.depositor)) { - // Swap-from-WIRE: the escrowed WIRE was never credited to a reserve — - // refund it directly (there is no source outpost to route a revert to). - // Full refund: uwreq-stage rejection (race loss, post-drain market - // movement, expiry) is not a caller-controlled revert cause. - action( - permission_level{self, "active"_n}, - uwrit::RESERVE_ACCOUNT, "refundwire"_n, - std::make_tuple(*user, req.src_amount, REFUND_FEE_EXEMPT_BPS) - ).send(); - } else { - sysio::print("reject_and_refund: cannot parse from-WIRE depositor for " - "refund on uwreq ", req.id, "\n"); - } - reqs.modify(same_payer, pk, [&](auto& r) { - r.status = terminal_status; - r.settled_at_ms = current_time_ms(); - r.expires_at_epoch = get_current_epoch() + read_config(self).uwreq_retention_epochs; - for (auto& c : r.commits_by) { - if (c.status == UnderwriteStatus::UNDERWRITE_STATUS_INTENT_SUBMITTED) { - c.status = UnderwriteStatus::UNDERWRITE_STATUS_RELEASED; - c.reason = commit_reason; - } - // Terminal compaction (SEC-129 / WSA-223): the raw UIC blobs were - // only needed to resolve the race; retention keeps the compact - // audit metadata, not the payload bytes. - c.source_uic_bytes.clear(); - c.dest_uic_bytes.clear(); - } - // The refund above already decoded the snapshot's copy; the stored - // bytes have no remaining reader on a terminal row. - r.attestation_inbound_data.clear(); - }); -} - -/// Resolve the race once every REQUIRED leg of a (uwreq, underwriter) pair -/// has arrived. A leg is required iff its chain is an outpost; a depot -/// (WIRE) leg needs no UIC, no bond, and no lock — single-leg swaps -/// (to/from WIRE) therefore resolve on their one outpost commit. On a win: -/// verify the required legs' signatures + bond, pre-validate reserve -/// liquidity against the local mirror AND pre-build the outbound SWAP_REMIT -/// envelope (so both the inline reserv settlement actions and the remit are -/// unreachable-failure by construction — nothing past the CONFIRMED write can -/// `check()`-abort and stall evalcons), push one lock per required leg (a 12h -/// wall-clock challenge window — never released by delivery: `chklocks` sweeps -/// it at expiry, or `sweeplocks` erases it earlier on an UPHELD challenge), -/// mark CONFIRMED, then settle: -/// * normal — reserv::applyswap + SWAP_REMIT to the dst outpost -/// * from-WIRE — reserv::applyfromwire + SWAP_REMIT to the dst outpost -/// * to-WIRE — reserv::paywire (REAL WIRE to the recipient; no remit) -/// Stale/invalid evidence disqualifies a candidate durably. Candidate-local -/// eligibility, collateral, and identity-link gaps, plus mutable reserve -/// availability, remain PENDING until an outpost replays an exact stored UIC. -/// Every failure remains non-throwing inside the enclosing consensus dispatch. -void try_select_winner(name self, uint64_t uwreq_id, name candidate, - std::optional just_verified_leg) { - uwrit::uwreqs_t reqs(self); - auto pk = uwrit::id_key{uwreq_id}; - if (!reqs.contains(pk)) return; - auto req = reqs.get(pk); - if (req.status != UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_PENDING) return; - - const bool src_needed = !leg_is_depot(req.src_chain_code); - const bool dst_needed = !leg_is_depot(req.dst_chain_code); - - // The resolver only operates on one complete, live candidate. Keeping this - // invariant here prevents consensus dispatch from reaching costly - // permission recovery with incomplete or terminal evidence. - const uwrit::commit_entry* ce_ptr = nullptr; - for (const auto& c : req.commits_by) { - if (c.underwriter == candidate) { ce_ptr = &c; break; } - } - if (!ce_ptr || - ce_ptr->status != UnderwriteStatus::UNDERWRITE_STATUS_INTENT_SUBMITTED || - !required_uic_legs_complete(req, *ce_ptr)) { - return; - } - - // ── Underwriter eligibility — role + activation gate ───────────────── - // Mirrored bond availability and UIC signature recovery alone do NOT - // establish that `candidate` is an authorized underwriter. opreg registers - // every non-bootstrapped operator as UNKNOWN, forbids bootstrapping an - // underwriter active, and only flips an underwriter to ACTIVE through - // `opreg::processuw` once it clears `req_uw_collat`. The balance mirror - // (`available_via_mirrors`) zeroes only SLASHED / TERMINATED and ignores - // `op.type`, so without this gate a non-underwriter (PRODUCER / BATCH / - // CHALLENGER) or a not-yet-active underwriter (UNKNOWN / WARMUP / COOLDOWN) - // carrying enough mirrored balance and a valid UIC signature would be - // selected as the winner, consume lock capacity, and settle against the - // reserves. Require an ACTIVE UNDERWRITER before any signature work, lock, - // CONFIRMED write, or reserve mutation. This status can change through the - // ordinary opreg lifecycle, so retain valid UIC evidence and let an exact - // external replay retry after it becomes eligible rather than permanently - // disqualifying it. This resolver runs inside the evalcons dispatch chain, - // so it must remain non-throwing (`feedback_opp_handlers_never_throw`). - if (!is_active_underwriter(candidate)) { - sysio::print("try_select_winner: candidate ", candidate, - " is not an ACTIVE underwriter for uwreq ", uwreq_id, - ", leaving valid commit retryable\n"); - return; - } - - // ── T7: permission freshness ────────────────────────────────────────── - // `rcrdcommit` passes its incoming leg after verifying it immediately, so - // only the older stored opposite leg needs another recovery against current - // owner/active keys before locks or settlement. - if (src_needed && just_verified_leg != std::optional{uic_leg::source} && - !validate_candidate_uic(reqs, pk, uwreq_id, candidate, uic_leg::source, - ce_ptr->source_outpost_id, - ce_ptr->source_uic_bytes)) { - return; - } - if (dst_needed && just_verified_leg != std::optional{uic_leg::destination} && - !validate_candidate_uic(reqs, pk, uwreq_id, candidate, - uic_leg::destination, ce_ptr->dest_outpost_id, - ce_ptr->dest_uic_bytes)) { - return; - } - - // ── Settlement quote — re-price on the live curve ──────────────────── - // The books move in this transaction, so the price they move at is the price - // computed HERE, not the one quoted at ingestion. Settling on a stale quote - // would over-debit the destination reserve by exactly the drift the LP took - // between ingestion and the race. - // - // Only the quote is computed at this point — every decision it feeds is made - // below, AFTER the candidate has proved its bond. The quote must come first - // regardless, because it is what sizes the obligation the bond has to cover. - const uint64_t settle_quote = swap_quote( - req.src_chain_code, req.src_token_code, req.src_reserve_code, - req.dst_chain_code, req.dst_token_code, req.dst_reserve_code, - req.src_amount, current_fee_bps(self)); - if (settle_quote != 0) { - // Every downstream read of `req.dst_amount` — bond coverage below, the - // reserve pre-checks, the lock amount, the SWAP_REMIT payload, and the - // reserv settlement actions — now uses the live price, and the CONFIRMED - // write persists it as the amount the winner owes. When the curve cannot - // price the swap the stored ingestion quote stands in for the bond check; - // the request is terminated (or skipped) below before it could settle. - req.dst_amount = settle_quote; - } - - // ── Bond availability — required legs, aggregated per collateral bucket ─ - // Underwriter collateral is held and rolled up by (underwriter, - // chain_code, token_code) — NOT by reserve_code (see `available_via_mirrors` - // / `opreg::available`). A same-(chain, token) swap between two reserves - // (distinct `reserve_code`, a shape `rcrdcommit` explicitly routes) draws - // BOTH required legs against the SAME balance bucket. Checking each leg - // independently would let an underwriter whose balance covers each single - // leg but not their sum win and write two locks that overcommit the one - // balance: balance 150 wins `src_amount=100` + `dst_amount=100` because - // 150>=100 holds per leg, yet the aggregate 200 is uncovered, and - // the deferred slash/remit cleanup (`opreg::releaselock` inside `chklocks`) - // then has to draw 200 from a 150 balance. Require availability to cover - // the AGGREGATE required amount of every leg that shares a collateral - // bucket. The sum is computed in uint128: `src + dst` can exceed uint64, - // and a balance is itself uint64, so an overflowing aggregate must read as - // genuinely insufficient — NOT saturate to UINT64_MAX (which a UINT64_MAX - // available balance would spuriously satisfy) and NOT wrap to a small - // passing value. Collateral can be replenished or freed by lock expiry, so - // insufficient coverage keeps this valid candidate replayable while the - // uwreq remains PENDING. This resolver is non-throwing - // (`feedback_opp_handlers_never_throw`). - const bool same_bucket = src_needed && dst_needed - && req.src_chain_code == req.dst_chain_code - && req.src_token_code == req.dst_token_code; - if (same_bucket) { - // One opreg balance funds both legs — it must cover their sum. uint128 - // keeps the comparison honest when src+dst exceeds uint64 (no uint64 - // balance can cover it, so availability is insufficient by construction). - const uint64_t avail = available_via_mirrors(self, candidate, - req.src_chain_code, req.src_token_code); - const uint128_t need = static_cast(req.src_amount) + req.dst_amount; - if (static_cast(avail) < need) { - const std::string required = need > std::numeric_limits::max() - ? "greater than uint64 maximum" - : std::to_string(static_cast(need)); - sysio::print("try_select_winner: insufficient available collateral for uwreq ", - uwreq_id, " candidate ", candidate, " in bucket (", - req.src_chain_code.to_string(), ", ", req.src_token_code.to_string(), - "): required=", required, ", available=", avail, - ", leaving valid commit retryable\n"); - return; - } - } else { - // Distinct buckets (or a single required leg) — check each independently. - const uint64_t src_avail = src_needed - ? available_via_mirrors(self, candidate, req.src_chain_code, req.src_token_code) : 0; - const uint64_t dst_avail = dst_needed - ? available_via_mirrors(self, candidate, req.dst_chain_code, req.dst_token_code) : 0; - if (src_needed && src_avail < req.src_amount) { - sysio::print("try_select_winner: insufficient source collateral for uwreq ", - uwreq_id, " candidate ", candidate, - ", leaving valid commit retryable\n"); - return; - } - if (dst_needed && dst_avail < req.dst_amount) { - sysio::print("try_select_winner: insufficient destination collateral for uwreq ", - uwreq_id, " candidate ", candidate, - ", leaving valid commit retryable\n"); - return; - } - } - - // ── Candidate-specific remit eligibility ───────────────────────────── - // Build the outbound remit before any request-global verdict. A missing - // destination-chain authex link belongs to this candidate. It can be added - // without changing the request or UIC evidence, so an exact external replay - // can retry after the link is provisioned rather than permanently - // disqualifying the candidate. - // Request-wide build failures remain terminal, and every outcome here is - // still after the authoritative bond gate above. - uint64_t remit_dst_outpost_id = 0; - std::vector remit_encoded; - if (dst_needed) { - switch (try_build_swap_remit(req, candidate, remit_dst_outpost_id, remit_encoded)) { - case swap_remit_disp::ok: - break; - case swap_remit_disp::disqualified: - sysio::print("try_select_winner: candidate ", candidate, - " has no destination authex link for uwreq ", uwreq_id, - ", leaving valid commit retryable\n"); - return; - case swap_remit_disp::terminal: - reject_and_refund(self, reqs, pk, req, src_needed, - "swap unremittable at race resolution: destination outpost / " - "chain-kind unresolved or stored request undecodable", - "uwreq rejected: destination unremittable at race resolution"); - return; - } - } - - // ── Request-level terminal decisions ───────────────────────────────── - // Deliberately placed AFTER every candidate-specific gate (eligibility, - // signature, bond, destination identity). These outcomes REFUND the user - // and close the request for good, so they must not be reachable by a - // candidate that was never going to win: an ACTIVE underwriter can clear - // the role minimum yet be under-bonded for this particular swap, and - // letting it terminally close a healthy request during transient drift - // would hand any such operator a denial-of-service on other people's swaps. - // A candidate that reaches this point is eligible, remittable, and bonded - // for the currently known obligation, so every remaining verdict belongs - // to the request itself. - if (settle_quote == 0) { - if (required_reserves_active(req.src_chain_code, req.src_token_code, req.src_reserve_code, - req.dst_chain_code, req.dst_token_code, req.dst_reserve_code)) { - // Every required reserve is ACTIVE yet the curve cannot price the swap - // — a drained side, extreme connector weights, or a leg below the - // kernel's pricing floor. Terminal: fail closed rather than settle at a - // price the AMM does not have (WSA-041). - reject_and_refund(self, reqs, pk, req, src_needed, - "unpriceable reserve: zero quote from an ACTIVE reserve at race resolution", - "uwreq reverted at race resolution (unpriceable reserve)"); - return; - } - // A required reserve may be provisioned or activated while the UWREQ is - // still PENDING. Keep the candidate's valid evidence so a later exact - // UIC replay can re-evaluate the live route. - sysio::print("try_select_winner: required reserve missing or not ACTIVE for uwreq ", - uwreq_id, ", leaving request PENDING\n"); - return; - } - // Slippage — the live settlement quote against the user's ORIGINAL - // `target_amount`, never against the previous quote. Measuring drift from - // `dst_amount` (the ingestion quote) would compound the tolerance across the - // two checkpoints: a 10% bound would accept a 91 quote at ingestion and then - // an 83 quote at settlement, delivering 17% below a target of 100. Anchoring - // on the fixed target means the bound the user agreed to is the bound they - // get, whatever path the price took. `target_amount` is guaranteed positive - // by `createuwreq` / `swapfromwire`, so the guard is defensive. - // Non-throwing throughout (`feedback_opp_handlers_never_throw`). - if (req.target_amount != 0) { - const uint64_t diff = settle_quote > req.target_amount - ? settle_quote - req.target_amount - : req.target_amount - settle_quote; - const uint128_t allowed = variance_allowance(settle_quote, req.variance_tolerance_bps); - if (static_cast(diff) > allowed) { - reject_and_refund(self, reqs, pk, req, src_needed, - "variance exceeded tolerance at race resolution: " - "target=" + std::to_string(req.target_amount) - + " current=" + std::to_string(settle_quote) - + " tolerance_bps=" + std::to_string(req.variance_tolerance_bps), - "uwreq reverted at race resolution (variance drift)"); - return; - } - } - - // ── Settlement pre-checks against the local reserve mirror ─────────── - // The reserv-side settlement actions (`applyswap` / `applyfromwire` / - // `paywire`) `check()`-abort on violation; an abort inside this - // dispatch chain stalls consensus, so every condition is pre-validated - // here — making the inline actions unreachable-failure by construction - // (no other reserve mutation can interleave within this transaction). - // A live reserve shortfall is shared mutable state. It may recover through - // a reserve top-up or a different settlement, so keep the request PENDING - // rather than letting a momentary shortfall permanently refund it. - name towire_recipient{}; - if (!dst_needed) { - // Swap-to-WIRE. Terminal first: a malformed recipient can never - // become valid — revert to the source outpost + REJECT. - opp::attestations::SwapRequest sr0; - { - auto in = zpp::bits::in{ - std::span{req.attestation_inbound_data.data(), - req.attestation_inbound_data.size()}, - zpp::bits::no_size{}}; - if (in(sr0) != zpp::bits::errc{}) { - sysio::print("try_select_winner: cannot decode stored SwapRequest " - "for uwreq ", uwreq_id, "\n"); - return; - } - } - std::vector recipient_bytes{sr0.recipient.address.begin(), - sr0.recipient.address.end()}; - auto rcpt = parse_wire_name(recipient_bytes); - if (!rcpt || !is_account(*rcpt)) { - // A malformed recipient can never become valid (terminal). to-WIRE - // always has an outpost source leg, so this routes a SWAP_REVERT - // back to it. - reject_and_refund(self, reqs, pk, req, src_needed, - "swap-to-WIRE rejected: recipient is not a valid WIRE account", - "uwreq rejected: invalid WIRE recipient"); - return; - } - towire_recipient = *rcpt; - // Terminal: the to-WIRE payout is sent as `asset(dst_amount, WIRE)` by - // reserv::paywire, and an amount past asset::max_amount (2^62-1) has no - // WIRE-asset representation — it would (a) wrap the `dst_amount + - // to_wire_fee` sufficiency guard below and (b) abort paywire's asset() - // constructor mid-evalcons, stalling consensus. Since `dst_amount` is the - // AMM quote (WNS-02), it is bounded by the source reserve's WIRE side, - // itself an asset-bounded balance — so this is unreachable - // defense-in-depth rather than a live path. Kept because the consequence - // of it ever becoming reachable is a chain-wide consensus stall; it must - // stay AHEAD of any CONFIRMED / lock / reserve write. - if (req.dst_amount > static_cast(asset::max_amount)) { - reject_and_refund(self, reqs, pk, req, src_needed, - "swap-to-WIRE rejected: quoted amount exceeds the maximum " - "representable WIRE asset", - "uwreq rejected: to-WIRE quote exceeds asset max_amount"); - return; - } - auto src_r = find_active_reserve(req.src_chain_code, req.src_token_code, req.src_reserve_code); - // paywire gives up `dst_amount` (to the recipient) + the fee (on the gross - // WIRE leg) out of the source reserve's WIRE; pre-validate that exact sum. - // `dst_amount` is bounded <= asset::max_amount above and `to_wire_fee` - // derives from the (asset-bounded) reserve WIRE, so the sum cannot wrap - // uint64 — but add saturating so the guard is provably overflow-free at - // the settlement boundary regardless of inputs. - const uint64_t w_gross = src_r - ? opp::amm::token_to_wire(src_r->reserve_chain_amount, src_r->reserve_wire_amount, - src_r->connector_weight_bps, req.src_amount) - : 0; - // The TOTAL fee `paywire` will charge on this leg: the network fee plus - // the source reserve's own owner fee (swap-to-WIRE has no destination - // reserve). Only `.fee` is read, and the total is independent of how the - // network fee sub-divides, so the emissions share is irrelevant here. - const uint64_t to_wire_fee = opp::amm::split_wire_fee( - w_gross, current_fee_bps(self), reserve::FEE_UNDERWRITER_SHARE_BPS, - /*emissions_share_bps*/ 0, - src_r ? src_r->owner_fee_bps : 0, /*dst_reserve_fee_bps*/ 0).fee; - const uint64_t wire_needed = opp::safe::add_sat_u64(req.dst_amount, to_wire_fee); - if (!src_r || w_gross == 0 || src_r->reserve_wire_amount < wire_needed) { - sysio::print("try_select_winner: insufficient source-reserve WIRE for uwreq ", - uwreq_id, ", leaving request PENDING\n"); - return; - } - } else if (!src_needed) { - // Swap-from-WIRE. drainfwq validated at queue-drain; re-validate the - // live state (privacy is immutable, but liquidity can drift). - auto dst_r = find_active_reserve(req.dst_chain_code, req.dst_token_code, req.dst_reserve_code); - if (!dst_r || dst_r->is_private || dst_r->reserve_chain_amount < req.dst_amount) { - sysio::print("try_select_winner: destination reserve cannot settle uwreq ", - uwreq_id, ", leaving request PENDING\n"); - return; - } - } else { - // Normal outpost↔outpost swap — both rows must cover the four-leg - // apply (the WIRE intermediate is derived from the same pre-mutation - // source row `applyswap` will read). - auto src_r = find_active_reserve(req.src_chain_code, req.src_token_code, req.src_reserve_code); - auto dst_r = find_active_reserve(req.dst_chain_code, req.dst_token_code, req.dst_reserve_code); - // applyswap debits the gross weighted WIRE intermediate from the source - // (the fee is taken from it before the net reaches dst); pre-validate the - // same conditions applyswap will check. - const uint64_t w_gross = src_r - ? opp::amm::token_to_wire(src_r->reserve_chain_amount, src_r->reserve_wire_amount, - src_r->connector_weight_bps, req.src_amount) - : 0; - if (!src_r || !dst_r || w_gross == 0 || - src_r->reserve_wire_amount < w_gross || - dst_r->reserve_chain_amount < req.dst_amount) { - sysio::print("try_select_winner: insufficient reserve liquidity for uwreq ", - uwreq_id, ", leaving request PENDING\n"); - return; - } - } - - // Winner — push one lock per REQUIRED leg + mark uwreq CONFIRMED. - // Each lock_entry carries the matching leg's full slug_name triple - // (`chain_code, token_code, reserve_code`) so a future slash routes - // unambiguously back to the originating reserve. Locks are a - // wall-clock challenge window (12h default): they are NEVER released - // by delivery — `chklocks` (epoch advance) sweeps them after - // `expires_at_ms` on the healthy path, and `sweeplocks` erases them - // ahead of it when a challenge against the commitment is UPHELD. - const uint64_t now_ms_v = current_time_ms(); - uwrit::uwconfig_t uwcfg_tbl(self); - auto uwcfg = uwcfg_tbl.get_or_default(uwrit::uw_config{}); - const uint64_t expires_ms = now_ms_v + uwcfg.collateral_lock_duration_ms; - - uwrit::locks_t locks(self); - - if (src_needed) { - uint64_t src_lock_id = next_lock_id(self); - locks.emplace(ram_payer, uwrit::lock_key{src_lock_id}, uwrit::lock_entry{ - .lock_id = src_lock_id, - .uwreq_id = uwreq_id, - .underwriter = candidate, - .chain_code = req.src_chain_code, - .token_code = req.src_token_code, - .reserve_code = req.src_reserve_code, - .amount = req.src_amount, - .created_at_ms = now_ms_v, - .expires_at_ms = expires_ms, - }); - add_locked_total(self, candidate, req.src_chain_code, req.src_token_code, - req.src_amount); - } - - if (dst_needed) { - uint64_t dst_lock_id = next_lock_id(self); - locks.emplace(ram_payer, uwrit::lock_key{dst_lock_id}, uwrit::lock_entry{ - .lock_id = dst_lock_id, - .uwreq_id = uwreq_id, - .underwriter = candidate, - .chain_code = req.dst_chain_code, - .token_code = req.dst_token_code, - .reserve_code = req.dst_reserve_code, - .amount = req.dst_amount, - .created_at_ms = now_ms_v, - .expires_at_ms = expires_ms, - }); - add_locked_total(self, candidate, req.dst_chain_code, req.dst_token_code, - req.dst_amount); - } - - reqs.modify(same_payer, pk, [&](auto& r) { - r.status = UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_CONFIRMED; - r.winner = candidate; - r.committed_at_ms = current_time_ms(); - // The live quote this race settled at — the amount the winner owes on the - // destination chain, and the amount the locks and the SWAP_REMIT carry. - // Persisting it keeps the row's record of the swap equal to what the - // reserve books actually moved. - r.dst_amount = req.dst_amount; - // Deadline cleared (SEC-129 / WSA-223): a CONFIRMED row is owned by the - // wall-clock lock window — `chklocks` is guaranteed to terminalize it - // once the locks expire, so a second (epoch-count) deadline would just - // race the first. 0 excludes the row from the `pruneuwreqs` sweep. - r.expires_at_epoch = 0; - // Mark the winner's commit_entry CONFIRMED, others RELEASED (loser). - for (auto& c : r.commits_by) { - if (c.underwriter == candidate) { - c.status = UnderwriteStatus::UNDERWRITE_STATUS_INTENT_CONFIRMED; - } else if (c.status == UnderwriteStatus::UNDERWRITE_STATUS_INTENT_SUBMITTED) { - // Eligible loser — promote to RELEASED for retention/debugging. - c.status = UnderwriteStatus::UNDERWRITE_STATUS_RELEASED; - c.reason = "lost the COMMIT race"; - } - // Race resolved — non-winner UIC blobs have no remaining reader. - // The winner's bytes stay through the lock window as - // the challenge-evidence trail; `chklocks` clears them at COMPLETED. - if (c.underwriter != candidate) { - c.source_uic_bytes.clear(); - c.dest_uic_bytes.clear(); - } - } - }); - - // ── Settlement — reserve books move NOW, before any remit leaves ───── - // All reserve mutations are queued ahead of the SWAP_REMIT queueout in - // this same transaction, so every quote between emit and delivery - // prices the post-swap books. There is no failure path past this - // point: everything was verified above, and transport is - // consensus-gated (delivery either reaches consensus or nothing - // happens — the envelope dispute process covers divergence). Success - // is implicit; locks expire on their own 12h window via `chklocks`. - if (!dst_needed) { - // Swap-to-WIRE: pay the recipient REAL WIRE from reserve custody. - // The depot itself is the payer — no outbound remit, no ack. - action( - permission_level{self, "active"_n}, - uwrit::RESERVE_ACCOUNT, "paywire"_n, - std::make_tuple(req.src_chain_code, req.src_token_code, req.src_reserve_code, - req.src_amount, towire_recipient, req.dst_amount, candidate) - ).send(); - } else if (!src_needed) { - // Swap-from-WIRE: the escrowed WIRE becomes dst-reserve liquidity, - // then the normal remit tail to the destination outpost. - action( - permission_level{self, "active"_n}, - uwrit::RESERVE_ACCOUNT, "applyfromwire"_n, - std::make_tuple(req.dst_chain_code, req.dst_token_code, req.dst_reserve_code, - req.src_amount, req.dst_amount, candidate) - ).send(); - queue_swap_remit(self, remit_dst_outpost_id, remit_encoded); - } else { - // Normal swap: emit-time four-leg apply, then the remit tail. - action( - permission_level{self, "active"_n}, - uwrit::RESERVE_ACCOUNT, "applyswap"_n, - std::make_tuple(req.src_chain_code, req.src_token_code, req.src_reserve_code, - req.src_amount, - req.dst_chain_code, req.dst_token_code, req.dst_reserve_code, - req.dst_amount, candidate) - ).send(); - queue_swap_remit(self, remit_dst_outpost_id, remit_encoded); - } -} - -} // anonymous namespace - -// --------------------------------------------------------------------------- -// rcrdcommit — record a per-leg COMMIT arrival -// --------------------------------------------------------------------------- -// -// The leg-classification logic now uses the slug_name triple -// `(from_chain_code, from_token_code, reserve_code)` to disambiguate src -// vs dst. The triple is required because two reserves of the same token -// can coexist on the same chain — same-(chain, token) swaps fall back to -// reserve_code as the tiebreaker. -void uwrit::rcrdcommit(uint64_t uwreq_id, - name underwriter, - uint64_t chain_code, - sysio::slug_name from_chain_code, - sysio::slug_name from_token_code, - sysio::slug_name reserve_code, - std::vector uic_bytes) { - require_auth(MSGCH_ACCOUNT); - - uwreqs_t reqs(get_self()); - auto pk = id_key{uwreq_id}; - // Dispatched-from-msgch handlers MUST NOT throw — a check() halts - // evalcons (`feedback_opp_handlers_never_throw.md`). Silently no-op - // on unknown uwreq_id or wrong status. - if (!reqs.contains(pk)) { - sysio::print("rcrdcommit: unknown uwreq ", uwreq_id, ", skipping\n"); - return; - } - auto req_snapshot = reqs.get(pk); - if (req_snapshot.status != UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_PENDING) { - sysio::print("rcrdcommit: uwreq ", uwreq_id, - " not in PENDING (status=", - magic_enum::enum_integer(req_snapshot.status), "), skipping\n"); - return; - } - - // Classify the UIC's leg BEFORE touching commits_by. `src_*`/`dst_*` are set once at - // createuwreq and never change here, so the snapshot's values are authoritative. Route by the - // full `(chain_code, token_code, reserve_code)` triple so same-chain swaps with multiple - // reserves on the same (chain, token) pair land in the correct per-leg slot. - const bool is_source = (from_chain_code == req_snapshot.src_chain_code - && from_token_code == req_snapshot.src_token_code - && reserve_code == req_snapshot.src_reserve_code); - const bool is_dest = (from_chain_code == req_snapshot.dst_chain_code - && from_token_code == req_snapshot.dst_token_code - && reserve_code == req_snapshot.dst_reserve_code); - // A UIC whose leg matches neither the source nor the destination is not bound to this request. - // Drop it with no mutation so a stream of unmatched commits cannot grow commits_by or re-arm a - // disqualified entry. Fail closed, never check(). - if (!is_source && !is_dest) { - sysio::print("rcrdcommit: uwreq ", uwreq_id, - " UIC leg matches neither source nor destination, skipping\n"); - return; - } - - // Only a registered ACTIVE underwriter can win this race — try_select_winner enforces the same - // gate, so an entry for - // any other account is pure dead weight. Refuse to create one: otherwise a matched-leg UIC could - // append one commit_entry per attacker-chosen valid-but-unregistered account name and bloat the - // row. Fail closed, no mutation, never check(). - if (!is_active_underwriter(underwriter)) { - sysio::print("rcrdcommit: uwreq ", uwreq_id, " underwriter ", underwriter, - " is not an ACTIVE underwriter, skipping\n"); - return; - } - - // Reject a new name at the roster cap before protobuf decoding, hashing, or - // recovery. Existing candidates can still provide their missing required leg. - const auto existing_it = - std::find_if(req_snapshot.commits_by.begin(), req_snapshot.commits_by.end(), - [&](const commit_entry& c) { return c.underwriter == underwriter; }); - const bool existing_entry = existing_it != req_snapshot.commits_by.end(); - if (!existing_entry && req_snapshot.commits_by.size() >= MAX_UWREQ_CANDIDATES) { - sysio::print("rcrdcommit: uwreq ", uwreq_id, " already carries ", - req_snapshot.commits_by.size(), - " candidates (cap reached), skipping new candidate\n"); - return; - } - - const auto incoming_leg = is_source ? uic_leg::source : uic_leg::destination; - - // Disqualification is durable for this request. Once winner-time checks - // reject a candidate, no replay, malformed record, unauthorized record, or - // even newly valid replacement may rewrite the evidence/timestamps or - // re-arm that candidate. Another underwriter can still win the open race. - if (existing_entry && - existing_it->status == UnderwriteStatus::UNDERWRITE_STATUS_DISQUALIFIED) { - sysio::print("rcrdcommit: uwreq ", uwreq_id, " candidate ", underwriter, - " is already DISQUALIFIED, skipping\n"); - return; - } - - // A candidate leg is write-once. Outpost delivery is at-least-once, so a - // conflicting duplicate is never replacement evidence. A byte-identical - // replay of a complete candidate is the explicit external trigger for a - // fresh winner-selection pass: it does not mutate evidence or timestamps, - // and `std::nullopt` makes the resolver revalidate every stored leg against - // current permissions. - if (existing_entry && required_uic_leg_populated(*existing_it, incoming_leg)) { - const auto& stored_bytes = is_source - ? existing_it->source_uic_bytes - : existing_it->dest_uic_bytes; - if (stored_bytes != uic_bytes) { - sysio::print("rcrdcommit: uwreq ", uwreq_id, " candidate ", underwriter, - " received conflicting duplicate ", - is_source ? "source" : "destination", " UIC, skipping\n"); - return; - } - if (required_uic_legs_complete(req_snapshot, *existing_it)) { - sysio::print("rcrdcommit: uwreq ", uwreq_id, " candidate ", underwriter, - " received exact stored UIC replay, re-evaluating\n"); - try_select_winner(get_self(), uwreq_id, underwriter, std::nullopt); - return; - } - sysio::print("rcrdcommit: uwreq ", uwreq_id, " candidate ", underwriter, - " already carries a ", - is_source ? "source" : "destination", - " UIC, skipping duplicate\n"); - return; - } - - // Reject oversized blobs before protobuf decoding, hashing, or recovery. - // Candidate evidence/status/reason/timestamps remain untouched. - if (uic_bytes.size() > MAX_UIC_LEG_BYTES) { - sysio::print("rcrdcommit: uwreq ", uwreq_id, " UIC payload of ", - uic_bytes.size(), " bytes exceeds the per-leg cap, skipping\n"); - return; - } - - // Validate the claimed underwriter's signature before changing candidate - // evidence/status/reason/timestamps. - // Current outposts bind the canonical UIC's claimed identity and external - // address to their authenticated caller, while the depot remains - // authoritative for binding `uw_account` to current WIRE permission keys. - // An invalid claim must never overwrite an honest - // candidate's previously stored leg, re-arm its status, or create a - // competitor-visible entry. Log and ignore it; later - // records in the same envelope continue dispatching. - const auto signature_result = verify_uic_signature(underwriter, uic_bytes); - if (signature_result != uic_signature_result::valid) { - log_uic_signature_rejection(uwreq_id, underwriter, incoming_leg, - chain_code, signature_result); - return; - } - - // Remaining row-growth rails (SEC-129 / WSA-223) — every rail fails closed - // with no mutation, never check() (we are inside the evalcons dispatch - // chain). The candidate roster is capped so the ACTIVE-underwriter population - // bounds scan cost but not row size… - // …and the projected whole-row size is guarded so THIS modify — and every - // later consensus-dispatched one — stays clear of chain KV value limits. - // Conservative projection: current packed row + the incoming leg bytes + - // a fixed allowance for a new entry's non-vector fields. - const size_t projected_row_bytes = pack_size(req_snapshot) - + uic_bytes.size() - + (existing_entry ? 0 : COMMIT_ENTRY_PACK_ALLOWANCE_BYTES); - if (projected_row_bytes > MAX_UWREQ_ROW_BYTES) { - sysio::print("rcrdcommit: uwreq ", uwreq_id, " projected row size ", - projected_row_bytes, " bytes exceeds the row cap, skipping\n"); - return; - } - - reqs.modify(same_payer, pk, [&](auto& r) { - auto* c = find_or_create_commit(r, underwriter); - const uint64_t admitted_at_ms = current_time_ms(); - if (is_source) { - c->source_received_at_ms = admitted_at_ms; - c->source_outpost_id = chain_code; - c->source_uic_bytes = uic_bytes; - } else if (is_dest) { - c->dest_received_at_ms = admitted_at_ms; - c->dest_outpost_id = chain_code; - c->dest_uic_bytes = uic_bytes; - } - }); - - // Re-read after modify — try_select_winner needs the latest commit_entry. - // A leg is required iff its chain is an outpost; a depot (WIRE) leg is - // satisfied by construction (no outpost exists to send a UIC for it), - // so single-leg swaps arm the race on their one real commit. - auto refreshed = reqs.get(pk); - for (const auto& c : refreshed.commits_by) { - if (c.underwriter != underwriter) continue; - if (required_uic_legs_complete(refreshed, c)) { - try_select_winner(get_self(), uwreq_id, underwriter, incoming_leg); - } - break; - } -} - -// --------------------------------------------------------------------------- -// swapfromwire — queue a depot-originated swap (WIRE is the source) -// --------------------------------------------------------------------------- -// -// User transaction (require_auth(user)) — reverting on validation failure -// is the correct mode here, exactly like opreg::deposit. The WIRE escrow -// and the queue row land atomically; the uwreq itself is NOT created in -// this transaction (that would short-circuit ledger consistency) — the -// next sysio.epoch::advance drains the queue via drainfwq. -void uwrit::swapfromwire(name user, - uint64_t wire_amount, - sysio::slug_name dst_chain_code, - sysio::slug_name dst_token_code, - sysio::slug_name dst_reserve_code, - uint64_t target_amount, - uint32_t target_tolerance_bps, - opp::types::ChainKind recipient_kind, - std::vector recipient_addr) { - require_auth(user); - check(wire_amount > 0, "swapfromwire: wire_amount must be positive"); - // Queue-slot price floor (default 5 WIRE, retunable via setconfig): the - // escrow of a failed row is refunded, so without a floor dust rows could - // hold drain slots while locking no meaningful capital — and sit below the - // Bancor kernel's pricing floor, guaranteeing zero-quote reverts. - check(wire_amount >= read_config(get_self()).min_fromwire_amount, - "swapfromwire: wire_amount below the configured minimum"); - check(target_amount > 0, "swapfromwire: target_amount must be positive"); - check(!recipient_addr.empty() && recipient_addr.size() <= 64, - "swapfromwire: recipient address must be 1..64 bytes"); - check(chain_registered_active(dst_chain_code), - "swapfromwire: target chain not registered or not active"); - check(!leg_is_depot(dst_chain_code), - "swapfromwire: target must be an outpost chain (WIRE->WIRE is just " - "a token transfer)"); - { - auto kind_opt = chain_kind_for_code(dst_chain_code); - check(kind_opt.has_value() && *kind_opt == recipient_kind, - "swapfromwire: recipient_kind does not match the target chain's kind"); - } - - // Advisory reserve checks for UX — drainfwq re-validates authoritatively - // at the epoch boundary (state may change in between; drain refunds). - { - auto r = find_reserve(dst_chain_code, dst_token_code, dst_reserve_code); - check(r.has_value(), "swapfromwire: target reserve not found"); - check(r->status == ReserveStatus::RESERVE_STATUS_ACTIVE, - "swapfromwire: target reserve not ACTIVE"); - check(!r->is_private, - "swapfromwire: private reserves are excluded from WIRE-endpoint swaps"); - } - - // Escrow REAL WIRE into reserve custody NOW. The queue row records the - // claim; until drainfwq either creates the uwreq (escrow becomes - // reserve liquidity at race win) or refunds, this sits as the - // in-flight-escrow term of the custody invariant. - action( - permission_level{user, "active"_n}, - TOKEN_ACCOUNT, "transfer"_n, - std::make_tuple(user, RESERVE_ACCOUNT, - asset(static_cast(wire_amount), opp::wire::asset_symbol), - std::string("sysio.uwrit::swapfromwire escrow")) - ).send(); - - uint64_t qid = next_fromwire_id(get_self()); - fwqueue_t q(get_self()); - q.emplace(ram_payer, fw_key{qid}, fromwire_q{ - .id = qid, - .user = user, - .wire_amount = wire_amount, - .dst_chain_code = dst_chain_code, - .dst_token_code = dst_token_code, - .dst_reserve_code = dst_reserve_code, - .target_amount = target_amount, - .variance_tolerance_bps = target_tolerance_bps, - .recipient_kind = recipient_kind, - .recipient_addr = std::move(recipient_addr), - .created_at_epoch = get_current_epoch(), - }); -} - -// --------------------------------------------------------------------------- -// drainfwq — epoch-boundary drain of the swap-from-WIRE queue -// --------------------------------------------------------------------------- -// -// Inlined from sysio.epoch::advance. NEVER throws: every reachable -// `check()` in the reserv actions it inlines (`refundwire`) is -// pre-guaranteed (wire_amount > 0 enforced at swapfromwire; the user -// account existed at escrow time and accounts are not deletable; the -// post-fee refund stays positive because setconfig caps the revert fee -// below 100%). Validation failures refund + drop; only validated rows -// become PENDING uwreqs. -// -// Pre-guaranteeing `refundwire`'s own `check()`s is necessary but was NOT -// sufficient while the refund was a pushed `sysio.token::transfer`. A transfer -// notifies its recipient, and the chain executes notified receivers with no -// exception isolation, so the refund recipient -- not this contract -- decided -// whether `advance` committed. A recipient asserting in its notify handler (or -// merely burning CPU) aborted the whole transaction, and because the `q.erase()` -// below rolls back with it, the offending row survived and re-blocked every -// later epoch: a permanent chain-wide epoch halt bought for one -// `min_fromwire_amount` escrow. `refundwire` now credits a claimable balance and -// transfers nothing, so no counterparty sits inside this drain's abort surface. -void uwrit::drainfwq() { - check(has_auth(EPOCH_ACCOUNT) || has_auth(get_self()), - "drainfwq requires sysio.epoch or sysio.uwrit authority"); - - fwqueue_t q(get_self()); - uwreqs_t reqs(get_self()); - const auto depot_code = depot_chain_code(); - // One config snapshot for the whole drain: quotes and revert fees inside a - // single action must charge one rate. - const uw_config cfg = read_config(get_self()); - - // Bounded FIFO drain (SEC-77): process at most MAX_FWQ_DRAIN_PER_EPOCH rows - // per advance, oldest first (primary-key = id order). Every branch of the - // loop body erases the front row, so re-reading `q.begin()` each iteration - // walks the queue forward and the next advance resumes where this one - // stopped; undrained rows keep their WIRE escrow in reserve custody and - // drain a later epoch. Bounding this loop is what keeps `advance` inside its - // hard, uncatchable CPU deadline: an unbounded drain over an attacker- - // inflated queue would abort every advance and stall epoch progress chain- - // wide. `drained` is incremented before any of the per-row `continue` - // branches, so it counts every attempt (refund-and-drop included). Reading - // only the front row (not a full-table pre-copy) also bounds the scan cost. - uint32_t drained = 0; - while (drained < MAX_FWQ_DRAIN_PER_EPOCH) { - auto head = q.begin(); - if (head == q.end()) break; - const auto row = *head; - ++drained; - - // Fault taxonomy for the revert fee: system-caused failures (the chain - // registry or reserve state changed AFTER swapfromwire validated the - // row) refund in full via REFUND_FEE_EXEMPT_BPS; failures the caller's - // own parameters produce (unpriceable escrow, missed variance - // tolerance) forfeit `fromwire_revert_fee_bps` so revert churn cannot - // recycle for free. - auto refund_and_drop = [&](const char* why, uint32_t revert_fee_bps) { - sysio::print("drainfwq: ", why, " — refunding queued swap ", row.id, "\n"); - action( - permission_level{get_self(), "active"_n}, - RESERVE_ACCOUNT, "refundwire"_n, - std::make_tuple(row.user, row.wire_amount, revert_fee_bps) - ).send(); - q.erase(fw_key{row.id}); - }; - - if (!depot_code) { - refund_and_drop("depot chain not registered", REFUND_FEE_EXEMPT_BPS); - continue; - } - auto r = find_reserve(row.dst_chain_code, row.dst_token_code, row.dst_reserve_code); - if (!r || r->status != ReserveStatus::RESERVE_STATUS_ACTIVE) { - refund_and_drop("target reserve missing or not ACTIVE", REFUND_FEE_EXEMPT_BPS); - continue; - } - if (r->is_private) { - refund_and_drop("target reserve is private (excluded from WIRE-endpoint swaps)", - REFUND_FEE_EXEMPT_BPS); - continue; - } - // Authoritative variance check. The target reserve is confirmed ACTIVE - // above, so a zero quote here is NOT an unprovisioned-LP skip — it is a - // degenerate / unpriceable ACTIVE reserve (token side drained to zero, - // extreme connector weights, or a WIRE escrow too small for the - // weighted-Bancor kernel to price). Fail closed and refund: otherwise a - // caller-chosen target_amount bypasses variance and drains the reserve's - // token side for a negligible WIRE escrow (WSA-041). - const uint64_t quote = swap_quote(*depot_code, opp::wire::token_code, - opp::wire::token_code, - row.dst_chain_code, row.dst_token_code, - row.dst_reserve_code, row.wire_amount, - cfg.fee_bps); - if (quote == 0) { - refund_and_drop("unpriceable target reserve: zero quote from an ACTIVE reserve", - cfg.fromwire_revert_fee_bps); - continue; - } - { - // `target_amount > 0` is guaranteed by swapfromwire. The allowance is a - // fraction of the AMM `quote`, never of the user's target (WNS-02) — - // see `variance_allowance`. - const uint64_t diff = quote > row.target_amount - ? quote - row.target_amount - : row.target_amount - quote; - const uint128_t allowed = variance_allowance(quote, row.variance_tolerance_bps); - if (static_cast(diff) > allowed) { - refund_and_drop("variance exceeded tolerance at drain", - cfg.fromwire_revert_fee_bps); - continue; - } - } - - // Synthetic SwapRequest payload — the settlement tail - // (`try_build_swap_remit`) decodes `recipient` from the stored bytes - // exactly as it does for outpost-originated swaps. - opp::attestations::SwapRequest sr; - sr.actor.kind = ChainKind::CHAIN_KIND_WIRE; - { - auto user_bytes = wire_name_bytes(row.user); - sr.actor.address.assign(user_bytes.begin(), user_bytes.end()); - } - sr.source_amount.token_code = opp::wire::token_code.value; - sr.source_amount.amount = static_cast(row.wire_amount); - sr.source_chain_code = depot_code->value; - sr.source_reserve_code = opp::wire::token_code.value; // sentinel — no WIRE-side reserve - sr.target_chain_code = row.dst_chain_code.value; - sr.target_token_code = row.dst_token_code.value; - sr.target_reserve_code = row.dst_reserve_code.value; - sr.recipient.kind = row.recipient_kind; - sr.recipient.address.assign(row.recipient_addr.begin(), row.recipient_addr.end()); - sr.target_amount = row.target_amount; - sr.target_tolerance_bps = row.variance_tolerance_bps; - sr.target_timestamp_ms = 0; - // Synthetic but non-empty source_tx_id (8-byte LE row id) so the - // uwreq row shape matches outpost-originated swaps. - std::vector stx(8); - for (size_t i = 0; i < 8; ++i) { - stx[i] = static_cast((row.id >> (i * 8)) & 0xff); - } - sr.source_tx_id.assign(stx.begin(), stx.end()); - - std::vector encoded; - auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; - if (out(sr) != zpp::bits::errc{}) { - // Internal encode failure — not a caller-controlled revert cause. - refund_and_drop("failed to encode synthetic SwapRequest", REFUND_FEE_EXEMPT_BPS); - continue; - } - - auto pk = id_key{row.id}; - if (reqs.contains(pk)) { - // Idempotency backstop — the id space makes this unreachable. - q.erase(fw_key{row.id}); - continue; - } - reqs.emplace(ram_payer, pk, uw_request_t{ - .id = row.id, - .type = AttestationType::ATTESTATION_TYPE_SWAP_REQUEST, - .status = UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_PENDING, - .src_chain_code = *depot_code, - .src_token_code = opp::wire::token_code, - .src_reserve_code = opp::wire::token_code, - .src_amount = row.wire_amount, - .dst_chain_code = row.dst_chain_code, - .dst_token_code = row.dst_token_code, - .dst_reserve_code = row.dst_reserve_code, - // The AMM quote, never the user's `target_amount` (WNS-02) — same - // contract as `createuwreq`; re-quoted at race resolution. - .dst_amount = quote, - // The user's expectation, retained as the FIXED slippage reference. - .target_amount = row.target_amount, - .variance_tolerance_bps = row.variance_tolerance_bps, - .source_tx_id = std::move(stx), - .depositor = wire_name_bytes(row.user), - .commits_by = {}, - .winner = name{}, - .committed_at_ms = 0, - .settled_at_ms = 0, - // Same PENDING deadline as createuwreq (SEC-129 / WSA-223): a - // from-WIRE race that never resolves is expired by `pruneuwreqs`, - // which refunds the escrow in full via `refundwire`. - .expires_at_epoch = get_current_epoch() + cfg.uwreq_pending_timeout_epochs, - .attestation_inbound_data = std::move(encoded), - .attestation_outbound_data = {}, - }); - q.erase(fw_key{row.id}); - } -} - -// --------------------------------------------------------------------------- -// sumlocks — read-only helper -// --------------------------------------------------------------------------- -uint64_t uwrit::sumlocks(name underwriter, - sysio::slug_name chain_code, - sysio::slug_name token_code) { - return sum_locks_inline(get_self(), underwriter, chain_code, token_code); -} - -// --------------------------------------------------------------------------- -// chklocks — epoch-boundary sweep of expired locks (the only HEALTHY release -// path; `sweeplocks` also erases, on an UPHELD challenge) -// --------------------------------------------------------------------------- -// -// Locks are a wall-clock challenge window (12h default via uwconfig) and -// are never released by delivery. This sweep: -// 1. erases every lock whose `expires_at_ms` has elapsed, -// 2. inlines `opreg::releaselock` per lock — the deferred-slash -// (SLASHED) / deferred-remit (TERMINATED) / no-op (healthy) hop that -// settles the underwriter's bond as the challenge window closes, -// 3. flips a CONFIRMED uwreq to COMPLETED once its last lock is swept -// (delivery success is implicit — there is no SWAP_REMIT ack). -// -// Runs as one of the FIRST steps of sysio.epoch::advance so freshly freed -// collateral is visible to the same advance's withdraw flushing. NEVER -// throws on reachable state: lock amounts are provably positive (enforced -// at uwreq/queue creation), so opreg::releaselock's amount>0 check cannot -// fire; and releaselock clamps its balance subtraction to the live bucket, -// so even a residual over-committed lock set cannot underflow + abort the -// advance (defence-in-depth — the winner-selection aggregate bond check -// already prevents the over-commit at lock-creation time). -namespace { - -/// COMPLETED flip for uwreqs whose LAST lock just left the table — a CONFIRMED -/// uwreq with no remaining locks has exited its challenge window. The flip -/// stamps the retention deadline (`pruneuwreqs` erases the row once it -/// elapses) and clears the remaining heavy payloads: with the window closed, -/// the winner's UIC evidence and the inbound attestation copy have no -/// remaining reader — retention keeps the compact audit metadata only -/// (SEC-129 / WSA-223). Shared by `chklocks` (natural expiry) and -/// `sweeplocks` (an UPHELD underwriter challenge's slash-sweep). -void finalize_settled_uwreqs(name self, uwrit::locks_t& locks, - const std::vector& affected) { - uwrit::uwreqs_t reqs(self); - auto byuwreq = locks.get_index<"byuwreq"_n>(); - const uint32_t now_ep = get_current_epoch(); - const uint32_t retention_epochs = read_config(self).uwreq_retention_epochs; - for (uint64_t id : affected) { - auto lit = byuwreq.lower_bound(id); - if (lit != byuwreq.end() && lit->uwreq_id == id) continue; // locks remain - auto pk = uwrit::id_key{id}; - if (!reqs.contains(pk)) continue; - auto r = reqs.get(pk); - if (r.status != UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_CONFIRMED) continue; - reqs.modify(same_payer, pk, [&](auto& row) { - row.status = UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_COMPLETED; - row.settled_at_ms = current_time_ms(); - row.expires_at_epoch = now_ep + retention_epochs; - row.attestation_inbound_data.clear(); - for (auto& c : row.commits_by) { - c.source_uic_bytes.clear(); - c.dest_uic_bytes.clear(); - } - }); - } -} - -} // anonymous namespace - -void uwrit::chklocks(uint32_t max_rows) { - // Two valid callers, mirroring pruneuwreqs / drainfwq: - // * sysio.epoch::advance — inlined at every epoch boundary with - // MAX_LOCK_RELEASE_PER_EPOCH. - // * sysio.uwrit — manual cleanup invocation with a caller-chosen budget, - // e.g. from a migration. - check(has_auth(EPOCH_ACCOUNT) || has_auth(get_self()), - "chklocks requires sysio.epoch or sysio.uwrit authority"); - if (max_rows == 0) return; - - const uint64_t now_ms = current_time_ms(); - locks_t locks(get_self()); - auto idx = locks.get_index<"byexpire"_n>(); - - // Walk in ascending `expires_at_ms` and collect full copies while - // expired — we erase in a second pass (an erase invalidates the index - // cursor) and need every field for the releaselock fan-out. - // - // A lock HELD by an open underwriter-fault challenge (WIRE-297) is not - // released at expiry: its collateral stays at risk until the council - // resolves or the challenge lapses. Skip it and poke the challenge's - // tally crank instead — this poke is the challenge system's only - // cadence (the chain is not paused during a challenge, so the epoch - // tick keeps arriving; envelope disputes need a standalone crank - // precisely because theirs does not). - // - // Bounded to `max_rows`, for the same reason as MAX_UWREQ_PRUNE_PER_EPOCH - // and MAX_FWQ_DRAIN_PER_EPOCH: the per-lock work here is an inline - // `opreg::releaselock` dispatch plus an erase, and it runs inside - // advance's hard, uncatchable transaction CPU deadline. Expiries arrive - // in BURSTS — every lock is stamped `now + collateral_lock_duration_ms` - // at creation, so one epoch's settlements all fall due in one epoch a - // lock-duration later — and an unbounded sweep of such a burst would - // abort `advance`. Those locks would still be expired at the next - // advance, so it would abort identically every epoch thereafter: a - // permanent chain-wide epoch stall. Ascending expiry order makes the - // bound a FIFO drain; an oversized burst simply spreads across - // subsequent epochs. - // - // The counter is EXAMINED ROWS, not released locks. A held lock is skipped - // rather than released, so counting only releases would let an unbounded - // number of held rows be scanned AND let every distinct challenge among - // them fan out its own inline `chkuwchal` — reintroducing precisely the - // unbounded advance work this bound removes. Counting the row when it is - // examined bounds the scan, the release fan-out and the challenge poke - // fan-out with one budget (`open_challenges.size() <= max_rows` follows). - std::vector expired; - std::vector open_challenges; - uint32_t examined = 0; - for (auto it = idx.begin(); - it != idx.end() && it->expires_at_ms <= now_ms && examined < max_rows; - ++it) { - ++examined; - if (it->challenge_id != 0) { - if (std::find(open_challenges.begin(), open_challenges.end(), - it->challenge_id) == open_challenges.end()) { - open_challenges.push_back(it->challenge_id); - } - continue; - } - expired.push_back(*it); - } - - // Poke each held challenge: quorum reached → it resolves (slash-and-sweep - // or reject-and-free); no quorum → it LAPSES with a bond refund, and the - // freed locks release on the NEXT sweep. Resolution clears the hold in the - // same transaction tree, so a resolved challenge is never re-poked. - for (uint64_t chal_id : open_challenges) { - action( - permission_level{get_self(), "active"_n}, - CHALG_ACCOUNT, "chkuwchal"_n, - std::make_tuple(chal_id) - ).send(); - } - - if (expired.empty()) return; - - std::vector affected; - for (const auto& l : expired) { - action( - permission_level{get_self(), "active"_n}, - OPREG_ACCOUNT, "releaselock"_n, - std::make_tuple(l.underwriter, l.chain_code, l.token_code, l.amount) - ).send(); - locks.erase(lock_key{l.lock_id}); - sub_locked_total(get_self(), l.underwriter, l.chain_code, l.token_code, l.amount); - if (std::find(affected.begin(), affected.end(), l.uwreq_id) == affected.end()) { - affected.push_back(l.uwreq_id); - } - } - - finalize_settled_uwreqs(get_self(), locks, affected); -} - -// --------------------------------------------------------------------------- -// holdlocks / freelocks / sweeplocks — underwriter-challenge lock control -// (WIRE-297; auth = sysio.chalg for all three) -// --------------------------------------------------------------------------- - -void uwrit::holdlocks(uint64_t uwreq_id, name underwriter, uint64_t chal_id) { - require_auth(CHALG_ACCOUNT); - check(chal_id != 0, "holdlocks: challenge id must be non-zero"); - - const uint64_t now_ms = current_time_ms(); - locks_t locks(get_self()); - auto byuwreq = locks.get_index<"byuwreq"_n>(); - - // Authoritative liveness validation for a challenge filing — throwing here - // aborts the WHOLE openuwchal, bond escrow included. Collect first, modify - // second (a modify invalidates the index cursor). - std::vector to_hold; - for (auto it = byuwreq.lower_bound(uwreq_id); - it != byuwreq.end() && it->uwreq_id == uwreq_id; ++it) { - if (it->underwriter != underwriter) continue; - check(it->challenge_id == 0, "holdlocks: lock is already under challenge"); - check(now_ms < it->expires_at_ms, "holdlocks: the lock window has closed"); - to_hold.push_back(it->lock_id); - } - check(!to_hold.empty(), "holdlocks: no locks found for this commitment"); - - for (uint64_t lock_id : to_hold) { - locks.modify(same_payer, lock_key{lock_id}, [&](auto& row) { - row.challenge_id = chal_id; - }); - } - - // Mark the REQUEST too, not just its locks. The locks hold the collateral; - // this row holds the evidence the challenge adjudicates against, and its - // retention window is far shorter than the challenge window — see - // `pruneuwreqs`, which refuses to erase a marked row. - uwreqs_t reqs(get_self()); - auto req_it = reqs.find(id_key{uwreq_id}); - check(req_it != reqs.end(), "holdlocks: uwreq not found"); - reqs.modify(same_payer, id_key{uwreq_id}, [&](auto& row) { - row.challenge_id = chal_id; - }); -} - -void uwrit::freelocks(uint64_t uwreq_id, name underwriter) { - require_auth(CHALG_ACCOUNT); - - locks_t locks(get_self()); - auto byuwreq = locks.get_index<"byuwreq"_n>(); - - // Deliberately soft past the auth gate: this runs inline from the epoch - // tick (`chklocks` → `chkuwchal` → here) on a REJECTED or LAPSED - // challenge, where an abort would stall epoch advancement chain-wide. - std::vector to_free; - for (auto it = byuwreq.lower_bound(uwreq_id); - it != byuwreq.end() && it->uwreq_id == uwreq_id; ++it) { - if (it->underwriter != underwriter || it->challenge_id == 0) continue; - to_free.push_back(it->lock_id); - } - - for (uint64_t lock_id : to_free) { - locks.modify(same_payer, lock_key{lock_id}, [&](auto& row) { - row.challenge_id = 0; - }); - } - - // Release the request's own marker so the retention sweep can reclaim it - // again. Soft like the loop above — this runs inline from the epoch tick, - // where an abort would stall advancement chain-wide. - uwreqs_t reqs(get_self()); - auto req_it = reqs.find(id_key{uwreq_id}); - if (req_it != reqs.end() && req_it->challenge_id != 0) { - reqs.modify(same_payer, id_key{uwreq_id}, [&](auto& row) { - row.challenge_id = 0; - }); - } -} - -void uwrit::sweeplocks(uint64_t uwreq_id, name underwriter) { - require_auth(CHALG_ACCOUNT); - - locks_t locks(get_self()); - auto byuwreq = locks.get_index<"byuwreq"_n>(); - - // Collect the underwriter's locks for this commitment (all held by - // construction on an UPHELD challenge). Soft when empty — same - // never-throw-inside-advance reasoning as `freelocks`. - std::vector held; - for (auto it = byuwreq.lower_bound(uwreq_id); - it != byuwreq.end() && it->uwreq_id == uwreq_id; ++it) { - if (it->underwriter == underwriter) held.push_back(*it); - } - if (held.empty()) return; - - // The underwriter is already SLASHED (`chkuwchal` slashes before sweeping), - // so each releaselock takes its deferred-slash branch: the locked - // collateral is debited from opreg and the outbound SLASH attestation - // queued for the outpost's slash-to-reserve hop. - // This is the THIRD lock-erasure path (with `chklocks` and, historically, - // nothing else), so it carries the same obligation: the `locksums` rollup is - // authoritative for `available()` and must never outlive the rows it - // summarizes. Erasing here without decrementing would leave the bucket - // permanently positive after its last lock is gone — `sumlocks` would report - // phantom locked collateral forever, and a re-registering operator would find - // that collateral unusable even though nothing holds it. - std::vector affected; - for (const auto& l : held) { - action( - permission_level{get_self(), "active"_n}, - OPREG_ACCOUNT, "releaselock"_n, - std::make_tuple(l.underwriter, l.chain_code, l.token_code, l.amount) - ).send(); - locks.erase(lock_key{l.lock_id}); - sub_locked_total(get_self(), l.underwriter, l.chain_code, l.token_code, l.amount); - if (std::find(affected.begin(), affected.end(), l.uwreq_id) == affected.end()) { - affected.push_back(l.uwreq_id); - } - } - - // Release the request's challenge marker. The UPHELD path ERASES the locks - // rather than clearing their markers, so this is the ONLY place the row's - // own marker comes off on that branch — without it `pruneuwreqs` would skip - // the row forever and the retention sweep could never reclaim it. - { - uwreqs_t reqs(get_self()); - auto req_it = reqs.find(id_key{uwreq_id}); - if (req_it != reqs.end() && req_it->challenge_id != 0) { - reqs.modify(same_payer, id_key{uwreq_id}, [&](auto& row) { - row.challenge_id = 0; - }); - } - } - - finalize_settled_uwreqs(get_self(), locks, affected); -} - -// --------------------------------------------------------------------------- -// pruneuwreqs — bounded UWREQ lifecycle sweep (SEC-129 / WSA-223) -// --------------------------------------------------------------------------- -// -// The enforcement half of `uw_request_t.expires_at_epoch`: every deadline is -// stamped by the lifecycle writes (createuwreq / drainfwq / reject_and_refund -// / chklocks) and evaluated only HERE, lazily, when the epoch machinery -// triggers this action. Nothing is scheduled; a row past its deadline is -// inert data until the sweep fires. NEVER throws past the auth gate: it runs -// inline inside `sysio.epoch::advance`, where an abort stalls epoch progress -// chain-wide. The per-call `max_rows` budget keeps the sweep inside advance's -// transaction CPU deadline (same rationale as MAX_FWQ_DRAIN_PER_EPOCH); a -// backlog drains across subsequent epochs. -void uwrit::pruneuwreqs(uint32_t max_rows) { - // Two valid callers, mirroring chklocks / drainfwq: - // * sysio.epoch::advance — inlined each epoch with MAX_UWREQ_PRUNE_PER_EPOCH. - // * sysio.uwrit — manual backlog drain with a caller-chosen budget. - check(has_auth(EPOCH_ACCOUNT) || has_auth(get_self()), - "pruneuwreqs requires sysio.epoch or sysio.uwrit authority"); - if (max_rows == 0) return; - - const uint32_t now_ep = get_current_epoch(); - uwreqs_t reqs(get_self()); - auto idx = reqs.get_index<"byexpire"_n>(); - - // Collect-then-act, bounded to `max_rows` copies: a modify/erase - // invalidates the index cursor (same two-pass shape as chklocks), and the - // bounded copy keeps the scan cost budgeted. `lower_bound(1)` skips the - // `expires_at_epoch == 0` population — rows with no deadline (CONFIRMED, - // owned by the wall-clock lock window) are never swept. - std::vector due; - for (auto it = idx.lower_bound(uint64_t{1}); - it != idx.end() && it->expires_at_epoch <= now_ep && due.size() < max_rows; - ++it) { - due.push_back(*it); - } - if (due.empty()) return; - - for (const auto& req : due) { - auto pk = id_key{req.id}; - // An OPEN challenge pins the row regardless of status or retention: this - // row carries the evidence the challenge adjudicates against - // (`attestation_inbound_data`, `source_tx_id`, `commits_by`), and the - // terminal retention window is far shorter than the challenge window. - // `freelocks` clears the marker on reject/lapse and `sweeplocks` on an - // upheld verdict, so the row rejoins the sweep once the challenge is done. - if (req.challenge_id != 0) { - sysio::print("pruneuwreqs: uwreq ", req.id, " held by challenge ", - req.challenge_id, ", skipping\n"); - continue; - } - switch (req.status) { - case UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_PENDING: { - // The race never resolved inside the pending timeout — abandon - // it: refund the source side (SWAP_REVERT to the source outpost, - // or the full from-WIRE escrow via refundwire — expiry is not a - // caller-controlled revert cause), flip to EXPIRED, and start the - // terminal retention window. PENDING rows hold no locks (locks - // are first written in the same modify that flips CONFIRMED), so - // there is nothing to release. - const bool src_needed = !leg_is_depot(req.src_chain_code); - reject_and_refund(get_self(), reqs, pk, req, src_needed, - "underwrite request expired: no underwriter resolved the race " - "within the pending timeout", - "uwreq expired: pending deadline elapsed before the race resolved", - UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_EXPIRED); - break; - } - case UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_REJECTED: - case UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_COMPLETED: - case UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_EXPIRED: - // Terminal row past its retention window — remove it. - reqs.erase(pk); - break; - default: - // CONFIRMED (or a future status) with a live deadline should be - // unreachable — winner selection zeroes the deadline. Defensive - // skip + log; never throw inside advance. - sysio::print("pruneuwreqs: uwreq ", req.id, " in status ", - magic_enum::enum_integer(req.status), - " unexpectedly carries an elapsed deadline, skipping\n"); - break; - } - } -} - -} // namespace sysio diff --git a/contracts/sysio.uwrit/sysio.uwrit.abi b/contracts/sysio.uwrit/sysio.uwrit.abi deleted file mode 100644 index 1c17ba33a7..0000000000 --- a/contracts/sysio.uwrit/sysio.uwrit.abi +++ /dev/null @@ -1,940 +0,0 @@ -{ - "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", - "version": "sysio::abi/1.2", - "types": [], - "structs": [ - { - "name": "chklocks", - "base": "", - "fields": [ - { - "name": "max_rows", - "type": "uint32" - } - ] - }, - { - "name": "commit_entry", - "base": "", - "fields": [ - { - "name": "underwriter", - "type": "name" - }, - { - "name": "source_received_at_ms", - "type": "uint64" - }, - { - "name": "source_outpost_id", - "type": "uint64" - }, - { - "name": "source_uic_bytes", - "type": "bytes" - }, - { - "name": "dest_received_at_ms", - "type": "uint64" - }, - { - "name": "dest_outpost_id", - "type": "uint64" - }, - { - "name": "dest_uic_bytes", - "type": "bytes" - }, - { - "name": "status", - "type": "UnderwriteStatus" - }, - { - "name": "reason", - "type": "string" - } - ] - }, - { - "name": "createuwreq", - "base": "", - "fields": [ - { - "name": "attestation_id", - "type": "uint64" - }, - { - "name": "type", - "type": "AttestationType" - }, - { - "name": "chain_code", - "type": "uint64" - }, - { - "name": "data", - "type": "bytes" - } - ] - }, - { - "name": "drainfwq", - "base": "", - "fields": [] - }, - { - "name": "freelocks", - "base": "", - "fields": [ - { - "name": "uwreq_id", - "type": "uint64" - }, - { - "name": "underwriter", - "type": "name" - } - ] - }, - { - "name": "fromwire_q", - "base": "", - "fields": [ - { - "name": "id", - "type": "uint64" - }, - { - "name": "user", - "type": "name" - }, - { - "name": "wire_amount", - "type": "uint64" - }, - { - "name": "dst_chain_code", - "type": "slug_name" - }, - { - "name": "dst_token_code", - "type": "slug_name" - }, - { - "name": "dst_reserve_code", - "type": "slug_name" - }, - { - "name": "target_amount", - "type": "uint64" - }, - { - "name": "variance_tolerance_bps", - "type": "uint32" - }, - { - "name": "recipient_kind", - "type": "ChainKind" - }, - { - "name": "recipient_addr", - "type": "bytes" - }, - { - "name": "created_at_epoch", - "type": "uint32" - } - ] - }, - { - "name": "fw_key", - "base": "", - "fields": [ - { - "name": "id", - "type": "uint64" - } - ] - }, - { - "name": "holdlocks", - "base": "", - "fields": [ - { - "name": "uwreq_id", - "type": "uint64" - }, - { - "name": "underwriter", - "type": "name" - }, - { - "name": "chal_id", - "type": "uint64" - } - ] - }, - { - "name": "id_key", - "base": "", - "fields": [ - { - "name": "id", - "type": "uint64" - } - ] - }, - { - "name": "lock_entry", - "base": "", - "fields": [ - { - "name": "lock_id", - "type": "uint64" - }, - { - "name": "uwreq_id", - "type": "uint64" - }, - { - "name": "underwriter", - "type": "name" - }, - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "reserve_code", - "type": "slug_name" - }, - { - "name": "amount", - "type": "uint64" - }, - { - "name": "created_at_ms", - "type": "uint64" - }, - { - "name": "expires_at_ms", - "type": "uint64" - }, - { - "name": "challenge_id", - "type": "uint64" - } - ] - }, - { - "name": "lock_key", - "base": "", - "fields": [ - { - "name": "lock_id", - "type": "uint64" - } - ] - }, - { - "name": "lock_sum", - "base": "", - "fields": [ - { - "name": "underwriter", - "type": "name" - }, - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - }, - { - "name": "amount", - "type": "uint64" - } - ] - }, - { - "name": "lock_sum_key", - "base": "", - "fields": [ - { - "name": "underwriter", - "type": "name" - }, - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - } - ] - }, - { - "name": "pruneuwreqs", - "base": "", - "fields": [ - { - "name": "max_rows", - "type": "uint32" - } - ] - }, - { - "name": "rcrdcommit", - "base": "", - "fields": [ - { - "name": "uwreq_id", - "type": "uint64" - }, - { - "name": "underwriter", - "type": "name" - }, - { - "name": "chain_code", - "type": "uint64" - }, - { - "name": "from_chain_code", - "type": "slug_name" - }, - { - "name": "from_token_code", - "type": "slug_name" - }, - { - "name": "reserve_code", - "type": "slug_name" - }, - { - "name": "uic_bytes", - "type": "bytes" - } - ] - }, - { - "name": "setconfig", - "base": "", - "fields": [ - { - "name": "fee_bps", - "type": "uint32" - }, - { - "name": "collateral_lock_duration_ms", - "type": "uint64" - }, - { - "name": "min_fromwire_amount", - "type": "uint64" - }, - { - "name": "fromwire_revert_fee_bps", - "type": "uint32" - }, - { - "name": "uwreq_pending_timeout_epochs", - "type": "uint32" - }, - { - "name": "uwreq_retention_epochs", - "type": "uint32" - } - ] - }, - { - "name": "sumlocks", - "base": "", - "fields": [ - { - "name": "underwriter", - "type": "name" - }, - { - "name": "chain_code", - "type": "slug_name" - }, - { - "name": "token_code", - "type": "slug_name" - } - ] - }, - { - "name": "swapfromwire", - "base": "", - "fields": [ - { - "name": "user", - "type": "name" - }, - { - "name": "wire_amount", - "type": "uint64" - }, - { - "name": "dst_chain_code", - "type": "slug_name" - }, - { - "name": "dst_token_code", - "type": "slug_name" - }, - { - "name": "dst_reserve_code", - "type": "slug_name" - }, - { - "name": "target_amount", - "type": "uint64" - }, - { - "name": "target_tolerance_bps", - "type": "uint32" - }, - { - "name": "recipient_kind", - "type": "ChainKind" - }, - { - "name": "recipient_addr", - "type": "bytes" - } - ] - }, - { - "name": "sweeplocks", - "base": "", - "fields": [ - { - "name": "uwreq_id", - "type": "uint64" - }, - { - "name": "underwriter", - "type": "name" - } - ] - }, - { - "name": "uw_config", - "base": "", - "fields": [ - { - "name": "fee_bps", - "type": "uint32" - }, - { - "name": "collateral_lock_duration_ms", - "type": "uint64" - }, - { - "name": "min_fromwire_amount", - "type": "uint64" - }, - { - "name": "fromwire_revert_fee_bps", - "type": "uint32" - }, - { - "name": "uwreq_pending_timeout_epochs", - "type": "uint32" - }, - { - "name": "uwreq_retention_epochs", - "type": "uint32" - } - ] - }, - { - "name": "uw_counters", - "base": "", - "fields": [ - { - "name": "next_lock_id", - "type": "uint64" - }, - { - "name": "next_fromwire_seq", - "type": "uint64" - } - ] - }, - { - "name": "uw_request_t", - "base": "", - "fields": [ - { - "name": "id", - "type": "uint64" - }, - { - "name": "type", - "type": "AttestationType" - }, - { - "name": "status", - "type": "UnderwriteRequestStatus" - }, - { - "name": "src_chain_code", - "type": "slug_name" - }, - { - "name": "src_token_code", - "type": "slug_name" - }, - { - "name": "src_reserve_code", - "type": "slug_name" - }, - { - "name": "src_amount", - "type": "uint64" - }, - { - "name": "dst_chain_code", - "type": "slug_name" - }, - { - "name": "dst_token_code", - "type": "slug_name" - }, - { - "name": "dst_reserve_code", - "type": "slug_name" - }, - { - "name": "dst_amount", - "type": "uint64" - }, - { - "name": "target_amount", - "type": "uint64" - }, - { - "name": "variance_tolerance_bps", - "type": "uint32" - }, - { - "name": "source_tx_id", - "type": "bytes" - }, - { - "name": "depositor", - "type": "bytes" - }, - { - "name": "commits_by", - "type": "commit_entry[]" - }, - { - "name": "winner", - "type": "name" - }, - { - "name": "committed_at_ms", - "type": "uint64" - }, - { - "name": "settled_at_ms", - "type": "uint64" - }, - { - "name": "expires_at_epoch", - "type": "uint32" - }, - { - "name": "attestation_inbound_data", - "type": "bytes" - }, - { - "name": "attestation_outbound_data", - "type": "bytes" - }, - { - "name": "challenge_id", - "type": "uint64" - } - ] - } - ], - "actions": [ - { - "name": "chklocks", - "type": "chklocks", - "ricardian_contract": "" - }, - { - "name": "createuwreq", - "type": "createuwreq", - "ricardian_contract": "" - }, - { - "name": "drainfwq", - "type": "drainfwq", - "ricardian_contract": "" - }, - { - "name": "freelocks", - "type": "freelocks", - "ricardian_contract": "" - }, - { - "name": "holdlocks", - "type": "holdlocks", - "ricardian_contract": "" - }, - { - "name": "pruneuwreqs", - "type": "pruneuwreqs", - "ricardian_contract": "" - }, - { - "name": "rcrdcommit", - "type": "rcrdcommit", - "ricardian_contract": "" - }, - { - "name": "setconfig", - "type": "setconfig", - "ricardian_contract": "" - }, - { - "name": "sumlocks", - "type": "sumlocks", - "ricardian_contract": "" - }, - { - "name": "swapfromwire", - "type": "swapfromwire", - "ricardian_contract": "" - }, - { - "name": "sweeplocks", - "type": "sweeplocks", - "ricardian_contract": "" - } - ], - "tables": [ - { - "name": "fwqueue", - "type": "fromwire_q", - "index_type": "i64", - "key_names": ["id"], - "key_types": ["uint64"], - "table_id": 19551, - "secondary_indexes": [ - { - "name": "byepoch", - "key_type": "uint64", - "table_id": 10478 - } - ] - }, - { - "name": "locks", - "type": "lock_entry", - "index_type": "i64", - "key_names": ["lock_id"], - "key_types": ["uint64"], - "table_id": 31311, - "secondary_indexes": [ - { - "name": "byuw", - "key_type": "uint64", - "table_id": 10723 - }, - { - "name": "byuwreq", - "key_type": "uint64", - "table_id": 30520 - }, - { - "name": "byexpire", - "key_type": "uint64", - "table_id": 53377 - } - ] - }, - { - "name": "locksums", - "type": "lock_sum", - "index_type": "i64", - "key_names": ["underwriter","chain_code","token_code"], - "key_types": ["name","slug_name","slug_name"], - "table_id": 57617 - }, - { - "name": "uwconfig", - "type": "uw_config", - "index_type": "i64", - "key_names": ["name"], - "key_types": ["name"], - "table_id": 7727 - }, - { - "name": "uwcounters", - "type": "uw_counters", - "index_type": "i64", - "key_names": ["name"], - "key_types": ["name"], - "table_id": 5169 - }, - { - "name": "uwreqs", - "type": "uw_request_t", - "index_type": "i64", - "key_names": ["id"], - "key_types": ["uint64"], - "table_id": 20885, - "secondary_indexes": [ - { - "name": "bystatus", - "key_type": "uint64", - "table_id": 60695 - }, - { - "name": "bywinner", - "key_type": "uint64", - "table_id": 29561 - }, - { - "name": "byexpire", - "key_type": "uint64", - "table_id": 36295 - } - ] - } - ], - "ricardian_clauses": [], - "variants": [], - "action_results": [ - { - "name": "sumlocks", - "result_type": "uint64" - } - ], - "enums": [ - { - "name": "AttestationType", - "type": "int32", - "values": [ - { - "name": "ATTESTATION_TYPE_UNSPECIFIED", - "value": 0 - }, - { - "name": "ATTESTATION_TYPE_OPERATOR_ACTION", - "value": 2001 - }, - { - "name": "ATTESTATION_TYPE_STAKE", - "value": 3001 - }, - { - "name": "ATTESTATION_TYPE_UNSTAKE", - "value": 3002 - }, - { - "name": "ATTESTATION_TYPE_PRETOKEN_PURCHASE", - "value": 3004 - }, - { - "name": "ATTESTATION_TYPE_PRETOKEN_YIELD", - "value": 3006 - }, - { - "name": "ATTESTATION_TYPE_RESERVE_BALANCE_SHEET", - "value": 43520 - }, - { - "name": "ATTESTATION_TYPE_STAKE_UPDATE", - "value": 60928 - }, - { - "name": "ATTESTATION_TYPE_WIRE_TOKEN_PURCHASE", - "value": 60930 - }, - { - "name": "ATTESTATION_TYPE_CHALLENGE_RESPONSE", - "value": 60932 - }, - { - "name": "ATTESTATION_TYPE_SWAP_REQUEST", - "value": 60934 - }, - { - "name": "ATTESTATION_TYPE_SWAP_REMIT", - "value": 60944 - }, - { - "name": "ATTESTATION_TYPE_CHALLENGE_REQUEST", - "value": 60945 - }, - { - "name": "ATTESTATION_TYPE_OPERATORS", - "value": 60947 - }, - { - "name": "ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS", - "value": 60943 - }, - { - "name": "ATTESTATION_TYPE_NODE_OWNER_REG", - "value": 60949 - }, - { - "name": "ATTESTATION_TYPE_STAKING_REWARD", - "value": 60950 - }, - { - "name": "ATTESTATION_TYPE_STAKE_RESULT", - "value": 60951 - }, - { - "name": "ATTESTATION_TYPE_ATTESTATION_PROCESSING_ERROR", - "value": 60952 - }, - { - "name": "ATTESTATION_TYPE_UNDERWRITE_INTENT_COMMIT", - "value": 60953 - }, - { - "name": "ATTESTATION_TYPE_SWAP_REVERT", - "value": 60955 - }, - { - "name": "ATTESTATION_TYPE_DEPOSIT_REVERT", - "value": 60956 - }, - { - "name": "ATTESTATION_TYPE_RESERVE_CREATE", - "value": 60958 - }, - { - "name": "ATTESTATION_TYPE_RESERVE_CREATE_CANCEL", - "value": 60959 - }, - { - "name": "ATTESTATION_TYPE_RESERVE_CREATE_CANCELLED", - "value": 60960 - }, - { - "name": "ATTESTATION_TYPE_RESERVE_READY", - "value": 60961 - }, - { - "name": "ATTESTATION_TYPE_EMISSIONS_BLOCKED", - "value": 60962 - }, - { - "name": "ATTESTATION_TYPE_SYNDICATE_LIQ", - "value": 60963 - }, - { - "name": "ATTESTATION_TYPE_LIQ_YIELD", - "value": 60964 - }, - { - "name": "ATTESTATION_TYPE_DESYNDICATE_LIQ", - "value": 60965 - } - ] - }, - { - "name": "ChainKind", - "type": "int32", - "values": [ - { - "name": "CHAIN_KIND_UNKNOWN", - "value": 0 - }, - { - "name": "CHAIN_KIND_WIRE", - "value": 1 - }, - { - "name": "CHAIN_KIND_EVM", - "value": 2 - }, - { - "name": "CHAIN_KIND_SVM", - "value": 3 - } - ] - }, - { - "name": "UnderwriteRequestStatus", - "type": "int32", - "values": [ - { - "name": "UNDERWRITE_REQUEST_STATUS_PENDING", - "value": 0 - }, - { - "name": "UNDERWRITE_REQUEST_STATUS_CONFIRMED", - "value": 1 - }, - { - "name": "UNDERWRITE_REQUEST_STATUS_REJECTED", - "value": 2 - }, - { - "name": "UNDERWRITE_REQUEST_STATUS_COMPLETED", - "value": 3 - }, - { - "name": "UNDERWRITE_REQUEST_STATUS_EXPIRED", - "value": 4 - } - ] - }, - { - "name": "UnderwriteStatus", - "type": "int32", - "values": [ - { - "name": "UNDERWRITE_STATUS_INTENT_CREATED", - "value": 0 - }, - { - "name": "UNDERWRITE_STATUS_INTENT_SUBMITTED", - "value": 1 - }, - { - "name": "UNDERWRITE_STATUS_INTENT_CONFIRMED", - "value": 2 - }, - { - "name": "UNDERWRITE_STATUS_READY", - "value": 3 - }, - { - "name": "UNDERWRITE_STATUS_RELEASED", - "value": 5 - }, - { - "name": "UNDERWRITE_STATUS_SLASHED", - "value": 10 - }, - { - "name": "UNDERWRITE_STATUS_DISQUALIFIED", - "value": 11 - } - ] - } - ] -} \ No newline at end of file diff --git a/contracts/sysio.uwrit/sysio.uwrit.wasm b/contracts/sysio.uwrit/sysio.uwrit.wasm deleted file mode 100755 index fae6467f3dcbd86be940b7bf6eb6ea4e0b176a15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 161033 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 S(*bnsNb#M5u~%uA3-Q0?a(l7> diff --git a/contracts/tests/contracts.hpp.in b/contracts/tests/contracts.hpp.in index 2506f42ba1..202e506231 100644 --- a/contracts/tests/contracts.hpp.in +++ b/contracts/tests/contracts.hpp.in @@ -24,14 +24,10 @@ struct contracts { static std::vector opreg_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.opreg/sysio.opreg.abi"); } static std::vector msgch_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.msgch/sysio.msgch.wasm"); } static std::vector msgch_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.msgch/sysio.msgch.abi"); } - static std::vector uwrit_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.uwrit/sysio.uwrit.wasm"); } - static std::vector uwrit_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.uwrit/sysio.uwrit.abi"); } static std::vector chalg_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.chalg/sysio.chalg.wasm"); } static std::vector chalg_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.chalg/sysio.chalg.abi"); } static std::vector councl_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.councl/sysio.councl.wasm"); } static std::vector councl_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.councl/sysio.councl.abi"); } - static std::vector reserve_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.reserv/sysio.reserv.wasm"); } - static std::vector reserve_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.reserv/sysio.reserv.abi"); } static std::vector dclaim_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.dclaim/sysio.dclaim.wasm"); } static std::vector dclaim_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.dclaim/sysio.dclaim.abi"); } static std::vector chains_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.chains/sysio.chains.wasm"); } diff --git a/contracts/tests/depot_flow_coverage.json b/contracts/tests/depot_flow_coverage.json index bc13700c4c..450d259d20 100644 --- a/contracts/tests/depot_flow_coverage.json +++ b/contracts/tests/depot_flow_coverage.json @@ -1,6 +1,6 @@ { - "tools_branch": "feature/syndication-underwriting", - "tools_head": "686f09cdaf0463873a4d6c80c72bd47cc96a0f1d", + "tools_branch": "feature/deprecated-cleanup", + "tools_head": "ed871bd7fa072ec273b896e0ab247072427cd37d", "flows": [ { "name": "flow-batch-operator-slashing", diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index 8184971450..186f3cf4cc 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -38,6 +38,7 @@ #include #include "finalizer_test_keys.hpp" +#include "contract_test_support.hpp" #include #include @@ -59,7 +60,6 @@ static constexpr account_name OPREG = "sysio.opreg"_n; static constexpr account_name EPOCH = "sysio.epoch"_n; static constexpr account_name CHALG = "sysio.chalg"_n; static constexpr account_name MSGCH = "sysio.msgch"_n; -static constexpr account_name UWRIT = "sysio.uwrit"_n; // Keep these in sync with contracts/sysio.system/src/emissions.cpp static constexpr uint32_t SECONDS_PER_MONTH = 30u * 24u * 60u * 60u; @@ -356,16 +356,16 @@ class sysio_emissions_tester : public tester { // // Under ROA (active via the base tester), accounts need explicit ROA // RAM policies before set_code can succeed for a large contract. - create_accounts({ OPREG, EPOCH, CHALG, MSGCH, UWRIT }); + create_accounts({ OPREG, EPOCH, CHALG, MSGCH }); produce_blocks(1); // OPREG, EPOCH and UWRIT have real contract code set below and need the // full RAM policy; CHALG and MSGCH are inline-target placeholders with // no code deployed. Sizing the placeholders down keeps the sixth // allocation within nodedaddy's tier-1 SYS pool. - for (auto acct : { OPREG, EPOCH, CHALG, MSGCH, UWRIT }) { + for (auto acct : { OPREG, EPOCH, CHALG, MSGCH }) { if (get_roa_policy(acct, "nodedaddy"_n).is_null()) { - const bool has_code = acct == OPREG || acct == EPOCH || acct == UWRIT; + const bool has_code = acct == OPREG || acct == EPOCH; auto tr = addpolicy_ram_only("nodedaddy"_n, acct, asset::from_string(has_code ? "500.0000 SYS" : "100.0000 SYS")); BOOST_REQUIRE( tr ); @@ -382,12 +382,6 @@ class sysio_emissions_tester : public tester { set_abi ( EPOCH, contracts::epoch_abi().data() ); set_privileged( EPOCH ); - // sysio.epoch::advance unconditionally inlines sysio.uwrit::chklocks, - // so uwrit must be deployed for advance_epoch_state() to succeed. - set_code( UWRIT, contracts::uwrit_wasm() ); - set_abi ( UWRIT, contracts::uwrit_abi().data() ); - set_privileged( UWRIT ); - produce_blocks(1); { @@ -547,95 +541,6 @@ class sysio_emissions_tester : public tester { produce_blocks(1); } - /// Deploy the real sysio.reserv contract (privileged) so the swap-fee - /// fold-in test can seed its rewards bucket via a swap. Mirrors - /// deploy_dclaim_for_signing's account + ROA-policy + code pattern. - void deploy_reserv() { - const account_name RESERV = "sysio.reserv"_n; - if (!control->db().find(RESERV)) { - create_accounts({ RESERV }); - produce_blocks(1); - } - if (get_roa_policy(RESERV, "nodedaddy"_n).is_null()) { - auto tr = addpolicy_ram_only("nodedaddy"_n, RESERV, asset::from_string("500.0000 SYS")); - BOOST_REQUIRE( tr ); - BOOST_REQUIRE( !tr->except ); - produce_blocks(1); - } - set_code( RESERV, contracts::reserve_wasm() ); - set_abi ( RESERV, contracts::reserve_abi().data() ); - set_privileged( RESERV ); - produce_blocks(1); - } - - /// Current balance of sysio.reserv's batch-operator rewards bucket. - /// - /// Requires deploy_reserv(). A missing bucket is reported as zero. - int64_t reserv_reward_balance() { - const account_name RESERV = "sysio.reserv"_n; - auto data = get_row_by_account(RESERV, RESERV, "rewardbkt"_n, "rewardbkt"_n); - if (data.empty()) return 0; - - const auto* meta = control->find_account_metadata(RESERV); - BOOST_REQUIRE(meta != nullptr); - abi_def def; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(meta->abi, def), true); - abi_serializer reserv_ser; - reserv_ser.set_abi(def, abi_serializer::create_yield_function(abi_serializer_max_time)); - auto bucket = reserv_ser.binary_to_variant( - "rewards_bucket", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - return static_cast(bucket["balance"].as_uint64()); - } - - /// Deploy sysio.reserv and seed its batch-operator rewards bucket with a - /// real bootstrap-window swap fee, returning the exact accrued balance. - int64_t seed_reserv_reward_bucket() { - const account_name RESERV = "sysio.reserv"_n; - const account_name UWRIT = "sysio.uwrit"_n; - deploy_reserv(); - - BOOST_REQUIRE_EQUAL(success(), push_reserv_action(RESERV, "regreserve"_n, mvo() - ("chain_code", "ETH")("token_code", "ETH")("reserve_code", "PRIMARY") - ("name", "eth")("description", "") - ("initial_chain_amount", 1'000'000'000'000ULL)("initial_wire_amount", 1'000'000'000'000ULL) - ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}))); - BOOST_REQUIRE_EQUAL(success(), push_reserv_action(RESERV, "regreserve"_n, mvo() - ("chain_code", "SOLANA")("token_code", "SOL")("reserve_code", "PRIMARY") - ("name", "sol")("description", "") - ("initial_chain_amount", 1'000'000'000'000ULL)("initial_wire_amount", 1'000'000'000'000ULL) - ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}))); - BOOST_REQUIRE_EQUAL(success(), push_reserv_action(UWRIT, "applyswap"_n, mvo() - ("src_chain_code", "ETH")("src_token_code", "ETH")("src_reserve_code", "PRIMARY") - ("src_amount", 1'000'000'000ULL) - ("dst_chain_code", "SOLANA")("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("dst_amount", 100'000'000ULL)("underwriter", name{}))); - - const int64_t balance = reserv_reward_balance(); - BOOST_REQUIRE_GT(balance, 0); - return balance; - } - - /// `sysio.reserv::wireclaims` balance owed to `acc`, or 0 when there is no row. - /// Requires deploy_reserv(). Credited by paywire / refundwire, drained by claimwire, and - /// reclaimed to the treasury by the retention sweep sysio.epoch::advance inlines. - uint64_t wire_claimable( account_name acc ) { - const account_name RESERV = "sysio.reserv"_n; - auto data = get_row_by_account(RESERV, RESERV, "wireclaims"_n, acc); - if (data.empty()) return 0u; - - const auto* meta = control->find_account_metadata( RESERV ); - BOOST_REQUIRE( meta != nullptr ); - abi_def def; - BOOST_REQUIRE_EQUAL( abi_serializer::to_abi(meta->abi, def), true ); - abi_serializer reserv_ser; - reserv_ser.set_abi( def, abi_serializer::create_yield_function(abi_serializer_max_time) ); - - return reserv_ser.binary_to_variant("wire_claim", data, - abi_serializer::create_yield_function(abi_serializer_max_time))["balance"].as_uint64(); - } - // ----------------------------- // sysio.system action helpers // ----------------------------- @@ -1435,17 +1340,6 @@ class sysio_emissions_tester : public tester { return push_epoch_action(signer, "advance"_n, mvo()); } - // Push an action against the (separately deployed) sysio.reserv contract. - // Only the swap-fee fold-in test deploys reserv and uses this. - action_result push_reserv_action(account_name signer, action_name act, const variant_object& data) { - try { - base_tester::push_action("sysio.reserv"_n, act, signer, data); - return success(); - } catch (const fc::exception& ex) { - return error(ex.top_message()); - } - } - // Convenience: set epoch config only. Genesis advance is deferred so each // test can decide whether to initt5 (and thus pass the emissions gate) or // exercise gate-block behavior. Under the new model, the first @@ -2684,7 +2578,6 @@ BOOST_FIXTURE_TEST_CASE( payepoch_recovers_from_incomplete_batch_roster_history, const account_name BATCH_OP = "batchopa"_n; constexpr int64_t period_emission = 10'000; create_t5_holding_accounts(); - const int64_t fee_total = seed_reserv_reward_bucket(); BOOST_REQUIRE_EQUAL(success(), setemitcfg_defaults(config::system_account_name)); BOOST_REQUIRE_EQUAL(success(), initt5(config::system_account_name, tpsec(head_secs()))); BOOST_REQUIRE_EQUAL(success(), @@ -2710,7 +2603,6 @@ BOOST_FIXTURE_TEST_CASE( payepoch_recovers_from_incomplete_batch_roster_history, log["batch_emission_retained"].as()); BOOST_REQUIRE_EQUAL(int64_t(0), log["fee_distributed"].as()); BOOST_REQUIRE_EQUAL(int64_t(0), log["batch_fee_retained"].as()); - BOOST_REQUIRE_EQUAL(fee_total, reserv_reward_balance()); // The next complete period must pick up the same retained fee bucket. This // proves the recovery path did not merely leave it readable before silently @@ -2732,8 +2624,7 @@ BOOST_FIXTURE_TEST_CASE( payepoch_recovers_from_incomplete_batch_roster_history, auto recovery_log = get_epoch_log(2); BOOST_REQUIRE(recovery_log["batch_history_complete"].as_bool()); - BOOST_REQUIRE_EQUAL(fee_total, recovery_log["fee_distributed"].as()); - BOOST_REQUIRE_EQUAL(int64_t(0), reserv_reward_balance()); + BOOST_REQUIRE_EQUAL(compute - producer_pool, get_wire_balance_paid(BATCH_OP).get_amount()); } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE( payepoch_seeds_initial_roster_history_at_activation_epoch, sysio_emissions_tester ) try { @@ -4292,148 +4183,6 @@ BOOST_FIXTURE_TEST_CASE( single_active_producer_paid_per_block, sysio_emissions_ BOOST_REQUIRE_LT( got, test_active_pool(compute) ); } FC_LOG_AND_RETHROW() -// Swap-fee rewards (sysio.reserv rewards_bucket) are folded into payepoch's -// BATCH-OPERATOR distribution only: batch ops receive them on top of their -// emission share, and producers receive none of them (producer_bps / -// batch_op_bps govern the emission split alone). End-to-end: deploy reserv, -// seed the bucket with a real swap fee, advance to the (cadence-1) pay-epoch, -// and verify the single producer is paid its emission producer_pool and NOTHING -// more, the all-empty roster leaves the bucket in reserv, and the fee is NOT -// counted against the emission treasury. -BOOST_FIXTURE_TEST_CASE( payepoch_folds_swap_fee_rewards, sysio_emissions_tester ) try { - create_t5_holding_accounts(); - const int64_t fee_total = seed_reserv_reward_bucket(); - - // --- Single full-round producer; advance to the cadence-1 pay-epoch --- - setup_producers(1); - wait_for_producer_schedule(); - produce_complete_cycles(1, 2); - - const uint32_t start = head_secs() - ONE_EPOCH - 1; - BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - - const int64_t t5_before = get_t5_state()["total_distributed"].as(); - const uint32_t blocks = unpaid_blocks_of("producera"_n); - const int64_t bal_before = get_wire_balance_paid("producera"_n).get_amount(); - - // Must NOT overdraw: payepoch queues the reserv->sysio drain ahead of the - // payout transfers, so the swept fee lands in sysio's balance first. - BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); - - const int64_t got = get_wire_balance_paid("producera"_n).get_amount() - bal_before; - - auto log = get_epoch_log(1); - const int64_t compute = log["compute_amount"].as(); - const int64_t capex = log["capex_amount"].as(); - const int64_t gov = log["governance_amount"].as(); - const int64_t producer_pool = test_split_bps(compute, PRODUCER_BPS); - const int64_t batch_pool = compute - producer_pool; - - // The producer received its emission share and NOTHING MORE. Swap fees pay - // the parties that carry an individual swap — the winning underwriter and the - // batch operators that relay it — never producers, who earn emissions for - // securing the chain. (The share is the per-block rate times its blocks; the - // slots it did not fill and the standby slice stay in the treasury.) - BOOST_REQUIRE_EQUAL( got, test_block_pay(test_active_pool(compute), blocks, - test_nominal_slots(T_EPOCH_SECS)) ); - BOOST_REQUIRE_LT( got, producer_pool ); - - // Nothing was distributed out of the fee: this fixture has no non-empty - // rotation group, so the bucket stays in reserv for a future payable period. - // This is the NEGATIVE case — a batch operator actually receiving the fee is - // `payepoch_pays_swap_fee_to_active_batch_operator` below. - BOOST_REQUIRE_EQUAL( log["fee_distributed"].as(), 0 ); - BOOST_REQUIRE(log["batch_history_complete"].as_bool()); - BOOST_REQUIRE_EQUAL(log["batch_emission_retained"].as(), batch_pool); - BOOST_REQUIRE_EQUAL(log["batch_fee_retained"].as(), int64_t(0)); - - BOOST_REQUIRE_EQUAL( reserv_reward_balance(), fee_total ); - - // total_distributed counts emission only (the producer's block pay + capex + - // gov, with the unfilled slots, the standby slice and the empty batch group's - // share staying in treasury) -- the fee is NOT charged against the emission - // curve. - const int64_t t5_after = get_t5_state()["total_distributed"].as(); - BOOST_REQUIRE_EQUAL( t5_after - t5_before, got + capex + gov ); -} FC_LOG_AND_RETHROW() - -// The POSITIVE counterpart: a swap fee actually reaching an ACTIVE batch -// operator's balance. `payepoch_folds_swap_fee_rewards` proves that an all-empty -// history preserves reserv custody; this positive case proves a non-empty, -// eligible roster drains and distributes the bucket. -// -// Scaled to a ONE-member rotation (operators_per_epoch = batch_op_groups = 1) so -// the arithmetic is exact rather than a proportional bound: with one group active -// for the single epoch of a cadence-1 period, that member's slice is the entire -// batch pool and the entire fee pool. Asserts the recipient's balance delta and -// the exact positive `epochlog.fee_distributed`. -BOOST_FIXTURE_TEST_CASE( payepoch_pays_swap_fee_to_active_batch_operator, sysio_emissions_tester ) try { - const account_name BATCH_OP = "batchopa"_n; - - create_t5_holding_accounts(); - const int64_t fee_total = seed_reserv_reward_bucket(); - - // --- A one-member rotation group, ACTIVE in opreg --- - // Bootstrapped so the ACTIVE flip bypasses the collateral gate (see - // .claude/rules/bootstrapped-operator-invariants.md); payepoch's own filter is - // `is_op_active(member, OPERATOR_TYPE_BATCH)`, which this satisfies. - create_accounts( { BATCH_OP }, false, false, false, true ); // include_ram_gift - BOOST_REQUIRE_EQUAL( success(), - register_operator( BATCH_OP, OperatorType::OPERATOR_TYPE_BATCH, /*is_bootstrapped*/true ) ); - - // operators_per_epoch = batch_op_groups = 1 -> batch_operator_minimum_active - // is 1, so this single operator satisfies schbatchgps and fills the whole - // window: one group, one member, paid every epoch. - BOOST_REQUIRE_EQUAL( success(), init_epoch_state(60, /*operators_per_epoch*/1, - /*batch_op_groups_count*/1) ); - produce_blocks(1); - BOOST_REQUIRE_EQUAL( success(), push_epoch_action(EPOCH, "schbatchgps"_n, mvo()) ); - - setup_producers(1); - wait_for_producer_schedule(); - produce_complete_cycles(1, 2); - - const uint32_t start = head_secs() - ONE_EPOCH - 1; - BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - - const int64_t t5_before = get_t5_state()["total_distributed"].as(); - const uint32_t producer_blocks = unpaid_blocks_of("producera"_n); - const int64_t bal_before = get_wire_balance(BATCH_OP).get_amount(); - - BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); - - // --- The payout --- - auto log = get_epoch_log(1); - const int64_t compute = log["compute_amount"].as(); - const int64_t producer_pool = test_split_bps(compute, PRODUCER_BPS); - const int64_t batch_pool = compute - producer_pool; - - // One group, active for the single epoch of a cadence-1 period, one member: - // the member's slice is the whole batch pool AND the whole fee pool. The - // emission and fee shares ride ONE credit, so the balance delta after the - // claim is the sum. - const int64_t got = get_wire_balance_paid(BATCH_OP).get_amount() - bal_before; - BOOST_REQUIRE_EQUAL( got, batch_pool + fee_total ); - - // The fee reached a real recipient — the assertion the negative case cannot - // make. Exact value, not merely positive: a fee that leaked into the producer - // pool or was double-counted would still be > 0 here. - BOOST_REQUIRE_EQUAL( log["fee_distributed"].as(), fee_total ); - BOOST_REQUIRE(log["batch_history_complete"].as_bool()); - BOOST_REQUIRE_EQUAL(log["batch_emission_retained"].as(), int64_t(0)); - BOOST_REQUIRE_EQUAL(log["batch_fee_retained"].as(), int64_t(0)); - - // Bucket swept, and the fee is NOT charged against the emission curve — - // total_distributed moves by the EMISSION only, excluding fee_total. - BOOST_REQUIRE_EQUAL( reserv_reward_balance(), 0 ); - const int64_t capex = log["capex_amount"].as(); - const int64_t gov = log["governance_amount"].as(); - const int64_t t5_after = get_t5_state()["total_distributed"].as(); - const int64_t producer_pay = test_block_pay(test_active_pool(compute), producer_blocks, - test_nominal_slots(T_EPOCH_SECS)); - BOOST_REQUIRE_EQUAL( t5_after - t5_before, producer_pay + batch_pool + capex + gov ); -} FC_LOG_AND_RETHROW() - // Lowering pay_cadence_epochs MID-PERIOD must not multiply the payout. // `accrueepoch` increments one batch_group_epochs slot per epoch unconditionally, // while `setemitcfg` may change pay_cadence_epochs at any time. Normalizing by @@ -4447,12 +4196,11 @@ BOOST_FIXTURE_TEST_CASE( payepoch_pays_swap_fee_to_active_batch_operator, sysio_ // Runs with an ACTIVE batch group and a NON-ZERO fee, because with either absent // the overpayment is unobservable: no group means nothing is distributed, and a // zero fee makes the fee half of the bug invisible. -BOOST_FIXTURE_TEST_CASE( cadence_drop_midperiod_does_not_multiply_batch_fee_payout, +BOOST_FIXTURE_TEST_CASE( cadence_drop_midperiod_does_not_multiply_batch_payout, sysio_emissions_tester ) try { const account_name BATCH_OP = "batchopb"_n; create_t5_holding_accounts(); - const int64_t fee_total = seed_reserv_reward_bucket(); create_accounts( { BATCH_OP }, false, false, false, true ); BOOST_REQUIRE_EQUAL( success(), @@ -4492,12 +4240,11 @@ BOOST_FIXTURE_TEST_CASE( cadence_drop_midperiod_does_not_multiply_batch_fee_payo // The single group holds BOTH accrued epochs, so normalizing by the accrued // total (2) gives it the whole pool exactly once -- not twice. const int64_t got = get_wire_balance_paid(BATCH_OP).get_amount() - bal_before; - BOOST_REQUIRE_EQUAL( got, batch_pool + fee_total ); + BOOST_REQUIRE_EQUAL( got, batch_pool ); // The load-bearing assertion: the fee is distributed ONCE. Under the old // divisor this was 2 * fee_total, with the surplus drawn from the treasury. - BOOST_REQUIRE_EQUAL( log["fee_distributed"].as(), fee_total ); - BOOST_REQUIRE_EQUAL( reserv_reward_balance(), 0 ); + BOOST_REQUIRE_EQUAL( log["fee_distributed"].as(), 0 ); // And the emission side is not double-paid either: the producer's count spans both accrued // epochs and is paid once, over the two epochs' worth of slots. @@ -5028,121 +4775,6 @@ BOOST_FIXTURE_TEST_CASE( advance_gate_blocks_on_insufficient_treasury_balance, s BOOST_REQUIRE_EQUAL( get_t5_state()["last_epoch_index"].as(), 0u ); } FC_LOG_AND_RETHROW() -// A balance-blocked epoch reclaims forfeited WIRE and unblocks ITSELF, with no manual sweep and -// no top-up. This pins the placement of the `sysio.reserv::sweepclaims` inline in advance: it sits -// BEFORE the emissions gate, so a BALANCE_INSUFFICIENT epoch still queues the reclaim on its way -// out. Move that call below the gate (where the other maintenance sweeps live) and the epoch -// returns without ever reclaiming the WIRE that covers its own shortfall -- every retry repeating -// it, with `sweepclaims` taking only epoch/reserv authority so no keeper can break the cycle. -// -// The two-attempt shape is the guarantee, not an artifact: `action.send()` QUEUES the inline, so -// this advance's gate has already read the treasury by the time the reclaim executes. The first -// attempt therefore records the block AND performs the reclaim; the next chkcons retry sees the -// larger balance and advances. -BOOST_FIXTURE_TEST_CASE( expired_wire_claims_unblock_a_balance_blocked_epoch, sysio_emissions_tester ) try { - const account_name RESERV = "sysio.reserv"_n; - - create_t5_holding_accounts(); - deploy_reserv(); - - // Move real WIRE into reserv custody so a claim has backing. regreserve is bootstrap-window - // only, which holds here: current_epoch_index is still 0. - constexpr uint64_t RESERVE_SEED = 1'000'000'000'000ULL; - BOOST_REQUIRE_EQUAL( success(), push_reserv_action(RESERV, "regreserve"_n, mvo() - ("chain_code", "ETH")("token_code", "ETH")("reserve_code", "PRIMARY") - ("name", "eth")("description", "") - ("initial_chain_amount", RESERVE_SEED)("initial_wire_amount", RESERVE_SEED) - ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}) ) ); - - // Fund the escrow the refund gives back, SEPARATELY from the reserve's booked liquidity. - // `regreserve` books RESERVE_SEED into `reserve_wire_amount`, and `refundwire` credits a claim - // without debiting that row — in production it is reached only after `swapfromwire` has already - // deposited the user's in-flight escrow on top. Skipping that deposit would make the sweep hand - // the treasury registered reserve liquidity instead of forfeited escrow, so the test would - // unblock emissions by breaking reserv's custody invariant rather than by reclaiming a claim. - constexpr uint64_t FORFEIT = 100'000'000'000ULL; - base_tester::push_action( - TOKEN, "transfer"_n, - vector{{ config::system_account_name, "active"_n }}, - mvo()("from", config::system_account_name) - ("to", RESERV) - ("quantity", asset(static_cast(FORFEIT), WIRE_SYMBOL)) - ("memo", "in-flight swap-from-WIRE escrow the refund returns") - ); - produce_blocks(1); - BOOST_REQUIRE_EQUAL( RESERVE_SEED + FORFEIT, - static_cast(get_wire_balance(RESERV).get_amount()) ); - - // A swap-from-WIRE refund credits a claimable balance that nobody ever pulls. Custody now - // reads `reserve_wire_amount (RESERVE_SEED) + Σ wireclaims (FORFEIT)`. - create_user_accounts({ "lapseduser"_n }); - BOOST_REQUIRE_EQUAL( success(), push_reserv_action(UWRIT, "refundwire"_n, mvo() - ("recipient", "lapseduser") - ("wire_amount", FORFEIT) - ("revert_fee_bps", 0)) ); - BOOST_REQUIRE_EQUAL( FORFEIT, wire_claimable("lapseduser"_n) ); - - // Age past the one-year window with NO further credit, so `credit_wire_claim`'s opportunistic - // sweep never fires and the epoch-driven one is the only thing that can collect the row. - produce_block(); - produce_block(fc::days(366)); - produce_blocks(2); - BOOST_REQUIRE_EQUAL( FORFEIT, wire_claimable("lapseduser"_n) ); - - const uint32_t start = head_secs() - ONE_EPOCH - 1; - BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - - // Epoch 1's emission is the annual initial scaled to the fixture's 60s epoch; it is also the - // period total, since nothing has accrued yet. - const int64_t period_emission = test_scale_annual_to_epoch(ANNUAL_INITIAL_EMISSION, 60); - BOOST_REQUIRE_GT( period_emission, static_cast(FORFEIT) ); - - // Leave the treasury short by EXACTLY the forfeited claim. - create_user_accounts({ "lapsedrain"_n }); - const int64_t balance = get_wire_balance(config::system_account_name).get_amount(); - const int64_t target = period_emission - static_cast(FORFEIT); - BOOST_REQUIRE_GT( balance, target ); - base_tester::push_action( - TOKEN, "transfer"_n, - vector{{ config::system_account_name, "active"_n }}, - mvo()("from", config::system_account_name) - ("to", "lapsedrain"_n) - ("quantity", asset(balance - target, WIRE_SYMBOL)) - ("memo", "leave the treasury one forfeited claim short of the epoch emission") - ); - produce_blocks(1); - - // FIRST attempt: blocked on the balance the gate could see, and the queued reclaim still runs. - produce_blocks(130); - BOOST_REQUIRE_EQUAL( success(), advance_epoch_state(EPOCH) ); - - auto blocked = get_blocklog_row(1u); - BOOST_REQUIRE( !blocked.is_null() ); - BOOST_REQUIRE_EQUAL( blocked["reason"].as_string(), "EMISSIONS_BLOCK_REASON_BALANCE_INSUFFICIENT" ); - BOOST_REQUIRE_EQUAL( blocked["attempted_emission"].as(), period_emission ); - BOOST_REQUIRE_EQUAL( get_t5_state()["last_epoch_index"].as(), 0u ); - - // The forfeited claim is gone and its WIRE is in the treasury -- reclaimed by the same - // transaction that recorded the block. - BOOST_REQUIRE_EQUAL( 0u, wire_claimable("lapseduser"_n) ); - BOOST_REQUIRE_EQUAL( period_emission, get_wire_balance(config::system_account_name).get_amount() ); - - // What moved was the ESCROW, not the reserve. Custody is back to exactly the booked - // `reserve_wire_amount`, so the epoch unblocked itself on forfeited value rather than on - // registered liquidity. - BOOST_REQUIRE_EQUAL( RESERVE_SEED, static_cast(get_wire_balance(RESERV).get_amount()) ); - - // SECOND attempt: no manual sweepclaims, no funding -- the epoch advances on its own. - produce_blocks(130); - BOOST_REQUIRE_EQUAL( success(), advance_epoch_state(EPOCH) ); - - auto est = get_epoch_state_row(); - BOOST_REQUIRE( !est.is_null() ); - BOOST_REQUIRE_EQUAL( est["current_epoch_index"].as(), 1u ); - BOOST_REQUIRE( get_blocklog_row(1u).is_null() ); - BOOST_REQUIRE_EQUAL( get_t5_state()["last_epoch_index"].as(), 1u ); -} FC_LOG_AND_RETHROW() - BOOST_FIXTURE_TEST_CASE( roa_forcereg_inlines_addnodeowner_happy_path, sysio_emissions_tester ) try { // Happy path: with emitcfg present (fixture default), sysio.roa::forcereg // fires an inline sysio.system::addnodeowner which writes a nodedist row. @@ -6049,12 +5681,12 @@ struct producer_score_tester : public producer_eligibility_tester { /// Chain/token pair the collateral helpers use. Any pair works -- opreg stores slug names /// opaquely -- so these name a plausible outpost rather than carrying meaning. - static constexpr std::string_view collateral_chain = "ETH"; - static constexpr std::string_view collateral_token = "ETH"; + static constexpr std::string_view collateral_chain = "WIRE"; + static constexpr std::string_view collateral_token = "WIRE"; /// A second pair, for the "minimum across pairs" case. - static constexpr std::string_view second_chain = "SOL"; - static constexpr std::string_view second_token = "SOL"; + static constexpr std::string_view second_chain = "WIRE"; + static constexpr std::string_view second_token = "NTA"; /// The minimum bond every collateral test measures its ratios against. static constexpr uint64_t base_min_bond = 1'000'000; @@ -6109,17 +5741,65 @@ struct producer_score_tester : public producer_eligibility_tester { /// This is the seam the score hangs off: a credit runs `reevaluate_eligibility`, which /// dispatches `processprod` for producers on EVERY balance change, whose notification /// sysio.system turns into a rescore. + bool native_shadow_ready = false; + + void setup_score_shadow() { + if (native_shadow_ready) return; + const auto chains = "sysio.chains"_n; + const auto tokens = "sysio.tokens"_n; + const auto synd = "sysio.synd"_n; + const auto liq = "sysio.liq"_n; + for (auto account : {chains, tokens, synd}) { + if (!control->db().find(account)) { + create_accounts({account}, false, false, false, true); + produce_blocks(1); + } + if (account != synd && get_roa_policy(account, "nodedaddy"_n).is_null()) { + const auto trace = addpolicy_ram_only("nodedaddy"_n, account, asset::from_string("100.0000 SYS")); + BOOST_REQUIRE(trace && !trace->except); + produce_blocks(1); + } + } + set_code(chains, contracts::chains_wasm()); + set_abi(chains, contracts::chains_abi().data()); + set_privileged(chains); + set_code(tokens, contracts::tokens_wasm()); + set_abi(tokens, contracts::tokens_abi().data()); + set_privileged(tokens); + deploy_liq_for_signing(); + const std::vector address(20, '\x31'); + base_tester::push_action(chains, "regchain"_n, chains, + mvo()("kind", ChainKind::CHAIN_KIND_EVM)("code", "EC1")("external_chain_id", 1) + ("name", "External Chain 1")("description", "") + ("outpost", sysio_system::test_support::no_outpost_mvo())); + base_tester::push_action(tokens, "regtoken"_n, tokens, + mvo()("kind", TokenKind::TOKEN_KIND_LIQ)("code", "NTA")("symbol_name", "NTA") + ("description", "Native Token 1")("precision", 9) + ("address", mvo()("kind", ChainKind::CHAIN_KIND_EVM)("address", address))); + base_tester::push_action(tokens, "regctok"_n, tokens, + mvo()("chain_code", "EC1")("token_code", "NTA")("contract_addr", address)("is_native", false)); + base_tester::push_action(liq, "create"_n, liq, + mvo()("sym", "9,NTA")("chain_code", "EC1")("token_code", "NTA")); + native_shadow_ready = true; + } + + /// Fund and bond real depot-native collateral, driving the production rescore notification. action_result credit_collateral(account_name account, uint64_t amount, std::string_view chain = collateral_chain, std::string_view token = collateral_token) { - return push_opreg_action(OPREG, "depositinle"_n, mvo() - ("account", account) - ("chain_code", chain) - ("token_code", token) - ("amount", amount) - ("actor_chain", ChainKind::CHAIN_KIND_EVM) - ("actor_address", std::vector(20, '\x06')) - ("original_message_id", fc::sha256())); + BOOST_REQUIRE_EQUAL(chain, "WIRE"); + if (token == "WIRE") { + BOOST_REQUIRE_EQUAL(success(), push_token_action(config::system_account_name, "transfer"_n, + mvo()("from", "sysio")("to", account) + ("quantity", asset(static_cast(amount), WIRE_SYMBOL))("memo", "bond funding"))); + } else { + BOOST_REQUIRE_EQUAL(token, "NTA"); + setup_score_shadow(); + base_tester::push_action("sysio.liq"_n, "mint"_n, "sysio.synd"_n, + mvo()("to", account)("token_code", token)("amount", amount)); + } + return push_opreg_action(account, "deposit"_n, + mvo()("account", account)("token_code", token)("amount", amount)); } /// Push `setscorecfg`. Defaults mirror the contract's own so a test names only the weight it diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index d7b9620273..7abdfb2c19 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -23,8 +23,6 @@ // The depot's swap kernel — tests re-derive both the quote settlement must pay // and (via token_to_wire) the challenge-bond reference math. #include -#include -#include #include #include @@ -72,37 +70,6 @@ constexpr uint32_t PROTOBUF_FIELD_TAG_SHIFT = 3u; /// or above any outstanding shadow a dispatch test can reach. constexpr uint64_t CUSTODY_COVERING_ANY_SUPPLY = static_cast(sysio::chain::asset::max_amount); -/** Append one unsigned protobuf varint to a hostile-wire-format fixture. */ -void append_proto_varint(std::vector& out, uint64_t value) { - do { - uint8_t byte = static_cast(value & PROTOBUF_VARINT_PAYLOAD_MASK); - value >>= PROTOBUF_VARINT_PAYLOAD_BITS; - if (value != 0) byte |= PROTOBUF_VARINT_CONTINUATION_BIT; - out.push_back(static_cast(byte)); - } while (value != 0); -} - -/** Append one complete protobuf varint field as an explicit key/value pair. */ -void append_varint_field(std::vector& out, uint32_t field_number, - uint64_t value) { - constexpr uint32_t VARINT_WIRE_TYPE = 0; - append_proto_varint(out, (static_cast(field_number) - << PROTOBUF_FIELD_TAG_SHIFT) - | VARINT_WIRE_TYPE); - append_proto_varint(out, value); -} - -/** Append one length-delimited protobuf field without a generated schema. */ -void append_length_delimited_field(std::vector& out, uint32_t field_number, - std::span payload) { - constexpr uint32_t LENGTH_DELIMITED_WIRE_TYPE = 2; - append_proto_varint(out, (static_cast(field_number) - << PROTOBUF_FIELD_TAG_SHIFT) - | LENGTH_DELIMITED_WIRE_TYPE); - append_proto_varint(out, payload.size()); - out.insert(out.end(), payload.begin(), payload.end()); -} - /// One `chain_min_bond` entry for `sysio.opreg::setconfig`'s `req_*_collat` /// vectors. `config_timestamp_ms` is stamped by the contract, so 0 here. inline fc::variant chain_min_bond_mvo(std::string_view chain_code, @@ -278,42 +245,6 @@ std::string encode_node_owner_registration( return out; } -/// Encode a SwapRequest attestation payload — the bytes `createuwreq` decodes. -std::string encode_swap_request( - sysio::opp::types::ChainKind actor_kind, - const std::vector& actor_addr, - uint64_t src_chain_code_v, uint64_t src_token_code_v, uint64_t src_reserve_code_v, - int64_t src_amount, - uint64_t dst_chain_code_v, uint64_t dst_token_code_v, uint64_t dst_reserve_code_v, - uint64_t target_amount, uint32_t tolerance_bps, - sysio::opp::types::ChainKind recipient_kind, - const std::vector& recipient_addr) -{ - sysio::opp::attestations::SwapRequest sr; - auto* actor = sr.mutable_actor(); - actor->set_kind(actor_kind); - actor->set_address(actor_addr.data(), actor_addr.size()); - auto* amt = sr.mutable_source_amount(); - amt->set_token_code(src_token_code_v); - amt->set_amount(src_amount); - sr.set_source_chain_code(src_chain_code_v); - sr.set_source_reserve_code(src_reserve_code_v); - sr.set_target_chain_code(dst_chain_code_v); - sr.set_target_token_code(dst_token_code_v); - sr.set_target_reserve_code(dst_reserve_code_v); - sr.set_target_amount(target_amount); - sr.set_target_tolerance_bps(tolerance_bps); - auto* rcpt = sr.mutable_recipient(); - rcpt->set_kind(recipient_kind); - rcpt->set_address(recipient_addr.data(), recipient_addr.size()); - const std::vector tx_id{'\x01', '\x02', '\x03', '\x04'}; - sr.set_source_tx_id(tx_id.data(), tx_id.size()); - - std::string out; - sr.SerializeToString(&out); - return out; -} - /// Encode a SyndicateLIQ attestation payload: the emitting outpost, the syndicating user's /// native pubkey (kind + bytes), the liq TokenAmount, the per-outpost sequence and the outpost's /// live custody total. @@ -396,20 +327,9 @@ class sysio_dispatch_tester : public tester { public: static constexpr auto MSGCH_ACCOUNT = "sysio.msgch"_n; static constexpr auto OPREG_ACCOUNT = "sysio.opreg"_n; - static constexpr auto UWRIT_ACCOUNT = "sysio.uwrit"_n; static constexpr auto EPOCH_ACCOUNT = "sysio.epoch"_n; - static constexpr auto RESERV_ACCOUNT = "sysio.reserv"_n; static constexpr auto CHALG_ACCOUNT = "sysio.chalg"_n; - /// `sysio.uwrit::uw_config::fee_bps`'s default (0.1% per spoke) — the rate in - /// force whenever a case does not push its own `uwrit::setconfig`. Tests that - /// re-derive a swap quote must charge the same fee the contract did. - static constexpr uint32_t kDefaultUwritFeeBps = 10; - - /// Mirrors `sysio.uwrit::MAX_LOCK_RELEASE_PER_EPOCH` — the per-epoch `chklocks` budget - /// `sysio.epoch::advance` passes. Tests that sweep "as production would" use this; tests - /// exercising the bound itself pass their own smaller value. - static constexpr uint32_t kMaxLockReleasePerEpoch = 32; static constexpr auto TOKEN_ACCOUNT = "sysio.token"_n; static constexpr auto AUTHEX_ACCOUNT = "sysio.authex"_n; static constexpr auto DCLAIM_ACCOUNT = "sysio.dclaim"_n; @@ -427,8 +347,8 @@ class sysio_dispatch_tester : public tester { produce_blocks(2); create_accounts({ - MSGCH_ACCOUNT, OPREG_ACCOUNT, UWRIT_ACCOUNT, EPOCH_ACCOUNT, - RESERV_ACCOUNT, CHALG_ACCOUNT, TOKEN_ACCOUNT, CHAINS_ACCOUNT, + MSGCH_ACCOUNT, OPREG_ACCOUNT, EPOCH_ACCOUNT, + CHALG_ACCOUNT, TOKEN_ACCOUNT, CHAINS_ACCOUNT, DCLAIM_ACCOUNT, BATCHOP, UWRIT_OP }); // CLAIM_ACCOUNT with NO roa policy (include_roa_policy=false) so regnodeowner exercises the @@ -442,9 +362,7 @@ class sysio_dispatch_tester : public tester { deploy(MSGCH_ACCOUNT, contracts::msgch_wasm(), contracts::msgch_abi(), msgch_abi); deploy(OPREG_ACCOUNT, contracts::opreg_wasm(), contracts::opreg_abi(), opreg_abi); - deploy(UWRIT_ACCOUNT, contracts::uwrit_wasm(), contracts::uwrit_abi(), uwrit_abi); deploy(EPOCH_ACCOUNT, contracts::epoch_wasm(), contracts::epoch_abi(), epoch_abi); - deploy(RESERV_ACCOUNT, contracts::reserve_wasm(), contracts::reserve_abi(), reserv_abi); deploy(AUTHEX_ACCOUNT, contracts::authex_wasm(), contracts::authex_abi(), authex_abi); deploy(DCLAIM_ACCOUNT, contracts::dclaim_wasm(), contracts::dclaim_abi(), dclaim_abi); deploy(CHAINS_ACCOUNT, contracts::chains_wasm(), contracts::chains_abi(), chains_abi); @@ -689,22 +607,6 @@ class sysio_dispatch_tester : public tester { return fc::variant(); } - /// Find an operator's balance entry for a (chain_code, token_code) pair. - fc::variant find_balance(const fc::variant& op, - std::string_view chain_code, - std::string_view token_code) { - const auto chain_v = fc::slug_name{chain_code}.value; - const auto token_v = fc::slug_name{token_code}.value; - const auto& arr = op["balances"].get_array(); - for (const auto& b : arr) { - if (b["chain_code"].as().value == chain_v && - b["token_code"].as().value == token_v) { - return b; - } - } - return fc::variant(); - } - // Read sysio.roa's kv tables (scoped by network_gen). `nodeowners` proves registration; // `nodeownerreg` is the audit row (status / reject_reason). fc::variant get_nodeowner(name acc) { @@ -742,641 +644,7 @@ class sysio_dispatch_tester : public tester { type, data, abi_serializer::create_yield_function(abi_serializer_max_time)); } - // ── uwrit swap-race helpers (direct msgch-auth action calls) ── - - action_result depositinle_credit(name account, std::string_view chain_code, - std::string_view token_code, uint64_t amount) { - // depositinle does require_auth(get_self()); sign as opreg for a direct call. - return push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "depositinle"_n, mvo() - ("account", account.to_string()) - ("chain_code", chain_code) - ("token_code", token_code) - ("amount", amount) - ("actor_chain", ChainKind::CHAIN_KIND_EVM) - ("actor_address", std::vector(20, '\x06')) - ("original_message_id", fc::sha256())); - } - - action_result createuwreq_direct(uint64_t attestation_id, uint64_t chain_code_v, - const std::string& swap_request_bytes) { - return push(UWRIT_ACCOUNT, uwrit_abi, MSGCH_ACCOUNT, "createuwreq"_n, mvo() - ("attestation_id", attestation_id) - ("type", AttestationType::ATTESTATION_TYPE_SWAP_REQUEST) - ("chain_code", chain_code_v) - ("data", std::vector(swap_request_bytes.begin(), - swap_request_bytes.end()))); - } - - action_result rcrdcommit_direct(uint64_t uwreq_id, name underwriter, - uint64_t outpost_chain_code, - std::string_view from_chain, std::string_view from_token, - std::string_view reserve, const std::vector& uic_bytes) { - return push(UWRIT_ACCOUNT, uwrit_abi, MSGCH_ACCOUNT, "rcrdcommit"_n, mvo() - ("uwreq_id", uwreq_id) - ("underwriter", underwriter.to_string()) - ("chain_code", outpost_chain_code) - ("from_chain_code", from_chain) - ("from_token_code", from_token) - ("reserve_code", reserve) - ("uic_bytes", uic_bytes)); - } - - /** Push `rcrdcommit` and retain its trace for exact rejection-log assertions. */ - transaction_trace_ptr rcrdcommit_trace( - uint64_t uwreq_id, name underwriter, uint64_t outpost_chain_code, - std::string_view from_chain, std::string_view from_token, - std::string_view reserve, const std::vector& uic_bytes) { - return push_trace(UWRIT_ACCOUNT, uwrit_abi, MSGCH_ACCOUNT, "rcrdcommit"_n, mvo() - ("uwreq_id", uwreq_id) - ("underwriter", underwriter.to_string()) - ("chain_code", outpost_chain_code) - ("from_chain_code", from_chain) - ("from_token_code", from_token) - ("reserve_code", reserve) - ("uic_bytes", uic_bytes)); - } - - /// Create the common UnderwriteIntentCommit fields used by signed, - /// malformed-signature, and malformed-account test payloads. - sysio::opp::attestations::UnderwriteIntentCommit create_uic( - std::string_view underwriter, - uint64_t uwreq_id, - uint64_t chain_code_v, - uint64_t token_code_v, - uint64_t reserve_code_v) { - sysio::opp::attestations::UnderwriteIntentCommit uic; - uic.mutable_uw_account()->set_name( - std::string{underwriter.data(), underwriter.size()}); - uic.mutable_uw_ext_chain_addr()->set_kind(sysio::opp::types::CHAIN_KIND_EVM); - uic.mutable_uw_ext_chain_addr()->set_address(std::string(20, '\x01')); - uic.set_uw_request_id(uwreq_id); - uic.set_token_code(token_code_v); - uic.set_chain_code(chain_code_v); - uic.set_reserve_code(reserve_code_v); - return uic; - } - - /// Serialize one UnderwriteIntentCommit into the raw bytes relayed by an - /// outpost and stored by `sysio.uwrit::rcrdcommit`. - std::vector serialize_uic( - const sysio::opp::attestations::UnderwriteIntentCommit& uic) { - std::string full; - BOOST_REQUIRE(uic.SerializeToString(&full)); - return std::vector(full.begin(), full.end()); - } - - /// Blank, digest, sign, and serialize an already-shaped UIC. - std::vector sign_uic( - sysio::opp::attestations::UnderwriteIntentCommit uic, - const fc::crypto::private_key& signing_key) { - uic.clear_signature(); - const auto blanked = serialize_uic(uic); - const auto digest = fc::sha256::hash(blanked.data(), blanked.size()); - - const auto sig = signing_key.sign(digest); - const auto sig_bytes = fc::raw::pack(sig); - uic.set_signature(sig_bytes.data(), sig_bytes.size()); - return serialize_uic(uic); - } - - /// Create a UIC claiming `claimed_underwriter` and signed by an explicit - /// WIRE private key. Tests use this to exercise every supported fixed-size - /// signature variant and to model forged account claims. - std::vector create_signed_uic_with_key( - name claimed_underwriter, - const fc::crypto::private_key& signing_key, - uint64_t uwreq_id, - uint64_t chain_code_v, - uint64_t token_code_v, - uint64_t reserve_code_v) { - auto uic = create_uic(claimed_underwriter.to_string(), uwreq_id, chain_code_v, - token_code_v, reserve_code_v); - return sign_uic(std::move(uic), signing_key); - } - - /// Produce a valid K1-signed UIC with an exact serialized size. Padding is - /// carried in the external-chain address, which is part of the signed digest - /// but otherwise irrelevant to rcrdcommit's row-growth boundary. - std::vector create_signed_uic_of_size( - name underwriter, - uint64_t uwreq_id, - uint64_t chain_code_v, - uint64_t token_code_v, - uint64_t reserve_code_v, - size_t target_size) { - const auto size_probe_digest = fc::sha256::hash(std::string{ - "wire.sysio.dispatch_tests.sized_uic_signature"}); - const size_t packed_k1_signature_size = fc::raw::pack( - get_private_key(underwriter, "active").sign(size_probe_digest)).size(); - auto uic = create_uic(underwriter.to_string(), uwreq_id, chain_code_v, - token_code_v, reserve_code_v); - - for (size_t padding_size = 0; padding_size < target_size; ++padding_size) { - uic.mutable_uw_ext_chain_addr()->set_address( - std::string(padding_size, '\x01')); - uic.set_signature(std::string(packed_k1_signature_size, '\0')); - const auto shaped_size = uic.ByteSizeLong(); - if (shaped_size < target_size) continue; - BOOST_REQUIRE_EQUAL(target_size, shaped_size); - - const auto signed_uic = sign_uic( - std::move(uic), get_private_key(underwriter, "active")); - BOOST_REQUIRE_EQUAL(target_size, signed_uic.size()); - return signed_uic; - } - - BOOST_FAIL("could not construct an exact-size signed UIC"); - return {}; - } - - /// Create a UIC claiming `claimed_underwriter` but signed by - /// `signing_account`. Separating these accounts lets tests model a real - /// protocol forgery: dispatch derives the candidate from the claimed - /// account, while signature recovery must reject an unrelated signing key. - std::vector create_signed_uic_as(name claimed_underwriter, - name signing_account, - uint64_t uwreq_id, - uint64_t chain_code_v, - uint64_t token_code_v, - uint64_t reserve_code_v) { - return create_signed_uic_with_key( - claimed_underwriter, get_private_key(signing_account, "active"), - uwreq_id, chain_code_v, token_code_v, reserve_code_v); - } - - /// Create and sign an UnderwriteIntentCommit so it passes verification: - /// serialize with signature empty (matching the contract's - /// blank-then-rehash), hash the raw bytes, sign with the claimed - /// underwriter's active key, embed the packed signature, and serialize. - std::vector create_signed_uic(name underwriter, uint64_t uwreq_id, - uint64_t chain_code_v, - uint64_t token_code_v, uint64_t reserve_code_v) { - return create_signed_uic_as(underwriter, underwriter, uwreq_id, chain_code_v, - token_code_v, reserve_code_v); - } - - /// Create a protobuf-valid UIC carrying caller-supplied packed signature - /// bytes. This reaches the depot's inner-signature verifier while allowing - /// tests to exercise truncated, oversized, and unsupported variant shapes. - std::vector create_uic_with_signature( - name underwriter, - uint64_t uwreq_id, - uint64_t chain_code_v, - uint64_t token_code_v, - uint64_t reserve_code_v, - const std::vector& signature) { - auto uic = create_uic(underwriter.to_string(), uwreq_id, chain_code_v, - token_code_v, reserve_code_v); - if (!signature.empty()) { - uic.set_signature(signature.data(), signature.size()); - } - return serialize_uic(uic); - } - - /** Return the one-byte fc variant tag for a packed UIC signature type. */ - static char packed_uic_signature_tag(fc::crypto::signature::sig_type type) { - return static_cast(magic_enum::enum_integer(type)); - } - - /** Produce a real signer-generated packed shape for a supported key type. */ - static std::vector create_packed_uic_signature( - fc::crypto::private_key::key_type type) { - const auto digest = fc::sha256::hash(std::string{ - "wire.sysio.dispatch_tests.packed_uic_signature"}); - return fc::raw::pack(fc::crypto::private_key::generate(type).sign(digest)); - } - - /** Produce a complete serialized WebAuthn variant with structured fields. */ - static std::vector create_packed_webauthn_uic_signature() { - fc::crypto::webauthn::signature webauthn{ - fc::crypto::r1::compact_signature{}, - std::vector{1, 2, 3}, - R"({"type":"webauthn.get"})"}; - return fc::raw::pack(fc::crypto::signature{ - fc::crypto::signature::storage_type{std::move(webauthn)}}); - } - /** Produce a complete serialized fixed-size variant for a shim type. */ - template - static std::vector create_default_packed_uic_signature() { - return fc::raw::pack(fc::crypto::signature{ - fc::crypto::signature::storage_type{ - std::in_place_type}}); - } - - /** Replace only the fc variant tag while preserving a real packed size. */ - static std::vector retag_packed_uic_signature( - std::vector packed, - fc::crypto::signature::sig_type type) { - BOOST_REQUIRE(!packed.empty()); - packed.front() = packed_uic_signature_tag(type); - return packed; - } - - /// Produce a correctly tagged fixed-size signature that is one byte short. - /// Its size comes from fc's real signer/serializer rather than a duplicated - /// protocol constant. - std::vector create_truncated_uic_signature( - fc::crypto::private_key::key_type type) { - auto packed = create_packed_uic_signature(type); - BOOST_REQUIRE_GT(packed.size(), 1u); - packed.pop_back(); - return packed; - } - - /// Preserve fc's serialized variant tag/size while replacing the signature - /// body with invalid recovery data. - std::vector create_invalid_uic_signature( - fc::crypto::private_key::key_type type) { - auto packed = create_packed_uic_signature(type); - BOOST_REQUIRE_GT(packed.size(), 1u); - std::fill(std::next(packed.begin()), packed.end(), '\0'); - return packed; - } - - /** Produce a correctly tagged fixed-size signature that is one byte long. */ - std::vector create_overlength_uic_signature( - fc::crypto::private_key::key_type type) { - auto packed = create_packed_uic_signature(type); - packed.push_back('\0'); - return packed; - } - - /** Corrupt only the Ed25519 signature suffix, preserving its embedded key. */ - std::vector create_corrupted_ed_uic_signature() { - auto packed = create_packed_uic_signature( - fc::crypto::private_key::key_type::ed); - BOOST_REQUIRE(!packed.empty()); - packed.back() = static_cast(static_cast(packed.back()) ^ 1u); - return packed; - } - - /** - * Convert a legitimate low-s UIC signature into its valid high-s alternate - * (`s' = n - s`, recovery parity flipped) without changing signed bytes. - */ - std::vector create_high_s_signed_uic( - name underwriter, - const fc::crypto::private_key& signing_key, - uint64_t uwreq_id, - uint64_t chain_code_v, - uint64_t token_code_v, - uint64_t reserve_code_v) { - using key_type = fc::crypto::private_key::key_type; - auto uic = create_uic(underwriter.to_string(), uwreq_id, chain_code_v, - token_code_v, reserve_code_v); - const auto blanked = serialize_uic(uic); - const auto digest = fc::sha256::hash(blanked.data(), blanked.size()); - auto packed = fc::raw::pack(signing_key.sign(digest)); - auto high_s = sysio::opp::test::create_high_s_alternate( - std::move(packed), signing_key.type()); - BOOST_REQUIRE(high_s.has_value()); - packed = std::move(*high_s); - - // Prove this is a valid alternate representation, not random corruption. - // libfc's R1 recovery entrypoint itself enforces low-s before recovery, - // so the algebraic n-s/parity construction is the executable proof for - // R1; K1/EM can additionally recover through their host shims. - const auto alternate = fc::raw::unpack(packed); - if (signing_key.type() != key_type::r1) { - BOOST_REQUIRE(fc::crypto::public_key::recover(alternate, digest) - == signing_key.get_public_key()); - } - - uic.set_signature(packed.data(), packed.size()); - return serialize_uic(uic); - } - - /** - * Replace a legitimate UIC signature recovery byte with libfc's equivalent - * noncanonical alias without changing the recovered key. - */ - std::vector create_recovery_alias_signed_uic( - name underwriter, - const fc::crypto::private_key& signing_key, - uint64_t uwreq_id, - uint64_t chain_code_v, - uint64_t token_code_v, - uint64_t reserve_code_v) { - auto uic = create_uic(underwriter.to_string(), uwreq_id, chain_code_v, - token_code_v, reserve_code_v); - const auto blanked = serialize_uic(uic); - const auto digest = fc::sha256::hash(blanked.data(), blanked.size()); - auto alias = sysio::opp::test::create_recovery_alias( - fc::raw::pack(signing_key.sign(digest)), signing_key.type()); - BOOST_REQUIRE(alias.has_value()); - - const auto alternate = fc::raw::unpack(*alias); - BOOST_REQUIRE(fc::crypto::public_key::recover(alternate, digest) - == signing_key.get_public_key()); - - uic.set_signature(alias->data(), alias->size()); - return serialize_uic(uic); - } - - /// Create an UnderwriteIntentCommit carrying an arbitrary raw `uw_account.name` - /// string — including names that are not constructible as a `sysio::name` - /// (uppercase, hyphen, over-long). No signature is embedded: the dispatch path - /// validates the account name and drops a malformed UIC before rcrdcommit or - /// signature verification is ever reached, so a valid signature is unnecessary. - std::vector create_uic_raw_name(const std::string& raw_name, uint64_t uwreq_id, - uint64_t chain_code_v, - uint64_t token_code_v, uint64_t reserve_code_v) { - return serialize_uic( - create_uic(raw_name, uwreq_id, chain_code_v, token_code_v, reserve_code_v)); - } - - fc::variant get_uwreq(uint64_t id) { - auto data = get_row_by_id(UWRIT_ACCOUNT, UWRIT_ACCOUNT, "uwreqs"_n, id); - return data.empty() ? fc::variant() : uwrit_abi.binary_to_variant( - "uw_request_t", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - } - - /// Next attestation id `sysio.msgch::mint_att_id` will return: the `attseq` singleton's - /// `next`, or 1 before the row is materialised. Outbound queueouts share the same sequence - /// (e.g. reserve registration emits RESERVE_READY), so a test that needs the ids an inbound - /// envelope's attestations will mint must read the counter rather than assume it starts at 1. - uint64_t next_att_id() { - auto data = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "attseq"_n, 0); - if (data.empty()) return 1; - auto v = msgch_abi.binary_to_variant("att_seq_entry", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - return v["next"].as_uint64(); - } - - /// Read a collateral lock row by lock_id (uwrit `locks` KV table). lock_ids - /// are allocated from 1 (uwcounters default), so the first swap's source + - /// destination locks are ids 1 and 2. - fc::variant get_lock(uint64_t lock_id) { - auto data = get_row_by_id(UWRIT_ACCOUNT, UWRIT_ACCOUNT, "locks"_n, lock_id); - return data.empty() ? fc::variant() : uwrit_abi.binary_to_variant( - "lock_entry", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - } - - /// Drive sysio.opreg::slash (CHALG-authorized economic punishment). Flips the - /// operator to SLASHED and immediately debits the unlocked (slashable-now) - /// portion of each balance; the locked portion is settled later by - /// releaselock as each lock's challenge window closes. - action_result slash_op(name account, const std::string& reason) { - return push(OPREG_ACCOUNT, opreg_abi, CHALG_ACCOUNT, "slash"_n, mvo() - ("account", account.to_string()) - ("reason", reason)); - } - - /// The `locksums` rollup total for one (underwriter, chain, token) bucket. - /// `locksums` is keyed by a checksum256 over the triple, so this walks the - /// KV table and filters in memory — same shape as `get_reserve`. An absent - /// row IS zero: the rollup erases a bucket once it empties. - uint64_t get_lock_sum(name underwriter, std::string_view chain_code, - std::string_view token_code) { - const auto target_chain = fc::slug_name{chain_code}.value; - const auto target_token = fc::slug_name{token_code}.value; - const auto& db = control->db(); - const auto table_id = chain::compute_table_id("locksums"_n.to_uint64_t()); - const auto& kv_idx = db.get_index(); - auto itr = kv_idx.lower_bound(boost::make_tuple(UWRIT_ACCOUNT, table_id, std::string_view{})); - for (; itr != kv_idx.end() && itr->code == UWRIT_ACCOUNT - && itr->table_id == table_id; ++itr) { - std::vector raw(itr->value.size()); - if (!raw.empty()) std::memcpy(raw.data(), itr->value.data(), raw.size()); - try { - auto row = uwrit_abi.binary_to_variant("lock_sum", raw, - abi_serializer::create_yield_function(abi_serializer_max_time)); - if (row["underwriter"].as_string() == underwriter.to_string() && - row["chain_code"].as().value == target_chain && - row["token_code"].as().value == target_token) { - return row["amount"].as_uint64(); - } - } catch (...) { - // Not a lock_sum row — skip. - } - } - return 0; - } - - /// Recompute the same bucket total from the AUTHORITATIVE `locks` rows. - /// This is the oracle the materialized rollup is asserted against — it is - /// the derivation `sum_locks_inline` used to perform inline before the - /// rollup replaced it. - uint64_t scan_lock_total(name underwriter, std::string_view chain_code, - std::string_view token_code) { - const auto target_chain = fc::slug_name{chain_code}.value; - const auto target_token = fc::slug_name{token_code}.value; - const auto& db = control->db(); - const auto table_id = chain::compute_table_id("locks"_n.to_uint64_t()); - const auto& kv_idx = db.get_index(); - uint64_t total = 0; - auto itr = kv_idx.lower_bound(boost::make_tuple(UWRIT_ACCOUNT, table_id, std::string_view{})); - for (; itr != kv_idx.end() && itr->code == UWRIT_ACCOUNT - && itr->table_id == table_id; ++itr) { - std::vector raw(itr->value.size()); - if (!raw.empty()) std::memcpy(raw.data(), itr->value.data(), raw.size()); - try { - auto row = uwrit_abi.binary_to_variant("lock_entry", raw, - abi_serializer::create_yield_function(abi_serializer_max_time)); - if (row["underwriter"].as_string() == underwriter.to_string() && - row["chain_code"].as().value == target_chain && - row["token_code"].as().value == target_token) { - total += row["amount"].as_uint64(); - } - } catch (...) { - // Not a lock_entry row — skip. - } - } - return total; - } - - /// Direct `sysio.uwrit::chklocks` call with an explicit budget - /// (self-authorized). `advance` inlines it with MAX_LOCK_RELEASE_PER_EPOCH; - /// the tests call it directly to exercise the budget itself without an - /// intervening advance sweeping the whole backlog first. - action_result chklocks_direct(uint32_t max_rows) { - return push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "chklocks"_n, mvo() - ("max_rows", max_rows)); - } - - /// Direct sysio.opreg::releaselock call (UWRIT-authorized). In production - /// this is fanned out one-per-lock by sysio.uwrit::chklocks at epoch advance; - /// the tests call it directly to exercise the deferred-slash settlement math. - action_result releaselock_direct(name account, std::string_view chain_code, - std::string_view token_code, uint64_t amount) { - return push(OPREG_ACCOUNT, opreg_abi, UWRIT_ACCOUNT, "releaselock"_n, mvo() - ("account", account.to_string()) - ("chain_code", chain_code) - ("token_code", token_code) - ("amount", amount)); - } - - /// Administratively terminate an operator (`sysio.opreg::terminate`, - /// self-authorized). Remits the unlocked portion of every balance and leaves - /// the locked remainder for `releaselock` — the state WNS-01's prune gate - /// must protect. - action_result terminate_op(name account, const std::string& reason) { - return push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "terminate"_n, mvo() - ("account", account.to_string()) - ("reason", reason)); - } - - /// The permissionless `sysio.opreg::prune` crank. - action_result opreg_prune() { - return push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "prune"_n, mvo()); - } - - /// Walk `sysio.reserv::reserves` and return the row matching the slug_name - /// triple. The table is KV-keyed by a checksum256, which `get_row_by_id` - /// (uint64 keys only) cannot reach — same scan `sysio.reserv_tests` uses. - fc::variant find_reserve(std::string_view chain_code, - std::string_view token_code, - std::string_view reserve_code) { - const auto target_chain = fc::slug_name{chain_code}.value; - const auto target_token = fc::slug_name{token_code}.value; - const auto target_reserve = fc::slug_name{reserve_code}.value; - - const auto& db = control->db(); - const auto table_id = chain::compute_table_id("reserves"_n.to_uint64_t()); - const auto& kv_idx = db.get_index(); - auto itr = kv_idx.lower_bound(boost::make_tuple(RESERV_ACCOUNT, table_id, std::string_view{})); - for (; itr != kv_idx.end() && itr->code == RESERV_ACCOUNT - && itr->table_id == table_id; ++itr) { - std::vector raw(itr->value.size()); - if (!raw.empty()) std::memcpy(raw.data(), itr->value.data(), raw.size()); - try { - auto row = reserv_abi.binary_to_variant("reserve_row", raw, - abi_serializer::create_yield_function(abi_serializer_max_time)); - if (row["chain_code"].as().value == target_chain && - row["token_code"].as().value == target_token && - row["reserve_code"].as().value == target_reserve) { - return row; - } - } catch (...) { - // Not a reserve_row — skip. - } - } - return fc::variant(); - } - - /// The depot's own quote for a swap, computed with the SAME kernel the - /// contract uses (`sysio.uwrit::swap_quote` -> `opp::amm::quote_swap`) over - /// the live reserve rows. Tests assert settlement against this rather than - /// against a hardcoded number, so they pin the INVARIANT ("dst_amount is the - /// curve's output") instead of one fixture's arithmetic. - uint64_t expected_quote(std::string_view src_chain, std::string_view src_token, - std::string_view src_reserve, uint64_t src_amount, - std::string_view dst_chain, std::string_view dst_token, - std::string_view dst_reserve, uint32_t fee_bps) { - const auto src = find_reserve(src_chain, src_token, src_reserve); - const auto dst = find_reserve(dst_chain, dst_token, dst_reserve); - BOOST_REQUIRE(!src.is_null()); - BOOST_REQUIRE(!dst.is_null()); - return sysio::opp::amm::quote_swap( - /*src_is_wire*/ false, - src["reserve_chain_amount"].as_uint64(), src["reserve_wire_amount"].as_uint64(), - static_cast(src["connector_weight_bps"].as_uint64()), - /*dst_is_wire*/ false, - dst["reserve_chain_amount"].as_uint64(), dst["reserve_wire_amount"].as_uint64(), - static_cast(dst["connector_weight_bps"].as_uint64()), - src_amount, fee_bps); - } - - /// Register one ACTIVE reserve with ample balanced liquidity (1e12 / 1e12, - /// 50% connector weight). Shared by the bootstrap pair below and by the - /// same-(chain, token) multi-reserve swap tests, which add a second reserve - /// on an already-registered (chain, token) pair. - action_result regreserve_active(std::string_view c, std::string_view t, std::string_view r) { - return regreserve_active_amounts(c, t, r, /*chain_amount*/ 1'000'000'000'000ull, - /*wire_amount*/ 1'000'000'000'000ull); - } - - /// `regreserve_active` with explicit pool depths, for a test that needs a deliberately - /// IMBALANCED reserve. At the 50% connector weight the curve is pure constant product - /// (`out = wire * amount / (chain + amount)`), so a deep token side over a shallow WIRE side - /// floors a small conversion to zero — the "dust bucket" the bond quote has to survive. - action_result regreserve_active_amounts(std::string_view c, std::string_view t, - std::string_view r, - uint64_t chain_amount, uint64_t wire_amount) { - return push(RESERV_ACCOUNT, reserv_abi, RESERV_ACCOUNT, "regreserve"_n, mvo() - ("chain_code", c) - ("token_code", t) - ("reserve_code", r) - ("name", std::string(c)) - ("description", std::string{}) - ("initial_chain_amount", chain_amount) - ("initial_wire_amount", wire_amount) - ("source_token_precision", uint32_t{9}) - ("connector_weight_bps", uint32_t{5000}) - ("is_private", false) - ("owner", "")); - } - - /// Drain chain-side reserve liquidity through the same UWRIT-authorized - /// action used by emitted swap settlement. Tests use this after request - /// creation to model liquidity changing while UICs are in flight. - action_result debit_reserve_chain(std::string_view c, std::string_view t, - std::string_view r, uint64_t amount) { - return push(RESERV_ACCOUNT, reserv_abi, UWRIT_ACCOUNT, "debit"_n, mvo() - ("chain_code", c) - ("token_code", t) - ("reserve_code", r) - ("amount", amount)); - } - - /// Deploy sysio.token, issue a WIRE supply to the treasury, and seed two - /// ACTIVE bootstrap reserves (ETH/ETH and SOLANA/SOL) with ample balanced - /// liquidity so try_select_winner's reserve-liquidity gate passes and the - /// race reaches try_build_swap_remit. Bootstrap window is open (epoch 0: - /// bootstrap_for_dispatch's advance() gate-blocks on missing emissions). - void setup_wire_token_and_reserves() { - deploy(TOKEN_ACCOUNT, contracts::token_wasm(), contracts::token_abi(), token_abi); - BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, TOKEN_ACCOUNT, "create"_n, mvo() - ("issuer", "sysio")("maximum_supply", "1000000000.000000000 WIRE"))); - BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, - "issue"_n, mvo()("to", "sysio")("quantity", "1000000000.000000000 WIRE")("memo", "seed"))); - - BOOST_REQUIRE_EQUAL(success(), regreserve_active("ETH", "ETH", "PRIMARY")); - BOOST_REQUIRE_EQUAL(success(), regreserve_active("SOLANA", "SOL", "PRIMARY")); - } - - /// Register the WIRE depot chain (`is_depot = (kind == CHAIN_KIND_WIRE)`), so a - /// swap whose destination leg is `WIRE` is treated as a to-WIRE swap (depot - /// destination, no destination UIC, settled inline via reserv::paywire). - void register_wire_depot() { - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, - "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_WIRE) - ("code", "WIRE") - ("external_chain_id", 0) - ("name", std::string("wire-depot")) - ("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - } - - /// Register SOLANA, seed ACTIVE ETH+SOLANA reserves, credit UWRIT_OP collateral, - /// and create a PENDING ETH->SOLANA uwreq under `att_id` with source leg - /// (ETH, ETH, PRIMARY) and destination leg (SOLANA, SOL, PRIMARY). Shared by the - /// UIC leg-binding and account-name-validation cases below. `bootstrap_for_dispatch` - /// must have been called first. - void setup_eth_to_sol_uwreq(uint64_t att_id) { - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM)("code", "SOLANA") - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 1'000'000'000)); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "SOLANA", "SOL", 1'000'000'000)); - - const auto eth = fc::slug_name{"ETH"}.value; - const auto sol_chain = fc::slug_name{"SOLANA"}.value; - const auto sol_token = fc::slug_name{"SOL"}.value; - const auto primary = fc::slug_name{"PRIMARY"}.value; - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, 100, sol_chain, sol_token, primary, 100, - 5000, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(att_id, /*proven=*/ eth, sr)); - BOOST_REQUIRE(!get_uwreq(att_id).is_null()); - } // ── SEC-129 / WSA-223: real epoch-aging helpers ────────────────────────── // @@ -1448,7 +716,7 @@ class sysio_dispatch_tester : public tester { /// Make `sysio.epoch::advance` genuinely advance: everything /// `deploy_system_with_emitcfg` sets up, plus the t5 state the gate needs /// past its STATE_UNINITIALIZED check. Requires - /// setup_wire_token_and_reserves() first (payepoch pays WIRE out of sysio's + /// setup_wire_token() first (payepoch pays WIRE out of sysio's /// token balance). void enable_epoch_advancement() { deploy_system_with_emitcfg(); @@ -1620,7 +888,15 @@ class sysio_dispatch_tester : public tester { return console; } - abi_serializer msgch_abi, opreg_abi, uwrit_abi, epoch_abi, reserv_abi, authex_abi, dclaim_abi, + void setup_wire_token() { + deploy(TOKEN_ACCOUNT, contracts::token_wasm(), contracts::token_abi(), token_abi); + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, TOKEN_ACCOUNT, "create"_n, mvo() + ("issuer", "sysio")("maximum_supply", "1000000000.000000000 WIRE"))); + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, + "issue"_n, mvo()("to", "sysio")("quantity", "1000000000.000000000 WIRE")("memo", "seed"))); + } + + abi_serializer msgch_abi, opreg_abi, epoch_abi, authex_abi, dclaim_abi, chains_abi, roa_abi, token_abi, tokens_abi, liq_abi, synd_abi; std::vector uwrit_op_eth_pubkey; @@ -1630,675 +906,116 @@ class sysio_dispatch_tester : public tester { BOOST_AUTO_TEST_SUITE(sysio_dispatch_tests) -BOOST_FIXTURE_TEST_CASE(dispatch_routes_deposit_to_opreg, sysio_dispatch_tester) { try { +BOOST_FIXTURE_TEST_CASE(dispatch_silently_drops_out_of_scope_types, sysio_dispatch_tester) { try { bootstrap_for_dispatch(); - constexpr int64_t DEPOSIT_AMOUNT = 1'000'000; const auto eth_code = fc::slug_name{"ETH"}.value; - - auto operator_payload = encode_operator_action( - sysio::opp::attestations::OperatorAction::ACTION_TYPE_DEPOSIT_REQUEST, - sysio::opp::types::CHAIN_KIND_EVM, - uwrit_op_eth_pubkey, - /*chain_code_v=*/ eth_code, - /*token_code_v=*/ eth_code, - DEPOSIT_AMOUNT); - auto envelope = encode_envelope_with_one_attestation( current_epoch(), - sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION, - operator_payload); + sysio::opp::types::ATTESTATION_TYPE_STAKE, + std::string{}); BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/eth_code, envelope)); auto op = get_operator(UWRIT_OP); BOOST_REQUIRE(!op.is_null()); - auto bal = find_balance(op, "ETH", "ETH"); - BOOST_REQUIRE(!bal.is_null()); - BOOST_REQUIRE_EQUAL(static_cast(DEPOSIT_AMOUNT), - bal["balance"].as_uint64()); + const auto& balances = op["balances"].get_array(); + BOOST_REQUIRE_EQUAL(0u, balances.size()); } FC_LOG_AND_RETHROW() } -// A payload `token_code` with no canonical spelling is DROPPED, never stored. -// -// `chain_code` is proven -- source_chain_binding_ok binds it to the delivering -// outpost -- but `token_code` rides the FORGEABLE payload and reaches slug_name -// through the non-validating raw constructor. Rendering is total, so persisting one -// leaves a balance row whose rendered code either fails validation on the way back, or -// silently re-parses as a DIFFERENT code -- aliasing one operator's balance onto -// another's key. -// So the dispatcher drops the attestation -- a check() here would halt -// evalcons and stall consensus (feedback_opp_handlers_never_throw). -// A DEPOSIT_REQUEST arrives only after the outpost has taken custody, so an -// unspellable token code must be REFUNDED rather than dropped: a drop leaves the -// depositor with no credit on the depot and no DEPOSIT_REVERT to release the escrow. -// The balance map is keyed by the code, so the credit itself must still not persist. -BOOST_FIXTURE_TEST_CASE(dispatch_refunds_uncanonical_deposit_token_code, sysio_dispatch_tester) { try { +// A second `deliver` from the SAME operator for the same outpost+epoch must REVERT, not land as a +// recorded no-op: a reverted transaction is never included in a block and bills no CPU/NET, whereas +// the previous soft print-and-return shape charged the operator and consumed block space for zero +// state change. Matching deliveries from DISTINCT operators are not duplicates -- they are the +// consensus tally itself (covered by the dispute/consensus suites). +BOOST_FIXTURE_TEST_CASE(deliver_duplicate_from_same_operator_reverts, sysio_dispatch_tester) { try { bootstrap_for_dispatch(); const auto eth_code = fc::slug_name{"ETH"}.value; - // Any value below the leading symbol's floor decodes to "" and packs back to - // 0, so it is not a code and has no spelling. - constexpr uint64_t uncanonical_token = 7; - BOOST_REQUIRE(!fc::slug_name{uncanonical_token}.is_canonical()); - - const auto before = get_operator(UWRIT_OP); - const size_t balances_before = before.is_null() ? 0 : before["balances"].get_array().size(); - - auto payload = encode_operator_action( - sysio::opp::attestations::OperatorAction::ACTION_TYPE_DEPOSIT_REQUEST, - sysio::opp::types::CHAIN_KIND_EVM, - uwrit_op_eth_pubkey, - /*chain_code_v=*/ eth_code, - /*token_code_v=*/ uncanonical_token, - 1'000'000); - auto envelope = encode_envelope_with_one_attestation( current_epoch(), - sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION, - payload); + sysio::opp::types::ATTESTATION_TYPE_STAKE, + std::string{}); - // The delivery SUCCEEDS -- the rejection happens inside dispatch, never as a revert - // of the envelope (a throw here would halt evalcons and stall consensus). BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/eth_code, envelope)); - - // Nothing was persisted, so no stored row can later render as the wrong code... - const auto after = get_operator(UWRIT_OP); - const size_t balances_after = after.is_null() ? 0 : after["balances"].get_array().size(); - BOOST_CHECK_EQUAL(balances_before, balances_after); - - // ...and the escrow is released: observe the OUTBOUND revert, not merely the - // absent credit. Without it the depositor's funds sit in outpost custody forever. - BOOST_REQUIRE(!find_queued_attestation( - "ATTESTATION_TYPE_DEPOSIT_REVERT", - sysio::opp::types::ATTESTATION_TYPE_DEPOSIT_REVERT).is_null()); + // Cross a block boundary so the re-submission is a distinct transaction — + // an identical push in the same block is rejected as tx_duplicate before + // the contract runs, which would mask the guard under test. + produce_blocks(); + BOOST_REQUIRE_EQUAL( + wasm_assert_msg("operator already delivered for this outpost+epoch"), + deliver(/*chain_code=*/eth_code, envelope)); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(dispatch_routes_withdraw_request_to_opreg, sysio_dispatch_tester) { try { +// The inbound `deliver` boundary enforces the same 32 KiB protocol envelope cap the outbound +// `buildenv` packer obeys (and that the Ethereum/Solana outposts enforce on their side): a +// decodable current-epoch envelope one byte over the cap must revert before anything is hashed +// or stored. Without the contract-level cap, the generic chain ceilings (~512 KiB inline-action, +// 256 KiB KV value) would admit inbound envelopes WIRE's own packer could never emit. +BOOST_FIXTURE_TEST_CASE(deliver_oversized_envelope_reverts, sysio_dispatch_tester) { try { bootstrap_for_dispatch(); - constexpr int64_t INITIAL_DEPOSIT = 5'000'000; - constexpr int64_t WITHDRAW_AMOUNT = 2'000'000; const auto eth_code = fc::slug_name{"ETH"}.value; + auto oversized = encode_envelope_padded_to(current_epoch(), MAX_ENVELOPE_BYTES + 1); - // The depot dedups per-(batch_op, chain_code, epoch) — a second `deliver` - // from the same batch op in the same epoch reverts as a duplicate. To - // exercise both dispatch branches in one test, both attestations ride a - // single envelope. - auto deposit_payload = encode_operator_action( - sysio::opp::attestations::OperatorAction::ACTION_TYPE_DEPOSIT_REQUEST, - sysio::opp::types::CHAIN_KIND_EVM, - uwrit_op_eth_pubkey, - eth_code, eth_code, - INITIAL_DEPOSIT); - - auto wtdw_payload = encode_operator_action( - sysio::opp::attestations::OperatorAction::ACTION_TYPE_WITHDRAW_REQUEST, - sysio::opp::types::CHAIN_KIND_EVM, - uwrit_op_eth_pubkey, - eth_code, eth_code, - WITHDRAW_AMOUNT); - - BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/eth_code, - encode_envelope_with_attestations(current_epoch(), - sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION, - {deposit_payload, wtdw_payload}))); - - auto row = get_wtdw(/*request_id=*/1); - BOOST_REQUIRE(!row.is_null()); - BOOST_REQUIRE_EQUAL(UWRIT_OP.to_string(), row["account"].as_string()); - BOOST_REQUIRE_EQUAL(static_cast(WITHDRAW_AMOUNT), - row["amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(eth_code, row["chain_code"].as().value); - BOOST_REQUIRE_EQUAL(eth_code, row["token_code"].as().value); + BOOST_REQUIRE_EQUAL( + wasm_assert_msg("inbound envelope exceeds MAX_ENVELOPE_BYTES"), + deliver(/*chain_code=*/eth_code, oversized)); + // Reverted before the emplace: no envelope row landed. + BOOST_REQUIRE(get_envelope(1).is_null()); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(dispatch_silently_drops_out_of_scope_types, sysio_dispatch_tester) { try { +// Boundary companion: an envelope exactly AT the cap is accepted and stored (and, with this +// suite's single-operator group, immediately reaches consensus and dispatches). Guards against +// an off-by-one regression turning the cap check exclusive. +BOOST_FIXTURE_TEST_CASE(deliver_envelope_at_size_cap_succeeds, sysio_dispatch_tester) { try { bootstrap_for_dispatch(); const auto eth_code = fc::slug_name{"ETH"}.value; - auto envelope = encode_envelope_with_one_attestation( - current_epoch(), - sysio::opp::types::ATTESTATION_TYPE_STAKE, - std::string{}); - - BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/eth_code, envelope)); + auto boundary = encode_envelope_padded_to(current_epoch(), MAX_ENVELOPE_BYTES); - auto op = get_operator(UWRIT_OP); - BOOST_REQUIRE(!op.is_null()); - const auto& balances = op["balances"].get_array(); - BOOST_REQUIRE_EQUAL(0u, balances.size()); + BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/eth_code, boundary)); + auto row = get_envelope(1); + BOOST_REQUIRE(!row.is_null()); + BOOST_REQUIRE_EQUAL(BATCHOP.to_string(), row["batch_op_name"].as_string()); + BOOST_REQUIRE_EQUAL(current_epoch(), row["epoch_index"].as()); } FC_LOG_AND_RETHROW() } -// ───────────────────────────── WSA-005: inbound source-chain binding ───────────────────────────── -// -// A consensus envelope is delivered for exactly ONE proven source outpost (the `deliver` chain_code, -// validated against `sysio.chains`). Every value-bearing attestation it carries embeds its own chain -// identifier; that identifier MUST equal the proven outpost. These tests drive a payload chain that -// diverges from the proven outpost and assert the depot applies NO value-bearing effect — and never -// throws (a throw inside the evalcons dispatch chain stalls consensus chain-wide). - -// OPERATOR_ACTION: a DEPOSIT_REQUEST and a WITHDRAW_REQUEST proven-delivered from the ETH outpost but -// whose payloads claim a different active chain (SOLANA) are both dropped — no operator collateral is -// credited and no withdraw is queued. The matched-chain control is `dispatch_routes_deposit_to_opreg` -// / `dispatch_routes_withdraw_request_to_opreg` above, which DO credit/queue. -BOOST_FIXTURE_TEST_CASE(operator_action_mismatched_source_chain_is_dropped, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); // ETH outpost + UWRIT_OP (EVM authex link) +// NodeOwnerRegistration: msgch decodes the attestation and inline-sends sysio.roa::newnameduser then +// nodeownreg. CLAIM_ACCOUNT pre-exists with a single-key active, so newnameduser no-ops and the +// claim's matching wire key drives nodeownreg's existing-account path to CONFIRMED (registers the +// owner and inline-records the depositor's ETH link in sysio.authex). Exercises the full dispatch: +// proto decode (account name + WireKey + ETH key) -> routing -> both roa actions -> recordlink. +BOOST_FIXTURE_TEST_CASE(dispatch_routes_node_owner_reg_to_roa, sysio_dispatch_tester) { try { + // Node-owner NFT deposits originate on the Ethereum outpost (code "ETHEREUM", matching the launch + // bootstrap config and msgch's NODE_OWNER_SRC_CHAIN). Bootstrap it as the scheduled source outpost + // so the delivery below reaches consensus and dispatches. + bootstrap_for_dispatch("ETHEREUM"); - const auto eth_code = fc::slug_name{"ETH"}.value; - const auto sol_code = fc::slug_name{"SOLANA"}.value; - constexpr int64_t DEPOSIT_AMOUNT = 1'000'000; - constexpr int64_t WITHDRAW_AMOUNT = 400'000; + const auto eth_code = fc::slug_name{"ETHEREUM"}.value; + // The claim must carry CLAIM_ACCOUNT's own active key so nodeownreg's active_key_matches passes. + auto wire_key = k1_pubkey_bytes(get_public_key(CLAIM_ACCOUNT, "active")); + // BAR supplies the depositor's ETH key as an uncompressed 65-byte point. + auto eth_pub = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em).get_public_key(); + auto eth_bytes = em_uncompressed_pubkey_bytes(eth_pub); + auto eth_address = fc::crypto::ethereum::address_to_bytes(eth_pub); - // SOLANA is a real, active outpost, so the ONLY thing wrong with the payloads below is that they - // were proven-delivered from ETH rather than SOLANA — the exact WSA-005 forgery. - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM)("code", "SOLANA") - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); + // Seed a pre-link reward at the key-derived address, then deliberately put a conflicting address + // in the redundant actor field. Dispatch must derive from actor_pub_key and sweep the real row. + const std::vector native_address(eth_address.begin(), eth_address.end()); + BOOST_REQUIRE_EQUAL(success(), push(DCLAIM_ACCOUNT, dclaim_abi, MSGCH_ACCOUNT, "onreward"_n, mvo() + ("chain_code", eth_code)("staker_wire_account", std::string{}) + ("reward_chain", ChainKind::CHAIN_KIND_EVM)("staker_native_addr", native_address) + ("reward_amount", uint64_t{4321})("reward_epoch_index", uint32_t{7}) + ("external_epoch_ref", uint64_t{100})("share_bps", uint32_t{10000}))); + BOOST_REQUIRE(!get_dclaim_row("unmapped"_n, "unmapped_token", 1).is_null()); + std::fill(eth_address.begin(), eth_address.end(), uint8_t{0xA5}); - auto deposit_sol = encode_operator_action( - sysio::opp::attestations::OperatorAction::ACTION_TYPE_DEPOSIT_REQUEST, - sysio::opp::types::CHAIN_KIND_EVM, uwrit_op_eth_pubkey, - /*chain_code_v=*/ sol_code, /*token_code_v=*/ sol_code, DEPOSIT_AMOUNT); - auto withdraw_sol = encode_operator_action( - sysio::opp::attestations::OperatorAction::ACTION_TYPE_WITHDRAW_REQUEST, - sysio::opp::types::CHAIN_KIND_EVM, uwrit_op_eth_pubkey, - /*chain_code_v=*/ sol_code, /*token_code_v=*/ sol_code, WITHDRAW_AMOUNT); - - // Proven outpost = ETH; both payloads claim SOLANA. deliver() must SUCCEED (the binding drops the - // attestations inside dispatch — it must not abort the envelope). - BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/eth_code, - encode_envelope_with_attestations(current_epoch(), - sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION, {deposit_sol, withdraw_sol}))); - - auto op = get_operator(UWRIT_OP); - BOOST_REQUIRE(!op.is_null()); - BOOST_REQUIRE_EQUAL(0u, op["balances"].get_array().size()); // no collateral on any chain - BOOST_REQUIRE(get_wtdw(/*request_id=*/1).is_null()); // no withdraw queued -} FC_LOG_AND_RETHROW() } - -// SWAP_REQUEST: a swap whose `source_chain_code` does not match the proven delivering outpost must be -// refunded (SwapRevert) and create NO uwreq — settling it would draw against the named source reserve -// while the user's deposit sits on a different chain. Same SwapRequest delivered from its real source -// outpost is the control: it creates the uwreq. -BOOST_FIXTURE_TEST_CASE(swap_request_mismatched_source_chain_is_refunded, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); // ETH source outpost - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM)("code", "SOLANA") - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - setup_wire_token_and_reserves(); // ACTIVE ETH/ETH/PRIMARY + SOLANA/SOL/PRIMARY reserves - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 1'000'000'000)); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "SOLANA", "SOL", 1'000'000'000)); - - const auto eth = fc::slug_name{"ETH"}.value; - const auto sol_chain = fc::slug_name{"SOLANA"}.value; - const auto sol_token = fc::slug_name{"SOL"}.value; - const auto primary = fc::slug_name{"PRIMARY"}.value; - - // A fully valid ETH->SOLANA swap (source leg = ETH). - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - sol_chain, sol_token, primary, /*target*/ 100, - 5000, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); - - // Mismatch: proven delivering outpost = SOLANA, but source_chain_code = ETH -> refund, no uwreq. - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(/*att_id*/ 9001, /*proven=*/ sol_chain, sr)); - BOOST_REQUIRE(get_uwreq(9001).is_null()); - - // Control: same SwapRequest proven-delivered from its real source outpost (ETH) -> uwreq created. - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(/*att_id*/ 9002, /*proven=*/ eth, sr)); - BOOST_REQUIRE(!get_uwreq(9002).is_null()); -} FC_LOG_AND_RETHROW() } - -// A SwapRequest's token/reserve codes are payload-controlled and reach `slug_name` -// through the non-validating raw constructor. Nothing downstream gates them: the -// zero-quote guard fails closed only when `required_reserves_active` holds, so a code -// naming NO reserve leaves the quote at zero, skips that guard, and reaches -// `reqs.emplace`. Rendering is total, so the row is not unreadable — it is WRONG: its -// rendered code either fails validation on the way back, or re-parses as a DIFFERENT -// reserve than the one it was created from. The request must be -// REFUNDED rather than dropped: the user's deposit is escrowed on the source outpost. -BOOST_FIXTURE_TEST_CASE(swap_request_uncanonical_code_is_refunded, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); // ETH source outpost - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM)("code", "SOLANA") - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 1'000'000'000)); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "SOLANA", "SOL", 1'000'000'000)); - - const auto eth = fc::slug_name{"ETH"}.value; - const auto sol_chain = fc::slug_name{"SOLANA"}.value; - const auto sol_token = fc::slug_name{"SOL"}.value; - const auto primary = fc::slug_name{"PRIMARY"}.value; - - // Below the leading symbol's floor: decodes to "" and packs back to 0, so it is not a - // code and does not round-trip. The TARGET CHAIN stays valid — the point is that a - // registered chain does not imply a canonical token/reserve code. - constexpr uint64_t uncanonical = 7; - BOOST_REQUIRE(!fc::slug_name{uncanonical}.is_canonical()); - - const auto bad_target_token = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - sol_chain, uncanonical, primary, /*target*/ 100, - 5000, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); - - // Delivery SUCCEEDS (refund path, never a throw) and NO row is persisted. - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(/*att_id*/ 9101, /*proven=*/ eth, bad_target_token)); - BOOST_REQUIRE(get_uwreq(9101).is_null()); - - // Same for an unspellable RESERVE code on the source leg. - const auto bad_source_reserve = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, uncanonical, /*src_amount*/ 100, - sol_chain, sol_token, primary, /*target*/ 100, - 5000, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(/*att_id*/ 9102, /*proven=*/ eth, bad_source_reserve)); - BOOST_REQUIRE(get_uwreq(9102).is_null()); - - // Control: every code spellable -> the uwreq is created, so the guard rejects the - // unspellable code rather than the shape of the request. - const auto good = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - sol_chain, sol_token, primary, /*target*/ 100, - 5000, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(/*att_id*/ 9103, /*proven=*/ eth, good)); - BOOST_REQUIRE(!get_uwreq(9103).is_null()); -} FC_LOG_AND_RETHROW() } - -// An exact `(chain, token, reserve)` self-route has only one outpost leg and -// can never produce the two distinct commitments an ordinary external-token -// swap requires. Depot ingestion must therefore refund it without creating a -// UWREQ. The cross-chain control proves same-asset-style routing remains -// admissible whenever the endpoint triple is not identical. -BOOST_FIXTURE_TEST_CASE(swap_request_identical_reserve_identity_is_refunded, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM)("code", "SOLANA") - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - setup_wire_token_and_reserves(); - - const auto eth = fc::slug_name{"ETH"}.value; - const auto sol_chain = fc::slug_name{"SOLANA"}.value; - const auto sol_token = fc::slug_name{"SOL"}.value; - const auto primary = fc::slug_name{"PRIMARY"}.value; - - const auto self_route = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - eth, eth, primary, /*target*/ 100, - 5000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(/*att_id*/ 9051, /*proven=*/ eth, self_route)); - BOOST_REQUIRE(get_uwreq(9051).is_null()); - - const auto cross_chain = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - sol_chain, sol_token, primary, /*target*/ 100, - 5000, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(/*att_id*/ 9052, /*proven=*/ eth, cross_chain)); - BOOST_REQUIRE(!get_uwreq(9052).is_null()); -} FC_LOG_AND_RETHROW() } - -// UNDERWRITE_INTENT_COMMIT: the same signed dest-leg (SOLANA) commit, delivered through the FULL -// deliver->evalcons->apply_consensus->dispatch path, is recorded only when its proven outpost matches -// `uic.chain_code`. Delivered from ETH it is dropped (no commit); delivered from SOLANA it lands. -BOOST_FIXTURE_TEST_CASE(underwrite_commit_mismatched_source_chain_is_dropped, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM)("code", "SOLANA") - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 1'000'000'000)); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "SOLANA", "SOL", 1'000'000'000)); - - const auto eth = fc::slug_name{"ETH"}.value; - const auto sol_chain = fc::slug_name{"SOLANA"}.value; - const auto sol_token = fc::slug_name{"SOL"}.value; - const auto primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 9100; - - // Create the uwreq (ETH source, SOLANA dest) via the proven ETH outpost. - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, 100, sol_chain, sol_token, primary, 100, - 5000, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, /*proven=*/ eth, sr)); - BOOST_REQUIRE(!get_uwreq(ATT_ID).is_null()); - - // One signed dest-leg (SOLANA) UIC, wrapped in an envelope. The outpost it is proven-delivered - // from is the ONLY thing that varies between the two deliveries below. - const auto uic_sol = create_signed_uic(UWRIT_OP, ATT_ID, - /*chain_code*/ sol_chain, sol_token, primary); - const auto uic_env = encode_envelope_with_one_attestation( - current_epoch(), sysio::opp::types::ATTESTATION_TYPE_UNDERWRITE_INTENT_COMMIT, - std::string(uic_sol.begin(), uic_sol.end())); - - auto dest_committed = [&]() { - auto req = get_uwreq(ATT_ID); - for (const auto& c : req["commits_by"].get_array()) - if (c["underwriter"].as_string() == UWRIT_OP.to_string() && - c["dest_received_at_ms"].as_uint64() != 0) - return true; - return false; - }; - - // Mismatch: a SOLANA-leg commit proven-delivered from the ETH outpost is dropped — no commit. - BOOST_REQUIRE_EQUAL(success(), deliver(/*proven=*/ eth, uic_env)); - BOOST_REQUIRE(!dest_committed()); - - // Control: the SAME commit proven-delivered from the SOLANA outpost is recorded. - BOOST_REQUIRE_EQUAL(success(), deliver(/*proven=*/ sol_chain, uic_env)); - BOOST_REQUIRE(dest_committed()); -} FC_LOG_AND_RETHROW() } - -// SEC-13/WSA-027: two chains of the SAME VM family (ETH + a second EVM chain) -// must be disambiguated by EXACT chain_code at the depot — never collapsed onto -// one ChainKind. A commit for the SECOND EVM chain's leg, proven-delivered from -// the FIRST EVM chain's outpost, is dropped (its chain_code != the proven -// outpost); delivered from the second EVM outpost it lands. This is the -// same-family analogue of underwrite_commit_mismatched_source_chain_is_dropped -// and pins the two-same-kind-chain invariant WSA-027 is about. -BOOST_FIXTURE_TEST_CASE(underwrite_commit_two_evm_chains_route_per_chain, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); // ETH (EVM) source outpost - // A SECOND active EVM chain — same VM family, distinct chain_code. - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_EVM)("code", "POLYGON") - ("external_chain_id", 137)("name", std::string("polygon-test"))("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - // SOLANA is registered only because the shared reserve-setup helper seeds a - // SOLANA/SOL reserve; it is otherwise unused by this two-EVM scenario. - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM)("code", "SOLANA") - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), regreserve_active("POLYGON", "POL", "PRIMARY")); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 1'000'000'000)); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "POLYGON", "POL", 1'000'000'000)); - - const auto eth = fc::slug_name{"ETH"}.value; - const auto polygon = fc::slug_name{"POLYGON"}.value; - const auto pol_tok = fc::slug_name{"POL"}.value; - const auto primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 9200; - - // ETH -> POLYGON swap (both EVM). Proven source outpost = ETH. - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, 100, polygon, pol_tok, primary, 100, - 5000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0c')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, /*proven=*/ eth, sr)); - BOOST_REQUIRE(!get_uwreq(ATT_ID).is_null()); - - // One signed dest-leg (POLYGON) UIC; the proven delivering outpost is the - // only thing that varies between the two deliveries below. - const auto uic_pol = create_signed_uic(UWRIT_OP, ATT_ID, /*chain_code*/ polygon, pol_tok, primary); - const auto uic_env = encode_envelope_with_one_attestation( - current_epoch(), sysio::opp::types::ATTESTATION_TYPE_UNDERWRITE_INTENT_COMMIT, - std::string(uic_pol.begin(), uic_pol.end())); - - auto dest_committed = [&]() { - auto req = get_uwreq(ATT_ID); - for (const auto& c : req["commits_by"].get_array()) - if (c["underwriter"].as_string() == UWRIT_OP.to_string() && - c["dest_received_at_ms"].as_uint64() != 0) - return true; - return false; - }; - - // Mismatch: a POLYGON-leg commit proven-delivered from the ETH outpost (same - // VM family, different chain_code) is dropped — exact chain_code != proven ETH. - BOOST_REQUIRE_EQUAL(success(), deliver(/*proven=*/ eth, uic_env)); - BOOST_REQUIRE(!dest_committed()); - - // Control: the SAME commit proven-delivered from the POLYGON outpost lands. - BOOST_REQUIRE_EQUAL(success(), deliver(/*proven=*/ polygon, uic_env)); - BOOST_REQUIRE(dest_committed()); -} FC_LOG_AND_RETHROW() } - -// Malformed UIC bytes and decode-clean UICs whose `uw_account.name` is not a -// canonical constructible account name must be logged and dropped without -// throwing. A valid UIC later in the same envelope must still be dispatched. -BOOST_FIXTURE_TEST_CASE(underwrite_commit_early_rejections_log_and_continue, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - constexpr uint64_t ATT_ID = 9200; - setup_eth_to_sol_uwreq(ATT_ID); - - const auto sol_chain = fc::slug_name{"SOLANA"}.value; - const auto sol_token = fc::slug_name{"SOL"}.value; - const auto primary = fc::slug_name{"PRIMARY"}.value; - - const auto uic_upper = create_uic_raw_name("BADNAME", ATT_ID, sol_chain, sol_token, primary); - const auto uic_hyphen = create_uic_raw_name("bad-name", ATT_ID, sol_chain, sol_token, primary); - const auto uic_long = create_uic_raw_name("abcdefghijklmn", ATT_ID, sol_chain, sol_token, primary); - const auto uic_alias = create_uic_raw_name("underwriter.", ATT_ID, sol_chain, sol_token, primary); - const auto valid_uic = create_signed_uic( - UWRIT_OP, ATT_ID, sol_chain, sol_token, primary); - const auto env = encode_envelope_with_attestations( - current_epoch(), sysio::opp::types::ATTESTATION_TYPE_UNDERWRITE_INTENT_COMMIT, - {std::string(1, '\x0a'), - std::string(uic_upper.begin(), uic_upper.end()), - std::string(uic_hyphen.begin(), uic_hyphen.end()), - std::string(uic_long.begin(), uic_long.end()), - std::string(uic_alias.begin(), uic_alias.end()), - std::string(valid_uic.begin(), valid_uic.end())}); - - const auto trace = deliver_trace(/*proven=*/ sol_chain, env); - BOOST_REQUIRE(trace != nullptr); - BOOST_REQUIRE(!trace->except); - const auto console = all_console(trace); - BOOST_CHECK_NE(std::string::npos, - console.find("UIC_DISPATCH_REJECTED: chain_code=")); - BOOST_CHECK_NE(std::string::npos, console.find("reason=malformed_uic")); - BOOST_CHECK_NE(std::string::npos, console.find("reason=invalid_wire_account")); - - const auto commits = get_uwreq(ATT_ID)["commits_by"].get_array(); - BOOST_REQUIRE_EQUAL(1u, commits.size()); - BOOST_CHECK_EQUAL(UWRIT_OP.to_string(), commits.front()["underwriter"].as_string()); - BOOST_CHECK(!commits.front()["dest_uic_bytes"].as_string().empty()); -} FC_LOG_AND_RETHROW() } - -// A UIC whose (chain_code, token_code, reserve_code) triple matches neither the source nor the -// destination leg of the pending request must leave commits_by untouched — no inert commit_entry, -// no mutation. rcrdcommit is msgch-authorized; call it directly. -BOOST_FIXTURE_TEST_CASE(rcrdcommit_unmatched_leg_leaves_commits_empty, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - constexpr uint64_t ATT_ID = 9300; - setup_eth_to_sol_uwreq(ATT_ID); // src=(ETH,ETH,PRIMARY) dst=(SOLANA,SOL,PRIMARY) - - const auto sol_chain = fc::slug_name{"SOLANA"}.value; - const auto sol_token = fc::slug_name{"SOL"}.value; - const auto primary = fc::slug_name{"PRIMARY"}.value; - const auto uic = create_signed_uic(UWRIT_OP, ATT_ID, sol_chain, sol_token, primary); - - // (SOLANA, ETH, PRIMARY): source-chain differs from the dest leg, token differs from the source - // leg — matches neither. - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, sol_chain, "SOLANA", "ETH", "PRIMARY", uic)); - BOOST_REQUIRE_EQUAL(0u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); -} FC_LOG_AND_RETHROW() } - -// Repeating unmatched commits with distinct underwriter names must not grow the row — the -// pre-mutation leg guard is what closes the storage/scan-cost bloat vector. -BOOST_FIXTURE_TEST_CASE(rcrdcommit_unmatched_distinct_underwriters_do_not_grow_row, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - constexpr uint64_t ATT_ID = 9301; - setup_eth_to_sol_uwreq(ATT_ID); - - const auto sol_chain = fc::slug_name{"SOLANA"}.value; - const auto sol_token = fc::slug_name{"SOL"}.value; - const auto primary = fc::slug_name{"PRIMARY"}.value; - const auto uic = create_signed_uic(UWRIT_OP, ATT_ID, sol_chain, sol_token, primary); - - for (name uw : {"uwtwo"_n, "uwthree"_n, "uwfour"_n}) { - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, uw, sol_chain, "SOLANA", "ETH", "PRIMARY", uic)); - } - BOOST_REQUIRE_EQUAL(0u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); -} FC_LOG_AND_RETHROW() } - -// Control for the leg-branch refactor: a UIC that DOES match the source leg still records a -// commit_entry with the source arrival slot armed. -BOOST_FIXTURE_TEST_CASE(rcrdcommit_matched_source_leg_is_recorded, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - constexpr uint64_t ATT_ID = 9302; - setup_eth_to_sol_uwreq(ATT_ID); - - const auto eth = fc::slug_name{"ETH"}.value; - const auto primary = fc::slug_name{"PRIMARY"}.value; - const auto uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - - // (ETH, ETH, PRIMARY) is the source leg. - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", uic)); - - const auto commits = get_uwreq(ATT_ID)["commits_by"].get_array(); - BOOST_REQUIRE_EQUAL(1u, commits.size()); - BOOST_REQUIRE_EQUAL(UWRIT_OP.to_string(), commits[0]["underwriter"].as_string()); - BOOST_REQUIRE(commits[0]["source_received_at_ms"].as_uint64() != 0); -} FC_LOG_AND_RETHROW() } - -// Even on a matched leg, a UIC whose account name is a valid `name` but NOT a registered ACTIVE -// underwriter must not create a commit_entry: only active underwriters can win, so gating entry -// creation on activation bounds commits_by to the legitimate racer set and blocks the matched-leg -// name-varying bloat vector (varying valid account names would otherwise append one entry per name). -BOOST_FIXTURE_TEST_CASE(rcrdcommit_matched_leg_non_underwriter_names_do_not_grow_row, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - constexpr uint64_t ATT_ID = 9303; - setup_eth_to_sol_uwreq(ATT_ID); // src=(ETH,ETH,PRIMARY) - - const auto eth = fc::slug_name{"ETH"}.value; - const auto primary = fc::slug_name{"PRIMARY"}.value; - - // These are real accounts with valid active keys, deliberately omitted - // from the operator roster. That isolates the ACTIVE-underwriter gate from - // account-nonexistence and malformed-key shortcuts. - create_accounts({"alice"_n, "bob"_n, "carol"_n}); - produce_blocks(); - - // (ETH, ETH, PRIMARY) IS the source leg, so these clear the leg-binding guard; each name is a - // real keyed account but none is a registered underwriter, so all are dropped before mutation. - for (name uw : {"alice"_n, "bob"_n, "carol"_n}) { - const auto uic = create_signed_uic(uw, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, uw, eth, "ETH", "ETH", "PRIMARY", uic)); - } - BOOST_REQUIRE_EQUAL(0u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); -} FC_LOG_AND_RETHROW() } - -// A second `deliver` from the SAME operator for the same outpost+epoch must REVERT, not land as a -// recorded no-op: a reverted transaction is never included in a block and bills no CPU/NET, whereas -// the previous soft print-and-return shape charged the operator and consumed block space for zero -// state change. Matching deliveries from DISTINCT operators are not duplicates -- they are the -// consensus tally itself (covered by the dispute/consensus suites). -BOOST_FIXTURE_TEST_CASE(deliver_duplicate_from_same_operator_reverts, sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - - const auto eth_code = fc::slug_name{"ETH"}.value; - auto envelope = encode_envelope_with_one_attestation( - current_epoch(), - sysio::opp::types::ATTESTATION_TYPE_STAKE, - std::string{}); - - BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/eth_code, envelope)); - // Cross a block boundary so the re-submission is a distinct transaction — - // an identical push in the same block is rejected as tx_duplicate before - // the contract runs, which would mask the guard under test. - produce_blocks(); - BOOST_REQUIRE_EQUAL( - wasm_assert_msg("operator already delivered for this outpost+epoch"), - deliver(/*chain_code=*/eth_code, envelope)); -} FC_LOG_AND_RETHROW() } - -// The inbound `deliver` boundary enforces the same 32 KiB protocol envelope cap the outbound -// `buildenv` packer obeys (and that the Ethereum/Solana outposts enforce on their side): a -// decodable current-epoch envelope one byte over the cap must revert before anything is hashed -// or stored. Without the contract-level cap, the generic chain ceilings (~512 KiB inline-action, -// 256 KiB KV value) would admit inbound envelopes WIRE's own packer could never emit. -BOOST_FIXTURE_TEST_CASE(deliver_oversized_envelope_reverts, sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - - const auto eth_code = fc::slug_name{"ETH"}.value; - auto oversized = encode_envelope_padded_to(current_epoch(), MAX_ENVELOPE_BYTES + 1); - - BOOST_REQUIRE_EQUAL( - wasm_assert_msg("inbound envelope exceeds MAX_ENVELOPE_BYTES"), - deliver(/*chain_code=*/eth_code, oversized)); - // Reverted before the emplace: no envelope row landed. - BOOST_REQUIRE(get_envelope(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// Boundary companion: an envelope exactly AT the cap is accepted and stored (and, with this -// suite's single-operator group, immediately reaches consensus and dispatches). Guards against -// an off-by-one regression turning the cap check exclusive. -BOOST_FIXTURE_TEST_CASE(deliver_envelope_at_size_cap_succeeds, sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - - const auto eth_code = fc::slug_name{"ETH"}.value; - auto boundary = encode_envelope_padded_to(current_epoch(), MAX_ENVELOPE_BYTES); - - BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/eth_code, boundary)); - auto row = get_envelope(1); - BOOST_REQUIRE(!row.is_null()); - BOOST_REQUIRE_EQUAL(BATCHOP.to_string(), row["batch_op_name"].as_string()); - BOOST_REQUIRE_EQUAL(current_epoch(), row["epoch_index"].as()); -} FC_LOG_AND_RETHROW() } - -// NodeOwnerRegistration: msgch decodes the attestation and inline-sends sysio.roa::newnameduser then -// nodeownreg. CLAIM_ACCOUNT pre-exists with a single-key active, so newnameduser no-ops and the -// claim's matching wire key drives nodeownreg's existing-account path to CONFIRMED (registers the -// owner and inline-records the depositor's ETH link in sysio.authex). Exercises the full dispatch: -// proto decode (account name + WireKey + ETH key) -> routing -> both roa actions -> recordlink. -BOOST_FIXTURE_TEST_CASE(dispatch_routes_node_owner_reg_to_roa, sysio_dispatch_tester) { try { - // Node-owner NFT deposits originate on the Ethereum outpost (code "ETHEREUM", matching the launch - // bootstrap config and msgch's NODE_OWNER_SRC_CHAIN). Bootstrap it as the scheduled source outpost - // so the delivery below reaches consensus and dispatches. - bootstrap_for_dispatch("ETHEREUM"); - - const auto eth_code = fc::slug_name{"ETHEREUM"}.value; - // The claim must carry CLAIM_ACCOUNT's own active key so nodeownreg's active_key_matches passes. - auto wire_key = k1_pubkey_bytes(get_public_key(CLAIM_ACCOUNT, "active")); - // BAR supplies the depositor's ETH key as an uncompressed 65-byte point. - auto eth_pub = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em).get_public_key(); - auto eth_bytes = em_uncompressed_pubkey_bytes(eth_pub); - auto eth_address = fc::crypto::ethereum::address_to_bytes(eth_pub); - - // Seed a pre-link reward at the key-derived address, then deliberately put a conflicting address - // in the redundant actor field. Dispatch must derive from actor_pub_key and sweep the real row. - const std::vector native_address(eth_address.begin(), eth_address.end()); - BOOST_REQUIRE_EQUAL(success(), push(DCLAIM_ACCOUNT, dclaim_abi, MSGCH_ACCOUNT, "onreward"_n, mvo() - ("chain_code", eth_code)("staker_wire_account", std::string{}) - ("reward_chain", ChainKind::CHAIN_KIND_EVM)("staker_native_addr", native_address) - ("reward_amount", uint64_t{4321})("reward_epoch_index", uint32_t{7}) - ("external_epoch_ref", uint64_t{100})("share_bps", uint32_t{10000}))); - BOOST_REQUIRE(!get_dclaim_row("unmapped"_n, "unmapped_token", 1).is_null()); - std::fill(eth_address.begin(), eth_address.end(), uint8_t{0xA5}); - - auto payload = encode_node_owner_registration( - CLAIM_ACCOUNT.to_string(), /*tier=*/2, - sysio::opp::types::WIRE_KEY_TYPE_K1, wire_key, eth_bytes, eth_address); - auto envelope = encode_envelope_with_one_attestation( - current_epoch(), sysio::opp::types::ATTESTATION_TYPE_NODE_OWNER_REG, payload); + auto payload = encode_node_owner_registration( + CLAIM_ACCOUNT.to_string(), /*tier=*/2, + sysio::opp::types::WIRE_KEY_TYPE_K1, wire_key, eth_bytes, eth_address); + auto envelope = encode_envelope_with_one_attestation( + current_epoch(), sysio::opp::types::ATTESTATION_TYPE_NODE_OWNER_REG, payload); BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/eth_code, envelope)); @@ -2351,7 +1068,7 @@ BOOST_FIXTURE_TEST_CASE(dispatch_node_owner_tier_cap_preserves_epoch_progress, s namespace owners = sysio_system::test_support::nodeowners; namespace audit = sysio_system::test_support::nodeownerreg; bootstrap_for_dispatch("ETHEREUM"); - setup_wire_token_and_reserves(); + setup_wire_token(); enable_epoch_advancement(); owners::fill_tier1(*this, roa_abi, owners::tier1_cap - 1); @@ -2596,4421 +1313,252 @@ class node_owner_claim_tester : public sysio_dispatch_tester { /// Every permission of `account`, for before/after comparison. permission_set permissions_of(name account) const { permission_set out; - const auto& db = control->db(); - const auto& idx = db.get_index(); - for (auto it = idx.lower_bound(boost::make_tuple(account)); it != idx.end() && it->owner == account; ++it) { - const name parent = it->parent._id == 0 ? name{} : db.get(it->parent).name; - out.emplace(it->name, std::make_pair(parent, it->auth.to_authority())); - } - return out; - } -}; - -// A tier-2/3 claim can take the name auth.msg and deploy code there, so an auth.msg::onlinkauth notification is -// attacker-controlled. It must reach sysio as an ordinary notification and change nothing: not sysio's owner, active, -// auth.ext or any other permission, and not a user's. -BOOST_FIXTURE_TEST_CASE(claimed_auth_msg_notification_changes_no_permission, node_owner_claim_tester) { try { - bootstrap_for_dispatch(NODE_OWNER_SOURCE_CHAIN); - create_account(PAYER_ACCOUNT); - deploy_system_contract(); - - claim_node_owner(AUTH_MSG_ACCOUNT, get_public_key(AUTH_MSG_ACCOUNT, "active")); - const auto audit = get_nodeownerreg(AUTH_MSG_ACCOUNT); - BOOST_REQUIRE(!audit.is_null()); - BOOST_REQUIRE_EQUAL(audit["status"].as(), sysio_system::test_support::nodeownerreg::status_confirmed); - issue_own_policy(AUTH_MSG_ACCOUNT); - set_code(AUTH_MSG_ACCOUNT, notify_system_account_wasm()); - produce_block(); - - const auto replacement_key = get_public_key(REPLACEMENT_KEY_NAME, "active"); - const auto sysio_before = permissions_of(config::system_account_name); - const auto user_before = permissions_of(CLAIM_ACCOUNT); - for (const auto target : {config::system_account_name, CLAIM_ACCOUNT}) { - for (const auto permission : {config::owner_name, config::active_name, AUTH_EXT_PERMISSION, OTHER_PERMISSION}) { - BOOST_CHECK_MESSAGE(notify_onlinkauth(target, permission, replacement_key), - "onlinkauth for " << target.to_string() << "@" << permission.to_string() - << " did not commit as a plain notification"); - } - } - BOOST_CHECK_MESSAGE(permissions_of(config::system_account_name) == sysio_before, "sysio permissions changed"); - BOOST_CHECK_MESSAGE(permissions_of(CLAIM_ACCOUNT) == user_before, "claimacct permissions changed"); -} FC_LOG_AND_RETHROW() } - -// No node owner may claim a name under the reserved sysio. prefix: the depot's claim is rejected as NAME_INVALID and -// no account is created. -BOOST_FIXTURE_TEST_CASE(node_owner_claim_rejects_reserved_system_name, node_owner_claim_tester) { try { - bootstrap_for_dispatch(NODE_OWNER_SOURCE_CHAIN); - deploy_system_contract(); - - claim_node_owner(RESERVED_SYSTEM_NAME, get_public_key(RESERVED_SYSTEM_NAME, "active")); - - const auto audit = get_nodeownerreg(RESERVED_SYSTEM_NAME); - BOOST_REQUIRE(!audit.is_null()); - BOOST_CHECK_EQUAL(audit["status"].as(), sysio_system::test_support::nodeownerreg::status_rejected); - BOOST_CHECK_EQUAL(audit["reason"].as(), sysio_system::test_support::nodeownerreg::reason_name_invalid); - BOOST_CHECK(get_nodeowner(RESERVED_SYSTEM_NAME).is_null()); - BOOST_CHECK(!account_exists(RESERVED_SYSTEM_NAME)); -} FC_LOG_AND_RETHROW() } - -/// Regression: a non-advancing advance() must not permanently strand the epoch. -/// -/// When every active outpost has reached consensus and the wall clock has passed, chkcons triggers -/// sysio.epoch::advance. advance can legally return WITHOUT bumping the epoch when emissions are not -/// ready -- its emissions gate records a block and returns gracefully, it does not throw. The earlier -/// chkcons cleared per-outpost consensus_reached BEFORE calling advance, so that graceful return -/// committed the cleared state and nothing re-armed it (apply_consensus does not re-fire for an -/// already-complete delivery set) -- permanently stalling advancement even once emissions later became -/// ready. -/// -/// This fixture never deploys sysio.system, so the emissions gate always blocks (CONFIG_MISSING): -/// exactly the non-advancing path. After consensus, each chkcons must re-attempt advance, which the -/// gate records as blocklog.retry_count. Pre-fix, the second chkcons bailed at the consensus gate -/// (retry_count would stay 1) and the epoch was stuck forever. -BOOST_FIXTURE_TEST_CASE(chkcons_survives_non_advancing_advance, sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - const auto eth_code = fc::slug_name{"ETH"}.value; - - // This fixture deploys no sysio.system, so even the genesis advance in bootstrap gate-blocked; the - // chain sits at `epoch0`. advance() always targets the next epoch, whose blocklog row counts gate - // re-attempts. - const uint32_t epoch0 = current_epoch(); - const uint32_t target = epoch0 + 1; - - // operators_per_epoch == 1, so a single delivery is Option-A unanimous consensus; apply_consensus - // records the outpcons row for ETH at epoch0. - auto operator_payload = encode_operator_action( - sysio::opp::attestations::OperatorAction::ACTION_TYPE_DEPOSIT_REQUEST, - sysio::opp::types::CHAIN_KIND_EVM, - uwrit_op_eth_pubkey, - /*chain_code_v=*/ eth_code, - /*token_code_v=*/ eth_code, - /*amount=*/ 1'000'000); - auto envelope = encode_envelope_with_one_attestation( - epoch0, - sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION, - operator_payload); - BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/eth_code, envelope)); - produce_blocks(); // land the deliver in its own block (this fixture's push does not) - - auto retry_count = [&]() -> uint32_t { - auto bl = get_blocklog(target); - return bl.is_null() ? 0u : bl["retry_count"].as(); - }; - const uint32_t rc0 = retry_count(); - - // Pass the wall clock (epoch_duration_sec == 60) so chkcons will fire advance, then refresh the head - // so the subsequent push is stamped against the post-skip time and does not expire. - produce_block(fc::seconds(120)); - produce_blocks(); - - // First chkcons fires advance, which gate-blocks on missing emissions and returns without advancing. - // The epoch is unchanged and the gate bumps retry_count -- confirming advance was actually attempted. - BOOST_REQUIRE_EQUAL(success(), chkcons()); - produce_blocks(); - BOOST_REQUIRE_EQUAL(current_epoch(), epoch0); - const uint32_t rc1 = retry_count(); - BOOST_REQUIRE_EQUAL(rc1, rc0 + 1); - - // REGRESSION: the per-outpost consensus signal must survive the non-advancing advance, so the second - // chkcons re-attempts advance (retry_count -> rc1 + 1). Pre-fix, chkcons cleared consensus_reached - // before calling advance, so this second call bailed at the consensus gate and never re-attempted -- - // retry_count would stay at rc1 and the epoch would be permanently stranded. - BOOST_REQUIRE_EQUAL(success(), chkcons()); - produce_blocks(); - BOOST_REQUIRE_EQUAL(current_epoch(), epoch0); - BOOST_REQUIRE_EQUAL(retry_count(), rc1 + 1); -} FC_LOG_AND_RETHROW() } - -// ═════════════════════════════════════════════════════════════════════════ -// msgch::bootstrap emissions guards. Missing emissions config is a bootstrap DEFECT, not an -// operational state: without these guards bootstrap's inline genesis advance would gate-block -// SILENTLY (the emissions gate never throws, by design) and the misconfiguration would surface -// only as "epoch stuck at 0". The three cases below cover both guards independently plus the -// fully-configured pass-through. advance()'s own soft block-and-retry behavior for the ECONOMIC -// gate reasons is covered above (chkcons_survives_non_advancing_advance) and in emissions_tests. -// ═════════════════════════════════════════════════════════════════════════ - -// Guard 1 — emitcfg missing. This fixture deploys no sysio.system at all (setemitcfg / initt5 -// never ran), which is exactly the never-configured deployment shape. -BOOST_FIXTURE_TEST_CASE(bootstrap_requires_emissions_config, sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - BOOST_REQUIRE_EQUAL( - wasm_assert_msg("msgch::bootstrap: emissions config missing -- sysio.system::setemitcfg must run before bootstrap"), - push(MSGCH_ACCOUNT, msgch_abi, MSGCH_ACCOUNT, "bootstrap"_n, mvo())); -} FC_LOG_AND_RETHROW() } - -// Guard 2 — emitcfg present, t5state missing (the half-configured deployment: setemitcfg ran, -// initt5 was forgotten). Proves the SECOND check is independently reachable and reads t5state, -// not emitcfg again: guard 1 passes here, so a wrong table/account lookup or a shadowed copy of -// the first check would surface as the WRONG diagnostic (or no throw at all). -BOOST_FIXTURE_TEST_CASE(bootstrap_requires_emissions_state, sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - deploy_system_with_emitcfg(); // setemitcfg only -- initt5 deliberately not run - BOOST_REQUIRE_EQUAL( - wasm_assert_msg("msgch::bootstrap: emissions state uninitialized -- sysio.system::initt5 must run before bootstrap"), - push(MSGCH_ACCOUNT, msgch_abi, MSGCH_ACCOUNT, "bootstrap"_n, mvo())); -} FC_LOG_AND_RETHROW() } - -// Both guards pass — the genesis 0 -> 1 advance actually happens. Guards that always threw (or a -// gate that never passes) would look identical to guards 1 and 2 above, so this is the case that -// pins them as guards rather than a permanent bootstrap block. -BOOST_FIXTURE_TEST_CASE(bootstrap_advances_genesis_epoch_when_emissions_configured, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - setup_wire_token_and_reserves(); // payepoch pays WIRE out of sysio's token balance - enable_epoch_advancement(); // setemitcfg + initt5 - - // bootstrap_for_dispatch's own advance ran while emissions were unconfigured, so it - // gate-blocked and left a blocklog row for the target epoch. - BOOST_REQUIRE_EQUAL(current_epoch(), 0u); - BOOST_REQUIRE(!get_blocklog(1).is_null()); - - BOOST_REQUIRE_EQUAL(success(), - push(MSGCH_ACCOUNT, msgch_abi, MSGCH_ACCOUNT, "bootstrap"_n, mvo())); - produce_blocks(); - - // The inline genesis advance passed the gate: epoch 0 -> 1, and the gate cleared the stale - // blocklog row on its way through. - BOOST_REQUIRE_EQUAL(current_epoch(), 1u); - BOOST_REQUIRE(get_blocklog(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// A forged/invalid delivery cannot strand the epoch. SEC-102's semantic-header check runs at -// INGRESS (msgch::deliver's inbound_envelope_valid gate), so a forged envelope reverts on delivery -// and records no envelope row -- it can never reach the consensus tally and leave a phantom -// consensus_reached with no outpcons row. A subsequent VALID delivery reaches consensus normally and -// chkcons proceeds to ATTEMPT advance (retry_count bumps) rather than waiting forever for a consensus -// row that an accepted-then-soft-dropped invalid winner would have left missing. Drives the -// production chkcons gate, not a direct epoch::advance. -BOOST_FIXTURE_TEST_CASE(forged_delivery_does_not_strand_chkcons, sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - const auto eth_code = fc::slug_name{"ETH"}.value; - const uint32_t epoch0 = current_epoch(); - const uint32_t target = epoch0 + 1; - - auto operator_payload = encode_operator_action( - sysio::opp::attestations::OperatorAction::ACTION_TYPE_DEPOSIT_REQUEST, - sysio::opp::types::CHAIN_KIND_EVM, - uwrit_op_eth_pubkey, - /*chain_code_v=*/ eth_code, - /*token_code_v=*/ eth_code, - /*amount=*/ 1'000'000); - const auto valid = encode_envelope_with_one_attestation( - epoch0, - sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION, - operator_payload); - - // A forged copy: corrupt payload_checksum so the semantic header no longer recomputes. - std::vector forged; - { - sysio::opp::Envelope env; - BOOST_REQUIRE(env.ParseFromArray(valid.data(), static_cast(valid.size()))); - auto* h = env.mutable_messages(0)->mutable_header(); - std::string c = h->payload_checksum(); - c[0] ^= 0x01; - h->set_payload_checksum(c); - forged.resize(env.ByteSizeLong()); - env.SerializeToArray(forged.data(), static_cast(forged.size())); - } - - // Forged delivery is rejected at ingress -- nothing recorded, no phantom consensus. - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: delivered envelope failed inbound-chain or " - "semantic-header validation"), - deliver(eth_code, forged)); - produce_blocks(); - - auto retry_count = [&]() -> uint32_t { - auto bl = get_blocklog(target); - return bl.is_null() ? 0u : bl["retry_count"].as(); - }; - - // The valid delivery (operators_per_epoch == 1 => Option-A unanimous) reaches consensus normally. - BOOST_REQUIRE_EQUAL(success(), deliver(eth_code, valid)); - produce_blocks(); - const uint32_t rc0 = retry_count(); - - // Pass the wall clock so chkcons fires advance; confirm it ATTEMPTED advance (retry_count bumps), - // i.e. it was NOT stranded waiting on a missing consensus row. Advance itself gate-blocks on - // emissions (this fixture deploys no sysio.system), exactly as in chkcons_survives_non_advancing_advance. - produce_block(fc::seconds(120)); - produce_blocks(); - BOOST_REQUIRE_EQUAL(success(), chkcons()); - produce_blocks(); - BOOST_REQUIRE_EQUAL(current_epoch(), epoch0); - BOOST_REQUIRE_EQUAL(retry_count(), rc0 + 1); -} FC_LOG_AND_RETHROW() } - -// A complete, fully bonded candidate lacking a destination-chain authex link -// keeps its valid UIC evidence while the request remains PENDING. Once the -// operator fixes the link, an exact external UIC replay settles from the -// preserved bytes without replacing the evidence. -BOOST_FIXTURE_TEST_CASE(swap_missing_dst_authex_recovers_after_exact_uic_replay, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); // registers ETH + UWRIT_OP (EVM link only) - - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, - "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM) - ("code", "SOLANA") - ("external_chain_id", 900) - ("name", std::string("solana-test")) - ("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t sol_chain = fc::slug_name{"SOLANA"}.value; - const uint64_t sol_token = fc::slug_name{"SOL"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 5000; - constexpr uint64_t AMOUNT = 1'000'000'000; - - setup_wire_token_and_reserves(); - - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", AMOUNT)); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "SOLANA", "SOL", AMOUNT)); - - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, static_cast(AMOUNT), - sol_chain, sol_token, primary, AMOUNT, - /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_SVM, - std::vector(32, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - - const auto src_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic)); - const auto dst_uic = create_signed_uic(UWRIT_OP, ATT_ID, sol_chain, sol_token, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, sol_chain, "SOLANA", "SOL", "PRIMARY", dst_uic)); - - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", req["status"].as_string()); - BOOST_REQUIRE(!req["attestation_inbound_data"].as_string().empty()); - bool found = false; - for (const auto& c : req["commits_by"].get_array()) { - if (c["underwriter"].as_string() == UWRIT_OP.to_string()) { - found = true; - BOOST_REQUIRE_EQUAL("UNDERWRITE_STATUS_INTENT_SUBMITTED", - c["status"].as_string()); - BOOST_REQUIRE(!c["source_uic_bytes"].as_string().empty()); - BOOST_REQUIRE(!c["dest_uic_bytes"].as_string().empty()); - } - } - BOOST_REQUIRE(found); - BOOST_REQUIRE(get_lock(1).is_null()); - - const auto solana_link_key = fc::crypto::private_key::generate( - fc::crypto::private_key::key_type::ed).get_public_key(); - const auto solana_link_raw = solana_link_key.get().serialize(); - BOOST_REQUIRE_EQUAL(success(), push( - AUTHEX_ACCOUNT, authex_abi, AUTHEX_ACCOUNT, "recordlink"_n, mvo() - ("account", UWRIT_OP) - ("chain_kind", ChainKind::CHAIN_KIND_SVM) - ("pub_key", solana_link_key) - ("native_address", std::vector(solana_link_raw.begin(), solana_link_raw.end())))); - produce_block(); - - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, sol_chain, "SOLANA", "SOL", "PRIMARY", dst_uic)); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", - get_uwreq(ATT_ID)["status"].as_string()); - BOOST_REQUIRE_EQUAL(UWRIT_OP.to_string(), get_uwreq(ATT_ID)["winner"].as_string()); - BOOST_REQUIRE(!get_lock(1).is_null()); - BOOST_REQUIRE(!get_lock(2).is_null()); - -} FC_LOG_AND_RETHROW() } - -// [P1] WSA-041: a zero AMM quote from ACTIVE reserves must FAIL CLOSED, not skip -// the variance check. createuwreq overloaded a zero `swap_quote` to mean "no LP -// provisioned, skip variance" — but a zero quote also arises from a degenerate -// ACTIVE reserve (extreme connector weights, a drained side, or — as here — a -// `src_amount` so small the weighted-Bancor kernel floors the output to 0). With -// variance skipped, the caller-supplied target_amount flowed straight into the -// uwreq and on to settlement, letting a ~0 input debit an arbitrary amount. -// Post-fix: when every required reserve is ACTIVE, a zero quote emits SWAP_REVERT -// and creates NO uwreq. (try_select_winner and drainfwq carry the same guard.) -BOOST_FIXTURE_TEST_CASE(swap_zero_quote_from_active_reserve_fails_closed, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); // registers ETH - - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, - "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM) - ("code", "SOLANA") - ("external_chain_id", 900) - ("name", std::string("solana-test")) - ("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t sol_chain = fc::slug_name{"SOLANA"}.value; - const uint64_t sol_token = fc::slug_name{"SOL"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 6100; - - // Seeds ETH/ETH and SOLANA/SOL ACTIVE reserves at 1e12/1e12. - setup_wire_token_and_reserves(); - - // src_amount = 1 against a 1e12 reserve floors token_to_wire to 0, so the - // chained quote is 0 — but both reserves are ACTIVE. The (huge) target must - // NOT slip through on a skipped variance check. - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 1, - sol_chain, sol_token, primary, /*target_amount*/ 900'000'000'000ull, - /*tolerance_bps*/ 5000, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); - // createuwreq never throws (it emits SWAP_REVERT and returns). - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - - // Fail closed: no uwreq was created from the unpriceable, ACTIVE-reserve swap. - BOOST_REQUIRE(get_uwreq(ATT_ID).is_null()); -} FC_LOG_AND_RETHROW() } - -/// WIRE-375: a foreign-chain token named WIRE is still an outpost asset. With -/// no ACTIVE `(ETH, WIRE, PRIMARY)` reserve, the ingress quote must remain -/// unavailable instead of treating both legs as native WIRE and pricing at par. -/// Keeping the zero-quote PENDING row also pins the existing unprovisioned-LP -/// behavior and proves `required_reserves_active` uses the same chain-aware test. -BOOST_FIXTURE_TEST_CASE(swap_foreign_wire_codename_is_not_native, - sysio_dispatch_tester) { try { - constexpr uint64_t ATT_ID = 6101; - constexpr int64_t SOURCE_AMOUNT = 2'000'000'000; - bootstrap_for_dispatch(); - register_wire_depot(); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t wire = fc::slug_name{"WIRE"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, wire, primary, SOURCE_AMOUNT, - wire, wire, wire, static_cast(SOURCE_AMOUNT), - /*tolerance_bps=*/10'000, ChainKind::CHAIN_KIND_WIRE, - std::vector{}); - - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE(!req.is_null()); - BOOST_REQUIRE_EQUAL(0u, req["dst_amount"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -// [P0] WNS-02 (CertiK "Wire Network - Sysio Audit 1", Critical): a swap must -// settle on the AMM QUOTE, never on the caller's `target_amount`. -// -// The attack: `createuwreq` stored `dst_amount = sr.target_amount` — a number -// the caller puts in the SwapRequest — and the only guard computed the allowed -// deviation as a percentage of that SAME caller-supplied target -// (`allowed = target * target_tolerance_bps / 10000`). At 10000 bps the check -// reduces to `|quote - target| <= target`, which every target above the quote -// satisfies. `try_select_winner` then locked and `sysio.reserv::applyswap` paid -// out `req.dst_amount` verbatim, so a negligible source deposit drained the -// destination reserve. -// -// Post-fix, both halves are closed and this pins each: -// (a) the allowance is a fraction of the QUOTE, so the drain request is -// refused outright — no uwreq exists to settle; -// (b) even for a target the tolerance legitimately admits, the row carries -// the quote, so the settlement amount is not caller-controlled at all. -BOOST_FIXTURE_TEST_CASE(swap_settles_on_amm_quote_not_caller_target, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - setup_eth_to_sol_uwreq(/*att_id*/ 9500); // registers SOLANA, reserves, collateral - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t sol_chain = fc::slug_name{"SOLANA"}.value; - const uint64_t sol_token = fc::slug_name{"SOL"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr int64_t SRC_AMOUNT = 1'000'000; - - const uint64_t quote = expected_quote("ETH", "ETH", "PRIMARY", SRC_AMOUNT, - "SOLANA", "SOL", "PRIMARY", kDefaultUwritFeeBps); - BOOST_REQUIRE(quote > 0); - - auto submit = [&](uint64_t att_id, uint64_t target, uint32_t tolerance_bps) { - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, SRC_AMOUNT, - sol_chain, sol_token, primary, target, - tolerance_bps, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(att_id, eth, sr)); - }; - - // (a) THE EXPLOIT, verbatim: a tiny source deposit naming most of the - // destination reserve, with the 100% tolerance that used to make the check - // pass unconditionally. Refused at ingestion — no uwreq, nothing to settle. - constexpr uint64_t DRAIN_TARGET = 900'000'000'000ull; // 90% of the 1e12 reserve - submit(/*att_id*/ 9501, DRAIN_TARGET, /*tolerance_bps*/ 10'000); - BOOST_REQUIRE(get_uwreq(9501).is_null()); - - // A tolerance ABOVE 100% must not buy the caller anything either — the - // allowance is clamped, so the same drain is still refused. - submit(/*att_id*/ 9502, DRAIN_TARGET, /*tolerance_bps*/ 1'000'000); - BOOST_REQUIRE(get_uwreq(9502).is_null()); - - // (b) A target the tolerance genuinely admits (1.5x the quote at 100% - // tolerance: |quote - target| = 0.5*quote <= quote). The request is accepted - // — and the row still carries the QUOTE, not the inflated target. - const uint64_t inflated_target = quote + quote / 2; - submit(/*att_id*/ 9503, inflated_target, /*tolerance_bps*/ 10'000); - const auto accepted = get_uwreq(9503); - BOOST_REQUIRE(!accepted.is_null()); - BOOST_REQUIRE_EQUAL(quote, accepted["dst_amount"].as_uint64()); - BOOST_REQUIRE_LT(accepted["dst_amount"].as_uint64(), inflated_target); -} FC_LOG_AND_RETHROW() } - -// [P0] WNS-02, settlement half: the destination reserve is debited by the AMM -// quote and by nothing else. The row-level assertion above proves `dst_amount` -// is the quote; this drives the full race to CONFIRMED and reads the reserve -// books, so the guarantee is pinned where the funds actually move -// (`sysio.reserv::applyswap`), which is where the drain happened. -BOOST_FIXTURE_TEST_CASE(swap_settlement_debits_destination_reserve_by_the_quote, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - setup_wire_token_and_reserves(); - // Both legs on ETH (PRIMARY -> SECOND): UWRIT_OP's only authex link is EVM, - // so an ETH destination is what lets the race actually reach settlement. - BOOST_REQUIRE_EQUAL(success(), regreserve_active("ETH", "ETH", "SECOND")); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - const uint64_t secondary = fc::slug_name{"SECOND"}.value; - constexpr uint64_t ATT_ID = 9601; - constexpr int64_t SRC_AMOUNT = 1'000'000; - - // One bucket funds both legs, so it must cover src + quote. - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000'000})); - - const uint64_t quote = expected_quote("ETH", "ETH", "PRIMARY", SRC_AMOUNT, - "ETH", "ETH", "SECOND", kDefaultUwritFeeBps); - BOOST_REQUIRE(quote > 0); - - const uint64_t dst_chain_before = - find_reserve("ETH", "ETH", "SECOND")["reserve_chain_amount"].as_uint64(); - const uint64_t src_chain_before = - find_reserve("ETH", "ETH", "PRIMARY")["reserve_chain_amount"].as_uint64(); - - // Target 20% above the quote — inside a 100% tolerance, so the race resolves - // and the ONLY thing that can decide the payout is the curve. - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, SRC_AMOUNT, - eth, eth, secondary, /*target_amount*/ quote + quote / 5, - /*tolerance_bps*/ 10'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - - const auto src_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic)); - const auto dst_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, secondary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "SECOND", dst_uic)); - - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", req["status"].as_string()); - BOOST_REQUIRE_EQUAL(UWRIT_OP.to_string(), req["winner"].as_string()); - // The CONFIRMED row records the price the books moved at. - BOOST_REQUIRE_EQUAL(quote, req["dst_amount"].as_uint64()); - - // Destination reserve gave up exactly the quote; the source reserve took in - // exactly the source amount. Neither is the caller's target. - const uint64_t dst_chain_after = - find_reserve("ETH", "ETH", "SECOND")["reserve_chain_amount"].as_uint64(); - const uint64_t src_chain_after = - find_reserve("ETH", "ETH", "PRIMARY")["reserve_chain_amount"].as_uint64(); - BOOST_REQUIRE_EQUAL(dst_chain_before - quote, dst_chain_after); - BOOST_REQUIRE_EQUAL(src_chain_before + static_cast(SRC_AMOUNT), src_chain_after); - - // The destination lock — the collateral the winner has at risk — is sized to - // the delivered amount, so the challenge window covers the real obligation. - BOOST_REQUIRE_EQUAL(quote, get_lock(2)["amount"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -// [P0] Review follow-up (PR #550): the slippage bound must NOT compound across -// the two checkpoints. The row's `dst_amount` is re-priced at race resolution, -// so measuring race-time drift against it would compare the settlement quote -// with the PREVIOUS quote instead of with what the user actually asked for — -// letting the price walk one tolerance-step per checkpoint. The reviewer's -// example: target 100 at 10% accepts an ingestion quote of 91 (9 <= 9.1) and -// then a settlement quote of 83 (8 <= 8.3), delivering 17% below target. -// -// `target_amount` is retained on the row precisely so this check anchors on the -// user's ORIGINAL bound, whatever path the price took. -BOOST_FIXTURE_TEST_CASE(swap_slippage_bound_does_not_compound_across_checkpoints, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), regreserve_active("ETH", "ETH", "SECOND")); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - const uint64_t secondary = fc::slug_name{"SECOND"}.value; - constexpr uint64_t ATT_ID = 9800; - constexpr int64_t SRC_AMOUNT = 1'000'000; - constexpr uint32_t TOLERANCE_BPS = 1'000; // 10% - - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000'000})); - - // Target ~9% above the ingestion quote — inside the 10% bound, so the request - // is accepted and the row records the quote. - const uint64_t ingestion_quote = expected_quote("ETH", "ETH", "PRIMARY", SRC_AMOUNT, - "ETH", "ETH", "SECOND", kDefaultUwritFeeBps); - BOOST_REQUIRE(ingestion_quote > 0); - const uint64_t target = ingestion_quote + (ingestion_quote * 9 / 100); - - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, SRC_AMOUNT, - eth, eth, secondary, target, - TOLERANCE_BPS, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - { - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE(!req.is_null()); - BOOST_REQUIRE_EQUAL(ingestion_quote, req["dst_amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(target, req["target_amount"].as_uint64()); - } - - // Move the destination reserve so the price walks a second step down: debit - // 7% of its token side (UWRIT-authorized, the same primitive settlement uses). - BOOST_REQUIRE_EQUAL(success(), push(RESERV_ACCOUNT, reserv_abi, UWRIT_ACCOUNT, "debit"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", "SECOND") - ("amount", uint64_t{70'000'000'000}))); - - // Pin the scenario: the drift is inside tolerance of the PREVIOUS quote (so - // the compounding rule would have let it settle) but outside tolerance of the - // user's TARGET (so the anchored rule must reject). If the fixture's - // arithmetic ever drifts out of this window the test says so here, rather - // than silently asserting nothing. - const uint64_t settle_quote = expected_quote("ETH", "ETH", "PRIMARY", SRC_AMOUNT, - "ETH", "ETH", "SECOND", kDefaultUwritFeeBps); - const uint64_t allowed = settle_quote * TOLERANCE_BPS / 10'000; - BOOST_REQUIRE_LE(ingestion_quote - settle_quote, allowed); // old rule: would pass - BOOST_REQUIRE_GT(target - settle_quote, allowed); // new rule: must reject - - const auto src_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic)); - const auto dst_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, secondary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "SECOND", dst_uic)); - - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_REJECTED", req["status"].as_string()); - bool drift = false; - for (const auto& c : req["commits_by"].get_array()) { - if (c["underwriter"].as_string() == UWRIT_OP.to_string()) - drift = c["reason"].as_string().find("variance drift") != std::string::npos; - } - BOOST_REQUIRE(drift); -} FC_LOG_AND_RETHROW() } - -// [P1] Review follow-up (PR #550): an under-bonded candidate must not be able to -// terminally close someone else's swap. The variance / unpriceable verdicts -// refund the user and end the request, so they run only after a candidate has -// proved it could actually settle. An ACTIVE underwriter can clear the role -// minimum yet lack collateral for a particular swap; if it could trigger a -// terminal refund during transient drift, any such operator would hold a -// denial-of-service over other people's swaps. -BOOST_FIXTURE_TEST_CASE(swap_underbonded_candidate_cannot_terminally_reject, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), regreserve_active("ETH", "ETH", "SECOND")); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - const uint64_t secondary = fc::slug_name{"SECOND"}.value; - constexpr uint64_t ATT_ID = 9850; - constexpr int64_t SRC_AMOUNT = 1'000'000; - constexpr uint32_t TOLERANCE_BPS = 1'000; // 10% - - // Clears the 1-unit role minimum (so the candidate is an ACTIVE underwriter) - // but is nowhere near the src + dst aggregate this swap needs. - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 10)); - - const uint64_t ingestion_quote = expected_quote("ETH", "ETH", "PRIMARY", SRC_AMOUNT, - "ETH", "ETH", "SECOND", kDefaultUwritFeeBps); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, SRC_AMOUNT, - eth, eth, secondary, ingestion_quote, - TOLERANCE_BPS, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - - // Drift the price far outside the tolerance — the request IS terminally - // doomed, but this candidate must not be the one to close it. - BOOST_REQUIRE_EQUAL(success(), push(RESERV_ACCOUNT, reserv_abi, UWRIT_ACCOUNT, "debit"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", "SECOND") - ("amount", uint64_t{500'000'000'000}))); - - const auto src_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic)); - const auto dst_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, secondary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "SECOND", dst_uic)); - - // Still PENDING (not REJECTED): the valid candidate was under-bonded, so - // the request remains retryable after collateral changes. - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", req["status"].as_string()); - bool bond_retryable = false; - for (const auto& c : req["commits_by"].get_array()) { - if (c["underwriter"].as_string() == UWRIT_OP.to_string()) { - BOOST_REQUIRE_EQUAL("UNDERWRITE_STATUS_INTENT_SUBMITTED", c["status"].as_string()); - bond_retryable = c["reason"].as_string().empty() - && !c["source_uic_bytes"].as_string().empty() - && !c["dest_uic_bytes"].as_string().empty(); - } - } - BOOST_REQUIRE(bond_retryable); -} FC_LOG_AND_RETHROW() } - -// [P0] WNS-01 (CertiK "Wire Network - Sysio Audit 1", Critical): `opreg::prune` -// must not erase a TERMINATED operator whose collateral has not settled. -// -// `terminate` remits only the immediately-unlocked portion and deliberately -// LEAVES the locked remainder on the row, because `releaselock` settles it when -// `chklocks` frees each lock. `prune` gated on `terminated_at` + the prune delay -// alone, so any caller — it is permissionless — could erase the row first; -// `releaselock` then returns at its missing-operator check and the retained -// collateral is stranded with no attestation that could ever release it. -// `chklocks` releases at most `max_rows` locks per call, and the remainder -// drains on the next sweep. -// -// Lock expiry is inherently BURSTY: every lock is stamped -// `now + collateral_lock_duration_ms` when its race is won, so a burst of -// settlements inside one epoch produces a burst of expiries inside one epoch, -// exactly one lock-duration later. Each expired lock costs an inline -// `opreg::releaselock` dispatch plus an erase, all inside `advance`'s hard, -// uncatchable transaction CPU deadline — so an UNBOUNDED sweep of a large -// enough burst aborts `advance`, and because those same locks are still -// expired at the next advance it aborts identically forever: a permanent -// chain-wide epoch stall, not a transient one. The budget converts that into -// bounded release latency, which is harmless because the challenge window has -// already closed by the time a lock is swept. -BOOST_FIXTURE_TEST_CASE(chklocks_budget_bounds_the_sweep_and_backlog_drains, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - // A 2-minute lock window, so the locks can expire inside the test. - BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() - ("fee_bps", kDefaultUwritFeeBps)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 0) - ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - const uint64_t secondary = fc::slug_name{"SECOND"}.value; - constexpr uint64_t ATT_ID = 9750; - constexpr int64_t SRC_AMOUNT = 1'000'000; - - // Both legs on ETH — the shape whose race reaches CONFIRMED and writes a - // lock per required leg, so one won race yields the two locks this bound - // is exercised against. - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), regreserve_active("ETH", "ETH", "SECOND")); - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000'000})); - enable_epoch_advancement(); - - const uint64_t quote = expected_quote("ETH", "ETH", "PRIMARY", SRC_AMOUNT, - "ETH", "ETH", "SECOND", kDefaultUwritFeeBps); - BOOST_REQUIRE(quote > 0); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, SRC_AMOUNT, - eth, eth, secondary, /*target_amount*/ quote, - /*tolerance_bps*/ 10'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - const auto src_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic)); - const auto dst_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, secondary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "SECOND", dst_uic)); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", - get_uwreq(ATT_ID)["status"].as_string()); - BOOST_REQUIRE(!get_lock(1).is_null()); - BOOST_REQUIRE(!get_lock(2).is_null()); - - // Both legs are ETH/ETH, so they share one collateral bucket: the rollup - // carries their sum, and agrees with the authoritative scan of the lock - // rows it materializes. - BOOST_REQUIRE_EQUAL(uint64_t(SRC_AMOUNT) + quote, - get_lock_sum(UWRIT_OP, "ETH", "ETH")); - BOOST_REQUIRE_EQUAL(scan_lock_total(UWRIT_OP, "ETH", "ETH"), - get_lock_sum(UWRIT_OP, "ETH", "ETH")); - - // Let the lock window elapse WITHOUT an advance, so both locks are due and - // nothing has swept them yet (advance would run the full budget inline). - produce_blocks(260); // > 120s at 0.5s/block - BOOST_REQUIRE(!get_lock(1).is_null()); - BOOST_REQUIRE(!get_lock(2).is_null()); - - // Zero budget is a no-op even with expired locks waiting. - BOOST_REQUIRE_EQUAL(success(), chklocks_direct(0)); - BOOST_REQUIRE(!get_lock(1).is_null()); - BOOST_REQUIRE(!get_lock(2).is_null()); - - // Budget of one releases EXACTLY one lock. Both were written by the same - // transaction and so share an `expires_at_ms`; the bound is what is under - // test here, not the tie-break between equal expiries. - BOOST_REQUIRE_EQUAL(success(), chklocks_direct(1)); - BOOST_REQUIRE(get_lock(1).is_null() != get_lock(2).is_null()); - - // The rollup decremented by exactly the released leg and still matches the - // surviving lock rows — a partial sweep leaves the two consistent. - BOOST_REQUIRE_EQUAL(scan_lock_total(UWRIT_OP, "ETH", "ETH"), - get_lock_sum(UWRIT_OP, "ETH", "ETH")); - BOOST_REQUIRE_GT(get_lock_sum(UWRIT_OP, "ETH", "ETH"), 0u); - - // A block between the two sweeps: the second call carries identical action - // data and the same signer, so without a fresh TAPOS reference block the - // two pushes hash to the same transaction id and the second is rejected as - // a duplicate. In production each sweep rides its own epoch's advance. - produce_blocks(); - - // The remainder drains on the next sweep — an oversized burst costs release - // latency, never a stalled advance. - BOOST_REQUIRE_EQUAL(success(), chklocks_direct(1)); - BOOST_REQUIRE(get_lock(1).is_null()); - BOOST_REQUIRE(get_lock(2).is_null()); - - // Bucket emptied: the rollup row is ERASED, so it reads as zero and the - // table is left holding only live buckets. - BOOST_REQUIRE_EQUAL(0u, get_lock_sum(UWRIT_OP, "ETH", "ETH")); - BOOST_REQUIRE_EQUAL(0u, scan_lock_total(UWRIT_OP, "ETH", "ETH")); - - // The releaselock fan-out still ran for both, benignly: UWRIT_OP is healthy - // (neither SLASHED nor TERMINATED), so each release is a no-op on the - // ledger and the bond is left whole — the lock rows disappearing is what - // restores `available()`. - { - const auto op = get_operator(UWRIT_OP); - BOOST_REQUIRE(!op.is_null()); - BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_ACTIVE", op["status"].as_string()); - uint64_t total = 0; - for (const auto& b : op["balances"].get_array()) total += b["balance"].as_uint64(); - BOOST_REQUIRE_EQUAL(uint64_t{1'000'000'000}, total); - } -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(prune_keeps_terminated_operator_until_collateral_settles, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - // A 1s prune delay (vs the fixture's 10min) so the delay gate is satisfied - // within the test — isolating the SETTLEMENT gate as the only thing holding - // the row. The underwriter minimum is re-declared verbatim: setconfig - // replaces the whole config. - BOOST_REQUIRE_EQUAL(success(), - opreg_setconfig_collat(fc::variants{chain_min_bond_mvo("ETH", "ETH", 1)}, - fc::variants{}, /*prune_delay_ms*/ 1'000)); - // A 2-minute lock window, so `chklocks` can expire it inside the test. - BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() - ("fee_bps", kDefaultUwritFeeBps)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 0) - ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - const uint64_t secondary = fc::slug_name{"SECOND"}.value; - constexpr uint64_t ATT_ID = 9700; - constexpr int64_t SRC_AMOUNT = 1'000'000; - - // Both legs on ETH — UWRIT_OP is authex-linked on EVM only, so this is the - // shape whose race actually reaches CONFIRMED and writes locks. - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), regreserve_active("ETH", "ETH", "SECOND")); - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000'000})); - enable_epoch_advancement(); - - // Win a race so UWRIT_OP holds live locks on both legs. - const uint64_t quote = expected_quote("ETH", "ETH", "PRIMARY", SRC_AMOUNT, - "ETH", "ETH", "SECOND", kDefaultUwritFeeBps); - BOOST_REQUIRE(quote > 0); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, SRC_AMOUNT, - eth, eth, secondary, /*target_amount*/ quote, - /*tolerance_bps*/ 10'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - const auto src_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic)); - const auto dst_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, secondary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "SECOND", dst_uic)); - BOOST_REQUIRE(!get_uwreq(ATT_ID).is_null()); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", - get_uwreq(ATT_ID)["status"].as_string()); - BOOST_REQUIRE(!get_lock(1).is_null()); - BOOST_REQUIRE(!get_lock(2).is_null()); - - // Terminate: the unlocked portion is remitted, the locked portion stays on - // the row for `releaselock`. - BOOST_REQUIRE_EQUAL(success(), terminate_op(UWRIT_OP, "audit-test termination")); - { - const auto op = get_operator(UWRIT_OP); - BOOST_REQUIRE(!op.is_null()); - BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_TERMINATED", op["status"].as_string()); - uint64_t retained = 0; - for (const auto& b : op["balances"].get_array()) retained += b["balance"].as_uint64(); - BOOST_REQUIRE_GT(retained, 0u); // exactly the locked collateral - } - - // Past the prune delay, with locks still live: the row MUST survive. This is - // the vulnerability — pre-fix the delay alone was sufficient to erase it. - produce_blocks(4); // > 1s at 0.5s/block - BOOST_REQUIRE_EQUAL(success(), opreg_prune()); - BOOST_REQUIRE(!get_operator(UWRIT_OP).is_null()); - - // Let the 2-minute lock window elapse. Each advance runs `chklocks` inline, - // which frees the expired locks and fans out `opreg::releaselock` — the - // deferred remit that drains the retained balance. - age_one_epoch(); - age_one_epoch(); - age_one_epoch(); - BOOST_REQUIRE(get_lock(1).is_null()); - BOOST_REQUIRE(get_lock(2).is_null()); - { - const auto op = get_operator(UWRIT_OP); - BOOST_REQUIRE(!op.is_null()); - for (const auto& b : op["balances"].get_array()) - BOOST_REQUIRE_EQUAL(0u, b["balance"].as_uint64()); - } - - // Settled — now the row is genuinely disposable and prune erases it. - BOOST_REQUIRE_EQUAL(success(), opreg_prune()); - BOOST_REQUIRE(get_operator(UWRIT_OP).is_null()); -} FC_LOG_AND_RETHROW() } - -// [P0] Review follow-up (PR #550): `prune` is not the only path that destroys a -// TERMINATED row — `regoperator` REPLACES one to allow re-registration, and it -// strands collateral the same way. A terminated underwriter re-registering while -// its locks are live would swap the retained balance row for a fresh healthy -// one; `chklocks` then fans out `releaselock`, which settles only SLASHED / -// TERMINATED operators, sees a healthy row, and no-ops. Every terminated-row -// erase path carries the settlement precondition. -BOOST_FIXTURE_TEST_CASE(reregistration_blocked_until_collateral_settles, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() - ("fee_bps", kDefaultUwritFeeBps)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 0) - ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - const uint64_t secondary = fc::slug_name{"SECOND"}.value; - constexpr uint64_t ATT_ID = 9900; - constexpr int64_t SRC_AMOUNT = 1'000'000; - - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), regreserve_active("ETH", "ETH", "SECOND")); - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000'000})); - enable_epoch_advancement(); - - // Win a race so the operator holds live locks. - const uint64_t quote = expected_quote("ETH", "ETH", "PRIMARY", SRC_AMOUNT, - "ETH", "ETH", "SECOND", kDefaultUwritFeeBps); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, SRC_AMOUNT, - eth, eth, secondary, quote, - /*tolerance_bps*/ 10'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - const auto src_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic)); - const auto dst_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, secondary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "SECOND", dst_uic)); - BOOST_REQUIRE(!get_lock(1).is_null()); - - BOOST_REQUIRE_EQUAL(success(), terminate_op(UWRIT_OP, "audit-test termination")); - - auto reregister = [&]() { - return push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, mvo() - ("account", UWRIT_OP.to_string()) - ("type", OperatorType::OPERATOR_TYPE_UNDERWRITER) - ("is_bootstrapped", false)); - }; - - // Locks live + balance retained: re-registration must be refused, so the - // terminated row (and the collateral it accounts for) survives intact. - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: operator has unsettled collateral: a terminated " - "operator may only re-register once its balances are drained and no underwriting " - "locks remain"), - reregister()); - { - const auto op = get_operator(UWRIT_OP); - BOOST_REQUIRE(!op.is_null()); - BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_TERMINATED", op["status"].as_string()); - uint64_t retained = 0; - for (const auto& b : op["balances"].get_array()) retained += b["balance"].as_uint64(); - BOOST_REQUIRE_GT(retained, 0u); - } - - // Let the locks expire; `chklocks` -> `releaselock` drains the balance. - age_one_epoch(); - age_one_epoch(); - age_one_epoch(); - BOOST_REQUIRE(get_lock(1).is_null()); - - // Settled — re-registration is allowed again, with a clean row. - BOOST_REQUIRE_EQUAL(success(), reregister()); - const auto op = get_operator(UWRIT_OP); - BOOST_REQUIRE(!op.is_null()); - BOOST_REQUIRE(op["status"].as_string() != "OPERATOR_STATUS_TERMINATED"); -} FC_LOG_AND_RETHROW() } - -// [P1] regression (r3444213199) + WNS-02: an oversized to-WIRE target_amount -// must never reach settlement. Originally the target WAS the settlement amount, -// so a value near UINT64_MAX wrapped `dst_amount + to_wire_fee`, slipped into -// paywire, and aborted its `asset(static_cast(dst_amount))` inside -// evalcons — a chain-wide consensus stall — which the downstream -// `asset::max_amount` guard was added to catch. -// -// With settlement priced by the curve (WNS-02) the row never carries the target -// at all, and the variance check refuses these at INGRESS: the allowance is a -// fraction of the quote, so a target orders of magnitude above it fails for any -// tolerance. That is strictly stronger than the old outcome (no uwreq is created -// at all, versus one created then terminally rejected after a commit), and it -// makes the downstream asset-bound guard unreachable defense-in-depth — kept in -// the contract, but no longer constructible from a SwapRequest. -BOOST_FIXTURE_TEST_CASE(swap_to_wire_oversized_target_is_refused_at_ingress, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); // ETH source outpost + UWRIT_OP (EVM link) - register_wire_depot(); // WIRE depot => to-WIRE destination - setup_wire_token_and_reserves(); // ACTIVE ETH/ETH/PRIMARY source reserve w/ WIRE - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t wire = fc::slug_name{"WIRE"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - // Large enough that the source leg prices well above the kernel's floor, so - // the refusal is the variance check and not the unpriceable-reserve gate. - constexpr int64_t SRC_AMOUNT = 1'000'000; - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000'000})); - - // A valid existing WIRE recipient, so nothing earlier in the path rejects. - const std::string rs = UWRIT_OP.to_string(); - const std::vector rcpt(rs.begin(), rs.end()); - - constexpr uint64_t ASSET_MAX = (uint64_t{1} << 62) - 1; - auto run = [&](uint64_t att_id, uint64_t target) { - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, SRC_AMOUNT, - wire, wire, primary, target, - // 100x the meaningful maximum: `variance_allowance` clamps it to 100%, - // so even the widest tolerance a caller can name cannot admit a target - // this far from the quote. Pre-fix, this tolerance let it straight in. - /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_WIRE, rcpt); - // createuwreq never throws — it emits SWAP_REVERT and returns. - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(att_id, eth, sr)); - BOOST_REQUIRE(get_uwreq(att_id).is_null()); - }; - - run(7001, ASSET_MAX + 1); // target == asset::max_amount + 1 (boundary) - run(7002, uint64_t{1} << 63); // high bit set (negative as int64) - run(7003, ~uint64_t{0}); // UINT64_MAX — wrapped dst_amount + fee pre-fix -} FC_LOG_AND_RETHROW() } - -// WIRE-291: every protobuf-valid UIC whose embedded signature is malformed or -// unsupported must be ignored before storage without aborting rcrdcommit. -// The table covers all packed tags 0..5: fixed-size K1/R1/EM/ED truncations, -// unsupported WebAuthn/BLS shapes, and invalid K1 recovery math. These cases -// use real PENDING requests and an ACTIVE, bonded underwriter, so each action -// reaches pre-storage verification rather than an earlier guard. -BOOST_FIXTURE_TEST_CASE(swap_malformed_signature_shapes_are_ignored_before_storage, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - register_wire_depot(); // to-WIRE: a single (source) required leg - setup_wire_token_and_reserves(); // ACTIVE ETH/ETH/PRIMARY source reserve w/ WIRE - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t wire = fc::slug_name{"WIRE"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID_BASE = 7'210; - using key_type = fc::crypto::private_key::key_type; - - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000})); - - const std::vector> cases{ - {}, - create_truncated_uic_signature(key_type::k1), - create_truncated_uic_signature(key_type::r1), - create_packed_webauthn_uic_signature(), - create_truncated_uic_signature(key_type::em), - create_truncated_uic_signature(key_type::ed), - create_default_packed_uic_signature(), - create_invalid_uic_signature(key_type::k1), - }; - - const std::string recipient = UWRIT_OP.to_string(); - const std::vector recipient_bytes(recipient.begin(), recipient.end()); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - wire, wire, primary, /*target*/ 50, - /*tolerance_bps*/ 1'000'000, - ChainKind::CHAIN_KIND_WIRE, recipient_bytes); - - for (size_t i = 0; i < cases.size(); ++i) { - const uint64_t att_id = ATT_ID_BASE + i; - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(att_id, eth, sr)); - - const auto uic = create_uic_with_signature( - UWRIT_OP, att_id, eth, eth, primary, cases[i]); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(att_id, UWRIT_OP, eth, - "ETH", "ETH", "PRIMARY", uic)); - - const auto req = get_uwreq(att_id); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", - req["status"].as_string()); - BOOST_REQUIRE(req["commits_by"].get_array().empty()); - } - - // No malformed candidate reached lock creation or settlement. - BOOST_REQUIRE(get_lock(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// Every structural rejection class is stable and operator-visible. These -// payloads all reach signature verification on a real PENDING request; each -// action succeeds at the consensus level, logs its exact typed reason, and -// leaves the candidate roster untouched. -BOOST_FIXTURE_TEST_CASE(swap_signature_rejection_taxonomy_is_logged_and_non_throwing, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - register_wire_depot(); - setup_wire_token_and_reserves(); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t wire = fc::slug_name{"WIRE"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID_BASE = 7'250; - using key_type = fc::crypto::private_key::key_type; - using sig_type = fc::crypto::signature::sig_type; - - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000})); - const std::string recipient = UWRIT_OP.to_string(); - const std::vector recipient_bytes(recipient.begin(), recipient.end()); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, 100, wire, wire, primary, 50, - 1'000'000, ChainKind::CHAIN_KIND_WIRE, recipient_bytes); - - struct rejection_case { - std::vector payload; - std::string_view reason; - }; - const auto ecc_sized_shape = create_packed_uic_signature(key_type::k1); - const auto ed_sized_shape = create_packed_uic_signature(key_type::ed); - const std::vector cases{ - {{}, "empty_uic"}, - {{'\x0a'}, "malformed_uic"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 2, eth, eth, primary, {}), - "missing_signature"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 3, eth, eth, primary, - create_truncated_uic_signature(key_type::k1)), - "invalid_signature_length"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 4, eth, eth, primary, - create_truncated_uic_signature(key_type::r1)), - "invalid_signature_length"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 5, eth, eth, primary, - create_truncated_uic_signature(key_type::em)), - "invalid_signature_length"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 6, eth, eth, primary, - create_truncated_uic_signature(key_type::ed)), - "invalid_signature_length"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 7, eth, eth, primary, - create_overlength_uic_signature(key_type::k1)), - "invalid_signature_length"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 8, eth, eth, primary, - create_overlength_uic_signature(key_type::r1)), - "invalid_signature_length"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 9, eth, eth, primary, - create_overlength_uic_signature(key_type::em)), - "invalid_signature_length"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 10, eth, eth, primary, - create_overlength_uic_signature(key_type::ed)), - "invalid_signature_length"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 11, eth, eth, primary, - retag_packed_uic_signature( - ecc_sized_shape, sig_type::ed)), - "invalid_signature_length"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 12, eth, eth, primary, - retag_packed_uic_signature( - ed_sized_shape, sig_type::k1)), - "invalid_signature_length"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 13, eth, eth, primary, - retag_packed_uic_signature( - ed_sized_shape, sig_type::r1)), - "invalid_signature_length"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 14, eth, eth, primary, - retag_packed_uic_signature( - ed_sized_shape, sig_type::em)), - "invalid_signature_length"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 15, eth, eth, primary, - create_packed_webauthn_uic_signature()), - "unsupported_signature_type"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 16, eth, eth, primary, - create_default_packed_uic_signature< - fc::crypto::bls::signature_shim>()), - "unsupported_signature_type"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 17, eth, eth, primary, - create_corrupted_ed_uic_signature()), - "recovery_failed"}, - {create_uic_with_signature(UWRIT_OP, ATT_ID_BASE + 18, eth, eth, primary, - create_invalid_uic_signature(key_type::k1)), - "non_canonical_signature"}, - }; - - for (size_t i = 0; i < cases.size(); ++i) { - const uint64_t att_id = ATT_ID_BASE + i; - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(att_id, eth, sr)); - auto payload = cases[i].payload; - // Payload-bearing cases were shaped with their final attestation id. - // The two raw malformed cases contain no signed/id-dependent content. - const auto trace = rcrdcommit_trace( - att_id, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", payload); - BOOST_REQUIRE(trace != nullptr); - BOOST_REQUIRE(!trace->except); - BOOST_REQUIRE(!trace->action_traces.empty()); - const auto expected = "reason=" + std::string{cases[i].reason}; - BOOST_CHECK_NE(std::string::npos, - trace->action_traces.front().console.find(expected)); - BOOST_REQUIRE(get_uwreq(att_id)["commits_by"].get_array().empty()); - } -} FC_LOG_AND_RETHROW() } - -// Strict protobuf canonicality rejects alternate host encodings before -// signature recovery. An absent nested address, a present default-valued nested -// address, and a zero top-level scalar all decode but normalize differently. -// Repeated unknown field keys exercise decoder behavior all the way to the 2 -// KiB boundary: decoding succeeds, canonical re-encoding drops the unknown -// keys, and the payload is rejected as non-canonical. None can create -// candidate evidence. After one honest source leg is stored, valid replays are -// write-once no-ops before signature work. -BOOST_FIXTURE_TEST_CASE(swap_noncanonical_uic_is_rejected_and_valid_leg_is_write_once, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - constexpr uint64_t ATT_ID = 7'280; - setup_eth_to_sol_uwreq(ATT_ID); - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - - const auto honest = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - auto default_nested = create_uic(UWRIT_OP.to_string(), ATT_ID, eth, eth, primary); - default_nested.mutable_uw_ext_chain_addr()->set_kind( - ChainKind::CHAIN_KIND_UNKNOWN); - auto zero_scalar = create_uic(UWRIT_OP.to_string(), ATT_ID, eth, eth, primary); - zero_scalar.set_token_code(0); - auto repeated_unknown = honest; - constexpr size_t MAX_UIC_LEG_BYTES = 2048; - while (true) { - std::vector unknown_pair; - append_varint_field(unknown_pair, 9, 72); - if (repeated_unknown.size() + unknown_pair.size() > MAX_UIC_LEG_BYTES) break; - repeated_unknown.insert(repeated_unknown.end(), - unknown_pair.begin(), unknown_pair.end()); - } - sysio::opp::attestations::UnderwriteIntentCommit honest_decoded; - sysio::opp::attestations::UnderwriteIntentCommit unknown_decoded; - BOOST_REQUIRE(honest_decoded.ParseFromArray(honest.data(), honest.size())); - BOOST_REQUIRE(unknown_decoded.ParseFromArray( - repeated_unknown.data(), repeated_unknown.size())); - honest_decoded.DiscardUnknownFields(); - unknown_decoded.DiscardUnknownFields(); - BOOST_CHECK_EQUAL(honest_decoded.SerializeAsString(), - unknown_decoded.SerializeAsString()); - sysio::opp::types::WireAccount attacker_account; - attacker_account.set_name("alice"); - std::string attacker_account_bytes; - BOOST_REQUIRE(attacker_account.SerializeToString(&attacker_account_bytes)); - std::vector parser_differential; - append_length_delimited_field( - parser_differential, 1, - std::span{attacker_account_bytes.data(), attacker_account_bytes.size()}); - append_length_delimited_field(parser_differential, 15, honest); - struct alternate_encoding { - std::vector payload; - std::string_view rejection; - }; - const std::array alternates{ - alternate_encoding{ - sign_uic(std::move(default_nested), get_private_key(UWRIT_OP, "active")), - "non_canonical_uic"}, - alternate_encoding{ - sign_uic(std::move(zero_scalar), get_private_key(UWRIT_OP, "active")), - "non_canonical_uic"}, - alternate_encoding{std::move(repeated_unknown), "non_canonical_uic"}, - alternate_encoding{std::move(parser_differential), "non_canonical_uic"}, - }; - for (const auto& alternate : alternates) { - const auto trace = rcrdcommit_trace( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", alternate.payload); - BOOST_REQUIRE(!trace->except); - const auto& console = trace->action_traces.front().console; - BOOST_CHECK_MESSAGE( - console.find("reason=" + std::string{alternate.rejection}) != std::string::npos, - "unexpected rejection log: " << console); - BOOST_REQUIRE(get_uwreq(ATT_ID)["commits_by"].get_array().empty()); - // Seal the rejection before the next empty-block time jump. - produce_block(); - } - - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", honest)); - produce_block(); - const auto before = get_uwreq(ATT_ID)["commits_by"].get_array().front(); - - // Repeated canonical UICs can coexist in one relayed envelope. Pin the - // contract-side idempotency branch: every replay exits before signature - // recovery and leaves the first evidence and arrival time unchanged. - for (uint32_t replay = 0; replay < 8; ++replay) { - const auto trace = rcrdcommit_trace( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", honest); - BOOST_REQUIRE(!trace->except); - const auto& console = trace->action_traces.front().console; - BOOST_CHECK_NE(std::string::npos, - console.find("already carries a source UIC, skipping duplicate")); - BOOST_CHECK_EQUAL(std::string::npos, - console.find("UIC_SIGNATURE_REJECTED")); - const auto after = get_uwreq(ATT_ID)["commits_by"].get_array().front(); - BOOST_CHECK_EQUAL(before["source_uic_bytes"].as_string(), - after["source_uic_bytes"].as_string()); - BOOST_CHECK_EQUAL(before["source_received_at_ms"].as_uint64(), - after["source_received_at_ms"].as_uint64()); - BOOST_CHECK_EQUAL("UNDERWRITE_STATUS_INTENT_SUBMITTED", - after["status"].as_string()); - produce_block(); - } - BOOST_REQUIRE(get_lock(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// K1, R1, and EM high-s alternates remain cryptographically recoverable but -// are not canonical protocol signatures. Each is logged and ignored before -// storage, with no lock or settlement side effect. -BOOST_FIXTURE_TEST_CASE(swap_high_s_ecdsa_alternates_are_rejected, - sysio_dispatch_tester) { try { - using private_key = fc::crypto::private_key; - bootstrap_for_dispatch(); - register_wire_depot(); - setup_wire_token_and_reserves(); - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t wire = fc::slug_name{"WIRE"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID_BASE = 7'290; - - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000})); - const std::array keys{ - private_key::generate(private_key::key_type::k1), - private_key::generate(private_key::key_type::r1), - private_key::generate(private_key::key_type::em), - }; - std::vector active_keys; - for (const auto& key : keys) active_keys.push_back({key.get_public_key(), 1}); - set_authority(UWRIT_OP, config::active_name, - authority{1, std::move(active_keys)}, config::owner_name); - produce_blocks(); - - const std::string recipient = UWRIT_OP.to_string(); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, 100, wire, wire, primary, 50, 1'000'000, - ChainKind::CHAIN_KIND_WIRE, - std::vector(recipient.begin(), recipient.end())); - for (size_t i = 0; i < keys.size(); ++i) { - const uint64_t att_id = ATT_ID_BASE + i; - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(att_id, eth, sr)); - const auto high_s = create_high_s_signed_uic( - UWRIT_OP, keys[i], att_id, eth, eth, primary); - const auto trace = rcrdcommit_trace( - att_id, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", high_s); - BOOST_REQUIRE(!trace->except); - BOOST_CHECK_NE(std::string::npos, - trace->action_traces.front().console.find("reason=non_canonical_signature")); - BOOST_REQUIRE(get_uwreq(att_id)["commits_by"].get_array().empty()); - } - BOOST_REQUIRE(get_lock(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// Libfc recovery accepts the compact (31..34) and Ethereum (27..30) header -// families as aliases. The UIC boundary pins exactly one family per variant so -// the same key/signature cannot have two accepted byte representations. -BOOST_FIXTURE_TEST_CASE(swap_recovery_header_aliases_are_rejected, - sysio_dispatch_tester) { try { - using private_key = fc::crypto::private_key; - bootstrap_for_dispatch(); - register_wire_depot(); - setup_wire_token_and_reserves(); - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t wire = fc::slug_name{"WIRE"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID_BASE = 7'295; - - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000})); - const std::array keys{ - private_key::generate(private_key::key_type::k1), - private_key::generate(private_key::key_type::r1), - private_key::generate(private_key::key_type::em), - }; - std::vector active_keys; - for (const auto& key : keys) active_keys.push_back({key.get_public_key(), 1}); - set_authority(UWRIT_OP, config::active_name, - authority{1, std::move(active_keys)}, config::owner_name); - produce_blocks(); - - const std::string recipient = UWRIT_OP.to_string(); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, 100, wire, wire, primary, 50, 1'000'000, - ChainKind::CHAIN_KIND_WIRE, - std::vector(recipient.begin(), recipient.end())); - for (size_t i = 0; i < keys.size(); ++i) { - const uint64_t att_id = ATT_ID_BASE + i; - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(att_id, eth, sr)); - const auto aliased = create_recovery_alias_signed_uic( - UWRIT_OP, keys[i], att_id, eth, eth, primary); - const auto trace = rcrdcommit_trace( - att_id, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", aliased); - BOOST_REQUIRE(!trace->except); - BOOST_CHECK_NE(std::string::npos, - trace->action_traces.front().console.find("reason=non_canonical_signature")); - BOOST_REQUIRE(get_uwreq(att_id)["commits_by"].get_array().empty()); - } - BOOST_REQUIRE(get_lock(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// A UIC has one recoverable signature, so one minority key in a multisig -// permission cannot authorize the commitment by mere membership. The direct -// key's own weight must satisfy active or owner threshold by itself. -BOOST_FIXTURE_TEST_CASE(swap_below_threshold_permission_key_is_rejected, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - register_wire_depot(); - setup_wire_token_and_reserves(); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t wire = fc::slug_name{"WIRE"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 7'299; - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000})); - - const auto minority_key = fc::crypto::private_key::generate( - fc::crypto::private_key::key_type::k1); - const auto second_key = fc::crypto::private_key::generate( - fc::crypto::private_key::key_type::k1); - std::vector active_keys{ - {minority_key.get_public_key(), 1}, - {second_key.get_public_key(), 1}, - }; - std::sort(active_keys.begin(), active_keys.end()); - set_authority(UWRIT_OP, config::active_name, - authority{2, std::move(active_keys)}, config::owner_name); - produce_blocks(); - - const std::string recipient = UWRIT_OP.to_string(); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, 100, wire, wire, primary, 50, 1'000'000, - ChainKind::CHAIN_KIND_WIRE, - std::vector(recipient.begin(), recipient.end())); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - const auto uic = create_signed_uic_with_key( - UWRIT_OP, minority_key, ATT_ID, eth, eth, primary); - const auto trace = rcrdcommit_trace( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", uic); - BOOST_REQUIRE(!trace->except); - BOOST_CHECK_NE(std::string::npos, - trace->action_traces.front().console.find("reason=unauthorized_key")); - BOOST_REQUIRE(get_uwreq(ATT_ID)["commits_by"].get_array().empty()); - BOOST_REQUIRE(get_lock(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// Winner-time verification exists for permission freshness. If the first leg's -// active key is removed before the matching leg arrives, only that older leg is -// revalidated and the candidate is durably disqualified without aborting. -BOOST_FIXTURE_TEST_CASE(swap_key_rotation_disqualifies_older_stored_leg, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - constexpr uint64_t ATT_ID = 7'300; - setup_eth_to_sol_uwreq(ATT_ID); - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t sol = fc::slug_name{"SOLANA"}.value; - const uint64_t sol_token = fc::slug_name{"SOL"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - - const auto old_source = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", old_source)); - - const auto replacement_key = fc::crypto::private_key::generate( - fc::crypto::private_key::key_type::k1); - set_authority(UWRIT_OP, config::active_name, - authority{replacement_key.get_public_key()}, config::owner_name); - produce_blocks(); - - const auto new_destination = create_signed_uic_with_key( - UWRIT_OP, replacement_key, ATT_ID, sol, sol_token, primary); - const auto trace = rcrdcommit_trace( - ATT_ID, UWRIT_OP, sol, "SOLANA", "SOL", "PRIMARY", new_destination); - BOOST_REQUIRE(!trace->except); - BOOST_CHECK_NE(std::string::npos, - trace->action_traces.front().console.find("reason=unauthorized_key")); - - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", req["status"].as_string()); - const auto& candidate = req["commits_by"].get_array().front(); - BOOST_CHECK_EQUAL("UNDERWRITE_STATUS_DISQUALIFIED", candidate["status"].as_string()); - BOOST_CHECK_EQUAL("invalid underwrite-intent-commit source signature: unauthorized_key", - candidate["reason"].as_string()); - - // Advance past duplicate-transaction detection before replaying the same - // action bytes; the assertion below is about contract state, not mempool - // transaction de-duplication. - produce_blocks(); - - const auto assert_disqualification_unchanged = [&]() { - const auto current = get_uwreq(ATT_ID)["commits_by"].get_array().front(); - BOOST_CHECK_EQUAL(candidate["status"].as_string(), current["status"].as_string()); - BOOST_CHECK_EQUAL(candidate["reason"].as_string(), current["reason"].as_string()); - BOOST_CHECK_EQUAL(candidate["source_received_at_ms"].as_uint64(), - current["source_received_at_ms"].as_uint64()); - BOOST_CHECK_EQUAL(candidate["dest_received_at_ms"].as_uint64(), - current["dest_received_at_ms"].as_uint64()); - BOOST_CHECK_EQUAL(candidate["source_uic_bytes"].as_string(), - current["source_uic_bytes"].as_string()); - BOOST_CHECK_EQUAL(candidate["dest_uic_bytes"].as_string(), - current["dest_uic_bytes"].as_string()); - }; - - // An identical, otherwise valid replay cannot re-arm a disqualified entry. - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, sol, "SOLANA", "SOL", "PRIMARY", new_destination)); - assert_disqualification_unchanged(); - - // Neither malformed nor unauthorized changed replacements can re-arm it. - const auto malformed_source = create_uic_with_signature( - UWRIT_OP, ATT_ID, eth, eth, primary, - {packed_uic_signature_tag(fc::crypto::signature::sig_type::k1)}); - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", malformed_source)); - assert_disqualification_unchanged(); - const auto forged_source = create_signed_uic_as( - UWRIT_OP, BATCHOP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", forged_source)); - assert_disqualification_unchanged(); - BOOST_REQUIRE(get_lock(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// The freshness rule is symmetric: when the destination arrives first, a -// permission rotation before the source arrives durably disqualifies that -// older destination evidence and produces no settlement side effect. -BOOST_FIXTURE_TEST_CASE(swap_destination_key_rotation_disqualifies_older_stored_leg, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - constexpr uint64_t ATT_ID = 7'301; - setup_eth_to_sol_uwreq(ATT_ID); - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t sol = fc::slug_name{"SOLANA"}.value; - const uint64_t sol_token = fc::slug_name{"SOL"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - - const auto old_destination = create_signed_uic( - UWRIT_OP, ATT_ID, sol, sol_token, primary); - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, sol, "SOLANA", "SOL", "PRIMARY", old_destination)); - - const auto replacement_key = fc::crypto::private_key::generate( - fc::crypto::private_key::key_type::k1); - set_authority(UWRIT_OP, config::active_name, - authority{replacement_key.get_public_key()}, config::owner_name); - produce_blocks(); - - const auto new_source = create_signed_uic_with_key( - UWRIT_OP, replacement_key, ATT_ID, eth, eth, primary); - const auto trace = rcrdcommit_trace( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", new_source); - BOOST_REQUIRE(!trace->except); - BOOST_CHECK_NE(std::string::npos, - trace->action_traces.front().console.find("reason=unauthorized_key")); - - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", req["status"].as_string()); - const auto& candidate = req["commits_by"].get_array().front(); - BOOST_CHECK_EQUAL("UNDERWRITE_STATUS_DISQUALIFIED", candidate["status"].as_string()); - BOOST_CHECK_EQUAL( - "invalid underwrite-intent-commit destination signature: unauthorized_key", - candidate["reason"].as_string()); - BOOST_REQUIRE(get_lock(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// The WIRE provider key family includes four fixed-size recoverable variants. -// Pin the full contract boundary (packing, direct variant construction, host -// recovery, and active-permission comparison) for K1, R1, EM, and ED. -BOOST_FIXTURE_TEST_CASE(swap_fixed_size_recoverable_signatures_are_accepted, - sysio_dispatch_tester) { try { - using private_key = fc::crypto::private_key; - - bootstrap_for_dispatch(); - register_wire_depot(); - setup_wire_token_and_reserves(); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t wire = fc::slug_name{"WIRE"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID_BASE = 7'230; - - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000})); - - const std::array signing_keys{ - private_key::from_string( - "PVT_K1_2Nb6TgdiEJopY3dCFYhrA46NzXC3DSWzznes1Dc71oWF29tJTt"), - private_key::from_string( - "PVT_R1_iyQmnyPEGvFd8uffnk152WC2WryBjgTrg22fXQryuGL9mU6qW"), - private_key::from_string( - "PVT_EM_0x4646464646464646464646464646464646464646464646464646464646464646"), - private_key::from_string( - "4YFq9y5f5hi77Bq8kDCE6VgqoAqKGSQN87yW9YeGybpNfqKUG4WxnwhboHGUeXjY7g8262mhL1kCCM9yy8uGvdj7", - private_key::key_type::ed), - }; - constexpr std::array EXPECTED_PACKED_TAGS{0, 1, 3, 4}; - std::vector active_keys; - active_keys.reserve(signing_keys.size()); - for (const auto& key : signing_keys) { - active_keys.push_back({key.get_public_key(), 1}); - } - set_authority(UWRIT_OP, config::active_name, - authority{1, std::move(active_keys)}, config::owner_name); - produce_blocks(); - - const std::string recipient = UWRIT_OP.to_string(); - const std::vector recipient_bytes(recipient.begin(), recipient.end()); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - wire, wire, primary, /*target*/ 50, - /*tolerance_bps*/ 1'000'000, - ChainKind::CHAIN_KIND_WIRE, recipient_bytes); - - for (size_t i = 0; i < signing_keys.size(); ++i) { - const uint64_t att_id = ATT_ID_BASE + i; - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(att_id, eth, sr)); - const auto uic = create_signed_uic_with_key( - UWRIT_OP, signing_keys[i], att_id, eth, eth, primary); - sysio::opp::attestations::UnderwriteIntentCommit decoded; - BOOST_REQUIRE(decoded.ParseFromArray(uic.data(), static_cast(uic.size()))); - BOOST_REQUIRE(!decoded.signature().empty()); - BOOST_CHECK_EQUAL(EXPECTED_PACKED_TAGS[i], - static_cast(decoded.signature().front())); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(att_id, UWRIT_OP, eth, - "ETH", "ETH", "PRIMARY", uic)); - - const auto req = get_uwreq(att_id); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", - req["status"].as_string()); - BOOST_REQUIRE_EQUAL(UWRIT_OP.to_string(), req["winner"].as_string()); - } -} FC_LOG_AND_RETHROW() } - -// Owner remains an authorization ancestor of active, so a UIC signed by the -// claimed underwriter's owner key is accepted by the permission-authority -// check even though underwriters normally configure an active key provider. -BOOST_FIXTURE_TEST_CASE(swap_owner_permission_signature_is_accepted, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - register_wire_depot(); - setup_wire_token_and_reserves(); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t wire = fc::slug_name{"WIRE"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 7'240; - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000})); - - const std::string recipient = UWRIT_OP.to_string(); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, 100, wire, wire, primary, 50, 1'000'000, - ChainKind::CHAIN_KIND_WIRE, - std::vector(recipient.begin(), recipient.end())); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - - const auto owner_signed = create_signed_uic_with_key( - UWRIT_OP, get_private_key(UWRIT_OP, "owner"), - ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", owner_signed)); - - const auto req = get_uwreq(ATT_ID); - BOOST_CHECK_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", - req["status"].as_string()); - BOOST_CHECK_EQUAL(UWRIT_OP.to_string(), req["winner"].as_string()); -} FC_LOG_AND_RETHROW() } - -// Authorization is independent of signature encoding. Unrelated K1, R1, EM, -// and ED keys all produce structurally valid UICs, but none may claim the -// underwriter account or create candidate/lock state. -BOOST_FIXTURE_TEST_CASE(swap_unrelated_supported_keys_are_unauthorized, - sysio_dispatch_tester) { try { - using private_key = fc::crypto::private_key; - bootstrap_for_dispatch(); - register_wire_depot(); - setup_wire_token_and_reserves(); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t wire = fc::slug_name{"WIRE"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID_BASE = 7'245; - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000})); - - const std::string recipient = UWRIT_OP.to_string(); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, 100, wire, wire, primary, 50, 1'000'000, - ChainKind::CHAIN_KIND_WIRE, - std::vector(recipient.begin(), recipient.end())); - const std::array unrelated_keys{ - private_key::generate(private_key::key_type::k1), - private_key::generate(private_key::key_type::r1), - private_key::generate(private_key::key_type::em), - private_key::generate(private_key::key_type::ed), - }; - - for (size_t i = 0; i < unrelated_keys.size(); ++i) { - const uint64_t att_id = ATT_ID_BASE + i; - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(att_id, eth, sr)); - const auto forged = create_signed_uic_with_key( - UWRIT_OP, unrelated_keys[i], att_id, eth, eth, primary); - const auto trace = rcrdcommit_trace( - att_id, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", forged); - BOOST_REQUIRE(!trace->except); - BOOST_CHECK_NE(std::string::npos, - trace->action_traces.front().console.find("reason=unauthorized_key")); - BOOST_REQUIRE(get_uwreq(att_id)["commits_by"].get_array().empty()); - } - BOOST_REQUIRE(get_lock(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// Source and destination UICs are pre-validated independently. Exercise the -// destination branch with a valid source commit followed by a truncated ED -// destination signature; the bad record is ignored, the valid source bytes -// remain stored, and neither leg reaches lock creation. -BOOST_FIXTURE_TEST_CASE(swap_malformed_destination_signature_preserves_valid_source, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - BOOST_REQUIRE_EQUAL(success(), push( - CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM) - ("code", "SOLANA") - ("external_chain_id", 900) - ("name", std::string("solana-test")) - ("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - setup_wire_token_and_reserves(); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t sol_chain = fc::slug_name{"SOLANA"}.value; - const uint64_t sol_token = fc::slug_name{"SOL"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 7'219; - - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000})); - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "SOLANA", "SOL", uint64_t{1'000'000})); - - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - sol_chain, sol_token, primary, /*target*/ 100, - /*tolerance_bps*/ 1'000'000, - ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - - const auto valid_source = - create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, - "ETH", "ETH", "PRIMARY", valid_source)); - - const auto malformed_destination = create_uic_with_signature( - UWRIT_OP, ATT_ID, sol_chain, sol_token, primary, - create_truncated_uic_signature(fc::crypto::private_key::key_type::ed)); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, sol_chain, - "SOLANA", "SOL", "PRIMARY", malformed_destination)); - - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", - req["status"].as_string()); - bool found = false; - for (const auto& c : req["commits_by"].get_array()) { - if (c["underwriter"].as_string() != UWRIT_OP.to_string()) continue; - found = true; - BOOST_REQUIRE_EQUAL("UNDERWRITE_STATUS_INTENT_SUBMITTED", - c["status"].as_string()); - BOOST_REQUIRE(c["source_received_at_ms"].as_uint64() > 0); - BOOST_REQUIRE_EQUAL(uint64_t{0}, c["dest_received_at_ms"].as_uint64()); - BOOST_REQUIRE(c["reason"].as_string().empty()); - } - BOOST_REQUIRE(found); - BOOST_REQUIRE(get_lock(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// WIRE-291 full-path regression: the first UIC in one consensus envelope has -// a truncated K1 shape that previously reached `ds >> signature` -// and aborted evalcons. A valid commit follows in the SAME envelope. The bad -// record must be ignored before storage, and the valid record must still win, -// proving dispatch continued past the malformed record. -BOOST_FIXTURE_TEST_CASE(malformed_uic_does_not_abort_later_attestations_in_same_envelope, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - register_wire_depot(); // to-WIRE: a single (source) required leg - setup_wire_token_and_reserves(); // ACTIVE ETH/ETH/PRIMARY source reserve w/ WIRE - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t wire = fc::slug_name{"WIRE"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 7'220; - - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000})); - - const std::string recipient = UWRIT_OP.to_string(); - const std::vector recipient_bytes(recipient.begin(), recipient.end()); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - wire, wire, primary, /*target*/ 50, - /*tolerance_bps*/ 1'000'000, - ChainKind::CHAIN_KIND_WIRE, recipient_bytes); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - - const auto truncated_uic = create_uic_with_signature( - UWRIT_OP, ATT_ID, eth, eth, primary, - {packed_uic_signature_tag(fc::crypto::signature::sig_type::k1)}); - const auto valid_uic = create_signed_uic( - UWRIT_OP, ATT_ID, eth, eth, primary); - - const std::string truncated_bytes(truncated_uic.begin(), truncated_uic.end()); - const std::string valid_bytes(valid_uic.begin(), valid_uic.end()); - BOOST_REQUIRE_EQUAL(success(), deliver( - /*proven=*/ eth, - encode_envelope_with_attestations( - current_epoch(), - AttestationType::ATTESTATION_TYPE_UNDERWRITE_INTENT_COMMIT, - {truncated_bytes, valid_bytes}))); - - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", - req["status"].as_string()); - BOOST_REQUIRE_EQUAL(UWRIT_OP.to_string(), req["winner"].as_string()); - BOOST_REQUIRE(!get_lock(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// A UIC's `uw_account` remains a signed claim at the depot boundary. Current -// outposts decode and canonicalize each UIC, authenticate its chain-native -// submitter, and bind that caller to the claimed ACTIVE WIRE roster entry before -// relaying the original bytes. The depot independently repeats the current -// permission-signature check as defense in depth against a compromised, -// outdated, or misconfigured outpost. A forged direct delivery is ignored -// before mutation, so the untouched source leg can still pair with the honest -// destination and win. -BOOST_FIXTURE_TEST_CASE(swap_forged_claim_cannot_overwrite_honest_candidate, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - BOOST_REQUIRE_EQUAL(success(), push( - CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM) - ("code", "SOLANA") - ("external_chain_id", 900) - ("name", std::string("solana-test")) - ("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - const auto solana_link_key = fc::crypto::private_key::generate( - fc::crypto::private_key::key_type::ed).get_public_key(); - const auto solana_link_raw = solana_link_key.get().serialize(); - BOOST_REQUIRE_EQUAL(success(), push( - AUTHEX_ACCOUNT, authex_abi, AUTHEX_ACCOUNT, "recordlink"_n, mvo() - ("account", UWRIT_OP) - ("chain_kind", ChainKind::CHAIN_KIND_SVM) - ("pub_key", solana_link_key) - ("native_address", std::vector(solana_link_raw.begin(), solana_link_raw.end())))); - setup_wire_token_and_reserves(); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t sol_chain = fc::slug_name{"SOLANA"}.value; - const uint64_t sol_token = fc::slug_name{"SOL"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 7200; - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000})); - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "SOLANA", "SOL", uint64_t{1'000'000})); - - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - sol_chain, sol_token, primary, /*target*/ 100, - /*tolerance_bps*/ 1'000'000, - ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - - const auto honest_source = - create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, - "ETH", "ETH", "PRIMARY", honest_source)); - - const auto before_forgery = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", - before_forgery["status"].as_string()); - BOOST_REQUIRE_EQUAL(1u, before_forgery["commits_by"].get_array().size()); - const auto& honest_candidate = before_forgery["commits_by"].get_array().front(); - BOOST_REQUIRE_EQUAL("UNDERWRITE_STATUS_INTENT_SUBMITTED", - honest_candidate["status"].as_string()); - const auto stored_source_before = honest_candidate["source_uic_bytes"].as_string(); - const auto source_received_before = - honest_candidate["source_received_at_ms"].as_uint64(); - BOOST_REQUIRE(source_received_before > 0); - - // This UIC claims UWRIT_OP but is signed by the unrelated batchop.a key. - // Drive the real envelope path so dispatch derives UWRIT_OP from the forged - // field before signature recovery rejects it. - const auto forged_source = - create_signed_uic_as(UWRIT_OP, BATCHOP, ATT_ID, eth, eth, primary); - const std::string forged_source_bytes(forged_source.begin(), forged_source.end()); - BOOST_REQUIRE_EQUAL(success(), - deliver( - /*proven=*/ eth, - encode_envelope_with_one_attestation( - current_epoch(), - AttestationType::ATTESTATION_TYPE_UNDERWRITE_INTENT_COMMIT, - forged_source_bytes))); - - const auto after_forgery = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", - after_forgery["status"].as_string()); - BOOST_REQUIRE_EQUAL(1u, after_forgery["commits_by"].get_array().size()); - const auto& candidate_after = after_forgery["commits_by"].get_array().front(); - BOOST_REQUIRE_EQUAL("UNDERWRITE_STATUS_INTENT_SUBMITTED", - candidate_after["status"].as_string()); - BOOST_REQUIRE_EQUAL(stored_source_before, - candidate_after["source_uic_bytes"].as_string()); - BOOST_REQUIRE_EQUAL(source_received_before, - candidate_after["source_received_at_ms"].as_uint64()); - BOOST_REQUIRE(candidate_after["reason"].as_string().empty()); - - const auto honest_destination = - create_signed_uic(UWRIT_OP, ATT_ID, sol_chain, sol_token, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, sol_chain, - "SOLANA", "SOL", "PRIMARY", honest_destination)); - - const auto confirmed = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", - confirmed["status"].as_string()); - BOOST_REQUIRE_EQUAL(UWRIT_OP.to_string(), confirmed["winner"].as_string()); -} FC_LOG_AND_RETHROW() } - -// ── Underwriter role + activation gate at commit ingestion ─────────────────── -// -// Only an ACTIVE UNDERWRITER (opreg type == UNDERWRITER && status == ACTIVE) can -// win a race, so `rcrdcommit` refuses to record a commit_entry for anything else: -// a UIC from a non-underwriter, or a not-yet-active underwriter, is dropped before -// any mutation. This bounds `commits_by` to the registered active-underwriter set, -// so a matched-leg UIC cannot append one row per attacker-chosen valid name. -// `try_select_winner` keeps the same eligibility check as defensive depth. Both -// cases below carry real ETH bond and a valid self-signed UIC; the ingestion gate -// drops them (no row, no lock) and leaves the race PENDING (reclaimable) for a -// genuine winner. The positive control — an ACTIVE underwriter that wins — is -// `swap_same_token_legs_exact_balance_wins`. - -// A non-underwriter operator that is fully ACTIVE and bonded — a funded PRODUCER — -// cannot have a commit recorded even with a valid self-signed UIC. Activating it -// (status ACTIVE) isolates the op.type half of the gate: a status-only check would -// let it through, so this test fails if the type check is dropped. -BOOST_FIXTURE_TEST_CASE(swap_commit_non_underwriter_type_is_dropped, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); // ETH source outpost + UWRIT_OP - register_wire_depot(); // to-WIRE: a single (source) required leg - - // Require producer ETH collateral too, so funding the producer below promotes - // it to ACTIVE via opreg::processprod (the underwriter requirement stays as - // bootstrap set it). - BOOST_REQUIRE_EQUAL(success(), opreg_setconfig_collat( - /*req_uw_collat=*/ fc::variants{chain_min_bond_mvo("ETH", "ETH", 1)}, - /*req_prod_collat=*/ fc::variants{chain_min_bond_mvo("ETH", "ETH", 1)})); - - // A second operator registered as a PRODUCER (NOT an underwriter). Privileged - // opreg self-registration skips the authex-link precondition. - const name PRODOP = "prodop.a"_n; - create_account(PRODOP); - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, - "regoperator"_n, mvo() - ("account", PRODOP.to_string()) - ("type", OperatorType::OPERATOR_TYPE_PRODUCER) - ("is_bootstrapped", false))); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t wire = fc::slug_name{"WIRE"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 7400; - - // Fund ETH bond: covers the source leg AND clears req_prod_collat, so - // processprod flips PRODOP to ACTIVE. The candidate is now an ACTIVE, - // sufficiently-bonded operator that is simply the wrong role — only op.type - // can disqualify it. - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(PRODOP, "ETH", "ETH", uint64_t{1'000'000})); - { - const auto op = get_operator(PRODOP); - BOOST_REQUIRE_EQUAL("OPERATOR_TYPE_PRODUCER", op["type"].as_string()); - BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_ACTIVE", op["status"].as_string()); - } - - const std::string rs = UWRIT_OP.to_string(); - const std::vector rcpt(rs.begin(), rs.end()); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - wire, wire, primary, /*target*/ 50, - 5000, ChainKind::CHAIN_KIND_WIRE, rcpt); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - - // Valid UIC self-signed by PRODOP — signature recovery passes, so only the - // eligibility gate can reject it. - const auto uic = create_signed_uic(PRODOP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, PRODOP, eth, "ETH", "ETH", "PRIMARY", uic)); - - // rcrdcommit drops the commit at ingestion — an ACTIVE PRODUCER is the wrong role, so it is not - // an ACTIVE underwriter. No commit_entry is created (no row growth), the uwreq stays PENDING, and - // no lock is written. - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", req["status"].as_string()); - BOOST_REQUIRE_EQUAL(0u, req["commits_by"].get_array().size()); - BOOST_REQUIRE(get_lock(1).is_null()); // no lock written -} FC_LOG_AND_RETHROW() } - -// A registered UNDERWRITER that has NOT cleared its activation threshold (status -// UNKNOWN) cannot have a commit recorded, even funded on the swap's leg. Requiring -// an additional unfunded collateral pair (SOLANA/SOL) keeps UWRIT_OP inactive while -// it still holds ample ETH bond — isolating the activation gate from the bond check. -BOOST_FIXTURE_TEST_CASE(swap_commit_inactive_underwriter_is_dropped, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - register_wire_depot(); - // Require ETH AND SOLANA collateral, so funding ETH alone no longer activates. - BOOST_REQUIRE_EQUAL(success(), opreg_setconfig_collat(fc::variants{ - chain_min_bond_mvo("ETH", "ETH", 1), - chain_min_bond_mvo("SOLANA", "SOL", 1)})); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t wire = fc::slug_name{"WIRE"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 7500; - - // Ample ETH bond for the source leg, but SOLANA stays unfunded → meets_role_min - // is false → UWRIT_OP never reaches ACTIVE. - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000})); - { - const auto op = get_operator(UWRIT_OP); - BOOST_REQUIRE_EQUAL("OPERATOR_TYPE_UNDERWRITER", op["type"].as_string()); - BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_UNKNOWN", op["status"].as_string()); - } - - const std::string rs = UWRIT_OP.to_string(); - const std::vector rcpt(rs.begin(), rs.end()); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - wire, wire, primary, /*target*/ 50, - 5000, ChainKind::CHAIN_KIND_WIRE, rcpt); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - - const auto uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", uic)); - - // rcrdcommit drops the commit at ingestion — UWRIT_OP is a registered underwriter but not yet - // ACTIVE. No commit_entry is created, the uwreq stays PENDING, and no lock is written. - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", req["status"].as_string()); - BOOST_REQUIRE_EQUAL(0u, req["commits_by"].get_array().size()); - BOOST_REQUIRE(get_lock(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// Regression (r3444212155): a malformed inbound SwapRequest must NOT abort the -// consensus-tipping delivery. createuwreq logs + skips (no row, no throw) when -// the payload fails to decode — it cannot be refunded either, since the revert -// needs the undecodable actor / source_amount. -BOOST_FIXTURE_TEST_CASE(createuwreq_malformed_swaprequest_does_not_abort, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - constexpr uint64_t ATT_ID = 7300; - // A length-delimited protobuf field (tag 0x0a) claiming far more bytes than - // are present — the decoder underruns the buffer and returns an error. (Even - // a tolerant decoder yields random fields that name no registered chain, so - // no row is created either way.) - const std::vector garbage{'\x0a', '\x80', '\x80', '\x80', '\x80', '\x08'}; - const std::string garbage_str(garbage.begin(), garbage.end()); - // Must NOT throw (a check() here would abort the delivery before any row - // exists) and must NOT create a uwreq row. - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, garbage_str)); - BOOST_REQUIRE(get_uwreq(ATT_ID).is_null()); -} FC_LOG_AND_RETHROW() } - -// The consensus-envelope analogue of the case above. msgch's dispatch forwards SWAP_REQUEST bytes -// to createuwreq UNDECODED, so createuwreq's own decode guard is all that stands between -// attacker-shaped attestation bytes and a throw that would abort the consensus-tipping delivery -// (and drop the whole epoch's inbound dispatch with it). Three malformed shapes ride ONE envelope -// through the full deliver -> evalcons -> apply_consensus -> inline-createuwreq chain, ahead of a -// fully valid swap: deliver must succeed, the malformed entries must create no uwreq rows, and the -// valid entry behind them must still create exactly one. -BOOST_FIXTURE_TEST_CASE(swap_request_malformed_bytes_do_not_abort_consensus_delivery, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); // ETH source outpost - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM)("code", "SOLANA") - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 1'000'000'000)); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "SOLANA", "SOL", 1'000'000'000)); - - const auto eth = fc::slug_name{"ETH"}.value; - const auto sol_chain = fc::slug_name{"SOLANA"}.value; - const auto sol_token = fc::slug_name{"SOL"}.value; - const auto primary = fc::slug_name{"PRIMARY"}.value; - - // Empty bytes: proto3 decodes zero fields into an all-defaults SwapRequest whose - // source_chain_code (0) can never match the proven outpost -- refunded/dropped, never thrown. - const std::string empty_bytes{}; - // A lone length-delimited field tag with its length varint missing -- the decoder underruns. - const std::string truncated_tag{"\x0a"}; - // Deterministic junk: the leading tag varint carries wire type 6, which protobuf does not define. - const std::string junk_bytes{"\xde\xad\xbe\xef\x42"}; - // Fully valid ETH->SOLANA swap, placed LAST so every malformed entry dispatches before it. - const auto valid_sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - sol_chain, sol_token, primary, /*target*/ 100, - 5000, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); - - // The envelope's four attestations mint sequential ids starting here (reserve registration - // above already consumed ids for its outbound RESERVE_READY queueouts). - const uint64_t first_att_id = next_att_id(); - - // deliver() must SUCCEED -- each malformed payload is dropped inside its inline createuwreq, - // never thrown up through the dispatch chain. - BOOST_REQUIRE_EQUAL(success(), deliver(/*proven=*/ eth, - encode_envelope_with_attestations(current_epoch(), - sysio::opp::types::ATTESTATION_TYPE_SWAP_REQUEST, - {empty_bytes, truncated_tag, junk_bytes, valid_sr}))); - - // The three malformed entries created no uwreq; the valid one behind them created exactly - // one, keyed by its minted id. - BOOST_REQUIRE(get_uwreq(first_att_id + 0).is_null()); - BOOST_REQUIRE(get_uwreq(first_att_id + 1).is_null()); - BOOST_REQUIRE(get_uwreq(first_att_id + 2).is_null()); - const auto req = get_uwreq(first_att_id + 3); - BOOST_REQUIRE(!req.is_null()); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", req["status"].as_string()); - BOOST_REQUIRE_EQUAL(eth, req["src_chain_code"].as().value); - BOOST_REQUIRE_EQUAL(100u, req["src_amount"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -// Duplicate-id idempotency: re-delivering an attestation_id that already has a uwreq row -- the -// protocol's normal every-cron-tick re-relay -- must no-op WITHOUT throwing and WITHOUT touching -// the existing row, even when the re-delivery carries different (or malformed) bytes. The -// duplicate guard runs before the decode, so a garbage duplicate is skipped by id alone. -BOOST_FIXTURE_TEST_CASE(createuwreq_duplicate_attestation_id_is_idempotent, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM)("code", "SOLANA") - ("external_chain_id", 900)("name", std::string("solana-test"))("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 1'000'000'000)); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "SOLANA", "SOL", 1'000'000'000)); - - const auto eth = fc::slug_name{"ETH"}.value; - const auto sol_chain = fc::slug_name{"SOLANA"}.value; - const auto sol_token = fc::slug_name{"SOL"}.value; - const auto primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 7400; - - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - sol_chain, sol_token, primary, /*target*/ 100, - 5000, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - const auto row_before = get_row_by_id(UWRIT_ACCOUNT, UWRIT_ACCOUNT, "uwreqs"_n, ATT_ID); - BOOST_REQUIRE(!row_before.empty()); - - // Same id, different payload (amounts 100 -> 250): the duplicate must be skipped, not - // overwritten -- the row stays byte-identical to the original delivery. - const auto sr_conflicting = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0c'), - eth, eth, primary, /*src_amount*/ 250, - sol_chain, sol_token, primary, /*target*/ 250, - 5000, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0d')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr_conflicting)); - BOOST_REQUIRE(get_row_by_id(UWRIT_ACCOUNT, UWRIT_ACCOUNT, "uwreqs"_n, ATT_ID) == row_before); - - // Same id, malformed payload: skipped by id before the decode ever runs. - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, std::string{"\x0a"})); - BOOST_REQUIRE(get_row_by_id(UWRIT_ACCOUNT, UWRIT_ACCOUNT, "uwreqs"_n, ATT_ID) == row_before); -} FC_LOG_AND_RETHROW() } - -// ───────────────────────────── WSA-028: signed TokenAmount ingress ───────────────────────────── -// -// OPP TokenAmount.amount is signed on the wire (int64 / vint64_t). The historical foot-gun was -// static_cast(static_cast(amount)): a negative value such as -1 wraps to -// 18446744073709551615, an impossible "balance" that sails through zero-only guards and inflates -// collateral / reserve / settlement accounting. Every value-bearing ingress path now routes the -// amount through sysio::opp::safe::to_depot_amount, which rejects amount <= 0 AND amount > -// asset::max_amount before any unsigned use. These cases drive malformed amounts through the real -// dispatch paths and assert the depot applies NO value-bearing effect — and never throws (a throw -// inside the evalcons dispatch chain stalls consensus chain-wide). The positive controls are -// dispatch_routes_deposit_to_opreg / dispatch_routes_withdraw_request_to_opreg above. - -// DEPOSIT_REQUEST: a valid +1,000,000 deposit rides one envelope alongside two malformed amounts — -// -1 (wraps to UINT64_MAX, the amount <= 0 branch) and 2^62 (== asset::max_amount + 1, the -// out-of-range branch). The valid deposit credits EXACTLY 1,000,000; neither malformed amount -// credits anything (a wrapped credit would make the final balance differ from the valid amount), and -// deliver never throws. -BOOST_FIXTURE_TEST_CASE(operator_action_negative_deposit_is_dropped, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - - constexpr int64_t VALID_DEPOSIT = 1'000'000; - constexpr int64_t ASSET_MAX_PLUS_ONE = int64_t{1} << 62; // sysio::asset::max_amount + 1 - const auto eth_code = fc::slug_name{"ETH"}.value; - - auto mk = [&](int64_t amount) { - return encode_operator_action( - sysio::opp::attestations::OperatorAction::ACTION_TYPE_DEPOSIT_REQUEST, - sysio::opp::types::CHAIN_KIND_EVM, uwrit_op_eth_pubkey, - eth_code, eth_code, amount); - }; - - BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/eth_code, - encode_envelope_with_attestations(current_epoch(), - sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION, - {mk(VALID_DEPOSIT), mk(-1), mk(ASSET_MAX_PLUS_ONE)}))); - - auto op = get_operator(UWRIT_OP); - BOOST_REQUIRE(!op.is_null()); - auto bal = find_balance(op, "ETH", "ETH"); - BOOST_REQUIRE(!bal.is_null()); - // Exactly the valid amount — the wrapped -1 and the out-of-range 2^62 credited nothing. - BOOST_REQUIRE_EQUAL(static_cast(VALID_DEPOSIT), bal["balance"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -// WITHDRAW_REQUEST: a valid deposit funds the operator, then a wrapped -1 withdraw rides the same -// envelope. The deposit credits; the negative withdraw is dropped — no row is queued (and no -// successful action log is appended). Positive control: dispatch_routes_withdraw_request_to_opreg. -BOOST_FIXTURE_TEST_CASE(operator_action_negative_withdraw_is_dropped, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - - constexpr int64_t INITIAL_DEPOSIT = 5'000'000; - const auto eth_code = fc::slug_name{"ETH"}.value; - - auto deposit_payload = encode_operator_action( - sysio::opp::attestations::OperatorAction::ACTION_TYPE_DEPOSIT_REQUEST, - sysio::opp::types::CHAIN_KIND_EVM, uwrit_op_eth_pubkey, - eth_code, eth_code, INITIAL_DEPOSIT); - auto neg_wtdw_payload = encode_operator_action( - sysio::opp::attestations::OperatorAction::ACTION_TYPE_WITHDRAW_REQUEST, - sysio::opp::types::CHAIN_KIND_EVM, uwrit_op_eth_pubkey, - eth_code, eth_code, /*amount=*/ -1); - - BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/eth_code, - encode_envelope_with_attestations(current_epoch(), - sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION, - {deposit_payload, neg_wtdw_payload}))); - - // The deposit credited normally... - auto op = get_operator(UWRIT_OP); - BOOST_REQUIRE(!op.is_null()); - auto bal = find_balance(op, "ETH", "ETH"); - BOOST_REQUIRE(!bal.is_null()); - BOOST_REQUIRE_EQUAL(static_cast(INITIAL_DEPOSIT), bal["balance"].as_uint64()); - // ...but the wrapped-negative withdraw queued nothing. - BOOST_REQUIRE(get_wtdw(/*request_id=*/1).is_null()); -} FC_LOG_AND_RETHROW() } - -// SWAP_REQUEST: a wrapped -1 source_amount must REVERT (refund on the proven outpost) and create no -// uwreq — never wrap into a huge src_amount that corrupts the swap quote / reserve settlement. Mirrors -// swap_zero_quote_from_active_reserve_fails_closed; createuwreq never throws (it emits SWAP_REVERT). -BOOST_FIXTURE_TEST_CASE(swap_request_negative_source_is_reverted, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); // registers ETH - - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, - "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM) - ("code", "SOLANA") - ("external_chain_id", 900) - ("name", std::string("solana-test")) - ("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t sol_chain = fc::slug_name{"SOLANA"}.value; - const uint64_t sol_token = fc::slug_name{"SOL"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 6200; - - setup_wire_token_and_reserves(); // ACTIVE ETH/ETH and SOLANA/SOL reserves - - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ -1, - sol_chain, sol_token, primary, /*target_amount*/ 900'000'000'000ull, - /*tolerance_bps*/ 5000, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); - // createuwreq never throws (it emits SWAP_REVERT and returns). - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - // Fail closed: the wrapped-negative source created no pending uwreq. - BOOST_REQUIRE(get_uwreq(ATT_ID).is_null()); -} FC_LOG_AND_RETHROW() } - -// ── Same-token underwriter overcommit (one collateral bucket, two legs) ────── -// -// Underwriter collateral is held per (underwriter, chain_code, token_code) — -// NOT per reserve_code. A swap whose source and destination legs share one -// (chain, token) bucket but use different reserve_code values (a shape -// rcrdcommit explicitly routes) draws BOTH locks against that single balance. -// The winner check must require availability to cover the AGGREGATE of both -// legs; checking each leg independently lets a balance covering each single leg -// but not their sum win and overcommit the bucket. - -// Balance 150 covers each single 100-leg but not the 200 aggregate. The valid -// candidate remains PENDING after the failed capacity check; a top-up followed -// by an exact external UIC replay settles from the stored UICs. -BOOST_FIXTURE_TEST_CASE(swap_same_token_legs_overcommit_recovers_after_top_up_replay, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); // ETH chain + UWRIT_OP (EVM authex link) - // Both reserves must be ACTIVE and priceable: the resolver computes the live - // quote before the bond check so the bond covers the actual obligation, then - // applies request-level quote verdicts only after that candidate gate. This - // setup keeps the test specifically on the aggregate-collateral path. - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), regreserve_active("ETH", "ETH", "SECOND")); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - const uint64_t secondary = fc::slug_name{"SECOND"}.value; - constexpr uint64_t ATT_ID = 8000; - constexpr int64_t SRC_AMOUNT = 100; - constexpr uint64_t DST_AMOUNT = 100; - // One (ETH, ETH) bucket holds 150 against an aggregate need of - // `src_amount + quote(src_amount)` — just under 200, so 150 cannot cover it. - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 150)); - - // Same-(chain, token) swap between two reserves on the one ETH outpost. - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, SRC_AMOUNT, - eth, eth, secondary, DST_AMOUNT, - /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - - const auto src_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic)); - const auto dst_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, secondary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "SECOND", dst_uic)); - - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", req["status"].as_string()); - bool found = false; - for (const auto& c : req["commits_by"].get_array()) { - if (c["underwriter"].as_string() == UWRIT_OP.to_string()) { - found = true; - BOOST_REQUIRE_EQUAL("UNDERWRITE_STATUS_INTENT_SUBMITTED", - c["status"].as_string()); - BOOST_REQUIRE(c["reason"].as_string().empty()); - BOOST_REQUIRE(!c["source_uic_bytes"].as_string().empty()); - BOOST_REQUIRE(!c["dest_uic_bytes"].as_string().empty()); - } - } - BOOST_REQUIRE(found); - BOOST_REQUIRE(get_lock(1).is_null()); // no locks written - - // More collateral makes this already-proven candidate eligible. An exact - // replay revalidates both stored signatures and writes the two locks. - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", 50)); - produce_block(); - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic)); - - const auto confirmed = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", - confirmed["status"].as_string()); - BOOST_REQUIRE_EQUAL(UWRIT_OP.to_string(), confirmed["winner"].as_string()); - BOOST_REQUIRE_EQUAL(UWRIT_OP.to_string(), get_lock(1)["underwriter"].as_string()); - BOOST_REQUIRE_EQUAL(UWRIT_OP.to_string(), get_lock(2)["underwriter"].as_string()); - BOOST_REQUIRE_EQUAL(ATT_ID, get_lock(1)["uwreq_id"].as_uint64()); - BOOST_REQUIRE_EQUAL(ATT_ID, get_lock(2)["uwreq_id"].as_uint64()); - BOOST_REQUIRE(get_lock(3).is_null()); -} FC_LOG_AND_RETHROW() } - -// Reserve liquidity belongs to the request, not to an underwriter candidate. -// If it changes after request admission but before the second UIC reaches the -// depot, the live AMM re-quote turns the drain into price drift before the -// defensive raw-liquidity precheck. A fully bonded candidate may therefore -// trigger the request-level rejection/refund, releasing compact candidate -// metadata instead of leaving a complete row with no retry or wake-up path. -BOOST_FIXTURE_TEST_CASE(swap_race_time_reserve_drain_rejects_request, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, - "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM) - ("code", "SOLANA") - ("external_chain_id", 900) - ("name", std::string("solana-test")) - ("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - const auto solana_link_key = fc::crypto::private_key::generate( - fc::crypto::private_key::key_type::ed).get_public_key(); - BOOST_REQUIRE_EQUAL(success(), push( - AUTHEX_ACCOUNT, authex_abi, AUTHEX_ACCOUNT, "recordlink"_n, mvo() - ("account", UWRIT_OP) - ("chain_kind", ChainKind::CHAIN_KIND_SVM) - ("pub_key", solana_link_key) - ("native_address", std::vector(32, '\x0b')))); - setup_wire_token_and_reserves(); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t sol_chain = fc::slug_name{"SOLANA"}.value; - const uint64_t sol_token = fc::slug_name{"SOL"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 8004; - constexpr uint64_t AMOUNT = 1'000'000'000; - - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", AMOUNT)); - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "SOLANA", "SOL", AMOUNT)); - - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, static_cast(AMOUNT), - sol_chain, sol_token, primary, AMOUNT, - /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_SVM, - std::vector(32, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - - // The request was admitted against 1e12 units. Drain the destination only - // after admission, while its two UICs are notionally in flight. The live - // quote remains positive but falls outside the fixed target's tolerance, so - // the candidate passes its bond gate before the request is rejected. - constexpr uint64_t SEEDED_RESERVE = 1'000'000'000'000ull; - BOOST_REQUIRE_EQUAL(success(), debit_reserve_chain( - "SOLANA", "SOL", "PRIMARY", SEEDED_RESERVE - (AMOUNT - 1))); - - const auto src_uic = create_signed_uic( - UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic)); - const auto dst_uic = create_signed_uic( - UWRIT_OP, ATT_ID, sol_chain, sol_token, primary); - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, sol_chain, "SOLANA", "SOL", "PRIMARY", dst_uic)); - - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_REJECTED", - req["status"].as_string()); - BOOST_REQUIRE(req["attestation_inbound_data"].as_string().empty()); - const auto candidate = req["commits_by"].get_array().front(); - BOOST_REQUIRE_EQUAL("UNDERWRITE_STATUS_RELEASED", - candidate["status"].as_string()); - BOOST_CHECK_NE(std::string::npos, - candidate["reason"].as_string().find("variance drift")); - BOOST_REQUIRE(candidate["source_uic_bytes"].as_string().empty()); - BOOST_REQUIRE(candidate["dest_uic_bytes"].as_string().empty()); - BOOST_REQUIRE(get_lock(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// An exact external replay is a fresh outpost trigger. An adverse price move -// discovered while re-evaluating the immutable UIC pair therefore rejects and -// refunds this otherwise eligible request. -BOOST_FIXTURE_TEST_CASE(swap_replayed_uic_variance_drift_rejects_request, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); // registers ETH + UWRIT_OP (EVM link only) - BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, - "regchain"_n, mvo() - ("kind", ChainKind::CHAIN_KIND_SVM) - ("code", "SOLANA") - ("external_chain_id", 900) - ("name", std::string("solana-test")) - ("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo()))); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t sol_chain = fc::slug_name{"SOLANA"}.value; - const uint64_t sol_token = fc::slug_name{"SOL"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 8006; - constexpr uint64_t AMOUNT = 1'000'000'000; - - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", AMOUNT)); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "SOLANA", "SOL", AMOUNT)); - - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, static_cast(AMOUNT), - sol_chain, sol_token, primary, AMOUNT, - /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_SVM, - std::vector(32, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - - // Leave the destination authex link absent while both UICs arrive. This - // deliberately parks a complete, authenticated candidate for external replay. - const auto src_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", - src_uic)); - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, sol_chain, "SOLANA", "SOL", "PRIMARY", - create_signed_uic(UWRIT_OP, ATT_ID, sol_chain, sol_token, primary))); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", - get_uwreq(ATT_ID)["status"].as_string()); - - constexpr uint64_t SEEDED_RESERVE = 1'000'000'000'000ull; - BOOST_REQUIRE_EQUAL(success(), debit_reserve_chain( - "SOLANA", "SOL", "PRIMARY", SEEDED_RESERVE - (AMOUNT - 1))); - - const auto solana_link_key = fc::crypto::private_key::generate( - fc::crypto::private_key::key_type::ed).get_public_key(); - BOOST_REQUIRE_EQUAL(success(), push( - AUTHEX_ACCOUNT, authex_abi, AUTHEX_ACCOUNT, "recordlink"_n, mvo() - ("account", UWRIT_OP) - ("chain_kind", ChainKind::CHAIN_KIND_SVM) - ("pub_key", solana_link_key) - ("native_address", std::vector(32, '\x0b')))); - produce_block(); - - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic)); - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_REJECTED", req["status"].as_string()); - const auto candidate = req["commits_by"].get_array().front(); - BOOST_REQUIRE_EQUAL("UNDERWRITE_STATUS_RELEASED", - candidate["status"].as_string()); - BOOST_REQUIRE(candidate["source_uic_bytes"].as_string().empty()); - BOOST_REQUIRE(candidate["dest_uic_bytes"].as_string().empty()); - BOOST_REQUIRE(get_lock(1).is_null()); -} FC_LOG_AND_RETHROW() } - -// createuwreq admits missing/inactive reserves for dev and smoke clusters. A -// complete candidate must keep the request PENDING while the reserve route is -// provisioned, then settle after an exact external UIC replay. -BOOST_FIXTURE_TEST_CASE(swap_missing_reserve_complete_candidate_recovers_after_provisioning_replay, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - const uint64_t secondary = fc::slug_name{"SECOND"}.value; - constexpr uint64_t ATT_ID = 8005; - constexpr uint64_t AMOUNT = 100; - - // No reserve rows are provisioned. The zero ingestion quote leaves only the - // source amount in this same-bucket candidate's authoritative bond check. - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", uint64_t{1'000'000})); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, static_cast(AMOUNT), - eth, eth, secondary, AMOUNT, - /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_EVM, - std::vector(20, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", - create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary))); - const auto dst_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, secondary); - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "SECOND", - dst_uic)); - - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", - req["status"].as_string()); - const auto candidate = req["commits_by"].get_array().front(); - BOOST_REQUIRE_EQUAL("UNDERWRITE_STATUS_INTENT_SUBMITTED", - candidate["status"].as_string()); - BOOST_REQUIRE(!candidate["source_uic_bytes"].as_string().empty()); - BOOST_REQUIRE(!candidate["dest_uic_bytes"].as_string().empty()); - BOOST_REQUIRE(get_lock(1).is_null()); - - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), regreserve_active("ETH", "ETH", "SECOND")); - // The replay has identical action data by design. Advance the test chain so - // its enclosing transaction is a new relay submission rather than the - // duplicate transaction ID of the original delivery. - produce_block(); - BOOST_REQUIRE_EQUAL(success(), rcrdcommit_direct( - ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "SECOND", dst_uic)); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", - get_uwreq(ATT_ID)["status"].as_string()); - BOOST_REQUIRE(!get_lock(1).is_null()); - BOOST_REQUIRE(!get_lock(2).is_null()); -} FC_LOG_AND_RETHROW() } - -// Positive + existing-locks coverage: a balance that covers the aggregate -// (`src_amount + quote`) must select the underwriter and write two locks -// totaling exactly that. A subsequent same-bucket swap must then see -// availability reduced by those active locks and disqualify that candidate. -// -// The destination lock is sized by the AMM QUOTE, not by the caller's -// `target_amount` (WNS-02) — hence the assertions derive from `expected_quote` -// rather than repeating the request's target. -BOOST_FIXTURE_TEST_CASE(swap_same_token_legs_exact_balance_wins, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - setup_wire_token_and_reserves(); // ETH/ETH/PRIMARY (+ SOL) - BOOST_REQUIRE_EQUAL(success(), regreserve_active("ETH", "ETH", "SECOND")); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - const uint64_t secondary = fc::slug_name{"SECOND"}.value; - constexpr uint64_t ATT_ID = 8100; - - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 200)); - - // The quote the swap will settle at, taken before any reserve moves. - const uint64_t quote = expected_quote("ETH", "ETH", "PRIMARY", /*src_amount*/ 100, - "ETH", "ETH", "SECOND", kDefaultUwritFeeBps); - BOOST_REQUIRE(quote > 0); - - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - eth, eth, secondary, /*target_amount*/ 100, - /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - // The row carries the QUOTE, never the caller's target. - BOOST_REQUIRE_EQUAL(quote, get_uwreq(ATT_ID)["dst_amount"].as_uint64()); - - const auto src_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic)); - const auto dst_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, secondary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "SECOND", dst_uic)); - - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", req["status"].as_string()); - BOOST_REQUIRE_EQUAL(UWRIT_OP.to_string(), req["winner"].as_string()); - - // Two locks, both on (ETH, ETH): the source leg at `src_amount`, the - // destination leg at the quote — together the aggregate the bond had to cover. - const auto l1 = get_lock(1); - const auto l2 = get_lock(2); - BOOST_REQUIRE(!l1.is_null()); - BOOST_REQUIRE(!l2.is_null()); - BOOST_REQUIRE_EQUAL(eth, l1["chain_code"].as().value); - BOOST_REQUIRE_EQUAL(eth, l1["token_code"].as().value); - BOOST_REQUIRE_EQUAL(eth, l2["chain_code"].as().value); - BOOST_REQUIRE_EQUAL(eth, l2["token_code"].as().value); - BOOST_REQUIRE_EQUAL(100u, l1["amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(quote, l2["amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(100u + quote, l1["amount"].as_uint64() + l2["amount"].as_uint64()); - - // Existing active locks now reserve the whole bucket (available == 0), so a - // fresh same-bucket swap cannot win yet but retains valid retryable evidence. - // Amounts must be large enough - // to price against the 1e12 reserves — a sub-quote-floor amount is rejected - // earlier by the unpriceable-reserve gate, which would mask the bond check. - constexpr uint64_t ATT_ID2 = 8101; - const auto sr2 = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - eth, eth, secondary, /*dst_amount*/ 100, - /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID2, eth, sr2)); - const auto src_uic2 = create_signed_uic(UWRIT_OP, ATT_ID2, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID2, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic2)); - const auto dst_uic2 = create_signed_uic(UWRIT_OP, ATT_ID2, eth, eth, secondary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID2, UWRIT_OP, eth, "ETH", "ETH", "SECOND", dst_uic2)); - - const auto req2 = get_uwreq(ATT_ID2); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", req2["status"].as_string()); - bool retryable = false; - for (const auto& c : req2["commits_by"].get_array()) { - if (c["underwriter"].as_string() == UWRIT_OP.to_string()) { - retryable = - c["status"].as_string() == "UNDERWRITE_STATUS_INTENT_SUBMITTED" - && c["reason"].as_string().empty() - && !c["source_uic_bytes"].as_string().empty() - && !c["dest_uic_bytes"].as_string().empty(); - } - } - BOOST_REQUIRE(retryable); -} FC_LOG_AND_RETHROW() } - -// WSA-028 closes the single-swap aggregate-overflow vector at ingress. SEC-15's -// uint128 winner-check guard (uwrit.cpp `need = src + dst`) was originally proven -// by driving src_amount to UINT64_MAX — reachable only because a signed source -// amount of -1 wrapped to UINT64_MAX. to_depot_amount now rejects that source -// before any uwreq exists, so a single swap can no longer form the overflow: the -// request reverts and creates no uwreq. The uint128 aggregate addition itself -// stays covered by swap_same_token_legs_overcommit_recovers_after_top_up / -// _exact_balance_wins. -BOOST_FIXTURE_TEST_CASE(swap_oversized_source_reverts_at_ingress, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - const uint64_t secondary = fc::slug_name{"SECOND"}.value; - constexpr uint64_t ATT_ID = 8200; - - // Maximal availability, so the revert below is provably from the oversized - // source at ingress — not from an insufficient-balance check downstream. - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "ETH", (uint64_t{1} << 62) - 1)); - - // source_amount == UINT64_MAX, encoded as -1 in the signed wire field (the - // exact pre-WSA-028 wrap). to_depot_amount rejects it via the amount <= 0 branch. - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, static_cast(~uint64_t{0}), - eth, eth, secondary, /*dst_amount*/ 1, - /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); - // Reverts at ingress (never throws) and creates no uwreq. - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - BOOST_REQUIRE(get_uwreq(ATT_ID).is_null()); -} FC_LOG_AND_RETHROW() } - -// Defence-in-depth: opreg::releaselock settles deferred slashes INLINE inside -// sysio.uwrit::chklocks at sysio.epoch::advance. If a released amount ever -// exceeds the live balance bucket, subtract_balance must NOT underflow + abort -// — that would stall epoch advancement chain-wide. releaselock clamps the -// settled amount to the live balance instead. (The aggregate winner check above -// prevents the overcommit at lock-creation time; this guards the cleanup path -// regardless.) -BOOST_FIXTURE_TEST_CASE(releaselock_clamps_overdrain_without_aborting, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - register_wire_depot(); // to-WIRE: a single (source) required leg - setup_wire_token_and_reserves(); // ACTIVE ETH/ETH/PRIMARY source reserve w/ WIRE - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t wire = fc::slug_name{"WIRE"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - constexpr uint64_t ATT_ID = 8300; - - // Bond 100 on (ETH, ETH); a to-WIRE swap locks the whole 100 (one source - // lock), so slash leaves the balance intact (slashable-now == 0). - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 100)); - - const std::string rs = UWRIT_OP.to_string(); - const std::vector rcpt(rs.begin(), rs.end()); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - wire, wire, primary, /*target*/ 50, - /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_WIRE, rcpt); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - const auto src_uic = create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic)); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", - get_uwreq(ATT_ID)["status"].as_string()); - - // Slash: locked 100 == balance 100, so nothing is debited now; status SLASHED. - BOOST_REQUIRE_EQUAL(success(), slash_op(UWRIT_OP, "test slash")); - { - const auto op = get_operator(UWRIT_OP); - BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_SLASHED", op["status"].as_string()); - BOOST_REQUIRE_EQUAL(100u, find_balance(op, "ETH", "ETH")["balance"].as_uint64()); - } - - // Two deferred releases summing to 120 > balance 100 (distinct amounts so - // the txns don't collide as duplicates). The first debits 70 (100 -> 30); - // the second would underflow 30 - 50 without the clamp, which instead settles - // only the remaining 30. Both must succeed (no abort). - BOOST_REQUIRE_EQUAL(success(), releaselock_direct(UWRIT_OP, "ETH", "ETH", 70)); - BOOST_REQUIRE_EQUAL(success(), releaselock_direct(UWRIT_OP, "ETH", "ETH", 50)); - - const auto op = get_operator(UWRIT_OP); - BOOST_REQUIRE_EQUAL(0u, find_balance(op, "ETH", "ETH")["balance"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -// SEC-77 / WSA-165: drainfwq drains at most MAX_FWQ_DRAIN_PER_EPOCH swap-from-WIRE rows per advance, -// so a caller cannot split escrowed WIRE into enough queued rows to blow the transaction CPU deadline -// advance shares with the rest of its fan-out and stall epoch progress chain-wide. The remainder stays -// queued (escrow safe in reserve custody) and drains on the next advance. This is also the first -// end-to-end coverage of drainfwq draining a populated queue: the bounded front-read FIFO loop, and -// that a second drain resumes where the first stopped. -BOOST_FIXTURE_TEST_CASE(drainfwq_bounds_rows_per_epoch, sysio_dispatch_tester) { try { - // Mirror of the contract-internal cap (contract headers are not host-compilable, same convention - // as the msgch size-cap tests). Keep in sync with sysio.uwrit.hpp::MAX_FWQ_DRAIN_PER_EPOCH. - constexpr uint32_t MAX_FWQ_DRAIN_PER_EPOCH = 32; - constexpr uint32_t N = MAX_FWQ_DRAIN_PER_EPOCH + 8; // 40 > one epoch's drain budget - constexpr uint64_t DEPOT_ORIGIN_ID_BASE = 0x8000000000000000ull; // fwqueue id = base | seq - - bootstrap_for_dispatch(); // registers the ETH (EVM) outpost + epoch machinery - setup_wire_token_and_reserves(); // sysio.token + ACTIVE public ETH/ETH/PRIMARY reserve - register_wire_depot(); // so drainfwq's depot_chain_code() resolves - // Floor lowered to 1: this case exercises the drain BOUND with 40 cheap distinct rows; the - // default 5-WIRE floor and the revert fee have their own dedicated cases below. - BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() - ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 0) - ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); - - // A funded from-WIRE swap user (plain account, no ROA policy). - create_account("swapuser"_n, config::system_account_name, /*multisig=*/false, - /*include_code=*/true, /*include_roa_policy=*/false); - BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, - "transfer"_n, mvo()("from", "sysio")("to", "swapuser") - ("quantity", "1000.000000000 WIRE")("memo", "fund swap user"))); - - // Queue N from-WIRE swaps. wire_amount varies (1e6 + i) so each is a distinct transaction AND - // prices to a positive quote (rows take drainfwq's uwreq-emplace path, not the refund path); the - // exact amount is otherwise irrelevant to the bound. target_amount + 100% tolerance keep every - // row within variance so none refund. - for (uint32_t i = 0; i < N; ++i) { - BOOST_REQUIRE_EQUAL(success(), - push(UWRIT_ACCOUNT, uwrit_abi, "swapuser"_n, "swapfromwire"_n, mvo() - ("user", "swapuser") - ("wire_amount", uint64_t{1'000'000} + i) - ("dst_chain_code", "ETH") - ("dst_token_code", "ETH") - ("dst_reserve_code", "PRIMARY") - ("target_amount", uint64_t{1'000'000}) - ("target_tolerance_bps", uint32_t{10000}) - ("recipient_kind", sysio::opp::types::ChainKind::CHAIN_KIND_EVM) - ("recipient_addr", std::vector(20, '\x0a')))); - } - - auto get_fwqueue = [&](uint64_t id) -> fc::variant { - auto data = get_row_by_id(UWRIT_ACCOUNT, UWRIT_ACCOUNT, "fwqueue"_n, id); - return data.empty() ? fc::variant() : uwrit_abi.binary_to_variant( - "fromwire_q", data, abi_serializer::create_yield_function(abi_serializer_max_time)); - }; - auto count_queued = [&]() { - uint32_t n = 0; - for (uint32_t s = 0; s < N; ++s) - if (!get_fwqueue(DEPOT_ORIGIN_ID_BASE | s).is_null()) ++n; - return n; - }; - BOOST_REQUIRE_EQUAL(N, count_queued()); - - // One drain processes exactly MAX_FWQ_DRAIN_PER_EPOCH rows; the remainder stays queued. - BOOST_REQUIRE_EQUAL(success(), - push(UWRIT_ACCOUNT, uwrit_abi, EPOCH_ACCOUNT, "drainfwq"_n, mvo())); - BOOST_REQUIRE_EQUAL(N - MAX_FWQ_DRAIN_PER_EPOCH, count_queued()); - - // Cross a block boundary so the second drain is a distinct transaction — an identical action in - // the same block is rejected as a duplicate before the contract runs, masking the guard under test. - produce_blocks(); - // The next drain resumes where the first stopped and clears the rest. - BOOST_REQUIRE_EQUAL(success(), - push(UWRIT_ACCOUNT, uwrit_abi, EPOCH_ACCOUNT, "drainfwq"_n, mvo())); - BOOST_REQUIRE_EQUAL(0u, count_queued()); -} FC_LOG_AND_RETHROW() } - -// The swapfromwire escrow floor prices fwqueue slots in locked capital: dust rows are refunded in -// full at drain, so without the floor spam rows could hold drain slots while locking nothing. The -// floor defaults to 5 WIRE and is retunable via setconfig without an upgrade. -BOOST_FIXTURE_TEST_CASE(swapfromwire_enforces_min_amount, sysio_dispatch_tester) { try { - // Mirror of the contract default (contract headers are not host-compilable). Keep in sync with - // sysio.uwrit.hpp::DEFAULT_MIN_FROMWIRE_AMOUNT. - constexpr uint64_t DEFAULT_MIN_FROMWIRE_AMOUNT = 5'000'000'000ull; // 5 WIRE @ 9 decimals - constexpr uint64_t DEPOT_ORIGIN_ID_BASE = 0x8000000000000000ull; - - bootstrap_for_dispatch(); - setup_wire_token_and_reserves(); // ACTIVE public ETH/ETH/PRIMARY destination reserve - - create_account("swapuser"_n, config::system_account_name, /*multisig=*/false, - /*include_code=*/true, /*include_roa_policy=*/false); - BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, - "transfer"_n, mvo()("from", "sysio")("to", "swapuser") - ("quantity", "100.000000000 WIRE")("memo", "fund swap user"))); - - auto swap = [&](uint64_t wire_amount) { - return push(UWRIT_ACCOUNT, uwrit_abi, "swapuser"_n, "swapfromwire"_n, mvo() - ("user", "swapuser") - ("wire_amount", wire_amount) - ("dst_chain_code", "ETH") - ("dst_token_code", "ETH") - ("dst_reserve_code", "PRIMARY") - ("target_amount", uint64_t{1'000'000}) - ("target_tolerance_bps", uint32_t{10000}) - ("recipient_kind", sysio::opp::types::ChainKind::CHAIN_KIND_EVM) - ("recipient_addr", std::vector(20, '\x0a'))); - }; - auto queued = [&](uint64_t seq) { - return !get_row_by_id(UWRIT_ACCOUNT, UWRIT_ACCOUNT, "fwqueue"_n, - DEPOT_ORIGIN_ID_BASE | seq).empty(); - }; - - // Default config (setconfig never pushed): one atom below the floor is rejected, the exact - // floor is accepted and lands in the queue. - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: swapfromwire: wire_amount below the configured minimum"), - swap(DEFAULT_MIN_FROMWIRE_AMOUNT - 1)); - BOOST_REQUIRE_EQUAL(success(), swap(DEFAULT_MIN_FROMWIRE_AMOUNT)); - BOOST_REQUIRE(queued(0)); - - // The floor is live config: lower it and the new boundary is enforced instead. - constexpr uint64_t LOWERED_FLOOR = 1'000'000; - BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() - ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", LOWERED_FLOOR)("fromwire_revert_fee_bps", 10) - ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: swapfromwire: wire_amount below the configured minimum"), - swap(LOWERED_FLOOR - 1)); - BOOST_REQUIRE_EQUAL(success(), swap(LOWERED_FLOOR)); - BOOST_REQUIRE(queued(1)); -} FC_LOG_AND_RETHROW() } - -// Caller-controlled drain-time reverts forfeit the configured revert fee: the refund returns the -// escrow minus the fee, and the fee routes through the standard `route_wire_fee` path exactly -// like a settlement fee — so revert churn pays the system instead of recycling for free. -BOOST_FIXTURE_TEST_CASE(drainfwq_charges_revert_fee_on_caller_fault, sysio_dispatch_tester) { try { - constexpr uint64_t ESCROW = 5'000'000'000ull; // the default floor exactly - constexpr uint32_t REVERT_FEE_BPS = 100; // 1% - constexpr uint64_t FEE = ESCROW * REVERT_FEE_BPS / 10000ull; // 0.05 WIRE - // A revert has no winning underwriter, so `refundwire` passes a zero underwriter share and - // the WHOLE fee becomes the rewards pool. reserv's `fee_emissions_share_bps` is never set - // here (the default 0), so that pool lands in the rewards bucket intact (batch operators); - // a configured dial would divert its share to the emissions treasury. - constexpr uint64_t REWARD_SHARE = FEE; - constexpr uint64_t DEPOT_ORIGIN_ID_0 = 0x8000000000000000ull; - const auto WIRE_SYM = symbol(9, "WIRE"); - - bootstrap_for_dispatch(); - setup_wire_token_and_reserves(); - register_wire_depot(); // depot registered => the drain reaches the variance check - BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() - ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", ESCROW)("fromwire_revert_fee_bps", REVERT_FEE_BPS) - ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); - - create_account("swapuser"_n, config::system_account_name, /*multisig=*/false, - /*include_code=*/true, /*include_roa_policy=*/false); - BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, - "transfer"_n, mvo()("from", "sysio")("to", "swapuser") - ("quantity", "10.000000000 WIRE")("memo", "fund swap user"))); - const int64_t funded = get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount(); - - // target_amount=1 with zero tolerance: the live quote against the seeded 1e12/1e12 pool is - // ~5e9, so |quote - 1| > 0 == allowed and the row reverts at drain — a failure produced - // entirely by the caller's own parameters. - BOOST_REQUIRE_EQUAL(success(), - push(UWRIT_ACCOUNT, uwrit_abi, "swapuser"_n, "swapfromwire"_n, mvo() - ("user", "swapuser") - ("wire_amount", ESCROW) - ("dst_chain_code", "ETH") - ("dst_token_code", "ETH") - ("dst_reserve_code", "PRIMARY") - ("target_amount", uint64_t{1}) - ("target_tolerance_bps", uint32_t{0}) - ("recipient_kind", sysio::opp::types::ChainKind::CHAIN_KIND_EVM) - ("recipient_addr", std::vector(20, '\x0a')))); - BOOST_REQUIRE_EQUAL(funded - static_cast(ESCROW), - get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount()); - - BOOST_REQUIRE_EQUAL(success(), - push(UWRIT_ACCOUNT, uwrit_abi, EPOCH_ACCOUNT, "drainfwq"_n, mvo())); - - // Row consumed; escrow minus the fee is now OWED to the caller; the whole fee accrued into - // the reserv rewards bucket (custody-internal — no transfer leaves reserv for a fee). - // - // The refund is credited, not pushed. drainfwq runs inline from sysio.epoch::advance, - // where a recipient's transfer-notify handler could otherwise abort the whole advance -- and - // because the queue-row erase rolls back with it, that block would be permanent. Pulling the - // credit via claimwire produces the original balance. - BOOST_REQUIRE(get_row_by_id(UWRIT_ACCOUNT, UWRIT_ACCOUNT, "fwqueue"_n, DEPOT_ORIGIN_ID_0).empty()); - BOOST_REQUIRE_EQUAL(success(), - push(RESERV_ACCOUNT, reserv_abi, "swapuser"_n, "claimwire"_n, mvo()("account", "swapuser"))); - BOOST_REQUIRE_EQUAL(funded - static_cast(FEE), - get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount()); - auto bkt_data = get_row_by_account(RESERV_ACCOUNT, RESERV_ACCOUNT, "rewardbkt"_n, "rewardbkt"_n); - BOOST_REQUIRE(!bkt_data.empty()); - auto bkt = reserv_abi.binary_to_variant("rewards_bucket", bkt_data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - BOOST_REQUIRE_EQUAL(REWARD_SHARE, bkt["balance"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -// Reverts caused by system state changes after enqueue refund in full even with a nonzero revert -// fee configured — the caller did nothing wrong. Exercised via the cheapest system-fault branch: -// no WIRE depot chain registered at drain time. -BOOST_FIXTURE_TEST_CASE(drainfwq_full_refund_on_system_caused_revert, sysio_dispatch_tester) { try { - constexpr uint64_t ESCROW = 5'000'000'000ull; - constexpr uint64_t DEPOT_ORIGIN_ID_0 = 0x8000000000000000ull; - const auto WIRE_SYM = symbol(9, "WIRE"); - - bootstrap_for_dispatch(); - setup_wire_token_and_reserves(); - // register_wire_depot() deliberately NOT called: drainfwq's depot_chain_code() comes back - // empty, which is a system-caused revert (the registry, not the caller's parameters). - BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() - ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", ESCROW)("fromwire_revert_fee_bps", 100) - ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); - - create_account("swapuser"_n, config::system_account_name, /*multisig=*/false, - /*include_code=*/true, /*include_roa_policy=*/false); - BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, - "transfer"_n, mvo()("from", "sysio")("to", "swapuser") - ("quantity", "10.000000000 WIRE")("memo", "fund swap user"))); - const int64_t funded = get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount(); - - BOOST_REQUIRE_EQUAL(success(), - push(UWRIT_ACCOUNT, uwrit_abi, "swapuser"_n, "swapfromwire"_n, mvo() - ("user", "swapuser") - ("wire_amount", ESCROW) - ("dst_chain_code", "ETH") - ("dst_token_code", "ETH") - ("dst_reserve_code", "PRIMARY") - ("target_amount", uint64_t{1'000'000}) - ("target_tolerance_bps", uint32_t{10000}) - ("recipient_kind", sysio::opp::types::ChainKind::CHAIN_KIND_EVM) - ("recipient_addr", std::vector(20, '\x0a')))); - - BOOST_REQUIRE_EQUAL(success(), - push(UWRIT_ACCOUNT, uwrit_abi, EPOCH_ACCOUNT, "drainfwq"_n, mvo())); - - // Full escrow returned — no fee — and no rewards-bucket accrual. Credited rather than pushed - // (credited, not pushed), so the caller pulls it with claimwire. - BOOST_REQUIRE(get_row_by_id(UWRIT_ACCOUNT, UWRIT_ACCOUNT, "fwqueue"_n, DEPOT_ORIGIN_ID_0).empty()); - BOOST_REQUIRE_EQUAL(success(), - push(RESERV_ACCOUNT, reserv_abi, "swapuser"_n, "claimwire"_n, mvo()("account", "swapuser"))); - BOOST_REQUIRE_EQUAL(funded, - get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount()); - BOOST_REQUIRE(get_row_by_account(RESERV_ACCOUNT, RESERV_ACCOUNT, - "rewardbkt"_n, "rewardbkt"_n).empty()); -} FC_LOG_AND_RETHROW() } - -// [P0] A swap-from-WIRE user cannot halt the epoch drain by refusing its own refund. -// -// This was the cheapest and most durable chain-halt in the protocol. `sysio.token::transfer` -// notifies `to`, and the chain runs notified receivers with no exception isolation, so a recipient -// asserting in its transfer-notify handler aborts the WHOLE transaction. `refundwire` is inlined -// from the never-throw `drainfwq` drain, which is itself inlined from `sysio.epoch::advance` -- -// so the abort took down epoch advancement chain-wide. Worse, the drain's `q.erase()` rolled back -// with it, leaving the offending queue row in place to re-block every later epoch. Cost to the -// attacker: one `min_fromwire_amount` escrow, once. -// -// `refundwire` now credits `wireclaims` and transfers nothing, so no recipient handler runs on the -// drain path. The drain completes, the row is consumed, and the hostile user is merely owed money -// it cannot collect. -BOOST_FIXTURE_TEST_CASE(blocking_refund_recipient_cannot_stall_drainfwq, sysio_dispatch_tester) { try { - constexpr uint64_t ESCROW = 5'000'000'000ull; - constexpr uint64_t DEPOT_ORIGIN_ID_0 = 0x8000000000000000ull; - const auto WIRE_SYM = symbol(9, "WIRE"); - - bootstrap_for_dispatch(); - setup_wire_token_and_reserves(); - // No depot chain registered -> the drain takes the full-refund (system-fault) branch. - BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() - ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", ESCROW)("fromwire_revert_fee_bps", 100) - ("uwreq_pending_timeout_epochs", 10)("uwreq_retention_epochs", 10))); - - create_account("swapuser"_n, config::system_account_name, /*multisig=*/false, - /*include_code=*/true, /*include_roa_policy=*/false); - BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, - "transfer"_n, mvo()("from", "sysio")("to", "swapuser") - ("quantity", "10.000000000 WIRE")("memo", "fund swap user"))); - - // Queue the swap FIRST: the escrow leg is an outgoing transfer, and arming the blocker before - // it would stop the setup rather than the path under test. - BOOST_REQUIRE_EQUAL(success(), - push(UWRIT_ACCOUNT, uwrit_abi, "swapuser"_n, "swapfromwire"_n, mvo() - ("user", "swapuser") - ("wire_amount", ESCROW) - ("dst_chain_code", "ETH") - ("dst_token_code", "ETH") - ("dst_reserve_code", "PRIMARY") - ("target_amount", uint64_t{1'000'000}) - ("target_tolerance_bps", uint32_t{10000}) - ("recipient_kind", sysio::opp::types::ChainKind::CHAIN_KIND_EVM) - ("recipient_addr", std::vector(20, '\x0a')))); - const int64_t escrowed_bal = get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount(); - - // Arm the blocker: every INCOMING transfer to swapuser now asserts. - control->get_mutable_resource_limits_manager() - .set_account_limits("swapuser"_n, 1024 * 1024, -1, -1, false); - produce_blocks(); - set_code("swapuser"_n, contracts::util::block_transfer_wasm()); - set_abi("swapuser"_n, contracts::util::block_transfer_abi().data()); - produce_blocks(); - - // The drain must still complete. Before this change it aborted, taking advance() with it. - BOOST_REQUIRE_EQUAL(success(), - push(UWRIT_ACCOUNT, uwrit_abi, EPOCH_ACCOUNT, "drainfwq"_n, mvo())); - - // The queue row is consumed for good -- this is what made the old failure permanent, since the - // erase rolled back with the aborted transaction and the row re-blocked every later epoch. - BOOST_REQUIRE(get_row_by_id(UWRIT_ACCOUNT, UWRIT_ACCOUNT, "fwqueue"_n, DEPOT_ORIGIN_ID_0).empty()); - - // The refund is owed, not paid: the escrow has not returned to the blocker's balance. - BOOST_REQUIRE_EQUAL(escrowed_bal, - get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount()); - - // And the blocker can only block itself: its own claim is what its handler rejects. - auto r = push(RESERV_ACCOUNT, reserv_abi, "swapuser"_n, "claimwire"_n, mvo()("account", "swapuser")); - BOOST_REQUIRE_MESSAGE(r != success(), "blocking user unexpectedly claimed its own refund"); - BOOST_REQUIRE_MESSAGE(r.find("block_transfer: rejecting incoming transfer") != std::string::npos, - "unexpected failure reason: " + r); - - // A second drain is a clean no-op -- the queue really is empty, so epochs keep advancing. - // Advance a block first: an identical action replayed in the same block is rejected as a - // duplicate transaction before the contract runs, which would assert nothing. - produce_blocks(); - BOOST_REQUIRE_EQUAL(success(), - push(UWRIT_ACCOUNT, uwrit_abi, EPOCH_ACCOUNT, "drainfwq"_n, mvo())); -} FC_LOG_AND_RETHROW() } - -// ═════════════════════════════════════════════════════════════════════════ -// SEC-129 / WSA-223 — UWREQ lifecycle: expiry is enforced by triggers. -// -// `expires_at_epoch` is passive row data; the `pruneuwreqs` sweep inlined in -// every real `sysio.epoch::advance` is the only thing that evaluates it. The -// cases below drive genuine advances (emissions gate satisfied) and assert -// the full lifecycle: PENDING deadline → EXPIRED + refund + compaction → -// retention → erased; CONFIRMED → COMPLETED (chklocks) → retention → erased; -// the per-epoch budget; and the rcrdcommit row-growth rails. -// ═════════════════════════════════════════════════════════════════════════ - -// A PENDING uwreq whose underwriter race never resolves survives pre-deadline -// advances untouched, is expired by the first advance at/past its deadline -// (EXPIRED + payload compaction), and is erased once retention elapses. -BOOST_FIXTURE_TEST_CASE(uwreq_pending_timeout_expires_then_retention_erases, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - // Pending timeout 2 epochs + retention 1 epoch — the shortest schedule - // that still proves the pre-deadline advance is a no-op for the row. - BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() - ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 10) - ("uwreq_pending_timeout_epochs", 2)("uwreq_retention_epochs", 1))); - constexpr uint64_t ATT_ID = 9100; - setup_eth_to_sol_uwreq(ATT_ID); // PENDING, no commits, deadline = 0 + 2 - enable_epoch_advancement(); - - { - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL(2u, req["expires_at_epoch"].as()); - BOOST_REQUIRE(req["attestation_inbound_data"].as_string().size() > 0); - } - - // Epoch 1 < deadline 2 — the row is inert data; the sweep leaves it alone. - age_one_epoch(); - BOOST_REQUIRE_EQUAL(1u, current_epoch()); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", - get_uwreq(ATT_ID)["status"].as_string()); - - // Epoch 2 == deadline — the same advance's inline sweep expires it. - age_one_epoch(); - BOOST_REQUIRE_EQUAL(2u, current_epoch()); - { - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE(!req.is_null()); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_EXPIRED", req["status"].as_string()); - BOOST_REQUIRE(req["settled_at_ms"].as_uint64() > 0); - // Terminal compaction: the inbound attestation copy is gone; the - // compact audit metadata (codes, amounts, timestamps) is retained. - BOOST_REQUIRE_EQUAL(0u, req["attestation_inbound_data"].as_string().size()); - // Retention window re-stamped: epoch 2 + 1. - BOOST_REQUIRE_EQUAL(3u, req["expires_at_epoch"].as()); - } - - // Epoch 3 — retention elapsed; the row is erased outright. - age_one_epoch(); - BOOST_REQUIRE_EQUAL(3u, current_epoch()); - BOOST_REQUIRE(get_uwreq(ATT_ID).is_null()); -} FC_LOG_AND_RETHROW() } - -// A queued swap-from-WIRE whose race never resolves refunds the user's FULL -// escrow at expiry (expiry is not a caller-controlled revert cause — no -// revert fee) and follows the same EXPIRED → retention → erased lifecycle. -BOOST_FIXTURE_TEST_CASE(uwreq_from_wire_pending_timeout_refunds_escrow, - sysio_dispatch_tester) { try { - constexpr uint64_t DEPOT_ORIGIN_ID_0 = 0x8000000000000000ull; - constexpr uint64_t ESCROW = 1'000'000; - const auto WIRE_SYM = symbol(9, "WIRE"); - - bootstrap_for_dispatch(); - setup_wire_token_and_reserves(); - register_wire_depot(); - // Non-zero revert fee configured on purpose: the full refund below proves - // the expiry path is fee-exempt even when a fee is configured. - BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() - ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 100) - ("uwreq_pending_timeout_epochs", 1)("uwreq_retention_epochs", 1))); - - // Created + funded before the full system contract lands on `sysio`, the - // same way every other from-WIRE case provisions its user. - create_account("swapuser"_n, config::system_account_name, /*multisig=*/false, - /*include_code=*/true, /*include_roa_policy=*/false); - BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, - "transfer"_n, mvo()("from", "sysio")("to", "swapuser") - ("quantity", "10.000000000 WIRE")("memo", "fund swap user"))); - const int64_t funded = - get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount(); - - enable_epoch_advancement(); - - // target == wire_amount with 100% tolerance prices within variance against - // the balanced 1e12/1e12 reserve, so the drain emplaces the uwreq (no - // refund at drain) — same recipe as drainfwq_bounds_rows_per_epoch. - BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, "swapuser"_n, - "swapfromwire"_n, mvo() - ("user", "swapuser") - ("wire_amount", ESCROW) - ("dst_chain_code", "ETH") - ("dst_token_code", "ETH") - ("dst_reserve_code", "PRIMARY") - ("target_amount", uint64_t{1'000'000}) - ("target_tolerance_bps", uint32_t{10000}) - ("recipient_kind", ChainKind::CHAIN_KIND_EVM) - ("recipient_addr", std::vector(20, '\x0a')))); - BOOST_REQUIRE_EQUAL(funded - int64_t(ESCROW), - get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount()); - - // Advance #1: drainfwq (inline, post-increment) creates the PENDING uwreq - // at epoch 1 with deadline 1 + 1 = 2. - age_one_epoch(); - { - const auto req = get_uwreq(DEPOT_ORIGIN_ID_0); - BOOST_REQUIRE(!req.is_null()); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_PENDING", req["status"].as_string()); - BOOST_REQUIRE_EQUAL(2u, req["expires_at_epoch"].as()); - } - - // Advance #2 (epoch 2 == deadline): the sweep expires the row and refunds - // the FULL escrow via reserv::refundwire. - age_one_epoch(); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_EXPIRED", - get_uwreq(DEPOT_ORIGIN_ID_0)["status"].as_string()); - // Credited rather than pushed — the expiry sweep runs on the never-throw advance - // path, so the escrow is owed until the user pulls it. - BOOST_REQUIRE_EQUAL(success(), - push(RESERV_ACCOUNT, reserv_abi, "swapuser"_n, "claimwire"_n, mvo()("account", "swapuser"))); - BOOST_REQUIRE_EQUAL(funded, - get_currency_balance(TOKEN_ACCOUNT, WIRE_SYM, "swapuser"_n).get_amount()); - - // Advance #3 (epoch 3): retention elapsed — erased. - age_one_epoch(); - BOOST_REQUIRE(get_uwreq(DEPOT_ORIGIN_ID_0).is_null()); -} FC_LOG_AND_RETHROW() } - -// A settled swap's row is compacted at COMPLETED (chklocks closes the -// challenge window) and erased once retention elapses — the full happy-path -// lifecycle driven end to end by real advances. Winner selection itself -// zeroes the deadline (the lock window owns a CONFIRMED row). -BOOST_FIXTURE_TEST_CASE(uwreq_completed_row_erased_after_retention, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), regreserve_active("ETH", "ETH", "SECOND")); - // 60s challenge window (== one epoch) + 1-epoch retention; the pending - // timeout stays clear of the race so only the lock machinery drives it. - BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() - ("fee_bps", 10)("collateral_lock_duration_ms", 60'000u) - ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 10) - ("uwreq_pending_timeout_epochs", 5)("uwreq_retention_epochs", 1))); - enable_epoch_advancement(); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - const uint64_t secondary = fc::slug_name{"SECOND"}.value; - constexpr uint64_t ATT_ID = 9200; - - // Same-chain double-leg winner recipe as swap_same_token_legs_exact_balance_wins. - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 200)); - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ 100, - eth, eth, secondary, /*dst_amount*/ 100, - /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(ATT_ID, eth, sr)); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", - create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, primary))); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "SECOND", - create_signed_uic(UWRIT_OP, ATT_ID, eth, eth, secondary))); - { - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", req["status"].as_string()); - // Winner selection cleared the deadline — chklocks owns the row now. - BOOST_REQUIRE_EQUAL(0u, req["expires_at_epoch"].as()); - } - - // Advance #1 (62s elapsed > the 60s lock window): chklocks sweeps both - // locks, flips COMPLETED, stamps retention 1 + 1 = 2, and clears the - // remaining heavy payloads. The same advance's sweep (epoch 1 < 2) must - // NOT touch the freshly-completed row. - age_one_epoch(); - { - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE(!req.is_null()); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_COMPLETED", req["status"].as_string()); - BOOST_REQUIRE_EQUAL(2u, req["expires_at_epoch"].as()); - BOOST_REQUIRE_EQUAL(0u, req["attestation_inbound_data"].as_string().size()); - for (const auto& c : req["commits_by"].get_array()) { - BOOST_REQUIRE_EQUAL(0u, c["source_uic_bytes"].as_string().size()); - BOOST_REQUIRE_EQUAL(0u, c["dest_uic_bytes"].as_string().size()); - } - } - - // Advance #2 (epoch 2): retention elapsed — erased. Steady state: the - // table carries nothing from a fully-settled swap. - age_one_epoch(); - BOOST_REQUIRE(get_uwreq(ATT_ID).is_null()); -} FC_LOG_AND_RETHROW() } - -// The per-epoch budget bounds the sweep's work: with more due rows than -// MAX_UWREQ_PRUNE_PER_EPOCH (32), one advance handles exactly 32 and the -// backlog drains the next epoch — advance's CPU stays bounded, nothing is -// lost, nothing is double-handled. -BOOST_FIXTURE_TEST_CASE(pruneuwreqs_budget_bounds_rows_per_epoch, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - // Retention kept long (100) so the second advance only expires the - // leftover PENDING row instead of competing with 32 fresh erases. - BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "setconfig"_n, mvo() - ("fee_bps", 10)("collateral_lock_duration_ms", 120'000u) - ("min_fromwire_amount", 1)("fromwire_revert_fee_bps", 10) - ("uwreq_pending_timeout_epochs", 1)("uwreq_retention_epochs", 100))); - constexpr uint64_t FIRST_ATT_ID = 9300; - constexpr uint32_t ROWS = 33; // MAX_UWREQ_PRUNE_PER_EPOCH + 1 - setup_eth_to_sol_uwreq(FIRST_ATT_ID); // registers SOLANA + reserves once - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t sol_chain = fc::slug_name{"SOLANA"}.value; - const uint64_t sol_token = fc::slug_name{"SOL"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - for (uint32_t i = 1; i < ROWS; ++i) { - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, 100, sol_chain, sol_token, primary, 100, - 5000, ChainKind::CHAIN_KIND_SVM, std::vector(32, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(FIRST_ATT_ID + i, eth, sr)); - if (i % 8 == 7) produce_blocks(); // spread across blocks (per-block CPU) - } - produce_blocks(); - enable_epoch_advancement(); - - // Advance #1 (epoch 1 == every row's deadline): exactly the budgeted 32 - // rows are expired; one stays PENDING for the next epoch. - age_one_epoch(); - uint32_t expired = 0, pending = 0; - for (uint32_t i = 0; i < ROWS; ++i) { - const auto req = get_uwreq(FIRST_ATT_ID + i); - BOOST_REQUIRE(!req.is_null()); - const auto st = req["status"].as_string(); - if (st == "UNDERWRITE_REQUEST_STATUS_EXPIRED") ++expired; - else if (st == "UNDERWRITE_REQUEST_STATUS_PENDING") ++pending; - } - BOOST_REQUIRE_EQUAL(32u, expired); - BOOST_REQUIRE_EQUAL(1u, pending); - - // Advance #2 drains the backlog: every row is now EXPIRED. - age_one_epoch(); - expired = 0; - for (uint32_t i = 0; i < ROWS; ++i) { - const auto req = get_uwreq(FIRST_ATT_ID + i); - BOOST_REQUIRE(!req.is_null()); - if (req["status"].as_string() == "UNDERWRITE_REQUEST_STATUS_EXPIRED") ++expired; - } - BOOST_REQUIRE_EQUAL(ROWS, expired); -} FC_LOG_AND_RETHROW() } - -// ── rcrdcommit row-growth rails (SEC-129 / WSA-223) ── - -// An oversized UIC payload is refused at the door: no candidate entry, no -// stored bytes, no throw. At the cap, the commit records normally. -BOOST_FIXTURE_TEST_CASE(rcrdcommit_oversized_uic_leg_is_dropped, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - constexpr uint64_t ATT_ID = 9400; - setup_eth_to_sol_uwreq(ATT_ID); - const uint64_t eth = fc::slug_name{"ETH"}.value; - - // One byte past MAX_UIC_LEG_BYTES (2048) — validly signed, then dropped by - // the row-size boundary with no mutation. - const auto oversized_uic = create_signed_uic_of_size( - UWRIT_OP, ATT_ID, eth, eth, fc::slug_name{"PRIMARY"}.value, 2049); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", - oversized_uic)); - BOOST_REQUIRE_EQUAL(0u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); - - // At the cap the valid commit records normally. - const auto capped_uic = create_signed_uic_of_size( - UWRIT_OP, ATT_ID, eth, eth, fc::slug_name{"PRIMARY"}.value, 2048); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", - capped_uic)); - BOOST_REQUIRE_EQUAL(1u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); -} FC_LOG_AND_RETHROW() } - -// The candidate roster is capped at MAX_UWREQ_CANDIDATES (32): the 33rd -// distinct ACTIVE underwriter is refused with no row growth, while an -// existing candidate can still supply its missing leg at the cap. -BOOST_FIXTURE_TEST_CASE(rcrdcommit_candidate_cap_bounds_row, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - constexpr uint64_t ATT_ID = 9500; - setup_eth_to_sol_uwreq(ATT_ID); - const uint64_t eth = fc::slug_name{"ETH"}.value; - - // Provision 33 ACTIVE underwriters (register + meet the 1-unit ETH/ETH - // minimum from bootstrap_for_dispatch's opreg config). UWRIT_OP stays out - // of this roster. - std::vector uws; - for (uint32_t i = 0; i < 33; ++i) { - std::string s = "uwcap"; - s += static_cast('a' + i / 26); - s += static_cast('a' + i % 26); - uws.emplace_back(s); - } - create_accounts(uws); - produce_blocks(); - // Blocks are produced along the way — 33 registrations + credits + 33 - // commits in one block would exhaust the per-block billable CPU. - for (uint32_t i = 0; i < uws.size(); ++i) { - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, - "regoperator"_n, mvo() - ("account", uws[i].to_string()) - ("type", OperatorType::OPERATOR_TYPE_UNDERWRITER) - ("is_bootstrapped", false))); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(uws[i], "ETH", "ETH", 1'000)); - if (i % 8 == 7) produce_blocks(); - } - produce_blocks(); - - // 32 distinct candidates record; the 33rd is refused at the cap before its - // deliberately malformed signature can reach protobuf/signature work. - for (uint32_t i = 0; i < 32; ++i) { - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, uws[i], eth, "ETH", "ETH", "PRIMARY", - create_signed_uic( - uws[i], ATT_ID, eth, eth, - fc::slug_name{"PRIMARY"}.value))); - if (i % 8 == 7) produce_blocks(); - } - BOOST_REQUIRE_EQUAL(32u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); - produce_blocks(); - const auto capped_trace = rcrdcommit_trace( - ATT_ID, uws[32], eth, "ETH", "ETH", "PRIMARY", - create_uic_with_signature( - uws[32], ATT_ID, eth, eth, fc::slug_name{"PRIMARY"}.value, - {packed_uic_signature_tag(fc::crypto::signature::sig_type::k1)})); - BOOST_REQUIRE(!capped_trace->except); - const auto& capped_console = capped_trace->action_traces.front().console; - BOOST_CHECK_NE(std::string::npos, capped_console.find("cap reached")); - BOOST_CHECK_EQUAL(std::string::npos, - capped_console.find("UIC_SIGNATURE_REJECTED")); - { - const auto req = get_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL(32u, req["commits_by"].get_array().size()); - for (const auto& c : req["commits_by"].get_array()) { - BOOST_REQUIRE(c["underwriter"].as_string() != uws[32].to_string()); - } - } - - // An EXISTING candidate can still supply its missing destination leg at the - // cap. It reaches normal validation/winner handling rather than the roster rail. - const auto sol_chain = fc::slug_name{"SOLANA"}.value; - const auto sol_token = fc::slug_name{"SOL"}.value; - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(ATT_ID, uws[0], sol_chain, "SOLANA", "SOL", "PRIMARY", - create_signed_uic( - uws[0], ATT_ID, sol_chain, sol_token, - fc::slug_name{"PRIMARY"}.value))); - BOOST_REQUIRE_EQUAL(32u, get_uwreq(ATT_ID)["commits_by"].get_array().size()); -} FC_LOG_AND_RETHROW() } - -// ═══════════════════════════════════════════════════════════════════════════ -// Underwriter-fault challenge (WIRE-297) — openuwchal / voteuwchal / -// chkuwchal / uwchalbond + the uwrit lock-hold trio -// ═══════════════════════════════════════════════════════════════════════════ - -/// The dispatch stack + a deployed `sysio.chalg`, a Tier-1 electorate of three, and a -/// WIRE-funded challenger. The confirmed-uwreq builder mirrors -/// `swap_same_token_legs_exact_balance_wins` exactly: two same-chain legs of LEG_AMOUNT -/// against the 1e12/1e12 cw-5000 books, so the winner holds two (ETH, ETH) locks — the source -/// leg at LEG_AMOUNT and the destination leg at the AMM quote (swaps settle on the quote, never -/// on the caller's target). Nothing here pins either amount: the bond math reads the live lock -/// rows, so the fixture tracks whatever the curve prices. -class sysio_uwchal_tester : public sysio_dispatch_tester { -public: - static constexpr auto CHALLENGER = "challenger"_n; - static constexpr auto VOTER1 = "voter1"_n; - static constexpr auto VOTER2 = "voter2"_n; - static constexpr auto VOTER3 = "voter3"_n; - static constexpr auto VOTER4 = "voter4"_n; - - /// Ballot wire values (the uint8 `uwchal_ballot` members). - static constexpr uint8_t BALLOT_UPHOLD = 0; - static constexpr uint8_t BALLOT_REJECT_REFUND = 1; - static constexpr uint8_t BALLOT_REJECT_FORFEIT = 2; - /// Fault-reason wire value (`underwrite_fault_reason::SOURCE_DEPOSIT_MISSING`). - static constexpr uint8_t REASON_DEPOSIT_MISSING = 0; - - /// Mirrors `chalg::min_uwchal_bucket_wire` — the floor a nonzero collateral bucket contributes - /// when the depot cannot price it (no ACTIVE reserve for the pair, or dust that the books floor - /// to zero). Keeps the quote nonzero so the commitment stays challengeable. - static constexpr uint64_t MIN_UWCHAL_BUCKET_WIRE = 1; - - /// Source amount (and requested target) the confirmed-uwreq builder swaps. The SOURCE leg - /// locks exactly this; the destination leg locks the AMM quote for it, which the curve puts - /// just under it — so the two locks together stay inside the 200 credited to the underwriter. - static constexpr uint64_t LEG_AMOUNT = 100; - - abi_serializer chalg_abi; - - sysio_uwchal_tester() { - // CHALG_ACCOUNT exists (base fixture creates it); it just never had code until now. - deploy(CHALG_ACCOUNT, contracts::chalg_wasm(), contracts::chalg_abi(), chalg_abi); - - // Tier-1 voters (no roa policy — same shape as the dispute tester's electorate) and the - // bond-posting challenger. - for (auto v : {VOTER1, VOTER2, VOTER3, VOTER4, CHALLENGER}) { - create_account(v, config::system_account_name, /*multisig=*/false, - /*include_code=*/true, /*include_roa_policy=*/false); - } - produce_blocks(); - - // T1 electorate rows in sysio.roa (gen 0). `forcereg` works pre-emitcfg — the electorate - // snapshot walks `nodeowners` directly, never `nodecount`. Genesis already carries ONE T1 - // owner, so the snapshot is these four + it: N = 5, Q = 3 — every quorum path below casts - // three ballots, and the tie case splits four rejectors 2–2. - for (auto v : {VOTER1, VOTER2, VOTER3, VOTER4}) { - BOOST_REQUIRE_EQUAL(success(), push(ROA_ACCOUNT, roa_abi, ROA_ACCOUNT, "forcereg"_n, - mvo()("owner", v.to_string())("tier", 1))); - } - produce_blocks(); - } - - // ── setup: the challenged commitment ───────────────────────────────────── - - /// Full path to a CONFIRMED uwreq with two live (ETH, ETH) locks — the source leg at - /// LEG_AMOUNT, the destination leg at the AMM quote — byte-for-byte the - /// `swap_same_token_legs_exact_balance_wins` recipe. - void make_confirmed_uwreq(uint64_t att_id) { - bootstrap_for_dispatch(); - setup_wire_token_and_reserves(); - BOOST_REQUIRE_EQUAL(success(), regreserve_active("ETH", "ETH", "SECOND")); - - // The challenger's bond funding comes from the WIRE treasury seeded above. - BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, - "transfer"_n, mvo()("from", "sysio")("to", CHALLENGER.to_string()) - ("quantity", "100.000000000 WIRE")("memo", "challenge bond funding"))); - - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - const uint64_t secondary = fc::slug_name{"SECOND"}.value; - - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 200)); - - add_confirmed_uwreq(att_id); - } - - /// The uwreq half of `make_confirmed_uwreq`, without the one-time bootstrap: creates one more - /// CONFIRMED (ETH,ETH) commitment and its two locks on an already-provisioned cluster. Callers - /// wanting a second commitment must first credit its collateral — the two legs lock - /// `2 * LEG_AMOUNT`, and the bootstrap deposits only enough for one. - void add_confirmed_uwreq(uint64_t att_id) { - const uint64_t eth = fc::slug_name{"ETH"}.value; - const uint64_t primary = fc::slug_name{"PRIMARY"}.value; - const uint64_t secondary = fc::slug_name{"SECOND"}.value; - - const auto sr = encode_swap_request( - ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0a'), - eth, eth, primary, /*src_amount*/ LEG_AMOUNT, - eth, eth, secondary, /*dst_amount*/ LEG_AMOUNT, - /*tolerance_bps*/ 1'000'000, ChainKind::CHAIN_KIND_EVM, std::vector(20, '\x0b')); - BOOST_REQUIRE_EQUAL(success(), createuwreq_direct(att_id, eth, sr)); - - const auto src_uic = create_signed_uic(UWRIT_OP, att_id, eth, eth, primary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(att_id, UWRIT_OP, eth, "ETH", "ETH", "PRIMARY", src_uic)); - const auto dst_uic = create_signed_uic(UWRIT_OP, att_id, eth, eth, secondary); - BOOST_REQUIRE_EQUAL(success(), - rcrdcommit_direct(att_id, UWRIT_OP, eth, "ETH", "ETH", "SECOND", dst_uic)); - - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_CONFIRMED", - get_uwreq(att_id)["status"].as_string()); - } - - /// Walk `sysio.reserv::reserves` (checksum256-keyed, so `get_row_by_id` cannot address it) - /// and return the row matching the slug triple — the reserv tests' scan workaround. Takes the - /// raw slug VALUES so a lock row's fields feed straight in. - fc::variant find_reserve(uint64_t target_chain, uint64_t target_token, - uint64_t target_reserve) { - const auto& db = control->db(); - const auto table_id = chain::compute_table_id("reserves"_n.to_uint64_t()); - const auto& kv_idx = db.get_index(); - auto itr = kv_idx.lower_bound(boost::make_tuple(RESERV_ACCOUNT, table_id, std::string_view{})); - for (; itr != kv_idx.end() && itr->code == RESERV_ACCOUNT && itr->table_id == table_id; ++itr) { - std::vector raw(itr->value.size()); - if (!raw.empty()) - std::memcpy(raw.data(), itr->value.data(), raw.size()); - try { - auto row = reserv_abi.binary_to_variant( - "reserve_row", raw, abi_serializer::create_yield_function(abi_serializer_max_time)); - if (row["chain_code"].as().value == target_chain && - row["token_code"].as().value == target_token && - row["reserve_code"].as().value == target_reserve) { - return row; - } - } catch (...) { - // skip rows that don't decode - } - } - return fc::variant(); - } - - /// What `uwchalbond`/`openuwchal` must price: the underwriter's ENTIRE collateral, each - /// bucket quoted to WIRE on its pair's LIVE books and summed (Jonathan, 2026-08-11) — what - /// an upheld verdict actually costs them. Live books, not the registration constants: - /// winner selection settles inline, so by challenge time `applyswap` has already moved both - /// sides. Recomputing on the host over the same rows pins the contract to the shared kernel: - /// right reserve per bucket, right field order, and the sum. - uint64_t expected_bond(name underwriter) { - const auto op = get_operator(underwriter); - BOOST_REQUIRE(!op.is_null()); - uint64_t total = 0; - for (const auto& bal : op["balances"].get_array()) { - const uint64_t amount = bal["balance"].as_uint64(); - if (amount == 0) continue; - const auto chain = bal["chain_code"].as().value, - token = bal["token_code"].as().value; - const uint64_t wire = fc::slug_name{"WIRE"}.value; - if (chain == wire && token == wire) { - total += amount; // already WIRE — no curve - continue; - } - // A bucket with no ACTIVE reserve, or one whose books floor the conversion to zero, - // contributes the contract's floor rather than voiding the quote — mirrors - // `chalg::min_uwchal_bucket_wire`. - const auto row = first_active_reserve(chain, token); - const uint64_t bucket_wire = - row.is_null() ? 0 - : sysio::opp::amm::token_to_wire(row["reserve_chain_amount"].as_uint64(), - row["reserve_wire_amount"].as_uint64(), - row["connector_weight_bps"].as_uint64(), - amount); - total += (bucket_wire > 0 ? bucket_wire : MIN_UWCHAL_BUCKET_WIRE); - } - return total; - } - - /// The FIRST ACTIVE reserve for a (chain, token) pair — the row the contract's `bychaintok` - /// walk lands on when pricing a collateral bucket that names no reserve of its own. - fc::variant first_active_reserve(uint64_t target_chain, uint64_t target_token) { - const auto& db = control->db(); - const auto table_id = chain::compute_table_id("reserves"_n.to_uint64_t()); - const auto& kv_idx = db.get_index(); - auto itr = kv_idx.lower_bound(boost::make_tuple(RESERV_ACCOUNT, table_id, std::string_view{})); - for (; itr != kv_idx.end() && itr->code == RESERV_ACCOUNT - && itr->table_id == table_id; ++itr) { - std::vector raw(itr->value.size()); - if (!raw.empty()) std::memcpy(raw.data(), itr->value.data(), raw.size()); - try { - auto row = reserv_abi.binary_to_variant("reserve_row", raw, - abi_serializer::create_yield_function(abi_serializer_max_time)); - if (row["chain_code"].as().value == target_chain && - row["token_code"].as().value == target_token && - row["status"].as_string() == "RESERVE_STATUS_ACTIVE") { - return row; - } - } catch (...) { - // Not a reserve_row — skip. - } - } - return fc::variant(); - } - - // ── chalg action wrappers ──────────────────────────────────────────────── - - action_result openuwchal(name challenger, uint64_t uwreq_id, name underwriter, - uint8_t reason, const std::string& detail) { - return push(CHALG_ACCOUNT, chalg_abi, challenger, "openuwchal"_n, mvo() - ("challenger", challenger.to_string())("uwreq_id", uwreq_id) - ("underwriter", underwriter.to_string())("reason", reason)("detail", detail)); - } - - action_result voteuwchal(name owner, uint64_t chal_id, uint8_t ballot) { - return push(CHALG_ACCOUNT, chalg_abi, owner, "voteuwchal"_n, mvo() - ("owner", owner.to_string())("chal_id", chal_id)("ballot", ballot)); - } - - action_result chkuwchal(uint64_t chal_id, name signer = CHALLENGER) { - return push(CHALG_ACCOUNT, chalg_abi, signer, "chkuwchal"_n, mvo()("chal_id", chal_id)); - } - - /// Pull a resolved challenge's bond out of chalg custody, signed by the recipient itself. - action_result claimbond(name account, name signer = name()) { - return push(CHALG_ACCOUNT, chalg_abi, signer == name() ? account : signer, "claimbond"_n, - mvo()("account", account.to_string())); - } - - /// Decoded return of the read-only bond quote. Seals a block first: tests re-quote the SAME - /// (uwreq, underwriter) before and after filing, and identical bytes in one block window - /// collide on transaction dedup. - uint64_t uwchalbond(uint64_t uwreq_id, name underwriter) { - produce_block(); - auto trace = base_tester::push_action(CHALG_ACCOUNT, "uwchalbond"_n, CHALG_ACCOUNT, mvo() - ("uwreq_id", uwreq_id)("underwriter", underwriter.to_string())); - BOOST_REQUIRE(trace && !trace->action_traces.empty()); - return fc::raw::unpack(trace->action_traces[0].return_value); - } - - // ── row / balance readers ──────────────────────────────────────────────── - - fc::variant get_uwchal(uint64_t id) { - auto data = get_row_by_id(CHALG_ACCOUNT, CHALG_ACCOUNT, "uwchals"_n, id); - return data.empty() ? fc::variant() : chalg_abi.binary_to_variant( - "uwchal_entry", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - } - - /// The challenge's escrowed bond, straight off the row. - uint64_t uwchal_bond_amount(uint64_t id) { - return get_uwchal(id)["bond_amount"].as_uint64(); - } - - /// One owner's ballot in a challenge (the vote table is scoped by chal_id) — null once - /// resolution erased the scope. - fc::variant get_uwchal_vote(uint64_t chal_id, name owner) { - auto data = get_row_by_id(CHALG_ACCOUNT, name(chal_id), "uwchalvote"_n, owner.value); - return data.empty() ? fc::variant() : chalg_abi.binary_to_variant( - "uwchal_vote", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - } - - /// Unclaimed WIRE `sysio.chalg` owes `account` from resolved challenges; 0 when no row exists. - uint64_t get_bond_credit(name account) { - auto data = get_row_by_id(CHALG_ACCOUNT, CHALG_ACCOUNT, "bondcredits"_n, account.value); - if (data.empty()) return 0; - return chalg_abi.binary_to_variant( - "bond_credit", data, - abi_serializer::create_yield_function(abi_serializer_max_time))["amount"].as_uint64(); - } - - int64_t wire_balance(name account) { - return get_currency_balance(TOKEN_ACCOUNT, symbol{9, "WIRE"}, account).get_amount(); - } - - /// The decoded `sysio.opreg::operators` row. - fc::variant get_operator(name account) { - auto data = get_row_by_id(OPREG_ACCOUNT, OPREG_ACCOUNT, "operators"_n, account.value); - return data.empty() ? fc::variant() : opreg_abi.binary_to_variant( - "operator_entry", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - } - - /// The operator's status string straight off `sysio.opreg::operators`. - std::string operator_status(name account) { - return get_operator(account)["status"].as_string(); - } - - /// Sweep expired locks as the epoch machinery would (`chklocks` accepts epoch or self auth). - /// Defaults to the budget `sysio.epoch::advance` passes, so a challenge test sweeps exactly as - /// production does; pass a smaller one to exercise the bound itself. - action_result chklocks(uint32_t max_rows = kMaxLockReleasePerEpoch) { - return chklocks_direct(max_rows); - } -}; - -// The read-only quote prices the live locks through their own books and answers 0 for every -// not-challengeable state — the same soft contract as sysio.reserv::swapquote. -BOOST_FIXTURE_TEST_CASE(uwchalbond_quotes_the_live_lock_value, sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9100; - make_confirmed_uwreq(ATT_ID); - - BOOST_REQUIRE_EQUAL(expected_bond(UWRIT_OP), uwchalbond(ATT_ID, UWRIT_OP)); - BOOST_REQUIRE_GT(expected_bond(UWRIT_OP), 0u); - - BOOST_REQUIRE_EQUAL(0u, uwchalbond(ATT_ID + 1, UWRIT_OP)); // no such uwreq - BOOST_REQUIRE_EQUAL(0u, uwchalbond(ATT_ID, "batchop.a"_n)); // not the winner -} FC_LOG_AND_RETHROW() } - -/// WIRE-375: a direct depot deposit is already denominated in atomic WIRE-9, -/// so challenge-bond valuation must add exactly the deposited atomic amount. -/// The pre-existing ETH bucket keeps the confirmed request challengeable; the -/// before/after delta isolates the direct `(WIRE, WIRE)` collateral path. -BOOST_FIXTURE_TEST_CASE(wire_direct_deposit_adds_exact_units_to_uwchalbond, - sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9101; - const asset direct_deposit{2'000'000'000, symbol{9, "WIRE"}}; - make_confirmed_uwreq(ATT_ID); - - const uint64_t before = uwchalbond(ATT_ID, UWRIT_OP); - BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, config::system_account_name, - "transfer"_n, mvo()("from", "sysio")("to", UWRIT_OP.to_string()) - ("quantity", direct_deposit)("memo", "fund native collateral"))); - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, UWRIT_OP, "deposit"_n, mvo() - ("account", UWRIT_OP.to_string())("token_code", codename_mvo("WIRE")) - ("amount", static_cast(direct_deposit.get_amount())))); - - const uint64_t after = uwchalbond(ATT_ID, UWRIT_OP); - BOOST_REQUIRE_EQUAL(before + static_cast(direct_deposit.get_amount()), after); - BOOST_REQUIRE_EQUAL(expected_bond(UWRIT_OP), after); -} FC_LOG_AND_RETHROW() } - -// A collateral bucket whose (chain, token) pair carries NO ACTIVE reserve must not void the quote. -// `opreg::depositinle` accepts any positive amount for any pair without requiring one that quotes, -// so voiding here handed the underwriter an immunity switch: seed one unreserved bucket and every -// later CONFIRMED commitment becomes permanently unchallengeable. The bucket contributes the floor -// instead, and filing still works. -BOOST_FIXTURE_TEST_CASE(unreserved_collateral_bucket_still_quotes_a_bond, sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9800; - make_confirmed_uwreq(ATT_ID); - - const uint64_t priced_only = uwchalbond(ATT_ID, UWRIT_OP); - BOOST_REQUIRE_GT(priced_only, 0u); - - // No reserve was ever registered for (SOLANA, USDC) — nothing to price the bucket against. - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "SOLANA", "USDC", 5'000)); - BOOST_REQUIRE(first_active_reserve(fc::slug_name{"SOLANA"}.value, - fc::slug_name{"USDC"}.value).is_null()); - - // Quote survives, grows by exactly the floor, and still matches the shared formula. - const uint64_t quoted = uwchalbond(ATT_ID, UWRIT_OP); - BOOST_REQUIRE_EQUAL(priced_only + MIN_UWCHAL_BUCKET_WIRE, quoted); - BOOST_REQUIRE_EQUAL(expected_bond(UWRIT_OP), quoted); - - // The commitment is still challengeable — the property the void broke. - BOOST_REQUIRE_EQUAL(success(), - openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "no such deposit")); - BOOST_REQUIRE_EQUAL(static_cast(quoted), wire_balance(CHALG_ACCOUNT)); -} FC_LOG_AND_RETHROW() } - -/// A foreign-chain collateral bucket whose token codename is WIRE must not be -/// mistaken for depot-native WIRE and valued at par. With no ACTIVE reserve for -/// `(ETH, WIRE)`, it contributes only the unpriceable-bucket safety floor. -BOOST_FIXTURE_TEST_CASE(foreign_wire_collateral_bucket_is_not_native, - sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9801; - constexpr uint64_t FOREIGN_WIRE_AMOUNT = 5'000; - make_confirmed_uwreq(ATT_ID); - - const uint64_t priced_only = uwchalbond(ATT_ID, UWRIT_OP); - BOOST_REQUIRE_EQUAL(success(), - depositinle_credit(UWRIT_OP, "ETH", "WIRE", FOREIGN_WIRE_AMOUNT)); - BOOST_REQUIRE(first_active_reserve(fc::slug_name{"ETH"}.value, - fc::slug_name{"WIRE"}.value).is_null()); - - const uint64_t quoted = uwchalbond(ATT_ID, UWRIT_OP); - BOOST_REQUIRE_EQUAL(priced_only + MIN_UWCHAL_BUCKET_WIRE, quoted); - BOOST_REQUIRE_EQUAL(expected_bond(UWRIT_OP), quoted); -} FC_LOG_AND_RETHROW() } - -// Same property for the DUST flavour: the pair HAS an ACTIVE reserve, but its books floor the -// conversion to zero. A deep token side over a shallow WIRE side does it at the 50% weight, where -// the curve is pure constant product: 1 * 1000 / (1e12 + 1) = 0. -BOOST_FIXTURE_TEST_CASE(dust_collateral_bucket_still_quotes_a_bond, sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9810; - make_confirmed_uwreq(ATT_ID); - - const uint64_t priced_only = uwchalbond(ATT_ID, UWRIT_OP); - BOOST_REQUIRE_GT(priced_only, 0u); - - BOOST_REQUIRE_EQUAL(success(), regreserve_active_amounts("ETH", "USDC", "PRIMARY", - /*chain_amount*/ 1'000'000'000'000ull, - /*wire_amount*/ 1'000ull)); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "USDC", 1)); - - // Pin the premise: the pair IS priceable, and the books really do floor this bucket to zero. - const auto dust_row = first_active_reserve(fc::slug_name{"ETH"}.value, - fc::slug_name{"USDC"}.value); - BOOST_REQUIRE(!dust_row.is_null()); - BOOST_REQUIRE_EQUAL(0u, sysio::opp::amm::token_to_wire( - dust_row["reserve_chain_amount"].as_uint64(), - dust_row["reserve_wire_amount"].as_uint64(), - dust_row["connector_weight_bps"].as_uint64(), 1)); - - const uint64_t quoted = uwchalbond(ATT_ID, UWRIT_OP); - BOOST_REQUIRE_EQUAL(priced_only + MIN_UWCHAL_BUCKET_WIRE, quoted); - BOOST_REQUIRE_EQUAL(expected_bond(UWRIT_OP), quoted); - - BOOST_REQUIRE_EQUAL(success(), - openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "no such deposit")); - BOOST_REQUIRE_EQUAL(static_cast(quoted), wire_balance(CHALG_ACCOUNT)); -} FC_LOG_AND_RETHROW() } - -// Filing escrows exactly the quoted bond, stamps every lock with the challenge id, and records -// the OPEN row — and the quote answers 0 afterwards (a commitment is challengeable once). -BOOST_FIXTURE_TEST_CASE(openuwchal_escrows_bond_and_holds_locks, sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9200; - make_confirmed_uwreq(ATT_ID); - - const uint64_t bond = uwchalbond(ATT_ID, UWRIT_OP); - const int64_t before = wire_balance(CHALLENGER); - - BOOST_REQUIRE_EQUAL(success(), - openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "no such deposit")); - - BOOST_REQUIRE_EQUAL(before - static_cast(bond), wire_balance(CHALLENGER)); - BOOST_REQUIRE_EQUAL(static_cast(bond), wire_balance(CHALG_ACCOUNT)); - - const auto chal = get_uwchal(1); - BOOST_REQUIRE(!chal.is_null()); - BOOST_REQUIRE_EQUAL("DISPUTE_STATUS_OPEN", chal["status"].as_string()); - BOOST_REQUIRE_EQUAL("NONE", chal["verdict"].as_string()); - BOOST_REQUIRE_EQUAL(ATT_ID, chal["uwreq_id"].as_uint64()); - BOOST_REQUIRE_EQUAL(bond, chal["bond_amount"].as_uint64()); - // Four registered voters + the ONE genesis T1 owner (this assert breaks loudly if genesis - // ever seeds a different count — the quorum arithmetic below depends on it). - BOOST_REQUIRE_EQUAL(5u, chal["electorate"].get_array().size()); - BOOST_REQUIRE_EQUAL(3u, chal["quorum"].as_uint64()); - - BOOST_REQUIRE_EQUAL(1u, get_lock(1)["challenge_id"].as_uint64()); - BOOST_REQUIRE_EQUAL(1u, get_lock(2)["challenge_id"].as_uint64()); - - BOOST_REQUIRE_EQUAL(0u, uwchalbond(ATT_ID, UWRIT_OP)); - BOOST_REQUIRE(openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "again") - .find("already been challenged") != std::string::npos); -} FC_LOG_AND_RETHROW() } + const auto& db = control->db(); + const auto& idx = db.get_index(); + for (auto it = idx.lower_bound(boost::make_tuple(account)); it != idx.end() && it->owner == account; ++it) { + const name parent = it->parent._id == 0 ? name{} : db.get(it->parent).name; + out.emplace(it->name, std::make_pair(parent, it->auth.to_authority())); + } + return out; + } +}; -// Every way a filing can be stale or malformed is refused before any WIRE moves. -BOOST_FIXTURE_TEST_CASE(openuwchal_guards, sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9300; - make_confirmed_uwreq(ATT_ID); - - BOOST_REQUIRE(openuwchal(CHALLENGER, ATT_ID + 77, UWRIT_OP, REASON_DEPOSIT_MISSING, "x") - .find("not found") != std::string::npos); - BOOST_REQUIRE(openuwchal(CHALLENGER, ATT_ID, "batchop.a"_n, REASON_DEPOSIT_MISSING, "x") - .find("not this request's winner") != std::string::npos); - BOOST_REQUIRE(openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, /*reason*/ 99, "x") - .find("unknown fault reason") != std::string::npos); - // The caller-controlled detail note is retained on the audit row indefinitely (RAM billed to - // the contract) — the byte cap refuses the filing before any escrow moves. - BOOST_REQUIRE(openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, - std::string(1024 + 1, 'x')) - .find("detail exceeds max_uwchal_detail_bytes") != std::string::npos); - - // Time-travel past the 12h window: the commitment is no longer challengeable, and the locks - // release healthy on the next sweep. Seal the pending block FIRST — a big skip aborts pending - // and re-applies its transactions at the jumped time, where they have expired. - const int64_t before = wire_balance(CHALLENGER); - produce_blocks(); - produce_block(fc::hours(13)); - BOOST_REQUIRE(openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "late") - .find("lock window has closed") != std::string::npos); - // The whole transaction rolled back (the bond gate refuses pre-escrow here; a mid-window - // race would instead throw in holdlocks AFTER the escrow was sent — same atomicity): no - // WIRE moved, no challenge row survives. - BOOST_REQUIRE_EQUAL(before, wire_balance(CHALLENGER)); - BOOST_REQUIRE(get_uwchal(1).is_null()); -} FC_LOG_AND_RETHROW() } +// A tier-2/3 claim can take the name auth.msg and deploy code there, so an auth.msg::onlinkauth notification is +// attacker-controlled. It must reach sysio as an ordinary notification and change nothing: not sysio's owner, active, +// auth.ext or any other permission, and not a user's. +BOOST_FIXTURE_TEST_CASE(claimed_auth_msg_notification_changes_no_permission, node_owner_claim_tester) { try { + bootstrap_for_dispatch(NODE_OWNER_SOURCE_CHAIN); + create_account(PAYER_ACCOUNT); + deploy_system_contract(); -// Ballot guards mirror votedispute's: electorate membership, one vote per owner, a real ballot -// value, and an OPEN challenge. -BOOST_FIXTURE_TEST_CASE(voteuwchal_guards, sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9400; - make_confirmed_uwreq(ATT_ID); - BOOST_REQUIRE_EQUAL(success(), - openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "d")); - - BOOST_REQUIRE(voteuwchal(CHALLENGER, 1, BALLOT_UPHOLD) - .find("not in the challenge's tier-1 electorate") != std::string::npos); - BOOST_REQUIRE(voteuwchal(VOTER1, 1, /*ballot*/ 7).find("unknown ballot") != std::string::npos); - BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER1, 1, BALLOT_UPHOLD)); - produce_block(); // identical re-vote bytes need fresh TAPOS to reach the contract's guard - BOOST_REQUIRE(voteuwchal(VOTER1, 1, BALLOT_UPHOLD) - .find("already voted") != std::string::npos); - BOOST_REQUIRE(chkuwchal(99).find("challenge not found") != std::string::npos); -} FC_LOG_AND_RETHROW() } + claim_node_owner(AUTH_MSG_ACCOUNT, get_public_key(AUTH_MSG_ACCOUNT, "active")); + const auto audit = get_nodeownerreg(AUTH_MSG_ACCOUNT); + BOOST_REQUIRE(!audit.is_null()); + BOOST_REQUIRE_EQUAL(audit["status"].as(), sysio_system::test_support::nodeownerreg::status_confirmed); + issue_own_policy(AUTH_MSG_ACCOUNT); + set_code(AUTH_MSG_ACCOUNT, notify_system_account_wasm()); + produce_block(); -// The uphold path end-to-end: quorum -> SLASHED -> the locks sweep through releaselock's -// deferred-slash branch (collateral debited) -> uwreq COMPLETED -> bond back to the challenger. -// An OPEN challenge PINS the uwreq row against the retention sweep. The locks hold the -// collateral, but THIS row holds the evidence the challenge adjudicates against -// (`attestation_inbound_data`, `source_tx_id`, `commits_by`), and the terminal retention -// window is far shorter than the challenge window — so without the pin a challenge could -// outlive the record it exists to adjudicate. Both release branches are covered: `freelocks` -// on a REJECTED verdict here, `sweeplocks` on UPHELD below. -BOOST_FIXTURE_TEST_CASE(pruneuwreqs_skips_a_challenged_uwreq, sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9600; - make_confirmed_uwreq(ATT_ID); - - // Filing stamps the request, not just its locks. - BOOST_REQUIRE_EQUAL(success(), - openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "no deposit on ETH")); - BOOST_REQUIRE_EQUAL(1u, get_uwreq(ATT_ID)["challenge_id"].as_uint64()); - - // The sweep refuses it however generous the budget — the pin is not status- or - // retention-conditional. - BOOST_REQUIRE_EQUAL(success(), push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, - "pruneuwreqs"_n, mvo()("max_rows", 100))); - BOOST_REQUIRE(!get_uwreq(ATT_ID).is_null()); - - // REJECT_FORFEIT resolves through `freelocks`, which clears both markers. - for (auto v : {VOTER1, VOTER2, VOTER3}) { - BOOST_REQUIRE_EQUAL(success(), voteuwchal(v, 1, BALLOT_REJECT_FORFEIT)); + const auto replacement_key = get_public_key(REPLACEMENT_KEY_NAME, "active"); + const auto sysio_before = permissions_of(config::system_account_name); + const auto user_before = permissions_of(CLAIM_ACCOUNT); + for (const auto target : {config::system_account_name, CLAIM_ACCOUNT}) { + for (const auto permission : {config::owner_name, config::active_name, AUTH_EXT_PERMISSION, OTHER_PERMISSION}) { + BOOST_CHECK_MESSAGE(notify_onlinkauth(target, permission, replacement_key), + "onlinkauth for " << target.to_string() << "@" << permission.to_string() + << " did not commit as a plain notification"); + } } - produce_block(); - BOOST_REQUIRE_EQUAL(success(), chkuwchal(1)); - BOOST_REQUIRE_EQUAL(0u, get_uwreq(ATT_ID)["challenge_id"].as_uint64()); + BOOST_CHECK_MESSAGE(permissions_of(config::system_account_name) == sysio_before, "sysio permissions changed"); + BOOST_CHECK_MESSAGE(permissions_of(CLAIM_ACCOUNT) == user_before, "claimacct permissions changed"); } FC_LOG_AND_RETHROW() } -/// The UPHELD branch releases the pin too — `sweeplocks` ERASES the locks rather than -/// clearing their markers, so it is the only place the row's own marker comes off there. -/// Without it the row would be skipped forever and never reclaimable. -BOOST_FIXTURE_TEST_CASE(uphold_releases_the_uwreq_challenge_pin, sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9610; - make_confirmed_uwreq(ATT_ID); +// No node owner may claim a name under the reserved sysio. prefix: the depot's claim is rejected as NAME_INVALID and +// no account is created. +BOOST_FIXTURE_TEST_CASE(node_owner_claim_rejects_reserved_system_name, node_owner_claim_tester) { try { + bootstrap_for_dispatch(NODE_OWNER_SOURCE_CHAIN); + deploy_system_contract(); - BOOST_REQUIRE_EQUAL(success(), - openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "no deposit on ETH")); - BOOST_REQUIRE_EQUAL(1u, get_uwreq(ATT_ID)["challenge_id"].as_uint64()); + claim_node_owner(RESERVED_SYSTEM_NAME, get_public_key(RESERVED_SYSTEM_NAME, "active")); - for (auto v : {VOTER1, VOTER2, VOTER3}) { - BOOST_REQUIRE_EQUAL(success(), voteuwchal(v, 1, BALLOT_UPHOLD)); - } - produce_block(); - BOOST_REQUIRE_EQUAL(success(), chkuwchal(1)); - BOOST_REQUIRE_EQUAL("UPHELD", get_uwchal(1)["verdict"].as_string()); - BOOST_REQUIRE_EQUAL(0u, get_uwreq(ATT_ID)["challenge_id"].as_uint64()); + const auto audit = get_nodeownerreg(RESERVED_SYSTEM_NAME); + BOOST_REQUIRE(!audit.is_null()); + BOOST_CHECK_EQUAL(audit["status"].as(), sysio_system::test_support::nodeownerreg::status_rejected); + BOOST_CHECK_EQUAL(audit["reason"].as(), sysio_system::test_support::nodeownerreg::reason_name_invalid); + BOOST_CHECK(get_nodeowner(RESERVED_SYSTEM_NAME).is_null()); + BOOST_CHECK(!account_exists(RESERVED_SYSTEM_NAME)); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(chkuwchal_uphold_slashes_and_returns_bond, sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9500; - make_confirmed_uwreq(ATT_ID); - const int64_t challenger_start = wire_balance(CHALLENGER); +/// Regression: a non-advancing advance() must not permanently strand the epoch. +/// +/// When every active outpost has reached consensus and the wall clock has passed, chkcons triggers +/// sysio.epoch::advance. advance can legally return WITHOUT bumping the epoch when emissions are not +/// ready -- its emissions gate records a block and returns gracefully, it does not throw. The earlier +/// chkcons cleared per-outpost consensus_reached BEFORE calling advance, so that graceful return +/// committed the cleared state and nothing re-armed it (apply_consensus does not re-fire for an +/// already-complete delivery set) -- permanently stalling advancement even once emissions later became +/// ready. +/// +/// This fixture never deploys sysio.system, so the emissions gate always blocks (CONFIG_MISSING): +/// exactly the non-advancing path. After consensus, each chkcons must re-attempt advance, which the +/// gate records as blocklog.retry_count. Pre-fix, the second chkcons bailed at the consensus gate +/// (retry_count would stay 1) and the epoch was stuck forever. +BOOST_FIXTURE_TEST_CASE(chkcons_survives_non_advancing_advance, sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + const auto eth_code = fc::slug_name{"ETH"}.value; - BOOST_REQUIRE_EQUAL(success(), - openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "no deposit on ETH")); - BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER1, 1, BALLOT_UPHOLD)); - BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER2, 1, BALLOT_UPHOLD)); - - // Sub-quorum (2 of Q=3): the crank resolves nothing and the operator stands. - BOOST_REQUIRE_EQUAL(success(), chkuwchal(1)); - BOOST_REQUIRE_EQUAL("DISPUTE_STATUS_OPEN", get_uwchal(1)["status"].as_string()); - BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_ACTIVE", operator_status(UWRIT_OP)); - - BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER3, 1, BALLOT_UPHOLD)); - produce_block(); // the resolving crank re-pushes identical bytes — fresh TAPOS required - BOOST_REQUIRE_EQUAL(success(), chkuwchal(1)); - - const auto chal = get_uwchal(1); - BOOST_REQUIRE_EQUAL("DISPUTE_STATUS_RESOLVED", chal["status"].as_string()); - BOOST_REQUIRE_EQUAL("UPHELD", chal["verdict"].as_string()); - - // The operator is slashed and BOTH locks are gone — swept, not expired. - BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_SLASHED", operator_status(UWRIT_OP)); - BOOST_REQUIRE(get_lock(1).is_null()); - BOOST_REQUIRE(get_lock(2).is_null()); - - // `sweeplocks` is a lock-erasure path that does NOT go through `chklocks`, so it carries the - // rollup obligation independently: the cache must not outlive the rows it summarizes. Assert - // both halves — agreement with an authoritative scan, and zero, since the sweep took every - // lock in the bucket. Without the `sub_locked_total` in `sweeplocks` the rollup stays at the - // pre-sweep 200 while the scan reads 0, and that phantom locked amount is permanent: it - // suppresses `available()` on this bucket for good, so collateral deposited after a later - // re-registration is unusable with nothing holding it. - BOOST_REQUIRE_EQUAL(scan_lock_total(UWRIT_OP, "ETH", "ETH"), - get_lock_sum(UWRIT_OP, "ETH", "ETH")); - BOOST_REQUIRE_EQUAL(0u, get_lock_sum(UWRIT_OP, "ETH", "ETH")); - - // Every bucket drains: `opreg::slash` debits each bucket's slashable-now portion (balance - // minus active locks — for the challenged (ETH, ETH) bucket that is everything ABOVE the two - // 100-unit locks), and the two sweeping releaselocks then take the deferred-slash branch for - // the locked 200. Unlocked buckets on other chains were slashed in full at slash time. - // (Bind the row first — ranging over `get_operator(...)[...]` would iterate a dangling - // temporary.) - const auto slashed_op = get_operator(UWRIT_OP); - for (const auto& bal : slashed_op["balances"].get_array()) { - BOOST_REQUIRE_EQUAL(0u, bal["balance"].as_uint64()); - } + // This fixture deploys no sysio.system, so even the genesis advance in bootstrap gate-blocked; the + // chain sits at `epoch0`. advance() always targets the next epoch, whose blocklog row counts gate + // re-attempts. + const uint32_t epoch0 = current_epoch(); + const uint32_t target = epoch0 + 1; - // COMPLETED flip ran through the shared tail. - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_COMPLETED", - get_uwreq(ATT_ID)["status"].as_string()); - - // The bond is CREDITED, not pushed: resolution moves no WIRE at all, so custody is still - // chalg's and the challenger is still down the escrow. (The crank can run under the epoch - // tick, where a transfer would run the recipient's notification handler — see - // hostile_bond_recipient_cannot_stall_the_epoch_tick.) - const uint64_t bond = chal["bond_amount"].as_uint64(); - BOOST_REQUIRE_GT(bond, 0u); - BOOST_REQUIRE_EQUAL(challenger_start - static_cast(bond), wire_balance(CHALLENGER)); - BOOST_REQUIRE_EQUAL(static_cast(bond), wire_balance(CHALG_ACCOUNT)); - BOOST_REQUIRE_EQUAL(bond, get_bond_credit(CHALLENGER)); - - // The pull settles it: the bond comes home and chalg ends at zero custody. - BOOST_REQUIRE_EQUAL(success(), claimbond(CHALLENGER)); - BOOST_REQUIRE_EQUAL(challenger_start, wire_balance(CHALLENGER)); - BOOST_REQUIRE_EQUAL(0, wire_balance(CHALG_ACCOUNT)); - BOOST_REQUIRE_EQUAL(0u, get_bond_credit(CHALLENGER)); -} FC_LOG_AND_RETHROW() } + // operators_per_epoch == 1, so a single delivery is Option-A unanimous consensus; apply_consensus + // records the outpcons row for ETH at epoch0. + auto operator_payload = encode_operator_action( + sysio::opp::attestations::OperatorAction::ACTION_TYPE_DEPOSIT_REQUEST, + sysio::opp::types::CHAIN_KIND_EVM, + uwrit_op_eth_pubkey, + /*chain_code_v=*/ eth_code, + /*token_code_v=*/ eth_code, + /*amount=*/ 1'000'000); + auto envelope = encode_envelope_with_one_attestation( + epoch0, + sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION, + operator_payload); + BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/eth_code, envelope)); + produce_blocks(); // land the deliver in its own block (this fixture's push does not) -// An explicit REJECT_FORFEIT majority is the ONLY road to forfeiture: the bond lands on the -// wrongly-challenged underwriter, the holds clear, and the locks release healthy at expiry -// with the collateral untouched. -BOOST_FIXTURE_TEST_CASE(chkuwchal_reject_forfeit_pays_underwriter, sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9600; - make_confirmed_uwreq(ATT_ID); - const uint64_t bond = uwchalbond(ATT_ID, UWRIT_OP); - const int64_t uw_start = wire_balance(UWRIT_OP); + auto retry_count = [&]() -> uint32_t { + auto bl = get_blocklog(target); + return bl.is_null() ? 0u : bl["retry_count"].as(); + }; + const uint32_t rc0 = retry_count(); - BOOST_REQUIRE_EQUAL(success(), - openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "wrong")); - BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER1, 1, BALLOT_REJECT_FORFEIT)); - BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER2, 1, BALLOT_REJECT_FORFEIT)); - BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER3, 1, BALLOT_REJECT_FORFEIT)); - BOOST_REQUIRE_EQUAL(success(), chkuwchal(1)); - - BOOST_REQUIRE_EQUAL("REJECTED_FORFEIT", get_uwchal(1)["verdict"].as_string()); - BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_ACTIVE", operator_status(UWRIT_OP)); - - // Forfeiture credits the underwriter; the WIRE lands when IT pulls. - BOOST_REQUIRE_EQUAL(bond, get_bond_credit(UWRIT_OP)); - BOOST_REQUIRE_EQUAL(uw_start, wire_balance(UWRIT_OP)); - BOOST_REQUIRE_EQUAL(success(), claimbond(UWRIT_OP)); - BOOST_REQUIRE_EQUAL(uw_start + static_cast(bond), wire_balance(UWRIT_OP)); - BOOST_REQUIRE_EQUAL(0, wire_balance(CHALG_ACCOUNT)); - - // Holds cleared; locks still present until natural expiry, then a HEALTHY release. - BOOST_REQUIRE_EQUAL(0u, get_lock(1)["challenge_id"].as_uint64()); - BOOST_REQUIRE_EQUAL(0u, get_lock(2)["challenge_id"].as_uint64()); - const auto balances_before = get_operator(UWRIT_OP)["balances"]; + // Pass the wall clock (epoch_duration_sec == 60) so chkcons will fire advance, then refresh the head + // so the subsequent push is stamped against the post-skip time and does not expire. + produce_block(fc::seconds(120)); produce_blocks(); - produce_block(fc::hours(13)); - BOOST_REQUIRE_EQUAL(success(), chklocks()); - BOOST_REQUIRE(get_lock(1).is_null()); - // A healthy release moves NO collateral: the balances are byte-identical to the pre-expiry - // snapshot after a rejected challenge. - const auto balances_after = get_operator(UWRIT_OP)["balances"]; - BOOST_REQUIRE_EQUAL(fc::json::to_string(balances_before, fc::time_point::maximum()), - fc::json::to_string(balances_after, fc::time_point::maximum())); -} FC_LOG_AND_RETHROW() } -// A split reject quorum favours the refund on a tie — forfeiture requires a strict majority of -// the rejectors. -BOOST_FIXTURE_TEST_CASE(chkuwchal_reject_tie_favours_refund, sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9700; - make_confirmed_uwreq(ATT_ID); - const int64_t challenger_start = wire_balance(CHALLENGER); + // First chkcons fires advance, which gate-blocks on missing emissions and returns without advancing. + // The epoch is unchanged and the gate bumps retry_count -- confirming advance was actually attempted. + BOOST_REQUIRE_EQUAL(success(), chkcons()); + produce_blocks(); + BOOST_REQUIRE_EQUAL(current_epoch(), epoch0); + const uint32_t rc1 = retry_count(); + BOOST_REQUIRE_EQUAL(rc1, rc0 + 1); - BOOST_REQUIRE_EQUAL(success(), - openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "honest mistake")); - // Four rejectors split 2–2: the combined rejects clear Q=3, and the forfeit/refund tie must - // fall to the refund — forfeiture only ever happens by a strict majority of the rejectors. - BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER1, 1, BALLOT_REJECT_REFUND)); - BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER2, 1, BALLOT_REJECT_FORFEIT)); - BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER3, 1, BALLOT_REJECT_REFUND)); - BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER4, 1, BALLOT_REJECT_FORFEIT)); - BOOST_REQUIRE_EQUAL(success(), chkuwchal(1)); - - BOOST_REQUIRE_EQUAL("REJECTED_REFUND", get_uwchal(1)["verdict"].as_string()); - BOOST_REQUIRE_EQUAL(success(), claimbond(CHALLENGER)); - BOOST_REQUIRE_EQUAL(challenger_start, wire_balance(CHALLENGER)); + // REGRESSION: the per-outpost consensus signal must survive the non-advancing advance, so the second + // chkcons re-attempts advance (retry_count -> rc1 + 1). Pre-fix, chkcons cleared consensus_reached + // before calling advance, so this second call bailed at the consensus gate and never re-attempted -- + // retry_count would stay at rc1 and the epoch would be permanently stranded. + BOOST_REQUIRE_EQUAL(success(), chkcons()); + produce_blocks(); + BOOST_REQUIRE_EQUAL(current_epoch(), epoch0); + BOOST_REQUIRE_EQUAL(retry_count(), rc1 + 1); } FC_LOG_AND_RETHROW() } -// The epoch tick IS the challenge's cadence: an expired-but-challenged lock is NOT released by -// the sweep; the sweep pokes the tally, the sub-quorum challenge LAPSES (bond refunded — silence -// never punishes), the freed locks release on the NEXT sweep. -BOOST_FIXTURE_TEST_CASE(chklocks_skips_held_lock_and_lapse_refunds, sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9800; - make_confirmed_uwreq(ATT_ID); - const int64_t challenger_start = wire_balance(CHALLENGER); +// ═════════════════════════════════════════════════════════════════════════ +// msgch::bootstrap emissions guards. Missing emissions config is a bootstrap DEFECT, not an +// operational state: without these guards bootstrap's inline genesis advance would gate-block +// SILENTLY (the emissions gate never throws, by design) and the misconfiguration would surface +// only as "epoch stuck at 0". The three cases below cover both guards independently plus the +// fully-configured pass-through. advance()'s own soft block-and-retry behavior for the ECONOMIC +// gate reasons is covered above (chkcons_survives_non_advancing_advance) and in emissions_tests. +// ═════════════════════════════════════════════════════════════════════════ - BOOST_REQUIRE_EQUAL(success(), - openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "nobody voted")); - BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER1, 1, BALLOT_UPHOLD)); // sub-quorum (Q=3) - - produce_blocks(); // seal pending before the jump (see openuwchal_guards) - produce_block(fc::hours(13)); // past both the lock expiry and therefore the vote deadline - - // Past the deadline the ballot door is closed: a late quorum can never be assembled for a - // manual chkuwchal crank in the expiry→sweep gap — the only remaining outcome is LAPSED. - BOOST_REQUIRE(voteuwchal(VOTER2, 1, BALLOT_UPHOLD) - .find("the challenge window has expired") != std::string::npos); - - // First sweep: the held locks are SKIPPED (still present), the poke lapses the challenge. - BOOST_REQUIRE_EQUAL(success(), chklocks()); - BOOST_REQUIRE(!get_lock(1).is_null()); - BOOST_REQUIRE(!get_lock(2).is_null()); - BOOST_REQUIRE_EQUAL(0u, get_lock(1)["challenge_id"].as_uint64()); - BOOST_REQUIRE_EQUAL("LAPSED", get_uwchal(1)["verdict"].as_string()); - BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_ACTIVE", operator_status(UWRIT_OP)); - // Silence never punishes: the whole bond is credited back, claimable on demand. - BOOST_REQUIRE_EQUAL(uwchal_bond_amount(1), get_bond_credit(CHALLENGER)); - BOOST_REQUIRE_EQUAL(success(), claimbond(CHALLENGER)); - BOOST_REQUIRE_EQUAL(challenger_start, wire_balance(CHALLENGER)); - - // Second sweep: the now-unheld expired locks release healthy; uwreq completes. - produce_block(); // identical sweep bytes — fresh TAPOS - BOOST_REQUIRE_EQUAL(success(), chklocks()); - BOOST_REQUIRE(get_lock(1).is_null()); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_COMPLETED", - get_uwreq(ATT_ID)["status"].as_string()); +// Guard 1 — emitcfg missing. This fixture deploys no sysio.system at all (setemitcfg / initt5 +// never ran), which is exactly the never-configured deployment shape. +BOOST_FIXTURE_TEST_CASE(bootstrap_requires_emissions_config, sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + BOOST_REQUIRE_EQUAL( + wasm_assert_msg("msgch::bootstrap: emissions config missing -- sysio.system::setemitcfg must run before bootstrap"), + push(MSGCH_ACCOUNT, msgch_abi, MSGCH_ACCOUNT, "bootstrap"_n, mvo())); } FC_LOG_AND_RETHROW() } -// The epoch budget counts rows EXAMINED, not locks released. A held lock is skipped rather than -// released, so a budget spent only on releases would let an unbounded run of held rows be scanned -// — and every distinct challenge among them fan out its own inline `chkuwchal` — which is exactly -// the unbounded `advance` work the bound exists to delete. -// -// The discriminator is a budget SMALLER than the run of held locks, with an ordinary expired lock -// queued behind them: counting examined rows stops inside the held run and releases nothing; -// counting releases walks past every held row and reaches the ordinary lock in the same call. -BOOST_FIXTURE_TEST_CASE(chklocks_budget_counts_held_locks_and_still_drains, - sysio_uwchal_tester) { try { - constexpr uint64_t HELD_ATT = 9820; // challenged: contributes the two HELD locks (1, 2) - constexpr uint64_t ORDINARY_ATT = 9821; // unchallenged: its locks (3, 4) sit behind them - make_confirmed_uwreq(HELD_ATT); - - // A second commitment needs its own collateral; the bootstrap funds exactly one. Fresh TAPOS - // first — this credit is byte-identical to the bootstrap's and would otherwise collide as a - // duplicate transaction. - produce_block(); - BOOST_REQUIRE_EQUAL(success(), depositinle_credit(UWRIT_OP, "ETH", "ETH", 200)); - add_confirmed_uwreq(ORDINARY_ATT); - for (uint64_t id : {1u, 2u, 3u, 4u}) BOOST_REQUIRE(!get_lock(id).is_null()); - - // Hold ONLY the first commitment's locks, then expire everything. - BOOST_REQUIRE_EQUAL(success(), - openuwchal(CHALLENGER, HELD_ATT, UWRIT_OP, REASON_DEPOSIT_MISSING, "held run")); - BOOST_REQUIRE_GT(get_lock(1)["challenge_id"].as_uint64(), 0u); - BOOST_REQUIRE_GT(get_lock(2)["challenge_id"].as_uint64(), 0u); - BOOST_REQUIRE_EQUAL(0u, get_lock(3)["challenge_id"].as_uint64()); - BOOST_REQUIRE_EQUAL(0u, get_lock(4)["challenge_id"].as_uint64()); - - produce_blocks(); - produce_block(fc::hours(13)); // every lock now expired; the challenge deadline has passed - - // Budget of 1 against a 2-lock held run: the sweep spends it on the first held row and stops. - // Nothing is released — in particular the ordinary locks behind the run are never reached. - BOOST_REQUIRE_EQUAL(success(), chklocks(1)); - for (uint64_t id : {1u, 2u, 3u, 4u}) BOOST_REQUIRE(!get_lock(id).is_null()); - - // Eventual progress: the pokes the budget DID afford lapse the challenge, and once the hold is - // gone the same bounded sweep drains the whole backlog over subsequent ticks. - for (int sweep = 0; sweep < 4; ++sweep) { - produce_block(); // identical sweep bytes — fresh TAPOS - BOOST_REQUIRE_EQUAL(success(), chklocks()); - } - BOOST_REQUIRE_EQUAL("LAPSED", get_uwchal(1)["verdict"].as_string()); - for (uint64_t id : {1u, 2u, 3u, 4u}) BOOST_REQUIRE(get_lock(id).is_null()); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_COMPLETED", - get_uwreq(HELD_ATT)["status"].as_string()); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_COMPLETED", - get_uwreq(ORDINARY_ATT)["status"].as_string()); - - // The rollup tracked every release and lands at zero with the rows gone. - BOOST_REQUIRE_EQUAL(scan_lock_total(UWRIT_OP, "ETH", "ETH"), - get_lock_sum(UWRIT_OP, "ETH", "ETH")); - BOOST_REQUIRE_EQUAL(0u, get_lock_sum(UWRIT_OP, "ETH", "ETH")); +// Guard 2 — emitcfg present, t5state missing (the half-configured deployment: setemitcfg ran, +// initt5 was forgotten). Proves the SECOND check is independently reachable and reads t5state, +// not emitcfg again: guard 1 passes here, so a wrong table/account lookup or a shadowed copy of +// the first check would surface as the WRONG diagnostic (or no throw at all). +BOOST_FIXTURE_TEST_CASE(bootstrap_requires_emissions_state, sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + deploy_system_with_emitcfg(); // setemitcfg only -- initt5 deliberately not run + BOOST_REQUIRE_EQUAL( + wasm_assert_msg("msgch::bootstrap: emissions state uninitialized -- sysio.system::initt5 must run before bootstrap"), + push(MSGCH_ACCOUNT, msgch_abi, MSGCH_ACCOUNT, "bootstrap"_n, mvo())); } FC_LOG_AND_RETHROW() } -// A resolved challenge keeps only what consensus still needs: the uniqueness gate and the -// verdict. The caller-controlled detail note, the electorate snapshot, and every ballot row go — -// filing is permissionless and the bond returns on every non-forfeit outcome, so without this -// the same recycled capital could pin unbounded bytes in RAM billed to sysio. -BOOST_FIXTURE_TEST_CASE(chkuwchal_compacts_resolved_row_and_erases_ballots, - sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9250; - make_confirmed_uwreq(ATT_ID); +// Both guards pass — the genesis 0 -> 1 advance actually happens. Guards that always threw (or a +// gate that never passes) would look identical to guards 1 and 2 above, so this is the case that +// pins them as guards rather than a permanent bootstrap block. +BOOST_FIXTURE_TEST_CASE(bootstrap_advances_genesis_epoch_when_emissions_configured, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + setup_wire_token(); // payepoch pays WIRE out of sysio's token balance + enable_epoch_advancement(); // setemitcfg + initt5 + + // bootstrap_for_dispatch's own advance ran while emissions were unconfigured, so it + // gate-blocked and left a blocklog row for the target epoch. + BOOST_REQUIRE_EQUAL(current_epoch(), 0u); + BOOST_REQUIRE(!get_blocklog(1).is_null()); - const std::string detail(1024, 'e'); // a full-size note, right at max_uwchal_detail_bytes BOOST_REQUIRE_EQUAL(success(), - openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, detail)); - - // While OPEN the row carries everything the council votes against. - const auto open_row = get_uwchal(1); - BOOST_REQUIRE_EQUAL(detail, open_row["detail"].as_string()); - BOOST_REQUIRE_EQUAL(5u, open_row["electorate"].get_array().size()); - - BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER1, 1, BALLOT_REJECT_REFUND)); - BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER2, 1, BALLOT_REJECT_REFUND)); - BOOST_REQUIRE(!get_uwchal_vote(1, VOTER1).is_null()); - BOOST_REQUIRE_EQUAL(success(), voteuwchal(VOTER3, 1, BALLOT_REJECT_REFUND)); - BOOST_REQUIRE_EQUAL(success(), chkuwchal(1)); - - // Resolved: the fixed-width record stands, both variable-length fields are empty, the ballots - // are gone. - const auto tomb = get_uwchal(1); - BOOST_REQUIRE_EQUAL("DISPUTE_STATUS_RESOLVED", tomb["status"].as_string()); - BOOST_REQUIRE_EQUAL("REJECTED_REFUND", tomb["verdict"].as_string()); - BOOST_REQUIRE_EQUAL(ATT_ID, tomb["uwreq_id"].as_uint64()); - BOOST_REQUIRE_EQUAL(3u, tomb["quorum"].as_uint64()); - BOOST_REQUIRE(tomb["detail"].as_string().empty()); - BOOST_REQUIRE_EQUAL(0u, tomb["electorate"].get_array().size()); - for (auto v : {VOTER1, VOTER2, VOTER3}) { - BOOST_REQUIRE(get_uwchal_vote(1, v).is_null()); - } + push(MSGCH_ACCOUNT, msgch_abi, MSGCH_ACCOUNT, "bootstrap"_n, mvo())); + produce_blocks(); - // The tombstone still does its consensus job — a verdict is final per commitment, even though - // the reject freed the locks and they are live again. - BOOST_REQUIRE_EQUAL(0u, uwchalbond(ATT_ID, UWRIT_OP)); - BOOST_REQUIRE(openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "retry") - .find("already been challenged") != std::string::npos); + // The inline genesis advance passed the gate: epoch 0 -> 1, and the gate cleared the stale + // blocklog row on its way through. + BOOST_REQUIRE_EQUAL(current_epoch(), 1u); + BOOST_REQUIRE(get_blocklog(1).is_null()); } FC_LOG_AND_RETHROW() } -// The payout is a pull under the recipient's own authority, and it settles exactly once. -BOOST_FIXTURE_TEST_CASE(claimbond_guards, sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9350; - make_confirmed_uwreq(ATT_ID); +// A forged/invalid delivery cannot strand the epoch. SEC-102's semantic-header check runs at +// INGRESS (msgch::deliver's inbound_envelope_valid gate), so a forged envelope reverts on delivery +// and records no envelope row -- it can never reach the consensus tally and leave a phantom +// consensus_reached with no outpcons row. A subsequent VALID delivery reaches consensus normally and +// chkcons proceeds to ATTEMPT advance (retry_count bumps) rather than waiting forever for a consensus +// row that an accepted-then-soft-dropped invalid winner would have left missing. Drives the +// production chkcons gate, not a direct epoch::advance. +BOOST_FIXTURE_TEST_CASE(forged_delivery_does_not_strand_chkcons, sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + const auto eth_code = fc::slug_name{"ETH"}.value; + const uint32_t epoch0 = current_epoch(); + const uint32_t target = epoch0 + 1; - BOOST_REQUIRE(claimbond(CHALLENGER).find("no claimable bond") != std::string::npos); + auto operator_payload = encode_operator_action( + sysio::opp::attestations::OperatorAction::ACTION_TYPE_DEPOSIT_REQUEST, + sysio::opp::types::CHAIN_KIND_EVM, + uwrit_op_eth_pubkey, + /*chain_code_v=*/ eth_code, + /*token_code_v=*/ eth_code, + /*amount=*/ 1'000'000); + const auto valid = encode_envelope_with_one_attestation( + epoch0, + sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION, + operator_payload); - BOOST_REQUIRE_EQUAL(success(), - openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "d")); - for (auto v : {VOTER1, VOTER2, VOTER3}) { - BOOST_REQUIRE_EQUAL(success(), voteuwchal(v, 1, BALLOT_REJECT_REFUND)); + // A forged copy: corrupt payload_checksum so the semantic header no longer recomputes. + std::vector forged; + { + sysio::opp::Envelope env; + BOOST_REQUIRE(env.ParseFromArray(valid.data(), static_cast(valid.size()))); + auto* h = env.mutable_messages(0)->mutable_header(); + std::string c = h->payload_checksum(); + c[0] ^= 0x01; + h->set_payload_checksum(c); + forged.resize(env.ByteSizeLong()); + env.SerializeToArray(forged.data(), static_cast(forged.size())); } - BOOST_REQUIRE_EQUAL(success(), chkuwchal(1)); - - const uint64_t bond = uwchal_bond_amount(1); - BOOST_REQUIRE_GT(bond, 0u); - BOOST_REQUIRE_EQUAL(bond, get_bond_credit(CHALLENGER)); - // Another account cannot pull someone else's credit. - BOOST_REQUIRE(claimbond(CHALLENGER, /*signer*/ VOTER1) - .find("missing authority of challenger") != std::string::npos); - - const int64_t before = wire_balance(CHALLENGER); - BOOST_REQUIRE_EQUAL(success(), claimbond(CHALLENGER)); - BOOST_REQUIRE_EQUAL(before + static_cast(bond), wire_balance(CHALLENGER)); - - // Erased on payout — a second pull draws nothing. - produce_block(); // identical claim bytes need fresh TAPOS to reach the contract's guard - BOOST_REQUIRE(claimbond(CHALLENGER).find("no claimable bond") != std::string::npos); -} FC_LOG_AND_RETHROW() } + // Forged delivery is rejected at ingress -- nothing recorded, no phantom consensus. + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: delivered envelope failed inbound-chain or " + "semantic-header validation"), + deliver(eth_code, forged)); + produce_blocks(); -// A hostile bond recipient cannot hold epoch advancement hostage. `sysio.token::transfer` runs -// its recipient's code through `require_recipient(to)`, so a contract at the challenger's account -// can assert on the incoming refund. Were the payout pushed from the crank, that assert would -// abort `sysio.epoch::advance -> chklocks -> chkuwchal`, roll the challenge back to OPEN with its -// locks still held, and be retried identically by every later sweep — a permanent chain-wide -// stall bought with one permissionless filing. Crediting keeps the crank inside system-owned -// state: the challenge resolves, the locks release, and the only transaction the hostile account -// can abort is its own claim, stranding only its own funds. -BOOST_FIXTURE_TEST_CASE(hostile_bond_recipient_cannot_stall_the_epoch_tick, - sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9450; - make_confirmed_uwreq(ATT_ID); + auto retry_count = [&]() -> uint32_t { + auto bl = get_blocklog(target); + return bl.is_null() ? 0u : bl["retry_count"].as(); + }; - BOOST_REQUIRE_EQUAL(success(), - openuwchal(CHALLENGER, ATT_ID, UWRIT_OP, REASON_DEPOSIT_MISSING, "then i go hostile")); - const uint64_t bond = uwchal_bond_amount(1); - BOOST_REQUIRE_GT(bond, 0u); - - // The escrow is in; NOW the challenger deploys a contract that rejects every incoming - // notification. Nothing prevents this — filing is permissionless and code can land afterwards, - // so the payout path can never assume a bare recipient. (The fixture creates the challenger - // without a roa policy; setcode needs RAM, so grant one first.) - BOOST_REQUIRE(add_roa_policy(NODE_DADDY, CHALLENGER, "0.0010 SYS", "0.0010 SYS", - "50.0000 SYS", /*time_block*/ 0, /*network_gen*/ 0)); - set_code(CHALLENGER, contracts::util::reject_all_wasm()); - produce_blocks(); // seal pending before the jump (see openuwchal_guards) - produce_block(fc::hours(13)); // past the lock expiry: the sweep must now resolve the challenge - - // The epoch tick's sweep runs to completion: it pokes the tally, the sub-quorum challenge - // LAPSES, and nothing in the crank touches the hostile account. - BOOST_REQUIRE_EQUAL(success(), chklocks()); - BOOST_REQUIRE_EQUAL("LAPSED", get_uwchal(1)["verdict"].as_string()); - BOOST_REQUIRE_EQUAL(bond, get_bond_credit(CHALLENGER)); - BOOST_REQUIRE_EQUAL(static_cast(bond), wire_balance(CHALG_ACCOUNT)); - - // The freed locks release on the next sweep and the uwreq settles — advancement never stalled. - produce_block(); // identical sweep bytes — fresh TAPOS - BOOST_REQUIRE_EQUAL(success(), chklocks()); - BOOST_REQUIRE(get_lock(1).is_null()); - BOOST_REQUIRE(get_lock(2).is_null()); - BOOST_REQUIRE_EQUAL("UNDERWRITE_REQUEST_STATUS_COMPLETED", - get_uwreq(ATT_ID)["status"].as_string()); - - // Only the hostile account's OWN claim fails, and its credit simply stays put. - BOOST_REQUIRE(claimbond(CHALLENGER).find("rejecting all notifications") != std::string::npos); - BOOST_REQUIRE_EQUAL(bond, get_bond_credit(CHALLENGER)); - BOOST_REQUIRE_EQUAL(static_cast(bond), wire_balance(CHALG_ACCOUNT)); -} FC_LOG_AND_RETHROW() } + // The valid delivery (operators_per_epoch == 1 => Option-A unanimous) reaches consensus normally. + BOOST_REQUIRE_EQUAL(success(), deliver(eth_code, valid)); + produce_blocks(); + const uint32_t rc0 = retry_count(); -// The lock-control trio is chalg's alone. -BOOST_FIXTURE_TEST_CASE(lock_hold_actions_require_chalg_auth, sysio_uwchal_tester) { try { - constexpr uint64_t ATT_ID = 9900; - make_confirmed_uwreq(ATT_ID); - - BOOST_REQUIRE(push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "holdlocks"_n, mvo() - ("uwreq_id", ATT_ID)("underwriter", UWRIT_OP.to_string())("chal_id", 7)) - .find("missing authority of sysio.chalg") != std::string::npos); - BOOST_REQUIRE(push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "freelocks"_n, mvo() - ("uwreq_id", ATT_ID)("underwriter", UWRIT_OP.to_string())) - .find("missing authority of sysio.chalg") != std::string::npos); - BOOST_REQUIRE(push(UWRIT_ACCOUNT, uwrit_abi, UWRIT_ACCOUNT, "sweeplocks"_n, mvo() - ("uwreq_id", ATT_ID)("underwriter", UWRIT_OP.to_string())) - .find("missing authority of sysio.chalg") != std::string::npos); + // Pass the wall clock so chkcons fires advance; confirm it ATTEMPTED advance (retry_count bumps), + // i.e. it was NOT stranded waiting on a missing consensus row. Advance itself gate-blocks on + // emissions (this fixture deploys no sysio.system), exactly as in chkcons_survives_non_advancing_advance. + produce_block(fc::seconds(120)); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), chkcons()); + produce_blocks(); + BOOST_REQUIRE_EQUAL(current_epoch(), epoch0); + BOOST_REQUIRE_EQUAL(retry_count(), rc0 + 1); } FC_LOG_AND_RETHROW() } -// An OPERATOR_ACTION whose `op_address` is not a key the chain family can link — here 20 -// address bytes where the EVM link holds the 33-byte pubkey — resolves to no account and is -// dropped; it must never abort the envelope (a link lookup only ever hashes EM / ED keys). -BOOST_FIXTURE_TEST_CASE(operator_action_with_a_malformed_address_is_dropped, - sysio_dispatch_tester) { try { - bootstrap_for_dispatch(); - const auto eth = fc::slug_name{"ETH"}.value; - const std::vector evm_address(20, '\x0c'); - const auto envelope = encode_envelope_with_one_attestation( - current_epoch(), sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION, - encode_operator_action(sysio::opp::attestations::OperatorAction::ACTION_TYPE_DEPOSIT_REQUEST, - ChainKind::CHAIN_KIND_EVM, evm_address, eth, eth, 1'000'000)); - BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/ eth, envelope)); - const auto op = get_operator(UWRIT_OP); - BOOST_REQUIRE(!op.is_null()); - BOOST_CHECK_EQUAL(0u, op["balances"].get_array().size()); -} FC_LOG_AND_RETHROW() } +; // ── SYNDICATE_LIQ / LIQ_YIELD -> sysio.synd ───────────────────────────────── @@ -7097,6 +1645,34 @@ BOOST_FIXTURE_TEST_CASE(msgch_routes_syndicate_liq_to_synd, BOOST_CHECK_EQUAL(9 * LIQ_UNIT, liq_balance(SYND_ACCOUNT)); } FC_LOG_AND_RETHROW() } +// Retired payloads must be inert and must not prevent current LIQ traffic in +// the same envelope from reaching the syndication contract. +BOOST_FIXTURE_TEST_CASE(retired_attestations_do_not_mutate_collateral_or_block_liq, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + setup_liq_for_dispatch(); + const auto eth = fc::slug_name{"ETH"}.value; + const auto liqeth = fc::slug_name{"LIQETH"}.value; + std::vector entries; + for (auto type : {ATTESTATION_TYPE_OPERATOR_ACTION, ATTESTATION_TYPE_SWAP_REQUEST, + ATTESTATION_TYPE_UNDERWRITE_INTENT_COMMIT, + ATTESTATION_TYPE_RESERVE_CREATE, ATTESTATION_TYPE_RESERVE_CREATE_CANCEL}) { + entries.emplace_back(type, std::string(1, '\x0a')); + } + entries.emplace_back(ATTESTATION_TYPE_SYNDICATE_LIQ, + encode_syndicate_liq(eth, ChainKind::CHAIN_KIND_EVM, uwrit_op_eth_pubkey, + liqeth, LIQ_UNIT, 1)); + const auto trace = deliver_trace(eth, + encode_envelope_with_mixed_attestations(current_epoch(), entries)); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + BOOST_CHECK(get_operator(UWRIT_OP)["balances"].get_array().empty()); + BOOST_CHECK(get_wtdw(0).is_null()); + BOOST_REQUIRE_EQUAL(1u, synd_items().size()); + BOOST_CHECK_EQUAL(LIQ_UNIT, liq_supply()); + BOOST_CHECK_EQUAL(LIQ_UNIT, liq_balance(SYND_ACCOUNT)); +} FC_LOG_AND_RETHROW() } + // sysio.msgch forwards each message's outpost custody total verbatim as the last argument of // `onsynd` and `onyield`: every syndication and yield report of one envelope carries its own reading. BOOST_FIXTURE_TEST_CASE(msgch_forwards_the_custody_total_to_synd, @@ -7366,7 +1942,7 @@ BOOST_FIXTURE_TEST_CASE(a_pulled_andon_cord_leaves_advance_running, sysio_dispat abi_serializer andon_abi; andon::deploy(*this, andon_abi, contracts::andon_wasm(), contracts::andon_abi()); BOOST_REQUIRE_EQUAL(success(), andon::pull(*this, andon_abi)); - setup_wire_token_and_reserves(); + setup_wire_token(); enable_epoch_advancement(); BOOST_REQUIRE_EQUAL(current_epoch(), 0u); @@ -7376,7 +1952,6 @@ BOOST_FIXTURE_TEST_CASE(a_pulled_andon_cord_leaves_advance_running, sysio_dispat BOOST_REQUIRE(get_blocklog(1).is_null()); } FC_LOG_AND_RETHROW() } - // ── The solvency check (sysio.synd) through a delivered envelope ───────────── // Review focus 3: sysio.msgch sends an envelope's messages to sysio.synd as inline actions, and each @@ -7427,7 +2002,7 @@ BOOST_FIXTURE_TEST_CASE(a_custody_shortfall_pulls_the_cord_and_the_epoch_advance abi_serializer andon_abi; andon::deploy(*this, andon_abi, contracts::andon_wasm(), contracts::andon_abi()); setup_liq_for_dispatch(); - setup_wire_token_and_reserves(); + setup_wire_token(); enable_epoch_advancement(); const auto eth = fc::slug_name{"ETH"}.value; @@ -7483,7 +2058,6 @@ BOOST_FIXTURE_TEST_CASE(a_custody_shortfall_pulls_the_cord_and_the_epoch_advance BOOST_CHECK_EQUAL(current_epoch(), epoch + 1); } FC_LOG_AND_RETHROW() } - // Review focus 4 with work pending: the cord a shortfall pulls inline from `onsynd` freezes the very // queue step `closeenv` runs later in the same delivered envelope. The earlier envelope of the pair was // partly bonded, challenged and ruled VALID, so the next step would record the ruling, claim the hold-bond @@ -7512,7 +2086,7 @@ BOOST_FIXTURE_TEST_CASE(a_shortfall_freezes_the_same_envelopes_queue_step, sysio ("chain_code", codename_mvo("ETH"))("token_code", codename_mvo("LIQETH"))("synd_fee_bps", 0) ("desynd_fee_bps", 0)("synd_burst", 1'000'000 * LIQ_UNIT)("synd_refill", 1'000'000 * LIQ_UNIT) ("desynd_burst", 0)("desynd_refill", 0)("window_sec", 10800)("bounty", 0)("challenge_extra", 0))); - setup_wire_token_and_reserves(); + setup_wire_token(); enable_epoch_advancement(); const auto eth = fc::slug_name{"ETH"}.value; @@ -7620,7 +2194,6 @@ BOOST_FIXTURE_TEST_CASE(a_shortfall_freezes_the_same_envelopes_queue_step, sysio BOOST_CHECK(!envelope["share_pending"].as_bool()); } FC_LOG_AND_RETHROW() } - // The host-side simulator feeds canonical protobuf envelopes into the production // msgch -> epoch consensus -> synd -> liq path. No patched OPP or dispatch stub. BOOST_AUTO_TEST_CASE(generic_external_simulator_drives_production_dispatch) { @@ -7654,7 +2227,6 @@ BOOST_AUTO_TEST_CASE(generic_external_simulator_drives_production_dispatch) { } } - // A real staking-reward envelope crosses msgch -> dclaim -> system -> token. // Small treasury liquidity deliberately reaches the second fundclaim cap. BOOST_FIXTURE_TEST_CASE(generic_staking_rewards_fund_claim_and_dedupe_through_dispatch, sysio_dispatch_tester) { diff --git a/contracts/tests/sysio.dispute_tests.cpp b/contracts/tests/sysio.dispute_tests.cpp index 0c73b93940..206470e5bd 100644 --- a/contracts/tests/sysio.dispute_tests.cpp +++ b/contracts/tests/sysio.dispute_tests.cpp @@ -96,7 +96,6 @@ class sysio_dispute_tester : public tester { static constexpr auto EPOCH_ACCOUNT = "sysio.epoch"_n; static constexpr auto MSGCH_ACCOUNT = "sysio.msgch"_n; static constexpr auto OPREG_ACCOUNT = "sysio.opreg"_n; - static constexpr auto UWRIT_ACCOUNT = "sysio.uwrit"_n; static constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; static constexpr auto ROA_ACCOUNT = "sysio.roa"_n; static constexpr auto BATCHOP = "batchop.a"_n; @@ -111,7 +110,7 @@ class sysio_dispute_tester : public tester { create_accounts({ CHALG_ACCOUNT, EPOCH_ACCOUNT, MSGCH_ACCOUNT, OPREG_ACCOUNT, - UWRIT_ACCOUNT, CHAINS_ACCOUNT, BATCHOP + CHAINS_ACCOUNT, BATCHOP, "sysio.token"_n }); // Tier-1 voter accounts (node owners) and a Tier-2 account (ineligible voter). Created without // a roa policy so regnodeowner's reslimit creation does not collide. @@ -124,7 +123,6 @@ class sysio_dispute_tester : public tester { deploy(EPOCH_ACCOUNT, contracts::epoch_wasm(), contracts::epoch_abi(), epoch_abi); deploy(MSGCH_ACCOUNT, contracts::msgch_wasm(), contracts::msgch_abi(), msgch_abi); deploy(OPREG_ACCOUNT, contracts::opreg_wasm(), contracts::opreg_abi(), opreg_abi); - deploy(UWRIT_ACCOUNT, contracts::uwrit_wasm(), contracts::uwrit_abi(), uwrit_abi); deploy(CHAINS_ACCOUNT, contracts::chains_wasm(), contracts::chains_abi(), chains_abi); // sysio.roa is a genesis system account already running this build's code; load its abi. @@ -371,20 +369,25 @@ class sysio_dispute_tester : public tester { ("min_bond", min_bond)("config_timestamp_ms", uint64_t{0})); } - action_result depositinle(name account, std::string_view chain_code, - std::string_view token_code, uint64_t amount) { - return push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "depositinle"_n, mvo() - ("account", account.to_string())("chain_code", chain_code) - ("token_code", token_code)("amount", amount) - ("actor_chain", ChainKind::CHAIN_KIND_EVM)("actor_address", std::vector{}) - ("original_message_id", std::string(64, '0'))); + action_result fund_and_bond(name account, uint64_t amount) { + const auto token = "sysio.token"_n; + set_code(token, contracts::token_wasm()); + set_abi(token, contracts::token_abi().data()); + set_privileged(token); + base_tester::push_action(token, "create"_n, token, + mvo()("issuer", "sysio")("maximum_supply", "1000000000.000000000 WIRE")); + const auto quantity = asset(static_cast(amount), symbol(9, "WIRE")); + base_tester::push_action(token, "issue"_n, config::system_account_name, + mvo()("to", "sysio")("quantity", quantity)("memo", "bond funding")); + base_tester::push_action(token, "transfer"_n, config::system_account_name, + mvo()("from", "sysio")("to", account)("quantity", quantity)("memo", "bond funding")); + return push(OPREG_ACCOUNT, opreg_abi, account, "deposit"_n, + mvo()("account", account)("token_code", "WIRE")("amount", amount)); } - action_result withdrawinle(name account, std::string_view chain_code, - std::string_view token_code, uint64_t amount) { - return push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "withdrawinle"_n, mvo() - ("account", account.to_string())("chain_code", chain_code) - ("token_code", token_code)("amount", amount)); + action_result withdraw(name account, uint64_t amount) { + return push(OPREG_ACCOUNT, opreg_abi, account, "withdraw"_n, + mvo()("account", account)("token_code", "WIRE")("amount", amount)); } action_result flushwtdw(uint32_t up_to_epoch) { @@ -403,7 +406,7 @@ class sysio_dispute_tester : public tester { ("batch_op_name", op.to_string())("chain_code", chain_code)("data", data)); } - abi_serializer chalg_abi, epoch_abi, msgch_abi, opreg_abi, uwrit_abi, chains_abi, roa_abi, system_abi; + abi_serializer chalg_abi, epoch_abi, msgch_abi, opreg_abi, chains_abi, roa_abi, system_abi; }; /// The same OPP stack WITHOUT the emissions configuration: for tests that register node owners @@ -1064,14 +1067,14 @@ BOOST_FIXTURE_TEST_CASE(deliver_rejects_decollateralized_unknown_group_member, s ("terminate_prune_delay_ms", 600000)("terminate_max_consecutive_misses", 5) ("terminate_max_pct_misses_24h", 5)("terminate_window_ms", uint64_t{24ULL * 60 * 60 * 1000}) ("req_prod_collat", fc::variants{}) - ("req_batchop_collat", fc::variants{make_chain_min_bond("ETH", "ETH", MIN_BOND)}) + ("req_batchop_collat", fc::variants{make_chain_min_bond("WIRE", "WIRE", MIN_BOND)}) ("req_uw_collat", fc::variants{}))); // Register + fully collateralize batchop.b -> ACTIVE. BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, mvo() ("account", BATCHOP_B.to_string())("type", OperatorType::OPERATOR_TYPE_BATCH) ("is_bootstrapped", false))); - BOOST_REQUIRE_EQUAL(success(), depositinle(BATCHOP_B, "ETH", "ETH", MIN_BOND)); + BOOST_REQUIRE_EQUAL(success(), fund_and_bond(BATCHOP_B, MIN_BOND)); BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_ACTIVE", operator_status(BATCHOP_B)); // Schedule it into the single-slot group (non-bootstrapped sorts ahead of the @@ -1082,7 +1085,7 @@ BOOST_FIXTURE_TEST_CASE(deliver_rejects_decollateralized_unknown_group_member, s // Withdraw the full bond and flush past maturity -> reevaluate drops it to UNKNOWN. // The epoch group snapshot is untouched (no reschedule), so it stays resident. - BOOST_REQUIRE_EQUAL(success(), withdrawinle(BATCHOP_B, "ETH", "ETH", MIN_BOND)); + BOOST_REQUIRE_EQUAL(success(), withdraw(BATCHOP_B, MIN_BOND)); BOOST_REQUIRE_EQUAL(success(), flushwtdw(1'000'000u)); BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_UNKNOWN", operator_status(BATCHOP_B)); BOOST_REQUIRE(group_contains(current_group(), BATCHOP_B)); diff --git a/contracts/tests/sysio.epoch_flushwtdw_tests.cpp b/contracts/tests/sysio.epoch_flushwtdw_tests.cpp index 3fc1c889e5..f65f28adf7 100644 --- a/contracts/tests/sysio.epoch_flushwtdw_tests.cpp +++ b/contracts/tests/sysio.epoch_flushwtdw_tests.cpp @@ -33,7 +33,6 @@ class sysio_epoch_flushwtdw_tester : public tester { static constexpr auto MSGCH_ACCOUNT = "sysio.msgch"_n; static constexpr auto CHALG_ACCOUNT = "sysio.chalg"_n; static constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; - static constexpr auto UWRIT_ACCOUNT = "sysio.uwrit"_n; static constexpr auto BATCHOP = "batchop.a"_n; static constexpr auto UWRIT_OP = "uwrit.alice"_n; static constexpr auto UWRIT_OP_B = "uwrit.bob"_n; @@ -63,7 +62,7 @@ class sysio_epoch_flushwtdw_tester : public tester { // throws `account_name_exists_exception`. create_accounts({ TOKEN_ACCOUNT, EPOCH_ACCOUNT, OPREG_ACCOUNT, MSGCH_ACCOUNT, - CHALG_ACCOUNT, CHAINS_ACCOUNT, UWRIT_ACCOUNT, BATCHOP, UWRIT_OP, + CHALG_ACCOUNT, CHAINS_ACCOUNT, BATCHOP, UWRIT_OP, UWRIT_OP_B, UWRIT_OP_C, "sysio.dclaim"_n, "sysio.gov"_n, "sysio.ops"_n }); @@ -79,7 +78,6 @@ class sysio_epoch_flushwtdw_tester : public tester { deploy(OPREG_ACCOUNT, contracts::opreg_wasm(), contracts::opreg_abi(), opreg_abi); deploy(MSGCH_ACCOUNT, contracts::msgch_wasm(), contracts::msgch_abi(), msgch_abi); deploy(CHAINS_ACCOUNT, contracts::chains_wasm(), contracts::chains_abi(), chains_abi); - deploy(UWRIT_ACCOUNT, contracts::uwrit_wasm(), contracts::uwrit_abi(), uwrit_abi); deploy(TOKEN_ACCOUNT, contracts::token_wasm(), contracts::token_abi(), token_abi); produce_blocks(1); @@ -288,29 +286,6 @@ class sysio_epoch_flushwtdw_tester : public tester { ("is_bootstrapped", false)); } - /// Direct opreg::depositinle, signed as opreg itself. - /// Signature: codenames for chain and token, plus the actor identity. - action_result depositinle(name account, std::string_view chain_code, - std::string_view token_code, uint64_t amount) { - return push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "depositinle"_n, mvo() - ("account", account.to_string()) - ("chain_code", chain_code) - ("token_code", token_code) - ("amount", amount) - ("actor_chain", ChainKind::CHAIN_KIND_EVM) - ("actor_address", std::vector{}) - ("original_message_id", std::string(64, '0'))); - } - - action_result withdrawinle(name account, std::string_view chain_code, - std::string_view token_code, uint64_t amount) { - return push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "withdrawinle"_n, mvo() - ("account", account.to_string()) - ("chain_code", chain_code) - ("token_code", token_code) - ("amount", amount)); - } - action_result slash(name account, std::string reason) { return push(OPREG_ACCOUNT, opreg_abi, CHALG_ACCOUNT, "slash"_n, mvo() ("account", account.to_string()) @@ -363,23 +338,30 @@ class sysio_epoch_flushwtdw_tester : public tester { return n; } - std::vector> - collect_attestation_data(sysio::opp::types::AttestationType expected, - uint64_t scan_until = 32) { - std::vector> out; - for (uint64_t id = 0; id < scan_until; ++id) { - auto data = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, - "attestations"_n, id); - if (data.empty()) continue; - auto row = msgch_abi.binary_to_variant("attestation_entry", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - if (row["type"].as() != expected) continue; - out.push_back(row["data"].as>()); - } - return out; + action_result fund_and_bond(name account, std::string_view chain, std::string_view token, uint64_t amount) { + BOOST_REQUIRE_EQUAL("WIRE", chain); + const auto quantity = asset(static_cast(amount), symbol(9, "WIRE")); + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, SYSIO_ACCOUNT, "transfer"_n, + mvo()("from", SYSIO_ACCOUNT)("to", account)("quantity", quantity)("memo", "bond funding"))); + return push(OPREG_ACCOUNT, opreg_abi, account, "deposit"_n, + mvo()("account", account)("token_code", token)("amount", amount)); } - abi_serializer sysio_abi, token_abi, epoch_abi, opreg_abi, msgch_abi, chains_abi, uwrit_abi; + action_result withdraw(name account, std::string_view chain, std::string_view token, uint64_t amount) { + BOOST_REQUIRE_EQUAL("WIRE", chain); + return push(OPREG_ACCOUNT, opreg_abi, account, "withdraw"_n, + mvo()("account", account)("token_code", token)("amount", amount)); + } + + void claim_exact(name account, uint64_t amount) { + const auto before = get_currency_balance(TOKEN_ACCOUNT, symbol(9, "WIRE"), account).get_amount(); + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, account, "claimremit"_n, + mvo()("account", account)("token_code", "WIRE"))); + BOOST_CHECK_EQUAL(before + amount, + get_currency_balance(TOKEN_ACCOUNT, symbol(9, "WIRE"), account).get_amount()); + } + + abi_serializer sysio_abi, token_abi, epoch_abi, opreg_abi, msgch_abi, chains_abi; }; // ---- Tests ---- @@ -394,29 +376,30 @@ BOOST_FIXTURE_TEST_CASE(flushwtdw_requires_epoch_auth, sysio_epoch_flushwtdw_tes BOOST_REQUIRE(r.find("missing authority of sysio.epoch") != std::string::npos); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(advance_drains_matured_eth_withdraw, sysio_epoch_flushwtdw_tester) { try { +BOOST_FIXTURE_TEST_CASE(advance_releases_matured_native_withdraw, sysio_epoch_flushwtdw_tester) { try { bootstrap_for_flushwtdw(); constexpr uint64_t INITIAL_DEPOSIT = 5'000'000; constexpr uint64_t WITHDRAW_AMOUNT = 2'000'000; BOOST_REQUIRE_EQUAL(success(), - depositinle(UWRIT_OP, "ETH", "ETH", INITIAL_DEPOSIT)); + fund_and_bond(UWRIT_OP, "WIRE", "WIRE", INITIAL_DEPOSIT)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle(UWRIT_OP, "ETH", "ETH", WITHDRAW_AMOUNT)); + withdraw(UWRIT_OP, "WIRE", "WIRE", WITHDRAW_AMOUNT)); BOOST_REQUIRE(!get_wtdw(1).is_null()); - BOOST_REQUIRE_EQUAL(INITIAL_DEPOSIT, balance_of(UWRIT_OP, "ETH", "ETH")); + BOOST_REQUIRE_EQUAL(INITIAL_DEPOSIT, balance_of(UWRIT_OP, "WIRE", "WIRE")); advance_one_epoch(); advance_one_epoch(); BOOST_REQUIRE(get_wtdw(1).is_null()); BOOST_REQUIRE_EQUAL(INITIAL_DEPOSIT - WITHDRAW_AMOUNT, - balance_of(UWRIT_OP, "ETH", "ETH")); + balance_of(UWRIT_OP, "WIRE", "WIRE")); + claim_exact(UWRIT_OP, WITHDRAW_AMOUNT); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(flushwtdw_direct_emits_withdraw_remit_attestation, +BOOST_FIXTURE_TEST_CASE(flushwtdw_credits_native_claim_without_outbound_attestation, sysio_epoch_flushwtdw_tester) { try { bootstrap_for_flushwtdw(); @@ -424,9 +407,9 @@ BOOST_FIXTURE_TEST_CASE(flushwtdw_direct_emits_withdraw_remit_attestation, constexpr uint64_t WITHDRAW_AMOUNT = 400'000; BOOST_REQUIRE_EQUAL(success(), - depositinle(UWRIT_OP, "ETH", "ETH", INITIAL_DEPOSIT)); + fund_and_bond(UWRIT_OP, "WIRE", "WIRE", INITIAL_DEPOSIT)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle(UWRIT_OP, "ETH", "ETH", WITHDRAW_AMOUNT)); + withdraw(UWRIT_OP, "WIRE", "WIRE", WITHDRAW_AMOUNT)); constexpr uint32_t FUTURE_EPOCH = 100; BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, EPOCH_ACCOUNT, @@ -434,10 +417,11 @@ BOOST_FIXTURE_TEST_CASE(flushwtdw_direct_emits_withdraw_remit_attestation, BOOST_REQUIRE(get_wtdw(1).is_null()); BOOST_REQUIRE_EQUAL(INITIAL_DEPOSIT - WITHDRAW_AMOUNT, - balance_of(UWRIT_OP, "ETH", "ETH")); + balance_of(UWRIT_OP, "WIRE", "WIRE")); - BOOST_REQUIRE_GE(count_attestations( - sysio::opp::types::AttestationType::ATTESTATION_TYPE_OPERATOR_ACTION), 1u); + BOOST_REQUIRE_EQUAL(count_attestations( + sysio::opp::types::AttestationType::ATTESTATION_TYPE_OPERATOR_ACTION), 0u); + claim_exact(UWRIT_OP, WITHDRAW_AMOUNT); } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(single_advance_leaves_immature_row_intact, @@ -448,14 +432,14 @@ BOOST_FIXTURE_TEST_CASE(single_advance_leaves_immature_row_intact, constexpr uint64_t WITHDRAW_AMOUNT = 400'000; BOOST_REQUIRE_EQUAL(success(), - depositinle(UWRIT_OP, "ETH", "ETH", INITIAL_DEPOSIT)); + fund_and_bond(UWRIT_OP, "WIRE", "WIRE", INITIAL_DEPOSIT)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle(UWRIT_OP, "ETH", "ETH", WITHDRAW_AMOUNT)); + withdraw(UWRIT_OP, "WIRE", "WIRE", WITHDRAW_AMOUNT)); advance_one_epoch(); // only one boundary — eligible_at_epoch is +2 BOOST_REQUIRE(!get_wtdw(1).is_null()); - BOOST_REQUIRE_EQUAL(INITIAL_DEPOSIT, balance_of(UWRIT_OP, "ETH", "ETH")); + BOOST_REQUIRE_EQUAL(INITIAL_DEPOSIT, balance_of(UWRIT_OP, "WIRE", "WIRE")); } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(slashed_operator_withdraw_drops_silently, @@ -466,107 +450,19 @@ BOOST_FIXTURE_TEST_CASE(slashed_operator_withdraw_drops_silently, constexpr uint64_t WITHDRAW_AMOUNT = 400'000; BOOST_REQUIRE_EQUAL(success(), - depositinle(UWRIT_OP, "ETH", "ETH", INITIAL_DEPOSIT)); + fund_and_bond(UWRIT_OP, "WIRE", "WIRE", INITIAL_DEPOSIT)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle(UWRIT_OP, "ETH", "ETH", WITHDRAW_AMOUNT)); + withdraw(UWRIT_OP, "WIRE", "WIRE", WITHDRAW_AMOUNT)); BOOST_REQUIRE_EQUAL(success(), slash(UWRIT_OP, "test slash")); - uint64_t balance_after_slash = balance_of(UWRIT_OP, "ETH", "ETH"); + uint64_t balance_after_slash = balance_of(UWRIT_OP, "WIRE", "WIRE"); advance_one_epoch(); advance_one_epoch(); BOOST_REQUIRE(get_wtdw(1).is_null()); BOOST_REQUIRE_EQUAL(balance_after_slash, - balance_of(UWRIT_OP, "ETH", "ETH")); -} FC_LOG_AND_RETHROW() } - -/// The "clean protobuf" regression tests below verify that the bytes -/// emitted by `sysio.opreg::emit_*` parse as a standard protobuf -/// `OperatorAction` message. They were originally written against the v5 -/// OperatorAction proto (with a `chain` ChainKind field and a -/// `TokenAmount.kind` TokenKind field). The proto carries -/// `chain_code` (uint64) and `amount.token_code` (uint64) instead — same -/// shape, different field semantics — so the parse + field-1-tag-byte -/// invariant still holds. -BOOST_FIXTURE_TEST_CASE(flushwtdw_attestation_data_is_clean_protobuf, - sysio_epoch_flushwtdw_tester) { try { - bootstrap_for_flushwtdw(); - - constexpr uint64_t INITIAL_DEPOSIT = 1'000'000; - constexpr uint64_t WITHDRAW_AMOUNT = 400'000; - - BOOST_REQUIRE_EQUAL(success(), - depositinle(UWRIT_OP, "ETH", "ETH", INITIAL_DEPOSIT)); - BOOST_REQUIRE_EQUAL(success(), - withdrawinle(UWRIT_OP, "ETH", "ETH", WITHDRAW_AMOUNT)); - - constexpr uint32_t FUTURE_EPOCH = 100; - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, EPOCH_ACCOUNT, - "flushwtdw"_n, mvo()("current_epoch", FUTURE_EPOCH))); - - auto rows = collect_attestation_data( - sysio::opp::types::AttestationType::ATTESTATION_TYPE_OPERATOR_ACTION); - BOOST_REQUIRE_EQUAL(rows.size(), 1u); - const auto& bytes = rows.front(); - BOOST_REQUIRE(!bytes.empty()); - - // First byte MUST be a valid protobuf field-1-varint tag (0x08 = - // OperatorAction.action_type). - BOOST_REQUIRE_EQUAL(static_cast(bytes.front()), 0x08u); - - sysio::opp::attestations::OperatorAction oa; - BOOST_REQUIRE(oa.ParseFromArray(bytes.data(), - static_cast(bytes.size()))); - BOOST_REQUIRE_EQUAL( - static_cast(oa.action_type()), - static_cast(sysio::opp::attestations:: - OperatorAction_ActionType_ACTION_TYPE_WITHDRAW_REMIT)); - BOOST_REQUIRE_EQUAL(static_cast(oa.amount().amount()), - WITHDRAW_AMOUNT); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(flushwtdw_multiple_attestations_all_clean_protobuf, - sysio_epoch_flushwtdw_tester) { try { - bootstrap_for_flushwtdw(); - - constexpr uint64_t INITIAL_DEPOSIT = 1'000'000; - - const std::array operators{ UWRIT_OP, UWRIT_OP_B, UWRIT_OP_C }; - const std::array withdraws{ 100'000, 200'000, 300'000 }; - - BOOST_REQUIRE_EQUAL(success(), regunderwriter(UWRIT_OP_B)); - BOOST_REQUIRE_EQUAL(success(), regunderwriter(UWRIT_OP_C)); - for (size_t i = 0; i < operators.size(); ++i) { - BOOST_REQUIRE_EQUAL(success(), - depositinle(operators[i], "ETH", "ETH", INITIAL_DEPOSIT)); - BOOST_REQUIRE_EQUAL(success(), - withdrawinle(operators[i], "ETH", "ETH", withdraws[i])); - } - - constexpr uint32_t FUTURE_EPOCH = 100; - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, EPOCH_ACCOUNT, - "flushwtdw"_n, mvo()("current_epoch", FUTURE_EPOCH))); - - auto rows = collect_attestation_data( - sysio::opp::types::AttestationType::ATTESTATION_TYPE_OPERATOR_ACTION); - BOOST_REQUIRE_EQUAL(rows.size(), withdraws.size()); - - for (size_t i = 0; i < rows.size(); ++i) { - const auto& bytes = rows[i]; - BOOST_REQUIRE(!bytes.empty()); - BOOST_REQUIRE_EQUAL(static_cast(bytes.front()), 0x08u); - - sysio::opp::attestations::OperatorAction oa; - BOOST_REQUIRE(oa.ParseFromArray(bytes.data(), - static_cast(bytes.size()))); - BOOST_REQUIRE_EQUAL( - static_cast(oa.action_type()), - static_cast(sysio::opp::attestations:: - OperatorAction_ActionType_ACTION_TYPE_WITHDRAW_REMIT)); - BOOST_REQUIRE_EQUAL(static_cast(oa.amount().amount()), - withdraws[i]); - } + balance_of(UWRIT_OP, "WIRE", "WIRE")); } FC_LOG_AND_RETHROW() } BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index 6e06e2b82c..5c0be1adfe 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -212,7 +212,6 @@ class sysio_msgch_chain_tester : public tester { static constexpr auto MSGCH_ACCOUNT = "sysio.msgch"_n; static constexpr auto CHALG_ACCOUNT = "sysio.chalg"_n; static constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; - static constexpr auto UWRIT_ACCOUNT = "sysio.uwrit"_n; static constexpr auto ROA_ACCOUNT = "sysio.roa"_n; static constexpr auto BATCHOP = "batchop.a"_n; static constexpr auto BATCHOP_B = "batchop.b"_n; @@ -242,7 +241,7 @@ class sysio_msgch_chain_tester : public tester { // pay-epoch transfers. Same bootstrap rationale as sysio_epoch_flushwtdw_tester. create_accounts({ TOKEN_ACCOUNT, EPOCH_ACCOUNT, OPREG_ACCOUNT, MSGCH_ACCOUNT, - CHALG_ACCOUNT, CHAINS_ACCOUNT, UWRIT_ACCOUNT, + CHALG_ACCOUNT, CHAINS_ACCOUNT, BATCHOP, BATCHOP_B, BATCHOP_C, BATCHOP_D, BATCHOP_E, BATCHOP_F, "sysio.dclaim"_n, "sysio.gov"_n, "sysio.ops"_n }, false, true, !empty_roa); @@ -253,7 +252,6 @@ class sysio_msgch_chain_tester : public tester { deploy(OPREG_ACCOUNT, contracts::opreg_wasm(), contracts::opreg_abi(), opreg_abi); deploy(MSGCH_ACCOUNT, contracts::msgch_wasm(), contracts::msgch_abi(), msgch_abi); deploy(CHAINS_ACCOUNT, contracts::chains_wasm(), contracts::chains_abi(), chains_abi); - deploy(UWRIT_ACCOUNT, contracts::uwrit_wasm(), contracts::uwrit_abi(), uwrit_abi); deploy(TOKEN_ACCOUNT, contracts::token_wasm(), contracts::token_abi(), token_abi); produce_blocks(1); @@ -384,10 +382,7 @@ class sysio_msgch_chain_tester : public tester { ("req_batchop_collat", batchop_is_bootstrapped ? fc::variants{} : fc::variants{ - make_chain_min_bond(ETH_CHAIN_CODE, ETH_CHAIN_CODE, - BATCH_OPERATOR_MINIMUM_COLLATERAL), - make_chain_min_bond(SOL_CHAIN_CODE, SOL_CHAIN_CODE, - BATCH_OPERATOR_MINIMUM_COLLATERAL) }) + make_chain_min_bond("WIRE", "WIRE", BATCH_OPERATOR_MINIMUM_COLLATERAL) }) ("req_uw_collat", fc::variants{}))); const std::vector available_batch_ops{ @@ -409,11 +404,7 @@ class sysio_msgch_chain_tester : public tester { // A non-bootstrapped batch operator starts UNKNOWN and becomes ACTIVE // only after a collateral update re-evaluates its role eligibility. if (!batchop_is_bootstrapped) { - BOOST_REQUIRE_EQUAL(success(), depositinle(BATCHOP, ETH_CHAIN_CODE, ETH_CHAIN_CODE, - BATCH_OPERATOR_MINIMUM_COLLATERAL)); - BOOST_REQUIRE_EQUAL(success(), depositinle(BATCHOP, SOL_CHAIN_CODE, SOL_CHAIN_CODE, - BATCH_OPERATOR_MINIMUM_COLLATERAL, - opp::types::ChainKind::CHAIN_KIND_SVM)); + BOOST_REQUIRE_EQUAL(success(), fund_and_bond(BATCHOP, BATCH_OPERATOR_MINIMUM_COLLATERAL)); } BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, @@ -931,17 +922,12 @@ class sysio_msgch_chain_tester : public tester { /// Inline collateral credit (the path sysio.msgch drives in production; pushed directly here) that /// lifts a non-bootstrapped operator to ACTIVE once its bond meets the configured minimum. - action_result depositinle(name account, std::string_view chain_code, std::string_view token_code, - uint64_t amount, - opp::types::ChainKind actor_chain = opp::types::ChainKind::CHAIN_KIND_EVM) { - return push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "depositinle"_n, mvo() - ("account", account.to_string()) - ("chain_code", chain_code) - ("token_code", token_code) - ("amount", amount) - ("actor_chain", actor_chain) - ("actor_address", std::vector{}) - ("original_message_id", std::string(64, '0'))); + action_result fund_and_bond(name account, uint64_t amount) { + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi, SYSIO_ACCOUNT, "transfer"_n, + mvo()("from", SYSIO_ACCOUNT)("to", account) + ("quantity", asset(static_cast(amount), symbol(9, "WIRE")))("memo", "bond funding"))); + return push(OPREG_ACCOUNT, opreg_abi, account, "deposit"_n, + mvo()("account", account)("token_code", "WIRE")("amount", amount)); } /// bootstrap() variant for a real rotation: THREE single-operator groups (so a resident op is on @@ -969,7 +955,7 @@ class sysio_msgch_chain_tester : public tester { ("terminate_max_pct_misses_24h", 99) ("terminate_window_ms", terminate_window_ms) ("req_prod_collat", fc::variants{}) - ("req_batchop_collat", fc::variants{ make_chain_min_bond("ETH", "ETH", 1) }) + ("req_batchop_collat", fc::variants{ make_chain_min_bond("WIRE", "WIRE", 1) }) ("req_uw_collat", fc::variants{}))); register_chain(opp::types::ChainKind::CHAIN_KIND_EVM, "ETH", 31337); @@ -981,7 +967,7 @@ class sysio_msgch_chain_tester : public tester { BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, mvo() ("account", BATCHOP.to_string())("type", opp::types::OperatorType::OPERATOR_TYPE_BATCH) ("is_bootstrapped", false))); - BOOST_REQUIRE_EQUAL(success(), depositinle(BATCHOP, "ETH", "ETH", 1)); + BOOST_REQUIRE_EQUAL(success(), fund_and_bond(BATCHOP, 1)); for (const auto& op : {BATCHOP_B, BATCHOP_C}) { BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, mvo() ("account", op.to_string())("type", opp::types::OperatorType::OPERATOR_TYPE_BATCH) @@ -997,7 +983,7 @@ class sysio_msgch_chain_tester : public tester { produce_blocks(); } - abi_serializer sysio_abi, token_abi, epoch_abi, opreg_abi, msgch_abi, chalg_abi, chains_abi, uwrit_abi, roa_abi; + abi_serializer sysio_abi, token_abi, epoch_abi, opreg_abi, msgch_abi, chalg_abi, chains_abi, roa_abi; }; // --------------------------------------------------------------------------- @@ -1636,7 +1622,7 @@ BOOST_FIXTURE_TEST_CASE(noncanonical_delivery_slashes_before_termination, sysio_ constexpr const char* kHistoricalMissPayload = "history-miss"; constexpr const char* kCanonicalPayload = "canonical"; constexpr const char* kNonCanonicalPayload = "non-canonical"; - constexpr uint32_t kExpectedSlashActionsPerOutpost = 1; + constexpr uint32_t kExpectedSlashActionsPerOutpost = 0; constexpr uint32_t kEpochAdvanceCount = 1; constexpr uint32_t kExpectedDeliveredLogCount = 4; diff --git a/contracts/tests/sysio.opreg_tests.cpp b/contracts/tests/sysio.opreg_tests.cpp index c86d3ff13f..70640bde9d 100644 --- a/contracts/tests/sysio.opreg_tests.cpp +++ b/contracts/tests/sysio.opreg_tests.cpp @@ -497,14 +497,14 @@ class sysio_opreg_tester : public tester { /*terminate_window_ms=*/terminate_window_ms, /*req_prod_collat=*/{}, /*req_batchop_collat=*/{ - make_chain_min_bond("ETH", "ETH", kTestMinBond), + make_chain_min_bond("WIRE", "NTA", kTestMinBond), }, /*req_uw_collat=*/{})); BOOST_REQUIRE_EQUAL(success(), regoperator(account, OPERATOR_TYPE_BATCH, /*is_bootstrapped=*/false)); BOOST_REQUIRE_EQUAL(success(), - depositinle(account, "ETH", "ETH", kTestMinBond)); + bond_generic(account, "NTA", kTestMinBond)); produce_blocks(); auto op = get_operator(account); @@ -512,39 +512,6 @@ class sysio_opreg_tester : public tester { BOOST_REQUIRE_EQUAL(0, op["is_bootstrapped"].as_uint64()); } - // ── Collateral-action helpers (msgch-dispatched paths, codenames) ── - - /// `depositinle`: dispatched from sysio.msgch. - /// Signature: `(account, chain_code, token_code, amount, - /// actor_chain ChainKind, actor_address bytes, - /// original_message_id checksum256)`. - action_result depositinle(name account, - std::string_view chain_code, std::string_view token_code, - uint64_t amount, - ChainKind actor_chain = ChainKind::CHAIN_KIND_EVM, - const std::vector& actor_address = {}, - const std::string& original_message_id_hex = std::string(64, '0')) { - return push_opreg_action(OPREG_ACCOUNT, "depositinle"_n, mvo() - ("account", account) - ("chain_code", chain_code) - ("token_code", token_code) - ("amount", amount) - ("actor_chain", actor_chain) - ("actor_address", actor_address) - ("original_message_id", original_message_id_hex)); - } - - /// `withdrawinle`: same dispatch / auth model as `depositinle`. - action_result withdrawinle(name account, - std::string_view chain_code, std::string_view token_code, - uint64_t amount) { - return push_opreg_action(OPREG_ACCOUNT, "withdrawinle"_n, mvo() - ("account", account) - ("chain_code", chain_code) - ("token_code", token_code) - ("amount", amount)); - } - /// `withdraw`: operator-authorized request against the depot-native `(WIRE, token_code)` row. action_result withdraw(name account, std::string_view token_code, uint64_t amount) { return push_opreg_action(account, withdrawal_action::withdraw, mvo() @@ -601,16 +568,6 @@ class sysio_opreg_tester : public tester { ("is_eligible", is_eligible)); } - action_result releaselock(name signer, name account, - std::string_view chain_code, std::string_view token_code, - uint64_t amount) { - return push_opreg_action(signer, "releaselock"_n, mvo() - ("account", account) - ("chain_code", chain_code) - ("token_code", token_code) - ("amount", amount)); - } - /// Read a wtdwqueue row by request_id (primary key). fc::variant get_wtdw(uint64_t request_id) { auto data = get_row_by_id(OPREG_ACCOUNT, OPREG_ACCOUNT, "wtdwqueue"_n, request_id); @@ -696,6 +653,19 @@ class sysio_opreg_tester : public tester { produce_blocks(); } + bool generic_assets_ready = false; + + /// Mint generic shadow LIQ through sysio.synd and bond it through the public native action. + action_result bond_generic(name account, std::string_view token, uint64_t amount) { + if (!generic_assets_ready) { + setup_shadow_liq({sysio::testing::external::First, sysio::testing::external::Second}); + generic_assets_ready = true; + } + BOOST_REQUIRE_EQUAL(success(), push_contract(LIQ_ACCOUNT, liq_abi_ser, SYND_ACCOUNT, + shadow_action::mint, mvo()("to", account)("token_code", codename_mvo(token))("amount", amount))); + return deposit(account, token, amount); + } + // ── Shadow LIQ collateral (sysio.liq) ── /// Deploy a privileged registry or token contract and load its ABI serializer. @@ -961,14 +931,14 @@ BOOST_FIXTURE_TEST_CASE(setconfig_rejects_zero_min_bond, sysio_opreg_tester) { t "(an empty requirement set imposes no bond)"), setconfig(21, 63, 21, kDefaultPruneDelayMs, kDefaultMaxConsecutiveMisses, kDefaultMaxPctMisses24h, kTerminateWindowMs, - {}, {}, { make_chain_min_bond("ETH", "ETH", kRejectedZeroMinBond) }) + {}, {}, { make_chain_min_bond("WIRE", "NTA", kRejectedZeroMinBond) }) ); // The identical shape with a positive min_bond is accepted. BOOST_REQUIRE_EQUAL( success(), setconfig(21, 63, 21, kDefaultPruneDelayMs, kDefaultMaxConsecutiveMisses, kDefaultMaxPctMisses24h, kTerminateWindowMs, - {}, {}, { make_chain_min_bond("ETH", "ETH", kTestMinBond) }) + {}, {}, { make_chain_min_bond("WIRE", "NTA", kTestMinBond) }) ); } FC_LOG_AND_RETHROW() } @@ -1000,7 +970,7 @@ BOOST_FIXTURE_TEST_CASE(setconfig_rejects_uncanonical_collateral_code, sysio_opr success(), setconfig(21, 63, 21, kDefaultPruneDelayMs, kDefaultMaxConsecutiveMisses, kDefaultMaxPctMisses24h, kTerminateWindowMs, - {}, {}, { make_chain_min_bond("ETH", "ETH", kTestMinBond) }) + {}, {}, { make_chain_min_bond("WIRE", "NTA", kTestMinBond) }) ); } FC_LOG_AND_RETHROW() } @@ -1312,13 +1282,13 @@ BOOST_FIXTURE_TEST_CASE(deposit_credits_balance_row, sysio_opreg_tester) { try { BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.alice"_n, OPERATOR_TYPE_UNDERWRITER, false)); BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "ETH", "ETH", 1'000'000)); + bond_generic("uwrit.alice"_n, "NTA", 1'000'000)); auto op = get_operator("uwrit.alice"_n); auto balances = op["balances"].get_array(); BOOST_REQUIRE_EQUAL(1, balances.size()); - BOOST_REQUIRE_EQUAL(cn("ETH").value, balances[0]["chain_code"].as().value); - BOOST_REQUIRE_EQUAL(cn("ETH").value, balances[0]["token_code"].as().value); + BOOST_REQUIRE_EQUAL(cn("WIRE").value, balances[0]["chain_code"].as().value); + BOOST_REQUIRE_EQUAL(cn("NTA").value, balances[0]["token_code"].as().value); BOOST_REQUIRE_EQUAL(1'000'000, balances[0]["balance"].as_uint64()); } FC_LOG_AND_RETHROW() } @@ -1327,9 +1297,9 @@ BOOST_FIXTURE_TEST_CASE(deposit_aggregates_into_existing_balance_row, sysio_opre BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.alice"_n, OPERATOR_TYPE_UNDERWRITER, false)); BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "ETH", "ETH", 100)); + bond_generic("uwrit.alice"_n, "NTA", 100)); BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "ETH", "ETH", 50)); + bond_generic("uwrit.alice"_n, "NTA", 50)); auto op = get_operator("uwrit.alice"_n); auto balances = op["balances"].get_array(); @@ -1337,45 +1307,6 @@ BOOST_FIXTURE_TEST_CASE(deposit_aggregates_into_existing_balance_row, sysio_opre BOOST_REQUIRE_EQUAL(150, balances[0]["balance"].as_uint64()); } FC_LOG_AND_RETHROW() } -// SEC-103 (WSA-028 follow-up): operator collateral must never accumulate past -// asset::max_amount (2^62-1). A balance above the asset range would abort the -// depot-native claim payout's asset(balance, symbol); on the never-throw -// depositinle (OPP-inbound) path that abort would stall consensus. depositinle -// gates the RUNNING SUM: a credit that would push the balance over the cap is -// refunded via DEPOSIT_REVERT (the action still succeeds — never throws) and the -// stored balance is left unchanged. The single-value WSA-028 wrap is closed -// upstream in sysio.msgch; this is the accumulation guard. -BOOST_FIXTURE_TEST_CASE(depositinle_credit_over_max_collateral_is_reverted, sysio_opreg_tester) { try { - // asset::max_amount — the Antelope asset magnitude limit (2^62 - 1), the cap - // sysio.opreg enforces on a single balance row. - constexpr uint64_t MAX_COLLATERAL = (uint64_t{1} << 62) - 1; - - BOOST_REQUIRE_EQUAL(success(), setconfig()); - BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.alice"_n, OPERATOR_TYPE_UNDERWRITER, false)); - - // Credit the balance up to EXACTLY the cap — accepted (the boundary is inclusive). - BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "ETH", "ETH", MAX_COLLATERAL)); - { - auto op = get_operator("uwrit.alice"_n); - auto balances = op["balances"].get_array(); - BOOST_REQUIRE_EQUAL(1, balances.size()); - BOOST_REQUIRE_EQUAL(MAX_COLLATERAL, balances[0]["balance"].as_uint64()); - } - - // A further +1 would push the sum to 2^62 (one past asset::max_amount). It is - // refunded via DEPOSIT_REVERT: the action succeeds (never throws) and the - // stored balance is unchanged — the credit did NOT wrap or saturate it in. - BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "ETH", "ETH", 1)); - { - auto op = get_operator("uwrit.alice"_n); - auto balances = op["balances"].get_array(); - BOOST_REQUIRE_EQUAL(1, balances.size()); - BOOST_REQUIRE_EQUAL(MAX_COLLATERAL, balances[0]["balance"].as_uint64()); - } -} FC_LOG_AND_RETHROW() } - /// WIRE-375 / WNS-40: direct depot deposits must custody the same 9-decimal WIRE /// asset that the `(WIRE, WIRE)` collateral bucket denotes. Crediting raw /// 4-decimal SYS units into that bucket made challenge-bond valuation interpret @@ -1470,28 +1401,23 @@ BOOST_FIXTURE_TEST_CASE(deposit_keeps_chain_token_pairs_separate, sysio_opreg_te BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.alice"_n, OPERATOR_TYPE_UNDERWRITER, false)); BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "ETH", "ETH", 100)); + bond_generic("uwrit.alice"_n, "NTA", 100)); BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "SOL", "SOL", 200)); + bond_generic("uwrit.alice"_n, "NTB", 200)); auto op = get_operator("uwrit.alice"_n); auto balances = op["balances"].get_array(); BOOST_REQUIRE_EQUAL(2, balances.size()); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(depositinle_logs_failure_when_operator_slashed, sysio_opreg_tester) { try { +BOOST_FIXTURE_TEST_CASE(deposit_rejects_slashed_operator, sysio_opreg_tester) { try { BOOST_REQUIRE_EQUAL(success(), setconfig()); BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.alice"_n, OPERATOR_TYPE_UNDERWRITER, false)); BOOST_REQUIRE_EQUAL(success(), slash("uwrit.alice"_n, "test slash")); - - BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "ETH", "ETH", 100)); - - auto entry = latest_action_log("uwrit.alice"_n); - BOOST_REQUIRE(!entry.is_null()); - BOOST_REQUIRE_EQUAL(false, entry["success"].as_bool()); - BOOST_REQUIRE_EQUAL(std::string("operator not in a deposit-eligible state"), - entry["error_message"].as_string()); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: operator not in a deposit-eligible state"), + bond_generic("uwrit.alice"_n, "NTA", 100)); + BOOST_CHECK(get_operator("uwrit.alice"_n)["balances"].get_array().empty()); + BOOST_CHECK_EQUAL(100, get_currency_balance(LIQ_ACCOUNT, symbol(9, "NTA"), "uwrit.alice"_n).get_amount()); } FC_LOG_AND_RETHROW() } // ── queuewtdw + cancelwtdw ── @@ -1500,10 +1426,10 @@ BOOST_FIXTURE_TEST_CASE(queuewtdw_creates_request_row, sysio_opreg_tester) { try BOOST_REQUIRE_EQUAL(success(), setconfig()); BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.alice"_n, OPERATOR_TYPE_UNDERWRITER, false)); BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "ETH", "ETH", 1000)); + bond_generic("uwrit.alice"_n, "NTA", 1000)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle("uwrit.alice"_n, "ETH", "ETH", 400)); + withdraw("uwrit.alice"_n, "NTA", 400)); auto row = get_wtdw(1); // monotonic id starts at 1 BOOST_REQUIRE(!row.is_null()); @@ -1511,14 +1437,14 @@ BOOST_FIXTURE_TEST_CASE(queuewtdw_creates_request_row, sysio_opreg_tester) { try BOOST_REQUIRE_EQUAL(400, row["amount"].as_uint64()); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(withdrawinle_logs_failure_on_insufficient_available, sysio_opreg_tester) { try { +BOOST_FIXTURE_TEST_CASE(withdraw_logs_failure_on_insufficient_available, sysio_opreg_tester) { try { BOOST_REQUIRE_EQUAL(success(), setconfig()); BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.alice"_n, OPERATOR_TYPE_UNDERWRITER, false)); BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "ETH", "ETH", 100)); + bond_generic("uwrit.alice"_n, "NTA", 100)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle("uwrit.alice"_n, "ETH", "ETH", 200)); + withdraw("uwrit.alice"_n, "NTA", 200)); auto entry = latest_action_log("uwrit.alice"_n); BOOST_REQUIRE(!entry.is_null()); @@ -1527,18 +1453,18 @@ BOOST_FIXTURE_TEST_CASE(withdrawinle_logs_failure_on_insufficient_available, sys entry["error_message"].as_string()); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(withdrawinle_subtracts_from_available_on_subsequent_call, +BOOST_FIXTURE_TEST_CASE(withdraw_subtracts_from_available_on_subsequent_call, sysio_opreg_tester) { try { BOOST_REQUIRE_EQUAL(success(), setconfig()); BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.alice"_n, OPERATOR_TYPE_UNDERWRITER, false)); BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "ETH", "ETH", 1000)); + bond_generic("uwrit.alice"_n, "NTA", 1000)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle("uwrit.alice"_n, "ETH", "ETH", 700)); + withdraw("uwrit.alice"_n, "NTA", 700)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle("uwrit.alice"_n, "ETH", "ETH", 400)); + withdraw("uwrit.alice"_n, "NTA", 400)); auto entry = latest_action_log("uwrit.alice"_n); BOOST_REQUIRE(!entry.is_null()); @@ -1549,20 +1475,20 @@ BOOST_FIXTURE_TEST_CASE(withdrawinle_subtracts_from_available_on_subsequent_call BOOST_REQUIRE(get_wtdw(2).is_null()); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(withdrawinle_allows_requests_across_collateral_buckets, +BOOST_FIXTURE_TEST_CASE(withdraw_allows_requests_across_collateral_buckets, sysio_opreg_tester) { try { BOOST_REQUIRE_EQUAL(success(), setconfig()); BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.alice"_n, OPERATOR_TYPE_UNDERWRITER, false)); BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "ETH", "ETH", 1000)); + bond_generic("uwrit.alice"_n, "NTA", 1000)); BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "SOL", "SOL", 1000)); + bond_generic("uwrit.alice"_n, "NTB", 1000)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle("uwrit.alice"_n, "ETH", "ETH", 400)); + withdraw("uwrit.alice"_n, "NTA", 400)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle("uwrit.alice"_n, "SOL", "SOL", 400)); + withdraw("uwrit.alice"_n, "NTB", 400)); auto entry = latest_action_log("uwrit.alice"_n); BOOST_REQUIRE(!entry.is_null()); @@ -1571,7 +1497,7 @@ BOOST_FIXTURE_TEST_CASE(withdrawinle_allows_requests_across_collateral_buckets, BOOST_REQUIRE(!get_wtdw(2).is_null()); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(withdraw_rejects_when_inline_request_is_outstanding_for_same_bucket, +BOOST_FIXTURE_TEST_CASE(withdraw_rejects_when_request_is_outstanding_for_same_bucket, sysio_opreg_tester) { try { BOOST_REQUIRE_EQUAL(success(), setconfig()); BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.alice"_n, OPERATOR_TYPE_UNDERWRITER, false)); @@ -1579,7 +1505,8 @@ BOOST_FIXTURE_TEST_CASE(withdraw_rejects_when_inline_request_is_outstanding_for_ BOOST_REQUIRE_EQUAL(success(), deposit("uwrit.alice"_n, kWireCodename, 1000)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle("uwrit.alice"_n, "WIRE", "WIRE", 400)); + withdraw("uwrit.alice"_n, "WIRE", 400)); + produce_blocks(); BOOST_REQUIRE_EQUAL(success(), withdraw("uwrit.alice"_n, kWireCodename, 400)); auto entry = latest_action_log("uwrit.alice"_n); @@ -1594,11 +1521,11 @@ BOOST_FIXTURE_TEST_CASE(withdraw_rejects_when_inline_request_is_outstanding_for_ /// A successful outpost withdrawal reservation immediately removes an /// undercollateralized batch operator from the active set. -BOOST_FIXTURE_TEST_CASE(withdrawinle_rechecks_eligibility_after_enqueue, sysio_opreg_tester) { try { +BOOST_FIXTURE_TEST_CASE(withdraw_rechecks_batch_eligibility_after_enqueue, sysio_opreg_tester) { try { activate_batch_operator(kEligibilityBatchOperator); BOOST_REQUIRE_EQUAL(success(), - withdrawinle(kEligibilityBatchOperator, kEthCodename, kEthCodename, kTestMinBond)); + withdraw(kEligibilityBatchOperator, "NTA", kTestMinBond)); auto op = get_operator(kEligibilityBatchOperator); BOOST_REQUIRE(OperatorStatus::OPERATOR_STATUS_UNKNOWN == op[eligibility_field::status].as()); @@ -1637,11 +1564,11 @@ BOOST_FIXTURE_TEST_CASE(withdraw_rechecks_eligibility_after_enqueue, sysio_opreg /// A rejected outpost withdrawal has no reservation side effect and therefore /// cannot change an otherwise eligible operator's status. -BOOST_FIXTURE_TEST_CASE(withdrawinle_rejection_preserves_eligibility, sysio_opreg_tester) { try { +BOOST_FIXTURE_TEST_CASE(withdraw_rejection_preserves_eligibility, sysio_opreg_tester) { try { activate_batch_operator(kEligibilityBatchOperator); BOOST_REQUIRE_EQUAL(success(), - withdrawinle(kEligibilityBatchOperator, kEthCodename, kEthCodename, kInsufficientTestBond)); + withdraw(kEligibilityBatchOperator, "NTA", kInsufficientTestBond)); auto op = get_operator(kEligibilityBatchOperator); BOOST_REQUIRE(OperatorStatus::OPERATOR_STATUS_ACTIVE == op[eligibility_field::status].as()); @@ -1654,9 +1581,9 @@ BOOST_FIXTURE_TEST_CASE(cancelwtdw_removes_pending_request, sysio_opreg_tester) BOOST_REQUIRE_EQUAL(success(), setconfig()); BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.alice"_n, OPERATOR_TYPE_UNDERWRITER, false)); BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "ETH", "ETH", 1000)); + bond_generic("uwrit.alice"_n, "NTA", 1000)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle("uwrit.alice"_n, "ETH", "ETH", 400)); + withdraw("uwrit.alice"_n, "NTA", 400)); BOOST_REQUIRE_EQUAL(success(), cancelwtdw("uwrit.alice"_n, "uwrit.alice"_n, 1)); @@ -1664,7 +1591,7 @@ BOOST_FIXTURE_TEST_CASE(cancelwtdw_removes_pending_request, sysio_opreg_tester) BOOST_REQUIRE(row.is_null()); BOOST_REQUIRE_EQUAL(success(), - withdrawinle("uwrit.alice"_n, "ETH", "ETH", 1000)); + withdraw("uwrit.alice"_n, "NTA", 1000)); BOOST_REQUIRE(!get_wtdw(2).is_null()); } FC_LOG_AND_RETHROW() } @@ -1673,7 +1600,7 @@ BOOST_FIXTURE_TEST_CASE(cancelwtdw_removes_pending_request, sysio_opreg_tester) BOOST_FIXTURE_TEST_CASE(cancelwtdw_rechecks_eligibility_after_erase, sysio_opreg_tester) { try { activate_batch_operator(kEligibilityBatchOperator); BOOST_REQUIRE_EQUAL(success(), - withdrawinle(kEligibilityBatchOperator, kEthCodename, kEthCodename, kTestMinBond)); + withdraw(kEligibilityBatchOperator, "NTA", kTestMinBond)); BOOST_REQUIRE(OperatorStatus::OPERATOR_STATUS_UNKNOWN == get_operator(kEligibilityBatchOperator)[eligibility_field::status].as()); @@ -1724,9 +1651,9 @@ BOOST_FIXTURE_TEST_CASE(processbatch_preserves_terminated_bootstrapped_status, s BOOST_FIXTURE_TEST_CASE(flushwtdw_rechecks_eligibility_after_erase, sysio_opreg_tester) { try { activate_batch_operator(kEligibilityBatchOperator); BOOST_REQUIRE_EQUAL(success(), - depositinle(kEligibilityBatchOperator, kEthCodename, kEthCodename, kTestMinBond)); + bond_generic(kEligibilityBatchOperator, "NTA", kTestMinBond)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle(kEligibilityBatchOperator, kEthCodename, kEthCodename, kTestMinBond)); + withdraw(kEligibilityBatchOperator, "NTA", kTestMinBond)); BOOST_REQUIRE(OperatorStatus::OPERATOR_STATUS_ACTIVE == get_operator(kEligibilityBatchOperator)[eligibility_field::status].as()); @@ -1741,15 +1668,15 @@ BOOST_FIXTURE_TEST_CASE(flushwtdw_allows_operator_to_request_again, sysio_opreg_ BOOST_REQUIRE_EQUAL(success(), setconfig()); BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.alice"_n, OPERATOR_TYPE_UNDERWRITER, false)); BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "ETH", "ETH", 1000)); + bond_generic("uwrit.alice"_n, "NTA", 1000)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle("uwrit.alice"_n, "ETH", "ETH", 400)); + withdraw("uwrit.alice"_n, "NTA", 400)); BOOST_REQUIRE_EQUAL(success(), flushwtdw(kFlushAllMaturedEpoch)); BOOST_REQUIRE(get_wtdw(1).is_null()); BOOST_REQUIRE_EQUAL(success(), - withdrawinle("uwrit.alice"_n, "ETH", "ETH", 600)); + withdraw("uwrit.alice"_n, "NTA", 600)); BOOST_REQUIRE(!get_wtdw(2).is_null()); } FC_LOG_AND_RETHROW() } @@ -1758,9 +1685,9 @@ BOOST_FIXTURE_TEST_CASE(cancelwtdw_rejects_other_operators_request, sysio_opreg_ BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.alice"_n, OPERATOR_TYPE_UNDERWRITER, false)); BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.bob"_n, OPERATOR_TYPE_UNDERWRITER, false)); BOOST_REQUIRE_EQUAL(success(), - depositinle("uwrit.alice"_n, "ETH", "ETH", 1000)); + bond_generic("uwrit.alice"_n, "NTA", 1000)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle("uwrit.alice"_n, "ETH", "ETH", 400)); + withdraw("uwrit.alice"_n, "NTA", 400)); BOOST_REQUIRE_EQUAL( error("assertion failure with message: not your withdraw request"), @@ -1774,7 +1701,7 @@ BOOST_FIXTURE_TEST_CASE(terminate_marks_status_and_zeros_unlocked_balance, sysio BOOST_REQUIRE_EQUAL(success(), regoperator("batchop.a"_n, OPERATOR_TYPE_BATCH, /*is_bootstrapped=*/false)); BOOST_REQUIRE_EQUAL(success(), - depositinle("batchop.a"_n, "ETH", "ETH", 500)); + bond_generic("batchop.a"_n, "NTA", 500)); BOOST_REQUIRE_EQUAL(success(), terminate("batchop.a"_n, "rolling-24h: >5% miss rate")); @@ -2088,16 +2015,13 @@ BOOST_FIXTURE_TEST_CASE(remit_claims_for_two_tokens_coexist, sysio_opreg_tester) /// operator's ETH-bucket slash does queue an attestation, so the trace probe is live. BOOST_FIXTURE_TEST_CASE(slash_shadow_row_emits_no_attestation, sysio_opreg_tester) { try { const auto OPERATOR = "uwrit.alice"_n; - const auto CONTROL = "uwrit.bob"_n; constexpr uint64_t DEPOSIT = 2 * kWireUnit; BOOST_REQUIRE_EQUAL(success(), setconfig()); BOOST_REQUIRE_EQUAL(success(), regoperator(OPERATOR, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); - BOOST_REQUIRE_EQUAL(success(), regoperator(CONTROL, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); setup_shadow_liq(); mint_shadow(OPERATOR, kShadowFunding); BOOST_REQUIRE_EQUAL(success(), deposit(OPERATOR, kLiqEthCodename, DEPOSIT)); - BOOST_REQUIRE_EQUAL(success(), depositinle(CONTROL, kEthCodename, kEthCodename, DEPOSIT)); // The row under test is the shadow row, custodied as shadow. const auto bonded = get_operator(OPERATOR)["balances"].get_array(); @@ -2114,7 +2038,6 @@ BOOST_FIXTURE_TEST_CASE(slash_shadow_row_emits_no_attestation, sysio_opreg_teste BOOST_REQUIRE_EQUAL(0u, balances[0]["balance"].as_uint64()); BOOST_REQUIRE_EQUAL(static_cast(DEPOSIT), shadow_balance(OPREG_ACCOUNT)); - BOOST_REQUIRE(slash_queues_outbound(CONTROL, kTestSlashReason)); } FC_LOG_AND_RETHROW() } /// `claimremit` resolves its token before touching any claim row, so a token opreg cannot @@ -2211,7 +2134,7 @@ BOOST_FIXTURE_TEST_CASE(recorddel_ignores_withdrawal_ineligibility, sysio_opreg_ produce_block(fc::seconds(kDeliveryTimestampSeparationSeconds)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle(kEligibilityBatchOperator, kEthCodename, kEthCodename, kTestMinBond)); + withdraw(kEligibilityBatchOperator, "NTA", kTestMinBond)); BOOST_REQUIRE(OperatorStatus::OPERATOR_STATUS_UNKNOWN == get_operator(kEligibilityBatchOperator)[eligibility_field::status].as()); @@ -2268,7 +2191,7 @@ BOOST_FIXTURE_TEST_CASE(termcheck_preserves_percent_sample_across_withdrawal_ine produce_block(fc::seconds(kDeliveryTimestampSeparationSeconds)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle(kEligibilityBatchOperator, kEthCodename, kEthCodename, kTestMinBond)); + withdraw(kEligibilityBatchOperator, "NTA", kTestMinBond)); BOOST_REQUIRE_EQUAL(success(), cancelwtdw( kEligibilityBatchOperator, kEligibilityBatchOperator, kFirstWithdrawalRequestId)); @@ -2407,18 +2330,9 @@ BOOST_FIXTURE_TEST_CASE(recorddel_succeeds_without_opconfig, sysio_opreg_tester) BOOST_REQUIRE(!get_dellog_entry(1).is_null()); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(releaselock_requires_uwrit_authority, sysio_opreg_tester) { try { - BOOST_REQUIRE_EQUAL(success(), setconfig()); - BOOST_REQUIRE_EQUAL(success(), regoperator("uwrit.alice"_n, OPERATOR_TYPE_UNDERWRITER, false)); - - BOOST_REQUIRE( - releaselock(OPREG_ACCOUNT, "uwrit.alice"_n, "ETH", "ETH", 100) - .find("missing authority of sysio.uwrit") != std::string::npos); -} FC_LOG_AND_RETHROW() } - // ── setconfig: per-(chain_code, token_code) collateral requirements ── -BOOST_FIXTURE_TEST_CASE(setconfig_two_chain_bond_activation, sysio_opreg_tester) { try { +BOOST_FIXTURE_TEST_CASE(setconfig_two_native_asset_bond_activation, sysio_opreg_tester) { try { constexpr uint64_t MIN_BOND = 1'000'000; BOOST_REQUIRE_EQUAL(success(), setconfig( @@ -2427,8 +2341,8 @@ BOOST_FIXTURE_TEST_CASE(setconfig_two_chain_bond_activation, sysio_opreg_tester) /*terminate_window_ms=*/24ULL * 60 * 60 * 1000, /*req_prod_collat=*/{}, /*req_batchop_collat=*/{ - make_chain_min_bond("ETH", "ETH", MIN_BOND), - make_chain_min_bond("SOL", "SOL", MIN_BOND), + make_chain_min_bond("WIRE", "NTA", MIN_BOND), + make_chain_min_bond("WIRE", "NTB", MIN_BOND), }, /*req_uw_collat=*/{})); @@ -2441,13 +2355,13 @@ BOOST_FIXTURE_TEST_CASE(setconfig_two_chain_bond_activation, sysio_opreg_tester) // After ETH bond: SOL still missing → still UNKNOWN. BOOST_REQUIRE_EQUAL(success(), - depositinle("batchop.a"_n, "ETH", "ETH", MIN_BOND)); + bond_generic("batchop.a"_n, "NTA", MIN_BOND)); op = get_operator("batchop.a"_n); BOOST_REQUIRE(OperatorStatus::OPERATOR_STATUS_UNKNOWN == op["status"].as()); // After SOL bond: every requirement met → ACTIVE. BOOST_REQUIRE_EQUAL(success(), - depositinle("batchop.a"_n, "SOL", "SOL", MIN_BOND)); + bond_generic("batchop.a"_n, "NTB", MIN_BOND)); op = get_operator("batchop.a"_n); BOOST_REQUIRE(OperatorStatus::OPERATOR_STATUS_ACTIVE == op["status"].as()); BOOST_REQUIRE(op["available_at"].as_uint64() > 0); @@ -2455,8 +2369,8 @@ BOOST_FIXTURE_TEST_CASE(setconfig_two_chain_bond_activation, sysio_opreg_tester) BOOST_FIXTURE_TEST_CASE(setconfig_rejects_duplicate_chain_token_in_collat, sysio_opreg_tester) { try { const auto duplicate_vec = std::vector{ - make_chain_min_bond("ETH", "ETH", 100), - make_chain_min_bond("ETH", "ETH", 200), + make_chain_min_bond("WIRE", "NTA", 100), + make_chain_min_bond("WIRE", "NTA", 200), }; // The duplicate-detection assertion text refers to "(chain, token_kind)" @@ -2474,7 +2388,7 @@ BOOST_FIXTURE_TEST_CASE(setconfig_stamps_collat_config_timestamp, sysio_opreg_te 21, 63, 21, 600000, 5, 5, 24ULL * 60 * 60 * 1000, /*req_prod_collat=*/{}, /*req_batchop_collat=*/{ - make_chain_min_bond("ETH", "ETH", 1000), + make_chain_min_bond("WIRE", "NTA", 1000), }, /*req_uw_collat=*/{})); @@ -2492,11 +2406,11 @@ BOOST_FIXTURE_TEST_CASE(flushwtdw_terminated_operator_does_not_abort, sysio_opre BOOST_REQUIRE_EQUAL(success(), setconfig()); BOOST_REQUIRE_EQUAL(success(), regoperator("batchop.a"_n, OPERATOR_TYPE_BATCH, /*is_bootstrapped=*/false)); - BOOST_REQUIRE_EQUAL(success(), depositinle("batchop.a"_n, "ETH", "ETH", 500)); + BOOST_REQUIRE_EQUAL(success(), bond_generic("batchop.a"_n, "NTA", 500)); // Queue a withdraw of the full balance, then terminate (remits + zeroes the balance), leaving // the queued row matured against a now-zero balance. - BOOST_REQUIRE_EQUAL(success(), withdrawinle("batchop.a"_n, "ETH", "ETH", 500)); + BOOST_REQUIRE_EQUAL(success(), withdraw("batchop.a"_n, "NTA", 500)); BOOST_REQUIRE(!get_wtdw(1).is_null()); BOOST_REQUIRE_EQUAL(success(), terminate("batchop.a"_n, "rolling-24h miss")); @@ -2525,12 +2439,14 @@ BOOST_FIXTURE_TEST_CASE(flushwtdw_bounds_rows_per_epoch, sysio_opreg_tester) { t // Each distinct operator contributes its one permitted queue row. Raw name values remain valid // Antelope names and avoid coupling this global-bound regression to a fixed account-name list. for (uint32_t i = 0; i < N; ++i) { - const name account{OPERATOR_NAME_BASE.value + i + 1}; + const name account{OPERATOR_NAME_BASE.value + ((uint64_t(i) + 1) << 4)}; + create_accounts({account}); + produce_blocks(); BOOST_REQUIRE_EQUAL(success(), regoperator(account, OPERATOR_TYPE_BATCH, /*is_bootstrapped=*/false)); - BOOST_REQUIRE_EQUAL(success(), depositinle(account, "ETH", "ETH", i + 1)); + BOOST_REQUIRE_EQUAL(success(), bond_generic(account, "NTA", i + 1)); BOOST_REQUIRE_EQUAL(success(), - withdrawinle(account, "ETH", "ETH", i + 1)); + withdraw(account, "NTA", i + 1)); BOOST_REQUIRE_EQUAL(success(), terminate(account, "rolling-24h miss")); produce_blocks(); } @@ -2836,20 +2752,6 @@ BOOST_FIXTURE_TEST_CASE(terminated_operator_claims_uncovered_yield_before_prune, BOOST_REQUIRE_EQUAL(static_cast(kYieldPrincipal), wire_balance(OPREG_ACCOUNT)); } FC_LOG_AND_RETHROW() } -/// `depositinle` never credits the depot chain: depot-native collateral is bonded only through -/// `deposit`. The credit is dropped and logged, not thrown, since it runs inside a dispatch. -BOOST_FIXTURE_TEST_CASE(depositinle_drops_a_depot_chain_credit, sysio_opreg_tester) { try { - BOOST_REQUIRE_EQUAL(success(), setconfig()); - BOOST_REQUIRE_EQUAL(success(), regoperator(kYieldBonderA, OPERATOR_TYPE_UNDERWRITER, /*is_bootstrapped=*/false)); - - BOOST_REQUIRE_EQUAL(success(), depositinle(kYieldBonderA, kWireCodename, kWireCodename, kWireUnit)); - BOOST_REQUIRE(depot_native_row(kYieldBonderA, kWireCodename).is_null()); - const auto entry = latest_action_log(kYieldBonderA); - BOOST_REQUIRE(!entry.is_null()); - BOOST_REQUIRE_EQUAL(false, entry[eligibility_field::success].as_bool()); - BOOST_REQUIRE_EQUAL(std::string(kDepotChainCreditReason), entry["error_message"].as_string()); -} FC_LOG_AND_RETHROW() } - /// `claimyield` is permissionless: a keeper with no stake cranks it for a TERMINATED operator whose /// yield termination could not cover, and the credit lands in the operator's own WIRE claim. BOOST_FIXTURE_TEST_CASE(keeper_claims_terminated_operators_yield, sysio_opreg_tester) { try { @@ -2929,7 +2831,6 @@ BOOST_FIXTURE_TEST_CASE(reregistration_forfeits_banked_yield_but_keeps_remit_cla BOOST_REQUIRE_EQUAL(success(), claimremit(kYieldBonderA, kLiqEthCodename)); } FC_LOG_AND_RETHROW() } - // This is the dual-shadow producer flow's depot policy with generic symbols. // Registration of the Ethereum node NFT itself remains in the ETH-specific suites. BOOST_FIXTURE_TEST_CASE(two_generic_depot_assets_are_both_required_and_withdrawals_preserve_identity, diff --git a/contracts/tests/sysio.reserv_tests.cpp b/contracts/tests/sysio.reserv_tests.cpp deleted file mode 100644 index 89766a1d08..0000000000 --- a/contracts/tests/sysio.reserv_tests.cpp +++ /dev/null @@ -1,1935 +0,0 @@ -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -#include "contracts.hpp" -#include "contract_test_support.hpp" - -using namespace sysio::testing; -using namespace sysio; -using namespace sysio::chain; -using namespace sysio::opp::types; -using namespace fc; - -using mvo = fc::mutable_variant_object; - -namespace { - -/// Extract the raw 33-byte compressed pubkey from an EM `public_key` — the -/// `creator_pub_key` form `oncrtreserve` reconstructs via `pubkey_from_raw` -/// (CHAIN_KIND_EVM → EM key). Mirrors the helper in `sysio.dispatch_tests`. -std::vector em_pubkey_bytes(const fc::crypto::public_key& pk) { - const auto& shim = pk.get(); - auto compressed = shim.serialize(); // std::array - return std::vector(compressed.begin(), compressed.end()); -} - -} // anonymous namespace - -/// Data model: reserves are keyed by the triple `(chain_code, token_code, -/// reserve_code)` (each a `sysio::slug_name` packed uint64). The legacy -/// `setreserve` action is gone; `regreserve` is the bootstrap-window -/// equivalent (it works only while `current_epoch_index == 0`, which is the -/// state immediately after deploying the contract in these tests). -/// -/// Custody model: `reserve_wire_amount` is REAL — `regreserve` drains the -/// seed from the `sysio` treasury via `sysio.token::transfer` and the -/// settlement actions (`paywire` / `refundwire`) move real WIRE back out, so -/// this fixture deploys `sysio.token` and issues a WIRE supply to `sysio` -/// (mirroring `sysio.epoch_flushwtdw_tests.cpp`'s bootstrap). -class sysio_reserve_tester : public tester { -public: - static constexpr auto RESERVE_ACCOUNT = "sysio.reserv"_n; - static constexpr auto MSGCH_ACCOUNT = "sysio.msgch"_n; - static constexpr auto UWRIT_ACCOUNT = "sysio.uwrit"_n; - static constexpr auto TOKEN_ACCOUNT = "sysio.token"_n; - static constexpr auto AUTHEX_ACCOUNT = "sysio.authex"_n; - static constexpr auto DCLAIM_ACCOUNT = "sysio.dclaim"_n; - static constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; - static constexpr auto SYSIO_ACCOUNT = "sysio"_n; - /// Stand-in for a swap's winning underwriter — the account the settlement - /// actions accrue the underwriter half of the fee to. - static constexpr auto UNDERWRITER_ACCOUNT = "underwriter1"_n; - - sysio_reserve_tester() { - produce_blocks(2); - // sysio.authex is pre-created by the tester boot (account linking) — - // creating it again would collide. - create_accounts({RESERVE_ACCOUNT, MSGCH_ACCOUNT, UWRIT_ACCOUNT, - TOKEN_ACCOUNT, CHAINS_ACCOUNT, DCLAIM_ACCOUNT, "alice"_n, - UNDERWRITER_ACCOUNT}); - produce_blocks(2); - - set_code(RESERVE_ACCOUNT, contracts::reserve_wasm()); - set_abi(RESERVE_ACCOUNT, contracts::reserve_abi().data()); - set_privileged(RESERVE_ACCOUNT); - produce_blocks(); - - const auto* accnt = control->find_account_metadata(RESERVE_ACCOUNT); - BOOST_REQUIRE(accnt != nullptr); - abi_def abi; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt->abi, abi), true); - abi_ser.set_abi(std::move(abi), abi_serializer::create_yield_function(abi_serializer_max_time)); - - // Normal OPP reserve flows target registered outpost chains. The - // never-throw inbound handlers soft-skip unregistered chain_code values - // before they can queue outbound replies through sysio.msgch. - set_code(CHAINS_ACCOUNT, contracts::chains_wasm()); - set_abi(CHAINS_ACCOUNT, contracts::chains_abi().data()); - set_privileged(CHAINS_ACCOUNT); - produce_blocks(); - { - const auto* chains = control->find_account_metadata(CHAINS_ACCOUNT); - BOOST_REQUIRE(chains != nullptr); - abi_def chains_abi_def; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(chains->abi, chains_abi_def), true); - chains_abi_ser.set_abi(std::move(chains_abi_def), - abi_serializer::create_yield_function(abi_serializer_max_time)); - } - BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_EVM, "ETH", 1)); - BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_SVM, "SOLANA", 2)); - - // WIRE custody backing: deploy sysio.token + issue the treasury supply - // to `sysio` so regreserve's treasury drain and the paywire/refundwire - // payouts move real tokens. - set_code(TOKEN_ACCOUNT, contracts::token_wasm()); - set_abi(TOKEN_ACCOUNT, contracts::token_abi().data()); - // Privileged-contract RAM-pool model: sysio.token bills its rows to - // the sysio pool (same model reserv/uwrit use). - set_privileged(TOKEN_ACCOUNT); - produce_blocks(); - { - const auto* tok = control->find_account_metadata(TOKEN_ACCOUNT); - BOOST_REQUIRE(tok != nullptr); - abi_def tok_abi_def; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(tok->abi, tok_abi_def), true); - token_abi_ser.set_abi(std::move(tok_abi_def), - abi_serializer::create_yield_function(abi_serializer_max_time)); - } - BOOST_REQUIRE_EQUAL(success(), push_token_action(TOKEN_ACCOUNT, "create"_n, mvo() - ("issuer", SYSIO_ACCOUNT) - ("maximum_supply", "1000000000.000000000 WIRE"))); - BOOST_REQUIRE_EQUAL(success(), push_token_action(SYSIO_ACCOUNT, "issue"_n, mvo() - ("to", SYSIO_ACCOUNT) - ("quantity", "1000000000.000000000 WIRE") - ("memo", "test bootstrap"))); - produce_blocks(); - } - - action_result push_action(name signer, name action_name, const variant_object& data) { - return push_to(RESERVE_ACCOUNT, abi_ser, signer, action_name, data); - } - - action_result push_token_action(name signer, name action_name, const variant_object& data) { - return push_to(TOKEN_ACCOUNT, token_abi_ser, signer, action_name, data); - } - - action_result regchain(ChainKind kind, - std::string_view code, - uint32_t external_chain_id) { - return push_to(CHAINS_ACCOUNT, chains_abi_ser, CHAINS_ACCOUNT, "regchain"_n, mvo() - ("kind", kind) - ("code", code) - ("external_chain_id", external_chain_id) - ("name", std::string("outpost")) - ("description", std::string{}) - ("outpost", sysio_system::test_support::no_outpost_mvo())); - } - - action_result push_to(name account, abi_serializer& ser, name signer, - name action_name, const variant_object& data) { - string action_type_name = ser.get_action_type(action_name); - action act; - act.account = account; - act.name = action_name; - act.data = ser.variant_to_binary( - action_type_name, data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - act.authorization = vector{{signer, config::active_name}}; - - signed_transaction trx; - trx.actions.emplace_back(std::move(act)); - set_transaction_headers(trx); - trx.sign(get_private_key(signer, "active"), control->get_chain_id()); - try { - push_transaction(trx); - return success(); - } catch (const fc::exception& ex) { - return error(ex.top_message()); - } - } - - /// `swapquote` is a read-only action whose ANSWER is its return value. - /// `push_action` above discards the transaction trace and yields only - /// `success()`, so a test written against it passes even when the quote - /// ignores every fee. Decode the action return value instead. - uint64_t swapquote_value(std::string_view from_chain, std::string_view from_token, - std::string_view from_reserve, uint64_t from_amount, - std::string_view to_chain, std::string_view to_token, - std::string_view to_reserve) { - auto trace = tester::push_action(RESERVE_ACCOUNT, "swapquote"_n, RESERVE_ACCOUNT, mvo() - ("from_chain_code", from_chain) - ("from_token_code", from_token) - ("from_reserve_code", from_reserve) - ("from_amount", from_amount) - ("to_chain_code", to_chain) - ("to_token_code", to_token) - ("to_reserve_code", to_reserve)); - BOOST_REQUIRE(trace && !trace->action_traces.empty()); - return fc::raw::unpack(trace->action_traces[0].return_value); - } - - // ── SlugName helpers ── - - static fc::slug_name cn(std::string_view s) { return fc::slug_name{s}; } - - /// `regreserve` is the bootstrap-window action for inserting a reserve - /// row with `status=ACTIVE` and REAL WIRE backing drained from the - /// treasury. Triple-slug_name PK is `(chain_code, token_code, - /// reserve_code)`; `is_private`/`owner` seed privately-owned reserves. - action_result regreserve(std::string_view chain_code, - std::string_view token_code, - std::string_view reserve_code, - uint64_t initial_chain_amount, - uint64_t initial_wire_amount, - uint32_t weight = 5000, - bool is_private = false, - name owner = name{}, - const std::string& name_str = "test reserve", - const std::string& description = "", - uint32_t source_token_precision = 9) { - return push_action(RESERVE_ACCOUNT, "regreserve"_n, mvo() - ("chain_code", chain_code) - ("token_code", token_code) - ("reserve_code", reserve_code) - ("name", name_str) - ("description", description) - ("initial_chain_amount", initial_chain_amount) - ("initial_wire_amount", initial_wire_amount) - ("source_token_precision", source_token_precision) - ("connector_weight_bps", weight) - ("is_private", is_private) - ("owner", owner)); - } - - /// The contract's real WIRE token balance (raw units, 9 decimals). - int64_t wire_balance(name account) { - auto bal = get_currency_balance(TOKEN_ACCOUNT, symbol(9, "WIRE"), account); - return bal.get_amount(); - } - - /// Read `underwriter`'s `uwfees` accrual row. Null when they have never - /// earned a swap fee (no row is created until the first accrual). - fc::variant get_uwfees(name underwriter) { - auto data = get_row_by_account(RESERVE_ACCOUNT, RESERVE_ACCOUNT, "uwfees"_n, underwriter); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( - "uw_fee_row", data, abi_serializer::create_yield_function(abi_serializer_max_time)); - } - - /// Read the `rewards_bucket` singleton (kv::global). Null when never accrued. - fc::variant get_rewardbkt() { - auto data = get_row_by_account(RESERVE_ACCOUNT, RESERVE_ACCOUNT, "rewardbkt"_n, "rewardbkt"_n); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( - "rewards_bucket", data, abi_serializer::create_yield_function(abi_serializer_max_time)); - } - - /// Claimable WIRE credited by `paywire` / `refundwire`. Null when the account is owed - /// nothing. `paywire` and `refundwire` credit rather than transfer, because both run on - /// never-throw paths where a recipient's transfer-notify handler could otherwise abort the - /// enclosing transaction. - fc::variant get_wireclaim(name account) { - auto data = get_row_by_account(RESERVE_ACCOUNT, RESERVE_ACCOUNT, "wireclaims"_n, account); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( - "wire_claim", data, abi_serializer::create_yield_function(abi_serializer_max_time)); - } - - /// Claimable balance owed to `account`, or 0 when there is no row. - uint64_t wire_claimable(name account) { - auto c = get_wireclaim(account); - return c.is_null() ? 0u : c["balance"].as_uint64(); - } - - action_result claimwire(name account) { - return push_action(account, "claimwire"_n, mvo()("account", account)); - } - - /// Mirrors `sysio.reserv::MAX_CLAIM_SWEEP_PER_EPOCH` — the budget - /// `sysio.epoch::advance` hands the inline retention sweep. - static constexpr uint32_t reserve_max_claim_sweep_per_epoch = 32; - - /// Drive the retention sweep directly, as the epoch tick would. Signed by the contract itself - /// (`sweepclaims` accepts `sysio.epoch` or self), so a test can age rows out without standing - /// up the epoch machinery. - action_result sweepclaims(uint32_t max_rows) { - return push_action(RESERVE_ACCOUNT, "sweepclaims"_n, mvo()("max_rows", max_rows)); - } - - /// Walk every row in `sysio.reserv::reserves` (KV-keyed by checksum256) - /// via the DB index and return the row whose slug_name triple matches. - /// `get_row_by_id` only supports uint64 keys; this scan is the test-side - /// workaround. - fc::variant find_reserve(std::string_view chain_code, - std::string_view token_code, - std::string_view reserve_code) { - const auto target_chain = cn(chain_code).value; - const auto target_token = cn(token_code).value; - const auto target_reserve = cn(reserve_code).value; - - const auto& db = control->db(); - const auto table_id = chain::compute_table_id("reserves"_n.to_uint64_t()); - const auto& kv_idx = db.get_index(); - auto itr = kv_idx.lower_bound(boost::make_tuple(RESERVE_ACCOUNT, table_id, std::string_view{})); - for (; itr != kv_idx.end() - && itr->code == RESERVE_ACCOUNT - && itr->table_id == table_id; ++itr) { - std::vector raw(itr->value.size()); - if (!raw.empty()) - std::memcpy(raw.data(), itr->value.data(), raw.size()); - try { - auto row = abi_ser.binary_to_variant( - "reserve_row", raw, - abi_serializer::create_yield_function(abi_serializer_max_time)); - if (row["chain_code"].as().value == target_chain && - row["token_code"].as().value == target_token && - row["reserve_code"].as().value == target_reserve) { - return row; - } - } catch (...) { - // skip rows that don't decode - } - } - return fc::variant(); - } - - // ── authex link seeding ── - // `oncrtreserve` gates a create on the creator being authex-linked (it - // probes `sysio.authex::links.bypubkey`). The base fixture deploys no - // authex code, so every creator reads as unlinked; tests that exercise the - // LINKED path (e.g. CANCELLED-row reclaim) deploy authex on demand and seed - // a link with `recordlink`. - - /// Deploy sysio.authex (account is pre-created by the tester boot) and load - /// its ABI so `recordlink` can be pushed and the cross-contract - /// `links.bypubkey` read in oncrtreserve resolves. - void deploy_authex() { - set_code(AUTHEX_ACCOUNT, contracts::authex_wasm()); - set_abi(AUTHEX_ACCOUNT, contracts::authex_abi().data()); - set_privileged(AUTHEX_ACCOUNT); - produce_blocks(); - const auto* a = control->find_account_metadata(AUTHEX_ACCOUNT); - BOOST_REQUIRE(a != nullptr); - abi_def abi; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(a->abi, abi), true); - authex_abi_ser.set_abi(std::move(abi), - abi_serializer::create_yield_function(abi_serializer_max_time)); - } - - /// Seed an authex link for `pub` on `chain_kind` via the depot-only - /// `recordlink` (signed by sysio.authex itself). After this, a creator - /// presenting the matching raw pubkey reads as linked in oncrtreserve. - action_result recordlink(name account, ChainKind chain_kind, - const fc::crypto::public_key& pub) { - const auto address_bytes = fc::crypto::ethereum::address_to_bytes(pub); - const std::vector native_address(address_bytes.begin(), address_bytes.end()); - return push_to(AUTHEX_ACCOUNT, authex_abi_ser, AUTHEX_ACCOUNT, "recordlink"_n, mvo() - ("account", account) - ("chain_kind", chain_kind) - ("pub_key", pub) - ("native_address", native_address)); - } - - /// Deploy sysio.msgch so inline queueout actions execute in tests that - /// need to prove a reserve handler cannot trip msgch's hard chain guard. - void deploy_msgch() { - set_code(MSGCH_ACCOUNT, contracts::msgch_wasm()); - set_abi(MSGCH_ACCOUNT, contracts::msgch_abi().data()); - set_privileged(MSGCH_ACCOUNT); - produce_blocks(); - const auto* msgch_acct = control->find_account_metadata(MSGCH_ACCOUNT); - BOOST_REQUIRE(msgch_acct != nullptr); - abi_def msgch_def; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(msgch_acct->abi, msgch_def), true); - msgch_abi_ser.set_abi(std::move(msgch_def), - abi_serializer::create_yield_function(abi_serializer_max_time)); - } - - /// The first queued outbound attestation of `attest_type`, or a null variant. - /// `queueout` mints sequential ids, so a bounded forward scan finds any a test - /// queued. Requires deploy_msgch(). - fc::variant find_queued_attestation(const char* type_name, uint32_t type_value, - uint64_t scan_until = 32) { - for (uint64_t id = 0; id <= scan_until; ++id) { - auto data = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "attestations"_n, id); - if (data.empty()) continue; - auto row = msgch_abi_ser.binary_to_variant( - "attestation_entry", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - // `type` is an AttestationType; accept either rendering so the helper - // survives a change in how the enum reflects. - const auto& t = row["type"]; - if ((t.is_string() && t.as_string() == type_name) || - (t.is_integer() && t.as_uint64() == type_value)) return row; - } - return fc::variant(); - } - - abi_serializer abi_ser; - abi_serializer msgch_abi_ser; - abi_serializer token_abi_ser; - abi_serializer authex_abi_ser; - abi_serializer chains_abi_ser; -}; - -BOOST_AUTO_TEST_SUITE(sysio_reserve_tests) - -// ── regreserve (bootstrap-window action; real-WIRE treasury drain) ── - -BOOST_FIXTURE_TEST_CASE(regreserve_creates_reserve_row, sysio_reserve_tester) { try { - const int64_t treasury_before = wire_balance(SYSIO_ACCOUNT); - - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", - /*chain_amount*/ 1'000'000, /*wire_amount*/ 2'000'000)); - - auto r = find_reserve("ETH", "ETH", "PRIMARY"); - BOOST_REQUIRE(!r.is_null()); - BOOST_REQUIRE_EQUAL(1'000'000, r["reserve_chain_amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(2'000'000, r["reserve_wire_amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(5000, r["connector_weight_bps"].as_uint64()); - BOOST_REQUIRE_EQUAL(false, r["is_private"].as_bool()); - BOOST_REQUIRE_EQUAL("", r["owner"].as_string()); - // Reserve self-describes its precision (default 9 here); never assumed. - BOOST_REQUIRE_EQUAL(9u, r["source_token_precision"].as_uint64()); - - // Custody invariant: the row's WIRE side is REAL — drained from the - // treasury into sysio.reserv's token balance. - BOOST_REQUIRE_EQUAL(2'000'000, wire_balance(RESERVE_ACCOUNT)); - BOOST_REQUIRE_EQUAL(treasury_before - 2'000'000, wire_balance(SYSIO_ACCOUNT)); -} FC_LOG_AND_RETHROW() } - -// A `slug_name` reaches action JSON either as its canonical STRING or through the -// transitional object form `{"value": N}`, and only the string arm validates. A reserve -// row is keyed on all three codes and there is no erase action, so an uncanonical code -// would be permanent. Rendering is TOTAL and will not complain: the value can decode to -// "" or to a valid spelling that re-parses as a DIFFERENT code, aliasing this reserve -// onto another's identity. `regreserve` is a privileged bootstrap-window action and -// refuses. -BOOST_FIXTURE_TEST_CASE(regreserve_uncanonical_code_rejected, sysio_reserve_tester) { try { - // Below the leading symbol's floor: decodes to "" and packs back to 0, so it is not a - // code and has no spelling. - constexpr uint64_t uncanonical = 7; - BOOST_REQUIRE(!fc::slug_name{uncanonical}.is_canonical()); - - // Any of the three key columns is enough to refuse the row. - for (const auto& which : {std::string("chain_code"), - std::string("token_code"), - std::string("reserve_code")}) { - auto data = mvo() - ("chain_code", which == "chain_code" ? fc::variant(mvo()("value", uncanonical)) - : fc::variant(std::string("ETH"))) - ("token_code", which == "token_code" ? fc::variant(mvo()("value", uncanonical)) - : fc::variant(std::string("ETH"))) - ("reserve_code", which == "reserve_code" ? fc::variant(mvo()("value", uncanonical)) - : fc::variant(std::string("PRIMARY"))) - ("name", std::string("bad reserve")) - ("description", std::string{}) - ("initial_chain_amount", uint64_t{1'000'000}) - ("initial_wire_amount", uint64_t{2'000'000}) - ("source_token_precision", 9u) - ("connector_weight_bps", 5000u) - ("is_private", false) - ("owner", name{}); - BOOST_REQUIRE(push_action(RESERVE_ACCOUNT, "regreserve"_n, data) - .find("has no canonical slug_name spelling") != std::string::npos); - } - - // Control: spellable codes on the same shape still register. - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", - /*chain_amount*/ 1'000'000, /*wire_amount*/ 2'000'000)); -} FC_LOG_AND_RETHROW() } - -// A token whose depot-frame precision is below 9 (e.g. a 6-decimal stablecoin) -// is recorded as-is on the reserve: precision is carried, never assumed to be 9. -BOOST_FIXTURE_TEST_CASE(regreserve_records_non_default_precision, sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "USDC", "PRIMARY", - /*chain_amount*/ 1'000'000, /*wire_amount*/ 2'000'000, - /*weight*/ 5000, /*is_private*/ false, name{}, - "usdc reserve", "", /*source_token_precision*/ 6u)); - - auto r = find_reserve("ETH", "USDC", "PRIMARY"); - BOOST_REQUIRE(!r.is_null()); - BOOST_REQUIRE_EQUAL(6u, r["source_token_precision"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -// source_token_precision above the depot frame (9) is rejected — the outpost must -// downscale to min(native, 9) at its boundary, so a higher value is malformed. -BOOST_FIXTURE_TEST_CASE(regreserve_rejects_precision_over_frame, sysio_reserve_tester) { try { - BOOST_REQUIRE( - regreserve("ETH", "ETH", "PRIMARY", - 1'000'000, 2'000'000, 5000, false, name{}, "", "", /*source_token_precision*/ 18u) - .find("source_token_precision exceeds the depot frame") != std::string::npos); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(regreserve_rejects_duplicate, sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - regreserve("SOL", "SOL", "PRIMARY", 100, 200, 5000)); - - // Re-call with the same triple must reject (regreserve only inserts). - BOOST_REQUIRE( - regreserve("SOL", "SOL", "PRIMARY", 999, 1234, 6000).find("already") != std::string::npos); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(regreserve_rejects_invalid_connector_weight, sysio_reserve_tester) { try { - BOOST_REQUIRE( - regreserve("ETH", "ETH", "PRIMARY", 100, 100, 0) - .find("connector_weight_bps") != std::string::npos); - - BOOST_REQUIRE( - regreserve("ETH", "ETH", "PRIMARY2", 100, 100, 10001) - .find("connector_weight_bps") != std::string::npos); - - // R10: cw == 10000 (== WEIGHT_TOTAL_BPS) makes the token-side weight zero, so the weighted - // curve returns 0 for every swap — a permanently dead reserve. It must be rejected (max is 9999). - BOOST_REQUIRE( - regreserve("ETH", "ETH", "PRIMARY3", 100, 100, 10000) - .find("connector_weight_bps") != std::string::npos); - - // The boundary value just below the total is accepted (token side keeps 1 bps). - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY4", 100, 100, 9999)); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(regreserve_private_requires_owner, sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: a private bootstrap reserve must name an owner"), - regreserve("ETH", "ETH", "PRIVATE", 100, 100, 5000, /*is_private*/true, name{})); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(regreserve_seeds_private_reserve, sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIVATE", 100, 100, 5000, - /*is_private*/true, "alice"_n)); - auto r = find_reserve("ETH", "ETH", "PRIVATE"); - BOOST_REQUIRE(!r.is_null()); - BOOST_REQUIRE_EQUAL(true, r["is_private"].as_bool()); - BOOST_REQUIRE_EQUAL("alice", r["owner"].as_string()); -} FC_LOG_AND_RETHROW() } - -// `name` and `description` persist into a reserve row billed to `ram_payer = sysio` — the -// shared system pool — so both are bounded before emplace, via the same -// `sysio::opp::registry::check_metadata` the chains and tokens registries use. CertiK WNS-10 -// raised the unbounded pair on `sysio.tokens::regtoken`; it was identical here. -// -// `regreserve` is privileged and abort-safe, so it enforces the bound with `check_metadata`. -// The post-bootstrap `oncrtreserve` path must never abort and instead routes an over-bound -// row into the existing cancel/refund flow — see `oncrtreserve_oversized_metadata_is_cancelled`. -BOOST_FIXTURE_TEST_CASE(regreserve_bounds_metadata, sysio_reserve_tester) { try { - BOOST_REQUIRE(regreserve("ETH", "ETH", "PRIMARY", 100, 100, 5000, false, name{}, - std::string(129, 'x')) - .find("label exceeds 128 bytes") != std::string::npos); - - BOOST_REQUIRE(regreserve("ETH", "ETH", "PRIMARY", 100, 100, 5000, false, name{}, - "ok", std::string(257, 'x')) - .find("description exceeds 256 bytes") != std::string::npos); - - // The bounds are inclusive, and neither rejection claimed the reserve key. - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 100, 100, 5000, false, name{}, - std::string(128, 'x'), std::string(256, 'x'))); -} FC_LOG_AND_RETHROW() } - -// ── oncrtreserve (create gating) ── - -BOOST_FIXTURE_TEST_CASE(oncrtreserve_requires_msgch_auth, sysio_reserve_tester) { try { - BOOST_REQUIRE(push_action(RESERVE_ACCOUNT, "oncrtreserve"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", "USERRES") - ("name", "user reserve") - ("description", "") - ("external_token_amount", 1000) - ("requested_wire_amount", 1000) - ("source_token_precision", 9u) - ("connector_weight_bps", 5000) - ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) - ("creator_chain_addr", std::vector(20, '\x01')) - ("is_private", false) - ("creator_pub_key", std::vector(33, '\x02')) - ).find("missing authority of sysio.msgch") != std::string::npos); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(oncrtreserve_unregistered_chain_soft_skips_before_queueout, sysio_reserve_tester) { try { - // Deploy msgch so the inline RESERVE_CREATE_CANCELLED queueout would execute. - // Pre-guard, the unlinked-creator cancel path inserted a CANCELLED row and - // then aborted inside sysio.msgch::queueout's hard chain registration check. - deploy_msgch(); - - BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() - ("chain_code", "NOCHAIN") - ("token_code", "ETH") - ("reserve_code", "USERRES") - ("name", "user reserve") - ("description", "") - ("external_token_amount", 1000) - ("requested_wire_amount", 1000) - ("source_token_precision", 9u) - ("connector_weight_bps", 5000) - ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) - ("creator_chain_addr", std::vector(20, '\x01')) - ("is_private", false) - ("creator_pub_key", std::vector(33, '\x02')))); - - BOOST_REQUIRE(find_reserve("NOCHAIN", "ETH", "USERRES").is_null()); -} FC_LOG_AND_RETHROW() } - -// A reserve_code is chosen by the CALLER on the outpost -- ETH `create_reserve` and SOL -// `create_reserve_handler` validate only that it is non-zero -- and the creator's escrow is -// taken BEFORE the RESERVE_CREATE goes out. A code with no canonical spelling must therefore -// be REFUNDED, never dropped: a drop strands the funds in the outpost's PENDING record with -// no cancel path, since the outpost cannot release escrow on its own authority. -BOOST_FIXTURE_TEST_CASE(oncrtreserve_uncanonical_code_is_cancelled, sysio_reserve_tester) { try { - deploy_msgch(); - - // Below the leading symbol's floor: decodes to "" and packs back to 0, so it is - // not a code and has no spelling. - constexpr uint64_t uncanonical = 7; - BOOST_REQUIRE(!fc::slug_name{uncanonical}.is_canonical()); - - BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", mvo()("value", uncanonical)) - ("name", "user reserve") - ("description", "") - ("external_token_amount", 1000) - ("requested_wire_amount", 1000) - ("source_token_precision", 9u) - ("connector_weight_bps", 5000) - ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) - ("creator_chain_addr", std::vector(20, '\x01')) - ("is_private", false) - ("creator_pub_key", std::vector(33, '\x02')) - )); - - // The refund is what matters: observe the OUTBOUND cancellation, not merely the - // absence of a depot row (the row's key IS the unspellable triple, so it could - // not be looked up by spelling anyway). - BOOST_REQUIRE(!find_queued_attestation( - "ATTESTATION_TYPE_RESERVE_CREATE_CANCELLED", - sysio::opp::types::ATTESTATION_TYPE_RESERVE_CREATE_CANCELLED).is_null()); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(oncrtreserve_unlinked_creator_is_cancelled, sysio_reserve_tester) { try { - // Create gating: no authex link exists for the creator's pubkey (the - // authex account carries no links table here), so the request must be - // rejected by inserting a CANCELLED row (idempotency + audit) and - // queueing RESERVE_CREATE_CANCELLED back. Never throws. - BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", "USERRES") - ("name", "user reserve") - ("description", "") - ("external_token_amount", 1000) - ("requested_wire_amount", 1000) - ("source_token_precision", 9u) - ("connector_weight_bps", 5000) - ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) - ("creator_chain_addr", std::vector(20, '\x01')) - ("is_private", false) - ("creator_pub_key", std::vector(33, '\x02')) - )); - - auto r = find_reserve("ETH", "ETH", "USERRES"); - BOOST_REQUIRE(!r.is_null()); - BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", r["status"].as_string()); -} FC_LOG_AND_RETHROW() } - -// WNS-28: `chain_code` determines the authoritative ChainKind. Keep the EVM -// address, key, and authex link otherwise valid so only the redundant payload -// kind is malformed. Without the explicit registry-kind comparison this would -// create a PENDING row under ETH despite declaring its creator as SVM. -BOOST_FIXTURE_TEST_CASE(oncrtreserve_creator_chain_kind_mismatch_is_cancelled, - sysio_reserve_tester) { try { - deploy_authex(); - deploy_msgch(); - - auto creator_pub = fc::crypto::private_key::generate( - fc::crypto::private_key::key_type::em).get_public_key(); - BOOST_REQUIRE_EQUAL(success(), - recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); - const auto creator_key = em_pubkey_bytes(creator_pub); - const std::vector creator_address(20, '\x01'); - - BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", "USERRES") - ("name", "mismatched creator") - ("description", "") - ("external_token_amount", 1000) - ("requested_wire_amount", 1000) - ("source_token_precision", 9u) - ("connector_weight_bps", 5000) - ("creator_chain_kind", ChainKind::CHAIN_KIND_SVM) - ("creator_chain_addr", creator_address) - ("is_private", false) - ("creator_pub_key", creator_key))); - - auto r = find_reserve("ETH", "ETH", "USERRES"); - BOOST_REQUIRE(!r.is_null()); - BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", r["status"].as_string()); - BOOST_REQUIRE(r["creator_pub_key"].as_string().empty()); - - // The mismatch must take the refund path, not merely write the tombstone. - // `queueout` starts attestation ids at 1 (id 0 is its sequence singleton). - auto queued = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "attestations"_n, 1); - BOOST_REQUIRE(!queued.empty()); -} FC_LOG_AND_RETHROW() } - -// `oncnclrsv` must authenticate the redundant payload kind against the chain -// registry before cancelling. A mismatched callback silently preserves the -// PENDING row and queues no refund; the matching callback then wins the race, -// marks the row CANCELLED, and queues exactly one refund attestation. -BOOST_FIXTURE_TEST_CASE(oncnclrsv_requires_registry_creator_kind, - sysio_reserve_tester) { try { - deploy_authex(); - deploy_msgch(); - - auto creator_pub = fc::crypto::private_key::generate( - fc::crypto::private_key::key_type::em).get_public_key(); - BOOST_REQUIRE_EQUAL(success(), - recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); - const auto creator_key = em_pubkey_bytes(creator_pub); - const std::vector creator_address(20, '\x01'); - - BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", "CANCEL") - ("name", "cancel kind guard") - ("description", "") - ("external_token_amount", 1000) - ("requested_wire_amount", 1000) - ("source_token_precision", 9u) - ("connector_weight_bps", 5000) - ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) - ("creator_chain_addr", creator_address) - ("is_private", false) - ("creator_pub_key", creator_key))); - - auto pending = find_reserve("ETH", "ETH", "CANCEL"); - BOOST_REQUIRE(!pending.is_null()); - BOOST_REQUIRE_EQUAL("RESERVE_STATUS_PENDING", pending["status"].as_string()); - - BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncnclrsv"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", "CANCEL") - ("creator_chain_kind", ChainKind::CHAIN_KIND_SVM) - ("creator_chain_addr", creator_address))); - - auto after_mismatch = find_reserve("ETH", "ETH", "CANCEL"); - BOOST_REQUIRE_EQUAL("RESERVE_STATUS_PENDING", after_mismatch["status"].as_string()); - BOOST_REQUIRE(get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, - "attestations"_n, 1).empty()); - - BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncnclrsv"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", "CANCEL") - ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) - ("creator_chain_addr", creator_address))); - - auto cancelled = find_reserve("ETH", "ETH", "CANCEL"); - BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", cancelled["status"].as_string()); - BOOST_REQUIRE(!get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, - "attestations"_n, 1).empty()); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(oncrtreserve_invalid_creator_address_is_cancelled, - sysio_reserve_tester) { try { - deploy_authex(); - - auto creator_pub = fc::crypto::private_key::generate( - fc::crypto::private_key::key_type::em).get_public_key(); - BOOST_REQUIRE_EQUAL(success(), - recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); - const auto creator_key = em_pubkey_bytes(creator_pub); - - BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", "BADADDR") - ("name", "malformed creator address") - ("description", "") - ("external_token_amount", 1000) - ("requested_wire_amount", 1000) - ("source_token_precision", 9u) - ("connector_weight_bps", 5000) - ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) - ("creator_chain_addr", std::vector(32, '\x01')) - ("is_private", false) - ("creator_pub_key", creator_key))); - - auto r = find_reserve("ETH", "ETH", "BADADDR"); - BOOST_REQUIRE(!r.is_null()); - BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", r["status"].as_string()); - BOOST_REQUIRE(r["creator_addr"]["address"].as_string().empty()); -} FC_LOG_AND_RETHROW() } - -// A re-relay of the same unlinked create must be idempotent — it must NOT -// re-insert the row or queue a second RESERVE_CREATE_CANCELLED refund. The -// CANCELLED marker stays exactly as first written (the outpost refunds per -// (chain,token,reserve_code), so a second refund would be a double spend). -BOOST_FIXTURE_TEST_CASE(oncrtreserve_cancelled_relay_does_not_double_refund, sysio_reserve_tester) { try { - auto crt = [&]() { - return push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", "USERRES") - ("name", "user reserve") - ("description", "") - ("external_token_amount", 1000) - ("requested_wire_amount", 1000) - ("source_token_precision", 9u) - ("connector_weight_bps", 5000) - ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) - ("creator_chain_addr", std::vector(20, '\x01')) - ("is_private", false) - ("creator_pub_key", std::vector(33, '\x02'))); - }; - - BOOST_REQUIRE_EQUAL(success(), crt()); - auto r1 = find_reserve("ETH", "ETH", "USERRES"); - BOOST_REQUIRE(!r1.is_null()); - BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", r1["status"].as_string()); - const auto registered_at = r1["registered_at_ms"].as_uint64(); - - // Advance time, then re-relay the identical create. - produce_blocks(4); - BOOST_REQUIRE_EQUAL(success(), crt()); - - auto r2 = find_reserve("ETH", "ETH", "USERRES"); - BOOST_REQUIRE(!r2.is_null()); - BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", r2["status"].as_string()); - // The row is untouched: same registered_at_ms despite the advanced clock — - // proving the second relay did NOT re-insert (and therefore did not refund). - BOOST_REQUIRE_EQUAL(registered_at, r2["registered_at_ms"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -// A CANCELLED row must NOT permanently burn the (chain,token,reserve_code) -// identity. A later authex-LINKED creator reclaims the same triple — the row -// flips CANCELLED → PENDING with the new creator's fields, rather than the -// create being skipped by the existence guard. This is the namespace-squat fix. -BOOST_FIXTURE_TEST_CASE(oncrtreserve_cancelled_is_reclaimable_by_linked_creator, sysio_reserve_tester) { try { - deploy_authex(); - - // 1) An UNLINKED creator squats the triple → CANCELLED. - BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", "USERRES") - ("name", "squatter") - ("description", "squat") - ("external_token_amount", 1000) - ("requested_wire_amount", 1000) - ("source_token_precision", 9u) - ("connector_weight_bps", 5000) - ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) - ("creator_chain_addr", std::vector(20, '\x09')) - ("is_private", false) - ("creator_pub_key", std::vector(33, '\x07')))); // unlinked key - { - auto r = find_reserve("ETH", "ETH", "USERRES"); - BOOST_REQUIRE(!r.is_null()); - BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", r["status"].as_string()); - } - - // 2) The rightful owner is authex-linked. Seed the link for their EM key, - // then register the SAME triple with the matching raw pubkey. - auto creator_priv = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); - auto creator_pub = creator_priv.get_public_key(); - BOOST_REQUIRE_EQUAL(success(), - recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); - - BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", "USERRES") - ("name", "rightful owner") - ("description", "reclaimed") - ("external_token_amount", 5000) - ("requested_wire_amount", 4000) - ("source_token_precision", 9u) - ("connector_weight_bps", 5000) - ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) - ("creator_chain_addr", std::vector(20, '\x01')) - ("is_private", false) - ("creator_pub_key", em_pubkey_bytes(creator_pub)))); // linked key - - // The squat is gone: the row is PENDING and carries the reclaiming creator's - // create (proving overwrite, not the existence-guard skip). - auto r = find_reserve("ETH", "ETH", "USERRES"); - BOOST_REQUIRE(!r.is_null()); - BOOST_REQUIRE_EQUAL("RESERVE_STATUS_PENDING", r["status"].as_string()); - BOOST_REQUIRE_EQUAL("reclaimed", r["description"].as_string()); - BOOST_REQUIRE_EQUAL(5000u, r["external_token_amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(5000u, r["reserve_chain_amount"].as_uint64()); - // The canonical creator pubkey is now stored (empty on the prior CANCELLED row). - BOOST_REQUIRE(!r["creator_pub_key"].as_string().empty()); -} FC_LOG_AND_RETHROW() } - -// WSA-028: a reserve-create whose external amount is invalid — zero, which is -// what sysio.msgch's to_depot_amount clamp produces for a negative or -// out-of-range inbound TokenAmount — must NOT be silently dropped. The creator's -// outpost escrow has to be released, so the request is routed into the -// cancel/refund flow: a CANCELLED row is inserted (the same branch that queues -// RESERVE_CREATE_CANCELLED back to the outpost). Pre-fix, oncrtreserve returned -// at the zero-amount guard before any cancel, stranding the escrow. The creator -// here is properly authex-LINKED, so the amount is the sole rejection reason. -BOOST_FIXTURE_TEST_CASE(oncrtreserve_invalid_amount_is_cancelled, sysio_reserve_tester) { try { - deploy_authex(); - - auto creator_priv = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); - auto creator_pub = creator_priv.get_public_key(); - BOOST_REQUIRE_EQUAL(success(), - recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); - - // Linked creator, but external_token_amount == 0 (the clamp result for an - // invalid inbound amount). The link is valid, so the amount alone forces the - // cancel/refund — proving the amount path no longer drops silently. - BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", "USERRES") - ("name", "invalid amount") - ("description", "") - ("external_token_amount", 0) - ("requested_wire_amount", 1000) - ("source_token_precision", 9u) - ("connector_weight_bps", 5000) - ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) - ("creator_chain_addr", std::vector(20, '\x01')) - ("is_private", false) - ("creator_pub_key", em_pubkey_bytes(creator_pub)))); // linked key - - // The escrow is released via the cancel/refund flow: a CANCELLED row stands - // (inserted in the same branch that queues RESERVE_CREATE_CANCELLED). - auto r = find_reserve("ETH", "ETH", "USERRES"); - BOOST_REQUIRE(!r.is_null()); - BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", r["status"].as_string()); -} FC_LOG_AND_RETHROW() } - -// The same `sysio`-billed metadata bound `regreserve` enforces, applied to the inbound -// creation path. `oncrtreserve` is an OPP dispatch handler, so it must NOT `check()` — -// aborting here rolls back the consensus-tipping delivery and stalls epoch advancement -// chain-wide (feedback_opp_handlers_never_throw / epoch-stall-is-fatal). An over-bound -// string therefore joins `invalid_amount` and the unlinked-creator case on the existing -// reject predicate, releasing the creator's escrow via RESERVE_CREATE_CANCELLED. -// -// The creator here is properly LINKED with a valid amount, so the metadata is the sole -// rejection reason. The tombstone is itself a `sysio`-billed row, so the over-bound strings -// are replaced by a fixed marker rather than carried onto it. Both an ASCII and a MULTIBYTE -// over-bound label are covered: because nothing is truncated there is no code-point boundary -// to split, so neither can leave malformed text in state. A third case drives the OTHER half -// of `metadata_exceeds_bounds` — an over-bound `description` behind an in-bound name — so -// each side of the predicate is exercised independently rather than only via the label. -BOOST_FIXTURE_TEST_CASE(oncrtreserve_oversized_metadata_is_cancelled, sysio_reserve_tester) { try { - deploy_authex(); - - auto creator_priv = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); - auto creator_pub = creator_priv.get_public_key(); - BOOST_REQUIRE_EQUAL(success(), - recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); - - auto create_with_metadata = [&](std::string_view reserve_code, - const std::string& name, - const std::string& description) { - return push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", reserve_code) - ("name", name) - ("description", description) - ("external_token_amount", 1000) - ("requested_wire_amount", 1000) - ("source_token_precision", 9u) - ("connector_weight_bps", 5000) - ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) - ("creator_chain_addr", std::vector(20, '\x01')) - ("is_private", false) - ("creator_pub_key", em_pubkey_bytes(creator_pub))); // linked key - }; - - // 129 ASCII bytes — one over label_max_bytes. - BOOST_REQUIRE_EQUAL(success(), create_with_metadata("USERRES", std::string(129, 'x'), "")); - auto ascii_row = find_reserve("ETH", "ETH", "USERRES"); - BOOST_REQUIRE(!ascii_row.is_null()); - BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", ascii_row["status"].as_string()); - BOOST_REQUIRE_EQUAL("", ascii_row["name"].as_string()); - - // 129 bytes as 127 ASCII + `é` (0xC3 0xA9) — a byte-wise truncation at the bound would - // have split the final character and persisted a lone lead byte. The marker sidesteps - // that entirely: nothing is truncated, so there is no code-point boundary to get wrong. - const std::string multibyte_name = std::string(127, 'x') + "\xC3\xA9"; - BOOST_REQUIRE_EQUAL(129u, multibyte_name.size()); - BOOST_REQUIRE_EQUAL(success(), create_with_metadata("USERRES2", multibyte_name, "")); - auto multibyte_row = find_reserve("ETH", "ETH", "USERRES2"); - BOOST_REQUIRE(!multibyte_row.is_null()); - BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", multibyte_row["status"].as_string()); - BOOST_REQUIRE_EQUAL("", multibyte_row["name"].as_string()); - - // 257 bytes — one over description_max_bytes — behind an in-bound name. This is the - // `description` half of `metadata_exceeds_bounds`: the label alone would have passed, so - // the description is the sole rejection reason. The tombstone drops the over-bound text - // rather than carrying it onto a `sysio`-billed row, so the stored description is empty - // while the name still reads as the rejection marker. - const std::string oversized_description(257, 'd'); - BOOST_REQUIRE_EQUAL(success(), - create_with_metadata("USERRES3", "in-bound name", oversized_description)); - auto description_row = find_reserve("ETH", "ETH", "USERRES3"); - BOOST_REQUIRE(!description_row.is_null()); - BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", description_row["status"].as_string()); - BOOST_REQUIRE_EQUAL("", description_row["name"].as_string()); - BOOST_REQUIRE_EQUAL("", description_row["description"].as_string()); -} FC_LOG_AND_RETHROW() } - -// ── matchreserve (gating preconditions) ── - -BOOST_FIXTURE_TEST_CASE(matchreserve_rejects_unknown_reserve, sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: matchreserve: reserve not found"), - push_action("alice"_n, "matchreserve"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", "NOPE") - ("matcher", "alice") - ("wire_amount", 100))); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(matchreserve_rejects_non_pending, sysio_reserve_tester) { try { - // Bootstrap rows activate inline — there is nothing to match. - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1000, 1000)); - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: matchreserve: reserve is not PENDING"), - push_action("alice"_n, "matchreserve"_n, mvo() - ("chain_code", "ETH") - ("token_code", "ETH") - ("reserve_code", "PRIMARY") - ("matcher", "alice") - ("wire_amount", 1000))); -} FC_LOG_AND_RETHROW() } - -// ── Emit-time settlement actions (auth = sysio.uwrit) ── - -BOOST_FIXTURE_TEST_CASE(applyswap_requires_uwrit_auth, sysio_reserve_tester) { try { - BOOST_REQUIRE(push_action("alice"_n, "applyswap"_n, mvo() - ("src_chain_code", "ETH") - ("src_token_code", "ETH") - ("src_reserve_code", "PRIMARY") - ("src_amount", 100) - ("dst_chain_code", "SOLANA") - ("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("dst_amount", 50) - ("underwriter", "underwriter1") - ).find("missing authority of sysio.uwrit") != std::string::npos); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(applyswap_applies_four_legs, sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1000, 1000)); - BOOST_REQUIRE_EQUAL(success(), - regreserve("SOLANA", "SOL", "PRIMARY", 1000, 1000)); - - // w = cp_output(1000, 1000, 100) = 1000*100 / (1000+100) = 90 (floor). - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "applyswap"_n, mvo() - ("src_chain_code", "ETH") - ("src_token_code", "ETH") - ("src_reserve_code", "PRIMARY") - ("src_amount", 100) - ("dst_chain_code", "SOLANA") - ("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("dst_amount", 50) - ("underwriter", "underwriter1"))); - - auto src = find_reserve("ETH", "ETH", "PRIMARY"); - auto dst = find_reserve("SOLANA", "SOL", "PRIMARY"); - BOOST_REQUIRE_EQUAL(1100, src["reserve_chain_amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(910, src["reserve_wire_amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(1090, dst["reserve_wire_amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(950, dst["reserve_chain_amount"].as_uint64()); - // Σ reserve_wire_amount unchanged (910 + 1090 == 2000) — at these tiny - // amounts the 0.1% fee floors to 0, so the w hop stays fully internal. -} FC_LOG_AND_RETHROW() } - -// [P0] WNS-02: applyswap must refuse a destination debit larger than the -// destination curve's output for the post-fee WIRE it is receiving. The audited -// vulnerability paid out a caller-chosen `dst_amount` verbatim, so the reserve -// enforces its own floor instead of trusting `sysio.uwrit` to have derived it. -BOOST_FIXTURE_TEST_CASE(applyswap_rejects_debit_above_curve_output, sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1000, 1000)); - BOOST_REQUIRE_EQUAL(success(), - regreserve("SOLANA", "SOL", "PRIMARY", 1000, 1000)); - - // w_gross = 90 (see applyswap_applies_four_legs), fee floors to 0, so the - // destination receives 90 WIRE and its curve yields - // floor(1000*90/1090) = 82 token. 83 is one subunit past what the AMM owes. - constexpr int64_t CURVE_OUT = 82; - auto apply = [&](int64_t dst_amount) { - return push_action(UWRIT_ACCOUNT, "applyswap"_n, mvo() - ("src_chain_code", "ETH") - ("src_token_code", "ETH") - ("src_reserve_code", "PRIMARY") - ("src_amount", 100) - ("dst_chain_code", "SOLANA") - ("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("dst_amount", dst_amount) - ("underwriter", "underwriter1")); - }; - - // One subunit over — refused. A grossly-inflated amount (the drain shape) - // takes the same path. - const auto expected = error("assertion failure with message: applyswap: " - "destination amount exceeds the curve output for the post-fee WIRE"); - BOOST_REQUIRE_EQUAL(expected, apply(CURVE_OUT + 1)); - BOOST_REQUIRE_EQUAL(expected, apply(900)); - - // Exactly the curve output settles — the amount `sysio.uwrit` derives. - BOOST_REQUIRE_EQUAL(success(), apply(CURVE_OUT)); - BOOST_REQUIRE_EQUAL(1000 - CURVE_OUT, - find_reserve("SOLANA", "SOL", "PRIMARY")["reserve_chain_amount"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(applyswap_charges_fee_and_routes_50_50, sysio_reserve_tester) { try { - // Large amounts so the default 0.1% (10 bps) fee is non-zero and routes. - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); - BOOST_REQUIRE_EQUAL(success(), - regreserve("SOLANA", "SOL", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); - - const int64_t sysio_before = wire_balance(SYSIO_ACCOUNT); - const int64_t resv_before = wire_balance(RESERVE_ACCOUNT); - - // w_gross = cp_output(1e12, 1e12, 1e9) = 999'000'999 (50/50 = constant product). - // fee = 999'000'999 * 10 / 10000 = 999'000 ; underwriter = reward = 499'500 ; - // net = 999'000'999 - 999'000 = 998'001'999. - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "applyswap"_n, mvo() - ("src_chain_code", "ETH") - ("src_token_code", "ETH") - ("src_reserve_code", "PRIMARY") - ("src_amount", 1'000'000'000ULL) - ("dst_chain_code", "SOLANA") - ("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("dst_amount", 100'000'000ULL) - ("underwriter", "underwriter1"))); - - auto src = find_reserve("ETH", "ETH", "PRIMARY"); - auto dst = find_reserve("SOLANA", "SOL", "PRIMARY"); - // Source gives up the full gross WIRE; destination receives only the net. - BOOST_REQUIRE_EQUAL(1'000'000'000'000ULL - 999'000'999ULL, src["reserve_wire_amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(1'000'000'000'000ULL + 998'001'999ULL, dst["reserve_wire_amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(1'000'000'000'000ULL + 1'000'000'000ULL, src["reserve_chain_amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(1'000'000'000'000ULL - 100'000'000ULL, dst["reserve_chain_amount"].as_uint64()); - - // Fee split 50/50: half accrues to the winning underwriter's claimable row, - // half to the batch-operator rewards bucket. - auto uwf = get_uwfees(UNDERWRITER_ACCOUNT); - BOOST_REQUIRE_EQUAL(499'500ULL, uwf["balance"].as_uint64()); - BOOST_REQUIRE_EQUAL(499'500ULL, uwf["lifetime_accrued"].as_uint64()); - BOOST_REQUIRE_EQUAL(0ULL, uwf["lifetime_claimed"].as_uint64()); - - auto bkt = get_rewardbkt(); - BOOST_REQUIRE_EQUAL(499'500ULL, bkt["balance"].as_uint64()); - BOOST_REQUIRE_EQUAL(499'500ULL, bkt["lifetime_accrued"].as_uint64()); - - // BOTH halves stay in reserv custody — no part of a swap fee reaches the - // emissions treasury, so neither real balance moves at settlement. - BOOST_REQUIRE_EQUAL(sysio_before, wire_balance(SYSIO_ACCOUNT)); - BOOST_REQUIRE_EQUAL(resv_before, wire_balance(RESERVE_ACCOUNT)); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(setconfig_emissions_share_routes_pool_to_treasury, sysio_reserve_tester) { try { - // Stage 2 of the split is a governance dial: a non-zero - // `fee_emissions_share_bps` diverts that share of the REWARDS POOL (the half - // left after the underwriter's cut) out of custody to the `sysio` treasury. - BOOST_REQUIRE(push_action("alice"_n, "setconfig"_n, mvo()("fee_emissions_share_bps", 5000)) - .find("missing authority of sysio.reserv") != std::string::npos); - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: setconfig: fee_emissions_share_bps must be <= 10000 (100% of the rewards pool)"), - push_action(RESERVE_ACCOUNT, "setconfig"_n, mvo()("fee_emissions_share_bps", 10001))); - - // Send the WHOLE rewards pool to the treasury so the split is unambiguous. - BOOST_REQUIRE_EQUAL(success(), push_action(RESERVE_ACCOUNT, "setconfig"_n, - mvo()("fee_emissions_share_bps", 10000))); - - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); - BOOST_REQUIRE_EQUAL(success(), - regreserve("SOLANA", "SOL", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); - - const int64_t sysio_before = wire_balance(SYSIO_ACCOUNT); - const int64_t resv_before = wire_balance(RESERVE_ACCOUNT); - - // Same swap as the 50/50 test: fee 999'000, underwriter 499'500, pool 499'500. - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "applyswap"_n, mvo() - ("src_chain_code", "ETH") - ("src_token_code", "ETH") - ("src_reserve_code", "PRIMARY") - ("src_amount", 1'000'000'000ULL) - ("dst_chain_code", "SOLANA") - ("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("dst_amount", 100'000'000ULL) - ("underwriter", "underwriter1"))); - - // The underwriter's half is untouched by this dial and stays in custody. - BOOST_REQUIRE_EQUAL(499'500ULL, get_uwfees(UNDERWRITER_ACCOUNT)["balance"].as_uint64()); - // The whole pool went to the treasury instead of the rewards bucket, and it - // is the ONLY part of the fee that left custody. - BOOST_REQUIRE(get_rewardbkt().is_null()); - BOOST_REQUIRE_EQUAL(sysio_before + 499'500, wire_balance(SYSIO_ACCOUNT)); - BOOST_REQUIRE_EQUAL(resv_before - 499'500, wire_balance(RESERVE_ACCOUNT)); -} FC_LOG_AND_RETHROW() } - -// ── Reserve OWNER fee: per-reserve rate, accrual, and owner claim (WIRE-281) ── - -BOOST_FIXTURE_TEST_CASE(setrsvfee_guards_owner_status_and_bounds, sysio_reserve_tester) { try { - // Owner-less (bootstrap-seeded) reserve: nobody can authorize a fee, so no - // WIRE can ever accrue with no claimant. - BOOST_REQUIRE_EQUAL(success(), regreserve("ETH", "ETH", "PRIMARY", 1000, 1000)); - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: setrsvfee: reserve has no owner"), - push_action(RESERVE_ACCOUNT, "setrsvfee"_n, mvo() - ("chain_code", "ETH")("token_code", "ETH") - ("reserve_code", "PRIMARY")("owner_fee_bps", 100))); - - // An OWNED reserve: only the owner may set the fee. - BOOST_REQUIRE_EQUAL(success(), - regreserve("SOLANA", "SOL", "PRIMARY", 1000, 1000, 5000, false, "alice"_n)); - auto setFee = [&](name signer, uint32_t bps) { - return push_action(signer, "setrsvfee"_n, mvo() - ("chain_code", "SOLANA")("token_code", "SOL") - ("reserve_code", "PRIMARY")("owner_fee_bps", bps)); - }; - BOOST_REQUIRE(setFee(UNDERWRITER_ACCOUNT, 100).find("missing authority of alice") != std::string::npos); - - // 0 disables; the [1, 9900] band is accepted at both ends; past 99% rejected. - BOOST_REQUIRE_EQUAL(success(), setFee("alice"_n, 0)); - produce_block(); - BOOST_REQUIRE_EQUAL(success(), setFee("alice"_n, 1)); - produce_block(); - BOOST_REQUIRE_EQUAL(success(), setFee("alice"_n, 9900)); - produce_block(); - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: setrsvfee: owner_fee_bps must be 0 or within [1, 9900]"), - setFee("alice"_n, 9901)); - - // Unknown reserve fails before any auth work. - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: setrsvfee: reserve not found"), - push_action("alice"_n, "setrsvfee"_n, mvo() - ("chain_code", "ETH")("token_code", "NOPE") - ("reserve_code", "PRIMARY")("owner_fee_bps", 10))); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(applyswap_charges_both_reserve_owner_fees, sysio_reserve_tester) { try { - // Jonathan, 2026-08-04: "per reserve" — a chain-to-chain swap pays 3 fees, - // two reserve owners plus the network. - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL, - 5000, false, "alice"_n)); - BOOST_REQUIRE_EQUAL(success(), - regreserve("SOLANA", "SOL", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL, - 5000, false, UNDERWRITER_ACCOUNT)); - - // src 100 bps (1%), dst 200 bps (2%) on the same WIRE leg. - BOOST_REQUIRE_EQUAL(success(), push_action("alice"_n, "setrsvfee"_n, mvo() - ("chain_code", "ETH")("token_code", "ETH") - ("reserve_code", "PRIMARY")("owner_fee_bps", 100))); - BOOST_REQUIRE_EQUAL(success(), push_action(UNDERWRITER_ACCOUNT, "setrsvfee"_n, mvo() - ("chain_code", "SOLANA")("token_code", "SOL") - ("reserve_code", "PRIMARY")("owner_fee_bps", 200))); - - const int64_t resv_before = wire_balance(RESERVE_ACCOUNT); - - // w_gross = 999'000'999. network 10bps = 999'000; src 1% = 9'990'009; - // dst 2% = 19'980'019; net = w_gross - (999'000 + 9'990'009 + 19'980'019). - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "applyswap"_n, mvo() - ("src_chain_code", "ETH") - ("src_token_code", "ETH") - ("src_reserve_code", "PRIMARY") - ("src_amount", 1'000'000'000ULL) - ("dst_chain_code", "SOLANA") - ("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("dst_amount", 100'000'000ULL) - ("underwriter", "underwriter1"))); - - auto src = find_reserve("ETH", "ETH", "PRIMARY"); - auto dst = find_reserve("SOLANA", "SOL", "PRIMARY"); - BOOST_REQUIRE_EQUAL(9'990'009ULL, src["owner_fee_accrued"].as_uint64()); - BOOST_REQUIRE_EQUAL(9'990'009ULL, src["owner_fee_lifetime"].as_uint64()); - BOOST_REQUIRE_EQUAL(19'980'019ULL, dst["owner_fee_accrued"].as_uint64()); - BOOST_REQUIRE_EQUAL(19'980'019ULL, dst["owner_fee_lifetime"].as_uint64()); - - // Destination liquidity is the leg minus ALL THREE fees. - const uint64_t total_fee = 999'000ULL + 9'990'009ULL + 19'980'019ULL; - BOOST_REQUIRE_EQUAL(1'000'000'000'000ULL + (999'000'999ULL - total_fee), - dst["reserve_wire_amount"].as_uint64()); - - // Every fee stayed in custody — the reserve shares as accruals, the network - // fee as the underwriter accrual + rewards bucket. - BOOST_REQUIRE_EQUAL(resv_before, wire_balance(RESERVE_ACCOUNT)); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(single_reserve_paths_charge_only_their_own_side, sysio_reserve_tester) { try { - // A WIRE endpoint has no reserve, so only the one participating reserve - // charges: paywire → source, applyfromwire → destination. - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL, - 5000, false, "alice"_n)); - BOOST_REQUIRE_EQUAL(success(), push_action("alice"_n, "setrsvfee"_n, mvo() - ("chain_code", "ETH")("token_code", "ETH") - ("reserve_code", "PRIMARY")("owner_fee_bps", 100))); - - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "paywire"_n, mvo() - ("src_chain_code", "ETH") - ("src_token_code", "ETH") - ("src_reserve_code", "PRIMARY") - ("src_amount", 1'000'000'000ULL) - ("recipient", "alice") - ("wire_out", 100'000'000ULL) - ("underwriter", "underwriter1"))); - - // 1% of the 999'000'999 gross leg accrued to the source reserve's owner. - BOOST_REQUIRE_EQUAL(9'990'009ULL, - find_reserve("ETH", "ETH", "PRIMARY")["owner_fee_accrued"].as_uint64()); - - // applyfromwire: the destination reserve is the only one that charges. - BOOST_REQUIRE_EQUAL(success(), - regreserve("SOLANA", "SOL", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL, - 5000, false, UNDERWRITER_ACCOUNT)); - BOOST_REQUIRE_EQUAL(success(), push_action(UNDERWRITER_ACCOUNT, "setrsvfee"_n, mvo() - ("chain_code", "SOLANA")("token_code", "SOL") - ("reserve_code", "PRIMARY")("owner_fee_bps", 200))); - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "applyfromwire"_n, mvo() - ("dst_chain_code", "SOLANA") - ("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("wire_in", 1'000'000'000ULL) - ("dst_amount", 100'000'000ULL) - ("underwriter", "underwriter1"))); - - // 2% of the 1e9 escrow. The source reserve is untouched by this swap. - BOOST_REQUIRE_EQUAL(20'000'000ULL, - find_reserve("SOLANA", "SOL", "PRIMARY")["owner_fee_accrued"].as_uint64()); - BOOST_REQUIRE_EQUAL(9'990'009ULL, - find_reserve("ETH", "ETH", "PRIMARY")["owner_fee_accrued"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(claimrsvfee_pays_owner_and_guards_auth, sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL, - 5000, false, "alice"_n)); - - auto claim = [&](name signer) { - return push_action(signer, "claimrsvfee"_n, mvo() - ("chain_code", "ETH")("token_code", "ETH") - ("reserve_code", "PRIMARY")); - }; - // Nothing earned yet. - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: claimrsvfee: no unclaimed balance"), claim("alice"_n)); - - BOOST_REQUIRE_EQUAL(success(), push_action("alice"_n, "setrsvfee"_n, mvo() - ("chain_code", "ETH")("token_code", "ETH") - ("reserve_code", "PRIMARY")("owner_fee_bps", 100))); - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "paywire"_n, mvo() - ("src_chain_code", "ETH") - ("src_token_code", "ETH") - ("src_reserve_code", "PRIMARY") - ("src_amount", 1'000'000'000ULL) - ("recipient", UNDERWRITER_ACCOUNT) - ("wire_out", 100'000'000ULL) - ("underwriter", "underwriter1"))); - - const int64_t alice_before = wire_balance("alice"_n), - resv_before = wire_balance(RESERVE_ACCOUNT); - BOOST_REQUIRE_EQUAL(9'990'009ULL, - find_reserve("ETH", "ETH", "PRIMARY")["owner_fee_accrued"].as_uint64()); - - // Only the owner may sweep it. - BOOST_REQUIRE(claim(UNDERWRITER_ACCOUNT).find("missing authority of alice") != std::string::npos); - BOOST_REQUIRE_EQUAL(success(), claim("alice"_n)); - - BOOST_REQUIRE_EQUAL(alice_before + 9'990'009, wire_balance("alice"_n)); - BOOST_REQUIRE_EQUAL(resv_before - 9'990'009, wire_balance(RESERVE_ACCOUNT)); - - auto row = find_reserve("ETH", "ETH", "PRIMARY"); - BOOST_REQUIRE_EQUAL(0ULL, row["owner_fee_accrued"].as_uint64()); - BOOST_REQUIRE_EQUAL(9'990'009ULL, row["owner_fee_lifetime"].as_uint64()); // audit total survives - - produce_block(); - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: claimrsvfee: no unclaimed balance"), claim("alice"_n)); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(swapquote_prices_the_reserve_owner_fees, sysio_reserve_tester) { try { - // The read-only quote must price EXACTLY what settlement charges, reserve - // fees included — otherwise the variance check drifts against the books. - // So this asserts the DECODED quote against the shared AMM kernel, not just - // that the action succeeded: a quote that ignored `owner_fee_bps` entirely - // would still return success. - constexpr uint64_t POOL = 1'000'000'000'000ULL; - constexpr uint32_t WEIGHT = 5000; - constexpr uint64_t FROM = 1'000'000'000ULL; - constexpr uint32_t OWNER_FEE = 500; // 5%, charged by EACH side's reserve - // `sysio.uwrit` exists as a bare account in this fixture (no contract), so - // the contract's `uwrit_fee_bps()` reads the `uw_config{}` in-struct default. - constexpr uint32_t NETWORK_FEE_BPS = 10; - - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", POOL, POOL, WEIGHT, false, "alice"_n)); - BOOST_REQUIRE_EQUAL(success(), - regreserve("SOLANA", "SOL", "PRIMARY", POOL, POOL, WEIGHT, false, UNDERWRITER_ACCOUNT)); - - /// The kernel's answer for these pools at a given pair of reserve fees. - auto expected = [&](uint32_t src_fee, uint32_t dst_fee) { - return opp::amm::quote_swap(/*src_is_wire*/false, POOL, POOL, WEIGHT, - /*dst_is_wire*/false, POOL, POOL, WEIGHT, - FROM, NETWORK_FEE_BPS, src_fee, dst_fee); - }; - - // Fee-free baseline: network fee only. - const uint64_t before = swapquote_value("ETH", "ETH", "PRIMARY", FROM, - "SOLANA", "SOL", "PRIMARY"); - BOOST_REQUIRE_GT(before, 0u); - BOOST_CHECK_EQUAL(before, expected(0, 0)); - - BOOST_REQUIRE_EQUAL(success(), push_action("alice"_n, "setrsvfee"_n, mvo() - ("chain_code", "ETH")("token_code", "ETH") - ("reserve_code", "PRIMARY")("owner_fee_bps", OWNER_FEE))); - BOOST_REQUIRE_EQUAL(success(), push_action(UNDERWRITER_ACCOUNT, "setrsvfee"_n, mvo() - ("chain_code", "SOLANA")("token_code", "SOL") - ("reserve_code", "PRIMARY")("owner_fee_bps", OWNER_FEE))); - produce_block(); - - // Both owner fees are now priced in, off the same gross WIRE leg. - const uint64_t after = swapquote_value("ETH", "ETH", "PRIMARY", FROM, - "SOLANA", "SOL", "PRIMARY"); - BOOST_CHECK_EQUAL(after, expected(OWNER_FEE, OWNER_FEE)); - BOOST_CHECK_LT(after, before); // the claim this case exists to prove - - // Each side is priced INDEPENDENTLY — clearing one must move the quote back - // by only that side's share, which a quote summing the wrong reserve's rate - // (or double-counting one) would not reproduce. - BOOST_REQUIRE_EQUAL(success(), push_action(UNDERWRITER_ACCOUNT, "setrsvfee"_n, mvo() - ("chain_code", "SOLANA")("token_code", "SOL") - ("reserve_code", "PRIMARY")("owner_fee_bps", 0))); - produce_block(); - const uint64_t source_only = swapquote_value("ETH", "ETH", "PRIMARY", FROM, - "SOLANA", "SOL", "PRIMARY"); - BOOST_CHECK_EQUAL(source_only, expected(OWNER_FEE, 0)); - BOOST_CHECK_LT(source_only, before); - BOOST_CHECK_GT(source_only, after); -} FC_LOG_AND_RETHROW() } - -/// WIRE-375: a token called WIRE on a foreign chain is not depot-native WIRE. -/// Without an ACTIVE `(ETH, WIRE, PRIMARY)` reserve, the source leg cannot be -/// priced and must return zero instead of taking the native WIRE-at-par path. -BOOST_FIXTURE_TEST_CASE(swapquote_foreign_wire_codename_requires_reserve, - sysio_reserve_tester) { try { - constexpr uint64_t FROM = 2'000'000'000; - - BOOST_REQUIRE(find_reserve("ETH", "WIRE", "PRIMARY").is_null()); - BOOST_REQUIRE_EQUAL(0u, swapquote_value("ETH", "WIRE", "PRIMARY", FROM, - "WIRE", "WIRE", "WIRE")); -} FC_LOG_AND_RETHROW() } - -// ── Underwriter fee accrual + owner-authenticated claim ── - -BOOST_FIXTURE_TEST_CASE(claimuwfee_pays_accrual_and_zeroes_balance, sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); - BOOST_REQUIRE_EQUAL(success(), - regreserve("SOLANA", "SOL", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "applyswap"_n, mvo() - ("src_chain_code", "ETH") - ("src_token_code", "ETH") - ("src_reserve_code", "PRIMARY") - ("src_amount", 1'000'000'000ULL) - ("dst_chain_code", "SOLANA") - ("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("dst_amount", 100'000'000ULL) - ("underwriter", "underwriter1"))); - - const int64_t resv_before = wire_balance(RESERVE_ACCOUNT); - BOOST_REQUIRE_EQUAL(0, wire_balance(UNDERWRITER_ACCOUNT)); - BOOST_REQUIRE_EQUAL(499'500ULL, get_uwfees(UNDERWRITER_ACCOUNT)["balance"].as_uint64()); - - // The earner claims their own accrual. - BOOST_REQUIRE_EQUAL(success(), push_action(UNDERWRITER_ACCOUNT, "claimuwfee"_n, - mvo()("underwriter", "underwriter1"))); - - // REAL WIRE moved out of reserv custody to the underwriter. - BOOST_REQUIRE_EQUAL(499'500, wire_balance(UNDERWRITER_ACCOUNT)); - BOOST_REQUIRE_EQUAL(resv_before - 499'500, wire_balance(RESERVE_ACCOUNT)); - - // Row retained at zero balance; lifetime totals record the round trip. - auto uwf = get_uwfees(UNDERWRITER_ACCOUNT); - BOOST_REQUIRE_EQUAL(0ULL, uwf["balance"].as_uint64()); - BOOST_REQUIRE_EQUAL(499'500ULL, uwf["lifetime_accrued"].as_uint64()); - BOOST_REQUIRE_EQUAL(499'500ULL, uwf["lifetime_claimed"].as_uint64()); - - // A second claim on the drained row is a caller error, not a silent no-op. - // Produce a block first: an identical action re-pushed against the same TAPOS - // reference block serializes to the same tx id and is rejected as a duplicate - // before it ever reaches the contract. - produce_block(); - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: claimuwfee: no unclaimed balance"), - push_action(UNDERWRITER_ACCOUNT, "claimuwfee"_n, mvo()("underwriter", "underwriter1"))); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(claimuwfee_requires_earner_auth_and_a_row, sysio_reserve_tester) { try { - // Nobody else may sweep an underwriter's accrual. - BOOST_REQUIRE(push_action("alice"_n, "claimuwfee"_n, mvo()("underwriter", "underwriter1")) - .find("missing authority of underwriter1") != std::string::npos); - - // An underwriter that never earned has no row at all. - BOOST_REQUIRE(get_uwfees(UNDERWRITER_ACCOUNT).is_null()); - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: claimuwfee: no accrued swap fees for this underwriter"), - push_action(UNDERWRITER_ACCOUNT, "claimuwfee"_n, mvo()("underwriter", "underwriter1"))); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(applyswap_accrues_per_underwriter_and_accumulates, sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); - BOOST_REQUIRE_EQUAL(success(), - regreserve("SOLANA", "SOL", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); - - auto swap_won_by = [&](const char* underwriter) { - return push_action(UWRIT_ACCOUNT, "applyswap"_n, mvo() - ("src_chain_code", "ETH") - ("src_token_code", "ETH") - ("src_reserve_code", "PRIMARY") - ("src_amount", 1'000'000'000ULL) - ("dst_chain_code", "SOLANA") - ("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("dst_amount", 100'000'000ULL) - ("underwriter", underwriter)); - }; - - // Two swaps won by the same underwriter accumulate on one row. Each repeat of - // an identical action needs its own block — same TAPOS reference + same bytes - // is one tx id, rejected as a duplicate before reaching the contract. - BOOST_REQUIRE_EQUAL(success(), swap_won_by("underwriter1")); - const uint64_t first = get_uwfees(UNDERWRITER_ACCOUNT)["balance"].as_uint64(); - BOOST_REQUIRE_GT(first, 0u); - produce_block(); - BOOST_REQUIRE_EQUAL(success(), swap_won_by("underwriter1")); - auto after_two = get_uwfees(UNDERWRITER_ACCOUNT); - BOOST_REQUIRE_GT(after_two["balance"].as_uint64(), first); - BOOST_REQUIRE_EQUAL(after_two["balance"].as_uint64(), - after_two["lifetime_accrued"].as_uint64()); - - // A different winner accrues to their OWN row, leaving the first untouched. - // (Different `underwriter` bytes, so no duplicate-tx risk here.) - const uint64_t before_other = after_two["balance"].as_uint64(); - BOOST_REQUIRE_EQUAL(success(), swap_won_by("alice")); - BOOST_REQUIRE_GT(get_uwfees("alice"_n)["balance"].as_uint64(), 0u); - BOOST_REQUIRE_EQUAL(before_other, get_uwfees(UNDERWRITER_ACCOUNT)["balance"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -// ── drainrewards: sweep the accrued batch-operator share to the emissions treasury ── -// payepoch (sysio.system) calls this inline to fold swap fees into the per-epoch -// batch-operator distribution. - -BOOST_FIXTURE_TEST_CASE(drainrewards_sweeps_bucket_to_treasury, sysio_reserve_tester) { try { - // Seed the rewards bucket with a swap fee (same setup as the 50/50 routing test: - // reward half = 499'500 accrues into the bucket, staying in reserv custody). - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); - BOOST_REQUIRE_EQUAL(success(), - regreserve("SOLANA", "SOL", "PRIMARY", 1'000'000'000'000ULL, 1'000'000'000'000ULL)); - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "applyswap"_n, mvo() - ("src_chain_code", "ETH") - ("src_token_code", "ETH") - ("src_reserve_code", "PRIMARY") - ("src_amount", 1'000'000'000ULL) - ("dst_chain_code", "SOLANA") - ("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("dst_amount", 100'000'000ULL) - ("underwriter", "underwriter1"))); - - auto bkt = get_rewardbkt(); - const uint64_t reward = bkt["balance"].as_uint64(); - const uint64_t lifetime = bkt["lifetime_accrued"].as_uint64(); - BOOST_REQUIRE_EQUAL(499'500ULL, reward); - - const int64_t sysio_before = wire_balance(SYSIO_ACCOUNT); - const int64_t resv_before = wire_balance(RESERVE_ACCOUNT); - - // Sweep the whole bucket to the treasury (auth = sysio). - BOOST_REQUIRE_EQUAL(success(), push_action(SYSIO_ACCOUNT, "drainrewards"_n, - mvo()("amount", static_cast(reward)))); - - auto bkt_after = get_rewardbkt(); - BOOST_REQUIRE_EQUAL(0ULL, bkt_after["balance"].as_uint64()); // balance drained - BOOST_REQUIRE_EQUAL(lifetime, bkt_after["lifetime_accrued"].as_uint64()); // audit total untouched - BOOST_REQUIRE_EQUAL(sysio_before + static_cast(reward), - wire_balance(SYSIO_ACCOUNT)); // WIRE moved to treasury - BOOST_REQUIRE_EQUAL(resv_before - static_cast(reward), - wire_balance(RESERVE_ACCOUNT)); // left reserv custody -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(drainrewards_auth_and_overdrain_guarded, sysio_reserve_tester) { try { - // Only `sysio` (the treasury / system account) may drain. - BOOST_REQUIRE(push_action("alice"_n, "drainrewards"_n, mvo()("amount", int64_t(1))) - .find("missing authority of sysio") != std::string::npos); - - // Draining more than the live balance (here: an empty bucket) is rejected, - // not silently clamped -- this only fires on a caller bug. - BOOST_REQUIRE(push_action(SYSIO_ACCOUNT, "drainrewards"_n, mvo()("amount", int64_t(1))) - .find("amount exceeds rewards bucket balance") != std::string::npos); - - // Non-positive amounts fail loudly (internal sweep -> a non-positive amount - // signals a caller bug, not a no-op). - BOOST_REQUIRE(push_action(SYSIO_ACCOUNT, "drainrewards"_n, mvo()("amount", int64_t(0))) - .find("amount must be positive") != std::string::npos); - BOOST_REQUIRE(push_action(SYSIO_ACCOUNT, "drainrewards"_n, mvo()("amount", int64_t(-5))) - .find("amount must be positive") != std::string::npos); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(applyfromwire_credits_wire_and_debits_chain, sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - regreserve("SOLANA", "SOL", "PRIMARY", 1000, 1000)); - - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "applyfromwire"_n, mvo() - ("dst_chain_code", "SOLANA") - ("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("wire_in", 200) - ("dst_amount", 100) - ("underwriter", "underwriter1"))); - - auto r = find_reserve("SOLANA", "SOL", "PRIMARY"); - BOOST_REQUIRE_EQUAL(1200, r["reserve_wire_amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(900, r["reserve_chain_amount"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -// [P0] WNS-02, from-WIRE shape — see applyswap_rejects_debit_above_curve_output. -// The user's escrowed WIRE feeds the WIRE leg directly, so the bound is the -// destination curve's output for the post-fee remainder. -BOOST_FIXTURE_TEST_CASE(applyfromwire_rejects_debit_above_curve_output, - sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - regreserve("SOLANA", "SOL", "PRIMARY", 1000, 1000)); - - // 200 WIRE in, 0.1% fee floors to 0, so the curve yields - // floor(1000*200/1200) = 166 token. - constexpr int64_t CURVE_OUT = 166; - auto apply = [&](int64_t dst_amount) { - return push_action(UWRIT_ACCOUNT, "applyfromwire"_n, mvo() - ("dst_chain_code", "SOLANA") - ("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("wire_in", 200) - ("dst_amount", dst_amount) - ("underwriter", "underwriter1")); - }; - - const auto expected = error("assertion failure with message: applyfromwire: " - "destination amount exceeds the curve output for the post-fee WIRE"); - BOOST_REQUIRE_EQUAL(expected, apply(CURVE_OUT + 1)); - BOOST_REQUIRE_EQUAL(expected, apply(900)); - - BOOST_REQUIRE_EQUAL(success(), apply(CURVE_OUT)); - BOOST_REQUIRE_EQUAL(1000 - CURVE_OUT, - find_reserve("SOLANA", "SOL", "PRIMARY")["reserve_chain_amount"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(paywire_pays_real_wire_from_custody, sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1000, 1000)); - BOOST_REQUIRE_EQUAL(1000, wire_balance(RESERVE_ACCOUNT)); - BOOST_REQUIRE_EQUAL(0, wire_balance("alice"_n)); - - // The curve's output for this leg: equal weights reduce to constant product, - // so `token_to_wire(1000, 1000, 100)` = floor(1000*100/1100) = 90, and the - // 0.1% fee floors to 0 at that size, leaving a post-fee 90. `sysio.uwrit` - // passes exactly this (its `swap_quote` returns the post-fee WIRE leg for a - // WIRE destination), and paywire now refuses anything above it (WNS-02). - constexpr int64_t CURVE_OUT = 90; - - // Swap-to-WIRE settlement: source books move + alice is paid REAL WIRE. - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "paywire"_n, mvo() - ("src_chain_code", "ETH") - ("src_token_code", "ETH") - ("src_reserve_code", "PRIMARY") - ("src_amount", 100) - ("recipient", "alice") - ("wire_out", CURVE_OUT) - ("underwriter", "underwriter1"))); - - auto r = find_reserve("ETH", "ETH", "PRIMARY"); - BOOST_REQUIRE_EQUAL(1100, r["reserve_chain_amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(910, r["reserve_wire_amount"].as_uint64()); - - // Custody invariant: `Σ reserve_wire_amount` drops by the payout immediately, but the - // WIRE itself does NOT leave -- it moves sideways into `Σ wireclaims.balance`, still in this - // contract's custody, until the recipient pulls it. paywire credits rather than transfers - // because it settles inside the never-throw consensus dispatch chain. - BOOST_REQUIRE_EQUAL(90u, wire_claimable("alice"_n)); - BOOST_REQUIRE_EQUAL(1000, wire_balance(RESERVE_ACCOUNT)); // unchanged: 910 booked + 90 claimable - BOOST_REQUIRE_EQUAL(0, wire_balance("alice"_n)); - - // The pull completes the settlement and restores the original end state. - BOOST_REQUIRE_EQUAL(success(), claimwire("alice"_n)); - BOOST_REQUIRE_EQUAL(910, wire_balance(RESERVE_ACCOUNT)); - BOOST_REQUIRE_EQUAL(90, wire_balance("alice"_n)); -} FC_LOG_AND_RETHROW() } - -// [P0] WNS-02: paywire must refuse a payout larger than the WIRE its own curve -// produces for `src_amount`. `wire_out` is a caller-supplied parameter, and the -// audited vulnerability was precisely a caller-chosen destination amount being -// paid out verbatim — so the reserve enforces its own floor rather than trusting -// `sysio.uwrit` to have derived it. -// -// This bound also subsumes the older "insufficient source reserve WIRE for -// payout + fee" guard: `w_gross` is capped at the reserve's own WIRE balance by -// `out_given_in`, so `wire_out + fee <= w_gross <= reserve_wire_amount` holds -// by construction once the payout is bounded. That guard stays in the contract -// as defense-in-depth but is no longer constructible through this action. -BOOST_FIXTURE_TEST_CASE(paywire_rejects_payout_above_curve_output, sysio_reserve_tester) { try { - // 1000 token / 100 WIRE: the curve yields floor(100*100/1100) = 9 WIRE for a - // 100-token source leg, so a 200 payout is ~22x the reserve's own price. - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1000, 100)); - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: paywire: payout exceeds the post-fee WIRE the source leg produced"), - push_action(UWRIT_ACCOUNT, "paywire"_n, mvo() - ("src_chain_code", "ETH") - ("src_token_code", "ETH") - ("src_reserve_code", "PRIMARY") - ("src_amount", 100) - ("recipient", "alice") - ("wire_out", 200) - ("underwriter", "underwriter1"))); - - // The curve's own output settles cleanly against the same reserve. - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "paywire"_n, mvo() - ("src_chain_code", "ETH") - ("src_token_code", "ETH") - ("src_reserve_code", "PRIMARY") - ("src_amount", 100) - ("recipient", "alice") - ("wire_out", 9) - ("underwriter", "underwriter1"))); - BOOST_REQUIRE_EQUAL(9u, wire_claimable("alice"_n)); // credited, not pushed - BOOST_REQUIRE_EQUAL(success(), claimwire("alice"_n)); - BOOST_REQUIRE_EQUAL(9, wire_balance("alice"_n)); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(refundwire_returns_escrow, sysio_reserve_tester) { try { - // Seed custody via a bootstrap reserve (the refund itself touches no - // reserve row — it returns in-flight escrow). Zero revert fee: a no-fault - // refund returns the full escrow. - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1000, 1000)); - - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "refundwire"_n, mvo() - ("recipient", "alice") - ("wire_amount", 150) - ("revert_fee_bps", 0))); - - // The refund is CREDITED, not pushed. Until alice pulls it the WIRE stays in this - // contract's custody, so the escrow is owed but has not moved. - BOOST_REQUIRE_EQUAL(150u, wire_claimable("alice"_n)); - BOOST_REQUIRE_EQUAL(0, wire_balance("alice"_n)); - BOOST_REQUIRE_EQUAL(1000, wire_balance(RESERVE_ACCOUNT)); - - // Pulling it produces the original settlement: alice holds the escrow, custody drops by it. - BOOST_REQUIRE_EQUAL(success(), claimwire("alice"_n)); - BOOST_REQUIRE_EQUAL(150, wire_balance("alice"_n)); - BOOST_REQUIRE_EQUAL(850, wire_balance(RESERVE_ACCOUNT)); - BOOST_REQUIRE(get_wireclaim("alice"_n).is_null()); // row consumed by the payout - - auto r = find_reserve("ETH", "ETH", "PRIMARY"); - BOOST_REQUIRE_EQUAL(1000, r["reserve_wire_amount"].as_uint64()); // untouched - - BOOST_REQUIRE(get_rewardbkt().is_null()); // no fee — nothing accrued -} FC_LOG_AND_RETHROW() } - -// The retention deadline has to hold WITHOUT further settlement traffic. `credit_wire_claim` -// sweeps opportunistically, so it only fires while swaps keep arriving; if traffic stops, nothing -// revisits an aged-out row and both the system-funded table and the WIRE it reserves stay -// outstanding forever. `sweepclaims` — inlined every epoch by `sysio.epoch::advance` — is the -// trigger that makes the window real, and `claimwire` refuses an expired row so the deadline means -// "claimable until" rather than "swept eventually". -BOOST_FIXTURE_TEST_CASE(expired_wire_claim_is_swept_without_further_traffic, sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1000, 1000)); - - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "refundwire"_n, mvo() - ("recipient", "alice") - ("wire_amount", 150) - ("revert_fee_bps", 0))); - BOOST_REQUIRE_EQUAL(150u, wire_claimable("alice"_n)); - - const int64_t treasury_before = wire_balance(SYSIO_ACCOUNT); - const int64_t custody_before = wire_balance(RESERVE_ACCOUNT); - - // Age past the one-year window with NO further credits — the opportunistic sweep never runs. - produce_block(); - produce_block(fc::days(366)); - BOOST_REQUIRE_EQUAL(150u, wire_claimable("alice"_n)); // still sitting there - - // The deadline is enforced at the claim, so the forfeit balance cannot be pulled. - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: claimwire: this claim expired and is pending sweep to the treasury"), - claimwire("alice"_n)); - - // A zero budget is a no-op even with an expired row waiting. - BOOST_REQUIRE_EQUAL(success(), sweepclaims(0)); - BOOST_REQUIRE_EQUAL(150u, wire_claimable("alice"_n)); - - // The epoch-driven sweep reclaims it with no settlement traffic involved: the row is gone and - // its WIRE has left custody for the treasury. - BOOST_REQUIRE_EQUAL(success(), sweepclaims(reserve_max_claim_sweep_per_epoch)); - BOOST_REQUIRE(get_wireclaim("alice"_n).is_null()); - BOOST_REQUIRE_EQUAL(0u, wire_claimable("alice"_n)); - BOOST_REQUIRE_EQUAL(treasury_before + 150, wire_balance(SYSIO_ACCOUNT)); - BOOST_REQUIRE_EQUAL(custody_before - 150, wire_balance(RESERVE_ACCOUNT)); - - // Nothing to claim afterwards — the balance is forfeit, not merely deferred. - BOOST_REQUIRE_EQUAL(error("assertion failure with message: no claimable WIRE for this account"), - claimwire("alice"_n)); -} FC_LOG_AND_RETHROW() } - -// Crediting an account whose row already expired must not resurrect the forfeited balance. -// `claimable::credit` upserts, so without settling first the old amount would be added to and its -// stamp refreshed — making a balance `claimwire` has been refusing claimable again, and letting a -// trickle of small credits keep a system-funded row alive forever. The new credit legitimately -// starts a fresh window (an account being credited is not abandoned); only the old balance is -// forfeit. -BOOST_FIXTURE_TEST_CASE(recredit_after_expiry_does_not_revive_the_forfeited_balance, - sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1000, 1000)); - - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "refundwire"_n, mvo() - ("recipient", "alice") - ("wire_amount", 150) - ("revert_fee_bps", 0))); - BOOST_REQUIRE_EQUAL(150u, wire_claimable("alice"_n)); - - // Age past the window WITHOUT sweeping, so the forfeited row is still sitting there. - produce_block(); - produce_block(fc::days(366)); - BOOST_REQUIRE_EQUAL(150u, wire_claimable("alice"_n)); - - const int64_t treasury_before = wire_balance(SYSIO_ACCOUNT); - - // A later refund for the same account credits 40. - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "refundwire"_n, mvo() - ("recipient", "alice") - ("wire_amount", 40) - ("revert_fee_bps", 0))); - - // Only the NEW amount is claimable — the forfeited 150 went to the treasury, it did not - // accumulate into a 190 balance with a fresh window. - BOOST_REQUIRE_EQUAL(40u, wire_claimable("alice"_n)); - BOOST_REQUIRE_EQUAL(treasury_before + 150, wire_balance(SYSIO_ACCOUNT)); - - // And the fresh claim is live, so the recipient is not penalised for the new payout. - BOOST_REQUIRE_EQUAL(success(), claimwire("alice"_n)); - BOOST_REQUIRE_EQUAL(40, wire_balance("alice"_n)); -} FC_LOG_AND_RETHROW() } - -// A LIVE row is untouched by the sweep and still claims normally — the budget walks the -// expiry-ordered index and stops at the first row whose window is open. -BOOST_FIXTURE_TEST_CASE(sweepclaims_leaves_live_rows_alone, sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1000, 1000)); - - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "refundwire"_n, mvo() - ("recipient", "alice") - ("wire_amount", 150) - ("revert_fee_bps", 0))); - - BOOST_REQUIRE_EQUAL(success(), sweepclaims(reserve_max_claim_sweep_per_epoch)); - BOOST_REQUIRE_EQUAL(150u, wire_claimable("alice"_n)); - - BOOST_REQUIRE_EQUAL(success(), claimwire("alice"_n)); - BOOST_REQUIRE_EQUAL(150, wire_balance("alice"_n)); -} FC_LOG_AND_RETHROW() } - -// A nonzero revert fee (caller-fault drain revert) is taken out of the refund and -// routed exactly like a settlement fee, through `route_wire_fee`. Arithmetic here: -// 10% of 150 = 15, and a revert has no winning underwriter, so the whole 15 is the -// rewards pool. This fixture never calls `setconfig`, so `fee_emissions_share_bps` -// is the default 0 and all 15 accrues to `rewards_bucket` — custody-internal, with -// nothing transferred to the emissions treasury. -BOOST_FIXTURE_TEST_CASE(refundwire_routes_revert_fee, sysio_reserve_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - regreserve("ETH", "ETH", "PRIMARY", 1000, 1000)); - - BOOST_REQUIRE_EQUAL(success(), push_action(UWRIT_ACCOUNT, "refundwire"_n, mvo() - ("recipient", "alice") - ("wire_amount", 150) - ("revert_fee_bps", 1000))); // 10% - - // Credited net of the fee, and the refund waits to be pulled. With the default zero - // emissions dial the whole fee stays in custody as rewards, so nothing leaves at all. - BOOST_REQUIRE_EQUAL(135u, wire_claimable("alice"_n)); // 150 - 15 fee - BOOST_REQUIRE_EQUAL(0, wire_balance("alice"_n)); - BOOST_REQUIRE_EQUAL(1000, wire_balance(RESERVE_ACCOUNT)); // nothing transferred out yet - - BOOST_REQUIRE_EQUAL(success(), claimwire("alice"_n)); - BOOST_REQUIRE_EQUAL(135, wire_balance("alice"_n)); // 150 - 15 fee - BOOST_REQUIRE_EQUAL(865, wire_balance(RESERVE_ACCOUNT)); // 1000 - 135; the fee stays in custody - - // The whole fee, per the header: no underwriter share to carve out, and a - // zero emissions dial leaves the pool intact. A configured dial would divert - // its share to the emissions treasury instead. - auto bkt = get_rewardbkt(); - BOOST_REQUIRE(!bkt.is_null()); - BOOST_REQUIRE_EQUAL(15u, bkt["balance"].as_uint64()); - BOOST_REQUIRE_EQUAL(15u, bkt["lifetime_accrued"].as_uint64()); - BOOST_REQUIRE(get_uwfees(UNDERWRITER_ACCOUNT).is_null()); // nobody underwrote it - - auto r = find_reserve("ETH", "ETH", "PRIMARY"); - BOOST_REQUIRE_EQUAL(1000, r["reserve_wire_amount"].as_uint64()); // untouched -} FC_LOG_AND_RETHROW() } - -// ── swapquote ── - -BOOST_FIXTURE_TEST_CASE(swapquote_returns_zero_when_reserve_missing, sysio_reserve_tester) { try { - // No regreserve — the row simply doesn't exist. - auto r = find_reserve("ETH", "ETH", "PRIMARY"); - BOOST_REQUIRE(r.is_null()); -} FC_LOG_AND_RETHROW() } - -BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.uwrit_tests.cpp b/contracts/tests/sysio.uwrit_tests.cpp deleted file mode 100644 index 7dcb88f57a..0000000000 --- a/contracts/tests/sysio.uwrit_tests.cpp +++ /dev/null @@ -1,471 +0,0 @@ -#include -#include -#include -#include - -#include -#include -#include - -#include "contracts.hpp" - -using namespace sysio::testing; -using namespace sysio; -using namespace sysio::chain; -using namespace sysio::opp::types; -using namespace fc; -using namespace fc::crypto; - -using mvo = fc::mutable_variant_object; - -namespace { - -} // anonymous namespace - -class sysio_uwrit_tester : public tester { -public: - static constexpr auto UWRIT_ACCOUNT = "sysio.uwrit"_n; - static constexpr auto MSGCH_ACCOUNT = "sysio.msgch"_n; - static constexpr auto OPREG_ACCOUNT = "sysio.opreg"_n; - static constexpr auto CHALG_ACCOUNT = "sysio.chalg"_n; - - sysio_uwrit_tester() { - produce_blocks(2); - - create_accounts({ - UWRIT_ACCOUNT, MSGCH_ACCOUNT, OPREG_ACCOUNT, CHALG_ACCOUNT, - "sysio.epoch"_n, "sysio.chains"_n, "sysio.reserv"_n, - "uwrit.a"_n, "uwrit.b"_n - }); - produce_blocks(2); - - set_code(UWRIT_ACCOUNT, contracts::uwrit_wasm()); - set_abi(UWRIT_ACCOUNT, contracts::uwrit_abi().data()); - set_privileged(UWRIT_ACCOUNT); - - produce_blocks(); - - const auto* accnt = control->find_account_metadata(UWRIT_ACCOUNT); - BOOST_REQUIRE(accnt != nullptr); - abi_def abi; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt->abi, abi), true); - abi_ser.set_abi(std::move(abi), abi_serializer::create_yield_function(abi_serializer_max_time)); - } - - action_result push_uwrit_action(name signer, name action_name, const variant_object& data) { - string action_type_name = abi_ser.get_action_type(action_name); - - action act; - act.account = UWRIT_ACCOUNT; - act.name = action_name; - act.data = abi_ser.variant_to_binary( - action_type_name, data, - abi_serializer::create_yield_function(abi_serializer_max_time) - ); - act.authorization = vector{{signer, config::active_name}}; - - signed_transaction trx; - trx.actions.emplace_back(std::move(act)); - set_transaction_headers(trx); - trx.sign(get_private_key(signer, "active"), control->get_chain_id()); - try { - push_transaction(trx); - return success(); - } catch (const fc::exception& ex) { - return error(ex.top_message()); - } - } - - action_result setconfig(uint32_t fee_bps = 10, - uint64_t collateral_lock_duration_ms = 43'200'000, - uint64_t min_fromwire_amount = 5'000'000'000ull, - uint32_t fromwire_revert_fee_bps = 10, - uint32_t uwreq_pending_timeout_epochs = 10, - uint32_t uwreq_retention_epochs = 10) { - return push_uwrit_action(UWRIT_ACCOUNT, "setconfig"_n, mvo() - ("fee_bps", fee_bps) - ("collateral_lock_duration_ms", collateral_lock_duration_ms) - ("min_fromwire_amount", min_fromwire_amount) - ("fromwire_revert_fee_bps", fromwire_revert_fee_bps) - ("uwreq_pending_timeout_epochs", uwreq_pending_timeout_epochs) - ("uwreq_retention_epochs", uwreq_retention_epochs) - ); - } - - /// Read uwconfig singleton row. - fc::variant get_uwconfig() { - auto data = get_row_by_account(UWRIT_ACCOUNT, UWRIT_ACCOUNT, "uwconfig"_n, "uwconfig"_n); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( - "uw_config", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - } - - /// Read a uwreq by id. - fc::variant get_uwreq(uint64_t id) { - auto data = get_row_by_id(UWRIT_ACCOUNT, UWRIT_ACCOUNT, "uwreqs"_n, id); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( - "uw_request_t", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - } - - abi_serializer abi_ser; -}; - -// ---- Tests ---- - -BOOST_AUTO_TEST_SUITE(sysio_uwrit_tests) - -BOOST_FIXTURE_TEST_CASE(setconfig_basic, sysio_uwrit_tester) { try { - BOOST_REQUIRE_EQUAL(success(), setconfig(25)); - - auto cfg = get_uwconfig(); - BOOST_REQUIRE_EQUAL(25, cfg["fee_bps"].as_uint64()); - BOOST_REQUIRE_EQUAL(43'200'000u, cfg["collateral_lock_duration_ms"].as_uint64()); - BOOST_REQUIRE_EQUAL(5'000'000'000u, cfg["min_fromwire_amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(10, cfg["fromwire_revert_fee_bps"].as_uint64()); - BOOST_REQUIRE_EQUAL(10, cfg["uwreq_pending_timeout_epochs"].as_uint64()); - BOOST_REQUIRE_EQUAL(10, cfg["uwreq_retention_epochs"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(setconfig_writes_custom_lock_duration, sysio_uwrit_tester) { try { - // Test clusters shorten the 12h challenge window via setconfig — e.g. - // two minutes here. - BOOST_REQUIRE_EQUAL(success(), - setconfig(/*fee_bps*/10, /*lock_ms*/120'000)); - auto cfg = get_uwconfig(); - BOOST_REQUIRE_EQUAL(120'000u, cfg["collateral_lock_duration_ms"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(setconfig_rejects_excessive_fee, sysio_uwrit_tester) { try { - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: fee_bps must be below 10000 (100%): a 100% fee zeroes the post-fee WIRE leg"), - setconfig(10001) - ); -} FC_LOG_AND_RETHROW() } - -// SEC-26 / WSA-042: the exact 100% boundary must be rejected. It was previously -// accepted (`fee_bps <= 10000`), which zeroed the post-fee WIRE leg and let a -// swap debit destination reserve liquidity while crediting zero WIRE. -BOOST_FIXTURE_TEST_CASE(setconfig_rejects_full_fee, sysio_uwrit_tester) { try { - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: fee_bps must be below 10000 (100%): a 100% fee zeroes the post-fee WIRE leg"), - setconfig(10000) - ); -} FC_LOG_AND_RETHROW() } - -// The largest sub-100% fee (MAX_FEE_BPS) is accepted and leaves a positive -// post-fee WIRE leg for every positive input. -BOOST_FIXTURE_TEST_CASE(setconfig_accepts_max_fee, sysio_uwrit_tester) { try { - BOOST_REQUIRE_EQUAL(success(), setconfig(9999)); - BOOST_REQUIRE_EQUAL(9999, get_uwconfig()["fee_bps"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -// The swap-from-WIRE floor is the queue-slot price: a zero floor would reopen -// free dust rows in the fwqueue, so setconfig rejects it. -BOOST_FIXTURE_TEST_CASE(setconfig_rejects_zero_fromwire_minimum, sysio_uwrit_tester) { try { - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: min_fromwire_amount must be positive"), - setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/0) - ); -} FC_LOG_AND_RETHROW() } - -// A 100% revert fee would zero the post-fee refund transfer inside the -// never-throw drainfwq drain, so the boundary is rejected exactly like -// fee_bps. -BOOST_FIXTURE_TEST_CASE(setconfig_rejects_full_revert_fee, sysio_uwrit_tester) { try { - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: fromwire_revert_fee_bps must be below 10000 (100%): " - "a 100% revert fee zeroes the refund"), - setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, - /*revert_fee_bps*/10000) - ); -} FC_LOG_AND_RETHROW() } - -// The largest sub-100% revert fee and a custom floor round-trip through the -// config singleton. -BOOST_FIXTURE_TEST_CASE(setconfig_accepts_max_revert_fee_and_custom_floor, sysio_uwrit_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/1, /*revert_fee_bps*/9999)); - auto cfg = get_uwconfig(); - BOOST_REQUIRE_EQUAL(1u, cfg["min_fromwire_amount"].as_uint64()); - BOOST_REQUIRE_EQUAL(9999, cfg["fromwire_revert_fee_bps"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -// ── SEC-129 / WSA-223: UWREQ lifecycle knobs ── -// -// A zero pending timeout would expire every uwreq the epoch it is created; a -// zero retention would erase terminal rows before the audit window they exist -// for; a near-UINT32_MAX value would wrap the `current_epoch + knob` deadline -// stamp to a tiny epoch index (instant expiry). setconfig rejects all three. - -BOOST_FIXTURE_TEST_CASE(setconfig_rejects_zero_pending_timeout, sysio_uwrit_tester) { try { - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: uwreq_pending_timeout_epochs must be positive"), - setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, - /*revert_fee_bps*/10, /*pending_timeout*/0) - ); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(setconfig_rejects_zero_retention, sysio_uwrit_tester) { try { - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: uwreq_retention_epochs must be positive"), - setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, - /*revert_fee_bps*/10, /*pending_timeout*/10, /*retention*/0) - ); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(setconfig_rejects_lifecycle_epochs_over_ceiling, sysio_uwrit_tester) { try { - // The ceiling itself is accepted for both knobs; one epoch beyond it is - // rejected before the value could wrap a deadline stamp. - constexpr uint32_t ceiling = 1'000'000; - BOOST_REQUIRE_EQUAL(success(), - setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, - /*revert_fee_bps*/10, /*pending_timeout*/ceiling, /*retention*/ceiling)); - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: uwreq_pending_timeout_epochs exceeds the lifecycle ceiling"), - setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, - /*revert_fee_bps*/10, /*pending_timeout*/ceiling + 1, /*retention*/ceiling) - ); - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: uwreq_retention_epochs exceeds the lifecycle ceiling"), - setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, - /*revert_fee_bps*/10, /*pending_timeout*/ceiling, /*retention*/ceiling + 1) - ); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(setconfig_roundtrips_lifecycle_epochs, sysio_uwrit_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - setconfig(/*fee_bps*/10, /*lock_ms*/43'200'000, /*min_fromwire*/5'000'000'000ull, - /*revert_fee_bps*/10, /*pending_timeout*/3, /*retention*/7)); - auto cfg = get_uwconfig(); - BOOST_REQUIRE_EQUAL(3, cfg["uwreq_pending_timeout_epochs"].as_uint64()); - BOOST_REQUIRE_EQUAL(7, cfg["uwreq_retention_epochs"].as_uint64()); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(setconfig_rejects_zero_lock_duration, sysio_uwrit_tester) { try { - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: collateral_lock_duration_ms must be positive"), - setconfig(/*fee_bps*/10, /*lock_ms*/0) - ); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(setconfig_rejects_excess_lock_duration, sysio_uwrit_tester) { try { - // The one-year ceiling is accepted; one millisecond beyond it is rejected - // before the duration could wrap `now_ms + collateral_lock_duration_ms` and - // release the lock the instant it is placed. - constexpr uint64_t one_year_ms = 365ull * 24 * 60 * 60 * 1000; - BOOST_REQUIRE_EQUAL(success(), setconfig(/*fee_bps*/10, one_year_ms)); - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: collateral_lock_duration_ms exceeds the one-year ceiling"), - setconfig(/*fee_bps*/10, one_year_ms + 1) - ); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(createuwreq_requires_msgch_auth, sysio_uwrit_tester) { try { - // createuwreq must be invoked by sysio.msgch (inline action). A direct - // call from another account (uwrit.a here) is rejected. - BOOST_REQUIRE(push_uwrit_action("uwrit.a"_n, "createuwreq"_n, mvo() - ("attestation_id", 1) - ("type", sysio::opp::types::AttestationType::ATTESTATION_TYPE_SWAP_REQUEST) - ("chain_code", 1) - ("data", std::vector{}) - ).find("missing authority of sysio.msgch") != std::string::npos); -} FC_LOG_AND_RETHROW() } - -// ── chklocks — THE lock-release path (12h wall-clock challenge window) ── -// -// Locks are never released by delivery (there is no SWAP_REMIT ack); the -// epoch-advance sweep is the only releaser. `release`/`expirelock` were -// retired with the dead reflected-remit dispatch. Budget-bounded like its -// sibling epoch-inline sweeps; the bound's BEHAVIOR (one lock per unit of -// budget, backlog draining on the next sweep) needs real locks and lives in -// sysio.dispatch_tests.cpp. - -BOOST_FIXTURE_TEST_CASE(chklocks_requires_epoch_or_self_auth, sysio_uwrit_tester) { try { - BOOST_REQUIRE(push_uwrit_action("uwrit.a"_n, "chklocks"_n, mvo() - ("max_rows", 10) - ).find("chklocks requires sysio.epoch or sysio.uwrit authority") != std::string::npos); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(chklocks_noop_with_no_locks, sysio_uwrit_tester) { try { - // Steady-state: nothing expired, nothing to sweep — must be a clean - // no-op (it runs inside every epoch advance). - BOOST_REQUIRE_EQUAL(success(), - push_uwrit_action(UWRIT_ACCOUNT, "chklocks"_n, mvo()("max_rows", 10))); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(chklocks_zero_budget_is_noop, sysio_uwrit_tester) { try { - // The budget gate short-circuits before any index walk — mirrors - // pruneuwreqs / drainfwq. - BOOST_REQUIRE_EQUAL(success(), - push_uwrit_action(UWRIT_ACCOUNT, "chklocks"_n, mvo()("max_rows", 0))); -} FC_LOG_AND_RETHROW() } - -// ── pruneuwreqs — bounded UWREQ lifecycle sweep (SEC-129 / WSA-223) ── -// -// The enforcement trigger for `uw_request_t.expires_at_epoch`. Auth mirrors -// its sibling epoch-inline sweeps; the lifecycle behavior itself (PENDING -// timeout, terminal retention erase, per-epoch budget) needs real epoch -// movement and lives in sysio.dispatch_tests.cpp. - -BOOST_FIXTURE_TEST_CASE(pruneuwreqs_requires_epoch_or_self_auth, sysio_uwrit_tester) { try { - BOOST_REQUIRE(push_uwrit_action("uwrit.a"_n, "pruneuwreqs"_n, mvo() - ("max_rows", 10) - ).find("pruneuwreqs requires sysio.epoch or sysio.uwrit authority") != std::string::npos); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(pruneuwreqs_noop_with_empty_table, sysio_uwrit_tester) { try { - // Steady-state: nothing due, nothing to sweep — must be a clean no-op - // (it runs inside every epoch advance). - BOOST_REQUIRE_EQUAL(success(), - push_uwrit_action(UWRIT_ACCOUNT, "pruneuwreqs"_n, mvo()("max_rows", 10))); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(pruneuwreqs_zero_budget_is_noop, sysio_uwrit_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - push_uwrit_action(UWRIT_ACCOUNT, "pruneuwreqs"_n, mvo()("max_rows", 0))); -} FC_LOG_AND_RETHROW() } - -// ── drainfwq — epoch-boundary swap-from-WIRE queue drain ── - -BOOST_FIXTURE_TEST_CASE(drainfwq_requires_epoch_or_self_auth, sysio_uwrit_tester) { try { - BOOST_REQUIRE(push_uwrit_action("uwrit.a"_n, "drainfwq"_n, mvo() - ).find("drainfwq requires sysio.epoch or sysio.uwrit authority") != std::string::npos); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(drainfwq_noop_with_empty_queue, sysio_uwrit_tester) { try { - BOOST_REQUIRE_EQUAL(success(), - push_uwrit_action(UWRIT_ACCOUNT, "drainfwq"_n, mvo())); -} FC_LOG_AND_RETHROW() } - -// ── swapfromwire — queued depot-originated swap entry ── - -BOOST_FIXTURE_TEST_CASE(swapfromwire_rejects_zero_wire_amount, sysio_uwrit_tester) { try { - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: swapfromwire: wire_amount must be positive"), - push_uwrit_action("uwrit.a"_n, "swapfromwire"_n, mvo() - ("user", "uwrit.a") - ("wire_amount", 0) - ("dst_chain_code", "SOLANA") - ("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("target_amount", 100) - ("target_tolerance_bps", 50) - ("recipient_kind", sysio::opp::types::ChainKind::CHAIN_KIND_SVM) - ("recipient_addr", std::vector(32, '\x01')) - ) - ); -} FC_LOG_AND_RETHROW() } - -// One atom below the configured floor is rejected before any chain/reserve -// validation — the floor is the queue-slot price (default 5 WIRE). -BOOST_FIXTURE_TEST_CASE(swapfromwire_rejects_below_minimum, sysio_uwrit_tester) { try { - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: swapfromwire: wire_amount below the configured minimum"), - push_uwrit_action("uwrit.a"_n, "swapfromwire"_n, mvo() - ("user", "uwrit.a") - ("wire_amount", 4'999'999'999ull) - ("dst_chain_code", "SOLANA") - ("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("target_amount", 100) - ("target_tolerance_bps", 50) - ("recipient_kind", sysio::opp::types::ChainKind::CHAIN_KIND_SVM) - ("recipient_addr", std::vector(32, '\x01')) - ) - ); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(swapfromwire_rejects_unregistered_target_chain, sysio_uwrit_tester) { try { - // No sysio.chains rows exist in this fixture — the target chain cannot - // be registered+active, so the escrow never happens. wire_amount sits at - // the default floor so the chain check (not the floor) is what fires. - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: swapfromwire: target chain not " - "registered or not active"), - push_uwrit_action("uwrit.a"_n, "swapfromwire"_n, mvo() - ("user", "uwrit.a") - ("wire_amount", 5'000'000'000ull) - ("dst_chain_code", "SOLANA") - ("dst_token_code", "SOL") - ("dst_reserve_code", "PRIMARY") - ("target_amount", 100) - ("target_tolerance_bps", 50) - ("recipient_kind", sysio::opp::types::ChainKind::CHAIN_KIND_SVM) - ("recipient_addr", std::vector(32, '\x01')) - ) - ); -} FC_LOG_AND_RETHROW() } - -// ── rcrdcommit (Task 3: per-leg COMMIT arrival recorder) ── - -BOOST_FIXTURE_TEST_CASE(rcrdcommit_requires_msgch_auth, sysio_uwrit_tester) { try { - // rcrdcommit is invoked inline from sysio.msgch on UNDERWRITE_INTENT_COMMIT - // dispatch. the signature carries (from_chain_code, from_token_code, - // reserve_code) slug_name triples in place of the old enum pair. - BOOST_REQUIRE(push_uwrit_action("uwrit.a"_n, "rcrdcommit"_n, mvo() - ("uwreq_id", 1) - ("underwriter", "uwrit.a") - ("chain_code", 1) - ("from_chain_code", "ETH") - ("from_token_code", "ETH") - ("reserve_code", "PRIMARY") - ("uic_bytes", std::vector{}) - ).find("missing authority of sysio.msgch") != std::string::npos); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(rcrdcommit_rejects_unknown_uwreq, sysio_uwrit_tester) { try { - // OPP handlers MUST NEVER throw (feedback_opp_handlers_never_throw.md - // — a `check()` in dispatch halts consensus). The previous error-based - // assertion is gone; the action logs + skips on unknown uwreq and - // returns success. The test now pins THAT invariant. - BOOST_REQUIRE_EQUAL(success(), - push_uwrit_action(MSGCH_ACCOUNT, "rcrdcommit"_n, mvo() - ("uwreq_id", 42) - ("underwriter", "uwrit.a") - ("chain_code", 1) - ("from_chain_code", "ETH") - ("from_token_code", "ETH") - ("reserve_code", "PRIMARY") - ("uic_bytes", std::vector{}) - ) - ); -} FC_LOG_AND_RETHROW() } - -// ── sumlocks (Task 3: read-only per-(underwriter, chain, token) lock total) ── - -BOOST_FIXTURE_TEST_CASE(sumlocks_zero_for_unbonded_underwriter, sysio_uwrit_tester) { try { - // sumlocks signature: slug_name pair (chain_code, token_code). - BOOST_REQUIRE_EQUAL(success(), - push_uwrit_action("uwrit.a"_n, "sumlocks"_n, mvo() - ("underwriter", "uwrit.a") - ("chain_code", "ETH") - ("token_code", "ETH") - ) - ); -} FC_LOG_AND_RETHROW() } - -// ── B6: same-chain swap routing on rcrdcommit ────────────────────────── -// -// A swap on a single outpost (e.g. ERC20 → ETH-native, both on ETH) has -// src_chain == dst_chain. The depot routes the source-leg vs dest-leg of -// the COMMIT into commit_entry's source_uic_bytes / dest_uic_bytes slots -// based on (from_chain, from_token_kind) matching the uwreq's -// (src_chain, src_token_kind) vs (dst_chain, dst_token_kind). Without -// the from_token_kind discriminator, same-chain swaps would route both -// legs to the source slot. This case verifies the dispatch still -// auth-checks correctly when the two chains coincide. -BOOST_FIXTURE_TEST_CASE(rcrdcommit_same_chain_swap_auth, sysio_uwrit_tester) { try { - // Slug_name triple disambiguates same-chain swap legs. - BOOST_REQUIRE(push_uwrit_action("uwrit.a"_n, "rcrdcommit"_n, mvo() - ("uwreq_id", 7) - ("underwriter", "uwrit.a") - ("chain_code", 1) - ("from_chain_code", "ETH") // src == dst chain - ("from_token_code", "USDC") // distinguishes legs - ("reserve_code", "PRIMARY") - ("uic_bytes", std::vector{}) - ).find("missing authority of sysio.msgch") != std::string::npos); -} FC_LOG_AND_RETHROW() } - -BOOST_AUTO_TEST_SUITE_END() diff --git a/docs/contract-upgrade-order.md b/docs/contract-upgrade-order.md index 871442595c..e413c9cea8 100644 --- a/docs/contract-upgrade-order.md +++ b/docs/contract-upgrade-order.md @@ -1,7 +1,7 @@ # System-contract upgrade order The system contracts are not independent deployables. `sysio.epoch::advance` -inlines actions into six other contracts, and the emissions gate reads two more +inlines actions into four other contracts, and the emissions gate reads two contracts' tables, so a release's contract builds are only correct **as a set**. Upgrading them one at a time creates windows in which a new caller meets an old callee. @@ -16,22 +16,19 @@ Contracts outside the coupled set stage separately, in any order. ## The deployment recipe "Just `setcode` everything in one transaction" fails twice over — once on which -action deploys each account, once on transaction size — and both bite this -release specifically. +action deploys each account, once on transaction size. **1. The deploy action depends on WHICH account holds the contract — the transaction mixes both.** -*Separate system accounts* (`sysio.epoch`, `sysio.reserv`, `sysio.opreg`, -`sysio.uwrit`, …) deploy through **`sysio.roa::setsyscode(account, vmtype, -vmversion, code)` / `sysio.roa::setsysabi(account, abi)`**. Those set the +*Separate system accounts* (`sysio.epoch`, `sysio.opreg`, `sysio.msgch`, +`sysio.chalg`, `sysio.synd`, …) deploy through **`sysio.roa::setsyscode(account, +vmtype, vmversion, code)` / `sysio.roa::setsysabi(account, abi)`**. Those set the code/abi AND reconcile the account's gifted RAM to its exact new usage out of sysio's pool via `giftram`, measured after the write (re-callable: a smaller re-deploy reclaims). A raw `setcode` here skips that reconciliation and leaves -the account paying for the new size out of a finite quota it does not have — -not theoretical for this release, where `sysio.reserv.wasm` grows **6,040 -bytes** and `sysio.opreg.wasm` grows **2,447 bytes** against a system account's -small creation allowance. +the account paying for any growth out of a finite quota it does not have — a +system account only has a small creation allowance. *The root `sysio` account* — which holds `sysio.system` and **is the RAM pool itself** — deploys with the **native `setcode` / `setabi`**. `giftram` cannot @@ -45,37 +42,33 @@ lower than the actual quota, and it fails silently: sysio's limit is finite, so production bootstrap deploys `system` on `sysio` natively (the harness encodes the same split as `ContractSteps.DeployMode.raw` for bios/system/roa). -So the atomic transaction for this release's trio carries **native -`setcode`/`setabi` for `sysio`** plus **ROA `setsyscode`/`setsysabi` for -`sysio.epoch` and `sysio.reserv`**. +So an atomic transaction that includes `sysio.system` carries **native +`setcode`/`setabi` for `sysio`** plus **ROA `setsyscode`/`setsysabi` for every +other account in the set**. -**2. The whole release does not fit in one transaction.** The five changed -WASMs total **579,408 bytes**, already past the default -`max_transaction_net_usage` of **524,288** (`config::default_max_block_net_usage -/ 2`) before ABIs or action wrapping. `sysio.msig::propose` receives the -complete inner transaction before it chunks storage, so proposing through msig -does not dodge that input NET. - -What fits is the **compatibility-coupled trio** — `sysio.reserv`, -`sysio.epoch`, `sysio.system` — the three the edges below actually couple: +**2. A large set does not fit in one transaction.** The default +`max_transaction_net_usage` is **524,288** bytes (`config::default_max_block_net_usage +/ 2`), and `sysio.msig::propose` receives the complete inner transaction before it +chunks storage, so proposing through msig does not dodge that input NET. The +contracts on `advance`'s path are not small (sizes of the builds in this tree): | | code | abi | total | |---|---|---|---| -| `sysio.epoch` | 75,579 | 6,758 | 82,337 | -| `sysio.system` | 174,138 | 61,916 | 236,054 | -| `sysio.reserv` | 84,026 | 24,647 | 108,673 | -| **trio** | **333,743** | **93,321** | **427,064** | - -That leaves ~97 KB against the 524,288 ceiling — enough, but not by so much -that it can be assumed. **Preflight the packed size of the actual proposal** -rather than trusting this table, which is a snapshot of one release. - -`sysio.opreg` and `sysio.uwrit` are uncoupled (they gain no action another -contract inlines, and no other contract reads their new tables), so they stage -in their own transaction, before or after the trio. - -When a future release's coupled set does NOT fit, the options are: split off -whatever is genuinely uncoupled and stage it, or raise +| `sysio.epoch` | 95,314 | 6,758 | 102,072 | +| `sysio.system` | 277,387 | 66,071 | 343,458 | +| `sysio.opreg` | 115,210 | 27,979 | 143,189 | +| `sysio.msgch` | 177,916 | 21,421 | 199,337 | +| `sysio.chalg` | 32,009 | 6,797 | 38,806 | + +All five together exceed the ceiling, so a release that changes every one of +them cannot deploy them atomically. Put in the atomic set only the contracts a +release's changes actually couple (see [The cross-contract +edges](#the-cross-contract-edges)), and **preflight the packed size of the actual +proposal** rather than trusting this table, which is a snapshot of one build. + +When a release's coupled set does NOT fit, the options are: split off whatever is +genuinely uncoupled and stage it (see [Staged +rollout](#staged-rollout-when-one-transaction-is-not-possible)), or raise `max_transaction_net_usage` via `setparams` first — as a **tested** path, with the raise proposed and confirmed before the deploy proposal, never assumed to work on the day. @@ -121,17 +114,18 @@ Everything `sysio.epoch::advance` inlines, directly: | Callee | Actions | |---|---| -| `sysio.reserv` | `sweepclaims` | -| `sysio.uwrit` | `chklocks`, `pruneuwreqs`, `drainfwq` | | `sysio.opreg` | `recorddel`, `termcheck`, `flushwtdw` | | `sysio.chalg` | `slashop` | | `sysio.msgch` | `queueout`, `buildenv` | | `sysio` | `accrueepoch`, `rcrdbatch`, `payepoch` | -Those callees inline further (`drainfwq` → `sysio.reserv::refundwire`, -`termcheck` → the `sysio.opreg` remit path, `payepoch` → `sysio.token::transfer`), -so the transitive subtree — not just the table above — sits inside `advance`'s -abort surface. +Those callees inline further (`slashop` → `sysio.opreg::slash`, `termcheck` → the +`sysio.opreg` termination path that credits `remitclaims`, `payepoch` → +`sysio.token::transfer` for the pushed capex and governance buckets), so the +transitive subtree — not just the table above — sits inside `advance`'s abort +surface. `advance` itself is inlined by `sysio.msgch::chkcons` (and by +`sysio.msgch::bootstrap` at epoch 0), so `sysio.msgch` is both a caller and a +callee of `sysio.epoch`. Independently of inlines, the emissions readiness gate in `sysio.epoch` **reads** `sysio.system`'s `emitcfg`, `t5state` and `payclaimtot`, and `sysio.token`'s @@ -145,7 +139,17 @@ WIRE-352 adds separate inline edges whose failure behavior depends on the caller | `sysio.roa::nodeownreg` | `sysio.authex::recordlink` | Deploy `sysio.roa` and `sysio.authex` as the same compatibility-coupled set. | WIRE-352 adds the required `native_address` field. An old caller underflows the new action decoder; the inverse pairing is also unsupported. Node-owner delivery can abort. | | `sysio.authex::createlink` | `sysio.dclaim::linkswept` | Deploy `sysio.dclaim` before the first user-created external-key link. | A missing or non-privileged callee aborts before link insertion. Any inline failure rolls the transaction back, so the user can submit a fresh retry. | | `sysio.authex::recordlink` | `sysio.dclaim::linkswept` | Prefer deploying `sysio.dclaim` before trusted node-owner dispatch begins. | A missing or non-privileged callee skips the sweep but preserves the trusted link; an identical operator-authorized `recordlink` can retry it after bootstrap. | -| `sysio.authex::createlink` | `sysio.liq::linkswept` | None. Nothing is parked before `sysio.liq` is deployed, and it deploys privileged through `setsyscode`. | A missing or non-privileged callee skips the sweep and keeps the link; the permissionless `sysio.liq::sweep` delivers the parked shadow later. | +| `sysio.msgch` inbound envelope dispatch | `sysio.synd::onsynd`, `sysio.synd::onyield`, `sysio.synd::closeenv` | Deploy `sysio.msgch`, `sysio.synd`, `sysio.liq`, `sysio.bond` and `sysio.authex` as one compatibility-coupled set, and deploy `sysio.synd` before any liq token is activated. | The actions run inside the consensus transaction that accepts the envelope. A missing `sysio.synd`, or one without these actions, asserts in its dispatcher and aborts that transaction, so no envelope of the outpost is accepted until the set is whole. | +| `sysio.synd` intake and queue | `sysio.liq::mint`, `sysio.liq::burn`, `sysio.liq::mintyield` | The same set. | `sysio.synd` verifies every condition `mint` checks before sending it, so the current `sysio.liq` never refuses it. A `sysio.liq` without these actions, or one that does not accept `sysio.synd` as their only signer, aborts the intake and with it the inbound envelope. | +| `sysio.authex::createlink` | `sysio.synd::linkswept` | None. Nothing is parked before `sysio.synd` is deployed, and it deploys privileged through `setsyscode`. | A missing or non-privileged callee skips the sweep and keeps the link; the permissionless `sysio.synd::sweep` delivers the parked shadow later. The delivery's `sysio.liq::transfer` notifies the linking account, so a contract account whose transfer handler throws aborts its own `createlink` and cannot link while shadow is parked for its key. | +| `sysio.synd::linkswept`, `sysio.synd::sweep` | `sysio.liq::transfer`, `sysio.liq::creditowed` | Deploy `sysio.liq` before or with `sysio.synd`. | A `sysio.liq` without `creditowed` aborts the delivery: the user's `createlink` then fails as a whole and can be retried once the set is whole. | +| `sysio.synd` queue step (`closeenv`, `crank`) | `sysio.bond::request`, `sysio.bond::claim`, `sysio.liq::transfer`, `sysio.liq::mintyield`, `sysio.liq::burn`, `sysio.liq::creditowed` | Deploy `sysio.bond` before or with `sysio.synd`. | With no code on `sysio.bond` the step leaves each envelope WAITING and prints why; a later step requests it. `sysio.synd` checks every condition `request` checks before sending it, so the current `sysio.bond` never aborts the envelope path, but a `sysio.bond` without that action would abort `closeenv` and with it the inbound envelope. The current `sysio.bond` bills the request and escrow rows of a privileged issuer to the `sysio` RAM pool; an older build would bill them to `sysio.synd`, which has no RAM quota of its own, and abort `closeenv` the same way. | +| `sysio.synd` queue step, an envelope ruled INVALID or VALID after a challenge | `sysio.bond::claim` | Deploy `sysio.bond` before or with `sysio.synd`. | The step reads the `requests` and `escrows` rows first and sends `claim` only when `sysio.bond` records a forfeit or an award for `sysio.synd`, so the current `sysio.bond` never refuses it with `nothing to claim`. A `sysio.bond` whose rows or claim differ would abort `closeenv` and with it the inbound envelope; the two are one compatibility-coupled set. | +| `sysio.synd::challenge` | `sysio.liq::transfer`, `sysio.bond::hold` | Deploy `sysio.bond` before or with `sysio.synd`. | A signed action: a missing or refusing callee fails only the challenge, which the challenger can retry. | +| Andon custody readers: swap, liq, bond, synd | Three-field `cord` row | Deploy all five from the same revision on a fresh deployment; configure native pull/clear permissions. | Existing cord rows use an incompatible layout; no in-place migration is supplied. | +| Syndication custody shortfall | Andon `pull(reason)` with `sysio.andon@active` | Deploy Andon before syndication carries traffic. Privileged syndication needs no custom pull permission. | Missing account/permission or incompatible code fails the transaction atomically. | +| `sysio.synd` reading its own `mismatch` rows | its own table: `mismatch` is new | Deploy this `sysio.synd` fresh, as the row below requires. | Rows accumulate with no prune; nothing else reads them. | +| Syndication envelope and bucket rows | Current ABI layouts | Deploy fresh without rows from earlier layouts. | Buckets now store level and last epoch only; no frozen-epoch marker or migration is included. | Production deployment through `sysio.roa::setsyscode` privileges `sysio.dclaim` as part of the deploy, so there is no separate privilege step. The durable @@ -190,8 +194,7 @@ mixed or mid-period deployment. `rcrdbatch` prunes the exact oldest retained roster, and `payepoch` removes at most 20 history rows per payment, so even a malformed gapped table cannot turn cleanup into an unbounded `advance`. If `payepoch` sees missing, stale, non-contiguous, or over-cap history, it -retains that period's batch-emission slice in the treasury, leaves the swap-fee -bucket in `sysio.reserv` for the next complete period, records the retained +retains that period's batch-emission slice in the treasury, records the retained emission and incomplete-history status in `epochlog`, and drains stale history monotonically before resuming batch payouts. Producer, capex, and governance processing still completes. The runtime also shortens a legacy cadence when @@ -225,28 +228,25 @@ state that aborts every advance. ## Staged rollout (when one transaction is not possible) -For the SEC-150 claimable-payout release the order is: - -``` -sysio.reserv -> sysio.epoch -> sysio.system -``` - -`sysio.opreg` and `sysio.uwrit` are free to land anywhere in the sequence: they -gain no action any other contract inlines (`claimremit` is user-initiated), and -no other contract reads their new tables. +Derive the order from the two rules above for the edges a release actually +changes. For the edges on `advance`'s path today: | Edge | Why | |---|---| -| `sysio.reserv` before `sysio.epoch` | The new `advance` inlines `sysio.reserv::sweepclaims`, guarded only on the account existing. An old `sysio.reserv` build has the account and not the action, so the inline asserts and every advance aborts. | -| `sysio.epoch` before `sysio.system` | The new `payepoch` retains WIRE in `payclaims` and reserves it in `payclaimtot`. The old gate counts that backing as spendable, so a later pay period can double-commit it and leave credited claims underfunded. | +| `sysio.opreg`, `sysio.chalg`, `sysio.msgch` before `sysio.epoch`, when the release adds or changes an action `advance` inlines into them | Call edge: the new `advance` would otherwise reach a callee that cannot dispatch the action, and every advance aborts. | +| `sysio` (`sysio.system`) before `sysio.epoch`, when the release adds or changes `accrueepoch` / `rcrdbatch` / `payepoch` | Call edge — but an action `sysio.system` does not implement is silently ignored rather than asserted, so this skew drops the effect instead of stalling. | +| `sysio.epoch` before `sysio.system`, when `sysio.system` gains state the emissions gate must reserve (as `payclaimtot` reserves unclaimed `payclaims`) | Table edge: the old gate counts the new backing as spendable, so a later pay period can double-commit it and leave credited claims underfunded. | +| `sysio.epoch` before `sysio.msgch`, when the release adds or changes an action `chkcons` inlines into `sysio.epoch` | Call edge in the other direction. | + +When a release changes both directions of one pair, the two contracts are a +compatibility-coupled set and must deploy in one transaction; if they do not fit, +make the change layout-compatible so that either intermediate state is safe, or +raise `max_transaction_net_usage` first. -Every intermediate state of that order is safe. A new `sysio.reserv` under an old -`sysio.epoch` is simply never asked to sweep — the retention deadline then rests -on `credit_wire_claim`'s opportunistic sweep until epoch catches up. A new -`sysio.epoch` under an old `sysio.system` reads a `payclaimtot` whose KV key does -not exist yet, so `get_or_default` yields a zero reserve — the correct answer -while nothing is credited, and the absent-key case rather than the -short-decode one (see [above](#why-a-mixed-version-is-not-merely-degraded)). +A reader that meets a KV key the new writer has not created yet reads the +default from `get_or_default` — the correct answer while nothing is written, and +the absent-key case rather than the short-decode one (see +[above](#why-a-mixed-version-is-not-merely-degraded)). ## Downgrades @@ -260,13 +260,12 @@ short-decode one (see [above](#why-a-mixed-version-is-not-merely-degraded)). **A downgrade is not the upgrade run backwards. It is a data-migration problem first, and a code-ordering problem second** — because by the time you want to -roll back, all three claim tables may hold value that only the NEW code can pay -out: +roll back, the claim tables may hold value that only the NEW code can pay out: | Table | Contract | Paid out by | Stranded when that contract rolls back | |---|---|---|---| | `payclaims` | `sysio.system` | `claimpay` | earned epoch pay | -| `wireclaims` | `sysio.reserv` | `claimwire` | swap payouts + refunds already withheld from recipients | +| `wireclaims` | `sysio.bond` | `claimwire` | WIRE yield earned by underwriters and banked at `claim` | | `remitclaims` | `sysio.opreg` | `claimremit` | debited operator collateral | Every one of those balances is value already taken from someone's spendable @@ -281,14 +280,14 @@ resumes double-committing while the pile of unreachable claims grows. The safe procedure is therefore: 1. **Quiesce the credit writers** so no new claim rows appear. -2. **Drain or migrate all three claim tables** — claimants pull, or the balances +2. **Drain or migrate the claim tables** — claimants pull, or the balances are migrated. This step is the one that actually gates the rollback, and it cannot be completed unilaterally: a claimant who never claims holds it open. -3. **Roll back the coupled trio in the order `sysio.system` → `sysio.epoch` → - `sysio.reserv`** — the mirror of the upgrade order, so the writer is retired - before the reader that accounts for it, and the caller before the callee it - would otherwise inline into. -4. **Roll `sysio.opreg` back only once its remits are handled.** +3. **Roll back the coupled set in the mirror of the upgrade order** — the writer + is retired before the reader that accounts for it, and the caller before the + callee it would otherwise inline into. +4. **Roll `sysio.opreg` back only once its remits are handled, and `sysio.bond` + only once its `wireclaims` are drained.** **A live chain with uncooperative claimants is not safely downgradeable** by code order alone. If a rollback has to happen anyway, the outstanding balances diff --git a/libraries/opp/include/sysio/opp/uic_signature_canonical.hpp b/libraries/opp/include/sysio/opp/uic_signature_canonical.hpp deleted file mode 100644 index ac5b969bad..0000000000 --- a/libraries/opp/include/sysio/opp/uic_signature_canonical.hpp +++ /dev/null @@ -1,148 +0,0 @@ -#pragma once -/** - * @file uic_signature_canonical.hpp - * @brief Host/contract-shared recovery-byte and scalar canonicality policy for - * UIC ECDSA signatures. - */ - -#include -#include -#include -#include - -namespace sysio::opp { - -/// Curve used by one recoverable UIC ECDSA signature variant. -enum class uic_ecdsa_curve { - secp256k1, - p256, -}; - -/// Position of the recovery byte inside a 65-byte compact signature body. -enum class uic_ecdsa_recovery_position { - prefix, - suffix, -}; - -/// Canonical numeric encoding of the recovery byte. -enum class uic_ecdsa_recovery_encoding { - compact, - ethereum, -}; - -namespace detail { - -using uic_scalar = std::array; - -inline constexpr uic_scalar secp256k1_order{ - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, - 0xba, 0xae, 0xdc, 0xe6, 0xaf, 0x48, 0xa0, 0x3b, - 0xbf, 0xd2, 0x5e, 0x8c, 0xd0, 0x36, 0x41, 0x41, -}; - -inline constexpr uic_scalar secp256k1_half_order{ - 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0x5d, 0x57, 0x6e, 0x73, 0x57, 0xa4, 0x50, 0x1d, - 0xdf, 0xe9, 0x2f, 0x46, 0x68, 0x1b, 0x20, 0xa0, -}; - -inline constexpr uic_scalar p256_order{ - 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, - 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51, -}; - -inline constexpr uic_scalar p256_half_order{ - 0x7f, 0xff, 0xff, 0xff, 0x80, 0x00, 0x00, 0x00, - 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xde, 0x73, 0x7d, 0x56, 0xd3, 0x8b, 0xcf, 0x42, - 0x79, 0xdc, 0xe5, 0x61, 0x7e, 0x31, 0x92, 0xa8, -}; - -/// Compare a 32-byte big-endian scalar with a fixed 32-byte bound. -inline int compare_uic_scalar(std::span scalar, - const uic_scalar& bound) { - for (size_t i = 0; i < scalar.size(); ++i) { - const auto value = static_cast(scalar[i]); - if (value < bound[i]) return -1; - if (value > bound[i]) return 1; - } - return 0; -} - -/// Return true when a 32-byte big-endian scalar is nonzero. -inline bool is_nonzero_uic_scalar(std::span scalar) { - for (const char byte : scalar) { - if (byte != 0) return true; - } - return false; -} - -} // namespace detail - -/** - * @brief Validate the recovery byte, `r`/`s` scalars, and low-s form. - * - * K1 and R1 use `[recovery || r || s]`; EM uses `[r || s || recovery]`. - * K1/R1 use compact recovery headers 31..34, while EM uses Ethereum recovery - * values 27..30. Accepting the opposite range would create a second byte - * representation that libfc normalizes to the same recovered key. - * - * @param compact_body Exact 65-byte compact signature body, without the variant tag. - * @param curve Curve order governing the scalar fields. - * @param recovery_position Location of the recovery byte in `compact_body`. - * @param recovery_encoding Required numeric recovery-byte representation. - * @return True only for the required recovery range, `0 < r < n`, and - * `0 < s <= n/2`. - */ -inline bool is_canonical_uic_ecdsa_signature( - std::span compact_body, - uic_ecdsa_curve curve, - uic_ecdsa_recovery_position recovery_position, - uic_ecdsa_recovery_encoding recovery_encoding) { - constexpr size_t scalar_size = 32; - constexpr size_t recovery_field_size = 1; - constexpr size_t prefixed_recovery_offset = 0; - constexpr size_t suffixed_recovery_offset = 2 * scalar_size; - constexpr size_t compact_size = - 2 * scalar_size + recovery_field_size; - constexpr uint8_t compact_recovery_base = 31; - constexpr uint8_t ethereum_recovery_base = 27; - constexpr uint8_t recovery_value_count = 4; - if (compact_body.size() != compact_size) return false; - - const size_t recovery_offset = - recovery_position == uic_ecdsa_recovery_position::prefix - ? prefixed_recovery_offset : suffixed_recovery_offset; - const size_t r_offset = - recovery_position == uic_ecdsa_recovery_position::prefix - ? prefixed_recovery_offset + recovery_field_size - : prefixed_recovery_offset; - const size_t s_offset = r_offset + scalar_size; - const std::span r{compact_body.data() + r_offset, scalar_size}; - const std::span s{compact_body.data() + s_offset, scalar_size}; - - const uint8_t recovery_base = - recovery_encoding == uic_ecdsa_recovery_encoding::compact - ? compact_recovery_base : ethereum_recovery_base; - const auto recovery = static_cast(compact_body[recovery_offset]); - if (recovery < recovery_base || - recovery >= recovery_base + recovery_value_count) { - return false; - } - - const auto& order = curve == uic_ecdsa_curve::p256 - ? detail::p256_order : detail::secp256k1_order; - const auto& half_order = curve == uic_ecdsa_curve::p256 - ? detail::p256_half_order : detail::secp256k1_half_order; - - return detail::is_nonzero_uic_scalar(r) - && detail::compare_uic_scalar(r, order) < 0 - && detail::is_nonzero_uic_scalar(s) - && detail::compare_uic_scalar(s, half_order) <= 0; -} - -} // namespace sysio::opp diff --git a/libraries/opp/test/include/sysio/opp/test/uic_signature_test_utils.hpp b/libraries/opp/test/include/sysio/opp/test/uic_signature_test_utils.hpp deleted file mode 100644 index c8a1e68f10..0000000000 --- a/libraries/opp/test/include/sysio/opp/test/uic_signature_test_utils.hpp +++ /dev/null @@ -1,147 +0,0 @@ -#pragma once - -#include -#include - -#include -#include -#include -#include -#include - -namespace sysio::opp::test { - -/** - * Replace a canonical recovery byte with libfc's equivalent noncanonical - * alias while preserving the recovery id. - * - * @param packed Canonically packed recoverable signature. - * @param key_type Concrete signing-key variant. - * @return Mutated packed signature, or nullopt for an unsupported type or - * malformed input. - */ -inline std::optional> create_recovery_alias( - std::vector packed, - fc::crypto::private_key::key_type key_type) { - constexpr size_t signature_tag_size = 1; - constexpr size_t scalar_size = detail::uic_scalar{}.size(); - constexpr size_t prefixed_recovery_offset = signature_tag_size; - constexpr size_t suffixed_recovery_offset = - signature_tag_size + 2 * scalar_size; - constexpr uint8_t compact_recovery_base = 31; - constexpr uint8_t ethereum_recovery_base = 27; - constexpr uint8_t recovery_value_count = 4; - - size_t recovery_offset = 0; - uint8_t recovery_base = 0; - int alias_delta = 0; - using key_variant = fc::crypto::private_key::key_type; - switch (key_type) { - case key_variant::k1: - case key_variant::r1: - recovery_offset = prefixed_recovery_offset; - recovery_base = compact_recovery_base; - alias_delta = -static_cast(recovery_value_count); - break; - case key_variant::em: - recovery_offset = suffixed_recovery_offset; - recovery_base = ethereum_recovery_base; - alias_delta = recovery_value_count; - break; - default: - return std::nullopt; - } - - if (recovery_offset >= packed.size()) return std::nullopt; - const auto recovery = static_cast(packed[recovery_offset]); - if (recovery < recovery_base || - recovery >= recovery_base + recovery_value_count) { - return std::nullopt; - } - packed[recovery_offset] = static_cast( - static_cast(recovery) + alias_delta); - return packed; -} - -/** - * Convert a canonically packed low-s K1, R1, or EM signature into its - * cryptographically equivalent high-s form. - * - * @param packed Canonically packed recoverable signature. - * @param key_type Concrete signing-key variant. - * @return Mutated packed signature, or nullopt for an unsupported type or - * malformed input. - */ -inline std::optional> create_high_s_alternate( - std::vector packed, - fc::crypto::private_key::key_type key_type) { - constexpr size_t signature_tag_size = 1; - constexpr size_t recovery_field_size = 1; - constexpr size_t scalar_size = detail::uic_scalar{}.size(); - constexpr size_t prefixed_recovery_offset = signature_tag_size; - constexpr size_t prefixed_s_offset = - signature_tag_size + recovery_field_size + scalar_size; - constexpr size_t suffixed_s_offset = signature_tag_size + scalar_size; - constexpr size_t suffixed_recovery_offset = - signature_tag_size + 2 * scalar_size; - constexpr uint8_t compact_recovery_base = 31; - constexpr uint8_t ethereum_recovery_base = 27; - - size_t s_offset = 0; - size_t recovery_offset = 0; - uint8_t recovery_base = 0; - const detail::uic_scalar* order = nullptr; - using key_variant = fc::crypto::private_key::key_type; - switch (key_type) { - case key_variant::k1: - s_offset = prefixed_s_offset; - recovery_offset = prefixed_recovery_offset; - recovery_base = compact_recovery_base; - order = &detail::secp256k1_order; - break; - case key_variant::r1: - s_offset = prefixed_s_offset; - recovery_offset = prefixed_recovery_offset; - recovery_base = compact_recovery_base; - order = &detail::p256_order; - break; - case key_variant::em: - s_offset = suffixed_s_offset; - recovery_offset = suffixed_recovery_offset; - recovery_base = ethereum_recovery_base; - order = &detail::secp256k1_order; - break; - default: - return std::nullopt; - } - - if (s_offset + scalar_size > packed.size() || recovery_offset >= packed.size()) { - return std::nullopt; - } - - detail::uic_scalar high_s{}; - unsigned borrow = 0; - for (size_t offset = 0; offset < scalar_size; ++offset) { - const size_t i = scalar_size - 1 - offset; - const unsigned minuend = (*order)[i]; - const unsigned subtrahend = - static_cast(packed[s_offset + i]) + borrow; - if (minuend < subtrahend) { - high_s[i] = static_cast(minuend + 256u - subtrahend); - borrow = 1; - } else { - high_s[i] = static_cast(minuend - subtrahend); - borrow = 0; - } - } - if (borrow != 0) return std::nullopt; - - const auto recovery = static_cast(packed[recovery_offset]); - if (recovery < recovery_base) return std::nullopt; - std::copy(high_s.begin(), high_s.end(), packed.begin() + s_offset); - packed[recovery_offset] = static_cast( - recovery_base + ((recovery - recovery_base) ^ 1u)); - return packed; -} - -} // namespace sysio::opp::test diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index 0125f3c31f..557517a8f7 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -25,6 +25,5 @@ add_subdirectory(outpost_ethereum_client_plugin) add_subdirectory(outpost_solana_client_plugin) add_subdirectory(batch_operator_plugin) add_subdirectory(external_debugging_plugin) -add_subdirectory(underwriter_plugin) add_subdirectory(status_monitor_plugin) add_subdirectory(query_engine_plugin) diff --git a/plugins/batch_operator_plugin/test/mocks/mock_outpost_client.hpp b/plugins/batch_operator_plugin/test/mocks/mock_outpost_client.hpp index 57a5f7d669..94babd52b5 100644 --- a/plugins/batch_operator_plugin/test/mocks/mock_outpost_client.hpp +++ b/plugins/batch_operator_plugin/test/mocks/mock_outpost_client.hpp @@ -49,12 +49,6 @@ class mock_outpost_client : public sysio::outpost_client { _chain_id); } - struct commit_call { - uint64_t uw_request_id = 0; - std::vector uic_bytes; - fc::microseconds deadline; - }; - /// Deliver response — can be set to either a scripted string or a functor /// that produces responses / throws per-call. std::function deliver_response = @@ -63,11 +57,6 @@ class mock_outpost_client : public sysio::outpost_client { std::function(const inbound_call&)> inbound_response = [](const inbound_call&) { return std::vector{}; }; - /// uw_commit response — scripted per call; tests that don't exercise the - /// UIC relay path can leave the default in place. - std::function commit_response = - [](const commit_call&) { return std::string{"mock-commit-tx"}; }; - std::string deliver_outbound_envelope(uint32_t epoch_index, const std::vector& envelope_bytes, fc::microseconds deadline) override { @@ -89,17 +78,6 @@ class mock_outpost_client : public sysio::outpost_client { return inbound_response(call); } - std::string uw_commit(uint64_t uw_request_id, - const std::vector& uic_bytes, - fc::microseconds deadline) override { - commit_call call{uw_request_id, uic_bytes, deadline}; - { - std::lock_guard lock(_mx); - commit_calls.push_back(call); - } - return commit_response(call); - } - struct crank_call { uint32_t epoch_index = 0; fc::microseconds deadline; @@ -119,7 +97,7 @@ class mock_outpost_client : public sysio::outpost_client { std::vector outbound_calls; std::vector inbound_calls; - std::vector commit_calls; + std::vector crank_calls; std::vector caller_address; diff --git a/plugins/http_plugin/include/sysio/http_plugin/api_category.hpp b/plugins/http_plugin/include/sysio/http_plugin/api_category.hpp index 5afab03177..c51c3c6c01 100644 --- a/plugins/http_plugin/include/sysio/http_plugin/api_category.hpp +++ b/plugins/http_plugin/include/sysio/http_plugin/api_category.hpp @@ -17,7 +17,6 @@ enum class api_category : uint32_t { prometheus = 1 << 9, test_control = 1 << 10, snapshot_ro = 1 << 11, // public read-only snapshot metadata + download - underwriter = 1 << 12, // read-only underwriter diagnostics (stats/commits) node = UINT32_MAX }; @@ -30,10 +29,10 @@ class api_category_set { for (auto c: l) insert(c); } - constexpr bool contains(api_category category) const { + constexpr bool contains(api_category category) const { return sysio::chain::has_field(data, category); } - constexpr void insert(api_category category) { + constexpr void insert(api_category category) { data = sysio::chain::set_field(data, category, true); } @@ -46,4 +45,4 @@ class api_category_set { } }; -} \ No newline at end of file +} diff --git a/plugins/http_plugin/src/http_plugin.cpp b/plugins/http_plugin/src/http_plugin.cpp index 9e7b24b9ad..5ccb227d42 100644 --- a/plugins/http_plugin/src/http_plugin.cpp +++ b/plugins/http_plugin/src/http_plugin.cpp @@ -210,7 +210,6 @@ namespace sysio { if (name == "prometheus") return api_category::prometheus; if (name == "test_control") return api_category::test_control; if (name == "snapshot_ro") return api_category::snapshot_ro; - if (name == "underwriter") return api_category::underwriter; return api_category::unknown; } @@ -227,9 +226,8 @@ namespace sysio { if (category == api_category::prometheus) return "prometheus"; if (category == api_category::test_control) return "test_control"; if (category == api_category::snapshot_ro) return "snapshot_ro"; - if (category == api_category::underwriter) return "underwriter"; if (category == api_category::node) return "node"; - // It's a programming error when the control flow reaches this point, + // It's a programming error when the control flow reaches this point, // please make sure all the category names are returned from above statements. assert(false && "No correspding category name for the category value"); return ""; @@ -253,8 +251,6 @@ namespace sysio { return "sysio::producer_api_plugin"; if (category == api_category::snapshot_ro) return "sysio::snapshot_api_plugin"; - if (category == api_category::underwriter) - return "sysio::underwriter_plugin"; // It's a programming error when the control flow reaches this point, // please make sure all the plugin names are returned from above statements. assert(false && "No corresponding plugin for the category value"); @@ -264,7 +260,7 @@ namespace sysio { std::string category_names(api_category_set set) { if (set == api_category_set::all()) return "all"; std::string result; - for (uint32_t i = 1; i <= static_cast(api_category::underwriter); i<<=1) { + for (uint32_t i = 1; i <= static_cast(api_category::snapshot_ro); i<<=1) { if (set.contains(api_category(i))) { result += from_category(api_category(i)); result += " "; @@ -288,7 +284,6 @@ namespace sysio { std::shared_ptr plugin_state{new http_plugin_state(logger())}; std::atomic listening; - /** * Make an internal_url_handler that will run the url_handler on the app() thread and then * return to the http thread pool for response processing @@ -370,7 +365,7 @@ namespace sysio { }; return handler; } - + bool is_unix_socket_address(const std::string& address) const { return address.starts_with("/") || address.starts_with("./") || address.starts_with("../"); } @@ -395,7 +390,7 @@ namespace sysio { void create_listener(const std::string& address, api_category_set categories) { const std::chrono::milliseconds accept_timeout(500); auto extra_listening_log_info = " for API categories: " + category_names(categories); - using socket_type = typename Protocol::socket; + using socket_type = typename Protocol::socket; auto create_session = [this, categories, address](socket_type&& socket) { std::string remote_endpoint; if constexpr (std::is_same_v) { @@ -478,16 +473,16 @@ namespace sysio { if (current_http_plugin_defaults.support_categories) { cfg.add_options() - ("http-category-address", bpo::value>(), + ("http-category-address", bpo::value>(), "The local IP and port to listen for incoming http category connections." " Syntax: category,address\n" " Where the address can be :port, :port or unix socket path;\n" " in addition, unix socket path must starts with '/', './' or '../'. When relative path\n" " is used, it is relative to the data path.\n\n" " Valid categories include chain_ro, chain_rw, db_size, net_ro, net_rw, producer_ro\n" - " producer_rw, snapshot, trace_api, prometheus, test_control, snapshot_ro, and underwriter.\n\n" - " A single `hostname:port` specification can be used by multiple categories\n" - " However, two specifications having the same port with different hostname strings\n" + " producer_rw, snapshot, trace_api, prometheus, test_control, and snapshot_ro.\n\n" + " A single `hostname:port` specification can be used by multiple categories\n" + " However, two specifications having the same port with different hostname strings\n" " are always considered as configuration error regardless of whether they can be resolved\n" " into the same set of IP addresses.\n\n" " Examples:\n" @@ -605,7 +600,7 @@ namespace sysio { if (unix_sock_path.size()) { if (unix_sock_path[0] != '/') unix_sock_path = "./" + unix_sock_path; my->categories_by_address[unix_sock_path].insert(api_category::node); - } + } } if (options.count("http-category-address") != 0) { @@ -629,10 +624,10 @@ namespace sysio { auto category_name = spec.substr(0, comma_pos); auto category = to_category(category_name); - SYS_ASSERT(category != api_category::unknown, chain::plugin_config_exception, + SYS_ASSERT(category != api_category::unknown, chain::plugin_config_exception, "invalid category name `{}` for http_category_address", category_name); - SYS_ASSERT(has_plugin(category_plugin_name(category)), chain::plugin_config_exception, + SYS_ASSERT(has_plugin(category_plugin_name(category)), chain::plugin_config_exception, "--plugin={} is required for --http-category-address={}", category_plugin_name(category), spec); @@ -650,7 +645,6 @@ namespace sysio { } my->plugin_state->server_header = current_http_plugin_defaults.server_header; - //watch out for the returns above when adding new code here } FC_LOG_AND_RETHROW() } diff --git a/plugins/http_plugin/test/unit_tests.cpp b/plugins/http_plugin/test/unit_tests.cpp index b12a810f2a..92a3ae541c 100644 --- a/plugins/http_plugin/test/unit_tests.cpp +++ b/plugins/http_plugin/test/unit_tests.cpp @@ -46,7 +46,7 @@ constexpr uint32_t category_ro_index = 2; constexpr uint32_t bytes_in_flight_index = 3; constexpr uint32_t requests_in_flight_index = 4; constexpr uint32_t request_body_bytes_in_flight_index = 5; -constexpr uint32_t category_uw_index = 6; +constexpr uint32_t category_snapshot_index = 6; constexpr uint32_t plaintext_response_index = 7; // Keeps unsharded IPv6 probe coverage on the historical port 9999. constexpr uint32_t ipv6_probe_index = 2; @@ -129,7 +129,6 @@ struct ProtocolCommon Results& results; }; - template struct BasicProtocol : public ProtocolCommon { @@ -284,7 +283,8 @@ class producer_api_plugin : public appbase::plugin { void plugin_shutdown() {} }; -class underwriter_plugin : public appbase::plugin { +// Minimal registration fixture for the retained snapshot HTTP category. +class snapshot_api_plugin : public appbase::plugin { public: APPBASE_PLUGIN_REQUIRES(); virtual void set_program_options(options_description& cli, options_description& cfg) override {} @@ -296,7 +296,7 @@ class underwriter_plugin : public appbase::plugin { static auto _chain_api_plugin = application::register_plugin(); static auto _net_api_plugin = application::register_plugin(); static auto _producer_api_plugin = application::register_plugin(); -static auto _underwriter_plugin = application::register_plugin(); +static auto _snapshot_api_plugin = application::register_plugin(); } // namespace sysio struct http_plugin_test_fixture { @@ -452,10 +452,10 @@ BOOST_AUTO_TEST_CASE(invalid_category_addresses) { "http-category-address", "--http-category-address", chain_ro_localhost.c_str()}) .contains("--plugin=sysio::chain_api_plugin is required")); - const std::string underwriter_localhost = "underwriter," + localhost_rw; + const std::string snapshot_localhost = "snapshot_ro," + localhost_rw; BOOST_TEST(app_log({test_name, "--plugin=sysio::chain_api_plugin", "--http-server-address", - "http-category-address", "--http-category-address", underwriter_localhost.c_str()}) - .contains("--plugin=sysio::underwriter_plugin is required")); + "http-category-address", "--http-category-address", snapshot_localhost.c_str()}) + .contains("--plugin=sysio::snapshot_api_plugin is required")); // The test_control endpoints are registered by test_control_api_plugin, so that is the plugin the category needs. const std::string test_control_localhost = "test_control," + localhost_rw; @@ -564,12 +564,12 @@ BOOST_FIXTURE_TEST_CASE(valid_category_addresses, http_plugin_test_fixture) { const std::string ro_localhost = test_http_endpoint("localhost", category_ro_index); const std::string rw_loopback = test_http_endpoint("127.0.0.1", category_rw_index); const std::string rw_any = ":" + rw_port; - const std::string uw_loopback = test_http_endpoint("127.0.0.1", category_uw_index); + const std::string snapshot_loopback = test_http_endpoint("127.0.0.1", category_snapshot_index); const std::string chain_ro = "chain_ro," + ro_loopback; const std::string chain_rw = "chain_rw," + rw_any; const std::string net_ro = "net_ro," + ro_loopback; const std::string net_rw = "net_rw," + rw_any; - const std::string underwriter = "underwriter," + uw_loopback; + const std::string snapshot = "snapshot_ro," + snapshot_loopback; const std::string ipv6_rw = "[::1]:" + rw_port; // clang-format off @@ -578,13 +578,13 @@ BOOST_FIXTURE_TEST_CASE(valid_category_addresses, http_plugin_test_fixture) { "--plugin=sysio::chain_api_plugin", "--plugin=sysio::net_api_plugin", "--plugin=sysio::producer_api_plugin", - "--plugin=sysio::underwriter_plugin", + "--plugin=sysio::snapshot_api_plugin", "--http-server-address", "http-category-address", "--http-category-address", chain_ro.c_str(), "--http-category-address", chain_rw.c_str(), "--http-category-address", net_ro.c_str(), "--http-category-address", net_rw.c_str(), - "--http-category-address", underwriter.c_str(), + "--http-category-address", snapshot.c_str(), "--http-category-address", "producer_ro,./producer_ro.sock", "--http-category-address", "producer_rw,../producer_rw.sock" }); @@ -620,7 +620,7 @@ BOOST_FIXTURE_TEST_CASE(valid_category_addresses, http_plugin_test_fixture) { [&](string&&, string&& body, url_response_callback&& cb) { cb(200, fc::variant("world!")); }}, - {std::string("/v1/underwriter/hello"), api_category::underwriter, + {std::string("/v1/snapshot/hello"), api_category::snapshot_ro, [&](string&&, string&& body, url_response_callback&& cb) { cb(200, fc::variant("world!")); }}}, @@ -630,7 +630,7 @@ BOOST_FIXTURE_TEST_CASE(valid_category_addresses, http_plugin_test_fixture) { BOOST_CHECK(http_plugin->is_on_loopback(api_category::net_ro)); BOOST_CHECK(http_plugin->is_on_loopback(api_category::producer_ro)); BOOST_CHECK(http_plugin->is_on_loopback(api_category::producer_rw)); - BOOST_CHECK(http_plugin->is_on_loopback(api_category::underwriter)); + BOOST_CHECK(http_plugin->is_on_loopback(api_category::snapshot_ro)); BOOST_CHECK(!http_plugin->is_on_loopback(api_category::chain_rw)); BOOST_CHECK(!http_plugin->is_on_loopback(api_category::net_rw)); @@ -666,13 +666,13 @@ BOOST_FIXTURE_TEST_CASE(valid_category_addresses, http_plugin_test_fixture) { BOOST_CHECK_EQUAL(http_response_for(rw_loopback.c_str(), "/v1/chain_rw/hello").body(), world_string); BOOST_CHECK_EQUAL(http_response_for(rw_loopback.c_str(), "/v1/net_rw/hello").body(), world_string); - // The dedicated underwriter category serves its own listener (where node + // The dedicated snapshot category serves its own listener (where node // handlers stay reachable, node being all categories) and is NOT served // by other category-isolated listeners. - BOOST_CHECK_EQUAL(http_response_for(uw_loopback.c_str(), "/v1/underwriter/hello").body(), world_string); - BOOST_CHECK_EQUAL(http_response_for(uw_loopback.c_str(), "/v1/node/hello").body(), world_string); - BOOST_CHECK_EQUAL(http_response_for(ro_loopback.c_str(), "/v1/underwriter/hello").status(), http::status::not_found); - BOOST_CHECK_EQUAL(http_response_for(rw_loopback.c_str(), "/v1/underwriter/hello").status(), http::status::not_found); + BOOST_CHECK_EQUAL(http_response_for(snapshot_loopback.c_str(), "/v1/snapshot/hello").body(), world_string); + BOOST_CHECK_EQUAL(http_response_for(snapshot_loopback.c_str(), "/v1/node/hello").body(), world_string); + BOOST_CHECK_EQUAL(http_response_for(ro_loopback.c_str(), "/v1/snapshot/hello").status(), http::status::not_found); + BOOST_CHECK_EQUAL(http_response_for(rw_loopback.c_str(), "/v1/snapshot/hello").status(), http::status::not_found); BOOST_CHECK_EQUAL(http_response_for(data_dir / "./producer_ro.sock", "/v1/producer_ro/hello").body(), world_string); BOOST_CHECK_EQUAL(http_response_for(data_dir / "../producer_rw.sock", "/v1/producer_rw/hello").body(), world_string); @@ -683,11 +683,10 @@ BOOST_FIXTURE_TEST_CASE(valid_category_addresses, http_plugin_test_fixture) { BOOST_CHECK_EQUAL(http_response_for(rw_loopback.c_str(), "/v1/node/get_supported_apis").body(), R"({"apis":["/v1/chain_rw/hello","/v1/net_rw/hello","/v1/node/hello"]})"); - BOOST_CHECK_EQUAL(http_response_for(uw_loopback.c_str(), "/v1/node/get_supported_apis").body(), - R"({"apis":["/v1/node/hello","/v1/underwriter/hello"]})"); + BOOST_CHECK_EQUAL(http_response_for(snapshot_loopback.c_str(), "/v1/node/get_supported_apis").body(), + R"({"apis":["/v1/node/hello","/v1/snapshot/hello"]})"); } - bool on_loopback(std::initializer_list args){ appbase::scoped_app app; BOOST_REQUIRE(app->initialize(args.size(), const_cast(args.begin()))); @@ -775,7 +774,6 @@ BOOST_FIXTURE_TEST_CASE(bytes_in_flight, http_plugin_test_fixture) { BOOST_CHECK(max > 0); }; - //send a single request to start with send_4mb_requests(1u); std::unordered_map r = drain_http_replies(); @@ -933,7 +931,6 @@ BOOST_FIXTURE_TEST_CASE(requests_in_flight, http_plugin_test_fixture) { BOOST_CHECK(max > 0); }; - //8 requests to start with send_requests(8u); std::unordered_map r = scan_http_replies(); diff --git a/plugins/outpost_client_plugin/include/sysio/outpost_client/outpost_client.hpp b/plugins/outpost_client_plugin/include/sysio/outpost_client/outpost_client.hpp index 1306111b1d..7fcd75cd05 100644 --- a/plugins/outpost_client_plugin/include/sysio/outpost_client/outpost_client.hpp +++ b/plugins/outpost_client_plugin/include/sysio/outpost_client/outpost_client.hpp @@ -129,43 +129,6 @@ class outpost_client { virtual std::vector read_inbound_envelope(uint32_t epoch_index, fc::microseconds deadline) = 0; - /** - * @brief UNDERWRITER COMMIT — submit a signed `UnderwriteIntentCommit` - * (UIC) through an ACTIVE-role-gated outpost relay. - * - * Called by the underwriter plugin (or any future plugin that issues - * outpost-side commits) to deliver a signed intent without the caller - * knowing the outpost's contract surface, ABI / IDL layout, or message - * encoding. The chain-specific concrete resolves which contract or - * program action to invoke, how to encode the bytes for the wire, and - * how to await on-chain confirmation. The current outposts accept only a - * canonically encoded UIC whose claimed WIRE account and external address - * match the authenticated caller's current ACTIVE underwriter roster row. - * They queue the original validated bytes unchanged. The WIRE depot remains - * authoritative for validating the embedded permission signature and bond. - * - * Returns only after on-chain inclusion + confirmations — the caller - * uses the return value as a "this leg landed" signal before recording - * the commit locally. Late-arriving commits (after consensus has already - * been reached for the underlying envelope) are benign no-ops on the - * outpost side per `opp-consensus.md`; they still confirm here. - * - * @param uw_request_id The depot's `sysio.uwrit::uwreqs` row id this - * UIC is committing to. Used only for log - * correlation; the on-chain call carries the original - * validated UIC bytes. - * @param uic_bytes Serialized `UnderwriteIntentCommit` (protobuf - * encoded, signed by an authorized WIRE K1, R1, - * EM, or ED permission key). - * @param deadline Upper bound on the total time spent talking to - * the remote chain for this call. - * @return Chain-native tx id / signature suitable for logs. - * @throws fc::exception on RPC failure, tx revert, or deadline expiry. - */ - virtual std::string uw_commit(uint64_t uw_request_id, - const std::vector& uic_bytes, - fc::microseconds deadline) = 0; - /** * @brief OUTPOST CRANKS — drive the outpost's permissionless per-epoch * instructions that nothing on the outpost schedules. diff --git a/plugins/outpost_client_plugin/test/test_outpost_client_interface.cpp b/plugins/outpost_client_plugin/test/test_outpost_client_interface.cpp index 9e1d145ca7..bed0a34a59 100644 --- a/plugins/outpost_client_plugin/test/test_outpost_client_interface.cpp +++ b/plugins/outpost_client_plugin/test/test_outpost_client_interface.cpp @@ -34,9 +34,6 @@ class minimal_outpost_client : public sysio::outpost_client { const std::vector&, fc::microseconds) override { return {}; } std::vector read_inbound_envelope(uint32_t, fc::microseconds) override { return {}; } - std::string uw_commit(uint64_t, - const std::vector&, - fc::microseconds) override { return {}; } private: sysio::opp::types::ChainKind _kind; diff --git a/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp b/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp index b0e89cacb3..3cc05cad59 100644 --- a/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp +++ b/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin.hpp @@ -122,30 +122,6 @@ struct syndication_pool_contract_client : ethereum_contract_client { , realize_yield(create_tx_and_confirm(get_abi("realizeYield"))) {} }; -/// Typed contract client for OperatorRegistry.sol. Carries the actions -/// plugins reach for outside the OPP envelope path — today `commit` -/// (underwriter UIC relay); future deposit / withdraw / slash actions -/// land here as additional `ethereum_contract_tx_fn` members. -/// -/// State-changing actions use `create_tx_and_confirm` so the call -/// returns only after on-chain inclusion + confirmations — the caller -/// uses the return as a "this leg landed" signal before recording the -/// action locally. -struct operator_registry_contract_client : ethereum_contract_client { - /// `commit(bytes uicBytes)` — submits the original canonical - /// `UnderwriteIntentCommit` bytes. OperatorRegistry binds their signed EVM - /// caller and claimed ACTIVE roster identity before queuing the unchanged - /// bytes. The hardhat-generated ABI passes the parameter as a hex-encoded - /// string (per `ethereum_abi::encode_dynamic_data` for `dt::bytes`). - ethereum_contract_tx_fn commit; - - operator_registry_contract_client(const ethereum_client_ptr& client, - const address_compat_type& contract_address, - const std::vector& contracts) - : ethereum_contract_client(client, contract_address, contracts) - , commit(create_tx_and_confirm(get_abi("commit"))) {} -}; - class outpost_ethereum_client_plugin : public appbase::plugin { public: APPBASE_PLUGIN_REQUIRES((outpost_client_plugin)(signature_provider_manager_plugin)) @@ -197,12 +173,10 @@ class outpost_ethereum_client_plugin : public appbase::plugin my; }; - } // namespace sysio diff --git a/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin/outpost_ethereum_client.hpp b/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin/outpost_ethereum_client.hpp index f3beeb355d..9cc45ba5c4 100644 --- a/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin/outpost_ethereum_client.hpp +++ b/plugins/outpost_ethereum_client_plugin/include/sysio/outpost_ethereum_client_plugin/outpost_ethereum_client.hpp @@ -177,7 +177,6 @@ class outpost_ethereum_client : public outpost_client { outpost_ethereum_client(ethereum_client_entry_ptr entry, std::string opp_addr, std::string opp_inbound_addr, - std::string operator_registry_addr, std::vector abis, uint64_t chain_code, uint32_t chain_id); @@ -196,10 +195,6 @@ class outpost_ethereum_client : public outpost_client { std::vector read_inbound_envelope(uint32_t epoch_index, fc::microseconds deadline) override; - std::string uw_commit(uint64_t uw_request_id, - const std::vector& uic_bytes, - fc::microseconds deadline) override; - /// The Ethereum crank: `SyndicationPool.realizeYield()` on the pool the /// outpost registers as its `DESYNDICATE_LIQ` handler. Idle, at debug level, /// while the ABI set carries no `realizeYield` (a deployment that predates @@ -211,7 +206,6 @@ class outpost_ethereum_client : public outpost_client { const ethereum_client_entry_ptr& entry() const { return _entry; } const std::string& opp_address() const { return _opp_addr; } const std::string& opp_inbound_address() const { return _opp_inbound_addr; } - const std::string& operator_registry_address() const { return _operator_registry_addr; } /// This relay's own signer address in `0x`-hex — the identity every staging /// header is bound to. Derived once at construction; the chunk resume path /// compares it against `envelopeChunkState`'s `owner` on every multi-chunk @@ -264,10 +258,10 @@ class outpost_ethereum_client : public outpost_client { ethereum_client_entry_ptr _entry; std::string _opp_addr; std::string _opp_inbound_addr; - std::string _operator_registry_addr; + std::shared_ptr _opp_client; std::shared_ptr _opp_inbound_client; - std::shared_ptr _operator_registry_client; // nullable + // nullable /// The plugin's loaded ABI set, kept for the wrapper bound after construction. std::vector _abis; /// See `syndication_pool_address()` / `bind_syndication_pool`. diff --git a/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client.cpp b/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client.cpp index e480104ef4..9d6f34b829 100644 --- a/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client.cpp +++ b/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client.cpp @@ -28,7 +28,7 @@ namespace detail = outpost_ethereum_client_detail; // ── Op labels used for deadline-exceeded error messages ────────────────── constexpr std::string_view OP_DELIVER_OUTBOUND = "deliver_outbound_envelope"; constexpr std::string_view OP_READ_INBOUND = "read_inbound_envelope"; -constexpr std::string_view OP_UW_COMMIT = "uw_commit"; + constexpr std::string_view OP_REALIZE_YIELD = "crank_outpost:realizeYield"; /// Execution APIs code for a call the node executed and that reverted, as distinct from a @@ -267,14 +267,12 @@ outpost_ethereum_client::outpost_ethereum_client( ethereum_client_entry_ptr entry, std::string opp_addr, std::string opp_inbound_addr, - std::string operator_registry_addr, std::vector abis, uint64_t chain_code, uint32_t chain_id) : _entry(std::move(entry)) , _opp_addr(std::move(opp_addr)) , _opp_inbound_addr(std::move(opp_inbound_addr)) - , _operator_registry_addr(std::move(operator_registry_addr)) , _abis(std::move(abis)) , _outpost_id(chain_code) , _chain_id(chain_id) { @@ -296,10 +294,6 @@ outpost_ethereum_client::outpost_ethereum_client( _opp_inbound_client = _entry->client->get_contract(_opp_inbound_addr, _abis); } - if (!_operator_registry_addr.empty()) { - _operator_registry_client = - _entry->client->get_contract(_operator_registry_addr, _abis); - } // Every OPPInbound staging header is bound to the delivering signer, so the // chunk-resume read needs this relay's own address on every multi-chunk @@ -692,40 +686,6 @@ std::vector outpost_ethereum_client::read_inbound_envelope( return out; } -std::string outpost_ethereum_client::uw_commit( - uint64_t uw_request_id, - const std::vector& uic_bytes, - fc::microseconds deadline) { - const auto deadline_abs = fc::time_point::now() + deadline; - fc::task::deadline_scope rpc_deadline(deadline_abs); - - throw_if_past_deadline(deadline_abs, OP_UW_COMMIT); - - FC_ASSERT(_operator_registry_client, - "outpost_ethereum_client[{}]: uw_commit requires an OperatorRegistry " - "address — pass operator_registry_addr to create_outpost_client", - to_string()); - - // Solidity `commit(bytes uicBytes)` takes a `bytes` parameter; the - // libfc ABI encoder for `dt::bytes` expects a hex-encoded string - // (see ethereum_abi.cpp::encode_dynamic_data). Building the variant - // around the raw `std::vector` triggers an `fc::bad_cast` - // inside the encoder — the typed wrapper takes the hex form directly. - // - // The `ethereum_contract_tx_fn` signature - // binds the argument as a non-const `std::string&`, so the local - // must be a non-const lvalue (mirroring the `epoch_in(envelope_hex)` - // pattern in `deliver_outbound_envelope`). - std::string uic_hex = std::string("0x") + - fc::to_hex(uic_bytes.data(), uic_bytes.size()); - - const auto result = _operator_registry_client->commit(uic_hex); - const auto tx_hash = result.as_string(); - ilog("outpost_ethereum_client[{}]: uw_commit confirmed uwreq={} tx_hash={} bytes={}", - to_string(), uw_request_id, tx_hash, uic_bytes.size()); - return tx_hash; -} - void outpost_ethereum_client::bind_syndication_pool( std::string address, std::shared_ptr client) { FC_ASSERT(client, "outpost_ethereum_client[{}]: bind_syndication_pool needs a wrapper", to_string()); diff --git a/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp b/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp index 250f9a14e6..4787802b75 100644 --- a/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp +++ b/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client_plugin.cpp @@ -542,8 +542,7 @@ outpost_ethereum_client_plugin::create_outpost_client(const std::string& eth_cli uint64_t chain_code, uint32_t chain_id, const std::string& opp_addr, - const std::string& opp_inbound_addr, - const std::string& operator_registry_addr) { + const std::string& opp_inbound_addr) { const auto entry = my->get_client(eth_client_id); FC_ASSERT(entry, "Unknown ethereum client id: {}", eth_client_id); const auto chain_name = fc::slug_name{chain_code}.to_string(); @@ -564,7 +563,6 @@ outpost_ethereum_client_plugin::create_outpost_client(const std::string& eth_cli return std::make_shared(entry, opp_addr, opp_inbound_addr, - operator_registry_addr, std::move(all_abis), chain_code, chain_id); diff --git a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp index 3e423fa235..0f8eced462 100644 --- a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp +++ b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp @@ -507,14 +507,6 @@ BOOST_AUTO_TEST_CASE(all_typed_write_wrappers_share_the_policy_enforced_path) { [&] { inbound.epoch_in(epoch_index, chunk_index, total_chunks, total_bytes, chunk); }); expect_policy_rejection([&] { inbound.discard_envelope_chunks(); }); - sysio::operator_registry_contract_client registry{ - client, - std::string(contract_address), - {bytes_argument_function("commit")}, - }; - std::string commitment = "01"; - expect_policy_rejection([&] { registry.commit(commitment); }); - sysio::opp_contract_client opp{ client, std::string(contract_address), @@ -734,7 +726,6 @@ BOOST_AUTO_TEST_CASE(outpost_factory_reports_client_configuration_chain_mismatch chain_code, 1, std::string(contract_address), - std::string(contract_address), std::string(contract_address)); BOOST_FAIL("expected client/outpost chain mismatch rejection"); } catch (const sysio::chain::plugin_config_exception& rejection) { diff --git a/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp b/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp index 6ac2d7f0a6..5462a0234b 100644 --- a/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp +++ b/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp @@ -6,7 +6,6 @@ #include #include - #include #include #include @@ -88,7 +87,6 @@ std::vector test_tx_01_unsigned_result{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xc0 }; - std::string test_tx_01_r = "93166a3ed10a4050dce7261c4ca8bcba16a1731117c453a326a1742c959b33f0"; std::string test_tx_01_s = "7c17a232cd69ce93f21a30579a2a94309b2d71918043134b4c5df5788078a0e4"; fc::uint256 test_tx_01_v = 0; @@ -575,7 +573,6 @@ std::unique_ptr create_chunked_delivery_fixture() { stack.entry, /*opp_addr=*/std::string{}, inbound_address, - /*operator_registry_addr=*/std::string{}, abis, test_outpost_chain_code, test_evm_chain_id); @@ -716,7 +713,6 @@ std::unique_ptr create_crank_fixture(bool with_pool_abi = true) { stack.entry, /*opp_addr=*/std::string{}, inbound_address, - /*operator_registry_addr=*/std::string{}, fixture->abis, test_outpost_chain_code, test_evm_chain_id); @@ -1114,7 +1110,6 @@ BOOST_AUTO_TEST_CASE(read_inbound_envelope_validates_latest_slot) try { entry, opp_address, "", - "", abis, test_outpost_chain_code, test_evm_chain_id); @@ -1914,7 +1909,6 @@ BOOST_AUTO_TEST_CASE(can_encode_tx_01) try { "0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5", private_key_spec); - // Provider should be retrievable // Sign raw unsigned TX bytes — eth_client_signer hashes with keccak256 internally fc::crypto::eth_client_signer eth_signer(*sig_provider); diff --git a/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin.hpp b/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin.hpp index a9021bb36b..89509f270f 100644 --- a/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin.hpp +++ b/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin.hpp @@ -11,34 +11,9 @@ namespace sysio { using namespace fc::network::solana; -/// Default program name used in the Anchor IDL for the Solana OPP outpost. -/// Shared between the outpost_solana_client_plugin and the batch_operator_plugin -/// so both speak a single constant when locating the program's IDL entry. -/// Overridable at runtime via `--solana-outpost-program-name`: the clean-room -/// outpost implementation is hosted inside the `liqsol_core` program, whose -/// generated IDL carries that name instead of `opp_outpost`. +/// Default IDL name; integrated liqsol-core deployments override it through configuration. inline constexpr const char* OPP_SOLANA_OUTPOST_PROGRAM_NAME = "opp_outpost"; -/// Interval between successive `getSignaturesForAddress` + log-scan attempts -/// inside the underwriter daemon's `verify_source_deposit_sol`. Kept long -/// enough to keep the RPC load production-acceptable — a tighter interval is -/// reserved for tighter-budget flows (e.g. tx-confirmation polling at the -/// `processed` commitment level, ~400ms). -/// -/// Shared with the underwriter plugin so the production-tuning lever lives -/// in one place per chain client. -inline constexpr auto SOL_SWAP_DEPOSIT_POLL_INTERVAL = fc::seconds(15); - -/// Total wall-clock budget for `verify_source_deposit_sol` to find the -/// `SwapDeposit` marker log line emitted by `opp-outpost::request_swap`. -/// On expiry the verifier returns `false` and the underwriter's outer poll -/// loop reattempts on its next tick. 120s comfortably covers: -/// - the slot it took for `request_swap` to land + finalize, AND -/// - the RPC `getSignaturesForAddress` window (default ~1000 sigs back), -/// - across an `solana-test-validator` cluster (no ledger pruning within -/// this horizon) and a production RPC (≥ 2 epochs of tx history). -inline constexpr auto SOL_SWAP_DEPOSIT_TOTAL_TIMEOUT = fc::seconds(120); - /// Consumer role a Solana `outpost_client` is constructed for. Boot-time IDL /// validation is role-aware: only roles that call `read_inbound_envelope` /// require the loaded IDL to declare a decodable `LatestOutboundEnvelope` @@ -49,11 +24,7 @@ enum class solana_outpost_role { /// Delivers outbound envelopes AND polls `read_inbound_envelope` - the /// IDL must declare a readable `LatestOutboundEnvelope`, asserted at boot. batch_operator, - /// Only submits `uw_commit`; never reads inbound envelopes, so the - /// `LatestOutboundEnvelope` boot assertion is skipped. If such a client - /// ever does call `read_inbound_envelope` against an IDL without the - /// account, the read logs a warning and yields no envelope. - underwriter + }; struct solana_client_entry_t { @@ -110,18 +81,6 @@ struct opp_solana_outpost_client : fc::network::solana::solana_program_client { /// bytes — overwritten on every emit. The WIRE batch operator reads /// this to relay the envelope back to WIRE. fc::network::solana::solana_public_key latest_outbound_envelope_pda; - /// Outpost lamport vault. Holds escrowed collateral deposited via - /// `deposit`; drained on inbound WITHDRAW_REMIT / SLASH / - /// DEPOSIT_REVERT by the program's signed system_program::transfer - /// CPI. Pre-derived from seed `outpost_vault`. - fc::network::solana::solana_public_key vault_pda; - /// Outpost `reserve_aggregate` PDA — receives slashed-collateral routing - /// and DEPOSIT_REVERT penalties, and owns the destination ATA of an SPL - /// SLASH seizure. Pre-derived from seed `reserve_aggregate` (see the - /// derivation below for the `outpost_reserve` mis-seed incident this - /// spelling replaced). - fc::network::solana::solana_public_key reserve_pda; - /// `initialize(consensus_threshold: u32) -> signature`. solana_program_tx_fn initialize; /// `epoch_in(epoch_index, chunk_index, total_chunks, total_bytes, chunk_data, @@ -155,16 +114,6 @@ struct opp_solana_outpost_client : fc::network::solana::solana_program_client { /// never calls it because the consensus-reaching terminal `epoch_in` emits /// the outbound envelope inline. solana_program_tx_fn emit_outbound_envelope; - /// `deposit(operator_type: u8, wire_account_name: string, amount: u64) -> signature`. - solana_program_tx_fn deposit; - /// `commit_underwrite(uic_bytes: bytes) -> signature`. - /// Submits an underwriter's original canonical `UnderwriteIntentCommit` - /// bytes. The on-chain handler binds their signed SVM caller and claimed - /// ACTIVE roster identity, then stores the unchanged bytes for the next - /// outbound envelope so the batch operator can relay the COMMIT to the - /// depot. - solana_program_tx_fn> commit_underwrite; - /// Decode already-fetched Anchor account bytes using the outpost IDL. /// This lets callers distinguish account-not-found from RPC/transport /// failure before decoding the returned data. @@ -196,17 +145,6 @@ struct opp_solana_outpost_client : fc::network::solana::solana_program_client { , latest_outbound_envelope_pda(fc::network::solana::system::find_program_address( {std::vector{'l','a','t','e','s','t','_','o','u','t','b','o','u','n','d','_','e','n','v','e','l','o','p','e'}}, prog_id).first) - , vault_pda(fc::network::solana::system::find_program_address( - {std::vector{'o','u','t','p','o','s','t','_','v','a','u','l','t'}}, - prog_id).first) - // SOL outpost reserve aggregate is seeded with b"reserve_aggregate" - // (see `RESERVE_AGGREGATE_SEED` in programs/opp-outpost/src/state/reserve.rs). - // Previously this used b"outpost_reserve" which derived to a non-existent - // PDA → epoch_in's `reserve_aggregate` account validation failed with - // fc::assert_exception 10. - , reserve_pda(fc::network::solana::system::find_program_address( - {std::vector{'r','e','s','e','r','v','e','_','a','g','g','r','e','g','a','t','e'}}, - prog_id).first) // OPP writes default to the confirmed variant — any state-changing // call on this client is consensus-critical and must not silently // drop (see epoch-859 stall RCA). `execute_tx_and_confirm` + default @@ -350,22 +288,7 @@ struct opp_solana_outpost_client : fc::network::solana::solana_program_client { program_invoke_data_items params = {fc::variant(wire_epoch_index)}; return execute_tx_and_confirm(instr, resolve_accounts(instr, params, overrides), params); }) - , deposit(create_tx_and_confirm(get_idl("deposit"))) - // commit_underwrite is `(uic_bytes: bytes) -> signature`. The IDL declares - // three accounts — `underwriter` (signer, default-resolved from the - // client), `operator_registry` (PDA), and `outbound_message_buffer` - // (PDA). IDL v2 (Anchor 0.31+) does not embed PDA seeds, so the typed - // wrapper must inject the pre-derived PDAs as overrides — same pattern - // as `epoch_in` / `emit_outbound_envelope` above. - , commit_underwrite([this](std::vector uic_bytes) -> std::string { - account_overrides_t overrides = { - {"operator_registry", operator_registry_pda}, - {"outbound_message_buffer", outbound_message_buffer_pda}, - }; - auto& instr = get_idl("commit_underwrite"); - program_invoke_data_items params = {fc::variant(uic_bytes)}; - return execute_tx_and_confirm(instr, resolve_accounts(instr, params, overrides), params); - }) {} + {} }; class outpost_solana_client_plugin : public appbase::plugin { diff --git a/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin/outpost_solana_client.hpp b/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin/outpost_solana_client.hpp index 996dace4fd..47a49363c1 100644 --- a/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin/outpost_solana_client.hpp +++ b/plugins/outpost_solana_client_plugin/include/sysio/outpost_solana_client_plugin/outpost_solana_client.hpp @@ -64,10 +64,9 @@ inline constexpr uint32_t SOLANA_DISPATCH_HEAP_FRAME_BYTES = 256'000; /// the relay can never spin forever on one epoch. /// /// Sized against the depot's worst case now that the depot no longer bounds the -/// envelope by Solana's packet limit: `MAX_ENVELOPE_BYTES` (64 KiB) admits on the -/// order of 500 small SPL swap-remits across distinct reserves, and at ~5 -/// attestations per 16-account round that needs ~100 rounds. Exhausting this is -/// a liveness alarm, never a silent success. +/// envelope by Solana's packet limit: many LIQ effects can require separate +/// rounds under the account budget. Exhaustion is a liveness alarm, never a +/// silent success. /// /// Exported so the plugin's round-exhaustion test sizes its fixture against /// THIS constant rather than a copy of it. @@ -186,45 +185,6 @@ std::vector decode_latest_envelope_account(opp_solana_outpost_client& pro /// is absent or disagrees with what the cursor read decodes. void assert_epoch_deliveries_shape(const fc::network::solana::idl::program& program); -/// Assert that the loaded IDL's `CollateralPosition` declaration carries the -/// four fields the on-chain settlement path binds together: `operator` and -/// `custody_mint` (pubkeys), plus `token_code` and `amount` (u64), in either -/// IDL field home. -/// -/// The relay itself decodes only `custody_mint`; the other three are a -/// DELIBERATE drift canary — a program-side rename of any of the four fails -/// loudly at boot rather than surfacing later as a live position this relay -/// silently half-understands. -/// -/// Called at construction for the batch-operator role. IDL drift is the -/// realistic reason a live, program-readable position becomes unreadable to -/// THIS relay; without its pinned custody mint the manifest cannot include the -/// effect accounts the program's real branch requires, so the dispatch window -/// would abort and every retry would wedge on the same cursor position. -/// -/// @param program the program's loaded Anchor IDL. -/// @throws fc::exception if the account or any of the four fields is absent, -/// or a field has a type the manifest builder cannot interpret. -void assert_collateral_position_shape(const fc::network::solana::idl::program& program); - -/// Assert that the loaded IDL's `Reserve` declaration carries the four fields -/// the terminal manifest resolves from it: `creator`, `custody_mint` and -/// `custody_token_program` (pubkeys) and `custody_decimals` (u8), in either IDL -/// field home. -/// -/// Called at construction for the batch-operator role, beside the -/// `EpochDeliveries` check. IDL drift is the realistic reason a live, -/// program-readable `Reserve` becomes unreadable to THIS relay, and that -/// failure is unrecoverable in flight: the manifest cannot be completed, the -/// dispatch window would abort on a missing effect account, and every later -/// window repacks from the same unadvanced cursor. Catching it at boot turns a -/// wedged epoch into a startup error while the IDL is still fixable. -/// -/// @param program the program's loaded Anchor IDL. -/// @throws fc::exception if the account or any of the four fields is absent, -/// or a field has a type the manifest builder cannot interpret. -void assert_reserve_shape(const fc::network::solana::idl::program& program); - /// Assert the loaded IDL declares `DistributionState` with the one field a /// DESYNDICATE_LIQ manifest resolves from it: `liqsol_mint` (pubkey). Only the /// integrated liqsol-core program declares the account, so the boot check runs @@ -232,73 +192,6 @@ void assert_reserve_shape(const fc::network::solana::idl::program& program); /// unreadable and wedge every desyndication window on the same cursor. void assert_distribution_state_shape(const fc::network::solana::idl::program& program); -/// Terminal-finalization facts for one per-`(token_code, reserve_code)` -/// Reserve PDA, read from the `Reserve` ACCOUNT itself. -/// -/// Custody is pinned on the Reserve at creation time and is exactly what the -/// on-chain handlers branch on (`handle_swap_remit` / `handle_swap_revert` / -/// `handle_reserve_create_cancelled` all test `reserve.custody_mint` against -/// the native marker). Resolving it from the mutable -/// `OutpostConfig.token_addresses_by_code` instead would let an admin -/// re-pointing a token address between reserve creation and dispatch drive the -/// relay down the native branch while the program takes the SPL one -- the -/// manifest would then lack the vault/ATA accounts the handler requires and -/// the call would abort permanently, wedging the epoch. -struct reserve_terminal_info { - /// Reserve creator -- the `RESERVE_CREATE_CANCELLED` refund target. - fc::network::solana::solana_public_key creator; - /// Custody mint, or the all-zero system-program key for native lamports - /// (the program's `NATIVE_TOKEN_MARKER` convention). - fc::network::solana::solana_public_key custody_mint; - /// Chain-native decimals pinned at reserve creation. - uint8_t custody_decimals = 0; - /// Token program the reserve's custody is held under, pinned at reserve - /// creation (SOL-396). The SAME (owner, mint) pair has DIFFERENT canonical - /// ATAs under SPL-Token and Token-2022, so this drives BOTH the ATA - /// derivation and the token-program account the terminal handlers require. - /// Deriving with the legacy default against a Token-2022 reserve yields an - /// address the program never asks for -- `EffectAccountMissing`, forever. - fc::network::solana::solana_public_key custody_token_program; -}; - -/// Extract the terminal-finalization facts from an already-decoded `Reserve` -/// account object. ALL FOUR fields are required: `creator`, `custody_mint`, -/// `custody_decimals` and `custody_token_program` are written together at -/// reserve creation, so a record missing any of them is not a reserve this -/// relay can build an account-consistent manifest for — guessing custody is -/// precisely the divergence that aborts the on-chain call. -/// -/// Throwing here FAILS THE BUILD, by design — do not "restore" a degrade on -/// this path. Reaching this function means the account EXISTS and the program -/// therefore decodes it fine and takes its real branch, demanding the accounts -/// that branch needs. A manifest built by guessing the missing field is -/// guaranteed to abort on chain (`require_remaining_account` -> -/// EffectAccountMissing), and because `drive_dispatch_rounds` repacks every -/// window from the unadvanced on-chain cursor, that aborting attestation heads -/// every future window and the epoch never closes. -/// -/// The benign degrade lives one level up and covers a DIFFERENT cause: an -/// ABSENT or EMPTY reserve never reaches this function, and -/// `reserve_info_for_codes` returns empty for it because the program skips an -/// uninitialized reserve. Only that cause may degrade. -/// -/// Exposed in this header so the plugin's unit tests can drive custody -/// resolution — including a config-vs-reserve divergence — without RPC. -/// -/// @param reserve a decoded `Reserve` account object. -/// @throws fc::exception when a required field is absent or unparseable. -reserve_terminal_info reserve_info_from_account(const fc::variant_object& reserve); - -/// Reads the `Reserve` account behind `(token_code, reserve_code)`. -/// -/// Returns empty ONLY for a reserve the program itself will skip (absent or -/// uninitialized) — a benign per-attestation degrade. A reserve that exists -/// but cannot be read THROWS, because the program would take its real branch -/// and abort on the accounts a degraded manifest omits; that exception is -/// propagated by `build_dispatch_manifests` rather than absorbed. -using reserve_info_reader = - std::function(uint64_t token_code, uint64_t reserve_code)>; - /// One entry of a Token-2022 mint's `ExtraAccountMetaList`, exactly as the /// validation account stores it (35 bytes: discriminator, 32-byte address /// config, signer flag, writable flag). @@ -380,8 +273,6 @@ using transfer_hook_reader = std::function( const fc::network::solana::solana_public_key& custody_mint)>; - - /// How far the outpost has settled one inbound epoch's attestations. struct epoch_dispatch_progress { /// Whether consensus has tipped for the epoch. Until it has, a terminal @@ -443,26 +334,6 @@ std::string drive_dispatch_rounds( send_dispatch, const std::string& log_label); -/// Custody binding read from the account the on-chain settlement handler -/// branches on. Deliberately mint-only: `CollateralPosition` carries no -/// decimals, and the manifest path branches on the mint alone — a decimals -/// field here could only ever hold a made-up value (contrast -/// `reserve_terminal_info`, whose `custody_decimals` is a real pinned fact). -struct token_custody_info { - /// SPL mint for the token, or the all-zero system-program key when the - /// token is native lamports (the on-chain zero-marker convention). - fc::network::solana::solana_public_key mint; -}; - -/// Reads ONE `(operator, token_code)` position's custody binding from its -/// `CollateralPosition` PDA, exactly where the on-chain handler resolves it. -/// Absent positions degrade because the program log-and-skips them; a present -/// but unreadable position throws so the relay never submits a manifest that -/// is guaranteed to abort. -using collateral_custody_reader = - std::function( - const fc::network::solana::solana_public_key& operator_key, uint64_t token_code)>; - /// The syndicated liqSOL pool's facts a DESYNDICATE_LIQ manifest is derived /// from, read off the program's `DistributionState` singleton: the pool's /// liqSOL mint (Token-2022). The handler binds the mint from the same account, @@ -636,33 +507,11 @@ class outpost_solana_client : public outpost_client { std::vector read_inbound_envelope(uint32_t epoch_index, fc::microseconds deadline) override; - std::string uw_commit(uint64_t uw_request_id, - const std::vector& uic_bytes, - fc::microseconds deadline) override; - // Expose for inspection / tests const solana_client_entry_ptr& entry() const { return _entry; } const fc::network::solana::solana_public_key& program_id() const { return _program_id; } private: - /// Resolve the terminal-finalization facts for a per-reserve PDA -- creator - /// AND custody (mint / decimals) -- from the ONE account the on-chain - /// handlers themselves branch on: the `Reserve` PDA. One RPC read per - /// distinct reserve; no `OutpostConfig` read on this path at all. - /// - /// The two failure modes are deliberately NOT treated alike: - /// - /// * An ABSENT or EMPTY reserve returns empty (a warning is logged). The - /// program skips an uninitialized reserve, so a partial manifest is - /// harmless. - /// * A reserve that EXISTS but this relay cannot decode (or that is - /// missing creator/custody) THROWS, after logging the chain-side - /// reason. The program reads that account fine and demands the accounts - /// its real branch needs, so a degraded manifest would be guaranteed to - /// abort — permanently, since every later window repacks from the same - /// unadvanced cursor. Failing the tick leaves the cursor untouched. - std::optional - reserve_info_for_codes(uint64_t token_code, uint64_t reserve_code); /// Read `EpochDeliveries` for `epoch_index`. A missing/empty account means /// nothing has been delivered yet, reported as zero progress rather than @@ -681,19 +530,6 @@ class outpost_solana_client : public outpost_client { uint32_t dispatch_limit, std::vector extra_remaining_accounts); - /// Resolve one per-`(operator, token_code)` collateral position's pinned - /// custody mint from the `CollateralPosition` PDA the on-chain handlers - /// themselves branch on. - /// - /// An ABSENT or EMPTY position returns empty because the program - /// log-and-skips an uninitialized account. A position that EXISTS but this - /// relay cannot decode THROWS after logging: the program takes its real - /// custody branch, so degrading would omit required effect accounts and - /// permanently wedge the unadvanced dispatch cursor. - std::optional - collateral_position_custody( - const fc::network::solana::solana_public_key& operator_key, uint64_t token_code); - /// Read one custody mint's transfer-hook configuration (SOL-396). `nullopt` /// when the mint carries no hook -- every mint in the system today, for /// which the manifest is unchanged. A CONFIGURED hook whose @@ -743,56 +579,6 @@ void record_terminal_account(std::vector& met const fc::network::solana::solana_public_key& key, bool is_writable); -/// `(token_code, reserve_code)` pair for a Reserve PDA derivation, carried on -/// every reserve-backed `inbound_effect`. The manifest builder derives the -/// Reserve PDA via Anchor's `find_program_address` with the `[RESERVE_SEED, -/// &token_code.to_le_bytes(), &reserve_code.to_le_bytes()]` seed list against -/// the program id. -struct reserve_pda_seeds { - uint64_t token_code; - uint64_t reserve_code; -}; - -/// Derive the per-`(token_code, reserve_code)` `Reserve` PDA: seeds -/// `["reserve", token_code.to_le_bytes(), reserve_code.to_le_bytes()]`. -/// Byte-exact mirror of the program's `#[account(seeds = ...)]` declaration — -/// a derivation that disagrees fails seeds validation on chain. -/// -/// Exported so the manifest builder and its tests derive through ONE -/// implementation rather than re-spelling the seed list. -fc::network::solana::solana_public_key -derive_reserve_pda(const fc::network::solana::solana_public_key& program_id, - uint64_t token_code, - uint64_t reserve_code); - -/// Derive the per-`(token_code, reserve_code)` `reserve_vault` PDA: seeds -/// `["reserve_vault", token_code.to_le_bytes(), reserve_code.to_le_bytes()]`. -/// The vault holds the reserve's SPL custody; native reserves settle straight -/// out of the Reserve PDA, so this account only rides an SPL manifest. -fc::network::solana::solana_public_key -derive_reserve_vault_pda(const fc::network::solana::solana_public_key& program_id, - uint64_t token_code, - uint64_t reserve_code); - -/// Derive the per-`(operator, token_code)` `CollateralPosition` PDA (SOL-379): -/// seeds `["collateral_position", operator.as_ref(), token_code.to_le_bytes()]`. -/// Byte-exact mirror of the program's `#[account(seeds = ...)]` declaration. -/// -/// Exported so the manifest builder and its tests derive through ONE -/// implementation rather than re-spelling the seed list. -fc::network::solana::solana_public_key -derive_collateral_position_pda(const fc::network::solana::solana_public_key& program_id, - const fc::network::solana::solana_public_key& operator_key, - uint64_t token_code); - -/// Derive the per-`token_code` `collateral_vault` PDA: seeds -/// `["collateral_vault", token_code.to_le_bytes()]`. The vault holds the -/// token's SPL collateral custody; native collateral settles straight out of -/// the named `vault`, so this account only rides an SPL manifest. -fc::network::solana::solana_public_key -derive_collateral_vault_pda(const fc::network::solana::solana_public_key& program_id, - uint64_t token_code); - /// The liqSOL pool's fixed PDAs (wire-solana `liqsol-core`), byte-exact mirrors /// of the program's seed declarations: `GlobalState` /// (`["outpost_global_state"]`), `DistributionState` (`["distribution_state"]`), @@ -837,38 +623,7 @@ derive_liqsol_user_record_pda(const fc::network::solana::solana_public_key& prog /// derives the concrete metas per shape; the on-chain handler resolves them /// out of `remaining_accounts` by pubkey. /// -/// The collateral-settling shapes (`withdraw_remit`, `slash`, -/// `deposit_revert`) exist because SOL-379 replaced the bounded collateral -/// `Vec` on `OperatorRegistry` with a per-`(operator, token_code)` -/// `CollateralPosition` PDA, and SOL-380 made the handlers settle in the -/// asset the position actually escrows — so their manifests must declare the -/// position PDA (seeds `[COLLATERAL_POSITION_SEED, operator.as_ref(), -/// &token_code.to_le_bytes()]`) and, for SPL custody, the -/// `[COLLATERAL_VAULT_SEED, &token_code.to_le_bytes()]` vault PDA, the -/// destination ATA and the SPL token program. enum class effect_shape { - /// OPERATOR_ACTION(WITHDRAW_REMIT): pays the operator (natively out of the - /// named `vault`, or into their canonical ATA under SPL custody) and - /// debits their `CollateralPosition` PDA. - withdraw_remit, - /// OPERATOR_ACTION(SLASH): debits the operator's `CollateralPosition` PDA - /// and routes the seizure into the named `reserve_aggregate` (native) or - /// its canonical ATA via the collateral vault + token program (SPL). - slash, - /// OPERATOR_ACTION(DEPOSIT_REVERT): refunds the depositor (natively out of - /// the named `vault`, or into their canonical ATA under SPL custody) and - /// debits their `CollateralPosition` PDA. - deposit_revert, - /// SWAP_REMIT: Reserve PDA, plus vault + recipient ATA + token program - /// when the reserve's custody mint is SPL rather than native. - swap_remit, - /// SWAP_REVERT: refunds the depositor, so it additionally needs the mint, - /// the ATA program and the system program to create the ATA if absent. - swap_revert, - /// RESERVE_READY: Reserve PDA only. - reserve_ready, - /// RESERVE_CREATE_CANCELLED: refunds the reserve's creator. - reserve_create_cancelled, /// DESYNDICATE_LIQ: the depot releases a user's syndicated liqSOL. The /// handler resolves the pool's two state singletons, the pool-authority-signed /// Token-2022 transfer's accounts (the pool and user ATAs with their @@ -890,18 +645,9 @@ enum class effect_shape { struct inbound_effect { size_t attestation_index; effect_shape shape; - /// WITHDRAW_REMIT operator / DEPOSIT_REVERT depositor / SWAP_REMIT - /// recipient / SWAP_REVERT depositor / DESYNDICATE_LIQ user (paid into - /// their liqSOL ATA). For `slash` it is the SLASHED operator — it keys the - /// `CollateralPosition` PDA and the destination ATA owner lookup but is - /// never itself paid. + /// DESYNDICATE_LIQ recipient, paid into its liqSOL ATA. std::optional recipient; - /// Set for every reserve-backed shape. - std::optional reserve; - /// Set for every collateral-settling shape (`withdraw_remit`, `slash`, - /// `deposit_revert`): the `token_code` keying the `CollateralPosition` - /// PDA and its pinned custody lookup. - std::optional collateral_token_code; + /// Set for `desyndicate_liq`: the depot's `DesyndicateLIQ.request_id`, which /// keys the `PendingPayout` PDA the handler stores the payout at when it /// cannot pay inline (`derive_pending_desyndication_pda`). @@ -933,78 +679,23 @@ extract_inbound_effects(const std::vector& envelope_bytes); /// envelope does not decode. uint32_t count_inbound_attestations(const std::vector& envelope_bytes); -/// Build the per-attestation effect-account manifests `drive_dispatch_rounds` -/// packs its `dispatch_attestations` windows from, factored over its ONE RPC -/// touchpoint (`read_reserve_info`) so the plugin's unit tests can drive the -/// whole build -- custody branching, per-reserve read coalescing, degrade -/// paths and the deadline probe -- without a live Solana endpoint. -/// -/// The result is indexed by the FLAT attestation position the on-chain cursor -/// counts, sized to `total_attestations`; attestations needing no effect -/// account keep an empty entry so the indices stay aligned. -/// -/// Behaviour that matters: -/// * `read_reserve_info` is called at most ONCE per distinct -/// `(token_code, reserve_code)` -- the results are memoised for the build, -/// including the empty (degraded) ones, so a repeated reserve never -/// re-pays an RPC round-trip. -/// * Custody branching follows `reserve_terminal_info::custody_mint`, the -/// same field the on-chain handler branches on. -/// * `throw_if_past_deadline` runs once per effect, BEFORE its reserve read, -/// so an over-deadline build fails at the loop rather than deep in the RPC -/// layer with the work already lost. -/// * A degraded (empty) reserve read costs only THAT attestation its -/// custody-dependent accounts; every other attestation's manifest is still -/// built, so the envelope's healthy prefix still dispatches. A THROWING -/// read propagates untouched — it means a manifest the program would abort -/// on, and shipping one would wedge the epoch rather than delay it. -/// -/// * Collateral-settling shapes (`withdraw_remit`, `slash`, `deposit_revert`) -/// resolve custody through `read_collateral_custody` — called at most ONCE -/// per distinct `(operator, token_code)` position, memoised like the -/// reserve reads. A degraded (empty) custody read costs that attestation -/// only its SPL extras; the `CollateralPosition` PDA and (where owed) the -/// recipient are still declared, matching the program's log-and-skip / -/// abort-and-retry gates. -/// -/// * `desyndicate_liq` derives everything from the user, the program's fixed -/// pool PDAs and the mint on `DistributionState`, read through -/// `read_liq_pool` at most ONCE per build. A degraded (empty) read costs -/// the attestation everything but the two state singletons and the -/// pending-payout PDA, which is what lets the handler log-and-skip an -/// uninitialized pool instead of aborting. The pending-payout PDA -/// (`derive_pending_desyndication_pda`, writable) rides right after the -/// singletons for every non-zero request id, pool read or not: the handler's -/// frozen-outpost deferral needs it before any pool account, and its -/// legacy-`UserRecord` deferral after them; the paid path never touches it. -/// -/// @param program_id outpost program id, for PDA derivation. -/// @param effects account-needing attestations, in dispatch order. -/// @param total_attestations the envelope's attestation total (the cursor's -/// denominator) -- the size of the result. -/// @param throw_if_past_deadline throws once the caller's deadline passes. -/// @param read_reserve_info reads one `Reserve` record (may degrade). -/// @param read_collateral_custody reads one `(operator, token_code)` -/// position's pinned custody (may degrade only -/// when the position is absent or empty). -/// @param read_liq_pool reads the liqSOL pool's `DistributionState` -/// (may degrade only when the account is absent -/// or empty). -/// @param reserve_aggregate the named `reserve_aggregate` account — the -/// destination whose ATA receives an SPL slash -/// seizure. -/// @param log_label client identity for log lines. -/// @return one manifest per attestation, in dispatch order. +/// Build LIQ settlement account manifests, indexed by the envelope's flat +/// attestation position. Probe the deadline before each effect and memoize the +/// pool and transfer-hook reads for this build. An absent pool carries only the +/// state singletons and pending-payout PDA; an RPC error propagates for retry. +/// +/// The full manifest includes the Token-2022 transfer, share-accounting and +/// hook accounts. A nonzero request id always contributes its writable pending +/// payout PDA so frozen or refused payouts can be stored instead of lost. +/// +/// @return one manifest per attestation, including empty entries for no-effect tags. std::vector> build_dispatch_manifests( const fc::network::solana::solana_public_key& program_id, const std::vector& effects, uint32_t total_attestations, const std::function& throw_if_past_deadline, - const reserve_info_reader& read_reserve_info, - const collateral_custody_reader& read_collateral_custody, const transfer_hook_reader& read_transfer_hook, const liq_pool_reader& read_liq_pool, - const fc::network::solana::solana_public_key& reserve_aggregate, const std::string& log_label); } // namespace outpost_solana_client_detail diff --git a/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp b/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp index 27d417bd4d..ad0cff7bff 100644 --- a/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp +++ b/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp @@ -35,7 +35,6 @@ constexpr auto REPORT_LIQ_YIELD_INSTRUCTION = "report_liq_yield"; constexpr std::string_view OP_DISPATCH_ATTESTATIONS = "deliver_outbound_envelope:dispatch_attestations"; constexpr std::string_view OP_READ_LATEST = "read_inbound_envelope:get_account_info"; -constexpr std::string_view OP_UW_COMMIT = "uw_commit:commit_underwrite"; /// Anchor seed literals for the outpost program's per-epoch PDAs. Byte-exact /// mirrors of the program's `EPOCH_DELIVERIES_SEED` / `ENVELOPE_CHUNKS_SEED` @@ -45,13 +44,6 @@ constexpr std::string_view OP_UW_COMMIT = "uw_commit:commit_underwrite"; constexpr std::string_view EPOCH_DELIVERIES_SEED = "epoch_deliveries"; constexpr std::string_view ENVELOPE_CHUNKS_SEED = "envelope_chunks"; -/// Anchor seed literals for the collateral-settlement PDAs. Byte-exact -/// mirrors of the program's `COLLATERAL_POSITION_SEED` / -/// `COLLATERAL_VAULT_SEED` constants (wire-solana `opp_states.rs`) -- a -/// one-character drift derives a well-formed WRONG PDA that only surfaces at -/// runtime as `EffectAccountMissing`, holding the dispatch cursor. -constexpr std::string_view COLLATERAL_POSITION_SEED = "collateral_position"; -constexpr std::string_view COLLATERAL_VAULT_SEED = "collateral_vault"; /// The liqSOL pool's seeds (wire-solana `liqsol-core`: `inbound.rs` and the /// `report_liq_yield` / `synd` account declarations), same lock-step caveat. constexpr std::string_view LIQSOL_GLOBAL_STATE_SEED = "outpost_global_state"; @@ -133,30 +125,6 @@ namespace epoch_deliveries { constexpr auto field_dispatched_count = "dispatched_count"; } // namespace epoch_deliveries -/// Field identifiers of the per-`(token_code, reserve_code)` `Reserve` -/// account. `custody_mint` / `custody_decimals` / `custody_token_program` are -/// pinned at reserve creation -/// and are what the on-chain terminal handlers branch on, so the relay's -/// manifest must read custody from HERE and nowhere else. -namespace reserve_account { - constexpr auto account_name = "Reserve"; - constexpr auto field_creator = "creator"; - constexpr auto field_custody_mint = "custody_mint"; - constexpr auto field_custody_decimals = "custody_decimals"; - constexpr auto field_custody_token_program = "custody_token_program"; -} // namespace reserve_account - -/// Field identifiers of the per-`(operator, token_code)` collateral position. -/// Its custody mint is pinned at first deposit and is what the on-chain -/// settlement handlers branch on. -namespace collateral_position { - constexpr auto account_name = "CollateralPosition"; - constexpr auto field_operator = "operator"; - constexpr auto field_token_code = "token_code"; - constexpr auto field_custody_mint = "custody_mint"; - constexpr auto field_amount = "amount"; -} // namespace collateral_position - /// `DistributionState` -- the liqSOL pool singleton `handle_desyndicate_liq` /// binds the pool's mint from. The relay decodes only `liqsol_mint`. namespace distribution_state { @@ -308,98 +276,6 @@ void assert_epoch_deliveries_shape(const fc::network::solana::idl::program& prog epoch_deliveries::field_dispatched_count); } -/// Assert the loaded IDL declares `CollateralPosition` with the four fields -/// its on-chain settlement path binds together. Full contract on the header -/// declaration. -void assert_collateral_position_shape(const fc::network::solana::idl::program& program) { - namespace idl = fc::network::solana::idl; - const auto& fields = declared_account_fields(program, collateral_position::account_name); - - bool has_operator = false; - bool has_token_code = false; - bool has_mint = false; - bool has_amount = false; - for (const auto& field : fields) { - const bool is_pubkey = - field.type.is_primitive() && field.type.primitive == idl::primitive_type::pubkey; - const bool is_u64 = - field.type.is_primitive() && field.type.primitive == idl::primitive_type::u64; - if (field.name == collateral_position::field_operator) { - FC_ASSERT(is_pubkey, "CollateralPosition '{}' must be declared pubkey, got '{}'", - collateral_position::field_operator, describe_idl_type(field.type)); - has_operator = true; - } else if (field.name == collateral_position::field_token_code) { - FC_ASSERT(is_u64, "CollateralPosition '{}' must be declared u64, got '{}'", - collateral_position::field_token_code, describe_idl_type(field.type)); - has_token_code = true; - } else if (field.name == collateral_position::field_custody_mint) { - FC_ASSERT(is_pubkey, "CollateralPosition '{}' must be declared pubkey, got '{}'", - collateral_position::field_custody_mint, describe_idl_type(field.type)); - has_mint = true; - } else if (field.name == collateral_position::field_amount) { - FC_ASSERT(is_u64, "CollateralPosition '{}' must be declared u64, got '{}'", - collateral_position::field_amount, describe_idl_type(field.type)); - has_amount = true; - } - } - FC_ASSERT(has_operator && has_token_code && has_mint && has_amount, - "CollateralPosition IDL missing '{}' / '{}' / '{}' / '{}' " - "(found {} / {} / {} / {}); a live collateral position's pinned custody cannot be " - "resolved safely without this declaration", - collateral_position::field_operator, collateral_position::field_token_code, - collateral_position::field_custody_mint, collateral_position::field_amount, - has_operator, has_token_code, has_mint, has_amount); -} - -/// Assert the loaded IDL declares `Reserve` with the four fields the terminal -/// manifest resolves from it. Full contract on the header declaration. -void assert_reserve_shape(const fc::network::solana::idl::program& program) { - namespace idl = fc::network::solana::idl; - const auto& fields = declared_account_fields(program, reserve_account::account_name); - - bool has_creator = false; - bool has_mint = false; - bool has_decimals = false; - bool has_token_program = false; - for (const auto& field : fields) { - // Compare against the optional member directly, as the sibling shape - // checks do: a malformed type object must reach the per-field diagnostic - // rather than `get_primitive()`'s generic throw. - const bool is_pubkey = - field.type.is_primitive() && field.type.primitive == idl::primitive_type::pubkey; - if (field.name == reserve_account::field_creator) { - FC_ASSERT(is_pubkey, "Reserve '{}' must be declared pubkey, got '{}'", - reserve_account::field_creator, describe_idl_type(field.type)); - has_creator = true; - } else if (field.name == reserve_account::field_custody_mint) { - FC_ASSERT(is_pubkey, "Reserve '{}' must be declared pubkey, got '{}'", - reserve_account::field_custody_mint, describe_idl_type(field.type)); - has_mint = true; - } else if (field.name == reserve_account::field_custody_decimals) { - FC_ASSERT(field.type.is_primitive() && field.type.primitive == idl::primitive_type::u8, - "Reserve '{}' must be declared u8, got '{}'", - reserve_account::field_custody_decimals, describe_idl_type(field.type)); - has_decimals = true; - } else if (field.name == reserve_account::field_custody_token_program) { - FC_ASSERT(is_pubkey, "Reserve '{}' must be declared pubkey, got '{}'", - reserve_account::field_custody_token_program, describe_idl_type(field.type)); - has_token_program = true; - } - } - // One message for all four: they are written together at reserve creation, - // and any one missing costs the manifest the same way -- the effect account - // the program's branch requires cannot be derived, so its dispatch window - // aborts and the cursor never moves past it. - FC_ASSERT(has_creator && has_mint && has_decimals && has_token_program, - "Reserve IDL missing '{}' / '{}' / '{}' / '{}' (found {} / {} / {} / {}); the terminal " - "manifest resolves custody, the cancel-refund target and the ATA-deriving token " - "program from these, and a reserve-backed dispatch window cannot be built without them", - reserve_account::field_creator, reserve_account::field_custody_mint, - reserve_account::field_custody_decimals, - reserve_account::field_custody_token_program, - has_creator, has_mint, has_decimals, has_token_program); -} - /// Keep only the candidate IDLs whose declared address matches the deployed /// program id, so `--solana-idl-file` ORDER can never decide which same-named /// IDL version's field order drives account decoding. Full contract on the @@ -612,45 +488,6 @@ std::vector pubkey_seed(const fc::network::solana::solana_public_key& k return std::vector(key._data.begin(), key._data.end()); } -} // anonymous namespace (within outpost_solana_client_detail) - -/// Derive the per-reserve `Reserve` PDA. Full contract on the header -/// declaration. -fc::network::solana::solana_public_key derive_reserve_pda( - const fc::network::solana::solana_public_key& program_id, - uint64_t token_code, - uint64_t reserve_code) { - return fc::network::solana::system::find_program_address( - {std::vector{'r','e','s','e','r','v','e'}, - u64_seed(token_code), - u64_seed(reserve_code)}, - program_id).first; -} - -/// Derive the per-reserve `reserve_vault` PDA. Full contract on the header -/// declaration. -fc::network::solana::solana_public_key derive_reserve_vault_pda( - const fc::network::solana::solana_public_key& program_id, - uint64_t token_code, - uint64_t reserve_code) { - return fc::network::solana::system::find_program_address( - {std::vector{'r','e','s','e','r','v','e','_','v','a','u','l','t'}, - u64_seed(token_code), - u64_seed(reserve_code)}, - program_id).first; -} - -/// Derive the per-`(operator, token_code)` `CollateralPosition` PDA. Full -/// contract on the header declaration. -fc::network::solana::solana_public_key derive_collateral_position_pda( - const fc::network::solana::solana_public_key& program_id, - const fc::network::solana::solana_public_key& operator_key, - uint64_t token_code) { - return fc::network::solana::system::find_program_address( - {std::vector(COLLATERAL_POSITION_SEED.begin(), COLLATERAL_POSITION_SEED.end()), - pubkey_seed(operator_key), - u64_seed(token_code)}, - program_id).first; } namespace { @@ -821,7 +658,6 @@ fc::network::solana::account_overrides_t report_liq_yield_overrides( }; } - // ── Token-2022 transfer-hook resolution (SOL-396 lock-step) ───────────────── // // Layout constants are the on-chain ones, not guesses: @@ -1068,17 +904,6 @@ resolve_hook_metas(const std::vector& metas, return resolved; } -/// Derive the per-`token_code` `collateral_vault` PDA. Full contract on the -/// header declaration. -fc::network::solana::solana_public_key derive_collateral_vault_pda( - const fc::network::solana::solana_public_key& program_id, - uint64_t token_code) { - return fc::network::solana::system::find_program_address( - {std::vector(COLLATERAL_VAULT_SEED.begin(), COLLATERAL_VAULT_SEED.end()), - u64_seed(token_code)}, - program_id).first; -} - uint32_t count_inbound_attestations(const std::vector& envelope_bytes) { sysio::opp::Envelope env; if (!env.ParseFromArray(envelope_bytes.data(), @@ -1120,78 +945,6 @@ extract_inbound_effects(const std::vector& envelope_bytes) { for (const auto& entry : message.payload().attestations()) { const size_t at = index++; switch (entry.type()) { - // The collateral-settling operator actions (SOL-379/380). Both - // resolve the per-(operator, token_code) CollateralPosition PDA - // out of remaining_accounts, so both carry the amount's - // token_code alongside the operator key. - case sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION: { - sysio::opp::attestations::OperatorAction oa; - if (!oa.ParseFromString(entry.data())) continue; - std::optional shape; - if (oa.action_type() == - sysio::opp::attestations::OperatorAction_ActionType_ACTION_TYPE_WITHDRAW_REMIT) { - shape = effect_shape::withdraw_remit; - } else if (oa.action_type() == - sysio::opp::attestations::OperatorAction_ActionType_ACTION_TYPE_SLASH) { - shape = effect_shape::slash; - } - if (!shape.has_value()) continue; - if (auto pk = sol_pubkey_from_chain_address(oa.op_address())) { - effects.push_back(inbound_effect{ - at, *shape, *pk, std::nullopt, oa.amount().token_code()}); - } - break; - } - case sysio::opp::types::ATTESTATION_TYPE_DEPOSIT_REVERT: { - sysio::opp::attestations::DepositRevert dr; - if (!dr.ParseFromString(entry.data())) continue; - if (auto pk = sol_pubkey_from_chain_address(dr.depositor())) { - effects.push_back(inbound_effect{ - at, effect_shape::deposit_revert, *pk, std::nullopt, - dr.refund_amount().token_code()}); - } - break; - } - case sysio::opp::types::ATTESTATION_TYPE_SWAP_REMIT: { - sysio::opp::attestations::SwapRemit sr; - if (!sr.ParseFromString(entry.data())) continue; - effects.push_back(inbound_effect{ - at, effect_shape::swap_remit, - sol_pubkey_from_chain_address(sr.recipient()), - reserve_pda_seeds{sr.amount().token_code(), sr.reserve_code()}}); - break; - } - case sysio::opp::types::ATTESTATION_TYPE_SWAP_REVERT: { - sysio::opp::attestations::SwapRevert sr; - if (!sr.ParseFromString(entry.data())) continue; - effects.push_back(inbound_effect{ - at, effect_shape::swap_revert, - sol_pubkey_from_chain_address(sr.depositor()), - reserve_pda_seeds{sr.refund_amount().token_code(), sr.source_reserve_code()}}); - break; - } - // The reserve-lifecycle round-trips need the per-(token, reserve) - // Reserve PDA too: `handle_reserve_ready` flips its status field, - // and `handle_reserve_create_cancelled` reads the refund - // amount/creator off it. RESERVE_READY rides exactly ONE envelope - // (queued once at `matchreserve`), so a missing PDA does not defer - // the flip -- it strands the reserve in PENDING permanently. - case sysio::opp::types::ATTESTATION_TYPE_RESERVE_READY: { - sysio::opp::attestations::ReserveReady rr; - if (!rr.ParseFromString(entry.data())) continue; - effects.push_back(inbound_effect{ - at, effect_shape::reserve_ready, std::nullopt, - reserve_pda_seeds{rr.token_code(), rr.reserve_code()}}); - break; - } - case sysio::opp::types::ATTESTATION_TYPE_RESERVE_CREATE_CANCELLED: { - sysio::opp::attestations::ReserveCreateCancelled rcc; - if (!rcc.ParseFromString(entry.data())) continue; - effects.push_back(inbound_effect{ - at, effect_shape::reserve_create_cancelled, std::nullopt, - reserve_pda_seeds{rcc.token_code(), rcc.reserve_code()}}); - break; - } // The depot releasing a user's syndicated liqSOL. The user's pubkey is // the only payload fact the manifest needs -- the pool's mint comes from // `DistributionState`, and the handler's own token_code check precedes @@ -1201,7 +954,7 @@ extract_inbound_effects(const std::vector& envelope_bytes) { if (!dl.ParseFromString(entry.data())) continue; if (auto pk = sol_pubkey_from_chain_address(dl.user())) { effects.push_back(inbound_effect{ - at, effect_shape::desyndicate_liq, *pk, std::nullopt, std::nullopt, dl.request_id()}); + at, effect_shape::desyndicate_liq, *pk, dl.request_id()}); } break; } @@ -1214,74 +967,16 @@ extract_inbound_effects(const std::vector& envelope_bytes) { return effects; } -reserve_terminal_info reserve_info_from_account(const fc::variant_object& reserve) { - // Required, not defaulted: a Reserve without custody is one this relay - // cannot build an account-consistent manifest for, and a defaulted - // (all-zero) mint would silently mean "native" -- the exact branch - // divergence that makes the on-chain call abort for good. - FC_ASSERT(reserve.contains(reserve_account::field_creator), - "Reserve account missing '{}' field", reserve_account::field_creator); - FC_ASSERT(reserve.contains(reserve_account::field_custody_mint), - "Reserve account missing '{}' field — custody must come from the Reserve the " - "on-chain handlers branch on, never from the mutable OutpostConfig token maps", - reserve_account::field_custody_mint); - FC_ASSERT(reserve.contains(reserve_account::field_custody_decimals), - "Reserve account missing '{}' field", reserve_account::field_custody_decimals); - FC_ASSERT(reserve.contains(reserve_account::field_custody_token_program), - "Reserve account missing '{}' field — the canonical ATA differs per token program, " - "so deriving with the legacy default would name an account the program never asks for", - reserve_account::field_custody_token_program); - - const auto decimals = reserve[reserve_account::field_custody_decimals].as_uint64(); - FC_ASSERT(decimals <= std::numeric_limits::max(), - "Reserve '{}' = {} is out of byte range", - reserve_account::field_custody_decimals, decimals); - - return reserve_terminal_info{ - fc::network::solana::solana_public_key::from_base58_string( - reserve[reserve_account::field_creator].as_string()), - fc::network::solana::solana_public_key::from_base58_string( - reserve[reserve_account::field_custody_mint].as_string()), - static_cast(decimals), - fc::network::solana::solana_public_key::from_base58_string( - reserve[reserve_account::field_custody_token_program].as_string())}; -} - std::vector> build_dispatch_manifests( const fc::network::solana::solana_public_key& program_id, const std::vector& effects, uint32_t total_attestations, const std::function& throw_if_past_deadline, - const reserve_info_reader& read_reserve_info, - const collateral_custody_reader& read_collateral_custody, const transfer_hook_reader& read_transfer_hook, const liq_pool_reader& read_liq_pool, - const fc::network::solana::solana_public_key& reserve_aggregate, const std::string& log_label) { - const auto& token_program_id = fc::network::solana::system::program_ids::TOKEN_PROGRAM; const auto& token_2022_program_id = fc::network::solana::system::program_ids::TOKEN_2022_PROGRAM; - const auto& system_program_id = fc::network::solana::system::program_ids::SYSTEM_PROGRAM; - - // `NATIVE_TOKEN_MARKER` on the program side: an all-zero custody mint means - // the reserve custodies lamports, which is the branch the handler takes. - auto is_native_custody = [&](const fc::network::solana::solana_public_key& mint) { - return mint == system_program_id; - }; - - // One read per DISTINCT reserve for the whole build -- the same reserve can - // back many attestations of one envelope, and a degraded (empty) read is - // memoised too so a permanently unreadable reserve costs exactly one - // round-trip, not one per attestation. - std::map, std::optional> reserve_cache; - auto reserve_info = [&](uint64_t token_code, - uint64_t reserve_code) -> const std::optional& { - const auto cache_key = std::make_pair(token_code, reserve_code); - auto it = reserve_cache.find(cache_key); - if (it != reserve_cache.end()) return it->second; - return reserve_cache.emplace(cache_key, read_reserve_info(token_code, reserve_code)) - .first->second; - }; // One read per DISTINCT custody mint for the whole build, matching the two // caches around it. Without this every reserve-backed SPL attestation pays a @@ -1299,22 +994,6 @@ std::vector> build_dispatch_manif return hook_cache.emplace(custody_mint, read_transfer_hook(custody_mint)).first->second; }; - // One read per DISTINCT collateral position for the whole build. Custody - // is pinned per `(operator, token_code)`, so both values are load-bearing - // cache keys; absent/empty reads are memoised too. - using collateral_key = std::pair; - std::map> collateral_custody_cache; - auto collateral_custody = - [&](const fc::network::solana::solana_public_key& operator_key, - uint64_t token_code) -> const std::optional& { - const auto cache_key = std::make_pair(operator_key, token_code); - auto it = collateral_custody_cache.find(cache_key); - if (it != collateral_custody_cache.end()) return it->second; - return collateral_custody_cache - .emplace(cache_key, read_collateral_custody(operator_key, token_code)) - .first->second; - }; - // One DistributionState read for the whole build -- the pool singleton is // the same for every DESYNDICATE_LIQ in the envelope -- memoised like the // caches above, an absent read included. @@ -1347,49 +1026,6 @@ std::vector> build_dispatch_manif record_terminal_account(metas, key, is_writable); }; - // Collateral-settling shapes (SOL-379/380). Every one resolves the - // per-(operator, token_code) `CollateralPosition` PDA out of - // remaining_accounts and settles in the asset the position escrows. - if (effect.shape == effect_shape::withdraw_remit || effect.shape == effect_shape::slash || - effect.shape == effect_shape::deposit_revert) { - if (!effect.recipient || !effect.collateral_token_code) continue; - const auto collateral_token_code = *effect.collateral_token_code; - // WITHDRAW_REMIT pays the operator and DEPOSIT_REVERT refunds the - // depositor; SLASH pays nobody directly -- its native seizure lands - // in the named `reserve_aggregate`. - if (effect.shape != effect_shape::slash) add(*effect.recipient, true); - add(derive_collateral_position_pda(program_id, *effect.recipient, - collateral_token_code), - true); - - const auto& custody_opt = collateral_custody(*effect.recipient, collateral_token_code); - if (!custody_opt.has_value()) continue; // custody lookup already wlogged - const auto& custody = *custody_opt; - if (is_native_custody(custody.mint)) continue; - - // SPL custody: the collateral vault drains into the destination's - // canonical ATA -- the operator's for WITHDRAW_REMIT, the - // depositor's for DEPOSIT_REVERT, the `reserve_aggregate`'s for - // SLASH. - // - // LOCK-STEP: which shapes settle SPL here must match the program's - // handlers (`resolve_collateral_vault_transfer` callers in - // wire-solana `inbound.rs`) exactly -- the relay's shape decisions - // are unversioned against the program, and a handler that - // `require_remaining_account`s an account this manifest omits aborts - // the dispatch round and re-packs the identical window from the same - // cursor on every retry. A program-side settlement-policy change and - // this manifest must move together. - const auto settlement_owner = - effect.shape == effect_shape::slash ? reserve_aggregate : *effect.recipient; - add(derive_collateral_vault_pda(program_id, collateral_token_code), true); - add(fc::network::solana::system::get_associated_token_address( - settlement_owner, custody.mint), - true); - add(token_program_id, false); - continue; - } - // DESYNDICATE_LIQ: the depot releases a user's syndicated liqSOL. Everything // derives from the user's pubkey, the program's fixed pool PDAs and the mint // pinned on `DistributionState` -- the account the handler itself binds the @@ -1467,143 +1103,6 @@ std::vector> build_dispatch_manif continue; } - if (!effect.reserve) continue; - - const auto token_code = effect.reserve->token_code; - const auto reserve_code = effect.reserve->reserve_code; - - // Every reserve-backed handler loads the Reserve PDA first. - add(derive_reserve_pda(program_id, token_code, reserve_code), true); - if (effect.shape == effect_shape::reserve_ready) continue; - - const auto vault_pda = derive_reserve_vault_pda(program_id, token_code, reserve_code); - - const auto& info_opt = reserve_info(token_code, reserve_code); - if (!info_opt.has_value()) { - // ONE cause reaches here: the Reserve account is ABSENT or EMPTY (see - // `reserve_info_for_codes`, which throws on every other cause rather - // than degrade). That case is benign by construction — the program's - // `load_reserve_from_remaining` finds the Reserve PDA we DO pass, - // fails to deserialize an uninitialized account, and returns - // `Ok(None)`, which every reserve-backed handler turns into a logged - // skip. No account we omit below is ever reached. - // - // The accounts are still passed because the reserve may be created - // between this read and the crank landing. If it is, this manifest is - // complete only for a NATIVE swap remit/revert (Reserve PDA + recipient); - // an SPL one is short the recipient ATA, the custody mint and the custody - // token program — all three are read off the Reserve we could not decode, - // and the ATA is not even derivable without the token program (SOL-396) — - // and a RESERVE_CREATE_CANCELLED is short the `creator` on EITHER custody - // kind, since the creator is read off the Reserve we could not decode. - // Those windows abort and the next tick rebuilds them against - // a now-readable Reserve. - // - // The degrade is PER-ATTESTATION: the manifests are built for every - // other attestation too, so the envelope's healthy PREFIX — the - // attestations ahead of this one in dispatch order — still settles. - // Nothing behind an aborting window can settle until it clears, since - // `drive_dispatch_rounds` repacks each window from the on-chain - // cursor. - wlog("outpost_solana_client[{}]: Reserve({}, {}) absent or empty while building the " - "terminal manifest for attestation {}; passing derivable accounts only -- the " - "handler will log-and-skip an uninitialized reserve", - log_label, token_code, reserve_code, effect.attestation_index); - add(vault_pda, true); - if (effect.recipient) add(*effect.recipient, true); - // Best-effort only, and right solely for a legacy-SPL reserve: the real - // token program is pinned on the Reserve we could not read. Not the - // sanctioned derivation — see `custody_token_program` below. - add(token_program_id, false); - continue; - } - const auto& info = *info_opt; - - // SOL-396 LOCK-STEP: every reserve-backed SPL settlement routes through the - // program's `reserve_vault_transfer`, which requires BOTH the reserve's - // `custody_token_program` AND its `custody_mint` in remaining_accounts, and - // derives the destination ATA with that token program. Both facts come from - // the Reserve — never the legacy SPL-Token default — because the same - // (owner, mint) pair has different canonical ATAs under each program. - const auto& custody_token_program = info.custody_token_program; - const auto custody_ata = [&](const fc::network::solana::solana_public_key& owner) { - return fc::network::solana::system::get_associated_token_address( - owner, info.custody_mint, custody_token_program); - }; - - // SOL-396, one layer deeper: `reserve_vault_transfer` routes through - // `spl_token_2022::onchain::invoke_transfer_checked`, which for a mint - // carrying the TransferHook extension resolves the hook program, its - // validation PDA and every account that PDA declares OUT OF - // `remaining_accounts`. A hook mint whose extras are missing aborts the - // CPI before the transfer -- the same wedge, with the cursor pinned. - // - // No-op for every non-hook mint, which is all of them today: the reader - // returns nullopt and nothing is appended. - const auto add_hook_accounts = [&](const fc::network::solana::solana_public_key& source, - const fc::network::solana::solana_public_key& destination, - const fc::network::solana::solana_public_key& authority) { - // A legacy-SPL reserve cannot carry a Token-2022 extension, so there is - // no hook to find and no reason to pay a `get_account_info` -- nor to - // let a transient absent read fail the WHOLE build (the reader asserts - // rather than degrading). Gating here keeps that assert scoped to the - // mints where a hook is actually representable. - if (info.custody_token_program != token_2022_program_id) return; - const auto& hook = transfer_hook(info.custody_mint); - if (!hook.has_value()) return; - const auto validation_pda = - derive_extra_account_metas_pda(hook->program, info.custody_mint); - add(hook->program, false); - add(validation_pda, false); - for (const auto& meta : resolve_hook_metas(hook->declared, hook->program, source, - info.custody_mint, destination, authority, - validation_pda)) { - add(meta.key, meta.is_writable); - } - }; - - switch (effect.shape) { - case effect_shape::swap_remit: { - if (!effect.recipient) break; - if (is_native_custody(info.custody_mint)) { add(*effect.recipient, true); break; } - add(vault_pda, true); - add(custody_ata(*effect.recipient), true); - add_hook_accounts(vault_pda, custody_ata(*effect.recipient), vault_pda); - // The mint is REQUIRED here, not optional: `reserve_vault_transfer` - // `require_remaining_account`s it, and the CPI's `transfer_checked` - // needs the mint account to verify the decimals against — the decimals - // themselves come from the Reserve record, not from this account. - // Omitting it is what aborted every SPL swap remit with - // `EffectAccountMissing` after SOL-396 — the one reserve-backed shape - // that had never carried it. - add(info.custody_mint, false); - add(custody_token_program, false); - break; - } - case effect_shape::swap_revert: { - if (!effect.recipient) break; - if (is_native_custody(info.custody_mint)) { add(*effect.recipient, true); break; } - add(vault_pda, true); - add(info.custody_mint, false); - add(*effect.recipient, true); - add(custody_ata(*effect.recipient), true); - add(custody_token_program, false); - add_hook_accounts(vault_pda, custody_ata(*effect.recipient), vault_pda); - break; - } - case effect_shape::reserve_create_cancelled: { - if (is_native_custody(info.custody_mint)) { add(info.creator, true); break; } - add(vault_pda, true); - add(info.creator, false); - add(custody_ata(info.creator), true); - add(info.custody_mint, false); - add(custody_token_program, false); - add_hook_accounts(vault_pda, custody_ata(info.creator), vault_pda); - break; - } - default: - break; - } } return per_attestation; @@ -1761,18 +1260,6 @@ outpost_solana_client::outpost_solana_client( // a dlog -- the outpost would stop settling with no error anywhere. // Fail at boot instead, where the IDL is still fixable. outpost_solana_client_detail::assert_epoch_deliveries_shape(*_program_client->get_program()); - // Same reasoning one level down: the crank's manifests resolve custody - // and the cancel-refund target from `Reserve`, and a drifted declaration - // makes a LIVE reserve unreadable to this relay only. In flight that is - // unrecoverable -- the window aborts on the effect account it could not - // derive, and every later window repacks from the same cursor -- so it - // has to be a boot failure, not a first-drain surprise. - outpost_solana_client_detail::assert_reserve_shape(*_program_client->get_program()); - // A drifted CollateralPosition declaration makes a LIVE position's - // pinned custody unreadable and would wedge every collateral dispatch - // window on the same unadvanced cursor. - outpost_solana_client_detail::assert_collateral_position_shape( - *_program_client->get_program()); // `DistributionState` is read only for DESYNDICATE_LIQ manifests, and only // the integrated liqsol-core program declares it -- a standalone outpost // IDL has no syndicated pool to release from, and its DistributionState @@ -1850,70 +1337,6 @@ outpost_solana_client::read_epoch_dispatch_progress(uint32_t epoch_index) { return progress; } -std::optional -outpost_solana_client::reserve_info_for_codes(uint64_t token_code, uint64_t reserve_code) { - const auto reserve_pda = - outpost_solana_client_detail::derive_reserve_pda(_program_id, token_code, reserve_code); - const auto pda_label = reserve_pda.to_string(fc::yield_function_t{}); - - // The two ways this can fail are NOT interchangeable, and conflating them - // is what turns a bad reserve into a wedged epoch: - // - // * ABSENT / EMPTY -> degrade. The program's `load_reserve_from_remaining` - // fails to deserialize an uninitialized account and returns `Ok(None)`, - // which every reserve-backed handler turns into a logged skip. Passing a - // partial manifest costs nothing, because no omitted account is reached. - // - // * PRESENT BUT UNREADABLE BY US -> throw. The account exists and the - // PROGRAM decodes it fine (its Borsh is authoritative), so it takes the - // real branch and demands the accounts that branch needs -- the custody - // ATA for an SPL remit/revert, the `creator` for a cancel with an escrow - // to refund, on EITHER custody kind. A degraded manifest is then - // guaranteed to hit `require_remaining_account` -> EffectAccountMissing - // and abort. That is not a retryable blip: `drive_dispatch_rounds` - // repacks every window from the on-chain cursor, so the same unreadable - // attestation heads every future window and the epoch never closes. - // Failing the tick loudly leaves the cursor untouched and keeps the - // envelope re-drivable once the cause (realistically IDL drift, which - // `assert_reserve_shape` now catches at boot) is fixed. - // - // `get_account_info` sits OUTSIDE the try on purpose: an RPC or deadline - // exception is not a statement about this reserve, and `deadline_scope` - // MUST propagate so an over-deadline build stops instead of degrading every - // remaining attestation into an aborting manifest. - const auto account_info = _entry->client->get_account_info(reserve_pda); - if (!account_info.has_value() || account_info->data.empty()) { - wlog("outpost_solana_client[{}]: Reserve({}, {}) absent or empty at {}; terminal manifest " - "will omit branch-specific accounts for this reserve -- the handler log-and-skips an " - "uninitialized reserve", - to_string(), token_code, reserve_code, pda_label); - return std::nullopt; - } - - try { - const auto reserve_v = - _program_client->decode_account_info_data(reserve_account::account_name, account_info->data); - const auto& reserve = reserve_v.get_object(); - - // Custody is read from the RESERVE, never from the mutable - // `OutpostConfig` token maps: `handle_swap_remit`, `handle_swap_revert` - // and `handle_reserve_create_cancelled` all branch on - // `reserve.custody_mint`, so resolving it anywhere else lets an admin - // re-point of a token address between reserve creation and dispatch put - // the relay on the native branch while the program takes the SPL one -- - // the missing vault/ATA accounts then abort the call permanently. - return outpost_solana_client_detail::reserve_info_from_account(reserve); - } catch (const fc::exception& e) { - elog("outpost_solana_client[{}]: Reserve({}, {}) at {} EXISTS ({} bytes) but this relay " - "cannot read it; refusing to build a manifest the program is guaranteed to abort on. " - "The dispatch cursor is left untouched and this epoch cannot settle until the cause is " - "fixed -- check the loaded IDL against the deployed program: {}", - to_string(), token_code, reserve_code, pda_label, account_info->data.size(), - e.to_detail_string()); - throw; - } -} - std::string outpost_solana_client::send_dispatch_attestations( uint32_t epoch_index, uint32_t dispatch_limit, @@ -1933,8 +1356,6 @@ std::string outpost_solana_client::send_dispatch_attestations( {"outbound_message_buffer", _program_client->outbound_message_buffer_pda}, {"outbound_envelopes", _program_client->outbound_envelopes_pda}, {"latest_outbound_envelope", _program_client->latest_outbound_envelope_pda}, - {"vault", _program_client->vault_pda}, - {"reserve_aggregate", _program_client->reserve_pda}, }; const auto& instr = _program_client->get_idl("dispatch_attestations"); fc::network::solana::program_invoke_data_items params = { @@ -2010,17 +1431,10 @@ std::string outpost_solana_client::drain_dispatch( effects, total_attestations, [&] { throw_if_past_deadline(deadline_abs, OP_DISPATCH_ATTESTATIONS); }, - [&](uint64_t token_code, uint64_t reserve_code) { - return reserve_info_for_codes(token_code, reserve_code); - }, - [&](const fc::network::solana::solana_public_key& operator_key, uint64_t token_code) { - return collateral_position_custody(operator_key, token_code); - }, [&](const fc::network::solana::solana_public_key& custody_mint) { return mint_transfer_hook_for(custody_mint); }, [&] { return syndicated_liq_pool(); }, - _program_client->reserve_pda, to_string()); // Settlement is a SEPARATE instruction, driven from the on-chain cursor. @@ -2085,46 +1499,6 @@ outpost_solana_client::mint_transfer_hook_for( outpost_solana_client_detail::parse_extra_account_metas(validation_info->data)}; } -std::optional -outpost_solana_client::collateral_position_custody( - const fc::network::solana::solana_public_key& operator_key, uint64_t token_code) { - const auto position_pda = outpost_solana_client_detail::derive_collateral_position_pda( - _program_id, operator_key, token_code); - const auto pda_label = position_pda.to_string(fc::yield_function_t{}); - - // An RPC/deadline exception is not evidence that this position is absent, - // so the read deliberately sits outside the decode-only try/catch. - const auto account_info = _entry->client->get_account_info(position_pda); - if (!account_info.has_value() || account_info->data.empty()) { - wlog("outpost_solana_client[{}]: CollateralPosition({}, {}) absent or empty at {}; " - "terminal manifest will omit branch-specific accounts for this position -- the " - "handler log-and-skips an uninitialized position", - to_string(), operator_key.to_string(fc::yield_function_t{}), token_code, pda_label); - return std::nullopt; - } - - try { - const auto position_v = _program_client->decode_account_info_data( - collateral_position::account_name, account_info->data); - const auto& position = position_v.get_object(); - FC_ASSERT(position.contains(collateral_position::field_custody_mint), - "CollateralPosition account missing '{}' field", - collateral_position::field_custody_mint); - return outpost_solana_client_detail::token_custody_info{ - fc::network::solana::solana_public_key::from_base58_string( - position[collateral_position::field_custody_mint].as_string())}; - } catch (const fc::exception& e) { - elog("outpost_solana_client[{}]: CollateralPosition({}, {}) at {} EXISTS ({} bytes) but " - "this relay cannot read its pinned custody; refusing to build a manifest the program " - "is guaranteed to abort on. The dispatch cursor is left untouched and this epoch " - "cannot settle until the cause is fixed -- check the loaded IDL against the deployed " - "program: {}", - to_string(), operator_key.to_string(fc::yield_function_t{}), token_code, pda_label, - account_info->data.size(), e.to_detail_string()); - throw; - } -} - std::optional outpost_solana_client::syndicated_liq_pool() { const auto state_pda = @@ -2314,7 +1688,6 @@ std::string outpost_solana_client::deliver_outbound_envelope( return last_sig; } - std::vector outpost_solana_client::read_inbound_envelope( uint32_t epoch_index, fc::microseconds deadline) { @@ -2398,25 +1771,4 @@ std::vector outpost_solana_client::read_inbound_envelope( *_program_client, info->data, epoch_index, to_string()); } -std::string outpost_solana_client::uw_commit( - uint64_t uw_request_id, - const std::vector& uic_bytes, - fc::microseconds deadline) { - const auto deadline_abs = fc::time_point::now() + deadline; - fc::task::deadline_scope rpc_deadline(deadline_abs); - - throw_if_past_deadline(deadline_abs, OP_UW_COMMIT); - - // Submit the original canonical UIC bytes unchanged. The on-chain - // `commit_underwrite` handler decodes them and binds the signed SVM caller - // plus claimed ACTIVE roster identity before relaying them. The typed - // wrapper supplies the IDL-derived signer, operator-registry, and outbound - // message-buffer accounts; the underwriter does not override that list. - std::vector uic_bytes_u8(uic_bytes.begin(), uic_bytes.end()); - auto signature = _program_client->commit_underwrite(std::move(uic_bytes_u8)); - ilog("outpost_solana_client[{}]: uw_commit confirmed uwreq={} sig={} bytes={}", - to_string(), uw_request_id, signature, uic_bytes.size()); - return signature; -} - } // namespace sysio diff --git a/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp b/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp index bc5b922d37..78b07b9508 100644 --- a/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp +++ b/plugins/outpost_solana_client_plugin/test/test_outpost_solana_client_plugin.cpp @@ -227,7 +227,6 @@ transaction build_measured_legacy_transaction(const std::vector& in return tx; } - } // anonymous namespace BOOST_AUTO_TEST_SUITE(outpost_solana_client_plugin) @@ -280,7 +279,7 @@ BOOST_AUTO_TEST_CASE(authenticated_caller_address_returns_configured_signer_pubk {load_idl_fixture(opp_outpost_idl_fixture)}, 1, 1, - sysio::solana_outpost_role::underwriter); + sysio::solana_outpost_role::batch_operator); const auto expected_caller = sol_client->get_pubkey().serialize(); const auto actual_caller = outpost.authenticated_caller_address(); @@ -432,7 +431,6 @@ BOOST_AUTO_TEST_CASE(can_load_counter_anchor_idl) try { BOOST_CHECK(has_initialize); BOOST_CHECK(has_increment); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(can_load_opp_outpost_idl) try { auto prog = load_idl_fixture(opp_outpost_idl_fixture); @@ -444,7 +442,6 @@ BOOST_AUTO_TEST_CASE(can_load_opp_outpost_idl) try { BOOST_CHECK(has_epoch_in); BOOST_CHECK(has_emit); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(filter_outpost_program_idls_selects_configured_name) try { std::vector>> idl_files; idl_files.emplace_back("counter.json", @@ -469,97 +466,15 @@ BOOST_AUTO_TEST_CASE(filter_outpost_program_idls_selects_configured_name) try { BOOST_CHECK(sysio::filter_outpost_program_idls(idl_files, "liqsol_core").empty()); BOOST_CHECK(sysio::filter_outpost_program_idls({}, sysio::OPP_SOLANA_OUTPOST_PROGRAM_NAME).empty()); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(outpost_program_name_default_is_opp_outpost) try { // The option default is back-compat load-bearing: pre-cleanroom deployments // pass no --solana-outpost-program-name and must keep matching opp_outpost. BOOST_CHECK_EQUAL(std::string{sysio::OPP_SOLANA_OUTPOST_PROGRAM_NAME}, "opp_outpost"); } FC_LOG_AND_RETHROW(); - namespace { -/// A decoded `Reserve` account object as libfc's IDL decoder renders it: -/// pubkeys as base58 strings, `custody_decimals` as an integer. -fc::variant_object reserve_row(const solana_public_key& creator, - const solana_public_key& custody_mint, - unsigned custody_decimals, - const solana_public_key& custody_token_program = - system::program_ids::TOKEN_PROGRAM) { - return fc::mutable_variant_object("creator", creator.to_string(fc::yield_function_t{}))( - "custody_mint", custody_mint.to_string(fc::yield_function_t{}))( - "custody_decimals", custody_decimals)( - "custody_token_program", custody_token_program.to_string(fc::yield_function_t{})); } -} // anonymous namespace - -BOOST_AUTO_TEST_CASE(reserve_info_reads_custody_from_the_reserve_account) try { - using sysio::outpost_solana_client_detail::reserve_info_from_account; - - const auto creator = measurement_pubkey(76); - const auto spl_mint = measurement_pubkey(77); - const auto native_marker = system::program_ids::SYSTEM_PROGRAM; - - // SPL custody: the mint and decimals PINNED on the reserve at creation -- - // the same two values `handle_swap_remit_spl` / `handle_swap_revert_spl` - // are handed off `reserve.custody_mint` / `reserve.custody_decimals`. - const auto spl = reserve_info_from_account(reserve_row(creator, spl_mint, 6)); - BOOST_CHECK(spl.creator == creator); - BOOST_CHECK(spl.custody_mint == spl_mint); - BOOST_CHECK_EQUAL(static_cast(spl.custody_decimals), 6u); - BOOST_CHECK(spl.custody_token_program == system::program_ids::TOKEN_PROGRAM); - - // SOL-396: a Token-2022 reserve must round-trip its OWN token program -- - // the ATA derivation and the token-program account both hang off this, and - // silently substituting the legacy default names accounts the program never - // asks for (EffectAccountMissing, on every retry, forever). - const auto t22_program = measurement_pubkey(80); - const auto t22 = reserve_info_from_account(reserve_row(creator, spl_mint, 6, t22_program)); - BOOST_CHECK(t22.custody_token_program == t22_program); - - // Native custody is the all-zero `NATIVE_TOKEN_MARKER`, which is exactly - // what the handler compares against to take the lamport branch. - const auto native = reserve_info_from_account(reserve_row(creator, native_marker, 9)); - BOOST_CHECK(native.custody_mint == native_marker); - BOOST_CHECK_EQUAL(static_cast(native.custody_decimals), 9u); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(reserve_info_requires_every_custody_field) try { - using sysio::outpost_solana_client_detail::reserve_info_from_account; - - const auto creator = measurement_pubkey(78); - const auto spl_mint = measurement_pubkey(79); - - // A record missing ANY of the three is not one this relay can build an - // account-consistent manifest from. Defaulting custody_mint would silently - // mean "native" -- the divergence that makes the on-chain call abort. - BOOST_CHECK_THROW( - reserve_info_from_account(fc::mutable_variant_object( - "custody_mint", spl_mint.to_string(fc::yield_function_t{}))("custody_decimals", 6)), - fc::assert_exception); - BOOST_CHECK_THROW( - reserve_info_from_account(fc::mutable_variant_object( - "creator", creator.to_string(fc::yield_function_t{}))("custody_decimals", 6)), - fc::assert_exception); - BOOST_CHECK_THROW( - reserve_info_from_account(fc::mutable_variant_object( - "creator", creator.to_string(fc::yield_function_t{}))( - "custody_mint", spl_mint.to_string(fc::yield_function_t{}))), - fc::assert_exception); - // ...including the token program: without it the ATA is not derivable at all. - BOOST_CHECK_THROW( - reserve_info_from_account(fc::mutable_variant_object( - "creator", creator.to_string(fc::yield_function_t{}))( - "custody_mint", spl_mint.to_string(fc::yield_function_t{}))("custody_decimals", 6)), - fc::assert_exception); - BOOST_CHECK_THROW(reserve_info_from_account(fc::variant_object{fc::mutable_variant_object{}}), - fc::assert_exception); - // Out-of-byte-range decimals: a decoded row disagreeing with the on-chain - // u8 must be refused, not truncated. - BOOST_CHECK_THROW(reserve_info_from_account(reserve_row(creator, spl_mint, 256)), - fc::assert_exception); -} FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(opp_outpost_epoch_in_has_chunked_args) try { auto prog = load_idl_fixture(opp_outpost_idl_fixture); @@ -603,7 +518,7 @@ BOOST_AUTO_TEST_CASE(opp_outpost_epoch_in_has_chunked_args) try { BOOST_CHECK_EQUAL(dispatch->args[1].name, "dispatch_limit"); // No inbound_envelopes: the crank settles effects, it does not append the // inbound audit record -- that happens once, on the consensus-tipping call. - BOOST_CHECK_EQUAL(dispatch->accounts.size(), 11u); + BOOST_CHECK_EQUAL(dispatch->accounts.size(), 9u); BOOST_CHECK(dispatch->accounts[0].is_signer); BOOST_CHECK_EQUAL(dispatch->accounts[4].name, "chunk_buffer"); @@ -618,7 +533,6 @@ BOOST_AUTO_TEST_CASE(opp_outpost_epoch_in_has_chunked_args) try { BOOST_CHECK_EQUAL(epoch_in->accounts[5].name, "inbound_envelopes"); BOOST_CHECK_EQUAL(epoch_in->accounts[6].name, "system_program"); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(opp_outpost_dispatch_attestations_present) try { // The relay's crank path requires the two-phase program: a deployment // whose IDL lacks `dispatch_attestations` must fail at BOOT (the @@ -637,13 +551,12 @@ BOOST_AUTO_TEST_CASE(opp_outpost_dispatch_attestations_present) try { // Accounts: caller (permissionless signer), then the same static outpost // account list the relay overrides by name in send_dispatch_attestations. - BOOST_REQUIRE_EQUAL(dispatch->accounts.size(), 11u); + BOOST_REQUIRE_EQUAL(dispatch->accounts.size(), 9u); BOOST_CHECK_EQUAL(dispatch->accounts[0].name, "caller"); BOOST_CHECK(dispatch->accounts[0].is_signer); BOOST_CHECK_EQUAL(dispatch->accounts[3].name, "epoch_deliveries"); BOOST_CHECK_EQUAL(dispatch->accounts[4].name, "chunk_buffer"); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(opp_outpost_cleanup_envelope_chunks_present) try { auto prog = load_idl_fixture(opp_outpost_idl_fixture); @@ -664,7 +577,6 @@ BOOST_AUTO_TEST_CASE(opp_outpost_cleanup_envelope_chunks_present) try { BOOST_CHECK_EQUAL(cleanup->accounts[3].name, "chunk_buffer"); BOOST_CHECK_EQUAL(cleanup->accounts[4].name, "uploader"); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(epoch_in_full_data_chunk_fits_packet_limit) try { // A full data chunk must serialise under Solana's raw packet limit. Any // change to the `epoch_in` account list or argument tuple moves the fixed @@ -688,7 +600,6 @@ BOOST_AUTO_TEST_CASE(epoch_in_full_data_chunk_fits_packet_limit) try { const auto packet = build_measured_legacy_transaction(instructions, fee_payer).serialize(); BOOST_CHECK_LE(packet.size(), limits::PACKET_DATA_SIZE); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(dispatch_attestations_full_manifest_fits_packet_limit) try { // MAX_TERMINAL_DYNAMIC_ACCOUNTS is a BUDGET, and a budget nobody measures is // a guess. It counts only the `remaining_accounts` extras -- not the @@ -734,7 +645,6 @@ BOOST_AUTO_TEST_CASE(dispatch_attestations_full_manifest_fits_packet_limit) try << " extras serialises to " << packet.size() << " bytes"); BOOST_CHECK_LE(packet.size(), limits::PACKET_DATA_SIZE); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(envelope_chunk_count_math) try { // The relay derives `total_chunks` as `ceil(total / MAX_CHUNK_BYTES)`. // Verify the arithmetic at sentinel sizes: empty (rejected by the relay @@ -768,7 +678,6 @@ BOOST_AUTO_TEST_CASE(envelope_chunk_count_math) try { const size_t last_chunk_size = 2526 - 3 * sysio::SOLANA_MAX_CHUNK_BYTES; BOOST_CHECK_EQUAL(last_chunk_size, 522u); // 2526 - 2004 = 522 } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(opp_outpost_emit_has_wire_epoch_arg) try { auto prog = load_idl_fixture(opp_outpost_idl_fixture); @@ -780,8 +689,7 @@ BOOST_AUTO_TEST_CASE(opp_outpost_emit_has_wire_epoch_arg) try { BOOST_CHECK_EQUAL(emit->args.size(), 1u); BOOST_CHECK_EQUAL(emit->args[0].name, "wire_epoch_index"); } FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(opp_outpost_has_initialize_and_deposit) try { +BOOST_AUTO_TEST_CASE(opp_outpost_excludes_retired_instruction_entrypoints) try { auto prog = load_idl_fixture(opp_outpost_idl_fixture); // `add_attestation` was retired from the client (attestations flow through @@ -794,9 +702,8 @@ BOOST_AUTO_TEST_CASE(opp_outpost_has_initialize_and_deposit) try { } BOOST_CHECK(has_initialize); BOOST_CHECK(!has_add); - BOOST_CHECK(has_deposit); + BOOST_CHECK(!has_deposit); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(borsh_encode_u32_roundtrip) try { borsh::encoder enc; enc.write_u32(42); @@ -806,7 +713,6 @@ BOOST_AUTO_TEST_CASE(borsh_encode_u32_roundtrip) try { uint32_t val = dec.read_u32(); BOOST_CHECK_EQUAL(val, 42u); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(borsh_encode_bytes_roundtrip) try { std::vector test_data = {0x12, 0x0c, 0x0a, 0x04, 0x08}; borsh::encoder enc; @@ -819,7 +725,6 @@ BOOST_AUTO_TEST_CASE(borsh_encode_bytes_roundtrip) try { auto decoded = dec.read_bytes(); BOOST_CHECK(decoded == test_data); } FC_LOG_AND_RETHROW(); - // ── extract_inbound_effects: the single authoritative envelope decode. // Walks an inbound envelope's attestations in dispatch order and // surfaces one `inbound_effect` per account-needing attestation -- @@ -866,29 +771,30 @@ std::vector envelope_with_entries( /// `op_addr`, remitting `token_code`. The decoder reads `op_address`, /// `action_type` and `amount.token_code` (SOL-379/380 keys the /// CollateralPosition PDA on it); other proto fields stay neutral. -sysio::opp::AttestationEntry remit_entry(uint64_t token_code, - const sysio::opp::types::ChainAddress& op_addr) { - sysio::opp::attestations::OperatorAction oa; - oa.set_action_type(sysio::opp::attestations::OperatorAction_ActionType_ACTION_TYPE_WITHDRAW_REMIT); - oa.mutable_amount()->set_token_code(token_code); - oa.mutable_amount()->set_amount(777); - *oa.mutable_op_address() = op_addr; +constexpr uint64_t default_desyndication_request_id = 1; + +sysio::opp::AttestationEntry desyndicate_liq_entry(uint64_t token_code, + const sysio::opp::types::ChainAddress& user, + uint64_t request_id = default_desyndication_request_id) { + sysio::opp::attestations::DesyndicateLIQ dl; + dl.set_chain_code(900); + *dl.mutable_user() = user; + dl.mutable_amount()->set_token_code(token_code); + dl.mutable_amount()->set_amount(777); + dl.set_request_id(request_id); std::string body; - oa.SerializeToString(&body); + dl.SerializeToString(&body); sysio::opp::AttestationEntry entry; - entry.set_type(sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION); + entry.set_type(sysio::opp::types::ATTESTATION_TYPE_DESYNDICATE_LIQ); entry.set_data(std::move(body)); return entry; } -/// Same as `remit_entry` but for SLASH — surfaced with its own shape so the -/// manifest can declare the slashed operator's CollateralPosition PDA (and -/// the reserve_aggregate ATA under SPL custody). -sysio::opp::AttestationEntry slash_entry(uint64_t token_code, +sysio::opp::AttestationEntry remit_entry(uint64_t token_code, const sysio::opp::types::ChainAddress& op_addr) { sysio::opp::attestations::OperatorAction oa; - oa.set_action_type(sysio::opp::attestations::OperatorAction_ActionType_ACTION_TYPE_SLASH); + oa.set_action_type(sysio::opp::attestations::OperatorAction_ActionType_ACTION_TYPE_WITHDRAW_REMIT); oa.mutable_amount()->set_token_code(token_code); oa.mutable_amount()->set_amount(777); *oa.mutable_op_address() = op_addr; @@ -928,87 +834,6 @@ sysio::opp::AttestationEntry operators_mirror_entry() { return entry; } -/// Build a `DEPOSIT_REVERT` entry pointing at `depositor_addr`, refunding -/// `token_code` (keys the CollateralPosition PDA). -sysio::opp::AttestationEntry revert_entry(uint64_t token_code, - const sysio::opp::types::ChainAddress& depositor_addr) { - sysio::opp::attestations::DepositRevert dr; - dr.mutable_refund_amount()->set_token_code(token_code); - dr.mutable_refund_amount()->set_amount(888); - *dr.mutable_depositor() = depositor_addr; - std::string body; - dr.SerializeToString(&body); - - sysio::opp::AttestationEntry entry; - entry.set_type(sysio::opp::types::ATTESTATION_TYPE_DEPOSIT_REVERT); - entry.set_data(std::move(body)); - return entry; -} - -/// Build a `SWAP_REMIT` entry pointing at `recipient_addr`. -sysio::opp::AttestationEntry swap_remit_entry(uint64_t token_code, - uint64_t reserve_code, - const sysio::opp::types::ChainAddress& recipient_addr) { - sysio::opp::attestations::SwapRemit remit; - remit.mutable_amount()->set_token_code(token_code); - remit.mutable_amount()->set_amount(123); - remit.set_reserve_code(reserve_code); - *remit.mutable_recipient() = recipient_addr; - std::string body; - remit.SerializeToString(&body); - - sysio::opp::AttestationEntry entry; - entry.set_type(sysio::opp::types::ATTESTATION_TYPE_SWAP_REMIT); - entry.set_data(std::move(body)); - return entry; -} - -/// Build a `SWAP_REVERT` entry pointing at `depositor_addr`. -sysio::opp::AttestationEntry swap_revert_entry(uint64_t token_code, - uint64_t reserve_code, - const sysio::opp::types::ChainAddress& depositor_addr) { - sysio::opp::attestations::SwapRevert revert; - revert.mutable_refund_amount()->set_token_code(token_code); - revert.mutable_refund_amount()->set_amount(456); - revert.set_source_reserve_code(reserve_code); - *revert.mutable_depositor() = depositor_addr; - std::string body; - revert.SerializeToString(&body); - - sysio::opp::AttestationEntry entry; - entry.set_type(sysio::opp::types::ATTESTATION_TYPE_SWAP_REVERT); - entry.set_data(std::move(body)); - return entry; -} - -/// Build a `RESERVE_READY` entry. -sysio::opp::AttestationEntry reserve_ready_entry(uint64_t token_code, uint64_t reserve_code) { - sysio::opp::attestations::ReserveReady ready; - ready.set_token_code(token_code); - ready.set_reserve_code(reserve_code); - std::string body; - ready.SerializeToString(&body); - - sysio::opp::AttestationEntry entry; - entry.set_type(sysio::opp::types::ATTESTATION_TYPE_RESERVE_READY); - entry.set_data(std::move(body)); - return entry; -} - -/// Build a `RESERVE_CREATE_CANCELLED` entry. -sysio::opp::AttestationEntry reserve_create_cancelled_entry(uint64_t token_code, uint64_t reserve_code) { - sysio::opp::attestations::ReserveCreateCancelled cancelled; - cancelled.set_token_code(token_code); - cancelled.set_reserve_code(reserve_code); - std::string body; - cancelled.SerializeToString(&body); - - sysio::opp::AttestationEntry entry; - entry.set_type(sysio::opp::types::ATTESTATION_TYPE_RESERVE_CREATE_CANCELLED); - entry.set_data(std::move(body)); - return entry; -} - std::array filled_pubkey(uint8_t byte) { std::array arr{}; arr.fill(byte); @@ -1022,39 +847,6 @@ BOOST_AUTO_TEST_CASE(extract_effects_empty_envelope_returns_empty) try { auto effects = sysio::outpost_solana_client_detail::extract_inbound_effects(envelope); BOOST_CHECK(effects.empty()); } FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(extract_effects_single_withdraw_remit) try { - namespace detail = sysio::outpost_solana_client_detail; - auto op_pk = filled_pubkey(0xAA); - auto envelope = envelope_with_entries({remit_entry(501, make_sol_addr(op_pk))}); - - const auto effects = detail::extract_inbound_effects(envelope); - BOOST_REQUIRE_EQUAL(effects.size(), 1u); - BOOST_CHECK_EQUAL(effects[0].attestation_index, 0u); - BOOST_CHECK(effects[0].shape == detail::effect_shape::withdraw_remit); - BOOST_REQUIRE(effects[0].recipient.has_value()); - BOOST_CHECK(effects[0].recipient->serialize() == op_pk); - BOOST_CHECK(!effects[0].reserve.has_value()); - // SOL-379: the remit amount's token_code keys the CollateralPosition PDA. - BOOST_REQUIRE(effects[0].collateral_token_code.has_value()); - BOOST_CHECK_EQUAL(*effects[0].collateral_token_code, 501u); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(extract_effects_deposit_revert) try { - namespace detail = sysio::outpost_solana_client_detail; - auto depositor_pk = filled_pubkey(0xBB); - auto envelope = envelope_with_entries({revert_entry(503, make_sol_addr(depositor_pk))}); - - const auto effects = detail::extract_inbound_effects(envelope); - BOOST_REQUIRE_EQUAL(effects.size(), 1u); - BOOST_CHECK(effects[0].shape == detail::effect_shape::deposit_revert); - BOOST_REQUIRE(effects[0].recipient.has_value()); - BOOST_CHECK(effects[0].recipient->serialize() == depositor_pk); - // SOL-379: the refund amount's token_code keys the CollateralPosition PDA. - BOOST_REQUIRE(effects[0].collateral_token_code.has_value()); - BOOST_CHECK_EQUAL(*effects[0].collateral_token_code, 503u); -} FC_LOG_AND_RETHROW(); - /// The effect index MUST equal the attestation's flat position in dispatch /// order, counting attestations that need no effect accounts. /// @@ -1075,9 +867,9 @@ BOOST_AUTO_TEST_CASE(extract_effects_indices_track_dispatch_order) try { // index 1 on chain, so the entries either side must report 0 and 2, not // 0 and 1. auto envelope = envelope_with_entries({ - remit_entry(601, make_sol_addr(remit_op)), + desyndicate_liq_entry(601, make_sol_addr(remit_op)), operators_mirror_entry(), - revert_entry(602, make_sol_addr(revert_op)), + desyndicate_liq_entry(602, make_sol_addr(revert_op)), }); BOOST_CHECK_EQUAL(detail::count_inbound_attestations(envelope), 3u); @@ -1086,16 +878,15 @@ BOOST_AUTO_TEST_CASE(extract_effects_indices_track_dispatch_order) try { BOOST_REQUIRE_EQUAL(effects.size(), 2u); BOOST_CHECK_EQUAL(effects[0].attestation_index, 0u); - BOOST_CHECK(effects[0].shape == detail::effect_shape::withdraw_remit); + BOOST_CHECK(effects[0].shape == detail::effect_shape::desyndicate_liq); BOOST_REQUIRE(effects[0].recipient.has_value()); BOOST_CHECK(effects[0].recipient->serialize() == remit_op); BOOST_CHECK_EQUAL(effects[1].attestation_index, 2u); - BOOST_CHECK(effects[1].shape == detail::effect_shape::deposit_revert); + BOOST_CHECK(effects[1].shape == detail::effect_shape::desyndicate_liq); BOOST_REQUIRE(effects[1].recipient.has_value()); BOOST_CHECK(effects[1].recipient->serialize() == revert_op); } FC_LOG_AND_RETHROW(); - /// An envelope the decoder cannot parse yields no effects AND a zero count, /// so the relay submits a terminal call claiming nothing rather than one /// claiming attestations whose accounts it never derived. @@ -1105,7 +896,6 @@ BOOST_AUTO_TEST_CASE(extract_effects_on_undecodable_envelope_is_empty) try { BOOST_CHECK_EQUAL(detail::count_inbound_attestations(garbage), 0u); BOOST_CHECK(detail::extract_inbound_effects(garbage).empty()); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(extract_effects_repeated_recipient_yields_one_entry_per_attestation) try { namespace detail = sysio::outpost_solana_client_detail; auto op_pk = filled_pubkey(0xCC); @@ -1117,8 +907,8 @@ BOOST_AUTO_TEST_CASE(extract_effects_repeated_recipient_yields_one_entry_per_att // batch's manifests union (pinned by // record_terminal_account_dedupes_and_merges_writable). auto envelope = envelope_with_entries({ - remit_entry(600, make_sol_addr(op_pk)), - remit_entry(600, make_sol_addr(op_pk)), + desyndicate_liq_entry(600, make_sol_addr(op_pk)), + desyndicate_liq_entry(600, make_sol_addr(op_pk)), }); const auto effects = detail::extract_inbound_effects(envelope); @@ -1128,60 +918,13 @@ BOOST_AUTO_TEST_CASE(extract_effects_repeated_recipient_yields_one_entry_per_att BOOST_CHECK(effects[0].recipient->serialize() == op_pk); BOOST_CHECK(effects[1].recipient->serialize() == op_pk); } FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(extract_effects_slash_carries_collateral_position_key) try { - namespace detail = sysio::outpost_solana_client_detail; - // SLASH MUST be surfaced (SOL-379/380): the handler resolves the slashed - // operator's per-(operator, token_code) CollateralPosition PDA out of - // remaining_accounts, and under SPL custody additionally the collateral - // vault + reserve_aggregate ATA. An omitted manifest entry would abort - // the dispatch call on-chain and stall the cursor on this attestation. - auto slash_op = filled_pubkey(0xDD); - auto remit_op = filled_pubkey(0xEE); - auto envelope = envelope_with_entries({ - slash_entry(502, make_sol_addr(slash_op)), - remit_entry(504, make_sol_addr(remit_op)), - }); - - const auto effects = detail::extract_inbound_effects(envelope); - BOOST_REQUIRE_EQUAL(effects.size(), 2u); - - BOOST_CHECK_EQUAL(effects[0].attestation_index, 0u); - BOOST_CHECK(effects[0].shape == detail::effect_shape::slash); - BOOST_REQUIRE(effects[0].recipient.has_value()); - BOOST_CHECK(effects[0].recipient->serialize() == slash_op); - BOOST_REQUIRE(effects[0].collateral_token_code.has_value()); - BOOST_CHECK_EQUAL(*effects[0].collateral_token_code, 502u); - - BOOST_CHECK_EQUAL(effects[1].attestation_index, 1u); - BOOST_CHECK(effects[1].shape == detail::effect_shape::withdraw_remit); -} FC_LOG_AND_RETHROW(); - namespace { /// The depot request id `desyndicate_liq_entry` stamps unless a case names one. -constexpr uint64_t default_desyndication_request_id = 1; /// Build a `DESYNDICATE_LIQ` entry releasing `token_code` to `user` under /// `request_id`. The decoder reads `user` and `request_id` -- the pool mint comes /// from `DistributionState`, not the payload -- so the other fields stay neutral. -sysio::opp::AttestationEntry desyndicate_liq_entry(uint64_t token_code, - const sysio::opp::types::ChainAddress& user, - uint64_t request_id = default_desyndication_request_id) { - sysio::opp::attestations::DesyndicateLIQ dl; - dl.set_chain_code(900); - *dl.mutable_user() = user; - dl.mutable_amount()->set_token_code(token_code); - dl.mutable_amount()->set_amount(777); - dl.set_request_id(request_id); - std::string body; - dl.SerializeToString(&body); - - sysio::opp::AttestationEntry entry; - entry.set_type(sysio::opp::types::ATTESTATION_TYPE_DESYNDICATE_LIQ); - entry.set_data(std::move(body)); - return entry; -} } // namespace @@ -1202,39 +945,17 @@ BOOST_AUTO_TEST_CASE(extract_effects_desyndicate_liq_carries_the_user) try { }); const auto effects = detail::extract_inbound_effects(envelope); - BOOST_REQUIRE_EQUAL(effects.size(), 2u); + BOOST_REQUIRE_EQUAL(effects.size(), 1u); BOOST_CHECK_EQUAL(effects[0].attestation_index, 0u); BOOST_CHECK(effects[0].shape == detail::effect_shape::desyndicate_liq); BOOST_REQUIRE(effects[0].recipient.has_value()); BOOST_CHECK(effects[0].recipient->serialize() == user); - BOOST_CHECK(!effects[0].reserve.has_value()); - BOOST_CHECK(!effects[0].collateral_token_code.has_value()); // The request id keys the pending-payout PDA a frozen outpost stores the payout at. BOOST_REQUIRE(effects[0].desyndication_request_id.has_value()); BOOST_CHECK_EQUAL(*effects[0].desyndication_request_id, request_id); - BOOST_CHECK_EQUAL(effects[1].attestation_index, 2u); - BOOST_CHECK(effects[1].shape == detail::effect_shape::withdraw_remit); - BOOST_CHECK(!effects[1].desyndication_request_id.has_value()); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(extract_effects_keeps_distinct_collateral_token_codes) try { - namespace detail = sysio::outpost_solana_client_detail; - // One operator withdrawing two different token_codes resolves TWO - // distinct CollateralPosition PDAs — each effect carries its own key. - auto op_pk = filled_pubkey(0x75); - auto envelope = envelope_with_entries({ - remit_entry(700, make_sol_addr(op_pk)), - remit_entry(701, make_sol_addr(op_pk)), - }); - - const auto effects = detail::extract_inbound_effects(envelope); - BOOST_REQUIRE_EQUAL(effects.size(), 2u); - BOOST_CHECK_EQUAL(*effects[0].collateral_token_code, 700u); - BOOST_CHECK_EQUAL(*effects[1].collateral_token_code, 701u); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(extract_effects_skips_non_settling_operator_actions) try { namespace detail = sysio::outpost_solana_client_detail; // A DEPOSIT_REQUEST is outbound-from-outpost — inbound it settles @@ -1246,23 +967,19 @@ BOOST_AUTO_TEST_CASE(extract_effects_skips_non_settling_operator_actions) try { }); const auto effects = detail::extract_inbound_effects(envelope); - BOOST_REQUIRE_EQUAL(effects.size(), 1u); - BOOST_CHECK_EQUAL(effects[0].attestation_index, 1u); - BOOST_CHECK(effects[0].shape == detail::effect_shape::withdraw_remit); + BOOST_CHECK(effects.empty()); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(extract_effects_skips_non_solana_chain) try { // A WITHDRAW_REMIT whose `op_address` carries kind=ETHEREUM is not // for this outpost and must not contribute a SOL effect entry. auto eth_bytes = filled_pubkey(0x01); auto envelope = envelope_with_entries({ - remit_entry(800, make_eth_addr_32(eth_bytes)), + desyndicate_liq_entry(800, make_eth_addr_32(eth_bytes)), }); auto effects = sysio::outpost_solana_client_detail::extract_inbound_effects(envelope); BOOST_CHECK(effects.empty()); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(extract_effects_skips_malformed_address_length) try { // 20-byte address with kind=SOLANA — the bytes pass the chain check // but fail the length check; the decoder must drop the entry rather @@ -1272,125 +989,11 @@ BOOST_AUTO_TEST_CASE(extract_effects_skips_malformed_address_length) try { std::vector short_addr(20, 0xAB); malformed.set_address(short_addr.data(), short_addr.size()); - auto envelope = envelope_with_entries({remit_entry(801, malformed)}); + auto envelope = envelope_with_entries({desyndicate_liq_entry(801, malformed)}); auto effects = sysio::outpost_solana_client_detail::extract_inbound_effects(envelope); BOOST_CHECK(effects.empty()); } FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(extract_effects_mixed_remit_and_revert_preserved_order) try { - namespace detail = sysio::outpost_solana_client_detail; - auto op_a = filled_pubkey(0x10); - auto depositor = filled_pubkey(0x20); - auto op_b = filled_pubkey(0x30); - auto envelope = envelope_with_entries({ - remit_entry(901, make_sol_addr(op_a)), - revert_entry(902, make_sol_addr(depositor)), - remit_entry(903, make_sol_addr(op_b)), - }); - - const auto effects = detail::extract_inbound_effects(envelope); - BOOST_REQUIRE_EQUAL(effects.size(), 3u); - BOOST_CHECK(effects[0].recipient->serialize() == op_a); - BOOST_CHECK(effects[1].recipient->serialize() == depositor); - BOOST_CHECK(effects[2].recipient->serialize() == op_b); - BOOST_CHECK_EQUAL(effects[0].attestation_index, 0u); - BOOST_CHECK_EQUAL(effects[1].attestation_index, 1u); - BOOST_CHECK_EQUAL(effects[2].attestation_index, 2u); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(extract_effects_swap_shapes_carry_wallets_and_reserve_seeds) try { - namespace detail = sysio::outpost_solana_client_detail; - auto swap_recipient = filled_pubkey(0x41); - auto swap_depositor = filled_pubkey(0x42); - auto withdraw_op = filled_pubkey(0x43); - auto envelope = envelope_with_entries({ - swap_remit_entry(10, 20, make_sol_addr(swap_recipient)), - swap_revert_entry(11, 21, make_sol_addr(swap_depositor)), - remit_entry(12, make_sol_addr(withdraw_op)), - }); - - const auto effects = detail::extract_inbound_effects(envelope); - BOOST_REQUIRE_EQUAL(effects.size(), 3u); - - // SWAP_REMIT: recipient wallet + (token, reserve) seeds for the - // Reserve/vault PDA derivations and the recipient ATA. Reserve-backed - // shapes carry no collateral key — custody rides the Reserve record. - BOOST_CHECK(effects[0].shape == detail::effect_shape::swap_remit); - BOOST_CHECK(effects[0].recipient->serialize() == swap_recipient); - BOOST_REQUIRE(effects[0].reserve.has_value()); - BOOST_CHECK_EQUAL(effects[0].reserve->token_code, 10u); - BOOST_CHECK_EQUAL(effects[0].reserve->reserve_code, 20u); - BOOST_CHECK(!effects[0].collateral_token_code.has_value()); - - // SWAP_REVERT: the wallet is the DEPOSITOR (the refund target). - BOOST_CHECK(effects[1].shape == detail::effect_shape::swap_revert); - BOOST_CHECK(effects[1].recipient->serialize() == swap_depositor); - BOOST_REQUIRE(effects[1].reserve.has_value()); - BOOST_CHECK_EQUAL(effects[1].reserve->token_code, 11u); - BOOST_CHECK_EQUAL(effects[1].reserve->reserve_code, 21u); - BOOST_CHECK(!effects[1].collateral_token_code.has_value()); - - BOOST_CHECK(effects[2].shape == detail::effect_shape::withdraw_remit); - BOOST_CHECK(effects[2].recipient->serialize() == withdraw_op); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(extract_effects_reserve_shapes_carry_seeds_per_attestation) try { - namespace detail = sysio::outpost_solana_client_detail; - auto recipient = filled_pubkey(0x51); - auto depositor = filled_pubkey(0x52); - // The repeated RESERVE_READY pair appears TWICE: the walk is - // per-attestation (the cursor counts both); the duplicate Reserve PDA - // merges later in `record_terminal_account`. - auto envelope = envelope_with_entries({ - swap_remit_entry(100, 200, make_sol_addr(recipient)), - swap_revert_entry(101, 201, make_sol_addr(depositor)), - reserve_ready_entry(102, 202), - reserve_create_cancelled_entry(103, 203), - reserve_ready_entry(102, 202), - }); - - const auto effects = detail::extract_inbound_effects(envelope); - BOOST_REQUIRE_EQUAL(effects.size(), 5u); - const std::array, 5> expected_seeds{{ - {100u, 200u}, {101u, 201u}, {102u, 202u}, {103u, 203u}, {102u, 202u}}}; - for (size_t i = 0; i < expected_seeds.size(); ++i) { - BOOST_REQUIRE(effects[i].reserve.has_value()); - BOOST_CHECK_EQUAL(effects[i].reserve->token_code, expected_seeds[i].first); - BOOST_CHECK_EQUAL(effects[i].reserve->reserve_code, expected_seeds[i].second); - } - BOOST_CHECK(effects[2].shape == detail::effect_shape::reserve_ready); - BOOST_CHECK(!effects[2].recipient.has_value()); - BOOST_CHECK(effects[3].shape == detail::effect_shape::reserve_create_cancelled); - BOOST_CHECK(!effects[3].recipient.has_value()); - BOOST_CHECK(effects[4].shape == detail::effect_shape::reserve_ready); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(extract_effects_reserve_create_cancelled_shape_selects_cancelled_only) try { - namespace detail = sysio::outpost_solana_client_detail; - auto recipient = filled_pubkey(0x53); - auto depositor = filled_pubkey(0x54); - auto envelope = envelope_with_entries({ - swap_remit_entry(110, 210, make_sol_addr(recipient)), - reserve_create_cancelled_entry(111, 211), - swap_revert_entry(112, 212, make_sol_addr(depositor)), - reserve_create_cancelled_entry(111, 211), - reserve_create_cancelled_entry(113, 213), - }); - - const auto effects = detail::extract_inbound_effects(envelope); - BOOST_REQUIRE_EQUAL(effects.size(), 5u); - std::vector> cancelled; - for (const auto& effect : effects) { - if (effect.shape != detail::effect_shape::reserve_create_cancelled) continue; - BOOST_REQUIRE(effect.reserve.has_value()); - cancelled.emplace_back(effect.reserve->token_code, effect.reserve->reserve_code); - } - const std::vector> expected{ - {111u, 211u}, {111u, 211u}, {113u, 213u}}; - BOOST_CHECK(cancelled == expected); -} FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(record_terminal_account_dedupes_and_merges_writable) try { std::vector metas; const solana_public_key readonly_key(filled_pubkey(0x55)); @@ -1414,7 +1017,6 @@ BOOST_AUTO_TEST_CASE(record_terminal_account_dedupes_and_merges_writable) try { BOOST_CHECK(!metas[1].is_signer); BOOST_CHECK(metas[1].is_writable); } FC_LOG_AND_RETHROW(); - // ── build_dispatch_manifests: the per-attestation account manifests the // dispatch crank packs its windows from, driven through its Reserve and // CollateralPosition read seams. These pin the properties the manifest @@ -1431,59 +1033,7 @@ namespace manifest_detail = sysio::outpost_solana_client_detail; /// as uninitialized accounts. Every call is recorded so memoization is /// assertable. struct manifest_build_harness { - using seeds = std::pair; - /// One collateral position's full `(operator, token_code)` PDA seed tuple. - using collateral_seeds = std::pair; - solana_public_key program_id = measurement_pubkey(42); - /// The named `reserve_aggregate` account — SPL slash seizures settle into - /// its canonical ATA. - solana_public_key reserve_aggregate = measurement_pubkey(43); - std::map records; - std::vector reads; - std::map collateral_records; - std::vector collateral_reads; - - void put(uint64_t token_code, uint64_t reserve_code, - const solana_public_key& creator, const solana_public_key& custody_mint, - uint8_t custody_decimals, - const solana_public_key& custody_token_program = - system::program_ids::TOKEN_PROGRAM) { - records.emplace(seeds{token_code, reserve_code}, - manifest_detail::reserve_terminal_info{creator, custody_mint, - custody_decimals, - custody_token_program}); - } - - /// Seed one position's pinned custody for the collateral reader. - void put_collateral(const solana_public_key& operator_key, uint64_t token_code, - const solana_public_key& custody_mint) { - collateral_records.emplace(collateral_seeds{operator_key, token_code}, - manifest_detail::token_custody_info{custody_mint}); - } - - manifest_detail::reserve_info_reader reader() { - return [this](uint64_t token_code, - uint64_t reserve_code) -> std::optional { - reads.emplace_back(token_code, reserve_code); - auto it = records.find(seeds{token_code, reserve_code}); - if (it == records.end()) return std::nullopt; - return it->second; - }; - } - - /// Scripted stand-in for `collateral_position_custody`: anything absent - /// from `collateral_records` reads as an absent-position degrade. - manifest_detail::collateral_custody_reader collateral_reader() { - return [this](const solana_public_key& operator_key, - uint64_t token_code) -> std::optional { - const auto key = collateral_seeds{operator_key, token_code}; - collateral_reads.emplace_back(key); - auto it = collateral_records.find(key); - if (it == collateral_records.end()) return std::nullopt; - return it->second; - }; - } /// Per-mint transfer-hook configuration. Empty by default: every mint in /// the system today is hook-free, so the manifest is unchanged unless a @@ -1534,129 +1084,17 @@ struct manifest_build_harness { uint32_t total_attestations, const std::function& deadline_probe = [] {}) { return manifest_detail::build_dispatch_manifests( - program_id, effects, total_attestations, deadline_probe, reader(), - collateral_reader(), hook_reader(), liq_pool_reader(), reserve_aggregate, "test-relay"); + program_id, effects, total_attestations, deadline_probe, + hook_reader(), liq_pool_reader(), "test-relay"); } }; -/// One SWAP_REMIT effect at `index` paying `recipient` out of `(token, reserve)`. -manifest_detail::inbound_effect swap_remit_effect(size_t index, uint64_t token_code, - uint64_t reserve_code, - const solana_public_key& recipient) { - return manifest_detail::inbound_effect{ - index, manifest_detail::effect_shape::swap_remit, recipient, - manifest_detail::reserve_pda_seeds{token_code, reserve_code}}; -} - -/// One SWAP_REVERT effect at `index` refunding `depositor` out of `(token, reserve)`. -manifest_detail::inbound_effect swap_revert_effect(size_t index, uint64_t token_code, - uint64_t reserve_code, - const solana_public_key& depositor) { - return manifest_detail::inbound_effect{ - index, manifest_detail::effect_shape::swap_revert, depositor, - manifest_detail::reserve_pda_seeds{token_code, reserve_code}}; -} - -/// One RESERVE_CREATE_CANCELLED effect at `index` for `(token, reserve)`; the -/// refund target is the creator read off the Reserve, not a carried recipient. -manifest_detail::inbound_effect reserve_create_cancelled_effect(size_t index, uint64_t token_code, - uint64_t reserve_code) { - return manifest_detail::inbound_effect{ - index, manifest_detail::effect_shape::reserve_create_cancelled, std::nullopt, - manifest_detail::reserve_pda_seeds{token_code, reserve_code}}; -} - -/// One WITHDRAW_REMIT effect at `index` paying `recipient` out of their -/// `token_code`-keyed `CollateralPosition`. -manifest_detail::inbound_effect withdraw_remit_effect(size_t index, uint64_t token_code, - const solana_public_key& recipient) { - return manifest_detail::inbound_effect{ - index, manifest_detail::effect_shape::withdraw_remit, recipient, std::nullopt, - token_code}; -} - -/// One SLASH effect at `index` seizing `operator_key`'s `token_code`-keyed -/// `CollateralPosition`. -manifest_detail::inbound_effect slash_effect(size_t index, uint64_t token_code, - const solana_public_key& operator_key) { - return manifest_detail::inbound_effect{ - index, manifest_detail::effect_shape::slash, operator_key, std::nullopt, token_code}; -} - -/// One DEPOSIT_REVERT effect at `index` refunding `depositor`'s -/// `token_code`-keyed `CollateralPosition`. -manifest_detail::inbound_effect deposit_revert_effect(size_t index, uint64_t token_code, - const solana_public_key& depositor) { - return manifest_detail::inbound_effect{ - index, manifest_detail::effect_shape::deposit_revert, depositor, std::nullopt, - token_code}; -} - bool manifest_has(const std::vector& metas, const solana_public_key& key) { return std::any_of(metas.begin(), metas.end(), [&](const account_meta& meta) { return meta.key == key; }); } -} // anonymous namespace - -/// Custody MUST come from the Reserve account, never from a token-code-keyed -/// config map. The program's `handle_swap_remit` branches on -/// `reserve.custody_mint`; if the relay resolved custody from the mutable -/// `OutpostConfig.token_addresses_by_code` instead, an admin re-pointing that -/// token address after the reserve was created would send the relay down the -/// native branch while the program took the SPL one -- and the missing vault -/// and recipient-ATA accounts abort the call permanently, wedging the epoch. -/// -/// The divergence is modelled directly: ONE token_code backs two reserves -/// whose pinned custody disagrees. A config-keyed lookup could only produce -/// one answer for both; following the Reserve produces the right answer twice. -BOOST_AUTO_TEST_CASE(build_manifests_follows_reserve_custody_per_reserve) try { - constexpr uint64_t token_code = 111; - const auto spl_mint = measurement_pubkey(77); - const auto spl_recipient = measurement_pubkey(80); - const auto native_recipient = measurement_pubkey(81); - const auto creator = measurement_pubkey(82); - - manifest_build_harness harness; - harness.put(token_code, 200, creator, spl_mint, 6); // SPL custody - harness.put(token_code, 201, creator, system::program_ids::SYSTEM_PROGRAM, 9); // native custody - - const auto manifests = harness.build( - {swap_remit_effect(0, token_code, 200, spl_recipient), - swap_remit_effect(1, token_code, 201, native_recipient)}, - 2); - BOOST_REQUIRE_EQUAL(manifests.size(), 2u); - - // SPL branch: Reserve PDA + vault + the recipient's ATA FOR THE RESERVE'S - // OWN MINT + the custody mint + the token program. - const auto& spl = manifests[0]; - BOOST_CHECK(manifest_has(spl, manifest_detail::derive_reserve_pda( - harness.program_id, token_code, 200))); - BOOST_CHECK(manifest_has(spl, manifest_detail::derive_reserve_vault_pda( - harness.program_id, token_code, 200))); - BOOST_CHECK(manifest_has(spl, system::get_associated_token_address(spl_recipient, spl_mint))); - // The MINT itself. `reserve_vault_transfer` require_remaining_account()s it - // for the checked transfer's decimals, and swap_remit was the one - // reserve-backed shape that never carried it -- every SPL swap remit aborted - // with EffectAccountMissing and the epoch never closed. Asserted here - // because this case previously checked the ATA and token program only, - // which is exactly why the omission shipped. - BOOST_CHECK(manifest_has(spl, spl_mint)); - BOOST_CHECK(manifest_has(spl, system::program_ids::TOKEN_PROGRAM)); - // The bare recipient wallet is the NATIVE branch's account and must not - // appear on the SPL one. - BOOST_CHECK(!manifest_has(spl, spl_recipient)); - - // Native branch, same token_code: Reserve PDA + the recipient wallet, and - // NO vault / ATA / token program. - const auto& native = manifests[1]; - BOOST_CHECK(manifest_has(native, manifest_detail::derive_reserve_pda( - harness.program_id, token_code, 201))); - BOOST_CHECK(manifest_has(native, native_recipient)); - BOOST_CHECK(!manifest_has(native, manifest_detail::derive_reserve_vault_pda( - harness.program_id, token_code, 201))); - BOOST_CHECK(!manifest_has(native, system::program_ids::TOKEN_PROGRAM)); -} FC_LOG_AND_RETHROW(); +} namespace { @@ -1665,7 +1103,7 @@ namespace { manifest_detail::inbound_effect desyndicate_liq_effect(size_t index, const solana_public_key& user, uint64_t request_id) { return manifest_detail::inbound_effect{ - index, manifest_detail::effect_shape::desyndicate_liq, user, std::nullopt, std::nullopt, request_id}; + index, manifest_detail::effect_shape::desyndicate_liq, user, request_id}; } /// Whether `key` rides `metas` as WRITABLE. @@ -1677,6 +1115,29 @@ bool manifest_writes(const std::vector& metas, const solana_public } // namespace +BOOST_AUTO_TEST_CASE(build_liq_manifests_honors_deadline_before_pool_read) try { + manifest_build_harness harness; + BOOST_CHECK_THROW( + harness.build({desyndicate_liq_effect(0, measurement_pubkey(92), 7)}, 1, + [] { FC_THROW_EXCEPTION(fc::timeout_exception, "deadline exceeded"); }), + fc::timeout_exception); + BOOST_CHECK_EQUAL(harness.liq_pool_reads, 0u); + BOOST_CHECK_EQUAL(harness.hook_reads, 0u); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(build_liq_manifests_propagates_pool_rpc_failure) try { + manifest_build_harness harness; + BOOST_CHECK_THROW( + manifest_detail::build_dispatch_manifests( + harness.program_id, {desyndicate_liq_effect(0, measurement_pubkey(92), 7)}, 1, + [] {}, harness.hook_reader(), + []() -> std::optional { + FC_THROW_EXCEPTION(fc::exception, "pool RPC unavailable"); + }, "test-relay"), + fc::exception); + BOOST_CHECK_EQUAL(harness.hook_reads, 0u); +} FC_LOG_AND_RETHROW(); + // The DESYNDICATE_LIQ manifest is `handle_desyndicate_liq`'s // `require_remaining_account` list, derived from the user's pubkey, the // program's fixed pool PDAs and the mint on `DistributionState`: the two state @@ -1745,7 +1206,6 @@ BOOST_AUTO_TEST_CASE(build_manifests_desyndicate_liq_derives_the_pool_accounts) } } } FC_LOG_AND_RETHROW(); - // Without a readable `DistributionState` the manifest carries the two state // singletons only: the handler loads them first and turns an uninitialized // pool into a logged skip, so nothing behind them is derivable or required. @@ -1763,7 +1223,6 @@ BOOST_AUTO_TEST_CASE(build_manifests_desyndicate_liq_degrades_to_the_state_singl BOOST_CHECK(manifest_writes(m, manifest_detail::derive_pending_desyndication_pda(harness.program_id, request_id))); BOOST_CHECK_EQUAL(harness.hook_reads, 0u); } FC_LOG_AND_RETHROW(); - // Request id 0 is "no id": it cannot key a PendingPayout, and the handler records // such a payout as unpaid before it asks for the pending account, so the manifest // carries no pending-payout PDA for it -- and nothing else changes. @@ -1780,7 +1239,7 @@ BOOST_AUTO_TEST_CASE(build_manifests_desyndicate_liq_omits_the_pending_pda_for_r // An effect built without an id at all behaves the same. manifest_detail::inbound_effect{ 1, manifest_detail::effect_shape::desyndicate_liq, user, - std::nullopt, std::nullopt, std::nullopt}}, + std::nullopt}}, 2); BOOST_REQUIRE_EQUAL(manifests.size(), 2u); for (size_t i = 0; i < manifests.size(); ++i) { @@ -1796,7 +1255,6 @@ BOOST_AUTO_TEST_CASE(build_manifests_desyndicate_liq_omits_the_pending_pda_for_r BOOST_REQUIRE_EQUAL(bare.size(), 1u); BOOST_CHECK_EQUAL(bare[0].size(), 2u); } FC_LOG_AND_RETHROW(); - // The pending-payout PDA is derived exactly as wire-solana's // `PendingPayout::find_address` does: `["pending_desyndication", // request_id.to_le_bytes()]` under liqsol-core. The expected addresses were @@ -1830,7 +1288,6 @@ BOOST_AUTO_TEST_CASE(pending_desyndication_pda_matches_the_program_derivation) t liqsol_core) .first); } FC_LOG_AND_RETHROW(); - namespace { /// Pack a seed list into an `ExtraAccountMeta::address_config`, exactly as @@ -1902,17 +1359,14 @@ manifest_detail::extra_account_meta external_pda_meta(uint8_t program_index, /// 2 destination, 3 authority, 4 validation PDA) is load-bearing rather than /// decorative. BOOST_AUTO_TEST_CASE(build_manifests_carry_transfer_hook_accounts_for_a_hook_mint) try { - constexpr uint64_t token_code = 141; - constexpr uint64_t reserve_code = 242; const auto spl_mint = measurement_pubkey(92); const auto recipient = measurement_pubkey(93); - const auto creator = measurement_pubkey(94); const auto token_2022 = system::program_ids::TOKEN_2022_PROGRAM; const auto hook_program = measurement_pubkey(96); const auto hook_owned = measurement_pubkey(97); // a literal meta, e.g. liqsol_core manifest_build_harness harness; - harness.put(token_code, reserve_code, creator, spl_mint, 6, token_2022); + harness.put_liq_pool(spl_mint); harness.put_hook(spl_mint, hook_program, {literal_meta(hook_owned, false), // sender's user_record, seeded on the SOURCE token account (index 0) @@ -1921,12 +1375,12 @@ BOOST_AUTO_TEST_CASE(build_manifests_carry_transfer_hook_accounts_for_a_hook_min external_pda_meta(5, {seed_literal{"user_record"}, seed_account_key{2}}, true)}); const auto manifests = - harness.build({swap_remit_effect(0, token_code, reserve_code, recipient)}, 1); + harness.build({desyndicate_liq_effect(0, recipient, 1)}, 1); BOOST_REQUIRE_EQUAL(manifests.size(), 1u); const auto& m = manifests[0]; - const auto vault = manifest_detail::derive_reserve_vault_pda(harness.program_id, token_code, - reserve_code); + const auto vault = system::get_associated_token_address( + manifest_detail::derive_liqsol_pool_authority_pda(harness.program_id), spl_mint, token_2022); const auto dest_ata = system::get_associated_token_address(recipient, spl_mint, token_2022); const auto validation_pda = @@ -1962,7 +1416,6 @@ BOOST_AUTO_TEST_CASE(build_manifests_carry_transfer_hook_accounts_for_a_hook_min BOOST_CHECK_EQUAL(writable_of(hook_owned), false); BOOST_CHECK_EQUAL(writable_of(hook_program), false); } FC_LOG_AND_RETHROW(); - namespace { /// Build a well-formed `ExtraAccountMetaList` account: the Execute TLV @@ -2020,62 +1473,6 @@ BOOST_AUTO_TEST_CASE(extra_account_meta_parser_refuses_malformed_accounts) try { wrong_disc[0] ^= 0xff; BOOST_CHECK_THROW(parse_extra_account_metas(wrong_disc), fc::assert_exception); } FC_LOG_AND_RETHROW(); - -/// The worst-case hook manifest against the dispatch budget. -/// -/// `swap_revert` and `reserve_create_cancelled` on a hook mint carry the most -/// accounts of any shape, and with the REAL liqSOL hook's five declared metas -/// that comes to 13 of `MAX_TERMINAL_DYNAMIC_ACCOUNTS` (16) -- 15 before the -/// associated-token and system programs were dropped from both shapes, neither -/// of which any handler uses (nothing in `inbound.rs` creates an ATA; both -/// handlers log-and-skip on an uninitialized one). The packer always -/// takes at least one attestation regardless of size, so a manifest over budget -/// is not deferred -- `transaction::serialize` throws locally, which the header -/// documents as "an alarm, not a retry", and the epoch cannot settle at all. -/// -/// One more declared meta, or one more effect account added program-side, spends -/// the last slot. Pinning the number here turns that cliff into a failing test -/// instead of a wedged outpost. -BOOST_AUTO_TEST_CASE(hook_manifest_worst_case_fits_the_dispatch_budget) try { - constexpr uint64_t token_code = 161; - constexpr uint64_t reserve_code = 262; - const auto spl_mint = measurement_pubkey(102); - const auto depositor = measurement_pubkey(103); - const auto creator = measurement_pubkey(104); - const auto token_2022 = system::program_ids::TOKEN_2022_PROGRAM; - const auto hook_program = measurement_pubkey(106); - const auto core_program = measurement_pubkey(107); - const auto bucket_ata = measurement_pubkey(108); - - manifest_build_harness harness; - harness.put(token_code, reserve_code, creator, spl_mint, 6, token_2022); - // The real liqSOL declaration (transfer-hook/src/lib.rs): the core program, - // the source and destination `user_record`s, `distribution_state`, and the - // bucket token account. - harness.put_hook(spl_mint, hook_program, - {literal_meta(core_program, false), - external_pda_meta(5, {seed_literal{"user_record"}, seed_account_key{0}}, true), - external_pda_meta(5, {seed_literal{"user_record"}, seed_account_key{2}}, true), - external_pda_meta(5, {seed_literal{"distribution_state"}}, true), - literal_meta(bucket_ata, false)}); - - const auto manifests = harness.build( - {swap_revert_effect(0, token_code, reserve_code, depositor), - reserve_create_cancelled_effect(1, token_code, reserve_code)}, - 2); - BOOST_REQUIRE_EQUAL(manifests.size(), 2u); - - for (const auto& m : manifests) { - BOOST_TEST_CONTEXT("manifest size " << m.size()) { - BOOST_CHECK_LE(m.size(), sysio::MAX_TERMINAL_DYNAMIC_ACCOUNTS); - } - } - // Pinned exactly, not just bounded: a silent creep to 16 would still pass a - // <= check while leaving zero headroom. - BOOST_CHECK_EQUAL(manifests[0].size(), 13u); - BOOST_CHECK_EQUAL(manifests[1].size(), 13u); -} FC_LOG_AND_RETHROW(); - /// The DESYNDICATE_LIQ manifest under the REAL liqSOL hook declaration, pending- /// payout PDA included. Every meta the hook declares resolves to an account the /// handler already requires (liqsol-core, the two `UserRecord`s, @@ -2107,69 +1504,32 @@ BOOST_AUTO_TEST_CASE(desyndicate_liq_manifest_under_the_real_hook_fits_the_dispa BOOST_CHECK(manifest_has(manifests[0], manifest_detail::derive_pending_desyndication_pda(harness.program_id, 9))); } FC_LOG_AND_RETHROW(); - /// A `ExtraAccountMeta` may legally demand a signature. The dispatch /// transaction is signed by the batch operator alone, so the resolver cannot /// satisfy one -- and dropping the flag silently (keeping only key+writable) /// would build a manifest that aborts inside the CPI as an unauthorized signer, /// with the cursor pinned and no local diagnostic. Refuse at build time. BOOST_AUTO_TEST_CASE(hook_manifest_refuses_a_meta_demanding_a_signature) try { - constexpr uint64_t token_code = 171; - constexpr uint64_t reserve_code = 272; const auto spl_mint = measurement_pubkey(120); - const auto creator = measurement_pubkey(121); const auto recipient = measurement_pubkey(122); const auto hook_program = measurement_pubkey(123); const auto needs_sig = measurement_pubkey(124); - const auto token_2022 = system::program_ids::TOKEN_2022_PROGRAM; manifest_build_harness harness; - harness.put(token_code, reserve_code, creator, spl_mint, 6, token_2022); + harness.put_liq_pool(spl_mint); // Sanity: the same declaration WITHOUT the signer flag builds fine, so the // rejection below is about the flag and not about the meta being unresolvable. harness.put_hook(spl_mint, hook_program, {literal_meta(needs_sig, false)}); BOOST_CHECK_NO_THROW( - harness.build({swap_remit_effect(0, token_code, reserve_code, recipient)}, 1)); + harness.build({desyndicate_liq_effect(0, recipient, 1)}, 1)); harness.hooks.clear(); harness.put_hook(spl_mint, hook_program, {signer_meta(needs_sig)}); BOOST_CHECK_THROW( - harness.build({swap_remit_effect(0, token_code, reserve_code, recipient)}, 1), + harness.build({desyndicate_liq_effect(0, recipient, 1)}, 1), fc::assert_exception); } FC_LOG_AND_RETHROW(); - -/// A legacy-SPL reserve cannot carry a Token-2022 extension, so the mint is -/// never read for a hook. This is not just an economy: the reader ASSERTS on an -/// absent or empty mint account, and that assert fails the whole build -- every -/// attestation in the envelope, not just this effect. Scoping the read to -/// Token-2022 custody keeps a transient read anomaly on a legacy mint from -/// wedging an epoch it has no bearing on. -BOOST_AUTO_TEST_CASE(legacy_spl_custody_never_reads_the_mint_for_a_hook) try { - constexpr uint64_t token_code = 181; - constexpr uint64_t reserve_code = 282; - const auto spl_mint = measurement_pubkey(130); - const auto creator = measurement_pubkey(131); - const auto recipient = measurement_pubkey(132); - const auto hook_program = measurement_pubkey(133); - const auto hook_owned = measurement_pubkey(134); - - manifest_build_harness harness; - harness.put(token_code, reserve_code, creator, spl_mint, 6); // legacy SPL custody - // Declared anyway: if the gate regressed, these accounts would appear. - harness.put_hook(spl_mint, hook_program, {literal_meta(hook_owned, false)}); - - const auto manifests = - harness.build({swap_remit_effect(0, token_code, reserve_code, recipient)}, 1); - BOOST_REQUIRE_EQUAL(manifests.size(), 1u); - - BOOST_CHECK_EQUAL(harness.hook_reads, 0u); - for (const auto& meta : manifests[0]) { - BOOST_CHECK(meta.key != hook_program); - BOOST_CHECK(meta.key != hook_owned); - } -} FC_LOG_AND_RETHROW(); - /// The mint TLV walk itself: what it reads, and what it refuses. /// /// The refusals matter for the same reason `parse_extra_account_metas`'s do -- @@ -2232,429 +1592,21 @@ BOOST_AUTO_TEST_CASE(mint_transfer_hook_parser_reads_and_refuses) try { false)), fc::assert_exception); } FC_LOG_AND_RETHROW(); - -/// A hook-free mint -- every mint in the system today -- must be byte-for-byte -/// unchanged by the hook support. Pins that this is additive. -BOOST_AUTO_TEST_CASE(build_manifests_are_unchanged_for_a_hook_free_mint) try { - constexpr uint64_t token_code = 151; - constexpr uint64_t reserve_code = 252; - const auto spl_mint = measurement_pubkey(98); - const auto recipient = measurement_pubkey(99); - const auto creator = measurement_pubkey(100); - const auto token_2022 = system::program_ids::TOKEN_2022_PROGRAM; - - manifest_build_harness harness; // no put_hook: the reader returns nullopt - harness.put(token_code, reserve_code, creator, spl_mint, 6, token_2022); - - const auto manifests = - harness.build({swap_remit_effect(0, token_code, reserve_code, recipient)}, 1); - BOOST_REQUIRE_EQUAL(manifests.size(), 1u); - // Reserve PDA + vault + recipient ATA + mint + token program, and nothing else. - BOOST_CHECK_EQUAL(manifests[0].size(), 5u); -} FC_LOG_AND_RETHROW(); - -/// SOL-396: the reserve's OWN `custody_token_program` drives both the ATA -/// derivation and the token-program account. The same (owner, mint) pair has a -/// DIFFERENT canonical ATA under Token-2022, so deriving with the legacy -/// SPL-Token default names an account the program never asks for -- and -/// `require_remaining_account` then aborts the window on every retry, with the -/// cursor pinned, forever. -BOOST_AUTO_TEST_CASE(build_manifests_derive_the_ata_with_the_reserves_token_program) try { - constexpr uint64_t token_code = 121; - constexpr uint64_t reserve_code = 222; - const auto spl_mint = measurement_pubkey(84); - const auto recipient = measurement_pubkey(85); - const auto creator = measurement_pubkey(86); - const auto token_2022 = system::program_ids::TOKEN_2022_PROGRAM; - - manifest_build_harness harness; - harness.put(token_code, reserve_code, creator, spl_mint, 6, token_2022); - - const auto manifests = - harness.build({swap_remit_effect(0, token_code, reserve_code, recipient)}, 1); - BOOST_REQUIRE_EQUAL(manifests.size(), 1u); - const auto& m = manifests[0]; - - // The ATA under the RESERVE'S program, and that program as the token - // program account. - BOOST_CHECK(manifest_has( - m, system::get_associated_token_address(recipient, spl_mint, token_2022))); - BOOST_CHECK(manifest_has(m, token_2022)); - BOOST_CHECK(manifest_has(m, spl_mint)); - - // And NOT the legacy default's ATA, nor the legacy program. - BOOST_CHECK(!manifest_has(m, system::get_associated_token_address(recipient, spl_mint))); - BOOST_CHECK(!manifest_has(m, system::program_ids::TOKEN_PROGRAM)); -} FC_LOG_AND_RETHROW(); - -/// The SAME pinned-token-program derivation must hold for the other two -/// reserve-backed shapes. Without these, reverting either branch to the legacy -/// 2-arg `get_associated_token_address` leaves the suite green — which is exactly -/// how the missing custody mint shipped on `swap_remit` in the first place. -BOOST_AUTO_TEST_CASE(build_manifests_pin_the_token_program_on_revert_and_cancel) try { - constexpr uint64_t token_code = 131; - constexpr uint64_t reserve_code = 232; - const auto spl_mint = measurement_pubkey(88); - const auto depositor = measurement_pubkey(89); - const auto creator = measurement_pubkey(90); - const auto token_2022 = system::program_ids::TOKEN_2022_PROGRAM; - - manifest_build_harness harness; - harness.put(token_code, reserve_code, creator, spl_mint, 6, token_2022); - - const auto manifests = harness.build( - {swap_revert_effect(0, token_code, reserve_code, depositor), - reserve_create_cancelled_effect(1, token_code, reserve_code)}, - 2); - BOOST_REQUIRE_EQUAL(manifests.size(), 2u); - - // SWAP_REVERT refunds the depositor's ATA under the reserve's own program. - const auto& revert = manifests[0]; - BOOST_CHECK(manifest_has( - revert, system::get_associated_token_address(depositor, spl_mint, token_2022))); - BOOST_CHECK(manifest_has(revert, token_2022)); - BOOST_CHECK(manifest_has(revert, spl_mint)); - BOOST_CHECK(!manifest_has(revert, system::get_associated_token_address(depositor, spl_mint))); - BOOST_CHECK(!manifest_has(revert, system::program_ids::TOKEN_PROGRAM)); - - // RESERVE_CREATE_CANCELLED refunds the CREATOR's ATA, same program. - const auto& cancelled = manifests[1]; - BOOST_CHECK(manifest_has( - cancelled, system::get_associated_token_address(creator, spl_mint, token_2022))); - BOOST_CHECK(manifest_has(cancelled, token_2022)); - BOOST_CHECK(manifest_has(cancelled, spl_mint)); - BOOST_CHECK(!manifest_has(cancelled, system::get_associated_token_address(creator, spl_mint))); - BOOST_CHECK(!manifest_has(cancelled, system::program_ids::TOKEN_PROGRAM)); -} FC_LOG_AND_RETHROW(); - -/// Collateral custody MUST be resolved per position, never per token code. -/// The program pins `custody_mint` on each `(operator, token_code)` -/// `CollateralPosition`, so two operators using the same token code may take -/// different native/SPL settlement branches. A token-code-only cache would -/// make one manifest disagree with the program and permanently wedge the -/// dispatch cursor on `EffectAccountMissing`. -BOOST_AUTO_TEST_CASE(build_manifests_follows_collateral_custody_per_position) try { - constexpr uint64_t token_code = 700; - const auto native_operator = measurement_pubkey(96); - const auto spl_operator = measurement_pubkey(97); - const auto spl_mint = measurement_pubkey(98); - - manifest_build_harness harness; - harness.put_collateral(native_operator, token_code, - system::program_ids::SYSTEM_PROGRAM); - harness.put_collateral(spl_operator, token_code, spl_mint); - - const auto manifests = harness.build( - {withdraw_remit_effect(0, token_code, native_operator), - withdraw_remit_effect(1, token_code, spl_operator)}, - 2); - BOOST_REQUIRE_EQUAL(manifests.size(), 2u); - - // Native position: operator + position only, with no SPL extras. - const auto& native = manifests[0]; - BOOST_REQUIRE_EQUAL(native.size(), 2u); - BOOST_CHECK(manifest_has(native, native_operator)); - BOOST_CHECK(manifest_has(native, manifest_detail::derive_collateral_position_pda( - harness.program_id, native_operator, token_code))); - BOOST_CHECK(!manifest_has(native, manifest_detail::derive_collateral_vault_pda( - harness.program_id, token_code))); - BOOST_CHECK(!manifest_has(native, system::program_ids::TOKEN_PROGRAM)); - - // SPL position, same token code: position + collateral vault + operator's - // ATA for THIS POSITION'S mint + token program. - const auto& spl = manifests[1]; - BOOST_REQUIRE_EQUAL(spl.size(), 5u); - BOOST_CHECK(manifest_has(spl, spl_operator)); - BOOST_CHECK(manifest_has(spl, manifest_detail::derive_collateral_position_pda( - harness.program_id, spl_operator, token_code))); - BOOST_CHECK(manifest_has(spl, manifest_detail::derive_collateral_vault_pda( - harness.program_id, token_code))); - BOOST_CHECK(manifest_has(spl, system::get_associated_token_address(spl_operator, spl_mint))); - BOOST_CHECK(manifest_has(spl, system::program_ids::TOKEN_PROGRAM)); - - BOOST_REQUIRE_EQUAL(harness.collateral_reads.size(), 2u); - BOOST_CHECK((harness.collateral_reads[0] == - manifest_build_harness::collateral_seeds{native_operator, token_code})); - BOOST_CHECK((harness.collateral_reads[1] == - manifest_build_harness::collateral_seeds{spl_operator, token_code})); -} FC_LOG_AND_RETHROW(); - -/// SLASH is the one collateral shape whose SPL destination is NOT the -/// recipient: the seizure settles into the `reserve_aggregate`'s canonical -/// ATA, and the operator wallet is deliberately absent (SLASH pays nobody -/// directly). A wrong ATA owner here is the silently-stuck-seizure failure -/// mode — the handler's uninitialised-destination check degrades to -/// "status flipped, funds stuck" rather than aborting — so the owner swap is -/// pinned at the manifest level, native and SPL. -BOOST_AUTO_TEST_CASE(build_manifests_slash_settles_into_reserve_aggregate_ata) try { - constexpr uint64_t token_code = 700; - const auto native_operator = measurement_pubkey(90); - const auto spl_operator = measurement_pubkey(91); - const auto spl_mint = measurement_pubkey(92); - - manifest_build_harness harness; - harness.put_collateral(native_operator, token_code, - system::program_ids::SYSTEM_PROGRAM); - harness.put_collateral(spl_operator, token_code, spl_mint); - - const auto manifests = harness.build( - {slash_effect(0, token_code, native_operator), - slash_effect(1, token_code, spl_operator)}, - 2); - BOOST_REQUIRE_EQUAL(manifests.size(), 2u); - - // Native seizure: position ONLY — the lamports land in the named - // `reserve_aggregate` account, and the operator wallet is never declared. - const auto& native = manifests[0]; - BOOST_REQUIRE_EQUAL(native.size(), 1u); - BOOST_CHECK(manifest_has(native, manifest_detail::derive_collateral_position_pda( - harness.program_id, native_operator, token_code))); - BOOST_CHECK(!manifest_has(native, native_operator)); - - // SPL seizure: position + collateral vault + the RESERVE_AGGREGATE's ATA - // (not the operator's) + token program; still no operator wallet. - const auto& spl = manifests[1]; - BOOST_REQUIRE_EQUAL(spl.size(), 4u); - BOOST_CHECK(manifest_has(spl, manifest_detail::derive_collateral_position_pda( - harness.program_id, spl_operator, token_code))); - BOOST_CHECK(manifest_has(spl, manifest_detail::derive_collateral_vault_pda( - harness.program_id, token_code))); - BOOST_CHECK(manifest_has( - spl, system::get_associated_token_address(harness.reserve_aggregate, spl_mint))); - BOOST_CHECK(!manifest_has(spl, system::get_associated_token_address(spl_operator, spl_mint))); - BOOST_CHECK(manifest_has(spl, system::program_ids::TOKEN_PROGRAM)); - BOOST_CHECK(!manifest_has(spl, spl_operator)); -} FC_LOG_AND_RETHROW(); - -/// DEPOSIT_REVERT settles SPL for real on the companion program -/// (`handle_deposit_revert`'s non-native branch drains the collateral vault -/// into the DEPOSITOR's canonical ATA via `resolve_collateral_vault_transfer`, -/// which `require_remaining_account`s the vault, the destination ATA, and the -/// token program). A manifest that omits them aborts the dispatch round and -/// re-packs the identical window from the same cursor on every retry — the -/// regression this test pins is exactly the earlier "SPL deposit-revert is -/// refused on-chain" early-return, which held the cursor once the companion -/// implemented settlement. -BOOST_AUTO_TEST_CASE(build_manifests_deposit_revert_declares_spl_refund_accounts) try { - constexpr uint64_t token_code = 700; - const auto native_depositor = measurement_pubkey(93); - const auto spl_depositor = measurement_pubkey(94); - const auto spl_mint = measurement_pubkey(95); - - manifest_build_harness harness; - harness.put_collateral(native_depositor, token_code, - system::program_ids::SYSTEM_PROGRAM); - harness.put_collateral(spl_depositor, token_code, spl_mint); - - const auto manifests = harness.build( - {deposit_revert_effect(0, token_code, native_depositor), - deposit_revert_effect(1, token_code, spl_depositor)}, - 2); - BOOST_REQUIRE_EQUAL(manifests.size(), 2u); - - // Native refund: depositor + position only, with no SPL extras. - const auto& native = manifests[0]; - BOOST_REQUIRE_EQUAL(native.size(), 2u); - BOOST_CHECK(manifest_has(native, native_depositor)); - BOOST_CHECK(manifest_has(native, manifest_detail::derive_collateral_position_pda( - harness.program_id, native_depositor, token_code))); - BOOST_CHECK(!manifest_has(native, system::program_ids::TOKEN_PROGRAM)); - - // SPL refund: depositor wallet (the full-drain close's rent recipient) + - // position + collateral vault + the DEPOSITOR's ATA + token program. - const auto& spl = manifests[1]; - BOOST_REQUIRE_EQUAL(spl.size(), 5u); - BOOST_CHECK(manifest_has(spl, spl_depositor)); - BOOST_CHECK(manifest_has(spl, manifest_detail::derive_collateral_position_pda( - harness.program_id, spl_depositor, token_code))); - BOOST_CHECK(manifest_has(spl, manifest_detail::derive_collateral_vault_pda( - harness.program_id, token_code))); - BOOST_CHECK(manifest_has(spl, system::get_associated_token_address(spl_depositor, spl_mint))); - BOOST_CHECK(manifest_has(spl, system::program_ids::TOKEN_PROGRAM)); -} FC_LOG_AND_RETHROW(); - -/// The build reads each DISTINCT reserve exactly once, however many -/// attestations reference it -- the property that keeps a 500-remit envelope -/// from costing 500 sequential round-trips per drain. There is no -/// `OutpostConfig` read on this path at all: custody rides the Reserve. -BOOST_AUTO_TEST_CASE(build_manifests_reads_each_reserve_once) try { - const auto recipient = measurement_pubkey(83); - const auto creator = measurement_pubkey(84); - const auto mint = measurement_pubkey(85); - - manifest_build_harness harness; - harness.put(10, 20, creator, mint, 6); - harness.put(11, 21, creator, mint, 6); - - // Six attestations across TWO distinct reserves. - harness.build({swap_remit_effect(0, 10, 20, recipient), - swap_remit_effect(1, 11, 21, recipient), - swap_remit_effect(2, 10, 20, recipient), - swap_remit_effect(3, 10, 20, recipient), - swap_remit_effect(4, 11, 21, recipient), - swap_remit_effect(5, 10, 20, recipient)}, - 6); - - BOOST_CHECK_EQUAL(harness.reads.size(), 2u); - const std::vector> expected{{10u, 20u}, {11u, 21u}}; - BOOST_CHECK(harness.reads == expected); -} FC_LOG_AND_RETHROW(); - -/// An ABSENT reserve costs THAT attestation its custody-dependent accounts and -/// nothing else: every other attestation's manifest is still built, so the -/// envelope's healthy prefix still dispatches. This degrade is safe precisely -/// because the program skips an uninitialized reserve -/// (`load_reserve_from_remaining` -> `Ok(None)` -> logged skip), so none of the -/// omitted accounts is ever reached. The opposite case — a reserve that exists -/// but this relay cannot read — must NOT come through here; it is pinned by -/// `build_manifests_propagate_an_unreadable_reserve` below. -BOOST_AUTO_TEST_CASE(build_manifests_degrade_an_absent_reserve) try { - const auto recipient = measurement_pubkey(86); - const auto payee = measurement_pubkey(87); - const auto creator = measurement_pubkey(88); - const auto mint = measurement_pubkey(89); - - manifest_build_harness harness; - harness.put(10, 20, creator, mint, 6); - // (12, 22) deliberately absent -> the read degrades. - // Native custody for the collateral token: the zero-mint marker. - harness.put_collateral(payee, 700, system::program_ids::SYSTEM_PROGRAM); - - const auto manifests = harness.build({swap_remit_effect(0, 10, 20, recipient), - swap_remit_effect(1, 12, 22, recipient), - withdraw_remit_effect(2, 700, payee)}, - 3); - BOOST_REQUIRE_EQUAL(manifests.size(), 3u); - - // The healthy SPL attestation is complete. - BOOST_CHECK(manifest_has(manifests[0], system::get_associated_token_address(recipient, mint))); - // The degraded one still carries everything derivable WITHOUT the record - // (Reserve PDA, vault, recipient, token program) -- just not the ATA, which - // needs the custody mint. - BOOST_CHECK(manifest_has(manifests[1], manifest_detail::derive_reserve_pda( - harness.program_id, 12, 22))); - BOOST_CHECK(manifest_has(manifests[1], manifest_detail::derive_reserve_vault_pda( - harness.program_id, 12, 22))); - BOOST_CHECK(manifest_has(manifests[1], recipient)); - // The attestation AFTER the degraded one is untouched by it: a native - // withdraw remit declares exactly the payee and their CollateralPosition. - BOOST_REQUIRE_EQUAL(manifests[2].size(), 2u); - BOOST_CHECK(manifest_has(manifests[2], payee)); - BOOST_CHECK(manifest_has(manifests[2], manifest_detail::derive_collateral_position_pda( - harness.program_id, payee, 700))); - - // A permanently unusable reserve costs ONE read for the whole build, not - // one per attestation referencing it. - const auto degraded_reads = std::count(harness.reads.begin(), harness.reads.end(), - std::pair{12u, 22u}); - BOOST_CHECK_EQUAL(degraded_reads, 1); -} FC_LOG_AND_RETHROW(); - -/// A reserve that EXISTS but cannot be read by this relay must abort the build, -/// never degrade into a manifest. The program decodes that account fine, takes -/// its real branch, and requires the effect accounts that branch needs — the -/// custody ATA for an SPL remit/revert, the `creator` for a cancel with an -/// escrow to refund — so a degraded manifest is GUARANTEED to hit -/// `require_remaining_account` and abort. -/// -/// That is the difference between a delayed epoch and a wedged one: -/// `drive_dispatch_rounds` repacks every window from the on-chain cursor, so an -/// aborting attestation heads every future window and the cursor never moves. -/// Letting the exception out fails the tick with the cursor untouched, which is -/// recoverable; shipping the manifest is not. -BOOST_AUTO_TEST_CASE(build_manifests_propagate_an_unreadable_reserve) try { - const auto recipient = measurement_pubkey(94); - const auto later = measurement_pubkey(95); - - manifest_build_harness harness; - // The reader models `reserve_info_for_codes` meeting a present-but- - // undecodable Reserve: it throws rather than returning empty. - auto throwing_reader = [&](uint64_t token_code, uint64_t reserve_code) - -> std::optional { - harness.reads.emplace_back(token_code, reserve_code); - FC_THROW_EXCEPTION(fc::exception, "Reserve exists but is undecodable (test probe)"); - }; - - // BOTH effects are reserve-backed, on DIFFERENT reserves, so the read count - // is load-bearing: had the build swallowed the throw and carried on, the - // second effect would have paid its own read and `reads` would be 2. - BOOST_CHECK_EXCEPTION( - manifest_detail::build_dispatch_manifests( - harness.program_id, - {swap_remit_effect(0, 10, 20, recipient), swap_remit_effect(1, 11, 21, later)}, - 2, [] {}, throwing_reader, harness.collateral_reader(), harness.hook_reader(), - harness.liq_pool_reader(), harness.reserve_aggregate, "test-relay"), - fc::exception, - [](const fc::exception& e) { - return e.to_detail_string().find("undecodable") != std::string::npos; - }); - // It failed AT the first bad reserve; the second was never reached. - BOOST_REQUIRE_EQUAL(harness.reads.size(), 1u); - BOOST_CHECK((harness.reads[0] == std::pair{10u, 20u})); -} FC_LOG_AND_RETHROW(); - -/// The manifest build is where a large envelope burns its tick: the deadline -/// is probed per effect, BEFORE that effect's reserve read, so an over-deadline -/// build fails at the loop instead of deep in the RPC layer with the work -/// already lost and zero dispatches sent. -BOOST_AUTO_TEST_CASE(build_manifests_probes_deadline_before_each_reserve_read) try { - const auto recipient = measurement_pubkey(90); - const auto creator = measurement_pubkey(91); - const auto mint = measurement_pubkey(92); - - manifest_build_harness harness; - harness.put(10, 20, creator, mint, 6); - harness.put(11, 21, creator, mint, 6); - - uint32_t probes = 0; - BOOST_CHECK_EXCEPTION( - harness.build({swap_remit_effect(0, 10, 20, recipient), - swap_remit_effect(1, 11, 21, recipient)}, - 2, - [&] { - if (probes++ > 0) { - FC_THROW_EXCEPTION(fc::timeout_exception, - "deadline exceeded (test probe)"); - } - }), - fc::timeout_exception, - [](const fc::timeout_exception& e) { - return e.to_detail_string().find("deadline exceeded") != std::string::npos; - }); - // Probe fired before the SECOND effect's read, so only the first reserve - // was ever fetched. - BOOST_REQUIRE_EQUAL(harness.reads.size(), 1u); - BOOST_CHECK((harness.reads[0] == std::pair{10u, 20u})); -} FC_LOG_AND_RETHROW(); - /// The result is sized to the envelope's attestation TOTAL and indexed by the /// flat dispatch position, so an attestation needing no effect account keeps /// an empty entry and the cursor stays aligned with the manifests. BOOST_AUTO_TEST_CASE(build_manifests_index_by_flat_dispatch_position) try { - const auto payee = measurement_pubkey(93); - manifest_build_harness harness; - // Native custody for the collateral token: the zero-mint marker. - harness.put_collateral(payee, 700, system::program_ids::SYSTEM_PROGRAM); - // Attestations 0, 1 and 3 contribute no effect at all. - const auto manifests = harness.build({withdraw_remit_effect(2, 700, payee)}, 4); - + const auto payee = measurement_pubkey(93); + const auto manifests = harness.build({desyndicate_liq_effect(2, payee, 7)}, 4); BOOST_REQUIRE_EQUAL(manifests.size(), 4u); BOOST_CHECK(manifests[0].empty()); BOOST_CHECK(manifests[1].empty()); - BOOST_REQUIRE_EQUAL(manifests[2].size(), 2u); - BOOST_CHECK(manifest_has(manifests[2], payee)); - BOOST_CHECK(manifest_has(manifests[2], manifest_detail::derive_collateral_position_pda( - harness.program_id, payee, 700))); BOOST_CHECK(manifests[3].empty()); - // A collateral settlement needs no Reserve record — only its one custody - // lookup is paid. - BOOST_CHECK(harness.reads.empty()); - BOOST_REQUIRE_EQUAL(harness.collateral_reads.size(), 1u); - BOOST_CHECK((harness.collateral_reads[0] == - manifest_build_harness::collateral_seeds{payee, 700u})); + BOOST_REQUIRE_EQUAL(manifests[2].size(), 3u); + BOOST_CHECK(manifest_has(manifests[2], manifest_detail::derive_pending_desyndication_pda(harness.program_id, 7))); + BOOST_CHECK_EQUAL(harness.liq_pool_reads, 1u); } FC_LOG_AND_RETHROW(); - // ── drive_dispatch_rounds: the dispatch-crank state machine, driven through // its RPC seams (progress read + dispatch send) with a scripted on-chain // model. Both confirmed drain defects (the zero-attestation early return @@ -2747,28 +1699,24 @@ BOOST_AUTO_TEST_CASE(drive_dispatch_zero_attestation_envelope_sends_one_close_cr BOOST_CHECK(harness.sends[0].accounts.empty()); BOOST_CHECK_EQUAL(sig, "sig-0"); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(drive_dispatch_zero_attestation_waits_for_consensus) try { dispatch_drive_harness harness(0, /*consensus_reached=*/false); const auto sig = harness.drive({}); BOOST_CHECK(harness.sends.empty()); BOOST_CHECK_EQUAL(sig, ""); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(drive_dispatch_consensus_not_reached_sends_nothing) try { dispatch_drive_harness harness(3, /*consensus_reached=*/false); const auto sig = harness.drive(distinct_manifests(3, 2)); BOOST_CHECK(harness.sends.empty()); BOOST_CHECK_EQUAL(sig, ""); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(drive_dispatch_already_drained_cursor_sends_nothing) try { dispatch_drive_harness harness(3, true, /*initial_cursor=*/3); const auto sig = harness.drive(distinct_manifests(3, 2)); BOOST_CHECK(harness.sends.empty()); BOOST_CHECK_EQUAL(sig, ""); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(drive_dispatch_single_round_settles_small_manifests) try { // 3 attestations x 2 distinct accounts = a 6-account union, well inside // the 16-account budget -- one send covers the whole envelope. @@ -2779,7 +1727,6 @@ BOOST_AUTO_TEST_CASE(drive_dispatch_single_round_settles_small_manifests) try { BOOST_CHECK_EQUAL(harness.sends[0].accounts.size(), 6u); BOOST_CHECK_EQUAL(sig, "sig-0"); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(drive_dispatch_packs_union_to_exact_account_budget) try { // 8 distinct accounts per attestation: two fill the 16-account budget // EXACTLY (16 is allowed; the pack breaks only past it), a third would @@ -2792,7 +1739,6 @@ BOOST_AUTO_TEST_CASE(drive_dispatch_packs_union_to_exact_account_budget) try { BOOST_CHECK_EQUAL(harness.sends[1].limit, 2u); BOOST_CHECK_EQUAL(harness.sends[1].accounts.size(), sysio::MAX_TERMINAL_DYNAMIC_ACCOUNTS); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(drive_dispatch_shared_accounts_pack_as_a_union) try { // Every attestation references the SAME 10 accounts: the batch union stays // at 10, so all 3 fit one round even though 3 x 10 raw metas would not. @@ -2806,7 +1752,6 @@ BOOST_AUTO_TEST_CASE(drive_dispatch_shared_accounts_pack_as_a_union) try { BOOST_CHECK_EQUAL(harness.sends[0].limit, 3u); BOOST_CHECK_EQUAL(harness.sends[0].accounts.size(), 10u); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(drive_dispatch_resumes_from_nonzero_cursor) try { // Cursor already at 2 of 5 (a prior partial drain): the batch starts AT // the cursor and carries only the unsettled manifests' accounts. @@ -2826,7 +1771,6 @@ BOOST_AUTO_TEST_CASE(drive_dispatch_resumes_from_nonzero_cursor) try { BOOST_CHECK(sent_has(manifests[2][0].key)); // the resumed window BOOST_CHECK(sent_has(manifests[4][1].key)); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(drive_dispatch_oversized_single_manifest_still_takes_one) try { // One attestation whose own manifest exceeds the budget: the pack must // still take it (alone) so it can make progress -- an unsendable manifest @@ -2838,7 +1782,6 @@ BOOST_AUTO_TEST_CASE(drive_dispatch_oversized_single_manifest_still_takes_one) t BOOST_CHECK_EQUAL(harness.sends[0].limit, 1u); BOOST_CHECK_EQUAL(harness.sends[0].accounts.size(), oversized); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(drive_dispatch_exits_when_cursor_does_not_advance) try { // The send lands but the observed cursor does not move: another caller is // draining this envelope (or the read raced the write). One send, then a @@ -2849,7 +1792,6 @@ BOOST_AUTO_TEST_CASE(drive_dispatch_exits_when_cursor_does_not_advance) try { BOOST_REQUIRE_EQUAL(harness.sends.size(), 1u); BOOST_CHECK_EQUAL(sig, "sig-0"); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(drive_dispatch_round_exhaustion_alarms_and_returns) try { // 9-account manifests force one attestation per round; two more // attestations than the round budget means the loop stops at the budget. @@ -2863,7 +1805,6 @@ BOOST_AUTO_TEST_CASE(drive_dispatch_round_exhaustion_alarms_and_returns) try { BOOST_CHECK_EQUAL(harness.sends.size(), sysio::MAX_DISPATCH_ROUNDS); BOOST_CHECK_EQUAL(sig, "sig-" + std::to_string(sysio::MAX_DISPATCH_ROUNDS - 1)); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(drive_dispatch_deadline_expiry_throws) try { // The deadline probe fires once per round, before any read or send. Let // round 0 through and expire on round 1: exactly one batch goes out and @@ -2884,7 +1825,6 @@ BOOST_AUTO_TEST_CASE(drive_dispatch_deadline_expiry_throws) try { }); BOOST_REQUIRE_EQUAL(harness.sends.size(), 1u); } FC_LOG_AND_RETHROW(); - // ── LatestOutboundEnvelope inbound-read path. The epoch=511 RCA was // hardcoded STANDALONE offsets (epoch@8) silently misreading the // INTEGRATED account (`bump`=0xFF at byte 8, `epoch_index`=1 at byte 9 ⇒ @@ -3102,7 +2042,6 @@ BOOST_AUTO_TEST_CASE(latest_envelope_shape_accepts_known_layouts) try { } } } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(latest_envelope_shape_rejects_misdeclared_idls) try { struct reject_case { const char* name; @@ -3151,7 +2090,6 @@ BOOST_AUTO_TEST_CASE(latest_envelope_shape_rejects_misdeclared_idls) try { BOOST_CHECK_THROW(assert_latest_envelope_shape(latest_envelope_program({}, false)), fc::assert_exception); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(epoch_deliveries_shape_accepts_the_deployed_declaration) try { using sysio::outpost_solana_client_detail::assert_epoch_deliveries_shape; @@ -3161,7 +2099,6 @@ BOOST_AUTO_TEST_CASE(epoch_deliveries_shape_accepts_the_deployed_declaration) tr // guaranteed present before the first drain. BOOST_CHECK_NO_THROW(assert_epoch_deliveries_shape(load_idl_fixture(opp_outpost_idl_fixture))); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(epoch_deliveries_shape_rejects_drifted_declarations) try { using sysio::outpost_solana_client_detail::assert_epoch_deliveries_shape; @@ -3204,17 +2141,6 @@ BOOST_AUTO_TEST_CASE(epoch_deliveries_shape_rejects_drifted_declarations) try { BOOST_CHECK_THROW(assert_epoch_deliveries_shape(named_account_program("EpochDeliveries", {}, false)), fc::assert_exception); } FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(reserve_shape_accepts_the_deployed_declaration) try { - using sysio::outpost_solana_client_detail::assert_reserve_shape; - - // The stub fixture carries the program's own `Reserve` struct, so this is - // the boot check running against the declaration a batch operator will meet. - // It is what guarantees the manifest builder can always resolve custody and - // the cancel-refund creator from a reserve the program can read. - BOOST_CHECK_NO_THROW(assert_reserve_shape(load_idl_fixture(opp_outpost_idl_fixture))); -} FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(distribution_state_shape_accepts_a_pubkey_mint_and_rejects_drift) try { using sysio::outpost_solana_client_detail::assert_distribution_state_shape; @@ -3241,7 +2167,6 @@ BOOST_AUTO_TEST_CASE(distribution_state_shape_accepts_a_pubkey_mint_and_rejects_ } } } FC_LOG_AND_RETHROW(); - namespace { /// A synthetic program declaring `GlobalState` with `wire_state` of enum type @@ -3293,7 +2218,6 @@ BOOST_AUTO_TEST_CASE(global_state_shape_requires_a_post_launch_variant) try { } } } FC_LOG_AND_RETHROW(); - // The crank gates on the decoder's rendering of `GlobalState.wire_state` -- an // enum comes back as `{"variant": }` -- and only PostLaunch is due. BOOST_AUTO_TEST_CASE(liq_yield_report_is_due_only_post_launch) try { @@ -3308,7 +2232,6 @@ BOOST_AUTO_TEST_CASE(liq_yield_report_is_due_only_post_launch) try { // A bare string is not the decoder's shape: never due rather than guessed. BOOST_CHECK(!liq_yield_report_due(fc::mutable_variant_object()("wire_state", "PostLaunch"))); } FC_LOG_AND_RETHROW(); - // The emergency stop: while `GlobalState.frozen` is set the program refuses // `report_liq_yield`, so the crank does not fire. A row without `frozen` comes // from an IDL that declares no emergency stop and stays due; a `frozen` of any @@ -3335,7 +2258,6 @@ BOOST_AUTO_TEST_CASE(liq_yield_report_is_not_due_while_the_outpost_is_frozen) tr BOOST_CHECK(liq_outpost_frozen(state("PostLaunch", fc::variant(uint64_t{0})))); BOOST_CHECK(!liq_yield_report_due(state("PostLaunch", fc::variant("false")))); } FC_LOG_AND_RETHROW(); - // A `GlobalState` still at the pre-freeze layout (8 + 131 bytes, wire-solana // `GLOBAL_STATE_PRE_FREEZE_INIT_SPACE`) cannot carry the `frozen` the loaded IDL // declares: the crank treats it as not due instead of decoding past its end. The @@ -3363,7 +2285,6 @@ BOOST_AUTO_TEST_CASE(global_state_predates_freeze_only_under_an_idl_that_declare } BOOST_CHECK(!global_state_predates_freeze(idl::program{}, 0)); } FC_LOG_AND_RETHROW(); - // The overrides cover every non-signer account `report_liq_yield` declares, and // derive each the way the program's `#[derive(Accounts)]` constraints do: the // Token-2022 ATAs of the pool and bucket authorities, the `UserRecord`s seeded @@ -3417,151 +2338,6 @@ BOOST_AUTO_TEST_CASE(report_liq_yield_overrides_resolve_every_declared_account) BOOST_CHECK(overrides.at(accounts::associated_token_program) == system::program_ids::ASSOCIATED_TOKEN_PROGRAM); BOOST_CHECK(overrides.at(accounts::system_program) == system::program_ids::SYSTEM_PROGRAM); } FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(reserve_shape_rejects_drifted_declarations) try { - using sysio::outpost_solana_client_detail::assert_reserve_shape; - - // IDL drift here is the realistic way a LIVE, program-readable Reserve - // becomes unreadable to this relay — and in flight that is unrecoverable: - // the effect account the program's branch requires cannot be derived, the - // window aborts, and every later window repacks from the same cursor. Each - // shape below must therefore fail at BOOT, while the IDL is still fixable. - struct reject_case { - const char* name; - std::vector fields; - }; - const auto pubkey_t = prim(idl::primitive_type::pubkey); - const auto u8_t = prim(idl::primitive_type::u8); - - // Every case is the VALID four-field declaration with exactly ONE field - // dropped or mis-typed. Building them that way is load-bearing: the previous - // cases each omitted `custody_token_program` while testing some other defect, - // so deleting the `has_token_program` check (or its is_pubkey branch) left the - // whole suite green — the same coverage hole that let the missing custody mint - // ship in the first place. - const auto valid = [&](const char* drop, const char* retype, - const idl::idl_type& replacement) { - std::vector fields; - const std::pair declared[] = { - {"creator", pubkey_t}, - {"custody_mint", pubkey_t}, - {"custody_decimals", u8_t}, - {"custody_token_program", pubkey_t}, - }; - for (const auto& [name, type] : declared) { - if (drop != nullptr && std::string_view(name) == drop) continue; - fields.push_back({name, - (retype != nullptr && std::string_view(name) == retype) ? replacement - : type}); - } - return fields; - }; - const auto none = prim(idl::primitive_type::u8); // unused placeholder - - std::vector cases; - cases.push_back({"creator dropped", valid("creator", nullptr, none)}); - cases.push_back({"custody_mint dropped", valid("custody_mint", nullptr, none)}); - cases.push_back({"custody_decimals dropped", valid("custody_decimals", nullptr, none)}); - // The boot gate this PR adds: a pre-SOL-396 IDL is exactly the valid three - // fields with `custody_token_program` absent. - cases.push_back({"custody_token_program dropped (pre-SOL-396 IDL)", - valid("custody_token_program", nullptr, none)}); - cases.push_back({"custody_mint declared as a byte array", - valid(nullptr, "custody_mint", u8_32_array())}); - cases.push_back({"custody_decimals declared u32", - valid(nullptr, "custody_decimals", prim(idl::primitive_type::u32))}); - cases.push_back({"custody_token_program declared as a byte array", - valid(nullptr, "custody_token_program", u8_32_array())}); - cases.push_back({"creator declared as a byte array", - valid(nullptr, "creator", u8_32_array())}); - - for (const auto& c : cases) { - BOOST_TEST_CONTEXT(c.name) { - for (bool in_types : {false, true}) { - BOOST_CHECK_THROW(assert_reserve_shape(named_account_program("Reserve", c.fields, in_types)), - fc::assert_exception); - } - } - } - - // No `Reserve` account declared at all, and one declared with no field - // definition in either home. - BOOST_CHECK_THROW(assert_reserve_shape(idl::program{}), fc::assert_exception); - BOOST_CHECK_THROW(assert_reserve_shape(named_account_program("Reserve", {}, false)), - fc::assert_exception); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(collateral_position_shape_accepts_the_expected_declaration) try { - using sysio::outpost_solana_client_detail::assert_collateral_position_shape; - - const std::vector fields{ - {"bump", prim(idl::primitive_type::u8)}, - {"operator", prim(idl::primitive_type::pubkey)}, - {"token_code", prim(idl::primitive_type::u64)}, - {"custody_mint", prim(idl::primitive_type::pubkey)}, - {"amount", prim(idl::primitive_type::u64)}}; - for (bool in_types : {false, true}) { - BOOST_CHECK_NO_THROW(assert_collateral_position_shape( - named_account_program("CollateralPosition", fields, in_types))); - } -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(collateral_position_shape_rejects_drifted_declarations) try { - using sysio::outpost_solana_client_detail::assert_collateral_position_shape; - - struct reject_case { - const char* name; - std::vector fields; - }; - const auto pubkey_t = prim(idl::primitive_type::pubkey); - const auto u64_t = prim(idl::primitive_type::u64); - - std::vector cases; - cases.push_back({"operator dropped", - {{"token_code", u64_t}, {"custody_mint", pubkey_t}, {"amount", u64_t}}}); - cases.push_back({"token_code dropped", - {{"operator", pubkey_t}, {"custody_mint", pubkey_t}, {"amount", u64_t}}}); - cases.push_back({"custody_mint dropped", - {{"operator", pubkey_t}, {"token_code", u64_t}, {"amount", u64_t}}}); - cases.push_back({"amount dropped", - {{"operator", pubkey_t}, {"token_code", u64_t}, {"custody_mint", pubkey_t}}}); - cases.push_back({"operator declared as a byte array", - {{"operator", u8_32_array()}, - {"token_code", u64_t}, - {"custody_mint", pubkey_t}, - {"amount", u64_t}}}); - cases.push_back({"custody_mint declared as a byte array", - {{"operator", pubkey_t}, - {"token_code", u64_t}, - {"custody_mint", u8_32_array()}, - {"amount", u64_t}}}); - cases.push_back({"token_code declared u32", - {{"operator", pubkey_t}, - {"token_code", prim(idl::primitive_type::u32)}, - {"custody_mint", pubkey_t}, - {"amount", u64_t}}}); - cases.push_back({"amount declared u32", - {{"operator", pubkey_t}, - {"token_code", u64_t}, - {"custody_mint", pubkey_t}, - {"amount", prim(idl::primitive_type::u32)}}}); - - for (const auto& c : cases) { - BOOST_TEST_CONTEXT(c.name) { - for (bool in_types : {false, true}) { - BOOST_CHECK_THROW(assert_collateral_position_shape( - named_account_program("CollateralPosition", c.fields, in_types)), - fc::assert_exception); - } - } - } - - BOOST_CHECK_THROW(assert_collateral_position_shape(idl::program{}), fc::assert_exception); - BOOST_CHECK_THROW( - assert_collateral_position_shape(named_account_program("CollateralPosition", {}, false)), - fc::assert_exception); -} FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(decode_latest_envelope_reads_both_known_layouts) try { constexpr uint32_t epoch = 7; const auto payload = envelope_payload_bytes(epoch); @@ -3579,7 +2355,6 @@ BOOST_AUTO_TEST_CASE(decode_latest_envelope_reads_both_known_layouts) try { integrated_latest_fields(), integrated_latest_account(epoch, payload, checksum), epoch); BOOST_CHECK(bytes_equal(integrated, payload)); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(decode_latest_envelope_catches_field_order_drift) try { // The epoch=511 RCA input: an INTEGRATED account (bump=0xFF first) read // by a client whose loaded IDL declares the STANDALONE field order. The @@ -3595,7 +2370,6 @@ BOOST_AUTO_TEST_CASE(decode_latest_envelope_catches_field_order_drift) try { // must not be handed drift-garbage bytes. BOOST_CHECK(decode_with_layout(standalone_latest_fields(), integrated_account, 511).empty()); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(decode_latest_envelope_rejects_invalid_accounts) try { constexpr uint32_t epoch = 7; const auto payload = envelope_payload_bytes(epoch); @@ -3644,7 +2418,6 @@ BOOST_AUTO_TEST_CASE(decode_latest_envelope_rejects_invalid_accounts) try { epoch).empty()); } } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(borsh_payload_bytes_accepts_both_decoded_shapes) try { const std::vector payload = {0x12, 0x00, 0xFF, 0x42}; @@ -3663,7 +2436,6 @@ BOOST_AUTO_TEST_CASE(borsh_payload_bytes_accepts_both_decoded_shapes) try { BOOST_CHECK_THROW(borsh_payload_bytes(fc::variant(oversized)), fc::assert_exception); BOOST_CHECK_THROW(borsh_payload_bytes(fc::variant(static_cast(7))), fc::assert_exception); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(libfc_decode_renders_bytes_base64_and_vec_u8_as_array) try { // `borsh_payload_bytes` depends on HOW libfc's IDL decoder renders the // payload field into a variant: `bytes` as a base64 STRING, `Vec` as @@ -3706,7 +2478,6 @@ BOOST_AUTO_TEST_CASE(libfc_decode_renders_bytes_base64_and_vec_u8_as_array) try } } } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_CASE(select_program_idls_prefers_declared_address_match) try { const auto deployed = measurement_pubkey(90); const auto other = measurement_pubkey(91); @@ -3742,5 +2513,4 @@ BOOST_AUTO_TEST_CASE(select_program_idls_prefers_declared_address_match) try { select_program_idls_matching({named_program(""), named_program("")}, deployed), fc::assert_exception); } FC_LOG_AND_RETHROW(); - BOOST_AUTO_TEST_SUITE_END() diff --git a/plugins/underwriter_plugin/CMakeLists.txt b/plugins/underwriter_plugin/CMakeLists.txt deleted file mode 100644 index 13c8b8a017..0000000000 --- a/plugins/underwriter_plugin/CMakeLists.txt +++ /dev/null @@ -1,20 +0,0 @@ -plugin_target( - underwriter_plugin - LIBRARIES - appbase - chainbase - chain_plugin - cron_plugin - http_plugin - signature_provider_manager_plugin - outpost_client_plugin - outpost_ethereum_client_plugin - outpost_solana_client_plugin - opp - fc - sysio_chain) - -if(TARGET test_underwriter_plugin) - target_include_directories(test_underwriter_plugin PRIVATE - ${CMAKE_SOURCE_DIR}/libraries/opp/test/include) -endif() diff --git a/plugins/underwriter_plugin/README.md b/plugins/underwriter_plugin/README.md deleted file mode 100644 index daa63f35c0..0000000000 --- a/plugins/underwriter_plugin/README.md +++ /dev/null @@ -1,276 +0,0 @@ -# underwriter_plugin - -Autonomous underwriter daemon. Polls `sysio.uwrit::uwreqs` for PENDING -swaps, derives each candidate's authoritative stored-evidence state, and -submits signed `UnderwriteIntentCommit` records only for missing outpost legs. -Before submitting, it budgets the candidate's full eventual depot collateral -requirement, including stored or locally confirmed legs whose locks do not yet -exist. A complete depot candidate never automatically replays a paid outpost -commit; it remains reserved while the request awaits an external exact-UIC -replay. - -The underwriter is a **separate daemon** from the batch operator. It does -not relay OPP envelopes — that is the batch operator's job. The -underwriter signs and submits underwriting commits to outposts. The depot -receives those commits through ordinary OPP dispatch and performs the terminal -winner-selection decision. - -## Lifecycle - -### Startup pre-flight (unconditional, no dev escape hatch) - -`plugin_startup` runs a series of checks before scheduling the cron job; -any gating failure logs a structured `elog` and skips cron registration: - -1. The underwriter exists in `sysio.opreg::operators`. Its status is observed - but non-gating because bootstrap may activate it after node startup; - `poll_own_status()` blocks work until it is `OPERATOR_STATUS_ACTIVE`. -2. Every **active** non-depot chain in `sysio.chains::chains` has a - configured `--underwriter-{eth,sol}-outpost` endpoint of the matching - VM family. The served set is derived from the registry while the - outpost clients are built from config, so a missing or wrong-family - endpoint would let the scan loop pick a request it cannot fully - commit — landing one leg and stalling the other. Inactive - (not-yet-`activchain`ed) chains are skipped, so registering a future - chain never blocks startup before its endpoint/collateral land. -3. `sysio.authex::links` covers every active chain in `sysio.chains::chains` — - the underwriter cannot sign a commit on a chain it has no authex link for. -4. The required source-deposit function / instruction names resolve - against the loaded ABI / IDL files; exactly one operator-configured WIRE - UIC provider whose public key is a direct key whose weight alone reaches the - underwriter account's current `owner` or `active` threshold is selected - (automatically registered defaults and unrelated WIRE/block-signing - providers are ignored); - its public key and self-test signature use the same supported - fixed-size recoverable variant (K1, R1, EM, or ED); and the signature - self-test uses compact recovery headers `31..34` for K1/R1 or Ethereum - recovery values `27..30` for EM, is scalar-valid and low-`s` for ECDSA, - and recovers that threshold-satisfying key. If multiple - operator-configured providers can each satisfy one of those permissions, - startup fails as ambiguous instead of selecting by registry order. The - provider targets `chain=wire`; its native key type is - `wire` for K1/R1, `ethereum` for EM, or `solana` for ED. - -Raw collateral is observed during preflight but zero balance is non-fatal: -bootstrap may deposit after the node starts. Runtime admission uses each -request's exact `(chain_code, token_code)` buckets, so an unrelated active -chain with no collateral does not halt otherwise coverable work. - -No `--strict=false` flag, no dev fallback. Cluster bootstrap is -responsible for establishing the required state — see -`feedback_no_dev_escape_hatches.md`. - -### Per-cycle scan - -Every `--underwriter-scan-interval-ms` (default 5 s): - -1. `poll_own_status()` — short-circuit if the underwriter's status has - flipped to `SLASHED` / `TERMINATED`. -2. `read_outpost_registry()` — refresh the `(chain_code → chain_kind)` - cache from `sysio.chains::chains`. -3. `read_credit_lines()` — compute available bond per - `(chain_code, token_code)` by mirroring the depot's `sysio.opreg::available()` - math: - - available = balance(opreg::balances) - − sum(uwrit::locks where underwriter == self) - − sum(opreg::wtdwqueue where account == self) - -4. `scan_pending_requests()` — walk the small in-flight - `sysio.uwrit::uwreqs` KV table, filter to `PENDING` rows, and derive this - underwriter's absent, partial, complete, or terminal candidate state - from the authoritative `commits_by` row. -5. Remove terminal candidates before cover selection. For a complete stored - candidate, reserve its full eventual bond while it awaits an external exact - UIC replay; do not automatically replay either paid outpost transaction. -6. For candidates whose remaining legs are only locally confirmed, reserve the - full eventual bond while OPP catches up, then remove them from submission - work. Saturating reservation prevents a concurrent shortfall from exposing - leftover capacity to a new paid commit. -7. For remaining absent/partial candidates, run the bounded branch-and-bound - selector (with its value-sorted fallback) over every eventual non-depot - lock, including a leg whose UIC is already stored or locally confirmed. The - selector uses the daemon's current credit snapshot to avoid knowingly - submitting a candidate the depot cannot cover. `submit_intent_to_outpost()` - still sends only missing legs. This pre-validation is advisory because - collateral can change before OPP delivery: the contract rechecks live state - atomically, creates locks on success, and otherwise disqualifies only that - candidate while the request remains PENDING for another underwriter. - -Fresh outpost evidence, including an exact replay of a stored UIC, can make -request-level terminal decisions such as a variance rejection. If the route, -quote, or liquidity is still unsuitable, the row remains PENDING for another -external replay or for expiry pruning. - -### Commit submission (`create_signed_uic_bytes`) - -For each leg of every selected uwreq: - -1. Construct a proto `UnderwriteIntentCommit` with `uw_account`, - `uw_request_id`, `chain_code`, and a blank `signature`. Populate - `uw_ext_chain_addr` from the same concrete outpost client's authenticated - transaction signer (20-byte EVM address or 32-byte Solana public key), so - every production field has one unambiguous serialized representation. -2. Serialize the proto, compute `sha256(blanked_bytes)` — the digest. -3. Sign with the permission-authorized provider selected and cached during - startup preflight. Runtime signing never re-queries or switches providers; - a null cache fails closed and no UIC is emitted. Named and anonymous - `--signature-provider` entries qualify, but generated defaults and unrelated - WIRE signers do not. K1/R1 use the `wire` native key type, EM uses - `ethereum`, and ED uses `solana`. The fc::crypto::signature is packed via - `fc::raw::pack` into the wire format the depot's - `sysio.uwrit::verify_uic_signature` reads. -4. Place the packed signature back into the proto, re-serialize, and - submit those bytes verbatim to the outpost — `commit(bytes uicBytes)` - on Ethereum, `commit_underwrite(uic_bytes)` on Solana. - -The current outposts require an ACTIVE-role transaction caller, decode and -canonically re-encode the complete UIC, and require the original bytes to match. -They bind `uw_account` and `uw_ext_chain_addr` to that authenticated caller and -its current roster row, then enqueue the original validated UIC bytes unchanged. -The outposts do not validate the embedded WIRE permission signature or bond; -those remain the depot's authoritative responsibilities. Before storing a leg, -the depot first requires the complete signed protobuf bytes to equal the -CDT generator's canonical re-encoding. It then reconstructs the digest and -accepts exactly the canonical packed fixed-size recoverable shapes: K1/R1/EM -(variant tags `0`/`1`/`3` plus a 65-byte ECC body) or ED (tag `4` plus a -96-byte body). K1 and R1 require recovery headers `31..34`; EM requires recovery -values `27..30`. All three ECDSA variants additionally require in-range scalars -and low-`s`. -It constructs that known variant directly, recovers the signing key, and -accepts it only when that direct key's weight alone reaches the `active` or -`owner` permission threshold on the claimed `uw_account`. WebAuthn and BLS -remain unsupported. Malformed, -noncanonical, unsupported, or unauthorized incoming signatures are logged and -ignored without changing candidate evidence or aborting consensus dispatch. -When a second required leg arrives, winner selection revalidates only the older -stored leg so permission-key rotation cannot authorize stale evidence. A -candidate disqualified by that check remains durably disqualified for the -request; later records cannot replace its evidence, refresh its timestamps, or -re-arm it. The `UIC_SIGNATURE_REJECTED` marker names the -account field as `claimed_underwriter` and reports the provenance-bound -`chain_code`; it identifies the rejected candidate claim, not an independently -authenticated depot submitter. - -## Configuration - -| Option | Default | Description | -|---|---|---| -| `--underwriter-account` | — | WIRE account name for this underwriter. Configuring it enables the scan cycle | -| `--underwriter-scan-interval-ms` | 5000 | How often to scan for pending uwreqs (ms) | -| `--underwriter-action-timeout-ms` | 15000 | Timeout for outpost RPC calls and table reads (ms) | -| `--underwriter-eth-source-deposit-function` | — | Name of the ETH swap-deposit function; the chain-agnostic 4-byte selector is resolved at preflight from the loaded `--ethereum-abi-file` ABIs (required) | -| `--underwriter-sol-source-deposit-instruction` | — | Name of the SOL swap-deposit instruction; the 8-byte anchor discriminator is resolved at preflight from the loaded `--solana-idl-file` IDLs (required) | -| `--underwriter-eth-source-deposit-lookback-blocks` | 7200 | Recent finalized ETH blocks searched per source deposit | - -### Outpost wiring - -Nothing about an outpost is declared per node. - -* **Which chains** — every active non-depot `sysio.chains` row. There is no - per-chain config entry to keep in sync with the registry. -* **Where each one lives** — the row's `outpost` struct: the EVM - OperatorRegistry (the `uw_commit` target) and source-deposit contract, or the - single SVM outpost program, which stands in for both roles. Every underwriter - therefore commits against the same deployment. -* **How to reach it** — the RPC client registered under that chain's **own - code**. `--outpost-ethereum-client` / `--outpost-solana-client` take the client - id as their first field, and for an outpost that id must be the chain code - (`ETHEREUM`, `SOLANA`, ...). - -Preflight fails closed when an active chain has no RPC client registered under -its code, or when its row carries no contract addresses. A -`sysio.chains::setoutpost` redeploy is picked up on the next scan tick — the -client handle is rebuilt against the new address. - -SEC-13/WSA-027 is preserved by construction: the client is keyed by exact chain -code, so two chains of the same VM family are wired independently and a -wrong-family entry is simply a client that is not there. - -## HTTP diagnostics - -Read-only diagnostic endpoints, served by `http_plugin` on the -read-only exec queue: - -- `/v1/underwriter/stats` — session counters + config snapshot: - underwriter account, enabled/active flags, scan + timeout intervals, - per-chain outpost wiring (`chain_code`, `kind`, `client_id`, - `commit_addr`, `source_deposit_addr`), uwreq/commit/failure/mismatch - counters, outstanding-commit count, SOL source-deposit cursor health. -- `/v1/underwriter/commits` — outstanding confirmed commits, one entry - per leg: `uwreq_id`, `chain_code`, `token_code`, `reserve_code`. - -Both carry a `status` discriminator. Until the deferred startup body -completes they report the startup-gate state instead of the payload -(`waiting_for_sync` with `head_behind_sec` / `lib_behind_sec`, -`preflight_retrying`, or a terminal `preflight_failed` / -`wiring_failed` / `startup_failed` with `detail`); once the gate opens -they serve the payloads above with `status: "active"`. - -The endpoints are registered only when the underwriter is enabled, -which means only when `--underwriter-account` is configured. With the -plugin loaded but no account, `plugin_startup` skips endpoint -registration and every listener returns 404 for these routes. - -### Listener exposure - -The endpoints live in the dedicated `underwriter` HTTP API category — -not the always-on `node` category — because they expose operator -metadata (account identity, client ids, outpost contract addresses, -the outstanding-commit ledger). - -Default deployments are unchanged: the all-category listeners -(`--http-server-address`, `--unix-socket-path`) serve -`/v1/underwriter/*` as before. - -Category-isolated deployments -(`--http-server-address http-category-address`) must opt in -explicitly: listeners without the `underwriter` category return 404 -for these routes and omit them from `/v1/node/get_supported_apis`. -Bind the category to loopback or a private management network: - -``` -nodeop \ - --http-server-address http-category-address \ - --http-category-address underwriter,127.0.0.1:8890 \ - --plugin sysio::underwriter_plugin \ - --underwriter-account myuwriter \ - ... -``` - -The listener also serves the node-global endpoints, which are -reachable on every listener by design: `/v1/node/get_supported_apis` -(always registered) and `/v1/chain/get_info` when -`sysio::chain_api_plugin` is loaded — the underwriter depends only on -`chain_plugin`, so the example above does not load it. Like every -category, -`underwriter` is validated against its owning plugin: naming it in -`--http-category-address` without `--plugin sysio::underwriter_plugin` -is a startup configuration error. Binding it to a non-loopback address -logs a startup warning (the same pattern as the `snapshot_ro` exposure -notice). - -Query them directly over HTTP, e.g. -`curl http://127.0.0.1:8890/v1/underwriter/stats`. - -## Dependencies - -- `chain_plugin` — read-only table access against `sysio.opreg`, `sysio.uwrit`, `sysio.authex`, `sysio.chains`. -- `cron_plugin` — scheduled scan loop. -- `signature_provider_manager_plugin` — fixed-size recoverable WIRE signer for - the UIC digest (K1, R1, EM, or ED). -- `outpost_ethereum_client_plugin` — ETH RPC + ABI loader for the `commit(bytes)` call. -- `outpost_solana_client_plugin` — SOL RPC + IDL loader for the `commit_underwrite(uic_bytes)` call. - -## Deferred / follow-up - -The current implementation covers canonical commit construction, bounded -cover selection, full-candidate collateral pre-validation, terminal depot -resolution, and restart-safe missing-leg routing. The following optional -locator improvement remains out of scope: - -- **Source-deposit locator hardening** — the current verifier validates - `SwapRequest.source_tx_id` before committing; ETH uses a bounded - `eth_getLogs` window over finalized blocks, while SOL reads the source - tx directly. Future work can carry a richer tx/block locator to avoid - event search entirely. diff --git a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/routing_detail.hpp b/plugins/underwriter_plugin/include/sysio/underwriter_plugin/routing_detail.hpp deleted file mode 100644 index 7e61c41544..0000000000 --- a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/routing_detail.hpp +++ /dev/null @@ -1,175 +0,0 @@ -#pragma once -/** - * @file routing_detail.hpp - * @brief Pure, side-effect-free routing/accounting helpers for the underwriter - * plugin, lifted out of the `.cpp`-private `impl` so they are unit-testable - * without standing up a chain. - * - * SEC-13 / WSA-027: the depot identifies every value-bearing underwrite leg by - * its EXACT slug codes — `chain_code`, `token_code`, `reserve_code` — and an - * underwriter's collateral is posted per exact `(chain_code, token_code)` on every - * registered outpost. Two active chains of the same VM family (e.g. two EVM - * outposts) therefore hold INDEPENDENT collateral and back DISTINCT legs. The - * keys below are exact slug values (the packed `fc::slug_name` `uint64`), never the - * coarse `ChainKind`/`TokenKind` family enums, so same-family chains never collide. - * - * Codes are carried here as the raw `uint64` `fc::slug_name::value` (not - * `fc::slug_name`) to keep this header free of fc/opp dependencies — the plugin - * converts at the boundary, and tests can construct keys from plain integers. - */ - -#include -#include -#include - -namespace sysio::underwriter_detail { - -/// Identity of a collateral / credit bucket: the exact `(chain_code, token_code)` -/// slug pair. Collateral is posted per chain+token (NOT per reserve — multiple -/// reserves on one `(chain, token)` draw from the same bond), mirroring the -/// `sysio.opreg::operators.balances` row key. -struct bucket_key { - uint64_t chain_code = 0; ///< `fc::slug_name::value` of the leg's chain. - uint64_t token_code = 0; ///< `fc::slug_name::value` of the leg's token. - friend auto operator<=>(const bucket_key&, const bucket_key&) = default; -}; - -/// One swap leg's bond requirement for the bucket solver. `require == 0` marks a -/// WIRE depot leg (no outpost, no bond, no bucket consulted). -struct leg_bond { - bucket_key bucket{}; - uint64_t require = 0; -}; - -/// Construct the full winner-time collateral requirement for one candidate -/// leg. Only depot legs require no collateral. A UIC already submitted to or -/// stored from an outpost has not created a depot lock, so it still consumes -/// capacity during pre-validation of the candidate's final missing leg. -inline leg_bond candidate_leg_bond(bucket_key bucket, - uint64_t amount, - bool is_depot) { - return { - .bucket = bucket, - .require = is_depot ? 0 : amount, - }; -} - -/// Per-bucket spendable credit: exact `(chain_code, token_code)` → balance. -using credit_buckets = std::map; - -/// Attempt to debit a request's two legs from `remaining`. Returns true (and -/// mutates `remaining`) iff every required leg fits; leaves `remaining` untouched -/// on failure. A request with both legs requiring zero (degenerate depot/depot) -/// returns false. Same-bucket dual legs (e.g. ERC20 → native on one chain) debit -/// both from the single shared row, so a same-`(chain, token)` swap needs the sum. -inline bool try_debit_buckets(credit_buckets& remaining, - const leg_bond& src, const leg_bond& dst) { - const uint64_t src_req = src.require; - const uint64_t dst_req = dst.require; - if (src_req == 0 && dst_req == 0) return false; - - // Same bucket on both legs: a single row must cover the combined draw. - if (src_req > 0 && dst_req > 0 && src.bucket == dst.bucket) { - auto it = remaining.find(src.bucket); - // Sum the two user-controlled requirements in 128-bit width: a pair - // whose true sum exceeds UINT64_MAX (e.g. UINT64_MAX + 1) must never - // wrap to a small uint64 and make an impossible request look coverable. - const __uint128_t combined = static_cast<__uint128_t>(src_req) + dst_req; - if (it == remaining.end() || it->second < combined) return false; - // Reached only when combined <= it->second <= UINT64_MAX, so the draw - // fits a uint64 exactly and the debit cannot underflow. - it->second -= static_cast(combined); - return true; - } - - // Distinct buckets: check both fit BEFORE mutating either, so a partial - // failure never leaves `remaining` half-debited. - if (src_req > 0) { - auto it = remaining.find(src.bucket); - if (it == remaining.end() || it->second < src_req) return false; - } - if (dst_req > 0) { - auto it = remaining.find(dst.bucket); - if (it == remaining.end() || it->second < dst_req) return false; - } - if (src_req > 0) remaining[src.bucket] -= src_req; - if (dst_req > 0) remaining[dst.bucket] -= dst_req; - return true; -} - -/// Conservatively reserve the future depot locks for an in-flight candidate -/// that has no outpost submission work left. Unlike `try_debit_buckets`, this -/// operation is intentionally saturating: if concurrent state changes have -/// already reduced a bucket below the outstanding requirement, no additional -/// request may consume the remainder while the paid UIC is still reaching the -/// depot. -inline void reserve_buckets(credit_buckets& remaining, - const leg_bond& src, const leg_bond& dst) { - const auto reserve = [&](const leg_bond& leg) { - if (leg.require == 0) return; - auto it = remaining.find(leg.bucket); - if (it == remaining.end()) return; - it->second = it->second > leg.require ? it->second - leg.require : 0; - }; - reserve(src); - reserve(dst); -} - -/// Local commit de-dup key — one CONFIRMED leg. Keyed by the exact leg -/// identity `(uwreq_id, chain_code, token_code, reserve_code)` so two legs that -/// differ only by chain OR reserve (e.g. a same-`(chain, token)` swap with two -/// reserves) are tracked independently and never suppress each other. -struct commit_key { - uint64_t uwreq_id = 0; - uint64_t chain_code = 0; ///< `fc::slug_name::value` - uint64_t token_code = 0; ///< `fc::slug_name::value` - uint64_t reserve_code = 0; ///< `fc::slug_name::value` - friend auto operator<=>(const commit_key&, const commit_key&) = default; -}; - -/// Restart-safe work derived from one candidate's authoritative depot row. -/// A partial candidate submits only missing outpost legs. A complete candidate -/// awaits an external exact-UIC replay; an existing non-submitted terminal -/// candidate is still skipped. Depot legs never submit. -struct stored_commit_plan { - bool skip_candidate = false; - bool submit_source = false; - bool submit_destination = false; -}; - -/// True when at least one leg still requires a paid outpost submission after -/// combining the restart-safe depot plan with this process's confirmations. -inline bool has_submission_work(const stored_commit_plan& plan, - bool source_confirmed_locally, - bool destination_confirmed_locally) { - return (plan.submit_source && !source_confirmed_locally) || - (plan.submit_destination && !destination_confirmed_locally); -} - -/// Derive the restart-safe submission plan from one authoritative depot -/// candidate. Existing terminal candidates are skipped. A complete candidate -/// has no local submission work; otherwise only missing non-depot UIC legs -/// remain eligible for submission. -inline stored_commit_plan plan_stored_commits(bool candidate_exists, - bool intent_submitted, - bool source_is_depot, - bool destination_is_depot, - bool source_uic_stored, - bool destination_uic_stored) { - if (candidate_exists && !intent_submitted) { - return {.skip_candidate = true}; - } - const bool source_complete = source_is_depot || - (intent_submitted && source_uic_stored); - const bool destination_complete = destination_is_depot || - (intent_submitted && destination_uic_stored); - if (intent_submitted && source_complete && destination_complete) { - return {}; - } - return { - .submit_source = !source_complete, - .submit_destination = !destination_complete, - }; -} - -} // namespace sysio::underwriter_detail diff --git a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/solana_source_deposit_scanner.hpp b/plugins/underwriter_plugin/include/sysio/underwriter_plugin/solana_source_deposit_scanner.hpp deleted file mode 100644 index a18fa7c365..0000000000 --- a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/solana_source_deposit_scanner.hpp +++ /dev/null @@ -1,324 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace sysio::underwriter { - -/// Result categories for scanning one Solana `getSignaturesForAddress` page -/// for an authenticated `SwapDeposit` source-deposit marker. -enum class solana_source_deposit_page_status { - matched, - not_found, - deferred, - hard_mismatch -}; - -/// Inputs shared by every transaction inspected in one signature page. -struct solana_source_deposit_page_scan_config { - /// Deployed opp-outpost program id resolved from the loaded Anchor IDL. - std::string_view sol_program_id; - /// Full log prefix ending immediately before the 64-character hash field. - std::string_view marker_prefix; - /// Expected correlation hash recomputed from the depot UWREQ row. - const fc::crypto::keccak256& recomputed_hash; -}; - -/// Outcome details from scanning a single Solana signature page. -struct solana_source_deposit_page_scan_result { - solana_source_deposit_page_status status = solana_source_deposit_page_status::not_found; - /// Matching transaction signature when `status == matched`. - std::string matched_signature; - /// Last valid signature in the page. Use as the next `before` cursor only - /// when `status == not_found` and `page_exhausted == false`. - std::optional next_before; - /// True when the page had fewer results than the RPC page limit, so there - /// is no older page to request for this address history. - bool page_exhausted = false; - /// Human-readable reason for hard mismatches or deferred scans. - std::string reason; -}; - -/// Fetches one decoded Solana transaction for the supplied base58 signature. -using solana_transaction_fetcher = std::function; - -/// Cursor key for SOL source-deposit discovery. The uwreq id bounds the -/// cursor to one depot request while the deposit id prevents accidental reuse -/// if a stale entry survives across schema or replay changes. -struct solana_source_deposit_scan_key { - uint64_t uwreq_id; - uint64_t deposit_id; - friend auto operator<=>(const solana_source_deposit_scan_key&, - const solana_source_deposit_scan_key&) = default; -}; - -/// Persistent SOL signature scan progress for one pending UWREQ. -struct solana_source_deposit_scan_cursor { - /// Signature cursor passed to `getSignaturesForAddress(before=...)`. - std::optional before; - /// Number of pages inspected while walking this UWREQ's source deposit. - uint64_t pages_scanned = 0; - /// Number of signature-list entries inspected across scanned pages. - uint64_t signatures_scanned = 0; - /// True once verification found a terminal source-deposit failure. - bool terminal_failure = false; - /// First terminal failure reason, kept for later short-circuit logs. - std::string terminal_failure_reason; -}; - -/// Per-pending-UWREQ Solana scan state. -using solana_source_deposit_scan_cursor_map = - std::map; - -/// Result from recording a terminal SOL source-deposit verification failure. -struct solana_source_deposit_terminal_failure_result { - /// Updated cursor state after recording the failure. - solana_source_deposit_scan_cursor cursor; - /// True only for the first terminal failure recorded for this UWREQ. - bool first_failure = false; -}; - -/// Returns the current SOL source-deposit scan cursor for `key`, creating one -/// at the newest page when this is the first scan attempt for a UWREQ. -inline solana_source_deposit_scan_cursor -get_or_create_solana_source_deposit_scan_cursor(solana_source_deposit_scan_cursor_map& cursors, - const solana_source_deposit_scan_key& key) { - return cursors[key]; -} - -/// Returns terminal failure state for a SOL source-deposit scan, when present. -inline std::optional -get_solana_source_deposit_terminal_failure(const solana_source_deposit_scan_cursor_map& cursors, - const solana_source_deposit_scan_key& key) { - const auto it = cursors.find(key); - if (it == cursors.end() || !it->second.terminal_failure) return std::nullopt; - return it->second; -} - -/// Persists the next SOL source-deposit `before` cursor after a clean page -/// that did not contain the target marker. -inline solana_source_deposit_scan_cursor -advance_solana_source_deposit_scan_cursor(solana_source_deposit_scan_cursor_map& cursors, - const solana_source_deposit_scan_key& key, - std::string before, - size_t signature_count) { - auto& cursor = cursors[key]; - cursor.before = std::move(before); - cursor.pages_scanned++; - cursor.signatures_scanned += signature_count; - return cursor; -} - -/// Records a terminal SOL source-deposit failure without allowing future scan -/// cycles to re-walk the same pending UWREQ from the newest signature page. -inline solana_source_deposit_terminal_failure_result -record_solana_source_deposit_terminal_failure(solana_source_deposit_scan_cursor_map& cursors, - const solana_source_deposit_scan_key& key, - std::string reason, - size_t signature_count) { - auto& cursor = cursors[key]; - const bool first_failure = !cursor.terminal_failure; - if (first_failure) { - cursor.pages_scanned++; - cursor.signatures_scanned += signature_count; - cursor.terminal_failure = true; - cursor.terminal_failure_reason = std::move(reason); - } - return {.cursor = cursor, .first_failure = first_failure}; -} - -/// Drops SOL source-deposit scan progress for a UWREQ once it matches or -/// leaves the PENDING set. -inline void erase_solana_source_deposit_scan_cursor(solana_source_deposit_scan_cursor_map& cursors, - const solana_source_deposit_scan_key& key) { - cursors.erase(key); -} - -/// Prunes SOL source-deposit scan state for UWREQs that are no longer pending. -template -void prune_solana_source_deposit_scan_cursors(solana_source_deposit_scan_cursor_map& cursors, - const PendingUwreqIds& still_pending) { - std::erase_if(cursors, [&](const auto& entry) { - return !still_pending.contains(entry.first.uwreq_id); - }); -} - -/// Returns true when a Solana log line opens, closes, or otherwise describes -/// an invocation frame rather than carrying a user `Program log:` payload. -inline bool is_solana_program_frame_line(const std::string& line) { - return boost::algorithm::starts_with(line, "Program ") && - !boost::algorithm::starts_with(line, "Program log:") && - !boost::algorithm::starts_with(line, "Program data:") && - !boost::algorithm::starts_with(line, "Program return:"); -} - -/// Updates the executing-program stack from one Solana runtime frame line. -inline void update_solana_program_stack(const std::string& line, - std::vector& program_stack) { - std::string_view rest{line}; - rest.remove_prefix(8); // strip "Program " - const size_t sp1 = rest.find(' '); - if (sp1 == std::string_view::npos) return; - - const std::string_view prog = rest.substr(0, sp1); - std::string_view after = rest.substr(sp1 + 1); - const size_t sp2 = after.find(' '); - const std::string_view verb = (sp2 == std::string_view::npos) ? after : after.substr(0, sp2); - if (verb == "invoke") { - program_stack.emplace_back(prog); - } else if (verb.starts_with("success") || verb.starts_with("failed")) { - if (!program_stack.empty()) program_stack.pop_back(); - } -} - -/// Decodes one lower-case hex digit from the canonical marker hash. -inline std::optional decode_lowercase_hex_digit(char c) { - if (c >= '0' && c <= '9') return static_cast(c - '0'); - if (c >= 'a' && c <= 'f') return static_cast(c - 'a' + 10); - return std::nullopt; -} - -/// Parses the 64-character lower-case hex hash suffix emitted by -/// `opp-outpost::request_swap` after the canonical marker prefix. -inline std::optional> parse_solana_swap_deposit_hash(std::string_view hash_hex) { - if (hash_hex.size() != 64) return std::nullopt; - - std::array parsed{}; - for (size_t i = 0; i < parsed.size(); ++i) { - const auto high = decode_lowercase_hex_digit(hash_hex[i * 2]); - const auto low = decode_lowercase_hex_digit(hash_hex[i * 2 + 1]); - if (!high || !low) return std::nullopt; - parsed[i] = static_cast((*high << 4) | *low); - } - return parsed; -} - -/// Scans one page of Solana program signatures for a source-deposit marker. -/// -/// The function is deliberately free of plugin state: production code supplies -/// the RPC-backed transaction fetcher, and unit tests supply an in-memory -/// fetcher. Pagination is page-at-a-time; callers persist `next_before` only -/// after a clean `not_found` result so transient transaction fetch failures -/// cannot advance past an uninspected candidate. -inline solana_source_deposit_page_scan_result -scan_solana_source_deposit_signature_page(const std::vector& sigs, - const solana_transaction_fetcher& get_transaction, - const solana_source_deposit_page_scan_config& config) { - solana_source_deposit_page_scan_result result; - result.page_exhausted = sigs.size() < SOL_SIGNATURE_SCAN_PAGE_SIZE; - bool saw_uninspected_candidate = false; - std::string deferred_reason; - - for (const auto& sig_var : sigs) { - if (!sig_var.is_object()) continue; - const auto sig_obj = sig_var.get_object(); - if (!sig_obj.contains("signature") || !sig_obj["signature"].is_string()) continue; - const std::string sig_b58 = sig_obj["signature"].as_string(); - result.next_before = sig_b58; - - // Skip failed txs at the listing level. They cannot have emitted the - // successful source-deposit marker. - if (sig_obj.contains("err") && !sig_obj["err"].is_null()) { - continue; - } - - fc::variant tx; - try { - tx = get_transaction(sig_b58); - } catch (const fc::exception& e) { - saw_uninspected_candidate = true; - if (deferred_reason.empty()) { - deferred_reason = "getTransaction(" + sig_b58 + ") RPC error: " + e.to_detail_string(); - } - continue; - } - if (tx.is_null() || !tx.is_object()) continue; - const auto tx_obj = tx.get_object(); - if (!tx_obj.contains("meta") || !tx_obj["meta"].is_object()) continue; - const auto meta_obj = tx_obj["meta"].get_object(); - if (meta_obj.contains("err") && !meta_obj["err"].is_null()) continue; - if (!meta_obj.contains("logMessages") || !meta_obj["logMessages"].is_array()) continue; - - std::vector program_stack; - for (const auto& line_var : meta_obj["logMessages"].get_array()) { - if (!line_var.is_string()) continue; - const std::string line = line_var.as_string(); - - if (is_solana_program_frame_line(line)) { - update_solana_program_stack(line, program_stack); - continue; - } - - if (!boost::algorithm::starts_with(line, config.marker_prefix)) { - continue; - } - - // Attribute "Program log:" to the program currently executing. - if (program_stack.empty() || program_stack.back() != config.sol_program_id) { - continue; - } - - const auto hash_hex = std::string_view{line}.substr(config.marker_prefix.size()); - const auto on_chain_hash = parse_solana_swap_deposit_hash(hash_hex); - if (!on_chain_hash) { - result.status = solana_source_deposit_page_status::hard_mismatch; - result.next_before.reset(); - result.reason = "marker hash is malformed"; - return result; - } - - if (std::memcmp(config.recomputed_hash.data(), on_chain_hash->data(), on_chain_hash->size()) != 0) { - const std::string want_hex = - fc::to_hex(reinterpret_cast(config.recomputed_hash.data()), 32); - result.status = solana_source_deposit_page_status::hard_mismatch; - result.next_before.reset(); - result.reason = "SwapDeposit hash mismatch: recomputed=" + want_hex + - " on-chain=" + std::string{hash_hex}; - return result; - } - - std::string conf_status; - if (sig_obj.contains("confirmationStatus") && sig_obj["confirmationStatus"].is_string()) { - conf_status = sig_obj["confirmationStatus"].as_string(); - } - if (conf_status != "finalized") { - result.status = solana_source_deposit_page_status::deferred; - result.next_before.reset(); - result.reason = "matching tx is not finalized"; - return result; - } - - result.status = solana_source_deposit_page_status::matched; - result.matched_signature = sig_b58; - result.next_before.reset(); - return result; - } - } - - if (saw_uninspected_candidate) { - result.status = solana_source_deposit_page_status::deferred; - result.next_before.reset(); - result.reason = std::move(deferred_reason); - } - - return result; -} - -} // namespace sysio::underwriter diff --git a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/source_deposit_constants.hpp b/plugins/underwriter_plugin/include/sysio/underwriter_plugin/source_deposit_constants.hpp deleted file mode 100644 index 16033e7ce7..0000000000 --- a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/source_deposit_constants.hpp +++ /dev/null @@ -1,113 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace sysio::underwriter { - -/// Byte length of an EVM address carried in `SwapRequest.actor.address`. -inline constexpr size_t EVM_DEPOSITOR_SIZE = 20; - -/// Byte length of a Solana Ed25519 public key carried in -/// `SwapRequest.actor.address`. -inline constexpr size_t SVM_DEPOSITOR_SIZE = 32; - -/// ETH: maximum number of recent blocks one source-deposit `eth_getLogs` -/// lookup may cover. -/// -/// The verifier only has the outpost-local `SwapDeposit.id`, so it still -/// needs an event lookup. Keep that lookup bounded to protect underwriter -/// scan cycles and EVM RPC providers from whole-history scans on absent or -/// expensive ids. The window is anchored on Ethereum's `finalized` block so -/// the underwriter never fronts collateral against reversible source-chain -/// history. 7200 mainnet blocks is roughly one day at 12 seconds per block; -/// deployments with a different source-chain cadence can override the plugin -/// option without changing code. -inline constexpr uint64_t ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS = 7200; - -/// Configuration option name for the EVM source-deposit log lookup window. -inline constexpr std::string_view ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS_OPTION = - "underwriter-eth-source-deposit-lookback-blocks"; - -/// JSON-RPC quantity prefix used for explicit EVM block numbers. -inline constexpr std::string_view ETH_JSON_RPC_QUANTITY_PREFIX = "0x"; - -/// Parses an Ethereum JSON-RPC quantity into a block number. -/// -/// The parser requires the canonical `0x` prefix, a non-empty hex body, full -/// consumption, and no overflow. Malformed provider fields return -/// `std::nullopt` so callers can fail closed without throwing through the scan -/// cycle. -inline std::optional eth_parse_block_quantity(std::string_view block_quantity) { - if (block_quantity.size() <= ETH_JSON_RPC_QUANTITY_PREFIX.size()) return std::nullopt; - if (block_quantity[0] != '0' || (block_quantity[1] != 'x' && block_quantity[1] != 'X')) { - return std::nullopt; - } - - block_quantity.remove_prefix(ETH_JSON_RPC_QUANTITY_PREFIX.size()); - uint64_t out = 0; - const char* const begin = block_quantity.data(); - const char* const end = begin + block_quantity.size(); - const auto [ptr, ec] = std::from_chars(begin, end, out, 16); - if (ec != std::errc{} || ptr != end) return std::nullopt; - return out; -} - -/// Returns the inclusive lower block bound for a bounded EVM source-deposit -/// log lookup. -/// -/// `lookback_blocks` is the maximum inclusive span. A one-block lookback -/// searches only `head_block`; larger values walk backward from the captured -/// head without underflowing below genesis. A zero value is invalid at the -/// plugin option boundary, but this helper defensively collapses it to the -/// current head so arithmetic remains well-defined in tests and callers. -inline constexpr uint64_t eth_source_deposit_from_block(uint64_t head_block, uint64_t lookback_blocks) { - if (lookback_blocks <= 1) return head_block; - const uint64_t trailing_blocks = lookback_blocks - 1; - return head_block > trailing_blocks ? head_block - trailing_blocks : 0; -} - -/// Formats an EVM block number as a JSON-RPC quantity string. -/// -/// Ethereum JSON-RPC expects explicit block numbers as lowercase hex with a -/// `0x` prefix and no leading zeroes, e.g. `0x0`, `0xf`, `0x10`. -inline std::string eth_block_quantity(uint64_t block_num) { - char digits[sizeof(uint64_t) * 2]{}; - auto [ptr, ec] = std::to_chars(digits, digits + sizeof(digits), block_num, 16); - (void)ec; - - std::string out{ETH_JSON_RPC_QUANTITY_PREFIX.data(), ETH_JSON_RPC_QUANTITY_PREFIX.size()}; - out.append(digits, ptr); - return out; -} - -/// SOL: the JSON-RPC commitment level used when fetching a source-deposit -/// tx. `confirmed` = the tx has been voted on by a supermajority of the -/// cluster (one-vote level, ~400ms-2s after submission, typical Solana -/// reorg-immunity threshold for non-critical reads). `processed` is -/// faster (leader-only, can reorg) and `finalized` is slower (~12.8s+ -/// for 32-slot finality). -/// -/// `confirmed` strikes the balance: as short as reasonable while still -/// guaranteeing the tx will eventually land in the canonical chain -/// barring a catastrophic cluster failure. The single point of -/// configuration here means any future bump (e.g. to `finalized` if -/// reorgs become a real concern) is one edit. -inline constexpr auto SOL_COMMITMENT = - fc::network::solana::commitment_t::confirmed; - -/// SOL: maximum page size accepted by Solana JSON-RPC -/// `getSignaturesForAddress`. The underwriter walks one page per retry -/// callback and persists the `before` cursor between outer scan cycles so -/// unrelated newer program traffic cannot keep an older valid source deposit -/// outside the verifier's search window forever. -inline constexpr size_t SOL_SIGNATURE_SCAN_PAGE_SIZE = 1000; - -} // namespace sysio::underwriter diff --git a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/source_deposit_hash_detail.hpp b/plugins/underwriter_plugin/include/sysio/underwriter_plugin/source_deposit_hash_detail.hpp deleted file mode 100644 index 701376bd9d..0000000000 --- a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/source_deposit_hash_detail.hpp +++ /dev/null @@ -1,62 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include - -namespace sysio::underwriter { - -/// Number of unsigned 64-bit fields in the outpost correlation-hash payload. -inline constexpr size_t SOURCE_DEPOSIT_U64_FIELD_COUNT = 7; - -/// Immutable source-outpost fields covered by the `SwapDeposit` correlation -/// hash. The outposts hash the user's accepted `target_amount`, not the depot's -/// mutable AMM quote (`uwreq.dst_amount`). Keeping the two names distinct here -/// prevents a later re-price from invalidating otherwise genuine deposits. -struct source_deposit_hash_input { - std::span depositor; - uint64_t source_amount; - uint64_t source_token_code; - uint64_t source_reserve_code; - uint64_t target_chain_code; - uint64_t target_token_code; - uint64_t target_reserve_code; - uint64_t target_amount; - uint32_t target_tolerance_bps; -}; - -/// Reproduce the outposts' packed `SwapDeposit` correlation hash. -inline fc::crypto::keccak256 source_deposit_hash(const source_deposit_hash_input& input) { - std::vector packed; - packed.reserve(input.depositor.size() + SOURCE_DEPOSIT_U64_FIELD_COUNT * sizeof(uint64_t) + - sizeof(uint32_t)); - packed.insert(packed.end(), input.depositor.begin(), input.depositor.end()); - - const auto append_big_endian = [&](auto value) { - using value_type = decltype(value); - static_assert(std::is_unsigned_v); - for (size_t remaining_bytes = sizeof(value_type); remaining_bytes > 0; --remaining_bytes) { - const size_t shift = (remaining_bytes - 1) * CHAR_BIT; - packed.push_back(static_cast(value >> shift)); - } - }; - - append_big_endian(input.source_amount); - append_big_endian(input.source_token_code); - append_big_endian(input.source_reserve_code); - append_big_endian(input.target_chain_code); - append_big_endian(input.target_token_code); - append_big_endian(input.target_reserve_code); - append_big_endian(input.target_amount); - append_big_endian(input.target_tolerance_bps); - - return fc::crypto::keccak256::hash( - std::span{packed.data(), packed.size()}); -} - -} // namespace sysio::underwriter diff --git a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/sync_detail.hpp b/plugins/underwriter_plugin/include/sysio/underwriter_plugin/sync_detail.hpp deleted file mode 100644 index 2f99d2cc1d..0000000000 --- a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/sync_detail.hpp +++ /dev/null @@ -1,149 +0,0 @@ -#pragma once -/** - * @file sync_detail.hpp - * @brief Pure gate-lifecycle machinery for the underwriter plugin — the deferred- - * startup states and the `/v1/underwriter/` gate payloads — lifted out of - * the `.cpp`-private impl so they are unit-testable without standing up a - * chain. - * - * The underwriter's startup preflight validates depot-side state (opreg - * registration, chain registry, authex links) via LOCAL table reads. On a - * cold-booting operator node those reads see mid-sync (possibly genesis) - * state and fail spuriously, so the plugin must not begin underwriting until - * the node is synced. The sync predicate itself is `controller::is_synced()` - * (LIB recency, woken by the `irreversible_block` channel), shared by every - * operator-daemon plugin; this header carries only the underwriter's OWN gate - * lifecycle surface. - */ - -#include -#include - -#include - -#include - -namespace sysio::underwriter_detail { - -/// JSON field keys shared by the gate payloads below, the post-startup -/// response builders in `underwriter_plugin.cpp`, and their tests — one -/// spelling authority instead of per-translation-unit string literals. -namespace field { - inline constexpr auto status = "status"; - inline constexpr auto head_behind_sec = "head_behind_sec"; - inline constexpr auto lib_behind_sec = "lib_behind_sec"; - inline constexpr auto detail = "detail"; -} - -/** - * @brief Lifecycle of the deferred startup body, surfaced via the - * `/v1/underwriter/...` HTTP endpoints. - * - * The endpoints register unconditionally during `plugin_startup` — every - * `http_plugin` handler registration must happen before the posted HTTP - * listener goes live, because the beast worker threads read the handler map - * lock-free — so a request can arrive while the startup body is still gated - * behind chain sync (or after it failed terminally). The handlers answer with - * this state until the deferred startup completes, giving operators a - * queryable surface for why underwriting isn't live. Member spellings ARE the - * wire `status` values (via `magic_enum::enum_name`). - */ -enum class startup_state : uint8_t { - waiting_for_sync, ///< Sync gate not yet armed — the node is still catching up. - preflight_retrying, ///< Gate armed; preflight incomplete, retrying within the bounded grace. - preflight_failed, ///< Preflight still failing after the retry grace (terminal). - wiring_failed, ///< Preflight passed but outpost client wiring threw (terminal). - startup_failed, ///< The deferred startup body threw past the specific - ///< failure paths above (terminal). Exists so an escaping - ///< exception is contained as a diagnosable gate state — - ///< stored and logged BEFORE the fail-fast shutdown — instead - ///< of unwinding the app executor mid-task with the gate - ///< still claiming `waiting_for_sync`. - active ///< Deferred startup completed — the scan cron is scheduled. -}; - -/** - * @brief True for the terminal deferred-startup FAILURE states — the states the - * uniform fail-fast policy shuts the node down on (see - * `underwriter_plugin.cpp`'s `quit_if_startup_failed_terminally`). - * - * `active` is terminal success, not failure; `preflight_retrying` is transient by - * definition (bounded by the retry grace). Kept beside the enum so a new state - * cannot be added without deciding its fail-fast classification here. - * - * @param s The deferred-startup lifecycle state to classify. - * @return True when `s` is a terminal failure state. - */ -inline constexpr bool is_terminal_failure(startup_state s) { - return s == startup_state::preflight_failed || s == startup_state::wiring_failed || - s == startup_state::startup_failed; -} - -/// The wire `status` spelling of {@link startup_state::active} — shared by the -/// post-startup response builders so the value has one authority beside the -/// enum whose member spellings define the wire format. -inline constexpr std::string_view active_status = magic_enum::enum_name(startup_state::active); -// Compile-time wire-format pin: renaming the enum member would silently change -// the HTTP `status` value every consumer switches on. -static_assert(active_status == "active"); - -/// `detail` carried by the {@link startup_state::preflight_failed} gate payload. -inline constexpr std::string_view preflight_failed_detail = - "startup preflight failed after sync — depot-side state for this underwriter " - "is incomplete; the node is shutting down (fail-fast; see node log)"; -/// `detail` carried by the {@link startup_state::wiring_failed} gate payload. -inline constexpr std::string_view wiring_failed_detail = - "outpost client wiring failed after preflight; the node is shutting down " - "(fail-fast; see node log)"; -/// `detail` carried by the {@link startup_state::startup_failed} gate payload. -inline constexpr std::string_view startup_failed_detail = - "deferred startup failed unexpectedly; the node is shutting down " - "(fail-fast; see node log)"; - -/** - * @brief The JSON body a `/v1/underwriter/...` endpoint answers with for `state`. - * - * Pure (unit-testable without a chain). Shape per state: - * - `waiting_for_sync` → `{"status":"waiting_for_sync","head_behind_sec":N - * [,"lib_behind_sec":M]}` — the gate's criterion is the IRREVERSIBLE - * state (`lib_behind_sec`); the head gap is reported alongside so a - * head-current-but-finality-stalled node is distinguishable from one - * still catching up. `lib_behind_sec` is ABSENT until an irreversible - * root exists: an in-band sentinel would collide with real negative - * gaps (clock skew) after whole-second truncation. - * - `preflight_retrying` → `{"status":"preflight_retrying"}` - * - `preflight_failed` / `wiring_failed` / `startup_failed` → - * `{"status":...,"detail":...}` - * - `active` → `{"status":"active"}` (callers normally serve the real - * payload instead once active — the builders stamp the same field). - * - * @param state The current deferred-startup lifecycle state. - * @param head_behind How far the head trails wall-clock now; consumed only by - * the `waiting_for_sync` payload (emitted in whole seconds; - * may be negative under clock skew). - * @param lib_behind How far the last-irreversible block trails wall-clock - * now, or empty while no irreversible root exists yet (the - * key is then omitted); consumed only by the - * `waiting_for_sync` payload. - * @return The response body as an `fc::variant`. - */ -inline fc::variant startup_gate_payload(startup_state state, fc::microseconds head_behind, - std::optional lib_behind) { - fc::mutable_variant_object body; - body(field::status, std::string{magic_enum::enum_name(state)}); - if (state == startup_state::waiting_for_sync) { - body(field::head_behind_sec, head_behind.to_seconds()); - if (lib_behind) { - body(field::lib_behind_sec, lib_behind->to_seconds()); - } - } else if (state == startup_state::preflight_failed) { - body(field::detail, std::string{preflight_failed_detail}); - } else if (state == startup_state::wiring_failed) { - body(field::detail, std::string{wiring_failed_detail}); - } else if (state == startup_state::startup_failed) { - body(field::detail, std::string{startup_failed_detail}); - } - return fc::variant(std::move(body)); -} - -} // namespace sysio::underwriter_detail diff --git a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/uic_construction_detail.hpp b/plugins/underwriter_plugin/include/sysio/underwriter_plugin/uic_construction_detail.hpp deleted file mode 100644 index c2b8b59fd0..0000000000 --- a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/uic_construction_detail.hpp +++ /dev/null @@ -1,53 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include -#include - -namespace sysio::underwriter_detail { - -/** - * Return the canonical external caller-address size for a UIC outpost kind. - * @param kind Outpost chain family carried by the signed UIC. - * @return 20 for EVM, 32 for SVM, or zero for an unsupported kind. - */ -inline size_t expected_uic_caller_address_size( - sysio::opp::types::ChainKind kind) { - switch (kind) { - case sysio::opp::types::CHAIN_KIND_EVM: - return fc::crypto::chain_address_size_ethereum; - case sysio::opp::types::CHAIN_KIND_SVM: - return fc::crypto::chain_address_size_solana; - default: - return 0; - } -} - -/** - * Populate the non-default chain kind and exact authenticated caller bytes - * required by every production underwriter UIC. - * - * @param uic UIC to populate after all validation succeeds. - * @param kind Configured outpost transaction signer's chain family. - * @param address Authenticated chain-native caller bytes. - * @return true when the kind is supported and the address has its exact - * chain-native size; false without mutating `uic` otherwise. - */ -inline bool set_uic_authenticated_caller( - sysio::opp::attestations::UnderwriteIntentCommit& uic, - sysio::opp::types::ChainKind kind, - std::span address) { - const auto expected_size = expected_uic_caller_address_size(kind); - if (expected_size == 0 || address.size() != expected_size) return false; - - auto* ext_address = uic.mutable_uw_ext_chain_addr(); - ext_address->set_kind(kind); - ext_address->set_address(address.data(), address.size()); - return true; -} - -} // namespace sysio::underwriter_detail diff --git a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/uic_signature_detail.hpp b/plugins/underwriter_plugin/include/sysio/underwriter_plugin/uic_signature_detail.hpp deleted file mode 100644 index 3197f54f6b..0000000000 --- a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/uic_signature_detail.hpp +++ /dev/null @@ -1,279 +0,0 @@ -#pragma once -/** - * @file uic_signature_detail.hpp - * @brief Shared fixed-size recoverable signature policy for underwriter UIC - * preflight and construction. - * - * The depot accepts the canonical packed K1, R1, EM, and ED variants. These - * helpers keep the plugin's startup self-test, runtime construction guard, and - * unit tests on that same non-parsing boundary. Variable-size WebAuthn and - * unrecoverable BLS signatures are intentionally excluded. - */ - -#include -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace sysio::underwriter_detail { - -using uic_signature_type = fc::crypto::signature::sig_type; - -using uic_signature_storage = fc::crypto::signature::storage_type; -static_assert(std::variant_size_v == 6); -static_assert(std::is_same_v, - fc::ecc::signature_shim>); -static_assert(std::is_same_v, - fc::crypto::r1::signature_shim>); -static_assert(std::is_same_v, - fc::crypto::webauthn::signature>); -static_assert(std::is_same_v, - fc::em::signature_shim>); -static_assert(std::is_same_v, - fc::crypto::ed::signature_shim>); -static_assert(std::is_same_v, - fc::crypto::bls::signature_shim>); - -/// Return the canonical fc-packed size for one concrete signature variant. -template -inline std::size_t packed_uic_signature_size() { - static const auto size = fc::raw::pack(fc::crypto::signature{ - fc::crypto::signature::storage_type{std::in_place_type} - }).size(); - return size; -} - -/// Return the canonical packed size for a depot-supported signature type. -inline std::size_t canonical_packed_uic_signature_size(uic_signature_type type) { - switch (type) { - case uic_signature_type::k1: - return packed_uic_signature_size(); - case uic_signature_type::r1: - return packed_uic_signature_size(); - case uic_signature_type::em: - return packed_uic_signature_size(); - case uic_signature_type::ed: - return packed_uic_signature_size(); - default: - return 0; - } -} - -/// Domain-separated message signed by the provider during startup preflight. -inline constexpr std::string_view uic_signature_self_test_domain = - "wire.underwriter_plugin.signature_self_test.v1"; - -/** - * @brief Test whether a configured UIC provider public key is fixed-size and - * recoverable by the depot. - * @param key Provider public key to classify. - * @return True for K1, R1, EM, or ED. - */ -inline bool is_supported_uic_public_key(const fc::crypto::public_key& key) { - using key_type = fc::crypto::public_key::key_type; - return key.contains_type(key_type::k1, key_type::r1, key_type::em, key_type::ed); -} - -/** - * @brief Test whether a provider's UIC signature is fixed-size and recoverable. - * @param signature Signature to classify. - * @return True for K1, R1, EM, or ED. - */ -inline bool is_supported_uic_signature(const fc::crypto::signature& signature) { - using sig_type = fc::crypto::signature::sig_type; - return signature.contains_type(sig_type::k1, sig_type::r1, sig_type::em, sig_type::ed); -} - -/** - * @brief Select supported UIC signers from providers assigned to the WIRE - * target chain. - * - * K1/R1 providers use the WIRE native key format, while EM/ED providers use - * the Ethereum/Solana native key formats. Provider selection must therefore - * filter by the concrete public-key variant rather than by one native-format - * `chain_key_type_t` bucket. - * - * Automatically generated defaults are useful for ordinary WIRE transaction - * signing, but they must not compete with the operator's deliberate UIC key. - * The caller supplies both the manager's operator-configuration predicate and - * a current direct-permission-key predicate. Their intersection prevents an - * unrelated operator-configured WIRE provider (for example, a block-signing - * key) from competing with the underwriter account's UIC key. Both named and - * anonymous `--signature-provider` entries express operator intent; generated - * defaults and programmatic registrations do not. - * - * @param providers Providers returned by the signature-provider registry. - * @param is_operator_configured Predicate accepting one provider key name. - * @param is_permission_authorized Predicate accepting one provider public key. - * @return Operator-configured WIRE-target providers whose public keys are K1, - * R1, EM, or ED and are direct keys of the underwriter account's - * current owner or active permission. - */ -template -inline std::vector select_uic_signature_providers( - const std::vector& providers, - IsOperatorConfigured&& is_operator_configured, - IsPermissionAuthorized&& is_permission_authorized) { - std::vector selected; - selected.reserve(providers.size()); - for (const auto& provider : providers) { - if (provider && - is_operator_configured(provider->key_name) && - provider->target_chain == fc::crypto::chain_kind_wire && - is_supported_uic_public_key(provider->public_key) && - is_permission_authorized(provider->public_key)) { - selected.push_back(provider); - } - } - return selected; -} - -/** - * @brief Test whether the provider key and produced signature use the same - * supported variant. - * @param key Provider public key. - * @param signature Signature returned by the provider. - * @return True only when their shared variant tags match. - */ -inline bool uic_signature_type_matches_provider_key( - const fc::crypto::public_key& key, - const fc::crypto::signature& signature) { - using key_type = fc::crypto::public_key::key_type; - using sig_type = fc::crypto::signature::sig_type; - static_assert(magic_enum::enum_integer(key_type::k1) == - magic_enum::enum_integer(sig_type::k1)); - static_assert(magic_enum::enum_integer(key_type::r1) == - magic_enum::enum_integer(sig_type::r1)); - static_assert(magic_enum::enum_integer(key_type::em) == - magic_enum::enum_integer(sig_type::em)); - static_assert(magic_enum::enum_integer(key_type::ed) == - magic_enum::enum_integer(sig_type::ed)); - return magic_enum::enum_integer(key.type()) == - magic_enum::enum_integer(signature.type()); -} - -/** - * @brief Validate the depot's exact packed fixed-size UIC boundary. - * @param packed Packed fc signature bytes. - * @return True for canonical K1/R1/EM (66 bytes) or ED (97 bytes). - */ -inline bool is_canonical_packed_uic_signature(std::span packed) { - if (packed.empty()) return false; - const auto type = magic_enum::enum_cast( - static_cast(static_cast(packed.front()))); - if (!type) return false; - const auto expected_size = canonical_packed_uic_signature_size(*type); - return expected_size != 0 && packed.size() == expected_size; -} - -/** - * @brief Validate the recovery-byte and scalar policy for one packed UIC signature. - * @param packed Canonically sized fc-packed signature bytes. - * @return True for ED, or for K1/R1/EM with their canonical recovery range, - * nonzero in-range `r`, and low `s`. - */ -inline bool has_canonical_uic_signature_body(std::span packed) { - if (!is_canonical_packed_uic_signature(packed)) return false; - const auto type = magic_enum::enum_cast( - static_cast(static_cast(packed.front()))); - if (!type) return false; - const auto body = packed.subspan(1); - switch (*type) { - case uic_signature_type::k1: - return sysio::opp::is_canonical_uic_ecdsa_signature( - body, sysio::opp::uic_ecdsa_curve::secp256k1, - sysio::opp::uic_ecdsa_recovery_position::prefix, - sysio::opp::uic_ecdsa_recovery_encoding::compact); - case uic_signature_type::r1: - return sysio::opp::is_canonical_uic_ecdsa_signature( - body, sysio::opp::uic_ecdsa_curve::p256, - sysio::opp::uic_ecdsa_recovery_position::prefix, - sysio::opp::uic_ecdsa_recovery_encoding::compact); - case uic_signature_type::em: - return sysio::opp::is_canonical_uic_ecdsa_signature( - body, sysio::opp::uic_ecdsa_curve::secp256k1, - sysio::opp::uic_ecdsa_recovery_position::suffix, - sysio::opp::uic_ecdsa_recovery_encoding::ethereum); - case uic_signature_type::ed: - return true; - default: - return false; - } -} - -/// Result of the signature-shape portion of underwriter startup preflight. -enum class uic_signature_provider_result { - compatible, ///< Provider satisfies the complete fixed-shape policy. - unsupported_public_key_type, ///< Configured provider key is WA or BLS. - unsupported_signature_type, ///< Provider returned WA or BLS. - signature_type_mismatch, ///< Provider key and signature variants differ. - non_canonical_packed_signature, ///< Packed result violates the depot boundary. - non_canonical_signature, ///< ECDSA recovery/scalar body is noncanonical. -}; - -/// Provider-policy result and the recovered key available on success. -struct uic_signature_provider_check { - uic_signature_provider_result result; ///< Shape-policy classification. - std::optional recovered_key; ///< Present only when compatible. -}; - -/** - * @brief Run the signature-shape portion of underwriter startup preflight. - * - * Account-permission authorization remains a chain-state check in - * `run_preflight`; this shared policy prevents its implementation and unit - * regression from drifting apart. - * - * @param provider Configured WIRE provider to exercise. - * @param digest Fixed self-test digest the provider must sign. - * @return Shape classification and, on success, the recovered public key. - * @throws fc exceptions propagated by provider signing, packing, or recovery. - */ -inline uic_signature_provider_check check_uic_signature_provider( - const fc::crypto::signature_provider_t& provider, - const fc::sha256& digest) { - if (!is_supported_uic_public_key(provider.public_key)) { - return {uic_signature_provider_result::unsupported_public_key_type, std::nullopt}; - } - - const auto signature = provider.sign(digest); - if (!is_supported_uic_signature(signature)) { - return {uic_signature_provider_result::unsupported_signature_type, std::nullopt}; - } - if (!uic_signature_type_matches_provider_key(provider.public_key, signature)) { - return {uic_signature_provider_result::signature_type_mismatch, std::nullopt}; - } - - const auto packed = fc::raw::pack(signature); - if (!is_canonical_packed_uic_signature(packed)) { - return { - uic_signature_provider_result::non_canonical_packed_signature, - std::nullopt, - }; - } - if (!has_canonical_uic_signature_body(packed)) { - return { - uic_signature_provider_result::non_canonical_signature, - std::nullopt, - }; - } - - return { - uic_signature_provider_result::compatible, - fc::crypto::public_key::recover(signature, digest), - }; -} - -} // namespace sysio::underwriter_detail diff --git a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/underwriter_plugin.hpp b/plugins/underwriter_plugin/include/sysio/underwriter_plugin/underwriter_plugin.hpp deleted file mode 100644 index 7e9a2877ae..0000000000 --- a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/underwriter_plugin.hpp +++ /dev/null @@ -1,72 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -namespace sysio { - - /// Underwriter plugin — polls for underwrite requests and submits intents. - /// - /// The underwriter does NOT relay OPP envelopes (that's the batch operator's job). - /// Instead it: - /// 1. Polls sysio.uwrit::uwreqs for PENDING underwrite requests. - /// 2. Reads its available credit and authoritative stored-candidate state. - /// 3. Suppresses terminal and already-complete candidates after restart. - /// 4. Selects absent/partial candidates whose complete winner-time bond fits - /// the daemon's provisional available-collateral view. - /// 5. Submits signed UICs only for missing legs to the relevant ACTIVE-role- - /// gated outpost contract (ETH/SOL), which caller-binds and canonicalizes - /// each UIC before relaying its original validated bytes. - /// 6. The depot gives the complete candidate one authoritative attempt, - /// then either creates locks, disqualifies a candidate-local failure, or - /// rejects and refunds a request-global failure. - namespace underwriter_defaults { - constexpr uint32_t scan_interval_ms = 5000; - constexpr uint32_t action_timeout_ms = 15000; - // The sync-recency window moved to `controller::default_sync_recency_ms` - // — the sync predicate is `controller::is_synced()`, shared by every - // operator-daemon plugin. - /// How long after the sync gate arms a failing preflight keeps - /// RETRYING before the failure is terminal. Rows the harness confirmed - /// final on the producer can land in the LOCAL irreversible state a - /// beat after the gate arms (observed live: a registration in block N - /// readable only 50ms after a preflight read at LIB N−3); the grace - /// absorbs that boundary race while a genuinely incomplete bootstrap - /// still fails loudly once it expires. - constexpr uint32_t preflight_retry_grace_ms = 15000; - /// Cadence (ms) of preflight retries within the grace window. - constexpr uint32_t preflight_retry_interval_ms = 500; - // SEC-13/WSA-027: the single eth/sol client-id defaults were removed with - // the single-client config — outpost wiring is now per-chain and required - // (--underwriter-{eth,sol}-outpost), with no single fallback. - } - - class underwriter_plugin : public appbase::plugin { - public: - APPBASE_PLUGIN_REQUIRES( - (chain_plugin) - (cron_plugin) - (http_plugin) - (outpost_ethereum_client_plugin) - (outpost_solana_client_plugin) - (signature_provider_manager_plugin) - ) - - underwriter_plugin(); - virtual ~underwriter_plugin(); - - virtual void set_program_options(options_description& cli, options_description& cfg) override; - void plugin_initialize(const variables_map& options); - void plugin_startup(); - void plugin_shutdown(); - - private: - struct impl; - std::unique_ptr _impl; - }; - -} // namespace sysio diff --git a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/variant_enum_detail.hpp b/plugins/underwriter_plugin/include/sysio/underwriter_plugin/variant_enum_detail.hpp deleted file mode 100644 index cfbc2060da..0000000000 --- a/plugins/underwriter_plugin/include/sysio/underwriter_plugin/variant_enum_detail.hpp +++ /dev/null @@ -1,51 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include -#include - -namespace sysio::underwriter_detail { - -/// Decode an FC-reflected enum at a table/ABI trust boundary. Reflected enum -/// decoding accepts both the ABI spelling and a numeric representation while -/// validating that the value names a declared enumerator. Malformed values are -/// reported as `nullopt` so callers can skip the untrusted row safely. -template -requires std::is_enum_v -std::optional decode_enum_variant(const fc::variant& value) { - // FC's generic reflected-enum path coerces bool/null/double through - // `as_int64()`. Admit only the two exact integer storage types produced by - // the ABI serializer plus symbolic strings, and range-check unsigned input - // before the reflected decoder converts through int64_t. - if (!value.is_string() && !value.is_int64() && !value.is_uint64()) { - return std::nullopt; - } - if (value.is_uint64() && - value.as_uint64() > static_cast( - std::numeric_limits::max())) { - return std::nullopt; - } - try { - return value.as(); - } catch (const fc::exception&) { - return std::nullopt; - } -} - -/// Look up and decode one required FC-reflected enum field without throwing on -/// a missing key. Both absence and malformed values return `nullopt` so a -/// caller can skip an untrusted table row atomically. -template -requires std::is_enum_v -std::optional decode_enum_field(const fc::variant_object& object, - const char* key) { - const auto it = object.find(key); - if (it == object.end()) return std::nullopt; - return decode_enum_variant(it->value()); -} - -} // namespace sysio::underwriter_detail diff --git a/plugins/underwriter_plugin/src/underwriter_plugin.cpp b/plugins/underwriter_plugin/src/underwriter_plugin.cpp deleted file mode 100644 index 5dbfe7ba86..0000000000 --- a/plugins/underwriter_plugin/src/underwriter_plugin.cpp +++ /dev/null @@ -1,3054 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace sysio { - -using namespace chain_apis; -using namespace sysio::opp::types; -namespace eth = fc::network::ethereum; -namespace opp_att = sysio::opp::attestations; -/// Field spellings for the `sysio.chains::chains` rows this plugin reads, -/// shared with batch_operator_plugin so the two daemons cannot drift apart. -namespace depot_chains = sysio::opp::depot::chains; - -// SEC-13/WSA-027: exact-(chain_code, token_code, reserve_code) routing / -// accounting keys, lifted to a testable detail header. These replace the -// former ChainKind/TokenKind-collapsed in-memory keys so two active chains of -// the same VM family (e.g. two EVM outposts) never share a credit bucket or a -// commit-dedup slot. -using underwriter_detail::bucket_key; -using underwriter_detail::leg_bond; -using underwriter_detail::credit_buckets; -using underwriter_detail::commit_key; - -namespace { - -/// Ethereum JSON-RPC method used to inspect the finalized source-chain head. -constexpr std::string_view ETH_GET_BLOCK_BY_NUMBER_METHOD = "eth_getBlockByNumber"; -/// `sysio.uwrit` ABI identifiers used by the daemon. Keeping the contract, -/// action, table, and row-field spellings together makes host/contract schema -/// drift visible in one place. -namespace uwrit { -constexpr auto account = "sysio.uwrit"; -constexpr auto table_locks = "locks"; -constexpr auto table_requests = "uwreqs"; - -namespace lock_field { -constexpr auto underwriter = "underwriter"; -constexpr auto amount = "amount"; -} - -namespace request_field { -constexpr auto id = "id"; -constexpr auto type = "type"; -constexpr auto status = "status"; -constexpr auto underwriter_name = "uw_name"; -constexpr auto source_chain_code = "src_chain_code"; -constexpr auto source_token_code = "src_token_code"; -constexpr auto source_reserve_code = "src_reserve_code"; -constexpr auto source_amount = "src_amount"; -constexpr auto destination_chain_code = "dst_chain_code"; -constexpr auto destination_token_code = "dst_token_code"; -constexpr auto destination_reserve_code = "dst_reserve_code"; -constexpr auto destination_amount = "dst_amount"; -constexpr auto target_amount = "target_amount"; -constexpr auto variance_tolerance_bps = "variance_tolerance_bps"; -constexpr auto commits_by = "commits_by"; -constexpr auto source_transaction_id = "source_tx_id"; -constexpr auto depositor = "depositor"; -} - -namespace commit_field { -constexpr auto underwriter = "underwriter"; -constexpr auto status = "status"; -constexpr auto source_received_at_ms = "source_received_at_ms"; -constexpr auto source_uic_bytes = "source_uic_bytes"; -constexpr auto destination_received_at_ms = "dest_received_at_ms"; -constexpr auto destination_uic_bytes = "dest_uic_bytes"; -} -} // namespace uwrit - -} // namespace - -// --------------------------------------------------------------------------- -// Underwrite request — read directly from sysio.uwrit::uwreqs table. -// -// Per Task 3's uwrit refactor, the row carries src/dst (chain, token_kind, -// amount) fields populated by createuwreq from the originating SwapRequest. -// No more chasing through sysio.msgch::attestations to decode the -// attestation payload — the data we need is right on the uwreq row. -// --------------------------------------------------------------------------- -struct uw_request { - uint64_t id; // attestation ID (PK of uwreqs table) - AttestationType attestation_type; // AttestationType that needs underwriting (e.g., SWAP) - UnderwriteRequestStatus status; // typed status (always PENDING for plugin-selected rows) - std::string uw_name; // assigned underwriter ('' if unassigned, populated post race-resolve) - ChainKind src_chain; - TokenKind src_token_kind; - uint64_t src_amount; - ChainKind dst_chain; - TokenKind dst_token_kind; - uint64_t dst_amount; - /// The destination amount the caller ASKED for, retained as the fixed - /// reference the depot's slippage check measures against. Distinct from - /// `dst_amount`, which is the depot's AMM quote and is re-priced at - /// settlement. The outposts fold THIS field — never `dst_amount` — into the - /// `SwapDeposit` correlation hash, so it is the value the source-deposit - /// verifier must reproduce. - uint64_t target_amount; - /// Per-leg slug_name triples (data-model). These are the authoritative - /// identifiers for the depot's `rcrdcommit` routing and the - /// `UnderwriteIntentCommit` (`chain_code` / `token_code` / `reserve_code`) - /// payload populated in `create_signed_uic_bytes`. The `ChainKind` / - /// `TokenKind` siblings above are retained for VM-family dispatch and - /// human-readable diagnostics; collateral bucketing uses the exact slug - /// codes below. - fc::slug_name src_chain_code{}; - fc::slug_name src_token_code{}; - fc::slug_name src_reserve_code{}; - fc::slug_name dst_chain_code{}; - fc::slug_name dst_token_code{}; - fc::slug_name dst_reserve_code{}; - /// Variance tolerance the user attached to the original SwapRequest - /// (as basis points). Verified in the source-deposit hash binding - /// so the underwriter cannot collude with a stale on-chain - /// `SwapDeposit` whose terms differ from the depot's UWREQ. - uint32_t variance_tolerance_bps{}; - /// Source-chain identifier. ETH and SOL both use the 8-byte big-endian - /// `SwapDeposit.id` minted by their source outpost's monotonic counter. - /// Populated by `createuwreq` from `SwapRequest.source_tx_id`. The - /// underwriter's source-deposit verifier interprets the bytes - /// per-chain. - std::vector source_tx_id; - - /// Depositor's address on the source chain (decoded from - /// `SwapRequest.actor.address`). The plugin's verify_source_deposit - /// step cross-references the source-chain tx's `from` field (ETH) or - /// fee-payer (SOL) against this to confirm the recorded depositor - /// actually authorized the deposit. - std::vector depositor; - - /// Per-leg depot flags — true when the leg's chain_code is the WIRE - /// depot's own registry row (`sysio.chains` `is_depot`). A depot leg is - /// not underwritten: no outpost commit, no bond, no source-deposit - /// verification. Stamped by `scan_pending_requests` from the exact - /// depot code captured in `read_outpost_registry` — NOT inferred from - /// `CHAIN_KIND_UNKNOWN`, which also matches genuinely-unregistered - /// chains. - bool src_is_depot = false; - bool dst_is_depot = false; - - /// Authoritative depot-row plan for this daemon's candidate. It - /// distinguishes missing-leg work from complete or terminal candidates even - /// after restart has emptied the local `confirmed_commits` cache. - underwriter_detail::stored_commit_plan own_stored_commit_plan; -}; - -// --------------------------------------------------------------------------- -// Credit line — per-(chain_code, token_code) bond from sysio.opreg::operators -// -// Reads the `balances` field (one aggregate balance per EXACT -// (chain_code, token_code) — SEC-13/WSA-027: keyed by the slug codes, NOT -// the coarse (ChainKind, TokenKind) family, so two same-family chains hold -// independent collateral). `read_credit_lines` starts from this raw balance -// and subtracts active locks plus pending withdrawals to mirror the depot's -// authoritative `sysio.opreg::available()` rollup. Winner selection still -// revalidates the live rollup because capacity can change after the scan. -// -// Codes held as raw `fc::slug_name::value` (uint64) to match `bucket_key`. -// --------------------------------------------------------------------------- -struct credit_line { - uint64_t chain_code; - uint64_t token_code; - uint64_t balance; -}; - -// --------------------------------------------------------------------------- -// Outpost endpoint — operator-supplied wiring for ONE active chain -// -// SEC-13/WSA-027: the underwriter (one process) multiplexes every chain it -// serves, so it holds one of these per EXACT `chain_code` — two chains of the -// same VM family (e.g. two EVM outposts) are configured and routed -// independently. The chain-agnostic deposit selector / instruction -// discriminator + event decoding still come from the shared ABI / IDL files; -// only the per-chain *addresses* and the RPC `client_id` live here. -// -// ETH chain: commit_addr = OperatorRegistry address (uw_commit) -// source_deposit_addr = SwapDeposit-emitting contract (verify) -// SOL chain: commit_addr = source_deposit_addr = opp-outpost program id -// --------------------------------------------------------------------------- -struct outpost_endpoint { - ChainKind kind = ChainKind::CHAIN_KIND_UNKNOWN; - std::string client_id; ///< RPC connection id in the outpost client plugin - /// Remote contract identities, filled by `read_outpost_registry` from the - /// chain's `sysio.chains` row rather than from this node's config, so every - /// underwriter commits against the same deployment. Empty until governance - /// configures the row; the preflight and the wiring step both fail closed. - std::string commit_addr; ///< ETH OperatorRegistry addr / SOL program id - std::string source_deposit_addr; ///< ETH SwapDeposit contract / SOL program id -}; - -// --------------------------------------------------------------------------- -// Implementation -// --------------------------------------------------------------------------- -struct underwriter_plugin::impl { - // Configuration - chain::name underwriter_account; - /// Derived from `underwriter_account` at plugin_initialize: the scan cycle - /// runs iff an account was configured. There is no separate enable flag. - bool enabled = false; - uint32_t scan_interval_ms = underwriter_defaults::scan_interval_ms; - uint32_t action_timeout_ms = underwriter_defaults::action_timeout_ms; - /// SEC-13/WSA-027: per-chain outpost wiring, keyed by EXACT `chain_code` - /// slug value — one entry per ACTIVE non-depot chain. Built entirely by - /// `read_outpost_registry` from `sysio.chains`: the registry names which - /// chains are served and where their contracts live, and the RPC client id - /// is the chain's own code. Nothing here comes from this node's config. - std::map outpost_endpoints; - /// Per-chain external (numeric) chain id, captured from `sysio.chains` - /// (`external_chain_id`) by `read_outpost_registry`, fed to - /// `create_outpost_client` so each client carries its chain's real id. - std::map outpost_external_chain_ids; - /// Maximum number of recent EVM blocks one `eth_getLogs` - /// source-deposit lookup may cover. Keeping this window bounded prevents - /// invalid or stale deposit ids from forcing whole-history RPC scans. - uint64_t eth_source_deposit_lookback_blocks = - sysio::underwriter::ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS; - /// Configured names of the swap-deposit function (ETH) / instruction - /// (SOL). The CHAIN-AGNOSTIC function selector / instruction discriminator - /// are resolved at preflight from the ABI / IDL files registered with the - /// outpost client plugins; the per-chain contract / program ADDRESS comes - /// from `outpost_endpoints` instead (SEC-13/WSA-027 — the selector is shared - /// across same-family chains, only the address varies). Both names required. - std::string eth_source_deposit_function_name; - std::string sol_source_deposit_instruction_name; - - /// Resolved-at-preflight CHAIN-AGNOSTIC verify-path state (the deposit - /// selector / discriminator), derived from the outpost client plugins' - /// ABI / IDL surfaces. Populated only after `run_preflight()` succeeds; the - /// per-chain address is looked up from `outpost_endpoints` at verify time. - std::vector resolved_eth_source_deposit_selector; - std::vector resolved_sol_source_deposit_discriminator; - - /// Unique operator-configured WIRE-target provider whose public key is a - /// direct key whose weight alone reaches this underwriter account's current - /// owner or active threshold. Populated only after preflight validates its - /// signature shape and recovered key; runtime UIC construction uses this - /// exact cached provider instead of re-querying a broader registry that also - /// contains unrelated WIRE providers such as block signers. - fc::crypto::signature_provider_ptr uic_signature_provider; - - // ── Diagnostic counters surfaced via the `/v1/underwriter/*` HTTP API. - // - // `source_deposit_mismatch_count` increments every time a source- - // deposit verification fails for a uwreq the plugin tried to cover. - // The other counters tally session-level outcomes — they reset on - // plugin restart (no on-disk persistence; the read endpoint is for - // live monitoring, not historical accounting). - uint64_t source_deposit_mismatch_count = 0; - uint64_t commits_confirmed_count = 0; - uint64_t commits_failed_count = 0; - uint64_t uwreqs_seen_pending_count = 0; - - // Protects `confirmed_commits`, `sol_source_deposit_scan_cursors`, and - // the diagnostic counters from concurrent access between the - // cron-callback (single-threaded) and the HTTP handler threads. The cron - // callback takes the lock around mutations; the HTTP handlers take it - // around reads. - mutable std::mutex stats_mutex; - - // Credit lines (read from sysio.opreg::operators each cycle) - std::vector credit_lines; - - // Awareness: own status from `sysio.opreg::operators[underwriter_account]`. - // SLASHED / TERMINATED short-circuits the relay loop. Refreshed each cycle - // by `poll_own_status()` (mirror of batch_operator_plugin's awareness). - bool is_active = true; - - // Plugin references - chain_plugin* chain_plug = nullptr; - cron_plugin* cron_plug = nullptr; - outpost_ethereum_client_plugin* eth_plug = nullptr; - outpost_solana_client_plugin* sol_plug = nullptr; - - // Cron job handle - cron_service::job_id_t scan_job_id = 0; - std::atomic shutting_down{false}; - - /// Sync gate: `channels::irreversible_block` subscription that arms - /// {@link run_deferred_startup} once `controller::is_synced()` holds — a LIB - /// advance is the only event that can turn the predicate true. Unsubscribed - /// after arming; otherwise the handle's destructor releases it (no shutdown - /// unsubscribe needed: posted channel deliveries are drained without - /// executing after quit, and the slot checks `shutting_down` first). - chain::plugin_interface::channels::irreversible_block::channel_type::handle sync_gate_subscription; - /// When the sync gate armed — bounds the preflight retry grace window. - fc::time_point startup_armed_at; - /// Bounded-grace preflight retry timer (main io_context; main-thread wait). - std::optional preflight_retry_timer; - /// Deferred-startup lifecycle surfaced by the `/v1/underwriter/*` handlers - /// (which register in `plugin_startup`, BEFORE the gate arms). Written on - /// the main thread by {@link run_deferred_startup}; read from the HTTP - /// handlers' read-only queue — hence atomic. Also the single source of - /// truth for "has the deferred startup been armed" ({@link - /// startup_attempted}) — every armed attempt leaves `waiting_for_sync` - /// before it returns, so no separate armed flag exists to drift. - std::atomic gate_state{ - underwriter_detail::startup_state::waiting_for_sync}; - - /// True once the deferred startup has been armed: {@link - /// attempt_deferred_startup} stores a non-waiting `gate_state` on every - /// path (retrying / a terminal failure / active), so "still - /// `waiting_for_sync`" IS "not yet armed". Guards the gate callback and - /// its posted task against re-arming (each site also checks - /// `shutting_down`, which covers the only path that returns before the - /// first store). - bool startup_attempted() const { - return gate_state.load() != underwriter_detail::startup_state::waiting_for_sync; - } - - // Outpost chain_kind cache: chain_code -> ChainKind - std::map outpost_chain_kinds; - - /// The WIRE depot's own chain code (the `is_depot` row of - /// `sysio.chains::chains`), captured by `read_outpost_registry` each - /// cycle. Used for exact per-leg depot detection on uwreq rows — - /// to/from-WIRE swaps have one depot leg that the plugin must skip - /// (no commit, no bond) rather than error on. - std::optional depot_chain_code; - - /// SPI handles to the configured outposts, keyed by EXACT `chain_code` slug - /// value (SEC-13/WSA-027 — NOT `ChainKind`, so two chains of the same VM - /// family each get their own client). Built at `plugin_startup` (after - /// preflight) from `outpost_endpoints` via the - /// `outpost_{ethereum,solana}_client_plugin::create_outpost_client` - /// factories. The relay loop selects by the leg's `chain_code` and calls - /// `outpost->uw_commit(...)` — every chain-specific concern (ABI / IDL - /// discovery, address encoding, on-chain confirmation) lives in the - /// concrete. Per `outpost-client-spi.md`. - std::map outpost_by_chain; - /// The `commit_addr` each entry of `outpost_by_chain` was built against, so - /// a `sysio.chains::setoutpost` redeploy is noticed and the handle rebuilt. - std::map wired_commit_addrs; - /// cross-walk: token slug_name → TokenKind enum. Refreshed each - /// scan cycle by `read_credit_lines` (which reads `sysio.tokens::tokens` - /// for the lookup); used by `scan_pending_requests` to translate the - /// uwreq row's `src/dst_token_code` slug into the `TokenKind` the - /// `select_coverable` bucket lookup needs. - std::map token_kind_by_code; - - // ── Outstanding commit tracking (one entry per CONFIRMED leg) ─────── - // Per `feedback`: an underwriter that confirmed a commit tx for a leg - // should NOT resubmit on the next scan cycle. The de-dup key is the EXACT - // leg identity `(uwreq_id, chain_code, token_code, reserve_code)` - // (`underwriter_detail::commit_key`) — NOT the coarse (ChainKind, - // TokenKind), so two legs differing only by chain or reserve are tracked - // independently (SEC-13/WSA-027). The set is pruned at the end of each scan - // cycle to drop entries whose uwreq is no longer PENDING (the depot has - // resolved the race), keeping the set bounded. - std::set confirmed_commits; - - /// Per-pending-UWREQ Solana scan cursors. Persisting `before` across - /// retry attempts and outer scan cycles prevents unrelated newer - /// opp-outpost traffic from keeping a valid source deposit forever - /// outside the newest-page window. Terminal negative entries remain until - /// the UWREQ leaves PENDING so failed scans do not re-walk full history or - /// re-bump mismatch counters every outer scan cycle. - underwriter::solana_source_deposit_scan_cursor_map sol_source_deposit_scan_cursors; - - /// Returns the current SOL source-deposit scan cursor for `key`, creating - /// one at the newest page when this is the first scan attempt for a UWREQ. - underwriter::solana_source_deposit_scan_cursor get_or_create_sol_source_deposit_scan_cursor( - const underwriter::solana_source_deposit_scan_key& key) { - std::lock_guard lk{stats_mutex}; - return underwriter::get_or_create_solana_source_deposit_scan_cursor( - sol_source_deposit_scan_cursors, key); - } - - /// Returns a terminal SOL source-deposit failure for `key`, when one has - /// already been recorded while the UWREQ remains PENDING. - std::optional - get_sol_source_deposit_terminal_failure( - const underwriter::solana_source_deposit_scan_key& key) const { - std::lock_guard lk{stats_mutex}; - return underwriter::get_solana_source_deposit_terminal_failure( - sol_source_deposit_scan_cursors, key); - } - - /// Persists the next SOL source-deposit `before` cursor after a clean page - /// that did not contain the target marker. - underwriter::solana_source_deposit_scan_cursor advance_sol_source_deposit_scan_cursor( - const underwriter::solana_source_deposit_scan_key& key, - std::string before, - size_t signature_count) { - std::lock_guard lk{stats_mutex}; - return underwriter::advance_solana_source_deposit_scan_cursor( - sol_source_deposit_scan_cursors, key, std::move(before), signature_count); - } - - /// Records a terminal SOL source-deposit failure while keeping the scan - /// state until the UWREQ leaves PENDING. - underwriter::solana_source_deposit_terminal_failure_result - record_sol_source_deposit_terminal_failure( - const underwriter::solana_source_deposit_scan_key& key, - std::string reason, - size_t signature_count) { - std::lock_guard lk{stats_mutex}; - return underwriter::record_solana_source_deposit_terminal_failure( - sol_source_deposit_scan_cursors, key, std::move(reason), signature_count); - } - - /// Drops SOL source-deposit scan progress for a UWREQ once it either - /// matches or leaves the PENDING set. - void erase_sol_source_deposit_scan_cursor( - const underwriter::solana_source_deposit_scan_key& key) { - std::lock_guard lk{stats_mutex}; - underwriter::erase_solana_source_deposit_scan_cursor( - sol_source_deposit_scan_cursors, key); - } - - // ----------------------------------------------------------------------- - // Table read helper - // ----------------------------------------------------------------------- - - /// Thin delegate to `chain_plugin::read_table_rows`, which posts the scan onto the app executor's read_only queue - /// so chainbase iteration runs during the controller's read window instead of racing with block apply. - sysio::chain_apis::read_only::get_table_rows_result - read_table(sysio::chain_apis::read_only::get_table_rows_params p) { - return chain_plug->read_table_rows(std::move(p), fc::milliseconds(action_timeout_ms), - "underwriter", shutting_down); - } - - /// Shortcut for the common scan shape: walk every row from a code/scope/table and return unwrapped values. - sysio::chain_apis::read_only::get_table_rows_result - read_all(std::string_view code, std::string_view scope, std::string_view table) { - sysio::chain_apis::read_only::get_table_rows_params p; - p.code = chain::name(code); - p.scope = scope; - p.table = table; - p.all_rows = true; - p.values_only = true; - return read_table(std::move(p)); - } - - // ----------------------------------------------------------------------- - // Pre-flight checks — unconditional, no dev escape hatch - // - // Verifies that the configured `underwriter_account` is set up to - // participate in the race BEFORE any cron job is scheduled. Failure - // prevents the scan loop from starting; the cluster bootstrap is - // responsible for establishing whatever state is missing. - // - // Gating checks (all required): - // 1. Operator exists in `sysio.opreg::operators`. ACTIVE is observed - // but non-gating because bootstrap may activate after node startup; - // the scan poll blocks work until then. - // 2. Every ACTIVE outpost chain in `sysio.chains::chains` has a - // configured `--underwriter-{eth,sol}-outpost` endpoint of the - // matching VM family. The served set is derived from the registry - // while the outpost clients are built from config, so a gap would - // let the scan loop pick a request it cannot fully commit. Inactive - // (not-yet-activated) chains are excluded, so registering a future - // chain does not block startup before its endpoint is configured. - // 3. `sysio.authex::links` covers every chain in the - // `sysio.chains::chains` registered set; without an authex link - // for a chain the underwriter cannot sign a commit on that chain. - // - // Collateral is also observed and logged here, but zero balance is not a - // startup failure: bootstrap may deposit after the node starts, and the - // scan loop admits each request using its exact route buckets. - // - // Returns true on success. On any gating failure logs a structured `elog` - // naming the specific missing item, and returns false. The caller - // (plugin_startup) treats false as "do not schedule the cron". - // - // Per `feedback_no_dev_escape_hatches.md`: NO `--strict=false` option, - // no dev fallback. Dev clusters that fail preflight are bootstrap bugs - // to fix in `wire-tools-ts/packages/test-cluster-tool`, not workarounds - // to ship in the plugin. - // ----------------------------------------------------------------------- - bool run_preflight() { - // A retrying preflight must never retain a provider from an earlier - // attempt whose chain-state authorization may no longer be current. - uic_signature_provider.reset(); - // -- Check 1: operator status -- - bool found_op = false; - bool active = false; - { - auto rows = read_all("sysio.opreg", "sysio.opreg", "operators"); - for (auto& row : rows.rows) { - auto obj = row.get_object(); - if (chain::name(obj["account"].as_string()) != underwriter_account) continue; - found_op = true; - auto status = obj["status"].as(); - active = (status == OperatorStatus::OPERATOR_STATUS_ACTIVE); - break; - } - } - if (!found_op) { - elog("underwriter preflight: account {} not registered in sysio.opreg::operators", - underwriter_account.to_string()); - return false; - } - if (!active) { - // Non-fatal: depot's collateral-deposit + meets_role_min activation - // can land AFTER the underwriter node is up (cluster bootstrap - // orders it that way — Phase 11d deposits collateral on the - // already-running underwriter node to flip uwrit.a → ACTIVE). - // The scan loop's `poll_own_status()` re-checks every cycle and - // skips work until ACTIVE, so we just log and let the cron job - // pick up the activation transition. - ilog("underwriter preflight: account {} not yet OPERATOR_STATUS_ACTIVE — " - "scan loop will start polling and wait for activation", - underwriter_account.to_string()); - } - - // Populate the outpost-chain cache (also used by the scan loop) so - // the link + balance coverage checks know what to look for. - read_outpost_registry(); - - // -- Check 2: an RPC client is registered for every active chain -- - // - // The served set is `outpost_chain_kinds` (ACTIVE non-depot chains only, - // per `read_outpost_registry`) as consumed by `select_coverable`. An - // active chain with no RPC client would let the scan loop SELECT a - // request for it and land one leg before discovering the other leg has - // nowhere to commit (SEC-13/WSA-027). Fail closed here so a - // misconfigured underwriter never starts committing partial swaps. - // - // The client is looked up under the chain's OWN code: an - // outpost-ethereum-client / outpost-solana-client entry is registered - // with the chain code as its client id, which makes the binding exact by - // construction rather than inferred, and makes "wrong VM family" simply a - // client that is not there. - for (const auto& [chain_code, ep] : outpost_endpoints) { - const auto code_str = fc::slug_name{chain_code}.to_string(); - const bool have_client = - ep.kind == ChainKind::CHAIN_KIND_EVM ? eth_plug->get_client(ep.client_id) != nullptr - : ep.kind == ChainKind::CHAIN_KIND_SVM ? sol_plug->get_client(ep.client_id) != nullptr - : false; - if (!have_client) { - elog("underwriter preflight: active outpost chain {} (kind={}) has no RPC client " - "registered under that chain code — add an outpost-ethereum-client / " - "outpost-solana-client entry whose client id is {}", - code_str, - std::string{sysio::opp::types::ChainKind_Name(ep.kind)}, - code_str); - return false; - } - } - - // -- Check 2b: the registry supplies every served chain's remote addresses -- - // - // The addresses are no longer per-node config: `sysio.chains` carries them - // so every underwriter commits against the same deployment. A row - // registered before its remote contracts existed leaves them empty, and - // committing against an empty address would mean signing to the zero - // address. Fail closed until governance runs `sysio.chains::setoutpost`. - for (const auto& [chain_code, ep] : outpost_endpoints) { - if (ep.commit_addr.empty() || ep.source_deposit_addr.empty()) { - elog("underwriter preflight: outpost chain {} carries no remote contract addresses " - "on its sysio.chains row — run sysio.chains::setoutpost for this chain", - fc::slug_name{chain_code}.to_string()); - return false; - } - } - - if (outpost_chain_kinds.empty()) { - elog("underwriter preflight: no outposts registered in sysio.chains::chains — " - "nothing to commit against"); - return false; - } - - // -- Check 3: authex link coverage per outpost chain -- - std::set linked_chains; - { - auto rows = read_all("sysio.authex", "sysio.authex", "links"); - for (auto& row : rows.rows) { - auto obj = row.get_object(); - if (chain::name(obj["username"].as_string()) != underwriter_account) continue; - linked_chains.insert(obj["chain_kind"].as()); - } - } - for (auto& [chain_code, chain_kind] : outpost_chain_kinds) { - if (!linked_chains.count(chain_kind)) { - elog("underwriter preflight: missing sysio.authex link for outpost {} " - "(chain_kind={}) — bootstrap must call sysio.authex::createlink for " - "this account on every outpost chain", - chain_code, - std::string{sysio::opp::types::ChainKind_Name(chain_kind)}); - return false; - } - } - - // -- Observation 4: RAW balance per outpost chain (non-gating) -- - // - // Reads `sysio.opreg::operators[underwriter].balances` directly and - // does NOT subtract active locks or pending withdraws. An underwriter - // with all collateral currently locked is still eligible to remain - // running — the moment their locks expire (via the chklocks sweep) - // they can underwrite the next round. Deducting locks here would - // false-fail a healthy underwriter who is in the middle of an active - // race. - std::map raw_balance_by_chain; - { - auto ops_rows = read_all("sysio.opreg", "sysio.opreg", "operators"); - for (auto& row : ops_rows.rows) { - auto obj = row.get_object(); - if (chain::name(obj["account"].as_string()) != underwriter_account) continue; - if (!obj.contains("balances") || !obj["balances"].is_array()) break; - for (auto& bal_entry : obj["balances"].get_array()) { - auto be = bal_entry.get_object(); - if (!be.contains("chain") || !be.contains("balance")) continue; - const int chain = magic_enum::enum_integer(be["chain"].as()); - const uint64_t balance = be["balance"].as_uint64(); - raw_balance_by_chain[chain] += balance; - } - break; - } - } - for (auto& [chain_code, chain_kind] : outpost_chain_kinds) { - const int ck = magic_enum::enum_integer(chain_kind); - auto it = raw_balance_by_chain.find(ck); - if (it == raw_balance_by_chain.end() || it->second == 0) { - // Non-fatal: collateral may be deposited AFTER plugin startup - // (cluster bootstrap orders it that way — Phase 11d deposits - // on the already-running underwriter node to flip uwrit.a → - // ACTIVE). Per-request cover selection re-checks the exact - // route buckets every cycle; the plugin naturally joins a race - // once the needed collateral lands, without making an unrelated - // zero-balance chain a global liveness gate. - ilog("underwriter preflight: zero raw balance on outpost {} " - "(chain_kind={}) — scan loop will poll for collateral " - "deposit and begin committing once available", - chain_code, - std::string{sysio::opp::types::ChainKind_Name(chain_kind)}); - } - } - - // -- Check 5: required CLI options + ABI/IDL resolution -- - // - // The verify_source_deposit path identifies the swap-deposit - // function (ETH) / instruction (SOL) by NAME. The contract - // address + function selector + instruction discriminator are - // resolved from the ABI / IDL files registered with the outpost - // client plugins (avoiding duplicate plugin options that would - // need to be kept in sync with `--ethereum-abi-file` / - // `--solana-idl-file`). - if (eth_source_deposit_function_name.empty()) { - elog("underwriter preflight: --underwriter-eth-source-deposit-function is required"); - return false; - } - if (sol_source_deposit_instruction_name.empty()) { - elog("underwriter preflight: --underwriter-sol-source-deposit-instruction is required"); - return false; - } - - // ETH: walk every loaded ABI for a `function` contract whose name - // matches. The match yields keccak256(signature) → the CHAIN-AGNOSTIC - // 4-byte selector (identical on every EVM chain running the same - // contract). The per-chain deployed contract ADDRESS comes from - // `outpost_endpoints` (SEC-13/WSA-027), so the ABI's `contract_address` - // field is no longer consulted here. - { - resolved_eth_source_deposit_selector.clear(); - bool found = false; - for (const auto& [path, contracts] : eth_plug->get_abi_files()) { - for (const auto& c : contracts) { - if (c.type != fc::network::ethereum::abi::invoke_target_type::function) continue; - if (c.name != eth_source_deposit_function_name) continue; - const auto sel_hash = fc::network::ethereum::abi::to_contract_function_selector(c); - const uint8_t* sp = sel_hash.data(); - resolved_eth_source_deposit_selector.assign(sp, sp + 4); - dlog("underwriter preflight: ETH deposit selector for '{}' resolved from ABI '{}'", - eth_source_deposit_function_name, path.string()); - found = true; - break; - } - if (found) break; - } - if (!found) { - elog("underwriter preflight: no ETH ABI entry found for function '{}'; " - "pass --ethereum-abi-file pointing at the ABI that declares it", - eth_source_deposit_function_name); - return false; - } - } - - // SOL: walk every loaded IDL for the named instruction. The IDL parser - // populates each instruction's 8-byte anchor discriminator - // (`sha256("global:")[0..8]`) — the CHAIN-AGNOSTIC - // identifier the verify path matches. The per-chain program id comes from - // `outpost_endpoints` (SEC-13/WSA-027), not the IDL's `address`. - { - resolved_sol_source_deposit_discriminator.clear(); - bool found = false; - for (const auto& [path, programs] : sol_plug->get_idl_files()) { - for (const auto& p : programs) { - if (const auto* ix = p.find_instruction(sol_source_deposit_instruction_name); ix) { - resolved_sol_source_deposit_discriminator.assign( - ix->discriminator.begin(), ix->discriminator.end()); - dlog("underwriter preflight: SOL deposit discriminator for '{}' resolved from IDL '{}'", - sol_source_deposit_instruction_name, path.string()); - found = true; - break; - } - } - if (found) break; - } - if (!found) { - elog("underwriter preflight: no SOL IDL instruction found named '{}'; " - "pass --solana-idl-file pointing at the IDL that declares it", - sol_source_deposit_instruction_name); - return false; - } - } - - // ── Check 5: signature providers — 3-provider minimum ──────────── - // - // The underwriter signs UIC digests with a fixed-size recoverable WIRE - // key (K1, R1, EM, or ED), and submits - // commit transactions on each active outpost (ETH + SOL) — so - // three sig-provider entries are required at startup: - // • exactly one supported provider targeting chain=wire — for UIC - // signing. K1/R1 use key-type=wire, EM uses key-type=ethereum, and - // ED uses key-type=solana. More than one supported provider would - // silently arbitrate at `.front()`; we refuse the ambiguity. - // • at least one (chain=ethereum, key-type=ethereum) — for - // paying gas on ETH outpost commits. - // • at least one (chain=solana, key-type=solana) — for SOL - // outpost commits. - auto& sig_plug = app().get_plugin(); - auto wire_providers = underwriter_detail::select_uic_signature_providers( - sig_plug.query_providers(std::nullopt, fc::crypto::chain_kind_wire), - [&](const std::string& key_name) { - return sig_plug.is_operator_configured_provider(key_name); - }, - [&](const fc::crypto::public_key& key) { - return chain_plug->provider_can_authorize_active_alone( - underwriter_account, key); - }); - if (wire_providers.size() != 1) { - elog("underwriter preflight: expected exactly 1 operator-configured WIRE UIC " - "signature provider whose key alone reaches the underwriter " - "account's current owner or active threshold, got {} — configure one " - "supported chain=wire provider (K1/R1 key-type=wire, EM " - "key-type=ethereum, or ED key-type=solana)", wire_providers.size()); - return false; - } - auto eth_providers = sig_plug.query_providers( - std::nullopt, fc::crypto::chain_kind_ethereum, fc::crypto::chain_key_type_ethereum); - if (eth_providers.empty()) { - elog("underwriter preflight: at least 1 Ethereum signature provider is required " - "(chain=ethereum, key-type=ethereum) for paying gas on ETH outpost commits"); - return false; - } - auto sol_providers = sig_plug.query_providers( - std::nullopt, fc::crypto::chain_kind_solana, fc::crypto::chain_key_type_solana); - if (sol_providers.empty()) { - elog("underwriter preflight: at least 1 Solana signature provider is required " - "(chain=solana, key-type=solana) for SOL outpost commits"); - return false; - } - - // ── Check 6: signature self-test ───────────────────────────────── - // - // Sign a fixed test digest with the configured provider, recover - // the pubkey, and confirm its weight alone reaches the underwriter - // account's `owner` or `active` threshold. Catches "wrong key configured" - // at startup instead of after a live uwreq is silently rejected - // by the depot's verify_uic_signature. - try { - const fc::sha256 self_test_digest = fc::sha256::hash( - std::string{underwriter_detail::uic_signature_self_test_domain}); - const auto provider_check = underwriter_detail::check_uic_signature_provider( - *wire_providers.front(), self_test_digest); - switch (provider_check.result) { - case underwriter_detail::uic_signature_provider_result::compatible: - break; - case underwriter_detail::uic_signature_provider_result:: - unsupported_public_key_type: - elog("underwriter preflight: the WIRE UIC signature provider must use a " - "fixed-size recoverable K1, R1, EM, or ED public key; configured key " - "{} is unsupported.", - wire_providers.front()->public_key.to_string(fc::yield_function_t{})); - return false; - case underwriter_detail::uic_signature_provider_result:: - unsupported_signature_type: - elog("underwriter preflight: the WIRE UIC signature provider returned a " - "WebAuthn or BLS signature during its self-test. The depot accepts " - "only fixed-size recoverable K1, R1, EM, or ED UIC signatures."); - return false; - case underwriter_detail::uic_signature_provider_result::signature_type_mismatch: - elog("underwriter preflight: the WIRE UIC signature provider's public-key " - "and self-test signature variants do not match."); - return false; - case underwriter_detail::uic_signature_provider_result:: - non_canonical_packed_signature: - elog("underwriter preflight: the WIRE UIC signature provider returned a " - "non-canonical packed fixed-size signature during its self-test."); - return false; - case underwriter_detail::uic_signature_provider_result:: - non_canonical_signature: - elog("underwriter preflight: the WIRE UIC signature provider returned " - "a noncanonical ECDSA recovery/scalar body during its self-test."); - return false; - } - if (!provider_check.recovered_key) { - elog("underwriter preflight: the WIRE UIC signature provider self-test " - "did not recover a public key."); - return false; - } - const auto& recovered = *provider_check.recovered_key; - - if (recovered != wire_providers.front()->public_key || - !chain_plug->provider_can_authorize_active_alone( - underwriter_account, recovered)) { - elog("underwriter preflight: signature self-test failed — the configured " - "WIRE UIC signature provider's recovered pubkey ({}) does not equal " - "its configured key or its weight no longer reaches the underwriter " - "account's owner or active threshold. The depot will reject every " - "commit signed by this provider.", - recovered.to_string(fc::yield_function_t{})); - return false; - } - } catch (const fc::exception& e) { - elog("underwriter preflight: signature self-test threw: {}", e.to_detail_string()); - return false; - } - - uic_signature_provider = wire_providers.front(); - - ilog("underwriter preflight: all checks passed (account={} outposts={})", - underwriter_account.to_string(), - outpost_chain_kinds.size()); - return true; - } - - // ----------------------------------------------------------------------- - // Sync-gated startup - // ----------------------------------------------------------------------- - - /// Arm the deferred startup: runs AT MOST once (re-entry is guarded by - /// {@link startup_attempted}), on the main thread from the sync-gate channel - /// callback once `controller::is_synced()` holds. The body itself lives - /// in {@link attempt_deferred_startup}, which may retry the preflight - /// within a bounded grace and shuts the node down on terminal failure - /// (fail-fast). - void run_deferred_startup() { - startup_armed_at = fc::time_point::now(); - // Release the gate subscription; the callback already unsubscribed - // before calling here, so this is an idempotent cleanup, not the - // load-bearing disconnect. - sync_gate_subscription.unsubscribe(); - attempt_deferred_startup(); - } - - /// One attempt of the deferred startup body: preflight → outpost client - /// wiring → cron scheduling. A failing preflight within - /// `underwriter_defaults::preflight_retry_grace_ms` of the gate arming is - /// NOT terminal — rows the harness confirmed final on the producer can - /// land in the LOCAL irreversible state a beat after the gate arms - /// (observed live: `regoperator` in block 405 readable 50ms after a - /// preflight read at LIB 402), so the attempt re-schedules itself on - /// `preflight_retry_interval_ms` until the grace expires. Past the grace, - /// a preflight failure on a synced chain is a terminal bootstrap bug — - /// stored as a diagnosable gate state, logged, and answered with a - /// fail-fast node shutdown (see {@link quit_if_startup_failed_terminally}). - /// - /// Escapes are contained FIRST (unwinding out of a posted channel delivery - /// would skip the diagnosable gate-state store and the shutdown log) and - /// then routed through the same fail-fast shutdown. The expected - /// preflight/wiring failures inside the body set their own more specific - /// states; the containment catches what they don't (a throwing table - /// decode in the preflight or registry read, a non-fc exception from - /// client wiring, a cron add_job failure). FC_LOG_AND_DROP is the tree's - /// containment idiom (see scan_cycle below) and deliberately rethrows - /// boost::interprocess::bad_alloc — chainbase shared-memory exhaustion - /// stays immediately fatal. - void attempt_deferred_startup() { - if (shutting_down) { - return; - } - bool completed = false; - try { - attempt_deferred_startup_body(); - completed = true; - } FC_LOG_AND_DROP("underwriter_plugin: deferred startup failed unexpectedly:"); - if (!completed) { - gate_state = underwriter_detail::startup_state::startup_failed; - } - // Tripwire for the {@link startup_attempted} derivation: every attempt - // that returns normally must have left `waiting_for_sync` — a future - // early return in the body before its first gate_state store would - // otherwise strand the node in undiagnosable limbo (gate subscription - // already released, nothing left to re-arm). - assert(startup_attempted()); - quit_if_startup_failed_terminally(); - } - - /// The uniform fail-fast policy: a terminal deferred-startup failure shuts - /// the node down instead of leaving a quiet, non-underwriting node behind a - /// running process — an operator daemon that is not performing its duties - /// must be supervisor-visible (it has liveness/slashing consequences), not - /// hidden behind one log line. The specific failure was already stored as a - /// gate state and logged by the attempt; this holds the shutdown decision in - /// ONE place so the policy cannot diverge between the first attempt and the - /// bounded preflight retries. - void quit_if_startup_failed_terminally() { - const auto state = gate_state.load(); - if (!underwriter_detail::is_terminal_failure(state)) { - return; - } - elog("underwriter_plugin: deferred startup failed terminally (state={}) — " - "shutting down node (fail-fast)", magic_enum::enum_name(state)); - app().quit(); - } - - /// The deferred startup body proper — see {@link attempt_deferred_startup} - /// for the retry semantics and the exception containment around it. - void attempt_deferred_startup_body() { - // Pre-flight: bail (no cron job) if the depot-side state for this - // underwriter is incomplete. Cluster bootstrap is responsible for - // establishing the missing state — there is no dev escape hatch. - // The already-registered HTTP endpoints keep reporting the gate state - // so a not-yet-live underwriter is diagnosable over HTTP. - if (!run_preflight()) { - const auto since_armed = fc::time_point::now() - startup_armed_at; - if (since_armed < - fc::milliseconds(underwriter_defaults::preflight_retry_grace_ms)) { - gate_state = underwriter_detail::startup_state::preflight_retrying; - ilog("underwriter_plugin: preflight incomplete {}ms after the sync gate " - "armed — retrying in {}ms (grace {}ms); the error above is " - "transient until the grace expires", - since_armed.count() / 1000, - underwriter_defaults::preflight_retry_interval_ms, - underwriter_defaults::preflight_retry_grace_ms); - schedule_preflight_retry(); - return; - } - gate_state = underwriter_detail::startup_state::preflight_failed; - elog("underwriter_plugin: pre-flight failed — cron job NOT registered"); - return; - } - - // The preflight above already established that every active chain has both - // an RPC client and contract addresses; wire_outpost_clients documents the - // handles it builds. - read_outpost_registry(); - try { - wire_outpost_clients(); - if (outpost_by_chain.empty()) { - // Preflight passed, so every ACTIVE chain had a client and addresses - // — reaching here means the registry holds no active non-depot chain - // at all, which the preflight reports separately. Nothing to serve yet. - wlog("underwriter_plugin: NO outpost_clients wired — sysio.chains lists no active " - "non-depot chain"); - } - } catch (const fc::exception& e) { - gate_state = underwriter_detail::startup_state::wiring_failed; - elog("underwriter_plugin: failed to build outpost_client(s): {}", - e.to_detail_string()); - return; - } - - cron_service::job_schedule sched; - sched.milliseconds = {cron_service::job_schedule::step_value{scan_interval_ms}}; - - cron_service::job_metadata_t meta; - meta.label = "underwriter_scan"; - meta.one_at_a_time = true; - - scan_job_id = cron_plug->add_job( - sched, - [this]() { scan_cycle(); }, - meta - ); - // Stored immediately after the job exists — anything thrown between a - // successful add_job and this store would otherwise leave the scan cron - // running while the gate reports a terminal failure. The - // `/v1/underwriter/*` endpoints (registered back in `plugin_startup`) - // switch from gate-state reporting to the real payloads here. - gate_state = underwriter_detail::startup_state::active; - - ilog("underwriter_plugin: scheduled scan (id={}, interval={}ms)", - scan_job_id, scan_interval_ms); - } - - /// Re-run {@link attempt_deferred_startup} after one retry interval, on - /// the main thread (appbase-wrapped wait on the app io_context). - void schedule_preflight_retry() { - if (!preflight_retry_timer) { - preflight_retry_timer.emplace(app().get_io_context()); - } - preflight_retry_timer->expires_after( - std::chrono::milliseconds(underwriter_defaults::preflight_retry_interval_ms)); - // Plain wait on the main io_context, then POST the attempt to the app - // queue — the same pattern the sync-gate callback uses, so the body - // runs in the same main-thread context plugin_startup does. - preflight_retry_timer->async_wait([this](const boost::system::error_code& ec) { - if (ec || shutting_down) { - return; - } - app().executor().post(appbase::priority::medium, [this]() { - // Proceed only while the retry grace is the live state: a - // terminal state stored between the timer firing and this task - // running must stay terminal. Structural guard — today no such - // interleaving exists, but the terminal-stays-terminal invariant - // should not rest on statement order in schedule_preflight_retry. - if (shutting_down || - gate_state.load() != underwriter_detail::startup_state::preflight_retrying) { - return; - } - attempt_deferred_startup(); - }); - }); - } - - // ----------------------------------------------------------------------- - // Main scan cycle - // ----------------------------------------------------------------------- - - void scan_cycle() { - if (shutting_down || !enabled) return; - try { - do_scan_cycle(); - } FC_LOG_AND_DROP(); - } - - void do_scan_cycle() { - // Step 0: refresh own status. SLASHED / TERMINATED operators must NOT - // call commit() on outposts — the depot rejects (or simply doesn't - // select them as winner), but the wasted JSON-RPC tx + on-chain - // attestation is observable noise. Halting locally is cleaner. - poll_own_status(); - if (!is_active) return; - - // Step 1: Read outpost registry for chain_kind mappings, then re-wire any - // outpost whose remote deployment moved since the handle was built. - read_outpost_registry(); - try { - wire_outpost_clients(); - } catch (const fc::exception& e) { - // A rebuild that fails leaves the previous handle in place; the next - // tick retries. Losing the whole scan cycle over one chain's RPC - // hiccup would stall commits on every OTHER chain too. - wlog("underwriter_plugin: outpost re-wire failed, keeping existing clients: {}", - e.to_detail_string()); - } - - // Step 2: Read our credit lines from sysio.opreg::operators - read_credit_lines(); - - // Step 3: Scan sysio.uwrit::uwreqs for PENDING requests. Admission is - // request-specific: select_coverable checks the exact collateral - // buckets for each route, so an unrelated active chain with no balance - // must not block otherwise coverable work. - auto requests = scan_pending_requests(); - - // Step 3b: prune local per-uwreq state whose uwreq is no longer - // PENDING — the depot has resolved (won/lost/expired) those races, so - // the local sets should not grow unbounded. This is the same pass that - // already reads the PENDING set, so it's free. - { - std::unordered_set still_pending; - still_pending.reserve(requests.size()); - for (auto& r : requests) still_pending.insert(r.id); - std::lock_guard lk{stats_mutex}; - std::erase_if(confirmed_commits, [&](const commit_key& k) { - return !still_pending.contains(k.uwreq_id); - }); - underwriter::prune_solana_source_deposit_scan_cursors( - sol_source_deposit_scan_cursors, still_pending); - uwreqs_seen_pending_count = requests.size(); - } - - if (requests.empty()) return; - - ilog("underwriter: found {} pending underwrite requests", requests.size()); - - // A durable terminal candidate must not consume fresh-cover selection. - // A complete stored candidate is reserved below while it awaits its - // explicit external replay trigger. - const auto skipped_candidates = std::erase_if( - requests, [](const uw_request& r) { - return r.own_stored_commit_plan.skip_candidate; - }); - if (skipped_candidates != 0) { - ilog("underwriter: skipped {} terminal depot candidates before cover selection", - skipped_candidates); - } - if (requests.empty()) return; - - // Seed provisional capacity from the depot's current available balance. - // Candidates whose paid legs are all stored or locally confirmed have no - // submission work, but their eventual locks do not exist yet. Reserve - // their full winner-time requirements before selecting new work so OPP - // relay latency cannot make the daemon knowingly overcommit collateral. - credit_buckets remaining_credit; - for (const auto& cl : credit_lines) { - remaining_credit[bucket_key{cl.chain_code, cl.token_code}] = cl.balance; - } - size_t outstanding_no_work = 0; - std::erase_if(requests, [&](const uw_request& r) { - const bool source_confirmed = confirmed_commits.contains( - commit_key{r.id, r.src_chain_code.value, r.src_token_code.value, - r.src_reserve_code.value}); - const bool destination_confirmed = confirmed_commits.contains( - commit_key{r.id, r.dst_chain_code.value, r.dst_token_code.value, - r.dst_reserve_code.value}); - if (underwriter_detail::has_submission_work( - r.own_stored_commit_plan, source_confirmed, - destination_confirmed)) { - return false; - } - underwriter_detail::reserve_buckets( - remaining_credit, src_bond(r), dst_bond(r)); - ++outstanding_no_work; - return true; - }); - - if (outstanding_no_work != 0) { - ilog("underwriter: reserved collateral for {} locally complete " - "candidate(s) awaiting depot observation", outstanding_no_work); - } - - if (requests.empty()) { - ilog("underwriter: pending uwreqs have no remaining outpost " - "submission work"); - return; - } - - // Step 4: Select requests whose complete winner-time collateral - // requirements can be covered, then submit only their missing legs. - auto selected = select_coverable(requests, std::move(remaining_credit)); - if (selected.empty()) { - ilog("underwriter: no requests coverable with current credit lines"); - return; - } - - ilog("underwriter: selected {} requests for underwriting", selected.size()); - - // Step 5: Submit intent for each selected request - for (auto& req : selected) { - submit_intent_to_outpost(req); - } - } - - // ----------------------------------------------------------------------- - // Read outpost registry - // ----------------------------------------------------------------------- - - void read_outpost_registry() { - outpost_chain_kinds.clear(); - outpost_external_chain_ids.clear(); - outpost_endpoints.clear(); - // refactor: chain rows moved from `sysio.epoch::outposts` to - // `sysio.chains::chains`. Each row is a `Chain` with fields: - // `code` — slug_name (the universal chain identifier; the - // v5 `outpost_id` was just this slug's uint64). - // `kind` — ChainKind enum. - // `external_chain_id` — the chain's numeric id (1 = ETH mainnet, …); - // fed to `create_outpost_client` (SEC-13/WSA-027). - // `is_depot` — true for the WIRE depot's own row; we filter - // it out since underwriters don't commit to the - // depot itself. - // `active` - false until `sysio.chains::activchain` runs; - // post-bootstrap `regchain` rows start inactive, - // so they are not yet live outposts and are skipped. - auto rows = read_all("sysio.chains", "sysio.chains", "chains"); - depot_chain_code.reset(); - for (auto& row : rows.rows) { - auto obj = row.get_object(); - // `code` is a `slug_name`. Read it through fc::slug_name's own - // from_variant, which takes the decoded slug string ("" for the zero - // sentinel) plus the transitional `{"value": }` object. - uint64_t chain_code = obj["code"].as().value; - if (obj.contains("is_depot") && obj["is_depot"].as_bool()) { - // Record the depot's own code for exact per-leg depot - // detection (to/from-WIRE swaps), then skip caching it as an - // outpost — underwriters never commit to the depot itself. - depot_chain_code = chain_code; - continue; - } - // Skip chains not yet activated. `regchain` inserts post-bootstrap - // rows with `active=false` until `activchain` flips them; treating an - // inactive (future) chain as a live outpost would make the endpoint- - // coverage preflight and per-request cover selection demand config - // for a chain that is not serving yet, blocking startup or halting - // the already-active chains. Mirrors batch_operator_plugin's - // `is_depot || !active` outpost filter. - if (!obj.contains("active") || !obj["active"].as_bool()) - continue; - // FC_REFLECT_ENUM in sysio/opp/opp.hpp gives us a direct enum - // round-trip — the variant carries the symbolic name and `.as()` - // recovers the typed value without a string switch. - outpost_chain_kinds[chain_code] = obj["kind"].as(); - outpost_external_chain_ids[chain_code] = - static_cast(obj["external_chain_id"].as_uint64()); - build_endpoint_from_row(chain_code, obj); - } - } - - /// Build one `outpost_client` SPI handle per CONFIGURED chain (SEC-13/WSA-027: - /// keyed by EXACT chain_code, so two chains of the same VM family each get - /// their own client + RPC). The underwriter never sees raw `ethereum_client` - /// / `solana_client` instances after this point — every outpost-side action - /// goes through the SPI virtuals. Per `outpost-client-spi.md`: - /// * ETH client carries only the OperatorRegistry address (the uw_commit - /// target); the underwriter neither consumes nor emits OPP envelopes, so - /// OPP / OPPInbound addresses are left empty. - /// * SOL client carries the opp-outpost program id; the typed wrapper - /// exposes `commit_underwrite` directly. - /// `external_chain_id` and the contract addresses both come from the chain's - /// ACTIVE `sysio.chains` row, read by {@link read_outpost_registry}. - /// - /// Idempotent, and re-run every scan tick: a handle is rebuilt only when the - /// registry now names a DIFFERENT remote address than the one it was built - /// with. Governance can move a deployment under a running underwriter with - /// `sysio.chains::setoutpost`, and without this the daemon would keep - /// committing to the address the outpost has moved off. - void wire_outpost_clients() { - for (const auto& [chain_code, ep] : outpost_endpoints) { - const auto code_str = fc::slug_name{chain_code}.to_string(); - // Skip chains with no active registry row: the preflight already - // failed the startup path on those, and on a rescan a chain can be - // deactivated without invalidating the clients still in use. - auto ext = outpost_external_chain_ids.find(chain_code); - if (ext == outpost_external_chain_ids.end()) continue; - if (ep.commit_addr.empty()) { - // A row can LOSE an address: `setoutpost` replaces the whole set, so - // clearing a role retires that deployment. Drop any handle built - // against it — keeping one would let a later destination leg commit - // to a deployment the registry no longer names, which is worse than - // not committing at all. - if (outpost_by_chain.erase(chain_code) > 0) { - wlog("underwriter_plugin: outpost chain {} no longer carries a remote contract " - "address — retired the wired client; not committing on this chain until " - "sysio.chains::setoutpost supplies one", - code_str); - } else { - wlog("underwriter_plugin: outpost chain {} has no remote contract address on its " - "sysio.chains row — not wiring until sysio.chains::setoutpost supplies one", - code_str); - } - wired_commit_addrs.erase(chain_code); - continue; - } - if (auto wired = wired_commit_addrs.find(chain_code); - wired != wired_commit_addrs.end() && wired->second == ep.commit_addr - && outpost_by_chain.contains(chain_code)) { - continue; // already wired against this exact deployment - } - const bool rebuilt = outpost_by_chain.contains(chain_code); - if (ep.kind == ChainKind::CHAIN_KIND_EVM) { - outpost_by_chain[chain_code] = - eth_plug->create_outpost_client(ep.client_id, chain_code, ext->second, - /*opp_addr=*/"", /*opp_inbound_addr=*/"", - ep.commit_addr); - ilog("underwriter_plugin: {} ETH outpost_client chain={} (client_id='{}', opreg={})", - rebuilt ? "rewired" : "wired", code_str, ep.client_id, ep.commit_addr); - } else if (ep.kind == ChainKind::CHAIN_KIND_SVM) { - outpost_by_chain[chain_code] = - sol_plug->create_outpost_client(ep.client_id, chain_code, ext->second, - ep.commit_addr /*program_id*/, - solana_outpost_role::underwriter); - ilog("underwriter_plugin: {} SOL outpost_client chain={} (client_id='{}', program={})", - rebuilt ? "rewired" : "wired", code_str, ep.client_id, ep.commit_addr); - } else { - wlog("underwriter_plugin: outpost_endpoint chain={} has unknown kind — skipped", - code_str); - continue; - } - wired_commit_addrs[chain_code] = ep.commit_addr; - } - } - - /// Build this chain's endpoint entirely from its `chains` row. - /// - /// Nothing about an outpost is per-node any more: the registry names WHICH - /// chains are served (every active non-depot row) and WHERE each one lives, - /// and the RPC client is the one registered under the chain's own code. That - /// leaves the operator responsible only for supplying the endpoints - /// themselves, and makes it impossible for one underwriter to commit against - /// a deployment the rest of the network disagrees on. - void build_endpoint_from_row(uint64_t chain_code, const fc::variant_object& obj) { - outpost_endpoint ep; - ep.kind = outpost_chain_kinds.at(chain_code); - // The chain code IS the RPC client id — see outpost-ethereum-client / - // outpost-solana-client. The preflight fails closed when no client is - // registered under it. - ep.client_id = fc::slug_name{chain_code}.to_string(); - - std::string opp_addr, operator_registry_addr, source_deposit_addr; - if (auto out_it = obj.find(depot_chains::field::outpost); - out_it != obj.end() && out_it->value().is_object()) { - const auto& out = out_it->value().get_object(); - const auto read = [&](const char* f) -> std::string { - auto it = out.find(f); - return it == out.end() ? std::string{} : it->value().as_string(); - }; - opp_addr = read(depot_chains::field::outpost_addr::opp_addr); - operator_registry_addr = read(depot_chains::field::outpost_addr::operator_registry_addr); - source_deposit_addr = read(depot_chains::field::outpost_addr::source_deposit_addr); - } - // An SVM outpost is one program serving every role; an EVM outpost names a - // distinct contract per role and must NEVER fall back to opp_addr, or a - // row whose OperatorRegistry is not deployed yet would commit to the OPP - // contract instead of failing closed. - const bool single_program = ep.kind == ChainKind::CHAIN_KIND_SVM; - ep.commit_addr = - depot_chains::resolve_role_addr(operator_registry_addr, opp_addr, single_program); - ep.source_deposit_addr = - depot_chains::resolve_role_addr(source_deposit_addr, opp_addr, single_program); - outpost_endpoints[chain_code] = std::move(ep); - } - - /// True iff `code` is the WIRE depot's own chain code. Exact compare - /// against the registry's `is_depot` row — never inferred from - /// CHAIN_KIND_UNKNOWN (which also matches unregistered chains). - bool is_depot_leg(fc::slug_name code) const { - return depot_chain_code && code.value == *depot_chain_code; - } - - // ----------------------------------------------------------------------- - // Read credit lines from sysio.opreg::operators - // ----------------------------------------------------------------------- - - void read_credit_lines() { - credit_lines.clear(); - - // schema: balance / lock / withdraw rows carry `chain_code` - // and `token_code` slug_names (read via fc::slug_name's from_variant) - // — not the v5 `chain` (ChainKind enum) / `token_kind` (TokenKind - // enum). Translation: - // chain_code → ChainKind via `outpost_chain_kinds` map - // (populated by `read_outpost_registry`). - // token_code → TokenKind via `sysio.tokens::tokens.kind` - // (lazy-loaded into `token_kind_by_code`). - // Reads happen in this order so the registry caches are warm when - // the balance loop runs. - - // Refresh token-code → TokenKind cache once per scan (tokens - // table is small — 5 rows in the test cluster). Stored as a - // member so `scan_pending_requests` can reuse the same map when - // translating uwreq slug codes to TokenKind for bucket matching. - token_kind_by_code.clear(); - { - auto tk_rows = read_all("sysio.tokens", "sysio.tokens", "tokens"); - for (auto& row : tk_rows.rows) { - auto obj = row.get_object(); - uint64_t code = obj["code"].as().value; - token_kind_by_code[code] = obj["kind"].as(); - } - } - - // Local helper: read `chain_code`/`token_code` slug fields (shape - // `{"value": }`) as their EXACT packed slug values. Returns nullopt - // (row skipped) when the chain isn't a registered non-depot outpost or - // the token is unknown — neither can back a leg. SEC-13/WSA-027: key by - // exact code, never collapse to ChainKind/TokenKind. - auto read_slug_pair = [&](const fc::variant_object& obj) - -> std::optional> { - if (!obj.contains("chain_code") || !obj.contains("token_code")) { - return std::nullopt; - } - uint64_t chain_code = obj["chain_code"].as().value; - uint64_t token_code = obj["token_code"].as().value; - if (!outpost_chain_kinds.contains(chain_code) - || !token_kind_by_code.contains(token_code)) { - return std::nullopt; - } - return std::make_pair(chain_code, token_code); - }; - - // ── Step 1: raw balances from sysio.opreg::operators[underwriter] ── - auto ops_rows = read_all("sysio.opreg", "sysio.opreg", "operators"); - for (auto& row : ops_rows.rows) { - auto obj = row.get_object(); - if (chain::name(obj["account"].as_string()) != underwriter_account) continue; - if (!obj.contains("balances") || !obj["balances"].is_array()) break; - for (auto& bal_entry : obj["balances"].get_array()) { - auto be = bal_entry.get_object(); - auto codes = read_slug_pair(be); - if (!codes) continue; - credit_lines.push_back(credit_line{ - .chain_code = codes->first, - .token_code = codes->second, - .balance = be["balance"].as_uint64(), - }); - } - break; - } - - // ── Step 2: subtract active locks (sysio.uwrit::locks) ───────────── - // Locks that exceed the raw balance clamp to 0 — same convention as - // the depot's `available()`. - auto lock_rows = read_all(uwrit::account, uwrit::account, uwrit::table_locks); - for (auto& row : lock_rows.rows) { - auto obj = row.get_object(); - if (chain::name(obj[uwrit::lock_field::underwriter].as_string()) != underwriter_account) continue; - auto codes = read_slug_pair(obj); - if (!codes) continue; - const uint64_t amount = obj[uwrit::lock_field::amount].as_uint64(); - for (auto& cl : credit_lines) { - if (cl.chain_code == codes->first && cl.token_code == codes->second) { - cl.balance = (cl.balance > amount) ? (cl.balance - amount) : 0; - break; - } - } - } - - // ── Step 3: subtract pending withdraws (sysio.opreg::wtdwqueue) ──── - auto wq_rows = read_all("sysio.opreg", "sysio.opreg", "wtdwqueue"); - for (auto& row : wq_rows.rows) { - auto obj = row.get_object(); - if (chain::name(obj["account"].as_string()) != underwriter_account) continue; - auto codes = read_slug_pair(obj); - if (!codes) continue; - const uint64_t amount = obj["amount"].as_uint64(); - for (auto& cl : credit_lines) { - if (cl.chain_code == codes->first && cl.token_code == codes->second) { - cl.balance = (cl.balance > amount) ? (cl.balance - amount) : 0; - break; - } - } - } - - for (auto& cl : credit_lines) { - ilog("underwriter: credit line chain_code={} token_code={} available={}", - fc::slug_name{cl.chain_code}.to_string(), - fc::slug_name{cl.token_code}.to_string(), - cl.balance); - } - } - - /** - * Refresh `is_active` from `sysio.opreg::operators[underwriter_account].status`. - * Mirror of the awareness poll on batch_operator_plugin — both share - * the `sysio::opp::depot::opreg_status::compute_is_active` helper so the - * status spellings + decision table live in one place. Logs once per - * transition. - */ - void poll_own_status() { - auto rows = read_all("sysio.opreg", "sysio.opreg", "operators"); - bool was_active = is_active; - for (auto& row : rows.rows) { - auto obj = row.get_object(); - if (chain::name(obj["account"].as_string()) != underwriter_account) continue; - is_active = sysio::opp::depot::opreg_status::compute_is_active( - obj["status"].as_string(), was_active); - break; - } - if (was_active && !is_active) { - elog("underwriter: own status flipped to SLASHED / TERMINATED — halting relay loop"); - } else if (!was_active && is_active) { - ilog("underwriter: own status flipped to ACTIVE — resuming relay loop"); - } - } - - // ----------------------------------------------------------------------- - // Scan sysio.uwrit::uwreqs for PENDING requests - // ----------------------------------------------------------------------- - - std::vector scan_pending_requests() { - std::vector requests; - - // `sysio.uwrit::uwreqs` is now a KV table. The legacy - // multi_index-style `{"bystatus": }` lower_bound format - // doesn't traverse the KV secondary index — it returns 0 rows. - // Until a KV-index query path lands here, scan by primary - // key and filter PENDING in C++. uwreqs is small (one row per - // in-flight swap; race-resolved rows transition to other - // statuses within an epoch), so this is cheap. - auto rows = read_all(uwrit::account, uwrit::account, uwrit::table_requests); - for (auto& row : rows.rows) { - auto obj = row.get_object(); - - // Filter to PENDING only. FC-reflected decoding accepts the ABI - // spelling or numeric representation and rejects undeclared values. - const auto request_status = - underwriter_detail::decode_enum_field( - obj, uwrit::request_field::status); - if (!request_status || *request_status != - UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_PENDING) continue; - - // Skip if already assigned to another underwriter - auto uw_name = obj.contains(uwrit::request_field::underwriter_name) - ? obj[uwrit::request_field::underwriter_name].as_string() - : std::string{}; - if (!uw_name.empty() && chain::name(uw_name) != underwriter_account && - chain::name(uw_name) != chain::name()) { - continue; - } - - uw_request req; - req.id = obj[uwrit::request_field::id].as_uint64(); - req.status = *request_status; - req.uw_name = uw_name; - - const auto attestation_type = - underwriter_detail::decode_enum_field( - obj, uwrit::request_field::type); - if (!attestation_type || *attestation_type != - AttestationType::ATTESTATION_TYPE_SWAP_REQUEST) continue; - req.attestation_type = *attestation_type; - - // Data-model schema: src/dst identity lives on the uwreq row as - // `(chain_code, token_code, reserve_code)` slug_name triples plus a - // `*_amount`. Populated by `sysio.uwrit::createuwreq` from the - // originating SwapRequest. Each is read through fc::slug_name's own - // from_variant, so the carrier is decoded in exactly one place. - if (!obj.contains(uwrit::request_field::source_chain_code) || - !obj.contains(uwrit::request_field::source_amount) || - !obj.contains(uwrit::request_field::destination_chain_code) || - !obj.contains(uwrit::request_field::destination_amount) || - !obj.contains(uwrit::request_field::target_amount)) { - // Row not yet populated (createuwreq writes them inline so this - // should be unreachable for SWAP-derived UWREQs). Skip safely. - continue; - } - auto read_codename = [&](const char* key) -> fc::slug_name { - return obj[key].as(); - }; - req.src_chain_code = read_codename( - uwrit::request_field::source_chain_code); - req.src_token_code = read_codename( - uwrit::request_field::source_token_code); - req.src_reserve_code = read_codename( - uwrit::request_field::source_reserve_code); - req.src_amount = obj[uwrit::request_field::source_amount].as_uint64(); - req.dst_chain_code = read_codename( - uwrit::request_field::destination_chain_code); - req.dst_token_code = read_codename( - uwrit::request_field::destination_token_code); - req.dst_reserve_code = read_codename( - uwrit::request_field::destination_reserve_code); - req.dst_amount = obj[ - uwrit::request_field::destination_amount].as_uint64(); - req.target_amount = obj[ - uwrit::request_field::target_amount].as_uint64(); - req.variance_tolerance_bps = obj.contains( - uwrit::request_field::variance_tolerance_bps) - ? static_cast(obj[ - uwrit::request_field::variance_tolerance_bps].as_uint64()) - : 0u; - // Cross-walk the slug_name codes to ChainKind/TokenKind enums for - // outpost dispatch and diagnostics. Maps populated by the upstream - // `read_outpost_registry` (`sysio.chains::chains` → ChainKind) - // and `read_credit_lines` (`sysio.tokens::tokens` → TokenKind) - // calls in `do_scan_cycle`. Any uncovered slug — e.g. the - // depot's WIRE chain (filtered out of outpost_chain_kinds via - // is_depot) — falls through as UNKNOWN; depot legs are identified - // independently below and require neither an outpost nor collateral. - auto resolve_chain_kind = [&](fc::slug_name code) -> ChainKind { - auto it = outpost_chain_kinds.find(code.value); - return it != outpost_chain_kinds.end() - ? it->second : ChainKind::CHAIN_KIND_UNKNOWN; - }; - auto resolve_token_kind = [&](fc::slug_name code) -> TokenKind { - auto it = token_kind_by_code.find(code.value); - return it != token_kind_by_code.end() - ? it->second : TokenKind::TOKEN_KIND_UNKNOWN; - }; - req.src_chain = resolve_chain_kind(req.src_chain_code); - req.src_token_kind = resolve_token_kind(req.src_token_code); - req.dst_chain = resolve_chain_kind(req.dst_chain_code); - req.dst_token_kind = resolve_token_kind(req.dst_token_code); - // Depot-leg stamps — exact compare against the registry's - // is_depot row. A depot (WIRE) leg needs no commit / bond / - // source verification; downstream stages branch on these. - req.src_is_depot = is_depot_leg(req.src_chain_code); - req.dst_is_depot = is_depot_leg(req.dst_chain_code); - - // The depot row, not the process-local confirmation cache, is the - // restart-safe authority for whether this candidate already has all - // required outpost evidence. A complete INTENT_SUBMITTED candidate has - // already received its one depot attempt and must never replay an - // outpost commit. An existing status that is invalid or no longer - // INTENT_SUBMITTED also fails closed rather than masquerading as absence. - bool own_candidate_exists = false; - bool own_candidate_submitted = false; - bool source_stored = false; - bool destination_stored = false; - if (obj.contains(uwrit::request_field::commits_by) && - obj[uwrit::request_field::commits_by].is_array()) { - for (const auto& commit_v : - obj[uwrit::request_field::commits_by].get_array()) { - const auto commit = commit_v.get_object(); - if (!commit.contains(uwrit::commit_field::underwriter) || - chain::name(commit[ - uwrit::commit_field::underwriter].as_string()) != - underwriter_account) { - continue; - } - own_candidate_exists = true; - const auto stored_status = - underwriter_detail::decode_enum_field( - commit, uwrit::commit_field::status); - own_candidate_submitted = stored_status == - UnderwriteStatus::UNDERWRITE_STATUS_INTENT_SUBMITTED; - const auto leg_ready = [&](const char* timestamp, - const char* payload) { - return commit.contains(timestamp) && - commit[timestamp].as_uint64() != 0 && - commit.contains(payload) && - commit[payload].is_string() && - !commit[payload].as_string().empty(); - }; - source_stored = leg_ready( - uwrit::commit_field::source_received_at_ms, - uwrit::commit_field::source_uic_bytes); - destination_stored = leg_ready( - uwrit::commit_field::destination_received_at_ms, - uwrit::commit_field::destination_uic_bytes); - break; - } - } - req.own_stored_commit_plan = underwriter_detail::plan_stored_commits( - own_candidate_exists, own_candidate_submitted, - req.src_is_depot, req.dst_is_depot, - source_stored, destination_stored); - - // The ABI surfaces `bytes` as a hex string. Decode both - // source_tx_id and depositor — the depot rejects any SwapRequest - // with empty source_tx_id at createuwreq (emits SwapRevert), so - // every uwreq the plugin sees should carry both fields. - auto decode_hex_field = [&](const char* key, std::vector& out) { - if (!obj.contains(key)) return; - auto s = obj[key].as_string(); - if (s.empty()) return; - out.resize(s.size() / 2); - fc::from_hex(s, out.data(), out.size()); - }; - decode_hex_field( - uwrit::request_field::source_transaction_id, req.source_tx_id); - decode_hex_field(uwrit::request_field::depositor, req.depositor); - - requests.push_back(std::move(req)); - } - - return requests; - } - - // ----------------------------------------------------------------------- - // Select requests coverable by our credit lines - // Requires 100% coverage on BOTH non-depot legs of the swap, where each - // leg's required bond is per exact `(chain_code, token_code)` bucket. - // ----------------------------------------------------------------------- - - /// Hard cap on the number of candidates the branch-and-bound search - /// considers. Above this we fall back to value-sort-descending greedy - /// to keep the cycle bounded — the upper-bound prune is good but worst - /// case is still 2^N branches. - static constexpr size_t MAX_CANDIDATES = 64; - - /// Build a `leg_bond` (exact `(chain_code, token_code)` bucket + bond - /// requirement) for one eventual lock of `r`. Only depot legs require no - /// collateral. Stored or locally-confirmed UICs have not created a lock yet, - /// so pre-validation must still budget their full winner-time requirement. - leg_bond src_bond(const uw_request& r) const { - return underwriter_detail::candidate_leg_bond( - bucket_key{r.src_chain_code.value, r.src_token_code.value}, - r.src_amount, r.src_is_depot); - } - leg_bond dst_bond(const uw_request& r) const { - return underwriter_detail::candidate_leg_bond( - bucket_key{r.dst_chain_code.value, r.dst_token_code.value}, - r.dst_amount, r.dst_is_depot); - } - - /// Attempt to debit `r`'s per-leg bond requirements from `remaining`, - /// delegating to the pure `underwriter_detail::try_debit_buckets`. Returns - /// true (and mutates `remaining`) iff every required leg fits. Buckets are - /// keyed by the EXACT `(chain_code, token_code)` slug pair (SEC-13/WSA-027), - /// so two same-VM-family chains hold independent credit and never collide. - /// Same-bucket dual-leg swaps (e.g. ERC20 → native on one chain) debit both - /// legs from the single shared row; a both-depot swap is rejected. - bool try_debit_buckets(credit_buckets& remaining, const uw_request& r) const { - return underwriter_detail::try_debit_buckets(remaining, src_bond(r), dst_bond(r)); - } - - /// Branch-and-bound search that returns the subset of `candidates` - /// maximizing `Σ(src_amount + dst_amount)` while each exact - /// `(chain_code, token_code)` credit bucket stays non-negative. Recurses depth-first - /// in two branches per candidate (include / skip); on each include - /// branch verifies feasibility before descending, and prunes the - /// subtree on infeasibility OR when the upper-bound estimate (current - /// value + every remaining candidate's value, regardless of fit) can't - /// beat the current best. - /// - /// Same-chain swaps (e.g. ERC20 → ETH-native) draw from a single - /// bucket when `src` and `dst` keys coincide; the include branch - /// debits both legs from the same row in that case. - void knapsack_dfs(size_t i, - const std::vector& candidates, - const std::vector& suffix_value, - credit_buckets remaining, - std::vector cur_indices, - uint64_t cur_value, - uint64_t& best_value, - std::vector& best_indices) { - // Upper-bound prune: even if every remaining candidate fit, the - // resulting value couldn't beat the current best. - if (cur_value + suffix_value[i] <= best_value) return; - - if (i == candidates.size()) { - if (cur_value > best_value) { - best_value = cur_value; - best_indices = cur_indices; - } - return; - } - - const auto& r = candidates[i]; - credit_buckets after = remaining; - const bool feasible = try_debit_buckets(after, r); - - // Branch 1: include (if feasible). - if (feasible) { - cur_indices.push_back(i); - knapsack_dfs(i + 1, candidates, suffix_value, - std::move(after), cur_indices, - cur_value + r.src_amount + r.dst_amount, - best_value, best_indices); - cur_indices.pop_back(); - } - - // Branch 2: skip. - knapsack_dfs(i + 1, candidates, suffix_value, - std::move(remaining), std::move(cur_indices), - cur_value, best_value, best_indices); - } - - /// Greedy fallback for above-cap candidate counts. Sorts by total leg - /// value descending and picks anything that still fits. - std::vector - greedy_fallback(std::vector requests, - credit_buckets remaining) const { - std::sort(requests.begin(), requests.end(), - [](const uw_request& a, const uw_request& b) { - return (a.src_amount + a.dst_amount) - > (b.src_amount + b.dst_amount); - }); - std::vector picked; - for (auto& r : requests) { - if (!try_debit_buckets(remaining, r)) continue; - picked.push_back(r); - } - return picked; - } - - std::vector select_coverable( - std::vector& requests, credit_buckets initial_credit) { - // Pre-filter requests that can never fit in isolation (no bucket even - // matches), so the search space stays small. Selection budgets every - // eventual outpost lock, including stored or locally-confirmed UIC legs; - // only a depot leg costs zero. - std::vector feasible_in_isolation; - feasible_in_isolation.reserve(requests.size()); - for (auto& r : requests) { - auto scratch = initial_credit; - if (!try_debit_buckets(scratch, r)) continue; - feasible_in_isolation.push_back(r); - } - - std::vector selected; - if (feasible_in_isolation.size() > MAX_CANDIDATES) { - wlog("underwriter: {} feasible candidates exceeds knapsack cap ({}); " - "falling back to value-sort-descending greedy", - feasible_in_isolation.size(), MAX_CANDIDATES); - selected = greedy_fallback(std::move(feasible_in_isolation), initial_credit); - } else if (!feasible_in_isolation.empty()) { - // Suffix sum of per-candidate value — the upper-bound prune in - // knapsack_dfs uses this to skip subtrees whose maximum possible - // remaining value can't beat the current best. - const size_t n = feasible_in_isolation.size(); - std::vector suffix_value(n + 1, 0); - for (size_t k = n; k > 0; --k) { - suffix_value[k - 1] = suffix_value[k] - + feasible_in_isolation[k - 1].src_amount - + feasible_in_isolation[k - 1].dst_amount; - } - - uint64_t best_value = 0; - std::vector best_indices; - knapsack_dfs(/*i=*/0, feasible_in_isolation, suffix_value, - initial_credit, /*cur_indices=*/{}, /*cur_value=*/0, - best_value, best_indices); - - selected.reserve(best_indices.size()); - for (size_t idx : best_indices) { - selected.push_back(feasible_in_isolation[idx]); - } - } - - for (auto& r : selected) { - ilog("underwriter: selected request {} — " - "src(chain={},token={},amt={}) dst(chain={},token={},amt={})", - r.id, - ChainKind_Name(r.src_chain), TokenKind_Name(r.src_token_kind), r.src_amount, - ChainKind_Name(r.dst_chain), TokenKind_Name(r.dst_token_kind), r.dst_amount); - } - return selected; - } - - // ----------------------------------------------------------------------- - // Submit commit to outpost contract - // The outpost requires an ACTIVE caller, validates caller binding and - // canonicality, then relays the original UIC bytes unchanged - // ----------------------------------------------------------------------- - - /// Build a verbatim, signed `UnderwriteIntentCommit` payload for the - /// given leg's `(uwreq_id, leg_chain_kind, chain_code, token_code, - /// reserve_code)`. Returns an empty vector on any failure (no signature - /// provider, serialize failure, etc.). - /// - /// The slug_name triple `(chain_code, token_code, reserve_code)` is the - /// routing scalar set the depot's `rcrdcommit` uses to disambiguate - /// src vs dst legs — same-chain swaps with multiple reserves on a single - /// `(chain, token)` pair are still resolvable because `reserve_code` - /// breaks the tie. `chain_code` carries the chain identity at the OPP - /// envelope level (the originating-outpost field on the inbound - /// envelope); `chain_code` is the same chain as a slug_name and is what - /// the depot indexes against `sysio.uwrit::uw_request_t.*_chain_code`. - /// - /// Digest semantics: the underwriter signs `sha256(serialize(uic with - /// signature blanked))`. The depot's `try_select_winner` rebuilds the - /// same digest from the bytes it received and verifies the embedded - /// signature against the underwriter's WIRE account permissions - /// (`owner` / `active` only) — see `sysio.uwrit::verify_uic_signature`. - std::vector create_signed_uic_bytes( - uint64_t uwreq_id, const outpost_client& outpost, - fc::slug_name chain_code, fc::slug_name token_code, - fc::slug_name reserve_code) { - opp_att::UnderwriteIntentCommit uic; - uic.mutable_uw_account()->set_name(underwriter_account.to_string()); - uic.set_uw_request_id(uwreq_id); - // Data model: leg identity is the slug_name triple. The wire format - // for each field is the packed uint64 slug_name value (alphabet - // `[A-Z0-9_]+`, ≤8 chars). The depot decodes these back to - // `sysio::slug_name` via `sysio::slug_name{uic.chain_code}` etc. in - // `sysio.msgch::dispatch_underwrite_commit`. - uic.set_chain_code(chain_code.value); - uic.set_token_code(token_code.value); - uic.set_reserve_code(reserve_code.value); - // Populate BOTH members from the concrete client that will submit this - // UIC. Host protobuf omits empty byte fields while the generated encoders - // emit them, so including the configured transaction signer gives every - // production encoder the same representation. The WIRE signature covers - // this signer metadata. The outposts bind the claimed account and address - // to their authenticated caller and require canonical encoding, then - // relay these original validated bytes unchanged. - const auto leg_chain_kind = outpost.chain_kind(); - const auto caller_address = outpost.authenticated_caller_address(); - const auto expected_address_size = - underwriter_detail::expected_uic_caller_address_size(leg_chain_kind); - if (!underwriter_detail::set_uic_authenticated_caller( - uic, leg_chain_kind, caller_address)) { - elog("underwriter: invalid authenticated caller address size {} for {} " - "outpost on uwreq {} (expected {})", - caller_address.size(), ChainKind_Name(leg_chain_kind), uwreq_id, - expected_address_size); - return {}; - } - uic.clear_signature(); - - std::string blanked; - if (!uic.SerializeToString(&blanked)) { - elog("underwriter: UIC serialize failed (blank phase) for uwreq {}", uwreq_id); - return {}; - } - - auto digest = fc::sha256::hash(blanked.data(), blanked.size()); - - // Preflight intersects operator configuration with the underwriter's - // direct owner/active keys, exercises the unique provider, and caches - // this exact handle. Runtime must not re-query the broader WIRE registry: - // it also contains legitimate providers for unrelated duties. - const auto& wire_provider = uic_signature_provider; - if (!wire_provider) { - elog("underwriter: no cached WIRE UIC signature provider — preflight should " - "have caught this. Aborting commit for uwreq {}.", uwreq_id); - return {}; - } - if (!underwriter_detail::is_supported_uic_public_key( - wire_provider->public_key)) { - elog("underwriter: WIRE UIC signature provider no longer uses a supported " - "fixed-size recoverable key — preflight should have caught this. " - "Aborting commit for uwreq {}.", - uwreq_id); - return {}; - } - auto fc_sig = wire_provider->sign(digest); - if (!underwriter_detail::is_supported_uic_signature(fc_sig)) { - elog("underwriter: WIRE UIC signature provider returned an unsupported " - "signature type — preflight should have caught this. Aborting commit " - "for uwreq {}.", - uwreq_id); - return {}; - } - if (!underwriter_detail::uic_signature_type_matches_provider_key( - wire_provider->public_key, fc_sig)) { - elog("underwriter: WIRE UIC signature provider returned a signature variant " - "that does not match its configured public key — aborting commit for " - "uwreq {}.", uwreq_id); - return {}; - } - - // Pack the fc::crypto::signature via fc::raw. The configured provider - // emits one canonical fixed-size shape accepted by the depot: K1/R1/EM - // use a 65-byte ECC body and ED uses a 96-byte body, each following its - // one-byte variant tag. The depot constructs that known variant directly. - std::vector sig_bytes = fc::raw::pack(fc_sig); - if (!underwriter_detail::is_canonical_packed_uic_signature(sig_bytes)) { - elog("underwriter: WIRE UIC signature provider produced a non-canonical packed " - "fixed-size signature — aborting commit for uwreq {}.", - uwreq_id); - return {}; - } - if (!underwriter_detail::has_canonical_uic_signature_body(sig_bytes)) { - elog("underwriter: WIRE UIC signature provider produced a noncanonical " - "ECDSA recovery/scalar body — aborting commit for uwreq {}.", uwreq_id); - return {}; - } - uic.set_signature(std::string(sig_bytes.begin(), sig_bytes.end())); - - std::string final_bytes; - if (!uic.SerializeToString(&final_bytes)) { - elog("underwriter: UIC serialize failed (final phase) for uwreq {}", uwreq_id); - return {}; - } - return std::vector(final_bytes.begin(), final_bytes.end()); - } - - /// Before committing collateral, independently verify the source-chain - /// deposit that funded this swap. `req.source_tx_id` is the chain-native - /// deposit id captured at swap-emit time (ETH/SOL: 8-byte monotonic - /// `SwapDeposit.id`). The verify path locates the source-chain event or - /// log for that id, confirms the transaction succeeded against the - /// configured source contract / program, and cross-references every - /// argument we can decode against the uwreq: - /// - /// * `depositor` — `tx.from` (ETH) / fee-payer (SOL) must match `req.depositor`. - /// * source contract — `tx.to` (ETH) / program-id (SOL) must match the configured address. - /// * function selector / instruction discriminator — must match the configured value. - /// * receipt status (ETH) / meta.err (SOL) — must indicate success. - /// * finality — ETH logs are queried only through `finalized`; SOL txs - /// are fetched at commitment `SOL_COMMITMENT`. - /// - /// Hard-fail on empty `source_tx_id` — the depot's `createuwreq` rejects - /// SwapRequests without one (emits SwapRevert), so by the time a uwreq - /// reaches the plugin every row MUST carry one. A `req.source_tx_id` - /// empty here means either the depot's reject regressed OR the plugin - /// read a row pre-validation; either way the safe move is to refuse to - /// commit until the data is whole. - bool verify_source_deposit(const uw_request& req) { - if (req.src_is_depot) { - // Swap-from-WIRE: the source funds were escrowed ON the depot by - // `sysio.uwrit::swapfromwire` before the uwreq existed — there is - // no outpost SwapDeposit to verify, and the synthetic - // source_tx_id is just the depot-origin queue id. This branch - // must precede the empty-source_tx_id hard-fail below. - dlog("underwriter: uwreq {} source is the WIRE depot — skipping " - "source-deposit verification", req.id); - return true; - } - if (req.source_tx_id.empty()) { - elog("underwriter: REFUSING to commit uwreq {} — source_tx_id empty. " - "Every SwapRequest is required to carry a populated source_tx_id; " - "the depot's createuwreq must have regressed.", req.id); - std::lock_guard lk{stats_mutex}; - source_deposit_mismatch_count++; - return false; - } - switch (req.src_chain) { - case ChainKind::CHAIN_KIND_EVM: - return verify_source_deposit_eth(req); - case ChainKind::CHAIN_KIND_SVM: - return verify_source_deposit_sol(req); - default: - elog("underwriter: cannot verify source deposit for chain={} (uwreq {})", - ChainKind_Name(req.src_chain), req.id); - return false; - } - } - - /// ETH-side source-deposit verification, event-scan flavour. - /// - /// `req.source_tx_id` is an 8-byte big-endian uint64 — the outpost- - /// local monotonic counter value written into the SwapRequest - /// envelope by `ReserveManager.requestSwap`. The corresponding - /// `SwapDeposit(uint64 indexed id, bytes32 hash)` event is emitted - /// in the same tx. Verification: - /// - /// 1. Parse `source_tx_id` → `id` (uint64). Reject empty / wrong-size. - /// 2. `eth_getLogs` filtered by: - /// - address = resolved ReserveManager contract address - /// - topic[0] = keccak256("SwapDeposit(uint64,bytes32)") - /// - topic[1] = abi-encoded `id` (32-byte BE-padded uint64) - /// Must return exactly one matching log. - /// 3. Decode the log's `data` field as the emitted hash bytes. - /// 4. Recompute the same hash from UWREQ fields: - /// keccak256(packed - /// depositor[20] - /// src_amount [u64 BE] src_token_code [u64 BE] src_reserve_code [u64 BE] - /// dst_chain_code [u64 BE] dst_token_code [u64 BE] dst_reserve_code [u64 BE] - /// target_amount [u64 BE] - /// variance_tolerance_bps [u32 BE]) - /// The depot's UWREQ row carries every input; matches must be - /// bit-exact against the contract-emitted `hash`. - /// 5. Pull the matching log's `transactionHash`. Receipt must exist - /// and status != "0x0". The log lookup itself is bounded by - /// Ethereum's finalized head; if the finalized head is unavailable - /// the verifier defers without widening to `latest`. - /// - /// A non-matching hash is a hard mismatch — the depositor's swap - /// params disagree with what's recorded on the source chain. A - /// missing log / receipt without mismatched fields is a deferred - /// retry (returns false but no mismatch counter bump). - bool verify_source_deposit_eth(const uw_request& req) { - // SEC-13/WSA-027: resolve this leg's outpost wiring by EXACT chain_code — - // the RPC client and the SwapDeposit contract address are per-chain, so a - // second EVM chain is verified against ITS endpoint, not the first one's. - const auto ep_it = outpost_endpoints.find(req.src_chain_code.value); - if (ep_it == outpost_endpoints.end()) { - elog("underwriter: no outpost endpoint configured for source chain={} " - "(uwreq {})", req.src_chain_code.to_string(), req.id); - return false; - } - const std::string& eth_client_id = ep_it->second.client_id; - const std::string& resolved_eth_source_contract_addr = ep_it->second.source_deposit_addr; - - auto entry = eth_plug->get_client(eth_client_id); - if (!entry || !entry->client) { - elog("underwriter: ETH client '{}' not found for source-deposit verify " - "(uwreq {})", eth_client_id, req.id); - return false; - } - auto bump_mismatch = [&]() { - std::lock_guard lk{stats_mutex}; - source_deposit_mismatch_count++; - }; - - // (1) Decode source_tx_id as an 8-byte big-endian uint64 id. - if (req.source_tx_id.size() != 8) { - elog("underwriter: source-deposit verify failed for uwreq {} — " - "source_tx_id has wrong size ({} bytes; expected 8 for ETH " - "monotonic id)", req.id, req.source_tx_id.size()); - bump_mismatch(); - return false; - } - uint64_t deposit_id = 0; - for (size_t i = 0; i < 8; ++i) { - deposit_id = (deposit_id << 8) | - static_cast(req.source_tx_id[i]); - } - - // (2) eth_getLogs filter for SwapDeposit(uint64 indexed id, bytes32 hash). - // The event topic[0] is keccak256 of the canonical signature. - static const std::string SWAP_DEPOSIT_TOPIC = - "0x" + fc::to_hex(reinterpret_cast( - fc::crypto::keccak256::hash(std::string{ - "SwapDeposit(uint64,bytes32)"}).data()), 32); - // Indexed uint64 → left-pad to 32 bytes (Solidity uses 0-padded - // big-endian; topics are always 32 bytes). - std::string id_topic = "0x"; - id_topic.reserve(66); - id_topic.append(48, '0'); // 24 leading zero bytes = 48 hex chars - for (int shift = 56; shift >= 0; shift -= 8) { - uint8_t b = static_cast((deposit_id >> shift) & 0xff); - char buf[3]; std::snprintf(buf, sizeof buf, "%02x", b); - id_topic.append(buf, 2); - } - - uint64_t finalized_blk = 0; - try { - const auto finalized_var = entry->client->execute_idempotent( - std::string{ETH_GET_BLOCK_BY_NUMBER_METHOD}, - fc::variants{eth::to_block_tag(eth::block_tag_t::finalized), false}); - if (finalized_var.is_null()) { - ilog("underwriter: source-deposit verify deferred for uwreq {} — " - "ETH finalized head unavailable", req.id); - return false; - } - if (!finalized_var.is_object()) { - ilog("underwriter: source-deposit verify deferred for uwreq {} — " - "ETH finalized head response is not an object", req.id); - return false; - } - const auto finalized_obj = finalized_var.get_object(); - if (!finalized_obj.contains("number") || !finalized_obj["number"].is_string()) { - ilog("underwriter: source-deposit verify deferred for uwreq {} — " - "ETH finalized head missing numeric block number", req.id); - return false; - } - const auto parsed_finalized_blk = - sysio::underwriter::eth_parse_block_quantity(finalized_obj["number"].as_string()); - if (!parsed_finalized_blk) { - ilog("underwriter: source-deposit verify deferred for uwreq {} — " - "ETH finalized head block number is malformed ('{}')", - req.id, finalized_obj["number"].as_string()); - return false; - } - finalized_blk = *parsed_finalized_blk; - } catch (const fc::exception& e) { - ilog("underwriter: source-deposit verify deferred for uwreq {} — " - "ETH finalized head unavailable: {}", req.id, e.to_detail_string()); - return false; - } catch (const std::exception& e) { - ilog("underwriter: source-deposit verify deferred for uwreq {} — " - "ETH finalized head response is malformed: {}", req.id, e.what()); - return false; - } - - try { - const uint64_t from_blk = sysio::underwriter::eth_source_deposit_from_block( - finalized_blk, eth_source_deposit_lookback_blocks); - const std::string from_block = sysio::underwriter::eth_block_quantity(from_blk); - const std::string to_block = sysio::underwriter::eth_block_quantity(finalized_blk); - - fc::mutable_variant_object filter; - filter("address", resolved_eth_source_contract_addr); - filter("fromBlock", from_block); - filter("toBlock", to_block); - filter("topics", fc::variants{ - fc::variant{SWAP_DEPOSIT_TOPIC}, - fc::variant{id_topic}, - }); - // eth_getLogs takes its filter as the first element of the JSON-RPC - // `params` array — wrap the filter object in a one-element array. - auto logs_var = entry->client->get_logs( - fc::variant{fc::variants{fc::variant{filter}}}); - if (!logs_var.is_array() || logs_var.size() == 0) { - ilog("underwriter: source-deposit verify deferred for uwreq {} — " - "no SwapDeposit log in recent block window (id={}, " - "contract={}, range={}..{})", - req.id, deposit_id, resolved_eth_source_contract_addr, - from_block, to_block); - return false; - } - // Should be exactly one match — id is unique per outpost. Use - // the first; warn (don't fail) on duplicates so we surface a - // bug without halting the underwriter. - if (logs_var.size() > 1) { - wlog("underwriter: source-deposit verify uwreq {} — got {} " - "SwapDeposit logs for id={}; using the first", - req.id, logs_var.size(), deposit_id); - } - const auto& log = logs_var.get_array().at(0).get_object(); - - // (3) Decode log.data = abi.encodePacked(bytes32 hash) = 32 bytes. - std::string data_hex; - if (log.contains("data") && log["data"].is_string()) { - data_hex = log["data"].as_string(); - } - std::string_view data_view = data_hex; - if (data_view.size() >= 2 && data_view[0] == '0' && - (data_view[1] == 'x' || data_view[1] == 'X')) { - data_view.remove_prefix(2); - } - if (data_view.size() != 64) { - elog("underwriter: source-deposit verify failed for uwreq {} — " - "SwapDeposit log.data has wrong size ({} hex chars; " - "expected 64 for bytes32)", req.id, data_view.size()); - bump_mismatch(); - return false; - } - std::array on_chain_hash{}; - for (size_t i = 0; i < 32; ++i) { - on_chain_hash[i] = static_cast(std::stoul( - std::string{data_view.substr(i * 2, 2)}, nullptr, 16)); - } - - // (4) Recompute the hash from the UWREQ row's immutable source-outpost - // fields. Layout MUST match ReserveManagerLib.hashSwapDeposit's - // abi.encodePacked(...) call exactly. In particular, the outpost - // hashes the user's original `target_amount`, not the depot's - // re-priced `dst_amount` settlement quote. - if (req.depositor.size() != underwriter::EVM_DEPOSITOR_SIZE) { - elog("underwriter: source-deposit verify failed for uwreq {} — " - "depositor has wrong size ({} bytes; expected {} for EVM " - "address)", req.id, req.depositor.size(), underwriter::EVM_DEPOSITOR_SIZE); - bump_mismatch(); - return false; - } - const auto recomputed = underwriter::source_deposit_hash({ - .depositor = std::span{req.depositor}, - .source_amount = req.src_amount, - .source_token_code = req.src_token_code.value, - .source_reserve_code = req.src_reserve_code.value, - .target_chain_code = req.dst_chain_code.value, - .target_token_code = req.dst_token_code.value, - .target_reserve_code = req.dst_reserve_code.value, - .target_amount = req.target_amount, - .target_tolerance_bps = req.variance_tolerance_bps, - }); - if (std::memcmp(recomputed.data(), on_chain_hash.data(), 32) != 0) { - const std::string got_hex = fc::to_hex( - reinterpret_cast(on_chain_hash.data()), 32); - const std::string want_hex = fc::to_hex( - reinterpret_cast(recomputed.data()), 32); - elog("underwriter: source-deposit verify failed for uwreq {} — " - "SwapDeposit hash mismatch (id={}): on-chain={} recomputed={}", - req.id, deposit_id, got_hex, want_hex); - bump_mismatch(); - return false; - } - - // (5) Receipt status on the matching finalized tx. - std::string tx_hash_hex; - if (log.contains("transactionHash") && - log["transactionHash"].is_string()) { - tx_hash_hex = log["transactionHash"].as_string(); - } - if (tx_hash_hex.empty()) { - elog("underwriter: source-deposit verify failed for uwreq {} — " - "SwapDeposit log missing transactionHash", req.id); - bump_mismatch(); - return false; - } - auto receipt = entry->client->get_transaction_receipt(tx_hash_hex); - if (receipt.is_null()) { - ilog("underwriter: source-deposit verify deferred for uwreq {} — " - "no receipt yet for matching tx {}", req.id, tx_hash_hex); - return false; - } - const auto rcpt_obj = receipt.get_object(); - if (rcpt_obj.contains("status") && - rcpt_obj["status"].as_string() == "0x0") { - elog("underwriter: source-deposit verify failed for uwreq {} — " - "matching tx {} reverted on-chain", req.id, tx_hash_hex); - bump_mismatch(); - return false; - } - ilog("underwriter: source-deposit verify passed for uwreq {} " - "(SwapDeposit id={} tx={} finalized_head={})", - req.id, deposit_id, tx_hash_hex, finalized_blk); - return true; - } catch (const fc::exception& e) { - elog("underwriter: source-deposit verify failed for uwreq {} — " - "RPC error: {}", req.id, e.to_detail_string()); - bump_mismatch(); - return false; - } catch (const std::exception& e) { - // std::stoul/std::stoull on a malformed or non-hex RPC field throw std::invalid_argument / - // std::out_of_range, which are NOT fc::exception; catch them here so one bad response fails - // just this uwreq instead of escaping and aborting the whole scan cycle for every request. - elog("underwriter: source-deposit verify failed for uwreq {} — " - "malformed RPC response: {}", req.id, e.what()); - bump_mismatch(); - return false; - } - } - - /// SOL-side source-deposit verification. Mirrors - /// `verify_source_deposit_eth` step-for-step: - /// - /// 1. `req.source_tx_id` is an 8-byte big-endian monotonic - /// `deposit_id` (minted by `opp-outpost::request_swap`, - /// `OutboundMessageBuffer.next_swap_id`). Wrong-size → - /// hard mismatch. - /// 2. `fc::task::retry_until` with - /// `SOL_SWAP_DEPOSIT_POLL_INTERVAL` (15s) / - /// `SOL_SWAP_DEPOSIT_TOTAL_TIMEOUT` (120s, both in - /// `outpost_solana_client_plugin.hpp`) drives the paginated scan: - /// a. `getSignaturesForAddress(sol_program_id, - /// before=, limit=SOL_SIGNATURE_SCAN_PAGE_SIZE)` - /// enumerates the next older page of program sigs. - /// b. For each sig, `getTransaction(sig)`; inspect - /// `meta.err` (skip failed tx) and - /// `meta.logMessages[]` for the canonical marker: - /// `Program log: opp_outpost: SwapDeposit id= hash=<64hex>`. - /// c. Persist the next `before` cursor only after a clean page with - /// no match; unfinalized matches and transient tx fetch failures - /// retry the same page. - /// 3. On match, parse the 32-byte on-chain hash from the log. - /// 4. Recompute the same hash from the UWREQ row's flat fields - /// (depositor[32] + 7×u64 BE + u32 BE = 92 packed bytes). - /// Bit-exact mismatch → hard mismatch counter. - /// 5. Finality gate: matching tx's `meta.confirmationStatus` - /// (when present on the per-sig listing from step 2a) must - /// be `"finalized"` — same finality requirement as ETH's finalized - /// log lookup. Tx not yet finalized → deferred retry (no mismatch bump). - /// - /// `false` returns: - /// - hard mismatch on bad size; - /// - terminal source-deposit failure (counter bumped once per pending - /// UWREQ) on hash divergence, malformed marker hash, or full-history - /// exhaustion without a marker; - /// - deferred retry (no counter bump) on `retry_until` deadline - /// reached without a matching finalized log — the outer poll loop - /// reattempts on its next tick from the persisted cursor. - bool verify_source_deposit_sol(const uw_request& req) { - // SEC-13/WSA-027: resolve this leg's outpost wiring by EXACT chain_code. - const auto ep_it = outpost_endpoints.find(req.src_chain_code.value); - if (ep_it == outpost_endpoints.end()) { - elog("underwriter: no outpost endpoint configured for source chain={} " - "(uwreq {})", req.src_chain_code.to_string(), req.id); - return false; - } - const std::string& sol_client_id = ep_it->second.client_id; - const std::string& sol_program_id = ep_it->second.commit_addr; - - auto entry = sol_plug->get_client(sol_client_id); - if (!entry || !entry->client) { - elog("underwriter: SOL client '{}' not found for source-deposit verify " - "(uwreq {})", sol_client_id, req.id); - return false; - } - auto bump_mismatch = [&]() { - std::lock_guard lk{stats_mutex}; - source_deposit_mismatch_count++; - }; - - // ── (1) source_tx_id → 8-byte big-endian deposit_id ──────────────── - if (req.source_tx_id.size() != 8) { - elog("underwriter: source-deposit verify failed for uwreq {} — " - "source_tx_id has wrong size ({} bytes; expected 8 for SOL " - "monotonic deposit_id mirroring the ETH wire shape)", - req.id, req.source_tx_id.size()); - bump_mismatch(); - return false; - } - uint64_t deposit_id = 0; - for (size_t i = 0; i < 8; ++i) { - deposit_id = (deposit_id << 8) | - static_cast(req.source_tx_id[i]); - } - - // ── (4) Recompute the expected correlation hash from UWREQ fields ── - // Layout MUST stay synchronized with the producer side - // (`opp-outpost/src/instructions/request_swap.rs::correlation_hash`). - // Like the EVM outpost, it hashes the user's original `target_amount`, - // not the depot's re-priced `dst_amount` settlement quote. - if (req.depositor.size() != underwriter::SVM_DEPOSITOR_SIZE) { - elog("underwriter: source-deposit verify failed for uwreq {} — " - "depositor has wrong size ({} bytes; expected {} for SVM " - "Ed25519 pubkey)", req.id, req.depositor.size(), underwriter::SVM_DEPOSITOR_SIZE); - bump_mismatch(); - return false; - } - const auto recomputed_hash = underwriter::source_deposit_hash({ - .depositor = std::span{req.depositor}, - .source_amount = req.src_amount, - .source_token_code = req.src_token_code.value, - .source_reserve_code = req.src_reserve_code.value, - .target_chain_code = req.dst_chain_code.value, - .target_token_code = req.dst_token_code.value, - .target_reserve_code = req.dst_reserve_code.value, - .target_amount = req.target_amount, - .target_tolerance_bps = req.variance_tolerance_bps, - }); - - // Canonical marker the producer emits on `request_swap`. The - // Solana JSON-RPC `meta.logMessages[]` array contains each - // `msg!` line verbatim, prefixed with `"Program log: "`. - const std::string marker_prefix = - "Program log: opp_outpost: SwapDeposit id=" + - std::to_string(deposit_id) + " hash="; - - // ── (2) + (3) retry-loop until matched or budget expires ─────────── - // `bool hard_mismatch` distinguishes "found and known-bad" (hash - // divergence / malformed marker / exhausted history) from "not yet - // found" (deferred). Terminal SOL failures are cached until the UWREQ - // leaves PENDING so later scan cycles do not re-walk full history. - // The retry-callback returns: - // - Some(true) -> success, exit retry_until - // - Some(false) hard_mismatch -> fail-fast, exit retry_until - // - None -> deferred, sleep + retry - // Outer `try { retry_until } catch (timeout_exception)` maps the - // deadline-without-match case to deferred-no-mismatch-bump, while the - // cursor survives for the next outer scan tick. - const underwriter::solana_source_deposit_scan_key scan_key{req.id, deposit_id}; - if (const auto terminal = get_sol_source_deposit_terminal_failure(scan_key)) { - ilog("underwriter: source-deposit verify skipped for uwreq {} — " - "previous terminal SOL verification failure: {} " - "(deposit_id={}, pages_scanned={}, signatures_scanned={})", - req.id, - terminal->terminal_failure_reason, - deposit_id, - terminal->pages_scanned, - terminal->signatures_scanned); - return false; - } - - bool hard_mismatch_seen = false; - std::string matched_sig; - - try { - fc::task::retry_options opts; - opts.initial_backoff = SOL_SWAP_DEPOSIT_POLL_INTERVAL; - opts.max_backoff = SOL_SWAP_DEPOSIT_POLL_INTERVAL; - opts.total_timeout = SOL_SWAP_DEPOSIT_TOTAL_TIMEOUT; - opts.growth_factor = 1.0; // fixed-interval cadence - - std::function()> attempt = - [&]() -> std::optional { - const auto cursor = get_or_create_sol_source_deposit_scan_cursor(scan_key); - std::vector sigs; - try { - sigs = entry->client->get_signatures_for_address( - sol_program_id, - cursor.before, - std::nullopt, - underwriter::SOL_SIGNATURE_SCAN_PAGE_SIZE); - } catch (const fc::exception& e) { - ilog("underwriter: source-deposit verify deferred for uwreq {} — " - "getSignaturesForAddress({}, before={}) RPC error: {}", - req.id, - sol_program_id, - cursor.before.value_or(""), - e.to_detail_string()); - return std::nullopt; - } - - const underwriter::solana_source_deposit_page_scan_config scan_config{ - .sol_program_id = sol_program_id, - .marker_prefix = marker_prefix, - .recomputed_hash = recomputed_hash, - }; - const auto scan_result = underwriter::scan_solana_source_deposit_signature_page( - sigs, - [&](const std::string& sig_b58) { - return entry->client->get_transaction(sig_b58, underwriter::SOL_COMMITMENT); - }, - scan_config); - - using page_status = underwriter::solana_source_deposit_page_status; - switch (scan_result.status) { - case page_status::matched: - matched_sig = scan_result.matched_signature; - erase_sol_source_deposit_scan_cursor(scan_key); - return std::optional(true); - case page_status::hard_mismatch: - elog("underwriter: source-deposit verify failed for uwreq {} — " - "{} (deposit_id={}, program={}, before={})", - req.id, - scan_result.reason, - deposit_id, - sol_program_id, - cursor.before.value_or("")); - hard_mismatch_seen = true; - { - const auto terminal = record_sol_source_deposit_terminal_failure( - scan_key, scan_result.reason, sigs.size()); - if (terminal.first_failure) bump_mismatch(); - } - return std::optional(false); - case page_status::deferred: - ilog("underwriter: source-deposit verify deferred for uwreq {} — " - "{} (deposit_id={}, program={}, before={})", - req.id, - scan_result.reason, - deposit_id, - sol_program_id, - cursor.before.value_or("")); - return std::nullopt; - case page_status::not_found: - break; - } - - if (scan_result.page_exhausted) { - const std::string reason = - "exhausted Solana program signature history without SwapDeposit log"; - elog("underwriter: source-deposit verify failed for uwreq {} — " - "{} (deposit_id={}, program={}, before={}, page_size={})", - req.id, - reason, - deposit_id, - sol_program_id, - cursor.before.value_or(""), - sigs.size()); - hard_mismatch_seen = true; - { - const auto terminal = record_sol_source_deposit_terminal_failure( - scan_key, reason, sigs.size()); - if (terminal.first_failure) bump_mismatch(); - } - return std::optional(false); - } - - if (!scan_result.next_before) { - ilog("underwriter: source-deposit verify deferred for uwreq {} — " - "signature page produced no usable pagination cursor " - "(deposit_id={}, program={}, before={}, page_size={})", - req.id, - deposit_id, - sol_program_id, - cursor.before.value_or(""), - sigs.size()); - return std::nullopt; - } - - const auto advanced = advance_sol_source_deposit_scan_cursor( - scan_key, *scan_result.next_before, sigs.size()); - ilog("underwriter: source-deposit verify paginated for uwreq {} — " - "no SwapDeposit log in page (deposit_id={}, program={}, " - "before={}, next_before={}, pages_scanned={}, signatures_scanned={})", - req.id, - deposit_id, - sol_program_id, - cursor.before.value_or(""), - *scan_result.next_before, - advanced.pages_scanned, - advanced.signatures_scanned); - return std::nullopt; // not found this iteration; sleep + retry - }; - - const bool ok = fc::task::retry_until( - "underwriter:verify_source_deposit_sol", opts, attempt); - if (ok) { - ilog("underwriter: source-deposit verify passed for uwreq {} " - "(deposit_id={} tx={} program={})", - req.id, deposit_id, matched_sig, sol_program_id); - return true; - } - // retry_until returned `false` → hard mismatch already counted + - // logged inside the callback. Propagate the failure unchanged. - return false; - } catch (const fc::timeout_exception&) { - // Deadline reached without finding a matching log. Treat as a - // deferred retry by the outer underwriter loop — DO NOT bump - // the mismatch counter, matching the ETH verify's semantics - // for "log not yet present." - if (hard_mismatch_seen) { - // Defensive: shouldn't get here since hard mismatch returns - // Some(false) above, but if it ever does, keep counting. - return false; - } - ilog("underwriter: source-deposit verify deferred for uwreq {} — " - "no SwapDeposit log line for deposit_id={} after {}s " - "(program={}, scan-interval={}s, page-size={}, will retry from persisted cursor)", - req.id, deposit_id, - SOL_SWAP_DEPOSIT_TOTAL_TIMEOUT.count() / 1'000'000, - sol_program_id, - SOL_SWAP_DEPOSIT_POLL_INTERVAL.count() / 1'000'000, - underwriter::SOL_SIGNATURE_SCAN_PAGE_SIZE); - return false; - } catch (const fc::exception& e) { - elog("underwriter: source-deposit verify failed for uwreq {} — " - "RPC error: {}", req.id, e.to_detail_string()); - bump_mismatch(); - return false; - } - } - - /** - * Submit a `commit` JSON-RPC call to each still-missing outpost leg. Depot - * rows suppress already-stored legs after restart, while complete stored - * candidates are skipped before this function is reached. - * Each outpost requires an ACTIVE-role caller, canonicalizes the - * UNDERWRITE_INTENT_COMMIT, binds its identity fields to that caller and - * current roster row, then queues the original validated bytes. The depot's - * resolver (sysio.uwrit::try_select_winner) reconstructs the digest, verifies - * the signature against the underwriter's account permissions, and - * promotes the underwriter to winner only after every required UIC leg and - * current WIRE permission verifies, the complete live obligation is bonded - * (via `available()` rollup), destination remit identity is available, and - * the request-global live-price and reserve gates pass. - * - * Outposts do not validate the WIRE permission signature or bond; those - * remain the depot's authoritative responsibilities. - */ - void submit_intent_to_outpost(const uw_request& req) { - // T13: confirm the source-chain deposit backing this swap is real - // before committing collateral. Returns true with a warning log - // until the swap-emit-site populates source_tx_id (staged rollout). - if (!verify_source_deposit(req)) { - ilog("underwriter: skipping uwreq {} — source-deposit verify failed", - req.id); - return; - } - - ilog("underwriter: submitting missing commit legs for uwreq {} " - "src=({},{},{}) dst=({},{},{})", - req.id, - req.src_chain_code.to_string(), - req.src_token_code.to_string(), - req.src_reserve_code.to_string(), - req.dst_chain_code.to_string(), - req.dst_token_code.to_string(), - req.dst_reserve_code.to_string()); - - // Per-leg dispatch keyed on the slug_name triple - // `(chain_code, token_code, reserve_code)`. Same-chain swaps (e.g. - // ERC20 → native on one outpost) share `chain_code` between the two - // legs but differ on `token_code`/`reserve_code`; the UIC payload - // carries the full triple so the depot's `rcrdcommit` can route to - // the correct source/dest slot on `commit_entry`. - // - // Confirmation discipline: we skip any leg whose exact - // `(uwreq_id, chain_code, token_code, reserve_code)` identity is already - // in `confirmed_commits` (a previous cycle's tx confirmed on-chain). - // After a successful confirm we record the key so the next scan doesn't - // resubmit. Per project rules: confirm BEFORE recording so a - // partial-landing in the map cannot happen without OPP breakage. - auto submit_one = [this](ChainKind chain, - fc::slug_name chain_code, - fc::slug_name token_code, - fc::slug_name reserve_code, - uint64_t uw_request_id, - bool is_depot, - bool stored_by_depot) { - if (is_depot) { - // Intended, not an error: depot (WIRE) legs are never - // underwritten — no outpost exists, no UIC is built, no bond - // is consumed. Single-leg swaps commit only their real leg. - dlog("underwriter: uwreq {} leg chain={} is the WIRE depot — " - "no commit needed", uw_request_id, chain_code.to_string()); - return; - } - if (stored_by_depot) { - ilog("underwriter: skip depot-stored commit uwreq={} chain={} token={}", - uw_request_id, chain_code.to_string(), token_code.to_string()); - return; - } - const commit_key key{uw_request_id, chain_code.value, token_code.value, - reserve_code.value}; - if (confirmed_commits.contains(key)) { - ilog("underwriter: skip already-confirmed commit uwreq={} chain={} token={}", - uw_request_id, - chain_code.to_string(), - token_code.to_string()); - return; - } - - // Chain-agnostic SPI call. The `outpost_by_chain` map carries one - // `outpost_client` per EXACT `chain_code` (SEC-13/WSA-027), built at - // startup via the outpost-plugin factories. Each concrete owns its own - // ABI / IDL discovery, address encoding, and on-chain confirmation - // discipline — this loop passes the serialized UIC bytes unchanged - // through the virtual. Per `outpost-client-spi.md`. - auto it = outpost_by_chain.find(chain_code.value); - if (it == outpost_by_chain.end()) { - elog("underwriter: no outpost_client wired for chain={} (uwreq {})", - chain_code.to_string(), uw_request_id); - std::lock_guard lk{stats_mutex}; - commits_failed_count++; - return; - } - if (it->second->chain_kind() != chain) { - elog("underwriter: outpost_client kind {} does not match leg kind {} " - "for chain={} (uwreq {})", - ChainKind_Name(it->second->chain_kind()), ChainKind_Name(chain), - chain_code.to_string(), uw_request_id); - std::lock_guard lk{stats_mutex}; - commits_failed_count++; - return; - } - auto uic_bytes = create_signed_uic_bytes( - uw_request_id, *it->second, chain_code, token_code, reserve_code); - if (uic_bytes.empty()) return; // already logged - bool confirmed = false; - try { - auto tx_or_sig = it->second->uw_commit( - uw_request_id, uic_bytes, fc::milliseconds(action_timeout_ms)); - ilog("underwriter: commit landed on {} uwreq={} tx_or_sig={}", - it->second->to_string(), uw_request_id, tx_or_sig); - confirmed = true; - } catch (const fc::exception& e) { - elog("underwriter: commit failed on {} for uwreq={}: {}", - it->second->to_string(), uw_request_id, e.to_detail_string()); - } - std::lock_guard lk{stats_mutex}; - if (confirmed) { - confirmed_commits.insert(key); - commits_confirmed_count++; - } else { - commits_failed_count++; - } - }; - submit_one(req.src_chain, - req.src_chain_code, req.src_token_code, req.src_reserve_code, - req.id, req.src_is_depot, - !req.own_stored_commit_plan.submit_source); - submit_one(req.dst_chain, - req.dst_chain_code, req.dst_token_code, req.dst_reserve_code, - req.id, req.dst_is_depot, - !req.own_stored_commit_plan.submit_destination); - } - - // Outpost commit submission is delegated entirely to the `outpost_client` - // SPI — see `outpost_by_chain` above and `submit_intent_to_outpost` for - // the dispatch. Chain-specific ABI / IDL discovery, address encoding, - // and on-chain confirmation live inside - // `outpost_ethereum_client::uw_commit` and - // `outpost_solana_client::uw_commit`. Per `outpost-client-spi.md`. - - // Paid outpost commit submissions use the outpost_client SPI. Terminal - // candidates are suppressed by the authoritative depot-row plan; complete - // candidates await an external exact-UIC replay rather than resubmitting - // paid outpost work automatically. - - // ── HTTP API: /v1/underwriter/* ───────────────────────────────────── - // Read-only diagnostic surface for the operator. Wraps internal state - // (`confirmed_commits`, counters, account/config) under `stats_mutex` - // so http_plugin worker threads and the single cron thread don't race. - // - // Endpoints: - // /v1/underwriter/stats — session counters + config snapshot - // /v1/underwriter/commits — outstanding confirmed commits (per leg) - // - // Both carry a `status` discriminator: until the deferred startup body - // completes they answer with the gate state (`waiting_for_sync` + - // `head_behind_sec`/`lib_behind_sec`, `preflight_retrying`, or a terminal - // `preflight_failed`/`wiring_failed`/`startup_failed` with `detail`); - // once active they serve the payloads below with `status:"active"`. - - fc::variant build_stats_response() { - std::lock_guard lk{stats_mutex}; - size_t active_sol_source_deposit_cursor_count = 0; - size_t sol_source_deposit_terminal_failure_count = 0; - for (const auto& entry : sol_source_deposit_scan_cursors) { - if (entry.second.terminal_failure) { - sol_source_deposit_terminal_failure_count++; - } else { - active_sol_source_deposit_cursor_count++; - } - } - // SEC-13/WSA-027: surface the per-chain outpost wiring (was the single - // eth/sol client-id + address) so operators can confirm each served - // chain is configured. - std::vector ep_v; - ep_v.reserve(outpost_endpoints.size()); - for (const auto& [code, ep] : outpost_endpoints) { - ep_v.push_back(fc::mutable_variant_object() - ("chain_code", fc::slug_name{code}.to_string()) - ("kind", ChainKind_Name(ep.kind)) - ("client_id", ep.client_id) - ("commit_addr", ep.commit_addr) - ("source_deposit_addr", ep.source_deposit_addr)); - } - return fc::variant(fc::mutable_variant_object() - (underwriter_detail::field::status, std::string{underwriter_detail::active_status}) - ("underwriter_account", underwriter_account.to_string()) - ("enabled", enabled) - ("is_active", is_active) - ("scan_interval_ms", scan_interval_ms) - ("action_timeout_ms", action_timeout_ms) - ("outpost_endpoints", fc::variant(std::move(ep_v))) - ("eth_source_deposit_function", eth_source_deposit_function_name) - ("sol_source_deposit_instruction", sol_source_deposit_instruction_name) - ("uwreqs_seen_pending", uwreqs_seen_pending_count) - ("commits_confirmed", commits_confirmed_count) - ("commits_failed", commits_failed_count) - ("source_deposit_mismatch", source_deposit_mismatch_count) - ("outstanding_commit_count", confirmed_commits.size()) - ("sol_source_deposit_cursor_count", active_sol_source_deposit_cursor_count) - ("sol_source_deposit_terminal_failure_count", sol_source_deposit_terminal_failure_count) - ); - } - - fc::variant build_commits_response() { - std::lock_guard lk{stats_mutex}; - std::vector entries; - entries.reserve(confirmed_commits.size()); - for (const auto& k : confirmed_commits) { - entries.push_back(fc::variant(fc::mutable_variant_object() - ("uwreq_id", k.uwreq_id) - ("chain_code", fc::slug_name{k.chain_code}.to_string()) - ("token_code", fc::slug_name{k.token_code}.to_string()) - ("reserve_code", fc::slug_name{k.reserve_code}.to_string()) - )); - } - return fc::variant(fc::mutable_variant_object() - (underwriter_detail::field::status, std::string{underwriter_detail::active_status}) - ("count", entries.size()) - ("entries", std::move(entries)) - ); - } - - /// Answer `cb` with the gate-state payload and return true when the - /// deferred startup body has not completed (waiting for sync, retrying, - /// or failed terminally); return false once {@link run_deferred_startup} - /// reached `active` so the caller serves the real payload. - bool respond_if_gated(const url_response_callback& cb) { - const auto state = gate_state.load(); - if (state == underwriter_detail::startup_state::active) { - return false; - } - // Only the waiting_for_sync payload carries the behind-now gaps; skip - // the chain reads for the other states. `lib_behind_sec` is the gate's - // actual criterion (reads serve the irreversible state); the head gap - // distinguishes a finality-stalled node from one still catching up. - // An absent irreversible root stays a typed empty optional here — the - // payload builder turns it into the wire's -1 sentinel. - fc::microseconds head_behind{}; - std::optional lib_behind; - if (state == underwriter_detail::startup_state::waiting_for_sync) { - auto& chain = chain_plug->chain(); - const auto now = fc::time_point::now(); - head_behind = now - chain.head().block_time(); - if (chain.fork_db_has_root()) { - lib_behind = now - chain.fork_db_root().block_time(); - } - } - cb(200, underwriter_detail::startup_gate_payload(state, head_behind, lib_behind)); - return true; - } - - /// Register the `/v1/underwriter/*` HTTP endpoints. Called once from - /// `plugin_startup` when the underwriter is enabled, BEFORE the sync gate: - /// `http_plugin`'s handler map is read lock-free by the HTTP worker - /// threads, so every registration must happen during plugin startup — - /// before the posted listener creation runs — never from a task queued - /// after `exec()` is live. Until the deferred startup body completes the - /// handlers report the gate state (see {@link respond_if_gated}). - /// - /// Both endpoints live in the dedicated `api_category::underwriter`, NOT - /// `api_category::node`: they expose operator metadata (account identity, - /// client ids, outpost contract addresses, outstanding commits) that must - /// not ride the always-on node category onto category-isolated public - /// listeners. They remain reachable on the default all-category listeners - /// (`http-server-address` / `unix-socket-path`); on category-isolated - /// setups the operator opts in with `--http-category-address=underwriter,`. - void register_http_endpoints() { - auto& hp = app().get_plugin(); - hp.add_api({ - {"/v1/underwriter/stats", api_category::underwriter, - [this](std::string&& /*url*/, - std::string&& /*body*/, - url_response_callback&& cb) { - try { - if (respond_if_gated(cb)) { - return; - } - cb(200, build_stats_response()); - } catch (const fc::exception& e) { - cb(500, fc::variant(fc::mutable_variant_object() - ("error", e.to_detail_string()))); - } - }}, - {"/v1/underwriter/commits", api_category::underwriter, - [this](std::string&& /*url*/, - std::string&& /*body*/, - url_response_callback&& cb) { - try { - if (respond_if_gated(cb)) { - return; - } - cb(200, build_commits_response()); - } catch (const fc::exception& e) { - cb(500, fc::variant(fc::mutable_variant_object() - ("error", e.to_detail_string()))); - } - }}, - }, appbase::exec_queue::read_only); - - // Operator metadata should normally stay on loopback / private - // management networks; a public bind is a deliberate choice worth a - // startup warning (same pattern as the snapshot_ro exposure notice). - if (!hp.is_on_loopback(api_category::underwriter)) { - wlog("underwriter diagnostics API (/v1/underwriter/*) exposed on a non-loopback address"); - } - } -}; - -// --------------------------------------------------------------------------- -// Plugin lifecycle -// --------------------------------------------------------------------------- -underwriter_plugin::underwriter_plugin() - : _impl(std::make_unique()) {} - -underwriter_plugin::~underwriter_plugin() = default; - -void underwriter_plugin::set_program_options(options_description& cli, - options_description& cfg) { - auto opts = cfg.add_options(); - // Presence of this option IS the enable switch (mirrors producer_plugin's - // producer-name): the scan cycle runs when an account is configured. - opts("underwriter-account", bpo::value(), - "WIRE account name for this underwriter. Configuring an account enables the scan cycle."); - opts("underwriter-scan-interval-ms", bpo::value()->default_value(underwriter_defaults::scan_interval_ms), - "How often to scan for pending underwrite requests (ms)"); - opts("underwriter-action-timeout-ms", bpo::value()->default_value(underwriter_defaults::action_timeout_ms), - "Timeout for outpost contract calls and table reads (ms)"); - opts("underwriter-eth-source-deposit-function", bpo::value(), - "Name of the ETH swap-deposit function. Resolved at preflight against the ABI " - "files registered with --ethereum-abi-file; the matching `function` entry's keccak256 " - "signature yields the chain-agnostic 4-byte selector. The per-chain source contract " - "address comes from the chain's sysio.chains row. Required."); - opts("underwriter-sol-source-deposit-instruction", bpo::value(), - "Name of the SOL swap-deposit instruction. Resolved at preflight against the IDL " - "files registered with --solana-idl-file; the matching instruction's anchor " - "discriminator (8 bytes) is used to identify the deposit instruction in source " - "txs. Required."); - opts(sysio::underwriter::ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS_OPTION.data(), - bpo::value()->default_value( - sysio::underwriter::ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS), - "Maximum number of recent finalized ETH blocks searched for the source " - "SwapDeposit event. Bounds the per-uwreq eth_getLogs filter so stale " - "or invalid deposit ids cannot trigger whole-history provider scans."); -} - -void underwriter_plugin::plugin_initialize(const variables_map& options) { - if (options.count("underwriter-account")) - _impl->underwriter_account = chain::name(options["underwriter-account"].as()); - _impl->scan_interval_ms = options["underwriter-scan-interval-ms"].as(); - _impl->action_timeout_ms = options["underwriter-action-timeout-ms"].as(); - _impl->enabled = _impl->underwriter_account.good(); - if (options.count("underwriter-eth-source-deposit-function")) - _impl->eth_source_deposit_function_name = - options["underwriter-eth-source-deposit-function"].as(); - if (options.count("underwriter-sol-source-deposit-instruction")) - _impl->sol_source_deposit_instruction_name = - options["underwriter-sol-source-deposit-instruction"].as(); - _impl->eth_source_deposit_lookback_blocks = - options[sysio::underwriter::ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS_OPTION.data()].as(); - FC_ASSERT(_impl->eth_source_deposit_lookback_blocks > 0, - "{} must be positive", - sysio::underwriter::ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS_OPTION); - - _impl->chain_plug = &app().get_plugin(); - _impl->cron_plug = &app().get_plugin(); - _impl->eth_plug = &app().get_plugin(); - _impl->sol_plug = &app().get_plugin(); - - // Operator daemons are designed for read-mode = irreversible: the sync gate - // (controller::is_synced) measures LIB recency and every local table read the - // preflight and scan cycle perform serves the irreversible view. Any other read - // mode would validate/underwrite against state that can still fork out. Together - // with producer_plugin's inverse assert (no producer-name under irreversible - // read-mode), this also makes co-hosting a producer with an operator daemon - // impossible by configuration. - FC_ASSERT(!_impl->enabled || - _impl->chain_plug->chain().get_read_mode() == chain::db_read_mode::IRREVERSIBLE, - "underwriter_plugin requires read-mode = irreversible"); -} - -void underwriter_plugin::plugin_startup() { - if (!_impl->enabled) { - ilog("underwriter_plugin: no underwriter-account configured, skipping startup"); - return; - } - - ilog("underwriter_plugin: starting for account {}", _impl->underwriter_account.to_string()); - - // Register the `/v1/underwriter/*` endpoints FIRST, before the sync - // gate: handler registration must complete during plugin startup - // (before the posted HTTP listener goes live) because the handler map - // is read lock-free by the HTTP threads — it must never ride the - // deferred startup body below. Until that body completes, the handlers - // answer with the gate state, so a cold-booting (or terminally-failed) - // underwriter is diagnosable over HTTP instead of a single ilog. - _impl->register_http_endpoints(); - - // The preflight validates depot-side state (opreg registration, chain - // registry, authex links) via LOCAL table reads. On a cold-booting - // operator node those reads see mid-sync (possibly genesis) state and - // would fail spuriously, so the whole startup body (preflight → outpost - // client wiring → cron) is DEFERRED until the node is synced — - // `controller::is_synced()`: the LAST IRREVERSIBLE block's time within - // `controller::default_sync_recency_ms` of now (the state the reads - // actually serve under read-mode = irreversible). The wake-up is the - // existing `irreversible_block` channel: a LIB advance is the only event - // that can turn the predicate true, and channel deliveries are posted to - // the application executor — main thread, AFTER the triggering block - // fully commits — so the callback may run the startup body directly. - // There is deliberately no already-synced fast path: operator daemons - // boot with genesis-stale LIB in every deployment topology (producer - // co-hosting is impossible by configuration — see the read-mode - // requirement in plugin_initialize), and a node that somehow is synced at - // startup is released by the next LIB advance. That advance is also the - // underwriter's WORK SUPPLY — everything here acts on newly-finalized - // state — so a finality stall that spans startup (the classic lost-wakeup - // case) is one with nothing to underwrite: the first finalized block - // re-arms the gate and creates the first actionable state at the same - // instant. Post-arming, the preflight - // retries within a bounded grace (see attempt_deferred_startup) to absorb - // the LIB boundary race; a genuinely incomplete bootstrap still fails - // loudly once the grace expires and shuts the node down (fail-fast) — - // there remains no dev escape hatch. - auto& chain = _impl->chain_plug->chain(); - ilog("underwriter_plugin: waiting for chain sync before preflight " - "(head {} is {}s behind now; irreversible state is {}s behind)", - chain.head().block_num(), - (fc::time_point::now() - chain.head().block_time()).to_seconds(), - chain.fork_db_has_root() - ? std::to_string((fc::time_point::now() - chain.fork_db_root().block_time()).to_seconds()) - : "n/a"); - _impl->sync_gate_subscription = - app().get_channel().subscribe( - [impl = _impl.get()](const chain::plugin_interface::channels::block_params&) { - if (impl->startup_attempted() || impl->shutting_down || - !impl->chain_plug->chain().is_synced()) { - return; - } - // One-shot consumption: unsubscribe (safe from within the slot) and - // run the startup body directly — channel deliveries are posted to - // the application executor, so this already runs on the main thread - // AFTER the triggering block committed (the exact context the old - // accepted_block gate had to re-post itself into; running mid - // block-application, table reads see an incomplete view — observed - // live: a registered operator row read back EMPTY → spurious - // preflight failure). - impl->sync_gate_subscription.unsubscribe(); - ilog("underwriter_plugin: chain synced — starting deferred startup"); - impl->run_deferred_startup(); - }); -} - -void underwriter_plugin::plugin_shutdown() { - _impl->shutting_down = true; - if (_impl->preflight_retry_timer) { - _impl->preflight_retry_timer->cancel(); - } - - if (_impl->scan_job_id != 0) { - auto& cron = app().get_plugin(); - cron.cancel_job(_impl->scan_job_id); - } - - ilog("underwriter_plugin: shutdown complete"); -} - -} // namespace sysio diff --git a/plugins/underwriter_plugin/test/main.cpp b/plugins/underwriter_plugin/test/main.cpp deleted file mode 100644 index f897527acb..0000000000 --- a/plugins/underwriter_plugin/test/main.cpp +++ /dev/null @@ -1,2 +0,0 @@ -#define BOOST_TEST_MODULE underwriter_plugin_tests -#include diff --git a/plugins/underwriter_plugin/test/test_underwriter_plugin.cpp b/plugins/underwriter_plugin/test/test_underwriter_plugin.cpp deleted file mode 100644 index df8f8ceac4..0000000000 --- a/plugins/underwriter_plugin/test/test_underwriter_plugin.cpp +++ /dev/null @@ -1,1238 +0,0 @@ -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace std::literals; -using namespace sysio::underwriter_defaults; -using namespace sysio::underwriter; - -namespace { - -/// Removed ETH confirmation-depth option name. Keeping the spelling here makes -/// the option-surface test catch any future non-finality escape hatch. -constexpr std::string_view removed_eth_min_confirmations_option = "underwriter-eth-min-confirmations"; - -/// Removed enable flag. Configuring underwriter-account is what enables the -/// scan cycle; a separate flag would let an operator set the account and still -/// underwrite nothing, which is indistinguishable from a healthy node. -constexpr std::string_view removed_enabled_option = "underwriter-enabled"; - -/// Removed per-chain outpost wiring options. Nothing about an outpost is -/// declared per node any more: `sysio.chains` names which chains are served and -/// where their contracts live, and the RPC client for a chain is the one -/// registered under that chain's own code. -constexpr std::string_view removed_eth_outpost_option = "underwriter-eth-outpost"; -constexpr std::string_view removed_sol_outpost_option = "underwriter-sol-outpost"; - -/// Program id used by the scanner tests to model the configured opp-outpost. -const std::string test_sol_program_id = "OppOutpost11111111111111111111111111111111"; - -/// Program id used by the scanner tests to model an attacker-controlled CPI caller. -const std::string attacker_program_id = "Attacker111111111111111111111111111111111"; - -/// Returns a deterministic expected correlation hash for test markers. -fc::crypto::keccak256 test_expected_hash() { - return fc::crypto::keccak256::hash(std::string{"sec-40-source-deposit"}); -} - -/// Hex-encodes a 32-byte keccak hash the same way the Solana outpost log does. -std::string hash_hex(const fc::crypto::keccak256& hash) { - return fc::to_hex(reinterpret_cast(hash.data()), 32); -} - -/// Decodes a hex string into the `std::vector` shape a UWREQ row's -/// `depositor` field carries. -std::vector depositor_bytes(std::string_view hex) { - std::vector bytes(hex.size() / 2); - fc::from_hex(hex, bytes.data(), bytes.size()); - return bytes; -} - -/// Builds the canonical SwapDeposit marker prefix for a deposit id. -std::string marker_prefix(uint64_t deposit_id) { - return "Program log: opp_outpost: SwapDeposit id=" + std::to_string(deposit_id) + " hash="; -} - -/// Builds one `getSignaturesForAddress` response entry. -fc::variant signature_entry(const std::string& sig, - const std::string& confirmation_status = "finalized", - bool failed = false) { - fc::mutable_variant_object obj; - obj("signature", sig); - obj("confirmationStatus", confirmation_status); - if (failed) { - obj("err", "failed"); - } else { - obj("err", fc::variant()); - } - return fc::variant(obj); -} - -/// Builds one `getTransaction` response with the supplied runtime log lines. -fc::variant transaction_with_logs(const std::vector& logs, bool failed = false) { - fc::variants log_variants; - log_variants.reserve(logs.size()); - for (const auto& line : logs) { - log_variants.emplace_back(line); - } - - fc::mutable_variant_object meta; - if (failed) { - meta("err", "failed"); - } else { - meta("err", fc::variant()); - } - meta("logMessages", log_variants); - - return fc::variant(fc::mutable_variant_object()("meta", meta)); -} - -/// Builds runtime logs where `program_id` is the current executing program. -std::vector program_logs(const std::string& program_id, - const std::vector& payloads) { - std::vector logs; - logs.reserve(payloads.size() + 2); - logs.push_back("Program " + program_id + " invoke [1]"); - logs.insert(logs.end(), payloads.begin(), payloads.end()); - logs.push_back("Program " + program_id + " success"); - return logs; -} - -/// Runs the Solana source-deposit page scanner against in-memory transactions. -sysio::underwriter::solana_source_deposit_page_scan_result -scan_test_page(const std::vector& sigs, - const std::map& tx_by_sig, - const fc::crypto::keccak256& expected_hash, - uint64_t deposit_id, - size_t* fetch_count = nullptr) { - const auto prefix = marker_prefix(deposit_id); - const sysio::underwriter::solana_source_deposit_page_scan_config config{ - .sol_program_id = test_sol_program_id, - .marker_prefix = prefix, - .recomputed_hash = expected_hash, - }; - return sysio::underwriter::scan_solana_source_deposit_signature_page( - sigs, - [&](const std::string& sig) { - if (fetch_count) ++*fetch_count; - return tx_by_sig.at(sig); - }, - config); -} - -/** Build one explicitly named local signature-provider specification. */ -std::string explicit_uic_provider_spec( - std::string_view key_name, - fc::crypto::chain_key_type_t key_type, - fc::crypto::private_key::key_type concrete_type) { - const auto key = fc::crypto::private_key::generate(concrete_type); - return fc::crypto::to_signature_provider_spec( - std::string{key_name}, fc::crypto::chain_kind_wire, key_type, - key.get_public_key().to_string({}), "KEY:" + key.to_string({})); -} - -/** Build one anonymous four-field local signature-provider specification. */ -std::string anonymous_uic_provider_spec( - std::string_view key_name, - fc::crypto::chain_key_type_t key_type, - fc::crypto::private_key::key_type concrete_type) { - const auto named = explicit_uic_provider_spec(key_name, key_type, concrete_type); - return named.substr(named.find(',') + 1); -} - -/** - * Exercise operator-configured UIC selection after production-style WIRE default - * registration for one supported provider key type. - */ -void check_operator_configured_uic_provider_beats_wire_default( - std::string_view key_name, - fc::crypto::chain_key_type_t key_type, - fc::crypto::private_key::key_type concrete_type, - bool automatic_default_coexists = true, - bool has_explicit_name = true) { - using namespace fc::crypto; - auto reset_app = gsl_lite::finally([]() { - appbase::application::reset_app_singleton(); - }); - const auto config_dir = std::filesystem::temp_directory_path() - / ("underwriter-uic-provider-" + std::string{key_name}); - std::error_code ec; - std::filesystem::remove_all(config_dir, ec); - std::filesystem::create_directories(config_dir); - auto cleanup = gsl_lite::finally([&]() { - std::error_code ignored; - std::filesystem::remove_all(config_dir, ignored); - }); - - std::vector args{ - "test_underwriter_plugin", - "--config-dir", config_dir.string(), - "--signature-provider", - has_explicit_name - ? explicit_uic_provider_spec(key_name, key_type, concrete_type) - : anonymous_uic_provider_spec(key_name, key_type, concrete_type), - }; - std::vector argv; - argv.reserve(args.size()); - for (auto& arg : args) argv.push_back(arg.data()); - - appbase::scoped_app test_app; - BOOST_REQUIRE(test_app->initialize( - argv.size(), argv.data())); - auto& manager = test_app->get_plugin(); - - // Emulate chain_plugin's production initialize-time call. This focused test - // intentionally couples to that API; if chain_plugin changes registration - // timing, reassess the preflight ordering as well as this regression. - manager.register_default_signature_providers({chain_key_type_wire}); - const auto wire_providers = manager.query_providers(std::nullopt, chain_kind_wire); - const auto selected = sysio::underwriter_detail::select_uic_signature_providers( - wire_providers, [&](const std::string& name) { - return manager.is_operator_configured_provider(name); - }, [](const public_key&) { return true; }); - - BOOST_REQUIRE_EQUAL(1u, selected.size()); - BOOST_CHECK_EQUAL(has_explicit_name ? std::string{key_name} : "key-0", - selected.front()->key_name); - BOOST_CHECK(manager.is_operator_configured_provider( - selected.front()->key_name)); - BOOST_CHECK_EQUAL(has_explicit_name, - manager.is_explicitly_configured_provider(selected.front()->key_name)); - BOOST_CHECK(sysio::underwriter_detail::is_supported_uic_public_key( - selected.front()->public_key)); - - std::vector automatic_only; - for (const auto& provider : wire_providers) { - if (!manager.is_operator_configured_provider(provider->key_name)) { - automatic_only.push_back(provider); - } - } - if (automatic_default_coexists) { - BOOST_REQUIRE(!automatic_only.empty()); - } else { - // Production default registration is keyed by chain-key type. An - // explicit WIRE-native K1/R1 provider suppresses the automatic WIRE - // default in that same bucket; EM/ED occupy different buckets and - // therefore coexist with it. - BOOST_CHECK(automatic_only.empty()); - } - BOOST_CHECK(sysio::underwriter_detail::select_uic_signature_providers( - automatic_only, [&](const std::string& name) { - return manager.is_operator_configured_provider(name); - }, [](const public_key&) { return true; }).empty()); -} - -} // namespace - -BOOST_AUTO_TEST_SUITE(underwriter_plugin_tests) - -BOOST_AUTO_TEST_CASE(enum_variant_decode_is_typed_and_checked) try { - using sysio::opp::types::AttestationType; - using sysio::opp::types::UnderwriteRequestStatus; - using sysio::opp::types::UnderwriteStatus; - - const fc::variant_object request_object{ - fc::mutable_variant_object{}( - "status", std::string{"UNDERWRITE_REQUEST_STATUS_PENDING"})}; - const auto request_status = - sysio::underwriter_detail::decode_enum_field( - request_object, "status"); - BOOST_REQUIRE(request_status); - BOOST_CHECK(*request_status == - UnderwriteRequestStatus::UNDERWRITE_REQUEST_STATUS_PENDING); - BOOST_CHECK(!sysio::underwriter_detail::decode_enum_field< - UnderwriteRequestStatus>(request_object, "missing")); - - const auto attestation_type = - sysio::underwriter_detail::decode_enum_variant( - fc::variant{uint64_t{magic_enum::enum_integer( - AttestationType::ATTESTATION_TYPE_SWAP_REQUEST)}}); - BOOST_REQUIRE(attestation_type); - BOOST_CHECK(*attestation_type == AttestationType::ATTESTATION_TYPE_SWAP_REQUEST); - - const auto candidate_status = - sysio::underwriter_detail::decode_enum_variant( - fc::variant{std::string{"UNDERWRITE_STATUS_INTENT_SUBMITTED"}}); - BOOST_REQUIRE(candidate_status); - BOOST_CHECK(*candidate_status == - UnderwriteStatus::UNDERWRITE_STATUS_INTENT_SUBMITTED); - - const std::array rejected_shapes{ - fc::variant{}, - fc::variant{false}, - fc::variant{true}, - fc::variant{0.9}, - fc::variant{std::numeric_limits::max()}, - fc::variant{std::numeric_limits::infinity()}, - fc::variant{fc::variant_object{}}, - fc::variant{fc::variants{}}, - fc::variant{fc::blob{}}, - fc::variant{std::numeric_limits::max()}, - fc::variant{int64_t{-1}}, - }; - for (const auto& value : rejected_shapes) { - BOOST_CHECK(!sysio::underwriter_detail::decode_enum_variant< - UnderwriteStatus>(value)); - } - BOOST_CHECK(!sysio::underwriter_detail::decode_enum_variant( - fc::variant{std::numeric_limits::max()})); - BOOST_CHECK(!sysio::underwriter_detail::decode_enum_variant( - fc::variant{std::string{"UNDERWRITE_STATUS_NOT_REAL"}})); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(uic_authenticated_caller_shape_is_non_default_and_exact) try { - using sysio::opp::attestations::UnderwriteIntentCommit; - using sysio::opp::types::CHAIN_KIND_EVM; - using sysio::opp::types::CHAIN_KIND_SVM; - using sysio::opp::types::CHAIN_KIND_UNKNOWN; - - for (const auto [kind, size] : std::array{ - std::pair{CHAIN_KIND_EVM, size_t{20}}, - std::pair{CHAIN_KIND_SVM, size_t{32}}, - }) { - UnderwriteIntentCommit uic; - const std::vector address(size, 0x5au); - BOOST_REQUIRE(sysio::underwriter_detail::set_uic_authenticated_caller( - uic, kind, address)); - BOOST_CHECK_EQUAL(kind, uic.uw_ext_chain_addr().kind()); - BOOST_CHECK_EQUAL_COLLECTIONS( - address.begin(), address.end(), - uic.uw_ext_chain_addr().address().begin(), - uic.uw_ext_chain_addr().address().end()); - } - - UnderwriteIntentCommit invalid; - const std::vector short_evm(19, 0x01u); - BOOST_CHECK(!sysio::underwriter_detail::set_uic_authenticated_caller( - invalid, CHAIN_KIND_EVM, short_evm)); - BOOST_CHECK(!invalid.has_uw_ext_chain_addr()); - const std::vector unsupported(32, 0x01u); - BOOST_CHECK(!sysio::underwriter_detail::set_uic_authenticated_caller( - invalid, CHAIN_KIND_UNKNOWN, unsupported)); - BOOST_CHECK(!invalid.has_uw_ext_chain_addr()); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(plugin_can_be_constructed) try { - sysio::underwriter_plugin plugin; - BOOST_CHECK(true); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(plugin_options_are_registered) try { - sysio::underwriter_plugin plugin; - boost::program_options::options_description cli, cfg; - plugin.set_program_options(cli, cfg); - - const auto& opts = cfg.options(); - std::set option_names; - for (const auto& opt : opts) { - option_names.insert(opt->long_name()); - } - BOOST_CHECK(option_names.count("underwriter-account") > 0); - BOOST_CHECK(option_names.count("underwriter-scan-interval-ms") > 0); - BOOST_CHECK(option_names.count("underwriter-action-timeout-ms") > 0); - BOOST_CHECK(option_names.count(std::string{ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS_OPTION}) > 0); - BOOST_CHECK_EQUAL(option_names.count(std::string{removed_eth_min_confirmations_option}), 0); - BOOST_CHECK_EQUAL(option_names.count(std::string{removed_enabled_option}), 0); - BOOST_CHECK_EQUAL(option_names.count(std::string{removed_eth_outpost_option}), 0); - BOOST_CHECK_EQUAL(option_names.count(std::string{removed_sol_outpost_option}), 0); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(default_options_are_correct) try { - sysio::underwriter_plugin plugin; - boost::program_options::options_description cli, cfg; - plugin.set_program_options(cli, cfg); - - boost::program_options::variables_map vm; - boost::program_options::store( - boost::program_options::parse_command_line(0, static_cast(nullptr), cfg), vm); - boost::program_options::notify(vm); - - BOOST_CHECK_EQUAL(vm["underwriter-scan-interval-ms"].as(), scan_interval_ms); - BOOST_CHECK_EQUAL(vm["underwriter-action-timeout-ms"].as(), action_timeout_ms); - // No default account: an unconfigured node leaves the scan cycle off. - BOOST_CHECK_EQUAL(vm.count("underwriter-account"), 0u); - // No per-chain outpost wiring to default either: the former - // --underwriter-{eth,sol}-outpost options are gone, and their absence is - // asserted in the option-registration case above. - BOOST_CHECK_EQUAL( - vm[std::string{ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS_OPTION}].as(), - ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS); -} FC_LOG_AND_RETHROW(); - -/// The ETH source-deposit event lookup window is inclusive and anchored on the -/// finalized head. It never reaches below genesis, so the verifier can bound -/// each `eth_getLogs` request without losing well-defined behavior on young -/// chains. -BOOST_AUTO_TEST_CASE(eth_source_deposit_log_window_is_bounded) try { - constexpr uint64_t first_non_genesis_head = 1; - constexpr uint64_t one_block_window = 1; - constexpr uint64_t larger_head = ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS + 99; - - BOOST_CHECK_EQUAL( - eth_source_deposit_from_block(larger_head, ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS), - larger_head - ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS + 1); - BOOST_CHECK_EQUAL( - larger_head - eth_source_deposit_from_block(larger_head, ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS) + 1, - ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS); - BOOST_CHECK_EQUAL(eth_source_deposit_from_block(first_non_genesis_head, - ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS), - 0); - BOOST_CHECK_EQUAL(eth_source_deposit_from_block(larger_head, one_block_window), - larger_head); -} FC_LOG_AND_RETHROW(); - -/// Reject an empty bounded ETH log lookup window. -BOOST_AUTO_TEST_CASE(eth_source_deposit_options_reject_zero_window) try { - sysio::underwriter_plugin plugin; - boost::program_options::options_description cli, cfg; - plugin.set_program_options(cli, cfg); - - const std::vector args{ - "--" + std::string{ETH_SOURCE_DEPOSIT_LOOKBACK_BLOCKS_OPTION} + "=0", - }; - - boost::program_options::variables_map vm; - boost::program_options::store( - boost::program_options::command_line_parser(args).options(cfg).run(), vm); - boost::program_options::notify(vm); - - BOOST_CHECK_THROW(plugin.plugin_initialize(vm), fc::exception); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(source_deposit_hash_uses_the_immutable_target_amount) try { - const std::array depositor{ - 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, - 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, - }; - const sysio::underwriter::source_deposit_hash_input input{ - .depositor = depositor, - .source_amount = 0x0102030405060708ULL, - .source_token_code = 0x1112131415161718ULL, - .source_reserve_code = 0x2122232425262728ULL, - .target_chain_code = 0x3132333435363738ULL, - .target_token_code = 0x4142434445464748ULL, - .target_reserve_code = 0x5152535455565758ULL, - .target_amount = 0x6162636465666768ULL, - .target_tolerance_bps = 0x71727374U, - }; - const auto hash = sysio::underwriter::source_deposit_hash(input); - - BOOST_CHECK_EQUAL( - fc::to_hex(reinterpret_cast(hash.data()), 32), - "08e7395c07b220b0c1ae590b5273028f0e2f424a39fbd307d691e2b5eba2ac04"); - - auto changed_target = input; - ++changed_target.target_amount; - const auto different_target = sysio::underwriter::source_deposit_hash(changed_target); - BOOST_CHECK(hash != different_target); -} FC_LOG_AND_RETHROW(); - -/// Explicit block bounds must be encoded as JSON-RPC quantities, not tags such -/// as `earliest` or `latest`. -BOOST_AUTO_TEST_CASE(eth_block_quantity_formats_json_rpc_numbers) try { - constexpr uint64_t low_nibble_block = 15; - constexpr uint64_t two_digit_block = 16; - constexpr uint64_t sample_block = 0x1234abcd; - - BOOST_CHECK_EQUAL(eth_block_quantity(0), "0x0"); - BOOST_CHECK_EQUAL(eth_block_quantity(low_nibble_block), "0xf"); - BOOST_CHECK_EQUAL(eth_block_quantity(two_digit_block), "0x10"); - BOOST_CHECK_EQUAL(eth_block_quantity(sample_block), "0x1234abcd"); -} FC_LOG_AND_RETHROW(); - -/// Malformed finalized-head block numbers defer source-deposit verification -/// instead of escaping through the scan cycle. -BOOST_AUTO_TEST_CASE(eth_block_quantity_parser_rejects_malformed_rpc_numbers) try { - const auto parsed_zero = eth_parse_block_quantity("0x0"); - const auto parsed_sample = eth_parse_block_quantity("0x1234abcd"); - - BOOST_REQUIRE(parsed_zero); - BOOST_REQUIRE(parsed_sample); - BOOST_CHECK_EQUAL(*parsed_zero, 0); - BOOST_CHECK_EQUAL(*parsed_sample, 0x1234abcd); - BOOST_CHECK(!eth_parse_block_quantity("")); - BOOST_CHECK(!eth_parse_block_quantity("0x")); - BOOST_CHECK(!eth_parse_block_quantity("1234")); - BOOST_CHECK(!eth_parse_block_quantity("0x12zz")); - BOOST_CHECK(!eth_parse_block_quantity("0x10000000000000000")); -} FC_LOG_AND_RETHROW(); - -// ── B1: preflight option-coverage ────────────────────────────────────── -// -// The full preflight (operator status / authex links / balances / -// signature self-test) needs a live chain context to exercise. What we -// CAN cover at unit-test level is that every required CLI option for -// the verify path is declared by `set_program_options` — so a typo'd -// option name in production won't slip past with a silent default. -BOOST_AUTO_TEST_CASE(preflight_required_options_are_registered) try { - sysio::underwriter_plugin plugin; - boost::program_options::options_description cli, cfg; - plugin.set_program_options(cli, cfg); - - const auto& opts = cfg.options(); - std::set option_names; - for (const auto& opt : opts) { - option_names.insert(opt->long_name()); - } - BOOST_CHECK(option_names.count("underwriter-account") > 0); - BOOST_CHECK(option_names.count("underwriter-eth-source-deposit-function") > 0); - BOOST_CHECK(option_names.count("underwriter-sol-source-deposit-instruction") > 0); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(preflight_accepts_fixed_size_recoverable_uic_signature_providers) try { - using private_key = fc::crypto::private_key; - using provider_result = - sysio::underwriter_detail::uic_signature_provider_result; - - const auto digest = fc::sha256::hash( - std::string{sysio::underwriter_detail::uic_signature_self_test_domain}); - const std::array supported_types{ - private_key::key_type::k1, - private_key::key_type::r1, - private_key::key_type::em, - private_key::key_type::ed, - }; - - for (const auto type : supported_types) { - const auto key = private_key::generate(type); - const auto signature = key.sign(digest); - - fc::crypto::signature_provider_t provider; - provider.public_key = key.get_public_key(); - provider.sign = [signature](const fc::sha256&) { return signature; }; - - const auto check = - sysio::underwriter_detail::check_uic_signature_provider(provider, digest); - BOOST_CHECK(check.result == provider_result::compatible); - BOOST_REQUIRE(check.recovered_key); - BOOST_CHECK(*check.recovered_key == key.get_public_key()); - - const auto packed = fc::raw::pack(signature); - BOOST_CHECK( - sysio::underwriter_detail::is_canonical_packed_uic_signature(packed)); - const size_t expected_size = - sysio::underwriter_detail::canonical_packed_uic_signature_size( - signature.type()); - BOOST_CHECK_EQUAL(packed.size(), expected_size); - } - - const auto k1_private = private_key::generate(private_key::key_type::k1); - const auto r1_private = private_key::generate(private_key::key_type::r1); - const auto r1_signature = r1_private.sign(digest); - - // A supported provider key paired with another supported signature variant - // still fails: the signer identity must match its configured public key. - fc::crypto::signature_provider_t mismatched_provider; - mismatched_provider.public_key = k1_private.get_public_key(); - mismatched_provider.sign = [r1_signature](const fc::sha256&) { - return r1_signature; - }; - const auto mismatched_check = - sysio::underwriter_detail::check_uic_signature_provider(mismatched_provider, digest); - BOOST_CHECK(mismatched_check.result == provider_result::signature_type_mismatch); - BOOST_CHECK(!mismatched_check.recovered_key); - - // WebAuthn and BLS tags remain outside the non-parsing protocol boundary, - // and supported tags with the wrong exact size are non-canonical. - using sig_type = fc::crypto::signature::sig_type; - auto tag = [](sig_type type) { - return static_cast(magic_enum::enum_integer(type)); - }; - fc::crypto::webauthn::signature webauthn{ - fc::crypto::r1::compact_signature{}, - std::vector{1, 2, 3}, - R"({"type":"webauthn.get"})"}; - const auto webauthn_shape = fc::raw::pack(fc::crypto::signature{ - fc::crypto::signature::storage_type{std::move(webauthn)}}); - const auto bls_shape = fc::raw::pack(fc::crypto::signature{ - fc::crypto::signature::storage_type{ - std::in_place_type}}); - std::vector truncated_ed( - sysio::underwriter_detail::canonical_packed_uic_signature_size(sig_type::ed) - 1, - '\0'); - truncated_ed.front() = tag(sig_type::ed); - BOOST_CHECK(!sysio::underwriter_detail::is_canonical_packed_uic_signature( - webauthn_shape)); - BOOST_CHECK(!sysio::underwriter_detail::is_canonical_packed_uic_signature( - bls_shape)); - BOOST_CHECK(!sysio::underwriter_detail::is_canonical_packed_uic_signature( - truncated_ed)); - - // A provider can return a recoverable high-s alternate even though local - // libfc signers normally emit low-s. Preflight must reject it before key - // recovery so runtime construction cannot submit a depot-invalid UIC. - const auto canonical_k1 = k1_private.sign(digest); - auto high_s_packed = sysio::opp::test::create_high_s_alternate( - fc::raw::pack(canonical_k1), private_key::key_type::k1); - BOOST_REQUIRE(high_s_packed.has_value()); - BOOST_REQUIRE_EQUAL( - sysio::underwriter_detail::canonical_packed_uic_signature_size( - sig_type::k1), - high_s_packed->size()); - // create_signed_uic_bytes calls this shared predicate independently at - // runtime after packing every returned signature, even after startup - // preflight has passed. Exercise that exact guard as well as the aggregate - // preflight result below. - BOOST_CHECK(!sysio::underwriter_detail::has_canonical_uic_signature_body( - *high_s_packed)); - const auto high_s_signature = - fc::raw::unpack(*high_s_packed); - fc::crypto::signature_provider_t high_s_provider; - high_s_provider.public_key = k1_private.get_public_key(); - high_s_provider.sign = [high_s_signature](const fc::sha256&) { - return high_s_signature; - }; - const auto high_s_check = - sysio::underwriter_detail::check_uic_signature_provider(high_s_provider, digest); - BOOST_CHECK(high_s_check.result == provider_result::non_canonical_signature); - BOOST_CHECK(!high_s_check.recovered_key); - - // Libfc can recover the same key from the other recovery-header family. - // Both startup preflight and the runtime predicate must reject that alias - // so a UIC signature has exactly one accepted byte representation. - const std::array alias_types{ - private_key::key_type::k1, - private_key::key_type::r1, - private_key::key_type::em, - }; - for (const auto type : alias_types) { - const auto key = private_key::generate(type); - const auto canonical = key.sign(digest); - auto aliased_packed = sysio::opp::test::create_recovery_alias( - fc::raw::pack(canonical), type); - BOOST_REQUIRE(aliased_packed.has_value()); - BOOST_CHECK(!sysio::underwriter_detail::has_canonical_uic_signature_body( - *aliased_packed)); - - const auto aliased = fc::raw::unpack(*aliased_packed); - BOOST_REQUIRE(fc::crypto::public_key::recover(aliased, digest) - == key.get_public_key()); - fc::crypto::signature_provider_t alias_provider; - alias_provider.public_key = key.get_public_key(); - alias_provider.sign = [aliased](const fc::sha256&) { return aliased; }; - const auto alias_check = - sysio::underwriter_detail::check_uic_signature_provider(alias_provider, digest); - BOOST_CHECK(alias_check.result == provider_result::non_canonical_signature); - BOOST_CHECK(!alias_check.recovered_key); - } -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(uic_provider_selection_reaches_all_supported_native_key_formats) try { - using namespace fc::crypto; - - appbase::scoped_app test_app; - char program_name[] = "test_underwriter_plugin"; - char* argv[] = {program_name}; - BOOST_REQUIRE(test_app->initialize( - std::size(argv), argv)); - auto& manager = test_app->get_plugin(); - - auto add_provider = [&](std::string key_name, - chain_kind_t target_chain, - chain_key_type_t key_type, - private_key::key_type concrete_type) { - const auto key = private_key::generate(concrete_type); - return manager.create_provider( - key_name, target_chain, key_type, - key.get_public_key().to_string({}), "KEY:" + key.to_string({})); - }; - - add_provider("uic-k1", chain_kind_wire, chain_key_type_wire, - private_key::key_type::k1); - add_provider("uic-r1", chain_kind_wire, chain_key_type_wire, - private_key::key_type::r1); - add_provider("uic-em", chain_kind_wire, chain_key_type_ethereum, - private_key::key_type::em); - add_provider("uic-ed", chain_kind_wire, chain_key_type_solana, - private_key::key_type::ed); - add_provider("wire-bls", chain_kind_wire, chain_key_type_wire_bls, - private_key::key_type::bls); - add_provider("ethereum-transaction", chain_kind_ethereum, - chain_key_type_ethereum, private_key::key_type::em); - - const auto wire_target_providers = - manager.query_providers(std::nullopt, chain_kind_wire); - const auto uic_providers = - sysio::underwriter_detail::select_uic_signature_providers( - wire_target_providers, - [](const std::string&) { return true; }, - [](const public_key&) { return true; }); - - BOOST_REQUIRE_EQUAL(5u, wire_target_providers.size()); - BOOST_REQUIRE_EQUAL(4u, uic_providers.size()); - std::set selected_types; - for (const auto& provider : uic_providers) { - selected_types.insert(provider->public_key.type()); - BOOST_CHECK(provider->target_chain == chain_kind_wire); - } - const std::set expected_types{ - public_key::key_type::k1, - public_key::key_type::r1, - public_key::key_type::em, - public_key::key_type::ed, - }; - BOOST_CHECK(selected_types == expected_types); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(uic_provider_selection_excludes_unrelated_wire_signer) try { - using namespace fc::crypto; - const auto underwriter_key = private_key::generate(private_key::key_type::em); - const auto block_signing_key = private_key::generate(private_key::key_type::k1); - - auto create_provider = [](std::string name, chain_key_type_t key_type, - const private_key& key) { - auto provider = std::make_shared(); - provider->target_chain = chain_kind_wire; - provider->key_type = key_type; - provider->key_name = std::move(name); - provider->public_key = key.get_public_key(); - provider->sign = [key](const fc::sha256& digest) { return key.sign(digest); }; - return provider; - }; - - const std::vector providers{ - create_provider("uic-em", chain_key_type_ethereum, underwriter_key), - create_provider("block-k1", chain_key_type_wire, block_signing_key), - }; - const auto selected = sysio::underwriter_detail::select_uic_signature_providers( - providers, - [](const std::string&) { return true; }, - [&](const public_key& key) { - return key == underwriter_key.get_public_key(); - }); - - BOOST_REQUIRE_EQUAL(1u, selected.size()); - BOOST_CHECK_EQUAL("uic-em", selected.front()->key_name); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(uic_provider_selection_ignores_default_for_explicit_em) try { - check_operator_configured_uic_provider_beats_wire_default( - "uic-em", fc::crypto::chain_key_type_ethereum, - fc::crypto::private_key::key_type::em); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(uic_provider_selection_uses_explicit_k1_after_default_suppression) try { - check_operator_configured_uic_provider_beats_wire_default( - "uic-k1", fc::crypto::chain_key_type_wire, - fc::crypto::private_key::key_type::k1, false); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(uic_provider_selection_uses_explicit_r1_after_default_suppression) try { - check_operator_configured_uic_provider_beats_wire_default( - "uic-r1", fc::crypto::chain_key_type_wire, - fc::crypto::private_key::key_type::r1, false); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(uic_provider_selection_ignores_default_for_explicit_ed) try { - check_operator_configured_uic_provider_beats_wire_default( - "uic-ed", fc::crypto::chain_key_type_solana, - fc::crypto::private_key::key_type::ed); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(uic_provider_selection_accepts_anonymous_em) try { - check_operator_configured_uic_provider_beats_wire_default( - "ignored-em", fc::crypto::chain_key_type_ethereum, - fc::crypto::private_key::key_type::em, true, false); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(uic_provider_selection_accepts_anonymous_k1) try { - check_operator_configured_uic_provider_beats_wire_default( - "ignored-k1", fc::crypto::chain_key_type_wire, - fc::crypto::private_key::key_type::k1, false, false); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(uic_provider_selection_accepts_anonymous_r1) try { - check_operator_configured_uic_provider_beats_wire_default( - "ignored-r1", fc::crypto::chain_key_type_wire, - fc::crypto::private_key::key_type::r1, false, false); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(uic_provider_selection_accepts_anonymous_ed) try { - check_operator_configured_uic_provider_beats_wire_default( - "ignored-ed", fc::crypto::chain_key_type_solana, - fc::crypto::private_key::key_type::ed, true, false); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(uic_provider_selection_preserves_two_explicit_ambiguity) try { - using namespace fc::crypto; - auto reset_app = gsl_lite::finally([]() { - appbase::application::reset_app_singleton(); - }); - const auto config_dir = std::filesystem::temp_directory_path() - / "underwriter-uic-provider-ambiguity"; - std::error_code ec; - std::filesystem::remove_all(config_dir, ec); - std::filesystem::create_directories(config_dir); - auto cleanup = gsl_lite::finally([&]() { - std::error_code ignored; - std::filesystem::remove_all(config_dir, ignored); - }); - - std::vector args{ - "test_underwriter_plugin", - "--config-dir", config_dir.string(), - "--signature-provider", - explicit_uic_provider_spec("uic-em", chain_key_type_ethereum, - private_key::key_type::em), - "--signature-provider", - explicit_uic_provider_spec("uic-ed", chain_key_type_solana, - private_key::key_type::ed), - }; - std::vector argv; - argv.reserve(args.size()); - for (auto& arg : args) argv.push_back(arg.data()); - - appbase::scoped_app test_app; - BOOST_REQUIRE(test_app->initialize( - argv.size(), argv.data())); - auto& manager = test_app->get_plugin(); - manager.register_default_signature_providers({chain_key_type_wire}); - - const auto selected = sysio::underwriter_detail::select_uic_signature_providers( - manager.query_providers(std::nullopt, chain_kind_wire), - [&](const std::string& name) { - return manager.is_operator_configured_provider(name); - }, [](const public_key&) { return true; }); - BOOST_REQUIRE_EQUAL(2u, selected.size()); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(stored_commit_plan_leaves_complete_candidate_for_external_replay) try { - const auto plan = sysio::underwriter_detail::plan_stored_commits( - /*candidate_exists=*/true, - /*intent_submitted=*/true, - /*source_is_depot=*/false, - /*destination_is_depot=*/false, - /*source_uic_stored=*/true, - /*destination_uic_stored=*/true); - BOOST_CHECK(!plan.skip_candidate); - BOOST_CHECK(!plan.submit_source); - BOOST_CHECK(!plan.submit_destination); - BOOST_CHECK(!sysio::underwriter_detail::has_submission_work( - plan, /*source_confirmed_locally=*/false, - /*destination_confirmed_locally=*/false)); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(stored_commit_plan_submits_only_missing_outpost_leg) try { - const auto plan = sysio::underwriter_detail::plan_stored_commits( - /*candidate_exists=*/true, - /*intent_submitted=*/true, - /*source_is_depot=*/false, - /*destination_is_depot=*/false, - /*source_uic_stored=*/true, - /*destination_uic_stored=*/false); - BOOST_CHECK(!plan.skip_candidate); - BOOST_CHECK(!plan.submit_source); - BOOST_CHECK(plan.submit_destination); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(stored_commit_plan_handles_single_outpost_candidate) try { - const auto ready = sysio::underwriter_detail::plan_stored_commits( - true, true, false, true, true, false); - BOOST_CHECK(!ready.skip_candidate); - BOOST_CHECK(!ready.submit_source); - BOOST_CHECK(!ready.submit_destination); - BOOST_CHECK(!sysio::underwriter_detail::has_submission_work( - ready, /*source_confirmed_locally=*/false, - /*destination_confirmed_locally=*/false)); - - const auto missing = sysio::underwriter_detail::plan_stored_commits( - true, true, false, true, false, false); - BOOST_CHECK(!missing.skip_candidate); - BOOST_CHECK(missing.submit_source); - BOOST_CHECK(!missing.submit_destination); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(stored_commit_plan_skips_disqualified_candidate_after_restart) try { - const auto disqualified = sysio::underwriter_detail::plan_stored_commits( - /*candidate_exists=*/true, - /*intent_submitted=*/false, - /*source_is_depot=*/false, - /*destination_is_depot=*/false, - /*source_uic_stored=*/true, - /*destination_uic_stored=*/true); - BOOST_CHECK(disqualified.skip_candidate); - BOOST_CHECK(!disqualified.submit_source); - BOOST_CHECK(!disqualified.submit_destination); - - const auto absent = sysio::underwriter_detail::plan_stored_commits( - /*candidate_exists=*/false, - /*intent_submitted=*/false, - /*source_is_depot=*/false, - /*destination_is_depot=*/false, - /*source_uic_stored=*/false, - /*destination_uic_stored=*/false); - BOOST_CHECK(!absent.skip_candidate); - BOOST_CHECK(absent.submit_source); - BOOST_CHECK(absent.submit_destination); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(solana_source_deposit_scan_state_advances_across_pages) try { - sysio::underwriter::solana_source_deposit_scan_cursor_map cursors; - const sysio::underwriter::solana_source_deposit_scan_key key{ - .uwreq_id = 42, - .deposit_id = 7, - }; - - const auto initial = - sysio::underwriter::get_or_create_solana_source_deposit_scan_cursor(cursors, key); - BOOST_CHECK(!initial.before); - BOOST_CHECK_EQUAL(initial.pages_scanned, 0); - BOOST_CHECK_EQUAL(initial.signatures_scanned, 0); - - const auto first_page = sysio::underwriter::advance_solana_source_deposit_scan_cursor( - cursors, key, "page-0-last-signature", sysio::underwriter::SOL_SIGNATURE_SCAN_PAGE_SIZE); - BOOST_REQUIRE(first_page.before); - BOOST_CHECK_EQUAL(*first_page.before, "page-0-last-signature"); - BOOST_CHECK_EQUAL(first_page.pages_scanned, 1); - BOOST_CHECK_EQUAL(first_page.signatures_scanned, sysio::underwriter::SOL_SIGNATURE_SCAN_PAGE_SIZE); - - const auto second_page = sysio::underwriter::advance_solana_source_deposit_scan_cursor( - cursors, key, "page-1-last-signature", sysio::underwriter::SOL_SIGNATURE_SCAN_PAGE_SIZE); - BOOST_REQUIRE(second_page.before); - BOOST_CHECK_EQUAL(*second_page.before, "page-1-last-signature"); - BOOST_CHECK_EQUAL(second_page.pages_scanned, 2); - BOOST_CHECK_EQUAL(second_page.signatures_scanned, sysio::underwriter::SOL_SIGNATURE_SCAN_PAGE_SIZE * 2); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(solana_source_deposit_scan_state_records_terminal_failure_once) try { - sysio::underwriter::solana_source_deposit_scan_cursor_map cursors; - const sysio::underwriter::solana_source_deposit_scan_key key{ - .uwreq_id = 43, - .deposit_id = 8, - }; - - sysio::underwriter::advance_solana_source_deposit_scan_cursor( - cursors, key, "page-0-last-signature", sysio::underwriter::SOL_SIGNATURE_SCAN_PAGE_SIZE); - const auto first = sysio::underwriter::record_solana_source_deposit_terminal_failure( - cursors, key, "exhausted history", 17); - BOOST_CHECK(first.first_failure); - BOOST_CHECK(first.cursor.terminal_failure); - BOOST_CHECK_EQUAL(first.cursor.terminal_failure_reason, "exhausted history"); - BOOST_CHECK_EQUAL(first.cursor.pages_scanned, 2); - BOOST_CHECK_EQUAL(first.cursor.signatures_scanned, sysio::underwriter::SOL_SIGNATURE_SCAN_PAGE_SIZE + 17); - - const auto terminal = - sysio::underwriter::get_solana_source_deposit_terminal_failure(cursors, key); - BOOST_REQUIRE(terminal); - BOOST_CHECK_EQUAL(terminal->terminal_failure_reason, "exhausted history"); - - const auto repeated = sysio::underwriter::record_solana_source_deposit_terminal_failure( - cursors, key, "second failure should not replace first", 99); - BOOST_CHECK(!repeated.first_failure); - BOOST_CHECK_EQUAL(repeated.cursor.terminal_failure_reason, "exhausted history"); - BOOST_CHECK_EQUAL(repeated.cursor.pages_scanned, 2); - BOOST_CHECK_EQUAL(repeated.cursor.signatures_scanned, sysio::underwriter::SOL_SIGNATURE_SCAN_PAGE_SIZE + 17); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(solana_source_deposit_scan_state_prunes_resolved_uwreqs) try { - sysio::underwriter::solana_source_deposit_scan_cursor_map cursors; - const sysio::underwriter::solana_source_deposit_scan_key pending_key{ - .uwreq_id = 44, - .deposit_id = 9, - }; - const sysio::underwriter::solana_source_deposit_scan_key resolved_key{ - .uwreq_id = 45, - .deposit_id = 10, - }; - sysio::underwriter::advance_solana_source_deposit_scan_cursor( - cursors, pending_key, "pending-last-signature", 3); - sysio::underwriter::record_solana_source_deposit_terminal_failure( - cursors, resolved_key, "resolved terminal failure", 5); - - const std::unordered_set still_pending{pending_key.uwreq_id}; - sysio::underwriter::prune_solana_source_deposit_scan_cursors(cursors, still_pending); - - BOOST_CHECK(cursors.contains(pending_key)); - BOOST_CHECK(!cursors.contains(resolved_key)); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(solana_source_deposit_scan_finds_match_after_legacy_window) try { - const uint64_t deposit_id = 7; - const auto expected_hash = test_expected_hash(); - const auto marker = marker_prefix(deposit_id) + hash_hex(expected_hash); - - std::vector sigs; - std::map tx_by_sig; - for (size_t i = 0; i < 51; ++i) { - const std::string sig = "noise-" + std::to_string(i); - sigs.push_back(signature_entry(sig)); - tx_by_sig.emplace(sig, transaction_with_logs(program_logs(test_sol_program_id, {"Program log: unrelated"}))); - } - - const std::string target_sig = "target-source-deposit"; - sigs.push_back(signature_entry(target_sig)); - tx_by_sig.emplace(target_sig, transaction_with_logs(program_logs(test_sol_program_id, {marker}))); - - size_t fetch_count = 0; - const auto result = scan_test_page(sigs, tx_by_sig, expected_hash, deposit_id, &fetch_count); - - BOOST_CHECK(result.status == sysio::underwriter::solana_source_deposit_page_status::matched); - BOOST_CHECK_EQUAL(result.matched_signature, target_sig); - BOOST_CHECK_EQUAL(fetch_count, 52); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(solana_source_deposit_scan_returns_next_before_for_full_clean_page) try { - const uint64_t deposit_id = 8; - const auto expected_hash = test_expected_hash(); - - std::vector sigs; - std::map tx_by_sig; - for (size_t i = 0; i < sysio::underwriter::SOL_SIGNATURE_SCAN_PAGE_SIZE; ++i) { - const std::string sig = "full-page-" + std::to_string(i); - sigs.push_back(signature_entry(sig)); - tx_by_sig.emplace(sig, transaction_with_logs(program_logs(test_sol_program_id, {"Program log: unrelated"}))); - } - - const auto result = scan_test_page(sigs, tx_by_sig, expected_hash, deposit_id); - - BOOST_CHECK(result.status == sysio::underwriter::solana_source_deposit_page_status::not_found); - BOOST_REQUIRE(result.next_before); - BOOST_CHECK_EQUAL(*result.next_before, "full-page-999"); - BOOST_CHECK(!result.page_exhausted); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(solana_source_deposit_scan_ignores_forged_marker_from_wrong_program) try { - const uint64_t deposit_id = 9; - const auto expected_hash = test_expected_hash(); - const auto marker = marker_prefix(deposit_id) + hash_hex(expected_hash); - const std::string sig = "forged-marker"; - - const std::vector sigs{signature_entry(sig)}; - const std::map tx_by_sig{ - {sig, transaction_with_logs(program_logs(attacker_program_id, {marker}))}, - }; - - const auto result = scan_test_page(sigs, tx_by_sig, expected_hash, deposit_id); - - BOOST_CHECK(result.status == sysio::underwriter::solana_source_deposit_page_status::not_found); - BOOST_REQUIRE(result.next_before); - BOOST_CHECK_EQUAL(*result.next_before, sig); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(solana_source_deposit_scan_defers_unfinalized_match_without_cursor_advance) try { - const uint64_t deposit_id = 10; - const auto expected_hash = test_expected_hash(); - const auto marker = marker_prefix(deposit_id) + hash_hex(expected_hash); - const std::string sig = "unfinalized-marker"; - - const std::vector sigs{signature_entry(sig, "confirmed")}; - const std::map tx_by_sig{ - {sig, transaction_with_logs(program_logs(test_sol_program_id, {marker}))}, - }; - - const auto result = scan_test_page(sigs, tx_by_sig, expected_hash, deposit_id); - - BOOST_CHECK(result.status == sysio::underwriter::solana_source_deposit_page_status::deferred); - BOOST_CHECK(!result.next_before); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(solana_source_deposit_scan_continues_after_fetch_error_to_find_match) try { - const uint64_t deposit_id = 11; - const auto expected_hash = test_expected_hash(); - const auto marker = marker_prefix(deposit_id) + hash_hex(expected_hash); - const std::string error_sig = "transient-fetch-error"; - const std::string target_sig = "target-after-fetch-error"; - const std::vector sigs{signature_entry(error_sig), signature_entry(target_sig)}; - const std::map tx_by_sig{ - {target_sig, transaction_with_logs(program_logs(test_sol_program_id, {marker}))}, - }; - const auto prefix = marker_prefix(deposit_id); - const sysio::underwriter::solana_source_deposit_page_scan_config config{ - .sol_program_id = test_sol_program_id, - .marker_prefix = prefix, - .recomputed_hash = expected_hash, - }; - - const auto result = sysio::underwriter::scan_solana_source_deposit_signature_page( - sigs, - [&](const std::string& sig) -> fc::variant { - if (sig == error_sig) { - FC_THROW_EXCEPTION(fc::exception, "transient fetch failure"); - } - return tx_by_sig.at(sig); - }, - config); - - BOOST_CHECK(result.status == sysio::underwriter::solana_source_deposit_page_status::matched); - BOOST_CHECK_EQUAL(result.matched_signature, target_sig); - BOOST_CHECK(!result.next_before); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(solana_source_deposit_scan_defers_fetch_error_without_cursor_advance) try { - const uint64_t deposit_id = 12; - const auto expected_hash = test_expected_hash(); - const std::string error_sig = "transient-fetch-error"; - const std::string clean_sig = "clean-after-fetch-error"; - const std::vector sigs{signature_entry(error_sig), signature_entry(clean_sig)}; - const std::map tx_by_sig{ - {clean_sig, transaction_with_logs(program_logs(test_sol_program_id, {"Program log: unrelated"}))}, - }; - const auto prefix = marker_prefix(deposit_id); - const sysio::underwriter::solana_source_deposit_page_scan_config config{ - .sol_program_id = test_sol_program_id, - .marker_prefix = prefix, - .recomputed_hash = expected_hash, - }; - - const auto result = sysio::underwriter::scan_solana_source_deposit_signature_page( - sigs, - [&](const std::string& sig) -> fc::variant { - if (sig == error_sig) { - FC_THROW_EXCEPTION(fc::exception, "transient fetch failure"); - } - return tx_by_sig.at(sig); - }, - config); - - BOOST_CHECK(result.status == sysio::underwriter::solana_source_deposit_page_status::deferred); - BOOST_CHECK(!result.next_before); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(solana_source_deposit_scan_hard_fails_malformed_marker_hash) try { - const uint64_t deposit_id = 13; - const auto expected_hash = test_expected_hash(); - const auto marker = marker_prefix(deposit_id) + "abc"; - const std::string sig = "malformed-marker"; - - const std::vector sigs{signature_entry(sig)}; - const std::map tx_by_sig{ - {sig, transaction_with_logs(program_logs(test_sol_program_id, {marker}))}, - }; - - const auto result = scan_test_page(sigs, tx_by_sig, expected_hash, deposit_id); - - BOOST_CHECK(result.status == sysio::underwriter::solana_source_deposit_page_status::hard_mismatch); - BOOST_CHECK(!result.next_before); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(solana_source_deposit_scan_hard_fails_partial_hex_parse) try { - const uint64_t deposit_id = 14; - const fc::crypto::keccak256 expected_zero_hash; - std::string malformed_zero_hash; - malformed_zero_hash.reserve(64); - for (size_t i = 0; i < 32; ++i) { - malformed_zero_hash += "0g"; - } - const auto marker = marker_prefix(deposit_id) + malformed_zero_hash; - const std::string sig = "partial-hex-marker"; - - const std::vector sigs{signature_entry(sig)}; - const std::map tx_by_sig{ - {sig, transaction_with_logs(program_logs(test_sol_program_id, {marker}))}, - }; - - const auto result = scan_test_page(sigs, tx_by_sig, expected_zero_hash, deposit_id); - - BOOST_CHECK(result.status == sysio::underwriter::solana_source_deposit_page_status::hard_mismatch); - BOOST_CHECK(!result.next_before); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_CASE(solana_source_deposit_scan_skips_failed_listing_transactions) try { - const uint64_t deposit_id = 15; - const auto expected_hash = test_expected_hash(); - const auto marker = marker_prefix(deposit_id) + hash_hex(expected_hash); - const std::string sig = "failed-listing"; - - const std::vector sigs{signature_entry(sig, "finalized", true)}; - const std::map tx_by_sig{ - {sig, transaction_with_logs(program_logs(test_sol_program_id, {marker}))}, - }; - - size_t fetch_count = 0; - const auto result = scan_test_page(sigs, tx_by_sig, expected_hash, deposit_id, &fetch_count); - - BOOST_CHECK(result.status == sysio::underwriter::solana_source_deposit_page_status::not_found); - BOOST_CHECK_EQUAL(fetch_count, 0); -} FC_LOG_AND_RETHROW(); - -// ── SwapDeposit correlation-hash preimage ────────────────────────────── -// -// Both outposts hash the user's accepted `target_amount` -// (`ReserveManagerLib.hashSwapDeposit` on EVM, -// `swap_correlation_hash` on SVM). The depot re-prices `dst_amount` at -// ingestion, so the two diverge whenever the caller's target is not -// exactly the depot's quote — which is the normal case, and precisely -// what `variance_tolerance_bps` exists to bound. - -/// The EVM leg of a real ETH→SOL underwritten swap, captured from a live -/// cluster run. `on_chain_swap_deposit_hash` is the `SwapDeposit` event's -/// hash as `ReserveManager` emitted it. -const std::vector production_depositor = - depositor_bytes("cf2d5b3cbb4d7bf04e3f7bfa8e27081b52191f91"); -constexpr uint64_t production_source_amount = 100000000; -constexpr uint64_t production_source_token_code = 23373212024832; -constexpr uint64_t production_source_reserve_code = 71615576876608; -constexpr uint64_t production_target_chain_code = 84606581215232; -constexpr uint64_t production_target_token_code = 84606560763904; -constexpr uint64_t production_target_reserve_code = 71615576876608; -constexpr uint64_t production_target_amount = 98039214; -constexpr uint64_t production_settlement_quote = 97747972; -constexpr uint32_t production_tolerance_bps = 50; -constexpr auto on_chain_swap_deposit_hash = - "fd8f16aad2443acf2847c6f49c41feac6f32feadc87682df25835ae328a76695"; - -/// Build the production preimage, varying only the destination amount. -sysio::underwriter::source_deposit_hash_input production_hash_input(uint64_t destination_amount) { - return { - .depositor = std::span{production_depositor}, - .source_amount = production_source_amount, - .source_token_code = production_source_token_code, - .source_reserve_code = production_source_reserve_code, - .target_chain_code = production_target_chain_code, - .target_token_code = production_target_token_code, - .target_reserve_code = production_target_reserve_code, - .target_amount = destination_amount, - .target_tolerance_bps = production_tolerance_bps, - }; -} - -/// The recomputed hash matches the outpost's only when the preimage carries -/// the caller's `target_amount`. -BOOST_AUTO_TEST_CASE(source_deposit_hash_binds_the_callers_target_amount) try { - const auto recomputed = sysio::underwriter::source_deposit_hash( - production_hash_input(production_target_amount)); - - BOOST_CHECK_EQUAL(hash_hex(recomputed), on_chain_swap_deposit_hash); -} FC_LOG_AND_RETHROW(); - -/// The depot's re-priced settlement quote is NOT what the outpost hashed; -/// feeding it in reproduces the divergence that stalls every UWREQ whose -/// target differs from the quote. -BOOST_AUTO_TEST_CASE(source_deposit_hash_rejects_the_depot_settlement_quote) try { - BOOST_REQUIRE_NE(production_settlement_quote, production_target_amount); - - const auto recomputed = sysio::underwriter::source_deposit_hash( - production_hash_input(production_settlement_quote)); - - BOOST_CHECK_NE(hash_hex(recomputed), on_chain_swap_deposit_hash); -} FC_LOG_AND_RETHROW(); - -BOOST_AUTO_TEST_SUITE_END() diff --git a/plugins/underwriter_plugin/test/test_underwriter_routing.cpp b/plugins/underwriter_plugin/test/test_underwriter_routing.cpp deleted file mode 100644 index 6569bf46b1..0000000000 --- a/plugins/underwriter_plugin/test/test_underwriter_routing.cpp +++ /dev/null @@ -1,233 +0,0 @@ -#include - -#include - -#include -#include -#include - -/** - * SEC-13 / WSA-027 regression tests for the underwriter plugin's exact-chain - * routing/accounting keys. - * - * The plugin must key its credit buckets and its local commit de-dup by the - * EXACT slug codes — `(chain_code, token_code[, reserve_code])` — NOT the - * coarse `(ChainKind, TokenKind)` VM family. Otherwise two active chains of the - * same family (e.g. two EVM outposts) collapse onto one key: their collateral - * merges into a single bucket and a confirmed commit on one suppresses the - * still-needed commit on the other. These tests pin the exact-keying on the - * pure helpers lifted into `routing_detail.hpp`. - * - * Boost.Test module is defined once in `test/main.cpp`; this file only adds a - * suite. - */ - -using namespace sysio::underwriter_detail; - -namespace { -// Stand-in `fc::slug_name` values. Real codes are packed `[A-Z0-9_]` (≤8 chars), -// but the helpers treat them as opaque `uint64`, so any distinct values -// exercise the keying. ETH and EVM2 model two DISTINCT chains of the SAME VM -// family (both EVM) — the exact scenario WSA-027 is about. -constexpr uint64_t ETH = 0xE1; // chain_code, e.g. "ETHEREUM" -constexpr uint64_t EVM2 = 0xE2; // a SECOND active EVM chain, e.g. "POLYGON" -constexpr uint64_t USDC = 0x70; // token_code -constexpr uint64_t PRIM = 0x91; // reserve_code, e.g. "PRIMARY" -constexpr uint64_t SEC2 = 0x92; // reserve_code, e.g. "SECOND" - -// A non-participating leg (no outpost, no bond) — the shape of a WIRE depot leg. -const leg_bond NO_LEG{{0, 0}, 0}; - -// Pre-built bucket keys. Named so they can appear inside BOOST_* macros: a bare -// braced `{ETH, USDC}` inside a macro argument is split on its comma by the -// preprocessor ("too many arguments to function-like macro invocation"). -const bucket_key B_ETH_USDC { ETH, USDC }; -const bucket_key B_EVM2_USDC{ EVM2, USDC }; -} // namespace - -BOOST_AUTO_TEST_SUITE(underwriter_routing_tests) - -// -- commit_key: exact-identity de-dup -- - -BOOST_AUTO_TEST_CASE(commit_key_distinguishes_same_kind_chains) { - // Two legs identical but for chain_code: a confirmed commit on ETH must NOT - // suppress the still-needed commit on a second EVM chain. - const commit_key on_eth { /*uwreq*/ 1, ETH, USDC, PRIM }; - const commit_key on_evm2{ /*uwreq*/ 1, EVM2, USDC, PRIM }; - BOOST_CHECK(on_eth != on_evm2); - - std::set confirmed; - confirmed.insert(on_eth); - BOOST_CHECK(confirmed.contains(on_eth)); - BOOST_CHECK(!confirmed.contains(on_evm2)); // the EVM2 leg is still open -} - -BOOST_AUTO_TEST_CASE(commit_key_distinguishes_reserves_on_same_chain_token) { - // Same (uwreq, chain, token) but different reserve = distinct legs (a - // same-(chain, token) swap with two reserves). - const commit_key prim{7, ETH, USDC, PRIM}; - const commit_key sec {7, ETH, USDC, SEC2}; - BOOST_CHECK(prim != sec); - - std::set confirmed{prim}; - BOOST_CHECK(confirmed.contains(prim)); - BOOST_CHECK(!confirmed.contains(sec)); -} - -BOOST_AUTO_TEST_CASE(commit_key_identical_legs_dedup) { - const commit_key a{3, ETH, USDC, PRIM}; - const commit_key b{3, ETH, USDC, PRIM}; - BOOST_CHECK(a == b); - std::set s; - s.insert(a); - s.insert(b); - BOOST_CHECK_EQUAL(s.size(), 1u); // same leg recorded once -} - -// -- credit buckets: per-exact-chain capacity -- - -BOOST_AUTO_TEST_CASE(same_kind_chains_have_independent_credit) { - // Underwriter holds 100 USDC collateral on EACH of two EVM chains. - credit_buckets credit{ - {B_ETH_USDC, 100}, - {B_EVM2_USDC, 100}, - }; - - // A 100 swap leg on EVM2 debits ONLY the EVM2 bucket; ETH is untouched. - const leg_bond on_evm2{B_EVM2_USDC, 100}; - BOOST_REQUIRE(try_debit_buckets(credit, on_evm2, NO_LEG)); - BOOST_CHECK_EQUAL(credit[B_EVM2_USDC], 0u); - BOOST_CHECK_EQUAL(credit[B_ETH_USDC], 100u); // NOT shared/collapsed -} - -BOOST_AUTO_TEST_CASE(insufficient_on_one_chain_does_not_borrow_from_same_kind) { - // 10 on EVM2, 100 on ETH. A 100 leg on EVM2 must FAIL — it cannot borrow - // ETH's collateral just because both chains are EVM. (The pre-fix collapse - // merged them into one 110 bucket and would have wrongly succeeded.) - credit_buckets credit{ - {B_ETH_USDC, 100}, - {B_EVM2_USDC, 10}, - }; - const leg_bond on_evm2{B_EVM2_USDC, 100}; - BOOST_CHECK(!try_debit_buckets(credit, on_evm2, NO_LEG)); - // `remaining` untouched on failure. - BOOST_CHECK_EQUAL(credit[B_EVM2_USDC], 10u); - BOOST_CHECK_EQUAL(credit[B_ETH_USDC], 100u); -} - -BOOST_AUTO_TEST_CASE(same_bucket_dual_leg_needs_combined_balance) { - // ERC20 → native on ONE chain: both legs share the (chain, token) bucket, - // so the single row must cover the SUM, not each leg independently. - credit_buckets credit{{B_ETH_USDC, 150}}; - const leg_bond src{B_ETH_USDC, 100}; - const leg_bond dst{B_ETH_USDC, 100}; // 200 > 150 → must fail - BOOST_CHECK(!try_debit_buckets(credit, src, dst)); - BOOST_CHECK_EQUAL(credit[B_ETH_USDC], 150u); - - credit[B_ETH_USDC] = 200; - BOOST_REQUIRE(try_debit_buckets(credit, src, dst)); - BOOST_CHECK_EQUAL(credit[B_ETH_USDC], 0u); -} - -BOOST_AUTO_TEST_CASE(same_bucket_dual_leg_sum_cannot_overflow) { - // Two same-bucket legs whose bond requirements sum past UINT64_MAX must be - // rejected, never wrapped to a small, coverable-looking value. Before the - // 128-bit combine, `UINT64_MAX + 1` folded to 0 and the request debited the - // bucket as if free. - constexpr uint64_t U64_MAX = std::numeric_limits::max(); - credit_buckets credit{{B_ETH_USDC, 100}}; - const leg_bond src{B_ETH_USDC, U64_MAX}; - const leg_bond dst{B_ETH_USDC, 1}; // U64_MAX + 1 wraps to 0 in 64-bit - BOOST_CHECK(!try_debit_buckets(credit, src, dst)); - BOOST_CHECK_EQUAL(credit[B_ETH_USDC], 100u); // untouched on failure - - // Even a bucket holding the max possible uint64 balance cannot cover a draw - // that overflows: the row can never exceed UINT64_MAX < (U64_MAX + 1). - credit[B_ETH_USDC] = U64_MAX; - BOOST_CHECK(!try_debit_buckets(credit, src, dst)); - BOOST_CHECK_EQUAL(credit[B_ETH_USDC], U64_MAX); -} - -BOOST_AUTO_TEST_CASE(depot_leg_requires_no_bucket) { - // A to-WIRE swap has one real leg + one depot leg (require 0). The depot leg - // consults no bucket; only the real leg must cover. - credit_buckets credit{{B_ETH_USDC, 100}}; - const leg_bond real{B_ETH_USDC, 100}; - BOOST_REQUIRE(try_debit_buckets(credit, real, NO_LEG)); - BOOST_CHECK_EQUAL(credit[B_ETH_USDC], 0u); - - // both-depot (degenerate) is rejected — there is nothing to underwrite. - BOOST_CHECK(!try_debit_buckets(credit, NO_LEG, NO_LEG)); -} - -BOOST_AUTO_TEST_CASE(every_eventual_outpost_lock_requires_credit) { - const bucket_key bucket{.chain_code = 101, .token_code = 202}; - const auto outpost = candidate_leg_bond( - bucket, 50, /*is_depot=*/false); - const auto depot = candidate_leg_bond( - bucket, 50, /*is_depot=*/true); - - BOOST_CHECK(outpost.bucket == bucket); - BOOST_CHECK_EQUAL(outpost.require, 50u); - BOOST_CHECK_EQUAL(depot.require, 0u); - - // Pre-validation budgets the full winner-time lock even when one leg's UIC - // is already stored or locally confirmed. Those states avoid another paid - // outpost submission, but neither has consumed collateral on the depot. - credit_buckets insufficient{{bucket, 49}}; - BOOST_CHECK(!try_debit_buckets(insufficient, outpost, depot)); - BOOST_CHECK_EQUAL(insufficient[bucket], 49u); - - credit_buckets exact{{bucket, 50}}; - BOOST_CHECK(try_debit_buckets(exact, outpost, depot)); - BOOST_CHECK_EQUAL(exact[bucket], 0u); -} - -BOOST_AUTO_TEST_CASE(fully_local_candidate_reserves_before_new_submission) { - const stored_commit_plan absent{ - .submit_source = true, - .submit_destination = true, - }; - BOOST_CHECK(!has_submission_work( - absent, /*source_confirmed_locally=*/true, - /*destination_confirmed_locally=*/true)); - - credit_buckets remaining{{B_ETH_USDC, 100}}; - reserve_buckets( - remaining, leg_bond{B_ETH_USDC, 100}, NO_LEG); - BOOST_CHECK_EQUAL(remaining[B_ETH_USDC], 0u); - BOOST_CHECK(!try_debit_buckets( - remaining, leg_bond{B_ETH_USDC, 1}, NO_LEG)); -} - -BOOST_AUTO_TEST_CASE(stored_complete_candidate_reserves_while_awaiting_external_replay) { - credit_buckets credit{{B_ETH_USDC, 100}}; - const leg_bond src{B_ETH_USDC, 40}; - const leg_bond dst{B_ETH_USDC, 60}; - - reserve_buckets(credit, src, dst); - BOOST_CHECK_EQUAL(credit[B_ETH_USDC], 0u); - BOOST_CHECK(!try_debit_buckets(credit, leg_bond{B_ETH_USDC, 1}, NO_LEG)); -} - -BOOST_AUTO_TEST_CASE(mixed_stored_local_candidate_reserves_full_bond) { - const auto partial = plan_stored_commits( - /*candidate_exists=*/true, - /*intent_submitted=*/true, - /*source_is_depot=*/false, - /*destination_is_depot=*/false, - /*source_uic_stored=*/true, - /*destination_uic_stored=*/false); - BOOST_CHECK(!has_submission_work( - partial, /*source_confirmed_locally=*/false, - /*destination_confirmed_locally=*/true)); - - credit_buckets remaining{{B_ETH_USDC, 100}}; - reserve_buckets( - remaining, leg_bond{B_ETH_USDC, 40}, leg_bond{B_ETH_USDC, 60}); - BOOST_CHECK_EQUAL(remaining[B_ETH_USDC], 0u); - BOOST_CHECK(!try_debit_buckets( - remaining, leg_bond{B_ETH_USDC, 1}, NO_LEG)); -} - -BOOST_AUTO_TEST_SUITE_END() diff --git a/plugins/underwriter_plugin/test/test_underwriter_sync.cpp b/plugins/underwriter_plugin/test/test_underwriter_sync.cpp deleted file mode 100644 index c8b55f8e69..0000000000 --- a/plugins/underwriter_plugin/test/test_underwriter_sync.cpp +++ /dev/null @@ -1,147 +0,0 @@ -/** - * @file test_underwriter_sync.cpp - * @brief Unit tests for the underwriter plugin's sync-gate lifecycle surface — - * the `/v1/underwriter/` gate payloads served until the deferred startup - * completes, and the terminal-failure classification behind the fail-fast - * shutdown. The sync predicate itself is the first-class chain_plugin - * gate; its tests live in `plugins/chain_plugin/test/test_sync_gate.cpp`. - */ -#include - -#include -#include - -BOOST_AUTO_TEST_SUITE(underwriter_sync_tests) - -// ── is_terminal_failure: the fail-fast trigger behind the node shutdown ── - -BOOST_AUTO_TEST_CASE(terminal_failure_states_are_exactly_the_failed_states) { - using sysio::underwriter_detail::is_terminal_failure; - using sysio::underwriter_detail::startup_state; - // Non-failures: the pre-arm wait, the bounded transient retry, and success. - BOOST_TEST(!is_terminal_failure(startup_state::waiting_for_sync)); - BOOST_TEST(!is_terminal_failure(startup_state::preflight_retrying)); - BOOST_TEST(!is_terminal_failure(startup_state::active)); - // Failures: each one must shut the node down (fail-fast). - BOOST_TEST(is_terminal_failure(startup_state::preflight_failed)); - BOOST_TEST(is_terminal_failure(startup_state::wiring_failed)); - BOOST_TEST(is_terminal_failure(startup_state::startup_failed)); -} - -// ── startup_gate_payload: the /v1/underwriter/* body served until the ── -// ── deferred startup body completes (registration is unconditional at ── -// ── plugin_startup, so these payloads are what a cold-boot query sees) ── -// -// Wire-format pinning: the static_asserts below pin each shared `field::` -// KEY constant to its literal wire spelling once (a typo'd or renamed -// constant would otherwise change the HTTP format with builder and tests -// agreeing tautologically); every assertion after that is free to use the -// constants. The `status` VALUES stay literal in the cases for the same -// reason — an enum-member rename that would silently change the wire format -// fails here. - -using sysio::underwriter_detail::startup_gate_payload; -using sysio::underwriter_detail::startup_state; -namespace field = sysio::underwriter_detail::field; - -static_assert(std::string_view{field::status} == "status"); -static_assert(std::string_view{field::head_behind_sec} == "head_behind_sec"); -static_assert(std::string_view{field::lib_behind_sec} == "lib_behind_sec"); -static_assert(std::string_view{field::detail} == "detail"); - -BOOST_AUTO_TEST_CASE(gate_payload_waiting_for_sync_reports_behind_gaps) { - const auto body = startup_gate_payload(startup_state::waiting_for_sync, - fc::seconds(204), fc::seconds(206)) - .get_object(); - BOOST_TEST(body[field::status].as_string() == "waiting_for_sync"); - BOOST_TEST(body[field::head_behind_sec].as_int64() == 204); - BOOST_TEST(body[field::lib_behind_sec].as_int64() == 206); - BOOST_TEST(!body.contains(field::detail)); -} - -BOOST_AUTO_TEST_CASE(gate_payload_no_root_omits_lib_gap) { - // Before the fork database has a root the caller passes an empty optional - // and the key is omitted — an in-band sentinel would collide with real - // negative (clock-skew) gaps after whole-second truncation. - const auto body = startup_gate_payload(startup_state::waiting_for_sync, - fc::seconds(3), std::nullopt) - .get_object(); - BOOST_TEST(body[field::head_behind_sec].as_int64() == 3); - BOOST_TEST(!body.contains(field::lib_behind_sec)); -} - -BOOST_AUTO_TEST_CASE(gate_payload_passes_negative_skew_gaps_through) { - // A block time ahead of local wall clock (clock skew) yields a negative - // gap; it is reported as-is — the skew is itself diagnostic signal. - const auto body = startup_gate_payload(startup_state::waiting_for_sync, - fc::milliseconds(-1500), fc::milliseconds(-1500)) - .get_object(); - BOOST_TEST(body[field::head_behind_sec].as_int64() == -1); - BOOST_TEST(body[field::lib_behind_sec].as_int64() == -1); -} - -BOOST_AUTO_TEST_CASE(gate_payload_truncates_to_whole_seconds) { - // Durations travel as fc::microseconds; the JSON boundary emits whole - // seconds. - const auto body = startup_gate_payload(startup_state::waiting_for_sync, - fc::milliseconds(2500), fc::milliseconds(1999)) - .get_object(); - BOOST_TEST(body[field::head_behind_sec].as_int64() == 2); - BOOST_TEST(body[field::lib_behind_sec].as_int64() == 1); -} - -BOOST_AUTO_TEST_CASE(gate_payload_preflight_failed_carries_detail) { - const auto body = startup_gate_payload(startup_state::preflight_failed, - fc::microseconds(0), std::nullopt) - .get_object(); - BOOST_TEST(body[field::status].as_string() == "preflight_failed"); - BOOST_TEST(body[field::detail].as_string() == - std::string{sysio::underwriter_detail::preflight_failed_detail}); - BOOST_TEST(!body.contains(field::head_behind_sec)); - BOOST_TEST(!body.contains(field::lib_behind_sec)); -} - -BOOST_AUTO_TEST_CASE(gate_payload_wiring_failed_carries_detail) { - const auto body = startup_gate_payload(startup_state::wiring_failed, - fc::microseconds(0), std::nullopt) - .get_object(); - BOOST_TEST(body[field::status].as_string() == "wiring_failed"); - BOOST_TEST(body[field::detail].as_string() == - std::string{sysio::underwriter_detail::wiring_failed_detail}); - BOOST_TEST(!body.contains(field::head_behind_sec)); - BOOST_TEST(!body.contains(field::lib_behind_sec)); -} - -BOOST_AUTO_TEST_CASE(gate_payload_startup_failed_carries_detail) { - // The catch-all terminal state: the deferred startup body threw past the - // specific preflight/wiring failure paths. - const auto body = startup_gate_payload(startup_state::startup_failed, - fc::microseconds(0), std::nullopt) - .get_object(); - BOOST_TEST(body[field::status].as_string() == "startup_failed"); - BOOST_TEST(body[field::detail].as_string() == - std::string{sysio::underwriter_detail::startup_failed_detail}); - BOOST_TEST(!body.contains(field::head_behind_sec)); - BOOST_TEST(!body.contains(field::lib_behind_sec)); -} - -BOOST_AUTO_TEST_CASE(gate_payload_preflight_retrying_is_status_only) { - const auto body = - startup_gate_payload(startup_state::preflight_retrying, fc::microseconds(0), std::nullopt) - .get_object(); - BOOST_TEST(body[field::status].as_string() == "preflight_retrying"); - BOOST_TEST(body.size() == 1u); -} - -BOOST_AUTO_TEST_CASE(gate_payload_active_is_status_only) { - const auto body = startup_gate_payload(startup_state::active, - fc::seconds(99), fc::seconds(99)) - .get_object(); - BOOST_TEST(body[field::status].as_string() == "active"); - BOOST_TEST(body.size() == 1u); -} - -// `active_status` is pinned at compile time next to its definition in -// sync_detail.hpp (static_assert), so no runtime case is needed for it. - -BOOST_AUTO_TEST_SUITE_END() diff --git a/programs/nodeop/main.cpp b/programs/nodeop/main.cpp index e96ed19b41..c893279b3f 100644 --- a/programs/nodeop/main.cpp +++ b/programs/nodeop/main.cpp @@ -20,7 +20,6 @@ #include #include #include -#include using namespace appbase; using namespace sysio; @@ -47,7 +46,6 @@ int main(int argc, char** argv) application_base::register_plugin(); application_base::register_plugin(); application_base::register_plugin(); - application_base::register_plugin(); auto r = exe.init< resource_monitor_plugin, chain_plugin, diff --git a/tests/fixtures/solana-idl-opp-outpost-stub.json b/tests/fixtures/solana-idl-opp-outpost-stub.json index 69451a536d..ef645d4cbb 100644 --- a/tests/fixtures/solana-idl-opp-outpost-stub.json +++ b/tests/fixtures/solana-idl-opp-outpost-stub.json @@ -162,58 +162,6 @@ } ] }, - { - "name": "deposit", - "discriminator": [ - 242, - 35, - 198, - 137, - 82, - 225, - 242, - 182 - ], - "accounts": [ - { - "name": "depositor", - "writable": true, - "signer": true - }, - { - "name": "config" - }, - { - "name": "operator_registry", - "writable": true - }, - { - "name": "outbound_message_buffer", - "writable": true - }, - { - "name": "vault", - "writable": true - }, - { - "name": "system_program" - } - ], - "args": [ - { - "name": "operator_type", - "type": "u32" - }, - { - "name": "token_code", - "type": "u64" - }, - { - "name": "amount", - "type": "u64" - } - ] - }, { "name": "cleanup_envelope_chunks", "discriminator": [ @@ -298,14 +246,6 @@ "name": "latest_outbound_envelope", "writable": true }, - { - "name": "vault", - "writable": true - }, - { - "name": "reserve_aggregate", - "writable": true - }, { "name": "system_program" } @@ -416,19 +356,6 @@ 182 ] }, - { - "name": "Reserve", - "discriminator": [ - 43, - 242, - 204, - 202, - 26, - 247, - 59, - 127 - ] - }, { "name": "OperatorRegistry", "discriminator": [ @@ -480,6 +407,19 @@ 4, 103 ] + }, + { + "name": "LatestOutboundEnvelope", + "discriminator": [ + 74, + 80, + 163, + 159, + 178, + 236, + 249, + 15 + ] } ], "types": [ @@ -574,88 +514,6 @@ ] } }, - { - "name": "Reserve", - "type": { - "kind": "struct", - "fields": [ - { - "name": "bump", - "type": "u8" - }, - { - "name": "token_code", - "type": "u64" - }, - { - "name": "reserve_code", - "type": "u64" - }, - { - "name": "external_token_amount", - "type": "u64" - }, - { - "name": "requested_wire_amount", - "type": "u64" - }, - { - "name": "connector_weight_bps", - "type": "u32" - }, - { - "name": "status", - "type": { - "defined": { - "name": "ReserveStatus" - } - } - }, - { - "name": "creator", - "type": "pubkey" - }, - { - "name": "custody_mint", - "type": "pubkey" - }, - { - "name": "custody_decimals", - "type": "u8" - }, - { - "name": "custody_token_program", - "type": "pubkey" - }, - { - "name": "name_len", - "type": "u8" - }, - { - "name": "name_bytes", - "type": { - "array": [ - "u8", - 64 - ] - } - }, - { - "name": "description_len", - "type": "u16" - }, - { - "name": "description_bytes", - "type": { - "array": [ - "u8", - 256 - ] - } - } - ] - } - }, { "name": "OperatorDelivery", "type": { @@ -840,6 +698,37 @@ } ] } + }, + { + "name": "LatestOutboundEnvelope", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "epoch_index", + "type": "u32" + }, + { + "name": "checksum", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "data", + "type": { + "vec": "u8" + } + } + ] + } } ] }