diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c34e3ef1..19122656 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,9 @@ { "name": "swanky-env", - "image": "ghcr.io/swankyhub/swanky-cli/swanky-base:swanky3.1.0-beta.0_v2.1.0", - + "image": "ghcr.io/inkdevhub/swanky-cli/swanky-base:swanky3.1.0-beta.0_v2.1.1", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2.8.0": {} + }, // Mount the workspace volume "mounts": ["source=${localWorkspaceFolder},target=/workspaces,type=bind,consistency=cached"], "workspaceFolder": "/workspaces", diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 00000000..593ac77a --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,35 @@ +# This configuration file was automatically generated by Gitpod. +# Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml) +# and commit this file to your remote git repository to share the goodness with others. + +# Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart + +ports: + - name: Swanky Node + port: 9944 + +vscode: + extensions: + - rust-lang.rust-analyzer + +tasks: + - init: | + # Add wasm target + rustup target add wasm32-unknown-unknown + + # Add necessary components + rustup component add rust-src + + # Install or update cargo packages + cargo install --force --locked cargo-contract + cargo install cargo-dylint dylint-link + + yarn install + yarn build + command: | + echo "Swanky Dev Environment ready!" + echo "Use Swanky directly by running \"./bin/run.js COMMAND\"" + echo "For example:" + echo "./bin/run.js init temp_project" + echo "cd temp_project" + echo "../bin/run.js contract compile flipper" diff --git a/README.md b/README.md index 51a6b4e4..6d707795 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ A newly generated project will have a `swanky.config.json` file that will get po "node": { "localPath": "/Users/sasapul/Work/astar/swanky-cli/temp_proj/bin/swanky-node", "polkadotPalletVersions": "polkadot-v0.9.39", - "supportedInk": "v4.2.0" + "supportedInk": "v4.3.0" }, "accounts": [ { diff --git a/base-image/Dockerfile b/base-image/Dockerfile index c479863e..7429433c 100644 --- a/base-image/Dockerfile +++ b/base-image/Dockerfile @@ -19,12 +19,12 @@ RUN curl -L https://github.com/swankyhub/swanky-cli/releases/download/v3.1.0-bet # Install Rustup and Rust, additional components, packages, and verify the installations RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \ /bin/bash -c "source $HOME/.cargo/env && \ - rustup toolchain install nightly-2023-03-05 && \ - rustup default nightly-2023-03-05 && \ - rustup component add rust-src --toolchain nightly-2023-03-05 && \ - rustup target add wasm32-unknown-unknown --toolchain nightly-2023-03-05 && \ - cargo +stable install cargo-dylint dylint-link && \ - cargo +stable install cargo-contract --force --version 4.0.0-alpha && \ + rustup install 1.72 && \ + rustup default 1.72 && \ + rustup component add rust-src && \ + rustup target add wasm32-unknown-unknown && \ + cargo install cargo-dylint dylint-link && \ + cargo install cargo-contract --version 4.0.0-rc.1 && \ rustc --version" # Install Yarn 1.x diff --git a/package.json b/package.json index 02caec52..202efaf4 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "ora": "6.3.1", "semver": "7.5.4", "shelljs": "0.8.5", + "toml": "^3.0.0", "ts-mocha": "^10.0.0", "winston": "^3.10.0" }, diff --git a/src/commands/account/balance.ts b/src/commands/account/balance.ts new file mode 100644 index 00000000..4a9caf85 --- /dev/null +++ b/src/commands/account/balance.ts @@ -0,0 +1,63 @@ +import { Args } from "@oclif/core"; +import { ApiPromise } from "@polkadot/api"; +import type { AccountInfo, Balance as BalanceType } from "@polkadot/types/interfaces"; +import { ChainApi, resolveNetworkUrl } from "../../lib/index.js"; +import { SwankyCommand } from "../../lib/swankyCommand.js"; +import { InputError } from "../../lib/errors.js"; +import { formatBalance } from "@polkadot/util"; + +export class Balance extends SwankyCommand { + static description = "Balance of an account"; + + static args = { + alias: Args.string({ + name: "alias", + description: "Alias of account to be used", + }), + }; + async run(): Promise { + const { args } = await this.parse(Balance); + + if (!args.alias) { + throw new InputError( + "Missing argument! Please provide an alias account to get the balance from. Example usage: `swanky account balance `" + ); + } + + const accountData = this.findAccountByAlias(args.alias); + const networkUrl = resolveNetworkUrl(this.swankyConfig, ""); + + const api = (await this.spinner.runCommand(async () => { + const api = await ChainApi.create(networkUrl); + await api.start(); + return api.apiInst; + }, "Connecting to node")) as ApiPromise; + + const decimals = api.registry.chainDecimals[0]; + formatBalance.setDefaults({ unit: "UNIT", decimals }); + + const { nonce, data: balance } = await api.query.system.account( + accountData.address + ); + const { free, reserved, miscFrozen, feeFrozen } = balance; + + let frozen: BalanceType; + if (feeFrozen.gt(miscFrozen)) { + frozen = feeFrozen; + } else { + frozen = miscFrozen; + } + + const transferrableBalance = free.sub(frozen); + const totalBalance = free.add(reserved); + + console.log("Transferrable Balance:", formatBalance(transferrableBalance)); + if (!transferrableBalance.eq(totalBalance)) { + console.log("Total Balance:", formatBalance(totalBalance)); + console.log("Raw Balances:", balance.toHuman()); + } + console.log("Account Nonce:", nonce.toHuman()); + + await api.disconnect(); + } +} diff --git a/src/commands/account/create.ts b/src/commands/account/create.ts index 5c4fc4c4..8972f8af 100644 --- a/src/commands/account/create.ts +++ b/src/commands/account/create.ts @@ -1,21 +1,37 @@ import { Flags } from "@oclif/core"; import chalk from "chalk"; -import { ChainAccount, encrypt } from "../../lib/index.js"; +import { ChainAccount, encrypt, getSwankyConfig, isLocalConfigCheck } from "../../lib/index.js"; import { AccountData } from "../../types/index.js"; import inquirer from "inquirer"; import { SwankyCommand } from "../../lib/swankyCommand.js"; -export class CreateAccount extends SwankyCommand { +import { FileError } from "../../lib/errors.js"; +import { ConfigBuilder } from "../../lib/config-builder.js"; +import { SwankyAccountCommand } from "./swankyAccountCommands.js"; + +export class CreateAccount extends SwankyAccountCommand { static description = "Create a new dev account in config"; static flags = { - generate: Flags.boolean({ + global: Flags.boolean({ char: "g", + description: "Create account globally stored in Swanky system config.", + + }), + new: Flags.boolean({ + char: "n", + description: "Generate a brand new account.", }), dev: Flags.boolean({ char: "d", + description: "Make this account a dev account for local network usage.", }), }; + constructor(argv: string[], baseConfig: any) { + super(argv, baseConfig); + (this.constructor as typeof SwankyCommand).ENSURE_SWANKY_CONFIG = false; + } + async run(): Promise { const { flags } = await this.parse(CreateAccount); @@ -36,7 +52,7 @@ export class CreateAccount extends SwankyCommand { } let tmpMnemonic = ""; - if (flags.generate) { + if (flags.new) { tmpMnemonic = ChainAccount.generate(); console.log( `${ @@ -75,14 +91,29 @@ export class CreateAccount extends SwankyCommand { accountData.mnemonic = tmpMnemonic; } - this.swankyConfig.accounts.push(accountData); + const configType = flags.global ? "global" : isLocalConfigCheck() ? "local" : "global"; + const config = configType === "global" ? getSwankyConfig("global") : getSwankyConfig("local"); - await this.storeConfig(); + const configBuilder = new ConfigBuilder(config).addAccount(accountData); + + if (config.defaultAccount === null) { + configBuilder.setDefaultAccount(accountData.alias); + } + + try { + await this.storeConfig(configBuilder.build(), configType); + } catch (cause) { + throw new FileError(`Error storing created account in ${configType} config`, { + cause, + }); + } this.log( `${chalk.greenBright("✔")} Account with alias ${chalk.yellowBright( accountData.alias )} stored to config` ); + + await this.performFaucetTransfer(accountData, true); } } diff --git a/src/commands/account/default.ts b/src/commands/account/default.ts new file mode 100644 index 00000000..7ffdb422 --- /dev/null +++ b/src/commands/account/default.ts @@ -0,0 +1,82 @@ +import { Args, Flags } from "@oclif/core"; +import chalk from "chalk"; +import { SwankySystemConfig } from "../../types/index.js"; +import inquirer from "inquirer"; +import { SwankyCommand } from "../../lib/swankyCommand.js"; +import { ConfigError, FileError } from "../../lib/errors.js"; +import { getSwankyConfig, isLocalConfigCheck } from "../../lib/index.js"; +import { ConfigBuilder } from "../../lib/config-builder.js"; + +export class DefaultAccount extends SwankyCommand { + static description = "Set default account to use"; + + static flags = { + global: Flags.boolean({ + char: "g", + description: "Set default account globally in Swanky system config.", + }), + }; + + static args = { + accountAlias: Args.string({ + name: "accountAlias", + required: false, + description: "Alias of account to be used as default", + }), + }; + + constructor(argv: string[], baseConfig: any) { + super(argv, baseConfig); + (this.constructor as typeof SwankyCommand).ENSURE_SWANKY_CONFIG = false; + } + + async run(): Promise { + const { args, flags } = await this.parse(DefaultAccount); + + const configType = flags.global ? "global" : isLocalConfigCheck() ? "local" : "global"; + const config = configType === "global" ? getSwankyConfig("global") : getSwankyConfig("local"); + + const accountAlias = args.accountAlias ?? (await this.promptForAccountAlias(config)); + this.ensureAccountExists(config, accountAlias); + + const newConfig = new ConfigBuilder(config).setDefaultAccount(accountAlias).build(); + + try { + await this.storeConfig(newConfig, configType); + } catch (cause) { + throw new FileError(`Error storing default account in ${configType} config`, { + cause, + }); + } + + this.log( + `${chalk.greenBright("✔")} Account with alias ${chalk.yellowBright( + accountAlias + )} set as default in ${configType} config` + ); + } + + private async promptForAccountAlias(config: SwankySystemConfig): Promise { + const choices = config.accounts.map((account) => ({ + name: `${account.alias} (${account.address})`, + value: account.alias, + })); + + const answer = await inquirer.prompt([ + { + type: "list", + name: "defaultAccount", + message: "Select default account", + choices: choices, + }, + ]); + + return answer.defaultAccount; + } + + private ensureAccountExists(config: SwankySystemConfig, alias: string) { + const isSomeAccount = config.accounts.some((account) => account.alias === alias); + if (!isSomeAccount) + throw new ConfigError(`Provided account alias ${chalk.yellowBright(alias)} not found`); + } +} diff --git a/src/commands/account/faucet.ts b/src/commands/account/faucet.ts new file mode 100644 index 00000000..02b6b965 --- /dev/null +++ b/src/commands/account/faucet.ts @@ -0,0 +1,23 @@ +import { Args } from "@oclif/core"; +import { SwankyAccountCommand } from "./swankyAccountCommands.js"; + +export class Faucet extends SwankyAccountCommand { + static description = "Transfer some tokens from faucet to an account"; + + static aliases = [`account:faucet`]; + + static args = { + alias: Args.string({ + name: "alias", + required: true, + description: "Alias of account to be used", + }), + }; + + async run(): Promise { + const { args } = await this.parse(Faucet); + + const accountData = this.findAccountByAlias(args.alias); + await this.performFaucetTransfer(accountData); + } +} diff --git a/src/commands/account/list.ts b/src/commands/account/list.ts index a7c294bd..67f05ecd 100644 --- a/src/commands/account/list.ts +++ b/src/commands/account/list.ts @@ -5,11 +5,36 @@ export class ListAccounts extends SwankyCommand { static description = "List dev accounts stored in config"; static aliases = [`account:ls`]; + constructor(argv: string[], baseConfig: any) { + super(argv, baseConfig); + (this.constructor as typeof SwankyCommand).ENSURE_SWANKY_CONFIG = false; + } + async run(): Promise { - this.log(`${chalk.greenBright("✔")} Stored dev accounts:`); + const countOfDevAccounts = this.swankyConfig.accounts.filter((account) => account.isDev).length; + + if(countOfDevAccounts !== 0) { + this.log(`${chalk.greenBright("✔")} Stored dev accounts:`); + + for (const account of this.swankyConfig.accounts) { + if(account.isDev){ + this.log(`\t${chalk.yellowBright("Alias: ")} ${account.alias} \ +${chalk.yellowBright("Address: ")} ${account.address} ${this.swankyConfig.defaultAccount === account.alias ? chalk.greenBright("<- Default") : ""}`); + } + } + } + + const countOfProdAccounts = this.swankyConfig.accounts.length - countOfDevAccounts; + + if(countOfProdAccounts !== 0) { + this.log(`${chalk.greenBright("✔")} Stored prod accounts:`); - for (const account of this.swankyConfig.accounts) { - this.log(`\t${chalk.yellowBright("Alias: ")} ${account.alias}`); + for (const account of this.swankyConfig.accounts) { + if(!account.isDev){ + this.log(`\t${chalk.yellowBright("Alias: ")} ${account.alias} \ +${chalk.yellowBright("Address: ")} ${account.address} ${this.swankyConfig.defaultAccount === account.alias ? chalk.greenBright("<- Default") : ""}`); + } + } } } } diff --git a/src/commands/account/swankyAccountCommands.ts b/src/commands/account/swankyAccountCommands.ts new file mode 100644 index 00000000..b884f203 --- /dev/null +++ b/src/commands/account/swankyAccountCommands.ts @@ -0,0 +1,45 @@ +import { Command } from "@oclif/core"; +import chalk from "chalk"; +import { AccountData, ChainApi, resolveNetworkUrl } from "../../index.js"; +import { LOCAL_FAUCET_AMOUNT } from "../../lib/consts.js"; +import { SwankyCommand } from "../../lib/swankyCommand.js"; +import { ApiError } from "../../lib/errors.js"; + +export abstract class SwankyAccountCommand extends SwankyCommand { + async performFaucetTransfer(accountData: AccountData, canBeSkipped = false) { + let api: ChainApi | null = null; + try { + api = (await this.spinner.runCommand(async () => { + const networkUrl = resolveNetworkUrl(this.swankyConfig, ""); + const api = await ChainApi.create(networkUrl); + await api.start(); + return api; + }, "Connecting to node")) as ChainApi; + + if (api) + await this.spinner.runCommand( + async () => { + if (api) await api.faucet(accountData); + }, + `Transferring ${LOCAL_FAUCET_AMOUNT} units from faucet account to ${accountData.alias}`, + `Transferred ${LOCAL_FAUCET_AMOUNT} units from faucet account to ${accountData.alias}`, + `Failed to transfer ${LOCAL_FAUCET_AMOUNT} units from faucet account to ${accountData.alias}`, + true + ); + } catch (cause) { + if (cause instanceof Error) { + if (cause.message.includes('ECONNREFUSED') && canBeSkipped) { + this.warn(`Unable to connect to the node. Skipping faucet transfer for ${chalk.yellowBright(accountData.alias)}.`); + } else { + throw new ApiError("Error transferring tokens from faucet account", { cause }); + } + } else { + throw new ApiError("An unknown error occurred during faucet transfer", { cause: new Error(String(cause)) }); + } + } finally { + if (api) { + await api.disconnect(); + } + } + } +} diff --git a/src/commands/check/index.ts b/src/commands/check/index.ts index 9d1d27fa..eaa169fe 100644 --- a/src/commands/check/index.ts +++ b/src/commands/check/index.ts @@ -1,14 +1,23 @@ import { Listr } from "listr2"; -import { commandStdoutOrNull } from "../../lib/index.js"; +import { commandStdoutOrNull, extractCargoContractVersion } from "../../lib/index.js"; import { SwankyConfig } from "../../types/index.js"; -import { pathExistsSync, readJSON } from "fs-extra/esm"; +import { pathExistsSync, writeJson } from "fs-extra/esm"; import { readFileSync } from "fs"; import path from "node:path"; import TOML from "@iarna/toml"; import semver from "semver"; import { SwankyCommand } from "../../lib/swankyCommand.js"; +import { Flags } from "@oclif/core"; +import chalk from "chalk"; +import { CARGO_CONTRACT_INK_DEPS } from "../../lib/cargoContractInfo.js"; +import { CLIError } from "@oclif/core/lib/errors/index.js"; +import Warn = CLIError.Warn; interface Ctx { + os: { + platform: string; + architecture: string; + }, versions: { tools: { rust?: string | null; @@ -17,55 +26,124 @@ interface Ctx { cargoDylint?: string | null; cargoContract?: string | null; }; + supportedInk?: string; + missingTools: string[]; contracts: Record>; + swankyNode: string | null; }; - swankyConfig?: SwankyConfig; - mismatchedVersions?: Record; + swankyConfig: SwankyConfig; + mismatchedVersions: Record; looseDefinitionDetected: boolean; } export default class Check extends SwankyCommand { static description = "Check installed package versions and compatibility"; + static flags = { + print: Flags.string({ + char: "o", + description: "File to write output to", + }), + }; + + constructor(argv: string[], baseConfig: any) { + super(argv, baseConfig); + (this.constructor as typeof SwankyCommand).ENSURE_SWANKY_CONFIG = false; + } + public async run(): Promise { + const { flags } = await this.parse(Check); + const swankyNodeVersion = this.swankyConfig.node.version; + const isSwankyNodeInstalled = !!swankyNodeVersion; + const anyContracts = Object.keys(this.swankyConfig.contracts ?? {}).length > 0; const tasks = new Listr([ + { + title: "Check OS", + task: async (ctx, task) => { + ctx.os.platform = process.platform; + ctx.os.architecture = process.arch; + const supportedPlatforms = ["darwin", "linux"]; + const supportedArch = ["arm64", "x64"]; + + if (!supportedPlatforms.includes(ctx.os.platform)) { + throw new Error(`Platform ${ctx.os.platform} is not supported`); + } + if (!supportedArch.includes(ctx.os.architecture)) { + throw new Error(`Architecture ${ctx.os.architecture} is not supported`); + } + + task.title = `Check OS: '${ctx.os.platform}-${ctx.os.architecture}'`; + }, + exitOnError: false, + }, { title: "Check Rust", - task: async (ctx) => { - ctx.versions.tools.rust = await commandStdoutOrNull("rustc --version"); + task: async (ctx, task) => { + ctx.versions.tools.rust = commandStdoutOrNull("rustc --version")?.match(/rustc (.*) \((.*)/)?.[1]; + if (!ctx.versions.tools.rust) { + throw new Error("Rust is not installed"); + } + task.title = `Check Rust: ${ctx.versions.tools.rust}`; }, + exitOnError: false, }, { title: "Check cargo", - task: async (ctx) => { - ctx.versions.tools.cargo = await commandStdoutOrNull("cargo -V"); + task: async (ctx, task) => { + ctx.versions.tools.cargo = commandStdoutOrNull("cargo -V")?.match(/cargo (.*) \((.*)/)?.[1]; + if (!ctx.versions.tools.cargo) { + throw new Error("Cargo is not installed"); + } + task.title = `Check cargo: ${ctx.versions.tools.cargo}`; }, + exitOnError: false, }, { title: "Check cargo nightly", - task: async (ctx) => { - ctx.versions.tools.cargoNightly = await commandStdoutOrNull("cargo +nightly -V"); + task: async (ctx, task) => { + ctx.versions.tools.cargoNightly = commandStdoutOrNull("cargo +nightly -V")?.match(/cargo (.*)-nightly \((.*)/)?.[1]; + if (!ctx.versions.tools.cargoNightly) { + throw new Error("Cargo nightly is not installed"); + } + task.title = `Check cargo nightly: ${ctx.versions.tools.cargoNightly}`; }, + exitOnError: false, }, { title: "Check cargo dylint", - task: async (ctx) => { - ctx.versions.tools.cargoDylint = await commandStdoutOrNull("cargo dylint -V"); + task: async (ctx, task) => { + ctx.versions.tools.cargoDylint = commandStdoutOrNull("cargo dylint -V")?.match(/cargo-dylint (.*)/)?.[1]; + if (!ctx.versions.tools.cargoDylint) { + throw new Warn("Cargo dylint is not installed"); + } + task.title = `Check cargo dylint: ${ctx.versions.tools.cargoDylint}`; }, + exitOnError: false, }, { title: "Check cargo-contract", + task: async (ctx, task) => { + const cargoContractVersion = extractCargoContractVersion(); + ctx.versions.tools.cargoContract = cargoContractVersion; + if (!cargoContractVersion) { + throw new Error("Cargo contract is not installed"); + } + task.title = `Check cargo-contract: ${cargoContractVersion}`; + }, + exitOnError: false, + }, + { + title: "Check swanky node", task: async (ctx) => { - ctx.versions.tools.cargoContract = await commandStdoutOrNull("cargo contract -V"); + ctx.versions.swankyNode = this.swankyConfig.node.version !== "" ? this.swankyConfig.node.version : null; }, }, { title: "Read ink dependencies", + enabled: anyContracts, + skip: (ctx) => Object.keys(ctx.swankyConfig.contracts).length == 0, task: async (ctx) => { - const swankyConfig = await readJSON("swanky.config.json"); - ctx.swankyConfig = swankyConfig; - - for (const contract in swankyConfig.contracts) { + for (const contract in ctx.swankyConfig.contracts) { const tomlPath = path.resolve(`contracts/${contract}/Cargo.toml`); const doesCargoTomlExist = pathExistsSync(tomlPath); if (!doesCargoTomlExist) { @@ -79,7 +157,7 @@ export default class Check extends SwankyCommand { const cargoToml = TOML.parse(cargoTomlString); const inkDependencies = Object.entries(cargoToml.dependencies) - .filter((dependency) => dependency[0].includes("ink_")) + .filter(([depName]) => /^ink($|_)/.test(depName)) .map(([depName, depInfo]) => { const dependency = depInfo as Dependency; return [depName, dependency.version ?? dependency.tag]; @@ -89,43 +167,123 @@ export default class Check extends SwankyCommand { }, }, { - title: "Verify ink version", + title: "Verify ink version compatibility with Swanky node", + skip: (ctx) => Object.keys(ctx.versions.contracts).length === 0, + enabled: anyContracts && isSwankyNodeInstalled, task: async (ctx) => { - const supportedInk = ctx.swankyConfig?.node.supportedInk; - + const supportedInk = ctx.swankyConfig.node.supportedInk; const mismatched: Record = {}; - Object.entries(ctx.versions.contracts).forEach(([contract, inkPackages]) => { - Object.entries(inkPackages).forEach(([inkPackage, version]) => { - if (semver.gt(version, supportedInk!)) { + Object.entries(ctx.versions.contracts).forEach(([contract, inkDependencies]) => { + Object.entries(inkDependencies).forEach(([depName, version]) => { + if (semver.gt(version, supportedInk)) { mismatched[ - `${contract}-${inkPackage}` - ] = `Version of ${inkPackage} (${version}) in ${contract} is higher than supported ink version (${supportedInk})`; + `${contract}-${depName}` + ] = `Version of ${depName} (${version}) in ${chalk.yellowBright(contract)} is higher than supported ink version (${supportedInk}) in current Swanky node version (${swankyNodeVersion}). A Swanky node update can fix this warning.`; } - if (!(version.startsWith("=") || version.startsWith("v"))) { + if (version.startsWith(">") || version.startsWith("<") || version.startsWith("^") || version.startsWith("~")) { ctx.looseDefinitionDetected = true; } }); }); ctx.mismatchedVersions = mismatched; + if (Object.entries(mismatched).length > 0) { + throw new Warn("Ink versions in contracts don't match the Swanky node's supported version."); + } }, + exitOnError: false, + }, + { + title: "Verify cargo contract compatibility", + skip: (ctx) => !ctx.versions.tools.cargoContract, + enabled: anyContracts, + task: async (ctx) => { + const cargoContractVersion = ctx.versions.tools.cargoContract!; + const dependencyIdx = CARGO_CONTRACT_INK_DEPS.findIndex((dep) => + semver.satisfies(cargoContractVersion.replace(/-.*$/, ""), `>=${dep.minCargoContractVersion}`) + ); + + if (dependencyIdx === -1) { + throw new Warn(`cargo-contract version ${cargoContractVersion} is not supported`); + } + + const validInkVersionRange = CARGO_CONTRACT_INK_DEPS[dependencyIdx].validInkVersionRange; + const minCargoContractVersion = dependencyIdx === 0 + ? CARGO_CONTRACT_INK_DEPS[dependencyIdx].minCargoContractVersion + : CARGO_CONTRACT_INK_DEPS[dependencyIdx - 1].minCargoContractVersion + + const mismatched: Record = {}; + Object.entries(ctx.versions.contracts).forEach(([contract, inkPackages]) => { + Object.entries(inkPackages).forEach(([inkPackage, version]) => { + if (!semver.satisfies(version, validInkVersionRange)) { + mismatched[ + `${contract}-${inkPackage}` + ] = `Version of ${inkPackage} (${version}) in ${chalk.yellowBright(contract)} requires cargo-contract version >=${minCargoContractVersion}, but version ${cargoContractVersion} is installed`; + } + }); + }); + + ctx.mismatchedVersions = { ...ctx.mismatchedVersions, ...mismatched }; + if (Object.entries(mismatched).length > 0) { + throw new Warn("cargo-contract version mismatch"); + } + }, + exitOnError: false, + }, + { + title: "Check for missing tools", + task: async (ctx) => { + const missingTools: string[] = []; + for (const [toolName, toolVersion] of Object.entries(ctx.versions.tools)) { + if (!toolVersion) { + missingTools.push(toolName); + if (toolName === "cargoDylint") this.warn("Cargo dylint is not installed"); + else this.error(`${toolName} is not installed`); + } + } + ctx.versions.missingTools = missingTools; + if (Object.entries(missingTools).length > 0) { + throw new Warn(`Missing tools: ${missingTools.join(", ")}`); + } + }, + exitOnError: false, }, ]); + const context = await tasks.run({ - versions: { tools: {}, contracts: {} }, + os: { platform: "", architecture: "" }, + versions: { + tools: {}, + missingTools: [], + contracts: {}, + swankyNode: swankyNodeVersion || null, + }, + swankyConfig: this.swankyConfig, looseDefinitionDetected: false, + mismatchedVersions: {} }); - console.log(context.versions); - Object.values(context.mismatchedVersions as any).forEach((mismatch) => - console.error(`[ERROR] ${mismatch as string}`) - ); + + Object.values(context.mismatchedVersions).forEach((mismatch) => this.warn(mismatch)); + if (context.looseDefinitionDetected) { - console.log(`\n[WARNING]Some of the ink dependencies do not have a fixed version. + this.warn(`Some of the ink dependencies do not have a fixed version. This can lead to accidentally installing version higher than supported by the node. Please use "=" to install a fixed version (Example: "=3.0.1") `); } + + const output = { + ...context.os, + ...context.versions + } + + const filePath = flags.print; + if (filePath !== undefined) { + await this.spinner.runCommand(async () => { + writeJson(filePath, output, { spaces: 2 }); + }, `Writing output to file ${chalk.yellowBright(filePath)}`); + } } } diff --git a/src/commands/contract/compile.ts b/src/commands/contract/compile.ts index a42ccddf..93a00538 100644 --- a/src/commands/contract/compile.ts +++ b/src/commands/contract/compile.ts @@ -1,10 +1,12 @@ import { Args, Flags } from "@oclif/core"; import path from "node:path"; -import { storeArtifacts, Spinner, generateTypes } from "../../lib/index.js"; import { spawn } from "node:child_process"; import { pathExists } from "fs-extra/esm"; import { SwankyCommand } from "../../lib/swankyCommand.js"; +import { ensureCargoContractVersionCompatibility, extractCargoContractVersion, Spinner, storeArtifacts, configName, getSwankyConfig } from "../../lib/index.js"; import { ConfigError, InputError, ProcessError } from "../../lib/errors.js"; +import { BuildMode, SwankyConfig } from "../../index.js"; +import { ConfigBuilder } from "../../lib/config-builder.js"; export class CompileContract extends SwankyCommand { static description = "Compile the smart contract(s) in your contracts directory"; @@ -16,6 +18,11 @@ export class CompileContract extends SwankyCommand { description: "A production contract should always be build in `release` mode for building optimized wasm", }), + verifiable: Flags.boolean({ + default: false, + description: + "A production contract should be build in `verifiable` mode to deploy on a public network. Ensure Docker Engine is up and running.", + }), all: Flags.boolean({ default: false, char: "a", @@ -35,6 +42,8 @@ export class CompileContract extends SwankyCommand { async run(): Promise { const { args, flags } = await this.parse(CompileContract); + const localConfig = getSwankyConfig("local") as SwankyConfig; + if (args.contractName === undefined && !flags.all) { throw new InputError("No contracts were selected to compile", { winston: { stack: true } }); } @@ -49,7 +58,7 @@ export class CompileContract extends SwankyCommand { const contractInfo = this.swankyConfig.contracts[contractName]; if (!contractInfo) { throw new ConfigError( - `Cannot find contract info for ${contractName} contract in swanky.config.json` + `Cannot find contract info for ${contractName} contract in "${configName()}"` ); } const contractPath = path.resolve("contracts", contractInfo.name); @@ -58,6 +67,7 @@ export class CompileContract extends SwankyCommand { throw new InputError(`Contract folder not found at expected path`); } + let buildMode = BuildMode.Debug; const compilationResult = await spinner.runCommand( async () => { return new Promise((resolve, reject) => { @@ -65,11 +75,25 @@ export class CompileContract extends SwankyCommand { "contract", "build", "--manifest-path", - `${contractPath}/Cargo.toml`, + `contracts/${contractName}/Cargo.toml`, ]; - if (flags.release) { + if (flags.release && !flags.verifiable) { + buildMode = BuildMode.Release; compileArgs.push("--release"); } + if (flags.verifiable) { + buildMode = BuildMode.Verifiable; + const cargoContractVersion = extractCargoContractVersion(); + if (cargoContractVersion === null) + throw new InputError( + `Cargo contract tool is required for verifiable mode. Please ensure it is installed.` + ); + + ensureCargoContractVersionCompatibility(cargoContractVersion, "4.0.0", [ + "4.0.0-alpha", + ]); + compileArgs.push("--verifiable"); + } const compile = spawn("cargo", compileArgs); this.logger.info(`Running compile command: [${JSON.stringify(compile.spawnargs)}]`); let outputBuffer = ""; @@ -100,7 +124,7 @@ export class CompileContract extends SwankyCommand { }); }, `Compiling ${contractName} contract`, - `${contractName} Contract compiled successfully` + `${contractName} Contract compiled successfully`, ); const artifactsPath = compilationResult as string; @@ -109,11 +133,18 @@ export class CompileContract extends SwankyCommand { return storeArtifacts(artifactsPath, contractInfo.name, contractInfo.moduleName); }, "Moving artifacts"); - await spinner.runCommand( - async () => await generateTypes(contractInfo.name), - `Generating ${contractName} contract ts types`, - `${contractName} contract's TS types Generated successfully` - ); + await this.spinner.runCommand(async () => { + const buildData = { + timestamp: Date.now(), + artifactsPath, + buildMode, + isVerified: false, + }; + const newLocalConfig = new ConfigBuilder(localConfig) + .addContractBuild(args.contractName, buildData) + .build(); + await this.storeConfig(newLocalConfig, "local"); + }, "Writing config"); } } } diff --git a/src/commands/contract/deploy.ts b/src/commands/contract/deploy.ts index 7b2e5482..44d3cd8d 100644 --- a/src/commands/contract/deploy.ts +++ b/src/commands/contract/deploy.ts @@ -1,22 +1,20 @@ import { Args, Flags } from "@oclif/core"; -import path from "node:path"; -import { writeJSON } from "fs-extra/esm"; import { cryptoWaitReady } from "@polkadot/util-crypto/crypto"; -import { resolveNetworkUrl, ChainApi, ChainAccount, decrypt, AbiType } from "../../lib/index.js"; -import { AccountData, Encrypted } from "../../types/index.js"; +import { AbiType, ChainAccount, ChainApi, decrypt, resolveNetworkUrl, ensureAccountIsSet, configName, getSwankyConfig } from "../../lib/index.js"; +import { BuildMode, Encrypted, SwankyConfig } from "../../types/index.js"; import inquirer from "inquirer"; import chalk from "chalk"; import { Contract } from "../../lib/contract.js"; import { SwankyCommand } from "../../lib/swankyCommand.js"; -import { ApiError, ConfigError, FileError } from "../../lib/errors.js"; +import { ApiError, ConfigError, FileError, InputError, ProcessError } from "../../lib/errors.js"; +import { ConfigBuilder } from "../../lib/config-builder.js"; export class DeployContract extends SwankyCommand { static description = "Deploy contract to a running node"; static flags = { account: Flags.string({ - required: true, - description: "Alias of account to be used", + description: "Account alias to deploy contract with", }), gas: Flags.integer({ char: "g", @@ -32,6 +30,7 @@ export class DeployContract extends SwankyCommand { }), network: Flags.string({ char: "n", + default: "local", description: "Network name to connect to", }), }; @@ -47,10 +46,11 @@ export class DeployContract extends SwankyCommand { async run(): Promise { const { args, flags } = await this.parse(DeployContract); - const contractRecord = this.swankyConfig.contracts[args.contractName]; + const localConfig = getSwankyConfig("local") as SwankyConfig; + const contractRecord = localConfig.contracts[args.contractName]; if (!contractRecord) { throw new ConfigError( - `Cannot find a contract named ${args.contractName} in swanky.config.json` + `Cannot find a contract named ${args.contractName} in "${configName()}"` ); } @@ -70,13 +70,49 @@ export class DeployContract extends SwankyCommand { ); } - const accountData = this.swankyConfig.accounts.find( - (account: AccountData) => account.alias === flags.account - ); - if (!accountData) { - throw new ConfigError("Provided account alias not found in swanky.config.json"); + if (contract.buildMode === undefined) { + throw new ProcessError( + `Build mode is undefined for contract ${args.contractName}. Please ensure the contract is correctly compiled.` + ); + } else if (contract.buildMode !== BuildMode.Verifiable) { + await inquirer + .prompt([ + { + type: "confirm", + message: `You are deploying a not verified contract in ${ + contract.buildMode === BuildMode.Release ? "release" : "debug" + } mode. Are you sure you want to continue?`, + name: "confirm", + }, + ]) + .then((answers) => { + if (!answers.confirm) { + this.log( + `${chalk.redBright("✖")} Aborted deployment of ${chalk.yellowBright( + args.contractName + )}` + ); + process.exit(0); + } + }); + } + + ensureAccountIsSet(flags.account, this.swankyConfig); + + const accountAlias = flags.account ?? this.swankyConfig.defaultAccount; + + if (accountAlias === null) { + throw new InputError(`An account is required to deploy ${args.contractName}`); } + const accountData = this.findAccountByAlias(accountAlias); + + if (accountData.isDev && flags.network !== "local") { + throw new ConfigError( + `Account ${accountAlias} is a DEV account and can only be used with local network` + ); + } + const mnemonic = accountData.isDev ? (accountData.mnemonic as string) : decrypt( @@ -128,19 +164,16 @@ export class DeployContract extends SwankyCommand { }, "Deploying")) as string; await this.spinner.runCommand(async () => { - contractRecord.deployments = [ - ...contractRecord.deployments, - { - timestamp: Date.now(), - address: contractAddress, - networkUrl, - deployerAlias: flags.account, - }, - ]; - - await writeJSON(path.resolve("swanky.config.json"), this.swankyConfig, { - spaces: 2, - }); + const deploymentData = { + timestamp: Date.now(), + address: contractAddress, + networkUrl, + deployerAlias: accountAlias, + }; + const newLocalConfig = new ConfigBuilder(localConfig) + .addContractDeployment(args.contractName, deploymentData) + .build(); + await this.storeConfig(newLocalConfig, "local"); }, "Writing config"); this.log(`Contract deployed!`); diff --git a/src/commands/contract/explain.ts b/src/commands/contract/explain.ts index b171338d..3efba84e 100644 --- a/src/commands/contract/explain.ts +++ b/src/commands/contract/explain.ts @@ -2,6 +2,7 @@ import { SwankyCommand } from "../../lib/swankyCommand.js"; import { Args } from "@oclif/core"; import { Contract } from "../../lib/contract.js"; import { ConfigError, FileError } from "../../lib/errors.js"; +import { configName } from "../../lib/index.js"; export class ExplainContract extends SwankyCommand { static description = "Explain contract messages based on the contracts' metadata"; @@ -20,7 +21,7 @@ export class ExplainContract extends SwankyCommand { const contractRecord = this.swankyConfig.contracts[args.contractName]; if (!contractRecord) { throw new ConfigError( - `Cannot find a contract named ${args.contractName} in swanky.config.json` + `Cannot find a contract named ${args.contractName} in "${configName()}"` ); } diff --git a/src/commands/contract/new.ts b/src/commands/contract/new.ts index 0936663f..41892a0e 100644 --- a/src/commands/contract/new.ts +++ b/src/commands/contract/new.ts @@ -1,11 +1,13 @@ import { Args, Flags } from "@oclif/core"; import path from "node:path"; -import { ensureDir, pathExists, writeJSON } from "fs-extra/esm"; +import { ensureDir, pathExists, pathExistsSync } from "fs-extra/esm"; import { checkCliDependencies, copyContractTemplateFiles, processTemplates, getTemplates, + prepareTestFiles, + getSwankyConfig, copyFrontendTemplateFiles, addFrontendWorkspace, installDeps, } from "../../lib/index.js"; import { email, name, pickTemplate } from "../../lib/prompts.js"; import { paramCase, pascalCase, snakeCase } from "change-case"; @@ -13,6 +15,7 @@ import { execaCommandSync } from "execa"; import inquirer from "inquirer"; import { SwankyCommand } from "../../lib/swankyCommand.js"; import { InputError } from "../../lib/errors.js"; +import { ConfigBuilder } from "../../lib/config-builder.js"; export class NewContract extends SwankyCommand { static description = "Generate a new smart contract template inside a project"; @@ -78,6 +81,40 @@ export class NewContract extends SwankyCommand { "Copying contract template files" ); + if (contractTemplate === "psp22") { + const e2eTestHelpersPath = path.resolve(projectPath, "tests", "test_helpers"); + if (!pathExistsSync(e2eTestHelpersPath)) { + await this.spinner.runCommand( + () => prepareTestFiles("e2e", path.resolve(templates.templatesPath), projectPath), + "Copying e2e test helpers" + ); + } else { + console.log("e2e test helpers already exist. No files were copied."); + } + } + + let addFrontendTemplate = false; + if (contractTemplate === "flipper") { + addFrontendTemplate = (await inquirer.prompt([ + { + type: "confirm", + name: "addFrontendTemplate", + message: "Would you like to add a frontend template?", + default: true, + }, + ])).addFrontendTemplate; + if (addFrontendTemplate) { + await this.spinner.runCommand( + () => + copyFrontendTemplateFiles( + templates.templatesPath, + projectPath + ), + "Copying frontend template files" + ); + } + } + await this.spinner.runCommand( () => processTemplates(projectPath, { @@ -92,17 +129,24 @@ export class NewContract extends SwankyCommand { "Processing contract templates" ); + if (addFrontendTemplate) { + await this.spinner.runCommand( + () => addFrontendWorkspace(projectPath), + "Adding frontend workspace to config" + ); + await this.spinner.runCommand( + () => installDeps(projectPath), + "Installing dependencies" + ); + } + await ensureDir(path.resolve(projectPath, "artifacts", args.contractName)); - await ensureDir(path.resolve(projectPath, "tests", args.contractName)); await this.spinner.runCommand(async () => { - this.swankyConfig.contracts[args.contractName] = { - name: args.contractName, - moduleName: snakeCase(args.contractName), - deployments: [], - }; - - await writeJSON(path.resolve("swanky.config.json"), this.swankyConfig, { spaces: 2 }); + const newLocalConfig = new ConfigBuilder(getSwankyConfig("local")) + .addContract(args.contractName) + .build(); + await this.storeConfig(newLocalConfig, "local"); }, "Writing config"); this.log("😎 New contract successfully generated! 😎"); diff --git a/src/commands/contract/query.ts b/src/commands/contract/query.ts index b094103c..0442c1e5 100644 --- a/src/commands/contract/query.ts +++ b/src/commands/contract/query.ts @@ -6,13 +6,15 @@ export class Query extends ContractCall { static args = { ...ContractCall.callArgs }; + static flags = { ...ContractCall.callFlags }; + public async run(): Promise { const { flags, args } = await this.parse(Query); const contract = new ContractPromise( this.api.apiInst, this.metadata, - this.deploymentInfo.address + this.deploymentInfo.address, ); const storageDepositLimit = null; @@ -27,7 +29,7 @@ export class Query extends ContractCall { gasLimit, storageDepositLimit, }, - ...flags.params + ...flags.params, ); await this.api.apiInst.disconnect(); diff --git a/src/commands/contract/test.ts b/src/commands/contract/test.ts index 216eb6b0..29bd6e6a 100644 --- a/src/commands/contract/test.ts +++ b/src/commands/contract/test.ts @@ -3,11 +3,13 @@ import { Flags, Args } from "@oclif/core"; import path from "node:path"; import { globby } from "globby"; import Mocha from "mocha"; -import { emptyDir } from "fs-extra/esm"; +import { emptyDir, pathExistsSync } from "fs-extra/esm"; import shell from "shelljs"; import { Contract } from "../../lib/contract.js"; import { SwankyCommand } from "../../lib/swankyCommand.js"; -import { ConfigError, FileError, InputError, TestError } from "../../lib/errors.js"; +import { ConfigError, FileError, InputError, ProcessError, TestError } from "../../lib/errors.js"; +import { spawn } from "node:child_process"; +import { configName, Spinner } from "../../lib/index.js"; declare global { var contractTypesPath: string; // eslint-disable-line no-var @@ -20,7 +22,11 @@ export class TestContract extends SwankyCommand { all: Flags.boolean({ default: false, char: "a", - description: "Set all to true to compile all contracts", + description: "Run tests for all contracts", + }), + mocha: Flags.boolean({ + default: false, + description: "Run tests with mocha", }), }; @@ -43,13 +49,13 @@ export class TestContract extends SwankyCommand { ? Object.keys(this.swankyConfig.contracts) : [args.contractName]; - const testDir = path.resolve("tests"); + const spinner = new Spinner(); for (const contractName of contractNames) { const contractRecord = this.swankyConfig.contracts[contractName]; if (!contractRecord) { throw new ConfigError( - `Cannot find a contract named ${args.contractName} in swanky.config.json` + `Cannot find a contract named ${args.contractName} in "${configName()}"` ); } @@ -61,54 +67,119 @@ export class TestContract extends SwankyCommand { ); } - const artifactsCheck = await contract.artifactsExist(); + console.log(`Testing contract: ${contractName}`); - if (!artifactsCheck.result) { - throw new FileError( - `No artifact file found at path: ${artifactsCheck.missingPaths.toString()}` + if (!flags.mocha) { + await spinner.runCommand( + async () => { + return new Promise((resolve, reject) => { + const compileArgs = [ + "test", + "--features", + "e2e-tests", + "--manifest-path", + `contracts/${contractName}/Cargo.toml`, + "--release" + ]; + + const compile = spawn("cargo", compileArgs); + this.logger.info(`Running e2e-tests command: [${JSON.stringify(compile.spawnargs)}]`); + let outputBuffer = ""; + let errorBuffer = ""; + + compile.stdout.on("data", (data) => { + outputBuffer += data.toString(); + spinner.ora.clear(); + }); + compile.stdout.pipe(process.stdout); + + compile.stderr.on("data", (data) => { + errorBuffer += data; + }); + + compile.on("exit", (code) => { + if (code === 0) { + const regex = /test result: (.*)/; + const match = outputBuffer.match(regex); + if (match) { + this.logger.info(`Contract ${contractName} e2e-testing done.`); + resolve(match[1]); + } + } else { + reject(new ProcessError(errorBuffer)); + } + }); + }); + }, + `Testing ${contractName} contract`, + `${contractName} testing finished successfully` ); - } + } else { - console.log(`Testing contract: ${contractName}`); + const testDir = path.resolve("tests"); - const reportDir = path.resolve(testDir, contract.name, "testReports"); - - await emptyDir(reportDir); - - const mocha = new Mocha({ - timeout: 200000, - reporter: "mochawesome", - reporterOptions: { - reportDir, - charts: true, - reportTitle: `${contractName} test report`, - quiet: true, - json: false, - }, - }); - - const tests = await globby(`${path.resolve(testDir, contractName)}/*.test.ts`); - - tests.forEach((test) => { - mocha.addFile(test); - }); - - global.contractTypesPath = path.resolve(testDir, contractName, "typedContract"); - - shell.cd(`${testDir}/${contractName}`); - try { - await new Promise((resolve, reject) => { - mocha.run((failures) => { - if (failures) { - reject(`At least one of the tests failed. Check report for details: ${reportDir}`); - } else { - this.log(`All tests passing. Check the report for details: ${reportDir}`); - resolve(); - } - }); + if (!pathExistsSync(testDir)) { + throw new FileError(`Tests folder does not exist: ${testDir}`); + } + + const artifactsCheck = await contract.artifactsExist(); + + if (!artifactsCheck.result) { + throw new FileError( + `No artifact file found at path: ${artifactsCheck.missingPaths.toString()}` + ); + } + + const artifactPath = path.resolve("typedContracts", `${contractName}`); + const typedContractCheck = await contract.typedContractExists(contractName); + + this.log(`artifactPath: ${artifactPath}`); + + if (!typedContractCheck.result) { + throw new FileError( + `No typed contract found at path: ${typedContractCheck.missingPaths.toString()}` + ); + } + + const reportDir = path.resolve(testDir, contract.name, "testReports"); + + await emptyDir(reportDir); + + const mocha = new Mocha({ + timeout: 200000, + reporter: "mochawesome", + reporterOptions: { + reportDir, + charts: true, + reportTitle: `${contractName} test report`, + quiet: true, + json: false, + }, }); - } catch (cause) { - throw new TestError("Error in test", { cause }); + + const tests = await globby(`${path.resolve(testDir, contractName)}/*.test.ts`); + + tests.forEach((test) => { + mocha.addFile(test); + }); + + global.contractTypesPath = path.resolve(testDir, contractName, "typedContract"); + + shell.cd(`${testDir}/${contractName}`); + try { + await new Promise((resolve, reject) => { + mocha.run((failures) => { + if (failures) { + reject(`At least one of the tests failed. Check report for details: ${reportDir}`); + } else { + this.log(`All tests passing. Check the report for details: ${reportDir}`); + resolve(); + } + }); + }); + } catch (cause) { + throw new TestError("Error in test", { cause }); + } } } } diff --git a/src/commands/contract/tx.ts b/src/commands/contract/tx.ts index b7153789..dbe13ce0 100644 --- a/src/commands/contract/tx.ts +++ b/src/commands/contract/tx.ts @@ -12,11 +12,7 @@ export class Tx extends ContractCall { char: "d", description: "Do a dry run, without signing the transaction", }), - account: Flags.string({ - required: true, - char: "a", - description: "Account to sign the transaction with", - }), + ...ContractCall.callFlags, }; static args = { ...ContractCall.callArgs }; diff --git a/src/commands/contract/verify.ts b/src/commands/contract/verify.ts new file mode 100644 index 00000000..a0d4b85f --- /dev/null +++ b/src/commands/contract/verify.ts @@ -0,0 +1,135 @@ +import { Args, Flags } from "@oclif/core"; +import path from "node:path"; +import { ensureCargoContractVersionCompatibility, extractCargoContractVersion, getSwankyConfig, Spinner } from "../../lib/index.js"; +import { pathExists } from "fs-extra/esm"; +import { SwankyCommand } from "../../lib/swankyCommand.js"; +import { ConfigError, InputError, ProcessError } from "../../lib/errors.js"; +import { spawn } from "node:child_process"; +import { ConfigBuilder } from "../../lib/config-builder.js"; +import { BuildData, SwankyConfig } from "../../index.js"; + +export class VerifyContract extends SwankyCommand { + static description = "Verify the smart contract(s) in your contracts directory"; + + static flags = { + all: Flags.boolean({ + default: false, + char: "a", + description: "Set all to true to verify all contracts", + }), + }; + + static args = { + contractName: Args.string({ + name: "contractName", + required: false, + default: "", + description: "Name of the contract to verify", + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(VerifyContract); + + const localConfig = getSwankyConfig("local") as SwankyConfig; + + const cargoContractVersion = extractCargoContractVersion(); + if (cargoContractVersion === null) + throw new InputError( + `Cargo contract tool is required for verifiable mode. Please ensure it is installed.` + ); + + ensureCargoContractVersionCompatibility(cargoContractVersion, "4.0.0", [ + "4.0.0-alpha", + ]); + + if (args.contractName === undefined && !flags.all) { + throw new InputError("No contracts were selected to verify", { winston: { stack: true } }); + } + + const contractNames = flags.all + ? Object.keys(this.swankyConfig.contracts) + : [args.contractName]; + + const spinner = new Spinner(); + + for (const contractName of contractNames) { + this.logger.info(`Started compiling contract [${contractName}]`); + const contractInfo = this.swankyConfig.contracts[contractName]; + if (!contractInfo) { + throw new ConfigError( + `Cannot find contract info for ${contractName} contract in swanky.config.json` + ); + } + const contractPath = path.resolve("contracts", contractInfo.name); + this.logger.info(`"Looking for contract ${contractInfo.name} in path: [${contractPath}]`); + if (!(await pathExists(contractPath))) { + throw new InputError(`Contract folder not found at expected path`); + } + + if(!contractInfo.build) { + throw new InputError(`Contract ${contractName} is not compiled. Please compile it first`); + } + + await spinner.runCommand( + async () => { + return new Promise((resolve, reject) => { + if(contractInfo.build!.isVerified) { + this.logger.info(`Contract ${contractName} is already verified`); + resolve(true); + } + const compileArgs = [ + "contract", + "verify", + `artifacts/${contractName}/${contractName}.contract`, + "--manifest-path", + `contracts/${contractName}/Cargo.toml`, + ]; + const compile = spawn("cargo", compileArgs); + this.logger.info(`Running verify command: [${JSON.stringify(compile.spawnargs)}]`); + let outputBuffer = ""; + let errorBuffer = ""; + + compile.stdout.on("data", (data) => { + outputBuffer += data.toString(); + spinner.ora.clear(); + }); + + compile.stderr.on("data", (data) => { + errorBuffer += data; + }); + + compile.on("exit", (code) => { + if (code === 0) { + const regex = /Successfully verified contract (.*) against reference contract (.*)/; + const match = outputBuffer.match(regex); + if (match) { + this.logger.info(`Contract ${contractName} verification done.`); + resolve(true); + } + } else { + reject(new ProcessError(errorBuffer)); + } + }); + }); + }, + `Verifying ${contractName} contract`, + `${contractName} Contract verified successfully` + ); + + await this.spinner.runCommand(async () => { + const buildData = { + ...contractInfo.build, + isVerified: true + } as BuildData; + + const newLocalConfig = new ConfigBuilder(localConfig) + .addContractBuild(args.contractName, buildData) + .build(); + + await this.storeConfig(newLocalConfig, "local"); + }, "Writing config"); + + } + } +} diff --git a/src/commands/frontend/start.ts b/src/commands/frontend/start.ts new file mode 100644 index 00000000..67841bc9 --- /dev/null +++ b/src/commands/frontend/start.ts @@ -0,0 +1,28 @@ +import { SwankyCommand } from "../../lib/swankyCommand.js"; +import path from "node:path"; +import { pathExistsSync } from "fs-extra/esm"; +import { execaCommand } from "execa"; + + +export class StartFrontend extends SwankyCommand { + static description = "Start Frontend"; + + async run(): Promise { + const projectPath = path.resolve(); + + const frontendPath = path.resolve(projectPath, "frontend") + + if (!pathExistsSync(frontendPath)) { + this.error("Frontend has not initialized"); + } + + await execaCommand( + `pnpm run dev`, + { + stdio: "inherit", + } + ); + + this.log("Frontend dev server started successfully"); + } +} \ No newline at end of file diff --git a/src/commands/generate/tests.ts b/src/commands/generate/tests.ts new file mode 100644 index 00000000..09af4ead --- /dev/null +++ b/src/commands/generate/tests.ts @@ -0,0 +1,162 @@ +import { Args, Flags } from "@oclif/core"; +import { getTemplates, prepareTestFiles, processTemplates } from "../../lib/index.js"; +import { Contract } from "../../lib/contract.js"; +import { SwankyCommand } from "../../lib/swankyCommand.js"; +import { ConfigError, FileError, InputError } from "../../lib/errors.js"; +import path from "node:path"; +import { existsSync } from "node:fs"; +import inquirer from "inquirer"; +import { paramCase, pascalCase } from "change-case"; +import { TestType } from "../../index.js"; + +export class GenerateTests extends SwankyCommand { + static description = "Generate test files for the specified contract"; + + static args = { + contractName: Args.string({ + name: "contractName", + required: false, + description: "Name of the contract", + }), + }; + + static flags = { + template: Flags.string({ + options: getTemplates().contractTemplatesList, + }), + mocha: Flags.boolean({ + default: false, + description: "Generate mocha test files", + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(GenerateTests); + + if (flags.mocha) { + if (!args.contractName) { + throw new InputError("The 'contractName' argument is required to generate mocha tests."); + } + + await this.checkContract(args.contractName) + } + + const testType: TestType = flags.mocha ? "mocha" : "e2e"; + const testsFolderPath = path.resolve("tests"); + const testPath = this.getTestPath(testType, testsFolderPath, args.contractName); + + const templates = getTemplates(); + const templateName = await this.resolveTemplateName(flags, templates.contractTemplatesList); + + const overwrite = await this.checkOverwrite(testPath, testType, args.contractName); + if (!overwrite) return; + + await this.generateTests( + testType, + templates.templatesPath, + process.cwd(), + args.contractName, + templateName + ); + } + + async checkContract(name: string) { + const contractRecord = this.swankyConfig.contracts[name]; + if (!contractRecord) { + throw new ConfigError( + `Cannot find a contract named ${name} in swanky.config.json` + ); + } + + const contract = new Contract(contractRecord); + if (!(await contract.pathExists())) { + throw new FileError( + `Path to contract ${name} does not exist: ${contract.contractPath}` + ); + } + + const artifactsCheck = await contract.artifactsExist(); + if (!artifactsCheck.result) { + throw new FileError( + `No artifact file found at path: ${artifactsCheck.missingPaths.toString()}` + ); + } + } + + async checkOverwrite( + testPath: string, + testType: TestType, + contractName?: string + ): Promise { + if (!existsSync(testPath)) return true; // No need to overwrite + const message = + testType === "e2e" + ? "Test helpers already exist. Overwrite?" + : `Mocha tests for ${contractName} already exist. Overwrite?`; + + const { overwrite } = await inquirer.prompt({ + type: "confirm", + name: "overwrite", + message, + default: false, + }); + + return overwrite; + } + + getTestPath(testType: TestType, testsPath: string, contractName?: string): string { + if (testType === "e2e") { + return path.resolve(testsPath, "test_helpers"); + } else if (testType === "mocha" && contractName) { + return path.resolve(testsPath, contractName, "index.test.ts"); + } else { + throw new InputError("The 'contractName' argument is required to generate mocha tests."); + } + } + + async resolveTemplateName(flags: any, templates: any): Promise { + if (flags.mocha && !flags.template) { + if (!templates?.length) throw new ConfigError("Template list is empty!"); + const response = await inquirer.prompt([ + { + type: "list", + name: "template", + message: "Choose a contract template:", + choices: templates, + }, + ]); + return response.template; + } + return flags.template; + } + + async generateTests( + testType: TestType, + templatesPath: string, + projectPath: string, + contractName?: string, + templateName?: string + ): Promise { + if (testType === "e2e") { + await this.spinner.runCommand( + () => prepareTestFiles("e2e", templatesPath, projectPath), + "Generating e2e test helpers" + ); + } else { + await this.spinner.runCommand( + () => prepareTestFiles("mocha", templatesPath, projectPath, templateName, contractName), + `Generating tests for ${contractName} with mocha` + ); + } + await this.spinner.runCommand( + () => + processTemplates(projectPath, { + project_name: paramCase(this.config.pjson.name), + swanky_version: this.config.pjson.version, + contract_name: contractName ?? "", + contract_name_pascal: contractName ? pascalCase(contractName) : "", + }), + "Processing templates" + ); + } +} diff --git a/src/commands/contract/typegen.ts b/src/commands/generate/types.ts similarity index 78% rename from src/commands/contract/typegen.ts rename to src/commands/generate/types.ts index 391e0a3c..a6c0ed24 100644 --- a/src/commands/contract/typegen.ts +++ b/src/commands/generate/types.ts @@ -1,10 +1,10 @@ import { Args } from "@oclif/core"; -import { generateTypes } from "../../lib/index.js"; +import { configName, generateTypes } from "../../lib/index.js"; import { Contract } from "../../lib/contract.js"; import { SwankyCommand } from "../../lib/swankyCommand.js"; import { ConfigError, FileError } from "../../lib/errors.js"; -export class TypegenCommand extends SwankyCommand { +export class GenerateTypes extends SwankyCommand { static description = "Generate types from compiled contract metadata"; static args = { @@ -16,12 +16,12 @@ export class TypegenCommand extends SwankyCommand { }; async run(): Promise { - const { args } = await this.parse(TypegenCommand); + const { args } = await this.parse(GenerateTypes); const contractRecord = this.swankyConfig.contracts[args.contractName]; if (!contractRecord) { throw new ConfigError( - `Cannot find a contract named ${args.contractName} in swanky.config.json` + `Cannot find a contract named ${args.contractName} in "${configName()}"`, ); } @@ -29,7 +29,7 @@ export class TypegenCommand extends SwankyCommand { if (!(await contract.pathExists())) { throw new FileError( - `Path to contract ${args.contractName} does not exist: ${contract.contractPath}` + `Path to contract ${args.contractName} does not exist: ${contract.contractPath}`, ); } @@ -37,7 +37,7 @@ export class TypegenCommand extends SwankyCommand { if (!artifactsCheck.result) { throw new FileError( - `No artifact file found at path: ${artifactsCheck.missingPaths.toString()}` + `No artifact file found at path: ${artifactsCheck.missingPaths.toString()}`, ); } diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index 45177372..f0552cc3 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -1,36 +1,33 @@ import { Args, Flags } from "@oclif/core"; import path from "node:path"; -import { ensureDir, writeJSON, pathExists, copy, outputFile, readJSON, remove } from "fs-extra/esm"; -import { stat, readdir, readFile } from "fs/promises"; +import { copy, ensureDir, outputFile, pathExists, pathExistsSync, readJSON, remove, writeJSON } from "fs-extra/esm"; +import { readdir, readFile, stat } from "fs/promises"; import { execaCommand, execaCommandSync } from "execa"; import { paramCase, pascalCase, snakeCase } from "change-case"; import inquirer from "inquirer"; import TOML from "@iarna/toml"; -import { choice, email, name, pickTemplate } from "../../lib/prompts.js"; +import { choice, email, name, pickNodeVersion, pickTemplate } from "../../lib/prompts.js"; import { + addFrontendWorkspace, + buildSwankyConfig, checkCliDependencies, copyCommonTemplateFiles, - copyContractTemplateFiles, + copyContractTemplateFiles, copyFrontendTemplateFiles, downloadNode, + getTemplates, installDeps, - ChainAccount, + prepareTestFiles, processTemplates, - swankyNode, - getTemplates, + swankyNodeVersions, } from "../../lib/index.js"; -import { - DEFAULT_ASTAR_NETWORK_URL, - DEFAULT_NETWORK_URL, - DEFAULT_SHIBUYA_NETWORK_URL, - DEFAULT_SHIDEN_NETWORK_URL, -} from "../../lib/consts.js"; import { SwankyCommand } from "../../lib/swankyCommand.js"; import { InputError, UnknownError } from "../../lib/errors.js"; -import { GlobEntry, globby } from "globby"; +import { globby, GlobEntry } from "globby"; import { merge } from "lodash-es"; import inquirerFuzzyPath from "inquirer-fuzzy-path"; -import { SwankyConfig } from "../../types/index.js"; import chalk from "chalk"; +import { ConfigBuilder } from "../../lib/config-builder.js"; +import { DEFAULT_NODE_INFO } from "../../lib/consts.js"; type TaskFunction = (...args: any[]) => any; @@ -91,26 +88,13 @@ export class Init extends SwankyCommand { super(argv, config); (this.constructor as typeof SwankyCommand).ENSURE_SWANKY_CONFIG = false; } - projectPath = ""; - configBuilder: Partial = { - node: { - localPath: "", - polkadotPalletVersions: swankyNode.polkadotPalletVersions, - supportedInk: swankyNode.supportedInk, - }, - accounts: [], - networks: { - local: { url: DEFAULT_NETWORK_URL }, - astar: { url: DEFAULT_ASTAR_NETWORK_URL }, - shiden: { url: DEFAULT_SHIDEN_NETWORK_URL }, - shibuya: { url: DEFAULT_SHIBUYA_NETWORK_URL }, - }, - contracts: {}, - }; + projectPath = ""; taskQueue: Task[] = []; + configBuilder = new ConfigBuilder(buildSwankyConfig()); + async run(): Promise { const { args, flags } = await this.parse(Init); @@ -161,43 +145,37 @@ export class Init extends SwankyCommand { choice("useSwankyNode", "Do you want to download Swanky node?"), ]); if (useSwankyNode) { + const versions = Array.from(swankyNodeVersions.keys()); + let nodeVersion = DEFAULT_NODE_INFO.version; + await inquirer.prompt([ + pickNodeVersion(versions), + ]).then((answers) => { + nodeVersion = answers.version; + }); + + const nodeInfo = swankyNodeVersions.get(nodeVersion)!; + this.taskQueue.push({ task: downloadNode, - args: [this.projectPath, swankyNode, this.spinner], + args: [this.projectPath, nodeInfo, this.spinner], runningMessage: "Downloading Swanky node", - callback: (result) => - this.configBuilder.node ? (this.configBuilder.node.localPath = result) : null, + callback: (localPath) => this.configBuilder.updateNodeSettings({ supportedInk: nodeInfo.supportedInk, + polkadotPalletVersions: nodeInfo.polkadotPalletVersions, + version: nodeInfo.version, localPath }), }); } } - this.configBuilder.accounts = [ - { - alias: "alice", - mnemonic: "//Alice", - isDev: true, - address: new ChainAccount("//Alice").pair.address, - }, - { - alias: "bob", - mnemonic: "//Bob", - isDev: true, - address: new ChainAccount("//Bob").pair.address, - }, - ]; - - Object.keys(this.configBuilder.contracts!).forEach(async (contractName) => { + Object.keys(this.swankyConfig.contracts).forEach(async (contractName) => { await ensureDir(path.resolve(this.projectPath, "artifacts", contractName)); - await ensureDir(path.resolve(this.projectPath, "tests", contractName)); }); this.taskQueue.push({ - task: () => - writeJSON(path.resolve(this.projectPath, "swanky.config.json"), this.configBuilder, { - spaces: 2, - }), + task: async () => + await this.storeConfig(this.configBuilder.build(), "local", this.projectPath), args: [], runningMessage: "Writing config", + shouldExitOnError: true, }); for (const { @@ -214,13 +192,21 @@ export class Init extends SwankyCommand { runningMessage, successMessage, failMessage, - shouldExitOnError + shouldExitOnError, ); if (result && callback) { callback(result as string); } } - this.log("🎉 😎 Swanky project successfully initialised! 😎 🎉"); + this.log("🎉 😎 Swanky project successfully initialized! 😎 🎉"); + this.log("\n🚀 Next steps: "); + this.log("🛠️ Build a contract: swanky contract build "); + this.log("🌐 Start a node: swanky node start"); + this.log("📦 Deploy a contract: swanky contract deploy --args "); + this.log("📜 Generate types: swanky generate types "); + if (pathExistsSync(path.resolve(this.projectPath, "frontend"))) { + this.log("🖥️ Start a frontend dev server: swanky frontend start"); + } } async generate(projectName: string) { @@ -269,6 +255,29 @@ export class Init extends SwankyCommand { runningMessage: "Copying contract template files", }); + if (contractTemplate === "psp22") { + this.taskQueue.push({ + task: prepareTestFiles, + args: ["e2e", path.resolve(templates.templatesPath), this.projectPath], + runningMessage: "Copying test helpers", + }); + } + + let addFrontendTemplate = false; + if(contractTemplate === "flipper") { + addFrontendTemplate = (await inquirer.prompt([ + choice("addFrontendTemplate", "Do you want to add frontend to your project?"), + ])).addFrontendTemplate; + if (addFrontendTemplate) { + const templatesPath = getTemplates().templatesPath; + this.taskQueue.push({ + task: copyFrontendTemplateFiles, + args: [ templatesPath, this.projectPath ], + runningMessage: "Copying frontend template files", + }); + } + } + this.taskQueue.push({ task: processTemplates, args: [ @@ -286,13 +295,21 @@ export class Init extends SwankyCommand { runningMessage: "Processing templates", }); - this.configBuilder.contracts = { + if (addFrontendTemplate) { + this.taskQueue.push({ + task: addFrontendWorkspace, + args: [this.projectPath], + runningMessage: "Adding frontend workspace to configuration", + }); + } + + this.configBuilder.updateContracts( { [contractName as string]: { name: contractName, moduleName: snakeCase(contractName), deployments: [], }, - }; + }); } async convert(pathToExistingProject: string, projectName: string) { @@ -306,7 +323,7 @@ export class Init extends SwankyCommand { } catch (cause) { throw new InputError( `Error reading target directory [${chalk.yellowBright(pathToExistingProject)}]`, - { cause } + { cause }, ); } @@ -326,7 +343,7 @@ export class Init extends SwankyCommand { const candidatesList: CopyCandidates = await getCopyCandidatesList( pathToExistingProject, - copyGlobsList + copyGlobsList, ); const testDir = await detectTests(pathToExistingProject); @@ -362,14 +379,8 @@ export class Init extends SwankyCommand { }, }); - if (!this.configBuilder.contracts) this.configBuilder.contracts = {}; - for (const contract of confirmedCopyList.contracts) { - this.configBuilder.contracts[contract.name] = { - name: contract.name, - moduleName: contract.moduleName!, - deployments: [], - }; + this.configBuilder.addContract(contract.name, contract.moduleName); } let rootToml = await readRootCargoToml(pathToExistingProject); @@ -435,7 +446,7 @@ async function detectModuleNames(copyList: CopyCandidates): Promise { resultingList[item.group]?.push(item); - } + }, ); return resultingList; } @@ -524,7 +535,7 @@ async function detectTests(pathToExistingProject: string): Promise { const { selectedDirectory } = await inquirer.prompt([ { @@ -595,7 +606,7 @@ async function getCopyCandidatesList( pathsToCopy: { contractsDirectories: string[]; cratesDirectories: string[]; - } + }, ) { const detectedPaths = { contracts: await getDirsAndFiles(projectPath, pathsToCopy.contractsDirectories), @@ -616,7 +627,7 @@ async function getGlobPaths(projectPath: string, globList: string[], isDirOnly: onlyDirectories: isDirOnly, deep: 1, objectMode: true, - } + }, ); } diff --git a/src/commands/node/install.ts b/src/commands/node/install.ts index 8d9beb5e..9af510e1 100644 --- a/src/commands/node/install.ts +++ b/src/commands/node/install.ts @@ -1,48 +1,73 @@ import { SwankyCommand } from "../../lib/swankyCommand.js"; -import { ux } from "@oclif/core"; -import { downloadNode, swankyNode } from "../../lib/index.js"; +import { Flags } from "@oclif/core"; +import { downloadNode, getSwankyConfig, swankyNodeVersions } from "../../lib/index.js"; import path from "node:path"; -import { writeJSON } from "fs-extra/esm"; +import inquirer from "inquirer"; +import { ConfigBuilder } from "../../lib/config-builder.js"; +import { DEFAULT_NODE_INFO } from "../../lib/consts.js"; +import { choice, pickNodeVersion } from "../../lib/prompts.js"; +import { InputError } from "../../lib/errors.js"; export class InstallNode extends SwankyCommand { static description = "Install swanky node binary"; + static flags = { + "set-version": Flags.string({ + description: "Specify version of swanky node to install. \n List of supported versions: " + Array.from(swankyNodeVersions.keys()).join(", "), + required: false, + }), + } async run(): Promise { const { flags } = await this.parse(InstallNode); if (flags.verbose) { this.spinner.verbose = true; } + let nodeVersion= DEFAULT_NODE_INFO.version; + + if (flags["set-version"]) { + nodeVersion = flags["set-version"]; + if(!swankyNodeVersions.has(nodeVersion)) { + throw new InputError(`Version ${nodeVersion} is not supported.\n List of supported versions: ${Array.from(swankyNodeVersions.keys()).join(", ")}`); + } + } else { + const versions = Array.from(swankyNodeVersions.keys()); + await inquirer.prompt([ + pickNodeVersion(versions), + ]).then((answers) => { + nodeVersion = answers.version; + }); + } const projectPath = path.resolve(); if (this.swankyConfig.node.localPath !== "") { - const overwrite = await ux.confirm( - "Swanky node already installed. Do you want to overwrite it? (y/n)" - ); + const { overwrite } =await inquirer.prompt([ + choice("overwrite", "Swanky node already installed. Do you want to overwrite it?"), + ]); if (!overwrite) { return; } } + const nodeInfo = swankyNodeVersions.get(nodeVersion)!; + const taskResult = (await this.spinner.runCommand( - () => downloadNode(projectPath, swankyNode, this.spinner), + () => downloadNode(projectPath, nodeInfo, this.spinner), "Downloading Swanky node" )) as string; - const nodePath = path.relative(projectPath, taskResult); - - this.swankyConfig.node = { - localPath: nodePath, - polkadotPalletVersions: swankyNode.polkadotPalletVersions, - supportedInk: swankyNode.supportedInk, - }; - - await this.spinner.runCommand( - () => - writeJSON(path.resolve(projectPath, "swanky.config.json"), this.swankyConfig, { - spaces: 2, - }), - "Updating swanky config" - ); + const nodePath = path.resolve(projectPath, taskResult); + + await this.spinner.runCommand(async () => { + const newLocalConfig = new ConfigBuilder(getSwankyConfig("local")) + .updateNodeSettings({ + localPath: nodePath, + polkadotPalletVersions: nodeInfo.polkadotPalletVersions, + supportedInk: nodeInfo.supportedInk, + version: nodeInfo.version, + }) + .build(); + await this.storeConfig(newLocalConfig, "local"); + }, "Updating swanky config"); this.log("Swanky Node Installed successfully"); } diff --git a/src/commands/node/start.ts b/src/commands/node/start.ts index cb976870..53a85590 100644 --- a/src/commands/node/start.ts +++ b/src/commands/node/start.ts @@ -1,6 +1,7 @@ import { Flags } from "@oclif/core"; import { execaCommand } from "execa"; import { SwankyCommand } from "../../lib/swankyCommand.js"; +import semver from "semver"; export class StartNode extends SwankyCommand { static description = "Start a local node"; @@ -28,11 +29,14 @@ export class StartNode extends SwankyCommand { async run(): Promise { const { flags } = await this.parse(StartNode); + if(this.swankyConfig.node.localPath === "") { + this.error("Swanky node is not installed. Please run `swanky node:install` first."); + } // Run persistent mode by default. non-persistent mode in case flag is provided. // Non-Persistent mode (`--dev`) allows all CORS origin, without `--dev`, users need to specify origins by `--rpc-cors`. await execaCommand( `${this.swankyConfig.node.localPath} \ - --finalize-delay-sec ${flags.finalizeDelaySec} \ + ${ semver.gte(this.swankyConfig.node.version, "1.6.0") ? `--finalize-delay-sec ${flags.finalizeDelaySec}` : ""} \ ${flags.tmp ? "--dev" : `--rpc-cors ${flags.rpcCors}`}`, { stdio: "inherit", diff --git a/src/commands/node/version.ts b/src/commands/node/version.ts new file mode 100644 index 00000000..a6937d09 --- /dev/null +++ b/src/commands/node/version.ts @@ -0,0 +1,12 @@ +import { SwankyCommand } from "../../lib/swankyCommand.js"; +export class NodeVersion extends SwankyCommand { + static description = "Show swanky node version"; + async run(): Promise { + if(this.swankyConfig.node.version === ""){ + this.log("Swanky node is not installed"); + } + else { + this.log(`Swanky node version: ${this.swankyConfig.node.version}`); + } + } +} diff --git a/src/commands/zombienet/init.ts b/src/commands/zombienet/init.ts new file mode 100644 index 00000000..cea138f8 --- /dev/null +++ b/src/commands/zombienet/init.ts @@ -0,0 +1,105 @@ +import path from "node:path"; +import { Flags } from "@oclif/core"; +import { SwankyCommand } from "../../lib/swankyCommand.js"; +import { + buildZombienetConfigFromBinaries, + copyZombienetTemplateFile, + downloadZombienetBinaries, + getSwankyConfig, + getTemplates, + osCheck, + Spinner, +} from "../../lib/index.js"; +import { pathExistsSync } from "fs-extra/esm"; +import { zombienet, zombienetBinariesList } from "../../lib/zombienetInfo.js"; +import { ConfigBuilder } from "../../lib/config-builder.js"; +import { SwankyConfig, ZombienetData } from "../../index.js"; + +export const zombienetConfig = "zombienet.config.toml"; + +export class InitZombienet extends SwankyCommand { + static description = "Initialize Zombienet"; + + static flags = { + binaries: Flags.string({ + char: "b", + multiple: true, + required: false, + options: zombienetBinariesList, + default: [], + description: "Binaries to install", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(InitZombienet); + + const localConfig = getSwankyConfig("local") as SwankyConfig; + + const platform = osCheck().platform; + if (platform === "darwin") { + this.warn(`Note for MacOs users: Polkadot binary is not currently supported for MacOs. +As a result users of MacOS need to clone the Polkadot repo (https://github.com/paritytech/polkadot), create a release and add it in your PATH manually (setup will advice you so as well). Check the official zombienet documentation for manual settings: https://paritytech.github.io/zombienet/.`); + } + + const projectPath = path.resolve(); + if (pathExistsSync(path.resolve(projectPath, "zombienet", "bin", "zombienet"))) { + this.error("Zombienet config already initialized"); + } + + const spinner = new Spinner(flags.verbose); + + const zombienetData: ZombienetData = { + version: zombienet.version, + downloadUrl: zombienet.downloadUrl, + binaries: {}, + }; + + if (!flags.binaries.includes("polkadot")) { + flags.binaries.push("polkadot"); + } + + for (const binaryName of flags.binaries) { + if (platform === "darwin" && binaryName.startsWith("polkadot")) { + continue; + } + if (!Object.keys(zombienet.binaries).includes(binaryName)) { + this.error(`Binary ${binaryName} not found in Zombienet config`); + } + zombienetData.binaries[binaryName] = zombienet.binaries[binaryName as keyof typeof zombienet.binaries]; + } + + await this.spinner.runCommand(async () => { + const newLocalConfig = new ConfigBuilder(localConfig) + .addZombienet(zombienetData) + .build(); + await this.storeConfig(newLocalConfig, "local"); + }, "Writing config"); + + const zombienetTemplatePath = getTemplates().zombienetTemplatesPath; + + const configPath = path.resolve(projectPath, "zombienet", "config"); + + if (flags.binaries.length === 1 && flags.binaries[0] === "polkadot") { + await spinner.runCommand( + () => + copyZombienetTemplateFile(zombienetTemplatePath, configPath), + "Copying template files", + ); + } else { + await spinner.runCommand( + () => buildZombienetConfigFromBinaries(flags.binaries, zombienetTemplatePath, configPath), + "Copying template files", + ); + } + + // Install binaries based on zombie config + await this.spinner.runCommand( + () => downloadZombienetBinaries(flags.binaries, projectPath, localConfig, this.spinner), + "Downloading Zombienet binaries", + ); + + this.log("ZombieNet config Installed successfully"); + } +} + diff --git a/src/commands/zombienet/start.ts b/src/commands/zombienet/start.ts new file mode 100644 index 00000000..72f5b221 --- /dev/null +++ b/src/commands/zombienet/start.ts @@ -0,0 +1,40 @@ +import { SwankyCommand } from "../../lib/swankyCommand.js"; +import path from "node:path"; +import { pathExistsSync } from "fs-extra/esm"; +import { execaCommand } from "execa"; +import { Flags } from "@oclif/core"; + + +export class StartZombienet extends SwankyCommand { + static description = "Start Zombienet"; + + static flags = { + "config-path": Flags.string({ + char: "c", + required: false, + default: "./zombienet/config/zombienet.config.toml", + description: "Path to zombienet config", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(StartZombienet); + const projectPath = path.resolve(); + const binPath = path.resolve(projectPath, "zombienet", "bin") + if (!pathExistsSync(path.resolve(binPath, "zombienet"))) { + this.error("Zombienet has not initialized. Run `swanky zombienet:init` first"); + } + + await execaCommand( + `./zombienet/bin/zombienet \ + spawn --provider native \ + ${flags["config-path"]} + `, + { + stdio: "inherit", + } + ); + + this.log("ZombieNet started successfully"); + } +} \ No newline at end of file diff --git a/src/lib/account.ts b/src/lib/account.ts index 04897451..dc529cb0 100644 --- a/src/lib/account.ts +++ b/src/lib/account.ts @@ -2,6 +2,7 @@ import { mnemonicGenerate } from "@polkadot/util-crypto"; import { Keyring } from "@polkadot/keyring"; import { KeyringPair } from "@polkadot/keyring/types"; import { ChainProperty, KeypairType } from "../types/index.js"; +import { KEYPAIR_TYPE } from "./consts.js"; interface IChainAccount { pair: KeyringPair; @@ -17,7 +18,7 @@ export class ChainAccount implements IChainAccount { return mnemonicGenerate(); } - constructor(mnemonic: string, type: KeypairType = "sr25519") { + constructor(mnemonic: string, type: KeypairType = KEYPAIR_TYPE) { this._keyringType = type; this._keyring = new Keyring({ type: type }); this._mnemonic = mnemonic; diff --git a/src/lib/cargoContractInfo.ts b/src/lib/cargoContractInfo.ts new file mode 100644 index 00000000..eafdd57d --- /dev/null +++ b/src/lib/cargoContractInfo.ts @@ -0,0 +1,13 @@ +export interface CargoContractInkDependency { + minCargoContractVersion: string; + validInkVersionRange: string; +} + +// Keep cargo-contract versions in descending order +// Ranges are supported by semver +export const CARGO_CONTRACT_INK_DEPS: CargoContractInkDependency[] = [ + { minCargoContractVersion: "4.0.0", validInkVersionRange: "<99.0.0" }, // Non-max version known yet: a very high version is used as fallback in the meantime + { minCargoContractVersion: "2.2.0", validInkVersionRange: "<5.0.0" }, + { minCargoContractVersion: "2.0.2", validInkVersionRange: "<4.2.0" }, + { minCargoContractVersion: "2.0.0", validInkVersionRange: "<4.0.1" }, +]; \ No newline at end of file diff --git a/src/lib/command-utils.ts b/src/lib/command-utils.ts index 5cffd073..0bf61ce6 100644 --- a/src/lib/command-utils.ts +++ b/src/lib/command-utils.ts @@ -1,26 +1,50 @@ -import { execaCommand } from "execa"; -import { copy, emptyDir, ensureDir, readJSON } from "fs-extra/esm"; +import { execaCommand, execaCommandSync } from "execa"; +import { copy, emptyDir, ensureDir, readJSONSync } from "fs-extra/esm"; import path from "node:path"; -import { DEFAULT_NETWORK_URL, ARTIFACTS_PATH, TYPED_CONTRACTS_PATH } from "./consts.js"; -import { SwankyConfig } from "../types/index.js"; -import { ConfigError, FileError, InputError } from "./errors.js"; +import { + DEFAULT_NETWORK_URL, + ARTIFACTS_PATH, + TYPED_CONTRACTS_PATH, + DEFAULT_SHIBUYA_NETWORK_URL, + DEFAULT_SHIDEN_NETWORK_URL, + DEFAULT_ASTAR_NETWORK_URL, + DEFAULT_ACCOUNT, + DEFAULT_CONFIG_NAME, + DEFAULT_CONFIG_FOLDER_NAME, + DEFAULT_NODE_INFO, +} from "./consts.js"; +import { SwankyConfig, SwankySystemConfig } from "../types/index.js"; +import { ConfigError, FileError } from "./errors.js"; +import { userInfo } from "os"; +import { existsSync } from "fs"; -export async function commandStdoutOrNull(command: string): Promise { +export function commandStdoutOrNull(command: string): string | null { try { - const result = await execaCommand(command); + const result = execaCommandSync(command); return result.stdout; } catch { return null; } } -export async function getSwankyConfig(): Promise { - try { - const config = await readJSON("swanky.config.json"); - return config; - } catch (cause) { - throw new InputError("Error reading swanky.config.json in the current directory!", { cause }); +export function getSwankyConfig(configType: "local" | "global"): SwankyConfig | SwankySystemConfig { + let configPath: string; + + if (configType === "global") { + configPath = getSystemConfigDirectoryPath() + `/${DEFAULT_CONFIG_NAME}`; + } else { + configPath = isEnvConfigCheck() ? process.env.SWANKY_CONFIG! : DEFAULT_CONFIG_NAME; } + + const config = readJSONSync(configPath); + return config; +} + + +export function getSystemConfigDirectoryPath(): string { + const homeDir = userInfo().homedir; + const configPath = homeDir + `/${DEFAULT_CONFIG_FOLDER_NAME}`; + return configPath; } export function resolveNetworkUrl(config: SwankyConfig, networkName: string): string { @@ -117,6 +141,69 @@ export async function generateTypes(contractName: string) { emptyDir(outputPath); await execaCommand( - `npx typechain-polkadot --in ${relativeInputPath} --out ${relativeOutputPath}` + `npx @727-ventures/typechain-polkadot --in ${relativeInputPath} --out ${relativeOutputPath}` ); } +export function ensureAccountIsSet(account: string | undefined, config: SwankyConfig) { + if(!account && config.defaultAccount === null) { + throw new ConfigError("No default account set. Please set one or provide an account alias with --account"); + } +} + +export function buildSwankyConfig() { + return { + node: { + localPath: "", + polkadotPalletVersions: DEFAULT_NODE_INFO.polkadotPalletVersions, + supportedInk: DEFAULT_NODE_INFO.supportedInk, + version: DEFAULT_NODE_INFO.version, + }, + defaultAccount: DEFAULT_ACCOUNT, + accounts: [ + { + "alias": "alice", + "mnemonic": "//Alice", + "isDev": true, + "address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + }, + { + "alias": "bob", + "mnemonic": "//Bob", + "isDev": true, + "address": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + }, + ], + networks: { + local: { url: DEFAULT_NETWORK_URL }, + astar: { url: DEFAULT_ASTAR_NETWORK_URL }, + shiden: { url: DEFAULT_SHIDEN_NETWORK_URL }, + shibuya: { url: DEFAULT_SHIBUYA_NETWORK_URL }, + }, + contracts: {}, + }; +} + +export function isEnvConfigCheck(): boolean { + if (process.env.SWANKY_CONFIG === undefined) { + return false; + } else if (existsSync(process.env.SWANKY_CONFIG)) { + return true; + } else { + throw new ConfigError(`Provided config path ${process.env.SWANKY_CONFIG} does not exist`); + } +} + +export function isLocalConfigCheck(): boolean { + const defaultLocalConfigPath = process.cwd() + `/${DEFAULT_CONFIG_NAME}`; + return process.env.SWANKY_CONFIG === undefined + ? existsSync(defaultLocalConfigPath) + : existsSync(process.env.SWANKY_CONFIG); +} + +export function configName(): string { + if (!isLocalConfigCheck()) { + return DEFAULT_CONFIG_NAME + " [system config]"; + } + + return process.env.SWANKY_CONFIG?.split("/").pop() ?? DEFAULT_CONFIG_NAME; +} \ No newline at end of file diff --git a/src/lib/config-builder.ts b/src/lib/config-builder.ts new file mode 100644 index 00000000..386dd9fd --- /dev/null +++ b/src/lib/config-builder.ts @@ -0,0 +1,77 @@ +import { AccountData, BuildData, DeploymentData, SwankyConfig, SwankySystemConfig, ZombienetData } from "../index.js"; +import { snakeCase } from "change-case"; + +export class ConfigBuilder { + private config: T; + + constructor(existingConfig: T) { + this.config = { ...existingConfig }; + } + + setDefaultAccount(account: string): ConfigBuilder { + this.config.defaultAccount = account; + return this; + } + + addAccount(account: AccountData): ConfigBuilder { + this.config.accounts.push(account); + return this; + } + + updateNetwork(name: string, url: string): ConfigBuilder { + if (this.config.networks?.[name]) { + this.config.networks[name].url = url; + } + return this; + } + + updateNodeSettings(nodeSettings: Partial): ConfigBuilder { + if ("node" in this.config) { + this.config.node = { ...this.config.node, ...nodeSettings }; + } + return this; + } + + updateContracts(contracts: SwankyConfig["contracts"]): ConfigBuilder { + if ("contracts" in this.config) { + this.config.contracts = { ...contracts }; + } + return this; + } + + addContract(name: string, moduleName?: string): ConfigBuilder { + if ("contracts" in this.config) { + this.config.contracts[name] = { + name: name, + moduleName: moduleName ?? snakeCase(name), + deployments: [], + }; + } + return this; + } + + addContractDeployment(name: string, data: DeploymentData): ConfigBuilder { + if ("contracts" in this.config) { + this.config.contracts[name].deployments.push(data); + } + return this; + } + + addContractBuild(name: string, data: BuildData): ConfigBuilder { + if ("contracts" in this.config) { + this.config.contracts[name].build = data; + } + return this; + } + + addZombienet(data: ZombienetData): ConfigBuilder { + if ("zombienet" in this.config) { + this.config.zombienet = data; + } + return this; + } + + build(): T { + return this.config; + } +} diff --git a/src/lib/consts.ts b/src/lib/consts.ts index d48b2b67..8883913b 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -1,7 +1,20 @@ +import { swankyNodeVersions } from "./nodeInfo.js"; + +export const DEFAULT_NODE_INFO = swankyNodeVersions.get("1.6.0")!; + export const DEFAULT_NETWORK_URL = "ws://127.0.0.1:9944"; export const DEFAULT_ASTAR_NETWORK_URL = "wss://rpc.astar.network"; export const DEFAULT_SHIDEN_NETWORK_URL = "wss://rpc.shiden.astar.network"; export const DEFAULT_SHIBUYA_NETWORK_URL = "wss://shibuya.public.blastapi.io"; +export const DEFAULT_ACCOUNT = "alice"; +export const DEFAULT_CONFIG_FOLDER_NAME = "swanky"; +export const DEFAULT_CONFIG_NAME = "swanky.config.json"; + export const ARTIFACTS_PATH = "artifacts"; export const TYPED_CONTRACTS_PATH = "typedContracts"; + +export const LOCAL_FAUCET_AMOUNT = 100; +export const KEYPAIR_TYPE = "sr25519"; +export const ALICE_URI = "//Alice"; +export const BOB_URI = "//Bob"; diff --git a/src/lib/contract.ts b/src/lib/contract.ts index 482c0ae2..62d7f01b 100644 --- a/src/lib/contract.ts +++ b/src/lib/contract.ts @@ -1,5 +1,5 @@ import { AbiType, consts, printContractInfo } from "./index.js"; -import { ContractData, DeploymentData } from "../types/index.js"; +import { BuildMode, ContractData, DeploymentData } from "../types/index.js"; import { pathExists, readJSON } from "fs-extra/esm"; import path from "node:path"; import { FileError } from "./errors.js"; @@ -11,58 +11,65 @@ export class Contract { deployments: DeploymentData[]; contractPath: string; artifactsPath: string; + buildMode?: BuildMode; + constructor(contractRecord: ContractData) { this.name = contractRecord.name; this.moduleName = contractRecord.moduleName; this.deployments = contractRecord.deployments; this.contractPath = path.resolve("contracts", contractRecord.name); this.artifactsPath = path.resolve(consts.ARTIFACTS_PATH, contractRecord.name); + this.buildMode = contractRecord.build?.buildMode; } async pathExists() { return pathExists(this.contractPath); } - async artifactsExist() { - const result: { result: boolean; missingPaths: string[]; missingTypes: string[] } = { - result: true, - missingPaths: [], - missingTypes: [], - }; + + async artifactsExist(): Promise<{ result: boolean; missingPaths: string[] }> { + const missingPaths: string[] = []; + let result = true; + for (const artifactType of Contract.artifactTypes) { const artifactPath = path.resolve(this.artifactsPath, `${this.moduleName}${artifactType}`); - if (!(await pathExists(artifactPath))) { - result.result = false; - result.missingPaths.push(artifactPath); - result.missingTypes.push(artifactType); + result = false; + missingPaths.push(artifactPath); } } + + return { result, missingPaths }; + } + + async typedContractExists(contractName: string) { + const result: { result: boolean; missingPaths: string[] } = { + result: true, + missingPaths: [], + }; + const artifactPath = path.resolve("typedContracts", `${contractName}`); + if(!(await pathExists(artifactPath))) { + result.result = false; + result.missingPaths.push(artifactPath); + } return result; } async getABI(): Promise { - const check = await this.artifactsExist(); - if (!check.result && check.missingTypes.includes(".json")) { - throw new FileError(`Cannot read ABI, path not found: ${check.missingPaths.toString()}`); - } - return readJSON(path.resolve(this.artifactsPath, `${this.moduleName}.json`)); + const jsonArtifactPath = `${this.moduleName}.json`; + await this.ensureArtifactExists(jsonArtifactPath); + return readJSON(path.resolve(this.artifactsPath, jsonArtifactPath)); } async getBundle() { - const check = await this.artifactsExist(); - if (!check.result && check.missingTypes.includes(".contract")) { - throw new FileError( - `Cannot read .contract bundle, path not found: ${check.missingPaths.toString()}` - ); - } - return readJSON(path.resolve(this.artifactsPath, `${this.moduleName}.contract`)); + const contractArtifactPath = `${this.moduleName}.contract`; + await this.ensureArtifactExists(contractArtifactPath); + return readJSON(path.resolve(this.artifactsPath, contractArtifactPath), 'utf8'); } async getWasm(): Promise { const bundle = await this.getBundle(); if (bundle.source?.wasm) return bundle.source.wasm; - throw new FileError(`Cannot find wasm field in the .contract bundle!`); } @@ -70,4 +77,11 @@ export class Contract { const abi = await this.getABI(); printContractInfo(abi); } + + private async ensureArtifactExists(artifactFileName: string): Promise { + const artifactPath = path.resolve(this.artifactsPath, artifactFileName); + if (!(await pathExists(artifactPath))) { + throw new FileError(`Artifact file not found at path: ${artifactPath}`); + } + } } diff --git a/src/lib/contractCall.ts b/src/lib/contractCall.ts index 246e97d0..a65f4894 100644 --- a/src/lib/contractCall.ts +++ b/src/lib/contractCall.ts @@ -1,5 +1,5 @@ -import { AbiType, ChainAccount, ChainApi, decrypt, resolveNetworkUrl } from "./index.js"; -import { AccountData, ContractData, DeploymentData, Encrypted } from "../types/index.js"; +import { AbiType, ChainAccount, ChainApi, configName, ensureAccountIsSet, decrypt, resolveNetworkUrl } from "./index.js"; +import { ContractData, DeploymentData, Encrypted } from "../types/index.js"; import { Args, Command, Flags, Interfaces } from "@oclif/core"; import inquirer from "inquirer"; import chalk from "chalk"; @@ -28,6 +28,14 @@ export abstract class ContractCall extends SwankyComma }), }; + static callFlags = { + network: Flags.string({ + char: "n", + default: "local", + description: "Name of network to connect to", + }), + } + protected flags!: JoinedFlagsType; protected args!: Record; protected contractInfo!: ContractData; @@ -40,11 +48,12 @@ export abstract class ContractCall extends SwankyComma await super.init(); const { flags, args } = await this.parse(this.ctor); this.args = args; + this.flags = flags as JoinedFlagsType; const contractRecord = this.swankyConfig.contracts[args.contractName]; if (!contractRecord) { throw new ConfigError( - `Cannot find a contract named ${args.contractName} in swanky.config.json` + `Cannot find a contract named ${args.contractName} in "${configName()}"`, ); } @@ -52,7 +61,7 @@ export abstract class ContractCall extends SwankyComma if (!(await contract.pathExists())) { throw new FileError( - `Path to contract ${args.contractName} does not exist: ${contract.contractPath}` + `Path to contract ${args.contractName} does not exist: ${contract.contractPath}`, ); } @@ -60,28 +69,30 @@ export abstract class ContractCall extends SwankyComma if (!artifactsCheck.result) { throw new FileError( - `No artifact file found at path: ${artifactsCheck.missingPaths.toString()}` + `No artifact file found at path: ${artifactsCheck.missingPaths.toString()}`, ); } const deploymentData = flags.address ? contract.deployments.find( - (deployment: DeploymentData) => deployment.address === flags.address - ) + (deployment: DeploymentData) => deployment.address === flags.address, + ) : contract.deployments[0]; if (!deploymentData?.address) throw new NetworkError( - `Cannot find a deployment with address: ${flags.address} in swanky.config.json` + `Cannot find a deployment with address: ${flags.address} in "${configName()}"`, ); this.deploymentInfo = deploymentData; - const accountData = this.swankyConfig.accounts.find( - (account: AccountData) => account.alias === flags.account || "alice" - ); - if (!accountData) { - throw new ConfigError("Provided account alias not found in swanky.config.json"); + ensureAccountIsSet(flags.account, this.swankyConfig); + + const accountAlias = flags.account ?? this.swankyConfig.defaultAccount; + const accountData = this.findAccountByAlias(flags.account || "alice"); + + if (accountData.isDev && (flags.network !== "local" || !flags.network)) { + throw new ConfigError(`Account ${chalk.redBright(accountAlias)} is a dev account and can only be used on the local network`); } const networkUrl = resolveNetworkUrl(this.swankyConfig, flags.network ?? ""); @@ -92,17 +103,17 @@ export abstract class ContractCall extends SwankyComma const mnemonic = accountData.isDev ? (accountData.mnemonic as string) : decrypt( - accountData.mnemonic as Encrypted, - ( - await inquirer.prompt([ - { - type: "password", - message: `Enter password for ${chalk.yellowBright(accountData.alias)}: `, - name: "password", - }, - ]) - ).password - ); + accountData.mnemonic as Encrypted, + ( + await inquirer.prompt([ + { + type: "password", + message: `Enter password for ${chalk.yellowBright(accountData.alias)}: `, + name: "password", + }, + ]) + ).password, + ); const account = (await this.spinner.runCommand(async () => { await cryptoWaitReady(); @@ -149,7 +160,7 @@ ContractCall.baseFlags = { }), account: Flags.string({ char: "a", - description: "Account to sign the transaction with", + description: "Account alias to sign the transaction with", }), address: Flags.string({ required: false, diff --git a/src/lib/nodeInfo.ts b/src/lib/nodeInfo.ts index 3064488a..59e2f415 100644 --- a/src/lib/nodeInfo.ts +++ b/src/lib/nodeInfo.ts @@ -1,17 +1,123 @@ -export type nodeInfo = typeof swankyNode; - -export const swankyNode = { - version: "1.6.0", - polkadotPalletVersions: "polkadot-v0.9.39", - supportedInk: "v4.2.0", +export interface nodeInfo { + version: string; + polkadotPalletVersions: string; + supportedInk: string; downloadUrl: { darwin: { - "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.6.0/swanky-node-v1.6.0-macOS-universal.tar.gz", - "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.6.0/swanky-node-v1.6.0-macOS-universal.tar.gz" - }, + "arm64"?: string; + "x64"?: string; + }; linux: { - "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.6.0/swanky-node-v1.6.0-ubuntu-aarch64.tar.gz", - "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.6.0/swanky-node-v1.6.0-ubuntu-x86_64.tar.gz", + "arm64"?: string; + "x64"?: string; + }; + }; +} + +export const swankyNodeVersions = new Map([ + ["1.6.0", { + version: "1.6.0", + polkadotPalletVersions: "polkadot-v0.9.39", + supportedInk: "v4.3.0", + downloadUrl: { + darwin: { + "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.6.0/swanky-node-v1.6.0-macOS-universal.tar.gz", + "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.6.0/swanky-node-v1.6.0-macOS-universal.tar.gz" + }, + linux: { + "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.6.0/swanky-node-v1.6.0-ubuntu-aarch64.tar.gz", + "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.6.0/swanky-node-v1.6.0-ubuntu-x86_64.tar.gz", + } + } + }], + ["1.5.0", { + version: "1.5.0", + polkadotPalletVersions: "polkadot-v0.9.39", + supportedInk: "v4.0.0", + downloadUrl: { + darwin: { + "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.5.0/swanky-node-v1.5.0-macOS-universal.tar.gz", + "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.5.0/swanky-node-v1.5.0-macOS-universal.tar.gz" + }, + linux: { + "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.5.0/swanky-node-v1.5.0-ubuntu-aarch64.tar.gz", + "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.5.0/swanky-node-v1.5.0-ubuntu-x86_64.tar.gz", + } + } + }], + ["1.4.0", { + version: "1.4.0", + polkadotPalletVersions: "polkadot-v0.9.37", + supportedInk: "v4.0.0", + downloadUrl: { + darwin: { + "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.4.0/swanky-node-v1.4.0-macOS-universal.tar.gz", + "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.4.0/swanky-node-v1.4.0-macOS-universal.tar.gz" + }, + linux: { + "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.4.0/swanky-node-v1.4.0-ubuntu-aarch64.tar.gz", + "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.4.0/swanky-node-v1.4.0-ubuntu-x86_64.tar.gz", + } + } + }], + ["1.3.0", { + version: "1.3.0", + polkadotPalletVersions: "polkadot-v0.9.37", + supportedInk: "v4.0.0", + downloadUrl: { + darwin: { + "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.3.0/swanky-node-v1.3.0-macOS-universal.tar.gz", + "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.3.0/swanky-node-v1.3.0-macOS-universal.tar.gz" + }, + linux: { + "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.3.0/swanky-node-v1.3.0-ubuntu-aarch64.tar.gz", + "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.3.0/swanky-node-v1.3.0-ubuntu-x86_64.tar.gz", + } + } + }], + ["1.2.0", { + version: "1.2.0", + polkadotPalletVersions: "polkadot-v0.9.37", + supportedInk: "v4.0.0", + downloadUrl: { + darwin: { + "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.2.0/swanky-node-v1.2.0-macOS-universal.tar.gz", + "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.2.0/swanky-node-v1.2.0-macOS-universal.tar.gz" + }, + linux: { + "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.2.0/swanky-node-v1.2.0-ubuntu-aarch64.tar.gz", + "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.2.0/swanky-node-v1.2.0-ubuntu-x86_64.tar.gz", + } + } + }], + ["1.1.0", { + version: "1.1.0", + polkadotPalletVersions: "polkadot-v0.9.37", + supportedInk: "v4.0.0", + downloadUrl: { + darwin: { + "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.1.0/swanky-node-v1.1.0-macOS-x86_64.tar.gz", + "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.1.0/swanky-node-v1.1.0-macOS-x86_64.tar.gz" + }, + linux: { + "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.1.0/swanky-node-v1.1.0-ubuntu-x86_64.tar.gz", + "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.1.0/swanky-node-v1.1.0-ubuntu-x86_64.tar.gz", + } + } + }], + ["1.0.0", { + version: "1.0.0", + polkadotPalletVersions: "polkadot-v0.9.30", + supportedInk: "v3.4.0", + downloadUrl: { + darwin: { + "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.0.0/swanky-node-v1.0.0-macOS-x86_64.tar.gz", + "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.0.0/swanky-node-v1.0.0-macOS-x86_64.tar.gz" + }, + linux: { + "arm64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.0.0/swanky-node-v1.0.0-ubuntu-x86_64.tar.gz", + "x64": "https://github.com/AstarNetwork/swanky-node/releases/download/v1.0.0/swanky-node-v1.0.0-ubuntu-x86_64.tar.gz", + } } - }, -}; + }] +]); diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 259c45a9..3ee17625 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -11,6 +11,16 @@ export function pickTemplate(templateList: string[]): ListQuestion { }; } +export function pickNodeVersion(nodeVersions: string[]): ListQuestion { + if (!nodeVersions?.length) throw new ConfigError("Node version list is empty!"); + return { + name: "version", + type: "list", + choices: nodeVersions, + message: "Which node version should we use?", + }; +} + export function name( subject: string, initial?: (answers: Answers) => string, diff --git a/src/lib/substrate-api.ts b/src/lib/substrate-api.ts index 343d35f4..42bef37c 100644 --- a/src/lib/substrate-api.ts +++ b/src/lib/substrate-api.ts @@ -1,5 +1,5 @@ import { ApiPromise } from "@polkadot/api/promise"; -import { WsProvider } from "@polkadot/api"; +import { Keyring, WsProvider } from "@polkadot/api"; import { SignerOptions } from "@polkadot/api/types"; import { Codec, ITuple } from "@polkadot/types-codec/types"; import { ISubmittableResult } from "@polkadot/types/types"; @@ -7,11 +7,13 @@ import { TypeRegistry } from "@polkadot/types"; import { DispatchError, BlockHash } from "@polkadot/types/interfaces"; import { ChainAccount } from "./account.js"; import BN from "bn.js"; -import { ChainProperty, ExtrinsicPayload } from "../types/index.js"; +import { ChainProperty, ExtrinsicPayload, AccountData } from "../types/index.js"; import { KeyringPair } from "@polkadot/keyring/types"; import { Abi, CodePromise } from "@polkadot/api-contract"; import { ApiError, UnknownError } from "./errors.js"; +import { ALICE_URI, KEYPAIR_TYPE, LOCAL_FAUCET_AMOUNT } from "./consts.js"; +import { BN_TEN } from "@polkadot/util"; export type AbiType = Abi; // const AUTO_CONNECT_MS = 10_000; // [ms] @@ -101,6 +103,10 @@ export class ChainApi { return this._registry; } + public async disconnect(): Promise { + await this._provider.disconnect(); + } + public async start(): Promise { const chainProperties = await this._api.rpc.system.properties(); @@ -210,7 +216,6 @@ export class ChainApi { if (handler) handler(result); }); } - public async deploy( abi: Abi, wasm: Buffer, @@ -247,4 +252,27 @@ export class ChainApi { }); }); } + + public async faucet(accountData: AccountData): Promise { + const keyring = new Keyring({ type: KEYPAIR_TYPE }); + const alicePair = keyring.addFromUri(ALICE_URI); + + const chainDecimals = this._api.registry.chainDecimals[0]; + const amount = new BN(LOCAL_FAUCET_AMOUNT).mul(BN_TEN.pow(new BN(chainDecimals))); + + const tx = this._api.tx.balances.transfer(accountData.address, amount); + + return new Promise((resolve, reject) => { + this.signAndSend(alicePair, tx, {}, ({ status, events }) => { + if (status.isInBlock || status.isFinalized) { + const transferEvent = events.find(({ event }) => event?.method === "Transfer"); + if (!transferEvent) { + reject(); + return; + } + resolve(); + } + }).catch((error) => reject(error)); + }); + } } diff --git a/src/lib/swankyCommand.ts b/src/lib/swankyCommand.ts index b2d6c625..a184cb86 100644 --- a/src/lib/swankyCommand.ts +++ b/src/lib/swankyCommand.ts @@ -1,10 +1,18 @@ import { Command, Flags, Interfaces } from "@oclif/core"; -import { getSwankyConfig, Spinner } from "./index.js"; -import { SwankyConfig } from "../types/index.js"; +import chalk from "chalk"; +import { buildSwankyConfig, + configName, + getSwankyConfig, + getSystemConfigDirectoryPath, Spinner } from "./index.js"; +import { AccountData, SwankyConfig, SwankySystemConfig } from "../types/index.js"; import { writeJSON } from "fs-extra/esm"; +import { existsSync, mkdirSync } from "fs"; import { BaseError, ConfigError, UnknownError } from "./errors.js"; import { swankyLogger } from "./logger.js"; import { Logger } from "winston"; +import path from "node:path"; +import { DEFAULT_CONFIG_FOLDER_NAME, DEFAULT_CONFIG_NAME } from "./consts.js"; + export type Flags = Interfaces.InferredFlags< (typeof SwankyCommand)["baseFlags"] & T["flags"] >; @@ -30,20 +38,14 @@ export abstract class SwankyCommand extends Command { args: this.ctor.args, strict: this.ctor.strict, }); + this.flags = flags as Flags; this.args = args as Args; this.logger = swankyLogger; - try { - this.swankyConfig = await getSwankyConfig(); - } catch (error) { - if ( - error instanceof Error && - error.message.includes("swanky.config.json") && - (this.constructor as typeof SwankyCommand).ENSURE_SWANKY_CONFIG - ) - throw new ConfigError("Cannot find swanky.config.json", { cause: error }); - } + this.swankyConfig = buildSwankyConfig(); + + await this.loadAndMergeConfig(); this.logger.info(`Running command: ${this.ctor.name} Args: ${JSON.stringify(this.args)} @@ -51,8 +53,101 @@ export abstract class SwankyCommand extends Command { Full command: ${JSON.stringify(process.argv)}`); } - protected async storeConfig() { - await writeJSON("swanky.config.json", this.swankyConfig, { spaces: 2 }); + protected async loadAndMergeConfig(): Promise { + try { + const systemConfig = getSwankyConfig("global"); + this.swankyConfig = { ...this.swankyConfig, ...systemConfig }; + } catch (error) { + this.warn( + `No Swanky system config found; creating one in "/${DEFAULT_CONFIG_FOLDER_NAME}/${DEFAULT_CONFIG_NAME}}" at home directory` + ); + await this.storeConfig(this.swankyConfig, "global"); + } + + try { + const localConfig = getSwankyConfig("local") as SwankyConfig; + this.mergeAccountsWithExistingConfig(this.swankyConfig, localConfig); + const originalDefaultAccount = this.swankyConfig.defaultAccount; + this.swankyConfig = { ...this.swankyConfig, ...localConfig }; + this.swankyConfig.defaultAccount = localConfig.defaultAccount ?? originalDefaultAccount; + } catch (error) { + this.handleLocalConfigError(error); + } + } + + private handleLocalConfigError(error: unknown): void { + this.logger.warn("No local config found"); + if ( + error instanceof Error && + error.message.includes(configName()) && + (this.constructor as typeof SwankyCommand).ENSURE_SWANKY_CONFIG + ) { + throw new ConfigError(`Cannot find ${process.env.SWANKY_CONFIG ?? DEFAULT_CONFIG_NAME}`, { + cause: error, + }); + } + } + + protected async storeConfig( + newConfig: SwankyConfig | SwankySystemConfig, + configType: "local" | "global", + projectPath?: string + ) { + let configPath: string; + + if (configType === "local") { + configPath = + process.env.SWANKY_CONFIG ?? + path.resolve(projectPath ?? process.cwd(), DEFAULT_CONFIG_NAME); + } else { + // global + configPath = getSystemConfigDirectoryPath() + `/${DEFAULT_CONFIG_NAME}`; + if ("node" in newConfig) { + // If it's a SwankyConfig, extract only the system relevant parts for the global SwankySystemConfig config + newConfig = { + defaultAccount: newConfig.defaultAccount, + accounts: newConfig.accounts, + networks: newConfig.networks, + }; + } + if (existsSync(configPath)) { + const systemConfig = getSwankyConfig("global"); + this.mergeAccountsWithExistingConfig(systemConfig, newConfig); + } + } + + this.ensureDirectoryExists(configPath); + await writeJSON(configPath, newConfig, { spaces: 2 }); + } + + private ensureDirectoryExists(filePath: string) { + const directory = path.dirname(filePath); + if (!existsSync(directory)) { + mkdirSync(directory, { recursive: true }); + } + } + + private mergeAccountsWithExistingConfig( + existingConfig: SwankySystemConfig | SwankyConfig, + newConfig: SwankySystemConfig + ) { + const accountMap = new Map( + [...existingConfig.accounts, ...newConfig.accounts].map((account) => [account.alias, account]) + ); + + newConfig.accounts = Array.from(accountMap.values()); + } + + protected findAccountByAlias(alias: string): AccountData { + const accountData = this.swankyConfig.accounts.find( + (account: AccountData) => account.alias === alias + ); + + if (!accountData) { + throw new ConfigError(`Provided account alias ${chalk.yellowBright(alias)} not found in swanky.config.json`); + } + + return accountData; } protected async catch(err: Error & { exitCode?: number }): Promise { diff --git a/src/lib/tasks.ts b/src/lib/tasks.ts index 20c0efdc..cbbd1b36 100644 --- a/src/lib/tasks.ts +++ b/src/lib/tasks.ts @@ -1,16 +1,25 @@ import { execaCommand } from "execa"; -import { ensureDir, copy, remove } from "fs-extra/esm"; -import { rename, readFile, rm, writeFile } from "fs/promises"; +import { copy, ensureDir, remove } from "fs-extra/esm"; +import { readFile, rename, rm, writeFile } from "fs/promises"; import path from "node:path"; import { globby } from "globby"; import handlebars from "handlebars"; import { DownloadEndedStats, DownloaderHelper } from "node-downloader-helper"; import process from "node:process"; +import semver from "semver"; import { nodeInfo } from "./nodeInfo.js"; import decompress from "decompress"; import { Spinner } from "./spinner.js"; -import { SupportedPlatforms, SupportedArch } from "../types/index.js"; -import { ConfigError, NetworkError } from "./errors.js"; +import { Relaychain, SupportedArch, SupportedPlatforms, SwankyConfig, TestType, ZombienetConfig } from "../types/index.js"; +import { ConfigError, NetworkError, ProcessError } from "./errors.js"; +import { BinaryNames } from "./zombienetInfo.js"; +import { zombienetConfig } from "../commands/zombienet/init.js"; +import { readFileSync } from "fs"; +import TOML from "@iarna/toml"; +import { writeFileSync } from "node:fs"; +import { commandStdoutOrNull } from "./command-utils.js"; +import { readJSON, writeJSON } from "fs-extra/esm"; +import { fileExists } from "@oclif/core/lib/util.js"; export async function checkCliDependencies(spinner: Spinner) { const dependencyList = [ @@ -28,18 +37,41 @@ export async function checkCliDependencies(spinner: Spinner) { } } +export function osCheck() { + const platform = process.platform; + const arch = process.arch; + + const supportedConfigs = { + darwin: ["x64", "arm64"], + linux: ["x64", "arm64"], + }; + + if (!(platform in supportedConfigs)) { + throw new ConfigError(`Platform '${platform}' is not supported!`); + } + + const supportedArchs = supportedConfigs[platform as keyof typeof supportedConfigs]; + if (!supportedArchs.includes(arch)) { + throw new ConfigError( + `Architecture '${arch}' is not supported on platform '${platform}'.` + ); + } + + return { platform, arch }; +} + export async function copyCommonTemplateFiles(templatesPath: string, projectPath: string) { await ensureDir(projectPath); const commonFiles = await globby(`*`, { cwd: templatesPath }); await Promise.all( commonFiles.map(async (file) => { await copy(path.resolve(templatesPath, file), path.resolve(projectPath, file)); - }) + }), ); await rename(path.resolve(projectPath, "gitignore"), path.resolve(projectPath, ".gitignore")); await rename( path.resolve(projectPath, "mocharc.json"), - path.resolve(projectPath, ".mocharc.json") + path.resolve(projectPath, ".mocharc.json"), ); await copy(path.resolve(templatesPath, "github"), path.resolve(projectPath, ".github")); } @@ -47,18 +79,49 @@ export async function copyCommonTemplateFiles(templatesPath: string, projectPath export async function copyContractTemplateFiles( contractTemplatePath: string, contractName: string, - projectPath: string + projectPath: string, ) { await copy( path.resolve(contractTemplatePath, "contract"), - path.resolve(projectPath, "contracts", contractName) - ); - await copy( - path.resolve(contractTemplatePath, "test"), - path.resolve(projectPath, "tests", contractName) + path.resolve(projectPath, "contracts", contractName), ); } +export async function prepareTestFiles( + testType: TestType, + templatePath: string, + projectPath: string, + templateName?: string, + contractName?: string +) { + switch (testType) { + case "e2e": { + await copy( + path.resolve(templatePath, "test_helpers"), + path.resolve(projectPath, "tests", "test_helpers") + ); + break; + } + case "mocha": { + if (!templateName) { + throw new ProcessError("'templateName' argument is required for mocha tests"); + } + if (!contractName) { + throw new ProcessError("'contractName' argument is required for mocha tests"); + } + await copy( + path.resolve(templatePath, "contracts", templateName, "test"), + path.resolve(projectPath, "tests", contractName) + ); + break; + } + default: { + // This case will make the switch exhaustive + throw new ProcessError("Unhandled test type"); + } + } +} + export async function processTemplates(projectPath: string, templateData: Record) { const templateFiles = await globby(projectPath, { expandDirectories: { extensions: ["hbs"] }, @@ -80,13 +143,13 @@ export async function downloadNode(projectPath: string, nodeInfo: nodeInfo, spin const platformDlUrls = nodeInfo.downloadUrl[process.platform as SupportedPlatforms]; if (!platformDlUrls) throw new ConfigError( - `Could not download swanky-node. Platform ${process.platform} not supported!` + `Could not download swanky-node. Platform ${process.platform} not supported!`, ); const dlUrl = platformDlUrls[process.arch as SupportedArch]; if (!dlUrl) throw new ConfigError( - `Could not download swanky-node. Platform ${process.platform} Arch ${process.arch} not supported!` + `Could not download swanky-node. Platform ${process.platform} Arch ${process.arch} not supported!`, ); const dlFileDetails = await new Promise((resolve, reject) => { @@ -103,7 +166,7 @@ export async function downloadNode(projectPath: string, nodeInfo: nodeInfo, spin }); dl.start().catch((error: Error) => - reject(new Error(`Error downloading node: , ${error.message}`)) + reject(new Error(`Error downloading node: , ${error.message}`)), ); }); @@ -124,15 +187,203 @@ export async function downloadNode(projectPath: string, nodeInfo: nodeInfo, spin return path.resolve(binPath, dlFileDetails.filePath); } +export async function copyZombienetTemplateFile(templatePath: string, configPath: string) { + await ensureDir(configPath); + await copy( + path.resolve(templatePath, zombienetConfig), + path.resolve(configPath, zombienetConfig), + ); +} + +export async function copyFrontendTemplateFiles( + templatesPath: string, + projectPath: string, +) { + await copy(path.resolve(templatesPath, "pnpm-workspace.yaml.hbs"), path.resolve(projectPath, "pnpm-workspace.yaml.hbs")); + await copy( + path.resolve(templatesPath, "frontend"), + path.resolve(projectPath, "frontend"), + ); +} + +export async function addFrontendWorkspace( + projectPath: string, +) { + const packageJsonPath = path.resolve(projectPath, "package.json"); + const packageJson = await readJSON(packageJsonPath); + packageJson.workspace = [ "frontend" ]; + await writeJSON(packageJsonPath, packageJson, { spaces: 2 }); +} + +export async function downloadZombienetBinaries(binaries: string[], projectPath: string, swankyConfig: SwankyConfig, spinner: Spinner) { + const binPath = path.resolve(projectPath, "zombienet", "bin"); + await ensureDir(binPath); + + const zombienetInfo = swankyConfig.zombienet; + + if (!zombienetInfo) { + throw new ConfigError("No zombienet config found"); + } + + const dlUrls = new Map(); + if (zombienetInfo.version) { + const version = zombienetInfo.version; + const binaryName = "zombienet"; + const platformDlUrls = zombienetInfo.downloadUrl[process.platform as SupportedPlatforms]; + if (!platformDlUrls) + throw new ConfigError( + `Could not download ${binaryName}. Platform ${process.platform} not supported!`, + ); + let dlUrl = platformDlUrls[process.arch as SupportedArch]; + if (!dlUrl) + throw new ConfigError( + `Could not download ${binaryName}. Platform ${process.platform} Arch ${process.arch} not supported!`, + ); + dlUrl = dlUrl.replace("${version}", version); + dlUrls.set(binaryName, dlUrl); + } + + for (const binaryName of Object.keys(zombienetInfo.binaries).filter((binaryName) => binaries.includes(binaryName))) { + const binaryInfo = zombienetInfo.binaries[binaryName as BinaryNames]; + const version = binaryInfo.version; + const platformDlUrls = binaryInfo.downloadUrl[process.platform as SupportedPlatforms]; + if (!platformDlUrls) + throw new ConfigError( + `Could not download ${binaryName}. Platform ${process.platform} not supported!`, + ); + let dlUrl = platformDlUrls[process.arch as SupportedArch]; + if (!dlUrl) + throw new ConfigError( + `Could not download ${binaryName}. Platform ${process.platform} Arch ${process.arch} not supported!`, + ); + dlUrl = dlUrl.replace(/\$\{version}/gi, version); + dlUrls.set(binaryName, dlUrl); + } + + for (const [binaryName, dlUrl] of dlUrls) { + const dlFileDetails = await new Promise((resolve, reject) => { + const dl = new DownloaderHelper(dlUrl, binPath); + + dl.on("progress", (event) => { + spinner.text(`Downloading ${binaryName} ${event.progress.toFixed(2)}%`); + }); + dl.on("end", (event) => { + resolve(event); + }); + dl.on("error", (error) => { + reject(new Error(`Error downloading ${binaryName}: , ${error.message}`)); + }); + + dl.start().catch((error: Error) => + reject(new Error(`Error downloading ${binaryName}: , ${error.message}`)), + ); + }); + + if (dlFileDetails.incomplete) { + throw new NetworkError("${binaryName} download incomplete"); + } + + let fileName = dlFileDetails.fileName; + + if (dlFileDetails.filePath.endsWith(".tar.gz")) { + const compressedFilePath = path.resolve(binPath, dlFileDetails.filePath); + const decompressed = await decompress(compressedFilePath, binPath); + await remove(compressedFilePath); + fileName = decompressed[0].path; + } + + if (fileName !== binaryName) { + await execaCommand(`mv ${binPath}/${fileName} ${binPath}/${binaryName}`); + } + await execaCommand(`chmod +x ${binPath}/${binaryName}`); + } +} + +export async function buildZombienetConfigFromBinaries(binaries: string[], templatePath: string, configPath: string) { + await ensureDir(configPath); + const configBuilder = { + settings: { + timeout: 1000, + }, + relaychain: { + default_command: "", + chain: "", + nodes: [], + }, + parachains: [], + } as ZombienetConfig; + + for (const binaryName of binaries) { + const template = TOML.parse(readFileSync(path.resolve(templatePath, binaryName + ".toml"), "utf8")); + if (template.parachains !== undefined) { + (template.parachains as any).forEach((parachain: any) => { + configBuilder.parachains.push(parachain); + }); + } + if (template.hrmp_channels !== undefined) { + configBuilder.hrmp_channels = []; + (template.hrmp_channels as any).forEach((hrmp_channel: any) => { + configBuilder.hrmp_channels!.push(hrmp_channel); + }); + } + if (template.relaychain !== undefined) { + configBuilder.relaychain = template.relaychain as unknown as Relaychain; + } + + } + + writeFileSync(path.resolve(configPath, zombienetConfig), TOML.stringify(configBuilder as any)); +} + export async function installDeps(projectPath: string) { let installCommand = "npm install"; try { - await execaCommand("yarn --version"); - installCommand = "yarn install"; + if (await fileExists(path.resolve(projectPath, "pnpm-workspace.yaml"))) { + await execaCommand("pnpm --version"); + installCommand = "pnpm install"; + } else { + await execaCommand("yarn --version"); + installCommand = "yarn install"; + } } catch (_error) { console.log("\n\t >>Yarn not detected, using NPM"); } finally { await execaCommand(installCommand, { cwd: projectPath }); } } + +export function extractCargoContractVersion() { + const regex = /cargo-contract-contract (\d+\.\d+\.\d+(?:-[\w.]+)?)(?:-unknown-[\w-]+)/; + const cargoContractVersionOutput = commandStdoutOrNull("cargo contract -V"); + if (!cargoContractVersionOutput) { + return null + } + + const match = cargoContractVersionOutput.match(regex); + if (!match) { + throw new ProcessError( + `Unable to determine cargo-contract version. Please verify its installation.` + ); + } + + return match[1]; +} + +export function ensureCargoContractVersionCompatibility( + cargoContractVersion: string, + minimalVersion: string, + invalidVersionsList?: string[] +) { + if (invalidVersionsList?.includes(cargoContractVersion)) { + throw new ProcessError( + `The cargo-contract version ${cargoContractVersion} is not supported. Please update or change the version.` + ); + } + + if (!semver.satisfies(cargoContractVersion.replace(/-.*$/, ""), `>=${minimalVersion}`)) { + throw new ProcessError( + `cargo-contract version >= ${minimalVersion} required, but found version ${cargoContractVersion}. Please update to a compatible version.` + ); + } +} diff --git a/src/lib/templates.ts b/src/lib/templates.ts index ef814298..90996e34 100644 --- a/src/lib/templates.ts +++ b/src/lib/templates.ts @@ -8,6 +8,7 @@ const __dirname = path.dirname(__filename); export function getTemplates() { const templatesPath = path.resolve(__dirname, "..", "templates"); const contractTemplatesPath = path.resolve(templatesPath, "contracts"); + const zombienetTemplatesPath = path.resolve(templatesPath, "zombienet"); const fileList = readdirSync(contractTemplatesPath, { withFileTypes: true, }); @@ -19,5 +20,6 @@ export function getTemplates() { templatesPath, contractTemplatesPath, contractTemplatesList, + zombienetTemplatesPath, }; } diff --git a/src/lib/zombienetInfo.ts b/src/lib/zombienetInfo.ts new file mode 100644 index 00000000..3b6b0faf --- /dev/null +++ b/src/lib/zombienetInfo.ts @@ -0,0 +1,52 @@ +export type zombienetInfo = typeof zombienet; + +export type BinaryNames = "zombienet" | "polkadot" | "polkadot-parachain" | "astar-collator"; + +export const zombienet = { + version: "1.3.89", + downloadUrl: { + darwin: { + "arm64": "https://github.com/paritytech/zombienet/releases/download/v${version}/zombienet-macos", + "x64": "https://github.com/paritytech/zombienet/releases/download/v${version}/zombienet-macos", + }, + linux: { + "arm64": "https://github.com/paritytech/zombienet/releases/download/v${version}/zombienet-linux-arm64", + "x64": "https://github.com/paritytech/zombienet/releases/download/v${version}/zombienet-linux-x64", + }, + }, + binaries: { + "polkadot": { + version: "0.9.43", + downloadUrl: { + linux: { + "arm64": "https://github.com/paritytech/polkadot/releases/download/v${version}/polkadot", + "x64": "https://github.com/paritytech/polkadot/releases/download/v${version}/polkadot", + }, + }, + }, + "polkadot-parachain": { + version: "0.9.430", + downloadUrl: { + linux: { + "arm64": "https://github.com/paritytech/cumulus/releases/download/v${version}/polkadot-parachain", + "x64": "https://github.com/paritytech/cumulus/releases/download/v${version}/polkadot-parachain", + }, + }, + }, + "astar-collator": { + version: "5.28.0", + downloadUrl: { + darwin: { + "arm64": "https://github.com/AstarNetwork/Astar/releases/download/v${version}/astar-collator-v${version}-macOS-x86_64.tar.gz", + "x64": "https://github.com/AstarNetwork/Astar/releases/download/v${version}/astar-collator-v${version}-macOS-x86_64.tar.gz", + }, + linux: { + "arm64": "https://github.com/AstarNetwork/Astar/releases/download/v${version}/astar-collator-v${version}-ubuntu-aarch64.tar.gz", + "x64": "https://github.com/AstarNetwork/Astar/releases/download/v${version}/astar-collator-v${version}-ubuntu-x86_64.tar.gz", + }, + }, + }, + }, +}; + +export const zombienetBinariesList = Object.keys(zombienet.binaries); diff --git a/src/templates/contracts/flipper/contract/Cargo.toml.hbs b/src/templates/contracts/flipper/contract/Cargo.toml.hbs index de90dba7..2de5449d 100644 --- a/src/templates/contracts/flipper/contract/Cargo.toml.hbs +++ b/src/templates/contracts/flipper/contract/Cargo.toml.hbs @@ -10,6 +10,9 @@ ink = { version = "4.2.1", default-features = false } scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["derive"] } scale-info = { version = "2.6", default-features = false, features = ["derive"], optional = true } +[dev-dependencies] +ink_e2e = "4.2.1" + [lib] name = "{{contract_name_snake}}" path = "src/lib.rs" @@ -22,3 +25,4 @@ std = [ "scale-info/std", ] ink-as-dependency = [] +e2e-tests = [] diff --git a/src/templates/contracts/flipper/contract/src/lib.rs.hbs b/src/templates/contracts/flipper/contract/src/lib.rs.hbs index a5137e64..0681c362 100644 --- a/src/templates/contracts/flipper/contract/src/lib.rs.hbs +++ b/src/templates/contracts/flipper/contract/src/lib.rs.hbs @@ -57,17 +57,90 @@ mod {{contract_name_snake}} { /// We test if the default constructor does its job. #[ink::test] fn default_works() { - let {{contract_name_snake}} = {{contract_name_pascal}}::default(); - assert_eq!({{contract_name_snake}}.get(), false); + let flipper = {{contract_name_pascal}}::default(); + assert_eq!(flipper.get(), false); } /// We test a simple use case of our contract. #[ink::test] fn it_works() { - let mut {{contract_name_snake}} = {{contract_name_pascal}}::new(false); - assert_eq!({{contract_name_snake}}.get(), false); - {{contract_name_snake}}.flip(); - assert_eq!({{contract_name_snake}}.get(), true); + let mut flipper = {{contract_name_pascal}}::new(false); + assert_eq!(flipper.get(), false); + flipper.flip(); + assert_eq!(flipper.get(), true); + } + } + + + /// This is how you'd write end-to-end (E2E) or integration tests for ink! contracts. + /// + /// When running these you need to make sure that you: + /// - Compile the tests with the `e2e-tests` feature flag enabled (`--features e2e-tests`) + /// - Are running a Substrate node which contains `pallet-contracts` in the background + #[cfg(all(test, feature = "e2e-tests"))] + mod e2e_tests { + /// Imports all the definitions from the outer scope so we can use them here. + use super::*; + + /// A helper function used for calling contract messages. + use ink_e2e::build_message; + + /// The End-to-End test `Result` type. + type E2EResult = std::result::Result>; + + /// We test that we can upload and instantiate the contract using its default constructor. + #[ink_e2e::test] + async fn default_works(mut client: ink_e2e::Client) -> E2EResult<()> { + // Given + let constructor = {{contract_name_pascal}}Ref::default(); + + // When + let contract_account_id = client + .instantiate("{{contract_name_snake}}", &ink_e2e::alice(), constructor, 0, None) + .await + .expect("instantiate failed") + .account_id; + + // Then + let get = build_message::<{{contract_name_pascal}}Ref>(contract_account_id.clone()) + .call(|flipper| flipper.get()); + let get_result = client.call_dry_run(&ink_e2e::alice(), &get, 0, None).await; + assert!(matches!(get_result.return_value(), false)); + + Ok(()) + } + + /// We test that we can read and write a value from the on-chain contract contract. + #[ink_e2e::test] + async fn it_works(mut client: ink_e2e::Client) -> E2EResult<()> { + // Given + let constructor = {{contract_name_pascal}}Ref::new(false); + let contract_account_id = client + .instantiate("{{contract_name_snake}}", &ink_e2e::bob(), constructor, 0, None) + .await + .expect("instantiate failed") + .account_id; + + let get = build_message::<{{contract_name_pascal}}Ref>(contract_account_id.clone()) + .call(|flipper| flipper.get()); + let get_result = client.call_dry_run(&ink_e2e::bob(), &get, 0, None).await; + assert!(matches!(get_result.return_value(), false)); + + // When + let flip = build_message::<{{contract_name_pascal}}Ref>(contract_account_id.clone()) + .call(|flipper| flipper.flip()); + let _flip_result = client + .call(&ink_e2e::bob(), flip, 0, None) + .await + .expect("flip failed"); + + // Then + let get = build_message::<{{contract_name_pascal}}Ref>(contract_account_id.clone()) + .call(|flipper| flipper.get()); + let get_result = client.call_dry_run(&ink_e2e::bob(), &get, 0, None).await; + assert!(matches!(get_result.return_value(), true)); + + Ok(()) } } } diff --git a/src/templates/contracts/psp22/contract/Cargo.toml.hbs b/src/templates/contracts/psp22/contract/Cargo.toml.hbs index a2c880f3..0cf1e1d6 100644 --- a/src/templates/contracts/psp22/contract/Cargo.toml.hbs +++ b/src/templates/contracts/psp22/contract/Cargo.toml.hbs @@ -10,7 +10,11 @@ ink = { version = "4.2.1", default-features = false} scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["derive"] } scale-info = { version = "2.6", default-features = false, features = ["derive"], optional = true } -openbrush = { git = "https://github.com/Brushfam/openbrush-contracts", tag = "4.0.0-beta", default-features = false, features = ["psp22"] } +openbrush = { git = "https://github.com/Brushfam/openbrush-contracts", tag = "4.0.0", default-features = false, features = ["psp22"] } + +[dev-dependencies] +ink_e2e = "4.2.1" +test_helpers = { path = "../../tests/test_helpers", default-features = false } [lib] name = "{{contract_name_snake}}" @@ -25,6 +29,7 @@ std = [ "openbrush/std", ] ink-as-dependency = [] +e2e-tests = [] [profile.dev] codegen-units = 16 diff --git a/src/templates/contracts/psp22/contract/src/lib.rs.hbs b/src/templates/contracts/psp22/contract/src/lib.rs.hbs index 4b85fb96..88b50918 100644 --- a/src/templates/contracts/psp22/contract/src/lib.rs.hbs +++ b/src/templates/contracts/psp22/contract/src/lib.rs.hbs @@ -69,4 +69,89 @@ pub mod {{contract_name_snake}} { PSP22::total_supply(self) } } + + #[cfg(all(test, feature = "e2e-tests"))] + pub mod tests { + use super::*; + use ink_e2e::{ + build_message, + }; + use openbrush::contracts::psp22::psp22_external::PSP22; + use test_helpers::{ + address_of, + balance_of, + }; + + type E2EResult = Result>; + + #[ink_e2e::test] + async fn assigns_initial_balance(mut client: ink_e2e::Client) -> E2EResult<()> { + let constructor = {{contract_name_pascal}}Ref::new(100); + let address = client + .instantiate("{{contract_name_snake}}", &ink_e2e::alice(), constructor, 0, None) + .await + .expect("instantiate failed") + .account_id; + + let result = { + let _msg = build_message::<{{contract_name_pascal}}Ref>(address.clone()) + .call(|contract| contract.balance_of(address_of!(Alice))); + client.call_dry_run(&ink_e2e::alice(), &_msg, 0, None).await + }; + + assert!(matches!(result.return_value(), 100)); + + Ok(()) + } + + #[ink_e2e::test] + async fn transfer_adds_amount_to_destination_account(mut client: ink_e2e::Client) -> E2EResult<()> { + let constructor = {{contract_name_pascal}}Ref::new(100); + let address = client + .instantiate("{{contract_name_snake}}", &ink_e2e::alice(), constructor, 0, None) + .await + .expect("instantiate failed") + .account_id; + + let result = { + let _msg = build_message::<{{contract_name_pascal}}Ref>(address.clone()) + .call(|contract| contract.transfer(address_of!(Bob), 50, vec![])); + client + .call(&ink_e2e::alice(), _msg, 0, None) + .await + .expect("transfer failed") + }; + + assert!(matches!(result.return_value(), Ok(()))); + + let balance_of_alice = balance_of!({{contract_name_pascal}}Ref, client, address, Alice); + + let balance_of_bob = balance_of!({{contract_name_pascal}}Ref, client, address, Bob); + + assert_eq!(balance_of_bob, 50, "Bob should have 50 tokens"); + assert_eq!(balance_of_alice, 50, "Alice should have 50 tokens"); + + Ok(()) + } + + #[ink_e2e::test] + async fn cannot_transfer_above_the_amount(mut client: ink_e2e::Client) -> E2EResult<()> { + let constructor = {{contract_name_pascal}}Ref::new(100); + let address = client + .instantiate("{{contract_name_snake}}", &ink_e2e::alice(), constructor, 0, None) + .await + .expect("instantiate failed") + .account_id; + + let result = { + let _msg = build_message::<{{contract_name_pascal}}Ref>(address.clone()) + .call(|contract| contract.transfer(address_of!(Bob), 101, vec![])); + client.call_dry_run(&ink_e2e::alice(), &_msg, 0, None).await + }; + + assert!(matches!(result.return_value(), Err(PSP22Error::InsufficientBalance))); + + Ok(()) + } + } } diff --git a/src/templates/contracts/psp22/test/index.test.ts.hbs b/src/templates/contracts/psp22/test/index.test.ts.hbs index 5e018a64..c17776a3 100644 --- a/src/templates/contracts/psp22/test/index.test.ts.hbs +++ b/src/templates/contracts/psp22/test/index.test.ts.hbs @@ -79,47 +79,4 @@ describe("{{contract_name}} test", () => { }) ).to.eventually.be.rejected; }); - - it("Can not transfer to hated account", async () => { - const hated_account = wallet2; - const transferredAmount = 10; - const { gasRequired } = await contract - .withSigner(deployer) - .query.transfer(wallet1.address, transferredAmount, []); - // Check that we can transfer money while account is not hated - await expect( - contract.tx.transfer(hated_account.address, 10, [], { - gasLimit: gasRequired, - }) - ).to.eventually.be.fulfilled; - - const result = await contract.query.balanceOf(hated_account.address); - expect(result.value.ok?.toNumber()).to.equal(transferredAmount); - - expect((await contract.query.getHatedAccount()).value.ok).to.equal( - EMPTY_ADDRESS - ); - - // Hate account - await expect( - contract.tx.setHatedAccount(hated_account.address, { - gasLimit: gasRequired, - }) - ).to.eventually.be.ok; - expect((await contract.query.getHatedAccount()).value.ok).to.equal( - hated_account.address - ); - - // Transfer must fail - expect( - contract.tx.transfer(hated_account.address, 10, [], { - gasLimit: gasRequired, - }) - ).to.eventually.be.rejected; - - // Amount of tokens must be the same - expect( - (await contract.query.balanceOf(hated_account.address)).value.ok?.toNumber() - ).to.equal(10); - }); -}); +}); \ No newline at end of file diff --git a/src/templates/frontend/.env.local.example b/src/templates/frontend/.env.local.example new file mode 100644 index 00000000..accd13a8 --- /dev/null +++ b/src/templates/frontend/.env.local.example @@ -0,0 +1,19 @@ +## DOCS: https://github.com/scio-labs/inkathon#environment-variables + +## How use those variables in the frontend code? +## → 1. Add them in `./src/config/environment.ts` +## → 2. Always import `env` from `@config/environment` (not from `process.env`) + +## Flag to differentiate production environments (i.e. for analytics) +NEXT_PUBLIC_PRODUCTION_MODE=false + +## Active deployment url (i.e. useful for fetching from Next.js API routes) +NEXT_PUBLIC_URL=http://localhost:3000 + +## Default chain identifer the frontend should connect to first +NEXT_PUBLIC_DEFAULT_CHAIN=alephzero-testnet +# NEXT_PUBLIC_DEFAULT_CHAIN=development + +## [Optional] Multiple supported chain identifers the frontend connects to +## IMPORTANT: It's mandatory to use double quotes in the array +# NEXT_PUBLIC_SUPPORTED_CHAINS=[ "development", "alephzero-testnet", "rococo", "shibuya" ] \ No newline at end of file diff --git a/src/templates/frontend/.eslintignore b/src/templates/frontend/.eslintignore new file mode 100644 index 00000000..c4c528d5 --- /dev/null +++ b/src/templates/frontend/.eslintignore @@ -0,0 +1,8 @@ +package-lock.json +pnpm-lock.yaml +node_modules +LICENSE + +out +.next +public \ No newline at end of file diff --git a/src/templates/frontend/.eslintrc.json b/src/templates/frontend/.eslintrc.json new file mode 100644 index 00000000..19a6e8cf --- /dev/null +++ b/src/templates/frontend/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "next/core-web-vitals", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "rules": { + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-non-null-assertion": "warn", + "@typescript-eslint/no-empty-function": "warn", + "react/no-children-prop": "warn", + "react-hooks/exhaustive-deps": "off", + "react/jsx-no-target-blank": "off", + "no-extra-boolean-cast": "off", + "prefer-const": "warn", + "no-restricted-imports": ["warn", { "patterns": ["process"] }] + } +} diff --git a/src/templates/frontend/.gitignore b/src/templates/frontend/.gitignore new file mode 100644 index 00000000..69f6e696 --- /dev/null +++ b/src/templates/frontend/.gitignore @@ -0,0 +1,44 @@ +# package management +**/node_modules +**/.pnp +.pnp.js +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# testing +/coverage + +# next.js +/.next/ +/out/ +next-env.d.ts + +# production +/build + +# local env files +.env +.env*.local + +# vercel +.vercel +.gitsigners + +# typescript +*.tsbuildinfo +dist diff --git a/src/templates/frontend/.lintstagedrc.json b/src/templates/frontend/.lintstagedrc.json new file mode 100644 index 00000000..6fb5b539 --- /dev/null +++ b/src/templates/frontend/.lintstagedrc.json @@ -0,0 +1,4 @@ +{ + "*.{js,jsx,ts,tsx}": ["pnpm run lint:fix"], + "*.{json,md,mdx,html,css,yml,yaml}": ["pnpm run lint:format"] +} diff --git a/src/templates/frontend/.prettierignore b/src/templates/frontend/.prettierignore new file mode 100644 index 00000000..ea0d5432 --- /dev/null +++ b/src/templates/frontend/.prettierignore @@ -0,0 +1,10 @@ +package-lock.json +pnpm-lock.yaml +node_modules +LICENSE + +out +.next +public + +src/components/ui \ No newline at end of file diff --git a/src/templates/frontend/.prettierrc.js b/src/templates/frontend/.prettierrc.js new file mode 100644 index 00000000..65e90e0b --- /dev/null +++ b/src/templates/frontend/.prettierrc.js @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-env node */ + +/** @type {import('prettier').Config} */ +module.exports = { + ...require('../.prettierrc.js'), + plugins: ['@trivago/prettier-plugin-sort-imports', 'prettier-plugin-tailwindcss'], + importOrder: [ + '^((react|next)/(.*)$)|^((react|next)$)', + '', + '^@/(config|types|styles|shared|lib|utils|hooks|components|app|pages|features)/(.*)$', + '^[./]', + ], + importOrderSeparation: true, + overrides: [ + { + files: ['*.ts', '*.tsx'], + options: { + parser: 'typescript', + importOrderParserPlugins: ['typescript', 'jsx'], + }, + }, + ], + tailwindConfig: 'tailwind.config.ts', + tailwindFunctions: ['clsx', 'cva'], +} diff --git a/src/templates/frontend/CHANGELOG.md.hbs b/src/templates/frontend/CHANGELOG.md.hbs new file mode 100644 index 00000000..7d058b0e --- /dev/null +++ b/src/templates/frontend/CHANGELOG.md.hbs @@ -0,0 +1,154 @@ +# @inkathon/frontend + +## 0.6.0 + +### Minor Changes + +- [#57](https://github.com/scio-labs/inkathon/pull/57) [`d623968`](https://github.com/scio-labs/inkathon/commit/d623968827da0d96b51a09f79d2f02ecb1c6c2a8) Thanks [@peetzweg](https://github.com/peetzweg)! - uses zod for form validation + +- [#50](https://github.com/scio-labs/inkathon/pull/50) [`7c717dd`](https://github.com/scio-labs/inkathon/commit/7c717dd17e4b221b076ecf5d7bf74bebecc9df83) Thanks [@ical10](https://github.com/ical10)! - - Setup Docker workflow for local development of frontend (Next.js Startup & Watching) and production build (non-Vercel deployments) + - Setup Docker workflow for local development of contracts (Rust & Substrate Contracts Node Setup, Contract Deployment) + +### Patch Changes + +- [#55](https://github.com/scio-labs/inkathon/pull/55) [`6b7ea5d`](https://github.com/scio-labs/inkathon/commit/6b7ea5de1e425242fd4811dbc85898f64ceb069f) Thanks [@peetzweg](https://github.com/peetzweg)! - allow postinstall to work with new contracts instead of only packaged greeter + +- Updated dependencies [[`7c717dd`](https://github.com/scio-labs/inkathon/commit/7c717dd17e4b221b076ecf5d7bf74bebecc9df83), [`6b7ea5d`](https://github.com/scio-labs/inkathon/commit/6b7ea5de1e425242fd4811dbc85898f64ceb069f)]: + - @inkathon/contracts@0.6.0 + +## 0.5.0 + +### Minor Changes + +- [#53](https://github.com/scio-labs/inkathon/pull/53) [`194120d`](https://github.com/scio-labs/inkathon/commit/194120d21028d48102d370db72660e1e23c84c4f) Thanks [@wottpal](https://github.com/wottpal)! - Add type-safe contract integrations via `useRegisteredTypedContract` and `typechain-polkadot`. + +### Patch Changes + +- Updated dependencies [[`194120d`](https://github.com/scio-labs/inkathon/commit/194120d21028d48102d370db72660e1e23c84c4f)]: + - @inkathon/contracts@0.5.0 + +## 0.4.2 + +### Patch Changes + +- [`bc7d7ed`](https://github.com/scio-labs/inkathon/commit/bc7d7ed546fc2f17b6adaf96e34645f84ac2a5e0) Thanks [@wottpal](https://github.com/wottpal)! - Move VSCode settings to `settings.json` file but keep `inkathon.code-workspace`. It's now also supported by default to develop with ink!athon without opening the code-workspace file. + +- Updated dependencies [[`cf68f5f`](https://github.com/scio-labs/inkathon/commit/cf68f5f96888c69434014ff4f8eccdd3558d20bc), [`bc7d7ed`](https://github.com/scio-labs/inkathon/commit/bc7d7ed546fc2f17b6adaf96e34645f84ac2a5e0)]: + - @inkathon/contracts@0.4.2 + +## 0.4.1 + +### Patch Changes + +- [`14e8e11`](https://github.com/scio-labs/inkathon/commit/14e8e11ebc857e81b7cfa97e7c3c7f28d8dbccc3) Thanks [@wottpal](https://github.com/wottpal)! - Add sample code snippets from live workshops (greeter message reversion & make-it-rain script) + +- Updated dependencies [[`14e8e11`](https://github.com/scio-labs/inkathon/commit/14e8e11ebc857e81b7cfa97e7c3c7f28d8dbccc3)]: + - @inkathon/contracts@0.4.1 + +## 0.4.0 + +### Minor Changes + +- [#42](https://github.com/scio-labs/inkathon/pull/42) [`0533391`](https://github.com/scio-labs/inkathon/commit/0533391ac6f9b953ba0cb231af8b3037e80bcbab) Thanks [@ical10](https://github.com/ical10)! - Update project default to Node 20. + +- [#42](https://github.com/scio-labs/inkathon/pull/42) [`bc721ea`](https://github.com/scio-labs/inkathon/commit/bc721ea638a33d5d9d993eecddfd2a6f3ece1bfe) Thanks [@ical10](https://github.com/ical10)! - - Migrate the current pages directory to the Next.js 13 app directory. + - Migrate to `shadcn/ui` components and vanilla tailwind. + +### Patch Changes + +- Updated dependencies [[`0533391`](https://github.com/scio-labs/inkathon/commit/0533391ac6f9b953ba0cb231af8b3037e80bcbab)]: + - @inkathon/contracts@0.4.0 + +## 0.3.2 + +### Patch Changes + +- Updated dependencies [[`47aed1b`](https://github.com/scio-labs/inkathon/commit/47aed1b722138bd6fca2883337151d3c0b77e4a3)]: + - @inkathon/contracts@0.3.2 + +## 0.3.1 + +### Patch Changes + +- Updated dependencies [[`e73d9b8`](https://github.com/scio-labs/inkathon/commit/e73d9b86a4299702c59538ac43612b9977d479be)]: + - @inkathon/contracts@0.3.1 + +## 0.3.0 + +### Minor Changes + +- [`64adba1`](https://github.com/scio-labs/inkathon/commit/64adba196dd98ad272bbb4a99b4f7bc7186ae385) Thanks [@wottpal](https://github.com/wottpal)! - Add Nightly Connect support for Aleph Zero, Aleph Zero Testnet, and Local Node. Checkout: https://connect.nightly.app/. + +## 0.3.0 + +### Patch Changes + +- Updated dependencies [[`cda19ae`](https://github.com/scio-labs/inkathon/commit/cda19aeb4107c076daeb17a455fecfbd7f373044)]: + - @inkathon/contracts@0.3.0 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [[`3f4179e`](https://github.com/scio-labs/inkathon/commit/3f4179e9325b155324d23796234d9f853ae03dd9)]: + - @inkathon/contracts@0.2.1 + +## 0.2.0 + +### Minor Changes + +- [`c2cfbe4`](https://github.com/scio-labs/inkathon/commit/c2cfbe428a4e86f7ddb3d25886d4da79238b69be) Thanks [@wottpal](https://github.com/wottpal)! - Ensure & document Windows, Ubuntu, and macOS compatibility. 🌈 + +### Patch Changes + +- Updated dependencies [[`c2cfbe4`](https://github.com/scio-labs/inkathon/commit/c2cfbe428a4e86f7ddb3d25886d4da79238b69be)]: + - @inkathon/contracts@0.2.0 + +## 0.1.3 + +### Patch Changes + +- [`4bda28d`](https://github.com/scio-labs/inkathon/commit/4bda28d645abc8d8684d33bac788f04c278d7b4e) Thanks [@wottpal](https://github.com/wottpal)! - Further cross-platform script improvements (i.e. regarding the touch command). + +- Updated dependencies [[`4bda28d`](https://github.com/scio-labs/inkathon/commit/4bda28d645abc8d8684d33bac788f04c278d7b4e)]: + - @inkathon/contracts@0.1.3 + +## 0.1.2 + +### Patch Changes + +- [`2b9bc68`](https://github.com/scio-labs/inkathon/commit/2b9bc689876ea195a1cf2f6af1ca2414bcf04172) Thanks [@wottpal](https://github.com/wottpal)! - Make cp/copy command work cross-platform (i.e. on Windows) for postinstall and build-all scripts. + +- Updated dependencies [[`2b9bc68`](https://github.com/scio-labs/inkathon/commit/2b9bc689876ea195a1cf2f6af1ca2414bcf04172)]: + - @inkathon/contracts@0.1.2 + +## 0.1.1 + +### Patch Changes + +- [`1556c0f`](https://github.com/scio-labs/inkathon/commit/1556c0fb526c0b0219217cd19ab2a47dcc038ba4) Thanks [@wottpal](https://github.com/wottpal)! - Fix `@polkadot/*` package warnings about cjs/esm duplications. + +## 0.1.0 + +### Minor Changes + +- [#30](https://github.com/scio-labs/inkathon/pull/30) [`1839164`](https://github.com/scio-labs/inkathon/commit/183916440fb3043d06c1fd603aba923eb21a5964) Thanks [@wottpal](https://github.com/wottpal)! - Move `frontend` and `contracts` packages to the root level + +- [#30](https://github.com/scio-labs/inkathon/pull/30) [`7a41afe`](https://github.com/scio-labs/inkathon/commit/7a41afe1e7c2f45b6d3972760c173a4a2197c643) Thanks [@wottpal](https://github.com/wottpal)! - Add contributor guidelines at `CONTRIBUTING.md`. + +- [#30](https://github.com/scio-labs/inkathon/pull/30) [`3598618`](https://github.com/scio-labs/inkathon/commit/3598618f87d788ec51964167557210ed8b659797) Thanks [@wottpal](https://github.com/wottpal)! - Major README.md overhaul & improvements + +- [#30](https://github.com/scio-labs/inkathon/pull/30) [`1839164`](https://github.com/scio-labs/inkathon/commit/183916440fb3043d06c1fd603aba923eb21a5964) Thanks [@wottpal](https://github.com/wottpal)! - Switch from `husky` to `simple-git-hooks` pre-commit hooks + +- [#30](https://github.com/scio-labs/inkathon/pull/30) [`07d8381`](https://github.com/scio-labs/inkathon/commit/07d83819c48f4aaa129ccc3d27929767b916c93d) Thanks [@wottpal](https://github.com/wottpal)! - Add compatability for using yarn (stable only, not classic v1) as the package manager. Note: npm is still not compatible as it lacks support for the workspace import protocol. + +- [#30](https://github.com/scio-labs/inkathon/pull/30) [`1839164`](https://github.com/scio-labs/inkathon/commit/183916440fb3043d06c1fd603aba923eb21a5964) Thanks [@wottpal](https://github.com/wottpal)! - Auto-create `.env.local` files upon first package install if non-existent + +- [#30](https://github.com/scio-labs/inkathon/pull/30) [`1839164`](https://github.com/scio-labs/inkathon/commit/183916440fb3043d06c1fd603aba923eb21a5964) Thanks [@wottpal](https://github.com/wottpal)! - Setup changeset integration for version, release, and changelog management. + +- [#30](https://github.com/scio-labs/inkathon/pull/30) [`bda4108`](https://github.com/scio-labs/inkathon/commit/bda4108c9aac8234bdb5989caea0daa8d12f46fb) Thanks [@wottpal](https://github.com/wottpal)! - Change `@…` local import path shortcut to `@/…` (create-next-app default). + +### Patch Changes + +- Updated dependencies [[`cf04f67`](https://github.com/scio-labs/inkathon/commit/cf04f671c06276ffc51e33c1e38c181173227d75), [`1839164`](https://github.com/scio-labs/inkathon/commit/183916440fb3043d06c1fd603aba923eb21a5964), [`1839164`](https://github.com/scio-labs/inkathon/commit/183916440fb3043d06c1fd603aba923eb21a5964), [`7a41afe`](https://github.com/scio-labs/inkathon/commit/7a41afe1e7c2f45b6d3972760c173a4a2197c643), [`1839164`](https://github.com/scio-labs/inkathon/commit/183916440fb3043d06c1fd603aba923eb21a5964), [`3598618`](https://github.com/scio-labs/inkathon/commit/3598618f87d788ec51964167557210ed8b659797), [`1839164`](https://github.com/scio-labs/inkathon/commit/183916440fb3043d06c1fd603aba923eb21a5964), [`07d8381`](https://github.com/scio-labs/inkathon/commit/07d83819c48f4aaa129ccc3d27929767b916c93d), [`1839164`](https://github.com/scio-labs/inkathon/commit/183916440fb3043d06c1fd603aba923eb21a5964), [`1839164`](https://github.com/scio-labs/inkathon/commit/183916440fb3043d06c1fd603aba923eb21a5964)]: + - @inkathon/contracts@0.1.0 diff --git a/src/templates/frontend/components.json.hbs b/src/templates/frontend/components.json.hbs new file mode 100644 index 00000000..9990aa46 --- /dev/null +++ b/src/templates/frontend/components.json.hbs @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "zinc", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/utils/cn" + } +} diff --git a/src/templates/frontend/env.sh.hbs b/src/templates/frontend/env.sh.hbs new file mode 100644 index 00000000..c78544d0 --- /dev/null +++ b/src/templates/frontend/env.sh.hbs @@ -0,0 +1,10 @@ +files=$(find . -type f) +for f in $files; do + echo "$f" + if [[ "$f" == *.png || "$f" == *.jpg || "$f" == *.svg ]]; then + echo "skip" + continue + fi + mv "$f" "$f.hbs" +done +echo "done" \ No newline at end of file diff --git a/src/templates/frontend/next-env.d.ts.hbs b/src/templates/frontend/next-env.d.ts.hbs new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/src/templates/frontend/next-env.d.ts.hbs @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/src/templates/frontend/next.config.js.hbs b/src/templates/frontend/next.config.js.hbs new file mode 100644 index 00000000..0c5ad089 --- /dev/null +++ b/src/templates/frontend/next.config.js.hbs @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-env node */ +// @ts-check + +const path = require('path') + +/** + * @type {import('next').NextConfig} + **/ +const nextConfig = { + reactStrictMode: true, + // Fix for warnings about cjs/esm package duplication + // See: https://github.com/polkadot-js/api/issues/5636 + transpilePackages: ['@polkadot/.*'], + // Standalone builds for Dockerfiles + output: process.env.NEXT_BUILD_STANDALONE === 'true' ? 'standalone' : undefined, +} + +module.exports = nextConfig diff --git a/src/templates/frontend/package.json.hbs b/src/templates/frontend/package.json.hbs new file mode 100644 index 00000000..1088fcf2 --- /dev/null +++ b/src/templates/frontend/package.json.hbs @@ -0,0 +1,76 @@ +{ + "name": "@inkathon/frontend", + "private": true, + "version": "0.6.0", + "scripts": { + "dev": "NODE_ENV=development POLKADOTJS_DISABLE_ESM_CJS_WARNING_FLAG=1 next dev", + "node": "pnpm run -F contracts node", + "dev-and-node": "concurrently \"pnpm dev\" \"pnpm node\" --names \"Next,Node\" --kill-others", + "build": "NODE_ENV=production next build", + "start": "NODE_ENV=production next start", + "type-check": "tsc --pretty --noEmit", + "sync-types": "typesync", + "lint": "prettier . --check && eslint .", + "lint:fix": "prettier . --write && eslint . --fix", + "lint:format": "prettier . --write" + }, + "dependencies": { + "@azns/resolver-core": "^1.6.0", + "@azns/resolver-react": "^1.6.0", + "@hookform/resolvers": "^3.3.4", + "@polkadot/api": "^10.11.2", + "@polkadot/api-contract": "^10.11.2", + "@polkadot/extension-dapp": "^0.46.6", + "@polkadot/extension-inject": "^0.46.6", + "@polkadot/keyring": "^12.6.2", + "@polkadot/types": "^10.11.2", + "@polkadot/util": "^12.6.2", + "@polkadot/util-crypto": "^12.6.2", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tooltip": "^1.0.7", + "@scio-labs/use-inkathon": "^0.8.1", + "@vercel/analytics": "^1.1.3", + "autoprefixer": "^10.4.16", + "bn.js": "^5.2.1", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "geist": "^1.2.2", + "lucide-react": "^0.330.0", + "next": "^14.0.4", + "postcss": "^8.4.35", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.50.1", + "react-hot-toast": "^2.4.1", + "react-icons": "^5.0.1", + "sharp": "^0.33.1", + "spinners-react": "^1.0.7", + "tailwind-merge": "^2.2.0", + "tailwindcss-animate": "^1.0.7", + "use-async-effect": "^2.2.7", + "zod": "^3.22.4" + }, + "devDependencies": { + "@trivago/prettier-plugin-sort-imports": "^4.3.0", + "@types/bn.js": "~5.1.5", + "@types/downloadjs": "^1.4.5", + "@types/eslint": "^8.56.2", + "@types/eslint-config-prettier": "^6.11.3", + "@types/node": "^20.11.17", + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@typescript-eslint/eslint-plugin": "^7.0.1", + "@typescript-eslint/parser": "^7.0.1", + "concurrently": "^8.2.2", + "eslint": "^8.56.0", + "eslint-config-next": "^14.0.4", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-react": "^7.33.2", + "prettier": "^3.2.5", + "prettier-plugin-tailwindcss": "^0.5.11", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3" + } +} diff --git a/src/templates/frontend/postcss.config.js.hbs b/src/templates/frontend/postcss.config.js.hbs new file mode 100644 index 00000000..33ad091d --- /dev/null +++ b/src/templates/frontend/postcss.config.js.hbs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/src/templates/frontend/postinstall.sh.hbs b/src/templates/frontend/postinstall.sh.hbs new file mode 100755 index 00000000..4e5a2311 --- /dev/null +++ b/src/templates/frontend/postinstall.sh.hbs @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -eu + +# This script creates a default '.env.local' file if it doesn't exist yet. +# More information about environment variables: https://github.com/scio-labs/inkathon#environment-variables + +if [[ ! -e .env.local ]]; then + echo "Creating default '.env.local'…" + CP_CMD=$(command -v cp &> /dev/null && echo "cp" || echo "copy") + $CP_CMD .env.local.example .env.local +else + echo "Great, '.env.local' already exists! Skipping…" +fi diff --git a/src/templates/frontend/public/icons/azns-icon.svg b/src/templates/frontend/public/icons/azns-icon.svg new file mode 100644 index 00000000..7c22548e --- /dev/null +++ b/src/templates/frontend/public/icons/azns-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/templates/frontend/public/icons/github-button.svg b/src/templates/frontend/public/icons/github-button.svg new file mode 100644 index 00000000..2558614c --- /dev/null +++ b/src/templates/frontend/public/icons/github-button.svg @@ -0,0 +1,17 @@ + + + github-button + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/templates/frontend/public/icons/github.svg b/src/templates/frontend/public/icons/github.svg new file mode 100644 index 00000000..eb0a499f --- /dev/null +++ b/src/templates/frontend/public/icons/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/templates/frontend/public/icons/telegram-button.svg b/src/templates/frontend/public/icons/telegram-button.svg new file mode 100644 index 00000000..d2790239 --- /dev/null +++ b/src/templates/frontend/public/icons/telegram-button.svg @@ -0,0 +1,23 @@ + + + telegram-button + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/templates/frontend/public/icons/vercel-button.svg b/src/templates/frontend/public/icons/vercel-button.svg new file mode 100644 index 00000000..614f528c --- /dev/null +++ b/src/templates/frontend/public/icons/vercel-button.svg @@ -0,0 +1,17 @@ + + + vercel-button + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/templates/frontend/public/images/inkathon-logo.png b/src/templates/frontend/public/images/inkathon-logo.png new file mode 100644 index 00000000..6bf77da9 Binary files /dev/null and b/src/templates/frontend/public/images/inkathon-logo.png differ diff --git a/src/templates/frontend/public/images/inkathon-og-banner.jpg b/src/templates/frontend/public/images/inkathon-og-banner.jpg new file mode 100644 index 00000000..9ed5b7a2 Binary files /dev/null and b/src/templates/frontend/public/images/inkathon-og-banner.jpg differ diff --git a/src/templates/frontend/src/app/components/home-page-title.tsx.hbs b/src/templates/frontend/src/app/components/home-page-title.tsx.hbs new file mode 100644 index 00000000..3d8639e1 --- /dev/null +++ b/src/templates/frontend/src/app/components/home-page-title.tsx.hbs @@ -0,0 +1,97 @@ +import Image from 'next/image' +import Link from 'next/link' +import { AnchorHTMLAttributes, FC } from 'react' + +import githubIcon from 'public/icons/github-button.svg' +import telegramIcon from 'public/icons/telegram-button.svg' +import vercelIcon from 'public/icons/vercel-button.svg' +import inkathonLogo from 'public/images/inkathon-logo.png' + +import { cn } from '@/utils/cn' + +interface StyledIconLinkProps extends AnchorHTMLAttributes { + href: string + className?: string +} + +const StyledIconLink: React.FC = ({ className, children, ...rest }) => ( + + {children} + +) + +export const HomePageTitle: FC = () => { + const title = 'ink!athon' + const desc = 'Full-Stack DApp Boilerplate for ink! Smart Contracts' + const githubHref = 'https://github.com/scio-labs/inkathon' + const deployHref = 'https://github.com/scio-labs/inkathon#deployment-' + const telegramHref = 'https://t.me/inkathon' + + return ( + <> +
+ {/* Logo & Title */} + + ink!athon Logo +

{title}

+ + + {/* Tagline & Lincks */} +

{desc}

+

+ Built by{' '} + + Dennis Zoma + {' '} + &{' '} + + Scio Labs + + . Supported by{' '} + + Aleph Zero + + . +

+ + {/* Github & Vercel Buttons */} +
+ + Github Repository + + + Deploy with Vercel + + + Telegram Group + +
+ +
+
+ + ) +} diff --git a/src/templates/frontend/src/app/components/home-top-bar.tsx.hbs b/src/templates/frontend/src/app/components/home-top-bar.tsx.hbs new file mode 100644 index 00000000..52afee5a --- /dev/null +++ b/src/templates/frontend/src/app/components/home-top-bar.tsx.hbs @@ -0,0 +1,26 @@ +'use client' + +import Link from 'next/link' +import { FC } from 'react' + +import { HiOutlineExternalLink } from 'react-icons/hi' + +export const HomeTopBar: FC = () => { + return ( + <> + +
+ VIDEO▶ +
+
+ Watch the sub0 ink!athon workshop (45 min) + sub0 ink!athon workshop +
+ + + + ) +} diff --git a/src/templates/frontend/src/app/favicon.ico b/src/templates/frontend/src/app/favicon.ico new file mode 100644 index 00000000..a0ec94bc Binary files /dev/null and b/src/templates/frontend/src/app/favicon.ico differ diff --git a/src/templates/frontend/src/app/globals.css.hbs b/src/templates/frontend/src/app/globals.css.hbs new file mode 100644 index 00000000..11053e95 --- /dev/null +++ b/src/templates/frontend/src/app/globals.css.hbs @@ -0,0 +1,80 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 224 71.4% 4.1%; + + --card: 0 0% 100%; + --card-foreground: 224 71.4% 4.1%; + + --popover: 0 0% 100%; + --popover-foreground: 224 71.4% 4.1%; + + --primary: 262.1 83.3% 57.8%; + --primary-foreground: 210 20% 98%; + + --secondary: 220 14.3% 95.9%; + --secondary-foreground: 220.9 39.3% 11%; + + --muted: 220 14.3% 95.9%; + --muted-foreground: 220 8.9% 46.1%; + + --accent: 220 14.3% 95.9%; + --accent-foreground: 220.9 39.3% 11%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 20% 98%; + + --border: 220 13% 91%; + --input: 220 13% 91%; + --ring: 262.1 83.3% 57.8%; + + --radius: 0.75rem; + } + + .dark { + --background: 224 71.4% 4.1%; + + --foreground: 210 20% 98%; + --card: 224 71.4% 4.1%; + --card-foreground: 210 20% 98%; + + --popover: 224 71.4% 4.1%; + --popover-foreground: 210 20% 98%; + + --primary: 263.4 70% 50.4%; + --primary-foreground: 210 20% 98%; + + --secondary: 215 27.9% 16.9%; + --secondary-foreground: 210 20% 98%; + + --muted: 215 27.9% 16.9%; + --muted-foreground: 217.9 10.6% 64.9%; + + --accent: 215 27.9% 16.9%; + --accent-foreground: 210 20% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 20% 98%; + + --border: 215 27.9% 16.9%; + --input: 215 27.9% 16.9%; + --ring: 263.4 70% 50.4%; + } +} + +@layer base { + * { + @apply border-border; + } + html { + @apply scroll-smooth antialiased; + } + body { + @apply bg-background font-sans text-foreground; + @apply flex h-screen min-h-screen flex-col; + } +} diff --git a/src/templates/frontend/src/app/layout.tsx.hbs b/src/templates/frontend/src/app/layout.tsx.hbs new file mode 100644 index 00000000..c76b07f4 --- /dev/null +++ b/src/templates/frontend/src/app/layout.tsx.hbs @@ -0,0 +1,59 @@ +import { Metadata, Viewport } from 'next' +import { PropsWithChildren } from 'react' + +import { Analytics } from '@vercel/analytics/react' +import { GeistMono } from 'geist/font/mono' +import { GeistSans } from 'geist/font/sans' + +import { ToastConfig } from '@/app/toast-config' +import { TooltipProvider } from '@/components/ui/tooltip' +import { env } from '@/config/environment' +import { cn } from '@/utils/cn' + +import './globals.css' +import ClientProviders from './providers' + +export const viewport: Viewport = { + themeColor: '#000000', + colorScheme: 'dark', +} + +export const metadata: Metadata = { + title: 'ink!athon Boilerplate', + description: 'Full-Stack DApp Boilerplate for ink! Smart Contracts', + metadataBase: new URL(env.url), + robots: env.isProduction ? 'all' : 'noindex,nofollow', + openGraph: { + type: 'website', + locale: 'en', + url: env.url, + siteName: 'ink!athon Boilerplate', + images: [ + { + url: '/images/inkathon-og-banner.jpg', + width: 1280, + height: 640, + }, + ], + }, + twitter: { + site: '@scio_xyz', + creator: '@scio_xyz', + card: 'summary_large_image', + }, +} + +export default function RootLayout({ children }: PropsWithChildren) { + return ( + + + + {children} + + + + {!!env.isProduction && } + + + ) +} diff --git a/src/templates/frontend/src/app/page.tsx.hbs b/src/templates/frontend/src/app/page.tsx.hbs new file mode 100644 index 00000000..c6bc494b --- /dev/null +++ b/src/templates/frontend/src/app/page.tsx.hbs @@ -0,0 +1,42 @@ +'use client' + +import { useEffect } from 'react' + +import { useInkathon } from '@scio-labs/use-inkathon' +import { toast } from 'react-hot-toast' + +import { HomePageTitle } from '@/app/components/home-page-title' +import { ChainInfo } from '@/components/web3/chain-info' +import { ConnectButton } from '@/components/web3/connect-button' +import { + FlipperContractInteractions, +} from "@/components/web3/flipper-contract-interactions"; + +export default function HomePage() { + // Display `useInkathon` error messages (optional) + const { error } = useInkathon() + useEffect(() => { + if (!error) return + toast.error(error.message) + }, [error]) + + return ( + <> +
+ {/* Title */} + + + {/* Connect Wallet Button */} + + +
+ {/* Chain Metadata Information */} + + + {/*Flipper Read/Write Contract Interactions */} + +
+
+ + ) +} diff --git a/src/templates/frontend/src/app/providers.tsx.hbs b/src/templates/frontend/src/app/providers.tsx.hbs new file mode 100644 index 00000000..7b040f2f --- /dev/null +++ b/src/templates/frontend/src/app/providers.tsx.hbs @@ -0,0 +1,21 @@ +'use client' + +import { PropsWithChildren } from 'react' + +import { getDeployments } from '@/deployments/deployments' +import { UseInkathonProvider } from '@scio-labs/use-inkathon' + +import { env } from '@/config/environment' + +export default function ClientProviders({ children }: PropsWithChildren) { + return ( + + {children} + + ) +} diff --git a/src/templates/frontend/src/app/toast-config.tsx.hbs b/src/templates/frontend/src/app/toast-config.tsx.hbs new file mode 100644 index 00000000..46874ac1 --- /dev/null +++ b/src/templates/frontend/src/app/toast-config.tsx.hbs @@ -0,0 +1,35 @@ +import { FC } from 'react' + +import { Toaster } from 'react-hot-toast' + +export const ToastConfig: FC = () => { + return ( + + ) +} diff --git a/src/templates/frontend/src/components/ui/button.tsx.hbs b/src/templates/frontend/src/components/ui/button.tsx.hbs new file mode 100644 index 00000000..006acdb4 --- /dev/null +++ b/src/templates/frontend/src/components/ui/button.tsx.hbs @@ -0,0 +1,66 @@ +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import * as React from 'react' + +import { cn } from '@/utils/cn' +import { Spinner } from './spinner' + +const buttonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean + isLoading?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, isLoading, children, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + + if (isLoading) + return ( + + + {children} + + ) + + return ( + + {children} + + ) + }, +) +Button.displayName = 'Button' + +export { Button, buttonVariants } diff --git a/src/templates/frontend/src/components/ui/card.tsx.hbs b/src/templates/frontend/src/components/ui/card.tsx.hbs new file mode 100644 index 00000000..487d1336 --- /dev/null +++ b/src/templates/frontend/src/components/ui/card.tsx.hbs @@ -0,0 +1,80 @@ +import * as React from "react" + +import { cn } from "@/utils/cn" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } + diff --git a/src/templates/frontend/src/components/ui/dropdown-menu.tsx.hbs b/src/templates/frontend/src/components/ui/dropdown-menu.tsx.hbs new file mode 100644 index 00000000..3a4e5442 --- /dev/null +++ b/src/templates/frontend/src/components/ui/dropdown-menu.tsx.hbs @@ -0,0 +1,190 @@ +"use client" + +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" +import * as React from "react" + +import { cn } from "@/utils/cn" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger +} + diff --git a/src/templates/frontend/src/components/ui/form.tsx.hbs b/src/templates/frontend/src/components/ui/form.tsx.hbs new file mode 100644 index 00000000..4353ff6b --- /dev/null +++ b/src/templates/frontend/src/components/ui/form.tsx.hbs @@ -0,0 +1,172 @@ +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import * as React from "react" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { Label } from "@/components/ui/label" +import { cn } from "@/utils/cn" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +