The Crowdfunding Smart Contract (part 2)

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 after which the contract can no longer be funded. We will be adding 2 more storage fields and arguments to the constructor.

constructor(
    target: BigUint,
    deadline: ManagedU64
) {
    super();

    this.target().set(target)
    this.deadline().set(deadline)
}

abstract target(): Mapping<BigUint>

abstract deadline(): Mapping<ManagedU64>

abstract deposit(donor: ManagedAddress): Mapping<BigUint>

The deadline being a block timestamp can be expressed as a regular 64-bits unsigned int. The target, however, being a sum of EGLD cannot. Note that 1 EGLD = 10^18 EGLD-wei (also known as atto-EGLD), the smallest unit of currency, and all payments are expressed in wei. So you can see that even for small payments the numbers get large. Luckily, the framework offers support for big numbers out of the box.

Signed big numbers, a.k.a BigInt, are not currently supported by mx-sdk-as.

Also note that BigUint logic does not reside in the contract, but is built into the MultiversX VM API, to not bloat the contract code.

Let's test that initialization works.

{
    "name": "crowdfunding deployment test",
    "steps": [
        {
            "step": "setState",
            "accounts": {
                "address:my_address": {
                    "nonce": "0",
                    "balance": "1,000,000"
                }
            },
            "newAddresses": [
                {
                    "creatorAddress": "address:my_address",
                    "creatorNonce": "0",
                    "newAddress": "sc:crowdfunding"
                }
            ]
        },
        {
            "step": "scDeploy",
            "txId": "deploy",
            "tx": {
                "from": "address:my_address",
                "contractCode": "file:../output/release.wasm",
                "arguments": [
                    "500,000,000,000",
                    "123,000"
                ],
                "gasLimit": "5,000,000",
                "gasPrice": "0"
            },
            "expect": {
                "out": [],
                "status": "0",
                "gas": "*",
                "refund": "*"
            }
        },
        {
            "step": "checkState",
            "accounts": {
                "address:my_address": {
                    "nonce": "1",
                    "balance": "1,000,000",
                    "storage": {}
                },
                "sc:crowdfunding": {
                    "nonce": "0",
                    "balance": "0",
                    "storage": {
                        "str:target": "500,000,000,000",
                        "str:deadline": "123,000"
                    },
                    "code": "file:../output/release.wasm"
                }
            }
        }
    ]
}

Note the added "arguments" field in scDeploy and the added fields in storage.

Run the following commands:

npm run build
npm run mandos

You should once again see this:

Scenario: crowdfunding-init.scen.json ...   ok
Done. Passed: 1. Failed: 0. Skipped: 0.
SUCCESS

Funding the contract

It is not enough to receive the funds, the contract also needs to keep track of who donated how much.

@contract
abstract class Crowdfunding extends ContractBase {

    ...

    fund(): void {
        const payment = this.callValue.egldValue
        const caller = this.blockchain.caller

        const oldDeposit = this.deposit(caller).get()
        const newDeposit = oldDeposit + payment

        this.deposit(caller).set(newDeposit)
    }
    
    abstract deposit(donor: ManagedAddress): Mapping<BigUint>

    ...

}

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.

AssemblyScript is statically typed, you should explicit the return type of every function, even if it is void.

Instead of Rust contracts, you don't have to mark a method as endpoint and/or payable. Every public method of the class contract will be reachable and will accept payments.

If you don't want to make a method reachable, set is visibility to private.

To test the function, we'll add a new test file, in the same mandos folder. Let's call it crowdfunding-fund.scen.json .

To avoid duplicating the deployment code, we import it from crowdfunding-init.scen.json .

{
  "name": "crowdfunding funding",
  "steps": [
    {
      "step": "externalSteps",
      "path": "crowdfunding-init.scen.json"
    },
    {
      "step": "setState",
      "accounts": {
        "address:donor1": {
          "nonce": "0",
          "balance": "400,000,000,000"
        }
      }
    },
    {
      "step": "scCall",
      "txId": "fund-1",
      "tx": {
        "from": "address:donor1",
        "to": "sc:crowdfunding",
        "egldValue": "250,000,000,000",
        "function": "fund",
        "arguments": [],
        "gasLimit": "100,000,000",
        "gasPrice": "0"
      },
      "expect": {
        "out": [],
        "status": "",
        "gas": "*",
        "refund": "*"
      }
    },
    {
      "step": "checkState",
      "accounts": {
        "address:my_address": {
          "nonce": "1",
          "balance": "1,000,000",
          "storage": {}
        },
        "address:donor1": {
          "nonce": "1",
          "balance": "150,000,000,000",
          "storage": {}
        },
        "sc:crowdfunding": {
          "nonce": "0",
          "balance": "250,000,000,000",
          "storage": {
            "str:target": "500,000,000,000",
            "str:deadline": "123,000",
            "str:deposit|address:donor1": "250,000,000,000"
          },
          "code": "file:../output/release.wasm"
        }
      }
    }
  ]
}

