Proof of Chance: A Fair Launch Smart Contract

Introduction

It's difficult to ensure that all users have equal access to purchase or claim assets at launch.

The fundamental issue, as I see it, is that there's no way to link an account or a set of accounts to an individual, without destroying privacy.

We don't have a universal ZK identity solution yet.

I thought about implementing some kind of PoW/PoS contract for releasing assets, but either of those would tend towards a plutocratic distribution.

So I'm proposing a game of chance.

Proof of Chance

Assuming there are more claimants than assets, we need some way to allow only a subset of participants to make a purchase.

If we base the selection on accounts, then the individual with the most accounts has the greatest chance of being eligible.

If we base it on balance or stake, then the richest individual has the greatest chance.

But some combination of these two might get us closer to the goal.

If we impose a minimum balance requirement per participating account, we at least make it more difficult for bots to spam the mint.

The general idea I've come up with is to allow an account to claim an asset if it has sufficient balance, and the hash of its address + the previous hash in the contract is less than or equal to the target number.

The target number doubles at each interval (a certain number of rounds) until the next asset is minted, at which point it resets to the maximum difficulty.

This means that the chance of some account a being eligible doubles with each interval that passes.

If the maximum difficulty is high enough, it should be infeasible for a malicious actor to:

  • Have enough pre-funded accounts to substantially tip the odds in their favour

  • Generate and fund a new account that has a hash lower than the target, quicker than someone who already has such an account can claim the asset

The likelihood of a fair distribution increases with the number of participants.

Eligibility

To withdraw an asset from the contract, user a must prove that they have sufficient balance, and that the hash of their address and the previous hash digest is <= the target.

The contract creator provides an initial genesis hash G, so we can define the nth hash as:

$$\displaystyle {h}{n} = \begin{cases} H{\left(a + G \right)} & \text{for}\: n = 0 \\H{\left(a + {h}{n - 1} \right)} & \text{otherwise} \end{cases}$$

Let's start implementing this in Algorand Python:

class FairLaunch(ARC4Contract):
    """A contract for fairly launching an NFT project."""

    @arc4.abimethod(create="require")
    def new(self, *, genesis_hash: Bytes) -> None:
        self.previous_hash = genesis_hash

    @arc4.abimethod
    def calculate_hash(self) -> Bytes:
        return op.sha256(Txn.sender + self.previous_hash)

Next, let's think about the target number that a hash must be less than or equal to.

To take a simplified example, imagine we have an unsigned 8-bit target number.

The easiest possible target is the highest possible number (255), which is 11111111 in binary.

Assuming we also have an 8-bit hash, any hash will be <= the target, meaning anyone can claim an asset.

To make it more difficult, the contract creator provides an integer called zero_bits, which represents the maximum number of leading zeroes the target can have at any given time.

Each additional zero bit makes it twice as difficult, or twice as unlikely for a random address a to be <= the target:

TargetRatio of Values <= Target
11111111256/256 or 1
01111111128/256 or 1/2
0011111164/256 or 1/4
......

Instead of having a static target, the contract is going to linearly decrease the number of zero bits at some defined interval until an asset is released:

Which is the same as saying that the difficulty will halve at each interval:

Let's get back to programming so this starts to make sense!

First, we're going to define the target limit:

TARGET_LIMIT = 2**64-1

Unlike the simplified 8-bit example shown previously, we're using UInt64 in the contract.

The contract creator is going to provide some additional parameters, which are explained in the code comments below:

class FairLaunch(ARC4Contract):
    """A contract for fairly launching an NFT project."""

    @arc4.abimethod(create="require")
    def new(
        self, *, genesis_hash: Bytes, minimum_balance: UInt64, zero_bits: UInt64, difficulty_halving_interval: UInt64
    ) -> None:
        """Creates a new application.

        Args:
            genesis_hash (Bytes): The value to use for the first hash.
            minimum_balance (UInt64): The minimum amount of MicroAlgos an account must have to claim an asset.
            zero_bits (UInt64): The maximum number of leading zero bits the target number can have.
            difficulty_halving_interval (UInt64): The number of rounds that elapse between each difficulty halving.
        """
        assert zero_bits < 64, "`zero_bits` must be < 64"

        self.previous_hash = genesis_hash
        self.minimum_balance = minimum_balance
        self.zero_bits = zero_bits
        self.difficulty_halving_interval = difficulty_halving_interval
        self.last_round = Global.round

We also set an initial value for last_round, which is needed to work out the number of rounds that have elapsed since the last asset was released:

$$\displaystyle {elapsed\_rounds} = {current\_round} - {last\_round}$$

The number of halvings is:

$$\displaystyle halvings = \left\lfloor{\frac{{elapsed\_rounds}}{{difficulty\_halving\_interval}}}\right\rfloor$$

And the target is simply TARGET_LIMIT >> zero_bits - halvings.

So putting it all into code:

@arc4.abimethod
def calculate_target(
    self, zero_bits: UInt64, difficulty_halving_interval: UInt64, last_round: UInt64, at_round: UInt64
) -> UInt64:
    halvings = (at_round - last_round) // difficulty_halving_interval
    return UInt64(TARGET_LIMIT) if halvings > zero_bits else UInt64(TARGET_LIMIT) >> zero_bits - halvings

Claiming an Asset

Finally, we need a method to validate a user's claim and transfer an asset:

@arc4.abimethod
def claim(self, asset: Asset) -> None:
    assert Txn.sender.balance >= self.minimum_balance, "Sender's balance is below the minimum requirement"
    new_hash = op.sha256(Txn.sender.bytes + self.previous_hash)
    assert op.extract_uint64(new_hash, 0) < self.calculate_target(
        self.zero_bits, self.difficulty_halving_interval, self.last_round, Global.round
    )
    itxn.AssetTransfer(
        xfer_asset=asset,
        asset_amount=1,
        asset_receiver=Txn.sender,
        fee=0
    ).submit()
    self.previous_hash = new_hash
    self.last_round = Global.round

We start by checking that the user's account balance is >= the minimum set by the contract creator.

Next, we check that the hash is lower than the target.

The hash is truncated to the first 8 bytes (64 bits), because the target number is a UInt64.

If the checks pass, we submit an inner transaction to transfer the requested asset to the user, and update the application's global state.

The Complete Contract Code

The full example contract is below and available on GitHub.

You would probably need to extend it with additional methods for real use, to cater for things like minting assets or opting the contract in to receive assets.

from algopy import ARC4Contract, Asset, Bytes, Global, Txn, UInt64, arc4, itxn, op

TARGET_LIMIT = 2**64 - 1


class FairLaunch(ARC4Contract):
    """A contract for fairly launching an NFT project."""

    @arc4.abimethod(create="require")
    def new(
        self, *, genesis_hash: Bytes, minimum_balance: UInt64, zero_bits: UInt64, difficulty_halving_interval: UInt64
    ) -> None:
        """Creates a new application.

        Args:
            genesis_hash (Bytes): The value to use for the first hash.
            minimum_balance (UInt64): The minimum amount of MicroAlgos an account must have to claim an asset.
            zero_bits (UInt64): The maximum number of leading zero bits the target number can have.
            difficulty_halving_interval (UInt64): The number of rounds that elapse between each difficulty halving.
        """
        assert zero_bits < 64, "`zero_bits` must be < 64"

        self.previous_hash = genesis_hash
        self.minimum_balance = minimum_balance
        self.zero_bits = zero_bits
        self.difficulty_halving_interval = difficulty_halving_interval
        self.last_round = Global.round

    @arc4.abimethod
    def calculate_target(
        self, zero_bits: UInt64, difficulty_halving_interval: UInt64, last_round: UInt64, at_round: UInt64
    ) -> UInt64:
        """Calculate the target number that the hash must be less than or equal to.

        Args:
            zero_bits (UInt64): The maximum number of leading zero bits the target number can have.
            difficulty_halving_interval (UInt64): The number of rounds that elapse between each difficulty halving.
            last_round (UInt64): The last round an asset was released at.
            at_round (UInt64): The round that the target is calculated 'as at'.

        Returns:
            UInt64: The target number.
        """
        halvings = (at_round - last_round) // difficulty_halving_interval
        return UInt64(TARGET_LIMIT) if halvings > zero_bits else UInt64(TARGET_LIMIT) >> zero_bits - halvings

    @arc4.abimethod
    def claim(self, asset: Asset) -> None:
        """Transfers the requested asset to the claimant, if they are eligible to receive it.

        Args:
            asset (Asset): The requested asset.
        """
        assert Txn.sender.balance >= self.minimum_balance, "Sender's balance is below the minimum requirement"
        new_hash = op.sha256(Txn.sender.bytes + self.previous_hash)
        assert op.extract_uint64(new_hash, 0) < self.calculate_target(
            self.zero_bits, self.difficulty_halving_interval, self.last_round, Global.round
        )
        itxn.AssetTransfer(xfer_asset=asset, asset_amount=1, asset_receiver=Txn.sender, fee=0).submit()
        self.previous_hash = new_hash
        self.last_round = Global.round

Writing Tests

The tests below reconcile the smart contract's calculate_target() method with a reference implementation in sympy.

The tests are not exhaustive - please only use this as a starting point for your own exploration.

def target_reference(p_difficulty_halving_interval: int, p_zero_bits: int, p_at_round: int) -> int:
    """Target calculation reference implementation in sympy."""

    at_round, last_round, difficulty_halving_interval = symbols(
        r"{at\_round} {last\_round} {difficulty\_halving\_interval}", integer=True, nonnegative=True
    )

    halvings = (at_round - last_round) // difficulty_halving_interval

    zero_bits = symbols(r"{zero\_bits}", integer=True, nonnegative=True)

    TARGET_LIMIT = 2**64 - 1

    target_expr = Piecewise((TARGET_LIMIT, halvings > zero_bits), (TARGET_LIMIT // 2 ** (zero_bits - halvings), True))

    return int(
        target_expr.subs(
            [
                (difficulty_halving_interval, p_difficulty_halving_interval),
                (zero_bits, p_zero_bits),
                (last_round, 0),
                (at_round, p_at_round),
            ]
        )
    )


@pytest.mark.parametrize("p_difficulty_halving_interval, p_zero_bits, p_halvings", [(1, 8, 10), (1, 63, 70)])
def test_calculate_target(
    app_client: FairLaunchClient, p_difficulty_halving_interval: int, p_zero_bits: int, p_halvings: int
) -> None:
    """Tests the calculate_target() method against a reference implementation in sympy."""

    for r in range(p_halvings + 1):
        ref_amount = target_reference(p_difficulty_halving_interval, p_zero_bits, p_at_round=r)

        amount = app_client.calculate_target(
            zero_bits=p_zero_bits, difficulty_halving_interval=p_difficulty_halving_interval, last_round=0, at_round=r
        ).return_value

        assert ref_amount == amount, "Target does not match sympy reference calculation"

Also available on GitHub.