Skip to content

Commit 27b77d5

Browse files
feat: construct queued proposals with predecessors (#276)
Enables users to construct proposals given a list of preceding, non-executed proposals, such that proposal signing can happen in parallel assuming a pre-determined sequential execution order. The key changes are as follows: ### Enhancements to Proposal Creation: * Updated the `NewProposal` and `NewTimelockProposal` functions to accept a list of predecessor proposals, allowing for the configuration of operation counts based on the execution order of proposals. (`proposal.go`, `timelock_proposal.go`) [[1]](diffhunk://#diff-76c5df6d4d3acdc6b2bc63b2377214b642e59a655957a9e65884ed21f204a27bL90-R116) [[2]](diffhunk://#diff-22aa9f90e7ed0f30eb381e769a6ce492d3472c4e8029e134f0074e1913ce482fR54-R63) ### Documentation Updates: * Added a new section in `docs/usage/building-proposals.md` to explain how to build proposals with staged but non-executed predecessor proposals. ### Code Refactoring: * Modified various test files to accommodate the new `NewProposal` and `NewTimelockProposal` function signatures that include predecessor proposals. (`ledger_test.go`, `signing.go`, `proposal_test.go`, `timelock_proposal_test.go`) [[1]](diffhunk://#diff-046f7b9890699bc5c0782f4c739ef15b5f8be4727b0c2f7cf932fa171d9f514dL206-R207) [[2]](diffhunk://#diff-0c8a658097684777bc5992740e4c927a02a88830e4a6783742f7a3de02b6bfa3L52-R52) [[3]](diffhunk://#diff-f54ef2790620db06a34dcf62d0565c5536ddeb0b22274fafc6d0cff158c9b789R131-R190) [[4]](diffhunk://#diff-b80686a8969c14a89af64e7ed45e5a1e0dc025880256405d9e48eac6e33722b9R100-R173) ### New Methods: * Added `TransactionCounts`, `ChainMetadatas`, and `SetChainMetadata` methods to the `ProposalInterface` to manage chain metadata more effectively. (`proposal.go`, `timelock_proposal.go`) [[1]](diffhunk://#diff-76c5df6d4d3acdc6b2bc63b2377214b642e59a655957a9e65884ed21f204a27bR84-R93) [[2]](diffhunk://#diff-22aa9f90e7ed0f30eb381e769a6ce492d3472c4e8029e134f0074e1913ce482fR54-R63) ### Minor Changes: * Updated the `.changeset/shaggy-pianos-remember.md` file to reflect the minor version update for the `@smartcontractkit/mcms` package.
1 parent 28d52c3 commit 27b77d5

9 files changed

+383
-52
lines changed

.changeset/shaggy-pianos-remember.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@smartcontractkit/mcms": minor
3+
---
4+
5+
Update constructors to add predecessor proposals for queuing

docs/usage/building-proposals.md

+42-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ package main
2222
import (
2323
"log"
2424
"os"
25+
"io"
2526

2627
"github.com/smartcontractkit/mcms"
2728
)
@@ -35,7 +36,7 @@ func main() {
3536
defer file.Close()
3637

3738
// Create the proposal from the JSON data
38-
proposal, err := mcms.NewProposal(file)
39+
proposal, err := mcms.NewProposal(file, []io.Reader{})
3940
if err != nil {
4041
log.Fatalf("Error creating proposal: %v", err)
4142
}
@@ -46,6 +47,46 @@ func main() {
4647

4748
For the JSON structure of the proposal please check the [MCMS Proposal Format Doc.](/key-concepts/mcm-proposal.md)
4849

50+
### Build Proposal Given Staged but Non-Executed Predecessor Proposals
51+
52+
In scenarios where a proposal is generated with the assumption that multiple proposals are executed beforehand, you can enable proposals to be signed in parallel with a pre-determined execution order. This can be achieved by passing a list of files as the second argument in the Proposal constructor, as shown below:
53+
54+
```go
55+
package main
56+
57+
import (
58+
"log"
59+
"os"
60+
"io"
61+
62+
"github.com/smartcontractkit/mcms"
63+
)
64+
65+
func main() {
66+
// Open the JSON file for the new proposal
67+
file, err := os.Open("proposal.json")
68+
if err != nil {
69+
log.Fatalf("Error opening file: %v", err)
70+
}
71+
defer file.Close()
72+
73+
// Open the JSON file for the predecessor proposal
74+
preFile, err := os.Open("pre-proposal.json")
75+
if err != nil {
76+
log.Fatalf("Error opening predecessor file: %v", err)
77+
}
78+
defer preFile.Close()
79+
80+
// Create the proposal from the JSON data
81+
proposal, err := mcms.NewProposal(file, []io.Reader{preFile})
82+
if err != nil {
83+
log.Fatalf("Error creating proposal: %v", err)
84+
}
85+
86+
log.Printf("Successfully created proposal: %+v", proposal)
87+
}
88+
```
89+
4990
## 2. Programmatic Build
5091

5192
The Proposal Builder API provides a fluent interface to construct a Proposal with customizable fields and metadata,

e2e/ledger/ledger_test.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package ledger
55

66
import (
77
"context"
8+
"io"
89
"log"
910
"math/big"
1011
"os"
@@ -203,7 +204,7 @@ func (s *ManualLedgerSigningTestSuite) TestManualLedgerSigning() {
203204
}(file)
204205
s.Require().NoError(err)
205206

206-
proposal, err := mcms.NewProposal(file)
207+
proposal, err := mcms.NewProposal(file, []io.Reader{})
207208
s.Require().NoError(err, "Failed to parse proposal")
208209
s.T().Log("Proposal loaded successfully.")
209210
proposal.ChainMetadata[s.chainSelectorEVM] = types.ChainMetadata{

e2e/tests/evm/signing.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func (s *SigningTestSuite) TestReadAndSign() {
4949
}
5050
}(file)
5151
s.Require().NoError(err)
52-
proposal, err := mcms.NewProposal(file)
52+
proposal, err := mcms.NewProposal(file, []io.Reader{})
5353
s.Require().NoError(err)
5454
s.Require().NotNil(proposal)
5555
inspectors := map[mcmtypes.ChainSelector]sdk.Inspector{
@@ -81,7 +81,7 @@ func (s *SigningTestSuite) TestReadAndSign() {
8181
_, err = tmpFile.Seek(0, io.SeekStart)
8282
s.Require().NoError(err, "Failed to reset file pointer to the start")
8383

84-
writtenProposal, err := mcms.NewProposal(tmpFile)
84+
writtenProposal, err := mcms.NewProposal(tmpFile, []io.Reader{})
8585
s.Require().NoError(err)
8686

8787
// Validate the appended signature

proposal.go

+34-14
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ const SignMsgABI = `[{"type":"bytes32"},{"type":"uint32"}]`
2626

2727
type ProposalInterface interface {
2828
AppendSignature(signature types.Signature)
29+
TransactionCounts() map[types.ChainSelector]uint64
30+
ChainMetadatas() map[types.ChainSelector]types.ChainMetadata
31+
setChainMetadata(chainSelector types.ChainSelector, metadata types.ChainMetadata)
2932
Validate() error
3033
}
3134

@@ -41,7 +44,7 @@ func LoadProposal(proposalType types.ProposalKind, filePath string) (ProposalInt
4144
// Ensure the file is closed when done
4245
defer file.Close()
4346

44-
return NewProposal(file)
47+
return NewProposal(file, []io.Reader{}) // TODO: inject predecessors
4548
case types.KindTimelockProposal:
4649
// Open the file
4750
file, err := os.Open(filePath)
@@ -52,7 +55,7 @@ func LoadProposal(proposalType types.ProposalKind, filePath string) (ProposalInt
5255
// Ensure the file is closed when done
5356
defer file.Close()
5457

55-
return NewTimelockProposal(file)
58+
return NewTimelockProposal(file, []io.Reader{}) // TODO: inject predecessors
5659
default:
5760
return nil, errors.New("unknown proposal type")
5861
}
@@ -78,6 +81,21 @@ func (p *BaseProposal) AppendSignature(signature types.Signature) {
7881
p.Signatures = append(p.Signatures, signature)
7982
}
8083

84+
// ChainMetadata returns the chain metadata for the proposal.
85+
func (p *BaseProposal) ChainMetadatas() map[types.ChainSelector]types.ChainMetadata {
86+
cmCopy := make(map[types.ChainSelector]types.ChainMetadata, len(p.ChainMetadata))
87+
for k, v := range p.ChainMetadata {
88+
cmCopy[k] = v
89+
}
90+
91+
return cmCopy
92+
}
93+
94+
// SetChainMetadata sets the chain metadata for a given chain selector.
95+
func (p *BaseProposal) setChainMetadata(chainSelector types.ChainSelector, metadata types.ChainMetadata) {
96+
p.ChainMetadata[chainSelector] = metadata
97+
}
98+
8199
// Proposal is a struct where the target contract is an MCMS contract
82100
// with no forwarder contracts. This type does not support any type of atomic contract
83101
// call batching, as the MCMS contract natively doesn't support batching
@@ -87,18 +105,20 @@ type Proposal struct {
87105
Operations []types.Operation `json:"operations" validate:"required,min=1,dive"`
88106
}
89107

90-
// NewProposal unmarshal data from the reader to JSON and returns a new Proposal.
91-
func NewProposal(reader io.Reader) (*Proposal, error) {
92-
var p Proposal
93-
if err := json.NewDecoder(reader).Decode(&p); err != nil {
94-
return nil, err
95-
}
96-
97-
if err := p.Validate(); err != nil {
98-
return nil, err
99-
}
100-
101-
return &p, nil
108+
var _ ProposalInterface = (*Proposal)(nil)
109+
110+
// NewProposal unmarshals data from the reader to JSON and returns a new Proposal.
111+
// The predecessors parameter is a list of readers that contain the predecessors
112+
// for the proposal for configuring operations counts, which makes the following
113+
// assumptions:
114+
// - The order of the predecessors array is the order in which the proposals are
115+
// intended to be executed.
116+
// - The op counts for the first proposal are meant to be the starting op for the
117+
// full set of proposals.
118+
// - The op counts for all other proposals except the first are ignored
119+
// - all proposals are configured correctly and need no additional modifications
120+
func NewProposal(reader io.Reader, predecessors []io.Reader) (*Proposal, error) {
121+
return newProposal[*Proposal](reader, predecessors)
102122
}
103123

104124
// WriteProposal marshals the proposal to JSON and writes it to the provided writer.

proposal_test.go

+113-12
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ var (
2727
"kind": "Proposal",
2828
"validUntil": 2004259681,
2929
"chainMetadata": {
30-
"3379446385462418246": {}
30+
"3379446385462418246": {
31+
"startingOpCount": 0,
32+
"mcmAddress": ""
33+
}
3134
},
3235
"operations": [
3336
{
@@ -56,18 +59,54 @@ func Test_BaseProposal_AppendSignature(t *testing.T) {
5659
assert.Equal(t, []types.Signature{signature}, proposal.Signatures)
5760
}
5861

62+
func Test_BaseProposal_GetChainMetadata(t *testing.T) {
63+
t.Parallel()
64+
65+
chainMetadata := map[types.ChainSelector]types.ChainMetadata{
66+
chaintest.Chain1Selector: {},
67+
}
68+
69+
proposal := BaseProposal{
70+
ChainMetadata: chainMetadata,
71+
}
72+
73+
assert.Equal(t, chainMetadata, proposal.ChainMetadatas())
74+
}
75+
76+
func Test_BaseProposal_SetChainMetadata(t *testing.T) {
77+
t.Parallel()
78+
79+
proposal := BaseProposal{
80+
ChainMetadata: map[types.ChainSelector]types.ChainMetadata{},
81+
}
82+
83+
md, ok := proposal.ChainMetadata[chaintest.Chain1Selector]
84+
assert.False(t, ok)
85+
assert.Empty(t, md)
86+
87+
proposal.setChainMetadata(chaintest.Chain1Selector, types.ChainMetadata{
88+
StartingOpCount: 0,
89+
MCMAddress: "",
90+
})
91+
92+
assert.Equal(t, uint64(0), proposal.ChainMetadata[chaintest.Chain1Selector].StartingOpCount)
93+
assert.Equal(t, "", proposal.ChainMetadata[chaintest.Chain1Selector].MCMAddress)
94+
}
95+
5996
func Test_NewProposal(t *testing.T) {
6097
t.Parallel()
6198

6299
tests := []struct {
63-
name string
64-
give string
65-
want Proposal
66-
wantErr string
100+
name string
101+
give string
102+
givePredecessors []string
103+
want Proposal
104+
wantErr string
67105
}{
68106
{
69-
name: "success: initializes a proposal from an io.Reader",
70-
give: ValidProposal,
107+
name: "success: initializes a proposal from an io.Reader",
108+
give: ValidProposal,
109+
givePredecessors: []string{},
71110
want: Proposal{
72111
BaseProposal: BaseProposal{
73112
Version: "v1",
@@ -90,9 +129,66 @@ func Test_NewProposal(t *testing.T) {
90129
},
91130
},
92131
{
93-
name: "failure: could not unmarshal JSON",
94-
give: `invalid`,
95-
wantErr: "invalid character 'i' looking for beginning of value",
132+
name: "success: initializes a proposal with 1 predecessor proposals",
133+
give: ValidProposal,
134+
givePredecessors: []string{ValidProposal},
135+
want: Proposal{
136+
BaseProposal: BaseProposal{
137+
Version: "v1",
138+
Kind: types.KindProposal,
139+
ValidUntil: 2004259681,
140+
ChainMetadata: map[types.ChainSelector]types.ChainMetadata{
141+
chaintest.Chain1Selector: {
142+
StartingOpCount: 1,
143+
MCMAddress: "",
144+
},
145+
},
146+
},
147+
Operations: []types.Operation{
148+
{
149+
ChainSelector: chaintest.Chain1Selector,
150+
Transaction: types.Transaction{
151+
To: "0xsomeaddress",
152+
Data: []byte{0x12, 0x33}, // Representing "0x123" as bytes
153+
AdditionalFields: json.RawMessage(`{"value": 0}`), // JSON-encoded `{"value": 0}`
154+
},
155+
},
156+
},
157+
},
158+
},
159+
{
160+
name: "success: initializes a proposal with 2 predecessor proposals",
161+
give: ValidProposal,
162+
givePredecessors: []string{ValidProposal, ValidProposal},
163+
want: Proposal{
164+
BaseProposal: BaseProposal{
165+
Version: "v1",
166+
Kind: types.KindProposal,
167+
ValidUntil: 2004259681,
168+
ChainMetadata: map[types.ChainSelector]types.ChainMetadata{
169+
chaintest.Chain1Selector: {
170+
StartingOpCount: 2,
171+
MCMAddress: "",
172+
},
173+
},
174+
},
175+
Operations: []types.Operation{
176+
{
177+
ChainSelector: chaintest.Chain1Selector,
178+
Transaction: types.Transaction{
179+
To: "0xsomeaddress",
180+
Data: []byte{0x12, 0x33}, // Representing "0x123" as bytes
181+
AdditionalFields: json.RawMessage(`{"value": 0}`), // JSON-encoded `{"value": 0}`
182+
},
183+
},
184+
},
185+
},
186+
},
187+
{
188+
name: "failure: could not unmarshal JSON",
189+
give: `invalid`,
190+
givePredecessors: []string{},
191+
wantErr: "failed to decode and validate target proposal: failed to decode proposal: invalid character 'i' looking for beginning of value",
96192
},
97193
{
98194
name: "failure: invalid proposal",
@@ -103,7 +199,8 @@ func Test_NewProposal(t *testing.T) {
103199
"chainMetadata": {},
104200
"operations": []
105201
}`,
106-
wantErr: "Key: 'Proposal.BaseProposal.ChainMetadata' Error:Field validation for 'ChainMetadata' failed on the 'min' tag\nKey: 'Proposal.Operations' Error:Field validation for 'Operations' failed on the 'min' tag",
202+
givePredecessors: []string{},
203+
wantErr: "failed to decode and validate target proposal: failed to validate proposal: Key: 'Proposal.BaseProposal.ChainMetadata' Error:Field validation for 'ChainMetadata' failed on the 'min' tag\nKey: 'Proposal.Operations' Error:Field validation for 'Operations' failed on the 'min' tag",
107204
},
108205
}
109206

@@ -112,8 +209,12 @@ func Test_NewProposal(t *testing.T) {
112209
t.Parallel()
113210

114211
give := strings.NewReader(tt.give)
212+
givePredecessors := []io.Reader{}
213+
for _, p := range tt.givePredecessors {
214+
givePredecessors = append(givePredecessors, strings.NewReader(p))
215+
}
115216

116-
fileProposal, err := NewProposal(give)
217+
fileProposal, err := NewProposal(give, givePredecessors)
117218

118219
if tt.wantErr != "" {
119220
require.EqualError(t, err, tt.wantErr)

0 commit comments

Comments
 (0)