diff --git a/OVERVIEW.md b/OVERVIEW.md index a27b66d7df..a545d551a3 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -92,7 +92,7 @@ Batch operators post depot-native collateral on `sysio.opreg`; a non-canonical d 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()`. ### 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. +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. Any depot account can underwrite with the `batch_operator_plugin`'s underwriter role, which bonds an envelope only once the outpost confirms it. ### 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. diff --git a/PLUGINS.md b/PLUGINS.md index 78a5bf0c58..537812f9b1 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -12,7 +12,7 @@ documents what it does, how it works, and how to configure it. The plugin lifecy | Plugin | Description | Docs | |---|---|---| -| `batch_operator_plugin` | Cranks depot and outpost contracts, ferrying OPP message chains between the WIRE chain and the external blockchains (Ethereum, Solana) on the epoch schedule | [README](plugins/batch_operator_plugin/README.md) | +| `batch_operator_plugin` | Cranks depot and outpost contracts, ferrying OPP message chains between the WIRE chain and the external blockchains (Ethereum, Solana) on the epoch schedule; can also run an underwriter, which bonds `sysio.synd` envelope requests once the outpost confirms them | [README](plugins/batch_operator_plugin/README.md) | | `chain_api_plugin` | Publishes chain_plugin's read and write APIs as the `/v1/chain/*` HTTP endpoints | [README](plugins/chain_api_plugin/README.md) | | `chain_plugin` | Owns the controller (block log, chain state, fork database, WASM runtime) and relays its signals onto the appbase channels declared in `chain_interface` | [README](plugins/chain_plugin/README.md) | | `cron_plugin` | Provides an in-process cron scheduler that other plugins use to run functions on cron-style schedules | [README](plugins/cron_plugin/README.md) | diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index 3a52be73fa..23b5d2205c 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -35,8 +35,11 @@ #include #include +#include + #include #include +#include #include #include #include @@ -2204,6 +2207,98 @@ 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 flow the batch_operator_plugin underwriter drives, with zero fees and bounty and one account bonding every +// request in full. The request a delivery opens carries the statement the underwriter verifies (the outpost, the +// epoch and the digest `sysio.msgch` accepted); bonding releases the syndication on the next queue step; once the +// challenge window passes anyone approves, and the underwriter's claim returns the whole bond. +BOOST_FIXTURE_TEST_CASE(a_bond_releases_the_envelope_and_comes_back_after_the_window, 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 uint32_t window_sec = 10800; + bootstrap_for_dispatch(); + 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}); + 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()); + 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", window_sec)("bounty", 0)("challenge_extra", 0) + ("min_desyndicate", 1))); + setup_wire_token(); + enable_epoch_advancement(); + const auto request_of = [&](uint64_t id) { + return bond_abi.binary_to_variant("request_row", get_row_by_id(BOND_ACCOUNT, BOND_ACCOUNT, "requests"_n, id), + abi_serializer::create_yield_function(abi_serializer_max_time)); + }; + + const auto eth = fc::slug_name{"ETH"}.value; + const auto liqeth = fc::slug_name{"LIQETH"}.value; + const uint32_t epoch = current_epoch(); + const auto delivered = encode_envelope_with_mixed_attestations(epoch, { + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, ChainKind::CHAIN_KIND_EVM, user_pubkey, liqeth, + 10 * LIQ_UNIT, 1, 10 * LIQ_UNIT)}, + }); + BOOST_REQUIRE_EQUAL(success(), deliver(eth, delivered)); + produce_block(); + + const uint64_t id = synd_envelope("ETH", "LIQETH", epoch)["request_id"].as_uint64(); + BOOST_REQUIRE_GT(id, 0u); + auto request = request_of(id); + BOOST_REQUIRE_EQUAL("OPEN", request["state"].as_string()); + BOOST_REQUIRE_EQUAL("sysio.synd", request["issuer"].as_string()); + BOOST_REQUIRE_EQUAL("oppenvelope", request["schema"].as_string()); + BOOST_REQUIRE_EQUAL(10 * LIQ_UNIT, request["covered"].as_int64()); + // The statement: chain code, epoch, accepted digest and token code, little-endian, 52 bytes. + std::vector statement(52); + auto* p = reinterpret_cast(statement.data()); + boost::endian::store_little_u64(p, eth); + boost::endian::store_little_u32(p + 8, epoch); + const auto digest = accepted_envelope_digest(eth); + std::memcpy(p + 12, digest.data(), 32); + boost::endian::store_little_u64(p + 44, liqeth); + BOOST_CHECK(statement == request["statement"].as>()); + // The underwriter bonds a statement only when the outpost's record of the envelope carries its digest. An outpost + // records keccak256 of the canonical bytes it emits (`envelope_hash` empty): the same digest. + sysio::opp::Envelope emitted; + BOOST_REQUIRE(emitted.ParseFromArray(delivered.data(), static_cast(delivered.size()))); + BOOST_CHECK(std::memcmp(oracle::epoch_digest(emitted).data(), digest.data(), 32) == 0); + BOOST_CHECK_EQUAL(0, liq_balance(USER)); // held until underwritten + + // The underwriter bonds the whole request; a crank releases the syndication rather than the next envelope's + // step. + BOOST_REQUIRE_EQUAL(success(), mint_shadow(UNDERWRITER, 10 * LIQ_UNIT)); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi, UNDERWRITER, "accept"_n, mvo() + ("underwriter", UNDERWRITER)("request_id", id)("amount", 10 * LIQ_UNIT))); + BOOST_REQUIRE_EQUAL(success(), push(SYND_ACCOUNT, synd_abi, UNDERWRITER, "crank"_n, mvo()("limit", 16))); + produce_block(); + BOOST_CHECK_EQUAL("BONDED", request_of(id)["state"].as_string()); + BOOST_CHECK_EQUAL(10 * LIQ_UNIT, liq_balance(USER)); + BOOST_CHECK_EQUAL(0, liq_balance(UNDERWRITER)); + BOOST_CHECK_EQUAL("DONE", synd_envelope("ETH", "LIQETH", epoch)["state"].as_string()); + + // The bond stays at risk for the whole window, then anyone approves and the underwriter claims it back. + BOOST_CHECK_EQUAL(wasm_assert_msg("challenge window has not passed"), + push(BOND_ACCOUNT, bond_abi, USER, "approve"_n, mvo()("request_id", id))); + produce_block(fc::seconds(window_sec)); + produce_block(); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi, USER, "approve"_n, mvo()("request_id", id))); + BOOST_REQUIRE_EQUAL(success(), push(BOND_ACCOUNT, bond_abi, UNDERWRITER, "claim"_n, mvo() + ("request_id", id)("account", UNDERWRITER))); + produce_block(); + BOOST_CHECK_EQUAL("APPROVED", request_of(id)["state"].as_string()); + BOOST_CHECK_EQUAL(10 * LIQ_UNIT, liq_balance(UNDERWRITER)); + BOOST_CHECK_EQUAL(10 * LIQ_UNIT, liq_balance(USER)); +} 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) { diff --git a/libraries/libfc/include/fc/crypto/sha256.hpp b/libraries/libfc/include/fc/crypto/sha256.hpp index 242a9b43ab..9bde1eed54 100644 --- a/libraries/libfc/include/fc/crypto/sha256.hpp +++ b/libraries/libfc/include/fc/crypto/sha256.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -22,7 +23,7 @@ class sha256 : public add_packhash_to_hash using byte_array_type = std::array; sha256(); - explicit sha256( const std::string& hex_str ); + explicit sha256( std::string_view hex_str ); explicit sha256( const hash_array_type& hash_arr ); explicit sha256( const char *data, size_t size ); diff --git a/libraries/libfc/src/crypto/sha256.cpp b/libraries/libfc/src/crypto/sha256.cpp index 8d26739029..72ed4ccb6d 100644 --- a/libraries/libfc/src/crypto/sha256.cpp +++ b/libraries/libfc/src/crypto/sha256.cpp @@ -17,7 +17,7 @@ namespace fc { FC_THROW_EXCEPTION( exception, "sha256: size mismatch" ); memcpy(_hash, data, size ); } - sha256::sha256( const std::string& hex_str ) { + sha256::sha256( std::string_view hex_str ) { auto bytes_written = fc::from_hex( hex_str, (char*)_hash, sizeof(_hash) ); if( bytes_written < sizeof(_hash) ) memset( (char*)_hash + bytes_written, 0, (sizeof(_hash) - bytes_written) ); diff --git a/plugins/batch_operator_plugin/README.md b/plugins/batch_operator_plugin/README.md index 0453c9441e..5930f639ca 100644 --- a/plugins/batch_operator_plugin/README.md +++ b/plugins/batch_operator_plugin/README.md @@ -41,8 +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. The plugin does not crank -`sysio.synd::crank`; syndication release is driven by keepers. +`sysio.swap` and `sysio.liq` are deployed on the depot. `sysio.synd::crank` is pushed +by the underwriter every poll (see [Underwriter](#underwriter)). ## Configuration @@ -52,11 +52,34 @@ The two yield cranks stay idle, without logging a read failure per poll, until b | `--batch-epoch-poll-ms` | 15000 | How often to check epoch state (ms) | | `--batch-delivery-timeout-ms` | 15000 | Max time to wait for chain delivery confirmation (ms) | | `--batch-yield-tick-interval-ms` | 60000 | Minimum spacing between this operator's `sysio.swap::tickyield` pushes per yield pool (ms) | +| `--batch-underwriter-account` | none | `account` or `account@permission` (default `active`) the underwriter bonds from. Configuring it enables the underwriter | +| `--batch-underwriter-max-exposure` | none | Most the underwriter may have bonded at once in one shadow token, as an asset (`100.000000000 LIQETH`). Repeat per token; a token without one is not underwritten | There is no separate enable flag: the relay runs when `--batch-operator-account` -is configured, the way `producer_plugin` keys off `--producer-name`. The plugin -must also be listed under `plugin =` (or pulled in as a dependency by -`external_debugging_plugin`), and requires `read-mode = irreversible`. +is configured, the way `producer_plugin` keys off `--producer-name`, and the +underwriter when `--batch-underwriter-account` is; a node may run either or both, +and the poll and timeout options apply to both. The plugin must also be listed under +`plugin =` (or pulled in as a dependency by `external_debugging_plugin`), and +requires `read-mode = irreversible`. + +Each role signs with the one operator-configured WIRE signature provider, of any key +type, whose key alone satisfies its authorization on chain: `@active` for +the relay, the configured permission for the underwriter. At startup the node also +checks that the key's signatures recover to it and that the authorization may +declare every action the role pushes (`linkauth`). A role that fails either check, +or matches no provider or more than one, stops the node. + +The node exempts the accounts it signs as from subjective CPU billing: both roles +push on a schedule that loses some pushes (a race another operator won, an action +the irreversible view does not show done yet), and billed failures would soon have +the node refuse the account's transactions. The exemption also stops the node from +throttling anyone else's failing transactions that name those accounts, so an +operator node must not expose its transaction push API publicly. + +Signers are chosen once, at startup. Each poll the node checks that each role's key +still satisfies its authorization; after `updateauth` rotates it, the role stops +pushing (the underwriter) or logs (the relay) until the node is restarted with the +new key. ### Outpost wiring @@ -82,6 +105,71 @@ fail-closed and picked up on a later tick. A `setoutpost` redeploy is likewise picked up on the next epoch tick: the relay job is rebuilt against the new address rather than left pointing at the old one. +## Underwriter + +Any depot account can run the underwriter, with or without the relay, to bond the +requests `sysio.synd` issues for syndication envelopes. Each poll it: + +1. **Verifies each statement against its outpost.** An OPEN request states the + outpost, the depot epoch and the digest of the envelope the depot accepted. The + outpost keeps its own write-once record of each envelope it emitted, the keccak256 + of its canonical bytes (Ethereum `OPP.outboundEnvelopes`, Solana's outbound + `EnvelopeLog`); read at finality, that record must carry the statement's digest. + Whether the statement is true is checked against the outpost alone; amounts, + states and windows still come from the depot's tables. +2. **Bonds** each verified OPEN request `sysio.synd` issued under the `oppenvelope` + schema, oldest first and for its whole remainder (`sysio.bond::accept`), within + the token's exposure cap (bonds not yet claimed count) and the account's + `sysio.liq` balance. +3. **Cranks** `sysio.synd::crank`, which releases what bonding allows and issues the + next request: every poll while an envelope request is in play or has an outcome + `sysio.synd` has not acknowledged, otherwise every 5 minutes. +4. **Approves** its requests once their challenge window has passed by chain time, + and **claims** them once approved or ruled VALID, and again while yield the pool + could not yet cover is owed. Every 10 minutes it claims what a forfeited bond + earned, which pays it into `sysio.liq` so the request can be pruned, and prunes + `sysio.bond` and `sysio.synd`. + +What it cannot act on waits and is logged on every poll it holds; deduplicating +those lines is left to log tooling: + +| Condition | Effect | +|-----------|--------| +| An outpost cannot be read | That chain's requests wait | +| The outpost holds no final record for the statement's epoch | The request waits | +| The outpost recorded another digest for that epoch | Never bonded; an error log names both digests | +| The node cannot serve a chain (no RPC client, wrong chain id) | That chain's requests wait; the others go on | +| No cap, over the cap, or too little balance | The request waits | +| The `sysio.andon` cord is pulled | Nothing is bonded, approved or claimed until it clears | +| A request is challenged (HELD) | Reported, whether or not we bonded it: `sysio` rules it | +| A bond of ours is ruled INVALID while the node runs, even one since claimed or pruned | Bonding stops until a restart; approvals and claims go on. A forfeited bond's principal is not returned | +| A table read fails, or a row behind one of our bonds does not decode | The pass does nothing | + +### Underwriter setup + +On the depot, the underwriter account holds the shadow LIQ it bonds, RAM for its +bond rows, and CPU for a few pushes per poll. With a dedicated permission, link it to +exactly the actions it pushes, so the node's key can move the inventory nowhere +else: `sysio.bond::accept`, `approve`, `claim`, `prune` and `sysio.synd::crank`, +`pruneenv`. The WIRE its bonds earn is withdrawn with `sysio.bond::claimwire`, which +stays unlinked. + +The node needs an outpost RPC client for every active chain (each client spec names +a signing key, though the underwriter only reads), the Ethereum OPP ABI files (which +must declare `outboundEnvelopes`) and the Solana IDL (which must declare +`EnvelopeLog`). Use RPC endpoints independent of the batch operators': the +verification is only as good as the outpost reads. A request whose record has left +the outpost can no longer be verified and waits until `sysio` rules it. Ethereum keeps +200 epochs by default, but Solana keeps only its last 10 records, so a Solana request +must be verified within about 10 epochs. Before `sysio.chains::setoutpost` replaces an +active outpost's deployment, let that chain's `sysio.synd` envelopes drain, those +still waiting for a request included: the underwriter reads only the new deployment's +records. Run one underwriter node per account, and bond from that account by no +other means: a cap counts only the bonds its node makes. + +Size each cap for the most the token syndicates in one challenge window +(`window_sec`, 3 hours by default) plus the time it takes to claim. + ## Dependencies - `chain_plugin` — blockchain state access diff --git a/plugins/batch_operator_plugin/include/sysio/batch_operator_plugin/batch_operator_plugin.hpp b/plugins/batch_operator_plugin/include/sysio/batch_operator_plugin/batch_operator_plugin.hpp index 0400fdbe24..6357d223dc 100644 --- a/plugins/batch_operator_plugin/include/sysio/batch_operator_plugin/batch_operator_plugin.hpp +++ b/plugins/batch_operator_plugin/include/sysio/batch_operator_plugin/batch_operator_plugin.hpp @@ -18,6 +18,10 @@ namespace sysio { /// All 21 batch operators run this plugin in perpetuity. The epoch scheduler /// (sysio.epoch) assigns operators into 3 groups of 7. Each epoch, one group /// is elected; those 7 execute the full epoch cycle (outbound then inbound). + /// + /// With `batch-underwriter-account` it also runs, or only runs, the + /// underwriter: it bonds each sysio.synd envelope request once the outpost + /// confirms the envelope, then approves and claims the bond. class batch_operator_plugin : public appbase::plugin { public: APPBASE_PLUGIN_REQUIRES( diff --git a/plugins/batch_operator_plugin/src/async_action_completion.hpp b/plugins/batch_operator_plugin/src/async_action_completion.hpp index 065504d3e9..edf3fd9ccc 100644 --- a/plugins/batch_operator_plugin/src/async_action_completion.hpp +++ b/plugins/batch_operator_plugin/src/async_action_completion.hpp @@ -28,6 +28,9 @@ class async_action_completion { /// This may be called exactly once for each completion instance. std::future get_future() { return done.get_future(); } + /// Whether the push completed and the transaction succeeded. Read after the future is ready. + bool succeeded() const { return success.load(std::memory_order_acquire); } + /// Invokes `on_complete` once, suppresses callback exceptions, and signals /// the waiter without allowing duplicate asynchronous completions to throw. template @@ -58,10 +61,12 @@ class async_action_completion { const chain::next_function_variant& result, SuccessHandler&& on_success, FailureHandler&& on_failure) { - return complete([&result, &on_success, &on_failure] { + // `complete` runs this before returning, so the references cannot outlive what they name. + return complete([this, &result, &on_success, &on_failure] { if (const auto* error = std::get_if(&result)) { on_failure(*error); } else if (std::holds_alternative(result)) { + success.store(true, std::memory_order_release); on_success(); } else { const auto& deferred = std::get(&deferred_result)) { on_failure(*error); } else { + success.store(true, std::memory_order_release); on_success(); } } @@ -78,6 +84,7 @@ class async_action_completion { private: std::atomic completed{false}; + std::atomic success{false}; std::promise done; }; diff --git a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp index 3717f87b5b..937be5af8c 100644 --- a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp +++ b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp @@ -5,15 +5,24 @@ #include #include #include +#include #include +#include #include #include +#include +#include #include #include +#include +#include #include +#include #include "async_action_completion.hpp" #include "group_election.hpp" +#include "role_config.hpp" +#include "underwriter.hpp" #include "yield_cranks.hpp" #include @@ -24,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -39,12 +49,17 @@ namespace eth = fc::network::ethereum; namespace sol = fc::network::solana; namespace { - constexpr auto DELIVERY_TIMEOUT_MS = 15000; - constexpr auto EPOCH_POLL_MS = 15000; - constexpr auto EPOCH_EDGE_BUFFER_MS = 2500; + constexpr fc::microseconds DELIVERY_TIMEOUT = fc::seconds(15); + constexpr fc::microseconds EPOCH_POLL = fc::seconds(15); + constexpr fc::microseconds EPOCH_EDGE_BUFFER = fc::milliseconds(2500); /// Minimum spacing between this operator's `sysio.swap::tickyield` pushes per /// yield pool (`--batch-yield-tick-interval-ms`). - constexpr auto YIELD_TICK_INTERVAL_MS = 60000; + constexpr fc::microseconds YIELD_TICK_INTERVAL = fc::minutes(1); + + /// `d` in whole milliseconds, the unit of the `-ms` options and of cron schedules. + constexpr uint32_t whole_milliseconds(fc::microseconds d) { + return static_cast(d.count() / fc::milliseconds(1).count()); + } /// Minimum private cron-service thread count even when 0 outposts are /// discovered at startup — keeps `epoch_tick` viable so a cold-sync node @@ -54,8 +69,26 @@ namespace { constexpr std::size_t OPP_CRON_JOBS_PER_OUTPOST = 2; /// The plugin-wide epoch polling cron entry. constexpr std::size_t EPOCH_TICK_CRON_JOBS = 1; + /// The underwriter's polling cron entry. + constexpr std::size_t UNDERWRITER_CRON_JOBS = 1; /// Exact secondary-index lookups should return at most the matching row. constexpr uint32_t EXACT_LOOKUP_LIMIT = 1; + /// How long past the head block's time a pushed transaction stays valid. + constexpr fc::microseconds PUSH_EXPIRATION = fc::seconds(30); + + /// Work units of the `sysio.synd::crank` the underwriter pushes every pass. + constexpr uint32_t UNDERWRITER_CRANK_LIMIT = 16; + /// Requests one `sysio.bond::prune`, and envelopes one `sysio.synd::pruneenv`, act on. + constexpr uint32_t UNDERWRITER_PRUNE_LIMIT = 64; + /// Spacing of the underwriter's housekeeping: its prunes, and its claims of what a forfeited bond earned. + constexpr fc::microseconds UNDERWRITER_HOUSEKEEPING_INTERVAL = fc::minutes(10); + /// Spacing of the underwriter's rebuild of its outpost clients from `sysio.chains`. + constexpr fc::microseconds UNDERWRITER_CLIENTS_INTERVAL = fc::minutes(1); + /// Spacing of the underwriter's crank while no envelope request is in play: a bucket refill or a cleared cord + /// still lets queued releases move. + constexpr fc::microseconds UNDERWRITER_IDLE_CRANK_INTERVAL = fc::minutes(5); + /// What a signer signs at startup to show that its signatures recover to its own key. + constexpr std::string_view SIGNER_PROBE = "batch_operator_plugin signer probe"; // ── WIRE contract identifiers (actions, tables, indexes, field names) ── // Centralised so a contract rename/refactor shows up as one search hit, @@ -124,6 +157,38 @@ namespace { /// `chains` KV table, keyed by slug_name (uint64 packed). Field spellings /// are shared with underwriter_plugin, which reads the same rows. namespace chains = sysio::opp::depot::chains; + + namespace uw = batch_operator_detail::underwriter; + + /// A depot action a role pushes. + struct pushed_action { + const char* contract; + const char* action; + }; + + /// Every action the relay pushes: `@active` must be allowed to declare each one (`linkauth`). + constexpr std::array RELAY_ACTIONS{ + pushed_action{msgch::account, msgch::action_deliver}, + pushed_action{msgch::account, msgch::action_chkcons}, + pushed_action{chalg::account, chalg::action_chkdispute}, + pushed_action{batch_operator_detail::swap::account, batch_operator_detail::swap::action_tickyield}, + pushed_action{batch_operator_detail::liq::account, batch_operator_detail::liq::action_queueyield}, + }; + + /// Every action the underwriter pushes: its permission must be allowed to declare each one (`linkauth`). + constexpr std::array UNDERWRITER_ACTIONS{ + pushed_action{uw::bond::account, uw::bond::action_accept}, + pushed_action{uw::bond::account, uw::bond::action_approve}, + pushed_action{uw::bond::account, uw::bond::action_claim}, + pushed_action{uw::bond::account, uw::bond::action_prune}, + pushed_action{uw::synd::account, uw::synd::action_crank}, + pushed_action{uw::synd::account, uw::synd::action_pruneenv}, + }; + + /// The contracts the underwriter reads and pushes to; it idles until every one runs code. Not `sysio.andon`: + /// until that runs code there is no cord to pull. + constexpr std::array UNDERWRITER_CONTRACTS{uw::bond::account, uw::synd::account, + batch_operator_detail::liq::account}; } // --------------------------------------------------------------------------- @@ -141,6 +206,74 @@ struct outpost_descriptor { std::string opp_inbound_addr; }; +namespace { + /// The remote contract identities a relay job or an underwriter client was built for, so a `setoutpost` + /// redeploy is noticed and the job or client rebuilt. + struct outpost_deployment { + std::string opp_addr; + std::string opp_inbound_addr; + + explicit outpost_deployment(const outpost_descriptor& op) + : opp_addr(op.opp_addr) + , opp_inbound_addr(op.opp_inbound_addr) {} + + /// Whether `op` still names this deployment. + bool matches(const outpost_descriptor& op) const { + return op.opp_addr == opp_addr && op.opp_inbound_addr == opp_inbound_addr; + } + }; + + /// An outpost client the underwriter verifies with, and the deployment it was built for. + struct built_outpost_client { + std::shared_ptr client; + outpost_deployment deployment; + }; + + /// A chain this node cannot serve, and why. + struct unserviceable_chain { + std::string code; + std::string why; + }; + + /// A `sysio.liq` shadow, as the underwriter uses it. + struct liq_token { + chain::symbol symbol; ///< the shadow's symbol: its caps and balances are in it + fc::slug_name chain_code{}; ///< the outpost whose token it shadows + }; + + /// The authorization a role declares on its depot actions and the signature provider whose key alone satisfies + /// it, resolved once the node is synced (`impl::resolve_signer`). + struct signer { + chain::permission_level auth; + fc::crypto::signature_provider_ptr provider; + }; + + /// The descriptor of `chain_code` in `outposts`, or nullptr when it is not there. + const outpost_descriptor* find_outpost(const std::vector& outposts, uint64_t chain_code) { + const auto it = std::ranges::find(outposts, chain_code, &outpost_descriptor::id); + return it == outposts.end() ? nullptr : &*it; + } + + /// `find_outpost` by the chain's registry code as a slug. + const outpost_descriptor* find_outpost(const std::vector& outposts, fc::slug_name chain_code) { + return find_outpost(outposts, chain_code.value); + } + + /// Whether `account` runs code. Call from a read window. + bool runs_code(const chain::controller& controller, std::string_view account) { + const chain::account_metadata_object* meta = controller.find_account_metadata(chain::name(account)); + return meta != nullptr && meta->code_hash != chain::digest_type(); + } + + /// "request ( epoch )", or "request " when its statement does not decode. + std::string describe(const uw::request& r) { + const std::optional s = uw::decode_statement(r.statement); + if (!s) return std::format("request {}", r.id); + return std::format("request {} ({} {} epoch {})", r.id, s->chain_code.to_string(), s->token_code.to_string(), + s->epoch_index); + } +} + // --------------------------------------------------------------------------- // Implementation // --------------------------------------------------------------------------- @@ -150,9 +283,43 @@ struct batch_operator_plugin::impl { /// Derived from `operator_account` at plugin_initialize: the relay runs iff /// an account was configured. There is no separate enable flag. bool enabled = false; - uint32_t epoch_poll_ms = EPOCH_POLL_MS; - uint32_t delivery_timeout_ms = DELIVERY_TIMEOUT_MS; - uint32_t yield_tick_interval_ms = YIELD_TICK_INTERVAL_MS; + fc::microseconds epoch_poll = EPOCH_POLL; + fc::microseconds delivery_timeout = DELIVERY_TIMEOUT; + fc::microseconds yield_tick_interval = YIELD_TICK_INTERVAL; + + /// `account@permission` the underwriter bonds and claims as (`batch-underwriter-account`). + chain::permission_level underwriter_auth; + /// Derived from `underwriter_auth` at plugin_initialize: the underwriter runs iff an account was configured, + /// with or without the relay. + bool underwriter_enabled = false; + /// `batch-underwriter-max-exposure`, by shadow symbol code: the most the underwriter may have bonded at once. + std::map underwriter_caps; + + /// The relay's and the underwriter's signers, resolved by `resolve_signers` once the node is synced. + std::optional relay_signer; + std::optional underwriter_signer; + + /// What the underwriter reads in a read window every pass (`refresh_underwriter_chain_view`), acting on the last + /// reading: whether the contracts it needs run code (`UNDERWRITER_CONTRACTS`), whether `sysio.andon` does, the head + /// block's time, and whether its key still satisfies its permission. + std::atomic underwriter_contracts_deployed{false}; + std::atomic andon_deployed{false}; + std::atomic underwriter_head_time{}; + std::atomic underwriter_signer_valid{true}; + /// Whether the relay's key still satisfies `@active`, read with the yield contracts' presence. + std::atomic relay_signer_valid{true}; + + // Underwriter state, see `underwriter_tick`. Touched only from the underwriter's cron job. + std::map underwriter_clients; ///< by outpost chain code + fc::time_point underwriter_clients_at; ///< last rebuild of `underwriter_clients` + fc::time_point underwriter_housekeeping_at; ///< last housekeeping + fc::time_point underwriter_cranked_at; ///< last crank + /// The lowest `sysio.bond::requests` id the next pass reads (`underwriter::next_scan_start`); 0 reads them all. + uw::request_id_t underwriter_scan_from = 0; + /// Every request the underwriter bonded, until its request row is pruned (`underwriter::remember_bonded`). + std::set underwriter_bonded; + /// Which forfeit stops bonding (`underwriter::forfeit_watch`). + uw::forfeit_watch underwriter_forfeits; // Yield cranks -- see `crank_yield`. /// Whether `sysio.swap` and `sysio.liq` run code, refreshed off the read-only @@ -190,10 +357,9 @@ struct batch_operator_plugin::impl { // Only fires when num_slots() > 0 (i.e. external_debugging_plugin is connected). signal debug_envelope_signal; - /// Private cron_service owned by this plugin. Sized from the outpost - /// count at plugin_startup and accepts dynamic per-outpost jobs as - /// `refresh_outposts` observes governance changes. Lifecycle tied to the - /// plugin's startup/shutdown. + /// Private cron_service owned by this plugin, created by `run_deferred_startup` and sized from the outposts + /// discovered then. It runs the relay's epoch_tick and per-outpost jobs, which `refresh_outposts` adds and + /// removes as governance changes the active set, and the underwriter's poll. Stopped at plugin_shutdown. sysio::services::cron_service_ptr cron_svc; std::vector cron_job_ids; /// Cron job IDs for a scheduled outpost relay pair. @@ -266,7 +432,7 @@ struct batch_operator_plugin::impl { void deliver_to_depot(uint64_t chain_code, const std::vector& raw_messages) override { _impl.push_action( - msgch::account, msgch::action_deliver, _impl.operator_account, + msgch::account, msgch::action_deliver, *_impl.relay_signer, fc::mutable_variant_object() (msgch::field::batch_op_name, _impl.operator_account.to_string()) (msgch::field::chain_code, chain_code) @@ -304,8 +470,7 @@ struct batch_operator_plugin::impl { /// leaving the job relaying to an address the outpost has moved off. struct built_opp_job { std::shared_ptr job; - std::string opp_addr; - std::string opp_inbound_addr; + outpost_deployment deployment; }; std::map opp_jobs; @@ -317,8 +482,32 @@ struct batch_operator_plugin::impl { /// 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(delivery_timeout_ms), - "batch_operator", shutting_down); + return chain_plug->read_table_rows(std::move(p), delivery_timeout, "batch_operator", shutting_down); + } + + /// `read_table`, but nullopt when the read failed (logged by chain_plugin), so a caller that must not act on a + /// partial view can tell a failure from an empty table. + std::optional + read_table_checked(sysio::chain_apis::read_only::get_table_rows_params p) { + return chain_plug->read_table_rows_checked(std::move(p), delivery_timeout, + chain_plug->get_abi_serializer_max_time(), "batch_operator", + shutting_down); + } + + /// Every row of `code::table` from the key `from` (a key object; empty starts at the first row), values only, or + /// nullopt when the read failed. `scope` filters only a table whose first key is `scope`. + std::optional read_all_rows(std::string_view code, std::string_view scope, std::string_view table, + const fc::variant_object& from = {}) { + sysio::chain_apis::read_only::get_table_rows_params p; + p.code = chain::name(code); + p.scope = std::string(scope); + p.table = std::string(table); + if (from.size() > 0) p.lower_bound = fc::json::to_string(from, fc::json::yield_function_t{}); + p.all_rows = true; + p.values_only = true; + std::optional rows = read_table_checked(std::move(p)); + if (!rows) return std::nullopt; + return std::move(rows->rows); } /// Check if this operator already delivered an envelope for the @@ -367,13 +556,14 @@ struct batch_operator_plugin::impl { poll_own_status(); } FC_LOG_AND_DROP(); if (!is_active) return; + if (!relay_signer_valid) report_stale_signer(*relay_signer, "the relay"); // chkcons advances the epoch on consensus. Only the elected operator // should push it — the contract verifies authorization regardless, // but pushing from every batch op wastes trx slots. if (election.is_elected) { try { - push_action(msgch::account, msgch::action_chkcons, operator_account, + push_action(msgch::account, msgch::action_chkcons, *relay_signer, fc::mutable_variant_object()); } catch (const fc::exception& e) { dlog("batch_operator: chkcons: {}", e.to_string()); @@ -444,7 +634,7 @@ struct batch_operator_plugin::impl { const uint64_t dispute_id = id_it->value().as_uint64(); try { - push_action(chalg::account, chalg::action_chkdispute, operator_account, + push_action(chalg::account, chalg::action_chkdispute, *relay_signer, fc::mutable_variant_object()(chalg::field::dispute_id, dispute_id)); } catch (const fc::exception& e) { // Expected-transient: the dispute resolved between the scan and the push, or @@ -468,11 +658,8 @@ struct batch_operator_plugin::impl { using namespace batch_operator_detail; if (shutting_down) return; const auto& controller = chain_plug->chain(); - auto runs_code = [&](const char* account) { - const auto* meta = controller.find_account_metadata(chain::name(account)); - return meta != nullptr && meta->code_hash != chain::digest_type(); - }; - const bool deployed = runs_code(swap::account) && runs_code(liq::account); + const bool deployed = runs_code(controller, swap::account) && runs_code(controller, liq::account); + note_signer_validity(controller, relay_signer, relay_signer_valid); if (yield_contracts_deployed.exchange(deployed) != deployed) { ilog("batch_operator: yield cranks {}: {} and {} {}", deployed ? "active" : "idle", swap::account, liq::account, @@ -534,16 +721,15 @@ struct batch_operator_plugin::impl { } const auto now = fc::time_point::now(); - const auto interval = fc::milliseconds(yield_tick_interval_ms); for (const auto& r : pool_rows.rows) { const auto code = row_symbol_code(r.get_object()); if (!code) continue; const auto queued = queued_by_pool.find(code->value); if (queued == queued_by_pool.end() || queued->second <= 0) continue; const auto pool = symbol_code_name(*code); - if (!yield_tick_spacing.due(pool, now, interval)) continue; + if (!yield_tick_spacing.due(pool, now, yield_tick_interval)) continue; try { - push_action(swap::account, swap::action_tickyield, operator_account, + push_action(swap::account, swap::action_tickyield, *relay_signer, fc::mutable_variant_object()(swap::field::pair_token, *code)); yield_tick_spacing.mark(pool, now); } catch (const fc::exception& e) { @@ -568,7 +754,7 @@ struct batch_operator_plugin::impl { const auto value = row_value(row); if (!code || !value || asset_amount(*value, liq::field::quantity) <= 0) continue; try { - push_action(liq::account, liq::action_queueyield, operator_account, + push_action(liq::account, liq::action_queueyield, *relay_signer, fc::mutable_variant_object()(liq::field::sym, *code)); } catch (const fc::exception& e) { // Expected-transient: another operator queued it first. Persistent while @@ -719,46 +905,24 @@ struct batch_operator_plugin::impl { // Outpost registry // ----------------------------------------------------------------------- - void refresh_outposts() { - // Chain registry lives on `sysio.chains::chains` (replaces the - // removed `sysio.epoch::outposts` table). Each row carries the - // chain's slug_name + kind + external_chain_id + is_depot + active. - // Outposts are the non-depot, active rows; the single is_depot=true - // row is the WIRE chain itself and is skipped. - // - // Startup race: in a multi-node cluster the batch-op node replays - // blocks from the producer asynchronously. There's a brief window - // where `sysio.chains` exists on the producer but the local node - // hasn't replayed far enough to see it — `read_table` throws - // `Account Query Exception (3060002)` / `Contract Table Query - // Exception (3060003)` during that window. Catch + return; the - // outer cron tick re-enters every poll interval and self-heals. - sysio::chain_apis::read_only::get_table_rows_params p; - p.code = chain::name(chains::account); - p.scope = chains::account; - p.table = chains::table_chains; - p.all_rows = true; - p.values_only = true; - sysio::chain_apis::read_only::get_table_rows_result rows; - try { - rows = read_table(std::move(p)); - } catch (const fc::exception& e) { - // Transient (cold-start replay, account not yet visible). - // Don't clear `outposts` — keep the last-known set so jobs - // built from earlier reads continue to work; the next tick - // will refresh once the table is reachable. - static fc::time_point last_warn; - auto now = fc::time_point::now(); - if (now > last_warn + fc::seconds(30)) { - wlog("batch_operator: refresh_outposts deferred — sysio.chains read failed: {}", - ("e", e.top_message())); - last_warn = now; - } - return; + /// The active outposts on `sysio.chains::chains`, or nullopt when the table cannot be read. + /// + /// Each row carries the chain's slug_name + kind + external_chain_id + is_depot + active. Outposts are the + /// non-depot, active rows; the single is_depot=true row is the WIRE chain itself and is skipped. + /// + /// Startup race: in a multi-node cluster the batch-op node replays blocks from the producer asynchronously. + /// There's a brief window where `sysio.chains` exists on the producer but the local node hasn't replayed far + /// enough to see it, and the read fails (logged by chain_plugin). The caller keeps its last-known set and retries + /// on its next tick. + std::optional> read_active_outposts() { + const std::optional rows = read_all_rows(chains::account, chains::account, chains::table_chains); + if (!rows) { + wlog("batch_operator: outpost refresh deferred: sysio.chains could not be read"); + return std::nullopt; } - outposts.clear(); - for (auto& row : rows.rows) { - auto obj = row.get_object(); + std::vector active; + for (const fc::variant& row : *rows) { + const fc::variant_object& obj = row.get_object(); // `code` is a `slug_name`. fc::slug_name's own from_variant reads the // decoded slug string ("" for zero) and the transitional // `{value: }` object, so the shape is not probed here. @@ -767,139 +931,182 @@ struct batch_operator_plugin::impl { code_val = code_obj->value().as().value; } bool is_depot = obj[chains::field::is_depot].as_bool(); - bool active = obj[chains::field::active].as_bool(); - if (is_depot || !active) continue; + bool is_active = obj[chains::field::active].as_bool(); + if (is_depot || !is_active) continue; outpost_descriptor od; od.id = code_val; // slug_name uint64 doubles as outpost id od.chain_kind = obj[chains::field::kind].as(); od.chain_id = static_cast(obj[chains::field::external_chain_id].as_uint64()); // The remote contract identities live in a nested struct on the row. - // Absent (pre-upgrade row) reads as empty, which fails closed below - // exactly like a row governance has not configured yet. + // Absent (pre-upgrade row) reads as empty, which fails closed in + // `make_outpost_client` exactly like a row governance has not configured yet. if (auto out_it = obj.find(chains::field::outpost); out_it != obj.end() && out_it->value().is_object()) { - const auto& out_obj = out_it->value().get_object(); + const fc::variant_object& out_obj = out_it->value().get_object(); if (auto a = out_obj.find(chains::field::outpost_addr::opp_addr); a != out_obj.end()) od.opp_addr = a->value().as_string(); if (auto a = out_obj.find(chains::field::outpost_addr::opp_inbound_addr); a != out_obj.end()) od.opp_inbound_addr = a->value().as_string(); } - outposts.push_back(std::move(od)); + active.push_back(std::move(od)); } + return active; + } + + void refresh_outposts() { + std::optional> active = read_active_outposts(); + if (!active) return; // keep the last-known set; jobs built from it continue to work + outposts = std::move(*active); ilog("batch_operator: loaded {} outposts (sysio.chains)", outposts.size()); prune_stale_opp_jobs(); build_opp_jobs(); schedule_opp_jobs(); } - /// Construct an `outpost_opp_job` per registered outpost using the - /// chain-specific plugin factories. Idempotent: already-built jobs stay. + /// An outpost client, or why there is none. + struct client_build { + std::shared_ptr client; + /// Why this node cannot serve the chain at all; empty when `client` is set, or when the row only lacks + /// its remote contract address so far. + std::string unserviceable; + }; + + /// Build the outpost client for `op` with the chain-specific plugin factories, for `role` (which Solana IDL + /// declarations it validates at boot). + /// + /// A missing RPC client, an unsupported chain kind or a factory refusal is local configuration only the + /// operator can fix, reported as `unserviceable`. Missing CONTRACT ADDRESSES are governance state on the row, + /// fixable with `sysio.chains::setoutpost` without touching any node, so the chain is skipped (no client, no + /// reason) and picked up on a later refresh. + client_build make_outpost_client(const outpost_descriptor& op, solana_outpost_role role) { + const std::string code_str = fc::slug_name{op.id}.to_string(); + try { + if (op.chain_kind == CHAIN_KIND_EVM) { + // Bind this exact outpost to its own remote identity: the RPC + // client is the one registered under this chain's OWN code, and + // the OPP / OPPInbound contract addresses come from the row + // itself. `create_outpost_client` additionally asserts the + // client's verified eth_chainId equals the row's + // external_chain_id, so a client registered under the wrong code + // is caught rather than relayed through. + if (!eth_plug->get_client(code_str)) { + return {.unserviceable = "no Ethereum RPC client is registered under this chain code"}; + } + if (op.opp_addr.empty() || op.opp_inbound_addr.empty()) { + wlog("batch_operator: outpost {} (EVM) has no OPP/OPPInbound address on its " + "sysio.chains row; skipping until sysio.chains::setoutpost supplies both", + code_str); + return {}; + } + return {.client = eth_plug->create_outpost_client(code_str, op.id, op.chain_id, + op.opp_addr, op.opp_inbound_addr)}; + } + if (op.chain_kind == CHAIN_KIND_SVM) { + // SVM: the RPC client is likewise registered under the chain's + // own code. The per-outpost identity is the program id on the + // row; `sysio.chains` already rejects an SVM row that carries a + // separate inbound address, so only presence is checked here. + if (!sol_plug->get_client(code_str)) { + return {.unserviceable = "no Solana RPC client is registered under this chain code"}; + } + if (op.opp_addr.empty()) { + wlog("batch_operator: outpost {} (SVM) has no program id on its sysio.chains " + "row; skipping until sysio.chains::setoutpost supplies one", + code_str); + return {}; + } + return {.client = sol_plug->create_outpost_client(code_str, op.id, op.chain_id, op.opp_addr, role)}; + } + // A chain kind this build does not know how to serve is just as + // unserviceable as a missing client, and equally unfixable + // on-chain: the operator needs a newer nodeop. + return {.unserviceable = std::format("chain kind {} is not supported by this build", + ChainKind_Name(op.chain_kind))}; + } catch (const fc::exception& e) { + // The factory throws on a client that cannot serve this row at all, + // most importantly when the client registered under this chain code + // reports a different eth_chainId than the row's external_chain_id. + // That is a misconfiguration only the operator can fix, so it is + // unserviceable on the same terms as a missing client; swallowing it + // would leave the node silently unable to do its job on that chain. + return {.unserviceable = std::format("outpost client could not be built: {}", e.top_message())}; + } catch (const std::exception& e) { + // The same for a standard exception, such as a lookup of an ABI file that was never loaded. + return {.unserviceable = std::format("outpost client could not be built: {}", e.what())}; + } + } + + /// Build, with `wrap(op, client)`, what `built` lacks for each chain in `active`, the clients made for `role`. + /// Returns the chains this node cannot serve; a chain whose row has no remote address yet is skipped until + /// `setoutpost` supplies one. + template + std::vector build_missing(std::map& built, + const std::vector& active, + solana_outpost_role role, Wrap&& wrap) { + std::vector unserviceable; + for (const outpost_descriptor& op : active) { + const Key key{op.id}; + if (built.contains(key)) continue; + client_build made = make_outpost_client(op, role); + if (!made.unserviceable.empty()) { + unserviceable.push_back({fc::slug_name{op.id}.to_string(), std::move(made.unserviceable)}); + continue; + } + if (!made.client) continue; // no remote address on the row yet + built.emplace(key, wrap(op, made.client)); + } + return unserviceable; + } + + /// Drop what `built` holds for a chain that left `active`, or whose deployment moved (`setoutpost`), calling + /// `on_drop(chain_code, why)` for each, so the next `build_missing` builds it again for the new address. + template + static void drop_stale(std::map& built, const std::vector& active, + OnDrop&& on_drop) { + for (auto it = built.begin(); it != built.end();) { + const outpost_descriptor* current = find_outpost(active, it->first); + if (current != nullptr && it->second.deployment.matches(*current)) { + ++it; + continue; + } + on_drop(it->first, current == nullptr ? "inactive" : "redeployed"); + it = built.erase(it); + } + } + + /// Name the chains this node cannot serve and shut it down, so the failure is visible to a supervisor + /// instead of hiding behind a running process. Only the operator can fix it (it is local config). + void quit_unserviceable(const std::vector& unserviceable, size_t active_count, + std::string_view why_every_chain) { + for (const auto& [code, why] : unserviceable) { + elog("batch_operator: cannot serve active chain {}: {}", code, why); + } + elog("batch_operator: cannot serve {} of {} active chain(s), shutting down node ({})", + unserviceable.size(), active_count, why_every_chain); + app().quit(); + } + + /// Construct an `outpost_opp_job` per registered outpost. Idempotent: already-built jobs stay. /// Called from `refresh_outposts`; scheduling is handled separately so /// startup-created jobs and governance-added jobs share the same path. /// /// An elected group fans the epoch cycle out across EVERY active chain, so a /// batch operator that cannot reach one of them cannot do its job — it would /// simply withhold deliveries for that chain and drag its group below - /// consensus. A missing RPC client is therefore FATAL, not skippable: the - /// node names the chains it cannot serve and quits, so the failure is - /// visible to a supervisor instead of hiding behind a running process. Only - /// the operator can fix it (it is local config), and this runs after the - /// sync gate, where `sysio.chains` is actually readable. - /// - /// Missing CONTRACT ADDRESSES are different and stay non-fatal: those are - /// governance state on the row, fixable with `sysio.chains::setoutpost` - /// without touching any node, so the chain is skipped fail-closed and picked - /// up on a later tick. + /// consensus. An unserviceable chain is therefore FATAL, not skippable; this + /// runs after the sync gate, where `sysio.chains` is actually readable. void build_opp_jobs() { if (!depot_ops_backing) return; // plugin not initialized yet - // (chain code, why it cannot be served) for the fatal check below. - std::vector> unserviceable; - for (auto& op : outposts) { - if (opp_jobs.contains(op.id)) continue; - const auto code_str = fc::slug_name{op.id}.to_string(); - - std::shared_ptr client; - try { - if (op.chain_kind == CHAIN_KIND_EVM) { - // Bind this exact outpost to its own remote identity: the RPC - // client is the one registered under this chain's OWN code, and - // the OPP / OPPInbound contract addresses come from the row - // itself. `create_outpost_client` additionally asserts the - // client's verified eth_chainId equals the row's - // external_chain_id, so a client registered under the wrong code - // is caught rather than relayed through. - auto entry = eth_plug->get_client(code_str); - if (!entry) { - unserviceable.emplace_back(code_str, "no Ethereum RPC client is registered " - "under this chain code"); - continue; - } - if (op.opp_addr.empty() || op.opp_inbound_addr.empty()) { - wlog("batch_operator: outpost {} (EVM) has no OPP/OPPInbound address on its " - "sysio.chains row; skipping until sysio.chains::setoutpost supplies both", - code_str); - continue; - } - client = eth_plug->create_outpost_client(code_str, op.id, op.chain_id, - op.opp_addr, op.opp_inbound_addr); - } else if (op.chain_kind == CHAIN_KIND_SVM) { - // SVM: the RPC client is likewise registered under the chain's - // own code. The per-outpost identity is the program id on the - // row; `sysio.chains` already rejects an SVM row that carries a - // separate inbound address, so only presence is checked here. - if (!sol_plug->get_client(code_str)) { - unserviceable.emplace_back(code_str, "no Solana RPC client is registered " - "under this chain code"); - continue; - } - if (op.opp_addr.empty()) { - wlog("batch_operator: outpost {} (SVM) has no program id on its sysio.chains " - "row; skipping until sysio.chains::setoutpost supplies one", - code_str); - continue; - } - client = sol_plug->create_outpost_client(code_str, op.id, op.chain_id, - op.opp_addr, - solana_outpost_role::batch_operator); - } else { - // A chain kind this build does not know how to relay is just as - // unserviceable as a missing client, and equally unfixable - // on-chain — the operator needs a newer nodeop. - unserviceable.emplace_back(code_str, - std::format("chain kind {} is not supported by this build", - ChainKind_Name(op.chain_kind))); - continue; - } - } catch (const fc::exception& e) { - // The factory throws on a client that cannot serve this row at all — - // most importantly when the client registered under this chain code - // reports a different eth_chainId than the row's external_chain_id. - // That is a misconfiguration only the operator can fix, so it is - // unserviceable on the same terms as a missing client; swallowing it - // here would leave an elected operator silently unable to deliver. - unserviceable.emplace_back(code_str, - std::format("outpost client could not be built: {}", e.top_message())); - continue; - } - - auto job = std::make_shared( - client, *depot_ops_backing, fc::milliseconds(delivery_timeout_ms)); - opp_jobs.emplace(op.id, built_opp_job{std::move(job), op.opp_addr, op.opp_inbound_addr}); - ilog("batch_operator: built outpost_opp_job for {}", client->to_string()); - } - + const std::vector unserviceable = build_missing( + opp_jobs, outposts, solana_outpost_role::batch_operator, + [&](const outpost_descriptor& op, const std::shared_ptr& client) { + std::shared_ptr job = + std::make_shared(client, *depot_ops_backing, delivery_timeout); + ilog("batch_operator: built outpost_opp_job for {}", client->to_string()); + return built_opp_job{std::move(job), outpost_deployment{op}}; + }); if (!unserviceable.empty()) { - for (const auto& [code, why] : unserviceable) { - elog("batch_operator: cannot serve active chain {} — {}", code, why); - } - elog("batch_operator: cannot serve {} of {} active chain(s) — shutting down node " - "(an elected group must deliver on every active chain)", - unserviceable.size(), outposts.size()); - // build_opp_jobs also runs from the private cron_service (the epoch - // tick refreshes the active set), so hop to the app thread rather than - // tearing the executor down from a worker. - app().executor().post(appbase::priority::high, appbase::exec_queue::read_write, - []() { app().quit(); }); + quit_unserviceable(unserviceable, outposts.size(), "an elected group must deliver on every active chain"); } } @@ -922,15 +1129,6 @@ struct batch_operator_plugin::impl { scheduled_opp_jobs.erase(sched_it); } - /// The current descriptor for a chain code, or nullptr when the chain is no - /// longer in the active set read from `sysio.chains`. - const outpost_descriptor* find_current_outpost(uint64_t chain_code) const { - for (const auto& outpost : outposts) { - if (outpost.id == chain_code) return &outpost; - } - return nullptr; - } - /// Drop relay jobs that no longer match `sysio.chains`: the chain went /// inactive, or governance moved its remote deployment with `setoutpost`. /// `build_opp_jobs` rebuilds what is dropped here on the same refresh, so an @@ -938,19 +1136,10 @@ struct batch_operator_plugin::impl { /// it a redeployed outpost would keep receiving deliveries at a dead /// address, and inbound reads would keep polling the old contract. void prune_stale_opp_jobs() { - for (auto it = opp_jobs.begin(); it != opp_jobs.end(); ) { - const auto* current = find_current_outpost(it->first); - if (current != nullptr - && current->opp_addr == it->second.opp_addr - && current->opp_inbound_addr == it->second.opp_inbound_addr) { - ++it; - continue; - } - cancel_scheduled_opp_job(it->first); - ilog("batch_operator: removed outpost_opp_job for {} outpost {}", - current == nullptr ? "inactive" : "redeployed", fc::slug_name{it->first}.to_string()); - it = opp_jobs.erase(it); - } + drop_stale(opp_jobs, outposts, [&](uint64_t chain_code, std::string_view why) { + cancel_scheduled_opp_job(chain_code); + ilog("batch_operator: removed outpost_opp_job for {} outpost {}", why, fc::slug_name{chain_code}.to_string()); + }); } /// Schedule one cron direction for an outpost relay job. @@ -959,7 +1148,7 @@ struct batch_operator_plugin::impl { std::string_view direction, void (sysio::outpost_opp_job::*runner)()) { sysio::services::cron_service::job_schedule sched; - sched.milliseconds = {sysio::services::cron_service::job_schedule::step_value{epoch_poll_ms}}; + sched.milliseconds = {sysio::services::cron_service::job_schedule::step_value{whole_milliseconds(epoch_poll)}}; sysio::services::cron_service::job_metadata_t meta; meta.label = std::format("outpost_opp_{}_{}", direction, chain_code); meta.one_at_a_time = true; @@ -970,7 +1159,7 @@ struct batch_operator_plugin::impl { meta); cron_job_ids.push_back(id); ilog("batch_operator_plugin: scheduled {} for {} (id={}, every {}ms)", - meta.label, job->client().to_string(), id, epoch_poll_ms); + meta.label, job->client().to_string(), id, whole_milliseconds(epoch_poll)); return id; } @@ -998,13 +1187,12 @@ struct batch_operator_plugin::impl { /// Blocks operations only in the narrow buffer zones at epoch boundaries. /// Once past next_epoch_start, operations are allowed (epoch is overdue). bool within_epoch_window() const { - auto now = fc::time_point::now(); - auto buffer = fc::milliseconds(EPOCH_EDGE_BUFFER_MS); - if (now < epoch_start + buffer) return false; // too close to epoch start + const fc::time_point now = fc::time_point::now(); + if (now < epoch_start + EPOCH_EDGE_BUFFER) return false; // too close to epoch start // Only block in the narrow window BEFORE next_epoch_start. // Once past next_epoch_start, the epoch is overdue — allow operations. if (next_epoch_start != fc::time_point() && - now > next_epoch_start - buffer && + now > next_epoch_start - EPOCH_EDGE_BUFFER && now < next_epoch_start) return false; return true; } @@ -1012,108 +1200,672 @@ struct batch_operator_plugin::impl { // ----------------------------------------------------------------------- - // Helpers + // Signers // ----------------------------------------------------------------------- - /// Serializes and asynchronously submits a depot action. - /// - /// The bounded wait deliberately does not cancel the request. Its callback - /// retains its API, labels, and completion state so it remains safe if this - /// function returns after timing out but before the transaction completes. - void push_action(const std::string& contract, - const std::string& action_name, - chain::name auth_account, - const fc::variant_object& data) { - auto abi_max_time = fc::microseconds(delivery_timeout_ms * 1000); - auto& chain = chain_plug->chain(); - - // Resolve ABI and serialize action data - auto resolver = make_resolver(chain, abi_max_time, throw_on_yield::no); - auto abis_opt = resolver(chain::name(contract)); - if (!abis_opt) { - elog("batch_operator: no ABI found for {}", contract); - return; + /// The signer of `role`, declaring `level`: the one operator-configured WIRE signature provider whose key alone + /// satisfies `level` on chain. Nullopt, logged, when there is none, when there is more than one (the choice would + /// fall to provider order), when its signatures do not recover to its key, or when `level` may not declare one + /// of `actions`. Call on the main thread, after the sync gate. + std::optional resolve_signer(std::string_view role, const chain::permission_level& level, + std::span actions) { + const chain::controller& chain = chain_plug->chain(); + bool linked = true; + for (const auto& [contract, action] : actions) { + if (permission_satisfies_link(chain, level, chain::name(contract), chain::name(action))) continue; + elog("batch_operator: {} declares {}@{}, which may not authorize {}::{}: link that action to it", + role, level.actor.to_string(), level.permission.to_string(), contract, action); + linked = false; + } + signature_provider_manager_plugin& sig_plug = app().get_plugin(); + const batch_operator_detail::signer_choice choice = + batch_operator_detail::choose_signer( + sig_plug.query_providers(std::nullopt, fc::crypto::chain_kind_wire), + [&](const fc::crypto::signature_provider_ptr& provider) { + return sig_plug.is_operator_configured_provider(provider->key_name) && + key_alone_satisfies(chain, level, provider->public_key); + }); + if (!choice.chosen) { + elog("batch_operator: {} needs exactly one configured WIRE signature provider whose key alone satisfies " + "{}@{}, found {}", role, level.actor.to_string(), level.permission.to_string(), choice.matches); + return std::nullopt; } + if (!signs_for_its_key(role, **choice.chosen) || !linked) return std::nullopt; + return signer{.auth = level, .provider = *choice.chosen}; + } - auto action_type = abis_opt->get_action_type(chain::name(action_name)); - auto action_data = abis_opt->variant_to_binary( - action_type, fc::variant(data), - chain::abi_serializer::create_yield_function(abi_max_time)); + /// Whether `provider`'s signature over a probe digest recovers to its own key, which is how the chain checks a + /// transaction signature. Logs why not. + static bool signs_for_its_key(std::string_view role, const fc::crypto::signature_provider_t& provider) { + const fc::sha256 probe = fc::sha256::hash(SIGNER_PROBE.data(), SIGNER_PROBE.size()); + try { + if (fc::crypto::public_key::recover(provider.sign(probe), probe) == provider.public_key) return true; + elog("batch_operator: {}'s signature provider {} makes signatures that do not recover to its key", role, + provider.key_name); + } catch (const fc::exception& e) { + elog("batch_operator: {}'s signature provider {} cannot sign: {}", role, provider.key_name, e.top_message()); + } catch (const std::exception& e) { + elog("batch_operator: {}'s signature provider {} cannot sign: {}", role, provider.key_name, e.what()); + } + return false; + } - // Build the signed transaction - chain::signed_transaction trx; - trx.actions.emplace_back( - std::vector{{auth_account, chain::config::active_name}}, - chain::name(contract), chain::name(action_name), std::move(action_data)); - - trx.set_reference_block(chain.head().id()); - trx.expiration = fc::time_point_sec(chain.head().block_time() + fc::seconds(30)); - - // Sign with the operator's WIRE K1 key via signature_provider_manager - auto& sig_plug = app().get_plugin(); - auto wire_providers = sig_plug.query_providers( - std::nullopt, fc::crypto::chain_kind_wire, fc::crypto::chain_key_type_wire); - if (wire_providers.empty()) { - elog("batch_operator: no WIRE K1 signature provider available"); + /// Resolve the signer of every enabled role. False when one cannot be resolved, after both are reported. + bool resolve_signers() { + bool resolved = true; + if (enabled) { + relay_signer = resolve_signer("the relay", {operator_account, chain::config::active_name}, RELAY_ACTIONS); + resolved = relay_signer.has_value() && resolved; + } + if (underwriter_enabled) { + underwriter_signer = resolve_signer("the underwriter", underwriter_auth, UNDERWRITER_ACTIONS); + resolved = underwriter_signer.has_value() && resolved; + } + return resolved; + } + + // ----------------------------------------------------------------------- + // Underwriter + // ----------------------------------------------------------------------- + + /** + * One pass of the underwriter, run by its own cron job: bond each OPEN `sysio.synd` envelope request whose + * envelope the outpost has confirmed, crank `sysio.synd`, then approve, claim and prune what it bonded. + * `underwriter::plan_actions` makes every decision; this reads its inputs and pushes its actions, all signed by + * `underwriter_signer`. + * + * Bonding is what releases an envelope's syndications, so a request is bonded only when its outpost's own record + * of the envelope it emitted for the statement's epoch carries the statement's digest, within the token's exposure + * cap and the account's balance, while the andon cord is clear, and while no bond of the underwriter's has been + * ruled INVALID since the node started. A pass whose table reads fail does nothing: planning from part of the state + * could exceed a cap or miss a freeze. What it cannot act on waits for a later pass and is logged on every pass + * it holds; deduplicating those lines is left to log tooling. + */ + void underwriter_tick() { + if (shutting_down || !underwriter_enabled) return; + try { + run_underwriter_pass(); + } FC_LOG_AND_DROP(); + } + + /// The body of `underwriter_tick`. + void run_underwriter_pass() { + refresh_underwriter_chain_view(); + if (!underwriter_contracts_deployed) return; + if (!underwriter_signer_valid) { // nothing it pushes could be authorized + report_stale_signer(*underwriter_signer, "the underwriter"); return; } + // Chain time, which the contracts judge windows by; the wall clock only spaces this node's own housekeeping. + const fc::time_point head_time = underwriter_head_time.load(); + if (head_time == fc::time_point()) return; + const fc::time_point now = fc::time_point::now(); + if (now - underwriter_clients_at >= UNDERWRITER_CLIENTS_INTERVAL) { + underwriter_clients_at = now; + underwriter_refresh_clients(); + } + + const std::optional> tokens = read_liq_tokens(); + if (!tokens) return; + std::optional in = read_underwriter_state(*tokens, head_time); + if (!in || shutting_down) return; + uw::remember_bonded(underwriter_bonded, in->bonds, in->requests); + if (underwriter_forfeits.note(uw::forfeited_requests(in->requests, underwriter_bonded))) { + elog("batch_operator: underwriter stops bonding until the node is restarted: request {} was ruled INVALID " + "with our bond on it", *underwriter_forfeits.halted_by); + } + in->bonded = underwriter_bonded; + in->halted = underwriter_forfeits.halted_by.has_value(); + if (!in->frozen && !in->halted) in->emitted = read_emitted_digests(in->requests); + const uw::plan plan = uw::plan_actions(*in); + report_plan(*in, plan); + push_plan(plan, uw::synd_has_work(in->requests), now); + underwriter_scan_from = uw::next_scan_start(underwriter_scan_from, in->requests, in->bonds, underwriter_bonded); + + if (now - underwriter_housekeeping_at >= UNDERWRITER_HOUSEKEEPING_INTERVAL) { + underwriter_housekeeping_at = now; + for (const uw::request_id_t id : plan.forfeit_claims) underwriter_claim(id); + underwriter_prune(*tokens); + } + } + + /// Refresh what the underwriter reads in a read window: the contracts' presence, the head block's time and its + /// key's validity, as `refresh_yield_contract_presence` does for the yield cranks. + void refresh_underwriter_chain_view() { + app().executor().post(appbase::priority::low, appbase::exec_queue::read_only, [this] { + if (shutting_down) return; + const chain::controller& controller = chain_plug->chain(); + andon_deployed = runs_code(controller, uw::andon::account); + underwriter_head_time = controller.head().block_time(); + note_signer_validity(controller, underwriter_signer, underwriter_signer_valid); + const bool deployed = std::ranges::all_of(UNDERWRITER_CONTRACTS, [&](const char* account) { + return runs_code(controller, account); + }); + if (underwriter_contracts_deployed.exchange(deployed) != deployed) { + std::string accounts; + for (const char* account : UNDERWRITER_CONTRACTS) { + accounts += std::format("{}{}", accounts.empty() ? "" : ", ", account); + } + ilog("batch_operator: underwriter {}: {} {}", deployed ? "active" : "idle", accounts, + deployed ? "all run code" : "do not all run code yet"); + } + }); + } + + /// Refresh `valid` from whether `s`'s key still satisfies its authorization on chain. A rotated key needs a + /// restart: signers are chosen once, at startup. Call from a read window. + static void note_signer_validity(const chain::controller& chain, const std::optional& s, + std::atomic& valid) { + if (s) valid = key_alone_satisfies(chain, s->auth, s->provider->public_key); + } + + /// Log that `s`'s key no longer satisfies its authorization. + static void report_stale_signer(const signer& s, std::string_view role) { + elog("batch_operator: {}'s key no longer satisfies {}@{}; restart the node with the new key", role, + s.auth.actor.to_string(), s.auth.permission.to_string()); + } + + /// Push one action as the underwriter. True when the transaction was accepted; a refusal is logged by the push + /// callback. + bool underwriter_push(const char* contract, const char* action, const fc::variant_object& data) { + if (shutting_down) return false; + try { + return push_action(contract, action, *underwriter_signer, data); + } catch (const fc::exception& e) { + wlog("batch_operator: underwriter {}::{}: {}", contract, action, e.to_string()); + } catch (const std::exception& e) { + wlog("batch_operator: underwriter {}::{}: {}", contract, action, e.what()); + } + return false; + } - auto chain_id = chain.get_chain_id(); - auto digest = trx.sig_digest(chain_id, trx.context_free_data); - trx.signatures.push_back(wire_providers.front()->sign(digest)); + /// Push what the plan decided: accepts first, so their releases start this pass, then the crank, approves and + /// claims. A push the irreversible view does not show yet is planned, and refused, again next pass. `synd_busy` + /// says `sysio.synd` has an envelope request in play or an outcome to acknowledge. + void push_plan(const uw::plan& plan, bool synd_busy, fc::time_point now) { + for (const uw::accept_action& accept : plan.accepts) { + // Accepted by this node only: it can still fail where it lands, so the bond is known from its row. + if (underwriter_push(uw::bond::account, uw::bond::action_accept, + fc::mutable_variant_object() + (uw::bond::field::underwriter, underwriter_auth.actor) + (uw::bond::field::request_id, accept.request_id) + (uw::bond::field::amount, accept.amount.get_amount()))) { + ilog("batch_operator: underwriter pushed an accept of request {} ({})", accept.request_id, + accept.amount.to_string()); + } + } + // One crank moves a bounded amount of work. Every pass while synd is busy; otherwise now and then, for what a + // bucket refill or a cleared cord lets through. + if (synd_busy || now - underwriter_cranked_at >= UNDERWRITER_IDLE_CRANK_INTERVAL) { + underwriter_cranked_at = now; + underwriter_push(uw::synd::account, uw::synd::action_crank, + fc::mutable_variant_object()(uw::synd::field::limit, UNDERWRITER_CRANK_LIMIT)); + } + for (const uw::request_id_t id : plan.approves) { + underwriter_push(uw::bond::account, uw::bond::action_approve, + fc::mutable_variant_object()(uw::bond::field::request_id, id)); + } + for (const uw::request_id_t id : plan.claims) underwriter_claim(id); + } - // Pack and push - auto packed = chain::packed_transaction(std::move(trx), chain::packed_transaction::compression_type::none); - auto rw = std::make_shared(chain_plug->get_read_write_api(abi_max_time)); + /// Push `sysio.bond::claim` of the underwriter's bond on request `id`. + void underwriter_claim(uw::request_id_t id) { + underwriter_push(uw::bond::account, uw::bond::action_claim, + fc::mutable_variant_object() + (uw::bond::field::request_id, id) + (uw::bond::field::account, underwriter_auth.actor)); + } - fc::variant packed_var; - chain::to_variant(packed, packed_var); + /// Report what the plan leaves to people: a freeze, a halt, challenged and forfeited requests, and requests left + /// unbonded. + void report_plan(const uw::plan_inputs& in, const uw::plan& plan) { + std::map by_id; + for (const uw::request& r : in.requests) by_id.emplace(r.id, &r); + const auto name_of = [&](uw::request_id_t id) { + const auto r = by_id.find(id); + return r == by_id.end() ? std::format("request {}", id) : describe(*r->second); + }; + if (in.frozen) { + wlog("batch_operator: underwriter paused: the sysio.andon cord is pulled, so nothing is bonded, approved or " + "claimed until it clears"); + } + if (underwriter_forfeits.halted_by) { + elog("batch_operator: underwriter bonds nothing until the node is restarted: {} was ruled INVALID with our " + "bond on it", name_of(*underwriter_forfeits.halted_by)); + } + for (const uw::request_id_t id : plan.held) { + elog("batch_operator: underwriter {} is challenged (HELD) with our bond on it: sysio must rule it", + name_of(id)); + } + for (const uw::request_id_t id : plan.blocked) { + elog("batch_operator: underwriter {} was challenged (HELD) before it was bonded: its pair releases nothing " + "until sysio rules it", name_of(id)); + } + for (const uw::request_id_t id : plan.forfeited) { + elog("batch_operator: underwriter {} was ruled INVALID: our bond on it is forfeited", name_of(id)); + } + for (const uw::waiting_request& waiting : plan.waiting) { + if (waiting.reason == uw::wait_reason::CONTRADICTED) { + report_contradiction(in, waiting.request_id); + continue; + } + wlog("batch_operator: underwriter left {} unbonded: {}", name_of(waiting.request_id), + magic_enum::enum_name(waiting.reason)); + } + } + + /// Report a request whose statement names a digest its outpost did not record for that epoch: the depot accepted + /// an envelope the outpost never emitted, or the statement misstates it. It is never bonded. + void report_contradiction(const uw::plan_inputs& in, uw::request_id_t request_id) { + const auto r = std::ranges::find(in.requests, request_id, &uw::request::id); + if (r == in.requests.end()) return; + const std::optional s = uw::decode_statement(r->statement); + if (!s) return; + const auto emitted = in.emitted.find({s->chain_code, s->epoch_index}); + if (emitted == in.emitted.end()) return; + elog("batch_operator: underwriter: {} states digest {}, but outpost {} recorded {} for epoch {}; not bonded", + describe(*r), s->digest.str(), s->chain_code.to_string(), emitted->second.str(), s->epoch_index); + } + + /// `sysio.bond::prune` from the first request, and `sysio.synd::pruneenv` for every shadow's pair. Both are + /// permissionless and act on at most UNDERWRITER_PRUNE_LIMIT rows. + void underwriter_prune(const std::map& tokens) { + underwriter_push(uw::bond::account, uw::bond::action_prune, + fc::mutable_variant_object() + (uw::bond::field::from_id, 0) + (uw::bond::field::limit, UNDERWRITER_PRUNE_LIMIT)); + for (const auto& [token_code, token] : tokens) { + underwriter_push(uw::synd::account, uw::synd::action_pruneenv, + fc::mutable_variant_object() + (uw::synd::field::chain_code, token.chain_code) + (uw::synd::field::token_code, token_code) + (uw::synd::field::limit, UNDERWRITER_PRUNE_LIMIT)); + } + } + + /// Keep one outpost client per active chain for verification, rebuilding one whose deployment moved. A chain this + /// node cannot serve is reported and its requests wait; the other chains go on. + void underwriter_refresh_clients() { + try { + const std::optional> active = read_active_outposts(); + if (!active) return; + drop_stale(underwriter_clients, *active, [](fc::slug_name chain_code, std::string_view why) { + ilog("batch_operator: underwriter dropped its client for {} outpost {}", why, chain_code.to_string()); + }); + const std::vector unserviceable = build_missing( + underwriter_clients, *active, solana_outpost_role::underwriter, + [](const outpost_descriptor& op, const std::shared_ptr& client) { + return built_outpost_client{client, outpost_deployment{op}}; + }); + for (const unserviceable_chain& chain : unserviceable) { + elog("batch_operator: underwriter cannot verify chain {}, its requests wait: {}", chain.code, chain.why); + } + } catch (const fc::exception& e) { + wlog("batch_operator: underwriter could not refresh its outpost clients: {}", e.top_message()); + } catch (const std::exception& e) { + wlog("batch_operator: underwriter could not refresh its outpost clients: {}", e.what()); + } + } + + /// Everything a pass decides from but the verification, or nullopt when a read failed (logged by chain_plugin). + std::optional read_underwriter_state(const std::map& tokens, + fc::time_point now) { + uw::plan_inputs in; + in.now = now; + std::optional> requests = read_bond_requests(underwriter_scan_from, symbols_of(tokens)); + if (!requests) return std::nullopt; + in.requests = std::move(*requests); + std::optional> bonds = + read_own_bonds(underwriter_scan_from, in.requests); + if (!bonds) return std::nullopt; + in.bonds = std::move(*bonds); + in.caps = caps_by_token(tokens); + std::optional> balances = read_own_balances(tokens); + if (!balances) return std::nullopt; + in.balances = std::move(*balances); + const std::optional frozen = read_cord_pulled(); + if (!frozen) return std::nullopt; + in.frozen = *frozen; + return in; + } + + /// The symbol of every token a request can be bonded in: each `sysio.liq` shadow, and WIRE. + static uw::token_symbols symbols_of(const std::map& tokens) { + uw::token_symbols out{{uw::wire::token_code, uw::wire::asset_symbol}}; + for (const auto& [token_code, token] : tokens) out.emplace(token_code, token.symbol); + return out; + } + + /// Every `sysio.bond::requests` row from id `from` that decodes with `symbols`, or nullopt when the read failed. + std::optional> read_bond_requests(uw::request_id_t from, const uw::token_symbols& symbols) { + const std::optional rows = read_all_rows(uw::bond::account, uw::bond::account, + uw::bond::table_requests, + fc::mutable_variant_object()(uw::bond::field::id, from)); + if (!rows) return std::nullopt; + std::vector out; + size_t undecodable = 0; + for (const fc::variant& r : *rows) { + std::optional decoded = uw::decode_request(r.get_object(), symbols); + if (decoded) out.push_back(std::move(*decoded)); + else ++undecodable; + } + if (undecodable > 0) { + elog("batch_operator: underwriter skips {} sysio.bond::requests row(s) that do not decode", undecodable); + } + return out; + } + + /// The underwriter's `sysio.bond::bonds` rows on requests from id `from`, by request id, each in its request's + /// token, or nullopt when the read failed or one of them does not decode or names no request among `requests`: a + /// bond it cannot read or attribute to a token must not drop out of its exposure. The table is keyed by request id + /// first, and the scan window `from` holds every request the underwriter has a bond on. + std::optional> + read_own_bonds(uw::request_id_t from, const std::vector& requests) { + const std::optional rows = + read_all_rows(uw::bond::account, uw::bond::account, uw::bond::table_bonds, + fc::mutable_variant_object()(uw::bond::field::request_id, from) + (uw::bond::field::underwriter, chain::name())); + if (!rows) return std::nullopt; + std::map out; + try { + for (const fc::variant& r : *rows) { + const std::optional b = + uw::decode_bond(r.get_object(), underwriter_auth.actor, requests); + if (b) out.emplace(b->request_id, *b); + } + } catch (const fc::exception& e) { + elog("batch_operator: underwriter skips this pass: {}", e.top_message()); + underwriter_scan_from = 0; // read every request next pass, in case the scan window was what missed one + return std::nullopt; + } + return out; + } + + /// Every `sysio.liq` shadow by registry token code, or nullopt when the read failed. + std::optional> read_liq_tokens() { + namespace liq = batch_operator_detail::liq; + const std::optional rows = read_all_rows(liq::account, liq::account, liq::table_stat); + if (!rows) return std::nullopt; + std::map out; + size_t undecodable = 0; + for (const fc::variant& r : *rows) { + try { + const fc::variant_object& row = r.get_object(); + out.emplace(row[liq::field::token_code].as(), + liq_token{ + .symbol = chain::asset::from_string(row[liq::field::supply].as_string()).get_symbol(), + .chain_code = row[liq::field::chain_code].as(), + }); + } catch (const fc::exception&) { + ++undecodable; + } + } + if (undecodable > 0) { + elog("batch_operator: underwriter skips {} sysio.liq::stat row(s) that do not decode", undecodable); + } + return out; + } + + /// The configured exposure caps keyed by registry token code. A cap whose precision differs from its shadow's is + /// not applied, so that token's requests wait with no cap until the configuration is fixed; a cap that names no + /// shadow is reported. + std::map caps_by_token(const std::map& tokens) { + std::map out; + std::set named; // the caps that name a shadow + for (const auto& [token_code, token] : tokens) { + const auto cap = underwriter_caps.find(token.symbol.to_symbol_code()); + if (cap == underwriter_caps.end()) continue; + named.insert(cap->first); + if (cap->second.get_symbol() != token.symbol) { + elog("batch_operator: batch-underwriter-max-exposure {} does not match the shadow's symbol {}; not " + "underwriting it", cap->second.to_string(), token.symbol.to_string()); + continue; + } + out.emplace(token_code, cap->second); + } + for (const auto& [code, cap] : underwriter_caps) { + if (named.contains(code)) continue; + wlog("batch_operator: batch-underwriter-max-exposure {} names no sysio.liq shadow", cap.to_string()); + } + return out; + } - auto completion = std::make_shared(); - auto future = completion->get_future(); + /// The underwriter's `sysio.liq` balances by registry token code, or nullopt when the read failed. + std::optional> + read_own_balances(const std::map& tokens) { + namespace liq = batch_operator_detail::liq; + const std::optional rows = + read_all_rows(liq::account, underwriter_auth.actor.to_string(), liq::table_accounts); + if (!rows) return std::nullopt; + std::map by_symbol; + for (const fc::variant& r : *rows) { + try { + const chain::asset held = chain::asset::from_string(r.get_object()[liq::field::balance].as_string()); + by_symbol.insert_or_assign(held.get_symbol().to_symbol_code(), held); + } catch (const fc::exception&) { + // A row that does not decode is no balance to bond from. + } + } + std::map out; + for (const auto& [token_code, token] : tokens) { + const auto held = by_symbol.find(token.symbol.to_symbol_code()); + if (held != by_symbol.end() && held->second.get_symbol() == token.symbol && held->second.get_amount() > 0) { + out.emplace(token_code, held->second); + } + } + return out; + } + + /// Whether the `sysio.andon` cord is pulled, or nullopt when that cannot be read. Clear until `sysio.andon` runs + /// code, and while it holds no cord row. + std::optional read_cord_pulled() { + if (!andon_deployed) return false; + sysio::chain_apis::read_only::get_table_rows_params p; + p.code = chain::name(uw::andon::account); + p.scope = uw::andon::account; + p.table = uw::andon::table_cord; + p.limit = 1; + p.values_only = true; + const std::optional rows = read_table_checked(std::move(p)); + if (!rows) return std::nullopt; + if (rows->rows.empty()) return false; + try { + return rows->rows.front().get_object()[uw::andon::field::pulled].as_bool(); + } catch (const fc::exception&) { + elog("batch_operator: underwriter pauses: the sysio.andon cord does not decode"); + return std::nullopt; + } + } + + /// The digest each outpost recorded for the envelope it emitted, for every (chain, epoch) an OPEN envelope request + /// names, read at finality. A chain whose read fails is reported and leaves only its own requests waiting. + std::map read_emitted_digests(const std::vector& requests) { + return uw::collect_emitted( + uw::wanted_envelopes(requests), + [&](fc::slug_name chain_code, uint32_t epoch) -> std::optional { + if (shutting_down) return std::nullopt; + const auto client = underwriter_clients.find(chain_code); + FC_ASSERT(client != underwriter_clients.end(), "this node has no client for it"); + return client->second.client->read_emitted_envelope_digest(epoch, delivery_timeout); + }, + [](fc::slug_name chain_code, const std::string& why) { report_unreadable(chain_code, why); }); + } + + /// Report that `chain_code`'s outpost could not be read this pass, and why. + static void report_unreadable(fc::slug_name chain_code, std::string_view why) { + wlog("batch_operator: underwriter cannot read outpost {}, its requests wait: {}", chain_code.to_string(), why); + } - rw->push_transaction( - packed_var.get_object(), - batch_operator_detail::create_push_action_callback(rw, completion, contract, action_name)); + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /// An unsigned depot transaction and the digest its signature covers. + struct unsigned_trx { + chain::signed_transaction trx; + chain::digest_type digest; + }; + + /// `contract::action_name` declaring `auth`, unsigned, against the current head. Reads chain state (the ABI, the + /// head): call on the main thread or in a read window. Nullopt, logged, when it cannot be built. + std::optional build_unsigned(const std::string& contract, const std::string& action_name, + const chain::permission_level& auth, const fc::variant_object& data) { + const chain::controller& chain = chain_plug->chain(); + try { + auto resolver = make_resolver(chain, delivery_timeout, throw_on_yield::no); + std::optional abis_opt = resolver(chain::name(contract)); + if (!abis_opt) { + elog("batch_operator: no ABI found for {}", contract); + return std::nullopt; + } + const chain::type_name action_type = abis_opt->get_action_type(chain::name(action_name)); + chain::bytes action_data = abis_opt->variant_to_binary( + action_type, fc::variant(data), chain::abi_serializer::create_yield_function(delivery_timeout)); + unsigned_trx out; + out.trx.actions.emplace_back(std::vector{auth}, chain::name(contract), + chain::name(action_name), std::move(action_data)); + out.trx.set_reference_block(chain.head().id()); + out.trx.expiration = fc::time_point_sec(chain.head().block_time() + PUSH_EXPIRATION); + out.digest = out.trx.sig_digest(chain.get_chain_id(), out.trx.context_free_data); + return out; + } catch (const fc::exception& e) { + elog("batch_operator: cannot build {}::{}: {}", contract, action_name, e.to_string()); + } catch (const std::exception& e) { + elog("batch_operator: cannot build {}::{}: {}", contract, action_name, e.what()); + } + return std::nullopt; + } + + /// Submit a signed transaction through `read_write::push_transaction`, completing `completion`. Resolves ABIs: + /// call on the main thread. + void submit(const std::shared_ptr& completion, + const std::string& contract, const std::string& action_name, const fc::variant& packed_var) { + try { + std::shared_ptr rw = + std::make_shared(chain_plug->get_read_write_api(delivery_timeout)); + rw->push_transaction( + packed_var.get_object(), + batch_operator_detail::create_push_action_callback(rw, completion, contract, action_name)); + } catch (const fc::exception& e) { + completion->complete([&] { elog(batch_operator_detail::push_action_log::failure, contract, action_name, + e.to_string()); }); + } catch (const std::exception& e) { + completion->complete([&] { elog(batch_operator_detail::push_action_log::failure, contract, action_name, + e.what()); }); + } + } - if (future.wait_for(std::chrono::milliseconds(delivery_timeout_ms)) == std::future_status::timeout) { + /// Whether `future` is ready by `deadline`. + template + static bool ready_by(const std::future& future, fc::time_point deadline) { + const fc::microseconds left = std::max(deadline - fc::time_point::now(), fc::microseconds(0)); + return future.wait_for(std::chrono::microseconds(left.count())) == std::future_status::ready; + } + + /// Build, sign and submit a depot action, declaring `by.auth` and signed by `by.provider`. True when the node + /// accepted the transaction within `delivery_timeout`; a refusal is logged by the push callback. + /// + /// Called from cron threads, which may not read chain state: the build runs in a read window and the submission + /// on the main thread, while the signature, possibly a remote signer, stays here. The waits do not cancel + /// anything: each posted task owns what it uses, so returning on a timeout is safe. + bool push_action(const std::string& contract, + const std::string& action_name, + const signer& by, + const fc::variant_object& data) { + if (shutting_down) return false; + const fc::time_point deadline = fc::time_point::now() + delivery_timeout; + const bool on_main = std::this_thread::get_id() == app().executor().get_main_thread_id(); + + std::optional trx; + if (on_main) { + trx = build_unsigned(contract, action_name, by.auth, data); + } else { + std::shared_ptr>> built = + std::make_shared>>(); + std::future> built_future = built->get_future(); + // Shutdown is checked again when the task runs, since chain state may be going away by then. + app().executor().post(appbase::priority::medium, appbase::exec_queue::read_only, + [this, built, contract, action_name, auth = by.auth, data = fc::variant_object(data)] { + if (shutting_down) { + built->set_value(std::nullopt); + return; + } + built->set_value(build_unsigned(contract, action_name, auth, data)); + }); + if (!ready_by(built_future, deadline)) { + elog("batch_operator: push {}::{} timed out before signing", contract, action_name); + return false; + } + trx = built_future.get(); + } + if (!trx) return false; + trx->trx.signatures.push_back(by.provider->sign(trx->digest)); + + fc::variant packed_var; + chain::to_variant( + chain::packed_transaction(std::move(trx->trx), chain::packed_transaction::compression_type::none), + packed_var); + std::shared_ptr completion = + std::make_shared(); + std::future future = completion->get_future(); + if (on_main) { + submit(completion, contract, action_name, packed_var); + } else { + app().executor().post(appbase::priority::medium, appbase::exec_queue::read_write, + [this, completion, contract, action_name, packed_var = std::move(packed_var)] { + submit(completion, contract, action_name, packed_var); + }); + } + if (!ready_by(future, deadline)) { elog("batch_operator: push {}::{} timed out", contract, action_name); + return false; } + return completion->succeeded(); } // ----------------------------------------------------------------------- // Sync-gated startup // ----------------------------------------------------------------------- - /// The startup body deferred behind the sync gate: outpost - /// discovery → private cron_service creation (sized from the discovered - /// outposts) → epoch_tick scheduling → per-outpost relay jobs. Runs on the - /// main thread from {@link run_deferred_startup_or_quit} once the node is - /// synced. Deferral exists because `refresh_outposts` reads `sysio.chains` - /// LOCALLY: on a cold-booting operator node still replaying toward the - /// deploy blocks the read throws Account/Contract Query Exceptions - /// (3060002/3060003) — spurious boot-window errors the gate removes. + /// The startup body deferred behind the sync gate: signer resolution, the relay's outpost discovery, private + /// cron_service creation (sized from the discovered outposts), the relay's epoch_tick and the underwriter's poll, + /// then the per-outpost relay jobs. Runs on the main thread from {@link run_deferred_startup_or_quit} once the + /// node is synced. Deferral exists because both read chain state LOCALLY (each role's authority and links, + /// `sysio.chains`): on a cold-booting operator node still replaying toward the deploy blocks those reads fail + /// spuriously. void run_deferred_startup() { if (shutting_down) { return; } + // Each role pushes as its own account. A role whose key cannot sign for it, or whose permission may not + // authorize its actions, would only fail every push, so stop here instead, before anything is scheduled. + if (!resolve_signers()) { + elog("batch_operator_plugin: a configured role cannot sign its actions, shutting down node (fail-fast)"); + app().quit(); + return; + } + // Discover outposts before the private cron_service starts. Later refresh // ticks add/remove per-outpost cron jobs as the active chain set changes. - try { - refresh_outposts(); - } catch (const fc::exception& e) { - wlog("batch_operator_plugin: initial outpost discovery failed: {}. " - "Starting with 0 per-outpost jobs; refresh ticks will retry after " - "the chain has caught up.", e.to_string()); + if (enabled) { + try { + refresh_outposts(); + } catch (const fc::exception& e) { + wlog("batch_operator_plugin: initial outpost discovery failed: {}. " + "Starting with 0 per-outpost jobs; refresh ticks will retry after " + "the chain has caught up.", e.to_string()); + } } // Size the pool for startup outposts. Later dynamic outposts are added to - // the same queued cron service; the minimum keeps epoch_tick viable even + // the same queued cron service; the minimum keeps the polls viable even // when no outposts are known yet. - const std::size_t outpost_count = opp_jobs.size(); - const std::size_t required_threads = outpost_count * OPP_CRON_JOBS_PER_OUTPOST + EPOCH_TICK_CRON_JOBS; - const std::size_t thread_count = std::max(required_threads, MIN_CRON_THREADS); + const std::size_t outpost_count = opp_jobs.size(); + const std::size_t relay_jobs = + enabled ? outpost_count * OPP_CRON_JOBS_PER_OUTPOST + EPOCH_TICK_CRON_JOBS : 0; + const std::size_t underwriter_jobs = underwriter_enabled ? UNDERWRITER_CRON_JOBS : 0; + const std::size_t thread_count = std::max(relay_jobs + underwriter_jobs, MIN_CRON_THREADS); sysio::services::cron_service::options svc_opts; svc_opts.name = "batch_operator"; @@ -1124,24 +1876,24 @@ struct batch_operator_plugin::impl { ilog("batch_operator_plugin: cron_service started with {} thread(s) ({} outpost(s) discovered)", thread_count, outpost_count); - const auto poll_ms = epoch_poll_ms; - - // epoch_tick — refresh epoch state + election. Keeps `current_epoch` - // and `within_epoch_window` accurate for every per-outpost job. - { + const uint32_t poll_ms = whole_milliseconds(epoch_poll); + auto schedule_poll = [&](std::string label, std::function poll) { sysio::services::cron_service::job_schedule sched; sched.milliseconds = {sysio::services::cron_service::job_schedule::step_value{poll_ms}}; sysio::services::cron_service::job_metadata_t meta; - meta.label = "batch_operator_epoch_tick"; + meta.label = std::move(label); meta.one_at_a_time = true; - auto id = cron_svc->add(sched, - [this]() { poll_epoch_state(); }, - meta); + sysio::services::cron_service::job_id_t id = cron_svc->add(sched, std::move(poll), meta); cron_job_ids.push_back(id); ilog("batch_operator_plugin: scheduled {} (id={}, every {}ms)", meta.label, id, poll_ms); - } + }; - schedule_opp_jobs(); + // epoch_tick: refresh epoch state + election. Keeps `current_epoch` + // and `within_epoch_window` accurate for every per-outpost job. + if (enabled) schedule_poll("batch_operator_epoch_tick", [this]() { poll_epoch_state(); }); + if (underwriter_enabled) schedule_poll("batch_operator_underwriter", [this]() { underwriter_tick(); }); + + if (enabled) schedule_opp_jobs(); } /// {@link run_deferred_startup} plus the uniform fail-fast policy: the @@ -1189,7 +1941,7 @@ void batch_operator_plugin::set_program_options(options_description& cli, // would otherwise silently promote a debug node to a batch operator. opts("batch-operator-account", bpo::value(), "WIRE account name for this batch operator. Configuring an account enables the relay."); - opts("batch-epoch-poll-ms", bpo::value()->default_value(EPOCH_POLL_MS), + opts("batch-epoch-poll-ms", bpo::value()->default_value(whole_milliseconds(EPOCH_POLL)), "How often to check epoch state (ms)"); // SIZING RULE for batch-delivery-timeout-ms: it bounds the WHOLE outbound // delivery, and an Ethereum delivery is now one transaction PER CHUNK @@ -1207,19 +1959,48 @@ void batch_operator_plugin::set_program_options(options_description& cli, // chunking deliberately rather than as a blanket tuning step. // Help text below must stay ASCII with no " --" sequence (PerformanceHarness // splits nodeop --help output on that token). - opts("batch-delivery-timeout-ms", bpo::value()->default_value(DELIVERY_TIMEOUT_MS), + opts("batch-delivery-timeout-ms", bpo::value()->default_value(whole_milliseconds(DELIVERY_TIMEOUT)), "Max time to wait for chain delivery confirmation (ms)"); - opts("batch-yield-tick-interval-ms", bpo::value()->default_value(YIELD_TICK_INTERVAL_MS), + opts("batch-yield-tick-interval-ms", + bpo::value()->default_value(whole_milliseconds(YIELD_TICK_INTERVAL)), "Minimum spacing between this operator's sysio.swap::tickyield pushes per yield pool (ms)"); + // Same presence-is-the-switch rule as batch-operator-account, independently of it: a node may run the + // underwriter, the relay, or both. + opts("batch-underwriter-account", bpo::value(), + "WIRE account the underwriter bonds sysio.synd envelope requests from, as account or " + "account@permission (default active). Configuring it enables the underwriter, with or without the relay. " + "It signs with the one configured WIRE signature provider whose key alone satisfies that permission."); + opts("batch-underwriter-max-exposure", bpo::value>()->composing(), + "Most the underwriter may have bonded at once in one shadow token, as an asset such as " + "100.000000000 LIQETH. Repeat for each token; requests in a token without one are not bonded."); } void batch_operator_plugin::plugin_initialize(const variables_map& options) { if (options.count("batch-operator-account")) _impl->operator_account = chain::name(options["batch-operator-account"].as()); - _impl->epoch_poll_ms = options["batch-epoch-poll-ms"].as(); - _impl->delivery_timeout_ms = options["batch-delivery-timeout-ms"].as(); - _impl->yield_tick_interval_ms = options["batch-yield-tick-interval-ms"].as(); + _impl->epoch_poll = fc::milliseconds(options["batch-epoch-poll-ms"].as()); + _impl->delivery_timeout = fc::milliseconds(options["batch-delivery-timeout-ms"].as()); + _impl->yield_tick_interval = fc::milliseconds(options["batch-yield-tick-interval-ms"].as()); _impl->enabled = _impl->operator_account.good(); + if (options.count("batch-underwriter-account")) { + const std::string& spec = options["batch-underwriter-account"].as(); + try { + _impl->underwriter_auth = batch_operator_detail::parse_permission_level(spec); + } FC_RETHROW_EXCEPTIONS(error, "invalid batch-underwriter-account '{}'", spec) + } + _impl->underwriter_enabled = _impl->underwriter_auth.actor.good(); + if (options.count("batch-underwriter-max-exposure")) { + try { + _impl->underwriter_caps = batch_operator_detail::parse_exposure_caps( + options["batch-underwriter-max-exposure"].as>()); + } FC_RETHROW_EXCEPTIONS(error, "invalid batch-underwriter-max-exposure") + } + if (_impl->underwriter_enabled && _impl->underwriter_caps.empty()) { + wlog("batch_operator_plugin: batch-underwriter-account without batch-underwriter-max-exposure bonds nothing"); + } + if (!_impl->underwriter_enabled && !_impl->underwriter_caps.empty()) { + wlog("batch_operator_plugin: batch-underwriter-max-exposure has no effect without batch-underwriter-account"); + } _impl->chain_plug = &app().get_plugin(); _impl->cron_plug = &app().get_plugin(); _impl->eth_plug = &app().get_plugin(); @@ -1232,23 +2013,40 @@ void batch_operator_plugin::plugin_initialize(const variables_map& options) { // 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 || + FC_ASSERT(!(_impl->enabled || _impl->underwriter_enabled) || _impl->chain_plug->chain().get_read_mode() == chain::db_read_mode::IRREVERSIBLE, "batch_operator_plugin requires read-mode = irreversible"); + + // A failed push is billed subjectively to its authorizer, and both roles push on a schedule that loses some + // pushes (a race another operator won, a dispute already resolved, an action the irreversible view does not show + // done yet). Billed, an account would soon have every transaction refused by its own node, so the node exempts + // the accounts it signs as. + chain::subjective_billing& billing = _impl->chain_plug->chain().get_mutable_subjective_billing(); + for (const chain::name account : {_impl->operator_account, _impl->underwriter_auth.actor}) { + if (!account.good()) continue; + billing.disable_account(account); + ilog("batch_operator_plugin: {} is exempt from subjective CPU billing on this node", account.to_string()); + } } void batch_operator_plugin::plugin_startup() { - if (!_impl->enabled) { - ilog("batch_operator_plugin: no batch-operator-account configured, skipping startup"); + if (!_impl->enabled && !_impl->underwriter_enabled) { + ilog("batch_operator_plugin: neither batch-operator-account nor batch-underwriter-account configured, " + "skipping startup"); return; } - ilog("batch_operator_plugin: starting for account {}", _impl->operator_account.to_string()); - - // The startup body's outpost discovery reads `sysio.chains` LOCALLY. On a - // cold-booting operator node those reads see mid-sync (possibly genesis) - // state and throw spuriously, so the whole body (discovery → cron_service → - // epoch_tick → relay jobs) is DEFERRED until the node is synced — + if (_impl->enabled) + ilog("batch_operator_plugin: starting the relay for account {}", _impl->operator_account.to_string()); + if (_impl->underwriter_enabled) + ilog("batch_operator_plugin: starting the underwriter for {}@{}", _impl->underwriter_auth.actor.to_string(), + _impl->underwriter_auth.permission.to_string()); + + // The startup body's signer resolution and outpost discovery read chain + // state LOCALLY. On a cold-booting operator node those reads see mid-sync + // (possibly genesis) state and fail spuriously, so the whole body (signers, + // discovery, cron_service, the polls, relay jobs) is DEFERRED until the + // node is synced, per // `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 diff --git a/plugins/batch_operator_plugin/src/role_config.hpp b/plugins/batch_operator_plugin/src/role_config.hpp new file mode 100644 index 0000000000..b3402e91dd --- /dev/null +++ b/plugins/batch_operator_plugin/src/role_config.hpp @@ -0,0 +1,68 @@ +#pragma once +/** + * @file role_config.hpp + * @brief Parsing of the batch-operator plugin's role options and the choice of the key a role signs with. Pure + * functions, so the plugin's tests drive them without a node. + */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace sysio::batch_operator_detail { + +/// Parse `account[@permission]`, the permission defaulting to `active`. Throws when either part is empty or not a +/// valid name. +inline chain::permission_level parse_permission_level(const std::string& spec) { + const size_t at = spec.find('@'); + chain::permission_level level{chain::name(spec.substr(0, at)), chain::config::active_name}; + if (at != std::string::npos) level.permission = chain::name(spec.substr(at + 1)); + FC_ASSERT(level.actor.good(), "'{}' names no account", spec); + FC_ASSERT(level.permission.good(), "'{}' names no permission", spec); + return level; +} + +/// Parse the exposure caps, one asset each, keyed by symbol code. Throws on a malformed asset, an amount that is +/// not positive, or a symbol named twice. +inline std::map parse_exposure_caps(const std::vector& specs) { + std::map caps; + for (const std::string& spec : specs) { + const chain::asset cap = chain::asset::from_string(spec); + FC_ASSERT(cap.get_amount() > 0, "exposure cap {} must be positive", spec); + FC_ASSERT(caps.emplace(cap.get_symbol().to_symbol_code(), cap).second, + "exposure cap names {} more than once", cap.get_symbol().name()); + } + return caps; +} + +/// The provider a role signs with, and how many qualified. +template +struct signer_choice { + std::optional chosen; ///< set when exactly one qualified + std::size_t matches = 0; +}; + +/// Choose the one provider `qualifies` accepts. None, or more than one, chooses nothing: with two the choice would +/// fall to provider order. +template +signer_choice choose_signer(const std::vector& providers, Qualifies&& qualifies) { + signer_choice out; + for (const Provider& provider : providers) { + if (!qualifies(provider)) continue; + ++out.matches; + out.chosen = provider; + } + if (out.matches != 1) out.chosen.reset(); + return out; +} + +} // namespace sysio::batch_operator_detail diff --git a/plugins/batch_operator_plugin/src/underwriter.hpp b/plugins/batch_operator_plugin/src/underwriter.hpp new file mode 100644 index 0000000000..e0a32f2760 --- /dev/null +++ b/plugins/batch_operator_plugin/src/underwriter.hpp @@ -0,0 +1,459 @@ +#pragma once +/** + * @file underwriter.hpp + * @brief The decisions behind the underwriter: which `sysio.bond` requests issued by `sysio.synd` to bond, approve + * and claim, given what each outpost recorded of the envelopes it emitted. Pure functions over decoded rows, so + * the plugin's tests drive them without a chain. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sysio::batch_operator_detail::underwriter { + +/// `sysio.bond` identifiers the underwriter touches. +namespace bond { + constexpr auto account = "sysio.bond"; + constexpr auto table_requests = "requests"; + constexpr auto table_bonds = "bonds"; + constexpr auto action_accept = "accept"; + constexpr auto action_approve = "approve"; + constexpr auto action_claim = "claim"; + constexpr auto action_prune = "prune"; + namespace field { + constexpr auto id = "id"; + constexpr auto issuer = "issuer"; + constexpr auto schema = "schema"; + constexpr auto statement = "statement"; + constexpr auto token_code = "token_code"; + constexpr auto covered = "covered"; + constexpr auto bonded = "bonded"; + constexpr auto window_sec = "window_sec"; + constexpr auto state = "state"; + constexpr auto bonded_at = "bonded_at"; + constexpr auto outcome_acknowledged = "outcome_acknowledged"; + constexpr auto request_id = "request_id"; + constexpr auto underwriter = "underwriter"; + constexpr auto amount = "amount"; + constexpr auto paid = "paid"; + constexpr auto yield = "yield"; ///< a `bonds` row's yield position + constexpr auto owed_wire = "owed_wire"; ///< WIRE of it still owed + constexpr auto account = "account"; + constexpr auto from_id = "from_id"; + constexpr auto limit = "limit"; + } +} + +/// `sysio.synd` identifiers the underwriter touches. +namespace synd { + constexpr auto account = "sysio.synd"; + constexpr auto action_crank = "crank"; + constexpr auto action_pruneenv = "pruneenv"; + namespace field { + constexpr auto limit = "limit"; + constexpr auto chain_code = "chain_code"; + constexpr auto token_code = "token_code"; + } +} + +/// `sysio.andon` identifiers the underwriter reads: while the cord is pulled it bonds, approves and claims nothing. +namespace andon { + constexpr auto account = "sysio.andon"; + constexpr auto table_cord = "cord"; + namespace field { + constexpr auto pulled = "pulled"; + } +} + +/// The schema `sysio.synd` registers every envelope statement under (`sysio.opp.common`'s +/// `envelope_statement::schema`). +constexpr auto envelope_schema = "oppenvelope"; + +/// WIRE as `sysio.bond` handles it (`sysio.opp.common`'s `opp::wire`): the registry code of a request bonded in WIRE, +/// and the token a bond's yield is paid in. +namespace wire { + inline const fc::slug_name token_code{"WIRE"}; + inline const chain::symbol asset_symbol{9, "WIRE"}; +} + +/// The symbol of each token a request can be bonded in, by registry code: every `sysio.liq` shadow, and WIRE. +using token_symbols = std::map; + +/// `units` base units of `sym`, as `sysio.bond` stores an amount. Throws when no asset can carry them. +inline chain::asset asset_of(uint64_t units, chain::symbol sym) { + FC_ASSERT(units <= static_cast(chain::asset::max_amount), "{} base units exceed the asset range", units); + return chain::asset(static_cast(units), sym); +} + +/// `sysio.bond`'s request lifecycle, member for member: the chain renders the enum by these names. +enum class request_state : uint8_t { OPEN, BONDED, APPROVED, HELD, VALID, INVALID }; + +/// A `sysio.bond::requests` row id. `sysio.bond` assigns them in increasing order and never reuses one. +using request_id_t = uint64_t; + +/// One `sysio.bond::requests` row. +struct request { + request_id_t id = 0; + chain::name issuer{}; + chain::name schema{}; + std::vector statement; + fc::slug_name token_code{}; ///< registry code of the bond token + chain::asset covered{}; ///< what the underwriters must bond in total, in the bond token + chain::asset bonded{}; ///< what they have bonded so far + fc::microseconds window{}; ///< challenge window after full bonding + request_state state = request_state::OPEN; + fc::time_point bonded_at{}; ///< when bonded reached covered + bool outcome_acknowledged = false; ///< the issuer has processed the request's terminal state +}; + +/// Whether `r` is an envelope request: issued by `sysio.synd` under the envelope schema. No other request is +/// underwritten. +inline bool is_envelope_request(const request& r) { + return r.issuer == chain::name(synd::account) && r.schema == chain::name(envelope_schema); +} + +/// Whether no action moves a request out of `state`. +inline bool is_terminal(request_state state) { + return state == request_state::APPROVED || state == request_state::VALID || state == request_state::INVALID; +} + +/// Decode a `requests` row (`values_only`), its amounts in the symbol `symbols` names for its token, or nullopt when +/// it does not decode or its token has no symbol there. +inline std::optional decode_request(const fc::variant_object& row, const token_symbols& symbols) { + try { + const std::optional state = + magic_enum::enum_cast(row[bond::field::state].as_string()); + if (!state) return std::nullopt; + const fc::slug_name token_code = row[bond::field::token_code].as(); + const auto symbol = symbols.find(token_code); + if (symbol == symbols.end()) return std::nullopt; + return request{ + .id = row[bond::field::id].as_uint64(), + .issuer = chain::name(row[bond::field::issuer].as_string()), + .schema = chain::name(row[bond::field::schema].as_string()), + .statement = row[bond::field::statement].as>(), + .token_code = token_code, + .covered = asset_of(row[bond::field::covered].as_uint64(), symbol->second), + .bonded = asset_of(row[bond::field::bonded].as_uint64(), symbol->second), + .window = fc::seconds(row[bond::field::window_sec].as_int64()), + .state = *state, + .bonded_at = row[bond::field::bonded_at].as(), + .outcome_acknowledged = row[bond::field::outcome_acknowledged].as_bool(), + }; + } catch (const fc::exception&) { + return std::nullopt; + } +} + +/// One underwriter's bond on one request (`sysio.bond::bonds`). +struct bond_position { + request_id_t request_id = 0; + chain::asset amount{}; ///< bonded, in the request's token + bool paid = false; ///< `claim` returned the principal + /// WIRE yield still owed: a claim the yield pool could not cover in full leaves some. + chain::asset owed_wire = chain::asset(0, wire::asset_symbol); +}; + +/// Decode a `bonds` row (`values_only`) when it is `underwriter`'s, else nullopt; its amount is in the token of its +/// request among `requests`. Throws when the row is the underwriter's but does not decode, names no underwriter, or +/// names a request missing from `requests`: a bond it cannot read or attribute to a token must stop the pass rather +/// than drop out of its exposure. `sysio.bond` keeps a request until every bond on it is paid, so only a request row +/// that did not decode leaves one missing. +inline std::optional decode_bond(const fc::variant_object& row, chain::name underwriter, + const std::vector& requests) { + const auto owner = row.find(bond::field::underwriter); + FC_ASSERT(owner != row.end(), "a sysio.bond::bonds row names no underwriter"); + if (chain::name(owner->value().as_string()) != underwriter) return std::nullopt; + const request_id_t request_id = row[bond::field::request_id].as_uint64(); + const auto r = std::ranges::find(requests, request_id, &request::id); + FC_ASSERT(r != requests.end(), "request {} holds a bond of ours, but its sysio.bond::requests row did not decode", + request_id); + const uint64_t owed_wire = row[bond::field::yield].get_object()[bond::field::owed_wire].as_uint64(); + return bond_position{ + .request_id = request_id, + .amount = asset_of(row[bond::field::amount].as_uint64(), r->covered.get_symbol()), + .paid = row[bond::field::paid].as_bool(), + .owed_wire = asset_of(owed_wire, wire::asset_symbol), + }; +} + +/// Whether `sysio.synd` has queue work that only a crank moves: an envelope request still in play (OPEN, BONDED or +/// HELD), or one ruled whose outcome it has not acknowledged yet. +inline bool synd_has_work(const std::vector& requests) { + return std::ranges::any_of(requests, [](const request& r) { + return is_envelope_request(r) && (!is_terminal(r.state) || !r.outcome_acknowledged); + }); +} + +/// The lowest request id a later pass still needs to read: every request in play, ruled but not acknowledged, or +/// bonded by the underwriter. Request ids only grow and a settled request never comes back into play, so the rows +/// below it can be skipped. One past the newest read when nothing needs a later look; `from` when nothing was read. +inline request_id_t next_scan_start(request_id_t from, const std::vector& requests, + const std::map& bonds, + const std::set& bonded) { + std::optional lowest; + const auto keep = [&](request_id_t id) { lowest = std::min(lowest.value_or(id), id); }; + request_id_t next = from; + for (const request& r : requests) { + next = std::max(next, r.id + 1); + if (is_envelope_request(r) && (!is_terminal(r.state) || !r.outcome_acknowledged)) keep(r.id); + } + for (const auto& [id, bond] : bonds) keep(id); + for (const request_id_t id : bonded) keep(id); + return lowest.value_or(next); +} + +/// The envelope requests among `bonded` ruled INVALID, in id order: the underwriter's bond on each is forfeited. +/// Whether the bond row is paid, or even still there, does not matter: anyone may `claim` a forfeited bond's yield, +/// which marks it paid, and `prune` then erases the row. +inline std::vector forfeited_requests(const std::vector& requests, + const std::set& bonded) { + std::vector out; + for (const request& r : requests) { + if (is_envelope_request(r) && r.state == request_state::INVALID && bonded.contains(r.id)) out.push_back(r.id); + } + std::ranges::sort(out); + return out; +} + +/// Add the request behind each of the underwriter's bond rows to `bonded`, and drop the requests no longer among +/// `requests`. A bond row can go before its request (a paid one is pruned), and a forfeit on it must still be seen. +inline void remember_bonded(std::set& bonded, const std::map& bonds, + const std::vector& requests) { + for (const auto& [id, bond] : bonds) bonded.insert(id); + std::erase_if(bonded, + [&](request_id_t id) { return std::ranges::find(requests, id, &request::id) == requests.end(); }); +} + +/// Which forfeit stops bonding. The forfeits on chain at the first pass do not: the (re)start that began this run is +/// what resumes bonding after one. Any later forfeit halts bonding until the next restart. +struct forfeit_watch { + std::optional> known; ///< the forfeits seen at the first pass + std::optional halted_by; ///< the request whose forfeit halted bonding + + /// Note this pass's forfeits. True when one of them halts bonding now. + bool note(const std::vector& forfeited) { + if (!known) { + known.emplace(forfeited.begin(), forfeited.end()); + return false; + } + if (halted_by) return false; + for (const request_id_t id : forfeited) { + if (known->contains(id)) continue; + halted_by = id; + return true; + } + return false; + } +}; + +/// An envelope statement, as `sysio.synd` packs it for `sysio.bond` (`sysio.opp.common`'s envelope_statement.hpp). +struct envelope_statement { + fc::slug_name chain_code{}; ///< the outpost that sent the envelope + uint32_t epoch_index = 0; ///< the depot epoch the envelope was accepted for + fc::sha256 digest{}; ///< the envelope's epoch digest, as `sysio.msgch` accepted it + fc::slug_name token_code{}; ///< the liq token the statement covers +}; + +/// Bytes of one encoded statement: the chain code, the epoch, the digest and the token code, little-endian and +/// without a size prefix. +inline constexpr size_t STATEMENT_BYTES = sizeof(uint64_t) + sizeof(uint32_t) + sizeof(fc::sha256) + sizeof(uint64_t); + +/// Decode statement bytes, or nullopt when they are not exactly one encoded statement. +inline std::optional decode_statement(const std::vector& bytes) { + if (bytes.size() != STATEMENT_BYTES) return std::nullopt; + const unsigned char* p = reinterpret_cast(bytes.data()); + envelope_statement s; + s.chain_code = fc::slug_name{boost::endian::load_little_u64(p)}; + s.epoch_index = boost::endian::load_little_u32(p + sizeof(uint64_t)); + std::memcpy(s.digest.data(), p + sizeof(uint64_t) + sizeof(uint32_t), sizeof(fc::sha256)); + s.token_code = fc::slug_name{ + boost::endian::load_little_u64(p + sizeof(uint64_t) + sizeof(uint32_t) + sizeof(fc::sha256))}; + return s; +} + +/// The (outpost chain, depot epoch) an envelope statement names. +using envelope_key = std::pair; + +/// The (chain, epoch) of every OPEN envelope request with a remainder to bond: the outpost records a pass reads. +inline std::set wanted_envelopes(const std::vector& requests) { + std::set wanted; + for (const request& r : requests) { + if (r.state != request_state::OPEN || !is_envelope_request(r) || r.bonded >= r.covered) continue; + if (const std::optional s = decode_statement(r.statement)) { + wanted.emplace(s->chain_code, s->epoch_index); + } + } + return wanted; +} + +/// The digest each outpost recorded for the `wanted` epochs, read with `read(chain, epoch)`. A chain whose read +/// throws is reported through `on_failure(chain, why)` once and not read again, so it holds back only its own +/// requests. +template +std::map collect_emitted(const std::set& wanted, Read&& read, + OnFailure&& on_failure) { + std::map emitted; + std::set failed; + for (const envelope_key& key : wanted) { + const auto& [chain_code, epoch] = key; + if (failed.contains(chain_code)) continue; + try { + if (const std::optional digest = read(chain_code, epoch)) emitted.emplace(key, *digest); + } catch (const fc::exception& e) { + on_failure(chain_code, e.top_message()); + failed.insert(chain_code); + } catch (const std::exception& e) { + on_failure(chain_code, std::string(e.what())); + failed.insert(chain_code); + } + } + return emitted; +} + +/// Why an OPEN request is not bonded this pass. +enum class wait_reason : uint8_t { + BAD_STATEMENT, ///< the statement does not decode + TOKEN_MISMATCH, ///< the statement's token is not the request's bond token + UNVERIFIED, ///< the outpost holds no final record of an envelope for that epoch + CONTRADICTED, ///< the outpost recorded another digest for that epoch: the statement is false + NO_CAP, ///< no exposure cap is configured for the token + OVER_CAP, ///< bonding it would exceed the token's exposure cap + LOW_BALANCE ///< the underwriter does not hold enough of the token +}; + +/// Bond `amount` on request `request_id`. +struct accept_action { + request_id_t request_id = 0; + chain::asset amount{}; +}; + +/// An OPEN request left for a later pass. +struct waiting_request { + request_id_t request_id = 0; + wait_reason reason = wait_reason::UNVERIFIED; +}; + +/// Everything one pass decides. +struct plan { + std::vector accepts; + std::vector approves; ///< BONDED by the underwriter with the window passed + std::vector claims; ///< APPROVED or VALID with principal or WIRE yield still owed to it + std::vector held; ///< bonded by the underwriter and challenged: `sysio` must rule + std::vector blocked; ///< challenged before anyone bonded: stalls its pair until ruled + std::vector forfeited; ///< bonded by the underwriter and ruled INVALID + /// Ruled INVALID with what the underwriter's bond earned still unsettled: claimed with the housekeeping. The claim + /// returns no principal; it pays the earnings into `sysio.liq`, which lets `sysio.bond` prune the request. + std::vector forfeit_claims; + std::vector waiting; +}; + +/// What one pass decides from. +struct plan_inputs { + std::vector requests; ///< `requests` rows, any issuer + std::map bonds; ///< the underwriter's bonds, by request id + /// Every request the underwriter bonded, including those whose bond row is gone. + std::set bonded; + /// The digest each outpost recorded for the envelope it emitted, for the (chain, epoch) pairs read this pass. + std::map emitted; + std::map caps; ///< token code -> most the underwriter may have bonded at once + std::map balances; ///< token code -> the underwriter's balance + fc::time_point now{}; + /// The `sysio.andon` cord is pulled: nothing is bonded, approved or claimed until it clears. + bool frozen = false; + /// The underwriter stopped bonding after a forfeit: it still approves and claims what it bonded. + bool halted = false; +}; + +/// Decide one pass. Only envelope requests are underwritten, and only when the outpost's own record of the envelope +/// it emitted for the statement's epoch carries the statement's digest. OPEN requests are taken oldest first, so a +/// pair's envelopes are bonded in the order `sysio.synd` releases them; each bonds its whole remainder or waits. A +/// bond counts against its token's cap until `claim` returns it, unless it was forfeited. +inline plan plan_actions(const plan_inputs& in) { + std::map by_id; + for (const request& r : in.requests) by_id.emplace(r.id, &r); + + std::map outstanding; // token code -> unsettled bonds + for (const auto& [id, b] : in.bonds) { + const auto r = by_id.find(id); + if (b.paid || r == by_id.end() || r->second->state == request_state::INVALID) continue; + chain::asset& sum = outstanding.try_emplace(r->second->token_code, 0, b.amount.get_symbol()).first->second; + sum = sum + b.amount; + } + std::map balances = in.balances; + + std::vector ordered; + for (const request& r : in.requests) { + if (is_envelope_request(r)) ordered.push_back(&r); + } + std::ranges::sort(ordered, {}, &request::id); + + plan out; + out.forfeited = forfeited_requests(in.requests, in.bonded); + for (const request* r : ordered) { + const auto bond = in.bonds.find(r->id); + const bool ours = bond != in.bonds.end(); + // Our bond row is unpaid, or WIRE the pool could not yet cover is still owed on it: a claim has work to do. + const bool unsettled = ours && (!bond->second.paid || bond->second.owed_wire.get_amount() > 0); + switch (r->state) { + case request_state::OPEN: { + if (in.frozen || in.halted || r->bonded >= r->covered) break; + const chain::asset remainder = r->covered - r->bonded; + auto wait = [&](wait_reason why) { out.waiting.push_back({r->id, why}); }; + const std::optional s = decode_statement(r->statement); + if (!s) { wait(wait_reason::BAD_STATEMENT); break; } + if (s->token_code != r->token_code) { wait(wait_reason::TOKEN_MISMATCH); break; } + const auto emitted = in.emitted.find({s->chain_code, s->epoch_index}); + if (emitted == in.emitted.end()) { wait(wait_reason::UNVERIFIED); break; } + if (emitted->second != s->digest) { wait(wait_reason::CONTRADICTED); break; } + const auto cap = in.caps.find(r->token_code); + if (cap == in.caps.end()) { wait(wait_reason::NO_CAP); break; } + chain::asset& used = outstanding.try_emplace(r->token_code, 0, remainder.get_symbol()).first->second; + if (used + remainder > cap->second) { wait(wait_reason::OVER_CAP); break; } + const auto balance = balances.find(r->token_code); + if (balance == balances.end() || balance->second < remainder) { wait(wait_reason::LOW_BALANCE); break; } + out.accepts.push_back({r->id, remainder}); + used = used + remainder; + balance->second = balance->second - remainder; + break; + } + case request_state::BONDED: + if (!in.frozen && ours && in.now >= r->bonded_at + r->window) out.approves.push_back(r->id); + break; + case request_state::APPROVED: + case request_state::VALID: + if (!in.frozen && unsettled) out.claims.push_back(r->id); + break; + case request_state::INVALID: // reported through `forfeited` + if (!in.frozen && unsettled) out.forfeit_claims.push_back(r->id); + break; + case request_state::HELD: + if (ours) out.held.push_back(r->id); + else out.blocked.push_back(r->id); + break; + } + } + return out; +} + +} // namespace sysio::batch_operator_detail::underwriter diff --git a/plugins/batch_operator_plugin/src/yield_cranks.hpp b/plugins/batch_operator_plugin/src/yield_cranks.hpp index 6eaf30ad11..c1498fa858 100644 --- a/plugins/batch_operator_plugin/src/yield_cranks.hpp +++ b/plugins/batch_operator_plugin/src/yield_cranks.hpp @@ -41,15 +41,21 @@ namespace swap { } } -/// `sysio.liq` identifiers the pending-yield crank touches. +/// `sysio.liq` identifiers the pending-yield crank and the underwriter touch. namespace liq { constexpr auto account = "sysio.liq"; constexpr auto table_liqpending = "liqpending"; + constexpr auto table_accounts = "accounts"; + constexpr auto table_stat = "stat"; constexpr auto action_queueyield = "queueyield"; namespace field { constexpr auto quantity = "quantity"; constexpr auto symbol_code = "symbol_code"; ///< the kv key of `liqpending` constexpr auto sym = "sym"; ///< `queueyield`'s argument + constexpr auto balance = "balance"; ///< an `accounts` row's holding + constexpr auto supply = "supply"; ///< a `stat` row's supply, in the shadow's symbol + constexpr auto token_code = "token_code"; ///< a `stat` row's registry token code + constexpr auto chain_code = "chain_code"; ///< a `stat` row's outpost } } 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 94babd52b5..cbdb8afe19 100644 --- a/plugins/batch_operator_plugin/test/mocks/mock_outpost_client.hpp +++ b/plugins/batch_operator_plugin/test/mocks/mock_outpost_client.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -78,6 +79,19 @@ class mock_outpost_client : public sysio::outpost_client { return inbound_response(call); } + /// read_emitted_envelope_digest response, by epoch; the default holds no record. + std::function(uint32_t)> emitted_digest_response = + [](uint32_t) { return std::optional{}; }; + + std::optional read_emitted_envelope_digest(uint32_t epoch_index, + fc::microseconds deadline) override { + { + std::lock_guard lock(_mx); + emitted_digest_calls.push_back(inbound_call{epoch_index, deadline}); + } + return emitted_digest_response(epoch_index); + } + struct crank_call { uint32_t epoch_index = 0; fc::microseconds deadline; @@ -97,6 +111,7 @@ class mock_outpost_client : public sysio::outpost_client { std::vector outbound_calls; std::vector inbound_calls; + std::vector emitted_digest_calls; std::vector crank_calls; std::vector caller_address; diff --git a/plugins/batch_operator_plugin/test/test_role_config.cpp b/plugins/batch_operator_plugin/test/test_role_config.cpp new file mode 100644 index 0000000000..0f13b1d4c1 --- /dev/null +++ b/plugins/batch_operator_plugin/test/test_role_config.cpp @@ -0,0 +1,67 @@ +#include "../src/role_config.hpp" + +#include + +#include +#include + +using namespace sysio::batch_operator_detail; + +BOOST_AUTO_TEST_SUITE(role_config_tests) + +BOOST_AUTO_TEST_CASE(a_permission_level_defaults_to_active) { + const auto level = parse_permission_level("uw.acct"); + BOOST_CHECK_EQUAL(std::string("uw.acct"), level.actor.to_string()); + BOOST_CHECK_EQUAL(std::string("active"), level.permission.to_string()); + + const auto custom = parse_permission_level("uw.acct@underwrite"); + BOOST_CHECK_EQUAL(std::string("uw.acct"), custom.actor.to_string()); + BOOST_CHECK_EQUAL(std::string("underwrite"), custom.permission.to_string()); +} + +BOOST_AUTO_TEST_CASE(a_permission_level_needs_an_account_and_a_permission) { + BOOST_CHECK_THROW(parse_permission_level(""), fc::exception); + BOOST_CHECK_THROW(parse_permission_level("@active"), fc::exception); + BOOST_CHECK_THROW(parse_permission_level("uw.acct@"), fc::exception); + BOOST_CHECK_THROW(parse_permission_level("Uw.Acct"), fc::exception); // not a name + BOOST_CHECK_THROW(parse_permission_level("uw.acct@Active"), fc::exception); // not a name +} + +BOOST_AUTO_TEST_CASE(exposure_caps_are_keyed_by_symbol_code) { + const auto caps = parse_exposure_caps({"100.000000000 LIQETH", "5.000000000 LIQSOL"}); + BOOST_REQUIRE_EQUAL(2u, caps.size()); + const auto liqeth = sysio::chain::asset::from_string("100.000000000 LIQETH"); + const auto found = caps.find(liqeth.get_symbol().to_symbol_code()); + BOOST_REQUIRE(found != caps.end()); + BOOST_CHECK(liqeth == found->second); + BOOST_CHECK(parse_exposure_caps({}).empty()); +} + +BOOST_AUTO_TEST_CASE(an_exposure_cap_must_be_positive_well_formed_and_named_once) { + BOOST_CHECK_THROW(parse_exposure_caps({"0.000000000 LIQETH"}), fc::exception); + BOOST_CHECK_THROW(parse_exposure_caps({"-1.000000000 LIQETH"}), fc::exception); + BOOST_CHECK_THROW(parse_exposure_caps({"100 liqeth"}), fc::exception); + BOOST_CHECK_THROW(parse_exposure_caps({"many LIQETH"}), fc::exception); + // The same symbol twice, even at another precision. + BOOST_CHECK_THROW(parse_exposure_caps({"1.000000000 LIQETH", "2.0 LIQETH"}), fc::exception); +} + +BOOST_AUTO_TEST_CASE(a_signer_is_chosen_only_when_exactly_one_provider_qualifies) { + const std::vector providers{1, 2, 3, 4}; + + const auto none = choose_signer(providers, [](int p) { return p > 4; }); + BOOST_CHECK(!none.chosen.has_value()); + BOOST_CHECK_EQUAL(0u, none.matches); + + const auto one = choose_signer(providers, [](int p) { return p == 3; }); + BOOST_REQUIRE(one.chosen.has_value()); + BOOST_CHECK_EQUAL(3, *one.chosen); + BOOST_CHECK_EQUAL(1u, one.matches); + + // Two would leave the choice to provider order. + const auto two = choose_signer(providers, [](int p) { return p % 2 == 0; }); + BOOST_CHECK(!two.chosen.has_value()); + BOOST_CHECK_EQUAL(2u, two.matches); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/plugins/batch_operator_plugin/test/test_underwriter.cpp b/plugins/batch_operator_plugin/test/test_underwriter.cpp new file mode 100644 index 0000000000..b470148f81 --- /dev/null +++ b/plugins/batch_operator_plugin/test/test_underwriter.cpp @@ -0,0 +1,590 @@ +#include "../src/underwriter.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace sysio::batch_operator_detail::underwriter; +using mvo = fc::mutable_variant_object; + +namespace { + +const fc::slug_name eth{"ETH"}; +const fc::slug_name sol{"SOL"}; +const fc::slug_name liqeth{"LIQETH"}; +const fc::slug_name liqsol{"LIQSOL"}; +const sysio::chain::symbol liqeth_symbol{9, "LIQETH"}; +const sysio::chain::symbol liqsol_symbol{9, "LIQSOL"}; +const token_symbols symbols{{liqeth, liqeth_symbol}, {liqsol, liqsol_symbol}}; + +/// `units` base units of `token`. +sysio::chain::asset units_of(fc::slug_name token, int64_t units) { + return sysio::chain::asset(units, symbols.at(token)); +} +/// `units` base units of LIQETH. +sysio::chain::asset liqeth_units(int64_t units) { return units_of(liqeth, units); } +/// `units` base units of WIRE. +sysio::chain::asset wire_units(int64_t units) { return sysio::chain::asset(units, wire::asset_symbol); } + +fc::sha256 digest_of(const std::string& seed) { return fc::sha256::hash(seed); } + +/// Statement bytes as `sysio.synd` packs them: chain code, epoch, digest, token code, little-endian. +std::vector statement_bytes(fc::slug_name chain_code, uint32_t epoch, const fc::sha256& digest, + fc::slug_name token) { + std::vector out(STATEMENT_BYTES); + auto* p = reinterpret_cast(out.data()); + boost::endian::store_little_u64(p, chain_code.value); + boost::endian::store_little_u32(p + 8, epoch); + std::memcpy(p + 12, digest.data(), 32); + boost::endian::store_little_u64(p + 44, token.value); + return out; +} + +/// An OPEN `sysio.synd` envelope request covering `covered` base units of `token`. +request synd_request(uint64_t id, fc::slug_name chain_code, uint32_t epoch, fc::slug_name token, int64_t covered) { + return request{ + .id = id, + .issuer = sysio::chain::name(synd::account), + .schema = sysio::chain::name(envelope_schema), + .statement = statement_bytes(chain_code, epoch, digest_of(std::to_string(id)), token), + .token_code = token, + .covered = units_of(token, covered), + .bonded = units_of(token, 0), + .window = fc::hours(3), + }; +} + +/// Inputs where LIQETH has room for 100 with 100 held, and no outpost record has been read. +plan_inputs eth_inputs() { + plan_inputs in; + in.caps = {{liqeth, liqeth_units(100)}}; + in.balances = {{liqeth, liqeth_units(100)}}; + in.now = fc::time_point::now(); + return in; +} + +/// Record, as each outpost would, the digest every request in `in` states: every statement checks out. +void outpost_emitted_all(plan_inputs& in) { + for (const auto& r : in.requests) { + if (const auto s = decode_statement(r.statement)) in.emitted[{s->chain_code, s->epoch_index}] = s->digest; + } +} + +const sysio::chain::name underwriter_account{"uw.acct"}; + +} // namespace + +BOOST_AUTO_TEST_SUITE(underwriter_tests) + +BOOST_AUTO_TEST_CASE(decode_statement_reads_the_packed_layout) { + const auto digest = digest_of("envelope"); + const auto s = decode_statement(statement_bytes(eth, 7, digest, liqeth)); + BOOST_REQUIRE(s.has_value()); + BOOST_CHECK_EQUAL(eth, s->chain_code); + BOOST_CHECK_EQUAL(7u, s->epoch_index); + BOOST_CHECK(digest == s->digest); + BOOST_CHECK_EQUAL(liqeth, s->token_code); + + auto bytes = statement_bytes(eth, 7, digest, liqeth); + bytes.pop_back(); + BOOST_CHECK(!decode_statement(bytes).has_value()); + bytes.resize(STATEMENT_BYTES + 1); + BOOST_CHECK(!decode_statement(bytes).has_value()); +} + +BOOST_AUTO_TEST_CASE(decode_request_reads_a_rendered_row) { + const auto statement = statement_bytes(eth, 3, digest_of("e3"), liqeth); + const auto row = mvo() + ("id", 42)("issuer", "sysio.synd")("schema", "oppenvelope") + ("statement", fc::to_hex(statement.data(), statement.size())) + ("statement_digest", digest_of("sd"))("token_code", "LIQETH")("covered", 300)("bonded", 100) + ("bounty", 0)("window_sec", 10800)("state", "BONDED")("created_at", "2026-10-07T00:00:00.000") + ("bonded_at", "2026-10-07T00:00:05.000")("outcome_acknowledged", true); + const auto r = decode_request(row, symbols); + BOOST_REQUIRE(r.has_value()); + BOOST_CHECK_EQUAL(42u, r->id); + BOOST_CHECK_EQUAL(std::string("sysio.synd"), r->issuer.to_string()); + BOOST_CHECK_EQUAL(std::string("oppenvelope"), r->schema.to_string()); + BOOST_CHECK(statement == r->statement); + BOOST_CHECK_EQUAL(liqeth, r->token_code); + BOOST_CHECK_EQUAL(liqeth_units(300), r->covered); + BOOST_CHECK_EQUAL(liqeth_units(100), r->bonded); + BOOST_CHECK(fc::seconds(10800) == r->window); + BOOST_CHECK(request_state::BONDED == r->state); + BOOST_CHECK(fc::time_point::from_iso_string("2026-10-07T00:00:05.000") == r->bonded_at); + BOOST_CHECK(r->outcome_acknowledged); + + auto unknown_state = row; + unknown_state.set("state", 9); // a value the enum does not name renders as its integer + BOOST_CHECK(!decode_request(unknown_state, symbols).has_value()); + auto missing = row; + missing.erase("covered"); + BOOST_CHECK(!decode_request(missing, symbols).has_value()); + // A token with no known symbol, and an amount no asset can carry. + BOOST_CHECK(!decode_request(row, token_symbols{{liqsol, liqsol_symbol}}).has_value()); + auto huge = row; + huge.set("covered", std::numeric_limits::max()); + BOOST_CHECK(!decode_request(huge, symbols).has_value()); +} + +BOOST_AUTO_TEST_CASE(decode_bond_keeps_only_the_underwriters_rows) { + const std::vector requests{synd_request(5, eth, 5, liqeth, 100)}; + const auto row = mvo()("request_id", 5)("underwriter", "uw.acct")("amount", 70) + ("yield", mvo()("index_checkpoint", "0")("owed_wire", 7))("paid", false); + const auto ours = decode_bond(row, underwriter_account, requests); + BOOST_REQUIRE(ours.has_value()); + BOOST_CHECK_EQUAL(5u, ours->request_id); + BOOST_CHECK_EQUAL(liqeth_units(70), ours->amount); // in its request's token + BOOST_CHECK(!ours->paid); + BOOST_CHECK_EQUAL(wire_units(7), ours->owed_wire); + BOOST_CHECK(!decode_bond(row, sysio::chain::name{"someone"}, requests).has_value()); +} + +BOOST_AUTO_TEST_CASE(a_bond_row_of_ours_that_does_not_decode_stops_the_pass) { + const std::vector requests{synd_request(5, eth, 5, liqeth, 100)}; + // Ours without its yield position, and a row naming no underwriter: neither may drop out of the exposure. + BOOST_CHECK_THROW(decode_bond(mvo()("request_id", 5)("underwriter", "uw.acct")("amount", 70)("paid", false), + underwriter_account, requests), + fc::exception); + BOOST_CHECK_THROW(decode_bond(mvo()("request_id", 5)("amount", 70)("paid", false), underwriter_account, requests), + fc::exception); + // Someone else's malformed row is not ours to read. + BOOST_CHECK( + !decode_bond(mvo()("request_id", 5)("underwriter", "someone"), underwriter_account, requests).has_value()); +} + +BOOST_AUTO_TEST_CASE(a_bond_of_ours_without_its_request_stops_the_pass) { + // Its request's row did not decode, so its amount belongs to no token's cap: paid or not, the pass stops. + const std::vector requests{synd_request(1, eth, 1, liqeth, 10)}; + const auto bond_on = [](uint64_t id, bool paid) { + return mvo()("request_id", id)("underwriter", "uw.acct")("amount", 10) + ("yield", mvo()("index_checkpoint", "0")("owed_wire", 0))("paid", paid); + }; + BOOST_CHECK(decode_bond(bond_on(1, false), underwriter_account, requests).has_value()); + BOOST_CHECK_THROW(decode_bond(bond_on(3, false), underwriter_account, requests), fc::exception); + BOOST_CHECK_THROW(decode_bond(bond_on(3, true), underwriter_account, requests), fc::exception); +} + +BOOST_AUTO_TEST_CASE(synd_has_work_while_a_request_is_in_play_or_unacknowledged) { + auto settled = synd_request(1, eth, 1, liqeth, 10); + settled.state = request_state::APPROVED; + settled.outcome_acknowledged = true; + BOOST_CHECK(!synd_has_work({settled})); + BOOST_CHECK(synd_has_work({settled, synd_request(2, eth, 2, liqeth, 10)})); // OPEN + auto ruled = synd_request(3, eth, 3, liqeth, 10); + ruled.state = request_state::INVALID; // not yet acknowledged + BOOST_CHECK(synd_has_work({settled, ruled})); + auto other_issuer = synd_request(4, eth, 4, liqeth, 10); + other_issuer.issuer = sysio::chain::name{"someone"}; + BOOST_CHECK(!synd_has_work({settled, other_issuer})); +} + +BOOST_AUTO_TEST_CASE(the_scan_window_keeps_every_request_still_needed) { + const auto settled = [](uint64_t id) { + auto r = synd_request(id, eth, static_cast(id), liqeth, 10); + r.state = request_state::APPROVED; + r.outcome_acknowledged = true; + return r; + }; + auto in_play = synd_request(3, eth, 3, liqeth, 10); + in_play.state = request_state::BONDED; + const std::vector requests{settled(1), settled(2), in_play, settled(4)}; + BOOST_CHECK_EQUAL(3u, next_scan_start(0, requests, {}, {})); + // Nothing needs a later look: one past the newest read. + BOOST_CHECK_EQUAL(5u, next_scan_start(0, {settled(1), settled(4)}, {}, {})); + // A bond row of ours, or a request remembered as bonded, keeps its request in the window. + BOOST_CHECK_EQUAL(2u, next_scan_start(0, {settled(1), settled(2), settled(4)}, + {{2, {.request_id = 2, .amount = liqeth_units(10), .paid = true, + .owed_wire = wire_units(1)}}}, + {})); + BOOST_CHECK_EQUAL(1u, next_scan_start(0, {settled(1), settled(4)}, {}, {1})); + // Another issuer's open request does not pin the window, and an empty read keeps it where it was. + auto other_issuer = synd_request(0, eth, 0, liqeth, 10); + other_issuer.issuer = sysio::chain::name{"someone"}; + BOOST_CHECK_EQUAL(2u, next_scan_start(0, {other_issuer, settled(1)}, {}, {})); + BOOST_CHECK_EQUAL(7u, next_scan_start(7, {}, {}, {})); +} + +BOOST_AUTO_TEST_CASE(bonded_requests_are_remembered_until_their_row_is_pruned) { + const auto r1 = synd_request(1, eth, 1, liqeth, 10); + const auto r2 = synd_request(2, eth, 2, liqeth, 10); + std::set bonded; + remember_bonded(bonded, {{1, {.request_id = 1}}, {2, {.request_id = 2}}}, {r1, r2}); + BOOST_CHECK((bonded == std::set{1, 2})); + remember_bonded(bonded, {{1, {.request_id = 1}}}, {r1, r2}); // bond row 2 pruned, its request still there + BOOST_CHECK((bonded == std::set{1, 2})); + remember_bonded(bonded, {{1, {.request_id = 1}}}, {r1}); // request 2 pruned too + BOOST_CHECK((bonded == std::set{1})); +} + +BOOST_AUTO_TEST_CASE(only_a_forfeit_after_the_first_pass_halts_bonding) { + forfeit_watch watch; + BOOST_CHECK(!watch.note({4})); // on chain at startup: the restart resumed bonding + BOOST_CHECK(!watch.note({4})); + BOOST_CHECK(!watch.halted_by.has_value()); + BOOST_CHECK(watch.note({4, 7})); + BOOST_CHECK_EQUAL(7u, watch.halted_by.value()); + BOOST_CHECK(!watch.note({4, 7, 9})); // already halted + BOOST_CHECK_EQUAL(7u, watch.halted_by.value()); +} + +BOOST_AUTO_TEST_CASE(an_unreadable_chain_holds_back_only_its_own_reads) { + auto bonded_already = synd_request(4, eth, 9, liqeth, 10); + bonded_already.state = request_state::BONDED; + auto full = synd_request(5, eth, 10, liqeth, 10); + full.bonded = full.covered; + const auto wanted = wanted_envelopes({synd_request(1, eth, 7, liqeth, 10), synd_request(2, eth, 8, liqeth, 10), + synd_request(3, sol, 3, liqsol, 10), bonded_already, full}); + BOOST_CHECK((wanted == std::set{{eth, 7}, {eth, 8}, {sol, 3}})); + + std::vector reads; + std::vector failures; + const auto emitted = collect_emitted( + wanted, + [&](fc::slug_name chain, uint32_t epoch) -> std::optional { + reads.emplace_back(chain, epoch); + if (chain == eth) FC_THROW_EXCEPTION(fc::exception, "rpc down"); + return digest_of("sol3"); + }, + [&](fc::slug_name chain, const std::string&) { failures.push_back(chain); }); + BOOST_CHECK((reads == std::vector{{eth, 7}, {sol, 3}})); // ETH is not read again this pass + BOOST_CHECK((failures == std::vector{eth})); + BOOST_REQUIRE_EQUAL(1u, emitted.size()); + BOOST_CHECK(emitted.at({sol, 3}) == digest_of("sol3")); +} + +BOOST_AUTO_TEST_CASE(a_statement_the_outpost_recorded_is_bonded_for_its_remainder) { + auto in = eth_inputs(); + auto r = synd_request(1, eth, 10, liqeth, 60); + r.bonded = liqeth_units(20); // someone else bonded part of it + in.requests = {r}; + outpost_emitted_all(in); + const auto out = plan_actions(in); + BOOST_REQUIRE_EQUAL(1u, out.accepts.size()); + BOOST_CHECK_EQUAL(1u, out.accepts[0].request_id); + BOOST_CHECK_EQUAL(liqeth_units(40), out.accepts[0].amount); + BOOST_CHECK(out.waiting.empty()); +} + +BOOST_AUTO_TEST_CASE(a_statement_without_an_outpost_record_waits) { + auto in = eth_inputs(); + in.requests = {synd_request(1, eth, 11, liqeth, 10), synd_request(2, sol, 1, liqeth, 10)}; + // A record of another epoch, and one of another chain, prove neither statement. + in.emitted[{eth, 10}] = digest_of("1"); + in.emitted[{sol, 2}] = digest_of("2"); + const auto out = plan_actions(in); + BOOST_CHECK(out.accepts.empty()); + BOOST_REQUIRE_EQUAL(2u, out.waiting.size()); + BOOST_CHECK(wait_reason::UNVERIFIED == out.waiting[0].reason); + BOOST_CHECK(wait_reason::UNVERIFIED == out.waiting[1].reason); +} + +BOOST_AUTO_TEST_CASE(a_digest_the_outpost_did_not_record_is_never_bonded) { + auto in = eth_inputs(); + const auto r = synd_request(1, eth, 7, liqeth, 10); // states digest_of("1") for ETH epoch 7 + in.requests = {r}; + // Right chain and epoch, another digest: the depot accepted an envelope the outpost never emitted. + in.emitted[{eth, 7}] = digest_of("forged"); + auto out = plan_actions(in); + BOOST_CHECK(out.accepts.empty()); + BOOST_REQUIRE_EQUAL(1u, out.waiting.size()); + BOOST_CHECK(wait_reason::CONTRADICTED == out.waiting[0].reason); + + // Right chain and epoch, no record at all: it waits. + in.emitted.clear(); + out = plan_actions(in); + BOOST_CHECK(out.accepts.empty()); + BOOST_REQUIRE_EQUAL(1u, out.waiting.size()); + BOOST_CHECK(wait_reason::UNVERIFIED == out.waiting[0].reason); + + // The outpost's record carries the statement's digest: it is bonded. + in.emitted[{eth, 7}] = decode_statement(r.statement)->digest; + out = plan_actions(in); + BOOST_REQUIRE_EQUAL(1u, out.accepts.size()); + BOOST_CHECK(out.waiting.empty()); +} + +BOOST_AUTO_TEST_CASE(the_cap_counts_unsettled_bonds_and_this_pass_oldest_first) { + auto in = eth_inputs(); + // 30 already bonded on an approved request not yet claimed counts; a claimed bond does not. + auto approved = synd_request(1, eth, 1, liqeth, 30); + approved.state = request_state::APPROVED; + auto settled = synd_request(2, eth, 2, liqeth, 50); + settled.state = request_state::APPROVED; + in.bonds = {{1, {.request_id = 1, .amount = liqeth_units(30)}}, + {2, {.request_id = 2, .amount = liqeth_units(50), .paid = true}}}; + // Listed newest first: the plan still takes request 3 before request 4. + in.requests = {approved, settled, synd_request(4, eth, 4, liqeth, 40), synd_request(3, eth, 3, liqeth, 60)}; + outpost_emitted_all(in); + const auto out = plan_actions(in); + BOOST_REQUIRE_EQUAL(1u, out.accepts.size()); + BOOST_CHECK_EQUAL(3u, out.accepts[0].request_id); // 30 + 60 = 90 fits under 100 + BOOST_REQUIRE_EQUAL(1u, out.waiting.size()); + BOOST_CHECK_EQUAL(4u, out.waiting[0].request_id); // 90 + 40 does not + BOOST_CHECK(wait_reason::OVER_CAP == out.waiting[0].reason); + BOOST_CHECK(out.claims == std::vector{1}); +} + +BOOST_AUTO_TEST_CASE(no_cap_or_too_little_balance_waits) { + auto in = eth_inputs(); + in.requests = {synd_request(1, eth, 1, liqsol, 10)}; + outpost_emitted_all(in); + auto out = plan_actions(in); + BOOST_REQUIRE_EQUAL(1u, out.waiting.size()); + BOOST_CHECK(wait_reason::NO_CAP == out.waiting[0].reason); + + in = eth_inputs(); + in.balances = {{liqeth, liqeth_units(50)}}; + in.requests = {synd_request(1, eth, 1, liqeth, 40), synd_request(2, eth, 2, liqeth, 40)}; + outpost_emitted_all(in); + out = plan_actions(in); + BOOST_REQUIRE_EQUAL(1u, out.accepts.size()); + BOOST_REQUIRE_EQUAL(1u, out.waiting.size()); + BOOST_CHECK(wait_reason::LOW_BALANCE == out.waiting[0].reason); + + in.balances.clear(); // no balance of the token at all + out = plan_actions(in); + BOOST_CHECK(out.accepts.empty()); + BOOST_REQUIRE_EQUAL(2u, out.waiting.size()); + BOOST_CHECK(wait_reason::LOW_BALANCE == out.waiting[0].reason); +} + +BOOST_AUTO_TEST_CASE(a_malformed_or_mismatched_statement_waits) { + auto in = eth_inputs(); + auto bad = synd_request(1, eth, 1, liqeth, 10); + bad.statement.pop_back(); + auto mismatch = synd_request(2, eth, 2, liqeth, 10); + mismatch.token_code = liqsol; + in.requests = {bad, mismatch}; + const auto out = plan_actions(in); + BOOST_CHECK(out.accepts.empty()); + BOOST_REQUIRE_EQUAL(2u, out.waiting.size()); + BOOST_CHECK(wait_reason::BAD_STATEMENT == out.waiting[0].reason); + BOOST_CHECK(wait_reason::TOKEN_MISMATCH == out.waiting[1].reason); +} + +BOOST_AUTO_TEST_CASE(other_issuers_and_full_requests_are_left_alone) { + auto in = eth_inputs(); + auto other_issuer = synd_request(1, eth, 1, liqeth, 10); + other_issuer.issuer = sysio::chain::name{"someone"}; + auto other_schema = synd_request(2, eth, 2, liqeth, 10); + other_schema.schema = sysio::chain::name{"other"}; + auto full = synd_request(3, eth, 3, liqeth, 10); + full.bonded = full.covered; + in.requests = {other_issuer, other_schema, full}; + const auto out = plan_actions(in); + BOOST_CHECK(out.accepts.empty()); + BOOST_CHECK(out.waiting.empty()); +} + +BOOST_AUTO_TEST_CASE(a_bonded_request_is_approved_once_its_window_passes) { + auto in = eth_inputs(); + auto r = synd_request(1, eth, 1, liqeth, 10); + r.state = request_state::BONDED; + r.bonded = r.covered; + r.bonded_at = in.now - r.window + fc::seconds(1); + in.requests = {r}; + in.bonds = {{1, {.request_id = 1, .amount = liqeth_units(10)}}}; + BOOST_CHECK(plan_actions(in).approves.empty()); // one second to go + in.requests[0].bonded_at = in.now - r.window; + BOOST_CHECK(plan_actions(in).approves == std::vector{1}); + in.bonds.clear(); // not ours: someone else approves it + BOOST_CHECK(plan_actions(in).approves.empty()); +} + +BOOST_AUTO_TEST_CASE(rulings_are_claimed_and_held_or_forfeited_bonds_reported) { + auto in = eth_inputs(); + auto valid = synd_request(1, eth, 1, liqeth, 10); + valid.state = request_state::VALID; + auto held = synd_request(2, eth, 2, liqeth, 10); + held.state = request_state::HELD; + auto invalid = synd_request(3, eth, 3, liqeth, 90); + invalid.state = request_state::INVALID; + in.requests = {valid, held, invalid, synd_request(4, eth, 4, liqeth, 80)}; + in.bonds = {{1, {.request_id = 1, .amount = liqeth_units(10)}}, + {2, {.request_id = 2, .amount = liqeth_units(10)}}, + {3, {.request_id = 3, .amount = liqeth_units(90)}}}; + in.bonded = {1, 2, 3}; + outpost_emitted_all(in); + const auto out = plan_actions(in); + BOOST_CHECK(out.claims == std::vector{1}); // never the forfeited one + BOOST_CHECK(out.held == std::vector{2}); + BOOST_CHECK(out.blocked.empty()); + BOOST_CHECK(out.forfeited == std::vector{3}); + BOOST_CHECK(out.forfeit_claims == std::vector{3}); // what it earned is settled with the housekeeping + // The forfeited 90 no longer counts against the cap; the unclaimed valid and held 10s do: 20 + 80 fits in 100. + BOOST_REQUIRE_EQUAL(1u, out.accepts.size()); + BOOST_CHECK_EQUAL(4u, out.accepts[0].request_id); + BOOST_CHECK_EQUAL(liqeth_units(80), out.accepts[0].amount); + BOOST_CHECK(out.waiting.empty()); +} + +BOOST_AUTO_TEST_CASE(a_request_challenged_before_anyone_bonded_it_is_reported_blocked) { + auto in = eth_inputs(); + auto challenged = synd_request(1, eth, 1, liqeth, 10); + challenged.state = request_state::HELD; + auto other_issuer = synd_request(2, eth, 2, liqeth, 10); + other_issuer.state = request_state::HELD; + other_issuer.issuer = sysio::chain::name{"someone"}; + in.requests = {challenged, other_issuer}; + const auto out = plan_actions(in); + BOOST_CHECK(out.blocked == std::vector{1}); + BOOST_CHECK(out.held.empty()); +} + +BOOST_AUTO_TEST_CASE(nothing_is_bonded_approved_or_claimed_while_the_cord_is_pulled) { + auto in = eth_inputs(); + in.frozen = true; + auto bonded = synd_request(1, eth, 1, liqeth, 10); + bonded.state = request_state::BONDED; + bonded.bonded = bonded.covered; + bonded.bonded_at = in.now - bonded.window; + auto approved = synd_request(2, eth, 2, liqeth, 10); + approved.state = request_state::APPROVED; + auto held = synd_request(3, eth, 3, liqeth, 10); + held.state = request_state::HELD; + auto invalid = synd_request(4, eth, 4, liqeth, 10); + invalid.state = request_state::INVALID; + in.requests = {bonded, approved, held, invalid, synd_request(5, eth, 5, liqeth, 10)}; + in.bonds = {{1, {.request_id = 1, .amount = liqeth_units(10)}}, + {2, {.request_id = 2, .amount = liqeth_units(10)}}, + {3, {.request_id = 3, .amount = liqeth_units(10)}}, + {4, {.request_id = 4, .amount = liqeth_units(10)}}}; + in.bonded = {1, 2, 3, 4}; + outpost_emitted_all(in); + auto out = plan_actions(in); + BOOST_CHECK(out.accepts.empty()); + BOOST_CHECK(out.approves.empty()); + BOOST_CHECK(out.claims.empty()); + BOOST_CHECK(out.forfeit_claims.empty()); + BOOST_CHECK(out.waiting.empty()); // a frozen pass does not report waits + // What needs people is still reported. + BOOST_CHECK(out.held == std::vector{3}); + BOOST_CHECK(out.forfeited == std::vector{4}); + + in.frozen = false; + out = plan_actions(in); + BOOST_CHECK(out.approves == std::vector{1}); + BOOST_CHECK(out.claims == std::vector{2}); + BOOST_CHECK(out.forfeit_claims == std::vector{4}); + BOOST_REQUIRE_EQUAL(1u, out.accepts.size()); + BOOST_CHECK_EQUAL(5u, out.accepts[0].request_id); +} + +BOOST_AUTO_TEST_CASE(a_halted_underwriter_bonds_nothing_but_settles_what_it_bonded) { + auto in = eth_inputs(); + in.halted = true; + auto bonded = synd_request(1, eth, 1, liqeth, 10); + bonded.state = request_state::BONDED; + bonded.bonded = bonded.covered; + bonded.bonded_at = in.now - bonded.window; + auto approved = synd_request(2, eth, 2, liqeth, 10); + approved.state = request_state::APPROVED; + in.requests = {bonded, approved, synd_request(3, eth, 3, liqeth, 10)}; + in.bonds = {{1, {.request_id = 1, .amount = liqeth_units(10)}}, + {2, {.request_id = 2, .amount = liqeth_units(10)}}}; + outpost_emitted_all(in); + const auto out = plan_actions(in); + BOOST_CHECK(out.accepts.empty()); + BOOST_CHECK(out.waiting.empty()); + BOOST_CHECK(out.approves == std::vector{1}); + BOOST_CHECK(out.claims == std::vector{2}); +} + +BOOST_AUTO_TEST_CASE(a_forfeit_is_any_bonded_envelope_request_ruled_invalid) { + auto mine = synd_request(5, eth, 5, liqeth, 10); + mine.state = request_state::INVALID; + auto not_mine = synd_request(4, eth, 4, liqeth, 10); + not_mine.state = request_state::INVALID; + auto other_issuer = synd_request(2, eth, 2, liqeth, 10); + other_issuer.state = request_state::INVALID; + other_issuer.issuer = sysio::chain::name{"someone"}; + auto valid = synd_request(1, eth, 1, liqeth, 10); + valid.state = request_state::VALID; + auto earlier = synd_request(0, eth, 0, liqeth, 10); + earlier.state = request_state::INVALID; + const auto forfeits = forfeited_requests({mine, not_mine, other_issuer, valid, earlier}, {0, 1, 2, 5}); + BOOST_CHECK((forfeits == std::vector{0, 5})); // in id order +} + +BOOST_AUTO_TEST_CASE(a_forfeit_is_reported_after_anyone_claims_or_prunes_the_bond) { + auto in = eth_inputs(); + auto claimed = synd_request(1, eth, 1, liqeth, 10); // its yield claimed by anyone: the bond is paid + claimed.state = request_state::INVALID; + auto pruned = synd_request(2, eth, 2, liqeth, 10); // then pruned: the bond row is gone + pruned.state = request_state::INVALID; + in.requests = {claimed, pruned}; + in.bonds = {{1, {.request_id = 1, .amount = liqeth_units(10), .paid = true}}}; + in.bonded = {1, 2}; + const auto out = plan_actions(in); + BOOST_CHECK((out.forfeited == std::vector{1, 2})); + BOOST_CHECK(out.claims.empty()); + BOOST_CHECK(out.forfeit_claims.empty()); // nothing of ours is left to settle on either +} + +BOOST_AUTO_TEST_CASE(what_a_forfeited_bond_earned_is_claimed_with_the_housekeeping) { + auto in = eth_inputs(); + const auto invalid = [](uint64_t id) { + auto r = synd_request(id, eth, id, liqeth, 10); + r.state = request_state::INVALID; + return r; + }; + in.requests = {invalid(1), invalid(2), invalid(3), invalid(4)}; + // Unclaimed, claimed with WIRE the pool could not cover, and settled; 4 is someone else's. + in.bonds = {{1, {.request_id = 1, .amount = liqeth_units(10)}}, + {2, {.request_id = 2, .amount = liqeth_units(10), .paid = true, .owed_wire = wire_units(5)}}, + {3, {.request_id = 3, .amount = liqeth_units(10), .paid = true}}}; + in.bonded = {1, 2, 3}; + auto out = plan_actions(in); + BOOST_CHECK((out.forfeit_claims == std::vector{1, 2})); + BOOST_CHECK(out.claims.empty()); // never claimed every pass: a bond that earned nothing has nothing to claim + BOOST_CHECK((out.forfeited == std::vector{1, 2, 3})); + + in.frozen = true; // claims are refused while the cord is pulled + out = plan_actions(in); + BOOST_CHECK(out.forfeit_claims.empty()); +} + +BOOST_AUTO_TEST_CASE(owed_yield_is_claimed_after_the_principal) { + auto in = eth_inputs(); + auto yield_owed = synd_request(1, eth, 1, liqeth, 10); + yield_owed.state = request_state::APPROVED; + auto unpaid = synd_request(2, eth, 2, liqeth, 10); + unpaid.state = request_state::VALID; + auto settled = synd_request(3, eth, 3, liqeth, 10); + settled.state = request_state::APPROVED; + auto forfeited = synd_request(4, eth, 4, liqeth, 10); + forfeited.state = request_state::INVALID; + in.requests = {yield_owed, unpaid, settled, forfeited}; + in.bonds = {{1, {.request_id = 1, .amount = liqeth_units(10), .paid = true, .owed_wire = wire_units(1)}}, + {2, {.request_id = 2, .amount = liqeth_units(10)}}, + {3, {.request_id = 3, .amount = liqeth_units(10), .paid = true}}, + {4, {.request_id = 4, .amount = liqeth_units(10), .paid = true, .owed_wire = wire_units(5)}}}; + auto out = plan_actions(in); + BOOST_CHECK((out.claims == std::vector{1, 2})); // never the settled or the forfeited one + BOOST_CHECK(out.forfeit_claims == std::vector{4}); + + in.frozen = true; + out = plan_actions(in); + BOOST_CHECK(out.claims.empty()); + BOOST_CHECK(out.forfeit_claims.empty()); +} + +BOOST_AUTO_TEST_CASE(only_approved_valid_and_invalid_are_terminal) { + BOOST_CHECK(!is_terminal(request_state::OPEN)); + BOOST_CHECK(!is_terminal(request_state::BONDED)); + BOOST_CHECK(!is_terminal(request_state::HELD)); + BOOST_CHECK(is_terminal(request_state::APPROVED)); + BOOST_CHECK(is_terminal(request_state::VALID)); + BOOST_CHECK(is_terminal(request_state::INVALID)); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/plugins/chain_plugin/include/sysio/chain_plugin/chain_plugin.hpp b/plugins/chain_plugin/include/sysio/chain_plugin/chain_plugin.hpp index 7e3d8a43da..50f546cf9c 100644 --- a/plugins/chain_plugin/include/sysio/chain_plugin/chain_plugin.hpp +++ b/plugins/chain_plugin/include/sysio/chain_plugin/chain_plugin.hpp @@ -832,13 +832,6 @@ class chain_plugin : public plugin { std::string_view log_prefix, const std::atomic& shutdown_flag); - /// Return true when `provider_key` is a direct key whose weight alone - /// reaches `actor`'s active threshold or its owner ancestor. Call only from - /// the main app thread or an executor read window. - bool provider_can_authorize_active_alone( - chain::name actor, - const fc::crypto::public_key& provider_key) const; - void accept_transaction(const chain::packed_transaction_ptr& trx, chain::plugin_interface::next_function next); // Only call this after plugin_initialize()! @@ -875,6 +868,18 @@ class chain_plugin : public plugin { unique_ptr my; }; +/// True when a signature by `key` alone satisfies the authority of `level`, as the chain's authority checker +/// evaluates a transaction's signatures (delegated account permissions included). Call only from the main app +/// thread or an executor read window. +bool key_alone_satisfies(const chain::controller& chain, const chain::permission_level& level, + const fc::crypto::public_key& key); + +/// True when an action `code::action` may declare `level`, by the check a transaction gets: the permission +/// `level.actor` linked to it (`linkauth`), or `active` when none is, must be `level.permission` or a child of it. +/// Call only from the main app thread or an executor read window. +bool permission_satisfies_link(const chain::controller& chain, const chain::permission_level& level, + chain::name code, chain::name action); + } // namespace sysio FC_REFLECT( sysio::chain_apis::linked_action, (account)(action) ) diff --git a/plugins/chain_plugin/src/chain_plugin.cpp b/plugins/chain_plugin/src/chain_plugin.cpp index 4cea7c8cd1..c3893d86c4 100644 --- a/plugins/chain_plugin/src/chain_plugin.cpp +++ b/plugins/chain_plugin/src/chain_plugin.cpp @@ -1920,29 +1920,27 @@ chain_plugin::read_table_rows(chain_apis::read_only::get_table_rows_params param return result ? std::move(*result) : chain_apis::read_only::get_table_rows_result{}; } -bool chain_plugin::provider_can_authorize_active_alone( - chain::name actor, - const fc::crypto::public_key& provider_key) const { - const auto& authorization = chain().get_authorization_manager(); - // Declaring actor@active may be satisfied by active itself or its owner - // ancestor. Mere membership in a multisig authority is insufficient. - for (const auto permission : {chain::config::active_name, - chain::config::owner_name}) { - try { - const auto& permission_object = - authorization.get_permission({actor, permission}); - if (std::ranges::any_of( - permission_object.auth.keys, [&](const auto& weighted_key) { - return weighted_key.key.to_public_key() == provider_key && - weighted_key.weight >= permission_object.auth.threshold; - })) { - return true; - } - } catch (...) { - // A missing permission cannot authorize this transaction. - } +bool key_alone_satisfies(const chain::controller& chain, const chain::permission_level& level, + const fc::crypto::public_key& key) { + try { + chain.get_authorization_manager().check_authorization(level.actor, level.permission, {key}, {}, {}, true); + return true; + } catch (const fc::exception&) { + // Unsatisfied, or the permission does not exist: either way this key cannot sign for `level`. + return false; + } +} + +bool permission_satisfies_link(const chain::controller& chain, const chain::permission_level& level, + chain::name code, chain::name action) { + try { + // A transaction's own check, with `level` taken as signed so that only its link can fail. + chain.get_authorization_manager().check_authorization({chain::action{{level}, code, action, {}}}, {}, {level}); + return true; + } catch (const fc::exception&) { + // Not linked so that `level` may declare it, or the permission does not exist. + return false; } - return false; } void chain_plugin::accept_transaction(const chain::packed_transaction_ptr& trx, next_function next) { 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 7fcd75cd05..dda5844c65 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 @@ -4,10 +4,12 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -129,6 +131,18 @@ class outpost_client { virtual std::vector read_inbound_envelope(uint32_t epoch_index, fc::microseconds deadline) = 0; + /** + * @brief The digest of the envelope this outpost emitted for `epoch_index`, from the outpost's own write-once + * record of its emits: keccak256 of the canonical encoded envelope, which is the protocol's epoch digest + * and what the next envelope carries as `previous_envelope_hash`. Read at finality. + * + * @return The digest, or nullopt when the outpost holds no final record for that epoch: nothing emitted for it + * yet, or the record has left the outpost's retention window. + * @throws fc::exception on RPC failure, deadline expiry, or an outpost interface that keeps no such record. + */ + virtual std::optional read_emitted_envelope_digest(uint32_t epoch_index, + 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 bed0a34a59..a1b2b7b5c7 100644 --- a/plugins/outpost_client_plugin/test/test_outpost_client_interface.cpp +++ b/plugins/outpost_client_plugin/test/test_outpost_client_interface.cpp @@ -34,6 +34,7 @@ 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::optional read_emitted_envelope_digest(uint32_t, 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 3cc05cad59..441db7b444 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 @@ -38,13 +38,22 @@ struct opp_contract_client : ethereum_contract_client { /// on every `emitOutboundEnvelope`. Read by the WIRE batch operator /// to relay the envelope back to WIRE. ethereum_contract_call_fn get_latest_outbound_envelope; + /// The `outboundEnvelopes(uint32)` view's name: the outpost's write-once record of the envelope it emitted for + /// an epoch, `(uint32 epochIndex, uint64 emittedAt, bytes32 checksum)`, kept for its retention window. + static constexpr auto view_outbound_envelopes = "outboundEnvelopes"; + /// View `outboundEnvelopes(uint32)`. Empty when the loaded ABI does not declare it, so an older ABI still + /// serves the relay, which never reads it. + ethereum_contract_call_fn outbound_envelopes; opp_contract_client(const ethereum_client_ptr& client, const address_compat_type& contract_address, const std::vector& contracts) : ethereum_contract_client(client, contract_address, contracts) , emit_outbound_envelope(create_tx_and_confirm(get_abi("emitOutboundEnvelope"))) - , get_latest_outbound_envelope(create_call(get_abi("getLatestOutboundEnvelope"))) {} + , get_latest_outbound_envelope(create_call(get_abi("getLatestOutboundEnvelope"))) + , outbound_envelopes(has_abi(view_outbound_envelopes) + ? create_call(get_abi(view_outbound_envelopes)) + : ethereum_contract_call_fn{}) {} }; /// Typed contract client for OPPInbound.sol. Same confirmed-default 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 9cc45ba5c4..cfa7009121 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 @@ -195,6 +195,11 @@ class outpost_ethereum_client : public outpost_client { std::vector read_inbound_envelope(uint32_t epoch_index, fc::microseconds deadline) override; + /// `OPP.outboundEnvelopes(epoch)` at `finalized`: the checksum the outpost recorded when it emitted that epoch's + /// envelope. Throws when the loaded OPP ABI does not declare the view. + std::optional read_emitted_envelope_digest(uint32_t epoch_index, + 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 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 9d6f34b829..30fd11d533 100644 --- a/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client.cpp +++ b/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client.cpp @@ -28,6 +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_READ_EMITTED_DIGEST = "read_emitted_envelope_digest"; constexpr std::string_view OP_REALIZE_YIELD = "crank_outpost:realizeYield"; @@ -66,6 +67,9 @@ constexpr auto view_latest_outbound_envelope = "getLatestOutboundEnvelope"; namespace field { constexpr auto epoch = "epoch_"; constexpr auto data = "data_"; +/// `outboundEnvelopes(uint32)` record fields. +constexpr auto record_epoch_index = "epochIndex"; +constexpr auto record_checksum = "checksum"; } } // namespace opp_abi @@ -686,6 +690,46 @@ std::vector outpost_ethereum_client::read_inbound_envelope( return out; } +std::optional outpost_ethereum_client::read_emitted_envelope_digest(uint32_t epoch_index, + fc::microseconds deadline) { + const fc::time_point deadline_abs = fc::time_point::now() + deadline; + fc::task::deadline_scope rpc_deadline(deadline_abs); + + throw_if_past_deadline(deadline_abs, OP_READ_EMITTED_DIGEST); + FC_ASSERT(_opp_client, "outpost_ethereum_client[{}]: read_emitted_envelope_digest requires an OPP address", + to_string()); + FC_ASSERT(_opp_client->outbound_envelopes, "outpost_ethereum_client[{}]: the loaded OPP ABI declares no {} view", + to_string(), opp_contract_client::view_outbound_envelopes); + + // `finalized`, as `read_inbound_envelope` reads: a record from a block that can still reorg out proves nothing. + uint32_t epoch = epoch_index; + const fc::variant raw_var = _opp_client->outbound_envelopes(eth::block_tag_t::finalized, epoch); + // Three static words: (uint32 epochIndex, uint64 emittedAt, bytes32 checksum). + constexpr size_t record_hex_chars = HEX_PREFIX_CHARS + 3 * EVM_ABI_WORD_BYTES * HEX_CHARS_PER_BYTE; + FC_ASSERT(raw_var.is_string() && raw_var.as_string().size() == record_hex_chars, + "outpost_ethereum_client[{}]: {}({}) did not return one record", to_string(), + opp_contract_client::view_outbound_envelopes, epoch_index); + const fc::variant decoded = eth::contract_decode_data( + _opp_client->get_abi(opp_contract_client::view_outbound_envelopes), raw_var.as_string()); + FC_ASSERT(decoded.is_object(), "outpost_ethereum_client[{}]: {}({}) did not decode", to_string(), + opp_contract_client::view_outbound_envelopes, epoch_index); + const fc::variant_object& record = decoded.get_object(); + + const std::optional recorded_epoch = abi_uint_output(record[opp_abi::field::record_epoch_index]); + FC_ASSERT(recorded_epoch, "outpost_ethereum_client[{}]: {}({}) carried an unparsable epoch", to_string(), + opp_contract_client::view_outbound_envelopes, epoch_index); + if (*recorded_epoch == 0) return std::nullopt; // an absent mapping entry reads as all zeros + FC_ASSERT(*recorded_epoch == epoch_index, "outpost_ethereum_client[{}]: {}({}) holds the record of epoch {}", + to_string(), opp_contract_client::view_outbound_envelopes, epoch_index, *recorded_epoch); + + const std::string checksum_hex = record[opp_abi::field::record_checksum].as_string(); + const std::string_view checksum = strip_hex_prefix(checksum_hex); + FC_ASSERT(checksum.size() == sizeof(fc::sha256) * HEX_CHARS_PER_BYTE, + "outpost_ethereum_client[{}]: {}({}) carried a malformed checksum", to_string(), + opp_contract_client::view_outbound_envelopes, epoch_index); + return fc::sha256(checksum); +} + 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/test/test_outpost_ethereum_client_plugin.cpp b/plugins/outpost_ethereum_client_plugin/test/test_outpost_ethereum_client_plugin.cpp index 5462a0234b..93d5520281 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 @@ -1172,6 +1172,91 @@ BOOST_AUTO_TEST_CASE(read_inbound_envelope_validates_latest_slot) try { fc::seconds(test_rpc_deadline_seconds)).empty()); } FC_LOG_AND_RETHROW(); +/// `outboundEnvelopes(uint32)` returns `(uint32 epochIndex, uint64 emittedAt, bytes32 checksum)`. +std::string encode_outbound_record_result(uint64_t epoch, const fc::sha256& checksum) { + constexpr uint64_t emitted_at = 1'700'000'000; + return std::string(hex_prefix) + abi_word(epoch) + abi_word(emitted_at) + checksum.str(); +} + +BOOST_AUTO_TEST_CASE(read_emitted_envelope_digest_reads_the_outpost_record) try { + auto clean_app = gsl_lite::finally([]() { + appbase::application::reset_app_singleton(); + }); + auto tester = create_app(); + auto sig_provider = tester->plugin().create_provider( + std::string(latest_slot_test_entry_id), + chain_kind_ethereum, + chain_key_type_ethereum, + std::string(latest_slot_test_public_key), + to_private_key_spec(std::string(latest_slot_test_private_key))); + + // One outpost per ABI set: the client caches its typed contract per address. + const std::string opp_address{test_opp_address}; + auto make_outpost = [&](std::vector abis) { + ethereum_transaction_policy transaction_policy{ + .client_id = std::string(latest_slot_test_entry_id), + .chain_id = test_evm_chain_id, + .max_priority_fee_per_gas = maximum_ethereum_transaction_policy_value(), + .max_fee_per_gas = maximum_ethereum_transaction_policy_value(), + .max_gas_limit = maximum_ethereum_transaction_policy_value(), + .max_total_native_cost = maximum_ethereum_transaction_policy_value(), + }; + auto eth_client = std::make_shared( + sig_provider, std::variant{std::string{latest_slot_test_rpc_url}}, + std::move(transaction_policy)); + auto typed_opp = eth_client->get_contract(opp_address, abis); + auto entry = std::make_shared(); + entry->id = latest_slot_test_entry_id; + entry->signature_provider = sig_provider; + entry->client = eth_client; + entry->chain_id = test_evm_chain_id; + auto outpost = std::make_shared( + entry, opp_address, "", abis, test_outpost_chain_code, test_evm_chain_id); + return std::make_pair(typed_opp, outpost); + }; + + auto [typed_opp, outpost] = make_outpost(load_abi_fixture(opp_abi_fixture)); + BOOST_REQUIRE(typed_opp->outbound_envelopes); + auto set_response = [&](std::string response) { + typed_opp->outbound_envelopes = + [response = std::move(response)](const block_number_or_tag_t& block, uint32_t& epoch) -> fc::variant { + BOOST_CHECK(std::holds_alternative(block)); + BOOST_CHECK(std::get(block) == block_tag_t::finalized); + BOOST_CHECK_EQUAL(epoch, test_wire_epoch); + return fc::variant(response); + }; + }; + const auto deadline = fc::seconds(test_rpc_deadline_seconds); + const auto digest = fc::sha256::hash(std::string("emitted envelope")); + + set_response(encode_outbound_record_result(test_wire_epoch, digest)); + const auto read = outpost->read_emitted_envelope_digest(test_wire_epoch, deadline); + BOOST_REQUIRE(read.has_value()); + BOOST_CHECK(*read == digest); + + // An absent mapping entry reads as all zeros: nothing emitted, or pruned. + set_response(encode_outbound_record_result(0, fc::sha256{})); + BOOST_CHECK(!outpost->read_emitted_envelope_digest(test_wire_epoch, deadline).has_value()); + + // A record for another epoch, or no data at all, is a failed read rather than an answer. + set_response(encode_outbound_record_result(test_stale_wire_epoch, digest)); + BOOST_CHECK_THROW(outpost->read_emitted_envelope_digest(test_wire_epoch, deadline), fc::exception); + set_response(std::string(hex_prefix)); + BOOST_CHECK_THROW(outpost->read_emitted_envelope_digest(test_wire_epoch, deadline), fc::exception); + set_response(encode_outbound_record_result(test_wire_epoch, digest) + abi_word(0)); // more than one record + BOOST_CHECK_THROW(outpost->read_emitted_envelope_digest(test_wire_epoch, deadline), fc::exception); + + // An ABI without the view still builds the client, which the relay needs, and the read refuses. + auto abis_without_view = load_abi_fixture(opp_abi_fixture); + std::erase_if(abis_without_view, [](const fc::network::ethereum::abi::contract& c) { + return c.name == sysio::opp_contract_client::view_outbound_envelopes; + }); + auto [typed_without_view, outpost_without_view] = make_outpost(std::move(abis_without_view)); + BOOST_CHECK(!typed_without_view->outbound_envelopes); + BOOST_CHECK(typed_without_view->get_latest_outbound_envelope); + BOOST_CHECK_THROW(outpost_without_view->read_emitted_envelope_digest(test_wire_epoch, deadline), fc::exception); +} FC_LOG_AND_RETHROW(); + // --------------------------------------------------------------------------- // Chunked WIRE -> Ethereum envelope delivery // --------------------------------------------------------------------------- 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 89509f270f..fa7c29911e 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 @@ -24,6 +24,10 @@ 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, + /// Reads only the outpost's record of the envelopes it emitted + /// (`read_emitted_envelope_digest`) - the IDL must declare a readable + /// outbound `EnvelopeLog`, asserted at boot. + underwriter, }; 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 47a49363c1..89464db210 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 @@ -166,6 +166,14 @@ std::vector decode_latest_envelope_account(opp_solana_outpost_client& pro uint32_t epoch_index, const std::string& log_label); +/// The checksum of the outbound `EnvelopeLog` record for `epoch_index` in the fetched account bytes, decoded +/// through the loaded IDL: the keccak256 the program recorded when it emitted that epoch's envelope. Nullopt when +/// the log holds no record for the epoch. Throws when the bytes do not decode as an `EnvelopeLog`. Exposed for the +/// plugin's unit tests, which drive it against synthesized accounts. +std::optional decode_envelope_log_digest(opp_solana_outpost_client& program_client, + const std::vector& account_data, + uint32_t epoch_index); + /// Assert that the loaded IDL's `EpochDeliveries` declaration carries the /// fields the dispatch cursor read depends on: `consensus_reached` (bool) and /// `dispatched_count` (u32), in either IDL field home (inline on the account @@ -185,6 +193,14 @@ 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 the loaded IDL declares the outbound `EnvelopeLog` the way `decode_envelope_log_digest` reads it: an +/// `envelopes` vector of records carrying `epoch_index` (u32) and `checksum` ([u8; 32]). Called at construction for +/// the underwriter role, so a drifted IDL fails at boot instead of on every pass. +/// +/// @param program the program's loaded Anchor IDL. +/// @throws fc::exception if the account, the vector, the record type or either field is absent or misdeclared. +void assert_envelope_log_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 @@ -507,6 +523,11 @@ class outpost_solana_client : public outpost_client { std::vector read_inbound_envelope(uint32_t epoch_index, fc::microseconds deadline) override; + /// The outbound `EnvelopeLog` PDA at `finalized`: the checksum the program recorded when it emitted that + /// epoch's envelope. + std::optional read_emitted_envelope_digest(uint32_t epoch_index, + 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; } 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 ad0cff7bff..5860f28b41 100644 --- a/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp +++ b/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp @@ -35,6 +35,7 @@ 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_READ_EMITTED_DIGEST = "read_emitted_envelope_digest:get_account_info"; /// Anchor seed literals for the outpost program's per-epoch PDAs. Byte-exact /// mirrors of the program's `EPOCH_DELIVERIES_SEED` / `ENVELOPE_CHUNKS_SEED` @@ -114,6 +115,15 @@ namespace latest_envelope { constexpr auto field_checksum = "checksum"; } // namespace latest_envelope +/// Identifiers of the outbound `EnvelopeLog` account: one record of each envelope the program emitted, kept for +/// its retention window. +namespace envelope_log { + constexpr auto account_name = "EnvelopeLog"; + constexpr auto field_envelopes = "envelopes"; + constexpr auto field_epoch = "epoch_index"; + constexpr auto field_checksum = "checksum"; +} // namespace envelope_log + /// Identifiers of the per-epoch `EpochDeliveries` account + the two fields the /// dispatch cursor read consumes. Both are validated at boot for the /// batch-operator role (`assert_epoch_deliveries_shape`): a drifted @@ -276,6 +286,43 @@ void assert_epoch_deliveries_shape(const fc::network::solana::idl::program& prog epoch_deliveries::field_dispatched_count); } +/// Assert the loaded IDL declares the outbound `EnvelopeLog` the way the digest reader decodes it. Full contract on +/// the header declaration. +void assert_envelope_log_shape(const fc::network::solana::idl::program& program) { + namespace idl = fc::network::solana::idl; + const std::vector& fields = declared_account_fields(program, envelope_log::account_name); + const auto log = std::ranges::find(fields, std::string_view{envelope_log::field_envelopes}, &idl::field::name); + FC_ASSERT(log != fields.end(), "EnvelopeLog IDL missing '{}' field", envelope_log::field_envelopes); + FC_ASSERT(log->type.is_vec() && log->type.vec_element && log->type.vec_element->is_defined() && + log->type.vec_element->defined_name, + "EnvelopeLog '{}' must be declared a Vec of a defined record type, got '{}'", + envelope_log::field_envelopes, describe_idl_type(log->type)); + + const std::string& record_name = *log->type.vec_element->defined_name; + const idl::type_def* record = program.find_type(record_name); + FC_ASSERT(record && record->is_struct() && record->struct_fields, "IDL '{}' has no struct field definition", + record_name); + bool has_epoch = false; + bool has_checksum = false; + for (const idl::field& field : *record->struct_fields) { + if (field.name == envelope_log::field_epoch) { + FC_ASSERT(field.type.is_primitive() && field.type.primitive == idl::primitive_type::u32, + "{} '{}' must be declared u32, got '{}'", record_name, envelope_log::field_epoch, + describe_idl_type(field.type)); + has_epoch = true; + } else if (field.name == envelope_log::field_checksum) { + FC_ASSERT(field.type.is_array() && field.type.array_len == sizeof(fc::sha256) && field.type.array_element && + field.type.array_element->is_primitive() && + field.type.array_element->primitive == idl::primitive_type::u8, + "{} '{}' must be declared [u8; 32], got '{}'", record_name, envelope_log::field_checksum, + describe_idl_type(field.type)); + has_checksum = true; + } + } + FC_ASSERT(has_epoch, "{} IDL missing '{}' field", record_name, envelope_log::field_epoch); + FC_ASSERT(has_checksum, "{} IDL missing '{}' field", record_name, envelope_log::field_checksum); +} + /// 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 @@ -1207,6 +1254,29 @@ std::string drive_dispatch_rounds( return last_sig; } +std::optional decode_envelope_log_digest(opp_solana_outpost_client& program_client, + const std::vector& account_data, + uint32_t epoch_index) { + const fc::variant decoded = program_client.decode_account_info_data(envelope_log::account_name, account_data); + const fc::variants& envelopes = decoded.get_object()[envelope_log::field_envelopes].get_array(); + for (const fc::variant& entry : envelopes) { + const fc::variant_object& record = entry.get_object(); + if (record[envelope_log::field_epoch].as_uint64() != epoch_index) continue; + const fc::variants& checksum = record[envelope_log::field_checksum].get_array(); + FC_ASSERT(checksum.size() == sizeof(fc::sha256), "EnvelopeLog record for epoch {} carries a {}-byte checksum", + epoch_index, checksum.size()); + fc::sha256 digest; + for (size_t i = 0; i < checksum.size(); ++i) { + const uint64_t byte = checksum[i].as_uint64(); + FC_ASSERT(byte <= std::numeric_limits::max(), "EnvelopeLog checksum byte {} is out of range", + byte); + digest.data()[i] = static_cast(byte); + } + return digest; + } + return std::nullopt; +} + } // namespace outpost_solana_client_detail outpost_solana_client::outpost_solana_client( @@ -1241,6 +1311,11 @@ outpost_solana_client::outpost_solana_client( // (`create_outpost_client`) rather than on the first inbound poll - the // poll loop wlogs and retries forever, which would hide the misconfig. // The IDL is immutable after construction, so the check can never go stale. + if (role == solana_outpost_role::underwriter) { + FC_ASSERT(_program_client->get_program(), + "outpost_solana_client: no IDL program loaded; cannot validate the EnvelopeLog declaration"); + outpost_solana_client_detail::assert_envelope_log_shape(*_program_client->get_program()); + } if (role == solana_outpost_role::batch_operator) { FC_ASSERT(_program_client->get_program(), "outpost_solana_client: no IDL program loaded; cannot validate " @@ -1688,6 +1763,22 @@ std::string outpost_solana_client::deliver_outbound_envelope( return last_sig; } +std::optional outpost_solana_client::read_emitted_envelope_digest(uint32_t epoch_index, + fc::microseconds deadline) { + const fc::time_point deadline_abs = fc::time_point::now() + deadline; + fc::task::deadline_scope rpc_deadline(deadline_abs); + + throw_if_past_deadline(deadline_abs, OP_READ_EMITTED_DIGEST); + // `finalized`, as `read_inbound_envelope` reads: a record from a slot that can still roll back proves nothing. + const std::optional info = _entry->client->get_account_info( + _program_client->outbound_envelopes_pda, fc::network::solana::commitment_t::finalized); + if (!info.has_value()) return std::nullopt; // the log is created at initialize: nothing recorded yet + FC_ASSERT(info->owner == _program_id, + "outpost_solana_client[{}]: the outbound envelope log is owned by {} instead of program {}", to_string(), + info->owner.to_string(fc::yield_function_t{}), _program_id.to_string(fc::yield_function_t{})); + return outpost_solana_client_detail::decode_envelope_log_digest(*_program_client, info->data, epoch_index); +} + std::vector outpost_solana_client::read_inbound_envelope( uint32_t epoch_index, fc::microseconds deadline) { 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 78b07b9508..acc9f26566 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 @@ -2019,6 +2019,50 @@ idl::program named_program(std::string address) { return prog; } +/// Stub outpost program + the outbound `EnvelopeLog` as liqsol-core declares it: {bump, envelopes: Vec< +/// EnvelopeRecord{epoch_index, emitted_at, checksum}>}. +idl::program outpost_program_with_envelope_log() { + constexpr std::string_view envelope_log = "EnvelopeLog"; + constexpr std::string_view envelope_record = "EnvelopeRecord"; + auto prog = load_idl_fixture(opp_outpost_idl_fixture); + std::erase_if(prog.accounts, [&](const idl::account& a) { return a.name == envelope_log; }); + std::erase_if(prog.types, + [&](const idl::type_def& t) { return t.name == envelope_log || t.name == envelope_record; }); + + idl::type_def record; + record.name = std::string{envelope_record}; + record.struct_fields = std::vector{{"epoch_index", prim(idl::primitive_type::u32)}, + {"emitted_at", prim(idl::primitive_type::u64)}, + {"checksum", u8_32_array()}}; + prog.types.push_back(std::move(record)); + idl::type_def log; + log.name = std::string{envelope_log}; + log.struct_fields = std::vector{ + {"bump", prim(idl::primitive_type::u8)}, + {"envelopes", idl::idl_type::make_vec(idl::idl_type::make_defined(std::string{envelope_record}))}}; + prog.types.push_back(std::move(log)); + idl::account account; + account.name = std::string{envelope_log}; + account.compute_discriminator(); + prog.accounts.push_back(std::move(account)); + return prog; +} + +/// Serialize an `EnvelopeLog` account holding one record per (epoch, checksum). +std::vector envelope_log_account(const std::vector>>& records) { + borsh::encoder enc; + const auto disc = idl::compute_account_discriminator("EnvelopeLog"); + enc.write_fixed_bytes(disc.data(), disc.size()); + enc.write_u8(0xFF); // bump + enc.write_u32(static_cast(records.size())); + for (const auto& [epoch, checksum] : records) { + enc.write_u32(epoch); + enc.write_u64(1'700'000'000); // emitted_at + enc.write_fixed_bytes(checksum.data(), checksum.size()); + } + return enc.data(); +} + } // anonymous namespace BOOST_AUTO_TEST_CASE(latest_envelope_shape_accepts_known_layouts) try { @@ -2513,4 +2557,48 @@ 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_CASE(envelope_log_shape_is_checked_for_the_underwriter) try { + using sysio::outpost_solana_client_detail::assert_envelope_log_shape; + auto prog = outpost_program_with_envelope_log(); + BOOST_CHECK_NO_THROW(assert_envelope_log_shape(prog)); + + // A checksum that is not [u8; 32] cannot carry a keccak digest. + auto drifted = prog; + for (auto& type : drifted.types) { + if (type.name != "EnvelopeRecord") continue; + for (auto& field : *type.struct_fields) { + if (field.name == "checksum") field.type = idl::idl_type::make_array(prim(idl::primitive_type::u8), 20); + } + } + BOOST_CHECK_THROW(assert_envelope_log_shape(drifted), fc::exception); + + // No EnvelopeLog at all. + auto missing = prog; + std::erase_if(missing.accounts, [](const idl::account& a) { return a.name == "EnvelopeLog"; }); + std::erase_if(missing.types, [](const idl::type_def& t) { return t.name == "EnvelopeLog"; }); + BOOST_CHECK_THROW(assert_envelope_log_shape(missing), fc::exception); +} FC_LOG_AND_RETHROW(); + +BOOST_AUTO_TEST_CASE(decode_envelope_log_digest_finds_the_epochs_record) try { + using sysio::outpost_solana_client_detail::decode_envelope_log_digest; + const auto prog = outpost_program_with_envelope_log(); + sysio::opp_solana_outpost_client program_client(solana_client_ptr{}, measurement_pubkey(42), {prog}); + + const auto c7 = keccak_checksum(envelope_payload_bytes(7)); + const auto c8 = keccak_checksum(envelope_payload_bytes(8)); + const auto account = envelope_log_account({{7, c7}, {8, c8}}); + const auto d8 = decode_envelope_log_digest(program_client, account, 8); + BOOST_REQUIRE(d8.has_value()); + BOOST_CHECK(std::memcmp(d8->data(), c8.data(), c8.size()) == 0); + + // No record for the epoch: not emitted yet, or past the program's retention. + BOOST_CHECK(!decode_envelope_log_digest(program_client, account, 9).has_value()); + BOOST_CHECK(!decode_envelope_log_digest(program_client, envelope_log_account({}), 7).has_value()); + + // Another account's bytes are a failed read, not an answer. + BOOST_CHECK_THROW(decode_envelope_log_digest(program_client, + standalone_latest_account(7, envelope_payload_bytes(7), c7), 7), + fc::exception); +} FC_LOG_AND_RETHROW(); + BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/chain_plugin_tests.cpp b/tests/chain_plugin_tests.cpp index 66144237ac..5cf2888ac1 100644 --- a/tests/chain_plugin_tests.cpp +++ b/tests/chain_plugin_tests.cpp @@ -275,4 +275,68 @@ BOOST_FIXTURE_TEST_CASE( get_account, validating_tester ) try { } } FC_LOG_AND_RETHROW() /// get_account +BOOST_FIXTURE_TEST_CASE( key_alone_satisfies_follows_the_authority_checker, validating_tester ) try { + produce_block(); + create_accounts({"alice"_n, "bob"_n, "carol"_n}, false, false); + produce_block(); + + const auto alice_active = get_public_key("alice"_n, "active"); + const permission_level alice{"alice"_n, config::active_name}; + BOOST_CHECK(key_alone_satisfies(*control, alice, alice_active)); + // Declaring a permission takes that permission's own authority, not its parent's. + BOOST_CHECK(key_alone_satisfies(*control, {"alice"_n, config::owner_name}, get_public_key("alice"_n, "owner"))); + BOOST_CHECK(!key_alone_satisfies(*control, alice, get_public_key("alice"_n, "owner"))); + BOOST_CHECK(!key_alone_satisfies(*control, {"alice"_n, "nosuch"_n}, alice_active)); + BOOST_CHECK(!key_alone_satisfies(*control, {"nosuch"_n, config::active_name}, alice_active)); + + // A permission delegated to another account's permission is satisfied by that account's key. + set_authority("bob"_n, config::active_name, authority(1, {}, {permission_level_weight{alice, 1}})); + produce_block(); + BOOST_CHECK(key_alone_satisfies(*control, {"bob"_n, config::active_name}, alice_active)); + + // Under threshold 2, a key of weight 1 is not enough alone; one of weight 2 is. + const auto k1 = get_public_key("carol"_n, "k1"); + const auto k2 = get_public_key("carol"_n, "k2"); + authority two_of_two(2, {key_weight{k1, 1}, key_weight{k2, 1}}); + two_of_two.sort_fields(); + set_authority("carol"_n, config::active_name, two_of_two); + produce_block(); + BOOST_CHECK(!key_alone_satisfies(*control, {"carol"_n, config::active_name}, k1)); + authority heavy_k1(2, {key_weight{k1, 2}, key_weight{k2, 1}}); + heavy_k1.sort_fields(); + set_authority("carol"_n, config::active_name, heavy_k1); + produce_block(); + BOOST_CHECK(key_alone_satisfies(*control, {"carol"_n, config::active_name}, k1)); + BOOST_CHECK(!key_alone_satisfies(*control, {"carol"_n, config::active_name}, k2)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( permission_satisfies_link_follows_linkauth, validating_tester ) try { + produce_block(); + create_accounts({"alice"_n, "bob"_n}, false, false); + produce_block(); + set_authority("alice"_n, "underwrite"_n, authority(get_public_key("alice"_n, "underwrite")), config::active_name); + produce_block(); + + const permission_level underwrite{"alice"_n, "underwrite"_n}; + const permission_level active{"alice"_n, config::active_name}; + // Nothing linked: an action needs active, which active and owner satisfy and a child of active does not. + BOOST_CHECK(!permission_satisfies_link(*control, underwrite, "bob"_n, "accept"_n)); + BOOST_CHECK(permission_satisfies_link(*control, active, "bob"_n, "accept"_n)); + BOOST_CHECK(permission_satisfies_link(*control, {"alice"_n, config::owner_name}, "bob"_n, "accept"_n)); + + link_authority("alice"_n, "bob"_n, "underwrite"_n, "accept"_n); + produce_block(); + BOOST_CHECK(permission_satisfies_link(*control, underwrite, "bob"_n, "accept"_n)); + BOOST_CHECK(permission_satisfies_link(*control, active, "bob"_n, "accept"_n)); + BOOST_CHECK(!permission_satisfies_link(*control, underwrite, "bob"_n, "claim"_n)); // links are per action + + // Linked to sysio.any: any permission of the account may declare it. + link_authority("alice"_n, "bob"_n, "sysio.any"_n, "anyact"_n); + produce_block(); + BOOST_CHECK(permission_satisfies_link(*control, underwrite, "bob"_n, "anyact"_n)); + + // A permission that does not exist satisfies nothing. + BOOST_CHECK(!permission_satisfies_link(*control, {"alice"_n, "nosuch"_n}, "bob"_n, "accept"_n)); +} FC_LOG_AND_RETHROW() + BOOST_AUTO_TEST_SUITE_END()