Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,11 @@ node_modules

.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
!.vscode/extensions.json

# AlgoKit / PuyaPy generated artifacts (do not commit)
projects/hackalgo-contracts/smart_contracts/artifacts/
projects/hackalgo-contracts/smart_contracts/*.approval.teal
projects/hackalgo-contracts/smart_contracts/*.clear.teal
projects/hackalgo-contracts/smart_contracts/*.arc56.json
projects/hackalgo-contracts/smart_contracts/*.puya.map
33 changes: 33 additions & 0 deletions docs/ABI.MD
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Algo-Mint Smart Contract ABI (ARC-4 / ARC-19)

## Global State (read-only)
- `creator`: address – the person who minted the NFTs
- `total_nfts`: uint64 – number of NFTs minted (e.g., 10)
- `total_pct_bps`: uint64 – total % of earnings offered, in basis points (e.g., 500 = 5%)
- `duration_years`: uint64 – contract active years (e.g., 3)
- `start_quarter`: uint64 – quarter index when contract started (e.g., 20261)

## Methods

### mint_future_nft(axfer: pay) -> void
- Called by creator only, once.
- Sends ALGO to cover minimum balance requirement.
- Mints the entire series of NFTs (ARC-19) with metadata URI.

### buy_nft(asset_id: uint64, axfer: pay) -> void
- Investor calls this to purchase a specific NFT.
- Payment is forwarded to the creator’s address.
- Transfers the NFT from creator to investor.

### report_income(quarter: uint64, income_amount: uint64) -> void
- Called by creator only, once per quarter.
- `income_amount` in microAlgos or stablecoin units (we’ll use microAlgos for demo).
- Computes `payout_per_nft = (income_amount * pct_per_nft_bps) / 10000`
- Stores the total pending payout for each NFT holder.

### claim_payout(asset_id: uint64) -> void
- Any NFT holder can call to claim their accumulated payouts.
- Transfers ALGO from contract account to caller.

### get_pending_payout(asset_id: uint64, address: address) -> uint64
- Read-only view method to check how much is claimable.
144 changes: 144 additions & 0 deletions projects/hackalgo-contracts/smart_contracts/algo_mint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
from algopy import Account, Box, GlobalState, LocalState, Txn, UInt64, arc4, gtxn, itxn


class AlgoMint(arc4.ARC4Contract):
"""
Demo contract for "future earnings" payout claims.

Notes (demo constraints):
- This contract does not implement full ARC-19/ARC-69 NFT metadata.
- It uses a pull-based distribution: creator reports quarterly income, holders claim.
- For hackathon simplicity, "ownership" is represented by having called `buy_nft` once.
"""

def __init__(self) -> None:
# Global state
self.creator = GlobalState(Account, key="creator")
self.total_nfts = GlobalState(UInt64(0), key="total_nfts")
self.total_pct_bps = GlobalState(UInt64(0), key="total_pct_bps")
self.duration_years = GlobalState(UInt64(0), key="duration_years")
# Interpreted as "latest reported quarter" for monotonic enforcement
self.start_quarter = GlobalState(UInt64(0), key="start_quarter")

# Local state (simplified for demo)
# payout_pending: cached pending amount for a holder (updated on claim)
self.payout_pending = LocalState(UInt64, key="payout_pending")
# last_claimed_quarter: used to compute pending payouts
self.last_claimed_quarter = LocalState(UInt64, key="last_claimed_q")
# has_position: marker that an account is eligible to claim
self.has_position = LocalState(UInt64, key="has_pos")

@arc4.abimethod
def mint_future_nft(self, axfer: gtxn.PaymentTransaction) -> None:
# Only creator can mint; first call sets creator.
creator, exists = self.creator.maybe()
if exists:
assert Txn.sender == creator
else:
self.creator.value = Txn.sender

# Basic group sanity (demo): payment must be from creator.
assert axfer.sender == Txn.sender
assert axfer.amount > 0

# Demo defaults if not set (hackathon example: 10 NFTs, 5% over 3 years).
if self.total_nfts.value == 0:
self.total_nfts.value = UInt64(10)
if self.total_pct_bps.value == 0:
self.total_pct_bps.value = UInt64(500) # 5.00%
if self.duration_years.value == 0:
self.duration_years.value = UInt64(3)

@arc4.abimethod
def buy_nft(self, asset_id: UInt64, axfer: gtxn.PaymentTransaction) -> None:
# NFT must exist (demo check: non-zero ID and creator initialized)
creator_value, creator_exists = self.creator.maybe()
assert creator_exists
assert asset_id != UInt64(0)

# Payment must be from buyer.
assert axfer.sender == Txn.sender
assert axfer.amount > 0

# Forward payment to creator (demo: assumes ALGO payments).
itxn.Payment(receiver=creator_value, amount=axfer.amount).submit()

# Mark buyer as eligible holder (demo: assumes 1 NFT per account).
self.has_position[Txn.sender] = UInt64(1)
if Txn.sender not in self.last_claimed_quarter:
self.last_claimed_quarter[Txn.sender] = UInt64(0)
if Txn.sender not in self.payout_pending:
self.payout_pending[Txn.sender] = UInt64(0)

@arc4.abimethod
def report_income(self, quarter: UInt64, income_amount: UInt64) -> None:
# Only creator can report.
creator, creator_exists = self.creator.maybe()
assert creator_exists
assert Txn.sender == creator

# Quarter must increase.
assert quarter > self.start_quarter.value

# Persist income report for this quarter in a box.
report_box = Box(UInt64, key=b"inc_" + arc4.UInt64(quarter).bytes)
report_box.value = income_amount

# Track latest reported quarter.
self.start_quarter.value = quarter

@arc4.abimethod
def claim_payout(self, asset_id: UInt64) -> None:
# NFT must exist (demo check)
assert asset_id != UInt64(0)
creator_value, creator_exists = self.creator.maybe()
assert creator_exists

# Must have bought at least once to be eligible.
assert self.has_position.get(Txn.sender, default=UInt64(0)) == 1

pending = self.get_pending_payout(asset_id, Txn.sender)
assert pending > 0

# Pay from app account balance.
itxn.Payment(
receiver=Txn.sender,
amount=pending,
).submit()

# Update local state to reflect claim up to latest quarter.
self.last_claimed_quarter[Txn.sender] = self.start_quarter.value
self.payout_pending[Txn.sender] = UInt64(0)

@arc4.abimethod
def get_pending_payout(self, asset_id: UInt64, address: Account) -> UInt64:
# asset_id exists (demo check)
assert asset_id != UInt64(0)
creator_value, creator_exists = self.creator.maybe()
assert creator_exists

# Not a holder -> no pending
if self.has_position.get(address, default=UInt64(0)) != 1:
return UInt64(0)

last_claimed = self.last_claimed_quarter.get(address, default=UInt64(0))
latest = self.start_quarter.value
if latest <= last_claimed:
return UInt64(0)

# Demo computes pending only for the latest quarter (sufficient for the hackathon flow).
# If you later want multi-quarter accumulation, sum over (last_claimed+1..latest) boxes.
income_box = Box(UInt64, key=b"inc_" + arc4.UInt64(latest).bytes)
if not income_box:
return UInt64(0)

income_amount = income_box.value

# total_payout = income_amount * total_pct_bps / 10000
total_payout = (income_amount * self.total_pct_bps.value) // UInt64(10_000)
# per_nft = total_payout / total_nfts
if self.total_nfts.value == 0:
return UInt64(0)
per_nft = total_payout // self.total_nfts.value

return per_nft
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from __future__ import annotations

from collections.abc import Iterator

import pytest
from algopy import Application, UInt64
from algopy_testing import AlgopyTestContext, algopy_testing_context

from smart_contracts.algo_mint import AlgoMint


@pytest.fixture()
def context() -> Iterator[AlgopyTestContext]:
with algopy_testing_context() as ctx:
yield ctx


def test_mint_buy_report_and_claim_flow(context: AlgopyTestContext) -> None:
creator = context.any.account()
investor = context.any.account()

contract = AlgoMint()

# Creator mints/configures (demo defaults: 10 NFTs, 5%).
pay_mint = context.any.txn.payment(
sender=creator, receiver=context.any.account(), amount=UInt64(1_000_000)
)
mint_call = context.txn.defer_app_call(contract.mint_future_nft, axfer=pay_mint)
for txn in mint_call._txns: # type: ignore[attr-defined]
txn.fields["sender"] = creator
with context.txn.create_group(mint_call._txns): # type: ignore[arg-type, attr-defined]
mint_call.submit()

# Investor buys one NFT (asset_id is a demo identifier).
asset_id = UInt64(123)
pay_buy = context.any.txn.payment(
sender=investor, receiver=context.any.account(), amount=UInt64(2_000_000)
)
buy_call = context.txn.defer_app_call(contract.buy_nft, asset_id=asset_id, axfer=pay_buy)
for txn in buy_call._txns: # type: ignore[attr-defined]
txn.fields["sender"] = investor
with context.txn.create_group(buy_call._txns): # type: ignore[arg-type, attr-defined]
buy_call.submit()

# Creator reports quarterly income: income=10,000 => total payout=500 => per NFT=50.
report_call = context.txn.defer_app_call(
contract.report_income, quarter=UInt64(1), income_amount=UInt64(10_000)
)
for txn in report_call._txns: # type: ignore[attr-defined]
txn.fields["sender"] = creator
with context.txn.create_group(report_call._txns): # type: ignore[arg-type, attr-defined]
report_call.submit()

pending = contract.get_pending_payout(asset_id, investor)
assert pending == UInt64(50)

# Fund app so it can pay (claim is a payment from app account).
# In unit tests, we can just ensure the app has enough Algo to cover the claim.
app_address = Application(contract.__app_id__).address
context.ledger.update_account(app_address, balance=1_000_000)

claim_call = context.txn.defer_app_call(contract.claim_payout, asset_id=asset_id)
for txn in claim_call._txns: # type: ignore[attr-defined]
txn.fields["sender"] = investor
with context.txn.create_group(claim_call._txns): # type: ignore[arg-type, attr-defined]
claim_call.submit()

pending_after = contract.get_pending_payout(asset_id, investor)
assert pending_after == UInt64(0)
Loading