├── auction ├── __init__.py ├── testing │ ├── __init__.py │ ├── setup_test.py │ ├── setup.py │ └── resources.py ├── account.py ├── util.py ├── operations.py ├── contracts.py └── operations_test.py ├── requirements.txt ├── mypy.ini ├── .travis.yml ├── sandbox ├── README.md ├── .gitignore └── example.py /auction/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /auction/testing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyteal==0.9.1 2 | py-algorand-sdk==1.8.0 3 | mypy==0.910 4 | pytest 5 | black==21.7b0 6 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | 3 | [mypy-pytest.*] 4 | ignore_missing_imports = True 5 | 6 | [mypy-algosdk.*] 7 | ignore_missing_imports = True 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: focal 2 | language: python 3 | 4 | python: 5 | - 3.9 6 | 7 | install: 8 | - pip install -r requirements.txt 9 | 10 | before_script: 11 | - ./sandbox up -v 12 | 13 | script: 14 | - black --check . 15 | - pytest 16 | -------------------------------------------------------------------------------- /sandbox: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | PARENT_DIR=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) 5 | SANDBOX_DIR=$PARENT_DIR/_sandbox 6 | if [ ! -d "$SANDBOX_DIR" ]; then 7 | echo "Pulling sandbox..." 8 | git clone https://github.com/algorand/sandbox.git $SANDBOX_DIR 9 | fi 10 | 11 | $SANDBOX_DIR/sandbox "$@" 12 | -------------------------------------------------------------------------------- /auction/account.py: -------------------------------------------------------------------------------- 1 | from algosdk import account, mnemonic 2 | 3 | 4 | class Account: 5 | """Represents a private key and address for an Algorand account""" 6 | 7 | def __init__(self, privateKey: str) -> None: 8 | self.sk = privateKey 9 | self.addr = account.address_from_private_key(privateKey) 10 | 11 | def getAddress(self) -> str: 12 | return self.addr 13 | 14 | def getPrivateKey(self) -> str: 15 | return self.sk 16 | 17 | def getMnemonic(self) -> str: 18 | return mnemonic.from_private_key(self.sk) 19 | 20 | @classmethod 21 | def FromMnemonic(cls, m: str) -> "Account": 22 | return cls(mnemonic.to_private_key(m)) 23 | -------------------------------------------------------------------------------- /auction/testing/setup_test.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from algosdk.v2client.algod import AlgodClient 4 | from algosdk.kmd import KMDClient 5 | from algosdk import encoding 6 | 7 | from .setup import getAlgodClient, getKmdClient, getGenesisAccounts 8 | 9 | 10 | def test_getAlgodClient(): 11 | client = getAlgodClient() 12 | assert isinstance(client, AlgodClient) 13 | 14 | response = client.health() 15 | assert response is None 16 | 17 | 18 | def test_getKmdClient(): 19 | client = getKmdClient() 20 | assert isinstance(client, KMDClient) 21 | 22 | response = client.versions() 23 | expected = ["v1"] 24 | assert response == expected 25 | 26 | 27 | def test_getGenesisAccounts(): 28 | accounts = getGenesisAccounts() 29 | 30 | assert len(accounts) == 3 31 | assert all(encoding.is_valid_address(account.getAddress()) for account in accounts) 32 | assert all( 33 | len(base64.b64decode(account.getPrivateKey())) == 64 for account in accounts 34 | ) 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Algorand Auction Demo 2 | 3 | This demo is an on-chain NFT auction using smart contracts on the Algorand blockchain. 4 | 5 | ## Usage 6 | 7 | The file `auction/operations.py` provides a set of functions that can be used to create and interact 8 | with auctions. See that file for documentation. 9 | 10 | ## Development Setup 11 | 12 | This repo requires Python 3.6 or higher. We recommend you use a Python virtual environment to install 13 | the required dependencies. 14 | 15 | Set up venv (one time): 16 | * `python3 -m venv venv` 17 | 18 | Active venv: 19 | * `. venv/bin/activate` (if your shell is bash/zsh) 20 | * `. venv/bin/activate.fish` (if your shell is fish) 21 | 22 | Install dependencies: 23 | * `pip install -r requirements.txt` 24 | 25 | Run tests: 26 | * First, start an instance of [sandbox](https://github.com/algorand/sandbox) (requires Docker): `./sandbox up nightly` 27 | * `pytest` 28 | * When finished, the sandbox can be stopped with `./sandbox down` 29 | 30 | Format code: 31 | * `black .` 32 | -------------------------------------------------------------------------------- /auction/testing/setup.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | 3 | from algosdk.v2client.algod import AlgodClient 4 | from algosdk.kmd import KMDClient 5 | 6 | from ..account import Account 7 | 8 | ALGOD_ADDRESS = "http://localhost:4001" 9 | ALGOD_TOKEN = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 10 | 11 | 12 | def getAlgodClient() -> AlgodClient: 13 | return AlgodClient(ALGOD_TOKEN, ALGOD_ADDRESS) 14 | 15 | 16 | KMD_ADDRESS = "http://localhost:4002" 17 | KMD_TOKEN = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 18 | 19 | 20 | def getKmdClient() -> KMDClient: 21 | return KMDClient(KMD_TOKEN, KMD_ADDRESS) 22 | 23 | 24 | KMD_WALLET_NAME = "unencrypted-default-wallet" 25 | KMD_WALLET_PASSWORD = "" 26 | 27 | kmdAccounts: Optional[List[Account]] = None 28 | 29 | 30 | def getGenesisAccounts() -> List[Account]: 31 | global kmdAccounts 32 | 33 | if kmdAccounts is None: 34 | kmd = getKmdClient() 35 | 36 | wallets = kmd.list_wallets() 37 | walletID = None 38 | for wallet in wallets: 39 | if wallet["name"] == KMD_WALLET_NAME: 40 | walletID = wallet["id"] 41 | break 42 | 43 | if walletID is None: 44 | raise Exception("Wallet not found: {}".format(KMD_WALLET_NAME)) 45 | 46 | walletHandle = kmd.init_wallet_handle(walletID, KMD_WALLET_PASSWORD) 47 | 48 | try: 49 | addresses = kmd.list_keys(walletHandle) 50 | privateKeys = [ 51 | kmd.export_key(walletHandle, KMD_WALLET_PASSWORD, addr) 52 | for addr in addresses 53 | ] 54 | kmdAccounts = [Account(sk) for sk in privateKeys] 55 | finally: 56 | kmd.release_wallet_handle(walletHandle) 57 | 58 | return kmdAccounts 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Compiled smart contracts 132 | *.teal 133 | *.teal.tok 134 | 135 | _sandbox/ 136 | -------------------------------------------------------------------------------- /auction/testing/resources.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from random import choice, randint 3 | 4 | from algosdk.v2client.algod import AlgodClient 5 | from algosdk.future import transaction 6 | from algosdk import account 7 | 8 | from ..account import Account 9 | from ..util import PendingTxnResponse, waitForTransaction 10 | from .setup import getGenesisAccounts 11 | 12 | 13 | def payAccount( 14 | client: AlgodClient, sender: Account, to: str, amount: int 15 | ) -> PendingTxnResponse: 16 | txn = transaction.PaymentTxn( 17 | sender=sender.getAddress(), 18 | receiver=to, 19 | amt=amount, 20 | sp=client.suggested_params(), 21 | ) 22 | signedTxn = txn.sign(sender.getPrivateKey()) 23 | 24 | client.send_transaction(signedTxn) 25 | return waitForTransaction(client, signedTxn.get_txid()) 26 | 27 | 28 | FUNDING_AMOUNT = 100_000_000 29 | 30 | 31 | def fundAccount( 32 | client: AlgodClient, address: str, amount: int = FUNDING_AMOUNT 33 | ) -> PendingTxnResponse: 34 | fundingAccount = choice(getGenesisAccounts()) 35 | return payAccount(client, fundingAccount, address, amount) 36 | 37 | 38 | accountList: List[Account] = [] 39 | 40 | 41 | def getTemporaryAccount(client: AlgodClient) -> Account: 42 | global accountList 43 | 44 | if len(accountList) == 0: 45 | sks = [account.generate_account()[0] for i in range(16)] 46 | accountList = [Account(sk) for sk in sks] 47 | 48 | genesisAccounts = getGenesisAccounts() 49 | suggestedParams = client.suggested_params() 50 | 51 | txns: List[transaction.Transaction] = [] 52 | for i, a in enumerate(accountList): 53 | fundingAccount = genesisAccounts[i % len(genesisAccounts)] 54 | txns.append( 55 | transaction.PaymentTxn( 56 | sender=fundingAccount.getAddress(), 57 | receiver=a.getAddress(), 58 | amt=FUNDING_AMOUNT, 59 | sp=suggestedParams, 60 | ) 61 | ) 62 | 63 | txns = transaction.assign_group_id(txns) 64 | signedTxns = [ 65 | txn.sign(genesisAccounts[i % len(genesisAccounts)].getPrivateKey()) 66 | for i, txn in enumerate(txns) 67 | ] 68 | 69 | client.send_transactions(signedTxns) 70 | 71 | waitForTransaction(client, signedTxns[0].get_txid()) 72 | 73 | return accountList.pop() 74 | 75 | 76 | def optInToAsset( 77 | client: AlgodClient, assetID: int, account: Account 78 | ) -> PendingTxnResponse: 79 | txn = transaction.AssetOptInTxn( 80 | sender=account.getAddress(), 81 | index=assetID, 82 | sp=client.suggested_params(), 83 | ) 84 | signedTxn = txn.sign(account.getPrivateKey()) 85 | 86 | client.send_transaction(signedTxn) 87 | return waitForTransaction(client, signedTxn.get_txid()) 88 | 89 | 90 | def createDummyAsset(client: AlgodClient, total: int, account: Account = None) -> int: 91 | if account is None: 92 | account = getTemporaryAccount(client) 93 | 94 | randomNumber = randint(0, 999) 95 | # this random note reduces the likelihood of this transaction looking like a duplicate 96 | randomNote = bytes(randint(0, 255) for _ in range(20)) 97 | 98 | txn = transaction.AssetCreateTxn( 99 | sender=account.getAddress(), 100 | total=total, 101 | decimals=0, 102 | default_frozen=False, 103 | manager=account.getAddress(), 104 | reserve=account.getAddress(), 105 | freeze=account.getAddress(), 106 | clawback=account.getAddress(), 107 | unit_name=f"D{randomNumber}", 108 | asset_name=f"Dummy {randomNumber}", 109 | url=f"https://dummy.asset/{randomNumber}", 110 | note=randomNote, 111 | sp=client.suggested_params(), 112 | ) 113 | signedTxn = txn.sign(account.getPrivateKey()) 114 | 115 | client.send_transaction(signedTxn) 116 | 117 | response = waitForTransaction(client, signedTxn.get_txid()) 118 | assert response.assetIndex is not None and response.assetIndex > 0 119 | return response.assetIndex 120 | -------------------------------------------------------------------------------- /auction/util.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple, Dict, Any, Optional, Union 2 | from base64 import b64decode 3 | 4 | from algosdk.v2client.algod import AlgodClient 5 | from algosdk import encoding 6 | 7 | from pyteal import compileTeal, Mode, Expr 8 | 9 | from .account import Account 10 | 11 | 12 | class PendingTxnResponse: 13 | def __init__(self, response: Dict[str, Any]) -> None: 14 | self.poolError: str = response["pool-error"] 15 | self.txn: Dict[str, Any] = response["txn"] 16 | 17 | self.applicationIndex: Optional[int] = response.get("application-index") 18 | self.assetIndex: Optional[int] = response.get("asset-index") 19 | self.closeRewards: Optional[int] = response.get("close-rewards") 20 | self.closingAmount: Optional[int] = response.get("closing-amount") 21 | self.confirmedRound: Optional[int] = response.get("confirmed-round") 22 | self.globalStateDelta: Optional[Any] = response.get("global-state-delta") 23 | self.localStateDelta: Optional[Any] = response.get("local-state-delta") 24 | self.receiverRewards: Optional[int] = response.get("receiver-rewards") 25 | self.senderRewards: Optional[int] = response.get("sender-rewards") 26 | 27 | self.innerTxns: List[Any] = response.get("inner-txns", []) 28 | self.logs: List[bytes] = [b64decode(l) for l in response.get("logs", [])] 29 | 30 | 31 | def waitForTransaction( 32 | client: AlgodClient, txID: str, timeout: int = 10 33 | ) -> PendingTxnResponse: 34 | lastStatus = client.status() 35 | lastRound = lastStatus["last-round"] 36 | startRound = lastRound 37 | 38 | while lastRound < startRound + timeout: 39 | pending_txn = client.pending_transaction_info(txID) 40 | 41 | if pending_txn.get("confirmed-round", 0) > 0: 42 | return PendingTxnResponse(pending_txn) 43 | 44 | if pending_txn["pool-error"]: 45 | raise Exception("Pool error: {}".format(pending_txn["pool-error"])) 46 | 47 | lastStatus = client.status_after_block(lastRound + 1) 48 | 49 | lastRound += 1 50 | 51 | raise Exception( 52 | "Transaction {} not confirmed after {} rounds".format(txID, timeout) 53 | ) 54 | 55 | 56 | def fullyCompileContract(client: AlgodClient, contract: Expr) -> bytes: 57 | teal = compileTeal(contract, mode=Mode.Application, version=5) 58 | response = client.compile(teal) 59 | return b64decode(response["result"]) 60 | 61 | 62 | def decodeState(stateArray: List[Any]) -> Dict[bytes, Union[int, bytes]]: 63 | state: Dict[bytes, Union[int, bytes]] = dict() 64 | 65 | for pair in stateArray: 66 | key = b64decode(pair["key"]) 67 | 68 | value = pair["value"] 69 | valueType = value["type"] 70 | 71 | if valueType == 2: 72 | # value is uint64 73 | value = value.get("uint", 0) 74 | elif valueType == 1: 75 | # value is byte array 76 | value = b64decode(value.get("bytes", "")) 77 | else: 78 | raise Exception(f"Unexpected state type: {valueType}") 79 | 80 | state[key] = value 81 | 82 | return state 83 | 84 | 85 | def getAppGlobalState( 86 | client: AlgodClient, appID: int 87 | ) -> Dict[bytes, Union[int, bytes]]: 88 | appInfo = client.application_info(appID) 89 | return decodeState(appInfo["params"]["global-state"]) 90 | 91 | 92 | def getBalances(client: AlgodClient, account: str) -> Dict[int, int]: 93 | balances: Dict[int, int] = dict() 94 | 95 | accountInfo = client.account_info(account) 96 | 97 | # set key 0 to Algo balance 98 | balances[0] = accountInfo["amount"] 99 | 100 | assets: List[Dict[str, Any]] = accountInfo.get("assets", []) 101 | for assetHolding in assets: 102 | assetID = assetHolding["asset-id"] 103 | amount = assetHolding["amount"] 104 | balances[assetID] = amount 105 | 106 | return balances 107 | 108 | 109 | def getLastBlockTimestamp(client: AlgodClient) -> Tuple[int, int]: 110 | status = client.status() 111 | lastRound = status["last-round"] 112 | block = client.block_info(lastRound) 113 | timestamp = block["block"]["ts"] 114 | 115 | return block, timestamp 116 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | from time import time, sleep 2 | 3 | from algosdk import account, encoding 4 | from algosdk.logic import get_application_address 5 | from auction.operations import createAuctionApp, setupAuctionApp, placeBid, closeAuction 6 | from auction.util import ( 7 | getBalances, 8 | getAppGlobalState, 9 | getLastBlockTimestamp, 10 | ) 11 | from auction.testing.setup import getAlgodClient 12 | from auction.testing.resources import ( 13 | getTemporaryAccount, 14 | optInToAsset, 15 | createDummyAsset, 16 | ) 17 | 18 | 19 | def simple_auction(): 20 | client = getAlgodClient() 21 | 22 | print("Generating temporary accounts...") 23 | creator = getTemporaryAccount(client) 24 | seller = getTemporaryAccount(client) 25 | bidder = getTemporaryAccount(client) 26 | 27 | print("Alice (seller account):", seller.getAddress()) 28 | print("Bob (auction creator account):", creator.getAddress()) 29 | print("Carla (bidder account)", bidder.getAddress(), "\n") 30 | 31 | print("Alice is generating an example NFT...") 32 | nftAmount = 1 33 | nftID = createDummyAsset(client, nftAmount, seller) 34 | print("The NFT ID is", nftID) 35 | print("Alice's balances:", getBalances(client, seller.getAddress()), "\n") 36 | 37 | startTime = int(time()) + 10 # start time is 10 seconds in the future 38 | endTime = startTime + 30 # end time is 30 seconds after start 39 | reserve = 1_000_000 # 1 Algo 40 | increment = 100_000 # 0.1 Algo 41 | print("Bob is creating an auction that lasts 30 seconds to auction off the NFT...") 42 | appID = createAuctionApp( 43 | client=client, 44 | sender=creator, 45 | seller=seller.getAddress(), 46 | nftID=nftID, 47 | startTime=startTime, 48 | endTime=endTime, 49 | reserve=reserve, 50 | minBidIncrement=increment, 51 | ) 52 | print( 53 | "Done. The auction app ID is", 54 | appID, 55 | "and the escrow account is", 56 | get_application_address(appID), 57 | "\n", 58 | ) 59 | 60 | print("Alice is setting up and funding NFT auction...") 61 | setupAuctionApp( 62 | client=client, 63 | appID=appID, 64 | funder=creator, 65 | nftHolder=seller, 66 | nftID=nftID, 67 | nftAmount=nftAmount, 68 | ) 69 | print("Done\n") 70 | 71 | sellerBalancesBefore = getBalances(client, seller.getAddress()) 72 | sellerAlgosBefore = sellerBalancesBefore[0] 73 | print("Alice's balances:", sellerBalancesBefore) 74 | 75 | _, lastRoundTime = getLastBlockTimestamp(client) 76 | if lastRoundTime < startTime + 5: 77 | sleep(startTime + 5 - lastRoundTime) 78 | actualAppBalancesBefore = getBalances(client, get_application_address(appID)) 79 | print("Auction escrow balances:", actualAppBalancesBefore, "\n") 80 | 81 | bidAmount = reserve 82 | bidderBalancesBefore = getBalances(client, bidder.getAddress()) 83 | bidderAlgosBefore = bidderBalancesBefore[0] 84 | print("Carla wants to bid on NFT, her balances:", bidderBalancesBefore) 85 | print("Carla is placing bid for", bidAmount, "microAlgos") 86 | 87 | placeBid(client=client, appID=appID, bidder=bidder, bidAmount=bidAmount) 88 | 89 | print("Carla is opting into NFT with ID", nftID) 90 | 91 | optInToAsset(client, nftID, bidder) 92 | 93 | print("Done\n") 94 | 95 | _, lastRoundTime = getLastBlockTimestamp(client) 96 | if lastRoundTime < endTime + 5: 97 | waitTime = endTime + 5 - lastRoundTime 98 | print("Waiting {} seconds for the auction to finish\n".format(waitTime)) 99 | sleep(waitTime) 100 | 101 | print("Alice is closing out the auction\n") 102 | closeAuction(client, appID, seller) 103 | 104 | actualAppBalances = getBalances(client, get_application_address(appID)) 105 | expectedAppBalances = {0: 0} 106 | print("The auction escrow now holds the following:", actualAppBalances) 107 | assert actualAppBalances == expectedAppBalances 108 | 109 | bidderNftBalance = getBalances(client, bidder.getAddress())[nftID] 110 | assert bidderNftBalance == nftAmount 111 | 112 | actualSellerBalances = getBalances(client, seller.getAddress()) 113 | print("Alice's balances after auction: ", actualSellerBalances, " Algos") 114 | actualBidderBalances = getBalances(client, bidder.getAddress()) 115 | print("Carla's balances after auction: ", actualBidderBalances, " Algos") 116 | assert len(actualSellerBalances) == 2 117 | # seller should receive the bid amount, minus the txn fee 118 | assert actualSellerBalances[0] >= sellerAlgosBefore + bidAmount - 1_000 119 | assert actualSellerBalances[nftID] == 0 120 | 121 | 122 | simple_auction() 123 | -------------------------------------------------------------------------------- /auction/operations.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, List 2 | 3 | from algosdk.v2client.algod import AlgodClient 4 | from algosdk.future import transaction 5 | from algosdk.logic import get_application_address 6 | from algosdk import account, encoding 7 | 8 | from pyteal import compileTeal, Mode 9 | 10 | from .account import Account 11 | from .contracts import approval_program, clear_state_program 12 | from .util import ( 13 | waitForTransaction, 14 | fullyCompileContract, 15 | getAppGlobalState, 16 | ) 17 | 18 | APPROVAL_PROGRAM = b"" 19 | CLEAR_STATE_PROGRAM = b"" 20 | 21 | 22 | def getContracts(client: AlgodClient) -> Tuple[bytes, bytes]: 23 | """Get the compiled TEAL contracts for the auction. 24 | 25 | Args: 26 | client: An algod client that has the ability to compile TEAL programs. 27 | 28 | Returns: 29 | A tuple of 2 byte strings. The first is the approval program, and the 30 | second is the clear state program. 31 | """ 32 | global APPROVAL_PROGRAM 33 | global CLEAR_STATE_PROGRAM 34 | 35 | if len(APPROVAL_PROGRAM) == 0: 36 | APPROVAL_PROGRAM = fullyCompileContract(client, approval_program()) 37 | CLEAR_STATE_PROGRAM = fullyCompileContract(client, clear_state_program()) 38 | 39 | return APPROVAL_PROGRAM, CLEAR_STATE_PROGRAM 40 | 41 | 42 | def createAuctionApp( 43 | client: AlgodClient, 44 | sender: Account, 45 | seller: str, 46 | nftID: int, 47 | startTime: int, 48 | endTime: int, 49 | reserve: int, 50 | minBidIncrement: int, 51 | ) -> int: 52 | """Create a new auction. 53 | 54 | Args: 55 | client: An algod client. 56 | sender: The account that will create the auction application. 57 | seller: The address of the seller that currently holds the NFT being 58 | auctioned. 59 | nftID: The ID of the NFT being auctioned. 60 | startTime: A UNIX timestamp representing the start time of the auction. 61 | This must be greater than the current UNIX timestamp. 62 | endTime: A UNIX timestamp representing the end time of the auction. This 63 | must be greater than startTime. 64 | reserve: The reserve amount of the auction. If the auction ends without 65 | a bid that is equal to or greater than this amount, the auction will 66 | fail, meaning the bid amount will be refunded to the lead bidder and 67 | the NFT will return to the seller. 68 | minBidIncrement: The minimum different required between a new bid and 69 | the current leading bid. 70 | 71 | Returns: 72 | The ID of the newly created auction app. 73 | """ 74 | approval, clear = getContracts(client) 75 | 76 | globalSchema = transaction.StateSchema(num_uints=7, num_byte_slices=2) 77 | localSchema = transaction.StateSchema(num_uints=0, num_byte_slices=0) 78 | 79 | app_args = [ 80 | encoding.decode_address(seller), 81 | nftID.to_bytes(8, "big"), 82 | startTime.to_bytes(8, "big"), 83 | endTime.to_bytes(8, "big"), 84 | reserve.to_bytes(8, "big"), 85 | minBidIncrement.to_bytes(8, "big"), 86 | ] 87 | 88 | txn = transaction.ApplicationCreateTxn( 89 | sender=sender.getAddress(), 90 | on_complete=transaction.OnComplete.NoOpOC, 91 | approval_program=approval, 92 | clear_program=clear, 93 | global_schema=globalSchema, 94 | local_schema=localSchema, 95 | app_args=app_args, 96 | sp=client.suggested_params(), 97 | ) 98 | 99 | signedTxn = txn.sign(sender.getPrivateKey()) 100 | 101 | client.send_transaction(signedTxn) 102 | 103 | response = waitForTransaction(client, signedTxn.get_txid()) 104 | assert response.applicationIndex is not None and response.applicationIndex > 0 105 | return response.applicationIndex 106 | 107 | 108 | def setupAuctionApp( 109 | client: AlgodClient, 110 | appID: int, 111 | funder: Account, 112 | nftHolder: Account, 113 | nftID: int, 114 | nftAmount: int, 115 | ) -> None: 116 | """Finish setting up an auction. 117 | 118 | This operation funds the app auction escrow account, opts that account into 119 | the NFT, and sends the NFT to the escrow account, all in one atomic 120 | transaction group. The auction must not have started yet. 121 | 122 | The escrow account requires a total of 0.203 Algos for funding. See the code 123 | below for a breakdown of this amount. 124 | 125 | Args: 126 | client: An algod client. 127 | appID: The app ID of the auction. 128 | funder: The account providing the funding for the escrow account. 129 | nftHolder: The account holding the NFT. 130 | nftID: The NFT ID. 131 | nftAmount: The NFT amount being auctioned. Some NFTs has a total supply 132 | of 1, while others are fractional NFTs with a greater total supply, 133 | so use a value that makes sense for the NFT being auctioned. 134 | """ 135 | appAddr = get_application_address(appID) 136 | 137 | suggestedParams = client.suggested_params() 138 | 139 | fundingAmount = ( 140 | # min account balance 141 | 100_000 142 | # additional min balance to opt into NFT 143 | + 100_000 144 | # 3 * min txn fee 145 | + 3 * 1_000 146 | ) 147 | 148 | fundAppTxn = transaction.PaymentTxn( 149 | sender=funder.getAddress(), 150 | receiver=appAddr, 151 | amt=fundingAmount, 152 | sp=suggestedParams, 153 | ) 154 | 155 | setupTxn = transaction.ApplicationCallTxn( 156 | sender=funder.getAddress(), 157 | index=appID, 158 | on_complete=transaction.OnComplete.NoOpOC, 159 | app_args=[b"setup"], 160 | foreign_assets=[nftID], 161 | sp=suggestedParams, 162 | ) 163 | 164 | fundNftTxn = transaction.AssetTransferTxn( 165 | sender=nftHolder.getAddress(), 166 | receiver=appAddr, 167 | index=nftID, 168 | amt=nftAmount, 169 | sp=suggestedParams, 170 | ) 171 | 172 | transaction.assign_group_id([fundAppTxn, setupTxn, fundNftTxn]) 173 | 174 | signedFundAppTxn = fundAppTxn.sign(funder.getPrivateKey()) 175 | signedSetupTxn = setupTxn.sign(funder.getPrivateKey()) 176 | signedFundNftTxn = fundNftTxn.sign(nftHolder.getPrivateKey()) 177 | 178 | client.send_transactions([signedFundAppTxn, signedSetupTxn, signedFundNftTxn]) 179 | 180 | waitForTransaction(client, signedFundAppTxn.get_txid()) 181 | 182 | 183 | def placeBid(client: AlgodClient, appID: int, bidder: Account, bidAmount: int) -> None: 184 | """Place a bid on an active auction. 185 | 186 | Args: 187 | client: An Algod client. 188 | appID: The app ID of the auction. 189 | bidder: The account providing the bid. 190 | bidAmount: The amount of the bid. 191 | """ 192 | appAddr = get_application_address(appID) 193 | appGlobalState = getAppGlobalState(client, appID) 194 | 195 | nftID = appGlobalState[b"nft_id"] 196 | 197 | if any(appGlobalState[b"bid_account"]): 198 | # if "bid_account" is not the zero address 199 | prevBidLeader = encoding.encode_address(appGlobalState[b"bid_account"]) 200 | else: 201 | prevBidLeader = None 202 | 203 | suggestedParams = client.suggested_params() 204 | 205 | payTxn = transaction.PaymentTxn( 206 | sender=bidder.getAddress(), 207 | receiver=appAddr, 208 | amt=bidAmount, 209 | sp=suggestedParams, 210 | ) 211 | 212 | appCallTxn = transaction.ApplicationCallTxn( 213 | sender=bidder.getAddress(), 214 | index=appID, 215 | on_complete=transaction.OnComplete.NoOpOC, 216 | app_args=[b"bid"], 217 | foreign_assets=[nftID], 218 | # must include the previous lead bidder here to the app can refund that bidder's payment 219 | accounts=[prevBidLeader] if prevBidLeader is not None else [], 220 | sp=suggestedParams, 221 | ) 222 | 223 | transaction.assign_group_id([payTxn, appCallTxn]) 224 | 225 | signedPayTxn = payTxn.sign(bidder.getPrivateKey()) 226 | signedAppCallTxn = appCallTxn.sign(bidder.getPrivateKey()) 227 | 228 | client.send_transactions([signedPayTxn, signedAppCallTxn]) 229 | 230 | waitForTransaction(client, appCallTxn.get_txid()) 231 | 232 | 233 | def closeAuction(client: AlgodClient, appID: int, closer: Account): 234 | """Close an auction. 235 | 236 | This action can only happen before an auction has begun, in which case it is 237 | cancelled, or after an auction has ended. 238 | 239 | If called after the auction has ended and the auction was successful, the 240 | NFT is transferred to the winning bidder and the auction proceeds are 241 | transferred to the seller. If the auction was not successful, the NFT and 242 | all funds are transferred to the seller. 243 | 244 | Args: 245 | client: An Algod client. 246 | appID: The app ID of the auction. 247 | closer: The account initiating the close transaction. This must be 248 | either the seller or auction creator if you wish to close the 249 | auction before it starts. Otherwise, this can be any account. 250 | """ 251 | appGlobalState = getAppGlobalState(client, appID) 252 | 253 | nftID = appGlobalState[b"nft_id"] 254 | 255 | accounts: List[str] = [encoding.encode_address(appGlobalState[b"seller"])] 256 | 257 | if any(appGlobalState[b"bid_account"]): 258 | # if "bid_account" is not the zero address 259 | accounts.append(encoding.encode_address(appGlobalState[b"bid_account"])) 260 | 261 | deleteTxn = transaction.ApplicationDeleteTxn( 262 | sender=closer.getAddress(), 263 | index=appID, 264 | accounts=accounts, 265 | foreign_assets=[nftID], 266 | sp=client.suggested_params(), 267 | ) 268 | signedDeleteTxn = deleteTxn.sign(closer.getPrivateKey()) 269 | 270 | client.send_transaction(signedDeleteTxn) 271 | 272 | waitForTransaction(client, signedDeleteTxn.get_txid()) 273 | -------------------------------------------------------------------------------- /auction/contracts.py: -------------------------------------------------------------------------------- 1 | from pyteal import * 2 | 3 | 4 | def approval_program(): 5 | seller_key = Bytes("seller") 6 | nft_id_key = Bytes("nft_id") 7 | start_time_key = Bytes("start") 8 | end_time_key = Bytes("end") 9 | reserve_amount_key = Bytes("reserve_amount") 10 | min_bid_increment_key = Bytes("min_bid_inc") 11 | num_bids_key = Bytes("num_bids") 12 | lead_bid_amount_key = Bytes("bid_amount") 13 | lead_bid_account_key = Bytes("bid_account") 14 | 15 | @Subroutine(TealType.none) 16 | def closeNFTTo(assetID: Expr, account: Expr) -> Expr: 17 | asset_holding = AssetHolding.balance( 18 | Global.current_application_address(), assetID 19 | ) 20 | return Seq( 21 | asset_holding, 22 | If(asset_holding.hasValue()).Then( 23 | Seq( 24 | InnerTxnBuilder.Begin(), 25 | InnerTxnBuilder.SetFields( 26 | { 27 | TxnField.type_enum: TxnType.AssetTransfer, 28 | TxnField.xfer_asset: assetID, 29 | TxnField.asset_close_to: account, 30 | } 31 | ), 32 | InnerTxnBuilder.Submit(), 33 | ) 34 | ), 35 | ) 36 | 37 | @Subroutine(TealType.none) 38 | def repayPreviousLeadBidder(prevLeadBidder: Expr, prevLeadBidAmount: Expr) -> Expr: 39 | return Seq( 40 | InnerTxnBuilder.Begin(), 41 | InnerTxnBuilder.SetFields( 42 | { 43 | TxnField.type_enum: TxnType.Payment, 44 | TxnField.amount: prevLeadBidAmount - Global.min_txn_fee(), 45 | TxnField.receiver: prevLeadBidder, 46 | } 47 | ), 48 | InnerTxnBuilder.Submit(), 49 | ) 50 | 51 | @Subroutine(TealType.none) 52 | def closeAccountTo(account: Expr) -> Expr: 53 | return If(Balance(Global.current_application_address()) != Int(0)).Then( 54 | Seq( 55 | InnerTxnBuilder.Begin(), 56 | InnerTxnBuilder.SetFields( 57 | { 58 | TxnField.type_enum: TxnType.Payment, 59 | TxnField.close_remainder_to: account, 60 | } 61 | ), 62 | InnerTxnBuilder.Submit(), 63 | ) 64 | ) 65 | 66 | on_create_start_time = Btoi(Txn.application_args[2]) 67 | on_create_end_time = Btoi(Txn.application_args[3]) 68 | on_create = Seq( 69 | App.globalPut(seller_key, Txn.application_args[0]), 70 | App.globalPut(nft_id_key, Btoi(Txn.application_args[1])), 71 | App.globalPut(start_time_key, on_create_start_time), 72 | App.globalPut(end_time_key, on_create_end_time), 73 | App.globalPut(reserve_amount_key, Btoi(Txn.application_args[4])), 74 | App.globalPut(min_bid_increment_key, Btoi(Txn.application_args[5])), 75 | App.globalPut(lead_bid_account_key, Global.zero_address()), 76 | Assert( 77 | And( 78 | Global.latest_timestamp() < on_create_start_time, 79 | on_create_start_time < on_create_end_time, 80 | # TODO: should we impose a maximum auction length? 81 | ) 82 | ), 83 | Approve(), 84 | ) 85 | 86 | on_setup = Seq( 87 | Assert(Global.latest_timestamp() < App.globalGet(start_time_key)), 88 | # opt into NFT asset -- because you can't opt in if you're already opted in, this is what 89 | # we'll use to make sure the contract has been set up 90 | InnerTxnBuilder.Begin(), 91 | InnerTxnBuilder.SetFields( 92 | { 93 | TxnField.type_enum: TxnType.AssetTransfer, 94 | TxnField.xfer_asset: App.globalGet(nft_id_key), 95 | TxnField.asset_receiver: Global.current_application_address(), 96 | } 97 | ), 98 | InnerTxnBuilder.Submit(), 99 | Approve(), 100 | ) 101 | 102 | on_bid_txn_index = Txn.group_index() - Int(1) 103 | on_bid_nft_holding = AssetHolding.balance( 104 | Global.current_application_address(), App.globalGet(nft_id_key) 105 | ) 106 | on_bid = Seq( 107 | on_bid_nft_holding, 108 | Assert( 109 | And( 110 | # the auction has been set up 111 | on_bid_nft_holding.hasValue(), 112 | on_bid_nft_holding.value() > Int(0), 113 | # the auction has started 114 | App.globalGet(start_time_key) <= Global.latest_timestamp(), 115 | # the auction has not ended 116 | Global.latest_timestamp() < App.globalGet(end_time_key), 117 | # the actual bid payment is before the app call 118 | Gtxn[on_bid_txn_index].type_enum() == TxnType.Payment, 119 | Gtxn[on_bid_txn_index].sender() == Txn.sender(), 120 | Gtxn[on_bid_txn_index].receiver() 121 | == Global.current_application_address(), 122 | Gtxn[on_bid_txn_index].amount() >= Global.min_txn_fee(), 123 | ) 124 | ), 125 | If( 126 | Gtxn[on_bid_txn_index].amount() 127 | >= App.globalGet(lead_bid_amount_key) + App.globalGet(min_bid_increment_key) 128 | ).Then( 129 | Seq( 130 | If(App.globalGet(lead_bid_account_key) != Global.zero_address()).Then( 131 | repayPreviousLeadBidder( 132 | App.globalGet(lead_bid_account_key), 133 | App.globalGet(lead_bid_amount_key), 134 | ) 135 | ), 136 | App.globalPut(lead_bid_amount_key, Gtxn[on_bid_txn_index].amount()), 137 | App.globalPut(lead_bid_account_key, Gtxn[on_bid_txn_index].sender()), 138 | App.globalPut(num_bids_key, App.globalGet(num_bids_key) + Int(1)), 139 | Approve(), 140 | ) 141 | ), 142 | Reject(), 143 | ) 144 | 145 | on_call_method = Txn.application_args[0] 146 | on_call = Cond( 147 | [on_call_method == Bytes("setup"), on_setup], 148 | [on_call_method == Bytes("bid"), on_bid], 149 | ) 150 | 151 | on_delete = Seq( 152 | If(Global.latest_timestamp() < App.globalGet(start_time_key)).Then( 153 | Seq( 154 | # the auction has not yet started, it's ok to delete 155 | Assert( 156 | Or( 157 | # sender must either be the seller or the auction creator 158 | Txn.sender() == App.globalGet(seller_key), 159 | Txn.sender() == Global.creator_address(), 160 | ) 161 | ), 162 | # if the auction contract account has opted into the nft, close it out 163 | closeNFTTo(App.globalGet(nft_id_key), App.globalGet(seller_key)), 164 | # if the auction contract still has funds, send them all to the seller 165 | closeAccountTo(App.globalGet(seller_key)), 166 | Approve(), 167 | ) 168 | ), 169 | If(App.globalGet(end_time_key) <= Global.latest_timestamp()).Then( 170 | Seq( 171 | # the auction has ended, pay out assets 172 | If(App.globalGet(lead_bid_account_key) != Global.zero_address()) 173 | .Then( 174 | If( 175 | App.globalGet(lead_bid_amount_key) 176 | >= App.globalGet(reserve_amount_key) 177 | ) 178 | .Then( 179 | # the auction was successful: send lead bid account the nft 180 | closeNFTTo( 181 | App.globalGet(nft_id_key), 182 | App.globalGet(lead_bid_account_key), 183 | ) 184 | ) 185 | .Else( 186 | Seq( 187 | # the auction was not successful because the reserve was not met: return 188 | # the nft to the seller and repay the lead bidder 189 | closeNFTTo( 190 | App.globalGet(nft_id_key), App.globalGet(seller_key) 191 | ), 192 | repayPreviousLeadBidder( 193 | App.globalGet(lead_bid_account_key), 194 | App.globalGet(lead_bid_amount_key), 195 | ), 196 | ) 197 | ) 198 | ) 199 | .Else( 200 | # the auction was not successful because no bids were placed: return the nft to the seller 201 | closeNFTTo(App.globalGet(nft_id_key), App.globalGet(seller_key)) 202 | ), 203 | # send remaining funds to the seller 204 | closeAccountTo(App.globalGet(seller_key)), 205 | Approve(), 206 | ) 207 | ), 208 | Reject(), 209 | ) 210 | 211 | program = Cond( 212 | [Txn.application_id() == Int(0), on_create], 213 | [Txn.on_completion() == OnComplete.NoOp, on_call], 214 | [ 215 | Txn.on_completion() == OnComplete.DeleteApplication, 216 | on_delete, 217 | ], 218 | [ 219 | Or( 220 | Txn.on_completion() == OnComplete.OptIn, 221 | Txn.on_completion() == OnComplete.CloseOut, 222 | Txn.on_completion() == OnComplete.UpdateApplication, 223 | ), 224 | Reject(), 225 | ], 226 | ) 227 | 228 | return program 229 | 230 | 231 | def clear_state_program(): 232 | return Approve() 233 | 234 | 235 | if __name__ == "__main__": 236 | with open("auction_approval.teal", "w") as f: 237 | compiled = compileTeal(approval_program(), mode=Mode.Application, version=5) 238 | f.write(compiled) 239 | 240 | with open("auction_clear_state.teal", "w") as f: 241 | compiled = compileTeal(clear_state_program(), mode=Mode.Application, version=5) 242 | f.write(compiled) 243 | -------------------------------------------------------------------------------- /auction/operations_test.py: -------------------------------------------------------------------------------- 1 | from time import time, sleep 2 | 3 | import pytest 4 | 5 | from algosdk import account, encoding 6 | from algosdk.logic import get_application_address 7 | 8 | from .operations import createAuctionApp, setupAuctionApp, placeBid, closeAuction 9 | from .util import getBalances, getAppGlobalState, getLastBlockTimestamp 10 | from .testing.setup import getAlgodClient 11 | from .testing.resources import getTemporaryAccount, optInToAsset, createDummyAsset 12 | 13 | 14 | def test_create(): 15 | client = getAlgodClient() 16 | 17 | creator = getTemporaryAccount(client) 18 | _, seller_addr = account.generate_account() # random address 19 | 20 | nftID = 1 # fake ID 21 | startTime = int(time()) + 10 # start time is 10 seconds in the future 22 | endTime = startTime + 60 # end time is 1 minute after start 23 | reserve = 1_000_000 # 1 Algo 24 | increment = 100_000 # 0.1 Algo 25 | 26 | appID = createAuctionApp( 27 | client=client, 28 | sender=creator, 29 | seller=seller_addr, 30 | nftID=nftID, 31 | startTime=startTime, 32 | endTime=endTime, 33 | reserve=reserve, 34 | minBidIncrement=increment, 35 | ) 36 | 37 | actual = getAppGlobalState(client, appID) 38 | expected = { 39 | b"seller": encoding.decode_address(seller_addr), 40 | b"nft_id": nftID, 41 | b"start": startTime, 42 | b"end": endTime, 43 | b"reserve_amount": reserve, 44 | b"min_bid_inc": increment, 45 | b"bid_account": bytes(32), # decoded zero address 46 | } 47 | 48 | assert actual == expected 49 | 50 | 51 | def test_setup(): 52 | client = getAlgodClient() 53 | 54 | creator = getTemporaryAccount(client) 55 | seller = getTemporaryAccount(client) 56 | 57 | nftAmount = 1 58 | nftID = createDummyAsset(client, nftAmount, seller) 59 | 60 | startTime = int(time()) + 10 # start time is 10 seconds in the future 61 | endTime = startTime + 60 # end time is 1 minute after start 62 | reserve = 1_000_000 # 1 Algo 63 | increment = 100_000 # 0.1 Algo 64 | 65 | appID = createAuctionApp( 66 | client=client, 67 | sender=creator, 68 | seller=seller.getAddress(), 69 | nftID=nftID, 70 | startTime=startTime, 71 | endTime=endTime, 72 | reserve=reserve, 73 | minBidIncrement=increment, 74 | ) 75 | 76 | setupAuctionApp( 77 | client=client, 78 | appID=appID, 79 | funder=creator, 80 | nftHolder=seller, 81 | nftID=nftID, 82 | nftAmount=nftAmount, 83 | ) 84 | 85 | actualState = getAppGlobalState(client, appID) 86 | expectedState = { 87 | b"seller": encoding.decode_address(seller.getAddress()), 88 | b"nft_id": nftID, 89 | b"start": startTime, 90 | b"end": endTime, 91 | b"reserve_amount": reserve, 92 | b"min_bid_inc": increment, 93 | b"bid_account": bytes(32), # decoded zero address 94 | } 95 | 96 | assert actualState == expectedState 97 | 98 | actualBalances = getBalances(client, get_application_address(appID)) 99 | expectedBalances = {0: 2 * 100_000 + 2 * 1_000, nftID: nftAmount} 100 | 101 | assert actualBalances == expectedBalances 102 | 103 | 104 | def test_first_bid_before_start(): 105 | client = getAlgodClient() 106 | 107 | creator = getTemporaryAccount(client) 108 | seller = getTemporaryAccount(client) 109 | 110 | nftAmount = 1 111 | nftID = createDummyAsset(client, nftAmount, seller) 112 | 113 | startTime = int(time()) + 5 * 60 # start time is 5 minutes in the future 114 | endTime = startTime + 60 # end time is 1 minute after start 115 | reserve = 1_000_000 # 1 Algo 116 | increment = 100_000 # 0.1 Algo 117 | 118 | appID = createAuctionApp( 119 | client=client, 120 | sender=creator, 121 | seller=seller.getAddress(), 122 | nftID=nftID, 123 | startTime=startTime, 124 | endTime=endTime, 125 | reserve=reserve, 126 | minBidIncrement=increment, 127 | ) 128 | 129 | setupAuctionApp( 130 | client=client, 131 | appID=appID, 132 | funder=creator, 133 | nftHolder=seller, 134 | nftID=nftID, 135 | nftAmount=nftAmount, 136 | ) 137 | 138 | bidder = getTemporaryAccount(client) 139 | 140 | _, lastRoundTime = getLastBlockTimestamp(client) 141 | assert lastRoundTime < startTime 142 | 143 | with pytest.raises(Exception): 144 | bidAmount = 500_000 # 0.5 Algos 145 | placeBid(client=client, appID=appID, bidder=bidder, bidAmount=bidAmount) 146 | 147 | 148 | def test_first_bid(): 149 | client = getAlgodClient() 150 | 151 | creator = getTemporaryAccount(client) 152 | seller = getTemporaryAccount(client) 153 | 154 | nftAmount = 1 155 | nftID = createDummyAsset(client, nftAmount, seller) 156 | 157 | startTime = int(time()) + 10 # start time is 10 seconds in the future 158 | endTime = startTime + 60 # end time is 1 minute after start 159 | reserve = 1_000_000 # 1 Algo 160 | increment = 100_000 # 0.1 Algo 161 | 162 | appID = createAuctionApp( 163 | client=client, 164 | sender=creator, 165 | seller=seller.getAddress(), 166 | nftID=nftID, 167 | startTime=startTime, 168 | endTime=endTime, 169 | reserve=reserve, 170 | minBidIncrement=increment, 171 | ) 172 | 173 | setupAuctionApp( 174 | client=client, 175 | appID=appID, 176 | funder=creator, 177 | nftHolder=seller, 178 | nftID=nftID, 179 | nftAmount=nftAmount, 180 | ) 181 | 182 | bidder = getTemporaryAccount(client) 183 | 184 | _, lastRoundTime = getLastBlockTimestamp(client) 185 | if lastRoundTime < startTime + 5: 186 | sleep(startTime + 5 - lastRoundTime) 187 | 188 | bidAmount = 500_000 # 0.5 Algos 189 | placeBid(client=client, appID=appID, bidder=bidder, bidAmount=bidAmount) 190 | 191 | actualState = getAppGlobalState(client, appID) 192 | expectedState = { 193 | b"seller": encoding.decode_address(seller.getAddress()), 194 | b"nft_id": nftID, 195 | b"start": startTime, 196 | b"end": endTime, 197 | b"reserve_amount": reserve, 198 | b"min_bid_inc": increment, 199 | b"num_bids": 1, 200 | b"bid_amount": bidAmount, 201 | b"bid_account": encoding.decode_address(bidder.getAddress()), 202 | } 203 | 204 | assert actualState == expectedState 205 | 206 | actualBalances = getBalances(client, get_application_address(appID)) 207 | expectedBalances = {0: 2 * 100_000 + 2 * 1_000 + bidAmount, nftID: nftAmount} 208 | 209 | assert actualBalances == expectedBalances 210 | 211 | 212 | def test_second_bid(): 213 | client = getAlgodClient() 214 | 215 | creator = getTemporaryAccount(client) 216 | seller = getTemporaryAccount(client) 217 | 218 | nftAmount = 1 219 | nftID = createDummyAsset(client, nftAmount, seller) 220 | 221 | startTime = int(time()) + 10 # start time is 10 seconds in the future 222 | endTime = startTime + 60 # end time is 1 minute after start 223 | reserve = 1_000_000 # 1 Algo 224 | increment = 100_000 # 0.1 Algo 225 | 226 | appID = createAuctionApp( 227 | client=client, 228 | sender=creator, 229 | seller=seller.getAddress(), 230 | nftID=nftID, 231 | startTime=startTime, 232 | endTime=endTime, 233 | reserve=reserve, 234 | minBidIncrement=increment, 235 | ) 236 | 237 | setupAuctionApp( 238 | client=client, 239 | appID=appID, 240 | funder=creator, 241 | nftHolder=seller, 242 | nftID=nftID, 243 | nftAmount=nftAmount, 244 | ) 245 | 246 | bidder1 = getTemporaryAccount(client) 247 | bidder2 = getTemporaryAccount(client) 248 | 249 | _, lastRoundTime = getLastBlockTimestamp(client) 250 | if lastRoundTime < startTime + 5: 251 | sleep(startTime + 5 - lastRoundTime) 252 | 253 | bid1Amount = 500_000 # 0.5 Algos 254 | placeBid(client=client, appID=appID, bidder=bidder1, bidAmount=bid1Amount) 255 | 256 | bidder1AlgosBefore = getBalances(client, bidder1.getAddress())[0] 257 | 258 | with pytest.raises(Exception): 259 | bid2Amount = bid1Amount + 1_000 # increase is less than min increment amount 260 | placeBid( 261 | client=client, 262 | appID=appID, 263 | bidder=bidder2, 264 | bidAmount=bid2Amount, 265 | ) 266 | 267 | bid2Amount = bid1Amount + increment 268 | placeBid(client=client, appID=appID, bidder=bidder2, bidAmount=bid2Amount) 269 | 270 | actualState = getAppGlobalState(client, appID) 271 | expectedState = { 272 | b"seller": encoding.decode_address(seller.getAddress()), 273 | b"nft_id": nftID, 274 | b"start": startTime, 275 | b"end": endTime, 276 | b"reserve_amount": reserve, 277 | b"min_bid_inc": increment, 278 | b"num_bids": 2, 279 | b"bid_amount": bid2Amount, 280 | b"bid_account": encoding.decode_address(bidder2.getAddress()), 281 | } 282 | 283 | assert actualState == expectedState 284 | 285 | actualAppBalances = getBalances(client, get_application_address(appID)) 286 | expectedAppBalances = {0: 2 * 100_000 + 2 * 1_000 + bid2Amount, nftID: nftAmount} 287 | 288 | assert actualAppBalances == expectedAppBalances 289 | 290 | bidder1AlgosAfter = getBalances(client, bidder1.getAddress())[0] 291 | 292 | # bidder1 should receive a refund of their bid, minus the txn fee 293 | assert bidder1AlgosAfter - bidder1AlgosBefore >= bid1Amount - 1_000 294 | 295 | 296 | def test_close_before_start(): 297 | client = getAlgodClient() 298 | 299 | creator = getTemporaryAccount(client) 300 | seller = getTemporaryAccount(client) 301 | 302 | nftAmount = 1 303 | nftID = createDummyAsset(client, nftAmount, seller) 304 | 305 | startTime = int(time()) + 5 * 60 # start time is 5 minutes in the future 306 | endTime = startTime + 60 # end time is 1 minute after start 307 | reserve = 1_000_000 # 1 Algo 308 | increment = 100_000 # 0.1 Algo 309 | 310 | appID = createAuctionApp( 311 | client=client, 312 | sender=creator, 313 | seller=seller.getAddress(), 314 | nftID=nftID, 315 | startTime=startTime, 316 | endTime=endTime, 317 | reserve=reserve, 318 | minBidIncrement=increment, 319 | ) 320 | 321 | setupAuctionApp( 322 | client=client, 323 | appID=appID, 324 | funder=creator, 325 | nftHolder=seller, 326 | nftID=nftID, 327 | nftAmount=nftAmount, 328 | ) 329 | 330 | _, lastRoundTime = getLastBlockTimestamp(client) 331 | assert lastRoundTime < startTime 332 | 333 | closeAuction(client, appID, seller) 334 | 335 | actualAppBalances = getBalances(client, get_application_address(appID)) 336 | expectedAppBalances = {0: 0} 337 | 338 | assert actualAppBalances == expectedAppBalances 339 | 340 | sellerNftBalance = getBalances(client, seller.getAddress())[nftID] 341 | assert sellerNftBalance == nftAmount 342 | 343 | 344 | def test_close_no_bids(): 345 | client = getAlgodClient() 346 | 347 | creator = getTemporaryAccount(client) 348 | seller = getTemporaryAccount(client) 349 | 350 | nftAmount = 1 351 | nftID = createDummyAsset(client, nftAmount, seller) 352 | 353 | startTime = int(time()) + 10 # start time is 10 seconds in the future 354 | endTime = startTime + 30 # end time is 30 seconds after start 355 | reserve = 1_000_000 # 1 Algo 356 | increment = 100_000 # 0.1 Algo 357 | 358 | appID = createAuctionApp( 359 | client=client, 360 | sender=creator, 361 | seller=seller.getAddress(), 362 | nftID=nftID, 363 | startTime=startTime, 364 | endTime=endTime, 365 | reserve=reserve, 366 | minBidIncrement=increment, 367 | ) 368 | 369 | setupAuctionApp( 370 | client=client, 371 | appID=appID, 372 | funder=creator, 373 | nftHolder=seller, 374 | nftID=nftID, 375 | nftAmount=nftAmount, 376 | ) 377 | 378 | _, lastRoundTime = getLastBlockTimestamp(client) 379 | if lastRoundTime < endTime + 5: 380 | sleep(endTime + 5 - lastRoundTime) 381 | 382 | closeAuction(client, appID, seller) 383 | 384 | actualAppBalances = getBalances(client, get_application_address(appID)) 385 | expectedAppBalances = {0: 0} 386 | 387 | assert actualAppBalances == expectedAppBalances 388 | 389 | sellerNftBalance = getBalances(client, seller.getAddress())[nftID] 390 | assert sellerNftBalance == nftAmount 391 | 392 | 393 | def test_close_reserve_not_met(): 394 | client = getAlgodClient() 395 | 396 | creator = getTemporaryAccount(client) 397 | seller = getTemporaryAccount(client) 398 | 399 | nftAmount = 1 400 | nftID = createDummyAsset(client, nftAmount, seller) 401 | 402 | startTime = int(time()) + 10 # start time is 10 seconds in the future 403 | endTime = startTime + 30 # end time is 30 seconds after start 404 | reserve = 1_000_000 # 1 Algo 405 | increment = 100_000 # 0.1 Algo 406 | 407 | appID = createAuctionApp( 408 | client=client, 409 | sender=creator, 410 | seller=seller.getAddress(), 411 | nftID=nftID, 412 | startTime=startTime, 413 | endTime=endTime, 414 | reserve=reserve, 415 | minBidIncrement=increment, 416 | ) 417 | 418 | setupAuctionApp( 419 | client=client, 420 | appID=appID, 421 | funder=creator, 422 | nftHolder=seller, 423 | nftID=nftID, 424 | nftAmount=nftAmount, 425 | ) 426 | 427 | bidder = getTemporaryAccount(client) 428 | 429 | _, lastRoundTime = getLastBlockTimestamp(client) 430 | if lastRoundTime < startTime + 5: 431 | sleep(startTime + 5 - lastRoundTime) 432 | 433 | bidAmount = 500_000 # 0.5 Algos 434 | placeBid(client=client, appID=appID, bidder=bidder, bidAmount=bidAmount) 435 | 436 | bidderAlgosBefore = getBalances(client, bidder.getAddress())[0] 437 | 438 | _, lastRoundTime = getLastBlockTimestamp(client) 439 | if lastRoundTime < endTime + 5: 440 | sleep(endTime + 5 - lastRoundTime) 441 | 442 | closeAuction(client, appID, seller) 443 | 444 | actualAppBalances = getBalances(client, get_application_address(appID)) 445 | expectedAppBalances = {0: 0} 446 | 447 | assert actualAppBalances == expectedAppBalances 448 | 449 | bidderAlgosAfter = getBalances(client, bidder.getAddress())[0] 450 | 451 | # bidder should receive a refund of their bid, minus the txn fee 452 | assert bidderAlgosAfter - bidderAlgosBefore >= bidAmount - 1_000 453 | 454 | sellerNftBalance = getBalances(client, seller.getAddress())[nftID] 455 | assert sellerNftBalance == nftAmount 456 | 457 | 458 | def test_close_reserve_met(): 459 | client = getAlgodClient() 460 | 461 | creator = getTemporaryAccount(client) 462 | seller = getTemporaryAccount(client) 463 | 464 | nftAmount = 1 465 | nftID = createDummyAsset(client, nftAmount, seller) 466 | 467 | startTime = int(time()) + 10 # start time is 10 seconds in the future 468 | endTime = startTime + 30 # end time is 30 seconds after start 469 | reserve = 1_000_000 # 1 Algo 470 | increment = 100_000 # 0.1 Algo 471 | 472 | appID = createAuctionApp( 473 | client=client, 474 | sender=creator, 475 | seller=seller.getAddress(), 476 | nftID=nftID, 477 | startTime=startTime, 478 | endTime=endTime, 479 | reserve=reserve, 480 | minBidIncrement=increment, 481 | ) 482 | 483 | setupAuctionApp( 484 | client=client, 485 | appID=appID, 486 | funder=creator, 487 | nftHolder=seller, 488 | nftID=nftID, 489 | nftAmount=nftAmount, 490 | ) 491 | 492 | sellerAlgosBefore = getBalances(client, seller.getAddress())[0] 493 | 494 | bidder = getTemporaryAccount(client) 495 | 496 | _, lastRoundTime = getLastBlockTimestamp(client) 497 | if lastRoundTime < startTime + 5: 498 | sleep(startTime + 5 - lastRoundTime) 499 | 500 | bidAmount = reserve 501 | placeBid(client=client, appID=appID, bidder=bidder, bidAmount=bidAmount) 502 | 503 | optInToAsset(client, nftID, bidder) 504 | 505 | _, lastRoundTime = getLastBlockTimestamp(client) 506 | if lastRoundTime < endTime + 5: 507 | sleep(endTime + 5 - lastRoundTime) 508 | 509 | closeAuction(client, appID, seller) 510 | 511 | actualAppBalances = getBalances(client, get_application_address(appID)) 512 | expectedAppBalances = {0: 0} 513 | 514 | assert actualAppBalances == expectedAppBalances 515 | 516 | bidderNftBalance = getBalances(client, bidder.getAddress())[nftID] 517 | 518 | assert bidderNftBalance == nftAmount 519 | 520 | actualSellerBalances = getBalances(client, seller.getAddress()) 521 | 522 | assert len(actualSellerBalances) == 2 523 | # seller should receive the bid amount, minus the txn fee 524 | assert actualSellerBalances[0] >= sellerAlgosBefore + bidAmount - 1_000 525 | assert actualSellerBalances[nftID] == 0 526 | --------------------------------------------------------------------------------