Core Logic
Define contract arguments, handle storage, process payments, define new types, write better tests
Configuring the contract
The previous chapter 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 (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:
use multiversx_sc::imports::*;
Now let's add the storage mappers and init function:
#[view(getTarget)]
#[storage_mapper("target")]
fn target(&self) -> SingleValueMapper<BigUint>;
#[view(getDeadline)]
#[storage_mapper("deadline")]
fn deadline(&self) -> SingleValueMapper<TimestampMillis>;
#[view(getDeposit)]
#[storage_mapper("deposit")]
fn deposit(&self, donor: &ManagedAddress) -> SingleValueMapper<BigUint>;
#[view(getCrowdfundingTokenId)]
#[storage_mapper("tokenIdentifier")]
fn cf_token_id(&self) -> SingleValueMapper<TokenId>;
#[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);
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 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 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.
1 EGLD = 1018 EGLD-wei, also known as atto-EGLD.
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 and BigInt.
Try to avoid using the signed version whenever possible, unless negative values are truly needed. There are some caveats with BigInt argument serialization that can lead to subtle bugs.
Note that BigUint logic is not implemented within the contract itself but is provided by the MultiversX VM API to keep the contract code lightweight.
Let's test that initialization works.
First, navigate to the contract's crate path and rebuild it using:
sc-meta all build
Next, we regenerate the proxy at the same path using:
sc-meta all proxy
Finally, we update the test:
#[test]
fn crowdfunding_deploy_test() {
let mut world = world();
world.account(OWNER).nonce(0).balance(1000000);
let crowdfunding_address = world
.tx()
.from(OWNER)
.typed(crowdfunding_proxy::CrowdfundingProxy)
.init(500_000_000_000u64, 123000u64)
.code(CODE_PATH)
.new_address(CROWDFUNDING_ADDRESS)
.returns(ReturnsNewAddress)
.run();
assert_eq!(crowdfunding_address, CROWDFUNDING_ADDRESS.to_address());
world.check_account(OWNER).balance(1_000_000);
world
.query()
.to(CROWDFUNDING_ADDRESS)
.typed(crowdfunding_proxy::CrowdfundingProxy)
.target()
.returns(ExpectValue(500_000_000_000u64))
.run();
world
.query()
.to(CROWDFUNDING_ADDRESS)
.typed(crowdfunding_proxy::CrowdfundingProxy)
.deadline()
.returns(ExpectValue(123000u64))
.run();
}
Note the added arguments in the deploy call and the additional query for the deadline storage.
Run the test again from the contract crate's path:
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. Additionally, we need to validate that the correct token is being sent.
#[view(getDeposit)]
#[storage_mapper("deposit")]
fn deposit(&self, donor: &ManagedAddress) -> SingleValueMapper<BigUint>;
#[endpoint]
#[payable]
fn fund(&self) {
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.amount.as_big_uint());
}
Every time the contract is modified, you need to rebuild it and regenerate the proxy.
A few things to unpack:
- 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. - 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]. call_value().single()gets the payment as aPaymentstructure, which we then validate against our stored EGLD token identifier fromcf_token_id().fundneeds 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.
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(500_000_000_000u64, 123000u64)
.code(CODE_PATH)
.new_address(CROWDFUNDING_ADDRESS)
.returns(ReturnsNewAddress)
.run();
assert_eq!(crowdfunding_address, CROWDFUNDING_ADDRESS.to_address());
world
}
Now that we've moved the deployment logic to a separate function, let's update the test that checks the deploy endpoint like this:
#[test]
fn crowdfunding_deploy_test() {
let mut world = crowdfunding_deploy();
world.check_account(OWNER).balance(1_000_000);
world
.query()
.to(CROWDFUNDING_ADDRESS)
.typed(crowdfunding_proxy::CrowdfundingProxy)
.target()
.returns(ExpectValue(500_000_000_000u64))
.run();
world
.query()
.to(CROWDFUNDING_ADDRESS)
.typed(crowdfunding_proxy::CrowdfundingProxy)
.deadline()
.returns(ExpectValue(123000u64))
.run();
}
With the code organized, we can now start developing the test for the fund endpoint.
const DONOR: TestAddress = TestAddress::new("donor");
fn crowdfunding_fund() -> ScenarioWorld {
let mut world = crowdfunding_deploy();
world.account(DONOR).nonce(0).balance(400_000_000_000u64);
world
.tx()
.from(DONOR)
.to(CROWDFUNDING_ADDRESS)
.typed(crowdfunding_proxy::CrowdfundingProxy)
.fund()
.egld(250_000_000_000u64)
.run();
world
}
#[test]
fn crowdfunding_fund_test() {
let mut world = crowdfunding_fund();
world.check_account(OWNER).nonce(1).balance(1_000_000u64);
world
.check_account(DONOR)
.nonce(1)
.balance(150_000_000_000u64);
world
.check_account(CROWDFUNDING_ADDRESS)
.nonce(0)
.balance(250_000_000_000u64);
world
.query()
.to(CROWDFUNDING_ADDRESS)
.typed(crowdfunding_proxy::CrowdfundingProxy)
.target()
.returns(ExpectValue(500_000_000_000u64))
.run();
world
.query()
.to(CROWDFUNDING_ADDRESS)
.typed(crowdfunding_proxy::CrowdfundingProxy)
.deadline()
.returns(ExpectValue(123_000u64))
.run();
world
.query()
.to(CROWDFUNDING_ADDRESS)
.typed(crowdfunding_proxy::CrowdfundingProxy)
.deposit(DONOR)
.returns(ExpectValue(250_000_000_000u64))
.run();
}
Explanation:
- We need a donor, so we add another account using
.account(DONOR). - The simulated transaction includes:
- The payment in the transaction is made using
.egld(250_000_000_000u64). - When checking the state, we see that the donor's balance is decreased by the amount paid, and the contract balance increased by the same amount.
Run again the following command in the root of the project to test it:
sc-meta test
You should then see that both tests pass:
Running tests in ./ ...
Executing cargo test ...
Compiling crowdfunding v0.0.0 (/home/crowdfunding)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.22s
Running unittests src/crowdfunding.rs (target/debug/deps/crowdfunding-73d2b98f9e2cff29)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests/crowdfunding_blackbox_test.rs (target/debug/deps/crowdfunding_blackbox_test-19b9f0d2428bc9f9)
running 2 tests
test crowdfunding_deploy_test ... ok
test crowdfunding_fund_test ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Doc-tests crowdfunding
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Process finished with: exit status: 0