Algorand Smart Contract Cookbook

50+ minimal, self-contained examples demonstrating every major Algorand smart contract concept. Each example is the smallest possible program that illustrates one idea. Use this as a reference while working through Projects 1–3.

All examples use Algorand Python (Puya) and target AVM v12. Each can be compiled with puyapy and tested on LocalNet.

Table of Contents

  1. Contract basics
  2. ABI methods and routing
  3. Types and arithmetic
  4. Global state
  5. Local state
  6. Box storage
  7. Assets (ASAs)
  8. Inner transactions
  9. Group transactions
  10. Logic signatures
  11. Authorization and security
  12. Subroutines and code organization
  13. ARC-4 encoding and types
  14. Cryptographic operations
  15. Opcode budget and resource management
  16. Compilation and deployment

1. Contract Basics

(See Smart Contracts Overview.)

1.1 — The absolute minimum contract

from algopy import ARC4Contract, arc4

class MinimalContract(ARC4Contract):
    @arc4.abimethod
    def hello(self, name: arc4.String) -> arc4.String:
        return "Hello, " + name

This compiles to an approval program with an ARC-4 method router and a default clear state program that returns true. The method selector is the first 4 bytes of SHA512_256("hello(string)string").

1.2 — Contract with __init__ (runs once on creation)

from algopy import ARC4Contract, GlobalState, UInt64, arc4

class WithInit(ARC4Contract):
    def __init__(self) -> None:
        # Runs exactly ONCE when the app is first created
        self.counter = GlobalState(UInt64(0))
        self.initialized = GlobalState(UInt64(1))

    @arc4.abimethod
    def get_counter(self) -> UInt64:
        return self.counter.value

__init__ maps to the create application call. It never runs again.

1.3 — Non-ARC4 contract (raw approval/clear programs)

from algopy import Contract, UInt64

class RawContract(Contract):
    def approval_program(self) -> UInt64:
        return UInt64(1)  # Always approve

    def clear_state_program(self) -> UInt64:
        return UInt64(1)  # Always approve

Use Contract instead of ARC4Contract when you need full control over the approval program without ABI routing. Rarely needed.

1.4 — Immutable contract (reject update and delete)

from algopy import ARC4Contract, arc4

class ImmutableContract(ARC4Contract):
    @arc4.baremethod(allow_actions=["UpdateApplication", "DeleteApplication"])
    def reject(self) -> None:
        assert False, "Immutable"

    @arc4.abimethod
    def do_something(self) -> None:
        pass

AlgoKit templates are immutable by default. Always do this for production contracts.

2. ABI Methods and Routing

(See ABI.)

2.1 — Multiple methods with different signatures

from algopy import ARC4Contract, UInt64, arc4

class Calculator(ARC4Contract):
    @arc4.abimethod
    def add(self, a: UInt64, b: UInt64) -> UInt64:
        return a + b

    @arc4.abimethod
    def multiply(self, a: UInt64, b: UInt64) -> UInt64:
        return a * b

Each method gets a unique 4-byte selector. The router dispatches based on ApplicationArgs[0].

2.2 — Read-only method (no state changes)

from algopy import ARC4Contract, UInt64, GlobalState, arc4

class ReadOnlyExample(ARC4Contract):
    def __init__(self) -> None:
        self.value = GlobalState(UInt64(42))

    @arc4.abimethod(readonly=True)
    def get_value(self) -> UInt64:
        return self.value.value

readonly=True signals to clients that this method doesn't modify state. Clients can use simulate instead of submitting a real transaction.

2.3 — Bare methods (no ABI args, matched by OnComplete)

from algopy import ARC4Contract, arc4

class BareMethodExample(ARC4Contract):
    @arc4.baremethod(allow_actions=["OptIn"])
    def opt_in(self) -> None:
        # Runs when a user opts into this app
        pass

    @arc4.baremethod(allow_actions=["CloseOut"])
    def close_out(self) -> None:
        # Runs when a user closes out of this app
        pass

    @arc4.baremethod(create="require")
    def create(self) -> None:
        # Runs only on app creation (bare NoOp with create flag)
        pass

Bare methods have no ABI arguments. They match on OnCompletion action type.

2.4 — Method that allows multiple OnComplete actions

from algopy import ARC4Contract, OnCompleteAction, Txn, arc4

class MultiAction(ARC4Contract):
    @arc4.abimethod(allow_actions=["NoOp", "OptIn"])
    def register(self) -> None:
        # This method works for both regular calls and opt-in calls
        if Txn.on_completion == OnCompleteAction.OptIn:
            pass  # Handle opt-in logic

3. Types and Arithmetic

(See Algorand Python types.)

3.1 — Native types: UInt64 and Bytes

from algopy import ARC4Contract, Bytes, UInt64, arc4

class NativeTypes(ARC4Contract):
    @arc4.abimethod
    def uint_ops(self) -> UInt64:
        a = UInt64(100)
        b = UInt64(3)
        return a + b      # 103
        # a - b            # 97  (panics if result < 0)
        # a * b            # 300 (panics on overflow)
        # a // b           # 33  (floor division)
        # a % b            # 1   (modulo)

    @arc4.abimethod
    def bytes_ops(self) -> Bytes:
        a = Bytes(b"hello")
        b = Bytes(b" world")
        return a + b  # b"hello world" (concatenation)

