实战:构建一个 Web3 应用

从零构建一个可运行的去中心化图书馆:用户可以上架图书、浏览书目,并通过 TronLink 支付测试 TRX 完成租借。

本教程从空目录开始构建一个去中心化图书馆 DApp。书主可以上架图书并设置每日租金,其他用户可以浏览图书,通过 TronLink 支付测试 TRX 完成租借。合约记录当前租借人和到期时间,并将租金直接转给书主。

示例包含完整的 Solidity 合约和前端代码,不依赖特定 JavaScript 框架,覆盖以下流程:

  1. 使用 TronBox 编译合约,并在本地测试网络验证核心功能和失败路径。
  2. 将经过测试的合约部署到 Shasta,并检查部署结果。
  3. 通过 window.tron 请求用户授权并取得 TronWeb 实例。
  4. 使用 TronWeb 浏览和上架图书,并为租借交易附带 TRX。
  5. 根据交易执行结果刷新图书的租借状态。

准备工作

开始前请准备:

  • 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 的确定性测试账户,已公开且不具备保密性。不要在其他网络使用它,也不要用真实资产账户运行本地测试。测试会验证图书上架与读取、租金到账、BookAddedBookRented 事件,并覆盖付款金额错误、图书不存在、租期越界和租借期间重复租借。

部署到 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_id2,可以运行以下命令读取并保存部署记录:

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));
})();

交易收据的查询结果应输出 SUCCESSgetBook(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 时,重新读取合约状态,再显示上架或租借成功。
  • 收据返回 REVERTOUT_OF_ENERGY 等失败结果时,显示执行失败并保留 txID 供排查。
  • 在轮询时间内没有取得收据时,将状态保留为暂时无法确认,提示用户继续查询原 txID,而不是重新提交上架或付款交易。

用户在 TronLink 中拒绝签名时不会产生 txID,页面会直接结束本次操作。RPC 临时错误也不会被当作合约执行失败。

通过合约事件同步状态

当前用户发起的交易以交易收据判断执行结果。为发现其他账户造成的状态变化,页面还会轮询已固化的图书上架和租借事件;发现新事件后,示例重新读取链上书目,而不是仅根据事件内容修改本地状态。

示例使用 transaction_idevent_index 识别重复事件,并使用重叠时间窗口降低遗漏刚刚固化事件的风险。当前页面只在内存中保存去重状态;生产应用还应按照监听合约事件读取全部分页结果,并持久化查询游标和去重状态。

运行 DApp

确认 TronLink 已切换到 Shasta,并在项目根目录启动静态文件服务器:

npx serve frontend

打开命令输出的本地 HTTP 地址,依次执行:

  1. 点击“连接 TronLink”并授权当前站点;如果 TronLink 提示切换网络,确认切换到 Shasta。
  2. 输入书名、简介和每日租金,点击“上架图书”。
  3. 在另一账户中选择租借天数并点击“租借”。
  4. 在 TronLink 中确认交易,等待页面刷新租借人和到期时间。
  5. 使用另一账户上架或租借图书,确认当前页面会在事件固化后自动刷新书目。
  6. 再发起一次写入并在 TronLink 中拒绝签名,确认页面显示操作已取消且不会把它当成链上失败。

如果连接合约失败,首先检查 TronLink 是否处于 Shasta,以及 CONTRACT_ADDRESS 是否为本次 Shasta 部署得到的地址。生产应用还应增加事件分页与游标持久化、标准化的 Provider 发现、监控和更完整的错误分类;相关接口见下方文档。


相关资源