在链上治理系统中,创建提案和投票本质上是向智能合约发送状态变更交易。Android端如果只实现一个展示层,把签名交给中心化后端,会破坏DAO去中心化特性;如果让用户直接保管助记词并本地签名,则面临私钥泄露和误操作风险。因此,客户端需要把合约调用、签名、Gas管理、事件同步拆成独立模块,并利用Android系统级安全能力保护敏感信息。下面围绕一条典型链路展开:从合约包装类生成,到离线签名、交易广播,再到事件缓存和确认状态更新。

一、基于Web3j的智能合约交互层
Android端通常使用Web3j与以太坊系网络通信。先在Gradle中引入依赖,再通过solc生成的ABI和bin文件自动生成Java合约包装类。包装类能提供类型安全的函数调用,避免手写函数选择器和参数编码。比如治理合约暴露 createProposal、castVote、executeProposal 等写方法,以及 proposalCount、proposalVotes 等读方法,客户端拿到对应对象后即可直接调用。引入依赖时可以固定同一个版本,减少D8合并时出现的重复类冲突。
dependencies {
implementation "org.web3j:core:4.10.3"
implementation "org.web3j:crypto:4.10.3"
implementation "org.web3j:abi:4.10.3"
}
生成包装类可以使用Web3j命令行工具,将合约ABI文件传入后指定包名和输出目录。生成后的类默认依赖BigInteger和byte[],这俩类型在Android环境中都能正常使用。需要注意的是,部分合约函数参数可能是数组或结构体,生成的方法签名会包含List类型,此时如果项目开启R8混淆,要保留Web3j相关模型,否则反射解码事件时会因字段被混淆而失败。
链交互层建议拆成读操作和写操作两个通道。读操作走 eth_call,节点不会产生交易,也不会消耗用户Gas,适合处理重试和频繁刷新。写操作必须构造签名交易,并通过 eth_sendRawTransaction 广播。下面代码展示加载凭证、连接RPC节点并获取合约包装实例的过程。实际项目中可以把节点地址放在环境配置里,避免写死在业务代码中。
val credentials = WalletUtils.loadCredentials(
"user-password",
context.getFileStreamPath("wallet.json")
)
val web3j = Web3j.build(
HttpService("https://rpc.ippipp.com")
)
val contract = DaoGovernance.load(
"0xContractAddress",
web3j,
credentials,
DefaultGasProvider()
)
二、私钥安全与离线签名流程
创建一个提案时,如果直接使用明文私钥,攻击者通过内存转储或备份恢复就能控制地址。更好的做法是使用Android Keystore生成或托管加密私钥,或者在支持的情况下接入硬件钱包。简单场景下可以使用加密的JSON keystore,用户输入口令后在内存中解密,使用完毕后立即清除引用。Android Keystore生成的密钥无法导出,私钥材料只存在于安全硬件或系统进程中,能显著降低泄露风险。
离线签名是DAO投票客户端最关键的一步。签名并不需要把交易发送到网络,可以先在本地完成RawTransaction组装,设置nonce、gasPrice、gasLimit、目标地址和编码后的函数数据。签名完成后得到十六进制字符串,再通过广播接口提交。这样做的好处是签名过程完全本地完成,网络层只能拿到已经签名的交易,无法接触私钥。
val function = contract.createProposal(
"Proposal Title".toByteArray(),
"Proposal Description".toByteArray(),
BigInteger("1")
)
val rawTx = RawTransaction.createTransaction(
credentials.ecKeyPair,
BigInteger.valueOf(nonce),
DefaultGasProvider().getGasPrice(function),
DefaultGasProvider().getGasLimit(function),
contract.contractAddress,
function.encodeFunction()
)
val signedHex = TransactionEncoder.encode(rawTx, credentials.ecKeyPair)
val txHash = web3j.ethSendRawTransaction(signedHex).send().transactionHash
nonce处理需要特别小心。同一个地址连续发起两笔交易时,如果客户端复用旧nonce,第二笔交易会被节点拒绝,报错 nonce too low。因此每次构造交易前都要调用 ethGetTransactionCount 查询最新可用的nonce。多设备同时操作同一地址时,还可以在本地维护一个nonce队列,并设置一个合理的自动递增策略。chainId也要从节点获取并与交易一起签名,否则签名结果可能被重放到其他网络。
三、提案读取、事件监听与本地缓存
治理投票客户端最重要的体验是列表加载快、状态准确。如果每次打开都从链上全量同步,主网节点响应可能很慢且受RPC限流。建议将合约事件写入本地数据库,通过分页查询展示,后台按区块区间增量同步。Web3j提供Flowable接口来订阅事件,可以拿到提案创建、投票、取消、执行等完整记录。
val disposable = contract.proposalCreatedEventFlowable(
DefaultBlockParameterName.EARLIEST,
DefaultBlockParameterName.LATEST
).subscribe { event ->
val proposalId = event.proposalId
val proposer = event.proposer
val snapshotBlock = event.snapshotBlock
daoRepository.cacheProposal(proposalId, proposer, snapshotBlock)
}
事件同步还要考虑节点断连和链重组。Room表中建议同时保存blockNumber和blockHash两个字段。当客户端检测到相同区块高度但哈希不一致时,说明发生了重组,需要回滚该高度之后的数据并重新拉取。不要只保存blockNumber,因为链重组会改变区块内容,旧事件可能被无效化。
本地实体可以用Room定义,把合约事件中的数值类型映射为Long或BigInteger对应的可存储类型。展示层获取数据时只依赖数据库,不直接请求RPC,这样即使节点暂时不可用,用户依然能看到已缓存的历史提案和投票记录。投票状态则区分链上确认中和已确认,避免用户在未打包前误以为投票已完成。
四、交易确认、异常处理与安全边界
发送交易只代表交易进入待处理池,不等于提案已经上链。客户端必须轮询交易回执,并在确认数达到一定阈值时再更新最终状态。对投票场景,可以等待1到3个区块确认,同时UI给出链上确认进度。下面代码演示一个简单的回执轮询逻辑,实际开发中要加入超时和重试上限。
var receipt: TransactionReceipt? = null
var attempts = 0
while (receipt == null && attempts < 30) {
receipt = web3j.ethGetTransactionReceipt(txHash).send().transactionReceipt
if (receipt == null) {
Thread.sleep(2000)
attempts++
}
}
if (receipt != null && receipt.isStatusOK) {
daoRepository.markProposalConfirmed(txHash)
}
异常处理要覆盖gas不足、nonce冲突、替换交易价格过低等情况。gas估算失败时不要直接使用合约默认值,可以按比例上浮GasLimit,但也要防止上浮过高造成浪费。遇到 replacement transaction underpriced 提示时,说明存在一笔相同nonce的旧交易,需要提高GasPrice后重新签名。把原始错误原样展示给用户没有意义,客户端应转换成可操作的提示。
安全边界还包括日志和网络权限。不要在Logcat打印私钥、助记词、签名数据或完整交易原始字节,这些信息可能被其他应用通过日志读取。网络请求前需要在 AndroidManifest.xml 中声明 <uses-permission android:name="android.permission.INTERNET" />,否则节点RPC请求会直接失败。所有RPC交互使用HTTPS节点,私钥只出现在内存和系统安全模块中,绝不落入SharedPreferences或数据库明文字段。