Skip to content

a80497ef - Read isClosed and principal live from chain in positions refresh - #129

Merged
TaprootFreak merged 4 commits into
d-EURO:developfrom
TaprootFreakAI:fix/live-closed-principal
Aug 31, 2026
Merged

a80497ef - Read isClosed and principal live from chain in positions refresh#129
TaprootFreak merged 4 commits into
d-EURO:developfrom
TaprootFreakAI:fix/live-closed-principal

Conversation

@TaprootFreakAI

@TaprootFreakAI TaprootFreakAI commented Aug 29, 2026

Copy link
Copy Markdown

EN:
/positions/list and /positions/open kept serving positions as open, with their original principal, although the contracts had long reported them as closed with zero debt. This change reads isClosed() and principal() live from each position contract on every refresh, the same way collateralBalance, virtualPrice and interest are already read, and falls back to the indexed values only when the RPC call fails; closed is OR-ed with the indexed flag and the last observed value because it is monotonic on-chain. Consumers such as the best-clone selection, the Telegram alerts and the dApp monitoring table therefore no longer depend on the indexer having seen the position's last MintingUpdate event.

DE:
/positions/list und /positions/open lieferten Positionen weiterhin als offen und mit dem ursprünglichen principal, obwohl die Contracts sie längst als geschlossen mit Schuld 0 meldeten. Diese Änderung liest isClosed() und principal() bei jedem Refresh live vom jeweiligen Position-Contract – so wie collateralBalance, virtualPrice und interest bereits gelesen werden – und fällt nur bei einem fehlgeschlagenen RPC-Call auf die indexierten Werte zurück; closed wird mit dem indexierten Flag und dem zuletzt beobachteten Wert ODER-verknüpft, weil es on-chain monoton ist. Konsumenten wie die Best-Clone-Auswahl, die Telegram-Alerts und die Monitoring-Tabelle der dApp hängen damit nicht mehr davon ab, ob der Indexer das letzte MintingUpdate-Event der Position gesehen hat.

Details

Symptom

On 2026-08-29 GET /positions/open returned 30 addresses. For 11 of them the contracts report isClosed() == true, principal() == 0, getDebt() == 0 and a collateral balance of 0 (examples: 0x3fEfD8D571f0d0d7c4B37c1eC6D7E07a3af677dC, 0x7f8cb22fA3F0f67Cc65A7709e6994c1540CF07Ed, 0x15a91C214e7885C4c38A5A6500EAC68b9b4f8500). The API still served closed: false together with the principal from the day the position was opened (40 000, 22 825.38 and 5 000 dEURO). The dApp monitoring table rendered these as rows like "0 cbBTC · 40,000.00 dEURO · -.--%".

Cause

The indexer only updates closed and principal in its MintingUpdate handler for the position child contract. For 12 of the 13 positions opened since April 2026 not a single MintingUpdate event was indexed although the events exist on-chain (both positions above were closed by a regular repay plus full withdrawal in August 2026). The API copied the stale closed/principal verbatim while it already overwrote collateralBalance with the live balanceOf – producing the inconsistent "0 collateral, 40 000 principal, not closed" records.

Change

PositionsService.updatePositonV2s() adds two readContract calls per position (isClosed, principal, PositionV2ABI) next to the existing balanceOf / virtualPrice / getInterest calls. They are batched by the existing viem multicall configuration, so the cost is two more calls in the same batch per position. principal replaces the indexed value with the same Promise.allSettled fallback pattern that collateralBalance uses. closed is merged as cached || indexed || live: the contract only ever sets it to true, so neither a stale read from an eventually consistent RPC node nor a failed read in a later refresh can reopen a position that was already seen as closed. No type or endpoint changes; getPositionsOpen() and the cloneable-parent selection pick up the corrected closed flag automatically.

principal already denotes the currently outstanding principal everywhere it is consumed (Telegram "outstanding principal" filter, analytics sums, dApp loan display), and the indexer itself populates it from principal() – so reading it live is not a semantic change.

