diff --git a/.prettierignore b/.prettierignore index d5237f8bc13..74de3d64622 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,9 @@ dist .cache .test +.vercel +.astro +temp node_modules .github .changeset diff --git a/public/samples/CCIP/cct/TokenDependencies.sol b/public/samples/CCIP/cct/TokenDependencies.sol index 2905e1c937f..556528d605c 100644 --- a/public/samples/CCIP/cct/TokenDependencies.sol +++ b/public/samples/CCIP/cct/TokenDependencies.sol @@ -3,3 +3,7 @@ pragma solidity 0.8.24; // solhint-disable no-unused-import import {BurnMintERC677} from "@chainlink/contracts-ccip/src/v0.8/shared/token/ERC677/BurnMintERC677.sol"; +import {BurnMintTokenPool} from "@chainlink/contracts-ccip/src/v0.8/ccip/pools/BurnMintTokenPool.sol"; +import {LockReleaseTokenPool} from "@chainlink/contracts-ccip/src/v0.8/ccip/pools/LockReleaseTokenPool.sol"; +import {RegistryModuleOwnerCustom} from "@chainlink/contracts-ccip/src/v0.8/ccip/tokenAdminRegistry/RegistryModuleOwnerCustom.sol"; +import {TokenAdminRegistry} from "@chainlink/contracts-ccip/src/v0.8/ccip/tokenAdminRegistry/TokenAdminRegistry.sol"; diff --git a/src/components/CCIP/TutorialBlockchainSelector/AdminSetupStep.module.css b/src/components/CCIP/TutorialBlockchainSelector/AdminSetupStep.module.css new file mode 100644 index 00000000000..d49578f6c12 --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/AdminSetupStep.module.css @@ -0,0 +1,130 @@ +.steps { + padding-left: var(--space-4x); + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-4x); +} + +.instructions { + padding-left: var(--space-6x); + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-3x); +} + +.contractInfo { + display: flex; + align-items: center; + gap: var(--space-3x); + padding: var(--space-3x); + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + margin-bottom: var(--space-3x); +} + +.contractInfo strong { + color: var(--color-text-primary); + font-weight: 600; +} + +.actionDetails { + display: flex; + flex-direction: column; + gap: var(--space-2x); +} + +.actionTitle { + font-weight: 500; + color: var(--color-text-primary); +} + +.actionTitle code { + font-family: var(--font-mono); + color: var(--color-text-primary); + background: var(--color-background); + padding: var(--space-1x) var(--space-2x); + border-radius: 4px; + font-size: var(--font-size-sm); +} + +.parameter { + display: grid; + grid-template-columns: 120px 1fr; + gap: var(--space-3x); + align-items: center; + padding: var(--space-2x) var(--space-3x); + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); +} + +.paramName { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .parameter { + grid-template-columns: 1fr; + gap: var(--space-2x); + } +} + +.functionDescription { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + margin: var(--space-2x) 0; +} + +.parameters { + margin-top: var(--space-2x); +} + +.functionCall { + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: var(--space-4x); + margin: var(--space-2x) 0; +} + +.functionHeader { + margin-bottom: var(--space-3x); +} + +.functionName { + font-family: var(--font-mono); + font-size: var(--font-size-lg); + color: var(--color-text-primary); + background: var(--color-background); + padding: var(--space-1x) var(--space-2x); + border-radius: 4px; +} + +.functionPurpose { + color: var(--color-text); + margin-top: var(--space-2x); + font-size: var(--font-size-base); +} + +.functionRequirement { + color: var(--color-warning); + font-size: var(--font-size-sm); + margin-bottom: var(--space-3x); +} + +.parametersSection { + border-top: 1px solid var(--color-border); + padding-top: var(--space-3x); +} + +.parametersTitle { + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--space-2x); +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/AdminSetupStep.tsx b/src/components/CCIP/TutorialBlockchainSelector/AdminSetupStep.tsx new file mode 100644 index 00000000000..4c95ffb20ef --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/AdminSetupStep.tsx @@ -0,0 +1,146 @@ +import { useStore } from "@nanostores/react" +import { laneStore } from "@stores/lanes" +import { NetworkCheck } from "../TutorialSetup/NetworkCheck" +import { TutorialCard } from "../TutorialSetup/TutorialCard" +import { TutorialStep } from "../TutorialSetup/TutorialStep" +import { NetworkAddress } from "./NetworkAddress" +import { StepCheckbox } from "../TutorialProgress/StepCheckbox" +import { SolidityParam } from "../TutorialSetup/SolidityParam" +import { Callout } from "../TutorialSetup/Callout" +import styles from "./AdminSetupStep.module.css" + +interface AdminSetupStepProps { + chain: "source" | "destination" +} + +export const AdminSetupStep = ({ chain }: AdminSetupStepProps) => { + const state = useStore(laneStore) + const network = chain === "source" ? state.sourceNetwork : state.destinationNetwork + const networkInfo = network ? { name: network.name, logo: network.logo } : { name: "loading..." } + const stepId = chain === "source" ? "sourceChain" : "destinationChain" + const tokenAddress = chain === "source" ? state.sourceContracts.token : state.destinationContracts.token + + const getSubStepId = (subStepId: string) => `${stepId}-${subStepId}` + + const content = ( + <> + + +
    + } + > + + The Cross-Chain Token (CCT) standard supports multiple methods for registering as a token administrator. We + use registerAdminViaOwner() in this tutorial because our deployed BurnMintERC677 token + implements the owner() function. For other token implementations, you might use different + registration methods. See the{" "} + + self-service registration documentation + {" "} + for all available options. + + +
      +
    1. + In the "Deploy & Run Transactions" tab, select the RegistryModuleOwnerCustom contract +
    2. +
    3. + Click "At Address" with: +
      + Contract: RegistryModuleOwnerCustom + +
      +
    4. +
    5. The RegistryModuleOwnerCustom will be displayed in the "Deployed Contracts" section
    6. +
    7. Click on the RegistryModuleOwnerCustom contract address to open the contract details
    8. +
    9. + Call registerAdminViaOwner: +
      +
      + registerAdminViaOwner +
      + Register yourself as the CCIP administrator for your token +
      +
      + +
      ⚠️ You must be the token owner to call this function
      + +
      +
      Parameters:
      +
      + +
      +
      +
      +
    10. +
    11. Confirm the transaction in MetaMask
    12. +
    +
    + + } + > +
      +
    1. + In the "Deploy & Run Transactions" tab, select TokenAdminRegistry contract +
    2. +
    3. + Click "At Address" with: +
      + Contract: TokenAdminRegistry + +
      +
    4. +
    5. The TokenAdminRegistry will be displayed in the "Deployed Contracts" section
    6. +
    7. Click on the TokenAdminRegistry contract address to open the contract details
    8. +
    9. + Call acceptAdminRole: +
      +
      + acceptAdminRole +
      Accept your role as CCIP administrator for your token
      +
      + +
      + ⚠️ Must be called after registerAdminViaOwner is confirmed +
      + +
      +
      Parameters:
      +
      + +
      +
      +
      +
    10. +
    11. Confirm the transaction in MetaMask
    12. +
    +
    +
+ + ) + + return ( + + {content} + + ) +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/ChainSelect.module.css b/src/components/CCIP/TutorialBlockchainSelector/ChainSelect.module.css new file mode 100644 index 00000000000..dd316f1575e --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/ChainSelect.module.css @@ -0,0 +1,186 @@ +.container { + position: relative; + width: 100%; +} + +.trigger { + width: 100%; + height: 48px; + padding: 0 16px; + background: linear-gradient( + to bottom, + var(--color-background-secondary), + rgba(var(--color-background-secondary-rgb), 0.8) + ); + border: none; + border-radius: 12px; + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; + font-weight: 500; + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.2s ease; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(255, 255, 255, 0.1), 0 2px 4px rgba(0, 0, 0, 0.02); +} + +.trigger:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.trigger.active { + background: var(--color-background); + color: var(--color-accent); + font-weight: 600; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.placeholder { + color: var(--color-text-secondary); + font-weight: 500; +} + +.chainLogo { + width: 24px; + height: 24px; + border-radius: 50%; + flex-shrink: 0; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.arrow { + margin-left: auto; + color: var(--color-accent); + font-size: 16px; + transition: transform 0.2s ease; +} + +.active .arrow { + transform: rotate(180deg); +} + +.dropdown { + background: var(--color-background-secondary); + border: 1px solid rgba(var(--color-border-rgb), 0.1); + border-radius: 12px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.08); + max-height: 300px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; + animation: dropdownAppear 0.2s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 10000; +} + +@keyframes dropdownAppear { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.option { + width: 100%; + padding: 12px 16px; + border: none; + background: transparent; + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; + color: var(--color-text-primary); + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + border-bottom: 1px solid rgba(var(--color-border-rgb), 0.06); + position: relative; + min-height: 44px; +} + +.option:hover, +.option.focused { + background: rgba(var(--color-accent-rgb), 0.15); + color: var(--color-accent); + font-weight: 600; + padding-left: 20px; + box-shadow: 0 2px 8px rgba(var(--color-accent-rgb), 0.15), inset 4px 0 0 var(--color-accent); +} + +.option:hover::before, +.option.focused::before { + content: ""; + position: absolute; + inset: 2px; + border: 2px solid var(--color-accent); + border-radius: 8px; + pointer-events: none; +} + +.option:hover::after, +.option.focused::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(to right, rgba(var(--color-accent-rgb), 0.12), rgba(var(--color-accent-rgb), 0.08)); + pointer-events: none; +} + +.option:hover .chainLogo, +.option.focused .chainLogo { + transform: scale(1.15); + box-shadow: 0 2px 8px rgba(var(--color-accent-rgb), 0.3), 0 0 0 2px var(--color-accent); +} + +.option.selected { + background: rgba(var(--color-accent-rgb), 0.08); + color: var(--color-accent); + font-weight: 500; + box-shadow: inset 2px 0 0 var(--color-accent); +} + +@media (prefers-reduced-motion: reduce) { + .option, + .option .chainLogo { + transition: none; + } + + .option:hover, + .option.focused { + transform: none; + } +} + +.option:last-child { + border-bottom: none; +} + +.dropdown::-webkit-scrollbar { + width: 8px; +} + +.dropdown::-webkit-scrollbar-track { + background: transparent; +} + +.dropdown::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 4px; +} + +.dropdown::before { + content: "Use arrow keys or Page Up/Down to navigate"; + display: block; + padding: 8px 16px; + color: var(--color-text-secondary); + font-size: 12px; + border-bottom: 1px solid var(--color-border); +} + +.dropdownBackdrop { + display: none; +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/ChainSelect.tsx b/src/components/CCIP/TutorialBlockchainSelector/ChainSelect.tsx new file mode 100644 index 00000000000..88cb9e5b418 --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/ChainSelect.tsx @@ -0,0 +1,208 @@ +import { useRef, useState, useEffect } from "react" +import { createPortal } from "react-dom" +import styles from "./ChainSelect.module.css" +import type { Network } from "@config/data/ccip/types" + +interface ChainSelectProps { + value: string + onChange: (value: string) => void + options: Network[] + placeholder: string +} + +interface DropdownPosition { + top: number + left: number + width: number +} + +export const ChainSelect = ({ value, onChange, options, placeholder }: ChainSelectProps) => { + const [isOpen, setIsOpen] = useState(false) + const [focusedIndex, setFocusedIndex] = useState(-1) + const [dropdownPosition, setDropdownPosition] = useState(null) + const containerRef = useRef(null) + const dropdownRef = useRef(null) + const selectedOption = options.find((opt) => opt.chain === value) + + // Update dropdown position when opening + const updateDropdownPosition = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + setDropdownPosition({ + top: rect.bottom + window.scrollY + 4, // 4px gap as per original CSS + left: rect.left + window.scrollX, + width: rect.width, + }) + } + } + + // Update position on open and scroll + useEffect(() => { + if (isOpen) { + updateDropdownPosition() + window.addEventListener("scroll", updateDropdownPosition) + window.addEventListener("resize", updateDropdownPosition) + + // Scroll selected option into view when dropdown opens + if (dropdownRef.current && value) { + requestAnimationFrame(() => { + const dropdown = dropdownRef.current + if (!dropdown) return + + const selectedOptionElement = dropdown.querySelector(`.${styles.selected}`) as HTMLElement + if (selectedOptionElement) { + // Calculate the dropdown's visible height + const dropdownHeight = dropdown.clientHeight + const optionHeight = selectedOptionElement.offsetHeight + + // Scroll the selected option to be in the middle of the dropdown + const scrollPosition = selectedOptionElement.offsetTop - dropdownHeight / 2 + optionHeight / 2 + + dropdown.scrollTo({ + top: Math.max(0, scrollPosition), + behavior: "instant", // Use 'instant' to prevent visible scrolling when opening + }) + } + }) + } + } + return () => { + window.removeEventListener("scroll", updateDropdownPosition) + window.removeEventListener("resize", updateDropdownPosition) + } + }, [isOpen, value]) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + // Check if click is outside both the container and the dropdown + const isOutsideContainer = containerRef.current && !containerRef.current.contains(event.target as Node) + const isOutsideDropdown = dropdownRef.current && !dropdownRef.current.contains(event.target as Node) + + if (isOutsideContainer && isOutsideDropdown) { + setIsOpen(false) + setFocusedIndex(-1) + } + } + + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + // Handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) return + + switch (e.key) { + case "ArrowDown": + e.preventDefault() + setFocusedIndex((prev) => Math.min(prev + 1, options.length - 1)) + break + case "ArrowUp": + e.preventDefault() + setFocusedIndex((prev) => Math.max(prev - 1, 0)) + break + case "PageUp": + e.preventDefault() + setFocusedIndex(0) + break + case "PageDown": + e.preventDefault() + setFocusedIndex(options.length - 1) + break + case "Enter": + e.preventDefault() + if (focusedIndex >= 0) { + onChange(options[focusedIndex].chain) + setIsOpen(false) + setFocusedIndex(-1) + } + break + case "Escape": + e.preventDefault() + setIsOpen(false) + setFocusedIndex(-1) + break + } + } + + if (isOpen) { + document.addEventListener("keydown", handleKeyDown) + } + + return () => { + document.removeEventListener("keydown", handleKeyDown) + } + }, [isOpen, focusedIndex, options, onChange]) + + // Scroll focused option into view + useEffect(() => { + if (isOpen && focusedIndex >= 0 && dropdownRef.current) { + const options = dropdownRef.current.getElementsByClassName(styles.option) + const focusedOption = options[focusedIndex] as HTMLElement + if (focusedOption) { + focusedOption.scrollIntoView({ block: "nearest", behavior: "smooth" }) + } + } + }, [focusedIndex, isOpen]) + + const renderDropdown = () => { + if (!isOpen || !dropdownPosition) return null + + const dropdown = ( +
+ {options.map((option, idx) => ( + + ))} +
+ ) + + return createPortal(dropdown, document.body) + } + + return ( +
+ + + {renderDropdown()} +
+ ) +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/ChainUpdateBuilder.module.css b/src/components/CCIP/TutorialBlockchainSelector/ChainUpdateBuilder.module.css new file mode 100644 index 00000000000..6f9a99f0679 --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/ChainUpdateBuilder.module.css @@ -0,0 +1,322 @@ +/* Base Container */ +.builder { + display: flex; + flex-direction: column; + gap: var(--space-3x); + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + width: 100%; + padding: var(--space-3x); +} + +.configSection { + display: flex; + flex-direction: column; + gap: var(--space-3x); + width: 100%; +} + +/* Container Styles - Use CSS Grid for better responsiveness */ +.remoteConfig, +.rateLimits { + display: flex; + flex-direction: column; + gap: var(--space-3x); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + background: var(--color-background-secondary); +} + +/* Grid Layouts - More flexible approach */ +.field { + display: grid; + grid-template-columns: minmax(140px, auto) minmax(0, 1fr); + align-items: center; + gap: var(--space-3x); + min-width: 0; + width: 100%; +} + +/* Input Elements */ +.input input, +.rateLimiter { + background: var(--color-background); + padding: var(--space-2x) var(--space-3x); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); +} + +.input input { + font-family: var(--font-mono); + font-size: var(--font-size-base); + display: block; + width: 100%; +} + +/* Section Spacing */ +.rateLimits, +.parameterDetails, +.copyBlock { + margin-top: 0; +} + +/* Rate Limiter Layout - More adaptive */ +.rateLimiterGroup { + display: flex; + flex-direction: column; + gap: 1.5rem; + margin-top: 0.5rem; +} + +.rateLimiter { + display: flex; + flex-direction: column; + gap: var(--space-3x); + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); +} + +.rateLimiterHeader { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-2x); +} + +.rateLimiterHeader h5, +.rateLimiterHeader span { + font-size: var(--font-size-base); + font-weight: normal; + color: var(--color-text); + margin: 0; + white-space: nowrap; +} + +.rateLimiterInputs { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + background: var(--color-background-secondary); + border-radius: 8px; +} + +/* Interactive Elements */ +.toggle { + display: inline-flex; + align-items: center; + gap: var(--space-2x); + white-space: nowrap; +} + +/* Parameter Description */ +.parameterDetails { + border-left: 3px solid var(--color-accent); + padding-left: var(--space-3x); + margin: var(--space-2x) 0; + color: var(--color-text-secondary); +} + +/* Notice - Use semantic colors */ +.notice { + margin-top: 16px; + padding: 16px; + border-radius: 12px; + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + animation: slideIn 0.2s ease; +} + +.noticeHeader { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.noticeIcon { + font-size: 16px; +} + +.noticeTitle { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); +} + +.noticeContent { + display: flex; + flex-direction: column; + gap: 8px; +} + +.noticeItem { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: var(--color-text-secondary); + padding: 4px 0; +} + +.noticeItemIcon { + color: var(--color-accent); + font-size: 12px; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Responsive Design */ +@media (max-width: 768px) { + .field { + grid-template-columns: 1fr; + } + + .rateLimiterHeader { + flex-direction: column; + align-items: flex-start; + } +} + +/* Keep consistent with SetPoolStep.module.css */ +.functionCall { + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: var(--space-4x); + margin: var(--space-2x) 0; +} + +/* Use the same copyBlock style as SetPoolStep */ +.copyBlock { + display: flex; + align-items: center; + gap: var(--space-3x); + padding: var(--space-3x); + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + margin-top: var(--space-3x); +} + +.copyInstructions { + white-space: nowrap; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +/* Container Styles */ +.remoteConfig { + display: flex; + flex-direction: column; + gap: var(--space-3x); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + background: var(--color-background-secondary); + padding: var(--space-3x); + width: 100%; + max-width: none; +} + +/* Ensure code blocks don't overflow */ +.field code { + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + min-width: 0; + max-width: 100%; +} + +.input { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.inputLabel { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.inputLabel label { + font-weight: 600; + color: var(--color-text-primary); +} + +.inputHint { + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +.numericInput { + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 1rem; + transition: all 0.2s ease; +} + +.numericInput:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.1); +} + +.inputError { + border-color: var(--color-error); +} + +.inputError:focus { + border-color: var(--color-error); + box-shadow: 0 0 0 2px rgba(var(--color-error-rgb), 0.1); +} + +/* Validation warning styling */ +.rateLimiterInputs :global(.callout) { + margin-bottom: 1rem; + font-size: 0.875rem; + border-radius: 6px; +} + +.rateLimiterInputs :global(.callout-title) { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.25rem; +} + +.maxSupplyInfo { + margin-bottom: 1rem; +} + +.validationWarnings { + margin-bottom: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.validationWarnings :global(.callout) { + margin: 0; + padding: 0.75rem 1rem; + border-radius: 6px; + background-color: var(--color-warning-background); + border: 1px solid var(--color-warning-border); +} + +.validationWarnings :global(.callout-title) { + color: var(--color-warning-text); + font-weight: 600; + margin-bottom: 0.25rem; +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/ChainUpdateBuilder.tsx b/src/components/CCIP/TutorialBlockchainSelector/ChainUpdateBuilder.tsx new file mode 100644 index 00000000000..254e148803f --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/ChainUpdateBuilder.tsx @@ -0,0 +1,449 @@ +import { useState, useEffect } from "react" +import { ethers } from "ethers" +import { laneStore, type RateLimiterConfig, updateRateLimits } from "@stores/lanes" +import { useStore } from "@nanostores/react" +import styles from "./ChainUpdateBuilder.module.css" +import { ErrorBoundary } from "@components/ErrorBoundary" +import { Callout } from "../TutorialSetup/Callout" + +interface ChainUpdateBuilderProps { + chain: "source" | "destination" + readOnly: { + chainSelector: string + poolAddress: string + tokenAddress: string + } + defaultConfig: { + outbound: RateLimiterConfig + inbound: RateLimiterConfig + } + onCalculate: (chainUpdate: { + remoteChainSelector: string + poolAddress: string + tokenAddress: string + outbound: RateLimiterConfig + inbound: RateLimiterConfig + }) => string +} + +const calculateChainUpdate = ( + chainSelector: string, + poolAddresses: string[], + tokenAddress: string, + outbound: RateLimiterConfig, + inbound: RateLimiterConfig +) => { + return { + remoteChainSelector: chainSelector, + poolAddress: poolAddresses[0], + tokenAddress, + outbound, + inbound, + } +} + +const validateRateLimiterConfig = (config: RateLimiterConfig): string | null => { + const rate = BigInt(config.rate || "0") + const capacity = BigInt(config.capacity || "0") + + if (config.enabled) { + // For enabled config: 0 < rate < capacity + if (rate <= BigInt(0)) { + return "Rate must be greater than 0 when enabled" + } + if (rate >= capacity) { + return "Rate must be less than capacity for effective rate limiting" + } + } else { + // For disabled config: rate = 0 and capacity = 0 + if (rate !== BigInt(0) || capacity !== BigInt(0)) { + return "Rate and capacity must be 0 when disabled" + } + } + return null +} + +export const ChainUpdateBuilder = ({ chain, readOnly, defaultConfig, onCalculate }: ChainUpdateBuilderProps) => { + if (process.env.NODE_ENV === "development") { + console.log(`[RenderTrack] ChainUpdateBuilder-${chain} rendered`) + } + + const state = useStore(laneStore) + + const [outbound, setOutbound] = useState(() => { + const rateLimits = chain === "source" ? state.sourceRateLimits : state.destinationRateLimits + return rateLimits?.outbound ?? defaultConfig.outbound + }) + + const [inbound, setInbound] = useState(() => { + const rateLimits = chain === "source" ? state.sourceRateLimits : state.destinationRateLimits + return rateLimits?.inbound ?? defaultConfig.inbound + }) + + const [validationErrors, setValidationErrors] = useState<{ + inbound: string | null + outbound: string | null + }>({ inbound: null, outbound: null }) + + const canGenerateUpdate = () => { + return ( + readOnly.chainSelector && + ethers.utils.isAddress(readOnly.poolAddress) && + ethers.utils.isAddress(readOnly.tokenAddress) + ) + } + + const handleRateLimitChange = (type: "inbound" | "outbound", field: keyof RateLimiterConfig, value: string) => { + if (process.env.NODE_ENV === "development") { + console.log(`[RateLimitChange] ${chain}-${type}-${field}:`, { + value, + timestamp: new Date().toISOString(), + }) + } + + // Validate input as BigInt + try { + // Remove any non-numeric characters + const cleanValue = value.replace(/[^0-9]/g, "") + // Always update the state, using "0" for empty values + const stringValue = cleanValue ? BigInt(cleanValue).toString() : "0" + + // Check uint128 range only for non-empty values + if (cleanValue) { + const bigIntValue = BigInt(cleanValue) + const MAX_UINT128 = BigInt(2) ** BigInt(128) - BigInt(1) + + // Ensure it's within uint128 range + if (bigIntValue > MAX_UINT128) { + console.warn("Value exceeds uint128 maximum") + return + } + } + + // Update local state first + if (type === "inbound") { + setInbound((prev) => ({ ...prev, [field]: stringValue, enabled: true })) + } else { + setOutbound((prev) => ({ ...prev, [field]: stringValue, enabled: true })) + } + + // Then update store + updateRateLimits(chain, type, { + [field]: stringValue, + enabled: true, + }) + + // Force update of formatted data + if (canGenerateUpdate()) { + const chainUpdate = calculateChainUpdate( + readOnly.chainSelector, + [readOnly.poolAddress], + readOnly.tokenAddress, + outbound, + inbound + ) + onCalculate(chainUpdate) + } + } catch (e) { + console.error("Invalid BigInt value:", e) + } + } + + const handleRateLimitToggle = (type: "inbound" | "outbound", enabled: boolean) => { + if (process.env.NODE_ENV === "development") { + console.log(`[RateLimitToggle] ${chain}-${type}:`, { + enabled, + timestamp: new Date().toISOString(), + }) + } + + const current = laneStore.get() + const rateLimitsKey = chain === "source" ? "sourceRateLimits" : "destinationRateLimits" + + const currentLimits = { + inbound: { enabled: false, capacity: "0", rate: "0" }, + outbound: { enabled: false, capacity: "0", rate: "0" }, + ...current[rateLimitsKey], + } + + laneStore.set({ + ...current, + [rateLimitsKey]: { + ...currentLimits, + [type]: { + ...currentLimits[type], + enabled, + capacity: enabled ? currentLimits[type].capacity : "0", + rate: enabled ? currentLimits[type].rate : "0", + }, + }, + }) + + if (type === "inbound") { + setInbound((prev) => ({ + ...prev, + enabled, + capacity: enabled ? prev.capacity : "0", + rate: enabled ? prev.rate : "0", + })) + } else { + setOutbound((prev) => ({ + ...prev, + enabled, + capacity: enabled ? prev.capacity : "0", + rate: enabled ? prev.rate : "0", + })) + } + } + + useEffect(() => { + if (canGenerateUpdate()) { + const chainUpdate = calculateChainUpdate( + readOnly.chainSelector, + [readOnly.poolAddress], + readOnly.tokenAddress, + outbound, + inbound + ) + onCalculate(chainUpdate) + } + }, [outbound, inbound, readOnly.chainSelector, readOnly.poolAddress, readOnly.tokenAddress]) + + useEffect(() => { + // Validate configurations whenever they change + setValidationErrors({ + inbound: validateRateLimiterConfig(inbound), + outbound: validateRateLimiterConfig(outbound), + }) + }, [inbound, outbound]) + + if (process.env.NODE_ENV === "development") { + console.log(`[ConfigState] ${chain}-rate-limits:`, { + outbound, + inbound, + readOnly, + timestamp: new Date().toISOString(), + }) + } + + return ( + Error configuring rate limits. Please refresh and try again.} + onError={(error) => { + console.error("Rate limit configuration error:", error) + reportError?.(error) + }} + > +
+ +

+ Rate limits control how many tokens can be transferred over a given blockchain lane within a specific time + frame. When working with rate limits, consider the following: +

+
    +
  • + Maximum capacity: The total amount of tokens that can be transferred before the pool is + fully consumed. +
  • +
  • + Refill rate: How quickly this capacity is restored over time after transfers occur. +
  • +
  • + Disabling rate limits: Setting both capacity and rate to 0 removes all limitations, + allowing unlimited transfers. +
  • +
  • + Token decimals: When defining these limits, remember to account for token decimals. For + example, for a token with 18 decimals, to allow a maximum capacity of 1 whole token, set it to{" "} + 1000000000000000000. +
  • +
+

+ Learn more in the CCIP rate limits documentation. +

+
+ +
+ {/* Remote Configuration Section */} +
+ Remote Configuration +
+ + {readOnly.chainSelector} +
+
+ + {readOnly.poolAddress} +
+
+ + {readOnly.tokenAddress} +
+
+ + {/* Rate Limits Section */} +
+ Rate Limit Configuration + + {/* MaxSupply Consideration Callout */} + {(outbound.enabled || inbound.enabled) && ( +
+ + Ensure the capacity is not set higher than your token's maximum supply (configured during token + deployment). Setting a capacity larger than the maximum supply would create an ineffective rate limit. + +
+ )} + + {/* Validation Warnings */} + {(validationErrors.inbound || validationErrors.outbound) && ( +
+ {validationErrors.outbound && ( + + {validationErrors.outbound} + + )} + {validationErrors.inbound && ( + + {validationErrors.inbound} + + )} +
+ )} + +
+ {/* Outbound Configuration */} +
+
+ Outbound Transfers + +
+ + {outbound.enabled && ( +
+
+
+ + Maximum tokens allowed +
+ handleRateLimitChange("outbound", "capacity", e.target.value)} + placeholder="Enter amount..." + pattern="[0-9]*" + className={`${styles.numericInput} ${validationErrors.outbound ? styles.inputError : ""}`} + /> +
+
+
+ + + Rate at which available capacity is replenished (tokens/second) + +
+ handleRateLimitChange("outbound", "rate", e.target.value)} + placeholder="Enter amount..." + pattern="[0-9]*" + className={`${styles.numericInput} ${validationErrors.outbound ? styles.inputError : ""}`} + /> +
+
+ )} +
+ + {/* Inbound Configuration */} +
+
+ Inbound Transfers + +
+ + {inbound.enabled && ( +
+
+
+ + Maximum tokens allowed +
+ handleRateLimitChange("inbound", "capacity", e.target.value)} + placeholder="Enter amount..." + pattern="[0-9]*" + className={styles.numericInput} + /> +
+
+
+ + + Rate at which available capacity is replenished (tokens/second) + +
+ handleRateLimitChange("inbound", "rate", e.target.value)} + placeholder="Enter amount..." + pattern="[0-9]*" + className={styles.numericInput} + /> +
+
+ )} +
+
+
+ + {!canGenerateUpdate() && ( +
+
+ ⚠️ + Action Required +
+
+ {!ethers.utils.isAddress(readOnly.tokenAddress) && ( +
+ + Please deploy your token first to proceed with configuration +
+ )} + {!ethers.utils.isAddress(readOnly.poolAddress) && ( +
+ + Token pool address is required +
+ )} + {!readOnly.chainSelector && ( +
+ + Chain selector is required +
+ )} +
+
+ )} +
+
+
+ ) +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/ChainUpdateBuilderWrapper.module.css b/src/components/CCIP/TutorialBlockchainSelector/ChainUpdateBuilderWrapper.module.css new file mode 100644 index 00000000000..d9eeb735cfc --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/ChainUpdateBuilderWrapper.module.css @@ -0,0 +1,764 @@ +/* Reuse common styles from PoolConfigVerification */ +.verificationCard { + position: relative; + background: var(--color-background); + border-radius: var(--border-radius); + padding: var(--space-4x); + margin: 16px 0; + transition: all 0.2s ease; + border: 1px solid var(--color-border); + --section-spacing: 32px; +} + +.verificationCard::before { + content: ""; + position: absolute; + left: 0; + top: 0; + width: 3px; + height: 100%; + background: var(--color-accent); + border-radius: 3px 0 0 3px; + opacity: 0; + transition: opacity 0.2s ease; +} + +.verificationCard:hover::before { + opacity: 1; +} + +.chainDetails { + position: relative; + padding: var(--space-3x); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + transition: all 0.2s ease; + overflow: hidden; + background: var(--color-background-secondary); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02); +} + +.chainDetails:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06), 0 2px 4px rgba(0, 0, 0, 0.03); +} + +.chainValue { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: var(--space-2x); +} + +.chainValue code { + font-family: var(--font-mono); + font-size: var(--font-size-base); + color: var(--color-text-primary); + font-weight: 500; +} + +.chainSelector { + display: flex; + align-items: center; + gap: var(--space-2x); + font-size: 12px; + margin-top: var(--space-2x); + padding-top: var(--space-2x); + border-top: 1px solid var(--color-border); +} + +.chainSelector span { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.configurationSection { + margin-top: 24px; + padding: 20px; + border-radius: 12px; + border: 1px solid var(--color-border); + background: var(--color-background); +} + +.sectionTitle { + font-size: 16px; + font-weight: 500; + color: var(--color-text-primary); + margin-bottom: 16px; +} + +.configurationData { + margin-top: 24px; + padding: 20px; + border-radius: 12px; + border: 1px solid var(--color-border); + background: var(--color-background); +} + +.dataField { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dataField span { + color: var(--color-text-secondary); + font-size: 14px; +} + +.loadingState { + text-align: center; + padding: 32px 16px; + color: var(--color-text-secondary); +} + +.loadingState span { + display: block; + font-weight: 500; + margin-bottom: 8px; + color: var(--color-text-primary); +} + +.loadingState p { + font-size: 14px; +} + +.configurationBuilder { + margin-top: 24px; +} + +.debugSection { + margin-top: 16px; + border-top: 1px dashed var(--color-border); + padding-top: 16px; +} + +.debugToggle { + background: none; + border: none; + color: var(--color-text-secondary); + font-size: 12px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: all 0.2s ease; +} + +.debugToggle:hover { + background: var(--color-background-secondary); + color: var(--color-text-primary); +} + +.jsonData { + margin-top: 12px; + padding: 12px; + background: var(--color-background-secondary); + border-radius: 6px; + font-size: 12px; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Enhance parameter styling */ +.parameter { + position: relative; + transition: transform 0.2s ease, box-shadow 0.2s ease; + overflow: hidden; +} + +.parameter::after { + content: ""; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 2px; + background: var(--color-accent); + transform: scaleX(0); + transform-origin: left; + transition: transform 0.3s ease; +} + +.parameter:hover { + transform: translateY(-2px); +} + +.parameter:hover::after { + transform: scaleX(1); +} + +.parameterHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.parameterName { + display: flex; + flex-direction: column; + gap: 4px; +} + +.parameterLabel { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-secondary); +} + +.parameterIdentifier { + display: flex; + align-items: center; + gap: 8px; +} + +.parameterIdentifier span { + font-family: var(--font-family-mono); + font-size: 14px; + color: var(--color-accent); +} + +.parameterValue { + background: var(--color-background-secondary); + border-radius: 8px; + padding: 12px; + animation: slideIn 0.3s ease; +} + +/* Copy feedback animation */ +.copyWrapper { + position: relative; +} + +.copyFeedback { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + padding: 4px 8px; + background: var(--color-success); + color: white; + border-radius: 4px; + font-size: 12px; + opacity: 0; + transition: opacity 0.2s ease; +} + +.copyWrapper.copied .copyFeedback { + opacity: 1; +} + +/* Animations */ +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Accessibility */ +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.stepDescription { + color: var(--color-text-secondary); + line-height: 1.6; + margin-bottom: 32px; +} + +.parameterNote { + margin-top: 16px; + font-weight: 500; + color: var(--color-text-primary); +} + +.parameterList { + display: grid; + gap: 32px; /* Increased spacing between parameters */ + margin-bottom: 40px; +} + +.parameter { + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 24px; + transition: all 0.2s ease; +} + +.parameterLabel { + font-size: 14px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-accent); + margin-bottom: 8px; +} + +.copyInstructions { + font-size: 14px; + color: var(--color-text-secondary); + margin-bottom: 12px; +} + +/* Enhanced animations */ +.parameter { + position: relative; + overflow: hidden; +} + +.parameter::before { + content: ""; + position: absolute; + inset: 0; + background: var(--color-accent); + opacity: 0; + transition: opacity 0.3s ease; + z-index: -1; +} + +.parameter:hover::before { + opacity: 0.03; +} + +.configurationSection { + margin-top: 40px; + padding-top: 32px; + border-top: 1px dashed var(--color-border); +} + +/* Pool Address Spacing */ +.poolAddress { + padding: var(--space-3x); + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + margin-bottom: var(--space-4x); +} + +/* Function Block */ +.functionBlock { + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 32px; + margin-bottom: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02); +} + +/* Parameter Styling */ +.parameterList { + display: grid; + gap: 40px; + margin-top: 32px; +} + +.parameter { + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 24px; +} + +/* Copy Block Styling */ +.copyBlock { + display: flex; + flex-direction: column; + gap: var(--space-2x); + width: 100%; + margin-top: var(--space-3x); +} + +.copyInstructions { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +/* Add proper container for the copy section */ +.copyContainer { + display: flex; + align-items: flex-start; + gap: var(--space-2x); + padding: var(--space-3x); + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + width: 100%; + min-width: 0; +} + +/* Ensure code blocks wrap properly */ +.copyContainer code { + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; +} + +/* Update responsive behavior */ +@media (min-width: 768px) { + .copyBlock { + flex-direction: row; + align-items: center; + } + + .copyContainer { + flex: 1; + } +} + +/* Typography Consistency */ +.parameterLabel { + font-family: var(--font-family-base); + font-size: 14px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--color-accent); +} + +.parameterDetails { + font-family: var(--font-family-base); + font-size: 14px; + line-height: 1.6; + color: var(--color-text-secondary); +} + +/* Rate Config Section */ +.rateConfigSection { + margin-top: 24px; + padding-top: 24px; + border-top: 1px dashed var(--color-border); +} + +/* Common section header style */ +.sectionHeader { + font-size: 14px; + font-weight: 500; + color: var(--color-text-secondary); + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.steps { + padding-left: var(--space-4x); + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-4x); +} + +.instructions { + padding-left: var(--space-6x); + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-3x); +} + +.functionCall { + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: var(--space-4x); + margin: var(--space-2x) 0; +} + +/* Match SetPoolStep styles */ +.functionHeader { + margin-bottom: var(--space-3x); +} + +.functionName { + font-family: var(--font-mono); + font-size: var(--font-size-lg); + color: var(--color-text-primary); + background: var(--color-background); + padding: var(--space-1x) var(--space-2x); + border-radius: 4px; +} + +.functionPurpose { + color: var(--color-text); + margin-top: var(--space-2x); + font-size: var(--font-size-base); +} + +.functionRequirement { + color: var(--color-warning); + font-size: var(--font-size-sm); + margin-bottom: var(--space-3x); +} + +.parametersSection { + border-top: 1px solid var(--color-border); + padding-top: var(--space-3x); +} + +.parametersTitle { + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--space-2x); +} + +.parametersList { + display: flex; + flex-direction: column; + gap: var(--space-3x); +} + +.functionTitle { + display: flex; + align-items: center; + gap: var(--space-3x); +} + +@media (max-width: 768px) { + .verificationCard { + padding: var(--space-3x); + } + + .functionCall { + padding: var(--space-3x); + } + + .parametersList { + gap: var(--space-3x); + } +} + +@media (max-width: 480px) { + .chainDetails { + padding: var(--space-2x); + } + + .chainValue { + font-size: var(--font-size-sm); + } + + .functionName { + font-size: var(--font-size-base); + } + + .functionTitle { + flex-direction: column; + align-items: flex-start; + gap: var(--space-2x); + } +} + +.configurationTool { + margin-top: var(--space-4x); + padding-top: var(--space-4x); + border-top: 1px solid var(--color-border); +} + +.configSteps { + margin-bottom: var(--space-4x); +} + +.configTitle { + font-size: var(--font-size-lg); + color: var(--color-text-primary); + margin-bottom: var(--space-2x); +} + +.configDescription { + color: var(--color-text-secondary); + font-size: var(--font-size-base); + line-height: 1.5; +} + +.resultSection { + margin-top: var(--space-4x); + width: 100%; +} + +.copyBlock { + display: flex; + flex-direction: column; + gap: var(--space-3x); + width: 100%; +} + +.copyContainer { + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: var(--space-3x); + width: 100%; +} + +@media (min-width: 768px) { + .copyBlock { + flex-direction: row; + align-items: flex-start; + } + + .copyContainer { + flex: 1; + } +} + +.prerequisites { + display: flex; + gap: 16px; + padding: 24px; + background: var(--color-background-secondary); + border-radius: 12px; + border: 1px solid var(--color-border); + margin: 16px 0; +} + +.prerequisitesIcon { + font-size: 24px; +} + +.prerequisitesContent { + flex: 1; +} + +.prerequisitesContent h4 { + margin: 0 0 12px 0; + font-size: 16px; + font-weight: 600; + color: var(--color-text-primary); +} + +.prerequisitesContent ul { + margin: 0; + padding-left: 20px; + color: var(--color-text-secondary); +} + +.prerequisitesContent li { + margin: 8px 0; + font-size: 14px; +} + +.missingDependency { + display: flex; + gap: 12px; + padding: 16px; + background: var(--color-background); + border-radius: 8px; + margin-top: 12px; +} + +.missingDependencyIcon { + font-size: 20px; +} + +.missingDependencyMessage { + display: flex; + flex-direction: column; + gap: 4px; +} + +.missingDependencyMessage strong { + font-size: 14px; + color: var(--color-text-primary); +} + +.missingDependencyMessage span { + font-size: 13px; + color: var(--color-text-secondary); +} + +.remoteRequirements { + margin-top: 16px; + padding: 16px; + background: var(--color-background); + border-radius: 8px; + border: 1px solid var(--color-border); +} + +.remoteRequirements h4 { + margin: 0 0 12px 0; + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); +} + +.requirementsList { + display: flex; + flex-direction: column; + gap: 12px; +} + +.requirement { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: var(--color-background-secondary); + border-radius: 6px; + transition: all 0.2s ease; +} + +.requirement.fulfilled { + background: var(--color-background); + border: 1px solid var(--color-border); +} + +.requirementIcon { + font-size: 16px; + color: var(--color-text-secondary); + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; +} + +.fulfilled .requirementIcon { + color: var(--color-success); +} + +.requirementLabel { + font-size: 13px; + font-weight: 500; + color: var(--color-text-primary); + min-width: 120px; +} + +.requirementValue { + font-family: var(--font-mono); + font-size: 12px; + color: var(--color-text-secondary); + background: var(--color-background); + padding: 2px 6px; + border-radius: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; +} + +.requirementMissing { + font-size: 12px; + color: var(--color-text-secondary); + font-style: italic; +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/ChainUpdateBuilderWrapper.tsx b/src/components/CCIP/TutorialBlockchainSelector/ChainUpdateBuilderWrapper.tsx new file mode 100644 index 00000000000..f8d4a654ebb --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/ChainUpdateBuilderWrapper.tsx @@ -0,0 +1,303 @@ +import { useStore } from "@nanostores/react" +import { laneStore, TUTORIAL_STEPS } from "@stores/lanes" +import { ChainUpdateBuilder } from "./ChainUpdateBuilder" +import { ethers } from "ethers" +import styles from "./ChainUpdateBuilderWrapper.module.css" +import { ReactCopyText } from "@components/ReactCopyText" +import { useState } from "react" +import type { Network } from "@config/data/ccip/types" +import { TutorialCard, SolidityParam, NetworkCheck, TutorialStep } from "../TutorialSetup" +import { StepCheckbox } from "../TutorialProgress/StepCheckbox" + +interface ChainUpdateBuilderWrapperProps { + chain: "source" | "destination" +} + +interface RateLimiterConfig { + enabled: boolean + capacity: string + rate: string +} + +interface ChainUpdate { + remoteChainSelector: bigint + remotePoolAddresses: string[] + remoteTokenAddress: string + outboundRateLimiterConfig: RateLimiterConfig + inboundRateLimiterConfig: RateLimiterConfig +} + +interface ChainUpdateInput { + remoteChainSelector: string + poolAddress: string + tokenAddress: string + outbound: RateLimiterConfig + inbound: RateLimiterConfig +} + +const isValidNetwork = (network: Network | null): network is Network => { + return !!network && typeof network.chainSelector === "string" && typeof network.name === "string" +} + +const generateCallData = (chainUpdate: ChainUpdate) => { + if (!chainUpdate.remoteChainSelector || !chainUpdate.remotePoolAddresses || !chainUpdate.remoteTokenAddress) { + return "" + } + + return JSON.stringify( + [ + [ + chainUpdate.remoteChainSelector.toString(), + chainUpdate.remotePoolAddresses, + chainUpdate.remoteTokenAddress, + [ + chainUpdate.outboundRateLimiterConfig.enabled, + BigInt(chainUpdate.outboundRateLimiterConfig.capacity).toString(), + BigInt(chainUpdate.outboundRateLimiterConfig.rate).toString(), + ], + [ + chainUpdate.inboundRateLimiterConfig.enabled, + BigInt(chainUpdate.inboundRateLimiterConfig.capacity).toString(), + BigInt(chainUpdate.inboundRateLimiterConfig.rate).toString(), + ], + ], + ], + null, + 2 + ) +} + +export const ChainUpdateBuilderWrapper = ({ chain }: ChainUpdateBuilderWrapperProps) => { + const state = useStore(laneStore) + const [formattedUpdate, setFormattedUpdate] = useState("") + const [callData, setCallData] = useState("") + + if (process.env.NODE_ENV === "development") { + console.log(`[RenderTrack] ChainUpdateBuilderWrapper-${chain} rendered`) + } + + // Get current network info + const currentNetwork = chain === "source" ? state.sourceNetwork : state.destinationNetwork + const networkInfo = currentNetwork ? { name: currentNetwork.name, logo: currentNetwork.logo } : { name: "loading..." } + + // Get remote network info + const remoteNetwork = chain === "source" ? state.destinationNetwork : state.sourceNetwork + const remoteContracts = chain === "source" ? state.destinationContracts : state.sourceContracts + + // Get contract addresses and pool type + const poolAddress = chain === "source" ? state.sourceContracts.tokenPool : state.destinationContracts.tokenPool + const poolType = chain === "source" ? state.sourceContracts.poolType : state.destinationContracts.poolType + + if (process.env.NODE_ENV === "development") { + console.log(`[ConfigTrack] ${chain}-update-builder:`, { + currentNetwork: currentNetwork?.name, + remoteNetwork: remoteNetwork?.name, + poolAddress, + poolType, + remoteContracts, + timestamp: new Date().toISOString(), + }) + } + + const isDataReady = isValidNetwork(currentNetwork) && isValidNetwork(remoteNetwork) && Boolean(poolAddress) + + const canGenerateUpdate = () => { + return ( + isDataReady && + remoteNetwork?.chainSelector && + remoteContracts.tokenPool && + ethers.utils.isAddress(remoteContracts.tokenPool) && + remoteContracts.token && + ethers.utils.isAddress(remoteContracts.token) + ) + } + + const handleCalculate = (input: ChainUpdateInput): string => { + if (process.env.NODE_ENV === "development") { + console.log(`[UpdateCalculation] ${chain}-update-builder:`, { + input, + timestamp: new Date().toISOString(), + }) + } + + try { + // Validate addresses + if (!ethers.utils.isAddress(input.poolAddress) || !ethers.utils.isAddress(input.tokenAddress)) { + if (process.env.NODE_ENV === "development") { + console.log(`[UpdateSkipped] ${chain}-update-builder: Invalid addresses`, { + validPoolAddress: ethers.utils.isAddress(input.poolAddress), + validTokenAddress: ethers.utils.isAddress(input.tokenAddress), + timestamp: new Date().toISOString(), + }) + } + return "" + } + + const formattedUpdate = { + remoteChainSelector: input.remoteChainSelector, + remotePoolAddresses: [input.poolAddress].map((addr) => + ethers.utils.defaultAbiCoder.encode(["address"], [addr]) + ), + remoteTokenAddress: ethers.utils.defaultAbiCoder.encode(["address"], [input.tokenAddress]), + outboundRateLimiterConfig: { + enabled: input.outbound.enabled, + capacity: input.outbound.capacity, + rate: input.outbound.rate, + }, + inboundRateLimiterConfig: { + enabled: input.inbound.enabled, + capacity: input.inbound.capacity, + rate: input.inbound.rate, + }, + } + + const generatedCallData = generateCallData({ + ...formattedUpdate, + remoteChainSelector: BigInt(input.remoteChainSelector), + }) + + const formatted = JSON.stringify( + { + json: [formattedUpdate], + callData: generatedCallData, + }, + null, + 2 + ) + + setFormattedUpdate(formatted) + setCallData(generatedCallData) + + if (process.env.NODE_ENV === "development") { + console.log(`[UpdateSuccess] ${chain}-update-builder:`, { + formatted, + callData: generatedCallData, + timestamp: new Date().toISOString(), + }) + } + + return formatted + } catch (error) { + console.error("Error formatting chain update:", error) + setFormattedUpdate("") + setCallData("") + return "" + } + } + + const stepId = chain === "source" ? "sourceConfig" : "destinationConfig" + const subStepId = chain === "source" ? "source-pool-config" : "dest-pool-config" + const navigationId = `${stepId}-${subStepId}` + + return ( + + +
    + } + > + {!isValidNetwork(currentNetwork) && ( +
    ⚠️ Please select valid blockchains first
    + )} + {!poolAddress && ( +
    ⚠️ Please deploy your token pool before proceeding
    + )} + + {isDataReady ? ( +
      +
    1. Open the "Deploy & Run Transactions" tab in Remix
    2. +
    3. + Select your token pool contract: +
      + {poolType === "burn" ? "BurnMintTokenPool" : "LockReleaseTokenPool"} + +
      +
    4. +
    5. Click the contract to view its functions
    6. + +
    7. + Call applyChainUpdates: +
      +
      + applyChainUpdates +
      Configure cross-chain token and pool mapping
      +
      + +
      +
      Parameters:
      +
      + } + /> + + +
      +
      +
      +
    8. + + {canGenerateUpdate() && ( + <> +
    9. + Configure the rate limits below: +
      + +
      +
    10. + + {formattedUpdate && ( +
    11. + Copy the generated value and paste it into the chainsToAdd parameter in Remix: +
      +
      + +
      +
      +
    12. + )} + +
    13. Confirm the transaction in MetaMask
    14. + + )} +
    + ) : ( +
    +
    ⚡️
    +
    +

    Current Chain Prerequisites

    +
      + {!isValidNetwork(currentNetwork) &&
    • Select valid blockchains for the transfer
    • } + {!poolAddress &&
    • Deploy your token pool on the current chain
    • } +
    +
    +
    + )} +
    +
