diff --git a/.github/workflows/rust-tutorial-ci.yml b/.github/workflows/rust-tutorial-ci.yml index 3ab74821a..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: nightly + toolchain: 1.92 - name: Run rust tests - run: ./scripts/rust-tutorial-ci.sh + run: ./testing/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 23f73ecb4..bd06db43a 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,13 +42,27 @@ 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. ::: -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] @@ -55,23 +72,135 @@ fn accept_egld(&self) { } ``` -When annotated like this, the contract will reject any ESDT payment. Calling this function without any payment will work. -To accept any kind of payment, do 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) { - // ... +#[payable] +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. ::: +[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() { + // Handle each payment uniformly + self.process_payment(&payment.token_identifier, payment.token_nonce, &payment.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 +208,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.1 - use `all()` instead)* --- @@ -87,4 +216,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). 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/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/overview.md b/docs/developers/overview.md index 96f3da174..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-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.| +| [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. | 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 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/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 174b36d28..3eb0a6e68 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) @@ -218,20 +218,28 @@ 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 (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(); } ``` 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 d3f83f53f..98005d76d 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: Setup & Basics --- + [comment]: # (mx-abstract) Write, build and deploy a simple smart contract written in Rust. @@ -81,20 +82,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/crowdfunding-p2.md similarity index 72% rename from docs/developers/tutorials/crowdfunding-p2.md rename to docs/developers/tutorials/crowdfunding/crowdfunding-p2.md index 516a4a8bc..a538c8901 100644 --- a/docs/developers/tutorials/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: Core Logic --- [comment]: # (mx-abstract) Define contract arguments, handle storage, process payments, define new types, write better tests @@ -9,9 +9,17 @@ 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. +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,50 @@ 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; +#[view(getCrowdfundingTokenId)] +#[storage_mapper("tokenIdentifier")] +fn cf_token_id(&self) -> SingleValueMapper; + #[init] -fn init(&self, target: BigUint, deadline: u64) { - self.target().set(&target); - self.deadline().set(&deadline); +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); + + 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() } ``` -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()` 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 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**. @@ -65,7 +98,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(); @@ -115,7 +148,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)] @@ -123,11 +156,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().get(), + "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()); } ``` @@ -138,14 +177,15 @@ 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]`. -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 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()` . 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 +209,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,11 +234,11 @@ 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 { - let mut world = deploy_crowdfunding(); + let mut world = crowdfunding_deploy(); world.account(DONOR).nonce(0).balance(400_000_000_000u64); @@ -306,12 +346,14 @@ 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) { + self.cf_token_id().set(TokenId::egld()); + require!(target > 0, "Target must be more than 0"); self.target().set(target); require!( - deadline > self.get_current_time(), + deadline > self.get_current_time_millis(), "Deadline can't be in the past" ); self.deadline().set(deadline); @@ -322,15 +364,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().get(), + "wrong token" + ); - 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(); - self.deposit(&caller).update(|deposit| *deposit += &*payment); + self.deposit(&caller).update(|deposit| *deposit += payment.amount.as_big_uint()); } ``` @@ -340,12 +387,12 @@ 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(); - world.current_block().block_timestamp(123_001u64); + world.current_block().block_timestamp_millis(123_001u64); world .tx() @@ -421,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() <= 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 +479,8 @@ fn status(&self) -> Status { #[view(getCurrentFunds)] fn get_current_funds(&self) -> BigUint { - self.blockchain().get_sc_balance(&EgldOrEsdtTokenIdentifier::egld(), 0) + let token = self.cf_token_id().get(); + self.blockchain().get_sc_balance(&token, 0) } ``` @@ -440,9 +488,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().get(), + "wrong token" + ); require!( self.status() == Status::FundingPeriod, @@ -451,7 +504,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()); } ``` @@ -498,16 +551,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,124 +583,26 @@ 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) -## 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: +## Conclusion -```rust title=crowdfunding.rs -#![no_std] +Congratulations! You've successfully built a crowdfunding smart contract with: -use multiversx_sc::{derive_imports::*, imports::*}; -pub mod crowdfunding_proxy; - -#[type_abi] -#[derive(TopEncode, TopDecode, PartialEq, Clone, Copy)] -pub enum Status { - FundingPeriod, - Successful, - Failed, -} - -#[multiversx_sc::contract] -pub trait Crowdfunding { - #[init] - fn init(&self, target: BigUint, deadline: u64) { - require!(target > 0, "Target must be more than 0"); - self.target().set(target); - - require!( - deadline > self.get_current_time(), - "Deadline can't be in the past" - ); - self.deadline().set(deadline); - } - - #[endpoint] - #[payable("EGLD")] - fn fund(&self) { - let payment = self.call_value().egld(); - - require!( - self.status() == Status::FundingPeriod, - "cannot fund after deadline" - ); - - let caller = self.blockchain().get_caller(); - self.deposit(&caller).update(|deposit| *deposit += &*payment); - } - - #[view] - fn status(&self) -> Status { - if self.get_current_time() <= self.deadline().get() { - Status::FundingPeriod - } else if self.get_current_funds() >= self.target().get() { - Status::Successful - } else { - Status::Failed - } - } - - #[view(getCurrentFunds)] - fn get_current_funds(&self) -> BigUint { - self.blockchain().get_sc_balance(&EgldOrEsdtTokenIdentifier::egld(), 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 sc_balance = self.get_current_funds(); - self.send().direct_egld(&caller, &sc_balance); - }, - Status::Failed => { - let caller = self.blockchain().get_caller(); - let deposit = self.deposit(&caller).get(); - - if deposit > 0u32 { - self.deposit(&caller).clear(); - self.send().direct_egld(&caller, &deposit); - } - }, - } - } - - // private - - fn get_current_time(&self) -> u64 { - self.blockchain().get_block_timestamp() - } - - // storage - - #[view(getTarget)] - #[storage_mapper("target")] - fn target(&self) -> SingleValueMapper; - - #[view(getDeadline)] - #[storage_mapper("deadline")] - fn deadline(&self) -> SingleValueMapper; - - #[view(getDeposit)] - #[storage_mapper("deposit")] - fn deposit(&self, donor: &ManagedAddress) -> SingleValueMapper; -} -``` +- EGLD-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). +- **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 diff --git a/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md b/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md new file mode 100644 index 000000000..8500e869e --- /dev/null +++ b/docs/developers/tutorials/crowdfunding/crowdfunding-p3.md @@ -0,0 +1,149 @@ +--- +id: crowdfunding-p3 +title: Extend to Any 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. 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. + +[comment]: # (mx-context-auto) + +## Making the Token Identifier Configurable + +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 +#[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() +} +``` + +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 +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 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 +#[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()); +} +``` + +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) + +## Other Methods + +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) + +## Testing with Different Tokens + +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 +.init(TokenId::native(), 2_000u32, CF_DEADLINE) +``` + +**For ESDT:** +```rust +.init(CF_TOKEN_ID, 2_000u32, CF_DEADLINE) +``` + +The complete test files demonstrate this: + +- [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: + +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()` + +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) + +## 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 new file mode 100644 index 000000000..a76d80ea3 --- /dev/null +++ b/docs/developers/tutorials/crowdfunding/final-code.md @@ -0,0 +1,692 @@ +--- +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), [Part 2](crowdfunding-p2.md), and [Part 3](crowdfunding-p3.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::{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_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); + } + + #[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()); + } + + #[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_id().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_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(); + } + } + } + } + } + + // 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(getCrowdfundingTokenId)] + #[title("tokenIdentifier")] + #[storage_mapper("tokenIdentifier")] + fn cf_token_id(&self) -> SingleValueMapper; +} +``` + +[comment]: # (mx-context-auto) + +## Complete Blackbox Test (ESDT) + +```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 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(); + + 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) + .esdt_balance(OTHER_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() + .payment(Payment::new( + CF_TOKEN_ID.as_str().into(), + 0u64, + NonZeroBigUint::try_from(amount as u128).unwrap(), + )) + .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_esdt() { + 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_esdt() { + let mut state = CrowdfundingTestState::new(); + state.deploy(); + + state.check_status(crowdfunding_proxy::Status::FundingPeriod); +} + +#[test] +fn test_sc_error_esdt() { + 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_esdt() { + 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_esdt() { + 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) + +## 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. + + +```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: + +- **`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_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. + +[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: 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/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, 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/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/sidebars.js b/sidebars.js index fcf56a019..a234e5b74 100644 --- a/sidebars.js +++ b/sidebars.js @@ -52,8 +52,16 @@ 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/crowdfunding/crowdfunding-p3", + "developers/tutorials/crowdfunding/final-code", + ], + }, "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/crowdfunding-esdt/.gitignore b/testing/crowdfunding/.gitignore similarity index 72% rename from testing/crowdfunding-esdt/.gitignore rename to testing/crowdfunding/.gitignore index 8ad202325..2152c46ef 100644 --- a/testing/crowdfunding-esdt/.gitignore +++ b/testing/crowdfunding/.gitignore @@ -1,6 +1,7 @@ # Generated by `extract-tutorial-code` /scenarios -/src/* +/src/crowdfunding.rs +/tests/* wasm output target 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_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, +} 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..7ad17382f 100644 --- a/testing/extract-tutorial-code/src/extract_code.rs +++ b/testing/extract-tutorial-code/src/extract_code.rs @@ -1,55 +1,74 @@ 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/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 { 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() + .flat_map(extract_code_blocks_from_file) + .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(); + fs::create_dir_all("../crowdfunding/tests").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, + ); + + // 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_esdt_blackbox_test.rs", + "../crowdfunding/tests/crowdfunding_esdt_blackbox_test.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..f4de3ebd2 --- /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('.').next_back() { + *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() +} diff --git a/scripts/rust-tutorial-ci.sh b/testing/rust-tutorial-ci.sh similarity index 53% rename from scripts/rust-tutorial-ci.sh rename to testing/rust-tutorial-ci.sh index 3a0d5998c..30fdca94e 100755 --- a/scripts/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 testing/extract-tutorial-code -cargo run || return 1 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -cd ../crowdfunding-esdt -cargo test || return 1 +cd "$SCRIPT_DIR/extract-tutorial-code" || exit 1 +cargo run || exit 1 + +cd "$SCRIPT_DIR/crowdfunding" || exit 1 +cargo test || exit 1 \ No newline at end of file