diff --git a/README.md b/README.md index d68d5ff..d65a4f5 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ ## Overview -The Cartesi HLF is a framework developing DApps that run inside the [Cartesi](https://cartesi.io/) machine. +The Cartesi HLF is a framework developing Apps that run inside the [Cartesi](https://cartesi.io/) machine. The main goals of the framework are: - **Pythonic**: Offer a idiomatic way of writing the code and specifying the interactions. - **Easy to understand**: Inspired on widely used web frameworks, and have a clear to use interface. -- **Testability**: Have test as a first class citizen, giving the developer tools to write tests for the DApps that run on your local Python environment. +- **Testability**: Have test as a first class citizen, giving the developer tools to write tests for the Apps that run on your local Python environment. - **Flexibility**: You're free to take full control of the inputs and outputs for cases where the given high level tools are not enough. ## Installation @@ -19,23 +19,23 @@ To install the framework you just have to do a simple: pip install python-cartesi ``` -Although this is a pure Python library, it depends on PyCryptodome, which will need to compile some source code. You are advised to either include `build-essential` in the `apt-get install` command of your DApp's Dockerfile or include the line `--find-links https://prototyp3-dev.github.io/pip-wheels-riscv/wheels/` in the beginning of your requirements.txt file in order to use a pre-built binary for RiscV. +Although this is a pure Python library, it depends on PyCryptodome, which will need to compile some source code. You are advised to either include `build-essential` in the `apt-get install` command of your App's Dockerfile or include the line `--find-links https://prototyp3-dev.github.io/pip-wheels-riscv/wheels/` in the beginning of your requirements.txt file in order to use a pre-built binary for RiscV. ## Getting Started -A very simple DApp that simply echoes in a notice whatever input is sent to it can be seen below: +A very simple App that simply echoes in a notice whatever input is sent to it can be seen below: ```python import logging -from cartesi import DApp, Rollup, RollupData +from cartesi import App, Rollup, RollupData LOGGER = logging.getLogger(__name__) logging.basicConfig(level=logging.DEBUG) -dapp = DApp() +app = App() -@dapp.advance() +@app.advance() def handle_advance(rollup: Rollup, data: RollupData) -> bool: payload = data.str_payload() LOGGER.debug("Echoing '%s'", payload) @@ -44,10 +44,10 @@ def handle_advance(rollup: Rollup, data: RollupData) -> bool: if __name__ == '__main__': - dapp.run() + app.run() ``` -The handle_advance function will be registered as the DApp's default route of advance state requests by the `dapp.advance()` decorator. The framework also supplies several routers that will offer you convenient ways of handling inputs of commonly used formats. These routers will be discussed in the [Routers](#routers) section. +The handle_advance function will be registered as the App's default route of advance state requests by the `app.advance()` decorator. The framework also supplies several routers that will offer you convenient ways of handling inputs of commonly used formats. These routers will be discussed in the [Routers](#routers) section. The handler function also receives two inputs: an instance of a `Rollup` object, that will allow you to interact with the Rollup Server, and an instance of `RollupData`, that contains all the inputs and metadata for the current transaction. @@ -74,25 +74,112 @@ Adds a new [voucher](https://docs.cartesi.io/cartesi-rollups/main-concepts/#vouc - **destination**: The address of the destination contract as a string, starting with the `'0x'` prefix. - **payload**: The payload for the transaction in Ethereum hex binary format. The contents of this field will be the transaction's data field. +## Using the `Output` classes directly + +In addition to calling `rollup.notice()`, `rollup.report()`, and `rollup.voucher()` inside your handler, the Cartesi High-Level Framework also supports an object-oriented approach using specialized classes: `Notice`, `Report`, and `Voucher`. These classes implement a shared `Output` interface and offer more flexible and modular ways to emit outputs. + +Each of these classes can be used in multiple ways and support different helpers for common use cases: + +### Notice + +You can instantiate a notice directly using a hex-encoded string or use helper methods to generate one from a string or a JSON object. + +```python +Notice("0x68656c6c6f20776f726c64").create() +Notice.from_hex("0x68656c6c6f20776f726c64").create() +Notice.from_string("hello world").create() +Notice.from_json({"foo": "bar"}).create() +``` + +### Report + +Similar to notices, reports can also be created directly or from strings and JSON: + +```python +Report("0x68656c6c6f20776f726c64").create() +Report.from_hex("0x68656c6c6f20776f726c64").create() +Report.from_string("This is a log message").create() +Report.from_json({"event": "user_login", "user": "Alice"}).create() +``` + +### Voucher + +Vouchers represent actual transactions on the base layer. You can generate them from: + +* Hex strings of function selector + encoded payload +* Function selectors with ABI types and values + +```python +# From raw hex +Voucher.from_hex("0xRecipientAddress", "0xpayload").create() + +# From function selector +Voucher.from_function_selector( + destination="0xRecipientAddress", + selector="mint(address,string,uint256,uint256)", + types=["address", "string", "uint256", "uint256"], + values=[data["metadata"]["msg_sender"], "text_value", 100, 2000] +).create() +``` + +### Example usage in an advance handler + +```python +@dapp.advance() +def handle_advance(rollup: Rollup, data: RollupData) -> bool: + payload = data.str_payload() + + # Still using the Rollup object + rollup.notice("0x" + payload.encode('utf-8').hex()) + + # Also using the object-oriented style + Report.from_string("Received: " + payload).create() + Notice.from_json({"echoed": payload}).create() + + Voucher.from_function_selector( + destination="0x7b693356646348D27A745bFb3E3f9CC82B893a6c", + selector="registerPublicKey", + types=["bytes"], + values=[bytes.fromhex("ba57...")] + ).create() + + return True +``` + +This approach improves modularity, decouples your logic from the Rollup object, and can help with mocking or extending output behavior for testing or advanced scenarios. + +### Overriding the Rollup Server address + +By default, the `create()` method of `Notice`, `Report`, and `Voucher` will send the output to the Rollup Server address defined in the environment variable `ROLLUP_HTTP_SERVER_URL`. If that environment variable is not set, it falls back to `http://127.0.0.1:5004`. + +However, if you want to explicitly specify a different Rollup Server address—such as in testing environments or custom setups—you can override it by passing the `rollup_server_address` argument to the `create()` method: + +```python +custom_address = "http://localhost:7000" + +# Notice with overridden address +Notice.from_string("hello world").create(rollup_server_address=custom_address) +``` + ## Routers Routers simplify the coding experience by identifying the request type using common patterns in the input data, and calling your handler only when several conditions are met. -Once an input is received by either an advance-state or inspect request, the DApp will go through the list of registered handlers, find the first match and execute it. Each handler should return a boolean indicating whether the transaction was successful or not. If a handler for an advance state request returns false, the state of the DApp will be reverted to what it was before the transaction was received. +Once an input is received by either an advance-state or inspect request, the App will go through the list of registered handlers, find the first match and execute it. Each handler should return a boolean indicating whether the transaction was successful or not. If a handler for an advance state request returns false, the state of the App will be reverted to what it was before the transaction was received. -To use a router, it must be explicitly instantiated and added to the DApp. For example, to use a JSON Router, you should adapt your DApp code to include the `add_router()` call, like the snippet below: +To use a router, it must be explicitly instantiated and added to the App. For example, to use a JSON Router, you should adapt your App code to include the `add_router()` call, like the snippet below: ```python -from cartesi import DApp, JSONRouter +from cartesi import App, JSONRouter -# Create a DApp instance -dapp = DApp() +# Create a App instance +app = App() # Instantiate the JSON Router json_router = JSONRouter() -# Register the JSON Router into the DApp -dapp.add_router(json_router) +# Register the JSON Router into the App +app.add_router(json_router) ``` ### JSON Router @@ -107,11 +194,11 @@ The JSON Router expose two decorators methods: `advance(route_dict)` and `inspec For example, a route that handles the creation of a profile could be coded as below: ```python -from cartesi import DApp, Rollup, RollupData, JSONRouter +from cartesi import App, Rollup, RollupData, JSONRouter -dapp = DApp() +app = App() json_router = JSONRouter() -dapp.add_router(json_router) +app.add_router(json_router) @json_router.advance({"op": "create-profile"}) def handle_create_profile(rollup: Rollup, data: RollupData): @@ -121,10 +208,10 @@ def handle_create_profile(rollup: Rollup, data: RollupData): return True if __name__ == '__main__': - dapp.run() + app.run() ``` -For this DApp, if the data incoming from the Cartesi input is the equivalent to the JSON `{"op": "create-profile", "name": "John Doe"}`, router will match due to the presence of the `"op":"create-profile"` key-value pair, and the handler should generate a report containing the string "John Doe". +For this App, if the data incoming from the Cartesi input is the equivalent to the JSON `{"op": "create-profile", "name": "John Doe"}`, router will match due to the presence of the `"op":"create-profile"` key-value pair, and the handler should generate a report containing the string "John Doe". ### ABI Router @@ -142,11 +229,11 @@ To match with headers, you should pass an instance of a subclass of `ABIHeader` Matches with a literal header supplied by the developer in the `header` attribute, as bytes. For example, the following handler matches with inputs starting with the bytes `0x01020304`: ```python -from cartesi import DApp, Rollup, RollupData, ABIRouter, ABILiteralHeader +from cartesi import App, Rollup, RollupData, ABIRouter, ABILiteralHeader -dapp = DApp() +app = App() abi_router = ABIRouter() -dapp.add_router(abi_router) +app.add_router(abi_router) @abi_router.advance(header=ABILiteralHeader(header=bytes.fromhex('01020304'))) def handle_input_1234(rollup: Rollup, data: RollupData): @@ -187,11 +274,11 @@ class MyCustomHeader(ABIHeader): The `msg_sender` parameter for the advance decorator method of the `ABIRouter` will match not with the contents but with the sender of the message. For example, to match with the Cartesi's Ether Portal, you can declare a route like the code below: ```python -from cartesi import DApp, Rollup, RollupData, ABIRouter, ABILiteralHeader +from cartesi import App, Rollup, RollupData, ABIRouter, ABILiteralHeader -dapp = DApp() +app = App() abi_router = ABIRouter() -dapp.add_router(abi_router) +app.add_router(abi_router) ETHER_PORTAL = '0xffdbe43d4c855bf7e0f105c400a50857f53ab044' @@ -200,7 +287,7 @@ def handle_deposit(rollup: Rollup, data: RollupData): ... ``` -In this example, the `handle_deposit` function will be called whenever the Ether portal sends an input to the DApp. +In this example, the `handle_deposit` function will be called whenever the Ether portal sends an input to the App. Both the `msg_sender` and `header` parameters can be set at the same time. In this case, the message must match with both criteria to trigger the execution of the handler. @@ -221,14 +308,14 @@ The handler can receive a third argument of the `URLParameter` type. This object > [!IMPORTANT] > It is mandatory to correctly annotate the handler's parameters with type hints. The URLHandler will use this information to dynamically determine what information to send to the handler. -The code fragment for the DApp below, for example, will return a report containing the string 'Hello World' when the user send an input `hello/world`. When running with sunodo, this can be achieved by sending an HTTP GET request to `http://localhost:8000/inspect/hello/world`. +The code fragment for the App below, for example, will return a report containing the string 'Hello World' when the user send an input `hello/world`. When running with sunodo, this can be achieved by sending an HTTP GET request to `http://localhost:8000/inspect/hello/world`. ```python -from cartesi import DApp, Rollup, URLRouter, URLParameters +from cartesi import App, Rollup, URLRouter, URLParameters -dapp = DApp() +app = App() url_router = URLRouter() -dapp.add_router(url_router) +app.add_router(url_router) @url_router.inspect('hello/{name}') def hello_world_inspect_params(rollup: Rollup, params: URLParameters) -> bool: @@ -237,34 +324,18 @@ def hello_world_inspect_params(rollup: Rollup, params: URLParameters) -> bool: return True ``` -### DApp Relay Router - -This is a very simple router which will receive and accumulate the DApp's contract address, as reported by the DApp address relay contact. The router itself only exposes an attribute called `address`, that will be initialized as None and set to the address reported by the relay contract once it is received. - -```python -from cartesi import DApp -from cartesi.router import DAppAddressRouter - -ADDRESS_RELAY_ADDRESS = '0xf5de34d6bbc0446e2a45719e718efebaae179dae' - -dapp = DApp() - -dapp_address = DAppAddressRouter(relay_address=ADDRESS_RELAY_ADDRESS) -dapp.add_router(dapp_address) -``` - -### The DApp default Router +### The App default Router -The DApp object itself exposes two decorators: `advance()` and `inspect()`. The handled decorated with these methods will be called if none of the available routes match. They act, therefore, as a default handler for each type of request. This can be used to both create more specific error handlers for your application, or to handle specific cases not covered by a generic router. +The App object itself exposes two decorators: `advance()` and `inspect()`. The handled decorated with these methods will be called if none of the available routes match. They act, therefore, as a default handler for each type of request. This can be used to both create more specific error handlers for your application, or to handle specific cases not covered by a generic router. -For example, given the following DApp: +For example, given the following App: ```python -from cartesi import DApp, Rollup, RollupData, JSONRouter +from cartesi import App, Rollup, RollupData, JSONRouter -dapp = DApp() +app = App() json_router = JSONRouter() -dapp.add_router(json_router) +app.add_router(json_router) @json_router.advance({"op": "create-profile"}) def handle_create_profile(rollup: Rollup, data: RollupData): @@ -273,22 +344,22 @@ def handle_create_profile(rollup: Rollup, data: RollupData): rollup.report('0x' + name.encode('utf-8').hex()) return True -@dapp.advance() +@app.advance() def default_handler(rollup: Rollup, data: RollupData): rollup.report('0x' + 'Unknown Operation'.encode('utf-8').hex()) return True if __name__ == '__main__': - dapp.run() + app.run() ``` If the user passes an invalid JSON or a document that does not contain the `"op":"create-profile"` key-value pair, the `handle_create_profile` route will not match and the framework will call the `default_handler` function with the input. ## Testing -Testing is an important part of the development of complex software. The framework provides a TestClient that can be used to interact a DApp inside automated tests. The constructor of the `TestClient` class expects a fully configured instance of the `DApp` class, and expose methods for sending advance and inspect requests. +Testing is an important part of the development of complex software. The framework provides a TestClient that can be used to interact a App inside automated tests. The constructor of the `TestClient` class expects a fully configured instance of the `App` class, and expose methods for sending advance and inspect requests. -For example, supposing we have an `echo.py` file with an implementation of an echo DApp, we could write the following file for automated tests using pytest: +For example, supposing we have an `echo.py` file with an implementation of an echo App, we could write the following file for automated tests using pytest: ```python from cartesi.testclient import TestClient @@ -297,18 +368,18 @@ import pytest import echo @pytest.fixture -def dapp_client() -> TestClient: - client = TestClient(echo.dapp) +def app_client() -> TestClient: + client = TestClient(echo.app) return client -def test_simple_echo(dapp_client: TestClient): +def test_simple_echo(app_client: TestClient): hex_payload = '0x' + 'hello'.encode('utf-8').hex() - dapp_client.send_advance(hex_payload=hex_payload) + app_client.send_advance(hex_payload=hex_payload) - assert dapp_client.rollup.status - assert len(dapp_client.rollup.notices) > 0 - assert dapp_client.rollup.notices[-1]['data']['payload'] == hex_payload + assert app_client.rollup.status + assert len(app_client.rollup.notices) > 0 + assert app_client.rollup.notices[-1]['data']['payload'] == hex_payload ``` Although the example above was written using pytest, the TestClient makes no assumption about the testing framework, so it should work equally well using the python's builtin unittest module or other automated test frameworks. @@ -326,11 +397,11 @@ Sends an **advance state** input, such as one being received from the underlying Sends an **inspect** input, such as one being received from the [Inspect dApp state REST API](https://docs.cartesi.io/cartesi-rollups/api/inspect/inspect/). It only expects a hex encoded string, starting with `0x`, with the inspect payload. -For example, if you are running your DApp with sunodo, and want to simulate a the effects of a call to `http://localhost:8000/inspect/hello/world`, the value that should be passed in the hex_payload is `'0x68656c6c6f2f776f726c64'`, which is the hex encoded representation of `hello/world`. +For example, if you are running your App with sunodo, and want to simulate a the effects of a call to `http://localhost:8000/inspect/hello/world`, the value that should be passed in the hex_payload is `'0x68656c6c6f2f776f726c64'`, which is the hex encoded representation of `hello/world`. **`TestClient.rollup`** -This is an instance of a test double implementation of the rollup server. This object will contain attributes holding all the notices, reports and vouchers emitted by the DApp, together with the state of the last transaction. The individual attributes are listed below. +This is an instance of a test double implementation of the rollup server. This object will contain attributes holding all the notices, reports and vouchers emitted by the App, together with the state of the last transaction. The individual attributes are listed below. **`TestClient.rollup.status`** @@ -348,7 +419,7 @@ For notices and reports, the payload will be a hex encoded string, starting with ## Generating Vouchers -A voucher is an output that your DApp can generate to perform a transaction in the base layer blockchain. Once emitted, and finalized, the voucher can be retrieved by an external agent through the GraphQL API and then submitted to the DApp on-chain contract so that the desired transaction take place. Since it represents a full transaction, the voucher payload should be a full function call encoded according to the Solidity [Contract ABI Specification](https://docs.soliditylang.org/en/latest/abi-spec.html). +A voucher is an output that your App can generate to perform a transaction in the base layer blockchain. Once emitted, and finalized, the voucher can be retrieved by an external agent through the GraphQL API and then submitted to the App on-chain contract so that the desired transaction take place. Since it represents a full transaction, the voucher payload should be a full function call encoded according to the Solidity [Contract ABI Specification](https://docs.soliditylang.org/en/latest/abi-spec.html). This framework offers a pythonic way for representing the function calls and generating the voucher payload. The high level way of generating a voucher involves creating a [Pydantic](https://docs.pydantic.dev/1.10/) model with specially annotated type hints that will allow the encoder to understand which Solidity type should be used when encoding the values. @@ -380,7 +451,8 @@ my_withdrawal = TransferArgs(to=receiver_address, value=value) voucher = create_voucher_from_model( destination=erc20_contract_address, function_name='transfer', - args_model=my_withdrawal + args_model=my_withdrawal, + value=0 ) ``` @@ -422,11 +494,11 @@ Generate a voucher for transferring Ethers from the contract to the receiver. Th - **`receiver`**: Hex encoded address, starting with `0x`, of the receiver of Ethers - **`amount`**: Amount of ethers to transfer -**`withdraw_erc20(rollup_address, token, receiver, amount)`** +**`withdraw_erc20(app_contract, token, receiver, amount)`** Generate a voucher for transferring ERC20 tokens owned by the contract to a receiver. The parameters are -- **`rollup_address`**: The hex encoded address, starting with `0x`, of the current DApp. See the `DAppAddressRouter` above for a programmatic way of obtaining this value. +- **`app_contract`**: The hex encoded address, starting with `0x`, of the current App. All inputs inform this address the `app_contract` above for a programmatic way of obtaining this value. - **`token`**: The hex encoded address, starting with `0x`, of the ERC20 token contract - **`receiver`**: The hex encoded address, starting with `0x`, of the receiver of tokens - **`amount`**: Amount of tokens to transfer diff --git a/cartesi/__init__.py b/cartesi/__init__.py index d92c011..851ef74 100644 --- a/cartesi/__init__.py +++ b/cartesi/__init__.py @@ -1,14 +1,17 @@ """Framework for building distributed applications for Cartesi Rollups""" -from .dapp import DApp # noqa +from .app import App # noqa from .models import ( # noqa ABIFunctionSelectorHeader, ABILiteralHeader, RollupData, RollupMetadata, - RollupResponse + RollupResponse, ) from .rollup import Rollup, HTTPRollupServer # noqa + +from .outputs import Notice, Report, Voucher # noqa + from .router import ( # noqa Router, JSONRouter, diff --git a/cartesi/_eth_abi_packed.py b/cartesi/_eth_abi_packed.py index c484912..c2dc07b 100644 --- a/cartesi/_eth_abi_packed.py +++ b/cartesi/_eth_abi_packed.py @@ -1,11 +1,11 @@ -from eth_abi.codec import ( +from eth_abi_lite.codec import ( ABICodec, ) -from eth_abi.registry import ( +from eth_abi_lite.registry import ( registry_packed, BaseEquals ) -from eth_abi.decoding import ( +from eth_abi_lite.decoding import ( BooleanDecoder, AddressDecoder, UnsignedIntegerDecoder, @@ -64,4 +64,4 @@ def read_data_from_stream(self, stream): default_codec_packed = ABICodec(registry_packed) -decode_packed = default_codec_packed.decode +decode_abi_packed = default_codec_packed.decode_abi diff --git a/cartesi/abi.py b/cartesi/abi.py index 905bf19..b22dd09 100644 --- a/cartesi/abi.py +++ b/cartesi/abi.py @@ -5,11 +5,11 @@ from typing import Annotated, get_type_hints, TypeVar, get_args, get_origin from dataclasses import dataclass -import eth_abi -import eth_abi.packed +from eth_abi_lite import decode_abi, encode_abi +import eth_abi_lite.packed import pydantic -from . import _eth_abi_packed +from cartesi._eth_abi_packed import decode_abi_packed # Type Aliases for ABI encoding @@ -199,14 +199,14 @@ def encode_model(obj: pydantic.BaseModel, packed: bool = False) -> bytes: Serialized version of the model """ if packed: - encode = eth_abi.packed.encode_packed + encode_fn = eth_abi_lite.packed.encode_abi_packed else: - encode = eth_abi.encode + encode_fn = encode_abi data = _get_values_from_model(obj) types = get_abi_types_from_model(obj) - return encode(types, data) + return encode_fn(types, data) M = TypeVar('M', bound=pydantic.BaseModel) @@ -282,11 +282,11 @@ def decode_to_model(data: bytes, model: M, packed: bool = False) -> M: Object containing decoded data """ if packed: - decode = _eth_abi_packed.decode_packed + decode_fn = decode_abi_packed else: - decode = eth_abi.decode + decode_fn = decode_abi types = get_abi_types_from_model(model) - decoded = decode(types, data) + decoded = decode_fn(types, data) return _parse_to_model(model, decoded) diff --git a/cartesi/dapp.py b/cartesi/app.py similarity index 68% rename from cartesi/dapp.py rename to cartesi/app.py index 011c04a..d1d7ede 100644 --- a/cartesi/dapp.py +++ b/cartesi/app.py @@ -1,21 +1,24 @@ -import os -import logging +from os import environ +from logging import getLogger, debug from .models import RollupResponse -from .rollup import Rollup, HTTPRollupServer +from .rollup import Rollup from .router import Router -LOGGER = logging.getLogger(__name__) -ROLLUP_SERVER = os.environ.get('ROLLUP_HTTP_SERVER_URL') +LOGGER = getLogger(__name__) +ROLLUP_SERVER = environ.get('ROLLUP_HTTP_SERVER_URL') -class DApp: +class App: - def __init__(self): + def __init__(self, raw_input = False, use_pycmt = False, use_pycma = False): self.routers: list[Router] = [] self.default_advance_handler = lambda rollup, data: False self.default_inspect_handler = lambda rollup, data: False self.rollup: Rollup | None = None + self.raw_input = raw_input + self.use_pycmt = use_pycmt + self.use_pycma = use_pycma def advance(self): """Decorator for inserting handle advance""" @@ -32,9 +35,11 @@ def inspect(self): """Decorator for inserting handle advance""" def decorator(func): + LOGGER.debug("Adding func %s to inspect_handler", repr(func)) self.default_inspect_handler = func return func + LOGGER.debug('Returning an Inspect Decorator') return decorator def _get_default_handler(self, request: RollupResponse): @@ -59,7 +64,7 @@ def _handle(self, request: RollupResponse) -> bool: if handler is None: handler = self._get_default_handler(request) - logging.debug("Handler: %s", repr(handler)) + debug("Handler: %s", repr(handler)) try: status = handler(self.rollup, request.data) except Exception: @@ -73,6 +78,14 @@ def add_router(self, router: Router): def run(self): if self.rollup is None: - self.rollup = HTTPRollupServer() + if self.use_pycmt: + from .pycmt_rollup import CmtRollupApp + self.rollup = CmtRollupApp() + elif self.use_pycma: + from .pycma_rollup import CmaRollupApp + self.rollup = CmaRollupApp() + else: + from .rollup import HTTPRollupServer + self.rollup = HTTPRollupServer(raw_input=self.raw_input) self.rollup.set_handler(self._handle) self.rollup.main_loop() diff --git a/cartesi/models.py b/cartesi/models.py index 2870747..66bfcc2 100644 --- a/cartesi/models.py +++ b/cartesi/models.py @@ -3,6 +3,7 @@ from Crypto.Hash import keccak from pydantic import BaseModel +from .abi import UInt256, Bytes, Address, get_abi_types_from_model def _hex2str(hex): @@ -20,11 +21,13 @@ def _str2hex(str): class RollupMetadata(BaseModel): + chain_id: int + app_contract: str msg_sender: str - epoch_index: int input_index: int block_number: int - timestamp: int + block_timestamp: int + prev_randao: str class RollupData(BaseModel): @@ -37,7 +40,7 @@ def bytes_payload(self) -> bytes: def str_payload(self, encoding='utf-8') -> str: return bytes.fromhex(self.payload[2:]).decode(encoding) - def json_payload(self) -> bytes: + def json_payload(self) -> dict: return json.loads(self.str_payload()) @@ -49,7 +52,7 @@ class RollupResponse(BaseModel): class ABIHeader(BaseModel, abc.ABC): @abc.abstractmethod - def to_bytes(self): + def to_bytes(self) -> bytes: """Get the bytes representation for this header""" pass @@ -74,3 +77,18 @@ def to_bytes(self) -> bytes: selector = sig_hash.digest()[:4] return selector + +class EvmAdvance(BaseModel): + chain_id: UInt256 + app_contract: Address + msg_sender: Address + block_number: UInt256 + block_timestamp: UInt256 + prev_randao: UInt256 + input_index: UInt256 + payload: Bytes + +evm_advance_header = ABIFunctionSelectorHeader( + function=EvmAdvance.__name__, + argument_types=get_abi_types_from_model(EvmAdvance) +) diff --git a/cartesi/outputs.py b/cartesi/outputs.py new file mode 100644 index 0000000..c3df2ed --- /dev/null +++ b/cartesi/outputs.py @@ -0,0 +1,144 @@ +from abc import ABC, abstractmethod +from os import environ +from logging import getLogger +from requests import post + +import json +from typing import List +from .util import encode_values, encode_function_call + +LOGGER = getLogger(__name__) + +DEFAULT_ROLLUP_URL = 'http://127.0.0.1:5004' + +def encode(d): + return "0x" + d.encode("utf-8").hex() + +class Output(ABC): + """ + Base class representing a result generated by processing an input. + + It is resposible for converting its payload to the format expected by + Cartesi rollups. + + Parameters: + payload(str): the actual data generated after processing the input + """ + + def __init__(self, payload: str, address: str = None): + self.endpoint = "/output" + if payload[:2] == "0x": + self.payload = payload + else: + self.payload = "0x" + payload + + if address is None: + address = environ.get( + 'ROLLUP_HTTP_SERVER_URL', + DEFAULT_ROLLUP_URL + ) + self.address = address + + @classmethod + def from_hex(cls, hex_value:str): + return cls(hex_value) + + def create(self, rollup_server_address=None): + rollup_server = self.address + if rollup_server_address is not None: + rollup_server = rollup_server_address + + return post(rollup_server + self.endpoint, json={"payload": self.payload}) + +class Voucher(Output): + """ + A Voucher is an `Output` representing a transaction + that can be carried out on the base layer blockchain, + such as a transfer of assets. + + Parameters: + destination(str): destination of the contract who will execute the payload + payload(bytes): an ABI encoded contract function call + """ + + def __init__(self, destination: str, payload: bytes|str, value = None, address: str = None): + self.endpoint = "/voucher" + self.destination = destination + self.value = value + + if value is None: + self.value = '0x' + encode_values(["uint256"], [0]).hex() + + if payload[:2] == "0x": + super().__init__(payload, address=address) + else: + hexpayload = "0x" + payload.hex() + super().__init__(hexpayload, address=address) + + @classmethod + def from_hex(cls, destination:str, hex_value:str, value:int=None): + return cls(destination, hex_value, value) + + @classmethod + def from_function_selector(cls, destination:str, selector:str, types:List[str], values:List[int]): + transfer_payload = encode_function_call(selector, types, values) + return cls(destination, transfer_payload) + + def create(self, rollup_server_address=None): + rollup_server = self.address + + if rollup_server_address is not None: + rollup_server = rollup_server_address + + return post(rollup_server + "/voucher", json={"payload": self.payload, "destination": self.destination, "value": self.value}) + +class Notice(Output): + """ + A Notice is an `Output` representing an informational statement + that can be validated in the base layer blockchain. + + Parameters: + payload(str): a string containing arbitrary data + """ + + def __init__(self, payload: str): + super().__init__(payload) + self.endpoint = "/notice" + + @classmethod + def from_json(cls, json_object): + if (isinstance(json_object, str)): + return cls.from_string(json_object) + + j_string = json.dumps(json_object) + return cls.from_string(j_string) + + @classmethod + def from_string(cls, string:str): + hex_encoded = encode(string) + return cls(hex_encoded) + +class Report(Output): + """ + A Report is an `Output` representing an application log. + + Parameters: + payload(str): a string containing arbitrary data + """ + + def __init__(self, payload: str): + super().__init__(payload) + self.endpoint = "/report" + + @classmethod + def from_json(cls, json_object): + if (isinstance(json_object, str)): + return cls.from_string(json_object) + + j_string = json.dumps(json_object) + return cls.from_string(j_string) + + @classmethod + def from_string(cls, string:str): + hex_encoded = encode(string) + return cls(hex_encoded) \ No newline at end of file diff --git a/cartesi/pycma_rollup.py b/cartesi/pycma_rollup.py new file mode 100644 index 0000000..1b69a27 --- /dev/null +++ b/cartesi/pycma_rollup.py @@ -0,0 +1,101 @@ +from logging import getLogger +from pycma import RollupCma +import re + +from .rollup import Rollup +from .models import RollupResponse + +LOGGER = getLogger(__name__) + +def to_bytes(payload): + if isinstance(payload,bytes): + return payload + if isinstance(payload,str): + if payload.startswith('0x'): + return bytes.fromhex(payload[2:]) + if bool(re.fullmatch(r"[0-9a-fA-F]+", payload)) and len(payload) % 2 == 0: + return bytes.fromhex(payload) + return payload.encode('utf-8') + return bytes(payload) + +class CmaRollupApp(Rollup): + """Libcma and Libcmt Rollup based""" + _rollup: RollupCma + + def __init__(self): + super().__init__() + self._rollup = RollupCma() + + def main_loop(self): + accept_previous_request = True + + while True: + LOGGER.info("Sending finish") + next_request_type = self._rollup.finish(accept_previous_request) + rollup_response = { + 'request_type': None, + 'data': {}, + } + LOGGER.debug(f"Received {next_request_type} input") + if next_request_type == 'advance': + advance = self._rollup.read_advance_state() + rollup_response['data']['metadata'] = { + 'chain_id': advance['chain_id'], + 'app_contract': "0x" + advance['app_contract'].hex(), + 'msg_sender': "0x" + advance['msg_sender'].hex(), + 'input_index': advance['index'], + 'block_number': advance['block_number'], + 'block_timestamp': advance['block_timestamp'], + 'prev_randao': "0x" + advance['prev_randao'].hex() + } + LOGGER.debug(f"Advance state {rollup_response}") + rollup_response['data']['payload'] = "0x" + advance['payload']['data'].hex() + rollup_response['request_type'] = 'advance_state' + elif next_request_type == 'inspect': + inspect = self._rollup.read_inspect_state() + rollup_response['data']['payload'] = "0x" + inspect['payload']['data'].hex() + rollup_response['request_type'] = 'inspect_state' + LOGGER.debug(f"Inspect state {rollup_response}") + else: + LOGGER.error("Invalid request type.") + accept_previous_request = False + continue + + rollup_response = RollupResponse.parse_obj(rollup_response) + + handler = self.handler + if handler is not None: + accept_previous_request = handler(rollup_response) + else: + LOGGER.error("No handler found for message.") + accept_previous_request = False + + def notice(self, payload: str): + LOGGER.info("Adding notice") + payload_bytes = to_bytes(payload) + self._rollup.emit_notice(payload_bytes) + return b'' + + def report(self, payload): + LOGGER.info("Adding report") + payload_bytes = to_bytes(payload) + self._rollup.emit_report(payload_bytes) + return b'' + + def voucher(self, payload: dict): + LOGGER.info("Adding voucher") + payload_bytes = to_bytes(payload['payload']) + self._rollup.emit_voucher(payload['destination'], int(payload['value'],16), payload_bytes) + return b'' + + def delegate_call_voucher(self, payload: dict): + LOGGER.info("Adding delegate call voucher") + payload_bytes = to_bytes(payload['payload']) + self._rollup.emit_delegate_call_voucher(payload['destination'], payload_bytes) + return b'' + + def gio(self, payload: dict): + LOGGER.info("Adding gio request") + payload_bytes = to_bytes(payload['payload']) + ret = self._rollup.gio_request(payload['domain'], payload_bytes) + return bytes(ret['response_data'][:len(ret['response_data'])]) diff --git a/cartesi/pycmt_rollup.py b/cartesi/pycmt_rollup.py new file mode 100644 index 0000000..7db4905 --- /dev/null +++ b/cartesi/pycmt_rollup.py @@ -0,0 +1,101 @@ +from logging import getLogger +from pycmt import Rollup as CmtRollup +import re + +from .rollup import Rollup +from .models import RollupResponse + +LOGGER = getLogger(__name__) + +def to_bytes(payload): + if isinstance(payload,bytes): + return payload + if isinstance(payload,str): + if payload.startswith('0x'): + return bytes.fromhex(payload[2:]) + if bool(re.fullmatch(r"[0-9a-fA-F]+", payload)) and len(payload) % 2 == 0: + return bytes.fromhex(payload) + return payload.encode('utf-8') + return bytes(payload) + +class CmtRollupApp(Rollup): + """Libcmt Rollup based""" + _rollup: CmtRollup + + def __init__(self): + super().__init__() + self._rollup = CmtRollup() + + def main_loop(self): + accept_previous_request = True + + while True: + LOGGER.info("Sending finish") + next_request_type = self._rollup.finish(accept_previous_request) + rollup_response = { + 'request_type': None, + 'data': {}, + } + LOGGER.debug(f"Received {next_request_type} input") + if next_request_type == 'advance': + advance = self._rollup.read_advance_state() + rollup_response['data']['metadata'] = { + 'chain_id': advance['chain_id'], + 'app_contract': "0x" + advance['app_contract'].hex(), + 'msg_sender': "0x" + advance['msg_sender'].hex(), + 'input_index': advance['index'], + 'block_number': advance['block_number'], + 'block_timestamp': advance['block_timestamp'], + 'prev_randao': "0x" + advance['prev_randao'].hex() + } + LOGGER.debug(f"Advance state {rollup_response}") + rollup_response['data']['payload'] = "0x" + advance['payload']['data'].hex() + rollup_response['request_type'] = 'advance_state' + elif next_request_type == 'inspect': + inspect = self._rollup.read_inspect_state() + rollup_response['data']['payload'] = "0x" + inspect['payload']['data'].hex() + rollup_response['request_type'] = 'inspect_state' + LOGGER.debug(f"Inspect state {rollup_response}") + else: + LOGGER.error("Invalid request type.") + accept_previous_request = False + continue + + rollup_response = RollupResponse.parse_obj(rollup_response) + + handler = self.handler + if handler is not None: + accept_previous_request = handler(rollup_response) + else: + LOGGER.error("No handler found for message.") + accept_previous_request = False + + def notice(self, payload: str): + LOGGER.info("Adding notice") + payload_bytes = to_bytes(payload) + self._rollup.emit_notice(payload_bytes) + return b'' + + def report(self, payload): + LOGGER.info("Adding report") + payload_bytes = to_bytes(payload) + self._rollup.emit_report(payload_bytes) + return b'' + + def voucher(self, payload: dict): + LOGGER.info("Adding voucher") + payload_bytes = to_bytes(payload['payload']) + self._rollup.emit_voucher(payload['destination'], int(payload['value'],16), payload_bytes) + return b'' + + def delegate_call_voucher(self, payload: dict): + LOGGER.info("Adding delegate call voucher") + payload_bytes = to_bytes(payload['payload']) + self._rollup.emit_delegate_call_voucher(payload['destination'], payload_bytes) + return b'' + + def gio(self, payload: dict): + LOGGER.info("Adding gio request") + payload_bytes = to_bytes(payload['payload']) + ret = self._rollup.gio_request(payload['domain'], payload_bytes) + return bytes(ret['response_data'][:len(ret['response_data'])]) diff --git a/cartesi/rollup.py b/cartesi/rollup.py index ab05d55..b608877 100644 --- a/cartesi/rollup.py +++ b/cartesi/rollup.py @@ -1,13 +1,14 @@ from abc import ABC, abstractmethod from collections.abc import Callable -import os -import logging +from os import environ +from logging import getLogger -import requests +from requests import post -from .models import RollupResponse +from .models import RollupResponse, EvmAdvance, evm_advance_header +from .abi import decode_to_model -LOGGER = logging.getLogger(__name__) +LOGGER = getLogger(__name__) DEFAULT_ROLLUP_URL = 'http://127.0.0.1:5004' @@ -27,25 +28,37 @@ def main_loop(self): pass @abstractmethod - def notice(self, payload) -> str: + def notice(self, payload: str) -> bytes | None: pass @abstractmethod - def report(self, payload) -> str: + def report(self, payload: str) -> bytes | None: pass + @abstractmethod + def voucher(self, payload: dict) -> bytes | None: + pass + + @abstractmethod + def delegate_call_voucher(self, payload: dict) -> bytes | None: + pass + + @abstractmethod + def gio(self, payload: dict) -> bytes | None: + pass class HTTPRollupServer(Rollup): """HTTP Communication with Rollup Server based on Requests""" - def __init__(self, address: str = None): + def __init__(self, address: str = None, raw_input: bool = False): super().__init__() if address is None: - address = os.environ.get( + address = environ.get( 'ROLLUP_HTTP_SERVER_URL', DEFAULT_ROLLUP_URL ) self.address = address + self.raw_input = raw_input def main_loop(self): @@ -53,7 +66,7 @@ def main_loop(self): while True: LOGGER.info("Sending finish") - response = requests.post(self.address + "/finish", json=finish) + response = post(self.address + "/finish", json=finish) LOGGER.info(f"Received finish status {response.status_code}") if response.status_code == 202: @@ -61,7 +74,24 @@ def main_loop(self): continue rollup_response = response.json() + if self.raw_input: + if rollup_response.get('data') is not None and \ + rollup_response['data'].get('payload') is not None and \ + rollup_response['data']['payload'][2:10] == evm_advance_header.to_bytes().hex(): + advance_data = decode_to_model(data=bytes.fromhex(rollup_response['data']['payload'][10:]),model=EvmAdvance) + if rollup_response['data'].get('metadata') is not None: + rollup_response['data']['metadata'] = { + 'chain_id': advance_data.chain_id, + 'app_contract': advance_data.app_contract, + 'msg_sender': advance_data.msg_sender, + 'block_number': advance_data.block_number, + 'block_timestamp': advance_data.block_timestamp, + 'prev_randao': advance_data.prev_randao, + 'input_index': advance_data.input_index + } + rollup_response['data']['payload'] = f"0x{advance_data.payload.hex()}" # TODO: Error handling for this model creation + LOGGER.debug(f"Finish response body {rollup_response}") rollup_response = RollupResponse.parse_obj(rollup_response) handler = self.handler @@ -77,7 +107,7 @@ def notice(self, payload: str): data = { 'payload': payload } - response = requests.post(self.address + "/notice", json=data) + response = post(self.address + "/notice", json=data) LOGGER.info(f"Received notice status {response.status_code} " f"body {response.content}") return response.content @@ -87,14 +117,28 @@ def report(self, payload: str): data = { 'payload': payload } - response = requests.post(self.address + "/report", json=data) + response = post(self.address + "/report", json=data) LOGGER.info(f"Received report status {response.status_code} " f"body {response.content}") return response.content def voucher(self, payload: dict): LOGGER.info("Adding voucher") - response = requests.post(self.address + '/voucher', json=payload) - LOGGER.info(f"Received report status {response.status_code} " + response = post(self.address + '/voucher', json=payload) + LOGGER.info(f"Received voucher status {response.status_code} " + f"body {response.content}") + return response.content + + def delegate_call_voucher(self, payload: dict): + LOGGER.info("Adding delegate call voucher") + response = post(self.address + '/delegate-call-voucher', json=payload) + LOGGER.info(f"Received delegate call voucher status {response.status_code} " + f"body {response.content}") + return response.content + + def gio(self, payload: dict): + LOGGER.info("Adding gio") + response = post(self.address + '/gio', json=payload) + LOGGER.info(f"Received gio status {response.status_code} " f"body {response.content}") return response.content diff --git a/cartesi/router/__init__.py b/cartesi/router/__init__.py index c73727a..ae0e746 100644 --- a/cartesi/router/__init__.py +++ b/cartesi/router/__init__.py @@ -3,4 +3,3 @@ from .url import URLRouter, URLParameters # noqa from .abi import ABIRouter # noqa from .multi import MultiRouter # noqa -from .dapp_address import DAppAddressRouter # noqa diff --git a/cartesi/router/dapp_address.py b/cartesi/router/dapp_address.py deleted file mode 100644 index 652f4af..0000000 --- a/cartesi/router/dapp_address.py +++ /dev/null @@ -1,18 +0,0 @@ -from .abi import ABIRouter - -from ..models import RollupData -from ..rollup import Rollup - - -class DAppAddressRouter(ABIRouter): - - def __init__(self, relay_address: str): - super().__init__() - - self.address = None - - @self.advance(msg_sender=relay_address) - def set_dapp_address(rollup: Rollup, data: RollupData) -> bool: - addr_bytes = data.bytes_payload() - self.address = '0x' + addr_bytes.hex() - return True diff --git a/cartesi/router/url.py b/cartesi/router/url.py index 21b0606..99adf32 100644 --- a/cartesi/router/url.py +++ b/cartesi/router/url.py @@ -1,9 +1,9 @@ from collections.abc import Callable -import inspect +from inspect import getfullargspec from itertools import chain -import logging -import re -import typing +from logging import getLogger +from re import Pattern, compile, escape +from typing import Pattern as typing_Pattern from urllib.parse import parse_qs from pydantic import BaseModel @@ -12,7 +12,7 @@ from ..models import RollupResponse, RollupData from ..rollup import Rollup -LOGGER = logging.getLogger(__name__) +LOGGER = getLogger(__name__) class URLParameters(BaseModel): @@ -22,7 +22,7 @@ class URLParameters(BaseModel): class URLOperation(BaseModel): path: str - path_regex: re.Pattern + path_regex: Pattern handler: Callable operationId: str requestType: str @@ -141,7 +141,7 @@ def _create_handler(route_handler, url_params: URLParameters): Return a handler with the default router arguments, but applies additional args to the user's function according to introspection """ - args = inspect.getfullargspec(route_handler) + args = getfullargspec(route_handler) def _handler(rollup: Rollup, data: RollupData): kwargs = {} @@ -181,10 +181,10 @@ def _match_url(pattern, request_path: str): return (True, params) -PARAM_REGEX = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)}") +PARAM_REGEX = compile("{([a-zA-Z_][a-zA-Z0-9_]*)}") -def compile_path(path: str) -> typing.Pattern: +def compile_path(path: str) -> typing_Pattern: """ Given a path string like "/{operation}", returns a corresponding regex. @@ -195,10 +195,10 @@ def compile_path(path: str) -> typing.Pattern: for match in PARAM_REGEX.finditer(path): param_name = match.groups()[0] - path_regex += re.escape(path[idx: match.start()]) + path_regex += escape(path[idx: match.start()]) path_regex += f"(?P<{param_name}>[^/]+)" idx = match.end() path_regex += path[idx:] + "$" - return re.compile(path_regex) + return compile(path_regex) diff --git a/cartesi/testclient.py b/cartesi/testclient.py index d776dea..1d09966 100644 --- a/cartesi/testclient.py +++ b/cartesi/testclient.py @@ -14,10 +14,13 @@ def __init__(self): self.notices = [] self.reports = [] self.vouchers = [] - self.epoch = 0 + self.delegate_call_vouchers = [] + self.gios = [] self.input = 0 self.block = 0 self.status = None + self.chain_id = 31337 + self.app_contract = f"{1:#042x}" def main_loop(self): """There is no main loop for test rollup.""" @@ -25,7 +28,6 @@ def main_loop(self): def notice(self, payload: str): data = { - 'epoch_index': self.epoch, 'input_index': self.input, 'data': { 'payload': payload, @@ -35,7 +37,6 @@ def notice(self, payload: str): def report(self, payload: str): data = { - 'epoch_index': self.epoch, 'input_index': self.input, 'data': { 'payload': payload, @@ -43,16 +44,37 @@ def report(self, payload: str): } self.reports.append(data) - def voucher(self, payload: str): + def voucher(self, payload: dict): data = { - 'epoch_index': self.epoch, 'input_index': self.input, 'data': { - 'payload': payload, + 'destination': payload.get('destination'), + 'value': int(payload.get('value') or "0x0",0), + 'payload': payload.get('payload'), } } self.vouchers.append(data) + def delegate_call_voucher(self, payload: dict): + data = { + 'input_index': self.input, + 'data': { + 'destination': payload.get('destination'), + 'payload': payload.get('payload'), + } + } + self.delegate_call_vouchers.append(data) + + def gio(self, payload: dict): + data = { + 'input_index': self.input, + 'data': { + 'domain': payload.get('domain'), + 'id': payload.get('id'), + } + } + self.gios.append(data) + def send_advance( self, hex_payload: str, @@ -66,11 +88,13 @@ def send_advance( 'request_type': 'advance_state', 'data': { 'metadata': { + 'chain_id': self.chain_id, + 'app_contract': self.app_contract, 'msg_sender': msg_sender, - 'epoch_index': self.epoch, 'input_index': self.input, 'block_number': self.block, - 'timestamp': timestamp, + 'block_timestamp': timestamp, + 'prev_randao': f"{self.block:#066x}" }, 'payload': hex_payload, } @@ -87,9 +111,6 @@ def send_advance( self.input += 1 def send_inspect(self, hex_payload: str): - - self.block += 1 - data = { 'request_type': 'inspect_state', 'data': { @@ -104,9 +125,6 @@ def send_inspect(self, hex_payload: str): LOGGER.error("No handler found for message.") status = False self.status = status - if status: - self.input += 1 - class TestClient: __test__ = False diff --git a/cartesi/util.py b/cartesi/util.py new file mode 100644 index 0000000..81e3b0f --- /dev/null +++ b/cartesi/util.py @@ -0,0 +1,71 @@ +# Copyright 2022 Cartesi Pte. Ltd. +# +# SPDX-License-Identifier: Apache-2.0 +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +from Crypto.Hash import keccak +from eth_abi_lite import decode_abi, encode_abi +from typing import List, Tuple, Any + + +def hex_to_str(hex): + """Decode a hex string prefixed with "0x" into a UTF-8 string""" + return bytes.fromhex(hex[2:]).decode("utf-8") + + +def str_to_hex(str): + """Encode a string as a hex string, adding the "0x" prefix""" + return "0x" + str.encode("utf-8").hex() + +def decode_payload(types: List[str], payload: str, ) -> Tuple[Any, ...]: + """ + Decodes an ABI-encoded payload. + + Args: + types (List[str]): A list of ABI types as strings. + payload (str): The hex-encoded payload to decode. + + Returns: + Tuple[Any, ...]: A tuple containing the decoded values. + """ + # Ensure the payload is in bytes format + payload_bytes = bytes.fromhex(payload[2:]) # Remove '0x' prefix and convert to bytes + return decode_abi(types, payload_bytes) + +def decode_id_val(payload: str) -> Tuple[Any, ...]: + return decode_abi(['uint256[]', 'uint256[]'], payload) + + +def encode_function_call(function_signature: str, types: List[str], values: List) -> str: + """ + Encodes a function call with the given signature and parameters. + + Args: + function_signature (str): The signature of the function (e.g., 'transfer(address,uint256)'). + types (List[str]): A list of ABI types as strings. + values (List): A list of values corresponding to the types. + + Returns: + str: The hex-encoded function call data. + """ + # Get the 4-byte selector from the function signature + sig_hash = keccak.new(digest_bits=256) + sig_hash.update(function_signature.encode('utf-8')) + + selector = sig_hash.digest()[:4].hex() + + # Encode the values based on the provided types + encoded_params = encode_abi(types, values) + + # Concatenate the selector and the encoded parameters + return "0x" + (selector + encoded_params).hex() + +def encode_values(types: List[str], values: List): + return encode_abi(types, values) diff --git a/cartesi/vouchers.py b/cartesi/vouchers.py index 662f910..89215a2 100644 --- a/cartesi/vouchers.py +++ b/cartesi/vouchers.py @@ -9,8 +9,9 @@ def create_voucher_from_model( destination: abi.Address, - function_name: str, - args_model: BaseModel + function_name: str | None = None, + args_model: BaseModel | None = None, + value: abi.UInt256 | None = None ): """ Generates a voucher for a given contract, function and arguments. @@ -32,17 +33,24 @@ def create_voucher_from_model( dict Dictionary ready to be passed to rollup.voucher(). """ - args_types = abi.get_abi_types_from_model(args_model) - signature = f'{function_name}({",".join(args_types)})' - sig_hash = keccak.new(digest_bits=256) - sig_hash.update(signature.encode('utf-8')) + selector = "" + args = "" + if args_model is not None and function_name is not None : + args_types = abi.get_abi_types_from_model(args_model) + signature = f'{function_name}({",".join(args_types)})' + sig_hash = keccak.new(digest_bits=256) + sig_hash.update(signature.encode('utf-8')) - selector = sig_hash.digest()[:4].hex() + selector = sig_hash.digest()[:4].hex() - args = abi.encode_model(args_model).hex() + args = abi.encode_model(args_model).hex() + + if value is None: + value = 0 voucher = { 'destination': destination, + 'value': f"{value:#066x}", 'payload': '0x' + selector + args } return voucher @@ -54,7 +62,6 @@ class WithdrawEtherParams(BaseModel): def withdraw_ether( - rollup_address, receiver_address: abi.Address, amount: abi.UInt256 ): @@ -63,9 +70,8 @@ def withdraw_ether( amount=amount, ) return create_voucher_from_model( - destination=rollup_address, - function_name='withdrawEther', - args_model=params, + destination=params.receiver, + value=params.amount ) @@ -76,7 +82,7 @@ class WithdrawERC20Params(BaseModel): def withdraw_erc20( - rollup_address, + app_contract, token, receiver_address: abi.Address, amount: abi.UInt256 @@ -87,7 +93,7 @@ def withdraw_erc20( amount=amount, ) return create_voucher_from_model( - destination=rollup_address, + destination=app_contract, function_name='withdrawERC20Tokens', args_model=params, ) diff --git a/cartesi/wallet/ether.py b/cartesi/wallet/ether.py index f3d2fbe..5624676 100644 --- a/cartesi/wallet/ether.py +++ b/cartesi/wallet/ether.py @@ -6,7 +6,8 @@ from .. import abi from ..models import RollupData, ABIFunctionSelectorHeader from ..rollup import Rollup -from ..router import MultiRouter, ABIRouter, URLRouter, DAppAddressRouter +from ..router import MultiRouter, ABIRouter, URLRouter +from ..vouchers import create_voucher_from_model LOGGER = logging.getLogger(__name__) @@ -35,13 +36,11 @@ class EtherWallet(MultiRouter): def __init__( self, portal_address: str, - dapp_address_router: DAppAddressRouter, default_withdraw_route: bool = True, ): super().__init__() self.balance: dict[str, int] = {} self.portal_address = portal_address - self.dapp_address_router = dapp_address_router self.on_deposit = None @@ -129,5 +128,11 @@ def _withdraw_ether( wallet.balance[address] -= withdrawal.amount # Generate Voucher + rollup.voucher( + create_voucher_from_model( + destination=address, + value=withdrawal.amount + ) + ) return True diff --git a/examples/echo.py b/examples/echo.py index a73bbd4..1ac3127 100644 --- a/examples/echo.py +++ b/examples/echo.py @@ -1,10 +1,10 @@ -import logging +from logging import getLogger, basicConfig, DEBUG -from cartesi import DApp, Rollup, RollupData +from cartesi import App, Rollup, RollupData -LOGGER = logging.getLogger(__name__) -logging.basicConfig(level=logging.DEBUG) -dapp = DApp() +LOGGER = getLogger(__name__) +basicConfig(level=DEBUG) +app = App() def str2hex(str): @@ -12,7 +12,7 @@ def str2hex(str): return "0x" + str.encode("utf-8").hex() -@dapp.advance() +@app.advance() def handle_advance(rollup: Rollup, data: RollupData) -> bool: payload = data.str_payload() LOGGER.debug("Echoing '%s'", payload) @@ -20,7 +20,7 @@ def handle_advance(rollup: Rollup, data: RollupData) -> bool: return True -@dapp.inspect() +@app.inspect() def handle_inspect(rollup: Rollup, data: RollupData) -> bool: payload = data.str_payload() LOGGER.debug("Echoing '%s'", payload) @@ -29,4 +29,4 @@ def handle_inspect(rollup: Rollup, data: RollupData) -> bool: if __name__ == '__main__': - dapp.run() + app.run() diff --git a/examples/ether_wallet.py b/examples/ether_wallet.py index 4ef1142..19f7b1d 100644 --- a/examples/ether_wallet.py +++ b/examples/ether_wallet.py @@ -1,18 +1,18 @@ -import logging +from logging import getLogger, basicConfig, DEBUG -from cartesi import DApp +from cartesi import App from cartesi.wallet.ether import EtherWallet -LOGGER = logging.getLogger(__name__) -logging.basicConfig(level=logging.DEBUG) -dapp = DApp() +LOGGER = getLogger(__name__) +basicConfig(level=DEBUG) +app = App() -ETHER_PORTAL_ADDRESS = '0xffdbe43d4c855bf7e0f105c400a50857f53ab044' +ETHER_PORTAL_ADDRESS = '0xA632c5c05812c6a6149B7af5C56117d1D2603828' ether_wallet = EtherWallet(portal_address=ETHER_PORTAL_ADDRESS) -dapp.add_router(ether_wallet) +app.add_router(ether_wallet) if __name__ == '__main__': - dapp.run() + app.run() diff --git a/examples/json_handler.py b/examples/json_handler.py index a7a24c3..b1d2074 100644 --- a/examples/json_handler.py +++ b/examples/json_handler.py @@ -1,16 +1,16 @@ -import json -import logging +from json import dumps +from logging import getLogger, basicConfig, DEBUG -from cartesi import DApp, Rollup, RollupData, JSONRouter +from cartesi import App, Rollup, RollupData, JSONRouter -LOGGER = logging.getLogger(__name__) -logging.basicConfig(level=logging.DEBUG) +LOGGER = getLogger(__name__) +basicConfig(level=DEBUG) -dapp = DApp() +app = App() json_router = JSONRouter() -dapp.add_router(json_router) +app.add_router(json_router) -# This dapp will read and write from this global state dict +# This app will read and write from this global state dict STATE = {} @@ -21,12 +21,12 @@ def str2hex(str): def to_jsonhex(data): """Encode as a JSON hex""" - return str2hex(json.dumps(data)) + return str2hex(dumps(data)) @json_router.advance({"op": "set"}) -def handle_advance_set(rollup: Rollup, data: RollupData): - data = data.json_payload() +def handle_advance_set(rollup: Rollup, raw_data: RollupData): + data = raw_data.json_payload() key = data['key'] value = data['value'] @@ -63,4 +63,4 @@ def handle_inspect_get(rollup: Rollup, data: RollupData): if __name__ == '__main__': - dapp.run() + app.run() diff --git a/examples/outputs.py b/examples/outputs.py new file mode 100644 index 0000000..e5e39cd --- /dev/null +++ b/examples/outputs.py @@ -0,0 +1,48 @@ +import logging + +from cartesi import App, Rollup, RollupData, Notice, Voucher, Report + +LOGGER = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) +dapp = App() + + +def str2hex(str): + """Encodes a string as a hex string""" + return "0x" + str.encode("utf-8").hex() + + +@dapp.advance() +def handle_advance(rollup: Rollup, data: RollupData) -> bool: + payload = data.str_payload() + LOGGER.debug("Echoing '%s'", payload) + rollup.notice(str2hex(payload)) + + Report("0x68656c6c6f20776f726c64").create() + Report.from_hex("0x68656c6c6f20776f726c64").create() + Report.from_string("hello world").create() + Report.from_json({"foo":"bar"}).create() + + Notice("0x68656c6c6f20776f726c64").create() + Notice.from_hex("0x68656c6c6f20776f726c64").create() + Notice.from_string("hello world").create() + Notice.from_json({"foo":"bar"}).create() + + return True + + +@dapp.inspect() +def handle_inspect(rollup: Rollup, data: RollupData) -> bool: + payload = data.str_payload() + LOGGER.debug("Echoing '%s'", payload) + rollup.report(str2hex(payload)) + Report("0x68656c6c6f20776f726c64").create() + Report.from_hex("0x68656c6c6f20776f726c64").create() + Report.from_string("hello world").create() + Report.from_json({"foo":"bar"}).create() + + return True + + +if __name__ == '__main__': + dapp.run() diff --git a/examples/url_router.py b/examples/url_router.py index be285fe..195e7be 100644 --- a/examples/url_router.py +++ b/examples/url_router.py @@ -1,12 +1,12 @@ -import logging +from logging import getLogger, basicConfig, DEBUG -from cartesi import DApp, Rollup, RollupData, URLRouter, URLParameters +from cartesi import App, Rollup, RollupData, URLRouter, URLParameters -LOGGER = logging.getLogger(__name__) -logging.basicConfig(level=logging.DEBUG) -dapp = DApp() +LOGGER = getLogger(__name__) +basicConfig(level=DEBUG) +app = App() url_router = URLRouter() -dapp.add_router(url_router) +app.add_router(url_router) def str2hex(str): @@ -37,4 +37,4 @@ def hello_world_inspect_parms(rollup: Rollup, params: URLParameters) -> bool: if __name__ == '__main__': - dapp.run() + app.run() diff --git a/pyproject.toml b/pyproject.toml index 22274c5..0a3d4ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,8 @@ [project] name = "python-cartesi" -version = "0.1.2" +version = "0.2.3" authors = [ - { name="Felipe Fink Grael", email="felipefg@prototyp3.dev" }, + { name="Felipe Fink Grael", email="felipefg@prototyp3.dev" },{ name="Lyno Ferraz", email="lyno@prototyp3.dev" } ] description = "A Python Framework for Cartesi Distributed Applications" readme = "README.md" @@ -16,10 +16,20 @@ dependencies = [ "requests >= 2.31.0", "pydantic < 2", "pytest ~= 7.4.0", - "eth-abi ~= 4.2.1", + "eth_abi_lite ~= 3.2.0", "pycryptodome ~= 3.19.0", ] +[project.optional-dependencies] +machine = [ + "pycmt >= 0.0.2", +] +machine-asset = [ + "pycmt >= 0.0.2", + "pycma >= 0.0.3", +] + + [project.urls] "Homepage" = "https://github.com/prototyp3-dev/python-cartesi" diff --git a/requirements.txt b/requirements.txt index 6158406..88399ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile @@ -8,43 +8,25 @@ certifi==2023.7.22 # via requests charset-normalizer==3.2.0 # via requests -cytoolz==0.12.2 - # via eth-utils -eth-abi==4.2.1 - # via cartesi (pyproject.toml) -eth-hash==0.5.2 - # via eth-utils -eth-typing==3.5.2 - # via - # eth-abi - # eth-utils -eth-utils==2.3.1 - # via eth-abi +eth-abi-lite==3.2.0 + # via python-cartesi (pyproject.toml) idna==3.4 # via requests iniconfig==2.0.0 # via pytest packaging==23.1 # via pytest -parsimonious==0.9.0 - # via eth-abi pluggy==1.2.0 # via pytest pycryptodome==3.19.0 - # via cartesi (pyproject.toml) + # via python-cartesi (pyproject.toml) pydantic==1.10.12 - # via cartesi (pyproject.toml) + # via python-cartesi (pyproject.toml) pytest==7.4.0 - # via cartesi (pyproject.toml) -regex==2023.10.3 - # via parsimonious + # via python-cartesi (pyproject.toml) requests==2.31.0 - # via cartesi (pyproject.toml) -toolz==0.12.0 - # via cytoolz + # via python-cartesi (pyproject.toml) typing-extensions==4.7.1 - # via - # eth-typing - # pydantic + # via pydantic urllib3==2.0.4 # via requests diff --git a/tests/test_echo.py b/tests/test_echo.py index c2823e3..c84077d 100644 --- a/tests/test_echo.py +++ b/tests/test_echo.py @@ -6,16 +6,16 @@ @pytest.fixture -def dapp_client() -> TestClient: - client = TestClient(examples.echo.dapp) +def app_client() -> TestClient: + client = TestClient(examples.echo.app) return client -def test_simple_echo(dapp_client: TestClient): +def test_simple_echo(app_client: TestClient): hex_payload = '0x' + 'hello'.encode('utf-8').hex() - dapp_client.send_advance(hex_payload=hex_payload) + app_client.send_advance(hex_payload=hex_payload) - assert dapp_client.rollup.status - assert len(dapp_client.rollup.notices) > 0 - assert dapp_client.rollup.notices[-1]['data']['payload'] == hex_payload + assert app_client.rollup.status + assert len(app_client.rollup.notices) > 0 + assert app_client.rollup.notices[-1]['data']['payload'] == hex_payload diff --git a/tests/test_ether_wallet.py b/tests/test_ether_wallet.py index e029b5d..7870cab 100644 --- a/tests/test_ether_wallet.py +++ b/tests/test_ether_wallet.py @@ -9,15 +9,14 @@ @pytest.fixture -def dapp_client() -> TestClient: - client = TestClient(examples.ether_wallet.dapp) +def app_client() -> TestClient: + client = TestClient(examples.ether_wallet.app) return client @pytest.fixture def deposit_payload() -> str: deposit = DepositEtherPayload( - success=True, sender="0x721be000f6054b5e0e57aaab791015b53f0a18f4", depositAmount=int(1e18), execLayerData=b'', @@ -26,24 +25,24 @@ def deposit_payload() -> str: return payload -def test_should_handle_deposit(dapp_client: TestClient, deposit_payload: str): +def test_should_handle_deposit(app_client: TestClient, deposit_payload: str): # Send the Deposit - dapp_client.send_advance( + app_client.send_advance( hex_payload=deposit_payload, msg_sender=examples.ether_wallet.ETHER_PORTAL_ADDRESS, ) # Deposit should succeed - assert dapp_client.rollup.status + assert app_client.rollup.status # Send the inspect path = 'balance/ether' inspect_payload = '0x' + path.encode('ascii').hex() - dapp_client.send_inspect(hex_payload=inspect_payload) + app_client.send_inspect(hex_payload=inspect_payload) - assert dapp_client.rollup.status + assert app_client.rollup.status - report = dapp_client.rollup.reports[-1]['data']['payload'] + report = app_client.rollup.reports[-1]['data']['payload'] report = bytes.fromhex(report[2:]) report = json.loads(report.decode('utf-8')) print(json.dumps(report, indent=4)) diff --git a/tests/test_json_handler_app.py b/tests/test_json_handler_app.py new file mode 100644 index 0000000..5d34fff --- /dev/null +++ b/tests/test_json_handler_app.py @@ -0,0 +1,33 @@ +import pytest + +from cartesi.testclient import TestClient + +import examples.json_handler +from examples.json_handler import to_jsonhex + + +@pytest.fixture +def app_client() -> TestClient: + client = TestClient(examples.json_handler.app) + return client + + +def test_simple_set_get(app_client: TestClient): + + set_payload = to_jsonhex( + {'op': 'set', 'key': 'key_1', 'value': 'value_1'} + ) + app_client.send_advance(hex_payload=set_payload) + + assert app_client.rollup.status + assert len(app_client.rollup.notices) == 0 + + get_payload = to_jsonhex( + {'op': 'get', 'key': 'key_1'} + ) + app_client.send_advance(hex_payload=get_payload) + assert app_client.rollup.status + expected_payload = to_jsonhex( + {'key': 'key_1', 'value': 'value_1'} + ) + assert app_client.rollup.notices[-1]['data']['payload'] == expected_payload diff --git a/tests/test_json_handler_dapp.py b/tests/test_json_handler_dapp.py deleted file mode 100644 index 93fcbc2..0000000 --- a/tests/test_json_handler_dapp.py +++ /dev/null @@ -1,33 +0,0 @@ -import pytest - -from cartesi.testclient import TestClient - -import examples.json_handler -from examples.json_handler import to_jsonhex - - -@pytest.fixture -def dapp_client() -> TestClient: - client = TestClient(examples.json_handler.dapp) - return client - - -def test_simple_set_get(dapp_client: TestClient): - - set_payload = to_jsonhex( - {'op': 'set', 'key': 'key_1', 'value': 'value_1'} - ) - dapp_client.send_advance(hex_payload=set_payload) - - assert dapp_client.rollup.status - assert len(dapp_client.rollup.notices) == 0 - - get_payload = to_jsonhex( - {'op': 'get', 'key': 'key_1'} - ) - dapp_client.send_advance(hex_payload=get_payload) - assert dapp_client.rollup.status - expected_payload = to_jsonhex( - {'key': 'key_1', 'value': 'value_1'} - ) - assert dapp_client.rollup.notices[-1]['data']['payload'] == expected_payload diff --git a/tests/test_url_router_app.py b/tests/test_url_router_app.py new file mode 100644 index 0000000..11f4aa7 --- /dev/null +++ b/tests/test_url_router_app.py @@ -0,0 +1,65 @@ +import pytest + +from cartesi.testclient import TestClient + +import examples.url_router + + +def str2hex(str): + """Encodes a string as a hex string""" + return "0x" + str.encode("utf-8").hex() + + +@pytest.fixture +def app_client() -> TestClient: + client = TestClient(examples.url_router.app) + return client + + +def test_hello_world_advance(app_client: TestClient): + + payload = str2hex('hello/') + app_client.send_advance(hex_payload=payload) + + assert app_client.rollup.status + assert len(app_client.rollup.notices) > 0 + + response = str2hex('Hello World') + assert app_client.rollup.notices[-1]['data']['payload'] == response + + +def test_hello_world_inspect(app_client: TestClient): + + payload = str2hex('hello/') + app_client.send_inspect(hex_payload=payload) + + assert app_client.rollup.status + assert len(app_client.rollup.reports) > 0 + + response = str2hex('Hello World') + assert app_client.rollup.reports[-1]['data']['payload'] == response + + +def test_hello_world_inspect_parms_1(app_client: TestClient): + + payload = str2hex('hello/Earth') + app_client.send_inspect(hex_payload=payload) + + assert app_client.rollup.status + assert len(app_client.rollup.reports) > 0 + + response = str2hex('Hello Earth') + assert app_client.rollup.reports[-1]['data']['payload'] == response + + +def test_hello_world_inspect_parms_2(app_client: TestClient): + + payload = str2hex('hello/Earth?suffix=%21') + app_client.send_inspect(hex_payload=payload) + + assert app_client.rollup.status + assert len(app_client.rollup.reports) > 0 + + response = str2hex('Hello Earth!') + print(app_client.rollup.reports[-1]['data']['payload']) + assert app_client.rollup.reports[-1]['data']['payload'] == response diff --git a/tests/test_url_router_dapp.py b/tests/test_url_router_dapp.py deleted file mode 100644 index 826da53..0000000 --- a/tests/test_url_router_dapp.py +++ /dev/null @@ -1,65 +0,0 @@ -import pytest - -from cartesi.testclient import TestClient - -import examples.url_router - - -def str2hex(str): - """Encodes a string as a hex string""" - return "0x" + str.encode("utf-8").hex() - - -@pytest.fixture -def dapp_client() -> TestClient: - client = TestClient(examples.url_router.dapp) - return client - - -def test_hello_world_advance(dapp_client: TestClient): - - payload = str2hex('hello/') - dapp_client.send_advance(hex_payload=payload) - - assert dapp_client.rollup.status - assert len(dapp_client.rollup.notices) > 0 - - response = str2hex('Hello World') - assert dapp_client.rollup.notices[-1]['data']['payload'] == response - - -def test_hello_world_inspect(dapp_client: TestClient): - - payload = str2hex('hello/') - dapp_client.send_inspect(hex_payload=payload) - - assert dapp_client.rollup.status - assert len(dapp_client.rollup.reports) > 0 - - response = str2hex('Hello World') - assert dapp_client.rollup.reports[-1]['data']['payload'] == response - - -def test_hello_world_inspect_parms_1(dapp_client: TestClient): - - payload = str2hex('hello/Earth') - dapp_client.send_inspect(hex_payload=payload) - - assert dapp_client.rollup.status - assert len(dapp_client.rollup.reports) > 0 - - response = str2hex('Hello Earth') - assert dapp_client.rollup.reports[-1]['data']['payload'] == response - - -def test_hello_world_inspect_parms_2(dapp_client: TestClient): - - payload = str2hex('hello/Earth?suffix=%21') - dapp_client.send_inspect(hex_payload=payload) - - assert dapp_client.rollup.status - assert len(dapp_client.rollup.reports) > 0 - - response = str2hex('Hello Earth!') - print(dapp_client.rollup.reports[-1]['data']['payload']) - assert dapp_client.rollup.reports[-1]['data']['payload'] == response