Client๏ƒ

In this section, we describe the app channel client that is used for opening a channel with the Tic Tac Toe app instantiated. Much of the app channel client is similar to the payment channel client, and we will reuse some of the code here. The code will be placed in package client.

package client

Constructor๏ƒ

The main part of our app channel client is placed in client/client.go. Our client is of type AppClient, which is similar to the PaymentClient. The only additional parameters here are stake and app.

// AppClient is an app channel client.
type AppClient struct {
	perunClient *client.Client    // The core Perun client.
	account     wallet.Address    // The account we use for on-chain and off-chain transactions.
	currency    channel.Asset     // The currency we expect to get paid in.
	stake       channel.Bal       // The amount we put at stake.
	app         *app.TicTacToeApp // The app definition.
	channels    chan *TicTacToeChannel
}

SetupAppClient is extended accordingly. For an explanation of its logic, please have a look at the description of the payment client constructor.

Open channel๏ƒ

In the channel opening procedure OpenAppChannel, we need to perform two changes to adapt to our app channel use case.

On the one hand, we now expect both parties to deposit matching funds to ensure equal awards for the winner. We realize this by putting stake for both channel.Bal indices when calling Allocation.SetAssetBalances.

// OpenAppChannel opens a new app channel with the specified peer.
func (c *AppClient) OpenAppChannel(peer wire.Address) *TicTacToeChannel {
	participants := []wire.Address{c.account, peer}

	// We create an initial allocation which defines the starting balances.
	initAlloc := channel.NewAllocation(2, c.currency)
	initAlloc.SetAssetBalances(c.currency, []channel.Bal{
		c.stake, // Our initial balance.
		c.stake, // Peer's initial balance.
	})

On the other hand, we include our application app in the channel proposal. We call client.WithApp provided by go-perun, configuring the given app with the desired initial data. How app.InitData generates the initial data is described in the app section.

Important

Keep in mind the criticality of the challenge duration parameter. Here it also defines the amount of time players have for their turn once the channel is disputed on-chain.

	// Prepare the channel proposal by defining the channel parameters.
	challengeDuration := uint64(10) // On-chain challenge duration in seconds.

	firstActorIdx := channel.Index(0)
	withApp := client.WithApp(c.app, c.app.InitData(firstActorIdx))

	proposal, err := client.NewLedgerChannelProposal(
		challengeDuration,
		c.account,
		initAlloc,
		participants,
		withApp,
	)
	if err != nil {
		panic(err)
	}

	// Send the app channel proposal.
	ch, err := c.perunClient.ProposeChannel(context.TODO(), proposal)
	if err != nil {
		panic(err)
	}

	// Start the on-chain event watcher. It automatically handles disputes.
	c.startWatching(ch)

	return newTicTacToeChannel(ch)
}

Handle proposal๏ƒ

Our app channel clientโ€™s handler provides callbacks for handling incoming app channel proposals and updates in client/handle.go.

Note

This part is again very similar to the payment channel example with the difference that we now expect a specific app channel that receives equal funding from both sides. For more details, please have a look at the description of the payment channel proposal handler.

To ensure that the proposal includes the correct app, we check if the proposalโ€™s contract app address equals the one the client expects. Furthermore, we check that the funding agreement, lcp.FundingAgreement, corresponds to the expected stake.

// HandleProposal is the callback for incoming channel proposals.
func (c *AppClient) HandleProposal(p client.ChannelProposal, r *client.ProposalResponder) {
	lcp, err := func() (*client.LedgerChannelProposal, error) {
		// Ensure that we got a ledger channel proposal.
		lcp, ok := p.(*client.LedgerChannelProposal)
		if !ok {
			return nil, fmt.Errorf("Invalid proposal type: %T\n", p)
		}

		// Ensure the ledger channel proposal includes the expected app.
		if !lcp.App.Def().Equals(c.app.Def()) {
			return nil, fmt.Errorf("Invalid app type ")
		}

		// Check that we have the correct number of participants.
		if lcp.NumPeers() != 2 {
			return nil, fmt.Errorf("Invalid number of participants: %d", lcp.NumPeers())
		}

		// Check that the channel has the expected assets.
		err := channel.AssetsAssertEqual(lcp.InitBals.Assets, []channel.Asset{c.currency})
		if err != nil {
			return nil, fmt.Errorf("Invalid assets: %v\n", err)
		}

		// Check that the channel has the expected assets and funding balances.
		const assetIdx, peerIdx = 0, 1
		if err := channel.AssetsAssertEqual(lcp.InitBals.Assets, []channel.Asset{c.currency}); err != nil {
			return nil, fmt.Errorf("Invalid assets: %v\n", err)
		} else if lcp.FundingAgreement[assetIdx][peerIdx].Cmp(c.stake) != 0 {
			return nil, fmt.Errorf("Invalid funding balance")
		}
		return lcp, nil
	}()
	if err != nil {
		r.Reject(context.TODO(), err.Error()) //nolint:errcheck // It's OK if rejection fails.
	}

As in the payment channel example, we create the accept message, send it, and start the on-chain watcher.

	// Create a channel accept message and send it.
	accept := lcp.Accept(
		c.account,                // The account we use in the channel.
		client.WithRandomNonce(), // Our share of the channel nonce.
	)
	ch, err := r.Accept(context.TODO(), accept)
	if err != nil {
		fmt.Printf("Error accepting channel proposal: %v\n", err)
		return
	}

	// Start the on-chain event watcher. It automatically handles disputes.
	c.startWatching(ch)

	c.channels <- newTicTacToeChannel(ch)
}

