Multi-Ledger Channel Tutorial

In this tutorial, we will show you how to set up a multi-ledger channel, i.e., a channel that operates between between multiple (here, 2) chains using go-perun. We will then let two clients, Alice and Bob, swap their ERC20 tokens from the two different blockchains in this channel. If you are interested in the technical concept of multi-ledger channels in go-perun, you may take a look at the Multi-Ledger Protocol page. We recommend reading the payment channel tutorial first since we will reuse most of the code but it’s not a must as we will also explain the relevant parts in this tutorial.

Source Code

This tutorial’s source code is available at perun-examples/multi-ledger-example.

# Download repository.
git clone https://github.com/perun-network/perun-examples.git
cd perun-examples/multi-ledger-example

Client Setup

First, we will have a look how we initialize the client in order to use multi-ledger channels. For that, navigate into client/client.go.

There, we added a struct for holding all relevant information about the chains we want to use:

// ChainConfig is used to hold all information needed about a specific chain.
type ChainConfig struct {
	ChainID     ethchannel.ChainID
	ChainURL    string
	Token       common.Address // The address of the deployed ERC20 token.
	Adjudicator common.Address // The address of the deployed Adjudicator contract.
	AssetHolder common.Address // The address of the deployed AssetHolder contract.
}

The PaymentClient from the payment channel tutorial is extended to our SwapClient such that it holds an array of two currencies for the two chains instead of only one.

// SwapClient is a channel client for swaps.
type SwapClient struct {
	perunClient *client.Client    // The core Perun client.
	account     wallet.Address    // The account we use for on-chain and off-chain transactions.
	currencies  [2]channel.Asset  // The currencies of the different chains we support.
	channels    chan *SwapChannel // Accepted payment channels.
}

Now, we are ready to create the constructor for our PaymentClient, which takes a number of parameters as described below. Note that the last one is the array of the two chains that we want to use.

