patractlabs / py-patract

Substrate Contract SDK for Python As a part of Himalia
Apache License 2.0
12 stars 0 forks source link

Himalia PatractPy

Substrate Contract SDK for Python As a part of Himalia


PatractPy is a contract SDK to support the development of Python scripts that interact with contracts, including automated scripts to support testing. Unlike PatractGo, PatractPy is mainly for script development, so PatractPy mainly completes contract-related RPC interfaces, and completes contract deployment and instantiation-related operations.

PatractPy will provide support for europa env, which is a good environment for contract exec sandbox, With PatractPy, we can write contract unittest by python, which is more friendly to developer and can easy use other test tools.

PatractPy will be based on polkascan's Python Substrate Interface, which is a Python sdk for Substrate.

Element Group for disscusion: https://app.element.io/#/room/#PatractLabsDev:matrix.org

PatractPy will achieve the following support:

Usage

The sdk is in https://pypi.org/project/patract-interface/0.3.1/

install package:

pip3 install -U patract-interface 

use in python:

from patractinterface import ContractFactory, ContractAPI

... So something ...

Quick start

import os
from substrateinterface import SubstrateInterface, Keypair
from patractinterface.contract import ContractAPI, ContractFactory
from patractinterface.observer import ContractObserver

def main():
    # use [europa](https://github.com/patractlabs/europa) as test node endpoint, notice `type_registry` should set correctly.
    substrate=SubstrateInterface(url='ws://127.0.0.1:9944', type_registry_preset="default", type_registry={'types': {'LookupSource': 'MultiAddress'}})
    # load deployer key
    alice = Keypair.create_from_uri('//Alice')
    bob = Keypair.create_from_uri('//Bob')
    # 1. load a contract from WASM file and metadata.json file (Those files is complied by [ink!](https://github.com/paritytech/ink))
    # in this example, we use `ink/example/erc20` contract as example.
    contract = ContractFactory.create_from_file(
            substrate=substrate, # should provide a subtrate endpoint
            code_file= os.path.join(os.path.dirname(__file__), 'res', 'erc20.wasm'),
            metadata_file= os.path.join(os.path.dirname(__file__), 'res', 'erc20.json')
        )
    # 2. instantiate the uploaded code as a contract instance
    erc20_ins = contract.new(alice, 1000000 * (10 ** 15), endowment=2*10**10, gas_limit=20000000000, deployment_salt="0x12")
    # 2.1 create a observer to listen event
    observer = ContractObserver(erc20_ins.contract_address, erc20_ins.metadata, substrate)
    # 3. send a transfer call for this contract
    res = erc20_ins.transfer(alice, bob.ss58_address, 100000, gas_limit=20000000000)
    print('transfer res', res.is_success)

    def on_transfer(num, evt):
        print("on_transfer in {} : {} {} {}".format(num, evt['from'], evt['to'], evt['value']))

    def on_approval(num, evt):
        print("on_approval in {} : {} {} {}".format(num, evt['owner'], evt['spender'], evt['value']))
    # 4 set event callback 
    observer.scanEvents(handlers={
        'Transfer': on_transfer,
        'Approve': on_approval
    })

if __name__ == "__main__":
    main()
    pass

Test

For Unittest, should install europa at first.

# install v1.0.0 europa to local
cargo install europa --git=https://github.com/patractlabs/europa --tag=v1.0.0 --force --locked
# check europa version
europa --version

All of test pased by europa environment.

Install pytest and executor to run test:

pip3 install pytest
pip3 install executor
pytest ./test --log-cli-level info 

Basic Apis For Contracts

As polkascan's Python Substrate Interface has provide some support to contract api, so we not need to important the api for contract calls, but there is some api to add:

The basic api split into 2 parts:

All methods which belong to the instance of ContractAPI and ContractFactory receive a keypair as the first parameter, as the sender for this operation. And from the second parameter, receive the parameters defined in contracts.

ContractFactory and ContractAPI is used to react with contracts

we add a factory to put code and deploy contracts to chain:

    factory = ContractFactory.create_from_file(
        substrate=substrate, 
        code_file=os.path.join(os.path.dirname(__file__), 'contract', 'erc20.wasm'),
        metadata_file=os.path.join(os.path.dirname(__file__), 'contract', 'erc20.json')
    )

    # this api is `ContractAPI`
    api = factory.new(alice, 1000000 * (10 ** 15), endowment=10**15, gas_limit=1000000000000)
    print(api.contract_address) # contract_address is the deployed contract

The factory will generate constructors from metadata file.

We add api by metadata for Contract, api will auto generate caller for contract from metadata:

# create a ContractAPI from an existed contract address
api = ContractAPI(contract_address, contract_metadata, substrate)

# api will auto generate caller for contract from metadata
alice_balance_old = api.balance_of(bob, alice.ss58_address) # bob is the keypair for `//Bob`

res = api.transfer(alice, bob.ss58_address, 100000, gas_limit=20000000000)
logging.info(f'transfer res {res.error_message}')
print(res.is_success)

alice_balance = api.balance_of(bob, alice.ss58_address)
logging.info(f'transfer alice_balance {alice_balance}')

bob_balance = api.balance_of(bob, bob.ss58_address)
logging.info(f'transfer bob_balance {bob_balance}')

The api will generate exec and read api from metadata file, for example:

      {
        "args": [
          {
            "name": "owner",
            "type": {
              "displayName": [
                "AccountId"
              ],
              "type": 5
            }
          }
        ],
        "docs": [
          " Returns the account balance for the specified `owner`.",
          "",
          " Returns `0` if the account is non-existent."
        ],
        "mutates": false,
        "name": [
          "balance_of"
        ],
        "payable": false,
        "returnType": {
          "displayName": [
            "Balance"
          ],
          "type": 1
        },
        "selector": "0x56e929b2"
      },

