B9lab Logo
Tezos Developer Portal
Developer PortalDeveloper Portal

Smart Contract Implementation I

Implementing the buy function


Reading Time: 39 min

B9lab's sample project is an implementation of a continiuous programmable equity offering also known as a Rolling SAFE. To see the implementation in action, please visit the TZMINT web application hosted by B9lab. It is better to first try out the web application before taking a closer look at the implementation described in this course.

info icon

For a close look at the formulas used for calculation, we recommend consulting the Continuous Organizations Whitepaper by Thibauld Favre. In this section, we present an implementation of the whitepaper's ideas, our TZMINT project.

We implemented our smart contract in SmartPy. The tokens are represented as a simple map to keep things simple. In addition, we store everything we want to keep track of on-chain:

        self.init(
            organization  = organization,                                     # contract administrator
            ledger        = sp.map(l = {organization: sp.as_nat(preminted)}), # token ledger
            price         = initial_price,                                    # initial price before MFG
            total_tokens  = preminted,
            burned_tokens = burned_tokens,
            MFG           = MFG,                                              # minimal funding goal
            MPT           = sp.timestamp(...), # minimum period of time
            
            # percentage of the funds being held in the cash reserve
            funds_ratio_for_reserve    = funds_ratio_for_reserve,
            # percentage of the revenues being funneled into cash reserve
            revenues_ratio_for_reserve = revenues_ratio_for_reserve,

            buy_slope     = buy_slope,
            sell_slope    = sell_slope,
            minimumInvestment  = minimumInvestment,
            company_v          = company_valuation,
            base_currency      = base_currency,
            total_allocation   = total_allocation,
            stake_allocation   = stake_allocation,
            termination_events = termination_events,
            govRights          = govRights,
            company_name       = company_name,
            phase              = 0,                                              # starting under MFG
            total_investment   = sp.tez(0)
            )
tip icon

Need a recap on SmartPy? No worries! We recommend a look at the chapter Writing Smart Contracts in this course.

Contract entrypoints

The contract has five entrypoints:

  • buy(),
  • sell(),
  • pay(),
  • burn(), and
  • close().

In this section, we begin with an explanation of the buy() and pay() functions. Afterwards, in the next section, we will talk about the sell() and close() functions, also known as closing function.

info icon

You can find the smart contract in the project's GitHub repository.

If you want to extend this smart contract to, for example, fulfill the FA2 token standard, you will need to implement additional entrypoints. You can find more information on the token standard in the FA2 section.

The buy() function

buy() calculates the number of tokens for the sent amount of tez and it mints these tokens. In addition, it should send back the excess amount.

There are two phases in the offering regarding the buy price:

  1. The initial phase: Before the minimum funding goal (MFG) is reached, the buy price is constant;
  2. The slopy phase (i.e. post-MFG phase): Once the MFG is reached, the buy price increases for each issued token.

Entrypoint

Let us first have a look at the buy() entrypoint and understand the conditions for calling the initial or slope buy function:

    # buy some tokens with sender's tez
    @sp.entry_point
    def buy(self):
    #check the phase, dont sell or buy if closed
        sp.if self.data.phase != 2:
            # if token in intialization phase, the price is fixed and all funds are escrowed
            sp.if self.data.total_investment < self.data.MFG:
                # check the excess above MFG and send back
                sp.if sp.utils.mutez_to_nat(self.data.MFG - sp.amount - self.data.total_investment) < 0:
                    sp.send(sp.sender, sp.amount - self.data.MFG + self.data.total_investment)
                    self.buy_initial(self.data.MFG - self.data.total_investment)
                sp.else:
                    self.buy_initial(sp.amount)
            # if initialization phase is past
            sp.else:
                self.data.phase = 1
                self.buy_slope(sp.amount)

You can see that we do not allow any action to take place after the closing phase.

We check for excess in two different instances:

  • The excess in the buy() function, and
  • The excess in the buy_initial and buy_slope function.

The excess check in the buy() function makes sure that a buy transaction does not surpass the MFG. The excess in the buy_initial and buy_slope function calculates the excess amount of tez after a buy transaction, so to say, your change.

Initial phase - Pre-MFG

The buy price is determined during the initial phase by the following lines in the contract code:

    # initial phase, the price is fix
    def buy_initial(self, amount):
        # calculate amount of tokens from sp.amount and the price
        token_amount = sp.local(
            "token_amount", 
            sp.ediv(
                amount, 
                self.data.price
                ).open_some("Fatal Error: Price is zero")
            )

In this phase, the calculation is token_amount = amount/self.data.price, where amount represents the number of tokens (in tez) sent with the transaction and self.data.price is the current price of the token.

After the calculation, buy_initial checks if any tokens can be issued. Then it adds the calculated number of tokens to the ledger linking them to the transaction sender. In case no record of the user exists, it creates an entry. The comments in the code should suffice as an explanation:

        # fail if no tokens can be issued with this amount of tez
        sp.if sp.fst(token_amount.value) == sp.as_nat(0):
            sp.failwith("No token can be issued, please send more tez")
            
        # check if the address owns tokens
        sp.if self.data.ledger.contains(sp.sender):
            # add amount of the tokens into the ledger
            self.data.ledger[sp.sender] += sp.fst(token_amount.value)
        sp.else:
            # put amount of the tokens into the ledger
            self.data.ledger[sp.sender] = sp.fst(token_amount.value)
            
        # increase total amount of the tokens
        self.data.total_tokens += sp.fst(token_amount.value)

        # keep received funds in this contract as buyback reserve
        # but send back the excess
        sp.if sp.utils.mutez_to_nat(sp.snd(token_amount.value)) > 0:
            sp.send(sp.sender, sp.snd(token_amount.value))

        # track how much is invested
        self.data.total_investment = self.data.total_investment + amount - sp.snd(token_amount.value)

