Skip to content

Commit ebd08cc

Browse files
committed
feat: introduce trickle sp tier
1 parent 044930f commit ebd08cc

16 files changed

Lines changed: 342 additions & 44 deletions

apps/backend/.env.example

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,23 @@
2222
# Chain-specific variables never inherit the unprefixed slot and MUST be
2323
# prefixed: WALLET_ADDRESS, WALLET_PRIVATE_KEY, SESSION_KEY_PRIVATE_KEY, RPC_URL,
2424
# CLICKHOUSE_URL, PDP_SUBGRAPH_ENDPOINT, SUBGRAPH_ENDPOINT, DEALBOT_DATASET_VERSION,
25-
# BLOCKED_SP_IDS, BLOCKED_SP_ADDRESSES, DATASET_LIFECYCLE_CHECK_ENABLED
25+
# BLOCKED_SP_IDS, BLOCKED_SP_ADDRESSES, EXPECTED_APPROVED_SP_IDS,
26+
# EXPECTED_APPROVED_SP_ADDRESSES, DATASET_LIFECYCLE_CHECK_ENABLED
2627
# (network-dependent default: off on mainnet, so a shared =true must not enable
2728
# the canary there).
2829
#
30+
# SPs get full-rate testing (DEALS_PER_SP_PER_HOUR, DATASET_CREATIONS_PER_SP_PER_HOUR,
31+
# MIN_NUM_DATASETS_FOR_CHECKS, and the DATASET_LIFECYCLE_CHECK_ENABLED canary) only
32+
# if they're on-chain approved OR listed in
33+
# EXPECTED_APPROVED_SP_IDS/EXPECTED_APPROVED_SP_ADDRESSES (either one is enough —
34+
# they're checked independently, same as BLOCKED_SP_IDS/BLOCKED_SP_ADDRESSES).
35+
# Every other active, unblocked SP (new/unknown/not-yet-vetted) is throttled to a
36+
# fixed trickle tier (1 deal + 1 data-set-creation attempt every 4 hours, 1
37+
# data-set target; see `trickleTierRates` in src/config/constants.ts) to bound
38+
# wallet spend on SPs dealbot hasn't vetted yet. The lifecycle-check canary is
39+
# never scheduled at all for trickle-tier SPs (it creates+terminates a real
40+
# throwaway dataset every run). See #681.
41+
#
2942
# Process globals (database, HTTP ports, ClickHouse batching, pg-boss) have no
3043
# per-network form. Variables for INACTIVE networks are ignored; only active
3144
# networks are validated at startup.
@@ -140,6 +153,11 @@ CALIBRATION_PULL_PIECE_CLEANUP_INTERVAL_SECONDS=604800
140153
# CALIBRATION_BLOCKED_SP_IDS=1234,5678
141154
# CALIBRATION_BLOCKED_SP_ADDRESSES=0xAbCd...,0x1234...
142155

156+
# SPs eligible for full-rate testing beyond those already isApproved on-chain
157+
# (see #681) — candidates being considered for approval.
158+
# CALIBRATION_EXPECTED_APPROVED_SP_IDS=1234,5678
159+
# CALIBRATION_EXPECTED_APPROVED_SP_ADDRESSES=0xAbCd...,0x1234...
160+
143161
# -----------------------------------------------------------------------------
144162
# Per-network configuration — MAINNET (uncomment when adding to NETWORKS)
145163
# -----------------------------------------------------------------------------
@@ -174,6 +192,12 @@ MAINNET_MAINTENANCE_WINDOWS_UTC=07:00,22:00
174192
MAINNET_MAINTENANCE_WINDOW_MINUTES=20
175193
# MAINNET_BLOCKED_SP_IDS=1234,5678
176194
# MAINNET_BLOCKED_SP_ADDRESSES=0xAbCd...,0x1234...
195+
196+
# SPs eligible for full-rate testing beyond those already isApproved on-chain
197+
# (see #681) — candidates being considered for approval.
198+
# MAINNET_EXPECTED_APPROVED_SP_IDS=1234,5678
199+
# MAINNET_EXPECTED_APPROVED_SP_ADDRESSES=0xAbCd...,0x1234...
200+
177201
MAINNET_MAX_DATASET_STORAGE_SIZE_BYTES=25769803776
178202
MAINNET_TARGET_DATASET_STORAGE_SIZE_BYTES=21474836480
179203
MAINNET_MAX_PIECE_CLEANUP_RUNTIME_SECONDS=3000

