diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index c42f44e8..ab9eb6ce 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -6,11 +6,14 @@ jobs: test: uses: ./.github/workflows/test.yml + gas-report: + uses: ./.github/workflows/gas-report.yml + deploy-staging: name: Deploy to staging subaccount permissions: pull-requests: write - needs: [test] + needs: [test, gas-report] runs-on: ubuntu-latest env: NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID: gh-${{ github.event.number }}.${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_ID }} @@ -19,54 +22,117 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.85.0 + with: + targets: wasm32-unknown-unknown + - name: Install near CLI - run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/near/near-cli-rs/releases/download/v0.18.0/near-cli-rs-installer.sh | sh - - name: Create staging account + run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/near/near-cli-rs/releases/download/v0.19.0/near-cli-rs-installer.sh | sh + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Check if staging account exists + id: existence + continue-on-error: true run: | - set +e near account \ view-account-summary "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ network-config "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}" \ now - if [ $? -eq 1 ]; then - set -e - near account create-account fund-myself "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" '10 NEAR' \ - use-manually-provided-public-key "${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_PUBLIC_KEY }}" \ - sign-as "${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_ID }}" \ - network-config "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}" \ - sign-with-plaintext-private-key \ - --signer-public-key "${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_PUBLIC_KEY }}" \ - --signer-private-key "${{ secrets.NEAR_CONTRACT_STAGING_ACCOUNT_PRIVATE_KEY }}" \ - send - - echo "NEWLY_CREATED=1" >> $GITHUB_ENV - fi - + - name: Create staging account + if: steps.existence.outcome != 'success' + run: | + near account create-account fund-myself "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" '10 NEAR' \ + use-manually-provided-public-key "${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_PUBLIC_KEY }}" \ + sign-as "${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_ID }}" \ + network-config "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}" \ + sign-with-plaintext-private-key \ + --signer-public-key "${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_PUBLIC_KEY }}" \ + --signer-private-key "${{ secrets.NEAR_CONTRACT_STAGING_ACCOUNT_PRIVATE_KEY }}" \ + send - name: Install cargo-near CLI run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/near/cargo-near/releases/download/cargo-near-v0.13.3/cargo-near-installer.sh | sh + + # This step runs when the contract already exists and we are redeploying a new version. - name: Deploy to staging + if: steps.existence.outcome == 'success' + run: | + cd contract/market + cargo near deploy build-reproducible-wasm --skip-git-remote-check "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ + without-init-call \ + network-config "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}" \ + sign-with-plaintext-private-key \ + --signer-public-key "${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_PUBLIC_KEY }}" \ + --signer-private-key "${{ secrets.NEAR_CONTRACT_STAGING_ACCOUNT_PRIVATE_KEY }}" \ + send + + - name: Cache init args + id: cache-init-args + uses: actions/cache@v4 + with: + path: init-args.json + key: ${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}-init-args + + - name: Generate init args + if: steps.cache-init-args.outputs.cache-hit != 'true' + run: | + echo "INIT_ARGS<> $GITHUB_ENV + cargo run --package test-utils --example generate_testnet_configuration >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + # This step runs when the contract does not already exist, so we also call the initialization function. + - name: Deploy to staging & init + if: steps.existence.outcome != 'success' # `--skip-git-remote-check` was used # as pull request git refs `refs/pull/NUMBER/merge` are somewhat harder to access and live only as long as PRs do # # WASM reproducibility check akin to SourceScan won't be available for staging contracts, deployed from PRs run: | cd contract/market - if [ -n "$NEWLY_CREATED" ]; then - INIT_ARGS=$(cargo run --package templar-market-contract --example generate_testnet_configuration) - cargo near deploy build-reproducible-wasm --skip-git-remote-check "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ - with-init-call new \ - json-args "$INIT_ARGS" \ - prepaid-gas '100.0 Tgas' \ - attached-deposit '0 NEAR' \ + + cargo near deploy build-reproducible-wasm --skip-git-remote-check "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ + with-init-call new \ + json-args "$INIT_ARGS" \ + prepaid-gas '100.0 Tgas' \ + attached-deposit '0 NEAR' \ + network-config "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}" \ + sign-with-plaintext-private-key \ + --signer-public-key "${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_PUBLIC_KEY }}" \ + --signer-private-key "${{ secrets.NEAR_CONTRACT_STAGING_ACCOUNT_PRIVATE_KEY }}" \ + send + + - name: Perform storage deposits + if: steps.existence.outcome != 'success' + run: | + BORROW_ID=$(echo "$INIT_ARGS" | jq -r '.configuration.borrow_asset.Nep141') + if [ -n "$BORROW_ID" ]; then + near account \ + manage-storage-deposit "$BORROW_ID" \ + deposit "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" '0.00125 NEAR' \ + sign-as "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ network-config "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}" \ sign-with-plaintext-private-key \ --signer-public-key "${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_PUBLIC_KEY }}" \ --signer-private-key "${{ secrets.NEAR_CONTRACT_STAGING_ACCOUNT_PRIVATE_KEY }}" \ send - else - cargo near deploy build-reproducible-wasm --skip-git-remote-check "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ - without-init-call \ + fi + + COLLATERAL_ID=$(echo "$INIT_ARGS" | jq -r '.configuration.collateral_asset.Nep141') + if [ -n "$COLLATERAL_ID" ]; then + near account \ + manage-storage-deposit "$COLLATERAL_ID" \ + deposit "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" '0.00125 NEAR' \ + sign-as "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ network-config "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}" \ sign-with-plaintext-private-key \ --signer-public-key "${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_PUBLIC_KEY }}" \ @@ -74,8 +140,17 @@ jobs: send fi + - name: Write init args for cache + run: | + echo "$INIT_ARGS" > init-args.json + - name: Comment on pull request env: GH_TOKEN: ${{ github.token }} run: | - gh pr comment "${{ github.event.number }}" --body "Staging contract is deployed to ["'`'"${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}"'`'" account](https://explorer.${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}.near.org/accounts/${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }})" + echo "Staging contract is deployed to ["'`'"${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}"'`'" account](https://explorer.${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}.near.org/accounts/${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }})" > comment.md + echo '' >> comment.md + echo "---" >> comment.md + echo '' >> comment.md + echo '${{ needs.gas-report.outputs.comment }}' >> comment.md + gh pr comment "${{ github.event.number }}" -F comment.md --create-if-none --edit-last diff --git a/.github/workflows/gas-report.yml b/.github/workflows/gas-report.yml new file mode 100644 index 00000000..c07ffe45 --- /dev/null +++ b/.github/workflows/gas-report.yml @@ -0,0 +1,39 @@ +name: Generate gas report +on: + workflow_call: + outputs: + comment: + description: Markdown gas report + value: ${{ jobs.gas-report.outputs.comment }} + +jobs: + gas-report: + name: Gas report + runs-on: ubuntu-latest + outputs: + comment: ${{ steps.generate.outputs.comment }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.85.0 + - name: Install cargo-near CLI + run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/near/cargo-near/releases/download/cargo-near-v0.13.3/cargo-near-installer.sh | sh + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Generate gas report + id: generate + run: | + ./script/ci/gas-report.sh > gas-report.md + cat gas-report.md >> $GITHUB_STEP_SUMMARY + + echo "comment<> $GITHUB_OUTPUT + cat gas-report.md >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT diff --git a/.github/workflows/near-rewards.yml b/.github/workflows/near-rewards.yml new file mode 100644 index 00000000..d7326a4c --- /dev/null +++ b/.github/workflows/near-rewards.yml @@ -0,0 +1,26 @@ +name: NEAR Protocol Rewards Tracking +on: + schedule: + - cron: "0 */12 * * *" # Every 12 hours + workflow_dispatch: # Manual trigger + push: + branches: [main, dev] + +jobs: + calculate-rewards: + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: "18" + + - name: Calculate Rewards + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPO: ${{ github.repository }} + run: npx near-protocol-rewards calculate diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b30bbba6..b067eec6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,10 +17,22 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.85.0 + with: + components: clippy + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - name: Run cargo clippy run: | - rustup component add clippy - cargo clippy --all-features --workspace --tests -- --warn clippy::all --warn clippy::nursery + cargo clippy --all-features --workspace --tests -- -D warnings tests: name: Tests @@ -28,10 +40,23 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.85.0 + with: + targets: wasm32-unknown-unknown - name: Install cargo-nextest # https://nexte.st/docs/installation/pre-built-binaries/#linux-x86_64 run: curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C ${CARGO_HOME:-~/.cargo}/bin - - name: Install cargo-near CLI + - name: Install cargo-near run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/near/cargo-near/releases/download/cargo-near-v0.13.3/cargo-near-installer.sh | sh + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - name: Run tests - run: ./test.sh + run: ./script/test.sh diff --git a/.github/workflows/undeploy-staging.yml b/.github/workflows/undeploy-staging.yml index 0364a25c..9f442925 100644 --- a/.github/workflows/undeploy-staging.yml +++ b/.github/workflows/undeploy-staging.yml @@ -13,7 +13,48 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - name: Install near CLI - run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/near/near-cli-rs/releases/download/v0.18.0/near-cli-rs-installer.sh | sh + run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/near/near-cli-rs/releases/download/v0.19.0/near-cli-rs-installer.sh | sh + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.85.0 + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Cache init args + id: cache-init-args + uses: actions/cache@v4 + with: + path: init-args.json + key: ${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}-init-args + - name: Generate init args + if: steps.cache-init-args.outputs.cache-hit != 'true' + run: cargo run --package test-utils --example generate_testnet_configuration > init-args.json + - name: Recover and unregister tokens + run: | + BORROW_ID=$(cat init-args.json | jq -r '.configuration.borrow_asset.Nep141') + if [ -n "$BORROW_ID" ]; then + ./script/ci/recover-nep141.sh --account "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ + --token "$BORROW_ID" \ + --beneficiary "${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_ID }}" \ + --network "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}" \ + --public-key "${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_PUBLIC_KEY }}" \ + --private-key "${{ secrets.NEAR_CONTRACT_STAGING_ACCOUNT_PRIVATE_KEY }}" + fi + + COLLATERAL_ID=$(cat init-args.json | jq -r '.configuration.collateral_asset.Nep141') + if [ -n "$COLLATERAL_ID" ]; then + ./script/ci/recover-nep141.sh --account "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ + --token "$COLLATERAL_ID" \ + --beneficiary "${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_ID }}" \ + --network "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}" \ + --public-key "${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_PUBLIC_KEY }}" \ + --private-key "${{ secrets.NEAR_CONTRACT_STAGING_ACCOUNT_PRIVATE_KEY }}" + fi - name: Remove staging account run: | near account delete-account "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ diff --git a/.gitignore b/.gitignore index 582fc654..0eebcfa4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ # Ignore .DS_Store .DS_Store + +# generated by CI +init-args.json diff --git a/Cargo.lock b/Cargo.lock index 641ccc6f..f0a11486 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1232,6 +1232,12 @@ dependencies = [ "serde", ] +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + [[package]] name = "hmac" version = "0.12.1" @@ -1842,6 +1848,16 @@ dependencies = [ "near-sdk-contract-tools", ] +[[package]] +name = "mock-oracle" +version = "0.1.0" +dependencies = [ + "getrandom", + "near-contract-standards", + "near-sdk", + "templar-common", +] + [[package]] name = "native-tls" version = "0.2.12" @@ -3594,6 +3610,7 @@ name = "templar-common" version = "0.1.0" dependencies = [ "borsh", + "hex", "near-contract-standards", "near-sdk", "primitive-types", @@ -3610,6 +3627,7 @@ dependencies = [ "getrandom", "near-contract-standards", "near-sdk", + "near-sdk-contract-tools", "near-workspaces", "rstest", "templar-common", @@ -3630,7 +3648,9 @@ dependencies = [ name = "test-utils" version = "0.1.0" dependencies = [ + "hex-literal", "near-sdk", + "near-sdk-contract-tools", "near-workspaces", "templar-common", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 2d94b3c3..8edfa912 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,14 @@ [workspace] resolver = "2" members = ["common", "contract/*", "mock/*", "test-utils"] +package.license = "MIT" [workspace.dependencies] getrandom = { version = "0.2", features = ["custom"] } borsh = { version = "1.5", features = ["unstable__schema"] } +hex = { version = "0.4.3", features = ["serde"] } near-sdk = { version = "5.7", features = ["unstable"] } +near-sdk-contract-tools = "3.0.2" near-contract-standards = "5.7" near-workspaces = { version = "0.16", features = ["unstable"] } primitive-types = { version = "0.10.1" } @@ -24,6 +27,9 @@ redundant_closure_for_method_calls = "allow" module_name_repetitions = "allow" unwrap_used = "warn" expect_used = "warn" +large_digit_groups = "warn" +missing_panics_doc = "allow" +wildcard_imports = "warn" [profile.release] codegen-units = 1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..7930be32 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Templar Foundation + +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/common/Cargo.toml b/common/Cargo.toml index 04a267a4..1af68907 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -2,9 +2,11 @@ name = "templar-common" version = "0.1.0" edition = "2021" +license.workspace = true [dependencies] borsh.workspace = true +hex.workspace = true near-contract-standards.workspace = true near-sdk.workspace = true thiserror.workspace = true diff --git a/common/src/accumulator.rs b/common/src/accumulator.rs new file mode 100644 index 00000000..6ebb3042 --- /dev/null +++ b/common/src/accumulator.rs @@ -0,0 +1,79 @@ +use near_sdk::{near, require}; + +use crate::asset::{AssetClass, FungibleAssetAmount}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[near(serializers = [borsh, json])] +pub struct Accumulator { + total: FungibleAssetAmount, + next_snapshot_index: u32, + #[borsh(skip)] + #[serde(default, skip_serializing_if = "FungibleAssetAmount::is_zero")] + pub pending_estimate: FungibleAssetAmount, +} + +impl Accumulator { + pub fn new(next_snapshot_index: u32) -> Self { + Self { + total: 0.into(), + next_snapshot_index, + pending_estimate: 0.into(), + } + } + + pub fn get_next_snapshot_index(&self) -> u32 { + self.next_snapshot_index + } + + pub fn get_total(&self) -> FungibleAssetAmount { + self.total + } + + pub fn clear(&mut self, next_snapshot_index: u32) { + self.total = 0.into(); + self.next_snapshot_index = next_snapshot_index; + } + + pub fn remove(&mut self, amount: FungibleAssetAmount) -> Option> { + self.total.split(amount) + } + + pub fn add_once(&mut self, amount: FungibleAssetAmount) -> Option<()> { + self.total.join(amount) + } + + pub fn accumulate( + &mut self, + AccumulationRecord { + amount, + next_snapshot_index, + }: AccumulationRecord, + ) -> Option<()> { + require!( + next_snapshot_index >= self.next_snapshot_index, + "Invariant violation: Asset accumulations cannot occur retroactively.", + ); + self.total.join(amount)?; + self.next_snapshot_index = next_snapshot_index; + Some(()) + } +} + +#[must_use] +pub struct AccumulationRecord { + pub(crate) amount: FungibleAssetAmount, + pub(crate) next_snapshot_index: u32, +} + +impl AccumulationRecord { + pub fn empty(next_snapshot_index: u32) -> Self { + Self { + amount: FungibleAssetAmount::zero(), + next_snapshot_index, + } + } + + pub fn get_amount(&self) -> FungibleAssetAmount { + self.amount + } +} diff --git a/common/src/asset.rs b/common/src/asset.rs index 5cfafe75..a2f2474a 100644 --- a/common/src/asset.rs +++ b/common/src/asset.rs @@ -1,7 +1,9 @@ use std::{fmt::Display, marker::PhantomData}; use near_contract_standards::fungible_token::core::ext_ft_core; -use near_sdk::{env, ext_contract, json_types::U128, near, AccountId, NearToken, Promise}; +use near_sdk::{env, json_types::U128, near, AccountId, Gas, NearToken, Promise}; + +use crate::number::Decimal; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[near(serializers = [json, borsh])] @@ -16,26 +18,20 @@ pub struct FungibleAsset { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[near(serializers = [json, borsh])] enum FungibleAssetKind { - Native, Nep141(AccountId), } impl FungibleAsset { + /// Really depends on the implementation, but this should suffice, since + /// normal implementations use < 3TGas. + pub const GAS_FT_TRANSFER: Gas = Gas::from_tgas(6); + pub fn transfer(&self, receiver_id: AccountId, amount: FungibleAssetAmount) -> Promise { match self.kind { - FungibleAssetKind::Native => { - Promise::new(receiver_id).transfer(NearToken::from_yoctonear(amount.as_u128())) - } FungibleAssetKind::Nep141(ref contract_id) => ext_ft_core::ext(contract_id.clone()) + .with_static_gas(Self::GAS_FT_TRANSFER) .with_attached_deposit(NearToken::from_yoctonear(1)) - .ft_transfer(receiver_id, amount.as_u128().into(), None), - } - } - - pub fn native() -> Self { - Self { - discriminant: PhantomData, - kind: FungibleAssetKind::Native, + .ft_transfer(receiver_id, u128::from(amount).into(), None), } } @@ -46,51 +42,28 @@ impl FungibleAsset { } } - pub fn is_native(&self) -> bool { - matches!(self.kind, FungibleAssetKind::Native) - } - pub fn is_nep141(&self, account_id: &AccountId) -> bool { - if let FungibleAssetKind::Nep141(ref contract_id) = self.kind { - contract_id == account_id - } else { - false - } + matches!(self.kind, FungibleAssetKind::Nep141(ref contract_id) if contract_id == account_id) } pub fn into_nep141(self) -> Option { - #[allow(clippy::match_wildcard_for_single_variants)] - match self.kind { - FungibleAssetKind::Nep141(contract_id) => Some(contract_id), - _ => None, - } + let FungibleAssetKind::Nep141(contract_id) = self.kind; + Some(contract_id) } pub fn current_account_balance(&self) -> Promise { let current_account_id = env::current_account_id(); - match self.kind { - FungibleAssetKind::Native => { - ext_return_native_balance::ext(current_account_id).return_native_balance() - } - FungibleAssetKind::Nep141(ref account_id) => { - ext_ft_core::ext(account_id.clone()).ft_balance_of(current_account_id.clone()) - } - } + let FungibleAssetKind::Nep141(ref account_id) = self.kind; + ext_ft_core::ext(account_id.clone()).ft_balance_of(current_account_id.clone()) } } -#[ext_contract(ext_return_native_balance)] -pub trait ReturnNativeBalance { - fn return_native_balance(&self) -> U128; -} - impl Display for FungibleAsset { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}", match self.kind { - FungibleAssetKind::Native => "[native NEAR]", FungibleAssetKind::Nep141(ref contract_id) => contract_id.as_str(), } ) @@ -102,12 +75,13 @@ mod sealed { } pub trait AssetClass: sealed::Sealed + Copy + Clone {} -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[near(serializers = [borsh, json])] pub struct CollateralAsset; impl sealed::Sealed for CollateralAsset {} impl AssetClass for CollateralAsset {} -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] + +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[near(serializers = [borsh, json])] pub struct BorrowAsset; impl sealed::Sealed for BorrowAsset {} @@ -168,10 +142,6 @@ impl FungibleAssetAmount { self.amount.0 == 0 } - pub fn as_u128(&self) -> u128 { - self.amount.0 - } - pub fn split(&mut self, amount: impl Into) -> Option { let a = amount.into(); self.amount.0 = self.amount.0.checked_sub(a.amount.0)?; @@ -184,6 +154,24 @@ impl FungibleAssetAmount { } } +impl From> for Decimal { + fn from(value: FungibleAssetAmount) -> Self { + value.amount.0.into() + } +} + +impl From> for u128 { + fn from(value: FungibleAssetAmount) -> Self { + value.amount.0 + } +} + +impl std::fmt::Display for FungibleAssetAmount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.amount.0) + } +} + pub type BorrowAssetAmount = FungibleAssetAmount; pub type CollateralAssetAmount = FungibleAssetAmount; diff --git a/common/src/balance_log.rs b/common/src/balance_log.rs deleted file mode 100644 index 5eff8b20..00000000 --- a/common/src/balance_log.rs +++ /dev/null @@ -1,64 +0,0 @@ -use std::cmp::Ordering; - -use borsh::BorshDeserialize; -use near_sdk::{collections::Vector, near}; - -use crate::{ - asset::{AssetClass, FungibleAssetAmount}, - chain_time::ChainTime, -}; - -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -#[near(serializers = [borsh, json])] -pub struct BalanceLog { - pub chain_time: ChainTime, - pub amount: FungibleAssetAmount, -} - -impl BalanceLog { - pub fn new(chain_time: ChainTime, amount: FungibleAssetAmount) -> Self { - Self { chain_time, amount } - } -} - -pub enum SearchResult { - Found { index: u64, log: BalanceLog }, - NotFound { index_below: Option }, -} - -pub fn search_balance_logs( - logs: &Vector>, - target: ChainTime, -) -> SearchResult { - if logs.is_empty() { - return SearchResult::NotFound { index_below: None }; - } - - let mut bottom = 0; - let mut top = logs.len() - 1; - - while bottom <= top { - let i = (bottom + top) / 2; - let log = logs.get(i).unwrap_or_else(|| { - near_sdk::env::panic_str("Invariant violation: All vector elements in range exist") - }); - match log.chain_time.cmp(&target) { - Ordering::Less => { - bottom = i + 1; - } - Ordering::Equal => { - return SearchResult::Found { index: i, log }; - } - Ordering::Greater => { - if top == 0 { - return SearchResult::NotFound { index_below: None }; - } - top = i - 1; - } - } - } - - SearchResult::NotFound { - index_below: Some(top), - } -} diff --git a/common/src/borrow.rs b/common/src/borrow.rs index d5bb242d..ed09a619 100644 --- a/common/src/borrow.rs +++ b/common/src/borrow.rs @@ -1,12 +1,29 @@ -use near_sdk::{json_types::U64, near}; +use std::ops::{Deref, DerefMut}; + +use near_sdk::{env, json_types::U64, near, AccountId}; use crate::{ - asset::{ - AssetClass, BorrowAsset, BorrowAssetAmount, CollateralAssetAmount, FungibleAssetAmount, - }, - chain_time::ChainTime, + accumulator::{AccumulationRecord, Accumulator}, + asset::{BorrowAsset, BorrowAssetAmount, CollateralAssetAmount}, + event::MarketEvent, + market::{Market, PricePair}, + number::Decimal, + MS_IN_A_YEAR, }; +/// This struct can only be constructed after accumulating interest on a +/// borrow position. This serves as proof that the interest has accrued, so it +/// is safe to perform certain other operations. +#[derive(Clone, Copy)] +pub struct InterestAccumulationProof(()); + +#[cfg(test)] +impl InterestAccumulationProof { + pub fn test() -> Self { + Self(()) + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[near(serializers = [borsh, json])] pub enum BorrowStatus { @@ -31,63 +48,39 @@ pub enum LiquidationReason { Expiration, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[near(serializers = [borsh, json])] -pub struct FeeRecord { - pub(crate) total: FungibleAssetAmount, - pub(crate) last_updated: ChainTime, -} - -impl FeeRecord { - pub fn new(chain_time: ChainTime) -> Self { - Self { - total: 0.into(), - last_updated: chain_time, - } - } - - pub fn accumulate_fees( - &mut self, - additional_fees: FungibleAssetAmount, - chain_time: ChainTime, - ) -> Option<()> { - debug_assert!(chain_time > self.last_updated); - self.total.join(additional_fees)?; - self.last_updated = chain_time; - Some(()) - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] #[near(serializers = [borsh, json])] pub struct BorrowPosition { pub started_at_block_timestamp_ms: Option, pub collateral_asset_deposit: CollateralAssetAmount, borrow_asset_principal: BorrowAssetAmount, - pub borrow_asset_fees: FeeRecord, + pub borrow_asset_fees: Accumulator, pub temporary_lock: BorrowAssetAmount, pub liquidation_lock: bool, } impl BorrowPosition { - pub fn new(chain_time: ChainTime) -> Self { + pub fn new(current_snapshot_index: u32) -> Self { Self { started_at_block_timestamp_ms: None, collateral_asset_deposit: 0.into(), borrow_asset_principal: 0.into(), - borrow_asset_fees: FeeRecord::new(chain_time), + // Start from current (not next) snapshot to avoid the possibility + // of borrowing "for free". e.g. if TimeChunk units are epochs (12 + // hours), this prevents someone from getting 11 hours of free + // borrowing if they create the borrow 1 hour into the epoch. + borrow_asset_fees: Accumulator::new(current_snapshot_index), temporary_lock: 0.into(), liquidation_lock: false, } } - pub fn full_liquidation(&mut self, chain_time: ChainTime) { + pub(crate) fn full_liquidation(&mut self, current_snapshot_index: u32) { self.liquidation_lock = false; self.started_at_block_timestamp_ms = None; self.collateral_asset_deposit = 0.into(); self.borrow_asset_principal = 0.into(); - self.borrow_asset_fees.total = 0.into(); - self.borrow_asset_fees.last_updated = chain_time; + self.borrow_asset_fees.clear(current_snapshot_index); } pub fn get_borrow_asset_principal(&self) -> BorrowAssetAmount { @@ -97,7 +90,7 @@ impl BorrowPosition { pub fn get_total_borrow_asset_liability(&self) -> BorrowAssetAmount { let mut total = BorrowAssetAmount::zero(); total.join(self.borrow_asset_principal); - total.join(self.borrow_asset_fees.total); + total.join(self.borrow_asset_fees.get_total()); total.join(self.temporary_lock); total } @@ -107,22 +100,24 @@ impl BorrowPosition { || !self.get_total_borrow_asset_liability().is_zero() } - pub fn increase_collateral_asset_deposit( + pub(crate) fn increase_collateral_asset_deposit( &mut self, amount: CollateralAssetAmount, ) -> Option<()> { self.collateral_asset_deposit.join(amount) } - pub fn decrease_collateral_asset_deposit( + pub(crate) fn decrease_collateral_asset_deposit( &mut self, amount: CollateralAssetAmount, ) -> Option { self.collateral_asset_deposit.split(amount) } - pub fn increase_borrow_asset_principal( + /// Interest accumulation MUST be applied before calling this function. + pub(crate) fn increase_borrow_asset_principal( &mut self, + _proof: InterestAccumulationProof, amount: BorrowAssetAmount, block_timestamp_ms: u64, ) -> Option<()> { @@ -134,8 +129,10 @@ impl BorrowPosition { self.borrow_asset_principal.join(amount) } + /// Interest accumulation MUST be applied before calling this function. pub(crate) fn reduce_borrow_asset_liability( &mut self, + _proof: InterestAccumulationProof, mut amount: BorrowAssetAmount, ) -> Result { if self.liquidation_lock { @@ -144,9 +141,9 @@ impl BorrowPosition { // No bounds checks necessary here: the min() call prevents underflow. - let amount_to_fees = self.borrow_asset_fees.total.min(amount); + let amount_to_fees = self.borrow_asset_fees.get_total().min(amount); amount.split(amount_to_fees); - self.borrow_asset_fees.total.split(amount_to_fees); + self.borrow_asset_fees.remove(amount_to_fees); let amount_to_principal = self.borrow_asset_principal.min(amount); amount.split(amount_to_principal); @@ -178,3 +175,356 @@ pub mod error { #[error("This position is currently being liquidated.")] pub struct LiquidationLockError; } + +pub struct BorrowPositionRef { + market: M, + account_id: AccountId, + position: BorrowPosition, +} + +impl BorrowPositionRef { + pub fn new(market: M, account_id: AccountId, position: BorrowPosition) -> Self { + Self { + market, + account_id, + position, + } + } + + pub fn account_id(&self) -> &AccountId { + &self.account_id + } + + pub fn inner(&self) -> &BorrowPosition { + &self.position + } +} + +impl> BorrowPositionRef { + pub fn with_pending_interest(&mut self) { + let mut pending_estimate = self.calculate_interest(u32::MAX).get_amount(); + let prev_end_timestamp_ms = self.market.get_last_finalized_snapshot().end_timestamp_ms.0; + let current_snapshot = &self.market.current_snapshot; + let interest_in_current_snapshot = current_snapshot.interest_rate + * (env::block_timestamp_ms().saturating_sub(prev_end_timestamp_ms)) + * Decimal::from(self.position.get_borrow_asset_principal()) + / *MS_IN_A_YEAR; + #[allow(clippy::unwrap_used, reason = "This is a view method")] + pending_estimate.join(interest_in_current_snapshot.to_u128_ceil().unwrap().into()); + + self.position.borrow_asset_fees.pending_estimate = pending_estimate; + } + + pub(crate) fn calculate_interest( + &self, + snapshot_limit: u32, + ) -> AccumulationRecord { + let principal: Decimal = self.position.get_borrow_asset_principal().into(); + let mut next_snapshot_index = self.position.borrow_asset_fees.get_next_snapshot_index(); + + let mut accumulated = Decimal::ZERO; + #[allow(clippy::unwrap_used, reason = "1 finalized snapshot guaranteed")] + let mut prev_end_timestamp_ms = self + .market + .finalized_snapshots + .get(next_snapshot_index.checked_sub(1).unwrap()) + .unwrap() + .end_timestamp_ms + .0; + + #[allow( + clippy::cast_possible_truncation, + reason = "Assume # of snapshots will never be > u32::MAX" + )] + for (i, snapshot) in self + .market + .finalized_snapshots + .iter() + .enumerate() + .skip(next_snapshot_index as usize) + .take(snapshot_limit as usize) + { + let duration_ms = Decimal::from( + snapshot + .end_timestamp_ms + .0 + .checked_sub(prev_end_timestamp_ms) + .unwrap_or_else(|| { + env::panic_str(&format!( + "Invariant violation: Snapshot timestamp decrease at time chunk #{}.", + u64::from(snapshot.time_chunk.0), + )) + }), + ); + accumulated += principal * snapshot.interest_rate * duration_ms / *MS_IN_A_YEAR; + + prev_end_timestamp_ms = snapshot.end_timestamp_ms.0; + next_snapshot_index = i as u32 + 1; + } + + AccumulationRecord { + #[allow( + clippy::unwrap_used, + reason = "Assume accumulated interest will never exceed u128::MAX" + )] + amount: accumulated.to_u128_ceil().unwrap().into(), + next_snapshot_index, + } + } + + pub fn can_be_liquidated(&self, price_pair: &PricePair, block_timestamp_ms: u64) -> bool { + self.market + .configuration + .borrow_status(&self.position, price_pair, block_timestamp_ms) + .is_liquidation() + } + + pub fn is_within_minimum_initial_collateral_ratio(&self, price_pair: &PricePair) -> bool { + self.market + .configuration + .is_within_minimum_initial_collateral_ratio(&self.position, price_pair) + } + + pub fn is_within_minimum_collateral_ratio(&self, price_pair: &PricePair) -> bool { + self.market + .configuration + .is_within_minimum_collateral_ratio(&self.position, price_pair) + } + + pub fn minimum_acceptable_liquidation_amount( + &self, + price_pair: &PricePair, + ) -> Option { + self.market + .configuration + .minimum_acceptable_liquidation_amount( + self.position.collateral_asset_deposit, + price_pair, + ) + } +} + +pub struct BorrowPositionGuard<'a>(BorrowPositionRef<&'a mut Market>); + +impl Drop for BorrowPositionGuard<'_> { + fn drop(&mut self) { + self.0 + .market + .borrow_positions + .insert(&self.0.account_id, &self.0.position); + } +} + +impl<'a> Deref for BorrowPositionGuard<'a> { + type Target = BorrowPositionRef<&'a mut Market>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for BorrowPositionGuard<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl<'a> BorrowPositionGuard<'a> { + pub fn new(market: &'a mut Market, account_id: AccountId, position: BorrowPosition) -> Self { + Self(BorrowPositionRef::new(market, account_id, position)) + } + + pub fn record_collateral_asset_deposit( + &mut self, + _proof: InterestAccumulationProof, + amount: CollateralAssetAmount, + ) { + self.position + .increase_collateral_asset_deposit(amount) + .unwrap_or_else(|| env::panic_str("Borrow position collateral asset overflow")); + + MarketEvent::CollateralDeposited { + account_id: self.account_id.clone(), + collateral_asset_amount: amount, + } + .emit(); + } + + pub fn record_collateral_asset_withdrawal( + &mut self, + _proof: InterestAccumulationProof, + amount: CollateralAssetAmount, + ) { + self.position + .decrease_collateral_asset_deposit(amount) + .unwrap_or_else(|| env::panic_str("Borrow position collateral asset underflow")); + + MarketEvent::CollateralWithdrawn { + account_id: self.account_id.clone(), + collateral_asset_amount: amount, + } + .emit(); + } + + pub fn record_borrow_asset_in_flight_start( + &mut self, + _proof: InterestAccumulationProof, + amount: BorrowAssetAmount, + fees: BorrowAssetAmount, + ) { + self.market + .borrow_asset_in_flight + .join(amount) + .unwrap_or_else(|| env::panic_str("Borrow asset in flight amount overflow")); + self.position + .temporary_lock + .join(amount) + .and_then(|()| self.position.temporary_lock.join(fees)) + .unwrap_or_else(|| env::panic_str("Borrow position in flight amount overflow")); + } + + pub fn record_borrow_asset_in_flight_end( + &mut self, + _proof: InterestAccumulationProof, + amount: BorrowAssetAmount, + fees: BorrowAssetAmount, + ) { + // This should never panic, because a given amount of in-flight borrow + // asset should always be added before it is removed. + self.market + .borrow_asset_in_flight + .split(amount) + .unwrap_or_else(|| env::panic_str("Borrow asset in flight amount underflow")); + self.position + .temporary_lock + .split(amount) + .and_then(|_| self.position.temporary_lock.split(fees)) + .unwrap_or_else(|| env::panic_str("Borrow position in flight amount underflow")); + } + + pub fn record_borrow_asset_withdrawal( + &mut self, + proof: InterestAccumulationProof, + amount: BorrowAssetAmount, + fees: BorrowAssetAmount, + ) { + self.position.borrow_asset_fees.add_once(fees); + self.position + .increase_borrow_asset_principal(proof, amount, env::block_timestamp_ms()) + .unwrap_or_else(|| env::panic_str("Increase borrow asset principal overflow")); + + self.market + .borrow_asset_borrowed + .join(amount) + .unwrap_or_else(|| env::panic_str("Borrow asset borrowed overflow")); + self.market.snapshot(); + + MarketEvent::BorrowWithdrawn { + account_id: self.account_id.clone(), + borrow_asset_amount: amount, + } + .emit(); + } + + /// Returns the amount that is left over after repaying the whole + /// position. That is, the return value is the number of tokens that may + /// be returned to the owner of the borrow position. + pub fn record_repay( + &mut self, + proof: InterestAccumulationProof, + amount: BorrowAssetAmount, + ) -> BorrowAssetAmount { + let liability_reduction = self + .position + .reduce_borrow_asset_liability(proof, amount) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); + + self.market + .record_borrow_asset_yield_distribution(liability_reduction.amount_to_fees); + + // SAFETY: It should be impossible to panic here, since assets that + // have not yet been borrowed cannot be repaid. + self.market + .borrow_asset_borrowed + .split(liability_reduction.amount_to_principal) + .unwrap_or_else(|| env::panic_str("Borrow asset borrowed underflow")); + + self.market.snapshot(); + + MarketEvent::BorrowRepaid { + account_id: self.account_id.clone(), + borrow_asset_fees_repaid: liability_reduction.amount_to_fees, + borrow_asset_principal_repaid: liability_reduction.amount_to_principal, + borrow_asset_principal_remaining: self.position.get_borrow_asset_principal(), + } + .emit(); + + liability_reduction.amount_remaining + } + + pub fn accumulate_interest_partial(&mut self, snapshot_limit: u32) { + self.market.snapshot(); + + let accumulation_record = self.calculate_interest(snapshot_limit); + + if !accumulation_record.amount.is_zero() { + MarketEvent::InterestAccumulated { + account_id: self.account_id.clone(), + borrow_asset_amount: accumulation_record.amount, + } + .emit(); + } + + self.position + .borrow_asset_fees + .accumulate(accumulation_record); + } + + pub fn accumulate_interest(&mut self) -> InterestAccumulationProof { + self.accumulate_interest_partial(u32::MAX); + InterestAccumulationProof(()) + } + + pub fn liquidation_lock(&mut self) { + self.position.liquidation_lock = true; + } + + pub fn liquidation_unlock(&mut self) { + self.position.liquidation_lock = false; + } + + pub fn record_full_liquidation( + &mut self, + liquidator_id: AccountId, + mut recovered_amount: BorrowAssetAmount, + ) { + let principal = self.position.get_borrow_asset_principal(); + let collateral_asset_liquidated = self.position.collateral_asset_deposit; + + MarketEvent::FullLiquidation { + liquidator_id, + account_id: self.account_id.clone(), + borrow_asset_principal: principal, + borrow_asset_recovered: recovered_amount, + collateral_asset_liquidated, + } + .emit(); + + let snapshot_index = self.market.snapshot(); + self.position.full_liquidation(snapshot_index); + + self.market.borrow_asset_borrowed.split(principal); + + if recovered_amount.split(principal).is_some() { + // Distribute yield. + // record_borrow_asset_yield_distribution will take snapshot, no need to do it. + self.market + .record_borrow_asset_yield_distribution(recovered_amount); + } else { + // Took a loss on liquidation. + // This can be detected from the event (borrow_asset_principal > borrow_asset_recovered?). + // Deficit should be covered by protocol insurance. + // No need for additional action. + } + } +} diff --git a/common/src/chain_time.rs b/common/src/chain_time.rs deleted file mode 100644 index d3d5e457..00000000 --- a/common/src/chain_time.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::fmt::Display; - -use near_sdk::{env, json_types::U64, near}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -#[near(serializers = [borsh, json])] -pub struct ChainTime(U64); - -impl ChainTime { - pub fn now() -> Self { - Self(U64(env::block_height())) - } -} - -impl Display for ChainTime { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "block:{}", u64::from(self.0)) - } -} diff --git a/common/src/chunked_append_only_list.rs b/common/src/chunked_append_only_list.rs new file mode 100644 index 00000000..37275c01 --- /dev/null +++ b/common/src/chunked_append_only_list.rs @@ -0,0 +1,220 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use near_sdk::{env, near, store::Vector, BorshStorageKey, IntoStorageKey}; + +#[derive(Debug, Clone, Copy, BorshSerialize, BorshStorageKey, PartialEq, Eq, PartialOrd, Ord)] +enum StorageKey { + Inner, +} + +/// Represents an append-only iterable list that stores multiple items per +/// storage slot to reduce gas cost when reading. +#[derive(Debug)] +#[near(serializers = [borsh])] +pub struct ChunkedAppendOnlyList { + inner: Vector>, + last_chunk_next_index: u32, +} + +impl + ChunkedAppendOnlyList +{ + pub fn new(prefix: impl IntoStorageKey) -> Self { + Self { + inner: Vector::new( + [ + prefix.into_storage_key(), + StorageKey::Inner.into_storage_key(), + ] + .concat(), + ), + last_chunk_next_index: 0, + } + } + + pub fn len(&self) -> u32 { + let Some(last_index) = self.inner.len().checked_sub(1) else { + return 0; + }; + last_index * CHUNK_SIZE + + if self.last_chunk_next_index == 0 { + CHUNK_SIZE + } else { + self.last_chunk_next_index + } + } + + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + pub fn push(&mut self, item: T) { + if self.last_chunk_next_index == 0 { + let v = vec![item]; + self.inner.push(v); + } else { + let last_inner = self + .inner + .len() + .checked_sub(1) + .unwrap_or_else(|| env::panic_str("Inconsistent state: len == 0")); + let v = self + .inner + .get_mut(last_inner) + .unwrap_or_else(|| env::panic_str("Inconsistent state: tail dne")); + v.push(item); + } + self.last_chunk_next_index = (self.last_chunk_next_index + 1) % CHUNK_SIZE; + } + + pub fn get(&self, index: u32) -> Option<&T> { + self.inner + .get(index / CHUNK_SIZE) + .and_then(|v| v.get((index % CHUNK_SIZE) as usize)) + } + + pub fn replace_last(&mut self, item: T) { + let Some(entry) = self + .inner + .len() + .checked_sub(1) + .and_then(|last_index| self.inner.get_mut(last_index)) + .and_then(|v| v.last_mut()) + else { + env::panic_str("Cannot replace_last in empty list"); + }; + *entry = item; + } + + pub fn iter(&self) -> Iter<'_, T, CHUNK_SIZE> { + Iter { + list: self, + next_index: 0, + until_index: self.len(), + } + } + + pub fn flush(&mut self) { + self.inner.flush(); + } +} + +impl<'a, T: BorshSerialize + BorshDeserialize, const CHUNK_SIZE: u32> IntoIterator + for &'a ChunkedAppendOnlyList +{ + type Item = &'a T; + + type IntoIter = Iter<'a, T, CHUNK_SIZE>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +pub struct Iter<'a, T: BorshSerialize + BorshDeserialize, const CHUNK_SIZE: u32> { + list: &'a ChunkedAppendOnlyList, + next_index: u32, + until_index: u32, +} + +impl<'a, T: BorshSerialize + BorshDeserialize, const CHUNK_SIZE: u32> Iterator + for Iter<'a, T, CHUNK_SIZE> +{ + type Item = &'a T; + + fn nth(&mut self, n: usize) -> Option { + if self.until_index <= self.next_index { + return None; + } + #[allow( + clippy::unwrap_used, + reason = "Assume collection size is never > u32::MAX" + )] + let n = u32::try_from(n).unwrap(); + let index = self.next_index + n; + let value = self.list.get(index)?; + self.next_index = index + 1; + Some(value) + } + + fn next(&mut self) -> Option { + self.nth(0) + } +} + +impl DoubleEndedIterator + for Iter<'_, T, CHUNK_SIZE> +{ + fn next_back(&mut self) -> Option { + if self.until_index <= self.next_index { + return None; + } + let (index, value) = self + .until_index + .checked_sub(1) + .and_then(|index| self.list.get(index).map(|x| (index, x)))?; + self.until_index = index; + Some(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic() { + let mut list = ChunkedAppendOnlyList::<_, 47>::new(b"l"); + assert_eq!(list.len(), 0); + assert!(list.is_empty()); + + for i in 0..10_000usize { + list.push(i); + assert_eq!(list.len() as usize, i + 1); + assert!(!list.is_empty()); + } + + let mut count = 0; + for (i, v) in list.iter().enumerate() { + assert_eq!(i, *v); + count += 1; + } + + assert_eq!(count, 10_000); + } + + #[test] + fn replace_last() { + let mut list = ChunkedAppendOnlyList::<_, 47>::new(b"l"); + for i in 0..10_000u32 { + list.push(i); + list.replace_last(i * 2); + assert_eq!(list.len(), i + 1); + assert!(!list.is_empty()); + } + + for i in 0..10_000u32 { + let x = list.get(i).unwrap(); + assert_eq!(*x, i * 2); + } + + assert_eq!(list.len(), 10_000); + } + + #[test] + fn next_back() { + let mut list = ChunkedAppendOnlyList::<_, 47>::new(b"l"); + for i in 0..10_000u32 { + list.push(i); + } + + let mut it = list.iter(); + + let mut i = 10_000; + while let Some(x) = it.next_back() { + i -= 1; + assert_eq!(*x, i); + } + + assert_eq!(i, 0); + } +} diff --git a/common/src/event.rs b/common/src/event.rs new file mode 100644 index 00000000..cca25df7 --- /dev/null +++ b/common/src/event.rs @@ -0,0 +1,71 @@ +use near_sdk::{near, AccountId}; + +use crate::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + snapshot::Snapshot, +}; + +#[near(event_json(standard = "templar-market"))] +pub enum MarketEvent { + #[event_version("1.0.0")] + SnapshotFinalized { + index: u32, + #[serde(flatten)] + snapshot: Snapshot, + }, + #[event_version("1.0.0")] + GlobalYieldDistributed { + borrow_asset_amount: BorrowAssetAmount, + }, + #[event_version("1.0.0")] + YieldAccumulated { + account_id: AccountId, + borrow_asset_amount: BorrowAssetAmount, + }, + #[event_version("1.0.0")] + InterestAccumulated { + account_id: AccountId, + borrow_asset_amount: BorrowAssetAmount, + }, + #[event_version("1.0.0")] + SupplyDeposited { + account_id: AccountId, + borrow_asset_amount: BorrowAssetAmount, + }, + #[event_version("1.0.0")] + SupplyWithdrawn { + account_id: AccountId, + borrow_asset_amount_to_account: BorrowAssetAmount, + borrow_asset_amount_to_fees: BorrowAssetAmount, + }, + #[event_version("1.0.0")] + CollateralDeposited { + account_id: AccountId, + collateral_asset_amount: CollateralAssetAmount, + }, + #[event_version("1.0.0")] + CollateralWithdrawn { + account_id: AccountId, + collateral_asset_amount: CollateralAssetAmount, + }, + #[event_version("1.0.0")] + BorrowWithdrawn { + account_id: AccountId, + borrow_asset_amount: BorrowAssetAmount, + }, + #[event_version("1.0.0")] + BorrowRepaid { + account_id: AccountId, + borrow_asset_fees_repaid: BorrowAssetAmount, + borrow_asset_principal_repaid: BorrowAssetAmount, + borrow_asset_principal_remaining: BorrowAssetAmount, + }, + #[event_version("1.0.0")] + FullLiquidation { + liquidator_id: AccountId, + account_id: AccountId, + borrow_asset_principal: BorrowAssetAmount, + borrow_asset_recovered: BorrowAssetAmount, + collateral_asset_liquidated: CollateralAssetAmount, + }, +} diff --git a/common/src/fee.rs b/common/src/fee.rs index 6e4409fe..dae55a26 100644 --- a/common/src/fee.rs +++ b/common/src/fee.rs @@ -20,7 +20,7 @@ impl Fee { pub fn of(&self, amount: FungibleAssetAmount) -> Option> { match self { Fee::Flat(f) => Some(*f), - Fee::Proportional(factor) => (factor * amount.as_u128()) + Fee::Proportional(factor) => (factor * u128::from(amount)) .to_u128_ceil() .map(FungibleAssetAmount::new), } @@ -50,11 +50,14 @@ impl TimeBasedFee { pub enum TimeBasedFeeFunction { Fixed, Linear, - Logarithmic, } impl TimeBasedFee { - pub fn of(&self, amount: FungibleAssetAmount, time: u64) -> Option> { + pub fn of( + &self, + amount: FungibleAssetAmount, + duration: u64, + ) -> Option> { let base_fee = self.fee.of(amount)?; if self.duration.0 == 0 { @@ -62,23 +65,20 @@ impl TimeBasedFee { } match self.behavior { - TimeBasedFeeFunction::Fixed => Some(base_fee), - TimeBasedFeeFunction::Linear => (Decimal::from(time) / self.duration.0 - * base_fee.as_u128()) - .to_u128_ceil() - .map(FungibleAssetAmount::new), - TimeBasedFeeFunction::Logarithmic => Some( - // TODO: Seems jank. - #[allow( - clippy::cast_sign_loss, - clippy::cast_possible_truncation, - clippy::cast_precision_loss - )] - (((base_fee.as_u128() as f64 * f64::log2((1 + time - self.duration.0) as f64)) - / f64::log2((1 + time) as f64)) - .ceil() as u128) - .into(), - ), + TimeBasedFeeFunction::Fixed => { + if duration >= self.duration.0 { + Some(0.into()) + } else { + Some(base_fee) + } + } + TimeBasedFeeFunction::Linear => { + (Decimal::from(self.duration.0.saturating_sub(duration)) + / Decimal::from(self.duration.0) + * u128::from(base_fee)) + .to_u128_ceil() + .map(FungibleAssetAmount::new) + } } } } diff --git a/common/src/interest_rate_strategy.rs b/common/src/interest_rate_strategy.rs new file mode 100644 index 00000000..3a18a928 --- /dev/null +++ b/common/src/interest_rate_strategy.rs @@ -0,0 +1,268 @@ +use std::ops::Deref; + +use near_sdk::{near, require}; + +use crate::number::Decimal; + +pub trait UsageCurve { + fn at(&self, usage_ratio: Decimal) -> Decimal; +} + +#[derive(Clone, Debug)] +#[near(serializers = [json, borsh])] +pub enum InterestRateStrategy { + Linear(Linear), + Piecewise(Piecewise), + Exponential2(Exponential2), +} + +impl InterestRateStrategy { + #[must_use] + pub fn linear(base: Decimal, top: Decimal) -> Option { + Some(Self::Linear(Linear::new(base, top)?)) + } + + #[must_use] + pub fn piecewise( + base: Decimal, + optimal: Decimal, + rate_1: Decimal, + rate_2: Decimal, + ) -> Option { + Some(Self::Piecewise(Piecewise::new( + base, optimal, rate_1, rate_2, + )?)) + } + + #[must_use] + pub fn exponential2(base: Decimal, top: Decimal, eccentricity: Decimal) -> Option { + Some(Self::Exponential2(Exponential2::new( + base, + top, + eccentricity, + )?)) + } +} + +impl Deref for InterestRateStrategy { + type Target = dyn UsageCurve; + + fn deref(&self) -> &Self::Target { + match self { + Self::Linear(linear) => linear as &dyn UsageCurve, + Self::Piecewise(piecewise) => piecewise as &dyn UsageCurve, + Self::Exponential2(exponential2) => exponential2 as &dyn UsageCurve, + } + } +} + +/// ```text,no_run +/// r(u) = u * (t - b) + b +/// ``` +#[derive(Debug, Clone)] +#[near(serializers = [borsh, json])] +pub struct Linear { + base: Decimal, + top: Decimal, +} + +impl Linear { + pub fn new(base: Decimal, top: Decimal) -> Option { + (base <= top).then_some(Self { base, top }) + } +} + +impl UsageCurve for Linear { + fn at(&self, usage_ratio: Decimal) -> Decimal { + usage_ratio * (self.top - self.base) + self.base + } +} + +/// ```text,no_run +/// r(u) = { +/// if u < o : r_1 * u + b, +/// else : r_2 * u + o * (r_1 - r_2) + b +/// } +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [borsh, json])] +#[serde(try_from = "PiecewiseParams", into = "PiecewiseParams")] +pub struct Piecewise { + params: PiecewiseParams, + i_negative_rate_2_b: Decimal, +} + +impl Piecewise { + pub fn new(base: Decimal, optimal: Decimal, rate_1: Decimal, rate_2: Decimal) -> Option { + if optimal > 1u32 { + return None; + } + + if rate_1 > rate_2 { + return None; + } + + Some(Self { + i_negative_rate_2_b: optimal * (rate_2 - rate_1) - base, + params: PiecewiseParams { + base, + optimal, + rate_1, + rate_2, + }, + }) + } +} + +impl UsageCurve for Piecewise { + fn at(&self, usage_ratio: Decimal) -> Decimal { + require!( + usage_ratio <= Decimal::ONE, + "Invariant violation: Usage ratio cannot be over 100%.", + ); + + if usage_ratio < self.params.optimal { + self.params.rate_1 * usage_ratio + self.params.base + } else { + self.params.rate_2 * usage_ratio - self.i_negative_rate_2_b + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [json, borsh])] +pub struct PiecewiseParams { + base: Decimal, + optimal: Decimal, + rate_1: Decimal, + rate_2: Decimal, +} + +impl TryFrom for Piecewise { + type Error = &'static str; + + fn try_from( + PiecewiseParams { + base, + optimal, + rate_1, + rate_2, + }: PiecewiseParams, + ) -> Result { + Self::new(base, optimal, rate_1, rate_2).ok_or("Invalid Piecewise parameters") + } +} + +impl From for PiecewiseParams { + fn from(value: Piecewise) -> Self { + value.params + } +} + +/// ```text,no_run +/// r(u) = b + (t - b) * (2^ku - 1) / (2^k - 1) +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [borsh, json])] +#[serde(try_from = "Exponential2Params", into = "Exponential2Params")] +pub struct Exponential2 { + params: Exponential2Params, + i_factor: Decimal, +} + +impl Exponential2 { + /// # Panics + /// - If 2^eccentricity overflows `Decimal`. + pub fn new(base: Decimal, top: Decimal, eccentricity: Decimal) -> Option { + if base > top { + return None; + } + + if eccentricity > 24u32 || eccentricity.is_zero() { + return None; + } + + #[allow(clippy::unwrap_used, reason = "Invariant checked above")] + Some(Self { + i_factor: (top - base) / (eccentricity.pow2().unwrap() - 1u32), + params: Exponential2Params { + base, + top, + eccentricity, + }, + }) + } +} + +impl UsageCurve for Exponential2 { + fn at(&self, usage_ratio: Decimal) -> Decimal { + require!( + usage_ratio <= Decimal::ONE, + "Invariant violation: Usage ratio cannot be over 100%.", + ); + + #[allow(clippy::unwrap_used, reason = "Invariant checked above")] + (self.params.base + + self.i_factor * ((self.params.eccentricity * usage_ratio).pow2().unwrap() - 1u32)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [json, borsh])] +pub struct Exponential2Params { + base: Decimal, + top: Decimal, + eccentricity: Decimal, +} + +impl TryFrom for Exponential2 { + type Error = &'static str; + + fn try_from( + Exponential2Params { + base, + top, + eccentricity, + }: Exponential2Params, + ) -> Result { + Self::new(base, top, eccentricity).ok_or("Invalid Exponential2 parameters") + } +} + +impl From for Exponential2Params { + fn from(value: Exponential2) -> Self { + value.params + } +} + +#[cfg(test)] +mod tests { + use std::ops::Div; + + use crate::dec; + + use super::*; + + #[test] + fn piecewise() { + let s = Piecewise::new(Decimal::ZERO, dec!("0.9"), dec!("0.035"), dec!("0.6")).unwrap(); + + assert!(s.at(Decimal::ZERO).near_equal(Decimal::ZERO)); + assert!(s.at(dec!("0.1")).near_equal(dec!("0.0035"))); + assert!(s.at(dec!("0.5")).near_equal(dec!("0.0175"))); + assert!(s.at(dec!("0.6")).near_equal(dec!("0.021"))); + assert!(s.at(dec!("0.9")).near_equal(dec!("0.0315"))); + assert!(s.at(dec!("0.95")).near_equal(dec!("0.0615"))); + assert!(s.at(Decimal::ONE).near_equal(dec!("0.0915"))); + } + + #[test] + fn exponential2() { + let s = Exponential2::new(dec!("0.005"), dec!("0.08"), dec!("6")).unwrap(); + assert!(s.at(Decimal::ZERO).near_equal(dec!("0.005"))); + assert!(s.at(dec!("0.25")).near_equal(dec!( + "0.00717669895803117868762306839097547161564207589375463826946828509045412494" + ))); + assert!(s.at(Decimal::ONE_HALF).near_equal(Decimal::ONE.div(75u32))); + } +} diff --git a/common/src/lib.rs b/common/src/lib.rs index 68a76487..25a355d5 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -1,11 +1,18 @@ +pub mod accumulator; pub mod asset; -pub mod balance_log; pub mod borrow; -pub mod chain_time; +pub mod chunked_append_only_list; +pub mod event; pub mod fee; +pub mod interest_rate_strategy; pub mod market; pub mod number; +pub mod oracle; +pub mod snapshot; pub mod static_yield; pub mod supply; -pub mod util; +pub mod time_chunk; pub mod withdrawal_queue; + +pub static MS_IN_A_YEAR: std::sync::LazyLock = + std::sync::LazyLock::new(|| number::Decimal::from(31_556_952_000_u128)); // 1000 * 60 * 60 * 24 * 365.2425 diff --git a/common/src/market/balance_oracle_configuration.rs b/common/src/market/balance_oracle_configuration.rs new file mode 100644 index 00000000..1671aaaf --- /dev/null +++ b/common/src/market/balance_oracle_configuration.rs @@ -0,0 +1,160 @@ +use std::marker::PhantomData; + +use near_sdk::{near, AccountId, Gas, Promise}; + +use crate::{ + asset::{AssetClass, BorrowAsset, CollateralAsset, FungibleAssetAmount}, + number::Decimal, + oracle::pyth::{self, ext_pyth, OracleResponse, PriceIdentifier}, +}; + +#[derive(Clone, Debug)] +#[near(serializers = [json, borsh])] +pub struct BalanceOracleConfiguration { + pub account_id: AccountId, + pub collateral_asset_price_id: PriceIdentifier, + pub collateral_asset_decimals: i32, + pub borrow_asset_price_id: PriceIdentifier, + pub borrow_asset_decimals: i32, + pub price_maximum_age_s: u32, +} + +impl BalanceOracleConfiguration { + // Usually seems to take 1.64 TGas. + pub const GAS_RETRIEVE_PRICE_PAIR: Gas = Gas::from_tgas(3); + + pub fn retrieve_price_pair(&self) -> Promise { + ext_pyth::ext(self.account_id.clone()) + .with_static_gas(Self::GAS_RETRIEVE_PRICE_PAIR) + .list_ema_prices_no_older_than( + vec![self.borrow_asset_price_id, self.collateral_asset_price_id], + u64::from(self.price_maximum_age_s), + ) + } + + /// # Errors + /// + /// If the response from the oracle does not contain valid prices for the + /// configured asset pair. + pub fn create_price_pair( + &self, + oracle_response: &OracleResponse, + ) -> Result { + Ok(PricePair::new( + oracle_response + .get(&self.collateral_asset_price_id) + .and_then(|o| o.as_ref()) + .ok_or(error::RetrievalError::MissingPrice)?, + self.collateral_asset_decimals, + oracle_response + .get(&self.borrow_asset_price_id) + .and_then(|o| o.as_ref()) + .ok_or(error::RetrievalError::MissingPrice)?, + self.borrow_asset_decimals, + )?) + } +} + +#[derive(Clone, Debug)] +pub struct Price { + _asset: PhantomData, + price: u128, + confidence: u128, + power_of_10: Decimal, +} + +mod error { + use thiserror::Error; + + #[derive(Clone, Debug, Error)] + #[error("Error retrieving price: {0}")] + pub enum RetrievalError { + #[error("Missing price")] + MissingPrice, + #[error(transparent)] + PriceData(#[from] PriceDataError), + } + + #[derive(Clone, Debug, Error)] + #[error("Bad price data: {0}")] + pub enum PriceDataError { + #[error("Reported negative price")] + NegativePrice, + #[error("Confidence interval too large")] + ConfidenceIntervalTooLarge, + #[error("Exponent too large")] + ExponentTooLarge, + } +} + +// Maximum number of fully-representable whole digits in 384 bits: floor(log_10(2^384)) = 115 +// Maximum number of digits in a 64-bit integer: floor(log_10(2^64)) + 1 = 20 +const MAXIMUM_POSITIVE_EXPONENT: i32 = 115 - 20; + +fn from_pyth_price( + pyth_price: &pyth::Price, + decimals: i32, +) -> Result, error::PriceDataError> { + let Ok(price) = u64::try_from(pyth_price.price.0) else { + return Err(error::PriceDataError::NegativePrice); + }; + + if pyth_price.conf.0 >= price { + return Err(error::PriceDataError::ConfidenceIntervalTooLarge); + } + + if pyth_price.expo > MAXIMUM_POSITIVE_EXPONENT { + return Err(error::PriceDataError::ExponentTooLarge); + } + + // TODO: If price falls below minimum representation, it will get truncated to zero. + // Is this okay? + + Ok(Price { + _asset: PhantomData, + price: u128::from(price), + confidence: u128::from(pyth_price.conf.0), + power_of_10: Decimal::TEN.pow(pyth_price.expo - decimals), + }) +} + +impl Price { + pub fn upper_bound(&self) -> Decimal { + (self.price + self.confidence) * self.power_of_10 + } + + pub fn lower_bound(&self) -> Decimal { + (self.price - self.confidence) * self.power_of_10 + } + + pub fn value_optimistic(&self, amount: FungibleAssetAmount) -> Decimal { + Decimal::from(amount) * self.upper_bound() + } + + pub fn value_pessimistic(&self, amount: FungibleAssetAmount) -> Decimal { + Decimal::from(amount) * self.lower_bound() + } +} + +#[derive(Clone, Debug)] +pub struct PricePair { + pub collateral_asset_price: Price, + pub borrow_asset_price: Price, +} + +impl PricePair { + /// # Errors + /// + /// - If the price data are invalid. + pub fn new( + collateral_price: &pyth::Price, + collateral_decimals: i32, + borrow_price: &pyth::Price, + borrow_decimals: i32, + ) -> Result { + Ok(Self { + collateral_asset_price: from_pyth_price(collateral_price, collateral_decimals)?, + borrow_asset_price: from_pyth_price(borrow_price, borrow_decimals)?, + }) + } +} diff --git a/common/src/market/configuration.rs b/common/src/market/configuration.rs index 711a53dd..7b757684 100644 --- a/common/src/market/configuration.rs +++ b/common/src/market/configuration.rs @@ -6,41 +6,46 @@ use crate::{ }, borrow::{BorrowPosition, BorrowStatus, LiquidationReason}, fee::{Fee, TimeBasedFee}, + interest_rate_strategy::InterestRateStrategy, number::Decimal, + time_chunk::TimeChunkConfiguration, }; -use super::{OraclePriceProof, YieldWeights}; +use super::{BalanceOracleConfiguration, PricePair, YieldWeights}; #[derive(Clone, Debug)] #[near(serializers = [json, borsh])] pub struct MarketConfiguration { + pub time_chunk_configuration: TimeChunkConfiguration, pub borrow_asset: FungibleAsset, pub collateral_asset: FungibleAsset, - pub balance_oracle_account_id: AccountId, - pub minimum_initial_collateral_ratio: Decimal, - pub minimum_collateral_ratio_per_borrow: Decimal, + pub balance_oracle: BalanceOracleConfiguration, + pub borrow_mcr_initial: Decimal, + pub borrow_mcr: Decimal, /// How much of the deposited principal may be lent out (up to 100%)? /// This is a matter of protection for supply providers. /// Set to 99% for starters. - pub maximum_borrow_asset_usage_ratio: Decimal, + pub borrow_asset_maximum_usage_ratio: Decimal, /// The origination fee is a one-time amount added to the principal of the /// borrow. That is to say, the origination fee is denominated in units of /// the borrow asset and is paid by the borrowing account during repayment /// (or liquidation). pub borrow_origination_fee: Fee, - pub borrow_annual_maintenance_fee: Fee, - pub maximum_borrow_duration_ms: Option, - pub minimum_borrow_amount: BorrowAssetAmount, - pub maximum_borrow_amount: BorrowAssetAmount, - pub supply_withdrawal_fee: TimeBasedFee, + pub borrow_interest_rate_strategy: InterestRateStrategy, + pub borrow_maximum_duration_ms: Option, + pub borrow_minimum_amount: BorrowAssetAmount, + pub borrow_maximum_amount: BorrowAssetAmount, + pub supply_withdrawal_fee: TimeBasedFee, + pub supply_maximum_amount: Option, pub yield_weights: YieldWeights, + pub protocol_account_id: AccountId, /// How far below market rate to accept liquidation? This is effectively the liquidator's spread. /// /// For example, if a 100USDC borrow is (under)collateralized with $110 of - /// NEAR, a "maximum liquidator spread" of 10% would mean that a liquidator - /// could liquidate this borrow by sending 109USDC, netting the liquidator - /// ($110 - $100) * 10% = $1 of NEAR. - pub maximum_liquidator_spread: Decimal, + /// NEAR, a "maximum liquidator spread" of 1% would mean that a liquidator + /// could liquidate this borrow by sending 108.9USDC, netting the liquidator + /// $110 * 1% = $1.1 of NEAR. + pub liquidation_maximum_spread: Decimal, } pub mod error { @@ -79,26 +84,26 @@ impl MarketConfiguration { /// /// If the configuration is invalid. pub fn validate(&self) -> Result<(), error::ConfigurationValidationError> { - if self.minimum_initial_collateral_ratio < 1u32 { - return Err(error::out_of_bounds("minimum_initial_collateral_ratio")); + if self.borrow_mcr_initial < 1u32 { + return Err(error::out_of_bounds("borrow_mcr_initial")); } - if self.minimum_collateral_ratio_per_borrow < 1u32 { - return Err(error::out_of_bounds("minimum_collateral_ratio_per_borrow")); + if self.borrow_mcr < 1u32 { + return Err(error::out_of_bounds("borrow_mcr")); } - if self.maximum_borrow_asset_usage_ratio.is_zero() - || self.maximum_borrow_asset_usage_ratio > 1u32 + if self.borrow_asset_maximum_usage_ratio.is_zero() + || self.borrow_asset_maximum_usage_ratio > 1u32 { - return Err(error::out_of_bounds("maximum_borrow_asset_usage_ratio")); + return Err(error::out_of_bounds("borrow_asset_maximum_usage_ratio")); } - if self.maximum_borrow_amount < self.minimum_borrow_amount { - return Err(error::out_of_bounds("maximum_borrow_amount")); + if self.borrow_maximum_amount < self.borrow_minimum_amount { + return Err(error::out_of_bounds("borrow_maximum_amount")); } - if self.maximum_liquidator_spread >= 1u32 { - return Err(error::out_of_bounds("maximum_liquidator_spread")); + if self.liquidation_maximum_spread >= 1u32 { + return Err(error::out_of_bounds("liquidation_maximum_spread")); } Ok(()) @@ -107,10 +112,10 @@ impl MarketConfiguration { pub fn borrow_status( &self, borrow_position: &BorrowPosition, - oracle_price_proof: &OraclePriceProof, + price_pair: &PricePair, block_timestamp_ms: u64, ) -> BorrowStatus { - if !self.is_within_minimum_collateral_ratio(borrow_position, oracle_price_proof) { + if !self.is_within_minimum_collateral_ratio(borrow_position, price_pair) { return BorrowStatus::Liquidation(LiquidationReason::Undercollateralization); } @@ -126,23 +131,22 @@ impl MarketConfiguration { borrow_position: &BorrowPosition, block_timestamp_ms: u64, ) -> bool { - if let Some(U64(maximum_duration_ms)) = self.maximum_borrow_duration_ms { - borrow_position - .started_at_block_timestamp_ms - .and_then(|U64(started_at_ms)| block_timestamp_ms.checked_sub(started_at_ms)) - .map_or(true, |duration_ms| duration_ms <= maximum_duration_ms) - } else { - true - } + let Some(U64(maximum_duration_ms)) = self.borrow_maximum_duration_ms else { + return true; + }; + borrow_position + .started_at_block_timestamp_ms + .and_then(|U64(started_at_ms)| block_timestamp_ms.checked_sub(started_at_ms)) + .is_none_or(|duration_ms| duration_ms <= maximum_duration_ms) } pub fn is_within_minimum_initial_collateral_ratio( &self, borrow_position: &BorrowPosition, - oracle_price_proof: &OraclePriceProof, + oracle_price_proof: &PricePair, ) -> bool { is_within_mcr( - &self.minimum_initial_collateral_ratio, + &self.borrow_mcr_initial, borrow_position, oracle_price_proof, ) @@ -151,43 +155,66 @@ impl MarketConfiguration { pub fn is_within_minimum_collateral_ratio( &self, borrow_position: &BorrowPosition, - oracle_price_proof: &OraclePriceProof, + oracle_price_proof: &PricePair, ) -> bool { - is_within_mcr( - &self.minimum_collateral_ratio_per_borrow, - borrow_position, - oracle_price_proof, - ) + is_within_mcr(&self.borrow_mcr, borrow_position, oracle_price_proof) } pub fn minimum_acceptable_liquidation_amount( &self, amount: CollateralAssetAmount, - oracle_price_proof: &OraclePriceProof, - ) -> BorrowAssetAmount { - // minimum_acceptable_amount = collateral_amount * (1 - maximum_liquidator_spread) * collateral_price / borrow_price - BorrowAssetAmount::new( - ((1u32 - &self.maximum_liquidator_spread) * &oracle_price_proof.collateral_asset_price - / &oracle_price_proof.borrow_asset_price - * amount.as_u128()) - .to_u128_ceil() - .unwrap(), - ) + price_pair: &PricePair, + ) -> Option { + ((1u32 - self.liquidation_maximum_spread) + * price_pair.collateral_asset_price.value_pessimistic(amount) + / price_pair.borrow_asset_price.upper_bound()) + .to_u128_ceil() + .map(BorrowAssetAmount::new) } } -fn is_within_mcr( - mcr: &Decimal, - borrow_position: &BorrowPosition, - OraclePriceProof { - collateral_asset_price, - borrow_asset_price, - }: &OraclePriceProof, -) -> bool { - let scaled_collateral_value = - borrow_position.collateral_asset_deposit.as_u128() * collateral_asset_price; - let scaled_borrow_value = - borrow_position.get_total_borrow_asset_liability().as_u128() * borrow_asset_price * mcr; - - scaled_collateral_value >= scaled_borrow_value +fn is_within_mcr(mcr: &Decimal, borrow_position: &BorrowPosition, price_pair: &PricePair) -> bool { + let scaled_collateral_value = price_pair + .collateral_asset_price + .value_pessimistic(borrow_position.collateral_asset_deposit); + let scaled_borrow_value = price_pair + .borrow_asset_price + .value_optimistic(borrow_position.get_total_borrow_asset_liability()); + + scaled_collateral_value >= scaled_borrow_value * mcr +} + +#[cfg(test)] +mod tests { + use crate::{borrow::InterestAccumulationProof, dec, oracle::pyth}; + + use super::*; + + #[test] + fn test_is_within_mcr() { + let mut b = BorrowPosition::new(0); + b.increase_collateral_asset_deposit(121u128.into()); + b.increase_borrow_asset_principal(InterestAccumulationProof::test(), 100u128.into(), 0); + assert!(is_within_mcr( + &dec!("1.2"), + &b, + &PricePair::new( + &pyth::Price { + price: near_sdk::json_types::I64(10000), + conf: U64(1), + expo: -4, + publish_time: 0, + }, + 18, + &pyth::Price { + price: near_sdk::json_types::I64(10000), + conf: U64(1), + expo: -4, + publish_time: 0, + }, + 18, + ) + .unwrap() + )); + } } diff --git a/common/src/market/external.rs b/common/src/market/external.rs index dd932d93..79f72d2f 100644 --- a/common/src/market/external.rs +++ b/common/src/market/external.rs @@ -1,14 +1,26 @@ -use near_sdk::{json_types::U128, AccountId, Promise, PromiseOrValue}; +use near_sdk::{near, AccountId, Promise, PromiseOrValue}; use crate::{ asset::{BorrowAssetAmount, CollateralAssetAmount}, borrow::{BorrowPosition, BorrowStatus}, + number::Decimal, + oracle::pyth::OracleResponse, + snapshot::Snapshot, static_yield::StaticYieldRecord, supply::SupplyPosition, withdrawal_queue::{WithdrawalQueueStatus, WithdrawalRequestStatus}, }; -use super::{BorrowAssetMetrics, MarketConfiguration, OraclePriceProof}; +use super::{BorrowAssetMetrics, MarketConfiguration}; + +#[derive(Debug, Clone, Copy, Default)] +#[near(serializers = [json, borsh])] +pub enum HarvestYieldMode { + #[default] + Default, + Compounding, + SnapshotLimit(u32), +} #[near_sdk::ext_contract(ext_market)] pub trait MarketExternalInterface { @@ -17,51 +29,37 @@ pub trait MarketExternalInterface { // ======================== fn get_configuration(&self) -> MarketConfiguration; - /// Takes current balance as an argument so that it can be called as view. - /// `borrow_asset_balance` should be retrieved from the borrow asset - /// contract specified in the market configuration. - fn get_borrow_asset_metrics( - &self, - borrow_asset_balance: BorrowAssetAmount, - ) -> BorrowAssetMetrics; - - // TODO: Decide how to work with remote balances: - // Option 1: - // Balance oracle calls a function directly. - // Option 2: Balance oracle creates/maintains separate NEP-141-ish contracts that track remote - // balances. - - fn list_borrows(&self, offset: Option, count: Option) -> Vec; - fn list_supplys(&self, offset: Option, count: Option) -> Vec; + fn get_current_snapshot(&self) -> &Snapshot; + fn get_finalized_snapshots_len(&self) -> u32; + fn list_finalized_snapshots(&self, offset: Option, count: Option) -> Vec<&Snapshot>; + fn get_borrow_asset_metrics(&self) -> BorrowAssetMetrics; // ================== // BORROW FUNCTIONS // ================== // ft_on_receive :: where msg = Collateralize - fn collateralize_native(&mut self); // ft_on_receive :: where msg = Repay - fn repay_native(&mut self) -> PromiseOrValue<()>; + /// This function may report fees slightly inaccurately. This is because + /// the function has to estimate what fees will be applied between the last + /// market snapshot and the (present) time when the function was called. fn get_borrow_position(&self, account_id: AccountId) -> Option; /// This is just a read-only function, so we don't care about validating /// the provided price data. fn get_borrow_status( &self, account_id: AccountId, - oracle_price_proof: OraclePriceProof, + oracle_response: OracleResponse, ) -> Option; - fn borrow( - &mut self, - amount: BorrowAssetAmount, - oracle_price_proof: OraclePriceProof, - ) -> Promise; - fn withdraw_collateral( - &mut self, - amount: U128, - oracle_price_proof: Option, - ) -> Promise; + fn borrow(&mut self, amount: BorrowAssetAmount) -> Promise; + fn withdraw_collateral(&mut self, amount: CollateralAssetAmount) -> Promise; + + /// Applies interest to the predecessor's borrow record. + /// Not likely to be used in real life, since there it does not affect the + /// final interest calculation, and rounds fractional interest UP. + fn apply_interest(&mut self, snapshot_limit: Option); // ================ // SUPPLY FUNCTIONS @@ -70,13 +68,11 @@ pub trait MarketExternalInterface { // don't yet support supplying of remote assets. // ft_on_receive :: where msg = Supply - fn supply_native(&mut self); fn get_supply_position(&self, account_id: AccountId) -> Option; - fn create_supply_withdrawal_request(&mut self, amount: U128); + fn create_supply_withdrawal_request(&mut self, amount: BorrowAssetAmount); fn cancel_supply_withdrawal_request(&mut self); - /// Auto-harvests yield. fn execute_next_supply_withdrawal_request(&mut self) -> PromiseOrValue<()>; fn get_supply_withdrawal_request_status( &self, @@ -84,24 +80,28 @@ pub trait MarketExternalInterface { ) -> Option; fn get_supply_withdrawal_queue_status(&self) -> WithdrawalQueueStatus; - fn harvest_yield(&mut self); + /// Claim any distributed yield to the supply record. + /// If mode is set to `compounding`, the all of the yield (including any + /// harvested in previous, non-compounding `harvest_yield` calls) is + /// deposited to the supply record, so it will contribute to future yield + /// calculations. + fn harvest_yield(&mut self, mode: Option) -> BorrowAssetAmount; + + /// This value is an *expected average over time*. + /// Supply positions actually earn all of their yield the instant it is + /// distributed. + fn get_last_yield_rate(&self) -> Decimal; // ===================== // LIQUIDATION FUNCTIONS // ===================== // ft_on_receive :: where msg = Liquidate { account_id } - fn liquidate_native( - &mut self, - account_id: AccountId, - oracle_price_proof: OraclePriceProof, - ) -> Promise; // ================= // YIELD FUNCTIONS // ================= fn get_static_yield(&self, account_id: AccountId) -> Option; - fn withdraw_supply_yield(&mut self, amount: Option) -> Promise; fn withdraw_static_yield( &mut self, borrow_asset_amount: Option, diff --git a/common/src/market/impl.rs b/common/src/market/impl.rs index 88f03707..cdda2789 100644 --- a/common/src/market/impl.rs +++ b/common/src/market/impl.rs @@ -1,32 +1,26 @@ -use borsh::BorshDeserialize; -use near_sdk::{ - collections::{LookupMap, UnorderedMap, Vector}, - env, near, require, AccountId, BorshStorageKey, IntoStorageKey, -}; +use near_sdk::{collections::LookupMap, env, near, AccountId, BorshStorageKey, IntoStorageKey}; use crate::{ - asset::{ - AssetClass, BorrowAsset, BorrowAssetAmount, CollateralAssetAmount, FungibleAssetAmount, - }, - balance_log::{search_balance_logs, BalanceLog, SearchResult}, - borrow::BorrowPosition, - chain_time::ChainTime, + asset::BorrowAssetAmount, + borrow::{BorrowPosition, BorrowPositionGuard, BorrowPositionRef}, + chunked_append_only_list::ChunkedAppendOnlyList, + event::MarketEvent, market::MarketConfiguration, number::Decimal, + snapshot::Snapshot, static_yield::StaticYieldRecord, - supply::SupplyPosition, + supply::{SupplyPosition, SupplyPositionGuard, SupplyPositionRef}, withdrawal_queue::{error::WithdrawalQueueLockError, WithdrawalQueue}, }; -use super::OraclePriceProof; +use super::WithdrawalResolution; #[derive(BorshStorageKey)] #[near] enum StorageKey { SupplyPositions, BorrowPositions, - TotalBorrowAssetDepositedLog, - BorrowAssetYieldDistributionLog, + FinalizedSnapshots, WithdrawalQueue, StaticYield, } @@ -37,10 +31,11 @@ pub struct Market { pub configuration: MarketConfiguration, pub borrow_asset_deposited: BorrowAssetAmount, pub borrow_asset_in_flight: BorrowAssetAmount, - pub supply_positions: UnorderedMap, - pub borrow_positions: UnorderedMap, - pub total_borrow_asset_deposited_log: Vector>, - pub borrow_asset_yield_distribution_log: Vector>, + pub borrow_asset_borrowed: BorrowAssetAmount, + pub(crate) supply_positions: LookupMap, + pub(crate) borrow_positions: LookupMap, + pub current_snapshot: Snapshot, + pub finalized_snapshots: ChunkedAppendOnlyList, pub withdrawal_queue: WithdrawalQueue, pub static_yield: LookupMap, } @@ -61,34 +56,155 @@ impl Market { .concat() }; } - Self { + + let first_snapshot = Snapshot { + time_chunk: configuration.time_chunk_configuration.previous(), + end_timestamp_ms: env::block_timestamp_ms().into(), + deposited: 0.into(), + borrowed: 0.into(), + yield_distribution: BorrowAssetAmount::zero(), + interest_rate: configuration + .borrow_interest_rate_strategy + .at(Decimal::ZERO), + }; + let current_snapshot = Snapshot { + time_chunk: configuration.time_chunk_configuration.now(), + ..first_snapshot.clone() + }; + let mut self_ = Self { prefix: prefix.clone(), configuration, borrow_asset_deposited: 0.into(), borrow_asset_in_flight: 0.into(), - supply_positions: UnorderedMap::new(key!(SupplyPositions)), - borrow_positions: UnorderedMap::new(key!(BorrowPositions)), - total_borrow_asset_deposited_log: Vector::new(key!(TotalBorrowAssetDepositedLog)), - borrow_asset_yield_distribution_log: Vector::new(key!(BorrowAssetYieldDistributionLog)), + borrow_asset_borrowed: 0.into(), + supply_positions: LookupMap::new(key!(SupplyPositions)), + borrow_positions: LookupMap::new(key!(BorrowPositions)), + current_snapshot, + finalized_snapshots: ChunkedAppendOnlyList::new(key!(FinalizedSnapshots)), withdrawal_queue: WithdrawalQueue::new(key!(WithdrawalQueue)), static_yield: LookupMap::new(key!(StaticYield)), + }; + + self_.finalized_snapshots.push(first_snapshot); + + self_ + } + + pub fn get_last_finalized_snapshot(&self) -> &Snapshot { + #[allow(clippy::unwrap_used, reason = "Snapshots are never empty")] + self.finalized_snapshots + .get(self.finalized_snapshots.len() - 1) + .unwrap() + } + + pub fn snapshot(&mut self) -> u32 { + self.snapshot_with_yield_distribution(BorrowAssetAmount::zero()) + } + + fn snapshot_with_yield_distribution(&mut self, yield_distribution: BorrowAssetAmount) -> u32 { + let time_chunk = self.configuration.time_chunk_configuration.now(); + + // If still in current time chunk, just update the current snapshot. + if self.current_snapshot.time_chunk == time_chunk { + self.current_snapshot.end_timestamp_ms = env::block_timestamp_ms().into(); + self.current_snapshot.deposited = self.borrow_asset_deposited; + self.current_snapshot.borrowed = self.borrow_asset_borrowed; + self.current_snapshot + .yield_distribution + .join(yield_distribution); + self.current_snapshot.interest_rate = self + .configuration + .borrow_interest_rate_strategy + .at(self.current_snapshot.usage_ratio()); + } else { + // Otherwise, finalize the current snapshot and create a new one. + let mut snapshot = Snapshot { + time_chunk, + yield_distribution, + deposited: self.borrow_asset_deposited, + borrowed: self.borrow_asset_borrowed, + end_timestamp_ms: env::block_timestamp_ms().into(), + interest_rate: Decimal::ZERO, + }; + snapshot.interest_rate = self + .configuration + .borrow_interest_rate_strategy + .at(snapshot.usage_ratio()); + std::mem::swap(&mut snapshot, &mut self.current_snapshot); + MarketEvent::SnapshotFinalized { + index: self.finalized_snapshots.len(), + snapshot: snapshot.clone(), + } + .emit(); + self.finalized_snapshots.push(snapshot); } + + self.finalized_snapshots.len() } - pub fn get_borrow_asset_available_to_borrow( - &self, - current_contract_balance: BorrowAssetAmount, - ) -> BorrowAssetAmount { - let must_retain = ((1u32 - &self.configuration.maximum_borrow_asset_usage_ratio) - * self.borrow_asset_deposited.as_u128()) + pub fn get_borrow_asset_available_to_borrow(&self) -> BorrowAssetAmount { + #[allow( + clippy::unwrap_used, + reason = "Factor is guaranteed to be <=1, so value must still fit in u128" + )] + let must_retain = ((1u32 - self.configuration.borrow_asset_maximum_usage_ratio) + * Decimal::from(self.borrow_asset_deposited)) .to_u128_ceil() .unwrap(); - let known_available = current_contract_balance - .as_u128() - .saturating_sub(self.borrow_asset_in_flight.as_u128()); + u128::from(self.borrow_asset_deposited) + .saturating_sub(u128::from(self.borrow_asset_borrowed)) + .saturating_sub(u128::from(self.borrow_asset_in_flight)) + .saturating_sub(must_retain) + .into() + } + + pub fn supply_position_ref(&self, account_id: AccountId) -> Option> { + self.supply_positions + .get(&account_id) + .map(|position| SupplyPositionRef::new(self, account_id, position)) + } + + pub fn supply_position_guard(&mut self, account_id: AccountId) -> Option { + self.supply_positions + .get(&account_id) + .map(|position| SupplyPositionGuard::new(self, account_id, position)) + } + + pub fn get_or_create_supply_position_guard( + &mut self, + account_id: AccountId, + ) -> SupplyPositionGuard { + let position = self + .supply_positions + .get(&account_id) + .unwrap_or_else(|| SupplyPosition::new(self.snapshot())); + + SupplyPositionGuard::new(self, account_id, position) + } + + pub fn borrow_position_ref(&self, account_id: AccountId) -> Option> { + self.borrow_positions + .get(&account_id) + .map(|position| BorrowPositionRef::new(self, account_id, position)) + } + + pub fn borrow_position_guard(&mut self, account_id: AccountId) -> Option { + self.borrow_positions + .get(&account_id) + .map(|position| BorrowPositionGuard::new(self, account_id, position)) + } - known_available.saturating_sub(must_retain).into() + pub fn get_or_create_borrow_position_guard( + &mut self, + account_id: AccountId, + ) -> BorrowPositionGuard { + let position = self + .borrow_positions + .get(&account_id) + .unwrap_or_else(|| BorrowPosition::new(self.snapshot())); + + BorrowPositionGuard::new(self, account_id, position) } /// # Errors @@ -96,23 +212,19 @@ impl Market { /// - If the withdrawal queue is empty. pub fn try_lock_next_withdrawal_request( &mut self, - ) -> Result, WithdrawalQueueLockError> { + ) -> Result, WithdrawalQueueLockError> { let (account_id, requested_amount) = self.withdrawal_queue.try_lock()?; let Some((amount, mut supply_position)) = - self.supply_positions - .get(&account_id) + self.supply_position_guard(account_id) .and_then(|supply_position| { // Cap withdrawal amount to deposit amount at most. let amount = supply_position + .inner() .get_borrow_asset_deposit() .min(requested_amount); - if amount.is_zero() { - None - } else { - Some((amount, supply_position)) - } + (!amount.is_zero()).then_some((amount, supply_position)) }) else { // The amount that the entry is eligible to withdraw is zero, so skip it. @@ -122,67 +234,51 @@ impl Market { return Ok(None); }; - self.record_supply_position_borrow_asset_withdrawal(&mut supply_position, amount); + let proof = supply_position.accumulate_yield(); + let resolution = + supply_position.record_withdrawal(proof, amount, env::block_timestamp_ms()); - self.supply_positions.insert(&account_id, &supply_position); - - Ok(Some((account_id, amount))) + Ok(Some(resolution)) } - fn log_total_borrow_asset_deposited(&mut self, amount: BorrowAssetAmount) { - let now = ChainTime::now(); - - if let Some((last_index, mut last)) = self - .total_borrow_asset_deposited_log - .len() - .checked_sub(1) - .and_then(|last_index| { - self.total_borrow_asset_deposited_log - .get(last_index) - .filter(|log| log.chain_time == now) - .map(|log| (last_index, log)) - }) - { - last.amount = amount; - self.total_borrow_asset_deposited_log - .replace(last_index, &last); - } else { - self.total_borrow_asset_deposited_log - .push(&BalanceLog::new(now, amount)); - } + pub fn record_borrow_asset_protocol_yield(&mut self, amount: BorrowAssetAmount) { + let mut yield_record = self + .static_yield + .get(&self.configuration.protocol_account_id) + .unwrap_or_default(); + + yield_record.borrow_asset.join(amount); + + self.static_yield + .insert(&self.configuration.protocol_account_id, &yield_record); } - fn record_borrow_asset_yield_distribution(&mut self, mut amount: BorrowAssetAmount) { + pub fn record_borrow_asset_yield_distribution(&mut self, mut amount: BorrowAssetAmount) { // Sanity. if amount.is_zero() { return; } + MarketEvent::GlobalYieldDistributed { + borrow_asset_amount: amount, + } + .emit(); + // First, static yield. - let total_weight = u128::from(u16::from(self.configuration.yield_weights.total_weight())); - let total_amount = amount.as_u128(); - if total_weight != 0 { - for (account_id, share) in &self.configuration.yield_weights.r#static { - #[allow(clippy::unwrap_used)] - let portion = amount - .split( - // Safety: - // total_weight is guaranteed >0 and <=u16::MAX - // share is guaranteed <=u16::MAX - // Therefore, as long as total_amount <= u128::MAX / u16::MAX, this will never overflow. - // u128::MAX / u16::MAX == 5192376087906286159508272029171713 (0x10001000100010001000100010001) - // With 24 decimals, that's about 5,192,376,087 tokens. - // TODO: Fix. - total_amount - .checked_mul(u128::from(*share)) - .unwrap() // TODO: This one might panic. - / total_weight, // This will never panic: is never div0 - ) + let total_weight = + Decimal::from(u16::from(self.configuration.yield_weights.total_weight())); + let total_amount = Decimal::from(u128::from(amount)); + let amount_per_weight = total_amount / total_weight; + if !total_weight.is_zero() { + for (account_id, share_weight) in &self.configuration.yield_weights.r#static { + #[allow(clippy::unwrap_used, reason = "share_weight / total_weight <= 1")] + let share = amount + .split((*share_weight * amount_per_weight).to_u128_floor().unwrap()) // Safety: - // Guaranteed share <= total_weight - // Guaranteed sum(shares) == total_weight - // Guaranteed sum(floor(total_amount * share / total_weight) for each share in shares) <= total_amount + // Guaranteed share_weight <= total_weight + // Guaranteed sum(share_weights) == total_weight + // Guaranteed sum(floor(total_amount * share_weight / total_weight) for each share_weight in share_weights) <= total_amount // Therefore this should never panic. .unwrap(); @@ -190,318 +286,26 @@ impl Market { // Assuming borrow_asset is implemented correctly: // this only panics if the circulating supply is somehow >u128::MAX // and we have somehow obtained >u128::MAX amount. - // TODO: Include warning somewhere about tokens with >u128::MAX supply. + // + // NOTE: This is not necessary when working with NEP-141 + // tokens, which are required by standard to use 128-bit balances. // // Otherwise, borrow_asset is implemented incorrectly. // TODO: If that is the case, how to deal? - #[allow(clippy::unwrap_used)] - yield_record.borrow_asset.join(portion).unwrap(); + // + // Probably, it is okay to ignore this case. We can assume + // that the configuration will only specify + // correctly-implemented token contracts. + #[allow( + clippy::unwrap_used, + reason = "Assume borrow asset is implemented correctly" + )] + yield_record.borrow_asset.join(share).unwrap(); self.static_yield.insert(account_id, &yield_record); } } // Next, dynamic (supply-based) yield. - let now = ChainTime::now(); - - if let Some((last_index, mut last)) = self - .borrow_asset_yield_distribution_log - .len() - .checked_sub(1) - .and_then(|last_index| { - self.borrow_asset_yield_distribution_log - .get(last_index) - .filter(|log| log.chain_time == now) - .map(|log| (last_index, log)) - }) - { - last.amount = amount; - self.borrow_asset_yield_distribution_log - .replace(last_index, &last); - } else { - self.borrow_asset_yield_distribution_log - .push(&BalanceLog::new(now, amount)); - } - } - - pub fn record_supply_position_borrow_asset_deposit( - &mut self, - supply_position: &mut SupplyPosition, - amount: BorrowAssetAmount, - ) { - self.accumulate_yield_on_supply_position(supply_position, ChainTime::now()); - supply_position - .increase_borrow_asset_deposit(amount) - .unwrap_or_else(|| env::panic_str("Supply position borrow asset overflow")); - - self.borrow_asset_deposited - .join(amount) - .unwrap_or_else(|| env::panic_str("Borrow asset deposited overflow")); - - self.log_total_borrow_asset_deposited(self.borrow_asset_deposited); - } - - pub fn record_supply_position_borrow_asset_withdrawal( - &mut self, - supply_position: &mut SupplyPosition, - amount: BorrowAssetAmount, - ) -> BorrowAssetAmount { - self.accumulate_yield_on_supply_position(supply_position, ChainTime::now()); - let withdrawn = supply_position - .decrease_borrow_asset_deposit(amount) - .unwrap_or_else(|| env::panic_str("Supply position borrow asset underflow")); - - self.borrow_asset_deposited - .split(amount) - .unwrap_or_else(|| env::panic_str("Borrow asset deposited underflow")); - - self.log_total_borrow_asset_deposited(self.borrow_asset_deposited); - - withdrawn - } - - pub fn record_borrow_position_collateral_asset_deposit( - &mut self, - borrow_position: &mut BorrowPosition, - amount: CollateralAssetAmount, - ) { - borrow_position - .increase_collateral_asset_deposit(amount) - .unwrap_or_else(|| env::panic_str("Borrow position collateral asset overflow")); - } - - pub fn record_borrow_position_collateral_asset_withdrawal( - &mut self, - borrow_position: &mut BorrowPosition, - amount: CollateralAssetAmount, - ) { - borrow_position - .decrease_collateral_asset_deposit(amount) - .unwrap_or_else(|| env::panic_str("Borrow position collateral asset underflow")); - } - - pub fn record_borrow_position_borrow_asset_in_flight_start( - &mut self, - borrow_position: &mut BorrowPosition, - amount: BorrowAssetAmount, - fees: BorrowAssetAmount, - ) { - self.borrow_asset_in_flight - .join(amount) - .unwrap_or_else(|| env::panic_str("Borrow asset in flight amount overflow")); - borrow_position - .temporary_lock - .join(amount) - .and_then(|()| borrow_position.temporary_lock.join(fees)) - .unwrap_or_else(|| env::panic_str("Borrow position in flight amount overflow")); - } - - pub fn record_borrow_position_borrow_asset_in_flight_end( - &mut self, - borrow_position: &mut BorrowPosition, - amount: BorrowAssetAmount, - fees: BorrowAssetAmount, - ) { - // This should never panic, because a given amount of in-flight borrow - // asset should always be added before it is removed. - self.borrow_asset_in_flight - .split(amount) - .unwrap_or_else(|| env::panic_str("Borrow asset in flight amount underflow")); - borrow_position - .temporary_lock - .split(amount) - .and_then(|_| borrow_position.temporary_lock.split(fees)) - .unwrap_or_else(|| env::panic_str("Borrow position in flight amount underflow")); - } - - pub fn record_borrow_position_borrow_asset_withdrawal( - &mut self, - borrow_position: &mut BorrowPosition, - amount: BorrowAssetAmount, - fees: BorrowAssetAmount, - ) { - borrow_position - .borrow_asset_fees - .accumulate_fees(fees, ChainTime::now()); - borrow_position - .increase_borrow_asset_principal(amount, env::block_timestamp_ms()) - .unwrap_or_else(|| env::panic_str("Increase borrow asset principal overflow")); - } - - pub fn record_borrow_position_borrow_asset_repay( - &mut self, - borrow_position: &mut BorrowPosition, - amount: BorrowAssetAmount, - ) { - let liability_reduction = borrow_position - .reduce_borrow_asset_liability(amount) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); - - require!( - liability_reduction.amount_remaining.is_zero(), - "Overpayment not supported", - ); - - self.record_borrow_asset_yield_distribution(liability_reduction.amount_to_fees); - } - - /// In order for yield calculations to be accurate, this function MUST - /// BE CALLED every time a supply position's deposit changes. This - /// requirement is largely met by virtue of the fact that - /// `SupplyPosition->borrow_asset_deposit` is a private field and can only - /// be modified via `Self::record_supply_position_*` methods. - pub fn accumulate_yield_on_supply_position( - &self, - supply_position: &mut SupplyPosition, - until: ChainTime, - ) { - let (accumulated, last_epoch_height) = self.calculate_supply_position_yield( - &self.borrow_asset_yield_distribution_log, - supply_position.borrow_asset_yield.last_updated, - supply_position.get_borrow_asset_deposit(), - until, - ); - - supply_position - .borrow_asset_yield - .accumulate_yield(accumulated, last_epoch_height); - } - - #[allow(clippy::missing_panics_doc)] - pub fn calculate_supply_position_yield( - &self, - yield_distribution_logs: &Vector>, - last_updated: ChainTime, - borrow_asset_deposited_during_interval: BorrowAssetAmount, - until: ChainTime, - ) -> (FungibleAssetAmount, ChainTime) { - if yield_distribution_logs.is_empty() || self.total_borrow_asset_deposited_log.is_empty() { - return (0.into(), last_updated); - } - - let (starting_index, starting_chain_time) = - match search_balance_logs(yield_distribution_logs, last_updated) { - SearchResult::Found { index, log } => (index, log.chain_time), - SearchResult::NotFound { index_below } => { - let index_after = index_below.map_or(0, |i| i + 1); - match yield_distribution_logs - .get(index_after) - .filter(|log| log.chain_time < until) - { - Some(log) => (index_after, log.chain_time), - None => return (0.into(), last_updated), - } - } - }; - - let mut accumulated_fees_in_span = FungibleAssetAmount::::zero(); - - let mut total_assets_deposited_at_distribution = match search_balance_logs( - &self.total_borrow_asset_deposited_log, - starting_chain_time, - ) { - SearchResult::Found { index, log } => (index, log), - SearchResult::NotFound { - index_below: Some(index_below), - } => ( - index_below, - if let Some(log) = self.total_borrow_asset_deposited_log.get(index_below) { - log - } else { - return (0.into(), last_updated); - }, - ), - SearchResult::NotFound { index_below: None } => { - env::panic_str("Invariant violation: yield distribution before any deposits."); - } - }; - - // This value is not necessary for correctness; it just reduces - // duplicate reads. - let mut next_total_assets_deposited_at_distribution = self - .total_borrow_asset_deposited_log - .get(total_assets_deposited_at_distribution.0 + 1); - - let mut last_chain_time = last_updated; - - for i in starting_index..yield_distribution_logs.len() { - let log = yield_distribution_logs.get(i).unwrap(); - if log.chain_time >= until { - break; - } - - // Now, we are looking for the latest total asset deposited amount - // AT OR BEFORE the current yield distribution log. - while let Some(next) = next_total_assets_deposited_at_distribution - .clone() - .filter(|l| l.chain_time <= log.chain_time) - { - total_assets_deposited_at_distribution.0 += 1; - total_assets_deposited_at_distribution.1 = next; - - next_total_assets_deposited_at_distribution = self - .total_borrow_asset_deposited_log - .get(total_assets_deposited_at_distribution.0 + 1); - } - - let share_fraction = Decimal::from(borrow_asset_deposited_during_interval.as_u128()) - / total_assets_deposited_at_distribution.1.amount.as_u128(); - - let share_amount = FungibleAssetAmount::new( - (share_fraction * log.amount.as_u128()) - .to_u128_floor() - .unwrap(), - ); - - accumulated_fees_in_span.join(share_amount); - - last_chain_time = log.chain_time; - } - - (accumulated_fees_in_span, last_chain_time) - } - - pub fn can_borrow_position_be_liquidated( - &self, - account_id: &AccountId, - oracle_price_proof: &OraclePriceProof, - ) -> bool { - let Some(borrow_position) = self.borrow_positions.get(account_id) else { - return false; - }; - - self.configuration - .borrow_status( - &borrow_position, - oracle_price_proof, - env::block_timestamp_ms(), - ) - .is_liquidation() - } - - pub fn record_liquidation_lock(&mut self, borrow_position: &mut BorrowPosition) { - borrow_position.liquidation_lock = true; - } - - pub fn record_liquidation_unlock(&mut self, borrow_position: &mut BorrowPosition) { - borrow_position.liquidation_lock = false; - } - - pub fn record_full_liquidation( - &mut self, - borrow_position: &mut BorrowPosition, - mut recovered_amount: BorrowAssetAmount, - ) { - let principal = borrow_position.get_borrow_asset_principal(); - borrow_position.full_liquidation(ChainTime::now()); - - // TODO: Is it correct to only care about the original principal here? - if recovered_amount.split(principal).is_some() { - // distribute yield - self.record_borrow_asset_yield_distribution(recovered_amount); - } else { - // we took a loss - // TODO: some sort of recovery for suppliers - todo!("Took a loss during liquidation"); - } + self.snapshot_with_yield_distribution(amount); } } diff --git a/common/src/market/mod.rs b/common/src/market/mod.rs index e7818bea..24f1676f 100644 --- a/common/src/market/mod.rs +++ b/common/src/market/mod.rs @@ -5,6 +5,8 @@ use near_sdk::{env, near, AccountId}; use crate::{asset::BorrowAssetAmount, number::Decimal}; +mod balance_oracle_configuration; +pub use balance_oracle_configuration::*; mod configuration; pub use configuration::*; mod external; @@ -17,6 +19,7 @@ pub use r#impl::*; pub struct BorrowAssetMetrics { pub available: BorrowAssetAmount, pub deposited: BorrowAssetAmount, + pub borrowed: BorrowAssetAmount, } #[derive(Clone, Debug)] @@ -29,7 +32,7 @@ pub struct YieldWeights { impl YieldWeights { /// # Panics /// - If `supply` is zero. - #[allow(clippy::unwrap_used)] + #[allow(clippy::unwrap_used, reason = "Only used during initial construction")] pub fn new_with_supply_weight(supply: u16) -> Self { Self { supply: supply.try_into().unwrap(), @@ -53,7 +56,7 @@ impl YieldWeights { pub fn static_share(&self, account_id: &AccountId) -> Decimal { self.r#static .get(account_id) - .map_or_else(Decimal::zero, |weight| { + .map_or(Decimal::ZERO, |weight| { Decimal::from(*weight) / u16::from(self.total_weight()) }) } @@ -70,14 +73,12 @@ pub enum Nep141MarketDepositMessage { #[near(serializers = [json])] pub struct LiquidateMsg { pub account_id: AccountId, - pub oracle_price_proof: OraclePriceProof, } -/// This represents some sort of proof-of-price from a price oracle, e.g. Pyth. -/// In production, it must be validated, but for now it's just trust me bro. #[derive(Clone, Debug)] -#[near(serializers = [json])] -pub struct OraclePriceProof { - pub collateral_asset_price: Decimal, - pub borrow_asset_price: Decimal, +#[near(serializers = [json, borsh])] +pub struct WithdrawalResolution { + pub account_id: AccountId, + pub amount_to_account: BorrowAssetAmount, + pub amount_to_fees: BorrowAssetAmount, } diff --git a/common/src/number.rs b/common/src/number.rs index 325bdadc..fb959aee 100644 --- a/common/src/number.rs +++ b/common/src/number.rs @@ -21,14 +21,14 @@ macro_rules! dec { }; } -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct Decimal { repr: U512, } impl Default for Decimal { fn default() -> Self { - Self::zero() + Self::ZERO } } @@ -94,47 +94,116 @@ impl<'de> Deserialize<'de> for Decimal { } impl Decimal { - const REPR_ONE: U512 = U512([0, 0, 1, 0, 0, 0, 0, 0]); - /// When converting to & from strings, we don't guarantee accurate + /// When converting to and from strings, we do not guarantee accurate /// representation of bits lower than this. const REPR_EPSILON: U512 = U512([0b1000, 0, 0, 0, 0, 0, 0, 0]); - pub const fn zero() -> Self { - Self { repr: U512::zero() } + pub const ZERO: Self = Self { repr: U512::zero() }; + pub const ONE_HALF: Self = Self { + repr: U512([0, 0x8000_0000_0000_0000, 0, 0, 0, 0, 0, 0]), + }; + #[rustfmt::skip] + pub const LN2: Self = Self { + repr: U512([0xC9E3_B398_03F2_F6B0, 0xB172_17F7_D1CF_79AB, 0, 0, 0, 0, 0, 0]), + }; + pub const ONE: Self = Self { + repr: U512([0, 0, 1, 0, 0, 0, 0, 0]), + }; + pub const TWO: Self = Self { + repr: U512([0, 0, 2, 0, 0, 0, 0, 0]), + }; + #[rustfmt::skip] + pub const E: Self = Self { + repr: U512([0xBF71_5880_9CF4_F3C9, 0xB7E1_5162_8AED_2A6A, 2, 0, 0, 0, 0, 0]), + }; + pub const TEN: Self = Self { + repr: U512([0, 0, 10, 0, 0, 0, 0, 0]), + }; + + pub fn as_repr(self) -> [u64; 8] { + self.repr.0 } - pub const fn half() -> Self { - Self { - repr: U512([0, 0x8000_0000_0000_0000, 0, 0, 0, 0, 0, 0]), - } + pub fn is_zero(&self) -> bool { + self.repr.is_zero() } - pub const fn one() -> Self { - Self { - repr: Self::REPR_ONE, - } + pub fn near_equal(self, other: Self) -> bool { + self.abs_diff(other).repr <= Self::REPR_EPSILON } - pub const fn two() -> Self { - Self { - repr: U512([0, 0, 2, 0, 0, 0, 0, 0]), + #[must_use] + pub fn pow(self, mut exponent: i32) -> Self { + if exponent == 0 { + return Self::ONE; + } + + let exponent_is_negative = if exponent < 0 { + exponent = -exponent; + true + } else { + false + }; + + let mut y = Self::ONE; + let mut x = self; + + while exponent > 1 { + if exponent % 2 == 1 { + y *= x; + } + x *= x; + exponent >>= 1; + } + + let result = x * y; + + if exponent_is_negative { + Decimal::ONE / result + } else { + result } } - pub fn as_repr(&self) -> &[u64] { - &self.repr.0 + pub fn pow2_int(exponent: u32) -> Option { + #[allow(clippy::cast_possible_truncation)] + if exponent > 512 - FRACTIONAL_BITS as u32 { + None + } else { + Some(Self { + repr: Self::ONE.repr << exponent, + }) + } } - pub fn is_zero(&self) -> bool { - self.repr.is_zero() + fn pow2_frac(self) -> Self { + const MAX_ITERATIONS: u32 = 35; // n=35 is smallest n where n! >= 2^128 + debug_assert!(self <= Self::ONE); + + let mut sum = Self::ONE; + let mut term = Self::ONE; + let numerator = self * Self::LN2; + + for n in 1..=MAX_ITERATIONS { + term *= numerator / n; + if term == Self::ZERO { + break; + } + sum += &term; + } + + sum } - pub fn near_equal(&self, other: &Decimal) -> bool { - self.abs_diff(other).repr <= Self::REPR_EPSILON + pub fn pow2(self) -> Option { + let whole = u32::try_from(self.to_u128_floor()?).ok()?; + let frac = self - whole; + + Some(Self::pow2_int(whole)? * Self::pow2_frac(frac)) } #[must_use] - pub fn abs_diff(&self, other: &Decimal) -> Decimal { + pub fn abs_diff(self, other: Self) -> Self { if self > other { self - other } else { @@ -142,7 +211,7 @@ impl Decimal { } } - pub fn to_u128_floor(&self) -> Option { + pub fn to_u128_floor(self) -> Option { let truncated = self.repr >> FRACTIONAL_BITS; if truncated.bits() <= 128 { Some(truncated.as_u128()) @@ -151,7 +220,7 @@ impl Decimal { } } - pub fn to_u128_ceil(&self) -> Option { + pub fn to_u128_ceil(self) -> Option { let truncated = self.repr >> FRACTIONAL_BITS; if truncated.bits() <= 128 { if self.fractional_part().is_zero() { @@ -167,9 +236,10 @@ impl Decimal { #[allow( clippy::cast_precision_loss, clippy::cast_possible_truncation, - clippy::cast_possible_wrap + clippy::cast_possible_wrap, + reason = "Lossiness is acceptable for this function" )] - pub fn to_f64_lossy(&self) -> f64 { + pub fn to_f64_lossy(self) -> f64 { let frac = self.repr.low_u128() as f64 / 2f64.powi(FRACTIONAL_BITS as i32); let low = (self.repr >> FRACTIONAL_BITS).low_u128() as f64; let high = (self.repr >> (FRACTIONAL_BITS * 2)).low_u128() as f64 * 2f64.powi(128); @@ -179,29 +249,39 @@ impl Decimal { pub fn to_fixed(&self, precision: usize) -> String { let precision = precision.min(MAX_DECIMAL_PRECISION); - let fractional_part = self.fractional_part_to_dec_string(precision); + let (fractional_part, overflow) = self.fractional_part_to_dec_string(precision, false); let fractional_part_trimmed = fractional_part.trim_end_matches('0'); + let repr = if overflow { + self.repr.saturating_add(Self::ONE.repr) + } else { + self.repr + }; if fractional_part_trimmed.is_empty() { - format!("{}", self.repr >> FRACTIONAL_BITS) + format!("{}", repr >> FRACTIONAL_BITS) } else { - format!("{}.{fractional_part_trimmed}", self.repr >> FRACTIONAL_BITS) + format!("{}.{fractional_part_trimmed}", repr >> FRACTIONAL_BITS) } } fn fractional_part(&self) -> U512 { - U512::from(self.repr.low_u128()) + U512([self.repr.0[0], self.repr.0[1], 0, 0, 0, 0, 0, 0]) } fn epsilon_round(repr: U512) -> U512 { (repr + (Self::REPR_EPSILON >> 1)) & !(Self::REPR_EPSILON - 1) } - fn fractional_part_to_dec_string(&self, precision: usize) -> String { + fn fractional_part_to_dec_string(&self, precision: usize, round_up: bool) -> (String, bool) { let mut s = Vec::with_capacity(precision); let mut f = self.fractional_part(); - let d = Self::REPR_ONE; + let mut overflow = false; + + if round_up { + let plus_two = f.saturating_add(2.into()); + overflow = plus_two.0[2] != 0; + f = U512([plus_two.0[0], plus_two.0[1], 0, 0, 0, 0, 0, 0]); + } - #[allow(clippy::cast_possible_truncation)] for _ in 0..precision { if f.is_zero() { break; @@ -209,13 +289,19 @@ impl Decimal { f *= 10; - let digit = (f / d).low_u64(); + let digit = (f / Self::ONE.repr).low_u64(); + #[allow(clippy::cast_possible_truncation)] s.push(digit as u8 + b'0'); - f %= d; + f %= Self::ONE.repr; + } + + if !round_up && !f.is_zero() && (U512::MAX - 2 >= self.repr) { + return self.fractional_part_to_dec_string(precision, true); } - unsafe { String::from_utf8_unchecked(s) } + // Safety: all digits are guaranteed to be in range 0x30..=0x39 + (unsafe { String::from_utf8_unchecked(s) }, overflow) } } @@ -261,7 +347,7 @@ impl FromStr for Decimal { } Ok(Self { - repr: (whole + Decimal::epsilon_round(f >> FRACTIONAL_BITS)), + repr: whole.saturating_add(Decimal::epsilon_round(f >> FRACTIONAL_BITS)), }) } else { Ok(Self { repr: whole }) @@ -565,19 +651,33 @@ mod tests { )); } + #[rstest] + #[case(12, 2)] + #[case(2, 32)] + #[case(1, 0)] + #[case(0, 0)] + #[case(0, 1)] + #[case(1, 1)] + #[test] + fn power(#[case] x: u128, #[case] n: u32) { + #[allow(clippy::cast_possible_wrap)] + let n_i32 = n as i32; + assert_eq!(Decimal::from(x).pow(n_i32), Decimal::from(x.pow(n))); + } + #[test] fn constants_are_accurate() { - assert_eq!(Decimal::zero().to_u128_floor().unwrap(), 0); - assert!((Decimal::half().to_f64_lossy() - 0.5_f64).abs() < 1e-200); - assert_eq!(Decimal::one().to_u128_floor().unwrap(), 1); - assert_eq!(Decimal::two().to_u128_floor().unwrap(), 2); + assert_eq!(Decimal::ZERO.to_u128_floor().unwrap(), 0); + assert!((Decimal::ONE_HALF.to_f64_lossy() - 0.5_f64).abs() < 1e-200); + assert_eq!(Decimal::ONE.to_u128_floor().unwrap(), 1); + assert_eq!(Decimal::TWO.to_u128_floor().unwrap(), 2); } #[rstest] - #[case(Decimal::one())] - #[case(Decimal::two())] - #[case(Decimal::zero())] - #[case(Decimal::half())] + #[case(Decimal::ONE)] + #[case(Decimal::TWO)] + #[case(Decimal::ZERO)] + #[case(Decimal::ONE_HALF)] #[case(Decimal::from(u128::MAX))] #[case(Decimal::from(u64::MAX) / Decimal::from(u128::MAX))] #[test] @@ -585,20 +685,25 @@ mod tests { let serialized = serde_json::to_string(&value).unwrap(); let deserialized: Decimal = serde_json::from_str(&serialized).unwrap(); - assert!(value.near_equal(&deserialized)); + assert!(value.near_equal(deserialized)); } #[test] fn from_self_string_serialization_precision() { const ITERATIONS: usize = 1_024; - const TRANSFORMATIONS: usize = 32; + const TRANSFORMATIONS: usize = 16; let mut rng = rand::thread_rng(); let mut max_error = U512::zero(); + let mut error_distribution = [0u32; 16]; + let mut value_with_max_error = Decimal::ZERO; + #[allow(clippy::cast_possible_truncation)] for _ in 0..ITERATIONS { - let actual = Decimal::from(rng.gen::()) / Decimal::from(rng.gen::()); + let actual = Decimal { + repr: U512(rng.gen()), + }; let mut s = actual.to_fixed(MAX_DECIMAL_PRECISION); for _ in 0..(TRANSFORMATIONS - 1) { @@ -608,21 +713,28 @@ mod tests { } let parsed = Decimal::from_str(&s).unwrap(); - let e = actual.abs_diff(&parsed).repr; + let e = actual.abs_diff(parsed).repr; if e > max_error { max_error = e; + value_with_max_error = actual; } - assert!( - e <= Decimal::REPR_EPSILON, - "Stringification error of repr {:?} is repr {:?}", - actual.repr.0, - e.0, - ); + error_distribution[e.0[0] as usize] += 1; } + println!("Error distribution:"); + for (i, x) in error_distribution.iter().enumerate() { + println!("\t{i}: {x:b}"); + } println!("Max error: {:?}", max_error.0); + + assert!( + max_error <= Decimal::REPR_EPSILON, + "Stringification error of repr {:?} is repr {:?}", + value_with_max_error.repr.0, + max_error.0, + ); } #[test] @@ -649,4 +761,60 @@ mod tests { t(rng.gen::() * rng.gen::() as f64); } } + + #[test] + fn round_up_repr() { + let cases = [ + Decimal { + #[rustfmt::skip] + repr: U512([ 0x0966_4E4C_9169_501F, 0xB226_2812_5CF2_3CD0, 1, 0, 0, 0, 0, 0 ]), + }, + Decimal { + repr: U512([u64::MAX, u64::MAX, 1, 0, 0, 0, 0, 0]), + // 1.99999999999999999999999999999999999999706126412294428123007815865694438580... + }, + Decimal { + repr: U512([u64::MAX - 1, u64::MAX, 1, 0, 0, 0, 0, 0]), + }, + Decimal { repr: U512::MAX }, + Decimal { + repr: U512::MAX.saturating_sub(U512::one()), + }, + Decimal { repr: U512::zero() }, + ]; + + for case in cases { + let p: Decimal = case.to_fixed(MAX_DECIMAL_PRECISION).parse().unwrap(); + + eprintln!("{:x?}", case.repr.0); + eprintln!("{:x?}", p.repr.0); + eprintln!("|{p:?} - {case:?}| = {:?}", p.abs_diff(case).as_repr()); + + assert!(p.near_equal(case)); + } + } + + #[test] + fn round_up_str() { + // Cases that are (generally) not evenly representable in binary fraction. + let cases = [ + "1", + "0", + "1.6958947224456518", + "2.79", + "0.6", + "10.6", + "0.01", + "0.599999999999999999999999999999999999", + ]; + for case in cases { + println!("Testing {case}..."); + let n = Decimal::from_str(case).unwrap(); + let s = n.to_fixed(MAX_DECIMAL_PRECISION); + let parsed = Decimal::from_str(&s).unwrap(); + assert_eq!(n, parsed); + println!("{n:?}"); + println!(); + } + } } diff --git a/common/src/oracle/mod.rs b/common/src/oracle/mod.rs new file mode 100644 index 00000000..8cbcc404 --- /dev/null +++ b/common/src/oracle/mod.rs @@ -0,0 +1 @@ +pub mod pyth; diff --git a/common/src/oracle/pyth.rs b/common/src/oracle/pyth.rs new file mode 100644 index 00000000..b8127a18 --- /dev/null +++ b/common/src/oracle/pyth.rs @@ -0,0 +1,95 @@ +//! Derived from . +//! Modified for use with the Templar Protocol contracts. +//! +//! The original code was released under the following license: +//! +//! Copyright 2025 Pyth Data Association. +//! +//! Licensed under the Apache License, Version 2.0 (the "License"); +//! you may not use this file except in compliance with the License. +//! You may obtain a copy of the License at +//! +//! http://www.apache.org/licenses/LICENSE-2.0 +//! +//! Unless required by applicable law or agreed to in writing, software +//! distributed under the License is distributed on an "AS IS" BASIS, +//! WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//! See the License for the specific language governing permissions and +//! limitations under the License. +use std::collections::HashMap; + +use near_sdk::{ + ext_contract, + json_types::{I64, U64}, + near, +}; + +pub type OracleResponse = HashMap>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[near(serializers = [borsh, json])] +pub struct PriceIdentifier( + #[serde( + serialize_with = "hex::serde::serialize", + deserialize_with = "hex::serde::deserialize" + )] + pub [u8; 32], +); + +/// A price with a degree of uncertainty, represented as a price +- a confidence interval. +/// +/// The confidence interval roughly corresponds to the standard error of a normal distribution. +/// Both the price and confidence are stored in a fixed-point numeric representation, +/// `x * (10^expo)`, where `expo` is the exponent. +// +/// Please refer to the documentation at https://docs.pyth.network/documentation/pythnet-price-feeds/best-practices for how +/// to how this price safely. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[near(serializers = [json, borsh])] +pub struct Price { + pub price: I64, + /// Confidence interval around the price + pub conf: U64, + /// The exponent + pub expo: i32, + /// Unix timestamp of when this price was computed + pub publish_time: i64, +} + +#[ext_contract(ext_pyth)] +pub trait Pyth { + // See implementations for details, PriceIdentifier can be passed either as a 64 character + // hex price ID which can be found on the Pyth homepage. + fn price_feed_exists(&self, price_identifier: PriceIdentifier) -> bool; + // fn get_price(&self, price_identifier: PriceIdentifier) -> Option; + // fn get_price_unsafe(&self, price_identifier: PriceIdentifier) -> Option; + // fn get_price_no_older_than(&self, price_id: PriceIdentifier, age: u64) -> Option; + // fn get_ema_price(&self, price_id: PriceIdentifier) -> Option; + // fn get_ema_price_unsafe(&self, price_id: PriceIdentifier) -> Option; + // fn get_ema_price_no_older_than(&self, price_id: PriceIdentifier, age: u64) -> Option; + // fn list_prices( + // &self, + // price_ids: Vec, + // ) -> HashMap>; + // fn list_prices_unsafe( + // &self, + // price_ids: Vec, + // ) -> HashMap>; + // fn list_prices_no_older_than( + // &self, + // price_ids: Vec, + // ) -> HashMap>; + // fn list_ema_prices( + // &self, + // price_ids: Vec, + // ) -> HashMap>; + // fn list_ema_prices_unsafe( + // &self, + // price_ids: Vec, + // ) -> HashMap>; + fn list_ema_prices_no_older_than( + &self, + price_ids: Vec, + age: u64, + ) -> HashMap>; +} diff --git a/common/src/snapshot.rs b/common/src/snapshot.rs new file mode 100644 index 00000000..d335eb91 --- /dev/null +++ b/common/src/snapshot.rs @@ -0,0 +1,26 @@ +use near_sdk::{json_types::U64, near}; + +use crate::{asset::BorrowAssetAmount, number::Decimal, time_chunk::TimeChunk}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[near(serializers = [borsh, json])] +pub struct Snapshot { + pub time_chunk: TimeChunk, + pub end_timestamp_ms: U64, + pub deposited: BorrowAssetAmount, + pub borrowed: BorrowAssetAmount, + pub yield_distribution: BorrowAssetAmount, + pub interest_rate: Decimal, +} + +impl Snapshot { + pub fn usage_ratio(&self) -> Decimal { + if self.deposited.is_zero() || self.borrowed.is_zero() { + Decimal::ZERO + } else if self.borrowed >= self.deposited { + Decimal::ONE + } else { + Decimal::from(self.borrowed) / Decimal::from(self.deposited) + } + } +} diff --git a/common/src/supply.rs b/common/src/supply.rs index a4b031ec..bb010c44 100644 --- a/common/src/supply.rs +++ b/common/src/supply.rs @@ -1,22 +1,37 @@ -use near_sdk::near; +use std::ops::{Deref, DerefMut}; + +use near_sdk::{env, json_types::U64, near, require, AccountId}; use crate::{ - asset::{AssetClass, BorrowAsset, BorrowAssetAmount, FungibleAssetAmount}, - chain_time::ChainTime, + accumulator::{AccumulationRecord, Accumulator}, + asset::{BorrowAsset, BorrowAssetAmount, FungibleAssetAmount}, + event::MarketEvent, + market::{Market, WithdrawalResolution}, + number::Decimal, }; -#[derive(Debug, PartialEq, Eq)] +/// This struct can only be constructed after accumulating yield on a +/// supply position. This serves as proof that the yield has accrued, so it +/// is safe to perform certain other operations. +pub struct YieldAccumulationProof(()); + +#[derive(Debug, Clone, PartialEq, Eq)] #[near(serializers = [json, borsh])] pub struct SupplyPosition { + started_at_block_timestamp_ms: Option, borrow_asset_deposit: BorrowAssetAmount, - pub borrow_asset_yield: YieldRecord, + pub borrow_asset_yield: Accumulator, } impl SupplyPosition { - pub fn new(chain_time: ChainTime) -> Self { + pub fn new(current_snapshot_index: u32) -> Self { Self { + started_at_block_timestamp_ms: None, borrow_asset_deposit: 0.into(), - borrow_asset_yield: YieldRecord::new(chain_time), + // We start at next log index so that the supply starts + // accumulating yield from the _next_ log (since they were not + // necessarily supplying for all of the current log). + borrow_asset_yield: Accumulator::new(current_snapshot_index + 1), } } @@ -24,53 +39,246 @@ impl SupplyPosition { self.borrow_asset_deposit } + pub fn get_started_at_block_timestamp_ms(&self) -> Option { + self.started_at_block_timestamp_ms.map(u64::from) + } + pub fn exists(&self) -> bool { - !self.borrow_asset_deposit.is_zero() || !self.borrow_asset_yield.amount.is_zero() + !self.borrow_asset_deposit.is_zero() || !self.borrow_asset_yield.get_total().is_zero() } - /// MUST always be paired with a yield recalculation! + /// Yield accumulation MUST be applied before calling this function. pub(crate) fn increase_borrow_asset_deposit( &mut self, + _proof: YieldAccumulationProof, amount: BorrowAssetAmount, + block_timestamp_ms: u64, ) -> Option<()> { + if self.started_at_block_timestamp_ms.is_none() || self.borrow_asset_deposit.is_zero() { + self.started_at_block_timestamp_ms = Some(block_timestamp_ms.into()); + } self.borrow_asset_deposit.join(amount) } - /// MUST always be paired with a yield recalculation! + /// Yield accumulation MUST be applied before calling this function. pub(crate) fn decrease_borrow_asset_deposit( &mut self, + _proof: YieldAccumulationProof, amount: BorrowAssetAmount, ) -> Option { + // No need to reset the timer; it is a permanent indication of the + // initial supply event. self.borrow_asset_deposit.split(amount) } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[near(serializers = [json, borsh])] -pub struct YieldRecord { - pub amount: FungibleAssetAmount, - pub last_updated: ChainTime, +pub struct SupplyPositionRef { + market: M, + account_id: AccountId, + position: SupplyPosition, } -impl YieldRecord { - pub fn new(last_updated: ChainTime) -> Self { +impl SupplyPositionRef { + pub fn new(market: M, account_id: AccountId, position: SupplyPosition) -> Self { Self { - amount: 0.into(), - last_updated, + market, + account_id, + position, + } + } + + pub fn account_id(&self) -> &AccountId { + &self.account_id + } + + pub fn inner(&self) -> &SupplyPosition { + &self.position + } +} + +impl> SupplyPositionRef { + pub fn with_pending_yield_estimate(&mut self) { + let mut pending_estimate = self.calculate_yield(u32::MAX).get_amount(); + if !self.market.current_snapshot.deposited.is_zero() { + let yield_in_current_snapshot = + u128::from(self.market.current_snapshot.yield_distribution) + * u128::from(self.position.get_borrow_asset_deposit()) + / u128::from(self.market.current_snapshot.deposited); + pending_estimate.join(yield_in_current_snapshot.into()); + } + self.position.borrow_asset_yield.pending_estimate = pending_estimate; + } + + pub fn calculate_yield(&self, snapshot_limit: u32) -> AccumulationRecord { + let mut next_snapshot_index = self.position.borrow_asset_yield.get_next_snapshot_index(); + + let amount: Decimal = self.position.get_borrow_asset_deposit().into(); + + let mut accumulated = Decimal::ZERO; + + #[allow( + clippy::cast_possible_truncation, + reason = "Assume # of snapshots is never >u32::MAX" + )] + for (i, snapshot) in self + .market + .finalized_snapshots + .iter() + .enumerate() + .skip(next_snapshot_index as usize) + .take(snapshot_limit as usize) + { + if !snapshot.deposited.is_zero() { + accumulated += amount * Decimal::from(snapshot.yield_distribution) + / Decimal::from(snapshot.deposited); + } + + next_snapshot_index = i as u32 + 1; + } + + AccumulationRecord { + // Accumulated amount is derived from real balances, so it should + // never overflow underlying data type. + #[allow(clippy::unwrap_used, reason = "Derived from real balances")] + amount: accumulated.to_u128_floor().unwrap().into(), + next_snapshot_index, + } + } +} + +pub struct SupplyPositionGuard<'a>(SupplyPositionRef<&'a mut Market>); + +impl Drop for SupplyPositionGuard<'_> { + fn drop(&mut self) { + self.0 + .market + .supply_positions + .insert(&self.0.account_id, &self.0.position); + } +} + +impl<'a> Deref for SupplyPositionGuard<'a> { + type Target = SupplyPositionRef<&'a mut Market>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for SupplyPositionGuard<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl<'a> SupplyPositionGuard<'a> { + pub fn new(market: &'a mut Market, account_id: AccountId, position: SupplyPosition) -> Self { + Self(SupplyPositionRef::new(market, account_id, position)) + } + + pub fn accumulate_yield_partial(&mut self, snapshot_limit: u32) { + require!(snapshot_limit > 0, "snapshot_limit must be nonzero"); + self.market.snapshot(); + + let accumulation_record = self.calculate_yield(snapshot_limit); + + if !accumulation_record.amount.is_zero() { + MarketEvent::YieldAccumulated { + account_id: self.account_id.clone(), + borrow_asset_amount: accumulation_record.amount, + } + .emit(); } + + self.position + .borrow_asset_yield + .accumulate(accumulation_record); } - pub fn withdraw(&mut self, amount: u128) -> Option> { - self.amount.split(amount) + pub fn accumulate_yield(&mut self) -> YieldAccumulationProof { + self.accumulate_yield_partial(u32::MAX); + YieldAccumulationProof(()) } - pub fn accumulate_yield( + pub fn record_withdrawal( &mut self, - additional_yield: FungibleAssetAmount, - chain_time: ChainTime, + proof: YieldAccumulationProof, + mut amount: BorrowAssetAmount, + block_timestamp_ms: u64, + ) -> WithdrawalResolution { + self.position + .decrease_borrow_asset_deposit(proof, amount) + .unwrap_or_else(|| env::panic_str("Supply position borrow asset underflow")); + + // The only way to withdraw from a position is if it already has a deposit. + // Adding a deposit guarantees started_at_block_timestamp_ms != None + #[allow(clippy::unwrap_used, reason = "Guaranteed to never panic")] + let started_at_block_timestamp_ms = + self.0.position.started_at_block_timestamp_ms.unwrap().0; + let supply_duration = block_timestamp_ms.saturating_sub(started_at_block_timestamp_ms); + + self.market + .borrow_asset_deposited + .split(amount) + .unwrap_or_else(|| env::panic_str("Borrow asset deposited underflow")); + + self.market.snapshot(); + + let amount_to_fees = self + .market + .configuration + .supply_withdrawal_fee + .of(amount, supply_duration) + .unwrap_or_else(|| env::panic_str("Fee calculation overflow")); + + if amount.split(amount_to_fees).is_none() { + amount = FungibleAssetAmount::zero(); + } + + MarketEvent::SupplyWithdrawn { + account_id: self.account_id.clone(), + borrow_asset_amount_to_account: amount, + borrow_asset_amount_to_fees: amount_to_fees, + } + .emit(); + + WithdrawalResolution { + account_id: self.account_id.clone(), + amount_to_account: amount, + amount_to_fees, + } + } + + pub fn record_deposit( + &mut self, + proof: YieldAccumulationProof, + amount: BorrowAssetAmount, + block_timestamp_ms: u64, ) { - debug_assert!(chain_time > self.last_updated); - self.amount.join(additional_yield); - self.last_updated = chain_time; + self.position + .increase_borrow_asset_deposit(proof, amount, block_timestamp_ms) + .unwrap_or_else(|| env::panic_str("Supply position borrow asset overflow")); + + self.market + .borrow_asset_deposited + .join(amount) + .unwrap_or_else(|| env::panic_str("Borrow asset deposited overflow")); + + self.market.snapshot(); + + if !amount.is_zero() { + MarketEvent::SupplyDeposited { + account_id: self.account_id.clone(), + borrow_asset_amount: amount, + } + .emit(); + } + } + + pub fn record_yield_withdrawal( + &mut self, + amount: BorrowAssetAmount, + ) -> Option { + self.0.position.borrow_asset_yield.remove(amount) } } diff --git a/common/src/time_chunk.rs b/common/src/time_chunk.rs new file mode 100644 index 00000000..c8192152 --- /dev/null +++ b/common/src/time_chunk.rs @@ -0,0 +1,34 @@ +use near_sdk::{env, json_types::U64, near}; + +/// Configure a method of determining the current time chunk. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[near(serializers = [json, borsh])] +pub enum TimeChunkConfiguration { + BlockHeight { divisor: U64 }, + EpochHeight { divisor: U64 }, + BlockTimestampMs { divisor: U64 }, +} + +impl TimeChunkConfiguration { + pub fn now(&self) -> TimeChunk { + let (time, U64(mut divisor)) = match self { + Self::BlockHeight { divisor } => (env::block_height(), divisor), + Self::EpochHeight { divisor } => (env::epoch_height(), divisor), + Self::BlockTimestampMs { divisor } => (env::block_timestamp_ms(), divisor), + }; + if divisor == 0 { + divisor = 1; + } + TimeChunk(U64(time / divisor)) + } + + pub fn previous(&self) -> TimeChunk { + let TimeChunk(U64(time)) = self.now(); + #[allow(clippy::unwrap_used, reason = "Assume now > 0")] + TimeChunk(U64(time.checked_sub(1).unwrap())) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[near(serializers = [borsh, json])] +pub struct TimeChunk(pub U64); diff --git a/common/src/util.rs b/common/src/util.rs deleted file mode 100644 index e0e444e9..00000000 --- a/common/src/util.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::ops::Deref; - -use near_sdk::near; - -#[derive(Clone, Debug)] -#[near] -pub enum Lockable { - Unlocked(T), - Locked(T), -} - -impl Deref for Lockable { - type Target = T; - - fn deref(&self) -> &Self::Target { - self.get() - } -} - -impl Lockable { - pub fn is_locked(&self) -> bool { - matches!(self, Self::Locked(..)) - } - - #[must_use] - pub fn lock(self) -> Self { - match self { - Self::Unlocked(i) => Self::Locked(i), - Self::Locked(_) => self, - } - } - - #[must_use] - pub fn unlock(self) -> Self { - match self { - Self::Locked(i) => Self::Unlocked(i), - Self::Unlocked(_) => self, - } - } - - pub fn to_unlocked(self) -> Option { - match self { - Self::Unlocked(i) => Some(i), - Self::Locked(_) => None, - } - } - - pub fn get(&self) -> &T { - match self { - Self::Locked(ref i) | Self::Unlocked(ref i) => i, - } - } - - pub fn take(self) -> T { - match self { - Self::Locked(i) | Self::Unlocked(i) => i, - } - } - - pub fn try_get_mut(&mut self) -> Option<&mut T> { - match self { - Self::Unlocked(ref mut i) => Some(i), - Self::Locked(_) => None, - } - } -} diff --git a/common/src/withdrawal_queue.rs b/common/src/withdrawal_queue.rs index aa284b7a..7124ed3e 100644 --- a/common/src/withdrawal_queue.rs +++ b/common/src/withdrawal_queue.rs @@ -197,7 +197,6 @@ impl WithdrawalQueue { } } - #[allow(clippy::missing_panics_doc)] pub fn insert_or_update(&mut self, account_id: &AccountId, amount: BorrowAssetAmount) { if let Some(node_id) = self.entries.get(account_id) { // update existing @@ -238,13 +237,12 @@ impl WithdrawalQueue { } pub fn get_status(&self) -> WithdrawalQueueStatus { - let depth = self - .iter() - .map(|(_, amount)| amount.as_u128()) - .sum::() - .into(); WithdrawalQueueStatus { - depth, + depth: self + .iter() + .map(|(_, amount)| u128::from(amount)) + .sum::() + .into(), length: self.len(), } } @@ -258,8 +256,10 @@ impl WithdrawalQueue { for (index, (current_account, amount)) in self.iter().enumerate() { if ¤t_account == account_id { return Some(WithdrawalRequestStatus { - // The queue's length is u32, so this will never truncate. - #[allow(clippy::cast_possible_truncation)] + #[allow( + clippy::cast_possible_truncation, + reason = "Queue length is u32, so this will never truncate" + )] index: index as u32, depth, amount, @@ -344,8 +344,6 @@ mod tests { use super::WithdrawalQueue; - // TODO: Test locking. - #[test] fn withdrawal_remove() { let mut wq = WithdrawalQueue::new(b"w"); diff --git a/contract/market/Cargo.toml b/contract/market/Cargo.toml index 7e29d91f..3d2df6f1 100644 --- a/contract/market/Cargo.toml +++ b/contract/market/Cargo.toml @@ -4,6 +4,7 @@ description = "cargo-near-new-project-description" version = "0.1.0" edition = "2021" repository = "https://github.com/Templar-Protocol/contract-mvp" +license.workspace = true [lib] crate-type = ["cdylib", "rlib"] @@ -12,18 +13,25 @@ crate-type = ["cdylib", "rlib"] # in https://github.com/near/NEPs/blob/master/neps/nep-0330.md [package.metadata.near.reproducible_build] # docker image, descriptor of build environment -image = "sourcescan/cargo-near:git-e3c8adb4b5542cbfc159bb1534f2b94c900c1648-1.80.0" +image = "sourcescan/cargo-near:0.13.4-rust-1.85.0" # tag after colon above serves only descriptive purpose; image is identified by digest -image_digest = "sha256:4bbcdf985936e1cb9b71c627a00cb9b53546ac0c9ef6b175da2918c1dea21363" +image_digest = "sha256:a9d8bee7b134856cc8baa142494a177f2ba9ecfededfcdd38f634e14cca8aae2" # build command inside of docker container # if docker image from default gallery is used https://hub.docker.com/r/sourcescan/cargo-near/tags, # the command may be any combination of flags of `cargo-near`, # supported by respective version of binary inside the container besides `--no-locked` flag -container_build_command = ["cargo", "near", "build"] +container_build_command = [ + "cargo", + "near", + "build", + "non-reproducible-wasm", + "--locked", +] [dependencies] getrandom.workspace = true near-sdk.workspace = true +near-sdk-contract-tools.workspace = true near-contract-standards.workspace = true templar-common.workspace = true @@ -38,4 +46,7 @@ tokio.workspace = true workspace = true [[example]] -name = "generate_testnet_configuration" +name = "gas_report" + +[[example]] +name = "receipt_gas" diff --git a/contract/market/examples/gas_report.rs b/contract/market/examples/gas_report.rs new file mode 100644 index 00000000..f28215a5 --- /dev/null +++ b/contract/market/examples/gas_report.rs @@ -0,0 +1,106 @@ +#![allow(clippy::unwrap_used)] + +use near_sdk::{json_types::U64, Gas}; +use templar_common::{ + fee::Fee, interest_rate_strategy::InterestRateStrategy, market::HarvestYieldMode, + number::Decimal, time_chunk::TimeChunkConfiguration, +}; +use test_utils::{setup_everything, SetupEverything}; + +#[allow(clippy::unwrap_used)] +#[tokio::main] +async fn main() { + const ITERATIONS: usize = 128; + + let SetupEverything { + c, + supply_user, + borrow_user, + borrow_user_2, + .. + } = setup_everything(|c| { + c.borrow_interest_rate_strategy = + InterestRateStrategy::linear(Decimal::ZERO, Decimal::ZERO).unwrap(); + c.borrow_origination_fee = Fee::zero(); + c.time_chunk_configuration = TimeChunkConfiguration::BlockHeight { divisor: U64(1) }; + }) + .await; + + c.supply(&supply_user, 120_000).await; + let harvest_yield_0 = c + .harvest_yield_execution(&supply_user, Some(HarvestYieldMode::Compounding)) + .await; + let snapshot_count_before = c.list_finalized_snapshots(None, None).await.len(); + c.collateralize(&borrow_user, 2000).await; + c.collateralize(&borrow_user_2, 2000).await; + + c.borrow(&borrow_user_2, 1000).await; + let apply_interest_0 = c.apply_interest(&borrow_user_2, None).await; + + for _ in 0..ITERATIONS { + c.borrow(&borrow_user, 1000).await; + c.repay(&borrow_user, 1100).await; + } + + let apply_interest_max = c.apply_interest(&borrow_user_2, None).await; + let harvest_yield_max = c + .harvest_yield_execution(&supply_user, Some(HarvestYieldMode::Compounding)) + .await; + + let snapshot_count_after = c.list_finalized_snapshots(None, None).await.len(); + let snapshot_count = snapshot_count_after - snapshot_count_before; + eprintln!("Snapshot count: {snapshot_count}"); + let target_gas = Gas::from_tgas(285); // Max gas is 300, so this is a bit conservative + + let harvest_yield_snapshot_limit = calculate_snapshot_limit( + harvest_yield_0.total_gas_burnt, + snapshot_count as u64, + harvest_yield_max.total_gas_burnt, + target_gas, + ); + + let apply_interest_snapshot_limit = calculate_snapshot_limit( + apply_interest_0.total_gas_burnt, + snapshot_count as u64, + apply_interest_max.total_gas_burnt, + target_gas, + ); + + println!("**Gas Report**"); + println!(); + println!("`harvest_yield`"); + println!(); + println!("| Iterations | Gas |"); + println!("| ---------: | ---: |"); + println!("| 0 | {} |", harvest_yield_0.total_gas_burnt); + println!( + "| {snapshot_count} | {} |", + harvest_yield_max.total_gas_burnt + ); + println!(); + println!("Estimated snapshot limit: {harvest_yield_snapshot_limit}"); + println!(); + println!("`apply_interest`"); + println!(); + println!("| Iterations | Gas |"); + println!("| ---------: | ---: |"); + println!("| 0 | {} |", apply_interest_0.total_gas_burnt); + println!( + "| {snapshot_count} | {} |", + apply_interest_max.total_gas_burnt + ); + println!(); + println!("Estimated snapshot limit: {apply_interest_snapshot_limit}"); +} + +/// Estimate `snapshot_limit` that will maximize iterations while safely +/// staying within the gas limit. +fn calculate_snapshot_limit( + at_0: Gas, + max_snapshots: u64, + at_max_snapshots: Gas, + target_gas: Gas, +) -> u64 { + (target_gas.as_gas() - at_0.as_gas()) * max_snapshots + / (at_max_snapshots.as_gas() - at_0.as_gas()) +} diff --git a/contract/market/examples/generate_testnet_configuration.rs b/contract/market/examples/generate_testnet_configuration.rs deleted file mode 100644 index a1c70774..00000000 --- a/contract/market/examples/generate_testnet_configuration.rs +++ /dev/null @@ -1,35 +0,0 @@ -#![allow(clippy::unwrap_used)] -//! Used by GitHub Actions to generate default market configuration. - -use std::str::FromStr; - -use near_sdk::serde_json; -use templar_common::{ - asset::{FungibleAsset, FungibleAssetAmount}, - fee::{Fee, TimeBasedFee}, - market::{MarketConfiguration, YieldWeights}, - number::Decimal, -}; - -pub fn main() { - println!( - "{{\"configuration\":{}}}", - serde_json::to_string(&MarketConfiguration { - borrow_asset: FungibleAsset::nep141("usdt.fakes.testnet".parse().unwrap()), - collateral_asset: FungibleAsset::nep141("wrap.testnet".parse().unwrap()), - balance_oracle_account_id: "balance_oracle".parse().unwrap(), - minimum_initial_collateral_ratio: Decimal::from_str("1.25").unwrap(), - minimum_collateral_ratio_per_borrow: Decimal::from_str("1.2").unwrap(), - maximum_borrow_asset_usage_ratio: Decimal::from_str("0.99").unwrap(), - borrow_origination_fee: Fee::zero(), - borrow_annual_maintenance_fee: Fee::zero(), - maximum_borrow_duration_ms: None, - minimum_borrow_amount: FungibleAssetAmount::new(1), - maximum_borrow_amount: FungibleAssetAmount::new(u128::MAX), - supply_withdrawal_fee: TimeBasedFee::zero(), - yield_weights: YieldWeights::new_with_supply_weight(1), - maximum_liquidator_spread: Decimal::from_str("0.05").unwrap(), - }) - .unwrap(), - ); -} diff --git a/contract/market/examples/receipt_gas.rs b/contract/market/examples/receipt_gas.rs new file mode 100644 index 00000000..565800d2 --- /dev/null +++ b/contract/market/examples/receipt_gas.rs @@ -0,0 +1,34 @@ +use templar_common::fee::Fee; +use test_utils::{setup_everything, SetupEverything}; + +#[tokio::main] +async fn main() { + let SetupEverything { + c, + supply_user, + borrow_user, + liquidator_user, + insurance_yield_user, + .. + } = setup_everything(|c| { + c.borrow_origination_fee = Fee::zero(); + }) + .await; + + c.supply(&supply_user, 20_000).await; + c.collateralize(&borrow_user, 13_000).await; + c.borrow(&borrow_user, 10_000).await; + + c.set_collateral_asset_price(0.85).await; + + c.liquidate(&liquidator_user, borrow_user.id(), 11_000) + .await; + + let r = c + .withdraw_static_yield(&insurance_yield_user, None, None) + .await; + + for receipt in r.receipt_outcomes() { + eprintln!("{}: {}", receipt.executor_id, receipt.gas_burnt); + } +} diff --git a/contract/market/src/impl_ft_receiver.rs b/contract/market/src/impl_ft_receiver.rs index ef6c5c33..f45368d5 100644 --- a/contract/market/src/impl_ft_receiver.rs +++ b/contract/market/src/impl_ft_receiver.rs @@ -40,40 +40,38 @@ impl FungibleTokenReceiver for Contract { Nep141MarketDepositMessage::Supply => { let amount = use_borrow_asset(); - self.execute_supply(&sender_id, amount); + self.execute_supply(sender_id, amount); PromiseOrValue::Value(U128(0)) } Nep141MarketDepositMessage::Collateralize => { let amount = use_collateral_asset(); - self.execute_collateralize(&sender_id, amount); + self.execute_collateralize(sender_id, amount); PromiseOrValue::Value(U128(0)) } Nep141MarketDepositMessage::Repay => { let amount = use_borrow_asset(); - let refund = self.execute_repay(&sender_id, amount); + let refund = self.execute_repay(sender_id, amount); PromiseOrValue::Value(refund.into()) } - Nep141MarketDepositMessage::Liquidate(LiquidateMsg { - account_id, - oracle_price_proof, - }) => { + Nep141MarketDepositMessage::Liquidate(LiquidateMsg { account_id }) => { let amount = use_borrow_asset(); - let liquidated_collateral = - self.execute_liquidate_initial(&account_id, amount, &oracle_price_proof); - PromiseOrValue::Promise( self.configuration - .collateral_asset - .transfer(sender_id, liquidated_collateral) + .balance_oracle + .retrieve_price_pair() .then( - Self::ext(env::current_account_id()) - .after_liquidate_via_ft_transfer_call(account_id, amount), + self_ext!( + Self::GAS_LIQUIDATE_FT_TRANSFER_CALL_01_CONSUME_ORACLE_RESPONSE + ) + .liquidate_ft_transfer_call_01_consume_oracle_response( + sender_id, account_id, amount, + ), ), ) } diff --git a/contract/market/src/impl_helper.rs b/contract/market/src/impl_helper.rs index 097eded3..4ac0ae67 100644 --- a/contract/market/src/impl_helper.rs +++ b/contract/market/src/impl_helper.rs @@ -1,37 +1,39 @@ use near_sdk::{ - env, json_types::U128, near, require, serde_json, AccountId, Promise, PromiseError, - PromiseOrValue, PromiseResult, + env, json_types::U128, near, require, AccountId, Gas, Promise, PromiseError, PromiseResult, }; use templar_common::{ - asset::{BorrowAsset, BorrowAssetAmount, CollateralAssetAmount}, - balance_log::BalanceLog, - borrow::BorrowPosition, - chain_time::ChainTime, - market::OraclePriceProof, - supply::SupplyPosition, + asset::{ + BorrowAsset, BorrowAssetAmount, CollateralAsset, CollateralAssetAmount, FungibleAsset, + }, + market::{PricePair, WithdrawalResolution}, + oracle::pyth::OracleResponse, }; use crate::{Contract, ContractExt}; /// Internal helpers. impl Contract { - pub fn execute_supply(&mut self, account_id: &AccountId, amount: BorrowAssetAmount) { - let mut supply_position = self - .supply_positions - .get(account_id) - .unwrap_or_else(|| SupplyPosition::new(ChainTime::now())); - - self.record_supply_position_borrow_asset_deposit(&mut supply_position, amount); + pub fn execute_supply(&mut self, account_id: AccountId, amount: BorrowAssetAmount) { + if self.supply_position_ref(account_id.clone()).is_none() { + self.charge_for_storage( + &account_id, + self.storage_usage_supply_position + self.storage_usage_snapshot * 2, + ); + } - self.supply_positions.insert(account_id, &supply_position); + let supply_maximum_amount = self.configuration.supply_maximum_amount; + let mut supply_position = self.get_or_create_supply_position_guard(account_id); + let proof = supply_position.accumulate_yield(); + supply_position.record_deposit(proof, amount, env::block_timestamp_ms()); + if let Some(ref supply_maximum_amount) = supply_maximum_amount { + require!( + supply_position.inner().get_borrow_asset_deposit() <= *supply_maximum_amount, + "New supply position cannot exceed configured supply maximum", + ); + } } - pub fn execute_collateralize(&mut self, account_id: &AccountId, amount: CollateralAssetAmount) { - let mut borrow_position = self - .borrow_positions - .get(account_id) - .unwrap_or_else(|| BorrowPosition::new(ChainTime::now())); - + pub fn execute_collateralize(&mut self, account_id: AccountId, amount: CollateralAssetAmount) { // TODO: This creates a borrow record implicitly. If we // require a discrete "sign-up" step, we will need to add // checks before this function call. @@ -39,88 +41,74 @@ impl Contract { // The sign-up step would only be NFT gating or something of // that sort, which is just an additional pre condition check. // -- https://github.com/Templar-Protocol/contract-mvp/pull/6#discussion_r1923871982 - self.record_borrow_position_collateral_asset_deposit(&mut borrow_position, amount); + if self.borrow_position_ref(account_id.clone()).is_none() { + self.charge_for_storage( + &account_id, + self.storage_usage_borrow_position + self.storage_usage_snapshot * 2, + ); + } - self.borrow_positions.insert(account_id, &borrow_position); + let mut borrow_position = self.get_or_create_borrow_position_guard(account_id); + let proof = borrow_position.accumulate_interest(); + borrow_position.record_collateral_asset_deposit(proof, amount); } /// Returns the amount that should be returned to the account. pub fn execute_repay( &mut self, - account_id: &AccountId, + account_id: AccountId, amount: BorrowAssetAmount, ) -> BorrowAssetAmount { - if let Some(mut borrow_position) = self.borrow_positions.get(account_id) { - // TODO: This function *errors* on overpayment. Instead, add a - // check before and only repay the maximum, then return the excess. - // - // Due to the slightly imprecise calculation of yield and - // other fees, the returning of the excess should be - // anything >1%, for example, over the total amount - // borrowed + fees/interest. - // -- https://github.com/Templar-Protocol/contract-mvp/pull/6#discussion_r1923876327 - self.record_borrow_position_borrow_asset_repay(&mut borrow_position, amount); - - self.borrow_positions.insert(account_id, &borrow_position); - BorrowAssetAmount::zero() - } else { + let Some(mut borrow_position) = self.borrow_position_guard(account_id) else { // No borrow exists: just return the whole amount. - amount - } + return amount; + }; + let proof = borrow_position.accumulate_interest(); + // Returns the amount that should be returned to the borrower. + borrow_position.record_repay(proof, amount) } pub fn execute_liquidate_initial( &mut self, - account_id: &AccountId, + account_id: AccountId, amount: BorrowAssetAmount, - oracle_price_proof: &OraclePriceProof, + price_pair: &PricePair, ) -> CollateralAssetAmount { - let mut borrow_position = self - .borrow_positions - .get(account_id) - .unwrap_or_else(|| BorrowPosition::new(ChainTime::now())); + let mut borrow_position = self.get_or_create_borrow_position_guard(account_id); require!( - self.configuration - .borrow_status( - &borrow_position, - oracle_price_proof, - env::block_timestamp_ms(), - ) - .is_liquidation(), + borrow_position.can_be_liquidated(price_pair, env::block_timestamp_ms()), "Borrow position cannot be liquidated", ); - let minimum_acceptable_amount = self.configuration.minimum_acceptable_liquidation_amount( - borrow_position.collateral_asset_deposit, - oracle_price_proof, - ); + let minimum_acceptable_amount = borrow_position + .minimum_acceptable_liquidation_amount(price_pair) + .unwrap_or_else(|| env::panic_str("Minimum acceptable amount calculation overflow")); require!( amount >= minimum_acceptable_amount, "Too little attached to liquidate", ); - self.record_liquidation_lock(&mut borrow_position); - - self.borrow_positions.insert(account_id, &borrow_position); + borrow_position.liquidation_lock(); - borrow_position.collateral_asset_deposit + borrow_position.inner().collateral_asset_deposit } /// Returns the amount to return to the liquidator. pub fn execute_liquidate_final( &mut self, - account_id: &AccountId, + liquidator_id: AccountId, + account_id: AccountId, amount: BorrowAssetAmount, success: bool, ) -> BorrowAssetAmount { - let mut borrow_position = self.borrow_positions.get(account_id).unwrap_or_else(|| { + let mut borrow_position = self.borrow_position_guard(account_id).unwrap_or_else(|| { env::panic_str("Invariant violation: Liquidation of nonexistent position.") }); if success { - self.record_full_liquidation(&mut borrow_position, amount); + borrow_position.record_full_liquidation(liquidator_id, amount); BorrowAssetAmount::zero() } else { // Somehow transfer of collateral failed. This could mean: @@ -133,7 +121,7 @@ impl Contract { // broke down somewhere between the signer and the remote RPC. // Could be as simple as a nonce sync issue. Should just wait // and try again later. - self.record_liquidation_unlock(&mut borrow_position); + borrow_position.liquidation_unlock(); amount } } @@ -142,54 +130,27 @@ impl Contract { /// External helpers. #[near] impl Contract { - pub fn get_total_borrow_asset_deposited_log( - &self, - offset: Option, - count: Option, - ) -> Vec> { - let offset = offset.map_or(0, |o| o as usize); - let count = count.map_or(usize::MAX, |c| c as usize); - self.total_borrow_asset_deposited_log - .iter() - .skip(offset) - .take(count) - .collect::>() - } - - pub fn get_borrow_asset_yield_distribution_log( - &self, - offset: Option, - count: Option, - ) -> Vec> { - let offset = offset.map_or(0, |o| o as usize); - let count = count.map_or(usize::MAX, |c| c as usize); - self.borrow_asset_yield_distribution_log - .iter() - .skip(offset) - .take(count) - .collect::>() - } - - #[private] - pub fn return_static(&self, value: serde_json::Value) -> serde_json::Value { - value - } + pub const GAS_BORROW_01_CONSUME_PRICE: Gas = Gas::from_tgas(9) + .saturating_add(FungibleAsset::::GAS_FT_TRANSFER) + .saturating_add(Self::GAS_BORROW_02_FINALIZE); #[private] - pub fn borrow_01_consume_balance_and_price( + pub fn borrow_01_consume_price( &mut self, account_id: AccountId, amount: BorrowAssetAmount, - #[callback_result] current_balance: Result, - #[callback_result] oracle_price_proof: Result, + #[callback_result] oracle_response_result: Result, ) -> Promise { - let current_balance = current_balance - .unwrap_or_else(|_| env::panic_str("Failed to fetch borrow asset current balance.")); - let oracle_price_proof = oracle_price_proof + let oracle_response = oracle_response_result .unwrap_or_else(|_| env::panic_str("Failed to fetch price data from oracle.")); + let price_pair = self + .configuration + .balance_oracle + .create_price_pair(&oracle_response) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); // Ensure we have enough funds to dispense. - let available_to_borrow = self.get_borrow_asset_available_to_borrow(current_balance); + let available_to_borrow = self.get_borrow_asset_available_to_borrow(); require!( amount <= available_to_borrow, "Insufficient borrow asset available", @@ -201,58 +162,49 @@ impl Contract { .of(amount) .unwrap_or_else(|| env::panic_str("Fee calculation failed")); - let Some(mut borrow_position) = self.borrow_positions.get(&account_id) else { + let Some(mut borrow_position) = self.borrow_position_guard(account_id.clone()) else { env::panic_str("No borrower record. Please deposit collateral first."); }; - self.record_borrow_position_borrow_asset_in_flight_start( - &mut borrow_position, - amount, - fees, - ); + let proof = borrow_position.accumulate_interest(); + borrow_position.record_borrow_asset_in_flight_start(proof, amount, fees); require!( - self.configuration - .is_within_minimum_initial_collateral_ratio(&borrow_position, &oracle_price_proof), + borrow_position.is_within_minimum_initial_collateral_ratio(&price_pair), "New position must exceed initial minimum collateral ratio", ); require!( - self.configuration - .borrow_status( - &borrow_position, - &oracle_price_proof, - env::block_timestamp_ms(), - ) - .is_healthy(), + !borrow_position.can_be_liquidated(&price_pair, env::block_timestamp_ms()), "New position would be in liquidation", ); - self.borrow_positions.insert(&account_id, &borrow_position); + drop(borrow_position); self.configuration .borrow_asset - .transfer(account_id.clone(), amount) // TODO: Check for failure + .transfer(account_id.clone(), amount) .then( - Self::ext(env::current_account_id()) - .borrow_02_after_transfer(account_id, amount, fees), + self_ext!(Self::GAS_BORROW_02_FINALIZE) + .borrow_02_finalize(account_id, amount, fees), ) } + pub const GAS_BORROW_02_FINALIZE: Gas = Gas::from_tgas(9); + #[private] - pub fn borrow_02_after_transfer( + pub fn borrow_02_finalize( &mut self, account_id: AccountId, amount: BorrowAssetAmount, fees: BorrowAssetAmount, ) { - require!(env::promise_results_count() == 1); - - let Some(mut borrow_position) = self.borrow_positions.get(&account_id) else { + let Some(mut borrow_position) = self.borrow_position_guard(account_id) else { env::panic_str("Invariant violation: borrow position does not exist after transfer."); }; - self.record_borrow_position_borrow_asset_in_flight_end(&mut borrow_position, amount, fees); + let proof = borrow_position.accumulate_interest(); + borrow_position.record_borrow_asset_in_flight_end(proof, amount, fees); match env::promise_result(0) { PromiseResult::Successful(_) => { @@ -260,11 +212,7 @@ impl Contract { // // Borrow position has already been created: finalize // withdrawal record. - self.record_borrow_position_borrow_asset_withdrawal( - &mut borrow_position, - amount, - fees, - ); + borrow_position.record_borrow_asset_withdrawal(proof, amount, fees); } PromiseResult::Failed => { // Likely reasons for failure: @@ -287,17 +235,19 @@ impl Contract { // assets, so we IGNORE THIS CASE FOR NOW. // // TODO: Implement case 2 mitigation. + // NOTE: Not needed for chain-local (NEP-141-only) tokens. } } - - self.borrow_positions.insert(&account_id, &borrow_position); } - #[private] - pub fn after_execute_next_withdrawal(&mut self, account: AccountId, amount: BorrowAssetAmount) { - // TODO: Is this check even necessary in a #[private] function? - require!(env::promise_results_count() == 1); + // ~2.4 Tgas + pub const GAS_AFTER_EXECUTE_NEXT_WITHDRAWAL: Gas = Gas::from_tgas(4); + #[private] + pub fn execute_next_supply_withdrawal_request_01_finalize( + &mut self, + withdrawal_resolution: WithdrawalResolution, + ) { match env::promise_result(0) { PromiseResult::Successful(_) => { // Withdrawal succeeded: remove the withdrawal request from the queue. @@ -313,9 +263,11 @@ impl Contract { // head of the queue cannot change while transfers are // in-flight. This should be maintained by the queue itself. require!( - popped_account == account, + popped_account == withdrawal_resolution.account_id, "Invariant violation: Queue shifted while locked/in-flight.", ); + + self.record_borrow_asset_protocol_yield(withdrawal_resolution.amount_to_fees); } PromiseResult::Failed => { // Withdrawal failed: unlock the queue so they can try again. @@ -327,54 +279,172 @@ impl Contract { env::log_str("The withdrawal request cannot be fulfilled at this time. Please try again later."); self.withdrawal_queue.unlock(); - if let Some(mut supply_position) = self.supply_positions.get(&account) { - self.record_supply_position_borrow_asset_deposit(&mut supply_position, amount); - self.supply_positions.insert(&account, &supply_position); + if let Some(mut supply_position) = + self.supply_position_guard(withdrawal_resolution.account_id.clone()) + { + // This should not do very much since we also accumulate + // yield in the initial function call of execute_next_supply_withdrawal_request() + let proof = supply_position.accumulate_yield(); + let mut amount = withdrawal_resolution.amount_to_account; + amount.join(withdrawal_resolution.amount_to_fees); + supply_position.record_deposit(proof, amount, env::block_timestamp_ms()); } } } } + // ~3.3 Tgas + pub const GAS_LIQUIDATE_FT_TRANSFER_CALL_01_CONSUME_ORACLE_RESPONSE: Gas = Gas::from_tgas(4) + .saturating_add(FungibleAsset::::GAS_FT_TRANSFER) + .saturating_add(Self::GAS_LIQUIDATE_FT_TRANSFER_CALL_02_FINALIZE); + + #[private] + pub fn liquidate_ft_transfer_call_01_consume_oracle_response( + &mut self, + liquidator_id: AccountId, + account_id: AccountId, + amount: BorrowAssetAmount, + #[callback_unwrap] oracle_response: OracleResponse, + ) -> Promise { + let price_pair = self + .configuration + .balance_oracle + .create_price_pair(&oracle_response) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); + + let liquidated_collateral = + self.execute_liquidate_initial(account_id.clone(), amount, &price_pair); + + self.configuration + .collateral_asset + .transfer(liquidator_id.clone(), liquidated_collateral) + .then( + self_ext!(Self::GAS_LIQUIDATE_FT_TRANSFER_CALL_02_FINALIZE) + .liquidate_ft_transfer_call_02_finalize(liquidator_id, account_id, amount), + ) + } + + // ~3.2 Tgas + pub const GAS_LIQUIDATE_FT_TRANSFER_CALL_02_FINALIZE: Gas = Gas::from_tgas(4); + /// Called during liquidation process; checks whether the transfer of /// collateral to the liquidator was successful. #[private] - pub fn after_liquidate_via_ft_transfer_call( + pub fn liquidate_ft_transfer_call_02_finalize( &mut self, + liquidator_id: AccountId, account_id: AccountId, borrow_asset_amount: BorrowAssetAmount, ) -> U128 { - require!(env::promise_results_count() == 1); - let success = matches!(env::promise_result(0), PromiseResult::Successful(_)); let refund_to_liquidator = - self.execute_liquidate_final(&account_id, borrow_asset_amount, success); + self.execute_liquidate_final(liquidator_id, account_id, borrow_asset_amount, success); refund_to_liquidator.into() } + // ~7.25 Tgas + pub const GAS_WITHDRAW_COLLATERAL_01_CONSUME_PRICE: Gas = Gas::from_tgas(9) + .saturating_add(FungibleAsset::::GAS_FT_TRANSFER) + .saturating_add(Self::GAS_WITHDRAW_COLLATERAL_02_FINALIZE); + #[private] - pub fn after_liquidate_native( + pub fn withdraw_collateral_01_consume_price( &mut self, - liquidator_id: AccountId, account_id: AccountId, - borrow_asset_amount: BorrowAssetAmount, - ) -> PromiseOrValue<()> { - require!(env::promise_results_count() == 1); + amount: CollateralAssetAmount, + #[callback_unwrap] oracle_response: OracleResponse, + ) -> Promise { + let price_pair = self + .configuration + .balance_oracle + .create_price_pair(&oracle_response) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); - let success = matches!(env::promise_result(0), PromiseResult::Successful(_)); + let Some(mut borrow_position) = self.borrow_position_guard(account_id.clone()) else { + env::panic_str("No borrower record. Please deposit collateral first."); + }; - let refund_to_liquidator = - self.execute_liquidate_final(&account_id, borrow_asset_amount, success); + let proof = borrow_position.accumulate_interest(); + borrow_position.record_collateral_asset_withdrawal(proof, amount); + + require!( + borrow_position.is_within_minimum_collateral_ratio(&price_pair), + "Borrow must still be above MCR after collateral withdrawal.", + ); + + drop(borrow_position); + + self.configuration + .collateral_asset + .transfer(account_id.clone(), amount) + .then( + self_ext!(Self::GAS_WITHDRAW_COLLATERAL_02_FINALIZE) + .withdraw_collateral_02_finalize(account_id, amount), + ) + } - if refund_to_liquidator.is_zero() { - PromiseOrValue::Value(()) + // ~1.96 Tgas + pub const GAS_WITHDRAW_COLLATERAL_02_FINALIZE: Gas = Gas::from_tgas(3); + + #[private] + pub fn withdraw_collateral_02_finalize( + &mut self, + account_id: AccountId, + amount: CollateralAssetAmount, + ) { + let transfer_was_successful = + matches!(env::promise_result(0), PromiseResult::Successful(_)); + + if transfer_was_successful { + // Do nothing } else { - PromiseOrValue::Promise( - self.configuration + let Some(mut borrow_position) = self.borrow_position_guard(account_id) else { + env::panic_str("Invariant violation: Borrow position must exist after collateral withdrawal failure."); + }; + + let proof = borrow_position.accumulate_interest(); + borrow_position.record_collateral_asset_deposit(proof, amount); + } + } + + // ~2.0 Tgas + pub const GAS_WITHDRAW_STATIC_YIELD_01_FINALIZE: Gas = Gas::from_tgas(3); + + #[private] + pub fn withdraw_static_yield_01_finalize( + &mut self, + account_id: AccountId, + borrow_asset_amount: BorrowAssetAmount, + collateral_asset_amount: CollateralAssetAmount, + ) { + let mut static_yield = self.static_yield.get(&account_id).unwrap_or_else(|| { + env::panic_str("Invariant violation: static yield entry must exist during callback") + }); + let mut i = 0; + + if !borrow_asset_amount.is_zero() { + if matches!(env::promise_result(i), PromiseResult::Failed) { + static_yield .borrow_asset - .transfer(liquidator_id, refund_to_liquidator), - ) + .join(borrow_asset_amount) + .unwrap_or_else(|| { + env::panic_str("Borrow asset static yield returned overflows") + }); + } + i += 1; + } + + if !collateral_asset_amount.is_zero() + && matches!(env::promise_result(i), PromiseResult::Failed) + { + static_yield + .collateral_asset + .join(collateral_asset_amount) + .unwrap_or_else(|| { + env::panic_str("Collateral asset static yield returned overflows") + }); } } } diff --git a/contract/market/src/impl_market_external.rs b/contract/market/src/impl_market_external.rs index 2456c4ea..5a38bccc 100644 --- a/contract/market/src/impl_market_external.rs +++ b/contract/market/src/impl_market_external.rs @@ -1,11 +1,11 @@ -use near_sdk::{ - env, json_types::U128, near, require, serde_json, AccountId, Promise, PromiseOrValue, -}; +use near_sdk::{env, near, require, AccountId, Promise, PromiseOrValue}; use templar_common::{ asset::{BorrowAssetAmount, CollateralAssetAmount}, borrow::{BorrowPosition, BorrowStatus}, - chain_time::ChainTime, - market::{BorrowAssetMetrics, MarketConfiguration, MarketExternalInterface, OraclePriceProof}, + market::{BorrowAssetMetrics, HarvestYieldMode, MarketConfiguration, MarketExternalInterface}, + number::Decimal, + oracle::pyth::OracleResponse, + snapshot::Snapshot, static_yield::StaticYieldRecord, supply::SupplyPosition, withdrawal_queue::{WithdrawalQueueStatus, WithdrawalRequestStatus}, @@ -19,145 +19,155 @@ impl MarketExternalInterface for Contract { self.configuration.clone() } - fn get_borrow_asset_metrics( - &self, - borrow_asset_balance: BorrowAssetAmount, - ) -> BorrowAssetMetrics { - BorrowAssetMetrics { - available: self.get_borrow_asset_available_to_borrow(borrow_asset_balance), - deposited: self.borrow_asset_deposited, - } + fn get_current_snapshot(&self) -> &Snapshot { + &self.current_snapshot } - fn list_borrows(&self, offset: Option, count: Option) -> Vec { - let offset = offset.map_or(0, |o| o as usize); - let count = count.map_or(usize::MAX, |c| c as usize); - self.borrow_positions - .keys() - .skip(offset) - .take(count) - .collect() + fn get_finalized_snapshots_len(&self) -> u32 { + self.finalized_snapshots.len() } - fn list_supplys(&self, offset: Option, count: Option) -> Vec { + fn list_finalized_snapshots(&self, offset: Option, count: Option) -> Vec<&Snapshot> { let offset = offset.map_or(0, |o| o as usize); let count = count.map_or(usize::MAX, |c| c as usize); - self.supply_positions - .keys() + self.finalized_snapshots + .iter() .skip(offset) .take(count) - .collect() + .collect::>() + } + + fn get_borrow_asset_metrics(&self) -> BorrowAssetMetrics { + BorrowAssetMetrics { + available: self.get_borrow_asset_available_to_borrow(), + deposited: self.borrow_asset_deposited, + borrowed: self.borrow_asset_borrowed, + } } fn get_borrow_position(&self, account_id: AccountId) -> Option { - self.borrow_positions.get(&account_id) + let mut borrow_position = self.borrow_position_ref(account_id)?; + borrow_position.with_pending_interest(); + Some(borrow_position.inner().clone()) } fn get_borrow_status( &self, account_id: AccountId, - oracle_price_proof: OraclePriceProof, + oracle_response: OracleResponse, ) -> Option { - let borrow_position = self.borrow_positions.get(&account_id)?; + let borrow_position = self.get_borrow_position(account_id)?; + + let price_pair = self + .configuration + .balance_oracle + .create_price_pair(&oracle_response) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); Some(self.configuration.borrow_status( &borrow_position, - &oracle_price_proof, + &price_pair, env::block_timestamp_ms(), )) } - fn borrow( - &mut self, - amount: BorrowAssetAmount, - oracle_price_proof: OraclePriceProof, - ) -> Promise { + fn borrow(&mut self, amount: BorrowAssetAmount) -> Promise { require!(!amount.is_zero(), "Borrow amount must be greater than zero"); require!( - amount >= self.configuration.minimum_borrow_amount, + amount >= self.configuration.borrow_minimum_amount, "Borrow amount is smaller than minimum allowed", ); require!( - amount <= self.configuration.maximum_borrow_amount, + amount <= self.configuration.borrow_maximum_amount, "Borrow amount is greater than maximum allowed", ); let account_id = env::predecessor_account_id(); - // -> (current asset balance, price data) self.configuration - .borrow_asset - .current_account_balance() - .and( - #[allow(clippy::unwrap_used)] - // TODO: Replace with call to actual price oracle. - Self::ext(env::current_account_id()) - .return_static(serde_json::to_value(oracle_price_proof).unwrap()), - ) + .balance_oracle + .retrieve_price_pair() .then( - Self::ext(env::current_account_id()) - .borrow_01_consume_balance_and_price(account_id, amount), + self_ext!(Self::GAS_BORROW_01_CONSUME_PRICE) + .borrow_01_consume_price(account_id, amount), ) } - fn withdraw_collateral( - &mut self, - amount: U128, - oracle_price_proof: Option, - ) -> Promise { - let amount = CollateralAssetAmount::new(amount.0); - + fn withdraw_collateral(&mut self, amount: CollateralAssetAmount) -> Promise { let account_id = env::predecessor_account_id(); - let Some(mut borrow_position) = self.borrow_positions.get(&account_id) else { + let Some(mut borrow_position) = self.borrow_position_guard(account_id.clone()) else { env::panic_str("No borrower record. Please deposit collateral first."); }; - self.record_borrow_position_collateral_asset_withdrawal(&mut borrow_position, amount); + if borrow_position + .inner() + .get_total_borrow_asset_liability() + .is_zero() + { + // No need to retrieve prices, since there is zero liability. + let proof = borrow_position.accumulate_interest(); + borrow_position.record_collateral_asset_withdrawal(proof, amount); + drop(borrow_position); - if !borrow_position.get_total_borrow_asset_liability().is_zero() { - require!( - self.configuration.is_within_minimum_collateral_ratio( - &borrow_position, - &oracle_price_proof.unwrap_or_else(|| env::panic_str("Must provide price")), - ), - "Borrow must still be above MCR after collateral withdrawal.", - ); + self.configuration + .collateral_asset + .transfer(account_id.clone(), amount) + .then( + self_ext!(Self::GAS_WITHDRAW_COLLATERAL_02_FINALIZE) + .withdraw_collateral_02_finalize(account_id, amount), + ) + } else { + drop(borrow_position); + // They still have liability, so we need to check prices. + self.configuration + .balance_oracle + .retrieve_price_pair() + .then( + self_ext!(Self::GAS_WITHDRAW_COLLATERAL_01_CONSUME_PRICE) + .withdraw_collateral_01_consume_price(account_id, amount), + ) } + } - self.borrow_positions.insert(&account_id, &borrow_position); - - self.configuration - .collateral_asset - .transfer(account_id, amount) // TODO: Check for failure - .then(Self::ext(env::current_account_id()).return_static(serde_json::Value::Null)) + fn apply_interest(&mut self, snapshot_limit: Option) { + let predecessor = env::predecessor_account_id(); + if let Some(mut borrow_position) = self.borrow_position_guard(predecessor) { + borrow_position.accumulate_interest_partial(snapshot_limit.unwrap_or(u32::MAX)); + } } fn get_supply_position(&self, account_id: AccountId) -> Option { - self.supply_positions.get(&account_id) + let mut supply_position = self.supply_position_ref(account_id)?; + supply_position.with_pending_yield_estimate(); + Some(supply_position.inner().clone()) } /// If the predecessor has already entered the queue, calling this function /// will reset the position to the back of the queue. - fn create_supply_withdrawal_request(&mut self, amount: U128) { - let amount = BorrowAssetAmount::from(amount.0); + fn create_supply_withdrawal_request(&mut self, amount: BorrowAssetAmount) { require!( !amount.is_zero(), "Amount to withdraw must be greater than zero", ); let predecessor = env::predecessor_account_id(); - if self - .supply_positions - .get(&predecessor) - .filter(|supply_position| !supply_position.get_borrow_asset_deposit().is_zero()) - .is_none() - { + let Some(supply_position) = + self.supply_position_ref(predecessor.clone()) + .filter(|supply_position| { + !supply_position.inner().get_borrow_asset_deposit().is_zero() + }) + else { env::panic_str("Supply position does not exist"); - } + }; + + // We do check here, as well as during the execution. + // This check really only ensures that the `depth` reported by + // get_supply_withdrawal_queue_status() is realistically accurate. + require!( + supply_position.inner().get_borrow_asset_deposit() >= amount, + "Attempt to withdraw more than current deposit", + ); - // TODO: Check that amount is a sane value? i.e. within the amount actually deposited? - // Probably not, since this should be checked during the actual execution of the withdrawal. - // No sense duplicating the check, probably. self.withdrawal_queue.remove(&predecessor); self.withdrawal_queue.insert_or_update(&predecessor, amount); } @@ -167,7 +177,7 @@ impl MarketExternalInterface for Contract { } fn execute_next_supply_withdrawal_request(&mut self) -> PromiseOrValue<()> { - let Some((account_id, amount)) = self + let Some(withdrawal_resolution) = self .try_lock_next_withdrawal_request() .unwrap_or_else(|e| env::panic_str(&e.to_string())) else { @@ -178,10 +188,13 @@ impl MarketExternalInterface for Contract { PromiseOrValue::Promise( self.configuration .borrow_asset - .transfer(account_id.clone(), amount) + .transfer( + withdrawal_resolution.account_id.clone(), + withdrawal_resolution.amount_to_account, + ) .then( - Self::ext(env::current_account_id()) - .after_execute_next_withdrawal(account_id.clone(), amount), + self_ext!(Self::GAS_AFTER_EXECUTE_NEXT_WITHDRAWAL) + .execute_next_supply_withdrawal_request_01_finalize(withdrawal_resolution), ), ) } @@ -197,44 +210,46 @@ impl MarketExternalInterface for Contract { self.withdrawal_queue.get_status() } - fn harvest_yield(&mut self) { + fn harvest_yield(&mut self, mode: Option) -> BorrowAssetAmount { let predecessor = env::predecessor_account_id(); - if let Some(mut supply_position) = self.supply_positions.get(&predecessor) { - self.accumulate_yield_on_supply_position(&mut supply_position, ChainTime::now()); - self.supply_positions.insert(&predecessor, &supply_position); + let Some(mut supply_position) = self.supply_position_guard(predecessor) else { + return BorrowAssetAmount::zero(); + }; + + match mode.unwrap_or_default() { + HarvestYieldMode::Compounding => { + let proof = supply_position.accumulate_yield(); + // Compound yield by withdrawing it and recording it as an immediate deposit. + let total_yield = supply_position.inner().borrow_asset_yield.get_total(); + supply_position.record_yield_withdrawal(total_yield); + supply_position.record_deposit(proof, total_yield, env::block_timestamp_ms()); + return total_yield; + } + HarvestYieldMode::SnapshotLimit(limit) => { + supply_position.accumulate_yield_partial(limit); + } + HarvestYieldMode::Default => { + supply_position.accumulate_yield(); + } } - } - fn get_static_yield(&self, account_id: AccountId) -> Option { - self.static_yield.get(&account_id) + BorrowAssetAmount::zero() } - fn withdraw_supply_yield(&mut self, amount: Option) -> Promise { - let predecessor = env::predecessor_account_id(); - let Some(mut supply_position) = self.supply_positions.get(&predecessor) else { - env::panic_str("Supply position does not exist"); - }; - - let amount = amount.map_or_else( - || supply_position.borrow_asset_yield.amount.as_u128(), - |amount| amount.0, - ); - - let withdrawn = supply_position - .borrow_asset_yield - .withdraw(amount) - .unwrap_or_else(|| { - env::panic_str("Attempt to withdraw more yield than has accumulated") - }); - if withdrawn.is_zero() { - env::panic_str("No rewards can be withdrawn"); + fn get_last_yield_rate(&self) -> Decimal { + let deposited: Decimal = self.current_snapshot.deposited.into(); + if deposited.is_zero() { + return Decimal::ZERO; } - self.supply_positions.insert(&predecessor, &supply_position); + let borrowed: Decimal = self.current_snapshot.borrowed.into(); + let supply_weight: Decimal = self.configuration.yield_weights.supply.get().into(); + let total_weight: Decimal = self.configuration.yield_weights.total_weight().get().into(); - // TODO: Check for transfer success. - self.configuration - .borrow_asset - .transfer(predecessor, withdrawn) + self.current_snapshot.interest_rate * borrowed * supply_weight / deposited / total_weight + } + + fn get_static_yield(&self, account_id: AccountId) -> Option { + self.static_yield.get(&account_id) } fn withdraw_static_yield( @@ -272,114 +287,30 @@ impl MarketExternalInterface for Contract { self.static_yield.insert(&predecessor, &static_yield_record); - let borrow_promise = if borrow_asset_amount.is_zero() { - None - } else { - Some( - self.configuration - .borrow_asset - .transfer(predecessor.clone(), borrow_asset_amount), - ) - }; + let borrow_promise = (!borrow_asset_amount.is_zero()).then(|| { + self.configuration + .borrow_asset + .transfer(predecessor.clone(), borrow_asset_amount) + }); - let collateral_promise = if collateral_asset_amount.is_zero() { - None - } else { - Some( - self.configuration - .collateral_asset - .transfer(predecessor.clone(), collateral_asset_amount), - ) - }; + let collateral_promise = (!collateral_asset_amount.is_zero()).then(|| { + self.configuration + .collateral_asset + .transfer(predecessor.clone(), collateral_asset_amount) + }); match (borrow_promise, collateral_promise) { (Some(b), Some(c)) => b.and(c), (Some(p), _) | (_, Some(p)) => p, _ => env::panic_str("No yield to withdraw"), - } // TODO: Check for success - } - - #[payable] - fn supply_native(&mut self) { - require!( - self.configuration.borrow_asset.is_native(), - "Unsupported borrow asset", - ); - - let amount = BorrowAssetAmount::from(env::attached_deposit().as_yoctonear()); - - require!(!amount.is_zero(), "Deposit must be nonzero"); - - self.execute_supply(&env::predecessor_account_id(), amount); - } - - #[payable] - fn collateralize_native(&mut self) { - require!( - self.configuration.collateral_asset.is_native(), - "Unsupported collateral asset", - ); - - let amount = CollateralAssetAmount::from(env::attached_deposit().as_yoctonear()); - - require!(!amount.is_zero(), "Deposit must be nonzero"); - - self.execute_collateralize(&env::predecessor_account_id(), amount); - } - - #[payable] - fn repay_native(&mut self) -> PromiseOrValue<()> { - require!( - self.configuration.borrow_asset.is_native(), - "Unsupported borrow asset", - ); - - let amount = BorrowAssetAmount::from(env::attached_deposit().as_yoctonear()); - - require!(!amount.is_zero(), "Deposit must be nonzero"); - - let predecessor = env::predecessor_account_id(); - - let refund = self.execute_repay(&predecessor, amount); - - if refund.is_zero() { - PromiseOrValue::Value(()) - } else { - PromiseOrValue::Promise( - self.configuration - .borrow_asset - .transfer(predecessor, amount), - ) } - } - - #[payable] - fn liquidate_native( - &mut self, - account_id: AccountId, - oracle_price_proof: OraclePriceProof, - ) -> Promise { - require!( - self.configuration.borrow_asset.is_native(), - "Unsupported borrow asset", - ); - - let amount = BorrowAssetAmount::from(env::attached_deposit().as_yoctonear()); - - require!(!amount.is_zero(), "Deposit must be nonzero"); - - let liquidated_collateral = - self.execute_liquidate_initial(&account_id, amount, &oracle_price_proof); - - let liquidator_id = env::predecessor_account_id(); - - self.configuration - .collateral_asset - .transfer(liquidator_id.clone(), liquidated_collateral) - .then(Self::ext(env::current_account_id()).after_liquidate_native( - liquidator_id, - account_id, - amount, - )) + .then( + self_ext!(Self::GAS_WITHDRAW_STATIC_YIELD_01_FINALIZE) + .withdraw_static_yield_01_finalize( + predecessor, + borrow_asset_amount, + collateral_asset_amount, + ), + ) } } diff --git a/contract/market/src/lib.rs b/contract/market/src/lib.rs index 5ee5d55d..a5987d3f 100644 --- a/contract/market/src/lib.rs +++ b/contract/market/src/lib.rs @@ -2,11 +2,17 @@ use std::ops::{Deref, DerefMut}; -use near_sdk::{env, json_types::U128, near, BorshStorageKey, PanicOnDefault}; -use templar_common::{ - asset::ReturnNativeBalance, - market::{Market, MarketConfiguration}, +use near_sdk::{env, near, AccountId, BorshStorageKey, PanicOnDefault}; +use near_sdk_contract_tools::standard::nep145::{ + Nep145Controller, Nep145ForceUnregister, StorageBalanceBounds, }; +use templar_common::market::{Market, MarketConfiguration}; + +macro_rules! self_ext { + ($gas:expr) => { + Self::ext(::near_sdk::env::current_account_id()).with_static_gas($gas) + }; +} #[derive(BorshStorageKey)] #[near(serializers = [borsh])] @@ -14,19 +20,72 @@ enum StorageKey { Market, } -#[derive(PanicOnDefault)] +#[derive(PanicOnDefault, near_sdk_contract_tools::Nep145)] +#[nep145(force_unregister_hook = "Self")] #[near(contract_state)] pub struct Contract { pub market: Market, + storage_usage_snapshot: u64, + storage_usage_supply_position: u64, + storage_usage_borrow_position: u64, } #[near] impl Contract { + #[allow(clippy::unwrap_used, reason = "Infallible")] #[init] pub fn new(configuration: MarketConfiguration) -> Self { - Self { - market: Market::new(StorageKey::Market, configuration), - } + let mut market = Market::new(StorageKey::Market, configuration); + let storage_usage_1 = env::storage_usage(); + market.finalized_snapshots.flush(); + let storage_usage_2 = env::storage_usage(); + let storage_usage_snapshot = storage_usage_2.saturating_sub(storage_usage_1); + + // These values shoud be approximately: + // 161 (fixed cost) + + // borsh serialization length of position record + + // 128 (max account length in bytes) + + drop(market.get_or_create_supply_position_guard("0".repeat(64).parse().unwrap())); + let storage_usage_3 = env::storage_usage(); + let storage_usage_supply_position = storage_usage_3.saturating_sub(storage_usage_2); + + drop(market.get_or_create_borrow_position_guard("0".repeat(64).parse().unwrap())); + let storage_usage_4 = env::storage_usage(); + let storage_usage_borrow_position = storage_usage_4.saturating_sub(storage_usage_3); + + env::log_str(&format!("Storage usage: {{ \"snapshot\": {storage_usage_snapshot}, \"supply_position\":{storage_usage_supply_position}, \"borrow_position\": {storage_usage_borrow_position} }}")); + + let mut self_ = Self { + market, + storage_usage_snapshot, + storage_usage_supply_position, + storage_usage_borrow_position, + }; + + self_.set_storage_balance_bounds(&StorageBalanceBounds { + min: env::storage_byte_cost().saturating_mul(u128::from( + storage_usage_supply_position.max(storage_usage_borrow_position) + + 2 * storage_usage_snapshot, + )), + max: None, + }); + + self_ + } + + fn charge_for_storage(&mut self, account_id: &AccountId, storage_consumption: u64) { + self.lock_storage( + account_id, + env::storage_byte_cost().saturating_mul(u128::from(storage_consumption)), + ) + .unwrap_or_else(|e| env::panic_str(&format!("Storage error: {e}"))); + } +} + +impl near_sdk_contract_tools::hook::Hook> for Contract { + fn hook(_: &mut Self, _: &Nep145ForceUnregister, _: impl FnOnce(&mut Self) -> R) -> R { + env::panic_str("force unregistration is not supported") } } @@ -44,13 +103,6 @@ impl DerefMut for Contract { } } -#[near] -impl ReturnNativeBalance for Contract { - fn return_native_balance(&self) -> U128 { - U128(env::account_balance().as_yoctonear()) - } -} - mod impl_ft_receiver; mod impl_helper; mod impl_market_external; diff --git a/contract/market/tests/borrow_limits.rs b/contract/market/tests/borrow_limits.rs index 1016c2d8..37f475e5 100644 --- a/contract/market/tests/borrow_limits.rs +++ b/contract/market/tests/borrow_limits.rs @@ -16,14 +16,14 @@ async fn borrow_within_bounds(#[case] minimum: u128, #[case] amount: u128, #[cas borrow_user, .. } = setup_everything(|c| { - c.maximum_borrow_amount = maximum.into(); - c.minimum_borrow_amount = minimum.into(); + c.borrow_maximum_amount = maximum.into(); + c.borrow_minimum_amount = minimum.into(); }) .await; c.supply(&supply_user, 1000).await; c.collateralize(&borrow_user, 2000).await; - c.borrow(&borrow_user, amount, EQUAL_PRICE).await; + c.borrow(&borrow_user, amount).await; } #[rstest] @@ -40,14 +40,14 @@ async fn borrow_below_minimum(#[case] minimum: u128, #[case] amount: u128, #[cas borrow_user, .. } = setup_everything(|c| { - c.maximum_borrow_amount = maximum.into(); - c.minimum_borrow_amount = minimum.into(); + c.borrow_maximum_amount = maximum.into(); + c.borrow_minimum_amount = minimum.into(); }) .await; c.supply(&supply_user, 1000).await; c.collateralize(&borrow_user, 2000).await; - c.borrow(&borrow_user, amount, EQUAL_PRICE).await; + c.borrow(&borrow_user, amount).await; } #[rstest] @@ -64,12 +64,12 @@ async fn borrow_above_maximum(#[case] minimum: u128, #[case] amount: u128, #[cas borrow_user, .. } = setup_everything(|c| { - c.maximum_borrow_amount = maximum.into(); - c.minimum_borrow_amount = minimum.into(); + c.borrow_maximum_amount = maximum.into(); + c.borrow_minimum_amount = minimum.into(); }) .await; c.supply(&supply_user, 1000).await; c.collateralize(&borrow_user, 2000).await; - c.borrow(&borrow_user, amount, EQUAL_PRICE).await; + c.borrow(&borrow_user, amount).await; } diff --git a/contract/market/tests/compounding_yield.rs b/contract/market/tests/compounding_yield.rs new file mode 100644 index 00000000..c36e172d --- /dev/null +++ b/contract/market/tests/compounding_yield.rs @@ -0,0 +1,94 @@ +use std::{sync::atomic::Ordering, time::Duration}; + +use rstest::rstest; +use templar_common::{ + dec, fee::Fee, interest_rate_strategy::InterestRateStrategy, market::HarvestYieldMode, +}; +use test_utils::*; + +#[rstest] +#[case(1_000_000, InterestRateStrategy::linear(dec!("1000000"), dec!("1000000")).unwrap(), HarvestYieldMode::Compounding)] +#[case(1_000_000, InterestRateStrategy::linear(dec!("1000000"), dec!("1000000")).unwrap(), HarvestYieldMode::Default)] +#[tokio::test] +async fn compounding_yield( + #[case] principal: u128, + #[case] strategy: InterestRateStrategy, + #[case] compounding: HarvestYieldMode, +) { + let SetupEverything { + c, + supply_user, + supply_user_2, + borrow_user, + .. + } = setup_everything(|c| { + c.borrow_origination_fee = Fee::zero(); + c.borrow_interest_rate_strategy = strategy.clone(); + }) + .await; + + c.supply(&supply_user, principal * 5).await; + c.supply(&supply_user_2, principal * 5).await; + c.collateralize(&borrow_user, principal * 2).await; + + c.borrow(&borrow_user, principal).await; + + eprintln!("Sleeping..."); + let mut iters = 0; + let done = std::sync::atomic::AtomicBool::new(false); + tokio::join!( + async { + while !done.load(Ordering::Relaxed) { + c.harvest_yield(&supply_user_2, Some(compounding)).await; + iters += 1; + } + }, + async { + while !done.load(Ordering::Relaxed) { + let position = c.get_borrow_position(borrow_user.id()).await.unwrap(); + c.repay( + &borrow_user, + u128::from(position.get_total_borrow_asset_liability()) * 120 / 100, + ) + .await; + c.borrow(&borrow_user, principal).await; + } + }, + async { + tokio::time::sleep(Duration::from_secs(20)).await; + done.store(true, Ordering::Relaxed); + } + ); + eprintln!("Done sleeping!"); + + c.harvest_yield(&supply_user, Some(HarvestYieldMode::Default)) + .await; + + let (supply_position_1_after, supply_position_2_after) = tokio::join!( + async { c.get_supply_position(supply_user.id()).await.unwrap() }, + async { c.get_supply_position(supply_user_2.id()).await.unwrap() }, + ); + + let supply_yield_1 = u128::from(supply_position_1_after.get_borrow_asset_deposit()) + + u128::from(supply_position_1_after.borrow_asset_yield.get_total()) + + u128::from(supply_position_1_after.borrow_asset_yield.pending_estimate) + - principal * 5; + let supply_yield_2 = u128::from(supply_position_2_after.get_borrow_asset_deposit()) + + u128::from(supply_position_2_after.borrow_asset_yield.get_total()) + + u128::from(supply_position_2_after.borrow_asset_yield.pending_estimate) + - principal * 5; + + eprintln!("supply 1 yield: {supply_yield_1:#?}"); + eprintln!("supply 2 yield: {supply_yield_2:#?}"); + eprintln!("iterations: {iters}"); + + if matches!(compounding, HarvestYieldMode::Compounding) { + // Supply user 2 will be rounded DOWN each iteration. + // Ensure that it is compounding, so each iteration should add (much) more + // than 1. + assert!(supply_yield_2 > supply_yield_1 + iters); + } else { + assert!(supply_yield_1 >= supply_yield_2); + assert!(supply_yield_1 < supply_yield_2 + iters + 1); + } +} diff --git a/contract/market/tests/happy_path.rs b/contract/market/tests/happy_path.rs index 4695cf54..8957b004 100644 --- a/contract/market/tests/happy_path.rs +++ b/contract/market/tests/happy_path.rs @@ -1,25 +1,18 @@ +use near_sdk::serde_json::json; +use near_sdk_contract_tools::standard::nep145::StorageBalanceBounds; use rstest::rstest; use tokio::join; -use templar_common::{asset::FungibleAsset, borrow::BorrowStatus, dec}; +use templar_common::{ + borrow::BorrowStatus, dec, interest_rate_strategy::InterestRateStrategy, + market::HarvestYieldMode, number::Decimal, +}; use test_utils::*; -#[allow(dead_code)] -#[derive(PartialEq, Eq, Clone, Copy, Debug)] -enum NativeAssetCase { - Neither, - BorrowAsset, - CollateralAsset, -} - #[rstest] -#[case(NativeAssetCase::Neither)] -// TODO: Figure out gas accounting for native asset borrows. -// #[case(NativeAssetCase::BorrowAsset)] -// #[case(NativeAssetCase::CollateralAsset)] #[allow(clippy::too_many_lines)] #[tokio::test] -async fn test_happy(#[case] native_asset_case: NativeAssetCase) { +async fn test_happy() { let SetupEverything { c, supply_user, @@ -27,57 +20,44 @@ async fn test_happy(#[case] native_asset_case: NativeAssetCase) { protocol_yield_user, insurance_yield_user, .. - } = setup_everything(|c| match native_asset_case { - NativeAssetCase::Neither => {} - NativeAssetCase::BorrowAsset => { - c.borrow_asset = FungibleAsset::native(); - } - NativeAssetCase::CollateralAsset => { - c.collateral_asset = FungibleAsset::native(); - } + } = setup_everything(|c| { + c.borrow_interest_rate_strategy = + InterestRateStrategy::linear(Decimal::ZERO, Decimal::ZERO).unwrap(); }) .await; let configuration = c.get_configuration().await; - match native_asset_case { - NativeAssetCase::Neither => { - assert_eq!( - &configuration.collateral_asset.into_nep141().unwrap(), - c.collateral_asset.nep141_id().unwrap(), - ); - assert_eq!( - &configuration.borrow_asset.into_nep141().unwrap(), - c.borrow_asset.nep141_id().unwrap(), - ); - } - NativeAssetCase::BorrowAsset => { - assert_eq!( - &configuration.collateral_asset.into_nep141().unwrap(), - c.collateral_asset.nep141_id().unwrap(), - ); - assert!(&configuration.borrow_asset.is_native()); - } - NativeAssetCase::CollateralAsset => { - assert!(&configuration.collateral_asset.is_native()); - assert_eq!( - &configuration.borrow_asset.into_nep141().unwrap(), - c.borrow_asset.nep141_id().unwrap(), - ); - } - } - - eprintln!( - "{:?}", - configuration - .minimum_collateral_ratio_per_borrow - .abs_diff(&dec!("1.2")) - .as_repr(), + assert_eq!( + &configuration.collateral_asset.into_nep141().unwrap(), + c.collateral_asset.id(), + ); + assert_eq!( + &configuration.borrow_asset.into_nep141().unwrap(), + c.borrow_asset.id(), ); - assert!(configuration - .minimum_collateral_ratio_per_borrow - .near_equal(&dec!("1.2"))); + assert!(configuration.borrow_mcr.near_equal(dec!("1.2"))); + + let bounds = c + .contract + .view("storage_balance_bounds") + .args_json(json!({})) + .await + .unwrap() + .json::() + .unwrap(); + + assert!(!bounds.min.is_zero()); + + let snapshots_len = c.get_finalized_snapshots_len().await; + assert_eq!(snapshots_len, 1, "Should generate single snapshot on init"); + + let snapshots = c.list_finalized_snapshots(None, None).await; + assert_eq!(snapshots.len(), 1); + assert!(snapshots[0].yield_distribution.is_zero()); + assert!(snapshots[0].deposited.is_zero()); + assert!(snapshots[0].borrowed.is_zero()); // Step 1: Supply user sends tokens to contract to use for borrows. c.supply(&supply_user, 1100).await; @@ -85,19 +65,11 @@ async fn test_happy(#[case] native_asset_case: NativeAssetCase) { let supply_position = c.get_supply_position(supply_user.id()).await.unwrap(); assert_eq!( - supply_position.get_borrow_asset_deposit().as_u128(), + u128::from(supply_position.get_borrow_asset_deposit()), 1100, "Supply position should match amount of tokens supplied to contract", ); - let list_supplys = c.list_supplys().await; - - assert_eq!( - list_supplys, - [supply_user.id().clone()], - "Supply user should be the only account listed", - ); - // Step 2: Borrow user deposits collateral c.collateralize(&borrow_user, 2000).await; @@ -105,21 +77,13 @@ async fn test_happy(#[case] native_asset_case: NativeAssetCase) { let borrow_position = c.get_borrow_position(borrow_user.id()).await.unwrap(); assert_eq!( - borrow_position.collateral_asset_deposit.as_u128(), + u128::from(borrow_position.collateral_asset_deposit), 2000, "Collateral asset deposit should be equal to the number of collateral tokens sent", ); - let list_borrows = c.list_borrows().await; - - assert_eq!( - list_borrows, - [borrow_user.id().clone()], - "Borrow user should be the only account listed", - ); - let borrow_status = c - .get_borrow_status(borrow_user.id(), EQUAL_PRICE) + .get_borrow_status(borrow_user.id(), c.get_prices().await) .await .unwrap(); @@ -130,37 +94,38 @@ async fn test_happy(#[case] native_asset_case: NativeAssetCase) { ); // Step 3: Withdraw some of the borrow asset + let balance_before = c.borrow_asset_balance_of(borrow_user.id()).await; // Borrowing 1000 borrow tokens with 2000 collateral tokens should be fine given equal price and MCR of 120%. - c.borrow(&borrow_user, 1000, EQUAL_PRICE).await; + c.borrow(&borrow_user, 1000).await; - let balance = c.borrow_asset_balance_of(borrow_user.id()).await; + let balance_after = c.borrow_asset_balance_of(borrow_user.id()).await; - assert_eq!(balance, 1000, "Borrow user should receive assets"); + assert_eq!( + balance_before + 1000, + balance_after, + "Borrow user should receive assets" + ); let borrow_position = c.get_borrow_position(borrow_user.id()).await.unwrap(); - assert_eq!(borrow_position.collateral_asset_deposit.as_u128(), 2000); + assert_eq!(u128::from(borrow_position.collateral_asset_deposit), 2000); assert_eq!( - borrow_position.get_total_borrow_asset_liability().as_u128(), + u128::from(borrow_position.get_total_borrow_asset_liability()), 1000 + 100, // origination fee ); // Step 4: Repay borrow - // Need extra to pay for origination fee. - c.borrow_asset_transfer(&supply_user, borrow_user.id(), 100) - .await; - c.repay(&borrow_user, 1100).await; // Ensure borrow is paid off. let borrow_position = c.get_borrow_position(borrow_user.id()).await.unwrap(); - assert_eq!(borrow_position.collateral_asset_deposit.as_u128(), 2000); + assert_eq!(u128::from(borrow_position.collateral_asset_deposit), 2000); assert_eq!( - borrow_position.get_total_borrow_asset_liability().as_u128(), - 0 + u128::from(borrow_position.get_total_borrow_asset_liability()), + 0, ); join!( @@ -168,23 +133,37 @@ async fn test_happy(#[case] native_asset_case: NativeAssetCase) { async { // Withdraw yield. { - c.harvest_yield(&supply_user).await; + c.harvest_yield(&supply_user, Some(HarvestYieldMode::Default)) + .await; let supply_position = c.get_supply_position(supply_user.id()).await.unwrap(); - assert_eq!(supply_position.borrow_asset_yield.amount.as_u128(), 80); + assert_eq!( + u128::from(supply_position.borrow_asset_yield.get_total()), + 80, + ); + // Move the yield to the principal so that it can be withdrawn + let amount_moved_to_principal = c + .harvest_yield(&supply_user, Some(HarvestYieldMode::Compounding)) + .await; + + assert_eq!( + amount_moved_to_principal, + supply_position.borrow_asset_yield.get_total(), + ); let balance_before = c.borrow_asset_balance_of(supply_user.id()).await; // Withdraw all - c.withdraw_supply_yield(&supply_user, None).await; + c.create_supply_withdrawal_request(&supply_user, 80).await; + c.execute_next_supply_withdrawal_request(&supply_user).await; let balance_after = c.borrow_asset_balance_of(supply_user.id()).await; assert_eq!( balance_after - balance_before, - supply_position.borrow_asset_yield.amount.as_u128(), + u128::from(supply_position.borrow_asset_yield.get_total()), ); let supply_position = c.get_supply_position(supply_user.id()).await.unwrap(); assert!( - supply_position.borrow_asset_yield.amount.is_zero(), + supply_position.borrow_asset_yield.get_total().is_zero(), "Supply position should not have yield after withdrawing all", ); } @@ -211,11 +190,11 @@ async fn test_happy(#[case] native_asset_case: NativeAssetCase) { .get_supply_withdrawal_request_status(supply_user.id()) .await .expect("Should be enqueued now"); - assert_eq!(request_status.amount.as_u128(), 1100); - assert_eq!(request_status.depth.as_u128(), 0); + assert_eq!(u128::from(request_status.amount), 1100); + assert_eq!(u128::from(request_status.depth), 0); assert_eq!(request_status.index, 0); let queue_status = c.get_supply_withdrawal_queue_status().await; - assert_eq!(queue_status.depth.as_u128(), 1100); + assert_eq!(u128::from(queue_status.depth), 1100); assert_eq!(queue_status.length, 1); c.execute_next_supply_withdrawal_request(&supply_user).await; @@ -246,27 +225,39 @@ async fn test_happy(#[case] native_asset_case: NativeAssetCase) { // Protocol yield. async { let protocol_yield = c.get_static_yield(protocol_yield_user.id()).await.unwrap(); - assert_eq!(protocol_yield.borrow_asset.as_u128(), 10); + assert!(protocol_yield.collateral_asset.is_zero()); + assert_eq!(u128::from(protocol_yield.borrow_asset), 10); let balance_before = c.borrow_asset_balance_of(protocol_yield_user.id()).await; - c.withdraw_static_yield(&protocol_yield_user, None, None) + let result = c + .withdraw_static_yield(&protocol_yield_user, None, None) .await; + for receipt in result.receipt_outcomes() { + assert!(&receipt.executor_id != c.collateral_asset.id()); + } + assert!(result.failures().is_empty()); let balance_after = c.borrow_asset_balance_of(protocol_yield_user.id()).await; assert_eq!(balance_after - balance_before, 10); }, // Insurance yield. async { let insurance_yield = c.get_static_yield(insurance_yield_user.id()).await.unwrap(); - assert_eq!(insurance_yield.borrow_asset.as_u128(), 10); + assert!(insurance_yield.collateral_asset.is_zero()); + assert_eq!(u128::from(insurance_yield.borrow_asset), 10); let balance_before = c.borrow_asset_balance_of(insurance_yield_user.id()).await; - c.withdraw_static_yield(&insurance_yield_user, None, None) + let result = c + .withdraw_static_yield(&insurance_yield_user, None, None) .await; + for receipt in result.receipt_outcomes() { + assert!(&receipt.executor_id != c.collateral_asset.id()); + } + assert!(result.failures().is_empty()); let balance_after = c.borrow_asset_balance_of(insurance_yield_user.id()).await; assert_eq!(balance_after - balance_before, 10); }, // Borrower withdraws collateral. async { let balance_before = c.collateral_asset_balance_of(borrow_user.id()).await; - c.withdraw_collateral(&borrow_user, 2000, None).await; + c.withdraw_collateral(&borrow_user, 2000).await; let balance_after = c.collateral_asset_balance_of(borrow_user.id()).await; assert_eq!(balance_after - balance_before, 2000); let borrow_position = c.get_borrow_position(borrow_user.id()).await.unwrap(); diff --git a/contract/market/tests/harvest_yield_gas.rs b/contract/market/tests/harvest_yield_gas.rs deleted file mode 100644 index 137bf1f7..00000000 --- a/contract/market/tests/harvest_yield_gas.rs +++ /dev/null @@ -1,35 +0,0 @@ -use test_utils::{setup_everything, SetupEverything, EQUAL_PRICE}; - -#[tokio::test] -async fn harvest_yield_gas() { - const ITERATIONS: usize = 10; - - let SetupEverything { - c, - supply_user, - borrow_user, - .. - } = setup_everything(|_| {}).await; - - c.borrow_asset_transfer(&supply_user, borrow_user.id(), 100 * ITERATIONS as u128) - .await; - - c.supply(&supply_user, 1200).await; - c.collateralize(&borrow_user, 2000).await; - - for _ in 0..ITERATIONS { - c.borrow(&borrow_user, 1000, EQUAL_PRICE).await; - c.repay(&borrow_user, 1100).await; - } - - let r = c.harvest_yield(&supply_user).await; - println!("{r:#?}"); - - println!("Total gas burnt: {}", r.total_gas_burnt); - println!("Tokens burnt on outcome: {}", r.outcome().tokens_burnt); - println!("Gas burnt on outcome: {}", r.outcome().gas_burnt); - println!( - "Sum of gas on outcomes: {}", - near_sdk::Gas::from_gas(r.outcomes().iter().map(|o| o.gas_burnt.as_gas()).sum()), - ); -} diff --git a/contract/market/tests/interest_rate.rs b/contract/market/tests/interest_rate.rs new file mode 100644 index 00000000..68deae13 --- /dev/null +++ b/contract/market/tests/interest_rate.rs @@ -0,0 +1,193 @@ +use std::{sync::atomic::Ordering, time::Duration}; + +use rstest::rstest; +use templar_common::{ + asset::BorrowAssetAmount, dec, fee::Fee, interest_rate_strategy::InterestRateStrategy, + number::Decimal, MS_IN_A_YEAR, +}; +use test_utils::*; + +#[rstest] +#[case(1_000_000, InterestRateStrategy::linear(dec!("1000000"), dec!("1000000")).unwrap())] +#[case(1_000_000, InterestRateStrategy::linear(dec!("100000"), dec!("5000000")).unwrap())] +#[case(5_000_000, + InterestRateStrategy::piecewise(Decimal::ZERO, dec!("0.9"), dec!("350"), dec!("6000")).unwrap() +)] +#[case(5_000_000, + InterestRateStrategy::exponential2(dec!("5"), dec!("800"), dec!("6")).unwrap() +)] +#[tokio::test] +async fn interest_rate(#[case] principal: u128, #[case] strategy: InterestRateStrategy) { + use templar_common::market::HarvestYieldMode; + + let SetupEverything { + c, + supply_user, + supply_user_2, + borrow_user, + borrow_user_2, + .. + } = setup_everything(|c| { + c.borrow_origination_fee = Fee::zero(); + c.borrow_interest_rate_strategy = strategy.clone(); + }) + .await; + + c.supply(&supply_user, principal * 5).await; + c.supply(&supply_user_2, principal * 5).await; + c.collateralize(&borrow_user, principal * 2).await; + c.collateralize(&borrow_user_2, principal * 2).await; + + let time_outer = std::time::Instant::now(); + tokio::join!( + c.borrow(&borrow_user, principal), + c.borrow(&borrow_user_2, principal), + ); + // wait for ~1 block + tokio::time::sleep(Duration::from_secs(1)).await; + let time_inner = std::time::Instant::now(); + + let mut iters = 0; + + for _ in 0..3 { + eprintln!("Sleeping..."); + let done = std::sync::atomic::AtomicBool::new(false); + tokio::join!( + async { + // borrow_user_2 will be continually applying interest while borrow_user_1 does not. + // They should accumulate (very nearly) the same amount of interest regardless. + while !done.load(Ordering::Relaxed) { + tokio::join!( + c.apply_interest(&borrow_user_2, None), + // No compounding so we get apples-to-apples comparison. + // Technically it should be optimal to harvest (and + // compound) occasionally throughout the duration of + // the supply. + c.harvest_yield(&supply_user_2, Some(HarvestYieldMode::Default)), + ); + tokio::time::sleep(Duration::from_secs(1)).await; + iters += 1; + } + }, + async { + tokio::time::sleep(Duration::from_secs(12)).await; + done.store(true, Ordering::Relaxed); + } + ); + eprintln!("Done sleeping!"); + + let duration_inner = time_inner.elapsed(); + let (borrow_position_1, borrow_position_2, supply_position_1, supply_position_2) = tokio::join!( + async { c.get_borrow_position(borrow_user.id()).await.unwrap() }, + async { c.get_borrow_position(borrow_user_2.id()).await.unwrap() }, + async { c.get_supply_position(supply_user.id()).await.unwrap() }, + async { c.get_supply_position(supply_user_2.id()).await.unwrap() }, + ); + let duration_outer = time_outer.elapsed(); + + let supply_yield_1 = u128::from(supply_position_1.borrow_asset_yield.get_total()) + + u128::from(supply_position_1.borrow_asset_yield.pending_estimate); + let supply_yield_2 = u128::from(supply_position_2.borrow_asset_yield.get_total()) + + u128::from(supply_position_2.borrow_asset_yield.pending_estimate); + + // No yield yet. + assert_eq!(supply_yield_1, 0); + assert_eq!(supply_yield_2, 0); + + eprintln!("Borrow position 1: {borrow_position_1:#?}"); + eprintln!("Borrow position 2: {borrow_position_2:#?}"); + + let f = principal * strategy.at(dec!("0.2")) / *MS_IN_A_YEAR; + + let approximation_below = (f * duration_inner.as_millis()).to_u128_ceil().unwrap(); + let approximation_above = (f * duration_outer.as_millis()).to_u128_ceil().unwrap(); + + let actual_1 = u128::from(borrow_position_1.borrow_asset_fees.get_total()) + + u128::from(borrow_position_1.borrow_asset_fees.pending_estimate); + eprintln!("{approximation_below} <= {actual_1} <= {approximation_above}?"); + + assert!(approximation_below <= actual_1); + assert!(actual_1 <= approximation_above); + + let actual_2 = u128::from(borrow_position_2.borrow_asset_fees.get_total()) + + u128::from(borrow_position_2.borrow_asset_fees.pending_estimate); + eprintln!("{approximation_below} <= {actual_2} <= {approximation_above} + {iters}?"); + + assert!(approximation_below <= actual_2); + assert!(actual_2 <= approximation_above + iters); + + assert!( + actual_2 >= actual_1, + "Users should not be able to reduce interest by applying it more frequently" + ); + assert!( + actual_2 <= actual_1 + iters, + "Accuracy should be within # of iters due to rounding up", + ); + } + + tokio::join!( + async { + let borrow_position_before = c.get_borrow_position(borrow_user.id()).await.unwrap(); + let r = c + .repay( + &borrow_user, + (u128::from(borrow_position_before.get_total_borrow_asset_liability()) + + u128::from(borrow_position_before.borrow_asset_fees.pending_estimate)) + * 110 + / 100, /* overpayment */ + ) + .await; + eprintln!("{r:#?}"); + eprintln!("logs"); + for log in r.logs() { + eprintln!("\t{log}"); + } + let borrow_position_after = c.get_borrow_position(borrow_user.id()).await.unwrap(); + + assert_eq!( + borrow_position_after.get_total_borrow_asset_liability(), + BorrowAssetAmount::zero(), + "Borrow should be fully repaid", + ); + }, + async { + let borrow_position_before = c.get_borrow_position(borrow_user_2.id()).await.unwrap(); + c.repay( + &borrow_user_2, + (u128::from(borrow_position_before.get_total_borrow_asset_liability()) + + u128::from(borrow_position_before.borrow_asset_fees.pending_estimate)) + * 110 + / 100, /* overpayment */ + ) + .await; + let borrow_position_after = c.get_borrow_position(borrow_user_2.id()).await.unwrap(); + + assert_eq!( + borrow_position_after.get_total_borrow_asset_liability(), + BorrowAssetAmount::zero(), + "Borrow should be fully repaid", + ); + }, + ); + + let (supply_position_1, supply_position_2) = tokio::join!( + async { + c.harvest_yield(&supply_user, Some(HarvestYieldMode::Default)) + .await; + c.get_supply_position(supply_user.id()).await.unwrap() + }, + async { + c.harvest_yield(&supply_user_2, Some(HarvestYieldMode::Default)) + .await; + c.get_supply_position(supply_user_2.id()).await.unwrap() + }, + ); + + assert!(!supply_position_1.borrow_asset_yield.get_total().is_zero()); + assert_eq!( + supply_position_1.borrow_asset_yield.get_total(), + supply_position_2.borrow_asset_yield.get_total(), + "Harvesting yield more often should not change total", + ); +} diff --git a/contract/market/tests/liquidation.rs b/contract/market/tests/liquidation.rs index 4f6267ed..4cdcaf1e 100644 --- a/contract/market/tests/liquidation.rs +++ b/contract/market/tests/liquidation.rs @@ -1,6 +1,6 @@ use rstest::rstest; -use templar_common::{fee::Fee, market::OraclePriceProof, number::Decimal}; +use templar_common::{fee::Fee, number::Decimal}; use test_utils::*; #[tokio::test] @@ -15,7 +15,7 @@ async fn successful_liquidation_totally_underwater() { c.supply(&supply_user, 1000).await; c.collateralize(&borrow_user, 500).await; - c.borrow(&borrow_user, 300, EQUAL_PRICE).await; + c.borrow(&borrow_user, 300).await; // value of collateral will go 500->250 // collateralization: 250/300 ~= 83% @@ -24,11 +24,11 @@ async fn successful_liquidation_totally_underwater() { let collateral_balance_before = c.collateral_asset_balance_of(liquidator_user.id()).await; let borrow_balance_before = c.borrow_asset_balance_of(liquidator_user.id()).await; + c.set_collateral_asset_price(0.5).await; c.liquidate( &liquidator_user, borrow_user.id(), 300, // this is fmv (i.e. NOT what a real liquidator would do to purchase bad debt) - COLLATERAL_HALF_PRICE, ) .await; @@ -61,6 +61,8 @@ async fn successful_liquidation_good_debt_under_mcr( #[case] collateral_asset_price_pct: u128, #[case] liquidation_amount: u128, ) { + use templar_common::market::HarvestYieldMode; + let SetupEverything { c, liquidator_user, @@ -71,27 +73,24 @@ async fn successful_liquidation_good_debt_under_mcr( .. } = setup_everything(|config| { config.borrow_origination_fee = Fee::zero(); - config.minimum_collateral_ratio_per_borrow = Decimal::from(mcr) / 100u32; + config.borrow_mcr = Decimal::from(mcr) / 100u32; + config.borrow_mcr_initial = Decimal::from(mcr) / 100u32; }) .await; c.supply(&supply_user, 10000).await; c.collateralize(&borrow_user, collateral_amount).await; - c.borrow(&borrow_user, borrow_amount, EQUAL_PRICE).await; + c.borrow(&borrow_user, borrow_amount).await; let collateral_balance_before = c.collateral_asset_balance_of(liquidator_user.id()).await; let borrow_balance_before = c.borrow_asset_balance_of(liquidator_user.id()).await; - c.liquidate( - &liquidator_user, - borrow_user.id(), - liquidation_amount, - OraclePriceProof { - collateral_asset_price: Decimal::from(collateral_asset_price_pct) / 100u32, - borrow_asset_price: Decimal::one(), - }, + c.set_collateral_asset_price( + (Decimal::from(collateral_asset_price_pct) / 100u32).to_f64_lossy(), ) .await; + c.liquidate(&liquidator_user, borrow_user.id(), liquidation_amount) + .await; let collateral_balance_after = c.collateral_asset_balance_of(liquidator_user.id()).await; let borrow_balance_after = c.borrow_asset_balance_of(liquidator_user.id()).await; @@ -111,20 +110,21 @@ async fn successful_liquidation_good_debt_under_mcr( tokio::join!( async { - c.harvest_yield(&supply_user).await; + c.harvest_yield(&supply_user, Some(HarvestYieldMode::Default)) + .await; let supply_position = c.get_supply_position(supply_user.id()).await.unwrap(); assert_eq!( - supply_position.borrow_asset_yield.amount.as_u128(), + u128::from(supply_position.borrow_asset_yield.get_total()), yield_amount * 8 / 10, ); }, async { let protocol_yield = c.get_static_yield(protocol_yield_user.id()).await.unwrap(); - assert_eq!(protocol_yield.borrow_asset.as_u128(), yield_amount / 10); + assert_eq!(u128::from(protocol_yield.borrow_asset), yield_amount / 10); }, async { let insurance_yield = c.get_static_yield(insurance_yield_user.id()).await.unwrap(); - assert_eq!(insurance_yield.borrow_asset.as_u128(), yield_amount / 10,); + assert_eq!(u128::from(insurance_yield.borrow_asset), yield_amount / 10); }, ); } @@ -143,7 +143,7 @@ async fn successful_liquidation_with_spread( ) { assert!(spread_pct <= maximum_spread_pct); - let maximum_liquidator_spread: Decimal = Decimal::from(maximum_spread_pct) / 100u32; + let liquidation_maximum_spread: Decimal = Decimal::from(maximum_spread_pct) / 100u32; let target_spread: Decimal = Decimal::from(spread_pct) / 100u32; let mcr: Decimal = Decimal::from(mcr) / 100u32; @@ -154,14 +154,14 @@ async fn successful_liquidation_with_spread( borrow_user, .. } = setup_everything(|config| { - config.minimum_collateral_ratio_per_borrow = mcr.clone(); - config.maximum_liquidator_spread = maximum_liquidator_spread; + config.borrow_mcr = mcr; + config.liquidation_maximum_spread = liquidation_maximum_spread; }) .await; c.supply(&supply_user, 10000).await; c.collateralize(&borrow_user, 2000).await; // 2:1 collateralization - c.borrow(&borrow_user, 1000, EQUAL_PRICE).await; + c.borrow(&borrow_user, 1000).await; let collateral_balance_before = c.collateral_asset_balance_of(liquidator_user.id()).await; let borrow_balance_before = c.borrow_asset_balance_of(liquidator_user.id()).await; @@ -170,20 +170,14 @@ async fn successful_liquidation_with_spread( 201u32 * 100u32 // 2:1 collateralization + a bit to ensure we're under MCR ; - let liquidation_amount = (&collateral_asset_price * (1u32 - target_spread) * 2000u32) + let liquidation_amount = (collateral_asset_price * (1u32 - target_spread) * 2000u32) .to_u128_ceil() .unwrap(); - c.liquidate( - &liquidator_user, - borrow_user.id(), - liquidation_amount, - OraclePriceProof { - collateral_asset_price, - borrow_asset_price: Decimal::one(), - }, - ) - .await; + c.set_collateral_asset_price(collateral_asset_price.to_f64_lossy()) + .await; + c.liquidate(&liquidator_user, borrow_user.id(), liquidation_amount) + .await; let collateral_balance_after = c.collateral_asset_balance_of(liquidator_user.id()).await; let borrow_balance_after = c.borrow_asset_balance_of(liquidator_user.id()).await; @@ -212,18 +206,13 @@ async fn fail_liquidation_too_little_attached() { c.supply(&supply_user, 1000).await; c.collateralize(&borrow_user, 500).await; - c.borrow(&borrow_user, 300, EQUAL_PRICE).await; + c.borrow(&borrow_user, 300).await; let collateral_balance_before = c.collateral_asset_balance_of(liquidator_user.id()).await; let borrow_balance_before = c.borrow_asset_balance_of(liquidator_user.id()).await; - c.liquidate( - &liquidator_user, - borrow_user.id(), - 150, - COLLATERAL_HALF_PRICE, - ) - .await; + c.set_collateral_asset_price(0.5).await; + c.liquidate(&liquidator_user, borrow_user.id(), 150).await; let collateral_balance_after = c.collateral_asset_balance_of(liquidator_user.id()).await; let borrow_balance_after = c.borrow_asset_balance_of(liquidator_user.id()).await; @@ -239,8 +228,11 @@ async fn fail_liquidation_too_little_attached() { // ensure borrow position remains unchanged let borrow_position = c.get_borrow_position(borrow_user.id()).await.unwrap(); - assert_eq!(borrow_position.get_borrow_asset_principal().as_u128(), 300); - assert_eq!(borrow_position.collateral_asset_deposit.as_u128(), 500); + assert_eq!( + u128::from(borrow_position.get_borrow_asset_principal()), + 300, + ); + assert_eq!(u128::from(borrow_position.collateral_asset_deposit), 500); } #[tokio::test] @@ -255,13 +247,12 @@ async fn fail_liquidation_healthy_borrow() { c.supply(&supply_user, 1000).await; c.collateralize(&borrow_user, 500).await; - c.borrow(&borrow_user, 300, EQUAL_PRICE).await; + c.borrow(&borrow_user, 300).await; let collateral_balance_before = c.collateral_asset_balance_of(liquidator_user.id()).await; let borrow_balance_before = c.borrow_asset_balance_of(liquidator_user.id()).await; - c.liquidate(&liquidator_user, borrow_user.id(), 300, EQUAL_PRICE) - .await; + c.liquidate(&liquidator_user, borrow_user.id(), 300).await; let collateral_balance_after = c.collateral_asset_balance_of(liquidator_user.id()).await; let borrow_balance_after = c.borrow_asset_balance_of(liquidator_user.id()).await; @@ -277,6 +268,9 @@ async fn fail_liquidation_healthy_borrow() { // ensure borrow position remains unchanged let borrow_position = c.get_borrow_position(borrow_user.id()).await.unwrap(); - assert_eq!(borrow_position.get_borrow_asset_principal().as_u128(), 300); - assert_eq!(borrow_position.collateral_asset_deposit.as_u128(), 500); + assert_eq!( + u128::from(borrow_position.get_borrow_asset_principal()), + 300, + ); + assert_eq!(u128::from(borrow_position.collateral_asset_deposit), 500); } diff --git a/contract/market/tests/many_snapshots.rs b/contract/market/tests/many_snapshots.rs new file mode 100644 index 00000000..1327b834 --- /dev/null +++ b/contract/market/tests/many_snapshots.rs @@ -0,0 +1,59 @@ +use templar_common::time_chunk::TimeChunkConfiguration; +use test_utils::*; + +#[allow(clippy::pedantic)] +fn linear_regression_slope(data: &[(f64, f64)]) -> f64 { + let n = data.len() as f64; + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut sum_xy = 0.0; + let mut sum_xx = 0.0; + + for &(x, y) in data { + sum_x += x; + sum_y += y; + sum_xy += x * y; + sum_xx += x * x; + } + + (n * sum_xy - sum_x * sum_y) / (n * sum_xx - sum_x * sum_x) +} + +#[tokio::test] +async fn many_snapshots() { + let SetupEverything { + c, + supply_user, + borrow_user, + .. + } = setup_everything(|c| { + c.time_chunk_configuration = TimeChunkConfiguration::BlockHeight { divisor: 1.into() }; + }) + .await; + + c.supply(&supply_user, 100_000).await; + c.collateralize(&borrow_user, 200_000).await; + let r = c.borrow(&borrow_user, 100).await; + let base_gas = r.total_gas_burnt.as_gas(); + eprintln!("Base gas: {base_gas}"); + + let mut gas_record = vec![]; + + // 256 is 2*128 (2 * the chunk size of the snapshots container) + for i in 0..256 { + let e = c.borrow(&borrow_user, 100).await; + let gas = e.total_gas_burnt.as_gas(); + #[allow(clippy::cast_precision_loss)] + gas_record.push((f64::from(i), gas as f64)); + } + + eprintln!("Base gas:\t{base_gas}"); + for (i, g) in &gas_record { + eprintln!("Gas {i}:\t{g}"); + } + + let slope = linear_regression_slope(&gas_record); + eprintln!("Slope: {slope}"); + + assert!(slope < 1e+10, "Gas growing with snapshots"); +} diff --git a/contract/market/tests/maximum_borrow_asset_usage_ratio.rs b/contract/market/tests/maximum_borrow_asset_usage_ratio.rs index 2f57741c..f34eee3e 100644 --- a/contract/market/tests/maximum_borrow_asset_usage_ratio.rs +++ b/contract/market/tests/maximum_borrow_asset_usage_ratio.rs @@ -16,14 +16,13 @@ async fn borrow_within_maximum_usage_ratio(#[case] percent: u16) { borrow_user, .. } = setup_everything(|c| { - c.maximum_borrow_asset_usage_ratio = Decimal::from(percent) / 100u32; + c.borrow_asset_maximum_usage_ratio = Decimal::from(percent) / 100u32; }) .await; c.supply(&supply_user, 1000).await; c.collateralize(&borrow_user, 2000).await; - c.borrow(&borrow_user, u128::from(percent) * 10 - 1, EQUAL_PRICE) - .await; + c.borrow(&borrow_user, u128::from(percent) * 10 - 1).await; } #[rstest] @@ -40,12 +39,11 @@ async fn borrow_exceeds_maximum_usage_ratio(#[case] percent: u16) { borrow_user, .. } = setup_everything(|c| { - c.maximum_borrow_asset_usage_ratio = Decimal::from(percent) / 100u32; + c.borrow_asset_maximum_usage_ratio = Decimal::from(percent) / 100u32; }) .await; c.supply(&supply_user, 1000).await; c.collateralize(&borrow_user, 2000).await; - c.borrow(&borrow_user, u128::from(percent) * 10 + 1, EQUAL_PRICE) - .await; + c.borrow(&borrow_user, u128::from(percent) * 10 + 1).await; } diff --git a/contract/market/tests/maximum_borrow_duration_ms.rs b/contract/market/tests/maximum_borrow_duration_ms.rs index bb28051e..bc1f9bd5 100644 --- a/contract/market/tests/maximum_borrow_duration_ms.rs +++ b/contract/market/tests/maximum_borrow_duration_ms.rs @@ -10,16 +10,18 @@ async fn liquidation_after_expiration() { borrow_user, .. } = setup_everything(|c| { - c.maximum_borrow_duration_ms = Some(U64(100)); + c.borrow_maximum_duration_ms = Some(U64(1000)); }) .await; c.supply(&supply_user, 1000).await; c.collateralize(&borrow_user, 2000).await; - c.borrow(&borrow_user, 100, EQUAL_PRICE).await; + c.borrow(&borrow_user, 100).await; + + let prices = c.get_prices().await; let status = c - .get_borrow_status(borrow_user.id(), EQUAL_PRICE) + .get_borrow_status(borrow_user.id(), prices.clone()) .await .unwrap(); @@ -27,10 +29,7 @@ async fn liquidation_after_expiration() { c.worker.fast_forward(10).await.unwrap(); - let status = c - .get_borrow_status(borrow_user.id(), EQUAL_PRICE) - .await - .unwrap(); + let status = c.get_borrow_status(borrow_user.id(), prices).await.unwrap(); assert_eq!( status, diff --git a/contract/market/tests/minimum_initial_collateral_ratio.rs b/contract/market/tests/minimum_initial_collateral_ratio.rs index 097c95fd..3a766be6 100644 --- a/contract/market/tests/minimum_initial_collateral_ratio.rs +++ b/contract/market/tests/minimum_initial_collateral_ratio.rs @@ -20,15 +20,18 @@ async fn success_above_minimum_initial_collateral_ratio( .. } = setup_everything(|c| { c.borrow_origination_fee = Fee::zero(); - c.minimum_collateral_ratio_per_borrow = minimum; - c.minimum_initial_collateral_ratio = initial.clone(); + c.borrow_mcr = minimum; + c.borrow_mcr_initial = initial; }) .await; c.supply(&supply_user, 10_000).await; - c.collateralize(&borrow_user, (1000u32 * initial).to_u128_ceil().unwrap()) - .await; - c.borrow(&borrow_user, 1000, EQUAL_PRICE).await; + c.collateralize( + &borrow_user, + (1000u32 * initial + Decimal::ONE).to_u128_ceil().unwrap(), + ) + .await; + c.borrow(&borrow_user, 1000).await; } #[rstest] @@ -50,18 +53,18 @@ async fn fail_below_minimum_initial_collateral_ratio( .. } = setup_everything(|c| { c.borrow_origination_fee = Fee::zero(); - c.minimum_collateral_ratio_per_borrow = minimum; - c.minimum_initial_collateral_ratio = initial.clone(); + c.borrow_mcr = minimum; + c.borrow_mcr_initial = initial; }) .await; c.supply(&supply_user, 10_000).await; c.collateralize( &borrow_user, - (1000u32 * initial).to_u128_ceil().unwrap() - 1, + (1000u32 * initial).to_u128_floor().unwrap() - 1, ) .await; - c.borrow(&borrow_user, 1000, EQUAL_PRICE).await; + c.borrow(&borrow_user, 1000).await; } #[rstest] @@ -81,24 +84,23 @@ async fn not_in_liquidation_if_below_minimum_initial_collateral_ratio( .. } = setup_everything(|c| { c.borrow_origination_fee = Fee::zero(); - c.minimum_collateral_ratio_per_borrow = minimum.clone(); - c.minimum_initial_collateral_ratio = initial.clone(); + c.borrow_mcr = minimum; + c.borrow_mcr_initial = initial; }) .await; c.supply(&supply_user, 10_000).await; - c.collateralize(&borrow_user, (1000u32 * &initial).to_u128_ceil().unwrap()) - .await; - c.borrow(&borrow_user, 1000, EQUAL_PRICE).await; + c.collateralize( + &borrow_user, + (1000u32 * initial + Decimal::ONE).to_u128_ceil().unwrap(), + ) + .await; + c.borrow(&borrow_user, 1000).await; + + c.set_collateral_asset_price(0.99).await; let borrow_status = c - .get_borrow_status( - borrow_user.id(), - templar_common::market::OraclePriceProof { - collateral_asset_price: dec!("0.99"), - borrow_asset_price: Decimal::one(), - }, - ) + .get_borrow_status(borrow_user.id(), c.get_prices().await) .await .unwrap(); diff --git a/contract/market/tests/storage_management.rs b/contract/market/tests/storage_management.rs new file mode 100644 index 00000000..b64d7ee7 --- /dev/null +++ b/contract/market/tests/storage_management.rs @@ -0,0 +1,13 @@ +use test_utils::*; + +#[tokio::test] +#[should_panic = "is not registered"] +async fn registration_is_required() { + let SetupEverything { c, supply_user, .. } = setup_everything(|_| {}).await; + + let unregistered_account = c.worker.dev_create_account().await.unwrap(); + c.borrow_asset_transfer(&supply_user, unregistered_account.id(), 10000) + .await; + + c.supply(&unregistered_account, 1000).await; +} diff --git a/contract/market/tests/supply_maximum_amount.rs b/contract/market/tests/supply_maximum_amount.rs new file mode 100644 index 00000000..c5abd69d --- /dev/null +++ b/contract/market/tests/supply_maximum_amount.rs @@ -0,0 +1,50 @@ +use rstest::rstest; +use templar_common::asset::FungibleAssetAmount; +use test_utils::*; + +#[rstest] +#[case([10_000], 10_000)] +#[case([1_000, 9_000], 10_000)] +#[tokio::test] +async fn supply_within_maximum( + #[case] deposits: impl IntoIterator, + #[case] supply_maximum: u128, +) { + let SetupEverything { c, supply_user, .. } = setup_everything(|c| { + c.supply_maximum_amount = Some(FungibleAssetAmount::new(supply_maximum)); + }) + .await; + + let mut sum = 0; + for deposit in deposits { + sum += deposit; + c.supply(&supply_user, deposit).await; + } + + let supply_position = c.get_supply_position(supply_user.id()).await.unwrap(); + assert_eq!(u128::from(supply_position.get_borrow_asset_deposit()), sum); +} + +#[rstest] +#[case([10_001], 10_000)] +#[case([1, 100_000], 10_000)] +#[case([9_001, 500, 500], 10_000)] +#[case([1], 0)] +#[tokio::test] +#[should_panic = "Smart contract panicked: New supply position cannot exceed configured supply maximum"] +async fn supply_beyond_maximum( + #[case] deposits: impl IntoIterator, + #[case] supply_maximum: u128, +) { + let SetupEverything { c, supply_user, .. } = setup_everything(|c| { + c.supply_maximum_amount = Some(FungibleAssetAmount::new(supply_maximum)); + }) + .await; + + for deposit in deposits { + let r = c.supply(&supply_user, deposit).await; + for o in r.outcomes() { + o.clone().into_result().unwrap(); + } + } +} diff --git a/contract/market/tests/supply_withdrawal_fee.rs b/contract/market/tests/supply_withdrawal_fee.rs new file mode 100644 index 00000000..9644196d --- /dev/null +++ b/contract/market/tests/supply_withdrawal_fee.rs @@ -0,0 +1,110 @@ +use std::time::Duration; + +use near_sdk::json_types::U64; +use templar_common::fee::{Fee, TimeBasedFee, TimeBasedFeeFunction}; +use test_utils::*; + +#[tokio::test] +async fn supply_withdrawal_fee_flat() { + let fee = TimeBasedFee { + fee: Fee::Flat(100.into()), + duration: U64(1000 * 60 * 60 * 24 * 30), + behavior: TimeBasedFeeFunction::Fixed, + }; + + let SetupEverything { + c, + supply_user, + protocol_yield_user, + .. + } = setup_everything(|c| { + c.supply_withdrawal_fee = fee; + }) + .await; + + c.supply(&supply_user, 1000).await; + + eprintln!("Sleeping 10s..."); + tokio::time::sleep(Duration::from_secs(10)).await; + + let supply_user_balance_before = c.borrow_asset_balance_of(supply_user.id()).await; + let yield_before = c + .get_static_yield(protocol_yield_user.id()) + .await + .map_or(0, |r| u128::from(r.borrow_asset)); + + c.create_supply_withdrawal_request(&supply_user, 1000).await; + c.execute_next_supply_withdrawal_request(&supply_user).await; + + let supply_user_balance_after = c.borrow_asset_balance_of(supply_user.id()).await; + let yield_after = u128::from( + c.get_static_yield(protocol_yield_user.id()) + .await + .unwrap() + .borrow_asset, + ); + + assert_eq!( + supply_user_balance_after, + supply_user_balance_before + 900, + "Fee should be applied to early withdrawal", + ); + + assert_eq!( + yield_after, + yield_before + 100, + "Fee should be credited to the protocol account", + ); +} + +#[tokio::test] +async fn supply_withdrawal_fee_expired() { + let fee = TimeBasedFee { + fee: Fee::Flat(100.into()), + duration: U64(1000), // 1 second + behavior: TimeBasedFeeFunction::Fixed, + }; + + let SetupEverything { + c, + supply_user, + protocol_yield_user, + .. + } = setup_everything(|c| { + c.supply_withdrawal_fee = fee; + }) + .await; + + c.supply(&supply_user, 1000).await; + + eprintln!("Sleeping 10s..."); + tokio::time::sleep(Duration::from_secs(10)).await; + + let supply_user_balance_before = c.borrow_asset_balance_of(supply_user.id()).await; + let yield_before = c + .get_static_yield(protocol_yield_user.id()) + .await + .map_or(0, |r| u128::from(r.borrow_asset)); + + c.create_supply_withdrawal_request(&supply_user, 1000).await; + c.execute_next_supply_withdrawal_request(&supply_user).await; + + let supply_user_balance_after = c.borrow_asset_balance_of(supply_user.id()).await; + let yield_after = u128::from( + c.get_static_yield(protocol_yield_user.id()) + .await + .unwrap() + .borrow_asset, + ); + + assert_eq!( + supply_user_balance_after, + supply_user_balance_before + 1000, + "Fee should not be applied after period expires", + ); + + assert_eq!( + yield_after, yield_before, + "Fee should not be credited after period expires", + ); +} diff --git a/contract/market/tests/supply_withdrawal_queue.rs b/contract/market/tests/supply_withdrawal_queue.rs new file mode 100644 index 00000000..847d8115 --- /dev/null +++ b/contract/market/tests/supply_withdrawal_queue.rs @@ -0,0 +1,85 @@ +use rstest::rstest; + +use templar_common::withdrawal_queue::WithdrawalQueueStatus; +use test_utils::*; + +#[rstest] +#[tokio::test] +async fn successful_withdrawal() { + let SetupEverything { c, supply_user, .. } = setup_everything(|_| {}).await; + + c.supply(&supply_user, 10_000).await; + + let balance_before = c.borrow_asset_balance_of(supply_user.id()).await; + c.create_supply_withdrawal_request(&supply_user, 10_000) + .await; + let status = c.get_supply_withdrawal_queue_status().await; + assert_eq!( + status, + WithdrawalQueueStatus { + depth: 10_000.into(), + length: 1 + }, + ); + c.execute_next_supply_withdrawal_request(&supply_user).await; + let balance_after = c.borrow_asset_balance_of(supply_user.id()).await; + assert_eq!( + balance_before + 10_000, + balance_after, + "Supply user should receive full deposit back" + ); +} + +#[rstest] +#[tokio::test] +async fn unsuccessful_withdrawal() { + let SetupEverything { + c, + supply_user, + borrow_user, + .. + } = setup_everything(|_| {}).await; + + c.supply(&supply_user, 10_000).await; + c.collateralize(&borrow_user, 20_000).await; + c.borrow(&borrow_user, 5_000).await; + + let balance_before = c.borrow_asset_balance_of(supply_user.id()).await; + c.create_supply_withdrawal_request(&supply_user, 10_000) + .await; + let status = c.get_supply_withdrawal_queue_status().await; + assert_eq!( + status, + WithdrawalQueueStatus { + depth: 10_000.into(), + length: 1 + }, + ); + c.execute_next_supply_withdrawal_request(&supply_user).await; + let balance_after = c.borrow_asset_balance_of(supply_user.id()).await; + assert_eq!( + balance_before, balance_after, + "Supply user does not receive anything" + ); + + let status = c.get_supply_withdrawal_queue_status().await; + assert_eq!( + status, + WithdrawalQueueStatus { + depth: 10_000.into(), + length: 1 + }, + "Status of queue remains unchanged", + ); +} + +#[rstest] +#[tokio::test] +#[should_panic = "Smart contract panicked: Attempt to withdraw more than current deposit"] +async fn attempt_to_withdraw_more_than_deposit() { + let SetupEverything { c, supply_user, .. } = setup_everything(|_| {}).await; + + c.supply(&supply_user, 10_000).await; + c.create_supply_withdrawal_request(&supply_user, 12_000) + .await; +} diff --git a/contract/market/tests/untracked_funds.rs b/contract/market/tests/untracked_funds.rs new file mode 100644 index 00000000..43af95a5 --- /dev/null +++ b/contract/market/tests/untracked_funds.rs @@ -0,0 +1,41 @@ +use test_utils::*; + +#[tokio::test] +#[should_panic = "Smart contract panicked: Insufficient borrow asset available"] +async fn cannot_borrow_untracked_funds() { + let SetupEverything { + c, + supply_user, + borrow_user, + .. + } = setup_everything(|_| {}).await; + + c.supply(&supply_user, 10_000).await; + c.borrow_asset_transfer(&supply_user, c.contract.id(), 10_000) + .await; + c.collateralize(&borrow_user, 20_000).await; + c.borrow(&borrow_user, 12_000).await; +} + +#[tokio::test] +async fn can_withdraw_untracked_funds() { + let SetupEverything { + c, + supply_user, + borrow_user, + .. + } = setup_everything(|_| {}).await; + + c.supply(&supply_user, 10_000).await; + c.borrow_asset_transfer(&supply_user, c.contract.id(), 8_000) + .await; + c.collateralize(&borrow_user, 20_000).await; + c.borrow(&borrow_user, 8_000).await; + + let balance_before = c.borrow_asset_balance_of(supply_user.id()).await; + c.create_supply_withdrawal_request(&supply_user, 10_000) + .await; + c.execute_next_supply_withdrawal_request(&supply_user).await; + let balance_after = c.borrow_asset_balance_of(supply_user.id()).await; + assert_eq!(balance_before + 10_000, balance_after); +} diff --git a/docs/auditors.md b/docs/auditors.md new file mode 100644 index 00000000..ade1433d --- /dev/null +++ b/docs/auditors.md @@ -0,0 +1,20 @@ +# For auditors + +The SOW is `common` and `contract/market/src`. + +This project is a cross-chain DeFi lending protocol. + +To implement cross-chain behavior, it relies on NEAR Protocol's chain abstraction technologies, including the [NEAR MPC signer](https://github.com/near/mpc). + +> [!NOTE] +> The single-chain version of the contract does not use any multichain technologies. + +The contract relies on EMA prices from the local [Pyth oracle](https://www.pyth.network/). On testnet, the address is `pyth-oracle.testnet`, and on mainnet `pyth-oracle.near`. + +## Snapshots + +Interest and yield on borrow and supply positions (respectively) are calculated using a market-snapshot system. + +Every time a "time chunk" (wlog 1 hour, configurable) elapses, the contract takes a snapshot, recording such things as the total supply deposit, amount borrowed, timestamp, etc. Taking a snapshot is a relatively inexpensive process. + +Whenever a borrow or supply position update requires, interest/yield calculations are triggered. (They can also be triggered explicitly using `harvest_yield()` and `accumulate_interest()`.) These calculations iterate from the snapshot at which the record was last updated until the most-recently-finalized snapshot. diff --git a/docs/liquidation.md b/docs/liquidation.md index ef313153..25d26015 100644 --- a/docs/liquidation.md +++ b/docs/liquidation.md @@ -1,8 +1,5 @@ # Liquidation -> [!NOTE] -> Until price oracle support is fully implemented, the test version of the contract uses price data provided as an argument to the function call. - Liquidation is the process by which the asset collateralizing certain positions may be reappropriated e.g. to recover assets for an undercollateralized position. A liquidator is a third party willing to send a quantity of a market's borrow asset (usually a stablecoin) to the market in exchange for the amount of collateral asset supporting a specific account's position. As compensation for this service, the liquidator receives an exchange rate that is slightly better than the current rate. This difference in rates is called the "liquidator spread," and the maximum liquidator spread is configurable on a per-market basis. diff --git a/docs/structure.md b/docs/structure.md index 99131e98..fff34d61 100644 --- a/docs/structure.md +++ b/docs/structure.md @@ -2,4 +2,4 @@ If you are programming against the API of a Templar market, you can find the external interface specified in `common/src/market/external.rs`. -The NEAR smart contract that facilitates a market is specified in `contract/market/src/lib.rs`. +The NEAR smart contract that facilitates a market is specified in `contract/market/src`. diff --git a/mock/ft/Cargo.toml b/mock/ft/Cargo.toml index d8c87036..de142d04 100644 --- a/mock/ft/Cargo.toml +++ b/mock/ft/Cargo.toml @@ -3,6 +3,7 @@ name = "mock-ft" version = "0.1.0" edition = "2021" publish = false +license.workspace = true [lib] crate-type = ["cdylib", "rlib"] @@ -10,4 +11,4 @@ crate-type = ["cdylib", "rlib"] [dependencies] near-sdk.workspace = true near-contract-standards.workspace = true -near-sdk-contract-tools = "3.0.2" +near-sdk-contract-tools.workspace = true diff --git a/mock/ft/src/lib.rs b/mock/ft/src/lib.rs index 74fa7c01..97c68cf7 100644 --- a/mock/ft/src/lib.rs +++ b/mock/ft/src/lib.rs @@ -1,4 +1,4 @@ -use near_sdk::{env, json_types::U128, near, AccountId, PanicOnDefault}; +use near_sdk::{env, json_types::U128, near, PanicOnDefault}; use near_sdk_contract_tools::ft::*; #[derive(PanicOnDefault, FungibleToken)] @@ -7,20 +7,20 @@ pub struct Contract {} #[near] impl Contract { - #[payable] #[init] - pub fn new(name: String, symbol: String, owner_id: AccountId, supply: U128) -> Self { + pub fn new(name: String, symbol: String) -> Self { let mut contract = Self {}; Nep148Controller::set_metadata(&mut contract, &ContractMetadata::new(name, symbol, 24)); - Nep145Controller::deposit_to_storage_account( - &mut contract, - &owner_id, - env::attached_deposit(), - ) - .unwrap(); - Nep141Controller::mint(&mut contract, &Nep141Mint::new(supply.0, owner_id)).unwrap(); contract } + + pub fn mint(&mut self, amount: U128) { + Nep141Controller::mint( + self, + &Nep141Mint::new(amount.0, env::predecessor_account_id()), + ) + .unwrap(); + } } diff --git a/mock/oracle/Cargo.toml b/mock/oracle/Cargo.toml new file mode 100644 index 00000000..f75d58ec --- /dev/null +++ b/mock/oracle/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "mock-oracle" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +getrandom.workspace = true +near-sdk.workspace = true +near-contract-standards.workspace = true +templar-common.workspace = true diff --git a/mock/oracle/src/lib.rs b/mock/oracle/src/lib.rs new file mode 100644 index 00000000..e0e7d911 --- /dev/null +++ b/mock/oracle/src/lib.rs @@ -0,0 +1,60 @@ +use std::collections::HashMap; + +use near_sdk::{near, store::LookupMap, PanicOnDefault}; +use templar_common::oracle::pyth::{Price, PriceIdentifier, Pyth}; + +#[derive(PanicOnDefault)] +#[near(contract_state)] +pub struct Contract { + prices: LookupMap, +} + +#[near] +impl Contract { + #[init] + pub fn new() -> Self { + Self { + prices: LookupMap::new(b"p"), + } + } + + pub fn set_price(&mut self, price_identifier: PriceIdentifier, price: Price) { + self.prices.insert(price_identifier, price); + } +} + +#[near] +impl Pyth for Contract { + fn price_feed_exists(&self, price_identifier: PriceIdentifier) -> bool { + self.prices.contains_key(&price_identifier) + } + + fn list_ema_prices_no_older_than( + &self, + price_ids: Vec, + age: u64, + ) -> HashMap> { + let _ = age; + let mut r = HashMap::new(); + for price_id in price_ids { + r.insert(price_id, self.prices.get(&price_id).cloned()); + } + r + } +} + +#[cfg(target_arch = "wasm32")] +mod custom_getrandom { + #![allow(clippy::no_mangle_with_rust_abi)] + + use getrandom::{register_custom_getrandom, Error}; + use near_sdk::env; + + register_custom_getrandom!(custom_getrandom); + + #[allow(clippy::unnecessary_wraps)] + pub fn custom_getrandom(buf: &mut [u8]) -> Result<(), Error> { + buf.copy_from_slice(&env::random_seed_array()); + Ok(()) + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index a82ade34..c9bb4f93 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "stable" -components = ["rustfmt"] +channel = "1.85.0" +components = ["rustfmt", "cargo"] targets = ["wasm32-unknown-unknown"] diff --git a/script/ci/gas-report.sh b/script/ci/gas-report.sh new file mode 100755 index 00000000..8cc42282 --- /dev/null +++ b/script/ci/gas-report.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -e + +SCRIPT_DIR=$(dirname "$(readlink -f ${BASH_SOURCE[0]})") +source "$SCRIPT_DIR/../prebuild-test-contracts.sh" + +cargo run --package templar-market-contract --example gas_report diff --git a/script/ci/recover-nep141.sh b/script/ci/recover-nep141.sh new file mode 100755 index 00000000..982afddb --- /dev/null +++ b/script/ci/recover-nep141.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -e + +while [[ $# -gt 0 ]]; do + case "$1" in + -a|--account) + ACCOUNT_ID="$2" + shift 2 + ;; + -t|--token) + TOKEN_ID="$2" + shift 2 + ;; + -n|--network) + NETWORK="$2" + shift 2 + ;; + -b|--beneficiary) + BENEFICIARY_ID="$2" + shift 2 + ;; + -s|--private-key) + PRIVATE_KEY="$2" + shift 2 + ;; + -v|--public-key) + PUBLIC_KEY="$2" + shift 2 + ;; + *) + echo "Invalid option: $1" + exit 1 + ;; + esac +done + +if [ -z "$NETWORK" ]; then + NETWORK="testnet" +fi + +echo "Recovering $TOKEN_ID tokens for $ACCOUNT_ID on $NETWORK" + +echo "Transferring balance to $BENEFICIARY_ID" + +set +e # send all errors if balance is zero +near --quiet tokens "$ACCOUNT_ID" send-ft "$TOKEN_ID" "$BENEFICIARY_ID" all memo "" \ + network-config "$NETWORK" \ + sign-with-plaintext-private-key \ + --signer-public-key "$PUBLIC_KEY" \ + --signer-private-key "$PRIVATE_KEY" \ + send +set -e + +echo "Performing storage unregistration" + +near --quiet contract call-function as-transaction "$TOKEN_ID" storage_unregister \ + json-args '{"force":true}' \ + prepaid-gas '100.0 Tgas' \ + attached-deposit '1 yoctoNEAR' \ + sign-as "$ACCOUNT_ID" \ + network-config "$NETWORK" \ + sign-with-plaintext-private-key \ + --signer-public-key "$PUBLIC_KEY" \ + --signer-private-key "$PRIVATE_KEY" \ + send + +echo "Done" diff --git a/script/prebuild-test-contracts.sh b/script/prebuild-test-contracts.sh new file mode 100755 index 00000000..dc802809 --- /dev/null +++ b/script/prebuild-test-contracts.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -e + +export ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && cd .. && pwd)" + +cd "$ROOT_DIR/mock/oracle" +cargo near build non-reproducible-wasm 1>&2 + +cd "$ROOT_DIR/mock/ft" +cargo near build non-reproducible-wasm 1>&2 + +cd "$ROOT_DIR/contract/market" +cargo near build non-reproducible-wasm 1>&2 + +cd "$ROOT_DIR" +export TEST_CONTRACTS_PREBUILT=1 diff --git a/script/test.sh b/script/test.sh new file mode 100755 index 00000000..2c591215 --- /dev/null +++ b/script/test.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -e + +SCRIPT_DIR=$(dirname "$(readlink -f ${BASH_SOURCE[0]})") +source "$SCRIPT_DIR/./prebuild-test-contracts.sh" + +cargo nextest run "$@" diff --git a/test-utils/Cargo.toml b/test-utils/Cargo.toml index cad78601..3fbf44ee 100644 --- a/test-utils/Cargo.toml +++ b/test-utils/Cargo.toml @@ -3,9 +3,15 @@ name = "test-utils" version = "0.1.0" edition = "2021" publish = false +license.workspace = true [dependencies] +hex-literal = "0.4" near-sdk.workspace = true +near-sdk-contract-tools.workspace = true near-workspaces.workspace = true templar-common.workspace = true tokio.workspace = true + +[[example]] +name = "generate_testnet_configuration" diff --git a/test-utils/examples/generate_testnet_configuration.rs b/test-utils/examples/generate_testnet_configuration.rs new file mode 100644 index 00000000..b0da1328 --- /dev/null +++ b/test-utils/examples/generate_testnet_configuration.rs @@ -0,0 +1,61 @@ +#![allow(clippy::unwrap_used)] +//! Used by GitHub Actions to generate default market configuration. + +use std::str::FromStr; + +use near_sdk::serde_json; +use templar_common::{ + asset::{FungibleAsset, FungibleAssetAmount}, + dec, + fee::{Fee, TimeBasedFee}, + interest_rate_strategy::InterestRateStrategy, + market::{BalanceOracleConfiguration, MarketConfiguration, YieldWeights}, + number::Decimal, + oracle::pyth::PriceIdentifier, + time_chunk::TimeChunkConfiguration, +}; + +pub fn main() { + println!( + "{{\"configuration\":{}}}", + serde_json::to_string(&MarketConfiguration { + time_chunk_configuration: TimeChunkConfiguration::BlockTimestampMs { + divisor: (1000u64 * 60 * 10).into(), // every 10 minutes + }, + borrow_asset: FungibleAsset::nep141("usdt.fakes.testnet".parse().unwrap()), + collateral_asset: FungibleAsset::nep141("wrap.testnet".parse().unwrap()), + balance_oracle: BalanceOracleConfiguration { + account_id: "pyth-oracle.testnet".parse().unwrap(), + borrow_asset_price_id: PriceIdentifier(hex_literal::hex!( + "1fc18861232290221461220bd4e2acd1dcdfbc89c84092c93c18bdc7756c1588" + )), + borrow_asset_decimals: 6, + collateral_asset_price_id: PriceIdentifier(hex_literal::hex!( + "27e867f0f4f61076456d1a73b14c7edc1cf5cef4f4d6193a33424288f11bd0f4" + )), + collateral_asset_decimals: 24, + price_maximum_age_s: 60, + }, + borrow_mcr_initial: Decimal::from_str("1.25").unwrap(), + borrow_mcr: Decimal::from_str("1.2").unwrap(), + borrow_asset_maximum_usage_ratio: Decimal::from_str("0.99").unwrap(), + borrow_origination_fee: Fee::zero(), + borrow_interest_rate_strategy: InterestRateStrategy::piecewise( + Decimal::ZERO, + dec!("0.9"), + dec!("0.04"), + dec!("0.6") + ) + .unwrap(), + borrow_maximum_duration_ms: None, + borrow_minimum_amount: FungibleAssetAmount::new(1), + borrow_maximum_amount: FungibleAssetAmount::new(u128::MAX), + supply_withdrawal_fee: TimeBasedFee::zero(), + supply_maximum_amount: Some(FungibleAssetAmount::new(500 * 10u128.pow(6))), + yield_weights: YieldWeights::new_with_supply_weight(1), + liquidation_maximum_spread: Decimal::from_str("0.05").unwrap(), + protocol_account_id: "templar-in-training.testnet".parse().unwrap(), + }) + .unwrap(), + ); +} diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index ea481343..8a4ac668 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -1,88 +1,87 @@ use std::{path::Path, str::FromStr}; use near_sdk::{ - json_types::U128, + json_types::{I64, U128, U64}, serde_json::{self, json}, - AccountId, AccountIdRef, NearToken, + AccountId, Gas, NearToken, }; +use near_sdk_contract_tools::standard::nep145::StorageBalanceBounds; use near_workspaces::{ network::Sandbox, prelude::*, result::ExecutionSuccess, Account, Contract, DevNetwork, Worker, }; use templar_common::{ - asset::{BorrowAsset, BorrowAssetAmount, CollateralAssetAmount, FungibleAsset}, - balance_log::BalanceLog, + asset::{BorrowAssetAmount, CollateralAssetAmount, FungibleAsset}, borrow::{BorrowPosition, BorrowStatus}, + dec, fee::{Fee, TimeBasedFee}, + interest_rate_strategy::InterestRateStrategy, market::{ - LiquidateMsg, MarketConfiguration, Nep141MarketDepositMessage, OraclePriceProof, - YieldWeights, + BalanceOracleConfiguration, HarvestYieldMode, LiquidateMsg, MarketConfiguration, + Nep141MarketDepositMessage, YieldWeights, }, number::Decimal, + oracle::pyth::{self, OracleResponse, PriceIdentifier}, + snapshot::Snapshot, static_yield::StaticYieldRecord, supply::SupplyPosition, withdrawal_queue::{WithdrawalQueueStatus, WithdrawalRequestStatus}, }; use tokio::sync::OnceCell; -pub const EQUAL_PRICE: OraclePriceProof = OraclePriceProof { - collateral_asset_price: Decimal::one(), - borrow_asset_price: Decimal::one(), -}; - -pub const COLLATERAL_HALF_PRICE: OraclePriceProof = OraclePriceProof { - collateral_asset_price: Decimal::half(), - borrow_asset_price: Decimal::one(), -}; - -pub enum TestAsset { - Native, - Nep141(Contract), -} - -impl TestAsset { - pub fn is_native(&self) -> bool { - matches!(self, Self::Native) - } - - pub fn nep141_id(&self) -> Option<&AccountId> { - if let Self::Nep141(ref contract) = self { - Some(contract.id()) - } else { - None - } +pub fn to_price(price: f64) -> pyth::Price { + pyth::Price { + price: I64((price * 10000.0) as i64), + conf: U64(0), + expo: -4, + publish_time: 0, } } pub struct TestController { pub worker: Worker, pub contract: Contract, - pub borrow_asset: TestAsset, - pub collateral_asset: TestAsset, + pub config: MarketConfiguration, + pub balance_oracle: Contract, + pub borrow_asset: Contract, + pub collateral_asset: Contract, } impl TestController { pub async fn storage_deposits(&self, account: &Account) { - println!("Performing storage deposits for {}...", account.id()); - if let TestAsset::Nep141(ref borrow_asset) = self.borrow_asset { - account - .call(borrow_asset.id(), "storage_deposit") - .args_json(json!({})) - .deposit(NearToken::from_near(1)) - .transact() - .await - .unwrap() - .unwrap(); - } - if let TestAsset::Nep141(ref collateral_asset) = self.collateral_asset { - account - .call(collateral_asset.id(), "storage_deposit") - .args_json(json!({})) - .deposit(NearToken::from_near(1)) - .transact() - .await - .unwrap() - .unwrap(); - } + eprintln!("Performing storage deposits for {}...", account.id()); + let market_storage_bounds = self + .contract + .view("storage_balance_bounds") + .args_json(json!({})) + .await + .unwrap() + .json::() + .unwrap(); + eprintln!("Bounds: {market_storage_bounds:#?}"); + account + .call(self.contract.id(), "storage_deposit") + .args_json(json!({})) + .deposit(market_storage_bounds.min) + .transact() + .await + .unwrap() + .unwrap(); + account + .call(self.borrow_asset.id(), "storage_deposit") + .args_json(json!({})) + .deposit(NearToken::from_near(1)) + .transact() + .await + .unwrap() + .unwrap(); + account + .call(self.collateral_asset.id(), "storage_deposit") + .args_json(json!({})) + .deposit(NearToken::from_near(1)) + .transact() + .await + .unwrap() + .unwrap(); } pub async fn get_configuration(&self) -> MarketConfiguration { @@ -95,34 +94,86 @@ impl TestController { .unwrap() } - pub async fn supply_native(&self, supply_user: &Account, amount: u128) -> ExecutionSuccess { - supply_user - .call(self.contract.id(), "supply_native") + pub async fn get_finalized_snapshots_len(&self) -> u32 { + self.contract + .view("get_finalized_snapshots_len") .args_json(json!({})) - .deposit(NearToken::from_yoctonear(amount)) + .await + .unwrap() + .json::() + .unwrap() + } + + pub async fn list_finalized_snapshots( + &self, + offset: Option, + count: Option, + ) -> Vec { + self.contract + .view("list_finalized_snapshots") + .args_json(json!({ + "offset": offset, + "count": count, + })) + .await + .unwrap() + .json::>() + .unwrap() + } + + pub async fn set_collateral_asset_price(&self, price: f64) -> ExecutionSuccess { + eprintln!("Setting collateral asset price...",); + self.balance_oracle + .call("set_price") + .args_json(json!({ + "price_identifier": self.config.balance_oracle.collateral_asset_price_id, + "price": to_price(price), + })) + .transact() + .await + .unwrap() + .unwrap() + } + + pub async fn set_borrow_asset_price(&self, price: f64) -> ExecutionSuccess { + eprintln!("Setting borrow asset price...",); + self.balance_oracle + .call("set_price") + .args_json(json!({ + "price_identifier": self.config.balance_oracle.borrow_asset_price_id, + "price": to_price(price), + })) .transact() .await .unwrap() .unwrap() } + pub async fn get_prices(&self) -> OracleResponse { + self.balance_oracle + .view("list_ema_prices_no_older_than") + .args_json(json!({ "price_ids": [ + self.config.balance_oracle.borrow_asset_price_id, + self.config.balance_oracle.collateral_asset_price_id, + ], "age": self.config.balance_oracle.price_maximum_age_s })) + .await + .unwrap() + .json::() + .unwrap() + } + pub async fn supply(&self, supply_user: &Account, amount: u128) -> ExecutionSuccess { - println!( + eprintln!( "{} transferring {amount} tokens for supply...", supply_user.id() ); - match self.borrow_asset { - TestAsset::Native => self.supply_native(supply_user, amount).await, - TestAsset::Nep141(_) => { - self.borrow_asset_transfer_call( - supply_user, - self.contract.id(), - amount, - &serde_json::to_string(&Nep141MarketDepositMessage::Supply).unwrap(), - ) - .await - } - } + self.borrow_asset_transfer_call( + supply_user, + self.contract.id(), + amount, + &serde_json::to_string(&Nep141MarketDepositMessage::Supply).unwrap(), + ) + .await } pub async fn get_supply_position(&self, account_id: &AccountId) -> Option { @@ -137,44 +188,18 @@ impl TestController { .unwrap() } - pub async fn list_supplys(&self) -> Vec { - self.contract - .view("list_supplys") - .args_json(json!({})) - .await - .unwrap() - .json::>() - .unwrap() - } - - pub async fn collateralize_native(&self, borrow_user: &Account, amount: u128) { - borrow_user - .call(self.contract.id(), "collateralize_native") - .args_json(json!({})) - .deposit(NearToken::from_yoctonear(amount)) - .transact() - .await - .unwrap() - .unwrap(); - } - pub async fn collateralize(&self, borrow_user: &Account, amount: u128) { - println!( + eprintln!( "{} transferring {amount} tokens for collateral...", borrow_user.id(), ); - match self.collateral_asset { - TestAsset::Native => self.collateralize_native(borrow_user, amount).await, - TestAsset::Nep141(_) => { - self.collateral_asset_transfer_call( - borrow_user, - self.contract.id(), - amount, - &serde_json::to_string(&Nep141MarketDepositMessage::Collateralize).unwrap(), - ) - .await; - } - } + self.collateral_asset_transfer_call( + borrow_user, + self.contract.id(), + amount, + &serde_json::to_string(&Nep141MarketDepositMessage::Collateralize).unwrap(), + ) + .await; } pub async fn get_borrow_position(&self, account_id: &AccountId) -> Option { @@ -189,26 +214,16 @@ impl TestController { .unwrap() } - pub async fn list_borrows(&self) -> Vec { - self.contract - .view("list_borrows") - .args_json(json!({})) - .await - .unwrap() - .json::>() - .unwrap() - } - pub async fn get_borrow_status( &self, account_id: &AccountId, - price: OraclePriceProof, + oracle_response: OracleResponse, ) -> Option { self.contract .view("get_borrow_status") .args_json(json!({ "account_id": account_id, - "oracle_price_proof": price, + "oracle_response": oracle_response, })) .await .unwrap() @@ -216,65 +231,44 @@ impl TestController { .unwrap() } - pub async fn borrow(&self, borrow_user: &Account, amount: u128, price: OraclePriceProof) { - println!("{} borrowing {amount} tokens...", borrow_user.id()); + pub async fn borrow(&self, borrow_user: &Account, amount: u128) -> ExecutionSuccess { + eprintln!("{} borrowing {amount} tokens...", borrow_user.id()); borrow_user .call(self.contract.id(), "borrow") .args_json(json!({ "amount": U128(amount), - "oracle_price_proof": price, })) .max_gas() .transact() .await .unwrap() - .unwrap(); + .unwrap() } pub async fn collateral_asset_balance_of(&self, account_id: &AccountId) -> u128 { - match self.collateral_asset { - TestAsset::Native => self - .worker - .view_account(self.contract.id()) - .await - .map(|v| v.balance.as_yoctonear()) - .unwrap(), - TestAsset::Nep141(ref collateral_asset) => { - collateral_asset - .view("ft_balance_of") - .args_json(json!({ - "account_id": account_id, - })) - .await - .unwrap() - .json::() - .unwrap() - .0 - } - } + self.collateral_asset + .view("ft_balance_of") + .args_json(json!({ + "account_id": account_id, + })) + .await + .unwrap() + .json::() + .unwrap() + .0 } pub async fn borrow_asset_balance_of(&self, account_id: &AccountId) -> u128 { - match self.borrow_asset { - TestAsset::Native => self - .worker - .view_account(self.contract.id()) - .await - .map(|v| v.balance.as_yoctonear() - v.locked.as_yoctonear()) - .unwrap(), - TestAsset::Nep141(ref borrow_asset) => { - borrow_asset - .view("ft_balance_of") - .args_json(json!({ - "account_id": account_id, - })) - .await - .unwrap() - .json::() - .unwrap() - .0 - } - } + self.borrow_asset + .view("ft_balance_of") + .args_json(json!({ + "account_id": account_id, + })) + .await + .unwrap() + .json::() + .unwrap() + .0 } pub async fn asset_transfer( @@ -284,7 +278,7 @@ impl TestController { receiver_id: &AccountId, amount: u128, ) { - println!( + eprintln!( "{} sending {amount} tokens of {asset_id} to {receiver_id}...", sender.id(), ); @@ -309,7 +303,7 @@ impl TestController { amount: u128, msg: &str, ) -> ExecutionSuccess { - println!( + eprintln!( "{} sending {amount} tokens of {asset_id} to {receiver_id} with msg {msg}...", sender.id(), ); @@ -334,19 +328,8 @@ impl TestController { receiver_id: &AccountId, amount: u128, ) { - match self.borrow_asset { - TestAsset::Native => { - sender - .transfer_near(receiver_id, NearToken::from_yoctonear(amount)) - .await - .unwrap() - .unwrap(); - } - TestAsset::Nep141(ref contract) => { - self.asset_transfer(contract.id(), sender, receiver_id, amount) - .await; - } - } + self.asset_transfer(self.borrow_asset.id(), sender, receiver_id, amount) + .await; } pub async fn borrow_asset_transfer_call( @@ -356,12 +339,8 @@ impl TestController { amount: u128, msg: &str, ) -> ExecutionSuccess { - if let TestAsset::Nep141(ref borrow_asset) = self.borrow_asset { - self.asset_transfer_call(borrow_asset.id(), sender, receiver_id, amount, msg) - .await - } else { - panic!("Cannot perform an ft_transfer_call with a native asset"); - } + self.asset_transfer_call(self.borrow_asset.id(), sender, receiver_id, amount, msg) + .await } pub async fn collateral_asset_transfer_call( @@ -371,49 +350,73 @@ impl TestController { amount: u128, msg: &str, ) -> ExecutionSuccess { - if let TestAsset::Nep141(ref collateral_asset) = self.collateral_asset { - self.asset_transfer_call(collateral_asset.id(), sender, receiver_id, amount, msg) - .await - } else { - panic!("Cannot perform an ft_transfer_call with a native asset"); - } + self.asset_transfer_call(self.collateral_asset.id(), sender, receiver_id, amount, msg) + .await } - pub async fn repay_native(&self, borrow_user: &Account, amount: u128) { + pub async fn repay(&self, borrow_user: &Account, amount: u128) -> ExecutionSuccess { + eprintln!("{} repaying {amount} tokens...", borrow_user.id()); + self.borrow_asset_transfer_call( + borrow_user, + self.contract.id(), + amount, + &serde_json::to_string(&Nep141MarketDepositMessage::Repay).unwrap(), + ) + .await + } + + pub async fn apply_interest( + &self, + borrow_user: &Account, + snapshot_limit: Option, + ) -> ExecutionSuccess { + eprintln!("{} applying interest...", borrow_user.id()); borrow_user - .call(self.contract.id(), "repay_native") - .args_json(json!({})) - .deposit(NearToken::from_yoctonear(amount)) + .call(self.contract.id(), "apply_interest") + .args_json(json!({ + "snapshot_limit": snapshot_limit, + })) + .max_gas() .transact() .await .unwrap() - .unwrap(); + .unwrap() } - pub async fn repay(&self, borrow_user: &Account, amount: u128) { - println!("{} repaying {amount} tokens...", borrow_user.id()); - match self.borrow_asset { - TestAsset::Native => self.repay_native(borrow_user, amount).await, - TestAsset::Nep141(_) => { - self.borrow_asset_transfer_call( - borrow_user, - self.contract.id(), - amount, - &serde_json::to_string(&Nep141MarketDepositMessage::Repay).unwrap(), - ) - .await; - } - } + pub async fn harvest_yield_execution( + &self, + supply_user: &Account, + mode: Option, + ) -> ExecutionSuccess { + eprintln!("{} harvesting yield...", supply_user.id()); + supply_user + .call(self.contract.id(), "harvest_yield") + .args_json(json!({ + "mode": mode, + })) + .max_gas() + .transact() + .await + .unwrap() + .unwrap() } - pub async fn harvest_yield(&self, supply_user: &Account) -> ExecutionSuccess { - println!("{} harvesting yield...", supply_user.id()); + pub async fn harvest_yield( + &self, + supply_user: &Account, + mode: Option, + ) -> BorrowAssetAmount { + eprintln!("{} harvesting yield...", supply_user.id()); supply_user .call(self.contract.id(), "harvest_yield") - .args_json(json!({})) + .args_json(json!({ + "mode": mode, + })) + .max_gas() .transact() .await .unwrap() + .json::() .unwrap() } @@ -422,31 +425,15 @@ impl TestController { account: &Account, borrow_asset_amount: Option, collateral_asset_amount: Option, - ) { - println!("{} withdrawing static yield...", account.id()); + ) -> ExecutionSuccess { + eprintln!("{} withdrawing static yield...", account.id()); account .call(self.contract.id(), "withdraw_static_yield") .args_json(json!({ "borrow_asset_amount": borrow_asset_amount, "collateral_asset_amount": collateral_asset_amount, })) - .transact() - .await - .unwrap() - .unwrap(); - } - - pub async fn withdraw_supply_yield( - &self, - supply_user: &Account, - amount: Option, - ) -> ExecutionSuccess { - println!("{} withdrawing supply yield...", supply_user.id()); - supply_user - .call(self.contract.id(), "withdraw_supply_yield") - .args_json(json!({ - "amount": amount.map(U128), - })) + .gas(Gas::from_tgas(20)) .transact() .await .unwrap() @@ -469,15 +456,14 @@ impl TestController { &self, borrow_user: &Account, amount: u128, - price: Option, ) -> ExecutionSuccess { - println!("{} withdrawing {amount} collateral...", borrow_user.id()); + eprintln!("{} withdrawing {amount} collateral...", borrow_user.id()); borrow_user .call(self.contract.id(), "withdraw_collateral") .args_json(json!({ "amount": U128(amount), - "oracle_price_proof": price, })) + .gas(Gas::from_tgas(20)) .transact() .await .unwrap() @@ -485,7 +471,7 @@ impl TestController { } pub async fn create_supply_withdrawal_request(&self, supply_user: &Account, amount: u128) { - println!( + eprintln!( "{} creating supply withdrawal request for {amount}...", supply_user.id() ); @@ -525,38 +511,22 @@ impl TestController { .unwrap() } - pub async fn execute_next_supply_withdrawal_request(&self, account: &Account) { - println!( + pub async fn execute_next_supply_withdrawal_request( + &self, + account: &Account, + ) -> ExecutionSuccess { + eprintln!( "{} executing next supply withdrawal request...", account.id(), ); account .call(self.contract.id(), "execute_next_supply_withdrawal_request") .args_json(json!({})) + .gas(Gas::from_tgas(20)) .transact() .await .unwrap() - .unwrap(); - } - - pub async fn liquidate_native( - &self, - liquidator_user: &Account, - account_id: &AccountId, - borrow_asset_amount: u128, - oracle_price_proof: OraclePriceProof, - ) { - liquidator_user - .call(self.contract.id(), "liquidate_native") - .args_json(json!({ - "account_id": account_id, - "oracle_price_proof": oracle_price_proof, - })) - .deposit(NearToken::from_yoctonear(borrow_asset_amount)) - .transact() - .await .unwrap() - .unwrap(); } pub async fn liquidate( @@ -564,68 +534,76 @@ impl TestController { liquidator_user: &Account, account_id: &AccountId, borrow_asset_amount: u128, - oracle_price_proof: OraclePriceProof, - ) { - println!( + ) -> ExecutionSuccess { + eprintln!( "{} executing liquidation against {} for {}...", liquidator_user.id(), account_id, borrow_asset_amount, ); - match self.borrow_asset { - TestAsset::Native => { - self.liquidate_native( - liquidator_user, - account_id, - borrow_asset_amount, - oracle_price_proof, - ) - .await - } - TestAsset::Nep141(_) => { - self.borrow_asset_transfer_call( - liquidator_user, - self.contract.id(), - borrow_asset_amount, - &serde_json::to_string(&Nep141MarketDepositMessage::Liquidate(LiquidateMsg { - account_id: account_id.clone(), - oracle_price_proof, - })) - .unwrap(), - ) - .await; - } - } + self.borrow_asset_transfer_call( + liquidator_user, + self.contract.id(), + borrow_asset_amount, + &serde_json::to_string(&Nep141MarketDepositMessage::Liquidate(LiquidateMsg { + account_id: account_id.clone(), + })) + .unwrap(), + ) + .await } - #[allow(unused)] // This is useful for debugging tests - pub async fn print_logs(&self) { - let total_borrow_asset_deposited_logs = self - .contract - .view("get_total_borrow_asset_deposited_log") - .args_json(json!({})) + pub async fn mint_asset(&self, ft_id: &AccountId, receiver: &Account, amount: u128) { + eprintln!("{} minting {amount} of {}...", receiver.id(), ft_id); + receiver + .call(ft_id, "mint") + .args_json(json!({ + "amount": U128(amount), + })) + .transact() .await .unwrap() - .json::>>() .unwrap(); + } - println!("Total borrow asset deposited log:"); - for (i, log) in total_borrow_asset_deposited_logs.iter().enumerate() { - println!("\t{i}: {}\t[{}]", log.amount.as_u128(), log.chain_time); - } + pub async fn mint_collateral_asset(&self, receiver: &Account, amount: u128) { + self.mint_asset(self.collateral_asset.id(), receiver, amount) + .await; + } + + pub async fn mint_borrow_asset(&self, receiver: &Account, amount: u128) { + self.mint_asset(self.borrow_asset.id(), receiver, amount) + .await; + } + + pub async fn get_last_yield_rate(&self) -> Decimal { + self.contract + .view("get_last_yield_rate") + .args_json(json!({})) + .await + .unwrap() + .json() + .unwrap() + } - let borrow_asset_yield_distribution_logs = self + #[allow(unused)] // This is useful for debugging tests + pub async fn print_snapshots(&self) { + let snapshots = self .contract - .view("get_borrow_asset_yield_distribution_log") + .view("list_finalized_snapshots") .args_json(json!({})) .await .unwrap() - .json::>>() + .json::>() .unwrap(); - println!("Borrow asset yield distribution log:"); - for (i, log) in borrow_asset_yield_distribution_logs.iter().enumerate() { - println!("\t{i}: {}\t[{}]", log.amount.as_u128(), log.chain_time); + eprintln!("Market snapshots:"); + for (i, snapshot) in snapshots.iter().enumerate() { + eprintln!("\t{i}: {}", snapshot.time_chunk.0 .0); + eprintln!("\t\tTimestamp:\t{}", snapshot.end_timestamp_ms.0); + eprintln!("\t\tDeposited:\t{}", snapshot.deposited); + eprintln!("\t\tBorrowed:\t{}", snapshot.borrowed); + eprintln!("\t\tDistribution:\t{}", snapshot.yield_distribution); } } } @@ -643,30 +621,54 @@ pub async fn create_prefixed_account { - let ($($n,)*) = tokio::join!( $(create_prefixed_account(stringify!($n), &$w)),* ); + $(let $n = create_prefixed_account(stringify!($n), &$w).await;)* }; } pub fn market_configuration( + balance_oracle_id: AccountId, borrow_asset_id: AccountId, collateral_asset_id: AccountId, + protocol_account_id: AccountId, yield_weights: YieldWeights, ) -> MarketConfiguration { MarketConfiguration { + time_chunk_configuration: templar_common::time_chunk::TimeChunkConfiguration::BlockHeight { + divisor: U64(1), + }, borrow_asset: FungibleAsset::nep141(borrow_asset_id), collateral_asset: FungibleAsset::nep141(collateral_asset_id), - balance_oracle_account_id: "balance_oracle".parse().unwrap(), - minimum_initial_collateral_ratio: Decimal::from_str("1.25").unwrap(), - minimum_collateral_ratio_per_borrow: Decimal::from_str("1.2").unwrap(), - maximum_borrow_asset_usage_ratio: Decimal::from_str("0.99").unwrap(), + balance_oracle: BalanceOracleConfiguration { + account_id: balance_oracle_id, + collateral_asset_price_id: PriceIdentifier(hex_literal::hex!( + "1fc18861232290221461220bd4e2acd1dcdfbc89c84092c93c18bdc7756c1588" + )), + collateral_asset_decimals: 24, + borrow_asset_price_id: PriceIdentifier(hex_literal::hex!( + "27e867f0f4f61076456d1a73b14c7edc1cf5cef4f4d6193a33424288f11bd0f4" + )), + borrow_asset_decimals: 24, + price_maximum_age_s: 60, + }, + borrow_mcr_initial: Decimal::from_str("1.25").unwrap(), + borrow_mcr: Decimal::from_str("1.2").unwrap(), + borrow_asset_maximum_usage_ratio: Decimal::from_str("0.99").unwrap(), borrow_origination_fee: Fee::Proportional(Decimal::from_str("0.1").unwrap()), - borrow_annual_maintenance_fee: Fee::zero(), - maximum_borrow_duration_ms: None, - minimum_borrow_amount: 1.into(), - maximum_borrow_amount: u128::MAX.into(), - maximum_liquidator_spread: Decimal::from_str("0.05").unwrap(), + borrow_interest_rate_strategy: InterestRateStrategy::piecewise( + Decimal::ZERO, + dec!("0.9"), + dec!("0.04"), + dec!("0.6"), + ) + .unwrap(), + borrow_maximum_duration_ms: None, + borrow_minimum_amount: 1.into(), + borrow_maximum_amount: u128::MAX.into(), + liquidation_maximum_spread: Decimal::from_str("0.05").unwrap(), supply_withdrawal_fee: TimeBasedFee::zero(), + supply_maximum_amount: None, yield_weights, + protocol_account_id, } } @@ -696,6 +698,7 @@ async fn get_contract(name: &str, path: &str) -> Vec { pub static WASM_MARKET: OnceCell> = OnceCell::const_new(); pub static WASM_MOCK_FT: OnceCell> = OnceCell::const_new(); +pub static WASM_MOCK_ORACLE: OnceCell> = OnceCell::const_new(); pub async fn setup_market( worker: &Worker, @@ -706,7 +709,7 @@ pub async fn setup_market( .await; let contract = worker.dev_deploy(wasm).await.unwrap(); - contract + let init_call = contract .call("new") .args_json(json!({ "configuration": configuration, @@ -716,16 +719,34 @@ pub async fn setup_market( .unwrap() .unwrap(); + eprintln!("Init call logs"); + eprintln!("--------------"); + for log in init_call.logs() { + eprintln!("\t{log}"); + } + eprintln!("--------------"); + contract } -pub async fn deploy_ft( - account: Account, - name: &str, - symbol: &str, - owner_id: &AccountIdRef, - supply: u128, -) -> Contract { +pub async fn deploy_oracle(account: Account) -> Contract { + let wasm = WASM_MOCK_ORACLE + .get_or_init(|| get_contract("mock_oracle", "mock/oracle")) + .await; + + let contract = account.deploy(wasm).await.unwrap().unwrap(); + contract + .call("new") + .args_json(json!({})) + .transact() + .await + .unwrap() + .unwrap(); + + contract +} + +pub async fn deploy_ft(account: Account, name: &str, symbol: &str) -> Contract { let wasm = WASM_MOCK_FT .get_or_init(|| get_contract("mock_ft", "mock/ft")) .await; @@ -736,10 +757,7 @@ pub async fn deploy_ft( .args_json(json!({ "name": name, "symbol": symbol, - "owner_id": owner_id, - "supply": U128(supply), })) - .deposit(NearToken::from_near(1)) .transact() .await .unwrap() @@ -752,7 +770,9 @@ pub struct SetupEverything { pub c: TestController, pub liquidator_user: Account, pub supply_user: Account, + pub supply_user_2: Account, pub borrow_user: Account, + pub borrow_user_2: Account, pub protocol_yield_user: Account, pub insurance_yield_user: Account, } @@ -765,65 +785,70 @@ pub async fn setup_everything( worker, liquidator_user, supply_user, + supply_user_2, borrow_user, + borrow_user_2, protocol_yield_user, insurance_yield_user, collateral_asset, - borrow_asset + borrow_asset, + balance_oracle ); let mut config = market_configuration( + balance_oracle.id().clone(), borrow_asset.id().clone(), collateral_asset.id().clone(), + protocol_yield_user.id().clone(), YieldWeights::new_with_supply_weight(8) .with_static(protocol_yield_user.id().clone(), 1) .with_static(insurance_yield_user.id().clone(), 1), ); customize_market_configuration(&mut config); - let (contract, borrow_asset, collateral_asset) = tokio::join!( + let (contract, balance_oracle, borrow_asset, collateral_asset) = tokio::join!( setup_market(&worker, &config), - deploy_ft( - borrow_asset, - "Borrow Asset", - "BORROW", - supply_user.id(), - 200000, - ), - deploy_ft( - collateral_asset, - "Collateral Asset", - "COLLATERAL", - borrow_user.id(), - 100000, - ), + deploy_oracle(balance_oracle), + deploy_ft(borrow_asset, "Borrow Asset", "BORROW"), + deploy_ft(collateral_asset, "Collateral Asset", "COLLATERAL"), ); - let collateral_asset = config - .collateral_asset - .into_nep141() - .map_or(TestAsset::Native, |_| TestAsset::Nep141(collateral_asset)); - let borrow_asset = config - .borrow_asset - .into_nep141() - .map_or(TestAsset::Native, |_| TestAsset::Nep141(borrow_asset)); - let c = TestController { worker, + config, contract, + balance_oracle, collateral_asset, borrow_asset, }; + c.set_borrow_asset_price(1.0).await; + c.set_collateral_asset_price(1.0).await; + // Asset opt-ins. tokio::join!( c.storage_deposits(c.contract.as_account()), async { c.storage_deposits(&liquidator_user).await; - c.borrow_asset_transfer(&supply_user, liquidator_user.id(), 100000) - .await; + c.mint_borrow_asset(&liquidator_user, 100_000_000).await; + }, + async { + c.storage_deposits(&borrow_user).await; + c.mint_collateral_asset(&borrow_user, 100_000_000).await; + c.mint_borrow_asset(&borrow_user, 100_000_000).await; + }, + async { + c.storage_deposits(&borrow_user_2).await; + c.mint_collateral_asset(&borrow_user_2, 100_000_000).await; + c.mint_borrow_asset(&borrow_user_2, 100_000_000).await; + }, + async { + c.storage_deposits(&supply_user).await; + c.mint_borrow_asset(&supply_user, 100_000_000).await; + }, + async { + c.storage_deposits(&supply_user_2).await; + c.mint_borrow_asset(&supply_user_2, 100_000_000).await; }, - c.storage_deposits(&borrow_user), - c.storage_deposits(&supply_user), c.storage_deposits(&protocol_yield_user), c.storage_deposits(&insurance_yield_user), ); @@ -832,7 +857,9 @@ pub async fn setup_everything( c, liquidator_user, supply_user, + supply_user_2, borrow_user, + borrow_user_2, protocol_yield_user, insurance_yield_user, } diff --git a/test.sh b/test.sh deleted file mode 100755 index e37a6e64..00000000 --- a/test.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -e - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -cd "$ROOT_DIR/mock/ft" -cargo near build non-reproducible-wasm - -cd "$ROOT_DIR/contract/market" -cargo near build non-reproducible-wasm - -cd "$ROOT_DIR" -export TEST_CONTRACTS_PREBUILT=1 -cargo nextest run --retries 1 "$@"