Skip to content

Commit 46f7493

Browse files
authored
Merge pull request #3 from algorandfoundation/feat/unit-testing-boxes
feat: stub implementation of Box, BoxRef and BoxMap
2 parents 44000c9 + 3b95140 commit 46f7493

File tree

19 files changed

+1788
-131
lines changed

19 files changed

+1788
-131
lines changed

docs/coverage.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ See which `algorand-python` stubs are implemented by the `algorand-python-testin
2525
| Application | Emulated |
2626
| subroutine | Emulated |
2727
| Global | Emulated |
28+
| op.Box.\* | Emulated |
2829
| Box | Emulated |
30+
| BoxRef | Emulated |
31+
| BoxMap | Emulated |
2932
| Block | Emulated |
3033
| logicsig | Emulated |
3134
| log | Emulated |

docs/usage.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,35 @@ To be documented...
232232

233233
### Boxes
234234

235-
To be documented...
235+
The higher-level Boxes interface, introduced in version 2.1.0, along with all low-level Box 'op' calls, are available.
236236

237-
> NOTE: Higher level Boxes interface introduce in v2.1.0 is not supported yet, however all low level Box 'op' calls are available.
237+
```py
238+
import algopy
239+
240+
# Check and mark the sender's POA claim in the Box by their address
241+
# to prevent duplicates using low-level Box 'op' calls.
242+
_id, has_claimed = algopy.op.Box.get(algopy.Txn.sender.bytes)
243+
assert not has_claimed, "Already claimed POA"
244+
algopy.op.Box.put(algopy.Txn.sender.bytes, algopy.op.itob(minted_asset.id))
245+
246+
# Utilizing the higher-level 'Box' interface for an alternative implementation.
247+
box = algopy.Box(algopy.UInt64, key=algopy.Txn.sender.bytes)
248+
has_claimed = bool(box)
249+
assert not has_claimed, "Already claimed POA"
250+
box.value = minted_asset.id
251+
252+
# Utilizing the higher-level 'BoxRef' interface for an alternative implementation.
253+
box_ref = algopy.BoxRef(key=algopy.Txn.sender.bytes)
254+
has_claimed = bool(box_ref)
255+
assert not has_claimed, "Already claimed POA"
256+
box_ref.put(algopy.op.itob(minted_asset.id))
257+
258+
# Utilizing the higher-level 'BoxMap' interface for an alternative implementation.
259+
box_map = algopy.BoxMap(algopy.Bytes, algopy.UInt64, key_prefix="box_map")
260+
has_claimed = algopy.Txn.sender.bytes in self.box_map
261+
assert not has_claimed, "Already claimed POA"
262+
self.box_map[algopy.Txn.sender.bytes] = minted_asset.id
263+
```
238264

239265
## Smart Signatures
240266

examples/box/__init__.py

Whitespace-only changes.

examples/box/contract.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from algopy import ARC4Contract, Box, OnCompleteAction, TransactionType, arc4, op
2+
3+
4+
class BoxContract(ARC4Contract):
5+
6+
def __init__(self) -> None:
7+
self.oca = Box(OnCompleteAction)
8+
self.txn = Box(TransactionType)
9+
10+
@arc4.abimethod()
11+
def store_enums(self) -> None:
12+
self.oca.value = OnCompleteAction.OptIn
13+
self.txn.value = TransactionType.ApplicationCall
14+
15+
@arc4.abimethod()
16+
def read_enums(self) -> tuple[OnCompleteAction, TransactionType]:
17+
assert op.Box.get(b"oca")[0] == op.itob(self.oca.value)
18+
assert op.Box.get(b"txn")[0] == op.itob(self.txn.value)
19+
20+
return self.oca.value, self.txn.value

examples/box/test_contract.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from collections.abc import Generator
2+
3+
import pytest
4+
from algopy import op
5+
from algopy_testing import AlgopyTestContext, algopy_testing_context
6+
7+
from .contract import BoxContract
8+
9+
10+
@pytest.fixture()
11+
def context() -> Generator[AlgopyTestContext, None, None]:
12+
with algopy_testing_context() as ctx:
13+
yield ctx
14+
15+
16+
def test_enums(context: AlgopyTestContext) -> None:
17+
# Arrange
18+
contract = BoxContract()
19+
20+
# Act
21+
contract.store_enums()
22+
oca, txn = contract.read_enums()
23+
24+
# Assert
25+
assert context.get_box(b"oca") == op.itob(oca)
26+
assert context.get_box(b"txn") == op.itob(txn)

examples/proof_of_attendance/contract.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ def __init__(self) -> None:
66
self.max_attendees = algopy.UInt64(30)
77
self.asset_url = algopy.String("ipfs://QmW5vERkgeJJtSY1YQdcWU6gsHCZCyLFtM1oT9uyy2WGm8")
88
self.total_attendees = algopy.UInt64(0)
9+
self.box_map = algopy.BoxMap(algopy.Bytes, algopy.UInt64)
910

1011
@algopy.arc4.abimethod(create="require")
1112
def init(self, max_attendees: algopy.UInt64) -> None:
@@ -24,12 +25,70 @@ def confirm_attendance(self) -> None:
2425

2526
algopy.op.Box.put(algopy.Txn.sender.bytes, algopy.op.itob(minted_asset.id))
2627

28+
@algopy.arc4.abimethod()
29+
def confirm_attendance_with_box(self) -> None:
30+
assert self.total_attendees < self.max_attendees, "Max attendees reached"
31+
32+
minted_asset = self._mint_poa(algopy.Txn.sender)
33+
self.total_attendees += 1
34+
35+
box = algopy.Box(algopy.UInt64, key=algopy.Txn.sender.bytes)
36+
has_claimed = bool(box)
37+
assert not has_claimed, "Already claimed POA"
38+
39+
box.value = minted_asset.id
40+
41+
@algopy.arc4.abimethod()
42+
def confirm_attendance_with_box_ref(self) -> None:
43+
assert self.total_attendees < self.max_attendees, "Max attendees reached"
44+
45+
minted_asset = self._mint_poa(algopy.Txn.sender)
46+
self.total_attendees += 1
47+
48+
box_ref = algopy.BoxRef(key=algopy.Txn.sender.bytes)
49+
has_claimed = bool(box_ref)
50+
assert not has_claimed, "Already claimed POA"
51+
52+
box_ref.put(algopy.op.itob(minted_asset.id))
53+
54+
@algopy.arc4.abimethod()
55+
def confirm_attendance_with_box_map(self) -> None:
56+
assert self.total_attendees < self.max_attendees, "Max attendees reached"
57+
58+
minted_asset = self._mint_poa(algopy.Txn.sender)
59+
self.total_attendees += 1
60+
61+
has_claimed = algopy.Txn.sender.bytes in self.box_map
62+
assert not has_claimed, "Already claimed POA"
63+
64+
self.box_map[algopy.Txn.sender.bytes] = minted_asset.id
65+
2766
@algopy.arc4.abimethod(readonly=True)
2867
def get_poa_id(self) -> algopy.UInt64:
2968
poa_id, exists = algopy.op.Box.get(algopy.Txn.sender.bytes)
3069
assert exists, "POA not found"
3170
return algopy.op.btoi(poa_id)
3271

72+
@algopy.arc4.abimethod(readonly=True)
73+
def get_poa_id_with_box(self) -> algopy.UInt64:
74+
box = algopy.Box(algopy.UInt64, key=algopy.Txn.sender.bytes)
75+
poa_id, exists = box.maybe()
76+
assert exists, "POA not found"
77+
return poa_id
78+
79+
@algopy.arc4.abimethod(readonly=True)
80+
def get_poa_id_with_box_ref(self) -> algopy.UInt64:
81+
box_ref = algopy.BoxRef(key=algopy.Txn.sender.bytes)
82+
poa_id, exists = box_ref.maybe()
83+
assert exists, "POA not found"
84+
return algopy.op.btoi(poa_id)
85+
86+
@algopy.arc4.abimethod(readonly=True)
87+
def get_poa_id_with_box_map(self) -> algopy.UInt64:
88+
poa_id, exists = self.box_map.maybe(algopy.Txn.sender.bytes)
89+
assert exists, "POA not found"
90+
return poa_id
91+
3392
@algopy.arc4.abimethod()
3493
def claim_poa(self, opt_in_txn: algopy.gtxn.AssetTransferTransaction) -> None:
3594
poa_id, exists = algopy.op.Box.get(algopy.Txn.sender.bytes)
@@ -49,6 +108,65 @@ def claim_poa(self, opt_in_txn: algopy.gtxn.AssetTransferTransaction) -> None:
49108
algopy.op.btoi(poa_id),
50109
)
51110

111+
@algopy.arc4.abimethod()
112+
def claim_poa_with_box(self, opt_in_txn: algopy.gtxn.AssetTransferTransaction) -> None:
113+
box = algopy.Box(algopy.UInt64, key=algopy.Txn.sender.bytes)
114+
poa_id, exists = box.maybe()
115+
assert exists, "POA not found, attendance validation failed!"
116+
assert opt_in_txn.xfer_asset.id == poa_id, "POA ID mismatch"
117+
assert opt_in_txn.fee == algopy.UInt64(0), "We got you covered for free!"
118+
assert opt_in_txn.asset_amount == algopy.UInt64(0)
119+
assert (
120+
opt_in_txn.sender == opt_in_txn.asset_receiver == algopy.Txn.sender
121+
), "Opt-in transaction sender and receiver must be the same"
122+
assert (
123+
opt_in_txn.asset_close_to == opt_in_txn.rekey_to == algopy.Global.zero_address
124+
), "Opt-in transaction close to must be zero address"
125+
126+
self._send_poa(
127+
algopy.Txn.sender,
128+
poa_id,
129+
)
130+
131+
@algopy.arc4.abimethod()
132+
def claim_poa_with_box_ref(self, opt_in_txn: algopy.gtxn.AssetTransferTransaction) -> None:
133+
box_ref = algopy.BoxRef(key=algopy.Txn.sender.bytes)
134+
poa_id, exists = box_ref.maybe()
135+
assert exists, "POA not found, attendance validation failed!"
136+
assert opt_in_txn.xfer_asset.id == algopy.op.btoi(poa_id), "POA ID mismatch"
137+
assert opt_in_txn.fee == algopy.UInt64(0), "We got you covered for free!"
138+
assert opt_in_txn.asset_amount == algopy.UInt64(0)
139+
assert (
140+
opt_in_txn.sender == opt_in_txn.asset_receiver == algopy.Txn.sender
141+
), "Opt-in transaction sender and receiver must be the same"
142+
assert (
143+
opt_in_txn.asset_close_to == opt_in_txn.rekey_to == algopy.Global.zero_address
144+
), "Opt-in transaction close to must be zero address"
145+
146+
self._send_poa(
147+
algopy.Txn.sender,
148+
algopy.op.btoi(poa_id),
149+
)
150+
151+
@algopy.arc4.abimethod()
152+
def claim_poa_with_box_map(self, opt_in_txn: algopy.gtxn.AssetTransferTransaction) -> None:
153+
poa_id, exists = self.box_map.maybe(algopy.Txn.sender.bytes)
154+
assert exists, "POA not found, attendance validation failed!"
155+
assert opt_in_txn.xfer_asset.id == poa_id, "POA ID mismatch"
156+
assert opt_in_txn.fee == algopy.UInt64(0), "We got you covered for free!"
157+
assert opt_in_txn.asset_amount == algopy.UInt64(0)
158+
assert (
159+
opt_in_txn.sender == opt_in_txn.asset_receiver == algopy.Txn.sender
160+
), "Opt-in transaction sender and receiver must be the same"
161+
assert (
162+
opt_in_txn.asset_close_to == opt_in_txn.rekey_to == algopy.Global.zero_address
163+
), "Opt-in transaction close to must be zero address"
164+
165+
self._send_poa(
166+
algopy.Txn.sender,
167+
poa_id,
168+
)
169+
52170
@algopy.subroutine
53171
def _mint_poa(self, claimer: algopy.Account) -> algopy.Asset:
54172
algopy.ensure_budget(algopy.UInt64(10000), algopy.OpUpFeeSource.AppAccount)

examples/proof_of_attendance/test_contract.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,46 @@ def test_init(context: AlgopyTestContext) -> None:
2727
assert contract.max_attendees == max_attendees
2828

2929

30+
@pytest.mark.parametrize(
31+
("confirm_attendance", "key_prefix"),
32+
[
33+
("confirm_attendance", b""),
34+
("confirm_attendance_with_box", b""),
35+
("confirm_attendance_with_box_ref", b""),
36+
("confirm_attendance_with_box_map", b"box_map"),
37+
],
38+
)
3039
def test_confirm_attendance(
3140
context: AlgopyTestContext,
41+
confirm_attendance: str,
42+
key_prefix: bytes,
3243
) -> None:
3344
# Arrange
3445
contract = ProofOfAttendance()
3546
contract.max_attendees = context.any_uint64(1, 100)
3647

3748
# Act
38-
contract.confirm_attendance()
49+
confirm = getattr(contract, confirm_attendance)
50+
confirm()
3951

4052
# Assert
41-
assert context.get_box(context.default_creator.bytes) == algopy.op.itob(1)
42-
43-
44-
def test_claim_poa(context: AlgopyTestContext) -> None:
53+
assert context.get_box(key_prefix + context.default_creator.bytes) == algopy.op.itob(1)
54+
55+
56+
@pytest.mark.parametrize(
57+
("claim_poa", "key_prefix"),
58+
[
59+
("claim_poa", b""),
60+
("claim_poa_with_box", b""),
61+
("claim_poa_with_box_ref", b""),
62+
("claim_poa_with_box_map", b"box_map"),
63+
],
64+
)
65+
def test_claim_poa(
66+
context: AlgopyTestContext,
67+
claim_poa: str,
68+
key_prefix: bytes,
69+
) -> None:
4570
# Arrange
4671
contract = ProofOfAttendance()
4772
dummy_poa = context.any_asset()
@@ -54,10 +79,11 @@ def test_claim_poa(context: AlgopyTestContext) -> None:
5479
fee=algopy.UInt64(0),
5580
asset_amount=algopy.UInt64(0),
5681
)
57-
context.set_box(context.default_creator.bytes, algopy.op.itob(dummy_poa.id))
82+
context.set_box(key_prefix + context.default_creator.bytes, algopy.op.itob(dummy_poa.id))
5883

5984
# Act
60-
contract.claim_poa(opt_in_txn)
85+
claim = getattr(contract, claim_poa)
86+
claim(opt_in_txn)
6187

6288
# Assert
6389
axfer_itxn = context.get_submitted_itxn_group(-1).asset_transfer(0)

src/algopy/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@
1010
GTxn,
1111
ITxn,
1212
LogicSig,
13+
StateTotals,
1314
TemplateVar,
1415
Txn,
1516
logicsig,
17+
uenumerate,
1618
urange,
1719
)
20+
from algopy_testing.models.box import Box, BoxMap, BoxRef
1821
from algopy_testing.primitives import BigUInt, Bytes, String, UInt64
1922
from algopy_testing.protocols import BytesBacked
2023
from algopy_testing.state import GlobalState, LocalState
@@ -55,4 +58,7 @@
5558
"subroutine",
5659
"uenumerate",
5760
"urange",
61+
"Box",
62+
"BoxRef",
63+
"BoxMap",
5864
]

src/algopy_testing/context.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ def __init__(
285285
self._scratch_spaces: dict[str, list[algopy.Bytes | algopy.UInt64 | bytes | int]] = {}
286286
self._template_vars: dict[str, Any] = template_vars or {}
287287
self._blocks: dict[int, dict[str, int]] = {}
288-
self._boxes: dict[bytes, algopy.Bytes] = {}
288+
self._boxes: dict[bytes, bytes] = {}
289289
self._lsigs: dict[algopy.LogicSig, Callable[[], algopy.UInt64 | bool]] = {}
290290
self._active_lsig_args: Sequence[algopy.Bytes] = []
291291

@@ -1047,20 +1047,23 @@ def any_transaction( # type: ignore[misc]
10471047

10481048
return new_txn
10491049

1050-
def get_box(self, name: algopy.Bytes | bytes) -> algopy.Bytes:
1050+
def does_box_exist(self, name: algopy.Bytes | bytes) -> bool:
1051+
"""return true if the box with the given name exists."""
1052+
name_bytes = name if isinstance(name, bytes) else name.value
1053+
return name_bytes in self._boxes
1054+
1055+
def get_box(self, name: algopy.Bytes | bytes) -> bytes:
10511056
"""Get the content of a box."""
1052-
import algopy
10531057

10541058
name_bytes = name if isinstance(name, bytes) else name.value
1055-
return self._boxes.get(name_bytes, algopy.Bytes(b""))
1059+
return self._boxes.get(name_bytes, b"")
10561060

10571061
def set_box(self, name: algopy.Bytes | bytes, content: algopy.Bytes | bytes) -> None:
10581062
"""Set the content of a box."""
1059-
import algopy
10601063

10611064
name_bytes = name if isinstance(name, bytes) else name.value
10621065
content_bytes = content if isinstance(content, bytes) else content.value
1063-
self._boxes[name_bytes] = algopy.Bytes(content_bytes)
1066+
self._boxes[name_bytes] = content_bytes
10641067

10651068
def execute_logicsig(
10661069
self, lsig: algopy.LogicSig, lsig_args: Sequence[algopy.Bytes] | None = None
@@ -1071,12 +1074,14 @@ def execute_logicsig(
10711074
self._lsigs[lsig] = lsig.func
10721075
return lsig.func()
10731076

1074-
def clear_box(self, name: algopy.Bytes | bytes) -> None:
1077+
def clear_box(self, name: algopy.Bytes | bytes) -> bool:
10751078
"""Clear the content of a box."""
10761079

10771080
name_bytes = name if isinstance(name, bytes) else name.value
10781081
if name_bytes in self._boxes:
10791082
del self._boxes[name_bytes]
1083+
return True
1084+
return False
10801085

10811086
def clear_all_boxes(self) -> None:
10821087
"""Clear all boxes."""
@@ -1170,7 +1175,6 @@ def reset(self) -> None:
11701175
self._app_id = iter(range(1, 2**64))
11711176

11721177

1173-
#
11741178
_var: ContextVar[AlgopyTestContext] = ContextVar("_var")
11751179

11761180

0 commit comments

Comments
 (0)