Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/api/src/deployment/repositories/lease/lease.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<string, number>> {
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<string, number>();
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;

Expand Down
1 change: 1 addition & 0 deletions apps/api/src/provider/http-schemas/provider.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
86 changes: 82 additions & 4 deletions apps/api/src/provider/services/provider/provider.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = {
Expand All @@ -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);

Expand All @@ -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();

Expand Down Expand Up @@ -516,16 +584,26 @@ describe(ProviderService.name, () => {
const jwtTokenService = mock<ProviderJwtTokenService>({
generateJwtToken: jest.fn().mockResolvedValue(Ok("mock-jwt-token"))
});
const leaseRepository = mock<LeaseRepository>();
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,
providerRepository,
providerAttributesSchemaService,
auditorsService,
jwtTokenService,
providerProxyService
providerProxyService,
leaseRepository
};
}
});
35 changes: 28 additions & 7 deletions apps/api/src/provider/services/provider/provider.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 }) {
Expand Down Expand Up @@ -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<string, Provider>();
const ownersByHostUri = new Map<string, Set<string>>();
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<string>();
owners.add(provider.owner);
ownersByHostUri.set(provider.hostUri, owners);
});
const distinctProviders = Array.from(providerByHostUri.values());

Expand All @@ -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;
Expand All @@ -191,6 +198,20 @@ export class ProviderService {
return this.mapProviderResults(providersWithAttributesAndAuditors, providerWithNodes, auditors, providerAttributeSchema);
}

private isBetterProviderRepresentative(candidate: Provider, current: Provider, activeLeaseCountByOwner: Map<string, number>): 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[],
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ProviderList {
isOnline: boolean;
lastOnlineDate: Date | null;
isAudited: boolean;
aliasOwners: string[];
gpuModels: Array<{
vendor: string;
model: string;
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/utils/map/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export const mapProviderToList = (
provider: Provider,
providerAttributeSchema: ProviderAttributesSchema,
auditors: Array<Auditor>,
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;
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions apps/api/test/functional/__snapshots__/docs.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -12013,6 +12019,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"isOnline",
"lastOnlineDate",
"isAudited",
"aliasOwners",
"gpuModels",
"attributes",
"host",
Expand Down
5 changes: 3 additions & 2 deletions apps/deploy-web/src/components/new-deployment/BidGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -121,8 +122,8 @@ export const BidGroup: React.FunctionComponent<Props> = ({

<TableBody>
{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 ? (
<BidRow
key={bid.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,43 @@ describe(CreateLease.name, () => {
}
});

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"];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -213,7 +214,7 @@ export const CreateLease: React.FunctionComponent<Props> = ({ 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");
Expand Down Expand Up @@ -254,7 +255,7 @@ export const CreateLease: React.FunctionComponent<Props> = ({ 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);
});
}
Expand All @@ -264,7 +265,10 @@ export const CreateLease: React.FunctionComponent<Props> = ({ 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));
Expand Down
1 change: 1 addition & 0 deletions apps/deploy-web/src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading