实战:构建一个 Web3 应用
从零构建一个可运行的去中心化图书馆:用户可以上架图书、浏览书目,并通过 TronLink 支付测试 TRX 完成租借。
本教程从空目录开始构建一个去中心化图书馆 DApp。书主可以上架图书并设置每日租金,其他用户可以浏览图书,通过 TronLink 支付测试 TRX 完成租借。合约记录当前租借人和到期时间,并将租金直接转给书主。
示例包含完整的 Solidity 合约和前端代码,不依赖特定 JavaScript 框架,覆盖以下流程:
- 使用 TronBox 编译合约,并在本地测试网络验证核心功能和失败路径。
- 将经过测试的合约部署到 Shasta,并检查部署结果。
- 通过
window.tron请求用户授权并取得 TronWeb 实例。 - 使用 TronWeb 浏览和上架图书,并为租借交易附带 TRX。
- 根据交易执行结果刷新图书的租借状态。
准备工作
开始前请准备:
- Node.js 20 或更高版本与 npm。
- TronBox:本教程稍后在项目中安装固定版本,并通过
npx tronbox调用。 - Docker:用于运行本地 TronBox Runtime Environment(TRE)并执行合约测试。
- TronLink 浏览器扩展,并将网络切换到 Shasta。
- 拥有 Shasta 测试 TRX 的部署账户和 TronLink 账户。可从测试网水龙头领取。
本教程只使用测试网账户。不要把持有主网资产的私钥用于示例部署。
创建项目
创建项目目录:
mkdir tron-library
cd tron-library在项目根目录创建 package.json,固定本教程使用的 TronBox 及其依赖版本:
{
"name": "tron-library",
"version": "1.0.0",
"private": true,
"devDependencies": {
"tronbox": "4.10.0"
},
"overrides": {
"serialize-javascript": "7.0.5",
"ws": "8.21.0",
"tronbox": {
"tronweb": "6.5.0"
}
}
}安装依赖并创建后续步骤使用的目录:
npm install
mkdir -p contracts migrations test frontend项目中的相关文件如下:
tron-library/
├── contracts/
│ └── DecentralizedLibrary.sol
├── migrations/
│ └── 2_deploy_library.js
├── test/
│ └── decentralized_library.js
├── frontend/
│ ├── index.html
│ └── app.js
└── tronbox-config.js配置 TronBox
将 tronbox-config.js 配置为:
module.exports = {
networks: {
development: {
privateKey: process.env.LOCAL_PRIVATE_KEY,
userFeePercentage: 0,
feeLimit: 1000 * 1e6,
fullHost: 'http://127.0.0.1:9090',
network_id: '9'
},
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'
}
}
};development 用于本地自动化测试,shasta 用于公开测试网部署。两个网络的私钥都从环境变量读取,不写入配置文件。
示例中的 feeLimit 用于 Shasta 原型,目的是为首次部署和调用预留充足预算,并不代表所有合约都应使用相同数值。准备上线时,应使用与生产数据相近的参数估算 Energy,并按照 FeeLimit 与 Energy 成本重新设置部署和调用预算。
编写智能合约
创建 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() 和 isAvailable() 是只读调用。addBook() 与 rentBook() 会修改状态,需要 TronLink 请求用户签名。租借交易的 callValue 必须等于“每日租金 × 租借天数”;合约先记录租借状态,再将这笔测试 TRX 转给书主。到期后 isAvailable() 会自动恢复为 true。
该示例只实现链上的图书信息、租借状态和租金支付。实体图书的交付、押金、退款和争议处理不在示例范围内。
创建 migrations/2_deploy_library.js:
const DecentralizedLibrary = artifacts.require('DecentralizedLibrary');
module.exports = function (deployer) {
deployer.deploy(DecentralizedLibrary);
};在本地测试合约
创建 test/decentralized_library.js:
const DecentralizedLibrary = artifacts.require('DecentralizedLibrary');
contract('DecentralizedLibrary', accounts => {
const [owner, renter, anotherRenter] = accounts;
const dailyPrice = 1_000_000;
let library;
async function addBook() {
return library.addBook('TRON Guide', 'Developer documentation', dailyPrice, {
from: owner
});
}
async function getTransactionInfo(txID) {
await waitForTransactionReceipt(txID);
return tronWeb.trx.getTransactionInfo(txID);
}
function findEvent(transactionInfo, signature) {
const topic = tronWeb.sha3(signature).replace(/^0x/, '').toLowerCase();
return transactionInfo.log.find(log => log.topics[0].toLowerCase() === topic);
}
function addressTopic(address) {
return tronWeb.address.toHex(address).slice(2).padStart(64, '0').toLowerCase();
}
async function expectRevert(transactionPromise) {
const txID = await transactionPromise;
const transactionInfo = await getTransactionInfo(txID);
assert.equal(transactionInfo.receipt.result, 'REVERT');
}
beforeEach(async () => {
assert.isAtLeast(accounts.length, 3, 'TRE must provide at least three test accounts');
library = await DecentralizedLibrary.new({ from: owner });
});
it('adds and reads a book', async () => {
const txID = await addBook();
const transactionInfo = await getTransactionInfo(txID);
assert.equal((await library.bookCount()).toString(), '1');
const book = await library.getBook(0);
assert.equal(book[0], 'TRON Guide');
assert.equal(book[2].toString(), dailyPrice.toString());
assert.equal(book[3], tronWeb.address.toHex(owner));
assert.equal(book[4], true);
const event = findEvent(
transactionInfo,
'BookAdded(uint256,string,uint256,address)'
);
assert.exists(event, 'BookAdded event was not emitted');
assert.equal(BigInt(`0x${event.topics[1]}`).toString(), '0');
assert.equal(event.topics[2].toLowerCase(), addressTopic(owner));
});
it('rents an available book with the exact payment', async () => {
await addBook();
const ownerBalanceBefore = BigInt(await tronWeb.trx.getBalance(owner));
const totalPrice = dailyPrice * 2;
const txID = await library.rentBook(0, 2, {
from: renter,
callValue: totalPrice
});
const transactionInfo = await getTransactionInfo(txID);
assert.equal(transactionInfo.receipt.result, 'SUCCESS');
const ownerBalanceAfter = BigInt(await tronWeb.trx.getBalance(owner));
assert.equal(
(ownerBalanceAfter - ownerBalanceBefore).toString(),
totalPrice.toString()
);
const book = await library.getBook(0);
assert.equal(book[4], false);
assert.equal(book[5], tronWeb.address.toHex(renter));
const event = findEvent(
transactionInfo,
'BookRented(uint256,address,uint256,uint256,uint256)'
);
assert.exists(event, 'BookRented event was not emitted');
assert.equal(BigInt(`0x${event.topics[1]}`).toString(), '0');
assert.equal(event.topics[2].toLowerCase(), addressTopic(renter));
assert.equal(BigInt(`0x${event.data.slice(-64)}`).toString(), totalPrice.toString());
});
it('rejects an incorrect rental payment', async () => {
await addBook();
await expectRevert(library.rentBook(0, 2, {
from: renter,
callValue: dailyPrice
}));
});
it('rejects a nonexistent book', async () => {
await expectRevert(library.rentBook(99, 1, {
from: renter,
callValue: dailyPrice
}));
});
it('rejects a rental period outside 1-30 days', async () => {
await addBook();
await expectRevert(library.rentBook(0, 31, {
from: renter,
callValue: dailyPrice * 31
}));
});
it('rejects a second rental while the book is unavailable', async () => {
await addBook();
const firstTxID = await library.rentBook(0, 1, {
from: renter,
callValue: dailyPrice
});
await getTransactionInfo(firstTxID);
await expectRevert(library.rentBook(0, 1, {
from: anotherRenter,
callValue: dailyPrice
}));
});
});在一个终端中启动只监听本机地址的 TRE:
docker run --rm --name tron-library-tre \
-p 127.0.0.1:9090:9090 \
-e useDefaultPrivateKey=true \
tronbox/tre:2.0.0在另一个终端中等待 TRE 完成测试账户初始化,再设置公开测试私钥并运行测试:
until curl -fsS http://127.0.0.1:9090/admin/accounts | grep -q 'Available Accounts'; do
sleep 1
done
export LOCAL_PRIVATE_KEY="0000000000000000000000000000000000000000000000000000000000000001"
npx tronbox compile
npx tronbox test --network development该私钥只对应本地 TRE 的确定性测试账户,已公开且不具备保密性。不要在其他网络使用它,也不要用真实资产账户运行本地测试。测试会验证图书上架与读取、租金到账、BookAdded 和 BookRented 事件,并覆盖付款金额错误、图书不存在、租期越界和租借期间重复租借。
部署到 Shasta
本地测试通过后,通过环境变量提供 Shasta 测试账户的私钥,并使用相同的源码和编译器配置部署:
printf '请输入 Shasta 测试账户私钥(不含 0x 前缀):'
read -s PRIVATE_KEY
printf '\n'
export PRIVATE_KEY
npx tronbox migrate --network shasta
unset PRIVATE_KEY不要把私钥写入 tronbox-config.js 或提交到版本库。部署成功后,终端会输出 DecentralizedLibrary 的合约地址。保存这个以 T 开头的地址,前端初始化合约时需要使用。
TronBox 还会把部署地址和部署交易 ID 写入编译产物。Shasta 的 network_id 为 2,可以运行以下命令读取并保存部署记录:
node -e "const a=require('./build/contracts/DecentralizedLibrary.json'); console.log(a.networks['2'])"输出中的 address 是合约的十六进制地址,transactionHash 是部署交易 ID。将它们与终端输出的 Base58Check 合约地址、Solidity 版本及优化器设置一起记录,后续调用排查和源码验证都需要这些信息。
检查并验证部署结果
在接入前端前,先进入 Shasta 控制台检查部署结果:
npx tronbox console --network shasta在控制台中逐条执行以下命令,取得已部署的合约,完成一次只读调用和一次写入调用,并检查写入交易的收据:
(async () => {
const library = await DecentralizedLibrary.deployed();
console.log((await library.bookCount()).toString());
const result = await library.addBook('TRON Guide', 'Deployment check', 1_000_000, {
shouldPollResponse: true,
keepTxID: true
});
const txID = result[0];
console.log(txID);
const info = await tronWeb.trx.getTransactionInfo(txID);
console.log(info.receipt.result);
console.log(await library.getBook(0));
})();交易收据的查询结果应输出 SUCCESS,getBook(0) 应返回刚写入的图书。随后在 Shasta TRONSCAN 中打开该合约,并按照合约验证提交源码;选择 Solidity 0.8.6,并保持优化器关闭,与 tronbox-config.js 的编译配置一致。确认调用和源码验证均完成后,再把合约地址写入前端。
创建前端页面
创建 frontend/index.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TRON 去中心化图书馆</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 去中心化图书馆</h1>
<button id="connect" type="button">连接 TronLink</button>
<button id="refresh" type="button" disabled>刷新书目</button>
</header>
<p id="account">钱包尚未连接</p>
<form id="book-form">
<input id="title" name="title" placeholder="书名" required>
<input id="description" name="description" placeholder="图书简介">
<input id="daily-price" name="dailyPrice" type="number" min="0.000001" step="0.000001" placeholder="每日租金(TRX)" required>
<button id="list-book" type="submit">上架图书</button>
</form>
<p id="status" role="status"></p>
<h2>可浏览书目</h2>
<ol id="books"></ol>
<script type="module" src="./app.js"></script>
</body>
</html>连接 TronLink 并调用合约
创建 frontend/app.js,把 REPLACE_WITH_CONTRACT_ADDRESS 替换为部署得到的合约地址:
const CONTRACT_ADDRESS = 'REPLACE_WITH_CONTRACT_ADDRESS';
const SOLIDITY_NODE_URL = 'https://api.shasta.trongrid.io';
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;
let eventSyncInProgress = false;
let eventMinTimestamp = Date.now() - 10 * 60_000;
const seenEvents = new Set();
const pendingActions = new Set();
function setStatus(message) {
statusElement.textContent = message;
}
function errorMessage(error) {
const message = error?.message || String(error);
if (Number(error?.code) === 4001 || /user (rejected|denied)|cancelled/i.test(message)) {
return '操作已取消:你在 TronLink 中拒绝了本次请求。';
}
if (/OUT_OF_ENERGY|not enough energy/i.test(message)) {
return '交易的 Energy 预算不足,请检查账户资源和 feeLimit。';
}
if (/BANDWITH_ERROR|balance is not sufficient/i.test(message)) {
return '账户的 Bandwidth 或 TRX 余额不足,无法提交交易。';
}
return message;
}
function executionFailureMessage(result) {
if (result === 'OUT_OF_ENERGY') {
return '交易执行失败:Energy 预算不足。';
}
if (result === 'REVERT') {
return '交易执行失败:合约条件未满足,状态已回滚。';
}
return `交易执行失败:${result}`;
}
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 response = await fetch(
`${SOLIDITY_NODE_URL}/walletsolidity/gettransactioninfobyid`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ value: txID })
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const receipt = await response.json();
if (receipt.id?.toLowerCase() === txID.toLowerCase()) {
return receipt;
}
} catch (error) {
// 临时 RPC 错误不代表交易失败。
}
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;
if (result === 'SUCCESS') return { status: 'success' };
if (result) return { status: 'failed', result };
return { status: 'unknown' };
} 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 || '暂无简介'}`;
item.append(summary);
const details = document.createElement('p');
details.textContent = `每日租金:${tronWeb.fromSun(dailyPrice.toString())} TRX;书主:${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', `${title} 的租借天数`);
const rentButton = document.createElement('button');
rentButton.type = 'button';
rentButton.textContent = '租借';
const actionKey = `rent:${bookId}`;
rentButton.disabled = pendingActions.has(actionKey);
rentButton.addEventListener('click', async () => {
let txID;
try {
const rentalDays = Number(daysInput.value);
if (!Number.isInteger(rentalDays) || rentalDays < 1 || rentalDays > 30) {
throw new Error('租借天数必须是 1 到 30 之间的整数。');
}
const totalPrice = BigInt(dailyPrice.toString()) * BigInt(rentalDays);
rentButton.disabled = true;
pendingActions.add(actionKey);
setStatus('请在 TronLink 中确认租借交易...');
txID = await contract.rentBook(bookId, rentalDays).send({
feeLimit: 100_000_000,
callValue: totalPrice.toString()
});
} catch (error) {
pendingActions.delete(actionKey);
rentButton.disabled = false;
setStatus(errorMessage(error));
return;
}
setStatus(`租借交易已广播,等待确认:${txID}`);
const outcome = await confirmTransaction(txID);
if (outcome.status === 'failed') {
pendingActions.delete(actionKey);
rentButton.disabled = false;
setStatus(`${executionFailureMessage(outcome.result)} txID:${txID}`);
return;
}
if (outcome.status === 'unknown') {
setStatus(`租借交易已广播,但暂时无法确认结果:${txID}。请先按 txID 查询,确认状态后刷新页面;确认前不要重新提交。`);
return;
}
try {
pendingActions.delete(actionKey);
await loadBooks();
setStatus(`租借成功:${txID}`);
} catch (error) {
setStatus(`租借交易已确认:${txID};书目刷新失败,请点击“刷新书目”。`);
}
});
item.append(daysInput, rentButton);
} else {
const rental = document.createElement('p');
const endTime = new Date(Number(rentalEndTime.toString()) * 1000);
rental.textContent = `已由 ${formatAddress(renter)} 租借,预计到期时间:${endTime.toLocaleString()}`;
item.append(rental);
}
nextBooks.append(item);
}
booksElement.replaceChildren(nextBooks);
}
function eventKey(event) {
return `${event.transaction_id}:${event.event_index}`;
}
async function syncLibraryEvents() {
if (eventSyncInProgress || !tronWeb?.event || !contract) {
return;
}
eventSyncInProgress = true;
try {
const { data: events = [] } = await tronWeb.event.getEventsByContractAddress(
CONTRACT_ADDRESS,
{
minBlockTimestamp: eventMinTimestamp,
onlyConfirmed: true,
limit: 200,
orderBy: 'block_timestamp,asc'
}
);
let shouldRefresh = false;
let latestTimestamp = eventMinTimestamp;
for (const event of events) {
latestTimestamp = Math.max(latestTimestamp, event.block_timestamp || 0);
if (!['BookAdded', 'BookRented'].includes(event.event_name)) {
continue;
}
const key = eventKey(event);
if (!seenEvents.has(key)) {
seenEvents.add(key);
shouldRefresh = true;
}
}
eventMinTimestamp = Math.max(eventMinTimestamp, latestTimestamp - 60_000);
if (shouldRefresh) {
await loadBooks();
}
} finally {
eventSyncInProgress = false;
}
}
function startEventPolling() {
syncLibraryEvents().catch(error => console.warn('Event sync failed:', error));
setInterval(() => {
syncLibraryEvents().catch(error => console.warn('Event sync failed:', error));
}, 5_000);
}
async function connectWallet() {
provider = window.tron;
if (!provider?.isTronLink) {
throw new Error('未检测到 TronLink,请先安装并解锁扩展。');
}
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 未授权当前站点。');
}
if (!tronWeb.isAddress(CONTRACT_ADDRESS)) {
throw new Error('请先在 app.js 中填写有效的 Shasta 合约地址。');
}
contract = await tronWeb.contract().at(CONTRACT_ADDRESS);
await loadBooks();
accountElement.textContent = `当前账户:${tronWeb.defaultAddress.base58}`;
connectButton.disabled = true;
refreshButton.disabled = false;
provider.on('accountsChanged', () => window.location.reload());
provider.on('chainChanged', () => window.location.reload());
startEventPolling();
}
connectButton.addEventListener('click', async () => {
try {
setStatus('正在连接钱包...');
await connectWallet();
setStatus('已连接。');
} catch (error) {
setStatus(errorMessage(error));
}
});
refreshButton.addEventListener('click', async () => {
try {
refreshButton.disabled = true;
setStatus('正在刷新书目...');
await loadBooks();
setStatus('书目已刷新。');
} catch (error) {
setStatus(errorMessage(error));
} finally {
refreshButton.disabled = false;
}
});
form.addEventListener('submit', async event => {
event.preventDefault();
if (listBookButton.disabled) {
return;
}
if (!contract) {
setStatus('请先连接 TronLink。');
return;
}
const data = new FormData(form);
let txID;
listBookButton.disabled = true;
try {
const dailyPrice = tronWeb.toSun(data.get('dailyPrice'));
setStatus('请在 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(errorMessage(error));
return;
}
setStatus(`上架交易已广播,等待确认:${txID}`);
const outcome = await confirmTransaction(txID);
if (outcome.status === 'failed') {
listBookButton.disabled = false;
setStatus(`${executionFailureMessage(outcome.result)} txID:${txID}`);
return;
}
if (outcome.status === 'unknown') {
setStatus(`上架交易已广播,但暂时无法确认结果:${txID}。请先按 txID 查询,确认状态后刷新页面;确认前不要重新提交。`);
return;
}
form.reset();
try {
await loadBooks();
setStatus(`图书已上架:${txID}`);
} catch (error) {
setStatus(`上架交易已确认:${txID};书目刷新失败,请点击“刷新书目”。`);
} finally {
listBookButton.disabled = false;
}
});连接成功后,页面请求 TronLink 切换到 Shasta,随后通过 provider.tronWeb 使用当前账户。只读调用直接查询节点;.send() 则构建、签名并广播交易,返回 txID。上架交易只设置 feeLimit;租借交易还通过 callValue 附带按天计算的测试 TRX。
根据交易结果更新页面状态
.send() 返回 txID 只表示交易已经提交,不能据此显示图书上架或租借成功。示例保留该 txID,并通过 Shasta SolidityNode 轮询已固化交易收据,再根据结果更新页面:
- 收据返回
SUCCESS时,重新读取合约状态,再显示上架或租借成功。 - 收据返回
REVERT、OUT_OF_ENERGY等失败结果时,显示执行失败并保留txID供排查。 - 在轮询时间内没有取得收据时,将状态保留为暂时无法确认,提示用户继续查询原
txID,而不是重新提交上架或付款交易。
用户在 TronLink 中拒绝签名时不会产生 txID,页面会直接结束本次操作。RPC 临时错误也不会被当作合约执行失败。
通过合约事件同步状态
当前用户发起的交易以交易收据判断执行结果。为发现其他账户造成的状态变化,页面还会轮询已固化的图书上架和租借事件;发现新事件后,示例重新读取链上书目,而不是仅根据事件内容修改本地状态。
示例使用 transaction_id 与 event_index 识别重复事件,并使用重叠时间窗口降低遗漏刚刚固化事件的风险。当前页面只在内存中保存去重状态;生产应用还应按照监听合约事件读取全部分页结果,并持久化查询游标和去重状态。
运行 DApp
确认 TronLink 已切换到 Shasta,并在项目根目录启动静态文件服务器:
npx serve frontend打开命令输出的本地 HTTP 地址,依次执行:
- 点击“连接 TronLink”并授权当前站点;如果 TronLink 提示切换网络,确认切换到 Shasta。
- 输入书名、简介和每日租金,点击“上架图书”。
- 在另一账户中选择租借天数并点击“租借”。
- 在 TronLink 中确认交易,等待页面刷新租借人和到期时间。
- 使用另一账户上架或租借图书,确认当前页面会在事件固化后自动刷新书目。
- 再发起一次写入并在 TronLink 中拒绝签名,确认页面显示操作已取消且不会把它当成链上失败。
如果连接合约失败,首先检查 TronLink 是否处于 Shasta,以及 CONTRACT_ADDRESS 是否为本次 Shasta 部署得到的地址。生产应用还应增加事件分页与游标持久化、标准化的 Provider 发现、监控和更完整的错误分类;相关接口见下方文档。
相关资源
- TronLink 集成——Provider 发现、账户授权与网络切换
- 智能合约交互——TronWeb 合约调用与
.send()返回值 - 监听合约事件——使用事件索引替代逐条读取
- FeeLimit 与 Energy 成本——估算写入交易的费用上限
- 广播与 RPC 错误——排查签名、广播和执行失败
Updated 2 days ago