apps/backend/src/common/sp-tier.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { INetworkConfig } from "src/config/types.js";
2+
3+
/**
4+
* Returns true if the provider qualifies for the full-rate testing tier:
5+
* already `isApproved` on-chain (approved SPs must stay fully monitored
6+
* regardless of manual-list staleness), or manually curated as an
7+
* expected-approval candidate. Every other SP (new, unknown, or not yet
8+
* vetted) defaults to the trickle tier — see #681.
9+
*/
10+
export function isFullRateTier(
11+
cfg: Pick<INetworkConfig, "expectedApprovedSpAddresses" | "expectedApprovedSpIds">,
12+
address: string,
13+
isApproved: boolean,
14+
id?: bigint | null,
15+
): boolean {
16+
if (isApproved) return true;
17+
if (cfg.expectedApprovedSpAddresses.has(address.toLowerCase())) return true;
18+
if (id != null && cfg.expectedApprovedSpIds.has(String(id))) return true;
19+
return false;
20+
}

apps/backend/src/config/constants.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ export const networkDefaults = {
3636
pullPieceCleanupIntervalSeconds: 7 * 24 * 3600, // 7 days
3737
} satisfies NetworkDefaults;
3838

39+
/**
40+
* Rate/target ceiling applied to SPs that are not on the full-rate tier (see
41+
* `isFullRateTier` in `common/sp-tier.ts`) — i.e. not `isApproved` on-chain
42+
* and not in `EXPECTED_APPROVED_SP_IDS`/`EXPECTED_APPROVED_SP_ADDRESSES`.
43+
*
44+
* Deliberately NOT env-configurable per network: this tier exists to bound
45+
* worst-case wallet spend on new/unknown SPs (#681).
46+
*/
47+
export const trickleTierRates = {
48+
dealsPerSpPerHour: 1 / 4, // 1 data-storage check every 4 hours
49+
dataSetCreationsPerSpPerHour: 1 / 4, // 1 data-set-creation tick every 4 hours
50+
minNumDataSetsForChecks: 1,
51+
} as const;
52+
3953
/**
4054
* Uppercase env-var prefixes for every supported network, e.g.
4155
* `["CALIBRATION", "MAINNET"]`

apps/backend/src/config/env.schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ const perNetworkFieldRules = (): Record<PerNetworkVar, Joi.Schema> => ({
189189
MAINTENANCE_WINDOW_MINUTES: Joi.number().min(20).max(360).optional(),
190190
BLOCKED_SP_IDS: Joi.string().optional().allow(""),
191191
BLOCKED_SP_ADDRESSES: Joi.string().optional().allow(""),
192+
EXPECTED_APPROVED_SP_IDS: Joi.string().optional().allow(""),
193+
EXPECTED_APPROVED_SP_ADDRESSES: Joi.string().optional().allow(""),
192194
PIECE_CLEANUP_PER_SP_PER_HOUR: Joi.number().min(0.001).max(20).optional(),
193195
MAX_PIECE_CLEANUP_RUNTIME_SECONDS: Joi.number().min(60).optional(),
194196
MAX_DATASET_STORAGE_SIZE_BYTES: Joi.number().integer().min(1).optional(),

apps/backend/src/config/loader.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,9 @@ function loadNetworkEnvPrefix(
234234
blockedSpIds: parseIdList(resolve("BLOCKED_SP_IDS")),
235235
blockedSpAddresses: parseAddressList(resolve("BLOCKED_SP_ADDRESSES")),
236236

237+
expectedApprovedSpIds: parseIdList(resolve("EXPECTED_APPROVED_SP_IDS")),
238+
expectedApprovedSpAddresses: parseAddressList(resolve("EXPECTED_APPROVED_SP_ADDRESSES")),
239+
237240
pullChecksPerSpPerHour: coerceFloat(resolve("PULL_CHECKS_PER_SP_PER_HOUR"), networkDefaults.pullChecksPerSpPerHour),
238241
pullCheckJobTimeoutSeconds: coerceNumber(
239242
resolve("PULL_CHECK_JOB_TIMEOUT_SECONDS"),

apps/backend/src/config/network-fields.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export const PER_NETWORK_VARS = [
4747
"MAINTENANCE_WINDOW_MINUTES",
4848
"BLOCKED_SP_IDS",
4949
"BLOCKED_SP_ADDRESSES",
50+
"EXPECTED_APPROVED_SP_IDS",
51+
"EXPECTED_APPROVED_SP_ADDRESSES",
5052
"PIECE_CLEANUP_PER_SP_PER_HOUR",
5153
"MAX_PIECE_CLEANUP_RUNTIME_SECONDS",
5254
"MAX_DATASET_STORAGE_SIZE_BYTES",
@@ -76,6 +78,8 @@ export const CHAIN_SPECIFIC_NETWORK_VARS = new Set<PerNetworkVar>([
7678
"DEALBOT_DATASET_VERSION",
7779
"BLOCKED_SP_IDS",
7880
"BLOCKED_SP_ADDRESSES",
81+
"EXPECTED_APPROVED_SP_IDS",
82+
"EXPECTED_APPROVED_SP_ADDRESSES",
7983
// Kept chain-specific so a shared `=true` can't accidentally enable it on mainnet.
8084
"DATASET_LIFECYCLE_CHECK_ENABLED",
8185
]);

apps/backend/src/config/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,17 @@ export type BaseNetworkConfig = {
141141
blockedSpIds: Set<string>;
142142
blockedSpAddresses: Set<string>;
143143

144+
/**
145+
* Manually curated SPs that should receive full-rate testing even though
146+
* they aren't (yet) `isApproved` on-chain — e.g. candidates being
147+
* considered for approval. Combined with `isApproved` to gate the
148+
* full-rate tier (see `isFullRateTier` in `common/sp-tier.ts`); every
149+
* other active, unblocked SP defaults to the trickle tier
150+
* (`trickleTierRates` in `config/constants.ts`). See #681.
151+
*/
152+
expectedApprovedSpIds: Set<string>;
153+
expectedApprovedSpAddresses: Set<string>;
154+
144155
/** Piece Cleanup Config */
145156
maxDatasetStorageSizeBytes: number;
146157
targetDatasetStorageSizeBytes: number;

