diff --git a/apps/api/src/deployment/repositories/lease/lease.repository.ts b/apps/api/src/deployment/repositories/lease/lease.repository.ts index 112aff9eed..61e0976139 100644 --- a/apps/api/src/deployment/repositories/lease/lease.repository.ts +++ b/apps/api/src/deployment/repositories/lease/lease.repository.ts @@ -96,6 +96,26 @@ export class LeaseRepository implements DrainingDeploymentLeaseSource { * @param params - Query parameters for filtering and pagination * @returns Object with total count and array of lease rows */ + async getActiveLeaseCountByProviders(providerAddresses: string[]): Promise> { + if (!providerAddresses.length) return new Map(); + + const rows = (await Lease.findAll({ + attributes: ["providerAddress", [fn("COUNT", col("id")), "leaseCount"]], + where: { + providerAddress: { [Op.in]: providerAddresses }, + closedHeight: null + }, + group: ["providerAddress"], + raw: true + })) as unknown as Array<{ providerAddress: string; leaseCount: string | number }>; + + const result = new Map(); + for (const row of rows) { + result.set(row.providerAddress, Number(row.leaseCount)); + } + return result; + } + async findLeasesWithPagination(params: DatabaseLeaseListParams): Promise<{ count: number; rows: Lease[] }> { const { skip = 0, limit = 100, owner, dseq, gseq, oseq, provider, state, reverse = false } = params; diff --git a/apps/api/src/provider/http-schemas/provider.schema.ts b/apps/api/src/provider/http-schemas/provider.schema.ts index 0bc5d1dca8..8066833cb1 100644 --- a/apps/api/src/provider/http-schemas/provider.schema.ts +++ b/apps/api/src/provider/http-schemas/provider.schema.ts @@ -45,6 +45,7 @@ export const ProviderListResponseSchema = z.array( isOnline: z.boolean(), lastOnlineDate: z.string().nullable(), isAudited: z.boolean(), + aliasOwners: z.array(z.string()), gpuModels: z.array( z.object({ vendor: z.string(), diff --git a/apps/api/src/provider/services/provider/provider.service.spec.ts b/apps/api/src/provider/services/provider/provider.service.spec.ts index 5c73ef6fc1..f092e000fa 100644 --- a/apps/api/src/provider/services/provider/provider.service.spec.ts +++ b/apps/api/src/provider/services/provider/provider.service.spec.ts @@ -9,6 +9,7 @@ import { mock } from "vitest-mock-extended"; import { cacheEngine } from "@src/caching/helpers"; import { AUDITOR } from "@src/deployment/config/provider.config"; +import type { LeaseRepository } from "@src/deployment/repositories/lease/lease.repository"; import { createLeaseStatus } from "../../../../test/seeders/lease-status.seeder"; import { createProviderSeed, createProviderWithAttributeSignatures } from "../../../../test/seeders/provider.seeder"; import { createUserWallet } from "../../../../test/seeders/user-wallet.seeder"; @@ -397,8 +398,37 @@ describe(ProviderService.name, () => { expect(result[0].owner).toBe(onlineProvider.owner); }); - it("should prefer newest provider when multiple online providers share the same hostUri", async () => { - const { service, providerRepository, auditorsService, providerAttributesSchemaService } = setup(); + it("prefers provider with active leases over newer provider when multiple online providers share the same hostUri", async () => { + const { service, providerRepository, leaseRepository, auditorsService, providerAttributesSchemaService } = setup(); + + const sharedHostUri = "https://provider.example.com:8443"; + const olderOnlineWithLeases = { + ...createProviderWithAttributeSignatures(AUDITOR), + hostUri: sharedHostUri, + isOnline: true, + createdHeight: 100 + } as unknown as Provider; + const newerOnlineNoLeases = { + ...createProviderWithAttributeSignatures(AUDITOR), + hostUri: sharedHostUri, + isOnline: true, + createdHeight: 200 + } as unknown as Provider; + + providerRepository.getWithAttributesAndAuditors.mockResolvedValue([olderOnlineWithLeases, newerOnlineNoLeases]); + providerRepository.getProviderWithNodes.mockResolvedValue([]); + leaseRepository.getActiveLeaseCountByProviders.mockResolvedValue(new Map([[olderOnlineWithLeases.owner, 5]])); + auditorsService.getAuditors.mockResolvedValue([]); + providerAttributesSchemaService.getProviderAttributesSchema.mockResolvedValue(providerAttributeSchemaStub); + + const result = await service.getProviderList(); + + expect(result).toHaveLength(1); + expect(result[0].owner).toBe(olderOnlineWithLeases.owner); + }); + + it("falls back to newest provider when lease counts are tied", async () => { + const { service, providerRepository, leaseRepository, auditorsService, providerAttributesSchemaService } = setup(); const sharedHostUri = "https://provider.example.com:8443"; const olderOnline = { @@ -416,6 +446,7 @@ describe(ProviderService.name, () => { providerRepository.getWithAttributesAndAuditors.mockResolvedValue([olderOnline, newerOnline]); providerRepository.getProviderWithNodes.mockResolvedValue([]); + leaseRepository.getActiveLeaseCountByProviders.mockResolvedValue(new Map()); auditorsService.getAuditors.mockResolvedValue([]); providerAttributesSchemaService.getProviderAttributesSchema.mockResolvedValue(providerAttributeSchemaStub); @@ -425,6 +456,43 @@ describe(ProviderService.name, () => { expect(result[0].owner).toBe(newerOnline.owner); }); + it("populates aliasOwners with sibling wallets that share the canonical row's hostUri", async () => { + const { service, providerRepository, leaseRepository, auditorsService, providerAttributesSchemaService } = setup(); + + const sharedHostUri = "https://provider.example.com:8443"; + const canonical = { + ...createProviderWithAttributeSignatures(AUDITOR), + hostUri: sharedHostUri, + isOnline: true, + createdHeight: 100 + } as unknown as Provider; + const sibling = { + ...createProviderWithAttributeSignatures(AUDITOR), + hostUri: sharedHostUri, + isOnline: true, + createdHeight: 200 + } as unknown as Provider; + const standalone = { + ...createProviderWithAttributeSignatures(AUDITOR), + isOnline: true, + createdHeight: 50 + } as unknown as Provider; + + providerRepository.getWithAttributesAndAuditors.mockResolvedValue([canonical, sibling, standalone]); + providerRepository.getProviderWithNodes.mockResolvedValue([]); + leaseRepository.getActiveLeaseCountByProviders.mockResolvedValue(new Map([[canonical.owner, 1]])); + auditorsService.getAuditors.mockResolvedValue([]); + providerAttributesSchemaService.getProviderAttributesSchema.mockResolvedValue(providerAttributeSchemaStub); + + const result = await service.getProviderList(); + + const sharedRow = result.find(p => p.hostUri === sharedHostUri); + const standaloneRow = result.find(p => p.owner === standalone.owner); + expect(sharedRow?.owner).toBe(canonical.owner); + expect(sharedRow?.aliasOwners).toEqual([sibling.owner]); + expect(standaloneRow?.aliasOwners).toEqual([]); + }); + it("should deduplicate providers with the same hostUri", async () => { const { service, providerRepository, auditorsService, providerAttributesSchemaService } = setup(); @@ -516,8 +584,17 @@ describe(ProviderService.name, () => { const jwtTokenService = mock({ generateJwtToken: jest.fn().mockResolvedValue(Ok("mock-jwt-token")) }); + const leaseRepository = mock(); + leaseRepository.getActiveLeaseCountByProviders.mockResolvedValue(new Map()); - const service = new ProviderService(providerProxyService, providerRepository, providerAttributesSchemaService, auditorsService, jwtTokenService); + const service = new ProviderService( + providerProxyService, + providerRepository, + providerAttributesSchemaService, + auditorsService, + jwtTokenService, + leaseRepository + ); return { service, @@ -525,7 +602,8 @@ describe(ProviderService.name, () => { providerAttributesSchemaService, auditorsService, jwtTokenService, - providerProxyService + providerProxyService, + leaseRepository }; } }); diff --git a/apps/api/src/provider/services/provider/provider.service.ts b/apps/api/src/provider/services/provider/provider.service.ts index fa5a57f933..855a9b2958 100644 --- a/apps/api/src/provider/services/provider/provider.service.ts +++ b/apps/api/src/provider/services/provider/provider.service.ts @@ -9,6 +9,7 @@ import { singleton } from "tsyringe"; import { Memoize } from "@src/caching/helpers"; import { LeaseStatusResponse } from "@src/deployment/http-schemas/lease.schema"; +import { LeaseRepository } from "@src/deployment/repositories/lease/lease.repository"; import type { Auditor } from "@src/provider/http-schemas/auditor.schema"; import { ProviderRepository } from "@src/provider/repositories/provider/provider.repository"; import { ProviderAuth, ProviderIdentity, ProviderProxyService } from "@src/provider/services/provider/provider-proxy.service"; @@ -32,7 +33,8 @@ export class ProviderService { private readonly providerRepository: ProviderRepository, private readonly providerAttributesSchemaService: ProviderAttributesSchemaService, private readonly auditorsService: AuditorService, - private readonly jwtTokenService: ProviderJwtTokenService + private readonly jwtTokenService: ProviderJwtTokenService, + private readonly leaseRepository: LeaseRepository ) {} async sendManifest(options: { provider: string; dseq: string; manifest: string; auth: ProviderAuth }) { @@ -148,16 +150,20 @@ export class ProviderService { offset += BATCH_SIZE; } while (batch.length === BATCH_SIZE); + const activeLeaseCountByOwner = await this.leaseRepository.getActiveLeaseCountByProviders( + providersWithAttributesAndAuditors.map(provider => provider.owner) + ); + const providerByHostUri = new Map(); + const ownersByHostUri = new Map>(); await forEachInChunks(providersWithAttributesAndAuditors, provider => { const existing = providerByHostUri.get(provider.hostUri); - if ( - !existing || - (!existing.isOnline && provider.isOnline) || - (existing.isOnline === provider.isOnline && provider.createdHeight > existing.createdHeight) - ) { + if (!existing || this.isBetterProviderRepresentative(provider, existing, activeLeaseCountByOwner)) { providerByHostUri.set(provider.hostUri, provider); } + const owners = ownersByHostUri.get(provider.hostUri) ?? new Set(); + owners.add(provider.owner); + ownersByHostUri.set(provider.hostUri, owners); }); const distinctProviders = Array.from(providerByHostUri.values()); @@ -174,7 +180,8 @@ export class ProviderService { await forEachInChunks(distinctProviders, provider => { const lastSuccessfulSnapshot = providerByOwner.get(provider.owner)?.lastSuccessfulSnapshot; - finalProviders.push(mapProviderToList(provider, providerAttributeSchema, auditors, lastSuccessfulSnapshot)); + const aliasOwners = Array.from(ownersByHostUri.get(provider.hostUri) ?? []).filter(owner => owner !== provider.owner); + finalProviders.push(mapProviderToList(provider, providerAttributeSchema, auditors, lastSuccessfulSnapshot, aliasOwners)); }); return finalProviders; @@ -191,6 +198,20 @@ export class ProviderService { return this.mapProviderResults(providersWithAttributesAndAuditors, providerWithNodes, auditors, providerAttributeSchema); } + private isBetterProviderRepresentative(candidate: Provider, current: Provider, activeLeaseCountByOwner: Map): boolean { + if (candidate.isOnline !== current.isOnline) { + return !!candidate.isOnline; + } + + const candidateLeases = activeLeaseCountByOwner.get(candidate.owner) ?? 0; + const currentLeases = activeLeaseCountByOwner.get(current.owner) ?? 0; + if (candidateLeases !== currentLeases) { + return candidateLeases > currentLeases; + } + + return candidate.createdHeight > current.createdHeight; + } + private mapProviderResults( providersWithAttributesAndAuditors: Provider[], providerWithNodes: Provider[], diff --git a/apps/api/src/types/provider.ts b/apps/api/src/types/provider.ts index 2cc20efc4f..18b7e912f1 100644 --- a/apps/api/src/types/provider.ts +++ b/apps/api/src/types/provider.ts @@ -23,6 +23,7 @@ export interface ProviderList { isOnline: boolean; lastOnlineDate: Date | null; isAudited: boolean; + aliasOwners: string[]; gpuModels: Array<{ vendor: string; model: string; diff --git a/apps/api/src/utils/map/provider.ts b/apps/api/src/utils/map/provider.ts index bac5d61e7e..435a40b7ca 100644 --- a/apps/api/src/utils/map/provider.ts +++ b/apps/api/src/utils/map/provider.ts @@ -9,7 +9,8 @@ export const mapProviderToList = ( provider: Provider, providerAttributeSchema: ProviderAttributesSchema, auditors: Array, - lastSuccessfulSnapshot?: ProviderSnapshot + lastSuccessfulSnapshot?: ProviderSnapshot, + aliasOwners: string[] = [] ): ProviderList => { const isValidSdkVersion = provider.cosmosSdkVersion ? semver.gte(provider.cosmosSdkVersion, "v0.45.9") : false; const name = provider.isOnline ? new URL(provider.hostUri).hostname : null; @@ -73,6 +74,7 @@ export const mapProviderToList = ( isOnline: !!provider.isOnline, lastOnlineDate: lastSuccessfulSnapshot?.checkDate || null, isAudited: provider.providerAttributeSignatures.some(a => auditorSet.has(a.auditor)), + aliasOwners, attributes: provider.providerAttributes.map(attr => ({ key: attr.key, value: attr.value, diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index 7c8ea9c89d..f4c22c5c5a 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -11750,6 +11750,12 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "akashVersion": { "type": "string", }, + "aliasOwners": { + "items": { + "type": "string", + }, + "type": "array", + }, "attributes": { "items": { "properties": { @@ -12013,6 +12019,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "isOnline", "lastOnlineDate", "isAudited", + "aliasOwners", "gpuModels", "attributes", "host", diff --git a/apps/deploy-web/src/components/new-deployment/BidGroup.tsx b/apps/deploy-web/src/components/new-deployment/BidGroup.tsx index 550d16d9e2..4d86b73f30 100644 --- a/apps/deploy-web/src/components/new-deployment/BidGroup.tsx +++ b/apps/deploy-web/src/components/new-deployment/BidGroup.tsx @@ -8,6 +8,7 @@ import networkStore from "@src/store/networkStore"; import type { BidDto, DeploymentDto } from "@src/types/deployment"; import type { ApiProviderList } from "@src/types/provider"; import { deploymentGroupResourceSum, getStorageAmount } from "@src/utils/deploymentDetailUtils"; +import { findProviderForBidProvider } from "@src/utils/providerUtils"; import { FormPaper } from "../sdl/FormPaper"; import { LabelValueOld } from "../shared/LabelValueOld"; import { SpecDetail } from "../shared/SpecDetail"; @@ -121,8 +122,8 @@ export const BidGroup: React.FunctionComponent = ({ {fBids.map(bid => { - const provider = providers && providers.find(x => x.owner === bid.provider); - const showBid = provider?.isValidVersion && (!isSendingManifest || selectedBid?.id === bid.id); + const provider = findProviderForBidProvider(providers, bid.provider); + const showBid = provider?.isOnline && provider.isValidVersion && (!isSendingManifest || selectedBid?.id === bid.id); return (showBid && provider) || selectedNetworkId !== MAINNET_ID ? ( { } }); + describe("bid filtering", () => { + it("keeps only audited providers' bids when the Audited filter is enabled, resolving sibling wallets via aliasOwners", async () => { + const auditedProvider = buildProvider({ owner: "akash1audited", aliasOwners: ["akash1auditedsibling"], isAudited: true }); + const unauditedProvider = buildProvider({ owner: "akash1plain", aliasOwners: [], isAudited: false }); + // The bid is submitted from a sibling wallet that shares the canonical row's hostUri (the bug this PR fixes). + const auditedBid = buildRpcBid({ bid: { id: { gseq: 1, provider: "akash1auditedsibling" }, state: "open" } }); + const unauditedBid = buildRpcBid({ bid: { id: { gseq: 1, provider: "akash1plain" }, state: "open" } }); + const BidGroup = vi.fn(ComponentMock); + + setup({ BidGroup, bids: [auditedBid, unauditedBid], providers: [auditedProvider, unauditedProvider] }); + + await vi.waitFor(() => expect(BidGroup).toHaveBeenCalled()); + await userEvent.click(screen.getByRole("checkbox", { name: /Audited/i })); + + await vi.waitFor(() => { + expect(BidGroup).toHaveBeenLastCalledWith(expect.objectContaining({ filteredBids: [mapToBidDto(auditedBid).id] }), {}); + }); + }); + + it("filters bids by search term against the resolved provider's hostUri", async () => { + const matchingProvider = buildProvider({ owner: "akash1match", hostUri: "https://needlehost.example.com:8443", attributes: [] }); + const otherProvider = buildProvider({ owner: "akash1other", hostUri: "https://otherhost.example.com:8443", attributes: [] }); + const matchingBid = buildRpcBid({ bid: { id: { gseq: 1, provider: "akash1match" }, state: "open" } }); + const otherBid = buildRpcBid({ bid: { id: { gseq: 1, provider: "akash1other" }, state: "open" } }); + const BidGroup = vi.fn(ComponentMock); + + setup({ BidGroup, bids: [matchingBid, otherBid], providers: [matchingProvider, otherProvider] }); + + await vi.waitFor(() => expect(BidGroup).toHaveBeenCalled()); + await userEvent.type(screen.getByLabelText("Search provider"), "needlehost"); + + await vi.waitFor(() => { + expect(BidGroup).toHaveBeenLastCalledWith(expect.objectContaining({ filteredBids: [mapToBidDto(matchingBid).id] }), {}); + }); + }); + }); + function setup(input?: { dseq?: string; BidGroup?: (typeof CREATE_LEASE_DEPENDENCIES)["BidGroup"]; diff --git a/apps/deploy-web/src/components/new-deployment/CreateLease/CreateLease.tsx b/apps/deploy-web/src/components/new-deployment/CreateLease/CreateLease.tsx index 3602e51667..107b3cbad9 100644 --- a/apps/deploy-web/src/components/new-deployment/CreateLease/CreateLease.tsx +++ b/apps/deploy-web/src/components/new-deployment/CreateLease/CreateLease.tsx @@ -44,6 +44,7 @@ import type { BidDto } from "@src/types/deployment"; import { RouteStep } from "@src/types/route-steps.type"; import { deploymentData } from "@src/utils/deploymentData"; import { addScriptToHead } from "@src/utils/domUtils"; +import { findProviderForBidProvider } from "@src/utils/providerUtils"; import { TransactionMessageData } from "@src/utils/TransactionMessageData"; import { domainName, UrlService } from "@src/utils/urlUtils"; import { CustomDropdownLinkItem } from "../../shared/CustomDropdownLinkItem"; @@ -213,7 +214,7 @@ export const CreateLease: React.FunctionComponent = ({ dseq, dependencies for (let i = 0; i < bidKeys.length; i++) { const currentBid = selectedBids[bidKeys[i]]; - const provider = providers?.find(x => x.owner === currentBid.provider); + const provider = findProviderForBidProvider(providers, currentBid.provider); if (!provider) { throw new Error("Cannot find bid provider"); @@ -254,7 +255,7 @@ export const CreateLease: React.FunctionComponent = ({ dseq, dependencies if (search) { filteredBids = filteredBids.filter(bid => { - const provider = providers?.find(p => p.owner === bid.provider); + const provider = findProviderForBidProvider(providers, bid.provider); return provider?.attributes.some(att => att.value?.toLowerCase().includes(search.toLowerCase())) || provider?.hostUri.includes(search); }); } @@ -264,7 +265,10 @@ export const CreateLease: React.FunctionComponent = ({ dseq, dependencies } if (isFilteringAudited) { - filteredBids = filteredBids.filter(bid => !!providers.filter(x => x.isAudited).find(p => p.owner === bid.provider)); + filteredBids = filteredBids.filter(bid => { + const provider = findProviderForBidProvider(providers, bid.provider); + return !!provider?.isAudited; + }); } setFilteredBids(filteredBids.map(bid => bid.id)); diff --git a/apps/deploy-web/src/types/provider.ts b/apps/deploy-web/src/types/provider.ts index b509a54d73..05ec613247 100644 --- a/apps/deploy-web/src/types/provider.ts +++ b/apps/deploy-web/src/types/provider.ts @@ -189,6 +189,7 @@ export interface ApiProviderList { isOnline: boolean; lastOnlineDate: string; isAudited: boolean; + aliasOwners: string[]; gpuModels: { vendor: string; model: string; ram: string; interface: string }[]; stats: { cpu: StatsItem; diff --git a/apps/deploy-web/src/utils/providerUtils.spec.ts b/apps/deploy-web/src/utils/providerUtils.spec.ts new file mode 100644 index 0000000000..f58b8a21c0 --- /dev/null +++ b/apps/deploy-web/src/utils/providerUtils.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { findProviderForBidProvider } from "./providerUtils"; + +import { buildProvider } from "@tests/seeders/provider"; + +describe(findProviderForBidProvider.name, () => { + it("finds the provider when the bid provider matches its owner", () => { + const provider = buildProvider({ aliasOwners: [] }); + + const result = findProviderForBidProvider([provider], provider.owner); + + expect(result).toBe(provider); + }); + + it("finds the canonical provider when the bid provider matches one of its aliasOwners", () => { + const aliasOwner = "akash1alias000000000000000000000000000000000"; + const provider = buildProvider({ aliasOwners: [aliasOwner] }); + + const result = findProviderForBidProvider([provider], aliasOwner); + + expect(result).toBe(provider); + }); + + it("returns undefined when no provider matches owner or aliasOwners", () => { + const provider = buildProvider({ aliasOwners: [] }); + + const result = findProviderForBidProvider([provider], "akash1notlisted"); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when providers is undefined", () => { + const result = findProviderForBidProvider(undefined, "akash1anything"); + + expect(result).toBeUndefined(); + }); +}); diff --git a/apps/deploy-web/src/utils/providerUtils.ts b/apps/deploy-web/src/utils/providerUtils.ts index 38faa2624a..e0661e50b1 100644 --- a/apps/deploy-web/src/utils/providerUtils.ts +++ b/apps/deploy-web/src/utils/providerUtils.ts @@ -1,7 +1,7 @@ import networkStore from "@src/store/networkStore"; import type { ISnapshotMetadata } from "@src/types"; import { ProviderSnapshots } from "@src/types"; -import type { ProviderStatus, ProviderStatusDto, ProviderVersion } from "@src/types/provider"; +import type { ApiProviderList, ProviderStatus, ProviderStatusDto, ProviderVersion } from "@src/types/provider"; import { bytesToShrink } from "./unitUtils"; export type LocalProviderData = { @@ -76,3 +76,8 @@ export const getProviderNameFromUri = (uri: string) => { const name = new URL(uri).hostname; return name; }; + +export const findProviderForBidProvider = (providers: ApiProviderList[] | undefined, bidProvider: string): ApiProviderList | undefined => { + if (!providers) return undefined; + return providers.find(provider => provider.owner === bidProvider || provider.aliasOwners?.includes(bidProvider)); +}; diff --git a/apps/deploy-web/tests/seeders/provider.ts b/apps/deploy-web/tests/seeders/provider.ts index d95fb607b9..97a4a607aa 100644 --- a/apps/deploy-web/tests/seeders/provider.ts +++ b/apps/deploy-web/tests/seeders/provider.ts @@ -66,6 +66,7 @@ export function buildProvider(overrides?: Partial): ApiProvid isOnline: faker.datatype.boolean(), lastOnlineDate: faker.date.recent().toISOString(), isAudited: faker.datatype.boolean(), + aliasOwners: [], attributes: [ { key: "region", value: "us-east", auditedBy: [faker.string.alphanumeric(42)] }, { key: "host", value: faker.internet.domainWord(), auditedBy: [faker.string.alphanumeric(42)] },