+
+ ) +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/ChainValue.tsx b/src/components/CCIP/TutorialBlockchainSelector/ChainValue.tsx new file mode 100644 index 00000000000..161370bc698 --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/ChainValue.tsx @@ -0,0 +1,27 @@ +import { useStore } from "@nanostores/react" +import { laneStore } from "@stores/lanes" +import { getAllNetworks } from "@config/data/ccip" + +interface ChainValueProps { + type: "source" | "destination" + bold?: boolean + required?: boolean +} + +export const ChainValue = ({ type, bold = false, required = true }: ChainValueProps) => { + const state = useStore(laneStore) + const chainId = type === "source" ? state.sourceChain : state.destinationChain + + if (!chainId) { + return required ? [Select {type} blockchain] : + } + + const networks = getAllNetworks({ filter: state.environment }) + const network = networks.find((n) => n.chain === chainId) + + if (!network) { + return required ? Network not found : + } + + return bold ? {network.name} : {network.name} +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/ContractAddress.css b/src/components/CCIP/TutorialBlockchainSelector/ContractAddress.css new file mode 100644 index 00000000000..a45db011a5c --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/ContractAddress.css @@ -0,0 +1,42 @@ +.contract-address-container { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.contract-address-input { + width: 100%; + padding: 0.5rem; + border-radius: 4px; + border: 1px solid #e2e8f0; + font-family: var(--font-mono); + font-size: 0.875rem; + transition: all 0.2s ease; +} + +.contract-address-input:hover:not(.invalid-address) { + border-color: var(--color-primary); +} + +.contract-address-input:focus:not(.invalid-address) { + border-color: var(--color-primary); + box-shadow: 0 0 0 1px var(--color-primary); + outline: none; +} + +.contract-address-input::placeholder { + color: #375bd2; + opacity: 0.7; +} + +.contract-address-input.invalid-address { + border-color: #dc3545; + color: #dc3545; + background-color: #fff8f8; +} + +.validation-message { + color: #dc3545; + font-size: var(--space-3x); + margin-top: -0.25rem; +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/ContractAddress.tsx b/src/components/CCIP/TutorialBlockchainSelector/ContractAddress.tsx new file mode 100644 index 00000000000..82eb9814ab2 --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/ContractAddress.tsx @@ -0,0 +1,57 @@ +import { useState, useCallback } from "react" +import { utils } from "ethers" +import { setSourceContract, setDestinationContract } from "@stores/lanes" +import type { DeployedContracts } from "@stores/lanes" +import "./ContractAddress.css" + +interface ContractAddressProps { + type: keyof DeployedContracts + chain: "source" | "destination" + placeholder: string +} + +export const ContractAddress = ({ type, chain, placeholder }: ContractAddressProps) => { + const [inputValue, setInputValue] = useState("") + const [isValid, setIsValid] = useState(true) + const setValue = chain === "source" ? setSourceContract : setDestinationContract + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value.trim() + setInputValue(value) + + if (value === "") { + // Reset validation and clear store when empty + setIsValid(true) + setValue(type, "") // Clear the store value + return + } + + // Validate non-empty values + const valid = utils.isAddress(value) + setIsValid(valid) + + // Only update store if it's a valid address + if (valid) { + setValue(type, value) + } else { + // Clear store if value becomes invalid + setValue(type, "") + } + }, + [type, setValue] + ) + + return ( +
+ + {!isValid && Please enter a valid Ethereum address} +
+ ) +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/ContractVerificationStep.module.css b/src/components/CCIP/TutorialBlockchainSelector/ContractVerificationStep.module.css new file mode 100644 index 00000000000..e58bf00bc85 --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/ContractVerificationStep.module.css @@ -0,0 +1,243 @@ +.verificationIntro { + margin-bottom: var(--space-4x); +} + +.verificationSteps { + list-style: decimal; + padding-left: var(--space-4x); + margin: var(--space-4x) 0; + display: flex; + flex-direction: column; + gap: var(--space-4x); +} + +.stepTitle { + display: block; + font-weight: 600; + font-size: var(--font-size-lg); + color: var(--color-text-primary); + margin-bottom: var(--space-3x); +} + +.stepContent { + color: var(--color-text); +} + +/* Explorer Section */ +.explorerSection { + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: var(--space-4x); +} + +.explorerUrl { + display: flex; + align-items: center; + gap: var(--space-2x); + flex-wrap: wrap; + margin-bottom: var(--space-3x); +} + +.explorerUrl span { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.contractSection { + border-top: 1px solid var(--color-border); + padding-top: var(--space-3x); + display: flex; + flex-direction: column; + gap: var(--space-3x); +} + +.contractInfo { + display: flex; + align-items: center; + gap: var(--space-2x); + flex-wrap: wrap; +} + +.contractInfo span { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.address { + font-family: var(--font-mono); + background: var(--color-background); + padding: var(--space-1x) var(--space-2x); + border-radius: var(--border-radius); + font-size: var(--font-size-sm); +} + +/* Verification Options */ +.verificationOptions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--space-4x); + margin: var(--space-2x) 0; +} + +.verificationOption { + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: var(--space-4x); + display: flex; + flex-direction: column; + height: 100%; +} + +.optionHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-2x); +} + +.optionTitle { + font-weight: 600; + font-size: var(--font-size-base); + color: var(--color-text-primary); +} + +.optionTag { + font-size: var(--font-size-xs); + font-weight: 500; + padding: var(--space-1x) var(--space-2x); + border-radius: var(--border-radius); + background: var(--color-background); + color: var(--color-text-secondary); +} + +.optionDescription { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + margin-bottom: var(--space-3x); + flex-grow: 1; +} + +.optionLink { + display: inline-flex; + align-items: center; + color: var(--color-accent); + text-decoration: none; + font-size: var(--font-size-base); + font-weight: 500; + gap: var(--space-1x); + margin-top: auto; +} + +.optionLink:hover { + text-decoration: underline; +} + +/* Buttons */ +.viewContractButton, +.verifyButton { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--color-accent); + color: white; + padding: var(--space-2x) var(--space-3x); + border-radius: var(--border-radius); + text-decoration: none; + font-weight: 500; + gap: var(--space-2x); + transition: background-color 0.2s ease; +} + +.viewContractButton:hover, +.verifyButton:hover { + background: var(--color-accent-dark); +} + +/* Confirmation Steps */ +.subSteps { + list-style: lower-alpha; + padding-left: var(--space-4x); + margin: var(--space-3x) 0; +} + +.subSteps li { + margin-bottom: var(--space-2x); + color: var(--color-text-secondary); +} + +.externalIcon { + font-size: 0.8em; +} + +/* Verification Actions */ +.verificationActions { + margin-top: var(--space-4x); + display: flex; + flex-direction: column; + gap: var(--space-3x); +} + +.verificationUrl { + display: flex; + flex-direction: column; + gap: var(--space-2x); +} + +.verificationUrl span { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.urlDisplay { + font-family: var(--font-mono); + background: var(--color-background-secondary); + padding: var(--space-2x); + border-radius: var(--border-radius); + font-size: var(--font-size-sm); + word-break: break-all; + color: var(--color-text); + border: 1px solid var(--color-border); +} + +/* New styles for placeholders and verification link */ +.placeholderMessage { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + padding: var(--space-3x); + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); +} + +.verificationLink { + margin-top: var(--space-4x); + padding: var(--space-3x); + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); +} + +.verificationLink span { + display: block; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + margin-bottom: var(--space-2x); +} + +.contractLink { + display: block; + font-family: var(--font-mono); + color: var(--color-accent); + text-decoration: none; + word-break: break-all; + font-size: var(--font-size-sm); + padding: var(--space-2x); + background: var(--color-background); + border-radius: var(--border-radius); +} + +.contractLink:hover { + text-decoration: underline; +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/ContractVerificationStep.tsx b/src/components/CCIP/TutorialBlockchainSelector/ContractVerificationStep.tsx new file mode 100644 index 00000000000..82db05dc37f --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/ContractVerificationStep.tsx @@ -0,0 +1,142 @@ +import { TutorialStep } from "../TutorialSetup/TutorialStep" +import { Callout } from "../TutorialSetup/Callout" +import type { Network } from "@config/data/ccip/types" +import styles from "./ContractVerificationStep.module.css" + +interface ContractVerificationStepProps { + stepId: string + network: Network | null + contractAddress?: string + contractType: "token" | "pool" +} + +export const ContractVerificationStep = ({ + stepId, + network, + contractAddress, + contractType, +}: ContractVerificationStepProps) => { + // Debug values + console.log("ContractVerificationStep Props:", { + network, + contractAddress, + contractType, + hasNetwork: !!network?.explorerUrl, + hasAddress: !!contractAddress, + }) + + const explorerContractUrl = + contractAddress && network?.explorerUrl ? `${network.explorerUrl}/address/${contractAddress}#code` : undefined + + return ( + +
+ + Contract verification makes your {contractType} contract's source code public on the blockchain explorer. + This: +
    +
  • Builds trust by allowing anyone to audit your code
  • +
  • Enables direct interaction through the blockchain explorer
  • +
  • Helps other developers understand and integrate with your contract
  • +
+
+
+ +
    +
  1. + Access the Blockchain Explorer +
    + {network?.explorerUrl ? ( +
    +
    + Blockchain Explorer: + + {network.explorerUrl} + +
    +
    + ) : ( +
    + Blockchain explorer information will be available once you select a network. +
    + )} +
    +
  2. + +
  3. + Verify Using Remix IDE +
    +
    +
    +
    + Remix IDE Guide +
    +

    + Official guide for verifying contracts using the Remix IDE verification plugin +

    + + View Guide + +
    + +
    +
    + Chainlink Tutorial +
    +

    + Step-by-step tutorial for contract verification on blockchain explorers +

    + + View Tutorial + +
    +
    +
    +
  4. + +
  5. + Confirm Verification +
    +
      +
    1. Return to your contract on the blockchain explorer
    2. +
    3. Look for a green checkmark ✓ or "Verified" status
    4. +
    5. You should now see your contract's source code in the "Code" tab
    6. +
    + + {!network?.explorerUrl ? ( +
    + Contract verification link will be available once you select a network. +
    + ) : !contractAddress ? ( +
    + Contract verification link will be available after deployment. +
    + ) : ( +
    + Verify your contract at: + + {explorerContractUrl} + +
    + )} +
    +
  6. +
+
+ ) +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/DeployPoolStep.module.css b/src/components/CCIP/TutorialBlockchainSelector/DeployPoolStep.module.css new file mode 100644 index 00000000000..401b353903b --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/DeployPoolStep.module.css @@ -0,0 +1,217 @@ +.steps { + padding-left: var(--space-4x); + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-4x); +} + +.parametersIntro { + margin-bottom: var(--space-3x); +} + +.parameters { + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: var(--space-4x); + display: flex; + flex-direction: column; + gap: var(--space-3x); +} + +.addressInput { + margin-top: var(--space-2x); +} + +/* Pool-specific styles */ +.poolTypes { + display: flex; + gap: var(--space-2x); + margin-bottom: var(--space-3x); +} + +.poolTypes button { + padding: var(--space-2x) var(--space-4x); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + background: var(--color-background); + color: var(--color-text); + cursor: pointer; + transition: all 0.2s ease; +} + +.poolTypes button.active { + background: var(--color-accent); + color: white; + border-color: var(--color-accent); +} + +.poolDescription { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + margin-bottom: var(--space-3x); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .poolTypes { + flex-direction: column; + } + + .poolTypes button { + width: 100%; + } +} + +.poolSelection { + margin-bottom: var(--space-6x); +} + +.selectionHeader { + margin-bottom: var(--space-6x); +} + +.selectionTitle { + font-size: var(--font-size-xl); + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--space-2x); +} + +.selectionDescription { + color: var(--color-text-secondary); + font-size: var(--font-size-base); + margin-bottom: var(--space-4x); +} + +.poolOptions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4x); +} + +.poolOption { + position: relative; + padding: var(--space-4x); + border: 2px solid var(--color-border); + border-radius: var(--border-radius); + background: var(--color-background); + text-align: left; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + flex-direction: column; + gap: var(--space-4x); +} + +.poolOption:hover { + border-color: var(--color-accent); +} + +.poolOption.selected { + border-color: var(--color-accent); + background: var(--color-background-secondary); +} + +.poolTitle { + padding-bottom: var(--space-3x); + border-bottom: 1px solid var(--color-border); +} + +.poolName { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--color-text-primary); +} + +.selectedIndicator { + position: absolute; + top: var(--space-4x); + right: var(--space-4x); + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--color-accent); + border: 2px solid white; + box-shadow: 0 0 0 2px var(--color-accent); +} + +.selectedIndicator::after { + content: "✓"; + color: white; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: var(--font-size-base); + font-weight: bold; +} + +.poolContent { + display: flex; + flex-direction: column; + gap: var(--space-3x); + padding-top: var(--space-2x); +} + +.poolDescription { + color: var(--color-text); + font-size: var(--font-size-base); + line-height: 1.5; +} + +.poolNote { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + padding: var(--space-2x); + background: var(--color-background); + border-radius: var(--border-radius); +} + +@media (max-width: 768px) { + .poolOptions { + grid-template-columns: 1fr; + } +} + +.verificationIntro { + margin-bottom: 1rem; +} + +.verificationSteps { + list-style-type: decimal; + padding-left: 1.5rem; + margin-bottom: 1.5rem; +} + +.verificationSteps li { + margin-bottom: 0.75rem; + line-height: 1.5; +} + +.explorerLink { + display: inline-flex; + align-items: center; + color: var(--primary-color); + text-decoration: none; + margin-left: 0.5rem; + font-size: 0.9em; +} + +.explorerLink:hover { + text-decoration: underline; +} + +.externalIcon { + margin-left: 0.25rem; + font-size: 0.8em; +} + +.contractName { + display: block; + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + font-family: var(--font-mono); + margin-top: var(--space-1x); +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/DeployPoolStep.tsx b/src/components/CCIP/TutorialBlockchainSelector/DeployPoolStep.tsx new file mode 100644 index 00000000000..735c494d93a --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/DeployPoolStep.tsx @@ -0,0 +1,269 @@ +import { useState, useEffect } from "react" +import { useStore } from "@nanostores/react" +import { laneStore } from "@stores/lanes" +import { ContractAddress } from "./ContractAddress" +import { TutorialCard } from "../TutorialSetup/TutorialCard" +import { TutorialStep } from "../TutorialSetup/TutorialStep" +import { NetworkCheck } from "../TutorialSetup/NetworkCheck" +import { SolidityParam } from "../TutorialSetup/SolidityParam" +import { StoredContractAddress } from "./StoredContractAddress" +import { NetworkAddress } from "./NetworkAddress" +import { ContractVerificationStep } from "./ContractVerificationStep" +import { Callout } from "../TutorialSetup/Callout" +import type { LaneState, DeployedContracts } from "@stores/lanes" +import styles from "./DeployPoolStep.module.css" +import { utils } from "ethers" + +interface DeployPoolStepProps { + chain: "source" | "destination" +} + +// Extend LaneState to include the properties we need +interface ExtendedLaneState extends Omit { + tokenPoolAddress?: { + [key in "source" | "destination"]?: string + } + sourceContracts: { + tokenPool?: string + } & DeployedContracts + destinationContracts: { + tokenPool?: string + } & DeployedContracts +} + +export const DeployPoolStep = ({ chain }: DeployPoolStepProps) => { + const [poolType, setPoolType] = useState<"lock" | "burn">("burn") + const state = useStore(laneStore) as ExtendedLaneState + + // Add effect to store pool type when valid address is provided + useEffect(() => { + const currentContracts = chain === "source" ? state.sourceContracts : state.destinationContracts + + // Only update pool type when we have a valid address + if (currentContracts.tokenPool && utils.isAddress(currentContracts.tokenPool)) { + const current = laneStore.get() + + // Debug log before update + console.log(`[PoolType Update] ${chain}:`, { + address: currentContracts.tokenPool, + currentPoolType: currentContracts.poolType, + newPoolType: poolType, + timestamp: new Date().toISOString(), + }) + + if (chain === "source") { + laneStore.set({ + ...current, + sourceContracts: { + ...current.sourceContracts, + poolType, + }, + }) + } else { + laneStore.set({ + ...current, + destinationContracts: { + ...current.destinationContracts, + poolType, + }, + }) + } + } + }, [chain, poolType, state.sourceContracts.tokenPool, state.destinationContracts.tokenPool]) + + // Debug store values + console.log("DeployPoolStep Store:", { + chain, + poolAddress: state.tokenPoolAddress, + sourceContract: state.sourceContracts?.tokenPool, + destContract: state.destinationContracts?.tokenPool, + chainPoolAddress: state.tokenPoolAddress?.[chain], + network: state.sourceNetwork, + stateKeys: Object.keys(state), + }) + + const network = chain === "source" ? state.sourceNetwork : state.destinationNetwork + const contractAddress = chain === "source" ? state.sourceContracts?.tokenPool : state.destinationContracts?.tokenPool + + const networkInfo = network + ? { + name: network.name, + logo: network.logo, + } + : { name: "loading..." } + + const stepId = chain === "source" ? "sourceChain" : "destinationChain" + const getSubStepId = (subStep: string) => `${stepId}-${subStep}` + const deployedStepId = chain === "source" ? "pool-deployed" : "dest-pool-deployed" + + return ( + + + +
    + + + Each pool type serves different use cases and has specific requirements. Learn more about pool types and + their characteristics in the{" "} + token pools documentation. + +
    + Select the appropriate pool type based on your token's characteristics and requirements +
    + +
    + + + +
    +
    + + +
      +
    • Open the "Deploy & Run Transactions" tab
    • +
    • Set Environment to "Injected Provider - MetaMask"
    • +
    • + Select {poolType === "burn" ? "BurnMintTokenPool" : "LockReleaseTokenPool"} contract +
    • +
    +
    + + +
    +

    Configure your pool by setting these required parameters in Remix:

    +
    +
    + {poolType === "lock" ? ( + <> + } + /> + + + } + /> + + } + /> + + ) : ( + <> + } + /> + + + } + /> + } + /> + + )} +
    +
    + + +
      +
    • Click "Deploy" and confirm in MetaMask
    • +
    • Copy your pool address from "Deployed Contracts"
    • +
    +
    + +
    +
    + + +
+
+ ) +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/DeployTokenStep.module.css b/src/components/CCIP/TutorialBlockchainSelector/DeployTokenStep.module.css new file mode 100644 index 00000000000..cf03c8ac49c --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/DeployTokenStep.module.css @@ -0,0 +1,54 @@ +.parameters { + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: var(--space-4x); + display: flex; + flex-direction: column; + gap: var(--space-3x); +} + +.parametersIntro { + margin-bottom: var(--space-3x); +} + +.steps { + padding-left: var(--space-4x); /* Space for HTML numbers */ + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-4x); +} + +.verificationIntro { + margin-bottom: 1rem; +} + +.verificationSteps { + list-style-type: decimal; + padding-left: 1.5rem; + margin-bottom: 1.5rem; +} + +.verificationSteps li { + margin-bottom: 0.75rem; + line-height: 1.5; +} + +.explorerLink { + display: inline-flex; + align-items: center; + color: var(--primary-color); + text-decoration: none; + margin-left: 0.5rem; + font-size: 0.9em; +} + +.explorerLink:hover { + text-decoration: underline; +} + +.externalIcon { + margin-left: 0.25rem; + font-size: 0.8em; +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/DeployTokenStep.tsx b/src/components/CCIP/TutorialBlockchainSelector/DeployTokenStep.tsx new file mode 100644 index 00000000000..39852f3dbcd --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/DeployTokenStep.tsx @@ -0,0 +1,158 @@ +import { useStore } from "@nanostores/react" +import { laneStore } from "@stores/lanes" +import { ContractAddress } from "./ContractAddress" +import { TutorialCard } from "../TutorialSetup/TutorialCard" +import { TutorialStep } from "../TutorialSetup/TutorialStep" +import { NetworkCheck } from "../TutorialSetup/NetworkCheck" +import { SolidityParam } from "../TutorialSetup/SolidityParam" +import { Callout } from "../TutorialSetup/Callout" +import { ContractVerificationStep } from "./ContractVerificationStep" +import type { LaneState, DeployedContracts } from "@stores/lanes" +import styles from "./DeployTokenStep.module.css" + +interface DeployTokenStepProps { + chain: "source" | "destination" + isEnabled: boolean +} + +// Extend LaneState to include the properties we need +interface ExtendedLaneState extends Omit { + tokenAddress?: { + [key in "source" | "destination"]?: string + } + sourceContracts: DeployedContracts + destinationContracts: DeployedContracts +} + +export const DeployTokenStep = ({ chain }: DeployTokenStepProps) => { + const state = useStore(laneStore) as ExtendedLaneState + + // Debug store values + console.log("DeployTokenStep Store:", { + chain, + tokenAddress: state.tokenAddress, + sourceContract: state.sourceContracts?.token, + destContract: state.destinationContracts?.token, + chainTokenAddress: state.tokenAddress?.[chain], + network: state.sourceNetwork, + stateKeys: Object.keys(state), + }) + + const network = chain === "source" ? state.sourceNetwork : state.destinationNetwork + const contractAddress = chain === "source" ? state.sourceContracts?.token : state.destinationContracts?.token + + const networkInfo = network + ? { + name: network.name, + logo: network.logo, + } + : { name: "loading..." } + + const stepId = chain === "source" ? "sourceChain" : "destinationChain" + const getSubStepId = (subStep: string) => `${stepId}-${subStep}` + const deployedStepId = chain === "source" ? "token-deployed" : "dest-token-deployed" + + const content = ( + <> + + + + If you have an existing token that meets the{" "} + CCT requirements: +
    +
  • Skip the "Deploy Token" section
  • +
  • Enter your existing token address in the address field below
  • +
  • Continue with "Claim and Accept Admin Role"
  • +
+

The tutorial will use your provided token address for subsequent steps.

+
+ +
    + +
      +
    • Open the "Deploy & Run Transactions" tab
    • +
    • Set Environment to "Injected Provider - MetaMask"
    • +
    • + Select BurnMintERC677 contract +
    • +
    +
    + + +
    +

    Configure your token by setting these required parameters in Remix:

    +
    + + +
      +
    • The name and symbol help identify your token in wallets and applications.
    • +
    • + Using 18 decimals is standard for most ERC20 tokens (1 token = 1000000000000000000 wei or 10 + 18). +
    • +
    • + If maxSupply is set to 0, it allows unlimited minting. For a limited supply, you must scale the amount + according to the number of decimals. For example, if you want a max supply of 1,000 tokens with 18 + decimals, the maxSupply would be + + 1000 * 1018 + {" "} + = 1000000000000000000000 (that's 1 followed by 21 zeros). +
    • +
    +
    + +
    + + + + +
    +
    + + +
      +
    • Click "Deploy" and confirm in MetaMask
    • +
    • Copy your token address from "Deployed Contracts"
    • +
    +
    + +
    +
    + + +
+ + ) + + return ( + + {content} + + ) +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/GrantPrivilegesStep.module.css b/src/components/CCIP/TutorialBlockchainSelector/GrantPrivilegesStep.module.css new file mode 100644 index 00000000000..77bb8cf08aa --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/GrantPrivilegesStep.module.css @@ -0,0 +1,69 @@ +.steps { + padding-left: var(--space-4x); + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-4x); +} + +.instructions { + padding-left: var(--space-6x); + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-3x); +} + +.functionCall { + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: var(--space-4x); + margin: var(--space-2x) 0; +} + +.functionHeader { + margin-bottom: var(--space-3x); +} + +.functionName { + font-family: var(--font-mono); + font-size: var(--font-size-lg); + color: var(--color-text-primary); + background: var(--color-background); + padding: var(--space-1x) var(--space-2x); + border-radius: 4px; +} + +.functionPurpose { + color: var(--color-text); + margin-top: var(--space-2x); + font-size: var(--font-size-base); +} + +.functionRequirement { + color: var(--color-warning); + font-size: var(--font-size-sm); + margin-bottom: var(--space-3x); +} + +.parametersSection { + border-top: 1px solid var(--color-border); + padding-top: var(--space-3x); +} + +.parametersTitle { + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--space-2x); +} + +.parametersList { + display: flex; + flex-direction: column; + gap: var(--space-3x); +} + +.skipNote { + margin-bottom: var(--space-4x); +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/GrantPrivilegesStep.tsx b/src/components/CCIP/TutorialBlockchainSelector/GrantPrivilegesStep.tsx new file mode 100644 index 00000000000..072af6b9915 --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/GrantPrivilegesStep.tsx @@ -0,0 +1,80 @@ +import { useStore } from "@nanostores/react" +import { laneStore, TUTORIAL_STEPS } from "@stores/lanes" +import { TutorialStep } from "../TutorialSetup/TutorialStep" +import { NetworkCheck } from "../TutorialSetup/NetworkCheck" +import { StepCheckbox } from "../TutorialProgress/StepCheckbox" +import { SolidityParam } from "../TutorialSetup/SolidityParam" +import { StoredContractAddress } from "./StoredContractAddress" +import { Callout } from "../TutorialSetup/Callout" +import { TutorialCard } from "../TutorialSetup/TutorialCard" +import styles from "./GrantPrivilegesStep.module.css" + +interface GrantPrivilegesStepProps { + chain: "source" | "destination" +} + +export const GrantPrivilegesStep = ({ chain }: GrantPrivilegesStepProps) => { + const state = useStore(laneStore) + const network = chain === "source" ? state.sourceNetwork : state.destinationNetwork + const networkInfo = network ? { name: network.name, logo: network.logo } : { name: "loading..." } + + const stepId = chain === "source" ? "sourceConfig" : "destinationConfig" + const subStepId = chain === "source" ? "source-privileges" : "dest-privileges" + const navigationId = `${stepId}-${subStepId}` + + return ( + + +
    + } + > + + Skip this section if you deployed a LockReleaseTokenPool + + +
      +
    1. + In the list of deployed contracts, select the BurnMintERC677 at{" "} + +
    2. +
    3. Click to open the contract details
    4. +
    5. + Call grantMintAndBurnRoles: +
      +
      + grantMintAndBurnRoles +
      + Grant mint and burn privileges to your token pool for cross-chain transfers +
      +
      + +
      + ⚠️ You must be the token contract owner to call this function +
      + +
      +
      Parameters:
      +
      + } + /> +
      +
      +
      +
    6. +
    7. Confirm the transaction in MetaMask
    8. +
    +
    +
+
+ ) +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/NetworkAddress.tsx b/src/components/CCIP/TutorialBlockchainSelector/NetworkAddress.tsx new file mode 100644 index 00000000000..4652cea94a6 --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/NetworkAddress.tsx @@ -0,0 +1,57 @@ +import { useStore } from "@nanostores/react" +import { laneStore } from "@stores/lanes" +import { getAllNetworks } from "@config/data/ccip" +import { ReactCopyText } from "@components/ReactCopyText" + +type ContractType = "registryModule" | "tokenAdminRegistry" | "router" | "armProxy" | "chainSelector" + +interface NetworkAddressProps { + chain: "source" | "destination" + type: ContractType + required?: boolean +} + +export const NetworkAddress = ({ chain, type, required = true }: NetworkAddressProps) => { + const state = useStore(laneStore) + const chainId = chain === "source" ? state.sourceChain : state.destinationChain + + if (!chainId) return required ? [Select {chain} blockchain first] : null + + const networks = getAllNetworks({ filter: state.environment }) + const network = networks.find((n) => n.chain === chainId) + + if (!network) return required ? Network not found : null + + let address: string | undefined + switch (type) { + case "registryModule": + address = network.registryModule + break + case "tokenAdminRegistry": + address = network.tokenAdminRegistry + break + case "router": + address = network.router?.address + break + case "armProxy": + address = network.armProxy?.address + break + case "chainSelector": + address = network.chainSelector + break + } + + if (!address) return required ? Contract address not available : null + + return ( + + ) +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/PoolConfigVerification.module.css b/src/components/CCIP/TutorialBlockchainSelector/PoolConfigVerification.module.css new file mode 100644 index 00000000000..8a670f8e478 --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/PoolConfigVerification.module.css @@ -0,0 +1,581 @@ +.verificationCard { + position: relative; + background: var(--color-background); + border-radius: 12px; + padding: 32px; + margin: 16px 0; + transition: all 0.2s ease; + border: 1px solid var(--color-border); + --section-spacing: 32px; +} + +.verificationCard::before { + content: ""; + position: absolute; + left: 0; + top: 0; + width: 3px; + height: 100%; + background: var(--color-accent); + border-radius: 3px 0 0 3px; + opacity: 0; + transition: opacity 0.2s ease; +} + +.verificationCard:hover::before { + opacity: 1; +} + +.chainInfo { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-bottom: var(--section-spacing); +} + +.chainDetails { + position: relative; + padding: 20px; + border-radius: 12px; + background: var(--color-background); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02); + transition: all 0.2s ease; + overflow: hidden; +} + +.chainDetails::after { + content: ""; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 2px; + background: var(--color-accent); + transform: scaleX(0); + transform-origin: left; + transition: transform 0.2s ease; +} + +.chainDetails:hover::after { + transform: scaleX(1); +} + +.step { + position: relative; + padding: 32px; + margin-bottom: 24px; + border-radius: 12px; + border: 1px solid var(--color-border); + transition: all 0.2s ease; + background: var(--color-background); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02); +} + +.step:hover { + border-color: var(--color-accent); + transform: translateX(4px); +} + +.step::after { + content: ""; + position: absolute; + inset: -1px; + background: var(--color-accent); + opacity: 0; + border-radius: 12px; + z-index: -1; + transition: opacity 0.2s ease; +} + +.step:hover::after { + opacity: 0.1; +} + +.step:not(:last-child) { + border-bottom: none; +} + +.step:first-child { + margin-top: 0; +} + +.stepContent { + position: relative; + z-index: 1; +} + +.stepHeader { + margin-bottom: 20px; +} + +.parameterList { + border-radius: 8px; + background: var(--color-background-secondary); + margin: 16px 0; + padding: 16px; +} + +.parameter { + position: relative; + padding: 12px 16px; + transition: all 0.2s ease; + background: var(--color-background); +} + +.parameter:hover { + background: var(--color-background-secondary); +} + +.parameter:not(:last-child) { + border-bottom: 1px solid var(--color-border); +} + +.tokenBucketItem { + border-radius: 8px; + background: var(--color-background); + padding: 12px 16px; + margin-bottom: 8px; + border: 1px solid var(--color-border); + transition: border-color 0.2s ease; + transform-style: preserve-3d; + backface-visibility: hidden; + transform: translate3d(0, 0, 0); + will-change: transform; +} + +.tokenBucketItem:hover { + border-color: var(--color-accent); + transform: translate3d(4px, 0, 0); +} + +.tokenBucketItem::after { + content: ""; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 2px; + background: var(--color-accent); + transform: translate3d(0, 0, 0) scaleX(0); + transform-origin: left; + transition: transform 0.2s ease; + will-change: transform; +} + +.tokenBucketItem:hover::after { + transform: translate3d(0, 0, 0) scaleX(1); +} + +.configured, +.notConfigured { + position: relative; + padding-left: 16px; +} + +.configured::before, +.notConfigured::before { + content: ""; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 6px; + height: 6px; + border-radius: 50%; + transition: transform 0.2s ease; +} + +.configured::before { + background: var(--color-success); +} + +.notConfigured::before { + background: var(--color-warning); +} + +.loadingState { + text-align: center; + padding: 32px 16px; + color: var(--color-text-secondary); +} + +.loadingState span { + display: block; + font-weight: 500; + margin-bottom: 8px; + color: var(--color-text-primary); +} + +.loadingState p { + font-size: 14px; +} + +.chainValue { + display: flex; + flex-direction: column; + gap: 4px; +} + +.chainValue code { + font-family: var(--font-family-mono); + font-size: 14px; + color: var(--color-text-primary); +} + +.chainSelector { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.chainSelector span { + color: var(--color-text-secondary); +} + +.poolAddress { + display: flex; + align-items: center; + gap: 12px; + padding-bottom: 24px; + margin-bottom: var(--section-spacing); + border-bottom: 1px solid var(--color-border); + padding: 20px; + background: var(--color-background); + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02); +} + +.poolAddress span { + font-size: 14px; + color: var(--color-text-secondary); +} + +.stepTitle { + font-family: var(--font-family-mono); + font-size: 16px; + color: var(--color-accent); +} + +.functionDetails { + display: flex; + align-items: center; + gap: 8px; +} + +.functionName { + font-family: var(--font-family-mono); + font-size: 13px; + color: var(--color-accent); + padding: 4px 8px; + background: var(--color-background-secondary); + border-radius: 4px; +} + +.stepDescription { + color: var(--color-text-secondary); + font-size: 14px; + line-height: 1.5; + margin-bottom: 16px; +} + +.parameter { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; +} + +.parameter:last-child { + margin-bottom: 0; +} + +.parameterHeader { + display: flex; + align-items: center; + gap: 12px; +} + +.parameterName { + display: flex; + align-items: center; + gap: 8px; +} + +.parameterName span { + color: var(--color-text-secondary); + font-size: 14px; +} + +.parameterName code { + font-family: var(--font-family-mono); + font-size: 13px; + padding: 2px 6px; + background: var(--color-background-secondary); + border-radius: 4px; + color: var(--color-accent); +} + +.parameterRequired { + font-size: 11px; + color: var(--color-text-secondary); + background: var(--color-background); + padding: 2px 6px; + border-radius: 3px; +} + +.parameterValue { + display: flex; + align-items: center; + gap: 8px; +} + +.parameterChainName { + font-size: 12px; + color: var(--color-text-secondary); +} + +.expectedResult { + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: var(--space-3x); + display: flex; + flex-direction: column; + gap: var(--space-3x); +} + +.resultLabel { + display: block; + color: var(--color-text-secondary); + font-size: 13px; + margin-bottom: 12px; +} + +.resultContent { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + line-height: 1.5; + overflow-wrap: break-word; + margin-bottom: var(--space-2x); +} + +.expectedResult :global(.callout) { + margin: 0; +} + +.tokenBucket { + display: grid; + gap: 8px; +} + +.tokenBucketItem { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + background: var(--color-background); + border-radius: 4px; +} + +.tokenBucketItem span { + color: var(--color-text-secondary); + font-size: 13px; +} + +.tokenBucketItem code { + font-family: var(--font-family-mono); + font-size: 13px; + color: var(--color-text-primary); +} + +.tokenBucketNote { + margin-top: 16px; + padding-top: 16px; + border-top: 1px dashed var(--color-border); +} + +.tokenBucketNote span { + display: inline-block; + font-size: 12px; + font-weight: 500; + color: var(--color-text-secondary); + margin-bottom: 4px; +} + +.tokenBucketNote p { + font-size: 12px; + color: var(--color-text-secondary); + line-height: 1.5; +} + +.tokenBucketNote code { + font-size: 11px; + padding: 1px 4px; + background: var(--color-background); + border-radius: 3px; +} + +.tokenBucketItem code { + display: flex; + align-items: center; + gap: 4px; +} + +.tokenBucketItem code span { + font-family: var(--font-family-mono); +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 768px) { + .chainInfo { + grid-template-columns: 1fr; + gap: 16px; + } +} + +.configurationSection { + margin-top: 24px; + padding: 20px; + border-radius: 12px; + border: 1px solid var(--color-border); + background: var(--color-background); +} + +.sectionTitle { + font-size: 16px; + font-weight: 500; + color: var(--color-text-primary); + margin-bottom: 16px; +} + +/* Returns Section - Unique to verification */ +.returnsSection { + border-top: 1px solid var(--color-border); + padding-top: var(--space-3x); +} + +.returnsTitle { + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--space-2x); +} + +.returnsList { + display: flex; + flex-direction: column; + gap: var(--space-2x); +} + +.returnValue { + display: flex; + flex-direction: column; + gap: var(--space-1x); +} + +.returnType { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.returnDescription { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + line-height: 1.5; +} + +/* Token Bucket Display */ +.tokenBucket { + display: grid; + gap: var(--space-2x); +} + +.tokenBucketItem { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-2x); + background: var(--color-background); + border-radius: var(--border-radius); + border: 1px solid var(--color-border); +} + +.tokenBucketItem span { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.tokenBucketItem code { + font-family: var(--font-mono); + color: var(--color-text-primary); +} + +/* Expected Result Section */ +.expectedResult { + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: var(--space-3x); + display: flex; + flex-direction: column; + gap: var(--space-3x); +} + +.resultContent { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + line-height: 1.5; + overflow-wrap: break-word; + margin-bottom: var(--space-2x); +} + +/* Callout positioning */ +.expectedResult :global(.callout) { + margin: 0; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .tokenBucket { + grid-template-columns: 1fr; + } +} + +/* Function Call Styling */ +.functionCall { + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: var(--space-4x); + margin: var(--space-2x) 0; +} + +.functionHeader { + margin-bottom: var(--space-3x); +} + +.functionName { + font-family: var(--font-mono); + font-size: var(--font-size-lg); + color: var(--color-text-primary); + background: var(--color-background); + padding: var(--space-1x) var(--space-2x); + border-radius: 4px; +} + +.functionPurpose { + color: var(--color-text); + margin-top: var(--space-2x); + font-size: var(--font-size-base); +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/PoolConfigVerification.tsx b/src/components/CCIP/TutorialBlockchainSelector/PoolConfigVerification.tsx new file mode 100644 index 00000000000..a34d33b5760 --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/PoolConfigVerification.tsx @@ -0,0 +1,265 @@ +import { useStore } from "@nanostores/react" +import { laneStore, TUTORIAL_STEPS, type RateLimits } from "@stores/lanes" +import { utils } from "ethers" +import { ReactCopyText } from "@components/ReactCopyText" +import styles from "./PoolConfigVerification.module.css" +import { TutorialCard, TutorialStep, NetworkCheck, SolidityParam } from "../TutorialSetup" +import { Callout } from "../TutorialSetup/Callout" +import { StepCheckbox } from "../TutorialProgress/StepCheckbox" + +type ChainType = "source" | "destination" + +const formatRateLimiterState = (limits: RateLimits | null, type: "inbound" | "outbound") => { + const config = limits?.[type] + + return ( +
+
+ Tokens + 0 +
+
+ Last Updated + Current block timestamp +
+
+ Status + {config?.enabled ? "true" : "false"} +
+
+ Capacity + {config?.capacity || "0"} +
+
+ Rate + {config?.rate || "0"} +
+
+ ) +} + +export const PoolConfigVerification = ({ chain }: { chain: ChainType }) => { + const state = useStore(laneStore) + const currentNetwork = chain === "source" ? state.sourceNetwork : state.destinationNetwork + const remoteNetwork = chain === "source" ? state.destinationNetwork : state.sourceNetwork + const networkInfo = currentNetwork ? { name: currentNetwork.name, logo: currentNetwork.logo } : { name: "loading..." } + const remoteSelector = remoteNetwork?.chainSelector + const remoteName = remoteNetwork?.name + const remoteContracts = chain === "source" ? state.destinationContracts : state.sourceContracts + const poolAddress = chain === "source" ? state.sourceContracts.tokenPool : state.destinationContracts.tokenPool + const rateLimits = chain === "source" ? state.sourceRateLimits : state.destinationRateLimits + const poolType = chain === "source" ? state.sourceContracts.poolType : state.destinationContracts.poolType + + const stepId = chain === "source" ? "sourceConfig" : "destinationConfig" + const subStepId = chain === "source" ? "source-verification" : "dest-verification" + const navigationId = `${stepId}-${subStepId}` + + return ( + + +
    + } + > +
      +
    1. Open the "Deploy & Run Transactions" tab in Remix
    2. +
    3. + Select your token pool contract: +
      + {poolType === "burn" ? "BurnMintTokenPool" : "LockReleaseTokenPool"} + +
      +
    4. +
    5. Click the contract to view its functions
    6. +
    +
    + + +
      +
    1. + Call getRemoteToken: +
      +
      + getRemoteToken +
      + Retrieves the ABI-encoded address of your token on the remote chain +
      +
      + +
      +
      Parameters:
      +
      + } + /> +
      +
      + +
      +
      Returns:
      +
      +
      +
      bytes
      +
      + ABI-encoded address of the token on the remote chain +
      +
      +
      +
      + +
      +
      Expected Result:
      +
      + {remoteContracts.token + ? utils.defaultAbiCoder.encode(["address"], [remoteContracts.token]) + : "Waiting for remote token address..."} +
      +
      +
      +
    2. +
    +
    + + +
      +
    1. + Call getRemotePools: +
      +
      + getRemotePools +
      Returns all registered token pools on the remote chain
      +
      + +
      +
      Parameters:
      +
      + } + /> +
      +
      + +
      +
      Returns:
      +
      +
      +
      bytes[]
      +
      + Array of ABI-encoded addresses of all registered pools on the remote chain +
      +
      +
      +
      + +
      +
      Expected Result:
      +
      + {remoteContracts.tokenPool + ? [utils.defaultAbiCoder.encode(["address"], [remoteContracts.tokenPool])] + : "Waiting for remote pool address..."} +
      +
      +
      +
    2. +
    +
    + + +
      +
    1. + Call getCurrentInboundRateLimiterState: +
      +
      + getCurrentInboundRateLimiterState +
      Verifies your inbound transfer rate limits
      +
      + +
      +
      Parameters:
      +
      + } + /> +
      +
      + +
      +
      Returns:
      +
      +
      +
      TokenBucket
      +
      Current state of the inbound rate limiter
      +
      +
      +
      + +
      +
      Expected Result:
      +
      {formatRateLimiterState(rateLimits, "inbound")}
      + + The tokens field starts at 0 and the lastUpdated field will show the + current block timestamp. + +
      +
      +
    2. +
    3. + Call getCurrentOutboundRateLimiterState: +
      +
      + getCurrentOutboundRateLimiterState +
      Verifies your outbound transfer rate limits
      +
      + +
      +
      Parameters:
      +
      + } + /> +
      +
      + +
      +
      Returns:
      +
      +
      +
      TokenBucket
      +
      Current state of the outbound rate limiter
      +
      +
      +
      + +
      +
      Expected Result:
      +
      {formatRateLimiterState(rateLimits, "outbound")}
      + + The tokens field starts at 0 and the lastUpdated field will show the + current block timestamp. + +
      +
      +
    4. +
    +
    +
