From d5833232e26f86b2a8a7d6a23a8dab0bbb91e241 Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Thu, 10 Sep 2026 15:14:05 -0400 Subject: [PATCH 01/37] swap: pristine import of EOSArgentina/evolutiondex as contracts/sysio.swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-identical snapshot of https://github.com/EOSArgentina/evolutiondex at 066feb3ccd8d406ba2e095ef9978f6859a89bb03 (master, 2020-11-10, MIT — LICENSE kept verbatim), laid out in the house contract shape and renamed to sysio.swap: evolutiondex.{hpp,cpp} -> include/sysio.swap/sysio.swap.hpp, sysio.swap.cpp utils.hpp, safe.hpp -> include/sysio.swap/ token_functions.cpp -> token_functions.cpp evolutiondex.{contracts,clauses}.md -> sysio.swap.{contracts,clauses}.md tests/, wevotethefee/ -> kept alongside for the port No source edits, no CMake wiring — nothing compiles yet; the port to the sysio CDT idioms lands on top of this so every change diffs cleanly against upstream. Also narrows .gitignore's vim-swap pattern from *.sw* to *.sw?: the old glob matched the directory name sysio.swap and silently ignored the whole contract tree. contracts_unit_test: No errors detected. Co-Authored-By: Claude Fable 5.1 Change-Id: Icd32083b8c506efe914f70a7a89e73a840f01d77 --- .gitignore | 2 +- contracts/sysio.swap/Commands.md | 163 +++ contracts/sysio.swap/LICENSE | 21 + contracts/sysio.swap/README.md | 57 + .../sysio.swap/include/sysio.swap/safe.hpp | 221 ++++ .../include/sysio.swap/sysio.swap.hpp | 95 ++ .../sysio.swap/include/sysio.swap/utils.hpp | 95 ++ contracts/sysio.swap/sysio.swap.clauses.md | 8 + contracts/sysio.swap/sysio.swap.contracts.md | 213 ++++ contracts/sysio.swap/sysio.swap.cpp | 292 +++++ contracts/sysio.swap/tests/CMakeLists.txt | 15 + .../sysio.swap/tests/badtoken/badtoken.cpp | 20 + .../sysio.swap/tests/badtoken/badtoken.hpp | 23 + contracts/sysio.swap/tests/contracts.hpp.in | 31 + .../sysio.swap/tests/eosio.system_tester.hpp | 1101 ++++++++++++++++ .../sysio.swap/tests/evolutiondex_tests.cpp | 1109 +++++++++++++++++ contracts/sysio.swap/tests/main.cpp | 42 + contracts/sysio.swap/tests/test_symbol.hpp | 16 + contracts/sysio.swap/token_functions.cpp | 84 ++ contracts/sysio.swap/wevotethefee/README.md | 20 + .../wevotethefee/wevotethefee.clauses.md | 6 + .../wevotethefee/wevotethefee.contracts.md | 92 ++ .../sysio.swap/wevotethefee/wevotethefee.cpp | 123 ++ .../sysio.swap/wevotethefee/wevotethefee.hpp | 66 + 24 files changed, 3914 insertions(+), 1 deletion(-) create mode 100644 contracts/sysio.swap/Commands.md create mode 100644 contracts/sysio.swap/LICENSE create mode 100644 contracts/sysio.swap/README.md create mode 100644 contracts/sysio.swap/include/sysio.swap/safe.hpp create mode 100644 contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp create mode 100644 contracts/sysio.swap/include/sysio.swap/utils.hpp create mode 100644 contracts/sysio.swap/sysio.swap.clauses.md create mode 100644 contracts/sysio.swap/sysio.swap.contracts.md create mode 100644 contracts/sysio.swap/sysio.swap.cpp create mode 100644 contracts/sysio.swap/tests/CMakeLists.txt create mode 100644 contracts/sysio.swap/tests/badtoken/badtoken.cpp create mode 100644 contracts/sysio.swap/tests/badtoken/badtoken.hpp create mode 100644 contracts/sysio.swap/tests/contracts.hpp.in create mode 100644 contracts/sysio.swap/tests/eosio.system_tester.hpp create mode 100644 contracts/sysio.swap/tests/evolutiondex_tests.cpp create mode 100644 contracts/sysio.swap/tests/main.cpp create mode 100644 contracts/sysio.swap/tests/test_symbol.hpp create mode 100644 contracts/sysio.swap/token_functions.cpp create mode 100644 contracts/sysio.swap/wevotethefee/README.md create mode 100644 contracts/sysio.swap/wevotethefee/wevotethefee.clauses.md create mode 100644 contracts/sysio.swap/wevotethefee/wevotethefee.contracts.md create mode 100644 contracts/sysio.swap/wevotethefee/wevotethefee.cpp create mode 100644 contracts/sysio.swap/wevotethefee/wevotethefee.hpp diff --git a/.gitignore b/.gitignore index 4d7fe2b3dd..d7d01812fb 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ tmp .run/ /.data *.a -*.sw* +*.sw? *.dylib *.ll *.bc diff --git a/contracts/sysio.swap/Commands.md b/contracts/sysio.swap/Commands.md new file mode 100644 index 0000000000..b385c22d43 --- /dev/null +++ b/contracts/sysio.swap/Commands.md @@ -0,0 +1,163 @@ +**NOTE: In this example we use the PAIR EOS/PESO, which creates the evotoken EOSPESO. We assume an EOS token located in the eosio.token contract as usual, and a PESO token located in the contract pesocontract. You would need to replace these variables depending on your trading pairs.** + +First, let us describe the single actions of the smart contract. + +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: + + 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 + +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 EOS/PESO evotoken. Set the initial liquidity, the initial fee for the trading pair and the fee controller. + + cleos push action evolutiondex inittoken '["YOUR_ACCOUNT", "4,EOSPESO", {"contract":"eosio.token", "quantity":"1.0000 EOS"}, {"contract":"pesocontract", "quantity":"1.0000 PESO"}, 10, "wevotethefee"]' -p YOUR_ACCOUNT + +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. + + cleos push action evolutiondex addliquidity '["YOUR_ACCOUNT", "1.5000 EOSPESO", + "2.0000 EOS", "2.0000 PESO"]' -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 + +It is also possible to set the exact amount to obtain and limit the amount to pay. +To do this, use negative amounts. The following example means that you want to receive exactly 0.1000 PESO and pay at most 1.0000 EOS. + + cleos push action evolutiondex exchange '["YOUR_ACCOUNT", "EOSPESO", + {"contract":"eosio.token", "quantity":"-0.1000 PESO"}, "-1.0000 EOS"]' -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 EOS", + "max_asset2": "2.0000 PESO", + } + },{ + "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. + +The fee value will be governed by the liquidity providers using the +smart contract wevotethefee. +Check the commands of wevotethefee [here](wevotethefee/README.md). \ 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..08f17ef26c --- /dev/null +++ b/contracts/sysio.swap/README.md @@ -0,0 +1,57 @@ +![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 is set at initialization of each trading pair. The contract wevotethefee will be able to control the fee value through +a voting mechanism. The liquidity providers can vote for a value between +0.1% and 1%, their vote will be weighted according to their stake. +The fee value will be set to the median of the votes. The voting tables +are updated each time a liquidity provider modifies its balance. +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. + +**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. All the rounding errors will favour the pools in order to avoid the gaming of the system. 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. + + +**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/safe.hpp b/contracts/sysio.swap/include/sysio.swap/safe.hpp new file mode 100644 index 0000000000..075cdec011 --- /dev/null +++ b/contracts/sysio.swap/include/sysio.swap/safe.hpp @@ -0,0 +1,221 @@ +#pragma once + +#include +/** +* This type is designed to provide automatic checks for +* integer overflow and default initialization. It will +* throw an exception on overflow conditions. +* +* It can only be used on built-in types. In particular, +* safe is buggy and should not be used. +* +* Implemented using spec from: +* https://www.securecoding.cert.org/confluence/display/c/INT32-C.+Ensure+that+operations+on+signed+integers+do+not+result+in+overflow +*/ +template +struct safe +{ + T value = 0; + + template + safe( O o ):value(o){} + safe(){} + safe( const safe& o ):value(o.value){} + + static safe min() + { + return std::numeric_limits::min(); + } + static safe max() + { + return std::numeric_limits::max(); + } + + friend safe operator + ( const safe& a, const safe& b ) + { + if( b.value > 0 && a.value > (std::numeric_limits::max() - b.value) ) check(false, "overflow_exception, (a)(b)" ); + if( b.value < 0 && a.value < (std::numeric_limits::min() - b.value) ) check(false, "underflow_exception, (a)(b)" ); + return safe( a.value + b.value ); + } + friend safe operator - ( const safe& a, const safe& b ) + { + if( b.value > 0 && a.value < (std::numeric_limits::min() + b.value) ) check(false, "underflow_exception, (a)(b)" ); + if( b.value < 0 && a.value > (std::numeric_limits::max() + b.value) ) check(false, "overflow_exception, (a)(b)" ); + return safe( a.value - b.value ); + } + + friend safe operator * ( const safe& a, const safe& b ) + { + if( a.value > 0 ) + { + if( b.value > 0 ) + { + if( a.value > (std::numeric_limits::max() / b.value) ) check(false, "overflow_exception, (a)(b)" ); + } + else + { + if( b.value < (std::numeric_limits::min() / a.value) ) check(false, "underflow_exception, (a)(b)" ); + } + } + else + { + if( b.value > 0 ) + { + if( a.value < (std::numeric_limits::min() / b.value) ) check(false, "underflow_exception, (a)(b)" ); + } + else + { + if( a.value != 0 && b.value < (std::numeric_limits::max() / a.value) ) check(false, "overflow_exception, (a)(b)" ); + } + } + + return safe( a.value * b.value ); + } + + friend safe operator / ( const safe& a, const safe& b ) + { + if( b.value == 0 ) check(false, "divide_by_zero_exception, (a)(b)" ); + if( a.value == std::numeric_limits::min() && b.value == -1 ) check(false, "overflow_exception, (a)(b)" ); + return safe( a.value / b.value ); + } + friend safe operator % ( const safe& a, const safe& b ) + { + if( b.value == 0 ) check(false, "divide_by_zero_exception, (a)(b)" ); + if( a.value == std::numeric_limits::min() && b.value == -1 ) check(false, "overflow_exception, (a)(b)" ); + return safe( a.value % b.value ); + } + + safe operator - ()const + { + if( value == std::numeric_limits::min() ) check(false, "overflow_exception, (*this)" ); + return safe( -value ); + } + + safe& operator += ( const safe& b ) + { + value = (*this + b).value; + return *this; + } + safe& operator -= ( const safe& b ) + { + value = (*this - b).value; + return *this; + } + safe& operator *= ( const safe& b ) + { + value = (*this * b).value; + return *this; + } + safe& operator /= ( const safe& b ) + { + value = (*this / b).value; + return *this; + } + safe& operator %= ( const safe& b ) + { + value = (*this % b).value; + return *this; + } + + safe& operator++() + { + *this += 1; + return *this; + } + safe operator++( int ) + { + safe bak = *this; + *this += 1; + return bak; + } + + safe& operator--() + { + *this -= 1; + return *this; + } + safe operator--( int ) + { + safe bak = *this; + *this -= 1; + return bak; + } + + friend bool operator == ( const safe& a, const safe& b ) + { + return a.value == b.value; + } + friend bool operator == ( const safe& a, const T& b ) + { + return a.value == b; + } + friend bool operator == ( const T& a, const safe& b ) + { + return a == b.value; + } + + friend bool operator < ( const safe& a, const safe& b ) + { + return a.value < b.value; + } + friend bool operator < ( const safe& a, const T& b ) + { + return a.value < b; + } + friend bool operator < ( const T& a, const safe& b ) + { + return a < b.value; + } + + friend bool operator > ( const safe& a, const safe& b ) + { + return a.value > b.value; + } + friend bool operator > ( const safe& a, const T& b ) + { + return a.value > b; + } + friend bool operator > ( const T& a, const safe& b ) + { + return a > b.value; + } + + friend bool operator != ( const safe& a, const safe& b ) + { + return !(a == b); + } + friend bool operator != ( const safe& a, const T& b ) + { + return !(a == b); + } + friend bool operator != ( const T& a, const safe& b ) + { + return !(a == b); + } + + friend bool operator <= ( const safe& a, const safe& b ) + { + return !(a > b); + } + friend bool operator <= ( const safe& a, const T& b ) + { + return !(a > b); + } + friend bool operator <= ( const T& a, const safe& b ) + { + return !(a > b); + } + + friend bool operator >= ( const safe& a, const safe& b ) + { + return !(a < b); + } + friend bool operator >= ( const safe& a, const T& b ) + { + return !(a < b); + } + friend bool operator >= ( const T& a, const safe& b ) + { + return !(a < b); + } +}; 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..ded0890cec --- /dev/null +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include +#include +#include + +using namespace eosio; +using namespace std; + +namespace evolution { + + class [[eosio::contract("evolutiondex")]] evolutiondex : public contract { + public: + const int64_t MAX = eosio::asset::max_amount; + const int64_t INIT_MAX = 1000000000000000; // 10^15 + const int ADD_LIQUIDITY_FEE = 1; + const int DEFAULT_FEE = 10; + + using contract::contract; + [[eosio::action]] void inittoken(name user, symbol new_symbol, + extended_asset initial_pool1, extended_asset initial_pool2, + int initial_fee, name fee_contract); + [[eosio::on_notify("*::transfer")]] void ontransfer(name from, name to, asset quantity, string memo); + [[eosio::action]] void openext( const name& user, const name& payer, const extended_symbol& ext_symbol); + [[eosio::action]] void closeext ( const name& user, const name& to, const extended_symbol& ext_symbol, string memo); + [[eosio::action]] void withdraw(name user, name to, extended_asset to_withdraw, string memo); + [[eosio::action]] void addliquidity(name user, asset to_buy, asset max_asset1, asset max_asset2); + [[eosio::action]] void remliquidity(name user, asset to_sell, asset min_asset1, asset min_asset2); + [[eosio::action]] void exchange( name user, symbol_code pair_token, extended_asset ext_asset_in, asset min_expected ); + [[eosio::action]] void changefee(symbol_code pair_token, int newfee); + + [[eosio::action]] void transfer(const name& from, const name& to, + const asset& quantity, const string& memo ); + [[eosio::action]] void open( const name& owner, const symbol& symbol, const name& ram_payer ); + [[eosio::action]] void close( const name& owner, const symbol& symbol ); + [[eosio::action]] void indexpair(name user, symbol evo_symbol); // This action is only temporarily useful + + private: + + struct [[eosio::table]] account { + asset balance; + uint64_t primary_key()const { return balance.symbol.code().raw(); } + }; + + struct [[eosio::table]] evodexaccount { + extended_asset balance; + uint64_t id; + uint64_t primary_key()const { return id; } + uint128_t secondary_key()const { return + make128key(balance.contract.value, balance.quantity.symbol.raw() ); } + }; + + struct [[eosio::table]] currency_stats { + asset supply; + asset max_supply; + name issuer; + extended_asset pool1; + extended_asset pool2; + int fee; + name fee_contract; + uint64_t primary_key()const { return supply.symbol.code().raw(); } + }; + + struct [[eosio::table]] index_struct{ + symbol evo_symbol; + checksum256 id_256; + uint64_t primary_key()const { return evo_symbol.code().raw(); } + checksum256 secondary_key()const { return id_256; } + }; + + typedef eosio::multi_index< "evodexacnts"_n, evodexaccount, + indexed_by<"extended"_n, const_mem_fun> > evodexacnts; + typedef eosio::multi_index< "stat"_n, currency_stats > stats; + typedef eosio::multi_index< "evoindex"_n, index_struct, + indexed_by<"extended"_n, const_mem_fun> > evoindexes; + typedef eosio::multi_index< "accounts"_n, account > accounts; + + static uint128_t make128key(uint64_t a, uint64_t b); + static checksum256 make256key(uint64_t a, uint64_t b, uint64_t c, uint64_t d); + + 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); + extended_asset process_exch(symbol_code evo_token, extended_asset paying, asset min_expected); + int64_t compute(int64_t x, int64_t y, int64_t z, int fee); + asset string_to_asset(string input); + 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 ); + }; +} \ No newline at end of file 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..c1c53cefd4 --- /dev/null +++ b/contracts/sysio.swap/include/sysio.swap/utils.hpp @@ -0,0 +1,95 @@ +#pragma once +//#include +#include +#include +#include + +#include +#include "safe.hpp" + +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; +} + +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; +} + +bool starts_with(string_view sv, string_view s) { + return sv.size() >= s.size() && sv.compare(0, s.size(), s) == 0; +} + +template +void to_int(string_view sv, T& res) { + res = 0; + T p = 1; + for( auto itr = sv.rbegin(); itr != sv.rend(); ++itr ) { + check( *itr <= '9' && *itr >= '0', "invalid character"); + res += p * T(*itr-'0'); + p *= T(10); + } +} +template +void precision_from_decimals(int8_t decimals, T& p10) +{ + check(decimals <= 18, "precision should be <= 18"); + p10 = 1; + T p = decimals; + while( p > 0 ) { + p10 *= 10; --p; + } +} + +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 + safe int_part, fract_part; + if (dot_pos != string::npos) { + to_int(amount_str.substr(0, dot_pos), int_part); + to_int(amount_str.substr(dot_pos + 1), fract_part); + if (amount_str[0] == '-') fract_part *= -1; + } else { + to_int(amount_str, int_part); + } + + safe amount = int_part; + safe precision; precision_from_decimals(sym.precision(), precision); + amount *= precision; + amount += fract_part; + + return asset(amount.value, sym); +} diff --git a/contracts/sysio.swap/sysio.swap.clauses.md b/contracts/sysio.swap/sysio.swap.clauses.md new file mode 100644 index 0000000000..984cfff069 --- /dev/null +++ b/contracts/sysio.swap/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/sysio.swap.contracts.md b/contracts/sysio.swap/sysio.swap.contracts.md new file mode 100644 index 0000000000..9a23c0ba12 --- /dev/null +++ b/contracts/sysio.swap/sysio.swap.contracts.md @@ -0,0 +1,213 @@ +

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 default response is to deposit {{quantity}} to {{from}}'s extended balance for the extended symbol formed by the tranfer's contract and the symbol {{asset_to_symbol quantity}}. 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. + +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}}, fee_contract = {{fee_contract}}. The extended assets {{initial_pool1}} and {{initial_pool2}} will be deducted from the corresponding extended balances of {{user}}. + +RAM will be deducted from {{user}}’s resources to create the necessary records. +Authorization of {{user}} 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. 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 (possibly negative) extended assets will be respectively added to and substracted from the corresponding pools. + +The extended asset (might be negative) 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. + +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}}. + + +

changefee

+ +--- +spec_version: "0.2.0" +title: Change fee +summary: 'Change the fee value associated to a pair' +--- + +The account fee_contract associated to the token {{pair_token}} authorizes +to change the fee parameter associated to the same token, to the value {{newfee}}. + + +

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. +(7) The account fee_contract corresponding to the token {{asset_to_symbol_code quantity}} is notified. \ 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..62aaaca7ca --- /dev/null +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -0,0 +1,292 @@ +#include "evolutiondex.hpp" +#include "utils.hpp" + +using namespace evolution; + +void evolutiondex::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 ); + auto index = acnts.get_index<"extended"_n>(); + const auto& acnt_balance = index.find( + make128key(ext_symbol.get_contract().value, ext_symbol.get_symbol().raw()) ); + if( acnt_balance == index.end() ) { + acnts.emplace( payer, [&]( auto& a ){ + a.balance = extended_asset{0, ext_symbol}; + a.id = acnts.available_primary_key(); + }); + } +} + +void evolutiondex::closeext( const name& user, const name& to, const extended_symbol& ext_symbol, string memo) { + require_auth( user ); + evodexacnts acnts( get_self(), user.value ); + auto index = acnts.get_index<"extended"_n>(); + const auto& acnt_balance = index.find( make128key(ext_symbol.get_contract().value, ext_symbol.get_symbol().raw()) ); + check( acnt_balance != index.end(), "User does not have such token" ); + auto ext_balance = acnt_balance->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(); + } + index.erase( acnt_balance ); +} + +void evolutiondex::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 evolutiondex"); + check(quantity.amount >= 0, "quantity must be positive"); + + auto incoming = extended_asset{quantity, get_first_receiver()}; + 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 evolutiondex::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 evolutiondex::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 evolutiondex::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); +} + +// computes x * y / z plus the fee +int64_t evolutiondex::compute(int64_t x, int64_t y, int64_t z, int fee) { + check( (x != 0) && (y > 0) && (z > 0), "invalid parameters"); + int128_t prod = int128_t(x) * int128_t(y); + int128_t tmp = 0; + int128_t tmp_fee = 0; + if (x > 0) { + tmp = 1 + (prod - 1) / int128_t(z); + check( (tmp <= MAX), "computation overflow" ); + tmp_fee = (tmp * fee + 9999) / 10000; + } else { + tmp = prod / int128_t(z); + check( (tmp >= -MAX), "computation underflow" ); + tmp_fee = (-tmp * fee + 9999) / 10000; + } + tmp += tmp_fee; + return int64_t(tmp); +} + +void evolutiondex::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(), to_add.symbol.code().raw() ); + const auto& token = statstable.find( to_add.symbol.code().raw() ); + check ( token != statstable.end(), "pair token does not exist" ); + 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); + if (token->fee_contract) require_recipient(token->fee_contract); + statstable.modify( token, same_payer, [&]( auto& a ) { + a.supply += to_add; + a.pool1 += to_pay1; + a.pool2 += to_pay2; + }); + check(token->supply.amount != 0, "the pool cannot be left empty"); +} + +void evolutiondex::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) && (min_expected.amount >= 0)) || + ((ext_asset_in.quantity.amount < 0) && (min_expected.amount <= 0)), + "ext_asset_in must be nonzero and min_expected must have same sign or be zero"); + 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); +} + +extended_asset evolutiondex::process_exch(symbol_code pair_token, + extended_asset ext_asset_in, asset min_expected){ + stats statstable( get_self(), pair_token.raw() ); + const auto token = statstable.find( pair_token.raw() ); + check ( token != statstable.end(), "pair token does not exist" ); + 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; + } + auto A_in = ext_asset_in.quantity.amount; + int64_t A_out = compute(-A_in, P_out, P_in + 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; + } + statstable.modify( token, same_payer, [&]( auto& a ) { + a.pool1 += ext_asset1; + a.pool2 += ext_asset2; + }); + return ext_asset_out; +} + +void evolutiondex::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 evolutiondex::inittoken(name user, symbol new_symbol, extended_asset initial_pool1, +extended_asset initial_pool2, int initial_fee, name fee_contract) +{ + 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" ); + int128_t geometric_mean = sqrt(int128_t(initial_pool1.quantity.amount) * int128_t(initial_pool2.quantity.amount)); + auto new_token = asset{int64_t(geometric_mean), new_symbol}; + check( initial_pool1.get_extended_symbol() != initial_pool2.get_extended_symbol(), "extended symbols must be different"); + + stats statstable( get_self(), new_symbol.code().raw() ); + const auto& token = statstable.find( new_symbol.code().raw() ); + check ( token == statstable.end(), "token symbol already exists" ); + check( initial_fee == DEFAULT_FEE, "initial_fee must be 10"); + check( fee_contract == "wevotethefee"_n, "fee_contract must be wevotethefee"); + + statstable.emplace( user, [&]( auto& a ) { + a.supply = new_token; + a.max_supply = asset{MAX,new_symbol}; + a.issuer = get_self(); + a.pool1 = initial_pool1; + a.pool2 = initial_pool2; + a.fee = initial_fee; + a.fee_contract = fee_contract; + } ); + + placeindex(user, new_symbol, initial_pool1, initial_pool2 ); + add_balance(user, new_token, user); + add_signed_ext_balance(user, -initial_pool1); + add_signed_ext_balance(user, -initial_pool2); +} + +void evolutiondex::indexpair(name user, symbol evo_symbol) { + stats statstable( get_self(), evo_symbol.code().raw() ); + const auto& token = statstable.find( evo_symbol.code().raw() ); + check ( token != statstable.end(), "token symbol does not exist" ); + auto pool1 = token->pool1; + auto pool2 = token->pool2; + placeindex(user, evo_symbol, pool1, pool2); +} + +void evolutiondex::placeindex(name user, symbol evo_symbol, + extended_asset pool1, extended_asset pool2 ) { + auto id_256 = make256key(pool1.contract.value, pool1.quantity.symbol.raw(), + pool2.contract.value, pool2.quantity.symbol.raw()); + evoindexes indextable( get_self(), get_self().value ); + auto index = indextable.get_index<"extended"_n>(); + const auto& info = index.find( id_256 ); + check( info == index.end(), "the pool is already indexed"); + indextable.emplace( user, [&]( auto& a ){ + a.evo_symbol = evo_symbol; + a.id_256 = id_256; + }); +} + +void evolutiondex::changefee(symbol_code pair_token, int newfee) { + stats statstable( get_self(), pair_token.raw() ); + const auto& token = statstable.find( pair_token.raw() ); + check ( token != statstable.end(), "pair token does not exist" ); + require_auth(token->fee_contract); + statstable.modify( token, same_payer, [&]( auto& a ) { + a.fee = newfee; + } ); +} + +uint128_t evolutiondex::make128key(uint64_t a, uint64_t b) { + uint128_t aa = a; + uint128_t bb = b; + return (aa << 64) + bb; +} + +checksum256 evolutiondex::make256key(uint64_t a, uint64_t b, uint64_t c, uint64_t d) { + if (make128key(a,b) < make128key(c,d)) + return checksum256::make_from_word_sequence(a,b,c,d); + else + return checksum256::make_from_word_sequence(c,d,a,b); +} + +void evolutiondex::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 ); + auto index = acnts.get_index<"extended"_n>(); + const auto& acnt_balance = index.find( make128key(to_add.contract.value, to_add.quantity.symbol.raw() ) ); + check( acnt_balance != index.end(), "extended_symbol not registered for this user,\ + please run openext action or write exchange details in the memo of your transfer"); + index.modify( acnt_balance, same_payer, [&]( auto& a ) { + a.balance += to_add; + check( a.balance.quantity.amount >= 0, "insufficient funds"); + }); +} \ No newline at end of file diff --git a/contracts/sysio.swap/tests/CMakeLists.txt b/contracts/sysio.swap/tests/CMakeLists.txt new file mode 100644 index 0000000000..4d1f29b973 --- /dev/null +++ b/contracts/sysio.swap/tests/CMakeLists.txt @@ -0,0 +1,15 @@ +project(evolutiondex_tests) +cmake_minimum_required(VERSION 3.12) + +find_package(eosio) + +enable_testing() + +configure_file(${CMAKE_SOURCE_DIR}/contracts.hpp.in ${CMAKE_BINARY_DIR}/contracts.hpp) + +include_directories(${CMAKE_BINARY_DIR}) + +file(GLOB UNIT_TESTS "*.cpp" "*.hpp") + +add_eosio_test( unit_test ${UNIT_TESTS} ) + diff --git a/contracts/sysio.swap/tests/badtoken/badtoken.cpp b/contracts/sysio.swap/tests/badtoken/badtoken.cpp new file mode 100644 index 0000000000..6ec831a60f --- /dev/null +++ b/contracts/sysio.swap/tests/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( "evolutiondex"_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/sysio.swap/tests/badtoken/badtoken.hpp b/contracts/sysio.swap/tests/badtoken/badtoken.hpp new file mode 100644 index 0000000000..06c5e7bee7 --- /dev/null +++ b/contracts/sysio.swap/tests/badtoken/badtoken.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include +#include + +using namespace eosio; +using namespace std; + +class [[eosio::contract("badtoken")]] badtoken : public contract { + public: + using contract::contract; + + [[eosio::action]] void transfer( const name& from, const name& to, + const asset& quantity, const string& memo ); + [[eosio::on_notify("evolutiondex::transfer")]] void ontransfer( const name& from, const name& to, + const asset& quantity, const string& memo ); + [[eosio::on_notify("evolutiondex::addliquidity")]] void addliquidity(name user, asset to_buy, + asset max_asset1, asset max_asset2); + [[eosio::on_notify("evolutiondex::remliquidity")]] void remliquidity(name user, asset to_sell, + asset min_asset1, asset min_asset2); +}; \ No newline at end of file diff --git a/contracts/sysio.swap/tests/contracts.hpp.in b/contracts/sysio.swap/tests/contracts.hpp.in new file mode 100644 index 0000000000..cfa925b287 --- /dev/null +++ b/contracts/sysio.swap/tests/contracts.hpp.in @@ -0,0 +1,31 @@ +#pragma once +#include + +namespace eosio { namespace testing { + +struct contracts { + static std::vector system_wasm() { return read_wasm("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.system/eosio.system.wasm"); } + static std::vector system_abi() { return read_abi("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.system/eosio.system.abi"); } + + static std::vector token_wasm() { return read_wasm("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.token/eosio.token.wasm"); } + static std::vector token_abi() { return read_abi("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.token/eosio.token.abi"); } + + static std::vector msig_wasm() { return read_wasm("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.msig/eosio.msig.wasm"); } + static std::vector msig_abi() { return read_abi("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.msig/eosio.msig.abi"); } + + static std::vector wrap_wasm() { return read_wasm("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.wrap/eosio.wrap.wasm"); } + static std::vector wrap_abi() { return read_abi("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.wrap/eosio.wrap.abi"); } + + static std::vector bios_wasm() { return read_wasm("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.bios/eosio.bios.wasm"); } + static std::vector bios_abi() { return read_abi("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.bios/eosio.bios.abi"); } + + static std::vector evolutiondex_wasm() { return read_wasm("${CMAKE_CURRENT_SOURCE_DIR}/../evolutiondex.wasm"); } + static std::vector evolutiondex_abi() { return read_abi("${CMAKE_CURRENT_SOURCE_DIR}/../evolutiondex.abi"); } + + static std::vector badtoken_wasm() { return read_wasm("${CMAKE_CURRENT_SOURCE_DIR}/badtoken/badtoken.wasm"); } + static std::vector badtoken_abi() { return read_abi("${CMAKE_CURRENT_SOURCE_DIR}/badtoken/badtoken.abi"); } + + static std::vector wevotethefee_wasm() { return read_wasm("${CMAKE_CURRENT_SOURCE_DIR}/../wevotethefee/wevotethefee.wasm"); } + static std::vector wevotethefee_abi() { return read_abi("${CMAKE_CURRENT_SOURCE_DIR}/../wevotethefee/wevotethefee.abi"); } +}; +}} //ns eosio::testing \ No newline at end of file diff --git a/contracts/sysio.swap/tests/eosio.system_tester.hpp b/contracts/sysio.swap/tests/eosio.system_tester.hpp new file mode 100644 index 0000000000..1cdca29b5b --- /dev/null +++ b/contracts/sysio.swap/tests/eosio.system_tester.hpp @@ -0,0 +1,1101 @@ +#pragma once + +#include +#include +#include +#include "contracts.hpp" +#include "test_symbol.hpp" + +#include +#include + +using namespace eosio::chain; +using namespace eosio::testing; +using namespace fc; + +using mvo = fc::mutable_variant_object; + +#ifndef TESTER +#ifdef NON_VALIDATING_TEST +#define TESTER tester +#else +#define TESTER validating_tester +#endif +#endif + +namespace eosio_system { + + +class eosio_system_tester : public TESTER { +public: + + void basic_setup() { + produce_blocks( 2 ); + + create_accounts({ N(eosio.token), N(eosio.ram), N(eosio.ramfee), N(eosio.stake), + N(eosio.bpay), N(eosio.vpay), N(eosio.saving), N(eosio.names), N(eosio.rex) }); + + + produce_blocks( 100 ); + set_code( N(eosio.token), contracts::token_wasm()); + set_abi( N(eosio.token), contracts::token_abi().data() ); + { + const auto& accnt = control->db().get( N(eosio.token) ); + abi_def abi; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt.abi, abi), true); + token_abi_ser.set_abi(abi, abi_serializer_max_time); + } + } + + void create_core_token( symbol core_symbol = symbol{CORE_SYM} ) { + FC_ASSERT( core_symbol.decimals() == 4, "create_core_token assumes core token has 4 digits of precision" ); + create_currency( N(eosio.token), config::system_account_name, asset(100000000000000, core_symbol) ); + issue( asset(10000000000000, core_symbol) ); + BOOST_REQUIRE_EQUAL( asset(10000000000000, core_symbol), get_balance( "eosio", core_symbol ) ); + } + + void deploy_contract( bool call_init = true ) { + set_code( config::system_account_name, contracts::system_wasm() ); + set_abi( config::system_account_name, contracts::system_abi().data() ); + if( call_init ) { + base_tester::push_action(config::system_account_name, N(init), + config::system_account_name, mutable_variant_object() + ("version", 0) + ("core", CORE_SYM_STR) + ); + } + + { + const auto& accnt = control->db().get( config::system_account_name ); + abi_def abi; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt.abi, abi), true); + abi_ser.set_abi(abi, abi_serializer_max_time); + } + } + + void remaining_setup() { + produce_blocks(); + + // Assumes previous setup steps were done with core token symbol set to CORE_SYM + create_account_with_resources( N(alice1111111), config::system_account_name, core_sym::from_string("1.0000"), false ); + create_account_with_resources( N(bob111111111), config::system_account_name, core_sym::from_string("0.4500"), false ); + create_account_with_resources( N(carol1111111), config::system_account_name, core_sym::from_string("1.0000"), false ); + + BOOST_REQUIRE_EQUAL( core_sym::from_string("1000000000.0000"), get_balance("eosio") + get_balance("eosio.ramfee") + get_balance("eosio.stake") + get_balance("eosio.ram") ); + } + + enum class setup_level { + none, + minimal, + core_token, + deploy_contract, + full + }; + + eosio_system_tester( setup_level l = setup_level::full ) { + if( l == setup_level::none ) return; + + basic_setup(); + if( l == setup_level::minimal ) return; + + create_core_token(); + if( l == setup_level::core_token ) return; + + deploy_contract(); + if( l == setup_level::deploy_contract ) return; + + remaining_setup(); + } + + template + eosio_system_tester(Lambda setup) { + setup(*this); + + basic_setup(); + create_core_token(); + deploy_contract(); + remaining_setup(); + } + + + void create_accounts_with_resources( vector accounts, account_name creator = config::system_account_name ) { + for( auto a : accounts ) { + create_account_with_resources( a, creator ); + } + } + + transaction_trace_ptr create_account_with_resources( account_name a, account_name creator, uint32_t ram_bytes = 8000 ) { + signed_transaction trx; + set_transaction_headers(trx); + + authority owner_auth; + owner_auth = authority( get_public_key( a, "owner" ) ); + + trx.actions.emplace_back( vector{{creator,config::active_name}}, + newaccount{ + .creator = creator, + .name = a, + .owner = owner_auth, + .active = authority( get_public_key( a, "active" ) ) + }); + + trx.actions.emplace_back( get_action( config::system_account_name, N(buyrambytes), vector{{creator,config::active_name}}, + mvo() + ("payer", creator) + ("receiver", a) + ("bytes", ram_bytes) ) + ); + trx.actions.emplace_back( get_action( config::system_account_name, N(delegatebw), vector{{creator,config::active_name}}, + mvo() + ("from", creator) + ("receiver", a) + ("stake_net_quantity", core_sym::from_string("10.0000") ) + ("stake_cpu_quantity", core_sym::from_string("10.0000") ) + ("transfer", 0 ) + ) + ); + + set_transaction_headers(trx); + trx.sign( get_private_key( creator, "active" ), control->get_chain_id() ); + return push_transaction( trx ); + } + + transaction_trace_ptr create_account_with_resources( account_name a, account_name creator, asset ramfunds, bool multisig, + asset net = core_sym::from_string("10.0000"), asset cpu = core_sym::from_string("10.0000") ) { + signed_transaction trx; + set_transaction_headers(trx); + + authority owner_auth; + if (multisig) { + // multisig between account's owner key and creators active permission + owner_auth = authority(2, {key_weight{get_public_key( a, "owner" ), 1}}, {permission_level_weight{{creator, config::active_name}, 1}}); + } else { + owner_auth = authority( get_public_key( a, "owner" ) ); + } + + trx.actions.emplace_back( vector{{creator,config::active_name}}, + newaccount{ + .creator = creator, + .name = a, + .owner = owner_auth, + .active = authority( get_public_key( a, "active" ) ) + }); + + trx.actions.emplace_back( get_action( config::system_account_name, N(buyram), vector{{creator,config::active_name}}, + mvo() + ("payer", creator) + ("receiver", a) + ("quant", ramfunds) ) + ); + + trx.actions.emplace_back( get_action( config::system_account_name, N(delegatebw), vector{{creator,config::active_name}}, + mvo() + ("from", creator) + ("receiver", a) + ("stake_net_quantity", net ) + ("stake_cpu_quantity", cpu ) + ("transfer", 0 ) + ) + ); + + set_transaction_headers(trx); + trx.sign( get_private_key( creator, "active" ), control->get_chain_id() ); + return push_transaction( trx ); + } + + transaction_trace_ptr setup_producer_accounts( const std::vector& accounts, + asset ram = core_sym::from_string("1.0000"), + asset cpu = core_sym::from_string("80.0000"), + asset net = core_sym::from_string("80.0000") + ) + { + account_name creator(config::system_account_name); + signed_transaction trx; + set_transaction_headers(trx); + + for (const auto& a: accounts) { + authority owner_auth( get_public_key( a, "owner" ) ); + trx.actions.emplace_back( vector{{creator,config::active_name}}, + newaccount{ + .creator = creator, + .name = a, + .owner = owner_auth, + .active = authority( get_public_key( a, "active" ) ) + }); + + trx.actions.emplace_back( get_action( config::system_account_name, N(buyram), vector{ {creator, config::active_name} }, + mvo() + ("payer", creator) + ("receiver", a) + ("quant", ram) ) + ); + + trx.actions.emplace_back( get_action( config::system_account_name, N(delegatebw), vector{ {creator, config::active_name} }, + mvo() + ("from", creator) + ("receiver", a) + ("stake_net_quantity", net) + ("stake_cpu_quantity", cpu ) + ("transfer", 0 ) + ) + ); + } + + set_transaction_headers(trx); + trx.sign( get_private_key( creator, "active" ), control->get_chain_id() ); + return push_transaction( trx ); + } + + action_result buyram( const account_name& payer, account_name receiver, const asset& eosin ) { + return push_action( payer, N(buyram), mvo()( "payer",payer)("receiver",receiver)("quant",eosin) ); + } + action_result buyram( std::string_view payer, std::string_view receiver, const asset& eosin ) { + return buyram( account_name(payer), account_name(receiver), eosin ); + } + + action_result buyrambytes( const account_name& payer, account_name receiver, uint32_t numbytes ) { + return push_action( payer, N(buyrambytes), mvo()( "payer",payer)("receiver",receiver)("bytes",numbytes) ); + } + action_result buyrambytes( std::string_view payer, std::string_view receiver, uint32_t numbytes ) { + return buyrambytes( account_name(payer), account_name(receiver), numbytes ); + } + + action_result sellram( const account_name& account, uint64_t numbytes ) { + return push_action( account, N(sellram), mvo()( "account", account)("bytes",numbytes) ); + } + action_result sellram( std::string_view account, uint64_t numbytes ) { + return sellram( account_name(account), numbytes ); + } + + action_result push_action( const account_name& signer, const action_name &name, const variant_object &data, bool auth = true ) { + string action_type_name = abi_ser.get_action_type(name); + + action act; + act.account = config::system_account_name; + act.name = name; + act.data = abi_ser.variant_to_binary( action_type_name, data, abi_serializer_max_time ); + + return base_tester::push_action( std::move(act), (auth ? signer : signer == N(bob111111111) ? N(alice1111111) : N(bob111111111)).to_uint64_t() ); + } + + action_result stake( const account_name& from, const account_name& to, const asset& net, const asset& cpu ) { + return push_action( name(from), N(delegatebw), mvo() + ("from", from) + ("receiver", to) + ("stake_net_quantity", net) + ("stake_cpu_quantity", cpu) + ("transfer", 0 ) + ); + } + action_result stake( std::string_view from, std::string_view to, const asset& net, const asset& cpu ) { + return stake( account_name(from), account_name(to), net, cpu ); + } + + action_result stake( const account_name& acnt, const asset& net, const asset& cpu ) { + return stake( acnt, acnt, net, cpu ); + } + action_result stake( std::string_view acnt, const asset& net, const asset& cpu ) { + return stake( account_name(acnt), net, cpu ); + } + + action_result stake_with_transfer( const account_name& from, const account_name& to, const asset& net, const asset& cpu ) { + return push_action( name(from), N(delegatebw), mvo() + ("from", from) + ("receiver", to) + ("stake_net_quantity", net) + ("stake_cpu_quantity", cpu) + ("transfer", true ) + ); + } + action_result stake_with_transfer( std::string_view from, std::string_view to, const asset& net, const asset& cpu ) { + return stake_with_transfer( account_name(from), account_name(to), net, cpu ); + } + + action_result stake_with_transfer( const account_name& acnt, const asset& net, const asset& cpu ) { + return stake_with_transfer( acnt, acnt, net, cpu ); + } + action_result stake_with_transfer( std::string_view acnt, const asset& net, const asset& cpu ) { + return stake_with_transfer( account_name(acnt), net, cpu ); + } + + action_result unstake( const account_name& from, const account_name& to, const asset& net, const asset& cpu ) { + return push_action( name(from), N(undelegatebw), mvo() + ("from", from) + ("receiver", to) + ("unstake_net_quantity", net) + ("unstake_cpu_quantity", cpu) + ); + } + action_result unstake( std::string_view from, std::string_view to, const asset& net, const asset& cpu ) { + return unstake( account_name(from), account_name(to), net, cpu ); + } + + action_result unstake( const account_name& acnt, const asset& net, const asset& cpu ) { + return unstake( acnt, acnt, net, cpu ); + } + action_result unstake( std::string_view acnt, const asset& net, const asset& cpu ) { + return unstake( account_name(acnt), net, cpu ); + } + + int64_t bancor_convert( int64_t S, int64_t R, int64_t T ) { return double(R) * T / ( double(S) + T ); }; + + int64_t get_net_limit( account_name a ) { + int64_t ram_bytes = 0, net = 0, cpu = 0; + control->get_resource_limits_manager().get_account_limits( a, ram_bytes, net, cpu ); + return net; + }; + + int64_t get_cpu_limit( account_name a ) { + int64_t ram_bytes = 0, net = 0, cpu = 0; + control->get_resource_limits_manager().get_account_limits( a, ram_bytes, net, cpu ); + return cpu; + }; + + action_result deposit( const account_name& owner, const asset& amount ) { + return push_action( name(owner), N(deposit), mvo() + ("owner", owner) + ("amount", amount) + ); + } + + action_result withdraw( const account_name& owner, const asset& amount ) { + return push_action( name(owner), N(withdraw), mvo() + ("owner", owner) + ("amount", amount) + ); + } + + action_result buyrex( const account_name& from, const asset& amount ) { + return push_action( name(from), N(buyrex), mvo() + ("from", from) + ("amount", amount) + ); + } + + asset get_buyrex_result( const account_name& from, const asset& amount ) { + auto trace = base_tester::push_action( config::system_account_name, N(buyrex), from, mvo()("from", from)("amount", amount) ); + asset rex_received; + for ( size_t i = 0; i < trace->action_traces.size(); ++i ) { + if ( trace->action_traces[i].act.name == N(buyresult) ) { + fc::raw::unpack( trace->action_traces[i].act.data.data(), + trace->action_traces[i].act.data.size(), + rex_received ); + return rex_received; + } + } + return rex_received; + } + + action_result unstaketorex( const account_name& owner, const account_name& receiver, const asset& from_net, const asset& from_cpu ) { + return push_action( name(owner), N(unstaketorex), mvo() + ("owner", owner) + ("receiver", receiver) + ("from_net", from_net) + ("from_cpu", from_cpu) + ); + } + + asset get_unstaketorex_result( const account_name& owner, const account_name& receiver, const asset& from_net, const asset& from_cpu ) { + auto trace = base_tester::push_action( config::system_account_name, N(unstaketorex), owner, mvo() + ("owner", owner) + ("receiver", receiver) + ("from_net", from_net) + ("from_cpu", from_cpu) + ); + asset rex_received; + for ( size_t i = 0; i < trace->action_traces.size(); ++i ) { + if ( trace->action_traces[i].act.name == N(buyresult) ) { + fc::raw::unpack( trace->action_traces[i].act.data.data(), + trace->action_traces[i].act.data.size(), + rex_received ); + return rex_received; + } + } + return rex_received; + } + + action_result sellrex( const account_name& from, const asset& rex ) { + return push_action( name(from), N(sellrex), mvo() + ("from", from) + ("rex", rex) + ); + } + + asset get_sellrex_result( const account_name& from, const asset& rex ) { + auto trace = base_tester::push_action( config::system_account_name, N(sellrex), from, mvo()("from", from)("rex", rex) ); + asset proceeds; + for ( size_t i = 0; i < trace->action_traces.size(); ++i ) { + if ( trace->action_traces[i].act.name == N(sellresult) ) { + fc::raw::unpack( trace->action_traces[i].act.data.data(), + trace->action_traces[i].act.data.size(), + proceeds ); + return proceeds; + } + } + return proceeds; + } + + auto get_rexorder_result( const transaction_trace_ptr& trace ) { + std::vector> output; + for ( size_t i = 0; i < trace->action_traces.size(); ++i ) { + if ( trace->action_traces[i].act.name == N(orderresult) ) { + fc::datastream ds( trace->action_traces[i].act.data.data(), + trace->action_traces[i].act.data.size() ); + account_name owner; fc::raw::unpack( ds, owner ); + asset proceeds; fc::raw::unpack( ds, proceeds ); + output.emplace_back( owner, proceeds ); + } + } + return output; + } + + action_result cancelrexorder( const account_name& owner ) { + return push_action( name(owner), N(cnclrexorder), mvo()("owner", owner) ); + } + + action_result rentcpu( const account_name& from, const account_name& receiver, const asset& payment, const asset& fund = core_sym::from_string("0.0000") ) { + return push_action( name(from), N(rentcpu), mvo() + ("from", from) + ("receiver", receiver) + ("loan_payment", payment) + ("loan_fund", fund) + ); + } + + action_result rentnet( const account_name& from, const account_name& receiver, const asset& payment, const asset& fund = core_sym::from_string("0.0000") ) { + return push_action( name(from), N(rentnet), mvo() + ("from", from) + ("receiver", receiver) + ("loan_payment", payment) + ("loan_fund", fund) + ); + } + + asset _get_rentrex_result( const account_name& from, const account_name& receiver, const asset& payment, bool cpu ) { + const name act = cpu ? N(rentcpu) : N(rentnet); + auto trace = base_tester::push_action( config::system_account_name, act, from, mvo() + ("from", from) + ("receiver", receiver) + ("loan_payment", payment) + ("loan_fund", core_sym::from_string("0.0000") ) + ); + + asset rented_tokens = core_sym::from_string("0.0000"); + for ( size_t i = 0; i < trace->action_traces.size(); ++i ) { + if ( trace->action_traces[i].act.name == N(rentresult) ) { + fc::raw::unpack( trace->action_traces[i].act.data.data(), + trace->action_traces[i].act.data.size(), + rented_tokens ); + return rented_tokens; + } + } + return rented_tokens; + } + + asset get_rentcpu_result( const account_name& from, const account_name& receiver, const asset& payment ) { + return _get_rentrex_result( from, receiver, payment, true ); + } + + asset get_rentnet_result( const account_name& from, const account_name& receiver, const asset& payment ) { + return _get_rentrex_result( from, receiver, payment, false ); + } + + action_result fundcpuloan( const account_name& from, const uint64_t loan_num, const asset& payment ) { + return push_action( name(from), N(fundcpuloan), mvo() + ("from", from) + ("loan_num", loan_num) + ("payment", payment) + ); + } + + action_result fundnetloan( const account_name& from, const uint64_t loan_num, const asset& payment ) { + return push_action( name(from), N(fundnetloan), mvo() + ("from", from) + ("loan_num", loan_num) + ("payment", payment) + ); + } + + + action_result defundcpuloan( const account_name& from, const uint64_t loan_num, const asset& amount ) { + return push_action( name(from), N(defcpuloan), mvo() + ("from", from) + ("loan_num", loan_num) + ("amount", amount) + ); + } + + action_result defundnetloan( const account_name& from, const uint64_t loan_num, const asset& amount ) { + return push_action( name(from), N(defnetloan), mvo() + ("from", from) + ("loan_num", loan_num) + ("amount", amount) + ); + } + + action_result updaterex( const account_name& owner ) { + return push_action( name(owner), N(updaterex), mvo()("owner", owner) ); + } + + action_result rexexec( const account_name& user, uint16_t max ) { + return push_action( name(user), N(rexexec), mvo()("user", user)("max", max) ); + } + + action_result consolidate( const account_name& owner ) { + return push_action( name(owner), N(consolidate), mvo()("owner", owner) ); + } + + action_result mvtosavings( const account_name& owner, const asset& rex ) { + return push_action( name(owner), N(mvtosavings), mvo()("owner", owner)("rex", rex) ); + } + + action_result mvfrsavings( const account_name& owner, const asset& rex ) { + return push_action( name(owner), N(mvfrsavings), mvo()("owner", owner)("rex", rex) ); + } + + action_result closerex( const account_name& owner ) { + return push_action( name(owner), N(closerex), mvo()("owner", owner) ); + } + + fc::variant get_last_loan(bool cpu) { + vector data; + const auto& db = control->db(); + namespace chain = eosio::chain; + auto table = cpu ? N(cpuloan) : N(netloan); + const auto* t_id = db.find( boost::make_tuple( config::system_account_name, config::system_account_name, table ) ); + if ( !t_id ) { + return fc::variant(); + } + + const auto& idx = db.get_index(); + + auto itr = idx.upper_bound( boost::make_tuple( t_id->id, std::numeric_limits::max() )); + if ( itr == idx.begin() ) { + return fc::variant(); + } + --itr; + if ( itr->t_id != t_id->id ) { + return fc::variant(); + } + + data.resize( itr->value.size() ); + memcpy( data.data(), itr->value.data(), data.size() ); + return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "rex_loan", data, abi_serializer_max_time ); + } + + fc::variant get_last_cpu_loan() { + return get_last_loan( true ); + } + + fc::variant get_last_net_loan() { + return get_last_loan( false ); + } + + fc::variant get_loan_info( const uint64_t& loan_num, bool cpu ) const { + name table_name = cpu ? N(cpuloan) : N(netloan); + vector data = get_row_by_account( config::system_account_name, config::system_account_name, table_name, account_name(loan_num) ); + return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "rex_loan", data, abi_serializer_max_time ); + } + + fc::variant get_cpu_loan( const uint64_t loan_num ) const { + return get_loan_info( loan_num, true ); + } + + fc::variant get_net_loan( const uint64_t loan_num ) const { + return get_loan_info( loan_num, false ); + } + + fc::variant get_dbw_obj( const account_name& from, const account_name& receiver ) const { + vector data = get_row_by_account( config::system_account_name, from, N(delband), receiver ); + return data.empty() ? fc::variant() : abi_ser.binary_to_variant("delegated_bandwidth", data, abi_serializer_max_time); + } + + asset get_rex_balance( const account_name& act ) const { + vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(rexbal), act ); + return data.empty() ? asset(0, symbol(SY(4, REX))) : abi_ser.binary_to_variant("rex_balance", data, abi_serializer_max_time)["rex_balance"].as(); + } + + fc::variant get_rex_balance_obj( const account_name& act ) const { + vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(rexbal), act ); + return data.empty() ? fc::variant() : abi_ser.binary_to_variant("rex_balance", data, abi_serializer_max_time); + } + + asset get_rex_fund( const account_name& act ) const { + vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(rexfund), act ); + return data.empty() ? asset(0, symbol{CORE_SYM}) : abi_ser.binary_to_variant("rex_fund", data, abi_serializer_max_time)["balance"].as(); + } + + fc::variant get_rex_fund_obj( const account_name& act ) const { + vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(rexfund), act ); + return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "rex_fund", data, abi_serializer_max_time ); + } + + asset get_rex_vote_stake( const account_name& act ) const { + vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(rexbal), act ); + return data.empty() ? core_sym::from_string("0.0000") : abi_ser.binary_to_variant("rex_balance", data, abi_serializer_max_time)["vote_stake"].as(); + } + + fc::variant get_rex_order( const account_name& act ) { + vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(rexqueue), act ); + return abi_ser.binary_to_variant( "rex_order", data, abi_serializer_max_time ); + } + + fc::variant get_rex_order_obj( const account_name& act ) { + vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(rexqueue), act ); + return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "rex_order", data, abi_serializer_max_time ); + } + + fc::variant get_rex_pool() const { + vector data; + const auto& db = control->db(); + namespace chain = eosio::chain; + const auto* t_id = db.find( boost::make_tuple( config::system_account_name, config::system_account_name, N(rexpool) ) ); + if ( !t_id ) { + return fc::variant(); + } + + const auto& idx = db.get_index(); + + auto itr = idx.lower_bound( boost::make_tuple( t_id->id, 0 ) ); + if ( itr == idx.end() || itr->t_id != t_id->id || 0 != itr->primary_key ) { + return fc::variant(); + } + + data.resize( itr->value.size() ); + memcpy( data.data(), itr->value.data(), data.size() ); + return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "rex_pool", data, abi_serializer_max_time ); + } + + void setup_rex_accounts( const std::vector& accounts, + const asset& init_balance, + const asset& net = core_sym::from_string("80.0000"), + const asset& cpu = core_sym::from_string("80.0000"), + bool deposit_into_rex_fund = true ) { + const asset nstake = core_sym::from_string("10.0000"); + const asset cstake = core_sym::from_string("10.0000"); + create_account_with_resources( N(proxyaccount), config::system_account_name, core_sym::from_string("1.0000"), false, net, cpu ); + BOOST_REQUIRE_EQUAL( success(), push_action( N(proxyaccount), N(regproxy), mvo()("proxy", "proxyaccount")("isproxy", true) ) ); + for (const auto& a: accounts) { + create_account_with_resources( a, config::system_account_name, core_sym::from_string("1.0000"), false, net, cpu ); + transfer( config::system_account_name, a, init_balance + nstake + cstake, config::system_account_name ); + BOOST_REQUIRE_EQUAL( success(), stake( a, a, nstake, cstake) ); + BOOST_REQUIRE_EQUAL( success(), vote( a, { }, N(proxyaccount) ) ); + BOOST_REQUIRE_EQUAL( init_balance, get_balance(a) ); + BOOST_REQUIRE_EQUAL( asset::from_string("0.0000 REX"), get_rex_balance(a) ); + if (deposit_into_rex_fund) { + BOOST_REQUIRE_EQUAL( success(), deposit( a, init_balance ) ); + BOOST_REQUIRE_EQUAL( init_balance, get_rex_fund( a ) ); + BOOST_REQUIRE_EQUAL( 0, get_balance( a ).get_amount() ); + } + } + } + + action_result bidname( const account_name& bidder, const account_name& newname, const asset& bid ) { + return push_action( name(bidder), N(bidname), mvo() + ("bidder", bidder) + ("newname", newname) + ("bid", bid) + ); + } + action_result bidname( std::string_view bidder, std::string_view newname, const asset& bid ) { + return bidname( account_name(bidder), account_name(newname), bid ); + } + + static fc::variant_object producer_parameters_example( int n ) { + return mutable_variant_object() + ("max_block_net_usage", 10000000 + n ) + ("target_block_net_usage_pct", 10 + n ) + ("max_transaction_net_usage", 1000000 + n ) + ("base_per_transaction_net_usage", 100 + n) + ("net_usage_leeway", 500 + n ) + ("context_free_discount_net_usage_num", 1 + n ) + ("context_free_discount_net_usage_den", 100 + n ) + ("max_block_cpu_usage", 10000000 + n ) + ("target_block_cpu_usage_pct", 10 + n ) + ("max_transaction_cpu_usage", 1000000 + n ) + ("min_transaction_cpu_usage", 100 + n ) + ("max_transaction_lifetime", 3600 + n) + ("deferred_trx_expiration_window", 600 + n) + ("max_transaction_delay", 10*86400+n) + ("max_inline_action_size", 4096 + n) + ("max_inline_action_depth", 4 + n) + ("max_authority_depth", 6 + n) + ("max_ram_size", (n % 10 + 1) * 1024 * 1024) + ("ram_reserve_ratio", 100 + n); + } + + action_result regproducer( const account_name& acnt, int params_fixture = 1 ) { + action_result r = push_action( acnt, N(regproducer), mvo() + ("producer", acnt ) + ("producer_key", get_public_key( acnt, "active" ) ) + ("url", "" ) + ("location", 0 ) + ); + BOOST_REQUIRE_EQUAL( success(), r); + return r; + } + + action_result vote( const account_name& voter, const std::vector& producers, const account_name& proxy = name(0) ) { + return push_action(voter, N(voteproducer), mvo() + ("voter", voter) + ("proxy", proxy) + ("producers", producers)); + } + action_result vote( const account_name& voter, const std::vector& producers, std::string_view proxy ) { + return vote( voter, producers, account_name(proxy) ); + } + + uint32_t last_block_time() const { + return time_point_sec( control->head_block_time() ).sec_since_epoch(); + } + + asset get_balance( name contract, const account_name& act, symbol balance_symbol = symbol{CORE_SYM} ) { + vector data = get_row_by_account( contract, act, N(accounts), account_name(balance_symbol.to_symbol_code().value) ); + return data.empty() ? asset(0, balance_symbol) : token_abi_ser.binary_to_variant("account", data, abi_serializer_max_time)["balance"].as(); + } + + asset get_balance( const account_name& act, symbol balance_symbol = symbol{CORE_SYM} ) { + return get_balance(N(eosio.token), act, balance_symbol); + } + + asset get_balance( std::string_view act, symbol balance_symbol = symbol{CORE_SYM} ) { + return get_balance( account_name(act), balance_symbol ); + } + + fc::variant get_total_stake( const account_name& act ) { + vector data = get_row_by_account( config::system_account_name, act, N(userres), act ); + return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "user_resources", data, abi_serializer_max_time ); + } + fc::variant get_total_stake( std::string_view act ) { + return get_total_stake( account_name(act) ); + } + + fc::variant get_voter_info( const account_name& act ) { + vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(voters), act ); + return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "voter_info", data, abi_serializer_max_time ); + } + fc::variant get_voter_info( std::string_view act ) { + return get_voter_info( account_name(act) ); + } + + fc::variant get_producer_info( const account_name& act ) { + vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(producers), act ); + return abi_ser.binary_to_variant( "producer_info", data, abi_serializer_max_time ); + } + fc::variant get_producer_info( std::string_view act ) { + return get_producer_info( account_name(act) ); + } + + fc::variant get_producer_info2( const account_name& act ) { + vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(producers2), act ); + return abi_ser.binary_to_variant( "producer_info2", data, abi_serializer_max_time ); + } + fc::variant get_producer_info2( std::string_view act ) { + return get_producer_info2( account_name(act) ); + } + + void create_currency( name contract, name manager, asset maxsupply ) { + auto act = mutable_variant_object() + ("issuer", manager ) + ("maximum_supply", maxsupply ); + + base_tester::push_action(contract, N(create), contract, act ); + } + + void issue( const asset& amount, const name& manager = config::system_account_name ) { + issue(N(eosio.token), amount, manager); + } + + void issue( name contract, const asset& amount, const name& manager = config::system_account_name ) { + base_tester::push_action( contract, N(issue), manager, mutable_variant_object() + ("to", manager ) + ("quantity", amount ) + ("memo", "") + ); + } + + void transfer( const name& contract, const name& from, const name& to, const asset& amount, const name& manager = config::system_account_name ) { + base_tester::push_action( contract, N(transfer), manager, mutable_variant_object() + ("from", from) + ("to", to ) + ("quantity", amount) + ("memo", "") + ); + } + + void transfer( const name& from, const name& to, const asset& amount, const name& manager = config::system_account_name ) { + transfer(N(eosio.token), from, to, amount, manager); + } + + void transfer( const name& from, std::string_view to, const asset& amount, const name& manager = config::system_account_name ) { + transfer( from, name(to), amount, manager ); + } + + void transfer( std::string_view from, std::string_view to, const asset& amount, std::string_view manager ) { + transfer( name(from), name(to), amount, name(manager) ); + } + + void transfer( std::string_view from, std::string_view to, const asset& amount ) { + transfer( name(from), name(to), amount ); + } + + void issue_and_transfer( const name& to, const asset& amount, const name& manager = config::system_account_name ) { + signed_transaction trx; + trx.actions.emplace_back( get_action( N(eosio.token), N(issue), + vector{{manager, config::active_name}}, + mutable_variant_object() + ("to", manager ) + ("quantity", amount ) + ("memo", "") + ) + ); + if ( to != manager ) { + trx.actions.emplace_back( get_action( N(eosio.token), N(transfer), + vector{{manager, config::active_name}}, + mutable_variant_object() + ("from", manager) + ("to", to ) + ("quantity", amount ) + ("memo", "") + ) + ); + } + set_transaction_headers( trx ); + trx.sign( get_private_key( manager, "active" ), control->get_chain_id() ); + push_transaction( trx ); + } + + void issue_and_transfer( std::string_view to, const asset& amount, std::string_view manager ) { + issue_and_transfer( name(to), amount, name(manager) ); + } + + void issue_and_transfer( std::string_view to, const asset& amount, const name& manager ) { + issue_and_transfer( name(to), amount, manager); + } + + void issue_and_transfer( std::string_view to, const asset& amount ) { + issue_and_transfer( name(to), amount ); + } + + double stake2votes( asset stake ) { + auto now = control->pending_block_time().time_since_epoch().count() / 1000000; + return stake.get_amount() * pow(2, int64_t((now - (config::block_timestamp_epoch / 1000)) / (86400 * 7))/ double(52) ); // 52 week periods (i.e. ~years) + } + + double stake2votes( const string& s ) { + return stake2votes( core_sym::from_string(s) ); + } + + fc::variant get_stats( name contract, const string& symbolname ) { + auto symb = eosio::chain::symbol::from_string(symbolname); + auto symbol_code = symb.to_symbol_code().value; + vector data = get_row_by_account( contract, name(symbol_code), N(stat), account_name(symbol_code) ); + return data.empty() ? fc::variant() : token_abi_ser.binary_to_variant( "currency_stats", data, abi_serializer_max_time ); + } + + fc::variant get_stats( const string& symbolname ) { + return get_stats(N(eosio.token), symbolname); + } + + asset get_token_supply() { + return get_stats("4," CORE_SYM_NAME)["supply"].as(); + } + + uint64_t microseconds_since_epoch_of_iso_string( const fc::variant& v ) { + return static_cast( time_point::from_iso_string( v.as_string() ).time_since_epoch().count() ); + } + + fc::variant get_global_state() { + vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(global), N(global) ); + if (data.empty()) std::cout << "\nData is empty\n" << std::endl; + return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "eosio_global_state", data, abi_serializer_max_time ); + } + + fc::variant get_global_state2() { + vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(global2), N(global2) ); + return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "eosio_global_state2", data, abi_serializer_max_time ); + } + + fc::variant get_global_state3() { + vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(global3), N(global3) ); + return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "eosio_global_state3", data, abi_serializer_max_time ); + } + + fc::variant get_refund_request( name account ) { + vector data = get_row_by_account( config::system_account_name, account, N(refunds), account ); + return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "refund_request", data, abi_serializer_max_time ); + } + + abi_serializer initialize_multisig() { + abi_serializer msig_abi_ser; + { + create_account_with_resources( N(eosio.msig), config::system_account_name ); + BOOST_REQUIRE_EQUAL( success(), buyram( N(eosio), N(eosio.msig), core_sym::from_string("5000.0000") ) ); + produce_block(); + + auto trace = base_tester::push_action(config::system_account_name, N(setpriv), + config::system_account_name, mutable_variant_object() + ("account", "eosio.msig") + ("is_priv", 1) + ); + + set_code( N(eosio.msig), contracts::msig_wasm() ); + set_abi( N(eosio.msig), contracts::msig_abi().data() ); + + produce_blocks(); + const auto& accnt = control->db().get( N(eosio.msig) ); + abi_def msig_abi; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt.abi, msig_abi), true); + msig_abi_ser.set_abi(msig_abi, abi_serializer_max_time); + } + return msig_abi_ser; + } + + vector active_and_vote_producers() { + //stake more than 15% of total EOS supply to activate chain + transfer( N(eosio), N(alice1111111), core_sym::from_string("650000000.0000"), config::system_account_name ); + BOOST_REQUIRE_EQUAL( success(), stake( N(alice1111111), N(alice1111111), core_sym::from_string("300000000.0000"), core_sym::from_string("300000000.0000") ) ); + + // create accounts {defproducera, defproducerb, ..., defproducerz} and register as producers + std::vector producer_names; + { + producer_names.reserve('z' - 'a' + 1); + const std::string root("defproducer"); + for ( char c = 'a'; c < 'a'+21; ++c ) { + producer_names.emplace_back(root + std::string(1, c)); + } + setup_producer_accounts(producer_names); + for (const auto& p: producer_names) { + + BOOST_REQUIRE_EQUAL( success(), regproducer(p) ); + } + } + produce_blocks( 250); + + auto trace_auth = TESTER::push_action(config::system_account_name, updateauth::get_name(), config::system_account_name, mvo() + ("account", name(config::system_account_name).to_string()) + ("permission", name(config::active_name).to_string()) + ("parent", name(config::owner_name).to_string()) + ("auth", authority(1, {key_weight{get_public_key( config::system_account_name, "active" ), 1}}, { + permission_level_weight{{config::system_account_name, config::eosio_code_name}, 1}, + permission_level_weight{{config::producers_account_name, config::active_name}, 1} + } + )) + ); + BOOST_REQUIRE_EQUAL(transaction_receipt::executed, trace_auth->receipt->status); + + //vote for producers + { + transfer( config::system_account_name, N(alice1111111), core_sym::from_string("100000000.0000"), config::system_account_name ); + BOOST_REQUIRE_EQUAL(success(), stake( N(alice1111111), core_sym::from_string("30000000.0000"), core_sym::from_string("30000000.0000") ) ); + BOOST_REQUIRE_EQUAL(success(), buyram( N(alice1111111), N(alice1111111), core_sym::from_string("30000000.0000") ) ); + BOOST_REQUIRE_EQUAL(success(), push_action(N(alice1111111), N(voteproducer), mvo() + ("voter", "alice1111111") + ("proxy", name(0).to_string()) + ("producers", vector(producer_names.begin(), producer_names.begin()+21)) + ) + ); + } + produce_blocks( 250 ); + + auto producer_keys = control->head_block_state()->active_schedule.producers; + BOOST_REQUIRE_EQUAL( 21, producer_keys.size() ); + BOOST_REQUIRE_EQUAL( name("defproducera"), producer_keys[0].producer_name ); + + return producer_names; + } + + void cross_15_percent_threshold() { + setup_producer_accounts({N(producer1111)}); + regproducer(N(producer1111)); + { + signed_transaction trx; + set_transaction_headers(trx); + + trx.actions.emplace_back( get_action( config::system_account_name, N(delegatebw), + vector{{config::system_account_name, config::active_name}}, + mvo() + ("from", name{config::system_account_name}) + ("receiver", "producer1111") + ("stake_net_quantity", core_sym::from_string("150000000.0000") ) + ("stake_cpu_quantity", core_sym::from_string("0.0000") ) + ("transfer", 1 ) + ) + ); + trx.actions.emplace_back( get_action( config::system_account_name, N(voteproducer), + vector{{N(producer1111), config::active_name}}, + mvo() + ("voter", "producer1111") + ("proxy", name(0).to_string()) + ("producers", vector(1, N(producer1111))) + ) + ); + trx.actions.emplace_back( get_action( config::system_account_name, N(undelegatebw), + vector{{N(producer1111), config::active_name}}, + mvo() + ("from", "producer1111") + ("receiver", "producer1111") + ("unstake_net_quantity", core_sym::from_string("150000000.0000") ) + ("unstake_cpu_quantity", core_sym::from_string("0.0000") ) + ) + ); + + set_transaction_headers(trx); + trx.sign( get_private_key( config::system_account_name, "active" ), control->get_chain_id() ); + trx.sign( get_private_key( N(producer1111), "active" ), control->get_chain_id() ); + push_transaction( trx ); + produce_block(); + } + } + + action_result setinflation( int64_t annual_rate, int64_t inflation_pay_factor, int64_t votepay_factor ) { + return push_action( N(eosio), N(setinflation), mvo() + ("annual_rate", annual_rate) + ("inflation_pay_factor", inflation_pay_factor) + ("votepay_factor", votepay_factor) + ); + } + + abi_serializer abi_ser; + abi_serializer token_abi_ser; +}; + +inline fc::mutable_variant_object voter( account_name acct ) { + return mutable_variant_object() + ("owner", acct) + ("proxy", name(0).to_string()) + ("producers", variants() ) + ("staked", int64_t(0)) + //("last_vote_weight", double(0)) + ("proxied_vote_weight", double(0)) + ("is_proxy", 0) + ; +} +inline fc::mutable_variant_object voter( std::string_view acct ) { + return voter( account_name(acct) ); +} + +inline fc::mutable_variant_object voter( account_name acct, const asset& vote_stake ) { + return voter( acct )( "staked", vote_stake.get_amount() ); +} +inline fc::mutable_variant_object voter( std::string_view acct, const asset& vote_stake ) { + return voter( account_name(acct), vote_stake ); +} + +inline fc::mutable_variant_object voter( account_name acct, int64_t vote_stake ) { + return voter( acct )( "staked", vote_stake ); +} +inline fc::mutable_variant_object voter( std::string_view acct, int64_t vote_stake ) { + return voter( account_name(acct), vote_stake ); +} + +inline fc::mutable_variant_object proxy( account_name acct ) { + return voter( acct )( "is_proxy", 1 ); +} + +inline uint64_t M( const string& eos_str ) { + return core_sym::from_string( eos_str ).get_amount(); +} + +} + diff --git a/contracts/sysio.swap/tests/evolutiondex_tests.cpp b/contracts/sysio.swap/tests/evolutiondex_tests.cpp new file mode 100644 index 0000000000..3fa29f2906 --- /dev/null +++ b/contracts/sysio.swap/tests/evolutiondex_tests.cpp @@ -0,0 +1,1109 @@ +#include +#include + +#include + +#include +#include +#include + +#include +#include + +using namespace eosio::testing; +using namespace eosio; +using namespace eosio::chain; +using namespace eosio::testing; +using namespace fc; +using namespace std; +using namespace boost::multiprecision; + +using int128 = boost::multiprecision::int128_t; +using int256 = boost::multiprecision::int256_t; +using mvo = fc::mutable_variant_object; + +class evolutiondex_tester : public tester { +public: + + evolutiondex_tester() { + produce_blocks( 2 ); + + create_accounts( { N(alice), N(bob), N(carol), N(eosio.token), N(evolutiondex), + N(wevotethefee), N(badtoken), N(anothertoken) } ); + produce_blocks( 2 ); + + set_code( N(eosio.token), contracts::token_wasm() ); + set_abi( N(eosio.token), contracts::token_abi().data() ); + set_code( N(anothertoken), contracts::token_wasm() ); + set_abi( N(anothertoken), contracts::token_abi().data() ); + + set_code( N(evolutiondex), contracts::evolutiondex_wasm() ); + set_abi( N(evolutiondex), contracts::evolutiondex_abi().data() ); + + set_code( N(carol), contracts::token_wasm() ); + set_abi( N(carol), contracts::token_abi().data() ); + + set_code( N(badtoken), contracts::badtoken_wasm() ); + set_abi( N(badtoken), contracts::badtoken_abi().data() ); + + set_code( N(wevotethefee), contracts::wevotethefee_wasm() ); + set_abi( N(wevotethefee), contracts::wevotethefee_abi().data() ); + + produce_blocks(); + + const auto& accnt1 = control->db().get( N(eosio.token) ); + abi_def abi1; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt1.abi, abi1), true); + abi_ser.set_abi(abi1, abi_serializer_max_time); + } + + 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_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_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, N(create), 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, N(issue), 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, N(transfer), mvo() + ( "from", from) + ( "to", to) + ( "quantity", quantity) + ( "memo", memo) + ); + } + action_result open( name owner, symbol sym, name ram_payer ) { + return push_action( N(evolutiondex), owner, N(open), mvo() + ( "owner", owner) + ( "symbol", sym) + ( "ram_payer", ram_payer) + ); + } + action_result openext( name user, name payer, extended_symbol ext_symbol ) { + return push_action( N(evolutiondex), payer, N(openext), 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( N(evolutiondex), user, N(closeext), mvo() + ( "user", user ) + ( "to", to ) + ( "ext_symbol", ext_symbol ) + ( "memo", "" ) + ); + } + action_result withdraw(name user, name to, extended_asset to_withdraw) { + return push_action( N(evolutiondex), user, N(withdraw), mvo() + ( "user", user ) + ( "to", to ) + ( "to_withdraw", to_withdraw ) + ( "memo", "" ) + ); + } + action_result inittoken( name user, symbol new_symbol, extended_asset initial_pool1, + extended_asset initial_pool2, int initial_fee, name fee_contract){ + std::vector auths{user, N(evolutiondex)}; + try { + eosio::testing::base_tester::push_action( N(evolutiondex), N(inittoken), auths, mvo() + ( "user", user) + ("new_symbol", new_symbol) + ("initial_pool1", initial_pool1) + ("initial_pool2", initial_pool2) + ("initial_fee", initial_fee) + ("fee_contract", fee_contract) + , 100, 0); + } catch (const fc::exception& ex) { + edump((ex)); + edump((ex.to_detail_string())); + return error(ex.top_message()); + } + return success(); + } + action_result addliquidity(name user, asset to_buy, asset max_asset1, asset max_asset2) { + return push_action( N(evolutiondex), user, N(addliquidity), 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( N(evolutiondex), user, N(remliquidity), 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( N(evolutiondex), user, N(exchange), mvo() + ( "user", user ) + ( "pair_token", pair_token ) + ( "ext_asset_in", ext_asset_in ) + ( "min_expected", min_expected ) + ); + } + action_result changefee( symbol_code pair_token, int newfee ) { + return push_action( N(evolutiondex), N(wevotethefee), N(changefee), mvo() + ( "pair_token", pair_token ) + ( "newfee", newfee ) + ); + } + + action_result openfeetable( name user, symbol_code pair_token ) { + return push_action( N(wevotethefee), user, N(openfeetable), mvo() + ( "user", user ) + ( "pair_token", pair_token ) + ); + } + action_result closefeetable( symbol_code pair_token ) { + return push_action( N(wevotethefee), N(wevotethefee), N(closefeetable), mvo() + ( "pair_token", pair_token ) + ); + } + action_result votefee( name user, symbol_code pair_token, int fee_voted ) { + return push_action( N(wevotethefee), user, N(votefee), mvo() + ( "user", user ) + ( "pair_token", pair_token ) + ( "fee_voted", fee_voted ) + ); + } + action_result closevote( name user, symbol_code pair_token ) { + return push_action( N(wevotethefee), user, N(closevote), mvo() + ( "user", user ) + ( "pair_token", pair_token ) + ); + } + action_result updatefee( name user, symbol_code pair_token ) { + return push_action( N(wevotethefee), user, N(updatefee), mvo() + ( "pair_token", pair_token ) + ); + } + + int64_t balance(name user, int64_t id) { + auto _balance = get_balance(N(evolutiondex), user, N(evodexacnts), id, "evodexaccount" ); + return to_int(fc::json::to_string(_balance["balance"]["quantity"], + fc::time_point(fc::time_point::now() + abi_serializer_max_time) )); + } + int64_t tok_balance(name user, int64_t id){ + auto _balance = get_balance(N(evolutiondex), user, N(accounts), 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, N(accounts), 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(N(evolutiondex), name(id), N(stat), 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){ + int256 x = int256(v.at(0)) * int256(v.at(1)) * int256(w.at(2)) * int256(w.at(2)); + int256 y = int256(w.at(0)) * int256(w.at(1)) * int256(v.at(2)) * int256(v.at(2)); + return x <= y; + } + vector total(){ + auto EVO_value = symbol::from_string("4,EVO").to_symbol_code().value; + auto ETUSD_value = symbol::from_string("3,ETUSD").to_symbol_code().value; + int64_t total_eos = balance(N(alice), 0) + balance(N(bob), 0) + + system_balance(EVO_value).at(0) + system_balance(ETUSD_value).at(0); + int64_t total_voice = balance(N(alice), 1) + balance(N(bob), 1) + + system_balance(EVO_value).at(1); + int64_t total_tusd = balance(N(alice), 2) + balance(N(bob), 2) + + system_balance(ETUSD_value).at(1); + vector ans = {total_eos, total_voice, total_tusd}; + return ans; + } + void create_tokens_and_issue() { + create( N(eosio.token), N(alice), asset::from_string("461168601842738.7903 EOS") ); + create( N(anothertoken), N(bob), asset::from_string("461168601842738.7903 VOICE") ); + create( N(eosio.token), N(alice), asset::from_string("46116860184273879.03 TUSD") ); + issue( N(eosio.token), N(alice), N(alice), asset::from_string("461168601842738.7903 EOS"), ""); + issue( N(anothertoken), N(bob), N(bob), asset::from_string("461168601842738.7903 VOICE"), ""); + issue( N(eosio.token), N(alice), N(alice), asset::from_string("46116860184273879.03 TUSD"), ""); + } + void many_openext() { + openext( N(alice), N(alice), extended_symbol{symbol::from_string("4,EOS"), N(eosio.token)}); + openext( N(alice), N(alice), extended_symbol{symbol::from_string("4,VOICE"), N(anothertoken)}); + openext( N(alice), N(alice), extended_symbol{symbol::from_string("2,TUSD"), N(eosio.token)}); + openext( N(bob), N(alice), extended_symbol{symbol::from_string("4,EOS"), N(eosio.token)}); + openext( N(bob), N(alice), extended_symbol{symbol::from_string("4,VOICE"), N(anothertoken)}); + openext( N(bob), N(alice), extended_symbol{symbol::from_string("2,TUSD"), N(eosio.token)}); + } + void many_transfer() { + transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("461000000000000.0000 EOS"), ""); + transfer( N(anothertoken), N(bob), N(evolutiondex), asset::from_string("461168601842738.7000 VOICE"), "deposit to: alice"); + transfer( N(anothertoken), N(bob), N(evolutiondex), asset::from_string("0.0903 VOICE"), "this goes to Bob"); + transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("45000000000000000.00 TUSD"), ""); + transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("300000000000000.00 TUSD"), "deposit to: bob"); + } + void prepare_carol_token() { + create( N(carol), N(carol), asset::from_string("4.0000 EOS") ); + issue( N(carol), N(carol), N(carol), asset::from_string("4.0000 EOS"), ""); + create( N(carol), N(carol), asset::from_string("1.0000 VOICE") ); + issue( N(carol), N(carol), N(carol), asset::from_string("1.0000 VOICE"), ""); + } + abi_serializer abi_ser; +}; + +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(); + +extended_asset extend(asset to_extend) { + if (to_extend.symbol_name() == "VOICE") { + return extended_asset{to_extend, N(anothertoken)}; + } else { + return extended_asset{to_extend, N(eosio.token)}; + } +} + +BOOST_AUTO_TEST_SUITE(evolutiondex_tests) + +BOOST_FIXTURE_TEST_CASE( add_rem_liquidity, evolutiondex_tester ) try { + const auto& accnt2 = control->db().get( N(evolutiondex) ); + abi_def abi_evo; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2.abi, abi_evo), true); + + create_tokens_and_issue(); + transfer( N(anothertoken), N(bob), N(alice), asset::from_string("500000000.0000 VOICE"), ""); + + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + + many_openext(); + + transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("10000000.0000 EOS"), ""); + transfer( N(anothertoken), N(alice), N(evolutiondex), asset::from_string("200000000.0000 VOICE"), ""); + + inittoken( N(alice), EVO4, + extend(asset::from_string("1000000.0000 EOS")), + extend(asset::from_string("100000000.0000 VOICE")), 10, N(wevotethefee)); + + auto alice_evo_balance = get_balance(N(evolutiondex), N(alice), N(accounts), 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( N(evolutiondex), N(bob), N(addliquidity), mvo() + ( "user", N(alice))( "to_buy", asset::from_string("1 EVO")) + ( "max_asset1", asset::from_string("1 EOS") ) + ( "max_asset2", asset::from_string("1 VOICE")) ) + ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("to_buy amount must be positive"), + addliquidity( N(alice), asset::from_string("-5.0000 EVO"), + asset::from_string("0.5000 EOS"), asset::from_string("5000.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), + addliquidity( N(alice), asset::from_string("2.0000 EVO"), + asset::from_string("-0.3000 EOS"), asset::from_string("30.0000 NOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), + addliquidity( N(alice), asset::from_string("2.0000 EVO"), + asset::from_string("0.3000 EOS"), asset::from_string("-30.0000 NOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + addliquidity( N(alice), asset::from_string("5.0000 EVO"), + asset::from_string("0.5000 EOS"), asset::from_string("5000.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + addliquidity( N(alice), asset::from_string("2.0000 EVO"), + asset::from_string("0.0000 EOS"), asset::from_string("20.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), + addliquidity( N(alice), asset::from_string("2.0000 EMMO"), + asset::from_string("0.0000 EOS"), asset::from_string("20.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("incorrect symbol"), + addliquidity( N(alice), asset::from_string("3.0000 EVO"), + asset::from_string("0.3000 ECOS"), asset::from_string("30.0001 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), + addliquidity( N(alice), asset::from_string("1000000000000.0000 EVO"), + asset::from_string("1000000000000.0000 EOS"), asset::from_string("20000000000000.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( success(), + addliquidity( N(alice), asset::from_string("50.0000 EVO"), + asset::from_string("5.0050 EOS"), asset::from_string("500.5000 VOICE") ) + ); + produce_blocks(); + +// REMLIQUIDITY + BOOST_REQUIRE_EQUAL( error("missing authority of alice"), + push_action( N(evolutiondex), N(bob), N(remliquidity), mvo() + ( "user", N(alice))( "to_sell", asset::from_string("1 EVO")) + ( "min_asset1", asset::from_string("1 EOS") ) + ( "min_asset2", asset::from_string("1 VOICE")) ) + ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("to_sell amount must be positive"), + remliquidity( N(alice), asset::from_string("-5.0000 EVO"), + asset::from_string("0.5000 EOS"), asset::from_string("5000.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), + remliquidity( N(alice), asset::from_string("3.0000 EVO"), + asset::from_string("-0.3000 EOS"), asset::from_string("30.0001 NOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), + remliquidity( N(alice), asset::from_string("3.0000 EVO"), + asset::from_string("0.3000 EOS"), asset::from_string("-30.0001 NOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + remliquidity( N(alice), asset::from_string("1.0000 EVO"), + asset::from_string("0.1001 EOS"), asset::from_string("10.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + remliquidity( N(alice), asset::from_string("3.0000 EVO"), + asset::from_string("0.3000 EOS"), asset::from_string("30.0001 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("incorrect symbol"), + remliquidity( N(alice), asset::from_string("3.0000 EVO"), + asset::from_string("0.3000 EOS"), asset::from_string("30.0001 NOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("overdrawn balance"), + remliquidity( N(alice), asset::from_string("1000000000000.0000 EVO"), + asset::from_string("0.0000 EOS"), asset::from_string("0.0000 VOICE"))); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("computation overflow"), + addliquidity( N(alice), asset::from_string("46116860184273.8791 EVO"), + asset::from_string("1.0000 EOS"), asset::from_string("1.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("computation underflow"), + remliquidity( N(alice), asset::from_string("46116860184273.8791 EVO"), + asset::from_string("1.0000 EOS"), asset::from_string("1.0000 VOICE"))); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("the pool cannot be left empty"), + remliquidity( N(alice), asset::from_string("10000050.0000 EVO"), + asset::from_string("0.0001 EOS"), asset::from_string("0.0001 VOICE") ) + ); +/* BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), + addliquidity( N(alice), asset::from_string("2.0000 EVO"), + asset::from_string("0.0000 EOS"), asset::from_string("20.0000 VOICE")) + );*/ +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( exchange_action, evolutiondex_tester ) try { + const auto& accnt2 = control->db().get( N(evolutiondex) ); + abi_def abi_evo; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2.abi, abi_evo), true); + create_tokens_and_issue(); + transfer( N(anothertoken), N(bob), N(alice), asset::from_string("500000000.0000 VOICE"), ""); + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + many_openext(); + transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("10000000.0000 EOS"), ""); + transfer( N(anothertoken), N(alice), N(evolutiondex), asset::from_string("200000000.0000 VOICE"), ""); + inittoken( N(alice), EVO4, + extend(asset::from_string("1000000.0000 EOS")), + extend(asset::from_string("100000000.0000 VOICE")), 10, N(wevotethefee)); + addliquidity( N(alice), asset::from_string("50.0000 EVO"), + asset::from_string("5.0050 EOS"), asset::from_string("500.5000 VOICE") ); + remliquidity( N(alice), asset::from_string("17.1872 EVO"), + asset::from_string("0.0000 EOS"), asset::from_string("0.0000 VOICE") ); + +// EXCHANGE + BOOST_REQUIRE_EQUAL( error("missing authority of alice"), + push_action( N(evolutiondex), N(bob), N(exchange), mvo() + ( "user", N(alice))( "pair_token", EVO ) + ( "ext_asset_in", extend(asset::from_string("1 EOS")) ) + ( "min_expected", asset::from_string("1 VOICE")) ) + ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg( + "ext_asset_in must be nonzero and min_expected must have same sign or be zero"), + exchange( N(alice), 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 nonzero and min_expected must have same sign or be zero"), + exchange( N(alice), 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 nonzero and min_expected must have same sign or be zero"), + exchange( N(alice), EVO, extend(asset::from_string("0.0000 EOS")), + asset::from_string("-0.1000 VOICE")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("invalid parameters"), + exchange( N(alice), EVO, extend(asset::from_string("-1000004.0000 EOS")), + asset::from_string("-0.0001 VOICE")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("invalid parameters"), + exchange( N(alice), 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( N(alice), TUSD, extend(asset::from_string("-8.0000 VOICE")), + asset::from_string("0.0000 EOS")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), + exchange( N(alice), EVO, extend(asset::from_string("4.000 EOS")), + asset::from_string("10.0000 VOICE")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), + exchange( N(alice), EVO, extended_asset{asset::from_string("4.0000 EOS"), N(another)}, + asset::from_string("10.0000 VOICE")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), + exchange( N(alice), EVO, extended_asset{asset::from_string("1.0000 VOICE"), N(another)}, + asset::from_string("1.0000 EOS")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + exchange( N(alice), EVO, extend(asset::from_string("4.0000 EOS")), + asset::from_string("400.0000 VOICE")) ); + + exchange( N(alice), EVO, extend(asset::from_string("4.0000 EOS")), asset::from_string("10.0000 VOICE")); + exchange( N(alice), EVO, extend(asset::from_string("0.1000 EOS")), asset::from_string("4.8500 VOICE")); + exchange( N(alice), EVO, extend(asset::from_string("0.0001 EOS")), asset::from_string("0.0009 VOICE")); + + vector expected_system_balance = {10000073819, 999999185799, 100000328128}; + BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); + BOOST_REQUIRE_EQUAL(balance(N(alice),0), 89999926181); + BOOST_REQUIRE_EQUAL(balance(N(alice),1), 1000000814201); + + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 50) ); + + addliquidity( N(alice), asset::from_string("50.0000 EVO"), + asset::from_string("10000000.0000 EOS"), asset::from_string("10000000.0000 VOICE") ); + + expected_system_balance = {10000123826, 1000004186279, 100000828128}; + BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); + BOOST_REQUIRE_EQUAL(balance(N(alice),0), 89999876174); + BOOST_REQUIRE_EQUAL(balance(N(alice),1), 999995813721); + + BOOST_REQUIRE_EQUAL( success(), + exchange( N(alice), EVO, extend(asset::from_string("-4.0000 EOS")), + asset::from_string("-401.9984 VOICE")) ); + expected_system_balance = {10000083826, 1000008206263, 100000828128}; + BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); + BOOST_REQUIRE_EQUAL(balance(N(alice),0), 89999916174); + BOOST_REQUIRE_EQUAL(balance(N(alice),1), 999991793737); + +} FC_LOG_AND_RETHROW() + + +BOOST_FIXTURE_TEST_CASE( increasing_poolvalue, evolutiondex_tester) try { + const auto& accnt2 = control->db().get( N(evolutiondex) ); + 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_max_time); + many_openext(); + many_transfer(); + transfer( N(eosio.token), N(alice), N(evolutiondex), + asset::from_string("168601842738.7903 EOS"), ""); + + inittoken( N(alice), EVO4, + extend(asset::from_string("23058430092.1369 EOS")), + extend(asset::from_string("96116860184.2738 VOICE")), 10, N(wevotethefee)); + + inittoken( N(alice), ETUSD3, + extend(asset::from_string("10000000000.0000 EOS")), + extend(asset::from_string("9911686018427.38 TUSD")), 10, N(wevotethefee)); + + auto old_total = total(); + auto old_vec = system_balance(EVO.value); + auto old_alice_bal_0 = balance(N(alice),0); + auto old_alice_bal_1 = balance(N(alice),1); + + BOOST_REQUIRE_EQUAL(success(), + exchange( N(alice), 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(N(alice),0) - old_alice_bal_0, -40000); + BOOST_REQUIRE_EQUAL(balance(N(alice),1) - old_alice_bal_1, 166569); + + old_total = total(); + old_vec = system_balance(EVO.value); + old_alice_bal_0 = balance(N(alice), 0); + old_alice_bal_1 = balance(N(alice), 1); + addliquidity( N(alice), asset::from_string("0.0001 EVO"), + asset::from_string("10000000.0000 EOS"), asset::from_string("10000000.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(N(alice),0) - old_alice_bal_0, -2); + BOOST_REQUIRE_EQUAL(balance(N(alice),1) - old_alice_bal_1, -4); + + produce_blocks(); + + old_total = total(); + old_vec = system_balance(EVO.value); + old_alice_bal_0 = balance(N(alice), 0); + old_alice_bal_1 = balance(N(alice), 1); + remliquidity( N(alice), asset::from_string("0.0001 EVO"), + asset::from_string("0.0000 EOS"), asset::from_string("0.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(N(alice), 0) - old_alice_bal_0, 0); + BOOST_REQUIRE_EQUAL(balance(N(alice), 1) - old_alice_bal_1, 2); + + old_total = total(); + old_vec = system_balance(ETUSD.value); + BOOST_REQUIRE_EQUAL(success(), + exchange( N(bob), 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( N(bob), 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( N(bob), N(bob), + extend(asset::from_string("0.0001 EOS"))) ); + BOOST_REQUIRE_EQUAL(old_total == total(), false); + + old_total = total(); + old_vec = system_balance(EVO.value); + addliquidity( N(alice), asset::from_string("150000000000000.0000 EVO"), + asset::from_string("400000000000000.0000 EOS"), asset::from_string("400000000000000.0000 VOICE") ); + 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( N(alice), 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); + + old_total = total(); + old_vec = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( success(), exchange( N(alice), EVO, + extend(asset::from_string("-1.0000 EOS")), asset::from_string("-0.1069 VOICE")) ); + 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); + BOOST_REQUIRE_EQUAL( success(), exchange( N(bob), EVO, + extend(asset::from_string("-12.0001 VOICE")), asset::from_string("-122.0329 EOS")) ); + BOOST_REQUIRE_EQUAL(old_total == total(), true); + BOOST_REQUIRE_EQUAL(is_increasing(old_vec, system_balance(EVO.value)), true); +} FC_LOG_AND_RETHROW() + + +BOOST_FIXTURE_TEST_CASE( memoexchange_test, evolutiondex_tester ) try { + const auto& accnt2 = control->db().get( N(evolutiondex) ); + abi_def abi_evo; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2.abi, abi_evo), true); + + create_tokens_and_issue(); + prepare_carol_token(); + transfer( N(anothertoken), N(bob), N(alice), asset::from_string("0.0001 VOICE"), ""); + + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + + many_openext(); + many_transfer(); + + BOOST_REQUIRE_EQUAL( success(), + inittoken( N(alice), EVO4, + extend(asset::from_string("23058430092.1369 EOS")), + extend(asset::from_string("96116860184.2738 VOICE")), 10, N(wevotethefee)) ); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), + transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("4.0000 EOS"), + "exchange: EVO, 166536 VOICE") ); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("4.0000 EOS"), + "exchange: EVO, 16.6570 VOICE") ); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), + transfer( N(carol), N(carol), N(evolutiondex), asset::from_string("4.0000 EOS"), + "exchange: EVO, 16.6569 VOICE") ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), + transfer( N(carol), N(carol), N(evolutiondex), asset::from_string("1.0000 VOICE"), + "exchange: EVO, 0.0001 EOS") ); + + int64_t pre_eos_balance = token_balance(N(eosio.token), N(alice), EOS.value); + int64_t pre_voice_balance = token_balance(N(anothertoken), N(alice), VOICE.value); + BOOST_REQUIRE_EQUAL( success(), + transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("4.0000 EOS"), + "exchange: EVO, 16.6569 VOICE") ); + BOOST_REQUIRE_EQUAL( pre_eos_balance - 40000, token_balance(N(eosio.token), N(alice), EOS.value) ); + BOOST_REQUIRE_EQUAL( pre_voice_balance + 166569, token_balance(N(anothertoken), N(alice), VOICE.value) ); + + inittoken( N(alice), ETUSD3, + extend(asset::from_string("10000000000.0000 EOS")), + extend(asset::from_string("9911686018427.38 TUSD")), 10, N(wevotethefee)); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + transfer( N(eosio.token), N(alice), N(evolutiondex), + asset::from_string("400000.00 TUSD"), "exchange: ETUSD, 403.1605 EOS") ); + + pre_eos_balance = token_balance(N(eosio.token), N(alice), EOS.value); + int64_t pre_tusd_balance = token_balance(N(eosio.token), N(alice), TUSD.value); + BOOST_REQUIRE_EQUAL( success(), + transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("400000.00 TUSD"), + "exchange: ETUSD, 403.1604 EOS") ); + + BOOST_REQUIRE_EQUAL( pre_tusd_balance - 40000000, + token_balance(N(eosio.token), N(alice), TUSD.value) ); + BOOST_REQUIRE_EQUAL( pre_eos_balance + 4031604, + token_balance(N(eosio.token), N(alice), EOS.value) ); + + auto old_vec = system_balance(EVO.value); + transfer( N(eosio.token), N(alice), N(evolutiondex), 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( N(eosio.token), N(bob), N(evolutiondex), + 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, evolutiondex_tester ) try { + + create_tokens_and_issue(); + transfer( N(anothertoken), N(bob), N(alice), asset::from_string("500000000.0000 VOICE"), ""); + + const auto& accnt2 = control->db().get( N(evolutiondex) ); + abi_def abi_evo; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2.abi, abi_evo), true); + abi_ser.set_abi(abi_evo, 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( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("0.1000 EOS"), "") ); + + // OPENEXT + BOOST_REQUIRE_EQUAL( error("missing authority of natalia"), + push_action( N(evolutiondex), N(bob), N(openext), mvo() + ( "user", N(bob))( "payer", N(natalia) ) + ( "ext_symbol", extended_symbol{VOICE4, N(anothertoken)} )) + ); + BOOST_REQUIRE_EQUAL( success(), openext( N(alice), N(alice), + extended_symbol{EOS4, N(eosio.token)}) ); + BOOST_REQUIRE_EQUAL( success(), openext( N(alice), N(alice), + extended_symbol{VOICE4, N(anothertoken)}) ); + BOOST_REQUIRE_EQUAL( success(), openext( N(alice), N(alice), + extended_symbol{VOICE4, N(anothertoken)}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg( "user account does not exist"), + openext( N(cat), N(alice), extended_symbol{VOICE4, N(anothertoken)}) ); + BOOST_REQUIRE_EQUAL( success(), openext( N(alice), N(alice), + extended_symbol{VOICE4, N(anothertoken)}) ); + + // ONTRANSFER + BOOST_REQUIRE_EQUAL( wasm_assert_msg("This transfer is not for evolutiondex"), + transfer( N(badtoken), N(alice), N(bob), asset::from_string("1000.0000 EOS"), "")); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("quantity must be positive"), + transfer( N(badtoken), N(alice), N(evolutiondex), asset::from_string("-1000.0000 EOS"), "")); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Donation not accepted"), transfer( N(eosio.token), N(alice), N(evolutiondex), + asset::from_string("1000.0000 EOS"), "deposit to: evolutiondex") ); + BOOST_REQUIRE_EQUAL( success(), transfer( N(eosio.token), N(alice), N(evolutiondex), + asset::from_string("1000.0000 EOS"), "") ); + BOOST_REQUIRE_EQUAL( success(), transfer( N(anothertoken), N(alice), N(evolutiondex), + asset::from_string("20000.0000 VOICE"), "") ); + + // WITHDRAW + BOOST_REQUIRE_EQUAL( error("missing authority of alice"), + push_action( N(evolutiondex), N(bob), N(withdraw), mvo() + ("user", N(alice)) ("to", N(natalia)) + ("to_withdraw", extend(asset::from_string("1.0000 EOS"))) ("memo", "") ) + ); + BOOST_REQUIRE_EQUAL( success(), withdraw( N(alice), N(bob), + extend(asset::from_string("999.9998 EOS")) ) ); + BOOST_REQUIRE_EQUAL( 9999998, token_balance( N(eosio.token), N(bob), EOS.value )); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("quantity must be positive"), withdraw( N(alice), N(bob), + extend(asset::from_string("-0.0001 EOS")) )); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), withdraw( N(alice), N(bob), + extend(asset::from_string("0.0003 EOS")) )); + + // INITTOKEN + BOOST_REQUIRE_EQUAL( error("missing authority of evolutiondex"), + push_action( N(evolutiondex), N(alice), N(inittoken), mvo() + ("user", N(alice)) ("new_symbol", EVO4) + ("initial_pool1", extend(asset::from_string("1.0000 EOS"))) + ("initial_pool2", extend(asset::from_string("1.0000 ECO"))) + ("initial_fee", 1) ("fee_contract", N(carol)) ) + ); + BOOST_REQUIRE_EQUAL( error("missing authority of alice"), + push_action( N(evolutiondex), N(bob), N(inittoken), mvo() + ("user", N(alice)) ("new_symbol", EVO4) + ("initial_pool1", extend(asset::from_string("1.0000 EOS"))) + ("initial_pool2", extend(asset::from_string("1.0000 ECO"))) + ("initial_fee", 1) ("fee_contract", N(carol)) ) + ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Both assets must be positive"), inittoken( N(alice), EVO4, + extend(asset::from_string("-0.0001 EOS")), + extend(asset::from_string("0.1000 VOICE")), 10, N(wevotethefee)) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Both assets must be positive"), inittoken( N(alice), EVO4, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("-0.1000 VOICE")), 10, N(wevotethefee)) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Initial amounts must be less than 10^15"), inittoken( N(alice), EVO4, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("100000000000.0001 VOICE")), 10, N(wevotethefee)) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Initial amounts must be less than 10^15"), inittoken( N(alice), EVO4, + extend(asset::from_string("100000000000.0001 EOS")), + extend(asset::from_string("1.0001 VOICE")), 10, N(wevotethefee)) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended symbols must be different"), inittoken( N(alice), EVO4, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("0.1000 EOS")), 10, N(wevotethefee)) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), + inittoken( N(alice), EVO4, + extend(asset::from_string("0.0003 EOS")), + extend(asset::from_string("0.1000 VOICE")), 10, N(wevotethefee)) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), + inittoken( N(alice), EVO4, + extend(asset::from_string("0.0002 EOS")), + extend(asset::from_string("100000000.0000 VOICE")), 10, N(wevotethefee)) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("new_symbol precision must be (precision1 + precision2) / 2"), + inittoken( N(alice), EVO4, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("0.100 VOICE")), 10, N(wevotethefee)) ); + BOOST_REQUIRE_EQUAL( success(), inittoken( N(alice), EVO4, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("0.1000 VOICE")), 10, N(wevotethefee)) ); + produce_blocks(); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("token symbol already exists"), inittoken( N(alice), EVO4, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("0.1000 VOICE")), 10, N(wevotethefee)) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("initial_fee must be 10"), inittoken( N(alice), ETUSD3, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("0.10 TUSD")), 501, N(wevotethefee)) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee_contract must be wevotethefee"), + inittoken( N(alice), ETUSD3, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("0.10 ETUSD")), 10, N(alice)) ); +// The assert "the pool is already indexed" is tested in "indextable" test case. + + // TRANSFER + 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(N(evolutiondex), N(alice), N(evolutiondex), asset::from_string("0.0010 EVO"), "") + ); + + // CLOSEEXT + BOOST_REQUIRE_EQUAL( error("missing authority of natalia"), + push_action( N(evolutiondex), N(bob), N(closeext), mvo() + ("user", N(natalia))("ext_symbol", extended_symbol{EVO4, N(eosio.token)}) + ("to", N(alice))("memo", "") ) + ); + BOOST_REQUIRE_EQUAL( success(), + closeext( N(alice), N(alice), extended_symbol{VOICE4, N(anothertoken)}) ); + BOOST_REQUIRE_EQUAL( success(), + closeext( N(alice), N(bob), extended_symbol{EOS4, N(eosio.token)} ) ); + BOOST_REQUIRE_EQUAL( 9999999, token_balance( N(eosio.token), N(bob), EOS.value )); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("User does not have such token"), + closeext( N(alice), N(bob), extended_symbol{EOS4, N(eosio.token)} ) ); + + // CHANGEFEE + BOOST_REQUIRE_EQUAL( error("missing authority of wevotethefee"), + push_action( N(evolutiondex), N(bob), N(changefee), + mvo() ("pair_token", EVO) ("newfee", 50) ) + ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), changefee(EOS, 500)); + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 50) ); + + // Notifications to fee_contract + + set_code( N(wevotethefee), contracts::badtoken_wasm() ); + set_abi( N(wevotethefee), contracts::badtoken_abi().data() ); + many_openext(); + transfer( N(eosio.token), N(bob), N(evolutiondex), asset::from_string("0.0004 EOS"), ""); + transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("0.04 TUSD"), "deposit to: bob"); + + BOOST_REQUIRE_EQUAL( success(), inittoken( N(bob), ETUSD3, + extend(asset::from_string("0.0002 EOS")), + extend(asset::from_string("0.02 TUSD")), 10, N(wevotethefee) ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("notification received"), + transfer( N(evolutiondex), N(bob), N(alice), asset::from_string("0.001 ETUSD"), "") ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("notification received"), + addliquidity( N(bob), asset::from_string("0.001 ETUSD"), + asset::from_string("0.0002 EOS"), asset::from_string("0.02 TUSD"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("notification received"), + remliquidity( N(bob), asset::from_string("0.001 ETUSD"), + asset::from_string("0.0001 EOS"), asset::from_string("0.01 TUSD"))); + +} FC_LOG_AND_RETHROW() + + +BOOST_FIXTURE_TEST_CASE( we_vote_the_fee, evolutiondex_tester ) try { + + create_tokens_and_issue(); + transfer( N(anothertoken), N(bob), N(alice), asset::from_string("500000000.0000 VOICE"), ""); + + const auto& accnt2 = control->db().get( N(evolutiondex) ); + abi_def abi_evo; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2.abi, abi_evo), true); + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + + many_openext(); + + transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("10000000.0000 EOS"), ""); + transfer( N(anothertoken), N(alice), N(evolutiondex), asset::from_string("200000000.0000 VOICE"), ""); + + inittoken( N(alice), EVO4, + extend(asset::from_string("1000000.0000 EOS")), + extend(asset::from_string("100000000.0000 VOICE")), 10, N(wevotethefee)); + + const auto& accnt3 = control->db().get( N(wevotethefee) ); + abi_def abi_wevote; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt3.abi, abi_wevote), true); + +// Start wevotethefee actions. Testing checks and basic functions. + abi_ser.set_abi(abi_wevote, abi_serializer_max_time); + auto ISNT = symbol::from_string("4,ISNT").to_symbol_code(); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("pair_token balance does not exist"), + votefee(N(alice), ISNT, 10) ); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("pair_token balance does not exist"), + votefee(N(bob), EVO, 30) ); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("fee table nonexistent, run openfeetable"), votefee(N(alice), EVO, 30)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("fee table nonexistent, run openfeetable"), updatefee(N(alice), EVO)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("table does not exist"), closefeetable(EVO)); + BOOST_REQUIRE_EQUAL(success(), openfeetable(N(alice), EVO)); + BOOST_REQUIRE_EQUAL(success(), closefeetable(EVO)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("fee table nonexistent, run openfeetable"), votefee(N(alice), EVO, 30)); + BOOST_REQUIRE_EQUAL(success(), openfeetable(N(alice), EVO)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("already opened"), openfeetable(N(alice), EVO)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("user is not voting"), closevote(N(alice), EVO)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("only values between 10 and 100 are allowed"), votefee(N(alice), EVO, 101)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("only values between 10 and 100 are allowed"), votefee(N(alice), EVO, -10)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("only values between 10 and 100 are allowed"), votefee(N(alice), EVO, 9)); + BOOST_REQUIRE_EQUAL(success(), votefee(N(alice), EVO, 30)); + BOOST_REQUIRE_EQUAL(success(), updatefee(N(alice), EVO)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("table of votes is not empty"), closefeetable(EVO)); + + auto evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, + "feetable" )["votes"].get_array(); + BOOST_REQUIRE_EQUAL(100000000000, evo_votes[8]); + + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + auto evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(30, evo_stats["fee"]); + +// Leave vote table with zero votes, fee value remains unchanged + transfer( N(evolutiondex), N(alice), N(bob), asset::from_string("10000000.0000 EVO"), ""); + evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(30, evo_stats["fee"]); + abi_ser.set_abi(abi_wevote, abi_serializer_max_time); + evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, + "feetable" )["votes"].get_array(); + BOOST_REQUIRE_EQUAL(0, evo_votes[5]); + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + transfer( N(evolutiondex), N(bob), N(alice), asset::from_string("10000000.0000 EVO"), ""); + +// Test authorizations + abi_ser.set_abi(abi_wevote, abi_serializer_max_time); + BOOST_REQUIRE_EQUAL( error("missing authority of bob"), + push_action( N(wevotethefee), N(alice), N(votefee), + mvo()( "user", N(bob) )( "pair_token", EVO )( "fee_voted", "10" ) ) + ); + BOOST_REQUIRE_EQUAL( error("missing authority of bob"), + push_action( N(wevotethefee), N(alice), N(closevote), + mvo()( "user", N(bob) )( "pair_token", EVO ) ) + ); + +// Vote balance change after transfer + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + BOOST_REQUIRE_EQUAL(success(), + transfer( N(evolutiondex), N(alice), N(bob), asset::from_string("100.0000 EVO"), "")); + + abi_ser.set_abi(abi_wevote, abi_serializer_max_time); + BOOST_REQUIRE_EQUAL(success(), votefee(N(bob), EVO, 100)); + evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, + "feetable" )["votes"].get_array(); + BOOST_REQUIRE_EQUAL(99999000000, evo_votes[8]); + BOOST_REQUIRE_EQUAL(1000000, evo_votes[11]); + +// Scenarios with many votes + create_accounts( { N(dan), N(eva), N(fran)}); + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + BOOST_REQUIRE_EQUAL(success(), transfer( N(evolutiondex), N(alice), N(carol), asset::from_string("2000.0000 EVO"), "")); + transfer( N(evolutiondex), N(alice), N(dan), asset::from_string("400000.0000 EVO"), ""); + transfer( N(evolutiondex), N(alice), N(eva), asset::from_string("4240000.0000 EVO"), ""); + transfer( N(evolutiondex), N(alice), N(fran), asset::from_string("2500000.0000 EVO"), ""); + + abi_ser.set_abi(abi_wevote, abi_serializer_max_time); + BOOST_REQUIRE_EQUAL(success(), votefee(N(carol), EVO, 10)); + votefee(N(dan), EVO, 10); + votefee(N(eva), EVO, 74); + votefee(N(fran), EVO, 73); + + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(75, evo_stats["fee"]); + + // Test updatefee when closing votes, then restore votes. + abi_ser.set_abi(abi_wevote, abi_serializer_max_time); + closevote( N(alice), EVO ); + closevote( N(eva), EVO ); + closevote( N(fran), EVO ); + evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, + "feetable" )["votes"].get_array(); + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(10, evo_stats["fee"]); + abi_ser.set_abi(abi_wevote, abi_serializer_max_time); + votefee(N(alice), EVO, 30); + votefee(N(eva), EVO, 75); + votefee(N(fran), EVO, 75); + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(75, evo_stats["fee"]); + + // Test fee modification when adding and removing liquidity + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + openext( N(eva), N(eva), extended_symbol{symbol::from_string("4,EOS"), N(eosio.token)}); + openext( N(eva), N(eva), extended_symbol{symbol::from_string("4,VOICE"), N(anothertoken)}); + push_action( N(evolutiondex), N(eva), N(remliquidity), mvo() + ( "user", N(eva))( "to_sell", asset::from_string("4000000.0000 EVO")) + ( "min_asset1", asset::from_string("1.0000 EOS") ) + ( "min_asset2", asset::from_string("1.0000 VOICE")) ); + evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(30, evo_stats["fee"]); + + BOOST_REQUIRE_EQUAL(success(), push_action( N(evolutiondex), N(eva), N(addliquidity), mvo() + ( "user", N(eva))( "to_buy", asset::from_string("3900000.0000 EVO")) + ( "max_asset1", asset::from_string("100000000000.0000 EOS") ) + ( "max_asset2", asset::from_string("100000000000.0000 VOICE")) ) ); + evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(75, evo_stats["fee"]); + +// median 10, due to clamp between 10 and 100 + abi_ser.set_abi(abi_wevote, abi_serializer_max_time); + votefee(N(alice), EVO, 10); + votefee(N(dan), EVO, 10); + votefee(N(eva), EVO, 10); + votefee(N(fran), EVO, 10); + evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, + "feetable" )["votes"].get_array(); + BOOST_REQUIRE_EQUAL(98999000000, evo_votes[5]); + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(10, evo_stats["fee"]); + +// check votes after transfer, remliquidity and addliquidity + BOOST_REQUIRE_EQUAL(success(), + transfer( N(evolutiondex), N(alice), N(bob), asset::from_string("100.0000 EVO"), "")); + abi_ser.set_abi(abi_wevote, abi_serializer_max_time); + evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, + "feetable" )["votes"].get_array(); + BOOST_REQUIRE_EQUAL(98998000000, evo_votes[5]); + BOOST_REQUIRE_EQUAL(2000000, evo_votes[11]); + + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + push_action( N(evolutiondex), N(eva), N(remliquidity), mvo() + ( "user", N(eva))( "to_sell", asset::from_string("10000.0000 EVO")) + ( "min_asset1", asset::from_string("1.0000 EOS") ) + ( "min_asset2", asset::from_string("1.0000 VOICE")) ); + abi_ser.set_abi(abi_wevote, abi_serializer_max_time); + evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, + "feetable" )["votes"].get_array(); + BOOST_REQUIRE_EQUAL(98998000000 - 100000000, evo_votes[5]); + + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + push_action( N(evolutiondex), N(eva), N(addliquidity), mvo() + ( "user", N(eva))( "to_buy", asset::from_string("100.0000 EVO")) + ( "max_asset1", asset::from_string("10000000.0000 EOS") ) + ( "max_asset2", asset::from_string("10000000.0000 VOICE")) ); + abi_ser.set_abi(abi_wevote, abi_serializer_max_time); + evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, + "feetable" )["votes"].get_array(); + BOOST_REQUIRE_EQUAL(98998000000 - 100000000 + 1000000, evo_votes[5]); + +// median 100 + votefee(N(dan), EVO, 100); + votefee(N(eva), EVO, 100); + votefee(N(fran), EVO, 100); + evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, + "feetable" )["votes"].get_array(); + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(100, evo_stats["fee"]); + +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( indextable, evolutiondex_tester ) try { + + create_tokens_and_issue(); + transfer( N(anothertoken), N(bob), N(alice), asset::from_string("500000000.0000 VOICE"), ""); + const auto& accnt2 = control->db().get( N(evolutiondex) ); + abi_def abi_evo; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2.abi, abi_evo), true); + abi_ser.set_abi(abi_evo, abi_serializer_max_time); + many_openext(); + transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("10000000.0000 EOS"), ""); + transfer( N(anothertoken), N(alice), N(evolutiondex), asset::from_string("200000000.0000 VOICE"), ""); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token symbol does not exist"), + push_action( N(evolutiondex), N(alice), N(indexpair), + mvo() ( "user", N(alice) ) ( "evo_symbol", EVO4 ) ) ); + + BOOST_REQUIRE_EQUAL(success(), inittoken( N(alice), EVO4, + extend(asset::from_string("1000000.0000 EOS")), + extend(asset::from_string("100000000.0000 VOICE")), 10, N(wevotethefee)) ); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg("the pool is already indexed"), + push_action( N(evolutiondex), N(alice), N(indexpair), + mvo() ( "user", N(alice) ) ( "evo_symbol", EVO4 ) ) ); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg("the pool is already indexed"), + inittoken( N(alice), EOS4, + extend(asset::from_string("1.0000 EOS")), + extend(asset::from_string("1.0000 VOICE")), 10, N(wevotethefee)) ); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("the pool is already indexed"), + inittoken( N(alice), EOS4, + extend(asset::from_string("1.0000 VOICE")), + extend(asset::from_string("1.0000 EOS")), 10, N(wevotethefee)) ); + + auto table = get_balance(N(evolutiondex), N(evolutiondex), N(evoindex), EVO.value, "index_struct"); + BOOST_REQUIRE_EQUAL(table["id_256"], "34e996aaf9a4153000004543494f56045530ea033482a60000000000534f4504"); + BOOST_REQUIRE_EQUAL(table["evo_symbol"], "4,EVO"); + + 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( N(alice), EOS4, + extend(asset::from_string("1.00000 VOICE")), + extend(asset::from_string("1.0000 EOS")), 10, N(wevotethefee)) ); + 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( N(alice), EOS4, + extend(asset::from_string("1.0000 VOICE")), + extend(asset::from_string("1.00000 EOS")), 10, N(wevotethefee)) ); + 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( N(alice), EOS4, extend(asset::from_string("1.0000 VOICE")), + extended_asset{asset::from_string("1.0000 EOS"), N(anothertoken)}, + 10, N(wevotethefee)) ); +} FC_LOG_AND_RETHROW() + +BOOST_AUTO_TEST_SUITE_END() \ No newline at end of file diff --git a/contracts/sysio.swap/tests/main.cpp b/contracts/sysio.swap/tests/main.cpp new file mode 100644 index 0000000000..2c658f31a1 --- /dev/null +++ b/contracts/sysio.swap/tests/main.cpp @@ -0,0 +1,42 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "eosio.system_tester.hpp" + +using namespace eosio_system; +#define BOOST_TEST_STATIC_LINK + +void translate_fc_exception(const fc::exception &e) { + std::cerr << "\033[33m" << e.to_detail_string() << "\033[0m" << std::endl; + BOOST_TEST_FAIL("Caught Unexpected Exception"); +} + +boost::unit_test::test_suite* init_unit_test_suite(int argc, char* argv[]) { + // Turn off blockchain logging if no --verbose parameter is not added + // To have verbose enabled, call "tests/chain_test -- --verbose" + bool is_verbose = false; + std::string verbose_arg = "--verbose"; + for (int i = 0; i < argc; i++) { + if (verbose_arg == argv[i]) { + is_verbose = true; + break; + } + } + if(!is_verbose) fc::logger::get(DEFAULT_LOGGER).set_log_level(fc::log_level::off); + + // Register fc::exception translator + boost::unit_test::unit_test_monitor.template register_exception_translator(&translate_fc_exception); + + std::srand(time(NULL)); + std::cout << "Random number generator seeded to " << time(NULL) << std::endl; + return nullptr; +} diff --git a/contracts/sysio.swap/tests/test_symbol.hpp b/contracts/sysio.swap/tests/test_symbol.hpp new file mode 100644 index 0000000000..598ea43468 --- /dev/null +++ b/contracts/sysio.swap/tests/test_symbol.hpp @@ -0,0 +1,16 @@ +#pragma once + +#define CORE_SYM_NAME "TST" +#define CORE_SYM_PRECISION 4 + +#define _STRINGIZE1(x) #x +#define _STRINGIZE2(x) _STRINGIZE1(x) + +#define CORE_SYM_STR ( _STRINGIZE2(CORE_SYM_PRECISION) "," CORE_SYM_NAME ) +#define CORE_SYM ( ::eosio::chain::string_to_symbol_c( CORE_SYM_PRECISION, CORE_SYM_NAME ) ) + +struct core_sym { + static inline eosio::chain::asset from_string(const std::string& s) { + return eosio::chain::asset::from_string(s + " " CORE_SYM_NAME); + } +}; diff --git a/contracts/sysio.swap/token_functions.cpp b/contracts/sysio.swap/token_functions.cpp new file mode 100644 index 0000000000..3c67b36621 --- /dev/null +++ b/contracts/sysio.swap/token_functions.cpp @@ -0,0 +1,84 @@ +#include "evolutiondex.hpp" + +using namespace evolution; + +void evolutiondex::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(), sym.raw() ); + const auto& st = statstable.get( sym.raw() ); + + require_recipient( from ); + require_recipient( to ); + if (st.fee_contract) require_recipient( st.fee_contract ); // line added to code from eosio.token + + 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 evolutiondex::sub_balance( const name& owner, const asset& value ) { + accounts from_acnts( get_self(), owner.value ); + + const auto& from = from_acnts.get( value.symbol.code().raw(), "no balance object found" ); + check( from.balance.amount >= value.amount, "overdrawn balance" ); + + from_acnts.modify( from, owner, [&]( auto& a ) { + a.balance -= value; + }); +} + +void evolutiondex::add_balance( const name& owner, const asset& value, const name& ram_payer ) +{ + accounts to_acnts( get_self(), owner.value ); + auto to = to_acnts.find( value.symbol.code().raw() ); + if( to == to_acnts.end() ) { + to_acnts.emplace( ram_payer, [&]( auto& a ){ + a.balance = value; + }); + } else { + to_acnts.modify( to, same_payer, [&]( auto& a ) { + a.balance += value; + }); + } +} + +void evolutiondex::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(), sym_code_raw ); + const auto& st = statstable.get( sym_code_raw, "symbol does not exist" ); + check( st.supply.symbol == symbol, "symbol precision mismatch" ); + + accounts acnts( get_self(), owner.value ); + auto it = acnts.find( sym_code_raw ); + if( it == acnts.end() ) { + acnts.emplace( ram_payer, [&]( auto& a ){ + a.balance = asset{0, symbol}; + }); + } +} + +void evolutiondex::close( const name& owner, const symbol& symbol ) +{ + require_auth( owner ); + accounts acnts( get_self(), owner.value ); + auto it = acnts.find( symbol.code().raw() ); + check( it != acnts.end(), "Balance row already deleted or never existed. Action won't have any effect." ); + check( it->balance.amount == 0, "Cannot close because the balance is not zero." ); + acnts.erase( it ); +} diff --git a/contracts/sysio.swap/wevotethefee/README.md b/contracts/sysio.swap/wevotethefee/README.md new file mode 100644 index 0000000000..a79d5af2e9 --- /dev/null +++ b/contracts/sysio.swap/wevotethefee/README.md @@ -0,0 +1,20 @@ +**Single action examples for the contract wevotethefee** + +Open a fee table for the evotoken EVO: + + cleos push action wevotethefee openfeetable '["YOUR_ACCOUNT", "EVO"]' -p YOUR_ACCOUNT + +Vote the fee value 0.3% for the evotoken EVO. + + cleos push action wevotethefee votefee '["YOUR_ACCOUNT", "EVO", "30"]' -p YOUR_ACCOUNT + +Remark: the possible fee values in this version are 10, 15, 20, 30, 50, 75, 100. +Values less than 10 or greater than 100 will be rejected. Intermediate values will be rounded upwards. In future versions, other values might be possible as well. + +The fee value will automatically update to the median of the current votes +each time a vote is entered, a voter's pool token balance is modified or +a vote is closed. + +Close your vote for the evotoken EVO: + + cleos push action wevotethefee closevote '["YOUR_ACCOUNT", "EVO"]' -p YOUR_ACCOUNT diff --git a/contracts/sysio.swap/wevotethefee/wevotethefee.clauses.md b/contracts/sysio.swap/wevotethefee/wevotethefee.clauses.md new file mode 100644 index 0000000000..0693ac577a --- /dev/null +++ b/contracts/sysio.swap/wevotethefee/wevotethefee.clauses.md @@ -0,0 +1,6 @@ +

UserAgreement

+ +The contract wevotethefee is designed to govern the fee values of trading pairs from the contract evolutiondex. +Users in possession of pool tokens can vote for one of the following values: +10,15,20,30,50,75,100. One unit of fee value is equal to 0.01%. +The fee value set by wevotethefee will be equal to the weighted median of the votes. The weight of each vote is the corresponding balance of the pool token. Note: evolutiondex notifies this contract each time a user's pool token balance is modified. \ No newline at end of file diff --git a/contracts/sysio.swap/wevotethefee/wevotethefee.contracts.md b/contracts/sysio.swap/wevotethefee/wevotethefee.contracts.md new file mode 100644 index 0000000000..e47183e1e4 --- /dev/null +++ b/contracts/sysio.swap/wevotethefee/wevotethefee.contracts.md @@ -0,0 +1,92 @@ +

openfeetable

+ +--- +spec_version: "0.2.0" +title: Open fee table +summary: 'Open a fee table for a given pair token' +--- + +{{user}} initializes a table for registering votes for the pair token +{{pair_token}} of the contract evolutiondex. + +The authorization of {{user}} is required, who will pay for the RAM required. + + +

closefeetable

+ +--- +spec_version: "0.2.0" +title: Close fee table +summary: 'Close a fee table for a given pair token' +--- + +This action closes the fee table corresponding to {{pair_token}}. It will fail if the table is storing at least one vote. +In case it succeeds, the corresponding RAM will be liberated. + + +

votefee

+ +--- +spec_version: "0.2.0" +title: Vote fee +summary: 'Vote fee value for a specific pair token' +--- + +{{user}} votes a fee value for {{pair_token}} of evolutiondex. The exact value voted will be equal to the least of the numbers 10,15,20,30,50,75,100 that is greater than or equal to {{fee_voted}}. Values less than 10 or greater than 100 will be rejected. One unit of fee value is equal to 0.01%. + +The authorization of {{user}} is required, who will pay for the RAM required. + +

closevote

+ +--- +spec_version: "0.2.0" +title: Close vote +summary: 'Close vote for a specific pair token' +--- + +{{user}} closes its vote corresponding to {{pair_token}} of evolutiondex. +The corresponding RAM will be liberated. + +The authorization of {{user}} is required. + + +

updatefee

+ +--- +spec_version: "0.2.0" +title: Update fee +summary: 'Updates the fee value of a specific pair token' +--- + +This action executes the action changefee from evolutiondex with inputs {{pair_token}} and a fee value that is computed as the weighted median of the current votes in the corresponding fee table. The weights of the votes are the balances of the pair token of each voter. In the current version, it is always unnecessary to run this action, since it is automatically called by other actions. + +

onaddliquidity

+ +--- +spec_version: "0.2.0" +title: On add liquidity +summary: 'Updates the weight of a vote when adding liquidity' +--- + +When evolutiondex notifies that {{user}} has added liquidity to the pair token {{asset_to_symbol_code to_buy}}, the weight of the vote of {{user}} is updated accordingly. + +

onremliquidity

+ +--- +spec_version: "0.2.0" +title: On remove liquidity +summary: 'Updates the weight of a vote when removing liquidity' +--- + +When evolutiondex notifies that {{user}} has removed liquidity from the pair token {{asset_to_symbol_code to_sell}}, the weight of the vote of {{user}} is updated accordingly. + + +

ontransfer

+ +--- +spec_version: "0.2.0" +title: On transfer +summary: 'Updates the weight of a vote on a transfer by the contract evolutiondex' +--- + +When evolutiondex notifies that {{from}} has transferred {{quantity}} to {{to}}, the weights of the votes of {{from}} and {{to}} are updated accordingly. \ No newline at end of file diff --git a/contracts/sysio.swap/wevotethefee/wevotethefee.cpp b/contracts/sysio.swap/wevotethefee/wevotethefee.cpp new file mode 100644 index 0000000000..2b5887c27a --- /dev/null +++ b/contracts/sysio.swap/wevotethefee/wevotethefee.cpp @@ -0,0 +1,123 @@ +#include "wevotethefee.hpp" + +int wevotethefee::median(symbol_code pair_token){ + feetables tables( get_self(), pair_token.raw() ); + auto table = tables.find( pair_token.raw()); + check( table != tables.end(), "fee table nonexistent, run openfeetable" ); + auto votes = table->votes; + vector partial_sum_vec(votes.size()); + partial_sum(votes.begin(), votes.end(), partial_sum_vec.begin()); + int64_t sum = partial_sum_vec.back(); + if (sum == 0) return FEE_VECTOR.at(DEFAULT_FEE_INDEX); + auto it = lower_bound(partial_sum_vec.begin(), partial_sum_vec.end(), sum / 2); + auto index = it - partial_sum_vec.begin(); + check( index < FEE_VECTOR.size(), "invalid index" ); // should always pass + return FEE_VECTOR.at(index); +} + +void wevotethefee::updatefee(symbol_code pair_token) { + int new_fee = median(pair_token); + action(permission_level{ get_self(), "active"_n }, + "evolutiondex"_n, "changefee"_n, + make_tuple( pair_token, new_fee )).send(); +} + +void wevotethefee::onaddliquidity(name user, asset to_buy, asset max_asset1, asset max_asset2){ + add_balance(user, to_buy, true); +} + +void wevotethefee::onremliquidity(name user, asset to_sell, asset min_asset1, asset min_asset2){ + add_balance(user, -to_sell, true); +} + +void wevotethefee::ontransfer(const name& from, const name& to, const asset& quantity, const string& memo ){ + add_balance(from, -quantity, false); + add_balance(to, quantity, true); +}; + +asset wevotethefee::bring_balance(name user, symbol_code pair_token) { + accounts table( "evolutiondex"_n, user.value ); + const auto& user_balance = table.find( pair_token.raw() ); + check ( user_balance != table.end(), "pair_token balance does not exist" ); + return user_balance->balance; +} + +void wevotethefee::votefee(name user, symbol_code pair_token, int fee_voted){ + check( (FEE_VECTOR.at(MIN_FEE_INDEX) <= fee_voted) && + (fee_voted <= FEE_VECTOR.at(MAX_FEE_INDEX)), "only values between 10 and 100 are allowed"); + int fee_index_voted = get_index(fee_voted); + require_auth(user); + feeaccounts acnts( get_self(), user.value ); + auto acnt = acnts.find( pair_token.raw()); + auto balance = bring_balance(user, pair_token); + if ( acnt == acnts.end() ) { + acnts.emplace( user, [&]( auto& a ){ + a.pair_token = pair_token; + a.fee_index_voted = fee_index_voted; + }); + } else { + addvote(pair_token, acnt->fee_index_voted, -balance.amount, false); + acnts.modify( acnt, user, [&]( auto& a ){ + a.fee_index_voted = fee_index_voted; + }); + } + addvote(pair_token, fee_index_voted, balance.amount, true); +} + +void wevotethefee::closevote(name user, symbol_code pair_token) { + require_auth(user); + feeaccounts acnts( get_self(), user.value ); + auto acnt = acnts.find( pair_token.raw()); + check( acnt != acnts.end(), "user is not voting" ); + auto balance = bring_balance(user, pair_token); + addvote( pair_token, acnt->fee_index_voted, -balance.amount, true ); + acnts.erase(acnt); +} + +void wevotethefee::closefeetable(symbol_code pair_token) { + feetables tables( get_self(), pair_token.raw() ); + auto table = tables.find( pair_token.raw()); + check( table != tables.end(), "table does not exist" ); + auto votes = table->votes; + auto is_empty = all_of(votes.begin(), votes.end(), + [](const int & votes_number){return votes_number == 0;}); + check( is_empty, "table of votes is not empty"); + tables.erase(table); +} + +void wevotethefee::openfeetable(name user, symbol_code pair_token) { + feetables tables( get_self(), pair_token.raw() ); + auto table = tables.find( pair_token.raw()); + check( table == tables.end(), "already opened" ); + vector zeros( FEE_VECTOR.size()); + tables.emplace( user, [&]( auto& a ){ + a.pair_token = pair_token; + a.votes = zeros; + }); +} + +void wevotethefee::add_balance(name user, asset to_add, bool need_update) { + feeaccounts acnts( get_self(), user.value ); + auto acnt = acnts.find( to_add.symbol.code().raw()); + if (acnt != acnts.end()) { + addvote( acnt->pair_token, acnt->fee_index_voted, to_add.amount, need_update); + } +} + +void wevotethefee::addvote(symbol_code pair_token, int fee_index, int64_t amount, + bool need_update) { + feetables tables( get_self(), pair_token.raw() ); + auto table = tables.find( pair_token.raw()); + check( table != tables.end(), "fee table nonexistent, run openfeetable" ); + tables.modify(table, same_payer, [&]( auto& a ){ + check( (0 <= fee_index) && (fee_index < a.votes.size()), "invalid fee_index"); // should always pass + a.votes.at(fee_index) += amount; + }); + if (need_update) updatefee( pair_token ); +} + +int wevotethefee::get_index(int fee_value){ + auto it = lower_bound(FEE_VECTOR.begin(), FEE_VECTOR.end(), fee_value); + check( it < FEE_VECTOR.end(), "invalid iterator" ); // should always pass + return it - FEE_VECTOR.begin(); +} \ No newline at end of file diff --git a/contracts/sysio.swap/wevotethefee/wevotethefee.hpp b/contracts/sysio.swap/wevotethefee/wevotethefee.hpp new file mode 100644 index 0000000000..b33fb6d72d --- /dev/null +++ b/contracts/sysio.swap/wevotethefee/wevotethefee.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +using namespace eosio; +using namespace std; + +class [[eosio::contract("wevotethefee")]] wevotethefee : public contract { + public: + + using contract::contract; + [[eosio::action]] void votefee(name user, symbol_code pair_token, int fee_voted); + [[eosio::action]] void openfeetable(name user, symbol_code pair_token); + [[eosio::action]] void closevote(name user, symbol_code pair_token); + [[eosio::action]] void closefeetable(symbol_code pair_token); + [[eosio::action]] void updatefee(symbol_code pair_token); + [[eosio::on_notify("evolutiondex::addliquidity")]] void onaddliquidity(name user, asset to_buy, + asset max_asset1, asset max_asset2); + [[eosio::on_notify("evolutiondex::remliquidity")]] void onremliquidity(name user, asset to_sell, + asset min_asset1, asset min_asset2); + [[eosio::on_notify("evolutiondex::transfer")]] void ontransfer(const name& from, const name& to, + const asset& quantity, const string& memo ); + + private: + + static constexpr std::array FEE_VECTOR{1,2,3,5,7,10,15,20,30,50,75,100,150,200,300}; + static const int DEFAULT_FEE_INDEX = 5; + static const int MIN_FEE_INDEX = 5; + static const int MAX_FEE_INDEX = 11; + static_assert( (0 <= DEFAULT_FEE_INDEX) && (DEFAULT_FEE_INDEX < FEE_VECTOR.size() ), "invalid index" ); + static_assert( (0 <= MIN_FEE_INDEX) && (MIN_FEE_INDEX < FEE_VECTOR.size() ), "invalid index" ); + static_assert( (0 <= MAX_FEE_INDEX) && (MAX_FEE_INDEX < FEE_VECTOR.size() ), "invalid index" ); + static_assert( MIN_FEE_INDEX < MAX_FEE_INDEX, "min_fee must be smaller than max_fee" ); + + int median(symbol_code pair_token); + int get_index(int number); + void addvote(symbol_code pair_token, int fee_index, int64_t amount, bool need_update); + void add_balance(name user, asset to_add, bool need_update); + asset bring_balance(name user, symbol_code pair_token); + + struct [[eosio::table]] feeaccount { + symbol_code pair_token; + int fee_index_voted; + uint64_t primary_key()const { return pair_token.raw(); } + }; + + struct [[eosio::table]] feetable { + symbol_code pair_token; + vector votes; + uint64_t primary_key()const { return pair_token.raw(); } + }; + + typedef eosio::multi_index<"feeaccount"_n, feeaccount> feeaccounts; + typedef eosio::multi_index<"feetable"_n, feetable> feetables; + + struct [[eosio::table]] account { + asset balance; + uint64_t primary_key()const { return balance.symbol.code().raw(); } + }; + typedef eosio::multi_index< "accounts"_n, account > accounts; +}; From a33f587b66ab8e4941f3522993a7bf3351160fdf Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Thu, 10 Sep 2026 16:18:40 -0400 Subject: [PATCH 02/37] swap: port evolutiondex to the sysio CDT and bring its test suite onto contracts_unit_test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minimal port of the pristine import so sysio.swap builds, deploys, and passes every upstream test case on WIRE: - eosio -> sysio throughout (headers, attributes, sysio::multi_index — a drop-in over KV, so all four tables and both secondary indices are unchanged); namespace evolution -> sysio, class evolutiondex -> swap, contract name "sysio.swap". Logic untouched. - CMake: contracts/sysio.swap registered as a two-TU add_contract; built wasm/abi copied into the source tree per house convention. - Ricardian .md files moved under ricardian/: the CDT's add_contract macro passes its `${contracts}` glob unquoted to set_source_files_properties, which fails whenever a directory carries BOTH .contracts.md and .clauses.md. No house contract ships those in-dir, so the bug was latent; needs a one-line quoting fix in wire-cdt's CDTMacros.cmake.in. Tests: all 7 upstream cases move to contracts/tests/sysio.swap_tests.cpp, with badtoken and wevotethefee as contracts/test_contracts/. Fixture changes forced by WIRE semantics, not by the contract: - sysio.token hard-codes ram_payer = "sysio", which only a privileged contract may bill: every account hosting the token WASM (sysio.token, anothertoken, carol) is set_privileged, as sysio.token_tests does. - WIRE requires {payer, sysio.payer} in the action authorization when a contract bills RAM to a user; inittoken's helper (the one place upstream pushed a bare actor list) now carries it explicitly. - Every setup step asserts success(). Upstream ignored those results, which hid a real bug: memoexchange_test sends 0.0001 VOICE bob -> alice first, so many_transfer's 0.0903 VOICE deposit silently overdrew; it is 0.0902. - indextable's hard-coded id_256 embeds the token account name; the eosio.token word is recomputed for sysio.token (verified independently). - Tester API deltas: push_action takes no delay_sec; boost int256_t alias collided with a chain type; edump((ex)) has no fmt formatter. contracts_unit_test (full binary, --sys-vm): No errors detected. Co-Authored-By: Claude Fable 5.1 Change-Id: Ia097ef394c0b4312c03e1d1bd3ff4499f74d86cc --- contracts/CMakeLists.txt | 1 + contracts/sysio.swap/CMakeLists.txt | 6 + .../include/sysio.swap/sysio.swap.hpp | 58 +- .../sysio.swap/include/sysio.swap/utils.hpp | 2 +- .../{ => ricardian}/sysio.swap.clauses.md | 0 .../{ => ricardian}/sysio.swap.contracts.md | 0 contracts/sysio.swap/sysio.swap.abi | 478 +++++++ contracts/sysio.swap/sysio.swap.cpp | 47 +- contracts/sysio.swap/sysio.swap.wasm | Bin 0 -> 50884 bytes contracts/sysio.swap/tests/CMakeLists.txt | 15 - .../sysio.swap/tests/badtoken/badtoken.hpp | 23 - contracts/sysio.swap/tests/contracts.hpp.in | 31 - .../sysio.swap/tests/eosio.system_tester.hpp | 1101 ---------------- .../sysio.swap/tests/evolutiondex_tests.cpp | 1109 ---------------- contracts/sysio.swap/tests/main.cpp | 42 - contracts/sysio.swap/tests/test_symbol.hpp | 16 - contracts/sysio.swap/token_functions.cpp | 16 +- contracts/test_contracts/CMakeLists.txt | 4 +- .../test_contracts/badtoken/CMakeLists.txt | 3 + .../badtoken/badtoken.cpp | 2 +- .../test_contracts/badtoken/badtoken.hpp | 23 + .../wevotethefee/CMakeLists.txt | 3 + .../wevotethefee/README.md | 0 .../ricardian}/wevotethefee.clauses.md | 0 .../ricardian}/wevotethefee.contracts.md | 0 .../wevotethefee/wevotethefee.cpp | 4 +- .../wevotethefee/wevotethefee.hpp | 40 +- contracts/tests/contracts.hpp.in | 6 + contracts/tests/sysio.swap_tests.cpp | 1121 +++++++++++++++++ 29 files changed, 1730 insertions(+), 2421 deletions(-) create mode 100644 contracts/sysio.swap/CMakeLists.txt rename contracts/sysio.swap/{ => ricardian}/sysio.swap.clauses.md (100%) rename contracts/sysio.swap/{ => ricardian}/sysio.swap.contracts.md (100%) create mode 100644 contracts/sysio.swap/sysio.swap.abi create mode 100755 contracts/sysio.swap/sysio.swap.wasm delete mode 100644 contracts/sysio.swap/tests/CMakeLists.txt delete mode 100644 contracts/sysio.swap/tests/badtoken/badtoken.hpp delete mode 100644 contracts/sysio.swap/tests/contracts.hpp.in delete mode 100644 contracts/sysio.swap/tests/eosio.system_tester.hpp delete mode 100644 contracts/sysio.swap/tests/evolutiondex_tests.cpp delete mode 100644 contracts/sysio.swap/tests/main.cpp delete mode 100644 contracts/sysio.swap/tests/test_symbol.hpp create mode 100644 contracts/test_contracts/badtoken/CMakeLists.txt rename contracts/{sysio.swap/tests => test_contracts}/badtoken/badtoken.cpp (92%) create mode 100644 contracts/test_contracts/badtoken/badtoken.hpp create mode 100644 contracts/test_contracts/wevotethefee/CMakeLists.txt rename contracts/{sysio.swap => test_contracts}/wevotethefee/README.md (100%) rename contracts/{sysio.swap/wevotethefee => test_contracts/wevotethefee/ricardian}/wevotethefee.clauses.md (100%) rename contracts/{sysio.swap/wevotethefee => test_contracts/wevotethefee/ricardian}/wevotethefee.contracts.md (100%) rename contracts/{sysio.swap => test_contracts}/wevotethefee/wevotethefee.cpp (98%) rename contracts/{sysio.swap => test_contracts}/wevotethefee/wevotethefee.hpp (59%) create mode 100644 contracts/tests/sysio.swap_tests.cpp diff --git a/contracts/CMakeLists.txt b/contracts/CMakeLists.txt index 803be67bf1..68de0a9afe 100644 --- a/contracts/CMakeLists.txt +++ b/contracts/CMakeLists.txt @@ -58,4 +58,5 @@ add_subdirectory(sysio.dclaim) add_subdirectory(sysio.token) add_subdirectory(sysio.wrap) +add_subdirectory(sysio.swap) add_subdirectory(test_contracts) diff --git a/contracts/sysio.swap/CMakeLists.txt b/contracts/sysio.swap/CMakeLists.txt new file mode 100644 index 0000000000..a5b2e8c938 --- /dev/null +++ b/contracts/sysio.swap/CMakeLists.txt @@ -0,0 +1,6 @@ +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/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index ded0890cec..7b9ed1615b 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -1,50 +1,50 @@ #pragma once -#include -#include -#include -#include +#include +#include +#include +#include #include -using namespace eosio; +using namespace sysio; using namespace std; -namespace evolution { +namespace sysio { - class [[eosio::contract("evolutiondex")]] evolutiondex : public contract { + class [[sysio::contract("sysio.swap")]] swap : public contract { public: - const int64_t MAX = eosio::asset::max_amount; + const int64_t MAX = sysio::asset::max_amount; const int64_t INIT_MAX = 1000000000000000; // 10^15 const int ADD_LIQUIDITY_FEE = 1; const int DEFAULT_FEE = 10; using contract::contract; - [[eosio::action]] void inittoken(name user, symbol new_symbol, + [[sysio::action]] void inittoken(name user, symbol new_symbol, extended_asset initial_pool1, extended_asset initial_pool2, int initial_fee, name fee_contract); - [[eosio::on_notify("*::transfer")]] void ontransfer(name from, name to, asset quantity, string memo); - [[eosio::action]] void openext( const name& user, const name& payer, const extended_symbol& ext_symbol); - [[eosio::action]] void closeext ( const name& user, const name& to, const extended_symbol& ext_symbol, string memo); - [[eosio::action]] void withdraw(name user, name to, extended_asset to_withdraw, string memo); - [[eosio::action]] void addliquidity(name user, asset to_buy, asset max_asset1, asset max_asset2); - [[eosio::action]] void remliquidity(name user, asset to_sell, asset min_asset1, asset min_asset2); - [[eosio::action]] void exchange( name user, symbol_code pair_token, extended_asset ext_asset_in, asset min_expected ); - [[eosio::action]] void changefee(symbol_code pair_token, int newfee); + [[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); - [[eosio::action]] void transfer(const name& from, const name& to, + [[sysio::action]] void transfer(const name& from, const name& to, const asset& quantity, const string& memo ); - [[eosio::action]] void open( const name& owner, const symbol& symbol, const name& ram_payer ); - [[eosio::action]] void close( const name& owner, const symbol& symbol ); - [[eosio::action]] void indexpair(name user, symbol evo_symbol); // This action is only temporarily useful + [[sysio::action]] void open( const name& owner, const symbol& symbol, const name& ram_payer ); + [[sysio::action]] void close( const name& owner, const symbol& symbol ); + [[sysio::action]] void indexpair(name user, symbol evo_symbol); // This action is only temporarily useful private: - struct [[eosio::table]] account { + struct [[sysio::table]] account { asset balance; uint64_t primary_key()const { return balance.symbol.code().raw(); } }; - struct [[eosio::table]] evodexaccount { + struct [[sysio::table]] evodexaccount { extended_asset balance; uint64_t id; uint64_t primary_key()const { return id; } @@ -52,7 +52,7 @@ namespace evolution { make128key(balance.contract.value, balance.quantity.symbol.raw() ); } }; - struct [[eosio::table]] currency_stats { + struct [[sysio::table]] currency_stats { asset supply; asset max_supply; name issuer; @@ -63,21 +63,21 @@ namespace evolution { uint64_t primary_key()const { return supply.symbol.code().raw(); } }; - struct [[eosio::table]] index_struct{ + struct [[sysio::table]] index_struct{ symbol evo_symbol; checksum256 id_256; uint64_t primary_key()const { return evo_symbol.code().raw(); } checksum256 secondary_key()const { return id_256; } }; - typedef eosio::multi_index< "evodexacnts"_n, evodexaccount, + typedef sysio::multi_index< "evodexacnts"_n, evodexaccount, indexed_by<"extended"_n, const_mem_fun> > evodexacnts; - typedef eosio::multi_index< "stat"_n, currency_stats > stats; - typedef eosio::multi_index< "evoindex"_n, index_struct, + typedef sysio::multi_index< "stat"_n, currency_stats > stats; + typedef sysio::multi_index< "evoindex"_n, index_struct, indexed_by<"extended"_n, const_mem_fun> > evoindexes; - typedef eosio::multi_index< "accounts"_n, account > accounts; + typedef sysio::multi_index< "accounts"_n, account > accounts; static uint128_t make128key(uint64_t a, uint64_t b); static checksum256 make256key(uint64_t a, uint64_t b, uint64_t c, uint64_t d); diff --git a/contracts/sysio.swap/include/sysio.swap/utils.hpp b/contracts/sysio.swap/include/sysio.swap/utils.hpp index c1c53cefd4..4dbb2e02a2 100644 --- a/contracts/sysio.swap/include/sysio.swap/utils.hpp +++ b/contracts/sysio.swap/include/sysio.swap/utils.hpp @@ -4,7 +4,7 @@ #include #include -#include +#include #include "safe.hpp" string_view trim(string_view sv) { diff --git a/contracts/sysio.swap/sysio.swap.clauses.md b/contracts/sysio.swap/ricardian/sysio.swap.clauses.md similarity index 100% rename from contracts/sysio.swap/sysio.swap.clauses.md rename to contracts/sysio.swap/ricardian/sysio.swap.clauses.md diff --git a/contracts/sysio.swap/sysio.swap.contracts.md b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md similarity index 100% rename from contracts/sysio.swap/sysio.swap.contracts.md rename to contracts/sysio.swap/ricardian/sysio.swap.contracts.md diff --git a/contracts/sysio.swap/sysio.swap.abi b/contracts/sysio.swap/sysio.swap.abi new file mode 100644 index 0000000000..03b3b29589 --- /dev/null +++ b/contracts/sysio.swap/sysio.swap.abi @@ -0,0 +1,478 @@ +{ + "____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": "addliquidity", + "base": "", + "fields": [ + { + "name": "user", + "type": "name" + }, + { + "name": "to_buy", + "type": "asset" + }, + { + "name": "max_asset1", + "type": "asset" + }, + { + "name": "max_asset2", + "type": "asset" + } + ] + }, + { + "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": "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_contract", + "type": "name" + } + ] + }, + { + "name": "evodexaccount", + "base": "", + "fields": [ + { + "name": "balance", + "type": "extended_asset" + }, + { + "name": "id", + "type": "uint64" + } + ] + }, + { + "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": "index_struct", + "base": "", + "fields": [ + { + "name": "evo_symbol", + "type": "symbol" + }, + { + "name": "id_256", + "type": "checksum256" + } + ] + }, + { + "name": "indexpair", + "base": "", + "fields": [ + { + "name": "user", + "type": "name" + }, + { + "name": "evo_symbol", + "type": "symbol" + } + ] + }, + { + "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_contract", + "type": "name" + } + ] + }, + { + "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": "remliquidity", + "base": "", + "fields": [ + { + "name": "user", + "type": "name" + }, + { + "name": "to_sell", + "type": "asset" + }, + { + "name": "min_asset1", + "type": "asset" + }, + { + "name": "min_asset2", + "type": "asset" + } + ] + }, + { + "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": "addliquidity", + "type": "addliquidity", + "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": "indexpair", + "type": "indexpair", + "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": "transfer", + "type": "transfer", + "ricardian_contract": "" + }, + { + "name": "withdraw", + "type": "withdraw", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "account", + "type": "account", + "index_type": "i64", + "key_names": [], + "key_types": [], + "table_id": 15140 + }, + { + "name": "accounts", + "type": "account", + "index_type": "i64", + "key_names": ["scope","primary_key"], + "key_types": ["name","uint64"], + "table_id": 25660 + }, + { + "name": "currency_stats", + "type": "currency_stats", + "index_type": "i64", + "key_names": [], + "key_types": [], + "table_id": 18569 + }, + { + "name": "evodexaccount", + "type": "evodexaccount", + "index_type": "i64", + "key_names": [], + "key_types": [], + "table_id": 29959 + }, + { + "name": "evodexacnts", + "type": "evodexaccount", + "index_type": "i64", + "key_names": ["scope","primary_key"], + "key_types": ["name","uint64"], + "table_id": 63234, + "secondary_indexes": [ + { + "name": "extended", + "key_type": "uint128", + "table_id": 55555 + } + ] + }, + { + "name": "evoindex", + "type": "index_struct", + "index_type": "i64", + "key_names": ["scope","primary_key"], + "key_types": ["name","uint64"], + "table_id": 41326, + "secondary_indexes": [ + { + "name": "extended", + "key_type": "checksum256", + "table_id": 12143 + } + ] + }, + { + "name": "index_struct", + "type": "index_struct", + "index_type": "i64", + "key_names": [], + "key_types": [], + "table_id": 44724 + }, + { + "name": "stat", + "type": "currency_stats", + "index_type": "i64", + "key_names": ["scope","primary_key"], + "key_types": ["name","uint64"], + "table_id": 4264 + } + ], + "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 index 62aaaca7ca..3ccafba63d 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -1,9 +1,9 @@ -#include "evolutiondex.hpp" -#include "utils.hpp" +#include +#include -using namespace evolution; +namespace sysio { -void evolutiondex::openext( const name& user, const name& payer, const extended_symbol& ext_symbol) { +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 ); @@ -18,7 +18,7 @@ void evolutiondex::openext( const name& user, const name& payer, const extended_ } } -void evolutiondex::closeext( const name& user, const name& to, const extended_symbol& ext_symbol, string memo) { +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 ); auto index = acnts.get_index<"extended"_n>(); @@ -32,12 +32,12 @@ void evolutiondex::closeext( const name& user, const name& to, const extended_sy index.erase( acnt_balance ); } -void evolutiondex::ontransfer(name from, name to, asset quantity, string memo) { +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 evolutiondex"); + 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()}; @@ -53,7 +53,7 @@ void evolutiondex::ontransfer(name from, name to, asset quantity, string memo) { } } -void evolutiondex::withdraw(name user, name to, extended_asset to_withdraw, string memo){ +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); @@ -61,7 +61,7 @@ void evolutiondex::withdraw(name user, name to, extended_asset to_withdraw, stri std::make_tuple( get_self(), to, to_withdraw.quantity, memo) ).send(); } -void evolutiondex::addliquidity(name user, asset to_buy, +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"); @@ -69,7 +69,7 @@ void evolutiondex::addliquidity(name user, asset to_buy, add_signed_liq(user, to_buy, true, max_asset1, max_asset2); } -void evolutiondex::remliquidity(name user, asset to_sell, +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"); @@ -78,7 +78,7 @@ void evolutiondex::remliquidity(name user, asset to_sell, } // computes x * y / z plus the fee -int64_t evolutiondex::compute(int64_t x, int64_t y, int64_t z, int fee) { +int64_t swap::compute(int64_t x, int64_t y, int64_t z, int fee) { check( (x != 0) && (y > 0) && (z > 0), "invalid parameters"); int128_t prod = int128_t(x) * int128_t(y); int128_t tmp = 0; @@ -96,7 +96,7 @@ int64_t evolutiondex::compute(int64_t x, int64_t y, int64_t z, int fee) { return int64_t(tmp); } -void evolutiondex::add_signed_liq(name user, asset to_add, bool is_buying, +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(), to_add.symbol.code().raw() ); @@ -128,7 +128,7 @@ void evolutiondex::add_signed_liq(name user, asset to_add, bool is_buying, check(token->supply.amount != 0, "the pool cannot be left empty"); } -void evolutiondex::exchange( name user, symbol_code pair_token, +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) && (min_expected.amount >= 0)) || @@ -139,7 +139,7 @@ void evolutiondex::exchange( name user, symbol_code pair_token, add_signed_ext_balance(user, ext_asset_out); } -extended_asset evolutiondex::process_exch(symbol_code pair_token, +extended_asset swap::process_exch(symbol_code pair_token, extended_asset ext_asset_in, asset min_expected){ stats statstable( get_self(), pair_token.raw() ); const auto token = statstable.find( pair_token.raw() ); @@ -181,7 +181,7 @@ extended_asset evolutiondex::process_exch(symbol_code pair_token, return ext_asset_out; } -void evolutiondex::memoexchange(name user, extended_asset ext_asset_in, string_view details){ +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'"); @@ -196,7 +196,7 @@ void evolutiondex::memoexchange(name user, extended_asset ext_asset_in, string_v std::make_tuple( get_self(), user, ext_asset_out.quantity, std::string(memo)) ).send(); } -void evolutiondex::inittoken(name user, symbol new_symbol, extended_asset initial_pool1, +void swap::inittoken(name user, symbol new_symbol, extended_asset initial_pool1, extended_asset initial_pool2, int initial_fee, name fee_contract) { require_auth( user ); @@ -231,7 +231,7 @@ extended_asset initial_pool2, int initial_fee, name fee_contract) add_signed_ext_balance(user, -initial_pool2); } -void evolutiondex::indexpair(name user, symbol evo_symbol) { +void swap::indexpair(name user, symbol evo_symbol) { stats statstable( get_self(), evo_symbol.code().raw() ); const auto& token = statstable.find( evo_symbol.code().raw() ); check ( token != statstable.end(), "token symbol does not exist" ); @@ -240,7 +240,7 @@ void evolutiondex::indexpair(name user, symbol evo_symbol) { placeindex(user, evo_symbol, pool1, pool2); } -void evolutiondex::placeindex(name user, symbol evo_symbol, +void swap::placeindex(name user, symbol evo_symbol, extended_asset pool1, extended_asset pool2 ) { auto id_256 = make256key(pool1.contract.value, pool1.quantity.symbol.raw(), pool2.contract.value, pool2.quantity.symbol.raw()); @@ -254,7 +254,7 @@ void evolutiondex::placeindex(name user, symbol evo_symbol, }); } -void evolutiondex::changefee(symbol_code pair_token, int newfee) { +void swap::changefee(symbol_code pair_token, int newfee) { stats statstable( get_self(), pair_token.raw() ); const auto& token = statstable.find( pair_token.raw() ); check ( token != statstable.end(), "pair token does not exist" ); @@ -264,20 +264,20 @@ void evolutiondex::changefee(symbol_code pair_token, int newfee) { } ); } -uint128_t evolutiondex::make128key(uint64_t a, uint64_t b) { +uint128_t swap::make128key(uint64_t a, uint64_t b) { uint128_t aa = a; uint128_t bb = b; return (aa << 64) + bb; } -checksum256 evolutiondex::make256key(uint64_t a, uint64_t b, uint64_t c, uint64_t d) { +checksum256 swap::make256key(uint64_t a, uint64_t b, uint64_t c, uint64_t d) { if (make128key(a,b) < make128key(c,d)) return checksum256::make_from_word_sequence(a,b,c,d); else return checksum256::make_from_word_sequence(c,d,a,b); } -void evolutiondex::add_signed_ext_balance( const name& user, const extended_asset& to_add ) +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 ); @@ -289,4 +289,5 @@ void evolutiondex::add_signed_ext_balance( const name& user, const extended_asse a.balance += to_add; check( a.balance.quantity.amount >= 0, "insufficient funds"); }); -} \ No newline at end of file +} +} // namespace sysio diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm new file mode 100755 index 0000000000000000000000000000000000000000..b30dfcf167cfec56fdb19227c0aa6a9d95f3a6e4 GIT binary patch literal 50884 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 -#include -#include -#include - -using namespace eosio; -using namespace std; - -class [[eosio::contract("badtoken")]] badtoken : public contract { - public: - using contract::contract; - - [[eosio::action]] void transfer( const name& from, const name& to, - const asset& quantity, const string& memo ); - [[eosio::on_notify("evolutiondex::transfer")]] void ontransfer( const name& from, const name& to, - const asset& quantity, const string& memo ); - [[eosio::on_notify("evolutiondex::addliquidity")]] void addliquidity(name user, asset to_buy, - asset max_asset1, asset max_asset2); - [[eosio::on_notify("evolutiondex::remliquidity")]] void remliquidity(name user, asset to_sell, - asset min_asset1, asset min_asset2); -}; \ No newline at end of file diff --git a/contracts/sysio.swap/tests/contracts.hpp.in b/contracts/sysio.swap/tests/contracts.hpp.in deleted file mode 100644 index cfa925b287..0000000000 --- a/contracts/sysio.swap/tests/contracts.hpp.in +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once -#include - -namespace eosio { namespace testing { - -struct contracts { - static std::vector system_wasm() { return read_wasm("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.system/eosio.system.wasm"); } - static std::vector system_abi() { return read_abi("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.system/eosio.system.abi"); } - - static std::vector token_wasm() { return read_wasm("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.token/eosio.token.wasm"); } - static std::vector token_abi() { return read_abi("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.token/eosio.token.abi"); } - - static std::vector msig_wasm() { return read_wasm("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.msig/eosio.msig.wasm"); } - static std::vector msig_abi() { return read_abi("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.msig/eosio.msig.abi"); } - - static std::vector wrap_wasm() { return read_wasm("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.wrap/eosio.wrap.wasm"); } - static std::vector wrap_abi() { return read_abi("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.wrap/eosio.wrap.abi"); } - - static std::vector bios_wasm() { return read_wasm("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.bios/eosio.bios.wasm"); } - static std::vector bios_abi() { return read_abi("${CONTRACTS_BUILD_FOLDER}/contracts/eosio.bios/eosio.bios.abi"); } - - static std::vector evolutiondex_wasm() { return read_wasm("${CMAKE_CURRENT_SOURCE_DIR}/../evolutiondex.wasm"); } - static std::vector evolutiondex_abi() { return read_abi("${CMAKE_CURRENT_SOURCE_DIR}/../evolutiondex.abi"); } - - static std::vector badtoken_wasm() { return read_wasm("${CMAKE_CURRENT_SOURCE_DIR}/badtoken/badtoken.wasm"); } - static std::vector badtoken_abi() { return read_abi("${CMAKE_CURRENT_SOURCE_DIR}/badtoken/badtoken.abi"); } - - static std::vector wevotethefee_wasm() { return read_wasm("${CMAKE_CURRENT_SOURCE_DIR}/../wevotethefee/wevotethefee.wasm"); } - static std::vector wevotethefee_abi() { return read_abi("${CMAKE_CURRENT_SOURCE_DIR}/../wevotethefee/wevotethefee.abi"); } -}; -}} //ns eosio::testing \ No newline at end of file diff --git a/contracts/sysio.swap/tests/eosio.system_tester.hpp b/contracts/sysio.swap/tests/eosio.system_tester.hpp deleted file mode 100644 index 1cdca29b5b..0000000000 --- a/contracts/sysio.swap/tests/eosio.system_tester.hpp +++ /dev/null @@ -1,1101 +0,0 @@ -#pragma once - -#include -#include -#include -#include "contracts.hpp" -#include "test_symbol.hpp" - -#include -#include - -using namespace eosio::chain; -using namespace eosio::testing; -using namespace fc; - -using mvo = fc::mutable_variant_object; - -#ifndef TESTER -#ifdef NON_VALIDATING_TEST -#define TESTER tester -#else -#define TESTER validating_tester -#endif -#endif - -namespace eosio_system { - - -class eosio_system_tester : public TESTER { -public: - - void basic_setup() { - produce_blocks( 2 ); - - create_accounts({ N(eosio.token), N(eosio.ram), N(eosio.ramfee), N(eosio.stake), - N(eosio.bpay), N(eosio.vpay), N(eosio.saving), N(eosio.names), N(eosio.rex) }); - - - produce_blocks( 100 ); - set_code( N(eosio.token), contracts::token_wasm()); - set_abi( N(eosio.token), contracts::token_abi().data() ); - { - const auto& accnt = control->db().get( N(eosio.token) ); - abi_def abi; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt.abi, abi), true); - token_abi_ser.set_abi(abi, abi_serializer_max_time); - } - } - - void create_core_token( symbol core_symbol = symbol{CORE_SYM} ) { - FC_ASSERT( core_symbol.decimals() == 4, "create_core_token assumes core token has 4 digits of precision" ); - create_currency( N(eosio.token), config::system_account_name, asset(100000000000000, core_symbol) ); - issue( asset(10000000000000, core_symbol) ); - BOOST_REQUIRE_EQUAL( asset(10000000000000, core_symbol), get_balance( "eosio", core_symbol ) ); - } - - void deploy_contract( bool call_init = true ) { - set_code( config::system_account_name, contracts::system_wasm() ); - set_abi( config::system_account_name, contracts::system_abi().data() ); - if( call_init ) { - base_tester::push_action(config::system_account_name, N(init), - config::system_account_name, mutable_variant_object() - ("version", 0) - ("core", CORE_SYM_STR) - ); - } - - { - const auto& accnt = control->db().get( config::system_account_name ); - abi_def abi; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt.abi, abi), true); - abi_ser.set_abi(abi, abi_serializer_max_time); - } - } - - void remaining_setup() { - produce_blocks(); - - // Assumes previous setup steps were done with core token symbol set to CORE_SYM - create_account_with_resources( N(alice1111111), config::system_account_name, core_sym::from_string("1.0000"), false ); - create_account_with_resources( N(bob111111111), config::system_account_name, core_sym::from_string("0.4500"), false ); - create_account_with_resources( N(carol1111111), config::system_account_name, core_sym::from_string("1.0000"), false ); - - BOOST_REQUIRE_EQUAL( core_sym::from_string("1000000000.0000"), get_balance("eosio") + get_balance("eosio.ramfee") + get_balance("eosio.stake") + get_balance("eosio.ram") ); - } - - enum class setup_level { - none, - minimal, - core_token, - deploy_contract, - full - }; - - eosio_system_tester( setup_level l = setup_level::full ) { - if( l == setup_level::none ) return; - - basic_setup(); - if( l == setup_level::minimal ) return; - - create_core_token(); - if( l == setup_level::core_token ) return; - - deploy_contract(); - if( l == setup_level::deploy_contract ) return; - - remaining_setup(); - } - - template - eosio_system_tester(Lambda setup) { - setup(*this); - - basic_setup(); - create_core_token(); - deploy_contract(); - remaining_setup(); - } - - - void create_accounts_with_resources( vector accounts, account_name creator = config::system_account_name ) { - for( auto a : accounts ) { - create_account_with_resources( a, creator ); - } - } - - transaction_trace_ptr create_account_with_resources( account_name a, account_name creator, uint32_t ram_bytes = 8000 ) { - signed_transaction trx; - set_transaction_headers(trx); - - authority owner_auth; - owner_auth = authority( get_public_key( a, "owner" ) ); - - trx.actions.emplace_back( vector{{creator,config::active_name}}, - newaccount{ - .creator = creator, - .name = a, - .owner = owner_auth, - .active = authority( get_public_key( a, "active" ) ) - }); - - trx.actions.emplace_back( get_action( config::system_account_name, N(buyrambytes), vector{{creator,config::active_name}}, - mvo() - ("payer", creator) - ("receiver", a) - ("bytes", ram_bytes) ) - ); - trx.actions.emplace_back( get_action( config::system_account_name, N(delegatebw), vector{{creator,config::active_name}}, - mvo() - ("from", creator) - ("receiver", a) - ("stake_net_quantity", core_sym::from_string("10.0000") ) - ("stake_cpu_quantity", core_sym::from_string("10.0000") ) - ("transfer", 0 ) - ) - ); - - set_transaction_headers(trx); - trx.sign( get_private_key( creator, "active" ), control->get_chain_id() ); - return push_transaction( trx ); - } - - transaction_trace_ptr create_account_with_resources( account_name a, account_name creator, asset ramfunds, bool multisig, - asset net = core_sym::from_string("10.0000"), asset cpu = core_sym::from_string("10.0000") ) { - signed_transaction trx; - set_transaction_headers(trx); - - authority owner_auth; - if (multisig) { - // multisig between account's owner key and creators active permission - owner_auth = authority(2, {key_weight{get_public_key( a, "owner" ), 1}}, {permission_level_weight{{creator, config::active_name}, 1}}); - } else { - owner_auth = authority( get_public_key( a, "owner" ) ); - } - - trx.actions.emplace_back( vector{{creator,config::active_name}}, - newaccount{ - .creator = creator, - .name = a, - .owner = owner_auth, - .active = authority( get_public_key( a, "active" ) ) - }); - - trx.actions.emplace_back( get_action( config::system_account_name, N(buyram), vector{{creator,config::active_name}}, - mvo() - ("payer", creator) - ("receiver", a) - ("quant", ramfunds) ) - ); - - trx.actions.emplace_back( get_action( config::system_account_name, N(delegatebw), vector{{creator,config::active_name}}, - mvo() - ("from", creator) - ("receiver", a) - ("stake_net_quantity", net ) - ("stake_cpu_quantity", cpu ) - ("transfer", 0 ) - ) - ); - - set_transaction_headers(trx); - trx.sign( get_private_key( creator, "active" ), control->get_chain_id() ); - return push_transaction( trx ); - } - - transaction_trace_ptr setup_producer_accounts( const std::vector& accounts, - asset ram = core_sym::from_string("1.0000"), - asset cpu = core_sym::from_string("80.0000"), - asset net = core_sym::from_string("80.0000") - ) - { - account_name creator(config::system_account_name); - signed_transaction trx; - set_transaction_headers(trx); - - for (const auto& a: accounts) { - authority owner_auth( get_public_key( a, "owner" ) ); - trx.actions.emplace_back( vector{{creator,config::active_name}}, - newaccount{ - .creator = creator, - .name = a, - .owner = owner_auth, - .active = authority( get_public_key( a, "active" ) ) - }); - - trx.actions.emplace_back( get_action( config::system_account_name, N(buyram), vector{ {creator, config::active_name} }, - mvo() - ("payer", creator) - ("receiver", a) - ("quant", ram) ) - ); - - trx.actions.emplace_back( get_action( config::system_account_name, N(delegatebw), vector{ {creator, config::active_name} }, - mvo() - ("from", creator) - ("receiver", a) - ("stake_net_quantity", net) - ("stake_cpu_quantity", cpu ) - ("transfer", 0 ) - ) - ); - } - - set_transaction_headers(trx); - trx.sign( get_private_key( creator, "active" ), control->get_chain_id() ); - return push_transaction( trx ); - } - - action_result buyram( const account_name& payer, account_name receiver, const asset& eosin ) { - return push_action( payer, N(buyram), mvo()( "payer",payer)("receiver",receiver)("quant",eosin) ); - } - action_result buyram( std::string_view payer, std::string_view receiver, const asset& eosin ) { - return buyram( account_name(payer), account_name(receiver), eosin ); - } - - action_result buyrambytes( const account_name& payer, account_name receiver, uint32_t numbytes ) { - return push_action( payer, N(buyrambytes), mvo()( "payer",payer)("receiver",receiver)("bytes",numbytes) ); - } - action_result buyrambytes( std::string_view payer, std::string_view receiver, uint32_t numbytes ) { - return buyrambytes( account_name(payer), account_name(receiver), numbytes ); - } - - action_result sellram( const account_name& account, uint64_t numbytes ) { - return push_action( account, N(sellram), mvo()( "account", account)("bytes",numbytes) ); - } - action_result sellram( std::string_view account, uint64_t numbytes ) { - return sellram( account_name(account), numbytes ); - } - - action_result push_action( const account_name& signer, const action_name &name, const variant_object &data, bool auth = true ) { - string action_type_name = abi_ser.get_action_type(name); - - action act; - act.account = config::system_account_name; - act.name = name; - act.data = abi_ser.variant_to_binary( action_type_name, data, abi_serializer_max_time ); - - return base_tester::push_action( std::move(act), (auth ? signer : signer == N(bob111111111) ? N(alice1111111) : N(bob111111111)).to_uint64_t() ); - } - - action_result stake( const account_name& from, const account_name& to, const asset& net, const asset& cpu ) { - return push_action( name(from), N(delegatebw), mvo() - ("from", from) - ("receiver", to) - ("stake_net_quantity", net) - ("stake_cpu_quantity", cpu) - ("transfer", 0 ) - ); - } - action_result stake( std::string_view from, std::string_view to, const asset& net, const asset& cpu ) { - return stake( account_name(from), account_name(to), net, cpu ); - } - - action_result stake( const account_name& acnt, const asset& net, const asset& cpu ) { - return stake( acnt, acnt, net, cpu ); - } - action_result stake( std::string_view acnt, const asset& net, const asset& cpu ) { - return stake( account_name(acnt), net, cpu ); - } - - action_result stake_with_transfer( const account_name& from, const account_name& to, const asset& net, const asset& cpu ) { - return push_action( name(from), N(delegatebw), mvo() - ("from", from) - ("receiver", to) - ("stake_net_quantity", net) - ("stake_cpu_quantity", cpu) - ("transfer", true ) - ); - } - action_result stake_with_transfer( std::string_view from, std::string_view to, const asset& net, const asset& cpu ) { - return stake_with_transfer( account_name(from), account_name(to), net, cpu ); - } - - action_result stake_with_transfer( const account_name& acnt, const asset& net, const asset& cpu ) { - return stake_with_transfer( acnt, acnt, net, cpu ); - } - action_result stake_with_transfer( std::string_view acnt, const asset& net, const asset& cpu ) { - return stake_with_transfer( account_name(acnt), net, cpu ); - } - - action_result unstake( const account_name& from, const account_name& to, const asset& net, const asset& cpu ) { - return push_action( name(from), N(undelegatebw), mvo() - ("from", from) - ("receiver", to) - ("unstake_net_quantity", net) - ("unstake_cpu_quantity", cpu) - ); - } - action_result unstake( std::string_view from, std::string_view to, const asset& net, const asset& cpu ) { - return unstake( account_name(from), account_name(to), net, cpu ); - } - - action_result unstake( const account_name& acnt, const asset& net, const asset& cpu ) { - return unstake( acnt, acnt, net, cpu ); - } - action_result unstake( std::string_view acnt, const asset& net, const asset& cpu ) { - return unstake( account_name(acnt), net, cpu ); - } - - int64_t bancor_convert( int64_t S, int64_t R, int64_t T ) { return double(R) * T / ( double(S) + T ); }; - - int64_t get_net_limit( account_name a ) { - int64_t ram_bytes = 0, net = 0, cpu = 0; - control->get_resource_limits_manager().get_account_limits( a, ram_bytes, net, cpu ); - return net; - }; - - int64_t get_cpu_limit( account_name a ) { - int64_t ram_bytes = 0, net = 0, cpu = 0; - control->get_resource_limits_manager().get_account_limits( a, ram_bytes, net, cpu ); - return cpu; - }; - - action_result deposit( const account_name& owner, const asset& amount ) { - return push_action( name(owner), N(deposit), mvo() - ("owner", owner) - ("amount", amount) - ); - } - - action_result withdraw( const account_name& owner, const asset& amount ) { - return push_action( name(owner), N(withdraw), mvo() - ("owner", owner) - ("amount", amount) - ); - } - - action_result buyrex( const account_name& from, const asset& amount ) { - return push_action( name(from), N(buyrex), mvo() - ("from", from) - ("amount", amount) - ); - } - - asset get_buyrex_result( const account_name& from, const asset& amount ) { - auto trace = base_tester::push_action( config::system_account_name, N(buyrex), from, mvo()("from", from)("amount", amount) ); - asset rex_received; - for ( size_t i = 0; i < trace->action_traces.size(); ++i ) { - if ( trace->action_traces[i].act.name == N(buyresult) ) { - fc::raw::unpack( trace->action_traces[i].act.data.data(), - trace->action_traces[i].act.data.size(), - rex_received ); - return rex_received; - } - } - return rex_received; - } - - action_result unstaketorex( const account_name& owner, const account_name& receiver, const asset& from_net, const asset& from_cpu ) { - return push_action( name(owner), N(unstaketorex), mvo() - ("owner", owner) - ("receiver", receiver) - ("from_net", from_net) - ("from_cpu", from_cpu) - ); - } - - asset get_unstaketorex_result( const account_name& owner, const account_name& receiver, const asset& from_net, const asset& from_cpu ) { - auto trace = base_tester::push_action( config::system_account_name, N(unstaketorex), owner, mvo() - ("owner", owner) - ("receiver", receiver) - ("from_net", from_net) - ("from_cpu", from_cpu) - ); - asset rex_received; - for ( size_t i = 0; i < trace->action_traces.size(); ++i ) { - if ( trace->action_traces[i].act.name == N(buyresult) ) { - fc::raw::unpack( trace->action_traces[i].act.data.data(), - trace->action_traces[i].act.data.size(), - rex_received ); - return rex_received; - } - } - return rex_received; - } - - action_result sellrex( const account_name& from, const asset& rex ) { - return push_action( name(from), N(sellrex), mvo() - ("from", from) - ("rex", rex) - ); - } - - asset get_sellrex_result( const account_name& from, const asset& rex ) { - auto trace = base_tester::push_action( config::system_account_name, N(sellrex), from, mvo()("from", from)("rex", rex) ); - asset proceeds; - for ( size_t i = 0; i < trace->action_traces.size(); ++i ) { - if ( trace->action_traces[i].act.name == N(sellresult) ) { - fc::raw::unpack( trace->action_traces[i].act.data.data(), - trace->action_traces[i].act.data.size(), - proceeds ); - return proceeds; - } - } - return proceeds; - } - - auto get_rexorder_result( const transaction_trace_ptr& trace ) { - std::vector> output; - for ( size_t i = 0; i < trace->action_traces.size(); ++i ) { - if ( trace->action_traces[i].act.name == N(orderresult) ) { - fc::datastream ds( trace->action_traces[i].act.data.data(), - trace->action_traces[i].act.data.size() ); - account_name owner; fc::raw::unpack( ds, owner ); - asset proceeds; fc::raw::unpack( ds, proceeds ); - output.emplace_back( owner, proceeds ); - } - } - return output; - } - - action_result cancelrexorder( const account_name& owner ) { - return push_action( name(owner), N(cnclrexorder), mvo()("owner", owner) ); - } - - action_result rentcpu( const account_name& from, const account_name& receiver, const asset& payment, const asset& fund = core_sym::from_string("0.0000") ) { - return push_action( name(from), N(rentcpu), mvo() - ("from", from) - ("receiver", receiver) - ("loan_payment", payment) - ("loan_fund", fund) - ); - } - - action_result rentnet( const account_name& from, const account_name& receiver, const asset& payment, const asset& fund = core_sym::from_string("0.0000") ) { - return push_action( name(from), N(rentnet), mvo() - ("from", from) - ("receiver", receiver) - ("loan_payment", payment) - ("loan_fund", fund) - ); - } - - asset _get_rentrex_result( const account_name& from, const account_name& receiver, const asset& payment, bool cpu ) { - const name act = cpu ? N(rentcpu) : N(rentnet); - auto trace = base_tester::push_action( config::system_account_name, act, from, mvo() - ("from", from) - ("receiver", receiver) - ("loan_payment", payment) - ("loan_fund", core_sym::from_string("0.0000") ) - ); - - asset rented_tokens = core_sym::from_string("0.0000"); - for ( size_t i = 0; i < trace->action_traces.size(); ++i ) { - if ( trace->action_traces[i].act.name == N(rentresult) ) { - fc::raw::unpack( trace->action_traces[i].act.data.data(), - trace->action_traces[i].act.data.size(), - rented_tokens ); - return rented_tokens; - } - } - return rented_tokens; - } - - asset get_rentcpu_result( const account_name& from, const account_name& receiver, const asset& payment ) { - return _get_rentrex_result( from, receiver, payment, true ); - } - - asset get_rentnet_result( const account_name& from, const account_name& receiver, const asset& payment ) { - return _get_rentrex_result( from, receiver, payment, false ); - } - - action_result fundcpuloan( const account_name& from, const uint64_t loan_num, const asset& payment ) { - return push_action( name(from), N(fundcpuloan), mvo() - ("from", from) - ("loan_num", loan_num) - ("payment", payment) - ); - } - - action_result fundnetloan( const account_name& from, const uint64_t loan_num, const asset& payment ) { - return push_action( name(from), N(fundnetloan), mvo() - ("from", from) - ("loan_num", loan_num) - ("payment", payment) - ); - } - - - action_result defundcpuloan( const account_name& from, const uint64_t loan_num, const asset& amount ) { - return push_action( name(from), N(defcpuloan), mvo() - ("from", from) - ("loan_num", loan_num) - ("amount", amount) - ); - } - - action_result defundnetloan( const account_name& from, const uint64_t loan_num, const asset& amount ) { - return push_action( name(from), N(defnetloan), mvo() - ("from", from) - ("loan_num", loan_num) - ("amount", amount) - ); - } - - action_result updaterex( const account_name& owner ) { - return push_action( name(owner), N(updaterex), mvo()("owner", owner) ); - } - - action_result rexexec( const account_name& user, uint16_t max ) { - return push_action( name(user), N(rexexec), mvo()("user", user)("max", max) ); - } - - action_result consolidate( const account_name& owner ) { - return push_action( name(owner), N(consolidate), mvo()("owner", owner) ); - } - - action_result mvtosavings( const account_name& owner, const asset& rex ) { - return push_action( name(owner), N(mvtosavings), mvo()("owner", owner)("rex", rex) ); - } - - action_result mvfrsavings( const account_name& owner, const asset& rex ) { - return push_action( name(owner), N(mvfrsavings), mvo()("owner", owner)("rex", rex) ); - } - - action_result closerex( const account_name& owner ) { - return push_action( name(owner), N(closerex), mvo()("owner", owner) ); - } - - fc::variant get_last_loan(bool cpu) { - vector data; - const auto& db = control->db(); - namespace chain = eosio::chain; - auto table = cpu ? N(cpuloan) : N(netloan); - const auto* t_id = db.find( boost::make_tuple( config::system_account_name, config::system_account_name, table ) ); - if ( !t_id ) { - return fc::variant(); - } - - const auto& idx = db.get_index(); - - auto itr = idx.upper_bound( boost::make_tuple( t_id->id, std::numeric_limits::max() )); - if ( itr == idx.begin() ) { - return fc::variant(); - } - --itr; - if ( itr->t_id != t_id->id ) { - return fc::variant(); - } - - data.resize( itr->value.size() ); - memcpy( data.data(), itr->value.data(), data.size() ); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "rex_loan", data, abi_serializer_max_time ); - } - - fc::variant get_last_cpu_loan() { - return get_last_loan( true ); - } - - fc::variant get_last_net_loan() { - return get_last_loan( false ); - } - - fc::variant get_loan_info( const uint64_t& loan_num, bool cpu ) const { - name table_name = cpu ? N(cpuloan) : N(netloan); - vector data = get_row_by_account( config::system_account_name, config::system_account_name, table_name, account_name(loan_num) ); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "rex_loan", data, abi_serializer_max_time ); - } - - fc::variant get_cpu_loan( const uint64_t loan_num ) const { - return get_loan_info( loan_num, true ); - } - - fc::variant get_net_loan( const uint64_t loan_num ) const { - return get_loan_info( loan_num, false ); - } - - fc::variant get_dbw_obj( const account_name& from, const account_name& receiver ) const { - vector data = get_row_by_account( config::system_account_name, from, N(delband), receiver ); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant("delegated_bandwidth", data, abi_serializer_max_time); - } - - asset get_rex_balance( const account_name& act ) const { - vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(rexbal), act ); - return data.empty() ? asset(0, symbol(SY(4, REX))) : abi_ser.binary_to_variant("rex_balance", data, abi_serializer_max_time)["rex_balance"].as(); - } - - fc::variant get_rex_balance_obj( const account_name& act ) const { - vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(rexbal), act ); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant("rex_balance", data, abi_serializer_max_time); - } - - asset get_rex_fund( const account_name& act ) const { - vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(rexfund), act ); - return data.empty() ? asset(0, symbol{CORE_SYM}) : abi_ser.binary_to_variant("rex_fund", data, abi_serializer_max_time)["balance"].as(); - } - - fc::variant get_rex_fund_obj( const account_name& act ) const { - vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(rexfund), act ); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "rex_fund", data, abi_serializer_max_time ); - } - - asset get_rex_vote_stake( const account_name& act ) const { - vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(rexbal), act ); - return data.empty() ? core_sym::from_string("0.0000") : abi_ser.binary_to_variant("rex_balance", data, abi_serializer_max_time)["vote_stake"].as(); - } - - fc::variant get_rex_order( const account_name& act ) { - vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(rexqueue), act ); - return abi_ser.binary_to_variant( "rex_order", data, abi_serializer_max_time ); - } - - fc::variant get_rex_order_obj( const account_name& act ) { - vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(rexqueue), act ); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "rex_order", data, abi_serializer_max_time ); - } - - fc::variant get_rex_pool() const { - vector data; - const auto& db = control->db(); - namespace chain = eosio::chain; - const auto* t_id = db.find( boost::make_tuple( config::system_account_name, config::system_account_name, N(rexpool) ) ); - if ( !t_id ) { - return fc::variant(); - } - - const auto& idx = db.get_index(); - - auto itr = idx.lower_bound( boost::make_tuple( t_id->id, 0 ) ); - if ( itr == idx.end() || itr->t_id != t_id->id || 0 != itr->primary_key ) { - return fc::variant(); - } - - data.resize( itr->value.size() ); - memcpy( data.data(), itr->value.data(), data.size() ); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "rex_pool", data, abi_serializer_max_time ); - } - - void setup_rex_accounts( const std::vector& accounts, - const asset& init_balance, - const asset& net = core_sym::from_string("80.0000"), - const asset& cpu = core_sym::from_string("80.0000"), - bool deposit_into_rex_fund = true ) { - const asset nstake = core_sym::from_string("10.0000"); - const asset cstake = core_sym::from_string("10.0000"); - create_account_with_resources( N(proxyaccount), config::system_account_name, core_sym::from_string("1.0000"), false, net, cpu ); - BOOST_REQUIRE_EQUAL( success(), push_action( N(proxyaccount), N(regproxy), mvo()("proxy", "proxyaccount")("isproxy", true) ) ); - for (const auto& a: accounts) { - create_account_with_resources( a, config::system_account_name, core_sym::from_string("1.0000"), false, net, cpu ); - transfer( config::system_account_name, a, init_balance + nstake + cstake, config::system_account_name ); - BOOST_REQUIRE_EQUAL( success(), stake( a, a, nstake, cstake) ); - BOOST_REQUIRE_EQUAL( success(), vote( a, { }, N(proxyaccount) ) ); - BOOST_REQUIRE_EQUAL( init_balance, get_balance(a) ); - BOOST_REQUIRE_EQUAL( asset::from_string("0.0000 REX"), get_rex_balance(a) ); - if (deposit_into_rex_fund) { - BOOST_REQUIRE_EQUAL( success(), deposit( a, init_balance ) ); - BOOST_REQUIRE_EQUAL( init_balance, get_rex_fund( a ) ); - BOOST_REQUIRE_EQUAL( 0, get_balance( a ).get_amount() ); - } - } - } - - action_result bidname( const account_name& bidder, const account_name& newname, const asset& bid ) { - return push_action( name(bidder), N(bidname), mvo() - ("bidder", bidder) - ("newname", newname) - ("bid", bid) - ); - } - action_result bidname( std::string_view bidder, std::string_view newname, const asset& bid ) { - return bidname( account_name(bidder), account_name(newname), bid ); - } - - static fc::variant_object producer_parameters_example( int n ) { - return mutable_variant_object() - ("max_block_net_usage", 10000000 + n ) - ("target_block_net_usage_pct", 10 + n ) - ("max_transaction_net_usage", 1000000 + n ) - ("base_per_transaction_net_usage", 100 + n) - ("net_usage_leeway", 500 + n ) - ("context_free_discount_net_usage_num", 1 + n ) - ("context_free_discount_net_usage_den", 100 + n ) - ("max_block_cpu_usage", 10000000 + n ) - ("target_block_cpu_usage_pct", 10 + n ) - ("max_transaction_cpu_usage", 1000000 + n ) - ("min_transaction_cpu_usage", 100 + n ) - ("max_transaction_lifetime", 3600 + n) - ("deferred_trx_expiration_window", 600 + n) - ("max_transaction_delay", 10*86400+n) - ("max_inline_action_size", 4096 + n) - ("max_inline_action_depth", 4 + n) - ("max_authority_depth", 6 + n) - ("max_ram_size", (n % 10 + 1) * 1024 * 1024) - ("ram_reserve_ratio", 100 + n); - } - - action_result regproducer( const account_name& acnt, int params_fixture = 1 ) { - action_result r = push_action( acnt, N(regproducer), mvo() - ("producer", acnt ) - ("producer_key", get_public_key( acnt, "active" ) ) - ("url", "" ) - ("location", 0 ) - ); - BOOST_REQUIRE_EQUAL( success(), r); - return r; - } - - action_result vote( const account_name& voter, const std::vector& producers, const account_name& proxy = name(0) ) { - return push_action(voter, N(voteproducer), mvo() - ("voter", voter) - ("proxy", proxy) - ("producers", producers)); - } - action_result vote( const account_name& voter, const std::vector& producers, std::string_view proxy ) { - return vote( voter, producers, account_name(proxy) ); - } - - uint32_t last_block_time() const { - return time_point_sec( control->head_block_time() ).sec_since_epoch(); - } - - asset get_balance( name contract, const account_name& act, symbol balance_symbol = symbol{CORE_SYM} ) { - vector data = get_row_by_account( contract, act, N(accounts), account_name(balance_symbol.to_symbol_code().value) ); - return data.empty() ? asset(0, balance_symbol) : token_abi_ser.binary_to_variant("account", data, abi_serializer_max_time)["balance"].as(); - } - - asset get_balance( const account_name& act, symbol balance_symbol = symbol{CORE_SYM} ) { - return get_balance(N(eosio.token), act, balance_symbol); - } - - asset get_balance( std::string_view act, symbol balance_symbol = symbol{CORE_SYM} ) { - return get_balance( account_name(act), balance_symbol ); - } - - fc::variant get_total_stake( const account_name& act ) { - vector data = get_row_by_account( config::system_account_name, act, N(userres), act ); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "user_resources", data, abi_serializer_max_time ); - } - fc::variant get_total_stake( std::string_view act ) { - return get_total_stake( account_name(act) ); - } - - fc::variant get_voter_info( const account_name& act ) { - vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(voters), act ); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "voter_info", data, abi_serializer_max_time ); - } - fc::variant get_voter_info( std::string_view act ) { - return get_voter_info( account_name(act) ); - } - - fc::variant get_producer_info( const account_name& act ) { - vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(producers), act ); - return abi_ser.binary_to_variant( "producer_info", data, abi_serializer_max_time ); - } - fc::variant get_producer_info( std::string_view act ) { - return get_producer_info( account_name(act) ); - } - - fc::variant get_producer_info2( const account_name& act ) { - vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(producers2), act ); - return abi_ser.binary_to_variant( "producer_info2", data, abi_serializer_max_time ); - } - fc::variant get_producer_info2( std::string_view act ) { - return get_producer_info2( account_name(act) ); - } - - void create_currency( name contract, name manager, asset maxsupply ) { - auto act = mutable_variant_object() - ("issuer", manager ) - ("maximum_supply", maxsupply ); - - base_tester::push_action(contract, N(create), contract, act ); - } - - void issue( const asset& amount, const name& manager = config::system_account_name ) { - issue(N(eosio.token), amount, manager); - } - - void issue( name contract, const asset& amount, const name& manager = config::system_account_name ) { - base_tester::push_action( contract, N(issue), manager, mutable_variant_object() - ("to", manager ) - ("quantity", amount ) - ("memo", "") - ); - } - - void transfer( const name& contract, const name& from, const name& to, const asset& amount, const name& manager = config::system_account_name ) { - base_tester::push_action( contract, N(transfer), manager, mutable_variant_object() - ("from", from) - ("to", to ) - ("quantity", amount) - ("memo", "") - ); - } - - void transfer( const name& from, const name& to, const asset& amount, const name& manager = config::system_account_name ) { - transfer(N(eosio.token), from, to, amount, manager); - } - - void transfer( const name& from, std::string_view to, const asset& amount, const name& manager = config::system_account_name ) { - transfer( from, name(to), amount, manager ); - } - - void transfer( std::string_view from, std::string_view to, const asset& amount, std::string_view manager ) { - transfer( name(from), name(to), amount, name(manager) ); - } - - void transfer( std::string_view from, std::string_view to, const asset& amount ) { - transfer( name(from), name(to), amount ); - } - - void issue_and_transfer( const name& to, const asset& amount, const name& manager = config::system_account_name ) { - signed_transaction trx; - trx.actions.emplace_back( get_action( N(eosio.token), N(issue), - vector{{manager, config::active_name}}, - mutable_variant_object() - ("to", manager ) - ("quantity", amount ) - ("memo", "") - ) - ); - if ( to != manager ) { - trx.actions.emplace_back( get_action( N(eosio.token), N(transfer), - vector{{manager, config::active_name}}, - mutable_variant_object() - ("from", manager) - ("to", to ) - ("quantity", amount ) - ("memo", "") - ) - ); - } - set_transaction_headers( trx ); - trx.sign( get_private_key( manager, "active" ), control->get_chain_id() ); - push_transaction( trx ); - } - - void issue_and_transfer( std::string_view to, const asset& amount, std::string_view manager ) { - issue_and_transfer( name(to), amount, name(manager) ); - } - - void issue_and_transfer( std::string_view to, const asset& amount, const name& manager ) { - issue_and_transfer( name(to), amount, manager); - } - - void issue_and_transfer( std::string_view to, const asset& amount ) { - issue_and_transfer( name(to), amount ); - } - - double stake2votes( asset stake ) { - auto now = control->pending_block_time().time_since_epoch().count() / 1000000; - return stake.get_amount() * pow(2, int64_t((now - (config::block_timestamp_epoch / 1000)) / (86400 * 7))/ double(52) ); // 52 week periods (i.e. ~years) - } - - double stake2votes( const string& s ) { - return stake2votes( core_sym::from_string(s) ); - } - - fc::variant get_stats( name contract, const string& symbolname ) { - auto symb = eosio::chain::symbol::from_string(symbolname); - auto symbol_code = symb.to_symbol_code().value; - vector data = get_row_by_account( contract, name(symbol_code), N(stat), account_name(symbol_code) ); - return data.empty() ? fc::variant() : token_abi_ser.binary_to_variant( "currency_stats", data, abi_serializer_max_time ); - } - - fc::variant get_stats( const string& symbolname ) { - return get_stats(N(eosio.token), symbolname); - } - - asset get_token_supply() { - return get_stats("4," CORE_SYM_NAME)["supply"].as(); - } - - uint64_t microseconds_since_epoch_of_iso_string( const fc::variant& v ) { - return static_cast( time_point::from_iso_string( v.as_string() ).time_since_epoch().count() ); - } - - fc::variant get_global_state() { - vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(global), N(global) ); - if (data.empty()) std::cout << "\nData is empty\n" << std::endl; - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "eosio_global_state", data, abi_serializer_max_time ); - } - - fc::variant get_global_state2() { - vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(global2), N(global2) ); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "eosio_global_state2", data, abi_serializer_max_time ); - } - - fc::variant get_global_state3() { - vector data = get_row_by_account( config::system_account_name, config::system_account_name, N(global3), N(global3) ); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "eosio_global_state3", data, abi_serializer_max_time ); - } - - fc::variant get_refund_request( name account ) { - vector data = get_row_by_account( config::system_account_name, account, N(refunds), account ); - return data.empty() ? fc::variant() : abi_ser.binary_to_variant( "refund_request", data, abi_serializer_max_time ); - } - - abi_serializer initialize_multisig() { - abi_serializer msig_abi_ser; - { - create_account_with_resources( N(eosio.msig), config::system_account_name ); - BOOST_REQUIRE_EQUAL( success(), buyram( N(eosio), N(eosio.msig), core_sym::from_string("5000.0000") ) ); - produce_block(); - - auto trace = base_tester::push_action(config::system_account_name, N(setpriv), - config::system_account_name, mutable_variant_object() - ("account", "eosio.msig") - ("is_priv", 1) - ); - - set_code( N(eosio.msig), contracts::msig_wasm() ); - set_abi( N(eosio.msig), contracts::msig_abi().data() ); - - produce_blocks(); - const auto& accnt = control->db().get( N(eosio.msig) ); - abi_def msig_abi; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt.abi, msig_abi), true); - msig_abi_ser.set_abi(msig_abi, abi_serializer_max_time); - } - return msig_abi_ser; - } - - vector active_and_vote_producers() { - //stake more than 15% of total EOS supply to activate chain - transfer( N(eosio), N(alice1111111), core_sym::from_string("650000000.0000"), config::system_account_name ); - BOOST_REQUIRE_EQUAL( success(), stake( N(alice1111111), N(alice1111111), core_sym::from_string("300000000.0000"), core_sym::from_string("300000000.0000") ) ); - - // create accounts {defproducera, defproducerb, ..., defproducerz} and register as producers - std::vector producer_names; - { - producer_names.reserve('z' - 'a' + 1); - const std::string root("defproducer"); - for ( char c = 'a'; c < 'a'+21; ++c ) { - producer_names.emplace_back(root + std::string(1, c)); - } - setup_producer_accounts(producer_names); - for (const auto& p: producer_names) { - - BOOST_REQUIRE_EQUAL( success(), regproducer(p) ); - } - } - produce_blocks( 250); - - auto trace_auth = TESTER::push_action(config::system_account_name, updateauth::get_name(), config::system_account_name, mvo() - ("account", name(config::system_account_name).to_string()) - ("permission", name(config::active_name).to_string()) - ("parent", name(config::owner_name).to_string()) - ("auth", authority(1, {key_weight{get_public_key( config::system_account_name, "active" ), 1}}, { - permission_level_weight{{config::system_account_name, config::eosio_code_name}, 1}, - permission_level_weight{{config::producers_account_name, config::active_name}, 1} - } - )) - ); - BOOST_REQUIRE_EQUAL(transaction_receipt::executed, trace_auth->receipt->status); - - //vote for producers - { - transfer( config::system_account_name, N(alice1111111), core_sym::from_string("100000000.0000"), config::system_account_name ); - BOOST_REQUIRE_EQUAL(success(), stake( N(alice1111111), core_sym::from_string("30000000.0000"), core_sym::from_string("30000000.0000") ) ); - BOOST_REQUIRE_EQUAL(success(), buyram( N(alice1111111), N(alice1111111), core_sym::from_string("30000000.0000") ) ); - BOOST_REQUIRE_EQUAL(success(), push_action(N(alice1111111), N(voteproducer), mvo() - ("voter", "alice1111111") - ("proxy", name(0).to_string()) - ("producers", vector(producer_names.begin(), producer_names.begin()+21)) - ) - ); - } - produce_blocks( 250 ); - - auto producer_keys = control->head_block_state()->active_schedule.producers; - BOOST_REQUIRE_EQUAL( 21, producer_keys.size() ); - BOOST_REQUIRE_EQUAL( name("defproducera"), producer_keys[0].producer_name ); - - return producer_names; - } - - void cross_15_percent_threshold() { - setup_producer_accounts({N(producer1111)}); - regproducer(N(producer1111)); - { - signed_transaction trx; - set_transaction_headers(trx); - - trx.actions.emplace_back( get_action( config::system_account_name, N(delegatebw), - vector{{config::system_account_name, config::active_name}}, - mvo() - ("from", name{config::system_account_name}) - ("receiver", "producer1111") - ("stake_net_quantity", core_sym::from_string("150000000.0000") ) - ("stake_cpu_quantity", core_sym::from_string("0.0000") ) - ("transfer", 1 ) - ) - ); - trx.actions.emplace_back( get_action( config::system_account_name, N(voteproducer), - vector{{N(producer1111), config::active_name}}, - mvo() - ("voter", "producer1111") - ("proxy", name(0).to_string()) - ("producers", vector(1, N(producer1111))) - ) - ); - trx.actions.emplace_back( get_action( config::system_account_name, N(undelegatebw), - vector{{N(producer1111), config::active_name}}, - mvo() - ("from", "producer1111") - ("receiver", "producer1111") - ("unstake_net_quantity", core_sym::from_string("150000000.0000") ) - ("unstake_cpu_quantity", core_sym::from_string("0.0000") ) - ) - ); - - set_transaction_headers(trx); - trx.sign( get_private_key( config::system_account_name, "active" ), control->get_chain_id() ); - trx.sign( get_private_key( N(producer1111), "active" ), control->get_chain_id() ); - push_transaction( trx ); - produce_block(); - } - } - - action_result setinflation( int64_t annual_rate, int64_t inflation_pay_factor, int64_t votepay_factor ) { - return push_action( N(eosio), N(setinflation), mvo() - ("annual_rate", annual_rate) - ("inflation_pay_factor", inflation_pay_factor) - ("votepay_factor", votepay_factor) - ); - } - - abi_serializer abi_ser; - abi_serializer token_abi_ser; -}; - -inline fc::mutable_variant_object voter( account_name acct ) { - return mutable_variant_object() - ("owner", acct) - ("proxy", name(0).to_string()) - ("producers", variants() ) - ("staked", int64_t(0)) - //("last_vote_weight", double(0)) - ("proxied_vote_weight", double(0)) - ("is_proxy", 0) - ; -} -inline fc::mutable_variant_object voter( std::string_view acct ) { - return voter( account_name(acct) ); -} - -inline fc::mutable_variant_object voter( account_name acct, const asset& vote_stake ) { - return voter( acct )( "staked", vote_stake.get_amount() ); -} -inline fc::mutable_variant_object voter( std::string_view acct, const asset& vote_stake ) { - return voter( account_name(acct), vote_stake ); -} - -inline fc::mutable_variant_object voter( account_name acct, int64_t vote_stake ) { - return voter( acct )( "staked", vote_stake ); -} -inline fc::mutable_variant_object voter( std::string_view acct, int64_t vote_stake ) { - return voter( account_name(acct), vote_stake ); -} - -inline fc::mutable_variant_object proxy( account_name acct ) { - return voter( acct )( "is_proxy", 1 ); -} - -inline uint64_t M( const string& eos_str ) { - return core_sym::from_string( eos_str ).get_amount(); -} - -} - diff --git a/contracts/sysio.swap/tests/evolutiondex_tests.cpp b/contracts/sysio.swap/tests/evolutiondex_tests.cpp deleted file mode 100644 index 3fa29f2906..0000000000 --- a/contracts/sysio.swap/tests/evolutiondex_tests.cpp +++ /dev/null @@ -1,1109 +0,0 @@ -#include -#include - -#include - -#include -#include -#include - -#include -#include - -using namespace eosio::testing; -using namespace eosio; -using namespace eosio::chain; -using namespace eosio::testing; -using namespace fc; -using namespace std; -using namespace boost::multiprecision; - -using int128 = boost::multiprecision::int128_t; -using int256 = boost::multiprecision::int256_t; -using mvo = fc::mutable_variant_object; - -class evolutiondex_tester : public tester { -public: - - evolutiondex_tester() { - produce_blocks( 2 ); - - create_accounts( { N(alice), N(bob), N(carol), N(eosio.token), N(evolutiondex), - N(wevotethefee), N(badtoken), N(anothertoken) } ); - produce_blocks( 2 ); - - set_code( N(eosio.token), contracts::token_wasm() ); - set_abi( N(eosio.token), contracts::token_abi().data() ); - set_code( N(anothertoken), contracts::token_wasm() ); - set_abi( N(anothertoken), contracts::token_abi().data() ); - - set_code( N(evolutiondex), contracts::evolutiondex_wasm() ); - set_abi( N(evolutiondex), contracts::evolutiondex_abi().data() ); - - set_code( N(carol), contracts::token_wasm() ); - set_abi( N(carol), contracts::token_abi().data() ); - - set_code( N(badtoken), contracts::badtoken_wasm() ); - set_abi( N(badtoken), contracts::badtoken_abi().data() ); - - set_code( N(wevotethefee), contracts::wevotethefee_wasm() ); - set_abi( N(wevotethefee), contracts::wevotethefee_abi().data() ); - - produce_blocks(); - - const auto& accnt1 = control->db().get( N(eosio.token) ); - abi_def abi1; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt1.abi, abi1), true); - abi_ser.set_abi(abi1, abi_serializer_max_time); - } - - 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_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_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, N(create), 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, N(issue), 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, N(transfer), mvo() - ( "from", from) - ( "to", to) - ( "quantity", quantity) - ( "memo", memo) - ); - } - action_result open( name owner, symbol sym, name ram_payer ) { - return push_action( N(evolutiondex), owner, N(open), mvo() - ( "owner", owner) - ( "symbol", sym) - ( "ram_payer", ram_payer) - ); - } - action_result openext( name user, name payer, extended_symbol ext_symbol ) { - return push_action( N(evolutiondex), payer, N(openext), 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( N(evolutiondex), user, N(closeext), mvo() - ( "user", user ) - ( "to", to ) - ( "ext_symbol", ext_symbol ) - ( "memo", "" ) - ); - } - action_result withdraw(name user, name to, extended_asset to_withdraw) { - return push_action( N(evolutiondex), user, N(withdraw), mvo() - ( "user", user ) - ( "to", to ) - ( "to_withdraw", to_withdraw ) - ( "memo", "" ) - ); - } - action_result inittoken( name user, symbol new_symbol, extended_asset initial_pool1, - extended_asset initial_pool2, int initial_fee, name fee_contract){ - std::vector auths{user, N(evolutiondex)}; - try { - eosio::testing::base_tester::push_action( N(evolutiondex), N(inittoken), auths, mvo() - ( "user", user) - ("new_symbol", new_symbol) - ("initial_pool1", initial_pool1) - ("initial_pool2", initial_pool2) - ("initial_fee", initial_fee) - ("fee_contract", fee_contract) - , 100, 0); - } catch (const fc::exception& ex) { - edump((ex)); - edump((ex.to_detail_string())); - return error(ex.top_message()); - } - return success(); - } - action_result addliquidity(name user, asset to_buy, asset max_asset1, asset max_asset2) { - return push_action( N(evolutiondex), user, N(addliquidity), 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( N(evolutiondex), user, N(remliquidity), 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( N(evolutiondex), user, N(exchange), mvo() - ( "user", user ) - ( "pair_token", pair_token ) - ( "ext_asset_in", ext_asset_in ) - ( "min_expected", min_expected ) - ); - } - action_result changefee( symbol_code pair_token, int newfee ) { - return push_action( N(evolutiondex), N(wevotethefee), N(changefee), mvo() - ( "pair_token", pair_token ) - ( "newfee", newfee ) - ); - } - - action_result openfeetable( name user, symbol_code pair_token ) { - return push_action( N(wevotethefee), user, N(openfeetable), mvo() - ( "user", user ) - ( "pair_token", pair_token ) - ); - } - action_result closefeetable( symbol_code pair_token ) { - return push_action( N(wevotethefee), N(wevotethefee), N(closefeetable), mvo() - ( "pair_token", pair_token ) - ); - } - action_result votefee( name user, symbol_code pair_token, int fee_voted ) { - return push_action( N(wevotethefee), user, N(votefee), mvo() - ( "user", user ) - ( "pair_token", pair_token ) - ( "fee_voted", fee_voted ) - ); - } - action_result closevote( name user, symbol_code pair_token ) { - return push_action( N(wevotethefee), user, N(closevote), mvo() - ( "user", user ) - ( "pair_token", pair_token ) - ); - } - action_result updatefee( name user, symbol_code pair_token ) { - return push_action( N(wevotethefee), user, N(updatefee), mvo() - ( "pair_token", pair_token ) - ); - } - - int64_t balance(name user, int64_t id) { - auto _balance = get_balance(N(evolutiondex), user, N(evodexacnts), id, "evodexaccount" ); - return to_int(fc::json::to_string(_balance["balance"]["quantity"], - fc::time_point(fc::time_point::now() + abi_serializer_max_time) )); - } - int64_t tok_balance(name user, int64_t id){ - auto _balance = get_balance(N(evolutiondex), user, N(accounts), 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, N(accounts), 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(N(evolutiondex), name(id), N(stat), 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){ - int256 x = int256(v.at(0)) * int256(v.at(1)) * int256(w.at(2)) * int256(w.at(2)); - int256 y = int256(w.at(0)) * int256(w.at(1)) * int256(v.at(2)) * int256(v.at(2)); - return x <= y; - } - vector total(){ - auto EVO_value = symbol::from_string("4,EVO").to_symbol_code().value; - auto ETUSD_value = symbol::from_string("3,ETUSD").to_symbol_code().value; - int64_t total_eos = balance(N(alice), 0) + balance(N(bob), 0) - + system_balance(EVO_value).at(0) + system_balance(ETUSD_value).at(0); - int64_t total_voice = balance(N(alice), 1) + balance(N(bob), 1) - + system_balance(EVO_value).at(1); - int64_t total_tusd = balance(N(alice), 2) + balance(N(bob), 2) - + system_balance(ETUSD_value).at(1); - vector ans = {total_eos, total_voice, total_tusd}; - return ans; - } - void create_tokens_and_issue() { - create( N(eosio.token), N(alice), asset::from_string("461168601842738.7903 EOS") ); - create( N(anothertoken), N(bob), asset::from_string("461168601842738.7903 VOICE") ); - create( N(eosio.token), N(alice), asset::from_string("46116860184273879.03 TUSD") ); - issue( N(eosio.token), N(alice), N(alice), asset::from_string("461168601842738.7903 EOS"), ""); - issue( N(anothertoken), N(bob), N(bob), asset::from_string("461168601842738.7903 VOICE"), ""); - issue( N(eosio.token), N(alice), N(alice), asset::from_string("46116860184273879.03 TUSD"), ""); - } - void many_openext() { - openext( N(alice), N(alice), extended_symbol{symbol::from_string("4,EOS"), N(eosio.token)}); - openext( N(alice), N(alice), extended_symbol{symbol::from_string("4,VOICE"), N(anothertoken)}); - openext( N(alice), N(alice), extended_symbol{symbol::from_string("2,TUSD"), N(eosio.token)}); - openext( N(bob), N(alice), extended_symbol{symbol::from_string("4,EOS"), N(eosio.token)}); - openext( N(bob), N(alice), extended_symbol{symbol::from_string("4,VOICE"), N(anothertoken)}); - openext( N(bob), N(alice), extended_symbol{symbol::from_string("2,TUSD"), N(eosio.token)}); - } - void many_transfer() { - transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("461000000000000.0000 EOS"), ""); - transfer( N(anothertoken), N(bob), N(evolutiondex), asset::from_string("461168601842738.7000 VOICE"), "deposit to: alice"); - transfer( N(anothertoken), N(bob), N(evolutiondex), asset::from_string("0.0903 VOICE"), "this goes to Bob"); - transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("45000000000000000.00 TUSD"), ""); - transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("300000000000000.00 TUSD"), "deposit to: bob"); - } - void prepare_carol_token() { - create( N(carol), N(carol), asset::from_string("4.0000 EOS") ); - issue( N(carol), N(carol), N(carol), asset::from_string("4.0000 EOS"), ""); - create( N(carol), N(carol), asset::from_string("1.0000 VOICE") ); - issue( N(carol), N(carol), N(carol), asset::from_string("1.0000 VOICE"), ""); - } - abi_serializer abi_ser; -}; - -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(); - -extended_asset extend(asset to_extend) { - if (to_extend.symbol_name() == "VOICE") { - return extended_asset{to_extend, N(anothertoken)}; - } else { - return extended_asset{to_extend, N(eosio.token)}; - } -} - -BOOST_AUTO_TEST_SUITE(evolutiondex_tests) - -BOOST_FIXTURE_TEST_CASE( add_rem_liquidity, evolutiondex_tester ) try { - const auto& accnt2 = control->db().get( N(evolutiondex) ); - abi_def abi_evo; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2.abi, abi_evo), true); - - create_tokens_and_issue(); - transfer( N(anothertoken), N(bob), N(alice), asset::from_string("500000000.0000 VOICE"), ""); - - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - - many_openext(); - - transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("10000000.0000 EOS"), ""); - transfer( N(anothertoken), N(alice), N(evolutiondex), asset::from_string("200000000.0000 VOICE"), ""); - - inittoken( N(alice), EVO4, - extend(asset::from_string("1000000.0000 EOS")), - extend(asset::from_string("100000000.0000 VOICE")), 10, N(wevotethefee)); - - auto alice_evo_balance = get_balance(N(evolutiondex), N(alice), N(accounts), 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( N(evolutiondex), N(bob), N(addliquidity), mvo() - ( "user", N(alice))( "to_buy", asset::from_string("1 EVO")) - ( "max_asset1", asset::from_string("1 EOS") ) - ( "max_asset2", asset::from_string("1 VOICE")) ) - ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("to_buy amount must be positive"), - addliquidity( N(alice), asset::from_string("-5.0000 EVO"), - asset::from_string("0.5000 EOS"), asset::from_string("5000.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), - addliquidity( N(alice), asset::from_string("2.0000 EVO"), - asset::from_string("-0.3000 EOS"), asset::from_string("30.0000 NOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), - addliquidity( N(alice), asset::from_string("2.0000 EVO"), - asset::from_string("0.3000 EOS"), asset::from_string("-30.0000 NOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), - addliquidity( N(alice), asset::from_string("5.0000 EVO"), - asset::from_string("0.5000 EOS"), asset::from_string("5000.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), - addliquidity( N(alice), asset::from_string("2.0000 EVO"), - asset::from_string("0.0000 EOS"), asset::from_string("20.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), - addliquidity( N(alice), asset::from_string("2.0000 EMMO"), - asset::from_string("0.0000 EOS"), asset::from_string("20.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("incorrect symbol"), - addliquidity( N(alice), asset::from_string("3.0000 EVO"), - asset::from_string("0.3000 ECOS"), asset::from_string("30.0001 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), - addliquidity( N(alice), asset::from_string("1000000000000.0000 EVO"), - asset::from_string("1000000000000.0000 EOS"), asset::from_string("20000000000000.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( success(), - addliquidity( N(alice), asset::from_string("50.0000 EVO"), - asset::from_string("5.0050 EOS"), asset::from_string("500.5000 VOICE") ) - ); - produce_blocks(); - -// REMLIQUIDITY - BOOST_REQUIRE_EQUAL( error("missing authority of alice"), - push_action( N(evolutiondex), N(bob), N(remliquidity), mvo() - ( "user", N(alice))( "to_sell", asset::from_string("1 EVO")) - ( "min_asset1", asset::from_string("1 EOS") ) - ( "min_asset2", asset::from_string("1 VOICE")) ) - ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("to_sell amount must be positive"), - remliquidity( N(alice), asset::from_string("-5.0000 EVO"), - asset::from_string("0.5000 EOS"), asset::from_string("5000.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), - remliquidity( N(alice), asset::from_string("3.0000 EVO"), - asset::from_string("-0.3000 EOS"), asset::from_string("30.0001 NOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), - remliquidity( N(alice), asset::from_string("3.0000 EVO"), - asset::from_string("0.3000 EOS"), asset::from_string("-30.0001 NOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), - remliquidity( N(alice), asset::from_string("1.0000 EVO"), - asset::from_string("0.1001 EOS"), asset::from_string("10.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), - remliquidity( N(alice), asset::from_string("3.0000 EVO"), - asset::from_string("0.3000 EOS"), asset::from_string("30.0001 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("incorrect symbol"), - remliquidity( N(alice), asset::from_string("3.0000 EVO"), - asset::from_string("0.3000 EOS"), asset::from_string("30.0001 NOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("overdrawn balance"), - remliquidity( N(alice), asset::from_string("1000000000000.0000 EVO"), - asset::from_string("0.0000 EOS"), asset::from_string("0.0000 VOICE"))); - - BOOST_REQUIRE_EQUAL( wasm_assert_msg("computation overflow"), - addliquidity( N(alice), asset::from_string("46116860184273.8791 EVO"), - asset::from_string("1.0000 EOS"), asset::from_string("1.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("computation underflow"), - remliquidity( N(alice), asset::from_string("46116860184273.8791 EVO"), - asset::from_string("1.0000 EOS"), asset::from_string("1.0000 VOICE"))); - - BOOST_REQUIRE_EQUAL( wasm_assert_msg("the pool cannot be left empty"), - remliquidity( N(alice), asset::from_string("10000050.0000 EVO"), - asset::from_string("0.0001 EOS"), asset::from_string("0.0001 VOICE") ) - ); -/* BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), - addliquidity( N(alice), asset::from_string("2.0000 EVO"), - asset::from_string("0.0000 EOS"), asset::from_string("20.0000 VOICE")) - );*/ -} FC_LOG_AND_RETHROW() - -BOOST_FIXTURE_TEST_CASE( exchange_action, evolutiondex_tester ) try { - const auto& accnt2 = control->db().get( N(evolutiondex) ); - abi_def abi_evo; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2.abi, abi_evo), true); - create_tokens_and_issue(); - transfer( N(anothertoken), N(bob), N(alice), asset::from_string("500000000.0000 VOICE"), ""); - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - many_openext(); - transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("10000000.0000 EOS"), ""); - transfer( N(anothertoken), N(alice), N(evolutiondex), asset::from_string("200000000.0000 VOICE"), ""); - inittoken( N(alice), EVO4, - extend(asset::from_string("1000000.0000 EOS")), - extend(asset::from_string("100000000.0000 VOICE")), 10, N(wevotethefee)); - addliquidity( N(alice), asset::from_string("50.0000 EVO"), - asset::from_string("5.0050 EOS"), asset::from_string("500.5000 VOICE") ); - remliquidity( N(alice), asset::from_string("17.1872 EVO"), - asset::from_string("0.0000 EOS"), asset::from_string("0.0000 VOICE") ); - -// EXCHANGE - BOOST_REQUIRE_EQUAL( error("missing authority of alice"), - push_action( N(evolutiondex), N(bob), N(exchange), mvo() - ( "user", N(alice))( "pair_token", EVO ) - ( "ext_asset_in", extend(asset::from_string("1 EOS")) ) - ( "min_expected", asset::from_string("1 VOICE")) ) - ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg( - "ext_asset_in must be nonzero and min_expected must have same sign or be zero"), - exchange( N(alice), 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 nonzero and min_expected must have same sign or be zero"), - exchange( N(alice), 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 nonzero and min_expected must have same sign or be zero"), - exchange( N(alice), EVO, extend(asset::from_string("0.0000 EOS")), - asset::from_string("-0.1000 VOICE")) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("invalid parameters"), - exchange( N(alice), EVO, extend(asset::from_string("-1000004.0000 EOS")), - asset::from_string("-0.0001 VOICE")) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("invalid parameters"), - exchange( N(alice), 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( N(alice), TUSD, extend(asset::from_string("-8.0000 VOICE")), - asset::from_string("0.0000 EOS")) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), - exchange( N(alice), EVO, extend(asset::from_string("4.000 EOS")), - asset::from_string("10.0000 VOICE")) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), - exchange( N(alice), EVO, extended_asset{asset::from_string("4.0000 EOS"), N(another)}, - asset::from_string("10.0000 VOICE")) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), - exchange( N(alice), EVO, extended_asset{asset::from_string("1.0000 VOICE"), N(another)}, - asset::from_string("1.0000 EOS")) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), - exchange( N(alice), EVO, extend(asset::from_string("4.0000 EOS")), - asset::from_string("400.0000 VOICE")) ); - - exchange( N(alice), EVO, extend(asset::from_string("4.0000 EOS")), asset::from_string("10.0000 VOICE")); - exchange( N(alice), EVO, extend(asset::from_string("0.1000 EOS")), asset::from_string("4.8500 VOICE")); - exchange( N(alice), EVO, extend(asset::from_string("0.0001 EOS")), asset::from_string("0.0009 VOICE")); - - vector expected_system_balance = {10000073819, 999999185799, 100000328128}; - BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); - BOOST_REQUIRE_EQUAL(balance(N(alice),0), 89999926181); - BOOST_REQUIRE_EQUAL(balance(N(alice),1), 1000000814201); - - BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 50) ); - - addliquidity( N(alice), asset::from_string("50.0000 EVO"), - asset::from_string("10000000.0000 EOS"), asset::from_string("10000000.0000 VOICE") ); - - expected_system_balance = {10000123826, 1000004186279, 100000828128}; - BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); - BOOST_REQUIRE_EQUAL(balance(N(alice),0), 89999876174); - BOOST_REQUIRE_EQUAL(balance(N(alice),1), 999995813721); - - BOOST_REQUIRE_EQUAL( success(), - exchange( N(alice), EVO, extend(asset::from_string("-4.0000 EOS")), - asset::from_string("-401.9984 VOICE")) ); - expected_system_balance = {10000083826, 1000008206263, 100000828128}; - BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); - BOOST_REQUIRE_EQUAL(balance(N(alice),0), 89999916174); - BOOST_REQUIRE_EQUAL(balance(N(alice),1), 999991793737); - -} FC_LOG_AND_RETHROW() - - -BOOST_FIXTURE_TEST_CASE( increasing_poolvalue, evolutiondex_tester) try { - const auto& accnt2 = control->db().get( N(evolutiondex) ); - 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_max_time); - many_openext(); - many_transfer(); - transfer( N(eosio.token), N(alice), N(evolutiondex), - asset::from_string("168601842738.7903 EOS"), ""); - - inittoken( N(alice), EVO4, - extend(asset::from_string("23058430092.1369 EOS")), - extend(asset::from_string("96116860184.2738 VOICE")), 10, N(wevotethefee)); - - inittoken( N(alice), ETUSD3, - extend(asset::from_string("10000000000.0000 EOS")), - extend(asset::from_string("9911686018427.38 TUSD")), 10, N(wevotethefee)); - - auto old_total = total(); - auto old_vec = system_balance(EVO.value); - auto old_alice_bal_0 = balance(N(alice),0); - auto old_alice_bal_1 = balance(N(alice),1); - - BOOST_REQUIRE_EQUAL(success(), - exchange( N(alice), 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(N(alice),0) - old_alice_bal_0, -40000); - BOOST_REQUIRE_EQUAL(balance(N(alice),1) - old_alice_bal_1, 166569); - - old_total = total(); - old_vec = system_balance(EVO.value); - old_alice_bal_0 = balance(N(alice), 0); - old_alice_bal_1 = balance(N(alice), 1); - addliquidity( N(alice), asset::from_string("0.0001 EVO"), - asset::from_string("10000000.0000 EOS"), asset::from_string("10000000.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(N(alice),0) - old_alice_bal_0, -2); - BOOST_REQUIRE_EQUAL(balance(N(alice),1) - old_alice_bal_1, -4); - - produce_blocks(); - - old_total = total(); - old_vec = system_balance(EVO.value); - old_alice_bal_0 = balance(N(alice), 0); - old_alice_bal_1 = balance(N(alice), 1); - remliquidity( N(alice), asset::from_string("0.0001 EVO"), - asset::from_string("0.0000 EOS"), asset::from_string("0.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(N(alice), 0) - old_alice_bal_0, 0); - BOOST_REQUIRE_EQUAL(balance(N(alice), 1) - old_alice_bal_1, 2); - - old_total = total(); - old_vec = system_balance(ETUSD.value); - BOOST_REQUIRE_EQUAL(success(), - exchange( N(bob), 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( N(bob), 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( N(bob), N(bob), - extend(asset::from_string("0.0001 EOS"))) ); - BOOST_REQUIRE_EQUAL(old_total == total(), false); - - old_total = total(); - old_vec = system_balance(EVO.value); - addliquidity( N(alice), asset::from_string("150000000000000.0000 EVO"), - asset::from_string("400000000000000.0000 EOS"), asset::from_string("400000000000000.0000 VOICE") ); - 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( N(alice), 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); - - old_total = total(); - old_vec = system_balance(EVO.value); - BOOST_REQUIRE_EQUAL( success(), exchange( N(alice), EVO, - extend(asset::from_string("-1.0000 EOS")), asset::from_string("-0.1069 VOICE")) ); - 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); - BOOST_REQUIRE_EQUAL( success(), exchange( N(bob), EVO, - extend(asset::from_string("-12.0001 VOICE")), asset::from_string("-122.0329 EOS")) ); - BOOST_REQUIRE_EQUAL(old_total == total(), true); - BOOST_REQUIRE_EQUAL(is_increasing(old_vec, system_balance(EVO.value)), true); -} FC_LOG_AND_RETHROW() - - -BOOST_FIXTURE_TEST_CASE( memoexchange_test, evolutiondex_tester ) try { - const auto& accnt2 = control->db().get( N(evolutiondex) ); - abi_def abi_evo; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2.abi, abi_evo), true); - - create_tokens_and_issue(); - prepare_carol_token(); - transfer( N(anothertoken), N(bob), N(alice), asset::from_string("0.0001 VOICE"), ""); - - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - - many_openext(); - many_transfer(); - - BOOST_REQUIRE_EQUAL( success(), - inittoken( N(alice), EVO4, - extend(asset::from_string("23058430092.1369 EOS")), - extend(asset::from_string("96116860184.2738 VOICE")), 10, N(wevotethefee)) ); - - BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), - transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("4.0000 EOS"), - "exchange: EVO, 166536 VOICE") ); - - BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), - transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("4.0000 EOS"), - "exchange: EVO, 16.6570 VOICE") ); - - BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), - transfer( N(carol), N(carol), N(evolutiondex), asset::from_string("4.0000 EOS"), - "exchange: EVO, 16.6569 VOICE") ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), - transfer( N(carol), N(carol), N(evolutiondex), asset::from_string("1.0000 VOICE"), - "exchange: EVO, 0.0001 EOS") ); - - int64_t pre_eos_balance = token_balance(N(eosio.token), N(alice), EOS.value); - int64_t pre_voice_balance = token_balance(N(anothertoken), N(alice), VOICE.value); - BOOST_REQUIRE_EQUAL( success(), - transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("4.0000 EOS"), - "exchange: EVO, 16.6569 VOICE") ); - BOOST_REQUIRE_EQUAL( pre_eos_balance - 40000, token_balance(N(eosio.token), N(alice), EOS.value) ); - BOOST_REQUIRE_EQUAL( pre_voice_balance + 166569, token_balance(N(anothertoken), N(alice), VOICE.value) ); - - inittoken( N(alice), ETUSD3, - extend(asset::from_string("10000000000.0000 EOS")), - extend(asset::from_string("9911686018427.38 TUSD")), 10, N(wevotethefee)); - - BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), - transfer( N(eosio.token), N(alice), N(evolutiondex), - asset::from_string("400000.00 TUSD"), "exchange: ETUSD, 403.1605 EOS") ); - - pre_eos_balance = token_balance(N(eosio.token), N(alice), EOS.value); - int64_t pre_tusd_balance = token_balance(N(eosio.token), N(alice), TUSD.value); - BOOST_REQUIRE_EQUAL( success(), - transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("400000.00 TUSD"), - "exchange: ETUSD, 403.1604 EOS") ); - - BOOST_REQUIRE_EQUAL( pre_tusd_balance - 40000000, - token_balance(N(eosio.token), N(alice), TUSD.value) ); - BOOST_REQUIRE_EQUAL( pre_eos_balance + 4031604, - token_balance(N(eosio.token), N(alice), EOS.value) ); - - auto old_vec = system_balance(EVO.value); - transfer( N(eosio.token), N(alice), N(evolutiondex), 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( N(eosio.token), N(bob), N(evolutiondex), - 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, evolutiondex_tester ) try { - - create_tokens_and_issue(); - transfer( N(anothertoken), N(bob), N(alice), asset::from_string("500000000.0000 VOICE"), ""); - - const auto& accnt2 = control->db().get( N(evolutiondex) ); - abi_def abi_evo; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2.abi, abi_evo), true); - abi_ser.set_abi(abi_evo, 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( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("0.1000 EOS"), "") ); - - // OPENEXT - BOOST_REQUIRE_EQUAL( error("missing authority of natalia"), - push_action( N(evolutiondex), N(bob), N(openext), mvo() - ( "user", N(bob))( "payer", N(natalia) ) - ( "ext_symbol", extended_symbol{VOICE4, N(anothertoken)} )) - ); - BOOST_REQUIRE_EQUAL( success(), openext( N(alice), N(alice), - extended_symbol{EOS4, N(eosio.token)}) ); - BOOST_REQUIRE_EQUAL( success(), openext( N(alice), N(alice), - extended_symbol{VOICE4, N(anothertoken)}) ); - BOOST_REQUIRE_EQUAL( success(), openext( N(alice), N(alice), - extended_symbol{VOICE4, N(anothertoken)}) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg( "user account does not exist"), - openext( N(cat), N(alice), extended_symbol{VOICE4, N(anothertoken)}) ); - BOOST_REQUIRE_EQUAL( success(), openext( N(alice), N(alice), - extended_symbol{VOICE4, N(anothertoken)}) ); - - // ONTRANSFER - BOOST_REQUIRE_EQUAL( wasm_assert_msg("This transfer is not for evolutiondex"), - transfer( N(badtoken), N(alice), N(bob), asset::from_string("1000.0000 EOS"), "")); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("quantity must be positive"), - transfer( N(badtoken), N(alice), N(evolutiondex), asset::from_string("-1000.0000 EOS"), "")); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("Donation not accepted"), transfer( N(eosio.token), N(alice), N(evolutiondex), - asset::from_string("1000.0000 EOS"), "deposit to: evolutiondex") ); - BOOST_REQUIRE_EQUAL( success(), transfer( N(eosio.token), N(alice), N(evolutiondex), - asset::from_string("1000.0000 EOS"), "") ); - BOOST_REQUIRE_EQUAL( success(), transfer( N(anothertoken), N(alice), N(evolutiondex), - asset::from_string("20000.0000 VOICE"), "") ); - - // WITHDRAW - BOOST_REQUIRE_EQUAL( error("missing authority of alice"), - push_action( N(evolutiondex), N(bob), N(withdraw), mvo() - ("user", N(alice)) ("to", N(natalia)) - ("to_withdraw", extend(asset::from_string("1.0000 EOS"))) ("memo", "") ) - ); - BOOST_REQUIRE_EQUAL( success(), withdraw( N(alice), N(bob), - extend(asset::from_string("999.9998 EOS")) ) ); - BOOST_REQUIRE_EQUAL( 9999998, token_balance( N(eosio.token), N(bob), EOS.value )); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("quantity must be positive"), withdraw( N(alice), N(bob), - extend(asset::from_string("-0.0001 EOS")) )); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), withdraw( N(alice), N(bob), - extend(asset::from_string("0.0003 EOS")) )); - - // INITTOKEN - BOOST_REQUIRE_EQUAL( error("missing authority of evolutiondex"), - push_action( N(evolutiondex), N(alice), N(inittoken), mvo() - ("user", N(alice)) ("new_symbol", EVO4) - ("initial_pool1", extend(asset::from_string("1.0000 EOS"))) - ("initial_pool2", extend(asset::from_string("1.0000 ECO"))) - ("initial_fee", 1) ("fee_contract", N(carol)) ) - ); - BOOST_REQUIRE_EQUAL( error("missing authority of alice"), - push_action( N(evolutiondex), N(bob), N(inittoken), mvo() - ("user", N(alice)) ("new_symbol", EVO4) - ("initial_pool1", extend(asset::from_string("1.0000 EOS"))) - ("initial_pool2", extend(asset::from_string("1.0000 ECO"))) - ("initial_fee", 1) ("fee_contract", N(carol)) ) - ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("Both assets must be positive"), inittoken( N(alice), EVO4, - extend(asset::from_string("-0.0001 EOS")), - extend(asset::from_string("0.1000 VOICE")), 10, N(wevotethefee)) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("Both assets must be positive"), inittoken( N(alice), EVO4, - extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("-0.1000 VOICE")), 10, N(wevotethefee)) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("Initial amounts must be less than 10^15"), inittoken( N(alice), EVO4, - extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("100000000000.0001 VOICE")), 10, N(wevotethefee)) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("Initial amounts must be less than 10^15"), inittoken( N(alice), EVO4, - extend(asset::from_string("100000000000.0001 EOS")), - extend(asset::from_string("1.0001 VOICE")), 10, N(wevotethefee)) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended symbols must be different"), inittoken( N(alice), EVO4, - extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("0.1000 EOS")), 10, N(wevotethefee)) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), - inittoken( N(alice), EVO4, - extend(asset::from_string("0.0003 EOS")), - extend(asset::from_string("0.1000 VOICE")), 10, N(wevotethefee)) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), - inittoken( N(alice), EVO4, - extend(asset::from_string("0.0002 EOS")), - extend(asset::from_string("100000000.0000 VOICE")), 10, N(wevotethefee)) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("new_symbol precision must be (precision1 + precision2) / 2"), - inittoken( N(alice), EVO4, - extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("0.100 VOICE")), 10, N(wevotethefee)) ); - BOOST_REQUIRE_EQUAL( success(), inittoken( N(alice), EVO4, - extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("0.1000 VOICE")), 10, N(wevotethefee)) ); - produce_blocks(); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("token symbol already exists"), inittoken( N(alice), EVO4, - extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("0.1000 VOICE")), 10, N(wevotethefee)) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("initial_fee must be 10"), inittoken( N(alice), ETUSD3, - extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("0.10 TUSD")), 501, N(wevotethefee)) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee_contract must be wevotethefee"), - inittoken( N(alice), ETUSD3, - extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("0.10 ETUSD")), 10, N(alice)) ); -// The assert "the pool is already indexed" is tested in "indextable" test case. - - // TRANSFER - 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(N(evolutiondex), N(alice), N(evolutiondex), asset::from_string("0.0010 EVO"), "") - ); - - // CLOSEEXT - BOOST_REQUIRE_EQUAL( error("missing authority of natalia"), - push_action( N(evolutiondex), N(bob), N(closeext), mvo() - ("user", N(natalia))("ext_symbol", extended_symbol{EVO4, N(eosio.token)}) - ("to", N(alice))("memo", "") ) - ); - BOOST_REQUIRE_EQUAL( success(), - closeext( N(alice), N(alice), extended_symbol{VOICE4, N(anothertoken)}) ); - BOOST_REQUIRE_EQUAL( success(), - closeext( N(alice), N(bob), extended_symbol{EOS4, N(eosio.token)} ) ); - BOOST_REQUIRE_EQUAL( 9999999, token_balance( N(eosio.token), N(bob), EOS.value )); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("User does not have such token"), - closeext( N(alice), N(bob), extended_symbol{EOS4, N(eosio.token)} ) ); - - // CHANGEFEE - BOOST_REQUIRE_EQUAL( error("missing authority of wevotethefee"), - push_action( N(evolutiondex), N(bob), N(changefee), - mvo() ("pair_token", EVO) ("newfee", 50) ) - ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), changefee(EOS, 500)); - BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 50) ); - - // Notifications to fee_contract - - set_code( N(wevotethefee), contracts::badtoken_wasm() ); - set_abi( N(wevotethefee), contracts::badtoken_abi().data() ); - many_openext(); - transfer( N(eosio.token), N(bob), N(evolutiondex), asset::from_string("0.0004 EOS"), ""); - transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("0.04 TUSD"), "deposit to: bob"); - - BOOST_REQUIRE_EQUAL( success(), inittoken( N(bob), ETUSD3, - extend(asset::from_string("0.0002 EOS")), - extend(asset::from_string("0.02 TUSD")), 10, N(wevotethefee) ) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("notification received"), - transfer( N(evolutiondex), N(bob), N(alice), asset::from_string("0.001 ETUSD"), "") ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("notification received"), - addliquidity( N(bob), asset::from_string("0.001 ETUSD"), - asset::from_string("0.0002 EOS"), asset::from_string("0.02 TUSD"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("notification received"), - remliquidity( N(bob), asset::from_string("0.001 ETUSD"), - asset::from_string("0.0001 EOS"), asset::from_string("0.01 TUSD"))); - -} FC_LOG_AND_RETHROW() - - -BOOST_FIXTURE_TEST_CASE( we_vote_the_fee, evolutiondex_tester ) try { - - create_tokens_and_issue(); - transfer( N(anothertoken), N(bob), N(alice), asset::from_string("500000000.0000 VOICE"), ""); - - const auto& accnt2 = control->db().get( N(evolutiondex) ); - abi_def abi_evo; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2.abi, abi_evo), true); - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - - many_openext(); - - transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("10000000.0000 EOS"), ""); - transfer( N(anothertoken), N(alice), N(evolutiondex), asset::from_string("200000000.0000 VOICE"), ""); - - inittoken( N(alice), EVO4, - extend(asset::from_string("1000000.0000 EOS")), - extend(asset::from_string("100000000.0000 VOICE")), 10, N(wevotethefee)); - - const auto& accnt3 = control->db().get( N(wevotethefee) ); - abi_def abi_wevote; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt3.abi, abi_wevote), true); - -// Start wevotethefee actions. Testing checks and basic functions. - abi_ser.set_abi(abi_wevote, abi_serializer_max_time); - auto ISNT = symbol::from_string("4,ISNT").to_symbol_code(); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("pair_token balance does not exist"), - votefee(N(alice), ISNT, 10) ); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("pair_token balance does not exist"), - votefee(N(bob), EVO, 30) ); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("fee table nonexistent, run openfeetable"), votefee(N(alice), EVO, 30)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("fee table nonexistent, run openfeetable"), updatefee(N(alice), EVO)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("table does not exist"), closefeetable(EVO)); - BOOST_REQUIRE_EQUAL(success(), openfeetable(N(alice), EVO)); - BOOST_REQUIRE_EQUAL(success(), closefeetable(EVO)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("fee table nonexistent, run openfeetable"), votefee(N(alice), EVO, 30)); - BOOST_REQUIRE_EQUAL(success(), openfeetable(N(alice), EVO)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("already opened"), openfeetable(N(alice), EVO)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("user is not voting"), closevote(N(alice), EVO)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("only values between 10 and 100 are allowed"), votefee(N(alice), EVO, 101)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("only values between 10 and 100 are allowed"), votefee(N(alice), EVO, -10)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("only values between 10 and 100 are allowed"), votefee(N(alice), EVO, 9)); - BOOST_REQUIRE_EQUAL(success(), votefee(N(alice), EVO, 30)); - BOOST_REQUIRE_EQUAL(success(), updatefee(N(alice), EVO)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("table of votes is not empty"), closefeetable(EVO)); - - auto evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, - "feetable" )["votes"].get_array(); - BOOST_REQUIRE_EQUAL(100000000000, evo_votes[8]); - - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - auto evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(30, evo_stats["fee"]); - -// Leave vote table with zero votes, fee value remains unchanged - transfer( N(evolutiondex), N(alice), N(bob), asset::from_string("10000000.0000 EVO"), ""); - evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(30, evo_stats["fee"]); - abi_ser.set_abi(abi_wevote, abi_serializer_max_time); - evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, - "feetable" )["votes"].get_array(); - BOOST_REQUIRE_EQUAL(0, evo_votes[5]); - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - transfer( N(evolutiondex), N(bob), N(alice), asset::from_string("10000000.0000 EVO"), ""); - -// Test authorizations - abi_ser.set_abi(abi_wevote, abi_serializer_max_time); - BOOST_REQUIRE_EQUAL( error("missing authority of bob"), - push_action( N(wevotethefee), N(alice), N(votefee), - mvo()( "user", N(bob) )( "pair_token", EVO )( "fee_voted", "10" ) ) - ); - BOOST_REQUIRE_EQUAL( error("missing authority of bob"), - push_action( N(wevotethefee), N(alice), N(closevote), - mvo()( "user", N(bob) )( "pair_token", EVO ) ) - ); - -// Vote balance change after transfer - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - BOOST_REQUIRE_EQUAL(success(), - transfer( N(evolutiondex), N(alice), N(bob), asset::from_string("100.0000 EVO"), "")); - - abi_ser.set_abi(abi_wevote, abi_serializer_max_time); - BOOST_REQUIRE_EQUAL(success(), votefee(N(bob), EVO, 100)); - evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, - "feetable" )["votes"].get_array(); - BOOST_REQUIRE_EQUAL(99999000000, evo_votes[8]); - BOOST_REQUIRE_EQUAL(1000000, evo_votes[11]); - -// Scenarios with many votes - create_accounts( { N(dan), N(eva), N(fran)}); - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - BOOST_REQUIRE_EQUAL(success(), transfer( N(evolutiondex), N(alice), N(carol), asset::from_string("2000.0000 EVO"), "")); - transfer( N(evolutiondex), N(alice), N(dan), asset::from_string("400000.0000 EVO"), ""); - transfer( N(evolutiondex), N(alice), N(eva), asset::from_string("4240000.0000 EVO"), ""); - transfer( N(evolutiondex), N(alice), N(fran), asset::from_string("2500000.0000 EVO"), ""); - - abi_ser.set_abi(abi_wevote, abi_serializer_max_time); - BOOST_REQUIRE_EQUAL(success(), votefee(N(carol), EVO, 10)); - votefee(N(dan), EVO, 10); - votefee(N(eva), EVO, 74); - votefee(N(fran), EVO, 73); - - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(75, evo_stats["fee"]); - - // Test updatefee when closing votes, then restore votes. - abi_ser.set_abi(abi_wevote, abi_serializer_max_time); - closevote( N(alice), EVO ); - closevote( N(eva), EVO ); - closevote( N(fran), EVO ); - evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, - "feetable" )["votes"].get_array(); - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(10, evo_stats["fee"]); - abi_ser.set_abi(abi_wevote, abi_serializer_max_time); - votefee(N(alice), EVO, 30); - votefee(N(eva), EVO, 75); - votefee(N(fran), EVO, 75); - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(75, evo_stats["fee"]); - - // Test fee modification when adding and removing liquidity - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - openext( N(eva), N(eva), extended_symbol{symbol::from_string("4,EOS"), N(eosio.token)}); - openext( N(eva), N(eva), extended_symbol{symbol::from_string("4,VOICE"), N(anothertoken)}); - push_action( N(evolutiondex), N(eva), N(remliquidity), mvo() - ( "user", N(eva))( "to_sell", asset::from_string("4000000.0000 EVO")) - ( "min_asset1", asset::from_string("1.0000 EOS") ) - ( "min_asset2", asset::from_string("1.0000 VOICE")) ); - evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(30, evo_stats["fee"]); - - BOOST_REQUIRE_EQUAL(success(), push_action( N(evolutiondex), N(eva), N(addliquidity), mvo() - ( "user", N(eva))( "to_buy", asset::from_string("3900000.0000 EVO")) - ( "max_asset1", asset::from_string("100000000000.0000 EOS") ) - ( "max_asset2", asset::from_string("100000000000.0000 VOICE")) ) ); - evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(75, evo_stats["fee"]); - -// median 10, due to clamp between 10 and 100 - abi_ser.set_abi(abi_wevote, abi_serializer_max_time); - votefee(N(alice), EVO, 10); - votefee(N(dan), EVO, 10); - votefee(N(eva), EVO, 10); - votefee(N(fran), EVO, 10); - evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, - "feetable" )["votes"].get_array(); - BOOST_REQUIRE_EQUAL(98999000000, evo_votes[5]); - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(10, evo_stats["fee"]); - -// check votes after transfer, remliquidity and addliquidity - BOOST_REQUIRE_EQUAL(success(), - transfer( N(evolutiondex), N(alice), N(bob), asset::from_string("100.0000 EVO"), "")); - abi_ser.set_abi(abi_wevote, abi_serializer_max_time); - evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, - "feetable" )["votes"].get_array(); - BOOST_REQUIRE_EQUAL(98998000000, evo_votes[5]); - BOOST_REQUIRE_EQUAL(2000000, evo_votes[11]); - - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - push_action( N(evolutiondex), N(eva), N(remliquidity), mvo() - ( "user", N(eva))( "to_sell", asset::from_string("10000.0000 EVO")) - ( "min_asset1", asset::from_string("1.0000 EOS") ) - ( "min_asset2", asset::from_string("1.0000 VOICE")) ); - abi_ser.set_abi(abi_wevote, abi_serializer_max_time); - evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, - "feetable" )["votes"].get_array(); - BOOST_REQUIRE_EQUAL(98998000000 - 100000000, evo_votes[5]); - - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - push_action( N(evolutiondex), N(eva), N(addliquidity), mvo() - ( "user", N(eva))( "to_buy", asset::from_string("100.0000 EVO")) - ( "max_asset1", asset::from_string("10000000.0000 EOS") ) - ( "max_asset2", asset::from_string("10000000.0000 VOICE")) ); - abi_ser.set_abi(abi_wevote, abi_serializer_max_time); - evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, - "feetable" )["votes"].get_array(); - BOOST_REQUIRE_EQUAL(98998000000 - 100000000 + 1000000, evo_votes[5]); - -// median 100 - votefee(N(dan), EVO, 100); - votefee(N(eva), EVO, 100); - votefee(N(fran), EVO, 100); - evo_votes = get_balance(N(wevotethefee), name(EVO.value), N(feetable), EVO.value, - "feetable" )["votes"].get_array(); - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - evo_stats = get_balance(N(evolutiondex), name(EVO.value), N(stat), EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(100, evo_stats["fee"]); - -} FC_LOG_AND_RETHROW() - -BOOST_FIXTURE_TEST_CASE( indextable, evolutiondex_tester ) try { - - create_tokens_and_issue(); - transfer( N(anothertoken), N(bob), N(alice), asset::from_string("500000000.0000 VOICE"), ""); - const auto& accnt2 = control->db().get( N(evolutiondex) ); - abi_def abi_evo; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt2.abi, abi_evo), true); - abi_ser.set_abi(abi_evo, abi_serializer_max_time); - many_openext(); - transfer( N(eosio.token), N(alice), N(evolutiondex), asset::from_string("10000000.0000 EOS"), ""); - transfer( N(anothertoken), N(alice), N(evolutiondex), asset::from_string("200000000.0000 VOICE"), ""); - - BOOST_REQUIRE_EQUAL(wasm_assert_msg("token symbol does not exist"), - push_action( N(evolutiondex), N(alice), N(indexpair), - mvo() ( "user", N(alice) ) ( "evo_symbol", EVO4 ) ) ); - - BOOST_REQUIRE_EQUAL(success(), inittoken( N(alice), EVO4, - extend(asset::from_string("1000000.0000 EOS")), - extend(asset::from_string("100000000.0000 VOICE")), 10, N(wevotethefee)) ); - - BOOST_REQUIRE_EQUAL(wasm_assert_msg("the pool is already indexed"), - push_action( N(evolutiondex), N(alice), N(indexpair), - mvo() ( "user", N(alice) ) ( "evo_symbol", EVO4 ) ) ); - - BOOST_REQUIRE_EQUAL(wasm_assert_msg("the pool is already indexed"), - inittoken( N(alice), EOS4, - extend(asset::from_string("1.0000 EOS")), - extend(asset::from_string("1.0000 VOICE")), 10, N(wevotethefee)) ); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("the pool is already indexed"), - inittoken( N(alice), EOS4, - extend(asset::from_string("1.0000 VOICE")), - extend(asset::from_string("1.0000 EOS")), 10, N(wevotethefee)) ); - - auto table = get_balance(N(evolutiondex), N(evolutiondex), N(evoindex), EVO.value, "index_struct"); - BOOST_REQUIRE_EQUAL(table["id_256"], "34e996aaf9a4153000004543494f56045530ea033482a60000000000534f4504"); - BOOST_REQUIRE_EQUAL(table["evo_symbol"], "4,EVO"); - - 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( N(alice), EOS4, - extend(asset::from_string("1.00000 VOICE")), - extend(asset::from_string("1.0000 EOS")), 10, N(wevotethefee)) ); - 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( N(alice), EOS4, - extend(asset::from_string("1.0000 VOICE")), - extend(asset::from_string("1.00000 EOS")), 10, N(wevotethefee)) ); - 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( N(alice), EOS4, extend(asset::from_string("1.0000 VOICE")), - extended_asset{asset::from_string("1.0000 EOS"), N(anothertoken)}, - 10, N(wevotethefee)) ); -} FC_LOG_AND_RETHROW() - -BOOST_AUTO_TEST_SUITE_END() \ No newline at end of file diff --git a/contracts/sysio.swap/tests/main.cpp b/contracts/sysio.swap/tests/main.cpp deleted file mode 100644 index 2c658f31a1..0000000000 --- a/contracts/sysio.swap/tests/main.cpp +++ /dev/null @@ -1,42 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "eosio.system_tester.hpp" - -using namespace eosio_system; -#define BOOST_TEST_STATIC_LINK - -void translate_fc_exception(const fc::exception &e) { - std::cerr << "\033[33m" << e.to_detail_string() << "\033[0m" << std::endl; - BOOST_TEST_FAIL("Caught Unexpected Exception"); -} - -boost::unit_test::test_suite* init_unit_test_suite(int argc, char* argv[]) { - // Turn off blockchain logging if no --verbose parameter is not added - // To have verbose enabled, call "tests/chain_test -- --verbose" - bool is_verbose = false; - std::string verbose_arg = "--verbose"; - for (int i = 0; i < argc; i++) { - if (verbose_arg == argv[i]) { - is_verbose = true; - break; - } - } - if(!is_verbose) fc::logger::get(DEFAULT_LOGGER).set_log_level(fc::log_level::off); - - // Register fc::exception translator - boost::unit_test::unit_test_monitor.template register_exception_translator(&translate_fc_exception); - - std::srand(time(NULL)); - std::cout << "Random number generator seeded to " << time(NULL) << std::endl; - return nullptr; -} diff --git a/contracts/sysio.swap/tests/test_symbol.hpp b/contracts/sysio.swap/tests/test_symbol.hpp deleted file mode 100644 index 598ea43468..0000000000 --- a/contracts/sysio.swap/tests/test_symbol.hpp +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#define CORE_SYM_NAME "TST" -#define CORE_SYM_PRECISION 4 - -#define _STRINGIZE1(x) #x -#define _STRINGIZE2(x) _STRINGIZE1(x) - -#define CORE_SYM_STR ( _STRINGIZE2(CORE_SYM_PRECISION) "," CORE_SYM_NAME ) -#define CORE_SYM ( ::eosio::chain::string_to_symbol_c( CORE_SYM_PRECISION, CORE_SYM_NAME ) ) - -struct core_sym { - static inline eosio::chain::asset from_string(const std::string& s) { - return eosio::chain::asset::from_string(s + " " CORE_SYM_NAME); - } -}; diff --git a/contracts/sysio.swap/token_functions.cpp b/contracts/sysio.swap/token_functions.cpp index 3c67b36621..b44ccc2a0c 100644 --- a/contracts/sysio.swap/token_functions.cpp +++ b/contracts/sysio.swap/token_functions.cpp @@ -1,8 +1,8 @@ -#include "evolutiondex.hpp" +#include -using namespace evolution; +namespace sysio { -void evolutiondex::transfer( const name& from, const name& to, const asset& quantity, +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 ); @@ -27,7 +27,7 @@ void evolutiondex::transfer( const name& from, const name& to, const asset& quan if (to == get_self()) ontransfer(from, to, quantity, memo); // line added to code from eosio.token } -void evolutiondex::sub_balance( const name& owner, const asset& value ) { +void swap::sub_balance( const name& owner, const asset& value ) { accounts from_acnts( get_self(), owner.value ); const auto& from = from_acnts.get( value.symbol.code().raw(), "no balance object found" ); @@ -38,7 +38,7 @@ void evolutiondex::sub_balance( const name& owner, const asset& value ) { }); } -void evolutiondex::add_balance( const name& owner, const asset& value, const name& ram_payer ) +void swap::add_balance( const name& owner, const asset& value, const name& ram_payer ) { accounts to_acnts( get_self(), owner.value ); auto to = to_acnts.find( value.symbol.code().raw() ); @@ -53,7 +53,7 @@ void evolutiondex::add_balance( const name& owner, const asset& value, const nam } } -void evolutiondex::open( const name& owner, const symbol& symbol, const name& ram_payer ) +void swap::open( const name& owner, const symbol& symbol, const name& ram_payer ) { require_auth( ram_payer ); @@ -73,7 +73,7 @@ void evolutiondex::open( const name& owner, const symbol& symbol, const name& ra } } -void evolutiondex::close( const name& owner, const symbol& symbol ) +void swap::close( const name& owner, const symbol& symbol ) { require_auth( owner ); accounts acnts( get_self(), owner.value ); @@ -82,3 +82,5 @@ void evolutiondex::close( const name& owner, const symbol& symbol ) check( it->balance.amount == 0, "Cannot close because the balance is not zero." ); acnts.erase( it ); } + +} // namespace sysio diff --git a/contracts/test_contracts/CMakeLists.txt b/contracts/test_contracts/CMakeLists.txt index be2afe42f4..f4e340a154 100644 --- a/contracts/test_contracts/CMakeLists.txt +++ b/contracts/test_contracts/CMakeLists.txt @@ -1,4 +1,6 @@ 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) +add_subdirectory(wevotethefee) \ No newline at end of file diff --git a/contracts/test_contracts/badtoken/CMakeLists.txt b/contracts/test_contracts/badtoken/CMakeLists.txt new file mode 100644 index 0000000000..39ef0a7f51 --- /dev/null +++ b/contracts/test_contracts/badtoken/CMakeLists.txt @@ -0,0 +1,3 @@ +if(BUILD_SYSTEM_CONTRACTS) + add_contract(badtoken badtoken badtoken.cpp) +endif() diff --git a/contracts/sysio.swap/tests/badtoken/badtoken.cpp b/contracts/test_contracts/badtoken/badtoken.cpp similarity index 92% rename from contracts/sysio.swap/tests/badtoken/badtoken.cpp rename to contracts/test_contracts/badtoken/badtoken.cpp index 6ec831a60f..536a23f7d2 100644 --- a/contracts/sysio.swap/tests/badtoken/badtoken.cpp +++ b/contracts/test_contracts/badtoken/badtoken.cpp @@ -3,7 +3,7 @@ void badtoken::transfer( const name& from, const name& to, const asset& quantity, const string& memo ) { - require_recipient( "evolutiondex"_n ); + require_recipient( "sysio.swap"_n ); } void badtoken::ontransfer ( const name& from, const name& to, const asset& quantity, const string& memo ) 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/wevotethefee/CMakeLists.txt b/contracts/test_contracts/wevotethefee/CMakeLists.txt new file mode 100644 index 0000000000..bb5536365d --- /dev/null +++ b/contracts/test_contracts/wevotethefee/CMakeLists.txt @@ -0,0 +1,3 @@ +if(BUILD_SYSTEM_CONTRACTS) + add_contract(wevotethefee wevotethefee wevotethefee.cpp) +endif() diff --git a/contracts/sysio.swap/wevotethefee/README.md b/contracts/test_contracts/wevotethefee/README.md similarity index 100% rename from contracts/sysio.swap/wevotethefee/README.md rename to contracts/test_contracts/wevotethefee/README.md diff --git a/contracts/sysio.swap/wevotethefee/wevotethefee.clauses.md b/contracts/test_contracts/wevotethefee/ricardian/wevotethefee.clauses.md similarity index 100% rename from contracts/sysio.swap/wevotethefee/wevotethefee.clauses.md rename to contracts/test_contracts/wevotethefee/ricardian/wevotethefee.clauses.md diff --git a/contracts/sysio.swap/wevotethefee/wevotethefee.contracts.md b/contracts/test_contracts/wevotethefee/ricardian/wevotethefee.contracts.md similarity index 100% rename from contracts/sysio.swap/wevotethefee/wevotethefee.contracts.md rename to contracts/test_contracts/wevotethefee/ricardian/wevotethefee.contracts.md diff --git a/contracts/sysio.swap/wevotethefee/wevotethefee.cpp b/contracts/test_contracts/wevotethefee/wevotethefee.cpp similarity index 98% rename from contracts/sysio.swap/wevotethefee/wevotethefee.cpp rename to contracts/test_contracts/wevotethefee/wevotethefee.cpp index 2b5887c27a..1e872ca4cc 100644 --- a/contracts/sysio.swap/wevotethefee/wevotethefee.cpp +++ b/contracts/test_contracts/wevotethefee/wevotethefee.cpp @@ -18,7 +18,7 @@ int wevotethefee::median(symbol_code pair_token){ void wevotethefee::updatefee(symbol_code pair_token) { int new_fee = median(pair_token); action(permission_level{ get_self(), "active"_n }, - "evolutiondex"_n, "changefee"_n, + "sysio.swap"_n, "changefee"_n, make_tuple( pair_token, new_fee )).send(); } @@ -36,7 +36,7 @@ void wevotethefee::ontransfer(const name& from, const name& to, const asset& qua }; asset wevotethefee::bring_balance(name user, symbol_code pair_token) { - accounts table( "evolutiondex"_n, user.value ); + accounts table( "sysio.swap"_n, user.value ); const auto& user_balance = table.find( pair_token.raw() ); check ( user_balance != table.end(), "pair_token balance does not exist" ); return user_balance->balance; diff --git a/contracts/sysio.swap/wevotethefee/wevotethefee.hpp b/contracts/test_contracts/wevotethefee/wevotethefee.hpp similarity index 59% rename from contracts/sysio.swap/wevotethefee/wevotethefee.hpp rename to contracts/test_contracts/wevotethefee/wevotethefee.hpp index b33fb6d72d..2f39b839ca 100644 --- a/contracts/sysio.swap/wevotethefee/wevotethefee.hpp +++ b/contracts/test_contracts/wevotethefee/wevotethefee.hpp @@ -1,29 +1,29 @@ #pragma once -#include -#include -#include -#include +#include +#include +#include +#include #include #include -using namespace eosio; +using namespace sysio; using namespace std; -class [[eosio::contract("wevotethefee")]] wevotethefee : public contract { +class [[sysio::contract("wevotethefee")]] wevotethefee : public contract { public: using contract::contract; - [[eosio::action]] void votefee(name user, symbol_code pair_token, int fee_voted); - [[eosio::action]] void openfeetable(name user, symbol_code pair_token); - [[eosio::action]] void closevote(name user, symbol_code pair_token); - [[eosio::action]] void closefeetable(symbol_code pair_token); - [[eosio::action]] void updatefee(symbol_code pair_token); - [[eosio::on_notify("evolutiondex::addliquidity")]] void onaddliquidity(name user, asset to_buy, + [[sysio::action]] void votefee(name user, symbol_code pair_token, int fee_voted); + [[sysio::action]] void openfeetable(name user, symbol_code pair_token); + [[sysio::action]] void closevote(name user, symbol_code pair_token); + [[sysio::action]] void closefeetable(symbol_code pair_token); + [[sysio::action]] void updatefee(symbol_code pair_token); + [[sysio::on_notify("sysio.swap::addliquidity")]] void onaddliquidity(name user, asset to_buy, asset max_asset1, asset max_asset2); - [[eosio::on_notify("evolutiondex::remliquidity")]] void onremliquidity(name user, asset to_sell, + [[sysio::on_notify("sysio.swap::remliquidity")]] void onremliquidity(name user, asset to_sell, asset min_asset1, asset min_asset2); - [[eosio::on_notify("evolutiondex::transfer")]] void ontransfer(const name& from, const name& to, + [[sysio::on_notify("sysio.swap::transfer")]] void ontransfer(const name& from, const name& to, const asset& quantity, const string& memo ); private: @@ -43,24 +43,24 @@ class [[eosio::contract("wevotethefee")]] wevotethefee : public contract { void add_balance(name user, asset to_add, bool need_update); asset bring_balance(name user, symbol_code pair_token); - struct [[eosio::table]] feeaccount { + struct [[sysio::table]] feeaccount { symbol_code pair_token; int fee_index_voted; uint64_t primary_key()const { return pair_token.raw(); } }; - struct [[eosio::table]] feetable { + struct [[sysio::table]] feetable { symbol_code pair_token; vector votes; uint64_t primary_key()const { return pair_token.raw(); } }; - typedef eosio::multi_index<"feeaccount"_n, feeaccount> feeaccounts; - typedef eosio::multi_index<"feetable"_n, feetable> feetables; + typedef sysio::multi_index<"feeaccount"_n, feeaccount> feeaccounts; + typedef sysio::multi_index<"feetable"_n, feetable> feetables; - struct [[eosio::table]] account { + struct [[sysio::table]] account { asset balance; uint64_t primary_key()const { return balance.symbol.code().raw(); } }; - typedef eosio::multi_index< "accounts"_n, account > accounts; + typedef sysio::multi_index< "accounts"_n, account > accounts; }; diff --git a/contracts/tests/contracts.hpp.in b/contracts/tests/contracts.hpp.in index beecf826e1..4d4470d165 100644 --- a/contracts/tests/contracts.hpp.in +++ b/contracts/tests/contracts.hpp.in @@ -38,8 +38,14 @@ 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"); } 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 wevotethefee_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/test_contracts/wevotethefee/wevotethefee.wasm"); } + static std::vector wevotethefee_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/test_contracts/wevotethefee/wevotethefee.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/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp new file mode 100644 index 0000000000..ce08cc401a --- /dev/null +++ b/contracts/tests/sysio.swap_tests.cpp @@ -0,0 +1,1121 @@ +#include +#include + + +#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; + +class sysio_swap_tester : public tester { +public: + + sysio_swap_tester() { + produce_blocks( 2 ); + + create_accounts( { "alice"_n, "bob"_n, "carol"_n, "sysio.token"_n, "sysio.swap"_n, + "wevotethefee"_n, "badtoken"_n, "anothertoken"_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() ); + + set_code( "wevotethefee"_n, contracts::util::wevotethefee_wasm() ); + set_abi( "wevotethefee"_n, contracts::util::wevotethefee_abi().data() ); + + produce_blocks(); + + 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)); + } + + 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", "" ) + ); + } + action_result inittoken( name user, symbol new_symbol, extended_asset initial_pool1, + extended_asset initial_pool2, int initial_fee, name fee_contract){ + // inittoken bills the new stat row to `user`, so the action must carry the + // user's sysio.payer permission in addition to the two active authorities. + std::vector auths{ {user, config::sysio_payer_name}, + {user, config::active_name}, + {"sysio.swap"_n, config::active_name} }; + try { + sysio::testing::base_tester::push_action( "sysio.swap"_n, "inittoken"_n, auths, mvo() + ( "user", user) + ("new_symbol", new_symbol) + ("initial_pool1", initial_pool1) + ("initial_pool2", initial_pool2) + ("initial_fee", initial_fee) + ("fee_contract", fee_contract) + , 100); + } catch (const fc::exception& ex) { + edump((ex.to_detail_string())); + return error(ex.top_message()); + } + return success(); + } + 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 ) + ); + } + action_result changefee( symbol_code pair_token, int newfee ) { + return push_action( "sysio.swap"_n, "wevotethefee"_n, "changefee"_n, mvo() + ( "pair_token", pair_token ) + ( "newfee", newfee ) + ); + } + + action_result openfeetable( name user, symbol_code pair_token ) { + return push_action( "wevotethefee"_n, user, "openfeetable"_n, mvo() + ( "user", user ) + ( "pair_token", pair_token ) + ); + } + action_result closefeetable( symbol_code pair_token ) { + return push_action( "wevotethefee"_n, "wevotethefee"_n, "closefeetable"_n, mvo() + ( "pair_token", pair_token ) + ); + } + action_result votefee( name user, symbol_code pair_token, int fee_voted ) { + return push_action( "wevotethefee"_n, user, "votefee"_n, mvo() + ( "user", user ) + ( "pair_token", pair_token ) + ( "fee_voted", fee_voted ) + ); + } + action_result closevote( name user, symbol_code pair_token ) { + return push_action( "wevotethefee"_n, user, "closevote"_n, mvo() + ( "user", user ) + ( "pair_token", pair_token ) + ); + } + action_result updatefee( name user, symbol_code pair_token ) { + return push_action( "wevotethefee"_n, user, "updatefee"_n, mvo() + ( "pair_token", pair_token ) + ); + } + + int64_t balance(name user, int64_t id) { + auto _balance = get_balance("sysio.swap"_n, user, "evodexacnts"_n, id, "evodexaccount" ); + return to_int(fc::json::to_string(_balance["balance"]["quantity"], + fc::time_point(fc::time_point::now() + abi_serializer_max_time) )); + } + 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; + } + vector total(){ + auto EVO_value = symbol::from_string("4,EVO").to_symbol_code().value; + auto ETUSD_value = symbol::from_string("3,ETUSD").to_symbol_code().value; + int64_t total_eos = balance("alice"_n, 0) + balance("bob"_n, 0) + + system_balance(EVO_value).at(0) + system_balance(ETUSD_value).at(0); + int64_t total_voice = balance("alice"_n, 1) + balance("bob"_n, 1) + + system_balance(EVO_value).at(1); + int64_t total_tusd = balance("alice"_n, 2) + balance("bob"_n, 2) + + system_balance(ETUSD_value).at(1); + vector ans = {total_eos, total_voice, total_tusd}; + return ans; + } + void create_tokens_and_issue() { + BOOST_REQUIRE_EQUAL( success(), create( "sysio.token"_n, "alice"_n, asset::from_string("461168601842738.7903 EOS") ) ); + 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( "sysio.token"_n, "alice"_n, "alice"_n, asset::from_string("461168601842738.7903 EOS"), "") ); + 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, extended_symbol{symbol::from_string("4,EOS"), "sysio.token"_n}) ); + 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, extended_symbol{symbol::from_string("4,EOS"), "sysio.token"_n}) ); + 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}) ); + } + void many_transfer() { + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("461000000000000.0000 EOS"), "") ); + BOOST_REQUIRE_EQUAL( success(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_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(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_n, asset::from_string("0.0902 VOICE"), "this goes to Bob") ); + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("45000000000000000.00 TUSD"), "") ); + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("300000000000000.00 TUSD"), "deposit to: bob") ); + } + 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; +}; + +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(); + +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}; + } +} + +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"), ""); + transfer( "anothertoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("200000000.0000 VOICE"), ""); + + inittoken( "alice"_n, EVO4, + extend(asset::from_string("1000000.0000 EOS")), + extend(asset::from_string("100000000.0000 VOICE")), 10, "wevotethefee"_n); + + 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 EOS") ) + ( "max_asset2", asset::from_string("1 VOICE")) ) + ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("to_buy amount must be positive"), + addliquidity( "alice"_n, asset::from_string("-5.0000 EVO"), + asset::from_string("0.5000 EOS"), asset::from_string("5000.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), + addliquidity( "alice"_n, asset::from_string("2.0000 EVO"), + asset::from_string("-0.3000 EOS"), asset::from_string("30.0000 NOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), + addliquidity( "alice"_n, asset::from_string("2.0000 EVO"), + asset::from_string("0.3000 EOS"), asset::from_string("-30.0000 NOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + addliquidity( "alice"_n, asset::from_string("5.0000 EVO"), + asset::from_string("0.5000 EOS"), asset::from_string("5000.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + addliquidity( "alice"_n, asset::from_string("2.0000 EVO"), + asset::from_string("0.0000 EOS"), asset::from_string("20.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), + addliquidity( "alice"_n, asset::from_string("2.0000 EMMO"), + asset::from_string("0.0000 EOS"), asset::from_string("20.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("incorrect symbol"), + addliquidity( "alice"_n, asset::from_string("3.0000 EVO"), + asset::from_string("0.3000 ECOS"), asset::from_string("30.0001 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), + addliquidity( "alice"_n, asset::from_string("1000000000000.0000 EVO"), + asset::from_string("1000000000000.0000 EOS"), asset::from_string("20000000000000.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( success(), + addliquidity( "alice"_n, asset::from_string("50.0000 EVO"), + asset::from_string("5.0050 EOS"), asset::from_string("500.5000 VOICE") ) + ); + 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 EOS") ) + ( "min_asset2", asset::from_string("1 VOICE")) ) + ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("to_sell amount must be positive"), + remliquidity( "alice"_n, asset::from_string("-5.0000 EVO"), + asset::from_string("0.5000 EOS"), asset::from_string("5000.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), + remliquidity( "alice"_n, asset::from_string("3.0000 EVO"), + asset::from_string("-0.3000 EOS"), asset::from_string("30.0001 NOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), + remliquidity( "alice"_n, asset::from_string("3.0000 EVO"), + asset::from_string("0.3000 EOS"), asset::from_string("-30.0001 NOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + remliquidity( "alice"_n, asset::from_string("1.0000 EVO"), + asset::from_string("0.1001 EOS"), asset::from_string("10.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + remliquidity( "alice"_n, asset::from_string("3.0000 EVO"), + asset::from_string("0.3000 EOS"), asset::from_string("30.0001 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("incorrect symbol"), + remliquidity( "alice"_n, asset::from_string("3.0000 EVO"), + asset::from_string("0.3000 EOS"), asset::from_string("30.0001 NOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("overdrawn balance"), + remliquidity( "alice"_n, asset::from_string("1000000000000.0000 EVO"), + asset::from_string("0.0000 EOS"), asset::from_string("0.0000 VOICE"))); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("computation overflow"), + addliquidity( "alice"_n, asset::from_string("46116860184273.8791 EVO"), + asset::from_string("1.0000 EOS"), asset::from_string("1.0000 VOICE"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("computation underflow"), + remliquidity( "alice"_n, asset::from_string("46116860184273.8791 EVO"), + asset::from_string("1.0000 EOS"), asset::from_string("1.0000 VOICE"))); + + 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 EOS"), asset::from_string("0.0001 VOICE") ) + ); +/* BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), + addliquidity( "alice"_n, asset::from_string("2.0000 EVO"), + asset::from_string("0.0000 EOS"), asset::from_string("20.0000 VOICE")) + );*/ +} 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"), ""); + transfer( "anothertoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("200000000.0000 VOICE"), ""); + inittoken( "alice"_n, EVO4, + extend(asset::from_string("1000000.0000 EOS")), + extend(asset::from_string("100000000.0000 VOICE")), 10, "wevotethefee"_n); + addliquidity( "alice"_n, asset::from_string("50.0000 EVO"), + asset::from_string("5.0050 EOS"), asset::from_string("500.5000 VOICE") ); + remliquidity( "alice"_n, asset::from_string("17.1872 EVO"), + asset::from_string("0.0000 EOS"), asset::from_string("0.0000 VOICE") ); + +// 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")) ) + ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg( + "ext_asset_in must be nonzero and min_expected must have same sign or be zero"), + 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 nonzero and min_expected must have same sign or be zero"), + 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 nonzero and min_expected must have same sign or be zero"), + exchange( "alice"_n, EVO, extend(asset::from_string("0.0000 EOS")), + asset::from_string("-0.1000 VOICE")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("invalid parameters"), + exchange( "alice"_n, EVO, extend(asset::from_string("-1000004.0000 EOS")), + asset::from_string("-0.0001 VOICE")) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("invalid parameters"), + 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")); + + vector expected_system_balance = {10000073819, 999999185799, 100000328128}; + BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); + BOOST_REQUIRE_EQUAL(balance("alice"_n,0), 89999926181); + BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 1000000814201); + + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 50) ); + + addliquidity( "alice"_n, asset::from_string("50.0000 EVO"), + asset::from_string("10000000.0000 EOS"), asset::from_string("10000000.0000 VOICE") ); + + expected_system_balance = {10000123826, 1000004186279, 100000828128}; + BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); + BOOST_REQUIRE_EQUAL(balance("alice"_n,0), 89999876174); + BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 999995813721); + + BOOST_REQUIRE_EQUAL( success(), + exchange( "alice"_n, EVO, extend(asset::from_string("-4.0000 EOS")), + asset::from_string("-401.9984 VOICE")) ); + expected_system_balance = {10000083826, 1000008206263, 100000828128}; + BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); + BOOST_REQUIRE_EQUAL(balance("alice"_n,0), 89999916174); + BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 999991793737); + +} 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("23058430092.1369 EOS")), + extend(asset::from_string("96116860184.2738 VOICE")), 10, "wevotethefee"_n); + + inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("10000000000.0000 EOS")), + extend(asset::from_string("9911686018427.38 TUSD")), 10, "wevotethefee"_n); + + auto old_total = total(); + auto old_vec = system_balance(EVO.value); + auto old_alice_bal_0 = balance("alice"_n,0); + auto old_alice_bal_1 = balance("alice"_n,1); + + 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,0) - old_alice_bal_0, -40000); + BOOST_REQUIRE_EQUAL(balance("alice"_n,1) - old_alice_bal_1, 166569); + + old_total = total(); + old_vec = system_balance(EVO.value); + old_alice_bal_0 = balance("alice"_n, 0); + old_alice_bal_1 = balance("alice"_n, 1); + addliquidity( "alice"_n, asset::from_string("0.0001 EVO"), + asset::from_string("10000000.0000 EOS"), asset::from_string("10000000.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,0) - old_alice_bal_0, -2); + BOOST_REQUIRE_EQUAL(balance("alice"_n,1) - old_alice_bal_1, -4); + + produce_blocks(); + + old_total = total(); + old_vec = system_balance(EVO.value); + old_alice_bal_0 = balance("alice"_n, 0); + old_alice_bal_1 = balance("alice"_n, 1); + remliquidity( "alice"_n, asset::from_string("0.0001 EVO"), + asset::from_string("0.0000 EOS"), asset::from_string("0.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, 0) - old_alice_bal_0, 0); + BOOST_REQUIRE_EQUAL(balance("alice"_n, 1) - 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 EOS"), asset::from_string("400000000000000.0000 VOICE") ); + 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); + + old_total = total(); + old_vec = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( success(), 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(is_increasing(old_vec, system_balance(EVO.value)), true); + + old_total = total(); + old_vec = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( success(), 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(is_increasing(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("23058430092.1369 EOS")), + extend(asset::from_string("96116860184.2738 VOICE")), 10, "wevotethefee"_n) ); + + 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.6570 VOICE") ); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), + 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("extended_symbol mismatch"), + 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.6569 VOICE") ); + BOOST_REQUIRE_EQUAL( pre_eos_balance - 40000, token_balance("sysio.token"_n, "alice"_n, EOS.value) ); + BOOST_REQUIRE_EQUAL( pre_voice_balance + 166569, token_balance("anothertoken"_n, "alice"_n, VOICE.value) ); + + inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("10000000000.0000 EOS")), + extend(asset::from_string("9911686018427.38 TUSD")), 10, "wevotethefee"_n); + + 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.1605 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.1604 EOS") ); + + BOOST_REQUIRE_EQUAL( pre_tusd_balance - 40000000, + token_balance("sysio.token"_n, "alice"_n, TUSD.value) ); + BOOST_REQUIRE_EQUAL( pre_eos_balance + 4031604, + 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"), "") ); + BOOST_REQUIRE_EQUAL( success(), transfer( "anothertoken"_n, "alice"_n, "sysio.swap"_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 EOS"))) + ("initial_pool2", extend(asset::from_string("1.0000 ECO"))) + ("initial_fee", 1) ("fee_contract", "carol"_n) ) + ); + 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 EOS"))) + ("initial_pool2", extend(asset::from_string("1.0000 ECO"))) + ("initial_fee", 1) ("fee_contract", "carol"_n) ) + ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Both assets must be positive"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("-0.0001 EOS")), + extend(asset::from_string("0.1000 VOICE")), 10, "wevotethefee"_n) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Both assets must be positive"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("-0.1000 VOICE")), 10, "wevotethefee"_n) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Initial amounts must be less than 10^15"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("100000000000.0001 VOICE")), 10, "wevotethefee"_n) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("Initial amounts must be less than 10^15"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("100000000000.0001 EOS")), + extend(asset::from_string("1.0001 VOICE")), 10, "wevotethefee"_n) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended symbols must be different"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("0.1000 EOS")), 10, "wevotethefee"_n) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), + inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.0003 EOS")), + extend(asset::from_string("0.1000 VOICE")), 10, "wevotethefee"_n) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), + inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.0002 EOS")), + extend(asset::from_string("100000000.0000 VOICE")), 10, "wevotethefee"_n) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("new_symbol precision must be (precision1 + precision2) / 2"), + inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("0.100 VOICE")), 10, "wevotethefee"_n) ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("0.1000 VOICE")), 10, "wevotethefee"_n) ); + produce_blocks(); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("token symbol already exists"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("0.1000 VOICE")), 10, "wevotethefee"_n) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("initial_fee must be 10"), inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("0.10 TUSD")), 501, "wevotethefee"_n) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee_contract must be wevotethefee"), + inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("0.10 ETUSD")), 10, "alice"_n) ); +// The assert "the pool is already indexed" is tested in "indextable" test case. + + // TRANSFER + 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.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 wevotethefee"), + push_action( "sysio.swap"_n, "bob"_n, "changefee"_n, + mvo() ("pair_token", EVO) ("newfee", 50) ) + ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), changefee(EOS, 500)); + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 50) ); + + // Notifications to fee_contract + + set_code( "wevotethefee"_n, contracts::util::badtoken_wasm() ); + set_abi( "wevotethefee"_n, contracts::util::badtoken_abi().data() ); + many_openext(); + transfer( "sysio.token"_n, "bob"_n, "sysio.swap"_n, asset::from_string("0.0004 EOS"), ""); + transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("0.04 TUSD"), "deposit to: bob"); + + BOOST_REQUIRE_EQUAL( success(), inittoken( "bob"_n, ETUSD3, + extend(asset::from_string("0.0002 EOS")), + extend(asset::from_string("0.02 TUSD")), 10, "wevotethefee"_n ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("notification received"), + transfer( "sysio.swap"_n, "bob"_n, "alice"_n, asset::from_string("0.001 ETUSD"), "") ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("notification received"), + addliquidity( "bob"_n, asset::from_string("0.001 ETUSD"), + asset::from_string("0.0002 EOS"), asset::from_string("0.02 TUSD"))); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("notification received"), + remliquidity( "bob"_n, asset::from_string("0.001 ETUSD"), + asset::from_string("0.0001 EOS"), asset::from_string("0.01 TUSD"))); + +} FC_LOG_AND_RETHROW() + + +BOOST_FIXTURE_TEST_CASE( we_vote_the_fee, 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"), ""); + transfer( "anothertoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("200000000.0000 VOICE"), ""); + + inittoken( "alice"_n, EVO4, + extend(asset::from_string("1000000.0000 EOS")), + extend(asset::from_string("100000000.0000 VOICE")), 10, "wevotethefee"_n); + + const auto* accnt3 = control->find_account_metadata( "wevotethefee"_n ); + abi_def abi_wevote; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt3->abi, abi_wevote), true); + +// Start wevotethefee actions. Testing checks and basic functions. + abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); + auto ISNT = symbol::from_string("4,ISNT").to_symbol_code(); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("pair_token balance does not exist"), + votefee("alice"_n, ISNT, 10) ); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("pair_token balance does not exist"), + votefee("bob"_n, EVO, 30) ); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("fee table nonexistent, run openfeetable"), votefee("alice"_n, EVO, 30)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("fee table nonexistent, run openfeetable"), updatefee("alice"_n, EVO)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("table does not exist"), closefeetable(EVO)); + BOOST_REQUIRE_EQUAL(success(), openfeetable("alice"_n, EVO)); + BOOST_REQUIRE_EQUAL(success(), closefeetable(EVO)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("fee table nonexistent, run openfeetable"), votefee("alice"_n, EVO, 30)); + BOOST_REQUIRE_EQUAL(success(), openfeetable("alice"_n, EVO)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("already opened"), openfeetable("alice"_n, EVO)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("user is not voting"), closevote("alice"_n, EVO)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("only values between 10 and 100 are allowed"), votefee("alice"_n, EVO, 101)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("only values between 10 and 100 are allowed"), votefee("alice"_n, EVO, -10)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("only values between 10 and 100 are allowed"), votefee("alice"_n, EVO, 9)); + BOOST_REQUIRE_EQUAL(success(), votefee("alice"_n, EVO, 30)); + BOOST_REQUIRE_EQUAL(success(), updatefee("alice"_n, EVO)); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("table of votes is not empty"), closefeetable(EVO)); + + auto evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, + "feetable" )["votes"].get_array(); + BOOST_REQUIRE_EQUAL(100000000000, evo_votes[8]); + + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + auto evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(30, evo_stats["fee"]); + +// Leave vote table with zero votes, fee value remains unchanged + transfer( "sysio.swap"_n, "alice"_n, "bob"_n, asset::from_string("10000000.0000 EVO"), ""); + evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(30, evo_stats["fee"]); + abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); + evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, + "feetable" )["votes"].get_array(); + BOOST_REQUIRE_EQUAL(0, evo_votes[5]); + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + transfer( "sysio.swap"_n, "bob"_n, "alice"_n, asset::from_string("10000000.0000 EVO"), ""); + +// Test authorizations + abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); + BOOST_REQUIRE_EQUAL( error("missing authority of bob"), + push_action( "wevotethefee"_n, "alice"_n, "votefee"_n, + mvo()( "user", "bob"_n )( "pair_token", EVO )( "fee_voted", "10" ) ) + ); + BOOST_REQUIRE_EQUAL( error("missing authority of bob"), + push_action( "wevotethefee"_n, "alice"_n, "closevote"_n, + mvo()( "user", "bob"_n )( "pair_token", EVO ) ) + ); + +// Vote balance change after transfer + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + BOOST_REQUIRE_EQUAL(success(), + transfer( "sysio.swap"_n, "alice"_n, "bob"_n, asset::from_string("100.0000 EVO"), "")); + + abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); + BOOST_REQUIRE_EQUAL(success(), votefee("bob"_n, EVO, 100)); + evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, + "feetable" )["votes"].get_array(); + BOOST_REQUIRE_EQUAL(99999000000, evo_votes[8]); + BOOST_REQUIRE_EQUAL(1000000, evo_votes[11]); + +// Scenarios with many votes + create_accounts( { "dan"_n, "eva"_n, "fran"_n}); + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + BOOST_REQUIRE_EQUAL(success(), transfer( "sysio.swap"_n, "alice"_n, "carol"_n, asset::from_string("2000.0000 EVO"), "")); + transfer( "sysio.swap"_n, "alice"_n, "dan"_n, asset::from_string("400000.0000 EVO"), ""); + transfer( "sysio.swap"_n, "alice"_n, "eva"_n, asset::from_string("4240000.0000 EVO"), ""); + transfer( "sysio.swap"_n, "alice"_n, "fran"_n, asset::from_string("2500000.0000 EVO"), ""); + + abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); + BOOST_REQUIRE_EQUAL(success(), votefee("carol"_n, EVO, 10)); + votefee("dan"_n, EVO, 10); + votefee("eva"_n, EVO, 74); + votefee("fran"_n, EVO, 73); + + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(75, evo_stats["fee"]); + + // Test updatefee when closing votes, then restore votes. + abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); + closevote( "alice"_n, EVO ); + closevote( "eva"_n, EVO ); + closevote( "fran"_n, EVO ); + evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, + "feetable" )["votes"].get_array(); + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(10, evo_stats["fee"]); + abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); + votefee("alice"_n, EVO, 30); + votefee("eva"_n, EVO, 75); + votefee("fran"_n, EVO, 75); + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(75, evo_stats["fee"]); + + // Test fee modification when adding and removing liquidity + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + openext( "eva"_n, "eva"_n, extended_symbol{symbol::from_string("4,EOS"), "sysio.token"_n}); + openext( "eva"_n, "eva"_n, extended_symbol{symbol::from_string("4,VOICE"), "anothertoken"_n}); + push_action( "sysio.swap"_n, "eva"_n, "remliquidity"_n, mvo() + ( "user", "eva"_n)( "to_sell", asset::from_string("4000000.0000 EVO")) + ( "min_asset1", asset::from_string("1.0000 EOS") ) + ( "min_asset2", asset::from_string("1.0000 VOICE")) ); + evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(30, evo_stats["fee"]); + + BOOST_REQUIRE_EQUAL(success(), push_action( "sysio.swap"_n, "eva"_n, "addliquidity"_n, mvo() + ( "user", "eva"_n)( "to_buy", asset::from_string("3900000.0000 EVO")) + ( "max_asset1", asset::from_string("100000000000.0000 EOS") ) + ( "max_asset2", asset::from_string("100000000000.0000 VOICE")) ) ); + evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(75, evo_stats["fee"]); + +// median 10, due to clamp between 10 and 100 + abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); + votefee("alice"_n, EVO, 10); + votefee("dan"_n, EVO, 10); + votefee("eva"_n, EVO, 10); + votefee("fran"_n, EVO, 10); + evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, + "feetable" )["votes"].get_array(); + BOOST_REQUIRE_EQUAL(98999000000, evo_votes[5]); + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(10, evo_stats["fee"]); + +// check votes after transfer, remliquidity and addliquidity + BOOST_REQUIRE_EQUAL(success(), + transfer( "sysio.swap"_n, "alice"_n, "bob"_n, asset::from_string("100.0000 EVO"), "")); + abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); + evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, + "feetable" )["votes"].get_array(); + BOOST_REQUIRE_EQUAL(98998000000, evo_votes[5]); + BOOST_REQUIRE_EQUAL(2000000, evo_votes[11]); + + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + push_action( "sysio.swap"_n, "eva"_n, "remliquidity"_n, mvo() + ( "user", "eva"_n)( "to_sell", asset::from_string("10000.0000 EVO")) + ( "min_asset1", asset::from_string("1.0000 EOS") ) + ( "min_asset2", asset::from_string("1.0000 VOICE")) ); + abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); + evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, + "feetable" )["votes"].get_array(); + BOOST_REQUIRE_EQUAL(98998000000 - 100000000, evo_votes[5]); + + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + push_action( "sysio.swap"_n, "eva"_n, "addliquidity"_n, mvo() + ( "user", "eva"_n)( "to_buy", asset::from_string("100.0000 EVO")) + ( "max_asset1", asset::from_string("10000000.0000 EOS") ) + ( "max_asset2", asset::from_string("10000000.0000 VOICE")) ); + abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); + evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, + "feetable" )["votes"].get_array(); + BOOST_REQUIRE_EQUAL(98998000000 - 100000000 + 1000000, evo_votes[5]); + +// median 100 + votefee("dan"_n, EVO, 100); + votefee("eva"_n, EVO, 100); + votefee("fran"_n, EVO, 100); + evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, + "feetable" )["votes"].get_array(); + abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); + evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); + BOOST_REQUIRE_EQUAL(100, evo_stats["fee"]); + +} 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"), ""); + transfer( "anothertoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("200000000.0000 VOICE"), ""); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg("token symbol does not exist"), + push_action( "sysio.swap"_n, "alice"_n, "indexpair"_n, + mvo() ( "user", "alice"_n ) ( "evo_symbol", EVO4 ) ) ); + + BOOST_REQUIRE_EQUAL(success(), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1000000.0000 EOS")), + extend(asset::from_string("100000000.0000 VOICE")), 10, "wevotethefee"_n) ); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg("the pool is already indexed"), + push_action( "sysio.swap"_n, "alice"_n, "indexpair"_n, + mvo() ( "user", "alice"_n ) ( "evo_symbol", EVO4 ) ) ); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg("the pool is already indexed"), + inittoken( "alice"_n, EOS4, + extend(asset::from_string("1.0000 EOS")), + extend(asset::from_string("1.0000 VOICE")), 10, "wevotethefee"_n) ); + 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, "wevotethefee"_n) ); + + auto table = get_balance("sysio.swap"_n, "sysio.swap"_n, "evoindex"_n, EVO.value, "index_struct"); + // make256key(anothertoken, 4,VOICE, sysio.token, 4,EOS) as little-endian + // words; upstream's constant encoded eosio.token in the third word. + BOOST_REQUIRE_EQUAL(table["id_256"], "34e996aaf9a4153000004543494f5604c7b0ea033482a60000000000534f4504"); + BOOST_REQUIRE_EQUAL(table["evo_symbol"], "4,EVO"); + + 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, "wevotethefee"_n) ); + 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.0000 VOICE")), + extend(asset::from_string("1.00000 EOS")), 10, "wevotethefee"_n) ); + 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.0000 VOICE")), + extended_asset{asset::from_string("1.0000 EOS"), "anothertoken"_n}, + 10, "wevotethefee"_n) ); +} FC_LOG_AND_RETHROW() + +BOOST_AUTO_TEST_SUITE_END() \ No newline at end of file From da66d4587b9afb7dea28336596b0fec9d423b4d8 Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Thu, 10 Sep 2026 16:38:19 -0400 Subject: [PATCH 03/37] swap: bound changefee, and add fee-bounds, rounding-table, and randomized-invariant tests changefee accepted any int. A negative fee makes compute() subtract the fee (the pool pays out more than constant product allows), and a fee at or above 100% can push compute()'s result past the int64 range it checks BEFORE the fee is added. It now requires 0 <= fee <= MAX_FEE (FEE_DENOMINATOR - 1 = 9999); the 10000/9999 literals in compute() are the named FEE_DENOMINATOR. Three test cases close the gaps the upstream suite left on the swap math: - changefee_bounds: -1, 10000, INT_MIN, INT_MAX are rejected with state unchanged; 0, 9999 and every wevotethefee fee-vector value are accepted; at 9999 a swap still settles at exactly the spec quote. - compute_rounding_table: at each of the 15 fee-vector values and 7 amounts (1 unit to 1,000,000 units), both swap directions and exact-output withdrawals are pinned to the unit against a reference written from the spec (pay rounds up, receive rounds down, fee rounds up): one unit past the quote is refused, the quote lands, pool and user deltas match. Unit- share add/remove rows cover the liquidity path the same way. - invariants_under_random_sequences: a fixed-seed sequence of 400 ops over both pools and both users (swaps both ways, exact-output, add, remove, fee changes; log-uniform sizes from dust to full balance) asserts after every op that tokens are conserved and pool value per share never decreases, that any failure is one of the contract's own guards, and minimum success counts per op kind so the run cannot pass vacuously. The three cases run in ~4 s combined. contracts_unit_test (full binary, --sys-vm): No errors detected. Co-Authored-By: Claude Fable 5.1 Change-Id: Ibb536d371e26f67b4a6377338b51f451d489436a --- .../include/sysio.swap/sysio.swap.hpp | 6 + contracts/sysio.swap/sysio.swap.cpp | 5 +- contracts/sysio.swap/sysio.swap.wasm | Bin 50884 -> 50921 bytes contracts/tests/sysio.swap_tests.cpp | 281 +++++++++++++++++- 4 files changed, 288 insertions(+), 4 deletions(-) diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index 7b9ed1615b..c4a2eee370 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -17,6 +17,12 @@ namespace sysio { const int64_t INIT_MAX = 1000000000000000; // 10^15 const int ADD_LIQUIDITY_FEE = 1; const int DEFAULT_FEE = 10; + /// Fees are expressed in units of 1/FEE_DENOMINATOR of the traded amount. + static constexpr int FEE_DENOMINATOR = 10000; + /// Upper bound accepted by changefee. compute() checks its result against the + /// int64 range BEFORE adding the fee, so a fee at or above 100% could push the + /// final amount past that range; below 100% the sum stays within int64. + static constexpr int MAX_FEE = FEE_DENOMINATOR - 1; using contract::contract; [[sysio::action]] void inittoken(name user, symbol new_symbol, diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index 3ccafba63d..7bd8ab6cc2 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -86,11 +86,11 @@ int64_t swap::compute(int64_t x, int64_t y, int64_t z, int fee) { if (x > 0) { tmp = 1 + (prod - 1) / int128_t(z); check( (tmp <= MAX), "computation overflow" ); - tmp_fee = (tmp * fee + 9999) / 10000; + tmp_fee = (tmp * fee + (FEE_DENOMINATOR - 1)) / FEE_DENOMINATOR; } else { tmp = prod / int128_t(z); check( (tmp >= -MAX), "computation underflow" ); - tmp_fee = (-tmp * fee + 9999) / 10000; + tmp_fee = (-tmp * fee + (FEE_DENOMINATOR - 1)) / FEE_DENOMINATOR; } tmp += tmp_fee; return int64_t(tmp); @@ -259,6 +259,7 @@ void swap::changefee(symbol_code pair_token, int newfee) { const auto& token = statstable.find( pair_token.raw() ); check ( token != statstable.end(), "pair token does not exist" ); require_auth(token->fee_contract); + check( 0 <= newfee && newfee <= MAX_FEE, "fee out of range" ); statstable.modify( token, same_payer, [&]( auto& a ) { a.fee = newfee; } ); diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index b30dfcf167cfec56fdb19227c0aa6a9d95f3a6e4..711508e7990c9d8ed1e97759393de987b73467ca 100755 GIT binary patch delta 1477 zcmY*ZZERCj7(Va0w_CL=R8n-LquhH}Sm!oGGYk8Y9VnC`xUj*p4gm{oaSgP969qB` zZex%PGIJK)l)=UTha_Zhvhbln7ZAl59gtyNf=mK&NQ?{CgdZxtrwu>+^L{+5jCGS{w9v9JL%l+8jUdPRs+HYb<^$|Mi_C?jv7PJ zNSI9wvrU@~;zod$YLr>)H1cX1wqOil?C1Ve?=$VVN=q~`G8cU}Kc6}Sj*8LLZ&RU9 zoSeAT0%y2kcf)P&v+sh7e8hePhIoC39Ukz`j4ZT+8JURO%c!C)!@OkbIZVbJ8GMq% zhQ%z0k2YQ7?>RDIfFE}-gvK1RU`R}vwiTd{w`or36$6?D;3gl`JD`Wx7%SPv4}%1rf_Grf^M6&ZQLordahPIQ<67WR?&H~%?sH4hOhXi6TjCRW_}5mSq%xTwb<*$UB3 zw3K1c$4lHAZI1CZZa@6P2i;TQoVe}I1n3dUoWlT@cyGaSw2um2LYq^Vi?+IO8QM<^ zUqCxjn1eQ@NXG6WFWQ|&EfOi-j&^tPAl%{eOQuh{BnQw;E?TTBmL&7`5~pPDE-6ON zTO}dM>6vRoPT?yZRB*Z7<3L;Jk(-A-a`Q8uIcOhv)}bw5P=Kxb7Oas|-d`vud)AvP z`Mgi~Qd~5klP7%MB%jxdd}Ch8$IHuB;~uBVCM)>SFE7b~LwtOR4yVPWr4LQ0qN!4< z_sM-ZF|cX184=sI-w_dX$32Q}tR8PkyC`p{l&*Kz35qH%ZTtzY zimWCqT@?qKgB0&=yk(V&rHah-l9k#otu*(fI&J_`ze>F^L|plF5}%F5$h49(RgU zed!2@uTP9ickX1Jid}jAIO`Fy`}DYi*_MG!6)uVo&Zn4Np$Hv0Ky7FnFTXs4-~Q?s JbyglB{{h4|-RuAW delta 1518 zcmYjRZERCj7(Va0w`;LmsB?U|QSQAfu5$yF%mP~%c1E$H=)yM0I=&pNC2L>EsIp=xL zdGEPH9m?4bESLRf@1szvmVA~W=Foq!Nd7at&@D^O9B}I(P#xcxKnjeFm;->j)Gjxi> zqt{zuFE_GX@D2B6w?ZF}XYYg4d{a&~#QCnAJj7^DE;6@r7SX@I<)sq`a58Am;hFX< zOy=2r^v?_Y4SOzh^8QHy&{(Hrf^1#DWnvBnwc=S7OT#Cc)^O_@S$$rTr`;?$}sPU^AmhaG5?BhiOgT|Eiz+8g>{u^&{B5- zV1Pfh@;kWBv+B1lNnHoFBOS}run{lX6b3qxU7kWL|Mx68WyFgzT83FNH-pGFrWnRVPVUm35P z6!d3b(jY!G&EzITGoe~H;d^We8VKGDyv@-%c~A#Z2T}+3b!L9VUtO-Un*<+ZyMo5( z-zG=W& z-rC?pj5kyxH7$s5G{hr~FX7~orkye$Z_ve8YnNG&ac=k>88P;?TNH=8oAA>LiIpu? za_DdErP%tq&@~tkC0j5xAogwbQ~cO1ZA(>51=^oQ?pXT_nHuWYKMGTCzc&e2iH~=E zB89;ZKsv78y#n$4?i+B1_jTRFrG0x&qiRhg3+TOTBD8mq;=gTm_Z)y7{OJBm_p#Fxozl7b L#Sf}Bg^>RMKb+V( diff --git a/contracts/tests/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index ce08cc401a..1538eff749 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -8,6 +8,7 @@ #include #include +#include using namespace sysio::testing; using namespace sysio; @@ -288,6 +289,27 @@ class sysio_swap_tester : public tester { BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("45000000000000000.00 TUSD"), "") ); BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_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(); 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"), ""); @@ -311,12 +333,60 @@ static symbol_code TUSD = TUSD2.to_symbol_code(); extended_asset extend(asset to_extend) { if (to_extend.symbol_name() == "VOICE") { - return extended_asset{to_extend, "anothertoken"_n}; + return extended_asset{to_extend, "anothertoken"_n}; } else { return extended_asset{to_extend, "sysio.token"_n}; } } +// Reference quotes for the rounding tests, written from the SPEC rather than +// copied from swap::compute: every rounding goes the pool's way. What a user +// pays is rounded up, what a user receives is rounded down, and the fee on +// either is rounded up (so it is never zero on a non-zero amount). +namespace reference { + using wide = boost::multiprecision::int128_t; + constexpr int64_t FeeDenominator = 10000; + + 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 fee_on( int64_t amount, int fee ) { return ceil_div( wide(amount) * fee, FeeDenominator ); } + + // 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 - fee_on( gross, fee ); + } + // Units of `pool_pay` charged to withdraw exactly `amount_out` units of `pool_out`. + int64_t pay( int64_t amount_out, int64_t pool_out, int64_t pool_pay, int fee ) { + const int64_t gross = ceil_div( wide(amount_out) * pool_pay, wide(pool_out) - amount_out ); + return gross + 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 + 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 ); + } +} + +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("23058430092.1369 EOS")), + extend(asset::from_string("96116860184.2738 VOICE")), 10, "wevotethefee"_n) ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("10000000000.0000 EOS")), + extend(asset::from_string("9911686018427.38 TUSD")), 10, "wevotethefee"_n) ); +} + BOOST_AUTO_TEST_SUITE(sysio_swap_tests) BOOST_FIXTURE_TEST_CASE( add_rem_liquidity, sysio_swap_tester ) try { @@ -1118,4 +1188,211 @@ BOOST_FIXTURE_TEST_CASE( indextable, sysio_swap_tester ) try { 10, "wevotethefee"_n) ); } FC_LOG_AND_RETHROW() -BOOST_AUTO_TEST_SUITE_END() \ No newline at end of file +// --------------------------------------------------------------------------- +// Coverage added with the WIRE port: fee bounds, an exact rounding table, and +// the pool invariants under a long randomized operation sequence. +// --------------------------------------------------------------------------- + +// The fee vector wevotethefee can select from; the swap contract itself must +// accept the whole range and reject anything outside [0, MAX_FEE]. +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[0], before[1], 9999); + 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[1] - expected, after[1] ); +} 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 and exact-output withdrawals, at every fee. + 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[0], before[1], fee); + int64_t alice_eos = balance("alice"_n, 0), alice_voice = balance("alice"_n, 1); + 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[0] + amount, after[0] ); + BOOST_REQUIRE_EQUAL( before[1] - out, after[1] ); + BOOST_REQUIRE_EQUAL( alice_eos - amount, balance("alice"_n, 0) ); + BOOST_REQUIRE_EQUAL( alice_voice + out, balance("alice"_n, 1) ); + + // VOICE -> EOS + before = after; + out = reference::receive(amount, before[1], before[0], 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[1] + amount, after[1] ); + BOOST_REQUIRE_EQUAL( before[0] - out, after[0] ); + + // exact-output: withdraw exactly `amount` EOS, paying the spec quote in VOICE + before = after; + int64_t cost = reference::pay(amount, before[0], before[1], fee); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + exchange( "alice"_n, EVO, extend(asset(-amount, EOS4)), asset(-(cost - 1), VOICE4) ) ); + BOOST_REQUIRE_EQUAL( success(), + exchange( "alice"_n, EVO, extend(asset(-amount, EOS4)), asset(-cost, VOICE4) ) ); + after = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( before[0] - amount, after[0] ); + BOOST_REQUIRE_EQUAL( before[1] + cost, 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, EOS4), asset(pay2, VOICE4) ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), + addliquidity( "alice"_n, asset(shares, EVO4), asset(pay1, EOS4), asset(pay2 - 1, VOICE4) ) ); + BOOST_REQUIRE_EQUAL( success(), + addliquidity( "alice"_n, asset(shares, EVO4), asset(pay1, EOS4), asset(pay2, VOICE4) ) ); + 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, EOS4), asset(get2, VOICE4) ) ); + BOOST_REQUIRE_EQUAL( success(), + remliquidity( "alice"_n, asset(shares, EVO4), asset(get1, EOS4), asset(get2, VOICE4) ) ); + 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 + int leg1_id, leg2_id; // evodexacnts row ids (openext order: EOS=0, VOICE=1, TUSD=2) + }; + const std::vector pools{ + { EVO, EVO4, EOS4, VOICE4, 0, 1 }, + { ETUSD, ETUSD3, EOS4, TUSD2, 0, 2 }, + }; + 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_exact_output, 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_id); + const int64_t user_leg2 = balance(user, pool.leg2_id); + 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_exact_output) { + // withdraw exactly `w` of leg1, paying at most the user's whole leg2 balance + const int64_t w = draw(old_vec[0] / 2); + r = exchange( user, pool.code, extend(asset(-w, pool.leg1)), asset(-user_leg2, pool.leg2) ); + } 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 { + 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_exact_output], 15 ); + 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() + +BOOST_AUTO_TEST_SUITE_END() \ No newline at end of file From 0bb6899cdfb15e4d36f03c801d7b138a50f01c69 Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Thu, 10 Sep 2026 22:24:21 -0400 Subject: [PATCH 04/37] swap: retire the exact-output (negative-amount) exchange mode exchange now accepts exact-input swaps only: ext_asset_in must be positive and min_expected nonnegative. The implied-argument mode that let a negative ext_asset_in request an exact output amount is removed along with its documentation in Commands.md and the ricardian clause. Tests: the former exact-output success paths now assert rejection with unchanged state, the rounding table drops its exact-output block, and the randomized-invariant test replaces the exact-output op with a negative-input rejection op (>=40 rejections per run). Full contracts_unit_test green before commit. Co-Authored-By: Claude Fable 5.1 Change-Id: I4e46020619d0bc55d2e262dff755531e4fd92550 --- contracts/sysio.swap/Commands.md | 7 +- .../ricardian/sysio.swap.contracts.md | 4 +- contracts/sysio.swap/sysio.swap.cpp | 5 +- contracts/sysio.swap/sysio.swap.wasm | Bin 50921 -> 50890 bytes contracts/tests/sysio.swap_tests.cpp | 83 ++++++++---------- 5 files changed, 41 insertions(+), 58 deletions(-) diff --git a/contracts/sysio.swap/Commands.md b/contracts/sysio.swap/Commands.md index b385c22d43..136ebbd87e 100644 --- a/contracts/sysio.swap/Commands.md +++ b/contracts/sysio.swap/Commands.md @@ -59,11 +59,8 @@ of the input is account, evotoken, extended_asset to pay (exact), asset to recei cleos push action evolutiondex exchange '["YOUR_ACCOUNT", "EOSPESO", {"contract":"eosio.token", "quantity":"1.0000 EOS"}, "0.1000 PESO"]' -p YOUR_ACCOUNT -It is also possible to set the exact amount to obtain and limit the amount to pay. -To do this, use negative amounts. The following example means that you want to receive exactly 0.1000 PESO and pay at most 1.0000 EOS. - - cleos push action evolutiondex exchange '["YOUR_ACCOUNT", "EOSPESO", - {"contract":"eosio.token", "quantity":"-0.1000 PESO"}, "-1.0000 EOS"]' -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). Transfer your evotokens to another account: diff --git a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md index 9a23c0ba12..b896739b62 100644 --- a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md +++ b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md @@ -138,9 +138,9 @@ 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. 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 (possibly negative) extended assets will be respectively added to and substracted from the corresponding pools. +{{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 (might be negative) to be added to {{users}}'s extended balance as a result of the exchange operation, is computed as x + y, where +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. diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index 7bd8ab6cc2..fd0312ffec 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -131,9 +131,8 @@ void swap::add_signed_liq(name user, asset to_add, bool is_buying, void swap::exchange( name user, symbol_code pair_token, extended_asset ext_asset_in, asset min_expected) { require_auth(user); - check( ((ext_asset_in.quantity.amount > 0) && (min_expected.amount >= 0)) || - ((ext_asset_in.quantity.amount < 0) && (min_expected.amount <= 0)), - "ext_asset_in must be nonzero and min_expected must have same sign or be zero"); + 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); diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 711508e7990c9d8ed1e97759393de987b73467ca..2b4d68321253daa43da3f5ab3d880a77e8bd8fe5 100755 GIT binary patch delta 1647 zcmaJ>X-rgC6n^Kv83r7UBUO}uscCDCtv0dCxdZlRf4uLWd$#Z1bI!ZH zTjb6ya*JCB%~+~SWlwu~lWrZ$sLxj<)l_8#ua((ZeZDHGl8x2nTV+M^s)N01#WJg8 zCk4w?m};t2q&EhU3(4Rme{>ogLpD>C5_D3f3|i~)gyD7A!S5M54dCa82Ui)P39V6f z=*GOL8fd|usJ(C+*G5OdZQL0>mM{=KnlulhbJ?HQFn#2gw0UHX#&GjcGLALpu|K+S zy?Hbo!2M>GRFBM);53gMRR!S3tx61R=WU7+;9Cq>b^v0&wSc|2&W~GD8Ljv%W|`QQ zj;YYW5q>K+jlmf{ES@oVfFmXpMEUcrx@l6*Ajzb+=!|q%SkH!g9%63@NF09 zVl_EvGeClkh{#1k$e zJck=nVn{xeGLf(+C5|v8Rmk>K2jRNZO+u5lhVb3A09?cA>0`p%#4!}Tjip&+V;}#T z^ccbKPfsJ}+v#q>oRu+@n0a1iK&J4hEHhzBmPqc+63M^Fno9UMtAudwjAU|se@3x5 zYwt{Pz6eLW;B&m<3$ZbmMS8{O=;L!Zi0_d@@ZsE9iz&xvvxdpk&$DylU@JbEV}V8< z{^kN%_ZUD%-@FJ+QHNwSo)?9r4(q6j)2bH+tNSddRP?IJtA={1jiFv^2-%ZlsZVJV zN|V=W!29!N8x29zR7J0ZpgnsXY{Ir|1`Cw#4LNSukG`Cui1Xj|bI~uAsOV<~ zx%T1xoJeEOC1L8Tpnkp>-*NME7>)1Rg>TV?E-ow*^BR|X1B{(w68c>SE+SVxQ#(a;6`hSmTl%Zd8=BK7mj7_@iC8WkuGCQuwMFD`w_Kqd$*l#HV|!~M+~H4KM*uW& zBmV|yD6bxir8n@eW7ddG!OpWxYJv2s1z%snDQ)xq=M~z){cT}7XyJ!Wbm~GY%2#+Q zJyjYWK9#7Hd3?p%stT>7QgbO~D_2x1OEkq-?(=D-&Y;uIvk7?jR3wgTPlR|ZYX4|} zOIxnF>GPG~Zym$%mv$RY>1e>8I-;?=V>l+AGh;|#26_Uy5t^i}s?>Zg%~iZ&bs0T} nU6QdjlwS}0E=M?hzVb??tlZ^suU5(>b>l6`Si|_eb7Al=ho&%( delta 1689 zcmaJ>eNa?Y6o2QwU07QgS4Kbrm$#24mq0^?7Ug5$DYBzk$|~maA)&hvYc8nBsEuGD ze$0p;b8ysBL_tKY26$3_6tJn&Kpz5@5;LYb!=yfzU)$sw%92Kd6d{GyI=txn3^b^BeY<_3Nj*2IOb@Ln zhgjhbIzqNVI}U`LgdW@)8UhdTNa%DzZ)iAq21A##onK+{vW5|)UN`=i&pulfFrR*Ny z7Rs^da7QhQ4Q4Qcm2r!q1#iTep1R#9m|!C4mABMC=K~6SVmUo~V6& zkkRsRD~VjA+AEgwrE%LC23@$M@@u;3v)k#YOFdBKV#N3J&Rh*0W$sx>zPdf}{HVYB z)C4*zKNLn;YVDCrJkW6zIAiv)RfO%wZbL5~t^JEqj~?%!sGK?)-~H;k6Au{uzp@+V z0lo3#r~2quQ{7~SVLa5>3m;<9={)jxoz8|w7}2!cf8aCfx}>qNP}4s;sntzMl&f~1 z8RpC7bEO)p5!1|TWH$Fv4NFTl)!5#W1CP{iTc!dGsK2&;0yM@SpO1tA96B!tzB4NP z=zt323hcU$=C&pO^WSPz_q9#XK)ZV6Vz(yVQ|#H|T3;;Xdz{kRB2S)cQ;BPJsf#un zH&QJqDuj?A7NnrDH!n>agJTjxY@D zti`_00Br3vqSiYf!>%pBjozq0L8 amounts{1, 2, 3, 10, 100, 12345, 1'000'000}; - // Swaps in both directions and exact-output withdrawals, at every fee. + // Swaps in both directions, at every fee. for (int fee : FeeVector) { BOOST_REQUIRE_EQUAL( success(), changefee(EVO, fee) ); for (int64_t amount : amounts) { @@ -1259,17 +1252,6 @@ BOOST_FIXTURE_TEST_CASE( compute_rounding_table, sysio_swap_tester ) try { after = system_balance(EVO.value); BOOST_REQUIRE_EQUAL( before[1] + amount, after[1] ); BOOST_REQUIRE_EQUAL( before[0] - out, after[0] ); - - // exact-output: withdraw exactly `amount` EOS, paying the spec quote in VOICE - before = after; - int64_t cost = reference::pay(amount, before[0], before[1], fee); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), - exchange( "alice"_n, EVO, extend(asset(-amount, EOS4)), asset(-(cost - 1), VOICE4) ) ); - BOOST_REQUIRE_EQUAL( success(), - exchange( "alice"_n, EVO, extend(asset(-amount, EOS4)), asset(-cost, VOICE4) ) ); - after = system_balance(EVO.value); - BOOST_REQUIRE_EQUAL( before[0] - amount, after[0] ); - BOOST_REQUIRE_EQUAL( before[1] + cost, after[1] ); } } @@ -1342,7 +1324,7 @@ BOOST_FIXTURE_TEST_CASE( invariants_under_random_sequences, sysio_swap_tester ) return std::min(cap, magnitude * (1 + rng() % 9)); }; - enum op_kind { op_swap_forward, op_swap_backward, op_exact_output, op_add, op_remove, op_fee_change, op_count }; + 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; @@ -1360,10 +1342,13 @@ BOOST_FIXTURE_TEST_CASE( invariants_under_random_sequences, sysio_swap_tester ) 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_exact_output) { - // withdraw exactly `w` of leg1, paying at most the user's whole leg2 balance + } 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); - r = exchange( user, pool.code, extend(asset(-w, pool.leg1)), asset(-user_leg2, pool.leg2) ); + 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) ); @@ -1376,6 +1361,8 @@ BOOST_FIXTURE_TEST_CASE( invariants_under_random_sequences, sysio_swap_tester ) 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 ); @@ -1389,7 +1376,7 @@ BOOST_FIXTURE_TEST_CASE( invariants_under_random_sequences, sysio_swap_tester ) // 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_exact_output], 15 ); + 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 ); From d8742f1766a978126111f28757d18319259e1345 Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Fri, 11 Sep 2026 09:00:35 -0400 Subject: [PATCH 05/37] swap: quote exact-input swaps through amm_math::out_given_in process_exch now takes its gross output from the equal-weight constant-product path of sysio.opp.common/amm_math.hpp instead of the negative branch of compute(). The pair fee is unchanged (rounded up, via the new shared ceil_fee helper that compute() also uses), so every quote is bit-identical to before; the guards on a zero input or an empty pool keep their "invalid parameters" message. Characterization coverage added for the substitution, all written against the spec or the host-side amm_math model rather than the contract's arithmetic: differential agreement with the model across both pools, both directions, five fees and six amounts; fee-zero output equals the bare kernel and is the largest integer that keeps x*y from falling; round trips never profit; precision extremes with a one-unit dust pool, a whale pool grown past the init ceiling and whole-balance trades landing on the int64 asset ceiling; guard semantics through the memo path with badtoken's zero-amount notifications; the ABI surface pinned. sysio_swap_tests: 10 -> 16 cases. Full contracts_unit_test green before commit. Co-Authored-By: Claude Fable 5.1 Change-Id: I8e9e53f80f4234a43e9f6136c2bdc5efeff0042a --- contracts/sysio.swap/CMakeLists.txt | 3 +- .../include/sysio.swap/sysio.swap.hpp | 14 + contracts/sysio.swap/sysio.swap.cpp | 28 +- contracts/sysio.swap/sysio.swap.wasm | Bin 50890 -> 51499 bytes contracts/tests/sysio.swap_tests.cpp | 284 ++++++++++++++++++ 5 files changed, 317 insertions(+), 12 deletions(-) diff --git a/contracts/sysio.swap/CMakeLists.txt b/contracts/sysio.swap/CMakeLists.txt index a5b2e8c938..0293a16cc4 100644 --- a/contracts/sysio.swap/CMakeLists.txt +++ b/contracts/sysio.swap/CMakeLists.txt @@ -2,5 +2,6 @@ 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 $) + PUBLIC $ + $) endif() diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index c4a2eee370..74cb011bbf 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include using namespace sysio; @@ -23,6 +24,9 @@ namespace sysio { /// int64 range BEFORE adding the fee, so a fee at or above 100% could push the /// final amount past that range; below 100% the sum stays within int64. 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; using contract::contract; [[sysio::action]] void inittoken(name user, symbol new_symbol, @@ -91,8 +95,18 @@ namespace sysio { 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); + /// 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) + /// less the pair's fee, 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: `x * y / z` rounded the pool's way -- up when `x > 0` + /// (a leg the user pays), down when `x < 0` (a leg the user receives) -- plus + /// `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); + /// `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); asset string_to_asset(string input); 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 ); diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index fd0312ffec..abc6229578 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -77,22 +77,23 @@ void swap::remliquidity(name user, asset to_sell, add_signed_liq(user, -to_sell, false, -min_asset1, -min_asset2); } -// computes x * y / z plus the fee +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"); int128_t prod = int128_t(x) * int128_t(y); int128_t tmp = 0; - int128_t tmp_fee = 0; if (x > 0) { tmp = 1 + (prod - 1) / int128_t(z); check( (tmp <= MAX), "computation overflow" ); - tmp_fee = (tmp * fee + (FEE_DENOMINATOR - 1)) / FEE_DENOMINATOR; + tmp += ceil_fee(tmp, fee); } else { tmp = prod / int128_t(z); check( (tmp >= -MAX), "computation underflow" ); - tmp_fee = (-tmp * fee + (FEE_DENOMINATOR - 1)) / FEE_DENOMINATOR; + tmp += ceil_fee(-tmp, fee); } - tmp += tmp_fee; return int64_t(tmp); } @@ -160,16 +161,21 @@ extended_asset swap::process_exch(symbol_code pair_token, P_in = token-> pool2.quantity.amount; P_out = token-> pool1.quantity.amount; } - auto A_in = ext_asset_in.quantity.amount; - int64_t A_out = compute(-A_in, P_out, P_in + A_in, token->fee); - check(min_expected.amount <= -A_out, "available is less than expected"); + const int64_t A_in = ext_asset_in.quantity.amount; + check( (A_in > 0) && (P_in > 0) && (P_out > 0), "invalid parameters"); + // Constant-product quote, floored, then the pair's fee rounded up against it. + const int128_t gross = opp::amm::out_given_in(uint64_t(P_in), CP_WEIGHT_BPS, + uint64_t(P_out), CP_WEIGHT_BPS, + uint64_t(A_in)); + const int64_t A_out = int64_t(gross - ceil_fee(gross, 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) { + if (in_first) { ext_asset1 = ext_asset_in; - ext_asset2 = extended_asset{A_out, token-> pool2.get_extended_symbol()}; + 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_asset1 = extended_asset{-A_out, token-> pool1.get_extended_symbol()}; ext_asset2 = ext_asset_in; ext_asset_out = -ext_asset1; } diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 2b4d68321253daa43da3f5ab3d880a77e8bd8fe5..143a2ad1e6b02e6f43a8124ab39b73f829a4dcd9 100755 GIT binary patch delta 5206 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 delta 4622 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 #include +#include #include #include +#include using namespace sysio::testing; using namespace sysio; @@ -310,6 +312,10 @@ class sysio_swap_tester : public tester { // 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"), ""); @@ -367,6 +373,33 @@ namespace reference { } } +// 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 pair fee against it. +// `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; + + 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 int64_t g = gross( amount_in, pool_in, pool_out ); + return g - reference::fee_on( g, fee ); + } +} + +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) ); @@ -1382,4 +1415,255 @@ BOOST_FIXTURE_TEST_CASE( invariants_under_random_sequences, sysio_swap_tester ) 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{ + { EVO, EOS4, VOICE4, 0, 1 }, { EVO, VOICE4, EOS4, 1, 0 }, + { ETUSD, EOS4, TUSD2, 0, 1 }, { ETUSD, TUSD2, EOS4, 1, 0 }, + }; + 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 ? EOS4 : VOICE4; + const symbol out_symbol = in_leg == 0 ? VOICE4 : EOS4; + 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, 1); + const int64_t eos = voice > 0 ? settle_swap("alice"_n, EVO, asset(voice, VOICE4), EOS4, 0) : 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(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_n, all_voice, "deposit to: alice") ); + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, all_tusd, "") ); + + // A dust pool: one unit of EOS against the largest side inittoken accepts. + const int64_t init_max = 1'000'000'000'000'000 - 1; + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extend(asset(1, EOS4)), extend(asset(init_max, VOICE4)), 10, "wevotethefee"_n) ); + // 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, EOS4)), extend(asset(init_max, TUSD2)), 10, "wevotethefee"_n) ); + { + 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, EOS4), asset(pay2, TUSD2) ) ); + 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[0], before[1], 10); + BOOST_REQUIRE_EQUAL( out, model::receive(1, before[0], before[1], 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, 1) ); + // 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[1], before[0], 10) ); + BOOST_REQUIRE_EQUAL( 0, settle_swap("alice"_n, EVO, asset(1, VOICE4), EOS4, 0) ); + const auto after = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( before[0], after[0] ); + BOOST_REQUIRE_EQUAL( before[1] + 1, after[1] ); + } + // Dust pool: alice's entire VOICE balance in one trade. The pool side lands + // exactly on the int64 ceiling, the gross quote is a single unit, and the + // fee rounding decides whether that unit reaches her. + { + const auto before = system_balance(EVO.value); + const int64_t amount = balance("alice"_n, 1); + BOOST_REQUIRE_EQUAL( asset::max_amount, before[1] + amount ); + BOOST_REQUIRE_EQUAL( 1, model::gross(amount, before[1], before[0]) ); + const int64_t out = reference::receive(amount, before[1], before[0], 10); + BOOST_REQUIRE_EQUAL( out, settle_swap("alice"_n, EVO, asset(amount, VOICE4), EOS4, 0) ); + const auto after = system_balance(EVO.value); + BOOST_REQUIRE_EQUAL( asset::max_amount, after[1] ); + BOOST_REQUIRE_EQUAL( 0, balance("alice"_n, 1) ); + } + + // 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[0], before[1], 10) ); + BOOST_REQUIRE_EQUAL( 0, settle_swap("alice"_n, ETUSD, asset(1, EOS4), TUSD2, 1) ); + auto after = system_balance(ETUSD.value); + BOOST_REQUIRE_EQUAL( before[0] + 1, after[0] ); + BOOST_REQUIRE_EQUAL( before[1], after[1] ); + + // 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, 0); + BOOST_REQUIRE_EQUAL( asset::max_amount - 2, before[0] + amount ); + const int64_t out = reference::receive(amount, before[0], before[1], 10); + BOOST_REQUIRE_EQUAL( out, model::receive(amount, before[0], before[1], 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, 1) ); + after = system_balance(ETUSD.value); + BOOST_REQUIRE_EQUAL( asset::max_amount - 2, after[0] ); + BOOST_REQUIRE_EQUAL( 0, balance("alice"_n, 0) ); + + // And every unit of TUSD the other way. + before = after; + const int64_t tusd = balance("alice"_n, 2); + const int64_t out2 = reference::receive(tusd, before[1], before[0], 10); + BOOST_REQUIRE_EQUAL( out2, model::receive(tusd, before[1], before[0], 10) ); + BOOST_REQUIRE_EQUAL( out2, settle_swap("alice"_n, ETUSD, asset(tusd, TUSD2), EOS4, 0) ); + BOOST_REQUIRE_EQUAL( 0, balance("alice"_n, 2) ); + } +} 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) ); + BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, extended_symbol{EOS4, "badtoken"_n}) ); + BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, extended_symbol{VOICE4, "anothertoken"_n}) ); + BOOST_REQUIRE_EQUAL( success(), transfer( "badtoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("2000.0000 EOS"), "") ); + BOOST_REQUIRE_EQUAL( success(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_n, asset::from_string("2000.0000 VOICE"), "deposit to: alice") ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extended_asset{asset::from_string("1000.0000 EOS"), "badtoken"_n}, + extend(asset::from_string("1000.0000 VOICE")), 10, "wevotethefee"_n) ); + + 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 VOICE") ); + // ...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 VOICE") ); + 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, VOICE4).to_string() ) ); + BOOST_REQUIRE_EQUAL( success(), + transfer( "badtoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("1.0000 EOS"), + "exchange: EVO, " + asset(out, VOICE4).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() + +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{ + "addliquidity", "changefee", "close", "closeext", "exchange", "indexpair", + "inittoken", "open", "openext", "remliquidity", "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_contract", "name"} }; + const field_list currency_stats_fields{ + {"supply", "asset"}, {"max_supply", "asset"}, {"issuer", "name"}, {"pool1", "extended_asset"}, + {"pool2", "extended_asset"}, {"fee", "int32"}, {"fee_contract", "name"} }; + 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 ); + + std::set tables; + for (const auto& t : abi.tables) tables.insert(t.name); + const std::set expected_tables{ + "account", "accounts", "currency_stats", "evodexaccount", "evodexacnts", "evoindex", "index_struct", "stat" }; + BOOST_REQUIRE( tables == expected_tables ); +} FC_LOG_AND_RETHROW() + BOOST_AUTO_TEST_SUITE_END() \ No newline at end of file From 9d2b95c0a7803e4b166d9e41b73807926b0f50fd Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Fri, 11 Sep 2026 09:00:54 -0400 Subject: [PATCH 06/37] swap: take the pair fee with amm_math::split_wire_fee The swap fee now comes from the depot-wide fee decomposition in sysio.opp.common/amm_math.hpp: split_wire_fee(gross, fee, 0).net, with no underwriter share because the fee stays in the pool for the liquidity providers. That convention rounds the fee DOWN, where the evolutiondex math rounded it up, so a quote nets one unit more whenever gross*fee is not a multiple of 10000 -- and a one-unit quote is no longer eaten by its own fee. The curve output is still floored, so the pool product never falls. Liquidity pricing (compute, ceil + 0.01%) is unchanged. MAX_FEE keeps its 9999 bound with the new rationale (below 100% a positive quote always nets a unit; split_wire_fee reports net 0 at 100%). The ricardian exchange clause and the README now state the floor-fee rule; the clause had described the result as x + y. Tests: the reference model splits into a floored swap fee and a ceiled liquidity fee, the host model composes out_given_in with split_wire_fee, and the pinned quotes in exchange_action, increasing_poolvalue and memoexchange_test move by one to three units (re-derived with an independent replay). The precision-extremes case now pins the one-unit dust quote reaching the trader. Full contracts_unit_test green before commit. Co-Authored-By: Claude Fable 5.1 Change-Id: I2bfa45ca5d12cd62312c018b86b6c1aa33e6e006 --- contracts/sysio.swap/README.md | 2 +- .../include/sysio.swap/sysio.swap.hpp | 15 ++-- .../ricardian/sysio.swap.contracts.md | 4 +- contracts/sysio.swap/sysio.swap.cpp | 9 ++- contracts/sysio.swap/sysio.swap.wasm | Bin 51499 -> 51518 bytes contracts/tests/sysio.swap_tests.cpp | 74 ++++++++++-------- 6 files changed, 58 insertions(+), 46 deletions(-) diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md index 08f17ef26c..746a2ebd6f 100644 --- a/contracts/sysio.swap/README.md +++ b/contracts/sysio.swap/README.md @@ -19,7 +19,7 @@ The action of removing liquidity is free of charge. **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. All the rounding errors will favour the pools in order to avoid the gaming of the system. This criterion completely determines +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). 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: diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index 74cb011bbf..a7fab78bf0 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -20,13 +20,14 @@ namespace sysio { const int DEFAULT_FEE = 10; /// Fees are expressed in units of 1/FEE_DENOMINATOR of the traded amount. static constexpr int FEE_DENOMINATOR = 10000; - /// Upper bound accepted by changefee. compute() checks its result against the - /// int64 range BEFORE adding the fee, so a fee at or above 100% could push the - /// final amount past that range; below 100% the sum stays within int64. + /// 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; using contract::contract; [[sysio::action]] void inittoken(name user, symbol new_symbol, @@ -97,15 +98,15 @@ namespace sysio { void memoexchange(name user, extended_asset ext_asset_in, string_view details); /// 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) - /// less the pair's fee, and must reach `min_expected`. Moves the pools and - /// returns the extended asset the user receives. + /// net of the pair's fee (amm::split_wire_fee), 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: `x * y / z` rounded the pool's way -- up when `x > 0` /// (a leg the user pays), down when `x < 0` (a leg the user receives) -- plus /// `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); - /// `fee`/FEE_DENOMINATOR of `amount`, rounded up so a non-zero amount never - /// pays a zero fee. `amount` must be nonnegative. + /// 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); asset string_to_asset(string input); void placeindex(name user, symbol evo_symbol, extended_asset pool1, extended_asset pool2 ); diff --git a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md index b896739b62..fa25f5b042 100644 --- a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md +++ b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md @@ -140,10 +140,10 @@ 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 +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. +y = x * fee / 10000, up to the same precision as x, again rounded downward. 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. diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index abc6229578..5ab1df89d0 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -163,11 +163,14 @@ extended_asset swap::process_exch(symbol_code pair_token, } const int64_t A_in = ext_asset_in.quantity.amount; check( (A_in > 0) && (P_in > 0) && (P_out > 0), "invalid parameters"); - // Constant-product quote, floored, then the pair's fee rounded up against it. - const int128_t gross = opp::amm::out_given_in(uint64_t(P_in), CP_WEIGHT_BPS, + // 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 -- so only the net is consumed. + const uint64_t gross = opp::amm::out_given_in(uint64_t(P_in), CP_WEIGHT_BPS, uint64_t(P_out), CP_WEIGHT_BPS, uint64_t(A_in)); - const int64_t A_out = int64_t(gross - ceil_fee(gross, token->fee)); + const int64_t A_out = int64_t(opp::amm::split_wire_fee(gross, uint32_t(token->fee), + NO_UNDERWRITER_SHARE_BPS).net); check(min_expected.amount <= A_out, "available is less than expected"); extended_asset ext_asset1, ext_asset2, ext_asset_out; if (in_first) { diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 143a2ad1e6b02e6f43a8124ab39b73f829a4dcd9..39b81143e97e322a93b7aec4c6efeb9443d84dd6 100755 GIT binary patch delta 230 zcmZ2IiFw~7<_(Kk7@uxl!gAk~apvY0D=#KS_Q~gMBn;S{7(#g&xg8lCmmXsfVB~gU zP-J3qP~Z&UWl&%T5nLdG)zKhJfy)UfG+EqMUW-YANr5Y5t$=|7t0uF85|aWuP=Gze z5u{Kd%aJ2XlbInvT3V5bd-6s*^>|iCfh+|^4WQ*6OOG)KFmgLF zD6%s-C~yYwGAJ;C2rdx8>S&Oqz~uxKnk;TBugR{!uD}(tR=_}kRg+mkiCuvSD8Llr z2vVq!<;anx$;=QSEv?8tIo?*!&4ejJiAjOU$-Z5I-HAa!Ux5{*O+bmsss9{9jS?qN mlLSbI0w>UnwKYy3Hr6}U3m5`j!2%Lx2dQSL+5F5lq8k9W=PFPD diff --git a/contracts/tests/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index 67d337e686..2762ae33dc 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -346,26 +346,30 @@ extended_asset extend(asset to_extend) { } // Reference quotes for the rounding tests, written from the SPEC rather than -// copied from swap::compute: every rounding goes the pool's way. What a user -// pays is rounded up, what a user receives is rounded down, and the fee on -// either is rounded up (so it is never zero on a non-zero amount). +// 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, so a one-unit quote is not eaten by its own fee), while the +// liquidity fee added on top of what a provider pays is rounded UP (so it is +// never zero on a non-zero amount). namespace reference { using wide = boost::multiprecision::int128_t; constexpr int64_t FeeDenominator = 10000; 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 fee_on( int64_t amount, int fee ) { return ceil_div( wide(amount) * fee, FeeDenominator ); } + int64_t swap_fee_on( int64_t amount, int fee ) { return floor_div( wide(amount) * fee, FeeDenominator ); } + int64_t liquidity_fee_on( int64_t amount, int fee ) { return ceil_div( wide(amount) * fee, FeeDenominator ); } // 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 - fee_on( gross, fee ); + 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 + fee_on( gross, 1 ); + 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 ) { @@ -375,12 +379,14 @@ namespace reference { // 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 pair fee against it. +// constant-product kernel for the gross output, then the depot fee split +// against it (no underwriter share -- the fee stays in the pool). // `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; 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, @@ -389,7 +395,7 @@ namespace model { } int64_t receive( int64_t amount_in, int64_t pool_in, int64_t pool_out, int fee ) { const int64_t g = gross( amount_in, pool_in, pool_out ); - return g - reference::fee_on( g, fee ); + return int64_t( amm::split_wire_fee( uint64_t(g), uint32_t(fee), NoUnderwriterShareBps ).net ); } } @@ -586,20 +592,20 @@ BOOST_FIXTURE_TEST_CASE( exchange_action, sysio_swap_tester ) try { 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")); - vector expected_system_balance = {10000073819, 999999185799, 100000328128}; + vector expected_system_balance = {10000073819, 999999185796, 100000328128}; BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); BOOST_REQUIRE_EQUAL(balance("alice"_n,0), 89999926181); - BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 1000000814201); - + BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 1000000814204); + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 50) ); addliquidity( "alice"_n, asset::from_string("50.0000 EVO"), asset::from_string("10000000.0000 EOS"), asset::from_string("10000000.0000 VOICE") ); - expected_system_balance = {10000123826, 1000004186279, 100000828128}; + expected_system_balance = {10000123826, 1000004186276, 100000828128}; BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); BOOST_REQUIRE_EQUAL(balance("alice"_n,0), 89999876174); - BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 999995813721); + BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 999995813724); // 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"), @@ -607,7 +613,7 @@ BOOST_FIXTURE_TEST_CASE( exchange_action, sysio_swap_tester ) try { asset::from_string("-401.9984 VOICE")) ); BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); BOOST_REQUIRE_EQUAL(balance("alice"_n,0), 89999876174); - BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 999995813721); + BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 999995813724); } FC_LOG_AND_RETHROW() @@ -643,7 +649,7 @@ BOOST_FIXTURE_TEST_CASE( increasing_poolvalue, sysio_swap_tester) try { 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,0) - old_alice_bal_0, -40000); - BOOST_REQUIRE_EQUAL(balance("alice"_n,1) - old_alice_bal_1, 166569); + BOOST_REQUIRE_EQUAL(balance("alice"_n,1) - old_alice_bal_1, 166570); old_total = total(); old_vec = system_balance(EVO.value); @@ -743,9 +749,9 @@ BOOST_FIXTURE_TEST_CASE( memoexchange_test, sysio_swap_tester ) try { 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.6570 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") ); BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), transfer( "carol"_n, "carol"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), @@ -756,11 +762,11 @@ BOOST_FIXTURE_TEST_CASE( memoexchange_test, sysio_swap_tester ) try { 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.6569 VOICE") ); + 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 + 166569, token_balance("anothertoken"_n, "alice"_n, VOICE.value) ); + BOOST_REQUIRE_EQUAL( pre_voice_balance + 166570, token_balance("anothertoken"_n, "alice"_n, VOICE.value) ); inittoken( "alice"_n, ETUSD3, extend(asset::from_string("10000000000.0000 EOS")), @@ -768,17 +774,17 @@ BOOST_FIXTURE_TEST_CASE( memoexchange_test, sysio_swap_tester ) try { 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.1605 EOS") ); + 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.1604 EOS") ); + 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, + BOOST_REQUIRE_EQUAL( pre_tusd_balance - 40000000, token_balance("sysio.token"_n, "alice"_n, TUSD.value) ); - BOOST_REQUIRE_EQUAL( pre_eos_balance + 4031604, + BOOST_REQUIRE_EQUAL( pre_eos_balance + 4031605, token_balance("sysio.token"_n, "alice"_n, EOS.value) ); auto old_vec = system_balance(EVO.value); @@ -1541,14 +1547,15 @@ BOOST_FIXTURE_TEST_CASE( precision_extremes, sysio_swap_tester ) try { BOOST_REQUIRE_EQUAL( before[1] + 1, after[1] ); } // Dust pool: alice's entire VOICE balance in one trade. The pool side lands - // exactly on the int64 ceiling, the gross quote is a single unit, and the - // fee rounding decides whether that unit reaches her. + // exactly on the int64 ceiling and the gross quote is a single unit, which + // reaches her because the fee on it rounds down to nothing. { const auto before = system_balance(EVO.value); const int64_t amount = balance("alice"_n, 1); BOOST_REQUIRE_EQUAL( asset::max_amount, before[1] + amount ); BOOST_REQUIRE_EQUAL( 1, model::gross(amount, before[1], before[0]) ); const int64_t out = reference::receive(amount, before[1], before[0], 10); + BOOST_REQUIRE_EQUAL( 1, out ); BOOST_REQUIRE_EQUAL( out, settle_swap("alice"_n, EVO, asset(amount, VOICE4), EOS4, 0) ); const auto after = system_balance(EVO.value); BOOST_REQUIRE_EQUAL( asset::max_amount, after[1] ); @@ -1565,17 +1572,18 @@ BOOST_FIXTURE_TEST_CASE( precision_extremes, sysio_swap_tester ) try { BOOST_REQUIRE_EQUAL( before[1], after[1] ); // Every unit of EOS alice still holds: pool_in + amount is the whole - // supply less the two units parked in the dust pool. + // supply less the single unit still parked in the dust pool. before = after; const int64_t amount = balance("alice"_n, 0); - BOOST_REQUIRE_EQUAL( asset::max_amount - 2, before[0] + amount ); + BOOST_REQUIRE_EQUAL( 1, system_balance(EVO.value)[0] ); + BOOST_REQUIRE_EQUAL( asset::max_amount - 1, before[0] + amount ); const int64_t out = reference::receive(amount, before[0], before[1], 10); BOOST_REQUIRE_EQUAL( out, model::receive(amount, before[0], before[1], 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, 1) ); after = system_balance(ETUSD.value); - BOOST_REQUIRE_EQUAL( asset::max_amount - 2, after[0] ); + BOOST_REQUIRE_EQUAL( asset::max_amount - 1, after[0] ); BOOST_REQUIRE_EQUAL( 0, balance("alice"_n, 0) ); // And every unit of TUSD the other way. From a058257e477d6bc2392eb94de06db329e0ff103d Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Fri, 11 Sep 2026 10:38:06 -0400 Subject: [PATCH 07/37] swap: collect at least one unit of fee on every fee-bearing quote split_wire_fee rounds the fee down, which leaves any quote below FEE_DENOMINATOR/fee units fee-free; at the 10 bps floor that is a thousand units, and "units" is precision-relative, so a zero-decimal token could trade a thousand whole tokens per swap without paying the pool. Trades that also divide the curve exactly then leave x*y untouched, and those amounts can be chosen deliberately. process_exch now takes max(floored fee, MIN_SWAP_FEE) whenever both the pair's rate and the gross quote are nonzero; a zero rate still charges nothing. Every fee-bearing trade grows x*y again. Reference and model in the tests carry the same rule, the pinned quotes move by one unit where a swap's floored fee was zero, and a new case walks the boundary: a 999-unit quote pays one unit, a 19956-unit quote is unaffected, a zero rate charges nothing, and x*y strictly grows on the smallest trades. Co-Authored-By: Claude Fable 5.1 Change-Id: If2b6c93496278f3f25b55a335dab050effa03248 --- contracts/sysio.swap/README.md | 2 +- .../include/sysio.swap/sysio.swap.hpp | 9 +- .../ricardian/sysio.swap.contracts.md | 2 +- contracts/sysio.swap/sysio.swap.cpp | 10 +- contracts/sysio.swap/sysio.swap.wasm | Bin 51518 -> 51598 bytes contracts/tests/sysio.swap_tests.cpp | 98 ++++++++++++++---- 6 files changed, 95 insertions(+), 26 deletions(-) diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md index 746a2ebd6f..9d72e0ae09 100644 --- a/contracts/sysio.swap/README.md +++ b/contracts/sysio.swap/README.md @@ -19,7 +19,7 @@ The action of removing liquidity is free of charge. **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). This criterion completely determines +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: diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index a7fab78bf0..ca0144b423 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include using namespace sysio; @@ -28,6 +29,11 @@ namespace sysio { 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; [[sysio::action]] void inittoken(name user, symbol new_symbol, @@ -98,7 +104,8 @@ namespace sysio { void memoexchange(name user, extended_asset ext_asset_in, string_view details); /// 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), and must reach `min_expected`. + /// 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: `x * y / z` rounded the pool's way -- up when `x > 0` diff --git a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md index fa25f5b042..021a6bee3c 100644 --- a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md +++ b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md @@ -143,7 +143,7 @@ summary: 'Exchange token through a specific pair' 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. The fee y remains in the pools. +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. diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index 5ab1df89d0..cefd8d8df7 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -165,12 +165,16 @@ extended_asset swap::process_exch(symbol_code pair_token, check( (A_in > 0) && (P_in > 0) && (P_out > 0), "invalid parameters"); // 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 -- so only the net is consumed. + // 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(P_in), CP_WEIGHT_BPS, uint64_t(P_out), CP_WEIGHT_BPS, uint64_t(A_in)); - const int64_t A_out = int64_t(opp::amm::split_wire_fee(gross, uint32_t(token->fee), - NO_UNDERWRITER_SHARE_BPS).net); + uint64_t fee = opp::amm::split_wire_fee(gross, uint32_t(token->fee), NO_UNDERWRITER_SHARE_BPS).fee; + if (token->fee > 0 && gross > 0) fee = std::max(fee, MIN_SWAP_FEE); + const int64_t A_out = int64_t(gross - fee); check(min_expected.amount <= A_out, "available is less than expected"); extended_asset ext_asset1, ext_asset2, ext_asset_out; if (in_first) { diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 39b81143e97e322a93b7aec4c6efeb9443d84dd6..a371d90e7623841d749a8c3467dea74c454094b7 100755 GIT binary patch delta 306 zcmdltiMekw^M=JNOk3Y?UdnRMlM9P$|BQvMK7Ppn-;!@zOQDRbH0&?wb4JS{slV)Re z6v$FwoIJb0WpHG0yl{d+fRP() LA=_pzJC|+%Iszoi diff --git a/contracts/tests/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index 2762ae33dc..ad83111444 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -349,16 +350,20 @@ extended_asset extend(asset to_extend) { // 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, so a one-unit quote is not eaten by its own fee), while the -// liquidity fee added on top of what a provider pays is rounded UP (so it is -// never zero on a non-zero amount). +// 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 ) { return floor_div( wide(amount) * fee, FeeDenominator ); } + 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 ); } // Units of `pool_out` received for `amount_in` units of `pool_in`. @@ -380,13 +385,14 @@ namespace reference { // 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). -// `reference` is the spec written by hand; `model` is the library. A swap must -// agree with both. +// 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, @@ -394,8 +400,10 @@ namespace model { uint64_t(amount_in) ) ); } int64_t receive( int64_t amount_in, int64_t pool_in, int64_t pool_out, int fee ) { - const int64_t g = gross( amount_in, pool_in, pool_out ); - return int64_t( amm::split_wire_fee( uint64_t(g), uint32_t(fee), NoUnderwriterShareBps ).net ); + 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 ); } } @@ -592,20 +600,20 @@ BOOST_FIXTURE_TEST_CASE( exchange_action, sysio_swap_tester ) try { 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")); - vector expected_system_balance = {10000073819, 999999185796, 100000328128}; + vector expected_system_balance = {10000073819, 999999185797, 100000328128}; BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); BOOST_REQUIRE_EQUAL(balance("alice"_n,0), 89999926181); - BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 1000000814204); + BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 1000000814203); BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 50) ); addliquidity( "alice"_n, asset::from_string("50.0000 EVO"), asset::from_string("10000000.0000 EOS"), asset::from_string("10000000.0000 VOICE") ); - expected_system_balance = {10000123826, 1000004186276, 100000828128}; + expected_system_balance = {10000123826, 1000004186277, 100000828128}; BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); BOOST_REQUIRE_EQUAL(balance("alice"_n,0), 89999876174); - BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 999995813724); + BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 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"), @@ -613,7 +621,7 @@ BOOST_FIXTURE_TEST_CASE( exchange_action, sysio_swap_tester ) try { asset::from_string("-401.9984 VOICE")) ); BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); BOOST_REQUIRE_EQUAL(balance("alice"_n,0), 89999876174); - BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 999995813724); + BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 999995813723); } FC_LOG_AND_RETHROW() @@ -1548,14 +1556,14 @@ BOOST_FIXTURE_TEST_CASE( precision_extremes, sysio_swap_tester ) try { } // 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 - // reaches her because the fee on it rounds down to nothing. + // 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, 1); BOOST_REQUIRE_EQUAL( asset::max_amount, before[1] + amount ); BOOST_REQUIRE_EQUAL( 1, model::gross(amount, before[1], before[0]) ); const int64_t out = reference::receive(amount, before[1], before[0], 10); - BOOST_REQUIRE_EQUAL( 1, out ); + BOOST_REQUIRE_EQUAL( 0, out ); BOOST_REQUIRE_EQUAL( out, settle_swap("alice"_n, EVO, asset(amount, VOICE4), EOS4, 0) ); const auto after = system_balance(EVO.value); BOOST_REQUIRE_EQUAL( asset::max_amount, after[1] ); @@ -1572,18 +1580,18 @@ BOOST_FIXTURE_TEST_CASE( precision_extremes, sysio_swap_tester ) try { BOOST_REQUIRE_EQUAL( before[1], after[1] ); // Every unit of EOS alice still holds: pool_in + amount is the whole - // supply less the single unit still parked in the dust pool. + // supply less the two units parked in the dust pool. before = after; const int64_t amount = balance("alice"_n, 0); - BOOST_REQUIRE_EQUAL( 1, system_balance(EVO.value)[0] ); - BOOST_REQUIRE_EQUAL( asset::max_amount - 1, before[0] + amount ); + BOOST_REQUIRE_EQUAL( 2, system_balance(EVO.value)[0] ); + BOOST_REQUIRE_EQUAL( asset::max_amount - 2, before[0] + amount ); const int64_t out = reference::receive(amount, before[0], before[1], 10); BOOST_REQUIRE_EQUAL( out, model::receive(amount, before[0], before[1], 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, 1) ); after = system_balance(ETUSD.value); - BOOST_REQUIRE_EQUAL( asset::max_amount - 1, after[0] ); + BOOST_REQUIRE_EQUAL( asset::max_amount - 2, after[0] ); BOOST_REQUIRE_EQUAL( 0, balance("alice"_n, 0) ); // And every unit of TUSD the other way. @@ -1596,6 +1604,56 @@ BOOST_FIXTURE_TEST_CASE( precision_extremes, sysio_swap_tester ) try { } } 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(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_n, asset::from_string("2000.0000 VOICE"), "deposit to: alice") ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1000.0000 EOS")), extend(asset::from_string("1000.0000 VOICE")), 10, "wevotethefee"_n) ); + + // 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, 1) ); + // 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[0], before[1]) ); + BOOST_REQUIRE_EQUAL( 998, reference::receive(1000, before[0], before[1], 10) ); + BOOST_REQUIRE_EQUAL( 998, settle_swap("alice"_n, EVO, asset(1000, EOS4), VOICE4, 1) ); + } + // 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[0], before[1]); + BOOST_REQUIRE( g * 10 / 10000 >= 1 ); + BOOST_REQUIRE_EQUAL( g - g * 10 / 10000, settle_swap("alice"_n, EVO, asset(20000, EOS4), VOICE4, 1) ); + } + // 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[0], before[1]); + BOOST_REQUIRE_EQUAL( g, settle_swap("alice"_n, EVO, asset(1000, EOS4), VOICE4, 1) ); + } + // 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[0], before[1]); + const int64_t out = settle_swap("alice"_n, EVO, asset(amount, EOS4), VOICE4, 1); + 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 From 21b68c76c3f8356ed0186343d557e17ad8af5bd3 Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Fri, 11 Sep 2026 10:38:54 -0400 Subject: [PATCH 08/37] swap: cumulative-price accumulators for time-weighted average prices Every pair now keeps two 256-bit accumulators in a `priceaccum` row: the running sum of each side's Q64.64 spot price (the other side's balance over its own) times the microseconds that price held. They advance at the spot price in force since `last_update` immediately before the pools change -- swaps, adding and removing liquidity -- and on a new permissionless `sync` action, so a reader can bring a snapshot up to date without trading. A reader records the row at t0 and again 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. The kernel lives in sysio.opp.common/twap.hpp: integer only, host- compilable, with the 256-bit add (both carries), modular difference, and the 256-by-64 division that recovers the average. A Q64.64 price of int64 balances is below 2^126 and elapsed fits 64 bits, so the sum cannot wrap within the life of any chain. The row's limb type is spelled `uint128_t` so the ABI generator emits the `uint128` builtin. Tests: a host suite (twap_tests) checks the kernel against boost 256-bit arithmetic, including every carry and borrow path and 200 random multi-step windows; contract cases follow the accumulators through swap, add, remove and sync and recover the reader's average, show two trades in one transaction add nothing for the second, and hold the steepest int64 price for a week past 128 bits. The ABI pin gains the `sync` action, the `priceaccum` table and the two new structs. Co-Authored-By: Claude Fable 5.1 Change-Id: If0889e91e920c6a8e6ff305bfe78da1a31ea8891 --- .../include/sysio.opp.common/twap.hpp | 91 ++++++++ contracts/sysio.swap/Commands.md | 8 + contracts/sysio.swap/README.md | 4 + .../include/sysio.swap/sysio.swap.hpp | 24 +++ .../ricardian/sysio.swap.contracts.md | 13 ++ contracts/sysio.swap/sysio.swap.abi | 59 +++++ contracts/sysio.swap/sysio.swap.cpp | 31 +++ contracts/sysio.swap/sysio.swap.wasm | Bin 51598 -> 56570 bytes contracts/tests/sysio.swap_tests.cpp | 204 +++++++++++++++++- contracts/tests/twap_tests.cpp | 131 +++++++++++ contracts/tests/twap_wide.hpp | 28 +++ 11 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 contracts/sysio.opp.common/include/sysio.opp.common/twap.hpp create mode 100644 contracts/tests/twap_tests.cpp create mode 100644 contracts/tests/twap_wide.hpp 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/Commands.md b/contracts/sysio.swap/Commands.md index 136ebbd87e..e7ef2df505 100644 --- a/contracts/sysio.swap/Commands.md +++ b/contracts/sysio.swap/Commands.md @@ -62,6 +62,14 @@ of the input is account, evotoken, extended_asset to pay (exact), asset to recei 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 diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md index 9d72e0ae09..f4d5b54a3c 100644 --- a/contracts/sysio.swap/README.md +++ b/contracts/sysio.swap/README.md @@ -26,6 +26,10 @@ When adding or removing liquidity, the (again standard) criterion is to keep fix the 0.01% fee charged will slightly increase the value of the evotoken afterwards. +**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. + **Some considerations from the perspective of liquidity providers** Being a liquidity provider is a financial position that deserves a diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index ca0144b423..d6e435b73a 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -47,6 +48,10 @@ namespace sysio { [[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 ); @@ -87,6 +92,20 @@ namespace sysio { checksum256 secondary_key()const { return id_256; } }; + /// 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 { + symbol_code pair; + sysio::opp::twap::cumulative_price price1; + sysio::opp::twap::cumulative_price price2; + time_point last_update; + uint64_t primary_key()const { return pair.raw(); } + }; + typedef sysio::multi_index< "evodexacnts"_n, evodexaccount, indexed_by<"extended"_n, const_mem_fun> > evodexacnts; @@ -95,6 +114,7 @@ namespace sysio { indexed_by<"extended"_n, const_mem_fun> > evoindexes; typedef sysio::multi_index< "accounts"_n, account > accounts; + typedef sysio::multi_index< "priceaccum"_n, price_accumulator > priceaccums; static uint128_t make128key(uint64_t a, uint64_t b); static checksum256 make256key(uint64_t a, uint64_t b, uint64_t c, uint64_t d); @@ -115,6 +135,10 @@ namespace sysio { /// 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); asset string_to_asset(string input); 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 ); diff --git a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md index 021a6bee3c..d3fe3121b1 100644 --- a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md +++ b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md @@ -153,6 +153,19 @@ The operation is executed only if the extended asset to be added to {{user}} is 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. + +

changefee

--- diff --git a/contracts/sysio.swap/sysio.swap.abi b/contracts/sysio.swap/sysio.swap.abi index 03b3b29589..ebab3fdcd8 100644 --- a/contracts/sysio.swap/sysio.swap.abi +++ b/contracts/sysio.swap/sysio.swap.abi @@ -85,6 +85,20 @@ } ] }, + { + "name": "cumulative_price", + "base": "", + "fields": [ + { + "name": "lo", + "type": "uint128" + }, + { + "name": "hi", + "type": "uint128" + } + ] + }, { "name": "currency_stats", "base": "", @@ -263,6 +277,28 @@ } ] }, + { + "name": "price_accumulator", + "base": "", + "fields": [ + { + "name": "pair", + "type": "symbol_code" + }, + { + "name": "price1", + "type": "cumulative_price" + }, + { + "name": "price2", + "type": "cumulative_price" + }, + { + "name": "last_update", + "type": "time_point" + } + ] + }, { "name": "remliquidity", "base": "", @@ -285,6 +321,16 @@ } ] }, + { + "name": "sync", + "base": "", + "fields": [ + { + "name": "pair_token", + "type": "symbol_code" + } + ] + }, { "name": "transfer", "base": "", @@ -381,6 +427,11 @@ "type": "remliquidity", "ricardian_contract": "" }, + { + "name": "sync", + "type": "sync", + "ricardian_contract": "" + }, { "name": "transfer", "type": "transfer", @@ -463,6 +514,14 @@ "key_types": [], "table_id": 44724 }, + { + "name": "priceaccum", + "type": "price_accumulator", + "index_type": "i64", + "key_names": ["scope","primary_key"], + "key_types": ["name","uint64"], + "table_id": 54664 + }, { "name": "stat", "type": "currency_stats", diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index cefd8d8df7..8b3bb6865c 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -121,6 +121,7 @@ void swap::add_signed_liq(name user, asset to_add, bool is_buying, add_signed_ext_balance(user, -to_pay2); (to_add.amount > 0)? add_balance(user, to_add, user) : sub_balance(user, -to_add); if (token->fee_contract) require_recipient(token->fee_contract); + update_price_accumulators(*token); statstable.modify( token, same_payer, [&]( auto& a ) { a.supply += to_add; a.pool1 += to_pay1; @@ -186,6 +187,7 @@ extended_asset swap::process_exch(symbol_code pair_token, ext_asset2 = ext_asset_in; ext_asset_out = -ext_asset1; } + update_price_accumulators(*token); statstable.modify( token, same_payer, [&]( auto& a ) { a.pool1 += ext_asset1; a.pool2 += ext_asset2; @@ -237,6 +239,12 @@ extended_asset initial_pool2, int initial_fee, name fee_contract) a.fee_contract = fee_contract; } ); + priceaccums accums( get_self(), get_self().value ); + accums.emplace( user, [&]( auto& a ) { + a.pair = new_symbol.code(); + a.last_update = current_time_point(); + } ); + placeindex(user, new_symbol, initial_pool1, initial_pool2 ); add_balance(user, new_token, user); add_signed_ext_balance(user, -initial_pool1); @@ -266,6 +274,29 @@ void swap::placeindex(name user, symbol evo_symbol, }); } +void swap::update_price_accumulators(const currency_stats& token) { + priceaccums accums( get_self(), get_self().value ); + const auto accum = accums.find( token.supply.symbol.code().raw() ); + check( accum != accums.end(), "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( accum, same_payer, [&]( 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(), pair_token.raw() ); + const auto token = statstable.find( pair_token.raw() ); + check ( token != statstable.end(), "pair token does not exist" ); + update_price_accumulators(*token); +} + void swap::changefee(symbol_code pair_token, int newfee) { stats statstable( get_self(), pair_token.raw() ); const auto& token = statstable.find( pair_token.raw() ); diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index a371d90e7623841d749a8c3467dea74c454094b7..06e4335c0c84446ea88de37907140a8d7fc23e46 100755 GIT binary patch delta 14860 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 delta 11172 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 b&%E^Imr3Ydc~ #include +#include +#include "twap_wide.hpp" #include #include #include @@ -187,6 +189,51 @@ class sysio_swap_tester : public tester { ( "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() }; + } action_result openfeetable( name user, symbol_code pair_token ) { return push_action( "wevotethefee"_n, user, "openfeetable"_n, mvo() @@ -407,6 +454,17 @@ namespace model { } } +// 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; + } +} + 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) ) ); @@ -1690,6 +1748,140 @@ BOOST_FIXTURE_TEST_CASE( guard_semantics_on_the_memo_path, sysio_swap_tester ) t 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 EOS"), asset::from_string("100000.0000 VOICE")) ); } ); + step( fc::minutes(5), [&]{ BOOST_REQUIRE_EQUAL( success(), + remliquidity("alice"_n, asset::from_string("25.0000 EVO"), asset(0, EOS4), asset(0, VOICE4)) ); } ); + 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: a little over four VOICE per EOS. + BOOST_REQUIRE( average1 >= 4 * twap::PRICE_ONE && average1 < 5 * twap::PRICE_ONE ); + 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 > twap::PRICE_ONE / 5 && average2 < twap::PRICE_ONE / 4 ); +} 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 spot price -- 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[1], moved[0]) * 2 < twap_reference::price_fp(pools[1], pools[0]) ); + 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(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_n, asset::from_string("461168601842738.7903 VOICE"), "deposit to: alice") ); + const int64_t init_max = 1'000'000'000'000'000 - 1; + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extend(asset(1, EOS4)), extend(asset(init_max, VOICE4)), 10, "wevotethefee"_n) ); + 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(init_max, 1) * elapsed ); + BOOST_REQUIRE( next.price2 == twap_reference::price_fp(1, init_max) * elapsed ); + BOOST_REQUIRE( (next.price1 >> 128) != 0 ); // beyond 128 bits, as designed for + const twap::u128 average1 = twap::average_price( + twap::difference(twap_testing::to_cumulative(next.price1), twap_testing::to_cumulative(row.price1)), + uint64_t(elapsed) ); + BOOST_REQUIRE( average1 == 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, 1) ); +} 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. @@ -1699,7 +1891,7 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { for (const auto& a : abi.actions) actions.insert(a.name.to_string()); const std::set expected_actions{ "addliquidity", "changefee", "close", "closeext", "exchange", "indexpair", - "inittoken", "open", "openext", "remliquidity", "transfer", "withdraw" }; + "inittoken", "open", "openext", "remliquidity", "sync", "transfer", "withdraw" }; BOOST_REQUIRE( actions == expected_actions ); using field_list = std::vector>; @@ -1720,15 +1912,23 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { const field_list currency_stats_fields{ {"supply", "asset"}, {"max_supply", "asset"}, {"issuer", "name"}, {"pool1", "extended_asset"}, {"pool2", "extended_asset"}, {"fee", "int32"}, {"fee_contract", "name"} }; + 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{ + {"pair", "symbol_code"}, {"price1", "cumulative_price"}, {"price2", "cumulative_price"}, {"last_update", "time_point"} }; 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 ); std::set tables; for (const auto& t : abi.tables) tables.insert(t.name); const std::set expected_tables{ - "account", "accounts", "currency_stats", "evodexaccount", "evodexacnts", "evoindex", "index_struct", "stat" }; + "account", "accounts", "currency_stats", "evodexaccount", "evodexacnts", "evoindex", "index_struct", + "priceaccum", "stat" }; BOOST_REQUIRE( tables == expected_tables ); } FC_LOG_AND_RETHROW() 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 From ccd9ea266a61334c850c47c10fbf76199c966855 Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Fri, 11 Sep 2026 11:05:37 -0400 Subject: [PATCH 09/37] swap: move every table to kv::table / kv::scoped_table The five tables leave the multi_index compatibility wrapper for the native KV API, with explicit key structs and payer-first mutations: accounts scoped_table by owner, key = symbol code (bytes unchanged, so wevotethefee's foreign multi_index read still works) evodexacnts scoped_table by user, keyed by the deposited token's extended symbol -- the surrogate id, available_primary_key, make128key and the uint128 secondary index all go; a deposit lookup is one primary get stat unscoped table keyed by the pair's symbol code (the old scope duplicated the primary key) evoindex unscoped table keyed by the pair identity itself, the two legs in canonical (lower contract, symbol first) order -- the checksum256 hash, make256key and its secondary index all go; a duplicate pair is a plain emplace conflict priceaccum unscoped table keyed by the pair's symbol code; the redundant `pair` field leaves the row Table entries in the ABI now carry key_names/key_types/table_id, so an explorer can decode the composite keys as named fields. The test fixture reads deposits by extended symbol through a raw KV reader for composite keys, the indextable case pins the canonical four-word key (and the absence of the reversed one), and the ABI pin covers every table's row type and key layout. Co-Authored-By: Claude Fable 5.1 Change-Id: Ibce6a77e0da625217537923ab2fe23a7a8f42b4f --- .../include/sysio.swap/sysio.swap.hpp | 110 +++++++----- contracts/sysio.swap/sysio.swap.abi | 142 ++++++++------- contracts/sysio.swap/sysio.swap.cpp | 142 +++++++-------- contracts/sysio.swap/sysio.swap.wasm | Bin 56570 -> 35176 bytes contracts/sysio.swap/token_functions.cpp | 43 +++-- contracts/tests/sysio.swap_tests.cpp | 164 ++++++++++++------ 6 files changed, 333 insertions(+), 268 deletions(-) diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index d6e435b73a..3dcb99a9ba 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include #include @@ -17,7 +19,7 @@ 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 int64_t INIT_MAX = 1000000000000000; // 10^15 const int ADD_LIQUIDITY_FEE = 1; const int DEFAULT_FEE = 10; /// Fees are expressed in units of 1/FEE_DENOMINATOR of the traded amount. @@ -37,8 +39,8 @@ namespace sysio { static constexpr uint64_t MIN_SWAP_FEE = 1; using contract::contract; - [[sysio::action]] void inittoken(name user, symbol new_symbol, - extended_asset initial_pool1, extended_asset initial_pool2, + [[sysio::action]] void inittoken(name user, symbol new_symbol, + extended_asset initial_pool1, extended_asset initial_pool2, int initial_fee, name fee_contract); [[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); @@ -53,7 +55,7 @@ namespace sysio { /// 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, + [[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 ); @@ -61,35 +63,64 @@ namespace sysio { private: - struct [[sysio::table]] account { - asset balance; - uint64_t primary_key()const { return balance.symbol.code().raw(); } + // --- 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)) }; - struct [[sysio::table]] evodexaccount { - extended_asset balance; - uint64_t id; - uint64_t primary_key()const { return id; } - uint128_t secondary_key()const { return - make128key(balance.contract.value, balance.quantity.symbol.raw() ); } + /// 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)) }; - struct [[sysio::table]] currency_stats { - asset supply; - asset max_supply; - name issuer; - extended_asset pool1; - extended_asset pool2; - int fee; - name fee_contract; - uint64_t primary_key()const { return supply.symbol.code().raw(); } + /// 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)) + }; + + // --- Rows --- + + struct [[sysio::table("accounts")]] account { + asset balance; + SYSLIB_SERIALIZE(account, (balance)) }; - struct [[sysio::table]] index_struct{ + 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_contract; + SYSLIB_SERIALIZE(currency_stats, (supply)(max_supply)(issuer)(pool1)(pool2)(fee)(fee_contract)) + }; + + struct [[sysio::table("evoindex")]] pair_index { symbol evo_symbol; - checksum256 id_256; - uint64_t primary_key()const { return evo_symbol.code().raw(); } - checksum256 secondary_key()const { return id_256; } + SYSLIB_SERIALIZE(pair_index, (evo_symbol)) }; /// Cumulative-price accumulators for a pair (sysio.opp.common/twap.hpp). @@ -99,25 +130,24 @@ namespace sysio { /// 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 { - symbol_code pair; sysio::opp::twap::cumulative_price price1; sysio::opp::twap::cumulative_price price2; time_point last_update; - uint64_t primary_key()const { return pair.raw(); } + SYSLIB_SERIALIZE(price_accumulator, (price1)(price2)(last_update)) }; - typedef sysio::multi_index< "evodexacnts"_n, evodexaccount, - indexed_by<"extended"_n, const_mem_fun> > evodexacnts; - typedef sysio::multi_index< "stat"_n, currency_stats > stats; - typedef sysio::multi_index< "evoindex"_n, index_struct, - indexed_by<"extended"_n, const_mem_fun> > evoindexes; - typedef sysio::multi_index< "accounts"_n, account > accounts; - typedef sysio::multi_index< "priceaccum"_n, price_accumulator > priceaccums; + // --- Tables --- + + 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>; - static uint128_t make128key(uint64_t a, uint64_t b); - static checksum256 make256key(uint64_t a, uint64_t b, uint64_t c, uint64_t d); + /// 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); 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); @@ -144,4 +174,4 @@ namespace sysio { void add_balance( const name& owner, const asset& value, const name& ram_payer ); void sub_balance( const name& owner, const asset& value ); }; -} \ No newline at end of file +} diff --git a/contracts/sysio.swap/sysio.swap.abi b/contracts/sysio.swap/sysio.swap.abi index ebab3fdcd8..d6e0de1187 100644 --- a/contracts/sysio.swap/sysio.swap.abi +++ b/contracts/sysio.swap/sysio.swap.abi @@ -13,6 +13,16 @@ } ] }, + { + "name": "account_key", + "base": "", + "fields": [ + { + "name": "symbol_code", + "type": "uint64" + } + ] + }, { "name": "addliquidity", "base": "", @@ -134,16 +144,12 @@ ] }, { - "name": "evodexaccount", + "name": "evodex_account", "base": "", "fields": [ { "name": "balance", "type": "extended_asset" - }, - { - "name": "id", - "type": "uint64" } ] }, @@ -184,16 +190,16 @@ ] }, { - "name": "index_struct", + "name": "extended_symbol_key", "base": "", "fields": [ { - "name": "evo_symbol", - "type": "symbol" + "name": "contract", + "type": "name" }, { - "name": "id_256", - "type": "checksum256" + "name": "symbol", + "type": "uint64" } ] }, @@ -278,13 +284,51 @@ ] }, { - "name": "price_accumulator", + "name": "pair_identity_key", "base": "", "fields": [ { - "name": "pair", - "type": "symbol_code" + "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": "price_accumulator", + "base": "", + "fields": [ { "name": "price1", "type": "cumulative_price" @@ -444,90 +488,44 @@ } ], "tables": [ - { - "name": "account", - "type": "account", - "index_type": "i64", - "key_names": [], - "key_types": [], - "table_id": 15140 - }, { "name": "accounts", "type": "account", "index_type": "i64", - "key_names": ["scope","primary_key"], + "key_names": ["scope","symbol_code"], "key_types": ["name","uint64"], "table_id": 25660 }, - { - "name": "currency_stats", - "type": "currency_stats", - "index_type": "i64", - "key_names": [], - "key_types": [], - "table_id": 18569 - }, - { - "name": "evodexaccount", - "type": "evodexaccount", - "index_type": "i64", - "key_names": [], - "key_types": [], - "table_id": 29959 - }, { "name": "evodexacnts", - "type": "evodexaccount", + "type": "evodex_account", "index_type": "i64", - "key_names": ["scope","primary_key"], - "key_types": ["name","uint64"], - "table_id": 63234, - "secondary_indexes": [ - { - "name": "extended", - "key_type": "uint128", - "table_id": 55555 - } - ] + "key_names": ["scope","contract","symbol"], + "key_types": ["name","name","uint64"], + "table_id": 63234 }, { "name": "evoindex", - "type": "index_struct", + "type": "pair_index", "index_type": "i64", - "key_names": ["scope","primary_key"], - "key_types": ["name","uint64"], - "table_id": 41326, - "secondary_indexes": [ - { - "name": "extended", - "key_type": "checksum256", - "table_id": 12143 - } - ] - }, - { - "name": "index_struct", - "type": "index_struct", - "index_type": "i64", - "key_names": [], - "key_types": [], - "table_id": 44724 + "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": ["scope","primary_key"], - "key_types": ["name","uint64"], + "key_names": ["symbol_code"], + "key_types": ["uint64"], "table_id": 54664 }, { "name": "stat", "type": "currency_stats", "index_type": "i64", - "key_names": ["scope","primary_key"], - "key_types": ["name","uint64"], + "key_names": ["symbol_code"], + "key_types": ["uint64"], "table_id": 4264 } ], diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index 8b3bb6865c..db254d3d7f 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -1,5 +1,6 @@ #include #include +#include namespace sysio { @@ -7,29 +8,24 @@ void swap::openext( const name& user, const name& payer, const extended_symbol& check( is_account( user ), "user account does not exist" ); require_auth( payer ); evodexacnts acnts( get_self(), user.value ); - auto index = acnts.get_index<"extended"_n>(); - const auto& acnt_balance = index.find( - make128key(ext_symbol.get_contract().value, ext_symbol.get_symbol().raw()) ); - if( acnt_balance == index.end() ) { - acnts.emplace( payer, [&]( auto& a ){ - a.balance = extended_asset{0, ext_symbol}; - a.id = acnts.available_primary_key(); - }); + 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 ); - auto index = acnts.get_index<"extended"_n>(); - const auto& acnt_balance = index.find( make128key(ext_symbol.get_contract().value, ext_symbol.get_symbol().raw()) ); - check( acnt_balance != index.end(), "User does not have such token" ); - auto ext_balance = acnt_balance->balance; + 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(); } - index.erase( acnt_balance ); + acnts.erase( key ); } void swap::ontransfer(name from, name to, asset quantity, string memo) { @@ -100,9 +96,10 @@ int64_t swap::compute(int64_t x, int64_t y, int64_t z, int fee) { 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(), to_add.symbol.code().raw() ); - const auto& token = statstable.find( to_add.symbol.code().raw() ); - check ( token != statstable.end(), "pair token does not exist" ); + stats statstable( get_self() ); + const pair_key key{ to_add.symbol.code().raw() }; + const auto token = statstable.try_get( key ); + check ( token.has_value(), "pair token does not exist" ); auto A = token-> supply.amount; auto P1 = token-> pool1.quantity.amount; auto P2 = token-> pool2.quantity.amount; @@ -122,12 +119,12 @@ void swap::add_signed_liq(name user, asset to_add, bool is_buying, (to_add.amount > 0)? add_balance(user, to_add, user) : sub_balance(user, -to_add); if (token->fee_contract) require_recipient(token->fee_contract); update_price_accumulators(*token); - statstable.modify( token, same_payer, [&]( auto& a ) { + statstable.modify( name{}, key, [&]( auto& a ) { a.supply += to_add; a.pool1 += to_pay1; a.pool2 += to_pay2; }); - check(token->supply.amount != 0, "the pool cannot be left empty"); + check(token->supply.amount + to_add.amount != 0, "the pool cannot be left empty"); } void swap::exchange( name user, symbol_code pair_token, @@ -142,9 +139,10 @@ void swap::exchange( name user, symbol_code pair_token, extended_asset swap::process_exch(symbol_code pair_token, extended_asset ext_asset_in, asset min_expected){ - stats statstable( get_self(), pair_token.raw() ); - const auto token = statstable.find( pair_token.raw() ); - check ( token != statstable.end(), "pair token does not exist" ); + 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" ); bool in_first; if ((token->pool1.get_extended_symbol() == ext_asset_in.get_extended_symbol()) && (token->pool2.quantity.symbol == min_expected.symbol)) { @@ -188,7 +186,7 @@ extended_asset swap::process_exch(symbol_code pair_token, ext_asset_out = -ext_asset1; } update_price_accumulators(*token); - statstable.modify( token, same_payer, [&]( auto& a ) { + statstable.modify( name{}, key, [&]( auto& a ) { a.pool1 += ext_asset1; a.pool2 += ext_asset2; }); @@ -223,27 +221,24 @@ extended_asset initial_pool2, int initial_fee, name fee_contract) auto new_token = asset{int64_t(geometric_mean), new_symbol}; check( initial_pool1.get_extended_symbol() != initial_pool2.get_extended_symbol(), "extended symbols must be different"); - stats statstable( get_self(), new_symbol.code().raw() ); - const auto& token = statstable.find( new_symbol.code().raw() ); - check ( token == statstable.end(), "token symbol already exists" ); + stats statstable( get_self() ); + const pair_key key{ new_symbol.code().raw() }; + check ( !statstable.contains( key ), "token symbol already exists" ); check( initial_fee == DEFAULT_FEE, "initial_fee must be 10"); check( fee_contract == "wevotethefee"_n, "fee_contract must be wevotethefee"); - statstable.emplace( user, [&]( auto& a ) { - a.supply = new_token; - a.max_supply = asset{MAX,new_symbol}; - a.issuer = get_self(); - a.pool1 = initial_pool1; - a.pool2 = initial_pool2; - a.fee = initial_fee; - a.fee_contract = fee_contract; + 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_contract = fee_contract, } ); - priceaccums accums( get_self(), get_self().value ); - accums.emplace( user, [&]( auto& a ) { - a.pair = new_symbol.code(); - a.last_update = current_time_point(); - } ); + priceaccums accums( get_self() ); + accums.emplace( user, key, price_accumulator{ .last_update = current_time_point() } ); placeindex(user, new_symbol, initial_pool1, initial_pool2 ); add_balance(user, new_token, user); @@ -252,38 +247,29 @@ extended_asset initial_pool2, int initial_fee, name fee_contract) } void swap::indexpair(name user, symbol evo_symbol) { - stats statstable( get_self(), evo_symbol.code().raw() ); - const auto& token = statstable.find( evo_symbol.code().raw() ); - check ( token != statstable.end(), "token symbol does not exist" ); - auto pool1 = token->pool1; - auto pool2 = token->pool2; - placeindex(user, evo_symbol, pool1, pool2); + stats statstable( get_self() ); + const auto token = statstable.get( pair_key{ evo_symbol.code().raw() }, "token symbol does not exist" ); + placeindex(user, evo_symbol, token.pool1, token.pool2); } void swap::placeindex(name user, symbol evo_symbol, extended_asset pool1, extended_asset pool2 ) { - auto id_256 = make256key(pool1.contract.value, pool1.quantity.symbol.raw(), - pool2.contract.value, pool2.quantity.symbol.raw()); - evoindexes indextable( get_self(), get_self().value ); - auto index = indextable.get_index<"extended"_n>(); - const auto& info = index.find( id_256 ); - check( info == index.end(), "the pool is already indexed"); - indextable.emplace( user, [&]( auto& a ){ - a.evo_symbol = evo_symbol; - a.id_256 = id_256; - }); + 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(), get_self().value ); - const auto accum = accums.find( token.supply.symbol.code().raw() ); - check( accum != accums.end(), "price accumulator does not exist" ); + 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( accum, same_payer, [&]( auto& a ) { + 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; @@ -291,45 +277,45 @@ void swap::update_price_accumulators(const currency_stats& token) { } void swap::sync(symbol_code pair_token) { - stats statstable( get_self(), pair_token.raw() ); - const auto token = statstable.find( pair_token.raw() ); - check ( token != statstable.end(), "pair token does not exist" ); + 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); } void swap::changefee(symbol_code pair_token, int newfee) { - stats statstable( get_self(), pair_token.raw() ); - const auto& token = statstable.find( pair_token.raw() ); - check ( token != statstable.end(), "pair token does not exist" ); + 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_contract); check( 0 <= newfee && newfee <= MAX_FEE, "fee out of range" ); - statstable.modify( token, same_payer, [&]( auto& a ) { + statstable.modify( name{}, key, [&]( auto& a ) { a.fee = newfee; } ); } -uint128_t swap::make128key(uint64_t a, uint64_t b) { - uint128_t aa = a; - uint128_t bb = b; - return (aa << 64) + bb; +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() }; } -checksum256 swap::make256key(uint64_t a, uint64_t b, uint64_t c, uint64_t d) { - if (make128key(a,b) < make128key(c,d)) - return checksum256::make_from_word_sequence(a,b,c,d); - else - return checksum256::make_from_word_sequence(c,d,a,b); +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 ); - auto index = acnts.get_index<"extended"_n>(); - const auto& acnt_balance = index.find( make128key(to_add.contract.value, to_add.quantity.symbol.raw() ) ); - check( acnt_balance != index.end(), "extended_symbol not registered for this user,\ + 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"); - index.modify( acnt_balance, same_payer, [&]( auto& a ) { + acnts.modify( name{}, key, [&]( auto& a ) { a.balance += to_add; check( a.balance.quantity.amount >= 0, "insufficient funds"); }); diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 06e4335c0c84446ea88de37907140a8d7fc23e46..91dc261c74dcab25338d46dfa06da27691e650f2 100755 GIT binary patch delta 15553 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 diff --git a/contracts/sysio.swap/token_functions.cpp b/contracts/sysio.swap/token_functions.cpp index b44ccc2a0c..119dc176fd 100644 --- a/contracts/sysio.swap/token_functions.cpp +++ b/contracts/sysio.swap/token_functions.cpp @@ -8,8 +8,8 @@ void swap::transfer( const name& from, const name& to, const asset& quantity, require_auth( from ); check( is_account( to ), "to account does not exist"); auto sym = quantity.symbol.code(); - stats statstable( get_self(), sym.raw() ); - const auto& st = statstable.get( sym.raw() ); + stats statstable( get_self() ); + const auto st = statstable.get( pair_key{ sym.raw() }, "pair token does not exist" ); require_recipient( from ); require_recipient( to ); @@ -29,11 +29,13 @@ void swap::transfer( const name& from, const name& to, const asset& quantity, 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.get( value.symbol.code().raw(), "no balance object found" ); - check( from.balance.amount >= value.amount, "overdrawn balance" ); + 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( from, owner, [&]( auto& a ) { + from_acnts.modify( owner, key, [&]( auto& a ) { a.balance -= value; }); } @@ -41,13 +43,11 @@ void swap::sub_balance( const name& owner, const asset& value ) { void swap::add_balance( const name& owner, const asset& value, const name& ram_payer ) { accounts to_acnts( get_self(), owner.value ); - auto to = to_acnts.find( value.symbol.code().raw() ); - if( to == to_acnts.end() ) { - to_acnts.emplace( ram_payer, [&]( auto& a ){ - a.balance = 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( to, same_payer, [&]( auto& a ) { + to_acnts.modify( name{}, key, [&]( auto& a ) { a.balance += value; }); } @@ -60,16 +60,14 @@ void swap::open( const name& owner, const symbol& symbol, const name& ram_payer check( is_account( owner ), "owner account does not exist" ); auto sym_code_raw = symbol.code().raw(); - stats statstable( get_self(), sym_code_raw ); - const auto& st = statstable.get( sym_code_raw, "symbol does not exist" ); + 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 ); - auto it = acnts.find( sym_code_raw ); - if( it == acnts.end() ) { - acnts.emplace( ram_payer, [&]( auto& a ){ - a.balance = asset{0, symbol}; - }); + const account_key key{ sym_code_raw }; + if( !acnts.contains( key ) ) { + acnts.emplace( ram_payer, key, account{ asset{0, symbol} } ); } } @@ -77,10 +75,11 @@ void swap::close( const name& owner, const symbol& symbol ) { require_auth( owner ); accounts acnts( get_self(), owner.value ); - auto it = acnts.find( symbol.code().raw() ); - check( it != acnts.end(), "Balance row already deleted or never existed. Action won't have any effect." ); - check( it->balance.amount == 0, "Cannot close because the balance is not zero." ); - acnts.erase( it ); + 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/tests/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index e458406e73..b7b27354e4 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -265,11 +265,22 @@ class sysio_swap_tester : public tester { ); } - int64_t balance(name user, int64_t id) { - auto _balance = get_balance("sysio.swap"_n, user, "evodexacnts"_n, id, "evodexaccount" ); - return to_int(fc::json::to_string(_balance["balance"]["quantity"], - fc::time_point(fc::time_point::now() + abi_serializer_max_time) )); + // 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"], @@ -301,18 +312,9 @@ class sysio_swap_tester : public tester { wide y = wide(w.at(0)) * wide(w.at(1)) * wide(v.at(2)) * wide(v.at(2)); return x <= y; } - vector total(){ - auto EVO_value = symbol::from_string("4,EVO").to_symbol_code().value; - auto ETUSD_value = symbol::from_string("3,ETUSD").to_symbol_code().value; - int64_t total_eos = balance("alice"_n, 0) + balance("bob"_n, 0) - + system_balance(EVO_value).at(0) + system_balance(ETUSD_value).at(0); - int64_t total_voice = balance("alice"_n, 1) + balance("bob"_n, 1) - + system_balance(EVO_value).at(1); - int64_t total_tusd = balance("alice"_n, 2) + balance("bob"_n, 2) - + system_balance(ETUSD_value).at(1); - vector ans = {total_eos, total_voice, total_tusd}; - return ans; - } + // 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( "sysio.token"_n, "alice"_n, asset::from_string("461168601842738.7903 EOS") ) ); BOOST_REQUIRE_EQUAL( success(), create( "anothertoken"_n, "bob"_n, asset::from_string("461168601842738.7903 VOICE") ) ); @@ -465,6 +467,27 @@ namespace twap_reference { } } +vector sysio_swap_tester::total() { + const int64_t total_eos = balance("alice"_n, EOS4) + balance("bob"_n, EOS4) + + system_balance(EVO.value).at(0) + system_balance(ETUSD.value).at(0); + const int64_t total_voice = balance("alice"_n, VOICE4) + balance("bob"_n, VOICE4) + + system_balance(EVO.value).at(1); + const int64_t total_tusd = balance("alice"_n, TUSD2) + balance("bob"_n, TUSD2) + + system_balance(ETUSD.value).at(1); + return { total_eos, total_voice, total_tusd }; +} + +int64_t sysio_swap_tester::balance( name user, symbol sym ) { + const extended_asset ext = extend( asset(0, sym) ); + const auto data = get_kv_row( "sysio.swap"_n, "evodexacnts"_n, + { user.to_uint64_t(), ext.contract.to_uint64_t(), sym.value() } ); + BOOST_REQUIRE_MESSAGE( !data.empty(), "no deposit row for " << user << " " << 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::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) ) ); @@ -660,8 +683,8 @@ BOOST_FIXTURE_TEST_CASE( exchange_action, sysio_swap_tester ) try { vector expected_system_balance = {10000073819, 999999185797, 100000328128}; BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); - BOOST_REQUIRE_EQUAL(balance("alice"_n,0), 89999926181); - BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 1000000814203); + BOOST_REQUIRE_EQUAL(balance("alice"_n, EOS4), 89999926181); + BOOST_REQUIRE_EQUAL(balance("alice"_n, VOICE4), 1000000814203); BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 50) ); @@ -670,16 +693,16 @@ BOOST_FIXTURE_TEST_CASE( exchange_action, sysio_swap_tester ) try { expected_system_balance = {10000123826, 1000004186277, 100000828128}; BOOST_REQUIRE_EQUAL(expected_system_balance == system_balance(EVO.value), true); - BOOST_REQUIRE_EQUAL(balance("alice"_n,0), 89999876174); - BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 999995813723); + 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,0), 89999876174); - BOOST_REQUIRE_EQUAL(balance("alice"_n,1), 999995813723); + BOOST_REQUIRE_EQUAL(balance("alice"_n, EOS4), 89999876174); + BOOST_REQUIRE_EQUAL(balance("alice"_n, VOICE4), 999995813723); } FC_LOG_AND_RETHROW() @@ -706,40 +729,40 @@ BOOST_FIXTURE_TEST_CASE( increasing_poolvalue, sysio_swap_tester) try { auto old_total = total(); auto old_vec = system_balance(EVO.value); - auto old_alice_bal_0 = balance("alice"_n,0); - auto old_alice_bal_1 = balance("alice"_n,1); + 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,0) - old_alice_bal_0, -40000); - BOOST_REQUIRE_EQUAL(balance("alice"_n,1) - old_alice_bal_1, 166570); + 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, 0); - old_alice_bal_1 = balance("alice"_n, 1); + 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 EOS"), asset::from_string("10000000.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,0) - old_alice_bal_0, -2); - BOOST_REQUIRE_EQUAL(balance("alice"_n,1) - old_alice_bal_1, -4); + 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, 0); - old_alice_bal_1 = balance("alice"_n, 1); + 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 EOS"), asset::from_string("0.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, 0) - old_alice_bal_0, 0); - BOOST_REQUIRE_EQUAL(balance("alice"_n, 1) - old_alice_bal_1, 2); + 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); @@ -1263,11 +1286,18 @@ BOOST_FIXTURE_TEST_CASE( indextable, sysio_swap_tester ) try { extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.0000 EOS")), 10, "wevotethefee"_n) ); - auto table = get_balance("sysio.swap"_n, "sysio.swap"_n, "evoindex"_n, EVO.value, "index_struct"); - // make256key(anothertoken, 4,VOICE, sysio.token, 4,EOS) as little-endian - // words; upstream's constant encoded eosio.token in the third word. - BOOST_REQUIRE_EQUAL(table["id_256"], "34e996aaf9a4153000004543494f5604c7b0ea033482a60000000000534f4504"); + // 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() ); 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"), @@ -1336,7 +1366,7 @@ BOOST_FIXTURE_TEST_CASE( compute_rounding_table, sysio_swap_tester ) try { // 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[0], before[1], fee); - int64_t alice_eos = balance("alice"_n, 0), alice_voice = balance("alice"_n, 1); + 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(), @@ -1344,8 +1374,8 @@ BOOST_FIXTURE_TEST_CASE( compute_rounding_table, sysio_swap_tester ) try { auto after = system_balance(EVO.value); BOOST_REQUIRE_EQUAL( before[0] + amount, after[0] ); BOOST_REQUIRE_EQUAL( before[1] - out, after[1] ); - BOOST_REQUIRE_EQUAL( alice_eos - amount, balance("alice"_n, 0) ); - BOOST_REQUIRE_EQUAL( alice_voice + out, balance("alice"_n, 1) ); + BOOST_REQUIRE_EQUAL( alice_eos - amount, balance("alice"_n, EOS4) ); + BOOST_REQUIRE_EQUAL( alice_voice + out, balance("alice"_n, VOICE4) ); // VOICE -> EOS before = after; @@ -1398,11 +1428,10 @@ BOOST_FIXTURE_TEST_CASE( invariants_under_random_sequences, sysio_swap_tester ) symbol lp; symbol leg1; // pool1 symbol leg2; // pool2 - int leg1_id, leg2_id; // evodexacnts row ids (openext order: EOS=0, VOICE=1, TUSD=2) }; const std::vector pools{ - { EVO, EVO4, EOS4, VOICE4, 0, 1 }, - { ETUSD, ETUSD3, EOS4, TUSD2, 0, 2 }, + { EVO, EVO4, EOS4, VOICE4 }, + { ETUSD, ETUSD3, EOS4, TUSD2 }, }; const std::vector users{ "alice"_n, "bob"_n }; // Failures the sequence is allowed to produce: every one is a guard the @@ -1438,8 +1467,8 @@ BOOST_FIXTURE_TEST_CASE( invariants_under_random_sequences, sysio_swap_tester ) 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_id); - const int64_t user_leg2 = balance(user, pool.leg2_id); + 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; @@ -1617,7 +1646,7 @@ BOOST_FIXTURE_TEST_CASE( precision_extremes, sysio_swap_tester ) try { // 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, 1); + const int64_t amount = balance("alice"_n, VOICE4); BOOST_REQUIRE_EQUAL( asset::max_amount, before[1] + amount ); BOOST_REQUIRE_EQUAL( 1, model::gross(amount, before[1], before[0]) ); const int64_t out = reference::receive(amount, before[1], before[0], 10); @@ -1625,7 +1654,7 @@ BOOST_FIXTURE_TEST_CASE( precision_extremes, sysio_swap_tester ) try { BOOST_REQUIRE_EQUAL( out, settle_swap("alice"_n, EVO, asset(amount, VOICE4), EOS4, 0) ); const auto after = system_balance(EVO.value); BOOST_REQUIRE_EQUAL( asset::max_amount, after[1] ); - BOOST_REQUIRE_EQUAL( 0, balance("alice"_n, 1) ); + BOOST_REQUIRE_EQUAL( 0, balance("alice"_n, VOICE4) ); } // Whale pool: the smallest trade, then the largest alice can make. @@ -1640,7 +1669,7 @@ BOOST_FIXTURE_TEST_CASE( precision_extremes, sysio_swap_tester ) try { // 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, 0); + const int64_t amount = balance("alice"_n, EOS4); BOOST_REQUIRE_EQUAL( 2, system_balance(EVO.value)[0] ); BOOST_REQUIRE_EQUAL( asset::max_amount - 2, before[0] + amount ); const int64_t out = reference::receive(amount, before[0], before[1], 10); @@ -1650,15 +1679,15 @@ BOOST_FIXTURE_TEST_CASE( precision_extremes, sysio_swap_tester ) try { BOOST_REQUIRE_EQUAL( out, settle_swap("alice"_n, ETUSD, asset(amount, EOS4), TUSD2, 1) ); after = system_balance(ETUSD.value); BOOST_REQUIRE_EQUAL( asset::max_amount - 2, after[0] ); - BOOST_REQUIRE_EQUAL( 0, balance("alice"_n, 0) ); + 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, 2); + const int64_t tusd = balance("alice"_n, TUSD2); const int64_t out2 = reference::receive(tusd, before[1], before[0], 10); BOOST_REQUIRE_EQUAL( out2, model::receive(tusd, before[1], before[0], 10) ); BOOST_REQUIRE_EQUAL( out2, settle_swap("alice"_n, ETUSD, asset(tusd, TUSD2), EOS4, 0) ); - BOOST_REQUIRE_EQUAL( 0, balance("alice"_n, 2) ); + BOOST_REQUIRE_EQUAL( 0, balance("alice"_n, TUSD2) ); } } FC_LOG_AND_RETHROW() @@ -1915,7 +1944,10 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { 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{ - {"pair", "symbol_code"}, {"price1", "cumulative_price"}, {"price2", "cumulative_price"}, {"last_update", "time_point"} }; + {"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 ); @@ -1923,12 +1955,32 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { 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 ); + + // 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"} } ) ); std::set tables; for (const auto& t : abi.tables) tables.insert(t.name); - const std::set expected_tables{ - "account", "accounts", "currency_stats", "evodexaccount", "evodexacnts", "evoindex", "index_struct", - "priceaccum", "stat" }; + const std::set expected_tables{ "accounts", "evodexacnts", "evoindex", "priceaccum", "stat" }; BOOST_REQUIRE( tables == expected_tables ); } FC_LOG_AND_RETHROW() From 7a3db3e4e301df85a1ab349667b1ec80c6168617 Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Fri, 11 Sep 2026 12:25:48 -0400 Subject: [PATCH 10/37] swap: replace the wevotethefee integration with a configurable fee authority The account allowed to change a pair's fee was wired to the upstream wevotethefee voting contract: inittoken accepted no other name and no initial fee but wevotethefee's 10 bps floor, and every LP-token transfer, add and remove notified it so it could weigh votes by stake. WIRE governance executes approved proposals as sysio, so the fee authority is now a plain permission holder: * a `swapconfig` singleton, set at deployment by `setconfig` under the contract's own authority, names the contract-wide fee authority (sysio); * inittoken takes `fee_authority`: an empty name adopts the configured one, any other name becomes that pair's own -- pair creation already needs the contract's authority, so an override cannot be planted by an arbitrary caller; * `initial_fee` may be anything in [0, MAX_FEE]; * changefee requires the pair's stored authority, as before; * the pair row's `fee_contract` is renamed `fee_authority`, and the require_recipient notifications go with the voter they served. The wevotethefee test contract, its CMake and fixture hooks, its test case and its fixture helpers are removed; badtoken stays for the guard tests. New cases cover the unconfigured state (pair creation needs either a configured or a named authority), per-pair authority isolation, reconfiguration binding only pairs created afterwards, and the open fee range at creation. The ABI pin gains setconfig, the swapconfig singleton and the renamed field. Co-Authored-By: Claude Fable 5.1 Change-Id: I495df05e030b7fe0b5b349e48bedc31215da307b --- contracts/sysio.swap/Commands.md | 18 +- contracts/sysio.swap/README.md | 7 +- .../include/sysio.swap/sysio.swap.hpp | 22 +- .../ricardian/sysio.swap.contracts.md | 26 +- contracts/sysio.swap/sysio.swap.abi | 37 +- contracts/sysio.swap/sysio.swap.cpp | 35 +- contracts/sysio.swap/sysio.swap.wasm | Bin 35176 -> 35886 bytes contracts/sysio.swap/token_functions.cpp | 1 - contracts/test_contracts/CMakeLists.txt | 1 - .../wevotethefee/CMakeLists.txt | 3 - .../test_contracts/wevotethefee/README.md | 20 - .../ricardian/wevotethefee.clauses.md | 6 - .../ricardian/wevotethefee.contracts.md | 92 ---- .../wevotethefee/wevotethefee.cpp | 123 ----- .../wevotethefee/wevotethefee.hpp | 66 --- contracts/tests/contracts.hpp.in | 2 - contracts/tests/sysio.swap_tests.cpp | 452 ++++++------------ 17 files changed, 253 insertions(+), 658 deletions(-) delete mode 100644 contracts/test_contracts/wevotethefee/CMakeLists.txt delete mode 100644 contracts/test_contracts/wevotethefee/README.md delete mode 100644 contracts/test_contracts/wevotethefee/ricardian/wevotethefee.clauses.md delete mode 100644 contracts/test_contracts/wevotethefee/ricardian/wevotethefee.contracts.md delete mode 100644 contracts/test_contracts/wevotethefee/wevotethefee.cpp delete mode 100644 contracts/test_contracts/wevotethefee/wevotethefee.hpp diff --git a/contracts/sysio.swap/Commands.md b/contracts/sysio.swap/Commands.md index e7ef2df505..56061cf808 100644 --- a/contracts/sysio.swap/Commands.md +++ b/contracts/sysio.swap/Commands.md @@ -29,9 +29,17 @@ 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 EOS/PESO evotoken. Set the initial liquidity, the initial fee for the trading pair and the fee controller. +Create the EOS/PESO evotoken. Set the initial liquidity, the initial fee for the trading pair (in units of 0.01%, here 0.1%) and 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 contract's authority is required alongside yours. - cleos push action evolutiondex inittoken '["YOUR_ACCOUNT", "4,EOSPESO", {"contract":"eosio.token", "quantity":"1.0000 EOS"}, {"contract":"pesocontract", "quantity":"1.0000 PESO"}, 10, "wevotethefee"]' -p YOUR_ACCOUNT + cleos push action evolutiondex inittoken '["YOUR_ACCOUNT", "4,EOSPESO", {"contract":"eosio.token", "quantity":"1.0000 EOS"}, {"contract":"pesocontract", "quantity":"1.0000 PESO"}, 10, ""]' -p YOUR_ACCOUNT -p evolutiondex + +Set the contract-wide fee authority (deployment step, the contract's own authority): + + cleos push action evolutiondex setconfig '["sysio"]' -p evolutiondex + +Change a pair's fee, signed by its fee authority: + + cleos push action evolutiondex changefee '["EOSPESO", 30]' -p sysio Check your evotokens balance: @@ -161,8 +169,4 @@ where the file addliquidity.json contains: } The same idea applies to the operations of removing liquidity and inittoken. -Typically, a graphical user interface will perform this kind of multiaction transactions. - -The fee value will be governed by the liquidity providers using the -smart contract wevotethefee. -Check the commands of wevotethefee [here](wevotethefee/README.md). \ No newline at end of file +Typically, a graphical user interface will perform this kind of multiaction transactions. \ No newline at end of file diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md index f4d5b54a3c..58c965a999 100644 --- a/contracts/sysio.swap/README.md +++ b/contracts/sysio.swap/README.md @@ -8,12 +8,7 @@ Evolutiondex follows the line initiated by Bancor and Uniswap, but with some des 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 is set at initialization of each trading pair. The contract wevotethefee will be able to control the fee value through -a voting mechanism. The liquidity providers can vote for a value between -0.1% and 1%, their vote will be weighted according to their stake. -The fee value will be set to the median of the votes. The voting tables -are updated each time a liquidity provider modifies its balance. -A tiny fee of 0.01% is charged when providing liquidity in order +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. diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index 3dcb99a9ba..d92e9a8109 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -21,7 +22,6 @@ namespace sysio { const int64_t MAX = sysio::asset::max_amount; const int64_t INIT_MAX = 1000000000000000; // 10^15 const int ADD_LIQUIDITY_FEE = 1; - const int DEFAULT_FEE = 10; /// 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 @@ -39,9 +39,16 @@ namespace sysio { static constexpr uint64_t MIN_SWAP_FEE = 1; using contract::contract; + /// Set the contract-wide fee authority: 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`. + /// Requires the contract's own authority. + [[sysio::action]] void setconfig(name fee_authority); + /// Create a pair. `initial_fee` may be anything in [0, MAX_FEE]. An empty + /// `fee_authority` adopts the configured one; a name overrides it for this pair. [[sysio::action]] void inittoken(name user, symbol new_symbol, extended_asset initial_pool1, extended_asset initial_pool2, - int initial_fee, name fee_contract); + int initial_fee, name fee_authority); [[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); @@ -97,6 +104,12 @@ namespace sysio { // --- Rows --- + /// Contract-wide configuration, set on deployment by `setconfig`. + struct [[sysio::table("swapconfig")]] swap_config { + name fee_authority; + SYSLIB_SERIALIZE(swap_config, (fee_authority)) + }; + struct [[sysio::table("accounts")]] account { asset balance; SYSLIB_SERIALIZE(account, (balance)) @@ -114,8 +127,8 @@ namespace sysio { extended_asset pool1; extended_asset pool2; int fee; - name fee_contract; - SYSLIB_SERIALIZE(currency_stats, (supply)(max_supply)(issuer)(pool1)(pool2)(fee)(fee_contract)) + name fee_authority; ///< whose signature changefee requires for this pair + SYSLIB_SERIALIZE(currency_stats, (supply)(max_supply)(issuer)(pool1)(pool2)(fee)(fee_authority)) }; struct [[sysio::table("evoindex")]] pair_index { @@ -138,6 +151,7 @@ namespace sysio { // --- 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>; diff --git a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md index d3fe3121b1..1c39ea39ac 100644 --- a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md +++ b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md @@ -75,10 +75,12 @@ 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}}, fee_contract = {{fee_contract}}. The extended assets {{initial_pool1}} and {{initial_pool2}} will be deducted from the corresponding extended balances of {{user}}. +{{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}}. 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. RAM will be deducted from {{user}}’s resources to create the necessary records. -Authorization of {{user}} is required. +Authorization of {{user}} and of the contract is required.

addliquidity

@@ -174,8 +176,21 @@ title: Change fee summary: 'Change the fee value associated to a pair' --- -The account fee_contract associated to the token {{pair_token}} authorizes -to change the fee parameter associated to the same token, to the value {{newfee}}. +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 contract-wide fee authority to {{nowrap fee_authority}}' +--- + +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. Pairs already created keep the authority they were created with. + +The authorization of the contract is required.

close

@@ -222,5 +237,4 @@ summary: 'Send {{nowrap quantity}} from {{nowrap from}} to {{nowrap to}}' (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. -(7) The account fee_contract corresponding to the token {{asset_to_symbol_code quantity}} is notified. \ No newline at end of file +(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 index d6e0de1187..7f9e3d830b 100644 --- a/contracts/sysio.swap/sysio.swap.abi +++ b/contracts/sysio.swap/sysio.swap.abi @@ -138,7 +138,7 @@ "type": "int32" }, { - "name": "fee_contract", + "name": "fee_authority", "type": "name" } ] @@ -242,7 +242,7 @@ "type": "int32" }, { - "name": "fee_contract", + "name": "fee_authority", "type": "name" } ] @@ -365,6 +365,26 @@ } ] }, + { + "name": "setconfig", + "base": "", + "fields": [ + { + "name": "fee_authority", + "type": "name" + } + ] + }, + { + "name": "swap_config", + "base": "", + "fields": [ + { + "name": "fee_authority", + "type": "name" + } + ] + }, { "name": "sync", "base": "", @@ -471,6 +491,11 @@ "type": "remliquidity", "ricardian_contract": "" }, + { + "name": "setconfig", + "type": "setconfig", + "ricardian_contract": "" + }, { "name": "sync", "type": "sync", @@ -527,6 +552,14 @@ "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 } ], "ricardian_clauses": [], diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index db254d3d7f..6cf296e2b4 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -117,7 +117,6 @@ void swap::add_signed_liq(name user, asset to_add, bool is_buying, 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); - if (token->fee_contract) require_recipient(token->fee_contract); update_price_accumulators(*token); statstable.modify( name{}, key, [&]( auto& a ) { a.supply += to_add; @@ -208,8 +207,15 @@ void swap::memoexchange(name user, extended_asset ext_asset_in, string_view deta std::make_tuple( get_self(), user, ext_asset_out.quantity, std::string(memo)) ).send(); } +void swap::setconfig(name fee_authority) { + require_auth( get_self() ); + check( is_account( fee_authority ), "fee authority account does not exist" ); + swapconfig_t config( get_self() ); + config.set( swap_config{ fee_authority }, get_self() ); +} + void swap::inittoken(name user, symbol new_symbol, extended_asset initial_pool1, -extended_asset initial_pool2, int initial_fee, name fee_contract) +extended_asset initial_pool2, int initial_fee, name fee_authority) { require_auth( user ); require_auth( get_self() ); @@ -224,17 +230,22 @@ extended_asset initial_pool2, int initial_fee, name fee_contract) stats statstable( get_self() ); const pair_key key{ new_symbol.code().raw() }; check ( !statstable.contains( key ), "token symbol already exists" ); - check( initial_fee == DEFAULT_FEE, "initial_fee must be 10"); - check( fee_contract == "wevotethefee"_n, "fee_contract must be wevotethefee"); + check( 0 <= initial_fee && initial_fee <= MAX_FEE, "fee out of range" ); + if (fee_authority == name{}) { + swapconfig_t config( get_self() ); + fee_authority = config.get( "fee authority not configured" ).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_contract = fee_contract, + .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, } ); priceaccums accums( get_self() ); @@ -288,7 +299,7 @@ void swap::changefee(symbol_code pair_token, int newfee) { 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_contract); + require_auth(token->fee_authority); check( 0 <= newfee && newfee <= MAX_FEE, "fee out of range" ); statstable.modify( name{}, key, [&]( auto& a ) { a.fee = newfee; diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 91dc261c74dcab25338d46dfa06da27691e650f2..c1656e059c0dc4fbec4e5f7e50986c2ce0a66a0f 100755 GIT binary patch delta 3711 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 YT7N+@UzW9Rw|oRRVtax?8ZSxz0enz#m;e9( diff --git a/contracts/sysio.swap/token_functions.cpp b/contracts/sysio.swap/token_functions.cpp index 119dc176fd..9b72503dcd 100644 --- a/contracts/sysio.swap/token_functions.cpp +++ b/contracts/sysio.swap/token_functions.cpp @@ -13,7 +13,6 @@ void swap::transfer( const name& from, const name& to, const asset& quantity, require_recipient( from ); require_recipient( to ); - if (st.fee_contract) require_recipient( st.fee_contract ); // line added to code from eosio.token check( quantity.is_valid(), "invalid quantity" ); check( quantity.amount > 0, "must transfer positive quantity" ); diff --git a/contracts/test_contracts/CMakeLists.txt b/contracts/test_contracts/CMakeLists.txt index f4e340a154..cedf3b4139 100644 --- a/contracts/test_contracts/CMakeLists.txt +++ b/contracts/test_contracts/CMakeLists.txt @@ -3,4 +3,3 @@ add_subdirectory(sendinline) add_subdirectory(reenter_deposit) add_subdirectory(block_transfer) add_subdirectory(badtoken) -add_subdirectory(wevotethefee) \ No newline at end of file diff --git a/contracts/test_contracts/wevotethefee/CMakeLists.txt b/contracts/test_contracts/wevotethefee/CMakeLists.txt deleted file mode 100644 index bb5536365d..0000000000 --- a/contracts/test_contracts/wevotethefee/CMakeLists.txt +++ /dev/null @@ -1,3 +0,0 @@ -if(BUILD_SYSTEM_CONTRACTS) - add_contract(wevotethefee wevotethefee wevotethefee.cpp) -endif() diff --git a/contracts/test_contracts/wevotethefee/README.md b/contracts/test_contracts/wevotethefee/README.md deleted file mode 100644 index a79d5af2e9..0000000000 --- a/contracts/test_contracts/wevotethefee/README.md +++ /dev/null @@ -1,20 +0,0 @@ -**Single action examples for the contract wevotethefee** - -Open a fee table for the evotoken EVO: - - cleos push action wevotethefee openfeetable '["YOUR_ACCOUNT", "EVO"]' -p YOUR_ACCOUNT - -Vote the fee value 0.3% for the evotoken EVO. - - cleos push action wevotethefee votefee '["YOUR_ACCOUNT", "EVO", "30"]' -p YOUR_ACCOUNT - -Remark: the possible fee values in this version are 10, 15, 20, 30, 50, 75, 100. -Values less than 10 or greater than 100 will be rejected. Intermediate values will be rounded upwards. In future versions, other values might be possible as well. - -The fee value will automatically update to the median of the current votes -each time a vote is entered, a voter's pool token balance is modified or -a vote is closed. - -Close your vote for the evotoken EVO: - - cleos push action wevotethefee closevote '["YOUR_ACCOUNT", "EVO"]' -p YOUR_ACCOUNT diff --git a/contracts/test_contracts/wevotethefee/ricardian/wevotethefee.clauses.md b/contracts/test_contracts/wevotethefee/ricardian/wevotethefee.clauses.md deleted file mode 100644 index 0693ac577a..0000000000 --- a/contracts/test_contracts/wevotethefee/ricardian/wevotethefee.clauses.md +++ /dev/null @@ -1,6 +0,0 @@ -

UserAgreement

- -The contract wevotethefee is designed to govern the fee values of trading pairs from the contract evolutiondex. -Users in possession of pool tokens can vote for one of the following values: -10,15,20,30,50,75,100. One unit of fee value is equal to 0.01%. -The fee value set by wevotethefee will be equal to the weighted median of the votes. The weight of each vote is the corresponding balance of the pool token. Note: evolutiondex notifies this contract each time a user's pool token balance is modified. \ No newline at end of file diff --git a/contracts/test_contracts/wevotethefee/ricardian/wevotethefee.contracts.md b/contracts/test_contracts/wevotethefee/ricardian/wevotethefee.contracts.md deleted file mode 100644 index e47183e1e4..0000000000 --- a/contracts/test_contracts/wevotethefee/ricardian/wevotethefee.contracts.md +++ /dev/null @@ -1,92 +0,0 @@ -

openfeetable

- ---- -spec_version: "0.2.0" -title: Open fee table -summary: 'Open a fee table for a given pair token' ---- - -{{user}} initializes a table for registering votes for the pair token -{{pair_token}} of the contract evolutiondex. - -The authorization of {{user}} is required, who will pay for the RAM required. - - -

closefeetable

- ---- -spec_version: "0.2.0" -title: Close fee table -summary: 'Close a fee table for a given pair token' ---- - -This action closes the fee table corresponding to {{pair_token}}. It will fail if the table is storing at least one vote. -In case it succeeds, the corresponding RAM will be liberated. - - -

votefee

- ---- -spec_version: "0.2.0" -title: Vote fee -summary: 'Vote fee value for a specific pair token' ---- - -{{user}} votes a fee value for {{pair_token}} of evolutiondex. The exact value voted will be equal to the least of the numbers 10,15,20,30,50,75,100 that is greater than or equal to {{fee_voted}}. Values less than 10 or greater than 100 will be rejected. One unit of fee value is equal to 0.01%. - -The authorization of {{user}} is required, who will pay for the RAM required. - -

closevote

- ---- -spec_version: "0.2.0" -title: Close vote -summary: 'Close vote for a specific pair token' ---- - -{{user}} closes its vote corresponding to {{pair_token}} of evolutiondex. -The corresponding RAM will be liberated. - -The authorization of {{user}} is required. - - -

updatefee

- ---- -spec_version: "0.2.0" -title: Update fee -summary: 'Updates the fee value of a specific pair token' ---- - -This action executes the action changefee from evolutiondex with inputs {{pair_token}} and a fee value that is computed as the weighted median of the current votes in the corresponding fee table. The weights of the votes are the balances of the pair token of each voter. In the current version, it is always unnecessary to run this action, since it is automatically called by other actions. - -

onaddliquidity

- ---- -spec_version: "0.2.0" -title: On add liquidity -summary: 'Updates the weight of a vote when adding liquidity' ---- - -When evolutiondex notifies that {{user}} has added liquidity to the pair token {{asset_to_symbol_code to_buy}}, the weight of the vote of {{user}} is updated accordingly. - -

onremliquidity

- ---- -spec_version: "0.2.0" -title: On remove liquidity -summary: 'Updates the weight of a vote when removing liquidity' ---- - -When evolutiondex notifies that {{user}} has removed liquidity from the pair token {{asset_to_symbol_code to_sell}}, the weight of the vote of {{user}} is updated accordingly. - - -

ontransfer

- ---- -spec_version: "0.2.0" -title: On transfer -summary: 'Updates the weight of a vote on a transfer by the contract evolutiondex' ---- - -When evolutiondex notifies that {{from}} has transferred {{quantity}} to {{to}}, the weights of the votes of {{from}} and {{to}} are updated accordingly. \ No newline at end of file diff --git a/contracts/test_contracts/wevotethefee/wevotethefee.cpp b/contracts/test_contracts/wevotethefee/wevotethefee.cpp deleted file mode 100644 index 1e872ca4cc..0000000000 --- a/contracts/test_contracts/wevotethefee/wevotethefee.cpp +++ /dev/null @@ -1,123 +0,0 @@ -#include "wevotethefee.hpp" - -int wevotethefee::median(symbol_code pair_token){ - feetables tables( get_self(), pair_token.raw() ); - auto table = tables.find( pair_token.raw()); - check( table != tables.end(), "fee table nonexistent, run openfeetable" ); - auto votes = table->votes; - vector partial_sum_vec(votes.size()); - partial_sum(votes.begin(), votes.end(), partial_sum_vec.begin()); - int64_t sum = partial_sum_vec.back(); - if (sum == 0) return FEE_VECTOR.at(DEFAULT_FEE_INDEX); - auto it = lower_bound(partial_sum_vec.begin(), partial_sum_vec.end(), sum / 2); - auto index = it - partial_sum_vec.begin(); - check( index < FEE_VECTOR.size(), "invalid index" ); // should always pass - return FEE_VECTOR.at(index); -} - -void wevotethefee::updatefee(symbol_code pair_token) { - int new_fee = median(pair_token); - action(permission_level{ get_self(), "active"_n }, - "sysio.swap"_n, "changefee"_n, - make_tuple( pair_token, new_fee )).send(); -} - -void wevotethefee::onaddliquidity(name user, asset to_buy, asset max_asset1, asset max_asset2){ - add_balance(user, to_buy, true); -} - -void wevotethefee::onremliquidity(name user, asset to_sell, asset min_asset1, asset min_asset2){ - add_balance(user, -to_sell, true); -} - -void wevotethefee::ontransfer(const name& from, const name& to, const asset& quantity, const string& memo ){ - add_balance(from, -quantity, false); - add_balance(to, quantity, true); -}; - -asset wevotethefee::bring_balance(name user, symbol_code pair_token) { - accounts table( "sysio.swap"_n, user.value ); - const auto& user_balance = table.find( pair_token.raw() ); - check ( user_balance != table.end(), "pair_token balance does not exist" ); - return user_balance->balance; -} - -void wevotethefee::votefee(name user, symbol_code pair_token, int fee_voted){ - check( (FEE_VECTOR.at(MIN_FEE_INDEX) <= fee_voted) && - (fee_voted <= FEE_VECTOR.at(MAX_FEE_INDEX)), "only values between 10 and 100 are allowed"); - int fee_index_voted = get_index(fee_voted); - require_auth(user); - feeaccounts acnts( get_self(), user.value ); - auto acnt = acnts.find( pair_token.raw()); - auto balance = bring_balance(user, pair_token); - if ( acnt == acnts.end() ) { - acnts.emplace( user, [&]( auto& a ){ - a.pair_token = pair_token; - a.fee_index_voted = fee_index_voted; - }); - } else { - addvote(pair_token, acnt->fee_index_voted, -balance.amount, false); - acnts.modify( acnt, user, [&]( auto& a ){ - a.fee_index_voted = fee_index_voted; - }); - } - addvote(pair_token, fee_index_voted, balance.amount, true); -} - -void wevotethefee::closevote(name user, symbol_code pair_token) { - require_auth(user); - feeaccounts acnts( get_self(), user.value ); - auto acnt = acnts.find( pair_token.raw()); - check( acnt != acnts.end(), "user is not voting" ); - auto balance = bring_balance(user, pair_token); - addvote( pair_token, acnt->fee_index_voted, -balance.amount, true ); - acnts.erase(acnt); -} - -void wevotethefee::closefeetable(symbol_code pair_token) { - feetables tables( get_self(), pair_token.raw() ); - auto table = tables.find( pair_token.raw()); - check( table != tables.end(), "table does not exist" ); - auto votes = table->votes; - auto is_empty = all_of(votes.begin(), votes.end(), - [](const int & votes_number){return votes_number == 0;}); - check( is_empty, "table of votes is not empty"); - tables.erase(table); -} - -void wevotethefee::openfeetable(name user, symbol_code pair_token) { - feetables tables( get_self(), pair_token.raw() ); - auto table = tables.find( pair_token.raw()); - check( table == tables.end(), "already opened" ); - vector zeros( FEE_VECTOR.size()); - tables.emplace( user, [&]( auto& a ){ - a.pair_token = pair_token; - a.votes = zeros; - }); -} - -void wevotethefee::add_balance(name user, asset to_add, bool need_update) { - feeaccounts acnts( get_self(), user.value ); - auto acnt = acnts.find( to_add.symbol.code().raw()); - if (acnt != acnts.end()) { - addvote( acnt->pair_token, acnt->fee_index_voted, to_add.amount, need_update); - } -} - -void wevotethefee::addvote(symbol_code pair_token, int fee_index, int64_t amount, - bool need_update) { - feetables tables( get_self(), pair_token.raw() ); - auto table = tables.find( pair_token.raw()); - check( table != tables.end(), "fee table nonexistent, run openfeetable" ); - tables.modify(table, same_payer, [&]( auto& a ){ - check( (0 <= fee_index) && (fee_index < a.votes.size()), "invalid fee_index"); // should always pass - a.votes.at(fee_index) += amount; - }); - if (need_update) updatefee( pair_token ); -} - -int wevotethefee::get_index(int fee_value){ - auto it = lower_bound(FEE_VECTOR.begin(), FEE_VECTOR.end(), fee_value); - check( it < FEE_VECTOR.end(), "invalid iterator" ); // should always pass - return it - FEE_VECTOR.begin(); -} \ No newline at end of file diff --git a/contracts/test_contracts/wevotethefee/wevotethefee.hpp b/contracts/test_contracts/wevotethefee/wevotethefee.hpp deleted file mode 100644 index 2f39b839ca..0000000000 --- a/contracts/test_contracts/wevotethefee/wevotethefee.hpp +++ /dev/null @@ -1,66 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -using namespace sysio; -using namespace std; - -class [[sysio::contract("wevotethefee")]] wevotethefee : public contract { - public: - - using contract::contract; - [[sysio::action]] void votefee(name user, symbol_code pair_token, int fee_voted); - [[sysio::action]] void openfeetable(name user, symbol_code pair_token); - [[sysio::action]] void closevote(name user, symbol_code pair_token); - [[sysio::action]] void closefeetable(symbol_code pair_token); - [[sysio::action]] void updatefee(symbol_code pair_token); - [[sysio::on_notify("sysio.swap::addliquidity")]] void onaddliquidity(name user, asset to_buy, - asset max_asset1, asset max_asset2); - [[sysio::on_notify("sysio.swap::remliquidity")]] void onremliquidity(name user, asset to_sell, - asset min_asset1, asset min_asset2); - [[sysio::on_notify("sysio.swap::transfer")]] void ontransfer(const name& from, const name& to, - const asset& quantity, const string& memo ); - - private: - - static constexpr std::array FEE_VECTOR{1,2,3,5,7,10,15,20,30,50,75,100,150,200,300}; - static const int DEFAULT_FEE_INDEX = 5; - static const int MIN_FEE_INDEX = 5; - static const int MAX_FEE_INDEX = 11; - static_assert( (0 <= DEFAULT_FEE_INDEX) && (DEFAULT_FEE_INDEX < FEE_VECTOR.size() ), "invalid index" ); - static_assert( (0 <= MIN_FEE_INDEX) && (MIN_FEE_INDEX < FEE_VECTOR.size() ), "invalid index" ); - static_assert( (0 <= MAX_FEE_INDEX) && (MAX_FEE_INDEX < FEE_VECTOR.size() ), "invalid index" ); - static_assert( MIN_FEE_INDEX < MAX_FEE_INDEX, "min_fee must be smaller than max_fee" ); - - int median(symbol_code pair_token); - int get_index(int number); - void addvote(symbol_code pair_token, int fee_index, int64_t amount, bool need_update); - void add_balance(name user, asset to_add, bool need_update); - asset bring_balance(name user, symbol_code pair_token); - - struct [[sysio::table]] feeaccount { - symbol_code pair_token; - int fee_index_voted; - uint64_t primary_key()const { return pair_token.raw(); } - }; - - struct [[sysio::table]] feetable { - symbol_code pair_token; - vector votes; - uint64_t primary_key()const { return pair_token.raw(); } - }; - - typedef sysio::multi_index<"feeaccount"_n, feeaccount> feeaccounts; - typedef sysio::multi_index<"feetable"_n, feetable> feetables; - - struct [[sysio::table]] account { - asset balance; - uint64_t primary_key()const { return balance.symbol.code().raw(); } - }; - typedef sysio::multi_index< "accounts"_n, account > accounts; -}; diff --git a/contracts/tests/contracts.hpp.in b/contracts/tests/contracts.hpp.in index 4d4470d165..4dadac1045 100644 --- a/contracts/tests/contracts.hpp.in +++ b/contracts/tests/contracts.hpp.in @@ -44,8 +44,6 @@ struct contracts { 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 wevotethefee_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/test_contracts/wevotethefee/wevotethefee.wasm"); } - static std::vector wevotethefee_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/test_contracts/wevotethefee/wevotethefee.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/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index b7b27354e4..21a23b60c6 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -28,11 +28,13 @@ using mvo = fc::mutable_variant_object; class sysio_swap_tester : public tester { public: - sysio_swap_tester() { + // `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 ) { produce_blocks( 2 ); create_accounts( { "alice"_n, "bob"_n, "carol"_n, "sysio.token"_n, "sysio.swap"_n, - "wevotethefee"_n, "badtoken"_n, "anothertoken"_n } ); + "badtoken"_n, "anothertoken"_n } ); produce_blocks( 2 ); // sysio.token bills every row to the system account (ram_payer = "sysio"), @@ -55,17 +57,36 @@ class sysio_swap_tester : public tester { set_code( "badtoken"_n, contracts::util::badtoken_wasm() ); set_abi( "badtoken"_n, contracts::util::badtoken_abi().data() ); - set_code( "wevotethefee"_n, contracts::util::wevotethefee_wasm() ); - set_abi( "wevotethefee"_n, contracts::util::wevotethefee_abi().data() ); - produce_blocks(); + // Deployment's configuration step: governance executes as sysio. + if (configure) BOOST_REQUIRE_EQUAL( success(), setconfig( config::system_account_name ) ); + 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)); } + // 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, name signer = "sysio.swap"_n ) { + return push_swap_action( "setconfig"_n, { {signer, config::active_name} }, mvo() + ( "fee_authority", fee_authority ) + ); + } + 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) ); @@ -136,27 +157,21 @@ class sysio_swap_tester : public tester { ( "memo", "" ) ); } + // An empty `fee_authority` adopts the configured one (sysio in this fixture). action_result inittoken( name user, symbol new_symbol, extended_asset initial_pool1, - extended_asset initial_pool2, int initial_fee, name fee_contract){ - // inittoken bills the new stat row to `user`, so the action must carry the + extended_asset initial_pool2, int initial_fee, name fee_authority = name{} ){ + // 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. - std::vector auths{ {user, config::sysio_payer_name}, - {user, config::active_name}, - {"sysio.swap"_n, config::active_name} }; - try { - sysio::testing::base_tester::push_action( "sysio.swap"_n, "inittoken"_n, auths, mvo() + 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_pool2", initial_pool2) ("initial_fee", initial_fee) - ("fee_contract", fee_contract) - , 100); - } catch (const fc::exception& ex) { - edump((ex.to_detail_string())); - return error(ex.top_message()); - } - return success(); + ("fee_authority", fee_authority) + ); } action_result addliquidity(name user, asset to_buy, asset max_asset1, asset max_asset2) { return push_action( "sysio.swap"_n, user, "addliquidity"_n, mvo() @@ -183,8 +198,9 @@ class sysio_swap_tester : public tester { ( "min_expected", min_expected ) ); } - action_result changefee( symbol_code pair_token, int newfee ) { - return push_action( "sysio.swap"_n, "wevotethefee"_n, "changefee"_n, mvo() + // 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 ) ); @@ -235,35 +251,6 @@ class sysio_swap_tester : public tester { fc::time_point::from_iso_string(row["last_update"].as_string()).time_since_epoch().count() }; } - action_result openfeetable( name user, symbol_code pair_token ) { - return push_action( "wevotethefee"_n, user, "openfeetable"_n, mvo() - ( "user", user ) - ( "pair_token", pair_token ) - ); - } - action_result closefeetable( symbol_code pair_token ) { - return push_action( "wevotethefee"_n, "wevotethefee"_n, "closefeetable"_n, mvo() - ( "pair_token", pair_token ) - ); - } - action_result votefee( name user, symbol_code pair_token, int fee_voted ) { - return push_action( "wevotethefee"_n, user, "votefee"_n, mvo() - ( "user", user ) - ( "pair_token", pair_token ) - ( "fee_voted", fee_voted ) - ); - } - action_result closevote( name user, symbol_code pair_token ) { - return push_action( "wevotethefee"_n, user, "closevote"_n, mvo() - ( "user", user ) - ( "pair_token", pair_token ) - ); - } - action_result updatefee( name user, symbol_code pair_token ) { - return push_action( "wevotethefee"_n, user, "updatefee"_n, mvo() - ( "pair_token", pair_token ) - ); - } // 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. @@ -504,10 +491,10 @@ void sysio_swap_tester::setup_pools() { asset::from_string("168601842738.7903 EOS"), "") ); BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, extend(asset::from_string("23058430092.1369 EOS")), - extend(asset::from_string("96116860184.2738 VOICE")), 10, "wevotethefee"_n) ); + extend(asset::from_string("96116860184.2738 VOICE")), 10, name{}) ); BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, extend(asset::from_string("10000000000.0000 EOS")), - extend(asset::from_string("9911686018427.38 TUSD")), 10, "wevotethefee"_n) ); + extend(asset::from_string("9911686018427.38 TUSD")), 10, name{}) ); } BOOST_AUTO_TEST_SUITE(sysio_swap_tests) @@ -529,7 +516,7 @@ BOOST_FIXTURE_TEST_CASE( add_rem_liquidity, sysio_swap_tester ) try { inittoken( "alice"_n, EVO4, extend(asset::from_string("1000000.0000 EOS")), - extend(asset::from_string("100000000.0000 VOICE")), 10, "wevotethefee"_n); + extend(asset::from_string("100000000.0000 VOICE")), 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")); @@ -631,7 +618,7 @@ BOOST_FIXTURE_TEST_CASE( exchange_action, sysio_swap_tester ) try { transfer( "anothertoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("200000000.0000 VOICE"), ""); inittoken( "alice"_n, EVO4, extend(asset::from_string("1000000.0000 EOS")), - extend(asset::from_string("100000000.0000 VOICE")), 10, "wevotethefee"_n); + extend(asset::from_string("100000000.0000 VOICE")), 10, name{}); addliquidity( "alice"_n, asset::from_string("50.0000 EVO"), asset::from_string("5.0050 EOS"), asset::from_string("500.5000 VOICE") ); remliquidity( "alice"_n, asset::from_string("17.1872 EVO"), @@ -721,11 +708,11 @@ BOOST_FIXTURE_TEST_CASE( increasing_poolvalue, sysio_swap_tester) try { inittoken( "alice"_n, EVO4, extend(asset::from_string("23058430092.1369 EOS")), - extend(asset::from_string("96116860184.2738 VOICE")), 10, "wevotethefee"_n); + extend(asset::from_string("96116860184.2738 VOICE")), 10, name{}); inittoken( "alice"_n, ETUSD3, extend(asset::from_string("10000000000.0000 EOS")), - extend(asset::from_string("9911686018427.38 TUSD")), 10, "wevotethefee"_n); + extend(asset::from_string("9911686018427.38 TUSD")), 10, name{}); auto old_total = total(); auto old_vec = system_balance(EVO.value); @@ -832,7 +819,7 @@ BOOST_FIXTURE_TEST_CASE( memoexchange_test, sysio_swap_tester ) try { BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, extend(asset::from_string("23058430092.1369 EOS")), - extend(asset::from_string("96116860184.2738 VOICE")), 10, "wevotethefee"_n) ); + extend(asset::from_string("96116860184.2738 VOICE")), 10, name{}) ); BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), @@ -859,7 +846,7 @@ BOOST_FIXTURE_TEST_CASE( memoexchange_test, sysio_swap_tester ) try { inittoken( "alice"_n, ETUSD3, extend(asset::from_string("10000000000.0000 EOS")), - extend(asset::from_string("9911686018427.38 TUSD")), 10, "wevotethefee"_n); + extend(asset::from_string("9911686018427.38 TUSD")), 10, name{}); BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, @@ -954,57 +941,61 @@ BOOST_FIXTURE_TEST_CASE( the_other_actions, sysio_swap_tester ) try { ("user", "alice"_n) ("new_symbol", EVO4) ("initial_pool1", extend(asset::from_string("1.0000 EOS"))) ("initial_pool2", extend(asset::from_string("1.0000 ECO"))) - ("initial_fee", 1) ("fee_contract", "carol"_n) ) + ("initial_fee", 1) ("fee_authority", "carol"_n) ) ); 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 EOS"))) ("initial_pool2", extend(asset::from_string("1.0000 ECO"))) - ("initial_fee", 1) ("fee_contract", "carol"_n) ) + ("initial_fee", 1) ("fee_authority", "carol"_n) ) ); BOOST_REQUIRE_EQUAL( wasm_assert_msg("Both assets must be positive"), inittoken( "alice"_n, EVO4, extend(asset::from_string("-0.0001 EOS")), - extend(asset::from_string("0.1000 VOICE")), 10, "wevotethefee"_n) ); + extend(asset::from_string("0.1000 VOICE")), 10, name{}) ); BOOST_REQUIRE_EQUAL( wasm_assert_msg("Both assets must be positive"), inittoken( "alice"_n, EVO4, extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("-0.1000 VOICE")), 10, "wevotethefee"_n) ); + extend(asset::from_string("-0.1000 VOICE")), 10, name{}) ); BOOST_REQUIRE_EQUAL( wasm_assert_msg("Initial amounts must be less than 10^15"), inittoken( "alice"_n, EVO4, extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("100000000000.0001 VOICE")), 10, "wevotethefee"_n) ); + extend(asset::from_string("100000000000.0001 VOICE")), 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 EOS")), - extend(asset::from_string("1.0001 VOICE")), 10, "wevotethefee"_n) ); + extend(asset::from_string("1.0001 VOICE")), 10, name{}) ); BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended symbols must be different"), inittoken( "alice"_n, EVO4, extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("0.1000 EOS")), 10, "wevotethefee"_n) ); + extend(asset::from_string("0.1000 EOS")), 10, name{}) ); BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), inittoken( "alice"_n, EVO4, extend(asset::from_string("0.0003 EOS")), - extend(asset::from_string("0.1000 VOICE")), 10, "wevotethefee"_n) ); + extend(asset::from_string("0.1000 VOICE")), 10, name{}) ); BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), inittoken( "alice"_n, EVO4, extend(asset::from_string("0.0002 EOS")), - extend(asset::from_string("100000000.0000 VOICE")), 10, "wevotethefee"_n) ); + extend(asset::from_string("100000000.0000 VOICE")), 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.0001 EOS")), - extend(asset::from_string("0.100 VOICE")), 10, "wevotethefee"_n) ); + extend(asset::from_string("0.100 VOICE")), 10, name{}) ); BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("0.1000 VOICE")), 10, "wevotethefee"_n) ); + extend(asset::from_string("0.1000 VOICE")), 10, name{}) ); produce_blocks(); BOOST_REQUIRE_EQUAL( wasm_assert_msg("token symbol already exists"), inittoken( "alice"_n, EVO4, extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("0.1000 VOICE")), 10, "wevotethefee"_n) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("initial_fee must be 10"), inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("0.1000 VOICE")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee out of range"), inittoken( "alice"_n, ETUSD3, extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("0.10 TUSD")), 501, "wevotethefee"_n) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee_contract must be wevotethefee"), - inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("0.10 TUSD")), 10000, name{}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee out of range"), inittoken( "alice"_n, ETUSD3, extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("0.10 ETUSD")), 10, "alice"_n) ); + extend(asset::from_string("0.10 TUSD")), -1, name{}) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee authority account does not exist"), + inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("0.0001 EOS")), + extend(asset::from_string("0.10 TUSD")), 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 BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol not registered for this user,\ @@ -1027,232 +1018,74 @@ BOOST_FIXTURE_TEST_CASE( the_other_actions, sysio_swap_tester ) try { closeext( "alice"_n, "bob"_n, extended_symbol{EOS4, "sysio.token"_n} ) ); // CHANGEFEE - BOOST_REQUIRE_EQUAL( error("missing authority of wevotethefee"), - push_action( "sysio.swap"_n, "bob"_n, "changefee"_n, - mvo() ("pair_token", EVO) ("newfee", 50) ) - ); + 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) ); - // Notifications to fee_contract - - set_code( "wevotethefee"_n, contracts::util::badtoken_wasm() ); - set_abi( "wevotethefee"_n, contracts::util::badtoken_abi().data() ); - many_openext(); - transfer( "sysio.token"_n, "bob"_n, "sysio.swap"_n, asset::from_string("0.0004 EOS"), ""); - transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("0.04 TUSD"), "deposit to: bob"); - - BOOST_REQUIRE_EQUAL( success(), inittoken( "bob"_n, ETUSD3, - extend(asset::from_string("0.0002 EOS")), - extend(asset::from_string("0.02 TUSD")), 10, "wevotethefee"_n ) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("notification received"), - transfer( "sysio.swap"_n, "bob"_n, "alice"_n, asset::from_string("0.001 ETUSD"), "") ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("notification received"), - addliquidity( "bob"_n, asset::from_string("0.001 ETUSD"), - asset::from_string("0.0002 EOS"), asset::from_string("0.02 TUSD"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("notification received"), - remliquidity( "bob"_n, asset::from_string("0.001 ETUSD"), - asset::from_string("0.0001 EOS"), asset::from_string("0.01 TUSD"))); - } FC_LOG_AND_RETHROW() +// A fixture whose deployment step has NOT run: the fee authority is unset. +struct sysio_swap_unconfigured_tester : public sysio_swap_tester { + sysio_swap_unconfigured_tester() : sysio_swap_tester( false ) {} +}; -BOOST_FIXTURE_TEST_CASE( we_vote_the_fee, sysio_swap_tester ) try { - +BOOST_FIXTURE_TEST_CASE( pair_creation_needs_a_configured_fee_authority, sysio_swap_unconfigured_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)); - + abi_ser.set_abi( swap_abi_def(), abi_serializer::create_yield_function(abi_serializer_max_time) ); many_openext(); + many_transfer(); + // Adopting the configured authority is impossible until setconfig has run... + BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee authority not configured"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.0000 VOICE")), 10, name{}) ); + // ...but a pair that names its own authority does not need it. + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.0000 VOICE")), 10, "alice"_n) ); + BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 25, "alice"_n) ); + BOOST_REQUIRE_EQUAL( 25, pool_fee(EVO) ); + // setconfig is the contract's own call. + BOOST_REQUIRE_EQUAL( error("missing authority of sysio.swap"), setconfig( "alice"_n, "alice"_n ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee authority account does not exist"), setconfig( "natalia"_n ) ); + BOOST_REQUIRE_EQUAL( success(), setconfig( config::system_account_name ) ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.00 TUSD")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( success(), changefee(ETUSD, 25) ); +} FC_LOG_AND_RETHROW() - transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("10000000.0000 EOS"), ""); - transfer( "anothertoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("200000000.0000 VOICE"), ""); - - inittoken( "alice"_n, EVO4, - extend(asset::from_string("1000000.0000 EOS")), - extend(asset::from_string("100000000.0000 VOICE")), 10, "wevotethefee"_n); - - const auto* accnt3 = control->find_account_metadata( "wevotethefee"_n ); - abi_def abi_wevote; - BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(accnt3->abi, abi_wevote), true); - -// Start wevotethefee actions. Testing checks and basic functions. - abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); - auto ISNT = symbol::from_string("4,ISNT").to_symbol_code(); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("pair_token balance does not exist"), - votefee("alice"_n, ISNT, 10) ); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("pair_token balance does not exist"), - votefee("bob"_n, EVO, 30) ); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("fee table nonexistent, run openfeetable"), votefee("alice"_n, EVO, 30)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("fee table nonexistent, run openfeetable"), updatefee("alice"_n, EVO)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("table does not exist"), closefeetable(EVO)); - BOOST_REQUIRE_EQUAL(success(), openfeetable("alice"_n, EVO)); - BOOST_REQUIRE_EQUAL(success(), closefeetable(EVO)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("fee table nonexistent, run openfeetable"), votefee("alice"_n, EVO, 30)); - BOOST_REQUIRE_EQUAL(success(), openfeetable("alice"_n, EVO)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("already opened"), openfeetable("alice"_n, EVO)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("user is not voting"), closevote("alice"_n, EVO)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("only values between 10 and 100 are allowed"), votefee("alice"_n, EVO, 101)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("only values between 10 and 100 are allowed"), votefee("alice"_n, EVO, -10)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("only values between 10 and 100 are allowed"), votefee("alice"_n, EVO, 9)); - BOOST_REQUIRE_EQUAL(success(), votefee("alice"_n, EVO, 30)); - BOOST_REQUIRE_EQUAL(success(), updatefee("alice"_n, EVO)); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("table of votes is not empty"), closefeetable(EVO)); - - auto evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, - "feetable" )["votes"].get_array(); - BOOST_REQUIRE_EQUAL(100000000000, evo_votes[8]); - - abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); - auto evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(30, evo_stats["fee"]); - -// Leave vote table with zero votes, fee value remains unchanged - transfer( "sysio.swap"_n, "alice"_n, "bob"_n, asset::from_string("10000000.0000 EVO"), ""); - evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(30, evo_stats["fee"]); - abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); - evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, - "feetable" )["votes"].get_array(); - BOOST_REQUIRE_EQUAL(0, evo_votes[5]); - abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); - transfer( "sysio.swap"_n, "bob"_n, "alice"_n, asset::from_string("10000000.0000 EVO"), ""); - -// Test authorizations - abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); - BOOST_REQUIRE_EQUAL( error("missing authority of bob"), - push_action( "wevotethefee"_n, "alice"_n, "votefee"_n, - mvo()( "user", "bob"_n )( "pair_token", EVO )( "fee_voted", "10" ) ) - ); - BOOST_REQUIRE_EQUAL( error("missing authority of bob"), - push_action( "wevotethefee"_n, "alice"_n, "closevote"_n, - mvo()( "user", "bob"_n )( "pair_token", EVO ) ) - ); - -// Vote balance change after transfer - abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); - BOOST_REQUIRE_EQUAL(success(), - transfer( "sysio.swap"_n, "alice"_n, "bob"_n, asset::from_string("100.0000 EVO"), "")); - - abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); - BOOST_REQUIRE_EQUAL(success(), votefee("bob"_n, EVO, 100)); - evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, - "feetable" )["votes"].get_array(); - BOOST_REQUIRE_EQUAL(99999000000, evo_votes[8]); - BOOST_REQUIRE_EQUAL(1000000, evo_votes[11]); - -// Scenarios with many votes - create_accounts( { "dan"_n, "eva"_n, "fran"_n}); - abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); - BOOST_REQUIRE_EQUAL(success(), transfer( "sysio.swap"_n, "alice"_n, "carol"_n, asset::from_string("2000.0000 EVO"), "")); - transfer( "sysio.swap"_n, "alice"_n, "dan"_n, asset::from_string("400000.0000 EVO"), ""); - transfer( "sysio.swap"_n, "alice"_n, "eva"_n, asset::from_string("4240000.0000 EVO"), ""); - transfer( "sysio.swap"_n, "alice"_n, "fran"_n, asset::from_string("2500000.0000 EVO"), ""); - - abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); - BOOST_REQUIRE_EQUAL(success(), votefee("carol"_n, EVO, 10)); - votefee("dan"_n, EVO, 10); - votefee("eva"_n, EVO, 74); - votefee("fran"_n, EVO, 73); - - abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); - evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(75, evo_stats["fee"]); - - // Test updatefee when closing votes, then restore votes. - abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); - closevote( "alice"_n, EVO ); - closevote( "eva"_n, EVO ); - closevote( "fran"_n, EVO ); - evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, - "feetable" )["votes"].get_array(); - abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); - evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(10, evo_stats["fee"]); - abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); - votefee("alice"_n, EVO, 30); - votefee("eva"_n, EVO, 75); - votefee("fran"_n, EVO, 75); - abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); - evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(75, evo_stats["fee"]); - - // Test fee modification when adding and removing liquidity - abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); - openext( "eva"_n, "eva"_n, extended_symbol{symbol::from_string("4,EOS"), "sysio.token"_n}); - openext( "eva"_n, "eva"_n, extended_symbol{symbol::from_string("4,VOICE"), "anothertoken"_n}); - push_action( "sysio.swap"_n, "eva"_n, "remliquidity"_n, mvo() - ( "user", "eva"_n)( "to_sell", asset::from_string("4000000.0000 EVO")) - ( "min_asset1", asset::from_string("1.0000 EOS") ) - ( "min_asset2", asset::from_string("1.0000 VOICE")) ); - evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(30, evo_stats["fee"]); - - BOOST_REQUIRE_EQUAL(success(), push_action( "sysio.swap"_n, "eva"_n, "addliquidity"_n, mvo() - ( "user", "eva"_n)( "to_buy", asset::from_string("3900000.0000 EVO")) - ( "max_asset1", asset::from_string("100000000000.0000 EOS") ) - ( "max_asset2", asset::from_string("100000000000.0000 VOICE")) ) ); - evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(75, evo_stats["fee"]); - -// median 10, due to clamp between 10 and 100 - abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); - votefee("alice"_n, EVO, 10); - votefee("dan"_n, EVO, 10); - votefee("eva"_n, EVO, 10); - votefee("fran"_n, EVO, 10); - evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, - "feetable" )["votes"].get_array(); - BOOST_REQUIRE_EQUAL(98999000000, evo_votes[5]); - abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); - evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(10, evo_stats["fee"]); - -// check votes after transfer, remliquidity and addliquidity - BOOST_REQUIRE_EQUAL(success(), - transfer( "sysio.swap"_n, "alice"_n, "bob"_n, asset::from_string("100.0000 EVO"), "")); - abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); - evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, - "feetable" )["votes"].get_array(); - BOOST_REQUIRE_EQUAL(98998000000, evo_votes[5]); - BOOST_REQUIRE_EQUAL(2000000, evo_votes[11]); - - abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); - push_action( "sysio.swap"_n, "eva"_n, "remliquidity"_n, mvo() - ( "user", "eva"_n)( "to_sell", asset::from_string("10000.0000 EVO")) - ( "min_asset1", asset::from_string("1.0000 EOS") ) - ( "min_asset2", asset::from_string("1.0000 VOICE")) ); - abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); - evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, - "feetable" )["votes"].get_array(); - BOOST_REQUIRE_EQUAL(98998000000 - 100000000, evo_votes[5]); - - abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); - push_action( "sysio.swap"_n, "eva"_n, "addliquidity"_n, mvo() - ( "user", "eva"_n)( "to_buy", asset::from_string("100.0000 EVO")) - ( "max_asset1", asset::from_string("10000000.0000 EOS") ) - ( "max_asset2", asset::from_string("10000000.0000 VOICE")) ); - abi_ser.set_abi(abi_wevote, abi_serializer::create_yield_function(abi_serializer_max_time)); - evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, - "feetable" )["votes"].get_array(); - BOOST_REQUIRE_EQUAL(98998000000 - 100000000 + 1000000, evo_votes[5]); - -// median 100 - votefee("dan"_n, EVO, 100); - votefee("eva"_n, EVO, 100); - votefee("fran"_n, EVO, 100); - evo_votes = get_balance("wevotethefee"_n, name(EVO.value), "feetable"_n, EVO.value, - "feetable" )["votes"].get_array(); - abi_ser.set_abi(abi_evo, abi_serializer::create_yield_function(abi_serializer_max_time)); - evo_stats = get_balance("sysio.swap"_n, name(EVO.value), "stat"_n, EVO.value, "currency_stats" ); - BOOST_REQUIRE_EQUAL(100, evo_stats["fee"]); +BOOST_FIXTURE_TEST_CASE( fee_authority_configuration, 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(); + // 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 EOS")), extend(asset::from_string("1.0000 VOICE")), 10, name{}) ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.00 TUSD")), 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) ); + // (VOICE/TUSD is the one pair of these three tokens not yet created.) + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, symbol::from_string("3,BVO"), + extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.00 TUSD")), 0, name{}) ); + const auto BVO = symbol::from_string("3,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(); @@ -1271,7 +1104,7 @@ BOOST_FIXTURE_TEST_CASE( indextable, sysio_swap_tester ) try { BOOST_REQUIRE_EQUAL(success(), inittoken( "alice"_n, EVO4, extend(asset::from_string("1000000.0000 EOS")), - extend(asset::from_string("100000000.0000 VOICE")), 10, "wevotethefee"_n) ); + extend(asset::from_string("100000000.0000 VOICE")), 10, name{}) ); BOOST_REQUIRE_EQUAL(wasm_assert_msg("the pool is already indexed"), push_action( "sysio.swap"_n, "alice"_n, "indexpair"_n, @@ -1280,11 +1113,11 @@ BOOST_FIXTURE_TEST_CASE( indextable, sysio_swap_tester ) try { BOOST_REQUIRE_EQUAL(wasm_assert_msg("the pool is already indexed"), inittoken( "alice"_n, EOS4, extend(asset::from_string("1.0000 EOS")), - extend(asset::from_string("1.0000 VOICE")), 10, "wevotethefee"_n) ); + extend(asset::from_string("1.0000 VOICE")), 10, name{}) ); 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, "wevotethefee"_n) ); + extend(asset::from_string("1.0000 EOS")), 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 @@ -1303,17 +1136,17 @@ BOOST_FIXTURE_TEST_CASE( indextable, sysio_swap_tester ) try { 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, "wevotethefee"_n) ); + extend(asset::from_string("1.0000 EOS")), 10, name{}) ); 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.0000 VOICE")), - extend(asset::from_string("1.00000 EOS")), 10, "wevotethefee"_n) ); + extend(asset::from_string("1.00000 EOS")), 10, name{}) ); 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.0000 VOICE")), extended_asset{asset::from_string("1.0000 EOS"), "anothertoken"_n}, - 10, "wevotethefee"_n) ); + 10, name{}) ); } FC_LOG_AND_RETHROW() // --------------------------------------------------------------------------- @@ -1321,8 +1154,8 @@ BOOST_FIXTURE_TEST_CASE( indextable, sysio_swap_tester ) try { // the pool invariants under a long randomized operation sequence. // --------------------------------------------------------------------------- -// The fee vector wevotethefee can select from; the swap contract itself must -// accept the whole range and reject anything outside [0, MAX_FEE]. +// 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 { @@ -1607,11 +1440,11 @@ BOOST_FIXTURE_TEST_CASE( precision_extremes, sysio_swap_tester ) try { // A dust pool: one unit of EOS against the largest side inittoken accepts. const int64_t init_max = 1'000'000'000'000'000 - 1; BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, - extend(asset(1, EOS4)), extend(asset(init_max, VOICE4)), 10, "wevotethefee"_n) ); + extend(asset(1, EOS4)), extend(asset(init_max, VOICE4)), 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, EOS4)), extend(asset(init_max, TUSD2)), 10, "wevotethefee"_n) ); + extend(asset(init_max, EOS4)), extend(asset(init_max, TUSD2)), 10, name{}) ); { const auto before = system_balance(ETUSD.value); const int64_t shares = 2500 * before[2]; @@ -1700,7 +1533,7 @@ BOOST_FIXTURE_TEST_CASE( minimum_fee_closes_the_free_window, sysio_swap_tester ) BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("2000.0000 EOS"), "") ); BOOST_REQUIRE_EQUAL( success(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_n, asset::from_string("2000.0000 VOICE"), "deposit to: alice") ); BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, - extend(asset::from_string("1000.0000 EOS")), extend(asset::from_string("1000.0000 VOICE")), 10, "wevotethefee"_n) ); + extend(asset::from_string("1000.0000 EOS")), extend(asset::from_string("1000.0000 VOICE")), 10, name{}) ); // 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, 1) ); @@ -1753,7 +1586,7 @@ BOOST_FIXTURE_TEST_CASE( guard_semantics_on_the_memo_path, sysio_swap_tester ) t BOOST_REQUIRE_EQUAL( success(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_n, asset::from_string("2000.0000 VOICE"), "deposit to: alice") ); BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, extended_asset{asset::from_string("1000.0000 EOS"), "badtoken"_n}, - extend(asset::from_string("1000.0000 VOICE")), 10, "wevotethefee"_n) ); + extend(asset::from_string("1000.0000 VOICE")), 10, name{}) ); const auto before = system_balance(EVO.value); // A zero input through the memo path is refused before the pools move... @@ -1886,7 +1719,7 @@ BOOST_FIXTURE_TEST_CASE( price_accumulators_span_extreme_prices, sysio_swap_test BOOST_REQUIRE_EQUAL( success(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_n, asset::from_string("461168601842738.7903 VOICE"), "deposit to: alice") ); const int64_t init_max = 1'000'000'000'000'000 - 1; BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, - extend(asset(1, EOS4)), extend(asset(init_max, VOICE4)), 10, "wevotethefee"_n) ); + extend(asset(1, EOS4)), extend(asset(init_max, VOICE4)), 10, name{}) ); namespace twap = sysio::opp::twap; // At this price a single block already carries the sum past 128 bits; a @@ -1920,7 +1753,7 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { for (const auto& a : abi.actions) actions.insert(a.name.to_string()); const std::set expected_actions{ "addliquidity", "changefee", "close", "closeext", "exchange", "indexpair", - "inittoken", "open", "openext", "remliquidity", "sync", "transfer", "withdraw" }; + "inittoken", "open", "openext", "remliquidity", "setconfig", "sync", "transfer", "withdraw" }; BOOST_REQUIRE( actions == expected_actions ); using field_list = std::vector>; @@ -1937,10 +1770,12 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { 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_contract", "name"} }; + {"initial_pool2", "extended_asset"}, {"initial_fee", "int32"}, {"fee_authority", "name"} }; const field_list currency_stats_fields{ {"supply", "asset"}, {"max_supply", "asset"}, {"issuer", "name"}, {"pool1", "extended_asset"}, - {"pool2", "extended_asset"}, {"fee", "int32"}, {"fee_contract", "name"} }; + {"pool2", "extended_asset"}, {"fee", "int32"}, {"fee_authority", "name"} }; + const field_list setconfig_fields{ {"fee_authority", "name"} }; + const field_list swap_config_fields{ {"fee_authority", "name"} }; 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{ @@ -1958,6 +1793,8 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { 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 ); // 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. @@ -1977,10 +1814,11 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { 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"} } ) ); std::set tables; for (const auto& t : abi.tables) tables.insert(t.name); - const std::set expected_tables{ "accounts", "evodexacnts", "evoindex", "priceaccum", "stat" }; + const std::set expected_tables{ "accounts", "evodexacnts", "evoindex", "priceaccum", "stat", "swapconfig" }; BOOST_REQUIRE( tables == expected_tables ); } FC_LOG_AND_RETHROW() From 27b4eedde338581bf04d11e08c8b09e7dbfff348 Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Fri, 11 Sep 2026 15:48:22 -0400 Subject: [PATCH 11/37] swap: seed pairs with an integer geometric mean and a creator-chosen share lock inittoken minted sqrt(pool1 * pool2) through the floating sqrt, which the consensus rules forbid: its rounding is platform-dependent. The seed supply now comes from amm::geometric_mean, an integer Newton floor(sqrt(x*y)) added to sysio.opp.common/amm_math.hpp, so every node mints the same share count on every input. inittoken also takes `locked_shares`: part of the minted supply that is credited to no account and can never be redeemed, the Uniswap v2 minimum-liquidity idea with the size left to the pair's creator. The lock keeps the pool from ever being emptied and re-seeded at another unit, and bounds how far one share's value can be pushed. Seed-time attacks only victimise the creator, so the size is the creator's decision and zero is allowed; the creator must keep at least one share. The pair row records the lock, and remliquidity refuses to take supply below it (ownership already makes that unreachable; the check is defense in depth beside "the pool cannot be left empty"). Tests: host cases pin isqrt across the int128 range (exact around 20000 random perfect squares and at the top of the range) and the geometric mean, the fixture pins the exact integer seed supply of both standard pools, and a contract case walks the lock: the three validation failures, the creator holding minted-less-locked, removing every creator share leaving the locked slice of the pools behind, and the pool trading and growing again from that floor. Co-Authored-By: Claude Fable 5.1 Change-Id: I6edefc852f4653bacbbd50d728e8f5399693b92c --- .../include/sysio.opp.common/amm_math.hpp | 23 ++++++ contracts/sysio.swap/Commands.md | 4 +- contracts/sysio.swap/README.md | 2 + .../include/sysio.swap/sysio.swap.hpp | 14 +++- .../ricardian/sysio.swap.contracts.md | 2 + contracts/sysio.swap/sysio.swap.abi | 8 ++ contracts/sysio.swap/sysio.swap.cpp | 20 +++-- contracts/sysio.swap/sysio.swap.wasm | Bin 35886 -> 34989 bytes contracts/tests/amm_math_tests.cpp | 44 +++++++++++ contracts/tests/sysio.swap_tests.cpp | 72 ++++++++++++++++-- 10 files changed, 172 insertions(+), 17 deletions(-) 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..7476c09b3d 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,29 @@ 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)); +} + /// Basis-points denominator (10000 = 100%). inline constexpr uint32_t BPS_TOTAL = 10000; diff --git a/contracts/sysio.swap/Commands.md b/contracts/sysio.swap/Commands.md index 56061cf808..ad8afc6042 100644 --- a/contracts/sysio.swap/Commands.md +++ b/contracts/sysio.swap/Commands.md @@ -29,9 +29,9 @@ 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 EOS/PESO evotoken. Set the initial liquidity, the initial fee for the trading pair (in units of 0.01%, here 0.1%) and 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 contract's authority is required alongside yours. +Create the EOS/PESO evotoken. 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) and the shares to lock. 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. - cleos push action evolutiondex inittoken '["YOUR_ACCOUNT", "4,EOSPESO", {"contract":"eosio.token", "quantity":"1.0000 EOS"}, {"contract":"pesocontract", "quantity":"1.0000 PESO"}, 10, ""]' -p YOUR_ACCOUNT -p evolutiondex + cleos push action evolutiondex inittoken '["YOUR_ACCOUNT", "4,EOSPESO", {"contract":"eosio.token", "quantity":"1.0000 EOS"}, {"contract":"pesocontract", "quantity":"1.0000 PESO"}, 10, "", "0.1000 EOSPESO"]' -p YOUR_ACCOUNT -p evolutiondex Set the contract-wide fee authority (deployment step, the contract's own authority): diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md index 58c965a999..293a2957b8 100644 --- a/contracts/sysio.swap/README.md +++ b/contracts/sysio.swap/README.md @@ -20,6 +20,8 @@ the price behaviour. Notice that whenever the amount to exchange is small compar 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** diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index d92e9a8109..f172204cfc 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -44,11 +44,16 @@ namespace sysio { /// executes approved proposals as `sysio`, so deployment sets it to `sysio`. /// Requires the contract's own authority. [[sysio::action]] void setconfig(name fee_authority); - /// Create a pair. `initial_fee` may be anything in [0, MAX_FEE]. An empty - /// `fee_authority` adopts the configured one; a name overrides it for this pair. + /// Create a pair, minting sqrt(pool1 * pool2) LP shares. `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. [[sysio::action]] void inittoken(name user, symbol new_symbol, extended_asset initial_pool1, extended_asset initial_pool2, - int initial_fee, name fee_authority); + int initial_fee, name fee_authority, asset locked_shares); [[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); @@ -128,7 +133,8 @@ namespace sysio { extended_asset pool2; int fee; name fee_authority; ///< whose signature changefee requires for this pair - SYSLIB_SERIALIZE(currency_stats, (supply)(max_supply)(issuer)(pool1)(pool2)(fee)(fee_authority)) + asset locked_shares; ///< part of `supply` held by no account, never redeemable + SYSLIB_SERIALIZE(currency_stats, (supply)(max_supply)(issuer)(pool1)(pool2)(fee)(fee_authority)(locked_shares)) }; struct [[sysio::table("evoindex")]] pair_index { diff --git a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md index 1c39ea39ac..1c591ecfa3 100644 --- a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md +++ b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md @@ -79,6 +79,8 @@ summary: 'Initializes an evotoken by setting initial pair of token pools' 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. + RAM will be deducted from {{user}}’s resources to create the necessary records. Authorization of {{user}} and of the contract is required. diff --git a/contracts/sysio.swap/sysio.swap.abi b/contracts/sysio.swap/sysio.swap.abi index 7f9e3d830b..b59a737a8e 100644 --- a/contracts/sysio.swap/sysio.swap.abi +++ b/contracts/sysio.swap/sysio.swap.abi @@ -140,6 +140,10 @@ { "name": "fee_authority", "type": "name" + }, + { + "name": "locked_shares", + "type": "asset" } ] }, @@ -244,6 +248,10 @@ { "name": "fee_authority", "type": "name" + }, + { + "name": "locked_shares", + "type": "asset" } ] }, diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index 6cf296e2b4..18e037fed7 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -123,7 +123,11 @@ void swap::add_signed_liq(name user, asset to_add, bool is_buying, a.pool1 += to_pay1; a.pool2 += to_pay2; }); - check(token->supply.amount + to_add.amount != 0, "the pool cannot be left empty"); + // 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, @@ -215,7 +219,7 @@ void swap::setconfig(name fee_authority) { } void swap::inittoken(name user, symbol new_symbol, extended_asset initial_pool1, -extended_asset initial_pool2, int initial_fee, name fee_authority) +extended_asset initial_pool2, int initial_fee, name fee_authority, asset locked_shares) { require_auth( user ); require_auth( get_self() ); @@ -223,8 +227,12 @@ extended_asset initial_pool2, int initial_fee, name fee_authority) 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" ); - int128_t geometric_mean = sqrt(int128_t(initial_pool1.quantity.amount) * int128_t(initial_pool2.quantity.amount)); - auto new_token = asset{int64_t(geometric_mean), new_symbol}; + 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"); stats statstable( get_self() ); @@ -246,13 +254,15 @@ extended_asset initial_pool2, int initial_fee, name fee_authority) .pool2 = initial_pool2, .fee = initial_fee, .fee_authority = fee_authority, + .locked_shares = locked_shares, } ); priceaccums accums( get_self() ); accums.emplace( user, key, price_accumulator{ .last_update = current_time_point() } ); placeindex(user, new_symbol, initial_pool1, initial_pool2 ); - add_balance(user, new_token, user); + // 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); } diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index c1656e059c0dc4fbec4e5f7e50986c2ce0a66a0f..78392690c1c02d0637738c3fa86e8008b26cb5df 100755 GIT binary patch delta 7906 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 delta 8809 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 abl|>oK=bz+GIdXX@07h;n6bJ*)BX>{o~sxD diff --git a/contracts/tests/amm_math_tests.cpp b/contracts/tests/amm_math_tests.cpp index aac26a8b47..708a8a5a13 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,47 @@ 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); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index 21a23b60c6..84f1d488c7 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -157,9 +157,15 @@ class sysio_swap_tester : public tester { ( "memo", "" ) ); } - // An empty `fee_authority` adopts the configured one (sysio in this fixture). + // An empty `fee_authority` adopts the configured one (sysio in this fixture); + // `locked_shares` is in units of the new symbol, none by default. action_result inittoken( name user, symbol new_symbol, extended_asset initial_pool1, - extended_asset initial_pool2, int initial_fee, name fee_authority = name{} ){ + extended_asset initial_pool2, int initial_fee, name fee_authority = name{}, int64_t locked_shares = 0 ){ + return inittoken( user, new_symbol, initial_pool1, initial_pool2, initial_fee, fee_authority, + asset( locked_shares, new_symbol ) ); + } + 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 ){ // 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, @@ -171,6 +177,7 @@ class sysio_swap_tester : public tester { ("initial_pool2", initial_pool2) ("initial_fee", initial_fee) ("fee_authority", fee_authority) + ("locked_shares", locked_shares) ); } action_result addliquidity(name user, asset to_buy, asset max_asset1, asset max_asset2) { @@ -495,6 +502,9 @@ void sysio_swap_tester::setup_pools() { BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, extend(asset::from_string("10000000000.0000 EOS")), extend(asset::from_string("9911686018427.38 TUSD")), 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) @@ -941,14 +951,14 @@ BOOST_FIXTURE_TEST_CASE( the_other_actions, sysio_swap_tester ) try { ("user", "alice"_n) ("new_symbol", EVO4) ("initial_pool1", extend(asset::from_string("1.0000 EOS"))) ("initial_pool2", extend(asset::from_string("1.0000 ECO"))) - ("initial_fee", 1) ("fee_authority", "carol"_n) ) + ("initial_fee", 1) ("fee_authority", "carol"_n) ("locked_shares", asset::from_string("0.0000 EVO")) ) ); 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 EOS"))) ("initial_pool2", extend(asset::from_string("1.0000 ECO"))) - ("initial_fee", 1) ("fee_authority", "carol"_n) ) + ("initial_fee", 1) ("fee_authority", "carol"_n) ("locked_shares", asset::from_string("0.0000 EVO")) ) ); BOOST_REQUIRE_EQUAL( wasm_assert_msg("Both assets must be positive"), inittoken( "alice"_n, EVO4, extend(asset::from_string("-0.0001 EOS")), @@ -1051,6 +1061,55 @@ BOOST_FIXTURE_TEST_CASE( pair_creation_needs_a_configured_fee_authority, sysio_s BOOST_REQUIRE_EQUAL( success(), changefee(ETUSD, 25) ); } 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("1000000.0000 EOS")), extend(asset::from_string("100000000.0000 VOICE")), + 10, name{}, asset(locked, VOICE4) ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("locked_shares must be nonnegative"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1000000.0000 EOS")), extend(asset::from_string("100000000.0000 VOICE")), + 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("1000000.0000 EOS")), extend(asset::from_string("100000000.0000 VOICE")), + 10, name{}, lock_of(minted) ) ); + + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1000000.0000 EOS")), extend(asset::from_string("100000000.0000 VOICE")), + 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, EOS4), asset(0, VOICE4) ) ); + 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(0) ); // what 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, EOS4), asset(0, VOICE4) ) ); + + // 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, EOS4), asset(pay2, VOICE4) ) ); + BOOST_REQUIRE_EQUAL( 2 * locked, system_balance(EVO.value).at(2) ); + BOOST_REQUIRE_GE( settle_swap("alice"_n, EVO, asset(1000, EOS4), VOICE4, 1), 0 ); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( fee_authority_configuration, sysio_swap_tester ) try { create_tokens_and_issue(); abi_ser.set_abi( swap_abi_def(), abi_serializer::create_yield_function(abi_serializer_max_time) ); @@ -1770,10 +1829,11 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { 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"} }; + {"initial_pool2", "extended_asset"}, {"initial_fee", "int32"}, {"fee_authority", "name"}, + {"locked_shares", "asset"} }; const field_list currency_stats_fields{ {"supply", "asset"}, {"max_supply", "asset"}, {"issuer", "name"}, {"pool1", "extended_asset"}, - {"pool2", "extended_asset"}, {"fee", "int32"}, {"fee_authority", "name"} }; + {"pool2", "extended_asset"}, {"fee", "int32"}, {"fee_authority", "name"}, {"locked_shares", "asset"} }; const field_list setconfig_fields{ {"fee_authority", "name"} }; const field_list swap_config_fields{ {"fee_authority", "name"} }; const field_list sync_fields{ {"pair_token", "symbol_code"} }; From c0c06081b585e84a3fadb7a320c8d95c3c4f1eed Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Fri, 11 Sep 2026 16:20:10 -0400 Subject: [PATCH 12/37] swap: drop the indexpair migration action indexpair back-filled the pair-uniqueness index for pairs that upstream had created before the index existed. Every pair here is indexed by inittoken at creation and there are no pre-index pairs to migrate, so the action could only ever fail with "the pool is already indexed". The evoindex table and the placeindex helper stay: they are what enforce one pool per token pair. Co-Authored-By: Claude Fable 5.1 Change-Id: Ib1ade4a5ac33c23c5f6da56a2777e2fa5fca1127 --- .../include/sysio.swap/sysio.swap.hpp | 1 - contracts/sysio.swap/sysio.swap.abi | 19 ------------------ contracts/sysio.swap/sysio.swap.cpp | 6 ------ contracts/sysio.swap/sysio.swap.wasm | Bin 34989 -> 34328 bytes contracts/tests/sysio.swap_tests.cpp | 13 +++--------- 5 files changed, 3 insertions(+), 36 deletions(-) diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index f172204cfc..1bc6701664 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -71,7 +71,6 @@ namespace sysio { 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 ); - [[sysio::action]] void indexpair(name user, symbol evo_symbol); // This action is only temporarily useful private: diff --git a/contracts/sysio.swap/sysio.swap.abi b/contracts/sysio.swap/sysio.swap.abi index b59a737a8e..8371796a77 100644 --- a/contracts/sysio.swap/sysio.swap.abi +++ b/contracts/sysio.swap/sysio.swap.abi @@ -207,20 +207,6 @@ } ] }, - { - "name": "indexpair", - "base": "", - "fields": [ - { - "name": "user", - "type": "name" - }, - { - "name": "evo_symbol", - "type": "symbol" - } - ] - }, { "name": "inittoken", "base": "", @@ -474,11 +460,6 @@ "type": "exchange", "ricardian_contract": "" }, - { - "name": "indexpair", - "type": "indexpair", - "ricardian_contract": "" - }, { "name": "inittoken", "type": "inittoken", diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index 18e037fed7..aac295fafb 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -267,12 +267,6 @@ extended_asset initial_pool2, int initial_fee, name fee_authority, asset locked_ add_signed_ext_balance(user, -initial_pool2); } -void swap::indexpair(name user, symbol evo_symbol) { - stats statstable( get_self() ); - const auto token = statstable.get( pair_key{ evo_symbol.code().raw() }, "token symbol does not exist" ); - placeindex(user, evo_symbol, token.pool1, token.pool2); -} - void swap::placeindex(name user, symbol evo_symbol, extended_asset pool1, extended_asset pool2 ) { evoindexes indextable( get_self() ); diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 78392690c1c02d0637738c3fa86e8008b26cb5df..949c1661c9f9b22d4381eed4c3bfdff7303ecf8e 100755 GIT binary patch delta 2234 zcmZuy3s6*L6u#%)-4(gJaTx}L)!^pvnDc{v?9p2OjDGOk8H|EW;3O9{=2)_G_k`u=Rf~B-+%t|pL6cJ zwS0XwZ{~8BECP<>;0oAl&F2qap8!G~Y{58wrZq4$*c>GX%7KCGpM;)2+{fGc+iV6S zcmTjC00*Wpp$wGr7EUbVl#Azd)`i#jxL%DA*mMiFXi>syespWP%qh|`JE(SkINzGC za0(a8f0S+)WKLCrRK-(d=Z2GlPm*~>Dweq^7BV3jRIra{%PzCZGa89I;=13$NI1sqAg+lNpmG^CbVEe zpJOE7>obJ(j_^$Q3CqI=0Af?PLYiygLtujz9?@hpSA$iiSYmmDv<6Q_I?NTM01D2a zO0Q^tMW#c5o83>9T$mxpo6G-W4=-z<$cv0{8V5O2U^A9FY~t|_&IV=>l|S3@pyL8u z#x*g8(4gIpS#E+auwZZubZWJO*BD?Mh75ZKwrZ1x4Ku(At#m}5z~4WES4R$nMhs4{ zy?Ep-CxIw);!Mhq4AMrBfd!*;S(Pf-I@Y&-*0*lqe2NYdbc>55)G*U7zveo#(P|RD z2MW4yR2}4_A#p6xiHWo0YxPU&2l+)=mHgt6Mn#!0!@ zd+Y!d$6H~W<`{niU;#?WiYZSvDm6TQInv7C`4*QXvuo~7w!u+sNv?u(+N6mtz~hs5 zk$L8nom63eOc}?63lpYpf;P-ai^gB3R`z#UWnm0ALP(;D5@Px9fYFO!H!f6q>9wV_ zVO@&T=$4K2Y7bL}Kk1eOB<7`@1(j$Ws8XR1q>8+K>MByZaL%-7vzysc(%2T(W`$_` zrWt|v*wB>`MS)aj*zjiRFp{FulJvkD!~H=~ngwOQeoT#yXPq7iZhUUK0*keh>4N~4 zXa{H5c}tzQ&|-PBvH+bPU450q)Ae4u*!ia`uIeNRvtX{v+ zxGZL{E~}XtBxF12c5!o}iB@G3)edE6{qF`I%5KKEoPFeUF2_c3-p`rmd!jkH%&{_e z7LdAY?obnZ97V>4f(VS8mjo(4J+}`AsV3Y!Zv~kI%=bWnuB(|}MtG7lG}51%4DYR2 z-hS`Nn;XYEDQK*8y5Kdu<9xrj-<(5BejkGTY{6&(67rnzi z`L+C>M_Zw4U#P-3)dg#Dw;C0pw^@L%-Kaq;S-165rFD2-{T0^Z=L@I90kjrIW1=h8 zu>q`9yAaEBG$n}{>@dN?2}#uB0_#{oVNh7@8ckE|YnKBKXm?!;fDmT6Q(zOmMOMD&TGr*MDbph%CH1W-mdqz^#= zBL+LaPzmz)=H8-7(5(Gjv<0NCKv(8vf#$+dDz0r%7d$4p4z)s9wK8|SP@?^dqFgGd;VZ}Yzh4)v?rrMgZ zGM+(gSY?MD_|B>nI^*wEBSW^cRLiW*!x^E;wihRrZ99%! znunbwW1YR|+W^waAdz;;%ggVcKh=0?OsYljE=jZZOmeEl;9VX)SJ{5``8113cPb4M zd74=4N81G3-M#0>=2kAlsDh*W@k)GXRg^!o{_$nWN$;XR*e4&r8_VMMotaMOF zktkX?DVUxQWL|0jQS8({C_QGDmrXJYv*-tNf5RbPeRVe*iiZjw}EG delta 2647 zcmZuy4Nz3q6@KTw-Q{O@%VSdh428Q30>$dspx}g2c&I^16d{6^Fe=LLgJqF_H3Ejj zkj7Z_P^*9mCX$J<4R#)BF`YQ1nn^Mfr-&qKn*tr3Mw%H*8#SG_sXh1ZBGZ|&!#zLu zyXSuIoO{nbeu(|z2y-YU4IWSw1^xnYM;xECHszO-IiMbc9$dramN8wx6K zbR~&hFL0||G4v2auiNcbQpv$GIn%X9t~}`^2=ZOp%b)%;KVeES+q|4ASS_akQ_kNEr#|J^Xr%b2=t1tH#84!;>#S*{VzV*e2*JexPuE5 za^WmCCAjR@#uOJgz^?a<;i-gc@BsHDdf|O@Cb7i^H?d?%BHTA$T5`YwC(xd<9FCiM zN{R(0&8D;pmEiF=)56#VPtIRJ+x9DrA9p~H%}#}s0B$3NTex+z zlQOJwTbeo`@YXc-xR3#og~es1O8QJWZShYNyjxBX{w;xLX=)cq{TvuvNMcQ67jc)F z;}(^^8_%pi1r_*YPCs3(+c|3(cyRHCmth9aZ+M&Z(v2?C4{j{8dbyPj-0a5GMP44L zc@|Kt2^p~m46Tk#kvqAe&rKTm|jv^rUc*$c>ht{3jpc-8z5x8$>8+re^vknYt zKewwrV(z;I_q+0g1$PfdmPQkg<)uUrTS{x-XLzghT+kd^!3X6L6zkox3?gx_tc(qS z4B1fLNse#IMg1Zwj<8eG?WCCTiv0p(mUd{vp{F($aq7pEYa!MM!7bEqpYr8#1#!6~ zxlm_#xFwx^HAz5{*gChhS`Uz|TAvxGP9vQzt2pmgoGb|sRb1j$MT(P5k@P?Vmv2~a z;Wn*Y&~(#vBR*w}5gqDJ^d^W#naX6d5`F5&c(bAgUcgP2u~D)U1^PP@&AliZQnaCU zVOQlHcnMpoo`#F~Y0W~MtxAHIG0KPzqBSb*svX2tMh5xXj0Cu74jSb^P)AKJyn@9w z@uVN9$&rgsNBG*eY}kk7fmL*FXx;dtXE_CU-}40FEVawldVQT#2%7hhpgt-rkt`CU zR6k*gCMs;vL+eOV4WunPs27K7!UEMCc?~XNZ*3+8|3mE$mi$yc60%j#soc=!)TQ&P z*k!kj65_7|=6lz}nAz_=3K}&cdYVKv^btDgCves751{vGeKz!&>*~J)(2rXhRz7-C zyf)mTk6H{GH8BJldjBOw>obQMwgbG1Ax&#&+}xB6y=H%t1K>40(_BoK!`8A)gh_98 z!%5uMnhU-7PV3{3^ohiCQFmEen)>pczYl-) z;hD$q@SfG}Hcmy>_J-K$)G_gf|N65Re)s1M1=M+2R!2w2*Pnm#`i-nSr^RQIXW(jf zzSFmCe~8`Z9+8V4qNXqRy?bLFDiLXCT+2+z~?)IWW)}5$am`Ar@uctbC*K-34iPho#zxU6)haQJ~RB* zwTDah`6*BP);&Xe_<++B1HE6~xl6!6xkoJlMQZl=<+Fa-;+F@Ag9NnuWs_gl_+=Y@ zyLa&;^>({c(L(8z(5Go{fZcxIT)FSO&FZZ#tL1oQe}sAeKqka1dgqv8zhdP(%XkTI iE~%+1<+UZAGNrMys=l$TthTX|n?;@PG0SYSqWl-KC-Nr% diff --git a/contracts/tests/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index 84f1d488c7..110b9eac6d 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -1157,19 +1157,12 @@ BOOST_FIXTURE_TEST_CASE( indextable, sysio_swap_tester ) try { transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("10000000.0000 EOS"), ""); transfer( "anothertoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("200000000.0000 VOICE"), ""); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("token symbol does not exist"), - push_action( "sysio.swap"_n, "alice"_n, "indexpair"_n, - mvo() ( "user", "alice"_n ) ( "evo_symbol", EVO4 ) ) ); - BOOST_REQUIRE_EQUAL(success(), inittoken( "alice"_n, EVO4, extend(asset::from_string("1000000.0000 EOS")), extend(asset::from_string("100000000.0000 VOICE")), 10, name{}) ); - BOOST_REQUIRE_EQUAL(wasm_assert_msg("the pool is already indexed"), - push_action( "sysio.swap"_n, "alice"_n, "indexpair"_n, - mvo() ( "user", "alice"_n ) ( "evo_symbol", EVO4 ) ) ); - - BOOST_REQUIRE_EQUAL(wasm_assert_msg("the pool is already indexed"), + // One pool per token pair, whichever way round the legs are given. + BOOST_REQUIRE_EQUAL(wasm_assert_msg("the pool is already indexed"), inittoken( "alice"_n, EOS4, extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.0000 VOICE")), 10, name{}) ); @@ -1811,7 +1804,7 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { std::set actions; for (const auto& a : abi.actions) actions.insert(a.name.to_string()); const std::set expected_actions{ - "addliquidity", "changefee", "close", "closeext", "exchange", "indexpair", + "addliquidity", "changefee", "close", "closeext", "exchange", "inittoken", "open", "openext", "remliquidity", "setconfig", "sync", "transfer", "withdraw" }; BOOST_REQUIRE( actions == expected_actions ); From 961dba9466535dfcdd714d15f820fd66b9c7a90d Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Fri, 11 Sep 2026 16:34:47 -0400 Subject: [PATCH 13/37] swap: replace safe.hpp with overflow-checked builtins in the memo parser safe.hpp was evolutiondex's copy of the old fc::safe checked-integer wrapper, 221 lines whose only consumer was the digit accumulation in asset_from_string, the parser for the min_expected asset of an "exchange:" memo. The parser now guards the same three arithmetic steps with __builtin_mul_overflow / __builtin_add_overflow (deterministic on WASM) and the header is gone. The parse also loses a dead branch: it negated the fraction on a leading '-', but the digit loop rejects a sign before it could run, so memo amounts have always been unsigned magnitudes, which they should be. Tests pin the overflow guard on the memo path: a digit string past int64, the last digit that overflows, a scaled integer part that overflows, one that fits int64 but not an asset (the asset's own magnitude refusal), more than 18 decimals, a sign, a bare decimal point and a missing separator. Co-Authored-By: Claude Fable 5.1 Change-Id: I617c96039bc40d06a1f7cb299f75be8f616527c8 --- .../sysio.swap/include/sysio.swap/safe.hpp | 221 ------------------ .../sysio.swap/include/sysio.swap/utils.hpp | 74 +++--- contracts/sysio.swap/sysio.swap.wasm | Bin 34328 -> 34094 bytes contracts/tests/sysio.swap_tests.cpp | 34 ++- 4 files changed, 72 insertions(+), 257 deletions(-) delete mode 100644 contracts/sysio.swap/include/sysio.swap/safe.hpp diff --git a/contracts/sysio.swap/include/sysio.swap/safe.hpp b/contracts/sysio.swap/include/sysio.swap/safe.hpp deleted file mode 100644 index 075cdec011..0000000000 --- a/contracts/sysio.swap/include/sysio.swap/safe.hpp +++ /dev/null @@ -1,221 +0,0 @@ -#pragma once - -#include -/** -* This type is designed to provide automatic checks for -* integer overflow and default initialization. It will -* throw an exception on overflow conditions. -* -* It can only be used on built-in types. In particular, -* safe is buggy and should not be used. -* -* Implemented using spec from: -* https://www.securecoding.cert.org/confluence/display/c/INT32-C.+Ensure+that+operations+on+signed+integers+do+not+result+in+overflow -*/ -template -struct safe -{ - T value = 0; - - template - safe( O o ):value(o){} - safe(){} - safe( const safe& o ):value(o.value){} - - static safe min() - { - return std::numeric_limits::min(); - } - static safe max() - { - return std::numeric_limits::max(); - } - - friend safe operator + ( const safe& a, const safe& b ) - { - if( b.value > 0 && a.value > (std::numeric_limits::max() - b.value) ) check(false, "overflow_exception, (a)(b)" ); - if( b.value < 0 && a.value < (std::numeric_limits::min() - b.value) ) check(false, "underflow_exception, (a)(b)" ); - return safe( a.value + b.value ); - } - friend safe operator - ( const safe& a, const safe& b ) - { - if( b.value > 0 && a.value < (std::numeric_limits::min() + b.value) ) check(false, "underflow_exception, (a)(b)" ); - if( b.value < 0 && a.value > (std::numeric_limits::max() + b.value) ) check(false, "overflow_exception, (a)(b)" ); - return safe( a.value - b.value ); - } - - friend safe operator * ( const safe& a, const safe& b ) - { - if( a.value > 0 ) - { - if( b.value > 0 ) - { - if( a.value > (std::numeric_limits::max() / b.value) ) check(false, "overflow_exception, (a)(b)" ); - } - else - { - if( b.value < (std::numeric_limits::min() / a.value) ) check(false, "underflow_exception, (a)(b)" ); - } - } - else - { - if( b.value > 0 ) - { - if( a.value < (std::numeric_limits::min() / b.value) ) check(false, "underflow_exception, (a)(b)" ); - } - else - { - if( a.value != 0 && b.value < (std::numeric_limits::max() / a.value) ) check(false, "overflow_exception, (a)(b)" ); - } - } - - return safe( a.value * b.value ); - } - - friend safe operator / ( const safe& a, const safe& b ) - { - if( b.value == 0 ) check(false, "divide_by_zero_exception, (a)(b)" ); - if( a.value == std::numeric_limits::min() && b.value == -1 ) check(false, "overflow_exception, (a)(b)" ); - return safe( a.value / b.value ); - } - friend safe operator % ( const safe& a, const safe& b ) - { - if( b.value == 0 ) check(false, "divide_by_zero_exception, (a)(b)" ); - if( a.value == std::numeric_limits::min() && b.value == -1 ) check(false, "overflow_exception, (a)(b)" ); - return safe( a.value % b.value ); - } - - safe operator - ()const - { - if( value == std::numeric_limits::min() ) check(false, "overflow_exception, (*this)" ); - return safe( -value ); - } - - safe& operator += ( const safe& b ) - { - value = (*this + b).value; - return *this; - } - safe& operator -= ( const safe& b ) - { - value = (*this - b).value; - return *this; - } - safe& operator *= ( const safe& b ) - { - value = (*this * b).value; - return *this; - } - safe& operator /= ( const safe& b ) - { - value = (*this / b).value; - return *this; - } - safe& operator %= ( const safe& b ) - { - value = (*this % b).value; - return *this; - } - - safe& operator++() - { - *this += 1; - return *this; - } - safe operator++( int ) - { - safe bak = *this; - *this += 1; - return bak; - } - - safe& operator--() - { - *this -= 1; - return *this; - } - safe operator--( int ) - { - safe bak = *this; - *this -= 1; - return bak; - } - - friend bool operator == ( const safe& a, const safe& b ) - { - return a.value == b.value; - } - friend bool operator == ( const safe& a, const T& b ) - { - return a.value == b; - } - friend bool operator == ( const T& a, const safe& b ) - { - return a == b.value; - } - - friend bool operator < ( const safe& a, const safe& b ) - { - return a.value < b.value; - } - friend bool operator < ( const safe& a, const T& b ) - { - return a.value < b; - } - friend bool operator < ( const T& a, const safe& b ) - { - return a < b.value; - } - - friend bool operator > ( const safe& a, const safe& b ) - { - return a.value > b.value; - } - friend bool operator > ( const safe& a, const T& b ) - { - return a.value > b; - } - friend bool operator > ( const T& a, const safe& b ) - { - return a > b.value; - } - - friend bool operator != ( const safe& a, const safe& b ) - { - return !(a == b); - } - friend bool operator != ( const safe& a, const T& b ) - { - return !(a == b); - } - friend bool operator != ( const T& a, const safe& b ) - { - return !(a == b); - } - - friend bool operator <= ( const safe& a, const safe& b ) - { - return !(a > b); - } - friend bool operator <= ( const safe& a, const T& b ) - { - return !(a > b); - } - friend bool operator <= ( const T& a, const safe& b ) - { - return !(a > b); - } - - friend bool operator >= ( const safe& a, const safe& b ) - { - return !(a < b); - } - friend bool operator >= ( const safe& a, const T& b ) - { - return !(a < b); - } - friend bool operator >= ( const T& a, const safe& b ) - { - return !(a < b); - } -}; diff --git a/contracts/sysio.swap/include/sysio.swap/utils.hpp b/contracts/sysio.swap/include/sysio.swap/utils.hpp index 4dbb2e02a2..1789f48165 100644 --- a/contracts/sysio.swap/include/sysio.swap/utils.hpp +++ b/contracts/sysio.swap/include/sysio.swap/utils.hpp @@ -1,19 +1,17 @@ #pragma once -//#include #include #include #include #include -#include "safe.hpp" -string_view trim(string_view sv) { +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 + sv.remove_suffix(std::min(sv.size()-sv.find_last_not_of(" ")-1, sv.size())); // right trim return sv; } - -vector split(string_view str, string_view delims = " ") + +inline vector split(string_view str, string_view delims = " ") { vector res; std::size_t current, previous = 0; @@ -27,32 +25,41 @@ vector split(string_view str, string_view delims = " ") return res; } -bool starts_with(string_view sv, string_view s) { +inline bool starts_with(string_view sv, string_view s) { return sv.size() >= s.size() && sv.compare(0, s.size(), s) == 0; } -template -void to_int(string_view sv, T& res) { - res = 0; - T p = 1; - for( auto itr = sv.rbegin(); itr != sv.rend(); ++itr ) { - check( *itr <= '9' && *itr >= '0', "invalid character"); - res += p * T(*itr-'0'); - p *= T(10); +/// 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; } -template -void precision_from_decimals(int8_t decimals, T& p10) + +/// 10^decimals, the scale of an amount with that many decimal places. +inline int64_t precision_from_decimals(uint8_t decimals) { - check(decimals <= 18, "precision should be <= 18"); - p10 = 1; - T p = decimals; - while( p > 0 ) { - p10 *= 10; --p; - } + 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; } -asset asset_from_string(string_view from) +/// 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); @@ -77,19 +84,18 @@ asset asset_from_string(string_view from) symbol sym = symbol(symbol_str, precision_digit); // Parse amount - safe int_part, fract_part; + int64_t int_part = 0; + int64_t fract_part = 0; if (dot_pos != string::npos) { - to_int(amount_str.substr(0, dot_pos), int_part); - to_int(amount_str.substr(dot_pos + 1), fract_part); - if (amount_str[0] == '-') fract_part *= -1; + int_part = to_int(amount_str.substr(0, dot_pos)); + fract_part = to_int(amount_str.substr(dot_pos + 1)); } else { - to_int(amount_str, int_part); + int_part = to_int(amount_str); } - safe amount = int_part; - safe precision; precision_from_decimals(sym.precision(), precision); - amount *= precision; - amount += fract_part; + 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.value, sym); + return asset(amount, sym); } diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 949c1661c9f9b22d4381eed4c3bfdff7303ecf8e..7c98f6d208ae31372b1cd7449b9a73dc34a88b1c 100755 GIT binary patch delta 9562 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 delta 9765 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%0DG(gcKiL~TU9er@nq(={x zQ2HS=p`~Yc@8CewKzmQO3G&+dc69aZql<^?!_oDP Date: Sun, 13 Sep 2026 11:39:41 -0400 Subject: [PATCH 14/37] swap: optional yield leg, one yield pool per shadow symbol, setyield A pair may now name one of its legs as its shadow token, which makes it a yield pool; an empty yield leg is a plain pool with unchanged behaviour. A `yieldpairs` table keyed by the shadow's extended symbol enforces at most one yield pool per shadow symbol and doubles as the symbol-to-pair lookup the yield paths will use. The pair row gains the tick parameters (`conversion_horizon_sec`, `depth_cap_bps`, `last_tick`), set by the pair's fee authority through the new `setyield` action; both must be nonzero before a tick can run. Tests cover leg validation (must be one of the pair's legs, contract included), the uniqueness rule against a second pool over the same shadow, a plain pool alongside, and setyield's authority, target and range checks. The ABI pin gains the new fields, action and table. Co-Authored-By: Claude Fable 5.1 Change-Id: Idefdd2d7a469a6da7366f1c4f905af8fc8285df5 --- .../include/sysio.swap/sysio.swap.hpp | 30 +++++- contracts/sysio.swap/sysio.swap.abi | 61 +++++++++++ contracts/sysio.swap/sysio.swap.cpp | 30 +++++- contracts/sysio.swap/sysio.swap.wasm | Bin 34094 -> 36422 bytes contracts/tests/sysio.swap_tests.cpp | 100 ++++++++++++++++-- 5 files changed, 208 insertions(+), 13 deletions(-) diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index 1bc6701664..1676af0a5a 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -11,6 +11,7 @@ #include #include #include +#include using namespace sysio; using namespace std; @@ -51,9 +52,19 @@ namespace sysio { /// 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, names the pair's shadow token (one of its two legs) + /// and makes it a yield pool: the pool absorbs the WIRE yield distributed on + /// the shadow it holds, and sells queued yield shadow through itself. At most + /// one yield pool may exist per shadow symbol. Empty makes a plain pool. [[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); + 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, and the hard ceiling on one clip as basis + /// points of the pool's shadow side. Both must be nonzero before tickyield runs. + [[sysio::action]] void setyield(symbol_code pair_token, + uint32_t conversion_horizon_sec, uint32_t depth_cap_bps); [[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); @@ -133,7 +144,19 @@ namespace sysio { int fee; name fee_authority; ///< whose signature changefee requires for this pair asset locked_shares; ///< part of `supply` held by no account, never redeemable - SYSLIB_SERIALIZE(currency_stats, (supply)(max_supply)(issuer)(pool1)(pool2)(fee)(fee_authority)(locked_shares)) + 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 + time_point last_tick{}; ///< elapsed-time base of the clip formula + SYSLIB_SERIALIZE(currency_stats, (supply)(max_supply)(issuer)(pool1)(pool2)(fee)(fee_authority) + (locked_shares)(yield_leg)(conversion_horizon_sec)(depth_cap_bps)(last_tick)) + }; + + /// One yield pool per shadow symbol: keyed by the shadow's extended symbol, + /// this is also the lookup from a shadow symbol to its pair. + struct [[sysio::table("yieldpairs")]] yield_pair { + symbol_code pair; + SYSLIB_SERIALIZE(yield_pair, (pair)) }; struct [[sysio::table("evoindex")]] pair_index { @@ -162,11 +185,14 @@ namespace sysio { 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 yieldpairs = kv::table<"yieldpairs"_n, extended_symbol_key, yield_pair>; /// 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); 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); diff --git a/contracts/sysio.swap/sysio.swap.abi b/contracts/sysio.swap/sysio.swap.abi index 8371796a77..28c1f0f326 100644 --- a/contracts/sysio.swap/sysio.swap.abi +++ b/contracts/sysio.swap/sysio.swap.abi @@ -144,6 +144,22 @@ { "name": "locked_shares", "type": "asset" + }, + { + "name": "yield_leg", + "type": "extended_symbol?" + }, + { + "name": "conversion_horizon_sec", + "type": "uint32" + }, + { + "name": "depth_cap_bps", + "type": "uint32" + }, + { + "name": "last_tick", + "type": "time_point" } ] }, @@ -238,6 +254,10 @@ { "name": "locked_shares", "type": "asset" + }, + { + "name": "yield_leg", + "type": "extended_symbol?" } ] }, @@ -369,6 +389,24 @@ } ] }, + { + "name": "setyield", + "base": "", + "fields": [ + { + "name": "pair_token", + "type": "symbol_code" + }, + { + "name": "conversion_horizon_sec", + "type": "uint32" + }, + { + "name": "depth_cap_bps", + "type": "uint32" + } + ] + }, { "name": "swap_config", "base": "", @@ -432,6 +470,16 @@ "type": "string" } ] + }, + { + "name": "yield_pair", + "base": "", + "fields": [ + { + "name": "pair", + "type": "symbol_code" + } + ] } ], "actions": [ @@ -485,6 +533,11 @@ "type": "setconfig", "ricardian_contract": "" }, + { + "name": "setyield", + "type": "setyield", + "ricardian_contract": "" + }, { "name": "sync", "type": "sync", @@ -549,6 +602,14 @@ "key_names": ["name"], "key_types": ["name"], "table_id": 40605 + }, + { + "name": "yieldpairs", + "type": "yield_pair", + "index_type": "i64", + "key_names": ["contract","symbol"], + "key_types": ["name","uint64"], + "table_id": 19873 } ], "ricardian_clauses": [], diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index aac295fafb..e9c485cc18 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -219,7 +219,8 @@ void swap::setconfig(name fee_authority) { } 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) +extended_asset initial_pool2, int initial_fee, name fee_authority, asset locked_shares, +std::optional yield_leg) { require_auth( user ); require_auth( get_self() ); @@ -234,6 +235,13 @@ extended_asset initial_pool2, int initial_fee, name fee_authority, asset locked_ 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"); + if (yield_leg) { + check( *yield_leg == initial_pool1.get_extended_symbol() || *yield_leg == initial_pool2.get_extended_symbol(), + "yield_leg must be one of the pair's legs" ); + yieldpairs yieldtable( get_self() ); + yieldtable.emplace( user, key_of(*yield_leg), yield_pair{ new_symbol.code() }, + "a yield pool already exists for this symbol" ); + } stats statstable( get_self() ); const pair_key key{ new_symbol.code().raw() }; @@ -255,6 +263,7 @@ extended_asset initial_pool2, int initial_fee, name fee_authority, asset locked_ .fee = initial_fee, .fee_authority = fee_authority, .locked_shares = locked_shares, + .yield_leg = yield_leg, } ); priceaccums accums( get_self() ); @@ -298,6 +307,25 @@ void swap::sync(symbol_code pair_token) { 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; +} + +void swap::setyield(symbol_code pair_token, uint32_t conversion_horizon_sec, uint32_t depth_cap_bps) { + 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); + require_yield_leg(*token); + check( depth_cap_bps <= opp::amm::BPS_TOTAL, "depth_cap_bps out of range" ); + statstable.modify( name{}, key, [&]( auto& a ) { + a.conversion_horizon_sec = conversion_horizon_sec; + a.depth_cap_bps = depth_cap_bps; + } ); +} + void swap::changefee(symbol_code pair_token, int newfee) { stats statstable( get_self() ); const pair_key key{ pair_token.raw() }; diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 7c98f6d208ae31372b1cd7449b9a73dc34a88b1c..c813bf2ade036b5d0db75adaa36e5e9475a745c7 100755 GIT binary patch delta 8384 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 yield_leg = std::nullopt ){ return inittoken( user, new_symbol, initial_pool1, initial_pool2, initial_fee, fee_authority, - asset( locked_shares, new_symbol ) ); + 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 ){ + 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, @@ -178,8 +181,22 @@ class sysio_swap_tester : public tester { ("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, + 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 ) + ); + } + // 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" ); + } 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 ) @@ -981,14 +998,14 @@ BOOST_FIXTURE_TEST_CASE( the_other_actions, sysio_swap_tester ) try { ("user", "alice"_n) ("new_symbol", EVO4) ("initial_pool1", extend(asset::from_string("1.0000 EOS"))) ("initial_pool2", extend(asset::from_string("1.0000 ECO"))) - ("initial_fee", 1) ("fee_authority", "carol"_n) ("locked_shares", asset::from_string("0.0000 EVO")) ) + ("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 EOS"))) ("initial_pool2", extend(asset::from_string("1.0000 ECO"))) - ("initial_fee", 1) ("fee_authority", "carol"_n) ("locked_shares", asset::from_string("0.0000 EVO")) ) + ("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.0001 EOS")), @@ -1140,6 +1157,60 @@ BOOST_FIXTURE_TEST_CASE( seed_locks_shares, sysio_swap_tester ) try { BOOST_REQUIRE_GE( settle_swap("alice"_n, EVO, asset(1000, EOS4), VOICE4, 1), 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 eos{ EOS4, "sysio.token"_n }; + const extended_symbol tusd{ TUSD2, "sysio.token"_n }; + + // The yield leg must be one of the pair's own legs. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("yield_leg must be one of the pair's legs"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.0000 VOICE")), 10, name{}, 0, tusd ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("yield_leg must be one of the pair's legs"), inittoken( "alice"_n, EVO4, + extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.0000 VOICE")), 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 EOS")), extend(asset::from_string("1.0000 VOICE")), 10, name{}, 0, voice ) ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.00 TUSD")), 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 symbol: VOICE/TUSD may not also yield on VOICE... + BOOST_REQUIRE_EQUAL( wasm_assert_msg("a yield pool already exists for this symbol"), inittoken( "alice"_n, + symbol::from_string("3,BVO"), extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.00 TUSD")), + 10, name{}, 0, voice ) ); + // ...but may yield on TUSD, or be plain. + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, + symbol::from_string("3,BVO"), extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.00 TUSD")), + 10, name{}, 0, tusd ) ); + + // setyield: fee authority only, yield pools only, cap within basis points. + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair has no yield leg"), setyield( ETUSD, 86400, 3 ) ); + BOOST_REQUIRE_EQUAL( error("missing authority of sysio"), setyield( EVO, 86400, 3, "alice"_n ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("depth_cap_bps out of range"), setyield( EVO, 86400, 10001 ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), setyield( EOS, 86400, 3 ) ); + BOOST_REQUIRE_EQUAL( success(), setyield( EVO, 86400, 3 ) ); + evo = pair_row(EVO); + BOOST_REQUIRE_EQUAL( 86400u, evo["conversion_horizon_sec"].as_uint64() ); + BOOST_REQUIRE_EQUAL( 3u, evo["depth_cap_bps"].as_uint64() ); + // The leg is fixed at creation; setyield does not touch it. + BOOST_REQUIRE_EQUAL( "4,VOICE", evo["yield_leg"]["sym"].as_string() ); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( fee_authority_configuration, sysio_swap_tester ) try { create_tokens_and_issue(); abi_ser.set_abi( swap_abi_def(), abi_serializer::create_yield_function(abi_serializer_max_time) ); @@ -1835,7 +1906,7 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { for (const auto& a : abi.actions) actions.insert(a.name.to_string()); const std::set expected_actions{ "addliquidity", "changefee", "close", "closeext", "exchange", - "inittoken", "open", "openext", "remliquidity", "setconfig", "sync", "transfer", "withdraw" }; + "inittoken", "open", "openext", "remliquidity", "setconfig", "setyield", "sync", "transfer", "withdraw" }; BOOST_REQUIRE( actions == expected_actions ); using field_list = std::vector>; @@ -1853,10 +1924,15 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { 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"} }; + {"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"} }; + {"pool2", "extended_asset"}, {"fee", "int32"}, {"fee_authority", "name"}, {"locked_shares", "asset"}, + {"yield_leg", "extended_symbol?"}, {"conversion_horizon_sec", "uint32"}, {"depth_cap_bps", "uint32"}, + {"last_tick", "time_point"} }; + const field_list setyield_fields{ + {"pair_token", "symbol_code"}, {"conversion_horizon_sec", "uint32"}, {"depth_cap_bps", "uint32"} }; + const field_list yield_pair_fields{ {"pair", "symbol_code"} }; const field_list setconfig_fields{ {"fee_authority", "name"} }; const field_list swap_config_fields{ {"fee_authority", "name"} }; const field_list sync_fields{ {"pair_token", "symbol_code"} }; @@ -1878,6 +1954,8 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { 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("yield_pair") == yield_pair_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. @@ -1898,10 +1976,12 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { 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("yieldpairs"), { "yield_pair", {"contract", "symbol"}, {"name", "uint64"} } ) ); std::set tables; for (const auto& t : abi.tables) tables.insert(t.name); - const std::set expected_tables{ "accounts", "evodexacnts", "evoindex", "priceaccum", "stat", "swapconfig" }; + const std::set expected_tables{ + "accounts", "evodexacnts", "evoindex", "priceaccum", "stat", "swapconfig", "yieldpairs" }; BOOST_REQUIRE( tables == expected_tables ); } FC_LOG_AND_RETHROW() From 1355e609a0cfcbfd65bd1e62d2647a3a625cb954 Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Sun, 13 Sep 2026 11:58:08 -0400 Subject: [PATCH 15/37] swap: settle a yield pool's owed WIRE into the pool, minting nothing A yield pool's shadow token pays WIRE yield to its holders through a cumulative index, and the contract, holding the pool's shadow, is such a holder. `sysio.opp.common/shadow_yield.hpp` is the read contract between the token and its holders: table names, row layouts, the index scale, the `owed` formula, and the two typed actions a holder calls (`claim`, `addyield`). The swap reads the token's tables in place through aliases local to its implementation file (an alias inside the contract class makes the ABI generator list the table as the contract's own). `accrue` computes what the contract is owed from that public state, credits it to the pool's other leg with no shares minted, records a receipt keyed by the shadow contract, and calls the token's `claim` inline; `ontransfer` matches the delivering transfer against the receipt and retires it, failing on any other amount. A receipt that outlives its transaction blocks further accrual through that contract. Accrual runs before every mint and burn (`add_signed_liq`), so yield always belongs to the shares that existed when it was earned, and the new `accrueyield` action lets anyone settle in between. A plain token in a yield leg has no index row and reads as owing nothing. Deposit accounts are not yield-bearing: the contract's whole holding earns for the pool. `contracts/test_contracts/shadowtoken` is the mock shadow token the suite drives: sysio.token's shape plus the distribution, settling every balance move, stamping new holders at the current index, and carrying the truncation remainder. Tests cover a single and a chained distribution against a hand-written reference, exactness of the delivered payout, the mint and burn pricing against the accrued pool, refusals, and the payout route being live only while a receipt exists. Docs and the ABI pin gain the yield leg, setyield and accrueyield. Co-Authored-By: Claude Fable 5.1 Change-Id: Ic18f2d01bd283719a4c041bef139c8bd6cb90d3b --- .../include/sysio.opp.common/shadow_yield.hpp | 81 ++++ contracts/sysio.swap/Commands.md | 16 +- contracts/sysio.swap/README.md | 4 + .../include/sysio.swap/sysio.swap.hpp | 43 +++ .../ricardian/sysio.swap.contracts.md | 26 ++ contracts/sysio.swap/sysio.swap.abi | 47 +++ contracts/sysio.swap/sysio.swap.cpp | 94 ++++- contracts/sysio.swap/sysio.swap.wasm | Bin 36422 -> 40314 bytes contracts/test_contracts/CMakeLists.txt | 1 + .../test_contracts/shadowtoken/CMakeLists.txt | 5 + .../shadowtoken/shadowtoken.cpp | 112 ++++++ .../shadowtoken/shadowtoken.hpp | 63 +++ contracts/tests/contracts.hpp.in | 2 + contracts/tests/sysio.swap_tests.cpp | 362 +++++++++++++++++- 14 files changed, 836 insertions(+), 20 deletions(-) create mode 100644 contracts/sysio.opp.common/include/sysio.opp.common/shadow_yield.hpp create mode 100644 contracts/test_contracts/shadowtoken/CMakeLists.txt create mode 100644 contracts/test_contracts/shadowtoken/shadowtoken.cpp create mode 100644 contracts/test_contracts/shadowtoken/shadowtoken.hpp 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..b2b0bb6997 --- /dev/null +++ b/contracts/sysio.opp.common/include/sysio.opp.common/shadow_yield.hpp @@ -0,0 +1,81 @@ +#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. + * + * 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 + +namespace sysio::opp::shadow { + +using u128 = unsigned __int128; + +/// 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 +/// (the token contract 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; + uint64_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 { + uint64_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. +inline uint64_t owed(const account& row, uint64_t index) { + if (index <= row.index_checkpoint || row.balance.amount <= 0) return row.owed_wire; + const u128 accrued = static_cast(row.balance.amount) * (index - row.index_checkpoint) / YIELD_INDEX_SCALE; + return row.owed_wire + static_cast(accrued); +} + +} // namespace sysio::opp::shadow diff --git a/contracts/sysio.swap/Commands.md b/contracts/sysio.swap/Commands.md index ad8afc6042..5072523ce8 100644 --- a/contracts/sysio.swap/Commands.md +++ b/contracts/sysio.swap/Commands.md @@ -29,9 +29,21 @@ 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 EOS/PESO evotoken. 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) and the shares to lock. 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. +Create the EOS/PESO evotoken. 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. - cleos push action evolutiondex inittoken '["YOUR_ACCOUNT", "4,EOSPESO", {"contract":"eosio.token", "quantity":"1.0000 EOS"}, {"contract":"pesocontract", "quantity":"1.0000 PESO"}, 10, "", "0.1000 EOSPESO"]' -p YOUR_ACCOUNT -p evolutiondex + cleos push action evolutiondex inittoken '["YOUR_ACCOUNT", "4,EOSPESO", {"contract":"eosio.token", "quantity":"1.0000 EOS"}, {"contract":"pesocontract", "quantity":"1.0000 PESO"}, 10, "", "0.1000 EOSPESO", null]' -p YOUR_ACCOUNT -p evolutiondex + +Create a yield pool instead: name one of the legs as the pair's shadow token (a token that pays WIRE yield to its holders). Only one yield pool may exist per shadow symbol. + + 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, and the ceiling on one clip in basis points of the pool's shadow side. + + cleos push action evolutiondex setyield '["SHDEOS", 86400, 3]' -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 Set the contract-wide fee authority (deployment step, the contract's own authority): diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md index 293a2957b8..16656d8c15 100644 --- a/contracts/sysio.swap/README.md +++ b/contracts/sysio.swap/README.md @@ -27,6 +27,10 @@ A pair is seeded with an evotoken supply equal to the square root of the product 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 one of its legs as the pair's 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. At most one yield pool may exist per shadow symbol (`yieldpairs` table). A pair's fee authority sets its tick parameters with `setyield`: the horizon over which queued yield is meant to sell and the ceiling on one clip as basis points of the pool's shadow side. + **Some considerations from the perspective of liquidity providers** Being a liquidity provider is a financial position that deserves a diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index 1676af0a5a..b30c8cc886 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -65,6 +66,12 @@ namespace sysio { /// points of the pool's shadow side. Both must be nonzero before tickyield runs. [[sysio::action]] void setyield(symbol_code pair_token, uint32_t conversion_horizon_sec, uint32_t depth_cap_bps); + /// 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); [[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); @@ -117,6 +124,13 @@ namespace sysio { 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)) + }; + // --- Rows --- /// Contract-wide configuration, set on deployment by `setconfig`. @@ -159,6 +173,18 @@ namespace sysio { SYSLIB_SERIALIZE(yield_pair, (pair)) }; + /// 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)) + }; + struct [[sysio::table("evoindex")]] pair_index { symbol evo_symbol; SYSLIB_SERIALIZE(pair_index, (evo_symbol)) @@ -186,6 +212,10 @@ namespace sysio { using evoindexes = kv::table<"evoindex"_n, pair_identity_key, pair_index>; using priceaccums = kv::table<"priceaccum"_n, pair_key, price_accumulator>; using yieldpairs = kv::table<"yieldpairs"_n, extended_symbol_key, yield_pair>; + using yieldpayouts = kv::table<"yieldpayouts"_n, contract_key, payout_receipt>; + // (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 deposit-row key of an extended symbol. static extended_symbol_key key_of(const extended_symbol& ext_symbol); @@ -193,6 +223,19 @@ namespace sysio { 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 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); + /// 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. + 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); diff --git a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md index 1c591ecfa3..9c78162f61 100644 --- a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md +++ b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md @@ -81,6 +81,8 @@ The fee authority is the account whose authorization the changefee action requir 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 one of the two legs, 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 other leg without minting, before every addliquidity and remliquidity and on accrueyield. Only one yield pool may exist for a given shadow token. 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. @@ -170,6 +172,30 @@ The cumulative-price accumulators of the token {{pair_token}} are advanced to th 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, and the ceiling of {{depth_cap_bps}} basis points (at most 10000) of the pool's shadow side on one clip. Both must be nonzero before the pool's yield tick can run. + + +

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. + +

changefee

--- diff --git a/contracts/sysio.swap/sysio.swap.abi b/contracts/sysio.swap/sysio.swap.abi index 28c1f0f326..ab780ba73d 100644 --- a/contracts/sysio.swap/sysio.swap.abi +++ b/contracts/sysio.swap/sysio.swap.abi @@ -23,6 +23,16 @@ } ] }, + { + "name": "accrueyield", + "base": "", + "fields": [ + { + "name": "pair_token", + "type": "symbol_code" + } + ] + }, { "name": "addliquidity", "base": "", @@ -95,6 +105,16 @@ } ] }, + { + "name": "contract_key", + "base": "", + "fields": [ + { + "name": "contract", + "type": "name" + } + ] + }, { "name": "cumulative_price", "base": "", @@ -339,6 +359,20 @@ } ] }, + { + "name": "payout_receipt", + "base": "", + "fields": [ + { + "name": "pair", + "type": "symbol_code" + }, + { + "name": "quantity", + "type": "extended_asset" + } + ] + }, { "name": "price_accumulator", "base": "", @@ -483,6 +517,11 @@ } ], "actions": [ + { + "name": "accrueyield", + "type": "accrueyield", + "ricardian_contract": "" + }, { "name": "addliquidity", "type": "addliquidity", @@ -610,6 +649,14 @@ "key_names": ["contract","symbol"], "key_types": ["name","uint64"], "table_id": 19873 + }, + { + "name": "yieldpayouts", + "type": "payout_receipt", + "index_type": "i64", + "key_names": ["contract"], + "key_types": ["name"], + "table_id": 50476 } ], "ricardian_clauses": [], diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index e9c485cc18..5f7294804c 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -2,6 +2,17 @@ #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; +} + namespace sysio { void swap::openext( const name& user, const name& payer, const extended_symbol& ext_symbol) { @@ -37,6 +48,16 @@ void swap::ontransfer(name from, name to, asset quantity, string memo) { check(quantity.amount >= 0, "quantity must be positive"); auto incoming = extended_asset{quantity, get_first_receiver()}; + // 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( incoming == receipt->quantity, "yield payout does not match the claim" ); + payouts.erase( payer ); + return; + } string_view memosv(memo); if ( starts_with(memosv, EXCHANGE) ) { memoexchange(from, incoming, memosv.substr(EXCHANGE.size()) ); @@ -98,17 +119,20 @@ void swap::add_signed_liq(name user, asset to_add, bool is_buying, check( to_add.is_valid(), "invalid asset"); stats statstable( get_self() ); const pair_key key{ to_add.symbol.code().raw() }; - const auto token = statstable.try_get( key ); - check ( token.has_value(), "pair token does not exist" ); - auto A = token-> supply.amount; - auto P1 = token-> pool1.quantity.amount; - auto P2 = token-> pool2.quantity.amount; + 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}; + 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}; + 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) && @@ -117,7 +141,7 @@ void swap::add_signed_liq(name user, asset to_add, bool is_buying, 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); + update_price_accumulators(token); statstable.modify( name{}, key, [&]( auto& a ) { a.supply += to_add; a.pool1 += to_pay1; @@ -125,9 +149,9 @@ void swap::add_signed_liq(name user, asset to_add, bool is_buying, }); // 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; + 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"); + check(remaining >= token.locked_shares.amount, "locked shares cannot be removed"); } void swap::exchange( name user, symbol_code pair_token, @@ -312,6 +336,56 @@ const extended_symbol& swap::require_yield_leg(const currency_stats& token) { return *token.yield_leg; } +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::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) { stats statstable( get_self() ); const pair_key key{ pair_token.raw() }; diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index c813bf2ade036b5d0db75adaa36e5e9475a745c7..7d2ee4b798ed5081da2818ff3de8356d4a7da0ae 100755 GIT binary patch delta 8644 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 diff --git a/contracts/test_contracts/CMakeLists.txt b/contracts/test_contracts/CMakeLists.txt index cedf3b4139..00042b2761 100644 --- a/contracts/test_contracts/CMakeLists.txt +++ b/contracts/test_contracts/CMakeLists.txt @@ -3,3 +3,4 @@ add_subdirectory(sendinline) add_subdirectory(reenter_deposit) add_subdirectory(block_transfer) add_subdirectory(badtoken) +add_subdirectory(shadowtoken) diff --git a/contracts/test_contracts/shadowtoken/CMakeLists.txt b/contracts/test_contracts/shadowtoken/CMakeLists.txt new file mode 100644 index 0000000000..a098a628f1 --- /dev/null +++ b/contracts/test_contracts/shadowtoken/CMakeLists.txt @@ -0,0 +1,5 @@ +if(BUILD_SYSTEM_CONTRACTS) + add_contract(shadowtoken shadowtoken shadowtoken.cpp) + target_include_directories(shadowtoken + PUBLIC $) +endif() diff --git a/contracts/test_contracts/shadowtoken/shadowtoken.cpp b/contracts/test_contracts/shadowtoken/shadowtoken.cpp new file mode 100644 index 0000000000..62c4d699ab --- /dev/null +++ b/contracts/test_contracts/shadowtoken/shadowtoken.cpp @@ -0,0 +1,112 @@ +#include "shadowtoken.hpp" + +namespace { + using u128 = sysio::opp::shadow::u128; +} + +void shadowtoken::create(name issuer, asset maximum_supply, name wire_contract, symbol wire_symbol) { + require_auth( get_self() ); + check( maximum_supply.is_valid() && maximum_supply.amount > 0, "invalid supply" ); + stats statstable( get_self() ); + const opp::shadow::symbol_key key{ maximum_supply.symbol.code().raw() }; + statstable.emplace( get_self(), key, currency_stats{ + .supply = asset{ 0, maximum_supply.symbol }, + .max_supply = maximum_supply, + .issuer = issuer, + .wire_contract = wire_contract, + .wire_symbol = wire_symbol, + }, "symbol already exists" ); +} + +void shadowtoken::issue(name to, asset quantity, string memo) { + stats statstable( get_self() ); + const opp::shadow::symbol_key key{ quantity.symbol.code().raw() }; + const auto st = statstable.get( key, "symbol does not exist" ); + require_auth( st.issuer ); + check( quantity.is_valid() && quantity.amount > 0, "invalid quantity" ); + check( quantity.symbol == st.supply.symbol, "symbol precision mismatch" ); + check( quantity.amount <= st.max_supply.amount - st.supply.amount, "quantity exceeds available supply" ); + statstable.modify( name{}, key, [&]( auto& s ) { s.supply += quantity; } ); + settle_and_adjust( to, quantity ); +} + +void shadowtoken::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" ); + check( quantity.is_valid() && quantity.amount > 0, "invalid quantity" ); + require_recipient( from ); + require_recipient( to ); + settle_and_adjust( from, -quantity ); + settle_and_adjust( to, quantity ); +} + +void shadowtoken::addyield(name from, asset quantity, symbol_code target) { + require_auth( from ); + check( quantity.amount > 0, "yield must be positive" ); + stats statstable( get_self() ); + const opp::shadow::symbol_key key{ target.raw() }; + const auto st = statstable.get( key, "shadow symbol does not exist" ); + check( quantity.symbol == st.wire_symbol, "yield must be in the wire symbol" ); + check( st.supply.amount > 0, "no holders to distribute to" ); + + yieldidxs indexes( get_self() ); + opp::shadow::yield_index idx = indexes.try_get( key ).value_or( opp::shadow::yield_index{} ); + const u128 total = static_cast(quantity.amount) * opp::shadow::YIELD_INDEX_SCALE + idx.carry; + idx.index += static_cast( total / static_cast(st.supply.amount) ); + idx.carry = static_cast( total % static_cast(st.supply.amount) ); + idx.pot += quantity.amount; + indexes.upsert( get_self(), key, idx ); + + action( permission_level{ from, "active"_n }, st.wire_contract, "transfer"_n, + std::make_tuple( from, get_self(), quantity, string("yield") ) ).send(); +} + +void shadowtoken::claim(name holder, symbol_code sym) { + require_auth( holder ); + accounts holdings( get_self(), holder.value ); + const opp::shadow::symbol_key key{ sym.raw() }; + const auto row = holdings.try_get( key ); + check( row.has_value(), "no balance object found" ); + const uint64_t index = current_index( sym ); + const uint64_t owed = opp::shadow::owed( *row, index ); + holdings.modify( name{}, key, [&]( auto& a ) { + a.index_checkpoint = index; + a.owed_wire = 0; + } ); + if (owed == 0) return; + + stats statstable( get_self() ); + const auto st = statstable.get( key, "shadow symbol does not exist" ); + yieldidxs indexes( get_self() ); + indexes.modify( name{}, key, [&]( auto& y ) { + check( y.pot >= owed, "pot underfunded" ); + y.pot -= owed; + } ); + action( permission_level{ get_self(), "active"_n }, st.wire_contract, "transfer"_n, + std::make_tuple( get_self(), holder, asset{ int64_t(owed), st.wire_symbol }, string("") ) ).send(); +} + +uint64_t shadowtoken::current_index(symbol_code sym) const { + yieldidxs indexes( get_self() ); + const auto idx = indexes.try_get( opp::shadow::symbol_key{ sym.raw() } ); + return idx ? idx->index : 0; +} + +void shadowtoken::settle_and_adjust(name owner, const asset& delta) { + accounts holdings( get_self(), owner.value ); + const opp::shadow::symbol_key key{ delta.symbol.code().raw() }; + const uint64_t 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( get_self(), key, opp::shadow::account{ delta, index, 0 } ); + return; + } + opp::shadow::account updated = *row; + updated.owed_wire = opp::shadow::owed( updated, index ); // settle before mutate + updated.index_checkpoint = index; + updated.balance += delta; + check( updated.balance.amount >= 0, "overdrawn balance" ); + holdings.modify( name{}, key, [&]( auto& a ) { a = updated; } ); +} diff --git a/contracts/test_contracts/shadowtoken/shadowtoken.hpp b/contracts/test_contracts/shadowtoken/shadowtoken.hpp new file mode 100644 index 0000000000..9e009f07d7 --- /dev/null +++ b/contracts/test_contracts/shadowtoken/shadowtoken.hpp @@ -0,0 +1,63 @@ +#pragma once +/** + * @file shadowtoken.hpp + * @brief Test stand-in for the shadow token: sysio.token's shape plus the yield + * distribution of sysio.opp.common/shadow_yield.hpp, settled in every + * balance move. + * + * What sysio.swap depends on is the read layout in that shared header and the + * two typed calls below; this contract implements the minimum that exercises + * them end to end, including the rules the design plan marks as mandatory: + * settle before mutate, a new holder stamped at the current index, and the + * truncation remainder carried rather than dropped. + */ + +#include +#include +#include +#include +#include +#include + +using namespace sysio; +using std::string; + +class [[sysio::contract("shadowtoken")]] shadowtoken : public contract { +public: + using contract::contract; + + /// Register a shadow symbol and the WIRE token its yield is paid in. + [[sysio::action]] void create(name issuer, asset maximum_supply, name wire_contract, symbol wire_symbol); + [[sysio::action]] void issue(name to, asset quantity, string memo); + [[sysio::action]] void transfer(name from, name to, asset quantity, string memo); + /// Move `quantity` WIRE from `from` (its own authority, by inline transfer) + /// into `target`'s pot, advancing the index by quantity / supply and + /// carrying the remainder. + [[sysio::action]] void addyield(name from, asset quantity, symbol_code target); + /// Settle `holder`'s row for `sym` and pay what it is owed from the + /// contract's own WIRE, then zero it. Holder's authority. + [[sysio::action]] void claim(name holder, symbol_code sym); + + static_assert( "addyield"_n == opp::shadow::ADDYIELD_ACTION && "claim"_n == opp::shadow::CLAIM_ACTION, + "the action names above are the ones holders call through shadow_yield.hpp" ); + +private: + struct [[sysio::table("stat")]] currency_stats { + asset supply; + asset max_supply; + name issuer; + name wire_contract; + symbol wire_symbol; + SYSLIB_SERIALIZE(currency_stats, (supply)(max_supply)(issuer)(wire_contract)(wire_symbol)) + }; + + using stats = kv::table<"stat"_n, opp::shadow::symbol_key, currency_stats>; + using accounts = kv::scoped_table<"accounts"_n, opp::shadow::symbol_key, opp::shadow::account>; + using yieldidxs = kv::table<"yieldidx"_n, opp::shadow::symbol_key, opp::shadow::yield_index>; + + uint64_t current_index(symbol_code sym) const; + /// Settle `owner`'s row at the current index, then apply `delta` to its + /// balance. A holder without a row is stamped at the current index, so no + /// history is credited to it. + void settle_and_adjust(name owner, const asset& delta); +}; diff --git a/contracts/tests/contracts.hpp.in b/contracts/tests/contracts.hpp.in index 4dadac1045..d2512529e4 100644 --- a/contracts/tests/contracts.hpp.in +++ b/contracts/tests/contracts.hpp.in @@ -44,6 +44,8 @@ struct contracts { 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 shadowtoken_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/test_contracts/shadowtoken/shadowtoken.wasm"); } + static std::vector shadowtoken_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/test_contracts/shadowtoken/shadowtoken.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/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index 783ef391b8..b92ab36a2a 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -25,6 +25,22 @@ 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; + uint64_t index_checkpoint; + uint64_t owed_wire; +}; +FC_REFLECT( shadow_account_row, (balance)(index_checkpoint)(owed_wire) ) +struct shadow_index_row { + uint64_t index; + uint64_t pot; + uint64_t carry; +}; +FC_REFLECT( shadow_index_row, (index)(pot)(carry) ) + class sysio_swap_tester : public tester { public: @@ -34,7 +50,7 @@ class sysio_swap_tester : public tester { produce_blocks( 2 ); create_accounts( { "alice"_n, "bob"_n, "carol"_n, "sysio.token"_n, "sysio.swap"_n, - "badtoken"_n, "anothertoken"_n } ); + "badtoken"_n, "anothertoken"_n, "shadowtoken"_n } ); produce_blocks( 2 ); // sysio.token bills every row to the system account (ram_payer = "sysio"), @@ -57,6 +73,10 @@ class sysio_swap_tester : public tester { set_code( "badtoken"_n, contracts::util::badtoken_wasm() ); set_abi( "badtoken"_n, contracts::util::badtoken_abi().data() ); + // The shadow token stand-in bills its rows to itself; no privilege needed. + set_code( "shadowtoken"_n, contracts::util::shadowtoken_wasm() ); + set_abi( "shadowtoken"_n, contracts::util::shadowtoken_abi().data() ); + produce_blocks(); // Deployment's configuration step: governance executes as sysio. @@ -66,6 +86,11 @@ class sysio_swap_tester : public tester { 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( "shadowtoken"_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)); } // Push `act` on sysio.swap under exactly the given authorities, resolving the @@ -197,6 +222,71 @@ class sysio_swap_tester : public tester { 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() } ); + } + + // --- The shadow token stand-in (contracts/test_contracts/shadowtoken) --- + + action_result push_shadow_action( name signer, action_name act, const variant_object& data ) { + action a; + a.account = "shadowtoken"_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() ); + } + action_result shadow_create( name issuer, asset maximum_supply, extended_symbol wire ) { + return push_shadow_action( "shadowtoken"_n, "create"_n, mvo() + ( "issuer", issuer )( "maximum_supply", maximum_supply ) + ( "wire_contract", wire.contract )( "wire_symbol", wire.sym ) ); + } + action_result shadow_issue( name issuer, name to, asset quantity ) { + return push_shadow_action( issuer, "issue"_n, mvo()( "to", to )( "quantity", quantity )( "memo", "" ) ); + } + 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, so `from`'s active + // must carry shadowtoken@sysio.code (see grant_shadow_code). + 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 ) ); + } + // Let the shadow token act for `account`: its active keeps its key and gains + // shadowtoken@sysio.code, the delegation addyield's inline transfer needs. + void grant_shadow_code( name account ) { + set_authority( account, config::active_name, + authority( 1, { key_weight{ get_public_key( account, "active" ), 1 } }, + { permission_level_weight{ { "shadowtoken"_n, config::sysio_code_name }, 1 } } ), + config::owner_name ); + produce_block(); + } + // `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( "shadowtoken"_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( "shadowtoken"_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 ) @@ -383,7 +473,13 @@ class sysio_swap_tester : public tester { create( "carol"_n, "carol"_n, asset::from_string("1.0000 VOICE") ); issue( "carol"_n, "carol"_n, "carol"_n, asset::from_string("1.0000 VOICE"), ""); } + // The yield-pool state the accrual tests start from: SHD (the shadow, paying + // its yield in EOS) issued to alice, bob holding EOS to donate and able to + // route it through the shadow token, and SHEO = SHD/EOS created by alice as + // a yield pool on SHD. Defined after the file-scope symbols it uses. + void setup_yield_pool(); abi_serializer abi_ser; + abi_serializer shadow_abi_ser; }; static symbol EVO4 = symbol::from_string("4,EVO"); @@ -398,6 +494,21 @@ 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 shadow token and its yield pool against EOS (the tests' stand-in for WIRE). +static symbol SHD4 = symbol::from_string("4,SHD"); +static symbol SHEO4 = symbol::from_string("4,SHEO"); +static symbol_code SHD = SHD4.to_symbol_code(); +static symbol_code SHEO = SHEO4.to_symbol_code(); +static const extended_symbol SHADOW{ SHD4, "shadowtoken"_n }; +static const extended_symbol WIRE{ EOS4, "sysio.token"_n }; +static extended_asset shd( int64_t units ) { return extended_asset{ asset( units, SHD4 ), "shadowtoken"_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}; @@ -478,6 +589,23 @@ namespace twap_reference { } } +// The shadow yield spec (sysio.opp.common/shadow_yield.hpp), written by hand: +// 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. +namespace yield_reference { + using wide = boost::multiprecision::uint128_t; + constexpr uint64_t Scale = 1'000'000'000'000; + struct distribution { uint64_t index_delta; uint64_t carry; }; + distribution distribute( int64_t wire, int64_t supply, uint64_t carry_in ) { + const wide total = wide(wire) * Scale + carry_in; + return { uint64_t( total / supply ), uint64_t( total % supply ) }; + } + int64_t owed( int64_t balance, uint64_t index, uint64_t checkpoint, uint64_t banked = 0 ) { + return int64_t( banked + uint64_t( wide(balance) * (index - checkpoint) / Scale ) ); + } +} + vector sysio_swap_tester::total() { const int64_t total_eos = balance("alice"_n, EOS4) + balance("bob"_n, EOS4) + system_balance(EVO.value).at(0) + system_balance(ETUSD.value).at(0); @@ -488,17 +616,44 @@ vector sysio_swap_tester::total() { return { total_eos, total_voice, total_tusd }; } -int64_t sysio_swap_tester::balance( name user, symbol sym ) { - const extended_asset ext = extend( asset(0, sym) ); +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(), sym.value() } ); - BOOST_REQUIRE_MESSAGE( !data.empty(), "no deposit row for " << user << " " << sym ); + { 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 } ); +} + +void sysio_swap_tester::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_create( "alice"_n, asset( 100 * ShadowIssuance, SHD4 ), WIRE ) ); + BOOST_REQUIRE_EQUAL( success(), shadow_issue( "alice"_n, "alice"_n, asset( ShadowIssuance, SHD4 ) ) ); + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "bob"_n, asset( BobDonationBudget, EOS4 ), "" ) ); + grant_shadow_code( "bob"_n ); + BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, SHADOW ) ); + BOOST_REQUIRE_EQUAL( success(), shadow_transfer( "alice"_n, "sysio.swap"_n, asset( YieldPoolShadow, SHD4 ), "" ) ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, SHEO4, shd( YieldPoolShadow ), + extend( asset( YieldPoolWire, EOS4 ) ), 10, name{}, 0, SHADOW ) ); + // The pool holds exactly its seed; 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) ); + 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) ) ); @@ -1167,7 +1322,6 @@ BOOST_FIXTURE_TEST_CASE( yield_leg_rules, sysio_swap_tester ) try { many_openext(); many_transfer(); const extended_symbol voice{ VOICE4, "anothertoken"_n }; - const extended_symbol eos{ EOS4, "sysio.token"_n }; const extended_symbol tusd{ TUSD2, "sysio.token"_n }; // The yield leg must be one of the pair's own legs. @@ -1209,6 +1363,192 @@ BOOST_FIXTURE_TEST_CASE( yield_leg_rules, sysio_swap_tester ) try { BOOST_REQUIRE_EQUAL( 3u, evo["depth_cap_bps"].as_uint64() ); // 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_tester ) try { + setup_yield_pool(); + const auto pool_wire_at_token = [&]() { return token_balance( "sysio.token"_n, "sysio.swap"_n, EOS.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( "shadowtoken"_n ).empty() ); + + // bob donates 100 EOS 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, EOS4 ), SHD ) ); + const auto first = yield_reference::distribute( first_donation, ShadowIssuance, 0 ); + auto idx = shadow_index( SHD ); + BOOST_REQUIRE_EQUAL( first.index_delta, 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 + // EOS side, mints nothing, and the token delivers the same amount in the + // same transaction: the receipt is retired and the contract's EOS 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( "shadowtoken"_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, EOS4 ), SHD ) ); + BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( third_donation, EOS4 ), SHD ) ); + const auto second = yield_reference::distribute( second_donation, ShadowIssuance, first.carry ); + const auto third = yield_reference::distribute( third_donation, ShadowIssuance, second.carry ); + const uint64_t index_after_three = first.index_delta + second.index_delta + third.index_delta; + idx = shadow_index( SHD ); + BOOST_REQUIRE_EQUAL( index_after_three, 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, EOS.value ); + BOOST_REQUIRE_EQUAL( success(), shadow_claim( "alice"_n, SHD ) ); + BOOST_REQUIRE_EQUAL( alice_before + owed_alice, token_balance( "sysio.token"_n, "alice"_n, EOS.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() + +BOOST_FIXTURE_TEST_CASE( yield_is_credited_before_shares_are_priced, sysio_swap_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, EOS4 ), 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 = deposit_of( "alice"_n, WIRE ); + BOOST_REQUIRE_EQUAL( success(), addliquidity( "alice"_n, asset( shares, SHEO4 ), + asset( pay_shadow, SHD4 ), asset( pay_wire, EOS4 ) ) ); + 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, deposit_of( "alice"_n, WIRE ) ); + BOOST_REQUIRE( pending_payout( "shadowtoken"_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, EOS4 ), 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, SHEO4 ), asset( pay_shadow, SHD4 ), asset( stale_wire, EOS4 ) ) ); + // The refused action left nothing behind: no credit, no receipt. + BOOST_REQUIRE( before == system_balance( SHEO.value ) ); + BOOST_REQUIRE( pending_payout( "shadowtoken"_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 = deposit_of( "alice"_n, WIRE ); + BOOST_REQUIRE_EQUAL( success(), remliquidity( "alice"_n, asset( shares, SHEO4 ), + asset( get_shadow, SHD4 ), asset( get_wire, EOS4 ) ) ); + 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, deposit_of( "alice"_n, WIRE ) ); + // 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_payout_route_is_exact, sysio_swap_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_issue( "alice"_n, "shadowtoken"_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( "shadowtoken"_n, "sysio.swap"_n, asset( 1'0000, SHD4 ), "" ) ); + BOOST_REQUIRE_EQUAL( success(), openext( "shadowtoken"_n, "alice"_n, SHADOW ) ); + BOOST_REQUIRE_EQUAL( success(), shadow_transfer( "shadowtoken"_n, "sysio.swap"_n, asset( 1'0000, SHD4 ), "" ) ); + BOOST_REQUIRE_EQUAL( 1'0000, deposit_of( "shadowtoken"_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, EOS4 ), 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( "shadowtoken"_n ).empty() ); } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE( fee_authority_configuration, sysio_swap_tester ) try { @@ -1905,7 +2245,7 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { std::set actions; for (const auto& a : abi.actions) actions.insert(a.name.to_string()); const std::set expected_actions{ - "addliquidity", "changefee", "close", "closeext", "exchange", + "accrueyield", "addliquidity", "changefee", "close", "closeext", "exchange", "inittoken", "open", "openext", "remliquidity", "setconfig", "setyield", "sync", "transfer", "withdraw" }; BOOST_REQUIRE( actions == expected_actions ); @@ -1933,6 +2273,8 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { const field_list setyield_fields{ {"pair_token", "symbol_code"}, {"conversion_horizon_sec", "uint32"}, {"depth_cap_bps", "uint32"} }; const field_list yield_pair_fields{ {"pair", "symbol_code"} }; + const field_list accrueyield_fields{ {"pair_token", "symbol_code"} }; + const field_list payout_receipt_fields{ {"pair", "symbol_code"}, {"quantity", "extended_asset"} }; const field_list setconfig_fields{ {"fee_authority", "name"} }; const field_list swap_config_fields{ {"fee_authority", "name"} }; const field_list sync_fields{ {"pair_token", "symbol_code"} }; @@ -1956,6 +2298,8 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { BOOST_REQUIRE( fields("swap_config") == swap_config_fields ); BOOST_REQUIRE( fields("setyield") == setyield_fields ); BOOST_REQUIRE( fields("yield_pair") == yield_pair_fields ); + BOOST_REQUIRE( fields("accrueyield") == accrueyield_fields ); + BOOST_REQUIRE( fields("payout_receipt") == payout_receipt_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. @@ -1977,11 +2321,13 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { BOOST_REQUIRE( same( shape("priceaccum"), { "price_accumulator", {"symbol_code"}, {"uint64"} } ) ); BOOST_REQUIRE( same( shape("swapconfig"), { "swap_config", {"name"}, {"name"} } ) ); BOOST_REQUIRE( same( shape("yieldpairs"), { "yield_pair", {"contract", "symbol"}, {"name", "uint64"} } ) ); + BOOST_REQUIRE( same( shape("yieldpayouts"), { "payout_receipt", {"contract"}, {"name"} } ) ); + // 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", "stat", "swapconfig", "yieldpairs" }; + "accounts", "evodexacnts", "evoindex", "priceaccum", "stat", "swapconfig", "yieldpairs", "yieldpayouts" }; BOOST_REQUIRE( tables == expected_tables ); } FC_LOG_AND_RETHROW() From bd5989976461271826d7b0b0f31153cb2c0aafa3 Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Sun, 13 Sep 2026 12:03:59 -0400 Subject: [PATCH 16/37] swap: fundyield queues shadow in a yield pool's reservoir A yield pool gains a reservoir (`reservoirs` table, keyed by the pair, created at inittoken): shadow queued to be sold through the pool, held by the contract but in no pool and no deposit. It is its own row rather than a deposit row under a synthetic owner, so no account name can alias it. `fundyield(from, pair_token, quantity)` is the typed, memo-free announcement: `from` names the pair and the exact amount of its shadow it will transfer, and the transfer that matches (same contract, symbol and amount) fills the reservoir instead of `from`'s deposit, in the same transaction or a later one. One announcement per account is pending at a time, a new one replaces it, and while one is pending any other transfer from that account is refused. Both receipt routes in `ontransfer` now compare field-wise: asset's own == asserts on a symbol mismatch, which here must be an ordinary "does not match" failure. Tests cover the refusals, the pending state across transactions, the replacement, the one-transaction shape a contract would use inline, and that the reservoir earns yield for the pool like the rest of the contract's holding while accrual leaves the queue alone. Docs and the ABI pin gain fundyield, the receipt table and the reservoir. Co-Authored-By: Claude Fable 5.1 Change-Id: Ia754e0e2fbd8cb36c10d6cfd2a9430ae90a06ea0 --- contracts/sysio.swap/Commands.md | 9 ++ contracts/sysio.swap/README.md | 4 +- .../include/sysio.swap/sysio.swap.hpp | 35 +++++ .../ricardian/sysio.swap.contracts.md | 15 ++ contracts/sysio.swap/sysio.swap.abi | 73 +++++++++ contracts/sysio.swap/sysio.swap.cpp | 46 +++++- contracts/sysio.swap/sysio.swap.wasm | Bin 40314 -> 43271 bytes contracts/tests/sysio.swap_tests.cpp | 138 +++++++++++++++++- 8 files changed, 311 insertions(+), 9 deletions(-) diff --git a/contracts/sysio.swap/Commands.md b/contracts/sysio.swap/Commands.md index 5072523ce8..dcbe409772 100644 --- a/contracts/sysio.swap/Commands.md +++ b/contracts/sysio.swap/Commands.md @@ -45,6 +45,15 @@ Settle the yield the pool is owed on the shadow it holds into its other leg, min 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 + cleos push action shadowtoken transfer '["YOUR_ACCOUNT", "evolutiondex", "5.0000 SHD", ""]' -p YOUR_ACCOUNT + +See what is queued: + + cleos get table evolutiondex evolutiondex reservoirs -L SHDEOS -U SHDEOS + Set the contract-wide fee authority (deployment step, the contract's own authority): cleos push action evolutiondex setconfig '["sysio"]' -p evolutiondex diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md index 16656d8c15..a9abe4cc93 100644 --- a/contracts/sysio.swap/README.md +++ b/contracts/sysio.swap/README.md @@ -29,7 +29,9 @@ Every pair keeps two cumulative-price accumulators in the `priceaccum` table, on **Yield pools** -A pair may be created as a *yield pool* by naming one of its legs as the pair's 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. At most one yield pool may exist per shadow symbol (`yieldpairs` table). A pair's fee authority sets its tick parameters with `setyield`: the horizon over which queued yield is meant to sell and the ceiling on one clip as basis points of the pool's shadow side. +A pair may be created as a *yield pool* by naming one of its legs as the pair's 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. At most one yield pool may exist per shadow symbol (`yieldpairs` table). + +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. 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. **Some considerations from the perspective of liquidity providers** diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index b30c8cc886..9da4f4faac 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -72,6 +72,14 @@ namespace sysio { /// 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. + [[sysio::action]] void fundyield(name from, symbol_code pair_token, asset quantity); [[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); @@ -131,6 +139,13 @@ namespace sysio { 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`. @@ -185,6 +200,24 @@ namespace sysio { 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)) @@ -213,6 +246,8 @@ namespace sysio { using priceaccums = kv::table<"priceaccum"_n, pair_key, price_accumulator>; using yieldpairs = kv::table<"yieldpairs"_n, extended_symbol_key, yield_pair>; 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.) diff --git a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md index 9c78162f61..a8f75edc68 100644 --- a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md +++ b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md @@ -47,6 +47,8 @@ The default response is to deposit {{quantity}} to {{from}}'s extended balance f 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. @@ -196,6 +198,19 @@ The token {{pair_token}} must be a yield pool. The WIRE the contract is owed by 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. + +Authorization of {{from}} is required. + +

changefee

--- diff --git a/contracts/sysio.swap/sysio.swap.abi b/contracts/sysio.swap/sysio.swap.abi index ab780ba73d..fb2f321e8e 100644 --- a/contracts/sysio.swap/sysio.swap.abi +++ b/contracts/sysio.swap/sysio.swap.abi @@ -243,6 +243,48 @@ } ] }, + { + "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": "", @@ -413,6 +455,16 @@ } ] }, + { + "name": "reservoir", + "base": "", + "fields": [ + { + "name": "balance", + "type": "extended_asset" + } + ] + }, { "name": "setconfig", "base": "", @@ -547,6 +599,11 @@ "type": "exchange", "ricardian_contract": "" }, + { + "name": "fundyield", + "type": "fundyield", + "ricardian_contract": "" + }, { "name": "inittoken", "type": "inittoken", @@ -626,6 +683,14 @@ "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", @@ -642,6 +707,14 @@ "key_types": ["name"], "table_id": 40605 }, + { + "name": "yieldfunds", + "type": "fund_receipt", + "index_type": "i64", + "key_names": ["funder"], + "key_types": ["name"], + "table_id": 29681 + }, { "name": "yieldpairs", "type": "yield_pair", diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index 5f7294804c..af2b993092 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -11,6 +11,15 @@ namespace { sysio::opp::shadow::symbol_key, sysio::opp::shadow::account>; 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 { @@ -54,10 +63,21 @@ void swap::ontransfer(name from, name to, asset quantity, string memo) { yieldpayouts payouts( get_self() ); const contract_key payer{ from }; if (const auto receipt = payouts.try_get( payer )) { - check( incoming == receipt->quantity, "yield payout does not match the claim" ); + 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() ); + reservoir_table.modify( name{}, pair_key{ receipt->pair.raw() }, [&]( auto& r ) { r.balance += incoming; } ); + funds.erase( funder ); + return; + } string_view memosv(memo); if ( starts_with(memosv, EXCHANGE) ) { memoexchange(from, incoming, memosv.substr(EXCHANGE.size()) ); @@ -259,17 +279,18 @@ std::optional yield_leg) 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"); + stats statstable( get_self() ); + const pair_key key{ new_symbol.code().raw() }; + check ( !statstable.contains( key ), "token symbol already exists" ); if (yield_leg) { check( *yield_leg == initial_pool1.get_extended_symbol() || *yield_leg == initial_pool2.get_extended_symbol(), "yield_leg must be one of the pair's legs" ); yieldpairs yieldtable( get_self() ); yieldtable.emplace( user, key_of(*yield_leg), yield_pair{ new_symbol.code() }, "a yield pool already exists for this symbol" ); + reservoirs reservoir_table( get_self() ); + reservoir_table.emplace( user, key, reservoir{ extended_asset{ 0, *yield_leg } } ); } - - stats statstable( get_self() ); - const pair_key key{ new_symbol.code().raw() }; - check ( !statstable.contains( key ), "token symbol already exists" ); check( 0 <= initial_fee && initial_fee <= MAX_FEE, "fee out of range" ); if (fee_authority == name{}) { swapconfig_t config( get_self() ); @@ -377,6 +398,21 @@ swap::currency_stats swap::accrue(const pair_key& key, const currency_stats& tok 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" ); + // The row is transient (the matching transfer erases it) and replaceable by + // its own funder, so the contract carries it rather than billing `from`. + yieldfunds funds( get_self() ); + funds.upsert( get_self(), funder_key{ from }, + fund_receipt{ pair_token, extended_asset{ quantity, shadow.get_contract() } } ); +} + void swap::accrueyield(symbol_code pair_token) { stats statstable( get_self() ); const pair_key key{ pair_token.raw() }; diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 7d2ee4b798ed5081da2818ff3de8356d4a7da0ae..3d4ff0bf299e1dd47c614da7ee199ae1961b169e 100755 GIT binary patch delta 8570 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 delta 6842 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 diff --git a/contracts/tests/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index b92ab36a2a..02563cc3bb 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -232,6 +232,56 @@ class sysio_swap_tester : public tester { 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. + 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 ) + ); + } + // 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; + announce.authorization = { {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 = "shadowtoken"_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 stand-in (contracts/test_contracts/shadowtoken) --- @@ -644,10 +694,12 @@ void sysio_swap_tester::setup_yield_pool() { BOOST_REQUIRE_EQUAL( success(), shadow_transfer( "alice"_n, "sysio.swap"_n, asset( YieldPoolShadow, SHD4 ), "" ) ); BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, SHEO4, shd( YieldPoolShadow ), extend( asset( YieldPoolWire, EOS4 ) ), 10, name{}, 0, SHADOW ) ); - // The pool holds exactly its seed; the shadow row was stamped at index 0. + // 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 ); @@ -1551,6 +1603,77 @@ BOOST_FIXTURE_TEST_CASE( yield_payout_route_is_exact, sysio_swap_tester ) try { BOOST_REQUIRE( pending_payout( "shadowtoken"_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_tester ) try { + setup_yield_pool(); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.00 TUSD")), 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, EOS4 ) ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), fundyield( "alice"_n, EOS, 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, EOS4 ) ) ); + 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( SHEO4.name(), pending["pair"].as_string() ); + BOOST_REQUIRE_EQUAL( asset( first, SHD4 ).to_string(), pending["quantity"]["quantity"].as_string() ); + BOOST_REQUIRE_EQUAL( "shadowtoken", 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, EOS4 ), "" ) ); + 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 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, EOS4 ), 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() + BOOST_FIXTURE_TEST_CASE( fee_authority_configuration, sysio_swap_tester ) try { create_tokens_and_issue(); abi_ser.set_abi( swap_abi_def(), abi_serializer::create_yield_function(abi_serializer_max_time) ); @@ -2245,7 +2368,7 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { std::set actions; for (const auto& a : abi.actions) actions.insert(a.name.to_string()); const std::set expected_actions{ - "accrueyield", "addliquidity", "changefee", "close", "closeext", "exchange", + "accrueyield", "addliquidity", "changefee", "close", "closeext", "exchange", "fundyield", "inittoken", "open", "openext", "remliquidity", "setconfig", "setyield", "sync", "transfer", "withdraw" }; BOOST_REQUIRE( actions == expected_actions ); @@ -2275,6 +2398,9 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { const field_list yield_pair_fields{ {"pair", "symbol_code"} }; 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 fund_receipt_fields{ {"pair", "symbol_code"}, {"quantity", "extended_asset"} }; + const field_list reservoir_fields{ {"balance", "extended_asset"} }; const field_list setconfig_fields{ {"fee_authority", "name"} }; const field_list swap_config_fields{ {"fee_authority", "name"} }; const field_list sync_fields{ {"pair_token", "symbol_code"} }; @@ -2300,6 +2426,9 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { BOOST_REQUIRE( fields("yield_pair") == yield_pair_fields ); BOOST_REQUIRE( fields("accrueyield") == accrueyield_fields ); BOOST_REQUIRE( fields("payout_receipt") == payout_receipt_fields ); + BOOST_REQUIRE( fields("fundyield") == fundyield_fields ); + BOOST_REQUIRE( fields("fund_receipt") == fund_receipt_fields ); + BOOST_REQUIRE( fields("reservoir") == reservoir_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. @@ -2322,12 +2451,15 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { BOOST_REQUIRE( same( shape("swapconfig"), { "swap_config", {"name"}, {"name"} } ) ); BOOST_REQUIRE( same( shape("yieldpairs"), { "yield_pair", {"contract", "symbol"}, {"name", "uint64"} } ) ); 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", "stat", "swapconfig", "yieldpairs", "yieldpayouts" }; + "accounts", "evodexacnts", "evoindex", "priceaccum", "reservoirs", "stat", "swapconfig", + "yieldfunds", "yieldpairs", "yieldpayouts" }; BOOST_REQUIRE( tables == expected_tables ); } FC_LOG_AND_RETHROW() From ab99604fd4db2e17fb45d7e1fcdb4d3be179760d Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Sun, 13 Sep 2026 12:11:03 -0400 Subject: [PATCH 17/37] swap: tickyield sells the reservoir through the pool over the horizon `tickyield(pair_token)` sells one clip of a yield pool's reservoir through the pool and hands the proceeds to the shadow's holders. The clip is the reservoir's share of the horizon elapsed since the pair's clock last advanced, rounded up so a nonempty reservoir always drains, capped by `depth_cap_bps` of the pool's shadow side and by what is queued; it is sold at the pool's own curve and fee (the fee stays with the providers) after the pool's owed yield has been settled, and the proceeds go out through the token's `addyield`, so the pool, a holder, takes its share back on the next accrual. An empty reservoir, no elapsed time, or a clip that rounds to nothing is a no-op. Ticking more often does not sell faster: a second tick in the same block does nothing. The clock (`last_tick`) advances on every tick that sells, restarts on `setyield`, and restarts when a funding fills a reservoir that was empty, so a queue sells over a fresh horizon from the moment it is funded rather than dumping against a stale timestamp. The token moves the proceeds out of the contract under the contract's authority, so deployment grants the shadow token's sysio.code seat on the contract's active permission; the fixture does the same (keeping the contract's own seat). The test drives the refusals, the empty no-op, the clock restarts, one clip against the hand-written pacing and curve references with the index advancing by the proceeds, the fee retained, the proceeds returning on the next accrual, same-block pounding, the cap after a long gap, a full drain with the cap lifted, and accrual-before-trade ordering. Docs cover the tick and the deployment steps. Co-Authored-By: Claude Fable 5.1 Change-Id: I0dbbca1bfbf5745337494583c7f6216c01416dee --- contracts/sysio.swap/Commands.md | 5 + contracts/sysio.swap/README.md | 4 + .../include/sysio.swap/sysio.swap.hpp | 19 +- .../ricardian/sysio.swap.contracts.md | 17 +- contracts/sysio.swap/sysio.swap.abi | 15 ++ contracts/sysio.swap/sysio.swap.cpp | 58 ++++- contracts/sysio.swap/sysio.swap.wasm | Bin 43271 -> 45884 bytes contracts/tests/sysio.swap_tests.cpp | 202 +++++++++++++++++- 8 files changed, 310 insertions(+), 10 deletions(-) diff --git a/contracts/sysio.swap/Commands.md b/contracts/sysio.swap/Commands.md index dcbe409772..a22d4ae20f 100644 --- a/contracts/sysio.swap/Commands.md +++ b/contracts/sysio.swap/Commands.md @@ -54,6 +54,11 @@ 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 + Set the contract-wide fee authority (deployment step, the contract's own authority): cleos push action evolutiondex setconfig '["sysio"]' -p evolutiondex diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md index a9abe4cc93..cf416ce591 100644 --- a/contracts/sysio.swap/README.md +++ b/contracts/sysio.swap/README.md @@ -33,6 +33,10 @@ A pair may be created as a *yield pool* by naming one of its legs as the pair's 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. 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 (rounded up, so a nonempty reservoir always drains), 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. 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, so deployment must grant the shadow token's `sysio.code` on the contract's active permission. + +Deployment therefore involves, in order: `setconfig` with the governance account; for each shadow token, the `sysio.code` grant above; 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 diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index 9da4f4faac..270e361b49 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -80,6 +80,16 @@ namespace sysio { /// memo-free, so a contract can do both steps inline in one transaction. /// Requires `from`'s authority. [[sysio::action]] void fundyield(name from, symbol_code pair_token, asset quantity); + /// 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, rounded up, capped by + /// `depth_cap_bps` of the pool's shadow side 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. Nothing queued, or no + /// time elapsed, makes it a no-op. Requires `setyield` to have run. No + /// authorization is required; a crank runs it every block. + [[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); @@ -176,7 +186,9 @@ namespace sysio { 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 - time_point last_tick{}; ///< elapsed-time base of the clip formula + 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 SYSLIB_SERIALIZE(currency_stats, (supply)(max_supply)(issuer)(pool1)(pool2)(fee)(fee_authority) (locked_shares)(yield_leg)(conversion_horizon_sec)(depth_cap_bps)(last_tick)) }; @@ -258,8 +270,13 @@ namespace sysio { 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 diff --git a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md index a8f75edc68..d5d09db3be 100644 --- a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md +++ b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md @@ -182,7 +182,7 @@ 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, and the ceiling of {{depth_cap_bps}} basis points (at most 10000) of the pool's shadow side on one clip. Both must be nonzero before the pool's yield tick can run. +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, and the ceiling of {{depth_cap_bps}} basis points (at most 10000) of the pool's shadow side on one clip. Both must be nonzero before the pool's yield tick can run. The pair's tick clock restarts now.

accrueyield

@@ -206,11 +206,24 @@ 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. +{{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. 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 upward, but at most the depth cap (in basis points of the pool's shadow side) and at most the reservoir. 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. + +When the reservoir is empty, or no time has elapsed since the clock last advanced, or the clip rounds to nothing, the pools, the reservoir and the clock are not modified. No authorization is required. + +

changefee

--- diff --git a/contracts/sysio.swap/sysio.swap.abi b/contracts/sysio.swap/sysio.swap.abi index fb2f321e8e..60bee78433 100644 --- a/contracts/sysio.swap/sysio.swap.abi +++ b/contracts/sysio.swap/sysio.swap.abi @@ -513,6 +513,16 @@ } ] }, + { + "name": "tickyield", + "base": "", + "fields": [ + { + "name": "pair_token", + "type": "symbol_code" + } + ] + }, { "name": "transfer", "base": "", @@ -639,6 +649,11 @@ "type": "sync", "ricardian_contract": "" }, + { + "name": "tickyield", + "type": "tickyield", + "ricardian_contract": "" + }, { "name": "transfer", "type": "transfer", diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index af2b993092..d6a80c00af 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -74,7 +74,12 @@ void swap::ontransfer(name from, name to, asset quantity, string memo) { 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() ); - reservoir_table.modify( name{}, pair_key{ receipt->pair.raw() }, [&]( auto& r ) { r.balance += incoming; } ); + 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; } @@ -357,6 +362,12 @@ const extended_symbol& swap::require_yield_leg(const currency_stats& token) { 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" ); @@ -433,9 +444,54 @@ void swap::setyield(symbol_code pair_token, uint32_t conversion_horizon_sec, uin statstable.modify( name{}, key, [&]( auto& a ) { a.conversion_horizon_sec = conversion_horizon_sec; a.depth_cap_bps = depth_cap_bps; + 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, "yield tick parameters not set" ); + // What the pool is owed belongs to it before it trades. + const currency_stats token = accrue( key, *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, rounded up + // so a nonempty reservoir always drains, capped by depth_cap_bps of the pool's + // shadow side and by what is queued. Ticking more often than the horizon + // needs only shortens the interval each clip is measured over. + 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() ); + const uint128_t cap = uint128_t( pool_of( token, shadow ).quantity.amount ) * token.depth_cap_bps / opp::amm::BPS_TOTAL; + uint128_t clip = ( uint128_t(queued) * elapsed_us + horizon_us - 1 ) / horizon_us; + clip = std::min( { clip, cap, uint128_t(queued) } ); + if (clip == 0) 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; } ); + // 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() }; diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 3d4ff0bf299e1dd47c614da7ee199ae1961b169e..3876089caaeb40981a56b19fdfb0c3f3dd0a95e7 100755 GIT binary patch delta 6099 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 LYd+VwL7MhIKy+6( delta 3895 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 diff --git a/contracts/tests/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index 02563cc3bb..135c953e78 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -316,14 +316,45 @@ class sysio_swap_tester : public tester { return push_shadow_action( holder, "claim"_n, mvo()( "holder", holder )( "sym", sym ) ); } // Let the shadow token act for `account`: its active keeps its key and gains - // shadowtoken@sysio.code, the delegation addyield's inline transfer needs. - void grant_shadow_code( name account ) { + // shadowtoken@sysio.code, the delegation addyield's inline transfer needs. A + // contract account keeps its own sysio.code seat too (`keep_own_code`), which + // its own inline actions need; the seats are listed in name order. + void grant_shadow_code( name account, bool keep_own_code = false ) { + vector seats{ { { "shadowtoken"_n, config::sysio_code_name }, 1 } }; + if (keep_own_code) seats.push_back( { { account, config::sysio_code_name }, 1 } ); + std::sort( seats.begin(), seats.end() ); set_authority( account, config::active_name, - authority( 1, { key_weight{ get_public_key( account, "active" ), 1 } }, - { permission_level_weight{ { "shadowtoken"_n, config::sysio_code_name }, 1 } } ), + authority( 1, { key_weight{ get_public_key( account, "active" ), 1 } }, seats ), config::owner_name ); produce_block(); } + // 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(); + } // `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( "shadowtoken"_n, "accounts"_n, { holder.to_uint64_t(), sym.value } ); @@ -567,6 +598,11 @@ extended_asset extend(asset to_extend) { } } +// 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 @@ -654,6 +690,16 @@ namespace yield_reference { int64_t owed( int64_t balance, uint64_t index, uint64_t checkpoint, uint64_t banked = 0 ) { return int64_t( banked + uint64_t( wide(balance) * (index - checkpoint) / Scale ) ); } + // One tick's clip: the reservoir's share of the horizon elapsed, rounded up, + // capped by `cap_bps` of the pool's shadow side and by what is queued. + 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 ) { + const wide horizon_us = wide(horizon_sec) * MicrosecondsPerSecond; + const wide by_time = ( wide(queued) * elapsed_us + horizon_us - 1 ) / horizon_us; + const wide cap = wide(pool_shadow) * cap_bps / BpsTotal; + return int64_t( std::min( { by_time, cap, wide(queued) } ) ); + } } vector sysio_swap_tester::total() { @@ -1674,6 +1720,148 @@ BOOST_FIXTURE_TEST_CASE( yield_funding_fills_the_reservoir, sysio_swap_tester ) 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_tester ) try { + setup_yield_pool(); + // The token moves the proceeds out of the contract under the contract's own + // authority: the deployment grants it the shadow token's sysio.code seat. + grant_shadow_code( "sysio.swap"_n, true ); + BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, + extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.00 TUSD")), 10, name{}) ); + const uint32_t horizon_sec = 3600; + const uint32_t cap_bps = 1; + 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( EOS ) ); + BOOST_REQUIRE_EQUAL( wasm_assert_msg("yield tick parameters not set"), tickyield( SHEO ) ); + BOOST_REQUIRE_EQUAL( success(), setyield( SHEO, horizon_sec, cap_bps ) ); + 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, EOS.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 ); + BOOST_REQUIRE_LT( 0, clip ); + 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, EOS.value ) ); + auto distributed = yield_reference::distribute( proceeds, ShadowIssuance, idx_before.carry ); + auto idx_after = shadow_index( SHD ); + BOOST_REQUIRE_EQUAL( idx_before.index + distributed.index_delta, 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 ); + 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 ); + 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) ) ); + 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, EOS4 ), 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) ); + 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( "shadowtoken"_n ).empty() ); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( fee_authority_configuration, sysio_swap_tester ) try { create_tokens_and_issue(); abi_ser.set_abi( swap_abi_def(), abi_serializer::create_yield_function(abi_serializer_max_time) ); @@ -2368,8 +2556,8 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { std::set actions; for (const auto& a : abi.actions) actions.insert(a.name.to_string()); const std::set expected_actions{ - "accrueyield", "addliquidity", "changefee", "close", "closeext", "exchange", "fundyield", - "inittoken", "open", "openext", "remliquidity", "setconfig", "setyield", "sync", "transfer", "withdraw" }; + "accrueyield", "addliquidity", "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>; @@ -2401,6 +2589,7 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { const field_list fundyield_fields{ {"from", "name"}, {"pair_token", "symbol_code"}, {"quantity", "asset"} }; 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"} }; const field_list swap_config_fields{ {"fee_authority", "name"} }; const field_list sync_fields{ {"pair_token", "symbol_code"} }; @@ -2429,6 +2618,7 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { BOOST_REQUIRE( fields("fundyield") == fundyield_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. From 69c32edcba746ea6d8af392aaf5868a95aef4aa4 Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Sun, 13 Sep 2026 20:05:28 -0400 Subject: [PATCH 18/37] swap: one system token, second leg of every pair; the transfer handler is pinned `setconfig` now also names the system token (WIRE), and `inittoken` requires every pair's second leg to be it; the first leg is the pair's own token, and since pairs are unique there is exactly one pair per token. That orientation lets `ontransfer` recognise a transfer without a table of its own: a token is accepted if it is the system token, or if the pair it forms with the system token exists, one lookup in the pair index. Anything else is refused, a look-alike symbol from another contract included, which is what the evolutiondex `badtoken` surface was about. A first leg's seed lands before its pair exists, so a transfer carrying the contract's own authority, the authority that creates pairs, is accepted regardless. Nothing works before `setconfig`: no deposit, no pair. A yield leg must now be the first leg, and the `yieldpairs` table is gone: one pair per first leg already makes one yield pool per shadow, and the pair index is the lookup. The tests configure EOS on sysio.token as the system token, so every upstream pair flips to (token, EOS) and the seed deposits of VOICE, TUSD, SHD and badtoken's EOS carry the contract's authority; pool-index expectations and the accumulator direction checks follow. New coverage: the refusal of an unpaired token with and without the authority, the second-leg rule in both orders and against a look-alike contract, the pair token itself being undepositable, and the unconfigured contract refusing everything until setconfig runs. Docs and the ABI pin follow. Co-Authored-By: Claude Fable 5.1 Change-Id: I51e3337c65026a8977fa96a97e26329af8076192 --- contracts/sysio.swap/Commands.md | 28 +- contracts/sysio.swap/README.md | 6 +- .../include/sysio.swap/sysio.swap.hpp | 61 +- .../ricardian/sysio.swap.contracts.md | 12 +- contracts/sysio.swap/sysio.swap.abi | 26 +- contracts/sysio.swap/sysio.swap.cpp | 33 +- contracts/sysio.swap/sysio.swap.wasm | Bin 45884 -> 46295 bytes contracts/tests/sysio.swap_tests.cpp | 732 ++++++++++-------- 8 files changed, 488 insertions(+), 410 deletions(-) diff --git a/contracts/sysio.swap/Commands.md b/contracts/sysio.swap/Commands.md index a22d4ae20f..3de5a03ed8 100644 --- a/contracts/sysio.swap/Commands.md +++ b/contracts/sysio.swap/Commands.md @@ -1,7 +1,11 @@ -**NOTE: In this example we use the PAIR EOS/PESO, which creates the evotoken EOSPESO. We assume an EOS token located in the eosio.token contract as usual, and a PESO token located in the contract pesocontract. You would need to replace these variables depending on your trading pairs.** +**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 @@ -15,11 +19,11 @@ 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: +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 + cleos push action pesocontract transfer '["YOUR_ACCOUNT", "evolutiondex", "100.0000 PESO", "pesitos"]' -p YOUR_ACCOUNT -p evolutiondex Check your open channels and balances: @@ -29,11 +33,11 @@ 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 EOS/PESO evotoken. 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. +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":"eosio.token", "quantity":"1.0000 EOS"}, {"contract":"pesocontract", "quantity":"1.0000 PESO"}, 10, "", "0.1000 EOSPESO", null]' -p YOUR_ACCOUNT -p evolutiondex + 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 one of the legs as the pair's shadow token (a token that pays WIRE yield to its holders). Only one yield pool may exist per shadow symbol. +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 @@ -59,10 +63,6 @@ Sell one clip of the queue through the pool and hand the proceeds to the shadow' cleos set account permission evolutiondex active --add-code shadowtoken cleos push action evolutiondex tickyield '["SHDEOS"]' -p YOUR_ACCOUNT -Set the contract-wide fee authority (deployment step, the contract's own authority): - - cleos push action evolutiondex setconfig '["sysio"]' -p evolutiondex - Change a pair's fee, signed by its fee authority: cleos push action evolutiondex changefee '["EOSPESO", 30]' -p sysio @@ -72,10 +72,10 @@ 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. +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 EOS", "2.0000 PESO"]' -p YOUR_ACCOUNT + "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. @@ -167,8 +167,8 @@ where the file addliquidity.json contains: "data": { "user": "YOUR_ACCOUNT", "to_buy": "1.5000 EOSPESO", - "max_asset1": "2.0000 EOS", - "max_asset2": "2.0000 PESO", + "max_asset1": "2.0000 PESO", + "max_asset2": "2.0000 EOS", } },{ "account": "evolutiondex", diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md index cf416ce591..da8211c22f 100644 --- a/contracts/sysio.swap/README.md +++ b/contracts/sysio.swap/README.md @@ -12,6 +12,8 @@ Evolutiondex follows the line initiated by Bancor and Uniswap, but with some des 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. A first leg's seed must be on deposit before its pair can be created, so a transfer that carries the contract's own authority, the authority that creates pairs, 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 @@ -29,13 +31,13 @@ Every pair keeps two cumulative-price accumulators in the `priceaccum` table, on **Yield pools** -A pair may be created as a *yield pool* by naming one of its legs as the pair's 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. At most one yield pool may exist per shadow symbol (`yieldpairs` table). +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. 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 (rounded up, so a nonempty reservoir always drains), 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. 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, so deployment must grant the shadow token's `sysio.code` on the contract's active permission. -Deployment therefore involves, in order: `setconfig` with the governance account; for each shadow token, the `sysio.code` grant above; and for each yield pool, `setyield` before the first tick. +Deployment therefore involves, in order: `setconfig` with the governance account and the system token; for each shadow token, the `sysio.code` grant above; each pair's first-leg seed deposited 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** diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index 270e361b49..b3839e5b90 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -41,22 +41,30 @@ namespace sysio { static constexpr uint64_t MIN_SWAP_FEE = 1; using contract::contract; - /// Set the contract-wide fee authority: 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`. - /// Requires the contract's own authority. - [[sysio::action]] void setconfig(name fee_authority); - /// Create a pair, minting sqrt(pool1 * pool2) LP shares. `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, names the pair's shadow token (one of its two legs) - /// and makes it a yield pool: the pool absorbs the WIRE yield distributed on - /// the shadow it holds, and sells queued yield shadow through itself. At most - /// one yield pool may exist per shadow symbol. Empty makes a plain pool. + /// 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. The first leg's seed must already be on + /// deposit, which a transfer carrying this contract's authority can make + /// before the pair exists. [[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, @@ -160,8 +168,9 @@ namespace sysio { /// Contract-wide configuration, set on deployment by `setconfig`. struct [[sysio::table("swapconfig")]] swap_config { - name fee_authority; - SYSLIB_SERIALIZE(swap_config, (fee_authority)) + 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 { @@ -193,13 +202,6 @@ namespace sysio { (locked_shares)(yield_leg)(conversion_horizon_sec)(depth_cap_bps)(last_tick)) }; - /// One yield pool per shadow symbol: keyed by the shadow's extended symbol, - /// this is also the lookup from a shadow symbol to its pair. - struct [[sysio::table("yieldpairs")]] yield_pair { - symbol_code pair; - SYSLIB_SERIALIZE(yield_pair, (pair)) - }; - /// 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 @@ -256,7 +258,6 @@ namespace sysio { 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 yieldpairs = kv::table<"yieldpairs"_n, extended_symbol_key, yield_pair>; 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>; @@ -264,6 +265,14 @@ namespace sysio { // 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. diff --git a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md index d5d09db3be..27d57068bc 100644 --- a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md +++ b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md @@ -43,7 +43,9 @@ 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 default response is to deposit {{quantity}} to {{from}}'s extended balance for the extended symbol formed by the tranfer's contract and the symbol {{asset_to_symbol quantity}}. This is only possible if such extended balance previously exists. +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. @@ -77,13 +79,13 @@ 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}}. The extended assets {{initial_pool1}} and {{initial_pool2}} will be deducted from the corresponding extended balances of {{user}}. +{{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 one of the two legs, 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 other leg without minting, before every addliquidity and remliquidity and on accrueyield. Only one yield pool may exist for a given shadow token. Without {{yield_leg}} the pair is a plain pool. +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. @@ -241,10 +243,10 @@ to change the fee parameter associated to the same token, to the value {{newfee} --- spec_version: "0.2.0" title: Set configuration -summary: 'Set the contract-wide fee authority to {{nowrap fee_authority}}' +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. Pairs already created keep the authority they were created with. +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. diff --git a/contracts/sysio.swap/sysio.swap.abi b/contracts/sysio.swap/sysio.swap.abi index 60bee78433..2d4940dcc6 100644 --- a/contracts/sysio.swap/sysio.swap.abi +++ b/contracts/sysio.swap/sysio.swap.abi @@ -472,6 +472,10 @@ { "name": "fee_authority", "type": "name" + }, + { + "name": "system_token", + "type": "extended_symbol" } ] }, @@ -500,6 +504,10 @@ { "name": "fee_authority", "type": "name" + }, + { + "name": "system_token", + "type": "extended_symbol" } ] }, @@ -566,16 +574,6 @@ "type": "string" } ] - }, - { - "name": "yield_pair", - "base": "", - "fields": [ - { - "name": "pair", - "type": "symbol_code" - } - ] } ], "actions": [ @@ -730,14 +728,6 @@ "key_types": ["name"], "table_id": 29681 }, - { - "name": "yieldpairs", - "type": "yield_pair", - "index_type": "i64", - "key_names": ["contract","symbol"], - "key_types": ["name","uint64"], - "table_id": 19873 - }, { "name": "yieldpayouts", "type": "payout_receipt", diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index d6a80c00af..b493490cd4 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -57,6 +57,7 @@ void swap::ontransfer(name from, name to, asset quantity, string memo) { 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. @@ -260,11 +261,26 @@ void swap::memoexchange(name user, extended_asset ext_asset_in, string_view deta std::make_tuple( get_self(), user, ext_asset_out.quantity, std::string(memo)) ).send(); } -void swap::setconfig(name fee_authority) { +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 }, 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, @@ -284,22 +300,21 @@ std::optional yield_leg) 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) { - check( *yield_leg == initial_pool1.get_extended_symbol() || *yield_leg == initial_pool2.get_extended_symbol(), - "yield_leg must be one of the pair's legs" ); - yieldpairs yieldtable( get_self() ); - yieldtable.emplace( user, key_of(*yield_leg), yield_pair{ new_symbol.code() }, - "a yield pool already exists for this symbol" ); + // 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{}) { - swapconfig_t config( get_self() ); - fee_authority = config.get( "fee authority not configured" ).fee_authority; + fee_authority = cfg.fee_authority; } else { check( is_account( fee_authority ), "fee authority account does not exist" ); } diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 3876089caaeb40981a56b19fdfb0c3f3dd0a95e7..567c38153503bc64d55ce60ff3faadc1cd7f7386 100755 GIT binary patch delta 7144 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 F^?zq5)?)ww diff --git a/contracts/tests/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index 135c953e78..9c47b3bdc1 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -41,6 +41,27 @@ struct shadow_index_row { }; 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 (the tests' stand-in for WIRE): the second leg of every +// pair. The shadow token and its yield pool trade against it. +static const extended_symbol WIRE{ EOS4, "sysio.token"_n }; +static symbol SHD4 = symbol::from_string("4,SHD"); +static symbol SHEO4 = symbol::from_string("4,SHEO"); +static symbol_code SHD = SHD4.to_symbol_code(); +static symbol_code SHEO = SHEO4.to_symbol_code(); +static const extended_symbol SHADOW{ SHD4, "shadowtoken"_n }; + class sysio_swap_tester : public tester { public: @@ -79,7 +100,8 @@ class sysio_swap_tester : public tester { produce_blocks(); - // Deployment's configuration step: governance executes as sysio. + // Deployment's configuration step: governance executes as sysio, and EOS + // on sysio.token stands in for WIRE as the system token. if (configure) BOOST_REQUIRE_EQUAL( success(), setconfig( config::system_account_name ) ); const auto* accnt1 = control->find_account_metadata( "sysio.token"_n ); @@ -106,11 +128,25 @@ class sysio_swap_tester : public tester { produce_block(); return success(); } - action_result setconfig( name fee_authority, name signer = "sysio.swap"_n ) { + action_result setconfig( name fee_authority, extended_symbol system_token = WIRE, 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) { @@ -513,15 +549,18 @@ class sysio_swap_tester : public tester { 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. EOS is the system token and 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( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("461000000000000.0000 EOS"), "") ); - BOOST_REQUIRE_EQUAL( success(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_n, asset::from_string("461168601842738.7000 VOICE"), "deposit to: alice") ); + 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(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_n, asset::from_string("0.0902 VOICE"), "this goes to Bob") ); - BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("45000000000000000.00 TUSD"), "") ); - BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("300000000000000.00 TUSD"), "deposit to: bob") ); + 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 ); @@ -563,25 +602,6 @@ class sysio_swap_tester : public tester { abi_serializer shadow_abi_ser; }; -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 shadow token and its yield pool against EOS (the tests' stand-in for WIRE). -static symbol SHD4 = symbol::from_string("4,SHD"); -static symbol SHEO4 = symbol::from_string("4,SHEO"); -static symbol_code SHD = SHD4.to_symbol_code(); -static symbol_code SHEO = SHEO4.to_symbol_code(); -static const extended_symbol SHADOW{ SHD4, "shadowtoken"_n }; -static const extended_symbol WIRE{ EOS4, "sysio.token"_n }; static extended_asset shd( int64_t units ) { return extended_asset{ asset( units, SHD4 ), "shadowtoken"_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. @@ -703,12 +723,13 @@ namespace yield_reference { } 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(0) + system_balance(ETUSD.value).at(0); + + 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(1); + + system_balance(EVO.value).at(0); const int64_t total_tusd = balance("alice"_n, TUSD2) + balance("bob"_n, TUSD2) - + system_balance(ETUSD.value).at(1); + + system_balance(ETUSD.value).at(0); return { total_eos, total_voice, total_tusd }; } @@ -737,7 +758,7 @@ void sysio_swap_tester::setup_yield_pool() { BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "bob"_n, asset( BobDonationBudget, EOS4 ), "" ) ); grant_shadow_code( "bob"_n ); BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, SHADOW ) ); - BOOST_REQUIRE_EQUAL( success(), shadow_transfer( "alice"_n, "sysio.swap"_n, asset( YieldPoolShadow, SHD4 ), "" ) ); + BOOST_REQUIRE_EQUAL( success(), seed_transfer( "shadowtoken"_n, "alice"_n, asset( YieldPoolShadow, SHD4 ), "" ) ); BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, SHEO4, shd( YieldPoolShadow ), extend( asset( YieldPoolWire, EOS4 ) ), 10, name{}, 0, SHADOW ) ); // The pool holds exactly its seed, its reservoir exists and is empty, and @@ -767,11 +788,11 @@ void sysio_swap_tester::setup_pools() { 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("23058430092.1369 EOS")), - extend(asset::from_string("96116860184.2738 VOICE")), 10, name{}) ); + 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("10000000000.0000 EOS")), - extend(asset::from_string("9911686018427.38 TUSD")), 10, name{}) ); + 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) ); @@ -792,11 +813,11 @@ BOOST_FIXTURE_TEST_CASE( add_rem_liquidity, sysio_swap_tester ) try { many_openext(); transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("10000000.0000 EOS"), ""); - transfer( "anothertoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("200000000.0000 VOICE"), ""); - + seed_transfer( "anothertoken"_n, "alice"_n, asset::from_string("200000000.0000 VOICE"), ""); + inittoken( "alice"_n, EVO4, - extend(asset::from_string("1000000.0000 EOS")), - extend(asset::from_string("100000000.0000 VOICE")), 10, name{}); + 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")); @@ -807,83 +828,79 @@ BOOST_FIXTURE_TEST_CASE( add_rem_liquidity, sysio_swap_tester ) try { 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 EOS") ) - ( "max_asset2", asset::from_string("1 VOICE")) ) + ( "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("0.5000 EOS"), asset::from_string("5000.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), - addliquidity( "alice"_n, asset::from_string("2.0000 EVO"), - asset::from_string("-0.3000 EOS"), asset::from_string("30.0000 NOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), - addliquidity( "alice"_n, asset::from_string("2.0000 EVO"), - asset::from_string("0.3000 EOS"), asset::from_string("-30.0000 NOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), - addliquidity( "alice"_n, asset::from_string("5.0000 EVO"), - asset::from_string("0.5000 EOS"), asset::from_string("5000.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), - addliquidity( "alice"_n, asset::from_string("2.0000 EVO"), - asset::from_string("0.0000 EOS"), asset::from_string("20.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), - addliquidity( "alice"_n, asset::from_string("2.0000 EMMO"), - asset::from_string("0.0000 EOS"), asset::from_string("20.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("incorrect symbol"), - addliquidity( "alice"_n, asset::from_string("3.0000 EVO"), - asset::from_string("0.3000 ECOS"), asset::from_string("30.0001 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), - addliquidity( "alice"_n, asset::from_string("1000000000000.0000 EVO"), - asset::from_string("1000000000000.0000 EOS"), asset::from_string("20000000000000.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( success(), - addliquidity( "alice"_n, asset::from_string("50.0000 EVO"), - asset::from_string("5.0050 EOS"), asset::from_string("500.5000 VOICE") ) + 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"), + 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 EOS") ) - ( "min_asset2", asset::from_string("1 VOICE")) ) + ( "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("0.5000 EOS"), asset::from_string("5000.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), - remliquidity( "alice"_n, asset::from_string("3.0000 EVO"), - asset::from_string("-0.3000 EOS"), asset::from_string("30.0001 NOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("assets must be nonnegative"), - remliquidity( "alice"_n, asset::from_string("3.0000 EVO"), - asset::from_string("0.3000 EOS"), asset::from_string("-30.0001 NOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), - remliquidity( "alice"_n, asset::from_string("1.0000 EVO"), - asset::from_string("0.1001 EOS"), asset::from_string("10.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("available is less than expected"), - remliquidity( "alice"_n, asset::from_string("3.0000 EVO"), - asset::from_string("0.3000 EOS"), asset::from_string("30.0001 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("incorrect symbol"), - remliquidity( "alice"_n, asset::from_string("3.0000 EVO"), - asset::from_string("0.3000 EOS"), asset::from_string("30.0001 NOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("overdrawn balance"), - remliquidity( "alice"_n, asset::from_string("1000000000000.0000 EVO"), - asset::from_string("0.0000 EOS"), asset::from_string("0.0000 VOICE"))); - - BOOST_REQUIRE_EQUAL( wasm_assert_msg("computation overflow"), - addliquidity( "alice"_n, asset::from_string("46116860184273.8791 EVO"), - asset::from_string("1.0000 EOS"), asset::from_string("1.0000 VOICE"))); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("computation underflow"), - remliquidity( "alice"_n, asset::from_string("46116860184273.8791 EVO"), - asset::from_string("1.0000 EOS"), asset::from_string("1.0000 VOICE"))); - - BOOST_REQUIRE_EQUAL( wasm_assert_msg("the pool cannot be left empty"), + 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 EOS"), asset::from_string("0.0001 VOICE") ) + asset::from_string("0.0001 VOICE"), asset::from_string("0.0001 EOS") ) ); -/* BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), - addliquidity( "alice"_n, asset::from_string("2.0000 EVO"), - asset::from_string("0.0000 EOS"), asset::from_string("20.0000 VOICE")) - );*/ } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE( exchange_action, sysio_swap_tester ) try { @@ -895,14 +912,14 @@ BOOST_FIXTURE_TEST_CASE( exchange_action, sysio_swap_tester ) try { 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"), ""); - transfer( "anothertoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("200000000.0000 VOICE"), ""); + seed_transfer( "anothertoken"_n, "alice"_n, asset::from_string("200000000.0000 VOICE"), ""); inittoken( "alice"_n, EVO4, - extend(asset::from_string("1000000.0000 EOS")), - extend(asset::from_string("100000000.0000 VOICE")), 10, name{}); - addliquidity( "alice"_n, asset::from_string("50.0000 EVO"), - asset::from_string("5.0050 EOS"), asset::from_string("500.5000 VOICE") ); + 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 EOS"), asset::from_string("0.0000 VOICE") ); + asset::from_string("0.0000 VOICE"), asset::from_string("0.0000 EOS") ); // EXCHANGE BOOST_REQUIRE_EQUAL( error("missing authority of alice"), @@ -948,7 +965,8 @@ BOOST_FIXTURE_TEST_CASE( exchange_action, sysio_swap_tester ) try { 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")); - vector expected_system_balance = {10000073819, 999999185797, 100000328128}; + // {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); @@ -956,9 +974,9 @@ BOOST_FIXTURE_TEST_CASE( exchange_action, sysio_swap_tester ) try { BOOST_REQUIRE_EQUAL( success(), changefee(EVO, 50) ); addliquidity( "alice"_n, asset::from_string("50.0000 EVO"), - asset::from_string("10000000.0000 EOS"), asset::from_string("10000000.0000 VOICE") ); + asset::from_string("10000000.0000 VOICE"), asset::from_string("10000000.0000 EOS") ); - expected_system_balance = {10000123826, 1000004186277, 100000828128}; + 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); @@ -986,13 +1004,13 @@ BOOST_FIXTURE_TEST_CASE( increasing_poolvalue, sysio_swap_tester) try { transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("168601842738.7903 EOS"), ""); - inittoken( "alice"_n, EVO4, - extend(asset::from_string("23058430092.1369 EOS")), - extend(asset::from_string("96116860184.2738 VOICE")), 10, name{}); + 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("10000000000.0000 EOS")), - extend(asset::from_string("9911686018427.38 TUSD")), 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); @@ -1011,8 +1029,8 @@ BOOST_FIXTURE_TEST_CASE( increasing_poolvalue, sysio_swap_tester) try { 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 EOS"), asset::from_string("10000000.0000 VOICE") ); + 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); @@ -1025,7 +1043,7 @@ BOOST_FIXTURE_TEST_CASE( increasing_poolvalue, sysio_swap_tester) try { 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 EOS"), asset::from_string("0.0000 VOICE") ); + 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); @@ -1055,8 +1073,8 @@ BOOST_FIXTURE_TEST_CASE( increasing_poolvalue, sysio_swap_tester) try { old_total = total(); old_vec = system_balance(EVO.value); - addliquidity( "alice"_n, asset::from_string("150000000000000.0000 EVO"), - asset::from_string("400000000000000.0000 EOS"), asset::from_string("400000000000000.0000 VOICE") ); + 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); @@ -1098,8 +1116,8 @@ BOOST_FIXTURE_TEST_CASE( memoexchange_test, sysio_swap_tester ) try { BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, - extend(asset::from_string("23058430092.1369 EOS")), - extend(asset::from_string("96116860184.2738 VOICE")), 10, name{}) ); + 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 @@ -1139,11 +1157,13 @@ BOOST_FIXTURE_TEST_CASE( memoexchange_test, sysio_swap_tester ) try { transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), "exchange: EVO, 16.6571 VOICE") ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended_symbol mismatch"), - transfer( "carol"_n, "carol"_n, "sysio.swap"_n, asset::from_string("4.0000 EOS"), + // 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("extended_symbol mismatch"), - transfer( "carol"_n, "carol"_n, "sysio.swap"_n, asset::from_string("1.0000 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); @@ -1155,10 +1175,10 @@ BOOST_FIXTURE_TEST_CASE( memoexchange_test, sysio_swap_tester ) try { BOOST_REQUIRE_EQUAL( pre_voice_balance + 166570, token_balance("anothertoken"_n, "alice"_n, VOICE.value) ); inittoken( "alice"_n, ETUSD3, - extend(asset::from_string("10000000000.0000 EOS")), - extend(asset::from_string("9911686018427.38 TUSD")), 10, name{}); + 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"), + 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") ); @@ -1226,9 +1246,13 @@ BOOST_FIXTURE_TEST_CASE( the_other_actions, sysio_swap_tester ) try { 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, + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("1000.0000 EOS"), "") ); - BOOST_REQUIRE_EQUAL( success(), transfer( "anothertoken"_n, "alice"_n, "sysio.swap"_n, + // 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 @@ -1249,67 +1273,75 @@ BOOST_FIXTURE_TEST_CASE( the_other_actions, sysio_swap_tester ) try { 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 EOS"))) - ("initial_pool2", extend(asset::from_string("1.0000 ECO"))) + ("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"), + 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 EOS"))) - ("initial_pool2", extend(asset::from_string("1.0000 ECO"))) + ("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.0001 EOS")), - extend(asset::from_string("0.1000 VOICE")), 10, name{}) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("Both assets must be positive"), 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("Initial amounts must be less than 10^15"), inittoken( "alice"_n, EVO4, - extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("100000000000.0001 VOICE")), 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 EOS")), - extend(asset::from_string("1.0001 VOICE")), 10, name{}) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("extended symbols must be different"), inittoken( "alice"_n, EVO4, + 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 EOS")), 10, name{}) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("insufficient funds"), - inittoken( "alice"_n, EVO4, - extend(asset::from_string("0.0003 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("0.0002 EOS")), - extend(asset::from_string("100000000.0000 VOICE")), 10, name{}) ); + 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.0001 EOS")), - extend(asset::from_string("0.100 VOICE")), 10, name{}) ); - BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, - extend(asset::from_string("0.0001 EOS")), - extend(asset::from_string("0.1000 VOICE")), 10, name{}) ); + 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.0001 EOS")), - extend(asset::from_string("0.1000 VOICE")), 10, name{}) ); + 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.0001 EOS")), - extend(asset::from_string("0.10 TUSD")), 10000, name{}) ); + 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.0001 EOS")), - extend(asset::from_string("0.10 TUSD")), -1, name{}) ); + 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.0001 EOS")), - extend(asset::from_string("0.10 TUSD")), 10, "natalia"_n) ); + 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 - 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: 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"), "") ); @@ -1334,31 +1366,43 @@ BOOST_FIXTURE_TEST_CASE( the_other_actions, sysio_swap_tester ) try { } FC_LOG_AND_RETHROW() -// A fixture whose deployment step has NOT run: the fee authority is unset. +// 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( pair_creation_needs_a_configured_fee_authority, sysio_swap_unconfigured_tester ) try { +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, WIRE, "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(); - // Adopting the configured authority is impossible until setconfig has run... - BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee authority not configured"), inittoken( "alice"_n, EVO4, - extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.0000 VOICE")), 10, name{}) ); - // ...but a pair that names its own authority does not need it. BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, EVO4, - extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.0000 VOICE")), 10, "alice"_n) ); + 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) ); - // setconfig is the contract's own call. - BOOST_REQUIRE_EQUAL( error("missing authority of sysio.swap"), setconfig( "alice"_n, "alice"_n ) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("fee authority account does not exist"), setconfig( "natalia"_n ) ); - BOOST_REQUIRE_EQUAL( success(), setconfig( config::system_account_name ) ); BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, - extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.00 TUSD")), 10, name{}) ); + 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 { @@ -1372,17 +1416,17 @@ BOOST_FIXTURE_TEST_CASE( seed_locks_shares, sysio_swap_tester ) try { // 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("1000000.0000 EOS")), extend(asset::from_string("100000000.0000 VOICE")), + 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("1000000.0000 EOS")), extend(asset::from_string("100000000.0000 VOICE")), + 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("1000000.0000 EOS")), extend(asset::from_string("100000000.0000 VOICE")), + 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("1000000.0000 EOS")), extend(asset::from_string("100000000.0000 VOICE")), + 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); @@ -1393,21 +1437,21 @@ BOOST_FIXTURE_TEST_CASE( seed_locks_shares, sysio_swap_tester ) try { // 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, EOS4), asset(0, VOICE4) ) ); + 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(0) ); // what the lock still backs + 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, EOS4), asset(0, VOICE4) ) ); + 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, EOS4), asset(pay2, VOICE4) ) ); + 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, 1), 0 ); + BOOST_REQUIRE_GE( settle_swap("alice"_n, EVO, asset(1000, EOS4), VOICE4, 0), 0 ); } FC_LOG_AND_RETHROW() // --------------------------------------------------------------------------- @@ -1422,18 +1466,21 @@ BOOST_FIXTURE_TEST_CASE( yield_leg_rules, sysio_swap_tester ) try { const extended_symbol voice{ VOICE4, "anothertoken"_n }; const extended_symbol tusd{ TUSD2, "sysio.token"_n }; - // The yield leg must be one of the pair's own legs. - BOOST_REQUIRE_EQUAL( wasm_assert_msg("yield_leg must be one of the pair's legs"), inittoken( "alice"_n, EVO4, - extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.0000 VOICE")), 10, name{}, 0, tusd ) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("yield_leg must be one of the pair's legs"), inittoken( "alice"_n, EVO4, - extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.0000 VOICE")), 10, name{}, 0, + // 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, WIRE ) ); + 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 EOS")), extend(asset::from_string("1.0000 VOICE")), 10, name{}, 0, voice ) ); + 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.0000 EOS")), extend(asset::from_string("1.00 TUSD")), 10, name{} ) ); + 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() ); @@ -1441,14 +1488,14 @@ BOOST_FIXTURE_TEST_CASE( yield_leg_rules, sysio_swap_tester ) try { BOOST_REQUIRE_EQUAL( 0u, evo["depth_cap_bps"].as_uint64() ); BOOST_REQUIRE( pair_row(ETUSD)["yield_leg"].is_null() ); - // One yield pool per shadow symbol: VOICE/TUSD may not also yield on VOICE... - BOOST_REQUIRE_EQUAL( wasm_assert_msg("a yield pool already exists for this symbol"), inittoken( "alice"_n, - symbol::from_string("3,BVO"), extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.00 TUSD")), + // 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 ) ); - // ...but may yield on TUSD, or be plain. - BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, - symbol::from_string("3,BVO"), extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.00 TUSD")), - 10, name{}, 0, tusd ) ); + 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. BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair has no yield leg"), setyield( ETUSD, 86400, 3 ) ); @@ -1657,7 +1704,7 @@ BOOST_FIXTURE_TEST_CASE( yield_payout_route_is_exact, sysio_swap_tester ) try { BOOST_FIXTURE_TEST_CASE( yield_funding_fills_the_reservoir, sysio_swap_tester ) try { setup_yield_pool(); BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, - extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.00 TUSD")), 10, name{}) ); + extend(asset::from_string("1.00 TUSD")), extend(asset::from_string("1.0000 EOS")), 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. @@ -1731,7 +1778,7 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ // authority: the deployment grants it the shadow token's sysio.code seat. grant_shadow_code( "sysio.swap"_n, true ); BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, ETUSD3, - extend(asset::from_string("1.0000 EOS")), extend(asset::from_string("1.00 TUSD")), 10, name{}) ); + extend(asset::from_string("1.00 TUSD")), extend(asset::from_string("1.0000 EOS")), 10, name{}) ); const uint32_t horizon_sec = 3600; const uint32_t cap_bps = 1; const int fee = pool_fee( SHEO ); @@ -1864,15 +1911,21 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ 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 EOS")), extend(asset::from_string("1.0000 VOICE")), 10, name{}) ); + 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.0000 EOS")), extend(asset::from_string("1.00 TUSD")), 10, "alice"_n) ); + 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) ); @@ -1886,10 +1939,9 @@ BOOST_FIXTURE_TEST_CASE( fee_authority_configuration, sysio_swap_tester ) try { 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) ); - // (VOICE/TUSD is the one pair of these three tokens not yet created.) - BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, symbol::from_string("3,BVO"), - extend(asset::from_string("1.0000 VOICE")), extend(asset::from_string("1.00 TUSD")), 0, name{}) ); - const auto BVO = symbol::from_string("3,BVO").to_symbol_code(); + 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) ); @@ -1907,21 +1959,22 @@ BOOST_FIXTURE_TEST_CASE( indextable, sysio_swap_tester ) try { 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"), ""); - transfer( "anothertoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("200000000.0000 VOICE"), ""); - + seed_transfer( "anothertoken"_n, "alice"_n, asset::from_string("200000000.0000 VOICE"), ""); + BOOST_REQUIRE_EQUAL(success(), inittoken( "alice"_n, EVO4, - extend(asset::from_string("1000000.0000 EOS")), - extend(asset::from_string("100000000.0000 VOICE")), 10, name{}) ); + extend(asset::from_string("100000000.0000 VOICE")), + extend(asset::from_string("1000000.0000 EOS")), 10, name{}) ); - // One pool per token pair, whichever way round the legs are given. + // 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 EOS")), - extend(asset::from_string("1.0000 VOICE")), 10, name{}) ); - 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 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 @@ -1936,20 +1989,21 @@ BOOST_FIXTURE_TEST_CASE( indextable, sysio_swap_tester ) try { 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.00000 VOICE")), extend(asset::from_string("1.0000 EOS")), 10, name{}) ); - 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"), + 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.0000 VOICE")), extend(asset::from_string("1.00000 EOS")), 10, name{}) ); - 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.0000 VOICE")), - extended_asset{asset::from_string("1.0000 EOS"), "anothertoken"_n}, + 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() @@ -1985,45 +2039,45 @@ BOOST_FIXTURE_TEST_CASE( changefee_bounds, sysio_swap_tester ) try { 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[0], before[1], 9999); + 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[1] - expected, after[1] ); + 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. + // 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[0], before[1], fee); + 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[0] + amount, after[0] ); - BOOST_REQUIRE_EQUAL( before[1] - out, after[1] ); + 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[1], before[0], fee); + 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[1] + amount, after[1] ); - BOOST_REQUIRE_EQUAL( before[0] - out, after[0] ); + BOOST_REQUIRE_EQUAL( before[0] + amount, after[0] ); + BOOST_REQUIRE_EQUAL( before[1] - out, after[1] ); } } @@ -2033,11 +2087,11 @@ BOOST_FIXTURE_TEST_CASE( compute_rounding_table, sysio_swap_tester ) try { 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, EOS4), asset(pay2, VOICE4) ) ); + 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, EOS4), asset(pay2 - 1, VOICE4) ) ); + 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, EOS4), asset(pay2, VOICE4) ) ); + 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] ); @@ -2047,9 +2101,9 @@ BOOST_FIXTURE_TEST_CASE( compute_rounding_table, sysio_swap_tester ) try { 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, EOS4), asset(get2, VOICE4) ) ); + 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, EOS4), asset(get2, VOICE4) ) ); + 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] ); @@ -2067,8 +2121,8 @@ BOOST_FIXTURE_TEST_CASE( invariants_under_random_sequences, sysio_swap_tester ) symbol leg2; // pool2 }; const std::vector pools{ - { EVO, EVO4, EOS4, VOICE4 }, - { ETUSD, ETUSD3, EOS4, TUSD2 }, + { 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 @@ -2163,9 +2217,9 @@ BOOST_FIXTURE_TEST_CASE( invariants_under_random_sequences, sysio_swap_tester ) 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{ - { EVO, EOS4, VOICE4, 0, 1 }, { EVO, VOICE4, EOS4, 1, 0 }, - { ETUSD, EOS4, TUSD2, 0, 1 }, { ETUSD, TUSD2, EOS4, 1, 0 }, + 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}) { @@ -2189,8 +2243,8 @@ BOOST_FIXTURE_TEST_CASE( fee_zero_is_the_bare_constant_product_curve, sysio_swap 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 ? EOS4 : VOICE4; - const symbol out_symbol = in_leg == 0 ? VOICE4 : EOS4; + 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); @@ -2213,8 +2267,8 @@ BOOST_FIXTURE_TEST_CASE( round_trip_never_profits, sysio_swap_tester ) try { 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, 1); - const int64_t eos = voice > 0 ? settle_swap("alice"_n, EVO, asset(voice, VOICE4), EOS4, 0) : 0; + 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. @@ -2238,24 +2292,25 @@ BOOST_FIXTURE_TEST_CASE( precision_extremes, sysio_swap_tester ) try { 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(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_n, all_voice, "deposit to: alice") ); - BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "sysio.swap"_n, all_tusd, "") ); + 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: one unit of EOS against the largest side inittoken accepts. + // 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(1, EOS4)), extend(asset(init_max, VOICE4)), 10, name{}) ); + 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, EOS4)), extend(asset(init_max, TUSD2)), 10, name{}) ); + 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, EOS4), asset(pay2, TUSD2) ) ); + 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] ); @@ -2265,18 +2320,18 @@ BOOST_FIXTURE_TEST_CASE( precision_extremes, sysio_swap_tester ) try { // Dust pool: one unit in each direction. { auto before = system_balance(EVO.value); - const int64_t out = reference::receive(1, before[0], before[1], 10); - BOOST_REQUIRE_EQUAL( out, model::receive(1, before[0], before[1], 10) ); + 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, 1) ); + 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[1], before[0], 10) ); - BOOST_REQUIRE_EQUAL( 0, settle_swap("alice"_n, EVO, asset(1, VOICE4), EOS4, 0) ); + 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[0], after[0] ); - BOOST_REQUIRE_EQUAL( before[1] + 1, after[1] ); + 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 @@ -2284,46 +2339,46 @@ BOOST_FIXTURE_TEST_CASE( precision_extremes, sysio_swap_tester ) try { { const auto before = system_balance(EVO.value); const int64_t amount = balance("alice"_n, VOICE4); - BOOST_REQUIRE_EQUAL( asset::max_amount, before[1] + amount ); - BOOST_REQUIRE_EQUAL( 1, model::gross(amount, before[1], before[0]) ); - const int64_t out = reference::receive(amount, before[1], before[0], 10); + 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, 0) ); + 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[1] ); + 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[0], before[1], 10) ); - BOOST_REQUIRE_EQUAL( 0, settle_swap("alice"_n, ETUSD, asset(1, EOS4), TUSD2, 1) ); + 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[0] + 1, after[0] ); - BOOST_REQUIRE_EQUAL( before[1], after[1] ); + 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)[0] ); - BOOST_REQUIRE_EQUAL( asset::max_amount - 2, before[0] + amount ); - const int64_t out = reference::receive(amount, before[0], before[1], 10); - BOOST_REQUIRE_EQUAL( out, model::receive(amount, before[0], before[1], 10) ); + 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, 1) ); + 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[0] ); + 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[1], before[0], 10); - BOOST_REQUIRE_EQUAL( out2, model::receive(tusd, before[1], before[0], 10) ); - BOOST_REQUIRE_EQUAL( out2, settle_swap("alice"_n, ETUSD, asset(tusd, TUSD2), EOS4, 0) ); + 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() @@ -2335,32 +2390,33 @@ BOOST_FIXTURE_TEST_CASE( minimum_fee_closes_the_free_window, sysio_swap_tester ) 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(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_n, asset::from_string("2000.0000 VOICE"), "deposit to: alice") ); + 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 EOS")), extend(asset::from_string("1000.0000 VOICE")), 10, name{}) ); + 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, 1) ); + 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[0], before[1]) ); - BOOST_REQUIRE_EQUAL( 998, reference::receive(1000, before[0], before[1], 10) ); - BOOST_REQUIRE_EQUAL( 998, settle_swap("alice"_n, EVO, asset(1000, EOS4), VOICE4, 1) ); + 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[0], before[1]); + 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, 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[0], before[1]); - BOOST_REQUIRE_EQUAL( g, settle_swap("alice"_n, EVO, asset(1000, EOS4), VOICE4, 1) ); + 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. @@ -2368,8 +2424,8 @@ BOOST_FIXTURE_TEST_CASE( minimum_fee_closes_the_free_window, sysio_swap_tester ) 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[0], before[1]); - const int64_t out = settle_swap("alice"_n, EVO, asset(amount, EOS4), VOICE4, 1); + 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 ); @@ -2384,31 +2440,35 @@ BOOST_FIXTURE_TEST_CASE( guard_semantics_on_the_memo_path, sysio_swap_tester ) t // 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, extended_symbol{VOICE4, "anothertoken"_n}) ); - BOOST_REQUIRE_EQUAL( success(), transfer( "badtoken"_n, "alice"_n, "sysio.swap"_n, asset::from_string("2000.0000 EOS"), "") ); - BOOST_REQUIRE_EQUAL( success(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_n, asset::from_string("2000.0000 VOICE"), "deposit to: alice") ); + BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, WIRE) ); + 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 VOICE")), 10, name{}) ); + 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 VOICE") ); + 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 VOICE") ); + 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, VOICE4).to_string() ) ); + "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, VOICE4).to_string() ) ); + "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] ); @@ -2455,9 +2515,9 @@ BOOST_FIXTURE_TEST_CASE( price_accumulators_follow_the_pools, sysio_swap_tester 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 EOS"), asset::from_string("100000.0000 VOICE")) ); } ); + 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, EOS4), asset(0, VOICE4)) ); } ); + 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) ); } ); @@ -2470,13 +2530,14 @@ BOOST_FIXTURE_TEST_CASE( price_accumulators_follow_the_pools, sysio_swap_tester 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: a little over four VOICE per EOS. - BOOST_REQUIRE( average1 >= 4 * twap::PRICE_ONE && average1 < 5 * twap::PRICE_ONE ); + // ...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 > twap::PRICE_ONE / 5 && average2 < twap::PRICE_ONE / 4 ); + 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 { @@ -2490,12 +2551,12 @@ BOOST_FIXTURE_TEST_CASE( price_accumulators_ignore_same_block_moves, sysio_swap_ // 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 spot price -- happens with no time elapsed, so it contributes nothing - // however far it moves the spot. + // 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[1], moved[0]) * 2 < twap_reference::price_fp(pools[1], pools[0]) ); + 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() ); @@ -2520,10 +2581,12 @@ BOOST_FIXTURE_TEST_CASE( price_accumulators_span_extreme_prices, sysio_swap_test 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(), transfer( "anothertoken"_n, "bob"_n, "sysio.swap"_n, asset::from_string("461168601842738.7903 VOICE"), "deposit to: alice") ); + 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(1, EOS4)), extend(asset(init_max, VOICE4)), 10, name{}) ); + 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 @@ -2536,16 +2599,16 @@ BOOST_FIXTURE_TEST_CASE( price_accumulators_span_extreme_prices, sysio_swap_test 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(init_max, 1) * elapsed ); - BOOST_REQUIRE( next.price2 == twap_reference::price_fp(1, init_max) * elapsed ); - BOOST_REQUIRE( (next.price1 >> 128) != 0 ); // beyond 128 bits, as designed for - const twap::u128 average1 = twap::average_price( - twap::difference(twap_testing::to_cumulative(next.price1), twap_testing::to_cumulative(row.price1)), + 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( average1 == twap::price_fp(uint64_t(init_max), 1) ); + 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, 1) ); + 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 { @@ -2583,15 +2646,14 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { {"last_tick", "time_point"} }; const field_list setyield_fields{ {"pair_token", "symbol_code"}, {"conversion_horizon_sec", "uint32"}, {"depth_cap_bps", "uint32"} }; - const field_list yield_pair_fields{ {"pair", "symbol_code"} }; 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 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"} }; - const field_list swap_config_fields{ {"fee_authority", "name"} }; + 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{ @@ -2612,7 +2674,6 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { BOOST_REQUIRE( fields("setconfig") == setconfig_fields ); BOOST_REQUIRE( fields("swap_config") == swap_config_fields ); BOOST_REQUIRE( fields("setyield") == setyield_fields ); - BOOST_REQUIRE( fields("yield_pair") == yield_pair_fields ); BOOST_REQUIRE( fields("accrueyield") == accrueyield_fields ); BOOST_REQUIRE( fields("payout_receipt") == payout_receipt_fields ); BOOST_REQUIRE( fields("fundyield") == fundyield_fields ); @@ -2639,7 +2700,6 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { 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("yieldpairs"), { "yield_pair", {"contract", "symbol"}, {"name", "uint64"} } ) ); 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"} } ) ); @@ -2649,7 +2709,7 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { for (const auto& t : abi.tables) tables.insert(t.name); const std::set expected_tables{ "accounts", "evodexacnts", "evoindex", "priceaccum", "reservoirs", "stat", "swapconfig", - "yieldfunds", "yieldpairs", "yieldpayouts" }; + "yieldfunds", "yieldpayouts" }; BOOST_REQUIRE( tables == expected_tables ); } FC_LOG_AND_RETHROW() From e74e6726e397965148060f96a7655ff95c6ef557 Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Sun, 13 Sep 2026 21:17:39 -0400 Subject: [PATCH 19/37] swap docs: both seeds are on deposit, only the first leg's needs the contract's authority Co-Authored-By: Claude Fable 5.1 Change-Id: If0417cbd720c561cf628797dfa85206b873b2b81 --- contracts/sysio.swap/README.md | 4 ++-- contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md index da8211c22f..800d6f68c5 100644 --- a/contracts/sysio.swap/README.md +++ b/contracts/sysio.swap/README.md @@ -12,7 +12,7 @@ Evolutiondex follows the line initiated by Bancor and Uniswap, but with some des 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. A first leg's seed must be on deposit before its pair can be created, so a transfer that carries the contract's own authority, the authority that creates pairs, is accepted whatever the token. +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** @@ -37,7 +37,7 @@ A yield pool also has a *reservoir* (`reservoirs` table): shadow queued to be so 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 (rounded up, so a nonempty reservoir always drains), 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. 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, so deployment must grant the shadow token's `sysio.code` on the contract's active permission. -Deployment therefore involves, in order: `setconfig` with the governance account and the system token; for each shadow token, the `sysio.code` grant above; each pair's first-leg seed deposited under the contract's authority and the pair created; and for each yield pool, `setyield` before the first tick. +Deployment therefore involves, in order: `setconfig` with the governance account and the system token; for each shadow token, the `sysio.code` grant above; 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** diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index b3839e5b90..6d9c21c430 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -62,9 +62,9 @@ namespace sysio { /// `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. The first leg's seed must already be on - /// deposit, which a transfer carrying this contract's authority can make - /// before the pair exists. + /// 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, From 96d2c1538759971c8f85ff810b3078d373c43e2f Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Mon, 14 Sep 2026 10:09:38 -0400 Subject: [PATCH 20/37] amm_math: the proportional pool-share slice, with compute as its wrapper `sysio.swap::compute` held the last piece of the pool model that had not reached the shared kernel. The curve (`out_given_in`) and the seed (`geometric_mean`) were already there; what a share is worth against a pool side was not, because the header's original consumers, sysio.reserv and sysio.uwrit, have no tokenized pool share and never needed it. `in_given_shares` and `out_given_shares` are that slice, named off `out_given_in` and differing only in rounding: minting rounds up, burning rounds down, both the pool's way, which is why a mint-then-burn round trip cannot profit. They return `u128` because the value genuinely exceeds 64 bits (two 62-bit balances over a supply of one reaches about 2^124), so bounding it is the caller's job. The unsigned ceiling is written `(prod + supply - 1) / supply` rather than the contract's `1 + (prod - 1) / z`, which would wrap on a zero product. `compute` stays where it is and keeps everything the kernel deliberately does not do: the input guard, the sign that selects direction, the overflow and underflow bounds, and the liquidity fee. The bounds still run before the fee is added, as before. The kernel stays free of contract intrinsics and host-testable, which is the property that would have been lost by moving the `check()` calls with it. Behaviour is unchanged, and the swap suite's rounding table is what pins that: it computes every expectation from the hand-written spec rather than the contract. The ABI does not move. Four host cases cover the rounding directions, the exact-division agreement, dust in both directions, degenerate inputs, the result past 64 bits, and a random grid asserting each side is tight against the true quotient. Co-Authored-By: Claude Opus 5 Change-Id: Ibb36013965b1d7aa86bc572a695a01f6a623f0a6 --- .../include/sysio.opp.common/amm_math.hpp | 30 ++++++++ .../include/sysio.swap/sysio.swap.hpp | 10 ++- contracts/sysio.swap/sysio.swap.cpp | 10 ++- contracts/sysio.swap/sysio.swap.wasm | Bin 46295 -> 46386 bytes contracts/tests/amm_math_tests.cpp | 68 ++++++++++++++++++ 5 files changed, 112 insertions(+), 6 deletions(-) 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 7476c09b3d..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 @@ -192,6 +192,36 @@ 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.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index 6d9c21c430..90a3e52fee 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -307,9 +307,13 @@ namespace sysio { /// 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: `x * y / z` rounded the pool's way -- up when `x > 0` - /// (a leg the user pays), down when `x < 0` (a leg the user receives) -- plus - /// `fee` (in 1/FEE_DENOMINATOR units) of that amount, rounded up. + /// 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. diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index b493490cd4..7dbfebe63c 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -126,14 +126,18 @@ int128_t swap::ceil_fee(int128_t amount, int fee) { int64_t swap::compute(int64_t x, int64_t y, int64_t z, int fee) { check( (x != 0) && (y > 0) && (z > 0), "invalid parameters"); - int128_t prod = int128_t(x) * int128_t(y); + // 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 = 1 + (prod - 1) / int128_t(z); + 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 = prod / int128_t(z); + 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); } diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 567c38153503bc64d55ce60ff3faadc1cd7f7386..cf054295d18870ae63502b245c56921b3d5f65a1 100755 GIT binary patch delta 489 zcmZ{gy-UMT6vgj*w`~G#Ad4V~_2wx^C%ZaGn`rm=0e(!*Srq5QbP!wwh5ik02EEGic)9nSd+zVPed>oVeZRr__GR4T^?qwu)4J5>%M=gQ=wfrV z)TwZK7BzHV_eCXYH=u+f51)@hBOs~A-Ar^s=u)_dU&(}}-r;U;R8r@@X^jtw|!UiNT%_-iCt%@uBQp8r~Ow)ZLmZJydFUhF#ePT3cv1xt(G+R&q1J!r_t?k$8E;E!DIH4yZA3IJO4gH#rcCQp{LBP=fPx+O><881uV2y0hIK z@Uhz+b`N$3P#ATDCkqa?Q{{!xxDsI0q2RT>5Kv6<)iZ@I{}o{##Gq47=g#>wT_K$j at~tba`iBvxD&zO(YL}h;zxj6dX@T#b__$9vn@xwbmv!z4O^xSUkbl7K<-txJb}4I5dK9*5I>wd@n5F-LtWQp5~1shd$3q|Op@wda_zXeuTqO>TzDPtc+U*^)Ef znxgS(EC6EA!`4`Ow%o9uSQ^QejyAfGL;m#wiD?TZDa9t{m3zUcTaa2;A*Cily`F=Y zB8`Pioe+8sPl^9*q@P^QtZ@+9R+dhME!Tb(>)>ta(XJM4yX>x=k>IyA_tbL x6one)yTK{w95=p7N$eHoZsy=XC!#}?S;)l8ZIF`j?L{o#uqNLyy*PY0`vz<3Q!M}h diff --git a/contracts/tests/amm_math_tests.cpp b/contracts/tests/amm_math_tests.cpp index 708a8a5a13..1df1ea9935 100644 --- a/contracts/tests/amm_math_tests.cpp +++ b/contracts/tests/amm_math_tests.cpp @@ -360,4 +360,72 @@ BOOST_AUTO_TEST_CASE(geometric_mean_seeds_by_sqrt_of_the_product) { 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() From 88f5244fec9cdc1cb2b170b08eb9eb474dd785be Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Mon, 14 Sep 2026 13:32:37 -0400 Subject: [PATCH 21/37] tests: commit the badtoken and shadowtoken artifacts so CI can load them The swap suite's two test contracts compiled only under BUILD_SYSTEM_CONTRACTS and committed nothing, so their wasm existed only on a machine that had built them. CI sets that flag OFF for every build that is not a version tag and compiles no contract at all, which left `contracts.hpp.in`'s embed with nothing to read: all 28 swap cases died in the fixture constructor on "wasm file cannot be found", on both the asan and asserton jobs. Every other contract here already handles this through `bootstrap_contract`, which copies the committed artifact into the build tree and, when contracts ARE being built, leaves the freshly compiled one alone. Both test contracts now use it, with their wasm and abi committed alongside, matching what sysio.swap and the rest do. Local behaviour is unchanged: add_contract still compiles both as a build-time check and overwrites the copy, so a change to either source means rebuilding and re-committing the artifact, which the comment on each file now says. Verified by configuring the contracts tree standalone with BUILD_SYSTEM_CONTRACTS=OFF, as a PR build does: all four artifacts land in the build tree, where before the change those CMakeLists were inert and produced nothing. Co-Authored-By: Claude Opus 5 Change-Id: I93891438d64d85ee05a88e11156f3d6a2b704855 --- .../test_contracts/badtoken/CMakeLists.txt | 8 + .../test_contracts/badtoken/badtoken.abi | 40 +++ .../test_contracts/badtoken/badtoken.wasm | Bin 0 -> 3492 bytes .../test_contracts/shadowtoken/CMakeLists.txt | 8 + .../shadowtoken/shadowtoken.abi | 229 ++++++++++++++++++ .../shadowtoken/shadowtoken.wasm | Bin 0 -> 13751 bytes 6 files changed, 285 insertions(+) create mode 100644 contracts/test_contracts/badtoken/badtoken.abi create mode 100755 contracts/test_contracts/badtoken/badtoken.wasm create mode 100644 contracts/test_contracts/shadowtoken/shadowtoken.abi create mode 100755 contracts/test_contracts/shadowtoken/shadowtoken.wasm diff --git a/contracts/test_contracts/badtoken/CMakeLists.txt b/contracts/test_contracts/badtoken/CMakeLists.txt index 39ef0a7f51..99d4d00ff1 100644 --- a/contracts/test_contracts/badtoken/CMakeLists.txt +++ b/contracts/test_contracts/badtoken/CMakeLists.txt @@ -1,3 +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.wasm b/contracts/test_contracts/badtoken/badtoken.wasm new file mode 100755 index 0000000000000000000000000000000000000000..14f6c24cad4fc86171a68bd906290132da818798 GIT binary patch literal 3492 zcmd^C&5ImG6o2*6Gqc+>Zmq7#WH(B6o5KhOM8YHn!KuZVgd~LIF0-B8PMn$Do!OZ- zvK}&X@gV*K3ZA^>q6i)&rxo;^AO!RlA?U#zR6KZ#zgIoGGaCXT3WDxOSND7M>V4Ml zRZ%k75JV)7hbu%Y;s&kA;gD8H4EY65HS~iVf+d(Bz?L`grEcIU-xo!dZeE}7r(18e z`f0PDuC{uubTcEt^zvYP(CRjm!65BtB$;HA)vVRsY_26)(j2tjNlAHfKTXz}Q#{g4 z4w5TfND{vnHqwpN-ZlmNF+aU>bG5scQb8T6Qn7@;QfWr>&&mQsq@SsjR;5F${5 zMB#uM3OCxJa7b?S<=#*{s#!(1zb$FDnH!jHt|<{Vn{TE`uX!aIq)G-!ueZKU&(8jK zu&T+-hJB%ivSF!0j^;v#XH7o9w=>sc(T-&uUpe)DBUYB$Zm#WmTQSw;{RRZ3Gnu7x zERJ{4=J{6OB@!XnqCswWnz?MV5usI~q_8f;@Hyz{#NgCr8N$$8u1T6Ke-C=CSL5ha zj1L+RlS7y$`2XBeBxFNDg(<~U5so6PRg*tAVtJ|}teht*Qy5wb3s}d@gaH{%9ZBGfhk404BI70-u+Ijk5FheSZWbISzVVW z^5GZ{Hb5X=v1*x)tD05rSR0R8+nB|iW_M|JLaXL1v&%AVGmq)6)aDRT&SfhloQB0? zvUD10a%BV=hImcp$;IW-=H9VLWp3yxV;lOWShR>R=E2sw)QI>x5|!r{fdx~Try|q` zhoiJ_VfDC$6(WtL*mqtVN}g9GNbc8y%7d*?c2kQ2YqLlt2$I8f*ejC@5Rq=7Z59?~ zgMY28$tvm?!$PcIhvU3V9^l+OPG2VuT<>BfMAe7VBSix!};UtkQ@b17ThFDwW6IXD|f_-9=DS_~NGWHW( z(vR|x#ogGTJOoFK!-hZXBUB6*G!*+#>amZSt3{8iMINP+uL{BOFsAHl{E>M4*((2* z&B%zinJYQZCYQ0~omfRRBR0}mUm$aNIT>t_zg^gG11=|pEjce`3$?D?HdSA$fH89j zHcd8<>R6-_=|fs#)RwsD@o;JA$ug2^tUIpp5B}qLh5qm%BMSoe7qRLW zkt6w?e-6pdFb34+Ydm-)Kc9my5Z--7j7ujbu>E4rW0cQ%fO*Dk#yXo1K=v`OI^hB5 zJsj@wO3a&z!{gxjj)&*}q01SP%v(xF*mIG}K?%ho{XRpJM&Q2N*P7%DL{VUQZ|3J1 zq|!a4E#U4$u@&pgsy-UOcExstjTUilr!(9{g;m^dDSR1S3_65Y%nw2%jMW9;4Di;f zj#-@Zha9W0rCC+JZNuL)E|pSPgjAImAPp8z{E~5A|-?jzxva z5+@jRRMs7I;sO+)+YHZF0NL(v8wVk7xgP4FL0QaEPGoUlix&UqmfS=96j5Y6skH@j_J=Kc3T);$t zVJujom;f^1n9LItz9)dC2&2bFc0Tu#BaGt}g1`8I1Noze_ zvsu?B>+9XsBui~Cxt0!SvzxW9wm`r=$KpnmwyvjZ#221HhHK7-O|mTA=w-PuUwj}q W$ZYmzYH{J9YbkC#jzT@MM1KHPYBzEK literal 0 HcmV?d00001 diff --git a/contracts/test_contracts/shadowtoken/CMakeLists.txt b/contracts/test_contracts/shadowtoken/CMakeLists.txt index a098a628f1..a92e75a737 100644 --- a/contracts/test_contracts/shadowtoken/CMakeLists.txt +++ b/contracts/test_contracts/shadowtoken/CMakeLists.txt @@ -1,3 +1,11 @@ +# shadowtoken.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 shadowtoken.cpp changes, rebuild +# and re-commit the wasm/abi. +bootstrap_contract(shadowtoken) if(BUILD_SYSTEM_CONTRACTS) add_contract(shadowtoken shadowtoken shadowtoken.cpp) target_include_directories(shadowtoken diff --git a/contracts/test_contracts/shadowtoken/shadowtoken.abi b/contracts/test_contracts/shadowtoken/shadowtoken.abi new file mode 100644 index 0000000000..dd86aed379 --- /dev/null +++ b/contracts/test_contracts/shadowtoken/shadowtoken.abi @@ -0,0 +1,229 @@ +{ + "____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": "uint64" + }, + { + "name": "owed_wire", + "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": "create", + "base": "", + "fields": [ + { + "name": "issuer", + "type": "name" + }, + { + "name": "maximum_supply", + "type": "asset" + }, + { + "name": "wire_contract", + "type": "name" + }, + { + "name": "wire_symbol", + "type": "symbol" + } + ] + }, + { + "name": "currency_stats", + "base": "", + "fields": [ + { + "name": "supply", + "type": "asset" + }, + { + "name": "max_supply", + "type": "asset" + }, + { + "name": "issuer", + "type": "name" + }, + { + "name": "wire_contract", + "type": "name" + }, + { + "name": "wire_symbol", + "type": "symbol" + } + ] + }, + { + "name": "issue", + "base": "", + "fields": [ + { + "name": "to", + "type": "name" + }, + { + "name": "quantity", + "type": "asset" + }, + { + "name": "memo", + "type": "string" + } + ] + }, + { + "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": "uint64" + }, + { + "name": "pot", + "type": "uint64" + }, + { + "name": "carry", + "type": "uint64" + } + ] + } + ], + "actions": [ + { + "name": "addyield", + "type": "addyield", + "ricardian_contract": "" + }, + { + "name": "claim", + "type": "claim", + "ricardian_contract": "" + }, + { + "name": "create", + "type": "create", + "ricardian_contract": "" + }, + { + "name": "issue", + "type": "issue", + "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": "stat", + "type": "currency_stats", + "index_type": "i64", + "key_names": ["symbol_code"], + "key_types": ["uint64"], + "table_id": 4264 + }, + { + "name": "yieldidx", + "type": "yield_index", + "index_type": "i64", + "key_names": ["symbol_code"], + "key_types": ["uint64"], + "table_id": 3287 + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [] +} \ No newline at end of file diff --git a/contracts/test_contracts/shadowtoken/shadowtoken.wasm b/contracts/test_contracts/shadowtoken/shadowtoken.wasm new file mode 100755 index 0000000000000000000000000000000000000000..7888b021fb10cd0ae2bd073b04b91d8f1a7e0a2e GIT binary patch literal 13751 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 Date: Tue, 15 Sep 2026 14:08:30 -0400 Subject: [PATCH 22/37] swap: a clip floor, and a tick below it sells nothing and banks the time The tick rounded the clip UP so a nonempty reservoir always drained. That guaranteed a sale every tick, which had two consequences. A one-unit clip returns nothing on the curve whenever the WIRE side does not exceed the shadow side, and MIN_SWAP_FEE takes the rest up to an output of two, so across the whole below-parity range the clip moved into the pool and paid its holders zero: the reservoir drained to the liquidity providers a unit per block. It also made the elapsed-scaling unreachable, since the clock advanced on every tick and the window reset to one block forever, which is the opposite of the design's "pounding it yields zero-size no-ops". The clip is now floored, and `setyield` takes a third parameter: the least a clip may be. A clip short of `min(clip_floor, queued)` sells nothing and, the part that matters, leaves `last_tick` alone, so the interval is banked and the next tick offers a proportionally larger clip. Throughput is unchanged, because waiting N times as long sells N times as much; only the granularity is coarser. Yielding to `queued` keeps a remainder below the floor from stranding: it leaves as one sale once the time share reaches the whole queue, exactly one horizon later. The floor is in units of the shadow, so it is precision-relative and cannot be a constant. Size it where the pair's fee on a clip's output reaches a whole unit on its own, an output of FEE_DENOMINATOR/fee, since below that MIN_SWAP_FEE is the binding fee and a clip pays far above the pair's rate. Erring high is free; erring above the depth cap stops the pair selling, which is the one combination with no way out and is documented as such. A new case pins all of it: a floor above a block's share makes ten consecutive cranks no-ops that move nothing and hold the clock, the banked time then sells in one piece that actually pays, dust clears one horizon after funding, and a cap under the floor stalls until setyield widens one of them. Co-Authored-By: Claude Opus 5 Change-Id: I2b9ad271a61074acebc236cb0eb21061f97546a4 --- contracts/sysio.swap/Commands.md | 4 +- contracts/sysio.swap/README.md | 4 +- .../include/sysio.swap/sysio.swap.hpp | 43 +++-- .../ricardian/sysio.swap.contracts.md | 6 +- contracts/sysio.swap/sysio.swap.abi | 8 + contracts/sysio.swap/sysio.swap.cpp | 32 +++- contracts/sysio.swap/sysio.swap.wasm | Bin 46386 -> 46575 bytes contracts/tests/sysio.swap_tests.cpp | 151 +++++++++++++++--- 8 files changed, 204 insertions(+), 44 deletions(-) diff --git a/contracts/sysio.swap/Commands.md b/contracts/sysio.swap/Commands.md index 3de5a03ed8..bb986074cf 100644 --- a/contracts/sysio.swap/Commands.md +++ b/contracts/sysio.swap/Commands.md @@ -41,9 +41,9 @@ Create a yield pool instead: name the first leg as a shadow token (a token that 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, and the ceiling on one clip in basis points of the pool's shadow side. +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]' -p sysio + 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: diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md index 800d6f68c5..21b7f67a44 100644 --- a/contracts/sysio.swap/README.md +++ b/contracts/sysio.swap/README.md @@ -35,7 +35,9 @@ A pair may be created as a *yield pool* by naming its first leg as a shadow toke 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. 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 (rounded up, so a nonempty reservoir always drains), 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. 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, so deployment must grant the shadow token's `sysio.code` on the contract's active permission. +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. The floor is what keeps the mechanism from destroying the yield it exists to deliver: a clip small enough is consumed entirely by integer rounding on the curve and by the one-unit minimum swap fee, which would settle a zero payout while still moving the shadow into the pool, handing the reservoir to the liquidity providers a unit at a time. 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, so deployment must grant the shadow token's `sysio.code` on the contract's active permission. Deployment therefore involves, in order: `setconfig` with the governance account and the system token; for each shadow token, the `sysio.code` grant above; 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. diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index 90a3e52fee..6c3c3fb77d 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -70,10 +70,24 @@ namespace sysio { 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, and the hard ceiling on one clip as basis - /// points of the pool's shadow side. Both must be nonzero before tickyield runs. + /// 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 in units of the shadow, so it is precision-relative and has + /// no sensible default. Size it so the pair's fee on a clip's output reaches a + /// whole unit on its own, which is an output of FEE_DENOMINATOR/fee units: + /// below that, MIN_SWAP_FEE is the binding fee and a clip pays far above the + /// pair's rate (an output of 2 pays half of itself). 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. Erring low is what + /// bleeds the reservoir to the liquidity providers a unit at a time. + /// + /// It must stay under the depth cap, though: a floor above + /// `pool_shadow * depth_cap_bps / BPS_TOTAL` caps every clip below the floor + /// and the pair stops selling entirely. [[sysio::action]] void setyield(symbol_code pair_token, - uint32_t conversion_horizon_sec, uint32_t depth_cap_bps); + 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 @@ -90,13 +104,21 @@ namespace sysio { [[sysio::action]] void fundyield(name from, symbol_code pair_token, asset quantity); /// 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, rounded up, capped by + /// of the horizon elapsed since the last tick, floored, capped by /// `depth_cap_bps` of the pool's shadow side 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. Nothing queued, or no - /// time elapsed, makes it a no-op. Requires `setyield` to have run. No - /// authorization is required; a crank runs it every block. + /// pool takes its own share back on the next accrual. + /// + /// A no-op when nothing is queued, when no time has elapsed, or when the clip + /// has not reached `min(clip_floor, queued)` yet. That last case is what makes + /// cranking every block harmless: a clip below the floor sells nothing AND + /// leaves the clock alone, so the next tick measures a longer window and + /// offers a proportionally larger clip. Selling below the floor would hand the + /// reservoir to the liquidity providers a unit at a time, because integer + /// rounding and MIN_SWAP_FEE take the whole of a small enough clip. + /// + /// 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); @@ -195,11 +217,14 @@ namespace sysio { 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 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 + ///< 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)(last_tick)) + (locked_shares)(yield_leg)(conversion_horizon_sec)(depth_cap_bps) + (clip_floor)(last_tick)) }; /// A yield payout the contract has claimed and credited to `pair` but not yet diff --git a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md index 27d57068bc..bb1860e8ee 100644 --- a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md +++ b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md @@ -184,7 +184,7 @@ 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, and the ceiling of {{depth_cap_bps}} basis points (at most 10000) of the pool's shadow side on one clip. Both must be nonzero before the pool's yield tick can run. The pair's tick clock restarts now. +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

@@ -221,9 +221,9 @@ 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 upward, but at most the depth cap (in basis points of the pool's shadow side) and at most the reservoir. 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 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 (in basis points of the pool's shadow side) and at most the reservoir. 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. -When the reservoir is empty, or no time has elapsed since the clock last advanced, or the clip rounds to nothing, the pools, the reservoir and the clock are not modified. No authorization is required. +The exchange happens only if the clip has reached the pair's clip floor, or the whole of the reservoir if that is smaller. When it has not, 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

diff --git a/contracts/sysio.swap/sysio.swap.abi b/contracts/sysio.swap/sysio.swap.abi index 2d4940dcc6..97c31c1475 100644 --- a/contracts/sysio.swap/sysio.swap.abi +++ b/contracts/sysio.swap/sysio.swap.abi @@ -177,6 +177,10 @@ "name": "depth_cap_bps", "type": "uint32" }, + { + "name": "clip_floor", + "type": "int64" + }, { "name": "last_tick", "type": "time_point" @@ -494,6 +498,10 @@ { "name": "depth_cap_bps", "type": "uint32" + }, + { + "name": "clip_floor", + "type": "int64" } ] }, diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index 7dbfebe63c..301683418d 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -452,7 +452,8 @@ void swap::accrueyield(symbol_code pair_token) { accrue( key, *token ); } -void swap::setyield(symbol_code pair_token, uint32_t conversion_horizon_sec, uint32_t depth_cap_bps) { +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 ); @@ -460,9 +461,11 @@ void swap::setyield(symbol_code pair_token, uint32_t conversion_horizon_sec, uin require_auth(token->fee_authority); 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" ); 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 = current_time_point(); // new parameters, fresh horizon } ); } @@ -478,7 +481,8 @@ void swap::tickyield(symbol_code pair_token) { 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, "yield tick parameters not set" ); + check( stored->conversion_horizon_sec > 0 && stored->depth_cap_bps > 0 && stored->clip_floor > 0, + "yield tick parameters not set" ); // What the pool is owed belongs to it before it trades. const currency_stats token = accrue( key, *stored ); @@ -487,16 +491,28 @@ void swap::tickyield(symbol_code pair_token) { 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, rounded up - // so a nonempty reservoir always drains, capped by depth_cap_bps of the pool's - // shadow side and by what is queued. Ticking more often than the horizon - // needs only shortens the interval each clip is measured over. + // 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() ); const uint128_t cap = uint128_t( pool_of( token, shadow ).quantity.amount ) * token.depth_cap_bps / opp::amm::BPS_TOTAL; - uint128_t clip = ( uint128_t(queued) * elapsed_us + horizon_us - 1 ) / horizon_us; + uint128_t clip = ( uint128_t(queued) * elapsed_us ) / horizon_us; clip = std::min( { clip, cap, uint128_t(queued) } ); - if (clip == 0) return; + // 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. + // Selling anyway is what bled the reservoir to the liquidity providers: a + // clip small enough is taken entirely by integer rounding on the curve and + // by MIN_SWAP_FEE, settling a zero output while the shadow still moves. + // + // 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; const extended_asset selling{ asset{ int64_t(clip), shadow.get_symbol() }, shadow.get_contract() }; const symbol proceeds_symbol = other_pool( token, shadow ).quantity.symbol; diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index cf054295d18870ae63502b245c56921b3d5f65a1..2c627396b3458c1b84b96ae7e101b92a1a683db6 100755 GIT binary patch delta 5843 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 delta 5761 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 diff --git a/contracts/tests/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index 9c47b3bdc1..a29a888e32 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -247,11 +247,12 @@ class sysio_swap_tester : public tester { } // 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, - name authority = config::system_account_name ) { + 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). @@ -710,15 +711,19 @@ namespace yield_reference { int64_t owed( int64_t balance, uint64_t index, uint64_t checkpoint, uint64_t banked = 0 ) { return int64_t( banked + uint64_t( wide(balance) * (index - checkpoint) / Scale ) ); } - // One tick's clip: the reservoir's share of the horizon elapsed, rounded up, - // capped by `cap_bps` of the pool's shadow side and by what is queued. + // 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_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 - 1 ) / horizon_us; + const wide by_time = ( wide(queued) * elapsed_us ) / horizon_us; const wide cap = wide(pool_shadow) * cap_bps / BpsTotal; - return int64_t( std::min( { by_time, cap, wide(queued) } ) ); + const wide clip = std::min( { by_time, cap, wide(queued) } ); + return clip < std::min( wide(clip_floor), wide(queued) ) ? 0 : int64_t( clip ); } } @@ -1497,15 +1502,18 @@ BOOST_FIXTURE_TEST_CASE( yield_leg_rules, sysio_swap_tester ) try { 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. - BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair has no yield leg"), setyield( ETUSD, 86400, 3 ) ); - BOOST_REQUIRE_EQUAL( error("missing authority of sysio"), setyield( EVO, 86400, 3, "alice"_n ) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("depth_cap_bps out of range"), setyield( EVO, 86400, 10001 ) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), setyield( EOS, 86400, 3 ) ); - BOOST_REQUIRE_EQUAL( success(), setyield( EVO, 86400, 3 ) ); + // 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() ); @@ -1781,6 +1789,10 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ extend(asset::from_string("1.00 TUSD")), extend(asset::from_string("1.0000 EOS")), 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); }; @@ -1789,7 +1801,10 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ 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( EOS ) ); BOOST_REQUIRE_EQUAL( wasm_assert_msg("yield tick parameters not set"), tickyield( SHEO ) ); - BOOST_REQUIRE_EQUAL( success(), setyield( SHEO, horizon_sec, cap_bps ) ); + // 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 @@ -1815,8 +1830,9 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ const int64_t contract_wire_before = token_balance( "sysio.token"_n, "sysio.swap"_n, EOS.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 ); - BOOST_REQUIRE_LT( 0, clip ); + 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 ); @@ -1852,7 +1868,8 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ 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 = 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 ) ) ); @@ -1865,7 +1882,7 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ 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 ); + 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 ) ); @@ -1873,7 +1890,7 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ // 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) ) ); + 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 ); @@ -1901,7 +1918,7 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ 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) ); + 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) ); @@ -1909,6 +1926,97 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ BOOST_REQUIRE( pending_payout( "shadowtoken"_n ).empty() ); } FC_LOG_AND_RETHROW() +BOOST_FIXTURE_TEST_CASE( yield_tick_never_sells_below_the_clip_floor, sysio_swap_tester ) try { + setup_yield_pool(); + grant_shadow_code( "sysio.swap"_n, true ); + 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( fee_authority_configuration, sysio_swap_tester ) try { create_tokens_and_issue(); // A third token, for a third pair against the system token. @@ -2643,9 +2751,10 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { {"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"}, - {"last_tick", "time_point"} }; + {"clip_floor", "int64"}, {"last_tick", "time_point"} }; const field_list setyield_fields{ - {"pair_token", "symbol_code"}, {"conversion_horizon_sec", "uint32"}, {"depth_cap_bps", "uint32"} }; + {"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"} }; From 483f593025952476323293b0145801deef8e567c Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Tue, 15 Sep 2026 14:11:33 -0400 Subject: [PATCH 23/37] swap: trim the tick comments to what the code does Co-Authored-By: Claude Opus 5 Change-Id: I30e4047742f37e623d7ecefe1afd5eb78451c18f --- contracts/sysio.swap/README.md | 2 +- contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp | 7 ++----- contracts/sysio.swap/sysio.swap.cpp | 3 --- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md index 21b7f67a44..76a0bf0085 100644 --- a/contracts/sysio.swap/README.md +++ b/contracts/sysio.swap/README.md @@ -37,7 +37,7 @@ A yield pool also has a *reservoir* (`reservoirs` table): shadow queued to be so 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. The floor is what keeps the mechanism from destroying the yield it exists to deliver: a clip small enough is consumed entirely by integer rounding on the curve and by the one-unit minimum swap fee, which would settle a zero payout while still moving the shadow into the pool, handing the reservoir to the liquidity providers a unit at a time. 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, so deployment must grant the shadow token's `sysio.code` on the contract's active permission. +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. The floor exists because a clip small enough is consumed entirely by integer rounding on the curve and by the one-unit minimum swap fee, so it must be sized above that. 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, so deployment must grant the shadow token's `sysio.code` on the contract's active permission. Deployment therefore involves, in order: `setconfig` with the governance account and the system token; for each shadow token, the `sysio.code` grant above; 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. diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index 6c3c3fb77d..ca9db23672 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -80,8 +80,7 @@ namespace sysio { /// below that, MIN_SWAP_FEE is the binding fee and a clip pays far above the /// pair's rate (an output of 2 pays half of itself). 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. Erring low is what - /// bleeds the reservoir to the liquidity providers a unit at a time. + /// makes sales larger and rarer at the same average rate. /// /// It must stay under the depth cap, though: a floor above /// `pool_shadow * depth_cap_bps / BPS_TOTAL` caps every clip below the floor @@ -114,9 +113,7 @@ namespace sysio { /// has not reached `min(clip_floor, queued)` yet. That last case is what makes /// cranking every block harmless: a clip below the floor sells nothing AND /// leaves the clock alone, so the next tick measures a longer window and - /// offers a proportionally larger clip. Selling below the floor would hand the - /// reservoir to the liquidity providers a unit at a time, because integer - /// rounding and MIN_SWAP_FEE take the whole of a small enough clip. + /// offers a proportionally larger clip. /// /// Requires `setyield` to have run. No authorization is required. [[sysio::action]] void tickyield(symbol_code pair_token); diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index 301683418d..a4cb0d0e86 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -503,9 +503,6 @@ void swap::tickyield(symbol_code pair_token) { // 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. - // Selling anyway is what bled the reservoir to the liquidity providers: a - // clip small enough is taken entirely by integer rounding on the curve and - // by MIN_SWAP_FEE, settling a zero output while the shadow still moves. // // 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 From 062097daa933d20a2a0a9c9f9fa2b3cf92d346bb Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Tue, 15 Sep 2026 15:03:45 -0400 Subject: [PATCH 24/37] swap: settle owed yield before a swap is priced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit process_exch priced against the stored pool while accrueyield is permissionless, so one transaction could buy the shadow, settle, and sell it back, taking w·Y/(W+w) of the pending yield off the liquidity providers. It clears the round-trip fee once the pending yield passes about twice the fee rate against the WIRE side, which a pair reaches between ticks. The accrual now happens in process_exch rather than in exchange, so the memo route is covered by the same change, and a trade is always priced against a settled pool. tickyield no longer accrues for itself, because accrue must run at most once per action: its inline claim settles the token's row only after it returns, so a second call reads the same owed amount and collides with the receipt the first one left. That constraint is now on accrue's declaration. Nothing tickyield reads moves under accrual, which credits the other leg while the clip is measured against the shadow side. Covered by a new case, the first to swap against a yield pool at all: a buy prices against the pool including the owed yield and is refused at the stale quote, and the buy, settle, sell round trip returns less WIRE than it spent. Co-Authored-By: Claude Opus 5 Change-Id: I34195d121b9c9c557872a0d33b57c91d88931367 --- .../include/sysio.swap/sysio.swap.hpp | 4 ++ contracts/sysio.swap/sysio.swap.cpp | 42 +++++++++------- contracts/sysio.swap/sysio.swap.wasm | Bin 46575 -> 46587 bytes contracts/tests/sysio.swap_tests.cpp | 45 ++++++++++++++++++ 4 files changed, 73 insertions(+), 18 deletions(-) diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index ca9db23672..32bf53fc50 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -318,6 +318,10 @@ namespace sysio { /// 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 ); diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index a4cb0d0e86..25b1250a3f 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -198,24 +198,28 @@ 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 token = statstable.try_get( key ); - check ( token.has_value(), "pair token does not exist" ); + 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)) { + 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())) { + } 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; + 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; + 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"); @@ -228,21 +232,21 @@ extended_asset swap::process_exch(symbol_code pair_token, const uint64_t gross = opp::amm::out_given_in(uint64_t(P_in), CP_WEIGHT_BPS, uint64_t(P_out), CP_WEIGHT_BPS, uint64_t(A_in)); - uint64_t fee = opp::amm::split_wire_fee(gross, uint32_t(token->fee), NO_UNDERWRITER_SHARE_BPS).fee; - if (token->fee > 0 && gross > 0) fee = std::max(fee, MIN_SWAP_FEE); + uint64_t fee = opp::amm::split_wire_fee(gross, uint32_t(token.fee), NO_UNDERWRITER_SHARE_BPS).fee; + if (token.fee > 0 && gross > 0) fee = std::max(fee, MIN_SWAP_FEE); const int64_t A_out = int64_t(gross - 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_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_asset1 = extended_asset{-A_out, token.pool1.get_extended_symbol()}; ext_asset2 = ext_asset_in; ext_asset_out = -ext_asset1; } - update_price_accumulators(*token); + update_price_accumulators(token); statstable.modify( name{}, key, [&]( auto& a ) { a.pool1 += ext_asset1; a.pool2 += ext_asset2; @@ -483,8 +487,10 @@ void swap::tickyield(symbol_code pair_token) { 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" ); - // What the pool is owed belongs to it before it trades. - const currency_stats token = accrue( key, *stored ); + // 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; diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 2c627396b3458c1b84b96ae7e101b92a1a683db6..bfb3ce41dad2154a15d1e63a586d45996b20a710 100755 GIT binary patch delta 2579 zcma)8O^91n6uv)s$;(SauTX5vP`&SUpb2(r&`wg7(z$^uD#-ZPQA7||_EiOUZrT^H zFx?0PZq>Bg&f$3tF5e5u&EYVG~%tE2iLT5R}F8t2DFKMbS$}sPpd;ZV4=X@{s z?~1?giotowZ*9LOziA}fMadI+MI^_W83&glZ<%SY&02CIwWoPqf*cbmT5^Q9>53T7 zv83ln#_EDt)7}aj$}%&|41Q&bmmu0_vF1(%PZ+5UE$dQ+LaezzuzUPSj0jPlM1E9w zw~XMXpPoHkNp?7ibdCf*1crx2?bYRghk1H;dFUVFB}C6_lEW}F$=nQ`13|WIZtG{Z zdDtS~gAwxmw_7+B>2Ie`zLZ}tvo;Hb_Gd%^1R^zJU1&3%Z!>iaM~!CGWrGCi$<=D2 zBY~{&|Gpt@9UGsDSzlTT1vN)Oza~aJ3e46mrtR~MrJvzZIFIB`Sw&~rh8iT#qFOWr zYNSAYvvFLMS%@f~-drv5Aice+pDP;v^?94}gvAtB;sMhZoOouEe$jkp?L$zmS=3oG zl`$o98Ki5dIuX*v51oTNnUWW6De%cjvgR~GiM~>DeS1v{&g!zq;VB2bEEQ{?Tku4D zKh`-{vrW;K+mbd#CzT7*Sx(ZVxz+U=pZW-=Zt<~UHGllV)SKY%7l;q3 z>2sB@5{6E zx4FY(JbNfrm~OWBVufuj?ma>avoEMziv|yFSu}vLprOMUC892;BFqs4Xi*aff^r1$ z&=Gank!|)t*)HdJ?93^xDW9h`0ci#{k%>(T>DZfnS;PB4hMrmu^YHJr`Frq=Evd_E zGAeA8*(X}8B4~Sf_3V|KuVeF$^H>IgWe>dEwZHa$sv6 zET{^zll~qbO)xYRh({kNg(e>enUX}s(cw%o2Q*vN5Chwx2>}M+28#H8 z0CoZ}Di#0_&)wjRYRa=SwWI^%3RL~rq@P`SI`KhwQ7qHsu|l=`v0BFqOO5A$=Ff}^ zHi}hv1hu#U@U009K`aJ6z%`6%2%&=~idEn8DkzpT#fStWzz(wO66}E86a##Z-3rXP zm8RGsZYFi|rnoWJv?)}8cIL)yn$65DzC#F6vx+sia0mj%z>AWAwlg3EnN>UofDB7# zSyNd6cwi|$NiDfgokJ>5oc^pR2b$!8hb zpr*}lj+#$U^Fqn>kMS$RVF&@D1qxgReX)QE_UDBi{C20)8U66rgCAeamJ5qu_X^84 zLjUdEVY0?V=i2@?z?qlseR(XQwGC*N%S}NyvWv4LHR`qtxo0;UVbxWdK~$h rJsH_#Q6jk^4}m(0tE#~=6A^n?W_$pByv$0+8SGJz(%YYZwfXu#{rC32 delta 2640 zcma)7O^6&-5bmzm)6>(FA@87M*frsGCz2i{Q9^cRHAwOXVxk^QVodbrQ9EcZ9&#}0 z0VBypT$sy>ii_m1qJe=ZEO=QZ;2uI8M93aI>>W@DvxC~Xw6e!Dm9E!OOJpj>Zz+%iCaI$q z@oP7|9Nz))cad^^hK`uZK5u~|KQ-0MRcO!b-jd4I3lVbU|=>k)vjm4uBUh6m?7_$niYYK zI3-gx(fcVGGS$=9^)41?IEG+^r!AYva;JG>dO9HvstF?*4gIuc=Wcrj-|5@li__QP zhe|(@_-V3o|Ld72vIvzEqt0ZsWik!BKEm_QOv7ibe}Bg8jE_p_YhG@3ZWKOzsU%8j-BJVpU)EY` z(TAx3dSx(TAeb-(e*cSCc61~nxI^N~mCFTOhA^1~j}X#f?`!=7ou>-Sq<#aZ%_^$4 zHJkRAA3XgMIKzUh`r7TuNAT zj9Nz_L^7V#6Iqb)vHAG5S5=PhG73O;DR65}`@BIgvF311b}ry_h5PWf>>@4)JaCwx zf?BE>P!xtx z7iH=9vj#qgK~m9slCb~bTic7CN~>qt?F?;Dclq-3my!spQoWc|**pO^TzqzpWT6Ab zof;s6?7ZVBMjIR#pd3rufIg&XM3s2t_#|oorH_9uGXZ!|F++c%)Fy^TS~vCX;VVEA zVkAMNv!G_nW$pqDu>QNiS_R5h0X&DVFdB_fCc7-A-gU`8DS3A z1WZ%us10r}$w~`urPYpF6<$jnPooZs#tJ~Mj#}dpKy{OCRwuDKBB%auZ=az}>L1K! z3&1LGDlx2^x(W2&G~U`;G76K$&EljnQNAm1OU+SPvX+{M78lx8wFU@z^uiYW48dR8 zz;zdkIAUuo(RsSPl#H=Ya$CZDUw~xYW5B~p5CNWIFtkAB_OTV5kzZqU=DJ+yC=E@p z<6<&bmm|BLlfmW51XPkf0b)MpD*b0f?FJB>l~@79$dB-)u*{b}Nmtqbe2gYWxUvxy zd=q)=5Bw=mK&EvTtOM1cxD)`dggeBe;kIPVZMJEZxwykpCvVfQV@(O637E9s-r>0| z&1T{v6UD564TnA0=tfsNs#mpIKc2>Xwb%RZ`%iE+ zGI9TpccOw9J%*FC(I#%wU7Q_hPz+h&`+^0CMPKHr#767=yk!~P4_wobH=AJ#vEG4d fWVl(1n0+@W`FJ++Jzjmchx^D& 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, EOS4 ), 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, EOS4)), asset(stale_out, SHD4) ) ); + BOOST_REQUIRE_EQUAL( success(), + exchange( "alice"_n, SHEO, extend(asset(pay, EOS4)), 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( "shadowtoken"_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, EOS4 ), SHD ) ); + const int64_t wire_start = deposit_of( "alice"_n, WIRE ); + const int64_t shadow_start = deposit_of( "alice"_n, SHADOW ); + BOOST_REQUIRE_EQUAL( success(), exchange( "alice"_n, SHEO, extend(asset(pay, EOS4)), 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, EOS4) ) ); + BOOST_REQUIRE_EQUAL( shadow_start, deposit_of( "alice"_n, SHADOW ) ); + BOOST_REQUIRE_LT( deposit_of( "alice"_n, WIRE ), wire_start ); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( yield_payout_route_is_exact, sysio_swap_tester ) try { setup_yield_pool(); // A transfer from a shadow contract with no claim outstanding is an ordinary From fb057fc7b17a6a91116ae6a6228790bbab709e96 Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Tue, 15 Sep 2026 16:17:46 -0400 Subject: [PATCH 25/37] swap: measure the depth cap against depth an attacker cannot inflate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cap was taken against the shadow side as it stood, which a caller sets inside the same transaction by selling shadow into the pool. That is the same move that makes a clip worth sandwiching, so the bound scaled with the attack it exists to bound: the clip grew with the injection, the ratio the cap fixes stayed put, and sandwich profit went from self-limiting to linear in the attacker's position. The pair now records its shadow side at each setyield and each selling tick, and the cap is taken against the smaller of that and the current side. In-transaction inflation cannot widen it, a genuine shrink tightens it at once, and genuine growth takes effect one tick later. setyield always precedes a tick, so the recorded depth is never zero when it is read. The cap's sizing rule is now on setyield: keep depth_cap_bps below about twice the pair's fee, which is where a clip's price impact stops covering the round-trip fee a sandwicher pays on their own position. That is what makes the cap self-enforcing and is the argument the plan relies on in §09 when it leaves the band guard optional. The new case doubles the shadow side, then ticks, and pins the clip to the cap implied by the honest depth, which is half what the inflated side would have allowed. Co-Authored-By: Claude Opus 5 Change-Id: I2e870488d98e0597d0cb076bdff41fb5ceaed2f8 --- .../include/sysio.swap/sysio.swap.hpp | 20 ++++++-- contracts/sysio.swap/sysio.swap.abi | 4 ++ contracts/sysio.swap/sysio.swap.cpp | 19 ++++++-- contracts/sysio.swap/sysio.swap.wasm | Bin 46587 -> 46895 bytes contracts/tests/sysio.swap_tests.cpp | 46 +++++++++++++++++- 5 files changed, 80 insertions(+), 9 deletions(-) diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index 32bf53fc50..1f7a6e0567 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -82,9 +82,14 @@ namespace sysio { /// nothing, because the clip scales with elapsed time and a higher floor only /// makes sales larger and rarer at the same average rate. /// - /// It must stay under the depth cap, though: a floor above - /// `pool_shadow * depth_cap_bps / BPS_TOTAL` caps every clip below the floor - /// and the pair stops selling entirely. + /// 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 @@ -104,7 +109,8 @@ namespace sysio { /// 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 the pool's shadow side and by what is queued; it is sold + /// `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. @@ -215,13 +221,17 @@ namespace sysio { 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)) + (clip_floor)(last_tick_depth)(last_tick)) }; /// A yield payout the contract has claimed and credited to `pair` but not yet diff --git a/contracts/sysio.swap/sysio.swap.abi b/contracts/sysio.swap/sysio.swap.abi index 97c31c1475..8b8d151481 100644 --- a/contracts/sysio.swap/sysio.swap.abi +++ b/contracts/sysio.swap/sysio.swap.abi @@ -181,6 +181,10 @@ "name": "clip_floor", "type": "int64" }, + { + "name": "last_tick_depth", + "type": "int64" + }, { "name": "last_tick", "type": "time_point" diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index 25b1250a3f..7933f18257 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -463,13 +463,15 @@ void swap::setyield(symbol_code pair_token, uint32_t conversion_horizon_sec, uin const auto token = statstable.try_get( key ); check ( token.has_value(), "pair token does not exist" ); require_auth(token->fee_authority); - require_yield_leg(*token); + 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 } ); } @@ -501,7 +503,15 @@ void swap::tickyield(symbol_code pair_token) { // 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() ); - const uint128_t cap = uint128_t( pool_of( token, shadow ).quantity.amount ) * token.depth_cap_bps / opp::amm::BPS_TOTAL; + // 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 @@ -521,7 +531,10 @@ void swap::tickyield(symbol_code pair_token) { 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; } ); + 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) { diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index bfb3ce41dad2154a15d1e63a586d45996b20a710..392153befdcae267e0b6a64e3ea7c56428496195 100755 GIT binary patch delta 5968 zcmb7IdvKK1760!2_S?;FHp$08B_s{`c6nrDCm0_j5pBth4@6W1qDZwGQGzTMh&Y{8 zfejLId4(kxAs~p8`I%g^wm_GJCOZ2wCv&(bU& z;x2QKJT#<~y=?z$$X(34hFO|0!)8=g4ju2`eKFNow%3jieb38ca!Enn*e)Y%af^w6 z9M{ZSEiPJJ7z?jUG8+ z$G4a z3!4gP0dSBottlW*fPJudr0?E7-~6Bf0gWoHT<_H_!(zocz#am5x!#R`(aOX4_UQEO z*2BsOMv2y?lMzJX9gWOjh(3*Mq)6~>wZ~jDjqd;j%;|;k}4Y(y@kUfN>3h z*`yH-5my*eenK9m+SQP(qlQ`;|G@+F4+s#W7&4mIVFYZSf&V^|^Z&4jkjS$nUDQi9 z%Ma>hydUJwf--&~4O=>b=e%!Vs&^244zM`q!ww|1IVDfT1644eBTGoh6;jMXU~hW! z5HCguf(7-PGP5XRa+Rs<1r@XP>>gO(4ePrt22;yVud9l{@NP1-IX#PI!n(U1>$=H5 zU6`{p<)2OhJ>_~kkw@Vlx9Ei4Zqc6fj~hXHk$>D2ONbLi;w&;}TJB=Kg#|o>)xrp4 zx%2uTBYni&ui^pwT3Jg~JkU~jA72vhMv!>Rbwvs$R+`##NormRL$E3~12#x8;toUe zIP)WZ6k9rGJc6%M5`@^E1HKf`c6IiYTIMV)Z$)$svO+St+@jAzc+4iF%fWE-w|PLf zU_+%lMV4D6Xc!T>D$v{%Qs>Dw=MiX7vYi4=VS5bx!wi?eQYvDY?ay%H3_N;4?a-I; zb7|Pp5N99z2Bx}`@bwgcXE6|G5ok)>bf6_=2`RZkidzW$oj`Mkc#phmqVV+iCXjgWo zTPyRh^l)VW-w!Ik%8$9jmJ9jD$;&R$gS_k#z>0+YOw>8axVq}{k*7SMbBRt;v~v*L z0a$d{+o}x4y6oLI{N!fe%U!n)<#8rozBQhiV3SmsFfe>@N)fZ=swp$rlk$@(6>N!p z*=@J8%w;%oF;24HE7vb5&`tAsRc4OZZ{B_l8}}zwSX8=AfmYZ|fu#c=<&=YVVCr_4 zH%d7YXL^-{qfym@{l}hDeT9ql+UxFll(FOX_p|DBrKi2{cX#ltHkL=mGaY%} zePC=hz6naPZ`r+e+8~>;|7wDg(RNOfjf}9xQHi< z1su^jxL7Dh^fx~;Y<$XVP?rRQk;$mqohKE+qq@ z3m_FHe4&`LugV_R61#RipqrxuE^Mkh1p;x0vZM!%nZ$WKwy0`v{PnVc8=+_{4Zwb) zg@;kKNEjB#Q10f`rjWOQ>|w!(mz?Yg_-H$4B7@wBVFGJ?O2sy3LXkqkL1d`BNpEvH zY~UHwgPE2O8wCAUrsc2B$1t;M;xi;*qh6(%1z?Ti0n!RUkqRoOAg_v@)-XE~I4Z#K zE-gweK)lh4dz@Kk1ywyo->Zk|NI@l4c!~YM;pCRC$}$};aB(K+e4{l0E>f)D5_GEu zK{+j?+6U!^K$b8AKD`nP$tk{YFgv*#XTi#LRCHHEOGAC8q2Ia0ff7APKxR1NPykBrOB2enSUK3D6x?X# zdgzh_8hnw=%tn+@P90D;;k)TB2Fp?hq_FLD3e}lTV{?(R2#YoC2KILd%6LbbRLPc$ zyPJ6J10*RBD83=JM@SKdz{BZgkW$)>ppUYPkGkfR#_80RY(P>%lU8x(Vu*wr(W71^ zf}>FnG1n6@fIQI+w~$X` z2;3fmy;T`ebo7QW&0hq4w6vk&@R2Q8nqm29g>o2W3~Ut4F5@&li?IV*FbHlaTzm`^ zzlB_DK1~q|^O)m!CDn+Jb;qI5o%j$7pE(@94Pj|=iqL2EBp&VOoCT8^HFt&b^a9VY zqbe*_r}az@XZkkaY`ha0+hQ zLRD~e^gOA8n}$(vbG7@>qE;bJ7*UZfp}sDwMS zgoOKMNfmq6zG~?>#x~gxE*r)D#~={g9O8aF^~4(9&rP$q6Kl^YLFqXoDwi%VV5{VF z%YPgoSpEB#^LzZHWvAmSrOi#+uTSLA2l8(irC5` zNVX_L|(hY_Nm>pYwFoK5Kzvmlmf_&O@wJp0rV=>WFOj^%dU6>8l&3bNSP@R(xpIL-%=pS*2HAP zbC=2NZC4LT>7(RUrkTF2&P(LwzAlaz)66jWG zu%8-ybbf~DMR*#bUl#7T(XFuka^8+oRw=jcs2mRjm1W&*CjdPekwTH<=u0TMYC-44 zCUxUL-3@YCS~jdYq+Z(4#2SbJ%Vh=vv=$GERH9X-qV5Qn7j$kJ;Ei_(HIKcnwsbsU z306n7Q4jG9N}+g=;<9e%4eTv>c;_T`!v1k*He(&qzw6ieC)EoJu7v8Qjt)Y-Sblp# zGf&tL?|OiF--h(`2w=)_l?C#P-G%IJ>D@Dly=70^<7ez0dH>$~5ba0yuFLFZsasf| zobmj)2yEuuE$LD~4LZz6Z7AI@OKB&RUVN>v*m4r@xG;-Lb;wY0n#$2FZ>b!OOR3VD zuBXl$7dTMW$)zP&j}KhhfqSkqq)VygDsV2b8Ps%<*~}vGT8CzyM5m7%vdr5z83uWH z--MwjD4&6gP78F1X1*&A?faB%ma+Xs>>1g%zntxmef!6xgkD* zwa8QPiZQDh?&}Cyb88CNl>)Y_;*3FX(a|F4?#;NC{z#y!Dmq@DCpYSq*7lLHVT*k zMgjvGq)4Oz5Ds?TIbL*m_!8(e}IZ?`}f%^c-_eX7Aj& zbLYP1-aG&Er^IJ}5pCmzahMLO2_mYti)PR3$zdgdXU?V?OFxUZ4tv{2HEMZ&aBR0LY6*+vTQ57z>6Va9 zPRu2+vWZN0&=TgDG9#f5=a)FQk*rt$$dBZoabt_X38mun0Qf0D`Ken?8aY|ic+}xw zUi`_Co4oYAy0D;HeXAg#S_^7vzZxHzhVjA3)Yzp&vC$6MMt6suV;q*#A{=*vsIZ*l z94(aXkPgc*DGNF}Ws8=^3F7D&N7fOtx0-i{1$H_tGWQU)fYSQcikf#}8c9vd?P%gQC$3n0!LlNv*^k4eO~VNjGA-T0M_B8suc;IZ3?Y9(Fc90`M!lx?p%SWTDgcSKSDo%QU zJ_+x;3L&F82{T}mF8n50&M#r%{#ZRrciI~r=WyhOjZ)DEa(hFmIFW%Z1CepeKd{t2 z2<`+dJN-C;r8Z{-WlN|M=CfsqXt^SqSp@7&Ul8%&ia@Ziab;FEM?@~0swcjpe4&w( zfc4$5zS|<0TAi9y8H3^7Y-;nmY|4Ulci7f-vwsp;vmDw#9SnNPj5a2(fq&ex1A4n< zTgpFf1mRi!akDuQYnsH~WZr1G3ynq!c`%1{r8laczwGl^l9>l|Jm6d_d%2DW-U{zy zWbv*5iLcDiq+nuYs6AgvEh=CLs-!uvL7I`U8JYJ53WtIiFE>LXcgC*9jBzd7=d2MN+CyIJRsL2x@@*&g3j=^{$R_f7f2o%6SW z>}ZT}sKoS^KnqRNhY{pd`{$3P7u41nbLb)U@r-g>7LQy#lW4iRZ)PDi>q_#=_?t5) zP|?qI?a+BO4O&q%4Hgf8oLTn9FS&M`(^tz0(i=7F)Nkg@88j=IE9q4H{<#-9bn36Z6NGc`dFo7!t%Im~wRx;~X8jI;0aPBAhe- z&G_lz@(|y(AWHNTHFV(=jI&2vtC|-spcmE2o%t%fsEYoh)S_|U@WO5o8)HAXJHcHY zUlgQn_0gg{>QJuRBR7}B&T>ru7jW9u6 z%mL#>J;DGjV)0eC-$Ai{VV5t#si_YoHIpnf_zD%CxUM|1qD4T~@E-DwK2a>)jDWbxFg zb-Jjq>0PpeqM2foOXw>&$X>EfL}PGT2#gAiqzF0nbw!7DT3m_y1K^Ae6dE-~ zrQ>?L!u0gm=4Ll0DI-=dK&~bjd$mvyM;q}{=H6z6V4#qj4Q^+3^iPFGUCao3ZsfB{ zaA;*>)#WQfUSv4Wa?}_h8$>kZvxVZO0p<-b`LWt$V~p953)2=5`?ao!jSQ)f z9fQcA{rWd-@%3#Gu@dNrqT$DeaF&%-Il0!%2Q!2)#H#>=OM`&Ha%IDDN2S9Y30x8v z$zypy%#D!VfF_G~EU;JY9hNsm-)ltKT2ND0L}qVOl*Jfn;liPgbNqviy%I*TM$rbY z(3lnW>dLJr#k#I*Q-=c54EeQ`Yv-843@?cmXEdX9C z+lmDk3Rr%ml(1hGBX4F|0oxdeY<7GYtrIg~b;F)Y$90xxR$kbjH`fZJPJ@~_&z~0t zmDlpj&SQsi%D_4RG_erDX%^ssOr;i<0i`Jh4cYckifl)t!4S5Wae5QT*x|N!eW?3i z!@)ILfN*w37JUvg;}z&TgnI`T(`5f@|FFslT&^09W;CeK%vWf5%>o^9xlC#eNfRRz zvW9Hv%;p+w&c$BTNGzSDu&pO&d34qmx_Ce_BvYO0XW<8}CdMz_9@&HBqYDEF2Fa@N zdJE$RSr$5+M#S{+6~bPOP|{q9>L$2F>1Mos2^AW#M($BGx()ueFECtSV{?(V7>iB3 zYOkZ$$WUq#a`AGK?eFn5O7V^8Ga{NW0v2|=fqc^D3i~;=__?iRg<~vTCVi!nZdz(q ztI2+sS?)5UpH25a^8a7)Q({ZI#e!v1EtcO_3*6dbx_8RxhC5U$_W1`?Z5aeN1D4Hb zv9x@d7VGQj%>8q!twq>`a#Fsq{oABoy63!@zV^5s=+y=H?A=;C-Uy#E?5y~frDEv<~r=>9oi9`5x_Q_T`G9qiMb70&_4m*@)%HBdUF-WB65gXBr3`{ z@gFm6M?qEQ!z}#f`S4i;TT@Gn?le+(*{0V1@G9L;jml$g&Zv1%N1X0(P6+!6n@~Ab zb_E_^bwQq8fsyBrJFe0Ney?51(J`v(s(cq$-4>;)rr0I*w^h2N8fZV#B{c)1q#7ux z;pz%Xs72N5s8)SkJ(lX?=ly6rVe*}OH)4GM-Uo)PB~YRXV%vD|)nMV$f-;T6#bcb; ztcBbL7axr8;O4C3IVbRL-fH8!fv;7GrK4!AN-oW#$JCjnB{=TlA9s;aPa(Boi;PdY z|7jY%UUPc~^}&$G&^QjBqqvy$6jaHLHw;5V5fQU^xSD&Z3iDx7p_S*yfAU~4i5d;7 zu7_4biy6zRaq8h^56}uwsA-|wF8hAam0(Onc~dq15B}c zO#vi+cFkCfRLA3IYYwOn>vn(DTKuy$H}R&q>mGH3p$p8C>#!&8dF*kbb?S-w`C_$g z)SSm3r2#Ej$WOJFEM%?Pw>}>yy|x~LsO*Lx&`WArLmL=hxM2at`VEW4tG2lV8&?9+ zu<-@;_YI@e_f#I%b^qhlGBeaY8Q2vmzjsa~B2KYvXx zikgq9o3<7pB5Jmljabiw{Pjo!>`Xjt?XO4m@ej7<(nTFm6m<``%uIt2Dh=X+l?GX) zdQxxRf37OuHeqO54K1#=$ntH~J~kV;nTOOrx4j#9%McV3E70Zllj?nXMnMu0ka}XE zH^nnrXU#tTro#^uU1wdgPfg!`gd! z9>EcIYu?^^>rRQ8@=`UjFvRv{UJb=s)+G`j00=$b;GhPJx`v-s6z{dj2 z4f7JqKzA;wnu{YcfR}SEJ{ghP(KLl#R)1=mMn~gC&*TujqAuTYId!UKJB9D{ zrCFTO^eYr0^K13T-M^zJRA);8J*rN&l+iXdrgbbmt!A{&rUtdGwSa!Ax>_gFlNGm}6R2q0Z4QxyUjcV& 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, EOS4) ) ); + 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_tester ) try { setup_yield_pool(); grant_shadow_code( "sysio.swap"_n, true ); @@ -2796,7 +2840,7 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { {"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", "time_point"} }; + {"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"} }; From ae2f7fe46a5244d6c5096ce4dfffaf9c20df49f5 Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Tue, 15 Sep 2026 16:47:13 -0400 Subject: [PATCH 26/37] swap: bill a funding announcement to its funder, and let them cancel it fundyield billed the pending row to the contract and only the exact matching transfer erased it, so an announcement nobody delivers sat on sysio.swap's RAM forever, one per account that ever called the action and no way to reclaim any of them. The row is now billed to `from`, which puts the cost on whoever creates it and makes the accumulation self-limiting; the action therefore carries `{from, sysio.payer}` alongside its active permission, as inittoken already does. `cancelyield(from)` drops a pending announcement and refunds the row. That also fixes the state it left a funder in: while an announcement is pending every other transfer from them is refused, so a funder who changed their mind or named an amount they cannot send had no way back except delivering it. Covered by a case that watches both RAM balances across the announcement, refuses a cancel signed by anyone else, refuses a cancel with nothing pending, and checks ordinary deposits work again afterwards. Co-Authored-By: Claude Opus 5 Change-Id: Id0c32ce6e36a7cd580d99396d95cba8969cf5c49 --- contracts/sysio.swap/Commands.md | 6 ++- contracts/sysio.swap/README.md | 2 +- .../include/sysio.swap/sysio.swap.hpp | 6 +++ .../ricardian/sysio.swap.contracts.md | 15 ++++++ contracts/sysio.swap/sysio.swap.abi | 15 ++++++ contracts/sysio.swap/sysio.swap.cpp | 15 ++++-- contracts/sysio.swap/sysio.swap.wasm | Bin 46895 -> 47393 bytes contracts/tests/sysio.swap_tests.cpp | 45 ++++++++++++++++-- 8 files changed, 95 insertions(+), 9 deletions(-) diff --git a/contracts/sysio.swap/Commands.md b/contracts/sysio.swap/Commands.md index bb986074cf..43935b8619 100644 --- a/contracts/sysio.swap/Commands.md +++ b/contracts/sysio.swap/Commands.md @@ -51,9 +51,13 @@ Settle the yield the pool is owed on the shadow it holds into its other leg, min 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 + 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 diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md index 76a0bf0085..49fc11a121 100644 --- a/contracts/sysio.swap/README.md +++ b/contracts/sysio.swap/README.md @@ -33,7 +33,7 @@ Every pair keeps two cumulative-price accumulators in the `priceaccum` table, on 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. 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. +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. diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index 1f7a6e0567..393c2477e9 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -105,7 +105,13 @@ namespace sysio { /// 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 diff --git a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md index bb1860e8ee..4250390238 100644 --- a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md +++ b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md @@ -210,6 +210,21 @@ summary: 'Announce {{nowrap quantity}} of shadow for the reservoir of {{nowrap p {{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. diff --git a/contracts/sysio.swap/sysio.swap.abi b/contracts/sysio.swap/sysio.swap.abi index 8b8d151481..9f90819beb 100644 --- a/contracts/sysio.swap/sysio.swap.abi +++ b/contracts/sysio.swap/sysio.swap.abi @@ -55,6 +55,16 @@ } ] }, + { + "name": "cancelyield", + "base": "", + "fields": [ + { + "name": "from", + "type": "name" + } + ] + }, { "name": "changefee", "base": "", @@ -599,6 +609,11 @@ "type": "addliquidity", "ricardian_contract": "" }, + { + "name": "cancelyield", + "type": "cancelyield", + "ricardian_contract": "" + }, { "name": "changefee", "type": "changefee", diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index 7933f18257..8d79a3a56e 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -440,13 +440,22 @@ void swap::fundyield(name from, symbol_code pair_token, asset quantity) { 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" ); - // The row is transient (the matching transfer erases it) and replaceable by - // its own funder, so the contract carries it rather than billing `from`. + // 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( get_self(), funder_key{ from }, + 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() }; diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 392153befdcae267e0b6a64e3ea7c56428496195..866118b72dfb83d4b03c424da164d7bff8be4410 100755 GIT binary patch delta 2994 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 diff --git a/contracts/tests/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index 6894fb0cad..c51e84f9b3 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -1,4 +1,5 @@ #include +#include #include @@ -269,12 +270,20 @@ class sysio_swap_tester : public tester { 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. + // 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() } ); @@ -301,7 +310,9 @@ class sysio_swap_tester : public tester { action announce; announce.account = "sysio.swap"_n; announce.name = "fundyield"_n; - announce.authorization = { {from, config::active_name} }; + // 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) ); @@ -1813,6 +1824,29 @@ BOOST_FIXTURE_TEST_CASE( yield_funding_fills_the_reservoir, sysio_swap_tester ) 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, EOS4 ), "" ) ); + // ...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, EOS4 ), "" ) ); + 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() ); @@ -2816,8 +2850,9 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { std::set actions; for (const auto& a : abi.actions) actions.insert(a.name.to_string()); const std::set expected_actions{ - "accrueyield", "addliquidity", "changefee", "close", "closeext", "exchange", "fundyield", "inittoken", - "open", "openext", "remliquidity", "setconfig", "setyield", "sync", "tickyield", "transfer", "withdraw" }; + "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>; @@ -2847,6 +2882,7 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { 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"} }; @@ -2875,6 +2911,7 @@ BOOST_FIXTURE_TEST_CASE( abi_surface_is_pinned, sysio_swap_tester ) try { 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 ); From 8b2816945b6d1e0311025874381008daa5ca480a Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Tue, 15 Sep 2026 16:51:04 -0400 Subject: [PATCH 27/37] swap: drop the undefined string_to_asset declaration Declared on the contract class and never defined or called, carried over from upstream. The memo path parses through utils.hpp's asset_from_string. Sweeping the rest of the class turned up no other declaration without a definition. Co-Authored-By: Claude Opus 5 Change-Id: I13fe1db5c43318cba72926c13f38d84b574a3643 --- contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp | 1 - 1 file changed, 1 deletion(-) diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index 393c2477e9..676497d1fd 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -364,7 +364,6 @@ namespace sysio { /// 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); - asset string_to_asset(string input); 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 ); From 7b2838cdd0b410755ce1f13da14320499ff5bbc0 Mon Sep 17 00:00:00 2001 From: Josh Burroughs Date: Wed, 16 Sep 2026 09:35:56 -0400 Subject: [PATCH 28/37] swap: decline a clip whose output would not bear the pair's fee The clip floor is in shadow units, so the condition it stands in for -- that a clip's output is large enough for the pair's proportional fee to reach a whole unit -- it reaches only through the pool price and the precision gap between the two legs. Both move: the price with every trade, and the fee itself with changefee, after setyield has already fixed the floor. Below FEE_DENOMINATOR/fee units of output the proportional fee floors to zero and MIN_SWAP_FEE binds instead, so the clip pays far above the pair's rate -- an output of 2 pays half of itself -- out of the holders' distribution. tickyield now quotes the clip and declines it when the output falls short of that threshold, which is stated in output units, the one place the condition does not move with the price. The quote is taken against the pool before accrual, and accrual only ever raises the other leg, so a clip that clears it here clears it on the sale. Declining banks the elapsed time exactly as the clip floor's own skip does, so throughput is unchanged and a crank that finds nothing to do pays nothing for the attempt. Both gates return rather than assert, 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 instead as a non-empty reservoir whose last_tick is not advancing. 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. process_exch's quote math moves to a pure quote_out so the tick can ask what a clip would fetch without moving anything, and clip_floor's documentation now describes it as the sale granularity it is rather than the fee-rate guarantee it cannot be. The new case drops the fee to 0.01%, which puts the fee-bearing output an order of magnitude above the clip floor, and pins the tick declining while the clip clears the floor alone, then selling in one piece measured from the original clock once the banked time carries the output over. Co-Authored-By: Claude Opus 5 Change-Id: I78597c27d6215ca27f8dc7427a5dc6dc4761749c --- contracts/sysio.swap/README.md | 2 +- .../include/sysio.swap/sysio.swap.hpp | 43 ++++++++--- .../ricardian/sysio.swap.contracts.md | 4 +- contracts/sysio.swap/sysio.swap.cpp | 52 ++++++++++--- contracts/sysio.swap/sysio.swap.wasm | Bin 47393 -> 47667 bytes contracts/tests/sysio.swap_tests.cpp | 73 ++++++++++++++++++ 6 files changed, 148 insertions(+), 26 deletions(-) diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md index 49fc11a121..fa90dd59fa 100644 --- a/contracts/sysio.swap/README.md +++ b/contracts/sysio.swap/README.md @@ -37,7 +37,7 @@ A yield pool also has a *reservoir* (`reservoirs` table): shadow queued to be so 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. The floor exists because a clip small enough is consumed entirely by integer rounding on the curve and by the one-unit minimum swap fee, so it must be sized above that. 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, so deployment must grant the shadow token's `sysio.code` on the contract's active permission. +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, so deployment must grant the shadow token's `sysio.code` on the contract's active permission. Deployment therefore involves, in order: `setconfig` with the governance account and the system token; for each shadow token, the `sysio.code` grant above; 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. diff --git a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp index 676497d1fd..4fc420c307 100644 --- a/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp +++ b/contracts/sysio.swap/include/sysio.swap/sysio.swap.hpp @@ -74,13 +74,16 @@ namespace sysio { /// 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 in units of the shadow, so it is precision-relative and has - /// no sensible default. Size it so the pair's fee on a clip's output reaches a - /// whole unit on its own, which is an output of FEE_DENOMINATOR/fee units: - /// below that, MIN_SWAP_FEE is the binding fee and a clip pays far above the - /// pair's rate (an output of 2 pays half of itself). Erring high costs + /// `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. + /// 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. @@ -121,11 +124,17 @@ namespace sysio { /// 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, or when the clip - /// has not reached `min(clip_floor, queued)` yet. That last case is what makes - /// cranking every block harmless: a clip below the floor sells nothing AND - /// leaves the clock alone, so the next tick measures a longer window and - /// offers a proportionally larger clip. + /// 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); @@ -343,6 +352,18 @@ namespace sysio { 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 diff --git a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md index 4250390238..569fee47ba 100644 --- a/contracts/sysio.swap/ricardian/sysio.swap.contracts.md +++ b/contracts/sysio.swap/ricardian/sysio.swap.contracts.md @@ -236,9 +236,9 @@ 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 (in basis points of the pool's shadow side) and at most the reservoir. 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 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 the clip has reached the pair's clip floor, or the whole of the reservoir if that is smaller. When it has not, 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. +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

diff --git a/contracts/sysio.swap/sysio.swap.cpp b/contracts/sysio.swap/sysio.swap.cpp index 8d79a3a56e..6fcf1f2720 100644 --- a/contracts/sysio.swap/sysio.swap.cpp +++ b/contracts/sysio.swap/sysio.swap.cpp @@ -194,6 +194,26 @@ void swap::exchange( name user, symbol_code pair_token, 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() ); @@ -223,18 +243,7 @@ extended_asset swap::process_exch(symbol_code pair_token, } const int64_t A_in = ext_asset_in.quantity.amount; check( (A_in > 0) && (P_in > 0) && (P_out > 0), "invalid parameters"); - // 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(P_in), CP_WEIGHT_BPS, - uint64_t(P_out), CP_WEIGHT_BPS, - uint64_t(A_in)); - uint64_t fee = opp::amm::split_wire_fee(gross, uint32_t(token.fee), NO_UNDERWRITER_SHARE_BPS).fee; - if (token.fee > 0 && gross > 0) fee = std::max(fee, MIN_SWAP_FEE); - const int64_t A_out = int64_t(gross - fee); + 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) { @@ -535,6 +544,25 @@ void swap::tickyield(symbol_code pair_token) { // 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; diff --git a/contracts/sysio.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 866118b72dfb83d4b03c424da164d7bff8be4410..1b6378a4e1b3bc754301690251abbb4126ffffb8 100755 GIT binary patch delta 3622 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 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 ) { @@ -2140,6 +2147,72 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_never_sells_below_the_clip_floor, sysio_swap 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_tester ) try { + setup_yield_pool(); + grant_shadow_code( "sysio.swap"_n, true ); + 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( "shadowtoken"_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. From 4dc496fc4292b4c263708dd909e3ed23b5dd7cb4 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 21 Sep 2026 14:01:12 -0500 Subject: [PATCH 29/37] contracts: widen the shadow yield index to uint128 A uint64 index wraps once cumulative yield per shadow subunit passes ~1.8e7, which a thinly held symbol reaches on one add. shadow_yield.hpp now carries index and index_checkpoint as the ABI builtin uint128, owed() computes in 128 bits and checks the asset range, and the swap suite pins a supply-of-one add past 2^64. shadowtoken and sysio.swap rebuilt against the header. Change-Id: I167048937af536c89b2f6551682b02457a620a1d --- .../include/sysio.opp.common/shadow_yield.hpp | 37 ++++++--- contracts/sysio.swap/sysio.swap.wasm | Bin 47667 -> 47813 bytes .../shadowtoken/shadowtoken.abi | 4 +- .../shadowtoken/shadowtoken.cpp | 8 +- .../shadowtoken/shadowtoken.hpp | 2 +- .../shadowtoken/shadowtoken.wasm | Bin 13751 -> 14181 bytes contracts/tests/sysio.swap_tests.cpp | 78 ++++++++++++++---- 7 files changed, 95 insertions(+), 34 deletions(-) diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/shadow_yield.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/shadow_yield.hpp index b2b0bb6997..afd863e125 100644 --- a/contracts/sysio.opp.common/include/sysio.opp.common/shadow_yield.hpp +++ b/contracts/sysio.opp.common/include/sysio.opp.common/shadow_yield.hpp @@ -16,18 +16,24 @@ * `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 { -using u128 = unsigned __int128; +/// 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; @@ -43,8 +49,9 @@ inline constexpr name YIELD_INDEX_TABLE = "yieldidx"_n; 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 -/// (the token contract therefore needs `sysio.code` on `from`'s active), and -/// advance the index by quantity / supply, carrying the remainder. +/// (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. @@ -55,27 +62,31 @@ struct symbol_key { /// A holder's row for one shadow symbol. struct account { - asset balance; - uint64_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 + 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 { - uint64_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 + 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. -inline uint64_t owed(const account& row, uint64_t index) { - if (index <= row.index_checkpoint || row.balance.amount <= 0) return row.owed_wire; +/// 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; - return row.owed_wire + static_cast(accrued); + 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.swap/sysio.swap.wasm b/contracts/sysio.swap/sysio.swap.wasm index 1b6378a4e1b3bc754301690251abbb4126ffffb8..f4bd1c028fdf9dc8fb61b51e3c51d3c40990bf13 100755 GIT binary patch delta 1243 zcmYjQUu;uV7(ajRx(j!6S43^MbeI}PX@lGJ1*YfQ|R_R4lgwz3si zbzQ4jjGCYuXBrY4QWGUhaIt!QV95x5@r?(3Fx%n_&*%&>=#vQFcQ1&2`2L;m_dCD) zeW(6i_S0Xi{tVw%-y~Lw2<3L2vtu{gRb$ zGe*nC+%BRTbX>CCaG#(_PEzJaz5FUokpvekMS6Lih9_n(UxrZKAs*OeiuNk?>{*ok zJXxSeTY)3V^Jr$~)0hbYjJ|rla+OX(H8QwT5&*H| zpbu`egLY-&WvNPS>6`O$IPAnDA_J1XS;X1GO=@GMf%n{TQeMx`zMV zv43}V-l8yUio?Fi#wCYga|KJh!NA`j`uh&??0O6oVde?m9i9kJi3=mqaugm8ZURxZ zCt){1jLsDNVFqW|Z%5kUwMAjX!Q|o1-~@27h#~NCf+sob5)zECtZ0H+kg~%>0L@M2 z?IzZrl%r%?-X-CQ82?kfhC0_+z-rnrVWNMIC3NMonZPp04_X{gRhZo@Vc7$)6xh~b z;^@xqSgQl_>^Z1Vtf=<5-nwiadW?ld+s*ATQVixqlO08|%CRE>!h*PApvW`&_Z=}R zbb4=8PYg6~cV5Gb1XYfHa_=60d-m)T@R#sEI345g`m2RB*sa1AUH5-I(}AU}LPAH2 z2O)Z+_%hf}ihW?Kg)V)ks3Dqv_wV4WOV{3;foOO!uRor>4g{~4POz}xw^hD0pmJsY z(l&4dpZ-R{^(-XO4KH4YHeW1dF`NJVegr`Gs>k(Ebw3pDRi8!~Pt^`Zu2Ik}{NpuB z*=L)6`YW%6U5oi^D_5)|V-q=dM&zg5tebPEMPbwxQLCLd!AalO~XmPs0+Dxm8z zlQLxS5{t)sOw>r$V?ZQaPcU>w(P51(H7^$FsnRUam_5bO3CI9zjsW=-lXZ?-jcmkZ zXK_X+A9AAHcaUUqlKbKselDK{#!T`hCr(Cd@r1Y<_2W4?9yLkoCLuUQhYlbz$-gLwYqRiY8K^-3KR`5}K7Nm5G z0n8|K3`UUdgT0`uL&IySdkoJ`&fW1rXTQx-0S)*mXuypvX`KC4=6j351guP0CEX?k z6DS9BGX!$8cv`_yxpL-Eg~e!f0cRHX>MovYRgdI{t8&zY5F8)IGz(5;+O{bvC^gFj zFGCbcl{6cLV5>L{^<{u9wp;MNFm6ABng>P=HNbVmv7fl1CQZl-)Ky|tA@{sk3i_S! zVw!$TWP<_mdR`O1gG?AkB_0w@MitGhVR6yeC$g7ZV#GLI){SuruBsPq3a*E!SCq8w z7M@)$kqp+$b6veV;S-W?ddew$EgOk;h}m>V#IG;m2^s9IP;fTw7nhR_u)3E#4)J|5 z3~?h75Y7EU_4|L?y@2?3qYqXK<8kqBU7VefKN2nS15T zSSc*!!uSe|$}5vys>)9NajJe=>1ESxa{9rVQo1mE#CaPt%)N4E7Gv@%FVl~#VzT9x e+LOm_vHhY>)GfET5WfXKY*Zwc+Z^#j2>k`N6#KIP diff --git a/contracts/test_contracts/shadowtoken/shadowtoken.abi b/contracts/test_contracts/shadowtoken/shadowtoken.abi index dd86aed379..cc1036d440 100644 --- a/contracts/test_contracts/shadowtoken/shadowtoken.abi +++ b/contracts/test_contracts/shadowtoken/shadowtoken.abi @@ -13,7 +13,7 @@ }, { "name": "index_checkpoint", - "type": "uint64" + "type": "uint128" }, { "name": "owed_wire", @@ -157,7 +157,7 @@ "fields": [ { "name": "index", - "type": "uint64" + "type": "uint128" }, { "name": "pot", diff --git a/contracts/test_contracts/shadowtoken/shadowtoken.cpp b/contracts/test_contracts/shadowtoken/shadowtoken.cpp index 62c4d699ab..6ebb70dbf8 100644 --- a/contracts/test_contracts/shadowtoken/shadowtoken.cpp +++ b/contracts/test_contracts/shadowtoken/shadowtoken.cpp @@ -53,7 +53,7 @@ void shadowtoken::addyield(name from, asset quantity, symbol_code target) { yieldidxs indexes( get_self() ); opp::shadow::yield_index idx = indexes.try_get( key ).value_or( opp::shadow::yield_index{} ); const u128 total = static_cast(quantity.amount) * opp::shadow::YIELD_INDEX_SCALE + idx.carry; - idx.index += static_cast( total / static_cast(st.supply.amount) ); + idx.index += total / static_cast(st.supply.amount); idx.carry = static_cast( total % static_cast(st.supply.amount) ); idx.pot += quantity.amount; indexes.upsert( get_self(), key, idx ); @@ -68,7 +68,7 @@ void shadowtoken::claim(name holder, symbol_code sym) { const opp::shadow::symbol_key key{ sym.raw() }; const auto row = holdings.try_get( key ); check( row.has_value(), "no balance object found" ); - const uint64_t index = current_index( sym ); + const u128 index = current_index( sym ); const uint64_t owed = opp::shadow::owed( *row, index ); holdings.modify( name{}, key, [&]( auto& a ) { a.index_checkpoint = index; @@ -87,7 +87,7 @@ void shadowtoken::claim(name holder, symbol_code sym) { std::make_tuple( get_self(), holder, asset{ int64_t(owed), st.wire_symbol }, string("") ) ).send(); } -uint64_t shadowtoken::current_index(symbol_code sym) const { +u128 shadowtoken::current_index(symbol_code sym) const { yieldidxs indexes( get_self() ); const auto idx = indexes.try_get( opp::shadow::symbol_key{ sym.raw() } ); return idx ? idx->index : 0; @@ -96,7 +96,7 @@ uint64_t shadowtoken::current_index(symbol_code sym) const { void shadowtoken::settle_and_adjust(name owner, const asset& delta) { accounts holdings( get_self(), owner.value ); const opp::shadow::symbol_key key{ delta.symbol.code().raw() }; - const uint64_t index = current_index( delta.symbol.code() ); + 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" ); diff --git a/contracts/test_contracts/shadowtoken/shadowtoken.hpp b/contracts/test_contracts/shadowtoken/shadowtoken.hpp index 9e009f07d7..2c8ac23c3c 100644 --- a/contracts/test_contracts/shadowtoken/shadowtoken.hpp +++ b/contracts/test_contracts/shadowtoken/shadowtoken.hpp @@ -55,7 +55,7 @@ class [[sysio::contract("shadowtoken")]] shadowtoken : public contract { using accounts = kv::scoped_table<"accounts"_n, opp::shadow::symbol_key, opp::shadow::account>; using yieldidxs = kv::table<"yieldidx"_n, opp::shadow::symbol_key, opp::shadow::yield_index>; - uint64_t current_index(symbol_code sym) const; + opp::shadow::u128 current_index(symbol_code sym) const; /// Settle `owner`'s row at the current index, then apply `delta` to its /// balance. A holder without a row is stamped at the current index, so no /// history is credited to it. diff --git a/contracts/test_contracts/shadowtoken/shadowtoken.wasm b/contracts/test_contracts/shadowtoken/shadowtoken.wasm index 7888b021fb10cd0ae2bd073b04b91d8f1a7e0a2e..21f961f818b638687cd834d983fa727a0884dfa4 100755 GIT binary patch delta 4639 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 Z*WuHrb7$14!^gjn(_;H;Zl;~F{{THtdx-!5 delta 4118 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 WT@(3QX7Q=vHsxO*S%_9*A^r_Qun`~t diff --git a/contracts/tests/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index 0cd3236233..6f38868883 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -3,6 +3,7 @@ #include +#include #include #include #include @@ -13,6 +14,7 @@ #include "twap_wide.hpp" #include #include +#include #include #include @@ -30,18 +32,26 @@ using mvo = fc::mutable_variant_object; // 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; - uint64_t index_checkpoint; - uint64_t owed_wire; + 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 { - uint64_t index; - uint64_t pot; - uint64_t carry; + fc::uint128_t index; + uint64_t pot; + uint64_t carry; }; FC_REFLECT( shadow_index_row, (index)(pot)(carry) ) +// Boost.Test prints both operands of a failed assertion; the 128-bit index +// fields have no stream operator of their own. +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 ); } + }; +} + static symbol EVO4 = symbol::from_string("4,EVO"); static symbol ETUSD3 = symbol::from_string("3,ETUSD"); static symbol EOS4 = symbol::from_string("4,EOS"); @@ -725,13 +735,14 @@ namespace twap_reference { namespace yield_reference { using wide = boost::multiprecision::uint128_t; constexpr uint64_t Scale = 1'000'000'000'000; - struct distribution { uint64_t index_delta; uint64_t carry; }; + // The index and the checkpoints are 128-bit, like the rows they are read from. + struct distribution { wide index_delta; uint64_t carry; }; distribution distribute( int64_t wire, int64_t supply, uint64_t carry_in ) { const wide total = wide(wire) * Scale + carry_in; - return { uint64_t( total / supply ), uint64_t( total % supply ) }; + return { wide( total / supply ), uint64_t( total % supply ) }; } - int64_t owed( int64_t balance, uint64_t index, uint64_t checkpoint, uint64_t banked = 0 ) { - return int64_t( banked + uint64_t( wide(balance) * (index - checkpoint) / Scale ) ); + 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 ) ); } // 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 @@ -1571,7 +1582,7 @@ BOOST_FIXTURE_TEST_CASE( yield_accrues_into_the_pool_without_minting, sysio_swap BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( first_donation, EOS4 ), SHD ) ); const auto first = yield_reference::distribute( first_donation, ShadowIssuance, 0 ); auto idx = shadow_index( SHD ); - BOOST_REQUIRE_EQUAL( first.index_delta, idx.index ); + 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 @@ -1606,9 +1617,9 @@ BOOST_FIXTURE_TEST_CASE( yield_accrues_into_the_pool_without_minting, sysio_swap BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( third_donation, EOS4 ), SHD ) ); const auto second = yield_reference::distribute( second_donation, ShadowIssuance, first.carry ); const auto third = yield_reference::distribute( third_donation, ShadowIssuance, second.carry ); - const uint64_t index_after_three = first.index_delta + second.index_delta + third.index_delta; + 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, idx.index ); + 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 ) ); @@ -1629,6 +1640,44 @@ BOOST_FIXTURE_TEST_CASE( yield_accrues_into_the_pool_without_minting, sysio_swap 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_tester ) try { + create_tokens_and_issue(); + BOOST_REQUIRE_EQUAL( success(), shadow_create( "alice"_n, asset( ShadowIssuance, SHD4 ), WIRE ) ); + BOOST_REQUIRE_EQUAL( success(), shadow_issue( "alice"_n, "alice"_n, asset( 1, SHD4 ) ) ); + BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "bob"_n, asset( BobDonationBudget, EOS4 ), "" ) ); + grant_shadow_code( "bob"_n ); + + 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, EOS4 ), 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, EOS.value ); + BOOST_REQUIRE_EQUAL( success(), shadow_claim( "alice"_n, SHD ) ); + BOOST_REQUIRE_EQUAL( alice_before + donation, token_balance( "sysio.token"_n, "alice"_n, EOS.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, EOS4 ), 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, EOS.value ); + BOOST_REQUIRE_EQUAL( success(), shadow_claim( "alice"_n, SHD ) ); + BOOST_REQUIRE_EQUAL( alice_before + donation, token_balance( "sysio.token"_n, "alice"_n, EOS.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_tester ) try { setup_yield_pool(); const int64_t donation = 300'0000; @@ -1933,7 +1982,8 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ BOOST_REQUIRE_EQUAL( contract_wire_before - proceeds, token_balance( "sysio.token"_n, "sysio.swap"_n, EOS.value ) ); auto distributed = yield_reference::distribute( proceeds, ShadowIssuance, idx_before.carry ); auto idx_after = shadow_index( SHD ); - BOOST_REQUIRE_EQUAL( idx_before.index + distributed.index_delta, idx_after.index ); + 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 ) ); From 3b050f70e44a6119bfd9b6358c62ab9939ebe6d7 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 22 Sep 2026 08:39:08 -0500 Subject: [PATCH 30/37] contracts: the LIQ yield flow on the depot sysio.liq shadows each outpost's liq token 1:1. SYNDICATE_LIQ credits a linked user or parks an unlinked pubkey; LIQ_YIELD lands in a pending balance that queueyield hands to the swap's reservoir; the WIRE that tickyield sells it for distributes through a uint128 cumulative index, with a T5 kicker drawn through fundclaim(recipient, amount); claim pays owed(row, index); desyndicate burns and queues DESYNDICATE_LIQ. sysio.msgch routes both inbound types behind the active-liq-token gate and never aborts an envelope: a malformed op_address now misses the authex lookup instead of aborting inside pubkey_to_checksum256, which the OPERATOR_ACTION path was exposed to as well. The bootstrap config gains liq_pools and syndications, replayed by regliqpool and importsynd inside the epoch-0 window; the liq suite replays the dev config end to end. Change-Id: I74b2101f74fdfebfef5f324eaaaa8e5acef114a7 --- contracts/CMakeLists.txt | 1 + .../include/sysio.authex/sysio.authex.hpp | 51 + contracts/sysio.dclaim/src/sysio.dclaim.cpp | 2 +- contracts/sysio.dclaim/sysio.dclaim.wasm | Bin 30325 -> 30347 bytes contracts/sysio.liq/CMakeLists.txt | 48 + contracts/sysio.liq/README.md | 93 ++ .../sysio.liq/include/sysio.liq/sysio.liq.hpp | 319 ++++++ contracts/sysio.liq/src/sysio.liq.cpp | 599 ++++++++++++ contracts/sysio.liq/sysio.liq.abi | 747 ++++++++++++++ contracts/sysio.liq/sysio.liq.wasm | Bin 0 -> 62211 bytes contracts/sysio.msgch/README.md | 7 +- contracts/sysio.msgch/src/sysio.msgch.cpp | 133 ++- contracts/sysio.msgch/sysio.msgch.abi | 12 + contracts/sysio.msgch/sysio.msgch.wasm | Bin 157530 -> 167898 bytes contracts/sysio.system/EMISSIONS.md | 10 +- .../include/sysio.system/sysio.system.hpp | 23 +- contracts/sysio.system/src/emissions.cpp | 37 +- contracts/sysio.system/sysio.system.abi | 4 + contracts/sysio.system/sysio.system.wasm | Bin 202534 -> 202629 bytes contracts/tests/contracts.hpp.in | 4 + contracts/tests/emissions_tests.cpp | 71 +- contracts/tests/liq_test_support.hpp | 45 + contracts/tests/shadow_yield_reference.hpp | 39 + contracts/tests/sysio.dispatch_tests.cpp | 373 ++++++- contracts/tests/sysio.liq_tests.cpp | 924 ++++++++++++++++++ contracts/tests/sysio.swap_tests.cpp | 26 +- etc/config/dex/dex-config.dev.json | 36 +- .../proto/sysio/opp/bootstrap/bootstrap.proto | 90 +- .../test/test_bootstrap_platform_config.cpp | 127 ++- 29 files changed, 3653 insertions(+), 168 deletions(-) create mode 100644 contracts/sysio.liq/CMakeLists.txt create mode 100644 contracts/sysio.liq/README.md create mode 100644 contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp create mode 100644 contracts/sysio.liq/src/sysio.liq.cpp create mode 100644 contracts/sysio.liq/sysio.liq.abi create mode 100755 contracts/sysio.liq/sysio.liq.wasm create mode 100644 contracts/tests/liq_test_support.hpp create mode 100644 contracts/tests/shadow_yield_reference.hpp create mode 100644 contracts/tests/sysio.liq_tests.cpp diff --git a/contracts/CMakeLists.txt b/contracts/CMakeLists.txt index 68de0a9afe..e9ea5c905e 100644 --- a/contracts/CMakeLists.txt +++ b/contracts/CMakeLists.txt @@ -59,4 +59,5 @@ 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 a48db164e4..a5c1fe04f0 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.dclaim/src/sysio.dclaim.cpp b/contracts/sysio.dclaim/src/sysio.dclaim.cpp index 8391a56965..7fb2920cbf 100644 --- a/contracts/sysio.dclaim/src/sysio.dclaim.cpp +++ b/contracts/sysio.dclaim/src/sysio.dclaim.cpp @@ -282,7 +282,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 9e653a09feebbe50659da0ea95af15ea326db000..a921d24e06969330e15b07cf7bbd66c0b467ba51 100755 GIT binary patch delta 70 zcmV-M0J;D5>;a4I0kH7{0q3*w1Mn*Xh7YskFY5*d_yZsbAOW+@HX8>4Uz6!Q9Rv^% cg8`F_6(tRX0U96zK?G_b0YM0Avo1bIINYxnLI3~& delta 46 zcmV+}0MY-8?E&@d0kH7{0nxMa1Mn*X{tmO`FY5-g*ESgk0a26jJsklElMg;9vqe5e EI0o($F8}}l 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..b3388ad2d0 --- /dev/null +++ b/contracts/sysio.liq/README.md @@ -0,0 +1,93 @@ +# 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. 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 every intake `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 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 | +| `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..89d7294821 --- /dev/null +++ b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp @@ -0,0 +1,319 @@ +#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, the WIRE is pulled from + /// `from` by inline transfer, and the kicker is requested from T5. + [[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. + 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; + /// 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 the asset range. + 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..201b44b183 --- /dev/null +++ b/contracts/sysio.liq/src/sysio.liq.cpp @@ -0,0 +1,599 @@ +#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 > static_cast(asset::max_amount - st->supply.amount)) { + 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 } }; + pendings.upsert(ram_payer, key, fresh, [&](pending_yield& p) { + // Saturate rather than abort: a pending balance this large is not reachable, + // and the never-throw contract holds either way. + const int64_t room = asset::max_amount - p.quantity.amount; + p.quantity.amount += std::min(room, 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; + 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. 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. + 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; }); +} + +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 > static_cast(asset::max_amount - st.supply.amount)) 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 0000000000000000000000000000000000000000..507b45d8a693b83873e60c495e08f2b9b1a0e088 GIT binary patch literal 62211 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 #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 @@ -37,8 +38,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. @@ -245,50 +248,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); @@ -608,6 +577,73 @@ 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; + 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. @@ -898,6 +934,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 @@ -1714,10 +1763,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.abi b/contracts/sysio.msgch/sysio.msgch.abi index 1a423908ef..d273339ee9 100644 --- a/contracts/sysio.msgch/sysio.msgch.abi +++ b/contracts/sysio.msgch/sysio.msgch.abi @@ -681,6 +681,18 @@ { "name": "ATTESTATION_TYPE_EMISSIONS_BLOCKED", "value": 60962 + }, + { + "name": "ATTESTATION_TYPE_SYNDICATE_LIQ", + "value": 60963 + }, + { + "name": "ATTESTATION_TYPE_LIQ_YIELD", + "value": 60964 + }, + { + "name": "ATTESTATION_TYPE_DESYNDICATE_LIQ", + "value": 60965 } ] }, diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index b581f28a4f6c2d039c3f9a103e3d3b01ddea705b..fef74aab37db725324a5c85f182441a40514c149 100755 GIT binary patch delta 57954 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%0DjKG{iN5}-je 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 delta 48346 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 U+apEwR@0c9^lBAN>=}vtKRso`UH||9 diff --git a/contracts/sysio.system/EMISSIONS.md b/contracts/sysio.system/EMISSIONS.md index 4ef33900fb..fe77620acc 100644 --- a/contracts/sysio.system/EMISSIONS.md +++ b/contracts/sysio.system/EMISSIONS.md @@ -184,6 +184,10 @@ 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 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 +208,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 +224,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 +249,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 916edb54c8..60c2203d4a 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 237237d15b..9706cef038 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 // @@ -1264,15 +1266,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 @@ -1297,8 +1302,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; @@ -1322,7 +1329,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 4a1d780ccd..e5563a058c 100644 --- a/contracts/sysio.system/sysio.system.abi +++ b/contracts/sysio.system/sysio.system.abi @@ -668,6 +668,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 f6399eed110447585a9a4c1a8a1bbfd81b5bf3fb..f5c33e1026b4853aa29312a17113d68713835b0c 100755 GIT binary patch delta 1899 zcmZuxeQXp}5Pvgo?|OIJiyKnVfHZFpgdRcwu`NP?ZUL3xKS&e|rm^9}^nhBR5<^;g zfg+_GEx8r87^0O(A&|CcO}5e)FM!wx1Y(d>Qwb)ZB^cU7R1gf#+_us)=5jl4W`4i< z+S%9Amc64r`^2k3m|`yr&I$A-L9Y?kfPFCdCET)K56zu_y%ChKjLi{6{oOJ|9BbfA*-4*_J2)z=8dOf2H^2Dm&b;%+0Dk}4)yz(yU5Jis0M zp*}H}ECtA=Qc3K!gy|zqODDWzFUU5b&t8*#4<2;95&mW*#*-bB#~n6u-b;c78@Nde zW(n5nlJ<=m$EVe$9Shl$hkKJ?6zlpl!6~PRk26TDH6`uTvty?=4JS2cl0M5Iv3AtX z&d;5CbU5ckCg)5BhqX@owfx+fox?dPkFy;BYsLxdnphhV;|D9foDtx%-JU-(zng5- zRRLb211r~r5hqg~o=<{GtJ-L9H^@LvHUP%-@mI(eSgLAg;!*sV?cX9so01M+Tn-n@ztW zv4IyO4+&*zv_#af_kyimN$2WGFoWdr*sl3L%orQuuCvnPaY|fR3Z2fUF3b*biD`4M z`=<*@)DI5jZ5qtm^nZB?H7qafh;?8vFL^jGu_^C>d$$V?rl8;1SA|`esCQqk#(Nt4 z>b|rMbs+ol?RWvYoC&q~6kBW2MvU)vetH*wdosQo48hG|RCfFM+38=FUh-`4;|JP+)|3EsC2@ zwwy_(PWHU8BuvKQcs%v{p+S{)wrAJSh>Kw<0q6>iN>M2LKuN0)V#X;+UkaU@Am&-D z1HTC4wEP#jmypFZL@~&}H;po4#&>>G{;7c1 z5%Xy<*O>B(iBppFl3`nCmIRKG@yq(1n2?Mc`)?k zk#KaQ&!0OulYGr9fS!L-uwQ6H&S-E)N%+HbU1cdc>J?KAZPFIec?Dpr#8K_k(65=28 CKKelb delta 1772 zcmZ8hYfKzf6u#%&*#|oihYHcqKxT$o*`^gt-K8mMyMwLTkoaoqqcu%`nEsfW#%g1T z0TkUs|#{rd~97UN{He|`BmrRbL=7;so@ z(tyoOuw2rb+W1jb&UNe%o(h~vf>JBXRZK{3V1KVj5^OeUWgjW6oL!#u*UF^U)N&=k znymatS!I)JUlcl#S|QZ6B1f%~afrjF$~onFo{0F0M2BgYQvY|Ub)7grRxXk{YB zk#4fluAek zhADpOGzFs>l$4k|Rf4I4yQB1ev?@G6rZp7Olx51+5?NLO+(I;zE0hNMe~q4d6Bp3x zJ9J1MKvU&LFPh3#>*+INfiUkyB@wW?>myc^>F<0UvXr(t7hNbEM?9*bS?z%J;puQ^Z?60N!B0jF>f zQYim2N5!#)bEqEOGXe-#C_d}`u zb}Jsmg@o-(;5xWqS0zw`Pn~BIct^tNHs`4n9`Ql9GiKwy5L|M~&){uN4!fuauM3>& z!F>XEoX3YnLj8H%#$nVMynt&t&bQgQOQ;FtX(W>!urrOiz~MB81x}|i zBCssu#cj^u3xdCw!R;mUZJ^M18|LFdtw@zNu)(1Fb9A6G)+J=emn0=mLf7Ury)&j8(KGhiwsoF)s9*Ip5D8EWJ-b%ZN>T#?DUP1qt97YSqbu3_ zk;&bV(53ouG*}oXPeL`PVoQ6c8fXMfWr14pdQ+u_YHE=VgzYYXe5B7!R3dZ(Cdoqq zlR#V?5dDjzakn?)Cu0R(1D$z?HX%H8;i}9>H-L)HH`j5kk7jTF25!mYKb-<=|I|9? zg4N!cQJ!EJ%EWuz+yFK2yZ&ZM~SU W4>IGQ7Le1D^JFpKh*E4PWB&r|&*b(1 diff --git a/contracts/tests/contracts.hpp.in b/contracts/tests/contracts.hpp.in index d2512529e4..84030bfd9b 100644 --- a/contracts/tests/contracts.hpp.in +++ b/contracts/tests/contracts.hpp.in @@ -40,6 +40,10 @@ struct contracts { 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"); } diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index f1e4bad8f3..801c47f529 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) ); } @@ -5569,7 +5590,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; @@ -5577,11 +5598,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(); @@ -5593,7 +5626,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 ); @@ -5614,8 +5647,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() ); @@ -5664,7 +5697,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(); @@ -5673,7 +5706,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() ); @@ -5688,12 +5721,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..a75966e551 --- /dev/null +++ b/contracts/tests/liq_test_support.hpp @@ -0,0 +1,45 @@ +#pragma once + +#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()); +} + +} // 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 1b6d72e458..0e7eac430a 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -38,9 +38,11 @@ #include #include #include +#include #include "contracts.hpp" #include "contract_test_support.hpp" +#include "liq_test_support.hpp" // Canonical-encoding + header-derivation oracle: inbound envelopes must carry // spec-derived semantic headers or apply_consensus drops them before dispatch. #include "opp_envelope_oracle.hpp" @@ -125,11 +127,17 @@ authority active_with_code_authors(name account, const std::vector& code_a return a; } -/// 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); @@ -138,10 +146,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); @@ -150,34 +160,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 @@ -328,6 +329,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 class sysio_dispatch_tester : public tester { @@ -1387,8 +1450,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, chains_abi, roa_abi, - token_abi; + token_abi, tokens_abi, liq_abi; std::vector uwrit_op_eth_pubkey; }; @@ -1754,10 +1906,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")); @@ -6350,4 +6499,158 @@ 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 links. + const auto stranger = em_pubkey_bytes( + fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em).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")); +} 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..0efc1ec654 --- /dev/null +++ b/contracts/tests/sysio.liq_tests.cpp @@ -0,0 +1,924 @@ +#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; + +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) { + return sysio_system::test_support::push_contract_action_and_produce_block(*this, code, ser, signer, + action_name, data); + } + 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 mvo slug(std::string_view s) { return mvo()("value", fc::slug_name{ s }.value); } + 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", slug(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", slug(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", slug(chain_code))("token_code", slug(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) { + return push(AUTHEX_ACCOUNT, authex_abi_ser, AUTHEX_ACCOUNT, "recordlink"_n, mvo() + ("account", account)("chain_kind", kind)("pub_key", 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", slug(chain_code))("token_code", slug(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", slug(chain_code))("sequence", sequence) + ("account", account)("token_code", slug(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", slug(chain_code))("sequence", sequence) + ("chain_kind", kind)("pubkey", pubkey)("token_code", slug(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", slug(chain_code))("sequence", sequence) + ("epoch", epoch)("token_code", slug(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", slug(chain_code))("token_code", slug(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", slug(chain_code))("token_code", slug(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())); + } + + // --- 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 intake's trace carries a fundclaim for this contract at 2%, then the + // fold. 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. + auto trace = base_tester::push_action(LIQ_ACCOUNT, "addyield"_n, "carol"_n, mvo() + ("from", "carol"_n)("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; + } + BOOST_REQUIRE(requested); + BOOST_REQUIRE(folded); + BOOST_REQUIRE_EQUAL(100 * UNIT, owed("alice"_n)); + BOOST_REQUIRE_EQUAL(uint64_t(100 * UNIT), pot()); + + // With the kicker off, no request is made at all. + 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()); + trace = base_tester::push_action(LIQ_ACCOUNT, "addyield"_n, "carol"_n, mvo() + ("from", "carol"_n)("quantity", asset(100 * UNIT, WIRE_SYM))("target", LIQSOL_SYM.to_symbol_code())); + produce_block(); + for (const auto& at : trace->action_traces) + BOOST_REQUIRE(!(at.act.account == SYSIO_ACCOUNT && at.act.name == "fundclaim"_n)); +} 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() + +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 index 6f38868883..afb051e454 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -11,6 +11,7 @@ #include #include #include +#include "shadow_yield_reference.hpp" #include "twap_wide.hpp" #include #include @@ -44,14 +45,6 @@ struct shadow_index_row { }; FC_REFLECT( shadow_index_row, (index)(pot)(carry) ) -// Boost.Test prints both operands of a failed assertion; the 128-bit index -// fields have no stream operator of their own. -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 ); } - }; -} - static symbol EVO4 = symbol::from_string("4,EVO"); static symbol ETUSD3 = symbol::from_string("3,ETUSD"); static symbol EOS4 = symbol::from_string("4,EOS"); @@ -728,22 +721,9 @@ namespace twap_reference { } } -// The shadow yield spec (sysio.opp.common/shadow_yield.hpp), written by hand: -// 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. +// 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 { - 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; }; - 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 ) }; - } - 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 ) ); - } // 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 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; From 06370dd63ebdbd4e61da813b0a1c19fe9f025186 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 22 Sep 2026 08:39:08 -0500 Subject: [PATCH 31/37] plugins: cranks and the relay shape for the LIQ yield flow batch_operator_plugin pushes sysio.swap::tickyield per yield pool with a queued reservoir, spaced per pool by --batch-yield-tick-interval-ms, and sysio.liq::queueyield per shadow with pending yield; both stay idle until sysio.swap and sysio.liq are deployed, so an undeployed contract never logs a failed table read every poll. outpost_solana_client carries the DESYNDICATE_LIQ effect shape, the handler's remaining accounts derived from the user's pubkey, the pool PDAs and the mint on DistributionState, and cranks report_liq_yield once per epoch after the elected operator's delivery, PostLaunch only. outpost_client gains crank_outpost for that; the Ethereum relay keeps the no-op default. Change-Id: If43f669eeb6b16c57a0a627d9e5994fafabdc9b6 --- plugins/batch_operator_plugin/README.md | 17 + .../src/batch_operator_plugin.cpp | 150 ++++++++ .../src/outpost_opp_job.cpp | 16 + .../src/yield_cranks.hpp | 131 +++++++ .../test/mocks/mock_outpost_client.hpp | 18 + .../test/test_outpost_opp_job.cpp | 99 +++++ .../test/test_yield_cranks.cpp | 107 ++++++ .../sysio/outpost_client/outpost_client.hpp | 24 ++ .../outpost_solana_client_plugin/README.md | 42 +++ .../outpost_solana_client.hpp | 140 ++++++- .../src/outpost_solana_client.cpp | 355 ++++++++++++++++++ .../test_outpost_solana_client_plugin.cpp | 304 ++++++++++++++- .../fixtures/solana-idl-opp-outpost-stub.json | 178 +++++++++ 13 files changed, 1576 insertions(+), 5 deletions(-) create mode 100644 plugins/batch_operator_plugin/src/yield_cranks.hpp create mode 100644 plugins/batch_operator_plugin/test/test_yield_cranks.cpp 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 b1da015304..af0e372c88 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 @@ -164,6 +164,30 @@ 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). + * + * @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_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/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" + } + ] + } } ] } From 6602ec8fc27c53f6cefae7826edf859ebd830cf9 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 22 Sep 2026 09:11:04 -0500 Subject: [PATCH 32/37] contracts/tests: cover selling shadow into the yield pool The other exit: a holder deposits sysio.liq shadow on the swap, exchanges it for WIRE and withdraws it to their wallet, with both legs conserved exactly. The test support header gains payer_authorization for actions an unprivileged contract bills to the user. Change-Id: I3f0ac133a4641eb04ee6a698151b118e46c0a0b7 --- contracts/tests/contract_test_support.hpp | 26 ++++++--- contracts/tests/sysio.liq_tests.cpp | 66 ++++++++++++++++++++++- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/contracts/tests/contract_test_support.hpp b/contracts/tests/contract_test_support.hpp index 05637c0cda..b32cfe4d39 100644 --- a/contracts/tests/contract_test_support.hpp +++ b/contracts/tests/contract_test_support.hpp @@ -20,18 +20,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 +54,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 +78,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) { diff --git a/contracts/tests/sysio.liq_tests.cpp b/contracts/tests/sysio.liq_tests.cpp index 0efc1ec654..8d0034d0c6 100644 --- a/contracts/tests/sysio.liq_tests.cpp +++ b/contracts/tests/sysio.liq_tests.cpp @@ -124,9 +124,11 @@ class sysio_liq_tester : public tester { 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) { + 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); + 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); @@ -275,6 +277,26 @@ class sysio_liq_tester : public tester { 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 --- @@ -729,6 +751,46 @@ BOOST_FIXTURE_TEST_CASE(queued_yield_sells_through_the_pool_and_pays_holders, sy BOOST_REQUIRE_EQUAL(alice_before + alice_owed, wire_balance("alice"_n)); } 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)); From df505561dbaeabe3980fa5554ad4dc88d7742029 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 22 Sep 2026 12:21:11 -0500 Subject: [PATCH 33/37] authex: sweep parked liq on createlink; swap tests use the real sysio.liq createlink sends sysio.liq::linkswept inline once sysio.liq is deployed and privileged, so a syndication parked against an unlinked key is delivered the moment the key links (the hook deferred until #594 landed). The shadowtoken stand-in is gone: the swap suite deploys the real sysio.liq, and its yield tests run on a fixture whose system token is 9,WIRE (the contract takes yield and pays claims in WIRE, and a pair's second leg is the system token), so SHEO is SHD/WIRE at precision 6. The AMM tests keep upstream's EOS fixture. recordlink carries native_address (#594) in the liq tests. Change-Id: Ie3dbb061257a402a2ee0ef475131728baedd7b34 --- contracts/sysio.authex/src/sysio.authex.cpp | 23 + contracts/sysio.authex/sysio.authex.wasm | Bin 36079 -> 36689 bytes contracts/sysio.swap/README.md | 4 +- contracts/test_contracts/CMakeLists.txt | 1 - .../test_contracts/shadowtoken/CMakeLists.txt | 13 - .../shadowtoken/shadowtoken.abi | 229 ---------- .../shadowtoken/shadowtoken.cpp | 112 ----- .../shadowtoken/shadowtoken.hpp | 63 --- .../shadowtoken/shadowtoken.wasm | Bin 14181 -> 0 bytes contracts/tests/contract_test_support.hpp | 7 + contracts/tests/contracts.hpp.in | 2 - contracts/tests/liq_test_support.hpp | 9 + contracts/tests/sysio.dispatch_tests.cpp | 116 ++++- contracts/tests/sysio.liq_tests.cpp | 31 +- contracts/tests/sysio.swap_tests.cpp | 396 ++++++++++-------- docs/contract-upgrade-order.md | 1 + 16 files changed, 393 insertions(+), 614 deletions(-) delete mode 100644 contracts/test_contracts/shadowtoken/CMakeLists.txt delete mode 100644 contracts/test_contracts/shadowtoken/shadowtoken.abi delete mode 100644 contracts/test_contracts/shadowtoken/shadowtoken.cpp delete mode 100644 contracts/test_contracts/shadowtoken/shadowtoken.hpp delete mode 100755 contracts/test_contracts/shadowtoken/shadowtoken.wasm 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 e51c08c769f98da3118c9fc25ab7c37219dcc4cc..92f6be06b5c4853b04158bfa80ea23d8011adf2d 100755 GIT binary patch delta 960 zcma)3T}V_x6uxKX-n*lBl?xXAXf?ZPx`?a0(k@}^?lnS4*ffQ}zyv|v+mAn~y|q3? z(9g?Iph9dB6eY1V23kf&PeDCM1qDG5^`4+bghXdnP3a-J7v`Mvo$q|-oSCoVWZ?$M zKBI+&1hxRB6ac7IEML=BARjMCZcOeb_e$l{(kuBSV~FNpmoihq3T55~yLIMrCR(eC zL`$tMibIy{thoumB!p0k2|e6_XIHqgk&^(`du4pPQ9r1_})gbV7BDn~u+} z@UsYE+~myhxe;PJn$IInRF{<_!aaz1z5mao|JF=zXcEQbo4a$@@4^1q)pRa`i%D}Q zj)j=l3l@&05}IO|jarm2izFJy#^%4>nt%Mh$+iv^Xq!SWhFA;{@&nKsQM!mW?W}-{ zD$KQelpGdh9$dTn;?1RWl-{K|U`;SeGt{whJ`a-cd-P;H9Hn?8JTFR7+L@s)_c*re z*CRy5(-^P1YUC`6Idce^&LX&WFWL-s({>+8s7IKieot;fXI{xD1&Ylx zx}IT`L|dy;i7}E(r~>9wm%-WU&iI^O&i<&qf{h|E5uVIXaq4(+Ry!r)brM{;R7VJ$ zl;8Iqqi{+l_unRPS}fJeqB9m!v&1datm8n^9PA-6d!Q8tgcU1;eo-5%M!z$*2L|N% zSOKs>uHlf#5)D0&54z5LFxcL{s4x!{OnLg4@ag5JL%Mr+H!&BC@jhEOd zZm2y?bim16r{dFaiCpggq9g6Ya69f89H~OTV`K{NL|0q`@5CORK=A%(3ldgF1E{w5 I+{>-M0dc(Hs{jB1 delta 456 zcmcaOkLmqPrVaa<`9v6*nVA_G8JW4I`Is0tpJfhYWct;(S%G!79McK8&3l!5xfnSn z`{*SA$#r__K=Qg?GV21?ECr6qf!e~8|Ett(KCf@Y2o_?QY;PbwS;p`=(*oAXEJjfv zD&9zA@+*_ITxkl-jyzckECOkh7n*KnOy3-8#>B*!u{pvboS89m^9JiBOpIBRL+v&T zXLAFUZeYq%Vc?EtWU5zS6v&?}Z6C;(J2}^0g)wLHBzs*T`+&VXWA5e$_PmT7c?t{~ zObkF(UmemI(#a=1uW GDGC6oaE?#_ diff --git a/contracts/sysio.swap/README.md b/contracts/sysio.swap/README.md index fa90dd59fa..6e9f02dc62 100644 --- a/contracts/sysio.swap/README.md +++ b/contracts/sysio.swap/README.md @@ -37,9 +37,9 @@ A yield pool also has a *reservoir* (`reservoirs` table): shadow queued to be so 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, so deployment must grant the shadow token's `sysio.code` on the contract's active permission. +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; for each shadow token, the `sysio.code` grant above; 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. +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** diff --git a/contracts/test_contracts/CMakeLists.txt b/contracts/test_contracts/CMakeLists.txt index 00042b2761..cedf3b4139 100644 --- a/contracts/test_contracts/CMakeLists.txt +++ b/contracts/test_contracts/CMakeLists.txt @@ -3,4 +3,3 @@ add_subdirectory(sendinline) add_subdirectory(reenter_deposit) add_subdirectory(block_transfer) add_subdirectory(badtoken) -add_subdirectory(shadowtoken) diff --git a/contracts/test_contracts/shadowtoken/CMakeLists.txt b/contracts/test_contracts/shadowtoken/CMakeLists.txt deleted file mode 100644 index a92e75a737..0000000000 --- a/contracts/test_contracts/shadowtoken/CMakeLists.txt +++ /dev/null @@ -1,13 +0,0 @@ -# shadowtoken.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 shadowtoken.cpp changes, rebuild -# and re-commit the wasm/abi. -bootstrap_contract(shadowtoken) -if(BUILD_SYSTEM_CONTRACTS) - add_contract(shadowtoken shadowtoken shadowtoken.cpp) - target_include_directories(shadowtoken - PUBLIC $) -endif() diff --git a/contracts/test_contracts/shadowtoken/shadowtoken.abi b/contracts/test_contracts/shadowtoken/shadowtoken.abi deleted file mode 100644 index cc1036d440..0000000000 --- a/contracts/test_contracts/shadowtoken/shadowtoken.abi +++ /dev/null @@ -1,229 +0,0 @@ -{ - "____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": "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": "create", - "base": "", - "fields": [ - { - "name": "issuer", - "type": "name" - }, - { - "name": "maximum_supply", - "type": "asset" - }, - { - "name": "wire_contract", - "type": "name" - }, - { - "name": "wire_symbol", - "type": "symbol" - } - ] - }, - { - "name": "currency_stats", - "base": "", - "fields": [ - { - "name": "supply", - "type": "asset" - }, - { - "name": "max_supply", - "type": "asset" - }, - { - "name": "issuer", - "type": "name" - }, - { - "name": "wire_contract", - "type": "name" - }, - { - "name": "wire_symbol", - "type": "symbol" - } - ] - }, - { - "name": "issue", - "base": "", - "fields": [ - { - "name": "to", - "type": "name" - }, - { - "name": "quantity", - "type": "asset" - }, - { - "name": "memo", - "type": "string" - } - ] - }, - { - "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": "addyield", - "type": "addyield", - "ricardian_contract": "" - }, - { - "name": "claim", - "type": "claim", - "ricardian_contract": "" - }, - { - "name": "create", - "type": "create", - "ricardian_contract": "" - }, - { - "name": "issue", - "type": "issue", - "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": "stat", - "type": "currency_stats", - "index_type": "i64", - "key_names": ["symbol_code"], - "key_types": ["uint64"], - "table_id": 4264 - }, - { - "name": "yieldidx", - "type": "yield_index", - "index_type": "i64", - "key_names": ["symbol_code"], - "key_types": ["uint64"], - "table_id": 3287 - } - ], - "ricardian_clauses": [], - "variants": [], - "action_results": [] -} \ No newline at end of file diff --git a/contracts/test_contracts/shadowtoken/shadowtoken.cpp b/contracts/test_contracts/shadowtoken/shadowtoken.cpp deleted file mode 100644 index 6ebb70dbf8..0000000000 --- a/contracts/test_contracts/shadowtoken/shadowtoken.cpp +++ /dev/null @@ -1,112 +0,0 @@ -#include "shadowtoken.hpp" - -namespace { - using u128 = sysio::opp::shadow::u128; -} - -void shadowtoken::create(name issuer, asset maximum_supply, name wire_contract, symbol wire_symbol) { - require_auth( get_self() ); - check( maximum_supply.is_valid() && maximum_supply.amount > 0, "invalid supply" ); - stats statstable( get_self() ); - const opp::shadow::symbol_key key{ maximum_supply.symbol.code().raw() }; - statstable.emplace( get_self(), key, currency_stats{ - .supply = asset{ 0, maximum_supply.symbol }, - .max_supply = maximum_supply, - .issuer = issuer, - .wire_contract = wire_contract, - .wire_symbol = wire_symbol, - }, "symbol already exists" ); -} - -void shadowtoken::issue(name to, asset quantity, string memo) { - stats statstable( get_self() ); - const opp::shadow::symbol_key key{ quantity.symbol.code().raw() }; - const auto st = statstable.get( key, "symbol does not exist" ); - require_auth( st.issuer ); - check( quantity.is_valid() && quantity.amount > 0, "invalid quantity" ); - check( quantity.symbol == st.supply.symbol, "symbol precision mismatch" ); - check( quantity.amount <= st.max_supply.amount - st.supply.amount, "quantity exceeds available supply" ); - statstable.modify( name{}, key, [&]( auto& s ) { s.supply += quantity; } ); - settle_and_adjust( to, quantity ); -} - -void shadowtoken::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" ); - check( quantity.is_valid() && quantity.amount > 0, "invalid quantity" ); - require_recipient( from ); - require_recipient( to ); - settle_and_adjust( from, -quantity ); - settle_and_adjust( to, quantity ); -} - -void shadowtoken::addyield(name from, asset quantity, symbol_code target) { - require_auth( from ); - check( quantity.amount > 0, "yield must be positive" ); - stats statstable( get_self() ); - const opp::shadow::symbol_key key{ target.raw() }; - const auto st = statstable.get( key, "shadow symbol does not exist" ); - check( quantity.symbol == st.wire_symbol, "yield must be in the wire symbol" ); - check( st.supply.amount > 0, "no holders to distribute to" ); - - yieldidxs indexes( get_self() ); - opp::shadow::yield_index idx = indexes.try_get( key ).value_or( opp::shadow::yield_index{} ); - const u128 total = static_cast(quantity.amount) * 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 += quantity.amount; - indexes.upsert( get_self(), key, idx ); - - action( permission_level{ from, "active"_n }, st.wire_contract, "transfer"_n, - std::make_tuple( from, get_self(), quantity, string("yield") ) ).send(); -} - -void shadowtoken::claim(name holder, symbol_code sym) { - require_auth( holder ); - accounts holdings( get_self(), holder.value ); - const opp::shadow::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( name{}, key, [&]( auto& a ) { - a.index_checkpoint = index; - a.owed_wire = 0; - } ); - if (owed == 0) return; - - stats statstable( get_self() ); - const auto st = statstable.get( key, "shadow symbol does not exist" ); - yieldidxs indexes( get_self() ); - indexes.modify( name{}, key, [&]( auto& y ) { - check( y.pot >= owed, "pot underfunded" ); - y.pot -= owed; - } ); - action( permission_level{ get_self(), "active"_n }, st.wire_contract, "transfer"_n, - std::make_tuple( get_self(), holder, asset{ int64_t(owed), st.wire_symbol }, string("") ) ).send(); -} - -u128 shadowtoken::current_index(symbol_code sym) const { - yieldidxs indexes( get_self() ); - const auto idx = indexes.try_get( opp::shadow::symbol_key{ sym.raw() } ); - return idx ? idx->index : 0; -} - -void shadowtoken::settle_and_adjust(name owner, const asset& delta) { - accounts holdings( get_self(), owner.value ); - const opp::shadow::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( get_self(), key, opp::shadow::account{ delta, index, 0 } ); - return; - } - opp::shadow::account updated = *row; - updated.owed_wire = opp::shadow::owed( updated, index ); // settle before mutate - updated.index_checkpoint = index; - updated.balance += delta; - check( updated.balance.amount >= 0, "overdrawn balance" ); - holdings.modify( name{}, key, [&]( auto& a ) { a = updated; } ); -} diff --git a/contracts/test_contracts/shadowtoken/shadowtoken.hpp b/contracts/test_contracts/shadowtoken/shadowtoken.hpp deleted file mode 100644 index 2c8ac23c3c..0000000000 --- a/contracts/test_contracts/shadowtoken/shadowtoken.hpp +++ /dev/null @@ -1,63 +0,0 @@ -#pragma once -/** - * @file shadowtoken.hpp - * @brief Test stand-in for the shadow token: sysio.token's shape plus the yield - * distribution of sysio.opp.common/shadow_yield.hpp, settled in every - * balance move. - * - * What sysio.swap depends on is the read layout in that shared header and the - * two typed calls below; this contract implements the minimum that exercises - * them end to end, including the rules the design plan marks as mandatory: - * settle before mutate, a new holder stamped at the current index, and the - * truncation remainder carried rather than dropped. - */ - -#include -#include -#include -#include -#include -#include - -using namespace sysio; -using std::string; - -class [[sysio::contract("shadowtoken")]] shadowtoken : public contract { -public: - using contract::contract; - - /// Register a shadow symbol and the WIRE token its yield is paid in. - [[sysio::action]] void create(name issuer, asset maximum_supply, name wire_contract, symbol wire_symbol); - [[sysio::action]] void issue(name to, asset quantity, string memo); - [[sysio::action]] void transfer(name from, name to, asset quantity, string memo); - /// Move `quantity` WIRE from `from` (its own authority, by inline transfer) - /// into `target`'s pot, advancing the index by quantity / supply and - /// carrying the remainder. - [[sysio::action]] void addyield(name from, asset quantity, symbol_code target); - /// Settle `holder`'s row for `sym` and pay what it is owed from the - /// contract's own WIRE, then zero it. Holder's authority. - [[sysio::action]] void claim(name holder, symbol_code sym); - - static_assert( "addyield"_n == opp::shadow::ADDYIELD_ACTION && "claim"_n == opp::shadow::CLAIM_ACTION, - "the action names above are the ones holders call through shadow_yield.hpp" ); - -private: - struct [[sysio::table("stat")]] currency_stats { - asset supply; - asset max_supply; - name issuer; - name wire_contract; - symbol wire_symbol; - SYSLIB_SERIALIZE(currency_stats, (supply)(max_supply)(issuer)(wire_contract)(wire_symbol)) - }; - - using stats = kv::table<"stat"_n, opp::shadow::symbol_key, currency_stats>; - using accounts = kv::scoped_table<"accounts"_n, opp::shadow::symbol_key, opp::shadow::account>; - using yieldidxs = kv::table<"yieldidx"_n, opp::shadow::symbol_key, opp::shadow::yield_index>; - - opp::shadow::u128 current_index(symbol_code sym) const; - /// Settle `owner`'s row at the current index, then apply `delta` to its - /// balance. A holder without a row is stamped at the current index, so no - /// history is credited to it. - void settle_and_adjust(name owner, const asset& delta); -}; diff --git a/contracts/test_contracts/shadowtoken/shadowtoken.wasm b/contracts/test_contracts/shadowtoken/shadowtoken.wasm deleted file mode 100755 index 21f961f818b638687cd834d983fa727a0884dfa4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14181 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 #include +#include #include namespace sysio_system::test_support { @@ -97,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 84030bfd9b..0915cab7c6 100644 --- a/contracts/tests/contracts.hpp.in +++ b/contracts/tests/contracts.hpp.in @@ -48,8 +48,6 @@ struct contracts { 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 shadowtoken_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/contracts/test_contracts/shadowtoken/shadowtoken.wasm"); } - static std::vector shadowtoken_abi() { return read_abi("${CMAKE_BINARY_DIR}/contracts/test_contracts/shadowtoken/shadowtoken.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/liq_test_support.hpp b/contracts/tests/liq_test_support.hpp index a75966e551..d21fa28da8 100644 --- a/contracts/tests/liq_test_support.hpp +++ b/contracts/tests/liq_test_support.hpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include @@ -42,4 +44,11 @@ inline std::vector parked_row_bytes(const sysio::chain::controller& contro return std::vector(itr->value.data(), itr->value.data() + itr->value.size()); } +/// The chain-native address `sysio.authex::recordlink` carries beside a linked key. On SVM +/// that is the ED key's own 32 bytes -- the form `sysio.liq` parks against and sweeps by. +inline std::vector native_address_of(const fc::crypto::public_key& pub_key) { + const auto raw = pub_key.get().serialize(); + return std::vector(raw.begin(), raw.end()); +} + } // namespace sysio_liq::test_support diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index f240c993d8..d5076a43db 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -58,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 { @@ -66,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 { @@ -503,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; @@ -1459,6 +1459,95 @@ 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, tokens_abi, liq_abi; @@ -6656,9 +6745,9 @@ BOOST_FIXTURE_TEST_CASE(syndicate_liq_credits_a_linked_user_and_parks_an_unlinke const auto eth = fc::slug_name{"ETH"}.value; const auto liqeth = fc::slug_name{"LIQETH"}.value; - // An EVM key nobody links. - const auto stranger = em_pubkey_bytes( - fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em).get_public_key()); + // 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(), { @@ -6687,6 +6776,15 @@ BOOST_FIXTURE_TEST_CASE(syndicate_liq_credits_a_linked_user_and_parks_an_unlinke 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() } // LIQ_YIELD lands in sysio.liq's pending balance (no per-user routing) and stamps the report's diff --git a/contracts/tests/sysio.liq_tests.cpp b/contracts/tests/sysio.liq_tests.cpp index 8d0034d0c6..d7f7a082d6 100644 --- a/contracts/tests/sysio.liq_tests.cpp +++ b/contracts/tests/sysio.liq_tests.cpp @@ -32,6 +32,7 @@ using namespace sysio::opp::types; using namespace fc; using mvo = fc::mutable_variant_object; +using sysio_system::test_support::codename_mvo; namespace { @@ -139,7 +140,6 @@ class sysio_liq_tester : public tester { // --- slugs and keys --- - static mvo slug(std::string_view s) { return mvo()("value", fc::slug_name{ s }.value); } static uint64_t slug_value(std::string_view s) { return fc::slug_name{ s }.value; } static fc::crypto::public_key ed_key() { @@ -155,7 +155,7 @@ class sysio_liq_tester : public tester { // 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", slug(code))("external_chain_id", external_chain_id) + ("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())); } @@ -163,11 +163,11 @@ class sysio_liq_tester : public tester { 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", slug(code))("symbol_name", std::string(code))("description", std::string{}) + ("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", slug(chain_code))("token_code", slug(code))("contract_addr", address)("is_native", false)); + ("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) { @@ -185,29 +185,32 @@ class sysio_liq_tester : public tester { } /// 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)); + ("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", slug(chain_code))("token_code", slug(token_code))); + 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", slug(chain_code))("sequence", sequence) - ("account", account)("token_code", slug(token_code))("amount", amount)); + 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", slug(chain_code))("sequence", sequence) - ("chain_kind", kind)("pubkey", pubkey)("token_code", slug(token_code))("amount", amount)); + 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", slug(chain_code))("sequence", sequence) - ("epoch", epoch)("token_code", slug(token_code))("amount", amount)); + 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())); @@ -251,7 +254,7 @@ class sysio_liq_tester : public tester { 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", slug(chain_code))("token_code", slug(token_code)) + 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)); @@ -261,7 +264,7 @@ class sysio_liq_tester : public tester { } 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", slug(chain_code))("token_code", slug(token_code)) + 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) { diff --git a/contracts/tests/sysio.swap_tests.cpp b/contracts/tests/sysio.swap_tests.cpp index afb051e454..e17cb693fd 100644 --- a/contracts/tests/sysio.swap_tests.cpp +++ b/contracts/tests/sysio.swap_tests.cpp @@ -11,6 +11,9 @@ #include #include #include +#include +#include +#include "contract_test_support.hpp" #include "shadow_yield_reference.hpp" #include "twap_wide.hpp" #include @@ -57,25 +60,43 @@ 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 (the tests' stand-in for WIRE): the second leg of every -// pair. The shadow token and its yield pool trade against it. -static const extended_symbol WIRE{ EOS4, "sysio.token"_n }; +// 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 SHEO4 = symbol::from_string("4,SHEO"); +static symbol SHEO6 = symbol::from_string("6,SHEO"); static symbol_code SHD = SHD4.to_symbol_code(); -static symbol_code SHEO = SHEO4.to_symbol_code(); -static const extended_symbol SHADOW{ SHD4, "shadowtoken"_n }; +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 ) { + 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, "shadowtoken"_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"), @@ -98,25 +119,36 @@ class sysio_swap_tester : public tester { set_code( "badtoken"_n, contracts::util::badtoken_wasm() ); set_abi( "badtoken"_n, contracts::util::badtoken_abi().data() ); - // The shadow token stand-in bills its rows to itself; no privilege needed. - set_code( "shadowtoken"_n, contracts::util::shadowtoken_wasm() ); - set_abi( "shadowtoken"_n, contracts::util::shadowtoken_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 EOS - // on sysio.token stands in for WIRE as the system token. - if (configure) BOOST_REQUIRE_EQUAL( success(), setconfig( config::system_account_name ) ); + // 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( "shadowtoken"_n ); + 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 @@ -132,7 +164,7 @@ class sysio_swap_tester : public tester { produce_block(); return success(); } - action_result setconfig( name fee_authority, extended_symbol system_token = WIRE, name signer = "sysio.swap"_n ) { + 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 ) @@ -320,7 +352,7 @@ class sysio_swap_tester : public tester { mvo()( "from", from )( "pair_token", pair )( "quantity", quantity ), abi_serializer::create_yield_function(abi_serializer_max_time) ); action deliver; - deliver.account = "shadowtoken"_n; + deliver.account = "sysio.liq"_n; deliver.name = "transfer"_n; deliver.authorization = { {from, config::active_name} }; deliver.data = shadow_abi_ser.variant_to_binary( "transfer", @@ -334,31 +366,73 @@ class sysio_swap_tester : public tester { produce_block(); } - // --- The shadow token stand-in (contracts/test_contracts/shadowtoken) --- + // --- 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 = "shadowtoken"_n; + 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() ); } - action_result shadow_create( name issuer, asset maximum_supply, extended_symbol wire ) { - return push_shadow_action( "shadowtoken"_n, "create"_n, mvo() - ( "issuer", issuer )( "maximum_supply", maximum_supply ) - ( "wire_contract", wire.contract )( "wire_symbol", wire.sym ) ); + // 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(); } - action_result shadow_issue( name issuer, name to, asset quantity ) { - return push_shadow_action( issuer, "issue"_n, mvo()( "to", to )( "quantity", quantity )( "memo", "" ) ); + // 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, so `from`'s active - // must carry shadowtoken@sysio.code (see grant_shadow_code). + // 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 ) ); @@ -366,19 +440,6 @@ class sysio_swap_tester : public tester { action_result shadow_claim( name holder, symbol_code sym ) { return push_shadow_action( holder, "claim"_n, mvo()( "holder", holder )( "sym", sym ) ); } - // Let the shadow token act for `account`: its active keeps its key and gains - // shadowtoken@sysio.code, the delegation addyield's inline transfer needs. A - // contract account keeps its own sysio.code seat too (`keep_own_code`), which - // its own inline actions need; the seats are listed in name order. - void grant_shadow_code( name account, bool keep_own_code = false ) { - vector seats{ { { "shadowtoken"_n, config::sysio_code_name }, 1 } }; - if (keep_own_code) seats.push_back( { { account, config::sysio_code_name }, 1 } ); - std::sort( seats.begin(), seats.end() ); - set_authority( account, config::active_name, - authority( 1, { key_weight{ get_public_key( account, "active" ), 1 } }, seats ), - config::owner_name ); - produce_block(); - } // 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() @@ -412,13 +473,13 @@ class sysio_swap_tester : public tester { } // `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( "shadowtoken"_n, "accounts"_n, { holder.to_uint64_t(), sym.value } ); + 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( "shadowtoken"_n, "yieldidx"_n, { sym.value } ); + 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`. @@ -553,26 +614,26 @@ class sysio_swap_tester : public tester { // the two pools. Declared here, defined after the file-scope symbols. vector total(); void create_tokens_and_issue() { - BOOST_REQUIRE_EQUAL( success(), create( "sysio.token"_n, "alice"_n, asset::from_string("461168601842738.7903 EOS") ) ); + 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( "sysio.token"_n, "alice"_n, "alice"_n, asset::from_string("461168601842738.7903 EOS"), "") ); + 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, extended_symbol{symbol::from_string("4,EOS"), "sysio.token"_n}) ); + 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, extended_symbol{symbol::from_string("4,EOS"), "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. EOS is the system token and is - // always accepted; VOICE and TUSD have no pair yet, so their deposits carry - // the contract's authority. + // 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( "sysio.token"_n, "alice"_n, "sysio.swap"_n, asset::from_string("461000000000000.0000 EOS"), "") ); + 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 @@ -612,16 +673,11 @@ class sysio_swap_tester : public tester { create( "carol"_n, "carol"_n, asset::from_string("1.0000 VOICE") ); issue( "carol"_n, "carol"_n, "carol"_n, asset::from_string("1.0000 VOICE"), ""); } - // The yield-pool state the accrual tests start from: SHD (the shadow, paying - // its yield in EOS) issued to alice, bob holding EOS to donate and able to - // route it through the shadow token, and SHEO = SHD/EOS created by alice as - // a yield pool on SHD. Defined after the file-scope symbols it uses. - void setup_yield_pool(); abi_serializer abi_ser; abi_serializer shadow_abi_ser; }; -static extended_asset shd( int64_t units ) { return extended_asset{ asset( units, SHD4 ), "shadowtoken"_n }; } +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; @@ -766,30 +822,40 @@ int64_t sysio_swap_tester::balance( name user, symbol sym ) { return deposit_of( user, extended_symbol{ sym, ext.contract } ); } -void sysio_swap_tester::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_create( "alice"_n, asset( 100 * ShadowIssuance, SHD4 ), WIRE ) ); - BOOST_REQUIRE_EQUAL( success(), shadow_issue( "alice"_n, "alice"_n, asset( ShadowIssuance, SHD4 ) ) ); - BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "bob"_n, asset( BobDonationBudget, EOS4 ), "" ) ); - grant_shadow_code( "bob"_n ); - BOOST_REQUIRE_EQUAL( success(), openext( "alice"_n, "alice"_n, SHADOW ) ); - BOOST_REQUIRE_EQUAL( success(), seed_transfer( "shadowtoken"_n, "alice"_n, asset( YieldPoolShadow, SHD4 ), "" ) ); - BOOST_REQUIRE_EQUAL( success(), inittoken( "alice"_n, SHEO4, shd( YieldPoolShadow ), - extend( asset( YieldPoolWire, EOS4 ) ), 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 ); -} +// 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); @@ -1404,7 +1470,7 @@ BOOST_FIXTURE_TEST_CASE( nothing_works_before_setconfig, sysio_swap_unconfigured 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, WIRE, "alice"_n ) ); + 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 } ) ); @@ -1489,7 +1555,7 @@ BOOST_FIXTURE_TEST_CASE( yield_leg_rules, sysio_swap_tester ) try { 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, WIRE ) ); + 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 } ) ); @@ -1546,20 +1612,20 @@ BOOST_FIXTURE_TEST_CASE( yield_leg_rules, sysio_swap_tester ) try { // 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_tester ) try { +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, EOS.value ); }; + 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( "shadowtoken"_n ).empty() ); + BOOST_REQUIRE( pending_payout( "sysio.liq"_n ).empty() ); - // bob donates 100 EOS to SHD holders: the index advances by the spec, the + // 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, EOS4 ), SHD ) ); + 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 ) ); @@ -1568,8 +1634,8 @@ BOOST_FIXTURE_TEST_CASE( yield_accrues_into_the_pool_without_minting, sysio_swap 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 - // EOS side, mints nothing, and the token delivers the same amount in the - // same transaction: the receipt is retired and the contract's EOS grew by it. + // 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(); @@ -1579,7 +1645,7 @@ BOOST_FIXTURE_TEST_CASE( yield_accrues_into_the_pool_without_minting, sysio_swap 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( "shadowtoken"_n ).empty() ); + 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 ); @@ -1593,8 +1659,8 @@ BOOST_FIXTURE_TEST_CASE( yield_accrues_into_the_pool_without_minting, sysio_swap // 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, EOS4 ), SHD ) ); - BOOST_REQUIRE_EQUAL( success(), shadow_addyield( "bob"_n, asset( third_donation, EOS4 ), SHD ) ); + 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; @@ -1612,9 +1678,9 @@ BOOST_FIXTURE_TEST_CASE( yield_accrues_into_the_pool_without_minting, sysio_swap // 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, EOS.value ); + 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, EOS.value ) ); + 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 @@ -1623,25 +1689,23 @@ BOOST_FIXTURE_TEST_CASE( yield_accrues_into_the_pool_without_minting, sysio_swap // 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_tester ) try { +BOOST_FIXTURE_TEST_CASE( yield_index_grows_past_64_bits, sysio_swap_yield_tester ) try { create_tokens_and_issue(); - BOOST_REQUIRE_EQUAL( success(), shadow_create( "alice"_n, asset( ShadowIssuance, SHD4 ), WIRE ) ); - BOOST_REQUIRE_EQUAL( success(), shadow_issue( "alice"_n, "alice"_n, asset( 1, SHD4 ) ) ); - BOOST_REQUIRE_EQUAL( success(), transfer( "sysio.token"_n, "alice"_n, "bob"_n, asset( BobDonationBudget, EOS4 ), "" ) ); - grant_shadow_code( "bob"_n ); + 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, EOS4 ), SHD ) ); + 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, EOS.value ); + 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, EOS.value ) ); + 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 ); @@ -1649,16 +1713,16 @@ BOOST_FIXTURE_TEST_CASE( yield_index_grows_past_64_bits, sysio_swap_tester ) try // 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, EOS4 ), SHD ) ); + 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, EOS.value ); + 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, EOS.value ) ); + 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_tester ) try { +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 @@ -1677,7 +1741,7 @@ BOOST_FIXTURE_TEST_CASE( yield_is_credited_before_shares_are_priced, sysio_swap_ // 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, EOS4 ), SHD ) ); + 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 ); @@ -1685,40 +1749,40 @@ BOOST_FIXTURE_TEST_CASE( yield_is_credited_before_shares_are_priced, sysio_swap_ 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 = deposit_of( "alice"_n, WIRE ); - BOOST_REQUIRE_EQUAL( success(), addliquidity( "alice"_n, asset( shares, SHEO4 ), - asset( pay_shadow, SHD4 ), asset( pay_wire, EOS4 ) ) ); + 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, deposit_of( "alice"_n, WIRE ) ); - BOOST_REQUIRE( pending_payout( "shadowtoken"_n ).empty() ); + 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, EOS4 ), SHD ) ); + 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, SHEO4 ), asset( pay_shadow, SHD4 ), asset( stale_wire, EOS4 ) ) ); + 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( "shadowtoken"_n ).empty() ); + 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 = deposit_of( "alice"_n, WIRE ); - BOOST_REQUIRE_EQUAL( success(), remliquidity( "alice"_n, asset( shares, SHEO4 ), - asset( get_shadow, SHD4 ), asset( get_wire, EOS4 ) ) ); + 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, deposit_of( "alice"_n, WIRE ) ); + 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. @@ -1730,14 +1794,14 @@ BOOST_FIXTURE_TEST_CASE( yield_is_credited_before_shares_are_priced, sysio_swap_ BOOST_REQUIRE( after == system_balance( SHEO.value ) ); } FC_LOG_AND_RETHROW() -BOOST_FIXTURE_TEST_CASE( yield_accrues_before_a_swap_is_priced, sysio_swap_tester ) try { +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, EOS4 ), SHD ) ); + 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, @@ -1751,53 +1815,53 @@ BOOST_FIXTURE_TEST_CASE( yield_accrues_before_a_swap_is_priced, sysio_swap_teste 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, EOS4)), asset(stale_out, SHD4) ) ); + exchange( "alice"_n, SHEO, extend(asset(pay, WIRE9)), asset(stale_out, SHD4) ) ); BOOST_REQUIRE_EQUAL( success(), - exchange( "alice"_n, SHEO, extend(asset(pay, EOS4)), asset(accrued_out, SHD4) ) ); + 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( "shadowtoken"_n ).empty() ); + 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, EOS4 ), SHD ) ); - const int64_t wire_start = deposit_of( "alice"_n, WIRE ); + 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, EOS4)), asset(0, SHD4) ) ); + 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, EOS4) ) ); + 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( deposit_of( "alice"_n, WIRE ), wire_start ); + BOOST_REQUIRE_LT( balance( "alice"_n, WIRE9 ), wire_start ); } FC_LOG_AND_RETHROW() -BOOST_FIXTURE_TEST_CASE( yield_payout_route_is_exact, sysio_swap_tester ) try { +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_issue( "alice"_n, "shadowtoken"_n, asset( 1'0000, SHD4 ) ) ); + 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( "shadowtoken"_n, "sysio.swap"_n, asset( 1'0000, SHD4 ), "" ) ); - BOOST_REQUIRE_EQUAL( success(), openext( "shadowtoken"_n, "alice"_n, SHADOW ) ); - BOOST_REQUIRE_EQUAL( success(), shadow_transfer( "shadowtoken"_n, "sysio.swap"_n, asset( 1'0000, SHD4 ), "" ) ); - BOOST_REQUIRE_EQUAL( 1'0000, deposit_of( "shadowtoken"_n, SHADOW ) ); + 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, EOS4 ), SHD ) ); + 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( "shadowtoken"_n ).empty() ); + BOOST_REQUIRE( pending_payout( "sysio.liq"_n ).empty() ); } FC_LOG_AND_RETHROW() // --------------------------------------------------------------------------- @@ -1805,17 +1869,17 @@ BOOST_FIXTURE_TEST_CASE( yield_payout_route_is_exact, sysio_swap_tester ) try { // reservoir, not in the funder's deposit. // --------------------------------------------------------------------------- -BOOST_FIXTURE_TEST_CASE( yield_funding_fills_the_reservoir, sysio_swap_tester ) try { +BOOST_FIXTURE_TEST_CASE( yield_funding_fills_the_reservoir, sysio_swap_yield_tester ) try { setup_yield_pool(); - 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(), 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, EOS4 ) ) ); - BOOST_REQUIRE_EQUAL( wasm_assert_msg("pair token does not exist"), fundyield( "alice"_n, EOS, asset( 1'0000, SHD4 ) ) ); + 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, EOS4 ) ) ); + 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 ) ) ) ); @@ -1828,13 +1892,13 @@ BOOST_FIXTURE_TEST_CASE( yield_funding_fills_the_reservoir, sysio_swap_tester ) 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( SHEO4.name(), pending["pair"].as_string() ); + 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( "shadowtoken", pending["quantity"]["contract"].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, EOS4 ), "" ) ); + 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 ) ); @@ -1871,7 +1935,7 @@ BOOST_FIXTURE_TEST_CASE( yield_funding_fills_the_reservoir, sysio_swap_tester ) 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, EOS4 ), "" ) ); + 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 ) ); @@ -1879,14 +1943,14 @@ BOOST_FIXTURE_TEST_CASE( yield_funding_fills_the_reservoir, sysio_swap_tester ) 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, EOS4 ), "" ) ); + 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, EOS4 ), SHD ) ); + 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 ) ); @@ -1899,13 +1963,10 @@ BOOST_FIXTURE_TEST_CASE( yield_funding_fills_the_reservoir, sysio_swap_tester ) // 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_tester ) try { +BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_swap_yield_tester ) try { setup_yield_pool(); - // The token moves the proceeds out of the contract under the contract's own - // authority: the deployment grants it the shadow token's sysio.code seat. - grant_shadow_code( "sysio.swap"_n, true ); - 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(), 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 @@ -1918,7 +1979,7 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ // 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( EOS ) ); + 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 ) ); @@ -1946,7 +2007,7 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ // 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, EOS.value ); + 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, @@ -1959,7 +2020,7 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ 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, EOS.value ) ); + 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, @@ -2030,7 +2091,7 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ // 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, EOS4 ), SHD ) ); + 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 ); @@ -2043,12 +2104,11 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_sells_the_reservoir_over_the_horizon, sysio_ 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( "shadowtoken"_n ).empty() ); + 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_tester ) try { +BOOST_FIXTURE_TEST_CASE( yield_tick_cap_ignores_an_inflated_shadow_side, sysio_swap_yield_tester ) try { setup_yield_pool(); - grant_shadow_code( "sysio.swap"_n, true ); const uint32_t horizon_sec = 3600; const uint32_t cap_bps = 1; const int64_t clip_floor = 1000; @@ -2067,7 +2127,7 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_cap_ignores_an_inflated_shadow_side, sysio_s // 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, EOS4) ) ); + 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 @@ -2086,9 +2146,8 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_cap_ignores_an_inflated_shadow_side, sysio_s 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_tester ) try { +BOOST_FIXTURE_TEST_CASE( yield_tick_never_sells_below_the_clip_floor, sysio_swap_yield_tester ) try { setup_yield_pool(); - grant_shadow_code( "sysio.swap"_n, true ); 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 ); @@ -2177,9 +2236,8 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_never_sells_below_the_clip_floor, sysio_swap 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_tester ) try { +BOOST_FIXTURE_TEST_CASE( yield_tick_never_sells_below_a_fee_bearing_output, sysio_swap_yield_tester ) try { setup_yield_pool(); - grant_shadow_code( "sysio.swap"_n, true ); 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; @@ -2235,7 +2293,7 @@ BOOST_FIXTURE_TEST_CASE( yield_tick_never_sells_below_a_fee_bearing_output, sysi 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( "shadowtoken"_n ).empty() ); + 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 @@ -2777,7 +2835,7 @@ BOOST_FIXTURE_TEST_CASE( guard_semantics_on_the_memo_path, sysio_swap_tester ) t // 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, WIRE) ); + 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"), "") ); 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 From f0430969358c52fc4a64685ff2f859235b362dbd Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Wed, 23 Sep 2026 08:17:12 -0500 Subject: [PATCH 34/37] liq: request the kicker only on the swap's intake addyield is permissionless, and the kicker rode on every intake: a holder of most of a shadow's supply could donate, claim it back with the treasury's 2% on top, and repeat. Only the swap's intake (tickyield's proceeds) is yield, so only it requests fundclaim now; a donation distributes itself alone. Review P1 on Change-Id: Ia0de4e049188ece04fb1c6ed96bbfb9a48f18a4c #634. --- contracts/sysio.liq/README.md | 13 ++-- .../sysio.liq/include/sysio.liq/sysio.liq.hpp | 5 +- contracts/sysio.liq/src/sysio.liq.cpp | 10 ++- contracts/sysio.liq/sysio.liq.wasm | Bin 62211 -> 62226 bytes contracts/sysio.system/EMISSIONS.md | 5 +- contracts/tests/sysio.liq_tests.cpp | 57 +++++++++++------- 6 files changed, 55 insertions(+), 35 deletions(-) diff --git a/contracts/sysio.liq/README.md b/contracts/sysio.liq/README.md index b3388ad2d0..ef5b784861 100644 --- a/contracts/sysio.liq/README.md +++ b/contracts/sysio.liq/README.md @@ -28,11 +28,14 @@ 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 every intake `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 short T5 reduces the kicker only. `setkicker` -(auth `sysio`, the account council proposals execute as) changes the next intake. +**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 diff --git a/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp index 89d7294821..72169abecc 100644 --- a/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp +++ b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp @@ -138,8 +138,9 @@ namespace sysio { /// 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, the WIRE is pulled from - /// `from` by inline transfer, and the kicker is requested from T5. + /// 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 diff --git a/contracts/sysio.liq/src/sysio.liq.cpp b/contracts/sysio.liq/src/sysio.liq.cpp index 201b44b183..be1c0b493f 100644 --- a/contracts/sysio.liq/src/sysio.liq.cpp +++ b/contracts/sysio.liq/src/sysio.liq.cpp @@ -306,9 +306,13 @@ void liq::addyield(name from, asset quantity, symbol_code target) { 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. 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. + // 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( diff --git a/contracts/sysio.liq/sysio.liq.wasm b/contracts/sysio.liq/sysio.liq.wasm index 507b45d8a693b83873e60c495e08f2b9b1a0e088..ad504f48ac2abc96e6f62a53317b0dbe3b973e5b 100755 GIT binary patch delta 49 zcmV-10M7q|<^z)E1F$&-0iUxv1>#fzs()); - // The intake's trace carries a fundclaim for this contract at 2%, then the - // fold. 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. - auto trace = base_tester::push_action(LIQ_ACCOUNT, "addyield"_n, "carol"_n, mvo() - ("from", "carol"_n)("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; + // 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; } - if (at.act.account == LIQ_ACCOUNT && at.act.name == "addkicker"_n) folded = true; - } - BOOST_REQUIRE(requested); - BOOST_REQUIRE(folded); + 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()); - // With the kicker off, no request is made at all. + // 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()); - trace = base_tester::push_action(LIQ_ACCOUNT, "addyield"_n, "carol"_n, mvo() - ("from", "carol"_n)("quantity", asset(100 * UNIT, WIRE_SYM))("target", LIQSOL_SYM.to_symbol_code())); - produce_block(); - for (const auto& at : trace->action_traces) - BOOST_REQUIRE(!(at.act.account == SYSIO_ACCOUNT && at.act.name == "fundclaim"_n)); + 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 { From 16457e738df4768774deaea17921f79039d5fefa Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Wed, 23 Sep 2026 09:24:59 -0500 Subject: [PATCH 35/37] liq: reserve pending yield against the asset range The range check on every supply-growing path read supply alone, so reports that each fit could together exceed what queueyield can mint, and its assertion then wedged the crank for that symbol for good. One headroom, the range net of supply and of what is pending, now bounds every mint and every intake; mintyield drops a report past it before consuming the sequence instead of clipping it after. Review P2 on #634. Change-Id: I5bdcabb7b57c2920004391bfa75e1d720225612d --- contracts/sysio.liq/README.md | 7 ++- .../sysio.liq/include/sysio.liq/sysio.liq.hpp | 8 +++- contracts/sysio.liq/src/sysio.liq.cpp | 21 ++++++--- contracts/sysio.liq/sysio.liq.wasm | Bin 62226 -> 62321 bytes contracts/tests/sysio.liq_tests.cpp | 43 ++++++++++++++++++ 5 files changed, 69 insertions(+), 10 deletions(-) diff --git a/contracts/sysio.liq/README.md b/contracts/sysio.liq/README.md index ef5b784861..ab400baab3 100644 --- a/contracts/sysio.liq/README.md +++ b/contracts/sysio.liq/README.md @@ -21,7 +21,10 @@ unknown token, a token of another chain or an out-of-range amount is dropped wit a diagnostic. **Yield (LIQ_YIELD, inbound).** `mintyield` holds the reported yield in the -symbol's pending balance, outside supply. The permissionless `queueyield` mints it +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 @@ -58,7 +61,7 @@ import. | `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 | +| `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` | diff --git a/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp index 72169abecc..60f2b18e03 100644 --- a/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp +++ b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp @@ -236,7 +236,8 @@ namespace sysio { 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. + /// 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)) @@ -282,6 +283,9 @@ namespace sysio { 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. @@ -298,7 +302,7 @@ namespace sysio { /// 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 the asset range. + /// 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. diff --git a/contracts/sysio.liq/src/sysio.liq.cpp b/contracts/sysio.liq/src/sysio.liq.cpp index be1c0b493f..cf7f96cf66 100644 --- a/contracts/sysio.liq/src/sysio.liq.cpp +++ b/contracts/sysio.liq/src/sysio.liq.cpp @@ -143,7 +143,7 @@ std::optional liq::resolve_inbound(const char* path, sysio: drop(path, "amount out of range"); return std::nullopt; } - if (amount > static_cast(asset::max_amount - st->supply.amount)) { + if (amount > headroom(*st)) { drop(path, "supply exceeds the asset range"); return std::nullopt; } @@ -186,11 +186,10 @@ void liq::mintyield(sysio::slug_name chain_code, uint64_t sequence, uint64_t epo 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) { - // Saturate rather than abort: a pending balance this large is not reachable, - // and the never-throw contract holds either way. - const int64_t room = asset::max_amount - p.quantity.amount; - p.quantity.amount += std::min(room, static_cast(amount)); + p.quantity.amount += static_cast(amount); }); } @@ -207,6 +206,8 @@ void liq::queueyield(symbol_code sym) { 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 @@ -533,11 +534,19 @@ void liq::adjust_parked(const parked_key& key, ChainKind chain_kind, const std:: 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 > static_cast(asset::max_amount - st.supply.amount)) return false; + if (quantity > headroom(st)) return false; statstable.modify(ram_payer, key, [&](currency_stats& s) { s.supply.amount += static_cast(quantity); }); return true; } diff --git a/contracts/sysio.liq/sysio.liq.wasm b/contracts/sysio.liq/sysio.liq.wasm index ad504f48ac2abc96e6f62a53317b0dbe3b973e5b..95810708d495958d840092f5786f79acc1c42066 100755 GIT binary patch delta 4842 zcmbVQcU%?M65crrh}_F%4I&*DBzRGph9-)#sNl1pSd$2fv5{+UF-8R}#D7di8r&#GW%!DsXH#!Ob8pe_b$$-_VK62J#}i!{ zJiv1>*(pQ|Msp+RLC0bV3mXnpe{={5CcX|Ku^88RK8(e}R6qQ#vp4mgq=sO6NN-xL z3UP8uk!^y6*e>=0Z6?dOFQlRPtG3%1nhEcy+e5>xAO-8fx=4fdj34rXFDyKS1cG z`llm3o!*wMl}sBG(Hz1MH2l)=_egJAcaLfV?_y$V3mK_Pw}odPCIXs5tVT-eT=)Y;zdbr`Nz zwR3^SD?k>E%t{8lRqKdi%p0%^BV*Ro-2TdVy$+5_yvVX<9d2=0u{X)>Oq-XzVLQp2e<0edR5 zVIo#3JFO?tc(R%q*B@-BXt5?u#_OF%U`!Wh+TFiP9ejmPaN>F5Ub0VLu?A8_rs#V?e05kAf_iiuMT&BpioUW8THwO=AJ=l6@H*sAIG18H%-{~ppJeSit(;nxGcg)F>0@M_~J zw0i+LXfhsnGo1uSzBLn?&L^;WCfmqXl*!8R(}I+Rr{1n2ZY$-9K-_A1ZSF@xIyEJj`Gdf0|LQaclUoZ8zAm;(ol z5nSe)iWoV!rMBXYM}{_nHR`FM;r!4)-${Y_>Y!l;MnITGTq95tMy`QncxPlk7=Tfu zK7<@RGAaa?_Y-Y1 zsU>NVGRb6zK>n`LJa}7DF80$HbgMtD!g(bI=$3u_uqPWWIOOjKQGv;o3Qr__j?^RHR` z)NM0+zC3l8`0k``?BEb8_6Mw=SnQ+`;d1jBppfDR;xx%j1Y^%JL-G zuAW@J2B1)V2g4bar&TMfD23AU`ZYNS6r+?jWxS>9XceB%bAi!#Kd&RzQ2+d;^(UOm z=O;YKkEauYSG6O#rd1I{H?C^yNYz>71v{IxSM*xF#z(8}HY=tqpqr83zoHVx)0^(O zVyxcP9-FVe0xDkIDHrgM z170d_Xa!yV(d7Tj)-mE-~gvXbg~`@H5|&sjGq^$!b(k-?t4$Z(1V9kgP6tu)dS9Ays9)i2V_qH zMTK;}-W|Myy>@$Ind;FnWG(=voOg4eq!oG6n$>o}iUUniE(wJyOe&G7P){m}hCR5e zBn*nx+a=8aHficKDQyi`uv6&(sKeEzN#3TYf0QSKBh`|9dw%**kJcN-Wag| zG~B@N_iq$$l1g-VW8xOIU08OYBivMfJJ5+~#iBLQ3FW*LRhFAe(GIwWD-VUieLQoh zkoxZ(roLKucnXBQ`tCRg^!3en=IN;>GAbt$PZjzeRhm+H@gX9%_F)nC3?xA0b~M6_~^+7@6QB>J+*z#8{Fb%5--<#3#qY z8`U$ZfhSG?{G>iR8N*0O=;=rBJ9?ezOLWqiU>|xFY0fU?Eje3jUHCK0?DRjd@=Oo- z6AfqM=vwN!UBFHq8G*AL}V{d1E)E4h>TGt5DIZboU)o2x&G z_>3w*G5%3=3=k`>KBdce?^lbR`R5kU?M?2?rEg;1r3}o+ezoCn6BpIacx_+3w?C?X zZ50D1)j46{wKLIJzk@b&Ip#NjBID?nDcn7qw8sEQn_0bDQBIa+UhlYHxSTC$U*`i`F{I833b0GvC-mL=sIC>c)Ai<< zkR8m8f6!~Mpjq@iPfuyd{FLTHvxOzavt~!$N?J2p}de0KN+UA+^U@~xrbcH_0XjLLr_&<`K{Ycc5{g7i{?6) zMLHx~U{asl)&cCrKkmfYs5}`;u=Cv&bQ@j0n@XP?(f1kw?8mtKlZfuSZ-h#`aX%Uk zqw9l6+LiR6FC4)w43nACe5!TgGWkzMSxrKcy`TtjAbII@_ITl;9UM@vK5T3EA7gUSasU7T delta 4815 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::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. From 8803bfaaa38e81a31163555bc2660ae8d3dd5d2c Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Wed, 23 Sep 2026 09:38:13 -0500 Subject: [PATCH 36/37] msgch: drop a SYNDICATE_LIQ whose user is not the outpost's family The dispatch checked the payload's chain against the proven outpost and the token, then resolved the user's key through AuthX without comparing its family with the outpost's: a linked key of another family was credited by mintsynd while an unlinked one was refused by park. The family is now part of the provenance check, before the lookup. Review P2 on #634. Change-Id: If76a162d3144850cccabc609a3463b34b2d979cd --- contracts/sysio.msgch/README.md | 3 +- contracts/sysio.msgch/src/sysio.msgch.cpp | 15 +++++++ contracts/sysio.msgch/sysio.msgch.wasm | Bin 168761 -> 169583 bytes contracts/tests/sysio.dispatch_tests.cpp | 49 ++++++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/contracts/sysio.msgch/README.md b/contracts/sysio.msgch/README.md index fe0d72b03a..368ea8e699 100644 --- a/contracts/sysio.msgch/README.md +++ b/contracts/sysio.msgch/README.md @@ -38,6 +38,7 @@ Inbound/outbound OPP message chain management and consensus tracking contract. - 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. + 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 c524f9f90d..96d7a8f854 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -302,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. @@ -598,6 +612,7 @@ void dispatch_syndicate_liq(name self, const std::vector& data, uint64_t c 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; diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index 9db11383126eb82a99b15dedfd1b3e4d06f1c494..badd5b6d201080876cc2e116cb2e7819a552a942 100755 GIT binary patch delta 5066 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 AF8}}l delta 4042 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 diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index d5076a43db..6b3b73055b 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -6787,6 +6787,55 @@ BOOST_FIXTURE_TEST_CASE(syndicate_liq_credits_a_linked_user_and_parks_an_unlinke 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 From fea844900990bc3ef135f20522378339b9172793 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Wed, 23 Sep 2026 10:16:56 -0500 Subject: [PATCH 37/37] outpost_ethereum_client: crank the syndication pool's realizeYield The Ethereum counterpart of the Solana relay's report_liq_yield crank. Once per epoch, after the delivery lands, the relay reads the pool's address off the outpost itself, OPPInbound.attestationHandlers(DESYNDICATE_LIQ), which wire-ethereum #207's SyndicationPool registers itself under, binds a wrapper to it, and sends realizeYield. No handler, or an ABI set without the pool, leaves the crank idle; the pool's own refusals (no yield, below the deadband, underbacked) are outcomes, anything else is a failed crank for the job to log. Change-Id: Iea3551beab38bf84f6d4cf5fcb4b5e4dc47bcec1 --- .../sysio/outpost_client/outpost_client.hpp | 5 +- .../outpost_ethereum_client_plugin/README.md | 34 +- .../sysio/outpost_ethereum_client_plugin.hpp | 34 +- .../outpost_ethereum_client.hpp | 56 +++ .../src/outpost_ethereum_client.cpp | 167 ++++++++- .../test/test_ethereum_transaction_policy.cpp | 20 +- .../test_outpost_ethereum_client_plugin.cpp | 348 +++++++++++++++++- .../ethereum-abi-syndication-pool.json | 67 ++++ 8 files changed, 704 insertions(+), 27 deletions(-) create mode 100644 tests/fixtures/ethereum-abi-syndication-pool.json 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 1a9048465f..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 @@ -178,7 +178,10 @@ class outpost_client { * * 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). + * 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 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/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" + } + ] +}