diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index fd1c375..72d1e17 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -24,13 +24,6 @@ jobs: virtualenvs-create: true virtualenvs-in-project: true - - name: Load cached venv - id: cached-poetry-dependencies - uses: actions/cache@v3 - with: - path: ./.venv - key: venv-${{ runner.os }}-${{ matrix.python }}-${{ hashFiles('poetry.lock') }} - - name: Install dependencies if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' run: poetry install --with dev diff --git a/CHANGELOG.md b/CHANGELOG.md index 29ed4fe..eb05bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CDP Python SDK Changelog +## [0.18.0] - 2025-02-12 + +### Added + +- Add `TransactionReceipt` and `TransactionLog` to contract invocation response. + + ## [0.17.0] - 2025-02-11 ### Added diff --git a/cdp/__version__.py b/cdp/__version__.py index 5a313cc..1317d75 100644 --- a/cdp/__version__.py +++ b/cdp/__version__.py @@ -1 +1 @@ -__version__ = "0.16.0" +__version__ = "0.18.0" diff --git a/cdp/client/__init__.py b/cdp/client/__init__.py index 1eac2d4..4946da2 100644 --- a/cdp/client/__init__.py +++ b/cdp/client/__init__.py @@ -159,6 +159,8 @@ from cdp.client.models.trade_list import TradeList from cdp.client.models.transaction import Transaction from cdp.client.models.transaction_content import TransactionContent +from cdp.client.models.transaction_log import TransactionLog +from cdp.client.models.transaction_receipt import TransactionReceipt from cdp.client.models.transaction_type import TransactionType from cdp.client.models.transfer import Transfer from cdp.client.models.transfer_list import TransferList diff --git a/cdp/client/models/__init__.py b/cdp/client/models/__init__.py index 80300d3..7070d25 100644 --- a/cdp/client/models/__init__.py +++ b/cdp/client/models/__init__.py @@ -122,6 +122,8 @@ from cdp.client.models.trade_list import TradeList from cdp.client.models.transaction import Transaction from cdp.client.models.transaction_content import TransactionContent +from cdp.client.models.transaction_log import TransactionLog +from cdp.client.models.transaction_receipt import TransactionReceipt from cdp.client.models.transaction_type import TransactionType from cdp.client.models.transfer import Transfer from cdp.client.models.transfer_list import TransferList diff --git a/cdp/client/models/ethereum_transaction.py b/cdp/client/models/ethereum_transaction.py index 9405d8e..4658a3c 100644 --- a/cdp/client/models/ethereum_transaction.py +++ b/cdp/client/models/ethereum_transaction.py @@ -23,6 +23,7 @@ from cdp.client.models.ethereum_token_transfer import EthereumTokenTransfer from cdp.client.models.ethereum_transaction_access_list import EthereumTransactionAccessList from cdp.client.models.ethereum_transaction_flattened_trace import EthereumTransactionFlattenedTrace +from cdp.client.models.transaction_receipt import TransactionReceipt from typing import Optional, Set from typing_extensions import Self @@ -49,7 +50,8 @@ class EthereumTransaction(BaseModel): block_timestamp: Optional[datetime] = Field(default=None, description="The timestamp of the block in which the event was emitted") mint: Optional[StrictStr] = Field(default=None, description="This is for handling optimism rollup specific EIP-2718 transaction type field.") rlp_encoded_tx: Optional[StrictStr] = Field(default=None, description="RLP encoded transaction as a hex string (prefixed with 0x) for native compatibility with popular eth clients such as etherjs, viem etc.") - __properties: ClassVar[List[str]] = ["from", "gas", "gas_price", "hash", "input", "nonce", "to", "index", "value", "type", "max_fee_per_gas", "max_priority_fee_per_gas", "priority_fee_per_gas", "transaction_access_list", "token_transfers", "flattened_traces", "block_timestamp", "mint", "rlp_encoded_tx"] + receipt: Optional[TransactionReceipt] = None + __properties: ClassVar[List[str]] = ["from", "gas", "gas_price", "hash", "input", "nonce", "to", "index", "value", "type", "max_fee_per_gas", "max_priority_fee_per_gas", "priority_fee_per_gas", "transaction_access_list", "token_transfers", "flattened_traces", "block_timestamp", "mint", "rlp_encoded_tx", "receipt"] model_config = ConfigDict( populate_by_name=True, @@ -107,6 +109,9 @@ def to_dict(self) -> Dict[str, Any]: if _item_flattened_traces: _items.append(_item_flattened_traces.to_dict()) _dict['flattened_traces'] = _items + # override the default output from pydantic by calling `to_dict()` of receipt + if self.receipt: + _dict['receipt'] = self.receipt.to_dict() return _dict @classmethod @@ -137,7 +142,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "flattened_traces": [EthereumTransactionFlattenedTrace.from_dict(_item) for _item in obj["flattened_traces"]] if obj.get("flattened_traces") is not None else None, "block_timestamp": obj.get("block_timestamp"), "mint": obj.get("mint"), - "rlp_encoded_tx": obj.get("rlp_encoded_tx") + "rlp_encoded_tx": obj.get("rlp_encoded_tx"), + "receipt": TransactionReceipt.from_dict(obj["receipt"]) if obj.get("receipt") is not None else None }) return _obj diff --git a/cdp/client/models/ethereum_validator_metadata.py b/cdp/client/models/ethereum_validator_metadata.py index 43d72ec..bf73f3f 100644 --- a/cdp/client/models/ethereum_validator_metadata.py +++ b/cdp/client/models/ethereum_validator_metadata.py @@ -18,7 +18,7 @@ import json from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr -from typing import Any, ClassVar, Dict, List +from typing import Any, ClassVar, Dict, List, Optional from cdp.client.models.balance import Balance from typing import Optional, Set from typing_extensions import Self @@ -36,7 +36,9 @@ class EthereumValidatorMetadata(BaseModel): withdrawable_epoch: StrictStr = Field(description="The epoch at which the validator can withdraw.", alias="withdrawableEpoch") balance: Balance effective_balance: Balance - __properties: ClassVar[List[str]] = ["index", "public_key", "withdrawal_address", "slashed", "activationEpoch", "exitEpoch", "withdrawableEpoch", "balance", "effective_balance"] + fee_recipient_address: StrictStr = Field(description="The address for execution layer rewards (MEV & tx fees). If using a reward splitter plan, this is a smart contract address that splits rewards based on defined commissions and send a portion to the forwarded_fee_recipient_address. ") + forwarded_fee_recipient_address: Optional[StrictStr] = Field(default=None, description="If using a reward splitter plan, this address receives a defined percentage of the total execution layer rewards. ") + __properties: ClassVar[List[str]] = ["index", "public_key", "withdrawal_address", "slashed", "activationEpoch", "exitEpoch", "withdrawableEpoch", "balance", "effective_balance", "fee_recipient_address", "forwarded_fee_recipient_address"] model_config = ConfigDict( populate_by_name=True, @@ -103,7 +105,9 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "exitEpoch": obj.get("exitEpoch"), "withdrawableEpoch": obj.get("withdrawableEpoch"), "balance": Balance.from_dict(obj["balance"]) if obj.get("balance") is not None else None, - "effective_balance": Balance.from_dict(obj["effective_balance"]) if obj.get("effective_balance") is not None else None + "effective_balance": Balance.from_dict(obj["effective_balance"]) if obj.get("effective_balance") is not None else None, + "fee_recipient_address": obj.get("fee_recipient_address"), + "forwarded_fee_recipient_address": obj.get("forwarded_fee_recipient_address") }) return _obj diff --git a/cdp/client/models/transaction_log.py b/cdp/client/models/transaction_log.py new file mode 100644 index 0000000..401ccb6 --- /dev/null +++ b/cdp/client/models/transaction_log.py @@ -0,0 +1,91 @@ +# coding: utf-8 + +""" + Coinbase Platform API + + This is the OpenAPI 3.0 specification for the Coinbase Platform APIs, used in conjunction with the Coinbase Platform SDKs. + + The version of the OpenAPI document: 0.0.1-alpha + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing import Any, ClassVar, Dict, List +from typing import Optional, Set +from typing_extensions import Self + +class TransactionLog(BaseModel): + """ + A log emitted from an onchain transaction. + """ # noqa: E501 + address: StrictStr = Field(description="An onchain address of a contract.") + topics: List[StrictStr] + data: StrictStr = Field(description="The data included in this log.") + __properties: ClassVar[List[str]] = ["address", "topics", "data"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of TransactionLog from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of TransactionLog from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "address": obj.get("address"), + "topics": obj.get("topics"), + "data": obj.get("data") + }) + return _obj + + diff --git a/cdp/client/models/transaction_receipt.py b/cdp/client/models/transaction_receipt.py new file mode 100644 index 0000000..ae97b1f --- /dev/null +++ b/cdp/client/models/transaction_receipt.py @@ -0,0 +1,101 @@ +# coding: utf-8 + +""" + Coinbase Platform API + + This is the OpenAPI 3.0 specification for the Coinbase Platform APIs, used in conjunction with the Coinbase Platform SDKs. + + The version of the OpenAPI document: 0.0.1-alpha + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr +from typing import Any, ClassVar, Dict, List +from cdp.client.models.transaction_log import TransactionLog +from typing import Optional, Set +from typing_extensions import Self + +class TransactionReceipt(BaseModel): + """ + The receipt of an onchain transaction's execution. + """ # noqa: E501 + status: StrictInt = Field(description="The status of a transaction is 1 if successful or 0 if it was reverted.") + logs: List[TransactionLog] + gas_used: StrictStr = Field(description="The amount of gas actually used by this transaction.") + effective_gas_price: StrictStr = Field(description="The effective gas price the transaction was charged at.") + __properties: ClassVar[List[str]] = ["status", "logs", "gas_used", "effective_gas_price"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of TransactionReceipt from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of each item in logs (list) + _items = [] + if self.logs: + for _item_logs in self.logs: + if _item_logs: + _items.append(_item_logs.to_dict()) + _dict['logs'] = _items + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of TransactionReceipt from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "status": obj.get("status"), + "logs": [TransactionLog.from_dict(_item) for _item in obj["logs"]] if obj.get("logs") is not None else None, + "gas_used": obj.get("gas_used"), + "effective_gas_price": obj.get("effective_gas_price") + }) + return _obj + + diff --git a/cdp/client/models/user_operation.py b/cdp/client/models/user_operation.py index 947319c..f2ca9b2 100644 --- a/cdp/client/models/user_operation.py +++ b/cdp/client/models/user_operation.py @@ -32,15 +32,13 @@ class UserOperation(BaseModel): calls: List[Call] = Field(description="The list of calls to make from the smart wallet.") unsigned_payload: StrictStr = Field(description="The hex-encoded hash that must be signed by the user.") signature: Optional[StrictStr] = Field(default=None, description="The hex-encoded signature of the user operation.") - status: Optional[StrictStr] = Field(default=None, description="The status of the user operation.") - __properties: ClassVar[List[str]] = ["id", "network_id", "calls", "unsigned_payload", "signature", "status"] + transaction_hash: Optional[StrictStr] = Field(default=None, description="The hash of the transaction that was broadcast.") + status: StrictStr = Field(description="The status of the user operation.") + __properties: ClassVar[List[str]] = ["id", "network_id", "calls", "unsigned_payload", "signature", "transaction_hash", "status"] @field_validator('status') def status_validate_enum(cls, value): """Validates the enum""" - if value is None: - return value - if value not in set(['pending', 'signed', 'broadcast', 'complete', 'failed']): raise ValueError("must be one of enum values ('pending', 'signed', 'broadcast', 'complete', 'failed')") return value @@ -108,6 +106,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "calls": [Call.from_dict(_item) for _item in obj["calls"]] if obj.get("calls") is not None else None, "unsigned_payload": obj.get("unsigned_payload"), "signature": obj.get("signature"), + "transaction_hash": obj.get("transaction_hash"), "status": obj.get("status") }) return _obj diff --git a/docs/conf.py b/docs/conf.py index 0978418..4413861 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,7 +14,7 @@ project = 'CDP SDK' author = 'Coinbase Developer Platform' -release = '0.17.0' +release = '0.18.0' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/pyproject.toml b/pyproject.toml index ccc9730..bbb2372 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cdp-sdk" -version = "0.17.0" +version = "0.18.0" description = "CDP Python SDK" authors = ["John Peterson "] license = "LICENSE.md" diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 04ba70a..98032c0 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -171,6 +171,36 @@ def test_historical_balances(imported_wallet): assert balances assert all(balance.amount > 0 for balance in balances) +@pytest.mark.e2e +def test_invoke_contract_with_transaction_receipt(imported_wallet): + """Test invoke contract with transaction receipt.""" + destination_wallet = Wallet.create() + + faucet_transaction = imported_wallet.faucet("usdc") + faucet_transaction.wait() + + # Transfer 0.000001 USDC to the destination address. + invocation = imported_wallet.invoke_contract( + contract_address="0x036CbD53842c5426634e7929541eC2318f3dCF7e", + method="transfer", + args={"to": destination_wallet.default_address.address_id, "value": "1"} + ) + + invocation.wait() + + transaction_content = invocation.transaction.content.actual_instance + transaction_receipt = transaction_content.receipt + + assert transaction_receipt.status == 1 + + transaction_logs = transaction_receipt.logs + assert len(transaction_logs) == 1 + + transaction_log = transaction_logs[0] + assert transaction_log.address == "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + assert transaction_log.topics[0] == "Transfer" + assert transaction_log.topics[1] == f"from: {imported_wallet.default_address.address_id}" + assert transaction_log.topics[2] == f"to: {destination_wallet.default_address.address_id}" @pytest.mark.skip(reason="Gasless transfers have unpredictable latency") def test_gasless_transfer(imported_wallet):