Client

The client object provides methods for opening new channels and transacting on them. Its implementation will be placed in package client.

package client

Constructor

The main part of our payment channel client is placed in client/client.go. Our client is of type PaymentClient and holds the fields described below.

// PaymentClient is a payment channel client.
type PaymentClient 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.
	channels    chan *PaymentChannel // Accepted payment channels.
}

Note

Go-perun allows using different accounts for on-chain and off-chain transactions and also supports multi-asset channels. However, we will stick to one account and one asset for simplicity.

We first create the constructor for our PaymentClient, which takes a number of parameters as described below.

// SetupPaymentClient creates a new payment client.
func SetupPaymentClient(
	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.
	nodeURL string, // nodeURL is the URL of the blockchain node.
	chainID uint64, // chainID is the identifier of the blockchain.
	adjudicator common.Address, // adjudicator is the address of the adjudicator.
	asset ethwallet.Address, // asset is the address of the asset holder for our payment channels.
) (*PaymentClient, error) {

Before we can create the payment channel client, we need to create a Perun client, which requires setting up the following components first.

Contract backend. The contract backend is used for sending on-chain transactions to interact with the smart contracts. We create a new contract backend cb by using the function CreateContractBackend, which we explain in the utility section.

	// Create Ethereum client and contract backend.
	cb, err := CreateContractBackend(nodeURL, chainID, w)
	if err != nil {
		return nil, fmt.Errorf("creating contract backend: %w", err)
	}

Contract validation. Go-perun features two types of smart contracts: the Adjudicator and the Asset Holder. The Adjudicator is the central contract for dispute resolution. An Asset Holder handles asset deposits and payouts for a certain type of asset. To ensure that the provided addresses point to valid smart contracts, we validate the contract code at these addresses by using ValidateAdjudicator and ValidateAssetHolderETH.

	// Validate contracts.
	err = ethchannel.ValidateAdjudicator(context.TODO(), cb, adjudicator)
	if err != nil {
		return nil, fmt.Errorf("validating adjudicator: %w", err)
	}
	err = ethchannel.ValidateAssetHolderETH(context.TODO(), cb, common.Address(asset), adjudicator)
	if err != nil {
		return nil, fmt.Errorf("validating adjudicator: %w", err)
	}

Important

The validity of the smart contracts is crucial as a manipulated or broken contract puts the client’s funds at risk.

Note

We use ValidateAssetHolderETH because we use ETH as the payment channel’s currency. For validating an ERC20 asset holer, we would use ValidateAssetHolderERC20. Note that asset holder validation also requires an Adjudicator address as input. This is because an Asset Holder is always associated with an Adjudicator, and this relation is checked during contract validation.

Funder. Next, we create the Funder component, which will be used by the client to deposit assets into a channel. We first create a new funder funder using method NewFunder. We then create a depositor dep for the asset type ETH by calling NewETHDepositor. We then use funder.RegisterAsset to tell the funder how assets of that type are funded and which account should be used for sending funding transactions.

	// Setup funder.
	funder := ethchannel.NewFunder(cb)
	dep := ethchannel.NewETHDepositor()
	ethAcc := accounts.Account{Address: acc}
	funder.RegisterAsset(asset, dep, ethAcc)

Note

Different types of assets require different funded transactions and therefore require different depositor types. For example, an ERC20 token is funded using a depositor of type ERC20Depositor.

Adjudicator. Next, we use go-perun’s NewAdjudicator method to create a local Adjudicator instance, adj, which will be used by the client to interact with the Adjudicator smart contract. Here, acc denotes the address of the account that will receive the payout when a channel is closed, which can also later be changed using the Receiver property. The parameter ethAcc defines the account that is used for sending on-chain transactions.

	// Setup adjudicator.
	adj := ethchannel.NewAdjudicator(cb, adjudicator, acc, ethAcc)

Watcher. The responsibility of the watcher component is to watch the Adjudicator smart contract for channel disputes and react accordingly. We create a new watcher of type local.Watcher by calling local.NewWatcher with the adjudicator instance adj as input.

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

Note

The watcher used here is a local.Watcher and runs only as long as the client is online. However, you can also implement a remote watcher that runs independently and can handle disputes even when clients are offline.

Perun client. Now we have all components ready to create a Perun client. We create a new Perun client by calling client.New, where we provide the client’s network identity waddr and the previously constructed components as input.

	// Setup Perun client.
	waddr := ethwallet.AsWalletAddr(acc)
	perunClient, err := client.New(waddr, bus, funder, adj, w, watcher)
	if err != nil {
		return nil, errors.WithMessage(err, "creating client")
	}

Payment client. Finally, we construct a payment client c that holds the Perun client, the account, and the asset. We then start the message handler of the Perun client and set our payment client c as the channel proposal and update handler by calling perunClient.Handle with c as input. To handle requests, the payment client implements the methods HandleProposal and HandleUpdate in client/handle.go.

	c := &PaymentClient{
		perunClient: perunClient,
		account:     waddr,
		currency:    &asset,
		channels:    make(chan *PaymentChannel, 1),
	}
	go perunClient.Handle(c, c)

Open channel

The method OpenChannel allows a client to propose a new payment channel to another client. It gets as input the client’s network address peer and an amount, which defines the proposer’s starting balance in the channel. We do not expect the receiver to put funds into the channel in our case.

// OpenChannel opens a new channel with the specified peer and funding.
func (c *PaymentClient) OpenChannel(peer wire.Address, amount float64) *PaymentChannel {

Channel participants. The channel participants are defined as a list of wire addresses. The proposer must always go first.

	// 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.
	participants := []wire.Address{c.account, peer}

Initial balance. The initial balance allocation is constructed by using go-perun’s channel.NewAllocation, which returns a new allocation initAlloc for the given number of participants and asset type. With initAlloc.SetAssetBalances we set the actual initial balances. Note that the proposer always has index 0 and the receiver index 1.

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

Challenge duration. The challenge duration determines the duration after which a funding or dispute timeout occurs. If the channel is not funded within the funding duration, channel opening will fail. If a channel dispute is raised, channel participants can only respond within the dispute duration.

	challengeDuration := uint64(10) // On-chain challenge duration in seconds.

Attention

Note that in a real-world application, one would typically set a much higher dispute duration in order to give all clients an appropriate chance to respond to disputes. Depending on the application context, the dispute duration may be several hours or even days.

Channel proposal message. Next, we prepare the channel proposal message. We use go-perun’s client.NewLedgerChannelProposal to create the proposal message by giving the challenge duration, the client’s wallet address, the initial balances, and the channel participants as input.

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

Propose channel. The proposal is then sent by calling perunClient.ProposeChannel, which runs the channel opening protocol and returns a new Perun channel ch on success.

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

Start watcher. We instruct the watcher to start watching for disputes concerning ch by calling c.startWatching, which we describe in the utility section.

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

Create payment channel. Finally, we construct the payment channel from the Perun channel and the currency. We describe the implementation of type PaymentChannel in the channel section.

	return newPaymentChannel(ch, c.currency)
}

Handle proposal

The client will receive incoming channel proposals via HandleProposal.

// HandleProposal is the callback for incoming channel proposals.
func (c *PaymentClient) HandleProposal(p client.ChannelProposal, r *client.ProposalResponder) {

Check proposal. Before a channel proposal is accepted, it is essential to check its parameters. If any of the checks fail, we reject the proposal by using r.Reject. You can add additional checks to the logic, but the checks below are sufficient for our simple use case.

	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)
		}

		// 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 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(big.NewInt(0)) != 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.
	}

