diff --git a/certs/certs.go b/certs/certs.go index 4d91fa32..fef67a69 100644 --- a/certs/certs.go +++ b/certs/certs.go @@ -46,7 +46,7 @@ type FinalityCertificate struct { Signature []byte // Changes between the power table used to validate this finality certificate and the power // used to validate the next finality certificate. Sorted by ParticipantID, ascending. - PowerTableDelta PowerTableDiff + PowerTableDelta PowerTableDiff `json:"PowerTableDelta,omitempty"` } // NewFinalityCertificate constructs a new finality certificate from the given power delta (from diff --git a/gpbft/chain.go b/gpbft/chain.go index 65374478..cac191f8 100644 --- a/gpbft/chain.go +++ b/gpbft/chain.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/base32" "encoding/binary" + "encoding/json" "errors" "fmt" "strings" @@ -111,6 +112,68 @@ func (ts *TipSet) String() string { return fmt.Sprintf("%s@%d", encTs[:min(16, len(encTs))], ts.Epoch) } +// Custom JSON marshalling for TipSet to achieve: +// 1. a standard TipSetKey representation that presents an array of dag-json CIDs. +// 2. a commitment field that is a base64-encoded string. + +type tipSetSub TipSet +type tipSetJson struct { + Key []cid.Cid + Commitments []byte + *tipSetSub +} + +func (ts TipSet) MarshalJSON() ([]byte, error) { + cids, err := cidsFromTipSetKey(ts.Key) + if err != nil { + return nil, err + } + return json.Marshal(&tipSetJson{ + Key: cids, + Commitments: ts.Commitments[:], + tipSetSub: (*tipSetSub)(&ts), + }) +} + +func (ts *TipSet) UnmarshalJSON(b []byte) error { + aux := &tipSetJson{tipSetSub: (*tipSetSub)(ts)} + var err error + if err = json.Unmarshal(b, &aux); err != nil { + return err + } + if ts.Key, err = tipSetKeyFromCids(aux.Key); err != nil { + return err + } + if len(aux.Commitments) != 32 { + return errors.New("commitments must be 32 bytes") + } + copy(ts.Commitments[:], aux.Commitments) + return nil +} + +func cidsFromTipSetKey(encoded []byte) ([]cid.Cid, error) { + var cids []cid.Cid + for nextIdx := 0; nextIdx < len(encoded); { + nr, c, err := cid.CidFromBytes(encoded[nextIdx:]) + if err != nil { + return nil, err + } + cids = append(cids, c) + nextIdx += nr + } + return cids, nil +} + +func tipSetKeyFromCids(cids []cid.Cid) (TipSetKey, error) { + var buf bytes.Buffer + for _, c := range cids { + if _, err := buf.Write(c.Bytes()); err != nil { + return nil, err + } + } + return buf.Bytes(), nil +} + // A chain of tipsets comprising a base (the last finalised tipset from which the chain extends). // and (possibly empty) suffix. // Tipsets are assumed to be built contiguously on each other, diff --git a/gpbft/chain_test.go b/gpbft/chain_test.go index 2e7902a4..17b273f2 100644 --- a/gpbft/chain_test.go +++ b/gpbft/chain_test.go @@ -1,9 +1,12 @@ package gpbft_test import ( + "bytes" + "encoding/json" "testing" "github.com/filecoin-project/go-f3/gpbft" + "github.com/ipfs/go-cid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -184,3 +187,105 @@ func TestECChain_Eq(t *testing.T) { }) } } + +func TestTipSetSerialization(t *testing.T) { + t.Parallel() + var ( + c1 = gpbft.MakeCid([]byte("barreleye1")) + c2 = gpbft.MakeCid([]byte("barreleye2")) + c3 = gpbft.MakeCid([]byte("barreleye3")) + testCases = []gpbft.TipSet{ + { + Epoch: 1, + Key: append(append(c1.Bytes(), c2.Bytes()...), c3.Bytes()...), + PowerTable: gpbft.MakeCid([]byte("fish")), + Commitments: [32]byte{0x01}, + }, + { + Epoch: 101, + Key: c1.Bytes(), + PowerTable: gpbft.MakeCid([]byte("lobster")), + Commitments: [32]byte{0x02}, + }, + } + badJsonEncodable = []struct { + ts gpbft.TipSet + err string + }{ + { + ts: gpbft.TipSet{ + Epoch: 1, + Key: []byte("nope"), + PowerTable: gpbft.MakeCid([]byte("fish")), + Commitments: [32]byte{0x01}, + }, + err: "invalid cid", + }, + } + badJsonDecodable = []struct { + json string + err string + }{ + { + json: `{"Key":["nope"],"Commitments":"AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","Epoch":101,"PowerTable":{"/":"bafy2bzaced5zqzzbxzyzuq2tcxhuclnvdn3y6ijhurgaapnbayul2dd5gspc4"}}`, + err: "invalid cid", + }, + { + json: `{"Key":[{"/":"bafy2bzacecp4qqs334yrvzxsnlolskbtvyc3ub7k5tzx4s2m77vimzzkduj3g"}],"Commitments":"bm9wZQ==","Epoch":101,"PowerTable":{"/":"bafy2bzaced5zqzzbxzyzuq2tcxhuclnvdn3y6ijhurgaapnbayul2dd5gspc4"}}`, + err: "32 bytes", + }, + } + ) + + t.Run("cbor round trip", func(t *testing.T) { + req := require.New(t) + for _, ts := range testCases { + var buf bytes.Buffer + req.NoError(ts.MarshalCBOR(&buf)) + t.Logf("cbor: %x", buf.Bytes()) + var rt gpbft.TipSet + req.NoError(rt.UnmarshalCBOR(&buf)) + req.Equal(ts, rt) + } + }) + + t.Run("json round trip", func(t *testing.T) { + req := require.New(t) + for _, ts := range testCases { + data, err := ts.MarshalJSON() + req.NoError(err) + t.Logf("json: %s", data) + var rt gpbft.TipSet + req.NoError(rt.UnmarshalJSON(data)) + req.Equal(ts, rt) + + // check that we serialized the CIDs in the standard dag-json form + var bareMap map[string]any + req.NoError(json.Unmarshal(data, &bareMap)) + keyField, ok := bareMap["Key"].([]any) + req.True(ok) + req.Len(keyField, len(ts.Key)/38) + for j, c := range []cid.Cid{c1, c2, c3}[:len(ts.Key)/38] { + req.Equal(map[string]any{"/": c.String()}, keyField[j]) + } + + // check that the supplemental data is a base64 string + commitField, ok := bareMap["Commitments"].(string) + req.True(ok) + req.Len(commitField, 44) + } + }) + + t.Run("json error cases", func(t *testing.T) { + req := require.New(t) + for i, tc := range badJsonEncodable { + _, err := tc.ts.MarshalJSON() + req.ErrorContains(err, tc.err, "expected error for test case %d", i) + } + for i, tc := range badJsonDecodable { + var ts gpbft.TipSet + err := ts.UnmarshalJSON([]byte(tc.json)) + req.ErrorContains(err, tc.err, "expected error for test case %d", i) + } + }) +} diff --git a/gpbft/gpbft.go b/gpbft/gpbft.go index 37ab5bb3..8918cd5b 100644 --- a/gpbft/gpbft.go +++ b/gpbft/gpbft.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/binary" + "encoding/json" "errors" "fmt" "math" @@ -94,6 +95,35 @@ func (d *SupplementalData) Eq(other *SupplementalData) bool { return d.Commitments == other.Commitments && d.PowerTable == other.PowerTable } +// Custom JSON marshalling for SupplementalData to achieve a commitment field +// that is a base64-encoded string. + +type supplementalDataSub SupplementalData +type supplementalDataJson struct { + Commitments []byte + *supplementalDataSub +} + +func (sd SupplementalData) MarshalJSON() ([]byte, error) { + return json.Marshal(&supplementalDataJson{ + Commitments: sd.Commitments[:], + supplementalDataSub: (*supplementalDataSub)(&sd), + }) +} + +func (sd *SupplementalData) UnmarshalJSON(b []byte) error { + aux := &supplementalDataJson{supplementalDataSub: (*supplementalDataSub)(sd)} + var err error + if err = json.Unmarshal(b, &aux); err != nil { + return err + } + if len(aux.Commitments) != 32 { + return errors.New("commitments must be 32 bytes") + } + copy(sd.Commitments[:], aux.Commitments) + return nil +} + // Fields of the message that make up the signature payload. type Payload struct { // GossiPBFT instance (epoch) number. diff --git a/gpbft/gpbft_test.go b/gpbft/gpbft_test.go index 4bde70fa..afeeb3e0 100644 --- a/gpbft/gpbft_test.go +++ b/gpbft/gpbft_test.go @@ -3,6 +3,7 @@ package gpbft_test import ( "bytes" "crypto/rand" + "encoding/json" "io" "testing" @@ -1734,3 +1735,57 @@ func TestGPBFT_Sway(t *testing.T) { require.Fail(t, "after 10 tries did not swayed to proposals 1 and 2 at CONVERGE and COMMIT, respectively.") }) } + +func TestSupplementalDataSerialization(t *testing.T) { + t.Parallel() + var ( + testCases = []gpbft.SupplementalData{ + { + PowerTable: gpbft.MakeCid([]byte("fish")), + Commitments: [32]byte{0x01}, + }, + { + PowerTable: gpbft.MakeCid([]byte("lobster")), + Commitments: [32]byte{0x02}, + }, + } + ) + + t.Run("cbor round trip", func(t *testing.T) { + req := require.New(t) + for _, ts := range testCases { + var buf bytes.Buffer + req.NoError(ts.MarshalCBOR(&buf)) + t.Logf("cbor: %x", buf.Bytes()) + var rt gpbft.SupplementalData + req.NoError(rt.UnmarshalCBOR(&buf)) + req.Equal(ts, rt) + } + }) + + t.Run("json round trip", func(t *testing.T) { + req := require.New(t) + for _, ts := range testCases { + data, err := ts.MarshalJSON() + req.NoError(err) + t.Logf("json: %s", data) + var rt gpbft.SupplementalData + req.NoError(rt.UnmarshalJSON(data)) + req.Equal(ts, rt) + + // check that the supplemental data is a base64 string + var bareMap map[string]any + req.NoError(json.Unmarshal(data, &bareMap)) + commitField, ok := bareMap["Commitments"].(string) + req.True(ok) + req.Len(commitField, 44) + } + }) + + t.Run("json error cases", func(t *testing.T) { + req := require.New(t) + var ts gpbft.SupplementalData + err := ts.UnmarshalJSON([]byte(`{"Commitments":"bm9wZQ==","PowerTable":{"/":"bafy2bzaced5zqzzbxzyzuq2tcxhuclnvdn3y6ijhurgaapnbayul2dd5gspc4"}}`)) + req.ErrorContains(err, "32 bytes") + }) +}