Skip to content

fix(escrow): FK integrity + sponsor dashboard figures survive parent deletion - #33

Merged
chonilius merged 13 commits into
MergeFi:mainfrom
Temi-suwa18:fix/escrow-fk-integrity-and-sponsor-dashboard
Jul 17, 2026
Merged

fix(escrow): FK integrity + sponsor dashboard figures survive parent deletion#33
chonilius merged 13 commits into
MergeFi:mainfrom
Temi-suwa18:fix/escrow-fk-integrity-and-sponsor-dashboard

Conversation

@Temi-suwa18

@Temi-suwa18 Temi-suwa18 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

closes #27.

Traced src/sponsors/sponsors.service.ts's "budget locked in escrow" and "total spend" figures back to their actual source of truth and fixed every data-integrity gap the issue called out:

1. Escrow's parent FKs were onDelete: 'CASCADE'

bounty/milestone/maintenancePool were all CASCADE, and Bounty.escrow also had ORM-level cascade: true. Deleting a Bounty row would silently delete its Escrow row — even if LOCKED with real on-chain funds — and that cascaded again into deleting Payment rows (Payment.escrow was also CASCADE), destroying confirmed payout records. Changed to SET NULL (escrow parents) and RESTRICT (payment→escrow, since a payment is money that already moved and must never disappear because its escrow got cleaned up).

2. No constraint enforcing "exactly one of bountyId/milestoneId/maintenancePoolId"

Added @Check('CHK_escrow_exactly_one_parent', ...) on the Escrow entity, shipped via an actual migration (see below).

3. Dashboard figures were a drifting proxy, not the ledger

budgetLocked/totalSpend summed Bounty.amount filtered by Bounty.status — a hand-maintained workflow-state proxy that (a) only ever considered bounty-funded escrows, silently ignoring milestone budgets, and (b) depends on the parent Bounty row surviving, so it would go to zero the instant a bounty was deleted regardless of the escrow's real state. Rewrote both to query Escrow/Payment directly. To make that survive parent deletion, added a denormalized sponsorId column on Escrow (populated at fund time from the bounty/milestone's sponsor) that dashboard queries key off instead of joining through the deletable parent. recentPayments had the same escrow.bounty.sponsorId join bug (also silently excluding milestone-funded payments entirely) — fixed the same way.

4. No migration tooling existed at all

The project relied entirely on synchronize, with zero migration history. Added a CLI DataSource (src/database/data-source.ts), migration:generate/create/run/revert npm scripts, and the actual first migration (backfills sponsorId for existing rows, re-points the FK ON DELETE actions by looking up each constraint's real name via pg_constraint/pg_attribute rather than hardcoding TypeORM's generated name, and adds the CHECK constraint).

Test plan

  • npx jest (unit, mocked repos) — 60/60 passing, including new sponsors.service.spec.ts, milestones.service.spec.ts, and sponsorId-passthrough coverage in escrow.service.spec.ts/bounties.service.spec.ts
  • npm run lint — clean
  • npm run build — clean
  • npx tsc --noEmit — clean
  • Added src/database/escrow-fk-integrity.integration.spec.ts — a real Postgres integration test (not mocked; this bug is inherently DB-level and can't be exercised by mocked repositories) covering: the CHECK constraint rejecting all-null and multiply-set parents (against genuinely existing rows, so failures can't be misattributed to a coincidental FK violation) and allowing exactly-one; a deleted bounty SET NULLs its escrow instead of deleting the row; deleting an escrow with payment records is RESTRICTed; and — the issue's core acceptance criterion — SponsorsService.budgetLocked/totalSpend, wired to real repositories, keep returning the correct figure after the sponsoring bounty is deleted.

