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
20 changes: 18 additions & 2 deletions src/opengradient/client/opg_token.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""OPG token Permit2 approval utilities for x402 payments."""

from dataclasses import dataclass
import time
Comment on lines 3 to +4
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import ordering here is inconsistent with other modules in this repo (e.g., stdlib import ... lines come before from dataclasses import dataclass in src/opengradient/client/llm.py and src/opengradient/types.py). Reorder/group the stdlib imports (time, dataclasses, typing) to match the existing convention and avoid isort/ruff import-order noise.

Suggested change
from dataclasses import dataclass
import time
import time
from dataclasses import dataclass

Copilot uses AI. Check for mistakes.
from typing import Optional

from eth_account.account import LocalAccount
Expand All @@ -9,6 +10,9 @@

BASE_OPG_ADDRESS = "0x240b09731D96979f50B2C649C9CE10FcF9C7987F"
BASE_SEPOLIA_RPC = "https://sepolia.base.org"
APPROVAL_TX_TIMEOUT = 120
ALLOWANCE_CONFIRMATION_TIMEOUT = 120
ALLOWANCE_POLL_INTERVAL = 1.0

ERC20_ABI = [
{
Expand Down Expand Up @@ -102,12 +106,24 @@ def ensure_opg_approval(wallet_account: LocalAccount, opg_amount: float) -> Perm

signed = wallet_account.sign_transaction(tx) # type: ignore[arg-type]
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=APPROVAL_TX_TIMEOUT)

if receipt.status != 1: # type: ignore[attr-defined]
raise RuntimeError(f"Permit2 approval transaction reverted: {tx_hash.hex()}")

allowance_after = token.functions.allowance(owner, spender).call()
deadline = time.time() + ALLOWANCE_CONFIRMATION_TIMEOUT
allowance_after = allowance_before
while allowance_after < amount_base:
allowance_after = token.functions.allowance(owner, spender).call()
if allowance_after >= amount_base:
break
if time.time() >= deadline:
Comment on lines +114 to +120
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timeout logic should use a monotonic clock for reliability. Using time.time() can cause the polling loop to terminate too early or too late if system time changes (NTP adjustments, leap seconds, etc.). Consider switching to time.monotonic() for computing/checking deadline.

Suggested change
deadline = time.time() + ALLOWANCE_CONFIRMATION_TIMEOUT
allowance_after = allowance_before
while allowance_after < amount_base:
allowance_after = token.functions.allowance(owner, spender).call()
if allowance_after >= amount_base:
break
if time.time() >= deadline:
deadline = time.monotonic() + ALLOWANCE_CONFIRMATION_TIMEOUT
allowance_after = allowance_before
while allowance_after < amount_base:
allowance_after = token.functions.allowance(owner, spender).call()
if allowance_after >= amount_base:
break
if time.monotonic() >= deadline:

Copilot uses AI. Check for mistakes.
raise RuntimeError(
"Permit2 approval transaction was mined, but the updated allowance "
f"was not visible within {ALLOWANCE_CONFIRMATION_TIMEOUT} seconds: {tx_hash.hex()}"
)
time.sleep(ALLOWANCE_POLL_INTERVAL)


return Permit2ApprovalResult(
allowance_before=allowance_before,
Expand Down
32 changes: 31 additions & 1 deletion tests/opg_token_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ def test_zero_amount_with_zero_allowance_skips(self, mock_wallet, mock_web3):

assert result.tx_hash is None


class TestEnsureOpgApprovalSendsTx:
"""Cases where allowance is insufficient and a transaction is sent."""

Expand Down Expand Up @@ -150,6 +149,37 @@ def test_gas_estimate_has_20_percent_buffer(self, mock_wallet, mock_web3):
tx_dict = approve_fn.build_transaction.call_args[0][0]
assert tx_dict["gas"] == int(50_000 * 1.2)

def test_waits_for_allowance_update_after_receipt(self, mock_wallet, mock_web3, monkeypatch):
"""After a successful receipt, poll allowance until the updated value is visible."""
monkeypatch.setattr("opengradient.client.opg_token.ALLOWANCE_POLL_INTERVAL", 0)
contract = _setup_allowance(mock_web3, 0)

approve_fn = MagicMock()
contract.functions.approve.return_value = approve_fn
approve_fn.estimate_gas.return_value = 50_000
approve_fn.build_transaction.return_value = {"mock": "tx"}

mock_web3.eth.get_transaction_count.return_value = 0
mock_web3.eth.gas_price = 1_000_000_000
mock_web3.eth.chain_id = 84532

signed = MagicMock()
signed.raw_transaction = b"\x00"
mock_wallet.sign_transaction.return_value = signed

tx_hash = MagicMock()
tx_hash.hex.return_value = "0xconfirmed"
mock_web3.eth.send_raw_transaction.return_value = tx_hash
mock_web3.eth.wait_for_transaction_receipt.return_value = SimpleNamespace(status=1, blockNumber=100)

amount_base = int(1.0 * 10**18)
contract.functions.allowance.return_value.call.side_effect = [0, 0, amount_base]
Comment on lines +152 to +176
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test can hang for up to 120 seconds on a regression because it sets ALLOWANCE_POLL_INTERVAL = 0 but leaves ALLOWANCE_CONFIRMATION_TIMEOUT at its default. To keep failures fast and deterministic, also monkeypatch ALLOWANCE_CONFIRMATION_TIMEOUT to a small value (or patch the time source) so the loop exits quickly if the allowance never updates.

Copilot uses AI. Check for mistakes.

result = ensure_opg_approval(mock_wallet, 1.0)

assert result.allowance_before == 0
assert result.allowance_after == amount_base
assert result.tx_hash == "0xconfirmed"

class TestEnsureOpgApprovalErrors:
"""Error handling paths."""
Expand Down
Loading