Skip to content

Commit eebc0f1

Browse files
refactor: strict integration tests + tx utils refactor
- Group: return tx_receipt in add_ips_to_group/remove_ips_from_group; add get_added_ip_to_group_events and get_removed_ip_from_group_events for chain verification - Integration tests: verify on-chain state via AddedIpToGroup/RemovedIpFromGroup events and get_claimable_reward - transaction_utils: refactor build_and_send_transaction (extract helpers, retry on replacement underpriced/nonce too low with same nonce + higher gas); add nonce validation in encodedTxDataOnly path
1 parent 8107042 commit eebc0f1

File tree

3 files changed

+272
-59
lines changed

3 files changed

+272
-59
lines changed

src/story_protocol_python_sdk/resources/Group.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,10 @@ def add_ips_to_group(
492492
tx_options=tx_options,
493493
)
494494

495-
return {"tx_hash": response["tx_hash"]}
495+
result = {"tx_hash": response["tx_hash"]}
496+
if "tx_receipt" in response:
497+
result["tx_receipt"] = response["tx_receipt"]
498+
return result
496499

497500
except Exception as e:
498501
raise ValueError(f"Failed to add IP to group: {str(e)}")
@@ -528,7 +531,10 @@ def remove_ips_from_group(
528531
tx_options=tx_options,
529532
)
530533

531-
return {"tx_hash": response["tx_hash"]}
534+
result = {"tx_hash": response["tx_hash"]}
535+
if "tx_receipt" in response:
536+
result["tx_receipt"] = response["tx_receipt"]
537+
return result
532538

533539
except Exception as e:
534540
raise ValueError(f"Failed to remove IPs from group: {str(e)}")
@@ -927,3 +933,57 @@ def _parse_tx_royalty_paid_event(self, tx_receipt: dict) -> list:
927933
)
928934

929935
return royalties_distributed
936+
937+
def get_added_ip_to_group_events(self, tx_receipt: dict) -> list:
938+
"""
939+
Parse AddedIpToGroup events from a transaction receipt (for chain-state verification).
940+
941+
:param tx_receipt dict: The transaction receipt.
942+
:return list: List of dicts with groupId and ipIds (checksum addresses).
943+
"""
944+
events = []
945+
for log in tx_receipt["logs"]:
946+
try:
947+
event_result = self.grouping_module_client.contract.events.AddedIpToGroup.process_log(
948+
log
949+
)
950+
args = event_result["args"]
951+
events.append(
952+
{
953+
"groupId": self.web3.to_checksum_address(args["groupId"]),
954+
"ipIds": [
955+
self.web3.to_checksum_address(addr)
956+
for addr in args["ipIds"]
957+
],
958+
}
959+
)
960+
except Exception:
961+
continue
962+
return events
963+
964+
def get_removed_ip_from_group_events(self, tx_receipt: dict) -> list:
965+
"""
966+
Parse RemovedIpFromGroup events from a transaction receipt (for chain-state verification).
967+
968+
:param tx_receipt dict: The transaction receipt.
969+
:return list: List of dicts with groupId and ipIds (checksum addresses).
970+
"""
971+
events = []
972+
for log in tx_receipt["logs"]:
973+
try:
974+
event_result = self.grouping_module_client.contract.events.RemovedIpFromGroup.process_log(
975+
log
976+
)
977+
args = event_result["args"]
978+
events.append(
979+
{
980+
"groupId": self.web3.to_checksum_address(args["groupId"]),
981+
"ipIds": [
982+
self.web3.to_checksum_address(addr)
983+
for addr in args["ipIds"]
984+
],
985+
}
986+
)
987+
except Exception:
988+
continue
989+
return events
Lines changed: 124 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,91 @@
1+
import time
2+
13
from web3 import Web3
24

