Skip to content

fix(gateway,payments): no fabricated SWF accruals, distribution time or chain balance - #424

Merged
LamaSu merged 2 commits into
masterfrom
fix/swf-no-fabricated-money
Sep 30, 2026
Merged

LamaSu merged 2 commits into
masterfrom
fix/swf-no-fabricated-money

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026

Copy link
Copy Markdown
Owner

From lane pcc-economics (df42dbe5). Readmodels (c255d7dc) found these in N34's review of #421 (bus #3284). Economics owns the ledger, so the fix is here.

What was fabricated

  1. Accruals.
    • Both release paths (routes/settlement.ts:204 and facades/settlement.facade.ts:692) called swfAccrue("settlement", jobId, 1000, "USDC", "base") on every released milestone, whatever amount was released.
    • Every SWF money figure the real /api/swf routes serve came from that constant: the summary's totalBalance and chainBalances, epoch totals, accruals, forecasts and equity seeds.
  2. Summary.
    • GET /api/swf/summary reported lastDistributionAt as the current time when nothing had ever been distributed.
    • It also presented the in-memory ledger as a Base USDC chain balance.

What changes

  • Neither release path accrues any more.
    • Readmodels suggested passing the real released amount. That would still be wrong: the fund's ledger is in memory (routes/swf.ts: "Shared service instance (in-memory mock)"), and the escrow routes no share of a release to it. Any accrual therefore records money that never moved, including the "2% of released value" the old comment described.
    • swfAccrue stays, for a real flow into the fund once one exists.
  • The summary invents nothing.

Decision parked for the operator

Should the SWF be funded from the protocol fee on chain, for example by making it the V-next feeRecipient or a share of it? Until that is decided and built, the fund correctly shows no inflow.

Coordination

Tests

  • payments: the summary invents neither field, and reports the real distribution time once an epoch completes.
  • gateway, swf-no-fabricated-accrual.test.ts:
    • a source ratchet: no gateway file outside routes/swf.ts may call swfAccrue;
    • the live summary.
  • All four fail on master's code.
  • payments 301/301; gateway settlement and SWF tests 197/197 (13 files); tsc clean on spec, payments, gateway and dashboard.

Review: this touches money figures, so by the steward's rule it needs a coord-watch cross-family verdict on the exact merge SHA. Merging is the operator's decision.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KGRcaWqVay3ueHYRBcXJ7K

…or chain balance

Readmodels found these in N34's review of #421 (bus #3284). Economics owns
the ledger.

1. Both release paths (routes/settlement.ts and
   facades/settlement.facade.ts) accrued swfAccrue(..., 1000, "USDC",
   "base") for every released milestone, whatever was paid. Every SWF
   money figure the routes serve was derived from that constant.
   Passing the real amount would not fix it: the fund's ledger is in
   memory, and the escrow routes no share of a release to it, so any
   accrual records money that never moved. Neither path accrues now.
   swfAccrue stays for a real flow into the fund, once one exists.
2. GET /api/swf/summary reported lastDistributionAt as now when nothing
   had ever been distributed. It also labeled the in-memory ledger as a
   Base USDC chain balance. lastDistributionAt is now null until an epoch
   completes, and chainBalances is []. A new optional `unavailable` field
   names those two, so a reader shows "unavailable" instead of a guess.
   SWFSummary in @pcc/spec is updated to match.

Tests:
- payments: the summary invents neither field, and reports the real
  distribution time once an epoch completes.
- gateway: a source ratchet (no gateway file may call swfAccrue), and
  the live summary.
All four fail on master's code. payments 301/301; gateway settlement and
SWF tests 197/197 (13 files); tsc clean on spec, payments, gateway and
dashboard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGRcaWqVay3ueHYRBcXJ7K
…bels simulation totals

Cross-family review EC2 (astra, SHIP-WITH-FIXES at 4e79258). Each finding was reproduced
at 4e79258 with a test that fails there, then fixed:

- F1 (HIGH): accepting terms, activating an equity position, recording revenue and
  distributing an epoch now answer 501 not_available before anything is read or written.
  At 4e79258 they answered [200, 409, 409, 409] and the portfolio showed deployed capital
  and earned revenue that no transfer backed. The distribute handler is #421's hunk verbatim.
- F2 (MEDIUM): lastDistributionAt is always null. A completed epoch is a simulated split whose
  endTime is its scheduled deadline, so the summary reported a future time as a distribution.
- F3 (MEDIUM): the summary says basis "simulation" and lists the three money totals as
  unavailable, so a zero reads as an empty simulation ledger, not a measured fund.