Accept proposal. To accept the channel proposal, we follow two steps: First, we create the accept message, including the client’s address and a random nonce. This is done by simply calling lcp.Accept. Then we send the accept message via r.Accept.

	// 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
	}

If this is successful, we call startWatching for automatic dispute handling. Finally, we create a payment channel and push it on to c.channels.

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

	// Store channel.
	c.channels <- newPaymentChannel(ch, c.currency)
}

Fetch accepted channel. We define AcceptedChannel for fetching channels that the channel proposal handler accepted asynchronously.

// AcceptedChannel returns the next accepted channel.
func (c *PaymentClient) AcceptedChannel() *PaymentChannel {
	return <-c.channels
}

Payment channel

We implement the type PaymentChannel that wraps a Perun channel and provides convenience functions for interacting with a payment channel. We put this functionality in client/channel.go.

// PaymentChannel is a wrapper for a Perun channel for the payment use case.
type PaymentChannel struct {
	ch       *client.Channel
	currency channel.Asset
}

// newPaymentChannel creates a new payment channel.
func newPaymentChannel(ch *client.Channel, currency channel.Asset) *PaymentChannel {
	return &PaymentChannel{
		ch:       ch,
		currency: currency,
	}
}

Send Payment

Sending a payment is a proposal to change the balances of a channel so that the sender’s balance is reduced and the receiver’s balance is increased. By doing the opposite, we can realize payment requests. We create SendPayment to implement our basic payment logic here, which expects as input the amount that is to be transferred to the peer. We use go-perun’s Channel.UpdateBy for proposing the desired channel state update. We use go-perun’s TransferBalance function to automatically subtract the given amount from the proposer’s balance and add it to the receiver’s balance.

