Merge pull request #1 from Kava-Labs/add-payment-channels

Add payment channels
This commit is contained in:
Ruaridh 2018-09-03 16:41:01 -04:00 committed by GitHub
commit 8c9406cd35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1636 additions and 8 deletions

View File

@ -17,6 +17,7 @@ import (
//ibccmd "github.com/cosmos/cosmos-sdk/x/ibc/client/cli" //ibccmd "github.com/cosmos/cosmos-sdk/x/ibc/client/cli"
slashingcmd "github.com/cosmos/cosmos-sdk/x/slashing/client/cli" slashingcmd "github.com/cosmos/cosmos-sdk/x/slashing/client/cli"
stakecmd "github.com/cosmos/cosmos-sdk/x/stake/client/cli" stakecmd "github.com/cosmos/cosmos-sdk/x/stake/client/cli"
paychancmd "github.com/kava-labs/kava/internal/x/paychan/client/cmd"
"github.com/kava-labs/kava/internal/app" "github.com/kava-labs/kava/internal/app"
//"github.com/kava-labs/kava/internal/lcd" //"github.com/kava-labs/kava/internal/lcd"
@ -101,7 +102,7 @@ func main() {
stakeCmd, stakeCmd,
) )
//Add stake commands // Add gov commands
// govCmd := &cobra.Command{ // govCmd := &cobra.Command{
// Use: "gov", // Use: "gov",
// Short: "Governance and voting subcommands", // Short: "Governance and voting subcommands",
@ -128,10 +129,27 @@ func main() {
authcmd.GetAccountCmd("acc", cdc, authcmd.GetAccountDecoder(cdc)), authcmd.GetAccountCmd("acc", cdc, authcmd.GetAccountDecoder(cdc)),
)...) )...)
rootCmd.AddCommand( rootCmd.AddCommand(
client.PostCommands( client.PostCommands( // this just wraps the input cmds with common flags
bankcmd.SendTxCmd(cdc), bankcmd.SendTxCmd(cdc),
)...) )...)
// Add paychan commands
paychanCmd := &cobra.Command{
Use: "paychan",
Short: "Payment channel subcommand",
}
paychanCmd.AddCommand(
client.PostCommands(
paychancmd.CreateChannelCmd(cdc),
paychancmd.GetChannelCmd(cdc, "paychan"), // pass in storeKey
paychancmd.GeneratePaymentCmd(cdc),
paychancmd.VerifyPaymentCmd(cdc, "paychan"), // pass in storeKey
paychancmd.SubmitPaymentCmd(cdc),
)...)
rootCmd.AddCommand(
paychanCmd,
)
// add proxy, version and key info // add proxy, version and key info
rootCmd.AddCommand( rootCmd.AddCommand(
keys.Commands(), keys.Commands(),

View File

@ -20,6 +20,7 @@ import (
//"github.com/cosmos/cosmos-sdk/x/ibc" //"github.com/cosmos/cosmos-sdk/x/ibc"
"github.com/cosmos/cosmos-sdk/x/slashing" "github.com/cosmos/cosmos-sdk/x/slashing"
"github.com/cosmos/cosmos-sdk/x/stake" "github.com/cosmos/cosmos-sdk/x/stake"
"github.com/kava-labs/kava/internal/x/paychan"
) )
const ( const (
@ -39,16 +40,19 @@ type KavaApp struct {
// keys to access the substores // keys to access the substores
keyMain *sdk.KVStoreKey keyMain *sdk.KVStoreKey
keyAccount *sdk.KVStoreKey keyAccount *sdk.KVStoreKey
//keyIBC *sdk.KVStoreKey //keyIBC *sdk.KVStoreKey
keyStake *sdk.KVStoreKey keyStake *sdk.KVStoreKey
keySlashing *sdk.KVStoreKey keySlashing *sdk.KVStoreKey
//keyGov *sdk.KVStoreKey //keyGov *sdk.KVStoreKey
keyFeeCollection *sdk.KVStoreKey keyFeeCollection *sdk.KVStoreKey
keyPaychan *sdk.KVStoreKey
// keepers // keepers
accountMapper auth.AccountMapper accountMapper auth.AccountMapper
feeCollectionKeeper auth.FeeCollectionKeeper feeCollectionKeeper auth.FeeCollectionKeeper
coinKeeper bank.Keeper coinKeeper bank.Keeper
paychanKeeper paychan.Keeper
//ibcMapper ibc.Mapper //ibcMapper ibc.Mapper
stakeKeeper stake.Keeper stakeKeeper stake.Keeper
slashingKeeper slashing.Keeper slashingKeeper slashing.Keeper
@ -71,6 +75,7 @@ func NewKavaApp(logger log.Logger, db dbm.DB, traceStore io.Writer, baseAppOptio
cdc: cdc, cdc: cdc,
keyMain: sdk.NewKVStoreKey("main"), keyMain: sdk.NewKVStoreKey("main"),
keyAccount: sdk.NewKVStoreKey("acc"), keyAccount: sdk.NewKVStoreKey("acc"),
keyPaychan: sdk.NewKVStoreKey("paychan"),
//keyIBC: sdk.NewKVStoreKey("ibc"), //keyIBC: sdk.NewKVStoreKey("ibc"),
keyStake: sdk.NewKVStoreKey("stake"), keyStake: sdk.NewKVStoreKey("stake"),
keySlashing: sdk.NewKVStoreKey("slashing"), keySlashing: sdk.NewKVStoreKey("slashing"),
@ -87,6 +92,7 @@ func NewKavaApp(logger log.Logger, db dbm.DB, traceStore io.Writer, baseAppOptio
// Create the keepers // Create the keepers
app.coinKeeper = bank.NewKeeper(app.accountMapper) app.coinKeeper = bank.NewKeeper(app.accountMapper)
app.paychanKeeper = paychan.NewKeeper(app.cdc, app.keyPaychan, app.coinKeeper)
//app.ibcMapper = ibc.NewMapper(app.cdc, app.keyIBC, app.RegisterCodespace(ibc.DefaultCodespace)) //app.ibcMapper = ibc.NewMapper(app.cdc, app.keyIBC, app.RegisterCodespace(ibc.DefaultCodespace))
app.stakeKeeper = stake.NewKeeper(app.cdc, app.keyStake, app.coinKeeper, app.RegisterCodespace(stake.DefaultCodespace)) app.stakeKeeper = stake.NewKeeper(app.cdc, app.keyStake, app.coinKeeper, app.RegisterCodespace(stake.DefaultCodespace))
app.slashingKeeper = slashing.NewKeeper(app.cdc, app.keySlashing, app.stakeKeeper, app.RegisterCodespace(slashing.DefaultCodespace)) app.slashingKeeper = slashing.NewKeeper(app.cdc, app.keySlashing, app.stakeKeeper, app.RegisterCodespace(slashing.DefaultCodespace))
@ -98,7 +104,8 @@ func NewKavaApp(logger log.Logger, db dbm.DB, traceStore io.Writer, baseAppOptio
AddRoute("bank", bank.NewHandler(app.coinKeeper)). AddRoute("bank", bank.NewHandler(app.coinKeeper)).
//AddRoute("ibc", ibc.NewHandler(app.ibcMapper, app.coinKeeper)). //AddRoute("ibc", ibc.NewHandler(app.ibcMapper, app.coinKeeper)).
AddRoute("stake", stake.NewHandler(app.stakeKeeper)). AddRoute("stake", stake.NewHandler(app.stakeKeeper)).
AddRoute("slashing", slashing.NewHandler(app.slashingKeeper)) AddRoute("slashing", slashing.NewHandler(app.slashingKeeper)).
AddRoute("paychan", paychan.NewHandler(app.paychanKeeper))
//AddRoute("gov", gov.NewHandler(app.govKeeper)) //AddRoute("gov", gov.NewHandler(app.govKeeper))
// Set func to initialze the chain from appState in genesis file // Set func to initialze the chain from appState in genesis file
@ -110,7 +117,7 @@ func NewKavaApp(logger log.Logger, db dbm.DB, traceStore io.Writer, baseAppOptio
app.SetAnteHandler(auth.NewAnteHandler(app.accountMapper, app.feeCollectionKeeper)) app.SetAnteHandler(auth.NewAnteHandler(app.accountMapper, app.feeCollectionKeeper))
// Mount stores // Mount stores
app.MountStoresIAVL(app.keyMain, app.keyAccount, app.keyStake, app.keySlashing, app.keyFeeCollection) app.MountStoresIAVL(app.keyMain, app.keyAccount, app.keyStake, app.keySlashing, app.keyFeeCollection, app.keyPaychan)
err := app.LoadLatestVersion(app.keyMain) err := app.LoadLatestVersion(app.keyMain)
if err != nil { if err != nil {
cmn.Exit(err.Error()) cmn.Exit(err.Error())
@ -121,6 +128,7 @@ func NewKavaApp(logger log.Logger, db dbm.DB, traceStore io.Writer, baseAppOptio
// Creates a codec for use across the whole app. // Creates a codec for use across the whole app.
func CreateKavaAppCodec() *wire.Codec { func CreateKavaAppCodec() *wire.Codec {
cdc := wire.NewCodec() cdc := wire.NewCodec()
paychan.RegisterWire(cdc)
//ibc.RegisterWire(cdc) //ibc.RegisterWire(cdc)
bank.RegisterWire(cdc) bank.RegisterWire(cdc)
stake.RegisterWire(cdc) stake.RegisterWire(cdc)
@ -143,6 +151,7 @@ func (app *KavaApp) BeginBlocker(ctx sdk.Context, req abci.RequestBeginBlock) ab
// The function baseapp runs on receipt of a EndBlock ABCI message // The function baseapp runs on receipt of a EndBlock ABCI message
func (app *KavaApp) EndBlocker(ctx sdk.Context, req abci.RequestEndBlock) abci.ResponseEndBlock { func (app *KavaApp) EndBlocker(ctx sdk.Context, req abci.RequestEndBlock) abci.ResponseEndBlock {
paychan.EndBlocker(ctx, app.paychanKeeper)
validatorUpdates := stake.EndBlocker(ctx, app.stakeKeeper) validatorUpdates := stake.EndBlocker(ctx, app.stakeKeeper)
//tags, _ := gov.EndBlocker(ctx, app.govKeeper) //tags, _ := gov.EndBlocker(ctx, app.govKeeper)

View File

@ -1,5 +1,6 @@
package app package app
/*
import ( import (
"os" "os"
"testing" "testing"
@ -75,3 +76,4 @@ func TestGenesis(t *testing.T) {
res1 = bapp.accountMapper.GetAccount(ctx, baseAcc.Address) res1 = bapp.accountMapper.GetAccount(ctx, baseAcc.Address)
assert.Equal(t, acc, res1) assert.Equal(t, acc, res1)
} }
*/

View File

@ -0,0 +1,57 @@
# Unidrectional Payment Channels
This module implements simple but feature complete unidirectional payment channels. Channels can be opened by a sender and closed immediately by the receiver, or by the sender subject to a dispute period. There are no top-ups or partial withdrawals (yet). Channels support multiple currencies.
>Note: This module is still a bit rough around the edges. More feature planned. More test cases needed.
# Usage
## Create a channel
kvcli paychan create --from <your account name> --to <receivers address> --amount 100KVA --chain-id <your chain ID>
## Send an off-chain payment
Send a payment for 10 KVA.
kvcli paychan pay --from <your account name> --sen-amt 90KVA --rec-amt 10KVA --chan-id <ID of channel> --filename payment.json --chain-id <your chain ID>
Send the file `payment.json` to your receiver. Then they run the following to verify.
kvcli paychan verify --filename payment.json
## Close a channel
The receiver can close immediately at any time.
kvcli paychan submit --from <receiver's account name> --payment payment.json --chain-id <your chain ID>
The sender can submit a close request, causing the channel will close automatically after a dispute period. During this period a receiver can still close immediately.
kvcli paychan submit --from <receiver's account name> --payment payment.json --chain-id <your chain ID>
>Note: The dispute period on the testnet is 30 seconds for ease of testing.
## Get info on a channel
kvcli get --chan-id <ID of channel>
This will print out a channel, if it exists, and any submitted close requests.
# TODOs
- in code TODOs
- Tidy up - method descriptions, heading comments, remove uneccessary comments, README/docs
- Find a better name for Queue - clarify distinction between int slice and abstract queue concept
- write some sort of integration test
- possible bug in submitting same update repeatedly
- find nicer name for payout
- add Gas usage
- add tags (return channel id on creation)
- refactor cmds to be able to test them, then test them
- verify doesnt throw json parsing error on invalid json
- cant submit an update from an unitialised account
- pay without a --from returns confusing error
- use custom errors instead of using sdk.ErrInternal
- split off signatures from update as with txs/msgs - testing easier, code easier to use, doesn't store sigs unecessarily on chain
- consider removing pubKey from UpdateSignature - instead let channel module access accountMapper
- refactor queue into one object
- remove printout during tests caused by mock app initialisation

View File

@ -0,0 +1,316 @@
package cli
import (
"fmt"
"io/ioutil"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/cosmos/cosmos-sdk/client/context"
"github.com/cosmos/cosmos-sdk/client/keys"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/wire"
authcmd "github.com/cosmos/cosmos-sdk/x/auth/client/cli"
"github.com/kava-labs/kava/internal/x/paychan"
)
// list of functions that return pointers to cobra commands
// No local storage needed for cli acting as a sender
func CreateChannelCmd(cdc *wire.Codec) *cobra.Command {
flagTo := "to"
flagCoins := "amount"
cmd := &cobra.Command{
Use: "create",
Short: "Create a new payment channel",
Long: "Create a new unidirectional payment channel from a local address to a remote address, funded with some amount of coins. These coins are removed from the sender account and put into the channel.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// Create a "client context" stuct populated with info from common flags
ctx := context.NewCoreContextFromViper().WithDecoder(authcmd.GetAccountDecoder(cdc))
// TODO is this needed for channelID
// ctx.PrintResponse = true
// Get sender adress
sender, err := ctx.GetFromAddress()
if err != nil {
return err
}
// Get receiver address
toStr := viper.GetString(flagTo)
receiver, err := sdk.AccAddressFromBech32(toStr)
if err != nil {
return err
}
// Get channel funding amount
coinsString := viper.GetString(flagCoins)
coins, err := sdk.ParseCoins(coinsString)
if err != nil {
return err
}
// Create the create channel msg to send
msg := paychan.MsgCreate{
Participants: [2]sdk.AccAddress{sender, receiver},
Coins: coins,
}
err = msg.ValidateBasic()
if err != nil {
return err
}
// Build and sign the transaction, then broadcast to the blockchain
err = ctx.EnsureSignBuildBroadcast(ctx.FromAddressName, []sdk.Msg{msg}, cdc)
if err != nil {
return err
}
return nil
},
}
cmd.Flags().String(flagTo, "", "Recipient address of the payment channel.")
cmd.Flags().String(flagCoins, "", "Amount of coins to fund the payment channel with.")
return cmd
}
func GeneratePaymentCmd(cdc *wire.Codec) *cobra.Command {
flagId := "chan-id"
flagReceiverAmount := "rec-amt" // amount the receiver should received on closing the channel
flagSenderAmount := "sen-amt"
flagPaymentFile := "filename"
cmd := &cobra.Command{
Use: "pay",
Short: "Generate a new payment.", // TODO descriptions
Long: "Generate a payment file (json) to send to the receiver as a payment.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// Create a "client context" stuct populated with info from common flags
ctx := context.NewCoreContextFromViper().WithDecoder(authcmd.GetAccountDecoder(cdc))
// Get the paychan id
id := paychan.ChannelID(viper.GetInt64(flagId)) // TODO make this default to pulling id from chain
// Get channel receiver amount
senderCoins, err := sdk.ParseCoins(viper.GetString(flagSenderAmount))
if err != nil {
return err
}
// Get channel receiver amount
receiverCoins, err := sdk.ParseCoins(viper.GetString(flagReceiverAmount))
if err != nil {
return err
}
// create close paychan msg
update := paychan.Update{
ChannelID: id,
Payout: paychan.Payout{senderCoins, receiverCoins},
// empty sigs
}
// Sign the update as the sender
keybase, err := keys.GetKeyBase()
if err != nil {
return err
}
name := ctx.FromAddressName
passphrase, err := ctx.GetPassphraseFromStdin(name)
if err != nil {
return err
}
bz := update.GetSignBytes()
sig, pubKey, err := keybase.Sign(name, passphrase, bz)
if err != nil {
return err
}
update.Sigs = [1]paychan.UpdateSignature{{
PubKey: pubKey,
CryptoSignature: sig,
}}
// Write out the update
jsonUpdate, err := wire.MarshalJSONIndent(cdc, update)
if err != nil {
return err
}
paymentFile := viper.GetString(flagPaymentFile)
err = ioutil.WriteFile(paymentFile, jsonUpdate, 0644)
if err != nil {
return err
}
fmt.Printf("Written payment out to %v.\n", paymentFile)
return nil
},
}
cmd.Flags().Int(flagId, 0, "ID of the payment channel.")
cmd.Flags().String(flagSenderAmount, "", "Total coins to payout to sender on channel close.")
cmd.Flags().String(flagReceiverAmount, "", "Total coins to payout to sender on channel close.")
cmd.Flags().String(flagPaymentFile, "payment.json", "File name to write the payment into.")
return cmd
}
func VerifyPaymentCmd(cdc *wire.Codec, paychanStoreName string) *cobra.Command {
flagPaymentFile := "payment"
cmd := &cobra.Command{
Use: "verify",
Short: "Verify a payment file.",
Long: "Verify that a received payment can be used to close a channel.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// Create a "client context" stuct populated with info from common flags
ctx := context.NewCoreContextFromViper()
// read in update
bz, err := ioutil.ReadFile(viper.GetString(flagPaymentFile))
if err != nil {
// TODO add nice message about how to feed in stdin
return err
}
// decode json
var update paychan.Update
cdc.UnmarshalJSON(bz, &update)
// get the channel from the node
res, err := ctx.QueryStore(paychan.GetChannelKey(update.ChannelID), paychanStoreName)
if len(res) == 0 || err != nil {
return errors.Errorf("channel with ID '%d' does not exist", update.ChannelID)
}
var channel paychan.Channel
cdc.MustUnmarshalBinary(res, &channel)
//verify
verificationError := paychan.VerifyUpdate(channel, update)
// print result
if verificationError == nil {
fmt.Printf("Payment is valid for channel '%d'.\n", update.ChannelID)
} else {
fmt.Printf("Payment is NOT valid for channel '%d'.\n", update.ChannelID)
fmt.Println(verificationError)
}
return nil
},
}
cmd.Flags().String(flagPaymentFile, "payment.json", "File name to read the payment from.")
return cmd
}
func SubmitPaymentCmd(cdc *wire.Codec) *cobra.Command {
flagPaymentFile := "payment"
cmd := &cobra.Command{
Use: "submit",
Short: "Submit a payment to the blockchain to close the channel.",
Long: fmt.Sprintf("Submit a payment to the blockchain to either close a channel immediately (if you are the receiver) or after a dispute period of %d blocks (if you are the sender).", paychan.ChannelDisputeTime),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// Create a "client context" stuct populated with info from common flags
ctx := context.NewCoreContextFromViper().WithDecoder(authcmd.GetAccountDecoder(cdc))
// ctx.PrintResponse = true TODO is this needed for channelID
// Get sender adress
submitter, err := ctx.GetFromAddress()
if err != nil {
return err
}
// read in update
bz, err := ioutil.ReadFile(viper.GetString(flagPaymentFile))
if err != nil {
return err
}
// decode json
var update paychan.Update
cdc.UnmarshalJSON(bz, &update)
// Create the create channel msg to send
msg := paychan.MsgSubmitUpdate{
Update: update,
Submitter: submitter,
}
err = msg.ValidateBasic()
if err != nil {
return err
}
// Build and sign the transaction, then broadcast to the blockchain
err = ctx.EnsureSignBuildBroadcast(ctx.FromAddressName, []sdk.Msg{msg}, cdc)
if err != nil {
return err
}
return nil
},
}
cmd.Flags().String(flagPaymentFile, "payment.json", "File to read the payment from.")
return cmd
}
func GetChannelCmd(cdc *wire.Codec, paychanStoreName string) *cobra.Command {
flagId := "chan-id"
cmd := &cobra.Command{
Use: "get",
Short: "Get info on a channel.",
Long: "Get the details of a non closed channel plus any submitted update waiting to be executed.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// Create a "client context" stuct populated with info from common flags
ctx := context.NewCoreContextFromViper()
// Get channel ID
id := paychan.ChannelID(viper.GetInt64(flagId))
// Get the channel from the node
res, err := ctx.QueryStore(paychan.GetChannelKey(id), paychanStoreName)
if len(res) == 0 || err != nil {
return errors.Errorf("channel with ID '%d' does not exist", id)
}
var channel paychan.Channel
cdc.MustUnmarshalBinary(res, &channel)
// Convert the channel to a json object for pretty printing
jsonChannel, err := wire.MarshalJSONIndent(cdc, channel)
if err != nil {
return err
}
// print out json channel
fmt.Println(string(jsonChannel))
// Get any submitted updates from the node
res, err = ctx.QueryStore(paychan.GetSubmittedUpdateKey(id), paychanStoreName)
if err != nil {
return err
}
// Print out the submited update if it exsits
if len(res) != 0 {
var submittedUpdate paychan.SubmittedUpdate
cdc.MustUnmarshalBinary(res, &submittedUpdate)
// Convert the submitted update to a json object for pretty printing
jsonSU, err := wire.MarshalJSONIndent(cdc, submittedUpdate)
if err != nil {
return err
}
// print out json submitted update
fmt.Println(string(jsonSU))
}
return nil
},
}
cmd.Flags().Int(flagId, 0, "ID of the payment channel.")
return cmd
}

View File

@ -0,0 +1,7 @@
package lcd
import ()
// implement a thing to poll blockchain and handles paychan disputes
// needs plugged into LCD - add a "background processes" slot in the LCD run function?
// eventually LCD could evolve into paychan (network) daemon

View File

@ -0,0 +1,24 @@
package rest
import (
//"github.com/gorilla/mux"
//"github.com/tendermint/go-crypto/keys"
//"github.com/cosmos/cosmos-sdk/client/context"
//"github.com/cosmos/cosmos-sdk/wire"
)
/*
// RegisterRoutes registers paychan-related REST handlers to a router
func RegisterRoutes(ctx context.CoreContext, r *mux.Router, cdc *wire.Codec, kb keys.Keybase) {
//r.HandleFunc("/accounts/{address}/send", SendRequestHandlerFn(cdc, kb, ctx)).Methods("POST")
}
// handler functions ...
// create paychan
// close paychan
// get paychan(s)
// send paychan payment
// get balance from receiver
// get balance from local storage
// handle incoming payment
*/

View File

@ -0,0 +1,5 @@
/*
High level package documentation.
TODO Explain how the payment channels are implemented.
*/
package paychan

View File

@ -0,0 +1,35 @@
package paychan
import (
sdk "github.com/cosmos/cosmos-sdk/types"
)
func EndBlocker(ctx sdk.Context, k Keeper) sdk.Tags {
var err sdk.Error
var channelTags sdk.Tags
tags := sdk.EmptyTags()
// Iterate through submittedUpdatesQueue
// TODO optimise so it doesn't pull every channel update from DB every block
q := k.getSubmittedUpdatesQueue(ctx)
var sUpdate SubmittedUpdate
var found bool
for _, id := range q {
// close the channel if the update has reached its execution time.
// Using >= in case some are somehow missed.
sUpdate, found = k.getSubmittedUpdate(ctx, id)
if !found {
panic("can't find element in queue that should exist")
}
if ctx.BlockHeight() >= sUpdate.ExecutionTime {
k.removeFromSubmittedUpdatesQueue(ctx, sUpdate.ChannelID)
channelTags, err = k.closeChannel(ctx, sUpdate.Update)
if err != nil {
panic(err)
}
tags.AppendTags(channelTags)
}
}
return tags
}

View File

@ -0,0 +1,59 @@
package paychan
import (
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/stretchr/testify/assert"
"testing"
)
func TestEndBlocker(t *testing.T) {
// TODO test that endBlocker doesn't close channels before the execution time
// SETUP
accountSeeds := []string{"senderSeed", "receiverSeed"}
ctx, _, channelKeeper, addrs, _, _, _ := createMockApp(accountSeeds)
sender := addrs[0]
receiver := addrs[1]
coins := sdk.Coins{sdk.NewCoin("KVA", 10)}
// create new channel
channelID := ChannelID(0) // should be 0 as first channel
channel := Channel{
ID: channelID,
Participants: [2]sdk.AccAddress{sender, receiver},
Coins: coins,
}
channelKeeper.setChannel(ctx, channel)
// create closing update and submittedUpdate
payout := Payout{sdk.Coins{sdk.NewCoin("KVA", 3)}, sdk.Coins{sdk.NewCoin("KVA", 7)}}
update := Update{
ChannelID: channelID,
Payout: payout,
//Sigs: [1]crypto.Signature{},
}
sUpdate := SubmittedUpdate{
Update: update,
ExecutionTime: 0, // current blocktime
}
// Set empty submittedUpdatesQueue TODO work out proper genesis initialisation
channelKeeper.setSubmittedUpdatesQueue(ctx, SubmittedUpdatesQueue{})
// flag channel for closure
channelKeeper.addToSubmittedUpdatesQueue(ctx, sUpdate)
// ACTION
EndBlocker(ctx, channelKeeper)
// CHECK RESULTS
// ideally just check if keeper.channelClose was called, but can't
// writing endBlocker to accept an interface of which keeper is implementation would make this possible
// check channel is gone
_, found := channelKeeper.getChannel(ctx, channelID)
assert.False(t, found)
// check queue is empty, NOTE: due to encoding, an empty queue (underneath just an int slice) will be decoded as nil slice rather than an empty slice
suq := channelKeeper.getSubmittedUpdatesQueue(ctx)
assert.Equal(t, SubmittedUpdatesQueue(nil), suq)
// check submittedUpdate is gone
_, found = channelKeeper.getSubmittedUpdate(ctx, channelID)
assert.False(t, found)
}

View File

@ -0,0 +1,62 @@
package paychan
import (
sdk "github.com/cosmos/cosmos-sdk/types"
"reflect"
)
// NewHandler returns a handler for "paychan" type messages.
// Called when adding routes to a newly created app.
func NewHandler(k Keeper) sdk.Handler {
return func(ctx sdk.Context, msg sdk.Msg) sdk.Result {
switch msg := msg.(type) {
case MsgCreate:
return handleMsgCreate(ctx, k, msg)
case MsgSubmitUpdate:
return handleMsgSubmitUpdate(ctx, k, msg)
default:
errMsg := "Unrecognized paychan Msg type: " + reflect.TypeOf(msg).Name()
return sdk.ErrUnknownRequest(errMsg).Result()
}
}
}
// Handle MsgCreate
// Leaves validation to the keeper methods.
func handleMsgCreate(ctx sdk.Context, k Keeper, msg MsgCreate) sdk.Result {
tags, err := k.CreateChannel(ctx, msg.Participants[0], msg.Participants[len(msg.Participants)-1], msg.Coins)
if err != nil {
return err.Result()
}
// TODO any other information that should be returned in Result?
return sdk.Result{
Tags: tags,
}
}
// Handle MsgSubmitUpdate
// Leaves validation to the keeper methods.
func handleMsgSubmitUpdate(ctx sdk.Context, k Keeper, msg MsgSubmitUpdate) sdk.Result {
var err sdk.Error
tags := sdk.EmptyTags()
// TODO refactor signer detection - move to keeper or find nicer setup
channel, _ := k.getChannel(ctx, msg.Update.ChannelID)
participants := channel.Participants
// if only sender signed
if reflect.DeepEqual(msg.Submitter, participants[0]) {
tags, err = k.InitCloseChannelBySender(ctx, msg.Update)
// else if receiver signed
} else if reflect.DeepEqual(msg.Submitter, participants[len(participants)-1]) {
tags, err = k.CloseChannelByReceiver(ctx, msg.Update)
}
if err != nil {
return err.Result()
}
// These tags can be used by clients to subscribe to channel close attempts
return sdk.Result{
Tags: tags,
}
}

View File

@ -0,0 +1,375 @@
package paychan
import (
"bytes"
"fmt"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/wire"
"github.com/cosmos/cosmos-sdk/x/bank"
)
// Keeper of the paychan store
// Handles validation internally. Does not rely on calling code to do validation.
// Aim to keep public methods safe, private ones not necessaily.
// Keepers contain main business logic of the module.
type Keeper struct {
storeKey sdk.StoreKey
cdc *wire.Codec // needed to serialize objects before putting them in the store
coinKeeper bank.Keeper
//codespace sdk.CodespaceType TODO custom errors
}
// Called when creating new app.
func NewKeeper(cdc *wire.Codec, key sdk.StoreKey, ck bank.Keeper) Keeper {
keeper := Keeper{
storeKey: key,
cdc: cdc,
coinKeeper: ck,
//codespace: codespace,
}
return keeper
}
// Create a new payment channel and lock up sender funds.
func (k Keeper) CreateChannel(ctx sdk.Context, sender sdk.AccAddress, receiver sdk.AccAddress, coins sdk.Coins) (sdk.Tags, sdk.Error) {
// Check addresses valid (Technicaly don't need to check sender address is valid as SubtractCoins checks)
if len(sender) == 0 {
return nil, sdk.ErrInvalidAddress(sender.String())
}
if len(receiver) == 0 {
return nil, sdk.ErrInvalidAddress(receiver.String())
}
// check coins are sorted and positive (disallow channels with zero balance)
if !coins.IsValid() {
return nil, sdk.ErrInvalidCoins(coins.String())
}
if !coins.IsPositive() {
return nil, sdk.ErrInvalidCoins(coins.String())
}
// subtract coins from sender
_, tags, err := k.coinKeeper.SubtractCoins(ctx, sender, coins)
if err != nil {
return nil, err
}
// Calculate next id
id := k.getNewChannelID(ctx)
// create new Paychan struct
channel := Channel{
ID: id,
Participants: [2]sdk.AccAddress{sender, receiver},
Coins: coins,
}
// save to db
k.setChannel(ctx, channel)
// TODO add to tags
return tags, err
}
// Initiate the close of a payment channel, subject to dispute period.
func (k Keeper) InitCloseChannelBySender(ctx sdk.Context, update Update) (sdk.Tags, sdk.Error) {
// This is roughly the default path for non unidirectional channels
// get the channel
channel, found := k.getChannel(ctx, update.ChannelID)
if !found {
return nil, sdk.ErrInternal("Channel doesn't exist")
}
err := VerifyUpdate(channel, update)
if err != nil {
return nil, err
}
q := k.getSubmittedUpdatesQueue(ctx)
if q.Contains(update.ChannelID) {
// Someone has previously tried to update channel
// In bidirectional channels the new update is compared against existing and replaces it if it has a higher sequence number.
// existingSUpdate, found := k.getSubmittedUpdate(ctx, update.ChannelID)
// if !found {
// panic("can't find element in queue that should exist")
// }
// k.addToSubmittedUpdatesQueue(ctx, k.applyNewUpdate(existingSUpdate, update))
// However in unidirectional case, only the sender can close a channel this way. No clear need for them to be able to submit an update replacing a previous one they sent, so don't allow it.
// TODO tags
// TODO custom errors
sdk.ErrInternal("Sender can't submit an update for channel if one has already been submitted.")
} else {
// No one has tried to update channel
submittedUpdate := SubmittedUpdate{
Update: update,
ExecutionTime: ctx.BlockHeight() + ChannelDisputeTime, //TODO check what exactly BlockHeight refers to
}
k.addToSubmittedUpdatesQueue(ctx, submittedUpdate)
}
tags := sdk.EmptyTags() // TODO tags
return tags, nil
}
// Immediately close a channel.
func (k Keeper) CloseChannelByReceiver(ctx sdk.Context, update Update) (sdk.Tags, sdk.Error) {
// get the channel
channel, found := k.getChannel(ctx, update.ChannelID)
if !found {
return nil, sdk.ErrInternal("Channel doesn't exist")
}
err := VerifyUpdate(channel, update)
if err != nil {
return nil, err
}
// Check if there is an update in the queue already
q := k.getSubmittedUpdatesQueue(ctx)
if q.Contains(update.ChannelID) {
// Someone has previously tried to update channel but receiver has final say
k.removeFromSubmittedUpdatesQueue(ctx, update.ChannelID)
}
tags, err := k.closeChannel(ctx, update)
return tags, err
}
// Main function that compare updates against each other.
// Pure function, Not needed in unidirectional case.
// func (k Keeper) applyNewUpdate(existingSUpdate SubmittedUpdate, proposedUpdate Update) SubmittedUpdate {
// var returnUpdate SubmittedUpdate
// if existingSUpdate.Sequence > proposedUpdate.Sequence {
// // update accepted
// returnUpdate = SubmittedUpdate{
// Update: proposedUpdate,
// ExecutionTime: existingSUpdate.ExecutionTime, // FIXME any new update proposal should be subject to full dispute period from submission
// }
// } else {
// // update rejected
// returnUpdate = existingSUpdate
// }
// return returnUpdate
// }
func VerifyUpdate(channel Channel, update Update) sdk.Error {
// Check the num of payout participants match channel participants
if len(update.Payout) != len(channel.Participants) {
return sdk.ErrInternal("Payout doesn't match number of channel participants")
}
// Check each coins are valid
for _, coins := range update.Payout {
if !coins.IsValid() {
return sdk.ErrInternal("Payout coins aren't formatted correctly")
}
}
// Check payout coins are each not negative (can be zero though)
if !update.Payout.IsNotNegative() {
return sdk.ErrInternal("Payout cannot be negative")
}
// Check payout sums to match channel.Coins
if !channel.Coins.IsEqual(update.Payout.Sum()) {
return sdk.ErrInternal("Payout amount doesn't match channel amount")
}
// Check sender signature is OK
if !verifySignatures(channel, update) {
return sdk.ErrInternal("Signature on update not valid")
}
return nil
}
// unsafe close channel - doesn't check if update matches existing channel
// TODO make safer?
func (k Keeper) closeChannel(ctx sdk.Context, update Update) (sdk.Tags, sdk.Error) {
var err sdk.Error
var tags sdk.Tags
channel, _ := k.getChannel(ctx, update.ChannelID)
// TODO check channel exists and participants matches update payout length
// Add coins to sender and receiver
// TODO check for possible errors first to avoid coins being half paid out?
for i, coins := range update.Payout {
_, tags, err = k.coinKeeper.AddCoins(ctx, channel.Participants[i], coins)
if err != nil {
panic(err)
}
}
k.deleteChannel(ctx, update.ChannelID)
return tags, nil
}
func verifySignatures(channel Channel, update Update) bool {
// In non unidirectional channels there will be more than one signature to check
signBytes := update.GetSignBytes()
address := channel.Participants[0]
pubKey := update.Sigs[0].PubKey
cryptoSig := update.Sigs[0].CryptoSignature
// Check public key submitted with update signature matches the account address
valid := bytes.Equal(pubKey.Address(), address) &&
// Check the signature is correct
pubKey.VerifyBytes(signBytes, cryptoSig)
return valid
}
// =========================================== SUBMITTED UPDATES QUEUE
func (k Keeper) addToSubmittedUpdatesQueue(ctx sdk.Context, sUpdate SubmittedUpdate) {
// always overwrite prexisting values - leave paychan logic to higher levels
// get current queue
q := k.getSubmittedUpdatesQueue(ctx)
// append ID to queue
if !q.Contains(sUpdate.ChannelID) {
q = append(q, sUpdate.ChannelID)
}
// set queue
k.setSubmittedUpdatesQueue(ctx, q)
// store submittedUpdate
k.setSubmittedUpdate(ctx, sUpdate)
}
func (k Keeper) removeFromSubmittedUpdatesQueue(ctx sdk.Context, channelID ChannelID) {
// get current queue
q := k.getSubmittedUpdatesQueue(ctx)
// remove id
q.RemoveMatchingElements(channelID)
// set queue
k.setSubmittedUpdatesQueue(ctx, q)
// delete submittedUpdate
k.deleteSubmittedUpdate(ctx, channelID)
}
func (k Keeper) getSubmittedUpdatesQueue(ctx sdk.Context) SubmittedUpdatesQueue {
// load from DB
store := ctx.KVStore(k.storeKey)
bz := store.Get(k.getSubmittedUpdatesQueueKey())
var suq SubmittedUpdatesQueue // if the submittedUpdatesQueue not found then return an empty one
if bz != nil {
// unmarshal
k.cdc.MustUnmarshalBinary(bz, &suq)
}
return suq
}
func (k Keeper) setSubmittedUpdatesQueue(ctx sdk.Context, suq SubmittedUpdatesQueue) {
store := ctx.KVStore(k.storeKey)
// marshal
bz := k.cdc.MustMarshalBinary(suq)
// write to db
key := k.getSubmittedUpdatesQueueKey()
store.Set(key, bz)
}
func (k Keeper) getSubmittedUpdatesQueueKey() []byte {
return []byte("submittedUpdatesQueue")
}
// ============= SUBMITTED UPDATES
// These are keyed by the IDs of their associated Channels
// This section deals with only setting and getting
func (k Keeper) getSubmittedUpdate(ctx sdk.Context, channelID ChannelID) (SubmittedUpdate, bool) {
// load from DB
store := ctx.KVStore(k.storeKey)
bz := store.Get(GetSubmittedUpdateKey(channelID))
var sUpdate SubmittedUpdate
if bz == nil {
return sUpdate, false
}
// unmarshal
k.cdc.MustUnmarshalBinary(bz, &sUpdate)
// return
return sUpdate, true
}
// Store payment channel struct in blockchain store.
func (k Keeper) setSubmittedUpdate(ctx sdk.Context, sUpdate SubmittedUpdate) {
store := ctx.KVStore(k.storeKey)
// marshal
bz := k.cdc.MustMarshalBinary(sUpdate) // panics if something goes wrong
// write to db
key := GetSubmittedUpdateKey(sUpdate.ChannelID)
store.Set(key, bz) // panics if something goes wrong
}
func (k Keeper) deleteSubmittedUpdate(ctx sdk.Context, channelID ChannelID) {
store := ctx.KVStore(k.storeKey)
store.Delete(GetSubmittedUpdateKey(channelID))
// TODO does this have return values? What happens when key doesn't exist?
}
func GetSubmittedUpdateKey(channelID ChannelID) []byte {
return []byte(fmt.Sprintf("submittedUpdate:%d", channelID))
}
// ========================================== CHANNELS
// Reteive a payment channel struct from the blockchain store.
func (k Keeper) getChannel(ctx sdk.Context, channelID ChannelID) (Channel, bool) {
// load from DB
store := ctx.KVStore(k.storeKey)
bz := store.Get(GetChannelKey(channelID))
var channel Channel
if bz == nil {
return channel, false
}
// unmarshal
k.cdc.MustUnmarshalBinary(bz, &channel)
// return
return channel, true
}
// Store payment channel struct in blockchain store.
func (k Keeper) setChannel(ctx sdk.Context, channel Channel) {
store := ctx.KVStore(k.storeKey)
// marshal
bz := k.cdc.MustMarshalBinary(channel) // panics if something goes wrong
// write to db
key := GetChannelKey(channel.ID)
store.Set(key, bz) // panics if something goes wrong
}
func (k Keeper) deleteChannel(ctx sdk.Context, channelID ChannelID) {
store := ctx.KVStore(k.storeKey)
store.Delete(GetChannelKey(channelID))
// TODO does this have return values? What happens when key doesn't exist?
}
func (k Keeper) getNewChannelID(ctx sdk.Context) ChannelID {
// get last channel ID
var lastID ChannelID
store := ctx.KVStore(k.storeKey)
bz := store.Get(getLastChannelIDKey())
if bz == nil {
lastID = -1 // TODO is just setting to zero if uninitialized ok?
} else {
k.cdc.MustUnmarshalBinary(bz, &lastID)
}
// increment to create new one
newID := lastID + 1
bz = k.cdc.MustMarshalBinary(newID)
// set last channel id again
store.Set(getLastChannelIDKey(), bz)
// return
return newID
}
func GetChannelKey(channelID ChannelID) []byte {
return []byte(fmt.Sprintf("channel:%d", channelID))
}
func getLastChannelIDKey() []byte {
return []byte("lastChannelID")
}

View File

@ -0,0 +1,315 @@
package paychan
import (
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/stretchr/testify/assert"
"testing"
)
func TestKeeper(t *testing.T) {
t.Run("CreateChannel", func(t *testing.T) {
// TODO test for receiver account not existing (OK) and sender not existing (not ok)
accountSeeds := []string{"senderSeed", "receiverSeed"}
const (
senderAccountIndex int = 0
receiverAccountIndex int = 1
)
_, addrs, _, _ := createTestGenAccounts(accountSeeds, sdk.Coins{}) // pure function
testCases := []struct {
name string
sender sdk.AccAddress
receiver sdk.AccAddress
coins sdk.Coins
shouldCreateChannel bool
shouldError bool
}{
{
"HappyPath",
addrs[senderAccountIndex],
addrs[receiverAccountIndex],
sdk.Coins{sdk.NewCoin("KVA", 10)},
true,
false,
},
{
"NilAddress",
sdk.AccAddress{},
sdk.AccAddress{},
sdk.Coins{sdk.NewCoin("KVA", 10)},
false,
true,
},
{
"NilCoins",
addrs[senderAccountIndex],
addrs[receiverAccountIndex],
sdk.Coins{},
false,
true,
},
{
"NegativeCoins",
addrs[senderAccountIndex],
addrs[receiverAccountIndex],
sdk.Coins{sdk.NewCoin("KVA", -57)},
false,
true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
////// SETUP
// create basic mock app
ctx, coinKeeper, channelKeeper, addrs, _, _, genAccFunding := createMockApp(accountSeeds)
//
////// ACTION
_, err := channelKeeper.CreateChannel(ctx, testCase.sender, testCase.receiver, testCase.coins)
//
////// CHECK RESULTS
// Check error
if testCase.shouldError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
// Check if channel exists and is correct
channelID := ChannelID(0) // should be 0 as first channel
createdChan, found := channelKeeper.getChannel(ctx, channelID)
if testCase.shouldCreateChannel {
expectedChan := Channel{
ID: channelID,
Participants: [2]sdk.AccAddress{testCase.sender, testCase.receiver},
Coins: testCase.coins,
}
// channel exists and correct
assert.True(t, found)
assert.Equal(t, expectedChan, createdChan)
// check coins deducted from sender
assert.Equal(t, genAccFunding.Minus(testCase.coins), coinKeeper.GetCoins(ctx, testCase.sender))
// check no coins deducted from receiver
assert.Equal(t, genAccFunding, coinKeeper.GetCoins(ctx, testCase.receiver))
// check next global channelID incremented
assert.Equal(t, ChannelID(1), channelKeeper.getNewChannelID(ctx))
} else {
// channel doesn't exist
assert.False(t, found)
assert.Equal(t, Channel{}, createdChan)
// check no coins deducted from sender
assert.Equal(t, genAccFunding, coinKeeper.GetCoins(ctx, addrs[senderAccountIndex]))
// check no coins deducted from receiver
assert.Equal(t, genAccFunding, coinKeeper.GetCoins(ctx, addrs[receiverAccountIndex]))
// check next global channelID not incremented
assert.Equal(t, ChannelID(0), channelKeeper.getNewChannelID(ctx))
}
})
}
})
t.Run("CloseChannelByReceiver", func(t *testing.T) {
// TODO convert to table driven and add more test cases
// channel exists or not (assume channels correct)
// various Updates
// submittedUpdates existing or not (assume they are valid)
// SETUP
accountSeeds := []string{"senderSeed", "receiverSeed"}
const (
senderAccountIndex int = 0
receiverAccountIndex int = 1
)
ctx, coinKeeper, channelKeeper, addrs, pubKeys, privKeys, genAccFunding := createMockApp(accountSeeds)
coins := sdk.Coins{sdk.NewCoin("KVA", 10)}
// create new channel
channelID := ChannelID(0) // should be 0 as first channel
channel := Channel{
ID: channelID,
Participants: [2]sdk.AccAddress{addrs[senderAccountIndex], addrs[receiverAccountIndex]},
Coins: coins,
}
channelKeeper.setChannel(ctx, channel)
// create closing update
payout := Payout{sdk.Coins{sdk.NewCoin("KVA", 3)}, sdk.Coins{sdk.NewCoin("KVA", 7)}}
update := Update{
ChannelID: channelID,
Payout: payout,
// empty sig
}
cryptoSig, _ := privKeys[senderAccountIndex].Sign(update.GetSignBytes())
update.Sigs = [1]UpdateSignature{UpdateSignature{
PubKey: pubKeys[senderAccountIndex],
CryptoSignature: cryptoSig,
}}
// Set empty submittedUpdatesQueue TODO work out proper genesis initialisation
channelKeeper.setSubmittedUpdatesQueue(ctx, SubmittedUpdatesQueue{})
// ACTION
_, err := channelKeeper.CloseChannelByReceiver(ctx, update)
// CHECK RESULTS
// no error
assert.NoError(t, err)
// coins paid out
senderPayout := payout[senderAccountIndex]
assert.Equal(t, genAccFunding.Plus(senderPayout), coinKeeper.GetCoins(ctx, addrs[senderAccountIndex]))
receiverPayout := payout[receiverAccountIndex]
assert.Equal(t, genAccFunding.Plus(receiverPayout), coinKeeper.GetCoins(ctx, addrs[receiverAccountIndex]))
// channel deleted
_, found := channelKeeper.getChannel(ctx, channelID)
assert.False(t, found)
})
t.Run("InitCloseChannelBySender", func(t *testing.T) {
// TODO do some documentation here
// Ideally this should mock calls to ctx.store.Get/Set - test the side effects without being dependent on implementatino details
// TODO test correct behaviour when a submittedUpdate already exists
accountSeeds := []string{"senderSeed", "receiverSeed", "notInChannelSeed"}
const (
senderAccountIndex int = 0
receiverAccountIndex int = 1
otherAccountIndex int = 2
)
chanID := ChannelID(0)
type testUpdate struct { // A parameterised version of an Update for use in specifying test cases.
channelID ChannelID // channelID of submitted update
payout Payout // payout of submitted update
pubKeyAccountIndex int // pubkey of signature of submitted update
sigAccountIndex int // crypto signature of signature of submitted update
}
testCases := []struct {
name string
setupChannel bool
updateToSubmit testUpdate
expectedSubmittedUpdate string // "empty" or "sameAsSubmitted"
shouldError bool
}{
{
"HappyPath",
true,
testUpdate{chanID, Payout{sdk.Coins{sdk.NewCoin("KVA", 3)}, sdk.Coins{sdk.NewCoin("KVA", 7)}}, senderAccountIndex, senderAccountIndex},
"sameAsSubmited",
false,
},
{
"NoChannel",
false,
testUpdate{chanID, Payout{sdk.Coins{sdk.NewCoin("KVA", 3)}, sdk.Coins{sdk.NewCoin("KVA", 7)}}, senderAccountIndex, senderAccountIndex},
"empty",
true,
},
{
"NoCoins",
true,
testUpdate{chanID, Payout{sdk.Coins{}}, senderAccountIndex, senderAccountIndex},
"empty",
true,
},
{
"NegativeCoins",
true,
testUpdate{chanID, Payout{sdk.Coins{sdk.NewCoin("KVA", -5)}, sdk.Coins{sdk.NewCoin("KVA", 15)}}, senderAccountIndex, senderAccountIndex},
"empty",
true,
},
{
"TooManyCoins",
true,
testUpdate{chanID, Payout{sdk.Coins{sdk.NewCoin("KVA", 100)}, sdk.Coins{sdk.NewCoin("KVA", 7)}}, senderAccountIndex, senderAccountIndex},
"empty",
true,
},
{
"WrongSignature",
true,
testUpdate{chanID, Payout{sdk.Coins{sdk.NewCoin("KVA", 3)}, sdk.Coins{sdk.NewCoin("KVA", 7)}}, senderAccountIndex, otherAccountIndex},
"empty",
true,
},
{
"WrongPubKey",
true,
testUpdate{chanID, Payout{sdk.Coins{sdk.NewCoin("KVA", 3)}, sdk.Coins{sdk.NewCoin("KVA", 7)}}, otherAccountIndex, senderAccountIndex},
"empty",
true,
},
{
"ReceiverSigned",
true,
testUpdate{chanID, Payout{sdk.Coins{sdk.NewCoin("KVA", 3)}, sdk.Coins{sdk.NewCoin("KVA", 7)}}, receiverAccountIndex, receiverAccountIndex},
"empty",
true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
// SETUP
ctx, _, channelKeeper, addrs, pubKeys, privKeys, _ := createMockApp(accountSeeds)
// Set empty submittedUpdatesQueue TODO work out proper genesis initialisation
channelKeeper.setSubmittedUpdatesQueue(ctx, SubmittedUpdatesQueue{})
// create new channel
if testCase.setupChannel {
channel := Channel{
ID: chanID, // should be 0 as first channel
Participants: [2]sdk.AccAddress{addrs[senderAccountIndex], addrs[receiverAccountIndex]},
Coins: sdk.Coins{sdk.NewCoin("KVA", 10)},
}
channelKeeper.setChannel(ctx, channel)
}
// create update
// basic values
updateToSubmit := Update{
ChannelID: testCase.updateToSubmit.channelID,
Payout: testCase.updateToSubmit.payout,
// empty sig
}
// create update's signature
cryptoSig, _ := privKeys[testCase.updateToSubmit.sigAccountIndex].Sign(updateToSubmit.GetSignBytes())
updateToSubmit.Sigs = [1]UpdateSignature{UpdateSignature{
PubKey: pubKeys[testCase.updateToSubmit.pubKeyAccountIndex],
CryptoSignature: cryptoSig,
}}
// ACTION
_, err := channelKeeper.InitCloseChannelBySender(ctx, updateToSubmit)
// CHECK RESULTS
// Check error
if testCase.shouldError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
// Check submittedUpdate
su, found := channelKeeper.getSubmittedUpdate(ctx, chanID)
switch testCase.expectedSubmittedUpdate {
case "empty":
assert.False(t, found)
assert.Zero(t, su)
case "sameAsSubmitted":
assert.True(t, found)
expectedSU := SubmittedUpdate{updateToSubmit, ChannelDisputeTime}
assert.Equal(t, expectedSU, su)
}
})
}
})
}

View File

@ -0,0 +1,61 @@
package paychan
import (
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/x/mock"
//"github.com/stretchr/testify/require"
"github.com/cosmos/cosmos-sdk/x/auth"
"github.com/cosmos/cosmos-sdk/x/bank"
abci "github.com/tendermint/tendermint/abci/types"
"github.com/tendermint/tendermint/crypto"
"github.com/tendermint/tendermint/crypto/ed25519"
)
// Setup an example app with an in memory DB and the required keepers
// Also create two accounts with 1000KVA
// Could do with refactoring
func createMockApp(accountSeeds []string) (sdk.Context, bank.Keeper, Keeper, []sdk.AccAddress, []crypto.PubKey, []crypto.PrivKey, sdk.Coins) {
mApp := mock.NewApp() // creates a half complete app
coinKeeper := bank.NewKeeper(mApp.AccountMapper)
// create channel keeper
keyChannel := sdk.NewKVStoreKey("channel")
channelKeeper := NewKeeper(mApp.Cdc, keyChannel, coinKeeper)
// could add router for msg tests
//mapp.Router().AddRoute("channel", NewHandler(channelKeeper))
mApp.CompleteSetup([]*sdk.KVStoreKey{keyChannel})
// create some accounts
genAccFunding := sdk.Coins{sdk.NewCoin("KVA", 1000)}
genAccs, addrs, pubKeys, privKeys := createTestGenAccounts(accountSeeds, genAccFunding)
// initialize the app with these accounts
mock.SetGenesis(mApp, genAccs)
mApp.BeginBlock(abci.RequestBeginBlock{}) // going off other module tests
ctx := mApp.BaseApp.NewContext(false, abci.Header{})
return ctx, coinKeeper, channelKeeper, addrs, pubKeys, privKeys, genAccFunding
}
// CreateTestGenAccounts deterministically generates genesis accounts loaded with coins, and returns
// their addresses, pubkeys, and privkeys.
func createTestGenAccounts(accountSeeds []string, genCoins sdk.Coins) (genAccs []auth.Account, addrs []sdk.AccAddress, pubKeys []crypto.PubKey, privKeys []crypto.PrivKey) {
for _, seed := range accountSeeds {
privKey := ed25519.GenPrivKeyFromSecret([]byte(seed))
pubKey := privKey.PubKey()
addr := sdk.AccAddress(pubKey.Address())
genAcc := &auth.BaseAccount{
Address: addr,
Coins: genCoins,
}
genAccs = append(genAccs, genAcc)
privKeys = append(privKeys, privKey)
pubKeys = append(pubKeys, pubKey)
addrs = append(addrs, addr)
}
return
}

220
internal/x/paychan/types.go Normal file
View File

@ -0,0 +1,220 @@
package paychan
import (
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/tendermint/tendermint/crypto"
)
/* CHANNEL TYPES */
// Used to represent a channel in the keeper module.
// Participants is limited to two as currently these are unidirectional channels.
// Last participant is designated as receiver.
type Channel struct {
ID ChannelID
Participants [2]sdk.AccAddress // [senderAddr, receiverAddr]
Coins sdk.Coins
}
const ChannelDisputeTime = int64(6) // measured in blocks TODO pick reasonable time, add to channel or genesis
type ChannelID int64 // TODO should this be positive only?
// The data that is passed between participants as payments, and submitted to the blockchain to close a channel.
type Update struct {
ChannelID ChannelID
Payout Payout
//Sequence int64 Not needed for unidirectional channels
Sigs [1]UpdateSignature // only sender needs to sign in unidirectional
}
func (u Update) GetSignBytes() []byte {
bz, err := msgCdc.MarshalJSON(struct {
ChannelID ChannelID
Payout Payout
}{
ChannelID: u.ChannelID,
Payout: u.Payout})
if err != nil {
panic(err)
}
return sdk.MustSortJSON(bz)
}
type Payout [2]sdk.Coins // a list of coins to be paid to each of Channel.Participants
func (p Payout) IsNotNegative() bool {
result := true
for _, coins := range p {
result = result && coins.IsNotNegative()
}
return result
}
func (p Payout) Sum() sdk.Coins {
var total sdk.Coins
for _, coins := range p {
total = total.Plus(coins.Sort())
total = total.Sort()
}
return total
}
type UpdateSignature struct {
PubKey crypto.PubKey
CryptoSignature crypto.Signature
}
// An update that has been submitted to the blockchain, but not yet acted on.
type SubmittedUpdate struct {
Update
ExecutionTime int64 // BlockHeight
}
type SubmittedUpdatesQueue []ChannelID // not technically a queue
// Check if value is in queue
func (suq SubmittedUpdatesQueue) Contains(channelID ChannelID) bool {
found := false
for _, id := range suq {
if id == channelID {
found = true
break
}
}
return found
}
// Remove all values from queue that match argument
func (suq *SubmittedUpdatesQueue) RemoveMatchingElements(channelID ChannelID) {
newSUQ := SubmittedUpdatesQueue{}
for _, id := range *suq {
if id != channelID {
newSUQ = append(newSUQ, id)
}
}
*suq = newSUQ
}
/* MESSAGE TYPES */
/*
Message implement the sdk.Msg interface:
type Msg interface {
// Return the message type.
// Must be alphanumeric or empty.
Type() string
// Get the canonical byte representation of the Msg.
GetSignBytes() []byte
// ValidateBasic does a simple validation check that
// doesn't require access to any other information.
ValidateBasic() Error
// Signers returns the addrs of signers that must sign.
// CONTRACT: All signatures must be present to be valid.
// CONTRACT: Returns addrs in some deterministic order.
GetSigners() []AccAddress
}
*/
// A message to create a payment channel.
type MsgCreate struct {
Participants [2]sdk.AccAddress
Coins sdk.Coins
}
func (msg MsgCreate) Type() string { return "paychan" }
func (msg MsgCreate) GetSignBytes() []byte {
bz, err := msgCdc.MarshalJSON(msg)
if err != nil {
panic(err)
}
return sdk.MustSortJSON(bz)
}
func (msg MsgCreate) ValidateBasic() sdk.Error {
// Validate msg as an optimisation to avoid all validation going to keeper. It's run before the sigs are checked by the auth module.
// Validate without external information (such as account balance)
//TODO implement
/* old logic
// check if all fields present / not 0 valued
if len(msg.Sender) == 0 {
return sdk.ErrInvalidAddress(msg.Sender.String())
}
if len(msg.Receiver) == 0 {
return sdk.ErrInvalidAddress(msg.Receiver.String())
}
if len(msg.Amount) == 0 {
return sdk.ErrInvalidCoins(msg.Amount.String())
}
// Check if coins are sorted, non zero, non negative
if !msg.Amount.IsValid() {
return sdk.ErrInvalidCoins(msg.Amount.String())
}
if !msg.Amount.IsPositive() {
return sdk.ErrInvalidCoins(msg.Amount.String())
}
// TODO check if Address valid?
*/
return nil
}
func (msg MsgCreate) GetSigners() []sdk.AccAddress {
// Only sender must sign to create a paychan
return []sdk.AccAddress{msg.Participants[0]} // select sender address
}
// A message to close a payment channel.
type MsgSubmitUpdate struct {
Update
Submitter sdk.AccAddress
}
func (msg MsgSubmitUpdate) Type() string { return "paychan" }
func (msg MsgSubmitUpdate) GetSignBytes() []byte {
bz, err := msgCdc.MarshalJSON(msg)
if err != nil {
panic(err)
}
return sdk.MustSortJSON(bz)
}
func (msg MsgSubmitUpdate) ValidateBasic() sdk.Error {
// TODO implement
/* old logic
// check if all fields present / not 0 valued
if len(msg.Sender) == 0 {
return sdk.ErrInvalidAddress(msg.Sender.String())
}
if len(msg.Receiver) == 0 {
return sdk.ErrInvalidAddress(msg.Receiver.String())
}
if len(msg.ReceiverAmount) == 0 {
return sdk.ErrInvalidCoins(msg.ReceiverAmount.String())
}
// check id ≥ 0
if msg.Id < 0 {
return sdk.ErrInvalidAddress(strconv.Itoa(int(msg.Id))) // TODO implement custom errors
}
// Check if coins are sorted, non zero, non negative
if !msg.ReceiverAmount.IsValid() {
return sdk.ErrInvalidCoins(msg.ReceiverAmount.String())
}
if !msg.ReceiverAmount.IsPositive() {
return sdk.ErrInvalidCoins(msg.ReceiverAmount.String())
}
// TODO check if Address valid?
*/
return nil
}
func (msg MsgSubmitUpdate) GetSigners() []sdk.AccAddress {
return []sdk.AccAddress{msg.Submitter}
}

View File

@ -0,0 +1,45 @@
package paychan
import (
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/stretchr/testify/assert"
"testing"
)
func TestSubmittedUpdatesQueue(t *testing.T) {
t.Run("RemoveMatchingElements", func(t *testing.T) {
// SETUP
q := SubmittedUpdatesQueue{4, 8, 23, 0, 5645657}
// ACTION
q.RemoveMatchingElements(23)
// CHECK RESULTS
expectedQ := SubmittedUpdatesQueue{4, 8, 0, 5645657}
assert.Equal(t, expectedQ, q)
// SETUP
q = SubmittedUpdatesQueue{0}
// ACTION
q.RemoveMatchingElements(0)
// CHECK RESULTS
expectedQ = SubmittedUpdatesQueue{}
assert.Equal(t, expectedQ, q)
})
}
func TestPayout(t *testing.T) {
t.Run("IsNotNegative", func(t *testing.T) {
p := Payout{sdk.Coins{sdk.NewCoin("USD", 4), sdk.NewCoin("GBP", 0)}, sdk.Coins{sdk.NewCoin("USD", 129879234), sdk.NewCoin("GBP", 1)}}
assert.True(t, p.IsNotNegative())
p = Payout{sdk.Coins{sdk.NewCoin("USD", -4), sdk.NewCoin("GBP", 0)}, sdk.Coins{sdk.NewCoin("USD", 129879234), sdk.NewCoin("GBP", 1)}}
assert.False(t, p.IsNotNegative())
})
t.Run("Sum", func(t *testing.T) {
p := Payout{
sdk.Coins{sdk.NewCoin("EUR", 1), sdk.NewCoin("USD", -5)},
sdk.Coins{sdk.NewCoin("EUR", 1), sdk.NewCoin("USD", 100), sdk.NewCoin("GBP", 1)},
}
expected := sdk.Coins{sdk.NewCoin("EUR", 2), sdk.NewCoin("GBP", 1), sdk.NewCoin("USD", 95)}
assert.Equal(t, expected, p.Sum())
})
}

View File

@ -0,0 +1,18 @@
package paychan
import (
"github.com/cosmos/cosmos-sdk/wire"
)
func RegisterWire(cdc *wire.Codec) {
cdc.RegisterConcrete(MsgCreate{}, "paychan/MsgCreate", nil)
cdc.RegisterConcrete(MsgSubmitUpdate{}, "paychan/MsgSubmitUpdate", nil)
}
// TODO move this to near the msg definitions?
var msgCdc = wire.NewCodec()
func init() {
wire.RegisterCrypto(msgCdc)
RegisterWire(msgCdc)
}