Smart Contract Implementation I
Implementing the buy function
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.
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)
)
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()
, andclose()
.
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.
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:
- The initial phase: Before the minimum funding goal (MFG) is reached, the buy price is constant;
- 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
andbuy_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.

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)
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.