Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(x/gov): constitution amendments can update the constitution #25

Merged
merged 11 commits into from
Sep 25, 2024
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
55 changes: 55 additions & 0 deletions tests/e2e/e2e_gov_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"path/filepath"
"strconv"
"strings"
"time"

sdk "github.com/cosmos/cosmos-sdk/types"
Expand Down Expand Up @@ -176,6 +177,35 @@ 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()

res := s.queryConstitution(chainAAPIEndpoint)
newConstitution := "New test constitution"
amendment, err := govtypes.GenerateUnifiedDiff(res.Constitution, newConstitution)
giunatale marked this conversation as resolved.
Show resolved Hide resolved
s.Require().NoError(err)
s.writeGovConstitutionAmendmentProposal(s.chainA, 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 +313,28 @@ 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)
}
9 changes: 5 additions & 4 deletions tests/e2e/e2e_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,11 @@ 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"

// 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
}
70 changes: 70 additions & 0 deletions x/gov/client/cli/tx.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ 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"

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

// Deprecated
cmdSubmitLegacyProp,
Expand Down Expand Up @@ -375,3 +378,70 @@ $ %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 {
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 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
to submit on-chain. Use the 'tx gov submit-proposal' command in conjunction with the
result of this one to submit the proposal.

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
}

// Query the current constitution
clientCtx, err := client.GetClientTxContext(cmd)
if err != nil {
return err
}
queryClient := v1.NewQueryClient(clientCtx)
resp, err := queryClient.Constitution(cmd.Context(), &v1.QueryConstitutionRequest{})
if err != nil {
return err
}

// Generate the unified diff between the current and updated constitutions
diff, err := utils.GenerateUnifiedDiff(resp.Constitution, 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)
tbruyelle marked this conversation as resolved.
Show resolved Hide resolved
// query commands have the FlagOutput default to "text", but we want to override it to "json"
// in this case.
cmd.Flags().Set(flags.FlagOutput, "json")

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)
})
}
}
27 changes: 27 additions & 0 deletions x/gov/client/cli/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,30 @@ func ReadGovPropFlags(clientCtx client.Context, flagSet *pflag.FlagSet) (*govv1.

return rv, nil
}

// readFromMarkdownFile reads the contents of a markdown file
// and returns it as a string.
func readFromMarkdownFile(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()

stat, err := file.Stat()
if err != nil {
return "", fmt.Errorf("failed to get file info: %w", err)
}

if stat.Size() == 0 {
return "", fmt.Errorf("file is empty")
}

contents := make([]byte, stat.Size())
_, err = file.Read(contents)
if err != nil {
return "", fmt.Errorf("failed to read file: %w", err)
}

return string(contents), nil
}
35 changes: 35 additions & 0 deletions x/gov/client/utils/unified_diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package utils

import (
"fmt"

"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
)

// GenerateUnifiedDiff generates a unified diff from src and dst strings using gotextdiff.
// This is the only function that uses the gotextdiff library as its primary use is for
// clients.
func GenerateUnifiedDiff(src, dst string) (string, error) {
// Create spans for the source and destination texts
srcURI := span.URIFromPath("src")

if src == "" || src[len(src)-1] != '\n' {
src += "\n" // Add an EOL to src if it's empty or newline is missing
}
if dst == "" || dst[len(dst)-1] != '\n' {
dst += "\n" // Add an EOL to dst if it's empty or newline is missing
}

// Compute the edits using the Myers diff algorithm
eds := myers.ComputeEdits(srcURI, src, dst)

// Generate the unified diff string
diff := gotextdiff.ToUnified("src", "dst", src, eds)

// Convert the diff to a string
diffStr := fmt.Sprintf("%v", diff)

return diffStr, nil
}
Loading