Module baking_swap

Expand source code
import smartpy as sp


@sp.module
def main():
    class BakingSwap(sp.Contract):
        """A contract that takes tez deposits and pays interest.

        The deposited funds cannot leave the contract, but the administrator can
        delegate them for baking.

        In more detail:

        - The administrator funds the contract with collateral.

        - The administrator publishes an offer: a rate (in basis points) and a
        duration (in days).

        - For each deposit the amount to be paid out and the due date are recorded.
        The corresponding amount of collateral is locked up.

        - At maturity the deposit plus interest can be withdrawn.
        """

        def __init__(self, admin, initialRate, initialDuration):
            """Constructor

            Args:
                admin (sp.address): admin of the contract.
                initialRate (sp.nat): Basis points to compute the interest.
                initialDuration (sp.nat): Number of days before a deposit can be
                    withdrawn.
            """
            self.data.admin = admin
            self.data.collateral = sp.mutez(0)
            self.data.ledger = {}
            self.data.rate = initialRate
            self.data.duration = initialDuration

        # Admin-only entrypoints

        @sp.entrypoint
        def delegate(self, public_key_hash):
            """Admin-only. Delegate the contract's balance to the admin.

            Args:
                public_key_hash (sp.key_hash): public key hash of the admin.
            """
            assert sp.sender == self.data.admin
            assert sp.amount == sp.mutez(0)
            assert sp.sender == sp.to_address(sp.implicit_account(public_key_hash))
            sp.set_delegate(sp.Some(public_key_hash))

        @sp.entrypoint
        def collateralize(self):
            """Admin-only. Provide tez as collateral for interest to be paid."""
            assert sp.sender == self.data.admin
            self.data.collateral += sp.amount

        @sp.entrypoint
        def uncollateralize(self, amount, receiver):
            """Admin-only. Withdraw collateral.

            Transfer `amount` mutez to admin if it doesn't exceed collateral.

            Args:

                amount (sp.mutez): Amount to be removed from the collateral.
            """
            assert sp.sender == self.data.admin
            # Explicitly fails for insufficient collateral.
            assert amount <= self.data.collateral, "insufficient collateral"
            self.data.collateral -= amount
            sp.send(receiver, amount)

        @sp.entrypoint
        def set_offer(self, rate, duration):
            """Admin-only. Set the current offer: interest rate (in basis points)
            and duration.

            Args:
                rate (sp.nat): Basis points to compute the interest.
                duration (sp.nat): Number of days before a deposit can be withdrawn.
            """
            assert sp.sender == self.data.admin
            assert sp.amount == sp.mutez(0)
            self.data.rate = rate
            self.data.duration = duration

        # Permissionless entrypoints

        @sp.entrypoint
        def deposit(self, rate, duration):
            """Deposit tez. The current offer has to be repeated in the parameters.

            Args:
                rate (sp.nat): Basis points to compute the interest.
                duration (sp.nat): Number of days before a deposit can be withdrawn.
            """
            assert self.data.rate >= rate
            assert self.data.duration <= duration
            assert not self.data.ledger.contains(sp.sender)

            # Compute interest to be paid.
            interest = sp.split_tokens(sp.amount, self.data.rate, 10_000)
            self.data.collateral -= interest

            # Record the payment to be made.
            self.data.ledger[sp.sender] = sp.record(
                amount=sp.amount + interest,
                due=sp.add_days(sp.now, self.data.duration),
            )

        @sp.entrypoint
        def withdraw(self, receiver):
            """Withdraw tez at maturity."""
            assert sp.amount == sp.mutez(0)
            entry = self.data.ledger.get(sp.sender, error="NoDeposit")
            assert sp.now >= entry.due
            sp.send(receiver, entry.amount)
            del self.data.ledger[sp.sender]


@sp.module
def testing():
    class Receiver(sp.Contract):
        @sp.entrypoint
        def default(self):
            pass