// SetupSwapClient creates a new swap client.
func SetupSwapClient(
	bus wire.Bus, // bus is used of off-chain communication.
	w *swallet.Wallet, // w is the wallet used for signing transactions.
	acc common.Address, // acc is the address of the account to be used for signing transactions.
	chains [2]ChainConfig, // chains represent the two chains the client should be able to use.
) (*SwapClient, error) {

The initialization of the Perun client is similar as in the payment channel tutorial, however, here we will highlight the differences regarding the multi-ledger functionality. For a more detailed explanation, we refer to the payment client section.

First of all, we will use special variants of the funder and adjudicator which are located in the multi package of go-perun. Essentially, the multi-ledger funder and multi-ledger adjudicator contain a funder or adjudicator for each ledger identified by its ID. Just for curiosity, we can have a brief look how the multi-ledger funder is actually implemented in go-perun:

type Funder struct {
	funders map[LedgerIDMapKey]channel.Funder
}

// NewFunder creates a new funder.
func NewFunder() *Funder {
	return &Funder{
		funders: make(map[LedgerIDMapKey]channel.Funder),
	}
}

// RegisterFunder registers a funder for a given ledger.
func (f *Funder) RegisterFunder(l LedgerID, lf channel.Funder) {
	f.funders[l.MapKey()] = lf
}

Having this in mind, we can jump back to our code and see how we set up the multi funder and adjudicator (note the highlighted lines). After we have initialized them, we iterate over the chains array. The first loop only creates the assets which we will register on the funder. The second loop proceeds with creating the contract backend for this chain, validating the contracts, and create a traditional funder and adjudicator to register them on the multi-ledger variants.

	// The multi-funder and multi-adjudicator will be registered with a funder /
	// adjudicators for each chain.
	multiFunder := multi.NewFunder()
	multiAdjudicator := multi.NewAdjudicator()

	var assets [2]channel.Asset
	for i, chain := range chains {
		assets[i] = ethchannel.NewAsset(chain.ChainID.Int, chain.AssetHolder)
	}

	for i, chain := range chains {
		// Create Ethereum client and contract backend.
		cb, err := CreateContractBackend(chain.ChainURL, chain.ChainID.Int, w)
		if err != nil {
			return nil, fmt.Errorf("creating contract backend: %w", err)
		}

		// Validate contracts.
		err = ethchannel.ValidateAdjudicator(context.TODO(), cb, chain.Adjudicator)
		if err != nil {
			return nil, fmt.Errorf("validating adjudicator: %w", err)
		}
		err = ethchannel.ValidateAssetHolderERC20(context.TODO(), cb, chain.AssetHolder, chain.Adjudicator, chain.Token)
		if err != nil {
			return nil, fmt.Errorf("validating adjudicator: %w", err)
		}

		// Setup funder.
		funder := ethchannel.NewFunder(cb)
		// Register the asset on the funder.
		dep := ethchannel.NewERC20Depositor(chain.Token)
		ethAcc := accounts.Account{Address: acc}
		funder.RegisterAsset(*assets[i].(*ethchannel.Asset), dep, ethAcc)
		// We have to register the asset of the other chain too, but use a
		// NoOpDepositor there since this funder can ignore it.
		funder.RegisterAsset(*assets[1-i].(*ethchannel.Asset), ethchannel.NewNoOpDepositor(), ethAcc)
		// Register the funder on the multi-funder.
		multiFunder.RegisterFunder(chain.ChainID, funder)

		// Setup adjudicator.
		adj := ethchannel.NewAdjudicator(cb, chain.Adjudicator, acc, ethAcc)
		// Register the adjudicator on the multi-adjudicator.
		multiAdjudicator.RegisterAdjudicator(chain.ChainID, adj)
	}

After that, we only need to set up a watcher for our client who is watching for events on our multi-ledger adjudicator, and we have all components ready to create the Perun client by using client.New.

	// Setup dispute watcher.
	watcher, err := local.NewWatcher(multiAdjudicator)
	if err != nil {
		return nil, fmt.Errorf("intializing watcher: %w", err)
	}

	// Setup Perun client.
	walletAddr := ethwallet.AsWalletAddr(acc)
	wireAddr := &ethwire.Address{Address: walletAddr}
	perunClient, err := client.New(wireAddr, bus, multiFunder, multiAdjudicator, w, watcher)
	if err != nil {
		return nil, errors.WithMessage(err, "creating client")
	}

Finally, we can create our Payment client which gets the Perun client, and then start the handler of the Perun client to handle channel and update proposals.

	// Create client and start request handler.
	c := &SwapClient{
		perunClient: perunClient,
		account:     walletAddr,
		currencies:  assets,
		channels:    make(chan *SwapChannel, 1),
	}
	go perunClient.Handle(c, c)

Channel opening

The channel opening is again very similar to the payment channel tutorial, the only difference is visible when setting the allocations of the channel proposal. There, we will use the type channel.Balances which is a two-dimensional array for the assets and the participants. For example, balances[0] gives us the balance array for the first asset and balances[0][1] gives us the balance of the first asset of the second participant.

// OpenChannel opens a new channel with the specified peer and funding.
func (c *SwapClient) OpenChannel(peer wire.Address, balances channel.Balances) *SwapChannel {
	// We define the channel participants. The proposer has always index 0. Here
	// we use the on-chain addresses as off-chain addresses, but we could also
	// use different ones.
	wireAddr := &ethwire.Address{Address: c.account.(*ethwallet.Address)}
	participants := []wire.Address{wireAddr, peer}

	// We create an initial allocation which defines the starting balances.
	initAlloc := channel.NewAllocation(2, c.currencies[0], c.currencies[1])
	initAlloc.Balances = balances

When looking at the main.go, we can see how we would initialize this balance array. Alice inputs 20 PRN (PRN = PerunToken, our ERC20 example token) of chain A and 0 PRN of chain B, and Bob inputs 0 PRN of chain A and 50 PRN of chain B.

	var alicePRNChainA int64 = 20 // Alice puts 20 PRN from chain A in the channel.
	var bobPRNChainB int64 = 50   // Bob puts 50 PRN from chain B in the channel.
	// The balances that each party puts in the channel.
	balances := channel.Balances{
		{big.NewInt(alicePRNChainA), big.NewInt(0)},
		{big.NewInt(0), big.NewInt(bobPRNChainB)},
	}

	chAlice := alice.OpenChannel(bob.WireAddress(), balances)

Going back to the OpenChannel method itself, we can take a look at how we set the allocation for the proposal. First, we create a new allocation with NewAllocation which takes the number of participants and the assets of the channel as an input. Then, we can set the given balances to the allocation.

	// We create an initial allocation which defines the starting balances.
	initAlloc := channel.NewAllocation(2, c.currencies[0], c.currencies[1])
	initAlloc.Balances = balances

After Alice has sent the channel proposal, Bob will automatically accept the proposal after checking its correctness (see handle proposals for more information).

Channel Update

Using the established channel, Alice and Bob can now perform off-chain updates, meaning they can sent each other nearly instant fee-less payments with the two ERC20 tokens that live on different chains. In our case, we want to perform a swap which will essentially move all tokens of chain A from Alice to Bob and move all tokens from chain B from Bob to Alice. Also, it will make the channel state “final”, meaning that there cannot follow any other update after this one, which allows Alice and Bob to simply settle the channel on-chain afterwards.

// PerformSwap performs a swap by "swapping" the balances of the two
// participants for both assets.
func (c SwapChannel) PerformSwap() {
	err := c.ch.Update(context.TODO(), func(state *channel.State) { // We use context.TODO to keep the code simple.
		// We simply swap the balances for the two assets.
		state.Balances = channel.Balances{
			{state.Balances[0][1], state.Balances[0][0]},
			{state.Balances[1][1], state.Balances[1][0]},
		}

		// Set the state to final because we do not expect any other updates
		// than this swap.
		state.IsFinal = true
	})
	if err != nil {
		panic(err) // We panic on error to keep the code simple.
	}
}

Channel Settling

After Alice and Bob have sent their payments, we want to close the channel such that they receive the channel outcome of the ERC20 tokens on both chains; here Alice gets 50 tokens of chain B and Bob gets 20 tokens of chain A. To close the channel, they call the method Settle which will finalize the channel state, register it on-chain, and finally withdraw the funds.

// Settle settles the channel and withdraws the funds.
func (c SwapChannel) Settle() {

🎉 And that’s it for this tutorial, now you know how you can use go-perun for multi-ledger channels!