diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c9031dc..b65d0b0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,7 +7,15 @@ jobs: platform: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config + + - name: Install cargo-near + run: cargo install --locked cargo-near + - name: Install and test modules run: | cargo test diff --git a/Cargo.toml b/Cargo.toml index a856f10..7df05bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,32 +1,26 @@ [package] -name = "contract" -description = "Factory Contract Example" +name = "factory-contract-global" 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.3.0", features = ["unstable"] } +borsh = "1.5.7" +bs58 = "0.5.1" +near-sdk = { version = "5.17.1", features = ["global-contracts", "unstable"] } [dev-dependencies] -near-sdk = { version = "5.3.0", features = ["unit-testing"] } -near-workspaces = { version = "0.16.0", features = ["unstable"] } +near-sdk = { version = "5.17.1", features = ["global-contracts", "unit-testing"] } +near-workspaces = { version = "0.21", features = ["unstable"] } tokio = { version = "1.12.0", features = ["full"] } -serde_json = "1" +anyhow = "1.0" [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/README.md b/README.md index 9d6c435..4adc494 100644 --- a/README.md +++ b/README.md @@ -1,198 +1,77 @@ -# Factory Contract Example - -A factory is a smart contract that stores a compiled contract on itself, and -automatizes deploying it into sub-accounts. - -This particular example presents a factory of donation contracts, and enables -to: - -1. Create a sub-account of the factory and deploy the stored contract on it - (create_factory_subaccount_and_deploy). -2. Change the stored contract using the update_stored_contract method. - -```rust -#[payable] - pub fn create_factory_subaccount_and_deploy( - &mut self, - name: String, - beneficiary: AccountId, - public_key: Option, - ) -> 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 minimum_needed = NEAR_PER_STORAGE.saturating_mul(contract_bytes); - assert!( - attached >= minimum_needed, - "Attach at least {minimum_needed} yⓃ" - ); - - let init_args = near_sdk::serde_json::to_vec(&DonationInitArgs { beneficiary }).unwrap(); - - let mut 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 full access key is the user passes one - if let Some(pk) = public_key { - promise = promise.add_full_access_key(pk); - } - - // Add callback - promise.then( - Self::ext(env::current_account_id()).create_factory_subaccount_and_deploy_callback( - subaccount, - env::predecessor_account_id(), - attached, - ), - ) - } -``` +# Factory Contract with Global Contracts Example -## How to Build Locally? +This example demonstrates how to use NEAR's global contract functionality to deploy and use global smart contracts. -Install [`cargo-near`](https://github.com/near/cargo-near) and run: +Global contracts allow sharing contract code globally across the NEAR network, reducing deployment costs and enabling efficient code reuse. -```bash -cargo near build -``` +## Key Features -## How to Test Locally? +- Deploy a global contract using `deploy_global_contract()` +- Use an existing global contract by hash with `use_global_contract()` +- Use an existing global contract by deployer account with `use_global_contract_by_account_id()` +- Integration tests using near-workspaces -```bash -cargo test -``` +## Install `cargo-near` build tool -## How to Deploy? +See [`cargo-near` installation](https://github.com/near/cargo-near#installation) -Deployment is automated with GitHub Actions CI/CD pipeline. To deploy manually, -install [`cargo-near`](https://github.com/near/cargo-near) and run: +## Build with: ```bash -cargo near deploy +cargo near build ``` -## How to Interact? - -_In this example we will be using [NEAR CLI](https://github.com/near/near-cli) -to intract with the NEAR blockchain and the smart contract_ - -_If you want full control over of your interactions we recommend using the -[near-cli-rs](https://near.cli.rs)._ - -### Deploy the Stored Contract Into a Sub-Account - -`create_factory_subaccount_and_deploy` will create a sub-account of the factory -and deploy the stored contract on it. +## Run Tests: +### Unit Tests ```bash -near call create_factory_subaccount_and_deploy '{ "name": "sub", "beneficiary": ""}' --deposit 1.24 --accountId --gas 300000000000000 +cargo test ``` -This will create the `sub.`, which will have a `donation` -contract deployed on it: - +### Integration Tests ```bash -near view sub. get_beneficiary -# expected response is: +cargo test --test workspaces +cargo test --test realistic ``` -### Update the Stored Contract - -`update_stored_contract` enables to change the compiled contract that the -factory stores. - -The method is interesting because it has no declared parameters, and yet it -takes an input: the new contract to store as a stream of bytes. - -To use it, we need to transform the contract we want to store into its `base64` -representation, and pass the result as input to the method: +## Create testnet dev-account: ```bash -# Use near-cli to update stored contract -export BYTES=`cat ./src/to/new-contract/contract.wasm | base64` -near call update_stored_contract "$BYTES" --base64 --accountId --gas 30000000000000 +cargo near create-dev-account ``` -> This works because the arguments of a call can be either a `JSON` object or a -> `String Buffer` - -## Factories - Explanations & Limitations +## Deploy to dev-account: -Factories are an interesting concept, here we further explain some of their -implementation aspects, as well as their limitations. +```bash +cargo near deploy +``` -
+## How Global Contracts Work -### Automatically Creating Accounts +1. **Deploy Global Contract**: A contract deploys bytecode as a global contract, making it available network-wide +2. **Use by Hash**: Other contracts can reference the global contract by its code hash +3. **Use by Account**: Contracts can reference a global contract by the account that deployed it -NEAR accounts can only create sub-accounts of themselves, therefore, the -`factory` can only create and deploy contracts on its own sub-accounts. +This reduces storage costs and enables code sharing across the ecosystem. -This means that the factory: +## Use Cases from NEP-591 -1. **Can** create `sub.factory.testnet` and deploy a contract on it. -2. **Cannot** create sub-accounts of the `predecessor`. -3. **Can** create new accounts (e.g. `account.testnet`), but **cannot** deploy - contracts on them. +- **Multisig Contracts**: Deploy once, use for many wallets without paying 3N each time +- **Smart Contract Wallets**: Efficient user onboarding with chain signatures +- **Business Onboarding**: Companies can deploy user accounts cost-effectively +- **DeFi Templates**: Share common contract patterns across protocols -It is important to remember that, while `factory.testnet` can create -`sub.factory.testnet`, it has no control over it after its creation. +## Runtime Requirements -### The Update Method +⚠️ **Important**: Global contracts are not yet available in released versions of nearcore. -The `update_stored_contracts` has a very short implementation: +- **Current Status**: Global contract host functions are implemented in nearcore but will first be available in version 2.7.0 +- **SDK Status**: This near-sdk-rs implementation is ready and waiting for runtime support +- **Testing**: Integration tests require a custom nearcore build with global contract support -```rust -#[private] - pub fn update_stored_contract(&mut self) { - self.code.set(env::input()); - } -``` +### When Available -On first sight it looks like the method takes no input parameters, but we can -see that its only line of code reads from `env::input()`. What is happening here -is that `update_stored_contract` **bypasses** the step of **deserializing the -input**. - -You could implement `update_stored_contract(&mut self, new_code: Vec)`, -which takes the compiled code to store as a `Vec`, but that would trigger -the contract to: - -1. Deserialize the `new_code` variable from the input. -2. Sanitize it, making sure it is correctly built. - -When dealing with big streams of input data (as is the compiled `wasm` file to -be stored), this process of deserializing/checking the input ends up **consuming -the whole GAS** for the transaction. - -## Useful Links - -- [cargo-near](https://github.com/near/cargo-near) - NEAR smart contract - development toolkit for Rust -- [near CLI-rs](https://near.cli.rs) - Iteract with NEAR blockchain from command - line -- [NEAR Rust SDK Documentation](https://docs.near.org/sdk/rust/introduction) -- [NEAR Documentation](https://docs.near.org) -- [NEAR StackOverflow](https://stackoverflow.com/questions/tagged/nearprotocol) -- [NEAR Discord](https://near.chat) -- [NEAR Telegram Developers Community Group](https://t.me/neardev) -- NEAR DevHub: [Telegram](https://t.me/neardevhub), - [Twitter](https://twitter.com/neardevhub) +Once nearcore 2.7.0 is released, you'll be able to: +- Deploy global contracts on mainnet and testnet +- Run integration tests with near-workspaces using version "2.7.0" or later +- Use all the functionality demonstrated in this example \ No newline at end of file diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 75595da..59615fb 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.86.0" -components = ["rustfmt", "clippy", "rust-analyzer"] -targets = ["wasm32-unknown-unknown"] \ No newline at end of file +channel = "1.86" +components = ["rustfmt"] +targets = ["wasm32-unknown-unknown"] diff --git a/src/deploy.rs b/src/deploy.rs deleted file mode 100644 index 413d179..0000000 --- a/src/deploy.rs +++ /dev/null @@ -1,87 +0,0 @@ -use near_sdk::serde::Serialize; -use near_sdk::{env, log, near, AccountId, NearToken, Promise, PromiseError, PublicKey}; - -use crate::{Contract, ContractExt, NEAR_PER_STORAGE, NO_DEPOSIT, TGAS}; - -#[derive(Serialize)] -#[serde(crate = "near_sdk::serde")] -struct DonationInitArgs { - beneficiary: AccountId, -} - -#[near] -impl Contract { - #[payable] - pub fn create_factory_subaccount_and_deploy( - &mut self, - name: String, - beneficiary: AccountId, - public_key: Option, - ) -> 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); - // Require a little more since storage cost is not exact - let minimum_needed = contract_storage_cost.saturating_add(NearToken::from_millinear(100)); - assert!( - attached >= minimum_needed, - "Attach at least {minimum_needed} yⓃ" - ); - - let init_args = near_sdk::serde_json::to_vec(&DonationInitArgs { beneficiary }).unwrap(); - - let mut 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 full access key is the user passes one - if let Some(pk) = public_key { - promise = promise.add_full_access_key(pk); - } - - // Add callback - promise.then( - Self::ext(env::current_account_id()).create_factory_subaccount_and_deploy_callback( - subaccount, - env::predecessor_account_id(), - attached, - ), - ) - } - - #[private] - pub fn create_factory_subaccount_and_deploy_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 {account}, returning {attached}yⓃ to {user}"); - Promise::new(user).transfer(attached); - false - } -} diff --git a/src/donation-contract/donation.wasm b/src/donation-contract/donation.wasm deleted file mode 100755 index 561ae4e..0000000 Binary files a/src/donation-contract/donation.wasm and /dev/null differ diff --git a/src/lib.rs b/src/lib.rs index 2e24752..1ac6706 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,31 +1,76 @@ -// Find all our documentation at https://docs.near.org -use near_sdk::store::LazyOption; -use near_sdk::{near, Gas, NearToken}; +use near_sdk::{env, near, AccountId, NearToken, Promise}; -mod deploy; mod manager; -const NEAR_PER_STORAGE: NearToken = NearToken::from_yoctonear(10u128.pow(19)); // 10e19yⓃ -const DEFAULT_CONTRACT: &[u8] = include_bytes!("./donation-contract/donation.wasm"); -const TGAS: Gas = Gas::from_tgas(1); -const NO_DEPOSIT: NearToken = NearToken::from_near(0); // 0yⓃ +const DEFAULT_GLOBAL_CONTRACT_ID: &str = "ft.globals.primitives.testnet"; +const DEFAULT_DEPOSIT_AMOUNT: u128 = 200; // 0.2 NEAR + +#[derive(Clone, Debug, PartialEq, Eq)] +#[near(serializers = [borsh, json])] +pub enum GlobalContractId { + AccountId(AccountId), + CodeHash(String), +} -// 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 `DEFAULT_CONTRACT` - // However, this does not enable to update the stored code. +pub struct GlobalFactoryContract { + pub global_contract_id: GlobalContractId, + pub min_deposit_amount: NearToken, } -// Define the default, which automatically initializes the contract -impl Default for Contract { +impl Default for GlobalFactoryContract { fn default() -> Self { Self { - code: LazyOption::new("code".as_bytes(), Some(DEFAULT_CONTRACT.to_vec())), + global_contract_id: GlobalContractId::AccountId( + DEFAULT_GLOBAL_CONTRACT_ID.parse().unwrap(), + ), + min_deposit_amount: NearToken::from_millinear(DEFAULT_DEPOSIT_AMOUNT), // 0.2 NEAR + } + } +} + +#[near] +impl GlobalFactoryContract { + /// Deploy a global contract with the given bytecode, identifiable by its code hash + #[payable] + pub fn deploy(&mut self, name: String) -> Promise { + // Assert enough tokens are attached to cover minimal initial deposit on created account + let attached = env::attached_deposit(); + let minimum_needed = self.min_deposit_amount.exact_amount_display(); + assert!( + attached.ge(&self.min_deposit_amount), + "Attach at least {minimum_needed}" + ); + + // 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" + ); + + let promise = Promise::new(subaccount) + .create_account() + .transfer(env::attached_deposit()) + .add_full_access_key(env::signer_account_pk()); + + match self.global_contract_id { + GlobalContractId::AccountId(ref account_id) => { + env::log_str(&format!( + "Using global contract deployed by account: {}", + account_id + )); + + promise.use_global_contract_by_account_id(account_id.clone()) + } + GlobalContractId::CodeHash(ref code_hash) => { + env::log_str(&format!( + "Using global contract with code hash: {:?}", + code_hash + )); + promise.use_global_contract(bs58::decode(code_hash).into_vec().unwrap()) + } } } } diff --git a/src/manager.rs b/src/manager.rs index 63dc858..8f3f57c 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -1,19 +1,20 @@ -use near_sdk::{env, near}; +use near_sdk::{near, NearToken}; -use crate::{Contract, ContractExt}; +use crate::{GlobalContractId, GlobalFactoryContract, GlobalFactoryContractExt}; #[near] -impl Contract { +impl GlobalFactoryContract { #[private] - pub fn update_stored_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 update_global_contract_id(&mut self, contract_id: GlobalContractId, min_deposit: NearToken) { + self.global_contract_id = contract_id; + self.min_deposit_amount = min_deposit; } - 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() + pub fn get_global_contract_id(&self) -> GlobalContractId { + self.global_contract_id.clone() + } + + pub fn get_min_deposit(&self) -> NearToken { + self.min_deposit_amount } } diff --git a/tests/sandbox.rs b/tests/sandbox.rs deleted file mode 100644 index 69f8481..0000000 --- a/tests/sandbox.rs +++ /dev/null @@ -1,76 +0,0 @@ -use near_workspaces::types::{AccountId, NearToken}; -use serde_json::json; - -const TEN_NEAR: NearToken = NearToken::from_near(10); - -#[tokio::test] -async fn main() -> Result<(), Box> { - let sandbox = near_workspaces::sandbox().await?; - let root = sandbox.root_account()?; - - // Create accounts - let alice = create_subaccount(&root, "alice").await?; - let bob = create_subaccount(&root, "bob").await?; - - let contract_wasm = near_workspaces::compile_project("./").await?; - let contract = sandbox.dev_deploy(&contract_wasm).await?; - - // Launch new donation contract through factory - let res = alice - .call(contract.id(), "create_factory_subaccount_and_deploy") - .args_json(json!({"name": "donation_for_alice", "beneficiary": alice.id()})) - .max_gas() - .deposit(NearToken::from_millinear(1700)) - .transact() - .await?; - - assert!(res.is_success()); - - let sub_accountid: AccountId = format!("donation_for_alice.{}", contract.id()) - .parse() - .unwrap(); - - let res = bob - .view(&sub_accountid, "get_beneficiary") - .args_json({}) - .await?; - - assert_eq!(res.json::()?, alice.id().clone()); - - let res = bob - .call(&sub_accountid, "donate") - .args_json({}) - .max_gas() - .deposit(NearToken::from_near(5)) - .transact() - .await?; - - assert!(res.is_success()); - - // Try to create new donation contract with insufficient deposit - let res = alice - .call(contract.id(), "create_factory_subaccount_and_deploy") - .args_json(json!({"name": "donation_for_alice_2", "beneficiary": alice.id()})) - .max_gas() - .deposit(NearToken::from_millinear(1500)) - .transact() - .await?; - - assert!(res.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) -} \ No newline at end of file diff --git a/tests/workspaces.rs b/tests/workspaces.rs new file mode 100644 index 0000000..2d7a32e --- /dev/null +++ b/tests/workspaces.rs @@ -0,0 +1,118 @@ +use factory_contract_global::GlobalContractId; +use near_sdk::{serde_json::json, NearToken}; + +const DEFAULT_GLOBAL_CONTRACT_ACCOUNT_ID: &str = "ft.globals.primitives.testnet"; + +const TEST_GLOBAL_CONTRACT_ACCOUNT_ID: &str = "ft.globals.primitives.testnet"; +const TEST_GLOBAL_CONTRACT_HASH: &str = "3vaopJ7aRoivvzZLngPQRBEd8VJr2zPLTxQfnRCoFgNX"; +const TEST_DEPOSIT_AMOUNT: u128 = 100; // 0.1 NEAR + +/// TODO: add tests for deploy method as soon as near-workspaces-rs supports deploying global contracts. +/// Currently it does not, therefore it's impossible to deploy global contract to use it in tests. + +/// Test management of global contract ID +#[tokio::test] +async fn test_manager() -> anyhow::Result<()> { + let worker = near_workspaces::sandbox_with_version("2.7.0").await?; + let factory_wasm = near_workspaces::compile_project(".").await?; + let factory_contract = worker.dev_deploy(&factory_wasm).await?; + + let default_contract_id = factory_contract + .call("get_global_contract_id") + .view() + .await? + .json::>()? + .expect("Should have stored global contract ID"); + assert_eq!( + default_contract_id, + GlobalContractId::AccountId(DEFAULT_GLOBAL_CONTRACT_ACCOUNT_ID.parse().unwrap()) + ); + + let change_contract_id_res_1 = factory_contract + .call("update_global_contract_id") + .args_json(json!({ + "contract_id": GlobalContractId::CodeHash(TEST_GLOBAL_CONTRACT_HASH.to_string()), + "min_deposit": NearToken::from_millinear(TEST_DEPOSIT_AMOUNT) + })) + .max_gas() + .transact() + .await?; + assert!(change_contract_id_res_1.is_success()); + + let global_contract_id = factory_contract + .call("get_global_contract_id") + .view() + .await? + .json::>()? + .expect("Should have stored global contract ID"); + assert_eq!( + global_contract_id, + GlobalContractId::CodeHash(TEST_GLOBAL_CONTRACT_HASH.to_string()) + ); + + let change_contract_id_res_2 = factory_contract + .call("update_global_contract_id") + .args_json(json!({ + "contract_id": GlobalContractId::AccountId(TEST_GLOBAL_CONTRACT_ACCOUNT_ID.parse().unwrap()), + "min_deposit": NearToken::from_millinear(TEST_DEPOSIT_AMOUNT) + })) + .max_gas() + .transact() + .await?; + assert!(change_contract_id_res_2.is_success()); + + let global_contract_id = factory_contract + .call("get_global_contract_id") + .view() + .await? + .json::>()? + .expect("Should have stored global contract ID"); + assert_eq!( + global_contract_id, + GlobalContractId::AccountId(TEST_GLOBAL_CONTRACT_ACCOUNT_ID.parse().unwrap()) + ); + + let min_deposit = factory_contract + .call("get_min_deposit") + .args_json(()) + .view() + .await? + .json::>()? + .expect("Should have stored global contract ID"); + assert!(min_deposit.eq(&NearToken::from_millinear(TEST_DEPOSIT_AMOUNT))); + Ok(()) +} + +/// Test error cases and edge conditions +#[tokio::test] +async fn test_global_contract_edge_cases() -> anyhow::Result<()> { + let worker = near_workspaces::sandbox_with_version("2.7.0").await?; + let factory_wasm = near_workspaces::compile_project(".").await?; + let factory_contract = worker.dev_deploy(&factory_wasm).await?; + + let change_contract_id_res = factory_contract + .call("update_global_contract_id") + .args_json( + json!({ + "contract_id": GlobalContractId::CodeHash("11111111111111111111111111111111".to_string()), + "min_deposit": NearToken::from_millinear(TEST_DEPOSIT_AMOUNT) } + )) + .max_gas() + .transact() + .await?; + assert!(change_contract_id_res.is_success()); + + // Test using non-existent global contract + let res = factory_contract + .call("deploy") + .args_json(("new_ft",)) + .max_gas() + .transact() + .await?; + assert!( + res.is_failure(), + "Not failed to use global contract by hash: {res:?}" + ); + + Ok(()) +}