diff --git a/.gitignore b/.gitignore index ace1179c..ed459b48 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,4 @@ vite.config.ts.timestamp-* # IA .CLAUDE.md +.agents \ No newline at end of file diff --git a/apps/backend/src/contributions/contribution-transitions.spec.ts b/apps/backend/src/contributions/contribution-transitions.spec.ts new file mode 100644 index 00000000..a0cc7d41 --- /dev/null +++ b/apps/backend/src/contributions/contribution-transitions.spec.ts @@ -0,0 +1,140 @@ +import { ParticipantStatus, ParticipantContributionStep } from 'src/types/enums'; +import { + canCreateContribution, + canSetContributionValidity, + TransitionParticipant, + CREATE_TRANSITION_ERROR, + SET_VALID_TRANSITION_ERROR, +} from './contribution-transitions'; + +describe('Contribution transition helpers', () => { + describe('canCreateContribution', () => { + const allowedCombinations: [ParticipantStatus, ParticipantContributionStep][] = [ + [ParticipantStatus.CONTRIBUTING, ParticipantContributionStep.UPLOADING], + [ParticipantStatus.CONTRIBUTING, ParticipantContributionStep.VERIFYING], + ]; + + it.each(allowedCombinations)( + 'should return true for status=%s step=%s', + (status, contributionStep) => { + const participant: TransitionParticipant = { status, contributionStep }; + expect(canCreateContribution(participant)).toBe(true); + }, + ); + + const disallowedStatuses = [ + ParticipantStatus.CREATED, + ParticipantStatus.WAITING, + ParticipantStatus.READY, + ParticipantStatus.CONTRIBUTED, + ParticipantStatus.DONE, + ParticipantStatus.FINALIZING, + ParticipantStatus.FINALIZED, + ParticipantStatus.TIMEDOUT, + ParticipantStatus.EXHUMED, + ]; + + it.each(disallowedStatuses)( + 'should return false for status=%s regardless of step', + (status) => { + for (const step of Object.values(ParticipantContributionStep)) { + const participant: TransitionParticipant = { + status, + contributionStep: step, + }; + expect(canCreateContribution(participant)).toBe(false); + } + }, + ); + + const disallowedStepsForContributing = [ + ParticipantContributionStep.DOWNLOADING, + ParticipantContributionStep.COMPUTING, + ParticipantContributionStep.COMPLETED, + ]; + + it.each(disallowedStepsForContributing)( + 'should return false for CONTRIBUTING + step=%s', + (step) => { + const participant: TransitionParticipant = { + status: ParticipantStatus.CONTRIBUTING, + contributionStep: step, + }; + expect(canCreateContribution(participant)).toBe(false); + }, + ); + }); + + describe('canSetContributionValidity', () => { + const allowedCombinations: [ParticipantStatus, ParticipantContributionStep][] = [ + [ParticipantStatus.CONTRIBUTED, ParticipantContributionStep.VERIFYING], + [ParticipantStatus.CONTRIBUTED, ParticipantContributionStep.COMPLETED], + [ParticipantStatus.FINALIZED, ParticipantContributionStep.VERIFYING], + [ParticipantStatus.FINALIZED, ParticipantContributionStep.COMPLETED], + ]; + + it.each(allowedCombinations)( + 'should return true for status=%s step=%s', + (status, contributionStep) => { + const participant: TransitionParticipant = { status, contributionStep }; + expect(canSetContributionValidity(participant)).toBe(true); + }, + ); + + const disallowedStatuses = [ + ParticipantStatus.CREATED, + ParticipantStatus.WAITING, + ParticipantStatus.READY, + ParticipantStatus.CONTRIBUTING, + ParticipantStatus.DONE, + ParticipantStatus.FINALIZING, + ParticipantStatus.TIMEDOUT, + ParticipantStatus.EXHUMED, + ]; + + it.each(disallowedStatuses)( + 'should return false for status=%s regardless of step', + (status) => { + for (const step of Object.values(ParticipantContributionStep)) { + const participant: TransitionParticipant = { + status, + contributionStep: step, + }; + expect(canSetContributionValidity(participant)).toBe(false); + } + }, + ); + + const disallowedStepsForContributed = [ + ParticipantContributionStep.DOWNLOADING, + ParticipantContributionStep.COMPUTING, + ParticipantContributionStep.UPLOADING, + ]; + + it.each(disallowedStepsForContributed)( + 'should return false for CONTRIBUTED + step=%s', + (step) => { + const participant: TransitionParticipant = { + status: ParticipantStatus.CONTRIBUTED, + contributionStep: step, + }; + expect(canSetContributionValidity(participant)).toBe(false); + }, + ); + }); + + describe('error message constants', () => { + it('should export CREATE_TRANSITION_ERROR', () => { + expect(CREATE_TRANSITION_ERROR).toContain('CONTRIBUTING'); + expect(CREATE_TRANSITION_ERROR).toContain('UPLOADING'); + expect(CREATE_TRANSITION_ERROR).toContain('VERIFYING'); + }); + + it('should export SET_VALID_TRANSITION_ERROR', () => { + expect(SET_VALID_TRANSITION_ERROR).toContain('CONTRIBUTED'); + expect(SET_VALID_TRANSITION_ERROR).toContain('FINALIZED'); + expect(SET_VALID_TRANSITION_ERROR).toContain('VERIFYING'); + expect(SET_VALID_TRANSITION_ERROR).toContain('COMPLETED'); + }); + }); +}); diff --git a/apps/backend/src/contributions/contribution-transitions.ts b/apps/backend/src/contributions/contribution-transitions.ts new file mode 100644 index 00000000..2b305b12 --- /dev/null +++ b/apps/backend/src/contributions/contribution-transitions.ts @@ -0,0 +1,94 @@ +import { ParticipantStatus, ParticipantContributionStep } from 'src/types/enums'; + +/** + * Allowed participant states for creating a contribution record. + * + * In the p0tion ceremony flow, a contribution document is created when the + * participant is the current contributor (`CONTRIBUTING`) and has reached the + * upload or verification phase (`UPLOADING` or `VERIFYING`). + * + * Earlier steps (`DOWNLOADING`, `COMPUTING`) mean the participant has not yet + * produced a zKey to record; later/other statuses mean the contribution window + * has passed. + */ +const ALLOWED_CREATE_STATUSES = new Set([ParticipantStatus.CONTRIBUTING]); + +const ALLOWED_CREATE_STEPS = new Set([ + ParticipantContributionStep.UPLOADING, + ParticipantContributionStep.VERIFYING, +]); + +/** + * Allowed participant states for setting the `valid` field on a contribution. + * + * Verification results are recorded after the participant has contributed + * (`CONTRIBUTED` or `FINALIZED`) and the contribution step is in + * `VERIFYING` (verification running) or `COMPLETED` (verification finished). + * + * This ensures that `valid` can only be set once the actual verification + * process has been reached, aligning with p0tion's `verifycontribution` flow. + */ +const ALLOWED_SET_VALID_STATUSES = new Set([ + ParticipantStatus.CONTRIBUTED, + ParticipantStatus.FINALIZED, +]); + +const ALLOWED_SET_VALID_STEPS = new Set([ + ParticipantContributionStep.VERIFYING, + ParticipantContributionStep.COMPLETED, +]); + +/** + * Participant-like shape required by the transition helpers. + * Only the fields needed for lifecycle checks are required. + */ +export interface TransitionParticipant { + status: ParticipantStatus; + contributionStep: ParticipantContributionStep; +} + +/** + * Determines whether a contribution record can be created for the given + * participant based on their current status and contribution step. + * + * @param participant - The participant whose state is being checked + * @returns `true` if the participant is in a valid state to create a contribution + * + * @example + * ```ts + * canCreateContribution({ status: 'CONTRIBUTING', contributionStep: 'UPLOADING' }); // true + * canCreateContribution({ status: 'WAITING', contributionStep: 'DOWNLOADING' }); // false + * ``` + */ +export function canCreateContribution(participant: TransitionParticipant): boolean { + return ( + ALLOWED_CREATE_STATUSES.has(participant.status) && + ALLOWED_CREATE_STEPS.has(participant.contributionStep) + ); +} + +/** + * Determines whether the `valid` field can be set on a contribution + * based on the owning participant's current status and contribution step. + * + * @param participant - The participant whose state is being checked + * @returns `true` if the participant is in a valid state to have contribution validity set + * + * @example + * ```ts + * canSetContributionValidity({ status: 'CONTRIBUTED', contributionStep: 'VERIFYING' }); // true + * canSetContributionValidity({ status: 'CONTRIBUTING', contributionStep: 'COMPUTING' }); // false + * ``` + */ +export function canSetContributionValidity(participant: TransitionParticipant): boolean { + return ( + ALLOWED_SET_VALID_STATUSES.has(participant.status) && + ALLOWED_SET_VALID_STEPS.has(participant.contributionStep) + ); +} + +export const CREATE_TRANSITION_ERROR = + 'Contribution can only be created when participant is CONTRIBUTING in UPLOADING or VERIFYING step'; + +export const SET_VALID_TRANSITION_ERROR = + 'Contribution validity can only be set when participant is CONTRIBUTED or FINALIZED in VERIFYING or COMPLETED step'; diff --git a/apps/backend/src/contributions/contributions.service.spec.ts b/apps/backend/src/contributions/contributions.service.spec.ts index b50c75d0..6a7e0a9e 100644 --- a/apps/backend/src/contributions/contributions.service.spec.ts +++ b/apps/backend/src/contributions/contributions.service.spec.ts @@ -8,9 +8,11 @@ import { getModelToken } from '@nestjs/sequelize'; import { Test, TestingModule } from '@nestjs/testing'; import { CircuitsService } from 'src/circuits/circuits.service'; import { ParticipantsService } from 'src/participants/participants.service'; +import { ParticipantStatus, ParticipantContributionStep } from 'src/types/enums'; import { ContributionsService } from './contributions.service'; import { CreateContributionDto } from './dto/create-contribution.dto'; import { UpdateContributionDto } from './dto/update-contribution.dto'; +import { CREATE_TRANSITION_ERROR, SET_VALID_TRANSITION_ERROR } from './contribution-transitions'; jest.mock('./contribution.model', () => { return { @@ -41,7 +43,8 @@ describe('ContributionsService', () => { id: 1, userId: 100, ceremonyId: 10, - status: 'CONTRIBUTING', + status: ParticipantStatus.CONTRIBUTING, + contributionStep: ParticipantContributionStep.UPLOADING, }; const mockContribution = { @@ -101,7 +104,7 @@ describe('ContributionsService', () => { participantId: 1, }; - it('should successfully create a contribution', async () => { + it('should successfully create a contribution when participant is CONTRIBUTING + UPLOADING', async () => { (mockCircuitsService.findOne as jest.Mock).mockResolvedValueOnce(mockCircuit); (mockParticipantsService.findOne as jest.Mock).mockResolvedValueOnce(mockParticipant); mockContributionModel.findOne.mockResolvedValueOnce(null); @@ -120,6 +123,19 @@ describe('ContributionsService', () => { expect(result).toEqual({ id: 1, ...createDto }); }); + it('should successfully create a contribution when participant is CONTRIBUTING + VERIFYING', async () => { + (mockCircuitsService.findOne as jest.Mock).mockResolvedValueOnce(mockCircuit); + (mockParticipantsService.findOne as jest.Mock).mockResolvedValueOnce({ + ...mockParticipant, + contributionStep: ParticipantContributionStep.VERIFYING, + }); + mockContributionModel.findOne.mockResolvedValueOnce(null); + mockContributionModel.create.mockResolvedValueOnce({ id: 1, ...createDto }); + + const result = await service.create(createDto); + expect(result).toEqual({ id: 1, ...createDto }); + }); + it('should throw NotFoundException when circuit does not exist', async () => { (mockCircuitsService.findOne as jest.Mock).mockResolvedValueOnce(null); @@ -162,6 +178,36 @@ describe('ContributionsService', () => { await expect(service.create(createDto)).rejects.toThrow(ConflictException); }); + + describe('transition validation', () => { + const disallowedParticipantStates: [ParticipantStatus, ParticipantContributionStep][] = [ + [ParticipantStatus.CREATED, ParticipantContributionStep.DOWNLOADING], + [ParticipantStatus.WAITING, ParticipantContributionStep.DOWNLOADING], + [ParticipantStatus.READY, ParticipantContributionStep.DOWNLOADING], + [ParticipantStatus.CONTRIBUTED, ParticipantContributionStep.COMPLETED], + [ParticipantStatus.DONE, ParticipantContributionStep.COMPLETED], + [ParticipantStatus.TIMEDOUT, ParticipantContributionStep.DOWNLOADING], + [ParticipantStatus.CONTRIBUTING, ParticipantContributionStep.DOWNLOADING], + [ParticipantStatus.CONTRIBUTING, ParticipantContributionStep.COMPUTING], + [ParticipantStatus.CONTRIBUTING, ParticipantContributionStep.COMPLETED], + ]; + + it.each(disallowedParticipantStates)( + 'should throw BadRequestException when participant is %s + %s', + async (status, contributionStep) => { + (mockCircuitsService.findOne as jest.Mock).mockResolvedValueOnce(mockCircuit); + (mockParticipantsService.findOne as jest.Mock).mockResolvedValueOnce({ + ...mockParticipant, + status, + contributionStep, + }); + + await expect(service.create(createDto)).rejects.toThrow( + new BadRequestException(CREATE_TRANSITION_ERROR), + ); + }, + ); + }); }); describe('findAll', () => { @@ -253,9 +299,8 @@ describe('ContributionsService', () => { }); describe('update', () => { - it('should update a contribution', async () => { + it('should update non-valid fields without transition check', async () => { const updateDto: UpdateContributionDto = { - valid: true, verifyContributionTime: 60, }; @@ -269,15 +314,174 @@ describe('ContributionsService', () => { expect(mockContributionModel.findByPk).toHaveBeenCalledWith(1); expect(mockContribution.update).toHaveBeenCalledWith(updateDto); + expect(mockParticipantsService.findOne).not.toHaveBeenCalled(); expect(result).toEqual(mockContribution); }); + it('should allow setting valid when participant is CONTRIBUTED + VERIFYING', async () => { + const contributionRecord = { + ...mockContribution, + valid: undefined, + }; + const updateDto: UpdateContributionDto = { valid: true }; + + mockContributionModel.findByPk.mockResolvedValueOnce(contributionRecord); + (mockParticipantsService.findOne as jest.Mock).mockResolvedValueOnce({ + ...mockParticipant, + status: ParticipantStatus.CONTRIBUTED, + contributionStep: ParticipantContributionStep.VERIFYING, + }); + contributionRecord.update = jest.fn().mockResolvedValueOnce({ + ...contributionRecord, + valid: true, + }); + + const result = await service.update(1, updateDto); + expect(result).toBeDefined(); + expect(mockParticipantsService.findOne).toHaveBeenCalledWith(1); + }); + + it('should allow setting valid when participant is FINALIZED + COMPLETED', async () => { + const contributionRecord = { + ...mockContribution, + valid: undefined, + }; + const updateDto: UpdateContributionDto = { valid: false }; + + mockContributionModel.findByPk.mockResolvedValueOnce(contributionRecord); + (mockParticipantsService.findOne as jest.Mock).mockResolvedValueOnce({ + ...mockParticipant, + status: ParticipantStatus.FINALIZED, + contributionStep: ParticipantContributionStep.COMPLETED, + }); + contributionRecord.update = jest.fn().mockResolvedValueOnce({ + ...contributionRecord, + valid: false, + }); + + const result = await service.update(1, updateDto); + expect(result).toBeDefined(); + }); + + it('should throw BadRequestException when setting valid with invalid participant state', async () => { + const contributionRecord = { + ...mockContribution, + valid: undefined, + participantId: 1, + }; + const updateDto: UpdateContributionDto = { valid: true }; + + mockContributionModel.findByPk.mockResolvedValueOnce(contributionRecord); + (mockParticipantsService.findOne as jest.Mock).mockResolvedValueOnce({ + ...mockParticipant, + status: ParticipantStatus.CONTRIBUTING, + contributionStep: ParticipantContributionStep.COMPUTING, + }); + + await expect(service.update(1, updateDto)).rejects.toThrow( + new BadRequestException(SET_VALID_TRANSITION_ERROR), + ); + }); + it('should throw NotFoundException when updating a non-existent contribution', async () => { const updateDto: UpdateContributionDto = { valid: false }; mockContributionModel.findByPk.mockResolvedValueOnce(null); await expect(service.update(999, updateDto)).rejects.toThrow(NotFoundException); }); + + describe('idempotency', () => { + it('should allow setting valid=true when contribution already has valid=true (no participant check)', async () => { + const contributionRecord = { + ...mockContribution, + valid: true, + participantId: 1, + update: jest.fn().mockResolvedValueOnce({ ...mockContribution, valid: true }), + }; + const updateDto: UpdateContributionDto = { valid: true }; + + mockContributionModel.findByPk.mockResolvedValueOnce(contributionRecord); + + const result = await service.update(1, updateDto); + + expect(mockParticipantsService.findOne).not.toHaveBeenCalled(); + expect(contributionRecord.update).toHaveBeenCalledWith(updateDto); + expect(result).toBeDefined(); + }); + + it('should allow setting valid=false when contribution already has valid=false (no participant check)', async () => { + const contributionRecord = { + ...mockContribution, + valid: false, + participantId: 1, + update: jest.fn().mockResolvedValueOnce({ ...mockContribution, valid: false }), + }; + const updateDto: UpdateContributionDto = { valid: false }; + + mockContributionModel.findByPk.mockResolvedValueOnce(contributionRecord); + + const result = await service.update(1, updateDto); + + expect(mockParticipantsService.findOne).not.toHaveBeenCalled(); + expect(contributionRecord.update).toHaveBeenCalledWith(updateDto); + expect(result).toBeDefined(); + }); + + it('should check participant state when changing valid from true to false', async () => { + const contributionRecord = { + ...mockContribution, + valid: true, + participantId: 1, + update: jest.fn().mockResolvedValueOnce({ ...mockContribution, valid: false }), + }; + const updateDto: UpdateContributionDto = { valid: false }; + + mockContributionModel.findByPk.mockResolvedValueOnce(contributionRecord); + (mockParticipantsService.findOne as jest.Mock).mockResolvedValueOnce({ + ...mockParticipant, + status: ParticipantStatus.CONTRIBUTED, + contributionStep: ParticipantContributionStep.COMPLETED, + }); + + const result = await service.update(1, updateDto); + expect(mockParticipantsService.findOne).toHaveBeenCalledWith(1); + expect(result).toBeDefined(); + }); + }); + + describe('transition validation for setting valid', () => { + const disallowedStates: [ParticipantStatus, ParticipantContributionStep][] = [ + [ParticipantStatus.CONTRIBUTING, ParticipantContributionStep.DOWNLOADING], + [ParticipantStatus.CONTRIBUTING, ParticipantContributionStep.COMPUTING], + [ParticipantStatus.CONTRIBUTING, ParticipantContributionStep.UPLOADING], + [ParticipantStatus.WAITING, ParticipantContributionStep.DOWNLOADING], + [ParticipantStatus.READY, ParticipantContributionStep.DOWNLOADING], + [ParticipantStatus.DONE, ParticipantContributionStep.COMPLETED], + [ParticipantStatus.CONTRIBUTED, ParticipantContributionStep.DOWNLOADING], + [ParticipantStatus.CONTRIBUTED, ParticipantContributionStep.UPLOADING], + ]; + + it.each(disallowedStates)( + 'should throw BadRequestException when setting valid with participant %s + %s', + async (status, contributionStep) => { + const contributionRecord = { + ...mockContribution, + valid: undefined, + participantId: 1, + update: jest.fn(), + }; + + mockContributionModel.findByPk.mockResolvedValueOnce(contributionRecord); + (mockParticipantsService.findOne as jest.Mock).mockResolvedValueOnce({ + ...mockParticipant, + status, + contributionStep, + }); + + await expect(service.update(1, { valid: true })).rejects.toThrow(BadRequestException); + }, + ); + }); }); describe('remove', () => { diff --git a/apps/backend/src/contributions/contributions.service.ts b/apps/backend/src/contributions/contributions.service.ts index 0a6a9fd2..c8ca87b8 100644 --- a/apps/backend/src/contributions/contributions.service.ts +++ b/apps/backend/src/contributions/contributions.service.ts @@ -19,6 +19,12 @@ import { Circuit } from 'src/circuits/circuit.model'; import { Participant } from 'src/participants/participant.model'; import { CircuitsService } from 'src/circuits/circuits.service'; import { ParticipantsService } from 'src/participants/participants.service'; +import { + canCreateContribution, + canSetContributionValidity, + CREATE_TRANSITION_ERROR, + SET_VALID_TRANSITION_ERROR, +} from './contribution-transitions'; @Injectable() export class ContributionsService { @@ -32,12 +38,18 @@ export class ContributionsService { ) {} /** - * Creates a new contribution after validating the referenced circuit and participant. + * Creates a new contribution after validating the referenced circuit, participant, + * lifecycle state, and duplicate-contribution constraints. + * + * The participant must be in `CONTRIBUTING` status with contribution step + * `UPLOADING` or `VERIFYING` — matching p0tion's flow where the contribution + * record is created during the upload/verification phase. * * @param createContributionDto - The DTO containing contribution creation data * @returns The created contribution record * @throws {NotFoundException} If the circuit or participant does not exist * @throws {BadRequestException} If the participant does not belong to the circuit's ceremony + * or is not in a valid lifecycle state to contribute * @throws {ConflictException} If a valid contribution already exists for this circuit and participant */ async create(createContributionDto: CreateContributionDto) { @@ -60,6 +72,10 @@ export class ContributionsService { ); } + if (!canCreateContribution(participant)) { + throw new BadRequestException(CREATE_TRANSITION_ERROR); + } + const existingValid = await this.findValidOneByCircuitIdAndParticipantId( createContributionDto.circuitId, createContributionDto.participantId, @@ -167,10 +183,19 @@ export class ContributionsService { /** * Updates a contribution by ID with partial data. * + * When the payload includes `valid`, the owning participant's lifecycle state + * is checked: the participant must be `CONTRIBUTED` or `FINALIZED` with step + * `VERIFYING` or `COMPLETED`. If the contribution already has the same `valid` + * value, the update proceeds idempotently (no state corruption on retries). + * + * Fields other than `valid` (timing, files, etc.) can be updated without + * lifecycle checks. + * * @param id - The contribution's unique identifier * @param updateContributionDto - The DTO containing the fields to update * @returns The updated contribution record * @throws {NotFoundException} If the contribution does not exist + * @throws {BadRequestException} If setting `valid` while participant is not in a valid lifecycle state */ async update(id: number, updateContributionDto: UpdateContributionDto) { try { @@ -178,6 +203,20 @@ export class ContributionsService { if (!contribution) { throw new Error('Contribution not found'); } + + if (updateContributionDto.valid !== undefined) { + const isIdempotent = contribution.valid === updateContributionDto.valid; + if (!isIdempotent) { + const participant = await this.participantsService.findOne(contribution.participantId); + if (!participant) { + throw new Error('Participant not found'); + } + if (!canSetContributionValidity(participant)) { + throw new BadRequestException(SET_VALID_TRANSITION_ERROR); + } + } + } + await contribution.update(updateContributionDto); return contribution; } catch (error) { diff --git a/apps/website/docs/contributions-lifecycle.md b/apps/website/docs/contributions-lifecycle.md new file mode 100644 index 00000000..224b167f --- /dev/null +++ b/apps/website/docs/contributions-lifecycle.md @@ -0,0 +1,100 @@ +# Contribution Lifecycle Transitions + +This document describes the legal state transitions enforced by the contributions service when creating or updating contribution records. The rules align with the [p0tion](https://github.com/privacy-ethereum/p0tion) Phase 2 Trusted Setup ceremony flow. + +## State Machine + +```mermaid +stateDiagram-v2 + direction LR + + state "Participant States" as PS { + [*] --> CREATED + CREATED --> WAITING + WAITING --> READY + READY --> CONTRIBUTING + + state "Contribution Window" as CW { + CONTRIBUTING --> DOWNLOADING : step + DOWNLOADING --> COMPUTING : step + COMPUTING --> UPLOADING : step + UPLOADING --> VERIFYING : step + } + + CONTRIBUTING --> CONTRIBUTED + CONTRIBUTED --> DONE + CONTRIBUTING --> TIMEDOUT + CONTRIBUTED --> FINALIZING + FINALIZING --> FINALIZED + } + + note right of UPLOADING + ✅ Can CREATE contribution + end note + + note right of VERIFYING + ✅ Can CREATE contribution + ✅ Can SET valid (if CONTRIBUTED/FINALIZED) + end note + + note right of CONTRIBUTED + ✅ Can SET valid (VERIFYING or COMPLETED step) + end note + + note right of FINALIZED + ✅ Can SET valid (VERIFYING or COMPLETED step) + end note +``` + +## Legal Transitions + +### Create Contribution + +A contribution record can only be created when the owning participant satisfies **both** conditions: + +| Participant Status | Contribution Step | +| ------------------ | ----------------- | +| `CONTRIBUTING` | `UPLOADING` | +| `CONTRIBUTING` | `VERIFYING` | + +**Rationale:** In the p0tion flow, a contribution document is created when the participant is the current contributor and has produced a zKey (upload phase) or verification has started. Earlier steps (`DOWNLOADING`, `COMPUTING`) mean no zKey exists yet; other statuses mean the contribution window has passed. + +Any other combination is rejected with: + +> Contribution can only be created when participant is CONTRIBUTING in UPLOADING or VERIFYING step + +### Update Contribution — Setting `valid` + +The `valid` field (verification result) can only be set when the owning participant satisfies **both** conditions: + +| Participant Status | Contribution Step | +| ---------------------------- | ----------------- | +| `CONTRIBUTED` or `FINALIZED` | `VERIFYING` | +| `CONTRIBUTED` or `FINALIZED` | `COMPLETED` | + +**Rationale:** Verification results are recorded after the participant has contributed and the verification process is running or has completed. + +Any other combination is rejected with: + +> Contribution validity can only be set when participant is CONTRIBUTED or FINALIZED in VERIFYING or COMPLETED step + +### Update Contribution — Other Fields + +Fields other than `valid` (timing data, files, verification software metadata, etc.) can be updated without lifecycle checks, subject to existing authentication and authorization guards. + +### Delete Contribution + +No lifecycle transition check is applied. Deletion is restricted to ceremony coordinators via the `IsContributionCoordinatorGuard`. + +## Idempotency + +Repeated calls must not corrupt state: + +- **Create:** The existing "valid contribution already exists" check prevents duplicate _valid_ contributions for the same `(circuitId, participantId)`. Multiple rows (e.g. one invalid, one valid) are allowed by schema. +- **Update `valid`:** If the contribution already has the same `valid` value as the incoming update, the service treats it as a no-op (idempotent). No participant state check is performed in this case, and the update proceeds harmlessly. This prevents flip-flopping or errors on retries. + +## Enforcement + +- **Transition rules** are defined in `apps/backend/src/contributions/contribution-transitions.ts` via `canCreateContribution()` and `canSetContributionValidity()` helper functions. +- **Service enforcement** is in `apps/backend/src/contributions/contributions.service.ts` — the `create()` and `update()` methods call the transition helpers and throw `BadRequestException` on illegal transitions. +- **Tests** cover all allowed and disallowed `(status, step)` combinations in `contribution-transitions.spec.ts` and `contributions.service.spec.ts`.