+
+ ) +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/SetPoolStep.module.css b/src/components/CCIP/TutorialBlockchainSelector/SetPoolStep.module.css new file mode 100644 index 00000000000..ac9ee07e51b --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/SetPoolStep.module.css @@ -0,0 +1,81 @@ +.steps { + padding-left: var(--space-4x); + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-4x); +} + +.instructions { + padding-left: var(--space-6x); + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-3x); +} + +.contractInfo { + display: flex; + align-items: center; + gap: var(--space-3x); + padding: var(--space-3x); + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + margin-bottom: var(--space-3x); +} + +.contractInfo strong { + color: var(--color-text-primary); + font-weight: 600; +} + +.functionCall { + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: var(--space-4x); + margin: var(--space-2x) 0; +} + +.functionHeader { + margin-bottom: var(--space-3x); +} + +.functionName { + font-family: var(--font-mono); + font-size: var(--font-size-lg); + color: var(--color-text-primary); + background: var(--color-background); + padding: var(--space-1x) var(--space-2x); + border-radius: 4px; +} + +.functionPurpose { + color: var(--color-text); + margin-top: var(--space-2x); + font-size: var(--font-size-base); +} + +.functionRequirement { + color: var(--color-warning); + font-size: var(--font-size-sm); + margin-bottom: var(--space-3x); +} + +.parametersSection { + border-top: 1px solid var(--color-border); + padding-top: var(--space-3x); +} + +.parametersTitle { + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--space-2x); +} + +.parametersList { + display: flex; + flex-direction: column; + gap: var(--space-3x); +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/SetPoolStep.tsx b/src/components/CCIP/TutorialBlockchainSelector/SetPoolStep.tsx new file mode 100644 index 00000000000..b1fd494d57f --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/SetPoolStep.tsx @@ -0,0 +1,84 @@ +import { useStore } from "@nanostores/react" +import { laneStore } from "@stores/lanes" +import { NetworkCheck } from "../TutorialSetup/NetworkCheck" +import { TutorialCard } from "../TutorialSetup/TutorialCard" +import { TutorialStep } from "../TutorialSetup/TutorialStep" +import { NetworkAddress } from "./NetworkAddress" +import { StoredContractAddress } from "./StoredContractAddress" +import { StepCheckbox } from "../TutorialProgress/StepCheckbox" +import { SolidityParam } from "../TutorialSetup/SolidityParam" +import styles from "./SetPoolStep.module.css" + +interface SetPoolStepProps { + chain: "source" | "destination" +} + +export const SetPoolStep = ({ chain }: SetPoolStepProps) => { + const state = useStore(laneStore) + const network = chain === "source" ? state.sourceNetwork : state.destinationNetwork + const networkInfo = network ? { name: network.name, logo: network.logo } : { name: "loading..." } + const stepId = chain === "source" ? "sourceChain" : "destinationChain" + const subStepId = chain === "source" ? "pool-registered" : "dest-pool-registered" + + const getSubStepId = (subStep: string) => `${stepId}-${subStep}` + + return ( + + + +
    + } + > +
      +
    1. + In the "Deploy & Run Transactions" tab, select the TokenAdminRegistry contract in the{" "} + Contracts drop-down list. +
    2. +
    3. + Next to the At Address button, fill in the following contract address, and then click the{" "} + At Address button. +
      + Contract: TokenAdminRegistry + +
      +
    4. +
    5. Select the TokenAdminRegistry contract to expand its details
    6. +
    7. + Call setPool: +
      +
      + setPool +
      Enable your token for CCIP by registering its token pool
      +
      + +
      ⚠️ You must be the token admin to call this function
      + +
      +
      Parameters:
      +
      + } + /> + } + /> +
      +
      +
      +
    8. +
    9. Confirm the transaction in MetaMask
    10. +
    +
    +
