From 96bb4521ae0bc8568003129a93cd4d711b3fc88e Mon Sep 17 00:00:00 2001
From: Kkkakania <200867803+Kkkakania@users.noreply.github.com>
Date: Wed, 17 Jun 2026 10:01:04 +0800
Subject: [PATCH] docs: expand token flow specification
---
specs/token-flow.md | 664 +++++++++++++++++++++++++++++++++++++++-----
1 file changed, 588 insertions(+), 76 deletions(-)
diff --git a/specs/token-flow.md b/specs/token-flow.md
index 9260d54..e39ab49 100644
--- a/specs/token-flow.md
+++ b/specs/token-flow.md
@@ -1,105 +1,617 @@
# Token Flow Specification
-This document describes the end-to-end flow from eligibility list upload through vote submission and result publication.
+**Scope:** eligibility ingestion through token invalidation after vote
+submission
+**Core routes:** `POST /api/eligibility`, `POST /api/tokens`,
+`POST /api/tokens/reissue`, `POST /api/votes`,
+`POST /api/tokens/reset/:ballotId`
+**Crypto references:** `hashIdentifier`, `generateToken`, `hashToken`,
+`encryptVote` from `AnonVote/js/src/crypto.ts`
+**Core service references:** `identityManager.issueToken`,
+`identityManager.reissueToken`, `privacyEngine.submitVote`,
+`stellarService.writeRecord`, and the Soroban wrappers in `sorobanService`
+
+This document is the normative AnonVote token lifecycle specification. It
+describes how a raw voter identifier becomes an eligibility hash, how a raw
+token is issued without being stored, how a raw token is redeemed exactly once,
+and how replacement tokens are handled without linking a voter to a vote.
+
+The privacy invariant is:
+
+```text
+EligibilityEntry.identifierHash MUST NOT be joinable to Vote.encryptedPayload.
+VoterToken.tokenHash authorizes voting, but raw tokens MUST NOT be stored.
+```
+
+---
+
+## Data Model Summary
+
+The token flow relies on these persistent records:
+
+| Model | Relevant fields | Privacy rule |
+| --- | --- | --- |
+| `EligibilityEntry` | `eligibilityListId`, `identifierHash`, `weight`, `tokenIssued` | Stores only `hashIdentifier(identifier)`; never store the raw identifier. |
+| `VoterToken` | `tokenHash`, `ballotId`, `used`, `issuedAt`, `usedAt`, `delegatedFrom`, `delegatedTo` | Stores only `hashToken(rawToken)`; never store the raw token. |
+| `Vote` | `ballotId`, `optionId`, `encryptedPayload`, `weight`, `rank`, `stellarTxId` | Stores encrypted option payload; never store the token or identifier. |
+| `AuditEvent` | `ballotId`, `eventType`, `stellarTxId`, `stellarLedgerAt` | Stores event type and ballot context only; never store identifiers or tokens. |
+
+`EligibilityEntry` has a unique constraint on
+`(eligibilityListId, identifierHash)`. `VoterToken.tokenHash` is unique.
+
+---
+
+## Phase 1 - Eligibility Ingestion
+
+### Entry Point
+
+Core route: `POST /api/eligibility`
+
+The administrator uploads a CSV or plain-text eligibility file. The request is
+authenticated with the organization session cookie.
+
+### Required Steps
+
+1. Read the uploaded file as UTF-8.
+2. Remove a UTF-8 BOM if present.
+3. Split on line boundaries.
+4. Trim each line, normalize internal whitespace where core requires it, and
+ discard empty rows.
+5. Reject the upload if it exceeds file size, row count, or per-row length
+ limits.
+6. For every sanitized identifier, compute:
+
+ ```text
+ identifierHash = hashIdentifier(identifier)
+ ```
+
+ `hashIdentifier` trims and lowercases before SHA-256 hashing.
+
+7. Deduplicate by `identifierHash` within the same upload.
+8. In a database transaction:
+ - create one `EligibilityList`
+ - create one `EligibilityEntry` per unique hash with
+ `tokenIssued = false`
+9. Return the `eligibilityListId` and accepted entry count.
+
+### Stored vs Discarded Data
+
+Stored:
+
+- `EligibilityList.id`
+- `EligibilityEntry.eligibilityListId`
+- `EligibilityEntry.identifierHash`
+- optional voter `weight`
+- `EligibilityEntry.tokenIssued = false`
+
+Discarded:
+
+- raw identifier strings
+- CSV row order
+- casing and surrounding whitespace differences
+- malformed or empty entries
+
+### Duplicate Handling
+
+Duplicates MUST be detected after hashing. This ensures
+`" Alice@example.com "` and `"alice@example.com"` map to one eligibility entry.
+Within one eligibility list, duplicate hashes MUST NOT create multiple voting
+rights.
+
+If duplicates appear in a file, the accepted count SHOULD report unique voters,
+not raw rows. If an existing list is updated in a future implementation, the
+same uniqueness rule MUST apply across the existing and new entries.
---
-## Overview
+## Phase 2 - Token Issuance
+
+### Entry Point
+Core route: `POST /api/tokens`
+
+Request body:
+
+```json
+{
+ "ballotId": "uuid",
+ "voterIdentifier": "alice@example.com"
+}
```
-Admin Backend Voter
- │ │ │
- │── Upload CSV ──────────►│ │
- │ │ hashIdentifier(id) │
- │ │ store identifierHash │
- │ │ │
- │ │◄── Enter identifier ─────│
- │ │ │
- │ │ hashIdentifier(input) │
- │ │ lookup in EligibilityEntry│
- │ │ generateToken() │
- │ │ hashToken(rawToken) │
- │ │ store tokenHash │
- │ │ set tokenIssued = true │
- │ │── rawToken ─────────────►│
- │ │ │
- │ │◄── rawToken + optionId ──│
- │ │ │
- │ │ hashToken(rawToken) │
- │ │ lookup VoterToken │
- │ │ encryptVote(optionId) │
- │ │ store Vote │
- │ │ mark token used │
- │ │ write to Stellar │
- │ │── "Vote recorded" ──────►│
- │ │ │
- │── Tally ballot ────────►│ │
- │ │ decryptVote(payload) ×N │
- │ │ aggregate tally │
- │ │ store Result │
- │ │ write to Stellar │
+
+The endpoint is public because voters do not yet have an AnonVote credential.
+It MUST be rate limited. Core currently applies `strictRateLimiter`.
+
+### Required Steps
+
+1. Require `ballotId` and `voterIdentifier`.
+2. Trim `voterIdentifier` before service-level processing.
+3. Load the ballot and its `eligibilityListId`.
+4. Reject closed or unknown ballots with a generic error that does not reveal
+ whether the identifier is eligible.
+5. Compute:
+
+ ```text
+ identifierHash = hashIdentifier(voterIdentifier)
+ ```
+
+6. Look up `EligibilityEntry` by `(eligibilityListId, identifierHash)`.
+7. If the entry is missing, reject with a generic eligibility error.
+8. If `tokenIssued = true`, record `DUPLICATE_TOKEN_ATTEMPT` and return a
+ `TokenAlreadyIssued` style error unless the system can prove the voter has
+ already voted, in which case return an `AlreadyVoted` style error.
+9. Generate and hash the token in sequence:
+
+ ```text
+ rawToken = generateToken()
+ tokenHash = hashToken(rawToken)
+ ```
+
+10. In a database transaction:
+ - create `VoterToken { tokenHash, ballotId, used: false }`
+ - update the eligibility entry to `tokenIssued = true`
+ - create `AuditEvent { ballotId, eventType: "TOKEN_ISSUED" }`
+11. Deliver `rawToken` to the voter exactly once.
+12. Write the token-issued audit event to the active ledger layer:
+ - current core: `stellarService.writeRecord({ type: "TOKEN_ISSUED", ... })`
+ - Soroban-ready wrapper: `sorobanRecordToken(ballotIdHash)`
+
+### Stored vs Delivered Data
+
+Stored server-side:
+
+- `hashToken(rawToken)` as `VoterToken.tokenHash`
+- `ballotId`
+- `used = false`
+- `issuedAt`
+- audit event metadata
+
+Delivered to voter:
+
+- `rawToken`
+- voter `weight` if weighted voting is enabled
+
+Never stored:
+
+- `rawToken`
+- `voterIdentifier`
+- email delivery contents containing the token
+
+### Delivery Requirements
+
+The production delivery channel MAY be email, an authenticated admin-mediated
+channel, or the current core API response consumed by the voter UI. Regardless
+of channel, the requirements are identical:
+
+1. The raw token MUST be shown or sent only once.
+2. The raw token MUST NOT be logged by route handlers, mail providers, error
+ tracking, analytics, or request tracing.
+3. If email delivery is used, the system SHOULD record delivery attempt metadata
+ separately from token value:
+ - `ballotId`
+ - `AuditEvent` or delivery event ID
+ - provider message ID
+ - timestamp
+ - success/failure status
+4. Delivery confirmation MUST NOT include the raw token. It may reference the
+ audit event ID or provider message ID.
+5. If delivery fails before the voter can receive the token, the token issuance
+ transaction MUST either be rolled back or the failed token MUST be invalidated
+ before any replacement token is issued.
+
+### Failure Modes
+
+- Missing `ballotId` or `voterIdentifier`: reject before hashing.
+- Unknown ballot, closed ballot, or ineligible identifier: reject without
+ revealing more state than necessary.
+- Duplicate token request: record `DUPLICATE_TOKEN_ATTEMPT` without storing the
+ identifier.
+- Database unique collision on `tokenHash`: retry with a fresh token or abort
+ before returning a raw token.
+- Ledger write failure: MUST NOT expose the raw token in logs. If the ledger
+ write is asynchronous, the audit event should remain in database state and be
+ retriable.
+
+---
+
+## Phase 3 - Token Redemption
+
+### Entry Point
+
+Core route: `POST /api/votes`
+
+Request body:
+
+```json
+{
+ "ballotId": "uuid",
+ "voterToken": "64-char-hex-string",
+ "optionId": "uuid",
+ "weight": 1,
+ "rank": null
+}
```
+The endpoint MUST be rate limited. Core currently applies `strictRateLimiter`.
+
+### Required Steps
+
+1. Require `ballotId`, `voterToken`, and `optionId`.
+2. Trim `voterToken`.
+3. Compute:
+
+ ```text
+ tokenHash = hashToken(voterToken)
+ ```
+
+4. Resolve delegation if enabled. Current core calls
+ `delegationManager.getEffectiveVoter(ballotId, tokenHash)` before final
+ token lookup.
+5. Look up `VoterToken` by token hash. Validation MUST be hash comparison, not
+ plaintext comparison.
+6. Reject if the token is missing or belongs to another ballot.
+7. Reject if `VoterToken.used = true`. Record `DUPLICATE_VOTE_ATTEMPT` without
+ storing the raw token.
+8. Load the ballot and options.
+9. Reject if the ballot is missing, closed, or past its voting deadline.
+10. Reject if `optionId` is not an option in the ballot.
+11. Encrypt the vote:
+
+ ```text
+ encryptedPayload = encryptVote(optionId, ballotKey)
+ ```
+
+12. Execute the redemption transaction described below.
+13. Write the vote-cast audit event to the active ledger layer:
+ - current core: `stellarService.writeRecord({ type: "VOTE_CAST", ... })`
+ - Soroban-ready wrapper: `sorobanRecordVote(ballotIdHash)`
+14. Return vote confirmation without returning token data.
+
+### Atomicity Requirement
+
+The following operations MUST be one database transaction:
+
+1. Create `Vote { ballotId, optionId, encryptedPayload, weight, rank }`.
+2. Update the effective `VoterToken` to `used = true` and set `usedAt`.
+3. Create `AuditEvent { ballotId, eventType: "VOTE_CAST" }`.
+
+This transaction is the boundary that prevents double voting. A token is valid
+only if the vote write and token invalidation commit together.
+
+The transaction MUST NOT include external network calls such as Stellar,
+Soroban, email, or webhook delivery. External writes can be retried using the
+database `AuditEvent` or `Vote` ID after the core transaction commits.
+
+### Mid-Transaction Failure
+
+If the transaction fails before commit:
+
+- no `Vote` row may persist
+- `VoterToken.used` must remain `false`
+- no `VOTE_CAST` audit event may persist
+- the voter may retry with the same token
+
+If the database transaction commits but the ledger write fails:
+
+- the vote remains valid
+- the token remains invalidated
+- the audit event remains stored without `stellarTxId`
+- a retry worker may later write the audit event to Stellar or Soroban
+
+### Failure Modes
+
+- Invalid token: reject with a generic invalid token error.
+- Used token: reject and record `DUPLICATE_VOTE_ATTEMPT`.
+- Expired or closed ballot: reject before creating the vote.
+- Invalid option: reject before encryption and before token invalidation.
+- Encryption failure: abort before the transaction; the token remains unused.
+
+---
+
+## Phase 4 - Lost Token Reissue
+
+### Entry Point
+
+Core route: `POST /api/tokens/reissue`
+
+Request body:
+
+```json
+{
+ "ballotId": "uuid",
+ "voterIdentifier": "alice@example.com"
+}
+```
+
+The endpoint is public and MUST be rate limited. Core currently applies
+`strictRateLimiter`.
+
+### When Reissue Is Allowed
+
+A voter may request reissue only when all of these are true:
+
+1. The ballot exists and is open.
+2. `hashIdentifier(voterIdentifier)` matches an `EligibilityEntry` in the
+ ballot's eligibility list.
+3. `EligibilityEntry.tokenIssued = true`, or no prior token was issued and the
+ request can safely fall back to normal issuance.
+4. The system has not recorded a successful vote for the voter.
+5. Rate limiting and abuse detection allow the request.
+
+Because AnonVote deliberately does not link identifiers to token hashes, the
+system must avoid claiming it can identify the exact lost token unless a future
+implementation adds a privacy-preserving reissue handle. Current core searches
+for an unused token in the ballot and replaces one unused token. A stricter
+implementation MAY store an encrypted or blinded reissue handle, but it MUST
+NOT create a direct join path from `EligibilityEntry` to `Vote`.
+
+### Required Steps
+
+1. Require `ballotId` and `voterIdentifier`.
+2. Apply rate limiting before any database-intensive work.
+3. Load the ballot and eligibility list.
+4. Reject closed or unknown ballots using the same generic posture as token
+ issuance.
+5. Compute `identifierHash = hashIdentifier(voterIdentifier)`.
+6. Look up `EligibilityEntry`.
+7. If no entry exists, reject without revealing other voter state.
+8. If `tokenIssued = false`, call the normal issuance flow.
+9. Determine whether the previous token can be considered unused.
+10. If already voted, reject with an `AlreadyVoted` style response.
+11. Generate a replacement token:
+
+ ```text
+ newRawToken = generateToken()
+ newTokenHash = hashToken(newRawToken)
+ ```
+
+12. In a database transaction:
+ - invalidate exactly one old unused token for the ballot
+ - create the new `VoterToken { tokenHash: newTokenHash, ballotId }`
+ - create an audit event for token issuance or reissue
+13. Deliver `newRawToken` once, subject to the same delivery rules as Phase 2.
+
+### Old Token Invalidation
+
+Soft invalidation is preferred. The protocol-level representation is:
+
+```text
+old VoterToken.used = true
+old VoterToken.usedAt = reissue timestamp
+old VoterToken invalidation reason = "REISSUED" (if supported)
+```
+
+The current Prisma model does not include an invalidation reason, and current
+core deletes one unused token during reissue. Future core implementations SHOULD
+prefer soft invalidation so audits can distinguish a used vote token from a
+revoked lost token without storing the token value.
+
+### Rate Limiting
+
+The reissue endpoint MUST be at least as strict as initial token issuance.
+Recommended minimum controls:
+
+- per-IP rate limit
+- per-ballot rate limit
+- per-normalized-identifier rate limit after hashing
+- escalating delay or temporary lockout after repeated failed attempts
+
+Rate-limit records MUST NOT store raw identifiers. If identifier-scoped rate
+limiting is needed, store `hashIdentifier(voterIdentifier)`.
+
+### Reissue Logging Rules
+
+May log:
+
+- `ballotId`
+- audit event ID
+- provider message ID for delivery
+- whether reissue succeeded or failed
+- generic failure category
+
+Must not log:
+
+- raw voter identifier
+- raw token
+- token hash
+- encrypted vote payload
+- plaintext option ID
+
---
-## Step-by-step
+## Phase 5 - Token Invalidation
-### 1. Eligibility list upload (admin)
+Token invalidation is what enforces one token, one vote.
-- Admin uploads a CSV / newline-separated list of voter identifiers
-- Backend calls `hashIdentifier(id)` for each entry
-- Stores `{ eligibilityListId, identifierHash, weight, tokenIssued: false }` in `EligibilityEntry`
-- **Raw identifiers are never written to the database**
+### Successful Vote Invalidation
-### 2. Token request (voter)
+After a valid vote is written, core MUST mark the effective `VoterToken` as:
-- Voter submits their identifier to `POST /api/tokens`
-- Backend calls `hashIdentifier(input)` and looks up the hash in `EligibilityEntry`
-- If found and `tokenIssued = false`:
- - Calls `generateToken()` → `rawToken`
- - Calls `hashToken(rawToken)` → `tokenHash`
- - Stores `{ tokenHash, ballotId, used: false }` in `VoterToken`
- - Sets `EligibilityEntry.tokenIssued = true`
- - Returns `rawToken` to the voter
-- **`rawToken` is returned once and never stored**
+```text
+used = true
+usedAt = current timestamp
+```
-### 3. Vote submission (voter)
+This update MUST happen in the same transaction as the vote write and the
+`VOTE_CAST` audit event.
-- Voter submits `rawToken` + `optionId` to `POST /api/votes`
-- Backend calls `hashToken(rawToken)` and looks up the hash in `VoterToken`
-- Validates: token exists, belongs to ballot, not already used
-- Calls `encryptVote(optionId, BALLOT_ENCRYPTION_KEY)` → `encryptedPayload`
-- Stores `{ ballotId, optionId, encryptedPayload, weight, rank? }` in `Vote`
-- Marks `VoterToken.used = true`
-- Writes `VOTE_CAST` audit event to Stellar (fire-and-forget)
+### Why Soft Deletion Is Used
-### 4. Result tally (admin or scheduler)
+Successful vote tokens MUST be soft-invalidated rather than hard-deleted:
-- Admin calls `POST /api/results/:ballotId/tally` or scheduler auto-closes at deadline
-- Backend loads all `Vote` records for the ballot
-- Calls `decryptVote(payload, BALLOT_ENCRYPTION_KEY)` for each vote
-- Aggregates `tally[optionId] += vote.weight`
-- Checks consistency: `SUM(vote.weight) == COUNT(used tokens)`
-- Stores `Result` with `tallyJson`, `totalVotes`, `isConsistent`
-- Writes `RESULT_PUBLISHED` event to Stellar
+- A used token record proves that a duplicate vote attempt should be rejected.
+- Audit counts can compare issued tokens, used tokens, and votes cast.
+- Recount and incident response workflows can inspect state without needing raw
+ token values.
+- Hard deletion makes duplicate detection ambiguous.
+
+Hard deletion is only acceptable for pre-vote token reissue if the system has a
+separate audit record proving that the deleted token was revoked before use.
+
+### Audit Logging
+
+Token use is recorded as:
+
+```text
+AuditEvent { ballotId, eventType: "VOTE_CAST" }
+```
+
+Duplicate attempts are recorded as:
+
+```text
+AuditEvent { ballotId, eventType: "DUPLICATE_VOTE_ATTEMPT" }
+AuditEvent { ballotId, eventType: "DUPLICATE_TOKEN_ATTEMPT" }
+```
+
+No audit event may contain the raw token, token hash, raw identifier, or
+identifier hash.
+
+---
+
+## Sequence Diagram 1 - Happy Path
+
+```text
+Admin Core API Database Voter Ledger
+ | | | | |
+ | POST /eligibility | | | |
+ | CSV identifiers | | | |
+ |-------------------->| hashIdentifier(id) x N | | |
+ | | create EligibilityList | | |
+ | | create EligibilityEntry | | |
+ | |------------------------>| | |
+ |<--------------------| eligibilityListId | | |
+ | | | | |
+ | create ballot | | | |
+ |-------------------->| Ballot references list | | |
+ | |------------------------>| | |
+ | | | | |
+ | | POST /tokens | voterIdentifier | |
+ | |<---------------------------------------------| |
+ | | hashIdentifier(input) | | |
+ | | lookup EligibilityEntry | | |
+ | |------------------------>| | |
+ | | generateToken() | | |
+ | | hashToken(rawToken) | | |
+ | | tx: create VoterToken | | |
+ | | tx: tokenIssued = true | | |
+ | | tx: TOKEN_ISSUED event | | |
+ | |------------------------>| | |
+ | | deliver rawToken once |------------------->| |
+ | | writeRecord TOKEN_ISSUED|--------------------------------------->|
+ | | | | |
+ | | POST /votes | rawToken+optionId | |
+ | |<---------------------------------------------| |
+ | | hashToken(rawToken) | | |
+ | | lookup VoterToken | | |
+ | | validate ballot/option | | |
+ | | encryptVote(optionId) | | |
+ | | tx: create Vote | | |
+ | | tx: mark token used | | |
+ | | tx: VOTE_CAST event | | |
+ | |------------------------>| | |
+ | | vote confirmation |------------------->| |
+ | | writeRecord VOTE_CAST |--------------------------------------->|
+```
+
+---
+
+## Sequence Diagram 2 - Lost Token Reissue
+
+```text
+Voter Core API Database Delivery
+ | | | |
+ | POST /tokens | | |
+ | voterIdentifier | | |
+ |-------------------->| hashIdentifier(input) | |
+ | | find EligibilityEntry | |
+ | | tokenIssued = true | |
+ | |------------------------>| |
+ |<--------------------| TokenAlreadyIssued | |
+ | | | |
+ | POST /tokens/reissue| | |
+ | voterIdentifier | | |
+ |-------------------->| rate limit check | |
+ | | hashIdentifier(input) | |
+ | | verify eligibility | |
+ | | verify not already used | |
+ | | generateToken() | |
+ | | hashToken(newRawToken) | |
+ | | tx: revoke old unused | |
+ | | tx: create new token | |
+ | | tx: audit reissue | |
+ | |------------------------>| |
+ | | deliver newRawToken once|------------------->|
+ |<--------------------| confirmation | |
+```
+
+Reissue must not reveal whether any other voter has requested or used a token.
---
-## Token reissue flow
+## Sequence Diagram 3 - Failed Redemption
-If a voter loses their token before voting:
+```text
+Voter Core API Database Outcome
+ | | | |
+ | POST /votes | | |
+ | token + option | | |
+ |-------------------->| hashToken(token) | |
+ | | lookup VoterToken | |
+ | |------------------------>| |
+ | | | |
+ | | if token missing | |
+ |<--------------------| Invalid token | no DB mutation |
+ | | | |
+ | | if token used | |
+ | | create duplicate audit |------------------->|
+ |<--------------------| Token already used | no Vote row |
+ | | | |
+ | | if ballot closed/expired| |
+ |<--------------------| Ballot closed | token remains unused|
+ | | | |
+ | | if option invalid | |
+ |<--------------------| Invalid option | token remains unused|
+```
+
+No failed redemption path may create a `Vote` row. Only duplicate-token attempts
+may create a duplicate audit event, and that event must not contain the token.
+
+---
-1. Voter requests reissue at `POST /api/tokens/reissue`
-2. Backend verifies `tokenIssued = true` but no corresponding used token exists
-3. Backend deletes the old unused `VoterToken` and creates a new one
-4. Returns a fresh `rawToken`
+## Cross-Repository Reference Map
-If the voter's token was already used to vote, reissue is blocked with a clear error.
+| Concern | Reference |
+| --- | --- |
+| Identifier hashing | `AnonVote/js/src/crypto.ts` `hashIdentifier`; `AnonVote/core/backend/src/utils/crypto.ts` `hashIdentifier` |
+| Token generation | `AnonVote/js/src/crypto.ts` `generateToken`; `AnonVote/core/backend/src/utils/crypto.ts` `generateToken` |
+| Token hashing | `AnonVote/js/src/crypto.ts` `hashToken`; `AnonVote/core/backend/src/utils/crypto.ts` `hashToken` |
+| Eligibility upload | `AnonVote/core/backend/src/routes/eligibility.ts` `POST /api/eligibility` |
+| Token issuance | `AnonVote/core/backend/src/routes/tokens.ts` `POST /api/tokens`; `identityManager.issueToken` |
+| Token reissue | `AnonVote/core/backend/src/routes/tokens.ts` `POST /api/tokens/reissue`; `identityManager.reissueToken` |
+| Token reset | `AnonVote/core/backend/src/routes/tokens.ts` `POST /api/tokens/reset/:ballotId`; `identityManager.resetBallotTokens` |
+| Vote submission | `AnonVote/core/backend/src/routes/votes.ts` `POST /api/votes`; `privacyEngine.submitVote` |
+| Active ledger writes | `AnonVote/core/backend/src/services/stellarService.ts` `writeRecord` |
+| Soroban-ready helpers | `AnonVote/core/backend/src/services/sorobanService.ts` `sorobanRecordToken`, `sorobanRecordVote` |
+| Contract counters | `AnonVote/core/contracts/README.md` `record_token`, `record_vote`, `get_tokens_issued`, `get_votes_cast` |
---
-## Delegation flow
+## Implementation Checklist
-1. Delegator calls `POST /api/delegations` with their token hash and the delegate's token hash
-2. Backend sets `delegatorToken.delegatedTo = delegateToken.id`, marks delegator token used
-3. At vote submission time, backend resolves the delegation chain and uses the delegate's token
+- [ ] Eligibility ingestion stores only `identifierHash` values.
+- [ ] Duplicate eligibility entries are detected after normalization and hashing.
+- [ ] Token issuance calls `generateToken` before `hashToken` and stores only the
+ hash.
+- [ ] Raw token delivery is one-time and never logged.
+- [ ] `POST /api/tokens` and `POST /api/tokens/reissue` are rate limited.
+- [ ] Vote redemption validates by `hashToken(rawToken)`, never plaintext token
+ comparison.
+- [ ] Vote write, token invalidation, and `VOTE_CAST` audit creation are one
+ database transaction.
+- [ ] Failed redemption paths do not create `Vote` rows.
+- [ ] Lost token reissue invalidates an old unused token before issuing a new
+ token.
+- [ ] Successful vote token invalidation is soft deletion (`used = true`,
+ `usedAt` set).
+- [ ] Audit events never store raw identifiers, identifier hashes, raw tokens,
+ token hashes, plaintext options, or encrypted payloads.