35
TRANSACTION_TIMEOUT = 300
6+
REPLACEMENT_UNDERPRICED_RETRY_DELAY = 5
7+
REPLACEMENT_GAS_BUMP_RATIO = 1.2
8+
9+
10+
def _validate_nonce(nonce) -> int:
11+
"""Validate and return nonce. Raises ValueError if invalid."""
12+
if not isinstance(nonce, int) or nonce < 0:
13+
raise ValueError(
14+
f"Invalid nonce value: {nonce}. Nonce must be a non-negative integer."
15+
)
16+
return nonce
17+
18+
19+
def _get_transaction_options(
20+
web3: Web3,
21+
account,
22+
tx_options: dict,
23+
*,
24+
nonce_override: int | None = None,
25+
bump_gas: bool = False,
26+
) -> dict:
27+
"""
28+
Build the transaction options dict (from, nonce, value, gas).
29+
Used for both encodedTxDataOnly and send path.
30+
"""
31+
opts = {"from": account.address}
32+
33+
# Nonce: use override (retry), explicit from tx_options, or fetch from chain
34+
if nonce_override is not None:
35+
opts["nonce"] = nonce_override
36+
elif "nonce" in tx_options:
37+
opts["nonce"] = _validate_nonce(tx_options["nonce"])
38+
else:
39+
opts["nonce"] = web3.eth.get_transaction_count(account.address)
40+
41+
if "value" in tx_options:
42+
opts["value"] = tx_options["value"]
43+
44+
# Gas: bump for replacement, or use tx_options
45+
if bump_gas:
46+
try:
47+
opts["gasPrice"] = int(
48+
web3.eth.gas_price * REPLACEMENT_GAS_BUMP_RATIO
49+
)
50+
except Exception:
51+
opts["gasPrice"] = web3.to_wei(2, "gwei")
52+
else:
53+
if "gasPrice" in tx_options:
54+
opts["gasPrice"] = web3.to_wei(tx_options["gasPrice"], "gwei")
55+
if "maxFeePerGas" in tx_options:
56+
opts["maxFeePerGas"] = tx_options["maxFeePerGas"]
57+
58+
return opts
59+
60+
61+
def _is_retryable_send_error(exc: Exception) -> bool:
62+
"""True if we should retry send (same nonce, higher gas)."""
63+
msg = str(exc).lower()
64+
return (
65+
"replacement transaction underpriced" in msg
66+
or "nonce too low" in msg
67+
)
68+
69+
70+
def _send_one(
71+
web3: Web3,
72+
account,
73+
client_function,
74+
client_args: tuple,
75+
tx_options: dict,
76+
transaction_options: dict,
77+
) -> dict:
78+
"""Build, sign, send one transaction. No retry."""
79+
transaction = client_function(*client_args, transaction_options)
80+
signed_txn = account.sign_transaction(transaction)
81+
tx_hash = web3.eth.send_raw_transaction(signed_txn.raw_transaction)
82+
83+
if not tx_options.get("wait_for_receipt", True):
84+
return {"tx_hash": tx_hash.hex()}
85+
86+
timeout = tx_options.get("timeout", TRANSACTION_TIMEOUT)
87+
tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash, timeout=timeout)
88+
return {"tx_hash": tx_hash.hex(), "tx_receipt": tx_receipt}
489

590

691
def build_and_send_transaction(
@@ -13,6 +98,9 @@ def build_and_send_transaction(
1398
"""
1499
Builds and sends a transaction using the provided client function and arguments.
15100
101+
On "replacement transaction underpriced" or "nonce too low", retries once
102+
after a short delay with the same nonce and higher gas.
103+
16104
:param web3 Web3: An instance of Web3.
17105
:param account: The account to use for signing the transaction.
18106
:param client_function: The client function to build the transaction.
@@ -29,51 +117,44 @@ def build_and_send_transaction(
29117
or encoded data if encodedTxDataOnly is True.
30118
:raises Exception: If there is an error during the transaction process.
31119
"""
32-
try:
33-
tx_options = tx_options or {}
34-
35-
transaction_options = {
36-
"from": account.address,
37-
}
38-
39-
if "nonce" in tx_options:
40-
nonce = tx_options["nonce"]
41-
if not isinstance(nonce, int) or nonce < 0:
42-
raise ValueError(
43-
f"Invalid nonce value: {nonce}. Nonce must be a non-negative integer."
44-
)
45-
transaction_options["nonce"] = nonce
46-
else:
47-
transaction_options["nonce"] = web3.eth.get_transaction_count(
48-
account.address
49-
)
120+
tx_options = tx_options or {}
121+
client_args = tuple(client_args)
50122

51-
if "value" in tx_options:
52-
transaction_options["value"] = tx_options["value"]
123+
# Encode-only path: build options and return encoded data, no send
124+
if tx_options.get("encodedTxDataOnly"):
125+
opts = _get_transaction_options(web3, account, tx_options)
126+
encoded = client_function(*client_args, opts)
127+
return {"encodedTxData": encoded}
53128

54-
if "gasPrice" in tx_options:
55-
transaction_options["gasPrice"] = web3.to_wei(
56-
tx_options["gasPrice"], "gwei"
57-
)
58-
if "maxFeePerGas" in tx_options:
59-
transaction_options["maxFeePerGas"] = tx_options["maxFeePerGas"]
129+
# Send path: optionally retry once with same nonce + higher gas
130+
used_nonce = None
131+
last_error = None
60132

61-
transaction = client_function(*client_args, transaction_options)
133+
for attempt in range(2):
134+
opts = _get_transaction_options(
135+
web3,
136+
account,
137+
tx_options,
138+
nonce_override=used_nonce,
139+
bump_gas=(attempt == 1),
140+
)
141+
if used_nonce is None:
142+
used_nonce = opts["nonce"]
62143

63-
if tx_options.get("encodedTxDataOnly"):
64-
return {"encodedTxData": transaction}
65-
66-
signed_txn = account.sign_transaction(transaction)
67-
tx_hash = web3.eth.send_raw_transaction(signed_txn.raw_transaction)
68-
69-
wait_for_receipt = tx_options.get("wait_for_receipt", True)
70-
71-
if wait_for_receipt:
72-
timeout = tx_options.get("timeout", TRANSACTION_TIMEOUT)
73-
tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash, timeout=timeout)
74-
return {"tx_hash": tx_hash.hex(), "tx_receipt": tx_receipt}
75-
else:
76-
return {"tx_hash": tx_hash.hex()}
144+
try:
145+
return _send_one(
146+
web3,
147+
account,
148+
client_function,
149+
client_args,
150+
tx_options,
151+
opts,
152+
)
153+
except Exception as e:
154+
last_error = e
155+
if not _is_retryable_send_error(e):
156+
raise
157+
if attempt == 0:
158+
time.sleep(REPLACEMENT_UNDERPRICED_RETRY_DELAY)
77159

78-
except Exception as e:
79-
raise e
160+
raise last_error

0 commit comments

Comments
 (0)