Build a dApp on Monolythium
This tutorial walks through building a complete decentralized application on Monolythium, from smart contract to frontend. You will deploy a token-gated message board where users deposit LYTH to post messages, and anyone can read them.
What You'll Build
A MessageBoard dApp with:
- A Solidity contract that accepts LYTH deposits and stores messages on-chain
- Hardhat tests to verify contract behavior
- Deployment to Monolythium testnet
- Verification on Monoscan
- A React frontend using viem to connect wallets and interact with the contract
Prerequisites
| Requirement | Version |
|---|---|
| Node.js | 18 or later |
| npm or yarn | Latest |
| MetaMask | Installed and connected to Monolythium testnet |
| Testnet LYTH | From the faucet |
Ensure MetaMask is configured for Monolythium testnet before proceeding. See MetaMask Setup for instructions.
Step 1: Project Setup
Create a new Hardhat project and install dependencies:
mkdir message-board && cd message-board
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init
Select Create a TypeScript project when prompted. Accept the defaults.
Install additional dependencies for the frontend (used in Step 7):
npm install viem
Step 2: Write the Contract
Create contracts/MessageBoard.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
/// @title MessageBoard
/// @notice A token-gated message board. Users deposit LYTH to post messages.
contract MessageBoard {
struct Message {
address author;
string content;
uint256 timestamp;
uint256 deposit;
}
Message[] public messages;
uint256 public minimumDeposit;
address public owner;
event MessagePosted(
uint256 indexed messageId,
address indexed author,
string content,
uint256 deposit
);
event DepositUpdated(uint256 oldDeposit, uint256 newDeposit);
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
constructor(uint256 _minimumDeposit) {
owner = msg.sender;
minimumDeposit = _minimumDeposit;
}
/// @notice Post a message. Must send at least minimumDeposit in LYTH.
/// @param _content The message text
function postMessage(string calldata _content) external payable {
require(msg.value >= minimumDeposit, "Deposit too low");
require(bytes(_content).length > 0, "Empty message");
require(bytes(_content).length <= 1024, "Message too long");
uint256 messageId = messages.length;
messages.push(Message({
author: msg.sender,
content: _content,
timestamp: block.timestamp,
deposit: msg.value
}));
emit MessagePosted(messageId, msg.sender, _content, msg.value);
}
/// @notice Get the total number of messages
function messageCount() external view returns (uint256) {
return messages.length;
}
/// @notice Get a message by ID
function getMessage(uint256 _id) external view returns (
address author,
string memory content,
uint256 timestamp,
uint256 deposit
) {
require(_id < messages.length, "Message does not exist");
Message storage m = messages[_id];
return (m.author, m.content, m.timestamp, m.deposit);
}
/// @notice Update the minimum deposit (owner only)
function setMinimumDeposit(uint256 _newDeposit) external onlyOwner {
emit DepositUpdated(minimumDeposit, _newDeposit);
minimumDeposit = _newDeposit;
}
/// @notice Withdraw collected deposits (owner only)
function withdraw() external onlyOwner {
uint256 balance = address(this).balance;
require(balance > 0, "No balance");
(bool sent, ) = owner.call{value: balance}("");
require(sent, "Withdraw failed");
}
}
Step 3: Write Tests
Create test/MessageBoard.test.ts:
import { expect } from "chai";
import { ethers } from "hardhat";
import { MessageBoard } from "../typechain-types";
describe("MessageBoard", function () {
let board: MessageBoard;
let owner: any;
let user: any;
const MIN_DEPOSIT = ethers.parseEther("0.01");
beforeEach(async function () {
[owner, user] = await ethers.getSigners();
const factory = await ethers.getContractFactory("MessageBoard");
board = await factory.deploy(MIN_DEPOSIT);
await board.waitForDeployment();
});
describe("Posting messages", function () {
it("should accept a message with sufficient deposit", async function () {
await board.connect(user).postMessage("Hello Monolythium!", {
value: MIN_DEPOSIT,
});
const count = await board.messageCount();
expect(count).to.equal(1n);
const [author, content, , deposit] = await board.getMessage(0);
expect(author).to.equal(user.address);
expect(content).to.equal("Hello Monolythium!");
expect(deposit).to.equal(MIN_DEPOSIT);
});
it("should reject a message with insufficient deposit", async function () {
await expect(
board.connect(user).postMessage("Should fail", { value: 0 })
).to.be.revertedWith("Deposit too low");
});
it("should reject an empty message", async function () {
await expect(
board.connect(user).postMessage("", { value: MIN_DEPOSIT })
).to.be.revertedWith("Empty message");
});
it("should emit MessagePosted event", async function () {
await expect(
board.connect(user).postMessage("Event test", { value: MIN_DEPOSIT })
)
.to.emit(board, "MessagePosted")
.withArgs(0, user.address, "Event test", MIN_DEPOSIT);
});
});
describe("Owner functions", function () {
it("should allow owner to update minimum deposit", async function () {
const newDeposit = ethers.parseEther("0.05");
await board.setMinimumDeposit(newDeposit);
expect(await board.minimumDeposit()).to.equal(newDeposit);
});
it("should prevent non-owner from updating deposit", async function () {
await expect(
board.connect(user).setMinimumDeposit(0)
).to.be.revertedWith("Not owner");
});
it("should allow owner to withdraw", async function () {
await board.connect(user).postMessage("Deposit", { value: MIN_DEPOSIT });
const balanceBefore = await ethers.provider.getBalance(owner.address);
const tx = await board.withdraw();
const receipt = await tx.wait();
const gasCost = receipt!.gasUsed * receipt!.gasPrice;
const balanceAfter = await ethers.provider.getBalance(owner.address);
expect(balanceAfter).to.equal(balanceBefore + MIN_DEPOSIT - gasCost);
});
});
});
Run the tests:
npx hardhat test
Expected output:
MessageBoard
Posting messages
✔ should accept a message with sufficient deposit
✔ should reject a message with insufficient deposit
✔ should reject an empty message
✔ should emit MessagePosted event
Owner functions
✔ should allow owner to update minimum deposit
✔ should prevent non-owner from updating deposit
✔ should allow owner to withdraw
7 passing
Step 4: Configure Network
Update hardhat.config.ts to add the Monolythium testnet:
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
const config: HardhatUserConfig = {
solidity: "0.8.19",
networks: {
"monolythium-testnet": {
url: "https://evm.testnet.mononodes.xyz",
chainId: 6940,
accounts: [process.env.PRIVATE_KEY!],
},
"monolythium-mainnet": {
url: "https://evm.mainnet.mononodes.xyz",
chainId: 6941,
accounts: [process.env.PRIVATE_KEY!],
},
},
etherscan: {
apiKey: {
"monolythium-testnet": "",
},
customChains: [
{
network: "monolythium-testnet",
chainId: 6940,
urls: {
apiURL: "https://testnet.monoscan.xyz/api",
browserURL: "https://testnet.monoscan.xyz",
},
},
],
},
};
export default config;
Use environment variables or a .env file (added to .gitignore) for your private key. Never hardcode secrets in source files.
Set your private key:
export PRIVATE_KEY=your_testnet_private_key_here
Step 5: Deploy
Create scripts/deploy.ts:
import { ethers } from "hardhat";
async function main() {
const minimumDeposit = ethers.parseEther("0.01"); // 0.01 LYTH
console.log("Deploying MessageBoard...");
const factory = await ethers.getContractFactory("MessageBoard");
const board = await factory.deploy(minimumDeposit);
await board.waitForDeployment();
const address = await board.getAddress();
console.log("MessageBoard deployed to:", address);
console.log("Minimum deposit:", ethers.formatEther(minimumDeposit), "LYTH");
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Deploy to testnet:
npx hardhat run scripts/deploy.ts --network monolythium-testnet
Expected output:
Deploying MessageBoard...
MessageBoard deployed to: 0x1234...abcd
Minimum deposit: 0.01 LYTH
Save the deployed contract address for the next steps.
Step 6: Verify on Monoscan
Verify the contract source code so anyone can read and interact with it on Monoscan:
npx hardhat verify --network monolythium-testnet <contract-address> 10000000000000000
The constructor argument 10000000000000000 is 0.01 LYTH expressed in alyth (18 decimals).
After verification, your contract will show a green checkmark on Monoscan with the full source code and a read/write interface.
See Contract Verification for more options including Foundry verification and proxy contracts.
Step 7: Frontend
Below is a minimal React component using viem to interact with the deployed contract.
Define the Chain
// src/chains.ts
import { defineChain } from "viem";
export const monolythiumTestnet = defineChain({
id: 6940,
name: "Monolythium Testnet",
nativeCurrency: {
name: "Lythium",
symbol: "LYTH",
decimals: 18,
},
rpcUrls: {
default: {
http: ["https://evm.testnet.mononodes.xyz"],
},
},
blockExplorers: {
default: {
name: "Monoscan",
url: "https://testnet.monoscan.xyz",
},
},
});
Contract ABI (excerpt)
// src/abi.ts
export const messageBoardAbi = [
{
inputs: [{ name: "_content", type: "string" }],
name: "postMessage",
outputs: [],
stateMutability: "payable",
type: "function",
},
{
inputs: [],
name: "messageCount",
outputs: [{ name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
{
inputs: [{ name: "_id", type: "uint256" }],
name: "getMessage",
outputs: [
{ name: "author", type: "address" },
{ name: "content", type: "string" },
{ name: "timestamp", type: "uint256" },
{ name: "deposit", type: "uint256" },
],
stateMutability: "view",
type: "function",
},
] as const;
React Component
// src/MessageBoard.tsx
import { createPublicClient, createWalletClient, custom, http, parseEther } from "viem";
import { monolythiumTestnet } from "./chains";
import { messageBoardAbi } from "./abi";
const CONTRACT_ADDRESS = "0x..."; // Your deployed contract address
const publicClient = createPublicClient({
chain: monolythiumTestnet,
transport: http(),
});
async function connectWallet() {
if (!window.ethereum) throw new Error("No wallet found");
const walletClient = createWalletClient({
chain: monolythiumTestnet,
transport: custom(window.ethereum),
});
const [address] = await walletClient.requestAddresses();
return { walletClient, address };
}
async function postMessage(content: string) {
const { walletClient, address } = await connectWallet();
const hash = await walletClient.writeContract({
address: CONTRACT_ADDRESS,
abi: messageBoardAbi,
functionName: "postMessage",
args: [content],
value: parseEther("0.01"),
account: address,
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
console.log("Message posted in block:", receipt.blockNumber);
}
async function readMessages() {
const count = await publicClient.readContract({
address: CONTRACT_ADDRESS,
abi: messageBoardAbi,
functionName: "messageCount",
});
const messages = [];
for (let i = 0n; i < count; i++) {
const result = await publicClient.readContract({
address: CONTRACT_ADDRESS,
abi: messageBoardAbi,
functionName: "getMessage",
args: [i],
});
messages.push({
author: result[0],
content: result[1],
timestamp: Number(result[2]),
deposit: result[3],
});
}
return messages;
}
For production dApps, use wagmi for React hooks, connection management, and multi-wallet support. The example above uses raw viem for clarity.
Next Steps
You now have a working dApp deployed on Monolythium. From here you can:
- Add precompile calls -- Use Precompiled Contracts to integrate staking or governance into your dApp
- Optimize gas -- Profile your contracts and reduce deployment costs
- Explore tooling -- Set up a full development workflow with Hardhat & Foundry
- Verify complex contracts -- Learn about proxy verification in Contract Verification
Related
- Hardhat & Foundry -- Development framework configuration
- Contract Verification -- Source code verification on Monoscan
- RPC Endpoints -- Network endpoint reference
- MetaMask Setup -- Wallet configuration
- Faucet -- Get testnet LYTH