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
.