+
+ ) +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/StoredContractAddress.tsx b/src/components/CCIP/TutorialBlockchainSelector/StoredContractAddress.tsx new file mode 100644 index 00000000000..b16a18b19ef --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/StoredContractAddress.tsx @@ -0,0 +1,42 @@ +import { useStore } from "@nanostores/react" +import { laneStore } from "@stores/lanes" +import type { DeployedContracts } from "@stores/lanes" +import { ReactCopyText } from "@components/ReactCopyText" +import { utils } from "ethers" + +type AddressFields = Extract + +interface StoredContractAddressProps { + type: AddressFields + chain: "source" | "destination" + code?: boolean + encode?: boolean +} + +export const StoredContractAddress = ({ type, chain, code = true, encode = false }: StoredContractAddressProps) => { + const state = useStore(laneStore) + const contracts = chain === "source" ? state.sourceContracts : state.destinationContracts + const value = contracts[type] + + // Format and encode addresses + const displayAddress = (() => { + if (!value) return "" + + if (Array.isArray(value)) { + const validAddresses = value.filter((addr): addr is string => typeof addr === "string" && utils.isAddress(addr)) + if (!validAddresses.length) return "" + + return encode + ? validAddresses.map((addr) => utils.defaultAbiCoder.encode(["address"], [addr])).join(", ") + : validAddresses.join(", ") + } + + if (typeof value === "string" && value) { + return encode ? utils.defaultAbiCoder.encode(["address"], [utils.getAddress(value)]) : utils.getAddress(value) + } + + return "" + })() + + return +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/TutorialBlockchainSelector.module.css b/src/components/CCIP/TutorialBlockchainSelector/TutorialBlockchainSelector.module.css new file mode 100644 index 00000000000..8b7df65bf76 --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/TutorialBlockchainSelector.module.css @@ -0,0 +1,379 @@ +.blockchainSelector { + --section-spacing: 24px; + padding: 24px; + background: var(--color-background); + border-radius: 16px; + display: flex; + flex-direction: column; + gap: var(--section-spacing); + border: 1px solid var(--color-border); + transition: background-color 0.3s ease; +} + +/* Environment Section */ +.environmentSection { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.sectionTitle { + font-size: 14px; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.environmentToggle { + margin: 0 auto; + background: var(--color-background-secondary); + padding: 4px; + border-radius: 12px; + display: flex; + gap: 4px; + transition: background-color 0.3s ease; +} + +.environmentToggle:target { + background-color: rgba(55, 91, 210, 0.1); + border-radius: 8px; + outline: 2px solid rgba(55, 91, 210, 0.2); +} + +.toggleButton { + padding: 10px 20px; + border: none; + background: rgba(var(--color-background-rgb), 0.6); + border-radius: 8px; + font-size: 14px; + font-weight: 500; + color: var(--color-text-secondary); + transition: all 0.2s ease; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + min-width: 120px; + justify-content: center; + position: relative; + z-index: 1; +} + +.toggleIcon { + font-size: 16px; + opacity: 0.8; +} + +.active .toggleIcon { + opacity: 1; +} + +.toggleButton.active { + background: var(--color-background); + color: var(--color-accent); + font-weight: 600; + box-shadow: 0 4px 12px rgba(var(--color-accent-rgb), 0.15), 0 2px 4px rgba(var(--color-accent-rgb), 0.1); + transform: translateY(-1px); + background: linear-gradient(to bottom, var(--color-background), rgba(var(--color-accent-rgb), 0.03)); +} + +.toggleButton.active::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + to bottom right, + rgba(var(--color-accent-rgb), 0.15), + rgba(var(--color-accent-rgb), 0.05) + ); + border-radius: inherit; +} + +.toggleButton.active::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + border: 1px solid rgba(var(--color-accent-rgb), 0.2); +} + +/* Make inactive state clearer but still accessible */ +.toggleButton:not(.active) { + opacity: 0.8; + background: transparent; +} + +.toggleButton:not(.active):hover { + background: rgba(var(--color-background-rgb), 0.8); +} + +/* Chain Selection */ +.chainSection { + padding-top: var(--section-spacing); + border-top: 1px solid var(--color-border); +} + +.chainSelectors { + display: grid; + grid-template-columns: minmax(250px, 2fr) auto minmax(250px, 2fr); + gap: 32px; + align-items: center; + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 0 16px; + transition: background-color 0.3s ease; +} + +.chainSelectors:target { + background-color: rgba(55, 91, 210, 0.1); + border-radius: 8px; + outline: 2px solid rgba(55, 91, 210, 0.2); +} + +.chainSelector { + display: flex; + flex-direction: column; + gap: 12px; +} + +.chainLabel { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); + text-align: center; +} + +.selectWrapper { + position: relative; + height: 48px; +} + +.chainLogo { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 24px; + border-radius: 50%; + z-index: 1; +} + +.select { + width: 100%; + height: 100%; + padding: 0 16px 0 48px; + border: 1px solid var(--color-border); + border-radius: 12px; + background: var(--color-background); + font-size: 14px; + font-weight: 500; + color: var(--color-text-primary); + cursor: pointer; + appearance: none; + transition: all 0.2s ease; +} + +.select:hover { + border-color: var(--color-accent); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.select:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(var(--color-accent-rgb), 0.1); +} + +/* Arrow styling */ +.arrowContainer { + --touch-target-size: 44px; + --animation-timing: cubic-bezier(0.34, 1.56, 0.64, 1); + + display: flex; + align-items: center; + justify-content: center; + min-width: var(--touch-target-size); + min-height: var(--touch-target-size); + margin-inline: auto; + padding-block: 0; + padding-inline: 1rem; + position: relative; + + /* Make it purely decorative */ + pointer-events: none; + user-select: none; +} + +.arrow { + color: var(--color-text-secondary); + opacity: 0.7; + transition: opacity 0.3s var(--animation-timing); +} + +.arrowGroup { + transform-origin: center; +} + +/* Subtle animation */ +@media (prefers-reduced-motion: no-preference) { + .arrowPath { + opacity: 0.7; + animation: subtlePulse 3s var(--animation-timing) infinite; + } + + .arrowLine { + opacity: 0.5; + animation: subtlePulse 3s var(--animation-timing) infinite; + } + + .arrowPath:first-child { + animation-delay: 0s; + } + + .arrowPath:last-child { + animation-delay: 1.5s; + } + + @keyframes subtlePulse { + 0%, + 100% { + opacity: 0.7; + transform: translateX(0); + } + 50% { + opacity: 0.9; + transform: translateX(1px); + } + } +} + +/* RTL Support */ +[dir="rtl"] .arrowContainer { + transform: scaleX(-1); +} + +/* Vertical writing modes */ +[writing-mode="vertical-rl"] .arrowContainer { + transform: rotate(90deg); +} + +/* Interactive States */ +.arrowContainer:hover .arrow, +.arrowContainer.focused .arrow { + color: var(--color-primary); +} + +.arrowContainer:hover .arrowGroup, +.arrowContainer.focused .arrowGroup { + transform: scale(1.05); +} + +.arrowContainer:active .arrowGroup { + transform: scale(0.95); +} + +/* Focus States */ +.arrowContainer:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: 4px; +} + +/* Enhanced Animations */ +@media (prefers-reduced-motion: no-preference) { + .arrowPath { + opacity: 0.8; + animation: bidirectionalFlow 3s var(--animation-timing) infinite; + } + + .arrowLine { + opacity: 0.6; + animation: fadeInOut 3s var(--animation-timing) infinite; + } + + .arrowPath:first-child { + animation-delay: 0s; + } + + .arrowPath:last-child { + animation-delay: 1.5s; + } + + @keyframes bidirectionalFlow { + 0%, + 100% { + opacity: 0.8; + transform: translateX(0); + } + 50% { + opacity: 1; + transform: translateX(2px); + } + } + + @keyframes fadeInOut { + 0%, + 100% { + opacity: 0.6; + } + 50% { + opacity: 1; + } + } +} + +/* Perfect Touch Optimization */ +@media (hover: none) { + .arrowContainer { + cursor: pointer; + touch-action: manipulation; + user-select: none; + -webkit-tap-highlight-color: transparent; + } + + .arrowContainer:active .arrow { + transform: scale(0.95); + } +} + +@media (max-width: 1200px) { + .chainSelectors { + grid-template-columns: minmax(200px, 1fr) auto minmax(200px, 1fr); + max-width: 900px; + } +} + +@media (max-width: 768px) { + .chainSelectors { + grid-template-columns: 1fr; + padding: 0; + } + + .arrowContainer { + transform: rotate(90deg); + } +} + +.chainSelect.active { + background: var(--color-background); + color: var(--color-accent); + font-weight: 600; + box-shadow: 0 4px 12px rgba(var(--color-accent-rgb), 0.12), 0 2px 4px rgba(var(--color-accent-rgb), 0.08); + transform: translateY(-1px); +} + +.chainSelect.active::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + to bottom right, + rgba(var(--color-accent-rgb), 0.15), + rgba(var(--color-accent-rgb), 0.05) + ); + border-radius: inherit; +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/TutorialBlockchainSelector.tsx b/src/components/CCIP/TutorialBlockchainSelector/TutorialBlockchainSelector.tsx new file mode 100644 index 00000000000..36b3832f38c --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/TutorialBlockchainSelector.tsx @@ -0,0 +1,200 @@ +import { useStore } from "@nanostores/react" +import { laneStore, updateStepProgress } from "@stores/lanes" +import { Environment, getAllNetworks } from "@config/data/ccip" +import type { Network } from "@config/data/ccip/types" +import { ChainSelect } from "./ChainSelect" +import styles from "./TutorialBlockchainSelector.module.css" +import { TutorialCard } from "../TutorialSetup/TutorialCard" +import { SetupSection } from "../TutorialSetup/SetupSection" + +type ChainUpdate = Partial<{ + sourceChain: string + destinationChain: string + sourceNetwork: Network | null + destinationNetwork: Network | null + sourceContracts: Record + destinationContracts: Record +}> + +export const TutorialBlockchainSelector = () => { + const state = useStore(laneStore) + const allNetworks = getAllNetworks({ filter: state.environment }) + + // Filter available networks based on selection + const sourceNetworks = allNetworks.filter((n) => n.chain !== state.destinationChain) + const destinationNetworks = allNetworks.filter((n) => n.chain !== state.sourceChain) + + // Generate unique IDs for each substep + const getSubStepId = (subStepId: string) => `setup-${subStepId}` + + const checkAndUpdateProgress = () => { + // Use the latest state from the store instead of component state + const currentState = laneStore.get() + const bothChainsSelected = Boolean( + currentState.sourceChain && + currentState.destinationChain && + currentState.sourceNetwork && + currentState.destinationNetwork + ) + updateStepProgress("setup", "blockchains-selected", bothChainsSelected) + } + + const handleEnvironmentChange = (newEnvironment: Environment) => { + if (newEnvironment === state.environment) return + + // Reset both chains when environment changes + laneStore.set({ + ...state, + environment: newEnvironment, + sourceChain: "", + destinationChain: "", + sourceNetwork: null, + destinationNetwork: null, + sourceContracts: {}, + destinationContracts: {}, + }) + + // Reset progress when environment changes + updateStepProgress("setup", "blockchains-selected", false) + } + + const handleSourceChainChange = (chain: string) => { + const network = allNetworks.find((n) => n.chain === chain) + + const updates: ChainUpdate = { + sourceChain: chain, + sourceNetwork: network || null, + } + + if (chain === state.destinationChain) { + updates.destinationChain = "" + updates.destinationNetwork = null + updates.destinationContracts = {} + } + + // Update store first + laneStore.set({ + ...state, + ...updates, + }) + + // Then schedule progress check for next frame + requestAnimationFrame(checkAndUpdateProgress) + } + + const handleDestinationChainChange = (chain: string) => { + const network = allNetworks.find((n) => n.chain === chain) + + const updates: ChainUpdate = { + destinationChain: chain, + destinationNetwork: network || null, + } + + if (chain === state.sourceChain) { + updates.sourceChain = "" + updates.sourceNetwork = null + updates.sourceContracts = {} + } + + // Update store first + laneStore.set({ + ...state, + ...updates, + }) + + // Then schedule progress check for next frame + requestAnimationFrame(checkAndUpdateProgress) + } + + return ( + + +
+
+ + +
+ +
+ + + + + +
+
+
+
+ ) +} diff --git a/src/components/CCIP/TutorialBlockchainSelector/index.ts b/src/components/CCIP/TutorialBlockchainSelector/index.ts new file mode 100644 index 00000000000..9104980a71c --- /dev/null +++ b/src/components/CCIP/TutorialBlockchainSelector/index.ts @@ -0,0 +1,16 @@ +export { TutorialBlockchainSelector } from "./TutorialBlockchainSelector" +export { ChainValue } from "./ChainValue" +export { ContractAddress } from "./ContractAddress" +export { NetworkAddress } from "./NetworkAddress" +export { StoredContractAddress } from "./StoredContractAddress" +export { ChainUpdateBuilder } from "./ChainUpdateBuilder" +export { ChainUpdateBuilderWrapper } from "./ChainUpdateBuilderWrapper" +export { PoolConfigVerification } from "./PoolConfigVerification" +export { DeployTokenStep } from "./DeployTokenStep" +export { AdminSetupStep } from "./AdminSetupStep" +export { DeployPoolStep } from "./DeployPoolStep" +export { SetPoolStep } from "./SetPoolStep" +export { GrantPrivilegesStep } from "./GrantPrivilegesStep" + +// Re-export types from the correct location +export type { Network } from "@config/data/ccip/types" diff --git a/src/components/CCIP/TutorialProgress/ChainSelectorDisplay.module.css b/src/components/CCIP/TutorialProgress/ChainSelectorDisplay.module.css new file mode 100644 index 00000000000..b934dcd0b5e --- /dev/null +++ b/src/components/CCIP/TutorialProgress/ChainSelectorDisplay.module.css @@ -0,0 +1,66 @@ +.selectorContainer { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.9em; + padding: 4px 8px; + border-radius: 6px; + transition: all 0.15s ease; + position: relative; + cursor: help; + background: rgba(255, 255, 255, 0.1); + isolation: isolate; + display: inline-block; +} + +.selectorContainer:hover { + background: rgba(255, 255, 255, 0.15); +} + +.selectorText { + display: inline; + white-space: nowrap; +} + +.selectorContainer[data-tooltip] { + position: relative; +} + +.selectorContainer[data-tooltip]::before { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + padding: 6px 10px; + background: rgba(0, 0, 0, 0.85); + color: white; + font-size: 0.85em; + border-radius: 6px; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: all 0.15s ease; + pointer-events: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 100; +} + +.selectorContainer[data-tooltip]::after { + content: ""; + position: absolute; + bottom: calc(100% + 2px); + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: rgba(0, 0, 0, 0.85); + opacity: 0; + visibility: hidden; + transition: all 0.15s ease; + pointer-events: none; + z-index: 100; +} + +.selectorContainer[data-tooltip]:hover::before, +.selectorContainer[data-tooltip]:hover::after { + opacity: 1; + visibility: visible; +} diff --git a/src/components/CCIP/TutorialProgress/ChainSelectorDisplay.tsx b/src/components/CCIP/TutorialProgress/ChainSelectorDisplay.tsx new file mode 100644 index 00000000000..5b1d51c3e0e --- /dev/null +++ b/src/components/CCIP/TutorialProgress/ChainSelectorDisplay.tsx @@ -0,0 +1,20 @@ +import styles from "./ChainSelectorDisplay.module.css" + +interface ChainSelectorDisplayProps { + selector: string +} + +export const ChainSelectorDisplay = ({ selector }: ChainSelectorDisplayProps) => { + const truncateChainSelector = (selector: string) => { + if (!selector) return "" + const prefix = selector.slice(0, 4) + const suffix = selector.slice(-4) + return `${prefix}...${suffix}` + } + + return ( +
+ {truncateChainSelector(selector)} +
+ ) +} diff --git a/src/components/CCIP/TutorialProgress/ContractAddressDisplay.module.css b/src/components/CCIP/TutorialProgress/ContractAddressDisplay.module.css new file mode 100644 index 00000000000..0ae273f5315 --- /dev/null +++ b/src/components/CCIP/TutorialProgress/ContractAddressDisplay.module.css @@ -0,0 +1,66 @@ +.addressContainer { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.9em; + padding: 4px 8px; + border-radius: 6px; + transition: all 0.15s ease; + position: relative; + cursor: help; + background: rgba(255, 255, 255, 0.1); + isolation: isolate; + display: inline-block; +} + +.addressContainer:hover { + background: rgba(255, 255, 255, 0.15); +} + +.addressText { + display: inline; + white-space: nowrap; +} + +.addressContainer[data-tooltip] { + position: relative; +} + +.addressContainer[data-tooltip]::before { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + padding: 6px 10px; + background: rgba(0, 0, 0, 0.85); + color: white; + font-size: 0.85em; + border-radius: 6px; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: all 0.15s ease; + pointer-events: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 100; +} + +.addressContainer[data-tooltip]::after { + content: ""; + position: absolute; + bottom: calc(100% + 2px); + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: rgba(0, 0, 0, 0.85); + opacity: 0; + visibility: hidden; + transition: all 0.15s ease; + pointer-events: none; + z-index: 100; +} + +.addressContainer[data-tooltip]:hover::before, +.addressContainer[data-tooltip]:hover::after { + opacity: 1; + visibility: visible; +} diff --git a/src/components/CCIP/TutorialProgress/ContractAddressDisplay.tsx b/src/components/CCIP/TutorialProgress/ContractAddressDisplay.tsx new file mode 100644 index 00000000000..d9a8299b2f1 --- /dev/null +++ b/src/components/CCIP/TutorialProgress/ContractAddressDisplay.tsx @@ -0,0 +1,18 @@ +import styles from "./ContractAddressDisplay.module.css" + +interface ContractAddressDisplayProps { + address: string +} + +export const ContractAddressDisplay = ({ address }: ContractAddressDisplayProps) => { + const truncateAddress = (address: string) => { + if (!address) return "" + return `${address.slice(0, 6)}...${address.slice(-4)}` + } + + return ( +
+ {truncateAddress(address)} +
+ ) +} diff --git a/src/components/CCIP/TutorialProgress/SectionProgress.module.css b/src/components/CCIP/TutorialProgress/SectionProgress.module.css new file mode 100644 index 00000000000..d4164997667 --- /dev/null +++ b/src/components/CCIP/TutorialProgress/SectionProgress.module.css @@ -0,0 +1,101 @@ +.progressContainer { + margin-bottom: 2rem; + padding: 1.5rem; + background: #f8fafc; + border-radius: 12px; +} + +.progressHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.sectionTitle { + font-size: 1.25rem; + font-weight: 600; + color: #1e293b; +} + +.stepCount { + color: #64748b; + font-size: 0.875rem; +} + +.progressBar { + height: 8px; + background: transparent; + border-radius: 4px; + overflow: hidden; + display: flex; + gap: 2px; +} + +.progressSegment { + height: 100%; + border: none; + padding: 0; + margin: 0; + background: #e2e8f0; + transition: all 0.2s ease; + cursor: pointer; + position: relative; + overflow: hidden; +} + +.progressSegment::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.1); + opacity: 0; + transition: opacity 0.2s ease; +} + +.progressSegment:hover::after { + opacity: 1; +} + +.progressSegment.completed { + background: #375bd2; +} + +.progressSegment.in-progress { + background: #eab308; +} + +.progressSegment.not-started { + background: #e2e8f0; +} + +.progressSegment.active { + transform: scaleY(1.2); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +/* Tooltip styles */ +.progressSegment:hover::before { + content: attr(aria-label); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + white-space: nowrap; + pointer-events: none; + z-index: 10; +} + +/* Focus styles for accessibility */ +.progressSegment:focus { + outline: none; + box-shadow: 0 0 0 2px #375bd2; +} diff --git a/src/components/CCIP/TutorialProgress/SectionProgress.tsx b/src/components/CCIP/TutorialProgress/SectionProgress.tsx new file mode 100644 index 00000000000..2bbc9b3361d --- /dev/null +++ b/src/components/CCIP/TutorialProgress/SectionProgress.tsx @@ -0,0 +1,43 @@ +import styles from "./SectionProgress.module.css" +import { navigateToStep, type StepId } from "@stores/lanes" +import { useMemo } from "react" + +interface SectionProgressProps { + currentStep: number + totalSteps: number + sectionTitle: string + steps: Array<{ id: StepId; stepNumber: number; status: string }> +} + +export const SectionProgress = ({ currentStep, totalSteps, sectionTitle, steps }: SectionProgressProps) => { + const segments = useMemo(() => { + return steps.map((step) => ({ + ...step, + width: (1 / totalSteps) * 100, + isActive: step.stepNumber === currentStep, + })) + }, [steps, currentStep, totalSteps]) + + return ( +
+
+

{sectionTitle}

+ + Step {currentStep} of {totalSteps} + +
+
+ {segments.map((segment) => ( +
+
+ ) +} diff --git a/src/components/CCIP/TutorialProgress/StepCheckbox.css b/src/components/CCIP/TutorialProgress/StepCheckbox.css new file mode 100644 index 00000000000..46a71d4de7b --- /dev/null +++ b/src/components/CCIP/TutorialProgress/StepCheckbox.css @@ -0,0 +1,100 @@ +.step-checkbox { + margin: 1.5rem 0; + padding: 1.25rem 1.5rem; + border-radius: 12px; + background: #f8fafc; + border: 2px solid #e2e8f0; + box-shadow: 0 2px 4px rgba(55, 91, 210, 0.05); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; +} + +.step-checkbox:hover { + background: #f1f5f9; + border-color: #375bd2; + transform: translateY(-2px); + box-shadow: 0 6px 12px rgba(55, 91, 210, 0.1); +} + +.step-checkbox label { + display: flex; + align-items: center; + gap: 1rem; + cursor: pointer; + font-weight: 600; +} + +.step-checkbox input[type="checkbox"] { + appearance: none; + width: 24px; + height: 24px; + border: 2.5px solid #94a3b8; + border-radius: 8px; + position: relative; + cursor: pointer; + transition: all 0.2s ease; + background: white; +} + +.step-checkbox input[type="checkbox"]:hover { + border-color: #375bd2; + box-shadow: 0 0 0 4px rgba(55, 91, 210, 0.1); +} + +.step-checkbox input[type="checkbox"]:checked { + background: #375bd2; + border-color: #375bd2; + animation: checkbox-pop 0.3s ease-out; +} + +.step-checkbox input[type="checkbox"]:checked::after { + content: "✓"; + color: white; + position: absolute; + top: 45%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 1.125rem; + font-weight: bold; +} + +.step-checkbox span { + font-size: 1.125rem; + color: #1e293b; + line-height: 1.4; +} + +/* Add completion indicator */ +.step-checkbox:has(input:checked) { + border-color: #375bd2; + background: linear-gradient(to right, rgba(55, 91, 210, 0.05), transparent); +} + +.step-checkbox:has(input:checked) span { + color: #375bd2; +} + +/* Add visual feedback animation */ +@keyframes checkbox-pop { + 0% { + transform: scale(0.8); + } + 50% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + } +} + +/* Add completion badge */ +.step-checkbox:has(input:checked)::after { + content: "Completed!"; + position: absolute; + top: 0.75rem; + right: 1rem; + font-size: 0.875rem; + color: #375bd2; + font-weight: 600; + opacity: 0.8; +} diff --git a/src/components/CCIP/TutorialProgress/StepCheckbox.tsx b/src/components/CCIP/TutorialProgress/StepCheckbox.tsx new file mode 100644 index 00000000000..db3d633d4cd --- /dev/null +++ b/src/components/CCIP/TutorialProgress/StepCheckbox.tsx @@ -0,0 +1,125 @@ +import { updateStepProgress, type StepId, TUTORIAL_STEPS, subscribeToStepProgress } from "@stores/lanes" +import { useCallback, memo, useMemo, useEffect, useRef } from "react" + +interface StepCheckboxProps { + stepId: T + subStepId: T extends "sourceChain" + ? "token-deployed" | "admin-claimed" | "admin-accepted" | "pool-deployed" | "pool-registered" + : T extends "destinationChain" + ? "dest-token-deployed" | "admin-claimed" | "admin-accepted" | "dest-pool-deployed" | "dest-pool-registered" + : string + label?: string + onChange?: (checked: boolean) => void +} + +// Controlled checkbox component that manages its own animation state +const StepCheckboxBase = ({ stepId, subStepId, label, onChange }: StepCheckboxProps) => { + const checkboxRef = useRef(null) + const isAnimating = useRef(false) + const isUserAction = useRef(false) + const currentValue = useRef(false) + + // Subscribe to store changes with proper filtering + useEffect(() => { + const unsubscribe = subscribeToStepProgress(stepId, (progress) => { + const newValue = progress[subStepId] ?? false + + // Skip update if value hasn't changed + if (newValue === currentValue.current) { + return + } + currentValue.current = newValue + + // Only update DOM directly if it's not a user action + if (!isUserAction.current && checkboxRef.current) { + checkboxRef.current.checked = newValue + if (newValue && !isAnimating.current) { + isAnimating.current = true + checkboxRef.current.parentElement?.classList.add("animating") + setTimeout(() => { + if (checkboxRef.current?.parentElement) { + checkboxRef.current.parentElement.classList.remove("animating") + isAnimating.current = false + } + }, 300) + } + } + isUserAction.current = false + }) + return unsubscribe + }, [stepId, subStepId]) + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.checked + isUserAction.current = true + currentValue.current = newValue + + if (process.env.NODE_ENV === "development") { + console.log(`[CheckboxChange] ${stepId}.${subStepId}:`, { + newValue, + timestamp: new Date().toISOString(), + }) + } + + // Start animation if checked + if (newValue) { + isAnimating.current = true + e.target.parentElement?.classList.add("animating") + setTimeout(() => { + if (checkboxRef.current?.parentElement) { + checkboxRef.current.parentElement.classList.remove("animating") + isAnimating.current = false + } + }, 300) + } + + // Update store + if (onChange) { + onChange(newValue) + } else { + updateStepProgress(stepId.toString(), subStepId.toString(), newValue) + } + }, + [onChange, stepId, subStepId] + ) + + const ariaLabel = useMemo( + () => `Mark ${label || TUTORIAL_STEPS[stepId]?.subSteps?.[subStepId as string] || subStepId} as complete`, + [label, stepId, subStepId] + ) + + const displayLabel = useMemo( + () => label || TUTORIAL_STEPS[stepId]?.subSteps?.[subStepId as string] || subStepId, + [label, stepId, subStepId] + ) + + if (process.env.NODE_ENV === "development") { + console.log(`[RenderTrack] StepCheckbox-${stepId}-${subStepId} rendered`) + } + + return ( +
+ +
+ ) +} + +// Memoized version with prop comparison +export const StepCheckbox = memo(StepCheckboxBase, (prev, next) => { + return ( + prev.stepId === next.stepId && + prev.subStepId === next.subStepId && + prev.label === next.label && + prev.onChange === next.onChange + ) +}) diff --git a/src/components/CCIP/TutorialProgress/SubStepTracker.tsx b/src/components/CCIP/TutorialProgress/SubStepTracker.tsx new file mode 100644 index 00000000000..cd82d7fd537 --- /dev/null +++ b/src/components/CCIP/TutorialProgress/SubStepTracker.tsx @@ -0,0 +1,22 @@ +import { SubStep } from "./types" + +interface SubStepTrackerProps { + steps: SubStep[] + onComplete: (stepId: string, completed: boolean) => void +} + +export const SubStepTracker = ({ steps, onComplete }: SubStepTrackerProps) => { + return ( +
+ {steps.map((step) => ( +
+ + {step.title} +
+ ))} +
+ ) +} diff --git a/src/components/CCIP/TutorialProgress/TutorialProgress.module.css b/src/components/CCIP/TutorialProgress/TutorialProgress.module.css new file mode 100644 index 00000000000..84b1dd1d5c4 --- /dev/null +++ b/src/components/CCIP/TutorialProgress/TutorialProgress.module.css @@ -0,0 +1,363 @@ +/* Base container */ +.tutorialProgress { + position: sticky; + top: 0; + height: fit-content; + padding: var(--doc-padding) 0; + min-width: calc(315px - var(--space-16x)); + isolation: isolate; +} + +/* Override any parent title tooltips */ +.tutorialProgress::before, +.tutorialProgress::after { + display: none !important; +} + +.title { + font-family: var(--font-display); + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--space-4x); + pointer-events: none; +} + +.sectionTitle { + font-family: var(--font-display); + font-size: var(--font-size-base); + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--space-4x); + padding-bottom: var(--space-2x); + border-bottom: 1px solid var(--color-border); + pointer-events: none; +} + +/* Progress Steps */ +.progressSteps { + margin-bottom: 24px; +} + +.steps { + position: relative; + display: flex; + flex-direction: column; + gap: 8px; + padding-left: 16px; +} + +.stepContainer { + display: flex; + flex-direction: column; + border-left: 2px solid var(--color-border-primary); + margin-left: 15px; + padding-left: 15px; + transition: all 0.3s ease; +} + +.stepContainer.completed { + border-left-color: #16a34a; +} + +.stepContainer.in-progress { + border-left-color: #ea580c; +} + +.step { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + width: 100%; +} + +.step:hover { + transform: translateX(4px); + background: var(--color-background-secondary); +} + +.stepIndicator { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + background: var(--color-background-secondary); + color: var(--color-text-secondary); + border: 2px solid var(--color-border); + transition: all 0.2s ease; + flex-shrink: 0; +} + +.completed .stepIndicator { + background: #dcfce7; + border-color: #16a34a; + color: #16a34a; +} + +.in-progress .stepIndicator { + background: #fef3c7; + border-color: #eab308; + color: #b45309; +} + +.stepTitle { + font-size: 14px; + font-weight: 500; + color: var(--color-text-primary); + flex: 1; +} + +.step.in-progress .stepTitle { + color: #ea580c; +} + +.chevron { + width: 16px; + height: 16px; + border-right: 2px solid var(--color-text-secondary); + border-bottom: 2px solid var(--color-text-secondary); + transform: rotate(45deg); + transition: transform 0.2s ease; + flex-shrink: 0; +} + +.expanded .chevron { + transform: rotate(-135deg); +} + +.stepDetails { + margin-left: 40px; + padding: 8px 0; + border-left: 2px solid var(--color-border-primary); +} + +.substep { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border-radius: 6px; + transition: all 0.2s ease; + background: rgba(255, 255, 255, 0.03); + margin: 4px 0; + width: 100%; + text-align: left; + border: none; + cursor: pointer; +} + +.substep:hover { + background: rgba(255, 255, 255, 0.08); + transform: translateX(4px); +} + +.substep:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(55, 91, 210, 0.4); +} + +.substep.completed { + background: rgba(22, 163, 74, 0.1); + padding-right: 32px; + position: relative; +} + +.substep.completed::after { + content: "✓"; + position: absolute; + right: 12px; + color: #16a34a; + font-weight: bold; +} + +.substepTitle { + font-size: 0.9rem; + color: var(--color-text-secondary); + pointer-events: none; +} + +.substep.completed .substepTitle { + color: #16a34a; + font-weight: 500; +} + +/* Chain section styles */ +.chainBlock { + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 16px; + margin-bottom: 16px; + border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.2s ease; +} + +.chainHeader { + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.chainIdentity { + display: flex; + align-items: center; + gap: 12px; +} + +.chainLogo { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.1); + transition: all 0.2s ease; + background: rgba(255, 255, 255, 0.1); + flex-shrink: 0; +} + +.chainLogo img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} + +.chainLogo:not([src]), +.chainLogo[src=""], +.chainLogo[src="undefined"] { + background: rgba(255, 255, 255, 0.1); + border: 2px solid rgba(255, 255, 255, 0.15); +} + +.chainName { + font-weight: 600; + color: var(--color-text-primary); + font-size: 1.1em; + pointer-events: none; +} + +.chainConfigs { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Status styles */ +.statusItem { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.03); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.05); + transition: all 0.2s ease; +} + +.statusLabel { + color: var(--color-text-secondary); + font-size: 0.9em; + font-weight: 500; + pointer-events: none; +} + +.statusValue { + display: flex; + align-items: center; + min-width: 0; +} + +.statusValueWithAddress { + display: flex; + align-items: center; + gap: 8px; + position: relative; + max-width: 140px; +} + +.statusCheck { + color: #4caf50; + font-weight: bold; + flex-shrink: 0; + pointer-events: none; +} + +.statusPending { + color: var(--color-text-secondary); + font-size: 0.9em; + pointer-events: none; +} + +/* Value container with tooltip */ +.valueContainer { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.9em; + padding: 4px 8px; + border-radius: 6px; + transition: all 0.15s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100px; + position: relative; + cursor: help; + background: rgba(255, 255, 255, 0.1); + isolation: isolate; +} + +.valueContainer:hover { + background: rgba(255, 255, 255, 0.15); +} + +/* Tooltip styles */ +.valueContainer[data-tooltip] { + position: relative; +} + +.valueContainer[data-tooltip]::before { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + padding: 6px 10px; + background: rgba(0, 0, 0, 0.85); + color: white; + font-size: 0.85em; + border-radius: 6px; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: all 0.15s ease; + pointer-events: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 100; +} + +.valueContainer[data-tooltip]::after { + content: ""; + position: absolute; + bottom: calc(100% + 2px); + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: rgba(0, 0, 0, 0.85); + opacity: 0; + visibility: hidden; + transition: all 0.15s ease; + pointer-events: none; + z-index: 100; +} + +.valueContainer[data-tooltip]:hover::before, +.valueContainer[data-tooltip]:hover::after { + opacity: 1; + visibility: visible; +} diff --git a/src/components/CCIP/TutorialProgress/TutorialProgress.tsx b/src/components/CCIP/TutorialProgress/TutorialProgress.tsx new file mode 100644 index 00000000000..fed6e94fe08 --- /dev/null +++ b/src/components/CCIP/TutorialProgress/TutorialProgress.tsx @@ -0,0 +1,336 @@ +import { useStore } from "@nanostores/react" +import { useState, useEffect, useMemo, useCallback } from "react" +import { laneStore, progressStore, TUTORIAL_STEPS, type StepId, type LaneState } from "@stores/lanes" +import styles from "./TutorialProgress.module.css" +import { ChainSelectorDisplay } from "./ChainSelectorDisplay" +import { ContractAddressDisplay } from "./ContractAddressDisplay" +import { SectionProgress } from "./SectionProgress" + +// Helper function to determine current step +const determineCurrentStep = (state: Omit): number => { + if (!state.sourceChain || !state.destinationChain) return 1 + if (!state.sourceContracts.token || !state.sourceContracts.tokenPool) return 2 + if (!state.destinationContracts.token || !state.destinationContracts.tokenPool) return 3 + if (!state.sourceContracts.configured) return 4 + return 5 +} + +// Add navigation helper +const navigateToSubStep = (stepId: StepId, subStepId: string) => { + // First, ensure the parent step is expanded + const elementId = `${stepId}-${subStepId}` + const element = document.getElementById(elementId) + + if (element) { + // Expand the parent step if needed + laneStore.set({ + ...laneStore.get(), + currentStep: stepId, + }) + + // Scroll to the element + element.scrollIntoView({ behavior: "smooth", block: "center" }) + + // Add a temporary highlight + element.style.transition = "background-color 0.3s ease" + element.style.backgroundColor = "rgba(55, 91, 210, 0.1)" + setTimeout(() => { + element.style.backgroundColor = "" + }, 1500) + } +} + +export const TutorialProgress = () => { + const mainState = useStore(laneStore) + const progress = useStore(progressStore) + const [expandedStep, setExpandedStep] = useState(null) + const [forceExpanded, setForceExpanded] = useState(null) + + const steps = useMemo( + () => [ + { id: "setup", title: "Setup", stepNumber: 1 }, + { id: "sourceChain", title: "Source Chain", stepNumber: 2 }, + { id: "destinationChain", title: "Destination Chain", stepNumber: 3 }, + { id: "sourceConfig", title: "Source Configuration", stepNumber: 4 }, + { id: "destinationConfig", title: "Destination Configuration", stepNumber: 5 }, + ], + [] + ) + + const getStepStatus = useCallback( + (stepId: string) => { + const stepProgress = progress[stepId] || {} + const totalSubSteps = Object.keys(TUTORIAL_STEPS[stepId].subSteps).length + const completedSubSteps = Object.values(stepProgress).filter(Boolean).length + + if (completedSubSteps === 0) return "not-started" + if (completedSubSteps === totalSubSteps) return "completed" + return "in-progress" + }, + [progress] + ) + + // Calculate the highest completed step + const highestCompletedStep = useMemo(() => { + return steps.reduce((highest, step) => { + const status = getStepStatus(step.id) + if (status === "completed") { + return Math.max(highest, step.stepNumber) + } + return highest + }, 0) + }, [steps, getStepStatus]) + + // Determine the displayed step number (should be the lowest incomplete step) + const displayedStepNumber = useMemo(() => { + const inProgressStep = steps.find((step) => getStepStatus(step.id) === "in-progress") + if (inProgressStep) return inProgressStep.stepNumber + + // If no step is in progress, show the next step after highest completed + const nextStep = highestCompletedStep + 1 + return Math.min(nextStep, steps.length) + }, [steps, getStepStatus, highestCompletedStep]) + + // Use currentStep to determine which step should be expanded by default + const currentStepNumber = useMemo(() => determineCurrentStep(mainState), [mainState]) + + // Create steps data for progress bar + const progressSteps = useMemo( + () => + steps.map((step) => ({ + id: step.id as StepId, + stepNumber: step.stepNumber, + status: getStepStatus(step.id), + })), + [steps, getStepStatus] + ) + + // Auto-expand current step on initial render + useEffect(() => { + if (!expandedStep) { + const currentStep = steps.find((step) => step.stepNumber === currentStepNumber) + if (currentStep) { + setExpandedStep(currentStep.id) + setForceExpanded(currentStep.id) + } + } + }, [currentStepNumber, steps, expandedStep]) + + useEffect(() => { + if (forceExpanded) { + setExpandedStep(forceExpanded) + } + }, [forceExpanded]) + + const toggleStepDetails = useCallback( + (stepId: string) => { + setForceExpanded(stepId) + setExpandedStep(expandedStep === stepId ? null : stepId) + }, + [expandedStep] + ) + + const getStepProgress = useCallback( + (stepId: StepId) => { + const stepConfig = TUTORIAL_STEPS[stepId] + const stepProgress = progress[stepId] || {} + return Object.entries(stepConfig.subSteps).map(([id, title]) => ({ + id, + title, + completed: !!stepProgress[id], + })) + }, + [progress] + ) + + // Update the substep rendering to be clickable + const renderSubSteps = useCallback( + (stepId: StepId) => { + const subSteps = getStepProgress(stepId) + return subSteps.map(({ id: subStepId, title, completed }) => ( + + )) + }, + [getStepProgress] + ) + + return ( +
+
Tutorial Progress
+ +
+
+ {steps.map((step) => { + const status = getStepStatus(step.id) + const isCurrentStep = step.stepNumber === currentStepNumber + return ( +
+
+ + + + {expandedStep === step.id && ( +
+
{renderSubSteps(step.id as StepId)}
+
+ )} +
+ ) + })} +
+
+ +
+

Configuration Status

+ + {/* Source Chain Status */} +
+
+
+
+ {mainState.sourceNetwork?.logo ? ( + {mainState.sourceNetwork.name} { + e.currentTarget.style.display = "none" + }} + /> + ) : null} +
+
{mainState.sourceNetwork?.name || "Source Chain"}
+
+
+
+
+
Chain Selector
+
+ {mainState.sourceNetwork?.chainSelector ? ( +
+ + +
+ ) : ( + Not Available + )} +
+
+
+
Token
+
+ {mainState.sourceContracts.token ? ( +
+ + +
+ ) : ( + Not Deployed + )} +
+
+
+
Token Pool
+
+ {mainState.sourceContracts.tokenPool ? ( +
+ + +
+ ) : ( + Not Deployed + )} +
+
+
+
+ + {/* Destination Chain Status */} +
+
+
+
+ {mainState.destinationNetwork?.logo ? ( + {mainState.destinationNetwork.name} { + e.currentTarget.style.display = "none" + }} + /> + ) : null} +
+
{mainState.destinationNetwork?.name || "Destination Chain"}
+
+
+
+
+
Chain Selector
+
+ {mainState.destinationNetwork?.chainSelector ? ( +
+ + +
+ ) : ( + Not Available + )} +
+
+
+
Token
+
+ {mainState.destinationContracts.token ? ( +
+ + +
+ ) : ( + Not Deployed + )} +
+
+
+
Token Pool
+
+ {mainState.destinationContracts.tokenPool ? ( +
+ + +
+ ) : ( + Not Deployed + )} +
+
+
+
+
+
+ ) +} diff --git a/src/components/CCIP/TutorialProgress/types.ts b/src/components/CCIP/TutorialProgress/types.ts new file mode 100644 index 00000000000..e73804761f9 --- /dev/null +++ b/src/components/CCIP/TutorialProgress/types.ts @@ -0,0 +1,17 @@ +export interface SubStep { + id: string + title: string + completed: boolean +} + +export interface StepProgress { + setup: { + prerequisites: SubStep[] + chainSelection: SubStep[] + } + sourceChain: { + deployment: SubStep[] + adminSetup: SubStep[] + } + // ... rest of the interface +} diff --git a/src/components/CCIP/TutorialSetup/Callout.module.css b/src/components/CCIP/TutorialSetup/Callout.module.css new file mode 100644 index 00000000000..d124a47418d --- /dev/null +++ b/src/components/CCIP/TutorialSetup/Callout.module.css @@ -0,0 +1,104 @@ +.callout { + /* Base variables */ + --callout-bg: var(--color-background-info); + --callout-text: var(--color-text-info); + --callout-border: var(--theme-text-light); + + padding: var(--space-4x); + gap: var(--space-4x); + background-color: var(--callout-bg); + border: 2px dotted var(--callout-border); + border-radius: var(--border-radius); + color: var(--callout-text); + + /* For forced colors mode */ + outline: 1px solid transparent; + + display: flex; +} + +/* Type-specific styles */ +.caution { + --callout-bg: var(--color-background-warning); +} + +.danger { + --callout-bg: var(--color-background-error); +} + +.icon { + flex-shrink: 0; + width: 1.5em; + height: 1.5em; + display: flex; + align-items: center; + justify-content: center; +} + +.iconImage { + width: 100%; + height: 100%; + object-fit: contain; +} + +.content { + /* Typography */ + --content-font-size: 14px; + --content-line-height: 1.5; + --content-gap: var(--space-2x); + + flex: 1; +} + +.title { + font-weight: 600; + text-transform: uppercase; + color: var(--theme-text); + margin-bottom: var(--space-2x); +} + +.message { + color: var(--theme-text-light); +} + +/* Content styling */ +.message p, +.message li { + color: var(--theme-text-light); + font-size: var(--content-font-size); + line-height: var(--content-line-height); +} + +.message p { + margin-bottom: 0; +} + +.message p + p { + margin-top: var(--content-gap); +} + +.message p + :is(ul, ol) { + margin-top: var(--space-1x); +} + +.message li { + margin-top: var(--space-1x); +} + +/* Link styling */ +.message :global(a), +.message :global(a > code:not([class*="language"])) { + color: var(--color-text-link); + text-decoration: underline; +} + +/* Dark theme support */ +:global(.theme-dark) .message :global(code:not([class*="language"])) { + color: var(--theme-code-text); +} + +/* Focus handling */ +.callout:focus-within { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} diff --git a/src/components/CCIP/TutorialSetup/Callout.tsx b/src/components/CCIP/TutorialSetup/Callout.tsx new file mode 100644 index 00000000000..9430f918847 --- /dev/null +++ b/src/components/CCIP/TutorialSetup/Callout.tsx @@ -0,0 +1,47 @@ +import { ReactNode } from "react" +import infoIcon from "@components/Alert/Assets/info-icon.svg" +import alertIcon from "@components/Alert/Assets/alert-icon.svg" +import dangerIcon from "@components/Alert/Assets/danger-icon.svg" +import styles from "./Callout.module.css" + +interface CalloutProps { + type?: "note" | "tip" | "caution" | "danger" + title?: string + children: ReactNode + className?: string +} + +const CALLOUT_ICONS = { + note: infoIcon, + tip: infoIcon, + caution: alertIcon, + danger: dangerIcon, +} as const + +export const Callout = ({ type = "note", title, children, className }: CalloutProps) => { + // Debug log + console.log("Callout render:", { + type, + icon: CALLOUT_ICONS[type], + iconSrc: CALLOUT_ICONS[type]?.src, + }) + + return ( +
+ +
+ +
{children}
+
+
+ ) +} diff --git a/src/components/CCIP/TutorialSetup/ContractsImportCard.module.css b/src/components/CCIP/TutorialSetup/ContractsImportCard.module.css new file mode 100644 index 00000000000..f5d36701995 --- /dev/null +++ b/src/components/CCIP/TutorialSetup/ContractsImportCard.module.css @@ -0,0 +1,52 @@ +.requirements { + display: flex; + flex-direction: column; + gap: 24px; +} + +.stepsList { + counter-reset: step; + list-style: none; + padding: 0; + margin: 0; +} + +.stepsList li { + counter-increment: step; + padding-left: 36px; + position: relative; + margin-bottom: 20px; + font-size: 14px; + line-height: 1.5; + color: var(--color-text-secondary); +} + +.stepsList li:last-child { + margin-bottom: 0; +} + +.stepsList li::before { + content: counter(step); + position: absolute; + left: 0; + top: 0; + width: 24px; + height: 24px; + background: var(--color-accent); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: var(--color-background); + opacity: 0.9; +} + +.codeContainer { + margin: 16px 0; + padding: 16px; + background: var(--color-background-secondary); + border-radius: 8px; + border: 1px solid var(--color-border); +} diff --git a/src/components/CCIP/TutorialSetup/ContractsImportCard.tsx b/src/components/CCIP/TutorialSetup/ContractsImportCard.tsx new file mode 100644 index 00000000000..4468fa70ba7 --- /dev/null +++ b/src/components/CCIP/TutorialSetup/ContractsImportCard.tsx @@ -0,0 +1,41 @@ +import { StepCheckbox } from "@components/CCIP/TutorialProgress/StepCheckbox" +import { CodeSampleReact } from "@components/CodeSample/CodeSampleReact" +import styles from "./ContractsImportCard.module.css" +import { TutorialCard } from "./TutorialCard" +import { SetupSection } from "./SetupSection" +import type { StepId, SubStepId } from "@stores/lanes" + +export const ContractsImportCard = () => { + // Generate unique ID for navigation + const stepId = "setup-contracts-imported" + + return ( + +
+ , + }} + > +
    +
  1. + Open the pre-configured token contract in Remix: +
    + +
    +
  2. +
  3. Wait a few seconds for Remix to automatically compile all contracts.
  4. +
