diff --git a/src/components/run-job/payment-authorize.tsx b/src/components/run-job/payment-authorize.tsx deleted file mode 100644 index d501790d..00000000 --- a/src/components/run-job/payment-authorize.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import AuthorizationForm from '@/components/escrow/authorization-form'; -import Button from '@/components/button/button'; -import { SelectedToken } from '@/context/run-job-context'; -import { useAuthorizeTokens } from '@/lib/use-authorize-tokens'; -import { ComputeEnvironment } from '@/types/environments'; -import { roundTokenAmount } from '@/utils/formatters'; - -type PaymentAuthorizeProps = { - currentLockedAmount: number; - loadingPaymentInfo: boolean; - loadPaymentInfo: () => void; - minLockSeconds: number; - renderBackButton?: (disabled: boolean) => React.ReactNode; - selectedEnv: ComputeEnvironment; - selectedToken: SelectedToken; - totalCost: number; -}; - -const PaymentAuthorize = ({ - currentLockedAmount, - loadingPaymentInfo, - loadPaymentInfo, - minLockSeconds, - renderBackButton, - selectedEnv, - selectedToken, - totalCost, -}: PaymentAuthorizeProps) => { - const { handleAuthorize, isAuthorizing } = useAuthorizeTokens({ onSuccess: loadPaymentInfo }); - - return ( - - handleAuthorize({ - tokenAddress: selectedToken.address, - peerId: selectedEnv.nodeId, - spender: selectedEnv.consumerAddress, - maxLockedAmount: values.maxLockedAmount.toString(), - maxLockSeconds: values.maxLockSeconds.toString(), - maxLockCount: values.maxLockCount.toString(), - }) - } - renderSecondaryAction={renderBackButton} - renderSubmitButton={({ disabled, loading }) => ( - - )} - tokenSymbol={selectedToken.symbol} - /> - ); -}; - -export default PaymentAuthorize; diff --git a/src/components/run-job/payment-deposit.module.css b/src/components/run-job/payment-deposit.module.css deleted file mode 100644 index a8bfd788..00000000 --- a/src/components/run-job/payment-deposit.module.css +++ /dev/null @@ -1,5 +0,0 @@ -.root { - display: flex; - flex-direction: column; - gap: 32px; -} diff --git a/src/components/run-job/payment-deposit.tsx b/src/components/run-job/payment-deposit.tsx deleted file mode 100644 index a489222c..00000000 --- a/src/components/run-job/payment-deposit.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import Button from '@/components/button/button'; -import Input from '@/components/input/input'; -import { SelectedToken } from '@/context/run-job-context'; -import { useDepositTokens } from '@/lib/use-deposit-tokens'; -import { roundTokenAmount } from '@/utils/formatters'; -import { useFormik } from 'formik'; -import * as Yup from 'yup'; -import styles from './payment-deposit.module.css'; - -type DepositFormValues = { - amount: number; -}; - -type PaymentDepositProps = { - // currentLockedAmount: number; - escrowBalance: number; - loadingPaymentInfo: boolean; - loadPaymentInfo: () => void; - renderBackButton?: (disabled: boolean) => React.ReactNode; - selectedToken: SelectedToken; - totalCost: number; - walletBalance: number; -}; - -const PaymentDeposit = ({ - // currentLockedAmount, - escrowBalance, - loadingPaymentInfo, - loadPaymentInfo, - renderBackButton, - selectedToken, - totalCost, - walletBalance, -}: PaymentDepositProps) => { - const { handleDeposit, isDepositing } = useDepositTokens({ onSuccess: loadPaymentInfo }); - - const amountToDeposit = roundTokenAmount(Math.max(0, totalCost - escrowBalance), selectedToken.address, 'up'); - const hasSufficientFunds = escrowBalance >= totalCost; - - const formik = useFormik({ - enableReinitialize: true, - initialValues: { - amount: amountToDeposit, - }, - onSubmit: async (values) => { - handleDeposit({ - tokenAddress: selectedToken.address, - amount: values.amount.toString(), - }); - }, - validateOnMount: true, - validationSchema: Yup.object({ - amount: Yup.number().required('Required').min(0, 'Invalid amount'), - }), - }); - - return ( -
- -
- {renderBackButton?.(loadingPaymentInfo)} - {hasSufficientFunds ? ( - - ) : ( - - )} -
-
- ); -}; - -export default PaymentDeposit; diff --git a/src/components/run-job/payment-page.tsx b/src/components/run-job/payment-page.tsx index df4ef05c..808da5b6 100644 --- a/src/components/run-job/payment-page.tsx +++ b/src/components/run-job/payment-page.tsx @@ -54,7 +54,6 @@ const PaymentPage = () => { void; totalCost: number; }; +const MAX_LOCK_COUNT = 10; + const Payment = ({ minLockSeconds, selectedEnv, - selectedResources, selectedToken, setPageSubtitle, totalCost, @@ -41,38 +40,9 @@ const Payment = ({ const currentLockedAmount = Number(authorizations?.currentLockedAmount ?? 0); - const step: 'topup' | 'deposit' | 'authorize' = useMemo(() => { - // TODO re-enable topup page - // if ( - // (walletBalance ?? 0) + (escrowBalance ?? 0) < totalCost && - // selectedToken.address === getSupportedTokens().USDC - // ) { - // Only USDC can be topped up with fiat - // return 'topup'; - // } - if ((escrowBalance ?? 0) < totalCost) { - return 'deposit'; - } - return 'authorize'; - }, [escrowBalance, totalCost]); - useEffect(() => { - switch (step) { - // TODO re-enable topup page - // case 'topup': { - // setPageSubtitle('You need to top up in order to start your job'); - // break; - // } - case 'deposit': { - setPageSubtitle('You need to deposit funds in escrow in order to strart your job'); - break; - } - case 'authorize': { - setPageSubtitle('Confirm and authorize your payment in order to start your job'); - break; - } - } - }, [setPageSubtitle, step]); + setPageSubtitle('Confirm and authorize your payment in order to start your job'); + }, [setPageSubtitle]); const loadPaymentInfo = useCallback(async () => { if (ocean && account?.address) { @@ -95,6 +65,7 @@ const Payment = ({ loadPaymentInfo(); }, [loadPaymentInfo]); + // Once escrow + authorization satisfy the session requirements, move on to the summary. useEffect(() => { const sufficientEscrow = (escrowBalance ?? 0) >= totalCost; const suffficientAuthorized = @@ -119,74 +90,48 @@ const Payment = ({ escrowBalance, minLockSeconds, router, - selectedResources.maxJobDurationSeconds, selectedToken.address, selectedToken.symbol, totalCost, ]); - const renderBackButton = (disabled: boolean) => ( - + const { handlePay, isPaying } = usePaySession({ onSuccess: loadPaymentInfo }); + + const depositAmount = roundTokenAmount( + Math.max(0, totalCost - (escrowBalance ?? 0)), + selectedToken.address, + 'up' ); + const maxLockedAmount = roundTokenAmount(totalCost + currentLockedAmount, selectedToken.address, 'up'); + const maxLockSeconds = minLockSeconds < 1 ? 1 : Math.ceil(minLockSeconds); + // Escrow's authorize SETS (not increments) the lock cap. Derive above the current locks so a user + // who has already used all their slots can still raise the limit and start a new session. + const maxLockCount = Math.max(MAX_LOCK_COUNT, Number(authorizations?.currentLocks ?? 0) + 1); - const renderStep = () => { - switch (step) { - // TODO re-enable topup page - // case 'topup': { - // return ( - // - // ); - // } - case 'deposit': { - return ( - - ); - } - case 'authorize': { - return ( - - ); - } - default: - return null; - } - }; + const insufficientWalletFunds = (walletBalance ?? 0) < depositAmount; + + const handleSubmit = useCallback( + () => + handlePay({ + tokenAddress: selectedToken.address, + peerId: selectedEnv.nodeId, + spender: selectedEnv.consumerAddress, + depositAmount: depositAmount.toString(), + maxLockedAmount: maxLockedAmount.toString(), + maxLockSeconds: maxLockSeconds.toString(), + maxLockCount: maxLockCount.toString(), + }), + [ + handlePay, + selectedToken.address, + selectedEnv.nodeId, + selectedEnv.consumerAddress, + depositAmount, + maxLockedAmount, + maxLockSeconds, + maxLockCount, + ] + ); return loadingPaymentInfo && (escrowBalance === null || walletBalance === null) ? ( @@ -201,7 +146,28 @@ const Payment = ({ totalCost={totalCost} walletBalance={walletBalance ?? 0} /> - {renderStep()} +
+ + +
); }; diff --git a/src/components/run-job/select-resources.module.css b/src/components/run-job/select-resources.module.css index db931fd6..7c5a076b 100644 --- a/src/components/run-job/select-resources.module.css +++ b/src/components/run-job/select-resources.module.css @@ -40,6 +40,128 @@ } } +.gpuHeader { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: space-between; +} + +.gpuHeaderEnd { + align-items: center; + display: flex; + gap: 12px; +} + +.gpuHint { + color: var(--text-secondary); + font-size: 14px; +} + +.gpuGroups { + display: flex; + flex-direction: column; + gap: 16px; +} + +.gpuGroupsLabel { + color: var(--text-primary); + font-size: 16px; +} + +.gpuGroup { + display: flex; + flex-direction: column; + gap: 8px; +} + +.gpuGroupTop { + align-items: baseline; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.gpuGroupName { + color: var(--text-primary); + font-size: 15px; +} + +.gpuGroupFee, +.gpuGroupMeta { + color: var(--text-secondary); + font-size: 13px; +} + +.pills { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.pill { + background: var(--background-glass); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-primary); + cursor: pointer; + font-size: 14px; + min-width: 40px; + padding: 6px 14px; + transition: + border-color 0.2s ease, + background 0.2s ease, + color 0.2s ease; +} + +.pill:hover { + border-color: var(--accent1); +} + +.pillSelected { + background: var(--accent1); + border-color: var(--accent1); + color: var(--text-primary-inverse); +} + +.gpuError { + color: var(--error); + font-size: 14px; +} + +.wholeEnvNote { + color: var(--text-secondary); + font-size: 14px; +} + +.derivedGrid { + display: grid; + gap: 16px; + grid-template-columns: 1fr; + + @media (min-width: 768px) { + grid-template-columns: repeat(3, 1fr); + } +} + +.derivedCard { + .derivedLabel { + color: var(--text-secondary); + font-size: 14px; + } + + .derivedValue { + font-size: 24px; + font-weight: 700; + } + + .derivedHint { + color: var(--text-secondary); + font-size: 13px; + } +} + .costCard { .costEstimation { align-items: center; diff --git a/src/components/run-job/select-resources.tsx b/src/components/run-job/select-resources.tsx index da18b172..bf6e3479 100644 --- a/src/components/run-job/select-resources.tsx +++ b/src/components/run-job/select-resources.tsx @@ -1,11 +1,7 @@ import Button from '@/components/button/button'; import Card from '@/components/card/card'; -import GpuLabel from '@/components/gpu-label/gpu-label'; import useEnvResources from '@/components/hooks/use-env-resources'; import DurationInput from '@/components/input/duration-input'; -import Input from '@/components/input/input'; -import Select from '@/components/input/select'; -import Slider from '@/components/slider/slider'; import config from '@/config'; import { SelectedToken, useRunJobContext } from '@/context/run-job-context'; import { useP2P } from '@/contexts/P2PContext'; @@ -13,6 +9,7 @@ import { useOceanAccount } from '@/lib/use-ocean-account'; import { ComputeEnvironment } from '@/types/environments'; import { DURATION_UNIT_OPTIONS } from '@/utils/duration'; import { formatDuration, formatTokenAmount, roundTokenAmount } from '@/utils/formatters'; +import { capacityOf } from '@/utils/resources'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { CircularProgress, Collapse, Tooltip } from '@mui/material'; import { usePrivy } from '@privy-io/react-auth'; @@ -31,13 +28,12 @@ type SelectResourcesProps = { }; type ResourcesFormValues = { - cpuCores: number; - diskSpace: number | ''; gpus: string[]; maxJobDurationSeconds: number; - ram: number; }; +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + const SelectResources = ({ environment, freeCompute, token }: SelectResourcesProps) => { const { login } = usePrivy(); const router = useRouter(); @@ -109,41 +105,58 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro tokenAddress: token?.address ?? '', }); - const minAllowedCpuCores = cpu?.min ?? 1; - const minAllowedDiskSpace = disk?.min ?? 0; - const minAllowedJobDurationSeconds = minJobDurationSeconds ?? 0; - const minAllowedRam = ram?.min ?? 0; - - const maxAllowedCpuCores = cpu ? cpuAvailable : minAllowedCpuCores; - const maxAllowedDiskSpace = disk ? diskAvailable : minAllowedDiskSpace; - const maxAllowedJobDurationSeconds = maxJobDurationSeconds ?? 0; - const maxAllowedRam = ram ? ramAvailable : minAllowedRam; - - const cpuExhausted = !!cpu && cpuAvailable < minAllowedCpuCores; - const ramExhausted = !!ram && ramAvailable < minAllowedRam; - const diskExhausted = !!disk && diskAvailable < minAllowedDiskSpace; + // The environment is split into equal GPU-sized parts: each selected GPU grants one proportional + // share of CPU/RAM/disk. Nodes expose one resource entry per physical GPU (each total: 1), so the + // number of GPU units equals the number of selected GPU entries. + const hasGpu = gpus.length > 0; + + // Total physical GPU slots across all GPU resource entries the node advertises. + // No-GPU environments behave as a single, whole-environment unit. + const totalUnits = hasGpu + ? Math.max( + 1, + gpus.reduce((total, gpu) => total + capacityOf(gpu), 0) + ) + : 1; + // Every advertised GPU is currently in use elsewhere — nothing left to pick. + const gpuExhausted = hasGpu && gpus.every((gpu) => (gpusAvailable[gpu.id] ?? 0) <= 0); + + // Physical GPUs that share a description are the same model. Group them so the user picks a count + // per model instead of ticking individual cards. availableIds keeps declared order, so a chosen + // count maps deterministically to concrete resource ids. + const gpuGroups = useMemo(() => { + const byDescription = new Map(); + for (const gpu of gpus) { + const description = gpu.description ?? gpu.id; + let group = byDescription.get(description); + if (!group) { + group = { description, fee: gpuFees[gpu.id], availableIds: [], total: 0 }; + byDescription.set(description, group); + } + group.total += 1; + if ((gpusAvailable[gpu.id] ?? 0) > 0) { + group.availableIds.push(gpu.id); + } + } + return [...byDescription.values()]; + }, [gpus, gpusAvailable, gpuFees]); - const cpuSliderMax = Math.max(minAllowedCpuCores, cpuAvailable); - const ramSliderMax = Math.max(minAllowedRam, ramAvailable); + // Per-unit (per-GPU) share of each resource, derived from full env capacity / total units. + const perUnitCpu = capacityOf(cpu) / totalUnits; + const perUnitRam = capacityOf(ram) / totalUnits; + const perUnitDisk = capacityOf(disk) / totalUnits; - const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + const minAllowedJobDurationSeconds = minJobDurationSeconds ?? 0; + const maxAllowedJobDurationSeconds = maxJobDurationSeconds ?? 0; - const selectedCpu = selectedResources?.cpuCores; - const selectedDisk = selectedResources?.diskSpace; - const selectedRam = selectedResources?.ram; - const selectedGpus = selectedResources?.gpus.map((gpu) => gpu.id); + const selectedGpuIds = selectedResources?.gpus?.map((g) => g.id); const selectedMaxJobDurationSeconds = selectedResources?.maxJobDurationSeconds; const formik = useFormik({ enableReinitialize: true, initialValues: { - // Clamp prior/hydrated selections into what's currently available so a stale link - // can't pre-fill an unavailable amount. - cpuCores: clamp(selectedCpu ?? minAllowedCpuCores, minAllowedCpuCores, cpuSliderMax), - diskSpace: clamp(selectedDisk ?? minAllowedDiskSpace, minAllowedDiskSpace, maxAllowedDiskSpace), - gpus: (selectedGpus ?? []).filter((id) => (gpusAvailable[id] ?? 0) > 0), + gpus: (selectedGpuIds ?? []).filter((id) => (gpusAvailable[id] ?? 0) > 0), maxJobDurationSeconds: selectedMaxJobDurationSeconds ?? minAllowedJobDurationSeconds, - ram: clamp(selectedRam ?? minAllowedRam, minAllowedRam, ramSliderMax), }, onSubmit: (values) => { if (!account?.isConnected) { @@ -157,32 +170,34 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro estimatedTotalCost && token?.address ? roundTokenAmount(estimatedTotalCost, token.address, 'up') : 0 ); setSelectedResources({ - cpuCores: values.cpuCores, + cpuCores: derivedCpu, cpuId: cpu?.id ?? 'cpu', - diskSpace: Number(values.diskSpace) || 0, + diskSpace: derivedDisk, diskId: disk?.id ?? 'disk', - gpus: gpus - .filter((gpu) => values.gpus.includes(gpu.id)) - .map((gpu) => ({ id: gpu.id, description: gpu.description })), + gpus: selectedGpuEntries, + gpuCount: selectedGpuEntries.length, maxJobDurationSeconds: values.maxJobDurationSeconds, - ram: values.ram, + ram: derivedRam, ramId: ram?.id ?? 'ram', }); posthog.capture('environment_configured', { - cpuCores: values.cpuCores, - ram: values.ram, - diskSpace: Number(values.diskSpace) || 0, - gpus: values.gpus, + cpuCores: derivedCpu, + ram: derivedRam, + diskSpace: derivedDisk, + gpuCount: selectedGpuEntries.length, maxJobDurationSeconds: values.maxJobDurationSeconds, estimatedTotalCost, freeCompute, }); const query = { ...router.query, - cpu: values.cpuCores, - ram: values.ram, - disk: values.diskSpace, - ...(values.gpus.length > 0 && { gpus: values.gpus }), + cpu: derivedCpu, + ram: derivedRam, + disk: derivedDisk, + ...(values.gpus.length > 0 && { + gpus: values.gpus.map((id) => `${id}:1`), + gpuCount: values.gpus.length, + }), maxJobDuration: values.maxJobDurationSeconds, }; @@ -194,17 +209,9 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro }, validateOnMount: true, validationSchema: Yup.object({ - cpuCores: Yup.number() - .required('Required') - .min(minAllowedCpuCores, 'Limits exceeded') - .max(maxAllowedCpuCores, cpuExhausted ? 'Not enough available' : 'Limits exceeded') - .integer('Invalid format'), - diskSpace: Yup.number() - .required('Required') - .min(minAllowedDiskSpace, 'Limits exceeded') - .max(maxAllowedDiskSpace, diskExhausted ? 'Not enough available' : 'Limits exceeded'), gpus: Yup.array() .of(Yup.string()) + // GPUs are optional — a job with none gets the minimum CPU/RAM/disk slice. .test('gpus-available', 'One or more selected GPUs are no longer available', (value) => (value ?? []).every((id) => (id ? (gpusAvailable[id] ?? 0) > 0 : true)) ), @@ -212,23 +219,44 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro .required('Required') .min(minAllowedJobDurationSeconds, 'Limits exceeded') .max(maxAllowedJobDurationSeconds, 'Limits exceeded'), - ram: Yup.number() - .required('Required') - .min(minAllowedRam, 'Limits exceeded') - .max(maxAllowedRam, ramExhausted ? 'Not enough available' : 'Limits exceeded'), }), }); - const resources = useMemo( - () => [ - { id: cpu?.id ?? 'cpu', amount: formik.values.cpuCores }, - { id: disk?.id ?? 'disk', amount: Number(formik.values.diskSpace) || 0 }, - { id: ram?.id ?? 'ram', amount: formik.values.ram }, - ...formik.values.gpus.map((gpuId) => ({ id: gpuId, amount: 1 })), - ], - [cpu?.id, disk?.id, ram?.id, formik.values.cpuCores, formik.values.diskSpace, formik.values.ram, formik.values.gpus] + // Each selected GPU is one unit; no-GPU envs run as a single whole-environment unit. + const unitCount = hasGpu ? formik.values.gpus.length : 1; + + // The GPU resource entries the user picked, one unit each. + const selectedGpuEntries = useMemo( + () => + gpus + .filter((gpu) => formik.values.gpus.includes(gpu.id)) + .map((gpu) => ({ id: gpu.id, description: gpu.description, amount: 1 })), + [gpus, formik.values.gpus] ); + // Derived resource amounts for the chosen number of units, clamped to what's actually available. + const derivedCpu = clamp(Math.round(perUnitCpu * unitCount), cpu?.min ?? 0, Math.max(0, cpuAvailable)); + const derivedRam = clamp(Math.round(perUnitRam * unitCount), ram?.min ?? 0, Math.max(0, ramAvailable)); + const derivedDisk = clamp(Math.round(perUnitDisk * unitCount), disk?.min ?? 0, Math.max(0, diskAvailable)); + + const resourcesExhausted = + gpuExhausted || + (!!cpu && cpuAvailable < (cpu.min ?? 0)) || + (!!ram && ramAvailable < (ram.min ?? 0)) || + (!!disk && diskAvailable < (disk.min ?? 0)); + + const resources = useMemo(() => { + const list = [ + { id: cpu?.id ?? 'cpu', amount: derivedCpu }, + { id: disk?.id ?? 'disk', amount: derivedDisk }, + { id: ram?.id ?? 'ram', amount: derivedRam }, + ]; + for (const gpu of selectedGpuEntries) { + list.push({ id: gpu.id, amount: gpu.amount }); + } + return list; + }, [cpu?.id, disk?.id, ram?.id, selectedGpuEntries, derivedCpu, derivedRam, derivedDisk]); + const estimateCost = useCallback(async () => { setIsLoadingCost(true); setInitComputeError(null); @@ -260,6 +288,10 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro }; }, [estimateCost]); + const setMaxJobDuration = () => { + formik.setFieldValue('maxJobDurationSeconds', maxAllowedJobDurationSeconds); + }; + const selectAllGpus = () => { formik.setFieldValue( 'gpus', @@ -267,22 +299,29 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro ); }; - const setMaxDiskSpace = () => { - formik.setFieldValue('diskSpace', maxAllowedDiskSpace); - }; - - const setMaxJobDuration = () => { - formik.setFieldValue('maxJobDurationSeconds', maxAllowedJobDurationSeconds); + // Replace one model group's selection with its first `count` available cards, leaving the + // selections of other groups untouched. + const setGroupCount = (availableIds: string[], count: number) => { + const others = formik.values.gpus.filter((id) => !availableIds.includes(id)); + formik.setFieldValue('gpus', [...others, ...availableIds.slice(0, count)]); }; - const handleDiskSpaceChange = (e: React.ChangeEvent) => { - if (e.target.value === '') { - formik.setFieldValue('diskSpace', ''); - return; - } - const num = Number(e.target.value); - formik.setFieldValue('diskSpace', Math.max(0, num)); - }; + const renderDerivedResource = (label: React.ReactNode, value: string, fee: React.ReactNode) => ( + +
{label}
+
{value}
+
{fee}
+
+ ); const renderCostCard = () => { const renderCostEstimation = () => { @@ -368,116 +407,113 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro

Select resources

- - Set max - - } - errorText={formik.touched.diskSpace && formik.errors.diskSpace ? formik.errors.diskSpace : undefined} - hint={freeCompute ? 'Free' : `${diskFee ?? 0} ${token?.symbol}/GB`} - label={ -
- Disk space{' '} - - - -
- } - max={maxAllowedDiskSpace} - min={0} - name="diskSpace" - onBlur={formik.handleBlur} - onChange={handleDiskSpaceChange} - startAdornment="GB" - topRight={`${minAllowedDiskSpace} - ${maxAllowedDiskSpace} available`} - type="number" - value={formik.values.diskSpace} - /> - formik.setFieldValue('maxJobDurationSeconds', seconds)} - onSetMax={setMaxJobDuration} - topRight={`${formatDuration(minAllowedJobDurationSeconds, true)} - ${formatDuration(maxAllowedJobDurationSeconds, true)}`} - value={formik.values.maxJobDurationSeconds} - /> - + {freeCompute ? null : ( {initComputeError ? {renderConnectionErrorCard()} : null} - {!initComputeError && formik.isValid ? {renderCostCard()} : null} + {!initComputeError && formik.isValid && !resourcesExhausted ? ( + {renderCostCard()} + ) : null} )}
@@ -490,7 +526,7 @@ const SelectResources = ({ environment, freeCompute, token }: SelectResourcesPro > Change environment -
diff --git a/src/components/run-job/summary.tsx b/src/components/run-job/summary.tsx index 1db84578..88004936 100644 --- a/src/components/run-job/summary.tsx +++ b/src/components/run-job/summary.tsx @@ -113,11 +113,14 @@ const Summary = ({ } const peerMultiaddr = await getPeerMultiaddr(nodeInfo.id); const resources: IdeResourceRequest[] = [ - ...gpus.map((availableGpu) => ({ - id: availableGpu.id, - amount: selectedResources.gpus.find((selectedGpu) => selectedGpu.id === availableGpu.id) ? 1 : 0, - description: availableGpu.description, - })), + ...gpus.map((availableGpu) => { + const selected = selectedResources.gpus.find((selectedGpu) => selectedGpu.id === availableGpu.id); + return { + id: availableGpu.id, + amount: selected?.amount ?? 0, + description: availableGpu.description, + }; + }), ]; if (selectedResources.cpuId && selectedResources.cpuCores) { resources.push({ @@ -156,6 +159,7 @@ const Summary = ({ environmentId: selectedEnv.id, freeCompute: isFreeCompute, }); + router.push('/profile/consumer'); }; const handleOpenIdeMenu = () => { @@ -207,7 +211,13 @@ const Summary = ({
GPU:
{selectedResources.gpus.length - ? selectedResources.gpus.map((gpu) => ) + ? Object.entries( + selectedResources.gpus.reduce>((acc, gpu) => { + const key = gpu.description ?? gpu.id; + acc[key] = (acc[key] ?? 0) + gpu.amount; + return acc; + }, {}) + ).map(([desc, count]) => ) : '-'}
CPU cores:
diff --git a/src/context/run-job-context.tsx b/src/context/run-job-context.tsx index 8653bb0a..3827fe62 100644 --- a/src/context/run-job-context.tsx +++ b/src/context/run-job-context.tsx @@ -6,12 +6,15 @@ import { getTokenDecimals, getTokenSymbol } from '@/lib/token-symbol'; import { useOceanAccount } from '@/lib/use-ocean-account'; import { ComputeEnvironment, + ComputeResource, EnvNodeInfo, EnvResourcesSelection, MultiaddrsOrPeerId, NodeEnvironments, + SelectedGpu, } from '@/types/environments'; import { roundTokenAmount } from '@/utils/formatters'; +import { getAvailableAmount } from '@/utils/resources'; import axios from 'axios'; import BigNumber from 'bignumber.js'; import { useSearchParams } from 'next/navigation'; @@ -22,6 +25,12 @@ export type SelectedToken = { address: string; }; +// Clamp a requested GPU amount to [0, available] so a hand-crafted URL can't over-allocate. +const clampToAvailable = (requested: number, gpuRes?: ComputeResource): number => { + const sane = Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : 0; + return Math.min(sane, getAvailableAmount(gpuRes)); +}; + type RunJobContextType = { estimatedTotalCost: number | null; fetchEstimatedCost: ({ @@ -296,11 +305,25 @@ export const RunJobProvider = ({ children }: { children: ReactNode }) => { const qJobDuration = searchParams.get('maxJobDuration'); const queryGpusArray = searchParams.getAll('gpus[]'); const queryGpus = queryGpusArray.length > 0 ? queryGpusArray : searchParams.getAll('gpus'); + const gpuResources = (foundEnv.resources ?? []).filter((res) => res.type === 'gpu' || res.id === 'gpu'); + const findGpuRes = (id: string) => gpuResources.find((res) => res.id === id); + const requestedById = new Map(); + for (const raw of queryGpus) { + const [id, amountStr] = raw.split(':'); + const requested = Number(amountStr); + const sane = Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : 0; + requestedById.set(id, (requestedById.get(id) ?? 0) + sane); + } + const resolvedGpus: SelectedGpu[] = [...requestedById.entries()] + .map(([id, requested]) => { + const gpuRes = findGpuRes(id); + return { id, description: gpuRes?.description, amount: clampToAvailable(requested, gpuRes) }; + }) + .filter((gpu) => gpu.amount > 0); + const gpuCount = resolvedGpus.reduce((sum, gpu) => sum + gpu.amount, 0); let resources: EnvResourcesSelection = { - gpus: queryGpus.map((gpuId) => { - const gpuRes = foundEnv.resources?.find((res) => res.type === 'gpu' && res.id === gpuId); - return { id: gpuId, description: gpuRes?.description }; - }), + gpus: resolvedGpus, + gpuCount, maxJobDurationSeconds: qJobDuration ? Number(qJobDuration) : queryFree @@ -359,7 +382,7 @@ export const RunJobProvider = ({ children }: { children: ReactNode }) => { ...(resources.diskId && resources.diskSpace ? [{ id: resources.diskId, amount: resources.diskSpace }] : []), - ...resources.gpus.map((gpu) => ({ id: gpu.id, amount: 1 })), + ...resources.gpus.map((gpu) => ({ id: gpu.id, amount: gpu.amount })), ], tokenAddress: queryToken, }); diff --git a/src/lib/use-pay-session.ts b/src/lib/use-pay-session.ts new file mode 100644 index 00000000..fc12656c --- /dev/null +++ b/src/lib/use-pay-session.ts @@ -0,0 +1,171 @@ +import { CHAIN_ID } from '@/constants/chains'; +import { getTokenDecimals } from '@/lib/token-symbol'; +import { useOceanAccount } from '@/lib/use-ocean-account'; +import { useAlchemySendTransaction } from '@/lib/use-alchemy-client'; +import { formatWalletAddress } from '@/utils/formatters'; +import Address from '@oceanprotocol/contracts/addresses/address.json'; +import Escrow from '@oceanprotocol/contracts/artifacts/contracts/escrow/Escrow.sol/Escrow.json'; +import ERC20Template from '@oceanprotocol/contracts/artifacts/contracts/templates/ERC20Template.sol/ERC20Template.json'; +import BigNumber from 'bignumber.js'; +import posthog from 'posthog-js'; +import { useCallback, useState } from 'react'; +import { toast } from 'react-toastify'; +import { encodeFunctionData } from 'viem'; + +export interface PaySessionParams { + tokenAddress: string; + peerId?: string; + spender: string; + // Amount to deposit into escrow. Pass "0" (or omit) when escrow already holds enough. + depositAmount?: string; + maxLockedAmount: string; + maxLockSeconds: string; + maxLockCount: string; +} + +export interface UsePaySessionParams { + onSuccess?: () => void; +} + +export interface UsePaySessionReturn { + isPaying: boolean; + handlePay: (params: PaySessionParams) => Promise; + error?: string; +} + +/** + * Single-step payment: deposits funds into escrow (if needed) and authorizes the spender in one + * action. For smart accounts the approve + deposit + authorize calls are batched into a single + * user confirmation. For EOAs they run sequentially (no batching available until the contract + * exposes a bundle method). + */ +export const usePaySession = ({ onSuccess }: UsePaySessionParams = {}): UsePaySessionReturn => { + const { ocean, user } = useOceanAccount(); + const { sendTransaction } = useAlchemySendTransaction(); + + const [isPaying, setIsPaying] = useState(false); + const [error, setError] = useState(); + const chainId = CHAIN_ID; + + const handlePay = useCallback( + async ({ + tokenAddress, + peerId, + spender, + depositAmount, + maxLockedAmount, + maxLockSeconds, + maxLockCount, + }: PaySessionParams) => { + if (!tokenAddress || !spender) { + setError('Missing required parameters'); + toast.error('Missing required parameters'); + return; + } + + const needsDeposit = new BigNumber(depositAmount ?? 0).gt(0); + + setIsPaying(true); + setError(undefined); + try { + if (user?.type === 'eoa') { + // EOA path: no batching, run the deposit (approve + deposit) then the authorize sequentially. + if (!ocean) throw new Error('Wallet not ready'); + if (needsDeposit) { + const depositTx = await ocean.depositTokensEoa({ tokenAddress, amount: depositAmount! }); + await depositTx.wait(); + posthog.capture('payment_deposit', { tokenAddress, amount: depositAmount }); + } + const authorizeTx = await ocean.authorizeTokensEoa({ + tokenAddress, + spender, + maxLockedAmount, + maxLockSeconds, + maxLockCount, + }); + await authorizeTx.wait(); + posthog.capture('payment_authorize'); + } else { + // Smart account path: batch approve + deposit + authorize into a single confirmation. + const config = Object.values(Address).find((chainConfig: any) => chainConfig.chainId === chainId); + if (!config || !(config as any).Escrow) { + throw new Error('No escrow found for chainId'); + } + const escrowAddress = (config as any).Escrow as `0x${string}`; + + const tokenDecimals = await getTokenDecimals(tokenAddress); + const toUnits = (amount: string) => + new BigNumber(amount).multipliedBy(new BigNumber(10).pow(Number(tokenDecimals))).toFixed(0); + + // Calls run in order within a single atomic user-operation: approve must precede deposit so + // deposit observes the new allowance. The approve grants exactly the deposit amount (no + // approveMax), so USDT-style "reset to 0 first" tokens with a stale allowance would revert; + // the supported tokens (USDC/COMPY) don't enforce that. Until the Escrow exposes a bundle + // method, this batch is the single-confirmation equivalent of the old two-step flow. + const calls: { to: `0x${string}`; data: `0x${string}` }[] = []; + + if (needsDeposit) { + const normalizedDeposit = toUnits(depositAmount!); + calls.push({ + to: tokenAddress as `0x${string}`, + data: encodeFunctionData({ + abi: ERC20Template.abi, + functionName: 'approve', + args: [escrowAddress, BigInt(normalizedDeposit)], + }) as `0x${string}`, + }); + calls.push({ + to: escrowAddress, + data: encodeFunctionData({ + abi: Escrow.abi, + functionName: 'deposit', + args: [tokenAddress, BigInt(normalizedDeposit)], + }) as `0x${string}`, + }); + } + + calls.push({ + to: escrowAddress, + data: encodeFunctionData({ + abi: Escrow.abi, + functionName: 'authorize', + args: [ + tokenAddress, + spender, + BigInt(toUnits(maxLockedAmount)), + BigInt(maxLockSeconds), + BigInt(maxLockCount), + ], + }) as `0x${string}`, + }); + + await sendTransaction(calls); + + if (needsDeposit) { + posthog.capture('payment_deposit', { tokenAddress, amount: depositAmount }); + } + posthog.capture('payment_authorize'); + } + + toast.success(paySuccessMessage(spender, peerId)); + onSuccess?.(); + } catch (err) { + console.error('Pay session error:', err); + setError(err instanceof Error ? err.message : 'Payment failed'); + toast.error('Payment failed. See console for details.'); + } finally { + setIsPaying(false); + } + }, + [user?.type, ocean, sendTransaction, chainId, onSuccess] + ); + + return { isPaying, handlePay, error }; +}; + +const paySuccessMessage = (consumerAddress: string, peerId?: string) => { + const target = peerId + ? `node ${formatWalletAddress(peerId)} (consumer ${formatWalletAddress(consumerAddress)})` + : `consumer ${formatWalletAddress(consumerAddress)}`; + return `Payment authorized for ${target}. Your session can start shortly.`; +}; diff --git a/src/types/environments.ts b/src/types/environments.ts index 52e6e4b4..fa3a310a 100644 --- a/src/types/environments.ts +++ b/src/types/environments.ts @@ -74,12 +74,20 @@ export type ComputeEnvironment = { storageExpiry?: number; }; +export type SelectedGpu = { + id: string; + description?: string; + amount: number; +}; + export type EnvResourcesSelection = { cpuCores?: number; cpuId?: string; diskSpace?: number; diskId?: string; - gpus: { id: string; description?: string }[]; + gpus: SelectedGpu[]; + // Total GPU units selected across all GPU entries. Drives the proportional CPU/RAM/disk split. + gpuCount?: number; maxJobDurationSeconds: number; ram?: number; ramId?: string; diff --git a/src/utils/resources.ts b/src/utils/resources.ts index 85757398..fe50f493 100644 --- a/src/utils/resources.ts +++ b/src/utils/resources.ts @@ -11,3 +11,8 @@ export const getAvailableAmount = (resource?: Pick): number => { + const total = resource?.total ?? 0; + return total > 0 ? total : (resource?.max ?? 0); +};