diff --git a/.gitignore b/.gitignore index 856e760..ea887fa 100644 --- a/.gitignore +++ b/.gitignore @@ -170,4 +170,11 @@ node_modules .vscode/* !.vscode/settings.json -!.vscode/extensions.json \ No newline at end of file +!.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 \ No newline at end of file diff --git a/docs/ABI.MD b/docs/ABI.MD new file mode 100644 index 0000000..042b8ad --- /dev/null +++ b/docs/ABI.MD @@ -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. \ No newline at end of file diff --git a/projects/hackalgo-contracts/smart_contracts/algo_mint.py b/projects/hackalgo-contracts/smart_contracts/algo_mint.py new file mode 100644 index 0000000..7c029e7 --- /dev/null +++ b/projects/hackalgo-contracts/smart_contracts/algo_mint.py @@ -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 diff --git a/projects/hackalgo-contracts/smart_contracts/tests/test_algo_mint.py b/projects/hackalgo-contracts/smart_contracts/tests/test_algo_mint.py new file mode 100644 index 0000000..0569c89 --- /dev/null +++ b/projects/hackalgo-contracts/smart_contracts/tests/test_algo_mint.py @@ -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)