Package: python-cartesi
Cartesi High Level FrameworkOverviewThe Cartesi HLF is a framework developing DApps that run inside theCartesimachine.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.InstallationTo install the framework you just have to do a simple:pipinstallpython-cartesiAlthough this is a pure Python library, it depends on PyCryptodome, which will need to compile some source code. You are advised to either includebuild-essentialin theapt-get installcommand 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 StartedA very simple DApp that simply echoes in a notice whatever input is sent to it can be seen below:importloggingfromcartesiimportDApp,Rollup,RollupDataLOGGER=logging.getLogger(__name__)logging.basicConfig(level=logging.DEBUG)dapp=DApp()@dapp.advance()defhandle_advance(rollup:Rollup,data:RollupData)->bool:payload=data.str_payload()LOGGER.debug("Echoing '%s'",payload)rollup.notice("0x"+payload.encode('utf-8').hex())returnTrueif__name__=='__main__':dapp.run()The handle_advance function will be registered as the DApp's default route of advance state requests by thedapp.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 theRouterssection.The handler function also receives two inputs: an instance of aRollupobject, that will allow you to interact with the Rollup Server, and an instance ofRollupData, that contains all the inputs and metadata for the current transaction.Interacting with the Rollup ServerEvery handler will receive an instance of theRollupclass, that abstracts the communications with the Rollup Server. This class exposes three main methods:Rollup.notice(self, payload: str)Adds a newnoticeto the current advance-state request, by calling theAdd new noticeAPI. Thepayloadparameter 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 insideadvance-staterequests.Rollup.report(self, payload: str)Adds a newreportto the current request, by calling theAdd new reportAPI. Just like the notice, thepayloadparameter 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 bothadvance-stateandinspect-staterequests.Rollup.voucher(self, payload: dict)Adds a newvoucherto the current request by calling theAdd new voucherAPI. Thepayloadshould 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.RoutersRouters 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 theadd_router()call, like the snippet below:fromcartesiimportDApp,JSONRouter# Create a DApp instancedapp=DApp()# Instantiate the JSON Routerjson_router=JSONRouter()# Register the JSON Router into the DAppdapp.add_router(json_router)JSON RouterThe JSON Router will match with inputs whose contents meet two criteria:The entirety of the input's content must be a valid JSONA pair of key/value must match with the pair given in the declaration of the routeThe JSON Router expose two decorators methods:advance(route_dict)andinspect(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:fromcartesiimportDApp,Rollup,RollupData,JSONRouterdapp=DApp()json_router=JSONRouter()dapp.add_router(json_router)@json_router.advance({"op":"create-profile"})defhandle_create_profile(rollup:Rollup,data:RollupData):data=data.json_payload()name=data['name']rollup.report('0x'+name.encode('utf-8').hex())returnTrueif__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 RouterThe 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 headersTo match with headers, you should pass an instance of a subclass ofABIHeaderto theheaderparameter of the decorator. The framework supplies the following data classes:ABILiteralHeaderMatches with a literal header supplied by the developer in theheaderattribute, as bytes. For example, the following handler matches with inputs starting with the bytes0x01020304:fromcartesiimportDApp,Rollup,RollupData,ABIRouter,ABILiteralHeaderdapp=DApp()abi_router=ABIRouter()dapp.add_router(abi_router)@abi_router.advance(header=ABILiteralHeader(header=bytes.fromhex('01020304')))defhandle_input_1234(rollup:Rollup,data:RollupData):...ABIFunctionSelectorHeaderGenerates a header according to the Solidity ABIFunction Selector. It expects two attributes:function: The name of the function being calledargument_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 declarationABIFunctionSelectorHeader(function='withdraw', argument_types=['uint256', 'address'])will match with a message starting with the bytes00f714ce, as they are the first 4 bytes of the Keccak-256 of the stringwithdraw(uint256,address)Creating a custom headerThe header classes are Pydantic models that inherit from theABIHeaderabstract base class. To create a custom header class, you should subclassABIHeaderand implement the methodto_bytes(), as below:fromCrypto.Hashimportkeccakfromcartesi.modelsimportABIHeaderclassMyCustomHeader(ABIHeader):descriptor:strdefto_bytes(self)->bytes:sig_hash=keccak.new(digest_bits=256)sig_hash.update(self.descriptor.encode('utf-8'))header=sig_hash.digest()[:4]returnheaderMatching with message senderThemsg_senderparameter for the advance decorator method of theABIRouterwill 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:fromcartesiimportDApp,Rollup,RollupData,ABIRouter,ABILiteralHeaderdapp=DApp()abi_router=ABIRouter()dapp.add_router(abi_router)ETHER_PORTAL='0xffdbe43d4c855bf7e0f105c400a50857f53ab044'@abi_router.advance(msg_sender=ETHER_PORTAL)defhandle_deposit(rollup:Rollup,data:RollupData):...In this example, thehandle_depositfunction will be called whenever the Ether portal sends an input to the DApp.Both themsg_senderandheaderparameters 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 RouterThe 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 thepathportion of the URL, without the leading slash, and optionally followed by the query string part.The router exposes two decorator methods:inspect(path_template)andadvance(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 inputtransactions/by-dateandtransactions/by-date?destination=abc123A path template'wallet/{id}/balance'will matchwallet/123/balance, but notwallet//balanceThe handler can receive a third argument of theURLParametertype. 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 iswallet/123/balance, the value ofpath_paramswill 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 istransactions/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 inputhello/world. When running with sunodo, this can be achieved by sending an HTTP GET request tohttp://localhost:8000/inspect/hello/world.fromcartesiimportDApp,Rollup,URLRouter,URLParametersdapp=DApp()url_router=URLRouter()dapp.add_router(url_router)@url_router.inspect('hello/{name}')defhello_world_inspect_params(rollup:Rollup,params:URLParameters)->bool:msg=f'Hello{params.path_params["name"]}'rollup.report('0x'+msg.encode('utf-8').hex())returnTrueDApp Relay RouterThis 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 calledaddress, that will be initialized as None and set to the address reported by the relay contract once it is received.fromcartesiimportDAppfromcartesi.routerimportDAppAddressRouterADDRESS_RELAY_ADDRESS='0xf5de34d6bbc0446e2a45719e718efebaae179dae'dapp=DApp()dapp_address=DAppAddressRouter(relay_address=ADDRESS_RELAY_ADDRESS)dapp.add_router(dapp_address)The DApp default RouterThe DApp object itself exposes two decorators:advance()andinspect(). 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:fromcartesiimportDApp,Rollup,RollupData,JSONRouterdapp=DApp()json_router=JSONRouter()dapp.add_router(json_router)@json_router.advance({"op":"create-profile"})defhandle_create_profile(rollup:Rollup,data:RollupData):data=data.json_payload()name=data['name']rollup.report('0x'+name.encode('utf-8').hex())returnTrue@dapp.advance()defdefault_handler(rollup:Rollup,data:RollupData):rollup.report('0x'+'Unknown Operation'.encode('utf-8').hex())returnTrueif__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, thehandle_create_profileroute will not match and the framework will call thedefault_handlerfunction with the input.TestingTesting 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 theTestClientclass expects a fully configured instance of theDAppclass, and expose methods for sending advance and inspect requests.For example, supposing we have anecho.pyfile with an implementation of an echo DApp, we could write the following file for automated tests using pytest:fromcartesi.testclientimportTestClientimportpytestimportecho@pytest.fixturedefdapp_client()->TestClient:client=TestClient(echo.dapp)returnclientdeftest_simple_echo(dapp_client:TestClient):hex_payload='0x'+'hello'.encode('utf-8').hex()dapp_client.send_advance(hex_payload=hex_payload)assertdapp_client.rollup.statusassertlen(dapp_client.rollup.notices)>0assertdapp_client.rollup.notices[-1]['data']['payload']==hex_payloadAlthough 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.TheTestClientexposes the following methods and attributes:TestClient.send_advance(self, hex_payload, msg_sender, timestamp)Sends anadvance stateinput, such as one being received from the underlying blockchain input box. It expects the following parameters:hex_payload(required): a hex encoded string, starting with0x, of the inputmsg_sender(optional): a hex encoded string, starting with0xof the address for the message sender. The default value for this parameter is0xdeadbeef7dc51b33c9a3e4a21ae053daa1872810.TestClient.send_inspect(self, hex_payload)Sends aninspectinput, such as one being received from theInspect dApp state REST API. It only expects a hex encoded string, starting with0x, with the inspect payload.For example, if you are running your DApp with sunodo, and want to simulate a the effects of a call tohttp://localhost:8000/inspect/hello/world, the value that should be passed in the hex_payload is'0x68656c6c6f2f776f726c64', which is the hex encoded representation ofhello/world.TestClient.rollupThis 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.statusThe boolean status of the latest transaction as returned by the handler. ATruevalue indicates that the handler has completed successfully and its computation should be accepted. AFalsevalue generally denotes an error or invalid input.TestClient.rollup.notices,TestClient.rollup.reports,TestClient.rollup.vouchersA 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 outputinput_index: The index of the input within the epoch that generated this outputdataA dict containing the keypayload, whose value is the payload for the corresponding outputFor notices and reports, the payload will be a hex encoded string, starting with0x, of the contents of the notice or report. Vouchers, on the other hand, should be a dictionary as expected by the Rollups serverAdd new VoucherAPI, i.e., containing adestinationand apayloadkeys.Generating VouchersA 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 SolidityContract 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 aPydanticmodel 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:functiontransfer(addressto,uint256value)A Pydantic model that represents the arguments of this function should look like:fromcartesiimportabifrompydanticimportBaseModelclassTransferArgs(BaseModel):to:abi.Addressvalue:abi.UInt256Note 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 thecreate_voucher_from_modelfunction, as below:fromcartesi.vouchersimportcreate_voucher_from_modelmy_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 thecreate_voucher_from_modelfunction is a dict in the format expected by thevoucher()method of theRollupclass, 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:fromcartesiimportabifromcartesi.vouchersimportcreate_voucher_from_modelfrompydanticimportBaseModelclassTransferArgs(BaseModel):to:abi.Addressvalue:abi.UInt256deftransfer_erc20(erc20_address:abi.Address,receiver_address:abi.Address,value=abi.UInt256):args=TransferArgs(to=erc20_address,value=value)returncreate_voucher_from_model(destination=erc20_contract_address,function_name='transfer',args_model=args,)This way, inside your handler you can simply call thistransfer_erc20function to have the corresponding voucher generated.Thecartesi.vouchersmodule 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 with0x, of the receiver of Ethersamount: Amount of ethers to transferwithdraw_erc20(rollup_address, token, receiver, amount)Generate a voucher for transferring ERC20 tokens owned by the contract to a receiver. The parameters arerollup_address: The hex encoded address, starting with0x, of the current DApp. See theDAppAddressRouterabove for a programmatic way of obtaining this value.token: The hex encoded address, starting with0x, of the ERC20 token contractreceiver: The hex encoded address, starting with0x, of the receiver of tokensamount: Amount of tokens to transfer