The AVM has exactly two native types. Everything else is built on top of these.

3.2 — BigUInt: up to 512-bit integers

When to use BigUInt vs wide arithmetic (3.3): Use BigUInt when the result itself must exceed 64 bits (e.g., cumulative accumulators like TWAP that grow unboundedly). Use mulw/divmodw (Recipe 3.3) when the final result fits in 64 bits but an intermediate product might overflow (e.g., proportional calculations like a * b / c).

from algopy import ARC4Contract, BigUInt, UInt64, arc4, op

class BigMath(ARC4Contract):
    @arc4.abimethod
    def safe_multiply(self, a: UInt64, b: UInt64) -> UInt64:
        # UInt64 * UInt64 can overflow. Use BigUInt for intermediate:
        big_a = BigUInt(a)
        big_b = BigUInt(b)
        result = big_a * big_b  # Up to 512-bit result
        # Divide back down to UInt64 range:
        return op.btoi((result // BigUInt(1000)).bytes)

3.3 — Wide arithmetic opcodes (128-bit intermediate)

from algopy import ARC4Contract, UInt64, arc4, op

class WideArith(ARC4Contract):
    @arc4.abimethod
    def wide_multiply_then_divide(
        self, a: UInt64, b: UInt64, divisor: UInt64
    ) -> UInt64:
        # (a * b) / divisor without overflow
        # op.mulw returns (high, low) of 128-bit product
        high, low = op.mulw(a, b)
        # op.divmodw divides 128-bit by 64-bit
        q_hi, result, r_hi, r_lo = op.divmodw(high, low, UInt64(0), divisor)
        return result

Essential for AMM math where reserve products overflow uint64.

3.4 — Boolean logic

from algopy import ARC4Contract, UInt64, arc4

class BooleanOps(ARC4Contract):
    @arc4.abimethod
    def check(self, a: UInt64, b: UInt64) -> bool:
        # Standard Python boolean operators work
        return a > UInt64(0) and b > UInt64(0) and a != b

bool in Algorand Python compiles to UInt64 (0 or 1).

4. Global State

(See Global Storage.)

4.1 — Declaring and using global state

from algopy import ARC4Contract, Bytes, GlobalState, UInt64, arc4

class GlobalStateExample(ARC4Contract):
    def __init__(self) -> None:
        self.count = GlobalState(UInt64(0))
        self.name = GlobalState(Bytes(b"default"))

    @arc4.abimethod
    def increment(self) -> UInt64:
        self.count.value += UInt64(1)
        return self.count.value

    @arc4.abimethod
    def set_name(self, name: Bytes) -> None:
        self.name.value = name

Max 64 key-value pairs. Key + value ≤ 128 bytes each. Schema is immutable after creation.

4.2 — Checking if a global state key has a value

from algopy import ARC4Contract, GlobalState, UInt64, arc4

class OptionalState(ARC4Contract):
    def __init__(self) -> None:
        # Initial value type, but no default value set
        self.maybe_set = GlobalState(UInt64)

    @arc4.abimethod
    def set_it(self, val: UInt64) -> None:
        self.maybe_set.value = val

    @arc4.abimethod
    def is_set(self) -> bool:
        # Check if the key exists in state
        return bool(self.maybe_set)

4.3 — Reading another app's global state

from algopy import ARC4Contract, Application, Bytes, UInt64, arc4, op

class CrossAppReader(ARC4Contract):
    @arc4.abimethod
    def read_other_app(self, app: Application, key: Bytes) -> UInt64:
        # The target app must be in the foreign apps array
        value, exists = op.AppGlobal.get_ex_uint64(app, key)
        assert exists
        return value

Requires the target app ID in the transaction's foreign apps array.

5. Local State

(See Local Storage.)

5.1 — Per-user state with opt-in

When to use local state vs box storage (Section 6): Use local state only for non-critical user preferences or caches — data where unilateral deletion by the user is acceptable. For financial data, debts, or anything the application must control, use box storage (Section 6). Users can delete their local state via ClearState at any time; they cannot delete boxes.

from algopy import ARC4Contract, LocalState, Txn, UInt64, arc4

class UserScore(ARC4Contract):
    def __init__(self) -> None:
        self.score = LocalState(UInt64)

    @arc4.baremethod(allow_actions=["OptIn"])
    def opt_in(self) -> None:
        self.score[Txn.sender] = UInt64(0)

    @arc4.abimethod
    def add_points(self, points: UInt64) -> UInt64:
        self.score[Txn.sender] += points
        return self.score[Txn.sender]

Max 16 key-value pairs per user. Users can clear local state at any time via ClearState (always succeeds). Never use local state as the sole store for critical financial data.

5.2 — Reading another account's local state

from algopy import ARC4Contract, Account, Application, Bytes, UInt64, arc4, op

class LocalReader(ARC4Contract):
    @arc4.abimethod
    def read_user_score(
        self, user: Account, app: Application, key: Bytes
    ) -> UInt64:
        value, exists = op.AppLocal.get_ex_uint64(user, app, key)
        assert exists
        return value

6. Box Storage

(See Box Storage.)

6.1 — Simple named box (Box)

When to use Box vs BoxMap (6.2) vs raw box (6.3): Use Box for a single named value (e.g., a config struct). Use BoxMap for per-user or per-entity data keyed by address or ID (the most common pattern). Use raw box access (6.3) only when you need byte-level operations (extract, replace, splice) on packed binary data.

from algopy import ARC4Contract, Box, UInt64, arc4

class SimpleBox(ARC4Contract):
    def __init__(self) -> None:
        self.total = Box(UInt64, key=b"total")

    @arc4.abimethod
    def set_total(self, value: UInt64) -> None:
        self.total.value = value

    @arc4.abimethod
    def get_total(self) -> UInt64:
        return self.total.value

MBR: 2,500 + 400 × (5 + 8) = 7,700 μAlgo for this box.

6.2 — Key-value map (BoxMap)

from algopy import ARC4Contract, BoxMap, Txn, UInt64, arc4

class BalanceMap(ARC4Contract):
    def __init__(self) -> None:
        self.balances = BoxMap(arc4.Address, UInt64, key_prefix=b"b_")

    @arc4.abimethod
    def deposit(self, amount: UInt64) -> None:
        sender = arc4.Address(Txn.sender)
        if sender in self.balances:
            self.balances[sender] += amount
        else:
            self.balances[sender] = amount

    @arc4.abimethod
    def get_balance(self) -> UInt64:
        sender = arc4.Address(Txn.sender)
        if sender in self.balances:
            return self.balances[sender]
        return UInt64(0)

    @arc4.abimethod
    def withdraw(self) -> None:
        sender = arc4.Address(Txn.sender)
        assert sender in self.balances
        del self.balances[sender]  # Deletes the box, frees MBR

6.3 — Raw box access (Box with low-level methods)

Note: BoxRef is deprecated in current PuyaPy (see the @deprecated annotation in the PuyaPy _box stubs). Use Box instead. Methods like create, extract, replace, resize, and splice are available directly on Box. Deletion uses the property deleter: del box.value.

from algopy import ARC4Contract, Box, Bytes, UInt64, arc4

class RawBoxAccess(ARC4Contract):
    def __init__(self) -> None:
        self.data = Box(Bytes, key=b"data")

    @arc4.abimethod
    def create_data_box(self) -> None:
        self.data.create(size=UInt64(256))  # 256 bytes, zero-filled

    @arc4.abimethod
    def write_at_offset(self, offset: UInt64, data: Bytes) -> None:
        self.data.replace(offset, data)

    @arc4.abimethod
    def read_at_offset(self, offset: UInt64, length: UInt64) -> Bytes:
        return self.data.extract(offset, length)

    @arc4.abimethod
    def delete_box(self) -> None:
        del self.data.value  # Property deleter removes the box

Box gives byte-level access via create, extract, replace, resize, and splice. Essential for packed data structures.

6.4 — Box MBR calculation helper

from algopy import subroutine, UInt64

@subroutine
def box_mbr(name_length: UInt64, data_size: UInt64) -> UInt64:
    """Calculate minimum balance requirement for a box."""
    return UInt64(2500) + UInt64(400) * (name_length + data_size)

# Examples:
# box_mbr(5, 8)    →   7,700 μAlgo  (small counter)
# box_mbr(34, 64)  →  41,700 μAlgo  (per-user record)
# box_mbr(12, 32768)→ 13,114,500 μAlgo (max-size box ≈ 13.1 Algo)

6.5 — Box references and I/O budget

# CLIENT-SIDE: you must declare box references in the transaction
# Each reference grants 1KB of I/O budget

# For a box named "data" with 4KB of content:
app_call = transaction.ApplicationCallTxn(
    sender=user,
    sp=sp,
    index=app_id,
    app_args=["read_data"],
    boxes=[
        (app_id, b"data"),  # 1KB budget
        (app_id, b"data"),  # +1KB budget (same box, more budget)
        (app_id, b"data"),  # +1KB budget
        (app_id, b"data"),  # +1KB budget (total: 4KB)
    ],
)

Forgetting box references causes "box read/write budget exceeded" errors.

7. Assets (ASAs)

(See Assets Overview.)

7.1 — Creating an ASA from a contract

from algopy import ARC4Contract, Global, UInt64, arc4, itxn

class TokenCreator(ARC4Contract):
    @arc4.abimethod
    def create_token(self) -> UInt64:
        result = itxn.AssetConfig(
            asset_name=b"MyToken",
            unit_name=b"MTK",
            total=UInt64(1_000_000_000_000),  # 1M with 6 decimals
            decimals=UInt64(6),
            manager=Global.current_application_address,
            reserve=Global.current_application_address,
            fee=UInt64(0),
        ).submit()
        return result.created_asset.id

7.2 — Opting into an ASA (contract opts itself in)

from algopy import ARC4Contract, Asset, Global, UInt64, arc4, itxn

class AssetOptIn(ARC4Contract):
    @arc4.abimethod
    def opt_in_to_asset(self, asset: Asset) -> None:
        itxn.AssetTransfer(
            xfer_asset=asset,
            asset_receiver=Global.current_application_address,
            asset_amount=UInt64(0),  # Zero-amount self-transfer = opt-in
            fee=UInt64(0),
        ).submit()

Costs 100,000 μAlgo (0.1 Algo) in MBR per asset.

7.3 — Sending an ASA from a contract

from algopy import ARC4Contract, Account, Asset, UInt64, arc4, itxn

class AssetSender(ARC4Contract):
    @arc4.abimethod
    def send_tokens(
        self, receiver: Account, asset: Asset, amount: UInt64
    ) -> None:
        itxn.AssetTransfer(
            xfer_asset=asset,
            asset_receiver=receiver,
            asset_amount=amount,
            fee=UInt64(0),  # ALWAYS zero for inner txns
        ).submit()

7.4 — Reading asset properties

from algopy import ARC4Contract, Asset, UInt64, arc4

class AssetInfo(ARC4Contract):
    @arc4.abimethod
    def get_asset_decimals(self, asset: Asset) -> UInt64:
        return asset.decimals

    @arc4.abimethod
    def get_asset_total(self, asset: Asset) -> UInt64:
        return asset.total

The asset must be in the transaction's foreign assets array.

7.5 — Checking an account's asset balance

from algopy import ARC4Contract, Account, Asset, UInt64, arc4

class BalanceChecker(ARC4Contract):
    @arc4.abimethod
    def get_asset_balance(self, account: Account, asset: Asset) -> UInt64:
        # asset.balance() fails if the account has not opted in
        return asset.balance(account)

8. Inner Transactions

(See Inner Transactions.)

8.1 — Sending an Algo payment

from algopy import ARC4Contract, Account, UInt64, arc4, itxn

class PaymentSender(ARC4Contract):
    @arc4.abimethod
    def send_algo(self, receiver: Account, amount: UInt64) -> None:
        itxn.Payment(
            receiver=receiver,
            amount=amount,
            fee=UInt64(0),  # Caller covers via fee pooling
        ).submit()

8.2 — Calling another smart contract

from algopy import ARC4Contract, Application, Bytes, UInt64, arc4, itxn

class CrossContractCaller(ARC4Contract):
    @arc4.abimethod
    def call_other_app(self, app: Application) -> None:
        itxn.ApplicationCall(
            app_id=app,
            app_args=[Bytes(b"some_method")],
            fee=UInt64(0),
        ).submit()

Each inner app call adds +700 to the pooled opcode budget.

8.3 — Creating an app from another app (factory pattern)

from algopy import ARC4Contract, Bytes, UInt64, arc4, itxn

class AppFactory(ARC4Contract):
    @arc4.abimethod
    def deploy_child(
        self, approval: Bytes, clear: Bytes
    ) -> UInt64:
        result = itxn.ApplicationCall(
            approval_program=approval,
            clear_state_program=clear,
            global_num_uint=UInt64(4),
            global_num_bytes=UInt64(2),
            fee=UInt64(0),
        ).submit()
        return result.created_app.id

8.4 — Fee pooling: inner txn fees should ALWAYS be zero

# WRONG --- contract pays fee from its own balance:
itxn.Payment(receiver=user, amount=amt).submit()  # fee defaults to min_fee

# RIGHT --- caller covers via fee pooling:
itxn.Payment(receiver=user, amount=amt, fee=UInt64(0)).submit()

# CLIENT SIDE --- caller overpays their outer transaction:
sp = algod.suggested_params()
sp.fee = 2000  # Covers outer txn (1000) + inner txn (1000)
sp.flat_fee = True

9. Group Transactions

(See Atomic Groups.)

9.1 — Accepting a payment in a grouped transaction

from algopy import ARC4Contract, Global, UInt64, arc4, gtxn

class ReceivePayment(ARC4Contract):
    @arc4.abimethod
    def deposit(self, payment: gtxn.PaymentTransaction) -> UInt64:
        # PuyaPy validates that this arg IS a payment transaction
        # YOU must validate the critical fields:
        assert payment.receiver == Global.current_application_address
        assert payment.amount > UInt64(0)
        return payment.amount

The gtxn.PaymentTransaction parameter type makes the ABI router expect a payment transaction at the corresponding group position.

9.2 — Accepting an asset transfer in a group

from algopy import ARC4Contract, Asset, Global, UInt64, arc4, gtxn

class ReceiveAsset(ARC4Contract):
    @arc4.abimethod
    def deposit_asset(
        self,
        transfer: gtxn.AssetTransferTransaction,
        expected_asset: Asset,
    ) -> UInt64:
        assert transfer.asset_receiver == Global.current_application_address
        assert transfer.xfer_asset == expected_asset
        assert transfer.asset_amount > UInt64(0)
        return transfer.asset_amount

9.3 — Inspecting other transactions in the group via gtxn

Inside a smart contract, we use TransactionType enum values for clarity. The TransactionType enum maps to the same integer constants (Payment = 1, AssetTransfer = 4) but makes the code self-documenting.

from algopy import ARC4Contract, Global, TransactionType, UInt64, arc4, gtxn

class GroupInspector(ARC4Contract):
    @arc4.abimethod
    def verify_group(self) -> None:
        # Check total group size
        assert Global.group_size == UInt64(3)

        # Inspect transaction at index 0
        assert gtxn.Transaction(0).type == TransactionType.Payment
        assert gtxn.Transaction(0).receiver == Global.current_application_address

        # Inspect transaction at index 1
        assert gtxn.Transaction(1).type == TransactionType.AssetTransfer

10. Logic Signatures

(See Logic Signatures.)

10.1 — Minimal LogicSig (contract account)

from algopy import Txn, UInt64, Global, logicsig, TransactionType

@logicsig
def simple_escrow() -> bool:
    """Allows payments up to 1 Algo to anyone. No signing required."""
    return (
        Txn.type_enum == TransactionType.Payment
        and Txn.amount <= UInt64(1_000_000)
        and Txn.close_remainder_to == Global.zero_address
        and Txn.rekey_to == Global.zero_address
        and Txn.fee <= UInt64(10_000)
    )

This program's hash is its address. Fund it, and anyone can trigger payments from it.

10.2 — LogicSig with template variables

from algopy import Account, Bytes, Txn, UInt64, Global, TemplateVar, logicsig, TransactionType

@logicsig
def parameterized_escrow() -> bool:
    """Template vars are baked in at compile time."""
    RECEIVER = TemplateVar[Bytes]("RECEIVER")
    MAX_AMOUNT = TemplateVar[UInt64]("MAX_AMOUNT")
    EXPIRY = TemplateVar[UInt64]("EXPIRY")

    return (
        Txn.type_enum == TransactionType.Payment
        and Txn.receiver == Account(RECEIVER)
        and Txn.amount <= MAX_AMOUNT
        and Txn.last_valid <= EXPIRY
        and Txn.close_remainder_to == Global.zero_address
        and Txn.rekey_to == Global.zero_address
        and Txn.fee <= UInt64(10_000)
    )

Compile: puyapy contract.py --template-var RECEIVER=0xABCD... --template-var MAX_AMOUNT=5000000 --template-var EXPIRY=40000000

10.3 — LogicSig reading group transaction fields

from algopy import Application, Global, Txn, UInt64, gtxn, logicsig, TransactionType

@logicsig
def grouped_logicsig() -> bool:
    """Only valid when grouped with a specific app call."""
    return (
        Global.group_size == UInt64(2)
        and Txn.group_index == UInt64(0)
        and gtxn.Transaction(1).type == TransactionType.ApplicationCall
        and gtxn.Transaction(1).app_id == Application(12345)
        and Txn.close_remainder_to == Global.zero_address
        and Txn.rekey_to == Global.zero_address
        and Txn.fee <= UInt64(10_000)
    )

10.4 — Using a delegated LogicSig (client-side)

from algosdk import transaction
import base64

# Compile the TEAL
compiled = algorand.client.algod.compile(teal_source)
program = base64.b64decode(compiled["result"])

# Create LogicSig and DELEGATE by signing with Alice's key
lsig = transaction.LogicSigAccount(program)
lsig.sign(alice_private_key)  # ← This is the delegation

# Now anyone can use lsig to authorize txns from Alice's account:
txn = transaction.PaymentTxn(
    sender=alice_address,  # FROM Alice
    sp=suggested_params,
    receiver=bob_address,
    amt=100_000,
)
signed = transaction.LogicSigTransaction(txn, lsig)
algorand.client.algod.send_transaction(signed)

10.5 — Using a contract account LogicSig (client-side)

# Contract account: the LogicSig IS the account (no signing needed)
lsig = transaction.LogicSigAccount(program)

# The sender is the hash of the program:
escrow_address = lsig.address()

# Fund it first, then submit transactions from it:
txn = transaction.PaymentTxn(
    sender=escrow_address,
    sp=suggested_params,
    receiver=recipient,
    amt=50_000,
)
signed = transaction.LogicSigTransaction(txn, lsig)
algorand.client.algod.send_transaction(signed)

11. Authorization and Security

(See Rekeying and Signing.)

11.1 — Creator-only method

from algopy import ARC4Contract, Global, Txn, arc4

class AdminOnly(ARC4Contract):
    @arc4.abimethod
    def admin_action(self) -> None:
        assert Txn.sender == Global.creator_address
        # ... privileged operation ...

11.2 — Stored admin address (transferable)

from algopy import ARC4Contract, Account, GlobalState, Txn, Bytes, arc4

class TransferableAdmin(ARC4Contract):
    def __init__(self) -> None:
        self.admin = GlobalState(Bytes())

    @arc4.baremethod(create="require")
    def create(self) -> None:
        self.admin.value = Txn.sender.bytes

    @arc4.abimethod
    def transfer_admin(self, new_admin: Account) -> None:
        assert Txn.sender.bytes == self.admin.value
        self.admin.value = new_admin.bytes

11.3 — Close-to and rekey-to fields: LogicSigs vs stateful contracts

These fields (close_remainder_to, asset_close_to, rekey_to) are critical to check in Logic Signature programs (see Section 10). A LogicSig authorizes transactions from its own account, so unchecked close-to or rekey-to fields let an attacker drain or steal the LogicSig's account. Missing these checks is the #1 finding in LogicSig audits.

For stateful smart contracts accepting incoming grouped transactions, these fields affect the sender's account (the user), not the contract's. The contract receives the specified amount regardless. Inner transactions default these fields to the zero address automatically. Checking them in a stateful contract just restricts what users can do with their own wallets — it is the wallet's responsibility to warn about dangerous transaction fields, not the contract's.

11.4 — Verifying group size

from algopy import ARC4Contract, Global, UInt64, arc4

class GroupSizeCheck(ARC4Contract):
    @arc4.abimethod
    def swap(self) -> None:
        # Expect exactly: [asset_transfer, this_app_call]
        assert Global.group_size == UInt64(2)
        # Prevents attacker from appending extra transactions

12. Subroutines and Code Organization

(See Algorand Python structure guide.)

12.1 — Module-level subroutine (shared across contracts)

from algopy import UInt64, subroutine

@subroutine
def min_value(a: UInt64, b: UInt64) -> UInt64:
    return a if a < b else b

@subroutine
def max_value(a: UInt64, b: UInt64) -> UInt64:
    return a if a > b else b

12.2 — Subroutine within a contract class

from algopy import ARC4Contract, UInt64, arc4, subroutine

class MathContract(ARC4Contract):
    @subroutine
    def _calculate_fee(self, amount: UInt64) -> UInt64:
        """Private helper --- not callable externally."""
        return amount * UInt64(3) // UInt64(1000)  # 0.3%

    @arc4.abimethod
    def get_fee(self, amount: UInt64) -> UInt64:
        return self._calculate_fee(amount)

Subroutines compile to TEAL callsub/retsub, saving program bytes when called multiple times.

12.3 — Importing subroutines across files

# utils.py
from algopy import UInt64, subroutine

@subroutine
def safe_subtract(a: UInt64, b: UInt64) -> UInt64:
    assert a >= b
    return a - b

# contract.py
from algopy import ARC4Contract, UInt64, arc4
from utils import safe_subtract

class MyContract(ARC4Contract):
    @arc4.abimethod
    def withdraw(self, balance: UInt64, amount: UInt64) -> UInt64:
        return safe_subtract(balance, amount)

13. ARC-4 Encoding and Types

(See ARC-4 specification.)

13.1 — ARC-4 types vs native types

from algopy import ARC4Contract, UInt64, arc4

class TypeDemo(ARC4Contract):
    @arc4.abimethod
    def with_arc4_types(self, x: arc4.UInt64, s: arc4.String) -> arc4.String:
        # arc4 types are ABI-encoded (wire format)
        # Convert to native types for computation:
        native_x = x.as_uint64()  # → UInt64 (.native deprecated on numerics)
        native_s = s.native       # → algopy.String (.native valid on non-numerics)
        return arc4.String("Got: " + native_s)

    @arc4.abimethod
    def with_native_types(self, x: UInt64) -> UInt64:
        # Native types work directly, ABI encoding is automatic
        return x + UInt64(1)

Rule of thumb: use native types for method parameters and return types when possible. The ABI router handles encoding automatically. Use arc4.* types for box storage and struct definitions.

13.2 — ARC-4 structs

from algopy import ARC4Contract, Global, arc4

class Position(arc4.Struct):
    owner: arc4.Address
    amount: arc4.UInt64
    timestamp: arc4.UInt64

class StructExample(ARC4Contract):
    @arc4.abimethod
    def create_position(
        self, owner: arc4.Address, amount: arc4.UInt64
    ) -> Position:
        return Position(
            owner=owner,
            amount=amount,
            timestamp=arc4.UInt64(Global.latest_timestamp),
        )

Structs are ABI-encoded as concatenated fields. Useful for structured box storage.

13.3 — ARC-4 static and dynamic arrays

from typing import Literal
from algopy import ARC4Contract, arc4

class ArrayExample(ARC4Contract):
    @arc4.abimethod
    def static_array(self) -> arc4.StaticArray[arc4.UInt64, Literal[3]]:
        # Fixed-size array (3 elements, each 8 bytes = 24 bytes total)
        return arc4.StaticArray(
            arc4.UInt64(10), arc4.UInt64(20), arc4.UInt64(30)
        )

    @arc4.abimethod
    def dynamic_array(self) -> arc4.DynamicArray[arc4.String]:
        # Variable-length array
        arr = arc4.DynamicArray[arc4.String]()
        arr.append(arc4.String("hello"))
        arr.append(arc4.String("world"))
        return arr

14. Cryptographic Operations

(See Cryptographic Tools.)

14.1 — SHA-256 hashing

from algopy import ARC4Contract, Bytes, arc4, op

class HashExample(ARC4Contract):
    @arc4.abimethod
    def sha256_hash(self, data: Bytes) -> Bytes:
        return op.sha256(data)

    @arc4.abimethod
    def sha512_256_hash(self, data: Bytes) -> Bytes:
        return op.sha512_256(data)  # Algorand's preferred hash

14.2 — Ed25519 signature verification

from algopy import ARC4Contract, Bytes, arc4, op

class SigVerify(ARC4Contract):
    @arc4.abimethod
    def verify_signature(
        self, data: Bytes, signature: Bytes, public_key: Bytes
    ) -> bool:
        # ed25519verify_bare: 1,900 opcodes
        return op.ed25519verify_bare(data, signature, public_key)

14.3 — ECDSA verification (secp256k1 — Bitcoin/Ethereum compatible)

from algopy import ARC4Contract, Bytes, UInt64, arc4, op

class ECDSAVerify(ARC4Contract):
    @arc4.abimethod
    def verify_eth_signature(
        self, data_hash: Bytes, v: UInt64, r: Bytes, s: Bytes
    ) -> Bytes:
        # Recover the public key from the signature
        pub_x, pub_y = op.ecdsa_pk_recover(
            op.ECDSA.Secp256k1, data_hash, v, r, s
        )
        return pub_x + pub_y

14.4 — VRF (Verifiable Random Function) verification

from algopy import ARC4Contract, Bytes, arc4, op

class VRFExample(ARC4Contract):
    @arc4.abimethod
    def verify_randomness(
        self, message: Bytes, proof: Bytes, public_key: Bytes
    ) -> Bytes:
        output, is_valid = op.vrf_verify(
            op.VrfVerify.VrfAlgorand, message, proof, public_key
        )
        assert is_valid
        return output  # 64 bytes of verifiable randomness

14.5 — Elliptic curve point addition (BN254)

from algopy import ARC4Contract, Bytes, arc4, op

class ECExample(ARC4Contract):
    @arc4.abimethod
    def add_points(self, point_a: Bytes, point_b: Bytes) -> Bytes:
        # BN254 G1 points are 64 bytes each
        return op.EllipticCurve.add(op.EC.BN254g1, point_a, point_b)

15. Opcode Budget and Resource Management

(See Costs and Constraints.)

15.1 — Ensure minimum opcode budget

from algopy import ARC4Contract, OpUpFeeSource, arc4, ensure_budget

class BudgetExample(ARC4Contract):
    @arc4.abimethod
    def expensive_operation(self) -> None:
        # Request at least 2,800 opcodes
        # PuyaPy auto-generates inner app calls to pad the budget
        ensure_budget(2800, OpUpFeeSource.GroupCredit)
        # ... expensive computation ...

15.2 — NoOp bare method for budget padding (client-side approach)

from algopy import ARC4Contract, arc4

class BudgetPadded(ARC4Contract):
    @arc4.baremethod(allow_actions=["NoOp"])
    def noop(self) -> None:
        """Each call to this adds +700 to the pooled opcode budget."""
        pass

    @arc4.abimethod
    def heavy_computation(self) -> None:
        # Client adds 3 NoOp calls before this → 4 × 700 = 2,800 budget
        pass

15.3 — Reading the contract's own address and balance

from algopy import ARC4Contract, Bytes, Global, UInt64, arc4

class SelfInfo(ARC4Contract):
    @arc4.abimethod
    def my_address(self) -> Bytes:
        return Global.current_application_address.bytes

    @arc4.abimethod
    def my_balance(self) -> UInt64:
        return Global.current_application_address.balance

    @arc4.abimethod
    def my_min_balance(self) -> UInt64:
        return Global.current_application_address.min_balance

    @arc4.abimethod
    def my_app_id(self) -> UInt64:
        return Global.current_application_id.id

15.4 — Accessing transaction fields

from algopy import ARC4Contract, Global, Txn, UInt64, arc4

class TxnFields(ARC4Contract):
    @arc4.abimethod
    def tx_info(self) -> UInt64:
        _ = Txn.sender            # Who sent this transaction
        _ = Txn.fee               # Fee paid
        _ = Txn.first_valid       # First valid round
        _ = Txn.last_valid        # Last valid round
        _ = Txn.group_index       # Position in group (0-indexed)
        _ = Global.group_size     # Total transactions in group
        _ = Global.round          # Current round number
        _ = Global.latest_timestamp  # Block timestamp (±25 seconds)
        return Global.round

16. Compilation and Deployment

(See AlgoKit CLI overview and Algorand Python compilation guide.)

16.1 — Compiling with PuyaPy

# Compile via AlgoKit (recommended)
algokit compile py contract.py

# Or directly via PuyaPy
puyapy contract.py

# Output: contract.approval.teal, contract.clear.teal, contract.arc56.json

# Compile with template variables
algokit compile py contract.py --template-var MY_VAR=42

# Compile to bytecode directly (skip TEAL)
algokit compile py contract.py --output-bytecode

16.2 — Using compile_contract in Algorand Python

from algopy import ARC4Contract, UInt64, arc4, compile_contract, itxn

class MyContract(ARC4Contract):
    @arc4.abimethod
    def deploy_child(self) -> UInt64:
        compiled = compile_contract(ChildContract)
        result = itxn.ApplicationCall(
            approval_program=compiled.approval_program,
            clear_state_program=compiled.clear_state_program,
            global_num_uint=compiled.global_uints,
            global_num_bytes=compiled.global_bytes,
            fee=UInt64(0),
        ).submit()
        return result.created_app.id

16.3 — Generating typed clients

# From ARC-56 spec
algokit generate client artifacts/MyContract.arc56.json --output client.py

# Usage with typed client:
from client import MyContractClient, MyContractFactory

algorand = algokit_utils.AlgorandClient.default_localnet()
deployer = algorand.account.localnet_dispenser()

factory = MyContractFactory(algorand=algorand, default_sender=deployer.address)
client, deploy_result = factory.deploy()
result = client.send.my_method(args=MyMethodArgs(arg1=42))  # Type-safe method call
print(result.abi_return)

16.4 — Deploying to LocalNet (AppFactory)

from pathlib import Path
import algokit_utils

algorand = algokit_utils.AlgorandClient.default_localnet()
deployer = algorand.account.localnet_dispenser()

# Deploy using AppFactory
factory = algorand.client.get_app_factory(
    app_spec=Path("artifacts/MyContract.arc56.json").read_text(),
    default_sender=deployer.address,
)
app_client, deploy_result = factory.deploy()
print(f"App ID: {app_client.app_id}")
print(f"App Address: {app_client.app_address}")

# Call a method
result = app_client.send.call(
    algokit_utils.AppClientMethodCallParams(method="my_method", args=[42])
)
print(f"Return value: {result.abi_return}")

16.5 — Building and submitting a transaction group (client-side)

from algosdk import transaction

# Create individual transactions
pay_txn = transaction.PaymentTxn(sender=alice, sp=sp, receiver=pool, amt=100_000)
app_txn = transaction.ApplicationCallTxn(sender=alice, sp=sp, index=app_id, app_args=[b"swap"])

# Assign group ID (makes them atomic)
gid = transaction.calculate_group_id([pay_txn, app_txn])
pay_txn.group = gid
app_txn.group = gid

# Sign each transaction
signed_pay = pay_txn.sign(alice_key)
signed_app = app_txn.sign(alice_key)

# Submit as a group
algorand.client.algod.send_transactions([signed_pay, signed_app])

17. Additional Patterns

17.1 — ASA close-out (recover MBR)

When you no longer need to hold an ASA, close out to recover the 100,000 μAlgo MBR. The asset_close_to field sends the entire balance to a recipient and removes the ASA from the account.

# Client-side: close out of an ASA to recover MBR
algorand.send.asset_transfer(
    algokit_utils.AssetTransferParams(
        sender=user.address,
        receiver=user.address,     # Send remaining balance to self
        asset_id=token_id,
        amount=0,                   # Amount is ignored when closing
        close_asset_to=user.address, # This triggers the close-out
    )
)
# After this, the account no longer holds the ASA
# and recovers 100,000 μAlgo of MBR.

Warning: The close_asset_to field sends the entire balance of that ASA, not just the amount field. Double-check the recipient address. If you send it to the wrong address, all tokens are lost.

17.2 — Account rekeying

Rekeying changes which private key controls an account. The account address stays the same, but transactions must be signed by the new "auth address." This is useful for key rotation, converting a regular account into a contract-controlled account, or migrating to a new signing scheme.

# Client-side: rekey an account to a new address
algorand.send.payment(
    algokit_utils.PaymentParams(
        sender=old_key.address,
        receiver=old_key.address,  # Self-payment (any destination works)
        amount=algokit_utils.AlgoAmount.from_micro_algo(0),
        rekey_to=new_key.address,  # The new signing authority
    )
)
# After this, old_key can no longer sign for this account.
# All future transactions must be signed by new_key.

# To rekey back to the original key:
algorand.send.payment(
    algokit_utils.PaymentParams(
        sender=old_key.address,      # Still the account address
        signer=new_key,              # Must sign with current auth key
        receiver=old_key.address,
        amount=algokit_utils.AlgoAmount.from_micro_algo(0),
        rekey_to=old_key.address,    # Restore original authority
    )
)

Warning: Rekeying is irreversible without the new key. If you rekey to an address you do not control, the account is permanently lost. Always verify the rekey_to address before signing.

Quick Reference: AVM Limits

(See Costs and Constraints for the full specification.)

LimitValue
Max group size16 transactions
Opcode budget per app call700 (pooled)
Opcode budget per LogicSig txn20,000 (pooled, separate pool)
Max inner transactions per group256 (16 per app call, pooled across group)
Inner call depth8
Program size (approval + clear combined)2,048 bytes (base); up to 8,192 bytes with 3 extra pages (each adds 2,048)
Global state pairs64 max
Local state pairs per user16 max
Key + value size128 bytes max
Box size0–32,768 bytes
Box name1–64 bytes
Box MBR2,500 + 400 × (name_len + data_size) μAlgo
Foreign refs per txn8 per type (accounts, assets, apps); shared across group since AVM v9
ASA opt-in MBR100,000 μAlgo
Min account balance100,000 μAlgo
Min transaction fee1,000 μAlgo