Skip to content

Commit b32f73f

Browse files
committed
fix(repository): enforce valid epoch status transitions
1 parent e59b130 commit b32f73f

11 files changed

Lines changed: 588 additions & 57 deletions

File tree

internal/evmreader/input.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,12 @@ func (r *Service) readAndStoreInputs(
258258

259259
epochLength := app.application.EpochLength
260260
if epochLength == 0 {
261-
_ = r.setApplicationInoperable(ctx, app.application, "Application has epoch length of zero")
261+
// setApplicationInoperable always returns non-nil (the reason text itself).
262+
// The DB error case is already logged inside setApplicationState.
263+
// On DB success the app is marked inoperable and won't reappear next tick.
264+
// On DB failure the app reappears as Enabled next tick, retrying this path.
265+
_ = r.setApplicationInoperable(ctx, app.application,
266+
"Application has epoch length of zero")
262267
continue
263268
}
264269

internal/evmreader/output.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,10 @@ func (r *Service) readAndUpdateOutputs(
184184
}
185185

186186
if !bytes.Equal(output.RawData, event.Output) {
187+
// setApplicationInoperable always returns non-nil (the reason text itself).
188+
// The DB error case is already logged inside setApplicationState.
189+
// On DB success the app is marked inoperable and won't reappear next tick.
190+
// On DB failure the app reappears as Enabled next tick, retrying this path.
187191
_ = r.setApplicationInoperable(ctx, app.application,
188192
"Output mismatch. Application is in an invalid state. Output Index %d, raw data %s != event data %s",
189193
output.Index,

internal/repository/postgres/bulk.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,10 @@ func updateEpochClaim(
416416
).
417417
WHERE(
418418
table.Epoch.ApplicationID.EQ(postgres.Int64(e.ApplicationID)).
419-
AND(table.Epoch.Index.EQ(uint64Expr(e.Index))),
419+
AND(table.Epoch.Index.EQ(uint64Expr(e.Index))).
420+
AND(table.Epoch.Status.EQ(
421+
postgres.NewEnumValue(model.EpochStatus_InputsProcessed.String()),
422+
)),
420423
)
421424

422425
sqlStr, args := updStmt.Sql()

internal/repository/postgres/epoch.go

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,38 @@ func (r *PostgresRepository) CreateEpochsAndInputs(
126126
whereClause,
127127
)
128128

129+
// Guard: only update epoch fields when the existing row is still OPEN.
130+
// Once an epoch is sealed (CLOSED) or beyond, its status, block range,
131+
// input bounds, and tournament address are finalized and must not be
132+
// overwritten by crash-recovery re-processing.
133+
isOpen := table.Epoch.Status.EQ(
134+
postgres.NewEnumValue(model.EpochStatus_Open.String()),
135+
)
136+
129137
sqlStr, args := epochInsertStmt.QUERY(epochSelectQuery).
130138
ON_CONFLICT(table.Epoch.ApplicationID, table.Epoch.Index).
131139
DO_UPDATE(postgres.SET(
132-
table.Epoch.Status.SET(postgres.NewEnumValue(epoch.Status.String())),
133-
table.Epoch.LastBlock.SET(uint64Expr(epoch.LastBlock)),
134-
table.Epoch.InputIndexUpperBound.SET(uint64Expr(epoch.InputIndexUpperBound)),
135-
table.Epoch.TournamentAddress.SET(tournamentAddress),
136-
)).Sql() // FIXME on conflict
140+
table.Epoch.Status.SET(postgres.StringExp(
141+
postgres.CASE().
142+
WHEN(isOpen).THEN(table.Epoch.EXCLUDED.Status).
143+
ELSE(table.Epoch.Status),
144+
)),
145+
table.Epoch.LastBlock.SET(postgres.FloatExp(
146+
postgres.CASE().
147+
WHEN(isOpen).THEN(table.Epoch.EXCLUDED.LastBlock).
148+
ELSE(table.Epoch.LastBlock),
149+
)),
150+
table.Epoch.InputIndexUpperBound.SET(postgres.FloatExp(
151+
postgres.CASE().
152+
WHEN(isOpen).THEN(table.Epoch.EXCLUDED.InputIndexUpperBound).
153+
ELSE(table.Epoch.InputIndexUpperBound),
154+
)),
155+
table.Epoch.TournamentAddress.SET(postgres.ByteaExp(
156+
postgres.CASE().
157+
WHEN(isOpen).THEN(table.Epoch.EXCLUDED.TournamentAddress).
158+
ELSE(table.Epoch.TournamentAddress),
159+
)),
160+
)).Sql()
137161
_, err = tx.Exec(ctx, sqlStr, args...)
138162

139163
if err != nil {

internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,15 @@ ALTER TABLE "tournaments" DROP CONSTRAINT "tournaments_parent_match_fkey";
1414

1515
DROP TRIGGER IF EXISTS "matches_set_updated_at" ON "matches";
1616
DROP INDEX IF EXISTS "matches_unique_pair_idx";
17-
DROP INDEX IF EXISTS "matches_app_epoch_tournament_idx";
1817
DROP TABLE IF EXISTS "matches";
1918

2019
DROP TRIGGER IF EXISTS "commitments_set_updated_at" ON "commitments";
2120
DROP INDEX IF EXISTS "commitments_final_state_idx";
22-
DROP INDEX IF EXISTS "commitments_app_epoch_tournament_idx";
2321
DROP TABLE IF EXISTS "commitments";
2422

2523
DROP TRIGGER IF EXISTS "tournaments_set_updated_at" ON "tournaments";
2624
DROP INDEX IF EXISTS "tournaments_parent_match_nonroot_idx";
2725
DROP INDEX IF EXISTS "unique_root_per_epoch_idx";
28-
DROP INDEX IF EXISTS "tournaments_epoch_idx";
2926
DROP TABLE IF EXISTS "tournaments";
3027

3128
DROP TRIGGER IF EXISTS "node_config_set_updated_at" ON "node_config";
@@ -45,11 +42,14 @@ DROP INDEX IF EXISTS "input_status_idx";
4542
DROP INDEX IF EXISTS "input_block_number_idx";
4643
DROP TABLE IF EXISTS "input";
4744

45+
DROP TRIGGER IF EXISTS "epoch_status_transition_check" ON "epoch";
4846
DROP TRIGGER IF EXISTS "epoch_set_updated_at" ON "epoch";
4947
DROP INDEX IF EXISTS "epoch_status_idx";
5048
DROP INDEX IF EXISTS "epoch_last_block_idx";
5149
DROP TABLE IF EXISTS "epoch";
5250

51+
DROP FUNCTION IF EXISTS "enforce_epoch_status_transition";
52+
5353
DROP TRIGGER IF EXISTS "execution_parameters_set_updated_at" ON "execution_parameters";
5454
DROP TABLE IF EXISTS "execution_parameters";
5555

internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,84 @@ CREATE INDEX "epoch_status_idx" ON "epoch"("application_id", "status");
182182
CREATE TRIGGER "epoch_set_updated_at" BEFORE UPDATE ON "epoch"
183183
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
184184

185+
-- Enforce valid epoch status transitions.
186+
-- The state machine is:
187+
-- OPEN → CLOSED → INPUTS_PROCESSED → CLAIM_COMPUTED
188+
-- CLAIM_COMPUTED → CLAIM_SUBMITTED → CLAIM_ACCEPTED
189+
-- CLAIM_COMPUTED → CLAIM_ACCEPTED (PRT skips SUBMITTED; also valid when
190+
-- syncing from scratch and the claim was
191+
-- already accepted, or in reader-only mode
192+
-- with tx submission disabled)
193+
-- CLAIM_SUBMITTED → CLAIM_REJECTED
194+
-- Any other transition (including backwards) is rejected.
195+
-- Same-status updates are allowed (idempotent no-ops).
196+
--
197+
-- When transitioning to CLAIM_COMPUTED, the trigger also verifies that
198+
-- required proof fields are populated:
199+
-- All apps: machine_hash, outputs_merkle_root, outputs_merkle_proof
200+
-- PRT (DaveConsensus): additionally commitment, commitment_proof
201+
CREATE FUNCTION enforce_epoch_status_transition() RETURNS trigger AS $$
202+
DECLARE
203+
valid_transitions text[][] := ARRAY[
204+
ARRAY['OPEN', 'CLOSED'],
205+
ARRAY['CLOSED', 'INPUTS_PROCESSED'],
206+
ARRAY['INPUTS_PROCESSED', 'CLAIM_COMPUTED'],
207+
ARRAY['CLAIM_COMPUTED', 'CLAIM_SUBMITTED'],
208+
ARRAY['CLAIM_COMPUTED', 'CLAIM_ACCEPTED'],
209+
ARRAY['CLAIM_SUBMITTED', 'CLAIM_ACCEPTED'],
210+
ARRAY['CLAIM_SUBMITTED', 'CLAIM_REJECTED']
211+
];
212+
is_valid boolean := false;
213+
app_consensus text;
214+
BEGIN
215+
IF OLD.status = NEW.status THEN
216+
RETURN NEW;
217+
END IF;
218+
FOR i IN 1..array_length(valid_transitions, 1) LOOP
219+
IF OLD.status::text = valid_transitions[i][1]
220+
AND NEW.status::text = valid_transitions[i][2] THEN
221+
is_valid := true;
222+
EXIT;
223+
END IF;
224+
END LOOP;
225+
IF NOT is_valid THEN
226+
RAISE EXCEPTION 'invalid epoch status transition: % -> %',
227+
OLD.status, NEW.status;
228+
END IF;
229+
230+
-- Enforce required fields when entering CLAIM_COMPUTED.
231+
IF NEW.status::text = 'CLAIM_COMPUTED' THEN
232+
IF NEW.machine_hash IS NULL
233+
OR NEW.outputs_merkle_root IS NULL
234+
OR NEW.outputs_merkle_proof IS NULL THEN
235+
RAISE EXCEPTION
236+
'CLAIM_COMPUTED requires machine_hash, outputs_merkle_root, '
237+
'and outputs_merkle_proof to be non-null';
238+
END IF;
239+
240+
SELECT a.consensus_type::text INTO app_consensus
241+
FROM application a
242+
WHERE a.id = NEW.application_id;
243+
244+
IF app_consensus = 'PRT' THEN
245+
IF NEW.commitment IS NULL
246+
OR NEW.commitment_proof IS NULL THEN
247+
RAISE EXCEPTION
248+
'CLAIM_COMPUTED for PRT apps requires commitment '
249+
'and commitment_proof to be non-null';
250+
END IF;
251+
END IF;
252+
END IF;
253+
254+
RETURN NEW;
255+
END;
256+
$$ LANGUAGE plpgsql;
257+
258+
CREATE TRIGGER "epoch_status_transition_check"
259+
BEFORE UPDATE OF "status" ON "epoch"
260+
FOR EACH ROW
261+
EXECUTE FUNCTION enforce_epoch_status_transition();
262+
185263
CREATE TABLE "input"
186264
(
187265
"epoch_application_id" int4 NOT NULL,
@@ -292,9 +370,6 @@ CREATE TABLE "tournaments"
292370
CONSTRAINT "tournaments_max_level_gte_level_check" CHECK ("max_level" >= "level")
293371
);
294372

295-
CREATE INDEX "tournaments_epoch_idx"
296-
ON "tournaments"("application_id","epoch_index");
297-
298373
CREATE UNIQUE INDEX "unique_root_per_epoch_idx"
299374
ON "tournaments"("application_id","epoch_index")
300375
WHERE "level" = 0;
@@ -327,9 +402,6 @@ CREATE TABLE "commitments"
327402
ON DELETE CASCADE
328403
);
329404

330-
CREATE INDEX "commitments_app_epoch_tournament_idx"
331-
ON "commitments"("application_id","epoch_index","tournament_address");
332-
333405
CREATE INDEX "commitments_final_state_idx"
334406
ON "commitments"("final_state_hash");
335407

@@ -373,9 +445,6 @@ CREATE TABLE "matches"
373445
ON DELETE RESTRICT
374446
);
375447