// SendPayment sends a payment to the channel peer.
func (c PaymentChannel) SendPayment(amount float64) {
	// Transfer the given amount from us to peer.
	// Use UpdateBy to update the channel state.
	err := c.ch.UpdateBy(context.TODO(), func(state *channel.State) error { // We use context.TODO to keep the code simple.
		ethAmount := EthToWei(big.NewFloat(amount))
		actor := c.ch.Idx()
		peer := 1 - actor
		state.Allocation.TransferBalance(actor, peer, c.currency, ethAmount)
		return nil
	})
	if err != nil {
		panic(err) // We panic on error to keep the code simple.
	}
}

Note

Note that any update must maintain the overall sum of funds inside the channel. Otherwise, the update is blocked.

Handle Update

The client receives channel update proposals via the callback method HandleUpdate. The method gets as input the current channel state, the proposed update, and a responder object for either accepting or rejecting the update.

// HandleUpdate is the callback for incoming channel updates.
func (c *PaymentClient) HandleUpdate(cur *channel.State, next client.ChannelUpdate, r *client.UpdateResponder) {

We first check if the proposed update satisfies our payment channel conditions, i.e., that it increases our balance. If this is not the case, we reject using r.Reject.

	// We accept every update that increases our balance.
	err := func() error {
		err := channel.AssetsAssertEqual(cur.Assets, next.State.Assets)
		if err != nil {
			return fmt.Errorf("Invalid assets: %v", err)
		}

		receiverIdx := 1 - next.ActorIdx // This works because we are in a two-party channel.
		curBal := cur.Allocation.Balance(receiverIdx, c.currency)
		nextBal := next.State.Allocation.Balance(receiverIdx, c.currency)
		if nextBal.Cmp(curBal) < 0 {
			return fmt.Errorf("Invalid balance: %v", nextBal)
		}
		return nil
	}()
	if err != nil {
		r.Reject(context.TODO(), err.Error()) //nolint:errcheck // It's OK if rejection fails.
	}

If all checks above pass, we accept the update by calling r.Accept.

	// Send the acceptance message.
	err = r.Accept(context.TODO())
	if err != nil {
		panic(err)
	}
}

Settle Channel

Settling a channel means concluding the channel state and withdrawing our final balance. These steps are realized by go-perun’s Channel.Settle. To enable fast and cheap settlement, we try to finalize the channel via an off-chain update first. We create a method Settle, that first tries to finalize the channel off-chain using Channel.UpdateBy and then closes the channel on-chain using go-perun’s Channel.Settle.

