Skip to content
Merged
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions examples/client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
55 changes: 39 additions & 16 deletions internal/bft/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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())
Expand Down
28 changes: 26 additions & 2 deletions internal/bft/client_stub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -34,6 +38,7 @@ func NewBFTClientStub(logger *logger.Logger, roundManager RoundManager, nextRoun
roundManager: roundManager,
nextRoundNumber: nextRoundNumber,
delay: delay,
referenceTime: uint64(time.Now().Unix()),
}
}

Expand All @@ -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() {
Expand Down Expand Up @@ -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)
Expand All @@ -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())
}
}()
Expand Down
Loading
Loading