Off-chain component

Here we need to implement Perun’s Core App interface.

Data

Before we implement the channel app, we define the game’s states. We put this in app/data.go.

The game state is handled in TicTacToeAppData, which implements go-perun’s Data interface. NextActor represents the next party expected to make a move, and Grid represents the Tic-Tac-Toe 3x3 grid. The Grid indices are defined from the upper left to the lower right.

// TicTacToeAppData is the app data struct.
// Grid:
// 0 1 2
// 3 4 5
// 6 7 8
type TicTacToeAppData struct {
	NextActor uint8
	Grid      [9]FieldValue
}

Encode

The Data interface requires that TicTacToeData can be encoded onto an io.Writer via Encode. This method writes our game data to the given data stream. It is needed to push the local game data to the smart contract. Decoding will take place in the app implementation.

// Encode encodes app data onto an io.Writer.
func (d *TicTacToeAppData) Encode(w io.Writer) error {
	err := writeUInt8(w, d.NextActor)
	if err != nil {
		return errors.WithMessage(err, "writing actor")
	}

	err = writeUInt8Array(w, makeUInt8Array(d.Grid[:]))
	return errors.WithMessage(err, "writing grid")
}

Manipulate Grid

With Set, we allow the manipulation of TicTacToeData. It takes coordinates x, y , and an index identifying the actor actorIdx to realize a specific move. To get the grid field index from x and y, we calculate \(i = y*3+x\) and set Grid[i] to the actor’s icon. Then we update TicTacToeAppData.NextActor with calcNextActor.

func (d *TicTacToeAppData) Set(x, y int, actorIdx channel.Index) {
	if d.NextActor != uint8safe(uint16(actorIdx)) {
		panic("invalid actor")
	}
	v := makeFieldValueFromPlayerIdx(actorIdx)
	d.Grid[y*3+x] = v
	d.NextActor = calcNextActor(d.NextActor)
}

String and Clone

We implement a simple String method that prints the match field into the command line to visualize TicTacToeAppData.Grid. This will be handy for following the game’s progress later on.

func (d *TicTacToeAppData) String() string {
	var b bytes.Buffer
	fmt.Fprintf(&b, "%v|%v|%v\n", d.Grid[0], d.Grid[1], d.Grid[2])
	fmt.Fprintf(&b, "%v|%v|%v\n", d.Grid[3], d.Grid[4], d.Grid[5])
	fmt.Fprintf(&b, "%v|%v|%v\n", d.Grid[6], d.Grid[7], d.Grid[8])
	fmt.Fprintf(&b, "Next actor: %v\n", d.NextActor)
	return b.String()
}

To allow copying TicTacToeAppData, we provide Clone.

// Clone returns a deep copy of the app data.
func (d *TicTacToeAppData) Clone() channel.Data {
	_d := *d
	return &_d
}

Utilities

Before we go on with the app implementation, we need to define a few utilities. Most are trivial; therefore, we will only look at the important ones here. These app utilities are implemented in app/util.go.

Same Player

samePlayer accepts a list of indices that refer to particular fields on the Tic-Tac-Toe grid and checks if the same player marked these fields.

Start by fetching the value of the first gird index. Then we compare this with the values of the other fields. If one of the fields is not set or is set but with another value, we return false. If all fields match, we return true with the respective PlayerIndex.

func (d TicTacToeAppData) samePlayer(gridIndices ...int) (ok bool, player channel.Index) {
	if len(gridIndices) < 2 {
		panic("expecting at least two inputs")
	}

	first := d.Grid[gridIndices[0]]
	if first == notSet {
		return false, 0
	}
	for _, i := range gridIndices {
		if d.Grid[i] != first {
			return false, 0
		}
	}
	return true, first.PlayerIndex()
}

Check Final

With CheckFinal, we take the current state of the match field and evaluate if the game is finalized, hence if there is a winner.

Define the winning condition. We start by listing all winning possibilities in an array.

func (d TicTacToeAppData) CheckFinal() (isFinal bool, winner *channel.Index) {
	// 0 1 2
	// 3 4 5
	// 6 7 8

	// Check winner.
	v := [][]int{
		{0, 1, 2}, {3, 4, 5}, {6, 7, 8}, // rows
		{0, 3, 6}, {1, 4, 7}, {2, 5, 8}, // columns
		{0, 4, 8}, {2, 4, 6}, // diagonals
	}

Check for a win. Then we check if one of these combinations is held by one player via TicTacToeAppData.samePlayer. If there is a player, we find a winner and return true, including the winner’s index idx.

	for _, _v := range v {
		ok, idx := d.samePlayer(_v...)
		if ok {
			return true, &idx
		}
	}

Check for a draw. If we cannot find a winner, we check if there is a draw or if the game is still in progress. In case of a draw, we return true with no player index. Note that the winning/draw check order is essential because there are cases where all grid values are set, but there is one winner.

	// Check all set.
	for _, v := range d.Grid {
		if v != notSet {
			return false, nil
		}
	}
	return true, nil
}

