diff --git a/.gitignore b/.gitignore index dbd166ae7d..192541cefc 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ tmp .run/ /.data *.a -*.sw* +*.sw? *.dylib *.ll *.bc diff --git a/contracts/CMakeLists.txt b/contracts/CMakeLists.txt index 803be67bf1..e9ea5c905e 100644 --- a/contracts/CMakeLists.txt +++ b/contracts/CMakeLists.txt @@ -58,4 +58,6 @@ add_subdirectory(sysio.dclaim) add_subdirectory(sysio.token) add_subdirectory(sysio.wrap) +add_subdirectory(sysio.swap) +add_subdirectory(sysio.liq) add_subdirectory(test_contracts) diff --git a/contracts/sysio.authex/include/sysio.authex/sysio.authex.hpp b/contracts/sysio.authex/include/sysio.authex/sysio.authex.hpp index c0e1f2f8db..56c4501331 100644 --- a/contracts/sysio.authex/include/sysio.authex/sysio.authex.hpp +++ b/contracts/sysio.authex/include/sysio.authex/sysio.authex.hpp @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include @@ -189,6 +191,55 @@ namespace sysio { } } + /// The key width each chain family carries in a `ChainAddress.address`. + inline constexpr size_t EVM_PUBKEY_BYTES = 33; // compressed secp256k1 + inline constexpr size_t SVM_PUBKEY_BYTES = 32; // Ed25519 + + /** + * @brief Build the `sysio::public_key` variant a `links` row stores from a chain + * family and the raw key bytes an outpost carries in a `ChainAddress.address`. + * Inverse of `pubkey_to_bytes`, shared by every contract that resolves an inbound + * pubkey through the `links` `bypubkey` index. + * + * Only the two families authex links carry are representable: EM (33 bytes) for + * EVM and ED (32 bytes) for SVM. Anything else -- another chain kind, or bytes of + * the wrong width -- yields nullopt, and the caller treats that as "no link". + * That is the whole never-throw contract: `pubkey_to_checksum256` aborts on any + * other variant, so a resolver must never hash a key this function did not build. + * + * @param chain The chain family the bytes belong to. + * @param bytes The raw key: 33 bytes for EM, 32 for ED. + * @return The variant, or nullopt when no link could hold these bytes. + */ + inline std::optional public_key_from_op_address(opp::types::ChainKind chain, + const std::vector& bytes) { + sysio::public_key pk; + switch (chain) { + case opp::types::ChainKind::CHAIN_KIND_EVM: { // EM — variant index 3 + if (bytes.size() != EVM_PUBKEY_BYTES) return std::nullopt; + sysio::ecc_public_key arr; + std::copy(bytes.begin(), bytes.end(), arr.begin()); + pk.emplace<3>(arr); + return pk; + } + case opp::types::ChainKind::CHAIN_KIND_SVM: { // ED — variant index 4 + if (bytes.size() != SVM_PUBKEY_BYTES) return std::nullopt; + sysio::ed_public_key arr; + std::copy(bytes.begin(), bytes.end(), reinterpret_cast(arr.data())); + pk.emplace<4>(arr); + return pk; + } + default: + return std::nullopt; + } + } + + /// True iff `bytes` has the width of a key `chain` links can carry — the exact set + /// `public_key_from_op_address` accepts. + inline bool pubkey_fits(opp::types::ChainKind chain, const std::vector& bytes) { + return public_key_from_op_address(chain, bytes).has_value(); + } + class [[sysio::contract("sysio.authex")]] authex : public contract { public: using contract::contract; diff --git a/contracts/sysio.authex/src/sysio.authex.cpp b/contracts/sysio.authex/src/sysio.authex.cpp index 0015f8413b..aac0413357 100644 --- a/contracts/sysio.authex/src/sysio.authex.cpp +++ b/contracts/sysio.authex/src/sysio.authex.cpp @@ -10,6 +10,7 @@ using namespace sysio; // sysio funds the RAM for every link row (system-paid) -- createlink and recordlink alike. constexpr name link_row_payer = "sysio"_n; constexpr name dclaim_account = "sysio.dclaim"_n; +constexpr name liq_account = "sysio.liq"_n; constexpr name linkswept_action = "linkswept"_n; constexpr auto dclaim_not_ready_message = "sysio.dclaim must be deployed and privileged before creating a link"; @@ -66,6 +67,27 @@ void try_send_linked_rewards_sweep(const name self, const name account, if (dclaim_ready()) send_linked_rewards_sweep(self, account, chain_kind, native_address); } +/** Return whether the shadow-liq ledger can receive a system-paid link sweep. */ +[[nodiscard]] bool liq_ready() { + return is_account(liq_account) && is_privileged(liq_account); +} + +/** + * Best-effort sweep of the shadow liq parked against a user-created link's key. + * + * sysio.liq parks by the key bytes an outpost reports (the 33-byte EM key or the 32-byte ED + * key), so it receives the verified key, not the derived address the DClaim sweep takes. The + * sweep is optional by design: nothing is parked before sysio.liq exists, and a link created + * while it was absent is served by the permissionless `sysio.liq::sweep`. + */ +void try_send_parked_liq_sweep(const name self, const name account, + const opp::types::ChainKind chain_kind, + const std::vector& pubkey) { + if (!liq_ready()) return; + action(permission_level{self, "active"_n}, liq_account, linkswept_action, + std::make_tuple(account, chain_kind, pubkey)).send(); +} + } // anonymous namespace @@ -188,6 +210,7 @@ namespace sysio { }); send_linked_rewards_sweep(get_self(), account, chain_kind, native_address); + try_send_parked_liq_sweep(get_self(), account, chain_kind, pubkey_to_bytes(verified_pub_key)); // The verified key is recorded in the links table only; it is NOT added to the // account's `active` (or any) permission, so the link grants no Wire signing diff --git a/contracts/sysio.authex/sysio.authex.wasm b/contracts/sysio.authex/sysio.authex.wasm index e51c08c769..92f6be06b5 100755 Binary files a/contracts/sysio.authex/sysio.authex.wasm and b/contracts/sysio.authex/sysio.authex.wasm differ diff --git a/contracts/sysio.dclaim/src/sysio.dclaim.cpp b/contracts/sysio.dclaim/src/sysio.dclaim.cpp index de419166ac..ca7689c937 100644 --- a/contracts/sysio.dclaim/src/sysio.dclaim.cpp +++ b/contracts/sysio.dclaim/src/sysio.dclaim.cpp @@ -306,7 +306,7 @@ void dclaim::onreward(uint64_t chain_code, permission_level{ get_self(), "active"_n }, SYSTEM_ACCOUNT, "fundclaim"_n, - std::make_tuple(static_cast(reward_amount)) + std::make_tuple(get_self(), static_cast(reward_amount)) ).send(); } diff --git a/contracts/sysio.dclaim/sysio.dclaim.wasm b/contracts/sysio.dclaim/sysio.dclaim.wasm index 9f056cdcb5..b4c52b5383 100755 Binary files a/contracts/sysio.dclaim/sysio.dclaim.wasm and b/contracts/sysio.dclaim/sysio.dclaim.wasm differ diff --git a/contracts/sysio.liq/CMakeLists.txt b/contracts/sysio.liq/CMakeLists.txt new file mode 100644 index 0000000000..a7b849c0e6 --- /dev/null +++ b/contracts/sysio.liq/CMakeLists.txt @@ -0,0 +1,48 @@ +set(contract_name sysio.liq) +bootstrap_contract(${contract_name}) + +if(BUILD_SYSTEM_CONTRACTS) + find_cdt_magic_enum() + file(GLOB_RECURSE SOURCES src/*.cpp) + file(GLOB_RECURSE HEADERS include/*.hpp) + add_contract(${contract_name} ${contract_name} ${SOURCES}) + set(targets ${contract_name}) + + if("native-module" IN_LIST SYSIO_WASM_RUNTIMES) + list(APPEND targets ${contract_name}_native) + add_native_contract( + TARGET ${contract_name}_native + SOURCES ${SOURCES} + INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/include + CONTRACT_CLASS "sysio::liq" + HEADERS ${HEADERS} + ABI_FILE ${CMAKE_BINARY_DIR}/contracts/${contract_name}/${contract_name}.abi + ) + endif() + + foreach(target ${targets}) + if(NOT TARGET ${target}) + message(WARNING "Target ${target} not found, skipping include directory setup") + continue() + endif() + + target_include_directories(${target} + PUBLIC + $ + $ + $ + $ + $ + $ + $ + $ + $ + $ + ) + + target_link_libraries(${target} + INTERFACE + magic_enum::magic_enum + ) + endforeach() +endif() diff --git a/contracts/sysio.liq/README.md b/contracts/sysio.liq/README.md new file mode 100644 index 0000000000..ab400baab3 --- /dev/null +++ b/contracts/sysio.liq/README.md @@ -0,0 +1,99 @@ +# sysio.liq + +The depot's shadow token for syndicated liq. One shadow symbol per outpost liq +token (`LIQETH`, `LIQSOL`, precision 9), minted 1:1 against liq the outpost holds +in its syndicated pool and burned when a holder de-syndicates. Holders earn WIRE +yield through the cumulative index of `sysio.opp.common/shadow_yield.hpp`: every +balance move settles the row first, and `claim` pays what `shadow::owed` says. + +The contract is privileged (`sysio.roa::setsyscode`): holder rows bill the `sysio` +RAM pool, and every inline action carries the authority it needs, so no +`sysio.code` grant exists anywhere. + +## Flows + +**Syndication (SYNDICATE_LIQ, inbound).** `sysio.msgch` resolves the user's pubkey +through `sysio.authex` and calls `mintsynd` (linked) or `park` (not linked). A +parked row accrues like any holder; `linkswept` (inline from `createlink`) or the +permissionless `sweep` delivers it, accrued WIRE included, to the account the +pubkey later links. The inbound actions never throw: a replayed `sequence`, an +unknown token, a token of another chain or an out-of-range amount is dropped with +a diagnostic. + +**Yield (LIQ_YIELD, inbound).** `mintyield` holds the reported yield in the +symbol's pending balance, outside supply but reserved against the asset range +beside it: every supply-growing path measures its headroom net of what is pending, +a report past that headroom is dropped before its sequence is consumed, and +queueing always fits. The permissionless `queueyield` mints it +to this contract, announces it to `sysio.swap` with `fundyield` and transfers it, +in one transaction, so it lands in the pool's reservoir and never accrues to this +contract. The swap sells it in clips through the pool and pays the proceeds in +through `addyield`, which advances the index by `quantity / supply`, carries the +remainder and pulls the WIRE by inline transfer. + +**The kicker.** On the swap's intake, the one that is yield, `addyield` requests +`kicker_bps` (default 200) of the intake from T5 through +`sysio.system::fundclaim(sysio.liq, amount)`, then folds what actually landed into +the same index with `addkicker`, measured against the balance the pull left. A +donation from any other account distributes only itself: the treasury never tops +up what is not yield, or a near-sole holder could donate, claim it back with the +kicker on top, and repeat. A short T5 reduces the kicker only. `setkicker` (auth +`sysio`, the account council proposals execute as) changes the next intake. + +**De-syndication (DESYNDICATE_LIQ, outbound).** `desyndicate` requires the holder +to be AuthX-linked for the token's chain, settles and burns the shadow, and queues +`DesyndicateLIQ{chain_code, user = linked pubkey, amount, request_id}` through +`sysio.msgch::queueout`. Request ids start at 1. The burn is final: an outpost +refusal is reconciled from its log by governance through `recredit`. + +**Launch ingestion (epoch-0 bootstrap window, privileged caller).** `regliqpool` +mints the LCO liq to `sysio`, deposits it with the T5 dex earmark WIRE into +`sysio.swap`, creates the pair (`sysio` fee authority, the shadow as yield leg) +and sets the tick parameters. `importsynd` replays pre-launch positions in +batches (the LCO yield already folded into each amount); `importdone` closes the +import. + +## Tables + +| Table | Scope / key | Row | +|---|---|---| +| `stat` | symbol code | `supply`, `chain_code`, `token_code`, `pair_symbol` (empty until `regliqpool`); index `bytoken` | +| `accounts` | holder / symbol code | `balance`, `index_checkpoint` (uint128), `owed_wire` | +| `yieldidx` | symbol code | `index` (uint128), `pot`, `carry` | +| `parked` | symbol code, chain kind, pubkey | `chain_kind`, `pubkey`, `holding` (an account row) | +| `liqpending` | symbol code | `quantity` minted by LIQ_YIELD and not yet queued; counts against the asset range beside supply | +| `liqcursors` | chain code | `last_sequence`, `last_epoch` | +| `liqconfig` | singleton | `kicker_bps`, `import_complete` | +| `liqcounters` | singleton | `next_request_id` | + +## Actions + +| Action | Auth | Purpose | +|---|---|---| +| `create(sym, chain_code, token_code)` | self | Register a shadow for an active `TOKEN_KIND_LIQ` token bound to an active outpost | +| `setkicker(bps)` | `sysio` | Kicker for the intakes from now on | +| `recredit(holder, quantity)` | self | Mint back after an outpost refused a de-syndication | +| `mintsynd(chain_code, sequence, account, token_code, amount)` | `sysio.msgch` | SYNDICATE_LIQ, linked user | +| `park(chain_code, sequence, chain_kind, pubkey, token_code, amount)` | `sysio.msgch` | SYNDICATE_LIQ, unlinked user | +| `mintyield(chain_code, sequence, epoch, token_code, amount)` | `sysio.msgch` | LIQ_YIELD into the pending balance | +| `queueyield(sym)` | none | Pending yield into the swap's reservoir | +| `sweep(account, chain_kind)` | none | Deliver parked rows for an existing link | +| `transfer`, `open`, `close`, `claim` | holder | `sysio.token`'s shape; `close` refuses while yield is owed | +| `addyield(from, quantity, target)` | `from` | Distribute WIRE to `target`'s holders | +| `addkicker(sym, base_balance, requested)` | self | Inline from `addyield` | +| `linkswept(account, chain_kind, pubkey)` | `sysio.authex` | Deliver parked rows on link | +| `desyndicate(holder, quantity)` | holder | Burn and queue DESYNDICATE_LIQ | +| `regliqpool(...)`, `importsynd(...)`, `importdone()` | privileged caller, epoch 0 | Launch ingestion | + +## Deployment + +In order: `sysio.roa::setsyscode` for `sysio.liq`; the chain and its liq token +registered and active in `sysio.chains` / `sysio.tokens`; `create` per shadow +symbol; `setkicker` if the default is not wanted; `regliqpool` per pool; +`importsynd` batches; `importdone`. `sysio.swap` must be configured +(`setconfig`) before `regliqpool`, and the batch-operator crank pushes +`queueyield` per symbol and `sysio.swap::tickyield` per pair. + +The Solana relay must carry the `DESYNDICATE_LIQ` effect shape before this +contract is deployed: a delivered `DesyndicateLIQ` without its accounts aborts +the outpost's handler and wedges the epoch. diff --git a/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp new file mode 100644 index 0000000000..60f2b18e03 --- /dev/null +++ b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp @@ -0,0 +1,324 @@ +#pragma once +/** + * @file sysio.liq.hpp + * @brief sysio.liq: the depot's shadow token for syndicated liq (LIQETH, LIQSOL). + * + * One shadow symbol per outpost liq token, minted 1:1 against liq the outpost + * holds in its syndicated pool and burned when a holder de-syndicates. Holders + * earn WIRE yield through the cumulative index of + * sysio.opp.common/shadow_yield.hpp: every balance move settles the row first, + * and `claim` pays what `shadow::owed` says. + * + * Inbound, dispatched by sysio.msgch and therefore never throwing: + * SYNDICATE_LIQ -> `mintsynd` for an AuthX-linked user, `park` for one without a link; + * LIQ_YIELD -> `mintyield`, into a pending balance outside supply that the + * permissionless `queueyield` hands to sysio.swap's reservoir. + * Outbound: `desyndicate` burns and queues DESYNDICATE_LIQ; the burn is final. + * Yield intake: sysio.swap's tick sells reservoir shadow and pays the proceeds in + * through `addyield`, which also draws the kicker from T5 (sysio.system::fundclaim). + * Launch: `regliqpool` seeds the swap's yield pool and `importsynd` / `importdone` + * replay the pre-launch positions, all inside the epoch-0 bootstrap window. + * + * Privileged (roa::setsyscode): holder rows bill the `sysio` RAM pool, and every + * inline action carries the authority it needs, so no `sysio.code` grant exists. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace sysio { + + using std::string; + + class [[sysio::contract("sysio.liq")]] liq : public contract { + public: + using contract::contract; + + // Well-known accounts. + static constexpr name MSGCH_ACCOUNT = "sysio.msgch"_n; + static constexpr name AUTHEX_ACCOUNT = "sysio.authex"_n; + static constexpr name TOKEN_ACCOUNT = "sysio.token"_n; + static constexpr name TOKENS_ACCOUNT = "sysio.tokens"_n; + static constexpr name CHAINS_ACCOUNT = "sysio.chains"_n; + static constexpr name EPOCH_ACCOUNT = "sysio.epoch"_n; + static constexpr name SWAP_ACCOUNT = "sysio.swap"_n; + /// Governance executes approved proposals as `sysio`; it is also the T5 + /// treasury the bootstrap drains and the holder of the protocol's pool shares. + static constexpr name SYSTEM_ACCOUNT = "sysio"_n; + + /// The yield asset. The kicker arrives in it from sysio.system, so every + /// pot is in WIRE and nothing else. + static constexpr symbol WIRE_SYM = opp::wire::asset_symbol; + + /// The kicker at launch: 2% of every yield intake, drawn from T5. + static constexpr uint32_t DEFAULT_KICKER_BPS = 200; + + // ----------------------------------------------------------------------- + // Deployment and governance + // ----------------------------------------------------------------------- + + /// Register the shadow symbol `sym` for the liq token `token_code` of the + /// outpost `chain_code`. Both must be active registry rows, the token a + /// TOKEN_KIND_LIQ whose depot precision is `sym`'s; one shadow per token. + /// Requires this contract's authority. + [[sysio::action]] void create(symbol sym, sysio::slug_name chain_code, sysio::slug_name token_code); + + /// Set the kicker, in basis points of each yield intake, for the intakes + /// from now on. Auth=sysio: council proposals execute as it. + [[sysio::action]] void setkicker(uint32_t bps); + + /// Governance: mint `quantity` back to `holder` after its outpost refused + /// a de-syndication (reconciled from the outpost log). Requires this + /// contract's authority. + [[sysio::action]] void recredit(name holder, asset quantity); + + // ----------------------------------------------------------------------- + // Inbound OPP effects (sysio.msgch dispatch; never throw) + // ----------------------------------------------------------------------- + + /// SYNDICATE_LIQ for an AuthX-linked user: mint `amount` of `token_code`'s + /// shadow to `account`. Auth=sysio.msgch. A replayed `sequence`, an + /// unknown token, a token of another chain or an out-of-range amount is + /// dropped with a diagnostic, never an abort. + [[sysio::action]] void mintsynd(sysio::slug_name chain_code, uint64_t sequence, name account, + sysio::slug_name token_code, uint64_t amount); + + /// SYNDICATE_LIQ for a user with no AuthX link yet: mint to a parked row + /// keyed by the user's native pubkey, which accrues like any holder until + /// `linkswept` or `sweep` delivers it. Same contract as `mintsynd`. + [[sysio::action]] void park(sysio::slug_name chain_code, uint64_t sequence, + opp::types::ChainKind chain_kind, std::vector pubkey, + sysio::slug_name token_code, uint64_t amount); + + /// LIQ_YIELD: the outpost claimed `amount` of yield for its syndicated + /// pool. Held in the symbol's pending balance, outside supply, until + /// `queueyield` moves it; `epoch` is kept on the cursor for forensics. + /// Same contract as `mintsynd`. + [[sysio::action]] void mintyield(sysio::slug_name chain_code, uint64_t sequence, uint64_t epoch, + sysio::slug_name token_code, uint64_t amount); + + // ----------------------------------------------------------------------- + // Cranks + // ----------------------------------------------------------------------- + + /// Hand `sym`'s pending yield to sysio.swap's reservoir for its pool: mint + /// it to this contract, announce it with `fundyield` and transfer it, all + /// in one transaction. Permissionless; a no-op with nothing pending. + [[sysio::action]] void queueyield(symbol_code sym); + + /// Deliver every parked row of the pubkey `account` has linked for + /// `chain_kind`, for a link that already exists: late arrivals, and the + /// node-owner path that records links without `createlink`. Permissionless. + [[sysio::action]] void sweep(name account, opp::types::ChainKind chain_kind); + + // ----------------------------------------------------------------------- + // The token + // ----------------------------------------------------------------------- + + [[sysio::action]] void transfer(name from, name to, asset quantity, string memo); + [[sysio::action]] void open(name owner, symbol symbol, name ram_payer); + /// Erase `owner`'s empty row for `symbol`. Refused while the row is still + /// owed yield, so closing never discards WIRE. + [[sysio::action]] void close(name owner, symbol symbol); + /// Pay `holder` the WIRE its row for `sym` is owed and settle the row. + /// Holder's authority; sends nothing when nothing is owed. + [[sysio::action]] void claim(name holder, symbol_code sym); + /// Distribute `quantity` WIRE to `target`'s holders: the index advances by + /// quantity / supply with the remainder carried and the WIRE is pulled from + /// `from` by inline transfer. The kicker is requested from T5 only when + /// `from` is the swap; any other donation distributes itself alone. + [[sysio::action]] void addyield(name from, asset quantity, symbol_code target); + /// Fold the kicker `fundclaim` delivered into `sym`'s index: what this + /// contract's WIRE balance now exceeds `base_balance` by, at most + /// `requested`. Inline from `addyield`; this contract's authority. + [[sysio::action]] void addkicker(symbol_code sym, int64_t base_balance, uint64_t requested); + + /// AuthX link completed for `account` on `chain_kind`: deliver every parked + /// row of that pubkey to `account`, accrued WIRE included. Auth=sysio.authex. + [[sysio::action]] void linkswept(name account, opp::types::ChainKind chain_kind, std::vector pubkey); + + /// Burn `quantity` of `holder`'s shadow and queue DESYNDICATE_LIQ to the + /// symbol's outpost, paying the pubkey `holder` has linked for that chain. + /// The burn is final: an outpost refusal is reconciled by governance through + /// `recredit`. Holder's authority. + [[sysio::action]] void desyndicate(name holder, asset quantity); + + // ----------------------------------------------------------------------- + // Launch ingestion (privileged caller, epoch-0 bootstrap window) + // ----------------------------------------------------------------------- + + /// Seed the swap's yield pool for `token_code`'s shadow: mint the LCO liq + /// (`initial_chain_amount`, already in outpost custody) to `sysio`, deposit + /// it with `initial_wire_amount` WIRE from the T5 dex earmark into + /// sysio.swap, create the pair with `sysio` as its fee authority and the + /// shadow as its yield leg, and set the tick parameters. `fee` is in + /// 1/10000 of the traded amount, `locked_shares` in `pair_symbol`. + [[sysio::action]] void regliqpool(sysio::slug_name chain_code, sysio::slug_name token_code, symbol pair_symbol, + uint64_t initial_chain_amount, uint64_t initial_wire_amount, int32_t fee, + int64_t locked_shares, uint32_t conversion_horizon_sec, + uint32_t depth_cap_bps, int64_t clip_floor); + + /// One pre-launch position: the holder's native pubkey (32-byte Ed25519 on + /// SVM, 33-byte compressed secp256k1 on EVM) and its shadow amount in + /// subunits, the LCO yield already folded in. + struct import_credit { + std::vector pubkey; + uint64_t amount = 0; + SYSLIB_SERIALIZE(import_credit, (pubkey)(amount)) + }; + + /// Replay pre-launch positions of `token_code` on `chain_code`: each credit + /// mints to the account its pubkey has linked, or to a parked row. Batched; + /// the same pubkey across batches sums. Refused once `importdone` ran. + [[sysio::action]] void importsynd(sysio::slug_name chain_code, sysio::slug_name token_code, + std::vector credits); + + /// Close the import: every later `importsynd` is refused. + [[sysio::action]] void importdone(); + + // ----------------------------------------------------------------------- + // Tables + // ----------------------------------------------------------------------- + + using symbol_key = opp::shadow::symbol_key; + + /// One shadow symbol: its supply and the registry rows it mirrors. + struct [[sysio::table("stat")]] currency_stats { + asset supply; + sysio::slug_name chain_code; ///< the outpost whose liq this shadow mirrors + sysio::slug_name token_code; ///< that outpost's liq token in sysio.tokens + symbol_code pair_symbol; ///< the swap's pair token of this shadow's yield pool; empty until regliqpool + + uint64_t by_token_code() const { return token_code.value; } + + SYSLIB_SERIALIZE(currency_stats, (supply)(chain_code)(token_code)(pair_symbol)) + }; + + using stats = kv::table<"stat"_n, symbol_key, currency_stats, + kv::index<"bytoken"_n, const_mem_fun>>; + + /// Holder rows (scope = holder, key = symbol code) and the per-symbol + /// index, laid out by sysio.opp.common/shadow_yield.hpp. + using accounts = kv::scoped_table<"accounts"_n, symbol_key, opp::shadow::account>; + using yieldidxs = kv::table<"yieldidx"_n, symbol_key, opp::shadow::yield_index>; + + /// A parked row: the symbol, the chain family and the holder's native pubkey. + struct parked_key { + uint64_t symbol_code; + uint64_t chain_kind; ///< magic_enum::enum_integer of the ChainKind; the row keeps the enum + std::vector pubkey; + SYSLIB_SERIALIZE(parked_key, (symbol_code)(chain_kind)(pubkey)) + }; + + /// Shadow minted for a pubkey with no AuthX link yet. `holding` accrues + /// exactly as a holder row does, so linking late costs no yield. + struct [[sysio::table("parked")]] parked_row { + opp::types::ChainKind chain_kind; + std::vector pubkey; + opp::shadow::account holding; + SYSLIB_SERIALIZE(parked_row, (chain_kind)(pubkey)(holding)) + }; + + using parkeds = kv::table<"parked"_n, parked_key, parked_row>; + + /// Yield minted by LIQ_YIELD and not yet queued. Outside supply, so it + /// earns nothing while it waits and nothing is stranded when it leaves; + /// reserved against the asset range beside supply, so queueing always fits. + struct [[sysio::table("liqpending")]] pending_yield { + asset quantity; + SYSLIB_SERIALIZE(pending_yield, (quantity)) + }; + + using liqpendings = kv::table<"liqpending"_n, symbol_key, pending_yield>; + + struct cursor_key { + uint64_t chain_code; + SYSLIB_SERIALIZE(cursor_key, (chain_code)) + }; + + /// Per-outpost replay guard over the sequence SYNDICATE_LIQ and LIQ_YIELD share. + struct [[sysio::table("liqcursors")]] liq_cursor { + sysio::slug_name chain_code; + uint64_t last_sequence = 0; ///< highest sequence admitted; anything at or below it is a replay + uint64_t last_epoch = 0; ///< outpost epoch of the last LIQ_YIELD, for forensics + SYSLIB_SERIALIZE(liq_cursor, (chain_code)(last_sequence)(last_epoch)) + }; + + using liqcursors = kv::table<"liqcursors"_n, cursor_key, liq_cursor>; + + struct [[sysio::table("liqconfig")]] liq_config { + uint32_t kicker_bps = DEFAULT_KICKER_BPS; + bool import_complete = false; + SYSLIB_SERIALIZE(liq_config, (kicker_bps)(import_complete)) + }; + + using liqconfig_t = kv::global<"liqconfig"_n, liq_config>; + + struct [[sysio::table("liqcounters")]] liq_counters { + uint64_t next_request_id = 1; ///< DESYNDICATE_LIQ ids; the outpost reads 0 as "no id" + SYSLIB_SERIALIZE(liq_counters, (next_request_id)) + }; + + using liqcounters_t = kv::global<"liqcounters"_n, liq_counters>; + + private: + using ChainKind = opp::types::ChainKind; + using u128 = opp::shadow::u128; + + /// The stat row of `sym`, or a check failure. + currency_stats stat_of(symbol_code sym) const; + /// The stat row bound to `token_code`, if any. + std::optional stat_by_token(sysio::slug_name token_code) const; + /// Base units `st`'s supply can still grow by: the asset range net of the supply + /// and of the yield parked in `liqpending`, which mints when queued. + uint64_t headroom(const currency_stats& st) const; + /// The chain family of the registered outpost `chain_code`, or a check failure. + ChainKind kind_of_chain(sysio::slug_name chain_code) const; + /// `sym`'s index now; zero before the first distribution. + u128 current_index(symbol_code sym) const; + /// This contract's WIRE balance on sysio.token; zero without a row. + int64_t wire_balance() const; + + /// The one funnel every balance move goes through: settle `row` at `index`, + /// stamp it, then apply `delta`. Nothing else writes `balance`. + static void settle_and_adjust(opp::shadow::account& row, u128 index, const asset& delta); + /// Apply `delta` to `owner`'s row for its symbol through the funnel. A row + /// created here is stamped at the current index, so no history is credited. + void adjust_account(name owner, const asset& delta, name payer); + /// The same for a parked row. + void adjust_parked(const parked_key& key, ChainKind chain_kind, const std::vector& pubkey, + const asset& delta); + /// Grow `sym`'s supply by `quantity`; false (and no change) past its headroom. + bool mint(symbol_code sym, uint64_t quantity); + /// Advance `sym`'s index by `quantity` WIRE over its supply, carrying the + /// remainder, and grow its pot by the same. + void distribute(symbol_code sym, uint64_t quantity); + /// Admit `sequence` for `chain_code` and advance its cursor; false on a replay. + bool admit_sequence(sysio::slug_name chain_code, uint64_t sequence, uint64_t epoch); + /// Move every parked row of `(chain_kind, pubkey)` into `account`'s rows. + void deliver_parked(name account, ChainKind chain_kind, const std::vector& pubkey); + /// Credit `amount` of `sym` to the account `pubkey` has linked, else to its parked row. + void credit_by_pubkey(symbol_code sym, ChainKind chain_kind, const std::vector& pubkey, uint64_t amount); + /// The `mintsynd` / `park` / `mintyield` preamble: the symbol for `token_code` + /// on `chain_code`, with `amount` in range and room in the supply, or nullopt + /// after a diagnostic. Touches no cursor: the caller admits the sequence only + /// once every check has passed, so a dropped attestation consumes nothing. + std::optional resolve_inbound(const char* path, sysio::slug_name chain_code, + sysio::slug_name token_code, uint64_t amount); + }; + +} // namespace sysio diff --git a/contracts/sysio.liq/src/sysio.liq.cpp b/contracts/sysio.liq/src/sysio.liq.cpp new file mode 100644 index 0000000000..cf7f96cf66 --- /dev/null +++ b/contracts/sysio.liq/src/sysio.liq.cpp @@ -0,0 +1,612 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace sysio { + +namespace { + +using opp::types::AttestationType; +using opp::types::ChainKind; +using opp::types::TokenKind; +using u128 = opp::shadow::u128; + +// System-owned rows bill the sysio RAM pool, not this contract account (privileged-contract +// model, as sysio.token uses). +constexpr name ram_payer = "sysio"_n; + +constexpr size_t MAX_MEMO_BYTES = 256; + +constexpr std::string_view YIELD_MEMO = "sysio.liq yield"; +constexpr std::string_view CLAIM_MEMO = "sysio.liq claim"; + +/// The `sysio.payer` seat: an inline action to an unprivileged contract that bills a +/// row to `actor` must carry it beside `actor`'s active permission. +permission_level payer_of(name actor) { return permission_level{ actor, "sysio.payer"_n }; } +permission_level active_of(name actor) { return permission_level{ actor, "active"_n }; } + +uint32_t current_epoch_index() { + sysio::epoch::epochstate_t es(liq::EPOCH_ACCOUNT); + if (!es.exists()) return 0; + return es.get().current_epoch_index; +} + +bool is_bootstrap_window() { + return current_epoch_index() == 0; +} + +void require_priv_caller() { + require_auth(current_receiver()); + check(is_privileged(current_receiver()), "sysio.liq: privileged account required"); +} + +liq::parked_key parked_key_of(symbol_code sym, ChainKind kind, const std::vector& pubkey) { + return liq::parked_key{ sym.raw(), static_cast(magic_enum::enum_integer(kind)), pubkey }; +} + +/// The account `pubkey` has linked on `kind`, or an empty name. +name linked_account(ChainKind kind, const std::vector& pubkey) { + const auto pk = public_key_from_op_address(kind, pubkey); + if (!pk) return name{}; // no link could hold these bytes + sysio::authex::links_t links(liq::AUTHEX_ACCOUNT); + auto by_pubkey = links.get_index<"bypubkey"_n>(); + auto it = by_pubkey.find(pubkey_to_checksum256(*pk)); + return it == by_pubkey.end() ? name{} : it->username; +} + +/// The pubkey `account` has linked on `kind`, or nullopt. +std::optional> linked_pubkey(name account, ChainKind kind) { + sysio::authex::links_t links(liq::AUTHEX_ACCOUNT); + auto by_namechain = links.get_index<"bynamechain"_n>(); + auto it = by_namechain.find(to_namechain_key(account, kind)); + if (it == by_namechain.end()) return std::nullopt; + return pubkey_to_bytes(it->pub_key); +} + +void drop(const char* path, const char* reason) { + sysio::print("sysio.liq::", path, ": DROP -- ", reason, "\n"); +} + +} // namespace + +// --------------------------------------------------------------------------- +// Deployment and governance +// --------------------------------------------------------------------------- + +void liq::create(symbol sym, sysio::slug_name chain_code, sysio::slug_name token_code) { + require_auth(get_self()); + check(sym.is_valid(), "invalid symbol"); + + const ChainKind kind = kind_of_chain(chain_code); + check(kind == ChainKind::CHAIN_KIND_EVM || kind == ChainKind::CHAIN_KIND_SVM, + "the chain must be an EVM or SVM outpost"); + + sysio::tokens::tokens_t tokens(TOKENS_ACCOUNT); + const auto token = tokens.try_get(sysio::tokens::token_key{ token_code }); + check(token.has_value() && token->active, "token_code is not an active registry token"); + check(token->kind == TokenKind::TOKEN_KIND_LIQ, "token_code is not a liq token"); + check(token->precision == sym.precision(), "symbol precision must match the token's depot precision"); + + sysio::tokens::chaintokens_t chaintokens(TOKENS_ACCOUNT); + const auto binding = chaintokens.try_get(sysio::tokens::chain_token_key{ chain_code, token_code }); + check(binding.has_value() && binding->active, "token_code is not bound to chain_code"); + + stats statstable(get_self()); + check(!stat_by_token(token_code).has_value(), "token_code already has a shadow symbol"); + statstable.emplace(ram_payer, symbol_key{ sym.code().raw() }, currency_stats{ + .supply = asset{ 0, sym }, + .chain_code = chain_code, + .token_code = token_code, + .pair_symbol = symbol_code{}, + }, "symbol already exists"); +} + +void liq::setkicker(uint32_t bps) { + require_auth(SYSTEM_ACCOUNT); + check(bps <= opp::amm::BPS_TOTAL, "kicker_bps out of range"); + liqconfig_t config(get_self()); + liq_config cfg = config.get_or_default(liq_config{}); + cfg.kicker_bps = bps; + config.set(cfg, ram_payer); +} + +void liq::recredit(name holder, asset quantity) { + require_auth(get_self()); + check(is_account(holder), "holder account does not exist"); + check(quantity.is_valid() && quantity.amount > 0, "quantity must be positive"); + const currency_stats st = stat_of(quantity.symbol.code()); + check(quantity.symbol == st.supply.symbol, "symbol precision mismatch"); + check(mint(quantity.symbol.code(), static_cast(quantity.amount)), "supply exceeds the asset range"); + adjust_account(holder, quantity, ram_payer); +} + +// --------------------------------------------------------------------------- +// Inbound OPP effects +// --------------------------------------------------------------------------- + +std::optional liq::resolve_inbound(const char* path, sysio::slug_name chain_code, + sysio::slug_name token_code, uint64_t amount) { + const auto st = stat_by_token(token_code); + if (!st) { drop(path, "token_code has no shadow symbol"); return std::nullopt; } + if (st->chain_code != chain_code) { drop(path, "token_code belongs to another chain"); return std::nullopt; } + if (amount == 0 || amount > static_cast(opp::safe::depot_amount_max)) { + drop(path, "amount out of range"); + return std::nullopt; + } + if (amount > headroom(*st)) { + drop(path, "supply exceeds the asset range"); + return std::nullopt; + } + return st; +} + +void liq::mintsynd(sysio::slug_name chain_code, uint64_t sequence, name account, + sysio::slug_name token_code, uint64_t amount) { + require_auth(MSGCH_ACCOUNT); + if (!is_account(account)) { drop("mintsynd", "account does not exist"); return; } + const auto st = resolve_inbound("mintsynd", chain_code, token_code, amount); + if (!st) return; + // Every check is behind us: the sequence is consumed only by a credit that lands. + if (!admit_sequence(chain_code, sequence, 0)) { drop("mintsynd", "replayed sequence"); return; } + const symbol_code sym = st->supply.symbol.code(); + check(mint(sym, amount), "supply exceeds the asset range"); // resolve_inbound bounded it + adjust_account(account, asset{ static_cast(amount), st->supply.symbol }, ram_payer); +} + +void liq::park(sysio::slug_name chain_code, uint64_t sequence, ChainKind chain_kind, std::vector pubkey, + sysio::slug_name token_code, uint64_t amount) { + require_auth(MSGCH_ACCOUNT); + if (!pubkey_fits(chain_kind, pubkey)) { drop("park", "pubkey does not fit the chain family"); return; } + const auto st = resolve_inbound("park", chain_code, token_code, amount); + if (!st) return; + if (kind_of_chain(st->chain_code) != chain_kind) { drop("park", "chain_kind is not the token's chain"); return; } + if (!admit_sequence(chain_code, sequence, 0)) { drop("park", "replayed sequence"); return; } + const symbol_code sym = st->supply.symbol.code(); + check(mint(sym, amount), "supply exceeds the asset range"); + adjust_parked(parked_key_of(sym, chain_kind, pubkey), chain_kind, pubkey, + asset{ static_cast(amount), st->supply.symbol }); +} + +void liq::mintyield(sysio::slug_name chain_code, uint64_t sequence, uint64_t epoch, + sysio::slug_name token_code, uint64_t amount) { + require_auth(MSGCH_ACCOUNT); + const auto st = resolve_inbound("mintyield", chain_code, token_code, amount); + if (!st) return; + if (!admit_sequence(chain_code, sequence, epoch)) { drop("mintyield", "replayed sequence"); return; } + const symbol_key key{ st->supply.symbol.code().raw() }; + liqpendings pendings(get_self()); + const pending_yield fresh{ asset{ static_cast(amount), st->supply.symbol } }; + // resolve_inbound bounded the amount by the headroom net of what is already pending, + // so supply plus pending stays within the asset range and queueyield always fits. + pendings.upsert(ram_payer, key, fresh, [&](pending_yield& p) { + p.quantity.amount += static_cast(amount); + }); +} + +// --------------------------------------------------------------------------- +// Cranks +// --------------------------------------------------------------------------- + +void liq::queueyield(symbol_code sym) { + liqpendings pendings(get_self()); + const symbol_key key{ sym.raw() }; + const auto pending = pendings.try_get(key); + if (!pending || pending->quantity.amount <= 0) return; // nothing queued: a cheap no-op for the crank + + const currency_stats st = stat_of(sym); + check(st.pair_symbol != symbol_code{}, "no yield pool registered for this shadow"); + const asset quantity = pending->quantity; + // Erased before the mint: the headroom mint measures is net of what is pending, and + // this is the pending being minted. Every intake reserved it, so the mint cannot fail. + pendings.erase(key); + + // Minted to this contract and handed on in the same transaction, so its row is + // settled at one index and accrues nothing on the way through. + check(mint(sym, static_cast(quantity.amount)), "supply exceeds the asset range"); + adjust_account(get_self(), quantity, ram_payer); + + action(std::vector{ payer_of(get_self()), active_of(get_self()) }, + SWAP_ACCOUNT, "fundyield"_n, + std::make_tuple(get_self(), st.pair_symbol, quantity)).send(); + action(active_of(get_self()), get_self(), "transfer"_n, + std::make_tuple(get_self(), SWAP_ACCOUNT, quantity, std::string{})).send(); +} + +void liq::sweep(name account, ChainKind chain_kind) { + const auto pubkey = linked_pubkey(account, chain_kind); + check(pubkey.has_value(), "account has no link for this chain"); + deliver_parked(account, chain_kind, *pubkey); +} + +// --------------------------------------------------------------------------- +// The token +// --------------------------------------------------------------------------- + +void liq::transfer(name from, name to, asset quantity, string memo) { + check(from != to, "cannot transfer to self"); + require_auth(from); + check(is_account(to), "to account does not exist"); + const currency_stats st = stat_of(quantity.symbol.code()); + + require_recipient(from); + require_recipient(to); + + check(quantity.is_valid(), "invalid quantity"); + check(quantity.amount > 0, "must transfer positive quantity"); + check(quantity.symbol == st.supply.symbol, "symbol precision mismatch"); + check(memo.size() <= MAX_MEMO_BYTES, "memo has more than 256 bytes"); + + adjust_account(from, -quantity, ram_payer); + adjust_account(to, quantity, ram_payer); +} + +void liq::open(name owner, symbol symbol, name ram_payer_) { + require_auth(ram_payer_); + check(is_account(owner), "owner account does not exist"); + const currency_stats st = stat_of(symbol.code()); + check(st.supply.symbol == symbol, "symbol precision mismatch"); + accounts holdings(get_self(), owner.value); + if (!holdings.contains(symbol_key{ symbol.code().raw() })) { + adjust_account(owner, asset{ 0, symbol }, ram_payer_); + } +} + +void liq::close(name owner, symbol symbol) { + require_auth(owner); + accounts holdings(get_self(), owner.value); + const symbol_key key{ symbol.code().raw() }; + const auto row = holdings.try_get(key); + check(row.has_value(), "Balance row already deleted or never existed. Action won't have any effect."); + check(row->balance.amount == 0, "Cannot close because the balance is not zero."); + check(opp::shadow::owed(*row, current_index(symbol.code())) == 0, "Cannot close because yield is still owed; claim first."); + holdings.erase(key); +} + +void liq::claim(name holder, symbol_code sym) { + require_auth(holder); + accounts holdings(get_self(), holder.value); + const symbol_key key{ sym.raw() }; + const auto row = holdings.try_get(key); + check(row.has_value(), "no balance object found"); + const u128 index = current_index(sym); + const uint64_t owed = opp::shadow::owed(*row, index); + holdings.modify(ram_payer, key, [&](opp::shadow::account& a) { + a.index_checkpoint = index; + a.owed_wire = 0; + }); + if (owed == 0) return; + + yieldidxs indexes(get_self()); + indexes.modify(ram_payer, key, [&](opp::shadow::yield_index& y) { + check(y.pot >= owed, "pot underfunded"); + y.pot -= owed; + }); + action(active_of(get_self()), TOKEN_ACCOUNT, "transfer"_n, + std::make_tuple(get_self(), holder, asset{ static_cast(owed), WIRE_SYM }, + std::string{ CLAIM_MEMO })).send(); +} + +void liq::addyield(name from, asset quantity, symbol_code target) { + require_auth(from); + check(quantity.symbol == WIRE_SYM, "yield must be in WIRE"); + check(quantity.amount > 0, "yield must be positive"); + const currency_stats st = stat_of(target); + check(st.supply.amount > 0, "no holders to distribute to"); + + distribute(target, static_cast(quantity.amount)); + action(active_of(from), TOKEN_ACCOUNT, "transfer"_n, + std::make_tuple(from, get_self(), quantity, std::string{ YIELD_MEMO })).send(); + + // The kicker: `kicker_bps` of the intake, requested from T5 -- only on the + // swap's intake, the one that is yield (tickyield's proceeds). A donation + // draws nothing: a near-sole holder could otherwise donate, claim it back + // with the kicker on top, and repeat against the treasury. fundclaim caps + // the draw and never throws, so what actually lands is folded in afterwards + // by addkicker, measured against the balance the pull above will have left. + if (from != SWAP_ACCOUNT) return; + liqconfig_t config(get_self()); + const uint32_t kicker_bps = config.get_or_default(liq_config{}).kicker_bps; + const uint64_t kicker = static_cast( + static_cast(quantity.amount) * kicker_bps / opp::amm::BPS_TOTAL); + if (kicker == 0) return; + const int64_t base_balance = wire_balance() + quantity.amount; + action(active_of(get_self()), SYSTEM_ACCOUNT, "fundclaim"_n, + std::make_tuple(get_self(), static_cast(kicker))).send(); + action(active_of(get_self()), get_self(), "addkicker"_n, + std::make_tuple(target, base_balance, kicker)).send(); +} + +void liq::addkicker(symbol_code sym, int64_t base_balance, uint64_t requested) { + require_auth(get_self()); + const int64_t received = wire_balance() - base_balance; + if (received <= 0) return; // T5 had nothing to give: base yield only + const currency_stats st = stat_of(sym); + if (st.supply.amount <= 0) return; + distribute(sym, std::min(static_cast(received), requested)); +} + +void liq::linkswept(name account, ChainKind chain_kind, std::vector pubkey) { + require_auth(AUTHEX_ACCOUNT); + if (!is_account(account) || !pubkey_fits(chain_kind, pubkey)) return; + deliver_parked(account, chain_kind, pubkey); +} + +void liq::desyndicate(name holder, asset quantity) { + require_auth(holder); + check(quantity.is_valid() && quantity.amount > 0, "quantity must be positive"); + const currency_stats st = stat_of(quantity.symbol.code()); + check(quantity.symbol == st.supply.symbol, "symbol precision mismatch"); + + const ChainKind kind = kind_of_chain(st.chain_code); + const auto pubkey = linked_pubkey(holder, kind); + check(pubkey.has_value(), "holder is not AuthX-linked for the token's chain"); + + // Settle, then burn: the row keeps every subunit of yield accrued to now. + adjust_account(holder, -quantity, ram_payer); + stats statstable(get_self()); + statstable.modify(ram_payer, symbol_key{ quantity.symbol.code().raw() }, [&](currency_stats& s) { + s.supply -= quantity; + }); + + liqcounters_t counters(get_self()); + liq_counters c = counters.get_or_default(liq_counters{}); + const uint64_t request_id = c.next_request_id++; + counters.set(c, ram_payer); + + opp::attestations::DesyndicateLIQ msg; + msg.chain_code = st.chain_code.value; + msg.user = opp::types::ChainAddress{ kind, *pubkey }; + msg.amount = opp::types::TokenAmount{ st.token_code.value, quantity.amount }; + msg.request_id = request_id; + // `no_size{}`: raw protobuf bytes, the form the outpost decodes the attestation + // `data` field as (the same encoding sysio.opreg's emitters use). + std::vector encoded; + auto out = zpp::bits::out{ encoded, zpp::bits::no_size{} }; + (void)out(msg); + + action(active_of(get_self()), MSGCH_ACCOUNT, "queueout"_n, + std::make_tuple(st.chain_code.value, AttestationType::ATTESTATION_TYPE_DESYNDICATE_LIQ, encoded)).send(); +} + +// --------------------------------------------------------------------------- +// Launch ingestion +// --------------------------------------------------------------------------- + +void liq::regliqpool(sysio::slug_name chain_code, sysio::slug_name token_code, symbol pair_symbol, + uint64_t initial_chain_amount, uint64_t initial_wire_amount, int32_t fee, + int64_t locked_shares, uint32_t conversion_horizon_sec, uint32_t depth_cap_bps, + int64_t clip_floor) { + require_priv_caller(); + check(is_bootstrap_window(), "regliqpool is bootstrap-window only"); + const auto st = stat_by_token(token_code); + check(st.has_value(), "token_code has no shadow symbol"); + check(st->chain_code == chain_code, "token_code belongs to another chain"); + check(st->pair_symbol == symbol_code{}, "the shadow already has a yield pool"); + check(pair_symbol.is_valid(), "invalid pair symbol"); + check(initial_chain_amount > 0 && initial_wire_amount > 0, "both seeds must be positive"); + check(initial_chain_amount <= static_cast(asset::max_amount) && + initial_wire_amount <= static_cast(asset::max_amount), "seed exceeds the asset range"); + + const symbol sym = st->supply.symbol; + const asset shadow{ static_cast(initial_chain_amount), sym }; + const asset wire { static_cast(initial_wire_amount), WIRE_SYM }; + const extended_symbol shadow_symbol{ sym, get_self() }; + const extended_symbol wire_symbol { WIRE_SYM, TOKEN_ACCOUNT }; + + // The LCO liq is protocol-owned and already in outpost custody: its shadow is + // minted to sysio, which seeds the pool with it and holds the pool's shares. + check(mint(sym.code(), initial_chain_amount), "supply exceeds the asset range"); + adjust_account(SYSTEM_ACCOUNT, shadow, ram_payer); + stats statstable(get_self()); + statstable.modify(ram_payer, symbol_key{ sym.code().raw() }, [&](currency_stats& s) { + s.pair_symbol = pair_symbol.code(); + }); + + const std::vector sysio_billed{ payer_of(SYSTEM_ACCOUNT), active_of(SYSTEM_ACCOUNT) }; + action(sysio_billed, SWAP_ACCOUNT, "openext"_n, + std::make_tuple(SYSTEM_ACCOUNT, SYSTEM_ACCOUNT, shadow_symbol)).send(); + action(sysio_billed, SWAP_ACCOUNT, "openext"_n, + std::make_tuple(SYSTEM_ACCOUNT, SYSTEM_ACCOUNT, wire_symbol)).send(); + // The shadow has no pair yet, so its seed carries the swap's own authority. + action(std::vector{ active_of(SYSTEM_ACCOUNT), active_of(SWAP_ACCOUNT) }, + get_self(), "transfer"_n, + std::make_tuple(SYSTEM_ACCOUNT, SWAP_ACCOUNT, shadow, std::string{})).send(); + // The WIRE side is the T5 dex earmark, drained from the treasury. + action(active_of(SYSTEM_ACCOUNT), TOKEN_ACCOUNT, "transfer"_n, + std::make_tuple(SYSTEM_ACCOUNT, SWAP_ACCOUNT, wire, std::string{})).send(); + action(std::vector{ payer_of(SYSTEM_ACCOUNT), active_of(SYSTEM_ACCOUNT), active_of(SWAP_ACCOUNT) }, + SWAP_ACCOUNT, "inittoken"_n, + std::make_tuple(SYSTEM_ACCOUNT, pair_symbol, extended_asset{ shadow, get_self() }, + extended_asset{ wire, TOKEN_ACCOUNT }, fee, SYSTEM_ACCOUNT, + asset{ locked_shares, pair_symbol }, std::optional{ shadow_symbol })).send(); + action(active_of(SYSTEM_ACCOUNT), SWAP_ACCOUNT, "setyield"_n, + std::make_tuple(pair_symbol.code(), conversion_horizon_sec, depth_cap_bps, clip_floor)).send(); +} + +void liq::importsynd(sysio::slug_name chain_code, sysio::slug_name token_code, std::vector credits) { + require_priv_caller(); + check(is_bootstrap_window(), "importsynd is bootstrap-window only"); + liqconfig_t config(get_self()); + check(!config.get_or_default(liq_config{}).import_complete, "import already finalized"); + const auto st = stat_by_token(token_code); + check(st.has_value(), "token_code has no shadow symbol"); + check(st->chain_code == chain_code, "token_code belongs to another chain"); + const ChainKind kind = kind_of_chain(chain_code); + const symbol_code sym = st->supply.symbol.code(); + + for (const auto& credit : credits) { + check(pubkey_fits(kind, credit.pubkey), "pubkey does not fit the chain family"); + if (credit.amount == 0) continue; + check(mint(sym, credit.amount), "supply exceeds the asset range"); + credit_by_pubkey(sym, kind, credit.pubkey, credit.amount); + } +} + +void liq::importdone() { + require_priv_caller(); + liqconfig_t config(get_self()); + liq_config cfg = config.get_or_default(liq_config{}); + check(!cfg.import_complete, "import already finalized"); + cfg.import_complete = true; + config.set(cfg, ram_payer); +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +liq::currency_stats liq::stat_of(symbol_code sym) const { + stats statstable(get_self()); + return statstable.get(symbol_key{ sym.raw() }, "shadow symbol does not exist"); +} + +std::optional liq::stat_by_token(sysio::slug_name token_code) const { + stats statstable(get_self()); + auto by_token = statstable.get_index<"bytoken"_n>(); + auto it = by_token.find(token_code.value); + if (it == by_token.end()) return std::nullopt; + return *it; +} + +ChainKind liq::kind_of_chain(sysio::slug_name chain_code) const { + sysio::chains::chains_t chains(CHAINS_ACCOUNT); + const auto row = chains.try_get(sysio::chains::chain_key{ chain_code }); + check(row.has_value() && row->active && !row->is_depot, "chain_code is not an active outpost"); + return row->kind; +} + +u128 liq::current_index(symbol_code sym) const { + yieldidxs indexes(get_self()); + const auto idx = indexes.try_get(symbol_key{ sym.raw() }); + return idx ? idx->index : 0; +} + +int64_t liq::wire_balance() const { + token::accounts holdings(TOKEN_ACCOUNT, get_self().value); + const auto row = holdings.try_get(token::acct_key{ WIRE_SYM.code().raw() }); + return row ? row->balance.amount : 0; +} + +void liq::settle_and_adjust(opp::shadow::account& row, u128 index, const asset& delta) { + row.owed_wire = opp::shadow::owed(row, index); // settle before mutate + row.index_checkpoint = index; + row.balance += delta; + check(row.balance.amount >= 0, "overdrawn balance"); +} + +void liq::adjust_account(name owner, const asset& delta, name payer) { + accounts holdings(get_self(), owner.value); + const symbol_key key{ delta.symbol.code().raw() }; + const u128 index = current_index(delta.symbol.code()); + const auto row = holdings.try_get(key); + if (!row) { + check(delta.amount >= 0, "no balance object found"); + holdings.emplace(payer, key, opp::shadow::account{ delta, index, 0 }); + return; + } + opp::shadow::account updated = *row; + settle_and_adjust(updated, index, delta); + holdings.modify(payer, key, [&](opp::shadow::account& a) { a = updated; }); +} + +void liq::adjust_parked(const parked_key& key, ChainKind chain_kind, const std::vector& pubkey, + const asset& delta) { + parkeds parked(get_self()); + const u128 index = current_index(delta.symbol.code()); + const auto row = parked.try_get(key); + if (!row) { + check(delta.amount >= 0, "no parked row found"); + parked.emplace(ram_payer, key, parked_row{ chain_kind, pubkey, opp::shadow::account{ delta, index, 0 } }); + return; + } + parked_row updated = *row; + settle_and_adjust(updated.holding, index, delta); + parked.modify(ram_payer, key, [&](parked_row& p) { p = updated; }); +} + +uint64_t liq::headroom(const currency_stats& st) const { + liqpendings pendings(get_self()); + const auto pending = pendings.try_get(symbol_key{ st.supply.symbol.code().raw() }); + const int64_t reserved = pending ? pending->quantity.amount : 0; + const int64_t room = asset::max_amount - st.supply.amount - reserved; + return room > 0 ? static_cast(room) : 0; +} + +bool liq::mint(symbol_code sym, uint64_t quantity) { + stats statstable(get_self()); + const symbol_key key{ sym.raw() }; + const currency_stats st = statstable.get(key, "shadow symbol does not exist"); + if (quantity > headroom(st)) return false; + statstable.modify(ram_payer, key, [&](currency_stats& s) { s.supply.amount += static_cast(quantity); }); + return true; +} + +void liq::distribute(symbol_code sym, uint64_t quantity) { + const currency_stats st = stat_of(sym); + check(st.supply.amount > 0, "no holders to distribute to"); + yieldidxs indexes(get_self()); + const symbol_key key{ sym.raw() }; + opp::shadow::yield_index idx = indexes.try_get(key).value_or(opp::shadow::yield_index{}); + const u128 total = static_cast(quantity) * opp::shadow::YIELD_INDEX_SCALE + idx.carry; + idx.index += total / static_cast(st.supply.amount); + idx.carry = static_cast(total % static_cast(st.supply.amount)); + idx.pot = opp::safe::add_sat_u64(idx.pot, quantity); + indexes.upsert(ram_payer, key, idx); +} + +bool liq::admit_sequence(sysio::slug_name chain_code, uint64_t sequence, uint64_t epoch) { + liqcursors cursors(get_self()); + const cursor_key key{ chain_code.value }; + const auto cursor = cursors.try_get(key); + if (cursor && sequence <= cursor->last_sequence) return false; + cursors.upsert(ram_payer, key, liq_cursor{ chain_code, sequence, epoch }, [&](liq_cursor& c) { + c.last_sequence = sequence; + if (epoch != 0) c.last_epoch = epoch; + }); + return true; +} + +void liq::deliver_parked(name account, ChainKind chain_kind, const std::vector& pubkey) { + stats statstable(get_self()); + parkeds parked(get_self()); + for (auto it = statstable.begin(); it != statstable.end(); ++it) { + const symbol_code sym = it->supply.symbol.code(); + const parked_key key = parked_key_of(sym, chain_kind, pubkey); + const auto row = parked.try_get(key); + if (!row) continue; + // Settle the parked row at the current index, then move both its balance + // and its banked WIRE into the account's row, itself settled at that index. + const u128 index = current_index(sym); + const uint64_t banked = opp::shadow::owed(row->holding, index); + const asset balance = row->holding.balance; + parked.erase(key); + adjust_account(account, balance, ram_payer); + if (banked == 0) continue; + accounts holdings(get_self(), account.value); + holdings.modify(ram_payer, symbol_key{ sym.raw() }, [&](opp::shadow::account& a) { + a.owed_wire = opp::safe::add_sat_u64(a.owed_wire, banked); + }); + } +} + +void liq::credit_by_pubkey(symbol_code sym, ChainKind chain_kind, const std::vector& pubkey, uint64_t amount) { + const asset quantity{ static_cast(amount), stat_of(sym).supply.symbol }; + const name account = linked_account(chain_kind, pubkey); + if (account != name{}) { + adjust_account(account, quantity, ram_payer); + } else { + adjust_parked(parked_key_of(sym, chain_kind, pubkey), chain_kind, pubkey, quantity); + } +} + +} // namespace sysio diff --git a/contracts/sysio.liq/sysio.liq.abi b/contracts/sysio.liq/sysio.liq.abi new file mode 100644 index 0000000000..5057ac750e --- /dev/null +++ b/contracts/sysio.liq/sysio.liq.abi @@ -0,0 +1,747 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "account", + "base": "", + "fields": [ + { + "name": "balance", + "type": "asset" + }, + { + "name": "index_checkpoint", + "type": "uint128" + }, + { + "name": "owed_wire", + "type": "uint64" + } + ] + }, + { + "name": "addkicker", + "base": "", + "fields": [ + { + "name": "sym", + "type": "symbol_code" + }, + { + "name": "base_balance", + "type": "int64" + }, + { + "name": "requested", + "type": "uint64" + } + ] + }, + { + "name": "addyield", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + }, + { + "name": "target", + "type": "symbol_code" + } + ] + }, + { + "name": "claim", + "base": "", + "fields": [ + { + "name": "holder", + "type": "name" + }, + { + "name": "sym", + "type": "symbol_code" + } + ] + }, + { + "name": "close", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "symbol", + "type": "symbol" + } + ] + }, + { + "name": "create", + "base": "", + "fields": [ + { + "name": "sym", + "type": "symbol" + }, + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + } + ] + }, + { + "name": "currency_stats", + "base": "", + "fields": [ + { + "name": "supply", + "type": "asset" + }, + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "pair_symbol", + "type": "symbol_code" + } + ] + }, + { + "name": "cursor_key", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "uint64" + } + ] + }, + { + "name": "desyndicate", + "base": "", + "fields": [ + { + "name": "holder", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + } + ] + }, + { + "name": "import_credit", + "base": "", + "fields": [ + { + "name": "pubkey", + "type": "bytes" + }, + { + "name": "amount", + "type": "uint64" + } + ] + }, + { + "name": "importdone", + "base": "", + "fields": [] + }, + { + "name": "importsynd", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "credits", + "type": "import_credit[]" + } + ] + }, + { + "name": "linkswept", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "chain_kind", + "type": "ChainKind" + }, + { + "name": "pubkey", + "type": "bytes" + } + ] + }, + { + "name": "liq_config", + "base": "", + "fields": [ + { + "name": "kicker_bps", + "type": "uint32" + }, + { + "name": "import_complete", + "type": "bool" + } + ] + }, + { + "name": "liq_counters", + "base": "", + "fields": [ + { + "name": "next_request_id", + "type": "uint64" + } + ] + }, + { + "name": "liq_cursor", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "last_sequence", + "type": "uint64" + }, + { + "name": "last_epoch", + "type": "uint64" + } + ] + }, + { + "name": "mintsynd", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "sequence", + "type": "uint64" + }, + { + "name": "account", + "type": "name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "amount", + "type": "uint64" + } + ] + }, + { + "name": "mintyield", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "sequence", + "type": "uint64" + }, + { + "name": "epoch", + "type": "uint64" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "amount", + "type": "uint64" + } + ] + }, + { + "name": "open", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "symbol", + "type": "symbol" + }, + { + "name": "ram_payer", + "type": "name" + } + ] + }, + { + "name": "park", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "sequence", + "type": "uint64" + }, + { + "name": "chain_kind", + "type": "ChainKind" + }, + { + "name": "pubkey", + "type": "bytes" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "amount", + "type": "uint64" + } + ] + }, + { + "name": "parked_key", + "base": "", + "fields": [ + { + "name": "symbol_code", + "type": "uint64" + }, + { + "name": "chain_kind", + "type": "uint64" + }, + { + "name": "pubkey", + "type": "bytes" + } + ] + }, + { + "name": "parked_row", + "base": "", + "fields": [ + { + "name": "chain_kind", + "type": "ChainKind" + }, + { + "name": "pubkey", + "type": "bytes" + }, + { + "name": "holding", + "type": "account" + } + ] + }, + { + "name": "pending_yield", + "base": "", + "fields": [ + { + "name": "quantity", + "type": "asset" + } + ] + }, + { + "name": "queueyield", + "base": "", + "fields": [ + { + "name": "sym", + "type": "symbol_code" + } + ] + }, + { + "name": "recredit", + "base": "", + "fields": [ + { + "name": "holder", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + } + ] + }, + { + "name": "regliqpool", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "token_code", + "type": "slug_name" + }, + { + "name": "pair_symbol", + "type": "symbol" + }, + { + "name": "initial_chain_amount", + "type": "uint64" + }, + { + "name": "initial_wire_amount", + "type": "uint64" + }, + { + "name": "fee", + "type": "int32" + }, + { + "name": "locked_shares", + "type": "int64" + }, + { + "name": "conversion_horizon_sec", + "type": "uint32" + }, + { + "name": "depth_cap_bps", + "type": "uint32" + }, + { + "name": "clip_floor", + "type": "int64" + } + ] + }, + { + "name": "setkicker", + "base": "", + "fields": [ + { + "name": "bps", + "type": "uint32" + } + ] + }, + { + "name": "slug_name", + "base": "", + "fields": [ + { + "name": "value", + "type": "uint64" + } + ] + }, + { + "name": "sweep", + "base": "", + "fields": [ + { + "name": "account", + "type": "name" + }, + { + "name": "chain_kind", + "type": "ChainKind" + } + ] + }, + { + "name": "symbol_key", + "base": "", + "fields": [ + { + "name": "symbol_code", + "type": "uint64" + } + ] + }, + { + "name": "transfer", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "to", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + }, + { + "name": "memo", + "type": "string" + } + ] + }, + { + "name": "yield_index", + "base": "", + "fields": [ + { + "name": "index", + "type": "uint128" + }, + { + "name": "pot", + "type": "uint64" + }, + { + "name": "carry", + "type": "uint64" + } + ] + } + ], + "actions": [ + { + "name": "addkicker", + "type": "addkicker", + "ricardian_contract": "" + }, + { + "name": "addyield", + "type": "addyield", + "ricardian_contract": "" + }, + { + "name": "claim", + "type": "claim", + "ricardian_contract": "" + }, + { + "name": "close", + "type": "close", + "ricardian_contract": "" + }, + { + "name": "create", + "type": "create", + "ricardian_contract": "" + }, + { + "name": "desyndicate", + "type": "desyndicate", + "ricardian_contract": "" + }, + { + "name": "importdone", + "type": "importdone", + "ricardian_contract": "" + }, + { + "name": "importsynd", + "type": "importsynd", + "ricardian_contract": "" + }, + { + "name": "linkswept", + "type": "linkswept", + "ricardian_contract": "" + }, + { + "name": "mintsynd", + "type": "mintsynd", + "ricardian_contract": "" + }, + { + "name": "mintyield", + "type": "mintyield", + "ricardian_contract": "" + }, + { + "name": "open", + "type": "open", + "ricardian_contract": "" + }, + { + "name": "park", + "type": "park", + "ricardian_contract": "" + }, + { + "name": "queueyield", + "type": "queueyield", + "ricardian_contract": "" + }, + { + "name": "recredit", + "type": "recredit", + "ricardian_contract": "" + }, + { + "name": "regliqpool", + "type": "regliqpool", + "ricardian_contract": "" + }, + { + "name": "setkicker", + "type": "setkicker", + "ricardian_contract": "" + }, + { + "name": "sweep", + "type": "sweep", + "ricardian_contract": "" + }, + { + "name": "transfer", + "type": "transfer", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "accounts", + "type": "account", + "index_type": "i64", + "key_names": ["scope","symbol_code"], + "key_types": ["name","uint64"], + "table_id": 25660 + }, + { + "name": "liqconfig", + "type": "liq_config", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 57729 + }, + { + "name": "liqcounters", + "type": "liq_counters", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 41070 + }, + { + "name": "liqcursors", + "type": "liq_cursor", + "index_type": "i64", + "key_names": ["chain_code"], + "key_types": ["uint64"], + "table_id": 19002 + }, + { + "name": "liqpending", + "type": "pending_yield", + "index_type": "i64", + "key_names": ["symbol_code"], + "key_types": ["uint64"], + "table_id": 38824 + }, + { + "name": "parked", + "type": "parked_row", + "index_type": "i64", + "key_names": ["symbol_code","chain_kind","pubkey"], + "key_types": ["uint64","uint64","bytes"], + "table_id": 38854 + }, + { + "name": "stat", + "type": "currency_stats", + "index_type": "i64", + "key_names": ["symbol_code"], + "key_types": ["uint64"], + "table_id": 4264, + "secondary_indexes": [ + { + "name": "bytoken", + "key_type": "uint64", + "table_id": 11724 + } + ] + }, + { + "name": "yieldidx", + "type": "yield_index", + "index_type": "i64", + "key_names": ["symbol_code"], + "key_types": ["uint64"], + "table_id": 3287 + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [], + "enums": [ + { + "name": "ChainKind", + "type": "int32", + "values": [ + { + "name": "CHAIN_KIND_UNKNOWN", + "value": 0 + }, + { + "name": "CHAIN_KIND_WIRE", + "value": 1 + }, + { + "name": "CHAIN_KIND_EVM", + "value": 2 + }, + { + "name": "CHAIN_KIND_SVM", + "value": 3 + } + ] + } + ] +} \ No newline at end of file diff --git a/contracts/sysio.liq/sysio.liq.wasm b/contracts/sysio.liq/sysio.liq.wasm new file mode 100755 index 0000000000..95810708d4 Binary files /dev/null and b/contracts/sysio.liq/sysio.liq.wasm differ diff --git a/contracts/sysio.msgch/README.md b/contracts/sysio.msgch/README.md index 8ed5c9dbc7..368ea8e699 100644 --- a/contracts/sysio.msgch/README.md +++ b/contracts/sysio.msgch/README.md @@ -28,7 +28,7 @@ Inbound/outbound OPP message chain management and consensus tracking contract. | `deliver` | operator | Batch operator delivers a chain with hash + messages | | `evalcons` | `sysio.msgch` | Evaluate consensus on a chain request | | `processmsg` | `sysio.msgch` | Process a READY message (unpack, route attestations) | -| `queueout` | `sysio.msgch` | Queue an outbound message to an outpost | +| `queueout` | `sysio.epoch`, `sysio.opreg`, `sysio.uwrit`, `sysio.reserv`, `sysio.liq`, or `sysio.msgch` | Queue an outbound message to an outpost | | `buildenv` | `sysio.msgch` | Build outbound envelope from queued messages | ## Dependencies @@ -36,3 +36,9 @@ Inbound/outbound OPP message chain management and consensus tracking contract. - Reads epoch state from `sysio.epoch` - Notifies `sysio.chalg` on consensus failure - Routes attestations to `sysio.epoch`, `sysio.uwrit`, `sysio.chalg` +- Routes `SYNDICATE_LIQ` and `LIQ_YIELD` to `sysio.liq` (`mintsynd` for an AuthX-linked + user, `park` for an unlinked pubkey, `mintyield` for a yield report) once the payload's + token is an active `TOKEN_KIND_LIQ` row on `sysio.tokens` bound to the proven outpost + and, for a syndication, the user's key family is the outpost's own. + Every refusal is a logged drop, never an abort. `DESYNDICATE_LIQ` is outbound only: + `sysio.liq::desyndicate` queues it through `queueout`. diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index 8475eb03e9..96d7a8f854 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -2,6 +2,7 @@ #include #include #include +#include // active-liq-token gate on the SYNDICATE_LIQ / LIQ_YIELD paths #include // dispute trigger + open-dispute gate (disputes table) #include // operator-status delivery gate (operators table) #include // authoritative Tier-1 electorate preflight @@ -38,8 +39,10 @@ constexpr auto UWRIT_ACCOUNT = "sysio.uwrit"_n; constexpr auto CHALG_ACCOUNT = "sysio.chalg"_n; constexpr auto AUTHEX_ACCOUNT = "sysio.authex"_n; constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; +constexpr auto TOKENS_ACCOUNT = "sysio.tokens"_n; constexpr auto RESERV_ACCOUNT = "sysio.reserv"_n; constexpr auto ROA_ACCOUNT = "sysio.roa"_n; +constexpr auto LIQ_ACCOUNT = "sysio.liq"_n; // System-owned rows bill to the sysio RAM pool, not this contract account (privileged-contract // model, as sysio.token uses): the account stays finite at code+abi size; growth draws from the pool. @@ -246,50 +249,16 @@ void write_envelope_log(name self, } } -/// Build a `sysio::public_key` variant from the raw bytes carried in -/// `op_address.address` plus the originating chain. Inverse of -/// opreg.cpp's `pubkey_to_bytes`. Returns an empty (default-constructed -/// K1) variant for malformed input or unsupported chain kinds — the -/// downstream `bypubkey` lookup then misses and the dispatch drops. -sysio::public_key public_key_from_op_address(ChainKind chain, - const std::vector& bytes) { - sysio::public_key pk; - switch (chain) { - case ChainKind::CHAIN_KIND_WIRE: { // K1 — variant index 0 - if (bytes.size() != 33) return pk; - sysio::ecc_public_key arr; - std::copy(bytes.begin(), bytes.end(), arr.begin()); - pk.emplace<0>(arr); - return pk; - } - case ChainKind::CHAIN_KIND_EVM: { // EM — variant index 3 - if (bytes.size() != 33) return pk; - sysio::ecc_public_key arr; - std::copy(bytes.begin(), bytes.end(), arr.begin()); - pk.emplace<3>(arr); - return pk; - } - case ChainKind::CHAIN_KIND_SVM: { // ED — variant index 4 - if (bytes.size() != 32) return pk; - sysio::ed_public_key arr; - std::copy(bytes.begin(), bytes.end(), - reinterpret_cast(arr.data())); - pk.emplace<4>(arr); - return pk; - } - default: - return pk; - } -} - /// Resolve `op_address` (chain-kind + raw pubkey bytes) to the operator's /// WIRE account name via `sysio.authex::links`'s `bypubkey` index. Returns /// `name{}` (zero) on miss — caller treats that as "operator not linked, /// drop the attestation". name resolve_account_from_op_address(const opp::types::ChainAddress& op_address) { - sysio::public_key pk = public_key_from_op_address(op_address.kind, - op_address.address); - auto digest = sysio::pubkey_to_checksum256(pk); + // No key a link could hold (unsupported kind, wrong width) is a miss, never a hash: + // `pubkey_to_checksum256` aborts on anything but an EM / ED variant. + const auto pk = public_key_from_op_address(op_address.kind, op_address.address); + if (!pk) return name{}; + auto digest = sysio::pubkey_to_checksum256(*pk); sysio::authex::links_t links(AUTHEX_ACCOUNT); auto by_pubkey = links.get_index<"bypubkey"_n>(); auto it = by_pubkey.find(digest); @@ -333,6 +302,20 @@ name resolve_account_from_op_address(const opp::types::ChainAddress& op_address) return false; } +/// The syndicating user's key family must be the proven outpost's own: an outpost of family F +/// verifies and emits F-family keys only, so a key of another family is a forgery whatever it +/// resolves to. `sysio.liq::park` refuses the other family for an unlinked key; this refuses it +/// for every key, before the AuthX lookup could credit a linked one. Print + false, never a +/// throw, for the reason `source_chain_binding_ok` gives. +[[nodiscard]] bool user_kind_matches_chain(uint64_t proven_chain_code, ChainKind user_kind, const char* path) { + sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); + const auto row = chains_tbl.try_get(sysio::chains::chain_key{sysio::slug_name{proven_chain_code}}); + if (row && row->kind == user_kind) return true; + sysio::print("msgch::", path, ": DROP attestation -- user kind ", std::string(magic_enum::enum_name(user_kind)), + " is not the proven source outpost's chain family\n"); + return false; +} + /// Reinterpret an exactly-32-byte protobuf `bytes` field as a checksum256. Returns std::nullopt /// for any other length; chain and header verification treat a malformed hash as a mismatch, /// never as a match or a wildcard. @@ -607,6 +590,74 @@ void dispatch_underwrite_commit(name self, const std::vector& data, uint64 ).send(); } +/// True iff `(chain_code, token_code)` is an active TOKEN_KIND_LIQ registry row bound to an +/// active chaintokens row: the only tokens the shadow ledger mints against. +bool is_active_liq_token(sysio::slug_name chain_code, sysio::slug_name token_code) { + sysio::tokens::tokens_t tokens(TOKENS_ACCOUNT); + const auto token = tokens.try_get(sysio::tokens::token_key{ token_code }); + if (!token || !token->active || token->kind != opp::types::TOKEN_KIND_LIQ) return false; + sysio::tokens::chaintokens_t bindings(TOKENS_ACCOUNT); + const auto binding = bindings.try_get(sysio::tokens::chain_token_key{ chain_code, token_code }); + return binding && binding->active; +} + +/// SYNDICATE_LIQ: a user syndicated liq on the outpost. The pubkey resolves through authex; a +/// linked user is credited by `sysio.liq::mintsynd`, an unlinked one parked by `sysio.liq::park` +/// until the link exists. sysio.liq re-checks the token, the amount and the sequence and drops +/// (never aborts) what it cannot credit. Never-throw: every refusal here is a print + return. +void dispatch_syndicate_liq(name self, const std::vector& data, uint64_t chain_code) { + opp::attestations::SyndicateLIQ synd; + { + auto in = zpp::bits::in{std::span{data.data(), data.size()}, zpp::bits::no_size{}}; + if (in(synd) != zpp::bits::errc{}) return; + } + if (!source_chain_binding_ok(chain_code, synd.chain_code, "dispatch_syndicate_liq")) return; + if (!user_kind_matches_chain(chain_code, synd.user.kind, "dispatch_syndicate_liq")) return; + const std::optional amount = + sysio::opp::safe::to_depot_amount(static_cast(synd.amount.amount)); + if (!amount) return; + const sysio::slug_name chain_code_slug{chain_code}; + const sysio::slug_name token_code{synd.amount.token_code}; + if (!is_active_liq_token(chain_code_slug, token_code)) { + sysio::print("msgch::dispatch_syndicate_liq: DROP attestation -- token is not an active liq token\n"); + return; + } + const uint64_t sequence = synd.sequence; + const name account = resolve_account_from_op_address(synd.user); + if (account != name{}) { + action(permission_level{self, "active"_n}, LIQ_ACCOUNT, "mintsynd"_n, + std::make_tuple(chain_code_slug, sequence, account, token_code, *amount)).send(); + } else { + action(permission_level{self, "active"_n}, LIQ_ACCOUNT, "park"_n, + std::make_tuple(chain_code_slug, sequence, synd.user.kind, synd.user.address, token_code, *amount)).send(); + } +} + +/// LIQ_YIELD: the outpost claimed yield for its syndicated pool since its last report. It lands +/// in sysio.liq's pending balance (`mintyield`); the permissionless `queueyield` hands it to the +/// swap later, so nothing that can throw sits on this path. +void dispatch_liq_yield(name self, const std::vector& data, uint64_t chain_code) { + opp::attestations::LIQYield report; + { + auto in = zpp::bits::in{std::span{data.data(), data.size()}, zpp::bits::no_size{}}; + if (in(report) != zpp::bits::errc{}) return; + } + if (!source_chain_binding_ok(chain_code, report.chain_code, "dispatch_liq_yield")) return; + const std::optional amount = + sysio::opp::safe::to_depot_amount(static_cast(report.amount.amount)); + if (!amount) return; + const sysio::slug_name chain_code_slug{chain_code}; + const sysio::slug_name token_code{report.amount.token_code}; + if (!is_active_liq_token(chain_code_slug, token_code)) { + sysio::print("msgch::dispatch_liq_yield: DROP attestation -- token is not an active liq token\n"); + return; + } + const uint64_t sequence = report.sequence; + const uint64_t epoch = report.epoch; + action(permission_level{self, "active"_n}, LIQ_ACCOUNT, "mintyield"_n, + std::make_tuple(chain_code_slug, sequence, epoch, token_code, *amount)).send(); +} + /// Dispatch a RESERVE_CREATE attestation to sysio.reserv::oncrtreserve. /// Inserts a PENDING reserve row on the depot. Per /// `feedback_opp_handlers_never_throw`, decode failures silently no-op. @@ -903,6 +954,19 @@ void dispatch_attestation(name self, uint64_t attestation_id, } break; + case AttestationType::ATTESTATION_TYPE_SYNDICATE_LIQ: + dispatch_syndicate_liq(self, data, chain_code); + break; + + case AttestationType::ATTESTATION_TYPE_LIQ_YIELD: + dispatch_liq_yield(self, data, chain_code); + break; + + case AttestationType::ATTESTATION_TYPE_DESYNDICATE_LIQ: + // Depot -> outpost outbound-only (sysio.liq::desyndicate queues it). An + // outpost echoing one inbound is a benign no-op. + break; + case AttestationType::ATTESTATION_TYPE_RESERVE_CREATE: // Outpost-initiated reserve creation. Insert a PENDING row on // `sysio.reserv` awaiting a depot-side `matchreserve` call. The @@ -1719,10 +1783,10 @@ void msgch::queueout(uint64_t chain_code, // call it directly and inject a forged attestation that buildenv() then packs into the // depot's group-signed outbound envelope — a forged SWAP_REMIT / WITHDRAW_REMIT / SLASH that // the outpost authenticates by the group signature and executes. The intended callers - // (sysio.epoch / .opreg / .uwrit / .reserv) each send under their own {self, active} authority; - // get_self() permits msgch's own inline use and governance. + // (sysio.epoch / .opreg / .uwrit / .reserv / .liq) each send under their own {self, active} + // authority; get_self() permits msgch's own inline use and governance. check(has_auth(EPOCH_ACCOUNT) || has_auth(OPREG_ACCOUNT) || has_auth(UWRIT_ACCOUNT) || - has_auth(RESERV_ACCOUNT) || has_auth(get_self()), + has_auth(RESERV_ACCOUNT) || has_auth(LIQ_ACCOUNT) || has_auth(get_self()), "queueout: caller not authorized to queue outbound attestations"); // The chains registry is the ONLY authority on which chain codes exist. diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index 22043a782e..badd5b6d20 100755 Binary files a/contracts/sysio.msgch/sysio.msgch.wasm and b/contracts/sysio.msgch/sysio.msgch.wasm differ diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/amm_math.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/amm_math.hpp index 2116ba91e2..3e6fe58420 100644 --- a/contracts/sysio.opp.common/include/sysio.opp.common/amm_math.hpp +++ b/contracts/sysio.opp.common/include/sysio.opp.common/amm_math.hpp @@ -169,6 +169,59 @@ inline uint64_t wire_to_token(uint64_t reserve_wire_amount, amount_wire); } +/// `floor(sqrt(n))` by Newton's method on integers: consensus-safe where the +/// floating `sqrt` is not (its rounding is platform-dependent). Converges from +/// an overestimate, so the loop ends the first time it stops decreasing. +inline u128 isqrt(u128 n) { + if (n < 2) return n; + // Initial estimate 2^ceil(bits/2) >= sqrt(n). + int bits = 0; + for (u128 t = n; t != 0; t >>= 1) ++bits; + u128 x = static_cast(1) << ((bits + 1) / 2); + for (;;) { + const u128 y = (x + n / x) >> 1; + if (y >= x) return x; + x = y; + } +} + +/// Shares minted to seed a two-sided pool: the geometric mean `floor(sqrt(x*y))` +/// of the two deposits. It scales linearly with a proportional deposit, so the +/// first depositor cannot price the unit by seeding lopsided. +inline uint64_t geometric_mean(uint64_t x, uint64_t y) { + return static_cast(isqrt(static_cast(x) * y)); +} + +/// What a number of pool shares is worth on one side of a pool, once the pool +/// already has a supply: `shares * pool_balance / supply`, the proportional +/// slice. The two forms below differ ONLY in rounding, and both round the +/// pool's way, exactly as `out_given_in` floors its output for the same reason. +/// +/// A pool share is priced against every side, so a caller mints or burns by +/// calling these once per side with the same `shares` and `supply`. +/// +/// **The result is `u128` because it genuinely does not fit 64 bits**: the +/// product of two 62-bit balances over a supply of 1 reaches ~2^124. Bounding it +/// against whatever the caller's own amount type allows is the CALLER's job, and +/// must happen before any fee is added on top. Degenerate input returns 0. + +/// Amount of one pool side a provider must put IN to mint `shares`: the +/// proportional slice rounded UP, so minting never shorts the pool. +inline u128 in_given_shares(uint64_t pool_balance, uint64_t supply, uint64_t shares) { + if (pool_balance == 0 || supply == 0) return 0; + const u128 prod = static_cast(shares) * pool_balance; + return (prod + supply - 1) / supply; // ceil; prod peaks ~2^124, no carry +} + +/// Amount of one pool side a provider takes OUT by burning `shares`: the same +/// proportional slice rounded DOWN, so burning never over-pays. The remainder +/// stays with the pool, which is what makes a mint-then-burn round trip unable +/// to profit. +inline u128 out_given_shares(uint64_t pool_balance, uint64_t supply, uint64_t shares) { + if (pool_balance == 0 || supply == 0) return 0; + return (static_cast(shares) * pool_balance) / supply; // floor +} + /// Basis-points denominator (10000 = 100%). inline constexpr uint32_t BPS_TOTAL = 10000; diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/shadow_yield.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/shadow_yield.hpp new file mode 100644 index 0000000000..afd863e125 --- /dev/null +++ b/contracts/sysio.opp.common/include/sysio.opp.common/shadow_yield.hpp @@ -0,0 +1,92 @@ +#pragma once +/** + * @file shadow_yield.hpp + * @brief The shadow token's yield-distribution state, as its holders read it. + * + * A shadow token (shadow-liqETH, shadow-liqSOL) pays WIRE yield to its holders + * through one cumulative index per symbol: WIRE earned per shadow unit, scaled + * by YIELD_INDEX_SCALE. Every holder row carries the index value at its last + * settle and the WIRE banked so far; what a holder is owed at any moment is + * + * owed_wire + balance * (index - index_checkpoint) / YIELD_INDEX_SCALE + * + * with the product taken in 128 bits and the division floored, exactly as the + * token's own settle computes it. A contract that holds shadow (sysio.swap for + * its pools) can therefore compute its payout from public state BEFORE calling + * `claim`, and assert the exact amount when the transfer lands, instead of + * inferring it from a balance change. + * + * The index and the checkpoints are 128-bit: an add moves the index by + * yield * YIELD_INDEX_SCALE / supply, which passes 2^64 on one large add to a + * thinly held symbol. The ABI carries the fields as the builtin `uint128`. + * + * This header is the contract between the token and its holders: the table + * names, the row layouts, the scale, and the two typed actions a holder calls. + * Both sides compile against it. + */ + +#include +#include +#include +#include + +namespace sysio::opp::shadow { + +/// The index's width. `uint128_t` is the CDT builtin the ABI generator emits as `uint128`. +using u128 = uint128_t; + +/// Fixed-point scale of the cumulative index (WIRE per shadow unit). +inline constexpr uint64_t YIELD_INDEX_SCALE = 1'000'000'000'000; + +/// The holder balance table: scope = holder, key = symbol code. sysio.token's +/// layout plus the two accrual fields. +inline constexpr name ACCOUNTS_TABLE = "accounts"_n; +/// The per-symbol index: scope = the token contract, key = symbol code. +inline constexpr name YIELD_INDEX_TABLE = "yieldidx"_n; + +/// `claim(name holder, symbol_code sym)`: settle `holder`'s row for `sym`, pay it +/// what `owed` says from the token's own WIRE, and zero the row. Holder's authority. +inline constexpr name CLAIM_ACTION = "claim"_n; +/// `addyield(name from, asset quantity, symbol_code target)`: move `quantity` WIRE +/// from `from` into `target`'s pot by inline transfer under `from`'s own authority +/// (a token contract that is not privileged therefore needs `sysio.code` on +/// `from`'s active), and advance the index by quantity / supply, carrying the +/// remainder. +inline constexpr name ADDYIELD_ACTION = "addyield"_n; + +/// Key of an `accounts` or `yieldidx` row. +struct symbol_key { + uint64_t symbol_code; + SYSLIB_SERIALIZE(symbol_key, (symbol_code)) +}; + +/// A holder's row for one shadow symbol. +struct account { + asset balance; + uint128_t index_checkpoint = 0; ///< index value at the last settle of this row + uint64_t owed_wire = 0; ///< WIRE banked by earlier settles, not yet claimed + SYSLIB_SERIALIZE(account, (balance)(index_checkpoint)(owed_wire)) +}; + +/// One shadow symbol's distribution state. +struct yield_index { + uint128_t index = 0; ///< cumulative WIRE per shadow unit, scaled by YIELD_INDEX_SCALE + uint64_t pot = 0; ///< WIRE held for holders and not yet claimed + uint64_t carry = 0; ///< WIRE received but below one index unit, carried to the next add + SYSLIB_SERIALIZE(yield_index, (index)(pot)(carry)) +}; + +/// The WIRE a holder row is owed now, at index `index`: the banked amount plus +/// the accrual since its checkpoint, floored. Pure, so the token's settle and a +/// holder's pre-claim computation are one function. The index only grows and a +/// row is never owed more than its pot holds, so either bound failing is corrupt state. +inline uint64_t owed(const account& row, u128 index) { + check( index >= row.index_checkpoint, "index precedes the row checkpoint" ); + if (row.balance.amount <= 0) return row.owed_wire; + const u128 accrued = static_cast(row.balance.amount) * (index - row.index_checkpoint) / YIELD_INDEX_SCALE; + const u128 total = static_cast(row.owed_wire) + accrued; + check( total <= static_cast(asset::max_amount), "owed yield exceeds the asset range" ); + return static_cast(total); +} + +} // namespace sysio::opp::shadow diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/twap.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/twap.hpp new file mode 100644 index 0000000000..707e90065b --- /dev/null +++ b/contracts/sysio.opp.common/include/sysio.opp.common/twap.hpp @@ -0,0 +1,91 @@ +#pragma once +/** + * @file twap.hpp + * @brief Cumulative-price accumulators for time-weighted average prices. + * + * A pool keeps, for each of its two sides, the running sum of + * `price * elapsed` over every interval during which its balances were + * unchanged: `price` is the side's spot price in Q64.64, `elapsed` the + * interval's length in microseconds. A consumer snapshots the accumulator + * `r0` at time `t0` and reads `r` at a later `t`; `(r - r0) / (t - t0)` is the + * time-weighted average price over the window. Because each interval is + * weighted by its duration, a trade that moves the spot price inside one + * block contributes nothing until time has passed at the moved price, which + * is what makes the average expensive to manipulate. + * + * The math is integer-only and self-contained (no contract intrinsics), so it + * is unit-testable on the host and deterministic on-chain. Accumulators are + * 256 bits wide: a Q64.64 price of int64 balances is below 2^126, and any + * elapsed time fits in 64 bits, so a single step is below 2^190 and the sum + * cannot wrap within the life of any chain. Consumers that store the + * accumulator in a narrower type must subtract modulo that width. + */ + +#include + +namespace sysio::opp::twap { + +/// Spelled `uint128_t` so the CDT ABI generator, which maps type names by +/// their spelling, emits the `uint128` builtin for the accumulator limbs. +using uint128_t = unsigned __int128; +using u128 = uint128_t; + +/// Fractional bits of a price. Q64.64 holds every ratio of two int64 balances +/// with a resolution of 2^-64. +inline constexpr int PRICE_FRACTION_BITS = 64; +inline constexpr u128 PRICE_ONE = static_cast(1) << PRICE_FRACTION_BITS; + +/// Mask selecting the low 64 bits of a u128. +inline constexpr u128 LOW_64_MASK = ~static_cast(0); + +/// A 256-bit unsigned cumulative price, as two little-endian 128-bit limbs. +struct cumulative_price { + uint128_t lo = 0; + uint128_t hi = 0; +}; + +/// Q64.64 spot price of one unit of the `denominator` side expressed in units +/// of the `numerator` side, rounded down. Zero when the denominator side is +/// empty (there is no price to record). +inline u128 price_fp(uint64_t numerator, uint64_t denominator) { + if (denominator == 0) return 0; + return (static_cast(numerator) << PRICE_FRACTION_BITS) / denominator; +} + +/// `acc += price * elapsed`, computed in 256 bits. +inline void accumulate(cumulative_price& acc, u128 price, uint64_t elapsed) { + // price * elapsed as (hi_part << 64) + lo_part, each partial product below 2^128. + const u128 lo_part = (price & LOW_64_MASK) * elapsed; + const u128 hi_part = (price >> 64) * elapsed; + const u128 add_lo = lo_part + (hi_part << 64); + u128 add_hi = hi_part >> 64; + if (add_lo < lo_part) ++add_hi; // carry out of the low limb of the product + acc.lo += add_lo; + acc.hi += add_hi + (acc.lo < add_lo ? 1 : 0); // carry out of the low limb of the sum +} + +/// `later - earlier` modulo 2^256. +inline cumulative_price difference(const cumulative_price& later, const cumulative_price& earlier) { + cumulative_price d; + d.lo = later.lo - earlier.lo; + d.hi = later.hi - earlier.hi - (later.lo < earlier.lo ? 1 : 0); + return d; +} + +/// `delta / elapsed` as a Q64.64 price: the time-weighted average over a window +/// whose accumulator moved by `delta` in `elapsed` microseconds. A genuine +/// delta always yields a quotient that fits, since it is a mean of prices +/// each below 2^126. Returns 0 for a zero window or a delta no window of that +/// length could have produced. +inline u128 average_price(const cumulative_price& delta, uint64_t elapsed) { + if (elapsed == 0 || delta.hi >= elapsed) return 0; + // Long division of the 256-bit delta by a 64-bit divisor, one 64-bit digit + // at a time; every partial dividend stays below 2^128. + u128 cur = (delta.hi << 64) | (delta.lo >> 64); + const u128 q1 = cur / elapsed; + cur = ((cur % elapsed) << 64) | (delta.lo & LOW_64_MASK); + const u128 q0 = cur / elapsed; + return (q1 << 64) | q0; +} + +} // namespace sysio::opp::twap diff --git a/contracts/sysio.swap/CMakeLists.txt b/contracts/sysio.swap/CMakeLists.txt new file mode 100644 index 0000000000..0293a16cc4 --- /dev/null +++ b/contracts/sysio.swap/CMakeLists.txt @@ -0,0 +1,7 @@ +bootstrap_contract(sysio.swap) +if(BUILD_SYSTEM_CONTRACTS) + add_contract(sysio.swap sysio.swap sysio.swap.cpp token_functions.cpp) + target_include_directories(sysio.swap + PUBLIC $ + $) +endif() diff --git a/contracts/sysio.swap/Commands.md b/contracts/sysio.swap/Commands.md new file mode 100644 index 0000000000..43935b8619 --- /dev/null +++ b/contracts/sysio.swap/Commands.md @@ -0,0 +1,202 @@ +**NOTE: In this example we use the PAIR PESO/EOS, which creates the evotoken EOSPESO. We assume an EOS token located in the eosio.token contract as usual, configured as the system token (every pair's second leg), and a PESO token located in the contract pesocontract as the pair's first leg. You would need to replace these variables depending on your trading pairs.** + +First, let us describe the single actions of the smart contract. + +Configure the contract (deployment step, the contract's own authority): the contract-wide fee authority and the system token. Nothing else works before this. + + cleos push action evolutiondex setconfig '["sysio", {"contract":"eosio.token", "sym":"4,EOS"}]' -p evolutiondex + +Open a channel in the contract. This channel will store your trading tokens. You need to create one channel for each token you plan to trade. The second input below is the ram payer, and the authorizer must be the ram payer. + + cleos push action evolutiondex openext '["YOUR_ACCOUNT", "YOUR_ACCOUNT", {"contract":"eosio.token", "sym":"4,EOS"}]' -p YOUR_ACCOUNT + +Open a channel for the second token you wish to trade in evodex: + + cleos push action evolutiondex openext '["YOUR_ACCOUNT", "YOUR_ACCOUNT", {"contract":"pesocontract", "sym":"4,PESO"}]' -p YOUR_ACCOUNT + +Close the contract's channel for a specific token. In case there are funds there, +it returns them to the account "TO". + + cleos push action evolutiondex closeext '["YOUR_ACCOUNT", "TO", {"contract":"eosio.token", "sym":"4,EOS"}, "memo"]' -p YOUR_ACCOUNT + +Fill your account with the desired tokens. The contract accepts only the system token and the first legs of existing pairs; anything else is refused. A pair's first leg has no pair yet when its seed is deposited, so that one transfer also carries the contract's authority. + + cleos push action eosio.token transfer '["YOUR_ACCOUNT", "evolutiondex", "100.0000 EOS", "memo"]' -p YOUR_ACCOUNT + + cleos push action pesocontract transfer '["YOUR_ACCOUNT", "evolutiondex", "100.0000 PESO", "pesitos"]' -p YOUR_ACCOUNT -p evolutiondex + +Check your open channels and balances: + + cleos get table evolutiondex YOUR_ACCOUNT evodexacnts + +Withdraw funds from your opened channels, to the account "TO": + + cleos push action evolutiondex withdraw '["YOUR_ACCOUNT", "TO", {"contract":"eosio.token", "quantity":"1.0000 EOS"}, "memo"]' -p YOUR_ACCOUNT + +Create the PESO/EOS evotoken: the pair's own token first, the system token second. Set the initial liquidity, the initial fee for the trading pair (in units of 0.01%, here 0.1%), the fee authority (an empty name adopts the contract-wide authority set at deployment, any other name makes that account the pair's own), the shares to lock, and the yield leg (null for a plain pool). The supply minted is the square root of the product of the two amounts; the locked part is held by nobody and can never be redeemed, which keeps the pool from ever being emptied and bounds how far one share's value can be pushed. It is your call how much to lock, zero included. The contract's authority is required alongside yours, and a token can form only one pair. + + cleos push action evolutiondex inittoken '["YOUR_ACCOUNT", "4,EOSPESO", {"contract":"pesocontract", "quantity":"1.0000 PESO"}, {"contract":"eosio.token", "quantity":"1.0000 EOS"}, 10, "", "0.1000 EOSPESO", null]' -p YOUR_ACCOUNT -p evolutiondex + +Create a yield pool instead: name the first leg as a shadow token (a token that pays WIRE yield to its holders). + + cleos push action evolutiondex inittoken '["YOUR_ACCOUNT", "4,SHDEOS", {"contract":"shadowtoken", "quantity":"1.0000 SHD"}, {"contract":"eosio.token", "quantity":"1.0000 EOS"}, 10, "", "0.0000 SHDEOS", {"contract":"shadowtoken", "sym":"4,SHD"}]' -p YOUR_ACCOUNT -p evolutiondex + +Set a yield pool's tick parameters, signed by its fee authority: the horizon (seconds) over which queued yield is meant to sell, the ceiling on one clip in basis points of the pool's shadow side, and the least a clip may be, in units of the shadow. Size the floor so the pair's fee on a clip's output reaches a whole unit on its own, which at a 0.1% fee is an output of 1000 units; below that the one-unit minimum swap fee is what a clip actually pays, and a small enough clip is consumed entirely. Erring high only makes sales larger and rarer at the same average rate, but a floor above the depth cap stops the pair selling altogether. + + cleos push action evolutiondex setyield '["SHDEOS", 86400, 3, 1000]' -p sysio + +Settle the yield the pool is owed on the shadow it holds into its other leg, minting nothing. This also runs by itself before every addliquidity and remliquidity; anyone may call it in between: + + cleos push action evolutiondex accrueyield '["SHDEOS"]' -p YOUR_ACCOUNT + +Queue shadow to be sold through the pool: announce the exact amount, then transfer it. The transfer that matches the announcement fills the pool's reservoir instead of your deposit; while the announcement is pending any other transfer from you is refused, and announcing again replaces it. Both steps fit in one transaction. + + cleos push action evolutiondex fundyield '["YOUR_ACCOUNT", "SHDEOS", "5.0000 SHD"]' -p YOUR_ACCOUNT -p YOUR_ACCOUNT@sysio.payer + cleos push action shadowtoken transfer '["YOUR_ACCOUNT", "evolutiondex", "5.0000 SHD", ""]' -p YOUR_ACCOUNT + +The announcement's row is billed to you, which is why it needs the payer permission. Drop one you are not going to deliver, which refunds the row and lets you deposit normally again: + + cleos push action evolutiondex cancelyield '["YOUR_ACCOUNT"]' -p YOUR_ACCOUNT + +See what is queued: + + cleos get table evolutiondex evolutiondex reservoirs -L SHDEOS -U SHDEOS + +Sell one clip of the queue through the pool and hand the proceeds to the shadow's holders (the pool gets its share back on the next accrual). Anyone may call this; a crank calls it every block. Requires the pool's tick parameters to be set, and the shadow token's sysio.code seat on the contract's active permission, granted at deployment: + + cleos set account permission evolutiondex active --add-code shadowtoken + cleos push action evolutiondex tickyield '["SHDEOS"]' -p YOUR_ACCOUNT + +Change a pair's fee, signed by its fee authority: + + cleos push action evolutiondex changefee '["EOSPESO", 30]' -p sysio + +Check your evotokens balance: + + cleos get table evolutiondex YOUR_ACCOUNT accounts + +Add more liquidity to a pool. Set the exact amount of evotoken to obtain, in this case +1.5000 EOSPESO, and the maximum you are willing to pay of each token of the pair, first leg first. + + cleos push action evolutiondex addliquidity '["YOUR_ACCOUNT", "1.5000 EOSPESO", + "2.0000 PESO", "2.0000 EOS"]' -p YOUR_ACCOUNT + +Sell your evotokens and retire liquidity. The amount of evotoken is exact and the other two are minima required. + + cleos push action evolutiondex remliquidity '["YOUR_ACCOUNT", "1.0000 EOSPESO", + "0.1000 PESO", "1.0000 EOS"]' -p YOUR_ACCOUNT + +Exchange your tokens. +There two methods. The first one is to do a transfer to the contract with a memo starting with "exchange:" and followed by the details of your operation, with the format "EVOTOKN, min_expected_asset, memo". Blank spaces before EVOTOKN, min_expected_asset and memo are ignored. The amount to be obtained by the user will be computed by the contract and executed only if it is at least min_expected_asset. + + cleos push action eosio.token transfer '["YOUR_ACCOUNT", "evolutiondex", "1.0000 EOS", "exchange: EOSPESO, 0.1000 PESO, memo for the transfer"]' -p YOUR_ACCOUNT + +The other method operates between funds already deposited in the contract. The structure +of the input is account, evotoken, extended_asset to pay (exact), asset to receive (limiting). + + cleos push action evolutiondex exchange '["YOUR_ACCOUNT", "EOSPESO", + {"contract":"eosio.token", "quantity":"1.0000 EOS"}, "0.1000 PESO"]' -p YOUR_ACCOUNT + +The amount to pay must be positive and the amount to receive nonnegative; there is no +exact-output form (asking for an exact amount to receive by passing negative amounts). + +Read the pair's cumulative prices (a time-weighted average price oracle). Each row holds, for both directions, the running sum of the pool price in Q64.64 fixed point multiplied by the microseconds it held, and the time of the last update: + + cleos get table evolutiondex evolutiondex priceaccum -L EOSPESO -U EOSPESO + +Bring the accumulators up to the current block time without trading, so a snapshot taken now is current. Anyone may call this: + + cleos push action evolutiondex sync '["EOSPESO"]' -p YOUR_ACCOUNT + +Transfer your evotokens to another account: + + cleos push action evolutiondex transfer '["YOUR_ACCOUNT", "argentinaeos", "0.0001 EOSPESO", "ITS ALIVE"]' -p YOUR_ACCOUNT + +See evotoken stats: + + cleos get table evolutiondex EOSPESO stat + +In many practical cases, users will prefer to run many actions in a single transaction. +For example, if you want to add liquidity, you will probably prefer to close the accounts in the contract evolutiondex corresponding to the external tokens, to avoid spending RAM. To that end, you may run: + + cleos push transaction addliquidity.json + +where the file addliquidity.json contains: + + { + "actions": + [ + { + "account": "evolutiondex", + "name": "openext", + "authorization": [{"actor": "YOUR_ACCOUNT","permission": "active"}], + "data": { + "user": "YOUR_ACCOUNT", + "payer": "YOUR_ACCOUNT", + "ext_symbol": {"contract":"eosio.token", "sym":"4,EOS"} + } + },{ + "account": "evolutiondex", + "name": "openext", + "authorization": [{"actor": "YOUR_ACCOUNT","permission": "active"}], + "data": { + "user": "YOUR_ACCOUNT", + "payer": "YOUR_ACCOUNT", + "ext_symbol": {"contract":"pesocontract", "sym":"4,PESO"} + } + },{ + "account": "eosio.token", + "name": "transfer", + "authorization": [{"actor": "YOUR_ACCOUNT","permission": "active"}], + "data": { + "from": "YOUR_ACCOUNT", + "to": "evolutiondex", + "quantity": "2.0000 EOS", + "memo": "" + } + },{ + "account": "pesocontract", + "name": "transfer", + "authorization": [{"actor": "YOUR_ACCOUNT","permission": "active"}], + "data": { + "from": "YOUR_ACCOUNT", + "to": "evolutiondex", + "quantity": "2.0000 PESO", + "memo": "" + } + },{ + "account": "evolutiondex", + "name": "addliquidity", + "authorization": [{"actor": "YOUR_ACCOUNT","permission": "active"}], + "data": { + "user": "YOUR_ACCOUNT", + "to_buy": "1.5000 EOSPESO", + "max_asset1": "2.0000 PESO", + "max_asset2": "2.0000 EOS", + } + },{ + "account": "evolutiondex", + "name": "closeext", + "authorization": [{"actor": "YOUR_ACCOUNT","permission": "active"}], + "data": { + "user": "YOUR_ACCOUNT", + "to": "TO", + "ext_symbol": {"contract":"pesocontract", "sym":"4,PESO"}, + "memo": "" + } + },{ + "account": "evolutiondex", + "name": "closeext", + "authorization": [{"actor": "YOUR_ACCOUNT","permission": "active"}], + "data": { + "user": "YOUR_ACCOUNT", + "to": "TO", + "ext_symbol": {"contract":"eosio.token", "sym":"4,EOS"}, + "memo": "" + } + } + ] + } + +The same idea applies to the operations of removing liquidity and inittoken. +Typically, a graphical user interface will perform this kind of multiaction transactions. \ No newline at end of file diff --git a/contracts/sysio.swap/LICENSE b/contracts/sysio.swap/LICENSE new file mode 100644 index 0000000000..d02022d8d9 --- /dev/null +++ b/contracts/sysio.swap/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 EOS Argentina + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md new file mode 100644 index 0000000000..6e9f02dc62 --- /dev/null +++ b/contracts/sysio.swap/README.md @@ -0,0 +1,72 @@ +![EVODEX](evodex.png) + +This is a smart contract for EOSIO that allows the creation of continuous +liquidity pools for any pair of tokens in the chain. It facilitates the decentralization of +exchanges and offers an interesting financial position for the liquidity providers. + +Evolutiondex follows the line initiated by Bancor and Uniswap, but with some design improvements that we explain below. + +1- Evotokens. For each registered pair there will be a standard token backed by the assets in the corresponding pool. These new tokens can be freely transferred, facilitating the access and management of the investment position. We can call these tokens "evotokens". + +2- Initial fee and fee governance. A fee value in [0, 99.99%] is set at initialization of each trading pair. Every pair has a fee authority, the account whose signature `changefee` requires: by default the contract-wide one set at deployment with `setconfig` (WIRE governance executes approved proposals as `sysio`, so that is `sysio`), or a specific account named when the pair is created. A tiny fee of 0.01% is charged when providing liquidity in order +to protect previous liquidity providers from attacks to the fee value. +The action of removing liquidity is free of charge. + +3- One system token, one pair per token. `setconfig` also names the system token (WIRE), and every pair's second leg is the system token; the first leg is the pair's own token, and since pairs are unique there is exactly one pair per token. That orientation is what lets the contract recognise a transfer without an index of its own: a token is accepted if it is the system token, or if the pair it forms with the system token exists, which is one lookup in the pair index. The contract listens to every token contract's `transfer`, so this pin is what keeps anything else, a look-alike symbol from another contract included, from ever entering a deposit. Both seeds must be on deposit before a pair can be created; the system token's always can be, but the first leg has no pair yet, so its seed transfer must also carry the contract's own authority, the authority that creates pairs, which is accepted whatever the token. + +**On the formulas determining prices** + +We chose to follow the usual criterion: after an exchange operation for a given pair, the product of the amounts in the pools of the corresponding pair must remain equal before the fee is charged. After the fee, that product will rise accordingly. The curve output is rounded downward in favour of the pools, so the product can never fall and the system cannot be gamed through rounding; the fee taken from that output is rounded downward as well, following the fee convention shared with the WIRE reserves (`sysio.opp.common/amm_math.hpp`, which also supplies the constant-product curve itself), except that a nonzero fee always collects at least one unit of the output token so no trade is ever fee-free. This criterion completely determines +the price behaviour. Notice that whenever the amount to exchange is small compared to the pool sizes the price is approximately equal to the quotient between the amounts in the pools. + +When adding or removing liquidity, the (again standard) criterion is to keep fixed the ratios between minted evotokens and the amounts in the pools that back their value. Actually a small correction is in order for the case of adding liquidity: +the 0.01% fee charged will slightly increase the value of the evotoken afterwards. + +A pair is seeded with an evotoken supply equal to the square root of the product of the two initial amounts (integer, rounded down), so the unit's value does not depend on the ratio the creator picks. The creator may lock part of that supply: locked shares belong to nobody and can never be redeemed, so the pools always retain the value they represent and the pair can never be emptied and re-seeded at a different unit. Seed-time attacks only victimise the creator, so the size of the lock (zero included) is the creator's decision. + + +**Time-weighted average prices** + +Every pair keeps two cumulative-price accumulators in the `priceaccum` table, one per direction: the running sum of the pool's spot price (the other side's balance over this side's, in Q64.64 fixed point) multiplied by the microseconds that price held. They advance immediately before any operation that changes the pools, and on the permissionless `sync` action, so an interval is always weighted at the price that actually prevailed during it. A reader records an accumulator `r0` at time `t0` and reads `r` at `t`; `(r - r0) / (t - t0)` is the time-weighted average price over the window. A trade that moves the spot price inside a block contributes nothing until time passes at the moved price, which is what makes the average expensive to manipulate. The accumulators are 256 bits wide and cannot wrap; the arithmetic lives in `sysio.opp.common/twap.hpp`, which readers can use for the subtraction and division. + +**Yield pools** + +A pair may be created as a *yield pool* by naming its first leg as a shadow token (`yield_leg` in `inittoken`; an empty value makes a plain pool). A shadow token (`sysio.opp.common/shadow_yield.hpp`) pays WIRE yield to its holders through a cumulative index, and the contract, holding the pool's shadow, is such a holder. What it is owed is computed from the token's public state and settled into the pool's other leg with no shares minted, so every existing share appreciates. Settlement runs immediately before every mint and burn, so yield always belongs to the shares that existed when it was earned, and anyone may run it between them with `accrueyield`. The mechanics are deliberately strict: the contract credits the pool, records a receipt for the exact amount keyed by the shadow contract, and calls the token's `claim`; the transfer that delivers the payout must match the receipt, in the same transaction, or the transaction fails. Deposit accounts are not yield-bearing: the contract's whole shadow holding, deposits included, earns for the pool. One pair per token means one yield pool per shadow. + +A yield pool also has a *reservoir* (`reservoirs` table): shadow queued to be sold through the pool, held by the contract but in no pool and no deposit. It is filled with `fundyield`, which is typed and memo-free so that a contract can fund inline: the funder announces the pair and the exact amount, then transfers that amount of the shadow to the contract, in the same transaction or later. The transfer that matches the announcement fills the reservoir; while an announcement is pending, any other transfer from the funder is refused, and a new announcement replaces the pending one. The announcement's row is billed to the funder and refunded when the delivery lands or when they call `cancelyield`, which is also how a funder who changes their mind stops their transfers being routed to the reservoir. A pair's fee authority sets its tick parameters with `setyield`: the horizon over which the reservoir is meant to sell and the ceiling on one clip as basis points of the pool's shadow side. + +The reservoir sells through the pool itself, one clip per `tickyield`, which anyone may call and a crank is expected to call every block. A clip is the reservoir's share of the horizon elapsed since the pair's clock last advanced, floored, capped by the depth ceiling and by what is queued. The clock advances on every tick that *sells*, on `setyield`, and whenever the reservoir goes from empty to funded. Ticking more often does not sell faster: each clip is measured over the interval since the last one, and a second tick in the same block does nothing. + +A clip below `min(clip_floor, queued)` is not sold at all, and crucially the clock is left alone when that happens, so the unsold interval is banked and the next tick offers a proportionally larger clip. That costs no throughput, since waiting N times as long sells N times as much; it only makes sales larger and less frequent. A clip that clears that floor but whose *output* would be under `FEE_DENOMINATOR/fee` units is declined the same way, and for the reason the clip floor only approximates: below that output the proportional fee floors to zero, the one-unit minimum swap fee binds instead, and the clip pays far above the pair's rate out of the holders' distribution. `clip_floor` is in shadow units and that condition is in output units, and the rate between them is the pool price times the precision gap, which moves after `setyield` has run — so the floor is the sale granularity and the output check is the invariant. Both decline rather than fail, because a tick that asserted every block would accrue subjective CPU against the crank until its node stopped accepting the ticks that do clear; a pair that has stopped selling shows up instead as a non-empty reservoir whose `last_tick` is not advancing. A remainder smaller than the floor is not stranded either, because the floor gives way to what is queued: it leaves as a single sale once the time share reaches the whole queue, one horizon later. The clip is sold at the pool's own curve and fee, after the pool's owed yield has been settled, so the fee stays with the liquidity providers, and the proceeds go to every holder of the shadow through the token's own `addyield`; the pool, itself a holder, takes its share back on the next accrual. For that step the token moves the proceeds out of the contract under the contract's authority; `sysio.liq` is privileged (`sysio.roa::setsyscode`), so that inline transfer needs no `sysio.code` grant on the contract's active permission. + +Deployment therefore involves, in order: `setconfig` with the governance account and the system token; both seeds of each pair deposited, the first leg's under the contract's authority, and the pair created; and for each yield pool, `setyield` before the first tick. + +**Some considerations from the perspective of liquidity providers** + +Being a liquidity provider is a financial position that deserves a +careful analysis. It is necessary to understand the exposure to +gains and losses for the different future scenarios. This will be addressed +in a different article. + +Fee governance is an important tool, since the optimal fee parameter +is expected to change according to the mood of the market. For example, +high volatility suggests high fee. Another relevant factor is the competition from +other exchange opportunities. Without the ability to change the fee, liquidity +providers might want to move their funds from one place to the other, thus +discouraging them to invest in the first place. Therefore a dynamic fee offers +a practical way to benefit from the large exchange activity in the cryptocurrency +space. + +**From the perspective of exchangers** + +Continuous liquidity pools offer the advantages of decentralized exchange. +There is no need to trust funds to an institution. The prices are computed +algorithmically according to the available liquidity. High liquidity +will translate to low price slippage (this is the price variation as +the exchanged amount varies). +Conversely, if the liquidity pools are small, there will be considerable +slippage and the exchange will only be practical for tiny amounts. + +A list of Cleos commands to interact with this contract can be found at [Commands.md](Commands.md) + +**References:** Articles from [Bancor](https://about.bancor.network/protocol/), [Uniswap](https://uniswap.org), and [Eos Argentina](https://steemit.com/eosio/@yuhjtman/why-bancor-like-exchanges-are-expected-to-have-fees) (2018). diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp new file mode 100644 index 0000000000..4fc420c307 --- /dev/null +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -0,0 +1,392 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace sysio; +using namespace std; + +namespace sysio { + + class [[sysio::contract("sysio.swap")]] swap : public contract { + public: + const int64_t MAX = sysio::asset::max_amount; + const int64_t INIT_MAX = 1000000000000000; // 10^15 + const int ADD_LIQUIDITY_FEE = 1; + /// Fees are expressed in units of 1/FEE_DENOMINATOR of the traded amount. + static constexpr int FEE_DENOMINATOR = 10000; + /// Upper bound accepted by changefee: strictly below 100%, so a positive quote + /// always nets at least one unit (amm::split_wire_fee reports net 0 at 100%). + static constexpr int MAX_FEE = FEE_DENOMINATOR - 1; + /// Both pool sides carry the same weight: every pair is a plain constant-product + /// (x*y=k) pool, which is the exact-integer path of amm::out_given_in. + static constexpr uint64_t CP_WEIGHT_BPS = sysio::opp::amm::WEIGHT_TOTAL_BPS / 2; + /// The pair fee stays in the pool; no underwriter takes a share of it. + static constexpr uint32_t NO_UNDERWRITER_SHARE_BPS = 0; + /// Least fee, in units of the output token, a nonzero fee rate collects on a + /// nonzero quote. Without it the floored fee is zero on any quote below + /// FEE_DENOMINATOR/fee units, a window that is a thousand whole tokens for a + /// zero-precision symbol. + static constexpr uint64_t MIN_SWAP_FEE = 1; + + using contract::contract; + /// Deployment configuration, required before anything else works. The fee + /// authority is the account whose signature `changefee` requires for every + /// pair that did not name its own at creation; governance executes approved + /// proposals as `sysio`, so deployment sets it to `sysio`. The system token + /// (WIRE) is the second leg of every pair, which is what lets a transfer be + /// recognised without an index: a token is accepted if it is the system token + /// or the first leg of the one pair it can form with it. Requires the + /// contract's own authority. + [[sysio::action]] void setconfig(name fee_authority, extended_symbol system_token); + /// Create a pair, minting sqrt(pool1 * pool2) LP shares. `initial_pool2` must + /// be in the system token; `initial_pool1` is the pair's own token, and since + /// pairs are unique there is exactly one pair per such token. `initial_fee` + /// may be anything in [0, MAX_FEE]. An empty `fee_authority` adopts the + /// configured one; a name overrides it for this pair. `locked_shares` (in the + /// new symbol, below the minted amount) are held by no account and can never + /// be redeemed: they keep the pool from ever being emptied and bound how far + /// the value of one share can be pushed. Seed-time attacks victimise the + /// creator, so the size of the lock is the creator's call; zero is allowed. + /// `yield_leg`, when set, must be the first leg and names it as a shadow + /// token, making the pair a yield pool: the pool absorbs the WIRE yield + /// distributed on the shadow it holds, and sells queued yield shadow through + /// itself. Empty makes a plain pool. Both seeds must already be on deposit. + /// The system token's always can be; the first leg has no pair yet, so its + /// seed transfer must also carry this contract's authority. + [[sysio::action]] void inittoken(name user, symbol new_symbol, + extended_asset initial_pool1, extended_asset initial_pool2, + int initial_fee, name fee_authority, asset locked_shares, + std::optional yield_leg); + /// Set a yield pool's tick parameters (fee authority): the horizon over which + /// its queued yield is meant to sell, the hard ceiling on one clip as basis + /// points of the pool's shadow side, and the least a clip may be. All three + /// must be nonzero before tickyield runs. + /// + /// `clip_floor` is the sale GRANULARITY, in units of the shadow: how large a + /// clip has to get before it is worth selling at all. Erring high costs + /// nothing, because the clip scales with elapsed time and a higher floor only + /// makes sales larger and rarer at the same average rate. It is not what + /// guarantees a clip pays the pair's rate rather than MIN_SWAP_FEE -- that is + /// `min_fee_bearing_output`, which `tickyield` enforces on the OUTPUT, where + /// the condition does not move with the price. Sizing this one against that + /// condition is still worth doing, since a floor below it just means ticks + /// that quote and decline; at a 0.1% fee and a shadow near parity with the + /// system token that is around 1000 units. + /// + /// It must stay under the depth cap, though: a floor above the cap caps every + /// clip below the floor and the pair stops selling entirely. + /// + /// Keep `depth_cap_bps` below roughly twice the pair's fee. The cap is what + /// makes a tick not worth sandwiching: an attacker pays the round-trip fee on + /// their own position to capture a share of the clip's price impact, so the + /// attack only clears its cost once a clip can move the pool by more than the + /// fee costs them. + [[sysio::action]] void setyield(symbol_code pair_token, + uint32_t conversion_horizon_sec, uint32_t depth_cap_bps, int64_t clip_floor); + /// Settle the WIRE yield a yield pool is owed on the shadow it holds into the + /// pool's other leg, minting nothing: the pool is credited now and the token's + /// `claim` delivers the same amount in the same transaction (a mismatch fails + /// it). Runs implicitly before every mint and burn; this call lets anyone + /// settle between them. No authorization is required. + [[sysio::action]] void accrueyield(symbol_code pair_token); + /// Announce that `from` is about to transfer exactly `quantity` of the pair's + /// shadow to this contract as queued yield for the pool: the transfer, when it + /// lands, fills the pair's reservoir instead of `from`'s deposit. One + /// announcement per account is pending at a time; a new one replaces it, and + /// while one is pending any other transfer from `from` is refused. Typed and + /// memo-free, so a contract can do both steps inline in one transaction. + /// Requires `from`'s authority. + /// Requires `from`'s authority, and bills `from` for the row, so the + /// announcement carries `{from, sysio.payer}` alongside its active permission. + [[sysio::action]] void fundyield(name from, symbol_code pair_token, asset quantity); + /// Drop `from`'s pending announcement and refund its row. Their transfers go + /// back to being ordinary deposits. Announcing again replaces a pending row, + /// so this is for leaving none at all. Requires `from`'s authority. + [[sysio::action]] void cancelyield(name from); + /// Sell one clip of a yield pool's reservoir through the pool and hand the + /// proceeds to the shadow token's holders. The clip is the reservoir's share + /// of the horizon elapsed since the last tick, floored, capped by + /// `depth_cap_bps` of `last_tick_depth` or the current shadow side, whichever + /// is smaller, and by what is queued; it is sold + /// at the pool's own curve and fee, after the pool's owed yield has been + /// settled, and the proceeds go out through the token's `addyield`, so the + /// pool takes its own share back on the next accrual. + /// + /// A no-op when nothing is queued, when no time has elapsed, when the clip has + /// not reached `min(clip_floor, queued)` yet, or when what the clip would + /// fetch is under `min_fee_bearing_output`. Those last two are what make + /// cranking every block harmless: a clip that does not clear them sells + /// nothing AND leaves the clock alone, so the next tick measures a longer + /// window and offers a proportionally larger clip. They no-op rather than + /// fail on purpose -- a tick that asserted every block would accrue + /// subjective CPU against the crank until the node stopped accepting the + /// ticks that DO clear. A pair that has stopped selling is visible as a + /// non-empty reservoir whose `last_tick` is not advancing, which is the same + /// signal a depth cap under the clip floor gives. + /// + /// Requires `setyield` to have run. No authorization is required. + [[sysio::action]] void tickyield(symbol_code pair_token); + [[sysio::on_notify("*::transfer")]] void ontransfer(name from, name to, asset quantity, string memo); + [[sysio::action]] void openext( const name& user, const name& payer, const extended_symbol& ext_symbol); + [[sysio::action]] void closeext ( const name& user, const name& to, const extended_symbol& ext_symbol, string memo); + [[sysio::action]] void withdraw(name user, name to, extended_asset to_withdraw, string memo); + [[sysio::action]] void addliquidity(name user, asset to_buy, asset max_asset1, asset max_asset2); + [[sysio::action]] void remliquidity(name user, asset to_sell, asset min_asset1, asset min_asset2); + [[sysio::action]] void exchange( name user, symbol_code pair_token, extended_asset ext_asset_in, asset min_expected ); + [[sysio::action]] void changefee(symbol_code pair_token, int newfee); + /// Bring the pair's cumulative-price accumulators up to the current block + /// time without trading, so a reader can take an up-to-date snapshot. No + /// authorization is required; the caller pays only the CPU. + [[sysio::action]] void sync(symbol_code pair_token); + + [[sysio::action]] void transfer(const name& from, const name& to, + const asset& quantity, const string& memo ); + [[sysio::action]] void open( const name& owner, const symbol& symbol, const name& ram_payer ); + [[sysio::action]] void close( const name& owner, const symbol& symbol ); + + private: + + // --- Keys --- + + /// A pair's rows in `stat` and `priceaccum`: keyed by the LP token's symbol code. + struct pair_key { + uint64_t symbol_code; + SYSLIB_SERIALIZE(pair_key, (symbol_code)) + }; + + /// An LP-token balance row, scoped by its owner: keyed by the symbol code. + struct account_key { + uint64_t symbol_code; + SYSLIB_SERIALIZE(account_key, (symbol_code)) + }; + + /// A deposit row, scoped by its owner: keyed by the deposited token's extended + /// symbol, so a balance is one primary lookup and needs no surrogate id. + struct extended_symbol_key { + name contract; + uint64_t symbol; + SYSLIB_SERIALIZE(extended_symbol_key, (contract)(symbol)) + }; + + /// A pair's uniqueness row: keyed by its two legs, the lower (contract, symbol) + /// first, so the same two tokens in either order resolve to one key. + struct pair_identity_key { + name contract1; + uint64_t symbol1; + name contract2; + uint64_t symbol2; + SYSLIB_SERIALIZE(pair_identity_key, (contract1)(symbol1)(contract2)(symbol2)) + }; + + /// A pending yield payout: keyed by the shadow contract that owes it, which is + /// the `from` of the transfer that will settle it. + struct contract_key { + name contract; + SYSLIB_SERIALIZE(contract_key, (contract)) + }; + + /// A pending yield funding: keyed by the account that announced it, which is + /// the `from` of the transfer that will fill it. + struct funder_key { + name funder; + SYSLIB_SERIALIZE(funder_key, (funder)) + }; + + // --- Rows --- + + /// Contract-wide configuration, set on deployment by `setconfig`. + struct [[sysio::table("swapconfig")]] swap_config { + name fee_authority; + extended_symbol system_token; ///< the second leg of every pair + SYSLIB_SERIALIZE(swap_config, (fee_authority)(system_token)) + }; + + struct [[sysio::table("accounts")]] account { + asset balance; + SYSLIB_SERIALIZE(account, (balance)) + }; + + struct [[sysio::table("evodexacnts")]] evodex_account { + extended_asset balance; + SYSLIB_SERIALIZE(evodex_account, (balance)) + }; + + struct [[sysio::table("stat")]] currency_stats { + asset supply; + asset max_supply; + name issuer; + extended_asset pool1; + extended_asset pool2; + int fee; + name fee_authority; ///< whose signature changefee requires for this pair + asset locked_shares; ///< part of `supply` held by no account, never redeemable + std::optional yield_leg; ///< the shadow leg of a yield pool; empty for a plain pool + uint32_t conversion_horizon_sec = 0; ///< H: the reservoir is meant to sell over this long + uint32_t depth_cap_bps = 0; ///< hard ceiling on one clip, bps of the pool's shadow side + int64_t clip_floor = 0; ///< least a clip may be, in units of the shadow + int64_t last_tick_depth = 0; ///< the shadow side as of the last setyield or selling tick. + ///< The depth cap is taken against the SMALLER of this and + ///< the current side, so a shadow side inflated inside one + ///< transaction cannot widen the cap that bounds it. + time_point last_tick{}; ///< elapsed-time base of the clip formula: the last tick that + ///< sold, the last setyield, or when the reservoir last + ///< went from empty to funded, whichever is latest. A tick + ///< that sells nothing deliberately leaves it alone. + SYSLIB_SERIALIZE(currency_stats, (supply)(max_supply)(issuer)(pool1)(pool2)(fee)(fee_authority) + (locked_shares)(yield_leg)(conversion_horizon_sec)(depth_cap_bps) + (clip_floor)(last_tick_depth)(last_tick)) + }; + + /// A yield payout the contract has claimed and credited to `pair` but not yet + /// received. `quantity` is what the shadow contract's `claim` must deliver, + /// computed from the token's public state before the call; the transfer that + /// delivers it is matched against this row and the row erased. A row that + /// outlives its transaction means the token paid something else, and blocks + /// every further accrual through that contract until it is understood. + struct [[sysio::table("yieldpayouts")]] payout_receipt { + symbol_code pair; + extended_asset quantity; + SYSLIB_SERIALIZE(payout_receipt, (pair)(quantity)) + }; + + /// A yield funding announced by `fundyield` and not yet delivered: the + /// transfer from the funder that matches `quantity` fills `pair`'s reservoir + /// and erases the row. + struct [[sysio::table("yieldfunds")]] fund_receipt { + symbol_code pair; + extended_asset quantity; + SYSLIB_SERIALIZE(fund_receipt, (pair)(quantity)) + }; + + /// A yield pool's reservoir: the shadow queued to be sold through the pool, + /// held by the contract but in no pool and no deposit. Its own row rather + /// than a deposit row under a synthetic owner, so no account name can ever + /// alias it. Exists for yield pools only, from creation. + struct [[sysio::table("reservoirs")]] reservoir { + extended_asset balance; + SYSLIB_SERIALIZE(reservoir, (balance)) + }; + + struct [[sysio::table("evoindex")]] pair_index { + symbol evo_symbol; + SYSLIB_SERIALIZE(pair_index, (evo_symbol)) + }; + + /// Cumulative-price accumulators for a pair (sysio.opp.common/twap.hpp). + /// `price1` sums the Q64.64 price of one unit of pool1 in units of pool2, + /// times elapsed microseconds; `price2` the reverse. Both advance, at the + /// spot price that held since `last_update`, immediately before the pools + /// change and on `sync`. A reader snapshots the row at t0 and computes + /// `twap::average_price(twap::difference(now, snapshot), t - t0)`. + struct [[sysio::table("priceaccum")]] price_accumulator { + sysio::opp::twap::cumulative_price price1; + sysio::opp::twap::cumulative_price price2; + time_point last_update; + SYSLIB_SERIALIZE(price_accumulator, (price1)(price2)(last_update)) + }; + + // --- Tables --- + + using swapconfig_t = kv::global<"swapconfig"_n, swap_config>; + using accounts = kv::scoped_table<"accounts"_n, account_key, account>; + using evodexacnts = kv::scoped_table<"evodexacnts"_n, extended_symbol_key, evodex_account>; + using stats = kv::table<"stat"_n, pair_key, currency_stats>; + using evoindexes = kv::table<"evoindex"_n, pair_identity_key, pair_index>; + using priceaccums = kv::table<"priceaccum"_n, pair_key, price_accumulator>; + using yieldpayouts = kv::table<"yieldpayouts"_n, contract_key, payout_receipt>; + using yieldfunds = kv::table<"yieldfunds"_n, funder_key, fund_receipt>; + using reservoirs = kv::table<"reservoirs"_n, pair_key, reservoir>; + // (The shadow token's own tables are read through aliases local to the + // implementation file: an alias declared here would make the ABI generator + // list them as this contract's.) + + /// The deployment configuration, or a check failure before `setconfig` has run. + swap_config configured() const; + /// Refuse a transfer of anything this contract does not trade: `token` must be + /// the system token or the first leg of the pair it forms with the system + /// token (one lookup in the pair index, no table of its own). A first leg's + /// seed lands before its pair exists, so a transfer carrying this contract's + /// authority, the authority that creates pairs, is accepted regardless. + void require_deposit_token(const extended_symbol& token) const; + /// The deposit-row key of an extended symbol. + static extended_symbol_key key_of(const extended_symbol& ext_symbol); + /// The uniqueness-row key of a pair, in canonical leg order. + static pair_identity_key identity_of(const extended_symbol& a, const extended_symbol& b); + /// The pair's shadow leg, or a check failure on a plain pool: every yield path starts here. + static const extended_symbol& require_yield_leg(const currency_stats& token); + /// The pool holding `leg`; `leg` must be one of the pair's legs. + static const extended_asset& pool_of(const currency_stats& token, const extended_symbol& leg); + /// The pool holding the leg that is NOT `leg`; `leg` must be one of the pair's legs. + static const extended_asset& other_pool(const currency_stats& token, const extended_symbol& leg); + /// Restart the horizon clock of the pair at `key`: the next clip is measured + /// from now. + void restart_tick_clock(const pair_key& key); + /// The WIRE this contract is owed right now on the shadow it holds, from the + /// token's public state: `shadow::owed` over the contract's row and the current + /// index. Zero when the token has never distributed (no index row), so a + /// plain token in a yield leg reads as owing nothing. + uint64_t owed_yield(const extended_symbol& shadow) const; + /// Settle the owed yield of a yield pool into its other leg without minting: + /// credit the pool, record the receipt keyed by the shadow contract, and call + /// the token's `claim` inline; the transfer it sends lands in `ontransfer` + /// against the receipt. A plain pool, or nothing owed, changes nothing. + /// Returns the pair row as it now stands, for a caller that goes on to price. + /// + /// At most ONCE per action. The inline `claim` settles the token's row only + /// after this returns, so a second call inside the same action reads the same + /// owed amount and collides with the receipt the first one left. + currency_stats accrue(const pair_key& key, const currency_stats& token); + + void add_signed_ext_balance( const name& owner, const extended_asset& value ); + void add_signed_liq(name user, asset to_buy, bool is_buying, asset max_asset1, asset max_asset2); + void memoexchange(name user, extended_asset ext_asset_in, string_view details); + /// What paying `amount_in` into a pool holding `pool_in` of the paid leg and + /// `pool_out` of the other fetches, at `fee` in 1/FEE_DENOMINATOR units: the + /// constant-product quote floored (amm::out_given_in at equal weights), less + /// the pair's fee. Pure and side-effect free, so `tickyield` can ask what a + /// clip would fetch without moving anything. Returns 0 on degenerate input. + static int64_t quote_out(int64_t pool_in, int64_t pool_out, int64_t amount_in, int fee); + /// Least output at which the pair's own rate is the fee a trade pays. The fee + /// decomposition floors, so below FEE_DENOMINATOR/fee units the proportional + /// fee is zero and MIN_SWAP_FEE binds instead: an output of 2 pays half of + /// itself. Stated in output units, which is the only place the condition is + /// price-independent. + static int64_t min_fee_bearing_output(int fee); + /// Settle an exact-input swap of `paying` through the pair `evo_token`: the + /// output is the constant-product quote (amm::out_given_in at equal weights) + /// net of the pair's fee (amm::split_wire_fee, at least MIN_SWAP_FEE when the + /// rate and the quote are both nonzero), and must reach `min_expected`. + /// Moves the pools and returns the extended asset the user receives. + extended_asset process_exch(symbol_code evo_token, extended_asset paying, asset min_expected); + /// Liquidity pricing for one leg: what `x` shares are worth of a pool side + /// holding `y` against a supply of `z`, signed the way `add_signed_liq` reads + /// it -- positive is a leg the user pays, negative one they receive. The + /// proportional slice itself is amm::in_given_shares / amm::out_given_shares, + /// which round the pool's way; this adds the sign, the bounds that keep the + /// result inside an asset, and `fee` (in 1/FEE_DENOMINATOR units) of that + /// amount, rounded up. + int64_t compute(int64_t x, int64_t y, int64_t z, int fee); + /// The liquidity fee: `fee`/FEE_DENOMINATOR of `amount`, rounded up so a + /// non-zero amount never pays a zero fee. `amount` must be nonnegative. + static int128_t ceil_fee(int128_t amount, int fee); + /// Advance the pair's accumulators by `token`'s current spot prices times + /// the time since the last update. Must run BEFORE the pools change, so the + /// interval is weighted at the price that actually held during it. + void update_price_accumulators(const currency_stats& token); + void placeindex(name user, symbol evo_symbol, extended_asset pool1, extended_asset pool2 ); + void add_balance( const name& owner, const asset& value, const name& ram_payer ); + void sub_balance( const name& owner, const asset& value ); + }; +} diff --git a/contracts/sysio.swap/include/sysio.swap/utils.hpp b/contracts/sysio.swap/include/sysio.swap/utils.hpp new file mode 100644 index 0000000000..1789f48165 --- /dev/null +++ b/contracts/sysio.swap/include/sysio.swap/utils.hpp @@ -0,0 +1,101 @@ +#pragma once +#include +#include +#include + +#include + +inline string_view trim(string_view sv) { + sv.remove_prefix(std::min(sv.find_first_not_of(" "), sv.size())); // left trim + sv.remove_suffix(std::min(sv.size()-sv.find_last_not_of(" ")-1, sv.size())); // right trim + return sv; +} + +inline vector split(string_view str, string_view delims = " ") +{ + vector res; + std::size_t current, previous = 0; + current = str.find_first_of(delims); + while (current != std::string::npos) { + res.push_back(trim(str.substr(previous, current - previous))); + previous = current + 1; + current = str.find_first_of(delims, previous); + } + res.push_back(trim(str.substr(previous, current - previous))); + return res; +} + +inline bool starts_with(string_view sv, string_view s) { + return sv.size() >= s.size() && sv.compare(0, s.size(), s) == 0; +} + +/// Largest decimal precision an amount string may carry: 10^18 is the last +/// power of ten inside int64. +inline constexpr uint8_t MAX_AMOUNT_PRECISION = 18; + +/// Parse an unsigned decimal digit string into int64, aborting on any character +/// that is not a digit and on overflow. Signs are not accepted: every amount +/// read from a memo is a magnitude. An empty string is zero. +inline int64_t to_int(string_view sv) { + int64_t res = 0; + for (const char c : sv) { + check( c >= '0' && c <= '9', "invalid character" ); + check( !__builtin_mul_overflow(res, int64_t(10), &res) + && !__builtin_add_overflow(res, int64_t(c - '0'), &res), "amount too large" ); + } + return res; +} + +/// 10^decimals, the scale of an amount with that many decimal places. +inline int64_t precision_from_decimals(uint8_t decimals) +{ + check(decimals <= MAX_AMOUNT_PRECISION, "precision should be <= 18"); + int64_t p10 = 1; + for (uint8_t i = 0; i < decimals; ++i) p10 *= 10; + return p10; +} + +/// Parse " " as it appears in an exchange memo, e.g. +/// "16.6570 VOICE": the symbol's precision is the number of decimals written. +/// Every arithmetic step is overflow-checked, so an amount string too large for +/// int64 aborts with "amount too large" rather than wrapping. +inline asset asset_from_string(string_view from) +{ + string_view s = trim(from); + + // Find space in order to split amount and symbol + auto space_pos = s.find(' '); + check(space_pos != string::npos, "Asset's amount and symbol should be separated with space"); + auto symbol_str = trim(s.substr(space_pos + 1)); + auto amount_str = s.substr(0, space_pos); + + // Ensure that if decimal point is used (.), decimal fraction is specified + auto dot_pos = amount_str.find('.'); + if (dot_pos != string::npos) { + check(dot_pos != amount_str.size() - 1, "Missing decimal fraction after decimal point"); + } + + // Parse symbol + uint8_t precision_digit = 0; + if (dot_pos != string::npos) { + precision_digit = amount_str.size() - dot_pos - 1; + } + + symbol sym = symbol(symbol_str, precision_digit); + + // Parse amount + int64_t int_part = 0; + int64_t fract_part = 0; + if (dot_pos != string::npos) { + int_part = to_int(amount_str.substr(0, dot_pos)); + fract_part = to_int(amount_str.substr(dot_pos + 1)); + } else { + int_part = to_int(amount_str); + } + + int64_t amount = 0; + check( !__builtin_mul_overflow(int_part, precision_from_decimals(sym.precision()), &amount) + && !__builtin_add_overflow(amount, fract_part, &amount), "amount too large" ); + + return asset(amount, sym); +} diff --git a/contracts/sysio.swap/ricardian/sysio.swap.clauses.md b/contracts/sysio.swap/ricardian/sysio.swap.clauses.md new file mode 100644 index 0000000000..984cfff069 --- /dev/null +++ b/contracts/sysio.swap/ricardian/sysio.swap.clauses.md @@ -0,0 +1,8 @@ +

UserAgreement

+ +In order to function properly, the external tokens to be operated by this contract must permanently have a transfer action with input variables {{from}}, {{to}}, {{quantity}}, {{memo}} in that order, satisfying the following three conditions: +* {{from}} sends the asset {{quantity}} to {{to}}. {{to}} receives exactly {{quantity}}. The authorization of {{from}} is required. +* {{#if memo}} There is a memo attached to the transfer stating: {{memo}} {{/if}} +* The account {{to}} is notified. + +The user agrees that the present contract is not responsible for errors occurred in the operation of a pool which involves a token not satisfying the above conditions. \ No newline at end of file diff --git a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md new file mode 100644 index 0000000000..569fee47ba --- /dev/null +++ b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md @@ -0,0 +1,313 @@ +

openext

+ +--- +spec_version: "0.2.0" +title: Open extended Balance +summary: 'Open a zero quantity extended balance for {{nowrap user}}' +--- + +{{ram_payer}} agrees to establish a zero quantity extended balance for {{user}} +for the {{ext_symbol}} extended symbol. + +If {{user}} does not have an extended balance for {{ext_symbol}}, {{ram_payer}} will be designated as the RAM payer of {{user}}'s extended balance for {{ext_symbol}}. As a result, RAM will be deducted from {{ram_payer}}’s resources to create the necessary records. + +The authorization of {{ram_payer}} is required. + + +

closeext

+ +--- +spec_version: "0.2.0" +title: Close Extended Balance +summary: 'Close {{nowrap user}}’s extended balance' +--- + +{{user}} agree to close their extended balance for {{ext_symbol}}. + +If the extended balance is nonzero, it will be transfered to {{to}} before closing it, with a memo equal to {{memo}}. This transfer action corresponds to the contract of {{ext_symbol}}. + +In order to function properly, it is necessary that the contract of {{ext_symbol}} permanently has a transfer action that satisfies the conditions (1), (2), (6) of the present contract's transfer action. + +RAM will be refunded to the RAM payer of {{user}}'s extended balance for {{ext_symbol}}. + +The authorization of {{user}} is required. + + +

ontransfer

+ +--- +spec_version: "0.2.0" +title: On transfer +summary: 'Deposit or exchange upon a transfer from {{nowrap user}}' +--- + +This action is executed as a response to a notification of a transfer action with the input {{from}}, {{to}}, {{quantity}}, {{memo}} in that order. + +The transfer is accepted only if the extended symbol formed by the transfer's contract and the symbol {{asset_to_symbol quantity}} is the system token set by setconfig, or is the first leg of the pair it forms with the system token, or the transfer carries this contract's authority (the authority that creates pairs, so that a pair's first leg can be seeded before the pair exists). Any other transfer fails. + +The default response is to deposit {{quantity}} to {{from}}'s extended balance for that extended symbol. This is only possible if such extended balance previously exists. + +If {{memo}} starts with "deposit to:", the account {{from}} will be replaced by the subsequent content of {{memo}} whenever it is possible. + +Two typed routes take precedence over the memo, and each accepts exactly one transfer. If {{from}} is a shadow token from which this contract has a yield payout outstanding (see accrueyield), {{quantity}} must be that payout, which was already credited to the pool; the receipt is retired and nothing else is deposited. Otherwise, if {{from}} has a yield funding pending (see fundyield), {{quantity}} must be the announced amount, which fills the announced pair's reservoir. In either case any other transfer from {{from}} fails. + +If {{memo}} starts with "exchange:", the subsequent content of the memo is expected +to have the form "EVOTOKEN,min_expected_asset,optional memo". An exchange operation will be processed with this data, following the same conversion rules as in the exchange action for the input {{from}}, {{EVOTOKEN}}, {{quantity}}, {{min_expected_asset}}. If the output asset is at least equal to {{min_expected_asset}}, it will be transfered from this contract +to {{user}}, with {{optional memo}} as memo. + +In order to function properly, it is necessary that both pool contracts associated to {{EVOTOKEN}}, permanently have a transfer action that satisfies the conditions (1), (2), (6) of the present contract's transfer action. + + +

withdraw

+ +--- +spec_version: "0.2.0" +title: Withdraw +summary: 'Withdraw funds from extended balance' +--- + +{{user}} agree to withdraw the extended asset {{to_withdraw}} from their account. + +In order to function properly, it is necessary that the contract of {{to_withdraw}} permanently has a transfer action that satisfies the conditions (1), (2), (6) of the present contract's transfer action. + + +

inittoken

+ +--- +spec_version: "0.2.0" +title: Initialize token +summary: 'Initializes an evotoken by setting initial pair of token pools' +--- + +{{user}} agrees to initialize a pair token with symbol {{new_symbol}}, with the following initial parameters: pool1 = {{initial_pool1}}, pool2 = {{initial_pool2}}, fee = {{initial_fee}} (in units of 1/10000, at most 9999), fee_authority = {{fee_authority}}. {{initial_pool2}} must be in the system token set by setconfig; {{initial_pool1}} is the pair's own token, and a token can form only one pair. The extended assets {{initial_pool1}} and {{initial_pool2}} will be deducted from the corresponding extended balances of {{user}}. + +The fee authority is the account whose authorization the changefee action requires for this pair. An empty {{fee_authority}} adopts the contract-wide fee authority set by setconfig; any other name makes that account the pair's own. + +The pair token supply minted is the square root of the product of the two initial pool amounts, rounded downward. Of it, {{locked_shares}} are held by no account and can never be redeemed, so the pools always retain the value those shares represent; the remainder is credited to {{user}}. {{locked_shares}} must be less than the amount minted. + +If {{yield_leg}} is given it must be the first leg, and the pair becomes a yield pool on that shadow token: the yield the contract is owed on the shadow it holds is settled into the second leg without minting, before every addliquidity and remliquidity and on accrueyield. Without {{yield_leg}} the pair is a plain pool. + +RAM will be deducted from {{user}}’s resources to create the necessary records. +Authorization of {{user}} and of the contract is required. + + +

addliquidity

+ +--- +spec_version: "0.2.0" +title: Add liquidity +summary: '{{nowrap user}} buys an evotoken by adding liquidity to pools' +--- + +{{user}} agrees to buy the asset {{to_buy}} by paying no more than {{max_asset1}} and {{max_asset2}} to be deducted from {{user}}'s extended balances and added to the token +{{asset_to_symbol_code to_buy}} pools. The contracts of the assets that {{user}} pays will match the ones of the pools. The asset {{to_buy}} is minted by the contract. + +The first asset to be paid by {{user}} is computed as x + y, where + +x = pool1 * {{to_buy}} / supply, up to the precision of the symbol of pool1 rounded upward. +y = x / 10000, up to the same precision as x, again rounded upward. +The variables pool1 and supply denote the values pool1 and supply associated to +the token {{asset_to_symbol_code to_buy}} respectively, at the moment of operation. + +The second asset to be paid by {{user}} is computed analogously. + +Authorization of {{user}} is required. +The operation is executed only if the amounts to be paid by {{user}} are at most those indicated by {{user}}. + + +

remliquidity

+ +--- +spec_version: "0.2.0" +title: Remove liquidity +summary: '{{nowrap user}} sells an evotoken, removing liquidity from pools' +--- + +{{user}} agrees to sell the asset {{to_sell}} by receiving at least {{min_asset1}} and {{min_asset2}} to be added to {{user}}'s extended balances and removed from the token +{{asset_to_symbol_code to_sell}} pools. The asset {{to_sell}} is retired +from circulation by the contract. + +The first asset to be received by {{user}} is computed as + +pool1 * {{to_sell}} / supply, up to the precision of the symbol of pool1 rounded downward. + +The variables pool1 and supply denote the parameters pool1 and supply associated to the token {{asset_to_symbol_code to_sell}} respectively, at the moment of operation. + +The second asset to be received by {{user}} is computed analogously. + +Authorization of {{user}} is required. +The operation is executed only if the amounts to be received by {{user}} are at least +those indicated by {{user}}. + + +

exchange

+ +--- +spec_version: "0.2.0" +title: Exchange +summary: 'Exchange token through a specific pair' +--- + +{{user}} agree to substract {{ext_asset_in}} and to add at least {{min_expected}} to their extended balances. {{ext_asset_in}} must be positive and {{min_expected}} nonnegative. The extended symbol of {{ext_asset_in}} must match one of the pools associated to the token {{pair_token}}. The contract of {{min_expected}} is given by the other pool of that pair. These extended assets will be respectively added to and substracted from the corresponding pools. + +The extended asset to be added to {{users}}'s extended balance as a result of the exchange operation, is computed as x - y, where + +x = pool_out * {{ext_asset_in}} / (pool_in + {{ext_asset_in}}), up to the precision of the symbol of pool_out rounded downward. +y = x * fee / 10000, up to the same precision as x, again rounded downward, but never less than one unit of that precision when both x and fee are positive. The fee y remains in the pools. + +The variable pool_in denotes the corresponding extended asset pool1 or pool2 associated to the token {{pair_token}}; namely, the one whose extended symbol matches that of {{ext_asset_in}}. The variable pool_out is the extended asset pool1 or pool2, the one that is not pool_in. The variable fee is the integer fee associated to the token {{pair_token}}. +The values of these three variables must be taken at the moment of operation. + +Authorization of {{user}} is required. +The operation is executed only if the extended asset to be added to {{user}} is at least +that indicated by {{user}}. + + +

sync

+ +--- +spec_version: "0.2.0" +title: Sync price accumulators +summary: 'Bring the cumulative prices of {{nowrap pair_token}} up to the current time' +--- + +The cumulative-price accumulators of the token {{pair_token}} are advanced to the current block time: each accumulator grows by the pool price that has held since the previous update, multiplied by the time elapsed. The pools are not modified. No authorization is required. + +The accumulators also advance in the same way immediately before any operation that changes the pools of {{pair_token}}. A reader that records the accumulators at two times obtains the time-weighted average price between them as the difference of the accumulators divided by the elapsed time. + + +

setyield

+ +--- +spec_version: "0.2.0" +title: Set yield parameters +summary: 'Set the yield tick parameters of {{nowrap pair_token}}' +--- + +The fee authority associated to the token {{pair_token}}, which must be a yield pool, authorizes to set the horizon of {{conversion_horizon_sec}} seconds over which the pool's queued yield is meant to sell, the ceiling of {{depth_cap_bps}} basis points (at most 10000) of the pool's shadow side on one clip, and the least a clip may be, {{clip_floor}}, in units of the shadow token. All three must be nonzero before the pool's yield tick can run. The pair's tick clock restarts now. + + +

accrueyield

+ +--- +spec_version: "0.2.0" +title: Accrue yield +summary: 'Settle the yield owed to the pool of {{nowrap pair_token}}' +--- + +The token {{pair_token}} must be a yield pool. The WIRE the contract is owed by the pool's shadow token, computed from that token's public distribution state, is credited to the pool's other leg with no pair tokens minted, and the shadow token's claim action is called to deliver it. The delivery must match the credited amount exactly within the same transaction; otherwise the transaction fails. When nothing is owed the pools are not modified. + +The same settlement is performed immediately before every addliquidity and remliquidity on {{pair_token}}. No authorization is required. + + +

fundyield

+ +--- +spec_version: "0.2.0" +title: Fund yield +summary: 'Announce {{nowrap quantity}} of shadow for the reservoir of {{nowrap pair_token}}' +--- + +{{from}} announces the transfer of exactly {{quantity}}, in the shadow symbol of the yield pool {{pair_token}}, to this contract. The transfer of {{quantity}} from {{from}} that follows, in this transaction or a later one, is added to the reservoir of {{pair_token}}, the shadow queued to be sold through the pool, and not to {{from}}'s extended balance. While the announcement is pending, any other transfer from {{from}} to this contract is refused. A new announcement by {{from}} replaces the pending one. When the delivery fills a reservoir that was empty, the pair's tick clock restarts. + +RAM for the record of the announcement will be deducted from {{from}}'s resources, and returned by cancelyield or by the delivery. + +Authorization of {{from}} is required. + + +

cancelyield

+ +--- +spec_version: "0.2.0" +title: Cancel yield funding +summary: 'Drop the pending funding announcement of {{nowrap from}}' +--- + +{{from}} agrees to withdraw their pending fundyield announcement. The record is erased and its RAM returned to {{from}}, and transfers from {{from}} to this contract are treated as ordinary deposits again. The action fails if {{from}} has no pending announcement. + +Authorization of {{from}} is required. + + +

tickyield

+ +--- +spec_version: "0.2.0" +title: Tick yield +summary: 'Sell one clip of the reservoir of {{nowrap pair_token}} through the pool' +--- + +The token {{pair_token}} must be a yield pool whose tick parameters have been set. The yield owed to the pool is settled first, as in accrueyield. Then a clip of the reservoir is exchanged through the pool for the other leg, under the same conversion rules and fee as the exchange action with no minimum: the clip is the reservoir multiplied by the time elapsed since the pair's tick clock last advanced and divided by the conversion horizon, rounded downward, but at most the depth cap and at most the reservoir. The depth cap is the pair's basis points of the pool's shadow side as it stands now or as it stood at the last setyield or selling tick, whichever of the two is smaller, so that a shadow side enlarged since that tick cannot widen it. The clock then advances to now. The other-leg proceeds are handed to the shadow token's addyield action, which distributes them to every holder of the shadow; this contract, holding the pool's shadow, receives its share on a later accrual. + +The exchange happens only if two conditions hold. The clip must have reached the pair's clip floor, or the whole of the reservoir if that is smaller. And, unless the whole reservoir is what is being sold, what the clip would fetch must be enough that the pair's own fee rate is the fee it pays rather than the one-unit minimum. When either fails, and when the reservoir is empty or no time has elapsed, the pools, the reservoir and the clock are all left unmodified; in particular the clock is not advanced, so the elapsed time counts toward the next clip instead of being discarded. No authorization is required. + + +

changefee

+ +--- +spec_version: "0.2.0" +title: Change fee +summary: 'Change the fee value associated to a pair' +--- + +The fee authority associated to the token {{pair_token}} authorizes +to change the fee parameter associated to the same token, to the value {{newfee}}, in units of 1/10000 and at most 9999. + + +

setconfig

+ +--- +spec_version: "0.2.0" +title: Set configuration +summary: 'Set the fee authority to {{nowrap fee_authority}} and the system token to {{nowrap system_token}}' +--- + +The contract sets {{fee_authority}} as the account whose authorization the changefee action requires for every pair created afterwards without a fee authority of its own, and {{system_token}} as the system token: the second leg of every pair, and the one token accepted in a transfer without a pair of its own. Pairs already created keep the authority they were created with. Until this action has run, no transfer is accepted and no pair can be created. + +The authorization of the contract is required. + + +

close

+ +--- +spec_version: "0.2.0" +title: Close Token Balance +summary: 'Close {{nowrap owner}}’s zero quantity balance' +--- + +{{owner}} agree to close their zero quantity balance for the {{symbol_to_symbol_code symbol}} token. + +RAM will be refunded to the RAM payer of the {{symbol_to_symbol_code symbol}} token balance for {{owner}}. + + +

open

+ +--- +spec_version: "0.2.0" +title: Open Token Balance +summary: 'Open a zero quantity balance for {{nowrap owner}}' +--- + +{{ram_payer}} agrees to establish a zero quantity balance for {{owner}} for the {{symbol_to_symbol_code symbol}} token. + +If {{owner}} does not have a balance for {{symbol_to_symbol_code symbol}}, {{ram_payer}} will be designated as the RAM payer of the {{symbol_to_symbol_code symbol}} token balance for {{owner}}. As a result, RAM will be deducted from {{ram_payer}}’s resources to create the necessary records. + + +

transfer

+ +--- +spec_version: "0.2.0" +title: Transfer Tokens +summary: 'Send {{nowrap quantity}} from {{nowrap from}} to {{nowrap to}}' +--- + +(1) {{from}} sends the asset {{quantity}} to {{to}}. {{to}} receives exactly {{quantity}}.The authorization of {{from}} is required. + +(2) {{#if memo}} There is a memo attached to the transfer stating: {{memo}} +{{/if}} + +(3) If {{from}} is not already the RAM payer of their {{asset_to_symbol_code quantity}} token balance, {{from}} will be designated as such. As a result, RAM will be deducted from {{from}}’s resources to refund the original RAM payer. + +(4) If {{to}} does not have a balance for {{asset_to_symbol_code quantity}}, {{from}} will be designated as the RAM payer of the {{asset_to_symbol_code quantity}} token balance for {{to}}. As a result, RAM will be deducted from {{from}}’s resources to create the necessary records. + +(5) The account {{from}} is notified. +(6) The account {{to}} is notified. \ No newline at end of file diff --git a/contracts/sysio.swap/sysio.swap.abi b/contracts/sysio.swap/sysio.swap.abi new file mode 100644 index 0000000000..9f90819beb --- /dev/null +++ b/contracts/sysio.swap/sysio.swap.abi @@ -0,0 +1,770 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "account", + "base": "", + "fields": [ + { + "name": "balance", + "type": "asset" + } + ] + }, + { + "name": "account_key", + "base": "", + "fields": [ + { + "name": "symbol_code", + "type": "uint64" + } + ] + }, + { + "name": "accrueyield", + "base": "", + "fields": [ + { + "name": "pair_token", + "type": "symbol_code" + } + ] + }, + { + "name": "addliquidity", + "base": "", + "fields": [ + { + "name": "user", + "type": "name" + }, + { + "name": "to_buy", + "type": "asset" + }, + { + "name": "max_asset1", + "type": "asset" + }, + { + "name": "max_asset2", + "type": "asset" + } + ] + }, + { + "name": "cancelyield", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + } + ] + }, + { + "name": "changefee", + "base": "", + "fields": [ + { + "name": "pair_token", + "type": "symbol_code" + }, + { + "name": "newfee", + "type": "int32" + } + ] + }, + { + "name": "close", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "symbol", + "type": "symbol" + } + ] + }, + { + "name": "closeext", + "base": "", + "fields": [ + { + "name": "user", + "type": "name" + }, + { + "name": "to", + "type": "name" + }, + { + "name": "ext_symbol", + "type": "extended_symbol" + }, + { + "name": "memo", + "type": "string" + } + ] + }, + { + "name": "contract_key", + "base": "", + "fields": [ + { + "name": "contract", + "type": "name" + } + ] + }, + { + "name": "cumulative_price", + "base": "", + "fields": [ + { + "name": "lo", + "type": "uint128" + }, + { + "name": "hi", + "type": "uint128" + } + ] + }, + { + "name": "currency_stats", + "base": "", + "fields": [ + { + "name": "supply", + "type": "asset" + }, + { + "name": "max_supply", + "type": "asset" + }, + { + "name": "issuer", + "type": "name" + }, + { + "name": "pool1", + "type": "extended_asset" + }, + { + "name": "pool2", + "type": "extended_asset" + }, + { + "name": "fee", + "type": "int32" + }, + { + "name": "fee_authority", + "type": "name" + }, + { + "name": "locked_shares", + "type": "asset" + }, + { + "name": "yield_leg", + "type": "extended_symbol?" + }, + { + "name": "conversion_horizon_sec", + "type": "uint32" + }, + { + "name": "depth_cap_bps", + "type": "uint32" + }, + { + "name": "clip_floor", + "type": "int64" + }, + { + "name": "last_tick_depth", + "type": "int64" + }, + { + "name": "last_tick", + "type": "time_point" + } + ] + }, + { + "name": "evodex_account", + "base": "", + "fields": [ + { + "name": "balance", + "type": "extended_asset" + } + ] + }, + { + "name": "exchange", + "base": "", + "fields": [ + { + "name": "user", + "type": "name" + }, + { + "name": "pair_token", + "type": "symbol_code" + }, + { + "name": "ext_asset_in", + "type": "extended_asset" + }, + { + "name": "min_expected", + "type": "asset" + } + ] + }, + { + "name": "extended_symbol", + "base": "", + "fields": [ + { + "name": "sym", + "type": "symbol" + }, + { + "name": "contract", + "type": "name" + } + ] + }, + { + "name": "extended_symbol_key", + "base": "", + "fields": [ + { + "name": "contract", + "type": "name" + }, + { + "name": "symbol", + "type": "uint64" + } + ] + }, + { + "name": "fund_receipt", + "base": "", + "fields": [ + { + "name": "pair", + "type": "symbol_code" + }, + { + "name": "quantity", + "type": "extended_asset" + } + ] + }, + { + "name": "funder_key", + "base": "", + "fields": [ + { + "name": "funder", + "type": "name" + } + ] + }, + { + "name": "fundyield", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "pair_token", + "type": "symbol_code" + }, + { + "name": "quantity", + "type": "asset" + } + ] + }, + { + "name": "inittoken", + "base": "", + "fields": [ + { + "name": "user", + "type": "name" + }, + { + "name": "new_symbol", + "type": "symbol" + }, + { + "name": "initial_pool1", + "type": "extended_asset" + }, + { + "name": "initial_pool2", + "type": "extended_asset" + }, + { + "name": "initial_fee", + "type": "int32" + }, + { + "name": "fee_authority", + "type": "name" + }, + { + "name": "locked_shares", + "type": "asset" + }, + { + "name": "yield_leg", + "type": "extended_symbol?" + } + ] + }, + { + "name": "open", + "base": "", + "fields": [ + { + "name": "owner", + "type": "name" + }, + { + "name": "symbol", + "type": "symbol" + }, + { + "name": "ram_payer", + "type": "name" + } + ] + }, + { + "name": "openext", + "base": "", + "fields": [ + { + "name": "user", + "type": "name" + }, + { + "name": "payer", + "type": "name" + }, + { + "name": "ext_symbol", + "type": "extended_symbol" + } + ] + }, + { + "name": "pair_identity_key", + "base": "", + "fields": [ + { + "name": "contract1", + "type": "name" + }, + { + "name": "symbol1", + "type": "uint64" + }, + { + "name": "contract2", + "type": "name" + }, + { + "name": "symbol2", + "type": "uint64" + } + ] + }, + { + "name": "pair_index", + "base": "", + "fields": [ + { + "name": "evo_symbol", + "type": "symbol" + } + ] + }, + { + "name": "pair_key", + "base": "", + "fields": [ + { + "name": "symbol_code", + "type": "uint64" + } + ] + }, + { + "name": "payout_receipt", + "base": "", + "fields": [ + { + "name": "pair", + "type": "symbol_code" + }, + { + "name": "quantity", + "type": "extended_asset" + } + ] + }, + { + "name": "price_accumulator", + "base": "", + "fields": [ + { + "name": "price1", + "type": "cumulative_price" + }, + { + "name": "price2", + "type": "cumulative_price" + }, + { + "name": "last_update", + "type": "time_point" + } + ] + }, + { + "name": "remliquidity", + "base": "", + "fields": [ + { + "name": "user", + "type": "name" + }, + { + "name": "to_sell", + "type": "asset" + }, + { + "name": "min_asset1", + "type": "asset" + }, + { + "name": "min_asset2", + "type": "asset" + } + ] + }, + { + "name": "reservoir", + "base": "", + "fields": [ + { + "name": "balance", + "type": "extended_asset" + } + ] + }, + { + "name": "setconfig", + "base": "", + "fields": [ + { + "name": "fee_authority", + "type": "name" + }, + { + "name": "system_token", + "type": "extended_symbol" + } + ] + }, + { + "name": "setyield", + "base": "", + "fields": [ + { + "name": "pair_token", + "type": "symbol_code" + }, + { + "name": "conversion_horizon_sec", + "type": "uint32" + }, + { + "name": "depth_cap_bps", + "type": "uint32" + }, + { + "name": "clip_floor", + "type": "int64" + } + ] + }, + { + "name": "swap_config", + "base": "", + "fields": [ + { + "name": "fee_authority", + "type": "name" + }, + { + "name": "system_token", + "type": "extended_symbol" + } + ] + }, + { + "name": "sync", + "base": "", + "fields": [ + { + "name": "pair_token", + "type": "symbol_code" + } + ] + }, + { + "name": "tickyield", + "base": "", + "fields": [ + { + "name": "pair_token", + "type": "symbol_code" + } + ] + }, + { + "name": "transfer", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "to", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + }, + { + "name": "memo", + "type": "string" + } + ] + }, + { + "name": "withdraw", + "base": "", + "fields": [ + { + "name": "user", + "type": "name" + }, + { + "name": "to", + "type": "name" + }, + { + "name": "to_withdraw", + "type": "extended_asset" + }, + { + "name": "memo", + "type": "string" + } + ] + } + ], + "actions": [ + { + "name": "accrueyield", + "type": "accrueyield", + "ricardian_contract": "" + }, + { + "name": "addliquidity", + "type": "addliquidity", + "ricardian_contract": "" + }, + { + "name": "cancelyield", + "type": "cancelyield", + "ricardian_contract": "" + }, + { + "name": "changefee", + "type": "changefee", + "ricardian_contract": "" + }, + { + "name": "close", + "type": "close", + "ricardian_contract": "" + }, + { + "name": "closeext", + "type": "closeext", + "ricardian_contract": "" + }, + { + "name": "exchange", + "type": "exchange", + "ricardian_contract": "" + }, + { + "name": "fundyield", + "type": "fundyield", + "ricardian_contract": "" + }, + { + "name": "inittoken", + "type": "inittoken", + "ricardian_contract": "" + }, + { + "name": "open", + "type": "open", + "ricardian_contract": "" + }, + { + "name": "openext", + "type": "openext", + "ricardian_contract": "" + }, + { + "name": "remliquidity", + "type": "remliquidity", + "ricardian_contract": "" + }, + { + "name": "setconfig", + "type": "setconfig", + "ricardian_contract": "" + }, + { + "name": "setyield", + "type": "setyield", + "ricardian_contract": "" + }, + { + "name": "sync", + "type": "sync", + "ricardian_contract": "" + }, + { + "name": "tickyield", + "type": "tickyield", + "ricardian_contract": "" + }, + { + "name": "transfer", + "type": "transfer", + "ricardian_contract": "" + }, + { + "name": "withdraw", + "type": "withdraw", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "accounts", + "type": "account", + "index_type": "i64", + "key_names": ["scope","symbol_code"], + "key_types": ["name","uint64"], + "table_id": 25660 + }, + { + "name": "evodexacnts", + "type": "evodex_account", + "index_type": "i64", + "key_names": ["scope","contract","symbol"], + "key_types": ["name","name","uint64"], + "table_id": 63234 + }, + { + "name": "evoindex", + "type": "pair_index", + "index_type": "i64", + "key_names": ["contract1","symbol1","contract2","symbol2"], + "key_types": ["name","uint64","name","uint64"], + "table_id": 41326 + }, + { + "name": "priceaccum", + "type": "price_accumulator", + "index_type": "i64", + "key_names": ["symbol_code"], + "key_types": ["uint64"], + "table_id": 54664 + }, + { + "name": "reservoirs", + "type": "reservoir", + "index_type": "i64", + "key_names": ["symbol_code"], + "key_types": ["uint64"], + "table_id": 43956 + }, + { + "name": "stat", + "type": "currency_stats", + "index_type": "i64", + "key_names": ["symbol_code"], + "key_types": ["uint64"], + "table_id": 4264 + }, + { + "name": "swapconfig", + "type": "swap_config", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 40605 + }, + { + "name": "yieldfunds", + "type": "fund_receipt", + "index_type": "i64", + "key_names": ["funder"], + "key_types": ["name"], + "table_id": 29681 + }, + { + "name": "yieldpayouts", + "type": "payout_receipt", + "index_type": "i64", + "key_names": ["contract"], + "key_types": ["name"], + "table_id": 50476 + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [] +} \ No newline at end of file diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp new file mode 100644 index 0000000000..6fcf1f2720 --- /dev/null +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -0,0 +1,620 @@ +#include +#include +#include + +namespace { + // The shadow token's tables, read in place (sysio.opp.common/shadow_yield.hpp): + // constructed with the token contract as code, the holder table scoped by the + // holder. Declared outside the contract class on purpose: the ABI generator + // lists every kv table alias it finds inside the class as the contract's own. + using shadow_accounts = sysio::kv::scoped_table; + using shadow_indexes = sysio::kv::table; + + /// Whether a delivered extended asset is exactly the expected one: same + /// contract, same symbol, same amount. (asset's own == asserts on a symbol + /// mismatch, which here must be an ordinary "does not match" failure.) + bool delivers(const sysio::extended_asset& delivered, const sysio::extended_asset& expected) { + return delivered.contract == expected.contract + && delivered.quantity.symbol == expected.quantity.symbol + && delivered.quantity.amount == expected.quantity.amount; + } +} + +namespace sysio { + +void swap::openext( const name& user, const name& payer, const extended_symbol& ext_symbol) { + check( is_account( user ), "user account does not exist" ); + require_auth( payer ); + evodexacnts acnts( get_self(), user.value ); + const auto key = key_of(ext_symbol); + if( !acnts.contains(key) ) { + acnts.emplace( payer, key, evodex_account{ extended_asset{0, ext_symbol} } ); + } +} + +void swap::closeext( const name& user, const name& to, const extended_symbol& ext_symbol, string memo) { + require_auth( user ); + evodexacnts acnts( get_self(), user.value ); + const auto key = key_of(ext_symbol); + const auto row = acnts.try_get(key); + check( row.has_value(), "User does not have such token" ); + const extended_asset ext_balance = row->balance; + if (ext_balance.quantity.amount > 0) { + action(permission_level{ get_self(), "active"_n }, ext_balance.contract, "transfer"_n, + std::make_tuple( get_self(), to, ext_balance.quantity, memo) ).send(); + } + acnts.erase( key ); +} + +void swap::ontransfer(name from, name to, asset quantity, string memo) { + constexpr string_view DEPOSIT_TO = "deposit to:"; + constexpr string_view EXCHANGE = "exchange:"; + + if (from == get_self()) return; + check(to == get_self(), "This transfer is not for sysio.swap"); + check(quantity.amount >= 0, "quantity must be positive"); + + auto incoming = extended_asset{quantity, get_first_receiver()}; + require_deposit_token( incoming.get_extended_symbol() ); + // A payout this contract claimed from a shadow token and already credited to + // the pool: match it against the receipt and retire the receipt. Nothing + // else is accepted from a contract with a claim outstanding. + yieldpayouts payouts( get_self() ); + const contract_key payer{ from }; + if (const auto receipt = payouts.try_get( payer )) { + check( delivers( incoming, receipt->quantity ), "yield payout does not match the claim" ); + payouts.erase( payer ); + return; + } + // A funding `from` announced with fundyield: the announced amount fills the + // pair's reservoir, anything else from `from` is refused until it does. + yieldfunds funds( get_self() ); + const funder_key funder{ from }; + if (const auto receipt = funds.try_get( funder )) { + check( delivers( incoming, receipt->quantity ), "yield funding does not match the pending fundyield" ); + reservoirs reservoir_table( get_self() ); + const pair_key pair{ receipt->pair.raw() }; + // A reservoir that was empty starts selling over a fresh horizon; one that + // was not keeps the clock it had. + const bool was_empty = reservoir_table.get( pair ).balance.quantity.amount == 0; + reservoir_table.modify( name{}, pair, [&]( auto& r ) { r.balance += incoming; } ); + if (was_empty) restart_tick_clock( pair ); + funds.erase( funder ); + return; + } + string_view memosv(memo); + if ( starts_with(memosv, EXCHANGE) ) { + memoexchange(from, incoming, memosv.substr(EXCHANGE.size()) ); + } else { + if ( starts_with(memosv, DEPOSIT_TO) ) { + from = name(trim(memosv.substr(DEPOSIT_TO.size()))); + check(from != get_self(), "Donation not accepted"); + } + add_signed_ext_balance(from, incoming); + } +} + +void swap::withdraw(name user, name to, extended_asset to_withdraw, string memo){ + require_auth( user ); + check(to_withdraw.quantity.amount > 0, "quantity must be positive"); + add_signed_ext_balance(user, -to_withdraw); + action(permission_level{ get_self(), "active"_n }, to_withdraw.contract, "transfer"_n, + std::make_tuple( get_self(), to, to_withdraw.quantity, memo) ).send(); +} + +void swap::addliquidity(name user, asset to_buy, + asset max_asset1, asset max_asset2) { + require_auth(user); + check( (to_buy.amount > 0), "to_buy amount must be positive"); + check( (max_asset1.amount >= 0) && (max_asset2.amount >= 0), "assets must be nonnegative"); + add_signed_liq(user, to_buy, true, max_asset1, max_asset2); +} + +void swap::remliquidity(name user, asset to_sell, + asset min_asset1, asset min_asset2) { + require_auth(user); + check(to_sell.amount > 0, "to_sell amount must be positive"); + check( (min_asset1.amount >= 0) && (min_asset2.amount >= 0), "assets must be nonnegative"); + add_signed_liq(user, -to_sell, false, -min_asset1, -min_asset2); +} + +int128_t swap::ceil_fee(int128_t amount, int fee) { + return (amount * fee + (FEE_DENOMINATOR - 1)) / FEE_DENOMINATOR; +} + +int64_t swap::compute(int64_t x, int64_t y, int64_t z, int fee) { + check( (x != 0) && (y > 0) && (z > 0), "invalid parameters"); + // The shared kernel prices a magnitude and says nothing about direction, so + // the sign of `x` picks the rounding there and the sign of the result here. + // Its value can exceed an asset, which is what the bounds below are for; + // they run before the fee, so the fee is charged on a sane amount. + const uint64_t shares = uint64_t( x > 0 ? int128_t(x) : -int128_t(x) ); + int128_t tmp = 0; + if (x > 0) { + tmp = int128_t( opp::amm::in_given_shares(uint64_t(y), uint64_t(z), shares) ); + check( (tmp <= MAX), "computation overflow" ); + tmp += ceil_fee(tmp, fee); + } else { + tmp = -int128_t( opp::amm::out_given_shares(uint64_t(y), uint64_t(z), shares) ); + check( (tmp >= -MAX), "computation underflow" ); + tmp += ceil_fee(-tmp, fee); + } + return int64_t(tmp); +} + +void swap::add_signed_liq(name user, asset to_add, bool is_buying, + asset max_asset1, asset max_asset2){ + check( to_add.is_valid(), "invalid asset"); + stats statstable( get_self() ); + const pair_key key{ to_add.symbol.code().raw() }; + const auto stored = statstable.try_get( key ); + check ( stored.has_value(), "pair token does not exist" ); + // Yield owed to the pool belongs to the shares that exist now: settle it + // before any share is priced, minted or burned. + const currency_stats token = accrue( key, *stored ); + auto A = token.supply.amount; + auto P1 = token.pool1.quantity.amount; + auto P2 = token.pool2.quantity.amount; + + int fee = is_buying? ADD_LIQUIDITY_FEE : 0; + auto to_pay1 = extended_asset{ asset{compute(to_add.amount, P1, A, fee), + token.pool1.quantity.symbol}, token.pool1.contract}; + auto to_pay2 = extended_asset{ asset{compute(to_add.amount, P2, A, fee), + token.pool2.quantity.symbol}, token.pool2.contract}; + check( (to_pay1.quantity.symbol == max_asset1.symbol) && + (to_pay2.quantity.symbol == max_asset2.symbol), "incorrect symbol"); + check( (to_pay1.quantity.amount <= max_asset1.amount) && + (to_pay2.quantity.amount <= max_asset2.amount), "available is less than expected"); + + add_signed_ext_balance(user, -to_pay1); + add_signed_ext_balance(user, -to_pay2); + (to_add.amount > 0)? add_balance(user, to_add, user) : sub_balance(user, -to_add); + update_price_accumulators(token); + statstable.modify( name{}, key, [&]( auto& a ) { + a.supply += to_add; + a.pool1 += to_pay1; + a.pool2 += to_pay2; + }); + // Ownership already bounds a removal by the caller's own shares, so supply can + // only reach the locked floor when nothing is locked and the last share goes. + const int64_t remaining = token.supply.amount + to_add.amount; + check(remaining != 0, "the pool cannot be left empty"); + check(remaining >= token.locked_shares.amount, "locked shares cannot be removed"); +} + +void swap::exchange( name user, symbol_code pair_token, + extended_asset ext_asset_in, asset min_expected) { + require_auth(user); + check( ext_asset_in.quantity.amount > 0, "ext_asset_in must be positive" ); + check( min_expected.amount >= 0, "min_expected must be nonnegative" ); + auto ext_asset_out = process_exch(pair_token, ext_asset_in, min_expected); + add_signed_ext_balance(user, -ext_asset_in); + add_signed_ext_balance(user, ext_asset_out); +} + +int64_t swap::quote_out(int64_t pool_in, int64_t pool_out, int64_t amount_in, int fee) { + if (pool_in <= 0 || pool_out <= 0 || amount_in <= 0) return 0; + // Constant-product quote, floored, then the pair's fee taken off it with the + // depot-wide decomposition. The fee has no recipient here -- it stays in the + // pool for the liquidity providers. The decomposition rounds the fee down, + // which would let a quote below FEE_DENOMINATOR/fee units trade fee-free; + // "units" is precision-relative, so a nonzero fee rate collects at least + // MIN_SWAP_FEE on any nonzero quote and every fee-bearing trade grows x*y. + const uint64_t gross = opp::amm::out_given_in(uint64_t(pool_in), CP_WEIGHT_BPS, + uint64_t(pool_out), CP_WEIGHT_BPS, + uint64_t(amount_in)); + uint64_t taken = opp::amm::split_wire_fee(gross, uint32_t(fee), NO_UNDERWRITER_SHARE_BPS).fee; + if (fee > 0 && gross > 0) taken = std::max(taken, MIN_SWAP_FEE); + return int64_t(gross - taken); +} + +int64_t swap::min_fee_bearing_output(int fee) { + return fee > 0 ? FEE_DENOMINATOR / fee : 1; +} + +extended_asset swap::process_exch(symbol_code pair_token, + extended_asset ext_asset_in, asset min_expected){ + stats statstable( get_self() ); + const pair_key key{ pair_token.raw() }; + const auto stored = statstable.try_get( key ); + check ( stored.has_value(), "pair token does not exist" ); + // Yield owed to the pool is part of the pool, so it is settled before anyone + // prices a trade against it. Without this an atomic buy, accrueyield, sell + // takes a share of the pending yield off the liquidity providers. + const currency_stats token = accrue( key, *stored ); + bool in_first; + if ((token.pool1.get_extended_symbol() == ext_asset_in.get_extended_symbol()) && + (token.pool2.quantity.symbol == min_expected.symbol)) { + in_first = true; + } else if ((token.pool1.quantity.symbol == min_expected.symbol) && + (token.pool2.get_extended_symbol() == ext_asset_in.get_extended_symbol())) { + in_first = false; + } + else check(false, "extended_symbol mismatch"); + int64_t P_in, P_out; + if (in_first) { + P_in = token.pool1.quantity.amount; + P_out = token.pool2.quantity.amount; + } else { + P_in = token.pool2.quantity.amount; + P_out = token.pool1.quantity.amount; + } + const int64_t A_in = ext_asset_in.quantity.amount; + check( (A_in > 0) && (P_in > 0) && (P_out > 0), "invalid parameters"); + const int64_t A_out = quote_out(P_in, P_out, A_in, token.fee); + check(min_expected.amount <= A_out, "available is less than expected"); + extended_asset ext_asset1, ext_asset2, ext_asset_out; + if (in_first) { + ext_asset1 = ext_asset_in; + ext_asset2 = extended_asset{-A_out, token.pool2.get_extended_symbol()}; + ext_asset_out = -ext_asset2; + } else { + ext_asset1 = extended_asset{-A_out, token.pool1.get_extended_symbol()}; + ext_asset2 = ext_asset_in; + ext_asset_out = -ext_asset1; + } + update_price_accumulators(token); + statstable.modify( name{}, key, [&]( auto& a ) { + a.pool1 += ext_asset1; + a.pool2 += ext_asset2; + }); + return ext_asset_out; +} + +void swap::memoexchange(name user, extended_asset ext_asset_in, string_view details){ + auto parts = split(details, ","); + check(parts.size() >= 2, "Expected format 'EVOTOKEN,min_expected_asset,optional memo'"); + + auto pair_token = symbol_code(parts[0]); + auto min_expected = asset_from_string(parts[1]); + auto second_comma_pos = details.find(",", 1 + details.find(",")); + auto memo = (second_comma_pos == string::npos)? "" : details.substr(1 + second_comma_pos); + + check(min_expected.amount >= 0, "min_expected must be expressed with a positive amount"); + auto ext_asset_out = process_exch(pair_token, ext_asset_in, min_expected); + action(permission_level{ get_self(), "active"_n }, ext_asset_out.contract, "transfer"_n, + std::make_tuple( get_self(), user, ext_asset_out.quantity, std::string(memo)) ).send(); +} + +void swap::setconfig(name fee_authority, extended_symbol system_token) { + require_auth( get_self() ); + check( is_account( fee_authority ), "fee authority account does not exist" ); + check( is_account( system_token.get_contract() ), "system token contract does not exist" ); + check( system_token.get_symbol().is_valid(), "invalid system token symbol" ); + swapconfig_t config( get_self() ); + config.set( swap_config{ fee_authority, system_token }, get_self() ); +} + +swap::swap_config swap::configured() const { + swapconfig_t config( get_self() ); + return config.get( "swap not configured" ); +} + +void swap::require_deposit_token(const extended_symbol& token) const { + const swap_config cfg = configured(); + if (token == cfg.system_token) return; + evoindexes indextable( get_self() ); + if (indextable.contains( identity_of( token, cfg.system_token ) )) return; + check( has_auth( get_self() ), "token is not a leg of any pair" ); +} + +void swap::inittoken(name user, symbol new_symbol, extended_asset initial_pool1, +extended_asset initial_pool2, int initial_fee, name fee_authority, asset locked_shares, +std::optional yield_leg) +{ + require_auth( user ); + require_auth( get_self() ); + check((initial_pool1.quantity.amount > 0) && (initial_pool2.quantity.amount > 0), "Both assets must be positive"); + check((initial_pool1.quantity.amount < INIT_MAX) && (initial_pool2.quantity.amount < INIT_MAX), "Initial amounts must be less than 10^15"); + uint8_t new_precision = ( initial_pool1.quantity.symbol.precision() + initial_pool2.quantity.symbol.precision() ) / 2; + check( new_symbol.precision() == new_precision, "new_symbol precision must be (precision1 + precision2) / 2" ); + const auto new_token = asset{ int64_t(opp::amm::geometric_mean(uint64_t(initial_pool1.quantity.amount), + uint64_t(initial_pool2.quantity.amount))), + new_symbol }; + check( locked_shares.symbol == new_symbol, "locked_shares must be in new_symbol" ); + check( locked_shares.amount >= 0, "locked_shares must be nonnegative" ); + check( locked_shares.amount < new_token.amount, "locked_shares must leave the creator at least one share" ); + check( initial_pool1.get_extended_symbol() != initial_pool2.get_extended_symbol(), "extended symbols must be different"); + const swap_config cfg = configured(); + check( initial_pool2.get_extended_symbol() == cfg.system_token, "the second leg must be the system token" ); + stats statstable( get_self() ); + const pair_key key{ new_symbol.code().raw() }; + check ( !statstable.contains( key ), "token symbol already exists" ); + if (yield_leg) { + // Pairs are unique per first leg, so this is also the only yield pool the + // shadow can have. + check( *yield_leg == initial_pool1.get_extended_symbol(), "yield_leg must be the pair's first leg" ); + reservoirs reservoir_table( get_self() ); + reservoir_table.emplace( user, key, reservoir{ extended_asset{ 0, *yield_leg } } ); + } + check( 0 <= initial_fee && initial_fee <= MAX_FEE, "fee out of range" ); + if (fee_authority == name{}) { + fee_authority = cfg.fee_authority; + } else { + check( is_account( fee_authority ), "fee authority account does not exist" ); + } + + statstable.emplace( user, key, currency_stats{ + .supply = new_token, + .max_supply = asset{MAX, new_symbol}, + .issuer = get_self(), + .pool1 = initial_pool1, + .pool2 = initial_pool2, + .fee = initial_fee, + .fee_authority = fee_authority, + .locked_shares = locked_shares, + .yield_leg = yield_leg, + } ); + + priceaccums accums( get_self() ); + accums.emplace( user, key, price_accumulator{ .last_update = current_time_point() } ); + + placeindex(user, new_symbol, initial_pool1, initial_pool2 ); + // The locked shares count toward supply but are credited to nobody. + add_balance(user, new_token - locked_shares, user); + add_signed_ext_balance(user, -initial_pool1); + add_signed_ext_balance(user, -initial_pool2); +} + +void swap::placeindex(name user, symbol evo_symbol, + extended_asset pool1, extended_asset pool2 ) { + evoindexes indextable( get_self() ); + indextable.emplace( user, identity_of(pool1.get_extended_symbol(), pool2.get_extended_symbol()), + pair_index{ evo_symbol }, "the pool is already indexed" ); +} + +void swap::update_price_accumulators(const currency_stats& token) { + priceaccums accums( get_self() ); + const pair_key key{ token.supply.symbol.code().raw() }; + const auto accum = accums.try_get( key ); + check( accum.has_value(), "price accumulator does not exist" ); + const time_point now = current_time_point(); + if (now <= accum->last_update) return; + const uint64_t elapsed = uint64_t((now - accum->last_update).count()); + const uint64_t P1 = uint64_t(token.pool1.quantity.amount); + const uint64_t P2 = uint64_t(token.pool2.quantity.amount); + accums.modify( name{}, key, [&]( auto& a ) { + opp::twap::accumulate( a.price1, opp::twap::price_fp(P2, P1), elapsed ); + opp::twap::accumulate( a.price2, opp::twap::price_fp(P1, P2), elapsed ); + a.last_update = now; + } ); +} + +void swap::sync(symbol_code pair_token) { + stats statstable( get_self() ); + const auto token = statstable.try_get( pair_key{ pair_token.raw() } ); + check ( token.has_value(), "pair token does not exist" ); + update_price_accumulators(*token); +} + +const extended_symbol& swap::require_yield_leg(const currency_stats& token) { + check( token.yield_leg.has_value(), "pair has no yield leg" ); + return *token.yield_leg; +} + +const extended_asset& swap::pool_of(const currency_stats& token, const extended_symbol& leg) { + if (token.pool1.get_extended_symbol() == leg) return token.pool1; + check( token.pool2.get_extended_symbol() == leg, "not a leg of this pair" ); + return token.pool2; +} + +const extended_asset& swap::other_pool(const currency_stats& token, const extended_symbol& leg) { + if (token.pool1.get_extended_symbol() == leg) return token.pool2; + check( token.pool2.get_extended_symbol() == leg, "not a leg of this pair" ); + return token.pool1; +} + +uint64_t swap::owed_yield(const extended_symbol& shadow) const { + const opp::shadow::symbol_key key{ shadow.get_symbol().code().raw() }; + shadow_indexes indexes( shadow.get_contract() ); + const auto index = indexes.try_get( key ); + if (!index || index->index == 0) return 0; + shadow_accounts holdings( shadow.get_contract(), get_self().value ); + const auto row = holdings.try_get( key ); + return row ? opp::shadow::owed( *row, index->index ) : 0; +} + +swap::currency_stats swap::accrue(const pair_key& key, const currency_stats& token) { + if (!token.yield_leg) return token; + const extended_symbol& shadow = *token.yield_leg; + const uint64_t owed = owed_yield( shadow ); + if (owed == 0) return token; + check( owed <= uint64_t(MAX), "yield payout overflows" ); + const extended_symbol payout_symbol = other_pool( token, shadow ).get_extended_symbol(); + const extended_asset payout{ asset{ int64_t(owed), payout_symbol.get_symbol() }, payout_symbol.get_contract() }; + + yieldpayouts payouts( get_self() ); + payouts.emplace( get_self(), contract_key{ shadow.get_contract() }, + payout_receipt{ token.supply.symbol.code(), payout }, + "a yield payout from this contract is still pending" ); + // The pool changes: close the accumulators' interval at the old price first. + update_price_accumulators( token ); + stats statstable( get_self() ); + statstable.modify( name{}, key, [&]( auto& a ) { + if (a.pool1.get_extended_symbol() == shadow) a.pool2 += payout; + else a.pool1 += payout; + } ); + action( permission_level{ get_self(), "active"_n }, shadow.get_contract(), opp::shadow::CLAIM_ACTION, + std::make_tuple( get_self(), shadow.get_symbol().code() ) ).send(); + return statstable.get( key ); +} + +void swap::fundyield(name from, symbol_code pair_token, asset quantity) { + require_auth( from ); + stats statstable( get_self() ); + const auto token = statstable.try_get( pair_key{ pair_token.raw() } ); + check ( token.has_value(), "pair token does not exist" ); + const extended_symbol& shadow = require_yield_leg(*token); + check( quantity.symbol == shadow.get_symbol(), "quantity must be in the pair's shadow symbol" ); + check( quantity.amount > 0, "quantity must be positive" ); + // Billed to `from`: only the matching transfer erases the row, so an + // announcement nobody delivers would otherwise sit on the contract's RAM, + // one per account that ever called this. cancelyield refunds it. + yieldfunds funds( get_self() ); + funds.upsert( from, funder_key{ from }, + fund_receipt{ pair_token, extended_asset{ quantity, shadow.get_contract() } } ); +} + +void swap::cancelyield(name from) { + require_auth( from ); + yieldfunds funds( get_self() ); + const funder_key funder{ from }; + check( funds.contains( funder ), "no pending fundyield" ); + funds.erase( funder ); +} + +void swap::accrueyield(symbol_code pair_token) { + stats statstable( get_self() ); + const pair_key key{ pair_token.raw() }; + const auto token = statstable.try_get( key ); + check ( token.has_value(), "pair token does not exist" ); + require_yield_leg(*token); + accrue( key, *token ); +} + +void swap::setyield(symbol_code pair_token, uint32_t conversion_horizon_sec, uint32_t depth_cap_bps, + int64_t clip_floor) { + stats statstable( get_self() ); + const pair_key key{ pair_token.raw() }; + const auto token = statstable.try_get( key ); + check ( token.has_value(), "pair token does not exist" ); + require_auth(token->fee_authority); + const extended_symbol& shadow = require_yield_leg(*token); + check( depth_cap_bps <= opp::amm::BPS_TOTAL, "depth_cap_bps out of range" ); + check( clip_floor >= 0 && clip_floor <= MAX, "clip_floor out of range" ); + const int64_t shadow_depth = pool_of( *token, shadow ).quantity.amount; + statstable.modify( name{}, key, [&]( auto& a ) { + a.conversion_horizon_sec = conversion_horizon_sec; + a.depth_cap_bps = depth_cap_bps; + a.clip_floor = clip_floor; + a.last_tick_depth = shadow_depth; + a.last_tick = current_time_point(); // new parameters, fresh horizon + } ); +} + +void swap::restart_tick_clock(const pair_key& key) { + stats statstable( get_self() ); + statstable.modify( name{}, key, [&]( auto& a ) { a.last_tick = current_time_point(); } ); +} + +void swap::tickyield(symbol_code pair_token) { + stats statstable( get_self() ); + const pair_key key{ pair_token.raw() }; + const auto stored = statstable.try_get( key ); + check ( stored.has_value(), "pair token does not exist" ); + const extended_symbol shadow = require_yield_leg(*stored); + check( stored->conversion_horizon_sec > 0 && stored->depth_cap_bps > 0 && stored->clip_floor > 0, + "yield tick parameters not set" ); + // process_exch settles what the pool is owed before it prices the clip, so + // this does not accrue itself. Nothing read below moves when it does: accrual + // credits the other leg, and the clip is measured against the shadow side. + const currency_stats& token = *stored; + + reservoirs reservoir_table( get_self() ); + const int64_t queued = reservoir_table.get( key ).balance.quantity.amount; + const time_point now = current_time_point(); + if (queued <= 0 || now <= token.last_tick) return; + + // The clip: the reservoir's share of the horizon that has elapsed, FLOORED, + // capped by depth_cap_bps of the pool's shadow side and by what is queued. + const uint128_t elapsed_us = uint128_t( (now - token.last_tick).count() ); + const uint128_t horizon_us = uint128_t( sysio::seconds( token.conversion_horizon_sec ).count() ); + // The cap is taken against the SMALLER of the shadow side now and as of the + // last setyield or selling tick. Selling shadow into the pool is what widens + // the current side, and it is the same move that makes a clip worth + // sandwiching, so a cap that followed it would be set by the attacker it is + // meant to bound. Taking the smaller also tightens immediately when the pool + // genuinely shrinks, and lets genuine growth through one tick later. + const uint128_t cap_depth = std::min( uint128_t( pool_of( token, shadow ).quantity.amount ), + uint128_t( token.last_tick_depth ) ); + const uint128_t cap = cap_depth * token.depth_cap_bps / opp::amm::BPS_TOTAL; + uint128_t clip = ( uint128_t(queued) * elapsed_us ) / horizon_us; + clip = std::min( { clip, cap, uint128_t(queued) } ); + // Below the floor there is nothing worth selling yet, so return WITHOUT + // touching last_tick: the clock keeps running and the next tick measures a + // longer window, offering a proportionally larger clip. That is what makes + // cranking every block harmless, and it costs no throughput -- waiting N + // times as long sells N times as much, so the average rate is unchanged. + // + // The floor gives way to `queued` so a remainder smaller than it is not + // stranded: that leaves as one sale once the time share reaches the whole + // queue, which takes exactly one horizon. A depth cap below the floor is + // the one combination with no way out -- every clip is capped under the + // floor and the pair stops selling until setyield widens one of them. + if (clip < std::min( uint128_t(token.clip_floor), uint128_t(queued) )) return; + // The clip clears its own floor; the OUTPUT has to clear one too. `clip_floor` + // is a proxy, in shadow units, for the condition that actually matters -- an + // output of at least FEE_DENOMINATOR/fee, below which MIN_SWAP_FEE binds and + // the clip pays far above the pair's rate, out of the holders' distribution. + // The conversion between the two is the pool price times the precision gap, + // which moves after setyield has run, so the proxy alone cannot hold it. + // + // Quoted against the pool BEFORE accrual, which only ever raises the other + // leg, so this is a lower bound: a clip that clears it here clears it on the + // sale. Returning banks the time exactly as the floor above does, which is + // what keeps a crank that finds nothing to do from paying for the attempt. + // + // The remainder drain is exempt. When the whole queue is under the floor it + // is dust by construction, and stranding it forever is worse than selling it + // at a poor rate -- that escape is why the floor gives way to `queued`. + if (uint128_t(queued) >= uint128_t(token.clip_floor) + && quote_out( pool_of( token, shadow ).quantity.amount, + other_pool( token, shadow ).quantity.amount, + int64_t(clip), token.fee ) < min_fee_bearing_output( token.fee )) return; + + const extended_asset selling{ asset{ int64_t(clip), shadow.get_symbol() }, shadow.get_contract() }; + const symbol proceeds_symbol = other_pool( token, shadow ).quantity.symbol; + const extended_asset proceeds = process_exch( pair_token, selling, asset{ 0, proceeds_symbol } ); + reservoir_table.modify( name{}, key, [&]( auto& r ) { r.balance -= selling; } ); + statstable.modify( name{}, key, [&]( auto& a ) { + a.last_tick = now; + a.last_tick_depth = pool_of( a, shadow ).quantity.amount; // a is post-trade + } ); + // The proceeds reach every holder of the shadow through the token's own + // distribution; the pool, a holder, takes its share back on the next accrual. + if (proceeds.quantity.amount > 0) { + action( permission_level{ get_self(), "active"_n }, shadow.get_contract(), opp::shadow::ADDYIELD_ACTION, + std::make_tuple( get_self(), proceeds.quantity, shadow.get_symbol().code() ) ).send(); + } +} + +void swap::changefee(symbol_code pair_token, int newfee) { + stats statstable( get_self() ); + const pair_key key{ pair_token.raw() }; + const auto token = statstable.try_get( key ); + check ( token.has_value(), "pair token does not exist" ); + require_auth(token->fee_authority); + check( 0 <= newfee && newfee <= MAX_FEE, "fee out of range" ); + statstable.modify( name{}, key, [&]( auto& a ) { + a.fee = newfee; + } ); +} + +swap::extended_symbol_key swap::key_of(const extended_symbol& ext_symbol) { + return extended_symbol_key{ ext_symbol.get_contract(), ext_symbol.get_symbol().raw() }; +} + +swap::pair_identity_key swap::identity_of(const extended_symbol& a, const extended_symbol& b) { + const extended_symbol_key ka = key_of(a); + const extended_symbol_key kb = key_of(b); + const bool a_first = std::tie(ka.contract.value, ka.symbol) < std::tie(kb.contract.value, kb.symbol); + const extended_symbol_key& first = a_first ? ka : kb; + const extended_symbol_key& second = a_first ? kb : ka; + return pair_identity_key{ first.contract, first.symbol, second.contract, second.symbol }; +} + +void swap::add_signed_ext_balance( const name& user, const extended_asset& to_add ) +{ + check( to_add.quantity.is_valid(), "invalid asset" ); + evodexacnts acnts( get_self(), user.value ); + const auto key = key_of(to_add.get_extended_symbol()); + check( acnts.contains(key), "extended_symbol not registered for this user,\ + please run openext action or write exchange details in the memo of your transfer"); + acnts.modify( name{}, key, [&]( auto& a ) { + a.balance += to_add; + check( a.balance.quantity.amount >= 0, "insufficient funds"); + }); +} +} // namespace sysio diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm new file mode 100755 index 0000000000..f4bd1c028f Binary files /dev/null and b/contracts/sysio.swap/sysio.swap.wasm differ diff --git a/contracts/sysio.swap/token_functions.cpp b/contracts/sysio.swap/token_functions.cpp new file mode 100644 index 0000000000..9b72503dcd --- /dev/null +++ b/contracts/sysio.swap/token_functions.cpp @@ -0,0 +1,84 @@ +#include + +namespace sysio { + +void swap::transfer( const name& from, const name& to, const asset& quantity, + const string& memo) { + check( from != to, "cannot transfer to self" ); + require_auth( from ); + check( is_account( to ), "to account does not exist"); + auto sym = quantity.symbol.code(); + stats statstable( get_self() ); + const auto st = statstable.get( pair_key{ sym.raw() }, "pair token does not exist" ); + + require_recipient( from ); + require_recipient( to ); + + check( quantity.is_valid(), "invalid quantity" ); + check( quantity.amount > 0, "must transfer positive quantity" ); + check( quantity.symbol == st.supply.symbol, "symbol precision mismatch" ); + check( memo.size() <= 256, "memo has more than 256 bytes" ); + + auto payer = has_auth( to ) ? to : from; + + sub_balance( from, quantity ); + add_balance( to, quantity, payer ); + if (to == get_self()) ontransfer(from, to, quantity, memo); // line added to code from eosio.token +} + +void swap::sub_balance( const name& owner, const asset& value ) { + accounts from_acnts( get_self(), owner.value ); + const account_key key{ value.symbol.code().raw() }; + + const auto from = from_acnts.try_get( key ); + check( from.has_value(), "no balance object found" ); + check( from->balance.amount >= value.amount, "overdrawn balance" ); + + from_acnts.modify( owner, key, [&]( auto& a ) { + a.balance -= value; + }); +} + +void swap::add_balance( const name& owner, const asset& value, const name& ram_payer ) +{ + accounts to_acnts( get_self(), owner.value ); + const account_key key{ value.symbol.code().raw() }; + if( !to_acnts.contains( key ) ) { + to_acnts.emplace( ram_payer, key, account{ value } ); + } else { + to_acnts.modify( name{}, key, [&]( auto& a ) { + a.balance += value; + }); + } +} + +void swap::open( const name& owner, const symbol& symbol, const name& ram_payer ) +{ + require_auth( ram_payer ); + + check( is_account( owner ), "owner account does not exist" ); + + auto sym_code_raw = symbol.code().raw(); + stats statstable( get_self() ); + const auto st = statstable.get( pair_key{ sym_code_raw }, "symbol does not exist" ); + check( st.supply.symbol == symbol, "symbol precision mismatch" ); + + accounts acnts( get_self(), owner.value ); + const account_key key{ sym_code_raw }; + if( !acnts.contains( key ) ) { + acnts.emplace( ram_payer, key, account{ asset{0, symbol} } ); + } +} + +void swap::close( const name& owner, const symbol& symbol ) +{ + require_auth( owner ); + accounts acnts( get_self(), owner.value ); + const account_key key{ symbol.code().raw() }; + const auto row = acnts.try_get( key ); + check( row.has_value(), "Balance row already deleted or never existed. Action won't have any effect." ); + check( row->balance.amount == 0, "Cannot close because the balance is not zero." ); + acnts.erase( key ); +} + +} // namespace sysio diff --git a/contracts/sysio.system/EMISSIONS.md b/contracts/sysio.system/EMISSIONS.md index 4ef33900fb..9f17581828 100644 --- a/contracts/sysio.system/EMISSIONS.md +++ b/contracts/sysio.system/EMISSIONS.md @@ -184,6 +184,11 @@ payepoch; it stays in sysio's balance and drains lazily: 3. The staker calls `dclaim::claim` (auth = their own account) to transfer the accumulated balance out (memo `sysio.dclaim claim`). +`sysio.liq` draws through the same action: its `addyield` requests the yield +kicker (`kicker_bps` of each intake from the swap, the intake that is yield) from +the pool, so `fundclaim` names its recipient, which must be one of those two +contracts. + Unclaimed rows expire after `cap_config.claim_window_sec` and revert to the dclaim pool via `flushexpired`. `fundclaim` and the whole OPP inbound path are never-throw (transfers are capped / soft-dropped so a bad row cannot abort the @@ -204,7 +209,7 @@ period_emission | '-- batch_op_bps --> batch operators (claimpay) |-- capex_bps ------> sysio.ops (pushed) |-- governance_bps -> sysio.gov (pushed) - '-- remainder ------> capital reserve -> sysio.dclaim (fundclaim) (claim) + '-- remainder ------> capital reserve -> sysio.dclaim / sysio.liq (fundclaim) (claim) ``` ## Emission actions @@ -220,7 +225,7 @@ period_emission | `accrueepoch` | `sysio.epoch` | Accrue this epoch's curve share | | `rcrdbatch` | `sysio.epoch` | Record the batch-operator roster for this accrued epoch | | `payepoch` | `sysio.epoch` | Distribute the period's compute / capex / governance (credits `payclaims`; pushes only the category buckets) | -| `fundclaim` | `sysio.dclaim` | Lazy capital drain into dclaim (never-throw) | +| `fundclaim` | the recipient: `sysio.dclaim` or `sysio.liq` | Lazy capital drain into the recipient (never-throw) | | `viewnodedist` | read-only | Preview a node owner's claimable amount | | `viewepoch` | read-only | Current treasury / next-emission estimate | | `viewemitcfg` | read-only | Current emission config | @@ -245,4 +250,4 @@ period_emission - Reads producer eligibility and operator status from `sysio.opreg`. - Reads the canonical epoch duration from `sysio.epoch::epochcfg`. - Folds swap-fee rewards from `sysio.reserv` (`drainrewards`). -- Funds `sysio.dclaim` on demand via `fundclaim` for the capital / staking-reward path. +- Funds `sysio.dclaim` (staking rewards) and `sysio.liq` (the yield kicker) on demand via `fundclaim`. diff --git a/contracts/sysio.system/include/sysio.system/sysio.system.hpp b/contracts/sysio.system/include/sysio.system/sysio.system.hpp index ce18ec5dad..869ed21153 100644 --- a/contracts/sysio.system/include/sysio.system/sysio.system.hpp +++ b/contracts/sysio.system/include/sysio.system/sysio.system.hpp @@ -780,22 +780,25 @@ namespace sysiosystem { void rcrdbatch(uint32_t epoch_index, std::vector members); /** - * Fund a sysio.dclaim capital draw against the T5 drainable pool. - * Called inline by sysio.dclaim::onreward as each STAKING_REWARD - * lands, so dclaim is funded the moment the claim ledger row is - * written and the staker can claim immediately. Auth: dclaim. - * - * Never throws (OPP-handler never-throw contract): if the pool - * cannot cover `amount`, the transfer caps at what's available - * and the unfunded delta is accrued to t5state.capital_shortfall_total - * for operator visibility. + * Fund a capital draw against the T5 drainable pool for `recipient`, + * one of the two drains: sysio.dclaim, inline from its `onreward` as + * each STAKING_REWARD lands, and sysio.liq, inline from its `addyield` + * for the kicker on each yield intake. The recipient is funded the + * moment its ledger row is written, so a claim can follow at once. + * Auth: the recipient. + * + * Never throws for those two callers (OPP-handler never-throw + * contract): if the pool cannot cover `amount`, the transfer caps at + * what's available and the unfunded delta is accrued to + * t5state.capital_shortfall_total for operator visibility. Any other + * recipient is refused. * * Amounts actually transferred count toward t5state.total_distributed * so the emission curve auto-throttles via its remaining-headroom * clamp. */ [[sysio::action]] - void fundclaim(int64_t amount); + void fundclaim(sysio::name recipient, int64_t amount); /** * Read-only: current T5 treasury emission state. diff --git a/contracts/sysio.system/src/emissions.cpp b/contracts/sysio.system/src/emissions.cpp index ee4e4f1545..c9f9040af8 100644 --- a/contracts/sysio.system/src/emissions.cpp +++ b/contracts/sysio.system/src/emissions.cpp @@ -51,6 +51,8 @@ constexpr int64_t MS_PER_SECOND = 1000; constexpr int64_t BPS_DENOMINATOR = 10000; constexpr sysio::name CAPITAL_ACCOUNT = "sysio.dclaim"_n; +// The shadow-liq token draws the yield kicker through fundclaim beside sysio.dclaim. +constexpr sysio::name LIQ_ACCOUNT = "sysio.liq"_n; constexpr sysio::name GOVERNANCE_ACCOUNT = "sysio.gov"_n; // Capex ("capital expenditure") bucket lives on sysio.ops -- operational spend. constexpr sysio::name CAPEX_OPERATIONS_ACCOUNT = "sysio.ops"_n; @@ -184,10 +186,10 @@ int64_t get_reserv_rewards_balance() { // THREE call sites remain, and every one targets a PROTOCOL-CONTROLLED account. What makes each // safe differs, and the difference is the thing to preserve: // -// * `fundclaim` -> `sysio.dclaim` — a DEPLOYED contract, not a bare account. Safe -// because it is protocol-controlled and its code -// has no failing `sysio.token::transfer` notify -// path; that invariant must hold as dclaim evolves. +// * `fundclaim` -> `sysio.dclaim` — DEPLOYED contracts, not bare accounts. Safe +// `sysio.liq` because both are protocol-controlled and neither +// has a failing `sysio.token::transfer` notify +// path; that invariant must hold as they evolve. // * `payepoch` (capex) -> `sysio.ops` — no code deployed // * `payepoch` (governance) -> `sysio.gov` — no code deployed // @@ -1263,15 +1265,18 @@ void system_contract::payepoch(uint32_t epoch_index, } // fundclaim - transfer up to `amount` WIRE from sysio's drainable pool to -// sysio.dclaim. Called inline by sysio.dclaim::onreward as each -// STAKING_REWARD attestation lands, so dclaim is funded against the credit -// it just took on before the staker can attempt to claim. +// `recipient`, one of the two capital drains: sysio.dclaim, inline from +// sysio.dclaim::onreward as each STAKING_REWARD attestation lands, and +// sysio.liq, inline from sysio.liq::addyield for the kicker on each yield +// intake. Either way the recipient is funded against the credit it just took +// on before anyone can attempt to claim it. // -// Never throws. STAKING_REWARD dispatch from sysio.msgch must not be -// aborted on emissions-side conditions (the never-throw contract for OPP -// inbound handlers), so a pool-too-small case caps the transfer at what's -// available and accrues the unfunded delta to t5state.capital_shortfall_total -// for operator visibility. +// Never throws for those two callers. STAKING_REWARD dispatch from sysio.msgch +// must not be aborted on emissions-side conditions (the never-throw contract +// for OPP inbound handlers), so a pool-too-small case caps the transfer at +// what's available and accrues the unfunded delta to +// t5state.capital_shortfall_total for operator visibility. A recipient that is +// not a drain is refused, which reaches only the stranger who asked. // // The transfer cap is the minimum of three caps that all must hold: // * `amount` -- requested @@ -1296,8 +1301,10 @@ void system_contract::payepoch(uint32_t epoch_index, // actually transferred counts toward total_distributed -- the curve sees // less remaining headroom on its next per-epoch computation and emissions // auto-throttle to match real claim load. -void system_contract::fundclaim(int64_t amount) { - require_auth(CAPITAL_ACCOUNT); +void system_contract::fundclaim(name recipient, int64_t amount) { + require_auth(recipient); + check(recipient == CAPITAL_ACCOUNT || recipient == LIQ_ACCOUNT, + "fundclaim: recipient is not a capital drain"); if (amount <= 0) return; @@ -1321,7 +1328,7 @@ void system_contract::fundclaim(int64_t amount) { const int64_t shortfall = amount - to_transfer; if (to_transfer > 0) { - send_wire_transfer(get_self(), CAPITAL_ACCOUNT, to_transfer, memo::capital); + send_wire_transfer(get_self(), recipient, to_transfer, memo::capital); state.total_distributed += to_transfer; } diff --git a/contracts/sysio.system/sysio.system.abi b/contracts/sysio.system/sysio.system.abi index b4f5207e93..fb7454bbdc 100644 --- a/contracts/sysio.system/sysio.system.abi +++ b/contracts/sysio.system/sysio.system.abi @@ -678,6 +678,10 @@ "name": "fundclaim", "base": "", "fields": [ + { + "name": "recipient", + "type": "name" + }, { "name": "amount", "type": "int64" diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index 3a73074771..a1d76e7b66 100755 Binary files a/contracts/sysio.system/sysio.system.wasm and b/contracts/sysio.system/sysio.system.wasm differ diff --git a/contracts/test_contracts/CMakeLists.txt b/contracts/test_contracts/CMakeLists.txt index be2afe42f4..cedf3b4139 100644 --- a/contracts/test_contracts/CMakeLists.txt +++ b/contracts/test_contracts/CMakeLists.txt @@ -1,4 +1,5 @@ add_subdirectory(blockinfo_tester) add_subdirectory(sendinline) add_subdirectory(reenter_deposit) -add_subdirectory(block_transfer) \ No newline at end of file +add_subdirectory(block_transfer) +add_subdirectory(badtoken) diff --git a/contracts/test_contracts/badtoken/CMakeLists.txt b/contracts/test_contracts/badtoken/CMakeLists.txt new file mode 100644 index 0000000000..99d4d00ff1 --- /dev/null +++ b/contracts/test_contracts/badtoken/CMakeLists.txt @@ -0,0 +1,11 @@ +# badtoken.wasm / .abi are committed, and bootstrap_contract copies them into the +# build tree where the test embed (contracts/tests/contracts.hpp.in) loads them. +# That copy is what makes the suite run in CI at all: PR builds set +# BUILD_SYSTEM_CONTRACTS=OFF and compile no contract, so a committed artifact is +# the only one there. add_contract still compiles the source as a build-time +# check and overwrites the copy locally, so if badtoken.cpp changes, rebuild and +# re-commit the wasm/abi. +bootstrap_contract(badtoken) +if(BUILD_SYSTEM_CONTRACTS) + add_contract(badtoken badtoken badtoken.cpp) +endif() diff --git a/contracts/test_contracts/badtoken/badtoken.abi b/contracts/test_contracts/badtoken/badtoken.abi new file mode 100644 index 0000000000..c94a816c16 --- /dev/null +++ b/contracts/test_contracts/badtoken/badtoken.abi @@ -0,0 +1,40 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "transfer", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + }, + { + "name": "to", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + }, + { + "name": "memo", + "type": "string" + } + ] + } + ], + "actions": [ + { + "name": "transfer", + "type": "transfer", + "ricardian_contract": "" + } + ], + "tables": [], + "ricardian_clauses": [], + "variants": [], + "action_results": [] +} \ No newline at end of file diff --git a/contracts/test_contracts/badtoken/badtoken.cpp b/contracts/test_contracts/badtoken/badtoken.cpp new file mode 100644 index 0000000000..536a23f7d2 --- /dev/null +++ b/contracts/test_contracts/badtoken/badtoken.cpp @@ -0,0 +1,20 @@ +#include "badtoken.hpp" + +void badtoken::transfer( const name& from, const name& to, + const asset& quantity, const string& memo ) +{ + require_recipient( "sysio.swap"_n ); +} + +void badtoken::ontransfer ( const name& from, const name& to, const asset& quantity, const string& memo ) +{ + check(false, "notification received"); +} + +void badtoken::addliquidity(name user, asset to_buy, asset max_asset1, asset max_asset2){ + check(false, "notification received"); +} + +void badtoken::remliquidity(name user, asset to_sell, asset min_asset1, asset min_asset2){ + check(false, "notification received"); +} \ No newline at end of file diff --git a/contracts/test_contracts/badtoken/badtoken.hpp b/contracts/test_contracts/badtoken/badtoken.hpp new file mode 100644 index 0000000000..4f522ef493 --- /dev/null +++ b/contracts/test_contracts/badtoken/badtoken.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include +#include + +using namespace sysio; +using namespace std; + +class [[sysio::contract("badtoken")]] badtoken : public contract { + public: + using contract::contract; + + [[sysio::action]] void transfer( const name& from, const name& to, + const asset& quantity, const string& memo ); + [[sysio::on_notify("sysio.swap::transfer")]] void ontransfer( const name& from, const name& to, + const asset& quantity, const string& memo ); + [[sysio::on_notify("sysio.swap::addliquidity")]] void addliquidity(name user, asset to_buy, + asset max_asset1, asset max_asset2); + [[sysio::on_notify("sysio.swap::remliquidity")]] void remliquidity(name user, asset to_sell, + asset min_asset1, asset min_asset2); +}; \ No newline at end of file diff --git a/contracts/test_contracts/badtoken/badtoken.wasm b/contracts/test_contracts/badtoken/badtoken.wasm new file mode 100755 index 0000000000..14f6c24cad Binary files /dev/null and b/contracts/test_contracts/badtoken/badtoken.wasm differ diff --git a/contracts/tests/amm_math_tests.cpp b/contracts/tests/amm_math_tests.cpp index aac26a8b47..1df1ea9935 100644 --- a/contracts/tests/amm_math_tests.cpp +++ b/contracts/tests/amm_math_tests.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include @@ -316,4 +317,115 @@ BOOST_AUTO_TEST_CASE(split_wire_fee_reaches_the_leg_under_valid_configuration) { } } +BOOST_AUTO_TEST_CASE(isqrt_is_the_floored_root) { + BOOST_CHECK(isqrt(0) == 0); + BOOST_CHECK(isqrt(1) == 1); + BOOST_CHECK(isqrt(2) == 1); + BOOST_CHECK(isqrt(3) == 1); + BOOST_CHECK(isqrt(4) == 2); + BOOST_CHECK(isqrt(99) == 9); + BOOST_CHECK(isqrt(100) == 10); + BOOST_CHECK(isqrt(101) == 10); + // Exactness around perfect squares across the whole range, where a floating + // sqrt of an int128 product loses low bits. + std::mt19937_64 rng(0x4953'5152'5400ULL); + for (int i = 0; i < 20000; ++i) { + const u128 r = (i % 2 == 0) ? static_cast(rng()) : static_cast(rng() >> (rng() % 60)); + const u128 sq = r * r; + BOOST_REQUIRE(isqrt(sq) == r); + if (sq > 0) BOOST_REQUIRE(isqrt(sq - 1) == r - 1); + if (sq + 2 * r + 1 > sq) { // no wrap + BOOST_REQUIRE(isqrt(sq + 2 * r) == r); + BOOST_REQUIRE(isqrt(sq + 2 * r + 1) == r + 1); + } + } + // The largest product two int64 balances can form. + const u128 max_i64 = static_cast(INT64_MAX); + const u128 r = isqrt(max_i64 * max_i64); + BOOST_CHECK(r == max_i64); + BOOST_CHECK(isqrt(~static_cast(0)) == (static_cast(1) << 64) - 1); +} + +BOOST_AUTO_TEST_CASE(geometric_mean_seeds_by_sqrt_of_the_product) { + BOOST_CHECK(geometric_mean(10'000'000'000ULL, 1'000'000'000'000ULL) == 100'000'000'000ULL); + BOOST_CHECK(geometric_mean(1, 999'999'999'999'999ULL) == 31'622'776ULL); + BOOST_CHECK(geometric_mean(230'584'300'921'369ULL, 961'168'601'842'738ULL) == 470'776'369'546'600ULL); + BOOST_CHECK(geometric_mean(100'000'000'000'000ULL, 991'168'601'842'738ULL) == 314'828'302'705'258ULL); + // Scales linearly with a proportional deposit, up to the floor: seeding at 2x + // mints 2x or one unit more (2*floor(r) <= floor(2r) <= 2*floor(r) + 1). + const uint64_t once = geometric_mean(12345, 67890); + const uint64_t twice = geometric_mean(2 * 12345, 2 * 67890); + BOOST_CHECK(twice >= 2 * once && twice <= 2 * once + 1); + BOOST_CHECK(geometric_mean(2 * 300, 2 * 1200) == 2 * geometric_mean(300, 1200)); // exact roots: 1200 vs 600 + BOOST_CHECK(geometric_mean(0, 5) == 0); +} + +/// The largest amount an on-chain asset can hold, which is what bounds every +/// input the pool-share slices are called with. Spelled here because this suite +/// tests the shared kernel and links no contract. +static constexpr uint64_t MAX_ASSET = (1ULL << 62) - 1; // sysio::asset::max_amount + +/// Minting rounds up and burning rounds down, so the pool keeps the remainder +/// in both directions. +BOOST_AUTO_TEST_CASE(share_slices_round_toward_the_pool) { + // Exact division: nothing to round, so the two agree. + BOOST_CHECK(in_given_shares(1000, 100, 10) == 100u); + BOOST_CHECK(out_given_shares(1000, 100, 10) == 100u); + // A remainder splits them by exactly one unit, each the pool's way. + BOOST_CHECK(in_given_shares(1001, 100, 10) == 101u); // 100.1 + BOOST_CHECK(out_given_shares(1001, 100, 10) == 100u); + // The whole supply is worth the whole pool, exactly, either way. + BOOST_CHECK(in_given_shares(1234567, 8910, 8910) == 1234567u); + BOOST_CHECK(out_given_shares(1234567, 8910, 8910) == 1234567u); + // A slice below one unit still costs one to mint and returns nothing to + // burn: dust cannot be minted for free, nor extracted. + BOOST_CHECK(in_given_shares(5, 1'000'000, 1) == 1u); + BOOST_CHECK(out_given_shares(5, 1'000'000, 1) == 0u); +} + +/// Degenerate inputs return 0 rather than dividing by zero or, for the ceiling, +/// wrapping on `prod - 1`. +BOOST_AUTO_TEST_CASE(share_slices_degenerate_inputs) { + BOOST_CHECK(in_given_shares(0, 100, 10) == 0u); // empty pool side + BOOST_CHECK(out_given_shares(0, 100, 10) == 0u); + BOOST_CHECK(in_given_shares(1000, 0, 10) == 0u); // no supply to divide by + BOOST_CHECK(out_given_shares(1000, 0, 10) == 0u); + BOOST_CHECK(in_given_shares(1000, 100, 0) == 0u); // no shares + BOOST_CHECK(out_given_shares(1000, 100, 0) == 0u); +} + +/// The slice genuinely outgrows 64 bits, which is why it is returned as `u128` +/// and bounding it is the caller's job. +BOOST_AUTO_TEST_CASE(share_slices_exceed_64_bits) { + const u128 whole = static_cast(MAX_ASSET) * MAX_ASSET; + BOOST_CHECK(whole > static_cast(std::numeric_limits::max())); + BOOST_CHECK(in_given_shares(MAX_ASSET, 1, MAX_ASSET) == whole); + BOOST_CHECK(out_given_shares(MAX_ASSET, 1, MAX_ASSET) == whole); + // The ceiling's `+ supply - 1` cannot carry past `u128` even with every + // input at its maximum. + BOOST_CHECK(in_given_shares(MAX_ASSET, MAX_ASSET, MAX_ASSET) == static_cast(MAX_ASSET)); +} + +/// Across a wide random grid: each side is tight against the true quotient, and +/// the two differ by one unit exactly when there is a remainder to split. +BOOST_AUTO_TEST_CASE(share_slices_invariants) { + std::mt19937_64 rng(0x5348'4152'4553'0000ULL); + // Log-uniform inside the asset range, so dust and maxima are both frequent. + auto draw = [&]() -> uint64_t { + const uint64_t v = (rng() >> (rng() % 63)) & MAX_ASSET; + return v == 0 ? 1 : v; + }; + for (int i = 0; i < 20000; ++i) { + const uint64_t pool = draw(), supply = draw(), shares = draw(); + const u128 up = in_given_shares(pool, supply, shares); + const u128 down = out_given_shares(pool, supply, shares); + const u128 prod = static_cast(shares) * pool; + BOOST_REQUIRE(down * supply <= prod); // the floor never overshoots + BOOST_REQUIRE(prod - down * supply < supply); // ...and is tight + BOOST_REQUIRE(up * supply >= prod); // the ceiling never undershoots + if (prod % supply == 0) BOOST_REQUIRE(up == down); + else BOOST_REQUIRE(up == down + 1); + } +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/contract_test_support.hpp b/contracts/tests/contract_test_support.hpp index 7f89f085cb..17e18534cf 100644 --- a/contracts/tests/contract_test_support.hpp +++ b/contracts/tests/contract_test_support.hpp @@ -4,6 +4,7 @@ #include #include +#include #include namespace sysio_system::test_support { @@ -20,18 +21,28 @@ void load_account_abi(Tester& tester, name account, abi_serializer& out_ser) { out_ser.set_abi(std::move(parsed), abi_serializer::create_yield_function(Tester::abi_serializer_max_time)); } -/// Build and sign one ABI-encoded contract-action transaction. +/// The authorization an action needs when an unprivileged contract bills RAM to `signer` +/// (a deposit row emplaced for a user, say): the signer's `sysio.payer` beside `active`. +/// The active key satisfies both. +inline std::vector payer_authorization(name signer) { + return {{signer, config::sysio_payer_name}, {signer, config::active_name}}; +} + +/// Build and sign one ABI-encoded contract-action transaction. `authorization` defaults to +/// the signer's `active`; pass `payer_authorization(signer)` when the action bills RAM. template signed_transaction create_contract_action_transaction(Tester& tester, name contract, abi_serializer& serializer, name signer, name action_name, - const fc::variant_object& data) { + const fc::variant_object& data, + std::vector authorization = {}) { action act; act.account = contract; act.name = action_name; act.data = serializer.variant_to_binary( serializer.get_action_type(action_name), data, abi_serializer::create_yield_function(Tester::abi_serializer_max_time)); - act.authorization = std::vector{{signer, config::active_name}}; + act.authorization = authorization.empty() ? std::vector{{signer, config::active_name}} + : std::move(authorization); signed_transaction trx; trx.actions.emplace_back(std::move(act)); @@ -44,8 +55,10 @@ signed_transaction create_contract_action_transaction(Tester& tester, name contr template transaction_trace_ptr push_contract_action_trace(Tester& tester, name contract, abi_serializer& serializer, name signer, name action_name, - const fc::variant_object& data) { - auto trx = create_contract_action_transaction(tester, contract, serializer, signer, action_name, data); + const fc::variant_object& data, + std::vector authorization = {}) { + auto trx = create_contract_action_transaction(tester, contract, serializer, signer, action_name, data, + std::move(authorization)); return tester.push_transaction(trx); } @@ -66,9 +79,9 @@ typename Tester::action_result push_contract_action(Tester& tester, name contrac template typename Tester::action_result push_contract_action_and_produce_block( Tester& tester, name contract, abi_serializer& serializer, name signer, name action_name, - const fc::variant_object& data) { + const fc::variant_object& data, std::vector authorization = {}) { try { - push_contract_action_trace(tester, contract, serializer, signer, action_name, data); + push_contract_action_trace(tester, contract, serializer, signer, action_name, data, std::move(authorization)); tester.produce_block(); return Tester::success(); } catch (const fc::exception& ex) { @@ -85,6 +98,12 @@ typename Tester::action_result push_contract_action_and_produce_block( // ChainKind (see validate_outpost_addrs in sysio.chains.cpp). // --------------------------------------------------------------------------- +/// The variant form of a `slug_name` action argument (`chain_code`, `token_code`, +/// `code`, ...): the packed value under the struct's one field. +inline fc::mutable_variant_object codename_mvo(std::string_view s) { + return fc::mutable_variant_object()("value", fc::slug_name{ s }.value); +} + /// Every field empty — a chain registered before its remote contracts exist. /// Valid for any kind; both operator daemons fail closed and skip such a row. inline fc::mutable_variant_object no_outpost_mvo() { diff --git a/contracts/tests/contracts.hpp.in b/contracts/tests/contracts.hpp.in index beecf826e1..0915cab7c6 100644 --- a/contracts/tests/contracts.hpp.in +++ b/contracts/tests/contracts.hpp.in @@ -38,8 +38,16 @@ struct contracts { static std::vector chains_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.chains/sysio.chains.abi"); } static std::vector tokens_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.tokens/sysio.tokens.wasm"); } static std::vector tokens_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.tokens/sysio.tokens.abi"); } + static std::vector swap_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.swap/sysio.swap.wasm"); } + static std::vector swap_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.swap/sysio.swap.abi"); } + static std::vector liq_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/sysio.liq/sysio.liq.wasm"); } + static std::vector liq_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/sysio.liq/sysio.liq.abi"); } + /// The checked-in bootstrap configs (`dex-config.*.json`) the launch-ingestion tests replay. + static std::string dex_config_dir() { return "${CMAKE_SOURCE_DIR}/etc/config/dex"; } struct util { + static std::vector badtoken_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/test_contracts/badtoken/badtoken.wasm"); } + static std::vector badtoken_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/test_contracts/badtoken/badtoken.abi"); } static std::vector reject_all_wasm() { return read_wasm("${CMAKE_SOURCE_DIR}/contracts/test_contracts/reject_all.wasm"); } static std::vector reenter_deposit_wasm() { return read_wasm("${CMAKE_SOURCE_DIR}/contracts/test_contracts/reenter_deposit/reenter_deposit.wasm"); } static std::vector reenter_deposit_abi() { return read_abi("${CMAKE_SOURCE_DIR}/contracts/test_contracts/reenter_deposit/reenter_deposit.abi"); } diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index 2d96ee90f1..deb8b07f9e 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -527,6 +527,27 @@ class sysio_emissions_tester : public tester { produce_blocks(1); } + /// The same for sysio.liq, the other capital drain: deployed and privileged + /// so a test can sign fundclaim as it. + void deploy_liq_for_signing() { + const account_name LIQ = "sysio.liq"_n; + if (!control->db().find(LIQ)) { + create_accounts({ LIQ }, false, false, false, true); + produce_blocks(1); + } + if (get_roa_policy(LIQ, "nodedaddy"_n).is_null()) { + auto tr = addpolicy_ram_only("nodedaddy"_n, LIQ, + asset::from_string("500.0000 SYS")); + BOOST_REQUIRE( tr ); + BOOST_REQUIRE( !tr->except ); + produce_blocks(1); + } + set_code( LIQ, contracts::liq_wasm() ); + set_abi ( LIQ, contracts::liq_abi().data() ); + set_privileged( LIQ ); + produce_blocks(1); + } + /// Deploy the real sysio.reserv contract (privileged) so the swap-fee /// fold-in test can seed its rewards bucket via a swap. Mirrors /// deploy_dclaim_for_signing's account + ROA-policy + code pattern. @@ -726,11 +747,11 @@ class sysio_emissions_tester : public tester { ); } - action_result fundclaim( account_name signer, int64_t amount ) { + action_result fundclaim( account_name signer, account_name recipient, int64_t amount ) { return push_system_action( signer, "fundclaim"_n, - mvo()("amount", amount) + mvo()("recipient", recipient)("amount", amount) ); } @@ -5537,7 +5558,7 @@ BOOST_FIXTURE_TEST_CASE( pay_cadence_change_via_setemitcfg_takes_effect, sysio_e // fundclaim: per-onreward immediate funding for sysio.dclaim // --------------------------------------------------------------------------- -BOOST_FIXTURE_TEST_CASE( fundclaim_requires_dclaim_auth, sysio_emissions_tester ) try { +BOOST_FIXTURE_TEST_CASE( fundclaim_requires_the_recipients_auth, sysio_emissions_tester ) try { create_t5_holding_accounts(); deploy_dclaim_for_signing(); const uint32_t start = head_secs() - ONE_EPOCH - 1; @@ -5545,11 +5566,23 @@ BOOST_FIXTURE_TEST_CASE( fundclaim_requires_dclaim_auth, sysio_emissions_tester // alice has no claim to sysio.dclaim's authority. create_user_accounts({ "alice"_n }); - auto r = fundclaim( "alice"_n, int64_t(1'000'000) ); + auto r = fundclaim( "alice"_n, "sysio.dclaim"_n, int64_t(1'000'000) ); BOOST_REQUIRE( r != success() ); require_substr( r, "missing authority of sysio.dclaim" ); } FC_LOG_AND_RETHROW() +BOOST_FIXTURE_TEST_CASE( fundclaim_refuses_a_recipient_that_is_not_a_drain, sysio_emissions_tester ) try { + create_t5_holding_accounts(); + const uint32_t start = head_secs() - ONE_EPOCH - 1; + BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); + + // alice signs for herself; only the two drains may be funded. + create_user_accounts({ "alice"_n }); + auto r = fundclaim( "alice"_n, "alice"_n, int64_t(1'000'000) ); + BOOST_REQUIRE( r != success() ); + require_substr( r, "recipient is not a capital drain" ); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( fundclaim_transfers_and_tracks_distributed, sysio_emissions_tester ) try { create_t5_holding_accounts(); deploy_dclaim_for_signing(); @@ -5561,7 +5594,7 @@ BOOST_FIXTURE_TEST_CASE( fundclaim_transfers_and_tracks_distributed, sysio_emiss const int64_t distributed_before = get_t5_state()["total_distributed"].as(); const int64_t amt = int64_t(50'000'000'000); // 50 WIRE - BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, amt ) ); + BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, "sysio.dclaim"_n, amt ) ); const asset sysio_after = get_wire_balance_paid( config::system_account_name ); const asset dclaim_after = get_wire_balance_paid( "sysio.dclaim"_n ); @@ -5582,8 +5615,8 @@ BOOST_FIXTURE_TEST_CASE( fundclaim_no_op_for_zero_or_negative, sysio_emissions_t const asset dclaim_before = get_wire_balance_paid( "sysio.dclaim"_n ); const int64_t distributed_before = get_t5_state()["total_distributed"].as(); - BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, int64_t(0) ) ); - BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, int64_t(-100) ) ); + BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, "sysio.dclaim"_n, int64_t(0) ) ); + BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, "sysio.dclaim"_n, int64_t(-100) ) ); BOOST_REQUIRE_EQUAL( get_wire_balance_paid( "sysio.dclaim"_n ).get_amount(), dclaim_before.get_amount() ); @@ -5632,7 +5665,7 @@ BOOST_FIXTURE_TEST_CASE( fundclaim_caps_to_remaining_pool_and_records_shortfall, // Request 3x the headroom; expect partial transfer of `headroom`, shortfall = 2x. const int64_t request = headroom * 3; const int64_t shortfall = request - headroom; - BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, request ) ); + BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, "sysio.dclaim"_n, request ) ); const asset dclaim_after = get_wire_balance_paid( "sysio.dclaim"_n ); const auto state = get_t5_state(); @@ -5641,7 +5674,7 @@ BOOST_FIXTURE_TEST_CASE( fundclaim_caps_to_remaining_pool_and_records_shortfall, BOOST_REQUIRE_EQUAL( state["capital_shortfall_total"].as(), shortfall ); // A further request after pool is exhausted is a full shortfall. - BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, int64_t(500) ) ); + BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, "sysio.dclaim"_n, int64_t(500) ) ); const auto state2 = get_t5_state(); BOOST_REQUIRE_EQUAL( get_wire_balance_paid( "sysio.dclaim"_n ).get_amount(), dclaim_after.get_amount() ); @@ -5656,12 +5689,30 @@ BOOST_FIXTURE_TEST_CASE( fundclaim_silent_when_t5state_missing, sysio_emissions_ deploy_dclaim_for_signing(); const asset dclaim_before = get_wire_balance_paid( "sysio.dclaim"_n ); - BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, int64_t(1'000'000) ) ); + BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.dclaim"_n, "sysio.dclaim"_n, int64_t(1'000'000) ) ); BOOST_REQUIRE_EQUAL( get_wire_balance_paid( "sysio.dclaim"_n ).get_amount(), dclaim_before.get_amount() ); } FC_LOG_AND_RETHROW() +BOOST_FIXTURE_TEST_CASE( fundclaim_funds_the_liq_kicker_drain, sysio_emissions_tester ) try { + // sysio.liq draws the yield kicker through the same drain as sysio.dclaim. + create_t5_holding_accounts(); + deploy_liq_for_signing(); + const uint32_t start = head_secs() - ONE_EPOCH - 1; + BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); + + const asset liq_before = get_wire_balance( "sysio.liq"_n ); + const int64_t distributed_before = get_t5_state()["total_distributed"].as(); + + const int64_t amt = int64_t(2'000'000'000); // 2 WIRE + BOOST_REQUIRE_EQUAL( success(), fundclaim( "sysio.liq"_n, "sysio.liq"_n, amt ) ); + + BOOST_REQUIRE_EQUAL( get_wire_balance( "sysio.liq"_n ).get_amount() - liq_before.get_amount(), amt ); + BOOST_REQUIRE_EQUAL( get_t5_state()["total_distributed"].as(), distributed_before + amt ); + BOOST_REQUIRE_EQUAL( get_t5_state()["capital_shortfall_total"].as(), 0 ); +} FC_LOG_AND_RETHROW() + BOOST_AUTO_TEST_SUITE_END() // t5_emissions_tests // =========================================================================== diff --git a/contracts/tests/liq_test_support.hpp b/contracts/tests/liq_test_support.hpp new file mode 100644 index 0000000000..d21fa28da8 --- /dev/null +++ b/contracts/tests/liq_test_support.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +/// Test support for sysio.liq's kv tables, shared by the liq and dispatch suites. +namespace sysio_liq::test_support { + +/// The kv key of a `sysio.liq::parked` row: the symbol code and the chain kind as +/// big-endian words, then the pubkey NUL-escaped and NUL-NUL terminated (the kv key +/// encoding of the contract's `parked_key`). +inline std::string parked_key(sysio::chain::symbol_code sym, sysio::opp::types::ChainKind kind, + const std::vector& pubkey) { + std::string key; + for (uint64_t word : { sym.value, static_cast(magic_enum::enum_integer(kind)) }) + for (int shift = 56; shift >= 0; shift -= 8) key.push_back(char((word >> shift) & 0xff)); + for (char c : pubkey) { + key.push_back(c); + if (c == '\0') key.push_back('\x01'); + } + key.push_back('\0'); + key.push_back('\0'); + return key; +} + +/// The serialized `parked` row stored under `key` by `liq_account`, empty when absent. +inline std::vector parked_row_bytes(const sysio::chain::controller& control, sysio::chain::name liq_account, + const std::string& key) { + const auto& kv_idx = control.db().get_index(); + const auto itr = kv_idx.find(boost::make_tuple( + liq_account, sysio::chain::compute_table_id(sysio::chain::name{ "parked" }.to_uint64_t()), std::string_view(key))); + if (itr == kv_idx.end()) return {}; + return std::vector(itr->value.data(), itr->value.data() + itr->value.size()); +} + +/// The chain-native address `sysio.authex::recordlink` carries beside a linked key. On SVM +/// that is the ED key's own 32 bytes -- the form `sysio.liq` parks against and sweeps by. +inline std::vector native_address_of(const fc::crypto::public_key& pub_key) { + const auto raw = pub_key.get().serialize(); + return std::vector(raw.begin(), raw.end()); +} + +} // namespace sysio_liq::test_support diff --git a/contracts/tests/shadow_yield_reference.hpp b/contracts/tests/shadow_yield_reference.hpp new file mode 100644 index 0000000000..5b340eb276 --- /dev/null +++ b/contracts/tests/shadow_yield_reference.hpp @@ -0,0 +1,39 @@ +#pragma once +/** + * @file shadow_yield_reference.hpp + * @brief The shadow yield spec (sysio.opp.common/shadow_yield.hpp), written by + * hand and shared by the sysio.swap and sysio.liq suites: a distribution + * advances the index by WIRE * SCALE / supply with the remainder carried + * into the next one, and a holder is owed its banked WIRE plus + * balance * (index - checkpoint) / SCALE, floored. Also the Boost.Test + * printer for the 128-bit index fields, which have no stream operator of + * their own. + */ + +#include +#include +#include + +#include +#include + +// Boost.Test prints both operands of a failed assertion. +namespace boost::test_tools::tt_detail { + template<> struct print_log_value { + void operator()( std::ostream& os, const fc::uint128_t& v ) { os << fc::to_string( v ); } + }; +} + +namespace yield_reference { + using wide = boost::multiprecision::uint128_t; + constexpr uint64_t Scale = 1'000'000'000'000; + // The index and the checkpoints are 128-bit, like the rows they are read from. + struct distribution { wide index_delta; uint64_t carry; }; + inline distribution distribute( int64_t wire, int64_t supply, uint64_t carry_in ) { + const wide total = wide(wire) * Scale + carry_in; + return { wide( total / supply ), uint64_t( total % supply ) }; + } + inline int64_t owed( int64_t balance, fc::uint128_t index, fc::uint128_t checkpoint, uint64_t banked = 0 ) { + return int64_t( banked + uint64_t( wide(balance) * (wide(index) - wide(checkpoint)) / Scale ) ); + } +} diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index 6326a1d6f9..6b3b73055b 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -40,11 +40,13 @@ #include #include #include +#include #include #include #include "contracts.hpp" #include "contract_test_support.hpp" +#include "liq_test_support.hpp" #include "test_symbol.hpp" // Canonical-encoding + header-derivation oracle: inbound envelopes must carry // spec-derived semantic headers or apply_consensus drops them before dispatch. @@ -56,6 +58,7 @@ using namespace sysio::chain; using namespace sysio::opp::types; using mvo = fc::mutable_variant_object; +using sysio_system::test_support::codename_mvo; namespace { @@ -64,11 +67,6 @@ constexpr uint32_t PROTOBUF_VARINT_PAYLOAD_BITS = 7u; constexpr uint8_t PROTOBUF_VARINT_CONTINUATION_BIT = 0x80u; constexpr uint32_t PROTOBUF_FIELD_TAG_SHIFT = 3u; -/// SlugName mvo helper for v6 action arguments. -inline fc::mutable_variant_object codename_mvo(std::string_view s) { - return mvo()("value", fc::slug_name{s}.value); -} - /** Append one unsigned protobuf varint to a hostile-wire-format fixture. */ void append_proto_varint(std::vector& out, uint64_t value) { do { @@ -112,11 +110,17 @@ inline fc::variant chain_min_bond_mvo(std::string_view chain_code, ("config_timestamp_ms", uint64_t{0})); } -/// Encode an Envelope wrapping a single attestation. -std::vector encode_envelope_with_one_attestation( +/// One attestation of an envelope under construction: its type and its encoded payload. +using typed_attestation = std::pair; + +/// Encode an Envelope wrapping `attestations` in order, each with its own type — the +/// general form the single-type encoders below delegate to. Used to fit several +/// attestations into a single delivery, since the depot deduplicates +/// per-(batch_op, outpost, epoch) — a second `deliver` from the same batch op in the +/// same epoch reverts as a duplicate. +std::vector encode_envelope_with_mixed_attestations( uint32_t epoch_index, - sysio::opp::types::AttestationType att_type, - const std::string& att_data) + const std::vector& attestations) { sysio::opp::Envelope env; env.set_epoch_index(epoch_index); @@ -125,10 +129,12 @@ std::vector encode_envelope_with_one_attestation( auto* msg = env.add_messages(); auto* payload = msg->mutable_payload(); - auto* att = payload->add_attestations(); - att->set_type(att_type); - att->set_data(att_data); - att->set_data_size(static_cast(att_data.size())); + for (const auto& [att_type, att_data] : attestations) { + auto* att = payload->add_attestations(); + att->set_type(att_type); + att->set_data(att_data); + att->set_data_size(static_cast(att_data.size())); + } oracle::finalize_header(*env.mutable_messages(0), {}, 1'775'612'516'983ULL); @@ -137,34 +143,25 @@ std::vector encode_envelope_with_one_attestation( return out; } -/// Encode an Envelope wrapping N attestations of the same type. Used to fit -/// multiple OPERATOR_ACTIONs into a single delivery, since the depot -/// deduplicates per-(batch_op, outpost, epoch) — a second `deliver` from -/// the same batch op in the same epoch reverts as a duplicate. +/// Encode an Envelope wrapping N attestations of the same type. std::vector encode_envelope_with_attestations( uint32_t epoch_index, sysio::opp::types::AttestationType att_type, const std::vector& att_datas) { - sysio::opp::Envelope env; - env.set_epoch_index(epoch_index); - env.set_epoch_envelope_index(1); - env.set_epoch_timestamp(1'775'612'516'983ULL); - - auto* msg = env.add_messages(); - auto* payload = msg->mutable_payload(); - for (const auto& d : att_datas) { - auto* att = payload->add_attestations(); - att->set_type(att_type); - att->set_data(d); - att->set_data_size(static_cast(d.size())); - } - - oracle::finalize_header(*env.mutable_messages(0), {}, 1'775'612'516'983ULL); + std::vector attestations; + attestations.reserve(att_datas.size()); + for (const auto& d : att_datas) attestations.emplace_back(att_type, d); + return encode_envelope_with_mixed_attestations(epoch_index, attestations); +} - std::vector out(env.ByteSizeLong()); - env.SerializeToArray(out.data(), static_cast(out.size())); - return out; +/// Encode an Envelope wrapping a single attestation. +std::vector encode_envelope_with_one_attestation( + uint32_t epoch_index, + sysio::opp::types::AttestationType att_type, + const std::string& att_data) +{ + return encode_envelope_with_attestations(epoch_index, att_type, {att_data}); } /// Mirrors the contract-internal `MAX_ENVELOPE_BYTES` protocol cap (32 KiB, shared with the @@ -327,6 +324,68 @@ std::string encode_swap_request( return out; } +/// Encode a SyndicateLIQ attestation payload: the emitting outpost, the syndicating user's +/// native pubkey (kind + bytes), the liq TokenAmount, and the per-outpost sequence. +std::string encode_syndicate_liq(uint64_t chain_code_v, + sysio::opp::types::ChainKind user_kind, + const std::vector& user_pubkey, + uint64_t token_code_v, int64_t amount, uint64_t sequence) +{ + sysio::opp::attestations::SyndicateLIQ synd; + synd.set_chain_code(chain_code_v); + auto* user = synd.mutable_user(); + user->set_kind(user_kind); + user->set_address(user_pubkey.data(), user_pubkey.size()); + auto* amt = synd.mutable_amount(); + amt->set_token_code(token_code_v); + amt->set_amount(amount); + synd.set_sequence(sequence); + + std::string out; + synd.SerializeToString(&out); + return out; +} + +/// Encode a LIQYield attestation payload: the outpost's claimed yield in its liq token, +/// the per-outpost sequence it shares with SyndicateLIQ, and the outpost epoch of the report. +std::string encode_liq_yield(uint64_t chain_code_v, uint64_t token_code_v, int64_t amount, + uint64_t sequence, uint64_t epoch) +{ + sysio::opp::attestations::LIQYield report; + report.set_chain_code(chain_code_v); + auto* amt = report.mutable_amount(); + amt->set_token_code(token_code_v); + amt->set_amount(amount); + report.set_sequence(sequence); + report.set_epoch(epoch); + + std::string out; + report.SerializeToString(&out); + return out; +} + +/// Encode a DesyndicateLIQ attestation payload — a depot -> outpost type; the tests echo one +/// inbound to prove the depot drops it. +std::string encode_desyndicate_liq(uint64_t chain_code_v, + sysio::opp::types::ChainKind user_kind, + const std::vector& user_pubkey, + uint64_t token_code_v, int64_t amount, uint64_t request_id) +{ + sysio::opp::attestations::DesyndicateLIQ desynd; + desynd.set_chain_code(chain_code_v); + auto* user = desynd.mutable_user(); + user->set_kind(user_kind); + user->set_address(user_pubkey.data(), user_pubkey.size()); + auto* amt = desynd.mutable_amount(); + amt->set_token_code(token_code_v); + amt->set_amount(amount); + desynd.set_request_id(request_id); + + std::string out; + desynd.SerializeToString(&out); + return out; +} + } // anonymous namespace /// Wire layout of an `auth.msg::onlinkauth` action, the payload sysio.system once acted on. @@ -440,10 +499,14 @@ class sysio_dispatch_tester : public tester { } std::vector create_eth_authex_link(name account) { + return create_eth_authex_link(account, fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em)); + } + + /// Link `account` to the EM key behind `priv` through a user-created `createlink`. + std::vector create_eth_authex_link(name account, const fc::crypto::private_key& priv) { using namespace fc::crypto; using namespace sysio::opp::types; - auto priv = private_key::generate(private_key::key_type::em); auto pub = priv.get_public_key(); const uint64_t nonce = control->head().block_time().time_since_epoch().count() / 1000; @@ -1396,8 +1459,97 @@ class sysio_dispatch_tester : public tester { push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "advance"_n, mvo())); } + // ── sysio.liq inbound routing (SYNDICATE_LIQ / LIQ_YIELD) ───────────────── + + static constexpr auto TOKENS_ACCOUNT = "sysio.tokens"_n; + static constexpr auto LIQ_ACCOUNT = "sysio.liq"_n; + static inline const symbol LIQETH_SYM = symbol::from_string("9,LIQETH"); + /// One whole liq token in the depot's 9-decimal frame. + static constexpr int64_t LIQ_UNIT = 1'000'000'000; + + /// Register `code` on sysio.tokens as `kind` at the depot's 9-decimal precision and bind + /// it to `chain_code` (EVM address bytes; the registries only check the length). + action_result regtoken(TokenKind kind, std::string_view code, std::string_view chain_code) { + const std::vector addr(20, '\x5a'); + auto r = push(TOKENS_ACCOUNT, tokens_abi, TOKENS_ACCOUNT, "regtoken"_n, mvo() + ("kind", kind)("code", codename_mvo(code))("symbol_name", std::string(code)) + ("description", std::string{})("precision", 9) + ("address", mvo()("kind", ChainKind::CHAIN_KIND_EVM)("address", addr))); + if (r != success()) return r; + return push(TOKENS_ACCOUNT, tokens_abi, TOKENS_ACCOUNT, "regctok"_n, mvo() + ("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(code)) + ("contract_addr", addr)("is_native", false)); + } + + /// Deploy sysio.tokens + sysio.liq and register the bootstrapped outpost's liq token + /// ("LIQETH" on ETH) with its shadow, plus two tokens the shadow ledger must refuse: a + /// plain ERC20 ("USDCETH") and a liq token nobody opened a shadow for ("LIQTWO"). + /// `bootstrap_for_dispatch` must have run first — registrations inside the epoch-0 + /// bootstrap window land ACTIVE. + void setup_liq_for_dispatch() { + create_accounts({TOKENS_ACCOUNT, LIQ_ACCOUNT}); + produce_blocks(); + deploy(TOKENS_ACCOUNT, contracts::tokens_wasm(), contracts::tokens_abi(), tokens_abi); + deploy(LIQ_ACCOUNT, contracts::liq_wasm(), contracts::liq_abi(), liq_abi); + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, "LIQETH", "ETH")); + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, "LIQTWO", "ETH")); + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_ERC20, "USDCETH", "ETH")); + BOOST_REQUIRE_EQUAL(success(), push(LIQ_ACCOUNT, liq_abi, LIQ_ACCOUNT, "create"_n, mvo() + ("sym", LIQETH_SYM)("chain_code", codename_mvo("ETH"))("token_code", codename_mvo("LIQETH")))); + produce_blocks(); + } + + fc::variant liq_row(name table, const char* type, name scope, uint64_t id) { + auto data = get_row_by_id(LIQ_ACCOUNT, scope, table, id); + return data.empty() ? fc::variant() : liq_abi.binary_to_variant( + type, data, abi_serializer::create_yield_function(abi_serializer_max_time)); + } + + /// `holder`'s LIQETH shadow balance; 0 without a row. + int64_t liq_balance(name holder) { + const auto row = liq_row("accounts"_n, "account", holder, LIQETH_SYM.to_symbol_code().value); + return row.is_null() ? 0 : row["balance"].as().get_amount(); + } + + /// The LIQETH shadow supply; 0 without a stat row. + int64_t liq_supply() { + const auto row = liq_row("stat"_n, "currency_stats", LIQ_ACCOUNT, LIQETH_SYM.to_symbol_code().value); + return row.is_null() ? 0 : row["supply"].as().get_amount(); + } + + /// LIQETH yield reported by the outpost and not yet queued to the swap; 0 without a row. + int64_t liq_pending() { + const auto row = liq_row("liqpending"_n, "pending_yield", LIQ_ACCOUNT, LIQETH_SYM.to_symbol_code().value); + return row.is_null() ? 0 : row["quantity"].as().get_amount(); + } + + /// The per-outpost inbound cursor (`last_sequence`, `last_epoch`); null before any credit lands. + fc::variant liq_cursor(std::string_view chain_code) { + return liq_row("liqcursors"_n, "liq_cursor", LIQ_ACCOUNT, fc::slug_name{chain_code}.value); + } + + /// The LIQETH balance parked against an unlinked `pubkey` of `kind`; 0 without a row. + int64_t liq_parked(ChainKind kind, const std::vector& pubkey) { + using namespace sysio_liq::test_support; + const auto data = parked_row_bytes(*control, LIQ_ACCOUNT, parked_key(LIQETH_SYM.to_symbol_code(), kind, pubkey)); + if (data.empty()) return 0; + const auto row = liq_abi.binary_to_variant( + "parked_row", data, abi_serializer::create_yield_function(abi_serializer_max_time)); + return row["holding"]["balance"].as().get_amount(); + } + + /// Every action's console in `trace`, inline actions included: msgch's own drops print on + /// `deliver`, a downstream contract's on the inline action msgch sent it. + static std::string all_console(const transaction_trace_ptr& trace) { + std::string console; + for (const auto& action_trace : trace->action_traces) { + console += action_trace.console; + } + return console; + } + abi_serializer msgch_abi, opreg_abi, uwrit_abi, epoch_abi, reserv_abi, authex_abi, dclaim_abi, - chains_abi, roa_abi, token_abi; + chains_abi, roa_abi, token_abi, tokens_abi, liq_abi; std::vector uwrit_op_eth_pubkey; }; @@ -1763,10 +1915,7 @@ BOOST_FIXTURE_TEST_CASE(underwrite_commit_early_rejections_log_and_continue, const auto trace = deliver_trace(/*proven=*/ sol_chain, env); BOOST_REQUIRE(trace != nullptr); BOOST_REQUIRE(!trace->except); - std::string console; - for (const auto& action_trace : trace->action_traces) { - console += action_trace.console; - } + const auto console = all_console(trace); BOOST_CHECK_NE(std::string::npos, console.find("UIC_DISPATCH_REJECTED: chain_code=")); BOOST_CHECK_NE(std::string::npos, console.find("reason=malformed_uic")); @@ -6564,4 +6713,216 @@ BOOST_FIXTURE_TEST_CASE(lock_hold_actions_require_chalg_auth, sysio_uwchal_teste .find("missing authority of sysio.chalg") != std::string::npos); } FC_LOG_AND_RETHROW() } +// An OPERATOR_ACTION whose `op_address` is not a key the chain family can link — here 20 +// address bytes where the EVM link holds the 33-byte pubkey — resolves to no account and is +// dropped; it must never abort the envelope (a link lookup only ever hashes EM / ED keys). +BOOST_FIXTURE_TEST_CASE(operator_action_with_a_malformed_address_is_dropped, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + const auto eth = fc::slug_name{"ETH"}.value; + const std::vector evm_address(20, '\x0c'); + const auto envelope = encode_envelope_with_one_attestation( + current_epoch(), sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION, + encode_operator_action(sysio::opp::attestations::OperatorAction::ACTION_TYPE_DEPOSIT_REQUEST, + ChainKind::CHAIN_KIND_EVM, evm_address, eth, eth, 1'000'000)); + BOOST_REQUIRE_EQUAL(success(), deliver(/*chain_code=*/ eth, envelope)); + const auto op = get_operator(UWRIT_OP); + BOOST_REQUIRE(!op.is_null()); + BOOST_CHECK_EQUAL(0u, op["balances"].get_array().size()); +} FC_LOG_AND_RETHROW() } + +// ── SYNDICATE_LIQ / LIQ_YIELD -> sysio.liq ────────────────────────────────── + +// SYNDICATE_LIQ routes on the AuthX link: a linked user's syndication credits their shadow +// balance through sysio.liq::mintsynd, an unlinked user's is parked against the pubkey through +// sysio.liq::park, and the per-outpost sequence is consumed only by a credit that lands — a +// replay is dropped, a gap is admitted. A malformed payload and an echoed DESYNDICATE_LIQ are +// dropped too, and nothing aborts the envelope. +BOOST_FIXTURE_TEST_CASE(syndicate_liq_credits_a_linked_user_and_parks_an_unlinked_one, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + setup_liq_for_dispatch(); + + const auto eth = fc::slug_name{"ETH"}.value; + const auto liqeth = fc::slug_name{"LIQETH"}.value; + // An EVM key nobody has linked yet. + const auto stranger_key = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); + const auto stranger = em_pubkey_bytes(stranger_key.get_public_key()); + constexpr auto EVM = ChainKind::CHAIN_KIND_EVM; + + const auto env = encode_envelope_with_mixed_attestations(current_epoch(), { + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 5 * LIQ_UNIT, 1)}, + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, stranger, liqeth, 3 * LIQ_UNIT, 2)}, + // sequence 2 again: a replay, dropped by sysio.liq + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 4 * LIQ_UNIT, 2)}, + // not a SyndicateLIQ at all + {ATTESTATION_TYPE_SYNDICATE_LIQ, std::string(1, '\x0a')}, + // a depot -> outpost type echoed back inbound + {ATTESTATION_TYPE_DESYNDICATE_LIQ, encode_desyndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 1 * LIQ_UNIT, 77)}, + // a gap after the last admitted sequence is fine + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 1 * LIQ_UNIT, 9)}, + }); + const auto trace = deliver_trace(/*proven=*/ eth, env); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + const auto console = all_console(trace); + + BOOST_CHECK_EQUAL(6 * LIQ_UNIT, liq_balance(UWRIT_OP)); + BOOST_CHECK_EQUAL(3 * LIQ_UNIT, liq_parked(EVM, stranger)); + BOOST_CHECK_EQUAL(9 * LIQ_UNIT, liq_supply()); + BOOST_CHECK_EQUAL(0, liq_pending()); + const auto cursor = liq_cursor("ETH"); + BOOST_REQUIRE(!cursor.is_null()); + BOOST_CHECK_EQUAL(9u, cursor["last_sequence"].as()); + BOOST_CHECK_EQUAL(0u, cursor["last_epoch"].as()); + BOOST_CHECK_NE(std::string::npos, console.find("sysio.liq::mintsynd: DROP -- replayed sequence")); + + // The stranger links the key later: createlink sweeps the parked shadow into the new + // account inline, so nothing is left parked and no permissionless sweep is needed. + create_accounts({"stranger"_n}); + produce_blocks(); + create_eth_authex_link("stranger"_n, stranger_key); + BOOST_CHECK_EQUAL(3 * LIQ_UNIT, liq_balance("stranger"_n)); + BOOST_CHECK_EQUAL(0, liq_parked(EVM, stranger)); + BOOST_CHECK_EQUAL(9 * LIQ_UNIT, liq_supply()); +} FC_LOG_AND_RETHROW() } + +// The user's key family must be the proven outpost's own. A Solana-family key inside an +// Ethereum envelope is dropped by msgch before the AuthX lookup, whether an account has linked +// it (mintsynd receives no family and would have credited that account) or not (park would +// have refused it); the EVM credit beside them still lands and is the only sequence consumed. +BOOST_FIXTURE_TEST_CASE(syndicate_liq_refuses_a_key_of_another_chain_family, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + setup_liq_for_dispatch(); + + const auto eth = fc::slug_name{"ETH"}.value; + const auto liqeth = fc::slug_name{"LIQETH"}.value; + constexpr auto EVM = ChainKind::CHAIN_KIND_EVM; + constexpr auto SVM = ChainKind::CHAIN_KIND_SVM; + // A Solana key the underwriter has linked, and one nobody has. + const auto linked_sol_key = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::ed).get_public_key(); + const auto linked_sol_raw = linked_sol_key.get().serialize(); + const std::vector linked_sol(linked_sol_raw.begin(), linked_sol_raw.end()); + BOOST_REQUIRE_EQUAL(success(), push( + AUTHEX_ACCOUNT, authex_abi, AUTHEX_ACCOUNT, "recordlink"_n, mvo() + ("account", UWRIT_OP)("chain_kind", SVM)("pub_key", linked_sol_key)("native_address", linked_sol))); + produce_block(); + const auto stranger_sol_raw = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::ed) + .get_public_key().get().serialize(); + const std::vector stranger_sol(stranger_sol_raw.begin(), stranger_sol_raw.end()); + + const auto env = encode_envelope_with_mixed_attestations(current_epoch(), { + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, SVM, linked_sol, liqeth, 5 * LIQ_UNIT, 1)}, + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, SVM, stranger_sol, liqeth, 3 * LIQ_UNIT, 2)}, + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, 2 * LIQ_UNIT, 3)}, + }); + const auto trace = deliver_trace(/*proven=*/ eth, env); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + const auto console = all_console(trace); + + BOOST_CHECK_EQUAL(2 * LIQ_UNIT, liq_balance(UWRIT_OP)); + BOOST_CHECK_EQUAL(0, liq_parked(SVM, linked_sol)); + BOOST_CHECK_EQUAL(0, liq_parked(SVM, stranger_sol)); + BOOST_CHECK_EQUAL(2 * LIQ_UNIT, liq_supply()); + const auto cursor = liq_cursor("ETH"); + BOOST_REQUIRE(!cursor.is_null()); + BOOST_CHECK_EQUAL(3u, cursor["last_sequence"].as()); + const std::string dropped = "msgch::dispatch_syndicate_liq: DROP attestation -- user kind CHAIN_KIND_SVM"; + const auto first = console.find(dropped); + BOOST_REQUIRE_NE(std::string::npos, first); + BOOST_CHECK_NE(std::string::npos, console.find(dropped, first + dropped.size())); + BOOST_CHECK_EQUAL(std::string::npos, console.find("sysio.liq::park")); +} FC_LOG_AND_RETHROW() } + +// LIQ_YIELD lands in sysio.liq's pending balance (no per-user routing) and stamps the report's +// epoch on the outpost cursor. Every refusal is dropped at the boundary without aborting the +// envelope: a payload claiming another chain, a token that is not an active liq token (a plain +// ERC20, an unregistered code), a liq token with no shadow, a non-positive or oversized amount, +// an unlinked pubkey of the wrong shape, a replayed sequence, and a malformed payload. +BOOST_FIXTURE_TEST_CASE(liq_yield_lands_in_pending_and_refusals_are_dropped, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + setup_liq_for_dispatch(); + + const auto eth = fc::slug_name{"ETH"}.value; + const auto solana = fc::slug_name{"SOLANA"}.value; + const auto liqeth = fc::slug_name{"LIQETH"}.value; + const auto liqtwo = fc::slug_name{"LIQTWO"}.value; + const auto usdceth = fc::slug_name{"USDCETH"}.value; + const auto liqnone = fc::slug_name{"LIQNONE"}.value; + constexpr auto EVM = ChainKind::CHAIN_KIND_EVM; + constexpr int64_t oversized = std::numeric_limits::max(); + const std::vector evm_address(20, '\x0c'); // an address, not the 33-byte pubkey the link holds + + const auto env = encode_envelope_with_mixed_attestations(current_epoch(), { + {ATTESTATION_TYPE_LIQ_YIELD, encode_liq_yield(eth, liqeth, 7 * LIQ_UNIT, 1, 42)}, + // claims another chain than the proven outpost + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(solana, EVM, uwrit_op_eth_pubkey, liqeth, 1 * LIQ_UNIT, 2)}, + // an ERC20, an unregistered code, a liq token without a shadow + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, usdceth, 1 * LIQ_UNIT, 3)}, + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqnone, 1 * LIQ_UNIT, 4)}, + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqtwo, 1 * LIQ_UNIT, 5)}, + // amounts the fail-closed gate refuses + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, -1, 6)}, + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, uwrit_op_eth_pubkey, liqeth, oversized, 7)}, + // unlinked, and not a pubkey the chain family could ever link + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, EVM, evm_address, liqeth, 1 * LIQ_UNIT, 8)}, + // yield refusals: another chain, not a liq token, a replayed sequence, malformed + {ATTESTATION_TYPE_LIQ_YIELD, encode_liq_yield(solana, liqeth, 1 * LIQ_UNIT, 9, 43)}, + {ATTESTATION_TYPE_LIQ_YIELD, encode_liq_yield(eth, usdceth, 1 * LIQ_UNIT, 10, 43)}, + {ATTESTATION_TYPE_LIQ_YIELD, encode_liq_yield(eth, liqeth, 1 * LIQ_UNIT, 1, 44)}, + {ATTESTATION_TYPE_LIQ_YIELD, std::string(1, '\x0a')}, + }); + const auto trace = deliver_trace(/*proven=*/ eth, env); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + const auto console = all_console(trace); + + BOOST_CHECK_EQUAL(7 * LIQ_UNIT, liq_pending()); + BOOST_CHECK_EQUAL(0, liq_supply()); + BOOST_CHECK_EQUAL(0, liq_balance(UWRIT_OP)); + BOOST_CHECK_EQUAL(0, liq_parked(EVM, evm_address)); + const auto cursor = liq_cursor("ETH"); + BOOST_REQUIRE(!cursor.is_null()); + BOOST_CHECK_EQUAL(1u, cursor["last_sequence"].as()); + BOOST_CHECK_EQUAL(42u, cursor["last_epoch"].as()); + + BOOST_CHECK_NE(std::string::npos, console.find( + "msgch::dispatch_syndicate_liq: DROP attestation -- payload chain_code=")); + BOOST_CHECK_NE(std::string::npos, console.find( + "msgch::dispatch_syndicate_liq: DROP attestation -- token is not an active liq token")); + BOOST_CHECK_NE(std::string::npos, console.find("sysio.liq::mintsynd: DROP -- token_code has no shadow symbol")); + BOOST_CHECK_NE(std::string::npos, console.find("sysio.liq::park: DROP -- pubkey does not fit the chain family")); + BOOST_CHECK_NE(std::string::npos, console.find( + "msgch::dispatch_liq_yield: DROP attestation -- payload chain_code=")); + BOOST_CHECK_NE(std::string::npos, console.find( + "msgch::dispatch_liq_yield: DROP attestation -- token is not an active liq token")); + BOOST_CHECK_NE(std::string::npos, console.find("sysio.liq::mintyield: DROP -- replayed sequence")); +} FC_LOG_AND_RETHROW() } + +// Without a token registry there is no active liq token, so the envelope is delivered and the +// attestations dropped before msgch would send anything to a sysio.liq that does not exist. +BOOST_FIXTURE_TEST_CASE(liq_attestations_are_dropped_without_a_token_registry, + sysio_dispatch_tester) { try { + bootstrap_for_dispatch(); + + const auto eth = fc::slug_name{"ETH"}.value; + const auto liqeth = fc::slug_name{"LIQETH"}.value; + const auto env = encode_envelope_with_mixed_attestations(current_epoch(), { + {ATTESTATION_TYPE_SYNDICATE_LIQ, encode_syndicate_liq(eth, ChainKind::CHAIN_KIND_EVM, uwrit_op_eth_pubkey, + liqeth, 1 * LIQ_UNIT, 1)}, + {ATTESTATION_TYPE_LIQ_YIELD, encode_liq_yield(eth, liqeth, 1 * LIQ_UNIT, 2, 1)}, + }); + const auto trace = deliver_trace(/*proven=*/ eth, env); + BOOST_REQUIRE(trace != nullptr); + BOOST_REQUIRE(!trace->except); + const auto console = all_console(trace); + BOOST_CHECK_NE(std::string::npos, console.find( + "msgch::dispatch_syndicate_liq: DROP attestation -- token is not an active liq token")); + BOOST_CHECK_NE(std::string::npos, console.find( + "msgch::dispatch_liq_yield: DROP attestation -- token is not an active liq token")); +} FC_LOG_AND_RETHROW() } + BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.liq_tests.cpp b/contracts/tests/sysio.liq_tests.cpp new file mode 100644 index 0000000000..e898aeb33d --- /dev/null +++ b/contracts/tests/sysio.liq_tests.cpp @@ -0,0 +1,1043 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "contracts.hpp" +#include "contract_test_support.hpp" +#include "liq_test_support.hpp" +#include "shadow_yield_reference.hpp" + +#include +#include +#include +#include + +using namespace sysio::testing; +using namespace sysio; +using namespace sysio::chain; +using namespace sysio::opp::types; +using namespace fc; + +using mvo = fc::mutable_variant_object; +using sysio_system::test_support::codename_mvo; + +namespace { + +/// The action data of `sysio.system::fundclaim(name recipient, int64_t amount)`, for +/// reading the kicker request out of an addyield trace. +struct fundclaim_args { + name recipient; + int64_t amount; +}; + +} // anonymous namespace +FC_REFLECT( fundclaim_args, (recipient)(amount) ) + +/// sysio.liq end to end on the depot: the registries it validates against, the +/// swap it feeds, the authex links it resolves, and sysio.msgch as both the +/// inbound signer and the outbound queue. The treasury supply of WIRE sits on +/// `sysio`, as the bootstrap leaves it. +class sysio_liq_tester : public tester { +public: + static constexpr auto LIQ_ACCOUNT = "sysio.liq"_n; + static constexpr auto MSGCH_ACCOUNT = "sysio.msgch"_n; + static constexpr auto AUTHEX_ACCOUNT = "sysio.authex"_n; + static constexpr auto TOKEN_ACCOUNT = "sysio.token"_n; + static constexpr auto TOKENS_ACCOUNT = "sysio.tokens"_n; + static constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; + static constexpr auto SWAP_ACCOUNT = "sysio.swap"_n; + static constexpr auto SYSIO_ACCOUNT = "sysio"_n; + + static inline const symbol WIRE_SYM = symbol::from_string("9,WIRE"); + static inline const symbol LIQSOL_SYM = symbol::from_string("9,LIQSOL"); + static inline const symbol LIQETH_SYM = symbol::from_string("9,LIQETH"); + static inline const symbol POOL_SYM = symbol::from_string("9,LIQPOOL"); + static constexpr int64_t UNIT = 1'000'000'000; // one whole token of either symbol, in subunits + + static constexpr std::string_view SOLANA = "SOLANA"; + static constexpr std::string_view ETH = "ETH"; + static constexpr std::string_view LIQSOL = "LIQSOL"; + static constexpr std::string_view LIQETH = "LIQETH"; + + /// `seed_registries` registers the two outposts, their liq tokens and the LIQSOL shadow + /// every ledger case starts from; the launch-ingestion case replays a bootstrap config + /// into empty registries instead. + explicit sysio_liq_tester(bool seed_registries = true) { + produce_blocks(2); + // sysio.authex is pre-created by the tester boot. + create_accounts({ LIQ_ACCOUNT, MSGCH_ACCOUNT, TOKEN_ACCOUNT, TOKENS_ACCOUNT, CHAINS_ACCOUNT, SWAP_ACCOUNT, + "alice"_n, "bob"_n, "carol"_n, "dave"_n }); + produce_blocks(2); + + deploy(AUTHEX_ACCOUNT, contracts::authex_wasm(), contracts::authex_abi(), authex_abi_ser, true); + deploy(CHAINS_ACCOUNT, contracts::chains_wasm(), contracts::chains_abi(), chains_abi_ser, true); + deploy(TOKENS_ACCOUNT, contracts::tokens_wasm(), contracts::tokens_abi(), tokens_abi_ser, true); + deploy(TOKEN_ACCOUNT, contracts::token_wasm(), contracts::token_abi(), token_abi_ser, true); + deploy(MSGCH_ACCOUNT, contracts::msgch_wasm(), contracts::msgch_abi(), msgch_abi_ser, true); + deploy(SWAP_ACCOUNT, contracts::swap_wasm(), contracts::swap_abi(), swap_abi_ser, false); + deploy(LIQ_ACCOUNT, contracts::liq_wasm(), contracts::liq_abi(), liq_abi_ser, true); + + // WIRE: the treasury supply on sysio, and working balances for the funders. + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi_ser, TOKEN_ACCOUNT, "create"_n, mvo() + ("issuer", SYSIO_ACCOUNT)("maximum_supply", asset(1'000'000'000 * UNIT, WIRE_SYM)))); + BOOST_REQUIRE_EQUAL(success(), push(TOKEN_ACCOUNT, token_abi_ser, SYSIO_ACCOUNT, "issue"_n, mvo() + ("to", SYSIO_ACCOUNT)("quantity", asset(1'000'000'000 * UNIT, WIRE_SYM))("memo", ""))); + for (auto funder : { "alice"_n, "bob"_n, "carol"_n }) + BOOST_REQUIRE_EQUAL(success(), transfer_wire(SYSIO_ACCOUNT, funder, 1'000'000 * UNIT)); + + // The swap: governance is sysio, the system token WIRE. + BOOST_REQUIRE_EQUAL(success(), push(SWAP_ACCOUNT, swap_abi_ser, SWAP_ACCOUNT, "setconfig"_n, mvo() + ("fee_authority", SYSIO_ACCOUNT)("system_token", extended_symbol{ WIRE_SYM, TOKEN_ACCOUNT }))); + + if (!seed_registries) return; + + // Two outposts, each with an active liq token, plus a plain SPL token. + BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_SVM, SOLANA, 2)); + BOOST_REQUIRE_EQUAL(success(), regchain(ChainKind::CHAIN_KIND_EVM, ETH, 1)); + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, LIQSOL, ChainKind::CHAIN_KIND_SVM, SOLANA)); + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, LIQETH, ChainKind::CHAIN_KIND_EVM, ETH)); + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_SPL, "USDCSOL", ChainKind::CHAIN_KIND_SVM, SOLANA)); + + BOOST_REQUIRE_EQUAL(success(), create(LIQSOL_SYM, SOLANA, LIQSOL)); + } + + // --- deployment and pushing --- + + void deploy(name account, const std::vector& wasm, const std::vector& abi, + abi_serializer& ser, bool privileged) { + set_code(account, wasm); + set_abi(account, abi.data()); + if (privileged) set_privileged(account); + produce_blocks(); + sysio_system::test_support::load_account_abi(*this, account, ser); + } + + action_result push(name code, abi_serializer& ser, name signer, name action_name, const variant_object& data, + std::vector authorization = {}) { + return sysio_system::test_support::push_contract_action_and_produce_block(*this, code, ser, signer, + action_name, data, + std::move(authorization)); + } + action_result push_liq(name signer, name action_name, const variant_object& data) { + return push(LIQ_ACCOUNT, liq_abi_ser, signer, action_name, data); + } + static bool mentions(const action_result& r, std::string_view text) { + return r.find(text) != std::string::npos; + } + + // --- slugs and keys --- + + static uint64_t slug_value(std::string_view s) { return fc::slug_name{ s }.value; } + + static fc::crypto::public_key ed_key() { + return fc::crypto::private_key::generate(fc::crypto::private_key::key_type::ed).get_public_key(); + } + static std::vector ed_bytes(const fc::crypto::public_key& pk) { + const auto& raw = pk.get()._data; + return std::vector(raw.begin(), raw.end()); + } + + // --- registries --- + + // Registrations inside the epoch-0 bootstrap window land ACTIVE, as the launch bootstrap's do. + action_result regchain(ChainKind kind, std::string_view code, uint32_t external_chain_id) { + return push(CHAINS_ACCOUNT, chains_abi_ser, CHAINS_ACCOUNT, "regchain"_n, mvo() + ("kind", kind)("code", codename_mvo(code))("external_chain_id", external_chain_id) + ("name", std::string("outpost"))("description", std::string{}) + ("outpost", sysio_system::test_support::no_outpost_mvo())); + } + /// Register a token and bind it to its chain, as the bootstrap does from a `TokenSpec`. + action_result regtoken(TokenKind kind, std::string_view code, uint32_t precision, ChainKind chain_kind, + std::string_view chain_code, const std::vector& address) { + auto r = push(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, "regtoken"_n, mvo() + ("kind", kind)("code", codename_mvo(code))("symbol_name", std::string(code))("description", std::string{}) + ("precision", precision)("address", mvo()("kind", chain_kind)("address", address))); + if (r != success()) return r; + return push(TOKENS_ACCOUNT, tokens_abi_ser, TOKENS_ACCOUNT, "regctok"_n, mvo() + ("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(code))("contract_addr", address)("is_native", false)); + } + /// A 9-decimal token at a placeholder address of the chain family's width. + action_result regtoken(TokenKind kind, std::string_view code, ChainKind chain_kind, std::string_view chain_code) { + return regtoken(kind, code, 9, chain_kind, chain_code, + std::vector(chain_kind == ChainKind::CHAIN_KIND_SVM ? 32 : 20, char(0x5a))); + } + /// The bytes behind a bootstrap config's display-form address or pubkey: `0x`-hex on + /// EVM, base58 on SVM. + static std::vector address_bytes(ChainKind kind, const std::string& display) { + if (kind == ChainKind::CHAIN_KIND_SVM) return fc::from_base58(display); + BOOST_REQUIRE_MESSAGE(display.starts_with("0x"), "not a 0x-hex address: " << display); + std::vector out((display.size() - 2) / 2); + out.resize(fc::from_hex(std::string_view(display).substr(2), out.data(), out.size())); + return out; + } + /// A link recorded the depot's way, signed as sysio.authex. + action_result recordlink(name account, ChainKind kind, const fc::crypto::public_key& pub_key) { + // The trusted path carries the chain-native address alongside the key; for the ED keys + // these tests link on SVM that is the key's own 32 bytes. + return push(AUTHEX_ACCOUNT, authex_abi_ser, AUTHEX_ACCOUNT, "recordlink"_n, mvo() + ("account", account)("chain_kind", kind)("pub_key", pub_key) + ("native_address", sysio_liq::test_support::native_address_of(pub_key))); + } + + // --- sysio.liq actions --- + + action_result create(symbol sym, std::string_view chain_code, std::string_view token_code, name signer = LIQ_ACCOUNT) { + return push_liq(signer, "create"_n, mvo()("sym", sym)("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code))); + } + action_result mintsynd(std::string_view chain_code, uint64_t sequence, name account, std::string_view token_code, + uint64_t amount, name signer = MSGCH_ACCOUNT) { + return push_liq(signer, "mintsynd"_n, mvo()("chain_code", codename_mvo(chain_code))("sequence", sequence) + ("account", account)("token_code", codename_mvo(token_code))("amount", amount)); + } + action_result park(std::string_view chain_code, uint64_t sequence, ChainKind kind, const std::vector& pubkey, + std::string_view token_code, uint64_t amount, name signer = MSGCH_ACCOUNT) { + return push_liq(signer, "park"_n, mvo()("chain_code", codename_mvo(chain_code))("sequence", sequence) + ("chain_kind", kind)("pubkey", pubkey)("token_code", codename_mvo(token_code))("amount", amount)); + } + action_result mintyield(std::string_view chain_code, uint64_t sequence, uint64_t epoch, std::string_view token_code, + uint64_t amount, name signer = MSGCH_ACCOUNT) { + return push_liq(signer, "mintyield"_n, mvo()("chain_code", codename_mvo(chain_code))("sequence", sequence) + ("epoch", epoch)("token_code", codename_mvo(token_code))("amount", amount)); + } + action_result queueyield(symbol sym, name signer = "alice"_n) { + return push_liq(signer, "queueyield"_n, mvo()("sym", sym.to_symbol_code())); + } + action_result sweep(name account, ChainKind kind, name signer = "alice"_n) { + return push_liq(signer, "sweep"_n, mvo()("account", account)("chain_kind", kind)); + } + action_result linkswept(name account, ChainKind kind, const std::vector& pubkey, name signer = AUTHEX_ACCOUNT) { + return push_liq(signer, "linkswept"_n, mvo()("account", account)("chain_kind", kind)("pubkey", pubkey)); + } + action_result transfer_shadow(name from, name to, int64_t amount, const std::string& memo = "") { + return push_liq(from, "transfer"_n, mvo()("from", from)("to", to)("quantity", asset(amount, LIQSOL_SYM))("memo", memo)); + } + action_result open(name owner, symbol sym, name ram_payer) { + return push_liq(ram_payer, "open"_n, mvo()("owner", owner)("symbol", sym)("ram_payer", ram_payer)); + } + action_result close(name owner, symbol sym) { + return push_liq(owner, "close"_n, mvo()("owner", owner)("symbol", sym)); + } + action_result claim(name holder, symbol sym = LIQSOL_SYM) { + return push_liq(holder, "claim"_n, mvo()("holder", holder)("sym", sym.to_symbol_code())); + } + action_result addyield(name from, int64_t wire_amount, symbol target = LIQSOL_SYM) { + return push_liq(from, "addyield"_n, mvo()("from", from)("quantity", asset(wire_amount, WIRE_SYM)) + ("target", target.to_symbol_code())); + } + action_result addkicker(name signer, symbol sym, int64_t base_balance, uint64_t requested) { + return push_liq(signer, "addkicker"_n, mvo()("sym", sym.to_symbol_code())("base_balance", base_balance) + ("requested", requested)); + } + action_result setkicker(name signer, uint32_t bps) { + return push_liq(signer, "setkicker"_n, mvo()("bps", bps)); + } + action_result desyndicate(name holder, int64_t amount, symbol sym = LIQSOL_SYM) { + return push_liq(holder, "desyndicate"_n, mvo()("holder", holder)("quantity", asset(amount, sym))); + } + action_result recredit(name holder, int64_t amount, name signer = LIQ_ACCOUNT) { + return push_liq(signer, "recredit"_n, mvo()("holder", holder)("quantity", asset(amount, LIQSOL_SYM))); + } + action_result regliqpool(std::string_view chain_code, std::string_view token_code, symbol pair_symbol, + uint64_t initial_chain_amount, uint64_t initial_wire_amount, int32_t fee = 30, + int64_t locked_shares = 0, uint32_t horizon_sec = 86400, uint32_t depth_cap_bps = 300, + int64_t clip_floor = 1000, name signer = LIQ_ACCOUNT) { + return push_liq(signer, "regliqpool"_n, mvo()("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code)) + ("pair_symbol", pair_symbol)("initial_chain_amount", initial_chain_amount) + ("initial_wire_amount", initial_wire_amount)("fee", fee)("locked_shares", locked_shares) + ("conversion_horizon_sec", horizon_sec)("depth_cap_bps", depth_cap_bps)("clip_floor", clip_floor)); + } + static mvo credit(const std::vector& pubkey, uint64_t amount) { + return mvo()("pubkey", pubkey)("amount", amount); + } + action_result importsynd(std::string_view chain_code, std::string_view token_code, const fc::variants& credits, + name signer = LIQ_ACCOUNT) { + return push_liq(signer, "importsynd"_n, mvo()("chain_code", codename_mvo(chain_code))("token_code", codename_mvo(token_code)) + ("credits", credits)); + } + action_result importdone(name signer = LIQ_ACCOUNT) { + return push_liq(signer, "importdone"_n, mvo()); + } + + // --- other contracts --- + + action_result transfer_wire(name from, name to, int64_t amount) { + return push(TOKEN_ACCOUNT, token_abi_ser, from, "transfer"_n, mvo() + ("from", from)("to", to)("quantity", asset(amount, WIRE_SYM))("memo", "")); + } + action_result tickyield(symbol pair = POOL_SYM, name signer = "alice"_n) { + return push(SWAP_ACCOUNT, swap_abi_ser, signer, "tickyield"_n, mvo()("pair_token", pair.to_symbol_code())); + } + // The swap is unprivileged and bills a user's rows to the user, so these carry the + // user's `sysio.payer` beside `active`, as the swap suite's own pushes do. + /// A user's deposit row on the swap for one token, RAM billed to `payer`. + action_result openext(name user, name payer, const extended_symbol& ext_symbol) { + return push(SWAP_ACCOUNT, swap_abi_ser, payer, "openext"_n, mvo() + ("user", user)("payer", payer)("ext_symbol", ext_symbol), + sysio_system::test_support::payer_authorization(payer)); + } + /// Sell `ext_asset_in` from the user's deposit into `pair` for at least `min_expected`. + action_result exchange(name user, symbol pair, const extended_asset& ext_asset_in, const asset& min_expected) { + return push(SWAP_ACCOUNT, swap_abi_ser, user, "exchange"_n, mvo() + ("user", user)("pair_token", pair.to_symbol_code())("ext_asset_in", ext_asset_in)("min_expected", min_expected), + sysio_system::test_support::payer_authorization(user)); + } + /// Move `to_withdraw` from the user's deposit on the swap to `to`'s wallet. + action_result withdraw(name user, name to, const extended_asset& to_withdraw) { + return push(SWAP_ACCOUNT, swap_abi_ser, user, "withdraw"_n, mvo() + ("user", user)("to", to)("to_withdraw", to_withdraw)("memo", ""), + sysio_system::test_support::payer_authorization(user)); + } + + // --- rows --- + + fc::variant decode(abi_serializer& ser, const char* type, const std::vector& data) { + return data.empty() ? fc::variant() + : ser.binary_to_variant(type, data, abi_serializer::create_yield_function(abi_serializer_max_time)); + } + fc::variant liq_row(name table, const char* type, name scope, uint64_t id) { + return decode(liq_abi_ser, type, get_row_by_id(LIQ_ACCOUNT, scope, table, id)); + } + fc::variant account_row(name holder, symbol sym = LIQSOL_SYM) { + return liq_row("accounts"_n, "account", holder, sym.to_symbol_code().value); + } + fc::variant index_row(symbol sym = LIQSOL_SYM) { + return liq_row("yieldidx"_n, "yield_index", LIQ_ACCOUNT, sym.to_symbol_code().value); + } + fc::variant stat_row(symbol sym = LIQSOL_SYM) { + return liq_row("stat"_n, "currency_stats", LIQ_ACCOUNT, sym.to_symbol_code().value); + } + fc::variant pending_row(symbol sym = LIQSOL_SYM) { + return liq_row("liqpending"_n, "pending_yield", LIQ_ACCOUNT, sym.to_symbol_code().value); + } + fc::variant cursor_row(std::string_view chain_code) { + return liq_row("liqcursors"_n, "liq_cursor", LIQ_ACCOUNT, slug_value(chain_code)); + } + fc::variant config_row() { + return decode(liq_abi_ser, "liq_config", get_row_by_account(LIQ_ACCOUNT, LIQ_ACCOUNT, "liqconfig"_n, "liqconfig"_n)); + } + fc::variant counters_row() { + return decode(liq_abi_ser, "liq_counters", get_row_by_account(LIQ_ACCOUNT, LIQ_ACCOUNT, "liqcounters"_n, "liqcounters"_n)); + } + /// A parked row by its composite key (see `liq_test_support::parked_key`). + fc::variant parked_row(symbol sym, ChainKind kind, const std::vector& pubkey) { + using namespace sysio_liq::test_support; + return decode(liq_abi_ser, "parked_row", + parked_row_bytes(*control, LIQ_ACCOUNT, parked_key(sym.to_symbol_code(), kind, pubkey))); + } + int64_t wire_balance(name holder) { + const auto row = decode(token_abi_ser, "account", + get_row_by_id(TOKEN_ACCOUNT, holder, "accounts"_n, WIRE_SYM.to_symbol_code().value)); + return row.is_null() ? 0 : row["balance"].as().get_amount(); + } + int64_t shadow_balance(name holder) { + const auto row = account_row(holder); + return row.is_null() ? 0 : row["balance"].as().get_amount(); + } + int64_t supply() { return stat_row()["supply"].as().get_amount(); } + fc::uint128_t index() { + const auto row = index_row(); + return row.is_null() ? fc::uint128_t{ 0 } : row["index"].as_uint128(); + } + uint64_t pot() { + const auto row = index_row(); + return row.is_null() ? 0 : row["pot"].as_uint64(); + } + /// What the token's spec says `holder` is owed now. + int64_t owed(name holder) { + const auto row = account_row(holder); + if (row.is_null()) return 0; + return yield_reference::owed(row["balance"].as().get_amount(), index(), + row["index_checkpoint"].as_uint128(), row["owed_wire"].as_uint64()); + } + fc::variant swap_row(name table, const char* type, name scope, uint64_t id) { + return decode(swap_abi_ser, type, get_row_by_id(SWAP_ACCOUNT, scope, table, id)); + } + int64_t reservoir() { + const auto row = swap_row("reservoirs"_n, "reservoir", SWAP_ACCOUNT, POOL_SYM.to_symbol_code().value); + return row.is_null() ? -1 : row["balance"]["quantity"].as().get_amount(); + } + /// The swap's `stat` row of a pair: `pool1` is the shadow leg, `pool2` the WIRE leg. + fc::variant swap_pool_row(symbol pair) { + return swap_row("stat"_n, "currency_stats", SWAP_ACCOUNT, pair.to_symbol_code().value); + } + fc::variant attestation_row(uint64_t id) { + return decode(msgch_abi_ser, "attestation_entry", get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "attestations"_n, id)); + } + + abi_serializer liq_abi_ser, token_abi_ser, tokens_abi_ser, chains_abi_ser, swap_abi_ser, authex_abi_ser, msgch_abi_ser; +}; + +/// The contracts deployed and WIRE issued, but no registry row and no shadow: the state +/// the launch bootstrap finds when it replays a config. +struct sysio_liq_config_tester : sysio_liq_tester { + sysio_liq_config_tester() : sysio_liq_tester(false) {} +}; + +namespace { + +/// The dev bootstrap config, parsed as strictly as the bootstrap tool parses it. +sysio::opp::bootstrap::BootstrapPlatformConfig load_dev_config() { + const auto path = contracts::dex_config_dir() + "/dex-config.dev.json"; + std::ifstream in(path); + BOOST_REQUIRE_MESSAGE(in.good(), "cannot open " << path); + std::stringstream json; + json << in.rdbuf(); + sysio::opp::bootstrap::BootstrapPlatformConfig cfg; + google::protobuf::util::JsonParseOptions opts; // an unknown field is an error + const auto status = google::protobuf::util::JsonStringToMessage(json.str(), &cfg, opts); + BOOST_REQUIRE_MESSAGE(status.ok(), status.ToString()); + return cfg; +} + +} // anonymous namespace + +BOOST_AUTO_TEST_SUITE(sysio_liq_tests) + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(create_binds_an_active_liq_token, sysio_liq_tester) try { + const auto st = stat_row(); + BOOST_REQUIRE_EQUAL(0, st["supply"].as().get_amount()); + BOOST_REQUIRE_EQUAL(slug_value(SOLANA), st["chain_code"]["value"].as_uint64()); + BOOST_REQUIRE_EQUAL(slug_value(LIQSOL), st["token_code"]["value"].as_uint64()); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code already has a shadow symbol"), + create(symbol::from_string("9,LIQSOLB"), SOLANA, LIQSOL)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code is not an active registry token"), + create(LIQETH_SYM, ETH, "NOPE")); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code is not a liq token"), + create(symbol::from_string("9,USDCSOL"), SOLANA, "USDCSOL")); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("symbol precision must match the token's depot precision"), + create(symbol::from_string("4,LIQETH"), ETH, LIQETH)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code is not bound to chain_code"), + create(LIQETH_SYM, SOLANA, LIQETH)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("symbol already exists"), + create(LIQSOL_SYM, ETH, LIQETH)); + BOOST_REQUIRE(mentions(create(LIQETH_SYM, ETH, LIQETH, "alice"_n), "missing authority of sysio.liq")); + BOOST_REQUIRE_EQUAL(success(), create(LIQETH_SYM, ETH, LIQETH)); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Inbound: SYNDICATE_LIQ +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(mintsynd_credits_a_holder_and_drops_what_it_must, sysio_liq_tester) try { + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + auto row = account_row("alice"_n); + BOOST_REQUIRE_EQUAL(100 * UNIT, row["balance"].as().get_amount()); + BOOST_REQUIRE_EQUAL(fc::uint128_t{ 0 }, row["index_checkpoint"].as_uint128()); + BOOST_REQUIRE_EQUAL(0u, row["owed_wire"].as_uint64()); + BOOST_REQUIRE_EQUAL(100 * UNIT, supply()); + BOOST_REQUIRE_EQUAL(1u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + + // Every drop returns success and changes nothing: a replay, the sequence + // before it, an unknown token, a token of another chain, an out-of-range + // amount and an account that does not exist. + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 0, "alice"_n, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 2, "alice"_n, "NOPE", 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mintsynd(ETH, 2, "alice"_n, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 2, "alice"_n, LIQSOL, 0)); + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 2, "alice"_n, LIQSOL, (uint64_t{ 1 } << 62))); + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 2, "nobody"_n, LIQSOL, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(100 * UNIT, shadow_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(100 * UNIT, supply()); + // A drop consumes no sequence: the same sequence still admits a valid credit. + BOOST_REQUIRE_EQUAL(1u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 2, "bob"_n, LIQSOL, 50 * UNIT)); + BOOST_REQUIRE_EQUAL(50 * UNIT, shadow_balance("bob"_n)); + BOOST_REQUIRE_EQUAL(150 * UNIT, supply()); + BOOST_REQUIRE_EQUAL(2u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + + BOOST_REQUIRE(mentions(mintsynd(SOLANA, 3, "alice"_n, LIQSOL, UNIT, "alice"_n), "missing authority of sysio.msgch")); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(parked_rows_accrue_and_are_delivered_on_link, sysio_liq_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "bob"_n, LIQSOL, 50 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), park(SOLANA, 2, ChainKind::CHAIN_KIND_SVM, pubkey, LIQSOL, 50 * UNIT)); + BOOST_REQUIRE_EQUAL(100 * UNIT, supply()); + auto parked = parked_row(LIQSOL_SYM, ChainKind::CHAIN_KIND_SVM, pubkey); + BOOST_REQUIRE(!parked.is_null()); + BOOST_REQUIRE_EQUAL(50 * UNIT, parked["holding"]["balance"].as().get_amount()); + BOOST_REQUIRE(pubkey == parked["pubkey"].as>()); + + // A pubkey of the wrong width, or a chain kind that is not the token's, is dropped. + BOOST_REQUIRE_EQUAL(success(), park(SOLANA, 3, ChainKind::CHAIN_KIND_SVM, std::vector(20, 'x'), LIQSOL, UNIT)); + BOOST_REQUIRE_EQUAL(success(), park(SOLANA, 3, ChainKind::CHAIN_KIND_EVM, std::vector(33, 'x'), LIQSOL, UNIT)); + BOOST_REQUIRE_EQUAL(100 * UNIT, supply()); + + // Yield lands while the row is parked: half the supply, half the WIRE. + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 10 * UNIT)); + const auto first = yield_reference::distribute(10 * UNIT, 100 * UNIT, 0); + BOOST_REQUIRE_EQUAL(first.index_delta, yield_reference::wide(index())); + + // Nothing to sweep before the link exists, and nothing links a stranger. + BOOST_REQUIRE_EQUAL(wasm_assert_msg("account has no link for this chain"), sweep("carol"_n, ChainKind::CHAIN_KIND_SVM)); + BOOST_REQUIRE_EQUAL(success(), recordlink("carol"_n, ChainKind::CHAIN_KIND_SVM, key)); + BOOST_REQUIRE_EQUAL(success(), sweep("carol"_n, ChainKind::CHAIN_KIND_SVM)); + BOOST_REQUIRE(parked_row(LIQSOL_SYM, ChainKind::CHAIN_KIND_SVM, pubkey).is_null()); + auto carol = account_row("carol"_n); + BOOST_REQUIRE_EQUAL(50 * UNIT, carol["balance"].as().get_amount()); + BOOST_REQUIRE_EQUAL(uint64_t(5 * UNIT), carol["owed_wire"].as_uint64()); // banked at the sweep + BOOST_REQUIRE_EQUAL(index(), carol["index_checkpoint"].as_uint128()); + BOOST_REQUIRE_EQUAL(5 * UNIT, owed("carol"_n)); + BOOST_REQUIRE_EQUAL(5 * UNIT, owed("bob"_n)); + + // Sweeping again finds nothing; claiming pays exactly the banked WIRE. + BOOST_REQUIRE_EQUAL(success(), sweep("carol"_n, ChainKind::CHAIN_KIND_SVM)); + const int64_t before = wire_balance("carol"_n); + BOOST_REQUIRE_EQUAL(success(), claim("carol"_n)); + BOOST_REQUIRE_EQUAL(before + 5 * UNIT, wire_balance("carol"_n)); + BOOST_REQUIRE_EQUAL(uint64_t(5 * UNIT), pot()); // bob's share remains + + // The inline path from sysio.authex delivers the same way. + const auto key2 = ed_key(); + const auto pubkey2 = ed_bytes(key2); + BOOST_REQUIRE_EQUAL(success(), park(SOLANA, 3, ChainKind::CHAIN_KIND_SVM, pubkey2, LIQSOL, 20 * UNIT)); + BOOST_REQUIRE(mentions(linkswept("dave"_n, ChainKind::CHAIN_KIND_SVM, pubkey2, "dave"_n), "missing authority of sysio.authex")); + BOOST_REQUIRE_EQUAL(success(), linkswept("dave"_n, ChainKind::CHAIN_KIND_SVM, pubkey2)); + BOOST_REQUIRE_EQUAL(20 * UNIT, shadow_balance("dave"_n)); + BOOST_REQUIRE_EQUAL(index(), account_row("dave"_n)["index_checkpoint"].as_uint128()); + BOOST_REQUIRE_EQUAL(0, owed("dave"_n)); // stamped at the current index: no history credited +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// The token: settle before mutate +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(settle_before_mutate_conserves_every_subunit, sysio_liq_tester) try { + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 2, "bob"_n, LIQSOL, 300 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(25 * UNIT, owed("alice"_n)); + BOOST_REQUIRE_EQUAL(75 * UNIT, owed("bob"_n)); + + // A transfer mid-period banks each side's accrual and restamps both rows. + BOOST_REQUIRE_EQUAL(success(), transfer_shadow("alice"_n, "bob"_n, 50 * UNIT)); + auto alice = account_row("alice"_n); + auto bob = account_row("bob"_n); + BOOST_REQUIRE_EQUAL(uint64_t(25 * UNIT), alice["owed_wire"].as_uint64()); + BOOST_REQUIRE_EQUAL(uint64_t(75 * UNIT), bob["owed_wire"].as_uint64()); + BOOST_REQUIRE_EQUAL(index(), alice["index_checkpoint"].as_uint128()); + BOOST_REQUIRE_EQUAL(index(), bob["index_checkpoint"].as_uint128()); + + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(25 * UNIT + 12'500'000'000, owed("alice"_n)); // 50 of 400 + BOOST_REQUIRE_EQUAL(75 * UNIT + 87'500'000'000, owed("bob"_n)); // 350 of 400 + BOOST_REQUIRE_EQUAL(owed("alice"_n) + owed("bob"_n), 200 * UNIT); + + // Both claims pay what the spec says and empty the pot. + const int64_t alice_before = wire_balance("alice"_n), bob_before = wire_balance("bob"_n); + const int64_t alice_owed = owed("alice"_n), bob_owed = owed("bob"_n); + BOOST_REQUIRE_EQUAL(success(), claim("alice"_n)); + BOOST_REQUIRE_EQUAL(success(), claim("bob"_n)); + BOOST_REQUIRE_EQUAL(alice_before + alice_owed, wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(bob_before + bob_owed, wire_balance("bob"_n)); + BOOST_REQUIRE_EQUAL(0u, pot()); + BOOST_REQUIRE_EQUAL(0, owed("alice"_n)); + + // A row opened after the index moved is stamped, not zeroed: nothing is owed, + // claiming sends nothing, and a later credit accrues only from here. + BOOST_REQUIRE_EQUAL(success(), open("dave"_n, LIQSOL_SYM, "dave"_n)); + BOOST_REQUIRE_EQUAL(index(), account_row("dave"_n)["index_checkpoint"].as_uint128()); + const int64_t dave_before = wire_balance("dave"_n); + BOOST_REQUIRE_EQUAL(success(), claim("dave"_n)); + BOOST_REQUIRE_EQUAL(dave_before, wire_balance("dave"_n)); + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 3, "dave"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(0, owed("dave"_n)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 50 * UNIT)); // 500 supply: dave holds a fifth + BOOST_REQUIRE_EQUAL(10 * UNIT, owed("dave"_n)); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg("overdrawn balance"), transfer_shadow("dave"_n, "alice"_n, 101 * UNIT)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("no balance object found"), transfer_shadow("carol"_n, "alice"_n, UNIT)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("yield must be in WIRE"), + push_liq("carol"_n, "addyield"_n, mvo()("from", "carol"_n)("quantity", asset(UNIT, LIQSOL_SYM)) + ("target", LIQSOL_SYM.to_symbol_code()))); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(a_one_unit_distribution_carries_its_remainder, sysio_liq_tester) try { + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 3)); // three subunits + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 1)); + BOOST_REQUIRE_EQUAL(1u, index_row()["carry"].as_uint64()); + BOOST_REQUIRE_EQUAL(0, owed("alice"_n)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 1)); + BOOST_REQUIRE_EQUAL(2u, index_row()["carry"].as_uint64()); + BOOST_REQUIRE_EQUAL(1, owed("alice"_n)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 1)); + BOOST_REQUIRE_EQUAL(0u, index_row()["carry"].as_uint64()); + BOOST_REQUIRE_EQUAL(fc::uint128_t{ yield_reference::Scale }, index()); + BOOST_REQUIRE_EQUAL(3, owed("alice"_n)); + BOOST_REQUIRE_EQUAL(3u, pot()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(claim_with_nothing_owed_sends_nothing, sysio_liq_tester) try { + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + const int64_t before = wire_balance("alice"_n); + BOOST_REQUIRE_EQUAL(0, owed("alice"_n)); + BOOST_REQUIRE_EQUAL(success(), claim("alice"_n)); + BOOST_REQUIRE_EQUAL(before, wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(0u, account_row("alice"_n)["owed_wire"].as_uint64()); + BOOST_REQUIRE_EQUAL(100 * UNIT, shadow_balance("alice"_n)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(close_refuses_while_yield_is_owed, sysio_liq_tester) try { + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("Cannot close because the balance is not zero."), close("alice"_n, LIQSOL_SYM)); + BOOST_REQUIRE_EQUAL(success(), transfer_shadow("alice"_n, "bob"_n, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(0, shadow_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(10 * UNIT, owed("alice"_n)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("Cannot close because yield is still owed; claim first."), close("alice"_n, LIQSOL_SYM)); + BOOST_REQUIRE_EQUAL(success(), claim("alice"_n)); + BOOST_REQUIRE_EQUAL(success(), close("alice"_n, LIQSOL_SYM)); + BOOST_REQUIRE(account_row("alice"_n).is_null()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("no balance object found"), claim("alice"_n)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(the_index_grows_past_64_bits, sysio_liq_tester) try { + // One subunit of supply and 2e7 subunits of yield move the index by 2e19. + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 1)); + const int64_t donation = 20'000'000; + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, donation)); + BOOST_REQUIRE_LT(yield_reference::wide(std::numeric_limits::max()), yield_reference::wide(index())); + BOOST_REQUIRE_EQUAL(donation, owed("alice"_n)); + const int64_t before = wire_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), claim("alice"_n)); + BOOST_REQUIRE_EQUAL(before + donation, wire_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(index(), account_row("alice"_n)["index_checkpoint"].as_uint128()); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, donation)); + BOOST_REQUIRE_EQUAL(donation, owed("alice"_n)); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// The kicker +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(addyield_requests_the_kicker_from_t5, sysio_liq_tester) try { + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(uint32_t(200), config_row().is_null() ? 200u : config_row()["kicker_bps"].as()); + + // The swap's intake is the one that is yield, so it is the one that earns the + // kicker. Stand in for the pool's proceeds: WIRE deposited on the swap. + BOOST_REQUIRE_EQUAL(success(), openext("alice"_n, "alice"_n, extended_symbol{ WIRE_SYM, TOKEN_ACCOUNT })); + BOOST_REQUIRE_EQUAL(success(), transfer_wire("alice"_n, SWAP_ACCOUNT, 300 * UNIT)); + // 100 WIRE in from `from`; whether the trace carries the 2% fundclaim for + // this contract and the fold that follows it. + const auto intake = [&](name from) { + auto trace = base_tester::push_action(LIQ_ACCOUNT, "addyield"_n, from, mvo() + ("from", from)("quantity", asset(100 * UNIT, WIRE_SYM))("target", LIQSOL_SYM.to_symbol_code())); + produce_block(); + bool requested = false, folded = false; + for (const auto& at : trace->action_traces) { + if (at.act.account == SYSIO_ACCOUNT && at.act.name == "fundclaim"_n) { + const auto args = fc::raw::unpack(at.act.data); + BOOST_REQUIRE_EQUAL(LIQ_ACCOUNT, args.recipient); + BOOST_REQUIRE_EQUAL(2 * UNIT, args.amount); + requested = true; + } + if (at.act.account == LIQ_ACCOUNT && at.act.name == "addkicker"_n) folded = true; + } + return std::make_pair(requested, folded); + }; + + // sysio runs no emissions here, so the request lands nothing and the fold is + // a no-op: holders get the base yield and nothing else. + BOOST_REQUIRE(intake(SWAP_ACCOUNT) == std::make_pair(true, true)); + BOOST_REQUIRE_EQUAL(100 * UNIT, owed("alice"_n)); + BOOST_REQUIRE_EQUAL(uint64_t(100 * UNIT), pot()); + + // A donation from anyone else distributes only itself: no request, no fold. + // Otherwise a near-sole holder could donate, claim it back with the kicker on + // top, and repeat against the treasury. + BOOST_REQUIRE(intake("carol"_n) == std::make_pair(false, false)); + BOOST_REQUIRE_EQUAL(200 * UNIT, owed("alice"_n)); + BOOST_REQUIRE_EQUAL(uint64_t(200 * UNIT), pot()); + + // With the kicker off, the swap's intake makes no request either. + BOOST_REQUIRE(mentions(setkicker("alice"_n, 0), "missing authority of sysio")); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("kicker_bps out of range"), setkicker(SYSIO_ACCOUNT, 10'001)); + BOOST_REQUIRE_EQUAL(success(), setkicker(SYSIO_ACCOUNT, 0)); + BOOST_REQUIRE_EQUAL(0u, config_row()["kicker_bps"].as()); + BOOST_REQUIRE(intake(SWAP_ACCOUNT) == std::make_pair(false, false)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(addkicker_folds_only_what_landed, sysio_liq_tester) try { + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + // Stand in for T5: 5 WIRE arrive at the contract. + BOOST_REQUIRE_EQUAL(success(), transfer_wire("carol"_n, LIQ_ACCOUNT, 5 * UNIT)); + const int64_t balance = wire_balance(LIQ_ACCOUNT); + + BOOST_REQUIRE(mentions(addkicker("alice"_n, LIQSOL_SYM, balance - 2 * UNIT, 3 * UNIT), "missing authority of sysio.liq")); + // 2 WIRE over the base, 3 requested: 2 fold in. + BOOST_REQUIRE_EQUAL(success(), addkicker(LIQ_ACCOUNT, LIQSOL_SYM, balance - 2 * UNIT, 3 * UNIT)); + BOOST_REQUIRE_EQUAL(uint64_t(2 * UNIT), pot()); + BOOST_REQUIRE_EQUAL(2 * UNIT, owed("alice"_n)); + // 2 over, 1 requested: only the request folds in. + BOOST_REQUIRE_EQUAL(success(), addkicker(LIQ_ACCOUNT, LIQSOL_SYM, balance - 2 * UNIT, UNIT)); + BOOST_REQUIRE_EQUAL(uint64_t(3 * UNIT), pot()); + // Nothing over the base: nothing folds. + BOOST_REQUIRE_EQUAL(success(), addkicker(LIQ_ACCOUNT, LIQSOL_SYM, balance, UNIT)); + BOOST_REQUIRE_EQUAL(uint64_t(3 * UNIT), pot()); + BOOST_REQUIRE_EQUAL(3 * UNIT, owed("alice"_n)); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Yield in: LIQ_YIELD -> pending -> the swap's reservoir -> clips -> holders +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(queued_yield_sells_through_the_pool_and_pays_holders, sysio_liq_tester) try { + BOOST_REQUIRE_EQUAL(success(), setkicker(SYSIO_ACCOUNT, 0)); + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + + // Nothing pending: queueing is a no-op, and the pool does not exist yet. + BOOST_REQUIRE_EQUAL(success(), queueyield(LIQSOL_SYM)); + BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, 2, 7, LIQSOL, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(10 * UNIT, pending_row()["quantity"].as().get_amount()); + BOOST_REQUIRE_EQUAL(7u, cursor_row(SOLANA)["last_epoch"].as_uint64()); + BOOST_REQUIRE_EQUAL(100 * UNIT, supply()); // pending yield is outside supply + BOOST_REQUIRE_EQUAL(wasm_assert_msg("no yield pool registered for this shadow"), queueyield(LIQSOL_SYM)); + + // The bootstrap seeds the pool: the LCO liq minted to sysio and deposited + // with the earmark WIRE, the pair created with the shadow as its yield leg. + const int64_t treasury_before = wire_balance(SYSIO_ACCOUNT); + BOOST_REQUIRE_EQUAL(success(), regliqpool(SOLANA, LIQSOL, POOL_SYM, 1000 * UNIT, 1000 * UNIT)); + BOOST_REQUIRE_EQUAL("LIQPOOL", stat_row()["pair_symbol"].as_string()); + BOOST_REQUIRE_EQUAL(1100 * UNIT, supply()); + BOOST_REQUIRE_EQUAL(0, shadow_balance(SYSIO_ACCOUNT)); + BOOST_REQUIRE_EQUAL(treasury_before - 1000 * UNIT, wire_balance(SYSIO_ACCOUNT)); + const auto pair = swap_row("stat"_n, "currency_stats", SWAP_ACCOUNT, POOL_SYM.to_symbol_code().value); + BOOST_REQUIRE_EQUAL(1000 * UNIT, pair["pool1"]["quantity"].as().get_amount()); + BOOST_REQUIRE_EQUAL(1000 * UNIT, pair["pool2"]["quantity"].as().get_amount()); + BOOST_REQUIRE_EQUAL(LIQ_ACCOUNT.to_string(), pair["yield_leg"]["contract"].as_string()); + BOOST_REQUIRE_EQUAL(SYSIO_ACCOUNT.to_string(), pair["fee_authority"].as_string()); + BOOST_REQUIRE_EQUAL(0, reservoir()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("the shadow already has a yield pool"), + regliqpool(SOLANA, LIQSOL, symbol::from_string("9,POOLB"), UNIT, UNIT)); + + // Queueing mints the pending yield to the contract and hands it straight to + // the reservoir: the contract keeps no balance and accrues nothing. + BOOST_REQUIRE_EQUAL(success(), queueyield(LIQSOL_SYM)); + BOOST_REQUIRE(pending_row().is_null()); + BOOST_REQUIRE_EQUAL(1110 * UNIT, supply()); + BOOST_REQUIRE_EQUAL(10 * UNIT, reservoir()); + BOOST_REQUIRE_EQUAL(0, shadow_balance(LIQ_ACCOUNT)); + BOOST_REQUIRE_EQUAL(0, owed(LIQ_ACCOUNT)); + BOOST_REQUIRE(swap_row("yieldfunds"_n, "fund_receipt", SWAP_ACCOUNT, LIQ_ACCOUNT.to_uint64_t()).is_null()); + BOOST_REQUIRE_EQUAL(success(), queueyield(LIQSOL_SYM)); // nothing left: a no-op + BOOST_REQUIRE_EQUAL(10 * UNIT, reservoir()); + + // A replayed report is dropped; a later one queues on top. + BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, 2, 7, LIQSOL, 10 * UNIT)); + BOOST_REQUIRE(pending_row().is_null()); + BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, 3, 8, LIQSOL, 2 * UNIT)); + BOOST_REQUIRE_EQUAL(2 * UNIT, pending_row()["quantity"].as().get_amount()); + BOOST_REQUIRE_EQUAL(8u, cursor_row(SOLANA)["last_epoch"].as_uint64()); + + // The tick sells a clip of the reservoir through the pool and pays the + // proceeds in through addyield: the index moves, and alice, a holder, is + // owed her share of them. + produce_blocks(40); + const int64_t alice_before = wire_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), tickyield()); + BOOST_REQUIRE_LT(reservoir(), 10 * UNIT); + BOOST_REQUIRE_LT(0u, pot()); + BOOST_REQUIRE_LT(fc::uint128_t{ 0 }, index()); + const int64_t alice_owed = owed("alice"_n); + BOOST_REQUIRE_LT(0, alice_owed); + BOOST_REQUIRE_EQUAL(success(), claim("alice"_n)); + BOOST_REQUIRE_EQUAL(alice_before + alice_owed, wire_balance("alice"_n)); +} FC_LOG_AND_RETHROW() + +// Supply plus pending yield never exceeds the asset range: every supply-growing path +// measures its headroom net of what is parked in liqpending, so queueyield can always +// mint it. A report past that headroom is dropped before its sequence is consumed, so +// no value is clipped behind an acknowledged sequence. +BOOST_FIXTURE_TEST_CASE(pending_yield_is_reserved_against_the_asset_range, sysio_liq_tester) try { + BOOST_REQUIRE_EQUAL(success(), setkicker(SYSIO_ACCOUNT, 0)); + // The pool first, while the range is open: regliqpool mints the LCO seed. Then one + // syndication takes supply to 100 base units under the range. + BOOST_REQUIRE_EQUAL(success(), regliqpool(SOLANA, LIQSOL, POOL_SYM, 1000 * UNIT, 1000 * UNIT)); + const uint64_t range = static_cast(asset::max_amount); + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, range - 100 - 1000 * UNIT)); + BOOST_REQUIRE_EQUAL(asset::max_amount - 100, supply()); + + // 60 fits and parks. A second 60 exceeds the 40 left and is dropped, its sequence + // unconsumed; 40 under the same sequence then fills the range exactly. + BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, 2, 7, LIQSOL, 60)); + BOOST_REQUIRE_EQUAL(60, pending_row()["quantity"].as().get_amount()); + BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, 3, 8, LIQSOL, 60)); + BOOST_REQUIRE_EQUAL(60, pending_row()["quantity"].as().get_amount()); + BOOST_REQUIRE_EQUAL(2u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, 3, 8, LIQSOL, 40)); + BOOST_REQUIRE_EQUAL(100, pending_row()["quantity"].as().get_amount()); + BOOST_REQUIRE_EQUAL(3u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + BOOST_REQUIRE_EQUAL(asset::max_amount - 100, supply()); // pending stays outside supply + + // The reservation binds every other supply-growing path: a credit of one base unit + // is dropped, and the governance mint is refused. + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 4, "bob"_n, LIQSOL, 1)); + BOOST_REQUIRE_EQUAL(0, shadow_balance("bob"_n)); + BOOST_REQUIRE_EQUAL(3u, cursor_row(SOLANA)["last_sequence"].as_uint64()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("supply exceeds the asset range"), recredit("alice"_n, 1)); + + // Queueing always fits: the pending yield mints to exactly the range. + BOOST_REQUIRE_EQUAL(success(), queueyield(LIQSOL_SYM)); + BOOST_REQUIRE(pending_row().is_null()); + BOOST_REQUIRE_EQUAL(asset::max_amount, supply()); + BOOST_REQUIRE_EQUAL(100, reservoir()); + // Nothing can grow the supply now, and a report says why before it is dropped. + BOOST_REQUIRE_EQUAL(success(), mintyield(SOLANA, 5, 9, LIQSOL, 1)); + BOOST_REQUIRE(pending_row().is_null()); + BOOST_REQUIRE_EQUAL(3u, cursor_row(SOLANA)["last_sequence"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// The other exit: a holder sells shadow into the yield pool for WIRE on the depot, no +// outpost round trip. The pool is an ordinary pair, so alice opens her two deposit rows, +// deposits shadow by transfer, exchanges it for WIRE and withdraws the WIRE to her wallet. +// Both legs conserve exactly: the pool gains what she sold and she receives what the pool +// lost; the shadow supply is untouched, since it moved and nothing burned; the fee and the +// price impact keep her proceeds below par. +BOOST_FIXTURE_TEST_CASE(a_holder_can_sell_shadow_into_the_yield_pool_for_wire, sysio_liq_tester) try { + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), regliqpool(SOLANA, LIQSOL, POOL_SYM, 1000 * UNIT, 1000 * UNIT)); + + const extended_symbol shadow_ext{ LIQSOL_SYM, LIQ_ACCOUNT }; + const extended_symbol wire_ext{ WIRE_SYM, TOKEN_ACCOUNT }; + BOOST_REQUIRE_EQUAL(success(), openext("alice"_n, "alice"_n, shadow_ext)); + BOOST_REQUIRE_EQUAL(success(), openext("alice"_n, "alice"_n, wire_ext)); + + constexpr int64_t sold = 50 * UNIT; + BOOST_REQUIRE_EQUAL(success(), transfer_shadow("alice"_n, SWAP_ACCOUNT, sold)); + BOOST_REQUIRE_EQUAL(50 * UNIT, shadow_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(1050 * UNIT, shadow_balance(SWAP_ACCOUNT)); // the pool's 1000 plus her deposit + + const auto before = swap_pool_row(POOL_SYM); + const int64_t pool_shadow_before = before["pool1"]["quantity"].as().get_amount(); + const int64_t pool_wire_before = before["pool2"]["quantity"].as().get_amount(); + BOOST_REQUIRE_EQUAL(success(), exchange("alice"_n, POOL_SYM, extended_asset{ asset(sold, LIQSOL_SYM), LIQ_ACCOUNT }, + asset(1, WIRE_SYM))); + const auto after = swap_pool_row(POOL_SYM); + const int64_t received = pool_wire_before - after["pool2"]["quantity"].as().get_amount(); + BOOST_REQUIRE_EQUAL(pool_shadow_before + sold, after["pool1"]["quantity"].as().get_amount()); + BOOST_REQUIRE_LT(0, received); + BOOST_REQUIRE_LT(received, sold); + BOOST_REQUIRE_EQUAL(1100 * UNIT, supply()); + + const int64_t wallet_before = wire_balance("alice"_n); + BOOST_REQUIRE_EQUAL(success(), withdraw("alice"_n, "alice"_n, extended_asset{ asset(received, WIRE_SYM), TOKEN_ACCOUNT })); + BOOST_REQUIRE_EQUAL(wallet_before + received, wire_balance("alice"_n)); + // The deposit held exactly the proceeds: nothing is left to withdraw. + BOOST_REQUIRE_EQUAL(wasm_assert_msg("insufficient funds"), + withdraw("alice"_n, "alice"_n, extended_asset{ asset(1, WIRE_SYM), TOKEN_ACCOUNT })); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE(regliqpool_refusals, sysio_liq_tester) try { + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code has no shadow symbol"), regliqpool(ETH, LIQETH, POOL_SYM, UNIT, UNIT)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code belongs to another chain"), regliqpool(ETH, LIQSOL, POOL_SYM, UNIT, UNIT)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("both seeds must be positive"), regliqpool(SOLANA, LIQSOL, POOL_SYM, 0, UNIT)); + BOOST_REQUIRE(mentions(regliqpool(SOLANA, LIQSOL, POOL_SYM, UNIT, UNIT, 30, 0, 86400, 300, 1000, "alice"_n), + "missing authority of sysio.liq")); + BOOST_REQUIRE_EQUAL("", stat_row()["pair_symbol"].as_string()); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Outbound: DESYNDICATE_LIQ +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(desyndicate_burns_and_queues_the_attestation, sysio_liq_tester) try { + const auto key = ed_key(); + const auto pubkey = ed_bytes(key); + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 2, "bob"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 20 * UNIT)); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg("holder is not AuthX-linked for the token's chain"), desyndicate("alice"_n, 40 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), recordlink("alice"_n, ChainKind::CHAIN_KIND_SVM, key)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("overdrawn balance"), desyndicate("alice"_n, 101 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 40 * UNIT)); + + // The burn settled the row first: the yield earned on the whole balance stays. + BOOST_REQUIRE_EQUAL(60 * UNIT, shadow_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(160 * UNIT, supply()); + BOOST_REQUIRE_EQUAL(10 * UNIT, owed("alice"_n)); + + const auto att = attestation_row(1); + BOOST_REQUIRE(!att.is_null()); + BOOST_REQUIRE_EQUAL("ATTESTATION_TYPE_DESYNDICATE_LIQ", att["type"].as_string()); + BOOST_REQUIRE_EQUAL(slug_value(SOLANA), att["chain_code"].as_uint64()); + const auto data = att["data"].as>(); + sysio::opp::attestations::DesyndicateLIQ msg; + BOOST_REQUIRE(msg.ParseFromArray(data.data(), static_cast(data.size()))); + BOOST_REQUIRE_EQUAL(slug_value(SOLANA), msg.chain_code()); + BOOST_REQUIRE(msg.user().kind() == ChainKind::CHAIN_KIND_SVM); + BOOST_REQUIRE_EQUAL(std::string(pubkey.begin(), pubkey.end()), msg.user().address()); + BOOST_REQUIRE_EQUAL(slug_value(LIQSOL), msg.amount().token_code()); + BOOST_REQUIRE_EQUAL(40 * UNIT, msg.amount().amount()); + BOOST_REQUIRE_EQUAL(1u, msg.request_id()); + + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(2u, counters_row()["next_request_id"].as_uint64() - 1); + BOOST_REQUIRE_EQUAL(50 * UNIT, shadow_balance("alice"_n)); + + // Governance puts a refused de-syndication back. + BOOST_REQUIRE(mentions(recredit("alice"_n, 10 * UNIT, "alice"_n), "missing authority of sysio.liq")); + BOOST_REQUIRE_EQUAL(success(), recredit("alice"_n, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(60 * UNIT, shadow_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(160 * UNIT, supply()); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Launch ingestion +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE(importsynd_parks_or_credits_and_importdone_closes, sysio_liq_tester) try { + const auto key_a = ed_key(), key_b = ed_key(), key_c = ed_key(); + const auto a = ed_bytes(key_a), b = ed_bytes(key_b), c = ed_bytes(key_c); + BOOST_REQUIRE_EQUAL(success(), recordlink("carol"_n, ChainKind::CHAIN_KIND_SVM, key_c)); + + BOOST_REQUIRE_EQUAL(success(), importsynd(SOLANA, LIQSOL, { credit(a, 30 * UNIT), credit(b, 70 * UNIT), credit(c, 5 * UNIT) })); + BOOST_REQUIRE_EQUAL(30 * UNIT, parked_row(LIQSOL_SYM, ChainKind::CHAIN_KIND_SVM, a)["holding"]["balance"].as().get_amount()); + BOOST_REQUIRE_EQUAL(70 * UNIT, parked_row(LIQSOL_SYM, ChainKind::CHAIN_KIND_SVM, b)["holding"]["balance"].as().get_amount()); + BOOST_REQUIRE_EQUAL(5 * UNIT, shadow_balance("carol"_n)); // already linked: straight to the account + BOOST_REQUIRE_EQUAL(105 * UNIT, supply()); + + // The same pubkey across batches sums; zero credits are skipped. + BOOST_REQUIRE_EQUAL(success(), importsynd(SOLANA, LIQSOL, { credit(a, 10 * UNIT), credit(b, 0) })); + BOOST_REQUIRE_EQUAL(40 * UNIT, parked_row(LIQSOL_SYM, ChainKind::CHAIN_KIND_SVM, a)["holding"]["balance"].as().get_amount()); + BOOST_REQUIRE_EQUAL(115 * UNIT, supply()); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg("pubkey does not fit the chain family"), + importsynd(SOLANA, LIQSOL, { credit(std::vector(33, 'x'), UNIT) })); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token_code belongs to another chain"), importsynd(ETH, LIQSOL, { credit(a, UNIT) })); + BOOST_REQUIRE(mentions(importsynd(SOLANA, LIQSOL, { credit(a, UNIT) }, "alice"_n), "missing authority of sysio.liq")); + BOOST_REQUIRE(mentions(importdone("alice"_n), "missing authority of sysio.liq")); + + BOOST_REQUIRE_EQUAL(success(), importdone()); + BOOST_REQUIRE(config_row()["import_complete"].as_bool()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("import already finalized"), importsynd(SOLANA, LIQSOL, { credit(a, UNIT) })); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("import already finalized"), importdone()); + + // The imported positions earn from the first distribution on. + BOOST_REQUIRE_EQUAL(success(), addyield("carol"_n, 23 * UNIT)); // 115 supply: one WIRE per five shadow + BOOST_REQUIRE_EQUAL(success(), recordlink("alice"_n, ChainKind::CHAIN_KIND_SVM, key_a)); + BOOST_REQUIRE_EQUAL(success(), sweep("alice"_n, ChainKind::CHAIN_KIND_SVM)); + BOOST_REQUIRE_EQUAL(40 * UNIT, shadow_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(8 * UNIT, owed("alice"_n)); + BOOST_REQUIRE_EQUAL(UNIT, owed("carol"_n)); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Launch ingestion: the dev bootstrap config replays into the shadow-liq system +// --------------------------------------------------------------------------- + +// The bootstrap tool replays the config row by row: the chains, the LIQ tokens with their +// bindings, one `create` per shadow, one `regliqpool` per pool, `importsynd` per +// syndication, then `importdone`. Replaying the checked-in dev config here proves the +// values the validator accepts are values the contracts accept, and pins the accounting: +// each pool holds exactly its two seeds, the shadow supply is the LCO seed plus the parked +// syndications, every index starts at 0, the WIRE drained is the sum of the pools' WIRE +// seeds within the dex earmark, and the import closes for good. +BOOST_FIXTURE_TEST_CASE(the_dev_config_replays_into_the_shadow_liq_system, sysio_liq_config_tester) try { + const auto cfg = load_dev_config(); + BOOST_REQUIRE(!cfg.liq_pools().empty()); + BOOST_REQUIRE(!cfg.syndications().empty()); + + std::map kind_of_chain; + for (const auto& chain : cfg.chains()) { + kind_of_chain[chain.code()] = chain.kind(); + BOOST_REQUIRE_EQUAL(success(), regchain(chain.kind(), chain.code(), chain.external_chain_id())); + } + std::map shadow_of_token; + for (const auto& token : cfg.tokens()) { + if (token.kind() != TokenKind::TOKEN_KIND_LIQ) continue; // the shadow ledger binds only liq tokens + const auto chain_kind = kind_of_chain.at(token.chain_code()); + shadow_of_token.emplace(token.code(), symbol(static_cast(token.precision()), token.code())); + BOOST_REQUIRE_EQUAL(success(), regtoken(token.kind(), token.code(), token.precision(), chain_kind, + token.chain_code(), address_bytes(chain_kind, token.contract_address()))); + } + + const int64_t treasury_before = wire_balance(SYSIO_ACCOUNT); + uint64_t wire_drained = 0; + for (const auto& pool : cfg.liq_pools()) { + const symbol shadow = shadow_of_token.at(pool.token_code()); + const symbol pair(9, pool.pair_symbol()); + BOOST_REQUIRE_EQUAL(success(), create(shadow, pool.chain_code(), pool.token_code())); + BOOST_REQUIRE_EQUAL(success(), regliqpool(pool.chain_code(), pool.token_code(), pair, + pool.initial_chain_amount(), pool.initial_wire_amount(), + static_cast(pool.fee()), static_cast(pool.locked_shares()), + pool.conversion_horizon_sec(), pool.depth_cap_bps(), + static_cast(pool.clip_floor()))); + wire_drained += pool.initial_wire_amount(); + + const auto pool_row = swap_pool_row(pair); + BOOST_REQUIRE_MESSAGE(!pool_row.is_null(), "no swap pair " << pool.pair_symbol()); + BOOST_REQUIRE_EQUAL(static_cast(pool.initial_chain_amount()), + pool_row["pool1"]["quantity"].as().get_amount()); + BOOST_REQUIRE_EQUAL(static_cast(pool.initial_wire_amount()), + pool_row["pool2"]["quantity"].as().get_amount()); + BOOST_REQUIRE_EQUAL(pool.pair_symbol(), stat_row(shadow)["pair_symbol"].as_string()); + // The index row is materialized by the first distribution; until then an absent + // row reads as index 0 (`current_index`), which is what every seeded holder is stamped at. + const auto idx_row = index_row(shadow); + BOOST_REQUIRE(idx_row.is_null() || idx_row["index"].as_uint128() == fc::uint128_t{ 0 }); + } + + // Nobody is AuthX-linked at bootstrap, so every syndication parks against its pubkey; + // repeated pubkeys sum. + std::map, uint64_t> parked_of; // (token_code, pubkey) -> amount + for (const auto& synd : cfg.syndications()) { + const auto chain_kind = kind_of_chain.at(synd.chain_code()); + BOOST_REQUIRE_EQUAL(success(), importsynd(synd.chain_code(), synd.token_code(), + { credit(address_bytes(chain_kind, synd.pubkey()), synd.amount()) })); + parked_of[{ synd.token_code(), synd.pubkey() }] += synd.amount(); + } + BOOST_REQUIRE_EQUAL(success(), importdone()); + { + const auto& first = cfg.syndications(0); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("import already finalized"), + importsynd(first.chain_code(), first.token_code(), + { credit(address_bytes(kind_of_chain.at(first.chain_code()), first.pubkey()), + first.amount()) })); + } + + for (const auto& pool : cfg.liq_pools()) { + const symbol shadow = shadow_of_token.at(pool.token_code()); + const auto chain_kind = kind_of_chain.at(pool.chain_code()); + uint64_t syndicated = 0; + for (const auto& [key, amount] : parked_of) { + if (key.first != pool.token_code()) continue; + syndicated += amount; + const auto parked = parked_row(shadow, chain_kind, address_bytes(chain_kind, key.second)); + BOOST_REQUIRE_MESSAGE(!parked.is_null(), "no parked row for " << key.second); + BOOST_REQUIRE_EQUAL(static_cast(amount), parked["holding"]["balance"].as().get_amount()); + } + BOOST_REQUIRE_EQUAL(static_cast(pool.initial_chain_amount() + syndicated), + stat_row(shadow)["supply"].as().get_amount()); + // The config's custody cross-check is the contract's truth: the outpost custodies + // exactly what the depot minted against. + if (pool.custody_total() != 0) + BOOST_REQUIRE_EQUAL(pool.custody_total(), pool.initial_chain_amount() + syndicated); + } + + BOOST_REQUIRE_EQUAL(treasury_before - static_cast(wire_drained), wire_balance(SYSIO_ACCOUNT)); + BOOST_REQUIRE_LE(wire_drained, cfg.t5_dex_allocation()); +} FC_LOG_AND_RETHROW() + +BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp new file mode 100644 index 0000000000..e17cb693fd --- /dev/null +++ b/contracts/tests/sysio.swap_tests.cpp @@ -0,0 +1,3112 @@ +#include +#include +#include + + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include "contract_test_support.hpp" +#include "shadow_yield_reference.hpp" +#include "twap_wide.hpp" +#include +#include +#include +#include +#include + +using namespace sysio::testing; +using namespace sysio; +using namespace sysio::chain; +using namespace sysio::testing; +using namespace fc; +using namespace std; +using namespace boost::multiprecision; + +using mvo = fc::mutable_variant_object; + +// The shadow token's rows as sysio.opp.common/shadow_yield.hpp lays them out, +// spelled again here so the test reads them without that (CDT-only) header: +// this is the host-side pin of the layout the swap contract compiles against. +struct shadow_account_row { + asset balance; + fc::uint128_t index_checkpoint; + uint64_t owed_wire; +}; +FC_REFLECT( shadow_account_row, (balance)(index_checkpoint)(owed_wire) ) +struct shadow_index_row { + fc::uint128_t index; + uint64_t pot; + uint64_t carry; +}; +FC_REFLECT( shadow_index_row, (index)(pot)(carry) ) + +static symbol EVO4 = symbol::from_string("4,EVO"); +static symbol ETUSD3 = symbol::from_string("3,ETUSD"); +static symbol EOS4 = symbol::from_string("4,EOS"); +static symbol VOICE4 = symbol::from_string("4,VOICE"); +static symbol TUSD2 = symbol::from_string("2,TUSD"); + +static symbol_code EVO = EVO4.to_symbol_code(); +static symbol_code ETUSD = ETUSD3.to_symbol_code(); +static symbol_code EOS = EOS4.to_symbol_code(); +static symbol_code VOICE = VOICE4.to_symbol_code(); +static symbol_code TUSD = TUSD2.to_symbol_code(); + +// The system token every pair's second leg is in. The AMM tests run on +// upstream's fixture, where EOS on sysio.token stands in for WIRE; the yield +// tests run on WIRE itself (sysio_swap_yield_tester below), because the real +// sysio.liq takes its yield in WIRE and pays claims in WIRE. +static const extended_symbol SYSTEM_TOKEN{ EOS4, "sysio.token"_n }; +static symbol WIRE9 = symbol::from_string("9,WIRE"); +static symbol_code WIRE = WIRE9.to_symbol_code(); +// Upstream's fixture issues 461168601842738.7903 of its system token and +// deposits 461000000000000.0000 of it for alice -- kept in units, so the yield +// fixture's WIRE gets the same amounts at its own precision. +static const int64_t SystemTokenIssuance = 4'611'686'018'427'387'903; +static const int64_t SystemTokenDeposit = 4'610'000'000'000'000'000; +// The shadow token -- the real sysio.liq, opened on a 4-decimal liq token so +// the precision-gap cases below hold -- and its yield pool against WIRE, at the +// precision inittoken derives for the pair: (4 + 9) / 2. +static symbol SHD4 = symbol::from_string("4,SHD"); +static symbol SHEO6 = symbol::from_string("6,SHEO"); +static symbol_code SHD = SHD4.to_symbol_code(); +static symbol_code SHEO = SHEO6.to_symbol_code(); +static const extended_symbol SHADOW{ SHD4, "sysio.liq"_n }; +// TUSD's plain pool against WIRE, the yield tests' "no yield leg" case: (2 + 9) / 2. +static symbol ETUSD5 = symbol::from_string("5,ETUSD"); + +class sysio_swap_tester : public tester { +public: + // The system token every pair's second leg is in: SYSTEM_TOKEN unless a + // fixture configures another (sysio_swap_yield_tester configures WIRE). + const extended_symbol system_token; + + // `configure` = false leaves the fee authority unset, for the test that pins + // what pair creation does before deployment has run setconfig. + explicit sysio_swap_tester( bool configure = true, extended_symbol system_token = SYSTEM_TOKEN ) + : system_token( system_token ) { + produce_blocks( 2 ); + + create_accounts( { "alice"_n, "bob"_n, "carol"_n, "sysio.token"_n, "sysio.swap"_n, + "badtoken"_n, "anothertoken"_n, "sysio.chains"_n, "sysio.tokens"_n, "sysio.liq"_n, "sysio.msgch"_n } ); + produce_blocks( 2 ); + + // sysio.token bills every row to the system account (ram_payer = "sysio"), + // which only a privileged contract may do -- every account hosting the + // token WASM needs the flag, exactly as sysio.token_tests does. + set_code( "sysio.token"_n, contracts::token_wasm() ); + set_abi( "sysio.token"_n, contracts::token_abi().data() ); + set_privileged( "sysio.token"_n ); + set_code( "anothertoken"_n, contracts::token_wasm() ); + set_abi( "anothertoken"_n, contracts::token_abi().data() ); + set_privileged( "anothertoken"_n ); + + set_code( "sysio.swap"_n, contracts::swap_wasm() ); + set_abi( "sysio.swap"_n, contracts::swap_abi().data() ); + + set_code( "carol"_n, contracts::token_wasm() ); + set_abi( "carol"_n, contracts::token_abi().data() ); + set_privileged( "carol"_n ); + + set_code( "badtoken"_n, contracts::util::badtoken_wasm() ); + set_abi( "badtoken"_n, contracts::util::badtoken_abi().data() ); + + // The shadow token is the real sysio.liq: privileged like the deployment + // makes it, bound through the chain and token registries, and minted into + // only by sysio.msgch's inbound dispatch. + set_code( "sysio.chains"_n, contracts::chains_wasm() ); + set_abi( "sysio.chains"_n, contracts::chains_abi().data() ); + set_privileged( "sysio.chains"_n ); + set_code( "sysio.tokens"_n, contracts::tokens_wasm() ); + set_abi( "sysio.tokens"_n, contracts::tokens_abi().data() ); + set_privileged( "sysio.tokens"_n ); + set_code( "sysio.liq"_n, contracts::liq_wasm() ); + set_abi( "sysio.liq"_n, contracts::liq_abi().data() ); + set_privileged( "sysio.liq"_n ); + + produce_blocks(); + + // Deployment's configuration step: governance executes as sysio, and the + // fixture's system token is the second leg of every pair. + if (configure) BOOST_REQUIRE_EQUAL( success(), setconfig( config::system_account_name, system_token ) ); + + const auto* accnt1 = control->find_account_metadata( "sysio.token"_n ); + abi_def abi1; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt1->abi, abi1), true); + abi_ser.set_abi(abi1, abi_serializer::create_yield_function(abi_serializer_max_time)); + + const auto* shadow_accnt = control->find_account_metadata( "sysio.liq"_n ); + abi_def shadow_abi; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(shadow_accnt->abi, shadow_abi), true); + shadow_abi_ser.set_abi(shadow_abi, abi_serializer::create_yield_function(abi_serializer_max_time)); + + register_shadow_token(); + } + + // Push `act` on sysio.swap under exactly the given authorities, resolving the + // action's type from the deployed ABI, and seal its block -- the same + // one-action-per-block cadence as the fixture's plain push_action. Helpers + // whose signer is not a plain user go through here. + action_result push_swap_action( action_name act, std::vector auths, const variant_object& data ) { + try { + sysio::testing::base_tester::push_action( "sysio.swap"_n, act, auths, data, 100 ); + } catch (const fc::exception& ex) { + return error(ex.top_message()); + } + produce_block(); + return success(); + } + action_result setconfig( name fee_authority, extended_symbol system_token = SYSTEM_TOKEN, name signer = "sysio.swap"_n ) { + return push_swap_action( "setconfig"_n, { {signer, config::active_name} }, mvo() + ( "fee_authority", fee_authority ) + ( "system_token", system_token ) + ); + } + // A deposit of a token no pair has yet, made before the pair is created: the + // transfer carries the contract's authority, the one that creates pairs. + action_result seed_transfer( name contract, name from, asset quantity, string memo ) { + try { + base_tester::push_action( contract, "transfer"_n, + { {from, config::active_name}, {"sysio.swap"_n, config::active_name} }, + mvo()( "from", from )( "to", "sysio.swap"_n )( "quantity", quantity )( "memo", memo ), 100 ); + } catch (const fc::exception& ex) { + return error(ex.top_message()); + } + produce_block(); + return success(); + } + + fc::variant get_balance( name smartctr, name user, name table, int64_t id, string struc) + { + vector data = get_row_by_account( smartctr, user, table, name(id) ); + return data.empty() ? fc::variant() : abi_ser.binary_to_variant( struc, data, abi_serializer::create_yield_function(abi_serializer_max_time)); + } + + action_result push_action( const account_name& smartctr, const account_name& signer, const action_name &name, const variant_object &data ) { + string action_type_name = abi_ser.get_action_type(name); + + action act; + act.account = smartctr; + act.name = name; + act.data = abi_ser.variant_to_binary( action_type_name, data, abi_serializer::create_yield_function(abi_serializer_max_time)); + + return base_tester::push_action( std::move(act), signer.to_uint64_t() ); + } + action_result create( name contract, account_name issuer, asset maximum_supply ) { + return push_action( contract, contract, "create"_n, mvo() + ( "issuer", issuer) + ( "maximum_supply", maximum_supply) + ); + } + action_result issue( name contract, account_name issuer, account_name to, asset quantity, string memo ) { + return push_action( contract, issuer, "issue"_n, mvo() + ( "to", to) + ( "quantity", quantity) + ( "memo", memo) + ); + } + action_result transfer( name contract, account_name from, + account_name to, + asset quantity, + string memo ) { + return push_action( contract, from, "transfer"_n, mvo() + ( "from", from) + ( "to", to) + ( "quantity", quantity) + ( "memo", memo) + ); + } + action_result open( name owner, symbol sym, name ram_payer ) { + return push_action( "sysio.swap"_n, owner, "open"_n, mvo() + ( "owner", owner) + ( "symbol", sym) + ( "ram_payer", ram_payer) + ); + } + action_result openext( name user, name payer, extended_symbol ext_symbol ) { + return push_action( "sysio.swap"_n, payer, "openext"_n, mvo() + ( "user", user) + ( "payer", payer) + ( "ext_symbol", ext_symbol) + ); + } + action_result closeext ( const name user, const name to, const extended_symbol ext_symbol ){ + return push_action( "sysio.swap"_n, user, "closeext"_n, mvo() + ( "user", user ) + ( "to", to ) + ( "ext_symbol", ext_symbol ) + ( "memo", "" ) + ); + } + action_result withdraw(name user, name to, extended_asset to_withdraw) { + return push_action( "sysio.swap"_n, user, "withdraw"_n, mvo() + ( "user", user ) + ( "to", to ) + ( "to_withdraw", to_withdraw ) + ( "memo", "" ) + ); + } + // An empty `fee_authority` adopts the configured one (sysio in this fixture); + // `locked_shares` is in units of the new symbol, none by default; no yield + // leg makes a plain pool. + action_result inittoken( name user, symbol new_symbol, extended_asset initial_pool1, + extended_asset initial_pool2, int initial_fee, name fee_authority = name{}, int64_t locked_shares = 0, + std::optional yield_leg = std::nullopt ){ + return inittoken( user, new_symbol, initial_pool1, initial_pool2, initial_fee, fee_authority, + asset( locked_shares, new_symbol ), yield_leg ); + } + action_result inittoken( name user, symbol new_symbol, extended_asset initial_pool1, + extended_asset initial_pool2, int initial_fee, name fee_authority, asset locked_shares, + std::optional yield_leg = std::nullopt ){ + // inittoken bills the new rows to `user`, so the action must carry the + // user's sysio.payer permission in addition to the two active authorities. + return push_swap_action( "inittoken"_n, + { {user, config::sysio_payer_name}, {user, config::active_name}, {"sysio.swap"_n, config::active_name} }, + mvo() + ( "user", user) + ("new_symbol", new_symbol) + ("initial_pool1", initial_pool1) + ("initial_pool2", initial_pool2) + ("initial_fee", initial_fee) + ("fee_authority", fee_authority) + ("locked_shares", locked_shares) + ("yield_leg", yield_leg) + ); + } + // Signed by the pair's fee authority, sysio unless overridden. + action_result setyield( symbol_code pair_token, uint32_t conversion_horizon_sec, uint32_t depth_cap_bps, + int64_t clip_floor, name authority = config::system_account_name ) { + return push_swap_action( "setyield"_n, { {authority, config::active_name} }, mvo() + ( "pair_token", pair_token ) + ( "conversion_horizon_sec", conversion_horizon_sec ) + ( "depth_cap_bps", depth_cap_bps ) + ( "clip_floor", clip_floor ) + ); + } + // The pair's stat row as a variant (abi_ser must hold the swap ABI). + fc::variant pair_row( symbol_code pair_token ) { + return get_balance( "sysio.swap"_n, name(pair_token.value), "stat"_n, pair_token.value, "currency_stats" ); + } + // accrueyield needs no authorization; any account can foot the CPU. + action_result accrueyield( symbol_code pair_token ) { + return push_action( "sysio.swap"_n, "alice"_n, "accrueyield"_n, mvo() + ( "pair_token", pair_token ) + ); + } + // Raw bytes of the pending-payout receipt keyed by `contract`; empty when none. + vector pending_payout( name contract ) { + return get_kv_row( "sysio.swap"_n, "yieldpayouts"_n, { contract.to_uint64_t() } ); + } + // fundyield is the funder's own call; the fixture adds its payer permission, + // which the row is billed to. + action_result fundyield( name from, symbol_code pair_token, asset quantity ) { + return push_action( "sysio.swap"_n, from, "fundyield"_n, mvo() + ( "from", from )( "pair_token", pair_token )( "quantity", quantity ) + ); + } + action_result cancelyield( name from, name signer ) { + return push_swap_action( "cancelyield"_n, { {signer, config::active_name} }, mvo()( "from", from ) ); + } + action_result cancelyield( name from ) { return cancelyield( from, from ); } + int64_t ram_usage( name account ) const { + return control->get_resource_limits_manager().get_account_ram_usage( account ); + } + // The pending funding announced by `funder`, as a variant; null when none. + fc::variant pending_funding( name funder ) { + const auto data = get_kv_row( "sysio.swap"_n, "yieldfunds"_n, { funder.to_uint64_t() } ); + return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "fund_receipt", data, + abi_serializer::create_yield_function(abi_serializer_max_time) ); + } + // Units of shadow queued in `pair`'s reservoir. Requires the row: only yield + // pools have one. + int64_t reservoir_of( symbol_code pair ) { + const auto data = get_kv_row( "sysio.swap"_n, "reservoirs"_n, { pair.value } ); + BOOST_REQUIRE_MESSAGE( !data.empty(), "no reservoir row for " << pair ); + const auto row = abi_ser.binary_to_variant( "reservoir", data, + abi_serializer::create_yield_function(abi_serializer_max_time) ); + return to_int( fc::json::to_string( row["balance"]["quantity"], + fc::time_point(fc::time_point::now() + abi_serializer_max_time) ) ); + } + bool has_reservoir( symbol_code pair ) { + return !get_kv_row( "sysio.swap"_n, "reservoirs"_n, { pair.value } ).empty(); + } + // The intended shape of a funding: the announcement and the shadow transfer + // in ONE transaction, signed by the funder, as a contract would do it inline. + void fund_yield_in_one_transaction( name from, symbol_code pair, asset quantity ) { + signed_transaction trx; + action announce; + announce.account = "sysio.swap"_n; + announce.name = "fundyield"_n; + // The announcement's row is billed to the funder, so it carries the payer + // permission as well as the active one. + announce.authorization = { {from, config::sysio_payer_name}, {from, config::active_name} }; + announce.data = abi_ser.variant_to_binary( "fundyield", + mvo()( "from", from )( "pair_token", pair )( "quantity", quantity ), + abi_serializer::create_yield_function(abi_serializer_max_time) ); + action deliver; + deliver.account = "sysio.liq"_n; + deliver.name = "transfer"_n; + deliver.authorization = { {from, config::active_name} }; + deliver.data = shadow_abi_ser.variant_to_binary( "transfer", + mvo()( "from", from )( "to", "sysio.swap"_n )( "quantity", quantity )( "memo", "" ), + abi_serializer::create_yield_function(abi_serializer_max_time) ); + trx.actions.emplace_back( std::move(announce) ); + trx.actions.emplace_back( std::move(deliver) ); + set_transaction_headers( trx ); + trx.sign( get_private_key( from, "active" ), control->get_chain_id() ); + push_transaction( trx ); + produce_block(); + } + + // --- The shadow token (contracts/sysio.liq) --- + + // The registry rows the shadow is bound to: one SVM chain and one liq token + // declared at the shadow's own precision. + static constexpr std::string_view ShadowChainCodename = "SOLANA"; + static constexpr std::string_view ShadowTokenCodename = "SHD"; + static constexpr uint32_t ShadowExternalChainId = 2; + + action_result push_shadow_action( name signer, action_name act, const variant_object& data ) { + action a; + a.account = "sysio.liq"_n; + a.name = act; + a.data = shadow_abi_ser.variant_to_binary( shadow_abi_ser.get_action_type(act), data, + abi_serializer::create_yield_function(abi_serializer_max_time) ); + return base_tester::push_action( std::move(a), signer.to_uint64_t() ); + } + // Push a registry action on `code` (sysio.chains / sysio.tokens), signed by + // that contract, resolving the action's type from ITS deployed ABI -- the + // fixture's plain push_action reads the swap's serializer. + action_result push_registry_action( name code, action_name act, const variant_object& data ) { + try { + base_tester::push_action( code, act, code, data ); + } catch (const fc::exception& ex) { + return error(ex.top_message()); + } + produce_block(); + return success(); + } + // Register the chain and the liq token, then open the shadow symbol on + // sysio.liq -- the order its README gives a deployment. + void register_shadow_token() { + using sysio::opp::types::ChainKind; + using sysio::opp::types::TokenKind; + using sysio_system::test_support::codename_mvo; + const std::vector address( 32, char(0x5a) ); // an SVM-width placeholder + BOOST_REQUIRE_EQUAL( success(), push_registry_action( "sysio.chains"_n, "regchain"_n, mvo() + ( "kind", ChainKind::CHAIN_KIND_SVM )( "code", codename_mvo( ShadowChainCodename ) ) + ( "external_chain_id", ShadowExternalChainId )( "name", std::string( "outpost" ) ) + ( "description", std::string{} )( "outpost", sysio_system::test_support::no_outpost_mvo() ) ) ); + BOOST_REQUIRE_EQUAL( success(), push_registry_action( "sysio.tokens"_n, "regtoken"_n, mvo() + ( "kind", TokenKind::TOKEN_KIND_LIQ )( "code", codename_mvo( ShadowTokenCodename ) ) + ( "symbol_name", std::string( ShadowTokenCodename ) )( "description", std::string{} ) + ( "precision", SHD4.decimals() ) + ( "address", mvo()( "kind", ChainKind::CHAIN_KIND_SVM )( "address", address ) ) ) ); + BOOST_REQUIRE_EQUAL( success(), push_registry_action( "sysio.tokens"_n, "regctok"_n, mvo() + ( "chain_code", codename_mvo( ShadowChainCodename ) )( "token_code", codename_mvo( ShadowTokenCodename ) ) + ( "contract_addr", address )( "is_native", false ) ) ); + BOOST_REQUIRE_EQUAL( success(), push_shadow_action( "sysio.liq"_n, "create"_n, mvo() + ( "sym", SHD4 )( "chain_code", codename_mvo( ShadowChainCodename ) ) + ( "token_code", codename_mvo( ShadowTokenCodename ) ) ) ); + } + // Shadow enters supply the way the depot mints it: sysio.msgch credits a + // syndication the outpost reported, one sequence per credit. + uint64_t liq_sequence = 0; + action_result shadow_mint( name to, asset quantity ) { + using sysio_system::test_support::codename_mvo; + return push_shadow_action( "sysio.msgch"_n, "mintsynd"_n, mvo() + ( "chain_code", codename_mvo( ShadowChainCodename ) )( "sequence", ++liq_sequence ) + ( "account", to )( "token_code", codename_mvo( ShadowTokenCodename ) )( "amount", quantity.get_amount() ) ); + } + action_result shadow_transfer( name from, name to, asset quantity, string memo ) { + return push_shadow_action( from, "transfer"_n, mvo() + ( "from", from )( "to", to )( "quantity", quantity )( "memo", memo ) ); + } + // `from` donates `quantity` WIRE to `target`'s holders. The token moves the + // WIRE by an inline transfer under `from`'s authority; being privileged, it + // needs no sysio.code seat on `from` for that. + action_result shadow_addyield( name from, asset quantity, symbol_code target ) { + return push_shadow_action( from, "addyield"_n, mvo() + ( "from", from )( "quantity", quantity )( "target", target ) ); + } + action_result shadow_claim( name holder, symbol_code sym ) { + return push_shadow_action( holder, "claim"_n, mvo()( "holder", holder )( "sym", sym ) ); + } + // tickyield needs no authorization; any account can foot the CPU. + action_result tickyield( symbol_code pair_token ) { + return push_action( "sysio.swap"_n, "alice"_n, "tickyield"_n, mvo() + ( "pair_token", pair_token ) + ); + } + // Two ticks in ONE transaction, so no block time passes between them. + void tick_twice_in_one_transaction( symbol_code pair ) { + signed_transaction trx; + for (int i = 0; i < 2; ++i) { + action act; + act.account = "sysio.swap"_n; + act.name = "tickyield"_n; + act.authorization = { {"alice"_n, config::active_name} }; + act.data = abi_ser.variant_to_binary( "tickyield", mvo()( "pair_token", pair ), + abi_serializer::create_yield_function(abi_serializer_max_time) ); + trx.actions.emplace_back( std::move(act) ); + } + set_transaction_headers( trx ); + trx.sign( get_private_key( "alice"_n, "active" ), control->get_chain_id() ); + push_transaction( trx ); + produce_block(); + } + // The pair's tick clock, in microseconds since the epoch. + int64_t last_tick_us( symbol_code pair ) { + return fc::time_point::from_iso_string( pair_row( pair )["last_tick"].as_string() ).time_since_epoch().count(); + } + // The shadow side the pair recorded at its last setyield or selling tick. + int64_t last_tick_depth( symbol_code pair ) { + return pair_row( pair )["last_tick_depth"].as_int64(); + } + // `holder`'s row for `sym` on the shadow token (scope = holder, key = symbol code). + shadow_account_row shadow_account( name holder, symbol_code sym ) { + const auto data = get_kv_row( "sysio.liq"_n, "accounts"_n, { holder.to_uint64_t(), sym.value } ); + BOOST_REQUIRE_MESSAGE( !data.empty(), "no shadow row for " << holder << " " << sym ); + return fc::raw::unpack( data ); + } + // The distribution state of `sym`; all zero before the first addyield. + shadow_index_row shadow_index( symbol_code sym ) { + const auto data = get_kv_row( "sysio.liq"_n, "yieldidx"_n, { sym.value } ); + return data.empty() ? shadow_index_row{ 0, 0, 0 } : fc::raw::unpack( data ); + } + // A user's deposit of any extended symbol, resolved without `extend`. + int64_t deposit_of( name user, const extended_symbol& ext ); + action_result addliquidity(name user, asset to_buy, asset max_asset1, asset max_asset2) { + return push_action( "sysio.swap"_n, user, "addliquidity"_n, mvo() + ( "user", user ) + ( "to_buy", to_buy ) + ( "max_asset1", max_asset1) + ( "max_asset2", max_asset2) + ); + } + action_result remliquidity(name user, asset to_sell, + asset min_asset1, asset min_asset2) { + return push_action( "sysio.swap"_n, user, "remliquidity"_n, mvo() + ( "user", user ) + ( "to_sell", to_sell ) + ( "min_asset1", min_asset1) + ( "min_asset2", min_asset2) + ); + } + action_result exchange( name user, symbol_code pair_token, extended_asset ext_asset_in, asset min_expected ) { + return push_action( "sysio.swap"_n, user, "exchange"_n, mvo() + ( "user", user ) + ( "pair_token", pair_token ) + ( "ext_asset_in", ext_asset_in ) + ( "min_expected", min_expected ) + ); + } + // Signed by the pair's fee authority: the configured sysio unless overridden. + action_result changefee( symbol_code pair_token, int newfee, name authority = config::system_account_name ) { + return push_swap_action( "changefee"_n, { {authority, config::active_name} }, mvo() + ( "pair_token", pair_token ) + ( "newfee", newfee ) + ); + } + // Two exchanges by `user` in ONE transaction, so no block time can pass + // between them. (The fixture's push_action seals a block per action.) + void exchange_twice_in_one_transaction( name user, symbol_code pair, + extended_asset in_a, extended_asset in_b, symbol out_symbol ) { + signed_transaction trx; + for (const auto& in : { in_a, in_b }) { + action act; + act.account = "sysio.swap"_n; + act.name = "exchange"_n; + act.authorization = { {user, config::sysio_payer_name}, {user, config::active_name} }; + act.data = abi_ser.variant_to_binary( "exchange", mvo() + ( "user", user )( "pair_token", pair )( "ext_asset_in", in )( "min_expected", asset(0, out_symbol) ), + abi_serializer::create_yield_function(abi_serializer_max_time) ); + trx.actions.emplace_back( std::move(act) ); + } + set_transaction_headers( trx ); + trx.sign( get_private_key( user, "active" ), control->get_chain_id() ); + push_transaction( trx ); + } + // sync needs no authorization; any account can foot the CPU. + action_result sync( symbol_code pair_token ) { + return push_action( "sysio.swap"_n, "alice"_n, "sync"_n, mvo() + ( "pair_token", pair_token ) + ); + } + // The pair's cumulative-price row, with the 256-bit accumulators widened and + // the timestamp in microseconds since the epoch. + struct accumulator_row { + boost::multiprecision::uint256_t price1; + boost::multiprecision::uint256_t price2; + int64_t last_update_us; + }; + accumulator_row price_accumulator( symbol_code pair ) { + auto row = get_balance( "sysio.swap"_n, "sysio.swap"_n, "priceaccum"_n, pair.value, "price_accumulator" ); + BOOST_REQUIRE( !row.is_null() ); + // The ABI serializer decodes a `uint128` field into the variant's native + // 128-bit slot; widen it through the shared helper. + auto limb = [](const fc::variant& v) { + BOOST_REQUIRE( v.is_uint128() ); + return twap_testing::wide( v.as_uint128() ); + }; + auto cumulative = [&](const fc::variant& c) { return (limb(c["hi"]) << 128) | limb(c["lo"]); }; + return { cumulative(row["price1"]), cumulative(row["price2"]), + fc::time_point::from_iso_string(row["last_update"].as_string()).time_since_epoch().count() }; + } + + + // Raw KV read of a composite-keyed row: every key word big-endian, in order + // (for a scoped table the scope is the first word). Empty when absent. + vector get_kv_row( name code, name table, std::initializer_list key_words ) { + std::string key; + for (uint64_t word : key_words) + for (int shift = 56; shift >= 0; shift -= 8) key.push_back( char((word >> shift) & 0xff) ); + const auto& kv_idx = control->db().get_index(); + const auto itr = kv_idx.find( boost::make_tuple( code, chain::compute_table_id(table.to_uint64_t()), + std::string_view(key) ) ); + if (itr == kv_idx.end()) return {}; + return vector( itr->value.data(), itr->value.data() + itr->value.size() ); + } + // A user's deposit of `sym`, with the token contract resolved as `extend` does: + // the evodexacnts row keyed by the extended symbol, scoped by the user. + // Declared here, defined after `extend`. + int64_t balance( name user, symbol sym ); + int64_t tok_balance(name user, int64_t id){ + auto _balance = get_balance("sysio.swap"_n, user, "accounts"_n, id, "account" ); + return to_int(fc::json::to_string(_balance["balance"], + fc::time_point(fc::time_point::now() + abi_serializer_max_time) )); + } + int64_t token_balance(name contract, name user, int64_t id){ + auto _balance = get_balance(contract, user, "accounts"_n, id, "account" ); + return to_int(fc::json::to_string(_balance["balance"], + fc::time_point(fc::time_point::now() + abi_serializer_max_time) )); + } + int64_t to_int(string in) { + auto sub = in.substr(1,in.length()-2); + return asset::from_string(sub).get_amount(); + } + vector system_balance(int64_t id){ + auto sys_balance_json = get_balance("sysio.swap"_n, name(id), "stat"_n, id, "currency_stats" ); + auto saldo1 = to_int(fc::json::to_string(sys_balance_json["pool1"]["quantity"], + fc::time_point(fc::time_point::now() + abi_serializer_max_time) )); + auto saldo2 = to_int(fc::json::to_string(sys_balance_json["pool2"]["quantity"], + fc::time_point(fc::time_point::now() + abi_serializer_max_time) )); + auto minted = to_int(fc::json::to_string(sys_balance_json["supply"], + fc::time_point(fc::time_point::now() + abi_serializer_max_time) )); + vector ans = {saldo1, saldo2, minted}; + return ans; + } + bool is_increasing(vector v, vector w){ + using wide = boost::multiprecision::int256_t; + wide x = wide(v.at(0)) * wide(v.at(1)) * wide(w.at(2)) * wide(w.at(2)); + wide y = wide(w.at(0)) * wide(w.at(1)) * wide(v.at(2)) * wide(v.at(2)); + return x <= y; + } + // Every unit of EOS, VOICE and TUSD held by the contract for alice, bob and + // the two pools. Declared here, defined after the file-scope symbols. + vector total(); + void create_tokens_and_issue() { + BOOST_REQUIRE_EQUAL( success(), create( system_token.contract, "alice"_n, asset( SystemTokenIssuance, system_token.sym ) ) ); + BOOST_REQUIRE_EQUAL( success(), create( "anothertoken"_n, "bob"_n, asset::from_string("461168601842738.7903 VOICE") ) ); + BOOST_REQUIRE_EQUAL( success(), create( "sysio.token"_n, "alice"_n, asset::from_string("46116860184273879.03 TUSD") ) ); + BOOST_REQUIRE_EQUAL( success(), issue( system_token.contract, "alice"_n, "alice"_n, asset( SystemTokenIssuance, system_token.sym ), "") ); + BOOST_REQUIRE_EQUAL( success(), issue( "anothertoken"_n, "bob"_n, "bob"_n, asset::from_string("461168601842738.7903 VOICE"), "") ); + BOOST_REQUIRE_EQUAL( success(), issue( "sysio.token"_n, "alice"_n, "alice"_n, asset::from_string("46116860184273879.03 TUSD"), "") ); + } + void many_openext() { + BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, system_token ) ); + BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, extended_symbol{symbol::from_string("4,VOICE"), "anothertoken"_n}) ); + BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, extended_symbol{symbol::from_string("2,TUSD"), "sysio.token"_n}) ); + BOOST_REQUIRE_EQUAL( success(), openext( "bob"_n, "alice"_n, system_token ) ); + BOOST_REQUIRE_EQUAL( success(), openext( "bob"_n, "alice"_n, extended_symbol{symbol::from_string("4,VOICE"), "anothertoken"_n}) ); + BOOST_REQUIRE_EQUAL( success(), openext( "bob"_n, "alice"_n, extended_symbol{symbol::from_string("2,TUSD"), "sysio.token"_n}) ); + } + // The deposits the pools are seeded from. The system token is always + // accepted; VOICE and TUSD have no pair yet, so their deposits carry the + // contract's authority. + void many_transfer() { + BOOST_REQUIRE_EQUAL( success(), transfer( system_token.contract, "alice"_n, "sysio.swap"_n, asset( SystemTokenDeposit, system_token.sym ), "") ); + BOOST_REQUIRE_EQUAL( success(), seed_transfer( "anothertoken"_n, "bob"_n, asset::from_string("461168601842738.7000 VOICE"), "deposit to: alice") ); + // 0.0902, not bob's full 0.0903 remainder: memoexchange_test first sends + // 0.0001 VOICE bob -> alice, so 0.0903 overdraws there (upstream ignored + // that failure silently; this fixture asserts every setup step). + BOOST_REQUIRE_EQUAL( success(), seed_transfer( "anothertoken"_n, "bob"_n, asset::from_string("0.0902 VOICE"), "this goes to Bob") ); + BOOST_REQUIRE_EQUAL( success(), seed_transfer( "sysio.token"_n, "alice"_n, asset::from_string("45000000000000000.00 TUSD"), "") ); + BOOST_REQUIRE_EQUAL( success(), seed_transfer( "sysio.token"_n, "alice"_n, asset::from_string("300000000000000.00 TUSD"), "deposit to: bob") ); + } + abi_def swap_abi_def() { + const auto* accnt = control->find_account_metadata( "sysio.swap"_n ); + BOOST_REQUIRE( accnt != nullptr ); + abi_def abi; + BOOST_REQUIRE_EQUAL( abi_serializer::to_abi(accnt->abi, abi), true ); + return abi; + } + // LP-token balance, 0 when the user never held any (no accounts row). + int64_t lp_balance( name user, symbol_code pair_token ) { + auto row = get_balance( "sysio.swap"_n, user, "accounts"_n, pair_token.value, "account" ); + return row.is_null() ? 0 : to_int( fc::json::to_string( row["balance"], + fc::time_point(fc::time_point::now() + abi_serializer_max_time) ) ); + } + int64_t pool_fee( symbol_code pair_token ) { + return get_balance( "sysio.swap"_n, name(pair_token.value), "stat"_n, pair_token.value, + "currency_stats" )["fee"].as_int64(); + } + // The two-pool state the math tests start from: EVO = EOS/VOICE and + // ETUSD = EOS/TUSD, both funded by alice, with abi_ser on the swap ABI. + // Defined after the file-scope symbols and `extend` it uses. + void setup_pools(); + // Push an exact-input swap with a zero slippage floor and return what the + // pool paid out, measured from the pool rather than taken from any quote. + // `out_leg` is the pool index (0 = pool1, 1 = pool2) of the received token. + int64_t settle_swap( name user, symbol_code pair, asset in, symbol out_symbol, int out_leg ); + void prepare_carol_token() { + create( "carol"_n, "carol"_n, asset::from_string("4.0000 EOS") ); + issue( "carol"_n, "carol"_n, "carol"_n, asset::from_string("4.0000 EOS"), ""); + create( "carol"_n, "carol"_n, asset::from_string("1.0000 VOICE") ); + issue( "carol"_n, "carol"_n, "carol"_n, asset::from_string("1.0000 VOICE"), ""); + } + abi_serializer abi_ser; + abi_serializer shadow_abi_ser; +}; + +static extended_asset shd( int64_t units ) { return extended_asset{ asset( units, SHD4 ), "sysio.liq"_n }; } +// Seed of the yield pool and the shadow's total issuance: the pool holds a third +// of the supply, so every distribution splits 1:2 between the pool and alice. +static const int64_t YieldPoolShadow = 1'000'000'0000; +static const int64_t YieldPoolWire = 1'000'000'0000; +static const int64_t ShadowIssuance = 3 * YieldPoolShadow; +static const int64_t BobDonationBudget = 10'000'0000; + +extended_asset extend(asset to_extend) { + if (to_extend.symbol_name() == "VOICE") { + return extended_asset{to_extend, "anothertoken"_n}; + } else { + return extended_asset{to_extend, "sysio.token"_n}; + } +} + +// The constant product of a pair's two pools, as system_balance reports them. +static boost::multiprecision::int256_t wide_product( const vector& pool ) { + return boost::multiprecision::int256_t( pool.at(0) ) * pool.at(1); +} + +// Reference quotes for the rounding tests, written from the SPEC rather than +// copied from the contract. The curve goes the pool's way: what a user pays is +// rounded up, what a user receives is rounded down. The two fees differ: the +// swap fee taken off a quote is rounded DOWN (the depot-wide amm_math +// convention) but never below one unit when both the rate and the quote are +// nonzero (units are precision-relative, so a fee-free window is not dust), +// while the liquidity fee added on top of what a provider pays is rounded UP. +namespace reference { + using wide = boost::multiprecision::int128_t; + constexpr int64_t FeeDenominator = 10000; + constexpr int64_t MinSwapFee = 1; + + int64_t ceil_div( wide a, wide b ) { return int64_t( (a + b - 1) / b ); } + int64_t floor_div( wide a, wide b ) { return int64_t( a / b ); } + int64_t swap_fee_on( int64_t amount, int fee ) { + const int64_t floored = floor_div( wide(amount) * fee, FeeDenominator ); + return (amount > 0 && fee > 0) ? std::max( floored, MinSwapFee ) : floored; + } + int64_t liquidity_fee_on( int64_t amount, int fee ) { return ceil_div( wide(amount) * fee, FeeDenominator ); } + // The proportional fee BEFORE the one-unit minimum is applied. Zero is what + // makes MinSwapFee bind, and a clip whose output gets there pays far above + // the pair's rate. + int64_t proportional_fee_on( int64_t amount, int fee ) { return floor_div( wide(amount) * fee, FeeDenominator ); } + // Least output at which the pair's own rate is the fee a trade pays: the + // smallest amount whose proportional fee reaches a whole unit. + int64_t min_fee_bearing_output( int fee ) { return fee > 0 ? FeeDenominator / fee : 1; } + + // Units of `pool_out` received for `amount_in` units of `pool_in`. + int64_t receive( int64_t amount_in, int64_t pool_in, int64_t pool_out, int fee ) { + const int64_t gross = floor_div( wide(amount_in) * pool_out, wide(pool_in) + amount_in ); + return gross - swap_fee_on( gross, fee ); + } + // Units of one leg charged for `shares` new LP tokens (ADD_LIQUIDITY_FEE = 1). + int64_t add_leg( int64_t shares, int64_t pool_leg, int64_t supply ) { + const int64_t gross = ceil_div( wide(shares) * pool_leg, supply ); + return gross + liquidity_fee_on( gross, 1 ); + } + // Units of one leg returned for burning `shares` LP tokens (no fee). + int64_t remove_leg( int64_t shares, int64_t pool_leg, int64_t supply ) { + return floor_div( wide(shares) * pool_leg, supply ); + } +} + +// The amm_math composition sysio.swap implements, evaluated on the host with +// the SAME header the contract compiles against: the equal-weight +// constant-product kernel for the gross output, then the depot fee split +// against it (no underwriter share -- the fee stays in the pool), with the +// contract's one-unit minimum on top. `reference` is the spec written by hand; +// `model` is the library. A swap must agree with both. +namespace model { + namespace amm = sysio::opp::amm; + constexpr uint64_t CpWeightBps = amm::WEIGHT_TOTAL_BPS / 2; + constexpr uint32_t NoUnderwriterShareBps = 0; + constexpr uint64_t MinSwapFee = 1; + + int64_t gross( int64_t amount_in, int64_t pool_in, int64_t pool_out ) { + return int64_t( amm::out_given_in( uint64_t(pool_in), CpWeightBps, + uint64_t(pool_out), CpWeightBps, + uint64_t(amount_in) ) ); + } + int64_t receive( int64_t amount_in, int64_t pool_in, int64_t pool_out, int fee ) { + const uint64_t g = uint64_t( gross( amount_in, pool_in, pool_out ) ); + uint64_t f = amm::split_wire_fee( g, uint32_t(fee), NoUnderwriterShareBps ).fee; + if (fee > 0 && g > 0) f = std::max( f, MinSwapFee ); + return int64_t( g - f ); + } +} + +// The cumulative-price spec, written by hand: a side's spot price is the other +// side's balance over its own, in Q64.64 rounded down, and an accumulator is +// the sum of that price times the microseconds it held. +namespace twap_reference { + using boost::multiprecision::uint256_t; + constexpr int PriceFractionBits = 64; + uint256_t price_fp( int64_t numerator, int64_t denominator ) { + return (uint256_t(numerator) << PriceFractionBits) / denominator; + } +} + +// The shadow yield spec is shared with the sysio.liq suite (shadow_yield_reference.hpp); +// the tick's clip formula is the swap's own. +namespace yield_reference { + // One tick's clip: the reservoir's share of the horizon elapsed, FLOORED, + // capped by `cap_bps` of the pool's shadow side and by what is queued. A + // clip short of min(clip_floor, queued) is not sold at all, which this + // reports as 0 -- the tick moves nothing and leaves its clock alone. + constexpr int64_t MicrosecondsPerSecond = 1'000'000; + constexpr int64_t BpsTotal = 10'000; + int64_t clip_size( int64_t queued, int64_t elapsed_us, uint32_t horizon_sec, int64_t pool_shadow, + uint32_t cap_bps, int64_t clip_floor ) { + const wide horizon_us = wide(horizon_sec) * MicrosecondsPerSecond; + const wide by_time = ( wide(queued) * elapsed_us ) / horizon_us; + const wide cap = wide(pool_shadow) * cap_bps / BpsTotal; + const wide clip = std::min( { by_time, cap, wide(queued) } ); + return clip < std::min( wide(clip_floor), wide(queued) ) ? 0 : int64_t( clip ); + } +} + +vector sysio_swap_tester::total() { + // EOS, the system token, is the second leg of both pools. + const int64_t total_eos = balance("alice"_n, EOS4) + balance("bob"_n, EOS4) + + system_balance(EVO.value).at(1) + system_balance(ETUSD.value).at(1); + const int64_t total_voice = balance("alice"_n, VOICE4) + balance("bob"_n, VOICE4) + + system_balance(EVO.value).at(0); + const int64_t total_tusd = balance("alice"_n, TUSD2) + balance("bob"_n, TUSD2) + + system_balance(ETUSD.value).at(0); + return { total_eos, total_voice, total_tusd }; +} + +int64_t sysio_swap_tester::deposit_of( name user, const extended_symbol& ext ) { + const auto data = get_kv_row( "sysio.swap"_n, "evodexacnts"_n, + { user.to_uint64_t(), ext.contract.to_uint64_t(), ext.sym.value() } ); + BOOST_REQUIRE_MESSAGE( !data.empty(), "no deposit row for " << user << " " << ext.sym ); + const auto row = abi_ser.binary_to_variant( "evodex_account", data, + abi_serializer::create_yield_function(abi_serializer_max_time) ); + return to_int( fc::json::to_string( row["balance"]["quantity"], + fc::time_point(fc::time_point::now() + abi_serializer_max_time) ) ); +} + +int64_t sysio_swap_tester::balance( name user, symbol sym ) { + const extended_asset ext = extend( asset(0, sym) ); + return deposit_of( user, extended_symbol{ sym, ext.contract } ); +} + +// The yield tests trade against the real sysio.liq, which takes its yield in +// WIRE and pays claims in WIRE, and a pair's second leg is the swap's system +// token: this fixture configures WIRE itself where the AMM tests stand EOS in +// for it. +struct sysio_swap_yield_tester : public sysio_swap_tester { + sysio_swap_yield_tester() : sysio_swap_tester( true, extended_symbol{ WIRE9, "sysio.token"_n } ) {} + + // The yield-pool state the accrual tests start from: SHD (the shadow, paying + // its yield in WIRE) issued to alice, bob holding WIRE to donate and able to + // route it through the shadow token, and SHEO = SHD/WIRE created by alice as + // a yield pool on SHD. + void setup_yield_pool() { + create_tokens_and_issue(); + abi_ser.set_abi( swap_abi_def(), abi_serializer::create_yield_function(abi_serializer_max_time) ); + many_openext(); + many_transfer(); + BOOST_REQUIRE_EQUAL( success(), shadow_mint( "alice"_n, asset( ShadowIssuance, SHD4 ) ) ); + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "bob"_n, asset( BobDonationBudget, WIRE9 ), "" ) ); + BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, SHADOW ) ); + BOOST_REQUIRE_EQUAL( success(), seed_transfer( "sysio.liq"_n, "alice"_n, asset( YieldPoolShadow, SHD4 ), "" ) ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, SHEO6, shd( YieldPoolShadow ), + extend( asset( YieldPoolWire, WIRE9 ) ), 10, name{}, 0, SHADOW ) ); + // The pool holds exactly its seed, its reservoir exists and is empty, and + // the shadow row was stamped at index 0. + const auto pool = system_balance( SHEO.value ); + BOOST_REQUIRE_EQUAL( YieldPoolShadow, pool.at(0) ); + BOOST_REQUIRE_EQUAL( YieldPoolWire, pool.at(1) ); + BOOST_REQUIRE_EQUAL( 0, reservoir_of( SHEO ) ); + const auto held = shadow_account( "sysio.swap"_n, SHD ); + BOOST_REQUIRE_EQUAL( YieldPoolShadow, held.balance.get_amount() ); + BOOST_REQUIRE_EQUAL( 0u, held.index_checkpoint ); + BOOST_REQUIRE_EQUAL( 0u, held.owed_wire ); + } +}; + +int64_t sysio_swap_tester::settle_swap( name user, symbol_code pair, asset in, symbol out_symbol, int out_leg ) { + const auto before = system_balance(pair.value); + BOOST_REQUIRE_EQUAL( success(), exchange( user, pair, extend(in), asset(0, out_symbol) ) ); + const auto after = system_balance(pair.value); + return before[out_leg] - after[out_leg]; +} + +void sysio_swap_tester::setup_pools() { + create_tokens_and_issue(); + abi_ser.set_abi( swap_abi_def(), abi_serializer::create_yield_function(abi_serializer_max_time) ); + many_openext(); + many_transfer(); + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, + asset::from_string("168601842738.7903 EOS"), "") ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extend(asset::from_string("96116860184.2738 VOICE")), + extend(asset::from_string("23058430092.1369 EOS")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("9911686018427.38 TUSD")), + extend(asset::from_string("10000000000.0000 EOS")), 10, name{}) ); + // Seed supply is the exact integer root of the product (neither is a square). + BOOST_REQUIRE_EQUAL( 470776369546600, system_balance(EVO.value).at(2) ); + BOOST_REQUIRE_EQUAL( 314828302705258, system_balance(ETUSD.value).at(2) ); +} + +BOOST_AUTO_TEST_SUITE(sysio_swap_tests) + +BOOST_FIXTURE_TEST_CASE( add_rem_liquidity, sysio_swap_tester ) try { + const auto* accnt2 = control->find_account_metadata( "sysio.swap"_n ); + abi_def abi_evo; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2->abi, abi_evo), true); + + create_tokens_and_issue(); + transfer( "anothertoken"_n, "bob"_n, "alice"_n, asset::from_string("500000000.0000 VOICE"), ""); + + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + + many_openext(); + + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("10000000.0000 EOS"), ""); + seed_transfer( "anothertoken"_n, "alice"_n, asset::from_string("200000000.0000 VOICE"), ""); + + inittoken( "alice"_n, EVO4, + extend(asset::from_string("100000000.0000 VOICE")), + extend(asset::from_string("1000000.0000 EOS")), 10, name{}); + + auto alice_evo_balance = get_balance("sysio.swap"_n, "alice"_n, "accounts"_n, EVO.value, "account"); + auto bal = mvo() ("balance", asset::from_string("10000000.0000 EVO")); + BOOST_REQUIRE_EQUAL( fc::json::to_string(alice_evo_balance, fc::time_point(fc::time_point::now() + abi_serializer_max_time) ), + fc::json::to_string(bal, fc::time_point(fc::time_point::now() + abi_serializer_max_time) ) ); + +// ADDLIQUIDITY + BOOST_REQUIRE_EQUAL( error("missing authority of alice"), + push_action( "sysio.swap"_n, "bob"_n, "addliquidity"_n, mvo() + ( "user", "alice"_n)( "to_buy", asset::from_string("1 EVO")) + ( "max_asset1", asset::from_string("1 VOICE") ) + ( "max_asset2", asset::from_string("1 EOS")) ) + ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("to_buy amount must be positive"), + addliquidity( "alice"_n, asset::from_string("-5.0000 EVO"), + asset::from_string("5000.0000 VOICE"), asset::from_string("0.5000 EOS"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), + addliquidity( "alice"_n, asset::from_string("2.0000 EVO"), + asset::from_string("-30.0000 NOICE"), asset::from_string("0.3000 EOS"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), + addliquidity( "alice"_n, asset::from_string("2.0000 EVO"), + asset::from_string("30.0000 NOICE"), asset::from_string("-0.3000 EOS"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + addliquidity( "alice"_n, asset::from_string("5.0000 EVO"), + asset::from_string("5000.0000 VOICE"), asset::from_string("0.5000 EOS"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + addliquidity( "alice"_n, asset::from_string("2.0000 EVO"), + asset::from_string("20.0000 VOICE"), asset::from_string("0.0000 EOS"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), + addliquidity( "alice"_n, asset::from_string("2.0000 EMMO"), + asset::from_string("20.0000 VOICE"), asset::from_string("0.0000 EOS"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("incorrect symbol"), + addliquidity( "alice"_n, asset::from_string("3.0000 EVO"), + asset::from_string("30.0001 VOICE"), asset::from_string("0.3000 ECOS"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), + addliquidity( "alice"_n, asset::from_string("1000000000000.0000 EVO"), + asset::from_string("20000000000000.0000 VOICE"), asset::from_string("1000000000000.0000 EOS"))); + BOOST_REQUIRE_EQUAL( success(), + addliquidity( "alice"_n, asset::from_string("50.0000 EVO"), + asset::from_string("500.5000 VOICE"), asset::from_string("5.0050 EOS") ) + ); + produce_blocks(); + +// REMLIQUIDITY + BOOST_REQUIRE_EQUAL( error("missing authority of alice"), + push_action( "sysio.swap"_n, "bob"_n, "remliquidity"_n, mvo() + ( "user", "alice"_n)( "to_sell", asset::from_string("1 EVO")) + ( "min_asset1", asset::from_string("1 VOICE") ) + ( "min_asset2", asset::from_string("1 EOS")) ) + ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("to_sell amount must be positive"), + remliquidity( "alice"_n, asset::from_string("-5.0000 EVO"), + asset::from_string("5000.0000 VOICE"), asset::from_string("0.5000 EOS"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), + remliquidity( "alice"_n, asset::from_string("3.0000 EVO"), + asset::from_string("-30.0001 NOICE"), asset::from_string("0.3000 EOS"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), + remliquidity( "alice"_n, asset::from_string("3.0000 EVO"), + asset::from_string("30.0001 NOICE"), asset::from_string("-0.3000 EOS"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + remliquidity( "alice"_n, asset::from_string("1.0000 EVO"), + asset::from_string("10.0000 VOICE"), asset::from_string("0.1001 EOS"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + remliquidity( "alice"_n, asset::from_string("3.0000 EVO"), + asset::from_string("30.0001 VOICE"), asset::from_string("0.3000 EOS"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("incorrect symbol"), + remliquidity( "alice"_n, asset::from_string("3.0000 EVO"), + asset::from_string("30.0001 NOICE"), asset::from_string("0.3000 EOS"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("overdrawn balance"), + remliquidity( "alice"_n, asset::from_string("1000000000000.0000 EVO"), + asset::from_string("0.0000 VOICE"), asset::from_string("0.0000 EOS"))); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("computation overflow"), + addliquidity( "alice"_n, asset::from_string("46116860184273.8791 EVO"), + asset::from_string("1.0000 VOICE"), asset::from_string("1.0000 EOS"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("computation underflow"), + remliquidity( "alice"_n, asset::from_string("46116860184273.8791 EVO"), + asset::from_string("1.0000 VOICE"), asset::from_string("1.0000 EOS"))); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("the pool cannot be left empty"), + remliquidity( "alice"_n, asset::from_string("10000050.0000 EVO"), + asset::from_string("0.0001 VOICE"), asset::from_string("0.0001 EOS") ) + ); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( exchange_action, sysio_swap_tester ) try { + const auto* accnt2 = control->find_account_metadata( "sysio.swap"_n ); + abi_def abi_evo; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2->abi, abi_evo), true); + create_tokens_and_issue(); + transfer( "anothertoken"_n, "bob"_n, "alice"_n, asset::from_string("500000000.0000 VOICE"), ""); + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + many_openext(); + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("10000000.0000 EOS"), ""); + seed_transfer( "anothertoken"_n, "alice"_n, asset::from_string("200000000.0000 VOICE"), ""); + inittoken( "alice"_n, EVO4, + extend(asset::from_string("100000000.0000 VOICE")), + extend(asset::from_string("1000000.0000 EOS")), 10, name{}); + addliquidity( "alice"_n, asset::from_string("50.0000 EVO"), + asset::from_string("500.5000 VOICE"), asset::from_string("5.0050 EOS") ); + remliquidity( "alice"_n, asset::from_string("17.1872 EVO"), + asset::from_string("0.0000 VOICE"), asset::from_string("0.0000 EOS") ); + +// EXCHANGE + BOOST_REQUIRE_EQUAL( error("missing authority of alice"), + push_action( "sysio.swap"_n, "bob"_n, "exchange"_n, mvo() + ( "user", "alice"_n)( "pair_token", EVO ) + ( "ext_asset_in", extend(asset::from_string("1 EOS")) ) + ( "min_expected", asset::from_string("1 VOICE")) ) + ); + // Inputs are exact-in only: the amount must be positive and the slippage + // floor nonnegative. The negative-amount (exact-output) form is retired. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("min_expected must be nonnegative"), + exchange( "alice"_n, EVO, extend(asset::from_string("2.0000 VOICE")), + asset::from_string("-0.1000 EOS")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("ext_asset_in must be positive"), + exchange( "alice"_n, EVO, extend(asset::from_string("-2.0000 RICE")), + asset::from_string("0.1000 REOS")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("ext_asset_in must be positive"), + exchange( "alice"_n, EVO, extend(asset::from_string("0.0000 EOS")), + asset::from_string("-0.1000 VOICE")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("ext_asset_in must be positive"), + exchange( "alice"_n, EVO, extend(asset::from_string("-1000004.0000 EOS")), + asset::from_string("-0.0001 VOICE")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("ext_asset_in must be positive"), + exchange( "alice"_n, EVO, extend(asset::from_string("-100000328.6280 VOICE")), + asset::from_string("0.0000 EOS")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), + exchange( "alice"_n, TUSD, extend(asset::from_string("8.0000 VOICE")), + asset::from_string("0.0000 EOS")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), + exchange( "alice"_n, EVO, extend(asset::from_string("4.000 EOS")), + asset::from_string("10.0000 VOICE")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), + exchange( "alice"_n, EVO, extended_asset{asset::from_string("4.0000 EOS"), "another"_n}, + asset::from_string("10.0000 VOICE")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), + exchange( "alice"_n, EVO, extended_asset{asset::from_string("1.0000 VOICE"), "another"_n}, + asset::from_string("1.0000 EOS")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + exchange( "alice"_n, EVO, extend(asset::from_string("4.0000 EOS")), + asset::from_string("400.0000 VOICE")) ); + + exchange( "alice"_n, EVO, extend(asset::from_string("4.0000 EOS")), asset::from_string("10.0000 VOICE")); + exchange( "alice"_n, EVO, extend(asset::from_string("0.1000 EOS")), asset::from_string("4.8500 VOICE")); + exchange( "alice"_n, EVO, extend(asset::from_string("0.0001 EOS")), asset::from_string("0.0009 VOICE")); + + // {VOICE pool, EOS pool, supply} + vector expected_system_balance = {999999185797, 10000073819, 100000328128}; + BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); + BOOST_REQUIRE_EQUAL(balance("alice"_n, EOS4), 89999926181); + BOOST_REQUIRE_EQUAL(balance("alice"_n, VOICE4), 1000000814203); + + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 50) ); + + addliquidity( "alice"_n, asset::from_string("50.0000 EVO"), + asset::from_string("10000000.0000 VOICE"), asset::from_string("10000000.0000 EOS") ); + + expected_system_balance = {1000004186277, 10000123826, 100000828128}; + BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); + BOOST_REQUIRE_EQUAL(balance("alice"_n, EOS4), 89999876174); + BOOST_REQUIRE_EQUAL(balance("alice"_n, VOICE4), 999995813723); + + // The retired exact-output form is refused and leaves every balance as it was. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("ext_asset_in must be positive"), + exchange( "alice"_n, EVO, extend(asset::from_string("-4.0000 EOS")), + asset::from_string("-401.9984 VOICE")) ); + BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); + BOOST_REQUIRE_EQUAL(balance("alice"_n, EOS4), 89999876174); + BOOST_REQUIRE_EQUAL(balance("alice"_n, VOICE4), 999995813723); + +} FC_LOG_AND_RETHROW() + + +BOOST_FIXTURE_TEST_CASE( increasing_poolvalue, sysio_swap_tester) try { + const auto* accnt2 = control->find_account_metadata( "sysio.swap"_n ); + abi_def abi_evo; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2->abi, abi_evo), true); + + create_tokens_and_issue(); + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + many_openext(); + many_transfer(); + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, + asset::from_string("168601842738.7903 EOS"), ""); + + inittoken( "alice"_n, EVO4, + extend(asset::from_string("96116860184.2738 VOICE")), + extend(asset::from_string("23058430092.1369 EOS")), 10, name{}); + + inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("9911686018427.38 TUSD")), + extend(asset::from_string("10000000000.0000 EOS")), 10, name{}); + + auto old_total = total(); + auto old_vec = system_balance(EVO.value); + auto old_alice_bal_0 = balance("alice"_n, EOS4); + auto old_alice_bal_1 = balance("alice"_n, VOICE4); + + BOOST_REQUIRE_EQUAL(success(), + exchange( "alice"_n, EVO, extend(asset::from_string("4.0000 EOS")), + asset::from_string("1.0000 VOICE") )); + BOOST_REQUIRE_EQUAL(old_total == total(), true); + BOOST_REQUIRE_EQUAL(is_increasing(old_vec, system_balance(EVO.value)), true); + BOOST_REQUIRE_EQUAL(balance("alice"_n, EOS4) - old_alice_bal_0, -40000); + BOOST_REQUIRE_EQUAL(balance("alice"_n, VOICE4) - old_alice_bal_1, 166570); + + old_total = total(); + old_vec = system_balance(EVO.value); + old_alice_bal_0 = balance("alice"_n, EOS4); + old_alice_bal_1 = balance("alice"_n, VOICE4); + addliquidity( "alice"_n, asset::from_string("0.0001 EVO"), + asset::from_string("10000000.0000 VOICE"), asset::from_string("10000000.0000 EOS") ); + BOOST_REQUIRE_EQUAL(old_total == total(), true); + BOOST_REQUIRE_EQUAL(is_increasing(old_vec, system_balance(EVO.value)), true); + BOOST_REQUIRE_EQUAL(balance("alice"_n, EOS4) - old_alice_bal_0, -2); + BOOST_REQUIRE_EQUAL(balance("alice"_n, VOICE4) - old_alice_bal_1, -4); + + produce_blocks(); + + old_total = total(); + old_vec = system_balance(EVO.value); + old_alice_bal_0 = balance("alice"_n, EOS4); + old_alice_bal_1 = balance("alice"_n, VOICE4); + remliquidity( "alice"_n, asset::from_string("0.0001 EVO"), + asset::from_string("0.0000 VOICE"), asset::from_string("0.0000 EOS") ); + BOOST_REQUIRE_EQUAL(old_total == total(), true); + BOOST_REQUIRE_EQUAL(is_increasing(old_vec, system_balance(EVO.value)), true); + BOOST_REQUIRE_EQUAL(balance("alice"_n, EOS4) - old_alice_bal_0, 0); + BOOST_REQUIRE_EQUAL(balance("alice"_n, VOICE4) - old_alice_bal_1, 2); + + old_total = total(); + old_vec = system_balance(ETUSD.value); + BOOST_REQUIRE_EQUAL(success(), + exchange( "bob"_n, ETUSD, + extend(asset::from_string("3000000000000.00 TUSD")), + asset::from_string("40000000.0000 EOS") ) ); + BOOST_REQUIRE_EQUAL(old_total == total(), true); + BOOST_REQUIRE_EQUAL(is_increasing(old_vec, system_balance(ETUSD.value)), true); + + old_total = total(); + old_vec = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL(success(), + exchange( "bob"_n, EVO, extend(asset::from_string("4000.0000 EOS")), + asset::from_string("1.0000 VOICE") ) ); + BOOST_REQUIRE_EQUAL(old_total == total(), true); + BOOST_REQUIRE_EQUAL(is_increasing(old_vec, system_balance(EVO.value)), true); + + old_total = total(); + BOOST_REQUIRE_EQUAL( success(), withdraw( "bob"_n, "bob"_n, + extend(asset::from_string("0.0001 EOS"))) ); + BOOST_REQUIRE_EQUAL(old_total == total(), false); + + old_total = total(); + old_vec = system_balance(EVO.value); + addliquidity( "alice"_n, asset::from_string("150000000000000.0000 EVO"), + asset::from_string("400000000000000.0000 VOICE"), asset::from_string("400000000000000.0000 EOS") ); + BOOST_REQUIRE_EQUAL(old_total == total(), true); + BOOST_REQUIRE_EQUAL(is_increasing(old_vec, system_balance(EVO.value)), true); + + old_total = total(); + old_vec = system_balance(EVO.value); + exchange( "alice"_n, EVO, extend(asset::from_string("387592687324317.3478 EOS")), + asset::from_string("0.0001 VOICE") ); + BOOST_REQUIRE_EQUAL(old_total == total(), true); + BOOST_REQUIRE_EQUAL(is_increasing(old_vec, system_balance(EVO.value)), true); + + // The retired exact-output form is refused and moves nothing. + old_total = total(); + old_vec = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("ext_asset_in must be positive"), exchange( "alice"_n, EVO, + extend(asset::from_string("-1.0000 EOS")), asset::from_string("-0.1069 VOICE")) ); + BOOST_REQUIRE_EQUAL(old_total == total(), true); + BOOST_REQUIRE_EQUAL(old_vec == system_balance(EVO.value), true); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("ext_asset_in must be positive"), exchange( "bob"_n, EVO, + extend(asset::from_string("-12.0001 VOICE")), asset::from_string("-122.0329 EOS")) ); + BOOST_REQUIRE_EQUAL(old_total == total(), true); + BOOST_REQUIRE_EQUAL(old_vec == system_balance(EVO.value), true); +} FC_LOG_AND_RETHROW() + + +BOOST_FIXTURE_TEST_CASE( memoexchange_test, sysio_swap_tester ) try { + const auto* accnt2 = control->find_account_metadata( "sysio.swap"_n ); + abi_def abi_evo; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2->abi, abi_evo), true); + + create_tokens_and_issue(); + prepare_carol_token(); + transfer( "anothertoken"_n, "bob"_n, "alice"_n, asset::from_string("0.0001 VOICE"), ""); + + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + + many_openext(); + many_transfer(); + + BOOST_REQUIRE_EQUAL( success(), + inittoken( "alice"_n, EVO4, + extend(asset::from_string("96116860184.2738 VOICE")), + extend(asset::from_string("23058430092.1369 EOS")), 10, name{}) ); + + // The memo's amount is parsed with overflow-checked arithmetic: a digit string + // past int64 aborts at the digit that overflows, a scaled integer part past + // int64 aborts at the scaling, more than 18 decimals is refused, and a sign + // is not a digit. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("amount too large"), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), + "exchange: EVO, 99999999999999999999 VOICE") ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("amount too large"), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), + "exchange: EVO, 9223372036854775808 VOICE") ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("amount too large"), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), + "exchange: EVO, 922337203685477580.8 VOICE") ); + // ...and one that fits int64 but not an asset is the asset's own refusal. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("magnitude of asset amount must be less than 2^62"), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), + "exchange: EVO, 922337203685477580.7 VOICE") ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("precision should be <= 18"), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), + "exchange: EVO, 1.0000000000000000000 VOICE") ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("invalid character"), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), + "exchange: EVO, -1.0000 VOICE") ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Missing decimal fraction after decimal point"), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), + "exchange: EVO, 16. VOICE") ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Asset's amount and symbol should be separated with space"), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), + "exchange: EVO, 16.6570VOICE") ); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), + "exchange: EVO, 166536 VOICE") ); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), + "exchange: EVO, 16.6571 VOICE") ); + + // Look-alike symbols from another contract are not the system token and are + // the first leg of no pair: refused before the memo is even read. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("token is not a leg of any pair"), + transfer( "carol"_n, "carol"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), + "exchange: EVO, 16.6569 VOICE") ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("token is not a leg of any pair"), + transfer( "carol"_n, "carol"_n, "sysio.swap"_n, asset::from_string("1.0000 VOICE"), + "exchange: EVO, 0.0001 EOS") ); + + int64_t pre_eos_balance = token_balance("sysio.token"_n, "alice"_n, EOS.value); + int64_t pre_voice_balance = token_balance("anothertoken"_n, "alice"_n, VOICE.value); + BOOST_REQUIRE_EQUAL( success(), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), + "exchange: EVO, 16.6570 VOICE") ); + BOOST_REQUIRE_EQUAL( pre_eos_balance - 40000, token_balance("sysio.token"_n, "alice"_n, EOS.value) ); + BOOST_REQUIRE_EQUAL( pre_voice_balance + 166570, token_balance("anothertoken"_n, "alice"_n, VOICE.value) ); + + inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("9911686018427.38 TUSD")), + extend(asset::from_string("10000000000.0000 EOS")), 10, name{}); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, + asset::from_string("400000.00 TUSD"), "exchange: ETUSD, 403.1606 EOS") ); + + pre_eos_balance = token_balance("sysio.token"_n, "alice"_n, EOS.value); + int64_t pre_tusd_balance = token_balance("sysio.token"_n, "alice"_n, TUSD.value); + BOOST_REQUIRE_EQUAL( success(), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("400000.00 TUSD"), + "exchange: ETUSD, 403.1605 EOS") ); + + BOOST_REQUIRE_EQUAL( pre_tusd_balance - 40000000, + token_balance("sysio.token"_n, "alice"_n, TUSD.value) ); + BOOST_REQUIRE_EQUAL( pre_eos_balance + 4031605, + token_balance("sysio.token"_n, "alice"_n, EOS.value) ); + + auto old_vec = system_balance(EVO.value); + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), + "exchange: EVO, 10000 VOICE, nothing to say"); + auto new_vec = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL(is_increasing(old_vec, new_vec), true); + + old_vec = system_balance(ETUSD.value); + transfer( "sysio.token"_n, "bob"_n, "sysio.swap"_n, + asset::from_string("30000000000000000.00 TUSD"), "exchange: ETUSD, 40000000000000 EOS,"); + new_vec = system_balance(ETUSD.value); + BOOST_REQUIRE_EQUAL(is_increasing(old_vec, new_vec), true); +} FC_LOG_AND_RETHROW() + + +BOOST_FIXTURE_TEST_CASE( the_other_actions, sysio_swap_tester ) try { + + create_tokens_and_issue(); + transfer( "anothertoken"_n, "bob"_n, "alice"_n, asset::from_string("500000000.0000 VOICE"), ""); + + const auto* accnt2 = control->find_account_metadata( "sysio.swap"_n ); + abi_def abi_evo; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2->abi, abi_evo), true); + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + + // add_signed_ext_balance + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol not registered for this user,\ + please run openext action or write exchange details in the memo of your transfer"), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("0.1000 EOS"), "") ); + + // OPENEXT + BOOST_REQUIRE_EQUAL( error("missing authority of natalia"), + push_action( "sysio.swap"_n, "bob"_n, "openext"_n, mvo() + ( "user", "bob"_n)( "payer", "natalia"_n ) + ( "ext_symbol", extended_symbol{VOICE4, "anothertoken"_n} )) + ); + BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, + extended_symbol{EOS4, "sysio.token"_n}) ); + BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, + extended_symbol{VOICE4, "anothertoken"_n}) ); + BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, + extended_symbol{VOICE4, "anothertoken"_n}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg( "user account does not exist"), + openext( "cat"_n, "alice"_n, extended_symbol{VOICE4, "anothertoken"_n}) ); + BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, + extended_symbol{VOICE4, "anothertoken"_n}) ); + + // ONTRANSFER + BOOST_REQUIRE_EQUAL( wasm_assert_msg("This transfer is not for sysio.swap"), + transfer( "badtoken"_n, "alice"_n, "bob"_n, asset::from_string("1000.0000 EOS"), "")); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("quantity must be positive"), + transfer( "badtoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("-1000.0000 EOS"), "")); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Donation not accepted"), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, + asset::from_string("1000.0000 EOS"), "deposit to: sysio.swap") ); + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, + asset::from_string("1000.0000 EOS"), "") ); + // Only the system token and the first legs of existing pairs are accepted; + // a first leg's seed gets in ahead of its pair only with the contract's authority. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("token is not a leg of any pair"), transfer( "anothertoken"_n, "alice"_n, "sysio.swap"_n, + asset::from_string("20000.0000 VOICE"), "") ); + BOOST_REQUIRE_EQUAL( success(), seed_transfer( "anothertoken"_n, "alice"_n, + asset::from_string("20000.0000 VOICE"), "") ); + + // WITHDRAW + BOOST_REQUIRE_EQUAL( error("missing authority of alice"), + push_action( "sysio.swap"_n, "bob"_n, "withdraw"_n, mvo() + ("user", "alice"_n) ("to", "natalia"_n) + ("to_withdraw", extend(asset::from_string("1.0000 EOS"))) ("memo", "") ) + ); + BOOST_REQUIRE_EQUAL( success(), withdraw( "alice"_n, "bob"_n, + extend(asset::from_string("999.9998 EOS")) ) ); + BOOST_REQUIRE_EQUAL( 9999998, token_balance( "sysio.token"_n, "bob"_n, EOS.value )); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("quantity must be positive"), withdraw( "alice"_n, "bob"_n, + extend(asset::from_string("-0.0001 EOS")) )); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), withdraw( "alice"_n, "bob"_n, + extend(asset::from_string("0.0003 EOS")) )); + + // INITTOKEN + BOOST_REQUIRE_EQUAL( error("missing authority of sysio.swap"), + push_action( "sysio.swap"_n, "alice"_n, "inittoken"_n, mvo() + ("user", "alice"_n) ("new_symbol", EVO4) + ("initial_pool1", extend(asset::from_string("1.0000 ECO"))) + ("initial_pool2", extend(asset::from_string("1.0000 EOS"))) + ("initial_fee", 1) ("fee_authority", "carol"_n) ("locked_shares", asset::from_string("0.0000 EVO")) ("yield_leg", fc::variant()) ) + ); + BOOST_REQUIRE_EQUAL( error("missing authority of alice"), + push_action( "sysio.swap"_n, "bob"_n, "inittoken"_n, mvo() + ("user", "alice"_n) ("new_symbol", EVO4) + ("initial_pool1", extend(asset::from_string("1.0000 ECO"))) + ("initial_pool2", extend(asset::from_string("1.0000 EOS"))) + ("initial_fee", 1) ("fee_authority", "carol"_n) ("locked_shares", asset::from_string("0.0000 EVO")) ("yield_leg", fc::variant()) ) + ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Both assets must be positive"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("-0.1000 VOICE")), + extend(asset::from_string("0.0001 EOS")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Both assets must be positive"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.1000 VOICE")), + extend(asset::from_string("-0.0001 EOS")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Initial amounts must be less than 10^15"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("100000000000.0001 VOICE")), + extend(asset::from_string("0.0001 EOS")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Initial amounts must be less than 10^15"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1.0001 VOICE")), + extend(asset::from_string("100000000000.0001 EOS")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended symbols must be different"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.1000 EOS")), + extend(asset::from_string("0.0001 EOS")), 10, name{}) ); + // The second leg is the system token, whichever way round the legs are given + // and whatever contract a look-alike symbol comes from. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("the second leg must be the system token"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("0.1000 VOICE")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("the second leg must be the system token"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.1000 VOICE")), + extended_asset{asset::from_string("0.0001 EOS"), "anothertoken"_n}, 10, name{}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), + inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.1000 VOICE")), + extend(asset::from_string("0.0003 EOS")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), + inittoken( "alice"_n, EVO4, + extend(asset::from_string("100000000.0000 VOICE")), + extend(asset::from_string("0.0002 EOS")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("new_symbol precision must be (precision1 + precision2) / 2"), + inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.100 VOICE")), + extend(asset::from_string("0.0001 EOS")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.1000 VOICE")), + extend(asset::from_string("0.0001 EOS")), 10, name{}) ); + produce_blocks(); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("token symbol already exists"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.1000 VOICE")), + extend(asset::from_string("0.0001 EOS")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee out of range"), inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("0.10 TUSD")), + extend(asset::from_string("0.0001 EOS")), 10000, name{}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee out of range"), inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("0.10 TUSD")), + extend(asset::from_string("0.0001 EOS")), -1, name{}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee authority account does not exist"), + inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("0.10 TUSD")), + extend(asset::from_string("0.0001 EOS")), 10, "natalia"_n) ); +// The assert "the pool is already indexed" is tested in "indextable" test case. +// The fee authority itself is exercised in "fee_authority_configuration". + + // TRANSFER: the pair token itself is the first leg of no pair, so it cannot + // be deposited back into the contract. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("token is not a leg of any pair"), + transfer("sysio.swap"_n, "alice"_n, "sysio.swap"_n, asset::from_string("0.0010 EVO"), "") + ); + + // CLOSEEXT + BOOST_REQUIRE_EQUAL( error("missing authority of natalia"), + push_action( "sysio.swap"_n, "bob"_n, "closeext"_n, mvo() + ("user", "natalia"_n)("ext_symbol", extended_symbol{EVO4, "sysio.token"_n}) + ("to", "alice"_n)("memo", "") ) + ); + BOOST_REQUIRE_EQUAL( success(), + closeext( "alice"_n, "alice"_n, extended_symbol{VOICE4, "anothertoken"_n}) ); + BOOST_REQUIRE_EQUAL( success(), + closeext( "alice"_n, "bob"_n, extended_symbol{EOS4, "sysio.token"_n} ) ); + BOOST_REQUIRE_EQUAL( 9999999, token_balance( "sysio.token"_n, "bob"_n, EOS.value )); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("User does not have such token"), + closeext( "alice"_n, "bob"_n, extended_symbol{EOS4, "sysio.token"_n} ) ); + + // CHANGEFEE + BOOST_REQUIRE_EQUAL( error("missing authority of sysio"), changefee(EVO, 50, "bob"_n) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), changefee(EOS, 500)); + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 50) ); + +} FC_LOG_AND_RETHROW() + +// A fixture whose deployment step has NOT run: no fee authority, no system token. +struct sysio_swap_unconfigured_tester : public sysio_swap_tester { + sysio_swap_unconfigured_tester() : sysio_swap_tester( false ) {} +}; + +BOOST_FIXTURE_TEST_CASE( nothing_works_before_setconfig, sysio_swap_unconfigured_tester ) try { + create_tokens_and_issue(); + abi_ser.set_abi( swap_abi_def(), abi_serializer::create_yield_function(abi_serializer_max_time) ); + many_openext(); + // Without a system token nothing can be deposited, with or without the + // contract's authority, and no pair can be created, whoever its authority is. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("swap not configured"), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("1.0000 EOS"), "") ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("swap not configured"), + seed_transfer( "anothertoken"_n, "bob"_n, asset::from_string("1.0000 VOICE"), "deposit to: alice") ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("swap not configured"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.0000 EOS")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("swap not configured"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.0000 EOS")), 10, "alice"_n) ); + // setconfig is the contract's own call, and checks what it is given. + BOOST_REQUIRE_EQUAL( error("missing authority of sysio.swap"), setconfig( "alice"_n, SYSTEM_TOKEN, "alice"_n ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee authority account does not exist"), setconfig( "natalia"_n ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("system token contract does not exist"), + setconfig( config::system_account_name, extended_symbol{ EOS4, "natalia"_n } ) ); + BOOST_REQUIRE_EQUAL( success(), setconfig( config::system_account_name ) ); + // Configured: the system token deposits freely, a first leg with the + // contract's authority, and pairs form against the system token. + many_transfer(); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.0000 EOS")), 10, "alice"_n) ); + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 25, "alice"_n) ); + BOOST_REQUIRE_EQUAL( 25, pool_fee(EVO) ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("1.00 TUSD")), extend(asset::from_string("1.0000 EOS")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( success(), changefee(ETUSD, 25) ); + // Once the pair exists its first leg deposits on its own. + BOOST_REQUIRE_EQUAL( success(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_n, asset::from_string("0.0001 VOICE"), "") ); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( seed_locks_shares, sysio_swap_tester ) try { + create_tokens_and_issue(); + abi_ser.set_abi( swap_abi_def(), abi_serializer::create_yield_function(abi_serializer_max_time) ); + many_openext(); + many_transfer(); + const int64_t minted = 100'000'000'000; // sqrt(1e10 * 1e12) + const int64_t locked = 5'000'000; + const auto lock_of = [&](int64_t units) { return asset(units, EVO4); }; + + // The lock must be in the new symbol, nonnegative, and leave the creator something. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("locked_shares must be in new_symbol"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("100000000.0000 VOICE")), extend(asset::from_string("1000000.0000 EOS")), + 10, name{}, asset(locked, VOICE4) ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("locked_shares must be nonnegative"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("100000000.0000 VOICE")), extend(asset::from_string("1000000.0000 EOS")), + 10, name{}, lock_of(-1) ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("locked_shares must leave the creator at least one share"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("100000000.0000 VOICE")), extend(asset::from_string("1000000.0000 EOS")), + 10, name{}, lock_of(minted) ) ); + + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extend(asset::from_string("100000000.0000 VOICE")), extend(asset::from_string("1000000.0000 EOS")), + 10, name{}, lock_of(locked) ) ); + // The whole geometric mean is supply; the creator holds all of it but the lock. + auto pool = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( minted, pool.at(2) ); + BOOST_REQUIRE_EQUAL( minted - locked, lp_balance("alice"_n, EVO) ); + BOOST_REQUIRE_EQUAL( lock_of(locked).to_string(), get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, + "currency_stats")["locked_shares"].as_string() ); + + // Removing every share the creator holds succeeds and leaves the locked + // shares' slice of the pools behind: the pair can never be emptied. + BOOST_REQUIRE_EQUAL( success(), remliquidity( "alice"_n, lock_of(minted - locked), asset(0, VOICE4), asset(0, EOS4) ) ); + pool = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( locked, pool.at(2) ); + BOOST_REQUIRE_EQUAL( reference::remove_leg(locked, 10'000'000'000, minted), pool.at(1) ); // the EOS the lock still backs + BOOST_REQUIRE_EQUAL( 0, lp_balance("alice"_n, EVO) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("overdrawn balance"), + remliquidity( "alice"_n, lock_of(1), asset(0, VOICE4), asset(0, EOS4) ) ); + + // The pool keeps working from the locked floor: pricing uses the full supply. + const auto before = system_balance(EVO.value); + const int64_t pay1 = reference::add_leg(locked, before[0], before[2]); + const int64_t pay2 = reference::add_leg(locked, before[1], before[2]); + BOOST_REQUIRE_EQUAL( success(), addliquidity( "alice"_n, lock_of(locked), asset(pay1, VOICE4), asset(pay2, EOS4) ) ); + BOOST_REQUIRE_EQUAL( 2 * locked, system_balance(EVO.value).at(2) ); + BOOST_REQUIRE_GE( settle_swap("alice"_n, EVO, asset(1000, EOS4), VOICE4, 0), 0 ); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Yield pools: the optional yield leg and its uniqueness rule. +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE( yield_leg_rules, sysio_swap_tester ) try { + create_tokens_and_issue(); + abi_ser.set_abi( swap_abi_def(), abi_serializer::create_yield_function(abi_serializer_max_time) ); + many_openext(); + many_transfer(); + const extended_symbol voice{ VOICE4, "anothertoken"_n }; + const extended_symbol tusd{ TUSD2, "sysio.token"_n }; + + // The yield leg must be the pair's first leg: not another token, not the + // system token, not a look-alike from another contract. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("yield_leg must be the pair's first leg"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.0000 EOS")), 10, name{}, 0, tusd ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("yield_leg must be the pair's first leg"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.0000 EOS")), 10, name{}, 0, SYSTEM_TOKEN ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("yield_leg must be the pair's first leg"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.0000 EOS")), 10, name{}, 0, + extended_symbol{ VOICE4, "sysio.token"_n } ) ); + + // EVO is a yield pool on VOICE; ETUSD is a plain pool. + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.0000 EOS")), 10, name{}, 0, voice ) ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("1.00 TUSD")), extend(asset::from_string("1.0000 EOS")), 10, name{} ) ); + auto evo = pair_row(EVO); + BOOST_REQUIRE_EQUAL( "4,VOICE", evo["yield_leg"]["sym"].as_string() ); + BOOST_REQUIRE_EQUAL( "anothertoken", evo["yield_leg"]["contract"].as_string() ); + BOOST_REQUIRE_EQUAL( 0u, evo["conversion_horizon_sec"].as_uint64() ); + BOOST_REQUIRE_EQUAL( 0u, evo["depth_cap_bps"].as_uint64() ); + BOOST_REQUIRE( pair_row(ETUSD)["yield_leg"].is_null() ); + + // One yield pool per shadow follows from one pair per first leg: VOICE + // cannot form a second pair, yielding or plain. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("the pool is already indexed"), inittoken( "alice"_n, + symbol::from_string("4,BVO"), extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.0000 EOS")), + 10, name{}, 0, voice ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("the pool is already indexed"), inittoken( "alice"_n, + symbol::from_string("4,BVO"), extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.0000 EOS")), + 10, name{} ) ); + + // setyield: fee authority only, yield pools only, cap within basis points, + // clip floor within an asset. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair has no yield leg"), setyield( ETUSD, 86400, 3, 1000 ) ); + BOOST_REQUIRE_EQUAL( error("missing authority of sysio"), setyield( EVO, 86400, 3, 1000, "alice"_n ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("depth_cap_bps out of range"), setyield( EVO, 86400, 10001, 1000 ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("clip_floor out of range"), setyield( EVO, 86400, 3, -1 ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), setyield( EOS, 86400, 3, 1000 ) ); + BOOST_REQUIRE_EQUAL( success(), setyield( EVO, 86400, 3, 1000 ) ); + evo = pair_row(EVO); + BOOST_REQUIRE_EQUAL( 86400u, evo["conversion_horizon_sec"].as_uint64() ); + BOOST_REQUIRE_EQUAL( 3u, evo["depth_cap_bps"].as_uint64() ); + BOOST_REQUIRE_EQUAL( 1000, evo["clip_floor"].as_int64() ); + // The leg is fixed at creation; setyield does not touch it. + BOOST_REQUIRE_EQUAL( "4,VOICE", evo["yield_leg"]["sym"].as_string() ); + + // A plain token in the yield leg has no distribution state: it is owed + // nothing, and every accrual point is a no-op rather than a failure. + const auto before = system_balance( EVO.value ); + BOOST_REQUIRE_EQUAL( success(), accrueyield( EVO ) ); + BOOST_REQUIRE( before == system_balance( EVO.value ) ); + BOOST_REQUIRE( pending_payout( "anothertoken"_n ).empty() ); + // Only yield pools accrue; a missing pair is reported as such. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair has no yield leg"), accrueyield( ETUSD ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), accrueyield( EOS ) ); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Yield accrual: what the pool is owed on the shadow it holds lands in its +// other leg, computed from the token's public state and asserted on receipt. +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE( yield_accrues_into_the_pool_without_minting, sysio_swap_yield_tester ) try { + setup_yield_pool(); + const auto pool_wire_at_token = [&]() { return token_balance( "sysio.token"_n, "sysio.swap"_n, WIRE.value ); }; + const int64_t supply = system_balance( SHEO.value ).at(2); + + // Nothing distributed yet: accrual is a no-op and leaves no trace. + BOOST_REQUIRE_EQUAL( success(), accrueyield( SHEO ) ); + BOOST_REQUIRE_EQUAL( YieldPoolWire, system_balance( SHEO.value ).at(1) ); + BOOST_REQUIRE( pending_payout( "sysio.liq"_n ).empty() ); + + // bob donates 1e6 units of WIRE to SHD holders: the index advances by the spec, the + // truncation remainder is carried, and the pot holds the whole donation. + const int64_t first_donation = 100'0000; + BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( first_donation, WIRE9 ), SHD ) ); + const auto first = yield_reference::distribute( first_donation, ShadowIssuance, 0 ); + auto idx = shadow_index( SHD ); + BOOST_REQUIRE_EQUAL( first.index_delta, yield_reference::wide( idx.index ) ); + BOOST_REQUIRE_EQUAL( first.carry, idx.carry ); + BOOST_REQUIRE_EQUAL( uint64_t(first_donation), idx.pot ); + BOOST_REQUIRE_LT( 0u, idx.carry ); // the chosen supply does not divide evenly + + // The pool is owed its share, floored; accruing credits exactly that to the + // WIRE side, mints nothing, and the token delivers the same amount in the + // same transaction: the receipt is retired and the contract's WIRE grew by it. + const int64_t owed1 = yield_reference::owed( YieldPoolShadow, idx.index, 0 ); + BOOST_REQUIRE_EQUAL( 333333, owed1 ); // 1e6 units * 1/3, floored + const int64_t wire_before = pool_wire_at_token(); + BOOST_REQUIRE_EQUAL( success(), accrueyield( SHEO ) ); + auto pool = system_balance( SHEO.value ); + BOOST_REQUIRE_EQUAL( YieldPoolShadow, pool.at(0) ); + BOOST_REQUIRE_EQUAL( YieldPoolWire + owed1, pool.at(1) ); + BOOST_REQUIRE_EQUAL( supply, pool.at(2) ); + BOOST_REQUIRE_EQUAL( wire_before + owed1, pool_wire_at_token() ); + BOOST_REQUIRE( pending_payout( "sysio.liq"_n ).empty() ); + auto held = shadow_account( "sysio.swap"_n, SHD ); + BOOST_REQUIRE_EQUAL( idx.index, held.index_checkpoint ); + BOOST_REQUIRE_EQUAL( 0u, held.owed_wire ); + BOOST_REQUIRE_EQUAL( uint64_t(first_donation - owed1), shadow_index( SHD ).pot ); + + // Settled means settled: a second accrual changes nothing. + BOOST_REQUIRE_EQUAL( success(), accrueyield( SHEO ) ); + BOOST_REQUIRE( pool == system_balance( SHEO.value ) ); + BOOST_REQUIRE_EQUAL( wire_before + owed1, pool_wire_at_token() ); + + // Two more distributions before the next accrual: the carry chains through + // them, and one accrual collects the pool's share of both. + const int64_t second_donation = 7'0001, third_donation = 50'0000; + BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( second_donation, WIRE9 ), SHD ) ); + BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( third_donation, WIRE9 ), SHD ) ); + const auto second = yield_reference::distribute( second_donation, ShadowIssuance, first.carry ); + const auto third = yield_reference::distribute( third_donation, ShadowIssuance, second.carry ); + const yield_reference::wide index_after_three = first.index_delta + second.index_delta + third.index_delta; + idx = shadow_index( SHD ); + BOOST_REQUIRE_EQUAL( index_after_three, yield_reference::wide( idx.index ) ); + BOOST_REQUIRE_EQUAL( third.carry, idx.carry ); + const int64_t owed2 = yield_reference::owed( YieldPoolShadow, idx.index, held.index_checkpoint ); + BOOST_REQUIRE_EQUAL( success(), accrueyield( SHEO ) ); + pool = system_balance( SHEO.value ); + BOOST_REQUIRE_EQUAL( YieldPoolWire + owed1 + owed2, pool.at(1) ); + BOOST_REQUIRE_EQUAL( supply, pool.at(2) ); + BOOST_REQUIRE_EQUAL( wire_before + owed1 + owed2, pool_wire_at_token() ); + BOOST_REQUIRE_EQUAL( idx.index, shadow_account( "sysio.swap"_n, SHD ).index_checkpoint ); + + // alice, holding the other two thirds directly, is owed by the same formula, + // and the pot ends holding only what flooring left behind. + const int64_t owed_alice = yield_reference::owed( ShadowIssuance - YieldPoolShadow, idx.index, 0 ); + const int64_t alice_before = token_balance( "sysio.token"_n, "alice"_n, WIRE.value ); + BOOST_REQUIRE_EQUAL( success(), shadow_claim( "alice"_n, SHD ) ); + BOOST_REQUIRE_EQUAL( alice_before + owed_alice, token_balance( "sysio.token"_n, "alice"_n, WIRE.value ) ); + const int64_t donated = first_donation + second_donation + third_donation; + BOOST_REQUIRE_EQUAL( uint64_t(donated - owed1 - owed2 - owed_alice), shadow_index( SHD ).pot ); + BOOST_REQUIRE_LT( shadow_index( SHD ).pot, 3u ); // at most one unit of dust per holder +} FC_LOG_AND_RETHROW() + +// The index is 128-bit: one add to a thinly held symbol moves it past 2^64 (a +// single subunit of supply and 2e7 subunits of yield give 2e19), and the holder +// is still paid every subunit, through that add and the next one. +BOOST_FIXTURE_TEST_CASE( yield_index_grows_past_64_bits, sysio_swap_yield_tester ) try { + create_tokens_and_issue(); + BOOST_REQUIRE_EQUAL( success(), shadow_mint( "alice"_n, asset( 1, SHD4 ) ) ); + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "bob"_n, asset( BobDonationBudget, WIRE9 ), "" ) ); + + const int64_t supply = 1; + const int64_t donation = 2000'0000; // 2e7 subunits: the index moves by 2e7 * 1e12 / 1 + const auto first = yield_reference::distribute( donation, supply, 0 ); + BOOST_REQUIRE_LT( yield_reference::wide( std::numeric_limits::max() ), first.index_delta ); + BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( donation, WIRE9 ), SHD ) ); + BOOST_REQUIRE_EQUAL( first.index_delta, yield_reference::wide( shadow_index( SHD ).index ) ); + + // The sole holder is owed the whole donation, and claiming pays exactly that. + BOOST_REQUIRE_EQUAL( donation, yield_reference::owed( supply, shadow_index( SHD ).index, 0 ) ); + int64_t alice_before = token_balance( "sysio.token"_n, "alice"_n, WIRE.value ); + BOOST_REQUIRE_EQUAL( success(), shadow_claim( "alice"_n, SHD ) ); + BOOST_REQUIRE_EQUAL( alice_before + donation, token_balance( "sysio.token"_n, "alice"_n, WIRE.value ) ); + const auto held = shadow_account( "alice"_n, SHD ); + BOOST_REQUIRE_EQUAL( shadow_index( SHD ).index, held.index_checkpoint ); + BOOST_REQUIRE_EQUAL( 0u, held.owed_wire ); + BOOST_REQUIRE_EQUAL( 0u, shadow_index( SHD ).pot ); + + // A second add accrues from a checkpoint that is itself past 2^64. + const auto second = yield_reference::distribute( donation, supply, first.carry ); + BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( donation, WIRE9 ), SHD ) ); + BOOST_REQUIRE_EQUAL( first.index_delta + second.index_delta, yield_reference::wide( shadow_index( SHD ).index ) ); + BOOST_REQUIRE_EQUAL( donation, yield_reference::owed( supply, shadow_index( SHD ).index, held.index_checkpoint ) ); + alice_before = token_balance( "sysio.token"_n, "alice"_n, WIRE.value ); + BOOST_REQUIRE_EQUAL( success(), shadow_claim( "alice"_n, SHD ) ); + BOOST_REQUIRE_EQUAL( alice_before + donation, token_balance( "sysio.token"_n, "alice"_n, WIRE.value ) ); + BOOST_REQUIRE_EQUAL( 0u, shadow_index( SHD ).pot ); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( yield_is_credited_before_shares_are_priced, sysio_swap_yield_tester ) try { + setup_yield_pool(); + const int64_t donation = 300'0000; + // alice deposits more shadow to mint with. The contract now holds shadow that + // is not in the pool, and the token pays yield on all of it: deposit + // accounts are not yield-bearing, the pool takes what its custody earns. + const int64_t alice_extra_shadow = 500'000'0000; + BOOST_REQUIRE_EQUAL( success(), shadow_transfer( "alice"_n, "sysio.swap"_n, asset( alice_extra_shadow, SHD4 ), "" ) ); + const int64_t contract_shadow = YieldPoolShadow + alice_extra_shadow; + BOOST_REQUIRE_EQUAL( contract_shadow, shadow_account( "sysio.swap"_n, SHD ).balance.get_amount() ); + // What the contract is owed on its whole holding at the current index. + const auto owed_now = [&]() { + const auto held = shadow_account( "sysio.swap"_n, SHD ); + return yield_reference::owed( held.balance.get_amount(), shadow_index( SHD ).index, + held.index_checkpoint, held.owed_wire ); + }; + + // A mint after a distribution prices against the accrued pool: the yield + // belongs to the shares that existed, so the new shares pay for their cut. + BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( donation, WIRE9 ), SHD ) ); + auto before = system_balance( SHEO.value ); + int64_t owed = owed_now(); + BOOST_REQUIRE_LT( yield_reference::owed( before.at(0), shadow_index( SHD ).index, 0 ), owed ); + const int64_t shares = 1000'0000; + const int64_t pay_shadow = reference::add_leg( shares, before.at(0), before.at(2) ); + const int64_t pay_wire = reference::add_leg( shares, before.at(1) + owed, before.at(2) ); + const int64_t alice_shadow = deposit_of( "alice"_n, SHADOW ); + const int64_t alice_wire = balance( "alice"_n, WIRE9 ); + BOOST_REQUIRE_EQUAL( success(), addliquidity( "alice"_n, asset( shares, SHEO6 ), + asset( pay_shadow, SHD4 ), asset( pay_wire, WIRE9 ) ) ); + auto after = system_balance( SHEO.value ); + BOOST_REQUIRE_EQUAL( before.at(0) + pay_shadow, after.at(0) ); + BOOST_REQUIRE_EQUAL( before.at(1) + owed + pay_wire, after.at(1) ); + BOOST_REQUIRE_EQUAL( before.at(2) + shares, after.at(2) ); + BOOST_REQUIRE_EQUAL( alice_shadow - pay_shadow, deposit_of( "alice"_n, SHADOW ) ); + BOOST_REQUIRE_EQUAL( alice_wire - pay_wire, balance( "alice"_n, WIRE9 ) ); + BOOST_REQUIRE( pending_payout( "sysio.liq"_n ).empty() ); + // Paying one unit less than the accrued price is refused: the quote is the + // accrued one, not the stale one a caller might compute from the row. + BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( donation, WIRE9 ), SHD ) ); + before = after; + owed = owed_now(); + BOOST_REQUIRE_LT( 0, owed ); + const int64_t stale_wire = reference::add_leg( shares, before.at(1), before.at(2) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + addliquidity( "alice"_n, asset( shares, SHEO6 ), asset( pay_shadow, SHD4 ), asset( stale_wire, WIRE9 ) ) ); + // The refused action left nothing behind: no credit, no receipt. + BOOST_REQUIRE( before == system_balance( SHEO.value ) ); + BOOST_REQUIRE( pending_payout( "sysio.liq"_n ).empty() ); + + // A burn after a distribution pays out of the accrued pool too. + const int64_t get_shadow = reference::remove_leg( shares, before.at(0), before.at(2) ); + const int64_t get_wire = reference::remove_leg( shares, before.at(1) + owed, before.at(2) ); + const int64_t alice_wire_before = balance( "alice"_n, WIRE9 ); + BOOST_REQUIRE_EQUAL( success(), remliquidity( "alice"_n, asset( shares, SHEO6 ), + asset( get_shadow, SHD4 ), asset( get_wire, WIRE9 ) ) ); + after = system_balance( SHEO.value ); + BOOST_REQUIRE_EQUAL( before.at(0) - get_shadow, after.at(0) ); + BOOST_REQUIRE_EQUAL( before.at(1) + owed - get_wire, after.at(1) ); + BOOST_REQUIRE_EQUAL( before.at(2) - shares, after.at(2) ); + BOOST_REQUIRE_EQUAL( alice_wire_before + get_wire, balance( "alice"_n, WIRE9 ) ); + // Mint and burn move shadow between deposits and the pool, never out of the + // contract, and its row is settled at the current index: nothing is owed + // until the next distribution. + const auto held = shadow_account( "sysio.swap"_n, SHD ); + BOOST_REQUIRE_EQUAL( contract_shadow, held.balance.get_amount() ); + BOOST_REQUIRE_EQUAL( shadow_index( SHD ).index, held.index_checkpoint ); + BOOST_REQUIRE_EQUAL( 0u, held.owed_wire ); + BOOST_REQUIRE_EQUAL( success(), accrueyield( SHEO ) ); + BOOST_REQUIRE( after == system_balance( SHEO.value ) ); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( yield_accrues_before_a_swap_is_priced, sysio_swap_yield_tester ) try { + setup_yield_pool(); + const auto shadow_pool_of = [&]( const vector& pool ) { return pool.at(0); }; + const auto wire_pool_of = [&]( const vector& pool ) { return pool.at(1); }; + const int fee = pool_fee( SHEO ); + const int64_t pay = 1000'0000; + + BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( 100'0000, WIRE9 ), SHD ) ); + auto before = system_balance( SHEO.value ); + const auto held = shadow_account( "sysio.swap"_n, SHD ); + const int64_t owed = yield_reference::owed( held.balance.get_amount(), shadow_index( SHD ).index, + held.index_checkpoint, held.owed_wire ); + BOOST_REQUIRE_LT( 0, owed ); + + // Buying shadow prices against the pool WITH the owed yield in it, which is + // strictly worse for the buyer than the stale quote: the WIRE side is larger, + // so a given payment buys less. + const int64_t accrued_out = reference::receive( pay, wire_pool_of(before) + owed, shadow_pool_of(before), fee ); + const int64_t stale_out = reference::receive( pay, wire_pool_of(before), shadow_pool_of(before), fee ); + BOOST_REQUIRE_LT( accrued_out, stale_out ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + exchange( "alice"_n, SHEO, extend(asset(pay, WIRE9)), asset(stale_out, SHD4) ) ); + BOOST_REQUIRE_EQUAL( success(), + exchange( "alice"_n, SHEO, extend(asset(pay, WIRE9)), asset(accrued_out, SHD4) ) ); + auto after = system_balance( SHEO.value ); + BOOST_REQUIRE_EQUAL( wire_pool_of(before) + owed + pay, wire_pool_of(after) ); + BOOST_REQUIRE_EQUAL( shadow_pool_of(before) - accrued_out, shadow_pool_of(after) ); + BOOST_REQUIRE_EQUAL( before.at(2), after.at(2) ); // no shares minted + BOOST_REQUIRE( pending_payout( "sysio.liq"_n ).empty() ); + + // Buy, settle, sell back. With the yield already in the pool before the buy + // is priced there is nothing left to get in front of, so the round trip only + // pays the fee twice and alice ends with less WIRE than she started. + BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( 100'0000, WIRE9 ), SHD ) ); + const int64_t wire_start = balance( "alice"_n, WIRE9 ); + const int64_t shadow_start = deposit_of( "alice"_n, SHADOW ); + BOOST_REQUIRE_EQUAL( success(), exchange( "alice"_n, SHEO, extend(asset(pay, WIRE9)), asset(0, SHD4) ) ); + const int64_t bought = deposit_of( "alice"_n, SHADOW ) - shadow_start; + BOOST_REQUIRE_LT( 0, bought ); + BOOST_REQUIRE_EQUAL( success(), accrueyield( SHEO ) ); // nothing left pending + BOOST_REQUIRE_EQUAL( success(), exchange( "alice"_n, SHEO, shd(bought), asset(0, WIRE9) ) ); + BOOST_REQUIRE_EQUAL( shadow_start, deposit_of( "alice"_n, SHADOW ) ); + BOOST_REQUIRE_LT( balance( "alice"_n, WIRE9 ), wire_start ); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( yield_payout_route_is_exact, sysio_swap_yield_tester ) try { + setup_yield_pool(); + // A transfer from a shadow contract with no claim outstanding is an ordinary + // deposit, and needs the ordinary deposit row: the payout route only exists + // while a receipt does. + BOOST_REQUIRE_EQUAL( success(), shadow_mint( "sysio.liq"_n, asset( 1'0000, SHD4 ) ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol not registered for this user," + " please run openext action or write exchange details in the memo of your transfer"), + shadow_transfer( "sysio.liq"_n, "sysio.swap"_n, asset( 1'0000, SHD4 ), "" ) ); + BOOST_REQUIRE_EQUAL( success(), openext( "sysio.liq"_n, "alice"_n, SHADOW ) ); + BOOST_REQUIRE_EQUAL( success(), shadow_transfer( "sysio.liq"_n, "sysio.swap"_n, asset( 1'0000, SHD4 ), "" ) ); + BOOST_REQUIRE_EQUAL( 1'0000, deposit_of( "sysio.liq"_n, SHADOW ) ); + BOOST_REQUIRE_EQUAL( YieldPoolWire, system_balance( SHEO.value ).at(1) ); + + // The shadow's own holding (issued above) makes it a holder too; a + // distribution and an accrual still settle the contract's share exactly, + // on everything it holds (the pool plus that one deposited unit). + const int64_t donation = 10'0000; + BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( donation, WIRE9 ), SHD ) ); + const int64_t owed = yield_reference::owed( YieldPoolShadow + 1'0000, shadow_index( SHD ).index, 0 ); + BOOST_REQUIRE_EQUAL( success(), accrueyield( SHEO ) ); + BOOST_REQUIRE_EQUAL( YieldPoolWire + owed, system_balance( SHEO.value ).at(1) ); + BOOST_REQUIRE( pending_payout( "sysio.liq"_n ).empty() ); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Yield funding: shadow announced with fundyield lands in the pair's +// reservoir, not in the funder's deposit. +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE( yield_funding_fills_the_reservoir, sysio_swap_yield_tester ) try { + setup_yield_pool(); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD5, + extend(asset::from_string("1.00 TUSD")), extend( asset( 1'0000, WIRE9 ) ), 10, name{}) ); + BOOST_REQUIRE( !has_reservoir( ETUSD ) ); // plain pools queue nothing + + // Only a yield pool, in its shadow symbol, a positive amount, by the funder. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair has no yield leg"), fundyield( "alice"_n, ETUSD, asset( 1'0000, WIRE9 ) ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), fundyield( "alice"_n, WIRE, asset( 1'0000, SHD4 ) ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("quantity must be in the pair's shadow symbol"), + fundyield( "alice"_n, SHEO, asset( 1'0000, WIRE9 ) ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("quantity must be positive"), fundyield( "alice"_n, SHEO, asset( 0, SHD4 ) ) ); + BOOST_REQUIRE_EQUAL( error("missing authority of bob"), push_action( "sysio.swap"_n, "alice"_n, "fundyield"_n, mvo() + ( "from", "bob"_n )( "pair_token", SHEO )( "quantity", asset( 1'0000, SHD4 ) ) ) ); + BOOST_REQUIRE( pending_funding( "alice"_n ).is_null() ); + + // Announce, then deliver in a later transaction: until the announced amount + // arrives every other transfer from the funder is refused, the deposit is + // untouched, and the delivery goes to the reservoir. + const int64_t first = 100'0000; + const int64_t alice_deposit = deposit_of( "alice"_n, SHADOW ); + BOOST_REQUIRE_EQUAL( success(), fundyield( "alice"_n, SHEO, asset( first, SHD4 ) ) ); + auto pending = pending_funding( "alice"_n ); + BOOST_REQUIRE_EQUAL( SHEO6.name(), pending["pair"].as_string() ); + BOOST_REQUIRE_EQUAL( asset( first, SHD4 ).to_string(), pending["quantity"]["quantity"].as_string() ); + BOOST_REQUIRE_EQUAL( "sysio.liq", pending["quantity"]["contract"].as_string() ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("yield funding does not match the pending fundyield"), + shadow_transfer( "alice"_n, "sysio.swap"_n, asset( first / 2, SHD4 ), "" ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("yield funding does not match the pending fundyield"), + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset( 1'0000, WIRE9 ), "" ) ); + BOOST_REQUIRE_EQUAL( success(), shadow_transfer( "alice"_n, "sysio.swap"_n, asset( first, SHD4 ), "" ) ); + BOOST_REQUIRE_EQUAL( first, reservoir_of( SHEO ) ); + BOOST_REQUIRE_EQUAL( alice_deposit, deposit_of( "alice"_n, SHADOW ) ); + BOOST_REQUIRE( pending_funding( "alice"_n ).is_null() ); + BOOST_REQUIRE_EQUAL( YieldPoolShadow, system_balance( SHEO.value ).at(0) ); // not in the pool + // With nothing pending, a transfer is an ordinary deposit again. + BOOST_REQUIRE_EQUAL( success(), shadow_transfer( "alice"_n, "sysio.swap"_n, asset( 1'0000, SHD4 ), "" ) ); + BOOST_REQUIRE_EQUAL( alice_deposit + 1'0000, deposit_of( "alice"_n, SHADOW ) ); + BOOST_REQUIRE_EQUAL( first, reservoir_of( SHEO ) ); + + // A new announcement replaces a pending one. + const int64_t second = 20'0000; + BOOST_REQUIRE_EQUAL( success(), fundyield( "alice"_n, SHEO, asset( 30'0000, SHD4 ) ) ); + BOOST_REQUIRE_EQUAL( success(), fundyield( "alice"_n, SHEO, asset( second, SHD4 ) ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("yield funding does not match the pending fundyield"), + shadow_transfer( "alice"_n, "sysio.swap"_n, asset( 30'0000, SHD4 ), "" ) ); + BOOST_REQUIRE_EQUAL( success(), shadow_transfer( "alice"_n, "sysio.swap"_n, asset( second, SHD4 ), "" ) ); + BOOST_REQUIRE_EQUAL( first + second, reservoir_of( SHEO ) ); + + // The intended shape: both steps in one transaction. + const int64_t third = 7'0000; + fund_yield_in_one_transaction( "alice"_n, SHEO, asset( third, SHD4 ) ); + BOOST_REQUIRE_EQUAL( first + second + third, reservoir_of( SHEO ) ); + BOOST_REQUIRE( pending_funding( "alice"_n ).is_null() ); + + // The announcement's row is the funder's, not the contract's, and cancelling + // gives it back. Otherwise one abandoned announcement per account that ever + // called fundyield would sit on the contract's RAM with no way to reclaim it. + { + const int64_t swap_ram = ram_usage( "sysio.swap"_n ); + const int64_t bob_ram = ram_usage( "bob"_n ); + BOOST_REQUIRE_EQUAL( success(), fundyield( "bob"_n, SHEO, asset( 5'0000, SHD4 ) ) ); + BOOST_REQUIRE_LT( bob_ram, ram_usage( "bob"_n ) ); + BOOST_REQUIRE_EQUAL( swap_ram, ram_usage( "sysio.swap"_n ) ); + // While it is pending bob cannot deposit anything else... + BOOST_REQUIRE_EQUAL( wasm_assert_msg("yield funding does not match the pending fundyield"), + transfer( "sysio.token"_n, "bob"_n, "sysio.swap"_n, asset( 1'0000, WIRE9 ), "" ) ); + // ...and cancelling is his own call, not anyone else's. + BOOST_REQUIRE_EQUAL( error("missing authority of bob"), cancelyield( "bob"_n, "alice"_n ) ); + BOOST_REQUIRE_EQUAL( success(), cancelyield( "bob"_n ) ); + BOOST_REQUIRE( pending_funding( "bob"_n ).is_null() ); + BOOST_REQUIRE_EQUAL( bob_ram, ram_usage( "bob"_n ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("no pending fundyield"), cancelyield( "bob"_n ) ); + // Ordinary deposits work again. + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "bob"_n, "sysio.swap"_n, asset( 1'0000, WIRE9 ), "" ) ); + BOOST_REQUIRE_EQUAL( first + second + third, reservoir_of( SHEO ) ); + } + + // The reservoir is the contract's shadow too: it earns for the pool. + BOOST_REQUIRE_EQUAL( YieldPoolShadow + 1'0000 + first + second + third, + shadow_account( "sysio.swap"_n, SHD ).balance.get_amount() ); + BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( 100'0000, WIRE9 ), SHD ) ); + const int64_t owed = yield_reference::owed( YieldPoolShadow + 1'0000 + first + second + third, + shadow_index( SHD ).index, 0 ); + BOOST_REQUIRE_EQUAL( success(), accrueyield( SHEO ) ); + BOOST_REQUIRE_EQUAL( YieldPoolWire + owed, system_balance( SHEO.value ).at(1) ); + BOOST_REQUIRE_EQUAL( first + second + third, reservoir_of( SHEO ) ); // accrual leaves the queue alone +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// The yield tick: the reservoir sells through the pool in clips paced by the +// horizon, and the proceeds go back to the shadow's holders. +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_swap_yield_tester ) try { + setup_yield_pool(); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD5, + extend(asset::from_string("1.00 TUSD")), extend( asset( 1'0000, WIRE9 ) ), 10, name{}) ); + const uint32_t horizon_sec = 3600; + const uint32_t cap_bps = 1; + // Sized the way setyield's docs prescribe: at a 0.1% pair fee the proportional + // fee reaches a whole unit at an output of 1000, which is where MIN_SWAP_FEE + // stops being the binding fee. + const int64_t clip_floor = 1000; + const int fee = pool_fee( SHEO ); + const auto shadow_pool_of = [&]( const vector& pool ) { return pool.at(0); }; // SHD is pool1 + const auto wire_pool_of = [&]( const vector& pool ) { return pool.at(1); }; + + // Only a yield pool with its parameters set can tick. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair has no yield leg"), tickyield( ETUSD ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), tickyield( WIRE ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("yield tick parameters not set"), tickyield( SHEO ) ); + // A floor of zero is as unset as a horizon of zero: the tick refuses it. + BOOST_REQUIRE_EQUAL( success(), setyield( SHEO, horizon_sec, cap_bps, 0 ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("yield tick parameters not set"), tickyield( SHEO ) ); + BOOST_REQUIRE_EQUAL( success(), setyield( SHEO, horizon_sec, cap_bps, clip_floor ) ); + const int64_t set_at = last_tick_us( SHEO ); + BOOST_REQUIRE_EQUAL( control->head_block_time().time_since_epoch().count(), set_at ); // setyield starts the clock + + // An empty reservoir: the tick is a no-op and the clock is untouched. + auto before = system_balance( SHEO.value ); + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + BOOST_REQUIRE( before == system_balance( SHEO.value ) ); + BOOST_REQUIRE_EQUAL( set_at, last_tick_us( SHEO ) ); + + // Funding an empty reservoir restarts the clock: the queue sells over a + // fresh horizon from the moment it is funded. + const int64_t queued = 1000'0000; + fund_yield_in_one_transaction( "alice"_n, SHEO, asset( queued, SHD4 ) ); + const int64_t funded_at = last_tick_us( SHEO ); + BOOST_REQUIRE_LT( set_at, funded_at ); + BOOST_REQUIRE_EQUAL( queued, reservoir_of( SHEO ) ); + + // One tick, one block later: the clip is the queue's share of the elapsed + // horizon (rounded up), sold at the pool's curve and fee; the proceeds leave + // the contract for the token's pot, which advances the index by the spec. + before = system_balance( SHEO.value ); + auto idx_before = shadow_index( SHD ); + const int64_t contract_wire_before = token_balance( "sysio.token"_n, "sysio.swap"_n, WIRE.value ); + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + const int64_t ticked_at = last_tick_us( SHEO ); + int64_t clip = yield_reference::clip_size( queued, ticked_at - funded_at, horizon_sec, + shadow_pool_of(before), cap_bps, clip_floor ); + BOOST_REQUIRE_LT( clip_floor, clip ); // over the floor, so it sells + BOOST_REQUIRE_LT( clip, queued / 1000 ); // a block is a sliver of the horizon + int64_t proceeds = reference::receive( clip, shadow_pool_of(before), wire_pool_of(before), fee ); + auto after = system_balance( SHEO.value ); + BOOST_REQUIRE_EQUAL( shadow_pool_of(before) + clip, shadow_pool_of(after) ); + BOOST_REQUIRE_EQUAL( wire_pool_of(before) - proceeds, wire_pool_of(after) ); + BOOST_REQUIRE_EQUAL( before.at(2), after.at(2) ); + BOOST_REQUIRE_EQUAL( queued - clip, reservoir_of( SHEO ) ); + BOOST_REQUIRE_EQUAL( contract_wire_before - proceeds, token_balance( "sysio.token"_n, "sysio.swap"_n, WIRE.value ) ); + auto distributed = yield_reference::distribute( proceeds, ShadowIssuance, idx_before.carry ); + auto idx_after = shadow_index( SHD ); + BOOST_REQUIRE_EQUAL( yield_reference::wide( idx_before.index ) + distributed.index_delta, + yield_reference::wide( idx_after.index ) ); + BOOST_REQUIRE_EQUAL( idx_before.pot + uint64_t(proceeds), idx_after.pot ); + // The pool kept the fee: the product grew. + BOOST_REQUIRE( is_increasing( before, after ) ); + BOOST_REQUIRE_LT( wide_product( before ), wide_product( after ) ); + + // The proceeds come back: the contract is a holder, so the next accrual + // (explicit here; every later tick performs it too) credits the pool its + // share of what the tick distributed. + const auto held = shadow_account( "sysio.swap"_n, SHD ); + const int64_t returned = yield_reference::owed( held.balance.get_amount(), idx_after.index, + held.index_checkpoint, held.owed_wire ); + BOOST_REQUIRE_LT( 0, returned ); + BOOST_REQUIRE_LT( returned, proceeds ); // alice holds the rest of the shadow + before = system_balance( SHEO.value ); + BOOST_REQUIRE_EQUAL( success(), accrueyield( SHEO ) ); + BOOST_REQUIRE_EQUAL( wire_pool_of(before) + returned, wire_pool_of( system_balance( SHEO.value ) ) ); + + // Pounding the tick does not sell faster: a second tick in the same block + // sees no elapsed time and does nothing, and consecutive blocks sell only + // the slivers of horizon they span. + const int64_t queued_before_pair = reservoir_of( SHEO ); + before = system_balance( SHEO.value ); + tick_twice_in_one_transaction( SHEO ); + const int64_t paired_at = last_tick_us( SHEO ); + clip = yield_reference::clip_size( queued_before_pair, paired_at - ticked_at, horizon_sec, + shadow_pool_of(before), cap_bps, clip_floor ); + BOOST_REQUIRE_EQUAL( queued_before_pair - clip, reservoir_of( SHEO ) ); + BOOST_REQUIRE_EQUAL( shadow_pool_of(before) + clip, shadow_pool_of( system_balance( SHEO.value ) ) ); + + // After a gap longer than the horizon the time share is the whole queue, + // and the depth cap is what bounds the clip. + produce_block(); + produce_block( fc::hours(2) ); + before = system_balance( SHEO.value ); + const int64_t queued_before_gap = reservoir_of( SHEO ); + const int64_t clock_before_gap = last_tick_us( SHEO ); + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + clip = yield_reference::clip_size( queued_before_gap, last_tick_us( SHEO ) - clock_before_gap, horizon_sec, + shadow_pool_of(before), cap_bps, clip_floor ); + BOOST_REQUIRE_EQUAL( shadow_pool_of(before) * cap_bps / yield_reference::BpsTotal, clip ); + BOOST_REQUIRE_LT( clip, queued_before_gap ); + BOOST_REQUIRE_EQUAL( queued_before_gap - clip, reservoir_of( SHEO ) ); + BOOST_REQUIRE_EQUAL( shadow_pool_of(before) + clip, shadow_pool_of( system_balance( SHEO.value ) ) ); + + // With the cap lifted, the same gap drains the queue in one clip, and the + // tick after that is a no-op again. + BOOST_REQUIRE_EQUAL( success(), setyield( SHEO, horizon_sec, uint32_t(yield_reference::BpsTotal), clip_floor ) ); + produce_block(); + produce_block( fc::hours(2) ); + before = system_balance( SHEO.value ); + const int64_t remaining = reservoir_of( SHEO ); + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + BOOST_REQUIRE_EQUAL( 0, reservoir_of( SHEO ) ); + BOOST_REQUIRE_EQUAL( shadow_pool_of(before) + remaining, shadow_pool_of( system_balance( SHEO.value ) ) ); + const int64_t drained_at = last_tick_us( SHEO ); + // (Settle the drain's own proceeds first, so only the tick's trade is in question.) + BOOST_REQUIRE_EQUAL( success(), accrueyield( SHEO ) ); + before = system_balance( SHEO.value ); + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + BOOST_REQUIRE( before == system_balance( SHEO.value ) ); + BOOST_REQUIRE_EQUAL( drained_at, last_tick_us( SHEO ) ); + + // Accrual comes before the trade: a distribution the pool has not yet + // collected is credited first, and the clip is priced against that pool. + fund_yield_in_one_transaction( "alice"_n, SHEO, asset( queued, SHD4 ) ); + const int64_t refunded_at = last_tick_us( SHEO ); + BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( 100'0000, WIRE9 ), SHD ) ); + const auto held_now = shadow_account( "sysio.swap"_n, SHD ); + const int64_t owed = yield_reference::owed( held_now.balance.get_amount(), shadow_index( SHD ).index, + held_now.index_checkpoint, held_now.owed_wire ); + BOOST_REQUIRE_LT( 0, owed ); + before = system_balance( SHEO.value ); + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + clip = yield_reference::clip_size( queued, last_tick_us( SHEO ) - refunded_at, horizon_sec, + shadow_pool_of(before), uint32_t(yield_reference::BpsTotal), clip_floor ); + proceeds = reference::receive( clip, shadow_pool_of(before), wire_pool_of(before) + owed, fee ); + after = system_balance( SHEO.value ); + BOOST_REQUIRE_EQUAL( shadow_pool_of(before) + clip, shadow_pool_of(after) ); + BOOST_REQUIRE_EQUAL( wire_pool_of(before) + owed - proceeds, wire_pool_of(after) ); + BOOST_REQUIRE( pending_payout( "sysio.liq"_n ).empty() ); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( yield_tick_cap_ignores_an_inflated_shadow_side, sysio_swap_yield_tester ) try { + setup_yield_pool(); + const uint32_t horizon_sec = 3600; + const uint32_t cap_bps = 1; + const int64_t clip_floor = 1000; + const auto shadow_pool_of = [&]( const vector& pool ) { return pool.at(0); }; + + BOOST_REQUIRE_EQUAL( success(), setyield( SHEO, horizon_sec, cap_bps, clip_floor ) ); + const int64_t honest_depth = shadow_pool_of( system_balance( SHEO.value ) ); + BOOST_REQUIRE_EQUAL( honest_depth, last_tick_depth( SHEO ) ); + + // Queue enough that the cap, not the time share, is what binds. + const int64_t queued = 1'0000'0000; + fund_yield_in_one_transaction( "alice"_n, SHEO, asset( queued, SHD4 ) ); + + // Double the shadow side by selling into the pool. That is the same move + // that makes a clip worth sandwiching, so a cap following the current side + // would be set by the attacker it is meant to bound. + const int64_t inflate = honest_depth; + BOOST_REQUIRE_EQUAL( success(), shadow_transfer( "alice"_n, "sysio.swap"_n, asset( inflate, SHD4 ), "" ) ); + BOOST_REQUIRE_EQUAL( success(), exchange( "alice"_n, SHEO, shd(inflate), asset(0, WIRE9) ) ); + const int64_t inflated_depth = shadow_pool_of( system_balance( SHEO.value ) ); + BOOST_REQUIRE_EQUAL( honest_depth + inflate, inflated_depth ); + BOOST_REQUIRE_EQUAL( honest_depth, last_tick_depth( SHEO ) ); // the record did not follow + + produce_block(); + produce_block( fc::hours(2) ); + const auto before = system_balance( SHEO.value ); + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + const int64_t sold = shadow_pool_of( system_balance( SHEO.value ) ) - shadow_pool_of( before ); + BOOST_REQUIRE_EQUAL( honest_depth * cap_bps / yield_reference::BpsTotal, sold ); + BOOST_REQUIRE_EQUAL( inflated_depth * cap_bps / yield_reference::BpsTotal, 2 * sold ); // what it would have been + BOOST_REQUIRE_LT( sold, queued ); // the cap bound it, not the queue + + // The tick records the pool it actually left, so the next one is bounded by + // that rather than by the stale figure. + BOOST_REQUIRE_EQUAL( shadow_pool_of( system_balance( SHEO.value ) ), last_tick_depth( SHEO ) ); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( yield_tick_never_sells_below_the_clip_floor, sysio_swap_yield_tester ) try { + setup_yield_pool(); + const uint32_t horizon_sec = 3600; + const uint32_t cap_bps = 1; // cap = 1e6 against the 1e10 shadow side + const int fee = pool_fee( SHEO ); + const auto shadow_pool_of = [&]( const vector& pool ) { return pool.at(0); }; + const auto wire_pool_of = [&]( const vector& pool ) { return pool.at(1); }; + const int64_t queued = 1000'0000; + + // A floor far above one block's share: the clip is short, so the tick sells + // nothing AND leaves its clock alone. That is the whole point -- the unsold + // time is not lost, it accumulates into the next clip. + const int64_t high_floor = 100'000; + BOOST_REQUIRE_EQUAL( success(), setyield( SHEO, horizon_sec, cap_bps, high_floor ) ); + fund_yield_in_one_transaction( "alice"_n, SHEO, asset( queued, SHD4 ) ); + const int64_t funded_at = last_tick_us( SHEO ); + auto before = system_balance( SHEO.value ); + const int64_t block_share = yield_reference::clip_size( queued, 500'000, horizon_sec, + shadow_pool_of(before), cap_bps, 0 ); + BOOST_REQUIRE_LT( 0, block_share ); // a block's share is real... + BOOST_REQUIRE_LT( block_share, high_floor ); // ...but under the floor + + // Ten blocks of cranking: every one a no-op, nothing sold, clock untouched. + for (int i = 0; i < 10; ++i) { + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + BOOST_REQUIRE_EQUAL( queued, reservoir_of( SHEO ) ); + BOOST_REQUIRE( before == system_balance( SHEO.value ) ); + BOOST_REQUIRE_EQUAL( funded_at, last_tick_us( SHEO ) ); + } + + // Once enough time has accrued the clip clears the floor and sells in one + // piece, measured from the ORIGINAL clock: the skipped blocks were banked, + // so throughput is unchanged and only the granularity is coarser. + produce_block(); + produce_block( fc::seconds(40) ); + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + const int64_t sold_at = last_tick_us( SHEO ); + const int64_t clip = yield_reference::clip_size( queued, sold_at - funded_at, horizon_sec, + shadow_pool_of(before), cap_bps, high_floor ); + BOOST_REQUIRE_LE( high_floor, clip ); + const int64_t proceeds = reference::receive( clip, shadow_pool_of(before), wire_pool_of(before), fee ); + BOOST_REQUIRE_LT( 0, proceeds ); // and it actually pays, unlike a 1-unit clip + BOOST_REQUIRE_EQUAL( queued - clip, reservoir_of( SHEO ) ); + BOOST_REQUIRE_EQUAL( shadow_pool_of(before) + clip, shadow_pool_of( system_balance( SHEO.value ) ) ); + + // A remainder smaller than the floor is not stranded: the floor gives way to + // what is queued, so it leaves as one sale once the time share reaches the + // whole queue, which takes exactly one horizon. + BOOST_REQUIRE_EQUAL( success(), setyield( SHEO, horizon_sec, uint32_t(yield_reference::BpsTotal), high_floor ) ); + produce_block(); + produce_block( fc::hours(2) ); + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); // drain whatever is left + BOOST_REQUIRE_EQUAL( 0, reservoir_of( SHEO ) ); + BOOST_REQUIRE_EQUAL( success(), accrueyield( SHEO ) ); + + const int64_t dust = 500; // well under high_floor + fund_yield_in_one_transaction( "alice"_n, SHEO, asset( dust, SHD4 ) ); + const int64_t dust_at = last_tick_us( SHEO ); + before = system_balance( SHEO.value ); + produce_block(); + produce_block( fc::minutes(30) ); // half a horizon: not yet + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + BOOST_REQUIRE_EQUAL( dust, reservoir_of( SHEO ) ); + BOOST_REQUIRE_EQUAL( dust_at, last_tick_us( SHEO ) ); + produce_block(); + produce_block( fc::minutes(31) ); // past one horizon: clears + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + BOOST_REQUIRE_EQUAL( 0, reservoir_of( SHEO ) ); + BOOST_REQUIRE_EQUAL( shadow_pool_of(before) + dust, shadow_pool_of( system_balance( SHEO.value ) ) ); + + // A depth cap below the floor is the one combination with no way out: every + // clip is capped under the floor, so the pair stops selling however long it + // waits, until setyield widens one of them. + BOOST_REQUIRE_EQUAL( success(), accrueyield( SHEO ) ); + BOOST_REQUIRE_EQUAL( success(), setyield( SHEO, horizon_sec, cap_bps, 2'000'000 ) ); // cap is 1e6 + fund_yield_in_one_transaction( "alice"_n, SHEO, asset( queued, SHD4 ) ); + before = system_balance( SHEO.value ); + produce_block(); + produce_block( fc::hours(6) ); + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + BOOST_REQUIRE_EQUAL( queued, reservoir_of( SHEO ) ); + BOOST_REQUIRE( before == system_balance( SHEO.value ) ); + // Lowering the floor under the cap starts it again. + BOOST_REQUIRE_EQUAL( success(), setyield( SHEO, horizon_sec, cap_bps, 1000 ) ); + produce_block(); + produce_block( fc::hours(6) ); + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + BOOST_REQUIRE_LT( reservoir_of( SHEO ), queued ); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( yield_tick_never_sells_below_a_fee_bearing_output, sysio_swap_yield_tester ) try { + setup_yield_pool(); + const uint32_t horizon_sec = 3600; + const uint32_t cap_bps = uint32_t(yield_reference::BpsTotal); // the cap is not what binds here + const int64_t clip_floor = 1000; + const auto shadow_pool_of = [&]( const vector& pool ) { return pool.at(0); }; + const auto wire_pool_of = [&]( const vector& pool ) { return pool.at(1); }; + + // A 0.01% fee puts the fee-bearing output at 10000, an order of magnitude + // above the clip floor, which is what separates the two gates: the floor is + // a granularity in SHADOW units, and what the pair's rate actually depends + // on is the OUTPUT. A floor sized for one fee is wrong for another, and + // changefee can move the fee under a floor that setyield already set. + const int fee = 1; + BOOST_REQUIRE_EQUAL( success(), changefee( SHEO, fee ) ); + BOOST_REQUIRE_EQUAL( fee, pool_fee( SHEO ) ); + const int64_t fee_bearing = reference::min_fee_bearing_output( fee ); + BOOST_REQUIRE_EQUAL( 10000, fee_bearing ); + BOOST_REQUIRE_LT( clip_floor, fee_bearing ); + + BOOST_REQUIRE_EQUAL( success(), setyield( SHEO, horizon_sec, cap_bps, clip_floor ) ); + const int64_t queued = 1000'0000; + fund_yield_in_one_transaction( "alice"_n, SHEO, asset( queued, SHD4 ) ); + const int64_t funded_at = last_tick_us( SHEO ); + const auto before = system_balance( SHEO.value ); + + // One block on, the clip clears the CLIP floor, so that gate alone would + // have sold it -- but its output does not reach a whole unit of fee, so the + // clip would pay the one-unit minimum instead of the pair's rate, out of the + // holders' distribution. The tick declines, and leaves the clock alone. + const int64_t short_clip = yield_reference::clip_size( queued, 500'000, horizon_sec, + shadow_pool_of(before), cap_bps, clip_floor ); + BOOST_REQUIRE_LT( 0, short_clip ); // clears the clip floor + const int64_t short_out = reference::receive( short_clip, shadow_pool_of(before), wire_pool_of(before), fee ); + BOOST_REQUIRE_LT( short_out, fee_bearing ); // but not the output floor + BOOST_REQUIRE_EQUAL( 0, reference::proportional_fee_on( short_out, fee ) ); // MinSwapFee is what it would pay + for (int i = 0; i < 5; ++i) { + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + BOOST_REQUIRE_EQUAL( queued, reservoir_of( SHEO ) ); + BOOST_REQUIRE( before == system_balance( SHEO.value ) ); + BOOST_REQUIRE_EQUAL( funded_at, last_tick_us( SHEO ) ); + } + + // The banked time grows the clip until its output does clear, and then it + // sells in one piece measured from the original clock: declining costs + // throughput nothing, exactly as the clip floor's own skips do. + produce_block(); + produce_block( fc::seconds(10) ); + BOOST_REQUIRE_EQUAL( success(), tickyield( SHEO ) ); + const int64_t clip = yield_reference::clip_size( queued, last_tick_us( SHEO ) - funded_at, horizon_sec, + shadow_pool_of(before), cap_bps, clip_floor ); + const int64_t proceeds = reference::receive( clip, shadow_pool_of(before), wire_pool_of(before), fee ); + BOOST_REQUIRE_LE( fee_bearing, proceeds ); + BOOST_REQUIRE_LE( 1, reference::proportional_fee_on( proceeds, fee ) ); // the pair's own rate, not the minimum + BOOST_REQUIRE_EQUAL( queued - clip, reservoir_of( SHEO ) ); + BOOST_REQUIRE_EQUAL( shadow_pool_of(before) + clip, shadow_pool_of( system_balance( SHEO.value ) ) ); + BOOST_REQUIRE_EQUAL( wire_pool_of(before) - proceeds, wire_pool_of( system_balance( SHEO.value ) ) ); + BOOST_REQUIRE( pending_payout( "sysio.liq"_n ).empty() ); + + // (The remainder drain is exempt from this gate, so dust is not stranded by + // it either. yield_tick_never_sells_below_the_clip_floor pins that: at its + // 0.1% fee the 500-unit remainder it drains is itself under the 1000-unit + // fee-bearing output, and it still leaves.) +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( fee_authority_configuration, sysio_swap_tester ) try { + create_tokens_and_issue(); + // A third token, for a third pair against the system token. + const symbol CVO4 = symbol::from_string("4,CVO"); + BOOST_REQUIRE_EQUAL( success(), create( "sysio.token"_n, "alice"_n, asset::from_string("1000.0000 CVO") ) ); + BOOST_REQUIRE_EQUAL( success(), issue( "sysio.token"_n, "alice"_n, "alice"_n, asset::from_string("1000.0000 CVO"), "" ) ); + abi_ser.set_abi( swap_abi_def(), abi_serializer::create_yield_function(abi_serializer_max_time) ); + many_openext(); + many_transfer(); + BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, extended_symbol{ CVO4, "sysio.token"_n } ) ); + BOOST_REQUIRE_EQUAL( success(), seed_transfer( "sysio.token"_n, "alice"_n, asset::from_string("1000.0000 CVO"), "" ) ); + + // EVO adopts the configured authority (sysio); ETUSD names alice as its own. + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.0000 EOS")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("1.00 TUSD")), extend(asset::from_string("1.0000 EOS")), 10, "alice"_n) ); + + // Each pair answers only to its own authority. + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 30) ); + BOOST_REQUIRE_EQUAL( error("missing authority of sysio"), changefee(EVO, 40, "alice"_n) ); + BOOST_REQUIRE_EQUAL( 30, pool_fee(EVO) ); + BOOST_REQUIRE_EQUAL( success(), changefee(ETUSD, 30, "alice"_n) ); + BOOST_REQUIRE_EQUAL( error("missing authority of alice"), changefee(ETUSD, 40) ); + BOOST_REQUIRE_EQUAL( 30, pool_fee(ETUSD) ); + + // Reconfiguring binds pairs created afterwards; existing pairs keep theirs. + BOOST_REQUIRE_EQUAL( success(), setconfig( "bob"_n ) ); + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 35) ); + BOOST_REQUIRE_EQUAL( error("missing authority of sysio"), changefee(EVO, 45, "bob"_n) ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, symbol::from_string("4,BVO"), + extend(asset::from_string("1.0000 CVO")), extend(asset::from_string("1.0000 EOS")), 0, name{}) ); + const auto BVO = symbol::from_string("4,BVO").to_symbol_code(); + BOOST_REQUIRE_EQUAL( 0, pool_fee(BVO) ); + BOOST_REQUIRE_EQUAL( success(), changefee(BVO, 9999, "bob"_n) ); + BOOST_REQUIRE_EQUAL( error("missing authority of bob"), changefee(BVO, 1) ); + BOOST_REQUIRE_EQUAL( 9999, pool_fee(BVO) ); +} FC_LOG_AND_RETHROW() + + +BOOST_FIXTURE_TEST_CASE( indextable, sysio_swap_tester ) try { + + create_tokens_and_issue(); + transfer( "anothertoken"_n, "bob"_n, "alice"_n, asset::from_string("500000000.0000 VOICE"), ""); + const auto* accnt2 = control->find_account_metadata( "sysio.swap"_n ); + abi_def abi_evo; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2->abi, abi_evo), true); + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + many_openext(); + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("10000000.0000 EOS"), ""); + seed_transfer( "anothertoken"_n, "alice"_n, asset::from_string("200000000.0000 VOICE"), ""); + + BOOST_REQUIRE_EQUAL(success(), inittoken( "alice"_n, EVO4, + extend(asset::from_string("100000000.0000 VOICE")), + extend(asset::from_string("1000000.0000 EOS")), 10, name{}) ); + + // One pool per token: a second VOICE pair is refused, and the other way + // round is not a pair at all. + BOOST_REQUIRE_EQUAL(wasm_assert_msg("the pool is already indexed"), + inittoken( "alice"_n, EOS4, + extend(asset::from_string("1.0000 VOICE")), + extend(asset::from_string("1.0000 EOS")), 10, name{}) ); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("the second leg must be the system token"), + inittoken( "alice"_n, EOS4, + extend(asset::from_string("1.0000 EOS")), + extend(asset::from_string("1.0000 VOICE")), 10, name{}) ); + + // The pair's uniqueness row is keyed by its legs in canonical order, the lower + // (contract, symbol) first: anothertoken/VOICE ahead of sysio.token/EOS. The + // same two legs in the other order are the same key, so no second row exists. + BOOST_REQUIRE( "anothertoken"_n < "sysio.token"_n ); + const auto data = get_kv_row( "sysio.swap"_n, "evoindex"_n, + { "anothertoken"_n.to_uint64_t(), VOICE4.value(), "sysio.token"_n.to_uint64_t(), EOS4.value() } ); + BOOST_REQUIRE( !data.empty() ); + const auto table = abi_ser.binary_to_variant( "pair_index", data, + abi_serializer::create_yield_function(abi_serializer_max_time) ); + BOOST_REQUIRE_EQUAL(table["evo_symbol"], "4,EVO"); + BOOST_REQUIRE( get_kv_row( "sysio.swap"_n, "evoindex"_n, + { "sysio.token"_n.to_uint64_t(), EOS4.value(), "anothertoken"_n.to_uint64_t(), VOICE4.value() } ).empty() ); + + // A first leg with a different precision is a different token: it has no + // deposit row. A second leg that is not exactly the system token, by + // precision or by contract, is not a pair. + BOOST_REQUIRE_EQUAL(wasm_assert_msg("extended_symbol not registered for this user,\ + please run openext action or write exchange details in the memo of your transfer"), + inittoken( "alice"_n, EOS4, + extend(asset::from_string("1.00000 VOICE")), + extend(asset::from_string("1.0000 EOS")), 10, name{}) ); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("the second leg must be the system token"), + inittoken( "alice"_n, EOS4, + extend(asset::from_string("1.0000 VOICE")), + extend(asset::from_string("1.00000 EOS")), 10, name{}) ); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("the second leg must be the system token"), + inittoken( "alice"_n, EOS4, extend(asset::from_string("1.0000 VOICE")), + extended_asset{asset::from_string("1.0000 EOS"), "anothertoken"_n}, + 10, name{}) ); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Coverage added with the WIRE port: fee bounds, an exact rounding table, and +// the pool invariants under a long randomized operation sequence. +// --------------------------------------------------------------------------- + +// A spread of fee rates across the accepted range; the contract itself must +// accept the whole of [0, MAX_FEE] and reject anything outside it. +static const std::vector FeeVector{1, 2, 3, 5, 7, 10, 15, 20, 30, 50, 75, 100, 150, 200, 300}; + +BOOST_FIXTURE_TEST_CASE( changefee_bounds, sysio_swap_tester ) try { + setup_pools(); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee out of range"), changefee(EVO, -1) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee out of range"), changefee(EVO, 10000) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee out of range"), changefee(EVO, std::numeric_limits::max()) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee out of range"), changefee(EVO, std::numeric_limits::min()) ); + BOOST_REQUIRE_EQUAL( 10, pool_fee(EVO) ); + + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 0) ); + BOOST_REQUIRE_EQUAL( 0, pool_fee(EVO) ); + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 9999) ); + BOOST_REQUIRE_EQUAL( 9999, pool_fee(EVO) ); + for (int fee : FeeVector) { + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, fee) ); + BOOST_REQUIRE_EQUAL( fee, pool_fee(EVO) ); + } + + // At the maximum fee a swap still settles, and its output is exactly the + // spec quote -- the bound exists so this never overflows int64. + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 9999) ); + auto before = system_balance(EVO.value); + const int64_t amount_in = 1'000'000'000; + const int64_t expected = reference::receive(amount_in, before[1], before[0], 9999); // EOS in, VOICE out + BOOST_REQUIRE_EQUAL( success(), + exchange( "alice"_n, EVO, extend(asset(amount_in, EOS4)), asset(expected, VOICE4) ) ); + auto after = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( before[0] - expected, after[0] ); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( compute_rounding_table, sysio_swap_tester ) try { + setup_pools(); + static const std::vector amounts{1, 2, 3, 10, 100, 12345, 1'000'000}; + + // Swaps in both directions, at every fee. pool1 is VOICE, pool2 is EOS. + for (int fee : FeeVector) { + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, fee) ); + for (int64_t amount : amounts) { + // EOS -> VOICE: one unit above the spec quote is refused, the quote itself lands + auto before = system_balance(EVO.value); + int64_t out = reference::receive(amount, before[1], before[0], fee); + int64_t alice_eos = balance("alice"_n, EOS4), alice_voice = balance("alice"_n, VOICE4); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + exchange( "alice"_n, EVO, extend(asset(amount, EOS4)), asset(out + 1, VOICE4) ) ); + BOOST_REQUIRE_EQUAL( success(), + exchange( "alice"_n, EVO, extend(asset(amount, EOS4)), asset(out, VOICE4) ) ); + auto after = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( before[1] + amount, after[1] ); + BOOST_REQUIRE_EQUAL( before[0] - out, after[0] ); + BOOST_REQUIRE_EQUAL( alice_eos - amount, balance("alice"_n, EOS4) ); + BOOST_REQUIRE_EQUAL( alice_voice + out, balance("alice"_n, VOICE4) ); + + // VOICE -> EOS + before = after; + out = reference::receive(amount, before[0], before[1], fee); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + exchange( "alice"_n, EVO, extend(asset(amount, VOICE4)), asset(out + 1, EOS4) ) ); + BOOST_REQUIRE_EQUAL( success(), + exchange( "alice"_n, EVO, extend(asset(amount, VOICE4)), asset(out, EOS4) ) ); + after = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( before[0] + amount, after[0] ); + BOOST_REQUIRE_EQUAL( before[1] - out, after[1] ); + } + } + + // Liquidity: adding charges ceil + the fixed 0.01% fee per leg, removing returns floor. + for (int64_t shares : {int64_t(1), int64_t(2), int64_t(3), int64_t(10), int64_t(12345)}) { + auto before = system_balance(EVO.value); + const int64_t pay1 = reference::add_leg(shares, before[0], before[2]); + const int64_t pay2 = reference::add_leg(shares, before[1], before[2]); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + addliquidity( "alice"_n, asset(shares, EVO4), asset(pay1 - 1, VOICE4), asset(pay2, EOS4) ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + addliquidity( "alice"_n, asset(shares, EVO4), asset(pay1, VOICE4), asset(pay2 - 1, EOS4) ) ); + BOOST_REQUIRE_EQUAL( success(), + addliquidity( "alice"_n, asset(shares, EVO4), asset(pay1, VOICE4), asset(pay2, EOS4) ) ); + auto after = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( before[0] + pay1, after[0] ); + BOOST_REQUIRE_EQUAL( before[1] + pay2, after[1] ); + BOOST_REQUIRE_EQUAL( before[2] + shares, after[2] ); + + before = after; + const int64_t get1 = reference::remove_leg(shares, before[0], before[2]); + const int64_t get2 = reference::remove_leg(shares, before[1], before[2]); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + remliquidity( "alice"_n, asset(shares, EVO4), asset(get1 + 1, VOICE4), asset(get2, EOS4) ) ); + BOOST_REQUIRE_EQUAL( success(), + remliquidity( "alice"_n, asset(shares, EVO4), asset(get1, VOICE4), asset(get2, EOS4) ) ); + after = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( before[0] - get1, after[0] ); + BOOST_REQUIRE_EQUAL( before[1] - get2, after[1] ); + BOOST_REQUIRE_EQUAL( before[2] - shares, after[2] ); + } +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( invariants_under_random_sequences, sysio_swap_tester ) try { + setup_pools(); + + struct pool_spec { + symbol_code code; // LP token + symbol lp; + symbol leg1; // pool1 + symbol leg2; // pool2 + }; + const std::vector pools{ + { EVO, EVO4, VOICE4, EOS4 }, + { ETUSD, ETUSD3, TUSD2, EOS4 }, + }; + const std::vector users{ "alice"_n, "bob"_n }; + // Failures the sequence is allowed to produce: every one is a guard the + // contract is SUPPOSED to raise, and each leaves state untouched. + const std::vector allowed{ + wasm_assert_msg("insufficient funds"), + wasm_assert_msg("available is less than expected"), + wasm_assert_msg("invalid parameters"), + wasm_assert_msg("computation overflow"), + wasm_assert_msg("computation underflow"), + wasm_assert_msg("the pool cannot be left empty"), + wasm_assert_msg("overdrawn balance"), + wasm_assert_msg("no balance object found"), + }; + + std::mt19937_64 rng(0x57495245'53574150ULL); // fixed seed: the sequence is reproducible + // Log-uniform draw in [1, cap] so dust and large trades are both frequent. + auto draw = [&](int64_t cap) -> int64_t { + if (cap < 1) return 1; + int digits = 0; + for (int64_t c = cap; c > 0; c /= 10) ++digits; + int64_t magnitude = 1; + for (int e = rng() % digits; e > 0; --e) magnitude *= 10; + return std::min(cap, magnitude * (1 + rng() % 9)); + }; + + enum op_kind { op_swap_forward, op_swap_backward, op_negative_in, op_add, op_remove, op_fee_change, op_count }; + std::vector successes(op_count, 0); + const int steps = 400; + + for (int step = 0; step < steps; ++step) { + const auto& pool = pools[rng() % pools.size()]; + const name user = users[rng() % users.size()]; + const auto old_total = total(); + const auto old_vec = system_balance(pool.code.value); + const int64_t user_leg1 = balance(user, pool.leg1); + const int64_t user_leg2 = balance(user, pool.leg2); + const int op = rng() % op_count; + + action_result r; + if (op == op_swap_forward) { + r = exchange( user, pool.code, extend(asset(draw(user_leg1), pool.leg1)), asset(0, pool.leg2) ); + } else if (op == op_swap_backward) { + r = exchange( user, pool.code, extend(asset(draw(user_leg2), pool.leg2)), asset(0, pool.leg1) ); + } else if (op == op_negative_in) { + // a negative input (the retired exact-output mode) is refused outright, + // whatever the sign of min_expected, and must leave state untouched + const int64_t w = draw(old_vec[0] / 2); + const int64_t limit = (rng() % 2) ? -user_leg2 : user_leg2; + r = exchange( user, pool.code, extend(asset(-w, pool.leg1)), asset(limit, pool.leg2) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("ext_asset_in must be positive"), r ); + } else if (op == op_add) { + r = addliquidity( user, asset(draw(old_vec[2] / 10), pool.lp), + asset(user_leg1, pool.leg1), asset(user_leg2, pool.leg2) ); + } else if (op == op_remove) { + const int64_t lp = lp_balance(user, pool.code); + r = remliquidity( user, asset(draw(lp), pool.lp), asset(0, pool.leg1), asset(0, pool.leg2) ); + } else { + r = changefee( pool.code, FeeVector[rng() % FeeVector.size()] ); + } + + if (r == success()) { + ++successes[op]; + } else if (op == op_negative_in) { + ++successes[op]; // the rejection IS the expected outcome, asserted above + } else { + BOOST_REQUIRE_MESSAGE( std::find(allowed.begin(), allowed.end(), r) != allowed.end(), + "step " << step << " op " << op << ": unexpected failure: " << r ); + } + // Conservation: nothing enters or leaves the contract in any of these ops. + BOOST_REQUIRE_MESSAGE( old_total == total(), "step " << step << " op " << op << ": totals changed" ); + // Share value: P1*P2/S^2 never decreases through a swap, add, or remove. + BOOST_REQUIRE_MESSAGE( is_increasing(old_vec, system_balance(pool.code.value)), + "step " << step << " op " << op << ": pool value per share decreased" ); + } + + // The run must actually have exercised every path, not merely survived it. + BOOST_REQUIRE_GE( successes[op_swap_forward] + successes[op_swap_backward], 60 ); + BOOST_REQUIRE_GE( successes[op_negative_in], 40 ); + BOOST_REQUIRE_GE( successes[op_add], 15 ); + BOOST_REQUIRE_GE( successes[op_remove], 10 ); + BOOST_REQUIRE_GE( successes[op_fee_change], 20 ); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Characterization for the amm_math substitution. Every expectation below is +// written from the spec (`reference`) or from the host-side amm_math model +// (`model`), never from the contract's own arithmetic, so the cases survive +// a change of implementation and fail only on a change of behaviour. +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE( swap_matches_amm_math_model, sysio_swap_tester ) try { + setup_pools(); + struct leg { symbol_code pair; symbol in; symbol out; int in_leg; int out_leg; }; + const std::vector legs{ // EOS, the system token, is pool2 of both pairs + { EVO, EOS4, VOICE4, 1, 0 }, { EVO, VOICE4, EOS4, 0, 1 }, + { ETUSD, EOS4, TUSD2, 1, 0 }, { ETUSD, TUSD2, EOS4, 0, 1 }, + }; + static const std::vector amounts{1, 7, 999, 1'000'000, 1'000'000'000, 10'000'000'000'000}; + for (int fee : {0, 1, 10, 100, 9999}) { + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, fee) ); + BOOST_REQUIRE_EQUAL( success(), changefee(ETUSD, fee) ); + for (const auto& l : legs) { + for (int64_t amount : amounts) { + const auto before = system_balance(l.pair.value); + const int64_t expected = model::receive(amount, before[l.in_leg], before[l.out_leg], fee); + BOOST_REQUIRE_EQUAL( expected, reference::receive(amount, before[l.in_leg], before[l.out_leg], fee) ); + BOOST_REQUIRE_EQUAL( expected, settle_swap("alice"_n, l.pair, asset(amount, l.in), l.out, l.out_leg) ); + } + } + } +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( fee_zero_is_the_bare_constant_product_curve, sysio_swap_tester ) try { + setup_pools(); + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 0) ); + using wide = boost::multiprecision::int256_t; + for (int64_t amount : {int64_t(1), int64_t(3), int64_t(12345), int64_t(1'000'000'000), int64_t(123'456'789'012'345)}) { + for (int in_leg = 0; in_leg < 2; ++in_leg) { + const int out_leg = 1 - in_leg; + const symbol in_symbol = in_leg == 0 ? VOICE4 : EOS4; + const symbol out_symbol = in_leg == 0 ? EOS4 : VOICE4; + const auto before = system_balance(EVO.value); + const wide k = wide(before[0]) * wide(before[1]); + const int64_t out = settle_swap("alice"_n, EVO, asset(amount, in_symbol), out_symbol, out_leg); + const auto after = system_balance(EVO.value); + // With no fee the output IS the bare kernel... + BOOST_REQUIRE_EQUAL( out, model::gross(amount, before[in_leg], before[out_leg]) ); + // ...which is the largest integer output that keeps x*y from falling: + // one unit more would have broken the invariant. + BOOST_REQUIRE( wide(after[0]) * wide(after[1]) >= k ); + BOOST_REQUIRE( wide(after[in_leg]) * wide(after[out_leg] - 1) < k ); + } + } +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( round_trip_never_profits, sysio_swap_tester ) try { + setup_pools(); + using wide = boost::multiprecision::int256_t; + for (int fee : {0, 10, 100, 9999}) { + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, fee) ); + for (int64_t amount : {int64_t(1), int64_t(10), int64_t(12345), int64_t(1'000'000), int64_t(50'000'000'000'000)}) { + const auto start = system_balance(EVO.value); + const auto totals = total(); + const int64_t voice = settle_swap("alice"_n, EVO, asset(amount, EOS4), VOICE4, 0); + const int64_t eos = voice > 0 ? settle_swap("alice"_n, EVO, asset(voice, VOICE4), EOS4, 1) : 0; + const auto end = system_balance(EVO.value); + // Out and back never returns more than went in; with a fee and a + // trade big enough for the fee to bite, strictly less. + BOOST_REQUIRE_LE( eos, amount ); + if (fee > 0 && amount >= 1'000'000) BOOST_REQUIRE_LT( eos, amount ); + BOOST_REQUIRE( wide(end[0]) * wide(end[1]) >= wide(start[0]) * wide(start[1]) ); + BOOST_REQUIRE_EQUAL( end[2], start[2] ); + BOOST_REQUIRE( totals == total() ); + } + } +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( precision_extremes, sysio_swap_tester ) try { + // Every unit of every token sits in the contract under alice, so pools and + // trades can be pushed to the int64 asset ceiling with no external limit. + create_tokens_and_issue(); + abi_ser.set_abi( swap_abi_def(), abi_serializer::create_yield_function(abi_serializer_max_time) ); + many_openext(); + const asset all_eos = asset::from_string("461168601842738.7903 EOS"); + const asset all_voice = asset::from_string("461168601842738.7903 VOICE"); + const asset all_tusd = asset::from_string("46116860184273879.03 TUSD"); + BOOST_REQUIRE_EQUAL( asset::max_amount, all_eos.get_amount() ); + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, all_eos, "") ); + BOOST_REQUIRE_EQUAL( success(), seed_transfer( "anothertoken"_n, "bob"_n, all_voice, "deposit to: alice") ); + BOOST_REQUIRE_EQUAL( success(), seed_transfer( "sysio.token"_n, "alice"_n, all_tusd, "") ); + + // A dust pool: the largest side inittoken accepts against one unit of EOS. + // (pool1 is the token, pool2 is EOS, the system token.) + const int64_t init_max = 1'000'000'000'000'000 - 1; + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extend(asset(init_max, VOICE4)), extend(asset(1, EOS4)), 10, name{}) ); + // A whale pool: both sides at the inittoken ceiling, then grown 2500x by + // adding liquidity, which has no ceiling of its own. + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, + extend(asset(init_max, TUSD2)), extend(asset(init_max, EOS4)), 10, name{}) ); + { + const auto before = system_balance(ETUSD.value); + const int64_t shares = 2500 * before[2]; + const int64_t pay1 = reference::add_leg(shares, before[0], before[2]); + const int64_t pay2 = reference::add_leg(shares, before[1], before[2]); + BOOST_REQUIRE_EQUAL( success(), + addliquidity( "alice"_n, asset(shares, ETUSD3), asset(pay1, TUSD2), asset(pay2, EOS4) ) ); + const auto after = system_balance(ETUSD.value); + BOOST_REQUIRE_EQUAL( before[0] + pay1, after[0] ); + BOOST_REQUIRE_EQUAL( before[1] + pay2, after[1] ); + BOOST_REQUIRE_EQUAL( before[2] + shares, after[2] ); + } + + // Dust pool: one unit in each direction. + { + auto before = system_balance(EVO.value); + const int64_t out = reference::receive(1, before[1], before[0], 10); // EOS in, VOICE out + BOOST_REQUIRE_EQUAL( out, model::receive(1, before[1], before[0], 10) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + exchange( "alice"_n, EVO, extend(asset(1, EOS4)), asset(out + 1, VOICE4) ) ); + BOOST_REQUIRE_EQUAL( out, settle_swap("alice"_n, EVO, asset(1, EOS4), VOICE4, 0) ); + // One VOICE unit back into the now lopsided pool buys nothing; the pool keeps it. + before = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( 0, reference::receive(1, before[0], before[1], 10) ); + BOOST_REQUIRE_EQUAL( 0, settle_swap("alice"_n, EVO, asset(1, VOICE4), EOS4, 1) ); + const auto after = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( before[1], after[1] ); + BOOST_REQUIRE_EQUAL( before[0] + 1, after[0] ); + } + // Dust pool: alice's entire VOICE balance in one trade. The pool side lands + // exactly on the int64 ceiling and the gross quote is a single unit, which + // the one-unit minimum fee keeps in the pool: no fee-bearing trade is free. + { + const auto before = system_balance(EVO.value); + const int64_t amount = balance("alice"_n, VOICE4); + BOOST_REQUIRE_EQUAL( asset::max_amount, before[0] + amount ); + BOOST_REQUIRE_EQUAL( 1, model::gross(amount, before[0], before[1]) ); + const int64_t out = reference::receive(amount, before[0], before[1], 10); + BOOST_REQUIRE_EQUAL( 0, out ); + BOOST_REQUIRE_EQUAL( out, settle_swap("alice"_n, EVO, asset(amount, VOICE4), EOS4, 1) ); + const auto after = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( asset::max_amount, after[0] ); + BOOST_REQUIRE_EQUAL( 0, balance("alice"_n, VOICE4) ); + } + + // Whale pool: the smallest trade, then the largest alice can make. + { + auto before = system_balance(ETUSD.value); + BOOST_REQUIRE_EQUAL( 0, reference::receive(1, before[1], before[0], 10) ); // EOS in, TUSD out + BOOST_REQUIRE_EQUAL( 0, settle_swap("alice"_n, ETUSD, asset(1, EOS4), TUSD2, 0) ); + auto after = system_balance(ETUSD.value); + BOOST_REQUIRE_EQUAL( before[1] + 1, after[1] ); + BOOST_REQUIRE_EQUAL( before[0], after[0] ); + + // Every unit of EOS alice still holds: pool_in + amount is the whole + // supply less the two units parked in the dust pool. + before = after; + const int64_t amount = balance("alice"_n, EOS4); + BOOST_REQUIRE_EQUAL( 2, system_balance(EVO.value)[1] ); + BOOST_REQUIRE_EQUAL( asset::max_amount - 2, before[1] + amount ); + const int64_t out = reference::receive(amount, before[1], before[0], 10); + BOOST_REQUIRE_EQUAL( out, model::receive(amount, before[1], before[0], 10) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + exchange( "alice"_n, ETUSD, extend(asset(amount, EOS4)), asset(out + 1, TUSD2) ) ); + BOOST_REQUIRE_EQUAL( out, settle_swap("alice"_n, ETUSD, asset(amount, EOS4), TUSD2, 0) ); + after = system_balance(ETUSD.value); + BOOST_REQUIRE_EQUAL( asset::max_amount - 2, after[1] ); + BOOST_REQUIRE_EQUAL( 0, balance("alice"_n, EOS4) ); + + // And every unit of TUSD the other way. + before = after; + const int64_t tusd = balance("alice"_n, TUSD2); + const int64_t out2 = reference::receive(tusd, before[0], before[1], 10); + BOOST_REQUIRE_EQUAL( out2, model::receive(tusd, before[0], before[1], 10) ); + BOOST_REQUIRE_EQUAL( out2, settle_swap("alice"_n, ETUSD, asset(tusd, TUSD2), EOS4, 1) ); + BOOST_REQUIRE_EQUAL( 0, balance("alice"_n, TUSD2) ); + } +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( minimum_fee_closes_the_free_window, sysio_swap_tester ) try { + // A small, balanced pool so single-unit inputs produce single-unit quotes: + // 1000.0000 EOS against 1000.0000 VOICE, 10 bps. + create_tokens_and_issue(); + abi_ser.set_abi( swap_abi_def(), abi_serializer::create_yield_function(abi_serializer_max_time) ); + many_openext(); + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("2000.0000 EOS"), "") ); + BOOST_REQUIRE_EQUAL( success(), seed_transfer( "anothertoken"_n, "bob"_n, asset::from_string("2000.0000 VOICE"), "deposit to: alice") ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1000.0000 VOICE")), extend(asset::from_string("1000.0000 EOS")), 10, name{}) ); + // Every trade below sells EOS (pool2) for VOICE (pool1). + + // Below one unit of quote there is nothing to charge: the input is kept, nothing is paid. + BOOST_REQUIRE_EQUAL( 0, settle_swap("alice"_n, EVO, asset(1, EOS4), VOICE4, 0) ); + // A 999-unit quote would round to a zero fee; the minimum makes it one unit. + { + const auto before = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( 999, model::gross(1000, before[1], before[0]) ); + BOOST_REQUIRE_EQUAL( 998, reference::receive(1000, before[1], before[0], 10) ); + BOOST_REQUIRE_EQUAL( 998, settle_swap("alice"_n, EVO, asset(1000, EOS4), VOICE4, 0) ); + } + // Once the floored fee reaches a unit on its own the minimum is inert. + { + const auto before = system_balance(EVO.value); + const int64_t g = model::gross(20000, before[1], before[0]); + BOOST_REQUIRE( g * 10 / 10000 >= 1 ); + BOOST_REQUIRE_EQUAL( g - g * 10 / 10000, settle_swap("alice"_n, EVO, asset(20000, EOS4), VOICE4, 0) ); + } + // At a zero fee rate there is no minimum: the quote is the bare curve. + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 0) ); + { + const auto before = system_balance(EVO.value); + const int64_t g = model::gross(1000, before[1], before[0]); + BOOST_REQUIRE_EQUAL( g, settle_swap("alice"_n, EVO, asset(1000, EOS4), VOICE4, 0) ); + } + // And back at a nonzero rate, x*y grows on EVERY fee-bearing trade, including + // the smallest quotes -- there is no fee-free window to hunt for. + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 10) ); + using wide = boost::multiprecision::int256_t; + for (int64_t amount : {int64_t(1000), int64_t(1001), int64_t(1500), int64_t(2), int64_t(3)}) { + const auto before = system_balance(EVO.value); + const int64_t g = model::gross(amount, before[1], before[0]); + const int64_t out = settle_swap("alice"_n, EVO, asset(amount, EOS4), VOICE4, 0); + const auto after = system_balance(EVO.value); + if (g > 0) { + BOOST_REQUIRE_LT( out, g ); + BOOST_REQUIRE( wide(after[0]) * wide(after[1]) > wide(before[0]) * wide(before[1]) ); + } + } +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( guard_semantics_on_the_memo_path, sysio_swap_tester ) try { + // badtoken forwards any transfer notification untouched, including a zero + // amount a real token contract refuses, so with it hosting one leg of a + // pair the swap path can be driven with inputs sysio.token cannot produce. + create_tokens_and_issue(); + abi_ser.set_abi( swap_abi_def(), abi_serializer::create_yield_function(abi_serializer_max_time) ); + // badtoken's EOS is a different token from the system token's EOS: it is the + // pair's first leg, seeded with the contract's authority. + BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, extended_symbol{EOS4, "badtoken"_n}) ); + BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, SYSTEM_TOKEN) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("token is not a leg of any pair"), + transfer( "badtoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("2000.0000 EOS"), "") ); + BOOST_REQUIRE_EQUAL( success(), seed_transfer( "badtoken"_n, "alice"_n, asset::from_string("2000.0000 EOS"), "") ); + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("2000.0000 EOS"), "") ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extended_asset{asset::from_string("1000.0000 EOS"), "badtoken"_n}, + extend(asset::from_string("1000.0000 EOS")), 10, name{}) ); + + const auto before = system_balance(EVO.value); + // A zero input through the memo path is refused before the pools move... + BOOST_REQUIRE_EQUAL( wasm_assert_msg("invalid parameters"), + transfer( "badtoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("0.0000 EOS"), "exchange: EVO, 0.0000 EOS") ); + // ...and a negative one is refused one layer earlier. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("quantity must be positive"), + transfer( "badtoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("-1.0000 EOS"), "exchange: EVO, 0.0000 EOS") ); + BOOST_REQUIRE( before == system_balance(EVO.value) ); + + // The slippage floor is inclusive: the quote itself settles, one unit more is refused. + const int64_t out = reference::receive(10000, before[0], before[1], 10); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + transfer( "badtoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("1.0000 EOS"), + "exchange: EVO, " + asset(out + 1, EOS4).to_string() ) ); + BOOST_REQUIRE_EQUAL( success(), + transfer( "badtoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("1.0000 EOS"), + "exchange: EVO, " + asset(out, EOS4).to_string() ) ); + const auto after = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( before[0] + 10000, after[0] ); + BOOST_REQUIRE_EQUAL( before[1] - out, after[1] ); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Time-weighted average price: the cumulative-price accumulators. +// --------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_CASE( price_accumulators_follow_the_pools, sysio_swap_tester ) try { + setup_pools(); + // The tester replays a still-pending block's transactions at the skipped + // time when asked to skip ahead, so the pool creation is sealed at its own + // time before the clock moves. (The fixture's push_action seals a block per + // action, so the ops below need no sealing of their own.) + produce_block(); + using boost::multiprecision::uint256_t; + namespace twap = sysio::opp::twap; + + auto row = price_accumulator(EVO); + BOOST_REQUIRE( row.price1 == 0 && row.price2 == 0 ); + const auto snapshot = row; // the reader's t0 + uint256_t expect1 = 0, expect2 = 0; + + // Advance chain time by `skip`, apply `op`, and require the accumulators to + // have grown by the spot prices that held BEFORE the op, times the interval + // the row reports -- whatever the op then did to the pools. + auto step = [&](const fc::microseconds& skip, auto&& op) { + const auto pools = system_balance(EVO.value); + produce_block(skip); // an empty block `skip` after the sealed head + op(); + const auto next = price_accumulator(EVO); + const int64_t elapsed = next.last_update_us - row.last_update_us; + BOOST_REQUIRE_GE( elapsed, skip.count() ); + expect1 += twap_reference::price_fp(pools[1], pools[0]) * elapsed; + expect2 += twap_reference::price_fp(pools[0], pools[1]) * elapsed; + BOOST_REQUIRE_MESSAGE( next.price1 == expect1, "price1 " << next.price1 << " expected " << expect1 + << " elapsed " << elapsed << " pools " << pools[0] << "," << pools[1] + << " last_update " << row.last_update_us << " -> " << next.last_update_us ); + BOOST_REQUIRE_MESSAGE( next.price2 == expect2, "price2 " << next.price2 << " expected " << expect2 ); + row = next; + }; + step( fc::seconds(10), [&]{ BOOST_REQUIRE_EQUAL( success(), + exchange("alice"_n, EVO, extend(asset::from_string("4.0000 EOS")), asset(0, VOICE4)) ); } ); + step( fc::seconds(30), [&]{ BOOST_REQUIRE_EQUAL( success(), + addliquidity("alice"_n, asset::from_string("50.0000 EVO"), + asset::from_string("100000.0000 VOICE"), asset::from_string("100000.0000 EOS")) ); } ); + step( fc::minutes(5), [&]{ BOOST_REQUIRE_EQUAL( success(), + remliquidity("alice"_n, asset::from_string("25.0000 EVO"), asset(0, VOICE4), asset(0, EOS4)) ); } ); + step( fc::hours(1), [&]{ BOOST_REQUIRE_EQUAL( success(), + exchange("alice"_n, EVO, extend(asset::from_string("7.0000 VOICE")), asset(0, EOS4)) ); } ); + step( fc::hours(1), [&]{ BOOST_REQUIRE_EQUAL( success(), sync(EVO) ); } ); + + // The reader's window: (r_now - r_snapshot) / (t - t0), computed by the + // shared kernel and by exact 256-bit division, must agree... + const int64_t window = row.last_update_us - snapshot.last_update_us; + const uint256_t delta1 = row.price1 - snapshot.price1; + const twap::u128 average1 = twap::average_price( + twap::difference(twap_testing::to_cumulative(row.price1), twap_testing::to_cumulative(snapshot.price1)), + uint64_t(window) ); + BOOST_REQUIRE( twap_testing::wide(average1) == delta1 / window ); + // ...and land where the pools were all along: price1 is EOS per VOICE (pool2 + // over pool1), a little under a quarter; price2 a little over four. + BOOST_REQUIRE( average1 > twap::PRICE_ONE / 5 && average1 < twap::PRICE_ONE / 4 ); + const twap::u128 average2 = twap::average_price( + twap::difference(twap_testing::to_cumulative(row.price2), twap_testing::to_cumulative(snapshot.price2)), + uint64_t(window) ); + BOOST_REQUIRE( twap_testing::wide(average2) == (row.price2 - snapshot.price2) / window ); + BOOST_REQUIRE( average2 >= 4 * twap::PRICE_ONE && average2 < 5 * twap::PRICE_ONE ); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( price_accumulators_ignore_same_block_moves, sysio_swap_tester ) try { + setup_pools(); + produce_block(); // seal the pool creation before the clock moves + using boost::multiprecision::uint256_t; + + auto row = price_accumulator(EVO); + const auto pools = system_balance(EVO.value); + produce_block(fc::seconds(20)); + + // Two trades in one transaction: the first settles the interval at the + // pre-trade price; the second -- more than twice the EOS side, collapsing + // the VOICE-per-EOS spot price (pool1 over pool2) -- happens with no time + // elapsed, so it contributes nothing however far it moves the spot. + exchange_twice_in_one_transaction( "alice"_n, EVO, + extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("50000000000.0000 EOS")), VOICE4 ); + const auto moved = system_balance(EVO.value); + BOOST_REQUIRE( twap_reference::price_fp(moved[0], moved[1]) * 2 < twap_reference::price_fp(pools[0], pools[1]) ); + const auto after = price_accumulator(EVO); + const int64_t elapsed = after.last_update_us - row.last_update_us; + BOOST_REQUIRE_GE( elapsed, fc::seconds(20).count() ); + BOOST_REQUIRE( after.price1 == twap_reference::price_fp(pools[1], pools[0]) * elapsed ); + BOOST_REQUIRE( after.price2 == twap_reference::price_fp(pools[0], pools[1]) * elapsed ); + produce_block(); // seal the transaction in its block + + // Only once time passes does the moved price count, for exactly that time. + produce_block(fc::seconds(20)); + BOOST_REQUIRE_EQUAL( success(), sync(EVO) ); + const auto synced = price_accumulator(EVO); + const int64_t held = synced.last_update_us - after.last_update_us; + BOOST_REQUIRE_GE( held, fc::seconds(20).count() ); + BOOST_REQUIRE( synced.price1 == after.price1 + twap_reference::price_fp(moved[1], moved[0]) * held ); + BOOST_REQUIRE( synced.price2 == after.price2 + twap_reference::price_fp(moved[0], moved[1]) * held ); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( price_accumulators_span_extreme_prices, sysio_swap_tester ) try { + // The steepest price int64 balances allow, held for a decade: the sum + // needs the high limb and the average still recovers the price exactly. + create_tokens_and_issue(); + abi_ser.set_abi( swap_abi_def(), abi_serializer::create_yield_function(abi_serializer_max_time) ); + many_openext(); + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("461168601842738.7903 EOS"), "") ); + BOOST_REQUIRE_EQUAL( success(), seed_transfer( "anothertoken"_n, "bob"_n, asset::from_string("461168601842738.7903 VOICE"), "deposit to: alice") ); + const int64_t init_max = 1'000'000'000'000'000 - 1; + // The largest VOICE side against one unit of EOS: price2 (VOICE per EOS) is + // the steep one. + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extend(asset(init_max, VOICE4)), extend(asset(1, EOS4)), 10, name{}) ); + namespace twap = sysio::opp::twap; + + // At this price a single block already carries the sum past 128 bits; a + // week makes the point without stretching the chain clock. + produce_block(); // seal the pool creation before the clock moves + const auto row = price_accumulator(EVO); + produce_block(fc::days(7)); + BOOST_REQUIRE_EQUAL( success(), sync(EVO) ); + const auto next = price_accumulator(EVO); + const int64_t elapsed = next.last_update_us - row.last_update_us; + BOOST_REQUIRE_GE( elapsed, fc::days(7).count() ); + + BOOST_REQUIRE( next.price1 == twap_reference::price_fp(1, init_max) * elapsed ); + BOOST_REQUIRE( next.price2 == twap_reference::price_fp(init_max, 1) * elapsed ); + BOOST_REQUIRE( (next.price2 >> 128) != 0 ); // beyond 128 bits, as designed for + const twap::u128 average2 = twap::average_price( + twap::difference(twap_testing::to_cumulative(next.price2), twap_testing::to_cumulative(row.price2)), + uint64_t(elapsed) ); + BOOST_REQUIRE( average2 == twap::price_fp(uint64_t(init_max), 1) ); + + // And the pool still trades. + BOOST_REQUIRE_EQUAL( reference::receive(1, 1, init_max, 10), settle_swap("alice"_n, EVO, asset(1, EOS4), VOICE4, 0) ); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { + // The substitution is internal: the action set, the swap and fee + // signatures, the pair row, and the table set must not move. + const abi_def abi = swap_abi_def(); + + std::set actions; + for (const auto& a : abi.actions) actions.insert(a.name.to_string()); + const std::set expected_actions{ + "accrueyield", "addliquidity", "cancelyield", "changefee", "close", "closeext", "exchange", "fundyield", + "inittoken", "open", "openext", "remliquidity", "setconfig", "setyield", "sync", "tickyield", "transfer", + "withdraw" }; + BOOST_REQUIRE( actions == expected_actions ); + + using field_list = std::vector>; + auto fields = [&](const std::string& struct_name) { + field_list out; + for (const auto& s : abi.structs) { + if (s.name != struct_name) continue; + for (const auto& f : s.fields) out.emplace_back(f.name, f.type); + } + return out; + }; + const field_list exchange_fields{ + {"user", "name"}, {"pair_token", "symbol_code"}, {"ext_asset_in", "extended_asset"}, {"min_expected", "asset"} }; + const field_list changefee_fields{ {"pair_token", "symbol_code"}, {"newfee", "int32"} }; + const field_list inittoken_fields{ + {"user", "name"}, {"new_symbol", "symbol"}, {"initial_pool1", "extended_asset"}, + {"initial_pool2", "extended_asset"}, {"initial_fee", "int32"}, {"fee_authority", "name"}, + {"locked_shares", "asset"}, {"yield_leg", "extended_symbol?"} }; + const field_list currency_stats_fields{ + {"supply", "asset"}, {"max_supply", "asset"}, {"issuer", "name"}, {"pool1", "extended_asset"}, + {"pool2", "extended_asset"}, {"fee", "int32"}, {"fee_authority", "name"}, {"locked_shares", "asset"}, + {"yield_leg", "extended_symbol?"}, {"conversion_horizon_sec", "uint32"}, {"depth_cap_bps", "uint32"}, + {"clip_floor", "int64"}, {"last_tick_depth", "int64"}, {"last_tick", "time_point"} }; + const field_list setyield_fields{ + {"pair_token", "symbol_code"}, {"conversion_horizon_sec", "uint32"}, {"depth_cap_bps", "uint32"}, + {"clip_floor", "int64"} }; + const field_list accrueyield_fields{ {"pair_token", "symbol_code"} }; + const field_list payout_receipt_fields{ {"pair", "symbol_code"}, {"quantity", "extended_asset"} }; + const field_list fundyield_fields{ {"from", "name"}, {"pair_token", "symbol_code"}, {"quantity", "asset"} }; + const field_list cancelyield_fields{ {"from", "name"} }; + const field_list fund_receipt_fields{ {"pair", "symbol_code"}, {"quantity", "extended_asset"} }; + const field_list reservoir_fields{ {"balance", "extended_asset"} }; + const field_list tickyield_fields{ {"pair_token", "symbol_code"} }; + const field_list setconfig_fields{ {"fee_authority", "name"}, {"system_token", "extended_symbol"} }; + const field_list swap_config_fields{ {"fee_authority", "name"}, {"system_token", "extended_symbol"} }; + const field_list sync_fields{ {"pair_token", "symbol_code"} }; + const field_list cumulative_price_fields{ {"lo", "uint128"}, {"hi", "uint128"} }; + const field_list price_accumulator_fields{ + {"price1", "cumulative_price"}, {"price2", "cumulative_price"}, {"last_update", "time_point"} }; + const field_list account_fields{ {"balance", "asset"} }; + const field_list evodex_account_fields{ {"balance", "extended_asset"} }; + const field_list pair_index_fields{ {"evo_symbol", "symbol"} }; + BOOST_REQUIRE( fields("exchange") == exchange_fields ); + BOOST_REQUIRE( fields("changefee") == changefee_fields ); + BOOST_REQUIRE( fields("inittoken") == inittoken_fields ); + BOOST_REQUIRE( fields("currency_stats") == currency_stats_fields ); + BOOST_REQUIRE( fields("sync") == sync_fields ); + BOOST_REQUIRE( fields("cumulative_price") == cumulative_price_fields ); + BOOST_REQUIRE( fields("price_accumulator") == price_accumulator_fields ); + BOOST_REQUIRE( fields("account") == account_fields ); + BOOST_REQUIRE( fields("evodex_account") == evodex_account_fields ); + BOOST_REQUIRE( fields("pair_index") == pair_index_fields ); + BOOST_REQUIRE( fields("setconfig") == setconfig_fields ); + BOOST_REQUIRE( fields("swap_config") == swap_config_fields ); + BOOST_REQUIRE( fields("setyield") == setyield_fields ); + BOOST_REQUIRE( fields("accrueyield") == accrueyield_fields ); + BOOST_REQUIRE( fields("payout_receipt") == payout_receipt_fields ); + BOOST_REQUIRE( fields("fundyield") == fundyield_fields ); + BOOST_REQUIRE( fields("cancelyield") == cancelyield_fields ); + BOOST_REQUIRE( fields("fund_receipt") == fund_receipt_fields ); + BOOST_REQUIRE( fields("reservoir") == reservoir_fields ); + BOOST_REQUIRE( fields("tickyield") == tickyield_fields ); + + // KV tables: the row type and the key layout an explorer needs to decode + // the raw key bytes. A scoped table's first key word is the scope. + struct table_shape { std::string type; std::vector key_names; std::vector key_types; }; + auto shape = [&](const std::string& table_name) { + for (const auto& t : abi.tables) + if (t.name == table_name) + return table_shape{ t.type, std::vector(t.key_names.begin(), t.key_names.end()), + std::vector(t.key_types.begin(), t.key_types.end()) }; + return table_shape{}; + }; + auto same = [](const table_shape& a, const table_shape& b) { + return a.type == b.type && a.key_names == b.key_names && a.key_types == b.key_types; + }; + BOOST_REQUIRE( same( shape("accounts"), { "account", {"scope", "symbol_code"}, {"name", "uint64"} } ) ); + BOOST_REQUIRE( same( shape("evodexacnts"), { "evodex_account", {"scope", "contract", "symbol"}, {"name", "name", "uint64"} } ) ); + BOOST_REQUIRE( same( shape("stat"), { "currency_stats", {"symbol_code"}, {"uint64"} } ) ); + BOOST_REQUIRE( same( shape("evoindex"), { "pair_index", {"contract1", "symbol1", "contract2", "symbol2"}, {"name", "uint64", "name", "uint64"} } ) ); + BOOST_REQUIRE( same( shape("priceaccum"), { "price_accumulator", {"symbol_code"}, {"uint64"} } ) ); + BOOST_REQUIRE( same( shape("swapconfig"), { "swap_config", {"name"}, {"name"} } ) ); + BOOST_REQUIRE( same( shape("yieldpayouts"), { "payout_receipt", {"contract"}, {"name"} } ) ); + BOOST_REQUIRE( same( shape("yieldfunds"), { "fund_receipt", {"funder"}, {"name"} } ) ); + BOOST_REQUIRE( same( shape("reservoirs"), { "reservoir", {"symbol_code"}, {"uint64"} } ) ); + + // The shadow token's tables are read by this contract but are not its own. + std::set tables; + for (const auto& t : abi.tables) tables.insert(t.name); + const std::set expected_tables{ + "accounts", "evodexacnts", "evoindex", "priceaccum", "reservoirs", "stat", "swapconfig", + "yieldfunds", "yieldpayouts" }; + BOOST_REQUIRE( tables == expected_tables ); +} FC_LOG_AND_RETHROW() + +BOOST_AUTO_TEST_SUITE_END() \ No newline at end of file diff --git a/contracts/tests/twap_tests.cpp b/contracts/tests/twap_tests.cpp new file mode 100644 index 0000000000..34d3ad63b0 --- /dev/null +++ b/contracts/tests/twap_tests.cpp @@ -0,0 +1,131 @@ +/** + * @file twap_tests.cpp + * @brief Host-side unit tests for the cumulative-price accumulator kernel + * (`sysio::opp::twap`, contracts/sysio.opp.common/.../twap.hpp). + * + * The kernel is pure integer C++ (no contract intrinsics), so it is exercised + * directly on the host against boost 256-bit arithmetic. Coverage: + * - `price_fp` is the floored Q64.64 ratio, zero on an empty denominator. + * - `accumulate` equals the exact 256-bit sum, including every carry path. + * - `difference` subtracts modulo 2^256, including a borrow across limbs. + * - `average_price` recovers the exact time-weighted mean of a step sequence + * and reports 0 for a zero window or an impossible delta. + */ + +#include +#include +#include "twap_wide.hpp" + +#include +#include +#include + +using namespace sysio::opp::twap; +using namespace twap_testing; + +namespace { + +constexpr uint64_t I64_MAX = uint64_t(INT64_MAX); +constexpr u128 U128_MAX = ~u128(0); + +} // namespace + +BOOST_AUTO_TEST_SUITE(twap_tests) + +BOOST_AUTO_TEST_CASE(price_fp_is_the_floored_ratio) { + BOOST_CHECK(price_fp(1, 1) == PRICE_ONE); + BOOST_CHECK(price_fp(3, 2) == PRICE_ONE + PRICE_ONE / 2); + BOOST_CHECK(price_fp(1, 3) == PRICE_ONE / 3); // floored + BOOST_CHECK(price_fp(I64_MAX, 1) == (u128(I64_MAX) << 64)); // the largest price of int64 balances + BOOST_CHECK(price_fp(1, I64_MAX) == PRICE_ONE / I64_MAX); + BOOST_CHECK(price_fp(5, 0) == 0); + BOOST_CHECK(price_fp(0, 5) == 0); +} + +BOOST_AUTO_TEST_CASE(accumulate_matches_exact_256_bit_arithmetic) { + std::mt19937_64 rng(0x5457'4150'5445'5354ULL); + cumulative_price acc; + uint256_t expected = 0; + for (int i = 0; i < 2000; ++i) { + // prices across the whole Q64.64 range of int64 balances, elapsed across 64 bits + const u128 price = price_fp(rng() % (I64_MAX + 1), 1 + rng() % I64_MAX); + const uint64_t elapsed = (i % 3 == 0) ? rng() : rng() % 1'000'000'000; + accumulate(acc, price, elapsed); + expected += wide(price) * elapsed; + BOOST_REQUIRE(wide(acc) == expected); + } +} + +BOOST_AUTO_TEST_CASE(accumulate_carries_across_the_limb) { + // The product itself spills into the high limb. + { + cumulative_price acc; + accumulate(acc, u128(I64_MAX) << 64, UINT64_MAX); + BOOST_CHECK(wide(acc) == wide(u128(I64_MAX) << 64) * UINT64_MAX); + BOOST_CHECK(acc.hi != 0); + } + // The sum spills into the high limb while the product does not. + { + cumulative_price acc{U128_MAX, 0}; + accumulate(acc, 1, 1); + BOOST_CHECK(acc.lo == 0); + BOOST_CHECK(acc.hi == 1); + } + // Both carries at once. + { + cumulative_price acc{U128_MAX, 7}; + const u128 price = (u128(1) << 127) | 1; + accumulate(acc, price, 3); + BOOST_CHECK(wide(acc) == (wide(U128_MAX) | (uint256_t(7) << 128)) + wide(price) * 3); + } +} + +BOOST_AUTO_TEST_CASE(difference_borrows_across_the_limb) { + const cumulative_price later{5, 3}; + const cumulative_price earlier{U128_MAX, 1}; + const cumulative_price d = difference(later, earlier); + BOOST_CHECK(wide(d) == wide(later) - wide(earlier)); + BOOST_CHECK(d.lo == 6); + BOOST_CHECK(d.hi == 1); + // Identical accumulators differ by zero. + const cumulative_price z = difference(later, later); + BOOST_CHECK(z.lo == 0 && z.hi == 0); +} + +BOOST_AUTO_TEST_CASE(average_price_recovers_the_time_weighted_mean) { + std::mt19937_64 rng(0x4156'4552'4147'45ULL); + for (int round = 0; round < 200; ++round) { + cumulative_price start, end; + uint64_t total = 0; + uint256_t sum = 0; + const int steps = 1 + rng() % 8; + for (int s = 0; s < steps; ++s) { + const u128 price = price_fp(1 + rng() % I64_MAX, 1 + rng() % I64_MAX); + const uint64_t elapsed = 1 + rng() % 4'000'000'000'000ULL; // up to ~46 days of microseconds + accumulate(end, price, elapsed); + total += elapsed; + sum += wide(price) * elapsed; + } + BOOST_REQUIRE(average_price(difference(end, start), total) == narrow(sum / total)); + } + // A constant price over any window averages to exactly itself. + { + cumulative_price acc; + const u128 price = price_fp(I64_MAX, 1); + accumulate(acc, price, UINT64_MAX); + BOOST_CHECK(average_price(acc, UINT64_MAX) == price); + } +} + +BOOST_AUTO_TEST_CASE(average_price_rejects_degenerate_windows) { + cumulative_price acc; + accumulate(acc, PRICE_ONE, 10); + BOOST_CHECK(average_price(acc, 0) == 0); + // A delta whose high limb reaches the window length cannot be a mean of valid prices. + const cumulative_price impossible{0, 10}; + BOOST_CHECK(average_price(impossible, 10) == 0); + const cumulative_price possible{0, 9}; + BOOST_CHECK(average_price(possible, 10) != 0); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/twap_wide.hpp b/contracts/tests/twap_wide.hpp new file mode 100644 index 0000000000..296f14490f --- /dev/null +++ b/contracts/tests/twap_wide.hpp @@ -0,0 +1,28 @@ +#pragma once +/** + * @file twap_wide.hpp + * @brief Conversions between the twap kernel's 128/256-bit types and boost + * 256-bit integers, shared by the host kernel tests and the sysio.swap + * contract tests so each can check the on-chain arithmetic against an + * exact reference. + */ + +#include +#include + +#include + +namespace twap_testing { + +using boost::multiprecision::uint256_t; +using sysio::opp::twap::cumulative_price; +using sysio::opp::twap::u128; + +inline uint256_t wide(u128 v) { return (uint256_t(uint64_t(v >> 64)) << 64) | uint64_t(v); } +inline uint256_t wide(const cumulative_price& c) { return (wide(c.hi) << 128) | wide(c.lo); } +inline u128 narrow(const uint256_t& v) { return (u128(uint64_t(v >> 64)) << 64) | uint64_t(v); } +inline cumulative_price to_cumulative(const uint256_t& v) { + return cumulative_price{ narrow(v), narrow(v >> 128) }; +} + +} // namespace twap_testing diff --git a/docs/contract-upgrade-order.md b/docs/contract-upgrade-order.md index 69382e2411..871442595c 100644 --- a/docs/contract-upgrade-order.md +++ b/docs/contract-upgrade-order.md @@ -145,6 +145,7 @@ WIRE-352 adds separate inline edges whose failure behavior depends on the caller | `sysio.roa::nodeownreg` | `sysio.authex::recordlink` | Deploy `sysio.roa` and `sysio.authex` as the same compatibility-coupled set. | WIRE-352 adds the required `native_address` field. An old caller underflows the new action decoder; the inverse pairing is also unsupported. Node-owner delivery can abort. | | `sysio.authex::createlink` | `sysio.dclaim::linkswept` | Deploy `sysio.dclaim` before the first user-created external-key link. | A missing or non-privileged callee aborts before link insertion. Any inline failure rolls the transaction back, so the user can submit a fresh retry. | | `sysio.authex::recordlink` | `sysio.dclaim::linkswept` | Prefer deploying `sysio.dclaim` before trusted node-owner dispatch begins. | A missing or non-privileged callee skips the sweep but preserves the trusted link; an identical operator-authorized `recordlink` can retry it after bootstrap. | +| `sysio.authex::createlink` | `sysio.liq::linkswept` | None. Nothing is parked before `sysio.liq` is deployed, and it deploys privileged through `setsyscode`. | A missing or non-privileged callee skips the sweep and keeps the link; the permissionless `sysio.liq::sweep` delivers the parked shadow later. | Production deployment through `sysio.roa::setsyscode` privileges `sysio.dclaim` as part of the deploy, so there is no separate privilege step. The durable diff --git a/etc/config/dex/dex-config.dev.json b/etc/config/dex/dex-config.dev.json index 29fad1bc83..b105b87873 100644 --- a/etc/config/dex/dex-config.dev.json +++ b/etc/config/dex/dex-config.dev.json @@ -2,6 +2,7 @@ "schema_version": 1, "network": "dev-cluster", "t5_reserve_allocation": "100000000000", + "t5_dex_allocation": "20000000000", "chains": [ { "kind": "CHAIN_KIND_WIRE", "code": "WIRE", "external_chain_id": 0, "name": "Wire Network", "description": "WIRE depot chain" }, @@ -57,11 +58,6 @@ "description": "Bootstrap-seeded native ETH ↔ WIRE reserve", "initial_chain_amount": "10000000000", "initial_wire_amount": "10000000000", "connector_weight_bps": 5000, "is_private": false, "owner": "" }, - { "code": "PRIMARY", "chain_code": "ETHEREUM", "token_code": "LIQETH", - "name": "ETHEREUM-LIQETH/WIRE primary reserve", - "description": "Bootstrap-seeded liqETH ↔ WIRE reserve", - "initial_chain_amount": "10000000000", "initial_wire_amount": "10000000000", - "connector_weight_bps": 5000, "is_private": false, "owner": "" }, { "code": "PRIMARY", "chain_code": "ETHEREUM", "token_code": "USDC", "name": "ETHEREUM-USDC/WIRE primary reserve", "description": "Bootstrap-seeded USDC ↔ WIRE reserve (mock ERC-20)", @@ -77,11 +73,6 @@ "description": "Bootstrap-seeded native SOL ↔ WIRE reserve", "initial_chain_amount": "10000000000", "initial_wire_amount": "10000000000", "connector_weight_bps": 5000, "is_private": false, "owner": "" }, - { "code": "PRIMARY", "chain_code": "SOLANA", "token_code": "LIQSOL", - "name": "SOLANA-LIQSOL/WIRE primary reserve", - "description": "Bootstrap-seeded liqSOL ↔ WIRE reserve", - "initial_chain_amount": "10000000000", "initial_wire_amount": "10000000000", - "connector_weight_bps": 5000, "is_private": false, "owner": "" }, { "code": "PRIMARY", "chain_code": "SOLANA", "token_code": "USDCSOL", "name": "SOLANA-USDCSOL/WIRE primary reserve", "description": "Bootstrap-seeded USDC ↔ WIRE reserve on Solana (mock SPL)", @@ -93,6 +84,31 @@ "initial_chain_amount": "10000000000", "initial_wire_amount": "10000000000", "connector_weight_bps": 5000, "is_private": false, "owner": "" } ], + "liq_pools": [ + { "chain_code": "ETHEREUM", "token_code": "LIQETH", "pair_symbol": "LIQETHP", + "name": "LIQETH/WIRE yield pool", + "description": "Bootstrap-seeded shadow-liqETH yield pool (the LCO liqETH against the dex earmark)", + "initial_chain_amount": "10000000000", "initial_wire_amount": "10000000000", + "fee": 30, "locked_shares": "0", "conversion_horizon_sec": 86400, "depth_cap_bps": 50, + "clip_floor": "1000", "custody_total": "13000000000" }, + { "chain_code": "SOLANA", "token_code": "LIQSOL", "pair_symbol": "LIQSOLP", + "name": "LIQSOL/WIRE yield pool", + "description": "Bootstrap-seeded shadow-liqSOL yield pool (the LCO liqSOL against the dex earmark)", + "initial_chain_amount": "10000000000", "initial_wire_amount": "10000000000", + "fee": 30, "locked_shares": "0", "conversion_horizon_sec": 86400, "depth_cap_bps": 50, + "clip_floor": "1000", "custody_total": "15000000000" } + ], + "syndications": [ + { "chain_code": "ETHEREUM", "token_code": "LIQETH", + "pubkey": "0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "amount": "3000000000" }, + { "chain_code": "SOLANA", "token_code": "LIQSOL", + "pubkey": "So11111111111111111111111111111111111111112", + "amount": "2000000000" }, + { "chain_code": "SOLANA", "token_code": "LIQSOL", + "pubkey": "11111111111111111111111111111111", + "amount": "3000000000" } + ], "uwrit": { "fee_bps": 10, "collateral_lock_duration_ms": "43200000" diff --git a/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto b/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto index 51690a3ae6..7c6fc8cbfe 100644 --- a/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto +++ b/libraries/opp/proto/sysio/opp/bootstrap/bootstrap.proto @@ -143,6 +143,76 @@ message ReserveSpec { string owner = 10; } +// A shadow-liq yield pool to seed via +// `sysio.liq::regliqpool(chain_code, token_code, pair_symbol, +// initial_chain_amount, initial_wire_amount, fee, locked_shares, +// conversion_horizon_sec, depth_cap_bps, clip_floor)`. +// +// TEN arguments, one per field below. The action mints `initial_chain_amount` +// of the token's shadow to `sysio` (the LCO liq, protocol-owned and already in +// outpost custody), deposits it with `initial_wire_amount` WIRE from the +// `sysio` treasury into `sysio.swap`, creates the pair with `sysio` as its fee +// authority and the shadow as its yield leg, and sets the tick parameters. The +// WIRE side is the `t5_dex_allocation` earmark being drained; the shadow symbol +// must already exist (`sysio.liq::create`). Exactly one pool per shadow. +message LiqPoolSpec { + // Outpost chain the liq token lives on — references a declared non-WIRE + // `ChainSpec.code`. + string chain_code = 1; + // The liq token — must reference a `TokenSpec` of kind TOKEN_KIND_LIQ bound + // to `chain_code`. + string token_code = 2; + // The swap's pair (LP) token symbol CODE, 1..7 characters [A-Z]; the tool + // passes it at precision 9, the depot frame, which is what `inittoken` + // requires of a 9-decimal shadow against 9-decimal WIRE. + string pair_symbol = 3; + // Human-readable display name. + string name = 4; + // Free-form description / provenance note. + string description = 5; + // Shadow-side seed: the LCO liq, in the token's subunits (quote in JSON). + uint64 initial_chain_amount = 6; + // WIRE-side seed, in WIRE subunits, drained from `t5_dex_allocation` + // (quote in JSON). + uint64 initial_wire_amount = 7; + // The pair's swap fee in 1/10000 of the traded amount: 0..9999. + uint32 fee = 8; + // LP shares locked forever, in the pair symbol's subunits (quote in JSON); + // below the minted sqrt(initial_chain_amount * initial_wire_amount). + uint64 locked_shares = 9; + // Horizon over which queued yield is meant to sell, in seconds (> 0). + uint32 conversion_horizon_sec = 10; + // Hard ceiling on one clip, in basis points of the pool's shadow side: + // 1..10000, and best kept below roughly twice `fee`. + uint32 depth_cap_bps = 11; + // Least clip worth selling, in the shadow's subunits (> 0; quote in JSON). + uint64 clip_floor = 12; + // Optional cross-check: the liq the outpost holds in custody at the snapshot, + // in the token's subunits (quote in JSON). When non-zero it must equal + // `initial_chain_amount` plus the sum of the `syndications` for this token, + // since the depot mints shadow against exactly that custody. 0 skips it. + uint64 custody_total = 13; +} + +// A pre-launch syndicated position to replay via +// `sysio.liq::importsynd(chain_code, token_code, credits[] {pubkey, amount})` +// (batched by the tool; `importdone` closes the import). Filled from the same +// launch export that produces the WPT credits. +message SyndicationSpec { + // Outpost chain — references a declared non-WIRE `ChainSpec.code`. + string chain_code = 1; + // The liq token — a TOKEN_KIND_LIQ `TokenSpec` bound to `chain_code`. + string token_code = 2; + // The holder's native PUBKEY in display form, the identity + // `SyndicateLIQ.user` carries: base58 of the 32-byte Ed25519 key on SVM, + // 0x-hex of the 33-byte COMPRESSED secp256k1 point on EVM (never the + // 20-byte address). + string pubkey = 3; + // The position, in the token's subunits (quote in JSON), with the LCO yield + // already folded in. + uint64 amount = 4; +} + // Global swap settings applied once via `sysio.uwrit::setconfig`, which takes // SIX arguments: `(fee_bps, collateral_lock_duration_ms, min_fromwire_amount, // fromwire_revert_fee_bps, uwreq_pending_timeout_epochs, @@ -206,14 +276,13 @@ message BootstrapPlatformConfig { // Invariant: sum(reserves[].initial_wire_amount) <= t5_reserve_allocation. uint64 t5_reserve_allocation = 3; - // WIRE subunits earmarked from the T5 allotment for launch DEX integration — - // at least one DEX is seeded at launch (more added later by council vote). - // Like `t5_reserve_allocation`, this sits OUTSIDE `t5_distributable`, so the - // total T5 carve-out is `t5_reserve_allocation + t5_dex_allocation`. The total - // is set per-deployment (Kyle / Ken own the launch figure). RESERVED: the - // on-chain DEX-seeding mechanism is not finalized, so this field is carried - // for forward compatibility and is not yet drained by the bootstrap tool; - // default 0 disables the earmark. + // WIRE subunits earmarked from the T5 allotment to back the WIRE side of the + // launch shadow-liq yield pools (`liq_pools`), drained by + // `sysio.liq::regliqpool`. Like `t5_reserve_allocation`, this sits OUTSIDE + // `t5_distributable`, so the total T5 carve-out is + // `t5_reserve_allocation + t5_dex_allocation`; the figure is set + // per-deployment (Kyle / Ken own the launch number). + // Invariant: sum(liq_pools[].initial_wire_amount) <= t5_dex_allocation. uint64 t5_dex_allocation = 8; // Chains to register (one must be the CHAIN_KIND_WIRE depot). @@ -224,4 +293,9 @@ message BootstrapPlatformConfig { repeated ReserveSpec reserves = 6; // Global swap / underwriting settings. UwritConfig uwrit = 7; + // Shadow-liq yield pools to seed, one per liq token (WIRE side drained from + // `t5_dex_allocation`). + repeated LiqPoolSpec liq_pools = 9; + // Pre-launch syndicated positions to replay into the shadow-liq ledger. + repeated SyndicationSpec syndications = 10; } diff --git a/libraries/opp/test/test_bootstrap_platform_config.cpp b/libraries/opp/test/test_bootstrap_platform_config.cpp index f22bea6d11..85da38cc68 100644 --- a/libraries/opp/test/test_bootstrap_platform_config.cpp +++ b/libraries/opp/test/test_bootstrap_platform_config.cpp @@ -88,6 +88,24 @@ bool svm_addr_ok(const std::string& s) { } } +/// True iff `s` is a 0x-prefixed 33-byte hex string whose first byte is 02 or 03 +/// (a compressed secp256k1 point, the EVM syndication identity). +bool evm_pubkey_ok(const std::string& s) { + if (s.size() != 68 || s[0] != '0' || s[1] != 'x') return false; + if (!(s[2] == '0' && (s[3] == '2' || s[3] == '3'))) return false; + for (size_t i = 2; i < s.size(); ++i) + if (!std::isxdigit(static_cast(s[i]))) return false; + return true; +} + +/// True iff `s` is a symbol code the swap accepts for a pair token: 1..7 upper-case letters. +bool pair_symbol_ok(const std::string& s) { + if (s.empty() || s.size() > 7) return false; + for (char c : s) + if (c < 'A' || c > 'Z') return false; + return true; +} + /// Lightweight Antelope account-name check for the private-reserve `owner`. /// The contract is the authority; this catches gross authoring mistakes /// (charset `.a-z1-5`, non-empty, <= 13 chars). @@ -100,7 +118,7 @@ bool account_name_ok(const std::string& s) { return true; } -/// Validate a parsed config against the launch invariants V1..V9. Returns a +/// Validate a parsed config against the launch invariants V1..V13. Returns a /// list of human-readable failures (empty == valid). std::vector validate(const BootstrapPlatformConfig& c) { std::vector e; @@ -128,6 +146,7 @@ std::vector validate(const BootstrapPlatformConfig& c) { std::set token_codes; std::map native_per_chain; std::set> bindings; + std::set> liq_bindings; // the TOKEN_KIND_LIQ subset for (const auto& t : c.tokens()) { if (!slug_ok(t.code())) e.push_back("V2 token code: " + t.code()); if (!token_codes.insert(t.code()).second) e.push_back("V4 duplicate token: " + t.code()); @@ -136,6 +155,7 @@ std::vector validate(const BootstrapPlatformConfig& c) { e.push_back("V4 token " + t.code() + " references undeclared chain " + t.chain_code()); continue; } + if (t.kind() == TokenKind::TOKEN_KIND_LIQ) liq_bindings.insert({t.chain_code(), t.code()}); // 1..9, NOT 1..18: `TokenSpec.precision` is the DEPOT-FRAME precision, and // `sysio.tokens::regtoken` rejects anything above MAX_TOKEN_PRECISION (9). // Accepting 10..18 here let a config pass strict validation and then throw @@ -202,6 +222,60 @@ std::vector validate(const BootstrapPlatformConfig& c) { if (u.collateral_lock_duration_ms() == 0) e.push_back("V9 collateral_lock_duration_ms must be > 0"); } + // liq pools: V11 binding / uniqueness / parameters, V10 earmark + std::map, unsigned __int128> pool_seed; + unsigned __int128 sum_pool_wire = 0; + for (const auto& p : c.liq_pools()) { + const auto binding = std::make_pair(p.chain_code(), p.token_code()); + const auto label = p.chain_code() + "/" + p.token_code(); + if (!liq_bindings.count(binding)) + e.push_back("V11 liq pool references no declared liq token on its chain: " + label); + if (!pool_seed.emplace(binding, p.initial_chain_amount()).second) + e.push_back("V11 duplicate liq pool: " + label); + if (!pair_symbol_ok(p.pair_symbol())) + e.push_back("V11 pair_symbol must be 1..7 characters [A-Z]: " + p.pair_symbol()); + if (p.initial_chain_amount() == 0 || p.initial_wire_amount() == 0) + e.push_back("V11 liq pool seeds must be > 0: " + label); + // 0..9999: the swap's changefee/inittoken ceiling (MAX_FEE); 10000 is refused on-chain. + if (p.fee() > 9999) e.push_back("V11 fee > 9999 (100%): " + label); + if (p.conversion_horizon_sec() == 0) e.push_back("V11 conversion_horizon_sec must be > 0: " + label); + if (p.depth_cap_bps() == 0 || p.depth_cap_bps() > 10000) e.push_back("V11 depth_cap_bps out of 1..10000: " + label); + if (p.clip_floor() == 0) e.push_back("V11 clip_floor must be > 0: " + label); + sum_pool_wire += p.initial_wire_amount(); + } + + // V10 — the dex earmark covers the pools' WIRE sides + if (c.liq_pools_size() > 0 && c.t5_dex_allocation() == 0) + e.push_back("V10 t5_dex_allocation must be > 0 when liq pools are seeded"); + if (sum_pool_wire > static_cast(c.t5_dex_allocation())) + e.push_back("V10 sum(liq_pools[].initial_wire_amount) exceeds t5_dex_allocation"); + + // syndications: V12 binding / pubkey form / amount + std::map, unsigned __int128> synd_total; + for (const auto& s : c.syndications()) { + const auto binding = std::make_pair(s.chain_code(), s.token_code()); + if (!liq_bindings.count(binding)) { + e.push_back("V12 syndication references no declared liq token on its chain: " + s.chain_code() + "/" + s.token_code()); + continue; + } + const ChainKind kind = chain_kind.find(s.chain_code())->second; + bool ok = false; + if (kind == ChainKind::CHAIN_KIND_EVM) ok = evm_pubkey_ok(s.pubkey()); + else if (kind == ChainKind::CHAIN_KIND_SVM) ok = svm_addr_ok(s.pubkey()); + if (!ok) e.push_back("V12 syndication pubkey does not fit the chain family: " + s.pubkey()); + if (s.amount() == 0) e.push_back("V12 syndication amount must be > 0: " + s.pubkey()); + synd_total[binding] += s.amount(); + } + + // V13 — a declared custody total is exactly what the depot mints against + for (const auto& p : c.liq_pools()) { + if (p.custody_total() == 0) continue; + const auto binding = std::make_pair(p.chain_code(), p.token_code()); + const unsigned __int128 minted = static_cast(p.initial_chain_amount()) + synd_total[binding]; + if (minted != static_cast(p.custody_total())) + e.push_back("V13 custody_total != initial_chain_amount + sum(syndications) for " + p.chain_code() + "/" + p.token_code()); + } + return e; } @@ -229,7 +303,9 @@ BOOST_AUTO_TEST_CASE(dev_config_parses_and_validates) { BOOST_REQUIRE_MESSAGE(parse_strict(slurp(CONFIG_DIR + "/dex-config.dev.json"), cfg, err), err); for (const auto& v : validate(cfg)) BOOST_ERROR(v); BOOST_CHECK_EQUAL(cfg.tokens_size(), 9); - BOOST_CHECK_EQUAL(cfg.reserves_size(), 8); + BOOST_CHECK_EQUAL(cfg.reserves_size(), 6); // the two liq tokens are pools, not reserves + BOOST_CHECK_EQUAL(cfg.liq_pools_size(), 2); + BOOST_CHECK_EQUAL(cfg.syndications_size(), 3); } /// A typo'd / unknown JSON key must fail the strict parse, not be dropped. @@ -277,22 +353,53 @@ BOOST_AUTO_TEST_CASE(validator_rejects_mutations) { BOOST_CHECK(!validate(c).empty()); } { auto c = base; c.mutable_uwrit()->set_fee_bps(10000); BOOST_CHECK(!validate(c).empty()); } // V9 fee_bps 10000 rejected (100% zeroes post-fee WIRE) + { auto c = base; c.set_t5_dex_allocation(1); + BOOST_CHECK(!validate(c).empty()); } // V10 dex earmark too small + { auto c = base; c.mutable_liq_pools(0)->set_token_code("USDC"); // V11 an ERC-20 is not a liq token + BOOST_CHECK(!validate(c).empty()); } + { auto c = base; *c.add_liq_pools() = c.liq_pools(1); // V11 one pool per token + BOOST_CHECK(!validate(c).empty()); } + { auto c = base; c.mutable_liq_pools(0)->set_pair_symbol("TOOLONG9"); + BOOST_CHECK(!validate(c).empty()); } // V11 eight characters, a digit + { auto c = base; c.mutable_liq_pools(0)->set_fee(10000); + BOOST_CHECK(!validate(c).empty()); } // V11 fee 10000 rejected on-chain + { auto c = base; c.mutable_liq_pools(0)->set_depth_cap_bps(0); + BOOST_CHECK(!validate(c).empty()); } // V11 a zero cap never sells + { auto c = base; c.mutable_liq_pools(0)->set_clip_floor(0); + BOOST_CHECK(!validate(c).empty()); } // V11 floor + { auto c = base; c.mutable_liq_pools(0)->set_conversion_horizon_sec(0); + BOOST_CHECK(!validate(c).empty()); } // V11 horizon + { auto c = base; // V12 an EVM address is not the pubkey + c.mutable_syndications(0)->set_pubkey("0x5FbDB2315678afecb367f032d93F642f64180aa3"); + BOOST_CHECK(!validate(c).empty()); } + { auto c = base; // V12 an EVM pubkey on an SVM token + c.mutable_syndications(1)->set_pubkey("0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"); + BOOST_CHECK(!validate(c).empty()); } + { auto c = base; c.mutable_syndications(1)->set_amount(0); + BOOST_CHECK(!validate(c).empty()); } // V12 amount + { auto c = base; c.mutable_syndications(1)->set_token_code("USDCSOL"); + BOOST_CHECK(!validate(c).empty()); } // V12 not a liq token + { auto c = base; c.mutable_liq_pools(1)->set_custody_total(1); + BOOST_CHECK(!validate(c).empty()); } // V13 custody does not match + { auto c = base; c.mutable_liq_pools(1)->set_custody_total(0); + BOOST_CHECK(validate(c).empty()); } // V13 no custody declared: not checked } -/// The reserved `t5_dex_allocation` earmark is part of the strict schema and is -/// inert today: it defaults to 0, a non-zero value trips no launch invariant -/// (the on-chain DEX-seeding mechanism is not finalized), and strict parsing -/// accepts the key (an unknown key would be rejected — see the test above). -BOOST_AUTO_TEST_CASE(t5_dex_allocation_is_accepted_and_inert) { +/// The `t5_dex_allocation` earmark backs the liq pools' WIRE sides: required +/// once a pool is declared, free otherwise, and part of the strict schema. +BOOST_AUTO_TEST_CASE(t5_dex_allocation_backs_the_liq_pools) { BootstrapPlatformConfig base; std::string err; BOOST_REQUIRE_MESSAGE(parse_strict(slurp(CONFIG_DIR + "/dex-config.dev.json"), base, err), err); BOOST_REQUIRE(validate(base).empty()); - BOOST_CHECK_EQUAL(base.t5_dex_allocation(), 0u); // default: disabled + BOOST_CHECK_EQUAL(base.t5_dex_allocation(), 20'000'000'000ull); // covers both pools exactly auto c = base; - c.set_t5_dex_allocation(1'000'000'000ull); // non-zero earmark - BOOST_CHECK(validate(c).empty()); // reserved: trips nothing + c.set_t5_dex_allocation(0); + BOOST_CHECK(!validate(c).empty()); // pools declared: the earmark is required + c.clear_liq_pools(); + c.clear_syndications(); + BOOST_CHECK(validate(c).empty()); // no pools: a zero earmark is fine BootstrapPlatformConfig parsed; std::string perr; diff --git a/plugins/batch_operator_plugin/README.md b/plugins/batch_operator_plugin/README.md index 0546f7313c..94edd7a820 100644 --- a/plugins/batch_operator_plugin/README.md +++ b/plugins/batch_operator_plugin/README.md @@ -20,6 +20,22 @@ All 21 batch operators run this plugin in perpetuity. The epoch scheduler (`sysi 3. Deliver its raw protobuf bytes to Depot (`sysio.msgch::deliver`) 4. Depot evaluates consensus across all 7 deliveries +## Depot cranks + +Every epoch poll (`--batch-epoch-poll-ms`) also pushes the depot actions nothing on +chain schedules. `sysio.msgch::chkcons` comes only from the elected operator; the +rest come from every ACTIVE operator, because the elected one may be the operator +that is offline, and each is a cheap no-op once its work is done: + +| Action | When | Why nothing else drives it | +|--------|------|----------------------------| +| `sysio.chalg::chkdispute(dispute_id)` | every OPEN envelope dispute | a dispute pauses `sysio.epoch::advance`, so no inline poke can reach it | +| `sysio.swap::tickyield(pair_token)` | every yield pool whose reservoir has a queued balance, at most once per `--batch-yield-tick-interval-ms` per pool from this operator | the reservoir is sold into the pool as time passes; only a tick moves the clock | +| `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. + ## Configuration | Option | Default | Description | @@ -27,6 +43,7 @@ All 21 batch operators run this plugin in perpetuity. The epoch scheduler (`sysi | `--batch-operator-account` | — | WIRE account name for this operator. Configuring it enables the relay | | `--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) | 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 diff --git a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp index fb6c98a06a..0645f11c80 100644 --- a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp +++ b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp @@ -14,6 +14,7 @@ #include "async_action_completion.hpp" #include "group_election.hpp" +#include "yield_cranks.hpp" #include #include @@ -41,6 +42,9 @@ namespace { constexpr auto DELIVERY_TIMEOUT_MS = 15000; constexpr auto EPOCH_POLL_MS = 15000; constexpr auto EPOCH_EDGE_BUFFER_MS = 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; /// Minimum private cron-service thread count even when 0 outposts are /// discovered at startup — keeps `epoch_tick` viable so a cold-sync node @@ -148,6 +152,16 @@ struct batch_operator_plugin::impl { 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; + + // Yield cranks -- see `crank_yield`. + /// Whether `sysio.swap` and `sysio.liq` run code, refreshed off the read-only + /// executor queue every poll (a chainbase read belongs in a read window). The + /// cranks act on the last reading rather than wait for a fresh one: one poll + /// of lag is nothing against a yield horizon, and it keeps the cron thread + /// off chainbase. + std::atomic yield_contracts_deployed{false}; + batch_operator_detail::crank_spacing yield_tick_spacing; // Epoch state tracked across polls uint32_t current_epoch = 0; @@ -371,6 +385,15 @@ struct batch_operator_plugin::impl { try { crank_open_disputes(); } FC_LOG_AND_DROP(); + + // Sell queued yield and queue reported yield. Not gated on `is_elected` + // either — see crank_yield. Idle until both contracts are deployed. + refresh_yield_contract_presence(); + if (yield_contracts_deployed) { + try { + crank_yield(); + } FC_LOG_AND_DROP(); + } } /** @@ -431,6 +454,130 @@ struct batch_operator_plugin::impl { } } + /** + * Refresh `yield_contracts_deployed` from a read window. `sysio.swap` and + * `sysio.liq` are the two contracts the yield cranks read, and a depot whose + * bootstrap has not deployed them (or never will) must not pay a failed table + * read -- an `elog` per poll -- for tables that legitimately do not exist. + */ + void refresh_yield_contract_presence() { + // `this` outlives every queued task: appbase drains the executor before it + // destroys plugins (the same guarantee chain_plugin::read_table_rows_checked + // relies on for its own posted scans). + app().executor().post(appbase::priority::low, appbase::exec_queue::read_only, [this] { + 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); + if (yield_contracts_deployed.exchange(deployed) != deployed) { + ilog("batch_operator: yield cranks {}: {} and {} {}", + deployed ? "active" : "idle", swap::account, liq::account, + deployed ? "are deployed" : "are not both deployed"); + } + }); + } + + /** + * Crank the two permissionless yield actions nothing on chain schedules: + * `sysio.swap::tickyield` for every yield pool whose reservoir has a queued + * balance, and `sysio.liq::queueyield` for every shadow with yield pending from + * an outpost `LIQ_YIELD` report. Like `crank_open_disputes`, NOT gated on + * `is_elected`: the elected operator may be the one that is offline, and every + * push is a cheap no-op once its work is done (`tickyield` returns when nothing + * is queued or the clip is below its floor; `queueyield` when nothing is + * pending). `yield_tick_spacing` bounds this operator to one tick per pool per + * `batch-yield-tick-interval-ms`: a reservoir drains over a horizon of hours + * while every ACTIVE operator polls every few seconds. + */ + void crank_yield() { + crank_yield_ticks(); + crank_pending_yield(); + } + + /// `tickyield` for every tickable pool whose reservoir has something queued. + void crank_yield_ticks() { + using namespace batch_operator_detail; + // The pools: only a yield pool with its tick parameters set survives the + // action's own checks; the pool's symbol code is the row's kv key, so the + // rows are read with their keys. + sysio::chain_apis::read_only::get_table_rows_params pools; + pools.code = chain::name(swap::account); + pools.scope = swap::account; + pools.table = swap::table_stat; + pools.all_rows = true; + pools.filter = [](const fc::variant& row) { + const auto value = row_value(row.get_object()); + return value && is_tickable_pool(*value); + }; + auto pool_rows = read_table(std::move(pools)); + if (pool_rows.rows.empty()) return; + + // The reservoirs: what is queued to sell, keyed by the same symbol code. + sysio::chain_apis::read_only::get_table_rows_params reservoirs; + reservoirs.code = chain::name(swap::account); + reservoirs.scope = swap::account; + reservoirs.table = swap::table_reservoirs; + reservoirs.all_rows = true; + std::map queued_by_pool; + for (const auto& r : read_table(std::move(reservoirs)).rows) { + const auto& row = r.get_object(); + const auto code = row_symbol_code(row); + const auto value = row_value(row); + if (!code || !value) continue; + auto balance = value->find(swap::field::balance); + if (balance == value->end() || !balance->value().is_object()) continue; + queued_by_pool[code->value] = asset_amount(balance->value().get_object(), swap::field::quantity); + } + + 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; + try { + push_action(swap::account, swap::action_tickyield, operator_account, + fc::mutable_variant_object()(swap::field::pair_token, *code)); + yield_tick_spacing.mark(pool, now); + } catch (const fc::exception& e) { + // Expected-transient: another operator's tick sold the clip first, or the + // pool's parameters changed between the scan and the push. + dlog("batch_operator: tickyield({}): {}", pool, e.to_string()); + } + } + } + + /// `queueyield` for every shadow with yield pending from an outpost report. + void crank_pending_yield() { + using namespace batch_operator_detail; + sysio::chain_apis::read_only::get_table_rows_params pending; + pending.code = chain::name(liq::account); + pending.scope = liq::account; + pending.table = liq::table_liqpending; + pending.all_rows = true; + for (const auto& r : read_table(std::move(pending)).rows) { + const auto& row = r.get_object(); + const auto code = row_symbol_code(row); + 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, + fc::mutable_variant_object()(liq::field::sym, *code)); + } catch (const fc::exception& e) { + // Expected-transient: another operator queued it first. Persistent while + // the shadow has no yield pool yet (`regliqpool` still to come). + dlog("batch_operator: queueyield({}): {}", symbol_code_name(*code), e.to_string()); + } + } + } + /** * Refresh `is_active` from `sysio.opreg::operators[operator_account]`. * @@ -1066,6 +1213,8 @@ void batch_operator_plugin::set_program_options(options_description& cli, // splits nodeop --help output on that token). opts("batch-delivery-timeout-ms", bpo::value()->default_value(DELIVERY_TIMEOUT_MS), "Max time to wait for chain delivery confirmation (ms)"); + opts("batch-yield-tick-interval-ms", bpo::value()->default_value(YIELD_TICK_INTERVAL_MS), + "Minimum spacing between this operator's sysio.swap::tickyield pushes per yield pool (ms)"); } void batch_operator_plugin::plugin_initialize(const variables_map& options) { @@ -1073,6 +1222,7 @@ void batch_operator_plugin::plugin_initialize(const variables_map& options) { _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->enabled = _impl->operator_account.good(); _impl->chain_plug = &app().get_plugin(); _impl->cron_plug = &app().get_plugin(); diff --git a/plugins/batch_operator_plugin/src/outpost_opp_job.cpp b/plugins/batch_operator_plugin/src/outpost_opp_job.cpp index 36d67d938f..35aa82ca92 100644 --- a/plugins/batch_operator_plugin/src/outpost_opp_job.cpp +++ b/plugins/batch_operator_plugin/src/outpost_opp_job.cpp @@ -147,6 +147,22 @@ void outpost_opp_job::run_outbound() { wlog("outpost_opp_job[{}]: outbound delivery failed: {}", _client->to_string(), e.what()); } + + // The outpost's per-epoch cranks ride the first successful delivery of an + // epoch -- never its consensus retry, never a failed delivery. Best-effort + // and separate from the delivery result: the envelope landed regardless, and + // a failed crank is retried by the next epoch's delivery. + if (!retry_pending && _last_outbound_epoch == epoch) { + try { + _client->crank_outpost(epoch, _outpost_deadline); + } catch (const fc::exception& e) { + wlog("outpost_opp_job[{}]: outpost crank failed for epoch {}: {}", + _client->to_string(), epoch, e.to_detail_string()); + } catch (const std::exception& e) { + wlog("outpost_opp_job[{}]: outpost crank failed for epoch {}: {}", + _client->to_string(), epoch, e.what()); + } + } } void outpost_opp_job::run_inbound() { diff --git a/plugins/batch_operator_plugin/src/yield_cranks.hpp b/plugins/batch_operator_plugin/src/yield_cranks.hpp new file mode 100644 index 0000000000..6eaf30ad11 --- /dev/null +++ b/plugins/batch_operator_plugin/src/yield_cranks.hpp @@ -0,0 +1,131 @@ +#pragma once +/** + * @file yield_cranks.hpp + * @brief The decisions behind the depot's two yield cranks -- which `sysio.swap` + * yield pools are worth a `tickyield`, which `sysio.liq` shadows are worth a + * `queueyield` -- and the per-key spacing that keeps one operator from + * cranking the same pool every poll. 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 + +namespace sysio::batch_operator_detail { + +/// `sysio.swap` identifiers the yield-tick crank touches. +namespace swap { + constexpr auto account = "sysio.swap"; + constexpr auto table_stat = "stat"; + constexpr auto table_reservoirs = "reservoirs"; + constexpr auto action_tickyield = "tickyield"; + namespace field { + constexpr auto supply = "supply"; + constexpr auto yield_leg = "yield_leg"; + constexpr auto conversion_horizon_sec = "conversion_horizon_sec"; + constexpr auto depth_cap_bps = "depth_cap_bps"; + constexpr auto clip_floor = "clip_floor"; + constexpr auto balance = "balance"; + constexpr auto quantity = "quantity"; + constexpr auto symbol_code = "symbol_code"; ///< the kv key of `stat` / `reservoirs` + constexpr auto pair_token = "pair_token"; ///< `tickyield`'s argument + } +} + +/// `sysio.liq` identifiers the pending-yield crank touches. +namespace liq { + constexpr auto account = "sysio.liq"; + constexpr auto table_liqpending = "liqpending"; + 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 + } +} + +/// The kv row wrapper `get_table_rows` returns when `values_only` is off. +namespace kv_row { + constexpr auto key = "key"; + constexpr auto value = "value"; +} + +/// The amount of the ABI-rendered asset (`"1.000000000 SYM"`) under `field`; 0 +/// when the field is absent or does not parse -- a crank decides from what it can +/// read and pushes nothing on a row it cannot. +inline int64_t asset_amount(const fc::variant_object& row, const char* field) { + auto it = row.find(field); + if (it == row.end() || !it->value().is_string()) return 0; + try { + return chain::asset::from_string(it->value().as_string()).get_amount(); + } catch (const fc::exception&) { + return 0; + } +} + +/// The kv key of a `stat` / `reservoirs` / `liqpending` row (the `{key, value}` +/// wrapper): the pool or shadow symbol code, or nullopt when the row carries none. +inline std::optional row_symbol_code(const fc::variant_object& row) { + auto key = row.find(kv_row::key); + if (key == row.end() || !key->value().is_object()) return std::nullopt; + const auto& key_obj = key->value().get_object(); + auto code = key_obj.find(swap::field::symbol_code); + if (code == key_obj.end()) return std::nullopt; + return chain::symbol_code{ code->value().as_uint64() }; +} + +/// The name of a symbol code -- the spelling `tickyield` / `queueyield` take and +/// the logs use. +inline std::string symbol_code_name(chain::symbol_code code) { + return chain::symbol(code.value << 8).name(); +} + +/// The `value` of a `{key, value}` row, or nullopt when the row carries none. +inline std::optional row_value(const fc::variant_object& row) { + auto value = row.find(kv_row::value); + if (value == row.end() || !value->value().is_object()) return std::nullopt; + return value->value().get_object(); +} + +/// True iff a `sysio.swap::stat` row is a yield pool `tickyield` would accept: a +/// shadow `yield_leg` plus all three tick parameters set -- the exact preconditions +/// the action `check()`s, so a crank never pays for a push the contract is +/// guaranteed to reject. +inline bool is_tickable_pool(const fc::variant_object& stat_row) { + auto positive = [&](const char* field) { + auto it = stat_row.find(field); + return it != stat_row.end() && it->value().as_int64() > 0; + }; + auto leg = stat_row.find(swap::field::yield_leg); + return leg != stat_row.end() && !leg->value().is_null() && + positive(swap::field::conversion_horizon_sec) && + positive(swap::field::depth_cap_bps) && + positive(swap::field::clip_floor); +} + +/// One push per `interval` per key from this operator. `tickyield` is harmless to +/// crank every block, but every ACTIVE operator polls every few seconds and a +/// reservoir drains over a horizon of hours, so without spacing the whole group +/// would push a tick per pool per poll for the length of the sale. +class crank_spacing { +public: + bool due(const std::string& key, fc::time_point now, fc::microseconds interval) const { + auto it = _last_push.find(key); + return it == _last_push.end() || now - it->second >= interval; + } + void mark(const std::string& key, fc::time_point now) { _last_push[key] = now; } + +private: + std::map _last_push; +}; + +} // namespace sysio::batch_operator_detail 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 dad28e2d36..57a5f7d669 100644 --- a/plugins/batch_operator_plugin/test/mocks/mock_outpost_client.hpp +++ b/plugins/batch_operator_plugin/test/mocks/mock_outpost_client.hpp @@ -100,9 +100,27 @@ class mock_outpost_client : public sysio::outpost_client { return commit_response(call); } + struct crank_call { + uint32_t epoch_index = 0; + fc::microseconds deadline; + }; + + /// crank_outpost response — scripted per call; the default cranks nothing. + std::function crank_response = [](const crank_call&) {}; + + void crank_outpost(uint32_t epoch_index, fc::microseconds deadline) override { + crank_call call{epoch_index, deadline}; + { + std::lock_guard lock(_mx); + crank_calls.push_back(call); + } + crank_response(call); + } + std::vector outbound_calls; std::vector inbound_calls; std::vector commit_calls; + std::vector crank_calls; std::vector caller_address; private: diff --git a/plugins/batch_operator_plugin/test/test_outpost_opp_job.cpp b/plugins/batch_operator_plugin/test/test_outpost_opp_job.cpp index 2f5000eb9b..d1dabc7397 100644 --- a/plugins/batch_operator_plugin/test/test_outpost_opp_job.cpp +++ b/plugins/batch_operator_plugin/test/test_outpost_opp_job.cpp @@ -159,6 +159,105 @@ BOOST_AUTO_TEST_CASE(run_outbound_only_delivers_once_per_epoch) { BOOST_CHECK_EQUAL(client->outbound_calls[1].epoch_index, 6u); } +// The outpost's per-epoch cranks ride the first successful delivery of an epoch: +// once per epoch, after the envelope landed, with the delivery's own deadline. +BOOST_AUTO_TEST_CASE(run_outbound_cranks_the_outpost_once_per_epoch_after_delivery) { + auto client = make_client(CHAIN_KIND_SVM, 1, 0); + mock_depot_ops depot; + depot.epoch = 5; + + outbound_envelope_record rec; + rec.raw_envelope = {'x'}; + depot.pending_response = [rec](uint64_t, uint32_t) -> std::optional { + return rec; + }; + + outpost_opp_job job(client, depot, kDeadline); + job.run_outbound(); + BOOST_REQUIRE_EQUAL(client->outbound_calls.size(), 1u); + BOOST_REQUIRE_EQUAL(client->crank_calls.size(), 1u); + BOOST_CHECK_EQUAL(client->crank_calls[0].epoch_index, 5u); + BOOST_CHECK(client->crank_calls[0].deadline == kDeadline); + + job.run_outbound(); // Same epoch: neither a delivery nor a crank. + BOOST_CHECK_EQUAL(client->crank_calls.size(), 1u); + + depot.epoch = 6; + job.run_outbound(); + BOOST_CHECK_EQUAL(client->crank_calls.size(), 2u); + BOOST_CHECK_EQUAL(client->crank_calls[1].epoch_index, 6u); +} + +// The path-2 consensus re-delivery of an already-delivered epoch is not a new +// delivery: it cranks nothing a second time. +BOOST_AUTO_TEST_CASE(run_outbound_consensus_retry_does_not_crank_again) { + auto client = make_client(CHAIN_KIND_SVM, 1, 0); + mock_depot_ops depot; + depot.epoch = 5; + + outbound_envelope_record rec; + rec.raw_envelope = {'x'}; + depot.pending_response = [rec](uint64_t, uint32_t) -> std::optional { + return rec; + }; + + outpost_opp_job job(client, depot, kDeadline); + job.run_outbound(); + BOOST_REQUIRE_EQUAL(client->crank_calls.size(), 1u); + + depot.epoch_boundary_past = true; + job.run_outbound(); // The consensus retry re-delivers the same epoch... + BOOST_CHECK_EQUAL(client->outbound_calls.size(), 2u); + BOOST_CHECK_EQUAL(client->crank_calls.size(), 1u); // ...without cranking again. +} + +// A failed delivery cranks nothing: the crank belongs to a landed envelope. +BOOST_AUTO_TEST_CASE(run_outbound_does_not_crank_when_delivery_fails) { + auto client = make_client(CHAIN_KIND_SVM, 1, 0); + mock_depot_ops depot; + depot.epoch = 5; + + outbound_envelope_record rec; + rec.raw_envelope = {'x'}; + depot.pending_response = [rec](uint64_t, uint32_t) -> std::optional { + return rec; + }; + client->deliver_response = [](const auto&) -> std::string { FC_THROW("rpc down"); }; + + outpost_opp_job job(client, depot, kDeadline); + job.run_outbound(); + BOOST_CHECK_EQUAL(client->outbound_calls.size(), 1u); + BOOST_CHECK(client->crank_calls.empty()); +} + +// A failed crank is logged and retried by the next epoch's delivery; it never +// un-marks the delivery that already landed. +BOOST_AUTO_TEST_CASE(run_outbound_crank_failure_leaves_the_delivery_marked) { + auto client = make_client(CHAIN_KIND_SVM, 1, 0); + mock_depot_ops depot; + depot.epoch = 5; + + outbound_envelope_record rec; + rec.raw_envelope = {'x'}; + depot.pending_response = [rec](uint64_t, uint32_t) -> std::optional { + return rec; + }; + client->crank_response = [](const auto&) { FC_THROW("crank refused"); }; + + outpost_opp_job job(client, depot, kDeadline); + job.run_outbound(); + BOOST_CHECK_EQUAL(client->outbound_calls.size(), 1u); + BOOST_CHECK_EQUAL(client->crank_calls.size(), 1u); + + job.run_outbound(); // The epoch is delivered: no re-delivery, no second crank. + BOOST_CHECK_EQUAL(client->outbound_calls.size(), 1u); + BOOST_CHECK_EQUAL(client->crank_calls.size(), 1u); + + depot.epoch = 6; + job.run_outbound(); + BOOST_CHECK_EQUAL(client->crank_calls.size(), 2u); +} + BOOST_AUTO_TEST_CASE(run_outbound_swallows_exceptions_and_does_not_mark_epoch) { auto client = make_client(CHAIN_KIND_EVM, 0, 31337); mock_depot_ops depot; diff --git a/plugins/batch_operator_plugin/test/test_yield_cranks.cpp b/plugins/batch_operator_plugin/test/test_yield_cranks.cpp new file mode 100644 index 0000000000..2a35bc800b --- /dev/null +++ b/plugins/batch_operator_plugin/test/test_yield_cranks.cpp @@ -0,0 +1,107 @@ +#include "../src/yield_cranks.hpp" + +#include + +#include +#include + +#include +#include +#include + +using namespace sysio::batch_operator_detail; +using mvo = fc::mutable_variant_object; + +namespace { + +constexpr auto pool_code = "POOLB"; +constexpr auto shadow_code = "LIQSOL"; +constexpr int64_t one_token = 1'000'000'000; // one whole 9-decimal token, in subunits + +uint64_t raw_code(const char* code) { return sysio::chain::symbol(0, code).to_symbol_code().value; } + +/// A `sysio.swap::stat` value as the chain renders it for a yield pool: the shadow +/// leg set and the three tick parameters configured by `setyield`. +mvo yield_pool_row() { + return mvo() + ("supply", std::string("100.000000000 ") + pool_code) + ("yield_leg", mvo()("sym", std::string("9,") + shadow_code)("contract", "sysio.liq")) + ("conversion_horizon_sec", 86400) + ("depth_cap_bps", 50) + ("clip_floor", 1000); +} + +/// A `{key, value}` kv row with `code` as the symbol-code key. +mvo kv(const char* code, mvo value) { + return mvo()("key", mvo()("symbol_code", raw_code(code)))("value", std::move(value)); +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(yield_cranks_tests) + +BOOST_AUTO_TEST_CASE(a_yield_pool_with_its_tick_parameters_is_tickable) { + BOOST_CHECK(is_tickable_pool(yield_pool_row())); +} + +BOOST_AUTO_TEST_CASE(a_plain_pool_is_not_tickable) { + auto row = yield_pool_row(); + row.set("yield_leg", fc::variant()); // the optional is empty: no shadow leg + BOOST_CHECK(!is_tickable_pool(row)); +} + +BOOST_AUTO_TEST_CASE(a_yield_pool_without_tick_parameters_is_not_tickable) { + for (const char* field : {"conversion_horizon_sec", "depth_cap_bps", "clip_floor"}) { + auto zeroed = yield_pool_row(); + zeroed.set(field, 0); + BOOST_CHECK_MESSAGE(!is_tickable_pool(zeroed), field << " = 0 must refuse the tick"); + auto missing = yield_pool_row(); + missing.erase(field); + BOOST_CHECK_MESSAGE(!is_tickable_pool(missing), "absent " << field << " must refuse the tick"); + } +} + +BOOST_AUTO_TEST_CASE(asset_amount_reads_a_rendered_asset_and_nothing_else) { + BOOST_CHECK_EQUAL(12 * one_token, asset_amount(mvo()("quantity", "12.000000000 WIRE"), "quantity")); + BOOST_CHECK_EQUAL(0, asset_amount(mvo()("quantity", "12.000000000 WIRE"), "balance")); // absent + BOOST_CHECK_EQUAL(0, asset_amount(mvo()("quantity", 12), "quantity")); // not rendered + BOOST_CHECK_EQUAL(0, asset_amount(mvo()("quantity", "not an asset"), "quantity")); +} + +BOOST_AUTO_TEST_CASE(row_symbol_code_reads_the_kv_key_and_renders_it_for_an_action) { + const auto row = kv(shadow_code, mvo()("quantity", "3.000000000 LIQSOL")); + const auto code = row_symbol_code(row); + BOOST_REQUIRE(code.has_value()); + BOOST_CHECK_EQUAL(raw_code(shadow_code), code->value); + // The symbol_code ABI argument of `tickyield` / `queueyield` is the code's name. + BOOST_CHECK_EQUAL(std::string(shadow_code), symbol_code_name(*code)); + fc::variant rendered; + fc::to_variant(*code, rendered); + BOOST_CHECK_EQUAL(std::string(shadow_code), rendered.as_string()); + + BOOST_CHECK(!row_symbol_code(mvo()("value", mvo())).has_value()); // no key + BOOST_CHECK(!row_symbol_code(mvo()("key", "0a0b")("value", mvo())).has_value()); // undecoded key +} + +BOOST_AUTO_TEST_CASE(row_value_unwraps_the_kv_wrapper) { + const auto row = kv(pool_code, mvo()("balance", mvo()("quantity", "2.000000000 LIQSOL")("contract", "sysio.liq"))); + const auto value = row_value(row); + BOOST_REQUIRE(value.has_value()); + BOOST_CHECK_EQUAL(2 * one_token, asset_amount((*value)["balance"].get_object(), "quantity")); + BOOST_CHECK(!row_value(mvo()("key", mvo())).has_value()); +} + +BOOST_AUTO_TEST_CASE(crank_spacing_allows_one_push_per_interval_per_key) { + crank_spacing spacing; + const auto interval = fc::seconds(60); + const auto t0 = fc::time_point::now(); + + BOOST_CHECK(spacing.due(pool_code, t0, interval)); + spacing.mark(pool_code, t0); + BOOST_CHECK(!spacing.due(pool_code, t0 + fc::seconds(59), interval)); + BOOST_CHECK(spacing.due(pool_code, t0 + fc::seconds(60), interval)); + // Keys are independent: a tick on one pool does not delay another. + BOOST_CHECK(spacing.due(shadow_code, t0, interval)); +} + +BOOST_AUTO_TEST_SUITE_END() 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 4c87d9423f..1306111b1d 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 @@ -166,6 +166,33 @@ class outpost_client { const std::vector& uic_bytes, fc::microseconds deadline) = 0; + /** + * @brief OUTPOST CRANKS — drive the outpost's permissionless per-epoch + * instructions that nothing on the outpost schedules. + * + * Called by the outbound relay job once per epoch, right after this + * operator's envelope delivery for that epoch lands, so the cranks ride the + * same cadence and the same operator key as the delivery. Best-effort: a + * failure is logged by the job and retried at the next epoch, and it never + * travels back into the delivery result. + * + * The default cranks nothing. The Solana relay overrides it with the liqSOL + * pool's `report_liq_yield` (PostLaunch only; a no-op on chain when nothing + * new was claimed since the previous report); the Ethereum relay with the + * syndication pool's `realizeYield`, on the pool the outpost registers as its + * `DESYNDICATE_LIQ` handler (idle until one is, and quiet when the pool has + * nothing to report). + * + * @param epoch_index The WIRE epoch whose delivery just landed. + * @param deadline Upper bound on the total time spent talking to the + * remote chain for this call. + * @throws fc::exception on RPC failure, tx revert, or deadline expiry. + */ + virtual void crank_outpost(uint32_t epoch_index, fc::microseconds deadline) { + (void)epoch_index; + (void)deadline; + } + protected: /// Throw `fc::timeout_exception` if the wall-clock has crossed `deadline_abs`. /// Called by concretes before each blocking RPC to bound how long a hung diff --git a/plugins/outpost_ethereum_client_plugin/README.md b/plugins/outpost_ethereum_client_plugin/README.md index 2b4c2d98c1..b61a67d27a 100644 --- a/plugins/outpost_ethereum_client_plugin/README.md +++ b/plugins/outpost_ethereum_client_plugin/README.md @@ -89,15 +89,17 @@ endpoint cannot occupy a cron worker past its budget. Three typed wrappers are built over the shared connection, each only when its address was supplied to `create_outpost_client`; passing an empty string for the others is normal, and calling an SPI method whose -wrapper was not provisioned asserts with a message naming the missing address. State-changing calls go -through `create_tx_and_confirm`, which returns only after on-chain inclusion plus confirmations — OPP writes -are consensus-critical and must not silently drop. +wrapper was not provisioned asserts with a message naming the missing address. A fourth, the syndication +pool's, is bound by the crank once the outpost names the pool (see [Outpost cranks](#outpost-cranks)). +State-changing calls go through `create_tx_and_confirm`, which returns only after on-chain inclusion plus +confirmations — OPP writes are consensus-critical and must not silently drop. | Wrapper | Contract | Members | |---|---|---| | `opp_contract_client` | `OPP.sol` | `emitOutboundEnvelope(uint32)` (recovery-only write; no in-tree steady-state caller), `getLatestOutboundEnvelope()` view | -| `opp_inbound_contract_client` | `OPPInbound.sol` | `epochIn(uint32,uint16,uint16,uint32,bytes)`, `discardEnvelopeChunks()`, `nextEpochIndex()` view, `envelopeChunkState(address)` view | +| `opp_inbound_contract_client` | `OPPInbound.sol` | `epochIn(uint32,uint16,uint16,uint32,bytes)`, `discardEnvelopeChunks()`, `nextEpochIndex()` view, `envelopeChunkState(address)` view, `attestationHandlers(uint16)` view | | `operator_registry_contract_client` | `OperatorRegistry.sol` | `commit(bytes)` | +| `syndication_pool_contract_client` | `SyndicationPool.sol` (Wire-Network/wire-ethereum#207) | `realizeYield()` | ### Outbound delivery and chunking @@ -138,6 +140,26 @@ returning the transaction hash only after confirmation. The outpost binds the si claimed ACTIVE roster identity before queuing the unchanged bytes; the WIRE depot remains authoritative for the embedded permission signature and bond. +### Outpost cranks + +Once per epoch, right after this operator's envelope delivery lands, the outbound relay job calls +`crank_outpost`. On Ethereum that is the liq syndication pool's `realizeYield()` +(Wire-Network/wire-ethereum#207): the pool folds the liqETH yield it accrued since its last report into its +principal and reports the delta as one `LIQ_YIELD` attestation — the counterpart of the Solana relay's +`report_liq_yield`. Nothing has to tell the relay the pool's address: it reads +`OPPInbound.attestationHandlers(DESYNDICATE_LIQ)` at `latest`, because the pool registers itself as that +handler when its OPP endpoint is configured, binds a `syndication_pool_contract_client` to the address that +comes back, and re-binds if it changes. `address(0)` and `ATTESTATION_BLACKHOLE` mean no pool, and an ABI set +without `realizeYield` means an outpost deployment that predates the pool; both leave the crank idle at debug +level. + +`realizeYield()` refuses at estimate time, before any gas is spent, and the relay reads the pool's own three +refusals as outcomes rather than failures: `WIRE_NoYield()` and `WIRE_YieldBelowDeadband(uint64,uint64)` are +the quiet steady state (debug), `WIRE_PoolUnderbacked(uint64,uint64)` is a warning (the loss path is not in +that contract). Any other revert — a signer without the pool's `yield_operator` role, a paused endpoint, a +foreign implementation — and any transport failure propagate to the job, which logs the failed crank and +retries with the next epoch's delivery. + ## Enabling / configuration ### `config.ini` @@ -257,7 +279,9 @@ resolution and verification (explicit, RPC-resolved, mismatched, malformed, out- transport failure), signature-provider rejection cases, contract-client construction and ABI encoding, the chunk-count and chunk-resume decision tables, and every delivery path — single-chunk, multi-chunk in order, resume from a staged high-water mark, peer-owned header, superseded header, reverting discard, epoch -advanced, deadline abandonment, and the empty and over-cap rejections. +advanced, deadline abandonment, and the empty and over-cap rejections — and the outpost crank: idle without +the pool ABI or a registered handler, binding the registered pool and re-binding on a change, the pool's own +three refusals, unrecognised reverts and protocol errors, and deadline abandonment. `outpost_ethereum_transaction_policy_tests` covers the expenditure-policy boundary: that a rejection happens before signing or broadcasting, that exact caps pass through once, that two clients enforce their own policies, that file and CLI configuration produce the expected policies, and that a partial client map is 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 62f6f781a0..b0e89cacb3 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 @@ -77,6 +77,14 @@ struct opp_inbound_contract_client : ethereum_contract_client { /// against this ABI entry (the same shape `read_inbound_envelope` uses for /// `getLatestOutboundEnvelope`). ethereum_contract_call_fn envelope_chunk_state; + /// `attestationHandlers(uint16 attestationType)` view — the outpost's own + /// inbound routing table: the `IOPPReceiver` registered for one attestation + /// type, `address(0)` when none is and `ATTESTATION_BLACKHOLE` when governance + /// dropped the type. The relay discovers the liq syndication pool through it, + /// since the pool registers itself for `DESYNDICATE_LIQ`, so neither a depot + /// row nor operator config has to name the pool. Returns the raw `eth_call` + /// hex, one left-padded address word. + ethereum_contract_call_fn attestation_handlers; opp_inbound_contract_client(const ethereum_client_ptr& client, const address_compat_type& contract_address, @@ -87,7 +95,31 @@ struct opp_inbound_contract_client : ethereum_contract_client { , discard_envelope_chunks( create_tx_and_confirm(get_abi("discardEnvelopeChunks"))) , next_epoch_index(create_call(get_abi("nextEpochIndex"))) - , envelope_chunk_state(create_call(get_abi("envelopeChunkState"))) {} + , envelope_chunk_state(create_call(get_abi("envelopeChunkState"))) + , attestation_handlers(create_call(get_abi("attestationHandlers"))) {} +}; + +/// Typed contract client for wire-ethereum's `SyndicationPool.sol`, the liq +/// syndication surface (Wire-Network/wire-ethereum#207). The relay reaches it for +/// one crank, `realizeYield()`: the pool folds the liqETH yield it accrued since +/// its last report into its principal and reports the delta as one `LIQ_YIELD` +/// attestation, the Ethereum counterpart of liqsol-core's `report_liq_yield`. +/// The call is access-restricted on chain (`yield_operator`), so the relay's +/// signer must hold that role on the outpost's AccessManager. +struct syndication_pool_contract_client : ethereum_contract_client { + /// `realizeYield()` — confirmed like every other OPP write. The pool refuses + /// at estimate time, before any gas is spent, with `WIRE_NoYield()` or + /// `WIRE_YieldBelowDeadband(uint64,uint64)` when there is nothing to report + /// and with `WIRE_PoolUnderbacked(uint64,uint64)` when its balance fell below + /// the principal; the relay reads those as outcomes of the crank + /// (`classify_realize_yield_revert`), not as failures of it. + ethereum_contract_tx_fn realize_yield; + + syndication_pool_contract_client(const ethereum_client_ptr& client, + const address_compat_type& contract_address, + const std::vector& contracts) + : ethereum_contract_client(client, contract_address, contracts) + , realize_yield(create_tx_and_confirm(get_abi("realizeYield"))) {} }; /// Typed contract client for OperatorRegistry.sol. Carries the actions 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 33979fb6d0..f200c75947 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 @@ -124,6 +124,35 @@ chunk_resume_decision decide_chunk_resume(const envelope_chunk_state& staged, uint16_t total_chunks, uint32_t total_bytes); +/// Why `SyndicationPool.realizeYield()` refused, read from the node's revert bytes. +enum class realize_yield_refusal { + /// `WIRE_NoYield()`: the pool balance equals the principal. + no_yield, + /// `WIRE_YieldBelowDeadband(uint64 delta, uint64 deadband)`: accrued, but under + /// the contract's reporting floor. + below_deadband, + /// `WIRE_PoolUnderbacked(uint64 balanceDepot, uint64 principal)`: the balance + /// fell below the principal, a loss the `LIQYield` carrier cannot express. + underbacked +}; + +/// Classify `realizeYield()`'s revert bytes. `std::nullopt` for anything that is +/// not one of the pool's own three refusals, exactly shaped (the selector alone, +/// or the selector plus two words): a role error, a paused endpoint or a foreign +/// implementation must not pass as a quiet no-op. +/// +/// @param revert_data `json_rpc_error::data`, the node's revert bytes as `0x`-hex. +std::optional classify_realize_yield_revert(std::string_view revert_data); + +/// The address in one raw `eth_call` word, as `0x`-hex, or `std::nullopt` when +/// the hex is not exactly one word of hex digits with a zero 12-byte pad. +std::optional address_from_word(std::string_view raw_hex); + +/// True when `handler_address` names a contract the outpost routes to: neither +/// `address(0)` (nothing registered) nor `ATTESTATION_BLACKHOLE` (governance +/// dropped the type). +bool is_routable_handler(std::string_view handler_address); + } // namespace outpost_ethereum_client_detail /** @@ -165,6 +194,13 @@ class outpost_ethereum_client : public outpost_client { const std::vector& uic_bytes, fc::microseconds deadline) override; + /// The Ethereum crank: `SyndicationPool.realizeYield()` on the pool the + /// outpost registers as its `DESYNDICATE_LIQ` handler. Idle, at debug level, + /// while the ABI set carries no `realizeYield` (a deployment that predates + /// the pool) or no handler is registered; quiet when the pool has nothing to + /// report. Any other revert and any transport failure propagate. + void crank_outpost(uint32_t epoch_index, fc::microseconds deadline) override; + // Expose for inspection / tests const ethereum_client_entry_ptr& entry() const { return _entry; } const std::string& opp_address() const { return _opp_addr; } @@ -175,8 +211,23 @@ class outpost_ethereum_client : public outpost_client { /// compares it against `envelopeChunkState`'s `owner` on every multi-chunk /// delivery, so it is cached rather than re-derived per tick. const std::string& signer_address_hex() const { return _signer_address_hex; } + /// The liq syndication pool the crank drives, `0x`-hex, or empty until the + /// outpost's `DESYNDICATE_LIQ` handler has been discovered. + const std::string& syndication_pool_address() const { return _syndication_pool_addr; } + /// Bind the pool wrapper the crank drives to `address`, replacing whatever was + /// bound. Discovery goes through this; tests bind a wrapper whose + /// `realize_yield` is a stub. + void bind_syndication_pool(std::string address, std::shared_ptr client); private: + /// The `DESYNDICATE_LIQ` handler the outpost routes to, read from + /// `OPPInbound.attestationHandlers` at `latest` (the routing table is + /// configuration, not delivered content), or `std::nullopt` when none is + /// registered or the word did not decode. + /// + /// @throws fc::exception on transport failure. + std::optional discover_syndication_pool(); + /// Read `OPPInbound.envelopeChunkState(self)` at `latest` and decode it. /// /// `latest` is correct here (unlike `read_inbound_envelope`, which reads at @@ -211,6 +262,11 @@ class outpost_ethereum_client : public outpost_client { std::shared_ptr _opp_client; std::shared_ptr _opp_inbound_client; std::shared_ptr _operator_registry_client; // nullable + /// The plugin's loaded ABI set, kept for the wrapper bound after construction. + std::vector _abis; + /// See `syndication_pool_address()` / `bind_syndication_pool`. + std::string _syndication_pool_addr; + std::shared_ptr _syndication_pool_client; // nullable /// Cached `0x`-hex signer address — see `signer_address_hex()`. std::string _signer_address_hex; uint64_t _outpost_id; 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 05366ac672..83dd6df90c 100644 --- a/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client.cpp +++ b/plugins/outpost_ethereum_client_plugin/src/outpost_ethereum_client.cpp @@ -29,6 +29,7 @@ namespace detail = outpost_ethereum_client_detail; constexpr std::string_view OP_DELIVER_OUTBOUND = "deliver_outbound_envelope"; constexpr std::string_view OP_READ_INBOUND = "read_inbound_envelope"; constexpr std::string_view OP_UW_COMMIT = "uw_commit"; +constexpr std::string_view OP_REALIZE_YIELD = "crank_outpost:realizeYield"; /// Execution APIs code for a call the node executed and that reverted, as distinct from a /// protocol error such as a parse failure, where the node never ran the call at all. Not an @@ -42,6 +43,18 @@ constexpr int ethereum_execution_reverted_code = 3; /// four bytes the contract actually emits. constexpr auto chunk_buffer_missing_signature = "OPP_ChunkBufferMissing(address)"; +/// The three refusals `SyndicationPool.realizeYield()` raises for its own reasons +/// (Wire-Network/wire-ethereum#207), hashed the same way; +/// `realize_yield_refusal_selectors_are_pinned` holds each to the bytes the contract emits. +constexpr auto no_yield_signature = "WIRE_NoYield()"; +constexpr auto yield_below_deadband_signature = "WIRE_YieldBelowDeadband(uint64,uint64)"; +constexpr auto pool_underbacked_signature = "WIRE_PoolUnderbacked(uint64,uint64)"; + +/// `ATTESTATION_BLACKHOLE` in wire-ethereum's `OPPCommon.sol`: the handler governance +/// registers to drop an attestation type on purpose. Mirror duty, like +/// `ETHEREUM_MAX_CHUNK_BYTES`. +constexpr auto attestation_blackhole_address = "0x000000000000000000000000000000000000dead"; + /// ABI entry names and decoded-output field keys of the outpost contracts this /// client drives. Grouped per contract so a Solidity rename is one edit here /// rather than a scatter of string literals. @@ -53,9 +66,14 @@ constexpr auto data = "data_"; } } // namespace opp_abi +namespace syndication_pool_abi { +constexpr auto tx_realize_yield = "realizeYield"; +} // namespace syndication_pool_abi + namespace opp_inbound_abi { constexpr auto view_envelope_chunk_state = "envelopeChunkState"; constexpr auto view_next_epoch_index = "nextEpochIndex"; +constexpr auto view_attestation_handlers = "attestationHandlers"; namespace field { constexpr auto epoch_index = "epochIndex"; constexpr auto owner = "owner"; @@ -117,6 +135,20 @@ std::optional abi_uint_output(const fc::variant& value) { return std::nullopt; } +/// The four-byte selector of a Solidity function or error `signature`, as hex. +std::string selector_hex(const char* signature) { + return fc::crypto::keccak256::hash(std::string(signature)) + .str() + .substr(0, EVM_SELECTOR_BYTES * HEX_CHARS_PER_BYTE); +} + +/// True when `abis` declares a function named `name`. +bool has_function_abi(const std::vector& abis, std::string_view name) { + return std::ranges::any_of(abis, [&](const eth::abi::contract& contract) { + return contract.type == eth::abi::invoke_target_type::function && contract.name == name; + }); +} + } // namespace namespace outpost_ethereum_client_detail { @@ -137,8 +169,7 @@ bool is_chunk_buffer_missing_revert(std::string_view revert_data, std::string_vi // than the selector test: an error taking different arguments hashes differently. if (data.size() != selector_chars + word_chars) return false; - const auto expected = fc::crypto::keccak256::hash(std::string(chunk_buffer_missing_signature)).str(); - if (!same_hex(data.substr(0, selector_chars), std::string_view(expected).substr(0, selector_chars))) + if (!same_hex(data.substr(0, selector_chars), selector_hex(chunk_buffer_missing_signature))) return false; // The error's sole argument is the contract's `msg.sender`, so the word must be this @@ -189,6 +220,43 @@ chunk_resume_decision decide_chunk_resume(const envelope_chunk_state& staged, return {chunk_resume_action::resume, staged.received_chunks}; } +std::optional classify_realize_yield_revert(std::string_view revert_data) { + constexpr size_t selector_chars = EVM_SELECTOR_BYTES * HEX_CHARS_PER_BYTE; + constexpr size_t word_chars = EVM_ABI_WORD_BYTES * HEX_CHARS_PER_BYTE; + + const auto data = strip_hex_prefix(revert_data); + if (data.size() < selector_chars) return std::nullopt; + const auto selector = data.substr(0, selector_chars); + const auto argument_chars = data.size() - selector_chars; + // Exact shape, as for `OPP_ChunkBufferMissing`: an error with other arguments hashes + // differently, and a payload that does not fit the error is not that error. + const auto is = [&](const char* signature, size_t words) { + return argument_chars == words * word_chars && same_hex(selector, selector_hex(signature)); + }; + if (is(no_yield_signature, 0)) return realize_yield_refusal::no_yield; + if (is(yield_below_deadband_signature, 2)) return realize_yield_refusal::below_deadband; + if (is(pool_underbacked_signature, 2)) return realize_yield_refusal::underbacked; + return std::nullopt; +} + +std::optional address_from_word(std::string_view raw_hex) { + constexpr size_t word_chars = EVM_ABI_WORD_BYTES * HEX_CHARS_PER_BYTE; + constexpr size_t address_chars = EVM_ADDRESS_BYTES * HEX_CHARS_PER_BYTE; + + const auto word = strip_hex_prefix(raw_hex); + if (word.size() != word_chars) return std::nullopt; + if (!std::ranges::all_of(word, [](unsigned char c) { return std::isxdigit(c) != 0; })) return std::nullopt; + // An address word is left-padded with zeros; anything else in the pad is not an address. + if (word.find_first_not_of('0') < word_chars - address_chars) return std::nullopt; + return "0x" + std::string(word.substr(word_chars - address_chars)); +} + +bool is_routable_handler(std::string_view handler_address) { + const auto address = strip_hex_prefix(handler_address); + if (address.empty() || address.find_first_not_of('0') == std::string_view::npos) return false; + return !same_evm_address(handler_address, attestation_blackhole_address); +} + } // namespace outpost_ethereum_client_detail outpost_ethereum_client::outpost_ethereum_client( @@ -203,6 +271,7 @@ outpost_ethereum_client::outpost_ethereum_client( , _opp_addr(std::move(opp_addr)) , _opp_inbound_addr(std::move(opp_inbound_addr)) , _operator_registry_addr(std::move(operator_registry_addr)) + , _abis(std::move(abis)) , _outpost_id(chain_code) , _chain_id(chain_id) { FC_ASSERT(_entry && _entry->client, "ethereum_client_entry must carry a client"); @@ -213,17 +282,19 @@ outpost_ethereum_client::outpost_ethereum_client( // pass empty strings for the addresses it doesn't use; the methods // covering an unprovisioned wrapper assert on entry with a clear // diagnostic. Per `outpost-client-spi.md`: address configuration is - // a per-caller concern; the SPI shape stays uniform. + // a per-caller concern; the SPI shape stays uniform. The syndication + // pool's wrapper is the exception: the outpost names the pool, so the + // crank binds it when it discovers the address. if (!_opp_addr.empty()) { - _opp_client = _entry->client->get_contract(_opp_addr, abis); + _opp_client = _entry->client->get_contract(_opp_addr, _abis); } if (!_opp_inbound_addr.empty()) { _opp_inbound_client = - _entry->client->get_contract(_opp_inbound_addr, abis); + _entry->client->get_contract(_opp_inbound_addr, _abis); } if (!_operator_registry_addr.empty()) { _operator_registry_client = - _entry->client->get_contract(_operator_registry_addr, abis); + _entry->client->get_contract(_operator_registry_addr, _abis); } // Every OPPInbound staging header is bound to the delivering signer, so the @@ -651,4 +722,88 @@ std::string outpost_ethereum_client::uw_commit( return tx_hash; } +void outpost_ethereum_client::bind_syndication_pool( + std::string address, std::shared_ptr client) { + FC_ASSERT(client, "outpost_ethereum_client[{}]: bind_syndication_pool needs a wrapper", to_string()); + _syndication_pool_addr = std::move(address); + _syndication_pool_client = std::move(client); +} + +std::optional outpost_ethereum_client::discover_syndication_pool() { + // The routing table is configuration, not delivered content, so `latest` is right for the + // same reason it is for the staging-header read. + uint16_t attestation_type = static_cast( + magic_enum::enum_integer(sysio::opp::types::ATTESTATION_TYPE_DESYNDICATE_LIQ)); + const auto raw = _opp_inbound_client->attestation_handlers(eth::block_tag_t::latest, attestation_type); + if (!raw.is_string()) { + wlog("outpost_ethereum_client[{}]: attestationHandlers returned non-string variant", to_string()); + return std::nullopt; + } + const auto handler = detail::address_from_word(raw.as_string()); + if (!handler) { + wlog("outpost_ethereum_client[{}]: attestationHandlers returned an unparsable word: {}", + to_string(), raw.as_string()); + return std::nullopt; + } + if (!detail::is_routable_handler(*handler)) { + dlog("outpost_ethereum_client[{}]: no DESYNDICATE_LIQ handler is registered -- no syndication " + "pool to crank", + to_string()); + return std::nullopt; + } + return handler; +} + +void outpost_ethereum_client::crank_outpost(uint32_t epoch_index, fc::microseconds deadline) { + // The pool's ABI ships with wire-ethereum #207; an ABI set without `realizeYield` is an + // outpost deployment that predates the pool, and there is nothing to crank on it. Without + // the OPPInbound wrapper there is no routing table to discover the pool from. + if (!_opp_inbound_client || !has_function_abi(_abis, syndication_pool_abi::tx_realize_yield)) { + dlog("outpost_ethereum_client[{}]: no syndication pool ABI -- nothing to crank", to_string()); + return; + } + + const auto deadline_abs = fc::time_point::now() + deadline; + fc::task::deadline_scope rpc_deadline(deadline_abs); + throw_if_past_deadline(deadline_abs, OP_REALIZE_YIELD); + + const auto pool = discover_syndication_pool(); + if (!pool) return; + if (!_syndication_pool_client || !detail::same_evm_address(_syndication_pool_addr, *pool)) { + ilog("outpost_ethereum_client[{}]: syndication pool {} is the outpost's DESYNDICATE_LIQ handler", + to_string(), *pool); + bind_syndication_pool(*pool, + _entry->client->get_contract(*pool, _abis)); + } + + throw_if_past_deadline(deadline_abs, OP_REALIZE_YIELD); + try { + const auto result = _syndication_pool_client->realize_yield(); + ilog("outpost_ethereum_client[{}]: realizeYield sent for epoch {} tx={}", + to_string(), epoch_index, result.as_string()); + } catch (const fc::network::json_rpc::json_rpc_error& e) { + // A revert at estimate time costs no gas. Only the pool's own three refusals are + // outcomes of the crank rather than failures of it; anything else -- a signer without + // the `yield_operator` role, a paused endpoint, a foreign implementation -- is the job's + // to log as a failed crank, exactly like a transport failure. + const auto refusal = + e.code == ethereum_execution_reverted_code + ? detail::classify_realize_yield_revert(e.data.is_string() ? e.data.as_string() : std::string{}) + : std::nullopt; + if (!refusal) throw; + switch (*refusal) { + case detail::realize_yield_refusal::no_yield: + case detail::realize_yield_refusal::below_deadband: + dlog("outpost_ethereum_client[{}]: realizeYield has nothing to report for epoch {} ({})", + to_string(), epoch_index, magic_enum::enum_name(*refusal)); + return; + case detail::realize_yield_refusal::underbacked: + wlog("outpost_ethereum_client[{}]: syndication pool {} is below its principal; realizeYield " + "refused for epoch {} (the loss path is not in that contract)", + to_string(), _syndication_pool_addr, epoch_index); + return; + } + } +} + } // namespace sysio diff --git a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp index 3b5aef4348..3e423fa235 100644 --- a/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp +++ b/plugins/outpost_ethereum_client_plugin/test/test_ethereum_transaction_policy.cpp @@ -160,6 +160,16 @@ ethabi::contract address_argument_function(std::string name) { }; } +/** Build a function ABI with one uint16 argument. */ +ethabi::contract uint16_argument_function(std::string name) { + return ethabi::contract{ + .name = std::move(name), + .type = ethabi::invoke_target_type::function, + .inputs = {ethabi::component_type{"attestationType", ethabi::data_type::uint16}}, + .outputs = {}, + }; +} + /** Build a function ABI with one uint32 argument. */ ethabi::contract uint32_argument_function(std::string name) { return ethabi::contract{ @@ -483,7 +493,8 @@ BOOST_AUTO_TEST_CASE(all_typed_write_wrappers_share_the_policy_enforced_path) { std::string(contract_address), {chunked_epoch_in_function("epochIn"), no_argument_function("nextEpochIndex"), no_argument_function("discardEnvelopeChunks"), - address_argument_function("envelopeChunkState")}, + address_argument_function("envelopeChunkState"), + uint16_argument_function("attestationHandlers")}, }; // Both OPPInbound write wrappers — the per-chunk delivery and the staged // recovery — must be rejected by the policy before signing. @@ -512,6 +523,13 @@ BOOST_AUTO_TEST_CASE(all_typed_write_wrappers_share_the_policy_enforced_path) { uint32_t epoch = 1; expect_policy_rejection([&] { opp.emit_outbound_envelope(epoch); }); + sysio::syndication_pool_contract_client pool{ + client, + std::string(contract_address), + {no_argument_function("realizeYield")}, + }; + expect_policy_rejection([&] { pool.realize_yield(); }); + BOOST_CHECK_EQUAL(sign_count.load(), 0u); BOOST_CHECK_EQUAL(client->broadcast_count, 0u); } 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 a2360f7320..75f2b351e8 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 @@ -27,6 +27,7 @@ #include #include #include +#include #include #include @@ -480,11 +481,21 @@ std::vector serialize_envelope(uint32_t epoch) { /// whose OPPInbound wrapper has every typed callable replaced by a recording /// stub. The caller owns the returned fixture; the stubs capture it by /// reference, so it must not be moved after this returns. -std::unique_ptr create_chunked_delivery_fixture() { - auto fixture = std::make_unique(); - fixture->tester = create_app(); +/// The app, signer, chain connection and client entry every relay fixture +/// stands on. The connection points at a port nothing listens on, so a wrapper +/// a case forgot to stub fails loudly instead of dialing anything. +struct relay_test_stack { + std::unique_ptr tester; + fc::crypto::signature_provider_ptr sig_provider; + ethereum_client_ptr eth_client; + std::shared_ptr entry; +}; - auto sig_provider = fixture->tester->plugin().create_provider( +relay_test_stack create_relay_test_stack() { + relay_test_stack stack; + stack.tester = create_app(); + + stack.sig_provider = stack.tester->plugin().create_provider( std::string(latest_slot_test_entry_id), chain_kind_ethereum, chain_key_type_ethereum, @@ -499,11 +510,25 @@ std::unique_ptr create_chunked_delivery_fixture() { .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, + stack.eth_client = std::make_shared( + stack.sig_provider, std::variant{std::string(latest_slot_test_rpc_url)}, std::move(transaction_policy)); + stack.entry = std::make_shared(); + stack.entry->id = latest_slot_test_entry_id; + stack.entry->signature_provider = stack.sig_provider; + stack.entry->client = stack.eth_client; + stack.entry->chain_id = test_evm_chain_id; + return stack; +} + +std::unique_ptr create_chunked_delivery_fixture() { + auto fixture = std::make_unique(); + auto stack = create_relay_test_stack(); + fixture->tester = std::move(stack.tester); + auto eth_client = stack.eth_client; + auto abis = load_abi_fixture(opp_inbound_abi_fixture); const std::string inbound_address{test_opp_inbound_address}; fixture->inbound = @@ -546,14 +571,8 @@ std::unique_ptr create_chunked_delivery_fixture() { return fc::variant(raw->next_epoch_index_response); }; - 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; - fixture->outpost = std::make_unique( - entry, + stack.entry, /*opp_addr=*/std::string{}, inbound_address, /*operator_registry_addr=*/std::string{}, @@ -592,6 +611,115 @@ void check_chunk_call_sequence(const std::vector& calls, } } +// ── `crank_outpost` fixtures ───────────────────────────────────────────── +constexpr std::string_view syndication_pool_abi_fixture = "ethereum-abi-syndication-pool.json"; +constexpr std::string_view zero_evm_address = "0x0000000000000000000000000000000000000000"; +/// `ATTESTATION_BLACKHOLE` in wire-ethereum's `OPPCommon.sol`. +constexpr std::string_view attestation_blackhole_address = "0x000000000000000000000000000000000000dead"; +constexpr std::string_view test_syndication_pool_address = "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9"; +constexpr std::string_view test_moved_syndication_pool_address = "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9"; +/// `keccak256("WIRE_NoYield()")[0..4]` and the pool's other two refusals, written out so these +/// tests pin the client's own hashing of the signatures (as `chunk_buffer_missing_selector` does). +constexpr std::string_view no_yield_selector = "053716d1"; +constexpr std::string_view yield_below_deadband_selector = "45441430"; +constexpr std::string_view pool_underbacked_selector = "358cc7e9"; +/// `AccessManagedUnauthorized(address)`: what a signer without the `yield_operator` role gets. +constexpr std::string_view access_managed_unauthorized_selector = "068ca9d8"; +constexpr uint64_t test_yield_delta = 5; +constexpr uint64_t test_yield_deadband = 10; + +/// ABI-encode one address as the single word an `address` getter returns. +std::string encode_address_word(std::string_view address_hex) { + std::string_view address = address_hex; + if (address.starts_with("0x") || address.starts_with("0X")) address.remove_prefix(2); + return std::string(hex_prefix) + std::string(evm_abi_word_hex_chars - address.size(), '0') + + std::string(address); +} + +/// ABI-encode a two-`uint64` custom error the way a node returns it in `error.data`. +std::string encode_two_word_revert(std::string_view selector, uint64_t first, uint64_t second) { + return std::string(hex_prefix) + std::string(selector) + abi_word(first) + abi_word(second); +} + +/// A `crank_outpost` stand: a real `outpost_ethereum_client` over the OPPInbound ABI (plus the +/// pool's, unless the case leaves it out), its `attestationHandlers` view stubbed, and a pool +/// wrapper bound through `bind_stub_pool` whose `realizeYield` records the call or throws what +/// the case scripts. +struct crank_fixture { + ~crank_fixture() { + pool.reset(); + outpost.reset(); + inbound.reset(); + tester.reset(); + appbase::application::reset_app_singleton(); + } + + std::unique_ptr tester; + ethereum_client_ptr eth_client; + std::vector abis; + std::shared_ptr inbound; + std::shared_ptr pool; + std::unique_ptr outpost; + + /// Every attestation type the stubbed `attestationHandlers` view was asked for. + std::vector handler_reads; + /// Response the stubbed view returns; the default is `address(0)`, nothing registered. + std::string handler_response = encode_address_word(zero_evm_address); + size_t realize_calls = 0; + /// When set, the stubbed `realizeYield` write throws it. + std::optional realize_failure; + + /// Bind a pool wrapper at `address` whose `realizeYield` is this fixture's recording stub. + void bind_stub_pool(std::string_view address) { + const std::string pool_address{address}; + pool = eth_client->get_contract(pool_address, abis); + BOOST_REQUIRE(pool); + pool->realize_yield = [this]() -> fc::variant { + ++realize_calls; + if (realize_failure) throw *realize_failure; + return fc::variant(std::string(hex_prefix) + abi_word(realize_calls)); + }; + outpost->bind_syndication_pool(pool_address, pool); + } +}; + +std::unique_ptr create_crank_fixture(bool with_pool_abi = true) { + auto fixture = std::make_unique(); + auto stack = create_relay_test_stack(); + fixture->tester = std::move(stack.tester); + fixture->eth_client = stack.eth_client; + + fixture->abis = load_abi_fixture(opp_inbound_abi_fixture); + if (with_pool_abi) { + const auto pool_abis = load_abi_fixture(syndication_pool_abi_fixture); + fixture->abis.insert(fixture->abis.end(), pool_abis.begin(), pool_abis.end()); + } + const std::string inbound_address{test_opp_inbound_address}; + fixture->inbound = + fixture->eth_client->get_contract(inbound_address, fixture->abis); + BOOST_REQUIRE(fixture->inbound); + + auto* raw = fixture.get(); + raw->inbound->attestation_handlers = + [raw](const block_number_or_tag_t& block, uint16_t& attestation_type) -> fc::variant { + // The routing table is configuration, not delivered content: read at `latest`. + BOOST_CHECK(std::holds_alternative(block)); + BOOST_CHECK(std::get(block) == block_tag_t::latest); + raw->handler_reads.push_back(attestation_type); + return fc::variant(raw->handler_response); + }; + + fixture->outpost = std::make_unique( + stack.entry, + /*opp_addr=*/std::string{}, + inbound_address, + /*operator_registry_addr=*/std::string{}, + fixture->abis, + test_outpost_chain_code, + test_evm_chain_id); + return fixture; +} + } // anonymous namespace BOOST_AUTO_TEST_SUITE(outpost_ethereum_client_plugin) @@ -1515,6 +1643,200 @@ BOOST_AUTO_TEST_CASE(multi_chunk_delivery_proceeds_when_the_outpost_epoch_has_no check_chunk_call_sequence(fixture->chunk_calls, envelope, test_wire_epoch, 0); } FC_LOG_AND_RETHROW(); +// ── `crank_outpost` ────────────────────────────────────────────────────── + +/// The pool's three refusals are identified by selector AND shape, exactly as +/// `OPP_ChunkBufferMissing` is: a neighbouring error, a role error, a refusal with the wrong +/// argument count, and no bytes at all are none of them. +BOOST_AUTO_TEST_CASE(realize_yield_refusal_selectors_are_pinned) try { + namespace crank = sysio::outpost_ethereum_client_detail; + using refusal = crank::realize_yield_refusal; + + BOOST_CHECK(crank::classify_realize_yield_revert(std::string(hex_prefix) + std::string(no_yield_selector)) == + refusal::no_yield); + BOOST_CHECK(crank::classify_realize_yield_revert(encode_two_word_revert( + yield_below_deadband_selector, test_yield_delta, test_yield_deadband)) == + refusal::below_deadband); + BOOST_CHECK(crank::classify_realize_yield_revert(encode_two_word_revert( + pool_underbacked_selector, test_yield_delta, test_yield_deadband)) == + refusal::underbacked); + + BOOST_CHECK(!crank::classify_realize_yield_revert( + std::string(hex_prefix) + std::string(no_yield_selector) + abi_word(1))); + BOOST_CHECK(!crank::classify_realize_yield_revert( + std::string(hex_prefix) + std::string(pool_underbacked_selector) + abi_word(1))); + BOOST_CHECK(!crank::classify_realize_yield_revert( + encode_address_revert(access_managed_unauthorized_selector, test_other_operator_address))); + BOOST_CHECK(!crank::classify_realize_yield_revert( + encode_address_revert(chunk_buffer_missing_selector, test_other_operator_address))); + BOOST_CHECK(!crank::classify_realize_yield_revert("")); + BOOST_CHECK(!crank::classify_realize_yield_revert("0x")); +} FC_LOG_AND_RETHROW(); + +/// The handler word decodes to the registered address, and only a real one is routable: +/// `address(0)` and the blackhole are "no pool", and a malformed word is nothing at all. +BOOST_AUTO_TEST_CASE(handler_word_decoding_and_routability) try { + namespace crank = sysio::outpost_ethereum_client_detail; + const std::string pool{test_syndication_pool_address}; + + const auto decoded = crank::address_from_word(encode_address_word(pool)); + BOOST_REQUIRE(decoded.has_value()); + BOOST_CHECK(crank::same_evm_address(*decoded, pool)); + BOOST_CHECK(crank::is_routable_handler(*decoded)); + + const auto zero = crank::address_from_word(encode_address_word(zero_evm_address)); + BOOST_REQUIRE(zero.has_value()); + BOOST_CHECK(!crank::is_routable_handler(*zero)); + const auto blackhole = crank::address_from_word(encode_address_word(attestation_blackhole_address)); + BOOST_REQUIRE(blackhole.has_value()); + BOOST_CHECK(!crank::is_routable_handler(*blackhole)); + BOOST_CHECK(!crank::is_routable_handler("")); + + BOOST_CHECK(!crank::address_from_word("")); + BOOST_CHECK(!crank::address_from_word("0x")); + // A bare address is not a word. + BOOST_CHECK(!crank::address_from_word(pool)); + auto dirty_pad = encode_address_word(pool); + dirty_pad[hex_prefix.size()] = '1'; + BOOST_CHECK(!crank::address_from_word(dirty_pad)); + auto not_hex = encode_address_word(pool); + not_hex.back() = 'g'; + BOOST_CHECK(!crank::address_from_word(not_hex)); +} FC_LOG_AND_RETHROW(); + +/// An ABI set without `realizeYield` is an outpost deployment that predates the pool: the crank +/// asks the outpost nothing. +BOOST_AUTO_TEST_CASE(crank_outpost_is_idle_without_the_pool_abi) try { + auto fixture = create_crank_fixture(/*with_pool_abi=*/false); + fixture->handler_response = encode_address_word(test_syndication_pool_address); + + fixture->outpost->crank_outpost(test_wire_epoch, fc::seconds(test_rpc_deadline_seconds)); + + BOOST_CHECK(fixture->handler_reads.empty()); + BOOST_CHECK(fixture->outpost->syndication_pool_address().empty()); +} FC_LOG_AND_RETHROW(); + +/// Until the outpost routes `DESYNDICATE_LIQ` somewhere real there is no pool: `address(0)` and +/// the blackhole both leave the crank idle, with nothing bound and nothing sent. +BOOST_AUTO_TEST_CASE(crank_outpost_is_idle_until_the_outpost_registers_a_pool) try { + const auto desyndicate_liq = static_cast( + magic_enum::enum_integer(sysio::opp::types::ATTESTATION_TYPE_DESYNDICATE_LIQ)); + for (const auto unregistered : {zero_evm_address, attestation_blackhole_address}) { + BOOST_TEST_CONTEXT(unregistered) { + auto fixture = create_crank_fixture(); + fixture->handler_response = encode_address_word(unregistered); + + fixture->outpost->crank_outpost(test_wire_epoch, fc::seconds(test_rpc_deadline_seconds)); + + BOOST_REQUIRE_EQUAL(fixture->handler_reads.size(), 1u); + BOOST_CHECK_EQUAL(fixture->handler_reads.front(), desyndicate_liq); + BOOST_CHECK(fixture->outpost->syndication_pool_address().empty()); + } + } +} FC_LOG_AND_RETHROW(); + +/// The pool the outpost names is the pool that is cranked, once per epoch, and it stays bound. +BOOST_AUTO_TEST_CASE(crank_outpost_realizes_yield_on_the_registered_pool) try { + auto fixture = create_crank_fixture(); + fixture->handler_response = encode_address_word(test_syndication_pool_address); + fixture->bind_stub_pool(test_syndication_pool_address); + + fixture->outpost->crank_outpost(test_wire_epoch, fc::seconds(test_rpc_deadline_seconds)); + fixture->outpost->crank_outpost(test_wire_epoch + 1, fc::seconds(test_rpc_deadline_seconds)); + + BOOST_CHECK_EQUAL(fixture->handler_reads.size(), 2u); + BOOST_CHECK_EQUAL(fixture->realize_calls, 2u); + BOOST_CHECK(sysio::outpost_ethereum_client_detail::same_evm_address( + fixture->outpost->syndication_pool_address(), test_syndication_pool_address)); +} FC_LOG_AND_RETHROW(); + +/// When the outpost re-routes `DESYNDICATE_LIQ` (an upgrade to a new proxy) the crank follows: +/// it binds a wrapper to the new address before sending, and the stale stub is never called. +/// The fresh wrapper dials the fixture's dead endpoint, so the send fails -- the evidence that +/// the moved address, not the stub, was driven. +BOOST_AUTO_TEST_CASE(crank_outpost_rebinds_when_the_registered_pool_moves) try { + auto fixture = create_crank_fixture(); + fixture->bind_stub_pool(test_syndication_pool_address); + fixture->handler_response = encode_address_word(test_moved_syndication_pool_address); + + BOOST_CHECK_THROW(fixture->outpost->crank_outpost(test_wire_epoch, fc::seconds(test_rpc_deadline_seconds)), + std::exception); + + BOOST_CHECK_EQUAL(fixture->realize_calls, 0u); + BOOST_CHECK(sysio::outpost_ethereum_client_detail::same_evm_address( + fixture->outpost->syndication_pool_address(), test_moved_syndication_pool_address)); +} FC_LOG_AND_RETHROW(); + +/// The pool's own three refusals are outcomes of the crank, not failures: nothing to report is +/// debug-quiet, an underbacked pool is a warning, and none of them propagate. +BOOST_AUTO_TEST_CASE(crank_outpost_reads_the_pools_own_refusals_as_outcomes) try { + const std::vector> refusals{ + {"WIRE_NoYield()", std::string(hex_prefix) + std::string(no_yield_selector)}, + {"WIRE_YieldBelowDeadband(uint64,uint64)", + encode_two_word_revert(yield_below_deadband_selector, test_yield_delta, test_yield_deadband)}, + {"WIRE_PoolUnderbacked(uint64,uint64)", + encode_two_word_revert(pool_underbacked_selector, test_yield_delta, test_yield_deadband)}, + }; + for (const auto& [description, revert_data] : refusals) { + BOOST_TEST_CONTEXT(description) { + auto fixture = create_crank_fixture(); + fixture->handler_response = encode_address_word(test_syndication_pool_address); + fixture->bind_stub_pool(test_syndication_pool_address); + fixture->realize_failure = fc::network::json_rpc::json_rpc_error( + contract_revert_rpc_code, "execution reverted", fc::variant{revert_data}); + + fixture->outpost->crank_outpost(test_wire_epoch, fc::seconds(test_rpc_deadline_seconds)); + + BOOST_CHECK_EQUAL(fixture->realize_calls, 1u); + } + } +} FC_LOG_AND_RETHROW(); + +/// Everything else the send comes back with is the job's to log as a failed crank: a role error, +/// a refusal of the wrong shape, no revert bytes, and a protocol error that never ran the call. +BOOST_AUTO_TEST_CASE(crank_outpost_propagates_every_other_failure) try { + const std::vector> failures{ + {"a signer without the yield_operator role", + fc::network::json_rpc::json_rpc_error( + contract_revert_rpc_code, "execution reverted", + fc::variant{encode_address_revert(access_managed_unauthorized_selector, test_other_operator_address)})}, + {"WIRE_NoYield() with a stray argument", + fc::network::json_rpc::json_rpc_error( + contract_revert_rpc_code, "execution reverted", + fc::variant{std::string(hex_prefix) + std::string(no_yield_selector) + abi_word(1)})}, + {"no revert bytes at all", + fc::network::json_rpc::json_rpc_error(contract_revert_rpc_code, "execution reverted", + fc::variant{std::string{}})}, + {"a protocol error", + fc::network::json_rpc::json_rpc_error(json_rpc_parse_error_code, "parse error", fc::variant{})}, + }; + for (const auto& [description, failure] : failures) { + BOOST_TEST_CONTEXT(description) { + auto fixture = create_crank_fixture(); + fixture->handler_response = encode_address_word(test_syndication_pool_address); + fixture->bind_stub_pool(test_syndication_pool_address); + fixture->realize_failure = failure; + + BOOST_CHECK_THROW( + fixture->outpost->crank_outpost(test_wire_epoch, fc::seconds(test_rpc_deadline_seconds)), + fc::exception); + BOOST_CHECK_EQUAL(fixture->realize_calls, 1u); + } + } +} FC_LOG_AND_RETHROW(); + +/// A spent deadline abandons the crank before it asks the outpost anything. +BOOST_AUTO_TEST_CASE(crank_outpost_abandons_on_an_expired_deadline) try { + auto fixture = create_crank_fixture(); + fixture->handler_response = encode_address_word(test_syndication_pool_address); + fixture->bind_stub_pool(test_syndication_pool_address); + + BOOST_CHECK_THROW(fixture->outpost->crank_outpost(test_wire_epoch, fc::microseconds(0)), fc::exception); + + BOOST_CHECK(fixture->handler_reads.empty()); + BOOST_CHECK_EQUAL(fixture->realize_calls, 0u); +} FC_LOG_AND_RETHROW(); + /// An EVM client policy must bound `max_gas_limit` at EIP-7825's per-transaction /// cap. `derive_buffered_gas_limit` applies a x1.2 buffer to the node's /// estimate, so an estimate that itself fits the cap can still produce a diff --git a/plugins/outpost_solana_client_plugin/README.md b/plugins/outpost_solana_client_plugin/README.md index 36aa89950e..c1f91ffca1 100644 --- a/plugins/outpost_solana_client_plugin/README.md +++ b/plugins/outpost_solana_client_plugin/README.md @@ -6,6 +6,8 @@ The `outpost_solana_client_plugin` provides Solana JSON-RPC client integration f - [Plugin Configuration](#plugin-configuration) - [Class Diagrams](#class-diagrams) +- [Inbound Dispatch Manifests](#inbound-dispatch-manifests) +- [Outpost Cranks](#outpost-cranks) - [Client Architecture](#client-architecture) - [solana_client (RPC Client)](#solana_client-rpc-client) - [solana_program_data_client (Raw/Vanilla Programs)](#solana_program_data_client-rawvanilla-programs) @@ -443,6 +445,46 @@ ProgramClient --> Main : decoded data (variant or struct) --- +## Inbound Dispatch Manifests + +Settlement of a consensus envelope is a separate instruction (`dispatch_attestations`) +driven from the on-chain cursor, and every effect account a handler resolves out of +`remaining_accounts` must be derived by this relay. `extract_inbound_effects` walks the +envelope once, in dispatch order, and `build_dispatch_manifests` derives one manifest per +attestation. The shapes and what each derives: + +| `effect_shape` | Attestation | Accounts derived | +|---|---|---| +| `withdraw_remit`, `slash`, `deposit_revert` | `OPERATOR_ACTION` | the operator / depositor, their `CollateralPosition` PDA, and under SPL custody the collateral vault, the destination ATA and the token program | +| `swap_remit`, `swap_revert` | `SWAP_REMIT`, `SWAP_REVERT` | the `Reserve` PDA, and under SPL custody the reserve vault, the recipient's ATA, the custody mint, its token program and any transfer-hook metas | +| `reserve_ready`, `reserve_create_cancelled` | `RESERVE_READY`, `RESERVE_CREATE_CANCELLED` | the `Reserve` PDA, plus the creator's refund accounts for the cancel | +| `desyndicate_liq` | `DESYNDICATE_LIQ` | the liqSOL pool's `GlobalState` and `DistributionState` singletons, the pool authority, the pool and user Token-2022 ATAs with their `UserRecord`s, the bucket ATA, the liqSOL mint, Token-2022, the bucket authority, the mint's transfer-hook program and extra-metas PDA, and liqsol-core itself | + +Custody is read from the account the on-chain handler branches on (`Reserve`, +`CollateralPosition`, `DistributionState`), never from the mutable `OutpostConfig` token +map. An absent account degrades to the accounts the handler needs to log-and-skip; a +present but unreadable one throws, because a guessed manifest is one the program is +guaranteed to abort on. Each shape's account list is in lock-step with the program's +`require_remaining_account` calls in wire-solana `inbound.rs`: a program-side change to +what a handler requires and the matching shape here must move together. The relay +boot-checks the declarations it decodes (`Reserve`, `CollateralPosition`, +`EpochDeliveries`, `LatestOutboundEnvelope`, and `DistributionState` on a program that +declares it) so a drifted IDL fails at startup rather than on the first drain. + +## Outpost Cranks + +Once per epoch, right after this operator's envelope delivery lands, the outbound relay +job calls `crank_outpost`. On Solana that is liqsol-core's permissionless +`report_liq_yield`: it claims the syndicated pool's pending rewards and reports the delta +since the previous report as one `LIQ_YIELD` attestation (a no-op on chain when nothing +new was claimed). The relay reads `GlobalState.wire_state` first and submits only +PostLaunch, derives every account of the instruction (`report_liq_yield_overrides`), +signs with the operator's Solana key, and skips on a program that does not declare the +instruction. A failed crank is logged by the job and retried with the next epoch's +delivery. + +--- + ## Client Architecture ``` 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 9f2a7afb52..5db20c8cde 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 @@ -224,6 +224,13 @@ void assert_collateral_position_shape(const fc::network::solana::idl::program& p /// or a field has a type the manifest builder cannot interpret. void assert_reserve_shape(const fc::network::solana::idl::program& program); +/// Assert the loaded IDL declares `DistributionState` with the one field a +/// DESYNDICATE_LIQ manifest resolves from it: `liqsol_mint` (pubkey). Only the +/// integrated liqsol-core program declares the account, so the boot check runs +/// only when the IDL carries it; a drifted declaration would make a LIVE pool +/// unreadable and wedge every desyndication window on the same cursor. +void assert_distribution_state_shape(const fc::network::solana::idl::program& program); + /// Terminal-finalization facts for one per-`(token_code, reserve_code)` /// Reserve PDA, read from the `Reserve` ACCOUNT itself. /// @@ -455,6 +462,80 @@ using collateral_custody_reader = std::function( const fc::network::solana::solana_public_key& operator_key, uint64_t token_code)>; +/// The syndicated liqSOL pool's facts a DESYNDICATE_LIQ manifest is derived +/// from, read off the program's `DistributionState` singleton: the pool's +/// liqSOL mint (Token-2022). The handler binds the mint from the same account, +/// never from the mutable `OutpostConfig` token map. +struct liq_pool_info { + fc::network::solana::solana_public_key liqsol_mint; +}; + +/// Reads the `DistributionState` singleton. `nullopt` means the account is +/// absent or empty -- the program has no syndicated pool, and the handler +/// log-and-skips -- while a present but unreadable account throws so the relay +/// never submits a manifest that is guaranteed to abort. +using liq_pool_reader = std::function()>; + +/// `GlobalState` -- the liqSOL outpost singleton the yield-report crank gates on. +namespace global_state { + constexpr auto account_name = "GlobalState"; + constexpr auto field_wire_state = "wire_state"; + /// The `WireState` variant in which the syndicated pool is outpost property + /// and `report_liq_yield` is accepted. + constexpr auto post_launch = "PostLaunch"; +} // namespace global_state + +/// The key libfc's IDL decoder renders an enum field's variant name under. +constexpr auto IDL_ENUM_VARIANT_KEY = "variant"; + +/// The instruction-account names of liqsol-core's `report_liq_yield`, exactly +/// as its `#[derive(Accounts)]` declares them (wire-solana +/// `report_liq_yield.rs`). The relay overrides every one by name: the ATAs and +/// the `UserRecord`s seeded on them are account-based derivations the IDL +/// resolver does not perform, and `token_program` is an Interface the +/// well-known table would resolve to legacy SPL Token instead of Token-2022. +namespace report_liq_yield_accounts { + constexpr auto liqsol_mint = "liqsol_mint"; + constexpr auto global_state = "global_state"; + constexpr auto distribution_state = "distribution_state"; + constexpr auto pool_authority = "pool_authority"; + constexpr auto bucket_authority = "bucket_authority"; + constexpr auto bucket_token_account = "bucket_token_account"; + constexpr auto bucket_user_record = "bucket_user_record"; + constexpr auto liqsol_pool_ata = "liqsol_pool_ata"; + constexpr auto pool_user_record = "pool_user_record"; + constexpr auto extra_account_meta_list = "extra_account_meta_list"; + constexpr auto liqsol_core_program = "liqsol_core_program"; + constexpr auto transfer_hook_program = "transfer_hook_program"; + constexpr auto config = "config"; + constexpr auto outbound_message_buffer = "outbound_message_buffer"; + constexpr auto token_program = "token_program"; + constexpr auto associated_token_program = "associated_token_program"; + constexpr auto system_program = "system_program"; +} // namespace report_liq_yield_accounts + +/// True iff a decoded `GlobalState` says the outpost is PostLaunch -- the one +/// state `report_liq_yield` accepts. Anything else (another state, a missing or +/// misshaped field) reads as "not due", so the crank never pays for a refused tx. +bool liq_yield_report_due(const fc::variant_object& global_state); + +/// The `report_liq_yield` account overrides: every named account of the +/// instruction except the signer, derived from the program id, the pool's +/// liqSOL mint, the mint's transfer-hook program and the relay's own config / +/// outbound-buffer PDAs. Exported so the plugin's tests can hold it against the +/// instruction's declaration. +fc::network::solana::account_overrides_t report_liq_yield_overrides( + const fc::network::solana::solana_public_key& program_id, + const fc::network::solana::solana_public_key& liqsol_mint, + const fc::network::solana::solana_public_key& hook_program, + const fc::network::solana::solana_public_key& config_pda, + const fc::network::solana::solana_public_key& outbound_message_buffer_pda); + +/// Assert the loaded IDL declares `GlobalState` with a `wire_state` whose enum +/// type carries the `PostLaunch` variant the yield crank gates on. Boot-checked +/// only on a program that declares `report_liq_yield`. +void assert_global_state_shape(const fc::network::solana::idl::program& program); + } // namespace outpost_solana_client_detail /** @@ -588,6 +669,20 @@ class outpost_solana_client : public outpost_client { std::optional mint_transfer_hook_for(const fc::network::solana::solana_public_key& custody_mint); + /// Read the syndicated liqSOL pool's facts off `DistributionState` -- the + /// account `handle_desyndicate_liq` binds the pool's mint from. Absent or + /// empty degrades (the handler log-and-skips an uninitialized pool); present + /// but unreadable THROWS, as `reserve_info_for_codes` does, because a guessed + /// mint would name accounts the program never asks for. + std::optional syndicated_liq_pool(); + + /// The outpost's per-epoch crank: liqsol-core's permissionless + /// `report_liq_yield`, submitted once per epoch from the outbound relay job + /// after this operator's delivery lands. PostLaunch only (read off + /// `GlobalState` first, so a refused tx is never paid for); a program without + /// the instruction has no syndicated pool and cranks nothing. + void crank_outpost(uint32_t epoch_index, fc::microseconds deadline) override; + solana_client_entry_ptr _entry; fc::network::solana::solana_public_key _program_id; std::shared_ptr _program_client; @@ -664,6 +759,27 @@ fc::network::solana::solana_public_key derive_collateral_vault_pda(const fc::network::solana::solana_public_key& program_id, uint64_t token_code); +/// The liqSOL pool's fixed PDAs (wire-solana `liqsol-core`), byte-exact mirrors +/// of the program's seed declarations: `GlobalState` +/// (`["outpost_global_state"]`), `DistributionState` (`["distribution_state"]`), +/// the pool authority (`["liqsol_pool"]`) and the bucket authority +/// (`["liqsol_bucket"]`). Exported so the manifest builder and its tests derive +/// through ONE implementation. +fc::network::solana::solana_public_key +derive_liqsol_global_state_pda(const fc::network::solana::solana_public_key& program_id); +fc::network::solana::solana_public_key +derive_liqsol_distribution_state_pda(const fc::network::solana::solana_public_key& program_id); +fc::network::solana::solana_public_key +derive_liqsol_pool_authority_pda(const fc::network::solana::solana_public_key& program_id); +fc::network::solana::solana_public_key +derive_liqsol_bucket_authority_pda(const fc::network::solana::solana_public_key& program_id); + +/// The `UserRecord` PDA of one liqSOL token account: seeds +/// `["user_record", token_account.as_ref()]`. +fc::network::solana::solana_public_key +derive_liqsol_user_record_pda(const fc::network::solana::solana_public_key& program_id, + const fc::network::solana::solana_public_key& token_account); + /// Which family of effect accounts one inbound attestation needs. The relay /// derives the concrete metas per shape; the on-chain handler resolves them /// out of `remaining_accounts` by pubkey. @@ -700,6 +816,13 @@ enum class effect_shape { reserve_ready, /// RESERVE_CREATE_CANCELLED: refunds the reserve's creator. reserve_create_cancelled, + /// DESYNDICATE_LIQ: the depot releases a user's syndicated liqSOL. The + /// handler resolves the pool's two state singletons, the pool-authority-signed + /// Token-2022 transfer's accounts (the pool and user ATAs with their + /// `UserRecord`s, the bucket ATA, the mint, Token-2022) and the liqSOL + /// transfer hook's accounts (the bucket authority, the mint's extra-metas PDA, + /// the hook program, liqsol-core itself) out of `remaining_accounts`. + desyndicate_liq, }; /// One inbound attestation's effect-account requirement, keyed by its FLAT @@ -715,9 +838,10 @@ struct inbound_effect { size_t attestation_index; effect_shape shape; /// WITHDRAW_REMIT operator / DEPOSIT_REVERT depositor / SWAP_REMIT - /// recipient / SWAP_REVERT depositor. For `slash` it is the SLASHED - /// operator — it keys the `CollateralPosition` PDA and the destination - /// ATA owner lookup but is never itself paid. + /// recipient / SWAP_REVERT depositor / DESYNDICATE_LIQ user (paid into + /// their liqSOL ATA). For `slash` it is the SLASHED operator — it keys the + /// `CollateralPosition` PDA and the destination ATA owner lookup but is + /// never itself paid. std::optional recipient; /// Set for every reserve-backed shape. std::optional reserve; @@ -786,6 +910,12 @@ uint32_t count_inbound_attestations(const std::vector& envelope_bytes); /// recipient are still declared, matching the program's log-and-skip / /// abort-and-retry gates. /// +/// * `desyndicate_liq` derives everything from the user, the program's fixed +/// pool PDAs and the mint on `DistributionState`, read through +/// `read_liq_pool` at most ONCE per build. A degraded (empty) read costs +/// the attestation everything but the two state singletons, which is what +/// lets the handler log-and-skip an uninitialized pool instead of aborting. +/// /// @param program_id outpost program id, for PDA derivation. /// @param effects account-needing attestations, in dispatch order. /// @param total_attestations the envelope's attestation total (the cursor's @@ -795,6 +925,9 @@ uint32_t count_inbound_attestations(const std::vector& envelope_bytes); /// @param read_collateral_custody reads one `(operator, token_code)` /// position's pinned custody (may degrade only /// when the position is absent or empty). +/// @param read_liq_pool reads the liqSOL pool's `DistributionState` +/// (may degrade only when the account is absent +/// or empty). /// @param reserve_aggregate the named `reserve_aggregate` account — the /// destination whose ATA receives an SPL slash /// seizure. @@ -808,6 +941,7 @@ std::vector> build_dispatch_manif const reserve_info_reader& read_reserve_info, const collateral_custody_reader& read_collateral_custody, const transfer_hook_reader& read_transfer_hook, + const liq_pool_reader& read_liq_pool, const fc::network::solana::solana_public_key& reserve_aggregate, const std::string& log_label); 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 c255ffec51..f5c48361ea 100644 --- a/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp +++ b/plugins/outpost_solana_client_plugin/src/outpost_solana_client.cpp @@ -28,6 +28,10 @@ namespace { // ── Op labels used for deadline-exceeded error messages ────────────────── constexpr std::string_view OP_EPOCH_IN = "deliver_outbound_envelope:epoch_in"; +constexpr std::string_view OP_REPORT_LIQ_YIELD = "crank_outpost:report_liq_yield"; +/// liqsol-core's permissionless yield-report instruction; only the integrated +/// program declares it. +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"; @@ -48,6 +52,13 @@ constexpr std::string_view ENVELOPE_CHUNKS_SEED = "envelope_chunks"; /// runtime as `EffectAccountMissing`, holding the dispatch cursor. constexpr std::string_view COLLATERAL_POSITION_SEED = "collateral_position"; constexpr std::string_view COLLATERAL_VAULT_SEED = "collateral_vault"; +/// The liqSOL pool's seeds (wire-solana `liqsol-core`: `inbound.rs` and the +/// `report_liq_yield` / `synd` account declarations), same lock-step caveat. +constexpr std::string_view LIQSOL_GLOBAL_STATE_SEED = "outpost_global_state"; +constexpr std::string_view LIQSOL_DISTRIBUTION_STATE_SEED = "distribution_state"; +constexpr std::string_view LIQSOL_POOL_SEED = "liqsol_pool"; +constexpr std::string_view LIQSOL_BUCKET_SEED = "liqsol_bucket"; +constexpr std::string_view LIQSOL_USER_RECORD_SEED = "user_record"; /// The 4-byte little-endian seed encoding of a WIRE epoch index -- the exact /// bytes the program's `epoch_index.to_le_bytes()` seed component uses. @@ -146,6 +157,13 @@ namespace collateral_position { constexpr auto field_amount = "amount"; } // namespace collateral_position +/// `DistributionState` -- the liqSOL pool singleton `handle_desyndicate_liq` +/// binds the pool's mint from. The relay decodes only `liqsol_mint`. +namespace distribution_state { + constexpr auto account_name = "DistributionState"; + constexpr auto field_liqsol_mint = "liqsol_mint"; +} // namespace distribution_state + } // anonymous namespace namespace outpost_solana_client_detail { @@ -635,6 +653,139 @@ fc::network::solana::solana_public_key derive_collateral_position_pda( program_id).first; } +namespace { + +/// A single-literal-seed PDA of `program_id`. +fc::network::solana::solana_public_key literal_seed_pda( + const fc::network::solana::solana_public_key& program_id, std::string_view seed) { + return fc::network::solana::system::find_program_address( + {std::vector(seed.begin(), seed.end())}, program_id).first; +} + +} // anonymous namespace + +// The liqSOL pool's fixed PDAs. Full contract on the header declarations. +fc::network::solana::solana_public_key derive_liqsol_global_state_pda( + const fc::network::solana::solana_public_key& program_id) { + return literal_seed_pda(program_id, LIQSOL_GLOBAL_STATE_SEED); +} + +fc::network::solana::solana_public_key derive_liqsol_distribution_state_pda( + const fc::network::solana::solana_public_key& program_id) { + return literal_seed_pda(program_id, LIQSOL_DISTRIBUTION_STATE_SEED); +} + +fc::network::solana::solana_public_key derive_liqsol_pool_authority_pda( + const fc::network::solana::solana_public_key& program_id) { + return literal_seed_pda(program_id, LIQSOL_POOL_SEED); +} + +fc::network::solana::solana_public_key derive_liqsol_bucket_authority_pda( + const fc::network::solana::solana_public_key& program_id) { + return literal_seed_pda(program_id, LIQSOL_BUCKET_SEED); +} + +/// The `UserRecord` PDA of one liqSOL token account. Full contract on the +/// header declaration. +fc::network::solana::solana_public_key derive_liqsol_user_record_pda( + const fc::network::solana::solana_public_key& program_id, + const fc::network::solana::solana_public_key& token_account) { + return fc::network::solana::system::find_program_address( + {std::vector(LIQSOL_USER_RECORD_SEED.begin(), LIQSOL_USER_RECORD_SEED.end()), + pubkey_seed(token_account)}, + program_id).first; +} + +/// Assert the loaded IDL declares `DistributionState` with the pubkey +/// `liqsol_mint` the DESYNDICATE_LIQ manifest reads. Full contract on the +/// header declaration. +void assert_distribution_state_shape(const fc::network::solana::idl::program& program) { + namespace idl = fc::network::solana::idl; + const auto& fields = declared_account_fields(program, distribution_state::account_name); + for (const auto& field : fields) { + if (field.name != distribution_state::field_liqsol_mint) continue; + FC_ASSERT(field.type.is_primitive() && field.type.primitive == idl::primitive_type::pubkey, + "DistributionState '{}' must be declared pubkey, got '{}'", + distribution_state::field_liqsol_mint, describe_idl_type(field.type)); + return; + } + FC_ASSERT(false, + "DistributionState IDL missing '{}'; a DESYNDICATE_LIQ manifest resolves the pool's " + "mint from it and cannot be built without it", + distribution_state::field_liqsol_mint); +} + +/// Assert the loaded IDL declares `GlobalState.wire_state` as an enum carrying +/// `PostLaunch`. Full contract on the header declaration. +void assert_global_state_shape(const fc::network::solana::idl::program& program) { + namespace idl = fc::network::solana::idl; + const auto& fields = declared_account_fields(program, global_state::account_name); + for (const auto& field : fields) { + if (field.name != global_state::field_wire_state) continue; + FC_ASSERT(field.type.is_defined(), + "GlobalState '{}' must be declared as an enum type, got '{}'", + global_state::field_wire_state, describe_idl_type(field.type)); + const idl::type_def* def = program.find_type(field.type.get_defined_name()); + FC_ASSERT(def && def->is_enum(), + "GlobalState '{}' type '{}' is not an enum in the IDL", + global_state::field_wire_state, field.type.get_defined_name()); + const bool has_post_launch = + std::any_of(def->enum_variants->begin(), def->enum_variants->end(), + [](const idl::enum_variant& v) { return v.name == global_state::post_launch; }); + FC_ASSERT(has_post_launch, + "GlobalState '{}' enum '{}' has no '{}' variant; the yield crank gates on it", + global_state::field_wire_state, field.type.get_defined_name(), + global_state::post_launch); + return; + } + FC_ASSERT(false, "GlobalState IDL missing '{}'; the yield crank gates on it", + global_state::field_wire_state); +} + +bool liq_yield_report_due(const fc::variant_object& global_state_row) { + auto state = global_state_row.find(global_state::field_wire_state); + if (state == global_state_row.end() || !state->value().is_object()) return false; + const auto& state_obj = state->value().get_object(); + auto variant = state_obj.find(IDL_ENUM_VARIANT_KEY); + return variant != state_obj.end() && variant->value().is_string() && + variant->value().as_string() == global_state::post_launch; +} + +fc::network::solana::account_overrides_t report_liq_yield_overrides( + const fc::network::solana::solana_public_key& program_id, + const fc::network::solana::solana_public_key& liqsol_mint, + const fc::network::solana::solana_public_key& hook_program, + const fc::network::solana::solana_public_key& config_pda, + const fc::network::solana::solana_public_key& outbound_message_buffer_pda) { + namespace accounts = report_liq_yield_accounts; + const auto& token_2022 = fc::network::solana::system::program_ids::TOKEN_2022_PROGRAM; + const auto pool_authority = derive_liqsol_pool_authority_pda(program_id); + const auto bucket_authority = derive_liqsol_bucket_authority_pda(program_id); + const auto pool_ata = fc::network::solana::system::get_associated_token_address( + pool_authority, liqsol_mint, token_2022); + const auto bucket_ata = fc::network::solana::system::get_associated_token_address( + bucket_authority, liqsol_mint, token_2022); + return { + {accounts::liqsol_mint, liqsol_mint}, + {accounts::global_state, derive_liqsol_global_state_pda(program_id)}, + {accounts::distribution_state, derive_liqsol_distribution_state_pda(program_id)}, + {accounts::pool_authority, pool_authority}, + {accounts::bucket_authority, bucket_authority}, + {accounts::bucket_token_account, bucket_ata}, + {accounts::bucket_user_record, derive_liqsol_user_record_pda(program_id, bucket_ata)}, + {accounts::liqsol_pool_ata, pool_ata}, + {accounts::pool_user_record, derive_liqsol_user_record_pda(program_id, pool_ata)}, + {accounts::extra_account_meta_list, derive_extra_account_metas_pda(hook_program, liqsol_mint)}, + {accounts::liqsol_core_program, program_id}, + {accounts::transfer_hook_program, hook_program}, + {accounts::config, config_pda}, + {accounts::outbound_message_buffer, outbound_message_buffer_pda}, + {accounts::token_program, token_2022}, + {accounts::associated_token_program, fc::network::solana::system::program_ids::ASSOCIATED_TOKEN_PROGRAM}, + {accounts::system_program, fc::network::solana::system::program_ids::SYSTEM_PROGRAM}, + }; +} + // ── Token-2022 transfer-hook resolution (SOL-396 lock-step) ───────────────── // @@ -1006,6 +1157,19 @@ extract_inbound_effects(const std::vector& envelope_bytes) { reserve_pda_seeds{rcc.token_code(), rcc.reserve_code()}}); break; } + // The depot releasing a user's syndicated liqSOL. The user's pubkey is + // the only payload fact the manifest needs -- the pool's mint comes from + // `DistributionState`, and the handler's own token_code check precedes + // every account it requires, so a wrong token is a logged skip on chain. + case sysio::opp::types::ATTESTATION_TYPE_DESYNDICATE_LIQ: { + sysio::opp::attestations::DesyndicateLIQ dl; + if (!dl.ParseFromString(entry.data())) continue; + if (auto pk = sol_pubkey_from_chain_address(dl.user())) { + effects.push_back(inbound_effect{ + at, effect_shape::desyndicate_liq, *pk, std::nullopt, std::nullopt}); + } + break; + } default: break; } @@ -1056,6 +1220,7 @@ std::vector> build_dispatch_manif const reserve_info_reader& read_reserve_info, const collateral_custody_reader& read_collateral_custody, const transfer_hook_reader& read_transfer_hook, + const liq_pool_reader& read_liq_pool, const fc::network::solana::solana_public_key& reserve_aggregate, const std::string& log_label) { const auto& token_program_id = fc::network::solana::system::program_ids::TOKEN_PROGRAM; @@ -1115,6 +1280,15 @@ std::vector> build_dispatch_manif .first->second; }; + // One DistributionState read for the whole build -- the pool singleton is + // the same for every DESYNDICATE_LIQ in the envelope -- memoised like the + // caches above, an absent read included. + std::optional> liq_pool_cache; + auto liq_pool = [&]() -> const std::optional& { + if (!liq_pool_cache.has_value()) liq_pool_cache = read_liq_pool(); + return *liq_pool_cache; + }; + std::vector> per_attestation(total_attestations); for (const auto& effect : effects) { // The deadline is probed per effect, BEFORE its reserve read: a build @@ -1181,6 +1355,72 @@ std::vector> build_dispatch_manif continue; } + // DESYNDICATE_LIQ: the depot releases a user's syndicated liqSOL. Everything + // derives from the user's pubkey, the program's fixed pool PDAs and the mint + // pinned on `DistributionState` -- the account the handler itself binds the + // mint from. + // + // LOCK-STEP: `handle_desyndicate_liq`'s `require_remaining_account` list + // (wire-solana `inbound.rs`) IS this manifest. The two state singletons come + // first because the handler loads them before anything else and turns an + // uninitialized pool into a logged skip; every later account aborts the + // window when missing, which is the caller-fixable retry the program wants. + if (effect.shape == effect_shape::desyndicate_liq) { + if (!effect.recipient) continue; + add(derive_liqsol_global_state_pda(program_id), true); + add(derive_liqsol_distribution_state_pda(program_id), true); + + const auto& pool_opt = liq_pool(); + if (!pool_opt.has_value()) { + wlog("outpost_solana_client[{}]: DistributionState absent or empty while building the " + "terminal manifest for attestation {}; passing the state singletons only -- the " + "handler will log-and-skip an uninitialized pool", + log_label, effect.attestation_index); + continue; + } + const auto& mint = pool_opt->liqsol_mint; + const auto pool_authority = derive_liqsol_pool_authority_pda(program_id); + const auto bucket_authority = derive_liqsol_bucket_authority_pda(program_id); + const auto pool_ata = fc::network::solana::system::get_associated_token_address( + pool_authority, mint, token_2022_program_id); + const auto user_ata = fc::network::solana::system::get_associated_token_address( + *effect.recipient, mint, token_2022_program_id); + const auto bucket_ata = fc::network::solana::system::get_associated_token_address( + bucket_authority, mint, token_2022_program_id); + + // The pool-authority-signed transfer and the share move it settles. + add(pool_authority, false); + add(pool_ata, true); + add(user_ata, true); + add(derive_liqsol_user_record_pda(program_id, pool_ata), true); + add(derive_liqsol_user_record_pda(program_id, user_ata), true); + add(bucket_ata, false); + add(mint, false); + add(token_2022_program_id, false); + // The liqSOL transfer hook's accounts: `invoke_transfer_checked` resolves + // the hook program, the mint's extra-metas PDA and the metas that PDA + // declares out of `remaining_accounts`, and the handler requires the + // bucket authority and liqsol-core itself on top. + add(bucket_authority, false); + add(program_id, false); + const auto& hook = transfer_hook(mint); + if (!hook.has_value()) { + wlog("outpost_solana_client[{}]: liqSOL mint {} carries no transfer hook; the " + "DESYNDICATE_LIQ manifest for attestation {} omits the hook accounts the handler " + "requires, so its dispatch window aborts until the mint is fixed", + log_label, mint.to_string(fc::yield_function_t{}), effect.attestation_index); + continue; + } + const auto validation_pda = derive_extra_account_metas_pda(hook->program, mint); + add(hook->program, false); + add(validation_pda, false); + for (const auto& meta : resolve_hook_metas(hook->declared, hook->program, pool_ata, mint, + user_ata, pool_authority, validation_pda)) { + add(meta.key, meta.is_writable); + } + continue; + } + if (!effect.reserve) continue; const auto token_code = effect.reserve->token_code; @@ -1487,6 +1727,33 @@ outpost_solana_client::outpost_solana_client( // window on the same unadvanced cursor. outpost_solana_client_detail::assert_collateral_position_shape( *_program_client->get_program()); + // `DistributionState` is read only for DESYNDICATE_LIQ manifests, and only + // the integrated liqsol-core program declares it -- a standalone outpost + // IDL has no syndicated pool to release from, and its DistributionState + // PDA never exists on chain. Where it IS declared, a drifted `liqsol_mint` + // would wedge every desyndication window the way a drifted Reserve would. + if (_program_client->get_program()->find_account(distribution_state::account_name)) { + outpost_solana_client_detail::assert_distribution_state_shape( + *_program_client->get_program()); + } else { + ilog("outpost_solana_client[{}]: IDL declares no DistributionState; DESYNDICATE_LIQ " + "manifests are not derivable on this outpost program", + to_string()); + } + // The yield-report crank exists only where the program declares it. There, + // it gates on `GlobalState.wire_state` and derives its accounts from + // `DistributionState`, so both declarations are boot-checked with it. + if (_program_client->has_idl(REPORT_LIQ_YIELD_INSTRUCTION)) { + FC_ASSERT(_program_client->get_program()->find_account(distribution_state::account_name), + "outpost program IDL declares `{}` but no DistributionState; the crank " + "derives the pool accounts from it", + REPORT_LIQ_YIELD_INSTRUCTION); + outpost_solana_client_detail::assert_global_state_shape(*_program_client->get_program()); + } else { + ilog("outpost_solana_client[{}]: IDL declares no `{}`; the yield-report crank is idle on " + "this outpost program", + to_string(), REPORT_LIQ_YIELD_INSTRUCTION); + } } } @@ -1706,6 +1973,7 @@ std::string outpost_solana_client::drain_dispatch( [&](const fc::network::solana::solana_public_key& custody_mint) { return mint_transfer_hook_for(custody_mint); }, + [&] { return syndicated_liq_pool(); }, _program_client->reserve_pda, to_string()); @@ -1811,6 +2079,93 @@ outpost_solana_client::collateral_position_custody( } } +std::optional +outpost_solana_client::syndicated_liq_pool() { + const auto state_pda = + outpost_solana_client_detail::derive_liqsol_distribution_state_pda(_program_id); + const auto pda_label = state_pda.to_string(fc::yield_function_t{}); + + // An RPC/deadline exception is not evidence that the pool is absent, so the + // read deliberately sits outside the decode-only try/catch. + const auto account_info = _entry->client->get_account_info(state_pda); + if (!account_info.has_value() || account_info->data.empty()) { + wlog("outpost_solana_client[{}]: DistributionState absent or empty at {}; DESYNDICATE_LIQ " + "manifests carry the state singletons only -- the handler log-and-skips an " + "uninitialized pool", + to_string(), pda_label); + return std::nullopt; + } + + try { + const auto state_v = _program_client->decode_account_info_data( + distribution_state::account_name, account_info->data); + const auto& state = state_v.get_object(); + FC_ASSERT(state.contains(distribution_state::field_liqsol_mint), + "DistributionState account missing '{}' field", + distribution_state::field_liqsol_mint); + return outpost_solana_client_detail::liq_pool_info{ + fc::network::solana::solana_public_key::from_base58_string( + state[distribution_state::field_liqsol_mint].as_string())}; + } catch (const fc::exception& e) { + elog("outpost_solana_client[{}]: DistributionState at {} EXISTS ({} bytes) but this relay " + "cannot read its liqSOL mint; refusing to build a manifest the program is guaranteed to " + "abort on. The dispatch cursor is left untouched and this epoch cannot settle until the " + "cause is fixed -- check the loaded IDL against the deployed program: {}", + to_string(), pda_label, account_info->data.size(), e.to_detail_string()); + throw; + } +} + +void outpost_solana_client::crank_outpost(uint32_t epoch_index, fc::microseconds deadline) { + // A standalone outpost program has no syndicated pool to report on. + if (!_program_client->has_idl(REPORT_LIQ_YIELD_INSTRUCTION)) return; + + const auto deadline_abs = fc::time_point::now() + deadline; + fc::task::deadline_scope rpc_deadline(deadline_abs); + throw_if_past_deadline(deadline_abs, OP_REPORT_LIQ_YIELD); + + // PostLaunch only: before the flip the pool's yield belongs to the pretoken + // accounting and the program refuses the crank, so the state is read first + // and nothing is paid for a refused tx. + const auto global_state_pda = + outpost_solana_client_detail::derive_liqsol_global_state_pda(_program_id); + const auto global_info = _entry->client->get_account_info(global_state_pda); + if (!global_info.has_value() || global_info->data.empty()) { + dlog("outpost_solana_client[{}]: no GlobalState at {} -- no liq yield to report", + to_string(), global_state_pda.to_string(fc::yield_function_t{})); + return; + } + const auto global_v = _program_client->decode_account_info_data( + outpost_solana_client_detail::global_state::account_name, global_info->data); + if (!outpost_solana_client_detail::liq_yield_report_due(global_v.get_object())) { + dlog("outpost_solana_client[{}]: outpost is not PostLaunch -- no liq yield to report", + to_string()); + return; + } + + const auto pool = syndicated_liq_pool(); + if (!pool.has_value()) return; // already logged: no pool, nothing to report + const auto hook = mint_transfer_hook_for(pool->liqsol_mint); + if (!hook.has_value()) { + wlog("outpost_solana_client[{}]: liqSOL mint {} carries no transfer hook; report_liq_yield " + "needs the hook's accounts and is skipped until the mint is fixed", + to_string(), pool->liqsol_mint.to_string(fc::yield_function_t{})); + return; + } + + throw_if_past_deadline(deadline_abs, OP_REPORT_LIQ_YIELD); + const auto overrides = outpost_solana_client_detail::report_liq_yield_overrides( + _program_id, pool->liqsol_mint, hook->program, _program_client->config_pda, + _program_client->outbound_message_buffer_pda); + const auto& instr = _program_client->get_idl(REPORT_LIQ_YIELD_INSTRUCTION); + // The instruction takes no arguments; the cranker is this operator's own key. + const fc::network::solana::program_invoke_data_items params; + const auto accounts = _program_client->resolve_accounts(instr, params, overrides); + const auto sig = _program_client->execute_tx_and_confirm(instr, accounts, params); + ilog("outpost_solana_client[{}]: report_liq_yield sent for epoch {} sig={}", + to_string(), epoch_index, sig); +} + std::string outpost_solana_client::deliver_outbound_envelope( uint32_t epoch_index, const std::vector& envelope_bytes, 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 042536c511..6634a17750 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 @@ -1157,6 +1157,59 @@ BOOST_AUTO_TEST_CASE(extract_effects_slash_carries_collateral_position_key) try BOOST_CHECK(effects[1].shape == detail::effect_shape::withdraw_remit); } FC_LOG_AND_RETHROW(); +namespace { + +/// Build a `DESYNDICATE_LIQ` entry releasing `token_code` to `user`. The decoder +/// reads only `user` -- the pool mint comes from `DistributionState`, not the +/// payload -- so the other fields stay neutral. +sysio::opp::AttestationEntry desyndicate_liq_entry(uint64_t token_code, + const sysio::opp::types::ChainAddress& user) { + sysio::opp::attestations::DesyndicateLIQ dl; + dl.set_chain_code(900); + *dl.mutable_user() = user; + dl.mutable_amount()->set_token_code(token_code); + dl.mutable_amount()->set_amount(777); + dl.set_request_id(1); + std::string body; + dl.SerializeToString(&body); + + sysio::opp::AttestationEntry entry; + entry.set_type(sysio::opp::types::ATTESTATION_TYPE_DESYNDICATE_LIQ); + entry.set_data(std::move(body)); + return entry; +} + +} // namespace + +BOOST_AUTO_TEST_CASE(extract_effects_desyndicate_liq_carries_the_user) try { + namespace detail = sysio::outpost_solana_client_detail; + // DESYNDICATE_LIQ MUST be surfaced: the handler resolves the pool's state + // singletons, the transfer's accounts and the hook's accounts out of + // remaining_accounts, and a manifest that omits them aborts the dispatch + // call and pins the cursor on this attestation for every retry. + auto user = filled_pubkey(0xAB); + auto envelope = envelope_with_entries({ + desyndicate_liq_entry(700, make_sol_addr(user)), + // Not an SVM pubkey: dropped here and logged-and-skipped on chain, while + // the flat index still advances past it. + desyndicate_liq_entry(700, make_eth_addr_32(user)), + remit_entry(504, make_sol_addr(filled_pubkey(0xEE))), + }); + + const auto effects = detail::extract_inbound_effects(envelope); + BOOST_REQUIRE_EQUAL(effects.size(), 2u); + + BOOST_CHECK_EQUAL(effects[0].attestation_index, 0u); + BOOST_CHECK(effects[0].shape == detail::effect_shape::desyndicate_liq); + BOOST_REQUIRE(effects[0].recipient.has_value()); + BOOST_CHECK(effects[0].recipient->serialize() == user); + BOOST_CHECK(!effects[0].reserve.has_value()); + BOOST_CHECK(!effects[0].collateral_token_code.has_value()); + + BOOST_CHECK_EQUAL(effects[1].attestation_index, 2u); + BOOST_CHECK(effects[1].shape == detail::effect_shape::withdraw_remit); +} FC_LOG_AND_RETHROW(); + BOOST_AUTO_TEST_CASE(extract_effects_keeps_distinct_collateral_token_codes) try { namespace detail = sysio::outpost_solana_client_detail; // One operator withdrawing two different token_codes resolves TWO @@ -1448,13 +1501,32 @@ struct manifest_build_harness { }; } + /// The syndicated liqSOL pool, as `DistributionState` would render it. + /// Empty by default: a build with no DESYNDICATE_LIQ never reads it, and a + /// case that needs one seeds the pool's mint via `put_liq_pool`. + std::optional liq_pool; + /// Counts reader invocations so a case can assert the singleton is read once + /// per build, however many desyndications the envelope carries. + mutable size_t liq_pool_reads = 0; + + void put_liq_pool(const solana_public_key& liqsol_mint) { + liq_pool = manifest_detail::liq_pool_info{liqsol_mint}; + } + + manifest_detail::liq_pool_reader liq_pool_reader() { + return [this]() -> std::optional { + ++liq_pool_reads; + return liq_pool; + }; + } + std::vector> build( const std::vector& effects, uint32_t total_attestations, const std::function& deadline_probe = [] {}) { return manifest_detail::build_dispatch_manifests( program_id, effects, total_attestations, deadline_probe, reader(), - collateral_reader(), hook_reader(), reserve_aggregate, "test-relay"); + collateral_reader(), hook_reader(), liq_pool_reader(), reserve_aggregate, "test-relay"); } }; @@ -1579,6 +1651,96 @@ BOOST_AUTO_TEST_CASE(build_manifests_follows_reserve_custody_per_reserve) try { namespace { +/// One DESYNDICATE_LIQ effect at `index` releasing syndicated liqSOL to `user`. +manifest_detail::inbound_effect desyndicate_liq_effect(size_t index, const solana_public_key& user) { + return manifest_detail::inbound_effect{ + index, manifest_detail::effect_shape::desyndicate_liq, user, std::nullopt, std::nullopt}; +} + +/// Whether `key` rides `metas` as WRITABLE. +bool manifest_writes(const std::vector& metas, const solana_public_key& key) { + auto it = std::find_if(metas.begin(), metas.end(), + [&](const account_meta& meta) { return meta.key == key; }); + return it != metas.end() && it->is_writable; +} + +} // namespace + +// The DESYNDICATE_LIQ manifest is `handle_desyndicate_liq`'s +// `require_remaining_account` list, derived from the user's pubkey, the +// program's fixed pool PDAs and the mint on `DistributionState`: the two state +// singletons (writable), the pool authority, the pool and user Token-2022 ATAs +// with their `UserRecord`s (all writable), the bucket ATA, the mint, Token-2022, +// and the hook's accounts -- the bucket authority, the mint's extra-metas PDA, +// the hook program and liqsol-core. One DistributionState read serves every +// desyndication in the envelope. +BOOST_AUTO_TEST_CASE(build_manifests_desyndicate_liq_derives_the_pool_accounts) try { + const auto liqsol_mint = measurement_pubkey(90); + const auto hook_program = measurement_pubkey(91); + const auto user_a = measurement_pubkey(92); + const auto user_b = measurement_pubkey(93); + + manifest_build_harness harness; + harness.put_liq_pool(liqsol_mint); + harness.put_hook(liqsol_mint, hook_program, {}); + + const auto manifests = harness.build( + {desyndicate_liq_effect(0, user_a), desyndicate_liq_effect(1, user_b)}, 2); + BOOST_REQUIRE_EQUAL(manifests.size(), 2u); + BOOST_CHECK_EQUAL(harness.liq_pool_reads, 1u); + + const auto& program_id = harness.program_id; + const auto global_state = manifest_detail::derive_liqsol_global_state_pda(program_id); + const auto distribution = manifest_detail::derive_liqsol_distribution_state_pda(program_id); + const auto pool_authority = manifest_detail::derive_liqsol_pool_authority_pda(program_id); + const auto bucket_authority = manifest_detail::derive_liqsol_bucket_authority_pda(program_id); + const auto& token_2022 = system::program_ids::TOKEN_2022_PROGRAM; + const auto pool_ata = system::get_associated_token_address(pool_authority, liqsol_mint, token_2022); + const auto bucket_ata = system::get_associated_token_address(bucket_authority, liqsol_mint, token_2022); + + for (size_t i = 0; i < 2; ++i) { + const auto& m = manifests[i]; + const auto& user = i == 0 ? user_a : user_b; + const auto user_ata = system::get_associated_token_address(user, liqsol_mint, token_2022); + BOOST_TEST_CONTEXT("attestation " << i) { + BOOST_CHECK_EQUAL(m.size(), 14u); + BOOST_CHECK(manifest_writes(m, global_state)); + BOOST_CHECK(manifest_writes(m, distribution)); + BOOST_CHECK(manifest_has(m, pool_authority) && !manifest_writes(m, pool_authority)); + BOOST_CHECK(manifest_writes(m, pool_ata)); + BOOST_CHECK(manifest_writes(m, user_ata)); + BOOST_CHECK(manifest_writes(m, manifest_detail::derive_liqsol_user_record_pda(program_id, pool_ata))); + BOOST_CHECK(manifest_writes(m, manifest_detail::derive_liqsol_user_record_pda(program_id, user_ata))); + BOOST_CHECK(manifest_has(m, bucket_ata) && !manifest_writes(m, bucket_ata)); + BOOST_CHECK(manifest_has(m, liqsol_mint) && !manifest_writes(m, liqsol_mint)); + BOOST_CHECK(manifest_has(m, token_2022)); + BOOST_CHECK(manifest_has(m, bucket_authority) && !manifest_writes(m, bucket_authority)); + BOOST_CHECK(manifest_has(m, program_id)); + BOOST_CHECK(manifest_has(m, hook_program)); + BOOST_CHECK(manifest_has(m, manifest_detail::derive_extra_account_metas_pda(hook_program, liqsol_mint))); + // The bare user wallet is never an account of this shape: the payout + // lands in their ATA. + BOOST_CHECK(!manifest_has(m, user)); + } + } +} FC_LOG_AND_RETHROW(); + +// Without a readable `DistributionState` the manifest carries the two state +// singletons only: the handler loads them first and turns an uninitialized +// pool into a logged skip, so nothing behind them is derivable or required. +BOOST_AUTO_TEST_CASE(build_manifests_desyndicate_liq_degrades_to_the_state_singletons) try { + manifest_build_harness harness; // no pool seeded + const auto manifests = harness.build({desyndicate_liq_effect(0, measurement_pubkey(94))}, 1); + BOOST_REQUIRE_EQUAL(manifests.size(), 1u); + const auto& m = manifests[0]; + BOOST_CHECK_EQUAL(m.size(), 2u); + BOOST_CHECK(manifest_writes(m, manifest_detail::derive_liqsol_global_state_pda(harness.program_id))); + BOOST_CHECK(manifest_writes(m, manifest_detail::derive_liqsol_distribution_state_pda(harness.program_id))); + BOOST_CHECK_EQUAL(harness.hook_reads, 0u); +} FC_LOG_AND_RETHROW(); + +namespace { + /// Pack a seed list into an `ExtraAccountMeta::address_config`, exactly as /// `Seed::pack_into_address_config` does on chain: each seed is a 1-byte tag /// then its payload, and the remainder is left zero (the Uninitialized @@ -2297,7 +2459,7 @@ BOOST_AUTO_TEST_CASE(build_manifests_propagate_an_unreadable_reserve) try { harness.program_id, {swap_remit_effect(0, 10, 20, recipient), swap_remit_effect(1, 11, 21, later)}, 2, [] {}, throwing_reader, harness.collateral_reader(), harness.hook_reader(), - harness.reserve_aggregate, "test-relay"), + harness.liq_pool_reader(), harness.reserve_aggregate, "test-relay"), fc::exception, [](const fc::exception& e) { return e.to_detail_string().find("undecodable") != std::string::npos; @@ -2929,6 +3091,144 @@ BOOST_AUTO_TEST_CASE(reserve_shape_accepts_the_deployed_declaration) try { BOOST_CHECK_NO_THROW(assert_reserve_shape(load_idl_fixture(opp_outpost_idl_fixture))); } FC_LOG_AND_RETHROW(); +BOOST_AUTO_TEST_CASE(distribution_state_shape_accepts_a_pubkey_mint_and_rejects_drift) try { + using sysio::outpost_solana_client_detail::assert_distribution_state_shape; + + // The stub fixture carries the integrated program's `DistributionState`, so + // this is the boot check running against the declaration a batch operator + // meets. A standalone outpost program declares none at all; the boot check + // skips the assert (and logs it) there, and the assert itself refuses it. + BOOST_CHECK_NO_THROW(assert_distribution_state_shape(load_idl_fixture(opp_outpost_idl_fixture))); + BOOST_CHECK_THROW(assert_distribution_state_shape(idl::program{}), fc::assert_exception); + + for (bool in_types : {false, true}) { + BOOST_TEST_CONTEXT("fields in types section: " << in_types) { + BOOST_CHECK_NO_THROW(assert_distribution_state_shape(named_account_program( + "DistributionState", + {{"bump", prim(idl::primitive_type::u8)}, {"liqsol_mint", prim(idl::primitive_type::pubkey)}}, + in_types))); + // The mint declared as anything but a pubkey, or not declared at all. + BOOST_CHECK_THROW(assert_distribution_state_shape(named_account_program( + "DistributionState", {{"liqsol_mint", prim(idl::primitive_type::u64)}}, in_types)), + fc::assert_exception); + BOOST_CHECK_THROW(assert_distribution_state_shape(named_account_program( + "DistributionState", {{"bump", prim(idl::primitive_type::u8)}}, in_types)), + fc::assert_exception); + } + } +} FC_LOG_AND_RETHROW(); + +namespace { + +/// A synthetic program declaring `GlobalState` with `wire_state` of enum type +/// `WireState` carrying `variants`. +idl::program global_state_program(std::vector variants, bool fields_in_types_section) { + auto prog = named_account_program( + "GlobalState", {{"wire_state", idl::idl_type::make_defined("WireState")}}, fields_in_types_section); + idl::type_def wire_state; + wire_state.name = "WireState"; + wire_state.enum_variants = std::vector{}; + for (auto& name : variants) { + idl::enum_variant variant; + variant.name = std::move(name); + wire_state.enum_variants->push_back(std::move(variant)); + } + prog.types.push_back(std::move(wire_state)); + return prog; +} + +} // namespace + +BOOST_AUTO_TEST_CASE(global_state_shape_requires_a_post_launch_variant) try { + using sysio::outpost_solana_client_detail::assert_global_state_shape; + + // The stub fixture carries the integrated program's `GlobalState`. + BOOST_CHECK_NO_THROW(assert_global_state_shape(load_idl_fixture(opp_outpost_idl_fixture))); + BOOST_CHECK_THROW(assert_global_state_shape(idl::program{}), fc::assert_exception); + + for (bool in_types : {false, true}) { + BOOST_TEST_CONTEXT("fields in types section: " << in_types) { + BOOST_CHECK_NO_THROW(assert_global_state_shape( + global_state_program({"PreLaunch", "PostLaunch", "Refund", "Launching"}, in_types))); + // The variant the crank gates on renamed away, or the field not an enum. + BOOST_CHECK_THROW(assert_global_state_shape(global_state_program({"PreLaunch", "Live"}, in_types)), + fc::assert_exception); + BOOST_CHECK_THROW(assert_global_state_shape(named_account_program( + "GlobalState", {{"wire_state", prim(idl::primitive_type::u8)}}, in_types)), + fc::assert_exception); + } + } +} FC_LOG_AND_RETHROW(); + +// The crank gates on the decoder's rendering of `GlobalState.wire_state` -- an +// enum comes back as `{"variant": }` -- and only PostLaunch is due. +BOOST_AUTO_TEST_CASE(liq_yield_report_is_due_only_post_launch) try { + using sysio::outpost_solana_client_detail::liq_yield_report_due; + auto state = [](const char* variant) { + return fc::mutable_variant_object()("wire_state", fc::mutable_variant_object()("variant", variant)); + }; + BOOST_CHECK(liq_yield_report_due(state("PostLaunch"))); + BOOST_CHECK(!liq_yield_report_due(state("PreLaunch"))); + BOOST_CHECK(!liq_yield_report_due(state("Launching"))); + BOOST_CHECK(!liq_yield_report_due(fc::mutable_variant_object()("paused", false))); + // A bare string is not the decoder's shape: never due rather than guessed. + BOOST_CHECK(!liq_yield_report_due(fc::mutable_variant_object()("wire_state", "PostLaunch"))); +} FC_LOG_AND_RETHROW(); + +// The overrides cover every non-signer account `report_liq_yield` declares, and +// derive each the way the program's `#[derive(Accounts)]` constraints do: the +// Token-2022 ATAs of the pool and bucket authorities, the `UserRecord`s seeded +// on those ATAs, the hook's extra-metas PDA, and Token-2022 itself as the token +// program (the well-known table would have resolved legacy SPL Token). +BOOST_AUTO_TEST_CASE(report_liq_yield_overrides_resolve_every_declared_account) try { + namespace accounts = manifest_detail::report_liq_yield_accounts; + auto prog = load_idl_fixture(opp_outpost_idl_fixture); + const idl::instruction* instr = prog.find_instruction("report_liq_yield"); + BOOST_REQUIRE(instr != nullptr); + + const auto program_id = measurement_pubkey(42); + const auto mint = measurement_pubkey(90); + const auto hook = measurement_pubkey(91); + const auto config = measurement_pubkey(95); + const auto buffer = measurement_pubkey(96); + const auto overrides = manifest_detail::report_liq_yield_overrides(program_id, mint, hook, config, buffer); + + size_t signers = 0; + for (const auto& acct : instr->accounts) { + if (acct.is_signer) { ++signers; continue; } // the cranker is the client's own key + BOOST_CHECK_MESSAGE(overrides.contains(acct.name), "no override for '" << acct.name << "'"); + } + BOOST_CHECK_EQUAL(signers, 1u); + BOOST_CHECK_EQUAL(overrides.size(), instr->accounts.size() - signers); + + const auto& token_2022 = system::program_ids::TOKEN_2022_PROGRAM; + const auto pool_authority = manifest_detail::derive_liqsol_pool_authority_pda(program_id); + const auto bucket_authority = manifest_detail::derive_liqsol_bucket_authority_pda(program_id); + const auto pool_ata = system::get_associated_token_address(pool_authority, mint, token_2022); + const auto bucket_ata = system::get_associated_token_address(bucket_authority, mint, token_2022); + BOOST_CHECK(overrides.at(accounts::liqsol_mint) == mint); + BOOST_CHECK(overrides.at(accounts::global_state) == manifest_detail::derive_liqsol_global_state_pda(program_id)); + BOOST_CHECK(overrides.at(accounts::distribution_state) == + manifest_detail::derive_liqsol_distribution_state_pda(program_id)); + BOOST_CHECK(overrides.at(accounts::pool_authority) == pool_authority); + BOOST_CHECK(overrides.at(accounts::bucket_authority) == bucket_authority); + BOOST_CHECK(overrides.at(accounts::liqsol_pool_ata) == pool_ata); + BOOST_CHECK(overrides.at(accounts::bucket_token_account) == bucket_ata); + BOOST_CHECK(overrides.at(accounts::pool_user_record) == + manifest_detail::derive_liqsol_user_record_pda(program_id, pool_ata)); + BOOST_CHECK(overrides.at(accounts::bucket_user_record) == + manifest_detail::derive_liqsol_user_record_pda(program_id, bucket_ata)); + BOOST_CHECK(overrides.at(accounts::extra_account_meta_list) == + manifest_detail::derive_extra_account_metas_pda(hook, mint)); + BOOST_CHECK(overrides.at(accounts::liqsol_core_program) == program_id); + BOOST_CHECK(overrides.at(accounts::transfer_hook_program) == hook); + BOOST_CHECK(overrides.at(accounts::config) == config); + BOOST_CHECK(overrides.at(accounts::outbound_message_buffer) == buffer); + BOOST_CHECK(overrides.at(accounts::token_program) == token_2022); + BOOST_CHECK(overrides.at(accounts::associated_token_program) == system::program_ids::ASSOCIATED_TOKEN_PROGRAM); + BOOST_CHECK(overrides.at(accounts::system_program) == system::program_ids::SYSTEM_PROGRAM); +} FC_LOG_AND_RETHROW(); + BOOST_AUTO_TEST_CASE(reserve_shape_rejects_drifted_declarations) try { using sysio::outpost_solana_client_detail::assert_reserve_shape; diff --git a/tests/fixtures/ethereum-abi-syndication-pool.json b/tests/fixtures/ethereum-abi-syndication-pool.json new file mode 100644 index 0000000000..24fa18183c --- /dev/null +++ b/tests/fixtures/ethereum-abi-syndication-pool.json @@ -0,0 +1,67 @@ +{ + "_format": "hh-sol-artifact-1", + "_source": "The members of SyndicationPool.sol (Wire-Network/wire-ethereum#207) that outpost_ethereum_client touches.", + "contractName": "SyndicationPool", + "sourceName": "contracts/outpost/SyndicationPool.sol", + "abi": [ + { + "inputs": [], + "name": "WIRE_NoYield", + "type": "error" + }, + { + "inputs": [ + { "internalType": "uint64", "name": "balanceDepot", "type": "uint64" }, + { "internalType": "uint64", "name": "principal", "type": "uint64" } + ], + "name": "WIRE_PoolUnderbacked", + "type": "error" + }, + { + "inputs": [ + { "internalType": "uint64", "name": "delta", "type": "uint64" }, + { "internalType": "uint64", "name": "deadband", "type": "uint64" } + ], + "name": "WIRE_YieldBelowDeadband", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": false, "internalType": "uint64", "name": "depotAmount", "type": "uint64" }, + { "indexed": false, "internalType": "uint64", "name": "principal", "type": "uint64" }, + { "indexed": false, "internalType": "uint64", "name": "sequence", "type": "uint64" } + ], + "name": "YieldRealized", + "type": "event" + }, + { + "inputs": [], + "name": "poolBalanceDepot", + "outputs": [{ "internalType": "uint64", "name": "", "type": "uint64" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "realizeYield", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "syndicatedPrincipal", + "outputs": [{ "internalType": "uint64", "name": "", "type": "uint64" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "yieldDeadband", + "outputs": [{ "internalType": "uint64", "name": "", "type": "uint64" }], + "stateMutability": "view", + "type": "function" + } + ] +} diff --git a/tests/fixtures/solana-idl-opp-outpost-stub.json b/tests/fixtures/solana-idl-opp-outpost-stub.json index a723d48ab9..69451a536d 100644 --- a/tests/fixtures/solana-idl-opp-outpost-stub.json +++ b/tests/fixtures/solana-idl-opp-outpost-stub.json @@ -320,6 +320,86 @@ "type": "u32" } ] + }, + { + "name": "report_liq_yield", + "discriminator": [ + 79, + 18, + 82, + 254, + 185, + 29, + 211, + 140 + ], + "accounts": [ + { + "name": "cranker", + "writable": true, + "signer": true + }, + { + "name": "liqsol_mint", + "writable": true + }, + { + "name": "global_state", + "writable": true + }, + { + "name": "distribution_state", + "writable": true + }, + { + "name": "pool_authority" + }, + { + "name": "bucket_authority" + }, + { + "name": "bucket_token_account", + "writable": true + }, + { + "name": "bucket_user_record", + "writable": true + }, + { + "name": "liqsol_pool_ata", + "writable": true + }, + { + "name": "pool_user_record", + "writable": true + }, + { + "name": "extra_account_meta_list" + }, + { + "name": "liqsol_core_program" + }, + { + "name": "transfer_hook_program" + }, + { + "name": "config" + }, + { + "name": "outbound_message_buffer", + "writable": true + }, + { + "name": "token_program" + }, + { + "name": "associated_token_program" + }, + { + "name": "system_program" + } + ], + "args": [] } ], "accounts": [ @@ -374,6 +454,32 @@ 174, 190 ] + }, + { + "name": "GlobalState", + "discriminator": [ + 163, + 46, + 74, + 168, + 216, + 123, + 133, + 98 + ] + }, + { + "name": "DistributionState", + "discriminator": [ + 7, + 25, + 94, + 15, + 208, + 170, + 4, + 103 + ] } ], "types": [ @@ -662,6 +768,78 @@ } ] } + }, + { + "name": "GlobalState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "paused", + "type": "bool" + }, + { + "name": "yield_accumulated_liqsol", + "type": "u64" + }, + { + "name": "wire_state", + "type": { + "defined": { + "name": "WireState" + } + } + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "liq_sequence", + "type": "u64" + }, + { + "name": "liq_yield_reported", + "type": "u64" + } + ] + } + }, + { + "name": "DistributionState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "liqsol_mint", + "type": "pubkey" + } + ] + } + }, + { + "name": "WireState", + "type": { + "kind": "enum", + "variants": [ + { + "name": "PreLaunch" + }, + { + "name": "PostLaunch" + }, + { + "name": "Refund" + }, + { + "name": "Launching" + } + ] + } } ] }