Not in this PR (follow-up for the indexer)

  • Re-index the affected factory children so that history (mintingUpdateV2s) is complete again; the missing events match Factory child discovery can be falsely marked complete when reusing ponder_sync cache ponder-sh/ponder#2271 (factory child discovery falsely marked complete when the sync cache is reused across a factory definition change).
  • Handler gaps: closed is derived from collateral == 0 while the contract closes at collateral < minimumCollateral; PositionDenied does not set closed although deny() closes the position; ChallengeSucceeded / ForcedSale handlers do not refresh principal, collateral or closed.

Verification

  • npx eslint "positions/**/*.ts" – the three reported errors are pre-existing Prettier findings on unchanged lines (present on develop).
  • npx prettier --check positions/positions.service.ts
  • yarn build

The positions refresh already overwrites collateralBalance, virtualPrice
and interest with the live contract state but copied closed and principal
verbatim from the indexer. The indexer only updates those two fields in
its MintingUpdate handler, so a position whose last MintingUpdate never
reached the index stayed open with a stale principal in /positions/list
and /positions/open although the contract reported it as closed with
zero debt.

Read isClosed() and principal() per position in the same batched refresh
and fall back to the indexed values only when the RPC call fails.
closed is only ever set to true on-chain, so OR the indexed flag with
the live isClosed() result instead of replacing it. A stale read from an
eventually consistent RPC node can then never reopen a position that the
index already knows as closed.
State the motivation once and spell out that principal replaces the
indexed value while closed is OR-ed with it.
The indexed closed flag is fetched fresh on every refresh, so a failed
live read in a later cycle would have dropped a closure that an earlier
cycle had already observed. OR the previously cached flag into the
merge so that a position seen as closed stays closed.
@TaprootFreakAI

Copy link
Copy Markdown
Author

EN:
Four review rounds were needed until two independent reviewers reported zero findings on the final head. This PR reads isClosed() and principal() live from each position contract in the positions refresh so that closed positions no longer appear as open in the API.

DE:
Vier Review-Durchläufe waren nötig, bis zwei unabhängige Reviewer auf dem finalen Head keine Mängel mehr meldeten. Dieser PR liest isClosed() und principal() im Positions-Refresh live vom Position-Contract, damit geschlossene Positionen in der API nicht mehr als offen erscheinen.

Details

Review rounds

Round Head Result
1 44ff0d0 Logic finding: closed should not be replaced by the live value but OR-ed with the indexed flag, since it is monotonic on-chain.
2 37e8872 Quality finding: the comment block still read as replace-semantics for closed; PR body updated.
3 54eb916 Logic finding: the indexed flag is fetched fresh every refresh, so a failed live read in a later cycle could drop a closure observed earlier.
4 372f0f9 Zero findings in both dimensions from both reviewers.

Local verification on the final head

  • yarn build – passes.
  • npx eslint "positions/**/*.ts" – only the three pre-existing Prettier findings that are also present on develop (positions.controller.ts:79, :85, positions.service.ts:56); none on changed lines.
  • npx prettier positions/positions.service.ts | diff positions/positions.service.ts - – only the pre-existing hunk 56c56.
  • Multicall load measured with the same viem batching config: 505 reads (5 functions × 101 positions) = 6 JSON-RPC requests in 242 ms, versus 303 reads = 5 requests in 241 ms today.
  • Live check: isClosed() is true for 82 of the 101 positions the index knows, while the index reports 68 as closed.

Follow-ups outside this PR

  • Re-index the affected factory children in the indexer (missing MintingUpdate events, see PR body).
  • Indexer handler gaps (closed threshold, PositionDenied, ChallengeSucceeded/ForcedSale).
  • After a successful challenge a position can be closed with principal > 0; the Telegram and analytics consumers filter on !closed only. Pre-existing, not changed here.

@TaprootFreakAI
TaprootFreakAI marked this pull request as ready for review August 29, 2026 19:34
@TaprootFreak
TaprootFreak merged commit b35aadf into d-EURO:develop Aug 31, 2026
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.

2 participants