376-
CREATE INDEX "matches_app_epoch_tournament_idx"
377-
ON "matches"("application_id","epoch_index","tournament_address");
378-
379448
CREATE UNIQUE INDEX "matches_unique_pair_idx"
380449
ON "matches"("application_id","epoch_index","tournament_address","commitment_one","commitment_two");
381450

internal/repository/repotest/builders.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,119 @@ type SeedResult struct {
461461
Input *Input
462462
}
463463

464+
// AdvanceEpochStatus transitions an epoch through the valid state machine to
465+
// the target status, finding the shortest path through the transition graph.
466+
// The graph mirrors the SQL trigger enforce_epoch_status_transition:
467+
//
468+
// OPEN → CLOSED → INPUTS_PROCESSED → CLAIM_COMPUTED
469+
// CLAIM_COMPUTED → CLAIM_SUBMITTED → CLAIM_ACCEPTED
470+
// CLAIM_COMPUTED → CLAIM_ACCEPTED (PRT, sync catch-up, or reader-only mode)
471+
// CLAIM_SUBMITTED → CLAIM_REJECTED
472+
func AdvanceEpochStatus(
473+
ctx context.Context, t *testing.T,
474+
repo repository.Repository,
475+
nameOrAddress string,
476+
epoch *Epoch,
477+
target EpochStatus,
478+
) {
479+
t.Helper()
480+
481+
// Adjacency list mirrors the SQL trigger's valid transitions.
482+
next := map[EpochStatus][]EpochStatus{
483+
EpochStatus_Open: {EpochStatus_Closed},
484+
EpochStatus_Closed: {EpochStatus_InputsProcessed},
485+
EpochStatus_InputsProcessed: {EpochStatus_ClaimComputed},
486+
EpochStatus_ClaimComputed: {EpochStatus_ClaimSubmitted, EpochStatus_ClaimAccepted},
487+
EpochStatus_ClaimSubmitted: {EpochStatus_ClaimAccepted, EpochStatus_ClaimRejected},
488+
}
489+
490+
// BFS to find shortest valid path.
491+
type step struct {
492+
status EpochStatus
493+
path []EpochStatus
494+
}
495+
queue := []step{{status: epoch.Status, path: nil}}
496+
visited := map[EpochStatus]bool{epoch.Status: true}
497+
498+
var path []EpochStatus
499+
for len(queue) > 0 {
500+
cur := queue[0]
501+
queue = queue[1:]
502+
if cur.status == target {
503+
path = cur.path
504+
break
505+
}
506+
for _, n := range next[cur.status] {
507+
if !visited[n] {
508+
visited[n] = true
509+
p := make([]EpochStatus, len(cur.path)+1)
510+
copy(p, cur.path)
511+
p[len(cur.path)] = n
512+
queue = append(queue, step{n, p})
513+
}
514+
}
515+
}
516+
if path == nil {
517+
t.Fatalf("AdvanceEpochStatus: no valid path from %s to %s",
518+
epoch.Status, target)
519+
}
520+
521+
for _, s := range path {
522+
// The DB trigger requires proof fields to be non-null when
523+
// entering CLAIM_COMPUTED. Populate them with dummy values
524+
// so tests that only care about status transitions don't need
525+
// to set up proofs manually.
526+
if s == EpochStatus_ClaimComputed {
527+
setDummyProofFields(ctx, t, repo, nameOrAddress, epoch)
528+
// For PRT apps StoreClaimAndProofs already set the status
529+
// to CLAIM_COMPUTED, so skip the redundant UpdateEpochStatus.
530+
app, err := repo.GetApplication(ctx, nameOrAddress)
531+
require.NoError(t, err)
532+
if app.IsDaveConsensus() {
533+
epoch.Status = s
534+
continue
535+
}
536+
}
537+
epoch.Status = s
538+
err := repo.UpdateEpochStatus(ctx, nameOrAddress, epoch)
539+
require.NoError(t, err)
540+
}
541+
}
542+
543+
// setDummyProofFields populates the proof fields required by the DB trigger
544+
// for the INPUTS_PROCESSED → CLAIM_COMPUTED transition.
545+
// For all apps: machine_hash, outputs_merkle_root, outputs_merkle_proof.
546+
// For PRT apps: additionally commitment and commitment_proof (set via
547+
// StoreClaimAndProofs which also transitions the status atomically).
548+
func setDummyProofFields(
549+
ctx context.Context, t *testing.T,
550+
repo repository.Repository,
551+
nameOrAddress string,
552+
epoch *Epoch,
553+
) {
554+
t.Helper()
555+
556+
proof := &OutputsProof{
557+
OutputsHash: UniqueHash(),
558+
OutputsHashProof: [][32]byte{[32]byte(UniqueHash())},
559+
MachineHash: UniqueHash(),
560+
}
561+
err := repo.UpdateEpochOutputsProof(
562+
ctx, epoch.ApplicationID, epoch.Index, proof)
563+
require.NoError(t, err)
564+
565+
app, err := repo.GetApplication(ctx, nameOrAddress)
566+
require.NoError(t, err)
567+
if app.IsDaveConsensus() {
568+
commitHash := UniqueHash()
569+
epoch.Commitment = &commitHash
570+
epoch.CommitmentProof = []common.Hash{UniqueHash()}
571+
epoch.Status = EpochStatus_ClaimComputed
572+
err = repo.StoreClaimAndProofs(ctx, epoch, nil)
573+
require.NoError(t, err)
574+
}
575+
}
576+
464577
// Seed creates and persists a minimal Application with one Epoch and one Input.
465578
func Seed(ctx context.Context, t *testing.T, repo repository.Repository) *SeedResult {
466579
t.Helper()

internal/repository/repotest/bulk_test_cases.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,10 @@ func (s *BulkOperationsSuite) TestStoreClaimAndProofs() {
354354
s.Run("StoresClaimAndOutputProofs", func() {
355355
seed := Seed(s.Ctx, s.T(), s.Repo)
356356

357+
// Advance epoch to INPUTS_PROCESSED so StoreClaimAndProofs can set CLAIM_COMPUTED
358+
AdvanceEpochStatus(s.Ctx, s.T(), s.Repo,
359+
seed.App.IApplicationAddress.String(), seed.Epoch, EpochStatus_InputsProcessed)
360+
357361
// First store an advance result to create outputs
358362
machineHash := crypto.Keccak256Hash([]byte("machine"))
359363
outputData := []byte("output-for-claim")
@@ -700,6 +704,12 @@ func (s *BulkOperationsSuite) TestStoreClaimAndProofsRollback() {
700704
s.Run("RollbackOnOutputProofUpdateFailure", func() {
701705
seed := Seed(s.Ctx, s.T(), s.Repo)
702706

707+
// Advance epoch to INPUTS_PROCESSED so updateEpochClaim can
708+
// set CLAIM_COMPUTED (the trigger rejects other transitions).
709+
AdvanceEpochStatus(s.Ctx, s.T(), s.Repo,
710+
seed.App.IApplicationAddress.String(), seed.Epoch,
711+
EpochStatus_InputsProcessed)
712+
703713
// Store advance result to create one output (index 0)
704714
result := &AdvanceResult{
705715
EpochIndex: 0,
@@ -733,12 +743,12 @@ func (s *BulkOperationsSuite) TestStoreClaimAndProofsRollback() {
733743
s.Ctx, seed.Epoch, []*Output{nonExistentOutput})
734744
s.Require().Error(err)
735745

736-
// Verify the epoch status was rolled back — still Closed
746+
// Verify the epoch status was rolled back — still InputsProcessed
737747
gotEpoch, err := s.Repo.GetEpoch(
738748
s.Ctx, seed.App.IApplicationAddress.String(), 0)
739749
s.Require().NoError(err)
740-
s.Equal(EpochStatus_Closed, gotEpoch.Status,
741-
"epoch status should be rolled back to Closed")
750+
s.Equal(EpochStatus_InputsProcessed, gotEpoch.Status,
751+
"epoch status should be rolled back to InputsProcessed")
742752
s.Nil(gotEpoch.Commitment,
743753
"commitment should not have been persisted")
744754

0 commit comments

Comments
 (0)