vivraDocumentationV1Comprendre le vote

M0 · Foundations (T00–T04)

Exit gate: pnpm dev starts web, admin, api and workers locally against Docker Postgres + Redis; migrations apply; CI is green.


T00 · Monorepo scaffold#

Goal A pnpm + Turborepo monorepo with all apps and packages as empty, compiling shells, plus CI.

Depends on none

Read first annexe-mise-en-place.md, build-conventions.md, CLAUDE.template.md

Build

  • root: package.json, pnpm-workspace.yaml, turbo.json, tsconfig.base.json, .eslintrc.cjs, .prettierrc, .editorconfig, .gitignore, .env.example, .nvmrc (22)
  • apps/{web,admin,api,workers}, packages/{config,db,core,chain,agents,templates,ui} each with package.json, tsconfig.json, src/index.ts
  • infra/docker-compose.yml (postgres:16, redis:7, with healthchecks and named volumes)
  • .github/workflows/ci.yml

Steps

  1. Create the workspace and Turborepo pipelines: build, dev, lint, typecheck, test (with correct dependsOn).
  2. apps/web and apps/admin: create-next-app equivalent (App Router, TS, Tailwind, no src dir alias conflicts). apps/api: Fastify hello on :4000 with GET /v1/health → { ok: true }. apps/workers: a process that connects to Redis and logs "workers ready".
  3. packages/config: loadEnv() validating every variable of annexe F with zod; export a typed env. Missing required var = crash at boot with the var name.
  4. .env.example: every variable from annexe F, empty values, grouped with comments.
  5. ESLint (typescript-eslint strict), Prettier, Vitest at root with a sample test per package.
  6. CI: install, lint, typecheck, test, forge build placeholder step skipped until T30, gitleaks secret scan.

Done when

  • docker compose -f infra/docker-compose.yml up -d → both healthy
  • pnpm i && pnpm build succeeds from a clean clone
  • pnpm dev serves web :3000, admin :3001, api :4000 (curl :4000/v1/health → {"ok":true}), workers log "workers ready"
  • pnpm lint && pnpm typecheck && pnpm test pass
  • Removing DATABASE_URL from .env makes pnpm dev fail with a clear message
  • CI runs on a PR and is green

Out of scope Any business logic, DB schema, UI design.


T01 · Database schema, migrations, seed#

Goal The full Postgres schema of annexe A in Drizzle, migrated, with an example seed.

Depends on T00

Read first annexe-base-de-donnees.md (all of it), analyse-des-projets.md (token lifecycle), annexe-score-de-reprise.md (recovery_scores.components)

