Skip to content

Commit

Permalink
feat(x/gov): constitution amendments can update the constitution (#25)
Browse files Browse the repository at this point in the history
The `ProposeConstitutionAmendment` msg that is used for constitution amendment proposals now contains an `amendment` field, that needs to be in *unified diff* format (the output of `diff -u`) and will trigger an update
of the constitution.

The choice of using a unified diff format instead of requiring to overwrite the constitution with an entirely new string is for a couple reasons:

- human readability
- compactness

The choice of the unified diff format was deemed the best tradeoff to achieve both results.

A cli command in the txs called `generate-constitution-amendment` is also provided that fetches the current constitution and generates the amendment message that needs to go in the proposal, but the patching mechanism accepts standard unified diffs generated from any source. Putting it into the txs seemed the best place.

The code that checks and applies the diffs does not rely on any external library, conversely the CLI utility to generate the diff does make use of `github.com/hexops/gotextdiff`.
  • Loading branch information
giunatale authored Sep 25, 2024
1 parent db654c9 commit 31f0307
Show file tree
Hide file tree
Showing 30 changed files with 1,402 additions and 160 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ require (
github.com/golang/mock v1.6.0
github.com/google/gofuzz v1.2.0
github.com/gorilla/mux v1.8.1
github.com/hexops/gotextdiff v1.0.3
github.com/manifoldco/promptui v0.9.0
github.com/ory/dockertest/v3 v3.10.0
github.com/rakyll/statik v0.1.7
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,8 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hdevalence/ed25519consensus v0.1.0 h1:jtBwzzcHuTmFrQN6xQZn6CQEO/V9f7HsjsjeEZ6auqU=
github.com/hdevalence/ed25519consensus v0.1.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA=
github.com/holiman/uint256 v1.2.0/go.mod h1:y4ga/t+u+Xwd7CpDgZESaRcWy0I7XMlTMA25ApIH5Jw=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
Expand Down
2 changes: 1 addition & 1 deletion proto/atomone/gov/v1/query.proto
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ option go_package = "github.com/atomone-hub/atomone/x/gov/types/v1";
service Query {
// Constitution queries the chain's constitution.
rpc Constitution(QueryConstitutionRequest) returns (QueryConstitutionResponse) {
option (google.api.http).get = "/cosmos/gov/v1/constitution";
option (google.api.http).get = "/atomone/gov/v1/constitution";
}

// Proposal queries proposal details based on ProposalID.
Expand Down
3 changes: 3 additions & 0 deletions proto/atomone/gov/v1/tx.proto
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ message MsgProposeConstitutionAmendment {
// authority is the address that controls the module (defaults to x/gov unless
// overwritten).
string authority = 1 [ (cosmos_proto.scalar) = "cosmos.AddressString" ];

// amendment is the amendment to the constitution. It must be in valid GNU patch format.
string amendment = 2;
}

// MsgProposeConstitutionAmendmentResponse defines the response structure for executing a
Expand Down
95 changes: 95 additions & 0 deletions tests/e2e/e2e_gov_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package e2e

import (
"context"
"fmt"
"path/filepath"
"strconv"
"strings"
"time"

sdk "github.com/cosmos/cosmos-sdk/types"
Expand All @@ -12,6 +14,7 @@ import (
upgradetypes "github.com/cosmos/cosmos-sdk/x/upgrade/types"

govtypes "github.com/atomone-hub/atomone/x/gov/types"
govtypesv1 "github.com/atomone-hub/atomone/x/gov/types/v1"
govtypesv1beta1 "github.com/atomone-hub/atomone/x/gov/types/v1beta1"
)

Expand Down Expand Up @@ -176,6 +179,34 @@ func (s *IntegrationTestSuite) testGovParamChange() {
})
}

func (s *IntegrationTestSuite) testGovConstitutionAmendment() {
s.Run("constitution amendment", func() {
chainAAPIEndpoint := fmt.Sprintf("http://%s", s.valResources[s.chainA.id][0].GetHostPort("1317/tcp"))
senderAddress, _ := s.chainA.validators[0].keyInfo.GetAddress()
sender := senderAddress.String()

newConstitution := "New test constitution"
amendmentMsg := s.generateConstitutionAmendment(s.chainA, newConstitution)

s.writeGovConstitutionAmendmentProposal(s.chainA, amendmentMsg.Amendment)
// Gov tests may be run in arbitrary order, each test must increment proposalCounter to have the correct proposal id to submit and query
proposalCounter++
submitGovFlags := []string{configFile(proposalConstitutionAmendmentFilename)}
depositGovFlags := []string{strconv.Itoa(proposalCounter), depositAmount.String()}
voteGovFlags := []string{strconv.Itoa(proposalCounter), "yes"}
s.submitGovProposal(chainAAPIEndpoint, sender, proposalCounter, "gov/MsgSubmitProposal", submitGovFlags, depositGovFlags, voteGovFlags, "vote")

s.Require().Eventually(
func() bool {
res := s.queryConstitution(chainAAPIEndpoint)
return res.Constitution == newConstitution
},
10*time.Second,
time.Second,
)
})
}

func (s *IntegrationTestSuite) submitLegacyGovProposal(chainAAPIEndpoint, sender string, proposalID int, proposalType string, submitFlags []string, depositFlags []string, voteFlags []string, voteCommand string, withDeposit bool) {
s.T().Logf("Submitting Gov Proposal: %s", proposalType)
// min deposit of 1000uatone is required in e2e tests, otherwise the gov antehandler causes the proposal to be dropped
Expand Down Expand Up @@ -283,3 +314,67 @@ func (s *IntegrationTestSuite) writeStakingParamChangeProposal(c *chain, params
err := writeFile(filepath.Join(c.validators[0].configDir(), "config", proposalParamChangeFilename), []byte(propMsgBody))
s.Require().NoError(err)
}

func (s *IntegrationTestSuite) writeGovConstitutionAmendmentProposal(c *chain, amendment string) {
govModuleAddress := authtypes.NewModuleAddress(govtypes.ModuleName).String()
// escape newlines in amendment
amendment = strings.ReplaceAll(amendment, "\n", "\\n")
template := `
{
"messages":[
{
"@type": "/atomone.gov.v1.MsgProposeConstitutionAmendment",
"authority": "%s",
"amendment": "%s"
}
],
"deposit": "100uatone",
"proposer": "Proposing validator address",
"metadata": "Constitution Amendment",
"title": "Constitution Amendment",
"summary": "summary"
}
`
propMsgBody := fmt.Sprintf(template, govModuleAddress, amendment)
err := writeFile(filepath.Join(c.validators[0].configDir(), "config", proposalConstitutionAmendmentFilename), []byte(propMsgBody))
s.Require().NoError(err)
}

func (s *IntegrationTestSuite) generateConstitutionAmendment(c *chain, newConstitution string) govtypesv1.MsgProposeConstitutionAmendment {
err := writeFile(filepath.Join(c.validators[0].configDir(), "config", newConstitutionFilename), []byte(newConstitution))
s.Require().NoError(err)

ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()

govCommand := "generate-constitution-amendment"
cmd := []string{
atomonedBinary,
txCommand,
govtypes.ModuleName,
govCommand,
configFile(newConstitutionFilename),
}

s.T().Logf("Executing atomoned tx gov %s on chain %s", govCommand, c.id)
var msg govtypesv1.MsgProposeConstitutionAmendment
s.executeAtomoneTxCommand(ctx, c, cmd, 0, s.parseGenerateConstitutionAmendmentOutput(&msg))
s.T().Logf("Successfully executed %s", govCommand)

s.Require().NoError(err)
return msg
}

func (s *IntegrationTestSuite) parseGenerateConstitutionAmendmentOutput(msg *govtypesv1.MsgProposeConstitutionAmendment) func([]byte, []byte) bool {
return func(stdOut []byte, stdErr []byte) bool {
if len(stdErr) > 0 {
s.T().Logf("Error: %s", string(stdErr))
return false
}

err := cdc.UnmarshalJSON(stdOut, msg)
s.Require().NoError(err)

return true
}
}
10 changes: 6 additions & 4 deletions tests/e2e/e2e_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,12 @@ const (
numberOfEvidences = 10
slashingShares int64 = 10000

proposalBypassMsgFilename = "proposal_bypass_msg.json"
proposalMaxTotalBypassFilename = "proposal_max_total_bypass.json"
proposalCommunitySpendFilename = "proposal_community_spend.json"
proposalParamChangeFilename = "param_change.json"
proposalBypassMsgFilename = "proposal_bypass_msg.json"
proposalMaxTotalBypassFilename = "proposal_max_total_bypass.json"
proposalCommunitySpendFilename = "proposal_community_spend.json"
proposalParamChangeFilename = "param_change.json"
proposalConstitutionAmendmentFilename = "constitution_amendment.json"
newConstitutionFilename = "new_constitution.md"

// hermesBinary = "hermes"
// hermesConfigWithGasPrices = "/root/.hermes/config.toml"
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func (s *IntegrationTestSuite) TestGov() {
s.testGovCancelSoftwareUpgrade()
s.testGovCommunityPoolSpend()
s.testGovParamChange()
s.testGovConstitutionAmendment()
}

func (s *IntegrationTestSuite) TestSlashing() {
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/genesis.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ func modifyGenesis(path, moniker, amountStr string, addrAll []sdk.AccAddress, de
govv1.DefaultQuorumTimeout, govv1.DefaultMaxVotingPeriodExtension, govv1.DefaultQuorumCheckCount,
),
)
govGenState.Constitution = "This is a test constitution"
govGenStateBz, err := cdc.MarshalJSON(govGenState)
if err != nil {
return fmt.Errorf("failed to marshal gov genesis state: %w", err)
Expand Down
10 changes: 10 additions & 0 deletions tests/e2e/query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
evidencetypes "github.com/cosmos/cosmos-sdk/x/evidence/types"
stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types"

govtypesv1 "github.com/atomone-hub/atomone/x/gov/types/v1"
govtypesv1beta1 "github.com/atomone-hub/atomone/x/gov/types/v1beta1"
)

Expand Down Expand Up @@ -268,3 +269,12 @@ func (s *IntegrationTestSuite) queryStakingParams(endpoint string) stakingtypes.
s.Require().NoError(err)
return res
}

func (s *IntegrationTestSuite) queryConstitution(endpoint string) govtypesv1.QueryConstitutionResponse {
var res govtypesv1.QueryConstitutionResponse
body, err := httpGet(fmt.Sprintf("%s/atomone/gov/v1/constitution", endpoint))
s.Require().NoError(err)
err = cdc.UnmarshalJSON(body, &res)
s.Require().NoError(err)
return res
}
2 changes: 0 additions & 2 deletions x/gov/client/cli/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"github.com/spf13/cobra"

"github.com/cosmos/cosmos-sdk/client"
"github.com/cosmos/cosmos-sdk/client/flags"
"github.com/cosmos/cosmos-sdk/codec"
sdk "github.com/cosmos/cosmos-sdk/types"
authtypes "github.com/cosmos/cosmos-sdk/x/auth/types"
Expand Down Expand Up @@ -320,7 +319,6 @@ func NewCmdDraftProposal() *cobra.Command {
},
}

flags.AddTxFlagsToCmd(cmd)
cmd.Flags().Bool(flagSkipMetadata, false, "skip metadata prompt")

return cmd
Expand Down
95 changes: 95 additions & 0 deletions x/gov/client/cli/tx.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/cosmos/cosmos-sdk/client/tx"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/version"
authtypes "github.com/cosmos/cosmos-sdk/x/auth/types"

govutils "github.com/atomone-hub/atomone/x/gov/client/utils"
"github.com/atomone-hub/atomone/x/gov/types"
Expand Down Expand Up @@ -72,6 +73,7 @@ func NewTxCmd(legacyPropCmds []*cobra.Command) *cobra.Command {
NewCmdWeightedVote(),
NewCmdSubmitProposal(),
NewCmdDraftProposal(),
NewCmdGenerateConstitutionAmendment(),

// Deprecated
cmdSubmitLegacyProp,
Expand Down Expand Up @@ -375,3 +377,96 @@ $ %s tx gov weighted-vote 1 yes=0.6,no=0.3,abstain=0.1 --from mykey

return cmd
}

// NewCmdConstitutionAmendmentMsg returns the command to generate the sdk.Msg
// required for a constitution amendment proposal generating the unified diff
// between the current constitution (queried) and the updated constitution
// from the provided markdown file.
func NewCmdGenerateConstitutionAmendment() *cobra.Command {
flagCurrentConstitution := "current-constitution"

cmd := &cobra.Command{
Use: "generate-constitution-amendment [path/to/updated/constitution.md]",
Args: cobra.ExactArgs(1),
Short: "Generate a constitution amendment proposal message",
Long: strings.TrimSpace(
fmt.Sprintf(`Generate a constitution amendment proposal message from the current
constitution and the provided updated constitution.
Queries the current constitution from the node (unless --current-constitution is used)
and generates a valid constitution amendment proposal message containing the unified diff
between the current constitution and the updated constitution provided
in a markdown file.
NOTE: this is just a utility command, it is not able to generate or
submit a valid Tx. Use the 'tx gov submit-proposal' command in
conjunction with the result of this one to submit the proposal.
See also 'tx gov draft-proposal' for a more general proposal drafting tool.
Example:
$ %s tx gov generate-constitution-amendment path/to/updated/constitution.md
`,
version.AppName,
),
),
RunE: func(cmd *cobra.Command, args []string) error {
// Read the updated constitution from the provided markdown file
updatedConstitution, err := readFromMarkdownFile(args[0])
if err != nil {
return err
}

clientCtx, err := client.GetClientTxContext(cmd)
if err != nil {
return err
}

var currentConstitution string
currentConstitutionPath, err := cmd.Flags().GetString(flagCurrentConstitution)
if err != nil {
return err
}

if currentConstitutionPath != "" {
// Read the current constitution from the provided file
currentConstitution, err = readFromMarkdownFile(currentConstitutionPath)
if err != nil {
return err
}
} else {
// Query the current constitution from the node
queryClient := v1.NewQueryClient(clientCtx)
resp, err := queryClient.Constitution(cmd.Context(), &v1.QueryConstitutionRequest{})
if err != nil {
return err
}
currentConstitution = resp.Constitution
}

// Generate the unified diff between the current and updated constitutions
diff, err := govutils.GenerateUnifiedDiff(currentConstitution, updatedConstitution)
if err != nil {
return err
}

// Generate the sdk.Msg for the constitution amendment proposal
msg := v1.NewMsgProposeConstitutionAmendment(authtypes.NewModuleAddress(types.ModuleName), diff)
return clientCtx.PrintProto(msg)
},
}

// This is not a tx command (but a utility for the proposal tx), so we don't need to add tx flags.
// It might actually be confusing, so we just add the query flags.
flags.AddQueryFlagsToCmd(cmd)
// query commands have the FlagOutput default to "text", but we want to override it to "json"
// in this case.
cmd.Flags().Lookup(flags.FlagOutput).DefValue = "json"
err := cmd.Flags().Set(flags.FlagOutput, "json")
if err != nil {
panic(err)
}
// add flag to pass input constitution file instead of querying node
// for the current constitution
cmd.Flags().String(flagCurrentConstitution, "", "Path to the current constitution markdown file (optional, if not provided, the current constitution will be queried from the node)")

return cmd
}
29 changes: 29 additions & 0 deletions x/gov/client/cli/tx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,3 +496,32 @@ func (s *CLITestSuite) TestNewCmdWeightedVote() {
})
}
}

func (s *CLITestSuite) TestCmdGenerateConstitutionAmendment() {
newConstitution := `Modified Constitution`
newConstitutionFile := testutil.WriteToNewTempFile(s.T(), newConstitution)
defer newConstitutionFile.Close()

testCases := []struct {
name string
args []string
expCmdOutput string
}{
{
"generate constitution amendment",
[]string{newConstitutionFile.Name()},
"{\"authority\":\"cosmos10d07y265gmmuvt4z0w9aw880jnsr700j6zn9kn\",\"amendment\":\"--- src\\n+++ dst\\n@@ -1 +1 @@\\n-\\n+Modified Constitution\\n\"}",
},
}

for _, tc := range testCases {
tc := tc

s.Run(tc.name, func() {
cmd := cli.NewCmdGenerateConstitutionAmendment()
out, err := clitestutil.ExecTestCLICmd(s.clientCtx, cmd, tc.args)
s.Require().NoError(err)
s.Require().Contains(out.String(), tc.expCmdOutput)
})
}
}
Loading

0 comments on commit 31f0307

Please sign in to comment.