Compute Final Balances

Finally, we want to implement a function computeFinalBalances that computes the final balances based on the winner of the game.

First, we calculate the index of the loser. Notice that the way it is presented in code only works for two-party use cases. Then we loop through all the assets (in our case, it will only be one: Ethereum). In our case, the winner takes it all. Therefore, we add the loser’s balance to the winner’s and set the balance of the loser to zero. Ultimately we return the final balance finalBals.

func computeFinalBalances(bals channel.Balances, winner channel.Index) channel.Balances {
	loser := 1 - winner
	finalBals := bals.Clone()
	for i := range finalBals {
		finalBals[i][winner] = new(big.Int).Add(bals[i][0], bals[i][1])
		finalBals[i][loser] = big.NewInt(0)
	}
	return finalBals
}

App

We create the TicTacToeApp in app/app.go, which implements go-perun’s App interface that provides the base.

Note

The App interface comes in two flavors: As StateApp or ActionApp

  • The StateApp allows for simple one-sided state updates. This means that one party makes a move, and the state is updated accordingly (e.g., during the move of A, only input of A is needed to form the next state).

  • The ActionApp is a more fine-grained type. It allows to collect actions from all participants to apply those for a new state (e.g., A and B both contribute to forming the next state).

In our case, the StateApp is sufficient because, for a Tic-Tac-Toe game, we only need state updates from one side at a time. We implement this in app/app.go.

Data structure

We implement the off-chain component as type TicTacToeApp, which holds an address that links the object to the respective smart contract.

// TicTacToeApp is a channel app.
type TicTacToeApp struct {
	Addr wallet.Address
}

func NewTicTacToeApp(addr wallet.Address) *TicTacToeApp {
	return &TicTacToeApp{
		Addr: addr,
	}
}

Initialization

We create a getter for the app’s smart contract address Addr with Def.

// Def returns the app address.
func (a *TicTacToeApp) Def() wallet.Address {
	return a.Addr
}

Further, we create InitData, which wraps the TicTacToeAppData constructor to generate a new match field with a given party firstActor as the first to be allowed to make a move.

func (a *TicTacToeApp) InitData(firstActor channel.Index) *TicTacToeAppData {
	return &TicTacToeAppData{
		NextActor: uint8(firstActor),
	}
}

Decode

As Encode is required to encode the game data into a channel state, Decode is needed for converting a game state into a TicTacToeAppData format for us to use. We put the decoder in app/util.go.

First, we create an empty TicTacToeAppData object that we want to fill with the data (nextActor, grid) given by the io.Reader. We fetch the actor by calling readUInt8, which reads the first byte of the given data stream. Then we fetch the grid values by calling readUInt8Array, which reads the next nine bytes. Finally, we convert the bytes to their respective field values by calling makeFieldValueArray.

// DecodeData decodes the channel data.
func (a *TicTacToeApp) DecodeData(r io.Reader) (channel.Data, error) {
	d := TicTacToeAppData{}

	var err error
	d.NextActor, err = readUInt8(r)
	if err != nil {
		return nil, errors.WithMessage(err, "reading actor")
	}

	grid, err := readUInt8Array(r, len(d.Grid))
	if err != nil {
		return nil, errors.WithMessage(err, "reading grid")
	}
	copy(d.Grid[:], makeFieldValueArray(grid))
	return &d, nil
}

Validate Initial State

We will need to identify if an initial state is valid or not. Hence, we create ValidInit that takes the channel’s parameters channel.Params and a channel state channel.State as arguments. If the given arguments do not match the expected ones for a valid app channel initialization, it should return an error.

  1. Channel participants. Check if the number of actual channel participants matches the expected participants.

  2. Game data. Validate the data type of the channel.State’s data. It must be of type TicTacToeAppData.

  3. Grid. The grid must be empty. This is easily checkable by creating a new instance of TicTacToeAppData and comparing its gird with the given one.

  4. Finalization. Verify that the channel state is not finalized. Remember that a final state cannot be further progressed. Therefore it would not make sense to accept one here.

  5. Actor index. Validate the index of the NextActor. If no deviations are found, nil is returned.

// ValidInit checks that the initial state is valid.
func (a *TicTacToeApp) ValidInit(p *channel.Params, s *channel.State) error {
	if len(p.Parts) != numParts {
		return fmt.Errorf("invalid number of participants: expected %d, got %d", numParts, len(p.Parts))
	}

	appData, ok := s.Data.(*TicTacToeAppData)
	if !ok {
		return fmt.Errorf("invalid data type: %T", s.Data)
	}

	zero := TicTacToeAppData{}
	if appData.Grid != zero.Grid {
		return fmt.Errorf("invalid starting grid: %v", appData.Grid)
	}

	if s.IsFinal {
		return fmt.Errorf("must not be final")
	}

	if appData.NextActor >= numParts {
		return fmt.Errorf("invalid next actor: got %d, expected < %d", appData.NextActor, numParts)
	}
	return nil
}

