Uncategorized

python-cartesi · PyPI


Overview

The Cartesi HLF is a framework developing DApps that run inside the Cartesi 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.
  • 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

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.

Getting Started

A very simple DApp that simply echoes in a notice whatever input is sent to it can be seen below:

import logging

from cartesi import DApp, Rollup, RollupData

LOGGER = logging.getLogger(__name__)
logging.basicConfig(level=logging.DEBUG)
dapp = DApp()


@dapp.advance()
def handle_advance(rollup: Rollup, data: RollupData) -> bool:
    payload = data.str_payload()
    LOGGER.debug("Echoing '%s'", payload)
    rollup.notice("0x" + payload.encode('utf-8').hex())
    return True


if __name__ == '__main__':
    dapp.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 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.

Interacting with the Rollup Server

Every handler will receive an instance of the Rollup class, that abstracts the communications with the Rollup Server. This class exposes three main methods:

Rollup.notice(self, payload: str)

Adds a new notice to the current advance-state request, by calling the Add new notice API. The payload parameter should be in the Ethereum hex binary format, meaning that it should start with the '0x' characters followed by the hex-encoded content.

Please note that notices only make sense to be called inside advance-state requests.

Rollup.report(self, payload: str)

Adds a new report to the current request, by calling the Add new report API. Just like the notice, the payload parameter should be in the Ethereum hex binary format, meaning that it should start with the '0x' characters followed by the hex-encoded content.

Reports can be added in both advance-state and inspect-state requests.

Rollup.voucher(self, payload: dict)

Adds a new voucher to the current request by calling the Add new voucher API. The payload should be a voucher dict, containing the two following keys:

  • 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.

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.

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:

from cartesi import DApp, JSONRouter

# Create a DApp instance
dapp = DApp()

# Instantiate the JSON Router
json_router = JSONRouter()

# Register the JSON Router into the DApp
dapp.add_router(json_router)

JSON Router

The JSON Router will match with inputs whose contents meet two criteria:

  • The entirety of the input’s content must be a valid JSON
  • A pair of key/value must match with the pair given in the declaration of the route

The JSON Router expose two decorators methods: advance(route_dict) and inspect(route_dict). The first matches with advance-state requests and the latter with inspects. Both will receive a dictionary that will be tested against the input.

For example, a route that handles the creation of a profile could be coded as below:

from cartesi import DApp, Rollup, RollupData, JSONRouter

dapp = DApp()
json_router = JSONRouter()
dapp.add_router(json_router)

@json_router.advance({"op": "create-profile"})
def handle_create_profile(rollup: Rollup, data: RollupData):
    data = data.json_payload()
    name = data['name']
    rollup.report('0x' + name.encode('utf-8').hex())
    return True

if __name__ == '__main__':
    dapp.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”.

ABI Router

The ABI Router is useful when the input resembles the Solidity ABI encoding. It offers several ways of matching with the incoming content:

  • Header (optional): Matches with the input if it starts with a predefined sequence of bytes.
  • Sender Address (optional): Matches with the input when the sender corresponds to a predefined address (only for advance-state requests).

Working with headers

To match with headers, you should pass an instance of a subclass of ABIHeader to the header parameter of the decorator. The framework supplies the following data classes:

ABILiteralHeader

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:

from cartesi import DApp, Rollup, RollupData, ABIRouter, ABILiteralHeader

dapp = DApp()
abi_router = ABIRouter()
dapp.add_router(abi_router)

@abi_router.advance(header=ABILiteralHeader(header=bytes.fromhex('01020304')))
def handle_input_1234(rollup: Rollup, data: RollupData):
    ...

ABIFunctionSelectorHeader

Generates a header according to the Solidity ABI Function Selector. It expects two attributes:

  • function: The name of the function being called
  • argument_types: A list of strings representing the Solidity types for each parameter.

With these arguments, the class will compute the four first bytes of the keccak-256 hash for the string <function>(<argument_types>), and use it as a header.

For example, the declaration ABIFunctionSelectorHeader(function='withdraw', argument_types=['uint256', 'address']) will match with a message starting with the bytes 00f714ce, as they are the first 4 bytes of the Keccak-256 of the string withdraw(uint256,address)

Creating a custom header

The header classes are Pydantic models that inherit from the ABIHeader abstract base class. To create a custom header class, you should subclass ABIHeader and implement the method to_bytes(), as below:

from Crypto.Hash import keccak
from cartesi.models import ABIHeader

class MyCustomHeader(ABIHeader):
    descriptor: str

    def to_bytes(self) -> bytes:
        sig_hash = keccak.new(digest_bits=256)
        sig_hash.update(self.descriptor.encode('utf-8'))
        header = sig_hash.digest()[:4]
        return header

Matching with message sender

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:

from cartesi import DApp, Rollup, RollupData, ABIRouter, ABILiteralHeader

dapp = DApp()
abi_router = ABIRouter()
dapp.add_router(abi_router)

ETHER_PORTAL = '0xffdbe43d4c855bf7e0f105c400a50857f53ab044'

@abi_router.advance(msg_sender=ETHER_PORTAL)
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.

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.

URL Router

The URLRouter is useful when the input is part of a URL. This can happen, for example, in a GET inspect request. The input is assumed to be the path portion of the URL, without the leading slash, and optionally followed by the query string part.

The router exposes two decorator methods: inspect(path_template) and advance(path_template), for inspect and advance requests. Both methods receive a path template as parameter. This template can specify dynamic parts, such as path parameters, by surrounding it in curly braces. For example:

  • A path template 'transactions/by-date' will match both the input transactions/by-date and transactions/by-date?destination=abc123
  • A path template 'wallet/{id}/balance' will match wallet/123/balance, but not wallet//balance

