diff --git a/certchain/certchain.go b/certchain/certchain.go new file mode 100644 index 00000000..2b15001d --- /dev/null +++ b/certchain/certchain.go @@ -0,0 +1,354 @@ +package certchain + +import ( + "bytes" + "context" + "fmt" + "math/rand" + "slices" + + "github.com/filecoin-project/go-bitfield" + rlepluslazy "github.com/filecoin-project/go-bitfield/rle" + "github.com/filecoin-project/go-f3/certs" + "github.com/filecoin-project/go-f3/gpbft" +) + +var ( + _ gpbft.CommitteeProvider = (*CertChain)(nil) + _ gpbft.ProposalProvider = (*CertChain)(nil) +) + +type FinalityCertificateProvider func(context.Context, uint64) (*certs.FinalityCertificate, error) + +type tipSetWithPowerTable struct { + gpbft.TipSet + Beacon []byte + PowerTable *gpbft.PowerTable +} + +type CertChain struct { + *options + + rng *rand.Rand + certificates []*certs.FinalityCertificate + generateProposal func(context.Context, uint64) (gpbft.ECChain, error) +} + +func New(o ...Option) (*CertChain, error) { + opts, err := newOptions(o...) + if err != nil { + return nil, err + } + return &CertChain{ + options: opts, + rng: rand.New(rand.NewSource(opts.seed)), + }, nil +} + +func (cc *CertChain) GetCommittee(instance uint64) (*gpbft.Committee, error) { + var committeeEpoch int64 + if instance < cc.m.InitialInstance+cc.m.CommitteeLookback { + committeeEpoch = cc.m.BootstrapEpoch - cc.m.EC.Finality + } else { + lookbackIndex := instance - cc.m.CommitteeLookback + 1 + certAtLookback := cc.certificates[lookbackIndex] + committeeEpoch = certAtLookback.ECChain.Head().Epoch + } + //TODO refactor CommitteeProvider in gpbft to take context. + ctx := context.TODO() + tspt, err := cc.getTipSetWithPowerTableByEpoch(ctx, committeeEpoch) + if err != nil { + return nil, err + } + return cc.getCommittee(tspt) +} + +func (cc *CertChain) GetProposal(instance uint64) (*gpbft.SupplementalData, gpbft.ECChain, error) { + //TODO refactor ProposalProvider in gpbft to take context. + ctx := context.TODO() + proposal, err := cc.generateProposal(ctx, instance) + if err != nil { + return nil, nil, err + } + suppData, err := cc.getSupplementalData(instance) + if err != nil { + return nil, nil, err + } + return suppData, proposal, nil +} + +func (cc *CertChain) getSupplementalData(instance uint64) (*gpbft.SupplementalData, error) { + nextCommittee, err := cc.GetCommittee(instance + 1) + if err != nil { + return nil, err + } + var data gpbft.SupplementalData + data.PowerTable, err = certs.MakePowerTableCID(nextCommittee.PowerTable.Entries) + if err != nil { + return nil, err + } + return &data, nil +} + +func (cc *CertChain) getCommittee(tspt *tipSetWithPowerTable) (*gpbft.Committee, error) { + av, err := cc.sv.Aggregate(tspt.PowerTable.Entries.PublicKeys()) + if err != nil { + return nil, err + } + return &gpbft.Committee{ + PowerTable: tspt.PowerTable, + Beacon: tspt.Beacon, + AggregateVerifier: av, + }, nil +} + +func (cc *CertChain) getTipSetWithPowerTableByEpoch(ctx context.Context, epoch int64) (*tipSetWithPowerTable, error) { + ts, err := cc.ec.GetTipsetByEpoch(ctx, epoch) + if err != nil { + return nil, err + } + ptEntries, err := cc.ec.GetPowerTable(ctx, ts.Key()) + if err != nil { + return nil, err + } + pt := gpbft.NewPowerTable() + if err := pt.Add(ptEntries...); err != nil { + return nil, err + } + ptCid, err := certs.MakePowerTableCID(ptEntries) + if err != nil { + return nil, err + } + return &tipSetWithPowerTable{ + TipSet: gpbft.TipSet{ + Epoch: epoch, + Key: ts.Key(), + PowerTable: ptCid, + }, + Beacon: ts.Beacon(), + PowerTable: pt, + }, nil +} + +func (cc *CertChain) generateRandomProposal(ctx context.Context, base gpbft.TipSet, len int) (gpbft.ECChain, error) { + if len == 0 { + return gpbft.NewChain(base) + } + + suffix := make([]gpbft.TipSet, len-1) + for i := range suffix { + epoch := base.Epoch + 1 + int64(i) + gTS, err := cc.getTipSetWithPowerTableByEpoch(ctx, epoch) + if err != nil { + return nil, err + } + suffix[i] = gTS.TipSet + } + return gpbft.NewChain(base, suffix...) +} + +func (cc *CertChain) signProportionally(ctx context.Context, committee *gpbft.Committee, payload *gpbft.Payload) (*bitfield.BitField, []byte, error) { + candidateSigners := slices.Clone(committee.PowerTable.Entries) + cc.rng.Shuffle(candidateSigners.Len(), func(this, that int) { + candidateSigners[this], candidateSigners[that] = candidateSigners[that], candidateSigners[this] + }) + + // Pick a random proportion of signing power across committee between inclusive + // range of 66% to 100% of total power. + const minimumPower = 2.0 / 3.0 + targetPowerPortion := minimumPower + cc.rng.Float64()*(1.0-minimumPower) + signingPowerThreshold := int64(float64(committee.PowerTable.ScaledTotal) * targetPowerPortion) + + var signingPowerSoFar int64 + type signatureAt struct { + signerIndex int + signature []byte + } + var signatures []signatureAt + marshalledPayload := cc.sv.MarshalPayloadForSigning(cc.m.NetworkName, payload) + for _, p := range candidateSigners { + scaledPower, key := committee.PowerTable.Get(p.ID) + if scaledPower == 0 { + continue + } + sig, err := cc.sv.Sign(ctx, key, marshalledPayload) + if err != nil { + return nil, nil, err + } + signingPowerSoFar += scaledPower + signatures = append(signatures, signatureAt{ + signerIndex: committee.PowerTable.Lookup[p.ID], + signature: sig, + }) + if signingPowerSoFar >= signingPowerThreshold { + break + } + } + + // Now, sort the signatures in ascending order of their index in power table. + slices.SortFunc(signatures, func(one, other signatureAt) int { + return one.signerIndex - other.signerIndex + }) + // Type gymnastics. + signerIndicesMask := make([]int, len(signatures)) + signerIndices := make([]uint64, len(signatures)) + signatureBytes := make([][]byte, len(signatures)) + for i, s := range signatures { + signerIndicesMask[i] = s.signerIndex + signerIndices[i] = uint64(s.signerIndex) + signatureBytes[i] = s.signature + } + itr, err := rlepluslazy.RunsFromSlice(signerIndices) + if err != nil { + return nil, nil, err + } + signers, err := bitfield.NewFromIter(itr) + if err != nil { + return nil, nil, err + } + signature, err := committee.AggregateVerifier.Aggregate(signerIndicesMask, signatureBytes) + if err != nil { + return nil, nil, err + } + return &signers, signature, nil +} + +func (cc *CertChain) sign(ctx context.Context, committee *gpbft.Committee, payload *gpbft.Payload, signers *bitfield.BitField) ([]byte, error) { + const minimumPower = 2.0 / 3.0 + minSigningPower := int64(float64(committee.PowerTable.ScaledTotal) * minimumPower) + + var signingPowerSoFar int64 + var signatures [][]byte + var signersMask []int + marshalledPayload := cc.sv.MarshalPayloadForSigning(cc.m.NetworkName, payload) + if err := signers.ForEach( + func(signerIndex uint64) error { + p := committee.PowerTable.Entries[signerIndex] + scaledPower, key := committee.PowerTable.Get(p.ID) + if scaledPower == 0 { + return fmt.Errorf("zero scaled power for actor ID: %d", p.ID) + } + sig, err := cc.sv.Sign(ctx, key, marshalledPayload) + if err != nil { + return err + } + signatures = append(signatures, sig) + signersMask = append(signersMask, int(signerIndex)) + signingPowerSoFar += scaledPower + return nil + }, + ); err != nil { + return nil, err + } + if signingPowerSoFar < minSigningPower { + signingRatio := float64(signingPowerSoFar) / float64(committee.PowerTable.ScaledTotal) + return nil, fmt.Errorf("signing power does not meet the 2/3 of total power at instance %d: %.3f", payload.Instance, signingRatio) + } + return committee.AggregateVerifier.Aggregate(signersMask, signatures) +} + +func (cc *CertChain) Generate(ctx context.Context, length uint64) ([]*certs.FinalityCertificate, error) { + cc.certificates = make([]*certs.FinalityCertificate, 0, length) + + cc.generateProposal = func(ctx context.Context, instance uint64) (gpbft.ECChain, error) { + var baseEpoch int64 + if instance == cc.m.InitialInstance { + baseEpoch = cc.m.BootstrapEpoch - cc.m.EC.Finality + } else { + latest := len(cc.certificates) - 1 + if latest < 0 { + return nil, fmt.Errorf("no prior finality certificate to get proposal at instance %d", instance) + } + baseEpoch = cc.certificates[latest].ECChain.Head().Epoch + } + base, err := cc.getTipSetWithPowerTableByEpoch(ctx, baseEpoch) + if err != nil { + return nil, err + } + proposalLen := cc.rng.Intn(gpbft.ChainMaxLen) + return cc.generateRandomProposal(ctx, base.TipSet, proposalLen) + } + + instance := cc.m.InitialInstance + committee, err := cc.GetCommittee(instance) + if err != nil { + return nil, err + } + var nextCommittee *gpbft.Committee + for range length { + suppData, proposal, err := cc.GetProposal(instance) + if err != nil { + return nil, err + } + + bf, aggSig, err := cc.signProportionally(ctx, committee, &gpbft.Payload{ + Instance: instance, + Phase: gpbft.DECIDE_PHASE, + SupplementalData: *suppData, + Value: proposal, + }) + if err != nil { + return nil, err + } + + nextCommittee, err = cc.GetCommittee(instance + 1) + if err != nil { + return nil, err + } + certificate := &certs.FinalityCertificate{ + GPBFTInstance: instance, + ECChain: proposal, + SupplementalData: *suppData, + Signers: *bf, + Signature: aggSig, + PowerTableDelta: certs.MakePowerTableDiff(committee.PowerTable.Entries, nextCommittee.PowerTable.Entries), + } + cc.certificates = append(cc.certificates, certificate) + committee = nextCommittee + instance++ + } + return cc.certificates, nil +} + +func (cc *CertChain) Validate(ctx context.Context, crts []*certs.FinalityCertificate) error { + for _, cert := range crts { + instance := cert.GPBFTInstance + proposal := cert.ECChain + suppData, err := cc.getSupplementalData(instance) + if err != nil { + return err + } + if !suppData.Eq(&cert.SupplementalData) { + return fmt.Errorf("supplemental data mismatch at instance %d", instance) + } + committee, err := cc.GetCommittee(instance) + if err != nil { + return fmt.Errorf("getting committee for instance %d: %w", instance, err) + } + sig, err := cc.sign(ctx, committee, &gpbft.Payload{ + Instance: instance, + Phase: gpbft.DECIDE_PHASE, + SupplementalData: *suppData, + Value: proposal, + }, &cert.Signers) + if err != nil { + return err + } + if !bytes.Equal(sig, cert.Signature) { + return fmt.Errorf("certificate signature mismatch at instance %d", instance) + } + gotNextCommittee, err := certs.ApplyPowerTableDiffs(committee.PowerTable.Entries, cert.PowerTableDelta) + if err != nil { + return err + } + gotNextPtCid, err := certs.MakePowerTableCID(gotNextCommittee) + if err != nil { + return err + } + if !suppData.PowerTable.Equals(gotNextPtCid) { + return fmt.Errorf("power table diff mismatch at instance %d", instance) + } + cc.certificates = append(cc.certificates, cert) + } + return nil +} diff --git a/certchain/certchain_test.go b/certchain/certchain_test.go new file mode 100644 index 00000000..221a5a6d --- /dev/null +++ b/certchain/certchain_test.go @@ -0,0 +1,134 @@ +package certchain_test + +import ( + "context" + "math/rand" + "testing" + "time" + + "github.com/filecoin-project/go-f3/certchain" + "github.com/filecoin-project/go-f3/certs" + "github.com/filecoin-project/go-f3/gpbft" + "github.com/filecoin-project/go-f3/internal/clock" + "github.com/filecoin-project/go-f3/internal/consensus" + "github.com/filecoin-project/go-f3/manifest" + "github.com/filecoin-project/go-f3/sim/signing" + "github.com/stretchr/testify/require" +) + +func TestCertChain_GenerateAndVerify(t *testing.T) { + const ( + seed = 1427 + certChainLength = 150 + ) + + ctx, clk := clock.WithMockClock(context.Background()) + m := manifest.LocalDevnetManifest() + signVerifier := signing.NewFakeBackend() + rng := rand.New(rand.NewSource(seed * 23)) + generatePublicKey := func(id gpbft.ActorID) gpbft.PubKey { + //TODO: add the ability to evolve public key across instances. Fake signing + // backed does not support this. + + // Use allow instead of GenerateKey for a reproducible key generation. + return signVerifier.Allow(int(id)) + } + initialPowerTable := generatePowerTable(t, rng, generatePublicKey, nil) + + ec := consensus.NewFakeEC(ctx, + consensus.WithSeed(seed*13), + consensus.WithBootstrapEpoch(m.BootstrapEpoch), + consensus.WithECPeriod(m.EC.Period), + consensus.WithInitialPowerTable(initialPowerTable), + consensus.WithEvolvingPowerTable( + func(epoch int64, entries gpbft.PowerEntries) gpbft.PowerEntries { + if epoch == m.BootstrapEpoch-m.EC.Finality { + return initialPowerTable + } + rng := rand.New(rand.NewSource(epoch * seed)) + next := generatePowerTable(t, rng, generatePublicKey, entries) + return next + }, + ), + ) + + subject, err := certchain.New( + certchain.WithSeed(seed), + certchain.WithSignVerifier(signVerifier), + certchain.WithManifest(m), + certchain.WithEC(ec), + ) + require.NoError(t, err) + + // The mock clock is buried into context passed to fake EC. The face EC will + // refuse to generate a chain if the clock is not advanced. Advance it + // sufficiently to never be bothered by it again. + // + // The fake EC and its relationship with clock needs to be reworked: Clock should + // ideally be passed as an option, and its absence should mean "advance the clock + // as needed". Because, we do not always care about controlling the progress of + // chain generated by fake EC. + clk.Add(200 * time.Hour) + + generatedChain, err := subject.Generate(ctx, certChainLength) + require.NoError(t, err) + + initialCommittee, err := subject.GetCommittee(m.InitialInstance) + require.NoError(t, err) + + nextInstance, _, _, err := certs.ValidateFinalityCertificates( + signVerifier, + m.NetworkName, + initialCommittee.PowerTable.Entries, + generatedChain[0].GPBFTInstance, + generatedChain[0].ECChain.Base(), + generatedChain...) + require.NoError(t, err) + require.Equal(t, m.InitialInstance+certChainLength, nextInstance) + require.NoError(t, subject.Validate(ctx, generatedChain)) + + // Test verification without generation on a fresh CertChain instance is + // consistent for the same EC. + subject2, err := certchain.New( + certchain.WithSeed(seed), + certchain.WithSignVerifier(signVerifier), + certchain.WithManifest(m), + certchain.WithEC(ec), + ) + require.NoError(t, err) + require.NoError(t, subject2.Validate(ctx, generatedChain)) +} + +func generatePowerTable(t *testing.T, rng *rand.Rand, generatePublicKey func(id gpbft.ActorID) gpbft.PubKey, previousEntries gpbft.PowerEntries) gpbft.PowerEntries { + const ( + maxEntries = 100 + maxPower = 1 << 20 + minPower = 0 // Pick a sufficiently low power to facilitate entries with zero scaled power. + actorIDOffset = 1413 + powerChangeProbability = 0.2 + ) + + size := rng.Intn(maxEntries) + entries := make(gpbft.PowerEntries, 0, size) + for i := range size { + var entry gpbft.PowerEntry + if i < previousEntries.Len() { + entry = previousEntries[i] + changedPower := rng.Float64() > powerChangeProbability + if changedPower { + entry.Power = gpbft.NewStoragePower(int64(rng.Intn(maxPower) + minPower)) + } + } else { + id := gpbft.ActorID(uint64(actorIDOffset + i)) + entry = gpbft.PowerEntry{ + ID: id, + Power: gpbft.NewStoragePower(int64(rng.Intn(maxPower) + minPower)), + PubKey: generatePublicKey(id), + } + } + entries = append(entries, entry) + } + next := gpbft.NewPowerTable() + require.NoError(t, next.Add(entries...)) + return next.Entries +} diff --git a/certchain/options.go b/certchain/options.go new file mode 100644 index 00000000..ece84330 --- /dev/null +++ b/certchain/options.go @@ -0,0 +1,70 @@ +package certchain + +import ( + "errors" + + "github.com/filecoin-project/go-f3/ec" + "github.com/filecoin-project/go-f3/gpbft" + "github.com/filecoin-project/go-f3/manifest" +) + +type Option func(*options) error + +type SignVerifier interface { + gpbft.Signer + gpbft.Verifier + gpbft.SigningMarshaler +} + +type options struct { + ec ec.Backend + m *manifest.Manifest + sv SignVerifier + seed int64 +} + +func newOptions(o ...Option) (*options, error) { + opts := &options{ + m: manifest.LocalDevnetManifest(), + } + for _, apply := range o { + if err := apply(opts); err != nil { + return nil, err + } + } + if opts.ec == nil { + return nil, errors.New("ec backend must be specified") + } + if opts.sv == nil { + return nil, errors.New("sign verifier must be specified") + } + return opts, nil +} + +func WithEC(ec ec.Backend) Option { + return func(o *options) error { + o.ec = ec + return nil + } +} + +func WithManifest(m *manifest.Manifest) Option { + return func(o *options) error { + o.m = m + return nil + } +} + +func WithSignVerifier(sv SignVerifier) Option { + return func(o *options) error { + o.sv = sv + return nil + } +} + +func WithSeed(seed int64) Option { + return func(o *options) error { + o.seed = seed + return nil + } +}