diff --git a/Cargo.lock b/Cargo.lock index 738f9ab7..2fc7bb0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1024,6 +1024,14 @@ dependencies = [ "loam-subcontract-ft", ] +[[package]] +name = "example-nft" +version = "0.0.0" +dependencies = [ + "loam-sdk", + "loam-subcontract-core", +] + [[package]] name = "example-status-message" version = "0.0.0" diff --git a/crates/loam-cli/examples/soroban/calculator/Cargo.toml b/crates/loam-cli/examples/soroban/calculator/Cargo.toml index 99d423c1..39d144c4 100644 --- a/crates/loam-cli/examples/soroban/calculator/Cargo.toml +++ b/crates/loam-cli/examples/soroban/calculator/Cargo.toml @@ -15,5 +15,5 @@ loam-sdk = { workspace = true, features = ["loam-soroban-sdk"] } loam-subcontract-core = { workspace = true } -[dev_dependencies] +[dev-dependencies] loam-sdk = { workspace = true, features = ["soroban-sdk-testutils"] } diff --git a/crates/loam-cli/examples/soroban/core/Cargo.toml b/crates/loam-cli/examples/soroban/core/Cargo.toml index ab5fe825..54a816ad 100644 --- a/crates/loam-cli/examples/soroban/core/Cargo.toml +++ b/crates/loam-cli/examples/soroban/core/Cargo.toml @@ -17,7 +17,7 @@ loam-subcontract-core = { workspace = true } loam-soroban-sdk = { workspace = true } -[dev_dependencies] +[dev-dependencies] loam-sdk = { workspace = true, features = ["soroban-sdk-testutils"] } [package.metadata.loam] diff --git a/crates/loam-cli/examples/soroban/ft/Cargo.toml b/crates/loam-cli/examples/soroban/ft/Cargo.toml index b64983d9..fa4e4914 100644 --- a/crates/loam-cli/examples/soroban/ft/Cargo.toml +++ b/crates/loam-cli/examples/soroban/ft/Cargo.toml @@ -16,7 +16,7 @@ loam-subcontract-core = { workspace = true } loam-subcontract-ft = { workspace = true } -[dev_dependencies] +[dev-dependencies] loam-sdk = { workspace = true, features = ["soroban-sdk-testutils"] } [package.metadata.loam] diff --git a/crates/loam-cli/examples/soroban/ft/src/ft.rs b/crates/loam-cli/examples/soroban/ft/src/ft.rs index 0e614b88..93247ec6 100644 --- a/crates/loam-cli/examples/soroban/ft/src/ft.rs +++ b/crates/loam-cli/examples/soroban/ft/src/ft.rs @@ -48,7 +48,7 @@ impl Default for MyFungibleToken { } impl IsInitable for MyFungibleToken { - fn ft_init(&mut self, admin: Address, name: Bytes, symbol: Bytes, decimals: u32) { + fn ft_init(&self, admin: Address, name: Bytes, symbol: Bytes, decimals: u32) { Contract::admin_get().unwrap().require_auth(); MyFungibleToken::set_lazy(MyFungibleToken::new(admin, name, symbol, decimals)); } diff --git a/crates/loam-cli/examples/soroban/nft/Cargo.toml b/crates/loam-cli/examples/soroban/nft/Cargo.toml new file mode 100644 index 00000000..c94a6657 --- /dev/null +++ b/crates/loam-cli/examples/soroban/nft/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "example-nft" +version = "0.0.0" +authors = ["Stellar Development Foundation "] +license = "Apache-2.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +loam-sdk = { workspace = true, features = ["loam-soroban-sdk"] } +loam-subcontract-core = { workspace = true } + + +[dev-dependencies] +loam-sdk = { workspace = true, features = ["soroban-sdk-testutils"] } + +[package.metadata.loam] +contract = true diff --git a/crates/loam-cli/examples/soroban/nft/src/lib.rs b/crates/loam-cli/examples/soroban/nft/src/lib.rs new file mode 100644 index 00000000..89d0e9aa --- /dev/null +++ b/crates/loam-cli/examples/soroban/nft/src/lib.rs @@ -0,0 +1,27 @@ +#![no_std] +use loam_sdk::derive_contract; +use loam_subcontract_core::{admin::Admin, Core}; + +pub mod nft; +pub mod subcontract; + +use nft::MyNonFungibleToken; +use subcontract::{Initable, NonFungible}; +use crate::subcontract::Metadata; + +#[derive_contract( + Core(Admin), + NonFungible(MyNonFungibleToken), + Initable(MyNonFungibleToken) +)] +pub struct Contract; + +impl Contract { + pub(crate) fn require_auth() { + Contract::admin_get() + .expect("No admin! Call 'admin_set' first.") + .require_auth(); + } +} + +mod test; diff --git a/crates/loam-cli/examples/soroban/nft/src/nft.rs b/crates/loam-cli/examples/soroban/nft/src/nft.rs new file mode 100644 index 00000000..8bd65aca --- /dev/null +++ b/crates/loam-cli/examples/soroban/nft/src/nft.rs @@ -0,0 +1,116 @@ +use loam_sdk::{ + soroban_sdk::{self, contracttype, env, Address, Bytes, Lazy, Map, Vec}, + IntoKey, +}; + +use crate::{subcontract::{IsInitable, IsNonFungible, Metadata}, Contract}; + +#[contracttype] +#[derive(IntoKey)] +pub struct MyNonFungibleToken { + name: Bytes, + total_count: u128, + owners_to_nft_ids: Map>, // the owner's collection + nft_ids_to_owners: Map, + nft_ids_to_metadata: Map, +} + +impl MyNonFungibleToken { + #[must_use] + pub fn new(name: Bytes) -> Self { + MyNonFungibleToken { + name, + total_count: 0, + owners_to_nft_ids: Map::new(env()), + nft_ids_to_owners: Map::new(env()), + nft_ids_to_metadata: Map::new(env()), + } + } +} + +impl Default for MyNonFungibleToken { + fn default() -> Self { + MyNonFungibleToken::new(Bytes::new(env())) + } +} + +impl IsInitable for MyNonFungibleToken { + fn nft_init(&self, name: Bytes) { + Contract::require_auth(); + MyNonFungibleToken::set_lazy(MyNonFungibleToken::new(name)); + } +} + +impl IsNonFungible for MyNonFungibleToken { + fn mint(&mut self, owner: Address, metadata: Metadata) -> u128 { + Contract::require_auth(); + + let current_count = self.total_count; + let new_id = current_count + 1; + + //todo: check that the metadata is unique + self.nft_ids_to_metadata.set(new_id, metadata); + self.nft_ids_to_owners.set(new_id, owner.clone()); + + let mut owner_collection = self + .owners_to_nft_ids + .get(owner.clone()) + .unwrap_or_else(|| Map::new(env())); + owner_collection.set(new_id, ()); + self.owners_to_nft_ids.set(owner, owner_collection); + + self.total_count = new_id; + + new_id + } + + fn transfer(&mut self, id: u128, current_owner: Address, new_owner: Address) { + current_owner.require_auth(); + let owner_id = self.nft_ids_to_owners.get(id).expect("NFT does not exist"); + assert!( + owner_id == current_owner, + "You are not the owner of this NFT" + ); + + // update the nft_ids_to_owners map with the new owner + self.nft_ids_to_owners.remove(id); + self.nft_ids_to_owners.set(id, new_owner.clone()); + + // remove the NFT id from the current owner's collection + let mut current_owner_collection = self + .owners_to_nft_ids + .get(current_owner.clone()) + .expect("Owner does not have a collection of NFTs"); + current_owner_collection.remove(id); + + self.owners_to_nft_ids + .set(current_owner, current_owner_collection); + + // add the NFT id to the new owner's collection + let mut new_owner_collection = self + .owners_to_nft_ids + .get(new_owner.clone()) + .unwrap_or_else(|| Map::new(env())); + new_owner_collection.set(id, ()); + self.owners_to_nft_ids.set(new_owner, new_owner_collection); + } + + fn get_nft(&self, id: u128) -> Option { + self.nft_ids_to_metadata.get(id) + } + + fn get_owner(&self, id: u128) -> Option
{ + self.nft_ids_to_owners.get(id) + } + + fn get_total_count(&self) -> u128 { + self.total_count + } + + fn get_collection_by_owner(&self, owner: Address) -> Vec { + self.owners_to_nft_ids + .get(owner) + .unwrap_or(Map::new(env())) + .keys() + } +} diff --git a/crates/loam-cli/examples/soroban/nft/src/subcontract.rs b/crates/loam-cli/examples/soroban/nft/src/subcontract.rs new file mode 100644 index 00000000..62b9613c --- /dev/null +++ b/crates/loam-cli/examples/soroban/nft/src/subcontract.rs @@ -0,0 +1,47 @@ +use loam_sdk::{ + soroban_sdk::{self, contracttype, Lazy, String}, + subcontract, +}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub struct Metadata { + pub(crate) name: String, + pub(crate) description: String, + pub(crate) url: String, +} + +#[subcontract] +pub trait IsNonFungible { + /// Mint a new NFT with the given owner and metadata + fn mint(&mut self, owner: loam_sdk::soroban_sdk::Address, metadata: Metadata) -> u128; + + /// Transfer the NFT with the given ID from current_owner to new_owner + fn transfer( + &mut self, + id: u128, + current_owner: loam_sdk::soroban_sdk::Address, + new_owner: loam_sdk::soroban_sdk::Address, + ); + + /// Get the NFT with the given ID + fn get_nft(&self, id: u128) -> Option; + + /// Find the owner of the NFT with the given ID + fn get_owner(&self, id: u128) -> Option; + + /// Get the total count of NFTs + fn get_total_count(&self) -> u128; + + /// Get all of the NFTs owned by the given address + fn get_collection_by_owner( + &self, + owner: loam_sdk::soroban_sdk::Address, + ) -> loam_sdk::soroban_sdk::Vec; +} + +#[subcontract] +pub trait IsInitable { + /// Initialize the NFT contract with the given admin and name + fn nft_init(&self, name: loam_sdk::soroban_sdk::Bytes); +} diff --git a/crates/loam-cli/examples/soroban/nft/src/test.rs b/crates/loam-cli/examples/soroban/nft/src/test.rs new file mode 100644 index 00000000..aa0483a3 --- /dev/null +++ b/crates/loam-cli/examples/soroban/nft/src/test.rs @@ -0,0 +1,285 @@ +#![cfg(test)] + +use crate::subcontract::Metadata; + +use super::{ + SorobanContract__ as SorobanContract, SorobanContract__Client as SorobanContractClient, +}; +use loam_sdk::soroban_sdk::{ + testutils::{Address as _, MockAuth, MockAuthInvoke}, + Address, Bytes, Env, IntoVal, String, +}; + +extern crate std; + +mod contract { + use loam_sdk::soroban_sdk; + + soroban_sdk::contractimport!(file = "../../../../../target/loam/example_nft.wasm"); +} + +fn init() -> (Env, SorobanContractClient<'static>, Address) { + let env = Env::default(); + let contract_id = env.register_contract(None, SorobanContract); + let client = SorobanContractClient::new(&env, &contract_id); + (env, client, contract_id) +} + +fn set_admin(env: &Env, client: &SorobanContractClient, contract_id: &Address, admin: &Address) { + client + .mock_auths(&[MockAuth { + address: admin, + invoke: &MockAuthInvoke { + contract: contract_id, + fn_name: "admin_set", + args: (admin,).into_val(env), + sub_invokes: &[], + }, + }]) + .admin_set(admin); +} + +fn init_nft_contract( + env: &Env, + client: &SorobanContractClient, + contract_id: &Address, + admin: &Address, + name: &str, +) { + let name = Bytes::from_slice(env, name.as_bytes()); + client + .mock_auths(&[MockAuth { + address: &admin, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "nft_init", + args: (name.clone(),).into_val(env), + sub_invokes: &[], + }, + }]) + .nft_init(&name); +} + +fn mint_nft( + env: &Env, + client: &SorobanContractClient, + contract_id: &Address, + admin: &Address, + owner: &Address, + metadata: &Metadata, +) -> u128 { + client + .mock_auths(&[MockAuth { + address: &admin, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "mint", + args: (owner.clone(), metadata.clone()).into_val(env), + sub_invokes: &[], + }, + }]) + .mint(&owner, &metadata) +} + +fn transfer_nft( + env: &Env, + client: &SorobanContractClient, + contract_id: &Address, + nft_id: u128, + owner: &Address, + new_owner: &Address, +) { + client + .mock_auths(&[MockAuth { + address: &owner, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "transfer", + args: (nft_id.clone(), owner.clone(), new_owner.clone()).into_val(env), + sub_invokes: &[], + }, + }]) + .transfer(&nft_id, &owner, &new_owner); +} + +#[test] +fn test_nft() { + let (env, client, contract_id) = &init(); + let admin = Address::generate(&env); + + // test admin_get and admin_set + assert!(client.admin_get().is_none(), "Admin already set"); + set_admin(env, client, contract_id, &admin); + assert_eq!(client.admin_get().unwrap(), admin); + + init_nft_contract(env, client, contract_id, &admin, "nftexample"); + assert_eq!(client.get_total_count(), 0); + assert!(client.get_nft(&1).is_none()); + assert!(client.get_owner(&1).is_none()); + assert_eq!(client.get_collection_by_owner(&admin).len(), 0); + + // test mint & getter fns + let owner_1 = Address::generate(env); + let metadata = Metadata { + name: String::from_str(env, "Nft Name"), + description: String::from_str(env, "description"), + url: String::from_str(env, "url"), + }; + let nft_id = mint_nft(env, client, contract_id, &admin, &owner_1, &metadata); + assert_eq!(nft_id, 1); + assert_eq!(client.get_nft(&nft_id), Some(metadata)); + assert_eq!(client.get_owner(&nft_id), Some(owner_1.clone())); + assert_eq!(client.get_total_count(), 1); + assert_eq!(client.get_collection_by_owner(&owner_1).len(), 1); + + // test transfer + let owner_2 = Address::generate(env); + transfer_nft(env, client, contract_id, nft_id, &owner_1, &owner_2); + assert_eq!(client.get_owner(&nft_id), Some(owner_2.clone())); + assert_eq!(client.get_total_count(), 1); + assert_eq!(client.get_collection_by_owner(&owner_1).len(), 0); + assert_eq!(client.get_collection_by_owner(&owner_2).len(), 1); +} + +#[test] +#[should_panic(expected = "No admin! Call 'admin_set' first.")] +fn test_initializing_without_admin_set() { + let (env, client, contract_id) = &init(); + let admin = Address::generate(env); + // test nft_init + init_nft_contract(env, client, contract_id, &admin, "nftexample"); +} + +#[test] +#[should_panic(expected = "Unauthorized function call for address")] +fn test_minting_by_non_admin() { + let (env, client, contract_id) = &init(); + let admin = Address::generate(&env); + + // set admin + assert!(client.admin_get().is_none(), "Admin already set"); + set_admin(env, client, contract_id, &admin); + assert!(client.admin_get().is_some(), "Admin not set"); + + // nft_init + init_nft_contract(env, client, contract_id, &admin, "nftexample"); + + assert_eq!(client.get_total_count(), 0); + + // try to mint from non-admin + let non_admin = Address::generate(env); + let metadata = Metadata { + name: String::from_str(env, "Nft Name"), + description: String::from_str(env, "description"), + url: String::from_str(env, "url"), + }; + mint_nft(env, client, contract_id, &non_admin, &non_admin, &metadata); +} + +#[test] +#[should_panic] +#[ignore = "This should panic, but it's not"] +fn test_minting_without_contract_being_initialized() { + let (env, client, contract_id) = &init(); + let admin = Address::generate(&env); + + // set admin + assert!(client.admin_get().is_none(), "Admin already set"); + set_admin(env, client, contract_id, &admin); + assert!(client.admin_get().is_some(), "Admin not set"); + + // try to mint though the contract has not been initialized + let owner_1 = Address::generate(env); + let metadata = Metadata { + name: String::from_str(env, "Nft Name"), + description: String::from_str(env, "description"), + url: String::from_str(env, "url"), + }; + mint_nft(env, client, contract_id, &admin, &owner_1, &metadata); +} + +#[test] +#[should_panic] +#[ignore = "This should panic, but it's not"] +fn test_getter_methods_before_initialization() { + let (env, client, contract_id) = &init(); + let admin = Address::generate(&env); + + // set admin + assert!(client.admin_get().is_none(), "Admin already set"); + set_admin(env, client, contract_id, &admin); + assert!(client.admin_get().is_some(), "Admin not set"); + + assert_eq!(client.get_total_count(), 0); +} + +#[test] +#[should_panic(expected = "You are not the owner of this NFT")] +fn test_transfer_by_non_owner() { + let (env, client, contract_id) = &init(); + let admin = Address::generate(&env); + + // set admin + assert!(client.admin_get().is_none(), "Admin already set"); + set_admin(env, client, contract_id, &admin); + assert!(client.admin_get().is_some(), "Admin not set"); + + // nft_init + init_nft_contract(env, client, contract_id, &admin, "nftexample"); + assert_eq!(client.get_total_count(), 0); + assert!(client.get_nft(&1).is_none()); + assert!(client.get_owner(&1).is_none()); + assert_eq!(client.get_collection_by_owner(&admin).len(), 0); + + // mint nft to owner 1 + let owner_1 = Address::generate(env); + let metadata = Metadata { + name: String::from_str(env, "Nft Name"), + description: String::from_str(env, "description"), + url: String::from_str(env, "url"), + }; + let nft_id = mint_nft(env, client, contract_id, &admin, &owner_1, &metadata); + assert_eq!(client.get_owner(&nft_id), Some(owner_1.clone())); + + // try to transfer nft by non-owner + let non_owner = Address::generate(env); + transfer_nft(env, client, contract_id, nft_id, &non_owner, &non_owner); +} + +#[test] +#[should_panic(expected = "NFT does not exist")] +fn test_transferring_a_non_existent_nft() { + let (env, client, contract_id) = &init(); + let admin = Address::generate(&env); + + // set admin + assert!(client.admin_get().is_none(), "Admin already set"); + set_admin(env, client, contract_id, &admin); + assert!(client.admin_get().is_some(), "Admin not set"); + + // nft_init + init_nft_contract(env, client, contract_id, &admin, "nftexample"); + + // mint nft1 to owner 1 + let owner_1 = Address::generate(env); + let metadata = Metadata { + name: String::from_str(env, "Nft Name"), + description: String::from_str(env, "description"), + url: String::from_str(env, "url"), + }; + let nft_id = mint_nft(env, client, contract_id, &admin, &owner_1, &metadata); + assert_eq!(nft_id, 1); + assert_eq!(client.get_owner(&nft_id), Some(owner_1.clone())); + + // try to transfer non_existing_nft_id to owner 2 + let owner_2 = Address::generate(env); + let non_existing_nft_id = 0; + transfer_nft( + env, + client, + contract_id, + non_existing_nft_id, + &owner_1, + &owner_2, + ); +} diff --git a/crates/loam-cli/examples/soroban/status_message/Cargo.toml b/crates/loam-cli/examples/soroban/status_message/Cargo.toml index b92f38eb..cf6fb990 100644 --- a/crates/loam-cli/examples/soroban/status_message/Cargo.toml +++ b/crates/loam-cli/examples/soroban/status_message/Cargo.toml @@ -14,5 +14,5 @@ doctest = false loam-sdk = { workspace = true, features = ["loam-soroban-sdk"] } loam-subcontract-core = { workspace = true } -[dev_dependencies] +[dev-dependencies] loam-sdk = { workspace = true, features = ["soroban-sdk-testutils"] } diff --git a/crates/loam-subcontract-ft/src/lib.rs b/crates/loam-subcontract-ft/src/lib.rs index 9ad57854..a4ca4cb8 100644 --- a/crates/loam-subcontract-ft/src/lib.rs +++ b/crates/loam-subcontract-ft/src/lib.rs @@ -90,7 +90,7 @@ pub trait IsFungible { pub trait IsInitable { /// Initialize ft Subcontract fn ft_init( - &mut self, + &self, admin: loam_sdk::soroban_sdk::Address, name: loam_sdk::soroban_sdk::Bytes, symbol: loam_sdk::soroban_sdk::Bytes,