
Smart Contract Development
- 86 installs
- 9 repo stars
- Updated June 11, 2026
- vechain/vechain-ai-skills
Helps with ai & agent building tasks.
About
smart-contract-development is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- smart-contract-development
- AI & Agent Building
- AI-coding skill
Smart Contract Development by the numbers
- 86 all-time installs (skills.sh)
- Ranked #4,993 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vechain/vechain-ai-skills --skill smart-contract-developmentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 86 |
|---|---|
| repo stars | ★ 9 |
| Last updated | June 11, 2026 |
| Repository | vechain/vechain-ai-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Smart Contract Development Skill
CRITICAL RULES
1. Read reference files FIRST. When the user's request involves any topic in the reference table below, read those files before doing anything else — before writing code, before making decisions. Briefly mention which files you are reading so the user can confirm the skill is active (e.g., "Reading smart contracts reference..."). 2. Information priority for VeChain topics: (a) Reference files in this skill — always the primary source. (b) VeChain MCP tools — use @vechain/mcp-server for on-chain data, transaction building, and live network queries; use Kapa AI MCP for VeChain documentation lookups. (c) Web search — only as a last resort, and only for topics NOT covered in the reference files. 3. Prefer working directly in the main conversation for VeChain tasks. Plan mode and subagents do not inherit skill context and may fall back to web search instead of using reference files. 4. After compaction or context loss, re-read this SKILL.md to restore awareness of the reference table and operating procedure before continuing work.
Scope
Use this Skill for Solidity smart contract development on VeChainThor:
- Solidity contracts with Hardhat +
@vechain/sdk-hardhat-plugin - ERC-20, ERC-721, access control, upgradeable (UUPS) patterns
- Gas optimization techniques
- Testing with Hardhat + Thor Solo
- Security reviews and audit checklists
- ABI codegen and TypeChain setup
Default stack
| Layer | Default | Alternative |
|---|---|---|
| Contracts | Solidity + Hardhat + @vechain/sdk-hardhat-plugin | -- |
| EVM target | paris (mandatory) | -- |
| Testing | Hardhat + Thor Solo (--on-demand) | -- |
| Types | TypeChain (@typechain/ethers-v6) | @vechain/vechain-contract-types (pre-built) |
| Node | Node 20 LTS (managed via nvm) | -- |
Operating procedure
1. Check Node version
Before installing dependencies or running any command:
- Check if
.nvmrcexists in the project root. If yes, runnvm useto switch to the required version. - If
.nvmrcdoes not exist, create one with20(Node 20 LTS) and runnvm use.
2. Detect project structure
turbo.jsonpresent → follow Turborepo conventions (packages/contracts,packages/*)
3. Clarify before implementing
When the user's request is ambiguous or could be solved multiple ways, ask before building. Separate research from implementation.
4. Implement with VeChain-specific correctness
- Network: always explicit (
mainnet/testnet/solo) - EVM target: always
paris - Gas: estimate first
- Tokens: VET for value, VTHO for gas (dual-token model)
5. Verify and deliver
A task is not complete until all applicable gates pass:
1. Code compiles — no build errors 2. Tests pass — existing tests still pass; new logic has test coverage 3. Risk notes documented — any signing, fee, or token-transfer implications are called out
Reference files
Read the matching files BEFORE doing anything else. See Critical Rules above.
| Topic | File | Read when user mentions... |
|---|---|---|
| Smart contracts | references/smart-contracts.md | Solidity, Hardhat, ERC-20, ERC-721, deploy, contract interaction, libraries, contract size, upgradeable, proxy, upgrade, reinitializer, version pattern, deploy helpers, NatSpec, Slither |
| Gas optimization | references/smart-contracts-optimization.md | gas, optimize, storage packing, assembly, unchecked |
| Testing | references/testing.md | test, Thor Solo, Docker, CI, fixtures |
| ABI / codegen | references/abi-codegen.md | TypeChain, ABI, typechain-types, code generation |
| Security | references/security.md | security, audit, vulnerability, reentrancy, access control, invariant, balance check, path symmetry, adversarial, edge cases, what could go wrong |
ABIs + Client Generation (TypeChain)
When to use
Use when the user asks about TypeChain, type-safe contract interaction, ABI extraction, code generation, or @vechain/vechain-contract-types.
Rule
Never hand-maintain contract interaction code. Use ABI-driven, code-generated workflow.
Hardhat + TypeChain Setup
npm install --save-dev @typechain/hardhat typechain @typechain/ethers-v6// hardhat.config.ts
import '@typechain/hardhat';
const config = {
typechain: {
outDir: 'typechain-types',
target: 'ethers-v6',
},
};# Types generated automatically on compile
npx hardhat compile
# Output: typechain-types/Using Generated Types
import { MyToken, MyToken__factory } from '../typechain-types';
// Deploy
const factory = new MyToken__factory(signer);
const token: MyToken = await factory.deploy(1_000_000);
// Read/write with full type safety
const balance: bigint = await token.balanceOf(address);
await token.transfer(recipient, amount);
// Typed event filters
const filter = token.filters.Transfer(from, to);
const events = await token.queryFilter(filter);Using Types with VeChain Kit (useCallClause)
import { useCallClause } from '@vechain/vechain-kit';
import { MyContract__factory } from '../typechain-types';
export const useTokenBalance = (address: string) => {
return useCallClause({
abi: MyContract__factory.abi,
address: CONTRACT_ADDRESS,
method: 'balanceOf',
args: [address],
queryOptions: { enabled: !!address },
});
};Pre-built Types: @vechain/vechain-contract-types
Always install this package when building VeChain dApps. It provides TypeChain-generated ABIs and factories for all major VeChain ecosystem contracts — no need to hand-write ABIs.
npm install @vechain/vechain-contract-typesAvailable contract categories
| Category | Key factories | Use for |
|---|---|---|
| Built-in / B32 | Energy__factory, Params__factory, Authority__factory, Extension__factory, Prototype__factory | VeChainThor built-in contracts (VTHO, chain params) |
| Smart accounts | SocialLoginSmartAccount__factory, SocialLoginSmartAccountFactory__factory | Social login account abstraction |
| VeBetterDAO | B3TR__factory, VOT3__factory, X2EarnApps__factory, X2EarnRewardsPool__factory, XAllocationVoting__factory, VeBetterPassport__factory, GalaxyMember__factory, Emissions__factory, Treasury__factory, VoterRewards__factory | X2Earn apps, governance, rewards |
| StarGate | Stargate__factory, StargateNFT__factory, IProtocolStaker__factory | VET staking, node management |
| VeVote | VeVote__factory | Governance proposals and voting |
| VET domains | VetDomainsRegistry__factory, VetDomainsPublicResolver__factory, VetDomainsResolveUtilities__factory | .vet domain resolution |
| DEX | UniswapV2Factory__factory, UniswapV2Pair__factory, UniswapV2Router02__factory | DEX interactions |
| Tokens | Vip180Mintable__factory, Vip181Mintable_v7__factory | Standard VIP-180/VIP-181 tokens |
Usage with useCallClause
import { useCallClause } from '@vechain/vechain-kit';
import { B3TR__factory } from '@vechain/vechain-contract-types';
export const useB3trBalance = (address: string) => {
return useCallClause({
abi: B3TR__factory.abi,
address: B3TR_CONTRACT_ADDRESS,
method: 'balanceOf',
args: [address],
queryOptions: { enabled: !!address },
});
};Usage with ThorClient
import { B3TR__factory } from '@vechain/vechain-contract-types';
const contract = thorClient.contracts.load(contractAddress, B3TR__factory.abi);Do not modify auto-generated files in this package.
ABI Extraction
Hardhat produces ABI artifacts in artifacts/contracts/:
import MyTokenArtifact from '../artifacts/contracts/MyToken.sol/MyToken.json';
const abi = MyTokenArtifact.abi;
const bytecode = MyTokenArtifact.bytecode;For SDK contract interaction patterns using raw ABIs, see smart-contracts.md.
Guardrails
- TypeChain output should be in
.gitignore(generated on compile) - If consumers need pre-built types, publish the package or check in generated files
- Always regenerate after contract changes (
npx hardhat compile) - Do NOT copy-paste ABI arrays into application code manually
- Do NOT write manual TypeScript interfaces for contract methods
- Do NOT use
anytypes when TypeChain is available
VeChain Smart Contract Security Checklist
When to use
Use when the user asks about: security, audit, vulnerability review, reentrancy, access control, or when reviewing contract code.
Core Principle
Assume the attacker controls:
- Every function argument
- Transaction ordering (front-running, sandwich attacks)
- External contract calls (reentrancy, composability exploits)
- Contract state between transactions
---
Vulnerability Categories
1. Reentrancy Attacks
Risk: External calls allow malicious contracts to re-enter your function before state updates complete.
Attack: Attacker's receive() or fallback() function calls back into the vulnerable contract, draining funds.
Prevention:
// Option 1: Use OpenZeppelin's ReentrancyGuard (recommended)
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract Vault is ReentrancyGuard {
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "Insufficient");
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
// Option 2: Checks-Effects-Interactions pattern
function withdraw(uint256 amount) external {
// Checks
require(balances[msg.sender] >= amount, "Insufficient");
// Effects (update state BEFORE external call)
balances[msg.sender] -= amount;
// Interactions (external call last)
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}---
2. Access Control Vulnerabilities
Risk: Missing or incorrect access control allows unauthorized users to call privileged functions.
Attack: Attacker calls admin functions (mint, pause, upgrade, withdraw) directly.
Prevention:
// Use OpenZeppelin AccessControl or Ownable
import "@openzeppelin/contracts/access/Ownable.sol";
contract Secure is Ownable {
constructor() Ownable(msg.sender) {}
function adminAction() external onlyOwner {
// Only owner can call
}
}
// For granular roles:
import "@openzeppelin/contracts/access/AccessControl.sol";
contract RoleBased is AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
// Only accounts with MINTER_ROLE
}
}Critical: Never use tx.origin for authorization:
// BAD: vulnerable to phishing via malicious contracts
require(tx.origin == owner, "Not owner");
// GOOD: use msg.sender
require(msg.sender == owner, "Not owner");---
3. State-Bound Invariants & Path Symmetry
Risk: A protected invariant (e.g. inscribed_amount ≤ user_balance, total_allocated ≤ total_supply, sum(child) ≤ parent) is enforced on one path but not on all paths that mutate the same state. Users reach the invariant violation by chaining writes through the unguarded path.
Anti-pattern: An adjacent guard appears to enforce the invariant, but it actually only enforces the reverse direction. Most common with token locks — the lock stops outflow below the locked amount, but does not bound how high the locked amount itself can be inscribed.
Real incident (VeBetterDAO Navigator delegation, May 2026):
// VOT3 transfer lock — bounds OUTFLOW only
function _update(address from, ..., uint256 amount) internal {
uint256 locked = navRegistry.getDelegatedAmount(from);
require(balanceOf(from) - amount >= locked, "exceeds unlocked"); // outflow check
}
// NavigatorRegistry — INSCRIPTION had no balance check
function delegate(uint256 amount) external {
require(amount >= MIN_DELEGATION); // ✓
require(navHasCapacity(amount)); // ✓
// missing: require(amount <= balanceOf(msg.sender) - alreadyDelegated)
delegated[msg.sender] += amount;
}
function increaseDelegation(uint256 amount) external { /* same gap */ }A user with 3k VOT3 balance called delegate(3k) then increaseDelegation(3k) and ended with delegated = 6k, balance = 3k`. The transfer lock dutifully froze their entire 3k balance forever, "protecting" the inflated delegation.
Prevention:
// Add the explicit guard on EVERY write path that grows the locked/inscribed amount
function delegate(uint256 amount) external {
uint256 available = IVOT3(vot3).unlockedBalance(msg.sender);
if (amount > available) revert InsufficientUnlockedBalance(msg.sender, amount, available);
...
}
function increaseDelegation(uint256 amount) external {
uint256 available = IVOT3(vot3).unlockedBalance(msg.sender);
if (amount > available) revert InsufficientUnlockedBalance(msg.sender, amount, available);
...
}The discipline — for every protected invariant `inscribed ≤ resource`:
1. List every entrypoint that increases inscribed — set, add, increase, batch helpers, migration paths, auto-clear+rewrite paths. 2. List every entrypoint that decreases resource — transfer, burn, withdraw, conversion, slashing. 3. Confirm a guard exists on all of (1) AND all of (2). One side alone is not enough. 4. Pay extra attention to comments that claim "enforced elsewhere" — go read the elsewhere and check the direction of the check, not just its presence.
Common shapes of this bug:
| Invariant | Inscription paths to guard | Resource paths to guard |
|---|---|---|
delegated[user] ≤ balanceOf(user) | delegate, increaseDelegation, migration | transfer, burn, convertToB3TR |
sum(allocations) ≤ totalShares | setAllocation, addAllocation | mintShares, redeemShares |
borrowed ≤ collateral × LTV | borrow, withdrawCollateral | liquidate (other direction) |
staked[user] ≤ approved[user] | stake, restake, compound | approve (decreases), unstake |
---
4. Integer Overflow/Underflow
Risk: Arithmetic operations wrap around, leading to unexpected values.
Note: Solidity 0.8+ has built-in overflow checks. However, unchecked blocks bypass these.
Prevention:
// Solidity 0.8+: safe by default
uint256 result = a + b; // Reverts on overflow
// When using unchecked, ensure overflow is impossible by construction
unchecked {
// ONLY when you've proven overflow cannot happen
uint256 i = 0;
i++; // Safe: bounded by loop condition
}
// Be careful with downcasting
uint256 bigValue = 300;
uint8 smallValue = uint8(bigValue); // Silently truncates to 44!
// Use SafeCast for safe downcasting
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
uint8 safeValue = SafeCast.toUint8(bigValue); // Reverts if > 255---
5. Front-Running / MEV
Risk: Attackers observe pending transactions and submit their own with higher gas priority.
Attack: Sandwich attacks on DEX trades, front-running NFT mints, oracle manipulation.
Prevention:
// Use commit-reveal patterns for sensitive operations
mapping(bytes32 => uint256) public commitments;
function commit(bytes32 hash) external {
commitments[hash] = block.timestamp;
}
function reveal(uint256 value, bytes32 salt) external {
bytes32 hash = keccak256(abi.encodePacked(msg.sender, value, salt));
require(commitments[hash] > 0, "No commitment");
require(block.timestamp >= commitments[hash] + 10, "Too early"); // ~1 block on VeChain
delete commitments[hash];
// Process the revealed value
}
// Use slippage protection for swaps
function swap(uint256 amountIn, uint256 minAmountOut) external {
uint256 amountOut = calculateOutput(amountIn);
require(amountOut >= minAmountOut, "Slippage exceeded");
// Execute swap
}Note: VeChain's ~10-second block time and different mempool dynamics make front-running less common than on Ethereum, but it is still possible.
---
6. Uninitialized Storage / Proxy Vulnerabilities
Risk: Upgradeable contracts can have uninitialized state or storage collisions.
Attack: Attacker calls initialize() on an uninitialized proxy or exploits storage layout conflicts.
Prevention:
// Always use initializers for upgradeable contracts
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract MyUpgradeable is Initializable {
uint256 public value;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // Prevent implementation initialization
}
function initialize(uint256 _value) public initializer {
value = _value;
}
}- Never add new state variables between existing ones in upgrades
- Use OpenZeppelin's upgrade safety checks
- Always
_disableInitializers()in the constructor
---
7. Denial of Service (DoS)
Risk: Attacker makes a function unusable for legitimate users.
Attack: Gas griefing, unbounded loops, failed external calls blocking execution.
Prevention:
// BAD: Unbounded loop over user-controlled array
function distributeAll() external {
for (uint256 i = 0; i < recipients.length; i++) {
payable(recipients[i]).transfer(amounts[i]); // DoS if one fails
}
}
// GOOD: Pull pattern (users withdraw themselves)
mapping(address => uint256) public pendingWithdrawals;
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
// GOOD: Batch with limit
function distributeBatch(uint256 start, uint256 count) external {
uint256 end = start + count;
require(end <= recipients.length, "Out of bounds");
for (uint256 i = start; i < end;) {
// process
unchecked { ++i; }
}
}---
8. Oracle Manipulation
Risk: Price oracles or data feeds can be manipulated to exploit DeFi protocols.
Attack: Flash loan attack manipulates spot price, attacker profits from mispriced assets.
Prevention:
// Use time-weighted average prices (TWAP) instead of spot prices
// Use multiple oracle sources
// Add circuit breakers for extreme price movements
function getPrice() public view returns (uint256) {
uint256 price = oracle.getPrice();
require(price > minPrice && price < maxPrice, "Price out of bounds");
return price;
}---
9. Unsafe External Calls
Risk: Low-level calls can fail silently or return unexpected data.
Prevention:
// BAD: ignoring return value
address(target).call{value: amount}("");
// GOOD: check return value
(bool success, bytes memory returnData) = address(target).call{value: amount}("");
require(success, "Call failed");
// BAD: using transfer (2300 gas limit, can break)
payable(recipient).transfer(amount);
// GOOD: use call with reentrancy protection
(bool success, ) = recipient.call{value: amount}("");
require(success, "Transfer failed");
// For ERC-20 tokens, use SafeERC20
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
token.safeTransfer(recipient, amount);
token.safeTransferFrom(sender, recipient, amount);---
10. Signature Replay
Risk: Valid signatures can be reused across transactions, chains, or contexts.
Prevention:
// Include nonce, chain ID, and contract address in signed data
mapping(address => uint256) public nonces;
function executeWithSignature(
address to,
uint256 amount,
uint256 nonce,
bytes calldata signature
) external {
require(nonce == nonces[msg.sender], "Invalid nonce");
nonces[msg.sender]++;
bytes32 hash = keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR, // includes chain ID and contract address
keccak256(abi.encode(to, amount, nonce))
));
address signer = ECDSA.recover(hash, signature);
require(signer == msg.sender, "Invalid signature");
// Execute action
}---
Smart Contract Checklist
Input Validation
- [ ] Validate all function parameters (non-zero addresses, valid ranges)
- [ ] Use
requireor custom errors for all preconditions - [ ] Validate array lengths match when processing parallel arrays
- [ ] Check for zero amounts in transfer/approval functions
State-Bound Invariants
- [ ] For each invariant
inscribed ≤ resource, every entrypoint that growsinscribedhas a guard - [ ] For each invariant
inscribed ≤ resource, every entrypoint that shrinksresourcehas a guard - [ ] No comment claims "enforced by X" without a verified read of X confirming the direction of the check
- [ ] Sibling entrypoints mutating the same storage share the same guards (e.g.
setandaddandincrease)
Access Control
- [ ] Every privileged function has appropriate access control
- [ ] Use
Ownable2Stepfor ownership transfers - [ ] Never use
tx.originfor authorization - [ ] Admin functions are clearly identified and tested
Reentrancy
- [ ] Use
ReentrancyGuardon all functions that make external calls - [ ] Follow Checks-Effects-Interactions pattern
- [ ] State updates happen before external calls
Arithmetic
- [ ] Solidity 0.8+ is used (built-in overflow protection)
- [ ]
uncheckedblocks are only used where overflow is provably impossible - [ ] Safe downcasting with
SafeCastwhen needed - [ ] Division by zero is prevented
External Interactions
- [ ] Return values of external calls are checked
- [ ]
SafeERC20is used for token transfers - [ ] No reliance on
transfer()orsend()(usecallinstead) - [ ] Contract handles the case where external call reverts
Upgradeable Contracts
- [ ]
_disableInitializers()in constructor - [ ] Storage layout preserved across upgrades
- [ ]
initializermodifier on initialization functions - [ ] Upgrade authorization properly restricted
---
Client-Side Checklist
- [ ] Network awareness: never hardcode mainnet endpoints in dev flows
- [ ] Estimate gas before sending transactions
- [ ] Handle transaction confirmation properly (poll receipt)
- [ ] Treat "transaction ID received" as not-final; track confirmation
- [ ] Validate contract addresses against expected values
- [ ] Show clear error messages for revert reasons
- [ ] Handle fee delegation failures gracefully (fallback to user-paid)
- [ ] Never expose private keys in frontend code
---
VeChain-Specific Security Considerations
- Dual token model: Ensure contracts handle both VET and VTHO correctly
- Multi-clause atomicity: All clauses revert together; design accordingly
- Fee delegation: Validate that delegated transactions are properly authorized
- Block time: ~10 seconds; do not rely on sub-block-time precision for security
- EVM compatibility: Target
parisEVM version; newer opcodes will fail - Built-in contracts: Be aware of VeChainThor's genesis contracts and their interfaces
---
Security Review Procedure
When conducting a security review, follow this four-pass approach to avoid confirmation bias and missed paths.
Pass 0 — Map paths and invariants
Before looking for bugs, list:
1. Every entrypoint that mutates each storage variable (group by storage, not by feature). 2. Every cross-contract read the contract makes, and what it assumes about the other contract's state. 3. Every protected invariant in the form X ≤ Y or sum(children) ≤ parent — explicit (in require) or implicit (assumed by callers).
For each invariant, check that every entrypoint that grows the left-hand side has the guard, AND every entrypoint that shrinks the right-hand side has the corresponding guard. Missing path-symmetry on a state-bound invariant is the most common high-severity finding — see category #3.
Pass 1 — Enumerate (be thorough)
Walk through every contract function and external interaction. For each, check every category in the vulnerability list and checklists above. Report all potential findings, even low-confidence ones. Do not self-censor — over-reporting is better than missing a real issue.
Pass 2 — Challenge (be adversarial)
Re-examine each finding from Pass 1 critically:
- Is this actually exploitable, or only theoretically possible?
- Does the surrounding code (guards, modifiers, call context) already prevent it?
- Am I reporting this because there is evidence, or because the user asked me to find bugs?
Discard findings that cannot survive this scrutiny. Reclassify severity where needed.
Pass 3 — Classify and report
Present surviving findings grouped by severity (Critical / High / Medium / Low / Informational) with:
- What: the vulnerability
- Where: contract, function, line
- Why: how an attacker exploits it
- Fix: concrete remediation
---
Security Review Questions
Use these as prompts during Pass 1:
1. Can an attacker re-enter any function via an external call? 2. Can an attacker call privileged functions without authorization? 3. Can an attacker manipulate arithmetic to gain an advantage? 4. Can an attacker front-run a transaction to extract value? 5. Can an attacker replay a valid signature in a different context? 6. Can an attacker cause a DoS by making a function revert for everyone? 7. Can an attacker exploit the upgrade mechanism? 8. Can an attacker manipulate oracle data to misvalue assets? 9. Path-symmetry: For every guard I see on entrypoint A, does the sibling entrypoint B that mutates the same storage have the same guard? If not — why not? 10. Direction: For every guard I rely on, does it bound the value in the direction I think? A check like balance - amount ≥ locked bounds outflow, not the inscription of locked. 11. Sequencing: Can a user reach state X by chaining N legal calls? delegate(max) → increase(max), claim → claim, register → exit → re-register, setRate → setRate → settle? 12. Boundaries: What happens at 0, 1 wei, MIN, MIN-1, MAX, MAX+1, exact-resource, resource+1? Has every boundary been tested, not only the middle of the range? 13. Cross-contract assumptions: When I read from contract Y, what if Y returns 0, returns the previous block's value, is paused, has been upgraded, has had its admin role revoked? 14. State transitions during the action: What if the navigator dies, the round ends, the cycle rolls over, the allowance is revoked, the balance drops mid-flow?
Adversarial Path Coverage in Tests
A feature is not done when the happy path is green. Before declaring done, the test suite must cover:
- All sibling entrypoints mutating the same storage (writes, reductions, auto-clears, migrations, batch helpers).
- Sequences a user can chain:
max → increase,delegate → switch → delegate,claim → claim again,enter → exit → re-enter. - Boundaries: 0, 1 wei, MIN, MIN-1, MAX, MAX+1, exact-balance, balance+1.
- Cross-contract assumptions: stale reads, lazy invalidation, contract paused, oracle returning a different value than last block.
- State transitions during the action: counter-party dies mid-flow, round ends, reward cycle rolls over, balance drops between read and write.
"It works for the path I coded" is the bug. The user's path is every path the contract permits. Before submitting, write down: "could a user reach state X by any sequence?" — and prove the answer with a test, not by reading code.
Smart Contract Gas Optimization on VeChainThor
When to use
Use when the user needs:
- Lower VTHO costs: Reduce gas consumption for frequently-called functions
- Efficient storage: Minimize storage slots for cost-critical contracts
- High-throughput contracts: Maximize operations per transaction
- Production readiness: Optimize before mainnet deployment
Storage Optimization
Storage Packing
Pack multiple variables into a single 32-byte storage slot:
// Bad: 3 storage slots (96 bytes of storage)
contract Unpacked {
uint256 amount; // slot 0 (32 bytes)
address owner; // slot 1 (20 bytes, wastes 12)
bool isActive; // slot 2 (1 byte, wastes 31)
}
// Good: 2 storage slots
contract Packed {
uint256 amount; // slot 0 (32 bytes)
address owner; // slot 1, bytes 0-19 (20 bytes)
bool isActive; // slot 1, bytes 20 (1 byte, 11 bytes padding)
}Use Smaller Types When Possible
// Good: fits in one slot
struct PackedConfig {
uint128 maxAmount; // 16 bytes
uint64 startTime; // 8 bytes
uint32 duration; // 4 bytes
uint16 feeRate; // 2 bytes
bool isActive; // 1 byte
uint8 tier; // 1 byte
} // Total: 32 bytes = 1 slotMapping vs Array
- Use mappings for key-value lookups (O(1) access)
- Use arrays only when you need iteration or ordering
- Avoid unbounded arrays (gas cost scales linearly)
// Prefer mapping for lookups
mapping(address => uint256) public balances;
// Use array only when iteration is required
address[] public participants;
mapping(address => bool) public isParticipant; // for O(1) existence checkGas-Efficient Patterns
Constants and Immutables
// Constants: embedded in bytecode, zero storage cost
uint256 public constant MAX_SUPPLY = 1_000_000e18;
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN");
// Immutables: set once in constructor, stored in bytecode
address public immutable factory;
uint256 public immutable deployTimestamp;
constructor(address _factory) {
factory = _factory;
deployTimestamp = block.timestamp;
}Custom Errors (Solidity 0.8.4+)
// Bad: string revert messages cost gas for storage
require(balance >= amount, "Insufficient balance");
// Good: custom errors are much cheaper
error InsufficientBalance(uint256 available, uint256 required);
if (balance < amount) {
revert InsufficientBalance(balance, amount);
}Unchecked Arithmetic
When overflow is impossible by construction:
// Safe: i cannot overflow because it's bounded by array length
for (uint256 i = 0; i < arr.length;) {
// process arr[i]
unchecked { ++i; }
}
// Safe: we already checked balance >= amount
unchecked {
balances[sender] = balance - amount;
}Calldata vs Memory
// Bad: copies array to memory
function process(uint256[] memory data) external { ... }
// Good: reads directly from calldata (cheaper for external functions)
function process(uint256[] calldata data) external { ... }Short-Circuit Evaluation
// Put cheap checks first
require(amount > 0 && balances[msg.sender] >= amount, "Invalid");
// Avoid expensive storage reads when possible
if (cachedValue == 0) {
cachedValue = expensiveComputation();
}Assembly Optimization (Advanced)
Direct Storage Access
function getBalance(address account) external view returns (uint256 result) {
bytes32 slot = keccak256(abi.encode(account, uint256(0))); // mapping slot
assembly {
result := sload(slot)
}
}Efficient Hashing
function efficientHash(bytes32 a, bytes32 b) internal pure returns (bytes32 result) {
assembly {
mstore(0x00, a)
mstore(0x20, b)
result := keccak256(0x00, 0x40)
}
}Zero-Value Checks
function isZeroAddress(address addr) internal pure returns (bool result) {
assembly {
result := iszero(addr)
}
}Batch Operation Patterns
Batch Transfers
function batchTransfer(
address[] calldata recipients,
uint256[] calldata amounts
) external {
require(recipients.length == amounts.length, "Length mismatch");
for (uint256 i = 0; i < recipients.length;) {
_transfer(msg.sender, recipients[i], amounts[i]);
unchecked { ++i; }
}
}Combined with Multi-Clause
For maximum efficiency, combine Solidity batch functions with VeChain's multi-clause transactions:
- Use batch functions for same-contract operations
- Use multi-clause for cross-contract operations
Event Optimization
Indexed Parameters
// Index fields you'll filter by (max 3 indexed per event)
event Transfer(
address indexed from,
address indexed to,
uint256 value // not indexed: cheaper to emit
);
// Use anonymous events for maximum gas savings (rare use case)
event Anonymous() anonymous;Deployment Optimization
Constructor Arguments
Use immutable variables instead of storage for constructor-set values:
// Saves ~20,000 gas per read vs storage variable
address public immutable token;
constructor(address _token) {
token = _token;
}Optimizer Settings
In hardhat.config.ts:
solidity: {
version: '0.8.20',
settings: {
optimizer: {
enabled: true,
runs: 200 // Lower = cheaper deployment, higher = cheaper calls
},
evmVersion: 'paris'
}
}runs: 200- balanced (good default)runs: 1- optimize for deployment costruns: 10000- optimize for runtime cost (frequently-called contracts)
Gas Estimation
Using VeChain SDK
import { ThorClient } from '@vechain/sdk-network';
const thorClient = ThorClient.at('https://testnet.vechain.org');
const gasResult = await thorClient.gas.estimateGas(
clauses,
callerAddress,
{ gasPadding: 0.2 } // 20% padding for safety
);
console.log('Estimated gas:', gasResult.totalGas);Gas Profiling in Tests
it('should use reasonable gas', async () => {
const tx = await contract.transfer(recipient, amount);
const receipt = await tx.wait();
console.log('Gas used:', receipt.gasUsed.toString());
// Assert gas is within expected bounds
expect(receipt.gasUsed).to.be.lessThan(100000);
});Security vs Optimization Trade-offs
- Never sacrifice security for gas savings
- Keep reentrancy guards even if they cost gas
- Keep access control checks even if they cost gas
- Only use
uncheckedwhen overflow is provably impossible - Only use assembly for well-understood, critical-path operations
- Always test optimized code thoroughly
Smart Contracts on VeChainThor (Solidity + Hardhat)
When to use
Use when the user asks about: Solidity contracts, Hardhat setup, deployment, ERC-20, ERC-721, contract interaction with SDK, built-in contracts, VeChainThor EVM, libraries, contract size, upgradeable contracts.
Core Advantages
- EVM Compatibility: VeChainThor runs standard Solidity contracts
- Hardhat Integration: Full Hardhat toolchain with VeChain network support
- Fee Delegation: Built-in support for gasless transactions
- Multi-Clause: Batch multiple operations in a single transaction
Project Setup
Initialize
mkdir my-vechain-project && cd my-vechain-project
npm init -y
npm install --save-dev hardhat @vechain/sdk-hardhat-plugin
npm install @openzeppelin/contracts@5.0.2
npx hardhat initConfiguration (hardhat.config.ts)
import '@vechain/sdk-hardhat-plugin';
import { VET_DERIVATION_PATH } from '@vechain/sdk-core';
const config = {
solidity: {
version: '0.8.20',
settings: {
optimizer: { enabled: true, runs: 200 },
evmVersion: 'paris' // VeChainThor aligns with paris EVM
}
},
networks: {
vechain_solo: {
url: 'http://localhost:8669',
accounts: {
mnemonic: 'denial kitchen pet squirrel other broom bar gas better priority spoil cross',
count: 3,
path: VET_DERIVATION_PATH
},
debug: true,
gas: 'auto',
gasPrice: 'auto'
},
vechain_testnet: {
url: 'https://testnet.vechain.org',
accounts: {
mnemonic: process.env.MNEMONIC || '',
count: 3,
path: VET_DERIVATION_PATH
},
debug: true,
gas: 'auto',
gasPrice: 'auto'
},
vechain_mainnet: {
url: 'https://mainnet.vechain.org',
accounts: [process.env.PRIVATE_KEY || ''],
debug: false,
gas: 'auto',
gasPrice: 'auto'
}
}
};
export default config;EVM Version Compatibility
VeChainThor aligns with the paris EVM version. Always set:
evmVersion: 'paris'Opcodes introduced after Paris (e.g., PUSH0 from Shanghai) are NOT supported.
Pinned Versions
VeChain is not 100% aligned with Ethereum. Pin these to avoid compatibility issues:
- Solidity:
0.8.20— use exact pragma (pragma solidity 0.8.20;), not^0.8.20 - OpenZeppelin Contracts:
5.0.2— pin exact version, no caret (@openzeppelin/contracts@5.0.2) - OpenZeppelin Upgradeable:
5.0.2— pin exact version (@openzeppelin/contracts-upgradeable@5.0.2)
Newer Solidity versions may emit opcodes not yet supported on VeChainThor. Newer OZ versions may use Solidity features or patterns that break on VeChain's EVM.
Common Contract Patterns
ERC-20 Token (VIP-180 compatible)
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyToken is ERC20, Ownable {
constructor(
uint256 initialSupply
) ERC20("MyToken", "MTK") Ownable(msg.sender) {
_mint(msg.sender, initialSupply * 10 ** decimals());
}
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
}ERC-721 NFT (VIP-181 compatible)
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyNFT is ERC721, ERC721URIStorage, Ownable {
uint256 private _nextTokenId;
constructor() ERC721("MyNFT", "MNFT") Ownable(msg.sender) {}
function safeMint(address to, string memory uri) public onlyOwner {
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
}
// Required overrides
function tokenURI(uint256 tokenId)
public view override(ERC721, ERC721URIStorage) returns (string memory) {
return super.tokenURI(tokenId);
}
function supportsInterface(bytes4 interfaceId)
public view override(ERC721, ERC721URIStorage) returns (bool) {
return super.supportsInterface(interfaceId);
}
}Access Control Pattern
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import "@openzeppelin/contracts/access/AccessControl.sol";
contract Governed is AccessControl {
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(ADMIN_ROLE, msg.sender);
}
function adminOnlyAction() external onlyRole(ADMIN_ROLE) {
// ...
}
function operatorAction() external onlyRole(OPERATOR_ROLE) {
// ...
}
}Upgradeable Contract (UUPS) — Base Template
Production-ready base for all upgradeable contracts. Uses AccessControl (not Ownable) and ERC-7201 namespaced storage.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
contract MyContract is AccessControlUpgradeable, UUPSUpgradeable {
bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE");
error UnauthorizedUser(address user);
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
// ---------- Storage ------------ //
// ERC-7201 namespaced storage
struct MyContractStorage {
uint256 value;
// Add fields here. NEVER reorder or remove existing ones.
}
// keccak256(abi.encode(uint256(keccak256("storage.MyContract")) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant MyContractStorageLocation =
0x...; // Compute this value for your contract name
function _getMyContractStorage() private pure returns (MyContractStorage storage $) {
assembly {
$.slot := MyContractStorageLocation
}
}
// ---------- Initializer ------------ //
function initialize(address _upgrader, address[] memory _admins) external initializer {
require(_upgrader != address(0), "MyContract: upgrader is the zero address");
__UUPSUpgradeable_init();
__AccessControl_init();
_grantRole(UPGRADER_ROLE, _upgrader);
for (uint256 i; i < _admins.length; i++) {
require(_admins[i] != address(0), "MyContract: admin address cannot be zero");
_grantRole(DEFAULT_ADMIN_ROLE, _admins[i]);
}
}
// ---------- Modifiers ------------ //
modifier onlyRoleOrAdmin(bytes32 role) {
if (!hasRole(role, msg.sender) && !hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) {
revert UnauthorizedUser(msg.sender);
}
_;
}
// ---------- Upgrade ------------ //
function _authorizeUpgrade(address) internal virtual override onlyRole(UPGRADER_ROLE) {}
function version() public pure virtual returns (string memory) {
return "1";
}
}Key patterns:
- `_disableInitializers()` in constructor — prevents implementation contract from being initialized directly
- `UPGRADER_ROLE` — separates upgrade authority from admin
- Namespaced storage — each contract gets a unique storage slot, avoids collisions
- `version()` — returns current version string, used to verify upgrades succeeded
- `onlyRoleOrAdmin` — convenience modifier for functions that can be called by a specific role OR admin
ERC1967 Proxy Contract
Minimal UUPS-compatible proxy. All upgradeable contracts are deployed behind this proxy. Copy as-is.
// SPDX-License-Identifier: MIT
// Forked from OpenZeppelin Contracts v5.0.0 (proxy/ERC1967/ERC1967Proxy.sol)
pragma solidity 0.8.20;
import { Proxy } from "@openzeppelin/contracts/proxy/Proxy.sol";
import { ERC1967Utils } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol";
/// @dev UUPS-compatible ERC1967 proxy.
/// Constructor deploys the implementation and optionally calls an initializer via delegatecall.
// solc-ignore-next-line missing-receive
contract VeChainProxy is Proxy {
constructor(address implementation, bytes memory _data) payable {
ERC1967Utils.upgradeToAndCall(implementation, _data);
}
function _implementation() internal view virtual override returns (address) {
return ERC1967Utils.getImplementation();
}
}The proxy stores the implementation address in the ERC1967 slot (0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc). Upgrades happen via upgradeToAndCall() on the implementation contract (UUPS pattern — upgrade logic lives in the implementation, not the proxy).
Solidity Libraries
Always prefer libraries to keep contracts maintainable and under the 24KB contract size limit. Extract reusable or isolatable logic into libraries early.
When to Use Libraries
- Contract size: Near the 24KB limit (check with
npx hardhat compile) - Reuse: Logic shared across contracts or that can be isolated
- Readability: Split large contracts into a main contract plus focused "Utils" libraries
Two Kinds of Libraries
A) Storage-types library (not deployed)
Holds storage structs and internal getters that return storage via a fixed slot (ERC-7201). No external functions -- compiled into the contract, not deployed separately.
// contracts/my-module/libraries/MyModuleStorageTypes.sol
library MyModuleStorageTypes {
/// @custom:storage-location erc7201:mymodule.storage.main
struct MainStorage {
mapping(bytes32 => uint256) values;
uint256 counter;
}
bytes32 private constant MainStorageLocation =
0x...; // keccak256(abi.encode(uint256(keccak256("mymodule.storage.main")) - 1)) & ~bytes32(uint256(0xff))
function _getMainStorage() internal pure returns (MainStorage storage s) {
bytes32 location = MainStorageLocation;
assembly { s.slot := location }
}
}B) Utils libraries (deployed and linked)
Contain external functions with real logic. Read/write the same storage as the main contract via the storage-types library. Deployed separately; main contract calls them as LibraryName.functionName(...).
// contracts/my-module/libraries/ValidationUtils.sol
library ValidationUtils {
error InvalidInput(bytes32 id);
function validate(bytes32 id) external view returns (bool) {
MyModuleStorageTypes.MainStorage storage s = MyModuleStorageTypes._getMainStorage();
if (s.values[id] == 0) revert InvalidInput(id);
return true;
}
}Project Layout
contracts/
├── my-module/
│ ├── MyModule.sol # Main contract (thin facade)
│ └── libraries/
│ ├── MyModuleStorageTypes.sol # Storage structs + internal slot getters (not deployed)
│ ├── ValidationUtils.sol # External logic library (deployed)
│ └── ProcessingUtils.sol # External logic library (deployed)
├── libraries/
│ └── SharedDataTypes.sol # Types shared across modules
└── interfaces/
└── IMyModule.solMain Contract Pattern
import "./libraries/MyModuleStorageTypes.sol";
import "./libraries/ValidationUtils.sol";
import "./libraries/ProcessingUtils.sol";
contract MyModule is Initializable, UUPSUpgradeable, AccessControlUpgradeable {
using MyModuleStorageTypes for *;
function doSomething(bytes32 id) external onlyRole(OPERATOR_ROLE) {
ValidationUtils.validate(id); // Delegated to library
ProcessingUtils.process(id); // Delegated to library
}
}- Access control and modifiers stay in the main contract
- Libraries only get storage and do the logic
- No
usingfor deployed (external) libraries -- call directly
Deployment with Library Linking
Deploy Utils libraries first, then link them when deploying the main contract:
// scripts/libraries/myModuleLibraries.ts
export async function deployMyModuleLibraries() {
const ValidationUtils = await ethers.deployContract('ValidationUtils');
await ValidationUtils.waitForDeployment();
const ProcessingUtils = await ethers.deployContract('ProcessingUtils');
await ProcessingUtils.waitForDeployment();
return {
ValidationUtils: await ValidationUtils.getAddress(),
ProcessingUtils: await ProcessingUtils.getAddress(),
};
}
// scripts/deploy.ts
const libs = await deployMyModuleLibraries();
const MyModule = await ethers.getContractFactory('MyModule', {
libraries: {
ValidationUtils: libs.ValidationUtils,
ProcessingUtils: libs.ProcessingUtils,
},
});Use the same libraries object for both deploy and upgrade of the implementation.
Upgrade Rules
- Redeploy all libraries when upgrading the main contract version
- Pass the new library addresses in
options.librariestoupgradeProxy - For upgrade tests, keep deprecated contract/library versions under
contracts/deprecated/V{N}/so you can deploy V(N-1) and upgrade to V(N)
Storage Safety
- Never change order, remove, or change types of existing storage variables
- Only append new fields at the end of storage structs
- This applies to both the storage-types library and the main contract
Library Style
- Use custom errors (e.g.,
error NonexistentItem(bytes32 id);) - Emit events in the library when the event is part of the module's API
- Use NatSpec (
@title,@dev) on each library and public/external functions - Keep imports minimal and directional (e.g.,
../../interfaces/,./StorageTypes.sol)
Quick Checklist for Adding a New Utils Library
1. Create contracts/<module>/libraries/<Name>Utils.sol with external functions 2. Access storage only via the module's storage-types library 3. Add to the module's library deployment script and include in the returned object 4. Add library name + address to the libraries map for getContractFactory 5. Import in the main contract and call NewUtils.functionName(...) 6. Do not add or change storage layout in the main contract -- keep new storage in the storage-types library, append only
---
Deployment
Simple (non-upgradeable) Deployment
import { ethers } from 'hardhat';
async function main() {
const [deployer] = await ethers.getSigners();
console.log('Deploying with:', deployer.address);
const MyToken = await ethers.deployContract('MyToken', [1_000_000]);
await MyToken.waitForDeployment();
console.log('MyToken deployed to:', await MyToken.getAddress());
}
main().catch(console.error);Proxy Deployment Helpers (scripts/helpers/upgrades.ts)
For upgradeable contracts, always use the proxy helpers. These deploy the implementation + proxy together and handle initialization.
Required dependencies:
npm install @openzeppelin/contracts@5.0.2 @openzeppelin/contracts-upgradeable@5.0.2 @openzeppelin/upgrades-coreKey functions:
import { deployProxy, upgradeProxy, deployProxyOnly, initializeProxy } from "./helpers/upgrades"
// Deploy proxy + implementation + initialize in one step
const contract = await deployProxy("MyContract", [upgraderAddr, [adminAddr]])
// Deploy proxy without initialization (for contracts needing multi-step init)
const proxyAddress = await deployProxyOnly("MyContract")
await initializeProxy(proxyAddress, "MyContract", [upgraderAddr, [adminAddr]])
// Upgrade existing proxy to new implementation
const upgraded = await upgradeProxy(
"MyContractV1", // previous version contract name
"MyContract", // new version contract name (latest = no suffix)
proxyAddress,
[reinitArg1], // args for initializeV{N}
{ version: 2 } // triggers initializeV2
)How deployProxy works internally: 1. Deploys the implementation contract 2. Deploys VeChainProxy pointing to the implementation 3. Encodes and calls the initialize (or initializeV{N}) function via delegatecall 4. Verifies the proxy's implementation address matches (via @openzeppelin/upgrades-core) 5. Returns a contract instance attached to the proxy address
How upgradeProxy works internally: 1. Deploys the new implementation contract 2. Calls upgradeToAndCall() on the existing proxy (via the previous version's ABI) 3. If args provided, encodes initializeV{N} and passes as calldata 4. Verifies the new implementation address 5. Returns a contract instance with the new ABI attached to the proxy
Deploy Commands
# Local (Thor Solo)
npx hardhat run scripts/deploy/deploy.ts --network vechain_solo
# Testnet
npx hardhat run scripts/deploy/deploy.ts --network vechain_testnet
# Mainnet
npx hardhat run scripts/deploy/deploy.ts --network vechain_mainnetDeploy with Fee Delegation
Add a delegate config to the network. See the vechain-core skill (references/fee-delegation.md) for full setup.
---
Upgrade Infrastructure
Version Pattern
When upgrading a contract to a new version:
1. Copy current contract to contracts/deprecated/V{N}/ before modifying 2. Increment version() return value in the new version 3. Add initializeV{N} with reinitializer(N) for any new state setup 4. Create upgrade script in scripts/upgrade/upgrades/{contract}/{contract}-v{N}.ts 5. Register in scripts/upgrade/upgradesConfig.ts for CLI selection 6. Update scripts/deploy/deploy.ts with new deployment logic 7. Update test/helpers/deploy.ts to mirror deployment changes 8. Create upgrade test: test/{contract}/v{N}-upgrade.test.ts
Reinitializer Pattern
Use reinitializer(N) for upgrade initialization. The N must match the version number and can only be called once.
function initializeV2(address newParam) public reinitializer(2) {
MyContractStorage storage $ = _getMyContractStorage();
$.newField = newParam;
}
function initializeV3(uint256 threshold) public reinitializer(3) {
MyContractStorage storage $ = _getMyContractStorage();
$.threshold = threshold;
}The proxy helpers automatically find the right initializer: getInitializerData looks for initializeV{N} when version is specified, or initialize for V1.
Keeping Deprecated Versions
Deprecated versions in contracts/deprecated/V{N}/ enable upgrade tests that verify no storage corruption:
// Deploy previous version
const v1 = await deployProxy("MyContractV1", [upgrader, [admin]])
await v1.setValue(42)
// Upgrade to new version
const v2 = await upgradeProxy("MyContractV1", "MyContract", await v1.getAddress(), [newParam], { version: 2 })
// Verify state preserved
expect(await v2.value()).to.equal(42)
// Verify new functionality
expect(await v2.version()).to.equal("2")CRITICAL: Upgrade Test Version Mismatch
When writing upgrade tests, always use explicit version names for intermediate upgrades:
// WRONG: "MyContract" refers to the LATEST version (now V5), not V2
const v2 = await upgradeProxy("MyContractV1", "MyContract", ...) // Skips V2/V3/V4!
// CORRECT: explicit version for intermediate upgrades
const v2 = await upgradeProxy("MyContractV1", "MyContractV2", ...)
const v3 = await upgradeProxy("MyContractV2", "MyContractV3", ...)
// Only use bare name when upgrading TO the latest version
const latest = await upgradeProxy("MyContractV4", "MyContract", ...)CLI Upgrade System
Interactive CLI for selecting and running upgrades:
npx hardhat run scripts/upgrade/select-and-upgrade.ts --network vechain_soloReads from upgradesConfig.ts — a registry mapping contract names to available versions:
// scripts/upgrade/upgradesConfig.ts
export const upgradeConfig: Record<string, UpgradeContract> = {
MyContract: {
name: "my-contract",
configAddressField: "myContract", // key in config.contracts
versions: ["v2", "v3"],
descriptions: {
v2: "Add threshold configuration",
v3: "Add batch processing support",
},
},
}Upgrade Script Template
// scripts/upgrade/upgrades/my-contract/my-contract-v2.ts
import { getConfig } from "@repo/config"
import { upgradeProxy } from "../../helpers/upgrades"
import { ethers } from "hardhat"
async function main() {
const config = getConfig()
const contract = await ethers.getContractAt("MyContract", config.contracts.myContract)
console.log("Current version:", await contract.version())
const upgraded = await upgradeProxy(
"MyContractV1",
"MyContract",
config.contracts.myContract,
[newParam], // reinitializer args
{ version: 2 },
)
const newVersion = await upgraded.version()
if (parseInt(newVersion) !== 2) throw new Error("Upgrade failed")
console.log("Upgraded to version:", newVersion)
}
main().catch(console.error)Deploy + Test Sync
When upgrading contracts, always update both: 1. `scripts/deploy/deploy.ts` — production deployment (auto-runs via yarn dev if contracts not deployed) 2. `test/helpers/deploy.ts` — test fixture deployment (used by all contract tests)
These files must stay aligned — changes to one usually require changes to the other.
Adding a New Contract Checklist
When adding a completely new contract: 1. Create the contract following the BaseUpgradeable template 2. Add to scripts/deploy/deploy.ts 3. Add to test/helpers/deploy.ts 4. Update packages/config — add address field to AppConfig type 5. Update packages/config/scripts/generateMockLocalConfig.mjs — add mock address 6. Update scripts/checkContractsDeployment.ts — add deployment check
---
Code Style
NatSpec Documentation
All public/external functions require NatSpec:
/// @notice Brief description of what the function does
/// @dev Implementation details, edge cases, or important notes
/// @param paramName Description of the parameter
/// @return Description of the return value
function myFunction(uint256 paramName) external returns (uint256) {Custom Errors
Use custom errors instead of require strings (more gas efficient):
error InvalidAmount(uint256 provided, uint256 minimum);
error UnauthorizedUser(address user);
function deposit(uint256 amount) external {
if (amount < MIN_AMOUNT) revert InvalidAmount(amount, MIN_AMOUNT);
}Events
Emit events for all state changes:
event ValueUpdated(address indexed user, uint256 oldValue, uint256 newValue);
function setValue(uint256 _value) external {
uint256 old = _getStorage().value;
_getStorage().value = _value;
emit ValueUpdated(msg.sender, old, _value);
}---
Slither Static Analysis
Slither can be run in CI on contract changes. Configure false positive suppressions:
{
"suppressions": [
{
"check": "reentrancy-eth",
"file": "contracts/MyContract.sol",
"function": "myFunction(uint256)",
"reason": "CEI pattern followed, nonReentrant guard present"
}
]
}Contract Interaction with SDK
Read from contract
import { ThorClient } from '@vechain/sdk-network';
const thorClient = ThorClient.at('https://testnet.vechain.org');
const contract = thorClient.contracts.load(contractAddress, contractABI);
const balance = await contract.read.balanceOf(someAddress);
const name = await contract.read.name();Write to contract (backend/scripts)
import { ThorClient, VeChainProvider, ProviderInternalBaseWallet } from '@vechain/sdk-network';
const thorClient = ThorClient.at('https://testnet.vechain.org');
const wallet = new ProviderInternalBaseWallet([
{ privateKey: HexUInt.of(privateKey).bytes, address: senderAddress }
]);
const provider = new VeChainProvider(thorClient, wallet);
const signer = await provider.getSigner(senderAddress);
const contract = thorClient.contracts.load(contractAddress, contractABI, signer);
const tx = await contract.transact.transfer(recipientAddress, amount);Batch reads with multi-clause
const results = await thorClient.contracts.executeMultipleClausesCall([
contract.clause.totalSupply(),
contract.clause.name(),
contract.clause.symbol(),
contract.clause.decimals()
]);VeChain-Specific Considerations
Dual Token Model
- VET: Value transfer token. Transfer with
Clause.transferVET(). - VTHO: Gas token. Generated by staking VET. Transfer with
Clause.transferVTHOToken(). - VTHO contract address:
0x0000000000000000000000000000456E65726779
Built-in Contracts
VeChainThor has several built-in contracts at genesis:
- Authority:
0x0000000000000000000000417574686f72697479- Authority node management - Energy (VTHO):
0x0000000000000000000000000000456E65726779- VTHO token - Params:
0x0000000000000000000000000000506172616d73- Network parameters - Executor:
0x0000000000000000000000004578656375746f72- On-chain governance - Extension:
0x0000000000000000000000457874656e73696f6e- Extended functionality
Block Time
VeChainThor produces blocks every ~10 seconds (vs Ethereum's ~12 seconds). With Thor Solo --on-demand, blocks are produced only when transactions are pending.
Security Best Practices
Input Validation
- Use
requirestatements for all external input validation - Use OpenZeppelin's
ReentrancyGuardfor functions that transfer value - Validate addresses are non-zero with
require(addr != address(0))
Access Control
- Prefer OpenZeppelin's
AccessControlover custom role management - Use
Ownable2StepoverOwnablefor safer ownership transfers - Never use
tx.originfor authorization
Common Gotchas
- EVM version: Always use
paris. Newer opcodes will cause deployment failures. - Gas estimation: Use
gas: 'auto'in Hardhat config for VeChain's gas model. - Block timestamps: VeChain has ~10s block time; do not rely on sub-second precision.
- Chain ID: Mainnet is
0x4a(74), testnet is0x27(39).
Testing Strategy (Hardhat / Thor Solo)
Testing Pyramid
1. Unit tests (fast): Hardhat with in-memory EVM or Thor Solo 2. Integration tests (realistic state): Thor Solo with on-demand blocks 3. Network smoke tests: testnet/mainnet as needed
Hardhat Testing with VeChain
When to Use Hardhat Tests
- Fast execution with familiar testing patterns
- ethers.js-compatible contract interaction
- Built-in assertion helpers (Chai matchers)
- Snapshot/revert for state isolation
Setup
npm install --save-dev hardhat @vechain/sdk-hardhat-plugin
npm install --save-dev @nomicfoundation/hardhat-chai-matchers chai
npm install --save-dev @typechain/hardhat typechain @typechain/ethers-v6Basic Test Structure
import { expect } from 'chai';
import { ethers } from 'hardhat';
describe('MyToken', function () {
let token: any;
let owner: any;
let addr1: any;
beforeEach(async function () {
[owner, addr1] = await ethers.getSigners();
token = await ethers.deployContract('MyToken', [1_000_000]);
await token.waitForDeployment();
});
describe('Deployment', function () {
it('should set the correct total supply', async function () {
const totalSupply = await token.totalSupply();
expect(totalSupply).to.equal(ethers.parseEther('1000000'));
});
it('should assign total supply to owner', async function () {
const ownerBalance = await token.balanceOf(owner.address);
expect(ownerBalance).to.equal(await token.totalSupply());
});
});
describe('Transfers', function () {
it('should transfer tokens between accounts', async function () {
const amount = ethers.parseEther('100');
await token.transfer(addr1.address, amount);
expect(await token.balanceOf(addr1.address)).to.equal(amount);
});
it('should fail if sender has insufficient balance', async function () {
await expect(
token.connect(addr1).transfer(owner.address, 1)
).to.be.reverted;
});
it('should emit Transfer event', async function () {
const amount = ethers.parseEther('100');
await expect(token.transfer(addr1.address, amount))
.to.emit(token, 'Transfer')
.withArgs(owner.address, addr1.address, amount);
});
});
});Run Tests
# Run against Thor Solo (requires running instance)
npx hardhat test --network vechain_solo
# Run with verbose output
npx hardhat test --network vechain_solo --verboseThor Solo
A local VeChainThor node for development and testing.
Docker Setup (Recommended)
# Start Thor Solo with on-demand block generation
docker run -d \
--name thor-solo \
-p 127.0.0.1:8669:8669 \
vechain/thor:latest solo \
--on-demand \
--persist \
--api-cors '*' \
--api-addr 0.0.0.0:8669Key Flags
| Flag | Description |
|---|---|
--on-demand | Generate blocks only when transactions are pending |
--persist | Store blockchain data to disk |
--api-addr value | API listening address (default: localhost:8669) |
--api-cors '*' | Allow all cross-origin requests |
--api-call-gas-limit | Limit contract call gas (default: 50,000,000) |
--verbosity value | Log verbosity 0-9 (default: 3) |
Pre-Funded Accounts
Thor Solo generates 10 pre-funded accounts from a built-in mnemonic:
denial kitchen pet squirrel other broom bar gas better priority spoil crossEach account has ample VET and VTHO for development. Never use this mnemonic for mainnet.
API Documentation
Once running, access interactive docs at:
http://127.0.0.1:8669/doc/stoplight-ui/http://127.0.0.1:8669/doc/swagger-ui/
Testing Patterns
Contract Deployment in Tests
async function deployFixture() {
const [owner, addr1, addr2] = await ethers.getSigners();
const Token = await ethers.getContractFactory('MyToken');
const token = await Token.deploy(1_000_000);
await token.waitForDeployment();
return { token, owner, addr1, addr2 };
}
describe('MyToken', function () {
it('should work', async function () {
const { token, owner } = await deployFixture();
// ...
});
});Testing Events
it('should emit the correct event', async function () {
await expect(contract.doSomething(42))
.to.emit(contract, 'SomethingDone')
.withArgs(owner.address, 42);
});Testing Reverts
it('should revert with custom error', async function () {
await expect(contract.withdraw(1000))
.to.be.revertedWithCustomError(contract, 'InsufficientBalance')
.withArgs(0, 1000);
});
it('should revert with message', async function () {
await expect(contract.onlyOwnerAction())
.to.be.revertedWith('Not authorized');
});Testing Access Control
it('should restrict admin functions', async function () {
await expect(
contract.connect(addr1).adminAction()
).to.be.revertedWithCustomError(contract, 'OwnableUnauthorizedAccount');
});Testing Multi-Clause Transactions
import { ThorClient } from '@vechain/sdk-network';
import { Clause, Transaction, Address, VET } from '@vechain/sdk-core';
it('should execute multi-clause transaction', async function () {
const thorClient = ThorClient.at('http://localhost:8669');
const clauses = [
Clause.transferVET(Address.of(addr1.address), VET.of(100)),
Clause.transferVET(Address.of(addr2.address), VET.of(200)),
];
const gasResult = await thorClient.gas.estimateGas(clauses, owner.address);
// Build, sign, send, verify receipt...
});Testing Fee Delegation
it('should execute with fee delegation', async function () {
const body = {
chainTag: 0xa4, // solo chain tag
blockRef: '0x...',
expiration: 32,
clauses,
gasPriceCoef: 0,
gas: estimatedGas,
dependsOn: null,
nonce: Date.now(),
reserved: { features: 1 } // Enable VIP-191
};
const signedTx = Transaction.of(body).signAsSenderAndGasPayer(
senderPrivateKey,
gasPayerPrivateKey
);
// Verify the sender paid no VTHO
});Testing with SDK Directly
For lower-level testing without Hardhat:
import { ThorClient } from '@vechain/sdk-network';
import { Clause, Transaction, Mnemonic, Address, VET, HexUInt } from '@vechain/sdk-core';
describe('SDK Direct Tests', () => {
let thorClient: ThorClient;
before(() => {
thorClient = ThorClient.at('http://localhost:8669');
});
it('should transfer VET', async () => {
const mnemonic = 'denial kitchen pet squirrel other broom bar gas better priority spoil cross'.split(' ');
const privateKey = Mnemonic.toPrivateKey(mnemonic);
const clauses = [
Clause.transferVET(
Address.of('0x7567d83b7b8d80addcb281a71d54fc7b3364ffed'),
VET.of(100)
)
];
const gasResult = await thorClient.gas.estimateGas(
clauses,
'0x...' // sender address
);
// Build and send transaction...
});
});Test Layout Recommendation
test/
├── unit/
│ ├── Token.test.ts # Unit tests
│ ├── Governance.test.ts
│ └── utils.ts # Shared test utilities
├── integration/
│ ├── FullFlow.test.ts # Multi-contract integration
│ └── FeeDelegate.test.ts # Fee delegation scenarios
└── fixtures/
└── deploy.ts # Shared deployment fixturesCI Guidance
jobs:
test:
runs-on: ubuntu-latest
services:
thor-solo:
image: vechain/thor:latest
ports:
- 8669:8669
options: >-
--health-cmd "curl -f http://localhost:8669/blocks/best || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
THOR_ARGS: "solo --on-demand --api-cors '*' --api-addr 0.0.0.0:8669"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx hardhat compile
- run: npx hardhat test --network vechain_soloBest Practices
- Use Thor Solo with
--on-demandfor fast test cycles - Use fixtures for consistent contract deployment
- Test both success and failure paths
- Test access control for every privileged function
- Test edge cases (zero amounts, max values, empty arrays)
- Verify events are emitted with correct parameters
- Profile gas usage to catch regressions
- Run integration tests in separate CI stage
- Use Docker for reproducible Thor Solo environments