Skip to content

Commit 815460a

Browse files
author
Matthias Zimmermann
committed
add TxHash type, refactorings
1 parent 9b47804 commit 815460a

File tree

5 files changed

+175
-31
lines changed

5 files changed

+175
-31
lines changed

src/arkiv/module.py

Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
import logging
55
from typing import TYPE_CHECKING, Any
66

7-
from eth_typing import ChecksumAddress
8-
from hexbytes import HexBytes
7+
from eth_typing import ChecksumAddress, HexStr
98
from web3 import Web3
109
from web3.types import TxParams, TxReceipt
1110

@@ -21,6 +20,7 @@
2120
EntityKey,
2221
Operations,
2322
TransactionReceipt,
23+
TxHash,
2424
)
2525
from .utils import merge_annotations, to_receipt, to_tx_params
2626

@@ -54,13 +54,44 @@ def is_available(self) -> bool:
5454
"""Check if Arkiv functionality is available."""
5555
return True
5656

57+
def execute(
58+
self, operations: Operations, tx_params: TxParams | None = None
59+
) -> TransactionReceipt:
60+
"""
61+
Execute operations on the Arkiv storage contract.
62+
63+
Args:
64+
operations: Operations to execute (creates, updates, deletes, extensions)
65+
tx_params: Optional additional transaction parameters
66+
67+
Returns:
68+
TransactionReceipt with details of all operations executed
69+
"""
70+
# Convert to transaction parameters and send
71+
client: Arkiv = self.client
72+
tx_params = to_tx_params(operations, tx_params)
73+
tx_hash_bytes = client.eth.send_transaction(tx_params)
74+
tx_hash = TxHash(HexStr(tx_hash_bytes.to_0x_hex()))
75+
76+
tx_receipt: TxReceipt = client.eth.wait_for_transaction_receipt(tx_hash)
77+
tx_status: int = tx_receipt["status"]
78+
if tx_status != TX_SUCCESS:
79+
raise RuntimeError(f"Transaction failed with status {tx_status}")
80+
81+
# Parse and return receipt
82+
receipt: TransactionReceipt = to_receipt(
83+
client.arkiv.contract, tx_hash, tx_receipt
84+
)
85+
logger.info(f"Arkiv receipt: {receipt}")
86+
return receipt
87+
5788
def create_entity(
5889
self,
5990
payload: bytes | None = None,
6091
annotations: Annotations | None = None,
6192
btl: int = 0,
6293
tx_params: TxParams | None = None,
63-
) -> tuple[EntityKey, HexBytes]:
94+
) -> tuple[EntityKey, TxHash]:
6495
"""
6596
Create a new entity on the Arkiv storage contract.
6697
@@ -71,7 +102,7 @@ def create_entity(
71102
tx_params: Optional additional transaction parameters
72103
73104
Returns:
74-
Transaction hash of the create operation
105+
The entity key and transaction hash of the create operation
75106
"""
76107
# Check and set defaults
77108
if not payload:
@@ -82,30 +113,49 @@ def create_entity(
82113
# Create the operation
83114
create_op = CreateOp(payload=payload, annotations=annotations, btl=btl)
84115

85-
# Wrap in Operations container
116+
# Wrap in Operations container and execute
86117
operations = Operations(creates=[create_op])
118+
receipt = self.execute(operations, tx_params)
87119

88-
# Convert to transaction parameters and send
89-
client: Arkiv = self.client
90-
tx_params = to_tx_params(operations, tx_params)
91-
tx_hash = client.eth.send_transaction(tx_params)
92-
93-
tx_receipt: TxReceipt = client.eth.wait_for_transaction_receipt(tx_hash)
94-
tx_status: int = tx_receipt["status"]
95-
if tx_status != TX_SUCCESS:
96-
raise RuntimeError(f"Transaction failed with status {tx_status}")
97-
98-
# assert that receipt has a creates field
99-
receipt: TransactionReceipt = to_receipt(
100-
client.arkiv.contract, tx_hash, tx_receipt
101-
)
120+
# Verify we got at least one create
102121
creates = receipt.creates
103122
if len(creates) == 0:
104123
raise RuntimeError("Receipt should have at least one entry in 'creates'")
105124

106125
create = creates[0]
107126
entity_key = create.entity_key
108-
return entity_key, tx_hash
127+
return entity_key, receipt.tx_hash
128+
129+
def create_entities(
130+
self,
131+
create_ops: list[CreateOp],
132+
tx_params: TxParams | None = None,
133+
) -> tuple[list[EntityKey], TxHash]:
134+
"""
135+
Create multiple entities in a single transaction (bulk create).
136+
137+
Args:
138+
create_ops: List of CreateOp objects to create
139+
tx_params: Optional additional transaction parameters
140+
141+
Returns:
142+
An array of all created entity keys and transaction hash of the operation
143+
"""
144+
if not create_ops or len(create_ops) == 0:
145+
raise ValueError("create_ops must contain at least one CreateOp")
146+
147+
# Wrap in Operations container and execute
148+
operations = Operations(creates=create_ops)
149+
receipt = self.execute(operations, tx_params)
150+
151+
# Verify all creates succeeded
152+
if len(receipt.creates) != len(create_ops):
153+
raise RuntimeError(
154+
f"Expected {len(create_ops)} creates in receipt, got {len(receipt.creates)}"
155+
)
156+
157+
entity_keys = [create.entity_key for create in receipt.creates]
158+
return entity_keys, receipt.tx_hash
109159

110160
def get_entity(self, entity_key: EntityKey, fields: int = ALL) -> Entity:
111161
"""

src/arkiv/types.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from typing import NewType
66

77
from eth_typing import ChecksumAddress, HexStr
8-
from hexbytes import HexBytes
98
from web3.datastructures import AttributeDict
109

1110
# Field bitmask values to specify which entity fields are populated
@@ -14,6 +13,8 @@
1413
METADATA = 4
1514
ALL = PAYLOAD | ANNOTATIONS | METADATA
1615

16+
# Transaction hash type
17+
TxHash = NewType("TxHash", HexStr)
1718

1819
# Unique key for all entities
1920
EntityKey = NewType("EntityKey", HexStr)
@@ -145,7 +146,7 @@ class DeleteReceipt:
145146
class TransactionReceipt:
146147
"""The return type of a transaction."""
147148

148-
tx_hash: HexBytes
149+
tx_hash: TxHash
149150
creates: Sequence[CreateReceipt]
150151
updates: Sequence[UpdateReceipt]
151152
extensions: Sequence[ExtendReceipt]

src/arkiv/utils.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
StringAnnotations,
2727
StringAnnotationsRlp,
2828
TransactionReceipt,
29+
TxHash,
2930
UpdateReceipt,
3031
)
3132

@@ -107,12 +108,36 @@ def to_tx_params(
107108
return tx_params
108109

109110

111+
def to_hex_bytes(tx_hash: TxHash) -> HexBytes:
112+
"""
113+
Convert a TxHash to HexBytes for Web3.py methods that require it.
114+
115+
Args:
116+
tx_hash: Transaction hash as TxHash
117+
118+
Returns:
119+
Transaction hash as HexBytes with utility methods
120+
121+
Example:
122+
tx_hash: TxHash = client.arkiv.create_entity(...)
123+
hex_bytes = to_hex_bytes(tx_hash)
124+
"""
125+
return HexBytes(tx_hash)
126+
127+
110128
def to_receipt(
111-
contract_: Contract, tx_hash: HexBytes, tx_receipt: TxReceipt
129+
contract_: Contract, tx_hash_: TxHash | HexBytes, tx_receipt: TxReceipt
112130
) -> TransactionReceipt:
113131
"""Convert a tx hash and a raw transaction receipt to a typed receipt."""
114132
logger.debug(f"Transaction receipt: {tx_receipt}")
115133

134+
# normalize tx_hash to TxHash if needed
135+
tx_hash: TxHash = (
136+
tx_hash_
137+
if isinstance(tx_hash_, str)
138+
else TxHash(HexStr(HexBytes(tx_hash_).to_0x_hex()))
139+
)
140+
116141
# Initialize receipt with tx hash and empty receipt collections
117142
creates: list[CreateReceipt] = []
118143
updates: list[UpdateReceipt] = []
@@ -260,7 +285,7 @@ def split_annotations(
260285
else:
261286
string_annotations.append((key, value))
262287

263-
logger.info(
288+
logger.debug(
264289
f"Split annotations into {string_annotations} and {numeric_annotations}"
265290
)
266291
return string_annotations, numeric_annotations

tests/test_entity_create.py

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,25 @@
77

88
from arkiv.client import Arkiv
99
from arkiv.contract import STORAGE_ADDRESS
10-
from arkiv.types import Annotations, CreateOp, Operations
10+
from arkiv.types import Annotations, CreateOp, Operations, TxHash
1111
from arkiv.utils import check_entity_key, to_receipt, to_tx_params
1212

1313
logger = logging.getLogger(__name__)
1414

1515
TX_SUCCESS = 1
1616

1717

18-
def check_tx_hash(label: str, tx_hash: HexBytes) -> None:
18+
def check_tx_hash(label: str, tx_hash: TxHash) -> None:
1919
"""Check transaction hash validity."""
20-
logger.info(f"{label}: Checking transaction hash {tx_hash.to_0x_hex()}")
20+
logger.info(f"{label}: Checking transaction hash {tx_hash}")
2121
assert tx_hash is not None, f"{label}: Transaction hash should not be None"
22-
assert isinstance(tx_hash, HexBytes), (
23-
f"{label}: Transaction hash should be HexBytes"
22+
assert isinstance(tx_hash, str), (
23+
f"{label}: Transaction hash should be a string (TxHash)"
2424
)
25-
assert len(tx_hash) == 32, f"{label}: Transaction hash should be 32 bytes long"
25+
assert len(tx_hash) == 66, (
26+
f"{label}: Transaction hash should be 66 characters long (0x + 64 hex)"
27+
)
28+
assert tx_hash.startswith("0x"), f"{label}: Transaction hash should start with 0x"
2629

2730

2831
class TestEntityCreate:
@@ -171,3 +174,68 @@ def test_create_entity_simple(self, arkiv_client_http: Arkiv) -> None:
171174
f"{label}: Entity expiration block should be in the future"
172175
)
173176
logger.info(f"{label}: Entity creation and retrieval successful")
177+
178+
def test_create_entities_bulk(self, arkiv_client_http: Arkiv) -> None:
179+
"""Test create_entities for bulk entity creation."""
180+
# Create multiple CreateOp objects
181+
create_ops = [
182+
CreateOp(
183+
payload=b"Entity 1",
184+
annotations=Annotations({"type": "bulk", "index": 1}),
185+
btl=100,
186+
),
187+
CreateOp(
188+
payload=b"Entity 2",
189+
annotations=Annotations({"type": "bulk", "index": 2}),
190+
btl=100,
191+
),
192+
CreateOp(
193+
payload=b"Entity 3",
194+
annotations=Annotations({"type": "bulk", "index": 3}),
195+
btl=100,
196+
),
197+
]
198+
199+
# Call create_entities
200+
entity_keys, tx_hash = arkiv_client_http.arkiv.create_entities(create_ops)
201+
202+
label = "create_entities"
203+
logger.info(f"{label}: Entity keys: {entity_keys}, tx_hash: {tx_hash}")
204+
205+
# Verify transaction hash
206+
assert tx_hash is not None, f"{label}: Transaction hash should not be None"
207+
check_tx_hash(label, tx_hash)
208+
209+
# Verify all entities were created
210+
assert len(entity_keys) == 3, f"{label}: Should have 3 created entities"
211+
212+
# Verify each entity can be retrieved and has correct data
213+
for i, entity_key in enumerate(entity_keys):
214+
check_entity_key(entity_key, f"{label} entity {i + 1}")
215+
216+
entity = arkiv_client_http.arkiv.get_entity(entity_key)
217+
logger.info(f"{label}: Retrieved entity {i + 1}: {entity}")
218+
219+
expected_payload = f"Entity {i + 1}".encode()
220+
expected_annotations = Annotations({"type": "bulk", "index": i + 1})
221+
222+
assert entity.payload == expected_payload, (
223+
f"{label}: Entity {i + 1} payload should match"
224+
)
225+
assert entity.annotations == expected_annotations, (
226+
f"{label}: Entity {i + 1} annotations should match"
227+
)
228+
assert entity.owner == arkiv_client_http.eth.default_account, (
229+
f"{label}: Entity {i + 1} owner should match transaction sender"
230+
)
231+
232+
logger.info(f"{label}: Bulk entity creation successful")
233+
234+
def test_create_entities_empty_list_raises(self, arkiv_client_http: Arkiv) -> None:
235+
"""Test that create_entities raises ValueError for empty list."""
236+
import pytest
237+
238+
with pytest.raises(
239+
ValueError, match="create_ops must contain at least one CreateOp"
240+
):
241+
arkiv_client_http.arkiv.create_entities([])

tests/test_entity_create_parallel.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def client_task(client: Arkiv, client_idx: int, num_entities: int) -> list[Entit
8888
entity_key, tx_hash = client.arkiv.create_entity(
8989
payload=payload, annotations=annotations, btl=btl
9090
)
91-
logger.info(f"Entity creation TX[{client_idx}][{j}]: {tx_hash.to_0x_hex()}")
91+
logger.info(f"Entity creation TX[{client_idx}][{j}]: {tx_hash}")
9292
created.append(entity_key)
9393

9494
# entity tx successful, increase counter

0 commit comments

Comments
 (0)