diff --git a/README.md b/README.md index 6281e810..5515864d 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ The service is configured via environment variables: | `READ_TIMEOUT` | HTTP read timeout | `30s` | | `WRITE_TIMEOUT` | HTTP write timeout | `30s` | | `IDLE_TIMEOUT` | HTTP idle timeout | `120s` | +| `DEFAULT_REQUEST_TTL` | Lifetime assigned to requests that omit `expiresAt` | `1h` | | `CONCURRENCY_LIMIT` | Max concurrent requests | `1000` | | `ENABLE_DOCS` | Enable /docs endpoint | `true` | | `ENABLE_CORS` | Enable CORS headers | `true` | @@ -293,8 +294,15 @@ type CertificationData struct { SourceStateHash SourceStateHash `json:"sourceStateHash"` // TransactionHash is the raw 32-byte hash of the transaction data. + // It commits to ExpiresAt, so changing the deadline invalidates the witness. TransactionHash TransactionHash `json:"transactionHash"` + // ExpiresAt is the exclusive certification request deadline in Unix seconds, + // or null when the requester left the deadline to the service. It occupies a + // fixed position in the encoding either way. When absent, the service derives + // a deadline from consensus time and DEFAULT_REQUEST_TTL. + ExpiresAt *uint64 `json:"expiresAt"` + // Witness is the "unlocking part" of owner predicate. In case of PayToPublicKey owner predicate the witness must be // a signature created on the hash of CBOR array[SourceStateHash, TransactionHash], // in Unicity's [R || S || V] format (65 bytes). @@ -322,6 +330,8 @@ type CertificationData struct { - `INVALID_SOURCE_STATE_HASH_FORMAT` - SourceStateHash is not exactly 32 bytes - `INVALID_TRANSACTION_HASH_FORMAT` - TransactionHash is not exactly 32 bytes - `INVALID_SHARD` - The certification request was sent to the wrong shard +- `REQUEST_EXPIRED` - The round reference time has reached the request's exclusive deadline +- `SERVICE_NOT_READY` - Consensus reference time is not yet available #### `get_inclusion_proof.v2` Retrieve the v2 inclusion proof for a submitted certification request. @@ -350,17 +360,20 @@ The `stateId` must be exactly 64 hex characters (32 raw bytes). } ``` -The `result` field is a hex-encoded CBOR array: +The `result` field is a hex-encoded CBOR array whose proof is tagged. Every inclusion proof carries +the reference time at which its leaf was created, independently of request-deadline policy: ``` -[blockNumber, [certificationData, certificateBytes, unicityCertificate]] +[blockNumber, #39033([1, certificationData, referenceTime, certificateBytes, unicityCertificate])] ``` - `certificationData` is the certification data for inclusion proofs, or `null` for non-inclusion proofs. +- `referenceTime` is the round time fixed when the leaf was created. - For inclusion proofs, `certificateBytes` is the binary inclusion certificate: `bitmap[32] || sibling_1[32] || ... || sibling_n[32]`, where `n = popcount(bitmap)`. Siblings are in root-to-leaf order. For non-inclusion proofs, `certificateBytes` is an exclusion certificate: `k_l[32] || h_l[32] || bitmap[32] || siblings...` (exclusion proof generation is not yet implemented). - The expected SMT root is always taken from `UC.IR.h` (input record hash of the Unicity Certificate). No root field appears in the certificate itself. **Hash rules (Yellowpaper-aligned):** -- Leaf: `H(0x00 || key || value)` where value is the raw transaction hash bytes +- Value: `SHA-256(CBOR([transactionHash, referenceTime]))` for every inclusion proof +- Leaf: `H(0x00 || key || value)` - Inner node (two children): `H(0x01 || depth_byte || left || right)` - Inner node (one child): passthrough (child hash unchanged) @@ -467,8 +480,10 @@ Retrieve all certification requests included in a specific block. "publicKey": "027c4fdf89e8138b360397a7285ca99b863499d26f3c1652251fcf680f4d64882c", "signature": "65ed0261e093aa2df02c0e8fb0aa46144e053ea705ce7053023745b3626c60550b2a5e90eacb93416df116af96872547608a31de1f8ef25dc5a79104e6b69c8d00", "sourceStateHash": "539cb40d7450fa842ac13f4ea50a17e56c5b1ee544257d46b6ec8bb48a63e647", - "transactionHash": "c5f9a1f02e6475c599449250bb741b49bd8858afe8a42059ac1522bff47c6297" + "transactionHash": "c5f9a1f02e6475c599449250bb741b49bd8858afe8a42059ac1522bff47c6297", + "expiresAt": 1755003600 }, + "referenceTime": 1755000000, "blockNumber": "123", "leafIndex": "0", "createdAt": "1734435600000", diff --git a/examples/client/main.go b/examples/client/main.go index 5c960754..9efb96a6 100644 --- a/examples/client/main.go +++ b/examples/client/main.go @@ -105,6 +105,8 @@ func createValidCertificationRequest() *api.CertificationRequest { OwnerPredicate: ownerPredicate, SourceStateHash: stateHash, TransactionHash: transactionHash, + // ExpiresAt is omitted so the service derives the deadline from consensus + // time, which needs no clock on this side. } if err := signingService.SignCertData(certData, privateKey.Serialize()); err != nil { panic(fmt.Sprintf("Failed to sign certification request data: %v", err)) diff --git a/internal/bft/client.go b/internal/bft/client.go index 97012be8..a0e4aca1 100644 --- a/internal/bft/client.go +++ b/internal/bft/client.go @@ -93,8 +93,14 @@ type ( RoundManager interface { FinalizeBlock(ctx context.Context, block *models.Block) error FinalizeBlockWithRetry(ctx context.Context, block *models.Block) error - StartNewRound(ctx context.Context, roundNumber *api.BigInt) error - StartNextRoundFromPrecollector(ctx context.Context, roundNumber *api.BigInt) error + // StartNewRound and StartNextRoundFromPrecollector take the reference + // time the new round must pin: the seal timestamp of the certificate + // that ended the previous round. The round builds every leaf value from + // it and reports it as the input record timestamp, so it cannot be + // re-read later without risking disagreement with leaves already + // inserted. + StartNewRound(ctx context.Context, roundNumber *api.BigInt, referenceTime uint64) error + StartNextRoundFromPrecollector(ctx context.Context, roundNumber *api.BigInt, referenceTime uint64) error CommittedRoot(ctx context.Context) ([]byte, *api.BigInt, error) } @@ -476,7 +482,7 @@ func (c *BFTClientImpl) handleUnicityCertificate(ctx context.Context, uc *types. c.logger.WithContext(ctx).Info("Starting new round after repeat UC", "nextRoundNumber", nextRoundNumber.String()) - if err := c.roundManager.StartNewRound(ctx, api.NewBigInt(nextRoundNumber)); err != nil { + if err := c.roundManager.StartNewRound(ctx, api.NewBigInt(nextRoundNumber), referenceTimeFromUC(uc)); err != nil { rollbackInitialization() return err } @@ -519,7 +525,7 @@ func (c *BFTClientImpl) handleUnicityCertificate(ctx context.Context, uc *types. c.logger.WithContext(ctx).Info("Durable proposal finalized from initialization UC", "ucRound", uc.GetRoundNumber(), "nextRoundNumber", nextRoundNumber.String()) - err := c.roundManager.StartNewRound(ctx, api.NewBigInt(nextRoundNumber)) + err := c.roundManager.StartNewRound(ctx, api.NewBigInt(nextRoundNumber), referenceTimeFromUC(uc)) if err != nil { c.logger.WithContext(ctx).Error("Failed to start first round after durable proposal recovery", "nextRoundNumber", nextRoundNumber.String(), @@ -542,7 +548,7 @@ func (c *BFTClientImpl) handleUnicityCertificate(ctx context.Context, uc *types. completeInitialization() return nil } - err = c.roundManager.StartNewRound(ctx, api.NewBigInt(nextRoundNumber)) + err = c.roundManager.StartNewRound(ctx, api.NewBigInt(nextRoundNumber), referenceTimeFromUC(uc)) if err != nil { c.logger.WithContext(ctx).Error("Failed to start first round after initialization", "nextRoundNumber", nextRoundNumber.String(), @@ -581,7 +587,7 @@ func (c *BFTClientImpl) handleUnicityCertificate(ctx context.Context, uc *types. // Start new round immediately with root chain's next round c.logger.WithContext(ctx).Info("Starting new round to sync with root chain", "nextRoundNumber", nextRoundNumber.String()) - err := c.roundManager.StartNewRound(ctx, api.NewBigInt(nextRoundNumber)) + err := c.roundManager.StartNewRound(ctx, api.NewBigInt(nextRoundNumber), referenceTimeFromUC(uc)) if err != nil { c.logger.WithContext(ctx).Error("Failed to start new round for sync", "nextRoundNumber", nextRoundNumber.String(), @@ -628,7 +634,7 @@ func (c *BFTClientImpl) handleUnicityCertificate(ctx context.Context, uc *types. c.logger.WithContext(ctx).Info("Durable proposal finalized from UC", "ucRound", expectedRound, "nextRoundNumber", nextRoundNumber.String()) - err := c.roundManager.StartNewRound(ctx, api.NewBigInt(nextRoundNumber)) + err := c.roundManager.StartNewRound(ctx, api.NewBigInt(nextRoundNumber), referenceTimeFromUC(uc)) if err != nil { c.logger.WithContext(ctx).Error("Failed to start next round after durable proposal recovery", "nextRoundNumber", nextRoundNumber.String(), @@ -647,7 +653,7 @@ func (c *BFTClientImpl) handleUnicityCertificate(ctx context.Context, uc *types. // 3. The root chain advanced without us sending a certification request // Start the next round directly - err := c.roundManager.StartNewRound(ctx, api.NewBigInt(nextRoundNumber)) + err := c.roundManager.StartNewRound(ctx, api.NewBigInt(nextRoundNumber), referenceTimeFromUC(uc)) if err != nil { c.logger.WithContext(ctx).Error("Failed to start next round", "nextRoundNumber", nextRoundNumber.String(), @@ -724,7 +730,7 @@ func (c *BFTClientImpl) handleUnicityCertificate(ctx context.Context, uc *types. c.logger.WithContext(ctx).Info("Resumed durable proposal finalized, starting new round", "nextRoundNumber", nextRoundNumber.String()) - err = c.roundManager.StartNewRound(ctx, api.NewBigInt(nextRoundNumber)) + err = c.roundManager.StartNewRound(ctx, api.NewBigInt(nextRoundNumber), referenceTimeFromUC(uc)) if err != nil { c.logger.WithContext(ctx).Error("Failed to start new round", "nextRoundNumber", nextRoundNumber.String(), @@ -752,7 +758,7 @@ func (c *BFTClientImpl) handleUnicityCertificate(ctx context.Context, uc *types. c.logger.WithContext(ctx).Info("Block finalized, starting new round", "nextRoundNumber", nextRoundNumber.String()) - err = c.roundManager.StartNextRoundFromPrecollector(ctx, api.NewBigInt(nextRoundNumber)) + err = c.roundManager.StartNextRoundFromPrecollector(ctx, api.NewBigInt(nextRoundNumber), referenceTimeFromUC(uc)) if err != nil { c.logger.WithContext(ctx).Error("Failed to start new round", "nextRoundNumber", nextRoundNumber.String(), @@ -856,14 +862,14 @@ func (c *BFTClientImpl) resumeDurableProposalLocked(ctx context.Context, roundNu c.proposedBlock = block c.resumedDurableProposal = true c.certRequestTime.Store(time.Now().UnixNano()) - if err := c.sendCertificationRequest(ctx, block.RootHash.String(), block.Index.Uint64()); err != nil { + if err := c.sendCertificationRequest(ctx, block.RootHash.String(), block.Index.Uint64(), block.ReferenceTime); err != nil { metrics.BFTErrorsTotal.Inc() return true, fmt.Errorf("failed to resend durable proposal %s: %w", block.Index.String(), err) } return true, nil } -func (c *BFTClientImpl) sendCertificationRequest(ctx context.Context, rootHash string, roundNumber uint64) error { +func (c *BFTClientImpl) sendCertificationRequest(ctx context.Context, rootHash string, roundNumber uint64, referenceTime uint64) error { rootHashBytes, err := hex.DecodeString(rootHash) if err != nil { return fmt.Errorf("failed to decode root hash: %w", err) @@ -874,7 +880,7 @@ func (c *BFTClientImpl) sendCertificationRequest(ctx context.Context, rootHash s return fmt.Errorf("failed to prepare certification request: %w", err) } - inputRecord, err := c.buildCertificationInputRecord(luc, rootHashBytes, roundNumber) + inputRecord, err := c.buildCertificationInputRecord(luc, rootHashBytes, roundNumber, referenceTime) if err != nil { return err } @@ -915,10 +921,27 @@ func (c *BFTClientImpl) sendCertificationRequest(ctx context.Context, rootHash s return bftNetwork.Send(ctx, req, rootIDs...) } -func (c *BFTClientImpl) buildCertificationInputRecord(luc *types.UnicityCertificate, rootHashBytes []byte, roundNumber uint64) (*types.InputRecord, error) { +// referenceTimeFromUC returns the reference time a round following uc must pin: +// the timestamp of the seal that certified the previous round. +func referenceTimeFromUC(uc *types.UnicityCertificate) uint64 { + if uc == nil || uc.UnicitySeal == nil { + return 0 + } + return uc.UnicitySeal.Timestamp +} + +func (c *BFTClientImpl) buildCertificationInputRecord(luc *types.UnicityCertificate, rootHashBytes []byte, roundNumber uint64, referenceTime uint64) (*types.InputRecord, error) { if luc == nil || luc.InputRecord == nil || luc.UnicitySeal == nil { return nil, errors.New("latest UC is incomplete") } + // The round built its leaf values from referenceTime. Re-reading the latest + // seal here instead would certify a root the leaves do not correspond to + // whenever a repeat certificate arrived mid-round. + if referenceTime != luc.UnicitySeal.Timestamp { + return nil, fmt.Errorf("%w: round reference time %d does not match latest seal timestamp %d", + ErrStaleCertificationRound, + referenceTime, luc.UnicitySeal.Timestamp) + } var blockHash []byte if !bytes.Equal(rootHashBytes, luc.InputRecord.Hash) { @@ -937,7 +960,7 @@ func (c *BFTClientImpl) buildCertificationInputRecord(luc *types.UnicityCertific PreviousHash: luc.InputRecord.Hash, Hash: rootHashBytes, SummaryValue: []byte{}, // cant be nil if RoundNumber > 0 - Timestamp: luc.UnicitySeal.Timestamp, + Timestamp: referenceTime, BlockHash: blockHash, SumOfEarnedFees: 0, ETHash: nil, // can be nil, not validated @@ -1026,7 +1049,7 @@ func (c *BFTClientImpl) CertificationRequest(ctx context.Context, block *models. "blockNumber", block.Index.String(), "roundNumber", block.Index.Uint64()) - if err := c.sendCertificationRequest(ctx, block.RootHash.String(), block.Index.Uint64()); err != nil { + if err := c.sendCertificationRequest(ctx, block.RootHash.String(), block.Index.Uint64(), block.ReferenceTime); err != nil { c.logger.WithContext(ctx).Error("Failed to send certification request", "blockNumber", block.Index.String(), "error", err.Error()) diff --git a/internal/bft/client_stub.go b/internal/bft/client_stub.go index 221be396..fae27a44 100644 --- a/internal/bft/client_stub.go +++ b/internal/bft/client_stub.go @@ -25,6 +25,10 @@ type BFTClientStub struct { cancel context.CancelFunc stopped bool wg sync.WaitGroup + // referenceTime stands in for the seal timestamp a real BFT Core would + // return: it advances by one per stub round, so successive rounds pin + // distinct, increasing reference times as they do against a live core. + referenceTime uint64 } func NewBFTClientStub(logger *logger.Logger, roundManager RoundManager, nextRoundNumber *api.BigInt, delay time.Duration) *BFTClientStub { @@ -34,6 +38,7 @@ func NewBFTClientStub(logger *logger.Logger, roundManager RoundManager, nextRoun roundManager: roundManager, nextRoundNumber: nextRoundNumber, delay: delay, + referenceTime: uint64(time.Now().Unix()), } } @@ -45,7 +50,14 @@ func (n *BFTClientStub) Start(ctx context.Context) error { n.cancel = cancel n.stopped = false n.mu.Unlock() - return n.roundManager.StartNewRound(stubCtx, n.nextRoundNumber) + return n.roundManager.StartNewRound(stubCtx, n.nextRoundNumber, n.nextReferenceTime()) +} + +// nextReferenceTime returns the reference time the next stub round pins. +func (n *BFTClientStub) nextReferenceTime() uint64 { + n.mu.Lock() + defer n.mu.Unlock() + return n.referenceTime } func (n *BFTClientStub) Stop() { @@ -80,14 +92,24 @@ func (n *BFTClientStub) CertificationRequest(ctx context.Context, block *models. if len(block.UnicityCertificate) == 0 { // Emit a monotonic synthetic UC so child-mode freshness checks also work // when the parent runs against the local BFT stub. + // + // The certificate carries the same two timestamps a live core returns: + // the input record records the reference time this round's leaves were + // built under, and the seal records the time the next round will pin, + // which is what the stub hands StartNextRoundFromPrecollector below. A + // certificate without them leaves every consumer of this UC, a child + // shard in particular, with no reference time at all, and the service + // then rejects every request as not ready. roundNumber := block.Index.Uint64() uc := types.UnicityCertificate{ InputRecord: &types.InputRecord{ RoundNumber: roundNumber, Hash: hex.Bytes(block.RootHash), + Timestamp: block.ReferenceTime, }, UnicitySeal: &types.UnicitySeal{ RootChainRoundNumber: roundNumber, + Timestamp: block.ReferenceTime + 1, }, } ucBytes, err := types.Cbor.Marshal(uc) @@ -113,12 +135,14 @@ func (n *BFTClientStub) CertificationRequest(ctx context.Context, block *models. if nextCtx == nil { nextCtx = ctx } + n.referenceTime++ + referenceTime := n.referenceTime n.wg.Add(1) n.mu.Unlock() go func() { defer n.wg.Done() - if err := n.roundManager.StartNextRoundFromPrecollector(nextCtx, nextRoundNumber); err != nil { + if err := n.roundManager.StartNextRoundFromPrecollector(nextCtx, nextRoundNumber, referenceTime); err != nil { n.logger.Error("Failed to start next round", "error", err.Error()) } }() diff --git a/internal/bft/client_stub_test.go b/internal/bft/client_stub_test.go index 9c9d7067..e079e367 100644 --- a/internal/bft/client_stub_test.go +++ b/internal/bft/client_stub_test.go @@ -59,6 +59,7 @@ func TestBFTClientStartFailureReturnsToIdleAndCanRetry(t *testing.T) { type stubRoundManager struct { finalizedBlocks []*models.Block finalizeBlockCallCnt int + startedReferenceTimes []uint64 startedRounds []*api.BigInt committedRoot []byte committedBlock *api.BigInt @@ -90,13 +91,14 @@ func (m *stubRoundManager) FinalizeBlockWithRetry(ctx context.Context, block *mo return m.FinalizeBlock(ctx, block) } -func (m *stubRoundManager) StartNewRound(ctx context.Context, roundNumber *api.BigInt) error { +func (m *stubRoundManager) StartNewRound(ctx context.Context, roundNumber *api.BigInt, referenceTime uint64) error { m.startedRounds = append(m.startedRounds, api.NewBigInt(new(big.Int).Set(roundNumber.Int))) + m.startedReferenceTimes = append(m.startedReferenceTimes, referenceTime) return nil } -func (m *stubRoundManager) StartNextRoundFromPrecollector(ctx context.Context, roundNumber *api.BigInt) error { - return m.StartNewRound(ctx, roundNumber) +func (m *stubRoundManager) StartNextRoundFromPrecollector(ctx context.Context, roundNumber *api.BigInt, referenceTime uint64) error { + return m.StartNewRound(ctx, roundNumber, referenceTime) } func (m *stubRoundManager) CommittedRoot(context.Context) ([]byte, *api.BigInt, error) { @@ -147,6 +149,7 @@ func TestBFTClientCertificationRequestDoesNotRewriteBlockNumber(t *testing.T) { api.NewHexBytes(bytes.Repeat([]byte{0x11}, api.SiblingSize)), nil, nil, + testSealTimestamp, ) ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) @@ -182,6 +185,7 @@ func TestBFTClientCertificationRequestRejectsLocalRootMismatch(t *testing.T) { blockRoot, nil, nil, + testSealTimestamp, ) err = client.CertificationRequest(t.Context(), block) @@ -255,6 +259,7 @@ func TestBFTClientCrashAfterProposalBeforeFinalizeWedgesWithoutDurableProposal(t api.NewHexBytes(nextRoot), nil, nil, + testSealTimestamp, ) err = client.CertificationRequest(t.Context(), block) @@ -313,6 +318,7 @@ func TestBFTClientResumedDurableProposalFinalizesWithoutActiveRound(t *testing.T proposalRoot, api.NewHexBytes(committedRoot), nil, + testSealTimestamp, ) proposal.Status = models.FinalityStatusProposed rm := &stubRoundManager{ @@ -365,6 +371,7 @@ func TestBFTClientInitializationResendsDurableProposal(t *testing.T) { proposalRoot, api.NewHexBytes(committedRoot), nil, + testSealTimestamp, ) proposal.Status = models.FinalityStatusProposed rm := &stubRoundManager{ @@ -820,6 +827,7 @@ func TestBFTClientRejectsUCRootMismatch(t *testing.T) { blockRoot, nil, nil, + testSealTimestamp, ) client := &BFTClientImpl{ logger: log, @@ -869,6 +877,7 @@ func TestBFTClientUCRootMismatchPublishesFatalWhenAbandonFails(t *testing.T) { blockRoot, nil, nil, + testSealTimestamp, ) client := &BFTClientImpl{ logger: log, @@ -952,6 +961,7 @@ func TestBFTClientRepeatUCStartsFreshRound(t *testing.T) { proposedRoot, api.NewHexBytes(root), nil, + testSealTimestamp, ) err = client.handleUnicityCertificate(t.Context(), repeatUC, &certification.TechnicalRecord{Round: 55, Epoch: 1}) @@ -987,6 +997,7 @@ func TestBFTClientNewerUCAbandonsStaleProposalAndRecoversDurableProposal(t *test staleRoot, nil, nil, + 1755000000, ), } client.status.Store(normal) @@ -1019,9 +1030,10 @@ func TestBFTClientCertificationInputRecordUsesTechnicalEpoch(t *testing.T) { luc := testUnicityCertificate(7, 12, previousRoot, nil) luc.InputRecord.Epoch = 2 - ir, err := client.buildCertificationInputRecord(luc, newRoot, 8) + ir, err := client.buildCertificationInputRecord(luc, newRoot, 8, luc.UnicitySeal.Timestamp) require.NoError(t, err) + require.EqualValues(t, luc.UnicitySeal.Timestamp, ir.Timestamp) require.EqualValues(t, 8, ir.RoundNumber) require.EqualValues(t, 3, ir.Epoch) require.Equal(t, previousRoot, []byte(ir.PreviousHash)) @@ -1029,6 +1041,20 @@ func TestBFTClientCertificationInputRecordUsesTechnicalEpoch(t *testing.T) { require.Equal(t, newRoot, []byte(ir.BlockHash)) } +func TestBFTClientCertificationInputRecordClassifiesReferenceTimeMismatchAsStale(t *testing.T) { + client := &BFTClientImpl{} + luc := testUnicityCertificate(7, 12, bytes.Repeat([]byte{0x11}, api.SiblingSize), nil) + + _, err := client.buildCertificationInputRecord( + luc, + bytes.Repeat([]byte{0x22}, api.SiblingSize), + 8, + luc.UnicitySeal.Timestamp-1, + ) + + require.ErrorIs(t, err, ErrStaleCertificationRound) +} + func TestBFTClientStub_CertificationRequest_PopulatesSyntheticUC(t *testing.T) { rm := &stubRoundManager{} log, err := logger.New("warn", "json", "", false) @@ -1044,6 +1070,7 @@ func TestBFTClientStub_CertificationRequest_PopulatesSyntheticUC(t *testing.T) { api.HexBytes("0123"), nil, nil, + testSealTimestamp, ) err = client.CertificationRequest(t.Context(), block) @@ -1057,6 +1084,10 @@ func TestBFTClientStub_CertificationRequest_PopulatesSyntheticUC(t *testing.T) { require.EqualValues(t, 7, uc.GetRootRoundNumber()) } +// testSealTimestamp is the seal timestamp every fixture certificate carries; +// a round proposing under it pins the same value as its reference time. +const testSealTimestamp uint64 = 1755000000 + func testUnicityCertificate(round, rootRound uint64, root []byte, previous []byte) *types.UnicityCertificate { if previous == nil { previous = bytes.Repeat([]byte{0x00}, api.SiblingSize) @@ -1070,6 +1101,6 @@ func testUnicityCertificate(round, rootRound uint64, root []byte, previous []byt Hash: root, BlockHash: root, }, - UnicitySeal: &types.UnicitySeal{RootChainRoundNumber: rootRound}, + UnicitySeal: &types.UnicitySeal{RootChainRoundNumber: rootRound, Timestamp: testSealTimestamp}, } } diff --git a/internal/config/config.go b/internal/config/config.go index 64381a63..017eaf5d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -114,6 +114,7 @@ type LoggingConfig struct { // ProcessingConfig holds batch processing configuration type ProcessingConfig struct { BatchLimit int `mapstructure:"batch_limit"` + DefaultRequestTTL time.Duration `mapstructure:"default_request_ttl"` // Consensus-time TTL assigned when a request omits an explicit timeout PrecollectorGracePeriod time.Duration `mapstructure:"precollector_grace_period"` // Extra wait before cutting a precollected round snapshot MaxCommitmentsPerRound int `mapstructure:"max_commitments_per_round"` // Stop waiting once this many commitments collected CollectPhaseDuration time.Duration `mapstructure:"collect_phase_duration"` // Non-child fixed collection window before proposing a round @@ -122,6 +123,13 @@ type ProcessingConfig struct { SkipDuplicateCheck bool `mapstructure:"skip_duplicate_check"` // Skip finalized record lookup on submit } +func (c ProcessingConfig) RequestTTL() time.Duration { + if c.DefaultRequestTTL == 0 { + return time.Hour + } + return c.DefaultRequestTTL +} + // RedisConfig holds Redis connection configuration type RedisConfig struct { Host string `mapstructure:"host"` @@ -382,6 +390,7 @@ func Load() (*Config, error) { }, Processing: ProcessingConfig{ BatchLimit: getEnvIntOrDefault("BATCH_LIMIT", 1000), + DefaultRequestTTL: getEnvDurationOrDefault("DEFAULT_REQUEST_TTL", "1h"), PrecollectorGracePeriod: getEnvDurationOrDefault("PRECOLLECTOR_GRACE_PERIOD", "0s"), MaxCommitmentsPerRound: getEnvIntOrDefault("MAX_COMMITMENTS_PER_ROUND", 20000), CollectPhaseDuration: getEnvDurationOrDefault("COLLECT_PHASE_DURATION", "200ms"), @@ -521,6 +530,10 @@ func (c *Config) Validate() error { if c.Processing.CommitmentStreamBufferSize <= 0 { return fmt.Errorf("COMMITMENT_STREAM_BUFFER_SIZE must be positive") } + if c.Processing.DefaultRequestTTL != 0 && + (c.Processing.DefaultRequestTTL < time.Second || c.Processing.DefaultRequestTTL%time.Second != 0) { + return fmt.Errorf("DEFAULT_REQUEST_TTL must be a positive whole number of seconds") + } if c.Processing.CollectPhaseDuration <= 0 { return fmt.Errorf("COLLECT_PHASE_DURATION must be positive") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3b939c66..008f5f2d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -467,3 +467,13 @@ func TestGetEnvStringSliceOrDefault(t *testing.T) { } }) } + +func TestProcessingConfigRequestTTL(t *testing.T) { + if got := (ProcessingConfig{}).RequestTTL(); got != time.Hour { + t.Fatalf("zero-value request TTL = %s, want 1h", got) + } + configured := ProcessingConfig{DefaultRequestTTL: 90 * time.Minute} + if got := configured.RequestTTL(); got != 90*time.Minute { + t.Fatalf("configured request TTL = %s, want 90m", got) + } +} diff --git a/internal/gateway/docs.go b/internal/gateway/docs.go index 6d5f62bf..a4402133 100644 --- a/internal/gateway/docs.go +++ b/internal/gateway/docs.go @@ -170,12 +170,12 @@ func GenerateDocsHTML() string {
certification_request
-
Submit a state transition certification request to the aggregator. The example below uses a real secp256k1 signature that will pass validation. In the v2 wire format, stateId, transactionHash, and sourceStateHash are raw 32-byte SHA-256 values with no algorithm-prefix bytes.
+
Submit a state transition certification request to the aggregator. The example below uses a real secp256k1 signature that will pass validation. In the v2 wire format, stateId, transactionHash, and sourceStateHash are raw 32-byte SHA-256 values with no algorithm-prefix bytes. The certification data carries expiresAt, an exclusive deadline in Unix seconds that the transaction hash commits to. It occupies a fixed position and is sent as CBOR null when the requester has no clock, in which case the service assigns a deadline from consensus time instead. Either way the request is only inserted in a round whose reference time is strictly below its effective deadline.

Request Parameters

- +
diff --git a/internal/gateway/docs_test.go b/internal/gateway/docs_test.go index f4948fa8..674d64ce 100644 --- a/internal/gateway/docs_test.go +++ b/internal/gateway/docs_test.go @@ -18,7 +18,7 @@ import ( func TestDocumentationExamplePayload(t *testing.T) { // Extract the example payload from the documentation // This is the exact payload shown in the docs - exampleCBOR, err := hex.DecodeString("d9987684015820f6a9010354c4359cbe9d63892684bbff6bd54ef86e9dd98a155a1a32716a0247d998778501d9987883014101582102cbbbe7dc6d51dea5c5fb4d7e7da3416e5914b989c303399f31b51db090981cfa58208126936ae7bcd660a93368a8c83951e01ccbcd4af093769b1cc14f942e2a9ca85820ad8f039daf3827446a0af7ccf31b438aea079440406a07cca7374b52b4e84c2c584177ff8a78a8e59f71d03e687fa14851327babb6a4f5cafcdfc56b16b2267284247734523df030465e249734aab146ae18d65bb3c7aee570c4f53e56e779e0c4200100") + exampleCBOR, err := hex.DecodeString("d9987684015820f6a9010354c4359cbe9d63892684bbff6bd54ef86e9dd98a155a1a32716a0247d998778602d9987883014101582102cbbbe7dc6d51dea5c5fb4d7e7da3416e5914b989c303399f31b51db090981cfa58208126936ae7bcd660a93368a8c83951e01ccbcd4af093769b1cc14f942e2a9ca85820ad8f039daf3827446a0af7ccf31b438aea079440406a07cca7374b52b4e84c2c1a689b2cc0584177ff8a78a8e59f71d03e687fa14851327babb6a4f5cafcdfc56b16b2267284247734523df030465e249734aab146ae18d65bb3c7aee570c4f53e56e779e0c4200100") require.NoError(t, err) // Parse the CBOR @@ -63,6 +63,7 @@ func TestDocumentationExamplePayload(t *testing.T) { OwnerPredicate: certData.OwnerPredicate, SourceStateHash: certData.SourceStateHash, TransactionHash: certData.TransactionHash, + ExpiresAt: certData.ExpiresAt, Witness: certData.Witness, }, } diff --git a/internal/ha/block_syncer.go b/internal/ha/block_syncer.go index a9516a8c..4a6c62de 100644 --- a/internal/ha/block_syncer.go +++ b/internal/ha/block_syncer.go @@ -339,7 +339,7 @@ func replayLeavesForAggregatorRecords(records []*models.AggregatorRecord) ([]smt } leaves = append(leaves, smtbackend.LeafInput{ Key: append([]byte(nil), keyBytes...), - Value: append([]byte(nil), record.CertificationData.TransactionHash...), + Value: api.LeafValue(record.CertificationData.TransactionHash.DataBytes(), record.ReferenceTime), }) } return leaves, nil diff --git a/internal/ha/block_syncer_test.go b/internal/ha/block_syncer_test.go index 4add7563..bf0458bc 100644 --- a/internal/ha/block_syncer_test.go +++ b/internal/ha/block_syncer_test.go @@ -32,6 +32,24 @@ func (m *mockLeaderSelector) IsLeader(_ context.Context) (bool, error) { return m.isLeader.Load(), nil } +func TestReplayLeavesForAggregatorRecordsBindsReferenceTime(t *testing.T) { + const referenceTime uint64 = 1755000000 + txHash := api.ImprintV2(bytesOf(api.StateTreeKeyLengthBytes, 0x22)) + record := &models.AggregatorRecord{ + StateID: api.ImprintV2(bytesOf(api.StateTreeKeyLengthBytes, 0x11)), + ReferenceTime: referenceTime, + CertificationData: models.CertificationData{ + TransactionHash: txHash, + }, + } + + leaves, err := replayLeavesForAggregatorRecords([]*models.AggregatorRecord{record}) + require.NoError(t, err) + require.Len(t, leaves, 1) + require.Equal(t, api.LeafValue(txHash.DataBytes(), referenceTime), leaves[0].Value) + require.NotEqual(t, txHash.DataBytes(), leaves[0].Value) +} + func TestBlockSyncer(t *testing.T) { ctx := t.Context() storage := testutil.SetupTestStorage(t, config.Config{ @@ -221,10 +239,11 @@ func createBlock(t *testing.T, storage *mongodb.Storage, blockNum int64) api.Hex records := make([]*models.AggregatorRecord, len(testCommitments)) proposalID := fmt.Sprintf("proposal-%s", blockNumber.String()) for i, c := range testCommitments { + c.ReferenceTime = 1755000000 path, err := c.StateID.GetPath() require.NoError(t, err) - val, err := c.LeafValue() + val, err := c.LeafValue(1755000000) require.NoError(t, err) leaves[i] = &smt.Leaf{Path: path, Value: val} @@ -253,7 +272,7 @@ func createBlock(t *testing.T, storage *mongodb.Storage, blockNum int64) api.Hex rootHash := api.HexBytes(tmpSMT.GetRootHashRaw()) // persist block - block := models.NewBlock(blockNumber, "unicity", 0, "1.0", "mainnet", rootHash, nil, nil) + block := models.NewBlock(blockNumber, "unicity", 0, "1.0", "mainnet", rootHash, nil, nil, 1755000000) block.Finalized = true // Mark as finalized so GetLatestNumber finds it block.ProposalID = proposalID err = storage.BlockStorage().Store(ctx, block) @@ -313,9 +332,10 @@ func (f *blockSyncerFixture) addBlock(t *testing.T, blockNum int64, commitmentCo for i := 0; i < commitmentCount; i++ { c := testutil.CreateTestCertificationRequest(t, fmt.Sprintf("block_%d_request_%d", blockNum, i)) + c.ReferenceTime = 1755000000 path, err := c.StateID.GetPath() require.NoError(t, err) - value, err := c.LeafValue() + value, err := c.LeafValue(1755000000) require.NoError(t, err) key, err := c.StateID.GetTreeKey() require.NoError(t, err) @@ -332,7 +352,7 @@ func (f *blockSyncerFixture) addBlock(t *testing.T, blockNum int64, commitmentCo } rootHash := api.HexBytes(f.tree.GetRootHashRaw()) - block := models.NewBlock(blockNumber, "unicity", 0, "1.0", "test", rootHash, nil, nil) + block := models.NewBlock(blockNumber, "unicity", 0, "1.0", "test", rootHash, nil, nil, 1755000000) block.Finalized = true block.ProposalID = proposalID require.NoError(t, f.storage.BlockStorage().Store(ctx, block)) diff --git a/internal/models/aggregator_record.go b/internal/models/aggregator_record.go index b5930be2..a862688d 100644 --- a/internal/models/aggregator_record.go +++ b/internal/models/aggregator_record.go @@ -15,10 +15,14 @@ type AggregatorRecord struct { StateID api.StateID `json:"stateId"` CertificationData CertificationData `json:"certificationData"` AggregateRequestCount uint64 `json:"aggregateRequestCount"` - BlockNumber *api.BigInt `json:"blockNumber"` - LeafIndex *api.BigInt `json:"leafIndex"` - ProposalID string `json:"proposalId,omitempty"` - CreatedAt *api.Timestamp `json:"createdAt"` + // ReferenceTime is the reference time of the round this record's leaf was + // created in; a verifier needs it to reproduce the certified leaf value. + ReferenceTime uint64 `json:"referenceTime"` + EffectiveTimeout uint64 `json:"effectiveTimeout,omitempty"` + BlockNumber *api.BigInt `json:"blockNumber"` + LeafIndex *api.BigInt `json:"leafIndex"` + ProposalID string `json:"proposalId,omitempty"` + CreatedAt *api.Timestamp `json:"createdAt"` } // AggregatorRecordBSON represents the BSON version of AggregatorRecord for MongoDB storage @@ -27,6 +31,8 @@ type AggregatorRecordBSON struct { StateID string `bson:"stateId"` CertificationData CertificationDataBSON `bson:"certificationData"` AggregateRequestCount uint64 `bson:"aggregateRequestCount"` + ReferenceTime uint64 `bson:"referenceTime"` + EffectiveTimeout uint64 `bson:"effectiveTimeout,omitempty"` BlockNumber primitive.Decimal128 `bson:"blockNumber"` LeafIndex primitive.Decimal128 `bson:"leafIndex"` ProposalID string `bson:"proposalId,omitempty"` @@ -40,6 +46,8 @@ func NewAggregatorRecord(certRequest *CertificationRequest, blockNumber, leafInd StateID: certRequest.StateID, CertificationData: certRequest.CertificationData, AggregateRequestCount: certRequest.AggregateRequestCount, + ReferenceTime: certRequest.ReferenceTime, + EffectiveTimeout: certRequest.EffectiveTimeout, BlockNumber: blockNumber, LeafIndex: leafIndex, CreatedAt: certRequest.CreatedAt, @@ -65,6 +73,8 @@ func (ar *AggregatorRecord) ToBSON() (*AggregatorRecordBSON, error) { StateID: ar.StateID.String(), CertificationData: ar.CertificationData.ToBSON(), AggregateRequestCount: ar.AggregateRequestCount, + ReferenceTime: ar.ReferenceTime, + EffectiveTimeout: ar.EffectiveTimeout, BlockNumber: blockNumber, LeafIndex: leafIndex, ProposalID: ar.ProposalID, @@ -99,6 +109,8 @@ func (arb *AggregatorRecordBSON) FromBSON() (*AggregatorRecord, error) { StateID: stateID, CertificationData: *certDataBSON, AggregateRequestCount: arb.AggregateRequestCount, + ReferenceTime: arb.ReferenceTime, + EffectiveTimeout: arb.EffectiveTimeout, BlockNumber: blockNumber, LeafIndex: leafIndex, ProposalID: arb.ProposalID, diff --git a/internal/models/block.go b/internal/models/block.go index f7942ac4..ce129a61 100644 --- a/internal/models/block.go +++ b/internal/models/block.go @@ -11,12 +11,15 @@ import ( // Block represents a blockchain block type Block struct { - Index *api.BigInt `json:"index"` - ChainID string `json:"chainId"` - ShardID api.ShardID `json:"shardId"` - Version string `json:"version"` - ForkID string `json:"forkId"` - RootHash api.HexBytes `json:"rootHash"` + Index *api.BigInt `json:"index"` + ChainID string `json:"chainId"` + ShardID api.ShardID `json:"shardId"` + Version string `json:"version"` + ForkID string `json:"forkId"` + RootHash api.HexBytes `json:"rootHash"` + // ReferenceTime is the reference time this round's leaves were built under, + // pinned when the round started and reported as the input record timestamp. + ReferenceTime uint64 `json:"referenceTime"` PreviousBlockHash api.HexBytes `json:"previousBlockHash"` NoDeletionProofHash api.HexBytes `json:"noDeletionProofHash"` CreatedAt *api.Timestamp `json:"createdAt"` @@ -36,6 +39,7 @@ type BlockBSON struct { Version string `bson:"version"` ForkID string `bson:"forkId"` RootHash string `bson:"rootHash"` + ReferenceTime uint64 `bson:"referenceTime"` PreviousBlockHash string `bson:"previousBlockHash"` NoDeletionProofHash string `bson:"noDeletionProofHash,omitempty"` CreatedAt time.Time `bson:"createdAt"` @@ -73,6 +77,7 @@ func (b *Block) ToBSON() (*BlockBSON, error) { Version: b.Version, ForkID: b.ForkID, RootHash: b.RootHash.String(), + ReferenceTime: b.ReferenceTime, PreviousBlockHash: b.PreviousBlockHash.String(), NoDeletionProofHash: b.NoDeletionProofHash.String(), CreatedAt: b.CreatedAt.Time, @@ -127,6 +132,7 @@ func (bb *BlockBSON) FromBSON() (*Block, error) { Version: bb.Version, ForkID: bb.ForkID, RootHash: rootHash, + ReferenceTime: bb.ReferenceTime, PreviousBlockHash: previousBlockHash, NoDeletionProofHash: noDeletionProofHash, CreatedAt: api.NewTimestamp(bb.CreatedAt), @@ -140,7 +146,7 @@ func (bb *BlockBSON) FromBSON() (*Block, error) { } // NewBlock creates a new block -func NewBlock(index *api.BigInt, chainID string, shardID api.ShardID, version, forkID string, rootHash, previousBlockHash, uc api.HexBytes) *Block { +func NewBlock(index *api.BigInt, chainID string, shardID api.ShardID, version, forkID string, rootHash, previousBlockHash, uc api.HexBytes, referenceTime uint64) *Block { return &Block{ Index: index, ChainID: chainID, @@ -148,6 +154,7 @@ func NewBlock(index *api.BigInt, chainID string, shardID api.ShardID, version, f Version: version, ForkID: forkID, RootHash: rootHash, + ReferenceTime: referenceTime, PreviousBlockHash: previousBlockHash, CreatedAt: api.Now(), UnicityCertificate: uc, @@ -155,8 +162,8 @@ func NewBlock(index *api.BigInt, chainID string, shardID api.ShardID, version, f } // NewChildBlock creates a block for child mode with required parent proof metadata. -func NewChildBlock(index *api.BigInt, chainID string, shardID api.ShardID, version, forkID string, rootHash, previousBlockHash, uc api.HexBytes, parentFragment *api.ParentInclusionFragment, parentBlockNumber uint64) *Block { - block := NewBlock(index, chainID, shardID, version, forkID, rootHash, previousBlockHash, uc) +func NewChildBlock(index *api.BigInt, chainID string, shardID api.ShardID, version, forkID string, rootHash, previousBlockHash, uc api.HexBytes, referenceTime uint64, parentFragment *api.ParentInclusionFragment, parentBlockNumber uint64) *Block { + block := NewBlock(index, chainID, shardID, version, forkID, rootHash, previousBlockHash, uc, referenceTime) block.ParentFragment = parentFragment block.ParentBlockNumber = parentBlockNumber return block diff --git a/internal/models/certification_data.go b/internal/models/certification_data.go index baab0b1b..d1e73831 100644 --- a/internal/models/certification_data.go +++ b/internal/models/certification_data.go @@ -12,6 +12,7 @@ type CertificationData struct { OwnerPredicate api.Predicate `json:"ownerPredicate"` SourceStateHash api.SourceStateHash `json:"sourceStateHash"` TransactionHash api.TransactionHash `json:"transactionHash"` + ExpiresAt *uint64 `json:"expiresAt"` Witness api.HexBytes `json:"witness"` } @@ -19,6 +20,7 @@ type CertificationDataBSON struct { OwnerPredicate PredicateBSON `bson:"ownerPredicate"` SourceStateHash string `bson:"sourceStateHash"` TransactionHash string `bson:"transactionHash"` + ExpiresAt *uint64 `bson:"expiresAt"` Witness string `bson:"witness"` } @@ -33,6 +35,7 @@ func (a *CertificationData) ToAPI() *api.CertificationData { OwnerPredicate: a.OwnerPredicate, SourceStateHash: a.SourceStateHash, TransactionHash: a.TransactionHash, + ExpiresAt: a.ExpiresAt, Witness: a.Witness, } } @@ -46,6 +49,7 @@ func (a *CertificationData) ToBSON() CertificationDataBSON { }, SourceStateHash: a.SourceStateHash.String(), TransactionHash: a.TransactionHash.String(), + ExpiresAt: a.ExpiresAt, Witness: a.Witness.String(), } } @@ -72,6 +76,7 @@ func (ab *CertificationDataBSON) FromBSON() (*CertificationData, error) { }, SourceStateHash: sourceStateHash, TransactionHash: transactionHash, + ExpiresAt: ab.ExpiresAt, Witness: signature, }, nil } diff --git a/internal/models/certification_request.go b/internal/models/certification_request.go index 11ef9ddb..004e2277 100644 --- a/internal/models/certification_request.go +++ b/internal/models/certification_request.go @@ -16,9 +16,16 @@ type CertificationRequest struct { StateID api.StateID `json:"stateId"` CertificationData CertificationData `json:"certificationData"` AggregateRequestCount uint64 `json:"aggregateRequestCount"` - CreatedAt *api.Timestamp `json:"createdAt"` - ProcessedAt *api.Timestamp `json:"processedAt,omitempty"` - StreamID string `json:"-"` // Redis stream ID used for stream acknowledgements + // ReferenceTime is the reference time of the round this request's leaf was + // created in. Zero until the round that materialises the leaf pins it. + ReferenceTime uint64 `json:"referenceTime"` + // EffectiveTimeout is the absolute consensus-time deadline used for queue + // admission. It is assigned by the service when CertificationData.ExpiresAt + // is absent, and otherwise repeats that explicit deadline. + EffectiveTimeout uint64 `json:"effectiveTimeout"` + CreatedAt *api.Timestamp `json:"createdAt"` + ProcessedAt *api.Timestamp `json:"processedAt,omitempty"` + StreamID string `json:"-"` // Redis stream ID used for stream acknowledgements } // CertificationRequestBSON represents the BSON version of CertificationRequest for MongoDB storage @@ -29,6 +36,8 @@ type CertificationRequestBSON struct { TransactionHash string `bson:"transactionHash"` CertificationData CertificationDataBSON `bson:"certificationData"` AggregateRequestCount uint64 `bson:"aggregateRequestCount"` + ReferenceTime uint64 `bson:"referenceTime"` + EffectiveTimeout uint64 `bson:"effectiveTimeout,omitempty"` CreatedAt time.Time `bson:"createdAt"` ProcessedAt *time.Time `bson:"processedAt,omitempty"` } @@ -67,6 +76,8 @@ func (c *CertificationRequest) ToBSON() *CertificationRequestBSON { StateID: c.StateID.String(), CertificationData: c.CertificationData.ToBSON(), AggregateRequestCount: c.AggregateRequestCount, + ReferenceTime: c.ReferenceTime, + EffectiveTimeout: c.EffectiveTimeout, CreatedAt: c.CreatedAt.Time, ProcessedAt: processedAt, } @@ -93,6 +104,8 @@ func (cb *CertificationRequestBSON) FromBSON() (*CertificationRequest, error) { StateID: stateID, CertificationData: *certData, AggregateRequestCount: cb.AggregateRequestCount, + ReferenceTime: cb.ReferenceTime, + EffectiveTimeout: cb.EffectiveTimeout, CreatedAt: api.NewTimestamp(cb.CreatedAt), ProcessedAt: processedAt, }, nil @@ -106,9 +119,12 @@ func (c *CertificationRequest) ToAPI() *api.CertificationRequest { } } -func (c *CertificationRequest) LeafValue() ([]byte, error) { +// LeafValue returns the SMT leaf value for this request under the given round +// reference time: H(txhash, referenceTime). The reference time is a property of +// the leaf, not of whichever inclusion proof later establishes it. +func (c *CertificationRequest) LeafValue(referenceTime uint64) ([]byte, error) { if c.Version != 2 { return nil, fmt.Errorf("invalid version: %d", c.Version) } - return append([]byte(nil), c.CertificationData.TransactionHash...), nil + return api.LeafValue(c.CertificationData.TransactionHash.DataBytes(), referenceTime), nil } diff --git a/internal/models/certification_request_leafvalue_test.go b/internal/models/certification_request_leafvalue_test.go index 480a042d..ae421911 100644 --- a/internal/models/certification_request_leafvalue_test.go +++ b/internal/models/certification_request_leafvalue_test.go @@ -8,8 +8,11 @@ import ( "github.com/unicitynetwork/aggregator-go/pkg/api" ) -func TestCertificationRequestLeafValue_V2UsesTransactionHashBytes(t *testing.T) { +// The v2 leaf value binds the round's reference time, so the same request +// yields a different leaf in a different round. +func TestCertificationRequestLeafValue_V2BindsTheReferenceTime(t *testing.T) { txRaw := "11223344556677889900aabbccddeeff00112233445566778899aabbccddeeff" + const referenceTime uint64 = 1755000000 reqRaw := &CertificationRequest{ Version: 2, @@ -17,7 +20,29 @@ func TestCertificationRequestLeafValue_V2UsesTransactionHashBytes(t *testing.T) TransactionHash: api.RequireNewImprintV2(txRaw), }, } - leafRaw, err := reqRaw.LeafValue() + + leafRaw, err := reqRaw.LeafValue(referenceTime) + require.NoError(t, err) + require.Equal(t, api.LeafValue(api.RequireNewImprintV2(txRaw).DataBytes(), referenceTime), leafRaw) + require.NotEqual(t, api.RequireNewImprintV2(txRaw), api.ImprintV2(leafRaw)) + + laterLeaf, err := reqRaw.LeafValue(referenceTime + 1) + require.NoError(t, err) + require.NotEqual(t, leafRaw, laterLeaf) +} + +// Computing a leaf value is pure. The round materialisation path records the +// reference time only after it has admitted the request. +func TestCertificationRequestLeafValue_DoesNotMutateReferenceTime(t *testing.T) { + const txRaw = "11223344556677889900aabbccddeeff00112233445566778899aabbccddeeff" + req := &CertificationRequest{ + Version: 2, + CertificationData: CertificationData{ + TransactionHash: api.RequireNewImprintV2(txRaw), + }, + } + require.Zero(t, req.ReferenceTime) + _, err := req.LeafValue(1755000000) require.NoError(t, err) - require.Equal(t, api.RequireNewImprintV2(txRaw), api.ImprintV2(leafRaw)) + require.Zero(t, req.ReferenceTime) } diff --git a/internal/proofverify/local.go b/internal/proofverify/local.go index 7158bf2e..ee005693 100644 --- a/internal/proofverify/local.go +++ b/internal/proofverify/local.go @@ -38,5 +38,9 @@ func VerifyInclusionProofLocal(p *api.InclusionProofV2, req *api.CertificationRe if err != nil { return fmt.Errorf("failed to derive SMT key: %w", err) } - return cert.Verify(key, req.CertificationData.TransactionHash.DataBytes(), rootRaw, api.InclusionProofV2HashAlgorithm) + if p.ReferenceTime == nil { + return fmt.Errorf("missing inclusion proof reference time") + } + leafValue := api.LeafValue(req.CertificationData.TransactionHash.DataBytes(), *p.ReferenceTime) + return cert.Verify(key, leafValue, rootRaw, api.InclusionProofV2HashAlgorithm) } diff --git a/internal/round/batch_processor.go b/internal/round/batch_processor.go index d0d1d4c8..c3ebcda0 100644 --- a/internal/round/batch_processor.go +++ b/internal/round/batch_processor.go @@ -34,13 +34,28 @@ func (rm *RoundManager) processMiniBatchForRound(ctx context.Context, round *Rou if len(commitments) == 0 { return nil, nil } + if round == nil { + return nil, nil + } // Convert commitments to backend leaf inputs, tracking valid commitments. leaves := make([]smtbackend.LeafInput, 0, len(commitments)) validCommitments := make([]*models.CertificationRequest, 0, len(commitments)) + expired := make([]interfaces.CertificationRequestAck, 0) for _, commitment := range commitments { - leaf, err := commitmentLeafInput(commitment) + leaf, err := commitmentLeafInput(commitment, round.ReferenceTime) if err != nil { + if errors.Is(err, ErrRequestExpired) { + rm.logger.WithContext(ctx).Debug("Dropping expired certification request", + "stateID", commitment.StateID.String(), + "expiresAt", commitment.CertificationData.ExpiresAt, + "referenceTime", round.ReferenceTime) + expired = append(expired, interfaces.CertificationRequestAck{ + StateID: commitment.StateID, + StreamID: commitment.StreamID, + }) + continue + } rm.logger.WithContext(ctx).Error("Failed to create leaf input", "stateID", commitment.StateID.String(), "error", err.Error()) @@ -62,10 +77,10 @@ func (rm *RoundManager) processMiniBatchForRound(ctx context.Context, round *Rou round.PendingLeaves = append(round.PendingLeaves, addedLeaves...) round.PendingCommitments = append(round.PendingCommitments, addedCommitments...) rm.markProofsPending(addedCommitments) - return dropped, nil + return append(expired, dropped...), nil } - return nil, nil + return expired, nil } // ProposeBlock creates and proposes a new block with the given data. @@ -133,6 +148,7 @@ func (rm *RoundManager) proposeBlock(ctx context.Context, round *Round, blockNum rootHash, parentHash, nil, + round.ReferenceTime, ) block.ProposalID = round.ProposalID if err := rm.ensureDurableProposal(ctx, round, block); err != nil { @@ -235,6 +251,7 @@ func (rm *RoundManager) proposeBlock(ctx context.Context, round *Round, blockNum rootHash, parentHash, proof.UnicityCertificate, + round.ReferenceTime, proof.ParentFragment, proof.BlockNumber, ) @@ -255,7 +272,7 @@ func (rm *RoundManager) proposeBlock(ctx context.Context, round *Round, blockNum rm.roundMutex.RUnlock() if cp != nil { - preResult, advErr := rm.advancePrecollectorForHandoff(cp) + preResult, advErr := rm.advancePrecollectorForHandoff(cp, rm.lastReferenceTime()) if advErr == nil { nextRound := api.NewBigInt(nextRoundNumber) if err := validatePrecollectorBlockNumber(preResult, nextRound); err != nil { @@ -270,7 +287,7 @@ func (rm *RoundManager) proposeBlock(ctx context.Context, round *Round, blockNum } // StartNewRoundWithSnapshot atomically checks precollectorDisabled // under roundMutex — no race with concurrent Deactivate. - if err := rm.StartNewRoundWithSnapshot(ctx, nextRound, preResult.snapshot, preResult.commitments, preResult.leaves, preResult.recordsStaged, preResult.proposalID); err != nil { + if err := rm.StartNewRoundWithSnapshot(ctx, nextRound, rm.lastReferenceTime(), preResult.snapshot, preResult.commitments, preResult.leaves, preResult.recordsStaged, preResult.proposalID); err != nil { preResult.snapshot.Discard(ctx) if !errors.Is(err, ErrDeactivated) { rm.logger.WithContext(ctx).Error("Failed to start new round with snapshot.", "error", err.Error()) @@ -278,12 +295,12 @@ func (rm *RoundManager) proposeBlock(ctx context.Context, round *Round, blockNum } } else { rm.logger.WithContext(ctx).Warn("Failed to advance precollector", "error", advErr.Error()) - if err := rm.StartNewRound(ctx, api.NewBigInt(nextRoundNumber)); err != nil && !errors.Is(err, ErrDeactivated) { + if err := rm.StartNewRound(ctx, api.NewBigInt(nextRoundNumber), rm.lastReferenceTime()); err != nil && !errors.Is(err, ErrDeactivated) { rm.logger.WithContext(ctx).Error("Failed to start new round after finalization.", "error", err.Error()) } } } else { - if err := rm.StartNewRound(ctx, api.NewBigInt(nextRoundNumber)); err != nil && !errors.Is(err, ErrDeactivated) { + if err := rm.StartNewRound(ctx, api.NewBigInt(nextRoundNumber), rm.lastReferenceTime()); err != nil && !errors.Is(err, ErrDeactivated) { rm.logger.WithContext(ctx).Error("Failed to start new round after finalization.", "error", err.Error()) } } @@ -1030,12 +1047,14 @@ func (rm *RoundManager) storePrecomputedProofResponses(ctx context.Context, bloc if err != nil { return timing, fmt.Errorf("marshal inclusion cert %d: %w", i, err) } + referenceTime := record.ReferenceTime proofs[i] = smtbackend.PrecomputedProofResponse{ StateID: record.StateID, Response: &api.GetInclusionProofResponseV2{ BlockNumber: responseBlockNumber, InclusionProof: &api.InclusionProofV2{ CertificationData: record.CertificationData.ToAPI(), + ReferenceTime: &referenceTime, CertificateBytes: certBytes, UnicityCertificate: types.RawCBOR(block.UnicityCertificate), }, diff --git a/internal/round/disk_bft_integration_rocksdb_test.go b/internal/round/disk_bft_integration_rocksdb_test.go index 1d77a085..d57c402c 100644 --- a/internal/round/disk_bft_integration_rocksdb_test.go +++ b/internal/round/disk_bft_integration_rocksdb_test.go @@ -275,10 +275,11 @@ func finalizeManualDiskRound( snapshot := testRMSnapshot(t, ctx, rm) rm.roundMutex.Lock() rm.currentRound = &Round{ - Number: api.NewBigIntFromUint64(blockNumber), - State: RoundStateProcessing, - Commitments: commitments, - Snapshot: snapshot, + Number: api.NewBigIntFromUint64(blockNumber), + ReferenceTime: 1755000000, + State: RoundStateProcessing, + Commitments: commitments, + Snapshot: snapshot, } rm.roundMutex.Unlock() @@ -309,6 +310,7 @@ func finalizeManualDiskRound( rootHash, api.HexBytes{}, uc, + 1755000000, ) storeDurableProposalForCurrentRound(t, ctx, rm, block) require.NoError(t, rm.FinalizeBlock(ctx, block)) @@ -345,7 +347,7 @@ func applyMemoryRound( leaves := make([]smtbackend.LeafInput, 0, len(commitments)) validCommitments := make([]*models.CertificationRequest, 0, len(commitments)) for _, commitment := range commitments { - leaf, err := commitmentLeafInput(commitment) + leaf, err := commitmentLeafInput(commitment, 1755000000) if err != nil { continue } diff --git a/internal/round/disk_ha_failover_integration_rocksdb_test.go b/internal/round/disk_ha_failover_integration_rocksdb_test.go index 59eb9d0d..a51dd47f 100644 --- a/internal/round/disk_ha_failover_integration_rocksdb_test.go +++ b/internal/round/disk_ha_failover_integration_rocksdb_test.go @@ -93,7 +93,7 @@ func TestDiskSMTHAFollowerRejectsDivergentFinalizedRoot(t *testing.T) { storage := testutil.SetupTestStorage(t, cfg) commitment := testutil.CreateTestCertificationRequest(t, "disk-ha-divergent-root") - leaf, err := commitmentLeafInput(commitment) + leaf, err := commitmentLeafInput(commitment, 1755000000) require.NoError(t, err) wrongRoot := api.NewHexBytes(make([]byte, api.SiblingSize)) @@ -109,6 +109,7 @@ func TestDiskSMTHAFollowerRejectsDivergentFinalizedRoot(t *testing.T) { wrongRoot, api.HexBytes{}, uc, + 1755000000, ) block.Finalized = true block.Status = models.FinalityStatusFinalized @@ -205,7 +206,7 @@ func requirePublishedProof( t.Helper() reader, ok := backend.(smtbackend.PublishedProofReader) require.True(t, ok) - leaf, err := commitmentLeafInput(commitment) + leaf, err := commitmentLeafInput(commitment, 1755000000) require.NoError(t, err) publishedRoot, err := reader.PublishedRoot(ctx) require.NoError(t, err) diff --git a/internal/round/disk_smt_startup_test.go b/internal/round/disk_smt_startup_test.go index 00c10046..b1d61fd1 100644 --- a/internal/round/disk_smt_startup_test.go +++ b/internal/round/disk_smt_startup_test.go @@ -135,7 +135,7 @@ func TestDiskSMTStartupPaginatedReplay(t *testing.T) { storage := &diskStartupStorage{ blocks: newDiskStartupBlockStorage(diskStartupBlock(1, root1), block2), smt: newDiskStartupSMTStorage(models.NewSmtNode(leaf2.Key, leaf2.Value)), - aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block2, leaf2, 0)), + aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block2, leaf2, 22, 0)), } rm := newDiskStartupRoundManager(t, backend, storage) @@ -181,7 +181,7 @@ func TestDiskSMTStartupPaginatedReplayIgnoresLatestAdvanceDuringReplay(t *testin storage := &diskStartupStorage{ blocks: blockStorage, smt: newDiskStartupSMTStorage(models.NewSmtNode(leaf2.Key, leaf2.Value)), - aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block2, leaf2, 0)), + aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block2, leaf2, 22, 0)), } rm := newDiskStartupRoundManager(t, backend, storage) @@ -214,7 +214,7 @@ func TestDiskSMTStartupPaginatedReplaySkipsMissingRepeatUCRounds(t *testing.T) { storage := &diskStartupStorage{ blocks: newDiskStartupBlockStorage(diskStartupBlock(1, root1), block3), smt: newDiskStartupSMTStorage(models.NewSmtNode(leaf3.Key, leaf3.Value)), - aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block3, leaf3, 0)), + aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block3, leaf3, 33, 0)), } rm := newDiskStartupRoundManager(t, backend, storage) @@ -411,7 +411,7 @@ func TestDiskSMTStartReplaysFinalizedBeforeRecoveringUnfinalizedBlock(t *testing block3, ), smt: newDiskStartupSMTStorage(models.NewSmtNode(leaf2.Key, leaf2.Value), models.NewSmtNode(leaf3.Key, leaf3.Value)), - aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block2, leaf2, 0), diskStartupAggregatorRecord(block3, leaf3, 0)), + aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block2, leaf2, 22, 0), diskStartupAggregatorRecord(block3, leaf3, 33, 0)), } rm := newDiskStartupRoundManager(t, backend, storage) rm.commitmentQueue = &diskStartupCommitmentQueue{} @@ -440,7 +440,7 @@ func TestDiskSMTStartFinalizesAlreadyCommittedUnfinalizedBlock(t *testing.T) { storage := &diskStartupStorage{ blocks: newDiskStartupBlockStorage(diskStartupBlock(1, root1), block2), smt: newDiskStartupSMTStorage(models.NewSmtNode(leaf2.Key, leaf2.Value)), - aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block2, leaf2, 0)), + aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block2, leaf2, 22, 0)), } rm := newDiskStartupRoundManager(t, backend, storage) rm.commitmentQueue = &diskStartupCommitmentQueue{} @@ -466,7 +466,7 @@ func TestDiskSMTStartFinalizesAlreadyCommittedFirstBlock(t *testing.T) { storage := &diskStartupStorage{ blocks: newDiskStartupBlockStorage(block), smt: newDiskStartupSMTStorage(models.NewSmtNode(leaf.Key, leaf.Value)), - aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block, leaf, 0)), + aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block, leaf, 11, 0)), } rm := newDiskStartupRoundManager(t, backend, storage) rm.commitmentQueue = &diskStartupCommitmentQueue{} @@ -499,7 +499,7 @@ func TestDiskSMTStartAppliesUnfinalizedFirstBlockFromEmptyDisk(t *testing.T) { storage := &diskStartupStorage{ blocks: newDiskStartupBlockStorage(block), smt: newDiskStartupSMTStorage(models.NewSmtNode(leaf.Key, leaf.Value)), - aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block, leaf, 0)), + aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block, leaf, 11, 0)), } rm := newDiskStartupRoundManager(t, backend, storage) rm.commitmentQueue = &diskStartupCommitmentQueue{} @@ -525,7 +525,7 @@ func TestLoadRecoveredNodesIntoBackendRejectsRecoveredRootMismatch(t *testing.T) block := diskStartupBlock(1, wrongRoot) storage := &diskStartupStorage{ blocks: newDiskStartupBlockStorage(block), - aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block, leaf, 0)), + aggregator: newDiskStartupAggregatorRecordStorage(diskStartupAggregatorRecord(block, leaf, 11, 0)), } rm := newDiskStartupRoundManager(t, backend, storage) @@ -576,9 +576,16 @@ func newDiskStartupRoundManager(t *testing.T, backend smtbackend.Backend, storag func diskStartupLeaf(keyByte, valueByte byte) smtbackend.LeafInput { key := make([]byte, api.StateTreeKeyLengthBytes) key[len(key)-1] = keyByte - value := make([]byte, 32) + return smtbackend.LeafInput{ + Key: key, + Value: api.LeafValue(diskStartupTransactionHash(valueByte).DataBytes(), 1755000000), + } +} + +func diskStartupTransactionHash(valueByte byte) api.TransactionHash { + value := make([]byte, api.StateTreeKeyLengthBytes) value[len(value)-1] = valueByte - return smtbackend.LeafInput{Key: key, Value: value} + return api.TransactionHash(value) } func commitDiskStartupLeaves(t *testing.T, ctx context.Context, backend smtbackend.Backend, blockNumber uint64, leaves []smtbackend.LeafInput) []byte { @@ -605,6 +612,7 @@ func diskStartupBlock(number uint64, root []byte) *models.Block { api.HexBytes(append([]byte(nil), root...)), nil, nil, + 1755000000, ) block.Finalized = true block.Status = models.FinalityStatusFinalized @@ -612,11 +620,12 @@ func diskStartupBlock(number uint64, root []byte) *models.Block { return block } -func diskStartupAggregatorRecord(block *models.Block, leaf smtbackend.LeafInput, leafIndex uint64) *models.AggregatorRecord { +func diskStartupAggregatorRecord(block *models.Block, leaf smtbackend.LeafInput, transactionHashByte byte, leafIndex uint64) *models.AggregatorRecord { return &models.AggregatorRecord{ - StateID: api.StateID(append([]byte(nil), leaf.Key...)), + StateID: api.StateID(append([]byte(nil), leaf.Key...)), + ReferenceTime: 1755000000, CertificationData: models.CertificationData{ - TransactionHash: append([]byte(nil), leaf.Value...), + TransactionHash: diskStartupTransactionHash(transactionHashByte), }, BlockNumber: block.Index, LeafIndex: api.NewBigIntFromUint64(leafIndex), diff --git a/internal/round/factory.go b/internal/round/factory.go index 8de782ac..d2afdab9 100644 --- a/internal/round/factory.go +++ b/internal/round/factory.go @@ -33,6 +33,9 @@ type Manager interface { GetProofReadyBlockByRoot(rootHash api.HexBytes) (*models.Block, bool) GetCachedProofMetadata(stateID api.StateID, rootHash api.HexBytes) (*models.Block, *models.AggregatorRecord, bool) GetProofCacheStats() (pending int, records int, blocks int) + // CurrentReferenceTime reports the reference time a round starting now + // would pin, for fail-fast rejection of already-expired requests. + CurrentReferenceTime() uint64 } // NewManager creates the appropriate round manager based on sharding mode diff --git a/internal/round/finalize_duplicate_test.go b/internal/round/finalize_duplicate_test.go index d3f78e40..9dfb2f4a 100644 --- a/internal/round/finalize_duplicate_test.go +++ b/internal/round/finalize_duplicate_test.go @@ -203,6 +203,7 @@ func (s *FinalizeDuplicateTestSuite) Test1_DuplicateRecovery() { rootHashBytes, api.HexBytes{}, api.HexBytes{}, + 1755000000, ) storeDurableProposalForCurrentRound(t, ctx, rm, block) @@ -262,6 +263,7 @@ func (s *FinalizeDuplicateTestSuite) Test2_NoDuplicates() { rootHashBytes, api.HexBytes{}, api.HexBytes{}, + 1755000000, ) storeDurableProposalForCurrentRound(t, ctx, rm, block) @@ -325,6 +327,7 @@ func (s *FinalizeDuplicateTestSuite) Test3_AllDuplicates() { rootHashBytes, api.HexBytes{}, api.HexBytes{}, + 1755000000, ) storeDurableProposalForCurrentRound(t, ctx, rm, block) @@ -383,6 +386,7 @@ func (s *FinalizeDuplicateTestSuite) Test4_DuplicateBlock() { rootHashBytes, api.HexBytes{}, api.HexBytes{}, + 1755000000, ) // Pre-store the durable proposal (simulating the real pre-certification flow). @@ -456,6 +460,7 @@ func (s *FinalizeDuplicateTestSuite) Test4b_MarkProcessedFailureAfterFinalizatio rootHashBytes, api.HexBytes{}, api.HexBytes{}, + 1755000000, ) storeDurableProposalForCurrentRound(t, ctx, rm, block) @@ -519,6 +524,7 @@ func (s *FinalizeDuplicateTestSuite) Test4c_FinalizeFailureLeavesCertifiedBlockR rootHashBytes, api.HexBytes{}, certBytes, + 1755000000, ) storeDurableProposalForCurrentRound(t, ctx, rm, block) @@ -574,6 +580,7 @@ func (s *FinalizeDuplicateTestSuite) Test5_DuplicateBlockAlreadyFinalized() { rootHashBytes, api.HexBytes{}, api.HexBytes{}, + 1755000000, ) // Pre-store the block as FINALIZED (simulating previous successful attempt except MarkProcessed) @@ -643,9 +650,9 @@ func (s *FinalizeDuplicateTestSuite) Test6_ProposalRecordsMatchPendingCommitment conflictingCommitment := *commitment1 conflictingCommitment.CertificationData.TransactionHash = commitment2.CertificationData.TransactionHash - leafValue1, err := commitment1.LeafValue() + leafValue1, err := commitment1.LeafValue(1755000000) require.NoError(t, err) - leafValueConflict, err := conflictingCommitment.LeafValue() + leafValueConflict, err := conflictingCommitment.LeafValue(1755000000) require.NoError(t, err) require.NotEqual(t, leafValue1, leafValueConflict, "conflicting commitment must produce a different leaf value") @@ -686,6 +693,7 @@ func (s *FinalizeDuplicateTestSuite) Test6_ProposalRecordsMatchPendingCommitment rootHashBytes, api.HexBytes{}, api.HexBytes{}, + 1755000000, ) storeDurableProposalForCurrentRound(t, ctx, rm, block) @@ -734,7 +742,7 @@ func (s *FinalizeDuplicateTestSuite) Test7_FinalizeBlockRejectsRoundNumberMismat rootHashBytes, err := api.NewHexBytesFromString(rootHash) require.NoError(t, err) - block := models.NewBlock(api.NewBigInt(big.NewInt(7)), "unicity", 0, "1.0", "mainnet", rootHashBytes, api.HexBytes{}, api.HexBytes{}) + block := models.NewBlock(api.NewBigInt(big.NewInt(7)), "unicity", 0, "1.0", "mainnet", rootHashBytes, api.HexBytes{}, api.HexBytes{}, 1755000000) err = rm.FinalizeBlock(ctx, block) require.ErrorContains(t, err, "does not match active round") } @@ -767,14 +775,14 @@ func (s *FinalizeDuplicateTestSuite) Test8_DuplicateBlockMustMatchRootAndStateID rootHashBytes, err := api.NewHexBytesFromString(rootHash) require.NoError(t, err) - existing := models.NewBlock(api.NewBigInt(big.NewInt(8)), "unicity", 0, "1.0", "mainnet", api.HexBytes(repeatByte(32, 7)), api.HexBytes{}, api.HexBytes{}) + existing := models.NewBlock(api.NewBigInt(big.NewInt(8)), "unicity", 0, "1.0", "mainnet", api.HexBytes(repeatByte(32, 7)), api.HexBytes{}, api.HexBytes{}, 1755000000) existing.Finalized = true existing.Status = models.FinalityStatusFinalized existing.ProposalID = "proposal-8-existing" require.NoError(t, s.storage.BlockStorage().Store(ctx, existing)) require.NoError(t, s.storage.AggregatorRecordStorage().StoreBatch(ctx, recordsForBlock(commitments[:1], existing))) - block := models.NewBlock(api.NewBigInt(big.NewInt(8)), "unicity", 0, "1.0", "mainnet", rootHashBytes, api.HexBytes{}, api.HexBytes{}) + block := models.NewBlock(api.NewBigInt(big.NewInt(8)), "unicity", 0, "1.0", "mainnet", rootHashBytes, api.HexBytes{}, api.HexBytes{}, 1755000000) err = rm.FinalizeBlock(ctx, block) require.ErrorContains(t, err, "root mismatch") } @@ -800,7 +808,7 @@ func (s *FinalizeDuplicateTestSuite) Test9_EmptyRoundCannotFinalizeChangedRoot() Snapshot: testRMSnapshot(t, ctx, rm), } - block := models.NewBlock(api.NewBigInt(big.NewInt(9)), "unicity", 0, "1.0", "mainnet", api.HexBytes(repeatByte(32, 9)), api.HexBytes{}, api.HexBytes{}) + block := models.NewBlock(api.NewBigInt(big.NewInt(9)), "unicity", 0, "1.0", "mainnet", api.HexBytes(repeatByte(32, 9)), api.HexBytes{}, api.HexBytes{}, 1755000000) err = rm.FinalizeBlock(ctx, block) require.ErrorContains(t, err, "snapshot root") } @@ -809,7 +817,7 @@ func (s *FinalizeDuplicateTestSuite) Test10_FinalizeBlockWithRetryPropagatesCanc ctx, cancel := context.WithCancel(context.Background()) cancel() - block := models.NewBlock(api.NewBigInt(big.NewInt(10)), "unicity", 0, "1.0", "mainnet", api.HexBytes{}, api.HexBytes{}, api.HexBytes{}) + block := models.NewBlock(api.NewBigInt(big.NewInt(10)), "unicity", 0, "1.0", "mainnet", api.HexBytes{}, api.HexBytes{}, api.HexBytes{}, 1755000000) rm := &RoundManager{} err := rm.FinalizeBlockWithRetry(ctx, block) diff --git a/internal/round/leaf_add.go b/internal/round/leaf_add.go index cfb6dbd6..3b4a90b3 100644 --- a/internal/round/leaf_add.go +++ b/internal/round/leaf_add.go @@ -2,6 +2,7 @@ package round import ( "context" + "errors" "fmt" "github.com/unicitynetwork/aggregator-go/internal/logger" @@ -11,15 +12,42 @@ import ( "github.com/unicitynetwork/aggregator-go/internal/storage/interfaces" ) -func commitmentLeafInput(commitment *models.CertificationRequest) (smtbackend.LeafInput, error) { +// ErrRequestExpired reports a request whose timeout the round's reference time +// has already reached. The request can never be inserted in this or any later +// round, so it is acked out of the queue rather than retried. +var ErrRequestExpired = errors.New("certification request expired") + +// commitmentExpired reports whether the request may still be inserted in a +// round with this reference time. The timeout is exclusive. +func commitmentExpired(commitment *models.CertificationRequest, referenceTime uint64) bool { + deadline := commitment.EffectiveTimeout + if deadline == 0 { + // A record recovered from before the effective timeout was assigned falls + // back to the requester's own deadline, when the request carried one. + if commitment.CertificationData.ExpiresAt == nil { + return false + } + deadline = *commitment.CertificationData.ExpiresAt + } + return referenceTime >= deadline +} + +// commitmentLeafInput materialises a commitment's SMT leaf under the round's +// pinned reference time, recording that time on the commitment so the record +// and the served proof report the value the leaf was actually built from. +func commitmentLeafInput(commitment *models.CertificationRequest, referenceTime uint64) (smtbackend.LeafInput, error) { + if commitmentExpired(commitment, referenceTime) { + return smtbackend.LeafInput{}, ErrRequestExpired + } key, err := commitment.StateID.GetTreeKey() if err != nil { return smtbackend.LeafInput{}, err } - leafValue, err := commitment.LeafValue() + leafValue, err := commitment.LeafValue(referenceTime) if err != nil { return smtbackend.LeafInput{}, err } + commitment.ReferenceTime = referenceTime return smtbackend.LeafInput{ Key: append([]byte(nil), key...), Value: append([]byte(nil), leafValue...), diff --git a/internal/round/leaf_add_test.go b/internal/round/leaf_add_test.go new file mode 100644 index 00000000..398fd524 --- /dev/null +++ b/internal/round/leaf_add_test.go @@ -0,0 +1,90 @@ +package round + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/unicitynetwork/aggregator-go/internal/models" + "github.com/unicitynetwork/aggregator-go/pkg/api" +) + +// testExpiresAt is far enough ahead of the fixture reference times that +// only the expiry tests reach it. +const testExpiresAt uint64 = 1755003600 + +func testCommitment(t *testing.T) *models.CertificationRequest { + t.Helper() + return &models.CertificationRequest{ + Version: 2, + StateID: api.RequireNewImprintV2("1111111111111111111111111111111111111111111111111111111111111111"), + CertificationData: models.CertificationData{ + TransactionHash: api.RequireNewImprintV2("2222222222222222222222222222222222222222222222222222222222222222"), + ExpiresAt: ptr(testExpiresAt), + }, + } +} + +// The leaf a round inserts is built from the round's pinned reference time, and +// the commitment records that time so the record and the served proof report +// the value the leaf was actually built from. +func TestCommitmentLeafInputBindsTheRoundReferenceTime(t *testing.T) { + const referenceTime uint64 = 1755000000 + commitment := testCommitment(t) + + leaf, err := commitmentLeafInput(commitment, referenceTime) + require.NoError(t, err) + + require.Equal(t, referenceTime, commitment.ReferenceTime) + require.Equal(t, + api.LeafValue(commitment.CertificationData.TransactionHash.DataBytes(), referenceTime), + leaf.Value) + require.NotEqual(t, commitment.CertificationData.TransactionHash.DataBytes(), leaf.Value) +} + +// A different round produces a different leaf for the same request. +func TestCommitmentLeafInputDiffersAcrossRounds(t *testing.T) { + const referenceTime uint64 = 1755000000 + + first, err := commitmentLeafInput(testCommitment(t), referenceTime) + require.NoError(t, err) + second, err := commitmentLeafInput(testCommitment(t), referenceTime+1) + require.NoError(t, err) + + require.Equal(t, first.Key, second.Key) + require.NotEqual(t, first.Value, second.Value) +} + +func TestServiceAssignedDeadlineStillBindsReferenceTime(t *testing.T) { + const referenceTime uint64 = 1755000000 + commitment := testCommitment(t) + commitment.CertificationData.ExpiresAt = nil + commitment.EffectiveTimeout = referenceTime + 3600 + + leaf, err := commitmentLeafInput(commitment, referenceTime) + require.NoError(t, err) + require.Equal(t, api.LeafValue(commitment.CertificationData.TransactionHash.DataBytes(), referenceTime), leaf.Value) + require.NotEqual(t, commitment.CertificationData.TransactionHash.DataBytes(), leaf.Value) + + _, err = commitmentLeafInput(commitment, commitment.EffectiveTimeout) + require.ErrorIs(t, err, ErrRequestExpired) +} + +// A request may only be inserted in a round whose reference time is strictly +// below its timeout; an expired one is reported so it can be acked out of the +// queue rather than retried forever. +func TestCommitmentLeafInputRejectsAnExpiredRequest(t *testing.T) { + commitment := testCommitment(t) + + _, err := commitmentLeafInput(commitment, testExpiresAt-1) + require.NoError(t, err) + + _, err = commitmentLeafInput(commitment, testExpiresAt) + require.ErrorIs(t, err, ErrRequestExpired) + + _, err = commitmentLeafInput(commitment, testExpiresAt+1) + require.ErrorIs(t, err, ErrRequestExpired) +} + +// ptr returns a pointer to v, for the optional request deadline. +func ptr(v uint64) *uint64 { return &v } diff --git a/internal/round/parent_round_manager.go b/internal/round/parent_round_manager.go index d43c7f32..bbf7915b 100644 --- a/internal/round/parent_round_manager.go +++ b/internal/round/parent_round_manager.go @@ -69,6 +69,34 @@ type ParentRoundManager struct { totalShardUpdates int64 ready atomic.Bool + + // referenceTime is the BFT seal timestamp the next round will pin; see + // RoundManager.referenceTime. + referenceTime atomic.Uint64 +} + +// setReferenceTime records the reference time later rounds will pin. It never +// moves backwards. +func (prm *ParentRoundManager) setReferenceTime(referenceTime uint64) { + for { + current := prm.referenceTime.Load() + if referenceTime <= current { + return + } + if prm.referenceTime.CompareAndSwap(current, referenceTime) { + return + } + } +} + +// lastReferenceTime returns the reference time a round started now would pin. +func (prm *ParentRoundManager) lastReferenceTime() uint64 { + return prm.referenceTime.Load() +} + +// CurrentReferenceTime implements Manager. +func (prm *ParentRoundManager) CurrentReferenceTime() uint64 { + return prm.lastReferenceTime() } const parentRoundRetryDelay = 1 * time.Second @@ -182,13 +210,14 @@ func (prm *ParentRoundManager) SubmitShardRoot(ctx context.Context, update *mode } // StartNewRound begins a new aggregation round (public method for BFT interface) -func (prm *ParentRoundManager) StartNewRound(ctx context.Context, roundNumber *api.BigInt) error { +func (prm *ParentRoundManager) StartNewRound(ctx context.Context, roundNumber *api.BigInt, referenceTime uint64) error { + prm.setReferenceTime(referenceTime) return prm.startNewRound(ctx, roundNumber) } // StartNextRoundFromPrecollector exists to satisfy the BFT RoundManager // interface. Parent mode keeps its existing collect behavior. -func (prm *ParentRoundManager) StartNextRoundFromPrecollector(ctx context.Context, roundNumber *api.BigInt) error { +func (prm *ParentRoundManager) StartNextRoundFromPrecollector(ctx context.Context, roundNumber *api.BigInt, referenceTime uint64) error { prm.roundMutex.Lock() activeCtx := prm.activeCtx if activeCtx == nil { @@ -200,7 +229,7 @@ func (prm *ParentRoundManager) StartNextRoundFromPrecollector(ctx context.Contex prm.roundMutex.Unlock() go func() { defer prm.roundWG.Done() - if err := prm.StartNewRound(activeCtx, roundNumber); err != nil && !errors.Is(err, ErrDeactivated) && !errors.Is(err, context.Canceled) { + if err := prm.StartNewRound(activeCtx, roundNumber, referenceTime); err != nil && !errors.Is(err, ErrDeactivated) && !errors.Is(err, context.Canceled) { prm.logger.WithContext(ctx).Error("Failed to start next parent round", "roundNumber", roundNumber.String(), "error", err.Error()) @@ -383,6 +412,7 @@ func (prm *ParentRoundManager) processRound(ctx context.Context, round *ParentRo parentRootHash, previousBlockHash, nil, + prm.lastReferenceTime(), ) round.Block = block diff --git a/internal/round/precollection_test.go b/internal/round/precollection_test.go index b78f2b8c..22d0f166 100644 --- a/internal/round/precollection_test.go +++ b/internal/round/precollection_test.go @@ -480,7 +480,7 @@ func TestChildPrecollector_CollectsContinuouslyAcrossRound(t *testing.T) { time.Sleep(50 * time.Millisecond) // Advance round — should return both commitments - result, err := cp.AdvanceRound() + result, err := cp.AdvanceRound(1755000000) require.NoError(t, err) assert.Len(t, result.commitments, 2) assert.Len(t, result.leaves, 2) @@ -491,7 +491,7 @@ func TestChildPrecollector_CollectsContinuouslyAcrossRound(t *testing.T) { stream <- c3 time.Sleep(50 * time.Millisecond) - result2, err := cp.AdvanceRound() + result2, err := cp.AdvanceRound(1755000000) require.NoError(t, err) assert.Len(t, result2.commitments, 1) assert.Equal(t, c3, result2.commitments[0]) @@ -517,7 +517,7 @@ func TestChildPrecollector_AdvanceRound_FlushesPendingBatch(t *testing.T) { } time.Sleep(50 * time.Millisecond) - result, err := cp.AdvanceRound() + result, err := cp.AdvanceRound(1755000000) require.NoError(t, err) assert.Len(t, result.commitments, count, "AdvanceRound must flush pending batch") assert.Len(t, result.leaves, count) @@ -556,7 +556,7 @@ func TestChildPrecollector_AdvanceRoundStagesOneShotHandoff(t *testing.T) { } time.Sleep(50 * time.Millisecond) - result, err := cp.AdvanceRound() + result, err := cp.AdvanceRound(1755000000) require.NoError(t, err) require.True(t, result.recordsStaged) require.EqualValues(t, 12, result.blockNumber.Uint64()) @@ -602,7 +602,7 @@ func TestChildPrecollector_AdvanceRound_NoDropAcrossBoundary(t *testing.T) { } time.Sleep(50 * time.Millisecond) - r1, err := cp.AdvanceRound() + r1, err := cp.AdvanceRound(1755000000) require.NoError(t, err) r1Count := len(r1.commitments) @@ -613,7 +613,7 @@ func TestChildPrecollector_AdvanceRound_NoDropAcrossBoundary(t *testing.T) { } time.Sleep(50 * time.Millisecond) - r2, err := cp.AdvanceRound() + r2, err := cp.AdvanceRound(1755000000) require.NoError(t, err) // No commitments should be dropped across the boundary @@ -637,14 +637,14 @@ func TestChildPrecollector_HonorsMaxCommitmentsWithoutConsumingAndDropping(t *te } time.Sleep(100 * time.Millisecond) - result, err := cp.AdvanceRound() + result, err := cp.AdvanceRound(1755000000) require.NoError(t, err) assert.Equal(t, maxPerRound, len(result.commitments), "should honor max per round") // After advance, the overflow should still be in the stream (not consumed and dropped) // The precollector should now collect the remaining 3 for the next round time.Sleep(50 * time.Millisecond) - result2, err := cp.AdvanceRound() + result2, err := cp.AdvanceRound(1755000000) require.NoError(t, err) assert.Equal(t, 3, len(result2.commitments), "overflow should be collected in next round") } @@ -667,7 +667,7 @@ func TestChildPrecollector_ControlMessagesProgressUnderBackpressure(t *testing.T // AdvanceRound should still complete even under backpressure done := make(chan struct{}) go func() { - _, err := cp.AdvanceRound() + _, err := cp.AdvanceRound(1755000000) assert.NoError(t, err) close(done) }() @@ -706,7 +706,7 @@ func TestChildPrecollector_StopCancelsCleanly(t *testing.T) { } // AdvanceRound after stop should fail - _, err := cp.AdvanceRound() + _, err := cp.AdvanceRound(1755000000) assert.Error(t, err) } @@ -721,7 +721,7 @@ func TestChildPrecollector_AdvanceRoundWithNoData(t *testing.T) { defer cp.Stop() // Advance with no data sent - result, err := cp.AdvanceRound() + result, err := cp.AdvanceRound(1755000000) require.NoError(t, err) assert.Empty(t, result.commitments) assert.Empty(t, result.leaves) @@ -744,7 +744,7 @@ func TestChildPrecollector_BatchWithBadLeafFallsBackToOneByOne(t *testing.T) { time.Sleep(50 * time.Millisecond) // Advance to lock in c1 - r1, err := cp.AdvanceRound() + r1, err := cp.AdvanceRound(1755000000) require.NoError(t, err) assert.Len(t, r1.commitments, 1) @@ -757,7 +757,7 @@ func TestChildPrecollector_BatchWithBadLeafFallsBackToOneByOne(t *testing.T) { stream <- c2 time.Sleep(50 * time.Millisecond) - r2, err := cp.AdvanceRound() + r2, err := cp.AdvanceRound(1755000000) require.NoError(t, err) // modified should be rejected, c2 should succeed assert.Len(t, r2.commitments, 1, "only valid commitment should be collected") @@ -782,7 +782,7 @@ func TestChildPrecollector_AdvanceRoundFailsOnSnapshotAddError(t *testing.T) { stream <- testutil.CreateTestCertificationRequest(t, "snapshot_add_error") time.Sleep(50 * time.Millisecond) - result, err := cp.AdvanceRound() + result, err := cp.AdvanceRound(1755000000) require.Error(t, err) require.Nil(t, result) require.ErrorContains(t, err, "snapshot hash mismatch") @@ -860,7 +860,7 @@ func TestPreCollectionReparenting(t *testing.T) { stream <- preCollectedCommitment time.Sleep(50 * time.Millisecond) - result, err := cp.AdvanceRound() + result, err := cp.AdvanceRound(1755000000) require.NoError(t, err) require.Len(t, result.commitments, 1) @@ -1150,7 +1150,7 @@ func TestStartNewRoundWithSnapshot(t *testing.T) { preLeaves := []smtbackend.LeafInput{testLeafInputFromLegacyLeaf(t, leaf)} startTime := time.Now() - err = rm.StartNewRoundWithSnapshot(ctx, api.NewBigInt(big.NewInt(1)), preSnapshot, preCommitments, preLeaves, false, "") + err = rm.StartNewRoundWithSnapshot(ctx, api.NewBigInt(big.NewInt(1)), 1755000000, preSnapshot, preCommitments, preLeaves, false, "") require.NoError(t, err) rm.roundMutex.RLock() @@ -1234,7 +1234,7 @@ func TestStandalonePrecollectorGraceIncludesLateCommitment(t *testing.T) { }() start := time.Now() - require.NoError(t, rm.StartNextRoundFromPrecollector(ctx, api.NewBigInt(big.NewInt(2)))) + require.NoError(t, rm.StartNextRoundFromPrecollector(ctx, api.NewBigInt(big.NewInt(2)), 1755000000)) assert.GreaterOrEqual(t, time.Since(start), cfg.Processing.PrecollectorGracePeriod) rm.roundMutex.RLock() @@ -1337,7 +1337,7 @@ func TestStartNextRoundFromPrecollectorDiscardsFailedPrecollector(t *testing.T) // period gives the collect loop time to pick it up before AdvanceRound. rm.commitmentStream <- testutil.CreateTestCertificationRequest(t, "handoff_failure") - require.NoError(t, rm.StartNextRoundFromPrecollector(ctx, api.NewBigInt(big.NewInt(2)))) + require.NoError(t, rm.StartNextRoundFromPrecollector(ctx, api.NewBigInt(big.NewInt(2)), 1755000000)) rm.roundMutex.RLock() stillBlocking := rm.precollector == cp @@ -1370,7 +1370,7 @@ func TestStartNextRoundFromPrecollectorDiscardsSnapshotOnSetCommitTargetError(t } rm.precollector = cp - err := rm.StartNextRoundFromPrecollector(ctx, api.NewBigIntFromUint64(2)) + err := rm.StartNextRoundFromPrecollector(ctx, api.NewBigIntFromUint64(2), 1755000000) require.ErrorIs(t, err, setTargetErr) require.Equal(t, 1, handoffSnapshot.discardCount) @@ -1587,10 +1587,21 @@ func TestChildPreCollection_CommitmentAfterProofBeforeRoundEnd_ShouldBeInNextRou require.NoError(t, rm.Start(ctx)) require.NoError(t, rm.Activate(ctx)) + // Waiting for block 1 to appear is not enough: finalization and the start of + // round 2 are concurrent, so a commitment injected on that signal alone can + // still reach round 2 and land in block 2. Wait for round 2 to be the + // current round, which is the precondition the assertion below describes. require.Eventually(t, func() bool { block, err := storage.BlockStorage().GetByNumber(ctx, api.NewBigInt(big.NewInt(1))) - return err == nil && block != nil - }, 3*time.Second, 25*time.Millisecond, "block 1 should be finalized before injecting the late commitment") + if err != nil || block == nil { + return false + } + rm.roundMutex.RLock() + defer rm.roundMutex.RUnlock() + return rm.currentRound != nil && + rm.currentRound.Number != nil && + rm.currentRound.Number.Int64() == 2 + }, 3*time.Second, 25*time.Millisecond, "round 2 should have started before injecting the late commitment") lateCommitment := testutil.CreateTestCertificationRequest(t, "after_proof_before_round_end") rm.commitmentStream <- lateCommitment @@ -1645,6 +1656,7 @@ func TestChildMode_RequiresFreshParentProof(t *testing.T) { rootHash, api.HexBytes{}, initialUC, + 1755000000, ) initialBlock.Finalized = true require.NoError(t, storage.BlockStorage().Store(ctx, initialBlock)) diff --git a/internal/round/precollector.go b/internal/round/precollector.go index 58b4afc2..c2922c27 100644 --- a/internal/round/precollector.go +++ b/internal/round/precollector.go @@ -2,6 +2,7 @@ package round import ( "context" + "errors" "fmt" "math/big" "sync" @@ -25,7 +26,10 @@ type preCollectionResult struct { } type advanceRequest struct { - resultCh chan advanceResponse + // referenceTime is the round the collected commitments are being handed to. + // Leaves are materialised only here, because the leaf value binds it. + referenceTime uint64 + resultCh chan advanceResponse } type advanceResponse struct { @@ -110,8 +114,8 @@ func (cp *childPrecollector) Start(ctx context.Context, snapshot smtbackend.Snap // AdvanceRound returns the current round's collected data and internally chains // a new collection from the current snapshot before returning. -func (cp *childPrecollector) AdvanceRound() (*preCollectionResult, error) { - req := advanceRequest{resultCh: make(chan advanceResponse, 1)} +func (cp *childPrecollector) AdvanceRound(referenceTime uint64) (*preCollectionResult, error) { + req := advanceRequest{referenceTime: referenceTime, resultCh: make(chan advanceResponse, 1)} select { case cp.advanceCh <- req: case <-cp.doneCh: @@ -156,6 +160,7 @@ func (cp *childPrecollector) run(ctx context.Context, snapshot smtbackend.Snapsh blockNumber *api.BigInt, proposalID string, rawCommitments []*models.CertificationRequest, + referenceTime uint64, ) precollectorPrepareOutcome { prepareStart := time.Now() outcome := precollectorPrepareOutcome{} @@ -165,7 +170,7 @@ func (cp *childPrecollector) run(ctx context.Context, snapshot smtbackend.Snapsh if len(rawCommitments) > 0 { start := time.Now() var err error - added, addedLeaves, err = cp.addBatch(ctx, snapshot, rawCommitments) + added, addedLeaves, err = cp.addBatch(ctx, snapshot, rawCommitments, referenceTime) elapsed := time.Since(start) outcome.stats.flushCalls = 1 outcome.stats.flushAdded = len(added) @@ -229,8 +234,8 @@ func (cp *childPrecollector) run(ctx context.Context, snapshot smtbackend.Snapsh return outcome } - prepareSynchronously := func() (precollectorPrepareOutcome, error) { - outcome := prepareRound(snapshot, cloneBigInt(cp.blockNumber), cp.proposalID, commitments) + prepareSynchronously := func(referenceTime uint64) (precollectorPrepareOutcome, error) { + outcome := prepareRound(snapshot, cloneBigInt(cp.blockNumber), cp.proposalID, commitments, referenceTime) if outcome.err != nil { return outcome, outcome.err } @@ -269,7 +274,7 @@ func (cp *childPrecollector) run(ctx context.Context, snapshot smtbackend.Snapsh case req := <-cp.advanceCh: advanceStart := time.Now() pendingAtAdvance := len(commitments) - outcome, err := prepareSynchronously() + outcome, err := prepareSynchronously(req.referenceTime) if err != nil { cp.setStopErr(err) req.resultCh <- advanceResponse{err: err} @@ -325,6 +330,7 @@ func (cp *childPrecollector) addBatch( ctx context.Context, snapshot smtbackend.Snapshot, commitments []*models.CertificationRequest, + referenceTime uint64, ) ([]*models.CertificationRequest, []smtbackend.LeafInput, error) { if len(commitments) == 0 { return nil, nil, nil @@ -332,10 +338,22 @@ func (cp *childPrecollector) addBatch( leavesToAdd := make([]smtbackend.LeafInput, 0, len(commitments)) valid := make([]*models.CertificationRequest, 0, len(commitments)) + expired := make([]interfaces.CertificationRequestAck, 0) for _, c := range commitments { - leaf, err := commitmentLeafInput(c) + leaf, err := commitmentLeafInput(c, referenceTime) if err != nil { + if errors.Is(err, ErrRequestExpired) { + cp.logger.WithContext(ctx).Debug("Dropping expired certification request", + "stateID", c.StateID.String(), + "expiresAt", c.CertificationData.ExpiresAt, + "referenceTime", referenceTime) + expired = append(expired, interfaces.CertificationRequestAck{ + StateID: c.StateID, + StreamID: c.StreamID, + }) + continue + } cp.logger.WithContext(ctx).Error("Failed to create leaf input", "stateID", c.StateID.String(), "error", err.Error()) continue @@ -344,6 +362,8 @@ func (cp *childPrecollector) addBatch( valid = append(valid, c) } + ackDroppedCommitments(ctx, cp.logger, cp.commitmentQueue, expired) + if len(leavesToAdd) == 0 { return nil, nil, nil } diff --git a/internal/round/recovery.go b/internal/round/recovery.go index 18ff37e8..3e3cbdc7 100644 --- a/internal/round/recovery.go +++ b/internal/round/recovery.go @@ -295,7 +295,7 @@ func recoverBlock( if err != nil { return nil, fmt.Errorf("failed to get aggregator records: %w", err) } - stateIDs, _, err := stateIDsAndLeavesFromAggregatorRecords(records) + stateIDs, leaves, err := stateIDsAndLeavesFromAggregatorRecords(records) if err != nil { return nil, err } @@ -316,10 +316,10 @@ func recoverBlock( return nil, fmt.Errorf("failed to check existing SMT nodes: %w", err) } - var missingSmtKeys []api.StateID - for i, stateID := range stateIDs { + var missingSmtLeaves []smtbackend.LeafInput + for i := range stateIDs { if !existingSmtKeys[smtKeyStrings[i]] { - missingSmtKeys = append(missingSmtKeys, stateID) + missingSmtLeaves = append(missingSmtLeaves, leaves[i]) } } @@ -328,10 +328,10 @@ func recoverBlock( "existingRecords", len(records), "existingSmtNodes", len(existingSmtKeys), "missingRecords", 0, - "missingSmtNodes", len(missingSmtKeys)) + "missingSmtNodes", len(missingSmtLeaves)) - if len(missingSmtKeys) > 0 { - if err := recoverMissingSMTNodes(ctx, log, storage, commitmentQueue, missingSmtKeys); err != nil { + if len(missingSmtLeaves) > 0 { + if err := recoverMissingSMTNodes(ctx, log, storage, missingSmtLeaves); err != nil { return nil, err } } @@ -408,7 +408,7 @@ func stateIDsAndLeavesFromAggregatorRecords(records []*models.AggregatorRecord) } leaves[i] = smtbackend.LeafInput{ Key: append([]byte(nil), key...), - Value: append([]byte(nil), record.CertificationData.TransactionHash...), + Value: api.LeafValue(record.CertificationData.TransactionHash.DataBytes(), record.ReferenceTime), } } return stateIDs, leaves, nil @@ -461,61 +461,21 @@ func recoverMissingSMTNodes( ctx context.Context, log *logger.Logger, storage interfaces.Storage, - commitmentQueue interfaces.CommitmentQueue, - missingSmtKeys []api.StateID, + missingSmtLeaves []smtbackend.LeafInput, ) error { - if len(missingSmtKeys) == 0 { + if len(missingSmtLeaves) == 0 { return nil } - commitmentMap, err := commitmentQueue.GetByStateIDs(ctx, missingSmtKeys) - if err != nil { - return fmt.Errorf("failed to get commitments: %w", err) + nodes := make([]*models.SmtNode, len(missingSmtLeaves)) + for i, leaf := range missingSmtLeaves { + nodes[i] = models.NewSmtNode(leaf.Key, leaf.Value) } - var nodes []*models.SmtNode - for _, stateID := range missingSmtKeys { - commitment, ok := commitmentMap[stateID.String()] - if !ok { - keyBytes, err := stateID.GetTreeKey() - if err != nil { - return fmt.Errorf("failed to get SMT key for stateID: %w", err) - } - existingNode, err := storage.SmtStorage().GetByKey(ctx, keyBytes) - if err != nil { - return fmt.Errorf("failed to check existing SMT node: %w", err) - } - if existingNode != nil { - continue - } - existingRecord, err := getAggregatorRecordAnyFinalization(ctx, storage, stateID) - if err != nil { - return fmt.Errorf("failed to check existing aggregator record: %w", err) - } - if existingRecord == nil { - return fmt.Errorf("FATAL: durable aggregator record not found for SMT key %s", stateID) - } - nodes = append(nodes, models.NewSmtNode(keyBytes, append([]byte(nil), existingRecord.CertificationData.TransactionHash...))) - continue - } - - keyBytes, err := commitment.StateID.GetTreeKey() - if err != nil { - return fmt.Errorf("failed to get SMT key for commitment: %w", err) - } - leafValue, err := commitment.LeafValue() - if err != nil { - return fmt.Errorf("failed to create leaf value: %w", err) - } - nodes = append(nodes, models.NewSmtNode(keyBytes, leafValue)) - } - - if len(nodes) > 0 { - if err := storage.SmtStorage().StoreBatch(ctx, nodes); err != nil { - return fmt.Errorf("failed to store missing SMT nodes: %w", err) - } - log.WithContext(ctx).Info("Stored missing SMT nodes", "count", len(nodes)) + if err := storage.SmtStorage().StoreBatch(ctx, nodes); err != nil { + return fmt.Errorf("failed to store missing SMT nodes: %w", err) } + log.WithContext(ctx).Info("Stored missing SMT nodes", "count", len(nodes)) return nil } diff --git a/internal/round/recovery_test.go b/internal/round/recovery_test.go index cfc35026..07bde753 100644 --- a/internal/round/recovery_test.go +++ b/internal/round/recovery_test.go @@ -124,6 +124,25 @@ func (s *RecoveryTestSuite) SetupTest() { _ = s.commitmentQueue.Initialize(s.ctx) } +func TestStateIDsAndLeavesFromAggregatorRecordsBindsReferenceTime(t *testing.T) { + const referenceTime uint64 = 1755000000 + stateID := api.ImprintV2(bytes.Repeat([]byte{0x11}, api.StateTreeKeyLengthBytes)) + txHash := api.ImprintV2(bytes.Repeat([]byte{0x22}, api.StateTreeKeyLengthBytes)) + record := &models.AggregatorRecord{ + StateID: stateID, + ReferenceTime: referenceTime, + CertificationData: models.CertificationData{ + TransactionHash: txHash, + }, + } + + stateIDs, leaves, err := stateIDsAndLeavesFromAggregatorRecords([]*models.AggregatorRecord{record}) + require.NoError(t, err) + require.Equal(t, []api.StateID{stateID}, stateIDs) + require.Len(t, leaves, 1) + require.Equal(t, api.LeafValue(txHash.DataBytes(), referenceTime), leaves[0].Value) +} + // Helper to create and store test data func (s *RecoveryTestSuite) createTestData(blockNum int64, commitmentCount int, prefix string) ([]*models.CertificationRequest, *models.Block, []api.StateID) { t := s.T() @@ -135,6 +154,7 @@ func (s *RecoveryTestSuite) createTestData(blockNum int64, commitmentCount int, // Create state IDs. stateIDs := make([]api.StateID, len(commitments)) for i, c := range commitments { + c.ReferenceTime = 1755000000 stateIDs[i] = c.StateID } @@ -144,7 +164,7 @@ func (s *RecoveryTestSuite) createTestData(blockNum int64, commitmentCount int, for i, c := range commitments { path, err := c.StateID.GetPath() require.NoError(t, err) - leafValue, err := c.LeafValue() + leafValue, err := c.LeafValue(1755000000) require.NoError(t, err) leaves[i] = smt.NewLeaf(path, leafValue) } @@ -153,7 +173,7 @@ func (s *RecoveryTestSuite) createTestData(blockNum int64, commitmentCount int, rootHashBytes := smtTree.GetRootHashRaw() // Create block (unfinalized) - block := models.NewBlock(blockNumber, "unicity", 0, "1.0", "mainnet", api.HexBytes(rootHashBytes), nil, nil) + block := models.NewBlock(blockNumber, "unicity", 0, "1.0", "mainnet", api.HexBytes(rootHashBytes), nil, nil, 1755000000) block.Finalized = false block.ProposalID = "proposal-" + blockNumber.String() @@ -176,7 +196,7 @@ func (s *RecoveryTestSuite) storeSmtNodes(commitments []*models.CertificationReq for i, c := range commitments { keyBytes, err := c.StateID.GetTreeKey() s.Require().NoError(err) - leafValue, err := c.LeafValue() + leafValue, err := c.LeafValue(1755000000) s.Require().NoError(err) nodes[i] = models.NewSmtNode(api.HexBytes(keyBytes), leafValue) } @@ -580,17 +600,19 @@ func (s *RecoveryTestSuite) Test10_PartialSmtNodes_CorrectDetection() { for i, idx := range existingIndices { keyBytes, err := commitments[idx].StateID.GetTreeKey() require.NoError(t, err) - leafValue, err := commitments[idx].LeafValue() + leafValue, err := commitments[idx].LeafValue(1755000000) require.NoError(t, err) existingNodes[i] = models.NewSmtNode(api.HexBytes(keyBytes), leafValue) } err = s.storage.SmtStorage().StoreBatch(s.ctx, existingNodes) require.NoError(t, err) - // Store ONLY the commitments that need recovery (positions 2 and 3) in Redis + // Store pre-materialization queue copies; the round reference time is assigned later. missingIndices := []int{2, 3} for _, idx := range missingIndices { - err = s.commitmentQueue.Store(s.ctx, commitments[idx]) + preMaterializationCommitment := *commitments[idx] + preMaterializationCommitment.ReferenceTime = 0 + err = s.commitmentQueue.Store(s.ctx, &preMaterializationCommitment) require.NoError(t, err) } time.Sleep(200 * time.Millisecond) @@ -609,6 +631,17 @@ func (s *RecoveryTestSuite) Test10_PartialSmtNodes_CorrectDetection() { smtCountAfter, err := s.storage.SmtStorage().Count(s.ctx) require.NoError(t, err) require.Equal(t, int64(5), smtCountAfter, "Should have 5 SMT nodes after recovery") + for _, idx := range missingIndices { + keyBytes, err := commitments[idx].StateID.GetTreeKey() + require.NoError(t, err) + node, err := s.storage.SmtStorage().GetByKey(s.ctx, keyBytes) + require.NoError(t, err) + require.NotNil(t, node) + require.Equal(t, + api.HexBytes(api.LeafValue(commitments[idx].CertificationData.TransactionHash.DataBytes(), commitments[idx].ReferenceTime)), + node.Value, + ) + } t.Log("✓ Test10_PartialSmtNodes_CorrectDetection passed") } @@ -632,7 +665,7 @@ func (s *RecoveryTestSuite) Test11_LoadRecoveredNodesIntoBackend() { for i, c := range commitments { path, err := c.StateID.GetPath() require.NoError(t, err) - leafValue, err := c.LeafValue() + leafValue, err := c.LeafValue(1755000000) require.NoError(t, err) leaves[i] = smt.NewLeaf(path, leafValue) } diff --git a/internal/round/round_manager.go b/internal/round/round_manager.go index 2282de7b..218e6018 100644 --- a/internal/round/round_manager.go +++ b/internal/round/round_manager.go @@ -57,12 +57,17 @@ func (rs RoundState) String() string { // Round represents a single aggregation round type Round struct { - Number *api.BigInt - StartTime time.Time - State RoundState - Commitments []*models.CertificationRequest - Cancel context.CancelFunc - Block *models.Block + Number *api.BigInt + // ReferenceTime is pinned when the round starts and is the only time value + // the round uses: every leaf value is built from it and it is reported as + // the input record timestamp. Reading the latest certificate again at + // proposal time could disagree with the leaves already inserted. + ReferenceTime uint64 + StartTime time.Time + State RoundState + Commitments []*models.CertificationRequest + Cancel context.CancelFunc + Block *models.Block // Track commitments that have been added to SMT but not yet finalized in a block. // Raw 32-byte SMT root (no algorithm-id prefix), matching the V2 wire format. PendingRootHash api.HexBytes @@ -152,6 +157,11 @@ type RoundManager struct { // Child mode tracks the newest parent UC already accepted for finalization. // This prevents empty rounds from immediately reusing an older parent proof. lastAcceptedParentUCRound atomic.Uint64 + // referenceTime is the round reference time most recently learned from the + // certificate chain this aggregator is anchored to: the BFT seal timestamp + // in standalone and bft-shard modes, the parent input record timestamp in + // child mode. A round pins it at start and never re-reads it. + referenceTime atomic.Uint64 // Metrics totalRounds int64 @@ -552,9 +562,9 @@ func (rm *RoundManager) GetStats() map[string]interface{} { } // StartNewRound starts a new round for processing commitments (delegates to unified function) -func (rm *RoundManager) StartNewRound(ctx context.Context, roundNumber *api.BigInt) error { +func (rm *RoundManager) StartNewRound(ctx context.Context, roundNumber *api.BigInt, referenceTime uint64) error { rm.roundMutex.Lock() - supersededSnapshot, resetPendingSweep, unresolved := rm.abandonSupersededRoundLocked(roundNumber) + supersededSnapshot, resetPendingSweep, unresolved := rm.abandonSupersededRoundLocked(roundNumber, referenceTime) rm.roundMutex.Unlock() if supersededSnapshot != nil { supersededSnapshot.Discard(ctx) @@ -566,7 +576,7 @@ func (rm *RoundManager) StartNewRound(ctx context.Context, roundNumber *api.BigI rm.resetRedisPendingSweep(ctx) } } - return rm.StartNewRoundWithSnapshot(ctx, roundNumber, nil, nil, nil, false, "") + return rm.StartNewRoundWithSnapshot(ctx, roundNumber, referenceTime, nil, nil, nil, false, "") } // StartNewRoundWithSnapshot starts a new round, optionally with pre-collected data. @@ -579,6 +589,7 @@ var ErrDeactivated = fmt.Errorf("round manager deactivated") func (rm *RoundManager) StartNewRoundWithSnapshot( ctx context.Context, roundNumber *api.BigInt, + referenceTime uint64, snapshot smtbackend.Snapshot, commitments []*models.CertificationRequest, leaves []smtbackend.LeafInput, @@ -618,7 +629,8 @@ func (rm *RoundManager) StartNewRoundWithSnapshot( "previousRoundNumber", currentRoundNumberString, "previousRoundState", currentRoundState.String(), "previousRoundAge", currentRoundAge.String()) - if currentRoundNumber != nil && currentRoundNumber.Cmp(roundNumber.Int) == 0 { + if currentRoundNumber != nil && currentRoundNumber.Cmp(roundNumber.Int) == 0 && + rm.currentRound.ReferenceTime == referenceTime { retryRound := rm.currentRound shouldRetry := retryRound.State == RoundStateFinalizing && !retryRound.ProposalTime.IsZero() rm.roundMutex.Unlock() @@ -666,7 +678,7 @@ func (rm *RoundManager) StartNewRoundWithSnapshot( leaves = make([]smtbackend.LeafInput, 0) } - supersededSnapshot, resetPendingSweep, _ := rm.abandonSupersededRoundLocked(roundNumber) + supersededSnapshot, resetPendingSweep, _ := rm.abandonSupersededRoundLocked(roundNumber, referenceTime) if resetPendingSweep { rm.signalRedisPendingSweepReset() } @@ -675,8 +687,11 @@ func (rm *RoundManager) StartNewRoundWithSnapshot( proposalID = uuid.NewString() } + rm.setReferenceTime(referenceTime) + round := &Round{ Number: roundNumber, + ReferenceTime: referenceTime, StartTime: time.Now(), State: RoundStateProcessing, Commitments: commitments, @@ -788,14 +803,26 @@ func (rm *RoundManager) retryRoundProposal(ctx context.Context, round *Round) er // superseded by a newer one and returns its unproposed snapshot (or nil) for the // caller to discard after releasing roundMutex. Proof-pending markers owned by // unresolved superseded commitments are removed without disturbing newer markers. -func (rm *RoundManager) abandonSupersededRoundLocked(roundNumber *api.BigInt) (smtbackend.Snapshot, bool, bool) { +// abandonSupersededRoundLocked drops the current round when a later round is +// starting, or when the same round is restarting under a different reference +// time. The second case matters because the round's leaves are built from the +// reference time: a repeat certificate carrying a new seal timestamp makes the +// leaves already inserted unusable, so the round has to be re-collected rather +// than proposed under a timestamp its leaves do not match. +func (rm *RoundManager) abandonSupersededRoundLocked(roundNumber *api.BigInt, referenceTime uint64) (smtbackend.Snapshot, bool, bool) { if rm.currentRound == nil { return nil, false, false } if roundNumber == nil || roundNumber.Int == nil || - rm.currentRound.Number == nil || - rm.currentRound.Number.Cmp(roundNumber.Int) >= 0 { + rm.currentRound.Number == nil { + return nil, false, false + } + order := rm.currentRound.Number.Cmp(roundNumber.Int) + if order > 0 { + return nil, false, false + } + if order == 0 && rm.currentRound.ReferenceTime == referenceTime { return nil, false, false } unresolved := rm.currentRound.Block == nil @@ -1308,7 +1335,7 @@ func (rm *RoundManager) Activate(ctx context.Context) error { "latestBlock", latestBlockNumber, "nextRound", roundNumber.String()) - if err := rm.StartNewRound(activeCtx, api.NewBigInt(roundNumber)); err != nil { + if err := rm.StartNewRound(activeCtx, api.NewBigInt(roundNumber), rm.lastReferenceTime()); err != nil { return fmt.Errorf("failed to start new round: %w", err) } default: @@ -1341,6 +1368,9 @@ func (rm *RoundManager) restoreLastAcceptedParentUC(ctx context.Context) error { } rm.lastAcceptedParentUCRound.Store(parentUC.GetRoundNumber()) + if parentUC.InputRecord != nil { + rm.setReferenceTime(parentUC.InputRecord.Timestamp) + } rm.logger.WithContext(ctx).Info("Restored latest accepted parent UC from child storage", "childBlockNumber", latestBlock.Index.String(), "parentRound", parentUC.GetRoundNumber()) @@ -1353,6 +1383,34 @@ func (rm *RoundManager) acceptParentUC(parentUC *types.UnicityCertificate) { return } rm.lastAcceptedParentUCRound.Store(parentUC.GetRoundNumber()) + if parentUC.InputRecord != nil { + rm.setReferenceTime(parentUC.InputRecord.Timestamp) + } +} + +// setReferenceTime records the reference time later rounds will pin. It never +// moves backwards: a stale certificate arriving out of order must not undo a +// newer one. +func (rm *RoundManager) setReferenceTime(referenceTime uint64) { + for { + current := rm.referenceTime.Load() + if referenceTime <= current { + return + } + if rm.referenceTime.CompareAndSwap(current, referenceTime) { + return + } + } +} + +// lastReferenceTime returns the reference time a round started now would pin. +func (rm *RoundManager) lastReferenceTime() uint64 { + return rm.referenceTime.Load() +} + +// CurrentReferenceTime implements Manager. +func (rm *RoundManager) CurrentReferenceTime() uint64 { + return rm.lastReferenceTime() } func (rm *RoundManager) lastAcceptedParentUC() uint64 { @@ -1544,8 +1602,8 @@ func (rm *RoundManager) collectMiniBatchSize() int { return rm.config.Processing.CollectMiniBatchSize } -func (rm *RoundManager) advancePrecollectorForHandoff(cp *childPrecollector) (*preCollectionResult, error) { - return cp.AdvanceRound() +func (rm *RoundManager) advancePrecollectorForHandoff(cp *childPrecollector, referenceTime uint64) (*preCollectionResult, error) { + return cp.AdvanceRound(referenceTime) } func validatePrecollectorBlockNumber(preResult *preCollectionResult, roundNumber *api.BigInt) error { @@ -1567,9 +1625,9 @@ func validatePrecollectorBlockNumber(preResult *preCollectionResult, roundNumber // StartNextRoundFromPrecollector starts the next standalone/bft-shard round // from the active precollector snapshot. If precollection is disabled or no // precollector is available, it falls back to the fixed collect path. -func (rm *RoundManager) StartNextRoundFromPrecollector(ctx context.Context, roundNumber *api.BigInt) error { +func (rm *RoundManager) StartNextRoundFromPrecollector(ctx context.Context, roundNumber *api.BigInt, referenceTime uint64) error { if !rm.usesActivePrecollector() { - return rm.StartNewRound(ctx, roundNumber) + return rm.StartNewRound(ctx, roundNumber, referenceTime) } rm.roundMutex.RLock() @@ -1600,11 +1658,11 @@ func (rm *RoundManager) StartNextRoundFromPrecollector(ctx context.Context, roun return nil } if cp == nil { - return rm.StartNewRound(ctx, roundNumber) + return rm.StartNewRound(ctx, roundNumber, referenceTime) } advanceStart := time.Now() - preResult, err := rm.advancePrecollectorForHandoff(cp) + preResult, err := rm.advancePrecollectorForHandoff(cp, referenceTime) advanceDuration := time.Since(advanceStart) if err != nil { rm.roundMutex.RLock() @@ -1621,7 +1679,7 @@ func (rm *RoundManager) StartNextRoundFromPrecollector(ctx context.Context, roun // can start. StartNewRound only discards when abandoning a pending // round, which is not the case on the post-finalization fallback path. rm.discardActivePrecollector(ctx) - return rm.StartNewRound(ctx, roundNumber) + return rm.StartNewRound(ctx, roundNumber, referenceTime) } if err := validatePrecollectorBlockNumber(preResult, roundNumber); err != nil { @@ -1632,7 +1690,7 @@ func (rm *RoundManager) StartNextRoundFromPrecollector(ctx context.Context, roun preResult.snapshot.Discard(ctx) return fmt.Errorf("failed to set precollector commit target: %w", err) } - if err := rm.StartNewRoundWithSnapshot(ctx, roundNumber, preResult.snapshot, preResult.commitments, preResult.leaves, preResult.recordsStaged, preResult.proposalID); err != nil { + if err := rm.StartNewRoundWithSnapshot(ctx, roundNumber, referenceTime, preResult.snapshot, preResult.commitments, preResult.leaves, preResult.recordsStaged, preResult.proposalID); err != nil { if errors.Is(err, ErrDeactivated) { return nil } diff --git a/internal/round/round_process_regression_test.go b/internal/round/round_process_regression_test.go index f254c025..258d3a45 100644 --- a/internal/round/round_process_regression_test.go +++ b/internal/round/round_process_regression_test.go @@ -118,7 +118,7 @@ func TestRoundProcessingUsesScheduledRoundSnapshot(t *testing.T) { recorder := newRecordingBFTClient() rm.bftClient = recorder - require.NoError(t, rm.StartNewRound(ctx, api.NewBigInt(big.NewInt(1)))) + require.NoError(t, rm.StartNewRound(ctx, api.NewBigInt(big.NewInt(1)), 1755000000)) roundOneCommitment := testutil.CreateTestCertificationRequest(t, "scheduled_round_one") rm.commitmentStream <- roundOneCommitment @@ -131,7 +131,7 @@ func TestRoundProcessingUsesScheduledRoundSnapshot(t *testing.T) { }, time.Second, 10*time.Millisecond) roundTwoCommitment := testutil.CreateTestCertificationRequest(t, "scheduled_round_two") - roundTwoLeaf, err := commitmentLeafInput(roundTwoCommitment) + roundTwoLeaf, err := commitmentLeafInput(roundTwoCommitment, 1755000000) require.NoError(t, err) roundTwoSnapshot, err := rm.smtBackend.CreateSnapshot(ctx) require.NoError(t, err) @@ -143,7 +143,7 @@ func TestRoundProcessingUsesScheduledRoundSnapshot(t *testing.T) { require.NoError(t, rm.StartNewRoundWithSnapshot( ctx, - api.NewBigInt(big.NewInt(2)), + api.NewBigInt(big.NewInt(2)), 1755000000, roundTwoSnapshot, []*models.CertificationRequest{roundTwoCommitment}, []smtbackend.LeafInput{roundTwoLeaf}, @@ -244,7 +244,7 @@ func TestStartNewRoundAbandonsSupersededPendingRound(t *testing.T) { } rm.markProofsPending([]*models.CertificationRequest{commitment}) - require.NoError(t, rm.StartNewRound(ctx, api.NewBigInt(big.NewInt(2)))) + require.NoError(t, rm.StartNewRound(ctx, api.NewBigInt(big.NewInt(2)), 1755000000)) require.Equal(t, 1, discardSpy.discards) select { @@ -372,7 +372,7 @@ func TestStartNewRoundWithSnapshotAbandonAfterSnapshotCleanupResetsRedisPendingS rm.markProofsPending([]*models.CertificationRequest{oldCommitment}) newCommitment := testutil.CreateTestCertificationRequest(t, "new_precollected_round") - newLeaf, err := commitmentLeafInput(newCommitment) + newLeaf, err := commitmentLeafInput(newCommitment, 1755000000) require.NoError(t, err) newSnapshot, err := rm.smtBackend.CreateSnapshot(ctx) require.NoError(t, err) @@ -383,7 +383,7 @@ func TestStartNewRoundWithSnapshotAbandonAfterSnapshotCleanupResetsRedisPendingS require.NoError(t, rm.StartNewRoundWithSnapshot( ctx, - api.NewBigInt(big.NewInt(2)), + api.NewBigInt(big.NewInt(2)), 1755000000, newSnapshot, []*models.CertificationRequest{newCommitment}, []smtbackend.LeafInput{newLeaf}, @@ -457,7 +457,7 @@ func TestStartNewRoundWithSnapshotDoesNotReplayFinalizedRoundHistory(t *testing. } newCommitment := testutil.CreateTestCertificationRequest(t, "precollected_round_pending_marker") - newLeaf, err := commitmentLeafInput(newCommitment) + newLeaf, err := commitmentLeafInput(newCommitment, 1755000000) require.NoError(t, err) newSnapshot, err := rm.smtBackend.CreateSnapshot(ctx) require.NoError(t, err) @@ -468,7 +468,7 @@ func TestStartNewRoundWithSnapshotDoesNotReplayFinalizedRoundHistory(t *testing. require.NoError(t, rm.StartNewRoundWithSnapshot( ctx, - api.NewBigInt(big.NewInt(2)), + api.NewBigInt(big.NewInt(2)), 1755000000, newSnapshot, []*models.CertificationRequest{newCommitment}, []smtbackend.LeafInput{newLeaf}, @@ -521,7 +521,7 @@ func TestStaleCertificationRequestAbandonsStoredDurableProposal(t *testing.T) { }() commitment := testutil.CreateTestCertificationRequest(t, "stale_durable_proposal") - leaf, err := commitmentLeafInput(commitment) + leaf, err := commitmentLeafInput(commitment, 1755000000) require.NoError(t, err) snapshot, err := rm.smtBackend.CreateSnapshot(ctx) require.NoError(t, err) @@ -531,7 +531,7 @@ func TestStaleCertificationRequestAbandonsStoredDurableProposal(t *testing.T) { require.NoError(t, rm.StartNewRoundWithSnapshot( ctx, - api.NewBigInt(big.NewInt(1)), + api.NewBigInt(big.NewInt(1)), 1755000000, snapshot, []*models.CertificationRequest{commitment}, []smtbackend.LeafInput{leaf}, @@ -598,7 +598,7 @@ func TestStartNewRoundRetriesEqualFinalizingRoundProposal(t *testing.T) { }() commitment := testutil.CreateTestCertificationRequest(t, "repeat_uc_equal_round_retry") - leaf, err := commitmentLeafInput(commitment) + leaf, err := commitmentLeafInput(commitment, 1755000000) require.NoError(t, err) snapshot := testRMSnapshot(t, ctx, rm) result, err := snapshot.AddLeavesClassified(ctx, []smtbackend.LeafInput{leaf}) @@ -609,6 +609,7 @@ func TestStartNewRoundRetriesEqualFinalizingRoundProposal(t *testing.T) { rm.currentRound = &Round{ Number: api.NewBigInt(big.NewInt(7)), + ReferenceTime: 1755000000, StartTime: time.Now(), State: RoundStateFinalizing, Commitments: []*models.CertificationRequest{commitment}, @@ -619,7 +620,7 @@ func TestStartNewRoundRetriesEqualFinalizingRoundProposal(t *testing.T) { ProposalTime: time.Now(), } - require.NoError(t, rm.StartNewRound(ctx, api.NewBigInt(big.NewInt(7)))) + require.NoError(t, rm.StartNewRound(ctx, api.NewBigInt(big.NewInt(7)), 1755000000)) require.Eventually(t, func() bool { return len(recorder.snapshot()) == 1 @@ -671,6 +672,7 @@ func TestProposeBlockLinksToLatestFinalizedBlockAcrossRoundGap(t *testing.T) { parentRoot, nil, nil, + 1755000000, ) block1.Finalized = true require.NoError(t, storage.BlockStorage().Store(ctx, block1)) diff --git a/internal/round/smt_persistence_integration_test.go b/internal/round/smt_persistence_integration_test.go index 9381ee09..5959a29a 100644 --- a/internal/round/smt_persistence_integration_test.go +++ b/internal/round/smt_persistence_integration_test.go @@ -221,6 +221,7 @@ func TestCompleteWorkflowWithRestart(t *testing.T) { rootHashBytes, api.HexBytes{}, nil, + 1755000000, ) storeDurableProposalForCurrentRound(t, ctx, rm, block) diff --git a/internal/service/service.go b/internal/service/service.go index 7fc9bba9..584fe4d0 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "strconv" "time" @@ -163,10 +164,12 @@ func (as *AggregatorService) CertificationRequest(ctx context.Context, req *api. OwnerPredicate: req.CertificationData.OwnerPredicate, SourceStateHash: req.CertificationData.SourceStateHash, TransactionHash: req.CertificationData.TransactionHash, + ExpiresAt: req.CertificationData.ExpiresAt, Witness: req.CertificationData.Witness, }, aggregateCount) - // Validate certificationRequest signature and state ID + // Validate certificationRequest signature and state ID before assigning any + // service-managed metadata. validationResult := as.certificationRequestValidator.Validate(certificationRequest) if validationResult.Status != signing.ValidationStatusSuccess { errorMsg := "" @@ -178,9 +181,39 @@ func (as *AggregatorService) CertificationRequest(ctx context.Context, req *api. "validationStatus", validationResult.Status.String(), "error", errorMsg) - return &api.CertificationResponse{ - Status: validationResult.Status.String(), - }, nil + return &api.CertificationResponse{Status: validationResult.Status.String()}, nil + } + + referenceTime := as.roundManager.CurrentReferenceTime() + if referenceTime == 0 { + return &api.CertificationResponse{Status: api.CertificationStatusServiceNotReady}, nil + } + // An explicit deadline is used verbatim and is covered by the witness. When + // the requester omitted one, the service derives a deadline from consensus + // reference time; that value is service metadata and is never recorded in the + // leaf, signed, or checked by a later verifier. + var effectiveTimeout uint64 + if expiresAt := req.CertificationData.ExpiresAt; expiresAt != nil { + effectiveTimeout = *expiresAt + } else { + ttl := uint64(as.config.Processing.RequestTTL() / time.Second) + if referenceTime > math.MaxUint64-ttl { + return nil, errors.New("default request deadline overflows uint64") + } + effectiveTimeout = referenceTime + ttl + } + certificationRequest.EffectiveTimeout = effectiveTimeout + + // Fail fast on a request that is already expired against the reference time + // rounds are currently pinning. The authoritative check runs again where the + // leaf is materialised, against that round's pinned reference time. + if referenceTime >= effectiveTimeout { + as.logger.WithContext(ctx).Warn("Certification request expired", + "stateId", req.StateID, + "timeout", effectiveTimeout, + "referenceTime", referenceTime) + + return &api.CertificationResponse{Status: api.CertificationStatusRequestExpired}, nil } if !as.config.Processing.SkipDuplicateCheck { @@ -384,8 +417,10 @@ func (as *AggregatorService) GetInclusionProofV2(ctx context.Context, req *api.G return nil, fmt.Errorf("failed to marshal inclusion cert: %w", err) } + referenceTime := record.ReferenceTime proof := &api.InclusionProofV2{ CertificationData: record.CertificationData.ToAPI(), + ReferenceTime: &referenceTime, CertificateBytes: certBytes, UnicityCertificate: types.RawCBOR(block.UnicityCertificate), } diff --git a/internal/service/service_test.go b/internal/service/service_test.go index d33614fc..8818ba79 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -39,6 +39,7 @@ import ( smtbackend "github.com/unicitynetwork/aggregator-go/internal/smt/backend" "github.com/unicitynetwork/aggregator-go/internal/storage" "github.com/unicitynetwork/aggregator-go/internal/storage/interfaces" + "github.com/unicitynetwork/aggregator-go/internal/testutil" "github.com/unicitynetwork/aggregator-go/pkg/api" "github.com/unicitynetwork/aggregator-go/pkg/jsonrpc" ) @@ -265,8 +266,10 @@ func validateInclusionProof(t *testing.T, proof *api.InclusionProofV2, req *api. require.NoError(t, err, "UC.IR.h must be extractable") key, err := req.StateID.GetTreeKey() require.NoError(t, err) + require.NotNil(t, proof.ReferenceTime) + leafValue := api.LeafValue(req.CertificationData.TransactionHash.DataBytes(), *proof.ReferenceTime) require.NoError(t, - cert.Verify(key, req.CertificationData.TransactionHash.DataBytes(), rootRaw, api.InclusionProofV2HashAlgorithm), + cert.Verify(key, leafValue, rootRaw, api.InclusionProofV2HashAlgorithm), "v2 inclusion cert must verify against UC.IR.h") } @@ -384,7 +387,8 @@ func TestGetInclusionProofV2Child_ComposesParentFragment(t *testing.T) { childTree := smt.NewChildSparseMerkleTree(api.SHA256, api.StateTreeKeyLengthBits, shardingCfg.Child.ShardID) path, err := stateID.GetPath() require.NoError(t, err) - require.NoError(t, childTree.AddLeaf(path, transactionHash.DataBytes())) + const referenceTime uint64 = 1755000000 + require.NoError(t, childTree.AddLeaf(path, api.LeafValue(transactionHash.DataBytes(), referenceTime))) childRoot := childTree.GetRootHashRaw() parentTree := smt.NewParentSparseMerkleTree(api.SHA256, shardingCfg.ShardIDLength) @@ -404,6 +408,7 @@ func TestGetInclusionProofV2Child_ComposesParentFragment(t *testing.T) { api.HexBytes(childRoot), nil, parentUC, + 1755000000, ) block.ParentFragment = parentFragment block.ParentBlockNumber = 9 @@ -416,11 +421,13 @@ func TestGetInclusionProofV2Child_ComposesParentFragment(t *testing.T) { OwnerPredicate: api.Predicate{Engine: 1, Code: []byte{0x01}, Params: []byte{0x02}}, SourceStateHash: sourceStateHash, TransactionHash: transactionHash, + ExpiresAt: ptr(referenceTime + 3600), Witness: []byte{0x01, 0x02}, }, - BlockNumber: api.NewBigIntFromUint64(1), - LeafIndex: api.NewBigIntFromUint64(0), - CreatedAt: api.Now(), + ReferenceTime: referenceTime, + BlockNumber: api.NewBigIntFromUint64(1), + LeafIndex: api.NewBigIntFromUint64(0), + CreatedAt: api.Now(), } service := newAggregatorServiceForTest(t, shardingCfg, childTree) @@ -443,6 +450,7 @@ func TestGetInclusionProofV2Child_ComposesParentFragment(t *testing.T) { OwnerPredicate: record.CertificationData.OwnerPredicate, SourceStateHash: record.CertificationData.SourceStateHash, TransactionHash: record.CertificationData.TransactionHash, + ExpiresAt: record.CertificationData.ExpiresAt, Witness: record.CertificationData.Witness, }, } @@ -469,6 +477,7 @@ func TestGetInclusionProofV2Child_NonInclusionUsesParentBundleMetadata(t *testin api.HexBytes(childTree.GetRootHashRaw()), nil, parentUC, + 1755000000, ) block.ParentBlockNumber = 12 block.Finalized = true @@ -589,6 +598,7 @@ func createTestCertificationRequests(t *testing.T, count int) []*api.Certificati OwnerPredicate: ownerPredicate, SourceStateHash: sourceStateHash, TransactionHash: transactionHash, + ExpiresAt: testutil.ExpiresAt(), } require.NoError(t, signingService.SignCertData(certData, privateKey.Serialize())) @@ -616,7 +626,7 @@ func TestCertificationRequestDoesNotTouchSMTBackend(t *testing.T) { }, logger: log, commitmentQueue: queue, - roundManager: &stubRoundManager{backend: backend}, + roundManager: &stubRoundManager{backend: backend, referenceTime: 1}, certificationRequestValidator: signing.NewCertificationRequestValidator(shardingCfg, bfttypes.ShardID{}), } @@ -628,6 +638,106 @@ func TestCertificationRequestDoesNotTouchSMTBackend(t *testing.T) { require.Equal(t, 0, backend.calls, "submit path must not read or write the SMT") } +func TestCertificationRequestAssignsDefaultTimeoutFromConsensusTime(t *testing.T) { + ctx := context.Background() + log, err := logger.New("error", "text", "stdout", false) + require.NoError(t, err) + + const referenceTime uint64 = 1755000000 + queue := &recordingCommitmentQueue{} + shardingCfg := config.ShardingConfig{Mode: config.ShardingModeBFTShard} + req := createTestCertificationRequests(t, 1)[0] + req.CertificationData.Version = 0 + req.CertificationData.ExpiresAt = nil + service := &AggregatorService{ + config: &config.Config{Processing: config.ProcessingConfig{ + SkipDuplicateCheck: true, DefaultRequestTTL: 90 * time.Minute, + }, Sharding: shardingCfg}, + logger: log, commitmentQueue: queue, + roundManager: &stubRoundManager{referenceTime: referenceTime}, + certificationRequestValidator: signing.NewCertificationRequestValidator(shardingCfg, bfttypes.ShardID{}), + } + + resp, err := service.CertificationRequest(ctx, req) + require.NoError(t, err) + require.Equal(t, "SUCCESS", resp.Status) + require.Len(t, queue.stored, 1) + require.Nil(t, queue.stored[0].CertificationData.ExpiresAt) + require.Equal(t, referenceTime+5400, queue.stored[0].EffectiveTimeout) +} + +func TestCertificationRequestWithoutConsensusTimeReturnsServiceNotReady(t *testing.T) { + ctx := context.Background() + log, err := logger.New("error", "text", "stdout", false) + require.NoError(t, err) + queue := &recordingCommitmentQueue{} + shardingCfg := config.ShardingConfig{Mode: config.ShardingModeBFTShard} + service := &AggregatorService{ + config: &config.Config{Processing: config.ProcessingConfig{SkipDuplicateCheck: true}, Sharding: shardingCfg}, + logger: log, commitmentQueue: queue, roundManager: &stubRoundManager{}, + certificationRequestValidator: signing.NewCertificationRequestValidator(shardingCfg, bfttypes.ShardID{}), + } + + resp, err := service.CertificationRequest(ctx, createTestCertificationRequests(t, 1)[0]) + require.NoError(t, err) + require.Equal(t, api.CertificationStatusServiceNotReady, resp.Status) + require.Empty(t, queue.stored) +} + +// A request whose timeout the current reference time has already reached is +// rejected on arrival, distinctly from a double-spend, and never reaches the +// queue. +func TestCertificationRequestRejectsAnExpiredRequest(t *testing.T) { + ctx := context.Background() + log, err := logger.New("error", "text", "stdout", false) + require.NoError(t, err) + + queue := &recordingCommitmentQueue{} + shardingCfg := config.ShardingConfig{Mode: config.ShardingModeBFTShard} + req := createTestCertificationRequests(t, 1)[0] + service := &AggregatorService{ + config: &config.Config{ + Processing: config.ProcessingConfig{SkipDuplicateCheck: true}, + Sharding: shardingCfg, + }, + logger: log, + commitmentQueue: queue, + roundManager: &stubRoundManager{referenceTime: *req.CertificationData.ExpiresAt}, + certificationRequestValidator: signing.NewCertificationRequestValidator(shardingCfg, bfttypes.ShardID{}), + } + + resp, err := service.CertificationRequest(ctx, req) + require.NoError(t, err) + require.Equal(t, api.CertificationStatusRequestExpired, resp.Status) + require.Empty(t, queue.stored) +} + +// The timeout is exclusive: a round one second short of it still admits. +func TestCertificationRequestAcceptsOnTheTimeoutBoundary(t *testing.T) { + ctx := context.Background() + log, err := logger.New("error", "text", "stdout", false) + require.NoError(t, err) + + queue := &recordingCommitmentQueue{} + shardingCfg := config.ShardingConfig{Mode: config.ShardingModeBFTShard} + req := createTestCertificationRequests(t, 1)[0] + service := &AggregatorService{ + config: &config.Config{ + Processing: config.ProcessingConfig{SkipDuplicateCheck: true}, + Sharding: shardingCfg, + }, + logger: log, + commitmentQueue: queue, + roundManager: &stubRoundManager{referenceTime: *req.CertificationData.ExpiresAt - 1}, + certificationRequestValidator: signing.NewCertificationRequestValidator(shardingCfg, bfttypes.ShardID{}), + } + + resp, err := service.CertificationRequest(ctx, req) + require.NoError(t, err) + require.Equal(t, "SUCCESS", resp.Status) + require.Len(t, queue.stored, 1) +} + func TestGetInclusionProofUsesCachedProofMetadata(t *testing.T) { ctx := context.Background() log, err := logger.New("error", "text", "stdout", false) @@ -637,7 +747,9 @@ func TestGetInclusionProofUsesCachedProofMetadata(t *testing.T) { tree := smt.NewSparseMerkleTree(api.SHA256, api.StateTreeKeyLengthBits) path, err := req.StateID.GetPath() require.NoError(t, err) - require.NoError(t, tree.AddLeaf(path, req.CertificationData.TransactionHash.DataBytes())) + const referenceTime uint64 = 1755000000 + require.NoError(t, tree.AddLeaf(path, + api.LeafValue(req.CertificationData.TransactionHash.DataBytes(), referenceTime))) rootHash := api.HexBytes(tree.GetRootHashRaw()) uc := testChildProofUC(t, 9, rootHash) @@ -650,6 +762,7 @@ func TestGetInclusionProofUsesCachedProofMetadata(t *testing.T) { rootHash, nil, uc, + 1755000000, ) block.Finalized = true record := &models.AggregatorRecord{ @@ -659,11 +772,13 @@ func TestGetInclusionProofUsesCachedProofMetadata(t *testing.T) { OwnerPredicate: req.CertificationData.OwnerPredicate, SourceStateHash: req.CertificationData.SourceStateHash, TransactionHash: req.CertificationData.TransactionHash, + ExpiresAt: req.CertificationData.ExpiresAt, Witness: req.CertificationData.Witness, }, - BlockNumber: api.NewBigIntFromUint64(9), - LeafIndex: api.NewBigIntFromUint64(0), - CreatedAt: api.Now(), + ReferenceTime: referenceTime, + BlockNumber: api.NewBigIntFromUint64(9), + LeafIndex: api.NewBigIntFromUint64(0), + CreatedAt: api.Now(), } blockStorage := &testBlockStorage{latestByRoot: map[string]*models.Block{rootHash.String(): block}} @@ -711,6 +826,7 @@ func TestGetInclusionProofPublishedViewReturnsEmptyBeforeRecordRootIsPublished(t publishedRoot, nil, testChildProofUC(t, 9, publishedRoot), + 1755000000, ) block.Finalized = true backend := &publishedProofBackend{ @@ -766,6 +882,7 @@ func TestGetHealthStatusPublishedProofFollowerNotReadyDuringInitialDiskSync(t *t latestRoot, nil, testChildProofUC(t, 9, latestRoot), + 1755000000, ) latestBlock.Finalized = true backend := &publishedProofBackend{ @@ -814,6 +931,7 @@ func TestGetHealthStatusPublishedProofFollowerReadyAfterInitialDiskSync(t *testi latestRoot, nil, testChildProofUC(t, 9, latestRoot), + 1755000000, ) latestBlock.Finalized = true backend := &publishedProofBackend{ @@ -863,6 +981,7 @@ func TestGetInclusionProofPublishedViewRootChangeReturnsEmpty(t *testing.T) { publishedRoot, nil, testChildProofUC(t, 9, publishedRoot), + 1755000000, ) block.Finalized = true record := &models.AggregatorRecord{ @@ -937,11 +1056,12 @@ func TestGetInclusionProofUsesPrecomputedProofResponse(t *testing.T) { } type stubRoundManager struct { - smt *smt.ThreadSafeSMT - backend smtbackend.Backend - cachedRoot api.HexBytes - cachedBlock *models.Block - cachedRecord *models.AggregatorRecord + smt *smt.ThreadSafeSMT + backend smtbackend.Backend + cachedRoot api.HexBytes + cachedBlock *models.Block + cachedRecord *models.AggregatorRecord + referenceTime uint64 } func (s *stubRoundManager) Start(context.Context) error { return nil } @@ -984,6 +1104,8 @@ func (s *stubRoundManager) GetProofCacheStats() (pending int, records int, block return 0, 0, 0 } +func (s *stubRoundManager) CurrentReferenceTime() uint64 { return s.referenceTime } + type countingSMTBackend struct { calls int } @@ -1240,3 +1362,6 @@ func testChildProofUC(t *testing.T, roundNumber uint64, rootHash []byte) api.Hex require.NoError(t, err) return api.NewHexBytes(ucBytes) } + +// ptr returns a pointer to v, for the optional request deadline. +func ptr(v uint64) *uint64 { return &v } diff --git a/internal/sharding/root_aggregator_client_stub.go b/internal/sharding/root_aggregator_client_stub.go index b304f9df..7d486fe4 100644 --- a/internal/sharding/root_aggregator_client_stub.go +++ b/internal/sharding/root_aggregator_client_stub.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sync" + "time" "github.com/unicitynetwork/bft-go-base/types" "github.com/unicitynetwork/bft-go-base/types/hex" @@ -19,11 +20,16 @@ type RootAggregatorClientStub struct { submissions map[int]*api.SubmitShardRootRequest // shardID => last request submittedRootHash api.HexBytes submissionError error + // referenceTime stands in for the seal timestamp a real parent returns: it + // advances by one per returned proof, so a child under this stub pins + // distinct, increasing reference times as it does against a live parent. + referenceTime uint64 } func NewRootAggregatorClientStub() *RootAggregatorClientStub { return &RootAggregatorClientStub{ - submissions: make(map[int]*api.SubmitShardRootRequest), + submissions: make(map[int]*api.SubmitShardRootRequest), + referenceTime: uint64(time.Now().Unix()), } } @@ -47,7 +53,8 @@ func (m *RootAggregatorClientStub) GetShardProof(ctx context.Context, request *a if m.submissions[request.ShardID] != nil { m.returnedProofCount++ - ucBytes, err := stubProofUC(uint64(m.returnedProofCount), uint64(m.returnedProofCount), m.submittedRootHash) + m.referenceTime++ + ucBytes, err := stubProofUC(uint64(m.returnedProofCount), uint64(m.returnedProofCount), m.referenceTime, m.submittedRootHash) if err != nil { return nil, err } @@ -94,14 +101,21 @@ func (m *RootAggregatorClientStub) SetSubmissionError(err error) { m.submissionError = err } -func stubProofUC(parentRound, rootRound uint64, rootHash api.HexBytes) (api.HexBytes, error) { +// stubProofUC builds the certificate a parent returns with a shard proof. It +// carries the same two timestamps a live parent does: the input record records +// the reference time the certified round was built under, and the seal records +// the time the child's next round will pin. Without them the child has no +// reference time and rejects every request as not ready. +func stubProofUC(parentRound, rootRound, referenceTime uint64, rootHash api.HexBytes) (api.HexBytes, error) { uc := types.UnicityCertificate{ InputRecord: &types.InputRecord{ RoundNumber: parentRound, Hash: hex.Bytes(rootHash), + Timestamp: referenceTime, }, UnicitySeal: &types.UnicitySeal{ RootChainRoundNumber: rootRound, + Timestamp: referenceTime + 1, }, } diff --git a/internal/smt/backend/disk_backend_rocksdb_test.go b/internal/smt/backend/disk_backend_rocksdb_test.go index f42e4c32..5820dce4 100644 --- a/internal/smt/backend/disk_backend_rocksdb_test.go +++ b/internal/smt/backend/disk_backend_rocksdb_test.go @@ -13,6 +13,7 @@ import ( "github.com/unicitynetwork/aggregator-go/internal/smt/disk/persist" "github.com/unicitynetwork/aggregator-go/internal/smt/disk/rocksstore" "github.com/unicitynetwork/aggregator-go/internal/smt/disk/storage" + "github.com/unicitynetwork/aggregator-go/internal/testutil" "github.com/unicitynetwork/aggregator-go/pkg/api" ) @@ -306,8 +307,9 @@ func TestDiskBackendRocksDBPrecomputedProofResponsesRoundTrip(t *testing.T) { InclusionProof: &api.InclusionProofV2{ Version: 1, CertificationData: &api.CertificationData{ - Version: 1, + Version: 2, TransactionHash: api.TransactionHash(bytesOf(32, 7)), + ExpiresAt: testutil.ExpiresAt(), }, CertificateBytes: []byte{1, 2, 3}, UnicityCertificate: []byte{0x43, 4, 5, 6}, diff --git a/internal/smt/smt_memory_benchmark_test.go b/internal/smt/smt_memory_benchmark_test.go index fbc18f6b..eecac53b 100644 --- a/internal/smt/smt_memory_benchmark_test.go +++ b/internal/smt/smt_memory_benchmark_test.go @@ -91,7 +91,7 @@ func BenchmarkSMTMemoryUsageRealistic(b *testing.B) { b.Fatalf("Failed to get path: %v", err) } - leafValue, err := commitment.LeafValue() + leafValue, err := commitment.LeafValue(1755000000) if err != nil { b.Fatalf("Failed to create leaf value: %v", err) } @@ -153,7 +153,7 @@ func BenchmarkSMTOperationsWithLoad(b *testing.B) { } path, _ := commitment.StateID.GetPath() - leafValue, _ := commitment.LeafValue() + leafValue, _ := commitment.LeafValue(1755000000) leaves[i] = &Leaf{Path: path, Value: leafValue} paths[i] = path @@ -190,7 +190,7 @@ func BenchmarkSMTOperationsWithLoad(b *testing.B) { b.Fatalf("Failed to generate commitment: %v", err) } path, _ := commitment.StateID.GetPath() - leafValue, _ := commitment.LeafValue() + leafValue, _ := commitment.LeafValue(1755000000) b.StartTimer() err = snapshot.AddLeaf(path, leafValue) diff --git a/internal/storage/mongodb/aggregator_record_test.go b/internal/storage/mongodb/aggregator_record_test.go index 5ba173f2..7381643f 100644 --- a/internal/storage/mongodb/aggregator_record_test.go +++ b/internal/storage/mongodb/aggregator_record_test.go @@ -108,6 +108,7 @@ func storeTestBlock(t *testing.T, ctx context.Context, db *mongo.Database, block api.HexBytes(make([]byte, api.SiblingSize)), nil, nil, + 1755000000, ) block.Finalized = finalized block.ProposalID = testProposalID(blockNumber) diff --git a/internal/testutil/commitment.go b/internal/testutil/commitment.go index 5b4de2e4..247c43ca 100644 --- a/internal/testutil/commitment.go +++ b/internal/testutil/commitment.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "fmt" "testing" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/stretchr/testify/require" @@ -13,6 +14,20 @@ import ( "github.com/unicitynetwork/aggregator-go/pkg/api" ) +// ExpiresAt is the exclusive certification request deadline test requests carry: +// an hour ahead of the current wall clock, so no test run reaches it. +func ExpiresAt() *uint64 { + v := uint64(time.Now().Unix()) + 3600 + return &v +} + +// ExpiredExpiresAt is a deadline that has already passed, for exercising the +// expiry path. +func ExpiredExpiresAt() *uint64 { + v := uint64(time.Now().Unix()) - 3600 + return &v +} + // CreateTestCertificationRequest creates a valid, signed CertificationRequest for testing func CreateTestCertificationRequest(t *testing.T, baseData string) *models.CertificationRequest { privateKey, err := btcec.NewPrivateKey() @@ -48,6 +63,7 @@ func CreateTestCertificationRequest(t *testing.T, baseData string) *models.Certi OwnerPredicate: ownerPredicate, SourceStateHash: sourceStateHash, TransactionHash: transactionHash, + ExpiresAt: ExpiresAt(), Witness: signatureBytes, } return models.NewCertificationRequest(stateID, certData) diff --git a/pkg/api/README.md b/pkg/api/README.md index 01f07954..629c3d49 100644 --- a/pkg/api/README.md +++ b/pkg/api/README.md @@ -54,6 +54,7 @@ func main() { OwnerPredicate: api.NewPayToPublicKeyPredicate([]byte{0x03, 0x20, 0x44, 0xf2}), SourceStateHash: api.RequireNewImprintV2("cd60000000000000000000000000000000000000000000000000000000000000"), TransactionHash: api.RequireNewImprintV2("cd61000000000000000000000000000000000000000000000000000000000000"), + ExpiresAt: api.Uint64Ptr(1755003600), // nil lets the service assign the deadline Witness: []byte{0x41, 0x67, 0x51, 0xe8}}, } diff --git a/pkg/api/cbor.go b/pkg/api/cbor.go index 9bbb15cc..676102aa 100644 --- a/pkg/api/cbor.go +++ b/pkg/api/cbor.go @@ -35,6 +35,29 @@ func CborArray(n int) []byte { return cborTag(4, n) } +// CborUint returns the CBOR encoding of an unsigned integer. +// +// This does not route through cborTag, whose parameter is an int: converting a +// uint64 there truncates above 2^31 on 32-bit platforms and wraps negative +// above 2^63 anywhere, which cborTag rejects by panicking. Both reach hashing +// paths such as LeafValue, where the input is not always ours to bound. +func CborUint(n uint64) []byte { + switch { + case n <= 23: + return []byte{byte(n)} + case n <= 0xff: + return []byte{24, byte(n)} + case n <= 0xffff: + return []byte{25, byte(n >> 8), byte(n)} + case n <= 0xffffffff: + return []byte{26, byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)} + default: + return []byte{27, + byte(n >> 56), byte(n >> 48), byte(n >> 40), byte(n >> 32), + byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)} + } +} + // CborNull returns the CBOR tag for null func CborNull() []byte { return cborTag(7, 22) diff --git a/pkg/api/cbor_tags_test.go b/pkg/api/cbor_tags_test.go index 04678268..58ff0348 100644 --- a/pkg/api/cbor_tags_test.go +++ b/pkg/api/cbor_tags_test.go @@ -5,6 +5,8 @@ import ( "github.com/stretchr/testify/require" "github.com/unicitynetwork/bft-go-base/types" + + corecbor "github.com/unicitynetwork/aggregator-go/pkg/cbor" ) // cborTagPrefix returns the raw CBOR bytes that encode `tag` as a tag head @@ -76,9 +78,27 @@ func TestCertificationData_WireFormat(t *testing.T) { b, err := types.Cbor.Marshal(&cd) require.NoError(t, err) - prefix := cborTagPrefix(t, CertificationDataTag, 5) + prefix := cborTagPrefix(t, CertificationDataTag, 6) require.Equal(t, prefix, b[:len(prefix)]) - require.Equal(t, byte(0x01), b[len(prefix)], "Version slot should be 1") + require.Equal(t, byte(0x02), b[len(prefix)], "Version slot should be 2") +} + +func TestCertificationData_AbsentExpiryKeepsTheSameShape(t *testing.T) { + cd := createCertData(t) + cd.Version = 0 + cd.ExpiresAt = nil + + b, err := types.Cbor.Marshal(&cd) + require.NoError(t, err) + // Same tag, same element count, same version as a request that carries one. + prefix := cborTagPrefix(t, CertificationDataTag, 6) + require.Equal(t, prefix, b[:len(prefix)]) + require.Equal(t, byte(0x02), b[len(prefix)]) + + var decoded CertificationData + require.NoError(t, types.Cbor.Unmarshal(b, &decoded)) + require.Nil(t, decoded.ExpiresAt) + require.Equal(t, CertificationDataVersion, decoded.GetVersion()) } func TestCertificationData_RejectsWrongTag(t *testing.T) { @@ -132,8 +152,10 @@ func TestPredicate_RejectsWrongTag(t *testing.T) { func TestInclusionProofV2_WireFormat(t *testing.T) { cd := createCertData(t) + referenceTime := uint64(1755000000) proof := &InclusionProofV2{ CertificationData: &cd, + ReferenceTime: &referenceTime, CertificateBytes: HexBytes{0x01, 0x02}, // UnicityCertificate is raw CBOR; an empty byte-string is valid CBOR. UnicityCertificate: types.RawCBOR{0x40}, @@ -142,15 +164,17 @@ func TestInclusionProofV2_WireFormat(t *testing.T) { b, err := types.Cbor.Marshal(proof) require.NoError(t, err) - prefix := cborTagPrefix(t, InclusionProofTag, 4) + prefix := cborTagPrefix(t, InclusionProofTag, 5) require.Equal(t, prefix, b[:len(prefix)]) require.Equal(t, byte(0x01), b[len(prefix)], "Version slot should be 1") } func TestInclusionProofV2_RejectsWrongTag(t *testing.T) { cd := createCertData(t) + referenceTime := uint64(1755000000) proof := &InclusionProofV2{ CertificationData: &cd, + ReferenceTime: &referenceTime, CertificateBytes: HexBytes{0x01, 0x02}, UnicityCertificate: types.RawCBOR{0x40}, } @@ -166,8 +190,10 @@ func TestInclusionProofV2_RejectsWrongTag(t *testing.T) { func TestInclusionProofV2_RejectsWrongVersion(t *testing.T) { cd := createCertData(t) + referenceTime := uint64(1755000000) proof := &InclusionProofV2{ CertificationData: &cd, + ReferenceTime: &referenceTime, CertificateBytes: HexBytes{0x01, 0x02}, UnicityCertificate: types.RawCBOR{0x40}, } @@ -180,3 +206,34 @@ func TestInclusionProofV2_RejectsWrongVersion(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "version") } + +// CborUint feeds hashing paths such as LeafValue, where the value is not always +// ours to bound. It must therefore encode the whole uint64 range rather than +// narrowing to int, which truncates above 2^31 on 32-bit platforms and wraps +// negative above 2^63 anywhere. +func TestCborUintCoversTheFullUnsignedRange(t *testing.T) { + for _, tc := range []struct { + name string + in uint64 + want []byte + }{ + {"zero", 0, []byte{0x00}}, + {"one byte inline", 23, []byte{0x17}}, + {"one byte follows", 24, []byte{0x18, 0x18}}, + {"max uint8", 0xff, []byte{0x18, 0xff}}, + {"two bytes", 0x100, []byte{0x19, 0x01, 0x00}}, + {"max uint16", 0xffff, []byte{0x19, 0xff, 0xff}}, + {"four bytes", 0x10000, []byte{0x1a, 0x00, 0x01, 0x00, 0x00}}, + {"max uint32", 0xffffffff, []byte{0x1a, 0xff, 0xff, 0xff, 0xff}}, + {"eight bytes", 0x100000000, []byte{0x1b, 0, 0, 0, 1, 0, 0, 0, 0}}, + {"above max int64", 1 << 63, []byte{0x1b, 0x80, 0, 0, 0, 0, 0, 0, 0}}, + {"max uint64", ^uint64(0), []byte{0x1b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}}, + } { + t.Run(tc.name, func(t *testing.T) { + got := CborUint(tc.in) + require.Equal(t, tc.want, got) + // Shortest-form encoding is what the canonical validator requires. + require.NoError(t, corecbor.ValidateCoreDeterministic(got)) + }) + } +} diff --git a/pkg/api/certification_request.go b/pkg/api/certification_request.go index 6f2fe57c..0d86dd55 100644 --- a/pkg/api/certification_request.go +++ b/pkg/api/certification_request.go @@ -88,12 +88,36 @@ func UnmarshalCertificationRequestCBOR(data []byte, out *CertificationRequest) e if out.Version != 1 { return fmt.Errorf("unsupported CertificationRequest version: %d", out.Version) } - if out.CertificationData.Version != 1 { + if out.CertificationData.Version != CertificationDataVersion { return fmt.Errorf("unsupported CertificationData version: %d", out.CertificationData.Version) } return nil } +// Uint64Ptr returns a pointer to v. Go has no optional arguments, so an absent +// CertificationData.ExpiresAt is a nil pointer; this makes supplying a present +// one a single expression at the call site. +func Uint64Ptr(v uint64) *uint64 { return &v } + +// CertificationDataVersion is the only accepted CertificationData wire version. +// It carries every field in a fixed-length array, with ExpiresAt written as CBOR +// null when the requester left the deadline to the service. +const CertificationDataVersion types.Version = 2 + +// certificationDataFieldCount is the element count of the CertificationData CBOR +// array: version, owner predicate, source state hash, transaction hash, +// expires-at, witness. +const certificationDataFieldCount = 6 + +// CertificationStatusRequestExpired is returned when the round's reference time +// had already reached the request's timeout. It is distinct from a double-spend +// so a client can tell "too late" from "already spent". +const CertificationStatusRequestExpired = "REQUEST_EXPIRED" + +// CertificationStatusServiceNotReady is returned while the aggregator has no +// consensus reference time from which to derive or validate a request timeout. +const CertificationStatusServiceNotReady = "SERVICE_NOT_READY" + // CertificationResponse represents the certification_request JSON-RPC response. type CertificationResponse struct { Status string `json:"status"` @@ -115,9 +139,16 @@ type CertificationData struct { // SourceStateHash is the raw 32-byte hash of the source data. SourceStateHash SourceStateHash `json:"sourceStateHash"` - // TransactionHash is the raw 32-byte hash of the transaction data. + // TransactionHash is the raw 32-byte hash of the transaction. It commits to + // ExpiresAt, so changing the deadline invalidates the witness. TransactionHash TransactionHash `json:"transactionHash"` + // ExpiresAt is the exclusive certification request timeout in Unix seconds, + // or nil when the requester left the deadline to the service. It occupies a + // fixed position in the encoding and is written as CBOR null when absent, so + // a requester without a clock needs no separate wire format. + ExpiresAt *uint64 `json:"expiresAt"` + // Witness is the "unlocking part" of owner predicate. In case of PayToPublicKey owner predicate the witness must be // a signature created on the hash of CBOR array[SourceStateHash, TransactionHash], // in Unicity's [R || S || V] format (65 bytes). @@ -128,21 +159,50 @@ func (c *CertificationData) GetVersion() types.Version { if c != nil && c.Version > 0 { return c.Version } - return 1 + return CertificationDataVersion } func (c *CertificationData) MarshalCBOR() ([]byte, error) { + if c == nil { + return nil, errors.New("nil CertificationData") + } + if v := c.GetVersion(); v != CertificationDataVersion { + return nil, fmt.Errorf("unsupported CertificationData version: %d", v) + } type alias CertificationData cp := *c - if cp.Version == 0 { - cp.Version = 1 - } + cp.Version = CertificationDataVersion return types.Cbor.MarshalTaggedValue(CertificationDataTag, (*alias)(&cp)) } func (c *CertificationData) UnmarshalCBOR(data []byte) error { + tag, arr, err := types.Cbor.UnmarshalTagged(data) + if err != nil { + return err + } + if tag != CertificationDataTag { + return errors.New("invalid CertificationData tag") + } + // One version, one element count. ExpiresAt holds its position even when it + // carries no value, so the array length never depends on the payload. + if len(arr) != certificationDataFieldCount { + return fmt.Errorf("CertificationData: expected %d fields, got %d", + certificationDataFieldCount, len(arr)) + } + version, ok := arr[0].(uint64) + if !ok { + return errors.New("invalid CertificationData version") + } + if types.Version(version) != CertificationDataVersion { + return fmt.Errorf("unsupported CertificationData version: %d", version) + } type alias CertificationData - return types.UnmarshalTaggedVersioned(CertificationDataTag, 1, data, (*alias)(c), c) + var decoded alias + if err := types.Cbor.UnmarshalTaggedValue(CertificationDataTag, data, &decoded); err != nil { + return err + } + *c = CertificationData(decoded) + return nil } // SigDataHash returns the data hash used for signature generation. @@ -171,9 +231,9 @@ func SigDataHash(sourceStateHash []byte, transactionHash []byte) *DataHash { // Hash returns the data hash of certification data. // The hash is calculated as the CBOR array -// [OwnerPredicate, SourceStateHash, TransactionHash, Witness]. +// [OwnerPredicate, SourceStateHash, TransactionHash, ExpiresAt, Witness]. func (c CertificationData) Hash() ([]byte, error) { - dataHash, err := CertDataHash(c.OwnerPredicate, c.SourceStateHash, c.TransactionHash, c.Witness) + dataHash, err := CertDataHash(c.OwnerPredicate, c.SourceStateHash, c.TransactionHash, c.ExpiresAt, c.Witness) if err != nil { return nil, fmt.Errorf("failed to calculate certification data hash: %w", err) } @@ -186,8 +246,8 @@ func (c CertificationData) CreateStateID() (StateID, error) { // CertDataHash returns the data hash of certification data. // The hash is calculated as the CBOR array -// [OwnerPredicate, SourceStateHash, TransactionHash, Witness]. -func CertDataHash(ownerPredicate Predicate, sourceStateHash, transactionHash, signature []byte) (*DataHash, error) { +// [OwnerPredicate, SourceStateHash, TransactionHash, ExpiresAt, Witness]. +func CertDataHash(ownerPredicate Predicate, sourceStateHash, transactionHash []byte, expiresAt *uint64, signature []byte) (*DataHash, error) { if len(sourceStateHash) != StateTreeKeyLengthBytes { return nil, fmt.Errorf("invalid source state hash length: expected %d bytes, got %d", StateTreeKeyLengthBytes, len(sourceStateHash)) } @@ -203,6 +263,7 @@ func CertDataHash(ownerPredicate Predicate, sourceStateHash, transactionHash, si OwnerPredicate Predicate SourceStateHash []byte TransactionHash []byte + ExpiresAt *uint64 Witness []byte } @@ -210,6 +271,7 @@ func CertDataHash(ownerPredicate Predicate, sourceStateHash, transactionHash, si OwnerPredicate: ownerPredicate, SourceStateHash: sourceStateHash, TransactionHash: transactionHash, + ExpiresAt: expiresAt, Witness: signature, } diff --git a/pkg/api/certification_request_canonical_test.go b/pkg/api/certification_request_canonical_test.go index ec7422be..51f5c751 100644 --- a/pkg/api/certification_request_canonical_test.go +++ b/pkg/api/certification_request_canonical_test.go @@ -22,7 +22,7 @@ func canonicalCertificationRequestFixture(t testing.TB) (*CertificationRequest, req := &CertificationRequest{ StateID: RequireNewImprintV2("0000000000000000000000000000000000000000000000000000000000000000"), CertificationData: CertificationData{ - Version: 1, + Version: 2, OwnerPredicate: NewPayToPublicKeyPredicate(publicKey), SourceStateHash: RequireNewImprintV2("0000000000000000000000000000000000000000000000000000000000000000"), TransactionHash: RequireNewImprintV2("0000000000000000000000000000000000000000000000000000000000000001"), @@ -185,11 +185,11 @@ func TestUnmarshalCertificationRequestCBOR_VersionZero(t *testing.T) { func TestUnmarshalCertificationRequestCBOR_NestedVersionZero(t *testing.T) { _, canonical := canonicalCertificationRequestFixture(t) - certDataMarker := []byte{0xd9, 0x98, 0x77, 0x85, 0x01} + certDataMarker := []byte{0xd9, 0x98, 0x77} idx := bytes.Index(canonical, certDataMarker) require.GreaterOrEqual(t, idx, 0, "fixture invariant: nested certification data marker not found") - versionPos := idx + 4 + versionPos := idx + len(certDataMarker) + 1 // skip the following array header tainted := append([]byte{}, canonical...) tainted[versionPos] = 0x00 diff --git a/pkg/api/certification_request_test.go b/pkg/api/certification_request_test.go index fb562a43..f08e8e55 100644 --- a/pkg/api/certification_request_test.go +++ b/pkg/api/certification_request_test.go @@ -21,6 +21,34 @@ func TestCertificationData_SerializeAndValidate(t *testing.T) { require.NoError(t, types.Cbor.Unmarshal(certDataCborBytes, &deserializedCertData)) require.Equal(t, certData, deserializedCertData) }) + + t.Run("round-trips an absent expiry as CBOR null", func(t *testing.T) { + certData := createCertData(t) + certData.ExpiresAt = nil + encoded, err := types.Cbor.Marshal(certData) + require.NoError(t, err) + require.Contains(t, encoded, byte(0xf6), "absent expiry occupies its slot as CBOR null") + + var decoded CertificationData + require.NoError(t, types.Cbor.Unmarshal(encoded, &decoded)) + require.Nil(t, decoded.ExpiresAt) + require.Equal(t, certData, decoded) + }) + + t.Run("rejects any version other than the current one", func(t *testing.T) { + certData := createCertData(t) + encoded, err := types.Cbor.Marshal(certData) + require.NoError(t, err) + require.Equal(t, byte(2), encoded[4], "fixture invariant: version follows tag and array header") + + for _, bad := range []byte{1, 3} { + mismatched := append([]byte(nil), encoded...) + mismatched[4] = bad + var decoded CertificationData + require.ErrorContains(t, types.Cbor.Unmarshal(mismatched, &decoded), + "unsupported CertificationData version") + } + }) } func TestCertificationRequest_SerializeAndValidate(t *testing.T) { @@ -213,7 +241,7 @@ func TestCertificationRequestCBOR(t *testing.T) { Version: 1, StateID: RequireNewImprintV2("cfe84a1828e2edd0a7d9533b23e519f746069a938d549a150e07e14dc0f9cf00"), CertificationData: CertificationData{ - Version: 1, + Version: 2, OwnerPredicate: NewPayToPublicKeyPredicate([]byte{0x03, 0x20, 0x44, 0xf2}), SourceStateHash: RequireNewImprintV2("cd60000000000000000000000000000000000000000000000000000000000000"), TransactionHash: RequireNewImprintV2("8a51b5b84171e6c7c345bf3610cc18fa1b61bad33908e1522520c001b0e7fd1d"), @@ -245,10 +273,11 @@ func createCertData(t *testing.T) CertificationData { require.NoError(t, err) return CertificationData{ - Version: 1, + Version: 2, OwnerPredicate: NewPayToPublicKeyPredicate(publicKey), SourceStateHash: sourceStateHashHex, TransactionHash: transactionHashHex, + ExpiresAt: uint64Ptr(1755003600), Witness: NewHexBytes(witness), } } @@ -260,11 +289,12 @@ func TestCertificationDataHashing_Compatibility(t *testing.T) { predicateBytes, err := types.Cbor.Marshal(certData.OwnerPredicate) require.NoError(t, err) - expectedBytes := append([]byte{0x84}, predicateBytes...) + expectedBytes := append([]byte{0x85}, predicateBytes...) expectedBytes = append(expectedBytes, []byte{0x58, 0x20}...) expectedBytes = append(expectedBytes, certData.SourceStateHash...) expectedBytes = append(expectedBytes, []byte{0x58, 0x20}...) expectedBytes = append(expectedBytes, certData.TransactionHash...) + expectedBytes = append(expectedBytes, CborUint(*certData.ExpiresAt)...) expectedBytes = append(expectedBytes, append([]byte{0x58, byte(len(certData.Witness))}, certData.Witness...)...) expectedHash := NewDataHasher(SHA256).AddData(expectedBytes).GetHash() @@ -273,23 +303,36 @@ func TestCertificationDataHashing_Compatibility(t *testing.T) { OwnerPredicate Predicate SourceStateHash []byte TransactionHash []byte + ExpiresAt *uint64 Witness []byte } canonicalBytes, err := types.Cbor.Marshal(certDataInput{ OwnerPredicate: certData.OwnerPredicate, SourceStateHash: certData.SourceStateHash, TransactionHash: certData.TransactionHash, + ExpiresAt: certData.ExpiresAt, Witness: certData.Witness, }) require.NoError(t, err) assert.Equal(t, expectedBytes, canonicalBytes) - gotHash, err := CertDataHash(certData.OwnerPredicate, certData.SourceStateHash, certData.TransactionHash, certData.Witness) + gotHash, err := CertDataHash(certData.OwnerPredicate, certData.SourceStateHash, certData.TransactionHash, certData.ExpiresAt, certData.Witness) require.NoError(t, err) assert.Equal(t, expectedHash.RawHash, gotHash.RawHash) }) } +func TestCertificationDataRejectsRetiredVersionOneVector(t *testing.T) { + // The pre-expiry v1 encoding is no longer a format: one version, one shape. + encoded, err := hex.DecodeString("d998778501d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820df524cffc08a1dc30579a8a51f440a97b30630988084f8d12a4d8bd741c7791258419efb637f14dbdaada6e293e2182932d82265b04b1abf4f28bc4c285b32b5e2325140fe7f94bc9b705c568b4fcb7f9ea90cf0fadcacc1b4504275f81558aad1e700") + require.NoError(t, err) + var data CertificationData + require.Error(t, types.Cbor.Unmarshal(encoded, &data)) +} + +// uint64Ptr returns a pointer to v, for the optional request deadline. +func uint64Ptr(v uint64) *uint64 { return &v } + func TestCertificationDataHashing_InvalidLengths(t *testing.T) { validPredicate := NewPayToPublicKeyPredicate([]byte{0x01, 0x02, 0x03}) validHash := make([]byte, 32) @@ -297,13 +340,13 @@ func TestCertificationDataHashing_InvalidLengths(t *testing.T) { longHash := make([]byte, 64) t.Run("CertDataHash should reject invalid hash lengths", func(t *testing.T) { - _, err := CertDataHash(validPredicate, shortHash, validHash, []byte{0x00}) + _, err := CertDataHash(validPredicate, shortHash, validHash, uint64Ptr(1755003600), []byte{0x00}) assert.ErrorContains(t, err, "invalid source state hash length") - _, err = CertDataHash(validPredicate, validHash, longHash, []byte{0x00}) + _, err = CertDataHash(validPredicate, validHash, longHash, uint64Ptr(1755003600), []byte{0x00}) assert.ErrorContains(t, err, "invalid transaction hash length") - _, err = CertDataHash(validPredicate, []byte{}, validHash, []byte{0x00}) + _, err = CertDataHash(validPredicate, []byte{}, validHash, uint64Ptr(1755003600), []byte{0x00}) assert.ErrorContains(t, err, "invalid source state hash length") }) @@ -378,7 +421,7 @@ func TestCertificationDataHashing_InvalidHashLengths(t *testing.T) { for _, tc := range cases { t.Run("CertDataHash rejects "+tc.name, func(t *testing.T) { - _, err := CertDataHash(certData.OwnerPredicate, tc.sourceStateHash, tc.transactionHash, certData.Witness) + _, err := CertDataHash(certData.OwnerPredicate, tc.sourceStateHash, tc.transactionHash, uint64Ptr(1755003600), certData.Witness) require.ErrorContains(t, err, tc.expectedErrorPart) }) diff --git a/pkg/api/inclusion_cert.go b/pkg/api/inclusion_cert.go index b8435358..eea4702f 100644 --- a/pkg/api/inclusion_cert.go +++ b/pkg/api/inclusion_cert.go @@ -48,9 +48,10 @@ var ( // // The certificate carries no root, no key, and no value. Verification // requires these to be supplied from the outer proof tuple: -// - key (sid) — from the RPC request parameter. -// - value (txhash) — from CertificationData.TransactionHash. -// - root — from UC.IR.h. +// - key (sid) — from the RPC request parameter. +// - value (H(CBOR([txhash, referenceTime]))) — from CertificationData and +// InclusionProofV2.ReferenceTime. +// - root — from UC.IR.h. // // See docs/inclusion-proof-wire.md for the full specification. type InclusionCert struct { diff --git a/pkg/api/inclusion_proof_v2_verify_test.go b/pkg/api/inclusion_proof_v2_verify_test.go index 7cd609ef..68ccce7a 100644 --- a/pkg/api/inclusion_proof_v2_verify_test.go +++ b/pkg/api/inclusion_proof_v2_verify_test.go @@ -226,14 +226,16 @@ func buildSignedSingleLeafProof(t *testing.T, ownerShard types.ShardID) ( }, } - // Single-leaf root: H(0x00 || key || value) under the v2 hash algorithm. + // Single-leaf root: H(0x00 || key || value) under the v2 hash algorithm, + // where the leaf value binds the round's reference time. key, err := stateID.GetTreeKey() require.NoError(t, err) + const referenceTime uint64 = 1755000000 hasher := NewDataHasher(InclusionProofV2HashAlgorithm) hasher.Reset(). AddData([]byte{0x00}). AddData(key). - AddData(txHash.DataBytes()) + AddData(LeafValue(txHash.DataBytes(), referenceTime)) leafRoot := append([]byte(nil), hasher.GetHash().RawHash...) // Empty InclusionCert — single-leaf edge case, no siblings. @@ -329,8 +331,10 @@ func buildSignedSingleLeafProof(t *testing.T, ownerShard types.ShardID) ( ucBytes, err := types.Cbor.Marshal(uc) require.NoError(t, err) + certifiedAt := referenceTime proof := &InclusionProofV2{ CertificationData: &req.CertificationData, + ReferenceTime: &certifiedAt, CertificateBytes: certBytes, UnicityCertificate: ucBytes, } diff --git a/pkg/api/leaf_value.go b/pkg/api/leaf_value.go new file mode 100644 index 00000000..bfb688f8 --- /dev/null +++ b/pkg/api/leaf_value.go @@ -0,0 +1,23 @@ +package api + +// LeafValue returns the sparse Merkle tree leaf value the Unicity Service +// records for an accepted certification request: +// +// SHA-256( CBOR([transactionHash, referenceTime]) ) +// +// The value binds the reference time the request was validated under, not the +// transaction hash alone. The tree is append-only, so a leaf can be certified +// afresh against any later root and a later inclusion proof carries a later +// round's reference time. Binding the reference time into the leaf value fixes +// the value the transition was validated under, for any proof of that leaf. +// +// transactionHash is the raw 32-byte digest (no algorithm-id prefix); the +// returned value is raw 32 bytes, matching the v2 SMT profile. +func LeafValue(transactionHash []byte, referenceTime uint64) []byte { + return NewDataHasher(SHA256). + AddData(CborArray(2)). + AddCborBytes(transactionHash). + AddData(CborUint(referenceTime)). + GetHash(). + RawHash +} diff --git a/pkg/api/leaf_value_test.go b/pkg/api/leaf_value_test.go new file mode 100644 index 00000000..7ef1bf78 --- /dev/null +++ b/pkg/api/leaf_value_test.go @@ -0,0 +1,34 @@ +package api + +import ( + "encoding/hex" + "testing" +) + +// Shared across the Rust, Java and TypeScript implementations: the leaf value +// is SHA-256 over the deterministic CBOR array [transactionHash, referenceTime]. +const ( + sharedTransactionHash = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + sharedReferenceTime = uint64(1755000000) + sharedLeafValue = "0235bd52cfa10c9785dfa01942bc396f201fe715dbc3896ee117a97e895e1e36" +) + +func TestLeafValueMatchesTheSharedTestVector(t *testing.T) { + txHash, err := hex.DecodeString(sharedTransactionHash) + if err != nil { + t.Fatalf("failed to decode transaction hash: %v", err) + } + + got := hex.EncodeToString(LeafValue(txHash, sharedReferenceTime)) + if got != sharedLeafValue { + t.Fatalf("leaf value mismatch: got %s, want %s", got, sharedLeafValue) + } +} + +func TestLeafValueChangesWithTheReferenceTime(t *testing.T) { + txHash, _ := hex.DecodeString(sharedTransactionHash) + + if hex.EncodeToString(LeafValue(txHash, sharedReferenceTime+1)) == sharedLeafValue { + t.Fatal("leaf value did not change with the reference time") + } +} diff --git a/pkg/api/types.go b/pkg/api/types.go index 07e2b51b..98da3a67 100644 --- a/pkg/api/types.go +++ b/pkg/api/types.go @@ -112,11 +112,12 @@ type GetInclusionProofResponseV2 struct { // InclusionProofV2 is the canonical v2 inclusion proof payload. // -// Wire form: CBOR tag InclusionProofTag wrapping a 4-element toarray: +// Wire form: CBOR tag InclusionProofTag wrapping a 5-element toarray: // // #InclusionProofTag ([ // version: uint, // certificationDataOrNull, +// referenceTime: uint | null, // certificateBytes: bstr, // InclusionCert or ExclusionCert raw wire form // unicityCertificate: raw CBOR // ]) @@ -124,7 +125,13 @@ type GetInclusionProofResponseV2 struct { // Discriminator: // - CertificationData != nil → inclusion. CertificateBytes is an // InclusionCert wire payload. The SMT key comes from the outer RPC -// request (stateId); the leaf value is CertificationData.TransactionHash. +// request (stateId); the leaf value is +// LeafValue(CertificationData.TransactionHash, ReferenceTime). +// +// ReferenceTime is the reference time of the round the leaf was created in. It +// cannot be recovered from the embedded certificate: proofs are served against +// the current certified root, whose input record time is that of the latest +// round rather than the one the leaf was created under. // - CertificationData == nil → non-inclusion. CertificateBytes is an // ExclusionCert wire payload. Non-inclusion verification is not yet // implemented in Go. @@ -137,6 +144,7 @@ type InclusionProofV2 struct { _ struct{} `cbor:",toarray"` Version types.Version `json:"version"` CertificationData *CertificationData `json:"certificationData"` + ReferenceTime *uint64 `json:"referenceTime"` CertificateBytes HexBytes `json:"certificateBytes"` UnicityCertificate types.RawCBOR `json:"unicityCertificate"` } @@ -397,6 +405,9 @@ func (p *InclusionProofV2) Verify(v2 *CertificationRequest, vctx *VerifierContex ) { return errors.New("proof certification data transaction hash does not match certification request transaction hash") } + if !equalExpiresAt(p.CertificationData.ExpiresAt, v2.CertificationData.ExpiresAt) { + return errors.New("proof certification data expiry does not match certification request expiry") + } rootRaw, err := p.UCInputRecordHashRaw() if err != nil { @@ -411,8 +422,15 @@ func (p *InclusionProofV2) Verify(v2 *CertificationRequest, vctx *VerifierContex if err != nil { return fmt.Errorf("failed to derive SMT key from stateId: %w", err) } - // v2 leaf value is the raw transaction hash. - value := v2.CertificationData.TransactionHash.DataBytes() + if p.ReferenceTime == nil { + return errors.New("missing inclusion proof reference time") + } + // A request without an explicit deadline was admitted under a service-assigned + // one, which is not recorded here and is not checked by a later verifier. + if expiresAt := v2.CertificationData.ExpiresAt; expiresAt != nil && *p.ReferenceTime >= *expiresAt { + return errors.New("certification request expired") + } + value := LeafValue(v2.CertificationData.TransactionHash.DataBytes(), *p.ReferenceTime) if err := cert.Verify(key, value, rootRaw, InclusionProofV2HashAlgorithm); err != nil { return err } @@ -452,3 +470,12 @@ func ucInputRecordHashRaw(raw []byte) ([]byte, error) { } return append([]byte(nil), ir...), nil } + +// equalExpiresAt compares two optional request deadlines, treating "absent" as a +// value in its own right rather than as zero. +func equalExpiresAt(a, b *uint64) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} diff --git a/test/integration/sharding_e2e_test.go b/test/integration/sharding_e2e_test.go index 4a680654..f077ff3c 100644 --- a/test/integration/sharding_e2e_test.go +++ b/test/integration/sharding_e2e_test.go @@ -258,7 +258,9 @@ func waitForValidProof(t *testing.T, url string, req *api.CertificationRequest, require.NoError(t, err) key, err := req.StateID.GetTreeKey() require.NoError(t, err) - require.NoError(t, cert.Verify(key, req.CertificationData.TransactionHash.DataBytes(), rootRaw, api.InclusionProofV2HashAlgorithm)) + require.NotNil(t, resp.InclusionProof.ReferenceTime) + leafValue := api.LeafValue(req.CertificationData.TransactionHash.DataBytes(), *resp.InclusionProof.ReferenceTime) + require.NoError(t, cert.Verify(key, leafValue, rootRaw, api.InclusionProofV2HashAlgorithm)) return } }