Skip to content

fix(indexer): write milestone releasedAmount when a dispute resolves - #214

Merged
oluwaseyi1996-netizen merged 3 commits into
vatal-system:mainfrom
preciousolajide09:fix/indexer-resolved-released-amount
Oct 22, 2026
Merged

oluwaseyi1996-netizen merged 3 commits into
vatal-system:mainfrom
preciousolajide09:fix/indexer-resolved-released-amount

Conversation

@preciousolajide09

Copy link
Copy Markdown
Contributor

Summary

handleResolved settled the job and dispute for a resolved / dispute_resolved event, and flipped every milestone to RELEASED, but never wrote Milestone.releasedAmount. Since that column defaults to 0, a resolved escrow was stored as "COMPLETED, all milestones released, but nothing paid out". This PR makes the indexer mirror the contract exactly and backfills the rows that were already indexed with the old handler.

Closes #168

Root cause

contracts/escrow/src/lib.rs → resolve() marks every milestone before distributing the shares:

for i in 0..escrow.milestones.len() {
    let mut m = escrow.milestones.get(i)...;
    m.released = true;
    m.released_amount = m.amount;
    escrow.milestones.set(i, m);
}

The indexer only replayed the two visible side effects:

await store.milestone.updateMany({
  where: { job: { escrowId } },
  data: { status: "RELEASED", releasedAt: resolvedAt },
});

updateMany cannot set releasedAmount to each row's own amount, so the column stayed at its 0 default. handleMilestoneReleased already writes the full total for a normal release, which is why only arbitrated/dispute-resolved escrows were wrong.

This is not cosmetic: the escrow receipt (backend/src/lib/receipt.ts) sums milestone.releasedAmount, so a resolved job's receipt showed Released: 0 and its full value as still outstanding, and the same column feeds earnings/payment-history/audit projections.

Changes

1. Indexer (backend/src/indexer.ts)

After marking the job COMPLETED and its dispute RESOLVED, handleResolved now loads the job with its milestones and updates each milestone with:

  • status: "RELEASED"
  • releasedAt: resolvedAt — the same instant for the job, dispute and every milestone, because the contract changes them in one transaction
  • releasedAmount: formatAmount(toBaseUnits(milestone.amount, decimals), decimals) — the milestone's full amount in the job asset's human units

The per-row update is deliberate: releasedAmount is the row's own amount, which a single updateMany cannot express. The conversion matches handleMilestoneReleased, so both release paths write the same format and decimal scale.

This applies to both event names that dispatch to the handler (resolved and dispute_resolved).

2. Data repair migration

backend/prisma/migrations/20261021130000_backfill_resolved_milestone_released_amount/migration.sql

The indexer cursor has already moved past those events, so fixing the handler alone would leave every previously resolved escrow wrong forever. The migration repairs them:

  • scoped to Milestone.status = 'RELEASED' on a COMPLETED job that has a RESOLVED dispute — exactly the path escrow.resolve handles;
  • sets releasedAmount = amount when the two differ, which also repairs a milestone that had a partial release recorded before the dispute froze the escrow (on-chain resolve overwrites it with the full amount);
  • leaves normally completed jobs alone, because their value is already written by handleMilestoneReleased;
  • idempotent (a second run updates no rows).

The migration was applied to a real Postgres 16 database through prisma migrate deploy, and the backfill was exercised against seeded data: a resolved milestone with 0, a resolved milestone with a partial 4, and a normally completed milestone with 0. The first two became 10; the normal one stayed 0.

3. Regression tests (backend/src/__tests__/indexer.test.ts)

  • Existing resolved test now asserts both milestones end at releasedAmount: "0.5" as well as RELEASED and the shared releasedAt.
  • New test: a milestone part-paid before the dispute (releasedAmount: "4" of "10") is overwritten with the full "10" on resolution.
  • dispute_resolved test asserts releasedAmount: "0.5", proving both event names take the same path.

4. CI restoration on top of main

main was already red before this branch, so the PR also restores the checks the issue requires:

  • Backend typecheck: receipt fixtures predate the required releasedAmount field.
  • Backend tests: the session-aware requireAuth now reads prisma.user (and user.sessions) on every authenticated request, but route-test mocks either omitted user or returned a row without sessions, so ~100 tests saw 401 before reaching the handler under test. Mocks now distinguish the auth lookup from handler lookups (and still exercise the original 404/403/assignment branches).
  • OpenAPI coverage: POST /api/users/logout and GET /api/jobs/by-escrow/{escrowId} were registered as routes but not in the document, so the coverage test failed.
  • Deliverable test: the success fixture used a data: URL, which the new http(s)-only validator correctly rejects; it now uses an https URL.
  • Frontend lint: removed an unused ACTIVE_NETWORK import and an unused catch binding.
  • Frontend tests: jsdom has no scrollIntoView (the new auto-scroll effect threw), and the new localStorage draft leaked state between PostJob cases; the tests now stub scrolling and clear storage.

No handler assertions were removed or weakened.

5. Docs

  • docs/ARCHITECTURE.md event table: resolved / dispute_resolved now documents that every milestone is released in full.
  • docs/EVENTS.md: escrow.resolve emits only resolved, and the indexer writes the full amount to each milestone.

Verification

All CI-equivalent checks were run locally.

Check Command Result
Backend typecheck cd backend && npx tsc --noEmit pass
Backend tests cd backend && npx vitest run 412 passed / 412
Migrations apply npx prisma migrate deploy against Postgres 16 22/22 applied
Schema drift npx prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code no difference
Frontend lint cd frontend && npm run lint pass
Frontend typecheck npx tsc --noEmit pass
Frontend tests npm test 364 passed / 364
Frontend build npm run build pass
Contracts format cd contracts && cargo fmt --all -- --check pass
Contracts lint cargo clippy --all-targets -- -D warnings pass
Contracts tests cargo test pass
Contract wasm stellar contract build + 64 KB gzip check dispute 6,340 B; escrow 8,464 B; reputation 3,833 B

Note: backend/package.json also defines a lint script, but it is not part of the CI workflow and currently reports pre-existing no-explicit-any / unused-symbol findings across older tests and source files. Those are unrelated to this change and were left out of scope; the CI backend job runs typecheck and tests, both of which pass.

Risk / rollout

  • The handler change only runs for resolved / dispute_resolved, and is idempotent on replay.
  • The migration is a single UPDATE scoped by status and the resolved-dispute relation; it updates no rows on a fresh database and does not change the schema.
  • No contract changes, no new dependencies, no secrets.

Checklist

  • Bug fix is covered by regression tests for both resolved and dispute_resolved
  • Existing data is repaired with a committed migration
  • cargo fmt / cargo clippy -D warnings / cargo test
  • frontend: lint, typecheck, tests, build
  • backend: generate, typecheck, tests, migrations + drift check
  • Behaviour documented in docs/ARCHITECTURE.md and docs/EVENTS.md
  • No secrets committed; no contract interface changes

preciousolajide09 and others added 3 commits October 21, 2026 12:34
handleResolved flipped every milestone to RELEASED but only wrote
status and releasedAt, leaving releasedAmount at its 0 default. The
contract sets each on-chain milestone's released_amount to its full
amount in escrow.resolve, so the mirror disagreed with the chain and
receipt/earnings aggregates read a resolved escrow as having paid out
nothing.

Update each milestone with its own amount (converted through the job
asset's decimals) and add a data migration that repairs rows already
indexed before this fix. Covers resolved and dispute_resolved events.

Closes vatal-system#168

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
main was red before this branch: receipt fixtures predate the required
releasedAmount field, session-aware requireAuth queries prisma.user in
route tests whose mocks omit it, and the logout/by-escrow routes were
never registered in the OpenAPI document.

Update the fixtures and route mocks so authentication can resolve the
caller's session without hiding the handler behaviour each test checks.
Document both missing routes so the coverage test passes again.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
WalletProvider kept an unused import and catch binding, and the new
auto-scroll and localStorage draft features broke their tests in jsdom:
scrollIntoView is not implemented there, and a persisted draft leaked
between PostJob cases.

Remove the dead symbols and reset the jsdom/localStorage state each
test so the suite exercises a blank page, as it did before the drafts.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@drips-wave

drips-wave Bot commented Oct 21, 2026

Copy link
Copy Markdown

@preciousolajide09 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@oluwaseyi1996-netizen
oluwaseyi1996-netizen merged commit ace7ca0 into vatal-system:main Oct 22, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Indexer handleResolved leaves milestone releasedAmount unupdated, breaking financial aggregates

2 participants