Install
openclaw skills install jovay-dappFull-stack dApp generation skill for Jovay blockchain — from requirements gathering to contract deployment and frontend debugging
openclaw skills install jovay-dappThis skill generates complete full-stack dApps on the Jovay Layer 2 EVM blockchain. It guides the AI through an end-to-end workflow: gathering requirements from the user, initializing the project, generating Solidity smart contracts, compiling and testing, generating a Vue 3 frontend, deploying contracts to testnet, and running the frontend locally for debugging.
jovay-cli installed (npm install -g @jovaylabs/jovay-cli)jovay init) with wallet configuredjovay wallet airdrop)1. Requirements Gathering → Ask user about dApp type, features, contract logic
2. Project Initialization → jovay dapp init --name <project>
3. Install Dependencies → npm install, OpenZeppelin, etc.
4. Generate Contracts → Write Solidity contracts in contracts/
5. Compile & Test → jovay dapp build / npx hardhat test
6. Generate Frontend → Write Vue 3 frontend in frontend/
7. Deploy Contracts → jovay dapp deploy (testnet)
8. Local Frontend Debug → cd frontend && npm run dev
When the user says they want to generate a dApp, you MUST ask clarifying questions before proceeding. Continue asking until you have enough information to generate the complete dApp.
Ask the user about the following (adapt based on context):
User: "I want to build an NFT dApp"
AI should ask:
- What kind of NFTs? (art, collectibles, membership, gaming items)
- Should there be a minting limit (max supply)?
- Should users be able to mint, or only the owner?
- Do you need a whitelist/allowlist mechanism?
- Should NFTs be burnable or transferable?
- What metadata standard? (on-chain or off-chain URI)
- Frontend needs: gallery view, mint page, profile page?
- Any royalty mechanism (ERC2981)?
Before creating the project, verify the CLI is initialized:
jovay network get
If not initialized:
jovay init --enc
Check testnet balance:
jovay wallet balance
If balance is zero, request airdrop:
jovay wallet airdrop
jovay dapp init --name <project_name>
This clones the EasyJovayDappTemplate from GitHub and creates the project structure:
<project_name>/
├── contracts/ # Solidity smart contracts
├── frontend/ # Vue 3 + Vite frontend
│ ├── src/
│ │ ├── components/ # Reusable Vue components
│ │ ├── views/ # Page components
│ │ ├── stores/ # Pinia state management
│ │ ├── config/ # Configuration files
│ │ └── contracts/ # Contract ABI files for frontend
├── tools/ # Build and deploy shell scripts
├── scripts/ # Hardhat deployment scripts (JS)
├── hardhat.config.cjs # Hardhat configuration
├── package.json # Root package.json with Hardhat deps
└── .jovay/
└── .env # JSON: { PROJECT_NAME, TEMPLATE_NAME }
cd <project_name>
# Install base deps (Hardhat, ethers, etc.)
npm install
# Install OpenZeppelin contracts (almost always needed)
npm install @openzeppelin/contracts
# If the template has an install script:
# chmod +x tools/install_deps.sh && ./tools/install_deps.sh
Write Solidity files into the contracts/ directory. Remove any sample contracts that came with the template if they are not needed.
The template includes a hardhat.config.cjs that reads environment variables for Jovay testnet. Ensure it includes:
require("@nomicfoundation/hardhat-toolbox");
module.exports = {
solidity: "0.8.20",
networks: {
jovay_testnet: {
url: process.env.JOVAY_TESTNET_RPC_URL || "https://testnet-rpc.jovay.xyz",
accounts: process.env.DEPLOYER_PRIVATE_KEY
? [process.env.DEPLOYER_PRIVATE_KEY]
: [],
},
},
};
Below are common contract patterns. Adapt and combine based on user requirements.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyToken is ERC20, ERC20Burnable, Ownable {
constructor(
string memory name,
string memory symbol,
uint256 initialSupply,
address initialOwner
) ERC20(name, symbol) Ownable(initialOwner) {
_mint(initialOwner, initialSupply * 10 ** decimals());
}
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyNFT is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable {
uint256 private _nextTokenId;
uint256 public maxSupply;
uint256 public mintPrice;
constructor(
string memory name,
string memory symbol,
uint256 _maxSupply,
uint256 _mintPrice,
address initialOwner
) ERC721(name, symbol) Ownable(initialOwner) {
maxSupply = _maxSupply;
mintPrice = _mintPrice;
}
function safeMint(address to, string memory uri) public payable {
require(_nextTokenId < maxSupply, "Max supply reached");
if (msg.sender != owner()) {
require(msg.value >= mintPrice, "Insufficient payment");
}
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
}
function withdraw() public onlyOwner {
payable(owner()).transfer(address(this).balance);
}
// Required overrides
function _update(address to, uint256 tokenId, address auth)
internal override(ERC721, ERC721Enumerable) returns (address) {
return super._update(to, tokenId, auth);
}
function _increaseBalance(address account, uint128 value)
internal override(ERC721, ERC721Enumerable) {
super._increaseBalance(account, value);
}
function tokenURI(uint256 tokenId)
public view override(ERC721, ERC721URIStorage) returns (string memory) {
return super.tokenURI(tokenId);
}
function supportsInterface(bytes4 interfaceId)
public view override(ERC721, ERC721Enumerable, ERC721URIStorage) returns (bool) {
return super.supportsInterface(interfaceId);
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyMultiToken is ERC1155, Ownable {
mapping(uint256 => string) private _tokenURIs;
constructor(address initialOwner)
ERC1155("") Ownable(initialOwner) {}
function mint(address to, uint256 id, uint256 amount, bytes memory data) public onlyOwner {
_mint(to, id, amount, data);
}
function mintBatch(address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data) public onlyOwner {
_mintBatch(to, ids, amounts, data);
}
function setURI(uint256 tokenId, string memory newUri) public onlyOwner {
_tokenURIs[tokenId] = newUri;
}
function uri(uint256 tokenId) public view override returns (string memory) {
string memory tokenUri = _tokenURIs[tokenId];
return bytes(tokenUri).length > 0 ? tokenUri : super.uri(tokenId);
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";
contract SimpleVoting is Ownable {
struct Proposal {
string description;
uint256 voteCount;
uint256 deadline;
bool executed;
}
mapping(uint256 => Proposal) public proposals;
mapping(uint256 => mapping(address => bool)) public hasVoted;
uint256 public proposalCount;
event ProposalCreated(uint256 indexed proposalId, string description, uint256 deadline);
event Voted(uint256 indexed proposalId, address indexed voter);
constructor(address initialOwner) Ownable(initialOwner) {}
function createProposal(string memory description, uint256 duration) public onlyOwner returns (uint256) {
uint256 proposalId = proposalCount++;
proposals[proposalId] = Proposal({
description: description,
voteCount: 0,
deadline: block.timestamp + duration,
executed: false
});
emit ProposalCreated(proposalId, description, block.timestamp + duration);
return proposalId;
}
function vote(uint256 proposalId) public {
Proposal storage proposal = proposals[proposalId];
require(block.timestamp < proposal.deadline, "Voting ended");
require(!hasVoted[proposalId][msg.sender], "Already voted");
hasVoted[proposalId][msg.sender] = true;
proposal.voteCount++;
emit Voted(proposalId, msg.sender);
}
function getProposal(uint256 proposalId) public view returns (string memory, uint256, uint256, bool) {
Proposal storage p = proposals[proposalId];
return (p.description, p.voteCount, p.deadline, p.executed);
}
}
When generating contracts:
Option A — Using jovay dapp build:
jovay dapp build
# Or with encrypted wallet:
jovay dapp build --enc-key <YOUR_ENC_KEY>
Option B — Using Hardhat directly (recommended for more control):
npx hardhat compile
After compilation, ABIs are generated in artifacts/contracts/.
Create test files in the test/ directory using Hardhat's testing framework:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("MyNFT", function () {
let myNFT;
let owner;
let addr1;
beforeEach(async function () {
[owner, addr1] = await ethers.getSigners();
const MyNFT = await ethers.getContractFactory("MyNFT");
myNFT = await MyNFT.deploy("TestNFT", "TNFT", 100, ethers.parseEther("0.01"), owner.address);
await myNFT.waitForDeployment();
});
it("Should mint an NFT", async function () {
await myNFT.safeMint(owner.address, "https://example.com/token/0");
expect(await myNFT.ownerOf(0)).to.equal(owner.address);
expect(await myNFT.tokenURI(0)).to.equal("https://example.com/token/0");
});
it("Should enforce max supply", async function () {
// Test max supply logic
});
it("Should enforce mint price for non-owner", async function () {
await expect(
myNFT.connect(addr1).safeMint(addr1.address, "uri", { value: 0 })
).to.be.revertedWith("Insufficient payment");
});
});
Run tests:
npx hardhat test
After successful compilation, the ABIs are at:
artifacts/contracts/<ContractName>.sol/<ContractName>.json
Copy the ABI to the frontend for use:
# Extract ABI and copy to frontend
cp artifacts/contracts/<ContractName>.sol/<ContractName>.json frontend/src/contracts/
The template provides a Vue 3 + Vite + Pinia + ethers.js frontend scaffold. You need to customize it for the specific dApp.
<script setup>)frontend/
├── src/
│ ├── App.vue # Root component
│ ├── main.js # App entry point
│ ├── router/
│ │ └── index.js # Vue Router configuration
│ ├── views/ # Page-level components
│ │ ├── HomeView.vue
│ │ └── ...
│ ├── components/ # Reusable components
│ │ ├── WalletConnect.vue
│ │ └── ...
│ ├── stores/ # Pinia stores
│ │ ├── wallet.js # Wallet connection state
│ │ └── contract.js # Contract interaction state
│ ├── contracts/ # ABI JSON files
│ │ └── <ContractName>.json
│ └── config/
│ └── index.js # Chain config, contract addresses
├── package.json
├── vite.config.js
└── index.html
frontend/src/config/index.js)export const CHAIN_CONFIG = {
chainId: "0xC352", // Jovay Sepolia testnet chain ID (50002)
chainName: "Jovay Sepolia Testnet",
rpcUrls: ["https://testnet-rpc.jovay.xyz"],
nativeCurrency: {
name: "ETH",
symbol: "ETH",
decimals: 18,
},
blockExplorerUrls: ["https://testnet-explorer.jovay.xyz"],
};
export const CONTRACT_ADDRESSES = {
// Fill in after deployment
MyContract: "0x...",
};
frontend/src/stores/wallet.js)import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { BrowserProvider } from "ethers";
import { CHAIN_CONFIG } from "@/config";
export const useWalletStore = defineStore("wallet", () => {
const provider = ref(null);
const signer = ref(null);
const address = ref("");
const chainId = ref("");
const isConnected = computed(() => !!address.value);
async function connect() {
if (!window.ethereum) {
alert("Please install MetaMask!");
return;
}
const browserProvider = new BrowserProvider(window.ethereum);
const accounts = await browserProvider.send("eth_requestAccounts", []);
provider.value = browserProvider;
signer.value = await browserProvider.getSigner();
address.value = accounts[0];
const network = await browserProvider.getNetwork();
chainId.value = "0x" + network.chainId.toString(16);
if (chainId.value.toLowerCase() !== CHAIN_CONFIG.chainId.toLowerCase()) {
await switchChain();
}
window.ethereum.on("accountsChanged", handleAccountsChanged);
window.ethereum.on("chainChanged", () => window.location.reload());
}
async function switchChain() {
try {
await window.ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: CHAIN_CONFIG.chainId }],
});
} catch (switchError) {
if (switchError.code === 4902) {
await window.ethereum.request({
method: "wallet_addEthereumChain",
params: [CHAIN_CONFIG],
});
}
}
}
function handleAccountsChanged(accounts) {
if (accounts.length === 0) {
disconnect();
} else {
address.value = accounts[0];
}
}
function disconnect() {
provider.value = null;
signer.value = null;
address.value = "";
chainId.value = "";
}
function shortenAddress(addr) {
if (!addr) return "";
return addr.slice(0, 6) + "..." + addr.slice(-4);
}
return {
provider, signer, address, chainId, isConnected,
connect, disconnect, switchChain, shortenAddress,
};
});
frontend/src/stores/contract.js)import { defineStore } from "pinia";
import { ref } from "vue";
import { Contract } from "ethers";
import { useWalletStore } from "./wallet";
import { CONTRACT_ADDRESSES } from "@/config";
import ContractABI from "@/contracts/<ContractName>.json";
export const useContractStore = defineStore("contract", () => {
const contract = ref(null);
const loading = ref(false);
const error = ref("");
function getContract() {
const wallet = useWalletStore();
if (!wallet.signer) throw new Error("Wallet not connected");
if (!contract.value) {
contract.value = new Contract(
CONTRACT_ADDRESSES.MyContract,
ContractABI.abi,
wallet.signer
);
}
return contract.value;
}
async function callReadMethod(method, ...args) {
loading.value = true;
error.value = "";
try {
const c = getContract();
return await c[method](...args);
} catch (e) {
error.value = e.message;
throw e;
} finally {
loading.value = false;
}
}
async function callWriteMethod(method, ...args) {
loading.value = true;
error.value = "";
try {
const c = getContract();
const tx = await c[method](...args);
const receipt = await tx.wait();
return receipt;
} catch (e) {
error.value = e.message;
throw e;
} finally {
loading.value = false;
}
}
return { contract, loading, error, getContract, callReadMethod, callWriteMethod };
});
frontend/src/components/WalletConnect.vue)<script setup>
import { useWalletStore } from "@/stores/wallet";
const wallet = useWalletStore();
</script>
<template>
<button v-if="!wallet.isConnected" @click="wallet.connect" class="connect-btn">
Connect Wallet
</button>
<div v-else class="wallet-info">
<span class="address">{{ wallet.shortenAddress(wallet.address) }}</span>
<button @click="wallet.disconnect" class="disconnect-btn">Disconnect</button>
</div>
</template>
<style scoped>
.connect-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.connect-btn:hover { opacity: 0.9; }
.wallet-info { display: flex; align-items: center; gap: 12px; }
.address {
background: #f0f0f0;
padding: 8px 16px;
border-radius: 8px;
font-family: monospace;
font-size: 14px;
}
.disconnect-btn {
background: #ff4757;
color: white;
border: none;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
}
</style>
When generating frontend pages:
cd frontend
npm install
# ethers should already be included; if not:
npm install ethers
Create or update scripts/deploy.js:
const { ethers } = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying contracts with the account:", deployer.address);
console.log("Account balance:", (await deployer.provider.getBalance(deployer.address)).toString());
// Deploy each contract
const MyContract = await ethers.getContractFactory("MyContract");
const myContract = await MyContract.deploy(/* constructor args */);
await myContract.waitForDeployment();
console.log("MyContract deployed to:", myContract.target);
// If deploying multiple contracts:
// const Token = await ethers.getContractFactory("Token");
// const token = await Token.deploy(/* args */);
// await token.waitForDeployment();
// console.log("Token deployed to:", token.target);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Option A — Using jovay dapp deploy:
jovay dapp deploy
# Or with encrypted wallet:
jovay dapp deploy --enc-key <YOUR_ENC_KEY>
Option B — Using Hardhat directly:
JOVAY_TESTNET_RPC_URL="<rpc_url>" DEPLOYER_PRIVATE_KEY="<private_key>" \
npx hardhat run scripts/deploy.js --network jovay_testnet
After deployment:
// frontend/src/config/index.js
export const CONTRACT_ADDRESSES = {
MyContract: "0x<deployed_address>",
};
cp artifacts/contracts/MyContract.sol/MyContract.json frontend/src/contracts/
jovay transaction info --tx <deployment_tx_hash>
cd frontend
npm run dev
This starts the Vite dev server (typically at http://localhost:5173).
Users need to add Jovay Sepolia testnet to MetaMask:
| Field | Value |
|---|---|
| Network Name | Jovay Sepolia Testnet |
| RPC URL | https://testnet-rpc.jovay.xyz |
| Chain ID | 50002 |
| Currency Symbol | ETH |
| Block Explorer | https://testnet-explorer.jovay.xyz |
The frontend's switchChain() function handles this automatically via wallet_addEthereumChain.
This example demonstrates the full workflow for a simple NFT minting dApp.
<example> <user_query>I want to build an NFT minting dApp on Jovay</user_query> <assistant_response>Ask the user:
jovay dapp init --name my-nft-dapp
cd my-nft-dapp
npm install
npm install @openzeppelin/contracts
Create contracts/MyNFT.sol with ERC721 + URIStorage + Enumerable + Ownable.
Update scripts/deploy.js to deploy MyNFT with constructor arguments.
npx hardhat compile
npx hardhat test
jovay dapp deploy
Record the deployed contract address.
frontend/src/config/index.js with contract addressfrontend/src/contracts/cd frontend
npm install
npm run dev
Open http://localhost:5173, connect MetaMask, test minting.
</assistant_response> </example>
<example> <user_query>Help me create a token airdrop dApp</user_query> <assistant_response>Ask the user:
Two contracts needed:
MyToken.sol — ERC20 with mintingAirdrop.sol — Holds tokens and manages claimsDeploy token → deploy airdrop → transfer tokens to airdrop contract.
</assistant_response> </example>
<example> <user_query>Build a simple voting dApp</user_query> <assistant_response>Ask the user:
Create contracts/Voting.sol with proposal creation, voting, and result tallying.
Deploy contract, create test proposals through CLI or frontend.
</assistant_response> </example>
jovay network switch --network testnet to ensure testnet.jovay wallet airdrop (limited to once per 24h, gives 0.001 ETH).frontend/src/contracts/.jovay init --enc, they need --enc-key for build and deploy commands.jovay_testnet as the network name. The jovay dapp build/deploy commands handle the environment variables automatically.