Explanation:

  1. "externalSteps"allows us to import steps from another json file. This is very handy, because we can write test scenarios that branch out from each other without having to duplicate code. Here we will be reusing the deployment steps in all tests. These imported steps get executed again each time they are imported.

  2. We need a donor, so we add another account using a new "setState" step.

  3. The actual simulated transaction. Note that we use "scCall" instead of "scDeploy". There is a "to" field, and no "contractCode". The rest functions the same. The "egldValue" field indicates the amount paid to the function.

  4. When checking the state, we have a new user, we see that the donor's balance is decreased by the amount paid, and the contract balance increased by the same amount.

  5. There is another entry in the contract storage. The pipe symbol|in the key means concatenation. The addresses are serialized as itself, and we can represent it in the same readable format.

Test it by running the commands again:

npm run build
npm run mandos

You should then see that both tests pass:

Scenario: crowdfunding-fund.scen.json ...   ok
Scenario: crowdfunding-init.scen.json ...   ok
Done. Passed: 2. Failed: 0. Skipped: 0.
SUCCESS

Validation

It doesn't make sense to fund after the deadline has passed, so fund transactions after a certain block timestamp must be rejected. The idiomatic way to do this is:

fund(): void {
    const payment = this.callValue.egldValue

    const currentTime = this.blockchain.currentBlockTimestamp
    this.require(
        currentTime < this.deadline().get(),
        "cannot fund after deadline"
    )

    const caller = this.blockchain.caller

    const oldDeposit = this.deposit(caller).get()
    const newDeposit = oldDeposit + payment

    this.deposit(caller).set(newDeposit)
}

this.require(expression, errorMessage) is the same as if (!expression) { throw new Error(errorMessage) }

this.require may be replaced by a global function require in the future.

We'll create another test file to verify that the validation works: test-fund-too-late.scen.json .

{
  "name": "trying to fund one block too late",
  "steps": [
    {
      "step": "externalSteps",
      "path": "crowdfunding-fund.scen.json"
    },
    {
      "step": "setState",
      "currentBlockInfo": {
        "blockTimestamp": "123,001"
      }
    },
    {
      "step": "scCall",
      "txId": "fund-too-late",
      "tx": {
        "from": "address:donor1",
        "to": "sc:crowdfunding",
        "egldValue": "10,000,000,000",
        "function": "fund",
        "arguments": [],
        "gasLimit": "100,000,000",
        "gasPrice": "0"
      },
      "expect": {
        "out": [],
        "status": "4",
        "message": "str:cannot fund after deadline",
        "gas": "*",
        "refund": "*"
      }
    }
  ]
}

We branch this time from crowdfunding-fund.scen.json, where we already had a donor. Now the same donor wants to donate, again, but in the meantime the current block timestamp has become 123,001, one block later than the deadline. The transaction fails with status 4 (user error - all errors from within the contract will return this status). The testing environment allows us to also check that the correct message was returned.

By building and testing the contract again, you should see that all three tests pass:

Scenario: crowdfunding-fund-too-late.scen.json ...   ok
Scenario: crowdfunding-fund.scen.json ...   ok
Scenario: crowdfunding-init.scen.json ...   ok
Done. Passed: 3. Failed: 0. Skipped: 0.
SUCCESS

Querying the contract status

The contract status can be known by anyone by looking into the storage and on the blockchain, but it is really inconvenient right now. Let's create an endpoint that gives this status directly. The status will be one of: FundingPeriod, Successful or Failed. We could use a number to represent it in code, but the nice way to do it is with an enum. We will take this opportunity to show how to create a serializable type that can be taken as argument, returned as result or saved in storage.

This is the enum:

@enumtype
enum Status {
    FundingPeriod,
    Successful,
    Failed
}

Make sure to add it outside the contract class.

The @enumtype annotation tells the framework to automatically implement features like storage encoding.

At compile time enums the framework transforms enums into classes. This leads to some issues with switch statements, make sure to use if/else if/else instead

We can now use the type Status just like we use the other types, so we can write the following method in the contract class:

getCurrentFunds(): BigUint {
    return this.blockchain
        .getSCBalance(
            TokenIdentifier.egld(),
            ManagedU64.fromValue(0)
        )
}

status(): Status {
    if (this.blockchain.currentBlockTimestamp <= this.deadline().get()) {
        return Status.FundingPeriod
    } else if (this.getCurrentFunds() >= this.target().get()) {
        return Status.Successful
    } else {
        return Status.Failed
    }
}

Remember, these two methods are publics, so reachable by anyone on-chain.

To test this method, we append one more step to the last test we worked on, test-fund-too-late.scen.json :