Build packages/db/src/schema/{enums,tokens,users,cycles,operations,treasury,admin}.ts, packages/db/src/client.ts, packages/db/drizzle.config.ts, packages/db/migrations/*, packages/db/src/seed.ts, root scripts db:generate, db:migrate, db:seed

Steps

  1. Translate every create type and create table of annexe A to Drizzle 1:1 (names, nullability, defaults, uniques, indexes, FKs). numeric(78,0) → numeric({ precision: 78, scale: 0, mode: 'bigint' }) via a custom type that returns bigint.
  2. Add check constraints: EVM address regex, score between 0 and 100, position between 1 and 5, vote_closes_at > vote_opens_at.
  3. Export typed db client (node-postgres pool) and inferred row types.
  4. Seed (example data only, clearly named): 5 validated example tokens (KEEL, PULSR, NODA, ORBI, WEFT with the values from the dapp mockups), 1 published cycle, 1 operation in progress (GRIDL), the 6 wallets rows with placeholder addresses, default settings rows (all thresholds from analyse-des-projets.md and annexe E).
  5. Add a resetTestDb() helper for integration tests.

Done when

  • pnpm db:migrate on an empty DB succeeds; pnpm db:generate produces no diff afterwards
  • pnpm db:seed runs twice without error (idempotent upserts)
  • Test: inserting a vote twice for the same (cycle, user) fails with a unique violation
  • Test: inserting a token with an invalid address fails the check constraint
  • Test: a numeric(78,0) column round-trips 2n ** 200n exactly

Out of scope Business rules in code (they live in packages/core).


T02 · Chain package#

Goal @vivra/chain gives typed read clients, chunked log fetching and a guarded write path for testnet / fork / mainnet.

Depends on T00

Read first annexe-contrats.md, token-vivra-et-tresorerie.md, cto-pons-et-frais.md

Build packages/chain/src/{config.ts,clients.ts,logs.ts,write.ts,errors.ts}, packages/chain/src/abi/ (placeholders + README)

Steps

  1. config.ts: per CHAIN_ENV → chain id (4663 mainnet, 46630 testnet), RPC + fallback, Pons factory/escrow/hook addresses (from annexe D), burn address, BuybackBurner address (env).
  2. clients.ts: viem publicClient with fallback transport (primary then fallback RPC, 2 retries); walletClient(keyRef) that loads a private key from the secrets manager adapter (interface SecretStore.get(ref); local dev implementation reads from env var named by the ref).
  3. logs.ts: getLogsChunked({ address, event, fromBlock, toBlock, chunk=10_000 }) that splits ranges, retries on "range too large" by halving, returns logs in order.
  4. write.ts: sendTx({ request, capCheck, ledger }): refuse if CHAIN_ENV !== 'mainnet' and chain id is mainnet; simulateContract; run capCheck(); send; wait 1 confirmation; call ledger(txHash, receipt); return hash. Idempotency key param: if a ledger row with that key has a tx_hash, return it without sending.
  5. abi/: put a README listing the ABIs needed (Pons factory v2, escrow, Meme hook, curve, ERC-20) and an erc20.json. Pons ABIs are fetched from the explorer in T10 (⚠️ Pons).

Done when

  • Unit test: getLogsChunked splits 0→25,000 into 3 calls with chunk 10,000, and halves on a mocked "range too large" error
  • Unit test: sendTx on a mocked mainnet chain with CHAIN_ENV=testnet throws WRONG_CHAIN_ENV and never sends
  • Unit test: sendTx with an existing idempotency key returns the stored hash and never sends
  • Testnet smoke (TESTNET=1 pnpm --filter @vivra/chain test): reads latest block number on chain 46630

Out of scope Pons-specific reads (T10), BuybackBurner (T30).


T03 · API skeleton#

Goal Fastify app with config, logging, error format, rate limiting, CORS, sessions plumbing and a WebSocket hub.

Depends on T00, T01

Read first annexe-api.md (Généralités, format d'erreur, limites), build-conventions.md (API)

Build apps/api/src/{server.ts,errors.ts,plugins/{db,session,rateLimit,cors,ws}.ts,routes/health.ts}

Steps

  1. ApiError + global error handler producing { error: { code, message } }; zod validation errors → invalid_input 400; unknown → internal 500 with request_id.
  2. pino logger with request_id; redact cookie, authorization, email, code.
  3. CORS limited to APP_URL and admin URL. Rate limits per annexe B (per route config).
  4. Session plugin: reads vivra_session, loads sessions row (not revoked, not expired), sets req.user. Helper requireUser().
  5. WebSocket hub at /v1/ws: clients send { subscribe: "cycle:<id>" | "operation:<id>", since?: number }; server keeps rooms; publish(room, message) exported for other modules; ping every 25s. Use Redis pub/sub so workers can publish.

Done when

  • Test: unknown route → 404 { error: { code: "not_found" } }
  • Test: bad body → 400 invalid_input with the field name in message
  • Test: 121 requests/min on a public route → 429 with Retry-After
  • Test: a message published on Redis channel ws:operation:1 reaches a WS client subscribed to operation:1
  • Logs never contain the string of a cookie value (test with a fake cookie)

Out of scope Auth flows (T40), domain routes.


T04 · Workers skeleton#

Goal BullMQ worker process with every queue of annexe F registered, a scheduler for repeatable jobs, idempotency and alert helpers.

Depends on T00, T01

Read first annexe-mise-en-place.md (Files et tâches planifiées), securite-et-observabilite.md (alertes)

Build apps/workers/src/{index.ts,scheduler.ts,queues.ts,lib/{idempotent.ts,alert.ts,lock.ts}}, one stub job file per queue

Steps

  1. Register queues: projects.analyze, tracking.candidates, tracking.live, tracking.followup, cycles.clock, orchestrator.tick, agents.run, tg.updates, buyback.slices, buyback.refill, treasury.daily, social.publish, alerts.check.
  2. Repeatables: every minute for cycles.clock, orchestrator.tick, social.publish, alerts.check; every 15 min tracking.candidates; hourly tracking.followup; daily 00:30 UTC treasury.daily; daily buyback.refill.
  3. idempotent(jobKey, fn): Postgres advisory lock + a job_runs table (add it to packages/db in this ticket) storing key, status, result.
  4. Retry policy from conventions. On final failure: alert({ level, message, context }) → Telegram team chat (TELEGRAM_ALERTS_CHAT_ID) and pino.
  5. Graceful shutdown on SIGTERM (finish current jobs, 30s max).

Done when

  • pnpm --filter workers dev logs every queue name once at boot
  • Test: running the same idempotent('x', fn) twice concurrently executes fn once
  • Test: a job that throws 3 times calls alert once with the job name
  • SIGTERM during a 5s fake job lets it finish, then exits 0

Out of scope Real job logic (later tickets fill each stub).

↑ ↓ pour parcourir · Entrée pour ouvrirVIVRA DOCS