Building a Client
This tutorial covers how to interact with an Evolve node as a client, including transaction format, submission methods, calldata encoding, and using the schema system for discovery.
Transaction Format
Evolve uses EIP-2718 typed transactions, providing Ethereum wallet compatibility while supporting custom transaction types.
Supported Transaction Types
| Type | Byte | Description |
|---|---|---|
| Legacy | 0x00 | Pre-EIP-2718 transactions (optional EIP-155 replay protection) |
| EIP-1559 | 0x02 | Fee market transactions with base fee + priority fee |
| Batch | 0x80 | Multi-message transactions (planned) |
| Sponsored | 0x81 | Gasless/meta-transactions (planned) |
| Scheduled | 0x82 | Delayed execution (planned) |
EIP-1559 Transaction Structure
The recommended transaction type for new integrations:
TxEip1559 {
chain_id: u64, // Network chain ID
nonce: u64, // Sender's transaction count
max_priority_fee_per_gas: u128, // Tip for block producers
max_fee_per_gas: u128, // Maximum total fee
gas_limit: u64, // Gas budget
to: TxKind, // Recipient (Call or Create)
value: U256, // Native token transfer amount
input: Bytes, // Calldata (function selector + args)
access_list: AccessList, // Storage access hints (optional)
}Address and AccountId Mapping
Evolve uses 128-bit AccountId internally, while Ethereum uses 160-bit addresses. The mapping is deterministic and reversible for contract accounts.
Address to AccountId
Takes the last 16 bytes of the 20-byte Ethereum address:
pub fn address_to_account_id(addr: Address) -> AccountId {
let bytes = addr.as_slice();
let mut id_bytes = [0u8; 16];
id_bytes.copy_from_slice(&bytes[4..]); // Skip first 4 bytes
AccountId::new(u128::from_be_bytes(id_bytes))
}AccountId to Address
Pads with 4 zero bytes at the start:
pub fn account_id_to_address(id: AccountId) -> Address {
let id_bytes = id.as_bytes();
let mut addr_bytes = [0u8; 20];
addr_bytes[4..20].copy_from_slice(&id_bytes[..16]);
Address::from_slice(&addr_bytes)
}Important Notes
- Round-trip is perfect:
address_to_account_id(account_id_to_address(id)) == id - EOA addresses from public keys have random first 4 bytes; these are lost in the mapping
- Contract addresses derived from AccountIds round-trip perfectly
- Two Ethereum addresses differing only in the first 4 bytes map to the same AccountId (collision)
Calldata Encoding
Evolve uses a function selector + Borsh-encoded arguments format, different from Ethereum's ABI encoding.
Function Selector
The selector is the first 4 bytes of the keccak256 hash of the function name:
fn compute_selector(fn_name: &str) -> [u8; 4] {
let hash = keccak256(fn_name.as_bytes());
[hash[0], hash[1], hash[2], hash[3]]
}
// Example
let selector = compute_selector("transfer"); // e.g., [0x83, 0xf7, ...]Argument Encoding
Arguments are encoded using Borsh serialization, not Ethereum ABI:
use borsh::BorshSerialize;
// For transfer(to: AccountId, amount: u128)
let args = borsh::to_vec(&(recipient_account_id, amount))?;Complete Calldata
let selector = compute_selector("transfer");
let args = borsh::to_vec(&(bob_account_id, 100u128))?;
let mut calldata = Vec::with_capacity(4 + args.len());
calldata.extend_from_slice(&selector);
calldata.extend_from_slice(&args);Why Borsh Instead of ABI?
- Compact - Borsh produces smaller payloads than ABI encoding
- Deterministic - Consistent encoding across languages
- Type-safe - Strong type guarantees with no padding ambiguity
- Rust-native - First-class support in the Rust ecosystem
Submitting Transactions
JSON-RPC (Recommended)
Submit via the standard eth_sendRawTransaction endpoint:
curl -X POST http://localhost:8545 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "eth_sendRawTransaction",
"params": ["0x02f8...encoded_tx..."],
"id": 1
}'Response:
{
"jsonrpc": "2.0",
"id": 1,
"result": "0x...transaction_hash..."
}Direct Mempool (Testing/Embedded)
use evolve_mempool::new_shared_mempool;
let mempool = new_shared_mempool(chain_id);
let tx_hash = {
let mut pool = mempool.write().await;
pool.add_raw(&raw_tx_bytes)?
};Useful RPC Methods
| Method | Description |
|---|---|
eth_sendRawTransaction | Submit signed transaction |
eth_getTransactionCount | Get nonce for address |
eth_getTransactionReceipt | Get execution result |
eth_call | Simulate call without state change |
eth_estimateGas | Estimate gas for transaction |
eth_chainId | Get network chain ID |
Using the Schema System
The schema system enables runtime introspection of account modules, allowing clients to discover available functions without hardcoded knowledge.
See Schema Introspection for full documentation.
Schema RPC Endpoints
# List all registered modules
curl -X POST http://localhost:8545 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"evolve_listModules","params":[],"id":1}'
# Get schema for a specific module
curl -X POST http://localhost:8545 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"evolve_getModuleSchema","params":["Token"],"id":1}'Function ID vs Calldata Selector
| Concept | Hash | Size | Purpose |
|---|---|---|---|
function_id | SHA-256 | 8 bytes (u64) | Internal message dispatch |
| Calldata selector | keccak256 | 4 bytes | Transaction encoding |
The function_id in schemas is for internal use. When building Ethereum-compatible transactions, always compute the keccak256 selector from the function name.
Complete Examples
Rust
use alloy_consensus::{SignableTransaction, TxEip1559};
use alloy_primitives::{Bytes, PrimitiveSignature, TxKind, U256};
use k256::ecdsa::SigningKey;
use tiny_keccak::{Hasher, Keccak};
fn keccak256(data: &[u8]) -> [u8; 32] {
let mut keccak = Keccak::v256();
let mut output = [0u8; 32];
keccak.update(data);
keccak.finalize(&mut output);
output
}
fn compute_selector(fn_name: &str) -> [u8; 4] {
let hash = keccak256(fn_name.as_bytes());
[hash[0], hash[1], hash[2], hash[3]]
}
async fn transfer_tokens(
signing_key: &SigningKey,
chain_id: u64,
nonce: u64,
token_address: Address,
recipient: AccountId,
amount: u128,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
// Build calldata
let selector = compute_selector("transfer");
let args = borsh::to_vec(&(recipient, amount))?;
let mut calldata = Vec::with_capacity(4 + args.len());
calldata.extend_from_slice(&selector);
calldata.extend_from_slice(&args);
// Create transaction
let tx = TxEip1559 {
chain_id,
nonce,
max_priority_fee_per_gas: 1_000_000_000, // 1 gwei
max_fee_per_gas: 20_000_000_000, // 20 gwei
gas_limit: 100_000,
to: TxKind::Call(token_address),
value: U256::ZERO,
input: Bytes::from(calldata),
access_list: Default::default(),
};
// Sign and encode
let signature = sign_hash(signing_key, tx.signature_hash());
let signed = tx.into_signed(signature);
let mut encoded = vec![0x02];
signed.rlp_encode(&mut encoded);
Ok(encoded)
}TypeScript
import { ethers } from "ethers";
function computeSelector(fnName: string): Uint8Array {
const hash = ethers.keccak256(ethers.toUtf8Bytes(fnName));
return ethers.getBytes(hash).slice(0, 4);
}
function accountIdToAddress(accountId: bigint): string {
const bytes = new Uint8Array(20);
const idBytes = new Uint8Array(16);
for (let i = 15; i >= 0; i--) {
idBytes[i] = Number(accountId & 0xffn);
accountId >>= 8n;
}
bytes.set(idBytes, 4);
return ethers.hexlify(bytes);
}
async function sendTransfer(
wallet: ethers.Wallet,
tokenAddress: string,
recipientAccountId: bigint,
amount: bigint
) {
// Build calldata
const selector = computeSelector("transfer");
// Encode recipient AccountId as 16 bytes (big-endian)
const recipientBytes = new Uint8Array(16);
let temp = recipientAccountId;
for (let i = 15; i >= 0; i--) {
recipientBytes[i] = Number(temp & 0xffn);
temp >>= 8n;
}
// Borsh encode: AccountId (16 bytes) + amount (16 bytes little-endian)
const args = new Uint8Array(32);
args.set(recipientBytes, 0);
let amountTemp = amount;
for (let i = 0; i < 16; i++) {
args[16 + i] = Number(amountTemp & 0xffn);
amountTemp >>= 8n;
}
const calldata = new Uint8Array(4 + args.length);
calldata.set(selector, 0);
calldata.set(args, 4);
// Send transaction
const tx = await wallet.sendTransaction({
to: tokenAddress,
data: ethers.hexlify(calldata),
gasLimit: 100000,
});
const receipt = await tx.wait();
return tx.hash;
}Python
from web3 import Web3
def keccak256(data: bytes) -> bytes:
return Web3.keccak(data)
def compute_selector(fn_name: str) -> bytes:
return keccak256(fn_name.encode())[:4]
def account_id_to_address(account_id: int) -> str:
id_bytes = account_id.to_bytes(16, 'big')
addr_bytes = bytes(4) + id_bytes
return Web3.to_checksum_address(addr_bytes.hex())
def encode_transfer_args(recipient_id: int, amount: int) -> bytes:
# AccountId: 16 bytes big-endian
recipient_bytes = recipient_id.to_bytes(16, 'big')
# u128 amount: 16 bytes little-endian (Borsh)
amount_bytes = amount.to_bytes(16, 'little')
return recipient_bytes + amount_bytes
def send_transfer(w3: Web3, private_key: str, token_address: str,
recipient_id: int, amount: int) -> str:
account = w3.eth.account.from_key(private_key)
selector = compute_selector("transfer")
args = encode_transfer_args(recipient_id, amount)
calldata = selector + args
tx = {
'to': token_address,
'data': calldata,
'gas': 100000,
'maxFeePerGas': w3.to_wei(20, 'gwei'),
'maxPriorityFeePerGas': w3.to_wei(1, 'gwei'),
'nonce': w3.eth.get_transaction_count(account.address),
'chainId': w3.eth.chain_id,
}
signed = account.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
return tx_hash.hex()Key Takeaways
- Transaction Format - Use EIP-1559 transactions; standard Ethereum signing works
- Address Mapping - AccountId uses last 16 bytes of address; use
account_id_to_address()to target contracts - Calldata -
selector (4 bytes) + Borsh-encoded args(not ABI encoding) - Submission - Use
eth_sendRawTransactionvia JSON-RPC; compatible with ethers.js, web3.py, etc. - Schema Discovery - Use
evolve_*RPC methods to introspect modules without hardcoded ABIs
Troubleshooting
Transaction Rejected
- Invalid chain ID - Ensure
chain_idmatches the network - Nonce too low - Fetch current nonce with
eth_getTransactionCount - Insufficient gas - Increase
gas_limitor useeth_estimateGas - Invalid signature - Verify signing key matches sender address
Calldata Encoding Errors
- Wrong selector - Verify function name spelling; selectors are case-sensitive
- Borsh encoding mismatch - Ensure argument types match schema exactly
- Endianness - Borsh uses little-endian for integers; AccountId is big-endian bytes
Schema Not Found
- Module not registered - Check module identifier with
evolve_listModules - Case sensitivity - Module identifiers are case-sensitive