A very experimental PLC implementation which uses BFT consensus for decentralization
1package abciapp
2
3import (
4 "bytes"
5 "context"
6 "embed"
7 "errors"
8 "math/big"
9 "time"
10
11 "github.com/Yiling-J/theine-go"
12 "github.com/cometbft/cometbft/crypto"
13 cmtlog "github.com/cometbft/cometbft/libs/log"
14 "github.com/consensys/gnark-crypto/ecc"
15 "github.com/consensys/gnark-crypto/ecc/bn254"
16 "github.com/consensys/gnark-crypto/ecc/bn254/fr/mimc"
17 "github.com/consensys/gnark/backend/groth16"
18 "github.com/consensys/gnark/backend/witness"
19 "github.com/consensys/gnark/constraint"
20 "github.com/consensys/gnark/frontend"
21 gnarklogger "github.com/consensys/gnark/logger"
22 "github.com/gbl08ma/stacktrace"
23 "github.com/rs/zerolog"
24 "github.com/samber/lo"
25 "resenje.org/singleflight"
26 "tangled.org/gbl08ma.com/didplcbft/proof"
27 "tangled.org/gbl08ma.com/didplcbft/store"
28 "tangled.org/gbl08ma.com/didplcbft/transaction"
29)
30
31//go:embed proofcircuit/BlockChallenge_*
32var blockChallengeCircuitFS embed.FS
33
34var blockChallengeConstraintSystem constraint.ConstraintSystem
35var blockChallengeProvingKey groth16.ProvingKey
36var blockChallengeVerifyingKey groth16.VerifyingKey
37
38func init() {
39 blockChallengeConstraintSystem = groth16.NewCS(ecc.BN254)
40 csFile := lo.Must(blockChallengeCircuitFS.Open("proofcircuit/BlockChallenge_ConstraintSystem"))
41 defer csFile.Close()
42 lo.Must(blockChallengeConstraintSystem.ReadFrom(csFile))
43
44 blockChallengeProvingKey = groth16.NewProvingKey(ecc.BN254)
45 pkFile := lo.Must(blockChallengeCircuitFS.Open("proofcircuit/BlockChallenge_ProvingKey"))
46 defer pkFile.Close()
47 lo.Must(blockChallengeProvingKey.ReadFrom(pkFile))
48
49 blockChallengeVerifyingKey = groth16.NewVerifyingKey(ecc.BN254)
50 vkFile := lo.Must(blockChallengeCircuitFS.Open("proofcircuit/BlockChallenge_VerifyingKey"))
51 defer vkFile.Close()
52 lo.Must(blockChallengeVerifyingKey.ReadFrom(vkFile))
53
54 gnarklogger.Set(zerolog.Nop())
55}
56
57type blockChallengeCoordinator struct {
58 g singleflight.Group[int64, []byte]
59
60 runnerContext context.Context
61 logger cmtlog.Logger
62
63 isConfiguredToBeValidator bool
64 validatorAddress []byte
65 txFactory *transaction.Factory
66 blockHeaderGetter store.BlockHeaderGetter
67
68 sharedWitnessDataCache *theine.LoadingCache[int64, proof.BlockChallengeCircuit]
69}
70
71func newBlockChallengeCoordinator(runnerContext context.Context, logger cmtlog.Logger, txFactory *transaction.Factory, headerGetter store.BlockHeaderGetter, pubKey crypto.PubKey) (*blockChallengeCoordinator, error) {
72 c := &blockChallengeCoordinator{
73 runnerContext: runnerContext,
74 logger: logger,
75 txFactory: txFactory,
76 blockHeaderGetter: headerGetter,
77 isConfiguredToBeValidator: pubKey != nil,
78 }
79 if c.isConfiguredToBeValidator {
80 c.validatorAddress = pubKey.Address()
81 }
82
83 var err error
84 c.sharedWitnessDataCache, err = theine.NewBuilder[int64, proof.BlockChallengeCircuit](5).
85 Loading(
86 func(ctx context.Context, height int64) (theine.Loaded[proof.BlockChallengeCircuit], error) {
87 var zeroValue theine.Loaded[proof.BlockChallengeCircuit]
88
89 anyTx := ctx.Value(contextTxKey{})
90 if anyTx == nil {
91 return zeroValue, stacktrace.NewError("transaction not found in context")
92 }
93 tx, ok := anyTx.(transaction.Read)
94 if !ok {
95 return zeroValue, stacktrace.NewError("invalid transaction in context")
96 }
97
98 operationData, lastCommitHash, err := c.blockChallengeOperationDataForHeight(tx, height)
99 if err != nil {
100 return zeroValue, stacktrace.Propagate(err)
101 }
102
103 sharedPart := buildBlockChallengeCircuitAssignmentShared(lastCommitHash, [proof.OperationDataLength]byte(operationData))
104
105 return theine.Loaded[proof.BlockChallengeCircuit]{
106 Value: sharedPart,
107 }, nil
108 }).Build()
109 if err != nil {
110 return nil, stacktrace.Propagate(err)
111 }
112
113 return c, nil
114}
115
116type contextTxKey struct{}
117
118// in challengeManager method arguments, `height` is always the height of the "current" block C which uses data from block C-1:
119// - the consensus tree from block C-1
120// - the lastCommitHash from block C-1
121// to solve/prove the challenge for block C, which gets stored as the challenge for block C
122// C might be the height of the block that is currently still being prepared/proposed/voted on
123// the proofs should be stable regardless of how many proposal rounds we go through, because they only use data from C-1
124
125func (c *blockChallengeCoordinator) notifyOfIncomingBlockHeight(height int64) {
126 if !c.isConfiguredToBeValidator {
127 return
128 }
129 go func() {
130 _, err := c.loadOrComputeBlockChallengeProof(c.runnerContext, height)
131 if err != nil {
132 c.logger.Error("failed to compute block challenge", "height", height, "error", stacktrace.Propagate(err))
133 }
134 }()
135}
136
137func (c *blockChallengeCoordinator) loadOrComputeBlockChallengeProof(ctx context.Context, height int64) ([]byte, error) {
138 if !c.isConfiguredToBeValidator {
139 return nil, stacktrace.NewError("node is not configured to be a validator")
140 }
141 proof, _, err := c.g.Do(ctx, height, func(ctx context.Context) ([]byte, error) {
142 // we need to read the tree as it was on the block prior
143 tx, err := c.txFactory.ReadHeight(time.Now(), height-1)
144 if err != nil {
145 return nil, stacktrace.Propagate(err)
146 }
147
148 proof, err := store.Consensus.BlockChallengeProof(tx, uint64(height))
149 if err != nil {
150 return nil, stacktrace.Propagate(err)
151 }
152 if proof == nil {
153 st := time.Now()
154 // compute and store
155 proof, err = c.computeBlockChallengeProof(tx, height)
156 if err != nil {
157 return nil, stacktrace.Propagate(err)
158 }
159
160 wtx, err := tx.UpgradeForIndexOnly()
161 if err != nil {
162 return nil, stacktrace.Propagate(err)
163 }
164 defer wtx.Rollback()
165
166 err = store.Consensus.StoreBlockChallengeProof(wtx, uint64(height), proof)
167 if err != nil {
168 return nil, stacktrace.Propagate(err)
169 }
170
171 err = wtx.Commit()
172 if err != nil {
173 return nil, stacktrace.Propagate(err)
174 }
175
176 c.logger.Debug("computed and stored block challenge", "height", height, "took", time.Since(st))
177 }
178 return proof, nil
179 })
180 return proof, stacktrace.Propagate(err)
181}
182
183func (c *blockChallengeCoordinator) computeBlockChallengeProof(tx transaction.Read, height int64) ([]byte, error) {
184 witness, err := c.buildPrivateChallengeWitnessForHeight(tx, height)
185 if err != nil {
186 return nil, stacktrace.Propagate(err)
187 }
188 proof, err := groth16.Prove(blockChallengeConstraintSystem, blockChallengeProvingKey, witness)
189 if err != nil {
190 return nil, stacktrace.Propagate(err)
191 }
192
193 buf := new(bytes.Buffer)
194
195 _, err = proof.WriteTo(buf)
196 if err != nil {
197 return nil, stacktrace.Propagate(err)
198 }
199
200 return buf.Bytes(), nil
201}
202
203var bn254IDScalarField = bn254.ID.ScalarField()
204
205var errInvalidBlockChallengeProof = errors.New("invalid block challenge proof")
206
207func (c *blockChallengeCoordinator) verifyBlockChallengeProof(height int64, validatorAddress []byte, proofBytes []byte) error {
208 // timestamp shouldn't matter for this
209 // it is however important that we read the tree exactly as it was on the height prior to the one where the proof was supposedly generated
210 // this is because operations can change over time (nullification) and also the returned data for the highest operation indexes will be different
211 tx, err := c.txFactory.ReadHeight(time.Time{}, height-1)
212 if err != nil {
213 return stacktrace.Propagate(err)
214 }
215
216 sharedPart, err := c.fetchOrBuildBlockChallengeCircuitAssignmentShared(tx, height)
217 if err != nil {
218 return stacktrace.Propagate(err)
219 }
220
221 assignment := buildBlockChallengeCircuitAssignmentFull(sharedPart, validatorAddress)
222
223 witness, err := frontend.NewWitness(assignment, bn254IDScalarField)
224 if err != nil {
225 return stacktrace.Propagate(err)
226 }
227
228 publicWitness, err := witness.Public()
229 if err != nil {
230 return stacktrace.Propagate(err)
231 }
232
233 proof := groth16.NewProof(bn254.ID)
234
235 _, err = proof.ReadFrom(bytes.NewBuffer(proofBytes))
236 if err != nil {
237 return stacktrace.Propagate(errors.Join(errInvalidBlockChallengeProof, err))
238 }
239
240 err = groth16.Verify(proof, blockChallengeVerifyingKey, publicWitness)
241 if err != nil {
242 return stacktrace.Propagate(errors.Join(errInvalidBlockChallengeProof, err))
243 }
244 return nil
245}
246
247func (c *blockChallengeCoordinator) buildPrivateChallengeWitnessForHeight(tx transaction.Read, height int64) (witness.Witness, error) {
248 sharedPart, err := c.fetchOrBuildBlockChallengeCircuitAssignmentShared(tx, height)
249 if err != nil {
250 return nil, stacktrace.Propagate(err)
251 }
252
253 assignment := buildBlockChallengeCircuitAssignmentFull(sharedPart, c.validatorAddress)
254
255 witness, err := frontend.NewWitness(assignment, bn254IDScalarField)
256 return witness, stacktrace.Propagate(err)
257}
258
259func (c *blockChallengeCoordinator) blockChallengeOperationDataForHeight(tx transaction.Read, height int64) ([]byte, []byte, error) {
260 if height <= 1 {
261 return make([]byte, proof.OperationDataLength), make([]byte, 32), nil
262 }
263
264 blockHeader, err := c.blockHeaderGetter(height - 1)
265 if err != nil {
266 return nil, nil, stacktrace.Propagate(err)
267 }
268 lastCommitHash := blockHeader.LastCommitHash
269 lastCommitHashBigInt := new(big.Int).SetBytes(lastCommitHash)
270
271 highestOp, err := tx.CountOperations()
272 if err != nil {
273 return nil, nil, stacktrace.Propagate(err)
274 }
275
276 initialOpDataLen := proof.OperationDataLength + int(lastCommitHash[0])
277 operationData := make([]byte, initialOpDataLen)
278 operationDataCursor := 0
279 if highestOp > 0 {
280 startOpIdxBigInt := new(big.Int).Mod(lastCommitHashBigInt, new(big.Int).SetUint64(highestOp-1))
281 startOpIdx := startOpIdxBigInt.Uint64()
282 // the starting operation sequence is startOpIdx+1
283 // because operations sequences start at 1 but the result of the modulus is (0, n(
284 // but that's ok because the OperationsIterator cursor parameter is "after" (i.e. the lower bound is exclusive)
285 // so we indeed want a 0-indexed parameter
286 for _, rawOpValue := range store.Consensus.OperationsIterator(tx, startOpIdx, &err) {
287 operationDataCursor += copy(operationData[operationDataCursor:], rawOpValue)
288 if operationDataCursor >= initialOpDataLen {
289 break
290 }
291 }
292 if err != nil {
293 return nil, nil, stacktrace.Propagate(err)
294 }
295 }
296
297 return operationData[lastCommitHash[0]:], lastCommitHash, nil
298}
299
300func (c *blockChallengeCoordinator) fetchOrBuildBlockChallengeCircuitAssignmentShared(tx transaction.Read, height int64) (proof.BlockChallengeCircuit, error) {
301 ctx := context.WithValue(c.runnerContext, contextTxKey{}, tx)
302 v, err := c.sharedWitnessDataCache.Get(ctx, height)
303 return v, stacktrace.Propagate(err)
304}
305
306func buildBlockChallengeCircuitAssignmentShared(lastCommitHash []byte, data [proof.OperationDataLength]byte) proof.BlockChallengeCircuit {
307 var assignment proof.BlockChallengeCircuit
308
309 if len(lastCommitHash) > 31 {
310 lastCommitHash = lastCommitHash[len(lastCommitHash)-31:]
311 }
312 assignment.LastCommitHash = lastCommitHash
313
314 for i := 0; i < len(assignment.OperationData); i++ {
315 d := data[31*i : 31*(i+1)]
316 assignment.OperationData[i] = d
317 }
318
319 return assignment
320}
321
322func buildBlockChallengeCircuitAssignmentFull(shared proof.BlockChallengeCircuit, validatorAddress []byte) *proof.BlockChallengeCircuit {
323 h := mimc.NewMiMC()
324
325 h.Write(validatorAddress)
326 h.Write(shared.LastCommitHash.([]byte))
327
328 for i := 0; i < len(shared.OperationData); i++ {
329 h.Write(shared.OperationData[i].([]byte))
330 }
331
332 shared.ValidatorAddress = validatorAddress
333 shared.SpecificHash = h.Sum(nil)
334
335 return &shared
336}