diff --git a/src/lib.rs b/src/lib.rs index 55dafe4..5b9f851 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ #![deny(warnings)] mod event; mod ft; +mod oracle; mod owner; mod stable; mod staking; @@ -21,6 +22,7 @@ use near_sdk::{ assert_one_yocto, env, ext_contract, is_promise_success, near_bindgen, sys, AccountId, Balance, BorshStorageKey, Gas, PanicOnDefault, Promise, PromiseOrValue, ONE_YOCTO, }; +use oracle::{ExchangeRate, Oracle, PriceData}; use std::fmt::Debug; @@ -35,6 +37,10 @@ const NO_DEPOSIT: Balance = 0; const USN_DECIMALS: u8 = 18; const GAS_FOR_REFUND_PROMISE: Gas = Gas(5_000_000_000_000); const GAS_FOR_FT_TRANSFER: Gas = Gas(25_000_000_000_000); +const GAS_FOR_BUY_PROMISE: Gas = Gas(10_000_000_000_000); +const MIN_COLLATERAL_RATIO: u32 = 100; +const MAX_COLLATERAL_RATIO: u32 = 1000; +const PERCENT_MULTIPLIER: u128 = 100; #[derive(BorshStorageKey, BorshSerialize)] enum StorageKey { @@ -62,6 +68,14 @@ pub enum ContractStatus { Paused, } +#[derive(Serialize, Deserialize, Clone)] +#[serde(crate = "near_sdk::serde")] +pub struct ExpectedRate { + pub multiplier: U128, + pub slippage: U128, + pub decimals: u8, +} + impl std::fmt::Display for ContractStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -139,6 +153,7 @@ pub struct Contract { status: ContractStatus, commission: CommissionV1, stable_treasury: StableTreasury, + oracle: Oracle, } const DATA_IMAGE_SVG_NEAR_ICON: &str = @@ -156,16 +171,59 @@ pub trait FtApi { #[ext_contract(ext_self)] trait ContractCallback { + #[private] + fn mint_with_price_callback( + &mut self, + near: U128, + collateral_ratio: u32, + #[callback] price: PriceData, + ) -> U128; + + #[private] + fn handle_refund(&mut self, attached_deposit: U128); + #[private] fn handle_withdraw_refund(&mut self, account_id: AccountId, token_id: AccountId, amount: U128); } trait ContractCallback { + fn mint_with_price_callback( + &mut self, + near: U128, + collateral_ratio: u32, + price: PriceData, + ) -> U128; + + fn handle_refund(&mut self, attached_deposit: U128); + fn handle_withdraw_refund(&mut self, account_id: AccountId, token_id: AccountId, amount: U128); } #[near_bindgen] impl ContractCallback for Contract { + #[private] + fn mint_with_price_callback( + &mut self, + near: U128, + collateral_ratio: u32, + #[callback] price: PriceData, + ) -> U128 { + let rate: ExchangeRate = price.into(); + assert!(near.0 > 0, "Amount should be positive"); + + self.finish_mint_by_near(near.0, rate, collateral_ratio) + .into() + } + + #[private] + fn handle_refund(&mut self, attached_deposit: U128) { + if !is_promise_success() { + Promise::new(self.owner_id.clone()) + .transfer(attached_deposit.0) + .as_return(); + } + } + #[private] fn handle_withdraw_refund(&mut self, account_id: AccountId, token_id: AccountId, amount: U128) { if !is_promise_success() { @@ -204,6 +262,7 @@ impl Contract { status: ContractStatus::Working, commission: CommissionV1::default(), stable_treasury: StableTreasury::new(StorageKey::StableTreasury), + oracle: Oracle::default(), }; this @@ -322,11 +381,6 @@ impl Contract { pub smooth: ExchangeRate, } - #[derive(BorshSerialize, BorshDeserialize)] - struct Oracle { - pub last_report: Option, - } - #[derive(BorshDeserialize, BorshSerialize)] struct ExponentialSpreadParams { pub min: f64, @@ -429,6 +483,7 @@ impl Contract { &mut prev.stable_treasury, StorageKey::StableTreasury, ), + oracle: prev.oracle, } } @@ -572,6 +627,67 @@ impl FungibleTokenReceiver for Contract { #[near_bindgen] impl Contract { + // Owner only + #[payable] + pub fn mint_by_near(&mut self, collateral_ratio: u32) { + self.assert_owner(); + self.abort_if_pause(); + assert!( + collateral_ratio >= MIN_COLLATERAL_RATIO && collateral_ratio <= MAX_COLLATERAL_RATIO, + "Collateral ratio is out of bounds" + ); + + let near = env::attached_deposit(); + + Oracle::get_exchange_rate_promise() + .then(ext_self::mint_with_price_callback( + near.into(), + collateral_ratio, + env::current_account_id(), + NO_DEPOSIT, + GAS_FOR_BUY_PROMISE, + )) + // Returning callback promise, so the transaction will return the value or a failure. + // But the refund will still happen. + .as_return() + .then(ext_self::handle_refund( + near.into(), + env::current_account_id(), + NO_DEPOSIT, + GAS_FOR_REFUND_PROMISE, + )); + } + + fn finish_mint_by_near( + &mut self, + near: Balance, + rate: ExchangeRate, + collateral_ratio: u32, + ) -> Balance { + let near = U256::from(near); + let multiplier = U256::from(rate.multiplier()); + let collateral_ratio = U256::from(collateral_ratio); + + // Make exchange: NEAR -> USN + let amount = near * multiplier / 10u128.pow(u32::from(rate.decimals() - USN_DECIMALS)); + + // Apply collateral rate + let amount = amount * U256::from(PERCENT_MULTIPLIER) / collateral_ratio; + + // Expected result (128-bit) can have 20 digits before and 18 after the decimal point. + // We don't expect more than 10^20 tokens on a single account. It panics if overflows. + let amount = amount.as_u128(); + + if amount == 0 { + env::panic_str("Not enough NEAR: attached deposit exchanges to 0 tokens"); + } + + self.token.internal_deposit(&self.owner_id, amount); + event::emit::ft_mint(&self.owner_id, amount, None); + + amount + } + #[payable] pub fn withdraw(&mut self, asset_id: Option, amount: U128) -> Promise { let account_id = env::predecessor_account_id(); @@ -695,7 +811,7 @@ impl Contract { #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use near_sdk::test_utils::{accounts, VMContextBuilder}; - use near_sdk::{testing_env, Balance, ONE_YOCTO}; + use near_sdk::{testing_env, Balance, ONE_NEAR, ONE_YOCTO}; use super::*; @@ -922,6 +1038,60 @@ mod tests { contract.withdraw(None, U128(999900000000000000000)); } + #[test] + #[should_panic(expected = "This method can be called only by owner")] + fn test_buy_not_owner() { + let mut context = get_context(accounts(1)); + testing_env!(context.build()); + + let mut contract = Contract::new(accounts(1)); + + testing_env!(context + .predecessor_account_id(accounts(2)) + .attached_deposit(ONE_NEAR) + .build()); + contract.mint_by_near(100); + } + + #[test] + #[should_panic] + fn test_buy_low_collateral_rate() { + let mut context = get_context(accounts(1)); + testing_env!(context.build()); + + let mut contract = Contract::new(accounts(1)); + + testing_env!(context.attached_deposit(ONE_NEAR).build()); + contract.mint_by_near(MIN_COLLATERAL_RATIO - 1); + } + + #[test] + #[should_panic] + fn test_buy_exceeded_collateral_rate() { + let mut context = get_context(accounts(1)); + testing_env!(context.build()); + + let mut contract = Contract::new(accounts(1)); + + testing_env!(context.attached_deposit(ONE_NEAR).build()); + contract.mint_by_near(MAX_COLLATERAL_RATIO + 1); + } + + #[test] + fn test_owner_buy() { + let context = get_context(accounts(1)); + testing_env!(context.build()); + + let mut contract = Contract::new(accounts(1)); + + let fresh_rate = ExchangeRate::test_fresh_rate(); + + assert_eq!( + contract.finish_mint_by_near(1_000_000_000_000 * ONE_NEAR, fresh_rate.clone(), 100), + 11143900000000_000000000000000000 + ); + } + #[test] fn test_view_commission() { let context = get_context(accounts(1)); diff --git a/src/oracle/mod.rs b/src/oracle/mod.rs new file mode 100644 index 0000000..fe4c87b --- /dev/null +++ b/src/oracle/mod.rs @@ -0,0 +1,8 @@ +mod oracle; +mod priceoracle; + +pub use oracle::*; + +// Exposing original priceoracle DTO allows to decrease +// gas consumption from 25 to 19 TGas (~24%). +pub use priceoracle::PriceData; diff --git a/src/oracle/oracle.rs b/src/oracle/oracle.rs new file mode 100644 index 0000000..6b4b27d --- /dev/null +++ b/src/oracle/oracle.rs @@ -0,0 +1,106 @@ +use near_sdk::Timestamp; + +use crate::oracle::priceoracle::{ext_priceoracle, PriceData}; +use crate::*; + +struct OracleConfig { + oracle_address: &'static str, + asset_id: &'static str, + gas: Gas, +} + +const CONFIG: OracleConfig = if cfg!(feature = "mainnet") { + OracleConfig { + oracle_address: "priceoracle.near", + asset_id: "wrap.near", // NEARUSDT + gas: Gas(5_000_000_000_000), + } +} else if cfg!(feature = "testnet") { + OracleConfig { + oracle_address: "priceoracle.testnet", + asset_id: "wrap.testnet", // NEARUSDT + gas: Gas(5_000_000_000_000), + } +} else { + OracleConfig { + oracle_address: "priceoracle.test.near", + asset_id: "wrap.test.near", + gas: Gas(5_000_000_000_000), + } +}; + +#[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize, Clone)] +#[serde(crate = "near_sdk::serde")] +pub struct ExchangeRate { + multiplier: u128, + decimals: u8, + timestamp: Timestamp, + recency_duration: Timestamp, +} + +impl ExchangeRate { + pub fn multiplier(&self) -> u128 { + self.multiplier + } + + pub fn decimals(&self) -> u8 { + self.decimals + } + + pub fn timestamp(&self) -> Timestamp { + self.timestamp + } +} + +#[derive(BorshSerialize, BorshDeserialize)] +pub struct Oracle { + pub last_report: Option, +} + +impl Default for Oracle { + fn default() -> Self { + Self { last_report: None } + } +} + +impl Oracle { + pub fn get_exchange_rate_promise() -> Promise { + ext_priceoracle::get_price_data( + vec![CONFIG.asset_id.into()], + CONFIG.oracle_address.parse().unwrap(), + 0, + CONFIG.gas, + ) + } +} + +impl From for ExchangeRate { + fn from(price_data: PriceData) -> Self { + let price = price_data.price(&CONFIG.asset_id.into()); + + if env::block_timestamp() >= price_data.timestamp() + price_data.recency_duration() { + env::panic_str("Oracle provided an outdated price data"); + } + + let exchange_rate = ExchangeRate { + multiplier: price.multiplier.into(), + decimals: price.decimals, + timestamp: price_data.timestamp(), + recency_duration: price_data.recency_duration(), + }; + + exchange_rate + } +} + +#[cfg(test)] +impl ExchangeRate { + pub fn test_fresh_rate() -> Self { + Self { + multiplier: 111439, + decimals: 28, + timestamp: env::block_timestamp(), + recency_duration: env::block_timestamp() + 1000000000, + } + } +} diff --git a/src/oracle/priceoracle.rs b/src/oracle/priceoracle.rs new file mode 100644 index 0000000..fccd5a1 --- /dev/null +++ b/src/oracle/priceoracle.rs @@ -0,0 +1,59 @@ +//! Interface to `priceoracle.near`. + +use near_sdk::{ext_contract, json_types::U64, Timestamp}; + +use crate::*; + +// From https://github.com/NearDeFi/price-oracle/blob/main/src/*.rs +type AssetId = String; +type DurationSec = u32; + +// From https://github.com/NearDeFi/price-oracle/blob/main/src/utils.rs +#[derive(BorshSerialize, BorshDeserialize, Deserialize, Clone, Copy)] +#[serde(crate = "near_sdk::serde")] +pub struct Price { + pub multiplier: U128, + pub decimals: u8, +} + +// From https://github.com/NearDeFi/price-oracle/blob/main/src/asset.rs +#[derive(BorshSerialize, BorshDeserialize, Deserialize)] +#[serde(crate = "near_sdk::serde")] +pub struct AssetOptionalPrice { + pub asset_id: AssetId, + pub price: Option, +} + +// From https://github.com/NearDeFi/price-oracle/blob/main/src/lib.rs +#[derive(BorshSerialize, BorshDeserialize, Deserialize)] +#[serde(crate = "near_sdk::serde")] +pub struct PriceData { + timestamp: U64, + recency_duration_sec: DurationSec, + prices: Vec, +} + +impl PriceData { + pub fn timestamp(&self) -> Timestamp { + Timestamp::from(self.timestamp) + } + + pub fn recency_duration(&self) -> Timestamp { + Timestamp::from(self.recency_duration_sec) * 10u64.pow(9) + } + + pub fn price(&self, asset: &AssetId) -> Price { + let asset_error = format!("Oracle has NOT provided an exchange rate for {}", asset); + self.prices + .iter() + .find(|aop| &aop.asset_id == asset) + .expect(&asset_error) + .price + .expect(&asset_error) + } +} + +#[ext_contract(ext_priceoracle)] +pub trait PriceOracle { + fn get_price_data(&self, asset_ids: Vec) -> PriceData; +} diff --git a/tests/price_oracle.wasm b/tests/price_oracle.wasm new file mode 100644 index 0000000..6c54b35 Binary files /dev/null and b/tests/price_oracle.wasm differ diff --git a/tests/sandbox-setup.js b/tests/sandbox-setup.js index 3eca7e9..b17b446 100644 --- a/tests/sandbox-setup.js +++ b/tests/sandbox-setup.js @@ -18,6 +18,8 @@ const config = { usdcPath: './tests/test_token.wasm', refPath: './tests/ref_exchange.wasm', poolPath: './tests/staking_pool.wasm', + priceoraclePath: './tests/price_oracle.wasm', + priceoracleMultiplier: '111439', amount: new BN('300000000000000000000000000', 10), // 26 digits, 300 NEAR masterId: 'test.near', usnId: 'usn.test.near', @@ -25,6 +27,7 @@ const config = { usdcId: 'usdc.test.near', refId: 'ref.test.near', poolId: 'pool.test.near', + oracleId: 'priceoracle.test.near', aliceId: 'alice.test.near', carolId: 'carol.test.near', }; @@ -69,6 +72,7 @@ const usnMethods = { 'withdraw_all', 'transfer_commission', 'add_stable_asset', + 'mint_by_near', ], }; @@ -99,6 +103,16 @@ const poolMethods = { ], }; +const oracleMethods = { + changeMethods: [ + 'new', + 'add_asset', + 'add_asset_ema', + 'add_oracle', + 'report_prices', + ], +}; + async function sandboxSetup() { portUsed.check(config.port, config.domain).then( (inUse) => { @@ -137,6 +151,7 @@ async function sandboxSetup() { await masterAccount.createAccount(config.usdcId, pubKey, config.amount); await masterAccount.createAccount(config.refId, pubKey, config.amount); await masterAccount.createAccount(config.poolId, pubKey, config.amount); + await masterAccount.createAccount(config.oracleId, pubKey, config.amount); await masterAccount.createAccount(config.aliceId, pubKey, config.amount); await masterAccount.createAccount(config.carolId, pubKey, config.amount); keyStore.setKey(config.networkId, config.usnId, privKey); @@ -144,6 +159,7 @@ async function sandboxSetup() { keyStore.setKey(config.networkId, config.usdcId, privKey); keyStore.setKey(config.networkId, config.refId, privKey); keyStore.setKey(config.networkId, config.poolId, privKey); + keyStore.setKey(config.networkId, config.oracleId, privKey); keyStore.setKey(config.networkId, config.aliceId, privKey); keyStore.setKey(config.networkId, config.carolId, privKey); @@ -273,6 +289,47 @@ async function sandboxSetup() { amount: '1', }); + // Deploy the priceoracle contract. + const wasmPriceoracle = await fs.readFile(config.priceoraclePath); + const oracleAccount = new nearAPI.Account(near.connection, config.oracleId); + await oracleAccount.deployContract(wasmPriceoracle); + + // Initialize the Oracle contract. + const oracleContract = new nearAPI.Contract( + oracleAccount, + config.oracleId, + oracleMethods + ); + await oracleContract.new({ + args: { + recency_duration_sec: 360, + owner_id: config.oracleId, + near_claim_amount: '0', + }, + }); + await oracleContract.add_oracle({ + args: { account_id: config.oracleId }, + amount: '1', + }); + await oracleContract.add_asset({ + args: { asset_id: 'wrap.test.near' }, + amount: '1', + }); + await oracleContract.add_asset_ema({ + args: { asset_id: 'wrap.test.near', period_sec: 3600 }, + amount: '1', + }); + await oracleContract.report_prices({ + args: { + prices: [ + { + asset_id: 'wrap.test.near', + price: { multiplier: config.priceoracleMultiplier, decimals: 28 }, + }, + ], + }, + }); + // Initialize other accounts connected to the contract for all test cases. const aliceAccount = new nearAPI.Account(near.connection, config.aliceId); const aliceContract = new nearAPI.Contract( diff --git a/tests/tests.js b/tests/tests.js index 2de93f7..17c614e 100644 --- a/tests/tests.js +++ b/tests/tests.js @@ -7,6 +7,7 @@ const BN = require('bn.js'); const ONE_YOCTO = '1'; const GAS_FOR_CALL = '200000000000000'; // 200 TGas const ONE_NEAR = '1000000000000000000000000'; +const TEN_NEARS = '10000000000000000000000000'; describe('Smoke Test', function () { it('should get a version', async () => { @@ -256,6 +257,204 @@ describe('Guardian', function () { }); }); +describe('Owner', function () { + this.timeout(15000); + + before(async () => { + await global.usnContract.propose_new_owner({ + args: { proposed_owner_id: config.aliceId }, + }); + assert.equal(await global.usnContract.owner(), config.usnId); + + await global.aliceContract.accept_ownership({ + args: {}, + }); + assert.equal(await global.usnContract.owner(), config.aliceId); + }); + + + it('should fail to buy USN being called not by owner', async () => { + await assert.rejects( + async () => { + await global.usnContract.mint_by_near({ + args: { + collateral_ratio: 100, + } + }); + }, + (err) => { + assert.match(err.message, /This method can be called only by owner/); + return true; + } + ); + }); + + it('should fail to buy USN being due to low collateral ratio', async () => { + await assert.rejects( + async () => { + await global.aliceContract.mint_by_near({ + args: { + collateral_ratio: 99, + } + }); + }, + (err) => { + assert.match(err.message, /Collateral ratio is out of bounds/); + return true; + } + ); + }); + + it('should fail to buy USN being due to exceeded collateral ratio', async () => { + await assert.rejects( + async () => { + await global.aliceContract.mint_by_near({ + args: { + collateral_ratio: 1001, + } + }); + }, + (err) => { + assert.match(err.message, /Collateral ratio is out of bounds/); + return true; + } + ); + }); + + it('should be able to mint USN for NEAR with 100% collateralization', async () => { + const nearOwnerBalanceBefore = await global.aliceAccount.state(); + const nearUsnBalanceBefore = await global.usnAccount.state(); + const usnBalanceBefore = await global.aliceContract.ft_balance_of({ + account_id: config.aliceId, + }); + + await global.aliceContract.mint_by_near({ + args: { + collateral_ratio: 100, + }, + amount: TEN_NEARS, + gas: GAS_FOR_CALL, + }); + + const nearOwnerBalanceAfter = await global.aliceAccount.state(); + const nearUsnBalanceAfter = await global.usnAccount.state(); + const usnBalanceAfter = await global.aliceContract.ft_balance_of({ + account_id: config.aliceId, + }); + + assert(new BN(nearUsnBalanceAfter.amount) + .sub(new BN(nearUsnBalanceBefore.amount)) + .gt(new BN(TEN_NEARS)) + ); + assert(new BN(nearOwnerBalanceBefore.amount) + .sub(new BN(nearOwnerBalanceAfter.amount)) + .gt(new BN(TEN_NEARS)) + ); + assert.equal(new BN(usnBalanceAfter) + .sub(new BN(usnBalanceBefore)).toString(), + '111439000000000000000' // 111.43$ + ); + }); + + it('should be able to mint USN for NEAR with 210% collateralization', async () => { + const nearOwnerBalanceBefore = await global.aliceAccount.state(); + const nearUsnBalanceBefore = await global.usnAccount.state(); + const usnBalanceBefore = await global.aliceContract.ft_balance_of({ + account_id: config.aliceId, + }); + + await global.aliceContract.mint_by_near({ + args: { + collateral_ratio: 210, + }, + amount: TEN_NEARS, + gas: GAS_FOR_CALL, + }); + + const nearOwnerBalanceAfter = await global.aliceAccount.state(); + const nearUsnBalanceAfter = await global.usnAccount.state(); + const usnBalanceAfter = await global.aliceContract.ft_balance_of({ + account_id: config.aliceId, + }); + + assert(new BN(nearUsnBalanceAfter.amount) + .sub(new BN(nearUsnBalanceBefore.amount)) + .gt(new BN(TEN_NEARS)) + ); + assert(new BN(nearOwnerBalanceBefore.amount) + .sub(new BN(nearOwnerBalanceAfter.amount)) + .gt(new BN(TEN_NEARS)) + ); + assert.equal(new BN(usnBalanceAfter) + .sub(new BN(usnBalanceBefore)).toString(), + '53066190476190476190' // 53.06$ + ); + }); + + it('should be able to mint USN for NEAR with 1000% collateralization', async () => { + const nearOwnerBalanceBefore = await global.aliceAccount.state(); + const nearUsnBalanceBefore = await global.usnAccount.state(); + const usnBalanceBefore = await global.aliceContract.ft_balance_of({ + account_id: config.aliceId, + }); + + await global.aliceContract.mint_by_near({ + args: { + collateral_ratio: 1000, + }, + amount: TEN_NEARS, + gas: GAS_FOR_CALL, + }); + + const nearOwnerBalanceAfter = await global.aliceAccount.state(); + const nearUsnBalanceAfter = await global.usnAccount.state(); + const usnBalanceAfter = await global.aliceContract.ft_balance_of({ + account_id: config.aliceId, + }); + + assert(new BN(nearUsnBalanceAfter.amount) + .sub(new BN(nearUsnBalanceBefore.amount)) + .gt(new BN(TEN_NEARS)) + ); + assert(new BN(nearOwnerBalanceBefore.amount) + .sub(new BN(nearOwnerBalanceAfter.amount)) + .gt(new BN(TEN_NEARS)) + ); + assert.equal(new BN(usnBalanceAfter) + .sub(new BN(usnBalanceBefore)).toString(), + '11143900000000000000' // 11.14$ + ); + }); + + after(async () => { + const aliceBalance = await global.aliceContract.ft_balance_of({ + account_id: config.aliceId, + }); + + // Flush balances and force registration removal. + + if (aliceBalance != '0') { + await global.aliceContract.ft_transfer({ + args: { + receiver_id: 'any', + amount: aliceBalance, + }, + amount: ONE_YOCTO, + }); + } + + await global.aliceContract.propose_new_owner({ + args: { proposed_owner_id: config.usnId }, + }); + assert.equal(await global.usnContract.owner(), config.aliceId); + + await global.usnContract.accept_ownership({ + args: {}, + }); + assert.equal(await global.usnContract.owner(), config.usnId); + }); +}); + describe('User', async function () { this.timeout(15000);