原文首发于『程序员精进』博客,原文链接:【Java区块链实践】使用 Web3j、Spring Boot 实现以太坊智能合约
在前面的文章中我们介绍了如何使用以太坊、Web3j以及 Spring Boot 来开发区块链应用,该文章链接:【Java区块链实践】使用以太坊、web3j 与 Spring Boot 开发应用。本文将介绍如何在应用中实现以太坊的智能合约,首先我们需要来看看什么是智能合约。
智能合约是一个运行在 EVM(以太坊虚拟机)上的一个程序。每个智能合约都是代码(函数)和数据的集合,它在以太坊区块链上有一个地址,可以与其他合约进行交互,例如做决策、存储数据或向他人发送 Ether。通常用来编写以太坊智能合约的语言叫做 Solidity,是一种静态类型的高级语言。每个合约都需要被编译,基于编译后的二进制文件,可以为自己的应用创建源代码。Web3j 库提供了工具来帮助我们完成上述工作。在我们开始聊源代码之前,咱们先看看示例系统的架构。
包含了两个独立的应用,分别是 contract-service
和 transaction-service
,绝大部分的业务逻辑将放在 contract-service
应用中,它提供了创建智能钱包、部署智能合约到以太坊以及调用智能合约函数的能力。transaction-service
应用仅负责第三方和智能合约拥有者之间的交易,它将会调用 contract-service
应用暴露的接口。contract-service
应用会观察以太坊节点上执行的交易,如果交易与智能合约拥有者的账户相关,应用会负责将本合约所有由合约所有者签署的合约资金转入合约接收者的账户。下图是上面描述的过程表示。
1. 使用 Solidity 构建一个智能合约
在以太坊中创建智能合约最常用的工具就是 Solidity,Solidity 是一种用于实现智能合约的面向契约高级语言。它受到了 C++、Python 和 Javascript 的影响,被设计为针对以太坊虚拟机 EVM。它是静态类型的,支持继承、库和复杂的用户定义类型,以及一些其他特性。有关该语言的更多信息,可以查询官方文档:http://solidity.readthedocs.io/。
本文中我们的主要目标是构建一个简单的智能合约,并编译和创建必要的源代码。这样我们就不过多介绍使用 Solidity 设计实现智能合约的详细内容。智能合约的实现负责计算交易的费用,基于此计算,它将资金存入交易所有者的账户,并从发送者的账户中提取资金。本合约是由两个用户签署,他们每个人都有自己的智能钱包,由他们的凭证担保。理解这个简单的合约是非常重要的,接下来我们一行行地分析它。
下面每个智能合约都是描述按照一定百分比交易额的手续费,这些交易的接收者费用(1) 和 发送者账户地址(2)。合约前两行声明了用来存储数据的两个变量:fee
Solidity 数据类型 uint
,receiver
数据类型 address
。这些变量值都在合约构造时进行初始化(5)。参数 fee
表明手续费为该交易的百分比,从发送者账户中提取并存入接受者账户。mapping (address => uint) public balances
这行代码表示所有余额将用无符号整型来映射(3)。我们同样定义了发送事件,表示每个合约交易触发信息(4)。getReceiverBalance 函数返回接收者账户余额(6)。最后,有个名为 sendTrx(...)
的函数,可以被外部客户端进行调用,它负责根据合约的费用和交易金额进行提款和存款操作。这需要更多的关注,首先,它需要有 payable
修改器才能够在以太坊账户将进行资金转账。之后,可以从 msg.value
参数中读取交易金额。在然后,我们将调用函数向接收者地址发送给定的金额,并将数值保存到合约余额中。此外,我们可能发送一个事件,可以由客户端应用进行接收。
pragma solidity ^0.4.21;
contract TransactionFee {
// (1)
uint public fee;
// (2)
address public receiver;
// (3)
mapping (address => uint) public balances;
// (4)
event Sent(address from, address to, uint amount, bool sent);
// (5)
constructor(address _receiver, uint _fee) public {
receiver = _receiver;
fee = _fee;
}
// (6)
function getReceiverBalance() public view returns(uint) {
return receiver.balance;
}
// (7)
function sendTrx() public payable {
uint value = msg.value * fee / 100;
bool sent = receiver.send(value);
balances[receiver] += (value);
emit Sent(msg.sender, receiver, value, sent);
}
}
一旦我们创建了一个合约,我们必须编译并且创建源代码,这样我们才能够在我们的应用中部署合约并调用它的函数。有关 Solidity 编译器的相关信息,可以查阅官方网站:https://remix.ethereum.org。
2. 编译合约并创建源代码
Solidity 为编译器提供了最新的 Docker 镜像,正式版本标记为stable
,来自于开发分支的不稳定版本标记为nightly
。但是,Docker 镜像只包含编译器可执行文件,因此我们必须将 Solidity 合约输入文件进行持久化卷挂载。假设这些文件在我们运行 Docker 容器机器的目录 /home/docker
下,我们可以使用以下命令进行编译。这个命令创建了两个文件:一个二进制文件 .bin
,是 EVM 可以解释的智能合约代码,另外一个是应用程序二进制接口文件.abi
,里面定义了智能合约方法。
$ docker run --rm -v /home/docker:/build ethereum/solc:stable /build/TransactionFee.sol --bin --abi --optimize -o /build
编译输出文件在容器的/build
目录下,并且持久化存储在/home/docker
目录下。在编译结束后,该容器被删除,因为现在不需要它。我们可以使用 web3j 库来从编译后的智能合约文件中创建源代码。web3j 的可执行文件在${WEB3J_HOME}/bin
目录下,在创建源代码时,需要指定.bin
和 .abi
文件的路径,并且设定目标包名和目录。
$ web3j solidity generate /build/transactionfee.bin /build/transactionfee.abi -p pl.piomin.services.contract.model -o src/main/java/
Web3j 可执行文件在给定的包名下创建了 Java 源文件,该类名为 Solidity 智能合约名,下面是我们创建出来的源代码。
public class Transactionfee extends Contract {
private static final String BINARY = "608060405234801561..."
public static final String FUNC_GETRECEIVERBALANCE = "getReceiverBalance";
public static final String FUNC_BALANCES = "balances";
public static final String FUNC_SENDTRX = "sendTrx";
public static final String FUNC_FEE = "fee";
public static final String FUNC_RECEIVER = "receiver";
// ...
protected Transactionfee(String contractAddress, Web3j web3j, TransactionManager transactionManager, BigInteger gasPrice, BigInteger gasLimit) {
super(BINARY, contractAddress, web3j, transactionManager, gasPrice, gasLimit);
}
public RemoteCall getReceiverBalance() {
final Function function = new Function(FUNC_GETRECEIVERBALANCE,
Arrays.asList(),
Arrays.asList(new TypeReference() {}));
return executeRemoteCallSingleValueReturn(function, BigInteger.class);
}
public RemoteCall balances(String param0) {
final Function function = new Function(FUNC_BALANCES,
Arrays.asList(new org.web3j.abi.datatypes.Address(param0)),
Arrays.asList(new TypeReference() {}));
return executeRemoteCallSingleValueReturn(function, BigInteger.class);
}
public RemoteCall sendTrx(BigInteger weiValue) {
final Function function = new Function(
FUNC_SENDTRX,
Arrays.asList(),
Collections.emptyList());
return executeRemoteCallTransaction(function, weiValue);
}
public RemoteCall fee() {
final Function function = new Function(FUNC_FEE,
Arrays.asList(),
Arrays.asList(new TypeReference() {}));
return executeRemoteCallSingleValueReturn(function, BigInteger.class);
}
public RemoteCall receiver() {
final Function function = new Function(FUNC_RECEIVER,
Arrays.asList(),
Arrays.<TypeReference>asList(new TypeReference
<Address>() {}));
return executeRemoteCallSingleValueReturn(function, String.class);
}
public static RemoteCall deploy(Web3j web3j, Credentials credentials, BigInteger gasPrice, BigInteger gasLimit, String _receiver, BigInteger _fee) {
String encodedConstructor = FunctionEncoder.encodeConstructor(Arrays.asList(new org.web3j.abi.datatypes.Address(_receiver),
new org.web3j.abi.datatypes.generated.Uint256(_fee)));
return deployRemoteCall(Transactionfee.class, web3j, credentials, gasPrice, gasLimit, BINARY, encodedConstructor);
}
public static RemoteCall deploy(Web3j web3j, TransactionManager transactionManager, BigInteger gasPrice, BigInteger gasLimit, String _receiver, BigInteger _fee) {
String encodedConstructor = FunctionEncoder.encodeConstructor(Arrays.asList(new org.web3j.abi.datatypes.Address(_receiver),
new org.web3j.abi.datatypes.generated.Uint256(_fee)));
return deployRemoteCall(Transactionfee.class, web3j, transactionManager, gasPrice, gasLimit, BINARY, encodedConstructor);
}
// ...
public Observable sentEventObservable(DefaultBlockParameter startBlock, DefaultBlockParameter endBlock) {
EthFilter filter = new EthFilter(startBlock, endBlock, getContractAddress());
filter.addSingleTopic(EventEncoder.encode(SENT_EVENT));
return sentEventObservable(filter);
}
public static Transactionfee load(String contractAddress, Web3j web3j, Credentials credentials, BigInteger gasPrice, BigInteger gasLimit) {
return new Transactionfee(contractAddress, web3j, credentials, gasPrice, gasLimit);
}
public static Transactionfee load(String contractAddress, Web3j web3j, TransactionManager transactionManager, BigInteger gasPrice, BigInteger gasLimit) {
return new Transactionfee(contractAddress, web3j, transactionManager, gasPrice, gasLimit);
}
public static class SentEventResponse {
public Log log;
public String from;
public String to;
public BigInteger amount;
public Boolean sent;
}
}
3. 部署智能合约
一旦我们成功地生成了表示应用程序中合约对象的 Java 对象,我们就可以开始应用程序开发了。我们将从contract-service
应用开始。首先,我们将创建智能钱包,拥有足够的资金作为所有者来签署合约。以下代码是负责这块内容,在应用程序启动后进行处理,并且暴露出一个 HTTP 接口,通过 GET 方法可以获取所有者的地址。
@PostConstruct
public void init() throws IOException, CipherException, NoSuchAlgorithmException, NoSuchProviderException, InvalidAlgorithmParameterException {
String file = WalletUtils.generateLightNewWalletFile("piot123", null);
credentials = WalletUtils.loadCredentials("piot123", file);
LOGGER.info("Credentials created: file={}, address={}", file, credentials.getAddress());
EthCoinbase coinbase = web3j.ethCoinbase().send();
EthGetTransactionCount transactionCount = web3j.ethGetTransactionCount(coinbase.getAddress(), DefaultBlockParameterName.LATEST).send();
Transaction transaction = Transaction.createEtherTransaction(coinbase.getAddress(), transactionCount.getTransactionCount(), BigInteger.valueOf(20_000_000_000L), BigInteger.valueOf(21_000), credentials.getAddress(),BigInteger.valueOf(25_000_000_000_000_000L));
web3j.ethSendTransaction(transaction).send();
EthGetBalance balance = web3j.ethGetBalance(credentials.getAddress(), DefaultBlockParameterName.LATEST).send();
LOGGER.info("Balance: {}", balance.getBalance().longValue());
}
@GetMapping("/owner")
public String getOwnerAccount() {
return credentials.getAddress();
}
contract-service
应用暴露一些可以被内部应用调用的接口,例如示例应用transaction-service
。下面的代码主要实现 POST /contract
接口,该接口将做两个操作。首先,创建一个带有凭证信息的智能钱包,然后,它使用这些凭证与前面步骤中定义的地址签署一个智能合约。要签署一个新的合约,必须从 Solidity 定义的 Transactionfee
类来调用 deploy
方法。它负责在以太坊节点上部署一个新的合约实例。
private List contracts = new ArrayList();
@PostMapping
public Contract createContract(@RequestBody Contract newContract) throws Exception {
String file = WalletUtils.generateLightNewWalletFile("piot123", null);
Credentials receiverCredentials = WalletUtils.loadCredentials("piot123", file);
LOGGER.info("Credentials created: file={}, address={}", file, credentials.getAddress());
Transactionfee2 contract = Transactionfee2.deploy(web3j, credentials, GAS_PRICE, GAS_LIMIT, receiverCredentials.getAddress(), BigInteger.valueOf(newContract.getFee())).send();
newContract.setReceiver(receiverCredentials.getAddress());
newContract.setAddress(contract.getContractAddress());
contracts.add(contract.getContractAddress());
LOGGER.info("New contract deployed: address={}", contract.getContractAddress());
Optional tr = contract.getTransactionReceipt();
if (tr.isPresent()) {
LOGGER.info("Transaction receipt: from={}, to={}, gas={}", tr.get().getFrom(), tr.get().getTo(), tr.get().getGasUsed().intValue());
}
return newContract;
}
每一份部署在以太坊上的合约都有自己唯一地址。应用程序存储每个创建的合约的唯一地址。然后应用程序便可以使用这些地址加载所有已存在的智能合约。以下代码便是在选定的合约上执行 sentTrx
方法。
public void processContracts(long transactionAmount) {
contracts.forEach(it -> {
Transactionfee contract = Transactionfee.load(it, web3j, credentials, GAS_PRICE, GAS_LIMIT);
try {
TransactionReceipt tr = contract.sendTrx(BigInteger.valueOf(transactionAmount)).send();
LOGGER.info("Transaction receipt: from={}, to={}, gas={}", tr.getFrom(), tr.getTo(), tr.getGasUsed().intValue());
LOGGER.info("Get receiver: {}", contract.getReceiverBalance().send().longValue());
EthFilter filter = new EthFilter(DefaultBlockParameterName.EARLIEST, DefaultBlockParameterName.LATEST, contract.getContractAddress());
web3j.ethLogObservable(filter).subscribe(log -> {
LOGGER.info("Log: {}", log.getData());
});
} catch (Exception e) {
LOGGER.error("Error during contract execution", e);
}
});
}
contract-service
应用监听了传入以太坊节点的交易,交易由transaction-service
应用发出。如果交易目标账户与智能合约所有者账户一致,则处理给定的交易。
@Autowired
Web3j web3j;
@Autowired
ContractService service;
@PostConstruct
public void listen() {
web3j.transactionObservable().subscribe(tx -> {
if (tx.getTo() != null && tx.getTo().equals(service.getOwnerAccount())) {
LOGGER.info("New tx: id={}, block={}, from={}, to={}, value={}", tx.getHash(), tx.getBlockHash(), tx.getFrom(), tx.getTo(), tx.getValue().intValue());
service.processContracts(tx.getValue().longValue());
} else {
LOGGER.info("Not matched: id={}, to={}", tx.getHash(), tx.getTo());
}
});
}
这是 transaction-service
应用的源代码,负责将资金从第三方账户转到合约所有者账户。
@Value("${contract-service.url}")
String url;
@Autowired
Web3j web3j;
@Autowired
RestTemplate template;
Credentials credentials;
@PostMapping
public String performTransaction(@RequestBody TransactionRequest request) throws Exception {
EthAccounts accounts = web3j.ethAccounts().send();
String owner = template.getForObject(url, String.class);
EthGetTransactionCount transactionCount = web3j.ethGetTransactionCount(accounts.getAccounts().get(request.getFromId()), DefaultBlockParameterName.LATEST).send();
Transaction transaction = Transaction.createEtherTransaction(accounts.getAccounts().get(request.getFromId()), transactionCount.getTransactionCount(), GAS_PRICE, GAS_LIMIT, owner, BigInteger.valueOf(request.getAmount()));
EthSendTransaction response = web3j.ethSendTransaction(transaction).send();
if (response.getError() != null) {
LOGGER.error("Transaction error: {}", response.getError().getMessage());
return "ERR";
}
LOGGER.info("Transaction: {}", response.getResult());
EthGetTransactionReceipt receipt = web3j.ethGetTransactionReceipt(response.getTransactionHash()).send();
if (receipt.getTransactionReceipt().isPresent()) {
TransactionReceipt r = receipt.getTransactionReceipt().get();
LOGGER.info("Tx receipt: from={}, to={}, gas={}, cumulativeGas={}", r.getFrom(), r.getTo(), r.getGasUsed().intValue(), r.getCumulativeGasUsed().intValue());
}
EthGetBalance balance = web3j.ethGetBalance(accounts.getAccounts().get(request.getFromId()), DefaultBlockParameterName.LATEST).send();
LOGGER.info("Balance: address={}, amount={}", accounts.getAccounts().get(request.getFromId()), balance.getBalance().longValue());
balance = web3j.ethGetBalance(owner, DefaultBlockParameterName.LATEST).send();
LOGGER.info("Balance: address={}, amount={}", owner, balance.getBalance().longValue());
return response.getTransactionHash();
}
4. 测试场景
要运行测试场景,我们需要启动:
- 开放环境下在 Docker 容器中启动以太坊节点
- Docker 容器中以太坊 Geth 控制台客户端
-
contact-service
应用实例,默认端口8090 -
transaction-service
应用实例,默认端口8091
在前一篇文章中已经介绍了如何使用 Docker 容器运行以太坊节点和 Geth 客户端。在我们开始示例应用之前,我们需要在以太坊节点上至少创建一个测试账户,我们可以在 Geth 控制台执行 personal.newAccount
命令。
在 transaction-service
应用启动后,从coinbase账户转一些资金到所有其他账户上。
下一步是使用 contract-service
应用创建一些智能合约,该应用在启动时会自动创建所有者账户。通过调用 POST /contract
方法,fee
参数来指定交易金额从合约所有者账户转移到合约接收者账户的百分比。使用以下命令,我部署了两个合约,这意味着第三方用户发送到所有者账户的眉笔交易的10%和5%被转移到 POST 方法生成的对应账户中。POST 方法创建的账户地址将在 receiver
字段返回。
curl -X POST -H "Content-Type: application/json" -d '{"fee":10}' http://localhost:8090/contract
{"fee": 10,"receiver": "0x864ef9931c2690efcc6a773760237c4b09f40e65","address": "0xa6205a746ae0858fa22d6451b794cc977faa507c"}
curl -X POST -H "Content-Type: application/json" -d '{"fee":5}' http://localhost:8090/contract
{"fee": 5,"receiver": "0x098898594d7acd1481324af779e431ab87a3155d","address": "0x9c64d6b0fc01ee055e114a528fb5ad853843cde3"}
如果成功部署了合约,最后要做的就是通过调用 transaction-service
的 POST /transaction
方法来发送交易,所有者账户将从 contract-service
应用来获取。必须设置交易金额和源账户索引(eth.accounts[index]
)。
curl -X POST -H "Content-Type: application/json" -d '{"amount":1000000,"fromId":1}' http://localhost:8090/transaction
最后,交易由contract-service
应用接收,它将对所有已定义的合约执行 sendTrx(...)
函数,结果是,交易金额的10%和5%用于合约接收方。
示例代码已经托管到 GitHub:https://github.com/piomin/sample-spring-blockchain-contract.git。