Skip to main content

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

RequirementVersion
Node.js18 or later
npm or yarnLatest
MetaMaskInstalled and connected to Monolythium testnet
Testnet LYTHFrom 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;
Never Commit Private Keys

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;
}
Production Tip

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