diff --git a/.gitignore b/.gitignore index 3a6feaa..686f183 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # will have compiled files and executables **/target/ +.DS_Store + # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html Cargo.lock diff --git a/Cargo.toml b/Cargo.toml index 706ccd0..d1a79c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,8 @@ members = [ "./ref-exchange", "./test-token", - "./ref-farming" + "./ref-farming", + "./ref-adboard" ] diff --git a/ref-adboard/Cargo.toml b/ref-adboard/Cargo.toml new file mode 100644 index 0000000..f1085f3 --- /dev/null +++ b/ref-adboard/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ref_adboard" +version = "0.1.0" +authors = ["Marco Sun ", "Daniel"] +edition = "2018" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +near-sdk = { git = "https://github.com/near/near-sdk-rs", rev = "9d99077" } +near-contract-standards = { git = "https://github.com/near/near-sdk-rs", rev = "9d99077" } +uint = { version = "0.9.0", default-features = false } +# near-sdk = "3.1.0" +# near-contract-standards = "3.1.0" + +[dev-dependencies] +near-sdk-sim = { git = "https://github.com/near/near-sdk-rs", rev = "9d99077" } +# near-sdk-sim = "3.1.0" +test-token = { path = "../test-token" } +ref-exchange = { path = "../ref-exchange" } diff --git a/ref-adboard/build_docker.sh b/ref-adboard/build_docker.sh new file mode 100644 index 0000000..e3fa435 --- /dev/null +++ b/ref-adboard/build_docker.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Exit script as soon as a command fails. +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +NAME="build_ref_adboard" + +if docker ps -a --format '{{.Names}}' | grep -Eq "^${NAME}\$"; then + echo "Container exists" +else +docker create \ + --mount type=bind,source=$DIR/..,target=/host \ + --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ + --name=$NAME \ + -w /host/ref-adboard \ + -e RUSTFLAGS='-C link-arg=-s' \ + -it \ + nearprotocol/contract-builder \ + /bin/bash +fi + +docker start $NAME +docker exec -it $NAME /bin/bash -c "rustup toolchain install stable-2020-10-08;rustup default stable-2020-10-08;rustup target add wasm32-unknown-unknown; cargo build --target wasm32-unknown-unknown --release" + +mkdir -p res +cp $DIR/../target/wasm32-unknown-unknown/release/ref_adboard.wasm $DIR/../res/ref_adboard_release.wasm + diff --git a/ref-adboard/build_local.sh b/ref-adboard/build_local.sh new file mode 100644 index 0000000..cdf9b43 --- /dev/null +++ b/ref-adboard/build_local.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -e +rustup target add wasm32-unknown-unknown +RUSTFLAGS='-C link-arg=-s' cargo build --target wasm32-unknown-unknown --release +# RUSTFLAGS='-C link-arg=-s' cargo +stable build --target wasm32-unknown-unknown --release +cd .. +cp target/wasm32-unknown-unknown/release/ref_adboard.wasm ./res/ref_adboard_local.wasm diff --git a/ref-adboard/readme.md b/ref-adboard/readme.md new file mode 100644 index 0000000..afb08ad --- /dev/null +++ b/ref-adboard/readme.md @@ -0,0 +1,139 @@ +# ref-adboard + +### Background + +This is a RUST version of adboard AssemblyScript contract by Daniel. + +### Init +When initialize the adboard, we should set following params: + +* owner_id: the owner of this contract, has the right to call owner methods, +* amm_id: the ref main contract, play a role of contract of all whitelisted tokens, +* default_token_id and defalut_sell_balance: the initial status of frames, usually wnear's contract, which is wrap.near on mainnet and wrap.testnet on testnet. And the sell_balance is usually set to 1 wnear, which is 10**24. +* protected_period: the seconds that a frame can not be traded after previous trading. +* frame_count: the total frame counts in this contract. +* trading_fee: in bps, which means a 10,000 denominator. + +```rust +pub fn new( + owner_id: ValidAccountId, + amm_id: ValidAccountId, + default_token_id: ValidAccountId, + default_sell_balance: U128, + protected_period: u16, + frame_count: u16, + trading_fee: u16 + ) -> Self; +``` + +### Interface Structure + +```rust +/// metadata of the contract +pub struct ContractMetadata { + pub version: String, + pub owner_id: AccountId, + pub amm_id: AccountId, + pub default_token_id: AccountId, + pub default_sell_balance: U128, + pub protected_period: u16, + pub frame_count: u16, + pub trading_fee: u16, +} + +/// metadata of one frame +pub struct HumanReadableFrameMetadata { + pub token_price: U128, + pub token_id: AccountId, + pub owner: AccountId, + pub protected_ts: U64, +} + +/// Payment that failed auto-execution +pub struct HumanReadablePaymentItem { + pub amount: U128, + pub token_id: AccountId, + pub receiver_id: AccountId, +} +``` + +### Interface + +***view functions*** +```rust + +pub fn get_metadata(&self) -> ContractMetadata; + +/// tokens that permitted in this contract +pub fn get_whitelist(&self) -> Vec; + +/// get single frame's metadata +pub fn get_frame_metadata(&self, index: FrameId) -> Option; + +/// batch get frame's metadata +pub fn list_frame_metadata(&self, from_index: u64, limit: u64) -> Vec; + +/// get single frame's data +pub fn get_frame_data(&self, index: FrameId) -> Option; + +/// batch get frame's data +pub fn list_frame_data(&self, from_index: u64, limit: u64) -> Vec; + +/// batch get failed payments +pub fn list_failed_payments(&self, from_index: u64, limit: u64) -> Vec; + +``` + +***user functions*** +```rust +/// buy frame, call from ref-finance contract +/// with msg is "frame_id||sell_token_id||sell_balance||pool_id" +/// receiver_id is ref-adboard contract id, +/// token_id is the frame's current token, +/// amount is the frame's sell price at that token. +pub fn mft_transfer_call( + &mut self, + token_id: String, + receiver_id: ValidAccountId, + amount: U128, + memo: Option, + msg: String, + ) -> PromiseOrValue; + +/// edit frame +pub fn edit_frame(&mut self, frame_id: FrameId, frame_data: String); +``` + +***owner functions*** +The owner of this contract is suggested to be some DAO. It can adjust parameters, such as trading fee, protected period, token whitelist, and etc. +A regular operation for the owner is to repay failure payments. Those payments are generated through frame trading process. It's the last step of the process that the contract would pay prev-sell_balance in prev-frame_token of the trading frame to the prev-owner. But in some rare conditions, this payment would fail. Then it will be recorded in a special vector called failed_payments. +The ```repay_failure_payment``` interface gives owner the right to handle those payments. + +```rust +/// transfer ownership +pub fn set_owner(&mut self, owner_id: ValidAccountId); + +/// +pub fn add_token_to_whitelist(&mut self, token_id: ValidAccountId) -> bool; + +/// +pub fn remove_token_from_whitelist(&mut self, token_id: ValidAccountId) -> bool; + +/// handle one failed payment at one call. +pub fn repay_failure_payment(&mut self); + +/// owner can change amm account id. +pub fn set_amm(&mut self, amm_id: ValidAccountId); + +/// owner can change protected period from being sold after a frame complete trading. +pub fn set_protected_period(&mut self, protected_period: u16); + +/// owner can adjust trading fee, the unit is bps, that means a 10,000 denominator. +pub fn set_trading_fee(&mut self, trading_fee: u16); + +/// owner can expand total frames, the new generate frame would have default values. +pub fn expand_frames(&mut self, expend_count: u16); + +/// owner can change the default values a frame initially use. +pub fn set_default_token(&mut self, token_id: ValidAccountId, sell_balance: U128); +``` \ No newline at end of file diff --git a/ref-adboard/src/lib.rs b/ref-adboard/src/lib.rs new file mode 100644 index 0000000..931ed45 --- /dev/null +++ b/ref-adboard/src/lib.rs @@ -0,0 +1,302 @@ +/*! +* Ref-Adboard +* +* lib.rs is the main entry point. +*/ +use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; +use near_sdk::json_types::{ValidAccountId, U128}; +use near_sdk::collections::{UnorderedSet, UnorderedMap, Vector}; +use near_sdk::{env, near_bindgen, Balance, AccountId, PanicOnDefault, Timestamp}; +use crate::utils::*; +// use near_sdk::BorshStorageKey; + +mod utils; +mod owner; +mod token_receiver; +mod view; + +near_sdk::setup_alloc!(); + + +#[derive(BorshSerialize, BorshDeserialize)] +#[cfg_attr(feature = "test", derive(Clone))] +pub struct PaymentItem { + pub amount: u128, + pub token_id: AccountId, + pub receiver_id: AccountId, +} + +#[derive(BorshSerialize, BorshDeserialize)] +#[cfg_attr(feature = "test", derive(Clone))] +pub struct FrameMetadata { + + pub token_price: u128, + + pub token_id: AccountId, + + pub owner: AccountId, + + pub protected_ts: Timestamp, +} + +#[derive(BorshDeserialize, BorshSerialize)] +pub struct ContractData { + + owner_id: AccountId, + + amm_id: AccountId, + + default_token_id: AccountId, + default_sell_balance: Balance, + + // in seconds + protected_period: u16, + + trading_fee: u16, + + frame_count: u16, + + frames: UnorderedMap, + + frames_data: UnorderedMap, + + whitelist: UnorderedSet, + + failed_payments: Vector, +} + + +/// Versioned contract data. Allows to easily upgrade contracts. +#[derive(BorshSerialize, BorshDeserialize)] +pub enum VersionedContractData { + Current(ContractData), +} + +impl VersionedContractData {} + +#[near_bindgen] +#[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] +pub struct Contract { + + data: VersionedContractData, +} + +#[near_bindgen] +impl Contract { + #[init] + pub fn new( + owner_id: ValidAccountId, + amm_id: ValidAccountId, + default_token_id: ValidAccountId, + default_sell_balance: U128, + protected_period: u16, + frame_count: u16, + trading_fee: u16, + ) -> Self { + assert!(!env::state_exists(), "Already initialized"); + Self { + data: VersionedContractData::Current(ContractData { + owner_id: owner_id.into(), + amm_id: amm_id.into(), + default_token_id: default_token_id.into(), + default_sell_balance: default_sell_balance.into(), + protected_period, + frames: UnorderedMap::new(b"f".to_vec()), + frames_data: UnorderedMap::new(b"d".to_vec()), + whitelist: UnorderedSet::new(b"w".to_vec()), + failed_payments: Vector::new(b"l".to_vec()), + frame_count, + trading_fee, + }), + } + } + + pub fn edit_frame(&mut self, frame_id: FrameId, frame_data: String) { + + let metadata = self.data().frames.get(&frame_id).unwrap_or( + FrameMetadata { + token_price: self.data().default_sell_balance, + token_id: self.data().default_token_id.clone(), + owner: self.data().owner_id.clone(), + protected_ts: 0, + } + ); + + assert_eq!( + env::predecessor_account_id(), + metadata.owner, + "ERR_ONLY_OWNER_CAN_MODIFY" + ); + + self.data_mut().frames_data.insert(&frame_id, &frame_data); + } +} + +impl Contract { + fn data(&self) -> &ContractData { + match &self.data { + VersionedContractData::Current(data) => data, + } + } + + fn data_mut(&mut self) -> &mut ContractData { + match &mut self.data { + VersionedContractData::Current(data) => data, + } + } +} + +#[cfg(test)] +mod tests { + + use near_sdk::test_utils::{accounts, VMContextBuilder}; + use near_sdk::{testing_env, MockedBlockchain}; + use near_sdk::json_types::{U128}; + + use super::*; + + const ONE_NEAR: u128 = 1_000_000_000_000_000_000_000_000; + + fn setup_contract() -> (VMContextBuilder, Contract) { + let mut context = VMContextBuilder::new(); + testing_env!(context.predecessor_account_id(accounts(0)).build()); + let contract = Contract::new( + accounts(0), + accounts(1), + accounts(2), + U128(ONE_NEAR), + 15*60, 500, 15); + (context, contract) + } + + fn edit_frame( + context: &mut VMContextBuilder, + contract: &mut Contract, + frame_id: FrameId, + frame_data: String, + ) { + testing_env!(context + .predecessor_account_id(accounts(0)) + .build()); + contract.edit_frame(frame_id, frame_data); + } + + #[test] + fn test_basics() { + + let (mut context, mut contract) = setup_contract(); + + let frame_id: FrameId = 33; + + let frame_data = contract.get_frame_data(frame_id).expect("NO_DATA"); + assert_eq!(frame_data, DEFAULT_DATA, "ERR_DATA"); + + edit_frame(&mut context, &mut contract, frame_id, "TEST_STRINGS".to_string()); + + let frame_data = contract.get_frame_data(frame_id).expect("NO_DATA"); + assert_eq!(frame_data, "TEST_STRINGS".to_string(), "ERR_DATA"); + + } + + #[test] + fn test_owner_actions() { + let (_, mut contract) = setup_contract(); + + let metadata = contract.get_metadata(); + assert_eq!(metadata.version, "0.1.0".to_string()); + assert_eq!(metadata.owner_id, "alice".to_string()); + assert_eq!(metadata.amm_id, "bob".to_string()); + assert_eq!(metadata.default_token_id, "charlie".to_string()); + assert_eq!(metadata.default_sell_balance, U128(ONE_NEAR)); + assert_eq!(metadata.protected_period, 15*60); + assert_eq!(metadata.frame_count, 500); + assert_eq!(metadata.trading_fee, 15); + + let frame_metadata = contract.get_frame_metadata(100); + assert_eq!(frame_metadata.unwrap().token_id, "charlie".to_string()); + let frame_metadata = contract.get_frame_metadata(500); + assert!(frame_metadata.is_none()); + contract.expand_frames(10); + let frame_metadata = contract.get_frame_metadata(500); + assert_eq!(frame_metadata.unwrap().token_id, "charlie".to_string()); + let frame_metadata = contract.get_frame_metadata(510); + assert!(frame_metadata.is_none()); + + contract.set_trading_fee(25); + let metadata = contract.get_metadata(); + assert_eq!(metadata.version, "0.1.0".to_string()); + assert_eq!(metadata.owner_id, "alice".to_string()); + assert_eq!(metadata.amm_id, "bob".to_string()); + assert_eq!(metadata.default_token_id, "charlie".to_string()); + assert_eq!(metadata.default_sell_balance, U128(ONE_NEAR)); + assert_eq!(metadata.protected_period, 15*60); + assert_eq!(metadata.frame_count, 510); + assert_eq!(metadata.trading_fee, 25); + + contract.set_protected_period(20*60); + let metadata = contract.get_metadata(); + assert_eq!(metadata.version, "0.1.0".to_string()); + assert_eq!(metadata.owner_id, "alice".to_string()); + assert_eq!(metadata.amm_id, "bob".to_string()); + assert_eq!(metadata.default_token_id, "charlie".to_string()); + assert_eq!(metadata.default_sell_balance, U128(ONE_NEAR)); + assert_eq!(metadata.protected_period, 20*60); + assert_eq!(metadata.frame_count, 510); + assert_eq!(metadata.trading_fee, 25); + + contract.set_default_token(accounts(3), U128(ONE_NEAR+1)); + let metadata = contract.get_metadata(); + assert_eq!(metadata.version, "0.1.0".to_string()); + assert_eq!(metadata.owner_id, "alice".to_string()); + assert_eq!(metadata.amm_id, "bob".to_string()); + assert_eq!(metadata.default_token_id, "danny".to_string()); + assert_eq!(metadata.default_sell_balance, U128(ONE_NEAR+1)); + assert_eq!(metadata.protected_period, 20*60); + assert_eq!(metadata.frame_count, 510); + assert_eq!(metadata.trading_fee, 25); + + contract.set_amm(accounts(4)); + let metadata = contract.get_metadata(); + assert_eq!(metadata.version, "0.1.0".to_string()); + assert_eq!(metadata.owner_id, "alice".to_string()); + assert_eq!(metadata.amm_id, "eugene".to_string()); + assert_eq!(metadata.default_token_id, "danny".to_string()); + assert_eq!(metadata.default_sell_balance, U128(ONE_NEAR+1)); + assert_eq!(metadata.protected_period, 20*60); + assert_eq!(metadata.frame_count, 510); + assert_eq!(metadata.trading_fee, 25); + + contract.add_token_to_whitelist(accounts(1)); + let tokens = contract.get_whitelist(); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0], "bob".to_string()); + + contract.add_token_to_whitelist(accounts(2)); + let tokens = contract.get_whitelist(); + assert_eq!(tokens.len(), 2); + assert_eq!(tokens[1], "charlie".to_string()); + + contract.remove_token_from_whitelist(accounts(1)); + let tokens = contract.get_whitelist(); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0], "charlie".to_string()); + + contract.remove_token_from_whitelist(accounts(2)); + let tokens = contract.get_whitelist(); + assert_eq!(tokens.len(), 0); + + contract.set_owner(accounts(5)); + let metadata = contract.get_metadata(); + assert_eq!(metadata.version, "0.1.0".to_string()); + assert_eq!(metadata.owner_id, "fargo".to_string()); + assert_eq!(metadata.amm_id, "eugene".to_string()); + assert_eq!(metadata.default_token_id, "danny".to_string()); + assert_eq!(metadata.default_sell_balance, U128(ONE_NEAR+1)); + assert_eq!(metadata.protected_period, 20*60); + assert_eq!(metadata.frame_count, 510); + assert_eq!(metadata.trading_fee, 25); + + + } + +} \ No newline at end of file diff --git a/ref-adboard/src/owner.rs b/ref-adboard/src/owner.rs new file mode 100644 index 0000000..452e74f --- /dev/null +++ b/ref-adboard/src/owner.rs @@ -0,0 +1,96 @@ +use crate::*; + +use near_sdk::{Promise, Gas}; + +/// Amount of gas used for upgrade function itself. +pub const GAS_FOR_UPGRADE_CALL: Gas = 50_000_000_000_000; +/// Amount of gas for deploy action. +pub const GAS_FOR_DEPLOY_CALL: Gas = 20_000_000_000_000; + +#[near_bindgen] +impl Contract { + pub fn set_owner(&mut self, owner_id: ValidAccountId) { + self.assert_owner(); + self.data_mut().owner_id = owner_id.into(); + } + + pub fn set_amm(&mut self, amm_id: ValidAccountId) { + self.assert_owner(); + self.data_mut().amm_id = amm_id.into(); + } + + pub fn set_protected_period(&mut self, protected_period: u16) { + self.assert_owner(); + self.data_mut().protected_period = protected_period; + } + + pub fn set_trading_fee(&mut self, trading_fee: u16) { + self.assert_owner(); + self.data_mut().trading_fee = trading_fee; + } + + pub fn expand_frames(&mut self, expend_count: u16) { + self.assert_owner(); + self.data_mut().frame_count += expend_count; + } + + pub fn set_default_token(&mut self, token_id: ValidAccountId, sell_balance: U128) { + self.assert_owner(); + self.data_mut().default_token_id = token_id.into(); + self.data_mut().default_sell_balance = sell_balance.into(); + } + + pub fn add_token_to_whitelist(&mut self, token_id: ValidAccountId) -> bool { + self.assert_owner(); + self.data_mut().whitelist.insert(token_id.as_ref()) + } + + pub fn remove_token_from_whitelist(&mut self, token_id: ValidAccountId) -> bool { + self.assert_owner(); + self.data_mut().whitelist.remove(token_id.as_ref()) + } + + pub fn repay_failure_payment(&mut self) { + self.assert_owner(); + if let Some(item) = self.data_mut().failed_payments.pop() { + self.handle_payment(&item.token_id, &item.receiver_id, item.amount); + } + } + + /// Upgrades given contract. Only can be called by owner. + /// if `migrate` is true, calls `migrate()` function right after deployment. + /// TODO: consider adding extra grace period in case `owner` got attacked. + pub fn upgrade( + &self, + #[serializer(borsh)] code: Vec, + #[serializer(borsh)] migrate: bool, + ) -> Promise { + self.assert_owner(); + let mut promise = Promise::new(env::current_account_id()).deploy_contract(code); + if migrate { + promise = promise.function_call( + "migrate".into(), + vec![], + 0, + env::prepaid_gas() - GAS_FOR_UPGRADE_CALL - GAS_FOR_DEPLOY_CALL, + ); + } + promise + } + + /// Migration function between versions. + /// For next version upgrades, change this function. + #[init(ignore_state)] + pub fn migrate() -> Self { + assert_eq!( + env::predecessor_account_id(), + env::current_account_id(), + "ERR_NOT_ALLOWED" + ); + let contract: Contract = env::state_read().expect("ERR_NOT_INITIALIZED"); + contract + } + + +} + diff --git a/ref-adboard/src/token_receiver.rs b/ref-adboard/src/token_receiver.rs new file mode 100644 index 0000000..3819524 --- /dev/null +++ b/ref-adboard/src/token_receiver.rs @@ -0,0 +1,196 @@ +use crate::*; +use near_sdk::{ext_contract, PromiseOrValue, PromiseResult}; +use near_sdk::json_types::{U128}; +use std::num::ParseIntError; +use std::str::FromStr; + + +pub struct BuyFrameParams { + pub frame_id: FrameId, + pub token_id: AccountId, + pub sell_balance: Balance, + pub pool_id: u64, +} + +impl FromStr for BuyFrameParams { + type Err = ParseIntError; + + /// frame_id||sell_token_id||sell_balance||pool_id + fn from_str(s: &str) -> Result { + let parts: Vec<&str> = s.split("||").collect(); + let frame_id = parts[0].parse::()?; + let token_id = parts[1].to_string(); + let sell_balance = parts[2].parse::()?; + let pool_id = parts[3].parse::()?; + Ok(BuyFrameParams { + frame_id, + token_id, + sell_balance, + pool_id, + }) + } +} + +pub trait MFTTokenReceiver { + fn mft_on_transfer( + &mut self, + token_id: String, + sender_id: AccountId, + amount: U128, + msg: String, + ) -> PromiseOrValue; +} + +#[ext_contract(ext_multi_fungible_token)] +pub trait MultiFungibleToken { + fn mft_transfer(&mut self, token_id: String, receiver_id: AccountId, amount: U128, memo: Option); +} + +#[ext_contract(ext_self)] +trait FrameDealerResolver { + fn on_payment( + &mut self, + token_id: String, + receiver_id: AccountId, + amount: U128, + ); +} + +#[near_bindgen] +impl MFTTokenReceiver for Contract { + /// REF_AMM calls this to start a frame buying process. + fn mft_on_transfer( + &mut self, + token_id: String, + sender_id: AccountId, + amount: U128, + msg: String, + ) -> PromiseOrValue { + + assert_eq!( + env::predecessor_account_id(), + self.data().amm_id, + "ERR_ONLY_CAN_BE_CALLED_BY_REF" + ); + + let amount: u128 = amount.into(); + + let params = msg.parse::().expect(&format!("ERR_MSG_INCORRECT")); + + self.assert_valid_frame_id(params.frame_id); + let mut metadata = self.data().frames.get(¶ms.frame_id).unwrap_or( + FrameMetadata { + token_price: self.data().default_sell_balance, + token_id: self.data().default_token_id.clone(), + owner: self.data().owner_id.clone(), + protected_ts: 0, + } + ); + let cur_ts = env::block_timestamp(); + if metadata.protected_ts > 0 && metadata.protected_ts > cur_ts { + env::panic(b"Frame is currently protected") + } + assert_eq!(token_id, metadata.token_id, "Invalid token id"); + assert_eq!(amount, metadata.token_price, "Invalid price for frame"); + assert!(self.data().whitelist.contains(¶ms.token_id), "Token not on whitelist"); + + // charge fee + let fee = ( + U256::from(amount) + * U256::from(self.data().trading_fee) + / U256::from(FEE_DIVISOR) + ).as_u128(); + + self.handle_payment(&metadata.token_id, &metadata.owner, amount - fee); + + // update metadata + metadata.owner = sender_id.clone(); + metadata.token_id = params.token_id.clone(); + metadata.token_price = params.sell_balance; + metadata.protected_ts = env::block_timestamp() + to_nanoseconds(self.data().protected_period); + self.data_mut().frames.insert(¶ms.frame_id, &metadata); + + env::log( + format!( + "Frame {} got new owner {} with sell balance {} on token {}.", + params.frame_id, sender_id.clone(), params.sell_balance, params.token_id.clone(), + ) + .as_bytes(), + ); + + PromiseOrValue::Value(U128(0)) + } + +} + +#[near_bindgen] +impl Contract { + + pub fn handle_payment(&mut self, token_id: &String, receiver_id: &AccountId, amount: u128) { + ext_multi_fungible_token::mft_transfer( + token_id.clone(), + receiver_id.clone(), + amount.into(), + None, + &self.data().amm_id, + 1, // one yocto near + XCC_GAS, + ) + .then(ext_self::on_payment( + token_id.clone(), + receiver_id.clone(), + amount.into(), + &env::current_account_id(), + NO_DEPOSIT, + XCC_GAS, + )); + } + + + #[private] + pub fn on_payment( + &mut self, + token_id: String, + receiver_id: AccountId, + amount: U128, + ) { + + assert_eq!( + env::promise_results_count(), + 1, + "Expected 1 promise result on payment" + ); + let amount: Balance = amount.into(); + match env::promise_result(0) { + PromiseResult::NotReady => unreachable!(), + PromiseResult::Failed => { + env::log( + format!( + "Pay {} with {} {}, Callback Failed.", + receiver_id, amount, token_id, + ) + .as_bytes(), + ); + // Record it to lost_and_found + self.data_mut().failed_payments.push( + &PaymentItem { + amount, + receiver_id, + token_id, + } + ); + }, + PromiseResult::Successful(_) => { + env::log( + format!( + "Pay {} with {} {}, Succeed.", + receiver_id, amount, token_id, + ) + .as_bytes(), + ); + } + }; + } +} + + diff --git a/ref-adboard/src/token_receiver_ex.rs b/ref-adboard/src/token_receiver_ex.rs new file mode 100644 index 0000000..2f43691 --- /dev/null +++ b/ref-adboard/src/token_receiver_ex.rs @@ -0,0 +1,152 @@ +/// this file is reserved for future work + +use crate::*; +use near_sdk::{ext_contract, PromiseOrValue, PromiseResult}; +use near_sdk::json_types::{U128}; +use std::num::ParseIntError; +use std::str::FromStr; +use std::convert::TryInto; + + +pub struct BuyFrameParams { + pub frame_id: FrameId, + pub token_id: AccountId, + pub sell_balance: Balance, + pub pool_id: u64, +} + +impl FromStr for BuyFrameParams { + type Err = ParseIntError; + + /// frame_id||sell_token_id||sell_balance||pool_id + fn from_str(s: &str) -> Result { + let parts: Vec<&str> = s.split("||").collect(); + let frame_id = parts[0].parse::()?; + let token_id = parts[1].to_string(); + let sell_balance = parts[2].parse::()?; + let pool_id = parts[3].parse::()?; + Ok(BuyFrameParams { + frame_id, + token_id, + sell_balance, + pool_id, + }) + } +} + +pub trait MFTTokenReceiver { + fn mft_on_transfer( + &mut self, + token_id: String, + sender_id: AccountId, + amount: U128, + msg: String, + ) -> PromiseOrValue; +} + +#[ext_contract(ext_ref_amm)] +pub trait PoolPriceServer { + fn get_return( + &self, + pool_id: u64, + token_in: ValidAccountId, + amount_in: U128, + token_out: ValidAccountId, + ) -> U128; +} + +#[ext_contract(ext_self)] +trait FrameDealerResolver { + fn on_frame_deal( + &mut self, + frame_id: FrameId, + buyer_id: AccountId, + msg: String, // BuyFrameParams + ); +} + +#[near_bindgen] +impl MFTTokenReceiver for Contract { + /// Callback on receiving tokens by this contract. + fn mft_on_transfer( + &mut self, + token_id: String, + sender_id: AccountId, + amount: U128, + msg: String, + ) -> PromiseOrValue { + + assert_eq!( + env::predecessor_account_id(), + AMM_CONTRACT.to_string(), + "ERR_ONLY_CAN_BE_CALLED_BY_REF" + ); + + let amount: u128 = amount.into(); + + let params = msg.parse::().expect(&format!("ERR_MSG_INCORRECT")); + + self.assert_valid_frame_id(params.frame_id); + let metadata = self.data().frames.get(¶ms.frame_id).unwrap_or_default(); + let cur_ts = env::block_timestamp(); + if metadata.protected_ts > 0 && metadata.protected_ts < cur_ts { + env::panic(b"Frame is currently protected") + } + assert_eq!(token_id, metadata.token_id, "Invalid token id"); + assert_eq!(amount, metadata.token_price, "Invalid price for frame"); + assert!(self.data().whitelist.contains(¶ms.token_id), "Token not on whitelist"); + + // query price from AMM and using callback to handle results + ext_ref_amm::get_return( + params.pool_id, + token_id.clone().try_into().unwrap(), + amount.into(), + params.token_id.clone().try_into().unwrap(), + &AMM_CONTRACT.to_string(), + NO_DEPOSIT, + XCC_GAS, + ) + .then(ext_self::on_frame_deal( + params.frame_id, + sender_id.into(), + msg.clone(), + &env::current_account_id(), + NO_DEPOSIT, + XCC_GAS, + )); + + PromiseOrValue::Value(U128(0)) + } + +} + +#[near_bindgen] +impl Contract { + #[private] + pub fn on_frame_deal( + &mut self, + frame_id: FrameId, + buyer_id: AccountId, + msg: String, // BuyFrameParams + ) { + let params = msg.parse::().expect(&format!("ERR_MSG_INCORRECT")); + // seller, token_in, token_in_amount + // buyer, token_out, token_out_amount + let token_out_amount = match env::promise_result(0) { + PromiseResult::NotReady => unreachable!(), + PromiseResult::Successful(value) => { + if let Ok(amount) = near_sdk::serde_json::from_slice::(&value) { + amount.0 + } else { + 0 + } + } + PromiseResult::Failed => 0, + }; + + let mut metadata = self.data_mut().frames.get(¶ms.frame_id).unwrap_or_default(); + + } +} + + diff --git a/ref-adboard/src/utils.rs b/ref-adboard/src/utils.rs new file mode 100644 index 0000000..4178b7e --- /dev/null +++ b/ref-adboard/src/utils.rs @@ -0,0 +1,37 @@ +use near_sdk::{env, Gas}; +// use crate::*; +use crate::Contract; +use uint::construct_uint; + +construct_uint! { + /// 256-bit unsigned integer. + pub struct U256(4); +} + +pub type FrameId = u16; +pub const FEE_DIVISOR: u32 = 10_000; +pub const XCC_GAS: Gas = 30_000_000_000_000; +pub const NO_DEPOSIT: u128 = 0; + +pub const DEFAULT_DATA: &str = "WzIzNCzfBN8E1AQ23wTfBMkE31DfBN9Q3wTfBN9Q3wTfUN8o3wTfUN8E31DfUN8E31DfBN8E31DfBN9Q3wTfBN9Q3wTfUN8o3wTfUN8E31DfUN8E31DfBN8E31DfBN9Q3wTfBN9Q3wTfUN8E3wTPBF0"; + +pub(crate) fn to_nanoseconds(seconds: u16) -> u64 { + seconds as u64 * 1000 * 1000 * 1000 +} + +impl Contract { + + pub(crate) fn assert_owner(&self) { + assert_eq!( + env::predecessor_account_id(), + self.data().owner_id, + "ERR_NOT_ALLOWED" + ); + } + + pub(crate) fn assert_valid_frame_id(&self, frame_id: FrameId) { + let frame_count = self.data().frame_count; + assert!(frame_id < frame_count, "ERR_INVALID_FRAMEID"); + } +} + diff --git a/ref-adboard/src/view.rs b/ref-adboard/src/view.rs new file mode 100644 index 0000000..f3033c0 --- /dev/null +++ b/ref-adboard/src/view.rs @@ -0,0 +1,146 @@ +use near_sdk::serde::{Deserialize, Serialize}; +use near_sdk::json_types::{U64, U128}; +use crate::*; + + +#[derive(Serialize, Deserialize)] +#[serde(crate = "near_sdk::serde")] +pub struct ContractMetadata { + pub version: String, + pub owner_id: AccountId, + pub amm_id: AccountId, + pub default_token_id: AccountId, + pub default_sell_balance: U128, + pub protected_period: u16, + pub frame_count: u16, + pub trading_fee: u16, +} + +impl From<&Contract> for ContractMetadata { + fn from(contract: &Contract) -> Self { + Self { + version: "0.1.0".to_string(), + owner_id: contract.data().owner_id.clone(), + amm_id: contract.data().amm_id.clone(), + default_token_id: contract.data().default_token_id.clone(), + default_sell_balance: contract.data().default_sell_balance.into(), + protected_period: contract.data().protected_period, + frame_count: contract.data().frame_count, + trading_fee: contract.data().trading_fee, + } + } +} + + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[serde(crate = "near_sdk::serde")] +pub struct HumanReadablePaymentItem { + pub amount: U128, + pub token_id: AccountId, + pub receiver_id: AccountId, +} + +impl From<&PaymentItem> for HumanReadablePaymentItem { + fn from(item: &PaymentItem) -> Self { + Self { + amount: item.amount.into(), + token_id: item.token_id.clone(), + receiver_id: item.receiver_id.clone(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[serde(crate = "near_sdk::serde")] +pub struct HumanReadableFrameMetadata { + pub token_price: U128, + pub token_id: AccountId, + pub owner: AccountId, + pub protected_ts: U64, +} + +impl From<&FrameMetadata> for HumanReadableFrameMetadata { + fn from(metadata: &FrameMetadata) -> Self { + Self { + token_price: metadata.token_price.into(), + token_id: metadata.token_id.clone(), + owner: metadata.owner.clone(), + protected_ts: metadata.protected_ts.into(), + } + } +} + +#[near_bindgen] +impl Contract { + + pub fn get_metadata(&self) -> ContractMetadata { + self.into() + } + + pub fn get_whitelist(&self) -> Vec { + self.data().whitelist.to_vec() + } + + pub fn get_frame_metadata(&self, index: FrameId) -> Option { + if index >= self.data().frame_count { + None + } else { + let metadata = self.data().frames.get(&index).unwrap_or( + FrameMetadata { + token_price: self.data().default_sell_balance, + token_id: self.data().default_token_id.clone(), + owner: self.data().owner_id.clone(), + protected_ts: 0, + } + ); + Some((&metadata).into()) + } + } + + pub fn list_frame_metadata(&self, from_index: u64, limit: u64) ->Vec { + (from_index..std::cmp::min(from_index + limit, self.data().frame_count as u64)) + .map( + |index| { + let metadata = self.data().frames.get(&(index as u16)).unwrap_or( + FrameMetadata { + token_price: self.data().default_sell_balance, + token_id: self.data().default_token_id.clone(), + owner: self.data().owner_id.clone(), + protected_ts: 0, + } + ); + (&metadata).into() + } + ).collect() + } + + pub fn get_frame_data(&self, index: FrameId) -> Option { + if index >= self.data().frame_count { + None + } else { + Some( + self.data() + .frames_data.get(&index) + .unwrap_or(DEFAULT_DATA.to_string()) + ) + } + } + + pub fn list_frame_data(&self, from_index: u64, limit: u64) ->Vec { + (from_index..std::cmp::min(from_index + limit, self.data().frame_count as u64)) + .map(|index| + self.data().frames_data.get(&(index as u16)) + .unwrap_or(DEFAULT_DATA.to_string()) + ).collect() + } + + pub fn list_failed_payments(&self, from_index: u64, limit: u64) ->Vec { + (from_index..std::cmp::min(from_index + limit, self.data().failed_payments.len())) + .map(|index| { + let item = self.data().failed_payments.get(index).unwrap(); + (&item).into() + } + ).collect() + } + +} \ No newline at end of file diff --git a/ref-adboard/tests/common/mod.rs b/ref-adboard/tests/common/mod.rs new file mode 100644 index 0000000..c741d33 --- /dev/null +++ b/ref-adboard/tests/common/mod.rs @@ -0,0 +1,194 @@ +use std::convert::TryFrom; + +use near_sdk::serde::{Deserialize, Serialize}; +use near_sdk::json_types::{ValidAccountId, U128, U64}; +use near_sdk::{AccountId, Balance}; +use near_sdk_sim::{call, deploy, to_yocto, view, ContractAccount, UserAccount}; + +use ref_exchange::{ContractContract as TestRef}; +use test_token::ContractContract as TestToken; +use ref_adboard::{ContractContract as AdBoard}; + +near_sdk_sim::lazy_static_include::lazy_static_include_bytes! { + TEST_TOKEN_WASM_BYTES => "../res/test_token.wasm", + EXCHANGE_WASM_BYTES => "../res/ref_exchange_release.wasm", + ADBOARD_WASM_BYTES => "../res/ref_adboard_local.wasm", +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[serde(crate = "near_sdk::serde")] +pub struct HumanReadableFrameMetadata { + pub token_price: U128, + pub token_id: AccountId, + pub owner: AccountId, + pub protected_ts: U64, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[serde(crate = "near_sdk::serde")] +pub struct HumanReadablePaymentItem { + pub amount: U128, + pub token_id: AccountId, + pub receiver_id: AccountId, +} + +pub(crate) fn chain_move_and_show(root: &UserAccount, move_blocks: u64) { + if move_blocks > 0 { + if root.borrow_runtime_mut().produce_blocks(move_blocks).is_ok() { + println!("Chain goes {} blocks", move_blocks); + } else { + println!("produce_blocks failed!"); + } + } + + println!("*** Chain Env *** now height: {}, ts: {}", + root.borrow_runtime().current_block().block_height, + root.borrow_runtime().current_block().block_timestamp, + ); + +} + +pub(crate) fn prepair_pool( + root: &UserAccount, + owner: &UserAccount, +) -> (ContractAccount, ContractAccount, ContractAccount) { + let pool = deploy_pool(&root, swap(), owner.account_id()); + let token1 = deploy_token(&root, dai(), vec![swap()]); + let token2 = deploy_token(&root, eth(), vec![swap()]); + call!( + owner, + pool.extend_whitelisted_tokens(vec![to_va(dai()), to_va(eth())]) + ); + call!( + root, + pool.add_simple_pool(vec![to_va(dai()), to_va(eth())], 25), + deposit = to_yocto("1") + ) + .assert_success(); + (pool, token1, token2) +} + +pub(crate) fn swap_deposit( + user: &UserAccount, + pool: &ContractAccount, + token1: &ContractAccount, + token2: &ContractAccount, +) { + mint_token(&token1, user, to_yocto("105")); + mint_token(&token2, user, to_yocto("105")); + call!( + user, + pool.storage_deposit(None, None), + deposit = to_yocto("1") + ) + .assert_success(); + call!( + user, + token1.ft_transfer_call(to_va(swap()), to_yocto("100").into(), None, "".to_string()), + deposit = 1 + ) + .assert_success(); + call!( + user, + token2.ft_transfer_call(to_va(swap()), to_yocto("100").into(), None, "".to_string()), + deposit = 1 + ) + .assert_success(); +} + +pub(crate) fn mint_token(token: &ContractAccount, user: &UserAccount, amount: Balance) { + call!( + user, + token.mint(to_va(user.account_id.clone()), amount.into()) + ).assert_success(); +} + +fn deploy_pool(root: &UserAccount, contract_id: AccountId, owner_id: AccountId) -> ContractAccount { + let pool = deploy!( + contract: TestRef, + contract_id: contract_id, + bytes: &EXCHANGE_WASM_BYTES, + signer_account: root, + init_method: new(to_va(owner_id), 4, 1) + ); + pool +} + +fn deploy_token( + root: &UserAccount, + token_id: AccountId, + accounts_to_register: Vec, +) -> ContractAccount { + let t = deploy!( + contract: TestToken, + contract_id: token_id, + bytes: &TEST_TOKEN_WASM_BYTES, + signer_account: root + ); + call!(root, t.new()).assert_success(); + call!( + root, + t.mint(to_va(root.account_id.clone()), to_yocto("1000").into()) + ) + .assert_success(); + for account_id in accounts_to_register { + call!( + root, + t.storage_deposit(Some(to_va(account_id)), None), + deposit = to_yocto("1") + ) + .assert_success(); + } + t +} + +pub(crate) fn deploy_adboard(root: &UserAccount, adboard_id: AccountId, owner_id: AccountId) -> ContractAccount { + let adboard = deploy!( + contract: AdBoard, + contract_id: adboard_id, + bytes: &ADBOARD_WASM_BYTES, + signer_account: root, + init_method: new( + to_va(owner_id), + to_va(swap()), + to_va(dai()), + to_yocto("1").into(), + 30, // 30 secs + 500, + 100 // 1% fee + ) + ); + adboard +} + +pub(crate) fn dai() -> AccountId { + "dai".to_string() +} + +pub(crate) fn eth() -> AccountId { + "eth".to_string() +} + +pub(crate) fn swap() -> AccountId { + "swap".to_string() +} + +pub(crate) fn adboard_id() -> AccountId { + "adboard".to_string() +} + +pub(crate) fn to_va(a: AccountId) -> ValidAccountId { + ValidAccountId::try_from(a).unwrap() +} + +pub(crate) fn get_frame_metadata(adboard: &ContractAccount, frame_id: u16) -> Option { + view!(adboard.get_frame_metadata(frame_id)).unwrap_json::>() +} + +pub(crate) fn get_user_token(swap: &ContractAccount, user_id: AccountId, token_id: AccountId) -> U128 { + view!(swap.get_deposit(to_va(user_id), to_va(token_id))).unwrap_json::() +} + +pub(crate) fn get_failed_payment(adboard: &ContractAccount) -> Vec { + view!(adboard.list_failed_payments(0, 100)).unwrap_json::>() +} \ No newline at end of file diff --git a/ref-adboard/tests/test_trading.rs b/ref-adboard/tests/test_trading.rs new file mode 100644 index 0000000..75d9d22 --- /dev/null +++ b/ref-adboard/tests/test_trading.rs @@ -0,0 +1,166 @@ +use near_sdk_sim::{call, init_simulator, to_yocto, view}; +use near_sdk::json_types::{U128}; +use near_sdk_sim::transaction::ExecutionStatus; +use crate::common::*; + +mod common; + + + +#[test] +fn test_trading() { + let root = init_simulator(None); + + // prepair users + let owner = root.create_user("owner".to_string(), to_yocto("100")); + let alice = root.create_user("alice".to_string(), to_yocto("100")); + let bob = root.create_user("bob".to_string(), to_yocto("100")); + println!("----->> owner lp and user accounts prepaired."); + + // prepair pool and tokens + let(pool, token1, token2) = prepair_pool(&root, &owner); + println!("----->> The pool prepaired."); + + // deposit dai and eth to swap + swap_deposit(&alice, &pool, &token1, &token2); + assert_eq!( + view!(pool.get_deposit(to_va(alice.account_id.clone()), to_va(dai()))) + .unwrap_json::() + .0, + to_yocto("100") + ); + assert_eq!( + view!(pool.get_deposit(to_va(alice.account_id.clone()), to_va(eth()))) + .unwrap_json::() + .0, + to_yocto("100") + ); + println!("----->> token deposited to swap by alice."); + swap_deposit(&bob, &pool, &token1, &token2); + assert_eq!( + view!(pool.get_deposit(to_va(bob.account_id.clone()), to_va(dai()))) + .unwrap_json::() + .0, + to_yocto("100") + ); + assert_eq!( + view!(pool.get_deposit(to_va(bob.account_id.clone()), to_va(eth()))) + .unwrap_json::() + .0, + to_yocto("100") + ); + println!("----->> token deposited to swap by bob."); + + // create adboard + let adboard = deploy_adboard(&root, adboard_id(), owner.account_id()); + println!("Deploying adboard ... OK."); + // register to swap + call!( + root, + pool.storage_deposit(Some(to_va(adboard_id())), None), + deposit = to_yocto("1") + ) + .assert_success(); + println!("Registering adboard to swap ... OK."); + println!("----->> Adboard is ready."); + + // add_token_to_whitelist + call!( + owner, + adboard.add_token_to_whitelist(to_va(dai())), + deposit = 0 + ).assert_success(); + call!( + owner, + adboard.add_token_to_whitelist(to_va(eth())), + deposit = 0 + ).assert_success(); + let token_whitelist = view!(adboard.get_whitelist()).unwrap_json::>(); + assert_eq!(token_whitelist.len(), 2); + println!("----->> Adboard token whitelisted."); + + //******************** + // start the real test + //******************** + + // Alice buy frame0 using dai and new sell price 1.5, + // but payment would fail and recorded into failed_payment, + // cause owner of frame dosn't register storage in pool + chain_move_and_show(&root, 0); + let out_come = call!( + alice, + pool.mft_transfer_call( + dai(), to_va(adboard_id()), to_yocto("1").into(), + None, "0||dai||1500000000000000000000000||0".to_string()), + deposit = 1 + ); + out_come.assert_success(); + let ret = get_user_token(&pool, owner.account_id(), dai()); + assert_eq!(ret.0, 0); + let ret = get_failed_payment(&adboard); + assert_eq!(ret.len(), 1); // cause owner haven't register storage in pool + let ret = get_frame_metadata(&adboard, 0); + println!("{:?}", ret.unwrap()); + println!("----->> Alice buy frame0 using dai and new sell price 1.5, but payment failed as expected."); + + // Bob buy frame0 using eth and new sell price 2, + // but the frame is in pretection, so would fail, + chain_move_and_show(&root, 0); // 101_000_000_000 + let out_come = call!( + bob, + pool.mft_transfer_call( + dai(), to_va(adboard_id()), to_yocto("1.5").into(), + None, "0||eth||2000000000000000000000000||0".to_string()), + deposit = 1 + ); + // println!("{:#?}", out_come.promise_results()); + assert_eq!(out_come.promise_errors().len(), 1); + if let ExecutionStatus::Failure(execution_error) = + &out_come.promise_errors().remove(0).unwrap().outcome().status { + // println!("{}", execution_error); + assert!(execution_error.to_string().contains("Frame is currently protected")); + } else { + unreachable!(); + } + println!("----->> Bob buy frame0 failed as expected."); + + // Bob buy frame0 using eth and new sell price 2 + let ret = get_user_token(&pool, alice.account_id(), dai()); + let alice_balance = ret.0; + let ret = get_frame_metadata(&adboard, 0); + println!("{:?}", ret.unwrap()); + chain_move_and_show(&root, 60); + let out_come = call!( + bob, + pool.mft_transfer_call( + dai(), to_va(adboard_id()), to_yocto("1.5").into(), + None, "0||eth||2000000000000000000000000||0".to_string()), + deposit = 1 + ); + out_come.assert_success(); + let ret = get_user_token(&pool, alice.account_id(), dai()); + assert_eq!(ret.0 - alice_balance, to_yocto("1.485")); + let ret = get_frame_metadata(&adboard, 0); + println!("{:?}", ret.unwrap()); + println!("----->> Bob buy frame0 succeeded."); + + // repay failed_payment + // register to swap + call!( + root, + pool.storage_deposit(Some(to_va(owner.account_id())), None), + deposit = to_yocto("1") + ) + .assert_success(); + let out_come = call!( + owner, + adboard.repay_failure_payment(), + deposit = 0 + ); + out_come.assert_success(); + let ret = get_user_token(&pool, owner.account_id(), dai()); + assert_eq!(ret.0, to_yocto("0.99")); + let ret = get_failed_payment(&adboard); + assert_eq!(ret.len(), 0); + println!("----->> Repay failed_payment succeeded."); +} \ No newline at end of file diff --git a/res/ref_adboard_local.wasm b/res/ref_adboard_local.wasm new file mode 100755 index 0000000..077b7b8 Binary files /dev/null and b/res/ref_adboard_local.wasm differ diff --git a/res/ref_adboard_release.wasm b/res/ref_adboard_release.wasm new file mode 100755 index 0000000..0c53e7f Binary files /dev/null and b/res/ref_adboard_release.wasm differ