diff --git a/.github/workflows/tests-factory.yml b/.github/workflows/tests-factory.yml new file mode 100644 index 00000000..80177504 --- /dev/null +++ b/.github/workflows/tests-factory.yml @@ -0,0 +1,14 @@ +name: Tests Factory +on: push +jobs: + workflows: + strategy: + matrix: + platform: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + - name: Install and test modules + run: | + cd ./factory + cargo test diff --git a/factory/Cargo.toml b/factory/Cargo.toml new file mode 100644 index 00000000..1f3f1fd8 --- /dev/null +++ b/factory/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "contract" +description = "Factory Contract Example" +version = "0.1.0" +edition = "2021" +# TODO: Fill out the repository field to help NEAR ecosystem tools to discover your project. +# NEP-0330 is automatically implemented for all contracts built with https://github.com/near/cargo-near. +# Link to the repository will be available via `contract_source_metadata` view-function. +#repository = "https://github.com/xxx/xxx" + +[lib] +crate-type = ["cdylib", "rlib"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[dependencies] +near-sdk = { version = "5.5.0", features = ["unstable"] } + +[dev-dependencies] +near-sdk = { version = "5.5.0", features = ["unit-testing"] } +near-workspaces = { version = "0.14.0", features = ["unstable"] } +tokio = { version = "1.12.0", features = ["full"] } +serde_json = "1" +chrono = "0.4.38" + +[profile.release] +codegen-units = 1 +# Tell `rustc` to optimize for small code size. +opt-level = "z" +lto = true +debug = false +panic = "abort" +# Opt into extra safety checks on arithmetic operations https://stackoverflow.com/a/64136471/249801 +overflow-checks = true diff --git a/factory/rust-toolchain.toml b/factory/rust-toolchain.toml new file mode 100644 index 00000000..a82ade34 --- /dev/null +++ b/factory/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +components = ["rustfmt"] +targets = ["wasm32-unknown-unknown"] diff --git a/factory/src/auction-contract/auction.wasm b/factory/src/auction-contract/auction.wasm new file mode 100755 index 00000000..aecb4ec0 Binary files /dev/null and b/factory/src/auction-contract/auction.wasm differ diff --git a/factory/src/deploy.rs b/factory/src/deploy.rs new file mode 100644 index 00000000..a438e04f --- /dev/null +++ b/factory/src/deploy.rs @@ -0,0 +1,107 @@ +use near_sdk::json_types::{U128, U64}; +use near_sdk::serde::Serialize; +use near_sdk::{env, log, near, AccountId, NearToken, Promise, PromiseError}; + +use crate::{Contract, ContractExt, NEAR_PER_STORAGE, NO_DEPOSIT, TGAS}; + +pub type TokenId = String; + +#[derive(Serialize)] +#[serde(crate = "near_sdk::serde")] +struct AuctionInitArgs { + end_time: U64, + auctioneer: AccountId, + ft_contract: AccountId, + nft_contract: AccountId, + token_id: TokenId, + starting_price: U128, +} + +#[near] +impl Contract { + #[payable] + pub fn deploy_new_auction( + &mut self, + name: String, + end_time: U64, + auctioneer: AccountId, + ft_contract: AccountId, + nft_contract: AccountId, + token_id: TokenId, + starting_price: U128, + ) -> Promise { + // Assert the sub-account is valid + let current_account = env::current_account_id().to_string(); + let subaccount: AccountId = format!("{name}.{current_account}").parse().unwrap(); + assert!( + env::is_valid_account_id(subaccount.as_bytes()), + "Invalid subaccount" + ); + + // Assert enough tokens are attached to create the account and deploy the contract + let attached = env::attached_deposit(); + + let code = self.code.clone().unwrap(); + let contract_bytes = code.len() as u128; + let contract_storage_cost = NEAR_PER_STORAGE.saturating_mul(contract_bytes); + let minimum_needed = contract_storage_cost.saturating_add(NearToken::from_millinear(100)); + assert!( + attached >= minimum_needed, + "Attach at least {minimum_needed} yⓃ" + ); + + let args = &AuctionInitArgs { + end_time, + auctioneer, + ft_contract, + nft_contract, + token_id, + starting_price, + }; + + let init_args = near_sdk::serde_json::to_vec(args).unwrap(); + + let promise = Promise::new(subaccount.clone()) + .create_account() + .transfer(attached) + .deploy_contract(code) + .function_call( + "init".to_owned(), + init_args, + NO_DEPOSIT, + TGAS.saturating_mul(5), + ); + + // Add callback + promise.then( + Self::ext(env::current_account_id()).deploy_new_auction_callback( + subaccount, + env::predecessor_account_id(), + attached, + ), + ) + } + + #[private] + pub fn deploy_new_auction_callback( + &mut self, + account: AccountId, + user: AccountId, + attached: NearToken, + #[callback_result] create_deploy_result: Result<(), PromiseError>, + ) -> bool { + if let Ok(_result) = create_deploy_result { + log!("Correctly created and deployed to {}", account); + return true; + }; + + log!( + "Error creating {}, returning {}yⓃ to {}", + account, + attached, + user + ); + Promise::new(user).transfer(attached); + false + } +} diff --git a/factory/src/lib.rs b/factory/src/lib.rs new file mode 100644 index 00000000..046e1bc0 --- /dev/null +++ b/factory/src/lib.rs @@ -0,0 +1,31 @@ +// Find all our documentation at https://docs.near.org +use near_sdk::store::LazyOption; +use near_sdk::{near, Gas, NearToken}; + +mod deploy; +mod manager; + +const NEAR_PER_STORAGE: NearToken = NearToken::from_yoctonear(10u128.pow(19)); // 10e19yⓃ +const AUCTION_CONTRACT: &[u8] = include_bytes!("./auction-contract/auction.wasm"); +const TGAS: Gas = Gas::from_tgas(1); +const NO_DEPOSIT: NearToken = NearToken::from_near(0); // 0yⓃ + +// Define the contract structure +#[near(contract_state)] +pub struct Contract { + // Since a contract is something big to store, we use LazyOptions + // this way it is not deserialized on each method call + code: LazyOption>, + // Please note that it is much more efficient to **not** store this + // code in the state, and directly use `AUCTION_CONTRACT` + // However, this does not enable to update the stored code. +} + +// Define the default, which automatically initializes the contract +impl Default for Contract { + fn default() -> Self { + Self { + code: LazyOption::new("code".as_bytes(), Some(AUCTION_CONTRACT.to_vec())), + } + } +} diff --git a/factory/src/manager.rs b/factory/src/manager.rs new file mode 100644 index 00000000..cf2e6fb2 --- /dev/null +++ b/factory/src/manager.rs @@ -0,0 +1,19 @@ +use near_sdk::{env, near}; + +use crate::{Contract, ContractExt}; + +#[near] +impl Contract { + #[private] + pub fn update_auction_contract(&mut self) { + // This method receives the code to be stored in the contract directly + // from the contract's input. In this way, it avoids the overhead of + // deserializing parameters, which would consume a huge amount of GAS + self.code.set(env::input()); + } + + pub fn get_code(&self) -> &Vec { + // If a contract wants to update themselves, they can ask for the code needed + self.code.get().as_ref().unwrap() + } +} diff --git a/factory/tests/fungible_token.wasm b/factory/tests/fungible_token.wasm new file mode 100755 index 00000000..40045b57 Binary files /dev/null and b/factory/tests/fungible_token.wasm differ diff --git a/factory/tests/tests_basics.rs b/factory/tests/tests_basics.rs new file mode 100644 index 00000000..21c97733 --- /dev/null +++ b/factory/tests/tests_basics.rs @@ -0,0 +1,189 @@ +use chrono::Utc; +use near_sdk::{json_types::U128, NearToken}; +use near_sdk::{near, AccountId, Gas}; +use near_workspaces::result::ExecutionFinalResult; +use near_workspaces::{Account, Contract}; +use serde_json::json; + +const TEN_NEAR: NearToken = NearToken::from_near(10); +const FT_WASM_FILEPATH: &str = "./tests/fungible_token.wasm"; + +#[near(serializers = [json])] +pub struct Bid { + pub bidder: AccountId, + pub bid: U128, +} + +#[tokio::test] + +async fn test_contract_is_operational() -> Result<(), Box> { + let sandbox = near_workspaces::sandbox().await?; + + let root: near_workspaces::Account = sandbox.root_account()?; + + // Create accounts + let alice = create_subaccount(&root, "alice").await?; + let auctioneer = create_subaccount(&root, "auctioneer").await?; + let contract_account = create_subaccount(&root, "contract").await?; + let nft_account = create_subaccount(&root, "nft").await?; + + let ft_wasm = std::fs::read(FT_WASM_FILEPATH)?; + let ft_contract = sandbox.dev_deploy(&ft_wasm).await?; + + // Initialize FT contract + let res = ft_contract + .call("new_default_meta") + .args_json(serde_json::json!({ + "owner_id": root.id(), + "total_supply": U128(1_000_000), + })) + .transact() + .await?; + + assert!(res.is_success()); + + // Skip creating NFT contract as we are are only going to test making a bid to the auction + + // Deploy factory contract + let contract_wasm = near_workspaces::compile_project("./").await?; + let contract = contract_account.deploy(&contract_wasm).await?.unwrap(); + + // Create auction by calling factory contract + let now = Utc::now().timestamp(); + let a_minute_from_now = (now + 60) * 1000000000; + let starting_price = U128(10_000); + + let deploy_new_auction: ExecutionFinalResult = alice + .call(contract.id(), "deploy_new_auction") + .args_json( + json!({"name": "new-auction", "end_time": a_minute_from_now.to_string(),"auctioneer": auctioneer.id(),"ft_contract": ft_contract.id(),"nft_contract": nft_account.id(),"token_id":"1", "starting_price":starting_price }), + ) + .max_gas() + .deposit(NearToken::from_millinear(1600)) + .transact() + .await?; + + assert!(deploy_new_auction.is_success()); + + let auction_account_id: AccountId = format!("new-auction.{}", contract.id()).parse().unwrap(); + + // Register accounts + for account_id in [alice.id().clone(), auction_account_id.clone()].iter() { + let register = ft_contract + .call("storage_deposit") + .args_json(serde_json::json!({ "account_id": account_id })) + .deposit(NearToken::from_yoctonear(8000000000000000000000)) + .transact() + .await?; + + assert!(register.is_success()); + } + + // Transfer FTs + let transfer_amount = U128(150_000); + + let root_transfer_alice = + ft_transfer(&root, alice.clone(), ft_contract.clone(), transfer_amount).await?; + assert!(root_transfer_alice.is_success()); + + // Alice makes a bid + let alice_bid = ft_transfer_call( + alice.clone(), + ft_contract.id(), + &auction_account_id, + U128(50_000), + ) + .await?; + + assert!(alice_bid.is_success()); + + let highest_bid_alice: Bid = alice + .view(&auction_account_id, "get_highest_bid") + .args_json({}) + .await? + .json()?; + assert_eq!(highest_bid_alice.bid, U128(50_000)); + assert_eq!(highest_bid_alice.bidder, *alice.id()); + + let contract_account_balance: U128 = ft_balance_of(&ft_contract, &auction_account_id).await?; + assert_eq!(contract_account_balance, U128(50_000)); + let alice_balance_after_bid: U128 = ft_balance_of(&ft_contract, alice.id()).await?; + assert_eq!(alice_balance_after_bid, U128(100_000)); + + // Try to launch a new auction with insufficient deposit + let deploy_new_auction: ExecutionFinalResult = alice + .call(contract.id(), "deploy_new_auction") + .args_json( + json!({"name": "new-auction", "end_time": a_minute_from_now.to_string(),"auctioneer": auctioneer.id(),"ft_contract": ft_contract.id(),"nft_contract": nft_account.id(),"token_id":"1", "starting_price":starting_price }), + ) + .max_gas() + .deposit(NearToken::from_millinear(1400)) + .transact() + .await?; + + assert!(deploy_new_auction.is_failure()); + + Ok(()) +} + +async fn create_subaccount( + root: &near_workspaces::Account, + name: &str, +) -> Result> { + let subaccount = root + .create_subaccount(name) + .initial_balance(TEN_NEAR) + .transact() + .await? + .unwrap(); + + Ok(subaccount) +} + +async fn ft_transfer( + root: &near_workspaces::Account, + account: Account, + ft_contract: Contract, + transfer_amount: U128, +) -> Result> { + let transfer = root + .call(ft_contract.id(), "ft_transfer") + .args_json(serde_json::json!({ + "receiver_id": account.id(), + "amount": transfer_amount + })) + .deposit(NearToken::from_yoctonear(1)) + .transact() + .await?; + Ok(transfer) +} + +async fn ft_balance_of( + ft_contract: &Contract, + account_id: &AccountId, +) -> Result> { + let result = ft_contract + .view("ft_balance_of") + .args_json(json!({"account_id": account_id})) + .await? + .json()?; + + Ok(result) +} + +async fn ft_transfer_call( + account: Account, + ft_contract_id: &AccountId, + receiver_id: &AccountId, + amount: U128, +) -> Result> { + let transfer = account + .call(ft_contract_id, "ft_transfer_call") + .args_json(serde_json::json!({ + "receiver_id": receiver_id, "amount":amount, "msg": "0" })) + .deposit(NearToken::from_yoctonear(1)) + .gas(Gas::from_tgas(300)) + .transact() + .await?; + Ok(transfer) +}