diff --git a/.gitignore b/.gitignore index c556d1e590..244d898018 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,7 @@ osxcross/ target/ Cargo.lock **/*.rs.bk + +# simulator +simulator +!simulator/ diff --git a/Cargo.toml b/Cargo.toml index 0971adb859..0fd4a67189 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "x/programs/rust/wasmlanche_sdk", "x/programs/rust/examples/token", "x/programs/rust/examples/counter", + "x/programs/rust/examples/nft", ] resolver = "2" diff --git a/x/programs/rust/examples/examples/testdata/nft.wasm b/x/programs/rust/examples/examples/testdata/nft.wasm new file mode 100755 index 0000000000..c010b4f543 Binary files /dev/null and b/x/programs/rust/examples/examples/testdata/nft.wasm differ diff --git a/x/programs/rust/examples/nft/Cargo.toml b/x/programs/rust/examples/nft/Cargo.toml new file mode 100644 index 0000000000..ef183359cf --- /dev/null +++ b/x/programs/rust/examples/nft/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "nft" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +wasmlanche_sdk = { version = "0.1.0", path = "../../wasmlanche_sdk", features = ["simulator"]} +serde = "1.0.188" + +[dev-dependencies] +serde_json = "1.0.68" + +[lib] +crate-type = ["cdylib"] # set the crate(needed for cargo build to work properly) + +[[integration]] +name = "nft" +path = "tests/nft.rs" diff --git a/x/programs/rust/examples/nft/README.md b/x/programs/rust/examples/nft/README.md new file mode 100644 index 0000000000..3d4ff69ece --- /dev/null +++ b/x/programs/rust/examples/nft/README.md @@ -0,0 +1,18 @@ +# nft + +The `nft` HyperSDK Program is an example of an NFT (non-fungible token). + +For more information on NFTs, see the [resources at nft +school](https://nftschool.dev/concepts/non-fungible-tokens/#a-bit-of-history). + +## Usage + +The program exposes `mint` and `burn` methods which are publicly accessible. +Building a `Plan` with `Steps` to invoke these methods is the standard way to +interact with `nft`. See [the integration test](./tests/nft.rs) for a simple +invocation of `nft` via the WASM runtime. + +## Testing + +Use the [simulator](../../wasmlanche_sdk/src/simulator.rs) provided to run a +custom program. Detailed documentation on the simulator will soon be available. diff --git a/x/programs/rust/examples/nft/scripts/build_and_test.sh b/x/programs/rust/examples/nft/scripts/build_and_test.sh new file mode 100755 index 0000000000..04c7ea712b --- /dev/null +++ b/x/programs/rust/examples/nft/scripts/build_and_test.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if ! [[ "$0" =~ scripts/build_and_test.sh ]]; then + echo "must be run from crate root" + exit 255 +fi + +root="$(pwd)" + +simulator_path="${PWD}"/../../../cmd/simulator +simulator_bin="${simulator_path}"/bin/simulator + +echo "Downloading dependencies..." +cd "${simulator_path}" +go mod download + +echo "Building Simulator..." +go build -o "${simulator_bin}" "${simulator_path}"/simulator.go + +# Set environment variables for the test + +# The path to the simulator binary +export SIMULATOR_PATH="${simulator_bin}" + +# The path to the compiled Wasm program to be tested +export PROGRAM_PATH="${root}"/../examples/testdata/nft.wasm + +echo "Running Simulator Tests..." + +cargo test -p nft -- --include-ignored diff --git a/x/programs/rust/examples/nft/scripts/copy_wasm.sh b/x/programs/rust/examples/nft/scripts/copy_wasm.sh new file mode 100755 index 0000000000..103c1e197a --- /dev/null +++ b/x/programs/rust/examples/nft/scripts/copy_wasm.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if ! [[ "$0" =~ scripts/copy_wasm.sh ]]; then + echo "must be run from crate root" + exit 255 +fi + +root="$(pwd)" + +# Build the program +build_script_path="${root}"/../../scripts/build.sh +sh "${build_script_path}" out + +# Copy wasm file over +cp out/nft.wasm "${root}"/../examples/testdata/nft.wasm + +# Delete build artifacts +rm -rf "${root}"/out + +echo 'Successfully copied nft.wasm to examples/testdata' diff --git a/x/programs/rust/examples/nft/src/lib.rs b/x/programs/rust/examples/nft/src/lib.rs new file mode 100644 index 0000000000..bc2e4fe05a --- /dev/null +++ b/x/programs/rust/examples/nft/src/lib.rs @@ -0,0 +1,164 @@ +//! A basic NFT contract. +//! The program serves as a non-fungible token with the ability to mint and burn. +//! Only supports whole units with no decimal places. +//! +//! The NFT must support the common NFT metadata format. +//! This includes the name, symbol, and URI of the NFT. +use wasmlanche_sdk::{ + memory::{Memory, Pointer}, + program::Program, + public, state_keys, + types::Address, +}; + +/// The program storage keys. +#[state_keys] +enum StateKey { + /// The total supply of the token. Key prefix 0x0. + MaxSupply, + /// The name of the token. Key prefix 0x1. + Name, + /// The symbol of the token. Key prefix 0x2. + Symbol, + /// Metadata of the token. Key prefix 0x3. + Uri, + /// Balances of the NFT token by address. Key prefix 0x4(address). + Balances(Address), + /// Counter -- used to keep track of total NFTs minted. Key prefix 0x5. + Counter, +} + +/// Initializes the NFT with all required metadata. +/// This includes the name, symbol, image URI, owner, and total supply. +/// Returns true if the initialization was successful. +#[public] +// TODO: update the macro to enable String arguments +#[allow(clippy::too_many_arguments)] +pub fn init( + program: Program, + nft_name_ptr: i64, + nft_name_length: i64, + nft_symbol_ptr: i64, + nft_symbol_length: i64, + nft_uri_ptr: i64, + nft_uri_length: i64, + nft_max_supply: i64, +) -> bool { + let name_ptr = Memory::new(Pointer::from(nft_name_ptr)); + let nft_name = unsafe { name_ptr.range(nft_name_length as usize) }; + + let nft_symbol_ptr = Memory::new(Pointer::from(nft_symbol_ptr)); + let nft_symbol = unsafe { nft_symbol_ptr.range(nft_symbol_length as usize) }; + + let nft_uri_ptr = Memory::new(Pointer::from(nft_uri_ptr)); + let nft_uri = unsafe { nft_uri_ptr.range(nft_uri_length as usize) }; + + let counter = program + .state() + .get::(StateKey::Counter.to_vec()) + .unwrap_or_default(); + + assert_eq!(counter, 0, "init already called"); + + // Set token name + program + .state() + .store(StateKey::Name.to_vec(), &nft_name) + .expect("failed to store nft name"); + + // Set token symbol + program + .state() + .store(StateKey::Symbol.to_vec(), &nft_symbol) + .expect("failed to store nft symbol"); + + // Set token URI + program + .state() + .store(StateKey::Uri.to_vec(), &nft_uri) + .expect("failed to store nft uri"); + + // Set total supply + program + .state() + .store(StateKey::MaxSupply.to_vec(), &nft_max_supply) + .expect("failed to store total supply"); + + // Initialize counter + program + .state() + .store(StateKey::Counter.to_vec(), &0_i64) + .expect("failed to store counter"); + + true +} + +/// Mints NFT tokens and sends them to the recipient. +#[public] +pub fn mint(program: Program, recipient: Address, amount: i64) -> bool { + let counter = program + .state() + .get::(StateKey::Counter.to_vec()) + .unwrap_or_default(); + + let max_supply = program + .state() + .get::(StateKey::MaxSupply.to_vec()) + .unwrap_or_default(); + + assert!( + counter + amount <= max_supply, + "max supply for nft exceeded" + ); + + let balance = program + .state() + .get::(StateKey::Balances(recipient).to_vec()) + .unwrap_or_default(); + + // TODO: check for overflow + program + .state() + .store(StateKey::Balances(recipient).to_vec(), &(balance + amount)) + .expect("failed to store balance"); + + // TODO: check for overflow + program + .state() + .store(StateKey::Counter.to_vec(), &(counter + amount)) + .is_ok() +} + +#[public] +pub fn burn(program: Program, from: Address, amount: i64) -> bool { + let balance = program + .state() + .get::(StateKey::Balances(from).to_vec()) + .unwrap_or_default(); + + assert!( + balance >= amount, + "balance must be greater than or equal to amount burned" + ); + + let counter = program + .state() + .get::(StateKey::Counter.to_vec()) + .unwrap_or_default(); + + assert!(counter >= amount, "cannot burn more nfts"); + + // TODO: check for underflow + program + .state() + .store(StateKey::Balances(from).to_vec(), &(balance - amount)) + .is_ok() +} + +#[public] +fn balance(program: Program, owner: Address) -> i64 { + program + .state() + .get::(StateKey::Balances(owner).to_vec()) + .unwrap_or_default() +} diff --git a/x/programs/rust/examples/nft/tests/nft.rs b/x/programs/rust/examples/nft/tests/nft.rs new file mode 100644 index 0000000000..255f881620 --- /dev/null +++ b/x/programs/rust/examples/nft/tests/nft.rs @@ -0,0 +1,203 @@ +use std::env; +use wasmlanche_sdk::simulator::{ + id_from_step, Endpoint, Key, Operator, Param, ParamType, Plan, PlanResponse, Require, + ResultAssertion, Step, +}; + +pub fn initialize_plan( + nft_name: String, + nft_name_length: String, + nft_symbol: String, + nft_symbol_length: String, + nft_uri: String, + nft_uri_length: String, + nft_max_supply: String, +) -> Plan { + let p_path = env::var("PROGRAM_PATH").expect("PROGRAM_PATH not set"); + let steps = vec![ + Step { + endpoint: Endpoint::Execute, + method: "program_create".to_string(), + max_units: 0, + params: vec![Param { + param_type: ParamType::String, + value: p_path, + }], + require: None, + }, + Step { + endpoint: Endpoint::Execute, + method: "init".to_string(), + max_units: 100000, + params: vec![ + Param { + param_type: ParamType::Id, + value: id_from_step(0), + }, + Param { + param_type: ParamType::String, + value: nft_name, + }, + Param { + param_type: ParamType::U64, + value: nft_name_length, + }, + Param { + param_type: ParamType::String, + value: nft_symbol, + }, + Param { + param_type: ParamType::U64, + value: nft_symbol_length, + }, + Param { + param_type: ParamType::String, + value: nft_uri, + }, + Param { + param_type: ParamType::U64, + value: nft_uri_length, + }, + Param { + param_type: ParamType::U64, + value: nft_max_supply, + }, + ], + require: None, + }, + Step { + endpoint: Endpoint::Key, + method: "key_create".to_string(), + max_units: 0, + params: vec![Param { + param_type: ParamType::Key(Key::Ed25519), + value: "alice_key".to_string(), + }], + require: None, + }, + Step { + endpoint: Endpoint::Execute, + method: "mint".to_string(), + max_units: 100000, + params: vec![ + Param { + param_type: ParamType::Id, + value: id_from_step(0), + }, + Param { + param_type: ParamType::Key(Key::Ed25519), + value: "alice_key".to_string(), + }, + Param { + param_type: ParamType::U64, + value: "1".to_string(), + }, + ], + require: None, + }, + Step { + endpoint: Endpoint::Execute, + method: "burn".to_string(), + max_units: 100000, + params: vec![ + Param { + param_type: ParamType::Id, + value: id_from_step(0), + }, + Param { + param_type: ParamType::Key(Key::Ed25519), + value: "alice_key".to_string(), + }, + Param { + param_type: ParamType::U64, + value: "1".to_string(), + }, + ], + require: None, + }, + ]; + + Plan { + caller_key: "alice_key".to_string(), + steps, + } +} + +// export SIMULATOR_PATH=/path/to/simulator +// export PROGRAM_PATH=/path/to/program.wasm +#[test] +fn test_nft_plan() { + use wasmlanche_sdk::simulator::{self, Key}; + let s_path = env::var(simulator::PATH_KEY).expect("SIMULATOR_PATH not set"); + let simulator = simulator::Client::new(s_path); + + let alice_key = "alice_key"; + // create owner key in single step + let resp = simulator + .key_create::(alice_key, Key::Ed25519) + .unwrap(); + assert_eq!(resp.error, None); + + // create multiple step test plan + let nft_name = "MyNFT".to_string(); + let binding = nft_name.len().to_string(); + let nft_name_length = binding.to_string(); + + let nft_symbol = "MNFT".to_string(); + let binding = nft_symbol.len().to_string(); + let nft_symbol_length = binding.to_string(); + + let nft_uri = "ipfs://my-nft.jpg".to_string(); + let binding = nft_uri.len().to_string(); + let nft_uri_length = binding.to_string(); + + let nft_max_supply = "10".to_string(); + + let plan = initialize_plan( + nft_name, + nft_name_length, + nft_symbol, + nft_symbol_length, + nft_uri, + nft_uri_length, + nft_max_supply, + ); + + // run plan + let plan_responses = simulator.run::(&plan).unwrap(); + + // collect actual id of program from step 0 + let mut program_id = String::new(); + if let Some(step_0) = plan_responses.first() { + program_id = step_0.result.id.clone().unwrap_or_default(); + } + + assert!( + plan_responses.iter().all(|resp| resp.error.is_none()), + "error: {:?}", + plan_responses + .iter() + .filter_map(|resp| resp.error.as_ref()) + .next() + ); + + // Check Alice balance is 0 as expected after minting 1 and burning 1. + let resp = simulator + .read_only::( + "alice_key", + "balance", + vec![ + Param::new(ParamType::Id, program_id.as_ref()), + Param::new(ParamType::Key(Key::Ed25519), "alice_key"), + ], + Some(Require { + result: ResultAssertion { + operator: Operator::NumericEq, + value: "0".into(), + }, + }), + ) + .expect("failed to get alice balance"); + + assert_eq!(resp.error, None); +}