if "templates" not in __name__:
    admin = sp.test_account("Admin")
    non_admin = sp.test_account("non_admin")
    voting_powers = {
        admin.public_key_hash: 0,
    }

    @sp.add_test(name="Baking swap basic scenario", is_default=True)
    def basic_scenario():
        scenario = sp.test_scenario(main)
        scenario.h1("Baking Swap")
        c = main.BakingSwap(admin.address, 700, 365)
        scenario += c

        c.delegate(admin.public_key_hash).run(sender=admin, voting_powers=voting_powers)

    @sp.add_test(name="Full")
    def test():
        sc = sp.test_scenario([main, testing])
        sc.h1("Full test")
        sc.h2("Origination")
        c = main.BakingSwap(admin.address, 0, 10000)
        sc += c
        sc.h2("Delegator")
        delegator = testing.Receiver()
        sc += delegator
        sc.h2("Admin receiver")
        admin_receiver = testing.Receiver()
        sc += admin_receiver

        sc.h2("delegate")
        c.delegate(admin.public_key_hash).run(sender=admin, voting_powers=voting_powers)
        sc.verify(c.baker == sp.some(admin.public_key_hash))
        sc.h3("Failures")
        c.delegate(admin.public_key_hash).run(
            sender=non_admin,
            voting_powers=voting_powers,
            valid=False,
            exception="WrongCondition: sp.sender == self.data.admin",
        )
        c.delegate(admin.public_key_hash).run(
            sender=admin,
            amount=sp.mutez(1),
            voting_powers=voting_powers,
            valid=False,
            exception="WrongCondition: sp.amount == sp.tez(0)",
        )
        c.delegate(non_admin.public_key_hash).run(
            sender=admin,
            voting_powers=voting_powers,
            valid=False,
            exception="WrongCondition: sp.sender == sp.to_address(sp.implicit_account(params))",
        )

        sc.h2("collateralize")
        c.collateralize().run(sender=admin, amount=sp.tez(500))
        sc.verify(c.data.collateral == sp.tez(500))
        sc.h3("Failures")
        c.collateralize().run(
            sender=non_admin,
            amount=sp.tez(500),
            valid=False,
            exception="WrongCondition: sp.sender == self.data.admin",
        )

        sc.h2("set_offer")
        c.set_offer(rate=1000, duration=365).run(sender=admin)
        sc.h3("Failures")
        c.set_offer(rate=1000, duration=365).run(
            sender=non_admin,
            valid=False,
            exception="WrongCondition: sp.sender == self.data.admin",
        )
        c.set_offer(rate=1000, duration=365).run(
            sender=admin,
            amount=sp.mutez(1),
            valid=False,
            exception="WrongCondition: sp.amount == sp.tez(0)",
        )

        sc.h2("deposit")
        c.deposit(rate=1000, duration=365).run(
            sender=delegator.address, amount=sp.tez(100)
        )
        sc.verify(c.data.collateral == sp.tez(490))
        sc.verify(
            c.data.ledger[delegator.address]
            == sp.record(amount=sp.tez(110), due=sp.timestamp(365 * 24 * 3600))
        )
        sc.h3("Failures")
        c.deposit(rate=1001, duration=365).run(
            sender=delegator.address,
            amount=sp.tez(100),
            valid=False,
            exception="WrongCondition: self.data.rate >= params.rate",
        )
        c.deposit(rate=1000, duration=364).run(
            sender=delegator.address,
            amount=sp.tez(100),
            valid=False,
            exception="WrongCondition: self.data.duration <= params.duration",
        )
        c.deposit(rate=1000, duration=365).run(
            sender=delegator.address,
            amount=sp.tez(100),
            valid=False,
            exception="WrongCondition: not (self.data.ledger.contains(sp.sender))",
        )

        sc.h2("uncollateralize")
        sc.h3("Failures")
        c.uncollateralize(amount=sp.tez(500), receiver=admin_receiver.address).run(
            sender=admin, valid=False, exception="insufficient collateral"
        )
        c.uncollateralize(amount=sp.tez(490), receiver=admin_receiver.address).run(
            sender=non_admin,
            valid=False,
            exception="WrongCondition: sp.sender == self.data.admin",
        )
        sc.h3("Valid")
        c.uncollateralize(amount=sp.tez(490), receiver=admin_receiver.address).run(
            sender=admin
        )
        sc.verify(c.data.collateral == sp.tez(0))
        sc.verify(admin_receiver.balance == sp.tez(490))

        sc.h2("withdraw")
        sc.h3("Failures")
        c.withdraw(delegator.address).run(
            sender=delegator.address,
            amount=sp.mutez(1),
            now=sp.timestamp(365 * 24 * 3600),
            valid=False,
            exception="WrongCondition: sp.amount == sp.tez(0)",
        )
        c.withdraw(delegator.address).run(
            sender=delegator.address,
            now=sp.timestamp(365 * 24 * 3600 - 1),
            valid=False,
            exception="WrongCondition: sp.now >= entry.due",
        )
        sc.h3("Valid")
        c.withdraw(delegator.address).run(
            sender=delegator.address, now=sp.timestamp(365 * 24 * 3600)
        )
        sc.verify(delegator.balance == sp.tez(110))
        sc.verify(~c.data.ledger.contains(delegator.address))
        sc.h3("Failures")
        c.withdraw(delegator.address).run(
            sender=delegator.address,
            valid=False,
            now=sp.timestamp(365 * 24 * 3600),
            exception="NoDeposit",
        )

    # @sp.add_test(name="Mutation", is_default=False)
    # def test():
    #     s = sp.test_scenario()
    #     with s.mutation_test() as mt:
    #         mt.add_scenario("Full")