In api, can call by:

bob_balance = api.balance_of(bob, bob.ss58_address)
logging.info(f'transfer bob_balance {bob_balance}')

ContractObserver is used to listen contracts events

ContractObserver can observer events for a contract:

substrate=SubstrateInterface(url="ws://127.0.0.1:9944", type_registry_preset='canvas')
contract_metadata = ContractMetadata.create_from_file(
    metadata_file=os.path.join(os.path.dirname(__file__), 'constracts', 'ink', 'erc20.json'),
    substrate=substrate
)
observer = ContractObserver("0x8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a48", contract_metadata, substrate)

# for some handlers (callbacks)
observer.scanEvents()

The handler function can take the erc20 support as a example.

Special case: ERC20 API

Except react contract by ContractAPI, developers could create the wrapper by themself to react with corresponding contract. py-contract create an ERC20 API as an example to show this.

ERC20 api provide a wapper to erc20 contract exec, read and observer events, it can be a example for contracts api calling.


# init api
substrate=SubstrateInterface(url="ws://127.0.0.1:9944", type_registry_preset='canvas')

contract_metadata = ContractMetadata.create_from_file(
    metadata_file=os.path.join(os.path.dirname(__file__), 'constracts', 'ink', 'erc20.json'),
    substrate=substrate
)

alice = Keypair.create_from_uri('//Alice')
bob = Keypair.create_from_uri('//Bob')

# erc20 api
erc20 = ERC20.create_from_contracts(
    substrate= substrate, 
    contract_file= os.path.join(os.path.dirname(__file__), 'constracts', 'ink', 'erc20.wasm'),
    metadata_file= os.path.join(os.path.dirname(__file__), 'constracts', 'ink', 'erc20.json')
)

# deplay a erc20 contract
erc20.instantiate_with_code(alice, 1000000 * (10 ** 15))

# read total supply
total_supply = erc20.totalSupply()

# transfer
erc20.transfer_from(alice,
    from_acc=alice.ss58_address, 
    to_acc=bob.ss58_address, 
    amt=10000)

erc20.transfer(alice, bob.ss58_address, 10000)

# get balance
alice_balance = erc20.balance_of(alice.ss58_address)

# approve
erc20.approve(alice, spender=bob.ss58_address, amt=10000)

# get allowance
alice_allowance = erc20.allowance(alice.ss58_address, bob.ss58_address)

ERC20Observer is a event observer for erc20 contract:

observer = ERC20Observer.create_from_address(
    substrate = substrate, 
    contract_address = contract_address,
    metadata_file= os.path.join(os.path.dirname(__file__), 'constracts', 'ink', 'erc20.json')
)

def on_transfer(num, evt):
    logging.info("on_transfer in {} : {} {} {}".format(num, evt['from'], evt['to'], evt['value']))

def on_approval(num, evt):
    logging.info("on_approval in {} : {} {} {}".format(num, evt['owner'], evt['spender'], evt['value']))

observer.scanEvents(on_transfer = on_transfer, on_approval = on_approval)

Observer For Contracts

ContractObserver is a observer to listen events by contract with a given address:

observer = ContractObserver.create_from_address(
    substrate = substrate, 
    contract_address = 'contract_address',
    metadata_file= os.path.join(os.path.dirname(__file__), 'constracts', 'ink', 'erc20.json')
)

def on_transfer(num, evt):
    logging.info("on_transfer in {} : {} {} {}".format(num, evt['from'], evt['to'], evt['value']))

def on_approval(num, evt):
    logging.info("on_approval in {} : {} {} {}".format(num, evt['owner'], evt['spender'], evt['value']))

observer.scanEvents(from_num, to_num, {
    'Transfer': on_transfer,
    'Approve': on_approval
})

handlers is a hander dictionary by name to hander function.

Unittest Node Environment

PatractPy can support write contract unittest by node environment.

At First We need install europa.

from patractinterface.contracts.erc20 import ERC20
from patractinterface.unittest.env import SubstrateTestEnv

class UnittestEnvTest(unittest.TestCase):
    @classmethod
    def setUp(cls):
        # start env or use canvas for a 6s block
        cls.env = SubstrateTestEnv.create_europa(port=39944)
        cls.env.start_node()

        cls.api = SubstrateInterface(url=cls.env.url(), type_registry_preset=cls.env.typ(), type_registry=cls.env.types())
        cls.alice = Keypair.create_from_uri('//Alice')
        cls.bob = Keypair.create_from_uri('//Bob')

        cls.erc20 = ERC20.create_from_contracts(
            substrate= cls.substrate, 
            contract_file= os.path.join(os.path.dirname(__file__), 'constracts', 'ink', 'erc20.wasm'),
            metadata_file= os.path.join(os.path.dirname(__file__), 'constracts', 'ink', 'erc20.json')
        )
        cls.erc20.instantiate_with_code(alice, 1000000 * (10 ** 15))

        return

    def tearDown(cls):
        cls.env.stop_node()

    def test_transfer(self):
        self.erc20.transfer_from(alice,
            from_acc=alice.ss58_address, 
            to_acc=bob.ss58_address, 
            amt=10000)
        # some more test case

if __name__ == '__main__':
    unittest.main()

By example, we can use python to write testcase for some complex logics, by europa, we can test the contracts for python scripts.