Opening a state channel works by defining the initial asset allocation, setting the channel parameters, creating a proposal and sending that proposal to all participants. The channel is open after all participants accept the proposal and finish the on-chain funding.

It looks like this:

func (n *node) openChannel() error {
	fmt.Printf("Opening channel from %v to %v\n", n.role, 1-n.role)
	// Alice and Bob will both start with 10 ETH.
	initBal := ethToWei(10)
	// Perun needs an initial allocation which defines the balances of all
	// participants. The same structure is used for multi-asset channels.
	initBals := &channel.Allocation{
		Assets:   []channel.Asset{ethwallet.AsWalletAddr(n.assetholder)},
		Balances: [][]*big.Int{{initBal, initBal}},
	// All perun identities that we want to open a channel with. In this case
	// we use the same on- and off-chain accounts but you could use different.
	peers := []wire.Address{
	// Prepare the proposal by defining the channel parameters.
	proposal, err := client.NewLedgerChannelProposal(10, cfg.addrs[n.role], initBals, peers)
	if err != nil {
		return fmt.Errorf("creating channel proposal: %w", err)
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	// Send the proposal.
	channel, err := n.client.ProposeChannel(ctx, proposal)
	if err != nil {
		return fmt.Errorf("proposing channel: %w", err)
	fmt.Printf("🎉 Opened channel with id 0x%x \n", channel.ID())
	return nil

The ProposeChannel call blocks until Alice either accepted or rejected the channel and funded it.


The channel that is returned by ProposeChannel should only be used to retrieve its id.


An example Proposal handler looks like this:

func (n *node) HandleProposal(_proposal client.ChannelProposal, responder *client.ProposalResponder) {
	// Check that we got a ledger channel proposal.
	proposal, ok := _proposal.(*client.LedgerChannelProposal)
	if !ok {
		fmt.Println("Received a proposal that was not for a ledger channel.")
	// Print the proposers address (his index is always 0).
	fmt.Printf("Received channel proposal from 0x%x\n", proposal.Peers[0])
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()
	// Create a channel accept message and send it.
	accept := proposal.Accept(n.account.Address(), client.WithRandomNonce())
	channel, err := responder.Accept(ctx, accept)
	if err != nil {
		fmt.Println("Accepting channel: %w\n", err)
	} else {
		fmt.Printf("Accepted channel with id 0x%x\n", channel.ID())

You can add additional check logic here but in our simple use case we always accept incoming proposals. After the channel is open, both participants will have their NewChannel callback called.


The Channel that ProposalResponder.Accept returns should only be used to retrieve its ID.


go-perun expects this handler to finish quickly. Use go routines if you want to do time-intensive tasks. You should also start the watcher as shown below:

func (n *node) HandleNewChannel(ch *client.Channel) {
	fmt.Printf("%v HandleNewChannel with id 0x%x\n", n.role, ch.ID()) = ch
	// Start the on-chain watcher.
	go func() {
		err := ch.Watch(n)
		fmt.Println("Watcher returned with: ", err)


Starting the watcher is not mandatory but strongly advised. go-perun can otherwise not react to malicious behavior of other participants.