diff --git a/README.md b/README.md index 151ed32b..461cb489 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,7 @@ Building on the work of Tendermint and Interledger. Project status: We're currently in a very early public testnet. With future features being implemented. -Try it out - run a full node to sync to the testnet, or set up as a validator. - +Try it out - run a full node to sync to the testnet, [send some off chain payments](internal/x/paychan/README.md), or set up as a validator. ## Install @@ -40,6 +39,7 @@ Requirements: go installed and set up (version 1.10+). cd $GOPATH/src/github.com/kava-labs git clone https://github.com/kava-labs/kava cd kava + git checkout 8c9406c 2. Install the dependencies. @@ -79,15 +79,15 @@ TODO users need to set up keys first? ## Run a Full Node - kvd init --name --chain-id kava-test-1 + kvd init --name --chain-id kava-test-2 This will generate config and keys in `$HOME/.kvd` and `$HOME/.kvcli`. The default password is 'password'. > Note: Make sure `GOBIN` is set and added to your path if you want to be able to run installed go programs from any folder. -Copy the testnet genesis file (from https://raw.githubusercontent.com/Kava-Labs/kava/master/testnets/kava-test-1/genesis.json) into `$HOME/.kvd/config/`, replacing the existing one. +Copy the testnet genesis file (from https://raw.githubusercontent.com/Kava-Labs/kava/master/testnets/kava-test-2/genesis.json) into `$HOME/.kvd/config/`, replacing the existing one. -Add the kava node address, `0dfd43e440e34fc193ddee4ae99547184f3cb5d1@validator.connector.kava.io:26656`, to `seeds` in `$HOME/.kvd/config/config.toml` +Add the kava node address, `5c2bc5a95b014e4b2897791565398ee6bfd0a04a@validator.connector.kava.io:26656`, to `seeds` in `$HOME/.kvd/config/config.toml` Start your full node @@ -119,7 +119,7 @@ Then, your full running in the background or separate window, run: --pubkey \ --address-validator \ --moniker "" \ - --chain-id kava-test-1 \ + --chain-id kava-test-2 \ --from > Note You'll need to type in the default password "password" @@ -131,6 +131,6 @@ In order to stop validating, first remove yourself as validator, then you can st kvcli stake unbond begin \ --address-delegator \ --address-validator \ - --chain-id kava-test-1 \ + --chain-id kava-test-2 \ --shares-percent 1 \ --from diff --git a/cmd/kvcli/main.go b/cmd/kvcli/main.go index c9ff604b..acde7bc0 100644 --- a/cmd/kvcli/main.go +++ b/cmd/kvcli/main.go @@ -17,6 +17,7 @@ import ( //ibccmd "github.com/cosmos/cosmos-sdk/x/ibc/client/cli" slashingcmd "github.com/cosmos/cosmos-sdk/x/slashing/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/lcd" @@ -38,7 +39,7 @@ func main() { // add standard rpc commands rpc.AddCommands(rootCmd) - //Add state commands + // Add state commands tendermintCmd := &cobra.Command{ Use: "tendermint", Short: "Tendermint state querying subcommands", @@ -49,7 +50,7 @@ func main() { ) tx.AddCommands(tendermintCmd, cdc) - //Add IBC commands + // Add IBC commands // ibcCmd := &cobra.Command{ // Use: "ibc", // Short: "Inter-Blockchain Communication subcommands", @@ -75,7 +76,7 @@ func main() { client.LineBreak, ) - //Add stake commands + // Add stake commands stakeCmd := &cobra.Command{ Use: "stake", Short: "Stake and validation subcommands", @@ -101,7 +102,7 @@ func main() { stakeCmd, ) - //Add stake commands + // Add gov commands // govCmd := &cobra.Command{ // Use: "gov", // Short: "Governance and voting subcommands", @@ -122,16 +123,33 @@ func main() { // govCmd, // ) - //Add auth and bank commands + // Add auth and bank commands rootCmd.AddCommand( client.GetCommands( authcmd.GetAccountCmd("acc", cdc, authcmd.GetAccountDecoder(cdc)), )...) rootCmd.AddCommand( - client.PostCommands( + client.PostCommands( // this just wraps the input cmds with common flags 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 rootCmd.AddCommand( keys.Commands(), diff --git a/internal/app/app.go b/internal/app/app.go index ed117c28..1329ca4d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -20,6 +20,7 @@ import ( //"github.com/cosmos/cosmos-sdk/x/ibc" "github.com/cosmos/cosmos-sdk/x/slashing" "github.com/cosmos/cosmos-sdk/x/stake" + "github.com/kava-labs/kava/internal/x/paychan" ) const ( @@ -39,16 +40,19 @@ type KavaApp struct { // keys to access the substores keyMain *sdk.KVStoreKey keyAccount *sdk.KVStoreKey + //keyIBC *sdk.KVStoreKey keyStake *sdk.KVStoreKey keySlashing *sdk.KVStoreKey //keyGov *sdk.KVStoreKey keyFeeCollection *sdk.KVStoreKey + keyPaychan *sdk.KVStoreKey // keepers accountMapper auth.AccountMapper feeCollectionKeeper auth.FeeCollectionKeeper coinKeeper bank.Keeper + paychanKeeper paychan.Keeper //ibcMapper ibc.Mapper stakeKeeper stake.Keeper slashingKeeper slashing.Keeper @@ -71,6 +75,7 @@ func NewKavaApp(logger log.Logger, db dbm.DB, traceStore io.Writer, baseAppOptio cdc: cdc, keyMain: sdk.NewKVStoreKey("main"), keyAccount: sdk.NewKVStoreKey("acc"), + keyPaychan: sdk.NewKVStoreKey("paychan"), //keyIBC: sdk.NewKVStoreKey("ibc"), keyStake: sdk.NewKVStoreKey("stake"), keySlashing: sdk.NewKVStoreKey("slashing"), @@ -87,6 +92,7 @@ func NewKavaApp(logger log.Logger, db dbm.DB, traceStore io.Writer, baseAppOptio // Create the keepers 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.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)) @@ -98,7 +104,8 @@ func NewKavaApp(logger log.Logger, db dbm.DB, traceStore io.Writer, baseAppOptio AddRoute("bank", bank.NewHandler(app.coinKeeper)). //AddRoute("ibc", ibc.NewHandler(app.ibcMapper, app.coinKeeper)). 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)) // 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)) // 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) if err != nil { 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. func CreateKavaAppCodec() *wire.Codec { cdc := wire.NewCodec() + paychan.RegisterWire(cdc) //ibc.RegisterWire(cdc) bank.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 func (app *KavaApp) EndBlocker(ctx sdk.Context, req abci.RequestEndBlock) abci.ResponseEndBlock { + paychan.EndBlocker(ctx, app.paychanKeeper) validatorUpdates := stake.EndBlocker(ctx, app.stakeKeeper) //tags, _ := gov.EndBlocker(ctx, app.govKeeper) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 39d82033..f4e25f89 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1,5 +1,6 @@ package app +/* import ( "os" "testing" @@ -75,3 +76,4 @@ func TestGenesis(t *testing.T) { res1 = bapp.accountMapper.GetAccount(ctx, baseAcc.Address) assert.Equal(t, acc, res1) } +*/ diff --git a/internal/x/paychan/README.md b/internal/x/paychan/README.md new file mode 100644 index 00000000..463f495c --- /dev/null +++ b/internal/x/paychan/README.md @@ -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 --to --amount 100KVA --chain-id + +## Send an off-chain payment +Send a payment for 10 KVA. + + kvcli paychan pay --from --sen-amt 90KVA --rec-amt 10KVA --chan-id --filename payment.json --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 --payment payment.json --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 --payment payment.json --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 + +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 doesn’t throw json parsing error on invalid json + - can’t 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 diff --git a/internal/x/paychan/client/cmd/cmd.go b/internal/x/paychan/client/cmd/cmd.go new file mode 100644 index 00000000..c7b8457f --- /dev/null +++ b/internal/x/paychan/client/cmd/cmd.go @@ -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 +} diff --git a/internal/x/paychan/client/rest/channel-watcher.go b/internal/x/paychan/client/rest/channel-watcher.go new file mode 100644 index 00000000..3f4d1c58 --- /dev/null +++ b/internal/x/paychan/client/rest/channel-watcher.go @@ -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 diff --git a/internal/x/paychan/client/rest/rest.go b/internal/x/paychan/client/rest/rest.go new file mode 100644 index 00000000..926a3474 --- /dev/null +++ b/internal/x/paychan/client/rest/rest.go @@ -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 +*/ diff --git a/internal/x/paychan/doc.go b/internal/x/paychan/doc.go new file mode 100644 index 00000000..2b063b08 --- /dev/null +++ b/internal/x/paychan/doc.go @@ -0,0 +1,5 @@ +/* +High level package documentation. +TODO Explain how the payment channels are implemented. +*/ +package paychan diff --git a/internal/x/paychan/endblocker.go b/internal/x/paychan/endblocker.go new file mode 100644 index 00000000..f56f5083 --- /dev/null +++ b/internal/x/paychan/endblocker.go @@ -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 +} diff --git a/internal/x/paychan/endblocker_test.go b/internal/x/paychan/endblocker_test.go new file mode 100644 index 00000000..62fd276e --- /dev/null +++ b/internal/x/paychan/endblocker_test.go @@ -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) +} diff --git a/internal/x/paychan/handler.go b/internal/x/paychan/handler.go new file mode 100644 index 00000000..4bd93fd2 --- /dev/null +++ b/internal/x/paychan/handler.go @@ -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, + } +} diff --git a/internal/x/paychan/keeper.go b/internal/x/paychan/keeper.go new file mode 100644 index 00000000..14324795 --- /dev/null +++ b/internal/x/paychan/keeper.go @@ -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") +} diff --git a/internal/x/paychan/keeper_test.go b/internal/x/paychan/keeper_test.go new file mode 100644 index 00000000..145a4f28 --- /dev/null +++ b/internal/x/paychan/keeper_test.go @@ -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) + } + + }) + } + + }) + +} diff --git a/internal/x/paychan/test_common.go b/internal/x/paychan/test_common.go new file mode 100644 index 00000000..d102b27f --- /dev/null +++ b/internal/x/paychan/test_common.go @@ -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 +} diff --git a/internal/x/paychan/types.go b/internal/x/paychan/types.go new file mode 100644 index 00000000..57d44b52 --- /dev/null +++ b/internal/x/paychan/types.go @@ -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} +} diff --git a/internal/x/paychan/types_test.go b/internal/x/paychan/types_test.go new file mode 100644 index 00000000..e4004317 --- /dev/null +++ b/internal/x/paychan/types_test.go @@ -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()) + }) +} diff --git a/internal/x/paychan/wire.go b/internal/x/paychan/wire.go new file mode 100644 index 00000000..d8a745aa --- /dev/null +++ b/internal/x/paychan/wire.go @@ -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) +} diff --git a/testnets/kava-test-2/genesis.json b/testnets/kava-test-2/genesis.json new file mode 100644 index 00000000..17e0919f --- /dev/null +++ b/testnets/kava-test-2/genesis.json @@ -0,0 +1,99 @@ +{ + "genesis_time": "2018-09-04T01:25:04.497806902Z", + "chain_id": "kava-test-2", + "consensus_params": { + "block_size_params": { + "max_bytes": "22020096", + "max_txs": "10000", + "max_gas": "-1" + }, + "tx_size_params": { + "max_bytes": "10240", + "max_gas": "-1" + }, + "block_gossip_params": { + "block_part_size_bytes": "65536" + }, + "evidence_params": { + "max_age": "100000" + } + }, + "validators": [ + { + "pub_key": { + "type": "tendermint/PubKeyEd25519", + "value": "kcaCf2NcjO5I+jYTOnTsPMxXvO5m/c6HEUBKwYlLKFo=" + }, + "power": "1000", + "name": "" + } + ], + "app_hash": "", + "app_state": { + "accounts": [ + { + "address": "cosmosaccaddr1f6jrk3rgt9e647mjkeqqvq0yhl7wnntm5r6tyk", + "coins": [ + { + "denom": "KVA", + "amount": "99000" + } + ] + } + ], + "stake": { + "pool": { + "loose_tokens": "100000", + "bonded_tokens": "0", + "inflation_last_time": "0", + "inflation": "7/100", + "date_last_commission_reset": "0", + "prev_bonded_shares": "0" + }, + "params": { + "inflation_rate_change": "13/100", + "inflation_max": "1/5", + "inflation_min": "7/100", + "goal_bonded": "67/100", + "unbonding_time": "259200", + "max_validators": 100, + "bond_denom": "KVA" + }, + "validators": [ + { + "owner": "cosmosaccaddr1f6jrk3rgt9e647mjkeqqvq0yhl7wnntm5r6tyk", + "pub_key": { + "type": "tendermint/PubKeyEd25519", + "value": "kcaCf2NcjO5I+jYTOnTsPMxXvO5m/c6HEUBKwYlLKFo=" + }, + "revoked": false, + "status": 0, + "tokens": "1000", + "delegator_shares": "1000", + "description": { + "moniker": "kava-validator", + "identity": "", + "website": "", + "details": "" + }, + "bond_height": "0", + "bond_intra_tx_counter": 0, + "proposer_reward_pool": [], + "commission": "0", + "commission_max": "0", + "commission_change_rate": "0", + "commission_change_today": "0", + "prev_bonded_tokens": "0" + } + ], + "bonds": [ + { + "delegator_addr": "cosmosaccaddr1f6jrk3rgt9e647mjkeqqvq0yhl7wnntm5r6tyk", + "validator_addr": "cosmosaccaddr1f6jrk3rgt9e647mjkeqqvq0yhl7wnntm5r6tyk", + "shares": "1000", + "height": "0" + } + ] + } + } +} \ No newline at end of file