diff --git a/.changelog/t7-storage-credits.md b/.changelog/t7-storage-credits.md new file mode 100644 index 0000000..58c1af0 --- /dev/null +++ b/.changelog/t7-storage-credits.md @@ -0,0 +1,8 @@ +--- +pytempo: minor +--- + +Add T7 StorageCredits precompile bindings (TIP-1060): the `StorageCredits` +helper with `set_mode`/`set_budget` call builders and `balance_of`/`mode_of`/ +`budget_of` views, the `StorageCreditMode` enum, the `STORAGE_CREDITS_ADDRESS` +precompile address, and the `IStorageCredits` ABI. diff --git a/README.md b/README.md index 62c381c..b6b8328 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,38 @@ Typed call builders for Tempo precompiles and tokens: - `FeeAMM` — Fee AMM liquidity operations (mint, burn, rebalance_swap) - `FeeManager` — Fee manager operations (set fee token, distribute fees); inherits `FeeAMM` - `Nonce` — Nonce precompile queries (get_nonce) +- `StorageCredits` — T7 storage-credit accounting (set_mode, set_budget, balance/mode/budget queries) + +### Storage Credits (T7 / TIP-1060, v0.6.0+) + +Storage credits offset the cost of reusing previously freed storage. Each +account earns a credit when it deletes one of its own storage slots and can +apply it to a later storage creation via a per-transaction *mode*. + +```python +from pytempo import TempoTransaction +from pytempo.contracts import StorageCredits, StorageCreditMode + +# Read the persistent credit balance for an account +balance = StorageCredits.balance_of(w3, account="0xYourAddress...") + +# Select a storage-creation mode for the current transaction. +# NOTE: mode/budget are transaction-local (reset every tx) and must come +# BEFORE the storage-creating calls they affect. +tx = TempoTransaction.create( + chain_id=42429, + gas_limit=200_000, + max_fee_per_gas=2_000_000_000, + calls=( + StorageCredits.set_mode(StorageCreditMode.PRESERVE), + # ... subsequent calls that create storage ... + ), +) +``` + +Modes: `REFUND` (default, simulation-safe), `PRESERVE` (never consume credits), +`DIRECT` (consume synchronously; `set_budget(n)` bounds spend to `n`, while +`set_mode(DIRECT)` uses `uint64` max). ## Development diff --git a/pytempo/contracts/__init__.py b/pytempo/contracts/__init__.py index 33a6a65..9e74fe7 100644 --- a/pytempo/contracts/__init__.py +++ b/pytempo/contracts/__init__.py @@ -33,6 +33,7 @@ RECEIVE_POLICY_GUARD_ABI, SIGNATURE_VERIFIER_ABI, STABLECOIN_DEX_ABI, + STORAGE_CREDITS_ABI, TIP20_ABI, TIP20_ROLES_AUTH_ABI, TIP403_REGISTRY_ABI, @@ -48,6 +49,7 @@ RECEIVE_POLICY_GUARD_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, STABLECOIN_DEX_ADDRESS, + STORAGE_CREDITS_ADDRESS, THETA_USD, TIP20_FACTORY_ADDRESS, TIP20_REWARDS_REGISTRY_ADDRESS, @@ -60,6 +62,7 @@ from .nonce import Nonce from .receive_policy_guard import InboundKind, ReceivePolicyGuard from .signature_verifier import SignatureVerifier +from .storage_credits import StorageCreditMode, StorageCredits from .tip20 import TIP20 from .tip403 import BlockedReason, PolicyType, TIP403Registry @@ -74,10 +77,12 @@ "TIP403Registry", "ReceivePolicyGuard", "SignatureVerifier", + "StorageCredits", # Enums "PolicyType", "BlockedReason", "InboundKind", + "StorageCreditMode", # ABIs "TIP20_ABI", "TIP20_ROLES_AUTH_ABI", @@ -89,6 +94,7 @@ "TIP403_REGISTRY_ABI", "RECEIVE_POLICY_GUARD_ABI", "SIGNATURE_VERIFIER_ABI", + "STORAGE_CREDITS_ABI", # Token addresses "PATH_USD", "ALPHA_USD", @@ -104,5 +110,6 @@ "TIP403_REGISTRY_ADDRESS", "RECEIVE_POLICY_GUARD_ADDRESS", "SIGNATURE_VERIFIER_ADDRESS", + "STORAGE_CREDITS_ADDRESS", "VALIDATOR_CONFIG_ADDRESS", ] diff --git a/pytempo/contracts/abis/IStorageCredits.json b/pytempo/contracts/abis/IStorageCredits.json new file mode 100644 index 0000000..a2c4402 --- /dev/null +++ b/pytempo/contracts/abis/IStorageCredits.json @@ -0,0 +1,95 @@ +[ + { + "inputs": [], + "name": "InvalidMode", + "type": "error" + }, + { + "inputs": [], + "name": "OnlyDirectCall", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "budgetOf", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "modeOf", + "outputs": [ + { + "internalType": "enum IStorageCredits.Mode", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint64", + "name": "credits", + "type": "uint64" + } + ], + "name": "setBudget", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "enum IStorageCredits.Mode", + "name": "newMode", + "type": "uint8" + } + ], + "name": "setMode", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] \ No newline at end of file diff --git a/pytempo/contracts/abis/__init__.py b/pytempo/contracts/abis/__init__.py index 45ff5b5..0404d14 100644 --- a/pytempo/contracts/abis/__init__.py +++ b/pytempo/contracts/abis/__init__.py @@ -21,3 +21,4 @@ def _load(name: str) -> list: TIP403_REGISTRY_ABI = _load("ITIP403Registry") RECEIVE_POLICY_GUARD_ABI = _load("IReceivePolicyGuard") SIGNATURE_VERIFIER_ABI = _load("ISignatureVerifier") +STORAGE_CREDITS_ABI = _load("IStorageCredits") diff --git a/pytempo/contracts/addresses.py b/pytempo/contracts/addresses.py index 023828d..a388920 100644 --- a/pytempo/contracts/addresses.py +++ b/pytempo/contracts/addresses.py @@ -34,6 +34,10 @@ RECEIVE_POLICY_GUARD_ADDRESS = to_checksum_address( "0xB10C000000000000000000000000000000000000" ) +# T7 (TIP-1060) StorageCredits precompile — per-account reusable storage accounting +STORAGE_CREDITS_ADDRESS = to_checksum_address( + "0x1060000000000000000000000000000000000000" +) # Tokens (from StdTokens.sol) PATH_USD = to_checksum_address("0x20C0000000000000000000000000000000000000") diff --git a/pytempo/contracts/storage_credits.py b/pytempo/contracts/storage_credits.py new file mode 100644 index 0000000..211720f --- /dev/null +++ b/pytempo/contracts/storage_credits.py @@ -0,0 +1,181 @@ +"""Typed helpers for the StorageCredits precompile (T7 / TIP-1060). + +Storage credits are per-account, non-transferable credits that offset the +TIP-1000 storage-creation cost when previously freed storage is reused. An +account earns one credit whenever it deletes one of its own storage slots +(a nonzero-to-zero transition) and can apply that credit to a later storage +creation. + +Each account selects how its credits are applied to its own storage creations +via a *mode* (see :class:`StorageCreditMode`) and, for ``Direct`` mode, a +*budget*. + +Transient vs. persistent state: + +- ``balance_of`` reads **persistent** state (the account's credit balance). +- ``mode_of`` and ``budget_of`` read **transaction-local (transient)** state. + The mode defaults to ``Refund`` and the budget to ``0`` at the start of every + transaction, and both reset at the end of the transaction. A standalone + ``eth_call`` therefore normally observes the defaults, not a durable account + setting. + +Because mode/budget are transient, :meth:`StorageCredits.set_mode` and +:meth:`StorageCredits.set_budget` only affect the transaction they are included +in, and MUST appear **before** the storage-creating calls they are meant to +influence:: + + from pytempo.contracts import StorageCredits, StorageCreditMode + + tx = TempoTransaction.create( + ..., + calls=( + StorageCredits.set_mode(StorageCreditMode.PRESERVE), + # ... subsequent calls that create storage ... + ), + ) + + balance = StorageCredits.balance_of(w3, account="0xAbc...") +""" + +from __future__ import annotations + +from enum import IntEnum + +from pytempo.models import Call + +from ._decode import decode_u64 +from ._encode import encode_calldata +from .abis import STORAGE_CREDITS_ABI +from .addresses import STORAGE_CREDITS_ADDRESS + +_ABI = STORAGE_CREDITS_ABI + + +class StorageCreditMode(IntEnum): + """How an account applies storage credits to its own storage creations. + + - ``REFUND`` (default): ``STORAGE_CREDIT_VALUE`` is charged upfront; at + end-of-transaction settlement, any available credit is consumed and the + value refunded. Simulation-safe — required gas does not depend on the + credit balance at inclusion time. + - ``PRESERVE``: the full storage-creation cost is always charged; credits + are never consumed. + - ``DIRECT``: if a credit and nonzero budget are available, one credit is + consumed synchronously to cover ``STORAGE_CREDIT_VALUE``. Carries a + simulation-vs-inclusion risk: a credit available at simulation may be + drained before inclusion, causing the transaction to run out of gas. + """ + + REFUND = 0 + PRESERVE = 1 + DIRECT = 2 + + +def _require_account(account: str) -> None: + if not account: + raise ValueError("account required") + + +class StorageCredits: + """StorageCredits precompile call builders and views. + + All methods target the StorageCredits precompile at + :data:`STORAGE_CREDITS_ADDRESS`. + """ + + ADDRESS = STORAGE_CREDITS_ADDRESS + + @staticmethod + def set_mode(mode: StorageCreditMode | int) -> Call: + """Build a ``setMode(uint8)`` call for the calling account. + + Sets the caller's transient storage-creation mode for the current + transaction. Per TIP-1060, ``setMode(DIRECT)`` sets the direct-spend + budget to ``type(uint64).max`` (effectively unlimited); use + :meth:`set_budget` instead to enter ``DIRECT`` mode with a bounded + budget. Selecting any non-``DIRECT`` mode resets the budget to ``0``. + + Args: + mode: A :class:`StorageCreditMode` or an equivalent ``int`` + (``0``, ``1``, or ``2``). + + Raises: + ValueError: If ``mode`` is not a valid :class:`StorageCreditMode` + (e.g. the reserved value ``3``). + """ + mode_value = StorageCreditMode(mode) + data = encode_calldata(_ABI, "setMode", [int(mode_value)]) + return Call.create(to=STORAGE_CREDITS_ADDRESS, data=data) + + @staticmethod + def set_budget(credits: int) -> Call: + """Build a ``setBudget(uint64)`` call for the calling account. + + Switches the caller to ``DIRECT`` mode with ``credits`` as the maximum + number of credits spendable synchronously in the current transaction. + ``credits == 0`` selects ``DIRECT`` mode with no immediately spendable + budget. + + Args: + credits: Maximum credits to spend directly this transaction. Must + fit in a ``uint64``. + """ + if not 0 <= credits <= 2**64 - 1: + raise ValueError("credits must fit in uint64") + data = encode_calldata(_ABI, "setBudget", [credits]) + return Call.create(to=STORAGE_CREDITS_ADDRESS, data=data) + + @staticmethod + def balance_of(w3, *, account: str) -> int: + """Query the persistent storage-credit balance for ``account``. + + Args: + w3: Web3 instance connected to a Tempo RPC. + account: The account whose credit balance is queried. + + Returns: + The number of unspent credits (saturates at ``uint64`` max). + + Raises: + ValueError: If ``account`` is empty or the result is malformed. + """ + _require_account(account) + call_data = encode_calldata(_ABI, "balanceOf", [account]) + result = w3.eth.call({"to": STORAGE_CREDITS_ADDRESS, "data": call_data}) + return decode_u64(result, "balanceOf") + + @staticmethod + def mode_of(w3, *, account: str) -> StorageCreditMode: + """Query the transient storage-creation mode for ``account``. + + Note: mode is transaction-local. A standalone call normally returns the + default ``REFUND`` unless the account changed it earlier in the same + transaction. + + Raises: + ValueError: If ``account`` is empty, the result is malformed, or the + chain returns an unknown mode value. + """ + _require_account(account) + call_data = encode_calldata(_ABI, "modeOf", [account]) + result = w3.eth.call({"to": STORAGE_CREDITS_ADDRESS, "data": call_data}) + value = decode_u64(result, "modeOf") + try: + return StorageCreditMode(value) + except ValueError as exc: + raise ValueError(f"modeOf returned unknown mode value {value}") from exc + + @staticmethod + def budget_of(w3, *, account: str) -> int: + """Query the transient Direct-spend budget for ``account``. + + Note: budget is transaction-local. A standalone call normally returns + ``0`` unless the account set it earlier in the same transaction. + + Raises: + ValueError: If ``account`` is empty or the result is malformed. + """ + _require_account(account) + call_data = encode_calldata(_ABI, "budgetOf", [account]) + result = w3.eth.call({"to": STORAGE_CREDITS_ADDRESS, "data": call_data}) + return decode_u64(result, "budgetOf") diff --git a/scripts/tempo_abis.sh b/scripts/tempo_abis.sh index b8a202c..91e0d5e 100755 --- a/scripts/tempo_abis.sh +++ b/scripts/tempo_abis.sh @@ -23,7 +23,7 @@ REPO="tempoxyz/tempo-std" # Bump this (and re-run --sync) to adopt newer interfaces. Override per-run with # TEMPO_STD_REF=. REF="${TEMPO_STD_REF:-84baf1077e1d704d4197845d48cf2b9e7148a6df}" -INTERFACES=(ITIP20 ITIP20RolesAuth IAccountKeychain IStablecoinDEX IFeeManager IFeeAMM INonce ITIP403Registry IReceivePolicyGuard ISignatureVerifier) +INTERFACES=(ITIP20 ITIP20RolesAuth IAccountKeychain IStablecoinDEX IFeeManager IFeeAMM INonce ITIP403Registry IReceivePolicyGuard ISignatureVerifier IStorageCredits) ABI_DIR="$(cd "$(dirname "$0")/.." && pwd)/pytempo/contracts/abis" WORK_DIR=$(mktemp -d) diff --git a/tests/test_t7_bindings.py b/tests/test_t7_bindings.py new file mode 100644 index 0000000..3256b1f --- /dev/null +++ b/tests/test_t7_bindings.py @@ -0,0 +1,138 @@ +"""Tests for T7 contract bindings: StorageCredits precompile (TIP-1060).""" + +from unittest.mock import MagicMock + +import pytest +from eth_utils import function_signature_to_4byte_selector + +from pytempo.contracts import ( + STORAGE_CREDITS_ADDRESS, + StorageCreditMode, + StorageCredits, +) + +ADDR = "0x" + "aa" * 20 + + +def _selector(signature: str) -> bytes: + return function_signature_to_4byte_selector(signature) + + +class TestStorageCreditsAddress: + def test_address(self): + assert StorageCredits.ADDRESS == STORAGE_CREDITS_ADDRESS + assert bytes(bytes.fromhex(STORAGE_CREDITS_ADDRESS[2:])) == bytes.fromhex( + "1060" + "00" * 18 + ) + + +class TestSetMode: + def test_selector(self): + call = StorageCredits.set_mode(StorageCreditMode.PRESERVE) + assert bytes(call.to) == bytes.fromhex("1060" + "00" * 18) + assert call.data[:4] == _selector("setMode(uint8)") + + def test_encodes_each_mode(self): + for mode in ( + StorageCreditMode.REFUND, + StorageCreditMode.PRESERVE, + StorageCreditMode.DIRECT, + ): + call = StorageCredits.set_mode(mode) + # last byte of the single ABI word is the mode value + assert call.data[-1] == int(mode) + + def test_accepts_int(self): + call = StorageCredits.set_mode(2) + assert call.data[:4] == _selector("setMode(uint8)") + assert call.data[-1] == 2 + + def test_rejects_reserved_mode(self): + with pytest.raises(ValueError): + StorageCredits.set_mode(3) + + def test_rejects_negative_mode(self): + with pytest.raises(ValueError): + StorageCredits.set_mode(-1) + + +class TestSetBudget: + def test_selector(self): + call = StorageCredits.set_budget(5) + assert bytes(call.to) == bytes.fromhex("1060" + "00" * 18) + assert call.data[:4] == _selector("setBudget(uint64)") + assert call.data[-1] == 5 + + def test_zero_budget_allowed(self): + call = StorageCredits.set_budget(0) + assert call.data[:4] == _selector("setBudget(uint64)") + + def test_max_uint64_allowed(self): + call = StorageCredits.set_budget(2**64 - 1) + assert call.data[:4] == _selector("setBudget(uint64)") + + def test_rejects_overflow(self): + with pytest.raises(ValueError, match="uint64"): + StorageCredits.set_budget(2**64) + + def test_rejects_negative(self): + with pytest.raises(ValueError, match="uint64"): + StorageCredits.set_budget(-1) + + +class TestBalanceOf: + def test_decodes(self): + mock_w3 = MagicMock() + mock_w3.eth.call.return_value = (42).to_bytes(32, "big") + assert StorageCredits.balance_of(mock_w3, account=ADDR) == 42 + + def test_encodes_selector(self): + mock_w3 = MagicMock() + mock_w3.eth.call.return_value = (0).to_bytes(32, "big") + StorageCredits.balance_of(mock_w3, account=ADDR) + sent = mock_w3.eth.call.call_args[0][0] + assert sent["to"] == STORAGE_CREDITS_ADDRESS + assert bytes.fromhex(sent["data"][2:10]) == _selector("balanceOf(address)") + + def test_rejects_empty_account(self): + with pytest.raises(ValueError, match="account required"): + StorageCredits.balance_of(MagicMock(), account="") + + def test_rejects_overflow(self): + mock_w3 = MagicMock() + mock_w3.eth.call.return_value = (2**64).to_bytes(32, "big") + with pytest.raises(ValueError, match="uint64"): + StorageCredits.balance_of(mock_w3, account=ADDR) + + +class TestModeOf: + def test_decodes(self): + mock_w3 = MagicMock() + mock_w3.eth.call.return_value = (2).to_bytes(32, "big") + assert StorageCredits.mode_of(mock_w3, account=ADDR) is StorageCreditMode.DIRECT + + def test_default_refund(self): + mock_w3 = MagicMock() + mock_w3.eth.call.return_value = (0).to_bytes(32, "big") + assert StorageCredits.mode_of(mock_w3, account=ADDR) is StorageCreditMode.REFUND + + def test_rejects_unknown_mode(self): + mock_w3 = MagicMock() + mock_w3.eth.call.return_value = (3).to_bytes(32, "big") + with pytest.raises(ValueError, match="unknown mode"): + StorageCredits.mode_of(mock_w3, account=ADDR) + + def test_rejects_empty_account(self): + with pytest.raises(ValueError, match="account required"): + StorageCredits.mode_of(MagicMock(), account="") + + +class TestBudgetOf: + def test_decodes(self): + mock_w3 = MagicMock() + mock_w3.eth.call.return_value = (7).to_bytes(32, "big") + assert StorageCredits.budget_of(mock_w3, account=ADDR) == 7 + + def test_rejects_empty_account(self): + with pytest.raises(ValueError, match="account required"): + StorageCredits.budget_of(MagicMock(), account="")