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
}
}