apps/backend/src/deal/deal.service.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,7 +1365,7 @@ describe("DealService", () => {
13651365
it("returns undefined when minDataSets=1 and baseline is live", async () => {
13661366
probeSpy.mockResolvedValueOnce({ status: "live", dataSetId: 1n });
13671367

1368-
const result = await service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK);
1368+
const result = await service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK, 1);
13691369
expect(result).toBeUndefined();
13701370
expect(probeSpy).toHaveBeenCalledTimes(1);
13711371
const [, metadata] = probeSpy.mock.calls[0] ?? [];
@@ -1375,7 +1375,7 @@ describe("DealService", () => {
13751375
it("throws DealJobTerminatedDataSetError when baseline is terminated", async () => {
13761376
probeSpy.mockResolvedValueOnce({ status: "terminated", dataSetId: 42n });
13771377

1378-
await expect(service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK)).rejects.toBeInstanceOf(
1378+
await expect(service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK, 1)).rejects.toBeInstanceOf(
13791379
DealJobTerminatedDataSetError,
13801380
);
13811381
});
@@ -1390,7 +1390,7 @@ describe("DealService", () => {
13901390
vi.spyOn(Math, "random").mockReturnValue(0.5); // → index 1
13911391
probeSpy.mockResolvedValueOnce({ status: "live", dataSetId: 7n });
13921392

1393-
const result = await service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK);
1393+
const result = await service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK, 3);
13941394
expect(result).toEqual({ dealbotDS: "1" });
13951395
expect(probeSpy).toHaveBeenCalledTimes(1);
13961396
const [, metadata] = probeSpy.mock.calls[0] ?? [];
@@ -1412,7 +1412,7 @@ describe("DealService", () => {
14121412
setupIndexedProbe();
14131413
probeSpy.mockResolvedValueOnce({ status: "live", dataSetId: 1n });
14141414

1415-
const result = await service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK);
1415+
const result = await service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK, 3);
14161416
expect(result).toBeUndefined();
14171417
});
14181418

@@ -1431,7 +1431,7 @@ describe("DealService", () => {
14311431
});
14321432

14331433
await expect(
1434-
service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK, controller.signal),
1434+
service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK, 3, controller.signal),
14351435
).rejects.toThrow();
14361436
expect(probeSpy).toHaveBeenCalledTimes(1);
14371437
});

