Transaction Processor: Creating a Transaction Handler

A transaction processor has two top-level components:

  • Processor class. The SDK provides a general-purpose processor class.

  • Handler class. The handler class is application-dependent. It contains the business logic for a particular family of transactions. Multiple handlers can be connected to an instance of the processor class.

Entry Point

Since a transaction processor is a long running process, it must have an entry point.

In the entry point, the TransactionProcessor class is given the address to connect with the validator and the handler class.

a simplified sawtooth_xo/processor/main.py
from sawtooth_sdk.processor.core import TransactionProcessor
from sawtooth_xo.processor.handler import XoTransactionHandler

def main():
    # In docker, the url would be the validator's container name with
    # port 4004
    processor = TransactionProcessor(url='tcp://127.0.0.1:4004')

    handler = XoTransactionHandler()

    processor.add_handler(handler)

    processor.start()

Handlers get called in two ways: with an apply method and with various “metadata” methods. The metadata is used to connect the handler to the processor. The bulk of the handler, however, is made up of apply and its helper functions.

sawtooth_xo/processor/handler.py XoTransactionHandler class
class XoTransactionHandler(TransactionHandler):
    def __init__(self, namespace_prefix):
        self._namespace_prefix = namespace_prefix

    @property
    def family_name(self):
        return 'xo'

    @property
    def family_versions(self):
        return ['1.0']

    @property
    def namespaces(self):
        return [self._namespace_prefix]

    def apply(self, transaction, context):
        # ...

Note that the XoTransactionHandler extends the TransactionHandler defined in the Python SDK.

The apply Method

apply gets called with two arguments, transaction and context. The argument transaction is an instance of the class Transaction that is created from the protobuf definition. Also, context is an instance of the class Context from the python SDK.

transaction holds the command that is to be executed (e.g. taking a space or creating a game), while context stores information about the current state of the game (e.g. the board layout and whose turn it is).

The transaction contains payload bytes that are opaque to the validator core, and transaction family specific. When implementing a transaction handler the binary serialization protocol is up to the implementer.

To separate details of state encoding and payload handling from validation logic, the XO example has XoState and XoPayload classes. The XoPayload has name, action, and space fields, while the XoState contains information about the game name, board, state, and which players are playing in the game.

Valid actions are: create a new game, take an unoccupied space, and delete a game.

sawtooth_xo/processor/handler.py apply overview
def apply(self, transaction, context):

    header = transaction.header
    signer = header.signer_public_key

    xo_payload = XoPayload.from_bytes(transaction.payload)

    xo_state = XoState(context)

    if xo_payload.action == 'delete':
        ...
    elif xo.payload.action == 'create':
        ...
    elif xo.payload.action == 'take':
        ...
    else:
        raise InvalidTransaction('Unhandled action: {}'.format(
            xo_payload.action))

For every new payload, the transaction processor validates rules surrounding the action. If all of the rules validate, then state is updated based on whether we are creating a game, deleting a game, or updating the game by taking a space.

Game Logic

The XO game logic is described in the XO transaction family specification; see Execution.

The validation rules and state updates that are associated with the create, delete, and take actions are shown below.

Create

The create action has the following implementation:

sawtooth_xo/processor/handler.py apply ‘create’
elif xo_payload.action == 'create':

    if xo_state.get_game(xo_payload.name) is not None:
        raise InvalidTransaction(
            'Invalid action: Game already exists: {}'.format(
                xo_payload.name))

    game = Game(name=xo_payload.name,
                board="-" * 9,
                state="P1-NEXT",
                player1="",
                player2="")

    xo_state.set_game(xo_payload.name, game)
    _display("Player {} created a game.".format(signer[:6]))

Delete

The delete action has the following implementation:

sawtooth_xo/processor/handler.py apply ‘delete’
if xo_payload.action == 'delete':
    game = xo_state.get_game(xo_payload.name)

    if game is None:
        raise InvalidTransaction(
            'Invalid action: game does not exist')

    xo_state.delete_game(xo_payload.name)

Take

The take action has the following implementation:

sawtooth_xo/processor/handler.py apply ‘take’
elif xo_payload.action == 'take':
    game = xo_state.get_game(xo_payload.name)

    if game is None:
        raise InvalidTransaction(
            'Invalid action: Take requires an existing game')

    if game.state in ('P1-WIN', 'P2-WIN', 'TIE'):
        raise InvalidTransaction('Invalid Action: Game has ended')

    if (game.player1 and game.state == 'P1-NEXT' and
        game.player1 != signer) or \
            (game.player2 and game.state == 'P2-NEXT' and
                game.player2 != signer):
        raise InvalidTransaction(
            "Not this player's turn: {}".format(signer[:6]))

    if game.board[xo_payload.space - 1] != '-':
        raise InvalidTransaction(
            'Invalid Action: space {} already taken'.format(
                xo_payload))

    if game.player1 == '':
        game.player1 = signer

    elif game.player2 == '':
        game.player2 = signer

    upd_board = _update_board(game.board,
                                xo_payload.space,
                                game.state)

    upd_game_state = _update_game_state(game.state, upd_board)

    game.board = upd_board
    game.state = upd_game_state

    xo_state.set_game(xo_payload.name, game)
    _display(
        "Player {} takes space: {}\n\n".format(
            signer[:6],
            xo_payload.space) +
        _game_data_to_str(
            game.board,
            game.state,
            game.player1,
            game.player2,
            xo_payload.name))

Payload

Note

Transactions and Batches contains a detailed description of how transactions are structured and used. Please read this document before proceeding, if you have not reviewed it.

So how do we get data out of the transaction? The transaction consists of a header and a payload. The header contains the “signer”, which is used to identify the current player. The payload will contain an encoding of the game name, the action (create a game, delete a game, take a space), and the space (which will be an empty string if the action isn’t take).

An XO transaction request payload consists of the UTF-8 encoding of a string with exactly two commas, which is formatted as follows:

<name>,<action>,<space>

  • <name> is the game name as a non-empty string not containing the character |. If the action is create, the new name must be unique.

  • <action> is the game action: create, take, or delete

  • <space> is the location on the board, as an integer between 1-9 (inclusive), if the action is take

sawtooth_xo/processor/xo_payload.py
class XoPayload:

    def __init__(self, payload):
        try:
            # The payload is csv utf-8 encoded string
            name, action, space = payload.decode().split(",")
        except ValueError:
            raise InvalidTransaction("Invalid payload serialization")

        if not name:
            raise InvalidTransaction('Name is required')

        if '|' in name:
            raise InvalidTransaction('Name cannot contain "|"')

        if not action:
            raise InvalidTransaction('Action is required')

        if action not in ('create', 'take', 'delete'):
            raise InvalidTransaction('Invalid action: {}'.format(action))

        if action == 'take':
            try:

                if int(space) not in range(1, 10):
                    raise InvalidTransaction(
                        "Space must be an integer from 1 to 9")
            except ValueError:
                raise InvalidTransaction(
                    'Space must be an integer from 1 to 9')

        if action == 'take':
            space = int(space)

        self._name = name
        self._action = action
        self._space = space

    @staticmethod
    def from_bytes(payload):
        return XoPayload(payload=payload)

    @property
    def name(self):
        return self._name

    @property
    def action(self):
        return self._action

    @property
    def space(self):
        return self._space

State

The XoState class turns game information into bytes and stores it in the validator’s Radix-Merkle tree, turns bytes stored in the validator’s Radix-Merkle tree into game information, and does these operations with a state storage scheme that handles hash collisions.

An XO state entry consists of the UTF-8 encoding of a string with exactly four commas formatted as follows:

<name>,<board>,<game-state>,<player-key-1>,<player-key-2>

where

  • <name> is a nonempty string not containing |,

  • <board> is a string of length 9 containing only O, X, or -,

  • <game-state> is one of the following: P1-NEXT, P2-NEXT, P1-WIN,

  • P2-WIN, or TIE, and

  • <player-key-1> and <player-key-2> are the (possibly empty) public keys

  • associated with the game’s players.

In the event of a hash collision (i.e. two or more state entries sharing the same address), the colliding state entries will stored as the UTF-8 encoding of the string <a-entry>|<b-entry>|..., where <a-entry>, <b-entry>,… are sorted alphabetically.

sawtooth_xo/processor/xo_state.py
XO_NAMESPACE = hashlib.sha512('xo'.encode("utf-8")).hexdigest()[0:6]


class Game:
    def __init__(self, name, board, state, player1, player2):
        self.name = name
        self.board = board
        self.state = state
        self.player1 = player1
        self.player2 = player2


