From 8ddb526237cba5cdab46e249c45e375b73a45c74 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Mon, 29 Dec 2025 15:43:21 +0200 Subject: [PATCH 01/21] sc-payments update --- .../developer-reference/sc-payments.md | 130 ++++++++++++++++-- 1 file changed, 121 insertions(+), 9 deletions(-) diff --git a/docs/developers/developer-reference/sc-payments.md b/docs/developers/developer-reference/sc-payments.md index 23f73ecb4..727c9a08e 100644 --- a/docs/developers/developer-reference/sc-payments.md +++ b/docs/developers/developer-reference/sc-payments.md @@ -10,9 +10,12 @@ title: Smart contract payments We want to offer an overview on how smart contracts process payments. This includes two complementary parts: receiving tokens and sending them. :::important important -On MultiversX it is impossible to send both EGLD and any ESDT token at the same time. +On MultiversX it is possible to send one or more tokens with any transaction. This includes EGLD, and it is also possible (though impractical) to send several payments of the same token at once. +::: + -For this reason you will see no syntax for transferring both, neither when sending, nor receiving. +:::note note +Historically, it used to be impossible to send EGLD and ESDT at the same time, this is why some of the legacy APIs have this restriction. This restriction no longer applies since the [Spica release](https://multiversx.com/release/release-spica-patch-4-v1-8-12). ::: --- @@ -29,7 +32,7 @@ There are two ways in which a smart contract can receive payments: ### Receiving payments directly -Sending EGLD and ESDT tokens directly to accounts works the same way for EOAs (extrernally owned accounts) as for smart contracts: the tokens are transferred from one account to the other without firing up the VM. +Sending EGLD and ESDT tokens directly to accounts works the same way for EOAs (externally owned accounts) as for smart contracts: the tokens are transferred from one account to the other without firing up the VM. However, not all smart contracts are allowed to receive tokens directly. There is a flag that controls this, called "payable". This flag is part of the [code metadata](/developers/data/code-metadata), and is specified in the transaction that deploys or upgrades the smart contract. @@ -39,7 +42,7 @@ The rationale for this is as follows: the MultiversX blockchain doesn't offer an ### Receiving payments via endpoints -The most common way for contracts to accept payments is by having endpoints annotated with the `#[payable(...)]` annotation. +The most common way for contracts to accept payments is by having endpoints annotated with the `#[payable]` annotation (or `#[payable("*")]`). :::important important The "payable" flag in the code metadata only refers to direct transfers. Transferring tokens via contract endpoint calls is not affected by it in any way. @@ -55,13 +58,13 @@ fn accept_egld(&self) { } ``` -When annotated like this, the contract will reject any ESDT payment. Calling this function without any payment will work. +When annotated like this, the contract will only accept a single EGLD payment. -To accept any kind of payment, do annotate the endpoints with `#[payable("*")]`: +To accept any kind of payment, annotate the endpoints with `#[payable]`: ```rust #[endpoint] -#[payable("*")] +#[payable] fn accept_any_payment(&self) { // ... } @@ -71,7 +74,116 @@ fn accept_any_payment(&self) { It is also possible to hard-code a token identifier in the `payable`, e.g. `#[payable("MYTOKEN-123456")]`. It is rarely, if ever, used, tokens should normally be configured in storage, or at runtime. ::: +[comment]: # (mx-context-auto) + +## Payment Types + +The framework provides a unified approach to handling payments using the `Payment` type that treats EGLD and ESDT tokens uniformly. EGLD is represented as `EGLD-000000` token identifier, making all payment handling consistent. + +**`Payment`** - The primary payment type that combines: +- `token_identifier`: `TokenId` - unified token identifier (EGLD serialized as "EGLD-000000") +- `token_nonce`: `u64` - token nonce for NFTs/SFTs, which is zero for all fungible tokens (incl. EGLD) +- `amount`: `NonZeroBigUint` - guaranteed non-zero amount + +**`PaymentVec`** - A managed vector of `Payment` objects, representing multiple payments in a single transaction. + +[comment]: # (mx-context-auto) + +## Call Value Methods + Additional restrictions on the incoming tokens can be imposed in the body of the endpoint, by calling the call value API. Most of these functions retrieve data about the received payment, while also stopping execution if the payment is not of the expected type. + +[comment]: # (mx-context-auto) + +### `all()` - Complete Payment Collection + +`self.call_value().all()` retrieves all payments sent with the transaction as a `PaymentVec`. It handles all tokens uniformly, including EGLD (represented as "EGLD-000000"). Never stops execution. + +```rust +#[payable("*")] +#[endpoint] +pub fn process_all_payments(&self) { + let payments = self.call_value().all(); + for payment in payments.iter() { + let token_id = &payment.token_identifier; + let amount = payment.amount; + let nonce = &payment.token_nonce; + // Handle each payment uniformly + self.process_payment(token_id, nonce, amount); + } +} +``` + +[comment]: # (mx-context-auto) + +### `single()` - Strict Single Payment + +`self.call_value().single()` expects exactly one payment and returns it. Will halt execution if zero or multiple payments are received. Returns a `Payment` object. + +```rust +#[payable("*")] +#[endpoint] +pub fn deposit(&self) { + let payment = self.call_value().single(); + // Guaranteed to be exactly one payment + let token_id = &payment.token_identifier; + let amount = payment.amount; + + self.deposits(&self.blockchain().get_caller()).set(&amount); +} +``` + +[comment]: # (mx-context-auto) + +### `single_optional()` - Flexible Single Payment + +`self.call_value().single_optional()` accepts either zero or one payment. Returns `Option>` for graceful handling. Will halt execution if multiple payments are received. + +```rust +#[payable("*")] +#[endpoint] +pub fn execute_with_optional_fee(&self) { + match self.call_value().single_optional() { + Some(payment) => { + // Process the payment as fee + self.execute_premium_service(payment); + }, + None => { + // Handle no payment scenario + self.execute_basic_service(); + } + } +} +``` + +[comment]: # (mx-context-auto) + +### `array()` - Fixed-Size Payment Array + +`self.call_value().array()` expects exactly N payments and returns them as a fixed-size array. Will halt execution if the number of payments doesn't match exactly. + +```rust +#[payable("*")] +#[endpoint] +pub fn swap(&self) { + // Expect exactly 2 payments for the swap + let [input_payment, fee_payment] = self.call_value().array(); + + require!( + input_payment.token_identifier != fee_payment.token_identifier, + "Input and fee must be different tokens" + ); + + self.execute_swap(input_payment, fee_payment); +} +``` + +[comment]: # (mx-context-auto) + +## Legacy Call Value Methods + +The following methods are available for backwards compatibility but may be deprecated in future versions: + - `self.call_value().egld_value()` retrieves the EGLD value transferred, or zero. Never stops execution. - `self.call_value().all_esdt_transfers()` retrieves all the ESDT transfers received, or an empty list. Never stops execution. - `self.call_value().multi_esdt()` is ideal when we know exactly how many ESDT transfers we expect. It returns an array of `EsdtTokenPayment`. It knows exactly how many transfers to expect based on the return type (it is polymorphic in the length of the array). Will fail execution if the number of ESDT transfers does not match. @@ -79,7 +191,7 @@ Additional restrictions on the incoming tokens can be imposed in the body of the - `self.call_value().single_fungible_esdt()` further restricts `single_esdt` to only fungible tokens, so those with their nonce zero. Returns the token identifier and amount, as pair. - `self.call_value().egld_or_single_esdt()` retrieves an object of type `EgldOrEsdtTokenPayment`. Will halt execution in case of ESDT multi-transfer. - `self.call_value().egld_or_single_fungible_esdt()` further restricts `egld_or_single_esdt` to fungible ESDT tokens. It will return a pair of `EgldOrEsdtTokenIdentifier` and an amount. -- `self.call_value().any_payment()` is the most general payment retriever. Never stops execution. Returns an object of type `EgldOrMultiEsdtPayment`. +- `self.call_value().any_payment()` is the most general payment retriever. Never stops execution. Returns an object of type `EgldOrMultiEsdtPayment`. *(Deprecated since 0.64.0 - use `all()` instead)* --- @@ -87,4 +199,4 @@ Additional restrictions on the incoming tokens can be imposed in the body of the ## Sending payments -We have seen how contracts can accommodate receiving tokens. Sending them is, in principle, even more straightforward, as it only involves specializing the `Payment` generic of the transaction using specific methods, or better said, attaching a payload to a regular transaction. Read more about payments [here](../transactions/tx-payment.md). +We have seen how contracts can accommodate receiving tokens. Sending them is, in principle, even more straightforward, as it only involves specializing the `Payment` generic of the transaction using specific methods, essentially attaching a payload to a regular transaction. Read more about payments [here](../transactions/tx-payment.md). From d5f6c22212a87e2148b45e862307d1a177ac7e63 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Wed, 14 Jan 2026 13:42:04 +0200 Subject: [PATCH 02/21] crowdfunding update to v0.64.1 --- .github/workflows/rust-tutorial-ci.yml | 2 +- Cargo.toml | 4 +- .../developer-reference/sc-payments.md | 2 +- .../testing/rust/whitebox-legacy.md | 12 +- docs/developers/testing/sc-debugging.md | 2 +- docs/developers/tutorials/crowdfunding-p1.md | 9 +- docs/developers/tutorials/crowdfunding-p2.md | 67 ++++-- rust-toolchain.toml | 2 + scripts/rust-tutorial-ci.sh | 2 +- .../.gitignore | 0 testing/crowdfunding/Cargo.toml | 21 ++ .../meta/Cargo.toml | 4 +- .../meta/src/main.rs | 0 testing/crowdfunding/multiversx.json | 3 + testing/crowdfunding/sc-config.toml | 2 + testing/crowdfunding/src/crowdfunding.rs | 138 +++++++++++ .../tests/crowdfunding_blackbox_test.rs | 215 ++++++++++++++++++ testing/extract-tutorial-code/Cargo.toml | 6 +- .../extract-tutorial-code/src/extract_code.rs | 78 ++++--- testing/extract-tutorial-code/src/parser.rs | 94 ++++++++ 20 files changed, 589 insertions(+), 74 deletions(-) create mode 100644 rust-toolchain.toml rename testing/{crowdfunding-esdt => crowdfunding}/.gitignore (100%) create mode 100644 testing/crowdfunding/Cargo.toml rename testing/{crowdfunding-esdt => crowdfunding}/meta/Cargo.toml (80%) rename testing/{crowdfunding-esdt => crowdfunding}/meta/src/main.rs (100%) create mode 100644 testing/crowdfunding/multiversx.json create mode 100644 testing/crowdfunding/sc-config.toml create mode 100644 testing/crowdfunding/src/crowdfunding.rs create mode 100644 testing/crowdfunding/tests/crowdfunding_blackbox_test.rs create mode 100644 testing/extract-tutorial-code/src/parser.rs diff --git a/.github/workflows/rust-tutorial-ci.yml b/.github/workflows/rust-tutorial-ci.yml index 3ab74821a..292deac53 100644 --- a/.github/workflows/rust-tutorial-ci.yml +++ b/.github/workflows/rust-tutorial-ci.yml @@ -15,6 +15,6 @@ jobs: - uses: actions-rs/toolchain@v1 with: default: true - toolchain: nightly + toolchain: stable - name: Run rust tests run: ./scripts/rust-tutorial-ci.sh diff --git a/Cargo.toml b/Cargo.toml index e647788e5..a9ad1642f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,10 @@ [workspace] +resolver = "3" + members = [ "testing/extract-tutorial-code", ] exclude = [ - "testing/crowdfunding-esdt", + "testing/crowdfunding", ] diff --git a/docs/developers/developer-reference/sc-payments.md b/docs/developers/developer-reference/sc-payments.md index 727c9a08e..1be8ad8fd 100644 --- a/docs/developers/developer-reference/sc-payments.md +++ b/docs/developers/developer-reference/sc-payments.md @@ -191,7 +191,7 @@ The following methods are available for backwards compatibility but may be depre - `self.call_value().single_fungible_esdt()` further restricts `single_esdt` to only fungible tokens, so those with their nonce zero. Returns the token identifier and amount, as pair. - `self.call_value().egld_or_single_esdt()` retrieves an object of type `EgldOrEsdtTokenPayment`. Will halt execution in case of ESDT multi-transfer. - `self.call_value().egld_or_single_fungible_esdt()` further restricts `egld_or_single_esdt` to fungible ESDT tokens. It will return a pair of `EgldOrEsdtTokenIdentifier` and an amount. -- `self.call_value().any_payment()` is the most general payment retriever. Never stops execution. Returns an object of type `EgldOrMultiEsdtPayment`. *(Deprecated since 0.64.0 - use `all()` instead)* +- `self.call_value().any_payment()` is the most general payment retriever. Never stops execution. Returns an object of type `EgldOrMultiEsdtPayment`. *(Deprecated since 0.64.1 - use `all()` instead)* --- diff --git a/docs/developers/testing/rust/whitebox-legacy.md b/docs/developers/testing/rust/whitebox-legacy.md index c37dbc079..e55d9d379 100644 --- a/docs/developers/testing/rust/whitebox-legacy.md +++ b/docs/developers/testing/rust/whitebox-legacy.md @@ -39,14 +39,14 @@ num-traits = "0.2" hex = "0.4" ``` -For this tutorial, we're going to use the crowdfunding SC, so it might be handy to have it open or clone the repository: https://github.com/multiversx/mx-sdk-rs/tree/master/contracts/examples/crowdfunding-esdt +For this tutorial, we're going to use the crowdfunding SC, so it might be handy to have it open or clone the repository: https://github.com/multiversx/mx-sdk-rs/tree/master/contracts/examples/crowdfunding You need a `tests` and a `scenarios` folder in your contract. Create a `.rs` file in your `tests` folder. -In your newly created test file, add the following code (adapt the `crowdfunding_esdt` namespace, the struct/variable names, and the contract wasm path according to your contract): +In your newly created test file, add the following code (adapt the `crowdfunding` namespace, the struct/variable names, and the contract wasm path according to your contract): ```rust -use crowdfunding_esdt::*; +use crowdfunding::*; use multiversx_sc::{ sc_error, types::{Address, SCResult}, @@ -56,19 +56,19 @@ use multiversx_sc_scenario::{ DebugApi, }; -const WASM_PATH: &'static str = "crowdfunding-esdt/output/crowdfunding-esdt.wasm"; +const WASM_PATH: &'static str = "crowdfunding/output/crowdfunding.wasm"; struct CrowdfundingSetup where CrowdfundingObjBuilder: - 'static + Copy + Fn() -> crowdfunding_esdt::ContractObj, + 'static + Copy + Fn() -> crowdfunding::ContractObj, { pub blockchain_wrapper: BlockchainStateWrapper, pub owner_address: Address, pub first_user_address: Address, pub second_user_address: Address, pub cf_wrapper: - ContractObjWrapper, CrowdfundingObjBuilder>, + ContractObjWrapper, CrowdfundingObjBuilder>, } ``` diff --git a/docs/developers/testing/sc-debugging.md b/docs/developers/testing/sc-debugging.md index 8d324ea41..84f3ced93 100644 --- a/docs/developers/testing/sc-debugging.md +++ b/docs/developers/testing/sc-debugging.md @@ -19,7 +19,7 @@ For this tutorial, you will need: - the [CodeLLDB](https://marketplace.visualstudio.com/items?itemName=vadimcn.vscode-lldb) extension. - A [Rust test](rust/sc-blackbox-example) -If you want to follow along, you can clone the [mx-sdk-rs](https://github.com/multiversx/mx-sdk-rs) repository and use the [crowdfunding-esdt](https://github.com/multiversx/mx-sdk-rs/tree/master/contracts/examples/crowdfunding-esdt) example. +If you want to follow along, you can clone the [mx-sdk-rs](https://github.com/multiversx/mx-sdk-rs) repository and use the [crowdfunding](https://github.com/multiversx/mx-sdk-rs/tree/master/contracts/examples/crowdfunding) example. [comment]: # (mx-context-auto) diff --git a/docs/developers/tutorials/crowdfunding-p1.md b/docs/developers/tutorials/crowdfunding-p1.md index d3f83f53f..d1cfc7f3d 100644 --- a/docs/developers/tutorials/crowdfunding-p1.md +++ b/docs/developers/tutorials/crowdfunding-p1.md @@ -81,20 +81,17 @@ You may choose any location you want for your smart contract. Either way, now th name = "crowdfunding" version = "0.0.0" publish = false -edition = "2021" +edition = "2024" authors = ["you"] [lib] path = "src/crowdfunding.rs" [dependencies.multiversx-sc] -version = "0.57.0" - -[dev-dependencies] -num-bigint = "0.4" +version = "0.64.1" [dev-dependencies.multiversx-sc-scenario] -version = "0.57.0" +version = "0.64.1" [workspace] members = [ diff --git a/docs/developers/tutorials/crowdfunding-p2.md b/docs/developers/tutorials/crowdfunding-p2.md index 516a4a8bc..4b58bab38 100644 --- a/docs/developers/tutorials/crowdfunding-p2.md +++ b/docs/developers/tutorials/crowdfunding-p2.md @@ -527,11 +527,11 @@ If you followed all the steps presented until now, you should have ended up with ```rust title=crowdfunding.rs #![no_std] -use multiversx_sc::{derive_imports::*, imports::*}; +use multiversx_sc::{chain_core::types::TimestampMillis, derive_imports::*, imports::*}; pub mod crowdfunding_proxy; #[type_abi] -#[derive(TopEncode, TopDecode, PartialEq, Clone, Copy)] +#[derive(TopEncode, TopDecode, PartialEq, Eq, Clone, Copy, Debug)] pub enum Status { FundingPeriod, Successful, @@ -541,34 +541,43 @@ pub enum Status { #[multiversx_sc::contract] pub trait Crowdfunding { #[init] - fn init(&self, target: BigUint, deadline: u64) { + fn init(&self, token_identifier: TokenId, target: BigUint, deadline: TimestampMillis) { + require!(token_identifier.is_valid(), "Invalid token provided"); + self.cf_token_identifier().set(token_identifier); + require!(target > 0, "Target must be more than 0"); self.target().set(target); require!( - deadline > self.get_current_time(), + deadline > self.get_current_time_ms(), "Deadline can't be in the past" ); self.deadline().set(deadline); } #[endpoint] - #[payable("EGLD")] + #[payable] fn fund(&self) { - let payment = self.call_value().egld(); + let payment = self.call_value().single(); + require!( + payment.token_identifier == self.cf_token_identifier().get(), + "wrong token" + ); + require!(payment.is_fungible(), "only fungible tokens accepted"); require!( self.status() == Status::FundingPeriod, "cannot fund after deadline" ); let caller = self.blockchain().get_caller(); - self.deposit(&caller).update(|deposit| *deposit += &*payment); + self.deposit(&caller) + .update(|deposit| *deposit += payment.amount.as_big_uint()); } #[view] fn status(&self) -> Status { - if self.get_current_time() <= self.deadline().get() { + if self.get_current_time_ms() < self.deadline().get() { Status::FundingPeriod } else if self.get_current_funds() >= self.target().get() { Status::Successful @@ -578,8 +587,11 @@ pub trait Crowdfunding { } #[view(getCurrentFunds)] + #[title("currentFunds")] fn get_current_funds(&self) -> BigUint { - self.blockchain().get_sc_balance(&EgldOrEsdtTokenIdentifier::egld(), 0) + let token = self.cf_token_identifier().get(); + + self.blockchain().get_sc_balance(&token, 0) } #[endpoint] @@ -593,40 +605,63 @@ pub trait Crowdfunding { "only owner can claim successful funding" ); + let token_identifier = self.cf_token_identifier().get(); let sc_balance = self.get_current_funds(); - self.send().direct_egld(&caller, &sc_balance); - }, + + if let Some(sc_balance_non_zero) = sc_balance.into_non_zero() { + self.tx() + .to(&caller) + .payment(Payment::new(token_identifier, 0, sc_balance_non_zero)) + .transfer(); + } + } Status::Failed => { let caller = self.blockchain().get_caller(); let deposit = self.deposit(&caller).get(); if deposit > 0u32 { + let token_identifier = self.cf_token_identifier().get(); + self.deposit(&caller).clear(); - self.send().direct_egld(&caller, &deposit); + + if let Some(deposit_non_zero) = deposit.into_non_zero() { + self.tx() + .to(&caller) + .payment(Payment::new(token_identifier, 0, deposit_non_zero)) + .transfer(); + } } - }, + } } } // private - fn get_current_time(&self) -> u64 { - self.blockchain().get_block_timestamp() + fn get_current_time_ms(&self) -> TimestampMillis { + self.blockchain().get_block_timestamp_millis() } // storage #[view(getTarget)] + #[title("target")] #[storage_mapper("target")] fn target(&self) -> SingleValueMapper; #[view(getDeadline)] + #[title("deadline")] #[storage_mapper("deadline")] - fn deadline(&self) -> SingleValueMapper; + fn deadline(&self) -> SingleValueMapper; #[view(getDeposit)] + #[title("deposit")] #[storage_mapper("deposit")] fn deposit(&self, donor: &ManagedAddress) -> SingleValueMapper; + + #[view(getCrowdfundingTokenIdentifier)] + #[title("tokenIdentifier")] + #[storage_mapper("tokenIdentifier")] + fn cf_token_identifier(&self) -> SingleValueMapper; } ``` diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 000000000..50b3f5d47 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.92" diff --git a/scripts/rust-tutorial-ci.sh b/scripts/rust-tutorial-ci.sh index 3a0d5998c..aafe121a5 100755 --- a/scripts/rust-tutorial-ci.sh +++ b/scripts/rust-tutorial-ci.sh @@ -7,5 +7,5 @@ cd testing/extract-tutorial-code cargo run || return 1 -cd ../crowdfunding-esdt +cd ../crowdfunding cargo test || return 1 diff --git a/testing/crowdfunding-esdt/.gitignore b/testing/crowdfunding/.gitignore similarity index 100% rename from testing/crowdfunding-esdt/.gitignore rename to testing/crowdfunding/.gitignore diff --git a/testing/crowdfunding/Cargo.toml b/testing/crowdfunding/Cargo.toml new file mode 100644 index 000000000..835098bb4 --- /dev/null +++ b/testing/crowdfunding/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "crowdfunding" +version = "0.0.0" +publish = false +edition = "2024" +authors = ["you"] + +[lib] +path = "src/crowdfunding.rs" + +[dependencies.multiversx-sc] +version = "0.64.1" + +[dev-dependencies.multiversx-sc-scenario] +version = "0.64.1" + +[workspace] +members = [ + ".", + "meta", +] diff --git a/testing/crowdfunding-esdt/meta/Cargo.toml b/testing/crowdfunding/meta/Cargo.toml similarity index 80% rename from testing/crowdfunding-esdt/meta/Cargo.toml rename to testing/crowdfunding/meta/Cargo.toml index 44b5f94b0..322c76660 100644 --- a/testing/crowdfunding-esdt/meta/Cargo.toml +++ b/testing/crowdfunding/meta/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "crowdfunding-meta" version = "0.0.0" -edition = "2018" +edition = "2024" publish = false [dependencies.crowdfunding] path = ".." [dependencies.multiversx-sc-meta] -version = "0.39.0" +version = "0.64.1" diff --git a/testing/crowdfunding-esdt/meta/src/main.rs b/testing/crowdfunding/meta/src/main.rs similarity index 100% rename from testing/crowdfunding-esdt/meta/src/main.rs rename to testing/crowdfunding/meta/src/main.rs diff --git a/testing/crowdfunding/multiversx.json b/testing/crowdfunding/multiversx.json new file mode 100644 index 000000000..736553962 --- /dev/null +++ b/testing/crowdfunding/multiversx.json @@ -0,0 +1,3 @@ +{ + "language": "rust" +} \ No newline at end of file diff --git a/testing/crowdfunding/sc-config.toml b/testing/crowdfunding/sc-config.toml new file mode 100644 index 000000000..d8bcd01b8 --- /dev/null +++ b/testing/crowdfunding/sc-config.toml @@ -0,0 +1,2 @@ +[[proxy]] +path = "src/crowdfunding_proxy.rs" diff --git a/testing/crowdfunding/src/crowdfunding.rs b/testing/crowdfunding/src/crowdfunding.rs new file mode 100644 index 000000000..43fee2374 --- /dev/null +++ b/testing/crowdfunding/src/crowdfunding.rs @@ -0,0 +1,138 @@ +#![no_std] + +use multiversx_sc::{chain_core::types::TimestampMillis, derive_imports::*, imports::*}; +pub mod crowdfunding_proxy; + +#[type_abi] +#[derive(TopEncode, TopDecode, PartialEq, Eq, Clone, Copy, Debug)] +pub enum Status { + FundingPeriod, + Successful, + Failed, +} + +#[multiversx_sc::contract] +pub trait Crowdfunding { + #[init] + fn init(&self, token_identifier: TokenId, target: BigUint, deadline: TimestampMillis) { + require!(token_identifier.is_valid(), "Invalid token provided"); + self.cf_token_identifier().set(token_identifier); + + require!(target > 0, "Target must be more than 0"); + self.target().set(target); + + require!( + deadline > self.get_current_time_ms(), + "Deadline can't be in the past" + ); + self.deadline().set(deadline); + } + + #[endpoint] + #[payable] + fn fund(&self) { + let payment = self.call_value().single(); + + require!( + payment.token_identifier == self.cf_token_identifier().get(), + "wrong token" + ); + require!(payment.is_fungible(), "only fungible tokens accepted"); + require!( + self.status() == Status::FundingPeriod, + "cannot fund after deadline" + ); + + let caller = self.blockchain().get_caller(); + self.deposit(&caller) + .update(|deposit| *deposit += payment.amount.as_big_uint()); + } + + #[view] + fn status(&self) -> Status { + if self.get_current_time_ms() < self.deadline().get() { + Status::FundingPeriod + } else if self.get_current_funds() >= self.target().get() { + Status::Successful + } else { + Status::Failed + } + } + + #[view(getCurrentFunds)] + #[title("currentFunds")] + fn get_current_funds(&self) -> BigUint { + let token = self.cf_token_identifier().get(); + + self.blockchain().get_sc_balance(&token, 0) + } + + #[endpoint] + fn claim(&self) { + match self.status() { + Status::FundingPeriod => sc_panic!("cannot claim before deadline"), + Status::Successful => { + let caller = self.blockchain().get_caller(); + require!( + caller == self.blockchain().get_owner_address(), + "only owner can claim successful funding" + ); + + let token_identifier = self.cf_token_identifier().get(); + let sc_balance = self.get_current_funds(); + + if let Some(sc_balance_non_zero) = sc_balance.into_non_zero() { + self.tx() + .to(&caller) + .payment(Payment::new(token_identifier, 0, sc_balance_non_zero)) + .transfer(); + } + } + Status::Failed => { + let caller = self.blockchain().get_caller(); + let deposit = self.deposit(&caller).get(); + + if deposit > 0u32 { + let token_identifier = self.cf_token_identifier().get(); + + self.deposit(&caller).clear(); + + if let Some(deposit_non_zero) = deposit.into_non_zero() { + self.tx() + .to(&caller) + .payment(Payment::new(token_identifier, 0, deposit_non_zero)) + .transfer(); + } + } + } + } + } + + // private + + fn get_current_time_ms(&self) -> TimestampMillis { + self.blockchain().get_block_timestamp_millis() + } + + // storage + + #[view(getTarget)] + #[title("target")] + #[storage_mapper("target")] + fn target(&self) -> SingleValueMapper; + + #[view(getDeadline)] + #[title("deadline")] + #[storage_mapper("deadline")] + fn deadline(&self) -> SingleValueMapper; + + #[view(getDeposit)] + #[title("deposit")] + #[storage_mapper("deposit")] + fn deposit(&self, donor: &ManagedAddress) -> SingleValueMapper; + + #[view(getCrowdfundingTokenIdentifier)] + #[title("tokenIdentifier")] + #[storage_mapper("tokenIdentifier")] + fn cf_token_identifier(&self) -> SingleValueMapper; +} diff --git a/testing/crowdfunding/tests/crowdfunding_blackbox_test.rs b/testing/crowdfunding/tests/crowdfunding_blackbox_test.rs new file mode 100644 index 000000000..141402ede --- /dev/null +++ b/testing/crowdfunding/tests/crowdfunding_blackbox_test.rs @@ -0,0 +1,215 @@ +use crowdfunding::crowdfunding_proxy; + +use multiversx_sc_scenario::imports::*; + +const CF_DEADLINE: TimestampMillis = TimestampMillis::new(7 * 24 * 60 * 60 * 1000); // 1 week in milliseconds +const CF_TOKEN_ID: TestTokenIdentifier = TestTokenIdentifier::new("CROWD-123456"); +const FIRST_USER_ADDRESS: TestAddress = TestAddress::new("first-user"); +const OWNER_ADDRESS: TestAddress = TestAddress::new("owner"); +const SECOND_USER_ADDRESS: TestAddress = TestAddress::new("second-user"); +const CODE_PATH: MxscPath = MxscPath::new("output/crowdfunding.mxsc.json"); +const CROWDFUNDING_ADDRESS: TestSCAddress = TestSCAddress::new("crowdfunding-sc"); + +fn world() -> ScenarioWorld { + let mut blockchain = ScenarioWorld::new(); + + blockchain.set_current_dir_from_workspace("contracts/examples/crowdfunding"); + blockchain.register_contract(CODE_PATH, crowdfunding::ContractBuilder); + blockchain +} + +struct CrowdfundingTestState { + world: ScenarioWorld, +} + +impl CrowdfundingTestState { + fn new() -> Self { + let mut world = world(); + + world.account(OWNER_ADDRESS).nonce(1); + + world + .account(FIRST_USER_ADDRESS) + .nonce(1) + .balance(1000) + .esdt_balance(CF_TOKEN_ID, 1000); + + world + .account(SECOND_USER_ADDRESS) + .nonce(1) + .esdt_balance(CF_TOKEN_ID, 1000); + + Self { world } + } + + fn deploy(&mut self) { + self.world + .tx() + .from(OWNER_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .init(CF_TOKEN_ID, 2_000u32, CF_DEADLINE) + .code(CODE_PATH) + .new_address(CROWDFUNDING_ADDRESS) + .run(); + } + + fn fund(&mut self, address: TestAddress, amount: u64) { + self.world + .tx() + .from(address) + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .fund() + .egld_or_single_esdt( + &EgldOrEsdtTokenIdentifier::esdt(CF_TOKEN_ID), + 0u64, + &multiversx_sc::proxy_imports::BigUint::from(amount), + ) + .run(); + } + + fn check_deposit(&mut self, donor: TestAddress, amount: u64) { + self.world + .query() + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .deposit(donor) + .returns(ExpectValue(amount)) + .run(); + } + + fn check_status(&mut self, expected_value: crowdfunding_proxy::Status) { + self.world + .query() + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .status() + .returns(ExpectValue(expected_value)) + .run(); + } + + fn claim(&mut self, address: TestAddress) { + self.world + .tx() + .from(address) + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .claim() + .run(); + } + + fn check_esdt_balance(&mut self, address: TestAddress, balance: u64) { + self.world + .check_account(address) + .esdt_balance(CF_TOKEN_ID, balance); + } + + fn set_block_timestamp(&mut self, block_timestamp: TimestampMillis) { + self.world + .current_block() + .block_timestamp_millis(block_timestamp); + } +} + +#[test] +fn test_fund() { + let mut state = CrowdfundingTestState::new(); + state.deploy(); + + state.fund(FIRST_USER_ADDRESS, 1_000u64); + state.check_deposit(FIRST_USER_ADDRESS, 1_000u64); +} + +#[test] +fn test_status() { + let mut state = CrowdfundingTestState::new(); + state.deploy(); + + state.check_status(crowdfunding_proxy::Status::FundingPeriod); +} + +#[test] +fn test_sc_error() { + let mut state = CrowdfundingTestState::new(); + state.deploy(); + + state + .world + .tx() + .from(FIRST_USER_ADDRESS) + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .fund() + .egld(1000) + .with_result(ExpectError(4, "wrong token")) + .run(); + + state.check_deposit(FIRST_USER_ADDRESS, 0); +} + +#[test] +fn test_successful_cf() { + let mut state = CrowdfundingTestState::new(); + state.deploy(); + + // first user fund + state.fund(FIRST_USER_ADDRESS, 1_000u64); + state.check_deposit(FIRST_USER_ADDRESS, 1_000); + + // second user fund + state.fund(SECOND_USER_ADDRESS, 1000); + state.check_deposit(SECOND_USER_ADDRESS, 1_000); + + // set block timestamp after deadline + state.set_block_timestamp(CF_DEADLINE + DurationMillis::new(1)); + + // check status successful + state.check_status(crowdfunding_proxy::Status::Successful); + + state + .world + .tx() + .from(FIRST_USER_ADDRESS) + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .claim() + .with_result(ExpectError(4, "only owner can claim successful funding")) + .run(); + + // owner claim + state.claim(OWNER_ADDRESS); + + state.check_esdt_balance(OWNER_ADDRESS, 2000); + state.check_esdt_balance(FIRST_USER_ADDRESS, 0); + state.check_esdt_balance(SECOND_USER_ADDRESS, 0); +} + +#[test] +fn test_failed_cf() { + let mut state = CrowdfundingTestState::new(); + state.deploy(); + + // first user fund + state.fund(FIRST_USER_ADDRESS, 300); + state.check_deposit(FIRST_USER_ADDRESS, 300u64); + + // second user fund + state.fund(SECOND_USER_ADDRESS, 600); + state.check_deposit(SECOND_USER_ADDRESS, 600u64); + + // set block timestamp after deadline + state.set_block_timestamp(CF_DEADLINE + DurationMillis::new(1)); + + // check status failed + state.check_status(crowdfunding_proxy::Status::Failed); + + // first user claim + state.claim(FIRST_USER_ADDRESS); + + // second user claim + state.claim(SECOND_USER_ADDRESS); + + state.check_esdt_balance(OWNER_ADDRESS, 0); + state.check_esdt_balance(FIRST_USER_ADDRESS, 1000); + state.check_esdt_balance(SECOND_USER_ADDRESS, 1000); +} diff --git a/testing/extract-tutorial-code/Cargo.toml b/testing/extract-tutorial-code/Cargo.toml index 360ed12e6..9de7e29ae 100644 --- a/testing/extract-tutorial-code/Cargo.toml +++ b/testing/extract-tutorial-code/Cargo.toml @@ -2,12 +2,12 @@ name = "extract-tutorial-code" version = "0.0.0" authors = ["Andrei Marinica "] -edition = "2018" +edition = "2024" +publish = false [[bin]] name = "extract-tutorial-code" path = "src/extract_code.rs" [dependencies] -waltz = "0.4.1" -pulldown-cmark = { version = "0.1", default-features = false } +pulldown-cmark = "0.13" diff --git a/testing/extract-tutorial-code/src/extract_code.rs b/testing/extract-tutorial-code/src/extract_code.rs index df98e4941..7bbc17bef 100644 --- a/testing/extract-tutorial-code/src/extract_code.rs +++ b/testing/extract-tutorial-code/src/extract_code.rs @@ -1,55 +1,61 @@ use std::{fs, fs::File, io::Write, path::Path}; -use waltz::CodeBlock; +mod parser; + +use parser::{CodeBlock, extract_code_blocks_from_markdown}; + +const CROWDFUNDING_TUTORIAL_PATHS: &[&str] = &[ + "../../docs/developers/tutorials/crowdfunding-p1.md", + "../../docs/developers/tutorials/crowdfunding-p2.md", +]; fn extract_code_blocks_from_file>(path: P) -> Vec { let contents = fs::read_to_string(path.as_ref()) .unwrap_or_else(|e| panic!("not found: {} {:?}", e, path.as_ref())); - let markdown = pulldown_cmark::Parser::new(contents.as_str()); - waltz::extract_code_blocks(markdown).unwrap() + + extract_code_blocks_from_markdown(&contents) } fn extract_crowdfunding_tutorial_code_blocks() -> Vec { - let mut code_blocks_1 = - extract_code_blocks_from_file("../../docs/developers/tutorials/crowdfunding-p1.md"); - let code_blocks_2 = - extract_code_blocks_from_file("../../docs/developers/tutorials/crowdfunding-p2.md"); - code_blocks_1.extend(code_blocks_2.into_iter()); - code_blocks_1 + CROWDFUNDING_TUTORIAL_PATHS + .iter() + .map(|tutorial_path| extract_code_blocks_from_file(tutorial_path)) + .flatten() + .collect() } -fn write_code_block>(path: P, code_block: &CodeBlock) { +fn write_code_block>(code_block_filename: &str, path: P, code_blocks: &[CodeBlock]) { + let code_block = find_code_block_by_filename(code_blocks, code_block_filename); let mut file = File::create(path.as_ref()) .unwrap_or_else(|e| panic!("could not create file: {} {:?}", e, path.as_ref())); - file.write_all(code_block.content().as_bytes()).unwrap(); + file.write_all(code_block.content.as_bytes()).unwrap(); + println!( + "Successfully extracted {}, language: {}", + path.as_ref().display(), + code_block.language.as_deref().unwrap_or("unknown") + ); +} + +fn find_code_block_by_filename<'a>(code_blocks: &'a [CodeBlock], filename: &str) -> &'a CodeBlock { + code_blocks + .iter() + .find(|block| block.filename.as_deref() == Some(filename)) + .unwrap_or_else(|| panic!("{} code block not found in tutorials", filename)) } fn main() { - fs::create_dir_all("../crowdfunding-esdt/scenarios").unwrap(); - fs::create_dir_all("../crowdfunding-esdt/src").unwrap(); + fs::create_dir_all("../crowdfunding/scenarios").unwrap(); + fs::create_dir_all("../crowdfunding/src").unwrap(); let code_blocks = extract_crowdfunding_tutorial_code_blocks(); - for code_block in &code_blocks { - if let Some(filename) = code_block.filename() { - match filename.as_str() { - "Cargo.toml" => write_code_block("../crowdfunding-esdt/Cargo.toml", code_block), - "final.rs" => { - write_code_block("../crowdfunding-esdt/src/crowdfunding.rs", code_block) - } - "crowdfunding-init.scen.json" => write_code_block( - "../crowdfunding-esdt/scenarios/crowdfunding-init.scen.json", - code_block, - ), - "crowdfunding-fund.scen.json" => write_code_block( - "../crowdfunding-esdt/scenarios/crowdfunding-fund.scen.json", - code_block, - ), - "crowdfunding-fund-too-late.scen.json" => write_code_block( - "../crowdfunding-esdt/scenarios/crowdfunding-fund-too-late.scen.json", - code_block, - ), - _ => {} - } - } - } + + // Find and write Cargo.toml + write_code_block("Cargo.toml", "../crowdfunding/Cargo.toml", &code_blocks); + + // Find and write crowdfunding.rs + write_code_block( + "crowdfunding.rs", + "../crowdfunding/src/crowdfunding.rs", + &code_blocks, + ); } diff --git a/testing/extract-tutorial-code/src/parser.rs b/testing/extract-tutorial-code/src/parser.rs new file mode 100644 index 000000000..0d3804933 --- /dev/null +++ b/testing/extract-tutorial-code/src/parser.rs @@ -0,0 +1,94 @@ +use pulldown_cmark::{Event, Parser, Tag, TagEnd}; + +#[derive(Debug, Clone)] +pub struct CodeBlock { + pub filename: Option, + pub language: Option, + pub content: String, +} + +pub fn extract_code_blocks_from_markdown(markdown_content: &str) -> Vec { + let parser = Parser::new(markdown_content); + let mut code_blocks = Vec::new(); + let mut in_code_block = false; + let mut current_code = String::new(); + let mut current_language = None; + let mut current_filename = None; + + for event in parser { + match event { + Event::Start(Tag::CodeBlock(kind)) => { + in_code_block = true; + current_code.clear(); + + // Extract language and filename from code block info + let info = match kind { + pulldown_cmark::CodeBlockKind::Fenced(info) => info.to_string(), + pulldown_cmark::CodeBlockKind::Indented => String::new(), + }; + + // Parse info string which can contain language and filename + // Format: ```rust title="filename.rs" or ```rust filename="filename.rs" or ```filename.rs + if !info.is_empty() { + parse_code_block_info(&info, &mut current_language, &mut current_filename); + } + } + Event::End(TagEnd::CodeBlock) => { + if in_code_block { + code_blocks.push(CodeBlock { + filename: current_filename.take(), + language: current_language.take(), + content: current_code.clone(), + }); + in_code_block = false; + } + } + Event::Text(text) => { + if in_code_block { + current_code.push_str(&text); + } + } + _ => {} + } + } + + code_blocks +} + +fn parse_code_block_info(info: &str, language: &mut Option, filename: &mut Option) { + let parts: Vec<&str> = info.split_whitespace().collect(); + + if parts.is_empty() { + return; + } + + // First part is typically the language + let first_part = parts[0]; + + // Check if the first part looks like a filename (contains a dot) + if first_part.contains('.') + && !first_part.starts_with("title=") + && !first_part.starts_with("filename=") + { + *filename = Some(first_part.to_string()); + // If it looks like a filename, try to extract language from extension + if let Some(ext) = first_part.split('.').last() { + *language = Some(ext.to_string()); + } + } else { + *language = Some(first_part.to_string()); + } + + // Look for title= or filename= attributes + for part in &parts[1..] { + if let Some(stripped) = part.strip_prefix("title=") { + *filename = Some(strip_quotes(stripped)); + } else if let Some(stripped) = part.strip_prefix("filename=") { + *filename = Some(strip_quotes(stripped)); + } + } +} + +fn strip_quotes(s: &str) -> String { + s.trim_matches('"').trim_matches('\'').to_string() +} From ef66317f5e999a894bf960bc652b645e19abea2e Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Wed, 14 Jan 2026 15:04:52 +0200 Subject: [PATCH 03/21] crowdfunding tutorial section --- docs/developers/overview.md | 4 +-- .../{ => crowdfunding}/crowdfunding-p1.md | 3 ++- .../{ => crowdfunding}/crowdfunding-p2.md | 2 +- docusaurus.config.js | 8 ++++++ sidebars.js | 10 +++++-- .../tests/crowdfunding_scenario_rs_test.rs | 26 ------------------- .../extract-tutorial-code/src/extract_code.rs | 4 +-- {scripts => testing}/rust-tutorial-ci.sh | 8 +++--- 8 files changed, 27 insertions(+), 38 deletions(-) rename docs/developers/tutorials/{ => crowdfunding}/crowdfunding-p1.md (99%) rename docs/developers/tutorials/{ => crowdfunding}/crowdfunding-p2.md (99%) delete mode 100644 testing/crowdfunding-esdt/tests/crowdfunding_scenario_rs_test.rs rename {scripts => testing}/rust-tutorial-ci.sh (66%) diff --git a/docs/developers/overview.md b/docs/developers/overview.md index 96f3da174..1f1001c19 100644 --- a/docs/developers/overview.md +++ b/docs/developers/overview.md @@ -40,8 +40,8 @@ Below is a list of tutorials for building on MultiversX: | [Build your first dApp in 15 minutes](/developers/tutorials/your-first-dapp) | Video + written tutorial on how to create your first dApp. | | [Cryptozombies Tutorials](https://cryptozombies.io/en/multiversx) | Interactive way of learning how to write MultiversX Smart Contracts. | | [Build a microservice for your dApp](/developers/tutorials/your-first-microservice) | Video + written tutorial on how to create your microservice. | -| [Building a Crowdfunding Smart Contract](/docs/developers/tutorials/crowdfunding-p1.md) | Write, build and test a simple smart contract. | -| [Enhancing the Crowdfunding Smart Contract](/docs/developers/tutorials/crowdfunding-p2.md) | Expand and refine the functionality of an existing contract.| +| [Building a Crowdfunding Smart Contract](/docs/developers/tutorials/crowdfunding/crowdfunding-p1.md) | Write, build and test a simple smart contract. | +| [Enhancing the Crowdfunding Smart Contract](/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md) | Expand and refine the functionality of an existing contract.| | [Staking contract Tutorial](/developers/tutorials/staking-contract) | Step by step tutorial on how to create a Staking Smart Contract. | | [Energy DAO Tutorial](/developers/tutorials/energy-dao) | In depth analysis of the Energy DAO SC template. | | [DEX Walkthrough](/developers/tutorials/dex-walkthrough) | In depth walkthrough of all the main DEX contracts. | diff --git a/docs/developers/tutorials/crowdfunding-p1.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p1.md similarity index 99% rename from docs/developers/tutorials/crowdfunding-p1.md rename to docs/developers/tutorials/crowdfunding/crowdfunding-p1.md index d1cfc7f3d..c25e79769 100644 --- a/docs/developers/tutorials/crowdfunding-p1.md +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p1.md @@ -1,7 +1,8 @@ --- id: crowdfunding-p1 -title: Building a Crowdfunding Smart Contract +title: Crowdfunding Smart Contract Setup --- + [comment]: # (mx-abstract) Write, build and deploy a simple smart contract written in Rust. diff --git a/docs/developers/tutorials/crowdfunding-p2.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md similarity index 99% rename from docs/developers/tutorials/crowdfunding-p2.md rename to docs/developers/tutorials/crowdfunding/crowdfunding-p2.md index 4b58bab38..1d9ddfbad 100644 --- a/docs/developers/tutorials/crowdfunding-p2.md +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md @@ -9,7 +9,7 @@ Define contract arguments, handle storage, process payments, define new types, w ## Configuring the contract -[The previous chapter](/docs/developers/tutorials/crowdfunding-p1.md) left us with a minimal contract as a starting point. +[The previous chapter](crowdfunding-p1.md) left us with a minimal contract as a starting point. The first thing we need to do is to configure the desired target amount and the deadline. The deadline will be expressed as the block timestamp after which the contract can no longer be funded. We will be adding 2 more storage fields and arguments to the constructor. diff --git a/docusaurus.config.js b/docusaurus.config.js index e506f8458..4e9fe022b 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -462,6 +462,14 @@ const config = { from: "/developers/meta/rust-stable-vs-nightly", to: "/developers/meta/rust-version", }, + { + from: "/developers/tutorials/crowdfunding-p1", + to: "/developers/tutorials/crowdfunding/crowdfunding-p1", + }, + { + from: "/developers/tutorials/crowdfunding-p2", + to: "/developers/tutorials/crowdfunding/crowdfunding-p2", + }, ], createRedirects(existingPath) { return undefined; // Return a falsy value: no redirect created diff --git a/sidebars.js b/sidebars.js index fcf56a019..cca16f7a3 100644 --- a/sidebars.js +++ b/sidebars.js @@ -52,8 +52,14 @@ const sidebars = { items: [ "developers/tutorials/your-first-dapp", "developers/tutorials/your-first-microservice", - "developers/tutorials/crowdfunding-p1", - "developers/tutorials/crowdfunding-p2", + { + type: "category", + label: "Crowdfunding Tutorial", + items: [ + "developers/tutorials/crowdfunding/crowdfunding-p1", + "developers/tutorials/crowdfunding/crowdfunding-p2", + ], + }, "developers/tutorials/staking-contract", "developers/tutorials/energy-dao", "developers/tutorials/dex-walkthrough", diff --git a/testing/crowdfunding-esdt/tests/crowdfunding_scenario_rs_test.rs b/testing/crowdfunding-esdt/tests/crowdfunding_scenario_rs_test.rs deleted file mode 100644 index 0700cec6b..000000000 --- a/testing/crowdfunding-esdt/tests/crowdfunding_scenario_rs_test.rs +++ /dev/null @@ -1,26 +0,0 @@ -use multiversx_sc_scenario::*; - -fn world() -> ScenarioWorld { - let mut blockchain = ScenarioWorld::new(); - - blockchain.register_contract( - "file:output/crowdfunding.wasm", - crowdfunding::ContractBuilder, - ); - blockchain -} - -#[test] -fn crowdfunding_init_rs() { - multiversx_sc_scenario::run_rs("scenarios/crowdfunding-init.scen.json", world()); -} - -#[test] -fn crowdfunding_fund_rs() { - multiversx_sc_scenario::run_rs("scenarios/crowdfunding-fund.scen.json", world()); -} - -#[test] -fn crowdfunding_fund_too_late_rs() { - multiversx_sc_scenario::run_rs("scenarios/crowdfunding-fund-too-late.scen.json", world()); -} diff --git a/testing/extract-tutorial-code/src/extract_code.rs b/testing/extract-tutorial-code/src/extract_code.rs index 7bbc17bef..8a7515e13 100644 --- a/testing/extract-tutorial-code/src/extract_code.rs +++ b/testing/extract-tutorial-code/src/extract_code.rs @@ -5,8 +5,8 @@ mod parser; use parser::{CodeBlock, extract_code_blocks_from_markdown}; const CROWDFUNDING_TUTORIAL_PATHS: &[&str] = &[ - "../../docs/developers/tutorials/crowdfunding-p1.md", - "../../docs/developers/tutorials/crowdfunding-p2.md", + "../../docs/developers/tutorials/crowdfunding/crowdfunding-p1.md", + "../../docs/developers/tutorials/crowdfunding/crowdfunding-p2.md", ]; fn extract_code_blocks_from_file>(path: P) -> Vec { diff --git a/scripts/rust-tutorial-ci.sh b/testing/rust-tutorial-ci.sh similarity index 66% rename from scripts/rust-tutorial-ci.sh rename to testing/rust-tutorial-ci.sh index aafe121a5..b5f1d65e1 100755 --- a/scripts/rust-tutorial-ci.sh +++ b/testing/rust-tutorial-ci.sh @@ -4,8 +4,8 @@ ## The tests are also taken from the tutorial. ## Tests are only run on the rust backend. -cd testing/extract-tutorial-code -cargo run || return 1 +cd extract-tutorial-code || exit 1 +cargo run || exit 1 -cd ../crowdfunding -cargo test || return 1 +cd ../crowdfunding || exit 1 +cargo test || exit 1 \ No newline at end of file From db3fd67292950cdddfc9c322f2f1caf8fbb4bf04 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Wed, 14 Jan 2026 15:07:52 +0200 Subject: [PATCH 04/21] github action upgrade --- .github/workflows/rust-tutorial-ci.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/rust-tutorial-ci.yml b/.github/workflows/rust-tutorial-ci.yml index 292deac53..b6eaeba1d 100644 --- a/.github/workflows/rust-tutorial-ci.yml +++ b/.github/workflows/rust-tutorial-ci.yml @@ -11,10 +11,9 @@ jobs: name: Rust tutorial code test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions-rs/toolchain@v1 + - uses: actions/checkout@v5 + - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - default: true - toolchain: stable + toolchain: 1.92 - name: Run rust tests - run: ./scripts/rust-tutorial-ci.sh + run: ./testing/rust-tutorial-ci.sh From 83f167b3b8c9ec03ec0cee22c073853f4d860a70 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Wed, 14 Jan 2026 17:30:58 +0200 Subject: [PATCH 05/21] crowdfunding - final code page --- .../tutorials/crowdfunding/crowdfunding-p1.md | 2 +- .../tutorials/crowdfunding/crowdfunding-p2.md | 169 +------ .../tutorials/crowdfunding/final-code.md | 451 ++++++++++++++++++ sidebars.js | 1 + .../extract-tutorial-code/src/extract_code.rs | 8 + 5 files changed, 479 insertions(+), 152 deletions(-) create mode 100644 docs/developers/tutorials/crowdfunding/final-code.md diff --git a/docs/developers/tutorials/crowdfunding/crowdfunding-p1.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p1.md index c25e79769..6a7bcc763 100644 --- a/docs/developers/tutorials/crowdfunding/crowdfunding-p1.md +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p1.md @@ -1,6 +1,6 @@ --- id: crowdfunding-p1 -title: Crowdfunding Smart Contract Setup +title: Setup --- [comment]: # (mx-abstract) diff --git a/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md index 1d9ddfbad..96ad8803f 100644 --- a/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md @@ -1,6 +1,6 @@ --- id: crowdfunding-p2 -title: Enhancing the Crowdfunding Smart Contract +title: Crowdfunding Logic --- [comment]: # (mx-abstract) Define contract arguments, handle storage, process payments, define new types, write better tests @@ -65,7 +65,7 @@ sc-meta all proxy Finally, we update the test: -```rust title=crowdfunding_blackbox_test.rs +```rust #[test] fn crowdfunding_deploy_test() { let mut world = world(); @@ -145,7 +145,7 @@ To test the function, we will add a new test, in the same `crowdfunding_blackbox To avoid duplicate code, we will put all the deployment and account setup logic into a function called `crowdfunding_deploy()`. This function will return a **ScenarioWorld** response, which gives us the **state of the mocked chain** after setting up an account with the OWNER address and deploying the crowdfunding contract. -```rust title=crowdfunding_blackbox_test.rs +```rust fn crowdfunding_deploy() -> ScenarioWorld { let mut world = world(); @@ -169,7 +169,7 @@ fn crowdfunding_deploy() -> ScenarioWorld { Now that we've moved the deployment logic to a separate function, let's update the test that checks the deploy endpoint like this: -```rust title=crowdfunding_blackbox_test.rs +```rust #[test] fn crowdfunding_deploy_test() { let mut world = crowdfunding_deploy(); @@ -194,7 +194,7 @@ fn crowdfunding_deploy_test() { With the code organized, we can now start developing the test for the fund endpoint. -```rust title=crowdfunding_blackbox_test.rs +```rust const DONOR: TestAddress = TestAddress::new("donor"); fn crowdfunding_fund() -> ScenarioWorld { @@ -340,7 +340,7 @@ The [`require!`](/docs/developers/developer-reference/sc-messages.md#require) ma We will create another test to verify that the validation works: `crowdfunding_fund_too_late_test()` . -```rust title=crowdfunding_blackbox_test.rs +```rust #[test] fn crowdfunding_fund_too_late_test() { let mut world = crowdfunding_fund(); @@ -520,155 +520,22 @@ fn claim(&self) { [comment]: # (mx-context-auto) -## The final contract code - -If you followed all the steps presented until now, you should have ended up with a contract that looks something like: - -```rust title=crowdfunding.rs -#![no_std] - -use multiversx_sc::{chain_core::types::TimestampMillis, derive_imports::*, imports::*}; -pub mod crowdfunding_proxy; - -#[type_abi] -#[derive(TopEncode, TopDecode, PartialEq, Eq, Clone, Copy, Debug)] -pub enum Status { - FundingPeriod, - Successful, - Failed, -} - -#[multiversx_sc::contract] -pub trait Crowdfunding { - #[init] - fn init(&self, token_identifier: TokenId, target: BigUint, deadline: TimestampMillis) { - require!(token_identifier.is_valid(), "Invalid token provided"); - self.cf_token_identifier().set(token_identifier); - - require!(target > 0, "Target must be more than 0"); - self.target().set(target); - - require!( - deadline > self.get_current_time_ms(), - "Deadline can't be in the past" - ); - self.deadline().set(deadline); - } - - #[endpoint] - #[payable] - fn fund(&self) { - let payment = self.call_value().single(); - - require!( - payment.token_identifier == self.cf_token_identifier().get(), - "wrong token" - ); - require!(payment.is_fungible(), "only fungible tokens accepted"); - require!( - self.status() == Status::FundingPeriod, - "cannot fund after deadline" - ); - - let caller = self.blockchain().get_caller(); - self.deposit(&caller) - .update(|deposit| *deposit += payment.amount.as_big_uint()); - } - - #[view] - fn status(&self) -> Status { - if self.get_current_time_ms() < self.deadline().get() { - Status::FundingPeriod - } else if self.get_current_funds() >= self.target().get() { - Status::Successful - } else { - Status::Failed - } - } - - #[view(getCurrentFunds)] - #[title("currentFunds")] - fn get_current_funds(&self) -> BigUint { - let token = self.cf_token_identifier().get(); - - self.blockchain().get_sc_balance(&token, 0) - } - - #[endpoint] - fn claim(&self) { - match self.status() { - Status::FundingPeriod => sc_panic!("cannot claim before deadline"), - Status::Successful => { - let caller = self.blockchain().get_caller(); - require!( - caller == self.blockchain().get_owner_address(), - "only owner can claim successful funding" - ); - - let token_identifier = self.cf_token_identifier().get(); - let sc_balance = self.get_current_funds(); - - if let Some(sc_balance_non_zero) = sc_balance.into_non_zero() { - self.tx() - .to(&caller) - .payment(Payment::new(token_identifier, 0, sc_balance_non_zero)) - .transfer(); - } - } - Status::Failed => { - let caller = self.blockchain().get_caller(); - let deposit = self.deposit(&caller).get(); - - if deposit > 0u32 { - let token_identifier = self.cf_token_identifier().get(); - - self.deposit(&caller).clear(); - - if let Some(deposit_non_zero) = deposit.into_non_zero() { - self.tx() - .to(&caller) - .payment(Payment::new(token_identifier, 0, deposit_non_zero)) - .transfer(); - } - } - } - } - } - - // private - - fn get_current_time_ms(&self) -> TimestampMillis { - self.blockchain().get_block_timestamp_millis() - } - - // storage +## Conclusion - #[view(getTarget)] - #[title("target")] - #[storage_mapper("target")] - fn target(&self) -> SingleValueMapper; +Congratulations! You've successfully built a complete crowdfunding smart contract with: - #[view(getDeadline)] - #[title("deadline")] - #[storage_mapper("deadline")] - fn deadline(&self) -> SingleValueMapper; - - #[view(getDeposit)] - #[title("deposit")] - #[storage_mapper("deposit")] - fn deposit(&self, donor: &ManagedAddress) -> SingleValueMapper; - - #[view(getCrowdfundingTokenIdentifier)] - #[title("tokenIdentifier")] - #[storage_mapper("tokenIdentifier")] - fn cf_token_identifier(&self) -> SingleValueMapper; -} -``` +- Token-based funding mechanism +- Time-based campaign management +- Status tracking (FundingPeriod, Successful, Failed) +- Claim functionality for both successful campaigns and refunds +- Comprehensive testing -As an exercise, try to add some more tests, especially ones involving the claim function. +As an exercise, try to add some more tests, especially ones involving the claim function under different scenarios. [comment]: # (mx-context-auto) -## Next steps +## Next Steps -If you want to see some other smart contract examples, or even an extended version of the crowdfunding smart contract, you can check [here](https://github.com/multiversx/mx-contracts-rs). +- **View the complete code**: Check out the [final contract code](final-code.md) with detailed explanations +- **Explore more examples**: Visit the [MultiversX contracts repository](https://github.com/multiversx/mx-contracts-rs) for more smart contract examples and an extended version of the crowdfunding contract +- **Learn more**: Continue with other [tutorials](/developers/tutorials/your-first-dapp) to expand your MultiversX development skills diff --git a/docs/developers/tutorials/crowdfunding/final-code.md b/docs/developers/tutorials/crowdfunding/final-code.md new file mode 100644 index 000000000..b7ac3af7f --- /dev/null +++ b/docs/developers/tutorials/crowdfunding/final-code.md @@ -0,0 +1,451 @@ +--- +id: final-code +title: Final Code +--- + +[comment]: # (mx-abstract) +Complete crowdfunding smart contract implementation with all features. + +This page provides the complete, final version of the crowdfunding smart contract developed throughout the tutorial. This implementation includes all the features covered in [Part 1](crowdfunding-p1.md) and [Part 2](crowdfunding-p2.md). + +[comment]: # (mx-context-auto) + +## Overview + +The final crowdfunding smart contract includes: + +- **Initialization**: Sets up the token identifier, target amount, and deadline +- **Fund endpoint**: Accepts token payments from donors during the funding period +- **Claim endpoint**: Allows the owner to claim funds if successful, or donors to get refunds if failed +- **Status view**: Returns the current campaign status (FundingPeriod, Successful, or Failed) +- **Storage**: Tracks target amount, deadline, deposits per donor, and token identifier + +[comment]: # (mx-context-auto) + +## Contract Features + +### Status Enum + +The contract uses a custom `Status` enum to represent the three possible states of a crowdfunding campaign: + +- **FundingPeriod**: The campaign is still accepting donations (before the deadline) +- **Successful**: The deadline has passed and the target amount was reached +- **Failed**: The deadline has passed but the target amount was not reached + +### Key Methods + +- **`init`**: Initializes the contract with a token identifier, target amount, and deadline. Includes validation to ensure the token is valid, the target is greater than zero, and the deadline is in the future. + +- **`fund`**: Allows users to contribute tokens to the campaign. Validates that the correct token is being sent, that only fungible tokens are accepted, and that the funding period is still active. + +- **`claim`**: Handles the claiming logic based on the campaign status: + - During the funding period: Returns an error + - If successful: Allows only the owner to claim all collected funds + - If failed: Allows donors to claim their individual refunds + +- **`status`**: A view function that returns the current status of the campaign based on the deadline and funds raised. + +- **`get_current_funds`**: Returns the total amount of tokens currently held by the contract. + +[comment]: # (mx-context-auto) + +## Complete Contract Code + +```rust title=crowdfunding.rs +#![no_std] + +use multiversx_sc::{chain_core::types::TimestampMillis, derive_imports::*, imports::*}; +pub mod crowdfunding_proxy; + +#[type_abi] +#[derive(TopEncode, TopDecode, PartialEq, Eq, Clone, Copy, Debug)] +pub enum Status { + FundingPeriod, + Successful, + Failed, +} + +#[multiversx_sc::contract] +pub trait Crowdfunding { + #[init] + fn init(&self, token_identifier: TokenId, target: BigUint, deadline: TimestampMillis) { + require!(token_identifier.is_valid(), "Invalid token provided"); + self.cf_token_identifier().set(token_identifier); + + require!(target > 0, "Target must be more than 0"); + self.target().set(target); + + require!( + deadline > self.get_current_time_ms(), + "Deadline can't be in the past" + ); + self.deadline().set(deadline); + } + + #[endpoint] + #[payable] + fn fund(&self) { + let payment = self.call_value().single(); + + require!( + payment.token_identifier == self.cf_token_identifier().get(), + "wrong token" + ); + require!(payment.is_fungible(), "only fungible tokens accepted"); + require!( + self.status() == Status::FundingPeriod, + "cannot fund after deadline" + ); + + let caller = self.blockchain().get_caller(); + self.deposit(&caller) + .update(|deposit| *deposit += payment.amount.as_big_uint()); + } + + #[view] + fn status(&self) -> Status { + if self.get_current_time_ms() < self.deadline().get() { + Status::FundingPeriod + } else if self.get_current_funds() >= self.target().get() { + Status::Successful + } else { + Status::Failed + } + } + + #[view(getCurrentFunds)] + #[title("currentFunds")] + fn get_current_funds(&self) -> BigUint { + let token = self.cf_token_identifier().get(); + + self.blockchain().get_sc_balance(&token, 0) + } + + #[endpoint] + fn claim(&self) { + match self.status() { + Status::FundingPeriod => sc_panic!("cannot claim before deadline"), + Status::Successful => { + let caller = self.blockchain().get_caller(); + require!( + caller == self.blockchain().get_owner_address(), + "only owner can claim successful funding" + ); + + let token_identifier = self.cf_token_identifier().get(); + let sc_balance = self.get_current_funds(); + + if let Some(sc_balance_non_zero) = sc_balance.into_non_zero() { + self.tx() + .to(&caller) + .payment(Payment::new(token_identifier, 0, sc_balance_non_zero)) + .transfer(); + } + } + Status::Failed => { + let caller = self.blockchain().get_caller(); + let deposit = self.deposit(&caller).get(); + + if deposit > 0u32 { + let token_identifier = self.cf_token_identifier().get(); + + self.deposit(&caller).clear(); + + if let Some(deposit_non_zero) = deposit.into_non_zero() { + self.tx() + .to(&caller) + .payment(Payment::new(token_identifier, 0, deposit_non_zero)) + .transfer(); + } + } + } + } + } + + // private + + fn get_current_time_ms(&self) -> TimestampMillis { + self.blockchain().get_block_timestamp_millis() + } + + // storage + + #[view(getTarget)] + #[title("target")] + #[storage_mapper("target")] + fn target(&self) -> SingleValueMapper; + + #[view(getDeadline)] + #[title("deadline")] + #[storage_mapper("deadline")] + fn deadline(&self) -> SingleValueMapper; + + #[view(getDeposit)] + #[title("deposit")] + #[storage_mapper("deposit")] + fn deposit(&self, donor: &ManagedAddress) -> SingleValueMapper; + + #[view(getCrowdfundingTokenIdentifier)] + #[title("tokenIdentifier")] + #[storage_mapper("tokenIdentifier")] + fn cf_token_identifier(&self) -> SingleValueMapper; +} +``` + +[comment]: # (mx-context-auto) + +## Complete blackbox test + +```rust title=crowdfunding_blackbox_test.rs +use crowdfunding::crowdfunding_proxy; + +use multiversx_sc_scenario::imports::*; + +const CF_DEADLINE: TimestampMillis = TimestampMillis::new(7 * 24 * 60 * 60 * 1000); // 1 week in milliseconds +const CF_TOKEN_ID: TestTokenIdentifier = TestTokenIdentifier::new("CROWD-123456"); +const FIRST_USER_ADDRESS: TestAddress = TestAddress::new("first-user"); +const OWNER_ADDRESS: TestAddress = TestAddress::new("owner"); +const SECOND_USER_ADDRESS: TestAddress = TestAddress::new("second-user"); +const CODE_PATH: MxscPath = MxscPath::new("output/crowdfunding.mxsc.json"); +const CROWDFUNDING_ADDRESS: TestSCAddress = TestSCAddress::new("crowdfunding-sc"); + +fn world() -> ScenarioWorld { + let mut blockchain = ScenarioWorld::new(); + + blockchain.set_current_dir_from_workspace("contracts/examples/crowdfunding"); + blockchain.register_contract(CODE_PATH, crowdfunding::ContractBuilder); + blockchain +} + +struct CrowdfundingTestState { + world: ScenarioWorld, +} + +impl CrowdfundingTestState { + fn new() -> Self { + let mut world = world(); + + world.account(OWNER_ADDRESS).nonce(1); + + world + .account(FIRST_USER_ADDRESS) + .nonce(1) + .balance(1000) + .esdt_balance(CF_TOKEN_ID, 1000); + + world + .account(SECOND_USER_ADDRESS) + .nonce(1) + .esdt_balance(CF_TOKEN_ID, 1000); + + Self { world } + } + + fn deploy(&mut self) { + self.world + .tx() + .from(OWNER_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .init(CF_TOKEN_ID, 2_000u32, CF_DEADLINE) + .code(CODE_PATH) + .new_address(CROWDFUNDING_ADDRESS) + .run(); + } + + fn fund(&mut self, address: TestAddress, amount: u64) { + self.world + .tx() + .from(address) + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .fund() + .egld_or_single_esdt( + &EgldOrEsdtTokenIdentifier::esdt(CF_TOKEN_ID), + 0u64, + &multiversx_sc::proxy_imports::BigUint::from(amount), + ) + .run(); + } + + fn check_deposit(&mut self, donor: TestAddress, amount: u64) { + self.world + .query() + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .deposit(donor) + .returns(ExpectValue(amount)) + .run(); + } + + fn check_status(&mut self, expected_value: crowdfunding_proxy::Status) { + self.world + .query() + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .status() + .returns(ExpectValue(expected_value)) + .run(); + } + + fn claim(&mut self, address: TestAddress) { + self.world + .tx() + .from(address) + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .claim() + .run(); + } + + fn check_esdt_balance(&mut self, address: TestAddress, balance: u64) { + self.world + .check_account(address) + .esdt_balance(CF_TOKEN_ID, balance); + } + + fn set_block_timestamp(&mut self, block_timestamp: TimestampMillis) { + self.world + .current_block() + .block_timestamp_millis(block_timestamp); + } +} + +#[test] +fn test_fund() { + let mut state = CrowdfundingTestState::new(); + state.deploy(); + + state.fund(FIRST_USER_ADDRESS, 1_000u64); + state.check_deposit(FIRST_USER_ADDRESS, 1_000u64); +} + +#[test] +fn test_status() { + let mut state = CrowdfundingTestState::new(); + state.deploy(); + + state.check_status(crowdfunding_proxy::Status::FundingPeriod); +} + +#[test] +fn test_sc_error() { + let mut state = CrowdfundingTestState::new(); + state.deploy(); + + state + .world + .tx() + .from(FIRST_USER_ADDRESS) + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .fund() + .egld(1000) + .with_result(ExpectError(4, "wrong token")) + .run(); + + state.check_deposit(FIRST_USER_ADDRESS, 0); +} + +#[test] +fn test_successful_cf() { + let mut state = CrowdfundingTestState::new(); + state.deploy(); + + // first user fund + state.fund(FIRST_USER_ADDRESS, 1_000u64); + state.check_deposit(FIRST_USER_ADDRESS, 1_000); + + // second user fund + state.fund(SECOND_USER_ADDRESS, 1000); + state.check_deposit(SECOND_USER_ADDRESS, 1_000); + + // set block timestamp after deadline + state.set_block_timestamp(CF_DEADLINE + DurationMillis::new(1)); + + // check status successful + state.check_status(crowdfunding_proxy::Status::Successful); + + state + .world + .tx() + .from(FIRST_USER_ADDRESS) + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .claim() + .with_result(ExpectError(4, "only owner can claim successful funding")) + .run(); + + // owner claim + state.claim(OWNER_ADDRESS); + + state.check_esdt_balance(OWNER_ADDRESS, 2000); + state.check_esdt_balance(FIRST_USER_ADDRESS, 0); + state.check_esdt_balance(SECOND_USER_ADDRESS, 0); +} + +#[test] +fn test_failed_cf() { + let mut state = CrowdfundingTestState::new(); + state.deploy(); + + // first user fund + state.fund(FIRST_USER_ADDRESS, 300); + state.check_deposit(FIRST_USER_ADDRESS, 300u64); + + // second user fund + state.fund(SECOND_USER_ADDRESS, 600); + state.check_deposit(SECOND_USER_ADDRESS, 600u64); + + // set block timestamp after deadline + state.set_block_timestamp(CF_DEADLINE + DurationMillis::new(1)); + + // check status failed + state.check_status(crowdfunding_proxy::Status::Failed); + + // first user claim + state.claim(FIRST_USER_ADDRESS); + + // second user claim + state.claim(SECOND_USER_ADDRESS); + + state.check_esdt_balance(OWNER_ADDRESS, 0); + state.check_esdt_balance(FIRST_USER_ADDRESS, 1000); + state.check_esdt_balance(SECOND_USER_ADDRESS, 1000); +} +``` + +[comment]: # (mx-context-auto) + +## Storage Mappers + +The contract uses several storage mappers to persist data on the blockchain: + +- **`target`**: Stores the target amount of tokens to be raised (BigUint) +- **`deadline`**: Stores the campaign deadline as a timestamp in milliseconds (TimestampMillis) +- **`deposit`**: Maps each donor's address to their contribution amount (BigUint) +- **`cf_token_identifier`**: Stores the token identifier used for the crowdfunding campaign (TokenId) + +Each storage mapper is also exposed as a view function, allowing external queries to read these values. + +[comment]: # (mx-context-auto) + +## Next Steps + +Now that you have the complete crowdfunding contract: + +1. **Add more tests**: Try to write comprehensive tests covering all edge cases, especially for the `claim` function +2. **Extend the functionality**: Consider adding features like: + - Multiple funding rounds + - Partial withdrawals + - Campaign updates or extensions + - Reward tiers for different contribution levels +3. **Explore other contracts**: Check out more smart contract examples in the [MultiversX contracts repository](https://github.com/multiversx/mx-contracts-rs) + +[comment]: # (mx-context-auto) + +## Related Documentation + +- [Part 1: Crowdfunding Smart Contract Setup](crowdfunding-p1.md) +- [Part 2: Enhancing the Crowdfunding Smart Contract](crowdfunding-p2.md) +- [Smart Contract Developer Reference](/developers/developer-reference/sc-annotations) +- [Testing Smart Contracts](/developers/testing/testing-overview) diff --git a/sidebars.js b/sidebars.js index cca16f7a3..d568fa2f2 100644 --- a/sidebars.js +++ b/sidebars.js @@ -58,6 +58,7 @@ const sidebars = { items: [ "developers/tutorials/crowdfunding/crowdfunding-p1", "developers/tutorials/crowdfunding/crowdfunding-p2", + "developers/tutorials/crowdfunding/final-code", ], }, "developers/tutorials/staking-contract", diff --git a/testing/extract-tutorial-code/src/extract_code.rs b/testing/extract-tutorial-code/src/extract_code.rs index 8a7515e13..246a65c41 100644 --- a/testing/extract-tutorial-code/src/extract_code.rs +++ b/testing/extract-tutorial-code/src/extract_code.rs @@ -7,6 +7,7 @@ use parser::{CodeBlock, extract_code_blocks_from_markdown}; const CROWDFUNDING_TUTORIAL_PATHS: &[&str] = &[ "../../docs/developers/tutorials/crowdfunding/crowdfunding-p1.md", "../../docs/developers/tutorials/crowdfunding/crowdfunding-p2.md", + "../../docs/developers/tutorials/crowdfunding/final-code.md", ]; fn extract_code_blocks_from_file>(path: P) -> Vec { @@ -58,4 +59,11 @@ fn main() { "../crowdfunding/src/crowdfunding.rs", &code_blocks, ); + + // Find and write crowdfunding.rs + write_code_block( + "crowdfunding_blackbox_test.rs", + "../crowdfunding/tests/crowdfunding_blackbox_test.rs", + &code_blocks, + ); } From d932925961dce91961c765c1ca1e6c5bd03bd04e Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Thu, 15 Jan 2026 12:02:49 +0200 Subject: [PATCH 06/21] crowdfunding test cleanup --- testing/crowdfunding/.gitignore | 1 + testing/crowdfunding/src/crowdfunding.rs | 6 +- .../tests/crowdfunding_blackbox_test.rs | 215 ------------------ 3 files changed, 4 insertions(+), 218 deletions(-) delete mode 100644 testing/crowdfunding/tests/crowdfunding_blackbox_test.rs diff --git a/testing/crowdfunding/.gitignore b/testing/crowdfunding/.gitignore index 8ad202325..dc12637de 100644 --- a/testing/crowdfunding/.gitignore +++ b/testing/crowdfunding/.gitignore @@ -1,6 +1,7 @@ # Generated by `extract-tutorial-code` /scenarios /src/* +/tests/* wasm output target diff --git a/testing/crowdfunding/src/crowdfunding.rs b/testing/crowdfunding/src/crowdfunding.rs index 43fee2374..1484172ac 100644 --- a/testing/crowdfunding/src/crowdfunding.rs +++ b/testing/crowdfunding/src/crowdfunding.rs @@ -22,7 +22,7 @@ pub trait Crowdfunding { self.target().set(target); require!( - deadline > self.get_current_time_ms(), + deadline > self.get_current_time_millis(), "Deadline can't be in the past" ); self.deadline().set(deadline); @@ -50,7 +50,7 @@ pub trait Crowdfunding { #[view] fn status(&self) -> Status { - if self.get_current_time_ms() < self.deadline().get() { + if self.get_current_time_millis() < self.deadline().get() { Status::FundingPeriod } else if self.get_current_funds() >= self.target().get() { Status::Successful @@ -110,7 +110,7 @@ pub trait Crowdfunding { // private - fn get_current_time_ms(&self) -> TimestampMillis { + fn get_current_time_millis(&self) -> TimestampMillis { self.blockchain().get_block_timestamp_millis() } diff --git a/testing/crowdfunding/tests/crowdfunding_blackbox_test.rs b/testing/crowdfunding/tests/crowdfunding_blackbox_test.rs deleted file mode 100644 index 141402ede..000000000 --- a/testing/crowdfunding/tests/crowdfunding_blackbox_test.rs +++ /dev/null @@ -1,215 +0,0 @@ -use crowdfunding::crowdfunding_proxy; - -use multiversx_sc_scenario::imports::*; - -const CF_DEADLINE: TimestampMillis = TimestampMillis::new(7 * 24 * 60 * 60 * 1000); // 1 week in milliseconds -const CF_TOKEN_ID: TestTokenIdentifier = TestTokenIdentifier::new("CROWD-123456"); -const FIRST_USER_ADDRESS: TestAddress = TestAddress::new("first-user"); -const OWNER_ADDRESS: TestAddress = TestAddress::new("owner"); -const SECOND_USER_ADDRESS: TestAddress = TestAddress::new("second-user"); -const CODE_PATH: MxscPath = MxscPath::new("output/crowdfunding.mxsc.json"); -const CROWDFUNDING_ADDRESS: TestSCAddress = TestSCAddress::new("crowdfunding-sc"); - -fn world() -> ScenarioWorld { - let mut blockchain = ScenarioWorld::new(); - - blockchain.set_current_dir_from_workspace("contracts/examples/crowdfunding"); - blockchain.register_contract(CODE_PATH, crowdfunding::ContractBuilder); - blockchain -} - -struct CrowdfundingTestState { - world: ScenarioWorld, -} - -impl CrowdfundingTestState { - fn new() -> Self { - let mut world = world(); - - world.account(OWNER_ADDRESS).nonce(1); - - world - .account(FIRST_USER_ADDRESS) - .nonce(1) - .balance(1000) - .esdt_balance(CF_TOKEN_ID, 1000); - - world - .account(SECOND_USER_ADDRESS) - .nonce(1) - .esdt_balance(CF_TOKEN_ID, 1000); - - Self { world } - } - - fn deploy(&mut self) { - self.world - .tx() - .from(OWNER_ADDRESS) - .typed(crowdfunding_proxy::CrowdfundingProxy) - .init(CF_TOKEN_ID, 2_000u32, CF_DEADLINE) - .code(CODE_PATH) - .new_address(CROWDFUNDING_ADDRESS) - .run(); - } - - fn fund(&mut self, address: TestAddress, amount: u64) { - self.world - .tx() - .from(address) - .to(CROWDFUNDING_ADDRESS) - .typed(crowdfunding_proxy::CrowdfundingProxy) - .fund() - .egld_or_single_esdt( - &EgldOrEsdtTokenIdentifier::esdt(CF_TOKEN_ID), - 0u64, - &multiversx_sc::proxy_imports::BigUint::from(amount), - ) - .run(); - } - - fn check_deposit(&mut self, donor: TestAddress, amount: u64) { - self.world - .query() - .to(CROWDFUNDING_ADDRESS) - .typed(crowdfunding_proxy::CrowdfundingProxy) - .deposit(donor) - .returns(ExpectValue(amount)) - .run(); - } - - fn check_status(&mut self, expected_value: crowdfunding_proxy::Status) { - self.world - .query() - .to(CROWDFUNDING_ADDRESS) - .typed(crowdfunding_proxy::CrowdfundingProxy) - .status() - .returns(ExpectValue(expected_value)) - .run(); - } - - fn claim(&mut self, address: TestAddress) { - self.world - .tx() - .from(address) - .to(CROWDFUNDING_ADDRESS) - .typed(crowdfunding_proxy::CrowdfundingProxy) - .claim() - .run(); - } - - fn check_esdt_balance(&mut self, address: TestAddress, balance: u64) { - self.world - .check_account(address) - .esdt_balance(CF_TOKEN_ID, balance); - } - - fn set_block_timestamp(&mut self, block_timestamp: TimestampMillis) { - self.world - .current_block() - .block_timestamp_millis(block_timestamp); - } -} - -#[test] -fn test_fund() { - let mut state = CrowdfundingTestState::new(); - state.deploy(); - - state.fund(FIRST_USER_ADDRESS, 1_000u64); - state.check_deposit(FIRST_USER_ADDRESS, 1_000u64); -} - -#[test] -fn test_status() { - let mut state = CrowdfundingTestState::new(); - state.deploy(); - - state.check_status(crowdfunding_proxy::Status::FundingPeriod); -} - -#[test] -fn test_sc_error() { - let mut state = CrowdfundingTestState::new(); - state.deploy(); - - state - .world - .tx() - .from(FIRST_USER_ADDRESS) - .to(CROWDFUNDING_ADDRESS) - .typed(crowdfunding_proxy::CrowdfundingProxy) - .fund() - .egld(1000) - .with_result(ExpectError(4, "wrong token")) - .run(); - - state.check_deposit(FIRST_USER_ADDRESS, 0); -} - -#[test] -fn test_successful_cf() { - let mut state = CrowdfundingTestState::new(); - state.deploy(); - - // first user fund - state.fund(FIRST_USER_ADDRESS, 1_000u64); - state.check_deposit(FIRST_USER_ADDRESS, 1_000); - - // second user fund - state.fund(SECOND_USER_ADDRESS, 1000); - state.check_deposit(SECOND_USER_ADDRESS, 1_000); - - // set block timestamp after deadline - state.set_block_timestamp(CF_DEADLINE + DurationMillis::new(1)); - - // check status successful - state.check_status(crowdfunding_proxy::Status::Successful); - - state - .world - .tx() - .from(FIRST_USER_ADDRESS) - .to(CROWDFUNDING_ADDRESS) - .typed(crowdfunding_proxy::CrowdfundingProxy) - .claim() - .with_result(ExpectError(4, "only owner can claim successful funding")) - .run(); - - // owner claim - state.claim(OWNER_ADDRESS); - - state.check_esdt_balance(OWNER_ADDRESS, 2000); - state.check_esdt_balance(FIRST_USER_ADDRESS, 0); - state.check_esdt_balance(SECOND_USER_ADDRESS, 0); -} - -#[test] -fn test_failed_cf() { - let mut state = CrowdfundingTestState::new(); - state.deploy(); - - // first user fund - state.fund(FIRST_USER_ADDRESS, 300); - state.check_deposit(FIRST_USER_ADDRESS, 300u64); - - // second user fund - state.fund(SECOND_USER_ADDRESS, 600); - state.check_deposit(SECOND_USER_ADDRESS, 600u64); - - // set block timestamp after deadline - state.set_block_timestamp(CF_DEADLINE + DurationMillis::new(1)); - - // check status failed - state.check_status(crowdfunding_proxy::Status::Failed); - - // first user claim - state.claim(FIRST_USER_ADDRESS); - - // second user claim - state.claim(SECOND_USER_ADDRESS); - - state.check_esdt_balance(OWNER_ADDRESS, 0); - state.check_esdt_balance(FIRST_USER_ADDRESS, 1000); - state.check_esdt_balance(SECOND_USER_ADDRESS, 1000); -} From 8d23a5d782788cadbfa57ededa60839077117890 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Thu, 15 Jan 2026 13:07:01 +0200 Subject: [PATCH 07/21] crowdfunding - part 3 with general token id --- .../tutorials/crowdfunding/crowdfunding-p1.md | 2 +- .../tutorials/crowdfunding/crowdfunding-p2.md | 82 +++-- .../tutorials/crowdfunding/crowdfunding-p3.md | 317 ++++++++++++++++++ .../tutorials/crowdfunding/final-code.md | 29 +- sidebars.js | 1 + 5 files changed, 394 insertions(+), 37 deletions(-) create mode 100644 docs/developers/tutorials/crowdfunding/crowdfunding-p3.md diff --git a/docs/developers/tutorials/crowdfunding/crowdfunding-p1.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p1.md index 6a7bcc763..98005d76d 100644 --- a/docs/developers/tutorials/crowdfunding/crowdfunding-p1.md +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p1.md @@ -1,6 +1,6 @@ --- id: crowdfunding-p1 -title: Setup +title: Setup & Basics --- [comment]: # (mx-abstract) diff --git a/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md index 96ad8803f..4d1884110 100644 --- a/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md @@ -1,6 +1,6 @@ --- id: crowdfunding-p2 -title: Crowdfunding Logic +title: Core Logic --- [comment]: # (mx-abstract) Define contract arguments, handle storage, process payments, define new types, write better tests @@ -11,7 +11,15 @@ Define contract arguments, handle storage, process payments, define new types, w [The previous chapter](crowdfunding-p1.md) left us with a minimal contract as a starting point. -The first thing we need to do is to configure the desired target amount and the deadline. The deadline will be expressed as the block timestamp after which the contract can no longer be funded. We will be adding 2 more storage fields and arguments to the constructor. +The first thing we need to do is to configure the desired target amount and the deadline. The deadline will be expressed as the block timestamp (in milliseconds) after which the contract can no longer be funded. We will be adding 2 more storage fields and arguments to the constructor. + +For now, we'll hardcode the contract to only accept EGLD. First, let's add the necessary import at the top of the file: + +```rust +use multiversx_sc::imports::*; +``` + +Now let's add the storage mappers and init function: ```rust #[view(getTarget)] @@ -20,25 +28,39 @@ fn target(&self) -> SingleValueMapper; #[view(getDeadline)] #[storage_mapper("deadline")] -fn deadline(&self) -> SingleValueMapper; +fn deadline(&self) -> SingleValueMapper; #[view(getDeposit)] #[storage_mapper("deposit")] fn deposit(&self, donor: &ManagedAddress) -> SingleValueMapper; +fn cf_token_id(&self) -> TokenId { + TokenId::egld() +} + #[init] -fn init(&self, target: BigUint, deadline: u64) { - self.target().set(&target); - self.deadline().set(&deadline); +fn init(&self, target: BigUint, deadline: TimestampMillis) { + require!(target > 0, "Target must be more than 0"); + self.target().set(target); + + require!( + deadline > self.blockchain().get_block_timestamp_millis(), + "Deadline can't be in the past" + ); + self.deadline().set(deadline); } ``` -The deadline being a block timestamp can be expressed as a regular 64-bits unsigned integer. The target, however, being a sum of EGLD cannot. +The `cf_token_id()` method returns a hardcoded EGLD identifier using the `TokenId` type. In a future tutorial, we'll make this configurable to support any token. + +`TimestampMillis` is a type-safe wrapper for millisecond timestamps, providing better type safety than using raw `u64` values. + +The deadline being a block timestamp can be expressed as a 64-bits unsigned integer `TimestampMillis`. The target, however, being a sum of EGLD cannot. :::note 1 EGLD = 1018 EGLD-wei, also known as atto-EGLD. -It is the smallest unit of currency, and all payments are expressed in wei. +It is the smallest unit of currency, and all payments are expressed in wei. The same applies to ESDT tokens, where the smallest unit depends on the token's number of decimals. ::: Even for small payments, the numbers get large. Luckily, the framework offers support for big numbers out of the box. Two types are available: [**BigUint**](/docs/developers/best-practices/biguint-operations.md) and **BigInt**. @@ -115,7 +137,7 @@ sc-meta test ## Funding the contract -It is not enough to receive the funds, the contract also needs to keep track of who donated how much. +It is not enough to receive the funds, the contract also needs to keep track of who donated how much. Additionally, we need to validate that the correct token is being sent. ```rust #[view(getDeposit)] @@ -126,6 +148,7 @@ fn deposit(&self, donor: &ManagedAddress) -> SingleValueMapper; #[payable("EGLD")] fn fund(&self) { let payment = self.call_value().egld(); + let caller = self.blockchain().get_caller(); self.deposit(&caller).update(|deposit| *deposit += &*payment); } @@ -138,7 +161,7 @@ Every time the contract is modified, you need to rebuild it and regenerate the p A few things to unpack: 1. This storage mapper has an extra argument, for an address. This is how we define a map in the storage. The donor argument will become part of the storage key. Any number of such key arguments can be added, but in this case we only need one. The resulting storage key will be a concatenation of the specified base key `"deposit"` and the serialized argument. -2. We encounter the first payable function. By default, any function in a smart contract is not payable, i.e. sending a sum of EGLD to the contract using the function will cause the transaction to be rejected. Payable functions need to be annotated with `#[payable]`. +2. We encounter the first payable function. By default, any function in a smart contract is not payable, i.e. sending EGLD to the contract using the function will cause the transaction to be rejected. Payable functions need to be annotated with `#[payable]`. The `"EGLD"` parameter means the function only accepts EGLD. 3. `fund` needs to also be explicitly declared as an endpoint. All `#[payable]`methods need to be marked `#[endpoint]`, but not the other way around. To test the function, we will add a new test, in the same `crowdfunding_blackbox_test.rs` file. Let's call it `crowdfunding_fund_test()` . @@ -306,12 +329,12 @@ It doesn't make sense to create a funding that has the target 0 or a negative nu ```rust #[init] -fn init(&self, target: BigUint, deadline: u64) { +fn init(&self, target: BigUint, deadline: TimestampMillis) { require!(target > 0, "Target must be more than 0"); self.target().set(target); require!( - deadline > self.get_current_time(), + deadline > self.blockchain().get_block_timestamp_millis(), "Deadline can't be in the past" ); self.deadline().set(deadline); @@ -326,7 +349,7 @@ Additionally, it doesn't make sense to accept funding after the deadline has pas fn fund(&self) { let payment = self.call_value().egld(); - let current_time = self.blockchain().get_block_timestamp(); + let current_time = self.blockchain().get_block_timestamp_millis(); require!(current_time < self.deadline().get(), "cannot fund after deadline"); let caller = self.blockchain().get_caller(); @@ -421,7 +444,7 @@ We can now use the type **Status** just like we use the other types, so we can w ```rust #[view] fn status(&self) -> Status { - if self.blockchain().get_block_timestamp() <= self.deadline().get() { + if self.get_current_time_millis() < self.deadline().get() { Status::FundingPeriod } else if self.get_current_funds() >= self.target().get() { Status::Successful @@ -432,7 +455,8 @@ fn status(&self) -> Status { #[view(getCurrentFunds)] fn get_current_funds(&self) -> BigUint { - self.blockchain().get_sc_balance(&EgldOrEsdtTokenIdentifier::egld(), 0) + let token_id = self.cf_token_id(); + self.blockchain().get_sc_balance(&token_id, 0) } ``` @@ -498,16 +522,30 @@ fn claim(&self) { "only owner can claim successful funding" ); + let token_identifier = self.cf_token_id().get(); let sc_balance = self.get_current_funds(); - self.send().direct_egld(&caller, &sc_balance); + + if let Some(sc_balance_non_zero) = sc_balance.into_non_zero() { + self.tx() + .to(&caller) + .payment(Payment::new(token_identifier, 0, sc_balance_non_zero)) + .transfer(); + } }, Status::Failed => { let caller = self.blockchain().get_caller(); let deposit = self.deposit(&caller).get(); if deposit > 0u32 { + let token_identifier = self.cf_token_id().get(); self.deposit(&caller).clear(); - self.send().direct_egld(&caller, &deposit); + + if let Some(deposit_non_zero) = deposit.into_non_zero() { + self.tx() + .to(&caller) + .payment(Payment::new(token_identifier, 0, deposit_non_zero)) + .transfer(); + } } }, } @@ -516,15 +554,15 @@ fn claim(&self) { [`sc_panic!`](/docs/developers/developer-reference/sc-messages.md) has the same functionality as [`panic!`](https://doc.rust-lang.org/std/macro.panic.html) from Rust, with the difference that it works in a no_std environment. -`self.send().direct_egld()` forwards EGLD from the contract to the given address. +We use the modern [transaction syntax](/developers/transactions/tx-overview) with `.tx()` to send tokens. We convert amounts to `NonZeroUsize` to ensure we only transfer when there's actually something to send, preventing unnecessary transactions with zero amounts. [comment]: # (mx-context-auto) ## Conclusion -Congratulations! You've successfully built a complete crowdfunding smart contract with: +Congratulations! You've successfully built a crowdfunding smart contract with: -- Token-based funding mechanism +- EGLD-based funding mechanism - Time-based campaign management - Status tracking (FundingPeriod, Successful, Failed) - Claim functionality for both successful campaigns and refunds @@ -536,6 +574,6 @@ As an exercise, try to add some more tests, especially ones involving the claim ## Next Steps +- **Part 3**: In the [next chapter](crowdfunding-p3.md), we'll generalize the contract to accept any fungible token, not just EGLD - **View the complete code**: Check out the [final contract code](final-code.md) with detailed explanations -- **Explore more examples**: Visit the [MultiversX contracts repository](https://github.com/multiversx/mx-contracts-rs) for more smart contract examples and an extended version of the crowdfunding contract -- **Learn more**: Continue with other [tutorials](/developers/tutorials/your-first-dapp) to expand your MultiversX development skills +- **Explore more examples**: Visit the [MultiversX contracts repository](https://github.com/multiversx/mx-contracts-rs) for more smart contract examples diff --git a/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md new file mode 100644 index 000000000..d79559be0 --- /dev/null +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md @@ -0,0 +1,317 @@ +--- +id: crowdfunding-p3 +title: Supporting Any Fungible Token +--- + +[comment]: # (mx-abstract) +Generalize the crowdfunding contract to accept any fungible token instead of just EGLD. + +[comment]: # (mx-context-auto) + +## Introduction + +In [Part 2](crowdfunding-p2.md), we built a complete crowdfunding contract that accepts EGLD. However, the contract was hardcoded to only work with EGLD. In this chapter, we'll generalize it to accept any fungible token (EGLD or ESDT), specified at deployment time. + +This demonstrates an important principle in smart contract design: making contracts configurable and reusable for different use cases. + +[comment]: # (mx-context-auto) + +## Making the Token Identifier Configurable + +Instead of hardcoding the token identifier in a method, we'll store it as a configuration parameter. Let's update our imports and storage mappers: + +```rust +use multiversx_sc::imports::*; +``` + +Now let's convert the `cf_token_id()` method into a storage mapper and update the `init` function: + +```rust +#[view(getCrowdfundingTokenId)] +#[storage_mapper("tokenIdentifier")] +fn cf_token_id(&self) -> SingleValueMapper; + +#[init] +fn init(&self, token_identifier: TokenId, target: BigUint, deadline: TimestampMillis) { + require!(token_identifier.is_valid(), "Invalid token provided"); + self.cf_token_id().set(token_identifier); + + require!(target > 0, "Target must be more than 0"); + self.target().set(target); + + require!( + deadline > self.get_current_time_millis(), + "Deadline can't be in the past" + ); + self.deadline().set(deadline); +} + +fn get_current_time_millis(&self) -> TimestampMillis { + self.blockchain().get_block_timestamp_millis() +} +``` + +We've made several improvements: + +1. **TokenId type**: Represents any token identifier (EGLD or ESDT), providing type safety +2. **TimestampMillis type**: A type-safe wrapper for millisecond timestamps +3. **Validation**: We validate that the token identifier is valid before storing it +4. **Storage**: The token identifier is now stored and can be queried + +[comment]: # (mx-context-auto) + +## Updating the Deadline Storage + +The deadline storage mapper already uses `TimestampMillis` from Part 2: + +```rust +#[view(getDeadline)] +#[storage_mapper("deadline")] +fn deadline(&self) -> SingleValueMapper; +``` + +[comment]: # (mx-context-auto) + +## Updating the Fund Endpoint + +Now we need to update the `fund` endpoint to accept any token and validate it matches the configured token identifier: + +```rust +#[endpoint] +#[payable] +fn fund(&self) { + let payment = self.call_value().single(); + + require!( + payment.token_identifier == self.cf_token_id().get(), + "wrong token" + ); + require!(payment.is_fungible(), "only fungible tokens accepted"); + require!( + self.status() == Status::FundingPeriod, + "cannot fund after deadline" + ); + + let caller = self.blockchain().get_caller(); + self.deposit(&caller) + .update(|deposit| *deposit += payment.amount.as_big_uint()); +} +``` + +Changes from Part 2: + +1. **`#[payable]`** without a specific token: This accepts any token (we'll validate it ourselves) +2. **`call_value().single()`**: Gets the payment as an `EsdtTokenPayment` structure +3. **Token validation**: We check that the sent token matches our configured token identifier +4. **Fungible check**: We ensure that only fungible tokens (not NFTs) are accepted +5. **Amount extraction**: We use `.as_big_uint()` to get the amount as a `BigUint` + +[comment]: # (mx-context-auto) + +## Updating Get Current Funds + +We need to update `get_current_funds()` to use the stored token identifier: + +```rust +#[view(getCurrentFunds)] +fn get_current_funds(&self) -> BigUint { + let token = self.cf_token_id().get() + self.blockchain().get_sc_balance(&token, 0) +} +``` + +[comment]: # (mx-context-auto) + +## Updating the Status Method + +The status method needs to use the new `get_current_time_millis()` helper: + +```rust +#[view] +fn status(&self) -> Status { + if self.get_current_time_millis() < self.deadline().get() { + Status::FundingPeriod + } else if self.get_current_funds() >= self.target().get() { + Status::Successful + } else { + Status::Failed + } +} +``` + +[comment]: # (mx-context-auto) + +## Updating the Claim Endpoint + +Finally, we need to update the `claim` endpoint to send the configured token instead of hardcoded EGLD: + +```rust +#[endpoint] +fn claim(&self) { + match self.status() { + Status::FundingPeriod => sc_panic!("cannot claim before deadline"), + Status::Successful => { + let caller = self.blockchain().get_caller(); + require!( + caller == self.blockchain().get_owner_address(), + "only owner can claim successful funding" + ); + + let token_identifier = self.cf_token_id().get(); + let sc_balance = self.get_current_funds(); + + if let Some(sc_balance_non_zero) = sc_balance.into_non_zero() { + self.tx() + .to(&caller) + .payment(Payment::new(token_identifier, 0, sc_balance_non_zero)) + .transfer(); + } + } + Status::Failed => { + let caller = self.blockchain().get_caller(); + let deposit = self.deposit(&caller).get(); + + if deposit > 0u32 { + let token_identifier = self.cf_token_id().get(); + + self.deposit(&caller).clear(); + + if let Some(deposit_non_zero) = deposit.into_non_zero() { + self.tx() + .to(&caller) + .payment(Payment::new(token_identifier, 0, deposit_non_zero)) + .transfer(); + } + } + } + } +} +``` + +Key changes: + +1. **Get token identifier**: We retrieve the stored token identifier +2. **New transaction syntax**: We use the `.tx()` builder with `Payment::new()` to send any token type +3. **Non-zero amounts**: We convert amounts to `NonZeroUsize` to ensure we only transfer when there's actually something to send + +[comment]: # (mx-context-auto) + +## Updating Tests + +Let's update our tests to work with the new token-agnostic implementation. First, update the deploy function: + +```rust +fn crowdfunding_deploy() -> ScenarioWorld { + let mut world = world(); + + world.account(OWNER).nonce(0).balance(1000000); + + let crowdfunding_address = world + .tx() + .from(OWNER) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .init(TokenId::egld(), 500_000_000_000u64, 123000u64) + .code(CODE_PATH) + .new_address(CROWDFUNDING_ADDRESS) + .returns(ReturnsNewAddress) + .run(); + + assert_eq!(crowdfunding_address, CROWDFUNDING_ADDRESS.to_address()); + + world +} +``` + +Note that we now pass `TokenId::egld()` as the first argument. This means our test still uses EGLD, but now the contract could work with any token! + +Let's create a new test that uses an ESDT token instead: + +```rust +const CF_TOKEN_ID: TestTokenIdentifier = TestTokenIdentifier::new("CROWD-123456"); + +fn crowdfunding_deploy_esdt() -> ScenarioWorld { + let mut world = world(); + + world.account(OWNER).nonce(0); + + let crowdfunding_address = world + .tx() + .from(OWNER) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .init(TokenId::from(CF_TOKEN_ID), 2_000u64, 604_800_000u64) // 1 week in ms + .code(CODE_PATH) + .new_address(CROWDFUNDING_ADDRESS) + .returns(ReturnsNewAddress) + .run(); + + assert_eq!(crowdfunding_address, CROWDFUNDING_ADDRESS.to_address()); + + world +} + +#[test] +fn crowdfunding_esdt_test() { + let mut world = crowdfunding_deploy_esdt(); + + // Set up donor with ESDT tokens + world + .account(DONOR) + .nonce(0) + .esdt_balance(CF_TOKEN_ID, 1_000u64); + + // Donor funds with ESDT + world + .tx() + .from(DONOR) + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .fund() + .esdt(CF_TOKEN_ID, 0, 500u64) + .run(); + + // Verify deposit + world + .query() + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .deposit(DONOR) + .returns(ExpectValue(500u64)) + .run(); +} +``` + +This test demonstrates that the same contract can now work with ESDT tokens! + +[comment]: # (mx-context-auto) + +## Rebuild and Test + +Don't forget to rebuild and test: + +```bash +sc-meta all build +sc-meta all proxy +sc-meta test +``` + +[comment]: # (mx-context-auto) + +## What We Achieved + +By making these changes, we've: + +1. ✅ Made the contract **configurable** - it can work with any fungible token +2. ✅ Improved **type safety** with `TokenId` and `TimestampMillis` +3. ✅ Added **proper validation** for token types (fungible only) +4. ✅ Used modern **transaction syntax** for transfers +5. ✅ Maintained **backward compatibility** - EGLD still works perfectly + +The contract is now more flexible and can be deployed multiple times with different tokens, making it truly reusable! + +[comment]: # (mx-context-auto) + +## Next Steps + +- **View the complete code**: Check out the [final contract code](final-code.md) with all the improvements +- **Explore more examples**: Visit the [MultiversX contracts repository](https://github.com/multiversx/mx-contracts-rs) for more advanced patterns +- **Continue learning**: Try other [tutorials](/developers/tutorials/your-first-dapp) to expand your MultiversX development skills diff --git a/docs/developers/tutorials/crowdfunding/final-code.md b/docs/developers/tutorials/crowdfunding/final-code.md index b7ac3af7f..3cdd7b7ab 100644 --- a/docs/developers/tutorials/crowdfunding/final-code.md +++ b/docs/developers/tutorials/crowdfunding/final-code.md @@ -6,7 +6,7 @@ title: Final Code [comment]: # (mx-abstract) Complete crowdfunding smart contract implementation with all features. -This page provides the complete, final version of the crowdfunding smart contract developed throughout the tutorial. This implementation includes all the features covered in [Part 1](crowdfunding-p1.md) and [Part 2](crowdfunding-p2.md). +This page provides the complete, final version of the crowdfunding smart contract developed throughout the tutorial. This implementation includes all the features covered in [Part 1](crowdfunding-p1.md), [Part 2](crowdfunding-p2.md), and [Part 3](crowdfunding-p3.md). [comment]: # (mx-context-auto) @@ -54,7 +54,7 @@ The contract uses a custom `Status` enum to represent the three possible states ```rust title=crowdfunding.rs #![no_std] -use multiversx_sc::{chain_core::types::TimestampMillis, derive_imports::*, imports::*}; +use multiversx_sc::{derive_imports::*, imports::*}; pub mod crowdfunding_proxy; #[type_abi] @@ -70,13 +70,13 @@ pub trait Crowdfunding { #[init] fn init(&self, token_identifier: TokenId, target: BigUint, deadline: TimestampMillis) { require!(token_identifier.is_valid(), "Invalid token provided"); - self.cf_token_identifier().set(token_identifier); + self.cf_token_id().set(token_identifier); require!(target > 0, "Target must be more than 0"); self.target().set(target); require!( - deadline > self.get_current_time_ms(), + deadline > self.get_current_time_millis(), "Deadline can't be in the past" ); self.deadline().set(deadline); @@ -88,7 +88,7 @@ pub trait Crowdfunding { let payment = self.call_value().single(); require!( - payment.token_identifier == self.cf_token_identifier().get(), + payment.token_identifier == self.cf_token_id().get(), "wrong token" ); require!(payment.is_fungible(), "only fungible tokens accepted"); @@ -104,7 +104,7 @@ pub trait Crowdfunding { #[view] fn status(&self) -> Status { - if self.get_current_time_ms() < self.deadline().get() { + if self.get_current_time_millis() < self.deadline().get() { Status::FundingPeriod } else if self.get_current_funds() >= self.target().get() { Status::Successful @@ -116,7 +116,7 @@ pub trait Crowdfunding { #[view(getCurrentFunds)] #[title("currentFunds")] fn get_current_funds(&self) -> BigUint { - let token = self.cf_token_identifier().get(); + let token = self.cf_token_id().get(); self.blockchain().get_sc_balance(&token, 0) } @@ -132,7 +132,7 @@ pub trait Crowdfunding { "only owner can claim successful funding" ); - let token_identifier = self.cf_token_identifier().get(); + let token_identifier = self.cf_token_id().get(); let sc_balance = self.get_current_funds(); if let Some(sc_balance_non_zero) = sc_balance.into_non_zero() { @@ -147,7 +147,7 @@ pub trait Crowdfunding { let deposit = self.deposit(&caller).get(); if deposit > 0u32 { - let token_identifier = self.cf_token_identifier().get(); + let token_identifier = self.cf_token_id().get(); self.deposit(&caller).clear(); @@ -164,7 +164,7 @@ pub trait Crowdfunding { // private - fn get_current_time_ms(&self) -> TimestampMillis { + fn get_current_time_millis(&self) -> TimestampMillis { self.blockchain().get_block_timestamp_millis() } @@ -185,10 +185,10 @@ pub trait Crowdfunding { #[storage_mapper("deposit")] fn deposit(&self, donor: &ManagedAddress) -> SingleValueMapper; - #[view(getCrowdfundingTokenIdentifier)] + #[view(getCrowdfundingTokenId)] #[title("tokenIdentifier")] #[storage_mapper("tokenIdentifier")] - fn cf_token_identifier(&self) -> SingleValueMapper; + fn cf_token_id(&self) -> SingleValueMapper; } ``` @@ -423,7 +423,7 @@ The contract uses several storage mappers to persist data on the blockchain: - **`target`**: Stores the target amount of tokens to be raised (BigUint) - **`deadline`**: Stores the campaign deadline as a timestamp in milliseconds (TimestampMillis) - **`deposit`**: Maps each donor's address to their contribution amount (BigUint) -- **`cf_token_identifier`**: Stores the token identifier used for the crowdfunding campaign (TokenId) +- **`cf_token_id`**: Stores the token identifier used for the crowdfunding campaign (TokenId) Each storage mapper is also exposed as a view function, allowing external queries to read these values. @@ -446,6 +446,7 @@ Now that you have the complete crowdfunding contract: ## Related Documentation - [Part 1: Crowdfunding Smart Contract Setup](crowdfunding-p1.md) -- [Part 2: Enhancing the Crowdfunding Smart Contract](crowdfunding-p2.md) +- [Part 2: Crowdfunding Logic](crowdfunding-p2.md) +- [Part 3: Supporting Any Fungible Token](crowdfunding-p3.md) - [Smart Contract Developer Reference](/developers/developer-reference/sc-annotations) - [Testing Smart Contracts](/developers/testing/testing-overview) diff --git a/sidebars.js b/sidebars.js index d568fa2f2..a234e5b74 100644 --- a/sidebars.js +++ b/sidebars.js @@ -58,6 +58,7 @@ const sidebars = { items: [ "developers/tutorials/crowdfunding/crowdfunding-p1", "developers/tutorials/crowdfunding/crowdfunding-p2", + "developers/tutorials/crowdfunding/crowdfunding-p3", "developers/tutorials/crowdfunding/final-code", ], }, From 2fbaaf059558ab712f038eee59aa32bbfb2f8c8d Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Thu, 15 Jan 2026 13:07:17 +0200 Subject: [PATCH 08/21] crowdfunding - p2 vs p3 dedup --- .../tutorials/crowdfunding/crowdfunding-p2.md | 38 ++++++--- .../tutorials/crowdfunding/crowdfunding-p3.md | 83 ++----------------- 2 files changed, 32 insertions(+), 89 deletions(-) diff --git a/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md index 4d1884110..4f525f239 100644 --- a/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md @@ -145,12 +145,17 @@ It is not enough to receive the funds, the contract also needs to keep track of fn deposit(&self, donor: &ManagedAddress) -> SingleValueMapper; #[endpoint] -#[payable("EGLD")] +#[payable] fn fund(&self) { - let payment = self.call_value().egld(); + let payment = self.call_value().single(); + + require!( + payment.token_identifier == self.cf_token_id(), + "wrong token" + ); let caller = self.blockchain().get_caller(); - self.deposit(&caller).update(|deposit| *deposit += &*payment); + self.deposit(&caller).update(|deposit| *deposit += payment.amount.as_big_uint()); } ``` @@ -161,8 +166,9 @@ Every time the contract is modified, you need to rebuild it and regenerate the p A few things to unpack: 1. This storage mapper has an extra argument, for an address. This is how we define a map in the storage. The donor argument will become part of the storage key. Any number of such key arguments can be added, but in this case we only need one. The resulting storage key will be a concatenation of the specified base key `"deposit"` and the serialized argument. -2. We encounter the first payable function. By default, any function in a smart contract is not payable, i.e. sending EGLD to the contract using the function will cause the transaction to be rejected. Payable functions need to be annotated with `#[payable]`. The `"EGLD"` parameter means the function only accepts EGLD. -3. `fund` needs to also be explicitly declared as an endpoint. All `#[payable]`methods need to be marked `#[endpoint]`, but not the other way around. +2. We encounter the first payable function. By default, any function in a smart contract is not payable, i.e. sending EGLD to the contract using the function will cause the transaction to be rejected. Payable functions need to be annotated with `#[payable]`. +3. `call_value().single()` gets the payment as an `EsdtTokenPayment` structure, which we then validate against our hardcoded EGLD token identifier. +4. `fund` needs to also be explicitly declared as an endpoint. All `#[payable]` methods need to be marked `#[endpoint]`, but not the other way around. To test the function, we will add a new test, in the same `crowdfunding_blackbox_test.rs` file. Let's call it `crowdfunding_fund_test()` . @@ -345,15 +351,20 @@ Additionally, it doesn't make sense to accept funding after the deadline has pas ```rust #[endpoint] -#[payable("EGLD")] +#[payable] fn fund(&self) { - let payment = self.call_value().egld(); + let payment = self.call_value().single(); + + require!( + payment.token_identifier == self.cf_token_id(), + "wrong token" + ); let current_time = self.blockchain().get_block_timestamp_millis(); require!(current_time < self.deadline().get(), "cannot fund after deadline"); let caller = self.blockchain().get_caller(); - self.deposit(&caller).update(|deposit| *deposit += &*payment); + self.deposit(&caller).update(|deposit| *deposit += payment.amount.as_big_uint()); } ``` @@ -464,9 +475,14 @@ We will also modify the `require` condition in the `fund` endpoint to ensure tha ```rust #[endpoint] -#[payable("EGLD")] +#[payable] fn fund(&self) { - let payment = self.call_value().egld(); + let payment = self.call_value().single(); + + require!( + payment.token_identifier == self.cf_token_id(), + "wrong token" + ); require!( self.status() == Status::FundingPeriod, @@ -475,7 +491,7 @@ fn fund(&self) { let caller = self.blockchain().get_caller(); self.deposit(&caller) - .update(|deposit| *deposit += &*payment); + .update(|deposit| *deposit += payment.amount.as_big_uint()); } ``` diff --git a/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md index d79559be0..27d3f15b6 100644 --- a/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md @@ -74,37 +74,13 @@ fn deadline(&self) -> SingleValueMapper; ## Updating the Fund Endpoint -Now we need to update the `fund` endpoint to accept any token and validate it matches the configured token identifier: +The `fund` endpoint from Part 2 already validates the token identifier, so we only need to add one more check - we need to ensure that only fungible tokens (not NFTs) are accepted: ```rust -#[endpoint] -#[payable] -fn fund(&self) { - let payment = self.call_value().single(); - - require!( - payment.token_identifier == self.cf_token_id().get(), - "wrong token" - ); - require!(payment.is_fungible(), "only fungible tokens accepted"); - require!( - self.status() == Status::FundingPeriod, - "cannot fund after deadline" - ); - - let caller = self.blockchain().get_caller(); - self.deposit(&caller) - .update(|deposit| *deposit += payment.amount.as_big_uint()); -} +require!(payment.is_fungible(), "only fungible tokens accepted"); ``` -Changes from Part 2: - -1. **`#[payable]`** without a specific token: This accepts any token (we'll validate it ourselves) -2. **`call_value().single()`**: Gets the payment as an `EsdtTokenPayment` structure -3. **Token validation**: We check that the sent token matches our configured token identifier -4. **Fungible check**: We ensure that only fungible tokens (not NFTs) are accepted -5. **Amount extraction**: We use `.as_big_uint()` to get the amount as a `BigUint` +This check should be added after the token identifier validation. The key difference from Part 2 is that `cf_token_id()` now returns the stored token identifier (via `.get()`) instead of a hardcoded value. [comment]: # (mx-context-auto) @@ -141,58 +117,9 @@ fn status(&self) -> Status { [comment]: # (mx-context-auto) -## Updating the Claim Endpoint - -Finally, we need to update the `claim` endpoint to send the configured token instead of hardcoded EGLD: - -```rust -#[endpoint] -fn claim(&self) { - match self.status() { - Status::FundingPeriod => sc_panic!("cannot claim before deadline"), - Status::Successful => { - let caller = self.blockchain().get_caller(); - require!( - caller == self.blockchain().get_owner_address(), - "only owner can claim successful funding" - ); - - let token_identifier = self.cf_token_id().get(); - let sc_balance = self.get_current_funds(); - - if let Some(sc_balance_non_zero) = sc_balance.into_non_zero() { - self.tx() - .to(&caller) - .payment(Payment::new(token_identifier, 0, sc_balance_non_zero)) - .transfer(); - } - } - Status::Failed => { - let caller = self.blockchain().get_caller(); - let deposit = self.deposit(&caller).get(); - - if deposit > 0u32 { - let token_identifier = self.cf_token_id().get(); - - self.deposit(&caller).clear(); - - if let Some(deposit_non_zero) = deposit.into_non_zero() { - self.tx() - .to(&caller) - .payment(Payment::new(token_identifier, 0, deposit_non_zero)) - .transfer(); - } - } - } - } -} -``` - -Key changes: +## The Claim Endpoint -1. **Get token identifier**: We retrieve the stored token identifier -2. **New transaction syntax**: We use the `.tx()` builder with `Payment::new()` to send any token type -3. **Non-zero amounts**: We convert amounts to `NonZeroUsize` to ensure we only transfer when there's actually something to send +The `claim` endpoint from Part 2 already uses the modern transaction syntax with `self.cf_token_id()`, so it automatically works with the configurable token identifier without any changes needed. The only difference is that `cf_token_id()` now returns the stored token via `.get()` instead of the hardcoded EGLD value. [comment]: # (mx-context-auto) From 34c7d73ea7b5025a2153872371e25d51795c1966 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Thu, 15 Jan 2026 17:57:47 +0200 Subject: [PATCH 09/21] crowdfunding test cleanup --- testing/crowdfunding/.gitignore | 2 +- testing/crowdfunding/src/crowdfunding.rs | 138 --------------- .../crowdfunding/src/crowdfunding_proxy.rs | 157 ++++++++++++++++++ 3 files changed, 158 insertions(+), 139 deletions(-) delete mode 100644 testing/crowdfunding/src/crowdfunding.rs create mode 100644 testing/crowdfunding/src/crowdfunding_proxy.rs diff --git a/testing/crowdfunding/.gitignore b/testing/crowdfunding/.gitignore index dc12637de..2152c46ef 100644 --- a/testing/crowdfunding/.gitignore +++ b/testing/crowdfunding/.gitignore @@ -1,6 +1,6 @@ # Generated by `extract-tutorial-code` /scenarios -/src/* +/src/crowdfunding.rs /tests/* wasm output diff --git a/testing/crowdfunding/src/crowdfunding.rs b/testing/crowdfunding/src/crowdfunding.rs deleted file mode 100644 index 1484172ac..000000000 --- a/testing/crowdfunding/src/crowdfunding.rs +++ /dev/null @@ -1,138 +0,0 @@ -#![no_std] - -use multiversx_sc::{chain_core::types::TimestampMillis, derive_imports::*, imports::*}; -pub mod crowdfunding_proxy; - -#[type_abi] -#[derive(TopEncode, TopDecode, PartialEq, Eq, Clone, Copy, Debug)] -pub enum Status { - FundingPeriod, - Successful, - Failed, -} - -#[multiversx_sc::contract] -pub trait Crowdfunding { - #[init] - fn init(&self, token_identifier: TokenId, target: BigUint, deadline: TimestampMillis) { - require!(token_identifier.is_valid(), "Invalid token provided"); - self.cf_token_identifier().set(token_identifier); - - require!(target > 0, "Target must be more than 0"); - self.target().set(target); - - require!( - deadline > self.get_current_time_millis(), - "Deadline can't be in the past" - ); - self.deadline().set(deadline); - } - - #[endpoint] - #[payable] - fn fund(&self) { - let payment = self.call_value().single(); - - require!( - payment.token_identifier == self.cf_token_identifier().get(), - "wrong token" - ); - require!(payment.is_fungible(), "only fungible tokens accepted"); - require!( - self.status() == Status::FundingPeriod, - "cannot fund after deadline" - ); - - let caller = self.blockchain().get_caller(); - self.deposit(&caller) - .update(|deposit| *deposit += payment.amount.as_big_uint()); - } - - #[view] - fn status(&self) -> Status { - if self.get_current_time_millis() < self.deadline().get() { - Status::FundingPeriod - } else if self.get_current_funds() >= self.target().get() { - Status::Successful - } else { - Status::Failed - } - } - - #[view(getCurrentFunds)] - #[title("currentFunds")] - fn get_current_funds(&self) -> BigUint { - let token = self.cf_token_identifier().get(); - - self.blockchain().get_sc_balance(&token, 0) - } - - #[endpoint] - fn claim(&self) { - match self.status() { - Status::FundingPeriod => sc_panic!("cannot claim before deadline"), - Status::Successful => { - let caller = self.blockchain().get_caller(); - require!( - caller == self.blockchain().get_owner_address(), - "only owner can claim successful funding" - ); - - let token_identifier = self.cf_token_identifier().get(); - let sc_balance = self.get_current_funds(); - - if let Some(sc_balance_non_zero) = sc_balance.into_non_zero() { - self.tx() - .to(&caller) - .payment(Payment::new(token_identifier, 0, sc_balance_non_zero)) - .transfer(); - } - } - Status::Failed => { - let caller = self.blockchain().get_caller(); - let deposit = self.deposit(&caller).get(); - - if deposit > 0u32 { - let token_identifier = self.cf_token_identifier().get(); - - self.deposit(&caller).clear(); - - if let Some(deposit_non_zero) = deposit.into_non_zero() { - self.tx() - .to(&caller) - .payment(Payment::new(token_identifier, 0, deposit_non_zero)) - .transfer(); - } - } - } - } - } - - // private - - fn get_current_time_millis(&self) -> TimestampMillis { - self.blockchain().get_block_timestamp_millis() - } - - // storage - - #[view(getTarget)] - #[title("target")] - #[storage_mapper("target")] - fn target(&self) -> SingleValueMapper; - - #[view(getDeadline)] - #[title("deadline")] - #[storage_mapper("deadline")] - fn deadline(&self) -> SingleValueMapper; - - #[view(getDeposit)] - #[title("deposit")] - #[storage_mapper("deposit")] - fn deposit(&self, donor: &ManagedAddress) -> SingleValueMapper; - - #[view(getCrowdfundingTokenIdentifier)] - #[title("tokenIdentifier")] - #[storage_mapper("tokenIdentifier")] - fn cf_token_identifier(&self) -> SingleValueMapper; -} diff --git a/testing/crowdfunding/src/crowdfunding_proxy.rs b/testing/crowdfunding/src/crowdfunding_proxy.rs new file mode 100644 index 000000000..fcbf3bd84 --- /dev/null +++ b/testing/crowdfunding/src/crowdfunding_proxy.rs @@ -0,0 +1,157 @@ +// Code generated by the multiversx-sc proxy generator. DO NOT EDIT. + +//////////////////////////////////////////////////// +////////////////// AUTO-GENERATED ////////////////// +//////////////////////////////////////////////////// + +#![allow(dead_code)] +#![allow(clippy::all)] + +use multiversx_sc::proxy_imports::*; + +pub struct CrowdfundingProxy; + +impl TxProxyTrait for CrowdfundingProxy +where + Env: TxEnv, + From: TxFrom, + To: TxTo, + Gas: TxGas, +{ + type TxProxyMethods = CrowdfundingProxyMethods; + + fn proxy_methods(self, tx: Tx) -> Self::TxProxyMethods { + CrowdfundingProxyMethods { wrapped_tx: tx } + } +} + +pub struct CrowdfundingProxyMethods +where + Env: TxEnv, + From: TxFrom, + To: TxTo, + Gas: TxGas, +{ + wrapped_tx: Tx, +} + +#[rustfmt::skip] +impl CrowdfundingProxyMethods +where + Env: TxEnv, + Env::Api: VMApi, + From: TxFrom, + Gas: TxGas, +{ + pub fn init< + Arg0: ProxyArg>, + Arg1: ProxyArg>, + Arg2: ProxyArg, + >( + self, + token_identifier: Arg0, + target: Arg1, + deadline: Arg2, + ) -> TxTypedDeploy { + self.wrapped_tx + .payment(NotPayable) + .raw_deploy() + .argument(&token_identifier) + .argument(&target) + .argument(&deadline) + .original_result() + } +} + +#[rustfmt::skip] +impl CrowdfundingProxyMethods +where + Env: TxEnv, + Env::Api: VMApi, + From: TxFrom, + To: TxTo, + Gas: TxGas, +{ + pub fn fund( + self, + ) -> TxTypedCall { + self.wrapped_tx + .raw_call("fund") + .original_result() + } + + pub fn status( + self, + ) -> TxTypedCall { + self.wrapped_tx + .payment(NotPayable) + .raw_call("status") + .original_result() + } + + pub fn get_current_funds( + self, + ) -> TxTypedCall> { + self.wrapped_tx + .payment(NotPayable) + .raw_call("getCurrentFunds") + .original_result() + } + + pub fn claim( + self, + ) -> TxTypedCall { + self.wrapped_tx + .payment(NotPayable) + .raw_call("claim") + .original_result() + } + + pub fn target( + self, + ) -> TxTypedCall> { + self.wrapped_tx + .payment(NotPayable) + .raw_call("getTarget") + .original_result() + } + + pub fn deadline( + self, + ) -> TxTypedCall { + self.wrapped_tx + .payment(NotPayable) + .raw_call("getDeadline") + .original_result() + } + + pub fn deposit< + Arg0: ProxyArg>, + >( + self, + donor: Arg0, + ) -> TxTypedCall> { + self.wrapped_tx + .payment(NotPayable) + .raw_call("getDeposit") + .argument(&donor) + .original_result() + } + + pub fn cf_token_identifier( + self, + ) -> TxTypedCall> { + self.wrapped_tx + .payment(NotPayable) + .raw_call("getCrowdfundingTokenIdentifier") + .original_result() + } +} + +#[type_abi] +#[derive(TopEncode, TopDecode, PartialEq, Eq, Clone, Copy, Debug)] +pub enum Status { + FundingPeriod, + Successful, + Failed, +} From ed9070771c2db05423c8939de24b95f91a2ca038 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Thu, 15 Jan 2026 19:49:15 +0200 Subject: [PATCH 10/21] crowdfunding - egld vs esdt tests --- .../tutorials/crowdfunding/crowdfunding-p2.md | 19 +- .../tutorials/crowdfunding/crowdfunding-p3.md | 186 ++++-------- .../tutorials/crowdfunding/final-code.md | 267 +++++++++++++++++- .../extract-tutorial-code/src/extract_code.rs | 11 +- 4 files changed, 326 insertions(+), 157 deletions(-) diff --git a/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md index 4f525f239..4ffcee5ee 100644 --- a/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md @@ -34,12 +34,15 @@ fn deadline(&self) -> SingleValueMapper; #[storage_mapper("deposit")] fn deposit(&self, donor: &ManagedAddress) -> SingleValueMapper; -fn cf_token_id(&self) -> TokenId { - TokenId::egld() -} +#[view(getCrowdfundingTokenId)] +#[storage_mapper("tokenIdentifier")] +fn cf_token_id(&self) -> SingleValueMapper; #[init] fn init(&self, target: BigUint, deadline: TimestampMillis) { + // only support EGLD for now + self.cf_token_id().set(TokenId::egld()); + require!(target > 0, "Target must be more than 0"); self.target().set(target); @@ -51,7 +54,7 @@ fn init(&self, target: BigUint, deadline: TimestampMillis) { } ``` -The `cf_token_id()` method returns a hardcoded EGLD identifier using the `TokenId` type. In a future tutorial, we'll make this configurable to support any token. +The `cf_token_id()` storage mapper will hold the token identifier for our crowdfunding campaign. We initialize it to `TokenId::egld()` in the `init` function, hardcoding it to EGLD for now. In Part 3, we'll make this configurable to support any token. `TimestampMillis` is a type-safe wrapper for millisecond timestamps, providing better type safety than using raw `u64` values. @@ -167,7 +170,7 @@ A few things to unpack: 1. This storage mapper has an extra argument, for an address. This is how we define a map in the storage. The donor argument will become part of the storage key. Any number of such key arguments can be added, but in this case we only need one. The resulting storage key will be a concatenation of the specified base key `"deposit"` and the serialized argument. 2. We encounter the first payable function. By default, any function in a smart contract is not payable, i.e. sending EGLD to the contract using the function will cause the transaction to be rejected. Payable functions need to be annotated with `#[payable]`. -3. `call_value().single()` gets the payment as an `EsdtTokenPayment` structure, which we then validate against our hardcoded EGLD token identifier. +3. `call_value().single()` gets the payment as a `Payment` structure, which we then validate against our stored EGLD token identifier from `cf_token_id()`. 4. `fund` needs to also be explicitly declared as an endpoint. All `#[payable]` methods need to be marked `#[endpoint]`, but not the other way around. To test the function, we will add a new test, in the same `crowdfunding_blackbox_test.rs` file. Let's call it `crowdfunding_fund_test()` . @@ -466,8 +469,8 @@ fn status(&self) -> Status { #[view(getCurrentFunds)] fn get_current_funds(&self) -> BigUint { - let token_id = self.cf_token_id(); - self.blockchain().get_sc_balance(&token_id, 0) + let token = self.cf_token_id().get(); + self.blockchain().get_sc_balance(&token, 0) } ``` @@ -480,7 +483,7 @@ fn fund(&self) { let payment = self.call_value().single(); require!( - payment.token_identifier == self.cf_token_id(), + payment.token_identifier == self.cf_token_id().get(), "wrong token" ); diff --git a/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md index 27d3f15b6..b18f1c74f 100644 --- a/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md @@ -10,7 +10,7 @@ Generalize the crowdfunding contract to accept any fungible token instead of jus ## Introduction -In [Part 2](crowdfunding-p2.md), we built a complete crowdfunding contract that accepts EGLD. However, the contract was hardcoded to only work with EGLD. In this chapter, we'll generalize it to accept any fungible token (EGLD or ESDT), specified at deployment time. +In [Part 2](crowdfunding-p2.md), we built a complete crowdfunding contract that accepts EGLD. The `cf_token_id()` storage mapper was initialized to `TokenId::egld()` in the init function. In this chapter, we'll make it configurable so the contract can accept any fungible token (EGLD or ESDT), specified at deployment time. This demonstrates an important principle in smart contract design: making contracts configurable and reusable for different use cases. @@ -18,19 +18,9 @@ This demonstrates an important principle in smart contract design: making contra ## Making the Token Identifier Configurable -Instead of hardcoding the token identifier in a method, we'll store it as a configuration parameter. Let's update our imports and storage mappers: +The `cf_token_id()` storage mapper already exists from Part 2. We just need to update the `init` function to accept a token identifier as a parameter instead of hardcoding it: ```rust -use multiversx_sc::imports::*; -``` - -Now let's convert the `cf_token_id()` method into a storage mapper and update the `init` function: - -```rust -#[view(getCrowdfundingTokenId)] -#[storage_mapper("tokenIdentifier")] -fn cf_token_id(&self) -> SingleValueMapper; - #[init] fn init(&self, token_identifier: TokenId, target: BigUint, deadline: TimestampMillis) { require!(token_identifier.is_valid(), "Invalid token provided"); @@ -53,161 +43,93 @@ fn get_current_time_millis(&self) -> TimestampMillis { We've made several improvements: -1. **TokenId type**: Represents any token identifier (EGLD or ESDT), providing type safety -2. **TimestampMillis type**: A type-safe wrapper for millisecond timestamps -3. **Validation**: We validate that the token identifier is valid before storing it -4. **Storage**: The token identifier is now stored and can be queried - -[comment]: # (mx-context-auto) - -## Updating the Deadline Storage - -The deadline storage mapper already uses `TimestampMillis` from Part 2: - -```rust -#[view(getDeadline)] -#[storage_mapper("deadline")] -fn deadline(&self) -> SingleValueMapper; -``` +1. **New parameter**: `token_identifier: TokenId` is now the first parameter +2. **Validation**: We validate that the token identifier is valid before storing it +3. **Configuration**: Instead of `self.cf_token_id().set(TokenId::egld())`, we now use the provided parameter [comment]: # (mx-context-auto) ## Updating the Fund Endpoint -The `fund` endpoint from Part 2 already validates the token identifier, so we only need to add one more check - we need to ensure that only fungible tokens (not NFTs) are accepted: +The `fund` endpoint from Part 2 already validates the token identifier against `cf_token_id()`, so it automatically works with any configured token. We only need to add one more check to ensure that only fungible tokens (not NFTs) are accepted: ```rust -require!(payment.is_fungible(), "only fungible tokens accepted"); -``` - -This check should be added after the token identifier validation. The key difference from Part 2 is that `cf_token_id()` now returns the stored token identifier (via `.get()`) instead of a hardcoded value. - -[comment]: # (mx-context-auto) - -## Updating Get Current Funds +#[endpoint] +#[payable] +fn fund(&self) { + let payment = self.call_value().single(); + + require!( + payment.token_identifier == self.cf_token_id().get(), + "wrong token" + ); + require!(payment.is_fungible(), "only fungible tokens accepted"); -We need to update `get_current_funds()` to use the stored token identifier: + require!( + self.status() == Status::FundingPeriod, + "cannot fund after deadline" + ); -```rust -#[view(getCurrentFunds)] -fn get_current_funds(&self) -> BigUint { - let token = self.cf_token_id().get() - self.blockchain().get_sc_balance(&token, 0) + let caller = self.blockchain().get_caller(); + self.deposit(&caller) + .update(|deposit| *deposit += payment.amount.as_big_uint()); } ``` -[comment]: # (mx-context-auto) - -## Updating the Status Method - -The status method needs to use the new `get_current_time_millis()` helper: - -```rust -#[view] -fn status(&self) -> Status { - if self.get_current_time_millis() < self.deadline().get() { - Status::FundingPeriod - } else if self.get_current_funds() >= self.target().get() { - Status::Successful - } else { - Status::Failed - } -} -``` +The only new line is the fungible check. Everything else works automatically because `cf_token_id().get()` now returns the configured token instead of always returning EGLD. [comment]: # (mx-context-auto) -## The Claim Endpoint +## Other Methods -The `claim` endpoint from Part 2 already uses the modern transaction syntax with `self.cf_token_id()`, so it automatically works with the configurable token identifier without any changes needed. The only difference is that `cf_token_id()` now returns the stored token via `.get()` instead of the hardcoded EGLD value. +The `get_current_funds()`, `status()`, and `claim()` methods from Part 2 work automatically with the configurable token because they all use `cf_token_id().get()`, which now returns the configured token instead of hardcoded EGLD. [comment]: # (mx-context-auto) -## Updating Tests +## Testing with Different Tokens -Let's update our tests to work with the new token-agnostic implementation. First, update the deploy function: +Now that our contract is token-agnostic, we can test it with both EGLD and ESDT tokens. The key difference is in the deployment - we pass different token identifiers: +**For EGLD:** ```rust -fn crowdfunding_deploy() -> ScenarioWorld { - let mut world = world(); - - world.account(OWNER).nonce(0).balance(1000000); - - let crowdfunding_address = world - .tx() - .from(OWNER) - .typed(crowdfunding_proxy::CrowdfundingProxy) - .init(TokenId::egld(), 500_000_000_000u64, 123000u64) - .code(CODE_PATH) - .new_address(CROWDFUNDING_ADDRESS) - .returns(ReturnsNewAddress) - .run(); - - assert_eq!(crowdfunding_address, CROWDFUNDING_ADDRESS.to_address()); +.init(TokenId::native(), 2_000u32, CF_DEADLINE) +``` - world -} +**For ESDT:** +```rust +.init(CF_TOKEN_ID, 2_000u32, CF_DEADLINE) ``` -Note that we now pass `TokenId::egld()` as the first argument. This means our test still uses EGLD, but now the contract could work with any token! +The complete test files demonstrate this: -Let's create a new test that uses an ESDT token instead: +### EGLD Test File -```rust -const CF_TOKEN_ID: TestTokenIdentifier = TestTokenIdentifier::new("CROWD-123456"); +
+crowdfunding_egld_blackbox_test.rs (click to expand) -fn crowdfunding_deploy_esdt() -> ScenarioWorld { - let mut world = world(); +```rust file=/Users/andreim/multiversx/mx-docs/testing/crowdfunding/tests/crowdfunding_egld_blackbox_test.rs - world.account(OWNER).nonce(0); +``` +
- let crowdfunding_address = world - .tx() - .from(OWNER) - .typed(crowdfunding_proxy::CrowdfundingProxy) - .init(TokenId::from(CF_TOKEN_ID), 2_000u64, 604_800_000u64) // 1 week in ms - .code(CODE_PATH) - .new_address(CROWDFUNDING_ADDRESS) - .returns(ReturnsNewAddress) - .run(); +### ESDT Test File - assert_eq!(crowdfunding_address, CROWDFUNDING_ADDRESS.to_address()); +
+crowdfunding_esdt_blackbox_test.rs (click to expand) - world -} +```rust file=/Users/andreim/multiversx/mx-docs/testing/crowdfunding/tests/crowdfunding_esdt_blackbox_test.rs -#[test] -fn crowdfunding_esdt_test() { - let mut world = crowdfunding_deploy_esdt(); - - // Set up donor with ESDT tokens - world - .account(DONOR) - .nonce(0) - .esdt_balance(CF_TOKEN_ID, 1_000u64); - - // Donor funds with ESDT - world - .tx() - .from(DONOR) - .to(CROWDFUNDING_ADDRESS) - .typed(crowdfunding_proxy::CrowdfundingProxy) - .fund() - .esdt(CF_TOKEN_ID, 0, 500u64) - .run(); - - // Verify deposit - world - .query() - .to(CROWDFUNDING_ADDRESS) - .typed(crowdfunding_proxy::CrowdfundingProxy) - .deposit(DONOR) - .returns(ExpectValue(500u64)) - .run(); -} ``` +
+ +Key differences in the ESDT test: + +1. **Token setup**: Accounts are given ESDT balances instead of EGLD +2. **Deployment**: Contract is initialized with an ESDT token identifier +3. **Funding**: Uses `.payment(Payment::new(...))` instead of `.egld()` +4. **Balance checks**: Uses `.esdt_balance()` instead of `.balance()` -This test demonstrates that the same contract can now work with ESDT tokens! +Both test files verify the same functionality (successful funding, failed funding, wrong token rejection), proving the contract works identically regardless of the token type! [comment]: # (mx-context-auto) diff --git a/docs/developers/tutorials/crowdfunding/final-code.md b/docs/developers/tutorials/crowdfunding/final-code.md index 3cdd7b7ab..796335717 100644 --- a/docs/developers/tutorials/crowdfunding/final-code.md +++ b/docs/developers/tutorials/crowdfunding/final-code.md @@ -194,21 +194,25 @@ pub trait Crowdfunding { [comment]: # (mx-context-auto) -## Complete blackbox test +## Complete Blackbox Test (ESDT) -```rust title=crowdfunding_blackbox_test.rs +```rust title=crowdfunding_esdt_blackbox_test.rs use crowdfunding::crowdfunding_proxy; use multiversx_sc_scenario::imports::*; const CF_DEADLINE: TimestampMillis = TimestampMillis::new(7 * 24 * 60 * 60 * 1000); // 1 week in milliseconds -const CF_TOKEN_ID: TestTokenIdentifier = TestTokenIdentifier::new("CROWD-123456"); + const FIRST_USER_ADDRESS: TestAddress = TestAddress::new("first-user"); const OWNER_ADDRESS: TestAddress = TestAddress::new("owner"); const SECOND_USER_ADDRESS: TestAddress = TestAddress::new("second-user"); + const CODE_PATH: MxscPath = MxscPath::new("output/crowdfunding.mxsc.json"); const CROWDFUNDING_ADDRESS: TestSCAddress = TestSCAddress::new("crowdfunding-sc"); +const CF_TOKEN_ID: TestTokenIdentifier = TestTokenIdentifier::new("CROWD-123456"); +const OTHER_TOKEN_ID: TestTokenIdentifier = TestTokenIdentifier::new("OTHER-123456"); + fn world() -> ScenarioWorld { let mut blockchain = ScenarioWorld::new(); @@ -231,7 +235,8 @@ impl CrowdfundingTestState { .account(FIRST_USER_ADDRESS) .nonce(1) .balance(1000) - .esdt_balance(CF_TOKEN_ID, 1000); + .esdt_balance(CF_TOKEN_ID, 1000) + .esdt_balance(OTHER_TOKEN_ID, 1000); world .account(SECOND_USER_ADDRESS) @@ -259,11 +264,11 @@ impl CrowdfundingTestState { .to(CROWDFUNDING_ADDRESS) .typed(crowdfunding_proxy::CrowdfundingProxy) .fund() - .egld_or_single_esdt( - &EgldOrEsdtTokenIdentifier::esdt(CF_TOKEN_ID), + .payment(Payment::new( + CF_TOKEN_ID.as_str().into(), 0u64, - &multiversx_sc::proxy_imports::BigUint::from(amount), - ) + NonZeroBigUint::try_from(amount as u128).unwrap(), + )) .run(); } @@ -311,7 +316,7 @@ impl CrowdfundingTestState { } #[test] -fn test_fund() { +fn test_fund_esdt() { let mut state = CrowdfundingTestState::new(); state.deploy(); @@ -320,7 +325,7 @@ fn test_fund() { } #[test] -fn test_status() { +fn test_status_esdt() { let mut state = CrowdfundingTestState::new(); state.deploy(); @@ -328,7 +333,7 @@ fn test_status() { } #[test] -fn test_sc_error() { +fn test_sc_error_esdt() { let mut state = CrowdfundingTestState::new(); state.deploy(); @@ -339,7 +344,11 @@ fn test_sc_error() { .to(CROWDFUNDING_ADDRESS) .typed(crowdfunding_proxy::CrowdfundingProxy) .fund() - .egld(1000) + .payment(Payment::new( + OTHER_TOKEN_ID.as_str().into(), + 0, + NonZeroBigUint::try_from(1000u128).unwrap(), + )) .with_result(ExpectError(4, "wrong token")) .run(); @@ -347,7 +356,7 @@ fn test_sc_error() { } #[test] -fn test_successful_cf() { +fn test_successful_cf_esdt() { let mut state = CrowdfundingTestState::new(); state.deploy(); @@ -384,7 +393,7 @@ fn test_successful_cf() { } #[test] -fn test_failed_cf() { +fn test_failed_cf_esdt() { let mut state = CrowdfundingTestState::new(); state.deploy(); @@ -416,6 +425,236 @@ fn test_failed_cf() { [comment]: # (mx-context-auto) +## EGLD Test File + +For testing with EGLD, we have a separate test file that uses native EGLD transfers: + +```rust title=crowdfunding_egld_blackbox_test.rs +use crowdfunding::crowdfunding_proxy; + +use multiversx_sc_scenario::imports::*; + +const CF_DEADLINE: TimestampMillis = TimestampMillis::new(7 * 24 * 60 * 60 * 1000); // 1 week in milliseconds + +const FIRST_USER_ADDRESS: TestAddress = TestAddress::new("first-user"); +const OWNER_ADDRESS: TestAddress = TestAddress::new("owner"); +const SECOND_USER_ADDRESS: TestAddress = TestAddress::new("second-user"); + +const CODE_PATH: MxscPath = MxscPath::new("output/crowdfunding.mxsc.json"); +const CROWDFUNDING_ADDRESS: TestSCAddress = TestSCAddress::new("crowdfunding-sc"); + +const OTHER_TOKEN_ID: TestTokenIdentifier = TestTokenIdentifier::new("OTHER-123456"); + +fn world() -> ScenarioWorld { + let mut blockchain = ScenarioWorld::new(); + + blockchain.set_current_dir_from_workspace("contracts/examples/crowdfunding"); + blockchain.register_contract(CODE_PATH, crowdfunding::ContractBuilder); + blockchain +} + +struct CrowdfundingTestState { + world: ScenarioWorld, +} + +impl CrowdfundingTestState { + fn new() -> Self { + let mut world = world(); + + world.account(OWNER_ADDRESS).nonce(1); + + world + .account(FIRST_USER_ADDRESS) + .nonce(1) + .balance(1000) + .esdt_balance(OTHER_TOKEN_ID, 1000); + + world.account(SECOND_USER_ADDRESS).nonce(1).balance(1000); + + Self { world } + } + + fn deploy(&mut self) { + self.world + .tx() + .from(OWNER_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .init(TokenId::native(), 2_000u32, CF_DEADLINE) + .code(CODE_PATH) + .new_address(CROWDFUNDING_ADDRESS) + .run(); + } + + fn fund(&mut self, address: TestAddress, amount: u64) { + self.world + .tx() + .from(address) + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .fund() + .egld(amount) + .run(); + } + + fn check_deposit(&mut self, donor: TestAddress, amount: u64) { + self.world + .query() + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .deposit(donor) + .returns(ExpectValue(amount)) + .run(); + } + + fn check_status(&mut self, expected_value: crowdfunding_proxy::Status) { + self.world + .query() + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .status() + .returns(ExpectValue(expected_value)) + .run(); + } + + fn claim(&mut self, address: TestAddress) { + self.world + .tx() + .from(address) + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .claim() + .run(); + } + + fn check_balance(&mut self, address: TestAddress, balance: u64) { + self.world.check_account(address).balance(balance); + } + + fn set_block_timestamp(&mut self, block_timestamp: TimestampMillis) { + self.world + .current_block() + .block_timestamp_millis(block_timestamp); + } +} + +#[test] +fn test_fund_egld() { + let mut state = CrowdfundingTestState::new(); + state.deploy(); + + state.fund(FIRST_USER_ADDRESS, 1_000u64); + state.check_deposit(FIRST_USER_ADDRESS, 1_000u64); +} + +#[test] +fn test_status_egld() { + let mut state = CrowdfundingTestState::new(); + state.deploy(); + + state.check_status(crowdfunding_proxy::Status::FundingPeriod); +} + +#[test] +fn test_sc_error_egld() { + let mut state = CrowdfundingTestState::new(); + + state.deploy(); + + state + .world + .tx() + .from(FIRST_USER_ADDRESS) + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .fund() + .payment(Payment::new( + OTHER_TOKEN_ID.as_str().into(), + 0, + NonZeroBigUint::try_from(1000u128).unwrap(), + )) + .with_result(ExpectError(4, "wrong token")) + .run(); + + state.check_deposit(FIRST_USER_ADDRESS, 0); +} + +#[test] +fn test_successful_cf_egld() { + let mut state = CrowdfundingTestState::new(); + state.deploy(); + + // first user fund + state.fund(FIRST_USER_ADDRESS, 1_000u64); + state.check_deposit(FIRST_USER_ADDRESS, 1_000); + + // second user fund + state.fund(SECOND_USER_ADDRESS, 1000); + state.check_deposit(SECOND_USER_ADDRESS, 1_000); + + // set block timestamp after deadline + state.set_block_timestamp(CF_DEADLINE + DurationMillis::new(1)); + + // check status successful + state.check_status(crowdfunding_proxy::Status::Successful); + + state + .world + .tx() + .from(FIRST_USER_ADDRESS) + .to(CROWDFUNDING_ADDRESS) + .typed(crowdfunding_proxy::CrowdfundingProxy) + .claim() + .with_result(ExpectError(4, "only owner can claim successful funding")) + .run(); + + // owner claim + state.claim(OWNER_ADDRESS); + + state.check_balance(OWNER_ADDRESS, 2000); + state.check_balance(FIRST_USER_ADDRESS, 0); + state.check_balance(SECOND_USER_ADDRESS, 0); +} + +#[test] +fn test_failed_cf_egld() { + let mut state = CrowdfundingTestState::new(); + state.deploy(); + + // first user fund + state.fund(FIRST_USER_ADDRESS, 300); + state.check_deposit(FIRST_USER_ADDRESS, 300u64); + + // second user fund + state.fund(SECOND_USER_ADDRESS, 600); + state.check_deposit(SECOND_USER_ADDRESS, 600u64); + + // set block timestamp after deadline + state.set_block_timestamp(CF_DEADLINE + DurationMillis::new(1)); + + // check status failed + state.check_status(crowdfunding_proxy::Status::Failed); + + // first user claim + state.claim(FIRST_USER_ADDRESS); + + // second user claim + state.claim(SECOND_USER_ADDRESS); + + state.check_balance(OWNER_ADDRESS, 0); + state.check_balance(FIRST_USER_ADDRESS, 1000); + state.check_balance(SECOND_USER_ADDRESS, 1000); +} +``` + +The key differences in the EGLD test: +- Uses `TokenId::native()` for deployment +- Uses `.egld()` for funding transactions +- Uses `.balance()` for balance checks + +Both test files verify the same functionality (successful funding, failed funding, wrong token rejection), proving the contract works identically regardless of the token type! + +[comment]: # (mx-context-auto) + ## Storage Mappers The contract uses several storage mappers to persist data on the blockchain: diff --git a/testing/extract-tutorial-code/src/extract_code.rs b/testing/extract-tutorial-code/src/extract_code.rs index 246a65c41..a14a617ee 100644 --- a/testing/extract-tutorial-code/src/extract_code.rs +++ b/testing/extract-tutorial-code/src/extract_code.rs @@ -60,10 +60,15 @@ fn main() { &code_blocks, ); - // Find and write crowdfunding.rs + // Find and write blackbox tests + write_code_block( + "crowdfunding_egld_blackbox_test.rs", + "../crowdfunding/tests/crowdfunding_egld_blackbox_test.rs", + &code_blocks, + ); write_code_block( - "crowdfunding_blackbox_test.rs", - "../crowdfunding/tests/crowdfunding_blackbox_test.rs", + "crowdfunding_esdt_blackbox_test.rs", + "../crowdfunding/tests/crowdfunding_esdt_blackbox_test.rs", &code_blocks, ); } From 542da2e4355bf65677873a95af703d2e56834b77 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Thu, 15 Jan 2026 19:57:39 +0200 Subject: [PATCH 11/21] sc-payments update examples --- .../developer-reference/sc-random-numbers.md | 2 +- docs/developers/transactions/tx-payment.md | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/developers/developer-reference/sc-random-numbers.md b/docs/developers/developer-reference/sc-random-numbers.md index 81d4105b0..11de4f421 100644 --- a/docs/developers/developer-reference/sc-random-numbers.md +++ b/docs/developers/developer-reference/sc-random-numbers.md @@ -97,7 +97,7 @@ Example of BAD implementation: #[endpoint(rollDie)] fn roll_die(&self) { // ... - let payment = self.call_value().egld_value(); + let payment = self.call_value().egld(); let rand_nr = rand_source.next_u8(); if rand_nr % 6 == 0 { let prize = payment * 2u32; diff --git a/docs/developers/transactions/tx-payment.md b/docs/developers/transactions/tx-payment.md index 174b36d28..98de014c5 100644 --- a/docs/developers/transactions/tx-payment.md +++ b/docs/developers/transactions/tx-payment.md @@ -111,7 +111,7 @@ References are also allowed. A slightly less common variation is the `ManagedRef endpoint_name: ManagedBuffer, args: MultiValueEncoded, ) { - let payment = self.call_value().egld_value(); // readonly BigUint managed reference + let payment = self.call_value().egld(); // readonly BigUint managed reference self .tx() // tx with sc environment .to(to) @@ -221,17 +221,25 @@ For brevity, instead of `payment(EsdtTokenPaymentRefs::new(&token_identifier, to #[payable("*")] #[endpoint] fn send_esdt(&self, to: ManagedAddress) { - let (token_id, payment) = self.call_value().single_fungible_esdt(); - let half = payment / BigUint::from(2u64); + let payment = self.call_value().single(); + let half_payment = &payment.amount / 2u32; self.tx() .to(&to) - .single_esdt(&token_id, 0, &half) + .payment(PaymentRefs::new( + &payment.token_identifier, + 0, + &half_payment, + )) .transfer(); self.tx() .to(&self.blockchain().get_caller()) - .single_esdt(&token_id, 0, &half) + .payment(PaymentRefs::new( + &payment.token_identifier, + 0, + &half_payment, + )) .transfer(); } ``` From 7d07eff4bda7f6e0f78a617c64e4b4500f1d969d Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Thu, 15 Jan 2026 20:00:14 +0200 Subject: [PATCH 12/21] simplified payment annotation in examples --- docs/developers/developer-reference/sc-payments.md | 8 ++++---- docs/developers/meta/sc-config.md | 2 +- docs/developers/transactions/tx-legacy-calls.md | 4 ++-- docs/developers/transactions/tx-payment.md | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/developers/developer-reference/sc-payments.md b/docs/developers/developer-reference/sc-payments.md index 727c9a08e..7c1ae9b89 100644 --- a/docs/developers/developer-reference/sc-payments.md +++ b/docs/developers/developer-reference/sc-payments.md @@ -100,7 +100,7 @@ Additional restrictions on the incoming tokens can be imposed in the body of the `self.call_value().all()` retrieves all payments sent with the transaction as a `PaymentVec
`. It handles all tokens uniformly, including EGLD (represented as "EGLD-000000"). Never stops execution. ```rust -#[payable("*")] +#[payable] #[endpoint] pub fn process_all_payments(&self) { let payments = self.call_value().all(); @@ -121,7 +121,7 @@ pub fn process_all_payments(&self) { `self.call_value().single()` expects exactly one payment and returns it. Will halt execution if zero or multiple payments are received. Returns a `Payment` object. ```rust -#[payable("*")] +#[payable] #[endpoint] pub fn deposit(&self) { let payment = self.call_value().single(); @@ -140,7 +140,7 @@ pub fn deposit(&self) { `self.call_value().single_optional()` accepts either zero or one payment. Returns `Option>` for graceful handling. Will halt execution if multiple payments are received. ```rust -#[payable("*")] +#[payable] #[endpoint] pub fn execute_with_optional_fee(&self) { match self.call_value().single_optional() { @@ -163,7 +163,7 @@ pub fn execute_with_optional_fee(&self) { `self.call_value().array()` expects exactly N payments and returns them as a fixed-size array. Will halt execution if the number of payments doesn't match exactly. ```rust -#[payable("*")] +#[payable] #[endpoint] pub fn swap(&self) { // Expect exactly 2 payments for the swap diff --git a/docs/developers/meta/sc-config.md b/docs/developers/meta/sc-config.md index 6d90a1365..2ddbbfaa7 100644 --- a/docs/developers/meta/sc-config.md +++ b/docs/developers/meta/sc-config.md @@ -427,7 +427,7 @@ pub trait ForwarderQueue { // ... #[endpoint] - #[payable("*")] + #[payable] fn forward_queued_calls(&self) { while let Some(node) = self.queued_calls().pop_front() { // ... diff --git a/docs/developers/transactions/tx-legacy-calls.md b/docs/developers/transactions/tx-legacy-calls.md index fbdd261a1..082c562ec 100644 --- a/docs/developers/transactions/tx-legacy-calls.md +++ b/docs/developers/transactions/tx-legacy-calls.md @@ -151,7 +151,7 @@ mod callee_proxy { #[multiversx_sc::proxy] pub trait CalleeContract { - #[payable("*")] + #[payable] #[endpoint(myPayableEndpoint)] fn my_payable_endpoint(&self, arg: BigUint) -> BigUint; } @@ -224,7 +224,7 @@ Now that we specified the recipient address, the function and the arguments, it Let's assume we want to call a `#[payable]` endpoint, with this definition: ```rust -#[payable("*")] +#[payable] #[endpoint(myPayableEndpoint)] fn my_payable_endpoint(&self, arg: BigUint) -> BigUint { let payment = self.call_value().any_payment(); diff --git a/docs/developers/transactions/tx-payment.md b/docs/developers/transactions/tx-payment.md index 98de014c5..3eb0a6e68 100644 --- a/docs/developers/transactions/tx-payment.md +++ b/docs/developers/transactions/tx-payment.md @@ -218,7 +218,7 @@ Sometimes we don't have ownership of the token identifier object, or amount, and For brevity, instead of `payment(EsdtTokenPaymentRefs::new(&token_identifier, token_nonce, &amount))`, we can use `.single_esdt(&token_identifier, token_nonce, &amount)`. ```rust title=contract.rs - #[payable("*")] + #[payable] #[endpoint] fn send_esdt(&self, to: ManagedAddress) { let payment = self.call_value().single(); From 8022ddb1a33cddbe777fcc50b6248d4c79a210c4 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Thu, 15 Jan 2026 20:23:46 +0200 Subject: [PATCH 13/21] payments clarification --- .../developer-reference/sc-payments.md | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/developers/developer-reference/sc-payments.md b/docs/developers/developer-reference/sc-payments.md index 7c1ae9b89..3faf84507 100644 --- a/docs/developers/developer-reference/sc-payments.md +++ b/docs/developers/developer-reference/sc-payments.md @@ -48,7 +48,21 @@ The most common way for contracts to accept payments is by having endpoints anno The "payable" flag in the code metadata only refers to direct transfers. Transferring tokens via contract endpoint calls is not affected by it in any way. ::: -If an endpoint only accepts EGLD, it should be annotated with `#[payable("EGLD")]`: +To accept any kind of payment, annotate the endpoints with `#[payable]`: + +```rust +#[endpoint] +#[payable] +fn accept_any_payment(&self) { + // ... +} +``` + +Usually on the first line there will be an instruction that processes, interprets, and validates the received payment ([see below](#call-value-methods)) + + + +If an endpoint only accepts EGLD, it can be annotated with `#[payable("EGLD")]`, although this is slowly falling out of favor. ```rust #[endpoint] @@ -58,18 +72,24 @@ fn accept_egld(&self) { } ``` -When annotated like this, the contract will only accept a single EGLD payment. -To accept any kind of payment, annotate the endpoints with `#[payable]`: +:::note Multi-transfer note +Note that it is currently possible to send two or more EGLD payments in the same transaction. The `#[payable("EGLD")]` annotation rejects that. +::: + +This snippet is equivalent to: ```rust #[endpoint] #[payable] -fn accept_any_payment(&self) { - // ... +fn accept_egld(&self) { + let payment_amount = self.call_value().egld(); + // ... } ``` + + :::note Hard-coded token identifier It is also possible to hard-code a token identifier in the `payable`, e.g. `#[payable("MYTOKEN-123456")]`. It is rarely, if ever, used, tokens should normally be configured in storage, or at runtime. ::: @@ -105,11 +125,8 @@ Additional restrictions on the incoming tokens can be imposed in the body of the pub fn process_all_payments(&self) { let payments = self.call_value().all(); for payment in payments.iter() { - let token_id = &payment.token_identifier; - let amount = payment.amount; - let nonce = &payment.token_nonce; // Handle each payment uniformly - self.process_payment(token_id, nonce, amount); + self.process_payment(&payment.token_identifier, payment.token_nonce, &payment.amount); } } ``` From 5fd86b70a74859be018087c5b5eb29fb0d737b3c Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Thu, 15 Jan 2026 20:27:44 +0200 Subject: [PATCH 14/21] typo --- docs/sdk-and-tools/sdk-js/sdk-js-cookbook-v14.md | 4 ++-- docs/sdk-and-tools/sdk-js/sdk-js-cookbook-v15.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/sdk-and-tools/sdk-js/sdk-js-cookbook-v14.md b/docs/sdk-and-tools/sdk-js/sdk-js-cookbook-v14.md index 3d2b8ee5a..8571ce1d8 100644 --- a/docs/sdk-and-tools/sdk-js/sdk-js-cookbook-v14.md +++ b/docs/sdk-and-tools/sdk-js/sdk-js-cookbook-v14.md @@ -3208,11 +3208,11 @@ We are going to assume we have an account at this point. If you don't, feel free { const secretKeyHex = "413f42575f7f26fad3317a778771212fdb80245850981e48b58a4f25e344e8f9"; const secretKey = UserSecretKey.fromString(secretKeyHex); - const publickKey = secretKey.generatePublicKey(); + const publicKey = secretKey.generatePublicKey(); const transaction = new Transaction({ nonce: 90n, - sender: publickKey.toAddress(), + sender: publicKey.toAddress(), receiver: Address.newFromBech32("erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx"), value: 1000000000000000000n, gasLimit: 50000n, diff --git a/docs/sdk-and-tools/sdk-js/sdk-js-cookbook-v15.md b/docs/sdk-and-tools/sdk-js/sdk-js-cookbook-v15.md index c0ed54964..a84683128 100644 --- a/docs/sdk-and-tools/sdk-js/sdk-js-cookbook-v15.md +++ b/docs/sdk-and-tools/sdk-js/sdk-js-cookbook-v15.md @@ -3228,11 +3228,11 @@ We are going to assume we have an account at this point. If you don't, feel free { const secretKeyHex = "413f42575f7f26fad3317a778771212fdb80245850981e48b58a4f25e344e8f9"; const secretKey = UserSecretKey.fromString(secretKeyHex); - const publickKey = secretKey.generatePublicKey(); + const publicKey = secretKey.generatePublicKey(); const transaction = new Transaction({ nonce: 90n, - sender: publickKey.toAddress(), + sender: publicKey.toAddress(), receiver: Address.newFromBech32("erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx"), value: 1000000000000000000n, gasLimit: 50000n, From f7d20ec9859a554c3dd64e91764e6bfc86b98d2d Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Thu, 15 Jan 2026 20:34:26 +0200 Subject: [PATCH 15/21] rust-tutorial-ci.sh fix --- testing/rust-tutorial-ci.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/testing/rust-tutorial-ci.sh b/testing/rust-tutorial-ci.sh index b5f1d65e1..334111ae2 100755 --- a/testing/rust-tutorial-ci.sh +++ b/testing/rust-tutorial-ci.sh @@ -4,8 +4,10 @@ ## The tests are also taken from the tutorial. ## Tests are only run on the rust backend. -cd extract-tutorial-code || exit 1 +SCRIPT_DIR="$(dirname "$0")" + +cd "$SCRIPT_DIR/extract-tutorial-code" || exit 1 cargo run || exit 1 -cd ../crowdfunding || exit 1 +cd "$SCRIPT_DIR/crowdfunding" || exit 1 cargo test || exit 1 \ No newline at end of file From c75a96ca163dfcf650e261080a7c601635761682 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Thu, 15 Jan 2026 20:54:55 +0200 Subject: [PATCH 16/21] crowdfunding code fix --- .../tutorials/crowdfunding/crowdfunding-p3.md | 23 +++---------------- .../tutorials/crowdfunding/final-code.md | 5 ++-- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md index b18f1c74f..0c37ba703 100644 --- a/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md @@ -1,6 +1,6 @@ --- id: crowdfunding-p3 -title: Supporting Any Fungible Token +title: Extend to Any Token --- [comment]: # (mx-abstract) @@ -102,25 +102,8 @@ Now that our contract is token-agnostic, we can test it with both EGLD and ESDT The complete test files demonstrate this: -### EGLD Test File - -
-crowdfunding_egld_blackbox_test.rs (click to expand) - -```rust file=/Users/andreim/multiversx/mx-docs/testing/crowdfunding/tests/crowdfunding_egld_blackbox_test.rs - -``` -
- -### ESDT Test File - -
-crowdfunding_esdt_blackbox_test.rs (click to expand) - -```rust file=/Users/andreim/multiversx/mx-docs/testing/crowdfunding/tests/crowdfunding_esdt_blackbox_test.rs - -``` -
+- [EGLD Test File](final-code.md#egld-test-file) +- [ESDT Test File](final-code.md#esdt-test-file) Key differences in the ESDT test: diff --git a/docs/developers/tutorials/crowdfunding/final-code.md b/docs/developers/tutorials/crowdfunding/final-code.md index 796335717..a76d80ea3 100644 --- a/docs/developers/tutorials/crowdfunding/final-code.md +++ b/docs/developers/tutorials/crowdfunding/final-code.md @@ -425,9 +425,10 @@ fn test_failed_cf_esdt() { [comment]: # (mx-context-auto) -## EGLD Test File +## Complete Blackbox Test (EGLD) + +If you are interested in specifically only testing for EGLD, we have a separate test file that uses native EGLD transfers. It is very similar, but there is some specific syntax to deal with the EGLD balances and transfers. -For testing with EGLD, we have a separate test file that uses native EGLD transfers: ```rust title=crowdfunding_egld_blackbox_test.rs use crowdfunding::crowdfunding_proxy; From acc5e7f878a241fc0601311fc2e6ede359bd188d Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Thu, 15 Jan 2026 21:32:10 +0200 Subject: [PATCH 17/21] rust tutorial ci fix --- testing/extract-tutorial-code/src/extract_code.rs | 4 ++-- testing/extract-tutorial-code/src/parser.rs | 2 +- testing/rust-tutorial-ci.sh | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/testing/extract-tutorial-code/src/extract_code.rs b/testing/extract-tutorial-code/src/extract_code.rs index a14a617ee..7ad17382f 100644 --- a/testing/extract-tutorial-code/src/extract_code.rs +++ b/testing/extract-tutorial-code/src/extract_code.rs @@ -20,8 +20,7 @@ fn extract_code_blocks_from_file>(path: P) -> Vec { fn extract_crowdfunding_tutorial_code_blocks() -> Vec { CROWDFUNDING_TUTORIAL_PATHS .iter() - .map(|tutorial_path| extract_code_blocks_from_file(tutorial_path)) - .flatten() + .flat_map(extract_code_blocks_from_file) .collect() } @@ -47,6 +46,7 @@ fn find_code_block_by_filename<'a>(code_blocks: &'a [CodeBlock], filename: &str) fn main() { fs::create_dir_all("../crowdfunding/scenarios").unwrap(); fs::create_dir_all("../crowdfunding/src").unwrap(); + fs::create_dir_all("../crowdfunding/tests").unwrap(); let code_blocks = extract_crowdfunding_tutorial_code_blocks(); diff --git a/testing/extract-tutorial-code/src/parser.rs b/testing/extract-tutorial-code/src/parser.rs index 0d3804933..f4de3ebd2 100644 --- a/testing/extract-tutorial-code/src/parser.rs +++ b/testing/extract-tutorial-code/src/parser.rs @@ -72,7 +72,7 @@ fn parse_code_block_info(info: &str, language: &mut Option, filename: &m { *filename = Some(first_part.to_string()); // If it looks like a filename, try to extract language from extension - if let Some(ext) = first_part.split('.').last() { + if let Some(ext) = first_part.split('.').next_back() { *language = Some(ext.to_string()); } } else { diff --git a/testing/rust-tutorial-ci.sh b/testing/rust-tutorial-ci.sh index 334111ae2..30fdca94e 100755 --- a/testing/rust-tutorial-ci.sh +++ b/testing/rust-tutorial-ci.sh @@ -4,7 +4,7 @@ ## The tests are also taken from the tutorial. ## Tests are only run on the rust backend. -SCRIPT_DIR="$(dirname "$0")" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" cd "$SCRIPT_DIR/extract-tutorial-code" || exit 1 cargo run || exit 1 From 23a66106bd2db49e4222564f8e70962b8861eacd Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Thu, 15 Jan 2026 21:35:29 +0200 Subject: [PATCH 18/21] link fix --- .../tutorials/crowdfunding/crowdfunding-p2.md | 12 +++++++----- .../tutorials/crowdfunding/crowdfunding-p3.md | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md index 4ffcee5ee..f60fa3ee1 100644 --- a/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md @@ -153,7 +153,7 @@ fn fund(&self) { let payment = self.call_value().single(); require!( - payment.token_identifier == self.cf_token_id(), + payment.token_identifier == self.cf_token_id().get(), "wrong token" ); @@ -230,7 +230,7 @@ With the code organized, we can now start developing the test for the fund endpo const DONOR: TestAddress = TestAddress::new("donor"); fn crowdfunding_fund() -> ScenarioWorld { - let mut world = deploy_crowdfunding(); + let mut world = crowdfunding_deploy(); world.account(DONOR).nonce(0).balance(400_000_000_000u64); @@ -339,6 +339,8 @@ It doesn't make sense to create a funding that has the target 0 or a negative nu ```rust #[init] fn init(&self, target: BigUint, deadline: TimestampMillis) { + self.cf_token_id().set(TokenId::egld()); + require!(target > 0, "Target must be more than 0"); self.target().set(target); @@ -359,7 +361,7 @@ fn fund(&self) { let payment = self.call_value().single(); require!( - payment.token_identifier == self.cf_token_id(), + payment.token_identifier == self.cf_token_id().get(), "wrong token" ); @@ -382,7 +384,7 @@ We will create another test to verify that the validation works: `crowdfunding_f fn crowdfunding_fund_too_late_test() { let mut world = crowdfunding_fund(); - world.current_block().block_timestamp(123_001u64); + world.current_block().block_timestamp_millis(123_001u64); world .tx() @@ -458,7 +460,7 @@ We can now use the type **Status** just like we use the other types, so we can w ```rust #[view] fn status(&self) -> Status { - if self.get_current_time_millis() < self.deadline().get() { + if self.blockchain().get_block_timestamp_millis() < self.deadline().get() { Status::FundingPeriod } else if self.get_current_funds() >= self.target().get() { Status::Successful diff --git a/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md index 0c37ba703..503ddda71 100644 --- a/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md @@ -102,8 +102,8 @@ Now that our contract is token-agnostic, we can test it with both EGLD and ESDT The complete test files demonstrate this: -- [EGLD Test File](final-code.md#egld-test-file) -- [ESDT Test File](final-code.md#esdt-test-file) +- [EGLD Test File](final-code.md#complete-blackbox-test-egld) +- [ESDT Test File](final-code.md#complete-blackbox-test-esdt) Key differences in the ESDT test: From df514b0e66b4eecea945b8f830cbf1623b21d6f5 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Thu, 15 Jan 2026 21:42:02 +0200 Subject: [PATCH 19/21] crowdfunding continuity fixes --- .../tutorials/crowdfunding/crowdfunding-p2.md | 14 +++++++++++--- .../tutorials/crowdfunding/crowdfunding-p3.md | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md index f60fa3ee1..a538c8901 100644 --- a/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md @@ -47,17 +47,25 @@ fn init(&self, target: BigUint, deadline: TimestampMillis) { self.target().set(target); require!( - deadline > self.blockchain().get_block_timestamp_millis(), + deadline > self.get_current_time_millis(), "Deadline can't be in the past" ); self.deadline().set(deadline); } + +fn get_current_time_millis(&self) -> TimestampMillis { + self.blockchain().get_block_timestamp_millis() +} ``` The `cf_token_id()` storage mapper will hold the token identifier for our crowdfunding campaign. We initialize it to `TokenId::egld()` in the `init` function, hardcoding it to EGLD for now. In Part 3, we'll make this configurable to support any token. `TimestampMillis` is a type-safe wrapper for millisecond timestamps, providing better type safety than using raw `u64` values. +:::note Private functions +Note that `get_current_time_millis()` is not annotated with `#[endpoint]` or `#[view]`. This makes it a **private helper function** that can only be called from within the contract, not from external transactions. Private functions are useful for organizing code and avoiding duplication, but they cannot be called directly by users or other contracts. +::: + The deadline being a block timestamp can be expressed as a 64-bits unsigned integer `TimestampMillis`. The target, however, being a sum of EGLD cannot. :::note @@ -345,7 +353,7 @@ fn init(&self, target: BigUint, deadline: TimestampMillis) { self.target().set(target); require!( - deadline > self.blockchain().get_block_timestamp_millis(), + deadline > self.get_current_time_millis(), "Deadline can't be in the past" ); self.deadline().set(deadline); @@ -460,7 +468,7 @@ We can now use the type **Status** just like we use the other types, so we can w ```rust #[view] fn status(&self) -> Status { - if self.blockchain().get_block_timestamp_millis() < self.deadline().get() { + if self.get_current_time_millis() < self.deadline().get() { Status::FundingPeriod } else if self.get_current_funds() >= self.target().get() { Status::Successful diff --git a/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md index 503ddda71..8500e869e 100644 --- a/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md @@ -41,7 +41,7 @@ fn get_current_time_millis(&self) -> TimestampMillis { } ``` -We've made several improvements: +The key changes: 1. **New parameter**: `token_identifier: TokenId` is now the first parameter 2. **Validation**: We validate that the token identifier is valid before storing it From 108d29b2c5b2fcc7eb5543c1520bbb39c39c85ae Mon Sep 17 00:00:00 2001 From: Sorin Stanculeanu Date: Fri, 16 Jan 2026 11:17:25 +0200 Subject: [PATCH 20/21] relayed v3 note --- docs/developers/relayed-transactions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/developers/relayed-transactions.md b/docs/developers/relayed-transactions.md index 2290483c1..6bdbbbc59 100644 --- a/docs/developers/relayed-transactions.md +++ b/docs/developers/relayed-transactions.md @@ -88,6 +88,7 @@ Therefore, in order to build such a transaction, one has to follow the next step 1. For a guarded relayed transaction, the guarded operation fee will also be consumed from the relayer. 2. Relayer must be different from guardian, in case of guarded sender. 3. Guarded relayers are not allowed. +4. Relayer address must be in the same shard as the transaction sender. ::: ### Example From 021605783551882641c44b02836e87728ead5bd5 Mon Sep 17 00:00:00 2001 From: Andrei Marinica Date: Fri, 16 Jan 2026 15:17:09 +0200 Subject: [PATCH 21/21] tutorial overview update --- docs/developers/overview.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/developers/overview.md b/docs/developers/overview.md index 1f1001c19..82707bff5 100644 --- a/docs/developers/overview.md +++ b/docs/developers/overview.md @@ -40,8 +40,10 @@ Below is a list of tutorials for building on MultiversX: | [Build your first dApp in 15 minutes](/developers/tutorials/your-first-dapp) | Video + written tutorial on how to create your first dApp. | | [Cryptozombies Tutorials](https://cryptozombies.io/en/multiversx) | Interactive way of learning how to write MultiversX Smart Contracts. | | [Build a microservice for your dApp](/developers/tutorials/your-first-microservice) | Video + written tutorial on how to create your microservice. | -| [Building a Crowdfunding Smart Contract](/docs/developers/tutorials/crowdfunding/crowdfunding-p1.md) | Write, build and test a simple smart contract. | -| [Enhancing the Crowdfunding Smart Contract](/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md) | Expand and refine the functionality of an existing contract.| +| [Crowdfunding Tutorial - Part 1: Setup & Basics](/docs/developers/tutorials/crowdfunding/crowdfunding-p1.md) | Write, build and test your first smart contract. | +| [Crowdfunding Tutorial - Part 2: Core Logic](/docs/developers/tutorials/crowdfunding/crowdfunding-p2.md) | Add endpoints, payments, validation and comprehensive testing.| +| [Crowdfunding Tutorial - Part 3: Extend to Any Token](/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md) | Generalize the contract to accept any fungible token.| +| [Crowdfunding Tutorial - Final Code](/docs/developers/tutorials/crowdfunding/final-code.md) | Complete reference implementation with full source code.| | [Staking contract Tutorial](/developers/tutorials/staking-contract) | Step by step tutorial on how to create a Staking Smart Contract. | | [Energy DAO Tutorial](/developers/tutorials/energy-dao) | In depth analysis of the Energy DAO SC template. | | [DEX Walkthrough](/developers/tutorials/dex-walkthrough) | In depth walkthrough of all the main DEX contracts. |