The handler can receive a third argument of the URLParameter type. This object will contain two attributes:

  • path_params: a dict mapping the name of a path parameter to its value. For example, when the template is 'wallet/{id}/balance' and the input is wallet/123/balance, the value of path_params will be {'id': '123'}. If the template doesn’t specify any dynamic part, the value of this attribute will be an empty dictionary.
  • query_params: a dict mapping the name of each query string parameters to a list of values. For example, if the matched input is transactions/by-date?destination=abc123, the value for this attribute will be {'destination': ['abc123']}. If no query string is passed, this attribute will be an empty dictionary.

[!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.

from cartesi import DApp, Rollup, URLRouter, URLParameters

dapp = DApp()
url_router = URLRouter()
dapp.add_router(url_router)

@url_router.inspect('hello/{name}')
def hello_world_inspect_params(rollup: Rollup, params: URLParameters) -> bool:
    msg = f'Hello {params.path_params["name"]}'
    rollup.report('0x' + msg.encode('utf-8').hex())
    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.

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 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.

For example, given the following DApp:

from cartesi import DApp, Rollup, RollupData, JSONRouter

dapp = DApp()
json_router = JSONRouter()
dapp.add_router(json_router)

@json_router.advance({"op": "create-profile"})
def handle_create_profile(rollup: Rollup, data: RollupData):
    data = data.json_payload()
    name = data['name']
    rollup.report('0x' + name.encode('utf-8').hex())
    return True

@dapp.advance()
def default_handler(rollup: Rollup, data: RollupData):
    rollup.report('0x' + 'Unknown Operation'.encode('utf-8').hex())
    return True

if __name__ == '__main__':
    dapp.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.

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:

from cartesi.testclient import TestClient
import pytest

import echo

@pytest.fixture
def dapp_client() -> TestClient:
    client = TestClient(echo.dapp)
    return client

def test_simple_echo(dapp_client: TestClient):
    hex_payload = '0x' + 'hello'.encode('utf-8').hex()

    dapp_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

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.

The TestClient exposes the following methods and attributes:

TestClient.send_advance(self, hex_payload, msg_sender, timestamp)

Sends an advance state input, such as one being received from the underlying blockchain input box. It expects the following parameters:

  • hex_payload (required): a hex encoded string, starting with 0x, of the input
  • msg_sender (optional): a hex encoded string, starting with 0x of the address for the message sender. The default value for this parameter is 0xdeadbeef7dc51b33c9a3e4a21ae053daa1872810.

TestClient.send_inspect(self, hex_payload)

Sends an inspect input, such as one being received from the Inspect dApp state REST API. 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.

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.

TestClient.rollup.status

The boolean status of the latest transaction as returned by the handler. A True value indicates that the handler has completed successfully and its computation should be accepted. A False value generally denotes an error or invalid input.

TestClient.rollup.notices, TestClient.rollup.reports, TestClient.rollup.vouchers

A list of dictionaries containing the emitted notices, reports or vouchers. Each dictionary contains the following keys:

  • epoch_index: The epoch index for the input that generated this emitted output
  • input_index: The index of the input within the epoch that generated this output
  • data A dict containing the key payload, whose value is the payload for the corresponding output

For notices and reports, the payload will be a hex encoded string, starting with 0x, of the contents of the notice or report. Vouchers, on the other hand, should be a dictionary as expected by the Rollups server Add new Voucher API, i.e., containing a destination and a payload keys.

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.

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 model with specially annotated type hints that will allow the encoder to understand which Solidity type should be used when encoding the values.

For example, let’s suppose we want to call an ERC20 transfer function, which has the following signature:

function transfer(address to, uint256 value)

A Pydantic model that represents the arguments of this function should look like:

from cartesi import abi
from pydantic import BaseModel

class TransferArgs(BaseModel):
    to: abi.Address
    value: abi.UInt256

Note that the attributes of our TransferArgs class is in the same order as the corresponding function.

To generate a voucher, we can create an instance of this class with the desired arguments and pass it to the create_voucher_from_model function, as below:

from cartesi.vouchers import create_voucher_from_model

my_withdrawal = TransferArgs(to=receiver_address, value=value)
voucher = create_voucher_from_model(
    destination=erc20_contract_address,
    function_name='transfer',
    args_model=my_withdrawal
)

The value returned by the create_voucher_from_model function is a dict in the format expected by the voucher() method of the Rollup class, which is passed to each handler. See its description above for more information on the format.

A possible pattern that can simplify the development is to declare a function for generating a given voucher. Using the same use case, an example of this pattern would be:

from cartesi import abi
from cartesi.vouchers import create_voucher_from_model
from pydantic import BaseModel

class TransferArgs(BaseModel):
    to: abi.Address
    value: abi.UInt256

def transfer_erc20(
    erc20_address: abi.Address,
    receiver_address: abi.Address,
    value=abi.UInt256
):
    args = TransferArgs(to=erc20_address, value=value)

    return create_voucher_from_model(
        destination=erc20_contract_address,
        function_name='transfer',
        args_model=args,
    )

This way, inside your handler you can simply call this transfer_erc20 function to have the corresponding voucher generated.

The cartesi.vouchers module exposes two of such functions:

withdraw_ether(receiver, amount)

Generate a voucher for transferring Ethers from the contract to the receiver. The parameters are:

  • 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)

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.
  • 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



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *