Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ The following commands are available via the Smart Node client:
- `rocketpool pdao set-voting-delegate, svd` - Set the address you want to use when voting on Rocket Pool on-chain governance proposals, or the address you want to delegate your voting power to.
- `rocketpool pdao claim-bonds, cb` - Unlock any bonded RPL you have for a proposal or set of challenges, and claim any bond rewards for defending or defeating the proposal
- `rocketpool pdao propose, p` - Make a Protocol DAO proposal
- `rocketpool pdao propose submit-batch, sb` - Submit a single proposal that changes multiple Protocol DAO settings from a JSON file
- Setting propose commands accept `--to-json <file>` to write/append a setting change to a JSON file instead of submitting a transaction
- `rocketpool pdao proposals, o` - Manage Protocol DAO proposals
- **queue**, q - Manage the Rocket Pool deposit queue
- `rocketpool queue status, s` - Get the deposit pool and minipool queue status
Expand Down
153 changes: 153 additions & 0 deletions bindings/dao/protocol/proposal-payload.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package protocol

import (
"fmt"
"math/big"
"strings"

"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"

"github.com/rocket-pool/smartnode/bindings/types"
strutils "github.com/rocket-pool/smartnode/bindings/utils/strings"
)

const proposalSettingMultiMethod = "proposalSettingMulti"

type DecodedProposalSetting struct {
Contract string `json:"contract"`
Path string `json:"path"`
Type types.ProposalSettingType `json:"type"`
Value string `json:"value"`
}

func decodeProposalSettingMultiArgs(args []any) ([]DecodedProposalSetting, error) {
if len(args) != 4 {
return nil, fmt.Errorf("proposalSettingMulti expected 4 arguments, got %d", len(args))
}

contracts, err := asStringSlice(args[0])
if err != nil {
return nil, fmt.Errorf("contract names: %w", err)
}
paths, err := asStringSlice(args[1])
if err != nil {
return nil, fmt.Errorf("setting paths: %w", err)
}
settingTypes, err := asSettingTypes(args[2])
if err != nil {
return nil, fmt.Errorf("setting types: %w", err)
}
values, err := asBytesSlice(args[3])
if err != nil {
return nil, fmt.Errorf("setting values: %w", err)
}

if len(contracts) != len(paths) || len(paths) != len(settingTypes) || len(settingTypes) != len(values) {
return nil, fmt.Errorf("proposalSettingMulti argument lengths do not match")
}

settings := make([]DecodedProposalSetting, len(contracts))
for i := range contracts {
value, err := decodeMultiSettingValue(settingTypes[i], values[i])
if err != nil {
return nil, fmt.Errorf("setting %s: %w", paths[i], err)
}
settings[i] = DecodedProposalSetting{
Contract: contracts[i],
Path: paths[i],
Type: settingTypes[i],
Value: value,
}
}
return settings, nil
}

func FormatProposalSettingMulti(settings []DecodedProposalSetting) string {
parts := make([]string, len(settings))
for i, setting := range settings {
parts[i] = fmt.Sprintf("%s=%s", setting.Path, setting.Value)
}
return strutils.Sanitize(fmt.Sprintf("%s(%s)", proposalSettingMultiMethod, strings.Join(parts, ", ")))
}

func formatGenericProposalPayload(method *abi.Method, args []any) string {
argStrs := make([]string, 0, len(args))
for ai, arg := range args {
switch method.Inputs[ai].Type.T {
case abi.AddressTy:
argStrs = append(argStrs, arg.(common.Address).Hex())
case abi.HashTy:
argStrs = append(argStrs, arg.(common.Hash).Hex())
case abi.FixedBytesTy:
fallthrough
case abi.BytesTy:
argStrs = append(argStrs, fmt.Sprintf("%x", arg.([]byte)))
default:
argStrs = append(argStrs, fmt.Sprintf("%v", arg))
}
}
return strutils.Sanitize(fmt.Sprintf("%s(%s)", method.RawName, strings.Join(argStrs, ",")))
}

func decodeMultiSettingValue(settingType types.ProposalSettingType, data []byte) (string, error) {
switch settingType {
case types.ProposalSettingType_Uint256:
return new(big.Int).SetBytes(data).String(), nil
case types.ProposalSettingType_Bool:
return fmt.Sprint(new(big.Int).SetBytes(data).Sign() != 0), nil
case types.ProposalSettingType_Address:
if len(data) >= common.AddressLength {
return common.BytesToAddress(data[len(data)-common.AddressLength:]).Hex(), nil
}
return common.BytesToAddress(data).Hex(), nil
default:
return "", fmt.Errorf("unknown setting type %d", settingType)
}
}

func asStringSlice(value any) ([]string, error) {
switch typed := value.(type) {
case []string:
return typed, nil
default:
return nil, fmt.Errorf("expected []string, got %T", value)
}
}

func asSettingTypes(value any) ([]types.ProposalSettingType, error) {
switch typed := value.(type) {
case []uint8:
out := make([]types.ProposalSettingType, len(typed))
for i, item := range typed {
out[i] = types.ProposalSettingType(item)
}
return out, nil
case []*big.Int:
out := make([]types.ProposalSettingType, len(typed))
for i, item := range typed {
if item == nil {
return nil, fmt.Errorf("nil setting type at index %d", i)
}
out[i] = types.ProposalSettingType(item.Uint64())
}
return out, nil
case []uint16:
out := make([]types.ProposalSettingType, len(typed))
for i, item := range typed {
out[i] = types.ProposalSettingType(item)
}
return out, nil
default:
return nil, fmt.Errorf("expected uint8 array, got %T", value)
}
}

func asBytesSlice(value any) ([][]byte, error) {
switch typed := value.(type) {
case [][]byte:
return typed, nil
default:
return nil, fmt.Errorf("expected [][]byte, got %T", value)
}
}
120 changes: 120 additions & 0 deletions bindings/dao/protocol/proposal-payload_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package protocol

import (
"math/big"
"strings"
"testing"

"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/math"

"github.com/rocket-pool/smartnode/bindings/types"
)

const proposalSettingMultiABI = `[{
"name": "proposalSettingMulti",
"type": "function",
"stateMutability": "nonpayable",
"inputs": [
{"name": "_settingContractNames", "type": "string[]"},
{"name": "_settingPaths", "type": "string[]"},
{"name": "_types", "type": "uint8[]"},
{"name": "_data", "type": "bytes[]"}
],
"outputs": []
}]`

func TestDecodeAndFormatProposalSettingMulti(t *testing.T) {
parsed, err := abi.JSON(strings.NewReader(proposalSettingMultiABI))
if err != nil {
t.Fatalf("parse ABI: %v", err)
}

amount := big.NewInt(0).Mul(big.NewInt(1e18), big.NewInt(1))
address := common.HexToAddress("0x1234567890123456789012345678901234567890")
encoded := [][]byte{
math.PaddedBigBytes(common.Big1, 32),
math.U256Bytes(new(big.Int).Set(amount)),
common.LeftPadBytes(address.Bytes(), 32),
}

payload, err := parsed.Pack(
"proposalSettingMulti",
[]string{"rocketDAOProtocolSettingsAuction", "rocketDAOProtocolSettingsDeposit", "rocketDAOProtocolSettingsNetwork"},
[]string{"auction.lot.create.enabled", "deposit.minimum", "network.node.fee.target"},
[]uint8{
uint8(types.ProposalSettingType_Bool),
uint8(types.ProposalSettingType_Uint256),
uint8(types.ProposalSettingType_Address),
},
encoded,
)
if err != nil {
t.Fatalf("pack: %v", err)
}

method, err := parsed.MethodById(payload)
if err != nil {
t.Fatalf("method: %v", err)
}
args, err := method.Inputs.UnpackValues(payload[4:])
if err != nil {
t.Fatalf("unpack: %v", err)
}

settings, err := decodeProposalSettingMultiArgs(args)
if err != nil {
t.Fatalf("decode: %v", err)
}
if len(settings) != 3 {
t.Fatalf("got %d settings, want 3", len(settings))
}
if settings[0].Path != "auction.lot.create.enabled" || settings[0].Value != "true" || settings[0].Type != types.ProposalSettingType_Bool {
t.Fatalf("bool setting = %+v", settings[0])
}
if settings[1].Path != "deposit.minimum" || settings[1].Value != amount.String() || settings[1].Type != types.ProposalSettingType_Uint256 {
t.Fatalf("uint setting = %+v", settings[1])
}
if settings[2].Path != "network.node.fee.target" || settings[2].Value != address.Hex() || settings[2].Type != types.ProposalSettingType_Address {
t.Fatalf("address setting = %+v", settings[2])
}

formatted := FormatProposalSettingMulti(settings)
if !strings.Contains(formatted, "auction.lot.create.enabled=true") {
t.Fatalf("formatted missing bool setting: %s", formatted)
}
if !strings.Contains(formatted, "deposit.minimum="+amount.String()) {
t.Fatalf("formatted missing uint setting: %s", formatted)
}
if !strings.Contains(formatted, "network.node.fee.target="+address.Hex()) {
t.Fatalf("formatted missing address setting: %s", formatted)
}
if strings.Contains(formatted, "[") || strings.Contains(formatted, "0x000000") {
t.Fatalf("formatted still looks like raw ABI dump: %s", formatted)
}
}

func TestDecodeProposalSettingMultiArgs_LengthMismatch(t *testing.T) {
_, err := decodeProposalSettingMultiArgs([]any{
[]string{"a"},
[]string{"b", "c"},
[]uint8{0},
[][]byte{{1}},
})
if err == nil {
t.Fatal("expected length mismatch error")
}
}

func TestProposalSettingTypeString(t *testing.T) {
if types.ProposalSettingType_Bool.String() != "bool" {
t.Fatalf("bool string = %s", types.ProposalSettingType_Bool)
}
if types.ProposalSettingType_Uint256.String() != "uint256" {
t.Fatalf("uint string = %s", types.ProposalSettingType_Uint256)
}
if types.ProposalSettingType_Address.String() != "address" {
t.Fatalf("address string = %s", types.ProposalSettingType_Address)
}
}
52 changes: 20 additions & 32 deletions bindings/dao/protocol/proposal.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,12 @@ package protocol

import (
"context"
"encoding/hex"
"fmt"
"math/big"
"strings"
"sync"
"time"

"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"golang.org/x/sync/errgroup"
Expand All @@ -19,7 +16,6 @@ import (
"github.com/rocket-pool/smartnode/bindings/rocketpool"
"github.com/rocket-pool/smartnode/bindings/transactions/gaslimit"
"github.com/rocket-pool/smartnode/bindings/types"
strutils "github.com/rocket-pool/smartnode/bindings/utils/strings"
)

// Settings
Expand Down Expand Up @@ -57,6 +53,7 @@ type ProtocolDaoProposalDetails struct {
VetoQuorum *big.Int `json:"vetoQuorum"`
Payload []byte `json:"payload"`
PayloadStr string `json:"payloadStr"`
MultiSettings []DecodedProposalSetting `json:"multiSettings,omitempty"`
State types.ProtocolDaoProposalState `json:"state"`
ProposalBond *big.Int `json:"proposalBond"`
ChallengeBond *big.Int `json:"challengeBond"`
Expand Down Expand Up @@ -238,11 +235,12 @@ func GetProposalDetails(rp *rocketpool.RocketPool, proposalId uint64, opts *bind
}

// Get proposal payload string
payloadStr, err := GetProposalPayloadString(rp, prop.Payload, opts)
payloadStr, multiSettings, err := ParseProposalPayload(rp, prop.Payload, opts)
if err != nil {
payloadStr = "(unknown)"
}
prop.PayloadStr = payloadStr
prop.MultiSettings = multiSettings
return prop, nil
}

Expand Down Expand Up @@ -508,45 +506,35 @@ func GetProposalPayload(rp *rocketpool.RocketPool, proposalId uint64, opts *bind

// Get a proposal's payload as a human-readable string
func GetProposalPayloadString(rp *rocketpool.RocketPool, payload []byte, opts *bind.CallOpts) (string, error) {
payloadStr, _, err := ParseProposalPayload(rp, payload, opts)
return payloadStr, err
}

func ParseProposalPayload(rp *rocketpool.RocketPool, payload []byte, opts *bind.CallOpts) (string, []DecodedProposalSetting, error) {
rocketDAOProtocolProposals, err := getRocketDAOProtocolProposals(rp, nil)
if err != nil {
return "", err
return "", nil, err
}

// Get proposal DAO contract ABI
daoContractAbi := rocketDAOProtocolProposals.ABI

// Get proposal payload method
method, err := daoContractAbi.MethodById(payload)
method, err := rocketDAOProtocolProposals.ABI.MethodById(payload)
if err != nil {
return "", fmt.Errorf("error getting proposal payload method: %w", err)
return "", nil, fmt.Errorf("error getting proposal payload method: %w", err)
}

// Get proposal payload argument values
args, err := method.Inputs.UnpackValues(payload[4:])
if err != nil {
return "", fmt.Errorf("error getting proposal payload arguments: %w", err)
}

// Format argument values as strings
argStrs := []string{}
for ai, arg := range args {
switch method.Inputs[ai].Type.T {
case abi.AddressTy:
argStrs = append(argStrs, arg.(common.Address).Hex())
case abi.HashTy:
argStrs = append(argStrs, arg.(common.Hash).Hex())
case abi.FixedBytesTy:
fallthrough
case abi.BytesTy:
argStrs = append(argStrs, hex.EncodeToString(arg.([]byte)))
default:
argStrs = append(argStrs, fmt.Sprintf("%v", arg))
return "", nil, fmt.Errorf("error getting proposal payload arguments: %w", err)
}

if method.RawName == proposalSettingMultiMethod {
settings, err := decodeProposalSettingMultiArgs(args)
if err != nil {
return "", nil, err
}
return FormatProposalSettingMulti(settings), settings, nil
}

// Build & return payload string
return strutils.Sanitize(fmt.Sprintf("%s(%s)", method.RawName, strings.Join(argStrs, ","))), nil
return formatGenericProposalPayload(method, args), nil, nil
}

// Get the proposal's state
Expand Down
Loading
Loading