From e9f1dd96744374744447dba6b8948062f848b8c1 Mon Sep 17 00:00:00 2001 From: Patrick <42653152+lapatric@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:35:03 +0200 Subject: [PATCH] add /positions/best-cloneable endpoint (#95) * add /positions/best-cloneable endpoint * fix best parent sorting order --- challenges/challenges.service.ts | 11 +++++++ positions/positions.controller.ts | 18 ++++++++++- positions/positions.service.ts | 54 ++++++++++++++++++++++++++++++- positions/positions.types.ts | 4 +++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/challenges/challenges.service.ts b/challenges/challenges.service.ts index b989923..9a9614c 100644 --- a/challenges/challenges.service.ts +++ b/challenges/challenges.service.ts @@ -88,6 +88,17 @@ export class ChallengesService { }; } + /** Lowercased addresses of positions under an active challenge — used by other services to exclude them. */ + getActiveChallengedPositions(): Set
{ + const active = new Set(); + for (const challenge of Object.values(this.fetchedChallengesMapping)) { + if (challenge.status === ChallengesQueryStatus.Active) { + active.add(challenge.position.toLowerCase() as Address); + } + } + return active; + } + // challenges prices getChallengesPrices(): ApiChallengesPrices { const pr = this.fetchedPrices; diff --git a/positions/positions.controller.ts b/positions/positions.controller.ts index d759097..46daa88 100644 --- a/positions/positions.controller.ts +++ b/positions/positions.controller.ts @@ -1,12 +1,14 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Query } from '@nestjs/common'; import { PositionsService } from './positions.service'; import { + ApiBestCloneable, ApiMintingUpdateListing, ApiMintingUpdateMapping, ApiPositionsListing, ApiPositionsMapping, ApiPositionsOwners, } from './positions.types'; +import { Address } from 'viem'; import { ApiResponse, ApiTags } from '@nestjs/swagger'; @ApiTags('Positions Controller') @@ -69,4 +71,18 @@ export class PositionsController { geMintingtMapping(): ApiMintingUpdateMapping { return this.positionsService.getMintingUpdatesMapping(); } + + @Get('best-cloneable') + @ApiResponse({ + description: 'Returns the best cloneable parent position for the given collateral. Optionally filter by minting hub.', + }) + getBestCloneable( + @Query('collateral') collateral: string, + @Query('mintingHubAddress') mintingHubAddress?: string, + ): ApiBestCloneable { + return this.positionsService.getBestCloneableParent( + (collateral ?? '').toLowerCase() as Address, + mintingHubAddress ? (mintingHubAddress.toLowerCase() as Address) : undefined, + ); + } } diff --git a/positions/positions.service.ts b/positions/positions.service.ts index 77da399..4eb8657 100644 --- a/positions/positions.service.ts +++ b/positions/positions.service.ts @@ -5,7 +5,9 @@ import { FIVEDAYS_MS } from 'utils/const-helper'; import { Address, erc20Abi, getAddress } from 'viem'; import { ADDR, isV3Hub, VIEM_CONFIG } from '../api.config'; import { PONDER_CLIENT } from '../api.apollo.config'; +import { ChallengesService } from '../challenges/challenges.service'; import { + ApiBestCloneable, ApiMintingUpdateListing, ApiMintingUpdateMapping, ApiPositionsListing, @@ -25,7 +27,43 @@ export class PositionsService { private fetchedPositions: PositionsQueryObjectArray = {}; private fetchedMintingUpdates: MintingUpdateQueryObjectArray = {}; - constructor() {} + constructor(private readonly challengesService: ChallengesService) {} + + private normalizeOptionalAddress(address?: Address): Address | undefined { + return address ? (address.toLowerCase() as Address) : undefined; + } + + private compareParentPositions(a: PositionQuery, b: PositionQuery): number { + if (a.version !== b.version) return b.version - a.version; + + if (a.expiration !== b.expiration) return b.expiration - a.expiration; + + const priceDiff = BigInt(b.price) - BigInt(a.price); + if (priceDiff !== 0n) return priceDiff > 0n ? 1 : -1; + + const availableA = BigInt(a.availableForClones); + const availableB = BigInt(b.availableForClones); + if (availableA !== availableB) return availableA < availableB ? 1 : -1; + + return a.position.localeCompare(b.position); + } + + private isCloneableParentCandidate( + position: PositionQuery, + now: number, + collateral: Address, + challengedPositions: Set, + mintingHubAddress?: Address, + ): boolean { + if (mintingHubAddress && position.mintingHubAddress.toLowerCase() !== mintingHubAddress) return false; + if (position.closed || position.denied) return false; + if (challengedPositions.has(position.position.toLowerCase() as Address)) return false; + if (position.expiration <= now || position.cooldown >= now) return false; + if (position.collateral.toLowerCase() !== collateral.toLowerCase()) return false; + if (BigInt(position.collateralBalance) < BigInt(position.minimumCollateral)) return false; + if (BigInt(position.availableForClones) <= 0n) return false; + return true; + } getPositionsList(): ApiPositionsListing { const pos = Object.values(this.fetchedPositions) as PositionQuery[]; @@ -75,6 +113,20 @@ export class PositionsService { }; } + getBestCloneableParent(collateral: Address, mintingHubAddress?: Address): ApiBestCloneable { + const now = Math.floor(Date.now() / 1000); + const collateralLower = collateral.toLowerCase() as Address; + const candidates = Object.values(this.fetchedPositions) as PositionQuery[]; + const hubFilter = this.normalizeOptionalAddress(mintingHubAddress); + const challengedPositions = this.challengesService.getActiveChallengedPositions(); + + const cloneable = candidates + .filter((position) => this.isCloneableParentCandidate(position, now, collateralLower, challengedPositions, hubFilter)) + .sort((a, b) => this.compareParentPositions(a, b)); + + return { position: cloneable[0] ?? null }; + } + async updatePositonV2s() { this.logger.debug('Updating Positions V2'); const { data } = await PONDER_CLIENT.query({ diff --git a/positions/positions.types.ts b/positions/positions.types.ts index a3ed825..3e61acb 100644 --- a/positions/positions.types.ts +++ b/positions/positions.types.ts @@ -119,3 +119,7 @@ export type ApiMintingUpdateMapping = { positions: Address[]; map: MintingUpdateQueryObjectArray; }; + +export type ApiBestCloneable = { + position: PositionQuery | null; +};