Before calculating the recent value of the total investments, the excess is calculated and sent back to the user.

Post-MFG phase

Now, let us have a look at the post-MFG phase, so to say the "slopy phase" for the buy() function.

This time the calculation for the token_amount is more complex because of the linear price increase. We have to calculate the area of the trapezium under the price function.

Calculating the buy price post-MFG
Calculating the buy price post-MFG

Next, the contract does a reserve calculation to determine the amount of the excess, which has to be sent back to the transaction sender. In addition, each time someone buys a token, a part of the tez amount in the transaction is kept for the buyback reserve:

    # after initial phase, the price will increase
    def buy_slope(self, amount):
        # calculate amount of tokens from amount of tez
        # see https://github.com/C-ORG/whitepaper#buy-calculus

        token_amount = sp.local(
            "token_amount", 
            self.square_root(
                2 * sp.utils.mutez_to_nat(amount) /self.data.buy_slope + 
                self.data.total_tokens * self.data.total_tokens
                ) - self.data.total_tokens
            )

        tez_amount = sp.local(
            "tez_amount",
            sp.as_nat(token_amount.value) * self.data.total_tokens * self.data.buy_slope/2 + 
            (sp.as_nat(token_amount.value) + self.data.total_tokens) * sp.as_nat(token_amount.value) * self.data.buy_slope/2
            )

        send_back = sp.local(
            "send_back",
            amount - sp.utils.nat_to_mutez(tez_amount.value)
            )
        # send tez that is too much
        sp.if sp.utils.mutez_to_nat(send_back.value) > 0:
            sp.send(sp.sender, send_back.value)

        # track how much is invested
        self.data.total_investment += sp.utils.nat_to_mutez(tez_amount.value)

        # fail if no tokens can be issued with this amount of tez
        sp.if sp.as_nat(token_amount.value) == sp.as_nat(0):
            sp.failwith("No token can be issued, please send more tez")
            
        # calculate buyback reserve from amount I*amount/100
        buyback_reserve = sp.local(
            "buyback_reserve", 
            sp.utils.nat_to_mutez(self.data.I * tez_amount.value / sp.as_nat(100))
            )
        
        company_pay = sp.local(
            "company_pay",
            amount - buyback_reserve.value
            )

        # send (100-I) * amount/100 of the received tez to the organization
        sp.send(self.data.organization, company_pay.value)
        # this will keep I * amount/100 in this contract as buyback reserve
            
        # check if the address owns tokens
        sp.if self.data.ledger.contains(sp.sender):
            self.data.ledger[sp.sender] += sp.as_nat(token_amount.value)
        sp.else:
            self.data.ledger[sp.sender] = sp.as_nat(token_amount.value)
                 
        # increase total amount of the tokens
        self.data.total_tokens += sp.as_nat(token_amount.value)

        # set new price
        self.data.price = sp.utils.nat_to_mutez(self.data.buy_slope * self.data.total_tokens)
        self.modify_sell_slope(send_back.value + company_pay.value)
info icon

At the end of a buy_slope call, the contract updates the sell slope. This is something we address in the next section.

The pay() function

A user can call the pay() entrypoint to send a payment to the organization via the smart contract. If pay() is called, the smart contract issues new tokens and by default sends them to the organization.

We allow payments in the slopy phase:

   @sp.entry_point
    def pay(self):
        # check that the initial phase is over but not closed
        sp.verify(self.data.phase == 1)
        # see https://github.com/C-ORG/whitepaper#-revenues---pay
        buyback_reserve = sp.local(
            "local_amount", 
            sp.utils.nat_to_mutez(
                sp.utils.mutez_to_nat(sp.amount) * self.data.revenues_ratio_for_reserve / 100
                )
            )
        # send sp.amount - buyback_reserve to organization
        amount_to_sent = sp.amount - buyback_reserve.value
        sp.send(self.data.organization, amount_to_sent)

        # create the same amount of tokens a buy call would do
        token_amount = sp.local(
        "token_amount", 
        self.square_root(
            2 * sp.utils.mutez_to_nat(amount_to_sent) /self.data.buy_slope + 
            self.data.total_tokens * self.data.total_tokens
            ) - self.data.total_tokens
        )

        # give those tokens to the organization
        self.data.ledger[self.data.organization] = sp.as_nat(token_amount.value)
                
        # increase total amount of the tokens
        self.data.total_tokens += sp.as_nat(token_amount.value)

You can see, it mints the same number of tokens as if buy() would have been called. A part of the tez sent with the call is kept in the contract:

        # send sp.amount - buyback_reserve to organization
        amount_to_sent = sp.amount - buyback_reserve.value
        sp.send(self.data.organization, amount_to_sent)

Let us now take a look at the sell() function.

Discuss on Slack
Rate this Page
Would you like to add a message?
Submit
Thank you for your Feedback!