From 44ff0d0e5e3dd36448240305d066f23cd2886bdb Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:22:19 +0200 Subject: [PATCH 1/4] Read isClosed and principal live from chain in positions refresh 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. --- positions/positions.service.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/positions/positions.service.ts b/positions/positions.service.ts index 4eb8657..d8bf497 100644 --- a/positions/positions.service.ts +++ b/positions/positions.service.ts @@ -188,6 +188,8 @@ export class PositionsService { const balanceOfDataPromises: Promise[] = []; const virtualPriceDataPromises: Promise[] = []; const interestPromises: Promise[] = []; + const isClosedPromises: Promise[] = []; + const principalPromises: Promise[] = []; // V2 leadrate must succeed — failure aborts the update so stale-but-correct data is served const v2Leadrate = await VIEM_CONFIG.readContract({ @@ -200,6 +202,8 @@ export class PositionsService { // Forces the collateral balance to be overwritten with the latest blockchain state, instead of the ponder state. // This ensures that collateral transfers can be made without using the smart contract or application directly, // and the API will be aware of the updated state. + // Same for closed and principal: Ponder updates them only via the position contract's MintingUpdate event; + // if that event is missing from the index, a closed position would stay open with a stale principal. balanceOfDataPromises.push( VIEM_CONFIG.readContract({ address: p.collateral, @@ -225,6 +229,22 @@ export class PositionsService { }) ); + isClosedPromises.push( + VIEM_CONFIG.readContract({ + address: p.position, + abi: PositionV2ABI, + functionName: 'isClosed', + }) + ); + + principalPromises.push( + VIEM_CONFIG.readContract({ + address: p.position, + abi: PositionV2ABI, + functionName: 'principal', + }) + ); + // TODO: is this solved in V2? // fetch minted - See issue #11 // https://github.com/Frankencoin-ZCHF/frankencoin-api/issues/ @@ -243,12 +263,16 @@ export class PositionsService { const balanceOfData = await Promise.allSettled(balanceOfDataPromises); const virtualPriceData = await Promise.allSettled(virtualPriceDataPromises); const interestData = await Promise.allSettled(interestPromises); + const isClosedData = await Promise.allSettled(isClosedPromises); + const principalData = await Promise.allSettled(principalPromises); for (let idx = 0; idx < items.length; idx++) { const p = items[idx] as PositionQuery; const b = (balanceOfData[idx] as PromiseFulfilledResult).value; const v = (virtualPriceData[idx] as PromiseFulfilledResult).value; const i = (interestData[idx] as PromiseFulfilledResult).value; + const c = (isClosedData[idx] as PromiseFulfilledResult).value; + const pr = (principalData[idx] as PromiseFulfilledResult).value; const annualInterestPPM = isV3Hub(p.mintingHubAddress) ? p.fixedAnnualRatePPM : v2Leadrate + p.riskPremiumPPM; @@ -266,7 +290,7 @@ export class PositionsService { isOriginal: p.isOriginal, isClone: p.isClone, denied: p.denied, - closed: p.closed, + closed: typeof c === 'boolean' ? c : p.closed, original: getAddress(p.original), minimumCollateral: p.minimumCollateral, @@ -290,7 +314,7 @@ export class PositionsService { limitForClones: p.limitForClones, availableForClones: p.availableForClones, availableForMinting: p.availableForMinting, - principal: p.principal, + principal: typeof pr === 'bigint' ? pr.toString() : p.principal, fixedAnnualRatePPM: p.fixedAnnualRatePPM, virtualPrice: typeof v === 'bigint' ? v.toString() : p.virtualPrice, interest: typeof i === 'bigint' ? i.toString() : '0', From 37e8872956164ea145b3ebfbba487b9195f3430a Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:45:36 +0200 Subject: [PATCH 2/4] Keep closed monotonic when merging indexed and live flag 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. --- positions/positions.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/positions/positions.service.ts b/positions/positions.service.ts index d8bf497..c084b1d 100644 --- a/positions/positions.service.ts +++ b/positions/positions.service.ts @@ -204,6 +204,7 @@ export class PositionsService { // and the API will be aware of the updated state. // Same for closed and principal: Ponder updates them only via the position contract's MintingUpdate event; // if that event is missing from the index, a closed position would stay open with a stale principal. + // closed is monotonic on-chain, so the indexed and the live flag are OR-ed and a stale RPC read can never reopen a position. balanceOfDataPromises.push( VIEM_CONFIG.readContract({ address: p.collateral, @@ -290,7 +291,7 @@ export class PositionsService { isOriginal: p.isOriginal, isClone: p.isClone, denied: p.denied, - closed: typeof c === 'boolean' ? c : p.closed, + closed: p.closed || c === true, original: getAddress(p.original), minimumCollateral: p.minimumCollateral, From 54eb9163c6ebadb3d1e9125112bbc3319dde9c3b Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:55:45 +0200 Subject: [PATCH 3/4] Clarify comment on live principal and closed reads State the motivation once and spell out that principal replaces the indexed value while closed is OR-ed with it. --- positions/positions.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/positions/positions.service.ts b/positions/positions.service.ts index c084b1d..59c9651 100644 --- a/positions/positions.service.ts +++ b/positions/positions.service.ts @@ -202,9 +202,9 @@ export class PositionsService { // Forces the collateral balance to be overwritten with the latest blockchain state, instead of the ponder state. // This ensures that collateral transfers can be made without using the smart contract or application directly, // and the API will be aware of the updated state. - // Same for closed and principal: Ponder updates them only via the position contract's MintingUpdate event; - // if that event is missing from the index, a closed position would stay open with a stale principal. - // closed is monotonic on-chain, so the indexed and the live flag are OR-ed and a stale RPC read can never reopen a position. + // principal and closed are read live for the same reason: the indexer only updates them through the position + // contract's MintingUpdate event, so a position whose last event never reached the index would look open. + // principal replaces the indexed value (with fallback); closed is OR-ed with it, as it never flips back on-chain. balanceOfDataPromises.push( VIEM_CONFIG.readContract({ address: p.collateral, From 372f0f948ca5d1ded298ea9f8b9a3b074567da1e Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:19:21 +0200 Subject: [PATCH 4/4] Keep an observed closed flag across refresh cycles 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. --- positions/positions.service.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/positions/positions.service.ts b/positions/positions.service.ts index 59c9651..3e32564 100644 --- a/positions/positions.service.ts +++ b/positions/positions.service.ts @@ -204,7 +204,8 @@ export class PositionsService { // and the API will be aware of the updated state. // principal and closed are read live for the same reason: the indexer only updates them through the position // contract's MintingUpdate event, so a position whose last event never reached the index would look open. - // principal replaces the indexed value (with fallback); closed is OR-ed with it, as it never flips back on-chain. + // principal replaces the indexed value (with fallback); closed is OR-ed with the indexed and the previously + // cached flag, as it never flips back on-chain and a failed read must not reopen a position. balanceOfDataPromises.push( VIEM_CONFIG.readContract({ address: p.collateral, @@ -269,6 +270,7 @@ export class PositionsService { for (let idx = 0; idx < items.length; idx++) { const p = items[idx] as PositionQuery; + const cached = this.fetchedPositions[p.position.toLowerCase() as Address]; const b = (balanceOfData[idx] as PromiseFulfilledResult).value; const v = (virtualPriceData[idx] as PromiseFulfilledResult).value; const i = (interestData[idx] as PromiseFulfilledResult).value; @@ -291,7 +293,7 @@ export class PositionsService { isOriginal: p.isOriginal, isClone: p.isClone, denied: p.denied, - closed: p.closed || c === true, + closed: cached?.closed === true || p.closed || c === true, original: getAddress(p.original), minimumCollateral: p.minimumCollateral,