apps/backend/src/deal/deal.service.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import {
1717
redactSensitiveText,
1818
toStructuredError,
1919
} from "../common/logging.js";
20+
import { isFullRateTier } from "../common/sp-tier.js";
2021
import { createSynapseFromConfig } from "../common/synapse-factory.js";
2122
import type { DataFile, Hex, Network } from "../common/types.js";
23+
import { trickleTierRates } from "../config/constants.js";
2224
import type { IConfig, INetworkConfig } from "../config/types.js";
2325
import { Deal } from "../database/entities/deal.entity.js";
2426
import { DealStatus, IpniStatus, ServiceType } from "../database/types.js";
@@ -84,9 +86,14 @@ export class DealService {
8486
): Promise<Deal> {
8587
options.signal?.throwIfAborted();
8688

89+
const networkCfg = this.getNetworkConfig(options.network);
90+
const isFullTier = isFullRateTier(networkCfg, pdpProvider.serviceProvider, pdpProvider.isApproved, pdpProvider.id);
91+
const minDataSets = isFullTier ? networkCfg.minNumDataSetsForChecks : trickleTierRates.minNumDataSetsForChecks;
92+
8793
const extraDataSetMetadata = await this.resolveDataSetMetadataForDeal(
8894
pdpProvider.serviceProvider,
8995
options.network,
96+
minDataSets,
9097
options.signal,
9198
options.logContext,
9299
);
@@ -117,9 +124,10 @@ export class DealService {
117124
* Pick which data-set slot this deal will target.
118125
*
119126
* Policy:
120-
* - If `minNumDataSetsForChecks > 1` and a random index > 0 is selected,
121-
* probe that slot first. If live, use it. If missing or terminated,
122-
* fall through to baseline (data_set_creation owns repair/provisioning).
127+
* - If `minDataSets > 1` (the caller's per-SP tier target — see
128+
* `isFullRateTier`) and a random index > 0 is selected, probe that
129+
* slot first. If live, use it. If missing or terminated, fall through
130+
* to baseline (data_set_creation owns repair/provisioning).
123131
* - Probe baseline. If terminated, throw `DealJobTerminatedDataSetError`
124132
* (baseline is the fallback target; nothing else to try).
125133
* - Live or missing baseline → return `undefined` (use baseline slot).
@@ -131,6 +139,7 @@ export class DealService {
131139
async resolveDataSetMetadataForDeal(
132140
providerAddress: string,
133141
network: Network,
142+
minDataSets: number,
134143
signal?: AbortSignal,
135144
logContext?: ProviderJobContext,
136145
): Promise<Record<string, string> | undefined> {
@@ -141,6 +150,7 @@ export class DealService {
141150
providerAddress,
142151
baseDataSetMetadata,
143152
network,
153+
minDataSets,
144154
signal,
145155
logContext,
146156
);
@@ -174,10 +184,10 @@ export class DealService {
174184
providerAddress: string,
175185
baseDataSetMetadata: Record<string, string>,
176186
network: Network,
187+
minDataSets: number,
177188
signal: AbortSignal | undefined,
178189
logContext: ProviderJobContext | undefined,
179190
): Promise<Record<string, string> | undefined> {
180-
const minDataSets = this.getNetworkConfig(network).minNumDataSetsForChecks;
181191
if (minDataSets <= 1) return undefined;
182192
const dsIndex = Math.floor(Math.random() * minDataSets);
183193
if (dsIndex === 0) return undefined;

apps/backend/src/jobs/jobs.service.spec.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ describe("JobsService schedule rows", () => {
2424
upsertSchedule: ReturnType<typeof vi.fn>;
2525
deleteSchedulesForInactiveProviders: ReturnType<typeof vi.fn>;
2626
deleteSchedulesByJobType: ReturnType<typeof vi.fn>;
27+
deleteSchedulesForAddresses: ReturnType<typeof vi.fn>;
2728
countPausedSchedules: ReturnType<typeof vi.fn>;
2829
findDueSchedulesWithManager: ReturnType<typeof vi.fn>;
2930
runTransaction: ReturnType<typeof vi.fn>;
@@ -90,6 +91,7 @@ describe("JobsService schedule rows", () => {
9091
upsertSchedule: vi.fn(),
9192
deleteSchedulesForInactiveProviders: vi.fn(async () => []),
9293
deleteSchedulesByJobType: vi.fn(async () => 0),
94+
deleteSchedulesForAddresses: vi.fn(async () => []),
9395
countPausedSchedules: vi.fn(async () => []),
9496
findDueSchedulesWithManager: vi.fn(),
9597
runTransaction: vi.fn(async (callback: (manager: unknown) => Promise<void>) => {
@@ -151,6 +153,8 @@ describe("JobsService schedule rows", () => {
151153
maintenanceWindowMinutes: 20,
152154
blockedSpIds: new Set(),
153155
blockedSpAddresses: new Set(),
156+
expectedApprovedSpIds: new Set(),
157+
expectedApprovedSpAddresses: new Set(),
154158
pieceCleanupPerSpPerHour: 1,
155159
maxPieceCleanupRuntimeSeconds: 300,
156160
maxDatasetStorageSizeBytes: 24 * 1024 * 1024 * 1024,
@@ -1337,7 +1341,7 @@ describe("JobsService schedule rows", () => {
13371341
};
13381342

13391343
const providerRegistryRepository = {
1340-
findByAddress: vi.fn(() => ({ id: 1, name: "test-provider" })),
1344+
findByAddress: vi.fn(() => ({ id: 1, name: "test-provider", isApproved: true })),
13411345
};
13421346

13431347
service = buildService({
@@ -1474,7 +1478,9 @@ describe("JobsService schedule rows", () => {
14741478
} as unknown as JobsServiceDeps[0];
14751479

14761480
const dataSetLifecycleService = { runLifecycleCheck: vi.fn(async () => undefined) };
1477-
const providerRegistryRepository = { findByAddress: vi.fn(() => ({ id: 1, name: "test-provider" })) };
1481+
const providerRegistryRepository = {
1482+
findByAddress: vi.fn(() => ({ id: 1, name: "test-provider", isApproved: true })),
1483+
};
14781484

14791485
service = buildService({
14801486
configService,
@@ -1519,7 +1525,7 @@ describe("JobsService schedule rows", () => {
15191525
} as unknown as JobsServiceDeps[0];
15201526
service = buildService({ configService });
15211527

1522-
providerRegistryRepositoryMock.findActiveAddresses.mockResolvedValueOnce([{ address: "0xaaa" }]);
1528+
providerRegistryRepositoryMock.findActiveAddresses.mockResolvedValueOnce([{ address: "0xaaa", isApproved: true }]);
15231529

15241530
await callPrivate(service, "ensureScheduleRows", DEFAULT_NETWORK);
15251531

@@ -1529,11 +1535,12 @@ describe("JobsService schedule rows", () => {
15291535
expect(lifecycleUpserts).toHaveLength(1);
15301536
expect(lifecycleUpserts[0][1]).toBe("0xaaa");
15311537
expect(jobScheduleRepositoryMock.deleteSchedulesByJobType).not.toHaveBeenCalled();
1538+
expect(jobScheduleRepositoryMock.deleteSchedulesForAddresses).not.toHaveBeenCalled();
15321539
});
15331540

15341541
it("removes data_set_lifecycle_check schedules when disabled", async () => {
15351542
// base config has dataSetLifecycleCheckEnabled=false
1536-
providerRegistryRepositoryMock.findActiveAddresses.mockResolvedValueOnce([{ address: "0xaaa" }]);
1543+
providerRegistryRepositoryMock.findActiveAddresses.mockResolvedValueOnce([{ address: "0xaaa", isApproved: true }]);
15371544

15381545
await callPrivate(service, "ensureScheduleRows", DEFAULT_NETWORK);
15391546

@@ -1547,6 +1554,35 @@ describe("JobsService schedule rows", () => {
15471554
);
15481555
});
15491556

1557+
it("does not schedule data_set_lifecycle_check for trickle-tier providers, and clears stale rows", async () => {
1558+
baseConfigValues = {
1559+
...baseConfigValues,
1560+
networks: {
1561+
calibration: { ...(baseConfigValues.networks as any).calibration, dataSetLifecycleCheckEnabled: true },
1562+
} as unknown as IConfig["networks"],
1563+
};
1564+
configService = {
1565+
get: vi.fn((key: keyof IConfig) => baseConfigValues[key]),
1566+
} as unknown as JobsServiceDeps[0];
1567+
service = buildService({ configService });
1568+
1569+
// Neither approved nor on the expected-approved list -> trickle tier.
1570+
providerRegistryRepositoryMock.findActiveAddresses.mockResolvedValueOnce([{ address: "0xaaa", isApproved: false }]);
1571+
1572+
await callPrivate(service, "ensureScheduleRows", DEFAULT_NETWORK);
1573+
1574+
const lifecycleUpserts = jobScheduleRepositoryMock.upsertSchedule.mock.calls.filter(
1575+
(call) => call[0] === "data_set_lifecycle_check",
1576+
);
1577+
expect(lifecycleUpserts).toHaveLength(0);
1578+
expect(jobScheduleRepositoryMock.deleteSchedulesByJobType).not.toHaveBeenCalled();
1579+
expect(jobScheduleRepositoryMock.deleteSchedulesForAddresses).toHaveBeenCalledWith(
1580+
"data_set_lifecycle_check",
1581+
["0xaaa"],
1582+
DEFAULT_NETWORK,
1583+
);
1584+
});
1585+
15501586
it("sets active, inactive, and tested provider gauge values after refresh", async () => {
15511587
providerRegistryRepositoryMock.countByNetwork.mockResolvedValueOnce(10);
15521588
providerRegistryRepositoryMock.countActiveByNetwork.mockResolvedValueOnce(7);

0 commit comments

Comments
 (0)