I could not run this integration test locally — no Docker/Postgres is available in my environment — so I could not execute it against a live database myself. It's written to run against CI's existing postgres:16-alpine service (same as docker-compose.yml's db service, which npm run test/npm run test:cov already target per ci.yml), so it will run automatically as part of the existing CI test steps. I reviewed the SQL and TypeORM calls carefully, but please pay extra attention to this file in review since I couldn't self-verify it end-to-end.

…is deleted

Escrow.bounty/milestone/maintenancePool were all onDelete: 'CASCADE',
so deleting a Bounty/Milestone/MaintenancePool silently deleted its
Escrow row too — including funds still LOCKED on-chain. Change all
three to 'SET NULL': the parent link is cleared but the escrow ledger
row (and the real money it represents) survives.

Also narrow Bounty.escrow's ORM-level `cascade: true` to
['insert', 'update'] so removing a Bounty entity via the ORM (not just
raw SQL) doesn't separately force a cascaded escrow removal either.

Part of MergeFi#27.
… is deleted

Payment.escrow was onDelete: 'CASCADE'. A Payment is a record of money
that already moved (a confirmed payout leg); deleting its parent
Escrow must never silently delete that record too. Change to
'RESTRICT' so the database refuses the delete instead.

Part of MergeFi#27.
Escrow now carries a denormalized sponsorId (see previous commit),
but nothing populated it. Add sponsorId to FundEscrowInput and store
it on the created row in EscrowService.fund, defaulting to null for
funding paths that don't have a sponsor concept (maintenance pools).

Part of MergeFi#27.
…fund

Wires BountiesService.fund's already-available bounty.sponsorId into
the new FundEscrowInput.sponsorId field, so bounty-funded escrows are
correctly attributed to their sponsor independent of the bounty row.

Part of MergeFi#27.
…vice.fund

Same fix as the previous BountiesService commit, applied to
MilestonesService.fund. Also adds milestones.service.spec.ts, which
didn't exist before, covering the fund() path including the null-
sponsorId case (milestones don't require a sponsor).

Part of MergeFi#27.
…e Escrow/Payment ledger directly

budgetLocked and totalSpend summed Bounty.amount filtered by
Bounty.status — a hand-maintained workflow-state proxy for the real
money figures, and one that only ever considered bounty-funded
escrows (milestone budgets were invisible to the dashboard entirely).
Worse, this proxy silently keeps "working" even after an escrow's
underlying funds are stranded by a deleted parent, and silently goes
to zero if a bounty itself is deleted — neither reflects reality.

Rewrite both to query Escrow (budgetLocked, status = LOCKED) and
Payment (totalSpend, status = CONFIRMED) directly, filtered by the
denormalized escrow.sponsorId. This is the actual ledger source of
truth per MergeFi#27's requirements, and it's now robust to the parent
bounty/milestone being deleted since sponsorId doesn't depend on that
join.

Also fix recentPayments' query, which inner-joined
payment -> escrow -> bounty and so silently excluded any milestone-
funded payment (escrow.bounty is never set for those) and any payment
whose escrow's parent bounty was later deleted. Join on
escrow.sponsorId instead, same fix applied consistently.

Adds sponsors.service.spec.ts (didn't exist before) covering the
query construction for all three.

Part of MergeFi#27.
The project had no migration history at all — schema was managed
entirely by synchronize, with no way to make a reviewable, revertible
schema change (see MergeFi#27's Difficulty Justification). Add a CLI
DataSource (src/database/data-source.ts, reusing the same entities
list app.module.ts's TypeOrmModule.forRootAsync uses) and
migration:generate/create/run/revert npm scripts, following the
standard TypeORM CLI setup.

Part of MergeFi#27.
Ships the schema changes from the preceding commits as an actual
migration for any environment that already has data (rather than
relying on a fresh synchronize):

  - Adds escrows.sponsorId, backfilled from each row's existing parent
    bounty/milestone's sponsorId.
  - Re-points escrows.bountyId/milestoneId/maintenancePoolId's FKs
    from ON DELETE CASCADE to SET NULL, and payments.escrowId's from
    CASCADE to RESTRICT.
  - Adds the CHK_escrow_exactly_one_parent CHECK constraint.

The FK-replacement helper looks up each constraint's actual name via
pg_constraint/pg_attribute instead of hardcoding TypeORM's
auto-generated (content-hashed) name, so it works regardless of which
environment's synchronize run originally created it.

Part of MergeFi#27.
Every other .spec.ts in this repo mocks its repositories, which can't
exercise actual database-level FK/CHECK constraint behavior — the
whole subject of MergeFi#27. This connects to a real Postgres (DATABASE_URL,
matching CI's postgres service / docker-compose's db service) with
synchronize: true to build the schema straight from the entity
decorators, then verifies:

  - CHK_escrow_exactly_one_parent rejects an all-null parent and a
    multiply-set parent (against genuinely existing rows, so the only
    possible failure is the CHECK, not an incidental FK violation on a
    dangling id), and allows exactly-one-set.
  - Deleting a bounty SET NULLs its escrow's bountyId rather than
    deleting the escrow row; the escrow's amount/status survive
    unchanged.
  - Deleting an escrow that still has payment records is RESTRICTed.
  - SponsorsService.budgetLocked/totalSpend, wired to real
    repositories, keep returning the correct figure for a sponsor
    after the bounty funding/paying it is deleted — the MergeFi#27 acceptance
    criterion this whole change exists to satisfy.

Part of MergeFi#27.
Updates the Roadmap's 'wire up TypeORM migrations' item to done, adds
a Migrations section with the migration:* npm script usage, and notes
the new sponsors.service.spec.ts / real-Postgres integration test in
the test coverage list.

Part of MergeFi#27.
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

@Temi-suwa18 is attempting to deploy a commit to the chonilius' projects Team on Vercel.

A member of the Team first needs to authorize it.

…y-one

CI caught this: the exactly-one CHECK constraint and ON DELETE SET
NULL are fundamentally incompatible. Deleting a bounty drives its
escrow's bountyId to NULL — the exact scenario the SET NULL change
exists to support — which correctly leaves zero parents set. An
"exactly one" CHECK rejects that update outright, so the SET NULL
itself was failing with a constraint violation:

  QueryFailedError: new row for relation "escrows" violates check
  constraint "CHK_escrow_exactly_one_parent"

Relax the constraint (and matching migration) to "at most one"
(0 or 1, never 2+), renamed CHK_escrow_at_most_one_parent. Zero
parents is now a valid DB-level state — it's what a legitimately
orphaned escrow looks like after its parent is deleted. "Exactly one"
is still enforced, just at the layer that can actually tell the
difference between "never had a parent" and "orphaned by deletion":
application-side, in EscrowService (next commit).
…rvice

Companion to the previous commit's CHECK-constraint relaxation: since
the DB no longer forbids zero parents (it has to, for orphaned rows),
a newly-created escrow with zero or multiple parents would otherwise
slip through uncaught. Add assertExactlyOneParent to
EscrowService.fund's validation chain, and tests for both the
zero-parent and multiple-parent rejection cases.
Renames the describe block and regex matchers to
CHK_escrow_at_most_one_parent, removes the now-invalid "rejects zero
parents" DB-level test (zero is valid at the DB layer — see the
entity's doc comment), and adds a test explicitly asserting zero
parents is allowed at the DB level, since that's exactly the state
the SET NULL tests below it produce.
@chonilius
chonilius merged commit f935da3 into MergeFi:main Jul 17, 2026
1 of 2 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

2 participants