Validate Transitions

ValidTransition is an important part of the off-chain app implementation in which we check locally if a given game move is valid or not. The method takes the channel’s parameters channel.Params, the old and (proposed) new channel state channel.State, and the actor index as arguments.

Warning

A bug in the valid transition function can result in fraudulent state transitions (e.g., a game move that is against the rules), potentially putting the client’s funds at risk.

Note

Note that this part will only affect the detection of invalid transitions. Even more critical is the on-chain validTransition function, because it ultimately decides what we can enforce.

Validate data type. Check if the given Data included in the channel.State’s is of the expected type.

// ValidTransition is called whenever the channel state transitions.
func (a *TicTacToeApp) ValidTransition(params *channel.Params, from, to *channel.State, idx channel.Index) error {
	err := channel.AssetsAssertEqual(from.Assets, to.Assets)
	if err != nil {
		return fmt.Errorf("Invalid assets: %v", err)
	}

	fromData, ok := from.Data.(*TicTacToeAppData)
	if !ok {
		panic(fmt.Sprintf("from state: invalid data type: %T", from.Data))
	}

	toData, ok := to.Data.(*TicTacToeAppData)
	if !ok {
		panic(fmt.Sprintf("to state: invalid data type: %T", from.Data))
	}

Validate actor. The proposing actor must differ from the actor of the old state because the game moves are expected to be alternating. The actor for the next state must match the result of calcNextActor given the previous actor.

	// Check actor.
	if fromData.NextActor != uint8safe(uint16(idx)) {
		return fmt.Errorf("invalid actor: expected %v, got %v", fromData.NextActor, idx)
	}

	// Check next actor.
	if len(params.Parts) != numParts {
		panic("invalid number of participants")
	}
	expectedToNextActor := calcNextActor(fromData.NextActor)
	if toData.NextActor != expectedToNextActor {
		return fmt.Errorf("invalid next actor: expected %v, got %v", expectedToNextActor, toData.NextActor)
	}

Validate turn. We want to detect fraudulent or invalid turns here and look at three aspects: Value: Is the set field value a possible one? Overwrite: Does the set field value overwrite another set field? Fields: Did none/more than one field change?

	// Check grid.
	changed := false
	for i, v := range toData.Grid {
		if v > maxFieldValue {
			return fmt.Errorf("invalid grid value at index %d: %d", i, v)
		}
		vFrom := fromData.Grid[i]
		if v != vFrom {
			if vFrom != notSet {
				return fmt.Errorf("cannot overwrite field %d", i)
			}
			if changed {
				return fmt.Errorf("cannot change two fields")
			}
			changed = true
		}
	}
	if !changed {
		return fmt.Errorf("cannot skip turn")
	}

Validate finalization. Finally, we check if the proposed state transition resulted in a final state (one party winning or a draw). We do this by calling CheckFinal on the proposed data and comparing it with final state claimed by the proposal.

Depending on this, we check if the correct balances are included. For this, we calculate the balances on our own via computeFinalBalances and compare the result with the proposal.

If all the mentioned checks pass, we accept the transition by returning nil.

	// Check final and allocation.
	isFinal, winner := toData.CheckFinal()
	if to.IsFinal != isFinal {
		return fmt.Errorf("final flag: expected %v, got %v", isFinal, to.IsFinal)
	}
	expectedAllocation := from.Allocation.Clone()
	if winner != nil {
		expectedAllocation.Balances = computeFinalBalances(from.Allocation.Balances, *winner)
	}
	if err := expectedAllocation.Equal(&to.Allocation); err != nil {
		return errors.WithMessagef(err, "wrong allocation: expected %v, got %v", expectedAllocation, to.Allocation)
	}
	return nil
}

Game Move

Set is responsible for creating the state inside the client’s Set method. Therefore, we get the channel.State, x, y position, and actor index as arguments.

We again start by making a basic check that the state’s app Data we want to manipulate is indeed of type TicTacToeAppData. Then we call TicTacToeApp.Set to perform the manipulation. Finally, we check if the move eventually lets the client win the game. We again use CheckFinal to compute the respective balances via computeFinalBalances in case a winner is found.

func (a *TicTacToeApp) Set(s *channel.State, x, y int, actorIdx channel.Index) error {
	d, ok := s.Data.(*TicTacToeAppData)
	if !ok {
		return fmt.Errorf("invalid data type: %T", d)
	}

	d.Set(x, y, actorIdx)
	log.Println("\n" + d.String())

	if isFinal, winner := d.CheckFinal(); isFinal {
		s.IsFinal = true
		if winner != nil {
			s.Balances = computeFinalBalances(s.Balances, *winner)
		}
	}
	return nil
}