Repository: tangle-network/dapp
Pull request #2955: feat(tangle-cloud): deploy blueprint assets configuration step
Author: login: danielbui12
Labels: name: needs review 👓
name: app: tangle-cloud ☁️
Created: 2025-04-04T15:56:56Z | Merged: 2025-04-07T14:33:33Z

## Summary of changes

_Provide a detailed description of proposed changes._

- Implement assets configuration step in Blueprint deployment

### Proposed area of change

_Put an `x` in the boxes that apply._

- [ ] `apps/tangle-dapp`
- [x] `apps/tangle-cloud`
- [ ] `apps/leaderboard`
- [x] `libs/tangle-shared-ui`
- [ ] `libs/ui-components`

### Associated issue(s)

_Specify any issues that can be closed from these changes (e.g. `Closes #233`)._

- Closes

### Screen Recording

_If possible provide screenshots and/or a screen recording of proposed change._

https://github.com/user-attachments/assets/b6f44abb-9126-46c0-92cc-ee2fa941f647



Linked issue #233: [TASK] Remove options with Edgeware and use regular options
https://github.com/webb-tools/webb-dapp/blob/5d05fa04b67f08af6007fda0be8c07eead764640/packages/wallet/src/providers/polkadot/polkadot-provider.ts#L6

Diff:
diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/AssetConfigurationStep.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/AssetConfigurationStep.tsx
new file mode 100644
index 000000000..1d8e46dd4
--- /dev/null
+++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/AssetConfigurationStep.tsx
@@ -0,0 +1,212 @@
+import { Label, Input } from '@tangle-network/ui-components';
+import { Children, FC, useCallback, useMemo } from 'react';
+import { AssetConfigurationStepProps } from './type';
+import {
+  BLUEPRINT_DEPLOY_STEPS,
+  DeployBlueprintSchema,
+} from '../../../../../utils/validations/deployBlueprint';
+import { InstructionSideCard } from './InstructionSideCard';
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@tangle-network/ui-components/components/select';
+import useAssetsMetadata from '@tangle-network/tangle-shared-ui/hooks/useAssetsMetadata';
+import assertRestakeAssetId from '@tangle-network/tangle-shared-ui/utils/assertRestakeAssetId';
+import { AssetRequirementFormItem } from './components/AssetRequirementFormItem';
+import ErrorMessage from '../../../../../components/ErrorMessage';
+import { OperatorTable } from './components/OperatorTable';
+import { useWatch } from 'react-hook-form';
+
+export const AssetConfigurationStep: FC<AssetConfigurationStepProps> = ({
+  errors: globalErrors,
+  setValue,
+  watch,
+  control,
+}) => {
+  const labelClassName = 'text-mono-200 dark:text-mono-0 font-normal';
+
+  const stepKey = BLUEPRINT_DEPLOY_STEPS.ASSET_CONFIGURATION;
+  const operatorsStepKey = BLUEPRINT_DEPLOY_STEPS.OPERATOR_SELECTION;
+  const values = watch(stepKey);
+
+  const errors = globalErrors?.[stepKey];
+
+  const approvalModel = values?.approvalModel;
+  const minApprovalThreshold = values?.minApproval?.toString();
+
+  const selectedAssets = useMemo(
+    () =>
+      (watch(`${operatorsStepKey}.assets`) ?? []).map((asset) => ({
+        ...asset,
+        id: assertRestakeAssetId(asset.id),
+      })),
+    [watch(`${operatorsStepKey}.assets`)],
+  );
+
+  const selectedOperators = useWatch({
+    control,
+    name: `${operatorsStepKey}.operators`,
+    defaultValue: [],
+  });
+
+  const { result: assetsMetadata } = useAssetsMetadata(
+    useMemo(() => selectedAssets.map(({ id }) => id), [selectedAssets]),
+  );
+
+  const onChangeApprovalModel = useCallback(
+    (value: DeployBlueprintSchema[typeof stepKey]['approvalModel']) => {
+      let changes = { ...values, approvalModel: value };
+      if (value === 'Dynamic') {
+        changes = {
+          ...changes,
+          maxApproval: selectedOperators.length,
+        };
+      } else {
+        changes = {
+          ...changes,
+          maxApproval: undefined,
+          minApproval: selectedOperators.length,
+        };
+      }
+
+      setValue(stepKey, changes);
+    },
+    [stepKey, values, setValue, selectedOperators],
+  );
+
+  const onChangeMinApproval = useCallback(
+    (value: DeployBlueprintSchema[typeof stepKey]['minApproval']) => {
+      setValue(stepKey, {
+        ...values,
+        minApproval: value,
+      });
+    },
+    [stepKey, values, setValue],
+  );
+
+  return (
+    <div className="flex">
+      <div>
+        <InstructionSideCard
+          title="Finalize Deployment"
+          description="After submitting request, operators will need to approve it before the instance deployment begins."
+        />
+      </div>
+
+      <div className="w-full pl-8">
+        <Label className={labelClassName}>Asset Requirements:</Label>
+        <div className="mt-5">
+          {Children.toArray(
+            selectedAssets.map(({ id }, index) => {
+              const assetMetadata = assetsMetadata?.get(id);
+              const minExposurePercentFormValue = watch(
+                `${stepKey}.securityCommitments.${index}.minExposurePercent`,
+              )?.toString();
+              const maxExposurePercentFormValue = watch(
+                `${stepKey}.securityCommitments.${index}.maxExposurePercent`,
+              )?.toString();
+
+              return (
+                <AssetRequirementFormItem
+                  index={index}
+                  assetId={id}
+                  className="mb-8"
+                  assetMetadata={assetMetadata}
+                  minExposurePercent={minExposurePercentFormValue}
+                  onChangeMinExposurePercent={(value) => {
+                    setValue(
+                      `${stepKey}.securityCommitments.${index}.minExposurePercent`,
+                      Number(value),
+                      {
+                        shouldValidate: true,
+                        shouldDirty: true,
+                      },
+                    );
+                  }}
+                  minExposurePercentErrorMsg={
+                    errors?.securityCommitments?.[index]?.minExposurePercent
+                      ?.message
+                  }
+                  maxExposurePercent={maxExposurePercentFormValue}
+                  onChangeMaxExposurePercent={(value) => {
+                    setValue(
+                      `${stepKey}.securityCommitments.${index}.maxExposurePercent`,
+                      Number(value),
+                      {
+                        shouldValidate: true,
+                        shouldDirty: true,
+                      },
+                    );
+                  }}
+                  maxExposurePercentErrorMsg={
+                    errors?.securityCommitments?.[index]?.maxExposurePercent
+                      ?.message
+                  }
+                />
+              );
+            }),
+          )}
+        </div>
+
+        <div className="mt-5 flex gap-4">
+          <div className="w-1/2">
+            <Label className={labelClassName}>Approval Model:</Label>
+            <Select value={approvalModel} onValueChange={onChangeApprovalModel}>
+              <SelectTrigger>
+                <SelectValue
+                  className="text-[16px] leading-[30px]"
+                  placeholder="Select an approval model"
+                />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="Fixed">
+                  Require all operators to approve
+                </SelectItem>
+                <SelectItem value="Dynamic">
+                  Minimum required approvals
+                </SelectItem>
+              </SelectContent>
+            </Select>
+            {globalErrors?.[stepKey]?.approvalModel?.message && (
+              <ErrorMessage>
+                {globalErrors?.[stepKey]?.approvalModel?.message}
+              </ErrorMessage>
+            )}
+          </div>
+
+          {approvalModel === 'Dynamic' && (
+            <div className="w-1/2">
+              <Label className={labelClassName}>Approval Threshold:</Label>
+              <Input
+                value={minApprovalThreshold}
+                onChange={(nextValue) => onChangeMinApproval(Number(nextValue))}
+                isControlled
+                type="number"
+                id="approval-threshold"
+              />
+              {globalErrors?.[stepKey]?.minApproval?.message && (
+                <ErrorMessage>
+                  {globalErrors?.[stepKey]?.minApproval?.message}
+                </ErrorMessage>
+              )}
+            </div>
+          )}
+        </div>
+
+        <div className="mt-5">
+          <Label className={labelClassName}>Selected Operators:</Label>
+          <OperatorTable
+            advanceFilter={(row) => {
+              return watch(`${operatorsStepKey}.operators`).includes(
+                row.address,
+              );
+            }}
+          />
+        </div>
+      </div>
+    </div>
+  );
+};
diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/BasicInformationStep.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/BasicInformationStep.tsx
index 68474329c..a5fc5ddc0 100644
--- a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/BasicInformationStep.tsx
+++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/BasicInformationStep.tsx
@@ -20,7 +20,7 @@ export const BasicInformationStep: FC<BasicInformationStepProps> = ({
 }) => {
   const labelClassName = 'text-mono-200 dark:text-mono-0';
 
-  const stepKey = BLUEPRINT_DEPLOY_STEPS[0];
+  const stepKey = BLUEPRINT_DEPLOY_STEPS.BASIC_INFO;
   const values = watch(stepKey);
 
   const errors = globalErrors?.[stepKey];
@@ -103,6 +103,7 @@ export const BasicInformationStep: FC<BasicInformationStepProps> = ({
               type="number"
               min={1}
               value={values?.instanceDuration?.toString()}
+              rightIcon={<>Block(s)</>}
               onChange={(nextValue) => handleInstanceDurationChange(nextValue)}
             />
             {errors?.['instanceDuration'] && (
diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx
index 9cb22671b..c8fe55034 100644
--- a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx
+++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx
@@ -1,41 +1,19 @@
 import {
   Typography,
-  Avatar,
-  KeyValueWithButton,
-  Table,
-  EnergyChipColors,
-  EnergyChipStack,
-  EMPTY_VALUE_PLACEHOLDER,
-  CheckBox,
-  ExternalLinkIcon,
   assertSubstrateAddress,
   Input,
-  fuzzyFilter,
 } from '@tangle-network/ui-components';
 import { FC, useEffect, useMemo, useState, Children, useCallback } from 'react';
-import { SelectOperatorsStepProps, SelectOperatorsTable } from './type';
+import { SelectOperatorsStepProps, OperatorSelectionTable } from './type';
 import { BLUEPRINT_DEPLOY_STEPS } from '../../../../../utils/validations/deployBlueprint';
 import useRestakeOperatorMap from '@tangle-network/tangle-shared-ui/data/restake/useRestakeOperatorMap';
-import {
-  createColumnHelper,
-  useReactTable,
-  getCoreRowModel,
-  getSortedRowModel,
-  getPaginationRowModel,
-  getFilteredRowModel,
-  RowSelectionState,
-} from '@tanstack/react-table';
-import { sortByAddressOrIdentity } from '@tangle-network/tangle-shared-ui/components/tables/utils';
-import TableCellWrapper from '@tangle-network/tangle-shared-ui/components/tables/TableCellWrapper';
-import { TableVariant } from '@tangle-network/ui-components/components/Table/types';
-import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore';
+import { RowSelectionState } from '@tanstack/react-table';
 import ErrorMessage from '../../../../../components/ErrorMessage';
 import useIdentities from '@tangle-network/tangle-shared-ui/hooks/useIdentities';
 import useRestakeAssets from '@tangle-network/tangle-shared-ui/data/restake/useRestakeAssets';
 import safeFormatUnits from '@tangle-network/tangle-shared-ui/utils/safeFormatUnits';
 import useOperatorsServices from '@tangle-network/tangle-shared-ui/data/blueprints/useOperatorsServices';
 import delegationsToVaultTokens from '@tangle-network/tangle-shared-ui/utils/restake/delegationsToVaultTokens';
-import VaultsDropdown from '@tangle-network/tangle-shared-ui/components/tables/Operators/VaultsDropdown';
 import {
   Select,
   SelectContent,
@@ -46,17 +24,24 @@ import {
 import { Search } from '@tangle-network/icons';
 import LsTokenIcon from '@tangle-network/tangle-shared-ui/components/LsTokenIcon';
 import { RestakeAsset } from '@tangle-network/tangle-shared-ui/types/restake';
-
-const COLUMN_HELPER = createColumnHelper<SelectOperatorsTable>();
+import { OperatorTable } from './components/OperatorTable';
+import { useWatch } from 'react-hook-form';
 
 const MAX_ASSET_TO_SHOW = 3;
+const stepKey = BLUEPRINT_DEPLOY_STEPS.OPERATOR_SELECTION;
 
 export const SelectOperatorsStep: FC<SelectOperatorsStepProps> = ({
   errors: globalErrors,
   setValue,
   watch,
+  control,
 }) => {
-  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
+  const [rowSelection, setRowSelection] = useState<RowSelectionState>(
+    watch(`${stepKey}.operators`)?.reduce((acc, operator) => {
+      acc[operator] = true;
+      return acc;
+    }, {} as RowSelectionState) || {},
+  );
   const [searchQuery, setSearchQuery] = useState('');
 
   const { operatorMap } = useRestakeOperatorMap();
@@ -73,9 +58,7 @@ export const SelectOperatorsStep: FC<SelectOperatorsStepProps> = ({
   const { result: identities } = useIdentities(operatorAddresses);
   const { assets } = useRestakeAssets();
 
-  const activeNetwork = useNetworkStore().network;
-
-  const operators = useMemo<SelectOperatorsTable[]>(() => {
+  const operators = useMemo<OperatorSelectionTable[]>(() => {
     return Object.entries(operatorMap).map(
       ([addressString, { delegations, restakersCount }]) => {
         const address = assertSubstrateAddress(addressString);
@@ -113,191 +96,28 @@ export const SelectOperatorsStep: FC<SelectOperatorsStepProps> = ({
     );
   }, [operatorServicesMap, operatorMap, identities, assets]);
 
-  const stepKey = BLUEPRINT_DEPLOY_STEPS[1];
   const errors = globalErrors?.[stepKey];
 
-  const selectedAssets = watch(`${stepKey}.assets`) ?? [];
-
-  const columns = [
-    COLUMN_HELPER.accessor('address', {
-      header: () => 'Identity',
-      sortingFn: sortByAddressOrIdentity<SelectOperatorsTable>(),
-      cell: (props) => {
-        const { address, identityName: identity } = props.row.original;
-
-        const accountUrl = activeNetwork.createExplorerAccountUrl(address);
-
-        return (
-          <TableCellWrapper className="pl-3 min-h-fit">
-            <div className="flex items-center flex-1 gap-2 pr-3">
-              <CheckBox
-                wrapperClassName="!block !min-h-auto cursor-pointer"
-                className="cursor-pointer"
-                isChecked={props.row.getIsSelected()}
-                onChange={props.row.getToggleSelectedHandler()}
-              />
-
-              <Avatar
-                sourceVariant="address"
-                value={address}
-                theme="substrate"
-                size="md"
-              />
-
-              <div className="flex items-center">
-                <KeyValueWithButton
-                  keyValue={identity ? identity : address}
-                  size="sm"
-                />
-                {accountUrl && (
-                  <ExternalLinkIcon
-                    className="ml-1"
-                    href={accountUrl}
-                    target="_blank"
-                  />
-                )}
-              </div>
-            </div>
-          </TableCellWrapper>
-        );
-      },
-    }),
-    COLUMN_HELPER.accessor('instanceCount', {
-      header: () => 'Instance Count',
-      sortingFn: sortByAddressOrIdentity<SelectOperatorsTable>(),
-      cell: (props) => {
-        return (
-          <TableCellWrapper className="pl-3 min-h-fit">
-            <Typography variant="body1">
-              {props.row.original.instanceCount}
-            </Typography>
-          </TableCellWrapper>
-        );
-      },
-    }),
-    COLUMN_HELPER.accessor('restakersCount', {
-      header: () => 'Restakers Count',
-      cell: (props) => {
-        return (
-          <TableCellWrapper className="pl-3 min-h-fit">
-            <Typography variant="body1">
-              {props.row.original.restakersCount}
-            </Typography>
-          </TableCellWrapper>
-        );
-      },
-    }),
-    COLUMN_HELPER.accessor('uptime', {
-      header: () => 'Uptime',
-      cell: (props) => {
-        const DEFAULT_STACK = 10;
-        const DEFAULT_PERCENTAGE = 100;
-        const numberOfActiveChips = !props.row.original.uptime
-          ? 0
-          : Math.round(
-              (props.row.original.uptime * DEFAULT_STACK) / DEFAULT_PERCENTAGE,
-            );
-
-        const activeColors = Array.from({ length: numberOfActiveChips }).fill(
-          EnergyChipColors.GREEN,
-        );
-        const inactiveColors = Array.from({
-          length: DEFAULT_STACK - numberOfActiveChips,
-        }).fill(EnergyChipColors.GREY);
-        const colors = [...activeColors, ...inactiveColors];
-
-        return (
-          <TableCellWrapper className="pl-3 min-h-fit">
-            <EnergyChipStack
-              colors={colors as EnergyChipColors[]}
-              label={`${props.row.original.uptime || EMPTY_VALUE_PLACEHOLDER}%`}
-            />
-          </TableCellWrapper>
-        );
-      },
-    }),
-    COLUMN_HELPER.accessor('vaultTokensInUsd', {
-      header: () => 'Vault TVL',
-      cell: (props) => {
-        const tokensList = props.row.original.vaultTokens ?? [];
-        return (
-          <TableCellWrapper removeRightBorder className="pl-3 min-h-fit">
-            {tokensList.length > 0 ? (
-              <div className="flex gap-2 items-center">
-                <Typography variant="body1">
-                  {props.row.original.vaultTokensInUsd
-                    ? `$${props.row.original.vaultTokensInUsd}`
-                    : EMPTY_VALUE_PLACEHOLDER}
-                </Typography>
-                <VaultsDropdown vaultTokens={tokensList} />
-              </div>
-            ) : (
-              <Typography variant="body1">No vaults</Typography>
-            )}
-          </TableCellWrapper>
-        );
-      },
-      sortingFn: (rowA, rowB) => {
-        const aVaultTokens = rowA.original.vaultTokensInUsd ?? 0;
-        const bVaultTokens = rowB.original.vaultTokensInUsd ?? 0;
+  const selectedAssets = useWatch({
+    control,
+    name: `${stepKey}.assets`,
+    defaultValue: [],
+  });
 
-        return aVaultTokens - bVaultTokens;
-      },
-    }),
-  ];
-  const tableData = useMemo(() => {
-    if (selectedAssets.length === 0) return operators;
+  const advanceFilter = useCallback(
+    (operator: OperatorSelectionTable) => {
+      if (selectedAssets.length === 0) return true;
 
-    const selectedSymbols = new Set(
-      selectedAssets.map((asset) => asset.metadata.symbol),
-    );
+      const selectedSymbols = new Set(
+        selectedAssets.map((asset) => asset.metadata.symbol),
+      );
 
-    return operators.filter((operator) =>
-      operator.vaultTokens?.some((vaultToken) =>
+      return !!operator.vaultTokens?.some((vaultToken) =>
         selectedSymbols.has(vaultToken.symbol),
-      ),
-    );
-  }, [selectedAssets, operators]);
-  const table = useReactTable({
-    data: tableData,
-    columns,
-    getCoreRowModel: getCoreRowModel(),
-    getSortedRowModel: getSortedRowModel(),
-    getPaginationRowModel: getPaginationRowModel(),
-    getFilteredRowModel: getFilteredRowModel(),
-    enableRowSelection: true,
-    onRowSelectionChange: (onChange) => {
-      setRowSelection(onChange);
-    },
-    initialState: {
-      sorting: [
-        {
-          id: 'vaultTokensInUsd',
-          desc: true,
-        },
-        {
-          id: 'instanceCount',
-          desc: true,
-        },
-      ],
-    },
-    state: {
-      rowSelection,
-      columnFilters: [
-        {
-          id: 'address',
-          value: searchQuery,
-        },
-      ],
-    },
-    filterFns: {
-      fuzzy: fuzzyFilter,
+      );
     },
-    globalFilterFn: fuzzyFilter,
-    getRowId: (row) => row.address,
-    autoResetPageIndex: false,
-    enableSortingRemoval: false,
-  });
+    [selectedAssets],
+  );
 
   /**
    * @dev set the operators to the form value when the rowSelection changes
@@ -315,27 +135,25 @@ export const SelectOperatorsStep: FC<SelectOperatorsStepProps> = ({
             (selectedAsset) => selectedAsset.id !== asset.id,
           );
 
-      if (!isChecked) {
-        // Filter operators that have the selected assets
-        const selectedOperators = operators
-          .filter((operator) =>
-            operator.vaultTokens?.some((vaultToken) =>
-              newSelectedAssets.some(
-                (selectedAsset) =>
-                  selectedAsset.metadata.symbol === vaultToken.symbol,
-              ),
+      // Filter operators that have the selected assets
+      const selectedOperators = operators
+        .filter((operator) =>
+          operator.vaultTokens?.some((vaultToken) =>
+            newSelectedAssets.some(
+              (selectedAsset) =>
+                selectedAsset.metadata.symbol === vaultToken.symbol,
             ),
-          )
-          .map((operator) => operator.address);
+          ),
+        )
+        .map((operator) => operator.address);
 
-        // Create a single object with all operators set to false
-        const newRowSelection = selectedOperators.reduce((acc, operator) => {
-          acc[operator] = false;
-          return acc;
-        }, {} as RowSelectionState);
+      // Create a single object with all operators set to false
+      const newRowSelection = selectedOperators.reduce((acc, operator) => {
+        acc[operator] = false;
+        return acc;
+      }, {} as RowSelectionState);
 
-        setRowSelection(newRowSelection);
-      }
+      setRowSelection(newRowSelection);
 
       setValue(`${stepKey}.assets`, newSelectedAssets);
     },
@@ -419,8 +237,10 @@ export const SelectOperatorsStep: FC<SelectOperatorsStepProps> = ({
             </SelectContent>
           </Select>
 
-          {globalErrors?.[stepKey]?.assets && (
-            <ErrorMessage>{globalErrors[stepKey].assets.message}</ErrorMessage>
+          {globalErrors?.[stepKey]?.assets?.message && (
+            <ErrorMessage>
+              {globalErrors?.[stepKey]?.assets?.message}
+            </ErrorMessage>
           )}
         </div>
 
@@ -438,15 +258,38 @@ export const SelectOperatorsStep: FC<SelectOperatorsStepProps> = ({
           />
         </div>
       </div>
-      <Table
-        variant={TableVariant.GLASS_OUTER}
-        isPaginated
-        tableProps={table}
-        trClassName="group overflow-hidden"
+
+      <OperatorTable
+        enableRowSelection={true}
+        onRowSelectionChange={(onChange) => {
+          setRowSelection(onChange);
+        }}
+        initialState={{
+          sorting: [
+            {
+              id: 'vaultTokensInUsd',
+              desc: true,
+            },
+            {
+              id: 'instanceCount',
+              desc: true,
+            },
+          ],
+        }}
+        state={{
+          rowSelection,
+          columnFilters: [
+            {
+              id: 'address',
+              value: searchQuery,
+            },
+          ],
+        }}
+        advanceFilter={advanceFilter}
       />
 
-      {errors?.operators && (
-        <ErrorMessage>{errors.operators.message}</ErrorMessage>
+      {errors?.operators?.message && (
+        <ErrorMessage>{errors?.operators?.message}</ErrorMessage>
       )}
     </div>
   );
diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/AssetRequirementFormItem.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/AssetRequirementFormItem.tsx
new file mode 100644
index 000000000..6e787d1a2
--- /dev/null
+++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/AssetRequirementFormItem.tsx
@@ -0,0 +1,91 @@
+import { RestakeAssetId } from '@tangle-network/tangle-shared-ui/types';
+import { Input, Typography } from '@tangle-network/ui-components';
+import { FC } from 'react';
+import { PrimitiveAssetMetadata } from '@tangle-network/tangle-shared-ui/types/restake';
+import LsTokenIcon from '@tangle-network/tangle-shared-ui/components/LsTokenIcon';
+import ErrorMessage from '../../../../../../components/ErrorMessage';
+import cx from 'classnames';
+
+type AssetRequirementFormItemProps = {
+  index: number;
+  assetId?: RestakeAssetId;
+  onChangeMinExposurePercent: (value: string) => void;
+  onChangeMaxExposurePercent: (value: string) => void;
+  assetMetadata?: PrimitiveAssetMetadata | null;
+  minExposurePercent?: string;
+  maxExposurePercent?: string;
+  minExposurePercentErrorMsg?: string;
+  maxExposurePercentErrorMsg?: string;
+  className?: string;
+};
+
+export const AssetRequirementFormItem: FC<AssetRequirementFormItemProps> = ({
+  index,
+  assetId,
+  minExposurePercent,
+  maxExposurePercent,
+  onChangeMinExposurePercent,
+  onChangeMaxExposurePercent,
+  minExposurePercentErrorMsg,
+  maxExposurePercentErrorMsg,
+  assetMetadata,
+  className,
+}) => {
+  return (
+    <div className={cx('flex flex-wrap gap-4 items-center', className)}>
+      <div className="min-h-6 w-3/12">
+        <Input
+          id={`securityCommitment.${index}.assetId`}
+          isControlled
+          value={assetId ?? ''}
+          className="hidden"
+          isDisabled
+        />
+        <div className="flex items-center gap-2">
+          <LsTokenIcon
+            name={assetMetadata?.name ?? 'TNT'}
+            hasRainbowBorder
+            size="lg"
+          />
+
+          <Typography variant="h5" className="whitespace-nowrap">
+            {assetMetadata?.name ?? 'TNT'}
+          </Typography>
+        </div>
+      </div>
+
+      <div className="min-h-6 w-4/12">
+        <Input
+          id={`securityCommitment.${index}.minExposurePercent`}
+          isControlled
+          rightIcon={<>%</>}
+          value={minExposurePercent ?? ''}
+          inputClassName="placeholder:text-mono-80 dark:placeholder:text-mono-120 h-10"
+          placeholder="Enter exposure percentage to spend"
+          onChange={onChangeMinExposurePercent}
+          type="number"
+          autoComplete="off"
+          min={minExposurePercent || 0}
+          max={maxExposurePercent || 100}
+        />
+        <ErrorMessage>{minExposurePercentErrorMsg}</ErrorMessage>
+      </div>
+      <div className="min-h-6 w-4/12">
+        <Input
+          id={`securityCommitment.${index}.maxExposurePercent`}
+          isControlled
+          rightIcon={<>%</>}
+          value={maxExposurePercent ?? ''}
+          inputClassName="placeholder:text-mono-80 dark:placeholder:text-mono-120 h-10"
+          placeholder="Enter exposure percentage to spend"
+          onChange={onChangeMaxExposurePercent}
+          type="number"
+          autoComplete="off"
+          min={minExposurePercent || 0}
+          max={maxExposurePercent || 100}
+        />
+        <ErrorMessage>{maxExposurePercentErrorMsg}</ErrorMessage>
+      </div>
+    </div>
+  );
+};
diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/OperatorTable.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/OperatorTable.tsx
new file mode 100644
index 000000000..84ae354c4
--- /dev/null
+++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/OperatorTable.tsx
@@ -0,0 +1,304 @@
+import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore';
+import useOperatorsServices from '@tangle-network/tangle-shared-ui/data/blueprints/useOperatorsServices';
+import useRestakeOperatorMap from '@tangle-network/tangle-shared-ui/data/restake/useRestakeOperatorMap';
+import useIdentities from '@tangle-network/tangle-shared-ui/hooks/useIdentities';
+import {
+  assertSubstrateAddress,
+  Avatar,
+  CheckBox,
+  EMPTY_VALUE_PLACEHOLDER,
+  EnergyChipColors,
+  EnergyChipStack,
+  ExternalLinkIcon,
+  fuzzyFilter,
+  getRoundedAmountString,
+  KeyValueWithButton,
+  Table,
+  Typography,
+} from '@tangle-network/ui-components';
+import { FC, useMemo } from 'react';
+import { OperatorSelectionTable } from '../type';
+import safeFormatUnits from '@tangle-network/tangle-shared-ui/utils/safeFormatUnits';
+import useRestakeAssets from '@tangle-network/tangle-shared-ui/data/restake/useRestakeAssets';
+import delegationsToVaultTokens from '@tangle-network/tangle-shared-ui/utils/restake/delegationsToVaultTokens';
+import {
+  createColumnHelper,
+  getFilteredRowModel,
+  getSortedRowModel,
+  getCoreRowModel,
+  useReactTable,
+  getPaginationRowModel,
+  TableOptions,
+} from '@tanstack/react-table';
+import { sortByAddressOrIdentity } from '@tangle-network/tangle-shared-ui/components/tables/utils';
+import TableCellWrapper from '@tangle-network/tangle-shared-ui/components/tables/TableCellWrapper';
+import VaultsDropdown from '@tangle-network/tangle-shared-ui/components/tables/Operators/VaultsDropdown';
+import { TableVariant } from '@tangle-network/ui-components/components/Table/types';
+import useBlueprintRegisteredOperator from '@tangle-network/tangle-shared-ui/data/blueprints/useBlueprintRegisteredOperator';
+import { useParams } from 'react-router';
+import { getOperatorPricing } from '../../../../../../utils';
+
+const COLUMN_HELPER = createColumnHelper<OperatorSelectionTable>();
+
+type Props = Omit<
+  TableOptions<OperatorSelectionTable>,
+  'data' | 'columns' | 'getCoreRowModel'
+> & {
+  advanceFilter?: (row: OperatorSelectionTable) => boolean;
+};
+
+export const OperatorTable: FC<Props> = ({ advanceFilter, ...tableProps }) => {
+  const { id: _blueprintId } = useParams();
+  const blueprintId = Number(_blueprintId);
+
+  const { assets } = useRestakeAssets();
+
+  const { operatorMap: restakeOperatorMap } = useRestakeOperatorMap();
+
+  const { result: registeredOperators_ } =
+    useBlueprintRegisteredOperator(blueprintId);
+
+  const registeredOperators = useMemo(() => {
+    return new Map(
+      registeredOperators_.map((operator) => [
+        operator.operatorAccount,
+        operator,
+      ]),
+    );
+  }, [registeredOperators_]);
+  const operatorAddresses = useMemo(
+    () =>
+      registeredOperators_.map((operator) =>
+        assertSubstrateAddress(operator.operatorAccount),
+      ),
+    [registeredOperators_],
+  );
+
+  const { result: operatorServicesMap } =
+    useOperatorsServices(operatorAddresses);
+  const { result: identities } = useIdentities(operatorAddresses);
+
+  const activeNetwork = useNetworkStore().network;
+
+  // TODO: fetch and combine with registered operators
+  const operators = useMemo<OperatorSelectionTable[]>(() => {
+    const filteredRestakeOperators = Object.entries(restakeOperatorMap).filter(
+      ([address]) => registeredOperators.has(address),
+    );
+    return filteredRestakeOperators.map(
+      ([addressString, { delegations, restakersCount }]) => {
+        const address = assertSubstrateAddress(addressString);
+        const operatorPreferences = registeredOperators.get(address);
+        // @dev this case should not happen because we filter the operators in the `restakeOperatorMap`
+        if (!operatorPreferences) throw new Error('Operator not found');
+        const registeredData = operatorPreferences.preferences;
+
+        return {
+          address,
+          identityName: identities.get(address)?.name ?? undefined,
+          restakersCount,
+          vaultTokensInUsd: delegations.reduce((acc, curr) => {
+            const asset = assets?.get(curr.assetId);
+            if (!asset) {
+              return acc;
+            }
+            const parsed = safeFormatUnits(
+              curr.amount,
+              asset.metadata.decimals,
+            );
+
+            if (parsed.success === false) {
+              return acc;
+            }
+            const currPrice =
+              Number(parsed.value) * (asset.metadata.priceInUsd ?? 0);
+            return acc + currPrice;
+          }, 0),
+          vaultTokens:
+            assets === null
+              ? []
+              : delegationsToVaultTokens(delegations, assets),
+          instanceCount: operatorServicesMap.get(address)?.length ?? 0,
+          // TODO: using graphql with im online pallet to get uptime of operator
+          uptime: Math.round(Math.random() * 100),
+          pricing: getOperatorPricing(registeredData.priceTargets),
+        };
+      },
+    );
+  }, [operatorServicesMap, restakeOperatorMap, identities, assets]);
+
+  const columns = [
+    COLUMN_HELPER.accessor('address', {
+      header: () => 'Identity',
+      sortingFn: sortByAddressOrIdentity<OperatorSelectionTable>(),
+      cell: (props) => {
+        const { address, identityName: identity } = props.row.original;
+
+        const accountUrl = activeNetwork.createExplorerAccountUrl(address);
+
+        return (
+          <TableCellWrapper className="pl-3 min-h-fit">
+            <div className="flex items-center flex-1 gap-2 pr-3">
+              {tableProps.enableRowSelection && (
+                <CheckBox
+                  wrapperClassName="!block !min-h-auto cursor-pointer"
+                  className="cursor-pointer"
+                  isChecked={props.row.getIsSelected()}
+                  onChange={props.row.getToggleSelectedHandler()}
+                />
+              )}
+
+              <Avatar
+                sourceVariant="address"
+                value={address}
+                theme="substrate"
+                size="md"
+              />
+
+              <div className="flex items-center">
+                <KeyValueWithButton
+                  keyValue={identity ? identity : address}
+                  size="sm"
+                />
+                {accountUrl && (
+                  <ExternalLinkIcon
+                    className="ml-1"
+                    href={accountUrl}
+                    target="_blank"
+                  />
+                )}
+              </div>
+            </div>
+          </TableCellWrapper>
+        );
+      },
+    }),
+    COLUMN_HELPER.accessor('pricing', {
+      header: () => 'Pricing',
+      cell: (props) => {
+        return (
+          <TableCellWrapper className="pl-3 min-h-fit">
+            <Typography variant="body1">
+              {`$${getRoundedAmountString(props.row.original.pricing)}`}
+            </Typography>
+          </TableCellWrapper>
+        );
+      },
+    }),
+    COLUMN_HELPER.accessor('instanceCount', {
+      header: () => 'Instance Count',
+      sortingFn: sortByAddressOrIdentity<OperatorSelectionTable>(),
+      cell: (props) => {
+        return (
+          <TableCellWrapper className="pl-3 min-h-fit">
+            <Typography variant="body1">
+              {props.row.original.instanceCount}
+            </Typography>
+          </TableCellWrapper>
+        );
+      },
+    }),
+    COLUMN_HELPER.accessor('restakersCount', {
+      header: () => 'Restakers',
+      cell: (props) => {
+        return (
+          <TableCellWrapper className="pl-3 min-h-fit">
+            <Typography variant="body1">
+              {props.row.original.restakersCount}
+            </Typography>
+          </TableCellWrapper>
+        );
+      },
+    }),
+    COLUMN_HELPER.accessor('uptime', {
+      header: () => 'Uptime',
+      cell: (props) => {
+        const DEFAULT_STACK = 10;
+        const DEFAULT_PERCENTAGE = 100;
+        const numberOfActiveChips = !props.row.original.uptime
+          ? 0
+          : Math.round(
+              (props.row.original.uptime * DEFAULT_STACK) / DEFAULT_PERCENTAGE,
+            );
+
+        const activeColors = Array<EnergyChipColors>(numberOfActiveChips).fill(
+          EnergyChipColors.GREEN,
+        );
+        const inactiveColors = Array<EnergyChipColors>(
+          DEFAULT_STACK - numberOfActiveChips,
+        ).fill(EnergyChipColors.GREY);
+        const colors = [...activeColors, ...inactiveColors];
+
+        return (
+          <TableCellWrapper className="pl-3 min-h-fit">
+            <EnergyChipStack
+              colors={colors}
+              label={`${props.row.original.uptime || EMPTY_VALUE_PLACEHOLDER}%`}
+            />
+          </TableCellWrapper>
+        );
+      },
+    }),
+    COLUMN_HELPER.accessor('vaultTokensInUsd', {
+      header: () => 'Delegated Assets',
+      cell: (props) => {
+        const tokensList = props.row.original.vaultTokens ?? [];
+        return (
+          <TableCellWrapper removeRightBorder className="pl-3 min-h-fit">
+            {tokensList.length > 0 ? (
+              <div className="flex gap-2 items-center">
+                <Typography variant="body1">
+                  {props.row.original.vaultTokensInUsd
+                    ? `$${props.row.original.vaultTokensInUsd}`
+                    : EMPTY_VALUE_PLACEHOLDER}
+                </Typography>
+                <VaultsDropdown vaultTokens={tokensList} />
+              </div>
+            ) : (
+              <Typography variant="body1">No vaults</Typography>
+            )}
+          </TableCellWrapper>
+        );
+      },
+      sortingFn: (rowA, rowB) => {
+        const aVaultTokens = rowA.original.vaultTokensInUsd ?? 0;
+        const bVaultTokens = rowB.original.vaultTokensInUsd ?? 0;
+
+        return aVaultTokens - bVaultTokens;
+      },
+    }),
+  ];
+
+  const tableData = useMemo(() => {
+    if (advanceFilter) {
+      return operators.filter(advanceFilter);
+    }
+    return operators;
+  }, [operators, advanceFilter]);
+
+  const table = useReactTable({
+    getSortedRowModel: getSortedRowModel(),
+    getPaginationRowModel: getPaginationRowModel(),
+    getFilteredRowModel: getFilteredRowModel(),
+    filterFns: {
+      fuzzy: fuzzyFilter,
+    },
+    globalFilterFn: fuzzyFilter,
+    getRowId: (row) => row.address,
+    autoResetPageIndex: false,
+    enableSortingRemoval: false,
+    ...tableProps,
+    data: tableData,
+    columns,
+    getCoreRowModel: getCoreRowModel(),
+  });
+
+  return (
+    <Table
+      variant={TableVariant.GLASS_OUTER}
+      isPaginated
+      tableProps={table}
+      trClassName="group overflow-hidden"
+    />
+  );
+};
diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/type.ts b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/type.ts
index a8c14a5d0..123c412af 100644
--- a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/type.ts
+++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/type.ts
@@ -3,19 +3,23 @@ import { FieldErrors, UseFormSetValue, UseFormWatch } from 'react-hook-form';
 import { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint';
 import { SubstrateAddress } from '@tangle-network/ui-components/types/address';
 import { VaultToken } from '@tangle-network/tangle-shared-ui/types';
+import { Control } from 'react-hook-form';
 
 export type BaseDeployStepProps = {
   errors?: FieldErrors<DeployBlueprintSchema>;
   setValue: UseFormSetValue<DeployBlueprintSchema>;
   watch: UseFormWatch<DeployBlueprintSchema>;
   blueprint?: Blueprint;
+  control: Control<DeployBlueprintSchema>;
 };
 
 export type BasicInformationStepProps = BaseDeployStepProps;
 
 export type SelectOperatorsStepProps = BaseDeployStepProps;
 
-export type SelectOperatorsTable = {
+export type AssetConfigurationStepProps = BaseDeployStepProps;
+
+export type OperatorSelectionTable = {
   address: SubstrateAddress;
   identityName?: string;
   vaultTokensInUsd?: number;
@@ -23,4 +27,5 @@ export type SelectOperatorsTable = {
   uptime?: number;
   restakersCount?: number;
   vaultTokens?: VaultToken[];
+  pricing?: number;
 };
diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/page.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/page.tsx
index 607316c86..aecae0fa8 100644
--- a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/page.tsx
+++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/page.tsx
@@ -18,6 +18,9 @@ import useBlueprintDetails from '@tangle-network/tangle-shared-ui/data/restake/u
 import { ArrowLeft } from '@tangle-network/icons';
 import { SelectOperatorsStep } from './DeploySteps/OperatorSelectionStep';
 import { BasicInformationStep } from './DeploySteps/BasicInformationStep';
+import { AssetConfigurationStep } from './DeploySteps/AssetConfigurationStep';
+
+const DEPLOY_STEPS = Object.values(BLUEPRINT_DEPLOY_STEPS);
 
 const DeployPage: FC = () => {
   const { id } = useParams();
@@ -29,6 +32,7 @@ const DeployPage: FC = () => {
   } = useBlueprintDetails(id);
 
   const {
+    control,
     watch,
     setValue,
     trigger,
@@ -39,6 +43,7 @@ const DeployPage: FC = () => {
   });
 
   const commonProps = {
+    control,
     errors,
     setValue,
     watch,
@@ -55,6 +60,10 @@ const DeployPage: FC = () => {
         component: SelectOperatorsStep,
         props: commonProps,
       },
+      {
+        component: AssetConfigurationStep,
+        props: commonProps,
+      },
     ],
     [errors, setValue, watch, blueprintResult?.details],
   );
@@ -62,10 +71,10 @@ const DeployPage: FC = () => {
   const StepComponent = createElement(steps[step].component, steps[step].props);
 
   const onNextStep = useCallback(async () => {
-    const values = BLUEPRINT_DEPLOY_STEPS[step];
-    const isStepValid = await trigger(values);
+    const currentStepKey = DEPLOY_STEPS[step];
+    const isStepValid = await trigger(currentStepKey);
 
-    if (isStepValid && step < BLUEPRINT_DEPLOY_STEPS.length - 1) {
+    if (isStepValid && step < DEPLOY_STEPS.length - 1) {
       setStep(step + 1);
     }
   }, [step, trigger]);
diff --git a/apps/tangle-cloud/src/pages/instances/Instances/PendingInstanceTable.tsx b/apps/tangle-cloud/src/pages/instances/Instances/PendingInstanceTable.tsx
index b91d98bc3..d105f258e 100644
--- a/apps/tangle-cloud/src/pages/instances/Instances/PendingInstanceTable.tsx
+++ b/apps/tangle-cloud/src/pages/instances/Instances/PendingInstanceTable.tsx
@@ -11,7 +11,6 @@ import {
   Button,
   Dropdown,
   DropdownBody,
-  DropdownButton,
   EMPTY_VALUE_PLACEHOLDER,
   getRoundedAmountString,
   Modal,
diff --git a/apps/tangle-cloud/src/pages/instances/Instances/UpdateBlueprintModel/ApproveConfirmationModal.tsx b/apps/tangle-cloud/src/pages/instances/Instances/UpdateBlueprintModel/ApproveConfirmationModal.tsx
index 5c0118b07..a31a2786e 100644
--- a/apps/tangle-cloud/src/pages/instances/Instances/UpdateBlueprintModel/ApproveConfirmationModal.tsx
+++ b/apps/tangle-cloud/src/pages/instances/Instances/UpdateBlueprintModel/ApproveConfirmationModal.tsx
@@ -19,7 +19,7 @@ type ApproveConfirmationModalProps = {
   onClose: () => void;
   onConfirm: (data: ApprovalConfirmationFormFields) => Promise<void>;
   selectedRequest: MonitoringServiceRequest | null;
-  assetsMetadata: Map<string, PrimitiveAssetMetadata | null>;
+  assetsMetadata?: Map<string, PrimitiveAssetMetadata | null>;
   status: TxStatus;
 };
 
@@ -90,31 +90,29 @@ function ApproveConfirmationModal({
       </ModalHeader>
 
       <ModalBody>
-        <div className="mt-52">
-          <BlueprintItem
-            imgUrl={selectedRequest?.blueprintData?.metadata.logo ?? ''}
-            renderImage={(imageUrl) => {
-              return (
-                <img
-                  src={imageUrl}
-                  alt={selectedRequest?.blueprintData?.metadata.name ?? ''}
-                  className="flex-shrink-0 bg-center rounded-full"
-                />
-              );
-            }}
-            id={selectedRequest?.blueprintData?.metadata.name ?? ''}
-            name={selectedRequest?.blueprintData?.metadata.name ?? ''}
-            restakersCount={selectedRequest?.blueprintData?.restakersCount ?? 0}
-            operatorsCount={selectedRequest?.blueprintData?.operatorsCount ?? 0}
-            tvl={selectedRequest?.blueprintData?.tvl?.toString() ?? '0'}
-            isBoosted={false}
-            category={selectedRequest?.blueprintData?.metadata.category ?? ''}
-            description={
-              selectedRequest?.blueprintData?.metadata.description ?? ''
-            }
-            author={selectedRequest?.blueprintData?.metadata.author ?? ''}
-          />
-        </div>
+        <BlueprintItem
+          imgUrl={selectedRequest?.blueprintData?.metadata.logo ?? ''}
+          renderImage={(imageUrl) => {
+            return (
+              <img
+                src={imageUrl}
+                alt={selectedRequest?.blueprintData?.metadata.name ?? ''}
+                className="flex-shrink-0 bg-center rounded-full"
+              />
+            );
+          }}
+          id={selectedRequest?.blueprintData?.metadata.name ?? ''}
+          name={selectedRequest?.blueprintData?.metadata.name ?? ''}
+          restakersCount={selectedRequest?.blueprintData?.restakersCount ?? 0}
+          operatorsCount={selectedRequest?.blueprintData?.operatorsCount ?? 0}
+          tvl={selectedRequest?.blueprintData?.tvl?.toString() ?? '0'}
+          isBoosted={false}
+          category={selectedRequest?.blueprintData?.metadata.category ?? ''}
+          description={
+            selectedRequest?.blueprintData?.metadata.description ?? ''
+          }
+          author={selectedRequest?.blueprintData?.metadata.author ?? ''}
+        />
 
         <form onSubmit={handleSubmit(onConfirm)} className="mt-4 space-y-4">
           <Typography variant="h4" className="text-center mb-3">
@@ -122,7 +120,7 @@ function ApproveConfirmationModal({
           </Typography>
           {Children.toArray(
             securityCommitmentDefaultFormValue.map(({ assetId }, index) => {
-              const assetMetadata = assetsMetadata.get(assetId);
+              const assetMetadata = assetsMetadata?.get(assetId);
               const exposurePercentFormValue = watch(
                 `securityCommitment.${index}.exposurePercent`,
               );
diff --git a/apps/tangle-cloud/src/utils/index.ts b/apps/tangle-cloud/src/utils/index.ts
index 92ce90b09..ce53db7dd 100644
--- a/apps/tangle-cloud/src/utils/index.ts
+++ b/apps/tangle-cloud/src/utils/index.ts
@@ -4,6 +4,7 @@ import {
 } from '@tangle-network/ui-components/types/address';
 import { isSubstrateAddress } from '@tangle-network/ui-components/utils/isSubstrateAddress';
 import { decodeAddress } from '@polkadot/util-crypto';
+import { OperatorPreferences } from '@tangle-network/tangle-shared-ui/data/blueprints/utils/type';
 
 export const toTanglePrimitiveEcdsaKey = (
   address: SubstrateAddress | EvmAddress,
@@ -28,3 +29,24 @@ export const toTanglePrimitiveEcdsaKey = (
     return null;
   }
 };
+
+/**
+ * @dev should format data to human readable before using this function
+ * TODO: update this function
+ * Pricing
+ *   Server cost
+ *     This is quite clear and operator might just set some defaults Per CPU, Memory, GPU cost, etc. (not sure how much this would change)
+ *   Benchmarking
+ *     For job pricing
+ *     For service usage pricing (network egress/igress costs)
+ *     Storage growth rate
+ *   Third-party external API pricing
+ *     Phala cloud might have prices, other external services being used like AWS, GCP, etc.
+ * */
+export const getOperatorPricing = (
+  operatorPreferences: OperatorPreferences['priceTargets'],
+) => {
+  return Object.values(operatorPreferences).reduce((acc, curr) => {
+    return acc + Number(curr);
+  }, 0);
+};
diff --git a/apps/tangle-cloud/src/utils/validations/deployBlueprint.ts b/apps/tangle-cloud/src/utils/validations/deployBlueprint.ts
index c58d8ecca..4f3a06ef2 100644
--- a/apps/tangle-cloud/src/utils/validations/deployBlueprint.ts
+++ b/apps/tangle-cloud/src/utils/validations/deployBlueprint.ts
@@ -5,12 +5,15 @@ import {
 } from '@tangle-network/ui-components';
 import { z } from 'zod';
 
-export const BLUEPRINT_DEPLOY_STEPS = [
-  'step1',
-  'step2',
-  'step3',
-  'step4',
-] as const;
+export const BLUEPRINT_DEPLOY_STEPS = {
+  BASIC_INFO: 'BasicInfo',
+  OPERATOR_SELECTION: 'OperatorSelection',
+  ASSET_CONFIGURATION: 'AssetConfiguration',
+  // REQUEST_ARGS: 'RequestArgs',
+} as const;
+
+export type BlueprintDeployStep =
+  (typeof BLUEPRINT_DEPLOY_STEPS)[keyof typeof BLUEPRINT_DEPLOY_STEPS];
 
 export const restakeAssetSchema = z.object({
   id: z.string().transform((value, ctx) => {
@@ -41,76 +44,135 @@ export const restakeAssetSchema = z.object({
   }),
 });
 
-export const deployBlueprintSchema = z.object({
-  [BLUEPRINT_DEPLOY_STEPS[0]]: z.object({
-    instanceName: z.string().min(1),
-    instanceDuration: z.number().min(1),
-    permittedCallers: z.array(z.string()).transform((value, context) => {
-      if (value.length === 0) {
-        context.addIssue({
-          code: z.ZodIssueCode.custom,
-          message: 'At least one caller is required',
-        });
+export const deployBlueprintSchema = z
+  .object({
+    [BLUEPRINT_DEPLOY_STEPS.BASIC_INFO]: z.object({
+      instanceName: z.string().min(1),
+      instanceDuration: z.number().min(1),
+      permittedCallers: z.array(z.string()).transform((value, context) => {
+        if (value.length === 0) {
+          context.addIssue({
+            code: z.ZodIssueCode.custom,
+            message: 'At least one caller is required',
+          });
 
-        return z.NEVER;
-      }
+          return z.NEVER;
+        }
 
-      for (const [index, caller] of value.entries()) {
-        if (!isEvmAddress(caller) && !isSubstrateAddress(caller)) {
+        for (const [index, caller] of value.entries()) {
+          if (!isEvmAddress(caller) && !isSubstrateAddress(caller)) {
+            context.addIssue({
+              path: [index],
+              code: z.ZodIssueCode.custom,
+              message: 'Invalid caller address',
+            });
+          }
+        }
+
+        return value;
+      }),
+    }),
+    [BLUEPRINT_DEPLOY_STEPS.OPERATOR_SELECTION]: z.object({
+      operators: z.array(z.string()).transform((value, context) => {
+        if (value.length === 0) {
           context.addIssue({
-            path: [index],
             code: z.ZodIssueCode.custom,
-            message: 'Invalid caller address',
+            message: 'At least one operator is required',
           });
+
+          return z.NEVER;
         }
-      }
 
-      return value;
+        return value;
+      }),
+      assets: z.array(restakeAssetSchema).transform((value, context) => {
+        if (value.length === 0) {
+          context.addIssue({
+            code: z.ZodIssueCode.custom,
+            message: 'At least one asset is required',
+          });
+
+          return z.NEVER;
+        }
+
+        return value;
+      }),
     }),
-  }),
-  [BLUEPRINT_DEPLOY_STEPS[1]]: z.object({
-    operators: z.array(z.string()).transform((value, context) => {
-      if (value.length === 0) {
-        context.addIssue({
-          code: z.ZodIssueCode.custom,
-          message: 'At least one operator is required',
-        });
+    [BLUEPRINT_DEPLOY_STEPS.ASSET_CONFIGURATION]: z.object({
+      securityCommitments: z
+        .array(
+          z.object({
+            minExposurePercent: z.number().min(1).max(100),
+            maxExposurePercent: z.number().min(1).max(100),
+          }),
+        )
+        .transform((value, context) => {
+          if (value.length === 0) {
+            context.addIssue({
+              code: z.ZodIssueCode.custom,
+              message: 'At least one security commitment is required',
+            });
 
-        return z.NEVER;
-      }
+            return z.NEVER;
+          }
+
+          for (const [index, commitment] of value.entries()) {
+            if (commitment.minExposurePercent > commitment.maxExposurePercent) {
+              context.addIssue({
+                path: [index],
+                code: z.ZodIssueCode.custom,
+                message:
+                  'Min exposure percent cannot be greater than max exposure percent',
+              });
+
+              return z.NEVER;
+            }
+          }
 
-      return value;
+          return value;
+        }),
+      approvalModel: z.enum(['Dynamic', 'Fixed']),
+      minApproval: z.number().min(1),
+      maxApproval: z.number().min(1).optional(),
     }),
-    assets: z.array(restakeAssetSchema).transform((value, context) => {
-      if (value.length === 0) {
-        context.addIssue({
+    // [BLUEPRINT_DEPLOY_STEPS.REQUEST_ARGS]: z.object({
+    //   requestArgs: z.array(z.string()).min(1),
+    // }),
+  })
+  .superRefine((schema, ctx) => {
+    const operatorSelectionStep =
+      schema[BLUEPRINT_DEPLOY_STEPS.OPERATOR_SELECTION];
+    const assetConfigurationStep =
+      schema[BLUEPRINT_DEPLOY_STEPS.ASSET_CONFIGURATION];
+
+    if (assetConfigurationStep.approvalModel === 'Dynamic') {
+      // If approval model is dynamic, `maxApproval` is required
+      if (!assetConfigurationStep.maxApproval) {
+        ctx.addIssue({
+          path: [`${BLUEPRINT_DEPLOY_STEPS.ASSET_CONFIGURATION}.maxApproval`],
           code: z.ZodIssueCode.custom,
-          message: 'At least one asset is required',
+          message: 'Max approval is required for dynamic approval model',
         });
 
         return z.NEVER;
       }
 
-      return value;
-    }),
-  }),
-  [BLUEPRINT_DEPLOY_STEPS[2]]: z.object({
-    requestArgs: z.array(z.string()).min(1),
-  }),
-  [BLUEPRINT_DEPLOY_STEPS[3]]: z.object({
-    securityCommitments: z.array(z.string()).transform((value, context) => {
-      if (value.length === 0) {
-        context.addIssue({
+      // `approvalThreshold` must be less than or equal to the number of operators
+      if (
+        assetConfigurationStep.minApproval >
+        operatorSelectionStep.operators.length
+      ) {
+        ctx.addIssue({
+          path: [`${BLUEPRINT_DEPLOY_STEPS.ASSET_CONFIGURATION}.minApproval`],
           code: z.ZodIssueCode.custom,
-          message: 'At least one security commitment is required',
+          message: 'Min approval cannot be greater than number of operators',
         });
 
         return z.NEVER;
       }
 
-      return value;
-    }),
-  }),
-});
+      return schema;
+    }
+  });
 
 export type DeployBlueprintSchema = z.infer<typeof deployBlueprintSchema>;
diff --git a/libs/tangle-shared-ui/src/data/blueprints/useBlueprintRegisteredOperator.ts b/libs/tangle-shared-ui/src/data/blueprints/useBlueprintRegisteredOperator.ts
new file mode 100644
index 000000000..b7350396c
--- /dev/null
+++ b/libs/tangle-shared-ui/src/data/blueprints/useBlueprintRegisteredOperator.ts
@@ -0,0 +1,88 @@
+import { useCallback, useMemo } from 'react';
+import { catchError, map, of } from 'rxjs';
+import useApiRx from '../../hooks/useApiRx';
+import { TangleError, TangleErrorCode } from '../../types/error';
+import { z } from 'zod';
+
+const priceTargetsSchema = z.object({
+  cpu: z.string().default('0'),
+  mem: z.string().default('0'),
+  storageHdd: z.string().default('0'),
+  storageSsd: z.string().default('0'),
+  storageNvme: z.string().default('0'),
+});
+
+const preferencesSchema = z.object({
+  priceTargets: priceTargetsSchema.optional(),
+});
+
+export default function useBlueprintRegisteredOperator(blueprintId?: number) {
+  const { result, ...rest } = useApiRx(
+    useCallback(
+      (apiRx) => {
+        if (apiRx.query.services?.operators === undefined)
+          return new TangleError(TangleErrorCode.FEATURE_NOT_SUPPORTED);
+
+        if (blueprintId === null || blueprintId === undefined) {
+          return of([]);
+        }
+
+        return apiRx.query.services.operators.entries(blueprintId).pipe(
+          map(
+            (entries) => {
+              return entries.map(([storageKey, operatorPrefs]) => {
+                const operatorAccount = storageKey.args[1].toString();
+                const preferences = operatorPrefs.unwrapOrDefault().toHuman();
+                const parsedPreferences =
+                  preferencesSchema.safeParse(preferences);
+                const priceTargets = parsedPreferences.success
+                  ? parsedPreferences.data.priceTargets
+                  : undefined;
+                return {
+                  operatorAccount,
+                  preferences: {
+                    key: preferences.key,
+                    priceTargets: {
+                      cpu: formatLocaleStringToNumber(priceTargets?.cpu || '0'),
+                      mem: formatLocaleStringToNumber(priceTargets?.mem || '0'),
+                      storageHdd: formatLocaleStringToNumber(
+                        priceTargets?.storageHdd || '0',
+                      ),
+                      storageSsd: formatLocaleStringToNumber(
+                        priceTargets?.storageSsd || '0',
+                      ),
+                      storageNvme: formatLocaleStringToNumber(
+                        priceTargets?.storageNvme || '0',
+                      ),
+                    },
+                  },
+                };
+              });
+            },
+            catchError((error) => {
+              console.error(
+                'Error querying services with blueprints by operator:',
+                error,
+              );
+              return of([]);
+            }),
+          ),
+        );
+      },
+      [blueprintId],
+    ),
+  );
+
+  const data = useMemo(() => {
+    return result ?? [];
+  }, [result]);
+
+  return {
+    result: data,
+    ...rest,
+  };
+}
+
+function formatLocaleStringToNumber(value: string) {
+  return Number(value.replace(/,/g, ''));
+}
diff --git a/libs/tangle-shared-ui/src/data/blueprints/utils/type.ts b/libs/tangle-shared-ui/src/data/blueprints/utils/type.ts
index 9cff41818..b6f306836 100644
--- a/libs/tangle-shared-ui/src/data/blueprints/utils/type.ts
+++ b/libs/tangle-shared-ui/src/data/blueprints/utils/type.ts
@@ -56,3 +56,14 @@ export interface ServiceInstance {
   instanceId: number;
   serviceInstance?: ReturnType<typeof toPrimitiveService>;
 }
+
+export type OperatorPreferences = {
+  key: string;
+  priceTargets: {
+    cpu: number;
+    mem: number;
+    storageHdd: number;
+    storageSsd: number;
+    storageNvme: number;
+  };
+};
diff --git a/libs/tangle-shared-ui/src/hooks/useAssetsMetadata.ts b/libs/tangle-shared-ui/src/hooks/useAssetsMetadata.ts
index 881c6142c..e04315153 100644
--- a/libs/tangle-shared-ui/src/hooks/useAssetsMetadata.ts
+++ b/libs/tangle-shared-ui/src/hooks/useAssetsMetadata.ts
@@ -2,44 +2,117 @@ import useApiRx from './useApiRx';
 import { useCallback, useMemo } from 'react';
 import { RestakeAssetId } from '../types';
 import { PrimitiveAssetMetadata } from '../types/restake';
-type useAssetsMetadataProps = RestakeAssetId | RestakeAssetId[];
+import { isEvmAddress } from '@tangle-network/ui-components';
+import fetchErc20TokenMetadata from '../utils/fetchErc20TokenMetadata';
+import useViemPublicClient from './useViemPublicClient';
+import usePromise from './usePromise';
+import { EvmAddress } from '@tangle-network/ui-components/types/address';
+import { map } from 'rxjs';
 
 const useAssetsMetadata = (
-  singleOrMultipleAssetIds: useAssetsMetadataProps,
+  singleOrMultipleAssetIds: RestakeAssetId | RestakeAssetId[],
 ) => {
-  const addresses = useMemo(() => {
+  const viemPublicClient = useViemPublicClient();
+
+  const { evmAssetIds, nativeAssetIds } = useMemo(() => {
+    let assets: RestakeAssetId[] = [];
     if (Array.isArray(singleOrMultipleAssetIds)) {
-      return Array.from(new Set(singleOrMultipleAssetIds));
+      assets = singleOrMultipleAssetIds;
     } else {
-      return [singleOrMultipleAssetIds];
+      assets = [singleOrMultipleAssetIds];
     }
+
+    const { evmAssetIds, nativeAssetIds } = assets.reduce(
+      (acc, assetId) => {
+        if (isEvmAddress(assetId)) {
+          acc.evmAssetIds.push(assetId);
+        } else {
+          acc.nativeAssetIds.push(assetId);
+        }
+
+        return acc;
+      },
+      {
+        evmAssetIds: new Array<EvmAddress>(),
+        nativeAssetIds: new Array<RestakeAssetId>(),
+      },
+    );
+
+    return {
+      evmAssetIds: Array.from(new Set(evmAssetIds)),
+      nativeAssetIds: Array.from(new Set(nativeAssetIds)),
+    };
   }, [singleOrMultipleAssetIds]);
 
-  const { result, ...other } = useApiRx(
+  const { result: evmAssetMetadatas, isLoading: isLoadingEvmAssetMetadatas } =
+    usePromise(
+      useCallback(async () => {
+        if (evmAssetIds === null || viemPublicClient === null) {
+          return null;
+        }
+
+        return await fetchErc20TokenMetadata(viemPublicClient, evmAssetIds);
+      }, [evmAssetIds, viemPublicClient]),
+      null,
+    );
+
+  const {
+    result: nativeAssetMetadatas,
+    isLoading: isLoadingNativeAssetMetadatas,
+  } = useApiRx(
     useCallback(
-      (api) => api.query.assets.metadata.multi(addresses),
-      [addresses],
+      (api) => {
+        return api.query.assets.metadata.multi(nativeAssetIds).pipe(
+          map((assets) => {
+            return assets.map((asset, index) => {
+              if (asset.isEmpty) {
+                return null;
+              }
+
+              return {
+                name: asset.name.toHuman()?.toString() ?? '',
+                symbol: asset.symbol.toHuman()?.toString() ?? '',
+                decimals: asset.decimals.toNumber(),
+                deposit: asset.deposit.toHuman()?.toString(),
+                isFrozen: asset.isFrozen.toHuman(),
+                assetId: nativeAssetIds[index],
+              };
+            });
+          }),
+        );
+      },
+      [nativeAssetIds],
     ),
   );
 
   const assetsMetadata = useMemo(() => {
-    if (result === null) {
-      return [];
+    if (nativeAssetMetadatas === null || evmAssetMetadatas === null) {
+      return undefined;
     }
 
-    return result.map((metadataResult, index) => {
-      if (metadataResult.isEmpty) {
-        return [addresses[index], null] as const;
+    const assetsMetadataMap = new Map<
+      RestakeAssetId,
+      PrimitiveAssetMetadata | null
+    >();
+
+    for (const asset of nativeAssetMetadatas) {
+      if (asset) {
+        const { assetId, ...rest } = asset;
+        assetsMetadataMap.set(assetId, { ...rest });
       }
+    }
+
+    for (const asset of evmAssetMetadatas) {
+      assetsMetadataMap.set(asset.id, asset);
+    }
 
-      return [
-        addresses[index],
-        metadataResult.toHuman() as PrimitiveAssetMetadata,
-      ] as const;
-    });
-  }, [addresses, result]);
+    return assetsMetadataMap;
+  }, [evmAssetMetadatas, nativeAssetMetadatas]);
 
-  return { result: new Map(assetsMetadata), ...other };
+  return {
+    result: assetsMetadata,
+    isLoading: isLoadingEvmAssetMetadatas || isLoadingNativeAssetMetadatas,
+  };
 };
 
 export default useAssetsMetadata;


Reviews:
user: login: yuri-xyz
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T07:25:07Z
user: login: danielbui12
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T08:27:34Z
user: login: danielbui12
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T08:35:45Z
user: login: danielbui12
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T08:45:01Z
user: login: yuri-xyz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T08:54:18Z
user: login: danielbui12
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T08:55:10Z

Review comments:
diff_hunk: @@ -0,0 +1,203 @@
+import { Label, Input } from '@tangle-network/ui-components';
+import { Children, FC, useCallback, useMemo } from 'react';
+import { AssetConfigurationStepProps } from './type';
+import {
+  BLUEPRINT_DEPLOY_STEPS,
+  DeployBlueprintSchema,
+} from '../../../../../utils/validations/deployBlueprint';
+import { InstructionSideCard } from './InstructionSideCard';
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@tangle-network/ui-components/components/select';
+import useAssetsMetadata from '@tangle-network/tangle-shared-ui/hooks/useAssetsMetadata';
+import assertRestakeAssetId from '@tangle-network/tangle-shared-ui/utils/assertRestakeAssetId';
+import { AssetRequirementFormItem } from './components/AssetRequirementFormItem';
+import ErrorMessage from '../../../../../components/ErrorMessage';
+import { OperatorTable } from './components/OperatorTable';
+
+export const AssetConfigurationStep: FC<AssetConfigurationStepProps> = ({
+  errors: globalErrors,
+  setValue,
+  watch,
+}) => {
+  const labelClassName = 'text-mono-200 dark:text-mono-0 font-normal';
+
+  const stepKey = BLUEPRINT_DEPLOY_STEPS[2];
+  const operatorsStepKey = BLUEPRINT_DEPLOY_STEPS[1];
+  const values = watch(stepKey);
+
+  const errors = globalErrors?.[stepKey];
+
+  const approvalModel = values?.approvalModel;
+  const minApprovalThreshold = values?.minApproval?.toString();
+
+  const selectedAssets = useMemo(
+    () =>
+      (watch(`${BLUEPRINT_DEPLOY_STEPS[1]}.assets`) ?? []).map((asset) => ({
+        ...asset,
+        id: assertRestakeAssetId(asset.id),
+      })),
+    [watch(`${BLUEPRINT_DEPLOY_STEPS[1]}.assets`)],
+  );
+
+  const selectedOperators =
+    watch(`${BLUEPRINT_DEPLOY_STEPS[1]}.operators`) ?? [];
path: apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/AssetConfigurationStep.tsx
user: login: yuri-xyz
body: This might need a useMemo since a new array is being created
created_at: 2025-04-07T07:08:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -438,16 +250,37 @@ export const SelectOperatorsStep: FC<SelectOperatorsStepProps> = ({
           />
         </div>
       </div>
-      <Table
-        variant={TableVariant.GLASS_OUTER}
-        isPaginated
-        tableProps={table}
-        trClassName="group overflow-hidden"
+
+      <OperatorTable
+        enableRowSelection={true}
+        onRowSelectionChange={(onChange) => {
+          setRowSelection(onChange);
+        }}
+        initialState={{
+          sorting: [
+            {
+              id: 'vaultTokensInUsd',
+              desc: true,
+            },
+            {
+              id: 'instanceCount',
+              desc: true,
+            },
+          ],
+        }}
+        state={{
+          rowSelection,
+          columnFilters: [
+            {
+              id: 'address',
+              value: searchQuery,
+            },
+          ],
+        }}
path: apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx
user: login: yuri-xyz
body: Might be a good idea to place these objects in a useMemo instead of directly as params, otherwise will cause redundant re-renders
created_at: 2025-04-07T07:10:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -438,16 +250,37 @@ export const SelectOperatorsStep: FC<SelectOperatorsStepProps> = ({
           />
         </div>
       </div>
-      <Table
-        variant={TableVariant.GLASS_OUTER}
-        isPaginated
-        tableProps={table}
-        trClassName="group overflow-hidden"
+
+      <OperatorTable
+        enableRowSelection={true}
+        onRowSelectionChange={(onChange) => {
+          setRowSelection(onChange);
+        }}
+        initialState={{
+          sorting: [
+            {
+              id: 'vaultTokensInUsd',
+              desc: true,
+            },
+            {
+              id: 'instanceCount',
+              desc: true,
+            },
+          ],
+        }}
+        state={{
+          rowSelection,
+          columnFilters: [
+            {
+              id: 'address',
+              value: searchQuery,
+            },
+          ],
+        }}
+        advanceFilter={advanceFilter}
       />
 
-      {errors?.operators && (
-        <ErrorMessage>{errors.operators.message}</ErrorMessage>
-      )}
+      <ErrorMessage>{errors?.operators?.message}</ErrorMessage>
path: apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx
user: login: yuri-xyz
body: Something to keep in mind: If we always show the error message component (but it's shown as an empty string if it's not present), then it might cause issues with styling since the element is physically there but not visible. Ideally we should check if the error message is set and then conditionally render the error message component.
created_at: 2025-04-07T07:11:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,304 @@
+import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore';
+import useOperatorsServices from '@tangle-network/tangle-shared-ui/data/blueprints/useOperatorsServices';
+import useRestakeOperatorMap from '@tangle-network/tangle-shared-ui/data/restake/useRestakeOperatorMap';
+import useIdentities from '@tangle-network/tangle-shared-ui/hooks/useIdentities';
+import {
+  assertSubstrateAddress,
+  Avatar,
+  CheckBox,
+  EMPTY_VALUE_PLACEHOLDER,
+  EnergyChipColors,
+  EnergyChipStack,
+  ExternalLinkIcon,
+  fuzzyFilter,
+  getRoundedAmountString,
+  KeyValueWithButton,
+  Table,
+  Typography,
+} from '@tangle-network/ui-components';
+import { FC, useMemo } from 'react';
+import { OperatorSelectionTable } from '../type';
+import safeFormatUnits from '@tangle-network/tangle-shared-ui/utils/safeFormatUnits';
+import useRestakeAssets from '@tangle-network/tangle-shared-ui/data/restake/useRestakeAssets';
+import delegationsToVaultTokens from '@tangle-network/tangle-shared-ui/utils/restake/delegationsToVaultTokens';
+import {
+  createColumnHelper,
+  getFilteredRowModel,
+  getSortedRowModel,
+  getCoreRowModel,
+  useReactTable,
+  getPaginationRowModel,
+  TableOptions,
+} from '@tanstack/react-table';
+import { sortByAddressOrIdentity } from '@tangle-network/tangle-shared-ui/components/tables/utils';
+import TableCellWrapper from '@tangle-network/tangle-shared-ui/components/tables/TableCellWrapper';
+import VaultsDropdown from '@tangle-network/tangle-shared-ui/components/tables/Operators/VaultsDropdown';
+import { TableVariant } from '@tangle-network/ui-components/components/Table/types';
+import useBlueprintRegisteredOperator from '@tangle-network/tangle-shared-ui/data/blueprints/useBlueprintRegisteredOperator';
+import { useParams } from 'react-router';
+import { getOperatorPricing } from '../../../../../../utils';
+
+const COLUMN_HELPER = createColumnHelper<OperatorSelectionTable>();
+
+type Props = Omit<
+  TableOptions<OperatorSelectionTable>,
+  'data' | 'columns' | 'getCoreRowModel'
+> & {
+  advanceFilter?: (row: OperatorSelectionTable) => boolean;
+};
+
+export const OperatorTable: FC<Props> = ({ advanceFilter, ...tableProps }) => {
+  const { id: _blueprintId } = useParams();
+  const blueprintId = Number(_blueprintId);
+
+  const { assets } = useRestakeAssets();
+
+  const { operatorMap: restakeOperatorMap } = useRestakeOperatorMap();
+
+  const { result: _registeredOperators } =
path: apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/OperatorTable.tsx
user: login: yuri-xyz
body: To avoid ignoring Prettier/ESLint warnings:

```suggestion
  const { result: registeredOperators_ } =
```
created_at: 2025-04-07T07:12:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,304 @@
+import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore';
+import useOperatorsServices from '@tangle-network/tangle-shared-ui/data/blueprints/useOperatorsServices';
+import useRestakeOperatorMap from '@tangle-network/tangle-shared-ui/data/restake/useRestakeOperatorMap';
+import useIdentities from '@tangle-network/tangle-shared-ui/hooks/useIdentities';
+import {
+  assertSubstrateAddress,
+  Avatar,
+  CheckBox,
+  EMPTY_VALUE_PLACEHOLDER,
+  EnergyChipColors,
+  EnergyChipStack,
+  ExternalLinkIcon,
+  fuzzyFilter,
+  getRoundedAmountString,
+  KeyValueWithButton,
+  Table,
+  Typography,
+} from '@tangle-network/ui-components';
+import { FC, useMemo } from 'react';
+import { OperatorSelectionTable } from '../type';
+import safeFormatUnits from '@tangle-network/tangle-shared-ui/utils/safeFormatUnits';
+import useRestakeAssets from '@tangle-network/tangle-shared-ui/data/restake/useRestakeAssets';
+import delegationsToVaultTokens from '@tangle-network/tangle-shared-ui/utils/restake/delegationsToVaultTokens';
+import {
+  createColumnHelper,
+  getFilteredRowModel,
+  getSortedRowModel,
+  getCoreRowModel,
+  useReactTable,
+  getPaginationRowModel,
+  TableOptions,
+} from '@tanstack/react-table';
+import { sortByAddressOrIdentity } from '@tangle-network/tangle-shared-ui/components/tables/utils';
+import TableCellWrapper from '@tangle-network/tangle-shared-ui/components/tables/TableCellWrapper';
+import VaultsDropdown from '@tangle-network/tangle-shared-ui/components/tables/Operators/VaultsDropdown';
+import { TableVariant } from '@tangle-network/ui-components/components/Table/types';
+import useBlueprintRegisteredOperator from '@tangle-network/tangle-shared-ui/data/blueprints/useBlueprintRegisteredOperator';
+import { useParams } from 'react-router';
+import { getOperatorPricing } from '../../../../../../utils';
+
+const COLUMN_HELPER = createColumnHelper<OperatorSelectionTable>();
+
+type Props = Omit<
+  TableOptions<OperatorSelectionTable>,
+  'data' | 'columns' | 'getCoreRowModel'
+> & {
+  advanceFilter?: (row: OperatorSelectionTable) => boolean;
+};
+
+export const OperatorTable: FC<Props> = ({ advanceFilter, ...tableProps }) => {
+  const { id: _blueprintId } = useParams();
+  const blueprintId = Number(_blueprintId);
+
+  const { assets } = useRestakeAssets();
+
+  const { operatorMap: restakeOperatorMap } = useRestakeOperatorMap();
+
+  const { result: _registeredOperators } =
+    useBlueprintRegisteredOperator(blueprintId);
+
+  const registeredOperators = useMemo(() => {
+    return new Map(
+      _registeredOperators.map((operator) => [
+        operator.operatorAccount,
+        operator,
+      ]),
+    );
+  }, [_registeredOperators]);
+  const operatorAddresses = useMemo(
+    () =>
+      _registeredOperators.map((operator) =>
+        assertSubstrateAddress(operator.operatorAccount),
+      ),
+    [_registeredOperators],
+  );
+
+  const { result: operatorServicesMap } =
+    useOperatorsServices(operatorAddresses);
+  const { result: identities } = useIdentities(operatorAddresses);
+
+  const activeNetwork = useNetworkStore().network;
+
+  // TODO: fetch and combine with registered operators
+  const operators = useMemo<OperatorSelectionTable[]>(() => {
+    const filteredRestakeOperators = Object.entries(restakeOperatorMap).filter(
+      ([address]) => registeredOperators.has(address),
+    );
+    return filteredRestakeOperators.map(
+      ([addressString, { delegations, restakersCount }]) => {
+        const address = assertSubstrateAddress(addressString);
+        const operatorPreferences = registeredOperators.get(address);
+        // @dev this case should not happen because we filter the operators in the `restakeOperatorMap`
+        if (!operatorPreferences) throw new Error('Operator not found');
+        const registeredData = operatorPreferences.preferences;
+
+        return {
+          address,
+          identityName: identities.get(address)?.name ?? undefined,
+          restakersCount,
+          vaultTokensInUsd: delegations.reduce((acc, curr) => {
+            const asset = assets?.get(curr.assetId);
+            if (!asset) {
+              return acc;
+            }
+            const parsed = safeFormatUnits(
+              curr.amount,
+              asset.metadata.decimals,
+            );
+
+            if (parsed.success === false) {
+              return acc;
+            }
+            const currPrice =
+              Number(parsed.value) * (asset.metadata.priceInUsd ?? 0);
+            return acc + currPrice;
+          }, 0),
+          vaultTokens:
+            assets === null
+              ? []
+              : delegationsToVaultTokens(delegations, assets),
+          instanceCount: operatorServicesMap.get(address)?.length ?? 0,
+          // TODO: using graphql with im online pallet to get uptime of operator
+          uptime: Math.round(Math.random() * 100),
+          pricing: getOperatorPricing(registeredData.priceTargets),
+        };
+      },
+    );
+  }, [operatorServicesMap, restakeOperatorMap, identities, assets]);
+
+  const columns = [
+    COLUMN_HELPER.accessor('address', {
+      header: () => 'Identity',
+      sortingFn: sortByAddressOrIdentity<OperatorSelectionTable>(),
+      cell: (props) => {
+        const { address, identityName: identity } = props.row.original;
+
+        const accountUrl = activeNetwork.createExplorerAccountUrl(address);
+
+        return (
+          <TableCellWrapper className="pl-3 min-h-fit">
+            <div className="flex items-center flex-1 gap-2 pr-3">
+              {tableProps.enableRowSelection && (
+                <CheckBox
+                  wrapperClassName="!block !min-h-auto cursor-pointer"
+                  className="cursor-pointer"
+                  isChecked={props.row.getIsSelected()}
+                  onChange={props.row.getToggleSelectedHandler()}
+                />
+              )}
+
+              <Avatar
+                sourceVariant="address"
+                value={address}
+                theme="substrate"
+                size="md"
+              />
+
+              <div className="flex items-center">
+                <KeyValueWithButton
+                  keyValue={identity ? identity : address}
+                  size="sm"
+                />
+                {accountUrl && (
+                  <ExternalLinkIcon
+                    className="ml-1"
+                    href={accountUrl}
+                    target="_blank"
+                  />
+                )}
+              </div>
+            </div>
+          </TableCellWrapper>
+        );
+      },
+    }),
+    COLUMN_HELPER.accessor('pricing', {
+      header: () => 'Pricing',
+      cell: (props) => {
+        return (
+          <TableCellWrapper className="pl-3 min-h-fit">
+            <Typography variant="body1">
+              {`$${getRoundedAmountString(props.row.original.pricing)}`}
+            </Typography>
+          </TableCellWrapper>
+        );
+      },
+    }),
+    COLUMN_HELPER.accessor('instanceCount', {
+      header: () => 'Instance Count',
+      sortingFn: sortByAddressOrIdentity<OperatorSelectionTable>(),
+      cell: (props) => {
+        return (
+          <TableCellWrapper className="pl-3 min-h-fit">
+            <Typography variant="body1">
+              {props.row.original.instanceCount}
+            </Typography>
+          </TableCellWrapper>
+        );
+      },
+    }),
+    COLUMN_HELPER.accessor('restakersCount', {
+      header: () => 'Restakers',
+      cell: (props) => {
+        return (
+          <TableCellWrapper className="pl-3 min-h-fit">
+            <Typography variant="body1">
+              {props.row.original.restakersCount}
+            </Typography>
+          </TableCellWrapper>
+        );
+      },
+    }),
+    COLUMN_HELPER.accessor('uptime', {
+      header: () => 'Uptime',
+      cell: (props) => {
+        const DEFAULT_STACK = 10;
+        const DEFAULT_PERCENTAGE = 100;
+        const numberOfActiveChips = !props.row.original.uptime
+          ? 0
+          : Math.round(
+              (props.row.original.uptime * DEFAULT_STACK) / DEFAULT_PERCENTAGE,
+            );
+
+        const activeColors = Array.from({ length: numberOfActiveChips }).fill(
+          EnergyChipColors.GREEN,
+        );
+        const inactiveColors = Array.from({
+          length: DEFAULT_STACK - numberOfActiveChips,
+        }).fill(EnergyChipColors.GREY);
+        const colors = [...activeColors, ...inactiveColors];
+
+        return (
+          <TableCellWrapper className="pl-3 min-h-fit">
+            <EnergyChipStack
+              colors={colors as EnergyChipColors[]}
path: apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/OperatorTable.tsx
user: login: yuri-xyz
body: Any way to avoid forced type casting here?
created_at: 2025-04-07T07:14:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -90,39 +90,37 @@ function ApproveConfirmationModal({
       </ModalHeader>
 
       <ModalBody>
-        <div className="mt-52">
-          <BlueprintItem
-            imgUrl={selectedRequest?.blueprintData?.metadata.logo ?? ''}
-            renderImage={(imageUrl) => {
-              return (
-                <img
-                  src={imageUrl}
-                  alt={selectedRequest?.blueprintData?.metadata.name ?? ''}
-                  className="flex-shrink-0 bg-center rounded-full"
-                />
-              );
-            }}
-            id={selectedRequest?.blueprintData?.metadata.name ?? ''}
-            name={selectedRequest?.blueprintData?.metadata.name ?? ''}
-            restakersCount={selectedRequest?.blueprintData?.restakersCount ?? 0}
-            operatorsCount={selectedRequest?.blueprintData?.operatorsCount ?? 0}
-            tvl={selectedRequest?.blueprintData?.tvl?.toString() ?? '0'}
-            isBoosted={false}
-            category={selectedRequest?.blueprintData?.metadata.category ?? ''}
-            description={
-              selectedRequest?.blueprintData?.metadata.description ?? ''
-            }
-            author={selectedRequest?.blueprintData?.metadata.author ?? ''}
-          />
-        </div>
+        <BlueprintItem
+          imgUrl={selectedRequest?.blueprintData?.metadata.logo ?? ''}
+          renderImage={(imageUrl) => {
+            return (
+              <img
+                src={imageUrl}
+                alt={selectedRequest?.blueprintData?.metadata.name ?? ''}
+                className="flex-shrink-0 bg-center rounded-full"
+              />
+            );
+          }}
+          id={selectedRequest?.blueprintData?.metadata.name ?? ''}
+          name={selectedRequest?.blueprintData?.metadata.name ?? ''}
+          restakersCount={selectedRequest?.blueprintData?.restakersCount ?? 0}
+          operatorsCount={selectedRequest?.blueprintData?.operatorsCount ?? 0}
+          tvl={selectedRequest?.blueprintData?.tvl?.toString() ?? '0'}
+          isBoosted={false}
+          category={selectedRequest?.blueprintData?.metadata.category ?? ''}
+          description={
+            selectedRequest?.blueprintData?.metadata.description ?? ''
+          }
+          author={selectedRequest?.blueprintData?.metadata.author ?? ''}
path: apps/tangle-cloud/src/pages/instances/Instances/UpdateBlueprintModel/ApproveConfirmationModal.tsx
user: login: yuri-xyz
body: If the data is not defined/provided, we should probably do something different than defaulting to an empty string, for example checking above `if (!not defined) return undefined` for the component. The problem is that defaulting to empty strings might cause logic bugs as it may be unexpected by the child component
created_at: 2025-04-07T07:16:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,10 +6,10 @@ import {
 import { z } from 'zod';
 
 export const BLUEPRINT_DEPLOY_STEPS = [
-  'step1',
-  'step2',
-  'step3',
-  'step4',
+  'BasicInfo',
+  'OperatorSelection',
+  'AssetConfiguration',
+  // 'RequestArgs',
path: apps/tangle-cloud/src/utils/validations/deployBlueprint.ts
user: login: yuri-xyz
body: ```suggestion
```
created_at: 2025-04-07T07:17:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -41,76 +41,133 @@ export const restakeAssetSchema = z.object({
   }),
 });
 
-export const deployBlueprintSchema = z.object({
-  [BLUEPRINT_DEPLOY_STEPS[0]]: z.object({
-    instanceName: z.string().min(1),
-    instanceDuration: z.number().min(1),
-    permittedCallers: z.array(z.string()).transform((value, context) => {
-      if (value.length === 0) {
-        context.addIssue({
-          code: z.ZodIssueCode.custom,
-          message: 'At least one caller is required',
-        });
+export const deployBlueprintSchema = z
+  .object({
+    [BLUEPRINT_DEPLOY_STEPS[0]]: z.object({
+      instanceName: z.string().min(1),
+      instanceDuration: z.number().min(1),
+      permittedCallers: z.array(z.string()).transform((value, context) => {
+        if (value.length === 0) {
+          context.addIssue({
+            code: z.ZodIssueCode.custom,
+            message: 'At least one caller is required',
+          });
 
-        return z.NEVER;
-      }
+          return z.NEVER;
+        }
 
-      for (const [index, caller] of value.entries()) {
-        if (!isEvmAddress(caller) && !isSubstrateAddress(caller)) {
+        for (const [index, caller] of value.entries()) {
+          if (!isEvmAddress(caller) && !isSubstrateAddress(caller)) {
+            context.addIssue({
+              path: [index],
+              code: z.ZodIssueCode.custom,
+              message: 'Invalid caller address',
+            });
+          }
+        }
+
+        return value;
+      }),
+    }),
+    [BLUEPRINT_DEPLOY_STEPS[1]]: z.object({
+      operators: z.array(z.string()).transform((value, context) => {
+        if (value.length === 0) {
           context.addIssue({
-            path: [index],
             code: z.ZodIssueCode.custom,
-            message: 'Invalid caller address',
+            message: 'At least one operator is required',
           });
+
+          return z.NEVER;
         }
-      }
 
-      return value;
+        return value;
+      }),
+      assets: z.array(restakeAssetSchema).transform((value, context) => {
+        if (value.length === 0) {
+          context.addIssue({
+            code: z.ZodIssueCode.custom,
+            message: 'At least one asset is required',
+          });
+
+          return z.NEVER;
+        }
+
+        return value;
+      }),
     }),
-  }),
-  [BLUEPRINT_DEPLOY_STEPS[1]]: z.object({
-    operators: z.array(z.string()).transform((value, context) => {
-      if (value.length === 0) {
-        context.addIssue({
-          code: z.ZodIssueCode.custom,
-          message: 'At least one operator is required',
-        });
+    [BLUEPRINT_DEPLOY_STEPS[2]]: z.object({
+      securityCommitments: z
+        .array(
+          z.object({
+            minExposurePercent: z.number().min(1).max(100),
+            maxExposurePercent: z.number().min(1).max(100),
+          }),
+        )
+        .transform((value, context) => {
+          if (value.length === 0) {
+            context.addIssue({
+              code: z.ZodIssueCode.custom,
+              message: 'At least one security commitment is required',
+            });
 
-        return z.NEVER;
-      }
+            return z.NEVER;
+          }
+
+          for (const [index, commitment] of value.entries()) {
+            if (commitment.minExposurePercent > commitment.maxExposurePercent) {
+              context.addIssue({
+                path: [index],
+                code: z.ZodIssueCode.custom,
+                message:
+                  'Min exposure percent cannot be greater than max exposure percent',
+              });
+
+              return z.NEVER;
+            }
+          }
 
-      return value;
+          return value;
+        }),
+      approvalModel: z.enum(['Dynamic', 'Fixed']),
+      minApproval: z.number().min(1),
+      maxApproval: z.number().min(1).optional(),
     }),
-    assets: z.array(restakeAssetSchema).transform((value, context) => {
-      if (value.length === 0) {
-        context.addIssue({
+    // [BLUEPRINT_DEPLOY_STEPS[3]]: z.object({
+    //   requestArgs: z.array(z.string()).min(1),
+    // }),
path: apps/tangle-cloud/src/utils/validations/deployBlueprint.ts
user: login: yuri-xyz
body: ```suggestion
```
created_at: 2025-04-07T07:18:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -41,76 +41,133 @@ export const restakeAssetSchema = z.object({
   }),
 });
 
-export const deployBlueprintSchema = z.object({
-  [BLUEPRINT_DEPLOY_STEPS[0]]: z.object({
-    instanceName: z.string().min(1),
-    instanceDuration: z.number().min(1),
-    permittedCallers: z.array(z.string()).transform((value, context) => {
-      if (value.length === 0) {
-        context.addIssue({
-          code: z.ZodIssueCode.custom,
-          message: 'At least one caller is required',
-        });
+export const deployBlueprintSchema = z
+  .object({
+    [BLUEPRINT_DEPLOY_STEPS[0]]: z.object({
+      instanceName: z.string().min(1),
+      instanceDuration: z.number().min(1),
+      permittedCallers: z.array(z.string()).transform((value, context) => {
+        if (value.length === 0) {
+          context.addIssue({
+            code: z.ZodIssueCode.custom,
+            message: 'At least one caller is required',
+          });
 
-        return z.NEVER;
-      }
+          return z.NEVER;
+        }
 
-      for (const [index, caller] of value.entries()) {
-        if (!isEvmAddress(caller) && !isSubstrateAddress(caller)) {
+        for (const [index, caller] of value.entries()) {
+          if (!isEvmAddress(caller) && !isSubstrateAddress(caller)) {
+            context.addIssue({
+              path: [index],
+              code: z.ZodIssueCode.custom,
+              message: 'Invalid caller address',
+            });
+          }
+        }
+
+        return value;
+      }),
+    }),
+    [BLUEPRINT_DEPLOY_STEPS[1]]: z.object({
+      operators: z.array(z.string()).transform((value, context) => {
+        if (value.length === 0) {
           context.addIssue({
-            path: [index],
             code: z.ZodIssueCode.custom,
-            message: 'Invalid caller address',
+            message: 'At least one operator is required',
           });
+
+          return z.NEVER;
         }
-      }
 
-      return value;
+        return value;
+      }),
+      assets: z.array(restakeAssetSchema).transform((value, context) => {
+        if (value.length === 0) {
+          context.addIssue({
+            code: z.ZodIssueCode.custom,
+            message: 'At least one asset is required',
+          });
+
+          return z.NEVER;
+        }
+
+        return value;
+      }),
     }),
-  }),
-  [BLUEPRINT_DEPLOY_STEPS[1]]: z.object({
-    operators: z.array(z.string()).transform((value, context) => {
-      if (value.length === 0) {
-        context.addIssue({
-          code: z.ZodIssueCode.custom,
-          message: 'At least one operator is required',
-        });
+    [BLUEPRINT_DEPLOY_STEPS[2]]: z.object({
+      securityCommitments: z
+        .array(
+          z.object({
+            minExposurePercent: z.number().min(1).max(100),
+            maxExposurePercent: z.number().min(1).max(100),
+          }),
+        )
+        .transform((value, context) => {
+          if (value.length === 0) {
+            context.addIssue({
+              code: z.ZodIssueCode.custom,
+              message: 'At least one security commitment is required',
+            });
 
-        return z.NEVER;
-      }
+            return z.NEVER;
+          }
+
+          for (const [index, commitment] of value.entries()) {
+            if (commitment.minExposurePercent > commitment.maxExposurePercent) {
+              context.addIssue({
+                path: [index],
+                code: z.ZodIssueCode.custom,
+                message:
+                  'Min exposure percent cannot be greater than max exposure percent',
+              });
+
+              return z.NEVER;
+            }
+          }
 
-      return value;
+          return value;
+        }),
+      approvalModel: z.enum(['Dynamic', 'Fixed']),
+      minApproval: z.number().min(1),
+      maxApproval: z.number().min(1).optional(),
     }),
-    assets: z.array(restakeAssetSchema).transform((value, context) => {
-      if (value.length === 0) {
-        context.addIssue({
+    // [BLUEPRINT_DEPLOY_STEPS[3]]: z.object({
+    //   requestArgs: z.array(z.string()).min(1),
+    // }),
+  })
+  .superRefine((schema, ctx) => {
+    const operatorSelectionStep = schema[BLUEPRINT_DEPLOY_STEPS[1]];
+    const assetConfigurationStep = schema[BLUEPRINT_DEPLOY_STEPS[2]];
+
+    if (assetConfigurationStep.approvalModel === 'Dynamic') {
+      // If approval model is dynamic, `maxApproval` is required
+      if (!assetConfigurationStep.maxApproval) {
+        ctx.addIssue({
+          path: [`${BLUEPRINT_DEPLOY_STEPS[2]}.maxApproval`],
           code: z.ZodIssueCode.custom,
-          message: 'At least one asset is required',
+          message: 'Max approval is required for dynamic approval model',
path: apps/tangle-cloud/src/utils/validations/deployBlueprint.ts
user: login: yuri-xyz
body: Hmm I think this message can be improved, I don't quite understand what it means compared to the previous one
created_at: 2025-04-07T07:19:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,3 +56,14 @@ export interface ServiceInstance {
   instanceId: number;
   serviceInstance?: ReturnType<typeof toPrimitiveService>;
 }
+
+export type OperatorPreferences = {
+  key: string;
path: libs/tangle-shared-ui/src/data/blueprints/utils/type.ts
user: login: yuri-xyz
body: What is the key? Should this be a `SubstrateAddress` type?
created_at: 2025-04-07T07:19:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,44 +2,119 @@ import useApiRx from './useApiRx';
 import { useCallback, useMemo } from 'react';
 import { RestakeAssetId } from '../types';
 import { PrimitiveAssetMetadata } from '../types/restake';
+import { isEvmAddress } from '@tangle-network/ui-components';
+import fetchErc20TokenMetadata from '../utils/fetchErc20TokenMetadata';
+import useViemPublicClient from './useViemPublicClient';
+import usePromise from './usePromise';
+import { EvmAddress } from '@tangle-network/ui-components/types/address';
+import { map } from 'rxjs';
+
 type useAssetsMetadataProps = RestakeAssetId | RestakeAssetId[];
 
 const useAssetsMetadata = (
   singleOrMultipleAssetIds: useAssetsMetadataProps,
 ) => {
-  const addresses = useMemo(() => {
+  const viemPublicClient = useViemPublicClient();
+
+  const { evmAssetIds, nativeAssetIds } = useMemo(() => {
+    let assets: RestakeAssetId[] = [];
     if (Array.isArray(singleOrMultipleAssetIds)) {
-      return Array.from(new Set(singleOrMultipleAssetIds));
+      assets = singleOrMultipleAssetIds;
     } else {
-      return [singleOrMultipleAssetIds];
+      assets = [singleOrMultipleAssetIds];
     }
+
+    const { evmAssetIds, nativeAssetIds } = assets.reduce(
+      (acc, assetId) => {
+        if (isEvmAddress(assetId)) {
+          acc.evmAssetIds.push(assetId);
+        } else {
+          acc.nativeAssetIds.push(assetId);
+        }
+
+        return acc;
+      },
+      {
+        evmAssetIds: [] as EvmAddress[],
+        nativeAssetIds: [] as RestakeAssetId[],
path: libs/tangle-shared-ui/src/hooks/useAssetsMetadata.ts
user: login: yuri-xyz
body: ```suggestion
        evmAssetIds: [] satisfies EvmAddress[],
        nativeAssetIds: [] satisfies RestakeAssetId[],
```
created_at: 2025-04-07T07:20:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,44 +2,119 @@ import useApiRx from './useApiRx';
 import { useCallback, useMemo } from 'react';
 import { RestakeAssetId } from '../types';
 import { PrimitiveAssetMetadata } from '../types/restake';
+import { isEvmAddress } from '@tangle-network/ui-components';
+import fetchErc20TokenMetadata from '../utils/fetchErc20TokenMetadata';
+import useViemPublicClient from './useViemPublicClient';
+import usePromise from './usePromise';
+import { EvmAddress } from '@tangle-network/ui-components/types/address';
+import { map } from 'rxjs';
+
 type useAssetsMetadataProps = RestakeAssetId | RestakeAssetId[];
 
 const useAssetsMetadata = (
   singleOrMultipleAssetIds: useAssetsMetadataProps,
 ) => {
path: libs/tangle-shared-ui/src/hooks/useAssetsMetadata.ts
user: login: yuri-xyz
body: ```suggestion
const useAssetsMetadata = (
  singleOrMultipleAssetIds: RestakeAssetId | RestakeAssetId[],
) => {
```
created_at: 2025-04-07T07:21:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,44 +2,119 @@ import useApiRx from './useApiRx';
 import { useCallback, useMemo } from 'react';
 import { RestakeAssetId } from '../types';
 import { PrimitiveAssetMetadata } from '../types/restake';
+import { isEvmAddress } from '@tangle-network/ui-components';
+import fetchErc20TokenMetadata from '../utils/fetchErc20TokenMetadata';
+import useViemPublicClient from './useViemPublicClient';
+import usePromise from './usePromise';
+import { EvmAddress } from '@tangle-network/ui-components/types/address';
+import { map } from 'rxjs';
+
 type useAssetsMetadataProps = RestakeAssetId | RestakeAssetId[];
 
 const useAssetsMetadata = (
   singleOrMultipleAssetIds: useAssetsMetadataProps,
 ) => {
-  const addresses = useMemo(() => {
+  const viemPublicClient = useViemPublicClient();
+
+  const { evmAssetIds, nativeAssetIds } = useMemo(() => {
+    let assets: RestakeAssetId[] = [];
     if (Array.isArray(singleOrMultipleAssetIds)) {
-      return Array.from(new Set(singleOrMultipleAssetIds));
+      assets = singleOrMultipleAssetIds;
     } else {
-      return [singleOrMultipleAssetIds];
+      assets = [singleOrMultipleAssetIds];
     }
+
+    const { evmAssetIds, nativeAssetIds } = assets.reduce(
+      (acc, assetId) => {
+        if (isEvmAddress(assetId)) {
+          acc.evmAssetIds.push(assetId);
+        } else {
+          acc.nativeAssetIds.push(assetId);
+        }
+
+        return acc;
+      },
+      {
+        evmAssetIds: [] as EvmAddress[],
+        nativeAssetIds: [] as RestakeAssetId[],
+      },
+    );
+
+    return {
+      evmAssetIds: Array.from(new Set(evmAssetIds)),
+      nativeAssetIds: Array.from(new Set(nativeAssetIds)),
+    };
   }, [singleOrMultipleAssetIds]);
 
-  const { result, ...other } = useApiRx(
+  const { result: evmAssetMetadatas, isLoading: isLoadingEvmAssetMetadatas } =
+    usePromise(
+      useCallback(async () => {
+        if (evmAssetIds === null || viemPublicClient === null) {
+          return null;
+        }
+
+        return await fetchErc20TokenMetadata(viemPublicClient, evmAssetIds);
+      }, [evmAssetIds, viemPublicClient]),
+      null,
+    );
+
+  const {
+    result: nativeAssetMetadatas,
+    isLoading: isLoadingNativeAssetMetadatas,
+  } = useApiRx(
     useCallback(
-      (api) => api.query.assets.metadata.multi(addresses),
-      [addresses],
+      (api) => {
+        return api.query.assets.metadata.multi(nativeAssetIds).pipe(
+          map((assets) => {
+            return assets.map((asset, index) => {
+              if (asset.isEmpty) {
+                return null;
+              }
+
+              return {
+                name: asset.name.toHuman()?.toString() ?? '',
+                symbol: asset.symbol.toHuman()?.toString() ?? '',
+                decimals: asset.decimals.toNumber(),
+                deposit: asset.deposit.toHuman()?.toString() ?? '',
+                isFrozen: asset.isFrozen.toHuman() ?? false,
+                assetId: nativeAssetIds[index],
path: libs/tangle-shared-ui/src/hooks/useAssetsMetadata.ts
user: login: yuri-xyz
body: Instead of defaulting to empty string we should probably keep these props as `undefined` if they are undefined
created_at: 2025-04-07T07:22:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,44 +2,119 @@ import useApiRx from './useApiRx';
 import { useCallback, useMemo } from 'react';
 import { RestakeAssetId } from '../types';
 import { PrimitiveAssetMetadata } from '../types/restake';
+import { isEvmAddress } from '@tangle-network/ui-components';
+import fetchErc20TokenMetadata from '../utils/fetchErc20TokenMetadata';
+import useViemPublicClient from './useViemPublicClient';
+import usePromise from './usePromise';
+import { EvmAddress } from '@tangle-network/ui-components/types/address';
+import { map } from 'rxjs';
+
 type useAssetsMetadataProps = RestakeAssetId | RestakeAssetId[];
 
 const useAssetsMetadata = (
   singleOrMultipleAssetIds: useAssetsMetadataProps,
 ) => {
-  const addresses = useMemo(() => {
+  const viemPublicClient = useViemPublicClient();
+
+  const { evmAssetIds, nativeAssetIds } = useMemo(() => {
+    let assets: RestakeAssetId[] = [];
     if (Array.isArray(singleOrMultipleAssetIds)) {
-      return Array.from(new Set(singleOrMultipleAssetIds));
+      assets = singleOrMultipleAssetIds;
     } else {
-      return [singleOrMultipleAssetIds];
+      assets = [singleOrMultipleAssetIds];
     }
+
+    const { evmAssetIds, nativeAssetIds } = assets.reduce(
+      (acc, assetId) => {
+        if (isEvmAddress(assetId)) {
+          acc.evmAssetIds.push(assetId);
+        } else {
+          acc.nativeAssetIds.push(assetId);
+        }
+
+        return acc;
+      },
+      {
+        evmAssetIds: [] as EvmAddress[],
+        nativeAssetIds: [] as RestakeAssetId[],
+      },
+    );
+
+    return {
+      evmAssetIds: Array.from(new Set(evmAssetIds)),
+      nativeAssetIds: Array.from(new Set(nativeAssetIds)),
+    };
   }, [singleOrMultipleAssetIds]);
 
-  const { result, ...other } = useApiRx(
+  const { result: evmAssetMetadatas, isLoading: isLoadingEvmAssetMetadatas } =
+    usePromise(
+      useCallback(async () => {
+        if (evmAssetIds === null || viemPublicClient === null) {
+          return null;
+        }
+
+        return await fetchErc20TokenMetadata(viemPublicClient, evmAssetIds);
+      }, [evmAssetIds, viemPublicClient]),
+      null,
+    );
+
+  const {
+    result: nativeAssetMetadatas,
+    isLoading: isLoadingNativeAssetMetadatas,
+  } = useApiRx(
     useCallback(
-      (api) => api.query.assets.metadata.multi(addresses),
-      [addresses],
+      (api) => {
+        return api.query.assets.metadata.multi(nativeAssetIds).pipe(
+          map((assets) => {
+            return assets.map((asset, index) => {
+              if (asset.isEmpty) {
+                return null;
+              }
+
+              return {
+                name: asset.name.toHuman()?.toString() ?? '',
+                symbol: asset.symbol.toHuman()?.toString() ?? '',
+                decimals: asset.decimals.toNumber(),
+                deposit: asset.deposit.toHuman()?.toString() ?? '',
+                isFrozen: asset.isFrozen.toHuman() ?? false,
+                assetId: nativeAssetIds[index],
path: libs/tangle-shared-ui/src/hooks/useAssetsMetadata.ts
user: login: yuri-xyz
body: Or null
created_at: 2025-04-07T07:22:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,79 @@
+import { useCallback, useMemo } from 'react';
+import { catchError, map, of } from 'rxjs';
+import useApiRx from '../../hooks/useApiRx';
+import { TangleError, TangleErrorCode } from '../../types/error';
+
+export default function useBlueprintRegisteredOperator(blueprintId?: number) {
+  const { result, ...rest } = useApiRx(
+    useCallback(
+      (apiRx) => {
+        if (apiRx.query.services?.operators === undefined)
+          return new TangleError(TangleErrorCode.FEATURE_NOT_SUPPORTED);
+
+        if (blueprintId === null || blueprintId === undefined) {
+          return of([]);
+        }
+
+        return apiRx.query.services.operators.entries(blueprintId).pipe(
+          map(
+            (entries) => {
+              return entries.map(([storageKey, operatorPrefs]) => {
+                const operatorAccount = storageKey.args[1].toString();
+                const preferences = operatorPrefs.unwrapOrDefault().toHuman();
+                return {
+                  operatorAccount,
+                  preferences: {
+                    key: preferences.key,
+                    priceTargets: {
+                      cpu: formatLocaleStringToNumber(
+                        // @ts-expect-error Property 'cpu' does not exist on type AnyJson[]
+                        preferences?.priceTargets?.cpu || '0',
+                      ),
+                      mem: formatLocaleStringToNumber(
+                        // @ts-expect-error Property 'mem' does not exist on type AnyJson[]
+                        preferences?.priceTargets?.mem || '0',
+                      ),
+                      storageHdd: formatLocaleStringToNumber(
+                        // @ts-expect-error Property 'storageHdd' does not exist on type AnyJson[]
+                        preferences?.priceTargets?.storageHdd || '0',
+                      ),
+                      storageSsd: formatLocaleStringToNumber(
+                        // @ts-expect-error Property 'storageSsd' does not exist on type AnyJson[]
+                        preferences?.priceTargets?.storageSsd || '0',
+                      ),
+                      storageNvme: formatLocaleStringToNumber(
+                        // @ts-expect-error Property 'storageNvme' does not exist on type AnyJson[]
+                        preferences?.priceTargets?.storageNvme || '0',
+                      ),
path: libs/tangle-shared-ui/src/data/blueprints/useBlueprintRegisteredOperator.ts
user: login: yuri-xyz
body: Could Zod be used here?
created_at: 2025-04-07T07:24:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,203 @@
+import { Label, Input } from '@tangle-network/ui-components';
+import { Children, FC, useCallback, useMemo } from 'react';
+import { AssetConfigurationStepProps } from './type';
+import {
+  BLUEPRINT_DEPLOY_STEPS,
+  DeployBlueprintSchema,
+} from '../../../../../utils/validations/deployBlueprint';
+import { InstructionSideCard } from './InstructionSideCard';
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@tangle-network/ui-components/components/select';
+import useAssetsMetadata from '@tangle-network/tangle-shared-ui/hooks/useAssetsMetadata';
+import assertRestakeAssetId from '@tangle-network/tangle-shared-ui/utils/assertRestakeAssetId';
+import { AssetRequirementFormItem } from './components/AssetRequirementFormItem';
+import ErrorMessage from '../../../../../components/ErrorMessage';
+import { OperatorTable } from './components/OperatorTable';
+
+export const AssetConfigurationStep: FC<AssetConfigurationStepProps> = ({
+  errors: globalErrors,
+  setValue,
+  watch,
+}) => {
+  const labelClassName = 'text-mono-200 dark:text-mono-0 font-normal';
+
+  const stepKey = BLUEPRINT_DEPLOY_STEPS[2];
+  const operatorsStepKey = BLUEPRINT_DEPLOY_STEPS[1];
+  const values = watch(stepKey);
+
+  const errors = globalErrors?.[stepKey];
+
+  const approvalModel = values?.approvalModel;
+  const minApprovalThreshold = values?.minApproval?.toString();
+
+  const selectedAssets = useMemo(
+    () =>
+      (watch(`${BLUEPRINT_DEPLOY_STEPS[1]}.assets`) ?? []).map((asset) => ({
+        ...asset,
+        id: assertRestakeAssetId(asset.id),
+      })),
+    [watch(`${BLUEPRINT_DEPLOY_STEPS[1]}.assets`)],
+  );
+
+  const selectedOperators =
+    watch(`${BLUEPRINT_DEPLOY_STEPS[1]}.operators`) ?? [];
path: apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/AssetConfigurationStep.tsx
user: login: danielbui12
body: sovled by using `useWatch` hook from react-hook-form
created_at: 2025-04-07T08:27:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -438,16 +250,37 @@ export const SelectOperatorsStep: FC<SelectOperatorsStepProps> = ({
           />
         </div>
       </div>
-      <Table
-        variant={TableVariant.GLASS_OUTER}
-        isPaginated
-        tableProps={table}
-        trClassName="group overflow-hidden"
+
+      <OperatorTable
+        enableRowSelection={true}
+        onRowSelectionChange={(onChange) => {
+          setRowSelection(onChange);
+        }}
+        initialState={{
+          sorting: [
+            {
+              id: 'vaultTokensInUsd',
+              desc: true,
+            },
+            {
+              id: 'instanceCount',
+              desc: true,
+            },
+          ],
+        }}
+        state={{
+          rowSelection,
+          columnFilters: [
+            {
+              id: 'address',
+              value: searchQuery,
+            },
+          ],
+        }}
path: apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx
user: login: danielbui12
body: wrapping these objects in `useMemo` would add unnecessary complexity without providing meaningful performance benefits, I believe.  we should use `useMemo` when the logics are complex, or the value is used in dependency arrays of other hooks
created_at: 2025-04-07T08:35:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,3 +56,14 @@ export interface ServiceInstance {
   instanceId: number;
   serviceInstance?: ReturnType<typeof toPrimitiveService>;
 }
+
+export type OperatorPreferences = {
+  key: string;
path: libs/tangle-shared-ui/src/data/blueprints/utils/type.ts
user: login: danielbui12
body: nope, it is ECDSA key, [u8;65] in substrate type
created_at: 2025-04-07T08:45:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -438,16 +250,37 @@ export const SelectOperatorsStep: FC<SelectOperatorsStepProps> = ({
           />
         </div>
       </div>
-      <Table
-        variant={TableVariant.GLASS_OUTER}
-        isPaginated
-        tableProps={table}
-        trClassName="group overflow-hidden"
+
+      <OperatorTable
+        enableRowSelection={true}
+        onRowSelectionChange={(onChange) => {
+          setRowSelection(onChange);
+        }}
+        initialState={{
+          sorting: [
+            {
+              id: 'vaultTokensInUsd',
+              desc: true,
+            },
+            {
+              id: 'instanceCount',
+              desc: true,
+            },
+          ],
+        }}
+        state={{
+          rowSelection,
+          columnFilters: [
+            {
+              id: 'address',
+              value: searchQuery,
+            },
+          ],
+        }}
path: apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx
user: login: yuri-xyz
body: Agreed 👍🏼 
created_at: 2025-04-07T08:54:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,44 +2,119 @@ import useApiRx from './useApiRx';
 import { useCallback, useMemo } from 'react';
 import { RestakeAssetId } from '../types';
 import { PrimitiveAssetMetadata } from '../types/restake';
+import { isEvmAddress } from '@tangle-network/ui-components';
+import fetchErc20TokenMetadata from '../utils/fetchErc20TokenMetadata';
+import useViemPublicClient from './useViemPublicClient';
+import usePromise from './usePromise';
+import { EvmAddress } from '@tangle-network/ui-components/types/address';
+import { map } from 'rxjs';
+
 type useAssetsMetadataProps = RestakeAssetId | RestakeAssetId[];
 
 const useAssetsMetadata = (
   singleOrMultipleAssetIds: useAssetsMetadataProps,
 ) => {
-  const addresses = useMemo(() => {
+  const viemPublicClient = useViemPublicClient();
+
+  const { evmAssetIds, nativeAssetIds } = useMemo(() => {
+    let assets: RestakeAssetId[] = [];
     if (Array.isArray(singleOrMultipleAssetIds)) {
-      return Array.from(new Set(singleOrMultipleAssetIds));
+      assets = singleOrMultipleAssetIds;
     } else {
-      return [singleOrMultipleAssetIds];
+      assets = [singleOrMultipleAssetIds];
     }
+
+    const { evmAssetIds, nativeAssetIds } = assets.reduce(
+      (acc, assetId) => {
+        if (isEvmAddress(assetId)) {
+          acc.evmAssetIds.push(assetId);
+        } else {
+          acc.nativeAssetIds.push(assetId);
+        }
+
+        return acc;
+      },
+      {
+        evmAssetIds: [] as EvmAddress[],
+        nativeAssetIds: [] as RestakeAssetId[],
+      },
+    );
+
+    return {
+      evmAssetIds: Array.from(new Set(evmAssetIds)),
+      nativeAssetIds: Array.from(new Set(nativeAssetIds)),
+    };
   }, [singleOrMultipleAssetIds]);
 
-  const { result, ...other } = useApiRx(
+  const { result: evmAssetMetadatas, isLoading: isLoadingEvmAssetMetadatas } =
+    usePromise(
+      useCallback(async () => {
+        if (evmAssetIds === null || viemPublicClient === null) {
+          return null;
+        }
+
+        return await fetchErc20TokenMetadata(viemPublicClient, evmAssetIds);
+      }, [evmAssetIds, viemPublicClient]),
+      null,
+    );
+
+  const {
+    result: nativeAssetMetadatas,
+    isLoading: isLoadingNativeAssetMetadatas,
+  } = useApiRx(
     useCallback(
-      (api) => api.query.assets.metadata.multi(addresses),
-      [addresses],
+      (api) => {
+        return api.query.assets.metadata.multi(nativeAssetIds).pipe(
+          map((assets) => {
+            return assets.map((asset, index) => {
+              if (asset.isEmpty) {
+                return null;
+              }
+
+              return {
+                name: asset.name.toHuman()?.toString() ?? '',
+                symbol: asset.symbol.toHuman()?.toString() ?? '',
+                decimals: asset.decimals.toNumber(),
+                deposit: asset.deposit.toHuman()?.toString() ?? '',
+                isFrozen: asset.isFrozen.toHuman() ?? false,
+                assetId: nativeAssetIds[index],
path: libs/tangle-shared-ui/src/hooks/useAssetsMetadata.ts
user: login: danielbui12
body: ```ts

export type PrimitiveAssetMetadata = {
  name: string;
  symbol: string;
  decimals: number;
  deposit?: string;
  isFrozen?: boolean;
};

export type RestakeAssetMetadata = PrimitiveAssetMetadata &
  Readonly<{
    assetId: RestakeAssetId;
    vaultId: number | null;
    priceInUsd: number | null;
    details?: PalletAssetsAssetDetails;

    /**
     * The status of the asset.
     *
     * @field Live - The asset is live and can be staked.
     * @field Frozen - The asset is frozen and cannot be staked.
     * @field Destroying - The asset is being destroyed and cannot be staked.
     */
    status?: PalletAssetsAssetStatus['type'];
  }>;
```

I found existing type for asset metadata. If we keep undefined or null, that'll cause a lot of type error in all workspace
created_at: 2025-04-07T08:55:10Z
author_association: CONTRIBUTOR

PR comments:
user: login: netlify[bot]
created_at: 2025-04-04T15:57:14Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *tangle-dapp* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 43fcafc008c4e495c03bcf981360410ae3fdaa1c |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/tangle-dapp/deploys/67f39388cc72860008a8cfc6 |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-2955--tangle-dapp.netlify.app](https://deploy-preview-2955--tangle-dapp.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTI5NTUtLXRhbmdsZS1kYXBwLm5ldGxpZnkuYXBwIn0.M3hWprksQz7Rcyg9O5tKYFlVoTWDu1bxYOLIZqcKVW4)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [tangle-dapp Preview](https://deploy-preview-2955--tangle-dapp.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/tangle-dapp/configuration/notifications#deploy-webhooks)._
author_association: NONE
user: login: netlify[bot]
created_at: 2025-04-04T15:57:15Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *tangle-cloud* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 43fcafc008c4e495c03bcf981360410ae3fdaa1c |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/tangle-cloud/deploys/67f39388ca41a300085b665c |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-2955--tangle-cloud.netlify.app](https://deploy-preview-2955--tangle-cloud.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTI5NTUtLXRhbmdsZS1jbG91ZC5uZXRsaWZ5LmFwcCJ9.ikvTaMcR_jhWAlLToSOOmAaq7Tb0RCmWoP8HC-1Qm-c)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [tangle-cloud Preview](https://deploy-preview-2955--tangle-cloud.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/tangle-cloud/configuration/notifications#deploy-webhooks)._
author_association: NONE
user: login: netlify[bot]
created_at: 2025-04-04T15:57:29Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *tangle-leaderboard* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 43fcafc008c4e495c03bcf981360410ae3fdaa1c |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/tangle-leaderboard/deploys/67f39388b89c3e00089ac725 |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-2955--tangle-leaderboard.netlify.app](https://deploy-preview-2955--tangle-leaderboard.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTI5NTUtLXRhbmdsZS1sZWFkZXJib2FyZC5uZXRsaWZ5LmFwcCJ9.VrbbL3G2uUDpkMb7kGCfdWFkjL2Tb3eSMqb3Ed6BB7A)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [tangle-leaderboard Preview](https://deploy-preview-2955--tangle-leaderboard.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/tangle-leaderboard/configuration/notifications#deploy-webhooks)._
author_association: NONE
user: login: drewstone
created_at: 2025-04-07T02:10:56Z
body: I think the bar for the next and back buttons should be stuck at the bottom of the component right above the footer? What do you think?
author_association: CONTRIBUTOR