- F5 (MEDIUM): the ratchet now catches any spelling. swfAccrue may be named only at its
  definition (no import, alias or re-export), and .accrue( may appear only in routes/swf.ts.
  Behavioural tests show settlement release leaves the SWF ledger unchanged. Two comments
  that named the helper are reworded.

F4 (the dashboard's MOCK_SUMMARY page) is outside this diff; #394 retires that page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Sep 30, 2026
…eath it (N79 round 2)

astra's pack 116b on #462 @38cf4116: DO-NOT-SHIP, with three HIGH
findings and one MEDIUM. Each was reproduced first with a failing test at
38cf411 (n79-refund-vs-settlement.test.ts):
- F1 HIGH: a repeated failure write while /complete is in flight
  refunded ("expected 'refunded' to be 'skipped'");
- F2 HIGH: cancelling one job refunded an escrow another job shares;
- F3 HIGH: a refund landed while SettlementService.releaseMilestone
  awaited the chain ("expected 'refund_pending' to be 'skipped'");
- F4 MEDIUM: a completion that failed after a failure write left the
  failed job's escrow funded.
Two more were reproduced the same way:
- the keeper, which sweeps a findAll() snapshot across awaits, drove an
  escrow given back mid-sweep (astra's Q2 note);
- the raw chain release route let a refund land while it awaited the
  chain (the F3 class, found by extending the audit).

The fix is one mechanism: DURABLE SETTLEMENT OWNERSHIP on the escrow row.
- A settlement takes its escrow with a compare-and-set from
  funded/active to `completing` (already in @pcc/spec's vocabulary),
  synchronously at its claim: /complete (same stretch as the job claim),
  resume-settlement, SettlementService.releaseMilestone (before the
  chain await) and the raw release route (by contract address).
- A refund never lands on a `completing` escrow, however often the
  job's mutable status is rewritten (F1, F3, raw route).
- A settlement that gives up BEFORE recording evidence hands the escrow
  back (prior status). If the job ended meanwhile, the escrow goes to the
  payer then, in one transaction (F4). After evidence exists, the
  settlement keeps it, since resume-settlement continues it.
- A release that went through records its milestone `released`, and the
  escrow `completed` once every milestone is.
- The refund gives back a whole escrow only if no other job references
  it; a shared escrow is skipped as `escrow_shared` (F2).
- The keeper re-reads the row right before each drive.

Tests: the six reproductions, plus the gaps astra named:
- a failure on the LAST write rolls back every milestone and the status;
- a failure after evidence keeps ownership;
- resume takes a legacy row (the write repeated, as in F1);
- a partial release hands the escrow back;
- failed releases (service and raw route) hand it back.
All 40 mutants (both rounds) are killed. The gateway suite passes: 3053
passed, 6 skipped (six threads; the Spark is at load 37, where two /complete
tests time out at 5 s and pass alone). tsc is clean. It merges cleanly with
#326, #385, #403, #424, #441, #445, #450, #451 and #459.
@LamaSu
LamaSu merged commit 1c18aa8 into master Sep 30, 2026
5 checks passed
LamaSu added a commit that referenced this pull request Oct 3, 2026
#360 was SHIP at c73d708 (EC1c) with full CI green, but master moved on 9/30–10/01 (#424,
#459 N80, #436, #338, #477, #483 and others), and three files conflicted. Resolutions:

- packages/spec/src/index.ts: both sides added exports at the same place. Both are kept: #360's
  `economics` namespace, and master's money-status and readmodels exports.
- packages/gateway/src/routes/swf.ts: both sides made POST /api/swf/epochs/:epochId/distribute
  answer 501 not_available before anything is read. Master's hunk (#421's, reviewed and merged
  with #424) is kept. The behavior is identical.
- packages/gateway/src/routes/rewards.ts: #360 made the reward, certificate and treasury routes
  answer 501 and deleted their fixtures. Master's N80 (#459, reviewed and merged) changed only
  the certificates: an empty list, 404 for one, and a 501 mint that claims nothing. It kept the
  reward and treasury fixtures. Kept: master's N80 certificates, and #360's 501s for rewards and
  treasury, with no fixture import. economics-no-fabrication.test.ts drops its three certificate
  rows (certificates-n80.test.ts pins N80), and the reward and treasury rows still assert 501.

On the merged tree: spec 1286/1286; contracts 267/267; mcp-server 76/76; gateway 3717 passed,
13 skipped, 0 failed; dashboard 353/353; tsc clean for contracts, mcp-server, gateway and
dashboard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

1 participant