// Settle settles the payment channel and withdraws the funds.
func (c PaymentChannel) Settle() {
	// Finalize the channel to enable fast settlement.
	if !c.ch.State().IsFinal {
		err := c.ch.UpdateBy(context.TODO(), func(state *channel.State) error {
			state.IsFinal = true
			return nil
		})
		if err != nil {
			panic(err)
		}
	}

	// Settle concludes the channel and withdraws the funds.
	err := c.ch.Settle(context.TODO(), false)
	if err != nil {
		panic(err)
	}

	// Close frees up channel resources.
	c.ch.Close()
}

Utilities

In this section, we implement several utility functions that we were already using above and will be using in the following. We put the following code in client/client.go and client/util.go.

Start watching

The watcher is responsible for detecting the registration of an old state and refuting it with the most current state available. To start the dispute watcher for a given channel ch, we call ch.Watch, which expects as input an on-chain event handler implementing HandleAdjudicatorEvent to which events will be forwarded.

// startWatching starts the dispute watcher for the specified channel.
func (c *PaymentClient) startWatching(ch *client.Channel) {
	go func() {
		err := ch.Watch(c)
		if err != nil {
			fmt.Printf("Watcher returned with error: %v", err)
		}
	}()
}

In our case, the client will handle the on-chain events and print them to the standard output.

// HandleAdjudicatorEvent is the callback for smart contract events.
func (c *PaymentClient) HandleAdjudicatorEvent(e channel.AdjudicatorEvent) {
	log.Printf("Adjudicator event: type = %T, client = %v", e, c.account)
}

Client shutdown

To allow the client to shut down in a managed way, we define Shutdown, which gives go-perun the opportunity to free-up resources.

// Shutdown gracefully shuts down the client.
func (c *PaymentClient) Shutdown() {
	c.perunClient.Close()
}

Contract backend

The contract backend allows the client to interact with smart contracts on the blockchain. We put this code in client/util.go

Our constructor of the contract backend requires three parameters. nodeURL and chainID specify the blockchain to be used, and w is the wallet that will be used for signing transactions.

// CreateContractBackend creates a new contract backend.
func CreateContractBackend(
	nodeURL string,
	chainID uint64,
	w *swallet.Wallet,
) (ethchannel.ContractBackend, error) {

Using the chainID, we start by creating an EIP155Signer provided by go-ethereum. We can now create a channel.Transactor by calling swallet.NewTransactor with inputting the wallet and the signer. The transactor will be used for signing on-chain transactions.

	signer := types.NewEIP155Signer(new(big.Int).SetUint64(chainID))
	transactor := swallet.NewTransactor(w, signer)

The contract backend uses a go-ethereum client ethclient.Client for communicating with the blockchain, which we create using ethclient.Dial with input nodeURL. We then call NewContractBackend with the ethClient, transactor, and txFinalityDepth as input. The value txFinality determines how many blocks are required to confirm a transaction and is set to 1 by default.

	ethClient, err := ethclient.Dial(nodeURL)
	if err != nil {
		return ethchannel.ContractBackend{}, err
	}

	return ethchannel.NewContractBackend(ethClient, transactor, txFinalityDepth), nil
}

Conversion between Ether and Wei

Wei is the smallest unit of Ether (1 Ether = 10^18 Wei). Transaction amounts are usually defined in Wei to maintain high precision. However, for better usability, we provide functions that take an amount in Ether. To accommodate for this, we implement EthToWei and WeiToEth to convert between these different denominations.

// EthToWei converts a given amount in ETH to Wei.
func EthToWei(ethAmount *big.Float) (weiAmount *big.Int) {
	weiPerEth := new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil)
	weiPerEthFloat := new(big.Float).SetInt(weiPerEth)
	weiAmountFloat := new(big.Float).Mul(ethAmount, weiPerEthFloat)
	weiAmount, _ = weiAmountFloat.Int(nil)
	return weiAmount
}

// WeiToEth converts a given amount in Wei to ETH.
func WeiToEth(weiAmount *big.Int) (ethAmount *big.Float) {
	weiPerEth := new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil)
	weiPerEthFloat := new(big.Float).SetInt(weiPerEth)
	weiAmountFloat := new(big.Float).SetInt(weiAmount)
	return new(big.Float).Quo(weiAmountFloat, weiPerEthFloat)
}