diff --git a/docs/build-decentralized-apps/01-quickstart-solidity-remix.mdx b/docs/build-decentralized-apps/01-quickstart-solidity-remix.mdx index ed59b51873..82c4002662 100644 --- a/docs/build-decentralized-apps/01-quickstart-solidity-remix.mdx +++ b/docs/build-decentralized-apps/01-quickstart-solidity-remix.mdx @@ -1,805 +1,16 @@ --- title: 'Build a decentralized app with Solidity (Quickstart)' -description: 'Learn how to build and deploy your first Solidity smart contract on Arbitrum using Remix IDE. This beginner-friendly quickstart guides you from a JavaScript vending machine to a decentralized app deployed on Arbitrum One, covering smart contract basics, wallet setup, and testnet deployment.' +description: 'Learn how to build and deploy your first Solidity smart contract on Arbitrum using an on-page Solidity workbench.' author: symbolpunk -user_story: 'As a web2 developer new to blockchain, or a web3 developer new to Arbitrum, I want a step-by-step guide to writing, compiling, and deploying a Solidity smart contract on Arbitrum using Remix IDE so that I can understand the fundamentals of decentralized app development and start building on Arbitrum.' +user_story: 'As a web2 developer new to blockchain, or a web3 developer new to Arbitrum, I want a guided IDE-style workflow for writing, compiling, deploying, and calling a Solidity smart contract so that I can understand the fundamentals of decentralized app development and start building on Arbitrum.' content_type: quickstart -slug: /build-decentralized-apps/quickstart-solidity-remix +interactive_tutorial: true +tutorial_kind: Guided IDE quickstart +estimated_time: 35 min +slug: /build-decentralized-apps/quickstart-solidity displayed_sidebar: buildAppsSidebar --- -:::info Want to use Rust instead? +import { SolidityQuickstartWorkbench } from '@site/src/components/InteractiveTutorials'; -Head over to [the Stylus quickstart](/stylus/quickstart) if you'd like to use Rust instead of Solidity. - -::: - -This quickstart is for web developers who want to start building **decentralized applications** using Arbitrum. It makes no assumptions about your prior experience with Ethereum, Arbitrum, or Solidity. Familiarity with Javascript and yarn is expected. If you're new to Ethereum, consider studying the [Ethereum documentation](https://ethereum.org/en/developers/docs/) before proceeding. - -import { VendingMachine } from '@site/src/components/VendingMachine/VendingMachine'; - -## What we'll learn - -In this tutorial we will learn: - -1. The basics of Ethereum vs. client/server architecture -2. What is a Solidity smart contract -3. How to compile and deploy a smart contract -4. How to use an Ethereum wallet - -We're going to build a digital cupcake vending machine using Solidity smart contracts[^1]. This vending machine will follow two rules: - -1. The vending machine will distribute a cupcake to anyone who hasn't recently received one. -2. The vending machine's rules can't be changed by anyone. - -Here's the vending machine implemented with Javascript. -To use it, enter a name in the form below and press the **Cupcake please!** button, you should see your cupcake balance go up. - - - -We can assume that this vending machine operates as we expect, but it's largely up to the **centralized service provider** that hosts it. -In the case of a compromised cloud host: - -1. Our centralized service provider can deny access to particular users. -2. A malicious actor can change the rules of the vending machine at any time, for example, to give their friends extra cupcakes. - -Centralized third-party intermediaries represent a **single point of failure** that malicious actors can exploit. With a blockchain infrastructure such as Ethereum, we decentralize our vending machine's **business logic and data**, making this type of exploits nearly impossible. - -This is Arbitrum's core value proposition to you, dear developer. Arbitrum makes it easy for you to deploy your vending machines to Ethereum's permissionless, trustless, decentralized network of nodes[^2] **while keeping costs low for you and your users**. - -Let's implement the "Web3" version of the above vending machine using Arbitrum. - -## Prerequisites - -VS Code is the IDE we'll use to build our vending machine. See [code.visualstudio.com](https://code.visualstudio.com/) to install. - - - We will use Metamask as the wallet to interact with our vending machine. See [metamask.io](https://metamask.io/) and click View MetaMask Web or [OKX Wallet](https://www.okx.com/web3) and click Connect Wallet to install. - - -Yarn is the package manager we'll use to install dependencies. See [yarnpkg.com](https://yarnpkg.com/) to install. - -Foundry is the toolchain we'll use to compile and deploy our smart contract. See [getfoundry.sh](https://getfoundry.sh) to install. - -We'll address any remaining dependencies as we go. - -## Ethereum and Arbitrum in a nutshell - -- **Ethereum** - - Ethereum is a decentralized network of [nodes](https://docs.prylabs.network/docs/concepts/nodes-networks) that use Ethereum's client software (like [Offchain's Prysm](https://www.offchainlabs.com/prysm/docs) to maintain a public blockchain data structure. - - The data within Ethereum's blockchain data structure changes one transaction at a time. - - Smart contracts are small programs that execute transactions according to predefined rules. Ethereum's nodes host and execute smart contracts. - - You can use smart contracts to build decentralized apps that use Ethereum's network to process transactions and store data. Think of smart contracts as your app's backend - - Apps let users carry their data and identity between applications without trusting centralized service providers. - - People who run Ethereum validator nodes[^3] can earn **ETH** for processing and validating transactions on behalf of users and apps. - - These transactions can be expensive when the network is under heavy load. -- **Arbitrum** - - Arbitrum is a finance-native platform providing infrastructure for building applications. - - Arbitrum One is a child chain that implements the - Arbitrum Rollup protocol. - - You can use Arbitrum One to build user-friendly apps with high throughput, low latency, and low transaction costs while inheriting Ethereum's high-security standards[^4]. - - - -## Review the Javascript vending machine - -Here's the vending machine implemented as a Javascript class: - - - -```js -class VendingMachine { - // state variables = internal memory of the vending machine - cupcakeBalances = {}; - cupcakeDistributionTimes = {}; - - // Vend a cupcake to the caller - giveCupcakeTo(userId) { - if (this.cupcakeDistributionTimes[userId] === undefined) { - this.cupcakeBalances[userId] = 0; - this.cupcakeDistributionTimes[userId] = 0; - } - - // Rule 1: The vending machine will distribute a cupcake to anyone who hasn't recently received one. - const fiveSeconds = 5000; - const userCanReceiveCupcake = this.cupcakeDistributionTimes[userId] + fiveSeconds <= Date.now(); - if (userCanReceiveCupcake) { - this.cupcakeBalances[userId]++; - this.cupcakeDistributionTimes[userId] = Date.now(); - console.log(`Enjoy your cupcake, ${userId}!`); - return true; - } else { - console.error('HTTP 429: Too Many Cupcakes (you must wait at least 5 seconds between cupcakes)'); - return false; - } - } - - getCupcakeBalanceFor(userId) { - return this.cupcakeBalances[userId]; - } -} -``` - - - -The `VendingMachine` class uses _state variables_ and _functions_ to implement _predefined rules_. This implementation is useful because it automates cupcake distribution, but there's a problem: it's hosted by a centralized server controlled by a third-party service provider. - -:::info Working web2 version - -To use the Vending Machine (web2), copy and paste the HTML code below into a text document, then open it in your web browser. - - - -```html - - - - - - Cupcake Vending Machine - Arbitrum Quickstart - - - - - -

Cupcake Vending Machine

-

From the Arbitrum Solidity + Remix Quickstart

- -
- - -
- Web2 -

Free Cupcakes

-

Pure JavaScript vending machine. Business logic runs in your browser — no blockchain involved.

- - - - -
- - -
- -
- -
- Cupcake balance for : - 0 -
- -
-
- - - - - - - - - -``` - - - -::: - -Now, let's decentralize our vending machine's business logic and data by porting the above JavaScript implementation into a Solidity smart contract. - -## Review the Solidity vending machine - -Here is a Solidity implementation of the vending machine. -Solidity is a language that compiles to [EVM bytecode](https://blog.chain.link/what-are-abi-and-bytecode-in-solidity/). This means that it is deployable to any Ethereum-compatible blockchain, including Ethereum mainnet, Arbitrum One, and Arbitrum Nova. - - - -```solidity -// SPDX-License-Identifier: MIT -// Specify the Solidity compiler version - this contract requires version 0.8.9 or higher -pragma solidity ^0.8.9; - -// Define a smart contract named VendingMachine -// Unlike regular classes, once deployed, this contract's code cannot be modified -// This ensures that the vending machine's rules remain constant and trustworthy -contract VendingMachine { - // State variables are permanently stored in blockchain storage - // These mappings associate Ethereum addresses with unsigned integers - // The 'private' keyword means these variables can only be accessed from within this contract - mapping(address => uint) private _cupcakeBalances; // Tracks how many cupcakes each address owns - mapping(address => uint) private _cupcakeDistributionTimes; // Tracks when each address last received a cupcake - - // Function to give a cupcake to a specified address - // 'public' means this function can be called by anyone - // 'returns (bool)' specifies that the function returns a boolean value - function giveCupcakeTo(address userAddress) public returns (bool) { - // Initialize first-time users - // In Solidity, uninitialized values default to 0, so this check isn't strictly necessary - // but is included to mirror the JavaScript implementation - if (_cupcakeDistributionTimes[userAddress] == 0) { - _cupcakeBalances[userAddress] = 0; - _cupcakeDistributionTimes[userAddress] = 0; - } - - // Calculate when the user is eligible for their next cupcake - // 'seconds' is a built-in time unit in Solidity - // 'block.timestamp' gives us the current time in seconds since Unix epoch - uint fiveSecondsFromLastDistribution = _cupcakeDistributionTimes[userAddress] + 5 seconds; - bool userCanReceiveCupcake = fiveSecondsFromLastDistribution <= block.timestamp; - - if (userCanReceiveCupcake) { - // If enough time has passed, give them a cupcake and update their last distribution time - _cupcakeBalances[userAddress]++; - _cupcakeDistributionTimes[userAddress] = block.timestamp; - return true; - } else { - // If not enough time has passed, revert the transaction with an error message - // 'revert' cancels the transaction and returns the error message to the user - revert("HTTP 429: Too Many Cupcakes (you must wait at least 5 seconds between cupcakes)"); - } - } - - // Function to check how many cupcakes an address owns - // 'public' means anyone can call this function - // 'view' means this function only reads data and doesn't modify state - // This makes it free to call (no gas cost) when called externally - function getCupcakeBalanceFor(address userAddress) public view returns (uint) { - return _cupcakeBalances[userAddress]; - } -} -``` - - - -## Compile your smart contract with Remix - -Smart contracts need to be compiled to bytecode to be stored and executed onchain by the EVM; we'll use Remix to do that. - -Remix is a browser-based IDE for EVM development. There are other IDEs to choose from (Foundry, Hardhat), but Remix doesn't require any local environment setup, so we'll use it for this tutorial. - -Let's first add our smart contract to Remix following these steps: - -### 1. Load Remix: https://remix.ethereum.org - -### 2. Create a blank workspace in Remix: - -{' '} - - - - - -### 3. Copy your vending machine contract - -### 4. Paste your contract in Remix - - - - - -"File explorer > New file" - -### 5. Compile your contract in Remix - - - - - - - -Ensure that Remix's compiler version matches the one in your contract. You can find your contract's compiler version at the top of your contract's file. It looks like this: - -```solidity -pragma solidity ^0.8.2; -``` - -You can easily select the right compiler version in Remix's the "Solidity compiler" menu. - - - -## Deploy the smart contract to a local Ethereum chain - -Once a smart contract gets compiled, it is deployable to a blockchain. The safest way to do this is to deploy it to a locally hosted chain, where you can test and debug your contract before deploying it to a public chain. - -To deploy our `VendingMachine` smart contract locally, we will: - -1. Run Foundry's local Ethereum node in a terminal window -2. Configure a wallet so we can interact with our smart contract after deployment (1) -3. Deploy our smart contract to (1)'s node using Remix - -### Run a local chain - -Here, we'll use [Foundry's **anvil**](https://book.getfoundry.sh/anvil/) to run a local Ethereum network and node. - -```shell -curl -L https://foundry.paradigm.xyz | bash && anvil -``` - - - -```shell - (_) | | - __ _ _ __ __ __ _ | | - / _` | | '_ \ \ \ / / | | | | - | (_| | | | | | \ V / | | | | - \__,_| |_| |_| \_/ |_| |_| - - 0.2.0 (7f0f5b4 2024-08-08T00:19:07.020431000Z) - https://github.com/foundry-rs/foundry - -# Available Accounts - -(0) 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000.000000000000000000 ETH) -(1) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 (10000.000000000000000000 ETH) -(2) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC (10000.000000000000000000 ETH) -(3) 0x90F79bf6EB2c4f870365E785982E1f101E93b906 (10000.000000000000000000 ETH) -(4) 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 (10000.000000000000000000 ETH) -(5) 0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc (10000.000000000000000000 ETH) -(6) 0x976EA74026E726554dB657fA54763abd0C3a0aa9 (10000.000000000000000000 ETH) -(7) 0x14dC79964da2C08b23698B3D3cc7Ca32193d9955 (10000.000000000000000000 ETH) -(8) 0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f (10000.000000000000000000 ETH) -(9) 0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 (10000.000000000000000000 ETH) - -# Private Keys - -(0) 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -(1) 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d -(2) 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a -(3) 0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6 -(4) 0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a -(5) 0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba -(6) 0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e -(7) 0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356 -(8) 0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97 -(9) 0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6 - -# Wallet - -Mnemonic: test test test test test test test test test test test junk -Derivation path: m/44'/60'/0'/0/ - -# Chain ID - -31337. -``` - - - -### Configure Metamask - -Next, open Metamask and create or import a wallet by following the displayed instructions. - -By default, Metamask will connect to Ethereum's mainnet. To connect to our local "testnet," enable test networks for Metamask by clicking **Show/hide test networks**. - -Next, click Metamask's network selector dropdown and click the **Add Network** button. Click **Add a network manually** and then provide the following information: - -- Network Name: `localhost` -- New RPC URL: `http://127.0.0.1:8545` -- Chain ID: `31337` -- Currency Symbol: **ETH** - - - - - -Your wallet won't have a balance on your local testnet's node, but you can import one of the test accounts into Metamask to access to 10,000 testnet **ETH**. Copy the private key of one of the test accounts (it works with or without the `0x` prefix, so e.g., `0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80` or `ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80`) and import it into Metamask. Metamask will ask you if you want to connect this new account to Remix, to which you should answer "yes": - - - -:::caution Never share your private keys - -Your Ethereum Mainnet wallet's private key is the password to all of your tokens. Never share it with anyone; avoid copying it to your clipboard. - -::: - -Note that in the context of this quickstart, "account" refers to an EOA (externally owned account), and its associated private key[^5]. - -You should see a balance of 10,000 **ETH**. Keep your private key handy; we'll use it again shortly. - -As we interact with our cupcake vending machine, we'll use Metamask's network selector dropdown to choose which network our cupcake transactions get sent to. We'll leave the network set to `Localhost 8545` for now. - -### Connect Remix to Metamask - -In the last step, we'll connect Remix to Metamask so we can deploy our smart contract to the local chain using Remix. - - - - - -At this point, we're ready to deploy our smart contract to any chain we want. - -### Deploy the smart contract to your local chain - -- In MetaMask, ensure that the `Localhost` network is selected. -- In Remix, deploy the `VendingMachine` contract to the `Localhost` network, then go to the "Deploy & Run Transactions" tab and click "Deploy." - - - - - -Then copy and paste your **contract address** below and click **Get cupcake!**. A prompt should ask you to sign a transaction that gives you a cupcake. - - - -## What's going on, here? - -Our first `VendingMachine` is labeled "Web2" because it demonstrates traditional client-server web application architecture: the back-end lives in a centralized network of servers. - - - -The "Web3" architecture is similar to the "Web2" architecture, with one key difference: with the "Web3" version, business logic and data are hosted by decentralized network of nodes\*\* - -Let's take a closer look at the differences between our `VendingMachine` implementations: - - - -| | `WEB2`
(the first one) | `WEB3-LOCALHOST`
(the latest one) | `WEB3-ARB-SEPOLIA`
(the next one) | `WEB3-ARB-MAINNET`
(the final one) | -| --------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| **Data** (cupcakes) | Stored only in your **browser**. (Usually, stored by centralized infrastructure.) | Stored on your **device** in an **emulated Ethereum network** (via smart contract). | Stored on Ethereum's **decentralized test network** (via smart contract). | Stored on Ethereum's **decentralized mainnet network** (via smart contract). | -| **Logic** (vending) | Served from **Offchain's servers**. Executed by your **browser**. | Stored and executed by your **locally emulated Ethereum network** (via smart contract). | Stored and executed by Arbitrum's **decentralized test network** (via smart contract). | Stored and executed by Arbitrum's **decentralized mainnet network** (via smart contract). | -| **Presentation** (UI) | Served from **Offchain's servers**. Rendered and executed by your **browser**. | ← same | ← same | ← same | -| **Money** | Devs and users pay centralized service providers for server access using fiat currency. | ← same, but only for the presentation-layer concerns (code that supports frontend UI/UX). | ← same, but devs and users pay **testnet ETH** to testnet validators. | ← same, but instead of testnet **ETH**, they use **mainnet **ETH\*\*\*\*. | - -So far, we've deployed our "Web3" app to an emulated blockchain (Anvil), which is a normal step in EVM development. - -Next, we'll deploy our smart contract to a network of real nodes: Arbitrum's Sepolia testnet. - -## Deploy the smart contract to the Arbitrum Sepolia testnet - -We were able to deploy to a testnet for free because we were using Remix's built-in network, but now we'll deploy our contract to Arbitrum's Sepolia testnet. -Sepolia is powered by a network of nodes ran across the world by various participants, we'll need to compensate them with a small transaction fee in order to deploy our smart contract. - -To be able to pay the transaction fee, we will: - -- Use our MetaMask crypto wallet -- Obtain some Arbitrum Sepolia testnet's token called **ETH**. - -Click Metamask's **Network selector** dropdown, and then click the **Add Network** button. Click **Add a network manually** and then provide the following information: - -- Network Name: `Arbitrum Sepolia` -- New RPC URL: `https://sepolia-rollup.arbitrum.io/rpc` -- Chain ID: `421614` -- Currency Symbol: **ETH** - -As we interact with the cupcake vending machine, we'll use Metamask's network selector dropdown to determine which network our cupcake transactions are sent to. - -Next, let's deposit some **ETH** into the wallet corresponding to the private key we added to Remix. At the time of this quickstart's writing, the easiest way to acquire **ETH** is to bridge Sepolia **ETH** from Ethereum's parent chain Sepolia network to Arbitrum's child chain Sepolia network: - -1. Use a parent chain Sepolia **ETH** faucet like [sepoliafaucet.com](https://sepoliafaucet.com/) to acquire some testnet **ETH** on parent chain Sepolia. -2. Bridge your parent chain Sepolia **ETH** into Arbitrum child chain using [the Arbitrum bridge](https://bridge.arbitrum.io/). - -Once you've acquired some **ETH**, you'll be able to deploy your smart contract to Arbitrum's Sepolia testnet. -You can proceed exactly as with the local testnet. - -1. Connect Remix to the Arbitrum Sepolia testnet -2. Compile your vending machine contract -3. Deploy your vending machine contract to the Arbitrum Sepolia testnet - -In this last step, your compiled smart contract will be deployed through the RPC endpoint corresponding to "Arbitrum Sepolia" in MetaMask (MetaMask uses [INFURA](https://www.infura.io)'s nodes as endpoints). - -Congratulations! You've just deployed **business logic and data** to Arbitrum Sepolia. This logic and data will be hashed and submitted within a transaction to Ethereum's parent chian Sepolia network, and then it will be mirrored across all nodes in the Sepolia network[^6]. - -To view your smart contract in a blockchain explorer, visit `https://sepolia.arbiscan.io/address/0x...B3`, but replace the `0x...B3` part of the URL with the full address of your deployed smart contract. - -Select **Arbitrum Sepolia** from Metamask's dropdown, paste your contract address into the `VendingMachine` below, and click **Get cupcake!**. You should be prompted to sign a transaction that gives you a cupcake. - - - -The final step is deploying our Cupcake machine to a production network, such as Ethereum, Arbitrum One, or Arbitrum Nitro. -The good news is: deploying a smart contract in production is exactly the same as for Sepolia Testnet. -The harder news: it will cost real money, this time. If you deploy on Ethereum, the fees can be significant and the transaction confirmation time 12 seconds on average. -Arbitrum, a child chain, reduces these costs about 10X and a confirmation time in the same order while maintaining a similar level of security and decentralization. - -## Summary - -In this quickstart, we: - -- Identified **two business rules**: 1) fair and permissionless cupcake distribution 2) immutable business logic and data. -- Identified a **challenge**: These rules are difficult to follow in a centralized application. -- Identified a **solution**: Using Arbitrum, we can decentralize business logic and data. -- Converted a vending machine's Javascript business logic into a **Solidity smart contract**. -- **Deployed our smart contract** to a local development network, and then Arbitrum's Sepolia testnet. - -If you have any questions or feedback, reach out to us on [Discord](https://discord.gg/ZpZuw7p) and/or click the **Request an update** button at the top of this page—we're listening! - -## Learning resources - -| Resource | Description | -| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| [Official Solidity documentation](https://soliditylang.org) | Official documentation for Solidity programming language | -| [Solidity by example](https://solidity-by-example.org) | Learn Solidity patterns via a series of classic examples | -| [Upgrading Ethereum (e-book)](https://eth2book.info/altair) | Guide on upgrading Ethereum | -| [Ethernaut](https://ethernaut.openzeppelin.com) | Interactive smart contract hacking game | -| [RareSkills](https://www.rareskills.io/rust-bootcamp) | Rust programming course for blockchain development | -| [CryptoZombies](https://cryptozombies.io/en/course/) | Free online smart contract courses and tutorials | -| [LearnWeb3](https://learnweb3.io) | Web3 education platform with interactive lessons and projects | -| [HackQuest](https://www.hackquest.io/en) | Web3 hackathon and project-based learning platform | -| [Rise In](https://www.risein.com/bootcamps/solidity-bootcamp) | Solidity bootcamp for beginners | -| [Encode Club](https://www.encode.club) | Community-driven coding club with a focus on Web3 development | -| [Metana](https://metana.io) | Metana is not mentioned in the resources, please provide more information about this resource. | -| [Alchemy University](https://www.alchemy.com/university) | Online education platform for blockchain and Web3 development courses | - -[^1]: The vending machine example was inspired by [Ethereum.org's "Introduction to Smart Contracts"](https://ethereum.org/en/developers/docs/smart-contracts/), which was inspired by [Nick Szabo's "From vending machines to smart contracts"](http://unenumerated.blogspot.com/2006/12/from-vending-machines-to-smart.html). -[^2]: Although application front-ends are usually hosted by centralized services, smart contracts allow the underlying logic and data to be partially or fully decentralized. These smart contracts are hosted and executed by Ethereum's public, decentralized network of nodes. Arbitrum has its own network of nodes that use advanced cryptography techniques to "batch process" Ethereum transactions and then submit them to the Ethereum parent chain, which significantly reduces the cost of using Ethereum. All without requiring developers to compromise on security or decentralization. -[^3]: There are multiple types of Ethereum nodes. The ones that earn **ETH** for processing and validating transactions are called _validators_. See [Nodes and Networks](https://docs.prylabs.network/docs/concepts/nodes-networks) for a beginner-friendly introduction to Ethereum's node types. -[^4]: When our `VendingMachine` contract is deployed to Ethereum, it'll be hosted by Ethereum's decentralized network of nodes. Generally speaking, we won't be able to modify the contract's code after it's deployed. -[^5]: To learn more about how Ethereum wallets work, see [Ethereum.org's introduction to Ethereum wallets](https://ethereum.org/en/wallets/). -[^6]: Visit the [Gentle Introduction to Arbitrum](../get-started/arbitrum-introduction.mdx) for a beginner-friendly introduction to Arbitrum's Rollup protocol. + diff --git a/docs/build-decentralized-apps/reference/06-monitoring-tools-block-explorers.mdx b/docs/build-decentralized-apps/reference/06-monitoring-tools-block-explorers.mdx index 55c6d39255..4b52b4cbfb 100644 --- a/docs/build-decentralized-apps/reference/06-monitoring-tools-block-explorers.mdx +++ b/docs/build-decentralized-apps/reference/06-monitoring-tools-block-explorers.mdx @@ -33,7 +33,7 @@ Build on Arbitrum or dive deeper into the network. - [Contract addresses](/build-decentralized-apps/reference/contract-addresses) — key Arbitrum contract addresses - [Chain info and RPC endpoints](/for-devs/dev-tools-and-resources/chain-info) — chain IDs, RPC URLs, and network parameters - [How to estimate gas](/build-decentralized-apps/how-to-estimate-gas) — understand gas costs on Arbitrum -- [Quickstart: Build a dApp](/build-decentralized-apps/quickstart-solidity-remix) — deploy your first contract on Arbitrum +- [Quickstart: Build a dApp](/build-decentralized-apps/quickstart-solidity) — deploy your first contract on Arbitrum - [Run a full node](/run-arbitrum-node/run-full-node) — run your own Arbitrum node import KnowMoreToolsBox from '../../for-devs/partials/_know-more-tools-box-partial.mdx'; diff --git a/docs/build-decentralized-apps/token-bridging/configure-token-bridging/setup-custom-gateway.mdx b/docs/build-decentralized-apps/token-bridging/configure-token-bridging/setup-custom-gateway.mdx index 2128284fe9..dfeed12789 100644 --- a/docs/build-decentralized-apps/token-bridging/configure-token-bridging/setup-custom-gateway.mdx +++ b/docs/build-decentralized-apps/token-bridging/configure-token-bridging/setup-custom-gateway.mdx @@ -15,7 +15,7 @@ Before implementing and deploying a custom gateway, it is strongly encouraged to In this how-to, you'll learn how to bridge your own token between Ethereum (the parent chain) and Arbitrum (the child chain), using a custom gateway. For alternative ways of bridging tokens, check out the [token bridging overview](/build-decentralized-apps/token-bridging/get-started.mdx). -Familiarity with [Arbitrum's token bridge system](/how-arbitrum-works/deep-dives/token-bridging.mdx), smart contracts, and decentralized application development is expected. If you're new to developing on Arbitrum, consider reviewing our [Quickstart: Build a dApp with Arbitrum (Solidity, Remix)](/build-decentralized-apps/01-quickstart-solidity-remix.mdx) before proceeding. We'll use [Arbitrum's SDK](https://github.com/OffchainLabs/arbitrum-sdk) throughout this how-to, although no prior knowledge is required. +Familiarity with [Arbitrum's token bridge system](/how-arbitrum-works/deep-dives/token-bridging.mdx), smart contracts, and decentralized application development is expected. If you're new to developing on Arbitrum, consider reviewing our [Quickstart: Build a dApp with Arbitrum (Solidity)](/build-decentralized-apps/quickstart-solidity) before proceeding. We'll use [Arbitrum's SDK](https://github.com/OffchainLabs/arbitrum-sdk) throughout this how-to, although no prior knowledge is required. We will go through all the steps involved in the process. However, if you want to jump straight to the code, we have created a [custom gateway bridging tutorial script](https://github.com/OffchainLabs/arbitrum-tutorials/tree/master/packages/custom-gateway-bridging) that encapsulates the entire process. diff --git a/docs/build-decentralized-apps/token-bridging/configure-token-bridging/setup-generic-custom-gateway.mdx b/docs/build-decentralized-apps/token-bridging/configure-token-bridging/setup-generic-custom-gateway.mdx index adf10afdfd..a2619a82ae 100644 --- a/docs/build-decentralized-apps/token-bridging/configure-token-bridging/setup-generic-custom-gateway.mdx +++ b/docs/build-decentralized-apps/token-bridging/configure-token-bridging/setup-generic-custom-gateway.mdx @@ -8,7 +8,7 @@ displayed_sidebar: buildAppsSidebar In this how-to, you'll learn how to bridge your own token between Ethereum (parent chain) and Arbitrum (child chain), using [Arbitrum's generic-custom gateway](/how-arbitrum-works/deep-dives/token-bridging.mdx#the-arbitrum-generic-custom-gateway). For alternative ways of bridging tokens, check out the [token bridging overview](/how-arbitrum-works/deep-dives/token-bridging.mdx). -Familiarity with [Arbitrum's token bridge system](/how-arbitrum-works/deep-dives/token-bridging.mdx), smart contracts, and blockchain development is expected. If you're new to blockchain development, consider reviewing our [Quickstart: Build a dApp with Arbitrum (Solidity, Hardhat)](/build-decentralized-apps/01-quickstart-solidity-remix.mdx) before proceeding. We'll use [Arbitrum's SDK](https://github.com/OffchainLabs/arbitrum-sdk) throughout this how-to, although no prior knowledge is required. +Familiarity with [Arbitrum's token bridge system](/how-arbitrum-works/deep-dives/token-bridging.mdx), smart contracts, and blockchain development is expected. If you're new to blockchain development, consider reviewing our [Quickstart: Build a dApp with Arbitrum (Solidity, Hardhat)](/build-decentralized-apps/quickstart-solidity) before proceeding. We'll use [Arbitrum's SDK](https://github.com/OffchainLabs/arbitrum-sdk) throughout this how-to, although no prior knowledge is required. We'll go through all the steps involved in the process. However, if you want to jump straight to the code, we've created a [custom token bridging tutorial script](https://github.com/OffchainLabs/arbitrum-tutorials/tree/master/packages/custom-token-bridging) that encapsulates the entire process. diff --git a/docs/for-devs/oracles/api3/api3.mdx b/docs/for-devs/oracles/api3/api3.mdx index 9164ddfdfb..9f1d1d69d0 100644 --- a/docs/for-devs/oracles/api3/api3.mdx +++ b/docs/for-devs/oracles/api3/api3.mdx @@ -59,7 +59,7 @@ contract ARBPriceConsumer { } ``` -You can adapt this contract to your needs. Just remember to use the address of the asset you want to request the price for in the appropriate network and to **deploy your contract to the same network**. Remember we have a [Quickstart](/build-decentralized-apps/01-quickstart-solidity-remix.mdx) available that goes through the process of compiling and deploying a contract. +You can adapt this contract to your needs. Just remember to use the address of the asset you want to request the price for in the appropriate network and to **deploy your contract to the same network**. Remember we have a [Quickstart](/build-decentralized-apps/quickstart-solidity) available that goes through the process of compiling and deploying a contract. ## More examples diff --git a/docs/for-devs/oracles/chainlink/chainlink.mdx b/docs/for-devs/oracles/chainlink/chainlink.mdx index adfe9ea88c..85797d52a0 100644 --- a/docs/for-devs/oracles/chainlink/chainlink.mdx +++ b/docs/for-devs/oracles/chainlink/chainlink.mdx @@ -59,7 +59,7 @@ contract ARBPriceConsumer { } ``` -You can adapt this contract to your needs. Just remember to use the address of the asset you want to request the price for in the appropriate network, and to **deploy your contract to the same network**. Remember we have a [Quickstart](/build-decentralized-apps/01-quickstart-solidity-remix.mdx) available that goes through the process of compiling and deploying a contract. +You can adapt this contract to your needs. Just remember to use the address of the asset you want to request the price for in the appropriate network, and to **deploy your contract to the same network**. Remember we have a [Quickstart](/build-decentralized-apps/quickstart-solidity) available that goes through the process of compiling and deploying a contract. ## More examples diff --git a/docs/for-devs/oracles/trellor/trellor.mdx b/docs/for-devs/oracles/trellor/trellor.mdx index f509083e90..157c902f3f 100644 --- a/docs/for-devs/oracles/trellor/trellor.mdx +++ b/docs/for-devs/oracles/trellor/trellor.mdx @@ -47,7 +47,7 @@ contract ARBPriceConsumer is UsingTellor { } ``` -You can adapt this contract to your needs. Just remember to use the ticker of the assets you want to request the price for and to **deploy your contract to the appropriate network, with the address of the Oracle contract in that network**. Remember, we have a [Quickstart](/build-decentralized-apps/01-quickstart-solidity-remix.mdx) available that goes through the process of compiling and deploying a contract. +You can adapt this contract to your needs. Just remember to use the ticker of the assets you want to request the price for and to **deploy your contract to the appropriate network, with the address of the Oracle contract in that network**. Remember, we have a [Quickstart](/build-decentralized-apps/quickstart-solidity) available that goes through the process of compiling and deploying a contract. ## See also diff --git a/docs/get-started/overview.mdx b/docs/get-started/overview.mdx index 02e6093d0b..890a907026 100644 --- a/docs/get-started/overview.mdx +++ b/docs/get-started/overview.mdx @@ -43,10 +43,26 @@ Deploy smart contracts to Arbitrum One, Arbitrum Nova, or any Arbitrum chain. gap: '20px', }} > - - - - + + + +
## Launch your own chain diff --git a/docusaurus.config.js b/docusaurus.config.js index 00addd837c..4f5484d4b0 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -199,6 +199,7 @@ const config = { ], require.resolve('docusaurus-plugin-fathom'), require.resolve('docusaurus-plugin-sass'), + require.resolve('./src/plugins/interactive-tutorials'), [ '@signalwire/docusaurus-plugin-llms-txt', { @@ -256,7 +257,7 @@ const config = { items: [ { label: 'Build with Solidity', - to: '/build-decentralized-apps/quickstart-solidity-remix', + to: '/build-decentralized-apps/quickstart-solidity', }, { label: 'Build with Stylus', diff --git a/package.json b/package.json index 516f3b6675..38d0fbd295 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "prismjs": "^1.29.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-joyride": "^3.0.2", "react-syntax-highlighter": "^15.6.1", "rehype-katex": "7", "remark-math": "6", diff --git a/src/components/InteractiveTutorials/CodeWalkthrough.tsx b/src/components/InteractiveTutorials/CodeWalkthrough.tsx new file mode 100644 index 0000000000..29157c83e1 --- /dev/null +++ b/src/components/InteractiveTutorials/CodeWalkthrough.tsx @@ -0,0 +1,151 @@ +import React, { useMemo, useState } from 'react'; +import clsx from 'clsx'; +import { usePrismTheme } from '@docusaurus/theme-common'; +import Highlight, { defaultProps, Language } from 'prism-react-renderer'; +import { parseCodeTitle, parseCodeWalkthrough } from './codeWalkthroughParser'; +import styles from './styles.module.css'; + +type Props = { + children: string; + className?: string; + language?: string; + metastring?: string; + title?: string; +}; + +function normalizeLanguage(language?: string, className?: string): string { + const fromClassName = className?.match(/language-([^\s]+)/)?.[1]; + return (language || fromClassName || 'text').toLowerCase(); +} + +function resolveHighlightLanguage(language: string): string { + if (defaultProps.Prism.languages[language]) return language; + if (language === 'solidity' || language === 'rust') return 'clike'; + if (language === 'shell' || language === 'sh') return 'bash'; + return 'text'; +} + +function isActiveLine(lineNumber: number, startLine: number, endLine: number): boolean { + return lineNumber >= startLine && lineNumber <= endLine; +} + +export function CodeWalkthrough({ + children, + className, + language: languageProp, + metastring, + title, +}: Props) { + const parsed = useMemo(() => parseCodeWalkthrough(children), [children]); + const language = normalizeLanguage(languageProp, className); + const highlightLanguage = resolveHighlightLanguage(language); + const prismTheme = usePrismTheme(); + const [activeIndex, setActiveIndex] = useState(0); + const activeStep = parsed.steps[activeIndex]; + const resolvedTitle = parseCodeTitle(metastring, title); + + const copyCode = async () => { + if (typeof navigator === 'undefined' || !navigator.clipboard) return; + await navigator.clipboard.writeText(parsed.code); + }; + + if (parsed.steps.length === 0) { + return ( +
+        {children}
+      
+ ); + } + + return ( +
+
+
+ {resolvedTitle || language} + +
+ + {({ className: highlightClassName, style, tokens, getLineProps, getTokenProps }) => ( +
+              
+                {tokens.map((line, index) => {
+                  const lineNumber = index + 1;
+                  const lineProps = getLineProps({ line });
+                  const { key: lineKey, ...linePropsWithoutKey } = lineProps;
+                  return (
+                    
+                      {lineNumber}
+                      
+                        {line.map((token, key) => {
+                          const tokenProps = getTokenProps({ token });
+                          const { key: tokenKey, ...tokenPropsWithoutKey } = tokenProps;
+
+                          return ;
+                        })}
+                      
+                    
+                  );
+                })}
+              
+            
+ )} +
+
+
+ {parsed.steps.map((step, index) => ( + + ))} +
+ + +
+
+
+ ); +} diff --git a/src/components/InteractiveTutorials/InteractiveTutorialShell.tsx b/src/components/InteractiveTutorials/InteractiveTutorialShell.tsx new file mode 100644 index 0000000000..a1ddecc6c1 --- /dev/null +++ b/src/components/InteractiveTutorials/InteractiveTutorialShell.tsx @@ -0,0 +1,23 @@ +import React, { ReactNode, useEffect } from 'react'; +import styles from './styles.module.css'; + +type Props = { + children: ReactNode; + estimatedTime?: string; + tutorialKind?: string; +}; + +export function InteractiveTutorialShell({ children }: Props) { + useEffect(() => { + document.body.classList.add('interactive-tutorial-page'); + return () => document.body.classList.remove('interactive-tutorial-page'); + }, []); + + return ( +
+
+ {children} +
+
+ ); +} diff --git a/src/components/InteractiveTutorials/SolidityLab.tsx b/src/components/InteractiveTutorials/SolidityLab.tsx new file mode 100644 index 0000000000..aedfccb285 --- /dev/null +++ b/src/components/InteractiveTutorials/SolidityLab.tsx @@ -0,0 +1,1852 @@ +import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import clsx from 'clsx'; +import { ethers } from 'ethers'; +import { usePrismTheme } from '@docusaurus/theme-common'; +import Highlight, { defaultProps, Language } from 'prism-react-renderer'; +import { + Joyride, + EVENTS, + ACTIONS, + STATUS, + Step, + EventData, + TooltipRenderProps, +} from 'react-joyride'; +import { browserCall, browserDeploy, browserSend } from './browserChain'; +import styles from './styles.module.css'; + +const LOCAL_DEVNET_MNEMONIC = 'test test test test test test test test test test test junk'; + +type DevAccount = { + address: string; + privateKey: string; +}; + +function deriveLocalDevnetAccounts(count: number): DevAccount[] { + const wallets: DevAccount[] = []; + for (let i = 0; i < count; i++) { + const wallet = ethers.Wallet.fromMnemonic(LOCAL_DEVNET_MNEMONIC, `m/44'/60'/0'/0/${i}`); + wallets.push({ address: wallet.address, privateKey: wallet.privateKey }); + } + return wallets; +} + +function pickFunctionName( + abi: ethers.ContractInterface, + matcher: (fragment: ethers.utils.FunctionFragment) => boolean, +) { + const fragments = (Array.isArray(abi) ? abi : []) as ethers.utils.Fragment[]; + const fn = fragments.find( + (f) => f.type === 'function' && matcher(f as ethers.utils.FunctionFragment), + ) as ethers.utils.FunctionFragment | undefined; + return fn?.name; +} + +type LabAction = 'focus' | 'compile' | 'connect' | 'deploy' | 'write' | 'read'; +type SpotlightTarget = + | 'lesson' + | 'demo' + | 'editor' + | 'lines' + | 'network' + | 'account' + | 'compile' + | 'connect' + | 'deploy' + | 'write' + | 'read' + | 'runtime' + | 'status' + | 'next'; +type LineRange = [number, number]; + +export type SolidityLabTask = { + label: string; + lines?: LineRange; + note?: string; + bullets?: string[]; + action?: LabAction; + spotlight?: SpotlightTarget; + placement?: Step['placement']; +}; + +type CompilationIssue = { + severity?: string; + message: string; + line?: number; +}; + +type CompilationResult = { + abi: ethers.ContractInterface; + bytecode: string; + contractName: string; +}; + +type ConsoleKind = 'info' | 'success' | 'error'; + +type LabConsoleEntry = { + id: number; + kind: ConsoleKind; + source: string; + message: string; +}; + +type ExplorerTransaction = { + id: number; + hash: string; + method: string; + from: string; + to?: string; + status: 'Success' | 'Reverted'; + network: LabMode; + link?: string; +}; + +export type SolidityLabProps = { + title?: string; + description?: string; + source: string; + fileName?: string; + contractName: string; + height?: number; + tasks?: SolidityLabTask[]; + sidebarIntro?: ReactNode; + useSpotlight?: boolean; +}; + +const ARBITRUM_SEPOLIA = { + chainId: 421614, + chainIdHex: '0x66eee', + chainName: 'Arbitrum Sepolia', + rpcUrls: ['https://sepolia-rollup.arbitrum.io/rpc'], + blockExplorerUrls: ['https://sepolia.arbiscan.io/'], + nativeCurrency: { + name: 'Arbitrum Sepolia Ether', + symbol: 'ETH', + decimals: 18, + }, +}; + +const LOCAL_DEVNET_ACCOUNT_COUNT = 5; +const COMPILE_INSTRUCTION_MS = 4200; +const DEPLOY_INSTRUCTION_MS = 4800; + +const SOLC_BINARIES_URL = 'https://binaries.soliditylang.org/bin'; +const SOLC_VERSION = '0.8.34'; +const COMPILER_WORKER_SOURCE = ` +let compilerPromise; + +async function loadCompiler(version, binariesUrl) { + if (!compilerPromise) { + compilerPromise = (async () => { + const listResponse = await fetch(binariesUrl + '/list.json'); + const list = await listResponse.json(); + const latestPath = list.latestRelease ? list.releases[list.latestRelease] : undefined; + const compilerPath = list.releases[version] || latestPath; + if (!compilerPath) throw new Error('Unable to resolve a Solidity compiler build.'); + const compilerResponse = await fetch(binariesUrl + '/' + compilerPath); + const compilerScript = await compilerResponse.text(); + const load = new Function(compilerScript + '; return Module;'); + return load(); + })(); + } + return compilerPromise; +} + +self.onmessage = async (event) => { + const { id, input, version, binariesUrl } = event.data; + try { + const soljson = await loadCompiler(version, binariesUrl); + const compile = soljson.cwrap('solidity_compile', 'string', ['string', 'number', 'number']); + self.postMessage({ id, output: compile(input, 0, 0) }); + } catch (error) { + self.postMessage({ id, error: error instanceof Error ? error.message : String(error) }); + } +}; +`; + +function shortAddress(address?: string) { + if (!address) return '—'; + if (address.length <= 12) return address; + return `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getIssueLine(source: string, sourceLocation?: { start?: number }) { + if (!sourceLocation || typeof sourceLocation.start !== 'number' || sourceLocation.start < 0) { + return undefined; + } + return source.slice(0, sourceLocation.start).split('\n').length; +} + +function isLineActive(lineNumber: number, range?: LineRange) { + return Boolean(range && lineNumber >= range[0] && lineNumber <= range[1]); +} + +function taskSpotlightTarget(task: SolidityLabTask): SpotlightTarget { + if (task.spotlight) return task.spotlight; + if (task.action && task.action !== 'focus') return task.action; + if (task.lines) return 'lines'; + return 'editor'; +} + +function TaskLessonContent({ task }: { task: SolidityLabTask }) { + return ( + <> + {task.note &&

{task.note}

} + {task.bullets && task.bullets.length > 0 && ( + + )} + + ); +} + +function spotlightSelector(target: SpotlightTarget) { + switch (target) { + case 'lesson': + return '[data-lab-spotlight="lesson"]'; + case 'demo': + return '[data-lab-spotlight="demo"]'; + case 'lines': + return '[data-lab-active-line="true"]'; + case 'network': + return '[data-lab-spotlight="network"]'; + case 'account': + return '[data-lab-spotlight="account"]'; + case 'compile': + case 'connect': + case 'deploy': + case 'write': + case 'read': + return `[data-lab-action="${target}"]`; + case 'runtime': + return '[data-lab-spotlight="runtime"]'; + case 'status': + return '[data-lab-spotlight="status"]'; + case 'next': + return '[data-lab-spotlight="next"]'; + case 'editor': + default: + return '[data-lab-spotlight="editor"]'; + } +} + +function SpotlightOnlyTooltip(_props: TooltipRenderProps) { + return null; +} + +async function compileSource(source: string, fileName: string, preferredContractName: string) { + const input = { + language: 'Solidity', + sources: { [fileName]: { content: source } }, + settings: { + outputSelection: { '*': { '*': ['abi', 'evm.bytecode.object'] } }, + }, + }; + const output = JSON.parse(await compileInWorker(JSON.stringify(input))); + const issues: CompilationIssue[] = (output.errors || []).map( + (error: { + severity?: string; + formattedMessage?: string; + message?: string; + sourceLocation?: { start?: number }; + }) => ({ + severity: error.severity, + message: error.formattedMessage || error.message || 'Compiler message', + line: getIssueLine(source, error.sourceLocation), + }), + ); + const hasError = issues.some((issue) => issue.severity === 'error'); + const contracts = output.contracts?.[fileName] || {}; + const contractName = contracts[preferredContractName] + ? preferredContractName + : Object.keys(contracts)[0]; + const contract = contractName ? contracts[contractName] : undefined; + + if (hasError || !contract?.evm?.bytecode?.object) { + return { issues, result: undefined }; + } + + return { + issues, + result: { + abi: contract.abi, + bytecode: `0x${contract.evm.bytecode.object}`, + contractName, + } satisfies CompilationResult, + }; +} + +async function compileInWorker(input: string) { + return new Promise((resolve, reject) => { + const workerUrl = URL.createObjectURL( + new Blob([COMPILER_WORKER_SOURCE], { type: 'text/javascript' }), + ); + const worker = new Worker(workerUrl); + const id = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + + worker.onmessage = (event: MessageEvent<{ id: string; output?: string; error?: string }>) => { + if (event.data.id !== id) return; + worker.terminate(); + URL.revokeObjectURL(workerUrl); + if (event.data.error) { + reject(new Error(event.data.error)); + return; + } + resolve(event.data.output || '{}'); + }; + worker.onerror = (event) => { + worker.terminate(); + URL.revokeObjectURL(workerUrl); + reject(new Error(event.message)); + }; + worker.postMessage({ + id, + input, + version: SOLC_VERSION, + binariesUrl: SOLC_BINARIES_URL, + }); + }); +} + +const INDENT = ' '; +const PAIR_OPEN: Record = { '{': '}', '(': ')', '[': ']', '"': '"' }; + +function handleEditorKey( + event: React.KeyboardEvent, + setCode: (next: string) => void, +) { + const target = event.currentTarget; + const { selectionStart, selectionEnd, value } = target; + + const isToggleComment = (event.metaKey || event.ctrlKey) && !event.shiftKey && event.key === '/'; + if (isToggleComment) { + event.preventDefault(); + const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1; + const lineEndRaw = value.indexOf('\n', selectionEnd); + const lineEnd = lineEndRaw === -1 ? value.length : lineEndRaw; + const block = value.slice(lineStart, lineEnd); + const lines = block.split('\n'); + const allCommented = lines.every((line) => /^\s*\/\//.test(line) || line.trim() === ''); + const transformed = lines + .map((line) => { + if (line.trim() === '') return line; + if (allCommented) return line.replace(/^(\s*)\/\/ ?/, '$1'); + const indentMatch = line.match(/^\s*/)?.[0] ?? ''; + return `${indentMatch}// ${line.slice(indentMatch.length)}`; + }) + .join('\n'); + const delta = transformed.length - block.length; + setCode(`${value.slice(0, lineStart)}${transformed}${value.slice(lineEnd)}`); + requestAnimationFrame(() => { + target.selectionStart = selectionStart + (allCommented ? -3 : 3); + target.selectionEnd = selectionEnd + delta; + }); + return; + } + + if (event.key in PAIR_OPEN && selectionStart === selectionEnd) { + const close = PAIR_OPEN[event.key]; + if (event.key === '"' && value[selectionStart] === '"') return; + event.preventDefault(); + const next = `${value.slice(0, selectionStart)}${event.key}${close}${value.slice( + selectionEnd, + )}`; + setCode(next); + requestAnimationFrame(() => { + target.selectionStart = target.selectionEnd = selectionStart + 1; + }); + return; + } + + if (event.key === 'Tab') { + event.preventDefault(); + const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1; + const before = value.slice(0, lineStart); + const selected = value.slice(lineStart, selectionEnd); + const after = value.slice(selectionEnd); + if (event.shiftKey) { + const dedented = selected.replace(new RegExp(`^${INDENT}`, 'gm'), ''); + const next = `${before}${dedented}${after}`; + const removed = selected.length - dedented.length; + setCode(next); + requestAnimationFrame(() => { + target.selectionStart = Math.max(lineStart, selectionStart - INDENT.length); + target.selectionEnd = Math.max(lineStart, selectionEnd - removed); + }); + return; + } + if (selectionStart === selectionEnd) { + const next = `${value.slice(0, selectionStart)}${INDENT}${value.slice(selectionEnd)}`; + setCode(next); + requestAnimationFrame(() => { + target.selectionStart = target.selectionEnd = selectionStart + INDENT.length; + }); + return; + } + const indented = selected.replace(/^/gm, INDENT); + const added = indented.length - selected.length; + setCode(`${before}${indented}${after}`); + requestAnimationFrame(() => { + target.selectionStart = selectionStart + INDENT.length; + target.selectionEnd = selectionEnd + added; + }); + return; + } + + if (event.key === 'Enter') { + const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1; + const lineSoFar = value.slice(lineStart, selectionStart); + const indentMatch = lineSoFar.match(/^[ \t]*/)?.[0] ?? ''; + const trimmed = lineSoFar.trimEnd(); + const extra = trimmed.endsWith('{') || trimmed.endsWith('(') ? INDENT : ''; + const insert = `\n${indentMatch}${extra}`; + if (!indentMatch && !extra) return; + event.preventDefault(); + const next = `${value.slice(0, selectionStart)}${insert}${value.slice(selectionEnd)}`; + setCode(next); + const cursor = selectionStart + insert.length; + requestAnimationFrame(() => { + target.selectionStart = target.selectionEnd = cursor; + }); + } +} + +function resolveSolidityLanguage(): Language { + const prismLanguages = (defaultProps.Prism as unknown as { languages: Record }) + .languages; + if (prismLanguages.solidity) return 'solidity' as Language; + return 'clike' as Language; +} + +type LabMode = 'browser' | 'sepolia'; +type StatusKind = 'idle' | 'busy' | 'ok' | 'err'; +type ActionIconName = 'compile' | 'connect' | 'deploy' | 'write' | 'read'; +type ProcessVisual = 'compile' | 'deploy'; + +function ActionIcon({ name }: { name: ActionIconName }) { + const paths: Record = { + compile: ( + <> + + + + + ), + connect: ( + <> + + + + + + ), + deploy: ( + <> + + + + + ), + write: ( + <> + + + + + + ), + read: ( + <> + + + + ), + }; + + return ( + + ); +} + +export function SolidityLab({ + title = 'Solidity lab', + description, + source, + fileName = 'VendingMachine.sol', + contractName, + height = 520, + tasks = [], + sidebarIntro, + useSpotlight = false, +}: SolidityLabProps) { + const storageKey = `solidity-lab:${fileName}:${contractName}`; + const [code, setCode] = useState(() => { + if (typeof window === 'undefined') return source.trim(); + return window.localStorage.getItem(storageKey) ?? source.trim(); + }); + const [activeTaskIndex, setActiveTaskIndex] = useState(0); + const [tutorialComplete, setTutorialComplete] = useState(false); + const [issues, setIssues] = useState([]); + const [compiled, setCompiled] = useState(); + const [status, setStatus] = useState('Ready'); + const [statusKind, setStatusKind] = useState('idle'); + const [manualRange, setManualRange] = useState(); + const [account, setAccount] = useState(); + const [contractAddress, setContractAddress] = useState(); + const [lastTxHash, setLastTxHash] = useState(); + const [lastRead, setLastRead] = useState(); + const [mode, setMode] = useState('browser'); + const browserAccountIndex = 0; + const [completedActions, setCompletedActions] = useState([]); + const [runningAction, setRunningAction] = useState(); + const [cursor, setCursor] = useState({ line: 1, col: 1 }); + const [spotlightMounted, setSpotlightMounted] = useState(false); + const [spotlightRunning, setSpotlightRunning] = useState(useSpotlight); + const [processVisual, setProcessVisual] = useState(); + const [consoleEntries, setConsoleEntries] = useState([ + { + id: 1, + kind: 'info', + source: 'lab', + message: `Loaded ${fileName}; compiler target solc ${SOLC_VERSION}.`, + }, + ]); + const [explorerTransactions, setExplorerTransactions] = useState([]); + const [selectedExplorerTxId, setSelectedExplorerTxId] = useState(); + const runningActionRef = useRef(); + const codeLayerRef = useRef(null); + const editorRef = useRef(null); + const prismTheme = usePrismTheme(); + const highlightLanguage = useMemo(resolveSolidityLanguage, []); + const devAccounts = useMemo(() => deriveLocalDevnetAccounts(LOCAL_DEVNET_ACCOUNT_COUNT), []); + + const activeTask = tasks[activeTaskIndex]; + const activeSpotlightTarget = activeTask ? taskSpotlightTarget(activeTask) : undefined; + const activeRange = + manualRange || (activeSpotlightTarget === 'lines' ? activeTask?.lines : undefined); + const activeAction = + activeTask?.action && activeTask.action !== 'focus' ? activeTask.action : undefined; + const errorCount = issues.filter((issue) => issue.severity === 'error').length; + const warningCount = issues.filter((issue) => issue.severity === 'warning').length; + const totalLines = useMemo(() => code.split('\n').length, [code]); + const browserAccount = devAccounts[browserAccountIndex]; + const writeMethodName = useMemo( + () => + compiled + ? pickFunctionName( + compiled.abi, + (f) => f.stateMutability === 'nonpayable' || f.stateMutability === 'payable', + ) + : undefined, + [compiled], + ); + const readMethodName = useMemo( + () => + compiled + ? pickFunctionName( + compiled.abi, + (f) => f.stateMutability === 'view' || f.stateMutability === 'pure', + ) + : undefined, + [compiled], + ); + const writeCallLabel = `${writeMethodName ?? 'giveCupcakeTo'}(address)`; + const readCallLabel = `${readMethodName ?? 'getCupcakeBalanceFor'}(address)`; + const selectedExplorerTransaction = explorerTransactions.find( + (tx) => tx.id === selectedExplorerTxId, + ); + const isNetworkExplorerRelevant = + activeAction === 'deploy' || + activeAction === 'connect' || + activeSpotlightTarget === 'network' || + Boolean(contractAddress); + const isWalletExplorerRelevant = + activeAction === 'deploy' || + activeAction === 'write' || + activeAction === 'read' || + activeSpotlightTarget === 'runtime' || + Boolean(contractAddress); + const isTransactionsExplorerRelevant = + activeAction === 'deploy' || + activeAction === 'write' || + activeSpotlightTarget === 'runtime' || + explorerTransactions.length > 0; + const isContractExplorerRelevant = + activeAction === 'compile' || + activeAction === 'deploy' || + activeAction === 'write' || + activeAction === 'read' || + activeSpotlightTarget === 'runtime' || + Boolean(compiled); + const joyrideSteps = useMemo( + () => + tasks.map((task) => { + const target = taskSpotlightTarget(task); + + return { + target: spotlightSelector(target), + title: task.label, + content: , + placement: + task.placement || + (target === 'compile' || + target === 'connect' || + target === 'deploy' || + target === 'write' || + target === 'read' || + target === 'network' || + target === 'runtime' + ? 'left' + : 'bottom'), + skipBeacon: true, + skipScroll: true, + overlayClickAction: false, + dismissKeyAction: false, + blockTargetInteraction: true, + spotlightPadding: target === 'lines' ? 3 : 6, + data: { target }, + } satisfies Step; + }), + [tasks], + ); + + useEffect(() => { + setSpotlightMounted(true); + }, []); + + useEffect(() => { + if (mode === 'browser') { + setAccount(browserAccount?.address); + setCompletedActions((current) => + current.includes('connect') ? current : [...current, 'connect'], + ); + } else { + setAccount(undefined); + setCompletedActions((current) => current.filter((action) => action !== 'connect')); + } + }, [mode, browserAccount]); + + const update = (next: string, kind: StatusKind = 'idle') => { + setStatus(next); + setStatusKind(kind); + }; + + const pushConsole = (sourceName: string, message: string, kind: ConsoleKind = 'info') => { + setConsoleEntries((current) => [ + ...current.slice(-79), + { + id: current.length > 0 ? current[current.length - 1].id + 1 : 1, + kind, + source: sourceName, + message, + }, + ]); + }; + + const addExplorerTransaction = (entry: Omit) => { + setExplorerTransactions((current) => { + const id = current.length > 0 ? current[0].id + 1 : 1; + return [ + { + ...entry, + id, + network: mode, + link: + mode === 'sepolia' + ? `${ARBITRUM_SEPOLIA.blockExplorerUrls[0]}tx/${entry.hash}` + : undefined, + }, + ...current, + ].slice(0, 5); + }); + }; + + const markActionDone = (action: LabAction) => { + setCompletedActions((current) => (current.includes(action) ? current : [...current, action])); + }; + + const moveToNextTask = () => { + setManualRange(undefined); + setTutorialComplete(false); + setActiveTaskIndex((index) => Math.min(index + 1, Math.max(tasks.length - 1, 0))); + if (useSpotlight) setSpotlightRunning(true); + }; + + const moveToPreviousTask = () => { + setManualRange(undefined); + setTutorialComplete(false); + setActiveTaskIndex((index) => Math.max(index - 1, 0)); + if (useSpotlight) setSpotlightRunning(true); + }; + + const completeAction = (action: LabAction) => { + markActionDone(action); + if (activeAction === action && tasks.length > 0) { + moveToNextTask(); + } + }; + + const clearActions = (...actions: LabAction[]) => { + setCompletedActions((current) => current.filter((action) => !actions.includes(action))); + }; + + const isActionDone = (action: LabAction) => completedActions.includes(action); + + const isCurrentAction = (action: LabAction) => activeAction === action; + + const isActiveSpotlight = (target: SpotlightTarget) => + Boolean(useSpotlight && activeSpotlightTarget === target); + + const beginRunningAction = (action: LabAction) => { + if (runningActionRef.current) return false; + runningActionRef.current = action; + setRunningAction(action); + return true; + }; + + const endRunningAction = (action: LabAction) => { + if (runningActionRef.current !== action) return; + runningActionRef.current = undefined; + setRunningAction(undefined); + }; + + const canRunAction = (action: LabAction) => { + if (runningAction || runningActionRef.current) return false; + if (!isCurrentAction(action)) return false; + if (isActionDone(action)) return false; + if (action === 'compile') return true; + if (action === 'deploy') return Boolean(compiled); + if (action === 'write' || action === 'read') return Boolean(compiled && contractAddress); + return false; + }; + + const isTaskSatisfied = (task?: SolidityLabTask) => { + if (!task?.action || task.action === 'focus') return true; + if (task.action === 'connect' && mode === 'browser') return Boolean(account); + return isActionDone(task.action); + }; + + const canAdvanceTask = isTaskSatisfied(activeTask) && activeTaskIndex < tasks.length - 1; + + useEffect(() => { + if (typeof window === 'undefined') return; + if (code === source.trim()) { + window.localStorage.removeItem(storageKey); + return; + } + window.localStorage.setItem(storageKey, code); + }, [code, source, storageKey]); + + const runCompileRef = + useRef<(advanceOnSuccess?: boolean) => Promise>(); + const deployRef = useRef<() => Promise>(); + const runCompile = async (advanceOnSuccess = true) => { + if (!beginRunningAction('compile')) return undefined; + setProcessVisual('compile'); + const visualPromise = wait(COMPILE_INSTRUCTION_MS).then(() => { + setProcessVisual((current) => (current === 'compile' ? undefined : current)); + }); + update('Compiling…', 'busy'); + pushConsole('solc', `compile ${fileName} using ${SOLC_VERSION}`); + setContractAddress(undefined); + setLastTxHash(undefined); + setExplorerTransactions([]); + setSelectedExplorerTxId(undefined); + clearActions('compile', 'deploy', 'write', 'read'); + try { + const next = await compileSource(code, fileName, contractName); + await visualPromise; + setIssues(next.issues); + setCompiled(next.result); + if (next.result) { + pushConsole( + 'solc', + `ok ${next.result.contractName}: abi=${ + Array.isArray(next.result.abi) ? next.result.abi.length : 0 + } entries bytecode=${Math.max(0, (next.result.bytecode.length - 2) / 2)} bytes warnings=${ + next.issues.filter((issue) => issue.severity === 'warning').length + }`, + 'success', + ); + if (advanceOnSuccess) { + completeAction('compile'); + } else { + markActionDone('compile'); + } + update(`Compiled ${next.result.contractName}`, 'ok'); + } else { + pushConsole( + 'solc', + `failed: ${next.issues.filter((issue) => issue.severity === 'error').length} errors, ${ + next.issues.filter((issue) => issue.severity === 'warning').length + } warnings`, + 'error', + ); + update('Compile failed', 'err'); + } + return next.result; + } catch (error) { + await visualPromise; + pushConsole('solc', error instanceof Error ? error.message : 'Compile failed', 'error'); + update(error instanceof Error ? error.message : 'Compile failed', 'err'); + return undefined; + } finally { + endRunningAction('compile'); + } + }; + runCompileRef.current = runCompile; + + useEffect(() => { + const editor = editorRef.current; + if (!editor) return; + const onKey = (event: KeyboardEvent) => { + const isCompile = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b'; + const isDeploy = (event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'Enter'; + if (isCompile) { + event.preventDefault(); + runCompileRef.current?.(); + } else if (isDeploy) { + event.preventDefault(); + deployRef.current?.(); + } + }; + editor.addEventListener('keydown', onKey); + return () => editor.removeEventListener('keydown', onKey); + }, []); + + const connectWallet = async () => { + if (mode === 'browser') { + pushConsole('devnet', `selected account #${browserAccountIndex} ${browserAccount?.address}`); + update(`Using local devnet account #${browserAccountIndex}`, 'ok'); + completeAction('connect'); + return undefined; + } + const ethereum = (window as unknown as { ethereum?: ethers.providers.ExternalProvider }) + .ethereum; + if (!ethereum || typeof ethereum.request !== 'function') { + pushConsole('wallet', 'no injected EIP-1193 provider found', 'error'); + update('No injected wallet found — switch to browser mode', 'err'); + return undefined; + } + update('Connecting wallet…', 'busy'); + pushConsole( + 'wallet', + `request eth_requestAccounts; switch chain ${ARBITRUM_SEPOLIA.chainIdHex}`, + ); + try { + const accounts = (await ethereum.request({ method: 'eth_requestAccounts' })) as string[]; + await ensureArbitrumSepolia(ethereum); + setAccount(accounts[0]); + completeAction('connect'); + pushConsole('wallet', `connected ${accounts[0]}`, 'success'); + update('Wallet connected', 'ok'); + return new ethers.providers.Web3Provider(ethereum).getSigner(); + } catch (error) { + pushConsole('wallet', error instanceof Error ? error.message : 'Connect failed', 'error'); + update(error instanceof Error ? error.message : 'Connect failed', 'err'); + return undefined; + } + }; + + const ensureSepoliaSigner = async () => { + const ethereum = (window as unknown as { ethereum?: ethers.providers.ExternalProvider }) + .ethereum; + if (!ethereum || typeof ethereum.request !== 'function') return connectWallet(); + const provider = new ethers.providers.Web3Provider(ethereum); + const accounts = await provider.listAccounts(); + if (accounts.length === 0) return connectWallet(); + await ensureArbitrumSepolia(ethereum); + setAccount(accounts[0]); + return provider.getSigner(); + }; + + const deployContract = async () => { + if (!isCurrentAction('deploy') || isActionDone('deploy')) return; + if (!beginRunningAction('deploy')) return; + if (!compiled) { + endRunningAction('deploy'); + pushConsole('deploy', 'compile the contract before deploying', 'error'); + update('Compile first', 'err'); + return; + } + const compilation = compiled; + setProcessVisual('deploy'); + const visualPromise = wait(DEPLOY_INSTRUCTION_MS).then(() => { + setProcessVisual((current) => (current === 'deploy' ? undefined : current)); + }); + if (mode === 'browser') { + if (!browserAccount) { + await visualPromise; + setProcessVisual(undefined); + endRunningAction('deploy'); + return; + } + update('Deploying…', 'busy'); + pushConsole( + 'devnet', + `deploy ${compilation.contractName}: from=${browserAccount.address} bytecode=${Math.max( + 0, + (compilation.bytecode.length - 2) / 2, + )} bytes`, + ); + try { + const result = browserDeploy(compilation.abi, compilation.bytecode, browserAccount.address); + await visualPromise; + setLastTxHash(result.txHash); + setContractAddress(result.contractAddress); + addExplorerTransaction({ + hash: result.txHash, + method: 'Contract Creation', + from: browserAccount.address, + to: result.contractAddress, + status: 'Success', + }); + clearActions('write', 'read'); + completeAction('deploy'); + pushConsole( + 'devnet', + `deployed contract=${result.contractAddress} tx=${result.txHash}`, + 'success', + ); + update(`Local deploy ${shortAddress(result.contractAddress)}`, 'ok'); + } catch (error) { + await visualPromise; + pushConsole('devnet', error instanceof Error ? error.message : 'Deploy failed', 'error'); + update(error instanceof Error ? error.message : 'Deploy failed', 'err'); + } finally { + endRunningAction('deploy'); + } + return; + } + const signer = await ensureSepoliaSigner(); + if (!signer) { + await visualPromise; + setProcessVisual(undefined); + endRunningAction('deploy'); + return; + } + update('Deploying…', 'busy'); + try { + pushConsole('sepolia', `deploy ${compilation.contractName} via wallet signer`); + const factory = new ethers.ContractFactory(compilation.abi, compilation.bytecode, signer); + const contract = await factory.deploy(); + setLastTxHash(contract.deployTransaction.hash); + pushConsole('sepolia', `submitted deploy tx=${contract.deployTransaction.hash}`); + update('Waiting for deployment…', 'busy'); + await contract.deployed(); + await visualPromise; + setContractAddress(contract.address); + addExplorerTransaction({ + hash: contract.deployTransaction.hash, + method: 'Contract Creation', + from: await signer.getAddress(), + to: contract.address, + status: 'Success', + }); + clearActions('write', 'read'); + completeAction('deploy'); + pushConsole('sepolia', `deployed contract=${contract.address}`, 'success'); + update(`Deployed at ${shortAddress(contract.address)}`, 'ok'); + } catch (error) { + await visualPromise; + pushConsole('sepolia', error instanceof Error ? error.message : 'Deploy failed', 'error'); + update(error instanceof Error ? error.message : 'Deploy failed', 'err'); + } finally { + endRunningAction('deploy'); + } + }; + deployRef.current = deployContract; + + const writeCupcake = async () => { + if (!compiled || !contractAddress) { + update('Deploy first', 'err'); + return; + } + if (mode === 'browser') { + if (!browserAccount) return; + const fnName = pickFunctionName( + compiled.abi, + (f) => f.stateMutability === 'nonpayable' || f.stateMutability === 'payable', + ); + if (!fnName) { + pushConsole('abi', 'no nonpayable/payable function found for write call', 'error'); + update('No state-changing function in ABI', 'err'); + return; + } + update('Sending tx…', 'busy'); + pushConsole( + 'devnet', + `call ${fnName}(${browserAccount.address}) on ${contractAddress} from=${browserAccount.address}`, + ); + try { + const result = browserSend( + contractAddress, + compiled.abi, + fnName, + [browserAccount.address], + browserAccount.address, + ); + setLastTxHash(result.txHash); + addExplorerTransaction({ + hash: result.txHash, + method: fnName, + from: browserAccount.address, + to: contractAddress, + status: result.status === 1 ? 'Success' : 'Reverted', + }); + if (result.status === 1) { + completeAction('write'); + pushConsole('devnet', `tx confirmed hash=${result.txHash} status=1`, 'success'); + update('Tx confirmed', 'ok'); + } else { + pushConsole('devnet', result.revertReason || 'tx reverted', 'error'); + update(result.revertReason || 'Tx reverted', 'err'); + } + } catch (error) { + pushConsole('devnet', error instanceof Error ? error.message : 'Tx failed', 'error'); + update(error instanceof Error ? error.message : 'Tx failed', 'err'); + } + return; + } + const signer = await ensureSepoliaSigner(); + if (!signer) return; + update('Sending tx…', 'busy'); + try { + const contract = new ethers.Contract(contractAddress, compiled.abi, signer); + const signerAddress = await signer.getAddress(); + pushConsole('sepolia', `call giveCupcakeTo(${signerAddress}) on ${contractAddress}`); + const tx = await contract.giveCupcakeTo(signerAddress); + setLastTxHash(tx.hash); + pushConsole('sepolia', `submitted tx=${tx.hash}`); + await tx.wait(); + addExplorerTransaction({ + hash: tx.hash, + method: 'giveCupcakeTo', + from: signerAddress, + to: contractAddress, + status: 'Success', + }); + completeAction('write'); + pushConsole('sepolia', `tx confirmed hash=${tx.hash}`, 'success'); + update('Tx confirmed', 'ok'); + } catch (error) { + pushConsole('sepolia', error instanceof Error ? error.message : 'Tx failed', 'error'); + update(error instanceof Error ? error.message : 'Tx failed', 'err'); + } + }; + + const readBalance = async () => { + if (!compiled || !contractAddress) { + update('Deploy first', 'err'); + return; + } + if (mode === 'browser') { + if (!browserAccount) return; + const fnName = pickFunctionName( + compiled.abi, + (f) => f.stateMutability === 'view' || f.stateMutability === 'pure', + ); + if (!fnName) { + pushConsole('abi', 'no view/pure function found for read call', 'error'); + update('No view function in ABI', 'err'); + return; + } + update('Reading…', 'busy'); + pushConsole('devnet', `eth_call ${fnName}(${browserAccount.address}) on ${contractAddress}`); + try { + const result = browserCall(contractAddress, compiled.abi, fnName, [browserAccount.address]); + if (result.revertReason) { + pushConsole('devnet', result.revertReason, 'error'); + update(result.revertReason, 'err'); + return; + } + const value = result.result[0]; + const text = typeof value === 'bigint' ? value.toString() : String(value); + setLastRead(text); + completeAction('read'); + pushConsole('devnet', `return ${text}`, 'success'); + update(`Balance ${text}`, 'ok'); + } catch (error) { + pushConsole('devnet', error instanceof Error ? error.message : 'Read failed', 'error'); + update(error instanceof Error ? error.message : 'Read failed', 'err'); + } + return; + } + const signer = await ensureSepoliaSigner(); + if (!signer) return; + update('Reading…', 'busy'); + try { + const contract = new ethers.Contract(contractAddress, compiled.abi, signer); + const signerAddress = await signer.getAddress(); + pushConsole( + 'sepolia', + `eth_call getCupcakeBalanceFor(${signerAddress}) on ${contractAddress}`, + ); + const balance = await contract.getCupcakeBalanceFor(signerAddress); + setLastRead(balance.toString()); + completeAction('read'); + pushConsole('sepolia', `return ${balance.toString()}`, 'success'); + update(`Balance ${balance.toString()}`, 'ok'); + } catch (error) { + pushConsole('sepolia', error instanceof Error ? error.message : 'Read failed', 'error'); + update(error instanceof Error ? error.message : 'Read failed', 'err'); + } + }; + + const advanceTask = () => { + if (tasks.length === 0 || !canAdvanceTask) return; + moveToNextTask(); + }; + + const handlePrimaryLessonAction = (event?: React.MouseEvent) => { + event?.currentTarget.blur(); + if (tutorialComplete && activeTaskIndex === tasks.length - 1) return; + if (!activeTask) return; + if (activeTask.action === 'compile') { + runCompile(); + return; + } + if (activeTask.action === 'deploy') { + deployContract(); + return; + } + if (activeTask.action === 'write') { + writeCupcake(); + return; + } + if (activeTask.action === 'read') { + readBalance(); + return; + } + if (activeTaskIndex === tasks.length - 1) { + finishTutorial(); + return; + } + advanceTask(); + }; + + const primaryLessonLabel = (() => { + if (activeTask?.action === 'compile') return 'Compile'; + if (activeTask?.action === 'deploy') return 'Deploy'; + if (activeTask?.action === 'write') return `call ${writeCallLabel}`; + if (activeTask?.action === 'read') return `call ${readCallLabel}`; + return activeTaskIndex === tasks.length - 1 ? 'Done' : 'Next'; + })(); + + const primaryLessonIcon = + activeTask?.action && activeTask.action !== 'focus' ? activeTask.action : undefined; + + const canUsePrimaryLessonAction = (() => { + if (tutorialComplete && activeTaskIndex === tasks.length - 1) return false; + if (activeTask?.action && activeTask.action !== 'focus') return canRunAction(activeTask.action); + return isTaskSatisfied(activeTask); + })(); + + const retreatTask = () => { + if (tasks.length === 0 || activeTaskIndex === 0) return; + moveToPreviousTask(); + }; + + const finishTutorial = () => { + if (!isTaskSatisfied(activeTask)) return; + setManualRange(undefined); + setTutorialComplete(true); + setSpotlightRunning(false); + }; + + return ( +
+ {useSpotlight && spotlightMounted && joyrideSteps.length > 0 && ( + { + if ( + data.type === EVENTS.STEP_AFTER && + (data.action === ACTIONS.NEXT || data.action === ACTIONS.CLOSE) + ) { + advanceTask(); + } + if (data.type === EVENTS.STEP_AFTER && data.action === ACTIONS.PREV) { + moveToPreviousTask(); + } + if (data.status === STATUS.FINISHED || data.status === STATUS.SKIPPED) { + setManualRange(undefined); + setSpotlightRunning(false); + } + }} + locale={{ next: 'Next', back: 'Back', close: 'Close', last: 'Done' }} + options={{ + overlayColor: 'transparent', + primaryColor: 'var(--ide-accent)', + backgroundColor: 'var(--ide-side)', + textColor: 'var(--ide-fg)', + zIndex: 10000, + hideOverlay: true, + spotlightRadius: 5, + scrollDuration: 0, + scrollOffset: 0, + showProgress: false, + skipScroll: true, + disableFocusTrap: true, + }} + styles={{ + arrow: { + display: 'none', + }, + overlay: { + backgroundColor: 'transparent', + mixBlendMode: 'normal', + }, + tooltip: { + border: '1px solid var(--ide-spotlight-border)', + backgroundColor: 'var(--ide-side)', + borderRadius: 6, + boxShadow: '0 18px 44px rgb(0 0 0 / 36%)', + fontFamily: 'var(--ifm-font-family-base)', + maxWidth: 380, + padding: 18, + }, + tooltipContainer: { + textAlign: 'left', + }, + tooltipTitle: { + color: 'var(--ide-fg)', + fontSize: 15, + fontWeight: 800, + lineHeight: 1.35, + }, + tooltipContent: { + color: 'var(--ide-chrome-fg)', + fontSize: 13, + lineHeight: 1.45, + padding: '8px 0 0', + }, + tooltipFooter: { + alignItems: 'center', + gap: 8, + marginTop: 14, + }, + buttonBack: { + border: '1px solid var(--ide-border)', + borderRadius: 3, + color: 'var(--ide-chrome-fg)', + fontSize: 12, + padding: '7px 10px', + }, + buttonPrimary: { + borderRadius: 3, + fontSize: 12, + fontWeight: 800, + padding: '8px 12px', + }, + buttonClose: { + color: 'var(--ide-muted)', + height: 26, + width: 26, + }, + }} + /> + )} +
+ {sidebarIntro && ( + + )} +
+
+ + {fileName} + {code !== source.trim() && ( + + ● + + )} + +
+
+ {activeTask && !useSpotlight && ( +
+
+ + {activeTaskIndex + 1} / {tasks.length} + + {activeTask.lines && ( + + L{activeTask.lines[0]}-{activeTask.lines[1]} + + )} +
+ {activeTask.label} + +
+ )} + {tasks.length > 0 && !useSpotlight && ( + + )} + + {({ className: highlightClassName, style, tokens, getLineProps, getTokenProps }) => ( + + )} + +