class XoState:

    TIMEOUT = 3

    def __init__(self, context):
        """Constructor.
        Args:
            context (sawtooth_sdk.processor.context.Context): Access to
                validator state from within the transaction processor.
        """

        self._context = context
        self._address_cache = {}

    def delete_game(self, game_name):
        """Delete the Game named game_name from state.
        Args:
            game_name (str): The name.
        Raises:
            KeyError: The Game with game_name does not exist.
        """

        games = self._load_games(game_name=game_name)

        del games[game_name]
        if games:
            self._store_game(game_name, games=games)
        else:
            self._delete_game(game_name)

    def set_game(self, game_name, game):
        """Store the game in the validator state.
        Args:
            game_name (str): The name.
            game (Game): The information specifying the current game.
        """

        games = self._load_games(game_name=game_name)

        games[game_name] = game

        self._store_game(game_name, games=games)

    def get_game(self, game_name):
        """Get the game associated with game_name.
        Args:
            game_name (str): The name.
        Returns:
            (Game): All the information specifying a game.
        """

        return self._load_games(game_name=game_name).get(game_name)

    def _store_game(self, game_name, games):
        address = _make_xo_address(game_name)

        state_data = self._serialize(games)

        self._address_cache[address] = state_data

        self._context.set_state(
            {address: state_data},
            timeout=self.TIMEOUT)

    def _delete_game(self, game_name):
        address = _make_xo_address(game_name)

        self._context.delete_state(
            [address],
            timeout=self.TIMEOUT)

        self._address_cache[address] = None

    def _load_games(self, game_name):
        address = _make_xo_address(game_name)

        if address in self._address_cache:
            if self._address_cache[address]:
                serialized_games = self._address_cache[address]
                games = self._deserialize(serialized_games)
            else:
                games = {}
        else:
            state_entries = self._context.get_state(
                [address],
                timeout=self.TIMEOUT)
            if state_entries:

                self._address_cache[address] = state_entries[0].data

                games = self._deserialize(data=state_entries[0].data)

            else:
                self._address_cache[address] = None
                games = {}

        return games

    def _deserialize(self, data):
        """Take bytes stored in state and deserialize them into Python
        Game objects.
        Args:
            data (bytes): The UTF-8 encoded string stored in state.
        Returns:
            (dict): game name (str) keys, Game values.
        """

        games = {}
        try:
            for game in data.decode().split("|"):
                name, board, state, player1, player2 = game.split(",")

                games[name] = Game(name, board, state, player1, player2)
        except ValueError:
            raise InternalError("Failed to deserialize game data")

        return games

    def _serialize(self, games):
        """Takes a dict of game objects and serializes them into bytes.
        Args:
            games (dict): game name (str) keys, Game values.
        Returns:
            (bytes): The UTF-8 encoded string stored in state.
        """

        game_strs = []
        for name, g in games.items():
            game_str = ",".join(
                [name, g.board, g.state, g.player1, g.player2])
            game_strs.append(game_str)

        return "|".join(sorted(game_strs)).encode()

Addressing

By convention, we’ll store game data at an address obtained from hashing the game name prepended with some constant.

XO data is stored in state using addresses generated from the XO family name and the name of the game being stored. In particular, an XO address consists of the first 6 characters of the SHA-512 hash of the UTF-8 encoding of the string “xo” (which is “5b7349”) plus the first 64 characters of the SHA-512 hash of the UTF-8 encoding of the game name.

For example, the XO address for a game called “my-game” could be generated as follows (in Python):

>>> XO_NAMESPACE = hashlib.sha512('xo'.encode('utf-8')).hexdigest()[:6]
>>> XO_NAMESPACE
'5b7349'
>>> y = hashlib.sha512('my-game'.encode('utf-8')).hexdigest()[:64]
>>> y
'4d4cffe9cf3fb4e41def5114a323e292af9b0e07925cca6299d671ce7fc7ec37'
>>> XO_NAMESPACE+y
'5b73494d4cffe9cf3fb4e41def5114a323e292af9b0e07925cca6299d671ce7fc7ec37'

Addressing is implemented as follows:

def _make_xo_address(name):
return XO_NAMESPACE + \
    hashlib.sha512(name.encode('utf-8')).hexdigest()[:64]