Handle Update๏ƒ

We donโ€™t need to do much here in the app channel case for handling state updates. We can accept every update because the appโ€™s valid transition function ensures that the transition is valid.

// HandleUpdate is the callback for incoming channel updates.
func (c *AppClient) HandleUpdate(cur *channel.State, next client.ChannelUpdate, r *client.UpdateResponder) {
	// Perun automatically checks that the transition is valid.
	// We always accept.
	err := r.Accept(context.TODO())
	if err != nil {
		panic(err)
	}
}

With HandleAdjudicatorEvent we formulate a callback for smart contract events, only printing these in the command line output.

App channel๏ƒ

We implement the type TicTacToeChannel that wraps a Perun channel and provides functions to interact with the app channel. We put this functionality in client/channel.go.

// TicTacToeChannel is a wrapper for a Perun channel for the Tic-tac-toe app use case.
type TicTacToeChannel struct {
	ch *client.Channel
}

Set๏ƒ

We create Set, which expects x and y that indicate where the client wants to put its symbol on the grid.

We use channel.UpdateBy for proposing our desired update to the channelโ€™s state. With state.App, we fetch the app that identifies the channelโ€™s application and check if it is of the expected type. Then we call TicTacToeApp.Set, which will manipulate state.Data to include the desired turn. state.Data holds all app-specific data, reflecting our Tic-Tac-Toe gameโ€™s current game state. We go into more detail about this in the app description.

// Set sends a game move to the channel peer.
func (g *TicTacToeChannel) Set(x, y int) {
	err := g.ch.UpdateBy(context.TODO(), func(state *channel.State) error {
		app, ok := state.App.(*app.TicTacToeApp)
		if !ok {
			return fmt.Errorf("invalid app type: %T", app)
		}

		return app.Set(state, x, y, g.ch.Idx())
	})
	if err != nil {
		panic(err) // We panic on error to keep the code simple.
	}
}

Force Set๏ƒ

ForceSet is similar to Set but uses go-perunโ€™s channel.ForceUpdated instead of channel.UpdateBy. This forced update bypasses the state channel and registers a game move directly on-chain, which is needed in case of a dispute.

Note

Suppose party A sent an updated game state to malicious party B. Aโ€™s game move is valid and would lead to A winning the game, therefore gaining access to the locked funds. Now B could potentially refuse to accept this valid transition in an attempt not to lose the game. In this case, A utilizes ForceSet with its proposed update to enforce the game rules on-chain without full consensus and win the game properly.

// ForceSet registers a game move on-chain.
func (g *TicTacToeChannel) ForceSet(x, y int) {
	err := g.ch.ForceUpdate(context.TODO(), func(state *channel.State) {
		err := func() error {
			app, ok := state.App.(*app.TicTacToeApp)
			if !ok {
				return fmt.Errorf("invalid app type: %T", app)
			}

			return app.Set(state, x, y, g.ch.Idx())
		}()
		if err != nil {
			panic(err)
		}
	})
	if err != nil {
		panic(err)
	}
}

Settle Channel๏ƒ

Settling the channel is quite similar to the way we already implemented in the payment channel tutorial. But in our case, we can skip the finalization part because we expect the app logic to finalize the channel after the winning move.

// Settle settles the app channel and withdraws the funds.
func (g *TicTacToeChannel) Settle() {
	// Channel should be finalized through last ("winning") move.
	// No need to set `isFinal` here.
	err := g.ch.Settle(context.TODO(), false)
	if err != nil {
		panic(err)
	}

	// Cleanup.
	g.ch.Close()
}

Utilities๏ƒ

The utility functions startWatching(), AcceptedChannel(), Shutdown(), CreateContractBackend(), EthToWei() and WeiToEth() remain untouched and are are taken over from their definitions in the payment channel example. These functionalities are implemented in client/client.go and client/util.go.