diff --git a/apps/api/src/billing/config/env.config.ts b/apps/api/src/billing/config/env.config.ts index dd4f4608cd..5faa81f134 100644 --- a/apps/api/src/billing/config/env.config.ts +++ b/apps/api/src/billing/config/env.config.ts @@ -5,54 +5,66 @@ import { AUDITOR } from "@src/deployment/config/provider.config"; dotenv.config({ path: "env/.env.funding-wallet-index" }); -export const envSchema = z.object({ - OLD_MASTER_WALLET_MNEMONIC: z.string().optional(), - FUNDING_WALLET_MNEMONIC: z.string().optional(), - FUNDING_WALLET_MNEMONIC_V1: z.string().optional(), - FUNDING_WALLET_MNEMONIC_V2: z.string().optional(), - DERIVATION_WALLET_MNEMONIC: z.string().optional(), - DERIVATION_WALLET_MNEMONIC_V1: z.string().optional(), - DERIVATION_WALLET_MNEMONIC_V2: z.string().optional(), - NETWORK: z.enum(["mainnet", "testnet", "sandbox"]), - RPC_NODE_ENDPOINT: z.string(), - TRIAL_ALLOWANCE_EXPIRATION_DAYS: z.number({ coerce: true }).default(30), - TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT: z.number({ coerce: true }), - TRIAL_FEES_ALLOWANCE_AMOUNT: z.number({ coerce: true }), - TRIAL_DEPLOYMENT_CLEANUP_HOURS: z.number({ coerce: true }).default(24), - DEPLOYMENT_GRANT_DENOM: z.enum(["uakt", "uact"]), - GAS_SAFETY_MULTIPLIER: z.number({ coerce: true }).default(1.8), - AVERAGE_GAS_PRICE: z.number({ coerce: true }).default(0.025), - FEE_ALLOWANCE_REFILL_THRESHOLD: z.number({ coerce: true }), - FEE_ALLOWANCE_REFILL_AMOUNT: z.number({ coerce: true }), - DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT: z.number({ coerce: true }), - ALLOWANCE_REFILL_BATCH_SIZE: z.number({ coerce: true }).default(10), - WALLET_BATCHING_INTERVAL_MS: z.number().optional().default(1000), - STRIPE_SECRET_KEY: z.string(), - STRIPE_PRODUCT_ID: z.string(), - STRIPE_WEBHOOK_SECRET: z.string(), - STRIPE_ENABLE_COUPONS: z.enum(["true", "false"]).default("false"), - CONSOLE_WEB_PAYMENT_LINK: z.string(), - MANAGED_WALLET_LEASE_ALLOWED_AUDITORS: z - .string() - .default(AUDITOR) - .transform(val => (val ? val.split(",").map(addr => addr.trim()) : [])), - MANAGED_WALLET_TRIAL_MIN_TOP_UP_AMOUNT: z.number({ coerce: true }).min(20).default(100), - MANAGED_WALLET_TRIAL_BLOCKED_GPU_MODELS: z - .string() - .default("nvidia/b300,nvidia/b200,nvidia/h200,nvidia/h100,nvidia/pro6000se,nvidia/pro6000we,nvidia/a100,nvidia/rtx5090,nvidia/rtx4090,nvidia/rtx3090") - .transform(val => - val - ? val - .split(",") - .map(entry => entry.trim().toLowerCase()) - .filter(Boolean) - : [] - ) - .refine(entries => entries.every(entry => /^[a-z0-9._-]+\/[a-z0-9._-]+$/.test(entry)), { - message: "MANAGED_WALLET_TRIAL_BLOCKED_GPU_MODELS entries must be in 'vendor/model' format" - }), - MASTER_WALLET_TARGET_ACT_BALANCE: z.number({ coerce: true }).default(10_000_000_000), - TX_SIGNER_BASE_URL: z.string() -}); +export const envSchema = z + .object({ + OLD_MASTER_WALLET_MNEMONIC: z.string().optional(), + FUNDING_WALLET_MNEMONIC: z.string().optional(), + FUNDING_WALLET_MNEMONIC_V1: z.string().optional(), + FUNDING_WALLET_MNEMONIC_V2: z.string().optional(), + DERIVATION_WALLET_MNEMONIC: z.string().optional(), + DERIVATION_WALLET_MNEMONIC_V1: z.string().optional(), + DERIVATION_WALLET_MNEMONIC_V2: z.string().optional(), + NETWORK: z.enum(["mainnet", "testnet", "sandbox"]), + RPC_NODE_ENDPOINT: z.string(), + TRIAL_ALLOWANCE_EXPIRATION_DAYS: z.number({ coerce: true }).default(30), + TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT: z.number({ coerce: true }), + TRIAL_FEES_ALLOWANCE_AMOUNT: z.number({ coerce: true }), + TRIAL_DEPLOYMENT_CLEANUP_HOURS: z.number({ coerce: true }).default(24), + DEPLOYMENT_GRANT_DENOM: z.enum(["uakt", "uact"]), + GAS_SAFETY_MULTIPLIER: z.number({ coerce: true }).default(1.8), + AVERAGE_GAS_PRICE: z.number({ coerce: true }).default(0.025), + FEE_ALLOWANCE_REFILL_THRESHOLD: z.number({ coerce: true }), + FEE_ALLOWANCE_REFILL_AMOUNT: z.number({ coerce: true }), + DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT: z.number({ coerce: true }), + ALLOWANCE_REFILL_BATCH_SIZE: z.number({ coerce: true }).default(10), + WALLET_BATCHING_INTERVAL_MS: z.number().optional().default(1000), + STRIPE_SECRET_KEY: z.string(), + STRIPE_PRODUCT_ID: z.string(), + STRIPE_WEBHOOK_SECRET: z.string(), + STRIPE_ENABLE_COUPONS: z.enum(["true", "false"]).default("false"), + CONSOLE_WEB_PAYMENT_LINK: z.string(), + MANAGED_WALLET_LEASE_ALLOWED_AUDITORS: z + .string() + .default(AUDITOR) + .transform(val => (val ? val.split(",").map(addr => addr.trim()) : [])), + MANAGED_WALLET_TRIAL_MIN_TOP_UP_AMOUNT: z.number({ coerce: true }).min(20).default(100), + MANAGED_WALLET_TRIAL_BLOCKED_GPU_MODELS: z + .string() + .default("nvidia/b300,nvidia/b200,nvidia/h200,nvidia/h100,nvidia/pro6000se,nvidia/pro6000we,nvidia/a100,nvidia/rtx5090,nvidia/rtx4090,nvidia/rtx3090") + .transform(val => + val + ? val + .split(",") + .map(entry => entry.trim().toLowerCase()) + .filter(Boolean) + : [] + ) + .refine(entries => entries.every(entry => /^[a-z0-9._-]+\/[a-z0-9._-]+$/.test(entry)), { + message: "MANAGED_WALLET_TRIAL_BLOCKED_GPU_MODELS entries must be in 'vendor/model' format" + }), + MASTER_WALLET_TARGET_ACT_BALANCE: z.number({ coerce: true }).default(10_000_000_000), + MANAGED_WALLET_BID_PRICE_GUARD_ENABLED: z + .enum(["true", "false"]) + .default("true") + .transform(val => val === "true"), + MANAGED_WALLET_BID_PRICE_WARN_MULTIPLIER: z.number({ coerce: true }).positive().default(5), + MANAGED_WALLET_BID_PRICE_BLOCK_MULTIPLIER: z.number({ coerce: true }).positive().default(10), + MANAGED_WALLET_BID_PRICE_ABSOLUTE_MAX_UACT: z.number({ coerce: true }).positive().optional(), + TX_SIGNER_BASE_URL: z.string() + }) + .refine(config => config.MANAGED_WALLET_BID_PRICE_WARN_MULTIPLIER <= config.MANAGED_WALLET_BID_PRICE_BLOCK_MULTIPLIER, { + message: "MANAGED_WALLET_BID_PRICE_WARN_MULTIPLIER must be less than or equal to MANAGED_WALLET_BID_PRICE_BLOCK_MULTIPLIER", + path: ["MANAGED_WALLET_BID_PRICE_WARN_MULTIPLIER"] + }); export type BillingConfig = z.infer; diff --git a/apps/api/src/billing/lib/lease-messages/lease-messages.ts b/apps/api/src/billing/lib/lease-messages/lease-messages.ts new file mode 100644 index 0000000000..bdc4ec14a1 --- /dev/null +++ b/apps/api/src/billing/lib/lease-messages/lease-messages.ts @@ -0,0 +1,67 @@ +import { MsgCreateLease } from "@akashnetwork/chain-sdk/private-types/akash.v1beta5"; +import type { Bid, BidHttpService } from "@akashnetwork/http-sdk"; +import type { EncodeObject } from "@cosmjs/proto-signing"; +import assert from "http-assert"; + +type LeaseBidId = NonNullable; + +/** + * A lease bid id paired with the bids it resolves to: the full set of bids on the same + * order (`orderBids`, useful for peer comparisons) and the specific `accepted` bid the + * lease message references. + */ +export interface ResolvedLeaseBid { + bidId: LeaseBidId; + orderBids: Bid[]; + accepted: Bid; +} + +/** + * Extracts the bid IDs from every MsgCreateLease message in a decoded transaction. + * A MsgCreateLease references the bid being accepted via its bidId; messages of any + * other type are ignored. + */ +export function getLeaseBidIds(messages: EncodeObject[]): LeaseBidId[] { + return messages + .filter(message => message.typeUrl === `/${MsgCreateLease.$type}`) + .map(message => (message.value as MsgCreateLease).bidId) + .filter((id): id is LeaseBidId => !!id); +} + +/** + * Resolves every MsgCreateLease bid id in a transaction to its on-chain bids. + * + * A MsgCreateLease only references a bid, so the bids for each referenced order are fetched + * (deduplicated by dseq, in parallel) and the accepted bid is matched within its order. Throws + * 403 if a referenced bid cannot be found. Returns an empty list when there are no lease messages. + */ +export async function resolveLeaseBids(messages: EncodeObject[], owner: string, bidHttpService: BidHttpService): Promise { + const leaseBidIds = getLeaseBidIds(messages); + if (leaseBidIds.length === 0) return []; + + const uniqueDseqs = Array.from(new Set(leaseBidIds.map(id => id.dseq.toString()))); + const bidsByDseq = new Map(); + await Promise.all( + uniqueDseqs.map(async dseq => { + bidsByDseq.set(dseq, await bidHttpService.list(owner, dseq)); + }) + ); + + return leaseBidIds.map(bidId => { + const orderBids = bidsByDseq.get(bidId.dseq.toString()) ?? []; + const accepted = orderBids.find( + b => + b.bid.id.owner === bidId.owner && + b.bid.id.gseq === bidId.gseq && + b.bid.id.oseq === bidId.oseq && + b.bid.id.provider === bidId.provider && + b.bid.id.bseq === bidId.bseq + ); + assert( + accepted, + 403, + `Referenced lease bid not found: dseq=${bidId.dseq}, gseq=${bidId.gseq}, oseq=${bidId.oseq}, provider=${bidId.provider}, bseq=${bidId.bseq}` + ); + return { bidId, orderBids, accepted }; + }); +} diff --git a/apps/api/src/billing/services/lease-bid-price-guard/lease-bid-price-guard.service.spec.ts b/apps/api/src/billing/services/lease-bid-price-guard/lease-bid-price-guard.service.spec.ts new file mode 100644 index 0000000000..969d66538e --- /dev/null +++ b/apps/api/src/billing/services/lease-bid-price-guard/lease-bid-price-guard.service.spec.ts @@ -0,0 +1,194 @@ +import { MsgCreateDeployment } from "@akashnetwork/chain-sdk/private-types/akash.v1beta4"; +import { MsgCreateLease } from "@akashnetwork/chain-sdk/private-types/akash.v1beta5"; +import type { Bid } from "@akashnetwork/http-sdk"; +import type { BidHttpService } from "@akashnetwork/http-sdk"; +import type { EncodeObject } from "@cosmjs/proto-signing"; +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; +import type { LoggerService } from "@src/core"; +import { LeaseBidPriceGuardService } from "./lease-bid-price-guard.service"; + +import { mockConfigService } from "@test/mocks/config-service.mock"; +import { createBid } from "@test/seeders/bid.seeder"; +import { createUserWallet } from "@test/seeders/user-wallet.seeder"; + +const OWNER = "akash1owner"; +const ACCEPTED = { dseq: "111", gseq: 1, oseq: 1, bseq: 1, provider: "akash1prov" }; + +describe(LeaseBidPriceGuardService.name, () => { + describe("relative limit", () => { + it("blocks the lease and alerts when the accepted bid reaches the block multiple of the cheapest competing bid", async () => { + const { service, logger } = setup({ + bids: [pricedBid(ACCEPTED, 9_519_658), pricedBid({ ...ACCEPTED, provider: "akash1cheap", bseq: 2 }, 4.81)] + }); + + await expect(service.validateLeaseBidPrices([leaseMessage(ACCEPTED)], createUserWallet())).rejects.toMatchObject({ + status: 403, + message: expect.stringContaining("cheapest competing bid") + }); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ event: "LEASE_BLOCKED_EXCESSIVE_BID_PRICE", reason: "relative", provider: "akash1prov", denom: "uact" }) + ); + }); + + it("allows but warns when the accepted bid is between the warn and block multiples", async () => { + const { service, logger } = setup({ + warnMultiplier: 5, + blockMultiplier: 10, + bids: [pricedBid(ACCEPTED, 600), pricedBid({ ...ACCEPTED, provider: "akash1cheap", bseq: 2 }, 100)] + }); + + await expect(service.validateLeaseBidPrices([leaseMessage(ACCEPTED)], createUserWallet())).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ event: "LEASE_BID_PRICE_WARNING", ratio: 6 })); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it("allows silently when the accepted bid is below the warn multiple", async () => { + const { service, logger } = setup({ + bids: [pricedBid(ACCEPTED, 300), pricedBid({ ...ACCEPTED, provider: "akash1cheap", bseq: 2 }, 100)] + }); + + await expect(service.validateLeaseBidPrices([leaseMessage(ACCEPTED)], createUserWallet())).resolves.toBeUndefined(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it("allows when the accepted bid is the cheapest on the order", async () => { + const { service } = setup({ + bids: [pricedBid(ACCEPTED, 100), pricedBid({ ...ACCEPTED, provider: "akash1other", bseq: 2 }, 9_000_000)] + }); + + await expect(service.validateLeaseBidPrices([leaseMessage(ACCEPTED)], createUserWallet())).resolves.toBeUndefined(); + }); + }); + + describe("absolute limit", () => { + it("blocks a sole bidder priced above the per-denom ceiling", async () => { + const { service, logger } = setup({ absoluteMaxUact: 1000, bids: [pricedBid(ACCEPTED, 2000)] }); + + await expect(service.validateLeaseBidPrices([leaseMessage(ACCEPTED)], createUserWallet())).rejects.toMatchObject({ status: 403 }); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "LEASE_BLOCKED_EXCESSIVE_BID_PRICE", reason: "absolute" })); + }); + + it("allows a sole bidder priced below the per-denom ceiling", async () => { + const { service } = setup({ absoluteMaxUact: 1000, bids: [pricedBid(ACCEPTED, 500)] }); + + await expect(service.validateLeaseBidPrices([leaseMessage(ACCEPTED)], createUserWallet())).resolves.toBeUndefined(); + }); + + it("allows a sole bidder at any price when no ceiling is configured", async () => { + const { service } = setup({ absoluteMaxUact: undefined, bids: [pricedBid(ACCEPTED, 9_519_658)] }); + + await expect(service.validateLeaseBidPrices([leaseMessage(ACCEPTED)], createUserWallet())).resolves.toBeUndefined(); + }); + + it("applies the ceiling independently of the relative check when peers exist", async () => { + const { service, logger } = setup({ + absoluteMaxUact: 300, + bids: [pricedBid(ACCEPTED, 400), pricedBid({ ...ACCEPTED, provider: "akash1cheap", bseq: 2 }, 100)] + }); + + await expect(service.validateLeaseBidPrices([leaseMessage(ACCEPTED)], createUserWallet())).rejects.toMatchObject({ status: 403 }); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ reason: "absolute" })); + }); + + it("does not apply the ceiling to a non-uact denom", async () => { + const { service } = setup({ absoluteMaxUact: 1000, bids: [pricedBid(ACCEPTED, 2000, "uakt")] }); + + await expect(service.validateLeaseBidPrices([leaseMessage(ACCEPTED)], createUserWallet())).resolves.toBeUndefined(); + }); + }); + + describe("guard activation", () => { + it("is a no-op and skips fetching bids when the guard is disabled", async () => { + const { service, bidHttpService } = setup({ enabled: false, bids: [pricedBid(ACCEPTED, 9_519_658)] }); + + await expect(service.validateLeaseBidPrices([leaseMessage(ACCEPTED)], createUserWallet())).resolves.toBeUndefined(); + expect(bidHttpService.list).not.toHaveBeenCalled(); + }); + + it("is a no-op when there are no lease messages", async () => { + const { service, bidHttpService } = setup({}); + + await expect(service.validateLeaseBidPrices([deploymentMessage()], createUserWallet())).resolves.toBeUndefined(); + expect(bidHttpService.list).not.toHaveBeenCalled(); + }); + }); + + it("rejects with 403 when the referenced bid cannot be resolved", async () => { + const { service } = setup({ bids: [pricedBid({ ...ACCEPTED, provider: "akash1other" }, 100)] }); + + await expect(service.validateLeaseBidPrices([leaseMessage(ACCEPTED)], createUserWallet())).rejects.toMatchObject({ + status: 403, + message: expect.stringContaining("Referenced lease bid not found") + }); + }); + + it("rejects with 403 when the bid owner does not match the lease message owner", async () => { + const foreignBid = createBid({ owner: "akash1someoneelse", ...ACCEPTED }); + foreignBid.bid.price = { denom: "uakt", amount: "100" }; + const { service } = setup({ bids: [foreignBid] }); + + await expect(service.validateLeaseBidPrices([leaseMessage(ACCEPTED)], createUserWallet())).rejects.toMatchObject({ + status: 403, + message: expect.stringContaining("Referenced lease bid not found") + }); + }); + + it("evaluates each order independently and blocks the offending one", async () => { + const cheapOrder = { dseq: "111", gseq: 1, oseq: 1, bseq: 1, provider: "akash1prov" }; + const absurdOrder = { dseq: "111", gseq: 2, oseq: 1, bseq: 1, provider: "akash1prov" }; + const { service, logger } = setup({ + bids: [ + pricedBid(cheapOrder, 100), + pricedBid({ ...cheapOrder, provider: "akash1peer", bseq: 2 }, 100), + pricedBid(absurdOrder, 9_519_658), + pricedBid({ ...absurdOrder, provider: "akash1peer", bseq: 2 }, 4.81) + ] + }); + + await expect(service.validateLeaseBidPrices([leaseMessage(cheapOrder), leaseMessage(absurdOrder)], createUserWallet())).rejects.toMatchObject({ + status: 403 + }); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ reason: "relative", gseq: 2 })); + }); + + function pricedBid(ids: { dseq: string; gseq: number; oseq: number; bseq: number; provider: string }, amount: number, denom = "uact"): Bid { + const bid = createBid({ owner: OWNER, ...ids }); + bid.bid.price = { denom, amount: amount.toString() }; + return bid; + } + + function leaseMessage(bidId: { dseq: string; gseq: number; oseq: number; bseq: number; provider: string }): EncodeObject { + return { + typeUrl: `/${MsgCreateLease.$type}`, + value: MsgCreateLease.fromPartial({ bidId: { owner: OWNER, ...bidId } }) + }; + } + + function deploymentMessage(): EncodeObject { + return { typeUrl: `/${MsgCreateDeployment.$type}`, value: MsgCreateDeployment.fromPartial({}) }; + } + + function setup(input: { + enabled?: boolean; + warnMultiplier?: number; + blockMultiplier?: number; + absoluteMaxUact?: number; + bids?: Bid[]; + }) { + const config = mockConfigService({ + MANAGED_WALLET_BID_PRICE_GUARD_ENABLED: input.enabled ?? true, + MANAGED_WALLET_BID_PRICE_WARN_MULTIPLIER: input.warnMultiplier ?? 5, + MANAGED_WALLET_BID_PRICE_BLOCK_MULTIPLIER: input.blockMultiplier ?? 10, + MANAGED_WALLET_BID_PRICE_ABSOLUTE_MAX_UACT: input.absoluteMaxUact + }); + const bidHttpService = mock(); + bidHttpService.list.mockResolvedValue(input.bids ?? []); + const logger = mock(); + const service = new LeaseBidPriceGuardService(config, bidHttpService, logger); + return { service, config, bidHttpService, logger }; + } +}); diff --git a/apps/api/src/billing/services/lease-bid-price-guard/lease-bid-price-guard.service.ts b/apps/api/src/billing/services/lease-bid-price-guard/lease-bid-price-guard.service.ts new file mode 100644 index 0000000000..fec7f351d2 --- /dev/null +++ b/apps/api/src/billing/services/lease-bid-price-guard/lease-bid-price-guard.service.ts @@ -0,0 +1,141 @@ +import { Bid, BidHttpService } from "@akashnetwork/http-sdk"; +import { Trace } from "@akashnetwork/instrumentation"; +import { EncodeObject } from "@cosmjs/proto-signing"; +import assert from "http-assert"; +import { singleton } from "tsyringe"; + +import { resolveLeaseBids } from "@src/billing/lib/lease-messages/lease-messages"; +import type { UserWalletOutput } from "@src/billing/repositories"; +import { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; +import { LoggerService } from "@src/core"; + +/** + * Guards managed-wallet lease creation against absurdly-overpriced bids. + * + * A managed wallet pays for leases out of the shared funding (master) wallet, so a provider that + * sets a price-per-block far above the market can drain it. The chain message that opens a lease + * (MsgCreateLease) only references a bid, carrying no price, so the only place to catch this before + * broadcasting is here — by re-fetching the order's bids and comparing the accepted bid against its + * peers. + * + * Two checks: + * - Relative: block when the accepted bid is far above the cheapest competing bid for the same + * order (apples-to-apples — same resources, denom and moment). Only applied when peers exist. + * - Absolute: block when the per-block price exceeds a configured per-denom ceiling. Covers the + * sole-bidder case where there is no peer to compare against. + * + * Prices moderately above the norm are allowed but logged at warn level for visibility. All + * thresholds are configurable and the guard can be disabled entirely. + */ +@singleton() +export class LeaseBidPriceGuardService { + constructor( + private readonly config: BillingConfigService, + private readonly bidHttpService: BidHttpService, + private readonly logger: LoggerService + ) { + this.logger.setContext(LeaseBidPriceGuardService.name); + } + + @Trace() + async validateLeaseBidPrices(messages: EncodeObject[], userWallet: UserWalletOutput): Promise { + if (!this.config.get("MANAGED_WALLET_BID_PRICE_GUARD_ENABLED")) return; + + const resolvedBids = await resolveLeaseBids(messages, userWallet.address!, this.bidHttpService); + + for (const { accepted, orderBids } of resolvedBids) { + this.#assertBidPriceWithinLimits(accepted, orderBids, userWallet); + } + } + + #assertBidPriceWithinLimits(accepted: Bid, orderBids: Bid[], userWallet: UserWalletOutput): void { + const acceptedPrice = Number(accepted.bid.price.amount); + const denom = accepted.bid.price.denom; + + if (!Number.isFinite(acceptedPrice) || acceptedPrice <= 0) return; + + this.#assertWithinRelativeLimit(acceptedPrice, denom, accepted, orderBids, userWallet); + this.#assertWithinAbsoluteLimit(acceptedPrice, denom, accepted, userWallet); + } + + #assertWithinRelativeLimit(acceptedPrice: number, denom: string, accepted: Bid, orderBids: Bid[], userWallet: UserWalletOutput): void { + const { id } = accepted.bid; + + // Cheapest bid for the same order (gseq + oseq). Needs at least one competing bid to be meaningful. + const peerPrices = orderBids + .filter(b => b.bid.id.gseq === id.gseq && b.bid.id.oseq === id.oseq) + .map(b => Number(b.bid.price.amount)) + .filter(price => Number.isFinite(price) && price > 0); + + if (peerPrices.length < 2) return; + + const normPrice = Math.min(...peerPrices); + const ratio = acceptedPrice / normPrice; + const blockMultiplier = this.config.get("MANAGED_WALLET_BID_PRICE_BLOCK_MULTIPLIER"); + const warnMultiplier = this.config.get("MANAGED_WALLET_BID_PRICE_WARN_MULTIPLIER"); + + if (ratio >= blockMultiplier) { + this.#block( + "relative", + accepted, + userWallet, + { acceptedPrice, normPrice, ratio, denom, blockMultiplier }, + `Bid from provider ${id.provider} is ${ratio.toFixed(1)}x the cheapest competing bid for the same order and was refused for your protection.` + ); + } + + if (ratio >= warnMultiplier) { + this.logger.warn({ + event: "LEASE_BID_PRICE_WARNING", + userId: userWallet.userId, + owner: id.owner, + provider: id.provider, + dseq: id.dseq, + gseq: id.gseq, + oseq: id.oseq, + bseq: id.bseq, + acceptedPrice, + normPrice, + ratio, + denom, + warnMultiplier + }); + } + } + + #assertWithinAbsoluteLimit(acceptedPrice: number, denom: string, accepted: Bid, userWallet: UserWalletOutput): void { + const absoluteMax = this.#getAbsoluteMax(denom); + if (absoluteMax === undefined || acceptedPrice <= absoluteMax) return; + + this.#block( + "absolute", + accepted, + userWallet, + { acceptedPrice, absoluteMax, denom }, + `Bid from provider ${accepted.bid.id.provider} at ${acceptedPrice} ${denom}/block exceeds the maximum allowed price and was refused for your protection.` + ); + } + + #block(reason: "relative" | "absolute", accepted: Bid, userWallet: UserWalletOutput, details: Record, message: string): never { + const { id } = accepted.bid; + this.logger.error({ + event: "LEASE_BLOCKED_EXCESSIVE_BID_PRICE", + reason, + userId: userWallet.userId, + owner: id.owner, + provider: id.provider, + dseq: id.dseq, + gseq: id.gseq, + oseq: id.oseq, + bseq: id.bseq, + ...details + }); + assert(false, 403, message); + } + + // Deployments are uact-denominated; the ceiling only applies to uact bids. Any other denom + // (e.g. a legacy uakt bid) has no absolute cap and is covered by the relative check alone. + #getAbsoluteMax(denom: string): number | undefined { + return denom === "uact" ? this.config.get("MANAGED_WALLET_BID_PRICE_ABSOLUTE_MAX_UACT") : undefined; + } +} diff --git a/apps/api/src/billing/services/managed-signer/managed-signer.service.spec.ts b/apps/api/src/billing/services/managed-signer/managed-signer.service.spec.ts index a7db9dadbd..9cedf5e691 100644 --- a/apps/api/src/billing/services/managed-signer/managed-signer.service.spec.ts +++ b/apps/api/src/billing/services/managed-signer/managed-signer.service.spec.ts @@ -15,6 +15,7 @@ import type { UserWalletRepository } from "@src/billing/repositories"; import type { BalancesService } from "@src/billing/services/balances/balances.service"; import type { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; import type { ChainErrorService } from "@src/billing/services/chain-error/chain-error.service"; +import type { LeaseBidPriceGuardService } from "@src/billing/services/lease-bid-price-guard/lease-bid-price-guard.service"; import type { ManagedUserWalletService } from "@src/billing/services/managed-user-wallet/managed-user-wallet.service"; import type { TrialValidationService } from "@src/billing/services/trial-validation/trial-validation.service"; import type { WalletReloadJobService } from "@src/billing/services/wallet-reload-job/wallet-reload-job.service"; @@ -395,6 +396,29 @@ describe(ManagedSignerService.name, () => { expect(anonymousValidateService.validateLeaseProvidersAuditors).toHaveBeenCalledWith(messages, wallet); }); + + it("rejects an overpriced lease before broadcasting", async () => { + const wallet = createUserWallet({ userId: "user-123", feeAllowance: 100, deploymentAllowance: 100 }); + const user = createUser({ userId: "user-123" }); + const messages: EncodeObject[] = [ + { + typeUrl: MsgCreateLease.$type, + value: MsgCreateLease.fromPartial({ bidId: { dseq: 123, provider: "akash1provider" } }) + } + ]; + const signAndBroadcastWithDerivedWallet = jest.fn().mockResolvedValue({ code: 0, hash: "tx-hash", rawLog: "success" }); + + const { service } = setup({ + findOneByUserId: jest.fn().mockResolvedValue(wallet), + findById: jest.fn().mockResolvedValue(user), + validateLeaseProvidersAuditors: jest.fn().mockResolvedValue(undefined), + validateLeaseBidPrices: jest.fn().mockRejectedValue(Object.assign(new Error("Bid price too high"), { status: 403 })), + signAndBroadcastWithDerivedWallet + }); + + await expect(service.executeDerivedDecodedTxByUserId("user-123", messages)).rejects.toMatchObject({ status: 403 }); + expect(signAndBroadcastWithDerivedWallet).not.toHaveBeenCalled(); + }); }); describe("executeDerivedEncodedTxByUserId", () => { @@ -572,6 +596,7 @@ describe(ManagedSignerService.name, () => { enabledFeatures?: FeatureFlagValue[]; validateLeaseProviders?: TrialValidationService["validateLeaseProviders"]; validateLeaseProvidersAuditors?: TrialValidationService["validateLeaseProvidersAuditors"]; + validateLeaseBidPrices?: LeaseBidPriceGuardService["validateLeaseBidPrices"]; signAndBroadcastWithDerivedWallet?: TxManagerService["signAndBroadcastWithDerivedWallet"]; signAndBroadcastWithFundingWallet?: TxManagerService["signAndBroadcastWithFundingWallet"]; refreshUserWalletLimits?: BalancesService["refreshUserWalletLimits"]; @@ -605,6 +630,9 @@ describe(ManagedSignerService.name, () => { anonymousValidateService: mock({ validateLeaseProvidersAuditors: input?.validateLeaseProvidersAuditors ?? jest.fn() }), + leaseBidPriceGuardService: mock({ + validateLeaseBidPrices: input?.validateLeaseBidPrices ?? jest.fn() + }), txManagerService: mock({ signAndBroadcastWithDerivedWallet: input?.signAndBroadcastWithDerivedWallet ?? jest.fn(), signAndBroadcastWithFundingWallet: input?.signAndBroadcastWithFundingWallet ?? jest.fn(), @@ -646,6 +674,7 @@ describe(ManagedSignerService.name, () => { mocks.authService, mocks.chainErrorService, mocks.anonymousValidateService, + mocks.leaseBidPriceGuardService, mocks.txManagerService, mocks.domainEvents, mocks.leaseHttpService, diff --git a/apps/api/src/billing/services/managed-signer/managed-signer.service.ts b/apps/api/src/billing/services/managed-signer/managed-signer.service.ts index 80bb22b528..a5cf7f91a1 100644 --- a/apps/api/src/billing/services/managed-signer/managed-signer.service.ts +++ b/apps/api/src/billing/services/managed-signer/managed-signer.service.ts @@ -16,6 +16,7 @@ import { EnableDeploymentAlertCommand } from "@src/billing/commands/enable-deplo import { TrialDeploymentLeaseCreated } from "@src/billing/events/trial-deployment-lease-created"; import { InjectTypeRegistry } from "@src/billing/providers/type-registry.provider"; import { type UserWalletOutput, UserWalletRepository } from "@src/billing/repositories"; +import { LeaseBidPriceGuardService } from "@src/billing/services/lease-bid-price-guard/lease-bid-price-guard.service"; import { ManagedUserWalletService } from "@src/billing/services/managed-user-wallet/managed-user-wallet.service"; import { TxManagerService } from "@src/billing/services/tx-manager/tx-manager.service"; import { WalletReloadJobService } from "@src/billing/services/wallet-reload-job/wallet-reload-job.service"; @@ -43,6 +44,7 @@ export class ManagedSignerService { private readonly authService: AuthService, private readonly chainErrorService: ChainErrorService, private readonly anonymousValidateService: TrialValidationService, + private readonly leaseBidPriceGuardService: LeaseBidPriceGuardService, private readonly txManagerService: TxManagerService, private readonly domainEvents: DomainEventsService, private readonly leaseHttpService: LeaseHttpService, @@ -111,7 +113,8 @@ export class ManagedSignerService { await Promise.all([ this.anonymousValidateService.validateLeaseProvidersAuditors(messages, userWallet), this.anonymousValidateService.validateDeploymentGpuModels(messages, userWallet), - this.anonymousValidateService.validateLeaseGpuModels(messages, userWallet) + this.anonymousValidateService.validateLeaseGpuModels(messages, userWallet), + this.leaseBidPriceGuardService.validateLeaseBidPrices(messages, userWallet) ]); const createLeaseMessage: { typeUrl: string; value: MsgCreateLease } | undefined = messages.find(message => message.typeUrl.endsWith(".MsgCreateLease")); diff --git a/apps/api/src/billing/services/trial-validation/trial-validation.service.spec.ts b/apps/api/src/billing/services/trial-validation/trial-validation.service.spec.ts index 40d3071635..4fe0455332 100644 --- a/apps/api/src/billing/services/trial-validation/trial-validation.service.spec.ts +++ b/apps/api/src/billing/services/trial-validation/trial-validation.service.spec.ts @@ -175,7 +175,7 @@ describe(TrialValidationService.name, () => { model: string, ids: { dseq: string; gseq: number; oseq: number; bseq: number; provider: string } ): ReturnType { - const bid = createBid({ dseq: ids.dseq, gseq: ids.gseq, oseq: ids.oseq, bseq: ids.bseq, provider: ids.provider }); + const bid = createBid({ owner: "akash1owner", dseq: ids.dseq, gseq: ids.gseq, oseq: ids.oseq, bseq: ids.bseq, provider: ids.provider }); bid.bid.resources_offer[0].resources.gpu = { units: { val: "1" }, attributes: [{ key: `vendor/${vendor}/model/${model}`, value: "true" }] diff --git a/apps/api/src/billing/services/trial-validation/trial-validation.service.ts b/apps/api/src/billing/services/trial-validation/trial-validation.service.ts index ca347a6079..589f9808c0 100644 --- a/apps/api/src/billing/services/trial-validation/trial-validation.service.ts +++ b/apps/api/src/billing/services/trial-validation/trial-validation.service.ts @@ -1,11 +1,11 @@ import { GroupSpec, MsgCreateDeployment } from "@akashnetwork/chain-sdk/private-types/akash.v1beta4"; -import { MsgCreateLease } from "@akashnetwork/chain-sdk/private-types/akash.v1beta5"; import { BidHttpService } from "@akashnetwork/http-sdk"; import { Trace } from "@akashnetwork/instrumentation"; import { EncodeObject } from "@cosmjs/proto-signing"; import assert from "http-assert"; import { singleton } from "tsyringe"; +import { getLeaseBidIds, resolveLeaseBids } from "@src/billing/lib/lease-messages/lease-messages"; import type { UserWalletOutput } from "@src/billing/repositories"; import { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; import { AUDITOR, TRIAL_ATTRIBUTE, TRIAL_REGISTERED_ATTRIBUTE } from "@src/deployment/config/provider.config"; @@ -52,7 +52,7 @@ export class TrialValidationService { return; } - const bidIds = this.getLeaseBidIds(messages); + const bidIds = getLeaseBidIds(messages); if (bidIds.length === 0) return; const uniqueProviderAddresses = Array.from(new Set(bidIds.map(id => id.provider))); @@ -105,44 +105,16 @@ export class TrialValidationService { if (!userWallet.isTrialing) return; if (!this.blockedGpuService.hasBlockedModels()) return; - const leaseBidIds = this.getLeaseBidIds(messages); - if (leaseBidIds.length === 0) return; + const resolvedBids = await resolveLeaseBids(messages, userWallet.address!, this.bidHttpService); - const uniqueDseqs = Array.from(new Set(leaseBidIds.map(id => id.dseq.toString()))); - const owner = userWallet.address!; - - const bidsByDseq = new Map>>(); - await Promise.all( - uniqueDseqs.map(async dseq => { - bidsByDseq.set(dseq, await this.bidHttpService.list(owner, dseq)); - }) - ); - - for (const bidId of leaseBidIds) { - const bids = bidsByDseq.get(bidId.dseq.toString()) ?? []; - const bid = bids.find( - b => b.bid.id.gseq === bidId.gseq && b.bid.id.oseq === bidId.oseq && b.bid.id.provider === bidId.provider && b.bid.id.bseq === bidId.bseq - ); - assert( - bid, - 403, - `Referenced lease bid not found: dseq=${bidId.dseq}, gseq=${bidId.gseq}, oseq=${bidId.oseq}, provider=${bidId.provider}, bseq=${bidId.bseq}` - ); - - const blocked = this.blockedGpuService.findInBid(bid); + for (const { accepted } of resolvedBids) { + const blocked = this.blockedGpuService.findInBid(accepted); if (blocked.length === 0) continue; assert(false, 402, `${this.blockedGpuService.formatList(blocked)} not available on free trial: Add funds to unlock GPU access`); } } - private getLeaseBidIds(messages: EncodeObject[]): NonNullable[] { - return messages - .filter(message => message.typeUrl === `/${MsgCreateLease.$type}`) - .map(message => (message.value as MsgCreateLease).bidId) - .filter((id): id is NonNullable => !!id); - } - private validateAttribute(groups: GroupSpec[], key: string) { groups.forEach(group => { const hasAttribute = group.requirements?.attributes.some(attribute => {