Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
95 changes: 95 additions & 0 deletions contracts/tests/sysio.dispatch_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,11 @@
#include <fc/crypto/ethereum/ethereum_types.hpp>
#include <magic_enum/magic_enum.hpp>

#include <boost/endian/conversion.hpp>

#include <algorithm>
#include <array>
#include <cstring>
#include <iterator>
#include <limits>
#include <map>
Expand Down Expand Up @@ -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<char> statement(52);
auto* p = reinterpret_cast<unsigned char*>(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<std::vector<char>>());
// 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<int>(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) {
Expand Down
3 changes: 2 additions & 1 deletion libraries/libfc/include/fc/crypto/sha256.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include <span>
#include <compare>
#include <string_view>
#include <fc/fwd.hpp>
#include <fc/string.hpp>
#include <fc/platform_independence.hpp>
Expand All @@ -22,7 +23,7 @@ class sha256 : public add_packhash_to_hash<sha256>
using byte_array_type = std::array<uint8_t, sha256::byte_size>;

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 );

Expand Down
2 changes: 1 addition & 1 deletion libraries/libfc/src/crypto/sha256.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) );
Expand Down
98 changes: 93 additions & 5 deletions plugins/batch_operator_plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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: `<operator>@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

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<batch_operator_plugin> {
public:
APPBASE_PLUGIN_REQUIRES(
Expand Down
Loading
Loading