{
    "name": "trying to fund one block too late",
    "steps": [
        {
            "step": "externalSteps",
            "path": "crowdfunding-fund.scen.json"
        },
        {
            "step": "setState",
            "currentBlockInfo": {
                "blockTimestamp": "123,001"
            }
        },
        {
            "step": "scCall",
            "txId": "fund-too-late",
            "tx": {
                "from": "address:donor1",
                "to": "sc:crowdfunding",
                "egldValue": "10,000,000,000",
                "function": "fund",
                "arguments": [],
                "gasLimit": "100,000,000",
                "gasPrice": "0"
            },
            "expect": {
                "out": [],
                "status": "4",
                "message": "str:cannot fund after deadline",
                "gas": "*",
                "refund": "*"
            }
        },
        {
            "step": "scQuery",
            "txId": "check-status",
            "tx": {
                "to": "sc:crowdfunding",
                "function": "status",
                "arguments": []
            },
            "expect": {
                "out": [
                    "2"
                ],
                "status": "0"
            }
        }
    ]
}

Since the function we're trying to call is a view function, we use the scQuery step instead of the scCall step. The difference is that for scQuery, there is no caller, no payment, and gas price/gas limit. On the real blockchain, a smart contract query does not create a transaction on the blockchain, so no account is needed. scQuery simulates this exact behaviour.

Note the call to "status" at the end and the result "out": [ "2" ] , which is the encoding for Status::Failure. Enums are encoded as an index of their values. In this example, Status::FundingPeriod is "0" (or ""), Status::Successful is "1" and, as you've already seen, Status::Failure is "2".

Contract functions can return in principle any number of results, that is why "out" is a list.

Claim functionality

Finally, let's add the claim method. The status method we just implemented helps us keep the code tidy:

claim(): void {
    const status = this.status()

    if (status === Status.FundingPeriod) {
        throw new Error("cannot claim before deadline")
    } else if (status === Status.Successful) {
        const caller = this.blockchain.caller
        this.require(
            caller == this.blockchain.owner,
            "only owner can claim successful funding"
        )
        const scBalance = this.getCurrentFunds()
        this.send
            .directEgld(
                caller,
                scBalance
            )
    } else if (status === Status.Failed) {
        const caller = this.blockchain.caller
        const deposit = this.deposit(caller).get()

        if (deposit > BigUint.fromU64(0)) {
            this.deposit(caller).clear()
            this.send
                .directEgld(
                    caller,
                    deposit
                )
        }
    }
}

The only new function here is this.send().directEgld(), which simply forwards EGLD from the contract to the given address.

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:

//@ts-nocheck

import {
    ContractBase,
    Mapping,
    BigUint,
    ManagedU64,
    ManagedAddress,
    TokenIdentifier
} from "@gfusee/mx-sdk-as";

@enumtype
export enum Status {
    FundingPeriod,
    Successful,
    Failed
}

@contract
abstract class Crowdfunding extends ContractBase {

    constructor(
        target: BigUint,
        deadline: ManagedU64
    ) {
        super();

        this.target().set(target)
        this.deadline().set(deadline)
    }

    fund(): void {
        const payment = this.callValue.egldValue

        const currentTime = this.blockchain.currentBlockTimestamp
        this.require(
            currentTime < this.deadline().get(),
            "cannot fund after deadline"
        )

        const caller = this.blockchain.caller

        const oldDeposit = this.deposit(caller).get()
        const newDeposit = oldDeposit + payment

        this.deposit(caller).set(newDeposit)
    }

    getCurrentFunds(): BigUint {
        return this.blockchain
            .getSCBalance(
                TokenIdentifier.egld(),
                ManagedU64.fromValue(0)
            )
    }

    status(): Status {
        if (this.blockchain.currentBlockTimestamp <= this.deadline().get()) {
            return Status.FundingPeriod
        } else if (this.getCurrentFunds() >= this.target().get()) {
            return Status.Successful
        } else {
            return Status.Failed
        }
    }

    claim(): void {
        const status = this.status()

        if (status === Status.FundingPeriod) {
            throw new Error("cannot claim before deadline")
        } else if (status === Status.Successful) {
            const caller = this.blockchain.caller
            this.require(
                caller == this.blockchain.owner,
                "only owner can claim successful funding"
            )
            const scBalance = this.getCurrentFunds()
            this.send
                .directEgld(
                    caller,
                    scBalance
                )
        } else if (status === Status.Failed) {
            const caller = this.blockchain.caller
            const deposit = this.deposit(caller).get()

            if (deposit > BigUint.fromU64(0)) {
                this.deposit(caller).clear()
                this.send
                    .directEgld(
                        caller,
                        deposit
                    )
            }
        }
    }

    abstract target(): Mapping<BigUint>

    abstract deadline(): Mapping<ManagedU64>

    abstract deposit(donor: ManagedAddress): Mapping<BigUint>

}

As an exercise, try to add some more tests, especially ones involving the claim function.

Next steps

This concludes the first AssemblyScript MultiversX tutorial.

If you have any issue you can open one in the GitHub repo : https://github.com/gfusee/mx-sdk-as

Last updated