+
+
+
+ ) +} diff --git a/src/components/CCIP/TutorialSetup/NetworkCheck.module.css b/src/components/CCIP/TutorialSetup/NetworkCheck.module.css new file mode 100644 index 00000000000..7d3ea66cfcb --- /dev/null +++ b/src/components/CCIP/TutorialSetup/NetworkCheck.module.css @@ -0,0 +1,19 @@ +.networkCheck { + display: flex; + align-items: center; + gap: var(--space-2x); + padding: var(--space-4x); + margin-bottom: var(--space-4x); + background: var(--color-background-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); +} + +.networkCheck img { + width: 24px; + height: 24px; +} + +.networkCheck strong { + color: var(--color-text-primary); +} diff --git a/src/components/CCIP/TutorialSetup/NetworkCheck.tsx b/src/components/CCIP/TutorialSetup/NetworkCheck.tsx new file mode 100644 index 00000000000..45eddcdd7da --- /dev/null +++ b/src/components/CCIP/TutorialSetup/NetworkCheck.tsx @@ -0,0 +1,17 @@ +import styles from "./NetworkCheck.module.css" + +interface NetworkCheckProps { + network: { + name: string + logo?: string + } +} + +export const NetworkCheck = ({ network }: NetworkCheckProps) => ( +
+ {network?.logo && {network.name}} + + Ensure MetaMask is connected to {network?.name || "loading..."} + +
+) diff --git a/src/components/CCIP/TutorialSetup/PrerequisitesCard.module.css b/src/components/CCIP/TutorialSetup/PrerequisitesCard.module.css new file mode 100644 index 00000000000..1c8877b7ca7 --- /dev/null +++ b/src/components/CCIP/TutorialSetup/PrerequisitesCard.module.css @@ -0,0 +1,192 @@ +.card { + background: var(--color-background); + border-radius: 12px; + padding: 32px; + margin: 16px 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02); +} + +.title { + font-size: 20px; + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: 24px; +} + +.requirements { + display: flex; + flex-direction: column; + gap: 24px; +} + +.step { + position: relative; + background: var(--color-background); + border-radius: 12px; + transition: all 0.3s ease; +} + +.step:target { + background-color: rgba(55, 91, 210, 0.1); + border-color: rgba(55, 91, 210, 0.3); + box-shadow: 0 0 0 2px rgba(55, 91, 210, 0.2); +} + +.step.active { + border-color: var(--color-primary); +} + +.stepContent { + position: relative; + padding: 16px 0; +} + +.expandButton { + position: absolute; + top: -40px; + right: 8px; + background: none; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + padding: 8px; + transition: all 0.2s ease; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 4px; +} + +.expandButton:hover { + color: var(--color-text-primary); + background: var(--color-background-secondary); +} + +.optionsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + margin-top: 16px; + animation: slideDown 0.3s ease; +} + +.optionCard { + background: var(--color-background-secondary); + border-radius: 8px; + padding: 20px; + border: 1px solid var(--color-border); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + transition: all 0.2s ease; +} + +.optionCard:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); +} + +.optionCard h4 { + font-size: 15px; + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: 16px; +} + +.stepsList { + list-style: none; + padding: 0; + margin: 0 0 20px; + counter-reset: item; +} + +.stepsList li { + position: relative; + padding-left: 28px; + margin-bottom: 12px; + font-size: 14px; + line-height: 1.5; + color: var(--color-text-secondary); +} + +/* Numbered items */ +.numberedItem { + counter-increment: item; +} + +.numberedItem::before { + content: counter(item) "."; + position: absolute; + left: 8px; + color: var(--color-text-primary); + font-weight: 500; +} + +/* Bullet items */ +.bulletItem { + padding-left: 24px !important; +} + +.bulletItem::before { + content: "•"; + position: absolute; + left: 8px; + color: var(--color-text-primary); +} + +/* Header items */ +.headerItem { + padding-left: 0 !important; + font-weight: 500; + color: var(--color-text-primary) !important; + margin-bottom: 8px !important; +} + +.actionButton { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--color-accent); + color: var(--color-background) !important; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + text-decoration: none; + transition: all 0.2s ease; + margin-top: 16px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.actionButton:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(var(--color-accent-rgb), 0.2); + filter: brightness(1.05); +} + +.linkArrow { + font-size: 18px; + transition: transform 0.2s ease; +} + +.actionButton:hover .linkArrow { + transform: translateX(4px); +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 768px) { + .optionsGrid { + grid-template-columns: 1fr; + } +} diff --git a/src/components/CCIP/TutorialSetup/PrerequisitesCard.tsx b/src/components/CCIP/TutorialSetup/PrerequisitesCard.tsx new file mode 100644 index 00000000000..f445fd6c054 --- /dev/null +++ b/src/components/CCIP/TutorialSetup/PrerequisitesCard.tsx @@ -0,0 +1,166 @@ +import { useState } from "react" +import styles from "./PrerequisitesCard.module.css" +import { type StepId, type SubStepId } from "@stores/lanes" +import { SetupSection } from "./SetupSection" +import { TutorialCard } from "./TutorialCard" + +interface Link { + text: string + url: string +} + +interface PrerequisiteStep { + id: string + title: string + description: string + checkboxId: SubStepId + defaultOpen?: boolean + options?: { + title: string + steps: { + text: string + type?: "numbered" | "bullet" | "header" // Default to 'numbered' if not specified + }[] + links?: Link[] + }[] +} + +export const PrerequisitesCard = () => { + const [activeStep, setActiveStep] = useState("browser-setup") + + const getSubStepId = (subStepId: string) => `setup-${subStepId}` + + const prerequisites: PrerequisiteStep[] = [ + { + id: "browser-setup", + checkboxId: "browser-setup" as SubStepId, + title: "Web Browser Setup", + description: "Configure your browser with the required extensions and networks", + defaultOpen: true, + options: [ + { + title: "Using Chainlist (Recommended)", + steps: [ + { text: "Visit Chainlist", type: "numbered" }, + { text: "Search for your desired blockchains", type: "numbered" }, + { text: 'Click "Add to MetaMask" for each blockchain', type: "numbered" }, + ], + links: [ + { + text: "Open Chainlist", + url: "https://chainlist.org", + }, + ], + }, + { + title: "Manual Configuration", + steps: [ + { text: "Open MetaMask Settings", type: "numbered" }, + { text: "Select Networks", type: "numbered" }, + { text: "Add Network manually", type: "numbered" }, + ], + links: [ + { + text: "View Guide", + url: "https://support.metamask.io/networks-and-sidechains/managing-networks/how-to-add-a-custom-network-rpc/", + }, + ], + }, + ], + }, + { + id: "gas-tokens", + checkboxId: "gas-tokens" as SubStepId, + title: "Native Gas Tokens", + description: "Acquire tokens for transaction fees", + options: [ + { + title: "Testnet Setup", + steps: [ + { text: "Choose one of these options:", type: "header" }, + { text: "Visit blockchain-specific faucets", type: "bullet" }, + { text: "Use the Chainlink faucet for supported networks", type: "bullet" }, + ], + links: [ + { + text: "Visit Chainlink Faucet", + url: "https://faucets.chain.link/", + }, + ], + }, + { + title: "Mainnet Setup", + steps: [{ text: "Acquire tokens through an exchange", type: "bullet" }], + }, + ], + }, + ] + + return ( + +
+ {prerequisites.map((step, index) => ( +
+ +
+ + {activeStep === step.id && step.options && ( +
+ {step.options.map((option, idx) => ( +
+

{option.title}

+
    + {option.steps.map((step, stepIdx) => ( +
  • + {step.text} +
  • + ))} +
+ {option.links?.map((link, linkIdx) => ( + + {link.text} + + + ))} +
+ ))} +
+ )} +
+
+
+ ))} +
+
+ ) +} diff --git a/src/components/CCIP/TutorialSetup/SetupSection.module.css b/src/components/CCIP/TutorialSetup/SetupSection.module.css new file mode 100644 index 00000000000..26f601f2845 --- /dev/null +++ b/src/components/CCIP/TutorialSetup/SetupSection.module.css @@ -0,0 +1,41 @@ +.section { + background: var(--color-background); + border-radius: 12px; + padding: 24px; + margin: 16px 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02); +} + +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; +} + +.headerContent { + flex: 1; +} + +.headerActions { + margin-left: 16px; + padding-top: 4px; +} + +.header h4 { + font-size: 16px; + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: 8px; +} + +.description { + font-size: 14px; + color: var(--color-text-secondary); + line-height: 1.5; +} + +.content { + /* Maintain consistent spacing with PrerequisitesCard */ + --section-spacing: 32px; +} diff --git a/src/components/CCIP/TutorialSetup/SetupSection.tsx b/src/components/CCIP/TutorialSetup/SetupSection.tsx new file mode 100644 index 00000000000..cc445e4f500 --- /dev/null +++ b/src/components/CCIP/TutorialSetup/SetupSection.tsx @@ -0,0 +1,33 @@ +import { ReactNode } from "react" +import styles from "./SetupSection.module.css" +import { StepCheckbox } from "../TutorialProgress/StepCheckbox" +import type { StepId, SubStepId } from "@stores/lanes" + +interface SetupSectionProps { + title: string + description?: string + children: ReactNode + checkbox?: { + stepId: StepId + subStepId: SubStepId + } +} + +export const SetupSection = ({ title, description, children, checkbox }: SetupSectionProps) => { + return ( +
+
+
+

{title}

+ {description &&

{description}

} +
+ {checkbox && ( +
+ +
+ )} +
+
{children}
+
+ ) +} diff --git a/src/components/CCIP/TutorialSetup/SolidityParam.module.css b/src/components/CCIP/TutorialSetup/SolidityParam.module.css new file mode 100644 index 00000000000..8f06cb78cb1 --- /dev/null +++ b/src/components/CCIP/TutorialSetup/SolidityParam.module.css @@ -0,0 +1,90 @@ +.parameter { + display: grid; + gap: var(--space-3x); + align-items: start; + + /* Default mobile-first layout: stacked */ + grid-template-columns: 1fr; + + /* Tablet (768px and up) */ + @media (min-width: 768px) { + grid-template-columns: 120px 1fr; + grid-template-areas: + "name info" + "type info"; + } + + /* Desktop (1024px and up) */ + @media (min-width: 1024px) { + grid-template-columns: 120px 80px 1fr; + grid-template-areas: "name type info"; + } +} + +.name { + font-size: var(--font-size-sm); + padding: var(--space-1x) var(--space-2x); + border-radius: 4px; + grid-area: name; +} + +.type { + font-size: var(--font-size-sm); + grid-area: type; + + /* On mobile/tablet, align with name */ + @media (max-width: 1023px) { + margin-top: calc(-1 * var(--space-2x)); + } +} + +.info { + font-size: var(--font-size-sm); + grid-area: info; + display: flex; + flex-direction: column; + gap: var(--space-1x); +} + +/* Parent container adjustments */ +.parameters { + padding: var(--space-2x); + font-size: var(--font-size-sm); + + @media (min-width: 768px) { + padding: var(--space-3x); + } + + @media (min-width: 1024px) { + padding: var(--space-4x); + } +} + +.parameterHelp { + /* Stack help items on mobile */ + @media (max-width: 767px) { + .helpItem { + flex-direction: column; + gap: var(--space-1x); + } + } +} + +/* Ensure text remains readable */ +.description { + line-height: var(--line-height-base); + font-size: var(--font-size-sm); + + @media (min-width: 768px) { + font-size: var(--font-size-base); + } +} + +/* Adjust spacing for different screen sizes */ +.example { + margin-top: var(--space-1x); + + @media (min-width: 768px) { + margin-top: var(--space-2x); + } +} diff --git a/src/components/CCIP/TutorialSetup/SolidityParam.tsx b/src/components/CCIP/TutorialSetup/SolidityParam.tsx new file mode 100644 index 00000000000..91382c78027 --- /dev/null +++ b/src/components/CCIP/TutorialSetup/SolidityParam.tsx @@ -0,0 +1,23 @@ +import { type ReactNode } from "react" +import styles from "./SolidityParam.module.css" +import { ReactCopyText } from "@components/ReactCopyText" + +interface SolidityParamProps { + name: string + type: string + description: string + example?: string | ReactNode + children?: ReactNode +} + +export const SolidityParam = ({ name, type, description, example, children }: SolidityParamProps) => ( +
+ {name} + {type} +
+

{description}

+ {example && (typeof example === "string" ? : example)} +
+ {children &&
{children}
} +
+) diff --git a/src/components/CCIP/TutorialSetup/TutorialCard.module.css b/src/components/CCIP/TutorialSetup/TutorialCard.module.css new file mode 100644 index 00000000000..c2846a8e9c6 --- /dev/null +++ b/src/components/CCIP/TutorialSetup/TutorialCard.module.css @@ -0,0 +1,52 @@ +.card { + background: var(--color-background); + border-radius: 12px; + padding: 24px; + margin: 16px 0; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.03); + border: 1px solid var(--color-border); + transition: all 0.2s ease-in-out; + position: relative; +} + +.card:hover { + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05), 0 6px 16px rgba(0, 0, 0, 0.08), 0 12px 32px rgba(0, 0, 0, 0.04); +} + +.card::before { + content: ""; + position: absolute; + left: 0; + top: 0; + width: 3px; + height: 100%; + background: var(--color-accent); + border-radius: 3px 0 0 3px; + opacity: 0.7; + transition: opacity 0.2s ease; +} + +.card:hover::before { + opacity: 1; +} + +.title { + font-family: var(--font-display); + font-size: 16px; + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: 8px; +} + +.description { + font-family: var(--font-body); + color: var(--color-text-secondary); + font-size: 14px; + line-height: 1.5; + margin-bottom: 16px; +} + +.content { + margin-top: var(--space-4x); +} diff --git a/src/components/CCIP/TutorialSetup/TutorialCard.tsx b/src/components/CCIP/TutorialSetup/TutorialCard.tsx new file mode 100644 index 00000000000..45121ab96ef --- /dev/null +++ b/src/components/CCIP/TutorialSetup/TutorialCard.tsx @@ -0,0 +1,17 @@ +import styles from "./TutorialCard.module.css" + +interface TutorialCardProps { + title: string + description?: string + children: React.ReactNode +} + +export const TutorialCard = ({ title, description, children }: TutorialCardProps) => { + return ( +
+
{title}
+ {description &&
{description}
} +
{children}
+
+ ) +} diff --git a/src/components/CCIP/TutorialSetup/TutorialStep.module.css b/src/components/CCIP/TutorialSetup/TutorialStep.module.css new file mode 100644 index 00000000000..8b4db9def53 --- /dev/null +++ b/src/components/CCIP/TutorialSetup/TutorialStep.module.css @@ -0,0 +1,37 @@ +.step { + position: relative; + padding: 1.5rem; + border-radius: 12px; + background: #fff; + margin-bottom: 1rem; + border: 1px solid #e2e8f0; + transition: all 0.2s ease; +} + +.step:hover { + border-color: #375bd2; + box-shadow: 0 4px 12px rgba(55, 91, 210, 0.08); +} + +.stepHeader { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.stepTitle { + flex: 1; + font-weight: 600; + color: #1e293b; + font-size: 1.125rem; +} + +.checkbox { + margin-left: auto; +} + +.stepContent { + color: #475569; + line-height: 1.6; +} diff --git a/src/components/CCIP/TutorialSetup/TutorialStep.tsx b/src/components/CCIP/TutorialSetup/TutorialStep.tsx new file mode 100644 index 00000000000..82250f736b9 --- /dev/null +++ b/src/components/CCIP/TutorialSetup/TutorialStep.tsx @@ -0,0 +1,18 @@ +import styles from "./TutorialStep.module.css" + +interface TutorialStepProps { + id?: string + title: string + children: React.ReactNode + checkbox?: React.ReactNode +} + +export const TutorialStep = ({ id, title, children, checkbox }: TutorialStepProps) => ( +
  • +
    + {title} + {checkbox &&
    {checkbox}
    } +
    +
    {children}
    +
  • +) diff --git a/src/components/CCIP/TutorialSetup/index.ts b/src/components/CCIP/TutorialSetup/index.ts new file mode 100644 index 00000000000..31e3a864579 --- /dev/null +++ b/src/components/CCIP/TutorialSetup/index.ts @@ -0,0 +1,7 @@ +export { PrerequisitesCard } from "./PrerequisitesCard" +export { SetupSection } from "./SetupSection" +export { TutorialCard } from "./TutorialCard" +export { TutorialStep } from "./TutorialStep" +export { ContractsImportCard } from "./ContractsImportCard" +export * from "./SolidityParam" +export * from "./NetworkCheck" diff --git a/src/components/CodeSample/CodeSample.astro b/src/components/CodeSample/CodeSample.astro index 908d786f161..73cef4f4cb2 100644 --- a/src/components/CodeSample/CodeSample.astro +++ b/src/components/CodeSample/CodeSample.astro @@ -8,8 +8,10 @@ export type Props = { src: string lang?: string showButtonOnly?: boolean + optimize?: boolean + runs?: number } -const { src, lang, showButtonOnly } = Astro.props as Props +const { src, lang, showButtonOnly, optimize, runs } = Astro.props as Props const data = (await fs.readFile(path.join(process.cwd(), "public", src), "utf-8")).toString() @@ -18,13 +20,20 @@ const isSample = isSolidityFile && (src.indexOf("samples/") === 0 || src.indexOf // remove leading slashes const cleanSrc = src.replace(/^\/+/, "") + +// Build optimization parameters string if provided +const optimizationParams = [optimize && "optimize=true", runs && `runs=${runs}`].filter(Boolean).join("&") + +const remixUrl = `https://remix.ethereum.org/#url=https://docs.chain.link/${cleanSrc}&autoCompile=true${ + optimizationParams ? `&${optimizationParams}` : "" +}` --- {!showButtonOnly && } { isSample && (
    - + Open in Remix What is Remix? diff --git a/src/components/CodeSample/CodeSampleReact.tsx b/src/components/CodeSample/CodeSampleReact.tsx new file mode 100644 index 00000000000..ae8fcc81115 --- /dev/null +++ b/src/components/CodeSample/CodeSampleReact.tsx @@ -0,0 +1,27 @@ +import React from "react" +import { useRemixUrl } from "src/hooks/useRemixUrl" + +interface CodeSampleReactProps { + src: string + showButtonOnly?: boolean + optimize?: boolean + runs?: number +} + +export const CodeSampleReact: React.FC = ({ src, showButtonOnly = false, optimize, runs }) => { + const remixUrl = useRemixUrl(src, { optimize, runs }) + + const isSolidityFile = src.match(/\.sol/) + const isSample = isSolidityFile && (src.indexOf("samples/") === 0 || src.indexOf("/samples/") === 0) + + if (!isSample || !showButtonOnly || !remixUrl) return null + + return ( + + ) +} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000000..6e2c2f733bb --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,36 @@ +import React, { Component, ErrorInfo, ReactNode } from "react" + +interface Props { + children: ReactNode + fallback?: ReactNode + onError?: (error: Error, errorInfo: ErrorInfo) => void +} + +interface State { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + } + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("Error caught by boundary:", error, errorInfo) + this.props.onError?.(error, errorInfo) + } + + public render() { + if (this.state.hasError) { + return this.props.fallback ||
    Something went wrong. Please try again.
    + } + + return this.props.children + } +} diff --git a/src/components/QuickLinks/sections/ProductChainTable.tsx b/src/components/QuickLinks/sections/ProductChainTable.tsx index 06a3b3fd329..5d9bbd3ddbb 100644 --- a/src/components/QuickLinks/sections/ProductChainTable.tsx +++ b/src/components/QuickLinks/sections/ProductChainTable.tsx @@ -31,8 +31,8 @@ const handleLinkClick = (productTitle: string, network: string, url: string) => window.dataLayer.push({ event: "quick_link_clicked", product: productTitle, - network: network, - url: url, + network, + url, }) } diff --git a/src/components/ReactCopyText.css b/src/components/ReactCopyText.css new file mode 100644 index 00000000000..f308fcb8237 --- /dev/null +++ b/src/components/ReactCopyText.css @@ -0,0 +1,18 @@ +.copyContainer { + display: inline-flex; + align-items: center; + gap: var(--space-1x); + overflow: hidden; + text-overflow: ellipsis; + word-break: break-all; + margin-top: 0; +} + +.copyBtn { + background: none; + border: none; +} + +.copyBtn:hover { + color: var(--color-text-link); +} diff --git a/src/components/ReactCopyText.tsx b/src/components/ReactCopyText.tsx new file mode 100644 index 00000000000..d02f52b7cde --- /dev/null +++ b/src/components/ReactCopyText.tsx @@ -0,0 +1,63 @@ +import { clsx } from "../lib" +import "./ReactCopyText.css" + +interface ReactCopyTextProps { + text: string + code?: boolean + format?: boolean + formatType?: "bytes32" + eventName?: string + additionalInfo?: Record +} + +interface Window { + dataLayer?: { + push: (event: { event: string } & Record) => void + } +} + +declare const window: Window + +export const ReactCopyText = ({ + text, + code, + format, + formatType, + eventName, + additionalInfo = {}, +}: ReactCopyTextProps) => { + const formatText = (text: string, type: string | undefined) => { + if (type === "bytes32" && text.length > 10) { + return text.slice(0, 6) + "..." + text.slice(-4) + } + return text + } + + const displayText = format ? formatText(text, formatType) : text + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault() + + if (eventName !== undefined) { + const dataLayerEvent = { + event: eventName, + ...additionalInfo, + } + window.dataLayer?.push(dataLayerEvent) + } + } + + return ( + + {code ? {displayText} : displayText} + + + ) +} diff --git a/src/components/RightSidebar/RightSidebar.astro b/src/components/RightSidebar/RightSidebar.astro index 11a76ddd9e2..d8aab60c29c 100644 --- a/src/components/RightSidebar/RightSidebar.astro +++ b/src/components/RightSidebar/RightSidebar.astro @@ -26,10 +26,9 @@ const { githubEditUrl, headings } = Astro.props diff --git a/src/pages/ccip/tutorials/[...slug].astro b/src/pages/ccip/tutorials/[...slug].astro new file mode 100644 index 00000000000..7d643c37303 --- /dev/null +++ b/src/pages/ccip/tutorials/[...slug].astro @@ -0,0 +1,37 @@ +--- +import { CollectionEntry, getCollection } from "astro:content" +import TutorialLayout from "../../../layouts/TutorialLayout.astro" +import DocsLayout from "../../../layouts/DocsLayout.astro" + +type Props = { + entry: CollectionEntry<"ccip"> +} + +export async function getStaticPaths() { + const entries = await getCollection("ccip", (entry) => { + return entry.slug.startsWith("tutorials/") + }) + + const paths: { params: { slug: string }; props: Props }[] = [] + + entries.forEach((entry) => { + const tutorialSlug = entry.slug.replace("tutorials/", "") + paths.push({ + params: { slug: tutorialSlug }, + props: { entry }, + }) + }) + + return paths +} + +const { entry } = Astro.props +const { Content, headings } = await entry.render() + +// Only use TutorialLayout for specific interactive tutorials +const Layout = entry.id === "tutorials/cross-chain-tokens/register-from-eoa-remix.mdx" ? TutorialLayout : DocsLayout +--- + + + + diff --git a/src/stores/lanes/index.ts b/src/stores/lanes/index.ts new file mode 100644 index 00000000000..e42df291390 --- /dev/null +++ b/src/stores/lanes/index.ts @@ -0,0 +1,605 @@ +import { atom, computed } from "nanostores" +import { Environment } from "@config/data/ccip" +import type { Network } from "@config/data/ccip/types" +import { utils } from "ethers" + +export type DeployedContracts = { + token?: string + tokenPool?: string + tokenPools?: string[] + registered?: boolean + configured?: boolean + poolType?: "lock" | "burn" +} + +export interface TokenBucketState { + tokens: string + lastUpdated: number + isEnabled: boolean + capacity: string + rate: string +} + +export const TUTORIAL_STEPS = { + setup: { + id: "setup", + title: "Setup", + subSteps: { + "browser-setup": "Web Browser Setup", + "gas-tokens": "Native Gas Tokens Ready", + "blockchains-selected": "Blockchains Selected", + "contracts-imported": "Contracts Imported", + }, + }, + sourceChain: { + id: "sourceChain", + title: "Source Chain", + subSteps: { + "token-deployed": "Token Deployed", + "admin-claimed": "Admin Role Claimed", + "admin-accepted": "Admin Role Accepted", + "pool-deployed": "Pool Deployed", + "pool-registered": "Pool Registered", + }, + }, + destinationChain: { + id: "destinationChain", + title: "Destination Chain", + subSteps: { + "dest-token-deployed": "Token Deployed", + "admin-claimed": "Admin Role Claimed", + "admin-accepted": "Admin Role Accepted", + "dest-pool-deployed": "Pool Deployed", + "dest-pool-registered": "Pool Registered", + }, + }, + sourceConfig: { + id: "sourceConfig", + title: "Source Configuration", + subSteps: { + "source-privileges": "Grant Burn and Mint Privileges", + "source-pool-config": "Configure Pool", + "source-verification": "Verify Configuration", + }, + }, + destinationConfig: { + id: "destinationConfig", + title: "Destination Configuration", + subSteps: { + "dest-privileges": "Grant Burn and Mint Privileges", + "dest-pool-config": "Configure Pool", + "dest-verification": "Verify Configuration", + }, + }, +} as const + +export type StepId = keyof typeof TUTORIAL_STEPS +export type SubStepId = keyof (typeof TUTORIAL_STEPS)[T]["subSteps"] + +export interface RateLimiterConfig { + enabled: boolean + capacity: string + rate: string +} + +export type RateLimits = { + inbound: RateLimiterConfig + outbound: RateLimiterConfig +} + +export type LaneState = { + sourceChain: string + destinationChain: string + environment: Environment + sourceNetwork: Network | null + destinationNetwork: Network | null + sourceContracts: DeployedContracts + destinationContracts: DeployedContracts + progress: Record> + inboundRateLimiter: TokenBucketState | null + outboundRateLimiter: TokenBucketState | null + sourceRateLimits: RateLimits | null + destinationRateLimits: RateLimits | null + currentStep?: StepId +} + +// Add performance monitoring +const monitorStoreUpdate = (action: string, details: Record) => { + if (process.env.NODE_ENV === "development") { + console.log(`[StoreAction] ${action}:`, { + ...details, + timestamp: new Date().toISOString(), + }) + } +} + +// Define conditions at the top level +const conditions = [ + // Prerequisites conditions + { + stepId: "setup" as StepId, + subStepId: "browser-setup", + check: (state: LaneState) => state.progress.setup?.["browser-setup"] === true, + dependencies: [] as StepId[], + }, + { + stepId: "setup" as StepId, + subStepId: "gas-tokens", + check: (state: LaneState) => state.progress.setup?.["gas-tokens"] === true, + dependencies: ["setup"] as StepId[], + }, + { + stepId: "setup" as StepId, + subStepId: "blockchains-selected", + check: (state: LaneState) => { + const sourceSelected = Boolean(state.sourceChain && state.sourceNetwork) + const destSelected = Boolean(state.destinationChain && state.destinationNetwork) + return sourceSelected && destSelected + }, + dependencies: ["setup"] as StepId[], + }, + { + stepId: "setup" as StepId, + subStepId: "contracts-imported", + check: (state: LaneState) => state.progress.setup?.["contracts-imported"] === true, + dependencies: ["setup"] as StepId[], + }, + // Token deployment conditions + { + stepId: "sourceChain" as StepId, + subStepId: "token-deployed", + check: (state: LaneState) => { + const hasToken = !!state.sourceContracts.token && utils.isAddress(state.sourceContracts.token) + return hasToken + }, + dependencies: ["setup"] as StepId[], + }, + { + stepId: "sourceChain" as StepId, + subStepId: "pool-deployed", + check: (state: LaneState) => { + const hasPool = !!state.sourceContracts.tokenPool && utils.isAddress(state.sourceContracts.tokenPool) + return hasPool + }, + dependencies: ["sourceChain"] as StepId[], + }, + { + stepId: "sourceChain" as StepId, + subStepId: "pool-registered", + check: (state: LaneState) => !!state.sourceContracts.registered, + dependencies: ["sourceChain"] as StepId[], + }, + { + stepId: "destinationChain" as StepId, + subStepId: "dest-token-deployed", + check: (state: LaneState) => !!state.destinationContracts.token, + dependencies: ["setup"] as StepId[], + }, + { + stepId: "destinationChain" as StepId, + subStepId: "dest-pool-deployed", + check: (state: LaneState) => { + const hasPool = !!state.destinationContracts.tokenPool && utils.isAddress(state.destinationContracts.tokenPool) + return hasPool + }, + dependencies: ["destinationChain"] as StepId[], + }, + { + stepId: "destinationChain" as StepId, + subStepId: "dest-pool-registered", + check: (state: LaneState) => !!state.destinationContracts.registered, + dependencies: ["destinationChain"] as StepId[], + }, + { + stepId: "sourceChain" as StepId, + subStepId: "admin-claimed", + check: (state: LaneState) => state.progress.sourceChain?.["admin-claimed"] === true, + dependencies: ["sourceChain"] as StepId[], + }, + { + stepId: "sourceChain" as StepId, + subStepId: "admin-accepted", + check: (state: LaneState) => state.progress.sourceChain?.["admin-accepted"] === true, + dependencies: ["sourceChain"] as StepId[], + }, + { + stepId: "destinationChain" as StepId, + subStepId: "admin-claimed", + check: (state: LaneState) => state.progress.destinationChain?.["admin-claimed"] === true, + dependencies: ["destinationChain"] as StepId[], + }, + { + stepId: "destinationChain" as StepId, + subStepId: "admin-accepted", + check: (state: LaneState) => state.progress.destinationChain?.["admin-accepted"] === true, + dependencies: ["destinationChain"] as StepId[], + }, + { + stepId: "sourceConfig" as StepId, + subStepId: "source-privileges", + check: (state: LaneState) => state.progress.sourceConfig?.["source-privileges"] === true, + dependencies: ["sourceChain"], + }, + { + stepId: "sourceConfig" as StepId, + subStepId: "source-pool-config", + check: (state: LaneState) => state.progress.sourceConfig?.["source-pool-config"] === true, + dependencies: ["sourceChain"], + }, + { + stepId: "sourceConfig" as StepId, + subStepId: "source-verification", + check: (state: LaneState) => state.progress.sourceConfig?.["source-verification"] === true, + dependencies: ["sourceChain"], + }, + { + stepId: "destinationConfig" as StepId, + subStepId: "dest-privileges", + check: (state: LaneState) => state.progress.destinationConfig?.["dest-privileges"] === true, + dependencies: ["destinationChain"], + }, + { + stepId: "destinationConfig" as StepId, + subStepId: "dest-pool-config", + check: (state: LaneState) => state.progress.destinationConfig?.["dest-pool-config"] === true, + dependencies: ["destinationChain"], + }, + { + stepId: "destinationConfig" as StepId, + subStepId: "dest-verification", + check: (state: LaneState) => state.progress.destinationConfig?.["dest-verification"] === true, + dependencies: ["destinationChain"], + }, +] + +// Helper function to check if prerequisites are complete +export const arePrerequisitesComplete = (state: LaneState): boolean => { + return ( + state.progress.setup?.["browser-setup"] === true && + state.progress.setup?.["gas-tokens"] === true && + state.progress.setup?.["blockchains-selected"] === true && + state.progress.setup?.["contracts-imported"] === true + ) +} + +// Create individual atoms for each step's progress +export const setupProgressStore = atom>({}) +export const sourceChainProgressStore = atom>({}) +export const destinationChainProgressStore = atom>({}) +export const sourceConfigProgressStore = atom>({}) +export const destinationConfigProgressStore = atom>({}) + +// Computed store that combines all progress +export const progressStore = computed( + [ + setupProgressStore, + sourceChainProgressStore, + destinationChainProgressStore, + sourceConfigProgressStore, + destinationConfigProgressStore, + ], + (setup, sourceChain, destinationChain, sourceConfig, destinationConfig) => ({ + setup, + sourceChain, + destinationChain, + sourceConfig, + destinationConfig, + }) +) + +// Main store without progress +export const laneStore = atom>({ + sourceChain: "", + destinationChain: "", + environment: Environment.Testnet, + sourceNetwork: null, + destinationNetwork: null, + sourceContracts: {}, + destinationContracts: {}, + inboundRateLimiter: null, + outboundRateLimiter: null, + sourceRateLimits: { + inbound: { enabled: false, capacity: "", rate: "" }, + outbound: { enabled: false, capacity: "", rate: "" }, + }, + destinationRateLimits: { + inbound: { enabled: false, capacity: "", rate: "" }, + outbound: { enabled: false, capacity: "", rate: "" }, + }, +}) + +// Helper to get the correct store for a step with type safety +const getStoreForStep = (stepId: StepId) => { + switch (stepId) { + case "setup": + return setupProgressStore + case "sourceChain": + return sourceChainProgressStore + case "destinationChain": + return destinationChainProgressStore + case "sourceConfig": + return sourceConfigProgressStore + case "destinationConfig": + return destinationConfigProgressStore + default: + throw new Error(`Invalid step ID: ${stepId}`) + } +} + +// Standard progress update function for all checkboxes +export const updateStepProgress = (stepId: string, subStepId: string, completed: boolean) => { + const startTime = Date.now() + const store = getStoreForStep(stepId as StepId) + const current = store.get() + + if (current[subStepId] === completed) { + monitorStoreUpdate("SkippedUpdate", { + stepId, + subStepId, + reason: "No change needed", + duration: Date.now() - startTime, + }) + return + } + + monitorStoreUpdate("StartUpdate", { + stepId, + subStepId, + completed, + currentState: current, + }) + + // Batch updates to minimize renders + const updates = new Map() + + // Update progress store + updates.set(store, { + ...current, + [subStepId]: completed, + }) + + // For pool registration, also update contract state + if ( + (stepId === "sourceChain" && subStepId === "pool-registered") || + (stepId === "destinationChain" && subStepId === "dest-pool-registered") + ) { + const chain = stepId === "sourceChain" ? "source" : "destination" + const contractsKey = `${chain}Contracts` as const + const currentState = laneStore.get() + + updates.set(laneStore, { + ...currentState, + [contractsKey]: { + ...currentState[contractsKey], + registered: completed, + }, + }) + } + + // Apply all updates in a single batch + updates.forEach((value, store) => { + store.set(value) + }) + + monitorStoreUpdate("CompleteUpdate", { + stepId, + subStepId, + duration: Date.now() - startTime, + }) +} + +// Helper to update progress for a specific step (internal use only) +function updateProgressForStep(stepId: StepId, updates: Record) { + const store = getStoreForStep(stepId) + const current = store.get() + store.set({ + ...current, + ...updates, + }) +} + +// Utility function to handle contract progress updates +const updateContractProgress = (type: keyof DeployedContracts, chain: "source" | "destination", value: string) => { + const isValidAddress = Boolean(value) && utils.isAddress(value) + + if (type === "token") { + updateProgressForStep(chain === "source" ? "sourceChain" : "destinationChain", { + [chain === "source" ? "token-deployed" : "dest-token-deployed"]: isValidAddress, + }) + } else if (type === "tokenPool") { + updateProgressForStep(chain === "source" ? "sourceChain" : "destinationChain", { + [chain === "source" ? "pool-deployed" : "dest-pool-deployed"]: isValidAddress, + }) + } +} + +export const setSourceContract = (type: keyof DeployedContracts, value: string) => { + const currentState = laneStore.get() + monitorStoreUpdate("setSourceContract", { type, value }) + + laneStore.set({ + ...currentState, + sourceContracts: { + ...currentState.sourceContracts, + [type]: value, + }, + }) + + updateContractProgress(type, "source", value) +} + +export const setDestinationContract = (type: keyof DeployedContracts, value: string) => { + const currentState = laneStore.get() + monitorStoreUpdate("setDestinationContract", { type, value }) + + laneStore.set({ + ...currentState, + destinationContracts: { + ...currentState.destinationContracts, + [type]: value, + }, + }) + + updateContractProgress(type, "destination", value) +} + +// Helper to subscribe to specific step's progress +export const subscribeToStepProgress = (stepId: StepId, callback: (progress: Record) => void) => { + const store = getStoreForStep(stepId) + return store.subscribe(callback) +} + +export const setRateLimiterState = (type: "inbound" | "outbound", state: TokenBucketState | null) => { + const current = laneStore.get() + laneStore.set({ + ...current, + [type === "inbound" ? "inboundRateLimiter" : "outboundRateLimiter"]: state, + }) +} + +export const setRemotePools = (chain: "source" | "destination", pools: string[]) => { + const current = laneStore.get() + const contracts = chain === "source" ? "sourceContracts" : "destinationContracts" + laneStore.set({ + ...current, + [contracts]: { + ...current[contracts], + tokenPool: pools[0], + tokenPools: pools, + }, + }) +} + +export const validateRateLimits = (limits: RateLimits): boolean => { + if (!limits) return false + + const validateConfig = (config: RateLimiterConfig) => { + if (config.enabled) { + try { + // Parse as BigInt and validate + const capacity = BigInt(config.capacity || "0") + const rate = BigInt(config.rate || "0") + + // Ensure it's a valid uint128 + const MAX_UINT128 = BigInt(2) ** BigInt(128) - BigInt(1) + return capacity >= 0n && capacity <= MAX_UINT128 && rate >= 0n && rate <= MAX_UINT128 + } catch (e) { + console.error("Rate limit validation error:", e) + return false + } + } + return true + } + + return validateConfig(limits.inbound) && validateConfig(limits.outbound) +} + +// Helper to update rate limits with validation +export const updateRateLimits = ( + chain: "source" | "destination", + type: "inbound" | "outbound", + updates: Partial +) => { + const current = laneStore.get() + const rateLimitsKey = `${chain}RateLimits` as const + const currentLimits = current[rateLimitsKey] ?? { + inbound: { enabled: false, capacity: "", rate: "" }, + outbound: { enabled: false, capacity: "", rate: "" }, + } + + const newLimits = { + ...currentLimits, + [type]: { + ...currentLimits[type], + ...updates, + }, + } + + if (validateRateLimits(newLimits)) { + laneStore.set({ + ...current, + [rateLimitsKey]: newLimits, + }) + return true + } + return false +} + +// Helper function to get complete state +const getCompleteState = (): LaneState => ({ + ...laneStore.get(), + progress: progressStore.get(), +}) + +// Update checkSpecificProgress to optionally accept state +const checkSpecificProgress = (conditionsToCheck: typeof conditions, providedState?: LaneState) => { + const state = providedState || getCompleteState() + + // Only check dependent conditions if prerequisites are complete + if (!arePrerequisitesComplete(state) && !conditionsToCheck.every((c) => c.stepId === "setup")) { + return + } + + for (const condition of conditionsToCheck) { + const startTime = performance.now() + const isComplete = condition.check(state) + + monitorStoreUpdate("ConditionCheck", { + stepId: condition.stepId, + subStepId: condition.subStepId, + isComplete, + duration: Math.round(performance.now() - startTime), + }) + + // Update progress if needed + if (isComplete !== state.progress[condition.stepId]?.[condition.subStepId]) { + updateProgressForStep(condition.stepId, { [condition.subStepId]: isComplete }) + } + } +} + +// Update the progress check function to be more focused +export const checkProgress = (stepId: StepId, subStepId: string) => { + const state = getCompleteState() + monitorStoreUpdate("StartUpdate", { stepId, subStepId, currentState: state }) + + // For setup steps, check all setup conditions and their dependencies + if (stepId === "setup") { + const setupConditions = conditions.filter((condition) => condition.stepId === "setup") + checkSpecificProgress(setupConditions, state) + + // If all prerequisites are complete, check dependent conditions + if (arePrerequisitesComplete(state)) { + const dependentConditions = conditions.filter((condition) => condition.dependencies.includes("setup")) + checkSpecificProgress(dependentConditions, state) + } + } else { + // For other steps, check only directly related conditions + const relevantConditions = conditions.filter( + (condition) => + (condition.stepId === stepId && condition.subStepId === subStepId) || condition.dependencies.includes(stepId) + ) + if (relevantConditions.length > 0) { + checkSpecificProgress(relevantConditions, state) + } + } +} + +export const navigateToStep = (stepId: StepId) => { + const store = getStoreForStep(stepId) + const currentState = store.get() + + // Update lane store to reflect the current step + const currentLaneState = laneStore.get() + laneStore.set({ + ...currentLaneState, + currentStep: stepId, + }) + + // Emit navigation event for scroll handling + window.dispatchEvent( + new CustomEvent("tutorial-navigate", { + detail: { stepId }, + }) + ) +} diff --git a/tsconfig.json b/tsconfig.json index 222ebcf539b..ef11db7bc8d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,15 +19,18 @@ "baseUrl": ".", "paths": { "~/*": ["src/*"], + "@components": ["src/components/index.ts"], + "@components/*": ["src/components/*"], "@config": ["src/config/"], "@config/*": ["src/config/*"], "@features/*": ["src/features/*"], - "@variables": ["src/config/markdown-variables.ts"], - "@components/*": ["src/components/*"], - "@components": ["src/components/index.ts"], - "@abi": ["src/features/abi/index.ts"], "@graphql": ["src/graphql/"], - "@graphql/*": ["src/graphql/*"] + "@graphql/*": ["src/graphql/*"], + "@stores/*": ["src/stores/*"], + "@utils": ["src/utils/"], + "@utils/*": ["src/utils/*"], + "@variables": ["src/config/markdown-variables.ts"], + "@abi": ["src/features/abi/index.ts"] }, "strictNullChecks": true, "verbatimModuleSyntax": false