Despite the fact that VeChainThor is EVM compatible, the protocol has its unique design of constructing a transaction that can include multiple clauses representing different tasks. Moreover, each transaction is uniquely identified by a 32-byte id in the system instead of an address and nonce pair. These features make it difficult for developers to test their smart contracts on VeChainThor using tools (e.g., Truffle) built for Ethereum.
I encountered the difficulties and started to build my own tool myvetools
to make the job easier for me. The tool is not perfect, however, it does allow me to relatively efficiently write TS code to test my contracts. I feel like it may be worth sharing it with other developers.
The tool could ease your job in three ways:
- It allows you to relatively easily load a particular version of the solidity compiler;
- It allows you to compile a solidity source file directly in code;
- It wraps
Connex
to provide simplified methods for interacting with a Thor node.
npm install myvetools
To download a particular version, for instance, 0.7.0,
./node_modules/.bin/solcver -d 0.7.0
To use a particular version, for instance, 0.7.0, to compile contracts in your code,
./node_modules/.bin/solcver -u 0.7.0
The snippet below shows how to compile the source file of contract A
, named A.sol
, to get the corresponding ABI and binary code for deployment.
import { compileContract } from 'myvetools/dist/utils'
const abi = JSON.parse(compileContract(`path/to/A.sol`, 'A', 'abi'))
const bin = compileContract(filePath, 'A', 'bytecode')
I made a simple example to demonstrate how we can use myvetools
to write code for testing a smart contract. I'm going to explain the example in detail in the rest of this article.
The following is a snippet for setting up your code for testing. As can be seen, it connects a Thor node identified by variable url
and instantiates variable connex
that implements Connex
interfaces for interacting with a Thor node.
import { expect, assert } from 'chai'
import { Framework } from '@vechain/connex-framework'
import { Driver, SimpleNet, SimpleWallet } from '@vechain/connex-driver'
describe('Test contract A', () => {
const wallet = new SimpleWallet()
// Add private keys
wallet.import(...)
// Set to connect to a local Thor node
const url = 'http://localhost:8669/'
let driver: Driver
let connex: Framework
before(async () => {
try {
driver = await Driver.connect(new SimpleNet(url), wallet)
connex = new Framework(driver)
} catch (err) {
assert.fail('Failed to connect: ' + err)
}
})
after(() => {
driver.close()
})
})
To connect to a public testnet node, you can try
const url = 'http://testnet.veblocks.net'
For developers who are not familiar with VeChainThor, one important thing they need to understand is that
A transaction can sequentially perform multiple tasks (e.g., transfering tokens or calling smart contracts), all initiated by the same transaction sender and each task is represented by a clause.
Consequently, to invoke a particular contract function causing a state change, we need to
- First construct a clause,
- Put the clause inside a transaction, and
- Send off the transaction for execution.
Please keep this process in mind and it will help you better understand the example code later. More info about the transaction model can be found here.
Contract A
is a simple smart contract that basically stores an integer and allows you change its value through function set
.
pragma solidity ^0.7.0;
contract A {
uint public a;
event SetA(uint val);
constructor(uint _a) {
a = _a;
}
function set(uint _a) public {
a = _a;
emit SetA(_a);
}
}
To test contract A
, I'm going to do the following things:
- To deploy
A
with an initial value100
- To call function
a
and check whether the returned value equals100
- To invoke function
set
twice within one single transaction, setting the value to200
and then to300
- To validate the two emitted events
- To call function
a
and check whether the returned value equals300
Note that the complete code for testing contract A
can be found here.
We first initiate an object of Contract
to represent an instance of contract A
:
import { Contract } from 'myvetools/dist/contract'
const c = new Contract({ abi: abi, connex: connex, bytecode: bin })
Recall the relationship between clause and transaction.
We then generate the clause for the deployment with an initial value 100
, put the clause inside a transaction and send off the transation to the Thor node for execution:
const clause1 = c.deploy(0, 100)
const txRep = await connex.vendor.sign('tx', [clause1])
.signer(sender)
.request()
After that, we check the receipt of the transaction and check whether it has been successfully executed (or not reverted):
import { getReceipt } from 'myvetools/dist/connexUtils'
const receipt = await getReceipt(connex, 5, txRep.txid)
expect(receipt.reverted).to.equal(false)
Here, the transaction is identified by its ID, kept by txRep.txid
, which is then passed to function getReceipt
.
The next thing to do is to obtain the contract address and set the address in c
:
if (receipt.outputs[0].contractAddress !== null) {
c.at(receipt.outputs[0].contractAddress)
}
const callOut = await c.call('a')
expect(parseInt(callOut.decoded['0'])).to.equal(100)
const clause2 = c.send('set', 0, 200)
const clause3 = c.send('set', 0, 300)
txRep = await connex.vendor.sign('tx', [clause2, clause3])
.signer(sender)
.request()
Here, two clauses are constructed and put in one single transaction. Note that clauses are executed by the order they put in the array passed to function sign
. Therefore, the integer kept by the contract will be changed to 200
and then to 300
.
import { decodeEvent } from 'myvetools/dist/connexUtils'
import { getABI } from 'myvetools/dist/utils'
const expected = [200, 300]
receipt.outputs.forEach((output, i) => {
const decoded = decodeEvent(
output.events[0],
getABI(abi, 'SetA', 'event')
)
expect(parseInt(decoded['val'])).to.equal(expected[i])
})
Here, receipt
has two elements in its field outputs
, corresponding to the outputs for two included clauses, respectively.
const callOut = await c.call('a')
expect(parseInt(callOut.decoded['0'])).to.equal(300)