Walkthrough: build a Web3 app
Build a working decentralized library from scratch—list and browse books, then rent one by paying test TRX through TronLink.
This walkthrough builds a decentralized-library DApp from an empty directory. Owners list books with a daily price; other users browse the catalog and pay test TRX through TronLink to rent a book. The contract records the renter and expiration time and sends the rental payment directly to the owner.
The complete Solidity contract and frontend are included below, with no frontend-framework dependency. You will:
- Deploy a contract to the Shasta testnet with TronBox.
- Request wallet authorization through
window.tronand obtain a TronWeb instance. - Browse and list books with TronWeb and attach TRX to a rental transaction.
- Refresh each book's rental status after confirmation.
Prerequisites
Before starting, install or prepare:
- Node.js 20 or later, with npm.
- TronBox, installed with
npm install -g tronbox. - The TronLink browser extension, switched to Shasta.
- Shasta test TRX in both the deployment account and the TronLink account. Use a testnet faucet if needed.
Use testnet accounts only. Do not use a private key that controls Mainnet assets for this tutorial.
Create the project
Initialize a TronBox project and create a frontend directory:
mkdir tron-library
cd tron-library
tronbox init
mkdir frontendWhen tronbox init asks for a template, select Create a sample project. The tree below lists only the files added or changed by this tutorial; the other generated files can remain in place.
The relevant project files are:
tron-library/
├── contracts/
│ └── DecentralizedLibrary.sol
├── migrations/
│ └── 2_deploy_library.js
├── frontend/
│ ├── index.html
│ └── app.js
└── tronbox-config.jsWrite the smart contract
Create contracts/DecentralizedLibrary.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.6;
contract DecentralizedLibrary {
struct Book {
string title;
string description;
uint256 dailyPrice;
address payable owner;
}
struct Rental {
address renter;
uint256 startTime;
uint256 endTime;
}
Book[] private books;
mapping(uint256 => Rental) private rentals;
event BookAdded(
uint256 indexed bookId,
string title,
uint256 dailyPrice,
address indexed owner
);
event BookRented(
uint256 indexed bookId,
address indexed renter,
uint256 startTime,
uint256 endTime,
uint256 totalPrice
);
function addBook(
string calldata title,
string calldata description,
uint256 dailyPrice
) external {
require(bytes(title).length > 0, "Title is required");
require(dailyPrice > 0, "Daily price must be positive");
books.push(Book(title, description, dailyPrice, payable(msg.sender)));
emit BookAdded(books.length - 1, title, dailyPrice, msg.sender);
}
function rentBook(uint256 bookId, uint256 rentalDays) external payable {
require(bookId < books.length, "Book does not exist");
require(rentalDays > 0 && rentalDays <= 30, "Rental period must be 1-30 days");
require(isAvailable(bookId), "Book is already rented");
Book storage book = books[bookId];
uint256 totalPrice = book.dailyPrice * rentalDays;
require(msg.value == totalPrice, "Incorrect rental payment");
uint256 startTime = block.timestamp;
uint256 endTime = startTime + rentalDays * 1 days;
rentals[bookId] = Rental(msg.sender, startTime, endTime);
(bool sent, ) = book.owner.call{value: msg.value}("");
require(sent, "Rental payment failed");
emit BookRented(bookId, msg.sender, startTime, endTime, totalPrice);
}
function bookCount() external view returns (uint256) {
return books.length;
}
function isAvailable(uint256 bookId) public view returns (bool) {
require(bookId < books.length, "Book does not exist");
Rental storage rental = rentals[bookId];
return rental.renter == address(0) || block.timestamp >= rental.endTime;
}
function getBook(uint256 bookId)
external
view
returns (
string memory title,
string memory description,
uint256 dailyPrice,
address owner,
bool available,
address renter,
uint256 rentalEndTime
)
{
require(bookId < books.length, "Book does not exist");
Book storage book = books[bookId];
Rental storage rental = rentals[bookId];
return (
book.title,
book.description,
book.dailyPrice,
book.owner,
isAvailable(bookId),
rental.renter,
rental.endTime
);
}
}bookCount(), getBook(), and isAvailable() are read-only calls. addBook() and rentBook() change state and require a TronLink signature. A rental's callValue must equal the daily price multiplied by the number of days. The contract records the rental before sending the test TRX to the owner. isAvailable() automatically returns true after the rental expires.
This example implements on-chain book information, rental status, and rent payments only. Physical delivery, deposits, refunds, and dispute handling are outside its scope.
Create migrations/2_deploy_library.js:
const DecentralizedLibrary = artifacts.require('DecentralizedLibrary');
module.exports = function (deployer) {
deployer.deploy(DecentralizedLibrary);
};Configure and deploy to Shasta
Configure tronbox-config.js as follows:
module.exports = {
networks: {
shasta: {
privateKey: process.env.PRIVATE_KEY,
userFeePercentage: 100,
feeLimit: 1000 * 1e6,
fullHost: 'https://api.shasta.trongrid.io',
network_id: '2'
}
},
compilers: {
solc: {
version: '0.8.6'
}
}
};Provide the testnet key through an environment variable, then compile and deploy:
export PRIVATE_KEY="your-shasta-private-key-without-0x-prefix"
tronbox compile
tronbox migrate --network shastaDo not place the private key in tronbox-config.js or commit it to version control. After deployment, TronBox prints the DecentralizedLibrary contract address. Save this address, which starts with T; the frontend needs it to initialize the contract.
Create the frontend page
Create frontend/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TRON Decentralized Library</title>
<style>
body { font-family: sans-serif; max-width: 720px; margin: 40px auto; padding: 0 16px; }
header, form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
input, button { font: inherit; padding: 8px 12px; }
li { margin: 16px 0; }
li p { margin: 4px 0; }
#status { min-height: 24px; }
</style>
</head>
<body>
<header>
<h1>TRON Decentralized Library</h1>
<button id="connect" type="button">Connect TronLink</button>
<button id="refresh" type="button" disabled>Refresh catalog</button>
</header>
<p id="account">Wallet not connected</p>
<form id="book-form">
<input id="title" name="title" placeholder="Title" required>
<input id="description" name="description" placeholder="Description">
<input id="daily-price" name="dailyPrice" type="number" min="0.000001" step="0.000001" placeholder="Daily price (TRX)" required>
<button id="list-book" type="submit">List book</button>
</form>
<p id="status" role="status"></p>
<h2>Book catalog</h2>
<ol id="books"></ol>
<script type="module" src="./app.js"></script>
</body>
</html>Connect TronLink and call the contract
Create frontend/app.js and replace REPLACE_WITH_CONTRACT_ADDRESS with the deployed contract address:
const CONTRACT_ADDRESS = 'REPLACE_WITH_CONTRACT_ADDRESS';
const connectButton = document.querySelector('#connect');
const refreshButton = document.querySelector('#refresh');
const listBookButton = document.querySelector('#list-book');
const accountElement = document.querySelector('#account');
const form = document.querySelector('#book-form');
const statusElement = document.querySelector('#status');
const booksElement = document.querySelector('#books');
let provider;
let tronWeb;
let contract;
function setStatus(message) {
statusElement.textContent = message;
}
function formatAddress(address) {
const value = String(address);
return value.startsWith('41') ? tronWeb.address.fromHex(value) : value;
}
async function waitForTransaction(txID) {
for (let attempt = 0; attempt < 30; attempt += 1) {
try {
const receipt = await tronWeb.trx.getTransactionInfo(txID);
if (receipt && receipt.id) {
return receipt;
}
} catch (error) {
// A temporary RPC error does not mean that the transaction failed.
}
await new Promise(resolve => setTimeout(resolve, 3000));
}
throw new Error(`Confirmation timed out. Check txID: ${txID}`);
}
async function confirmTransaction(txID) {
try {
const receipt = await waitForTransaction(txID);
const result = receipt.result ?? receipt.receipt?.result;
return result && result !== 'SUCCESS'
? { status: 'failed', result }
: { status: 'success' };
} catch (error) {
return { status: 'unknown', error };
}
}
async function loadBooks() {
const nextBooks = document.createDocumentFragment();
const countValue = await contract.bookCount().call();
const count = Number(countValue.toString());
for (let bookId = 0; bookId < count; bookId += 1) {
const result = await contract.getBook(bookId).call();
const title = result.title ?? result[0];
const description = result.description ?? result[1];
const dailyPrice = result.dailyPrice ?? result[2];
const owner = result.owner ?? result[3];
const available = result.available ?? result[4];
const renter = result.renter ?? result[5];
const rentalEndTime = result.rentalEndTime ?? result[6];
const item = document.createElement('li');
const summary = document.createElement('p');
summary.textContent = `${title} — ${description || 'No description'}`;
item.append(summary);
const details = document.createElement('p');
details.textContent = `Daily price: ${tronWeb.fromSun(dailyPrice.toString())} TRX; owner: ${formatAddress(owner)}`;
item.append(details);
if (available) {
const daysInput = document.createElement('input');
daysInput.type = 'number';
daysInput.min = '1';
daysInput.max = '30';
daysInput.value = '1';
daysInput.setAttribute('aria-label', `Rental days for ${title}`);
const rentButton = document.createElement('button');
rentButton.type = 'button';
rentButton.textContent = 'Rent';
rentButton.addEventListener('click', async () => {
let txID;
try {
const rentalDays = Number(daysInput.value);
if (!Number.isInteger(rentalDays) || rentalDays < 1 || rentalDays > 30) {
throw new Error('Rental days must be an integer from 1 to 30.');
}
const totalPrice = BigInt(dailyPrice.toString()) * BigInt(rentalDays);
rentButton.disabled = true;
setStatus('Confirm the rental transaction in TronLink...');
txID = await contract.rentBook(bookId, rentalDays).send({
feeLimit: 100_000_000,
callValue: totalPrice.toString()
});
} catch (error) {
rentButton.disabled = false;
setStatus(error.message || String(error));
return;
}
setStatus(`Rental transaction broadcast; waiting for confirmation: ${txID}`);
const outcome = await confirmTransaction(txID);
if (outcome.status === 'failed') {
rentButton.disabled = false;
setStatus(`Rental transaction failed (${outcome.result}): ${txID}`);
return;
}
if (outcome.status === 'unknown') {
setStatus(`The rental transaction was broadcast, but its result is not yet available: ${txID}. Look up the txID and reload the page after confirming its status; do not submit it again before then.`);
return;
}
try {
await loadBooks();
setStatus(`Rental confirmed: ${txID}`);
} catch (error) {
setStatus(`Rental confirmed: ${txID}. The catalog could not be refreshed; select Refresh catalog.`);
}
});
item.append(daysInput, rentButton);
} else {
const rental = document.createElement('p');
const endTime = new Date(Number(rentalEndTime.toString()) * 1000);
rental.textContent = `Rented by ${formatAddress(renter)} until ${endTime.toLocaleString()}`;
item.append(rental);
}
nextBooks.append(item);
}
booksElement.replaceChildren(nextBooks);
}
async function connectWallet() {
provider = window.tron;
if (!provider?.isTronLink) {
throw new Error('TronLink was not detected. Install and unlock the extension first.');
}
await provider.request({ method: 'eth_requestAccounts' });
await provider.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: '0x94a9059e' }]
});
tronWeb = provider.tronWeb;
if (!tronWeb || !tronWeb.defaultAddress.base58) {
throw new Error('TronLink did not authorize this site.');
}
if (!tronWeb.isAddress(CONTRACT_ADDRESS)) {
throw new Error('Set a valid Shasta contract address in app.js first.');
}
contract = await tronWeb.contract().at(CONTRACT_ADDRESS);
await loadBooks();
accountElement.textContent = `Current account: ${tronWeb.defaultAddress.base58}`;
connectButton.disabled = true;
refreshButton.disabled = false;
provider.on('accountsChanged', () => window.location.reload());
provider.on('chainChanged', () => window.location.reload());
}
connectButton.addEventListener('click', async () => {
try {
setStatus('Connecting wallet...');
await connectWallet();
setStatus('Connected.');
} catch (error) {
setStatus(error.message || String(error));
}
});
refreshButton.addEventListener('click', async () => {
try {
refreshButton.disabled = true;
setStatus('Refreshing catalog...');
await loadBooks();
setStatus('Catalog refreshed.');
} catch (error) {
setStatus(error.message || String(error));
} finally {
refreshButton.disabled = false;
}
});
form.addEventListener('submit', async event => {
event.preventDefault();
if (listBookButton.disabled) {
return;
}
if (!contract) {
setStatus('Connect TronLink first.');
return;
}
const data = new FormData(form);
let txID;
listBookButton.disabled = true;
try {
const dailyPrice = tronWeb.toSun(data.get('dailyPrice'));
setStatus('Confirm the listing transaction in TronLink...');
txID = await contract
.addBook(
data.get('title').trim(),
data.get('description').trim(),
dailyPrice.toString()
)
.send({ feeLimit: 100_000_000 });
} catch (error) {
listBookButton.disabled = false;
setStatus(error.message || String(error));
return;
}
setStatus(`Listing transaction broadcast; waiting for confirmation: ${txID}`);
const outcome = await confirmTransaction(txID);
if (outcome.status === 'failed') {
listBookButton.disabled = false;
setStatus(`Listing transaction failed (${outcome.result}): ${txID}`);
return;
}
if (outcome.status === 'unknown') {
setStatus(`The listing transaction was broadcast, but its result is not yet available: ${txID}. Look up the txID and reload the page after confirming its status; do not submit it again before then.`);
return;
}
form.reset();
try {
await loadBooks();
setStatus(`Book listed: ${txID}`);
} catch (error) {
setStatus(`Listing confirmed: ${txID}. The catalog could not be refreshed; select Refresh catalog.`);
} finally {
listBookButton.disabled = false;
}
});After authorization, the page asks TronLink to switch to Shasta and then uses the current account through provider.tronWeb. Read-only methods query a node directly. .send() builds, signs, and broadcasts a transaction and returns its txID. Listing sets only feeLimit; renting also attaches the calculated test TRX through callValue. After broadcast, the page retains the txID while waiting for confirmation; if confirmation is temporarily unavailable, it does not report the transaction as an execution failure. After confirmation, the example reloads the catalog and rental status, and Refresh catalog updates rentals that have expired since the last load.
Run the DApp
Confirm that TronLink is connected to Shasta, then start a static file server from the project root:
npx serve frontendOpen the local HTTP URL printed by the command, then:
- Select Connect TronLink and authorize the site. If TronLink prompts you to switch networks, confirm the switch to Shasta.
- Enter a title, description, and daily price, then select List book.
- From another account, choose the rental period and select Rent.
- Confirm in TronLink and wait for the page to show the renter and expiration time.
If contract initialization fails, first confirm that TronLink is on Shasta and that CONTRACT_ADDRESS came from this Shasta deployment. Production applications should also add pagination, event indexing, provider discovery, and more detailed error handling; the related pages below cover those topics.
Related resources
- TronLink integration—provider discovery, account authorization, and network switching
- Smart contract interaction—TronWeb contract calls and
.send()return values - Listen to contract events—use indexed events instead of reading every record
- FeeLimit and Energy cost—estimate a suitable limit for state-changing calls
- Broadcast and RPC errors—troubleshoot signing, broadcast, and execution failures
Updated 19 days ago