19-3: sol! 매크로 - Solidity ABI를 Rust 타입으로
sol! 매크로란
sol!은 Alloy가 제공하는 절차적 매크로(procedural macro)다. Solidity 코드나 ABI JSON을 파싱하여 컴파일 타임에 대응하는 Rust 타입을 자동 생성한다.
이것이 왜 강력한가? 런타임에 ABI를 해석하는 대신, 컴파일 타임에 타입을 확인한다. 잘못된 함수 인자를 전달하면 컴파일 에러가 발생한다. Node.js의 ethers.js는 런타임까지 이런 오류를 발견하지 못한다.
ethers.js 방식:
contract.transfer(address, amount) ← 런타임에 ABI 인코딩, 타입 체크 없음
sol! 매크로 방식:
contract.transfer(address, amount) ← 컴파일 타임에 타입 검증
// address 자리에 U256을 넣으면 컴파일 에러!
인라인 Solidity 코드에서 타입 생성
가장 일반적인 방법이다. Solidity 인터페이스나 컨트랙트 코드를 그대로 Rust 파일에 작성한다.
기본 사용법
use alloy::sol;
// 단순 인터페이스
sol! {
interface ICounter {
function count() external view returns (uint256);
function increment() external;
function decrement() external;
function reset(uint256 value) external;
}
}
// 컨트랙트 (상태 변수 포함)
sol! {
contract Counter {
uint256 public count;
address public owner;
constructor(address _owner);
function increment() external;
function getCount() external view returns (uint256);
event Incremented(address indexed by, uint256 newCount);
error NotOwner(address caller);
}
}
생성되는 타입들
sol! 매크로는 다음 Rust 타입들을 자동 생성한다:
sol! {
contract Counter {
function increment() external;
function getCount() external view returns (uint256);
function reset(uint256 value) external;
event Incremented(address indexed by, uint256 newCount);
}
}
// 위 sol! 매크로가 생성하는 것들:
// 1. 함수 호출 타입 (각 함수마다)
Counter::incrementCall // 인자 없음
Counter::getCountCall // 인자 없음
Counter::resetCall { value: U256 } // 인자 있음
// 2. 함수 반환 타입
Counter::getCountReturn { _0: U256 }
// 3. 이벤트 타입
Counter::Incremented { by: Address, newCount: U256 }
// 4. 에러 타입
Counter::NotOwner { caller: Address }
// 5. 컨트랙트 인스턴스 (with #[sol(rpc)] 속성 시)
Counter::new(address, provider) // ContractInstance 반환
#[sol(rpc)] 속성
RPC 호출을 위한 메서드를 생성하려면 #[sol(rpc)] 속성이 필요하다:
use alloy::sol;
sol! {
#[sol(rpc)] // 이 속성이 있어야 .call(), .send() 메서드가 생성됨
contract Counter {
uint256 public count;
function increment() external;
function getCount() external view returns (uint256);
event Incremented(address indexed by, uint256 newCount);
}
}
// #[sol(rpc)]가 있으면:
let contract = Counter::new(address, &provider);
let count = contract.getCount().call().await?; // 가능
let tx = contract.increment().send().await?; // 가능
// #[sol(rpc)]가 없으면 ABI 인코딩만 가능:
let call_data = Counter::incrementCall {}.abi_encode(); // 가능
// contract.increment().call() // 불가능
복잡한 타입 처리
Solidity의 구조체, 배열, 매핑을 처리하는 방법:
use alloy::sol;
sol! {
#[sol(rpc)]
contract TraceRecord {
struct Record {
bytes32 dataHash;
uint256 timestamp;
address recorder;
bool verified;
}
mapping(string => Record) public records;
string[] public recordIds;
function addRecord(string calldata id, bytes32 hash) external;
function getRecord(string calldata id) external view returns (Record memory);
function verifyRecord(string calldata id, bytes32 hash) external view returns (bool);
function getAllIds() external view returns (string[] memory);
event RecordAdded(
string indexed id,
bytes32 hash,
address indexed recorder,
uint256 timestamp
);
error RecordNotFound(string id);
error DuplicateRecord(string id);
}
}
// 생성된 타입 사용
async fn use_trace_record(
contract: &TraceRecord::TraceRecordInstance<_, _>,
) -> eyre::Result<()> {
// 구조체 반환값 처리
let record = contract.getRecord("batch-001".to_string()).call().await?;
// Record 구조체 필드 접근
println!("해시: {:?}", record._0.dataHash);
println!("타임스탬프: {}", record._0.timestamp);
println!("기록자: {}", record._0.recorder);
println!("검증됨: {}", record._0.verified);
Ok(())
}
JSON ABI 파일에서 타입 생성
컨트랙트 ABI를 JSON 파일로 가지고 있을 때 사용한다. Foundry로 컴파일하면 out/ 디렉토리에 JSON이 생성된다.
ABI JSON 파일 구조
Foundry가 생성하는 ABI JSON 파일 (out/TraceRecord.sol/TraceRecord.json):
{
"abi": [
{
"type": "function",
"name": "addRecord",
"inputs": [
{"name": "id", "type": "string"},
{"name": "hash", "type": "bytes32"}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "event",
"name": "RecordAdded",
"inputs": [
{"name": "id", "type": "string", "indexed": true},
{"name": "hash", "type": "bytes32", "indexed": false}
]
}
],
"bytecode": {
"object": "0x608060..."
}
}
JSON ABI 파일에서 로드
use alloy::sol;
// 방법 1: ABI만 있는 JSON
sol!(
#[sol(rpc)]
TraceRecord,
"abi/TraceRecord.json" // ABI 배열만 있는 파일
);
// 방법 2: Foundry 출력 파일 (ABI + bytecode 포함)
sol!(
#[sol(rpc)]
TraceRecord,
"out/TraceRecord.sol/TraceRecord.json"
);
// 방법 3: 환경에 따라 경로 지정
sol!(
#[sol(rpc)]
MyContract,
concat!(env!("CARGO_MANIFEST_DIR"), "/abi/MyContract.json")
);
Cargo.toml에 ABI 파일 경로 설정
# build.rs가 ABI 파일 변경 시 재컴파일하도록
[package.metadata]
abi-dir = "abi/"
# 또는 build.rs 작성
// build.rs
fn main() {
// ABI 파일이 바뀌면 재컴파일
println!("cargo:rerun-if-changed=abi/");
}
생성된 타입으로 컨트랙트 호출
실제로 생성된 타입을 어떻게 활용하는지 보여주는 상세 예제:
use alloy::{
primitives::{Address, FixedBytes, U256},
sol,
sol_types::SolEvent,
};
sol! {
#[sol(rpc)]
contract TraceRecord {
struct Record {
bytes32 dataHash;
uint256 timestamp;
address recorder;
}
function addRecord(string calldata id, bytes32 hash) external returns (uint256 recordIndex);
function getRecord(string calldata id) external view returns (Record memory);
function recordExists(string calldata id) external view returns (bool);
event RecordAdded(string indexed id, bytes32 hash, uint256 timestamp);
error RecordAlreadyExists(string id);
}
}
// ABI 인코딩 직접 사용 (Provider 없이)
fn encode_call_data() {
// 함수 호출 데이터 인코딩
let call = TraceRecord::addRecordCall {
id: "batch-001".to_string(),
hash: FixedBytes::from([0x42u8; 32]),
};
use alloy::sol_types::SolCall;
let encoded: Vec<u8> = call.abi_encode();
println!("인코딩된 calldata: 0x{}", hex::encode(&encoded));
// 반환값 디코딩
let return_data: Vec<u8> = vec![/* raw bytes from node */];
// let decoded = TraceRecord::addRecordReturn::abi_decode(&return_data, true)?;
}
// 이벤트 로그 파싱
fn parse_event_log(log: &alloy::rpc::types::Log) -> Option<TraceRecord::RecordAdded> {
use alloy::sol_types::SolEvent;
TraceRecord::RecordAdded::decode_log(log.inner.as_ref(), true).ok()
}
이벤트 필터링과 로그 파싱
이벤트(Event)는 Solidity에서 emit으로 발생시키는 로그다. 블록체인에 영구적으로 저장되며 Rust에서 필터링하여 읽을 수 있다.
과거 이벤트 조회
use alloy::{
primitives::Address,
providers::{Provider, ProviderBuilder},
rpc::types::{Filter, FilterBlockOption},
sol,
sol_types::SolEvent,
};
sol! {
#[sol(rpc)]
contract ERC20Token {
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
}
async fn get_past_transfer_events(
provider: &impl Provider,
token_address: Address,
from_block: u64,
) -> eyre::Result<Vec<ERC20Token::Transfer>> {
// 이벤트 필터 구성
let filter = Filter::new()
.address(token_address)
.event_signature(ERC20Token::Transfer::SIGNATURE_HASH)
.from_block(from_block)
.to_block(alloy::rpc::types::BlockNumberOrTag::Latest);
// 로그 조회
let logs = provider.get_logs(&filter).await?;
println!("Transfer 이벤트 {}개 발견", logs.len());
// 로그 파싱
let mut events = Vec::new();
for log in logs {
match ERC20Token::Transfer::decode_log(log.inner.as_ref(), true) {
Ok(transfer) => {
println!(
"Transfer: {} → {} : {}",
transfer.from, transfer.to, transfer.value
);
events.push(transfer);
}
Err(e) => {
eprintln!("로그 파싱 실패: {}", e);
}
}
}
Ok(events)
}
특정 주소가 관련된 이벤트만 필터링
async fn get_transfers_for_address(
provider: &impl Provider,
token_address: Address,
user_address: Address,
) -> eyre::Result<()> {
use alloy::primitives::B256;
// indexed 파라미터로 필터링
// Transfer(address indexed from, address indexed to, uint256 value)
// from이 user_address인 이벤트만
let topic1: B256 = user_address.into_word(); // from 필드
let filter = Filter::new()
.address(token_address)
.event_signature(ERC20Token::Transfer::SIGNATURE_HASH)
.topic1(topic1); // indexed 첫 번째 파라미터 (from)
let outgoing_logs = provider.get_logs(&filter).await?;
println!("발신 Transfer: {}개", outgoing_logs.len());
// to가 user_address인 이벤트만
let topic2: B256 = user_address.into_word(); // to 필드
let filter_incoming = Filter::new()
.address(token_address)
.event_signature(ERC20Token::Transfer::SIGNATURE_HASH)
.topic2(topic2); // indexed 두 번째 파라미터 (to)
let incoming_logs = provider.get_logs(&filter_incoming).await?;
println!("수신 Transfer: {}개", incoming_logs.len());
Ok(())
}
WebSocket으로 실시간 이벤트 구독
use alloy::providers::{Provider, ProviderBuilder};
use alloy::transports::ws::WsConnect;
use futures_util::StreamExt;
async fn subscribe_to_events(token_address: Address) -> eyre::Result<()> {
let ws = WsConnect::new("ws://localhost:8546");
let provider = ProviderBuilder::new().on_ws(ws).await?;
let filter = Filter::new()
.address(token_address)
.event_signature(ERC20Token::Transfer::SIGNATURE_HASH);
// 실시간 로그 구독
let subscription = provider.subscribe_logs(&filter).await?;
let mut stream = subscription.into_stream();
println!("Transfer 이벤트 구독 시작...");
while let Some(log) = stream.next().await {
if let Ok(transfer) = ERC20Token::Transfer::decode_log(log.inner.as_ref(), true) {
println!(
"새 Transfer: {} → {} : {}",
transfer.from, transfer.to, transfer.value
);
}
}
Ok(())
}
전체 예제: ERC-20 컨트랙트와 상호작용하는 Rust 클라이언트
use alloy::{
network::EthereumWallet,
primitives::{address, Address, U256},
providers::{Provider, ProviderBuilder},
rpc::types::Filter,
signers::local::PrivateKeySigner,
sol,
sol_types::SolEvent,
};
use eyre::Result;
// 전체 ERC-20 ABI 정의
sol! {
#[sol(rpc)]
contract ERC20 {
// 상태 변수 (자동으로 getter 생성됨)
string public name;
string public symbol;
uint8 public decimals;
uint256 public totalSupply;
// 조회 함수
function balanceOf(address account) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
// 상태 변경 함수
function transfer(address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
// 이벤트
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
// 에러
error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed);
error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed);
}
}
pub struct Erc20Client {
contract: ERC20::ERC20Instance<
alloy::transports::http::Http<reqwest::Client>,
alloy::providers::fillers::FillProvider<
alloy::providers::fillers::JoinFill<
alloy::providers::Identity,
alloy::providers::fillers::JoinFill<
alloy::providers::fillers::GasFiller,
alloy::providers::fillers::JoinFill<
alloy::providers::fillers::BlobGasFiller,
alloy::providers::fillers::JoinFill<
alloy::providers::fillers::NonceFiller,
alloy::providers::fillers::ChainIdFiller,
>,
>,
>,
>,
alloy::providers::fillers::WalletFiller<EthereumWallet>,
alloy::network::Ethereum,
>,
alloy::network::Ethereum,
>,
token_address: Address,
}
// 타입이 너무 복잡할 때는 Box<dyn Provider>나 Arc<dyn Provider>를 사용하는 것도 방법
// 실제 코드에서는 제네릭으로 처리하는 경우가 많음
/// 더 실용적인 구조 - 제네릭 사용
pub struct SimpleErc20Client<P: Provider> {
provider: P,
token_address: Address,
}
impl<P: Provider + Clone> SimpleErc20Client<P> {
pub fn new(provider: P, token_address: Address) -> Self {
Self { provider, token_address }
}
fn contract(&self) -> ERC20::ERC20Instance<P::Transport, &P, alloy::network::Ethereum>
where
P::Transport: Clone,
{
ERC20::new(self.token_address, &self.provider)
}
/// 토큰 기본 정보 조회
pub async fn get_info(&self) -> Result<TokenInfo> where P::Transport: Clone {
let contract = self.contract();
let name = contract.name().call().await?.name;
let symbol = contract.symbol().call().await?.symbol;
let decimals = contract.decimals().call().await?.decimals;
let total_supply = contract.totalSupply().call().await?.totalSupply;
Ok(TokenInfo { name, symbol, decimals, total_supply })
}
/// 잔액 조회
pub async fn balance_of(&self, account: Address) -> Result<U256>
where P::Transport: Clone {
let result = self.contract().balanceOf(account).call().await?;
Ok(result._0)
}
/// 전송
pub async fn transfer(&self, to: Address, amount: U256) -> Result<alloy::primitives::B256>
where P::Transport: Clone {
let pending = self.contract()
.transfer(to, amount)
.send()
.await?;
let tx_hash = *pending.tx_hash();
let receipt = pending.get_receipt().await?;
if !receipt.status() {
return Err(eyre::eyre!("전송 실패 (revert)"));
}
Ok(tx_hash)
}
/// 과거 Transfer 이벤트 조회
pub async fn get_transfer_history(
&self,
from_block: u64,
) -> Result<Vec<TransferEvent>> where P::Transport: Clone {
let filter = Filter::new()
.address(self.token_address)
.event_signature(ERC20::Transfer::SIGNATURE_HASH)
.from_block(from_block);
let logs = self.provider.get_logs(&filter).await?;
let mut transfers = Vec::new();
for log in logs {
if let Ok(event) = ERC20::Transfer::decode_log(log.inner.as_ref(), true) {
transfers.push(TransferEvent {
from: event.from,
to: event.to,
value: event.value,
block_number: log.block_number.unwrap_or(0),
tx_hash: log.transaction_hash.unwrap_or_default(),
});
}
}
Ok(transfers)
}
}
#[derive(Debug)]
pub struct TokenInfo {
pub name: String,
pub symbol: String,
pub decimals: u8,
pub total_supply: U256,
}
#[derive(Debug)]
pub struct TransferEvent {
pub from: Address,
pub to: Address,
pub value: U256,
pub block_number: u64,
pub tx_hash: alloy::primitives::B256,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter("info")
.init();
// 서명자 설정 (쓰기 작업용)
let private_key = std::env::var("PRIVATE_KEY")
.unwrap_or_else(|_| {
// 개발용 Anvil 기본 키
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80".to_string()
});
let signer: PrivateKeySigner = private_key.parse()?;
let my_address = signer.address();
let wallet = EthereumWallet::from(signer);
let provider = ProviderBuilder::new()
.with_recommended_fillers()
.wallet(wallet)
.on_http("http://localhost:8545".parse()?);
// 토큰 주소 (로컬 Anvil에 배포된 테스트 토큰 가정)
let token_address: Address = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512".parse()?;
let client = SimpleErc20Client::new(provider, token_address);
// 1. 토큰 정보 조회
match client.get_info().await {
Ok(info) => {
println!("=== 토큰 정보 ===");
println!("이름: {}", info.name);
println!("심볼: {}", info.symbol);
println!("소수점: {}", info.decimals);
println!("총 공급량: {}", info.total_supply);
}
Err(e) => eprintln!("토큰 정보 조회 실패: {}", e),
}
// 2. 잔액 조회
match client.balance_of(my_address).await {
Ok(balance) => println!("\n내 잔액: {}", balance),
Err(e) => eprintln!("잔액 조회 실패: {}", e),
}
// 3. 전송
let recipient: Address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".parse()?;
let amount = U256::from(1_000u64);
println!("\n{} 토큰 전송 중...", amount);
match client.transfer(recipient, amount).await {
Ok(tx_hash) => println!("전송 성공: {:?}", tx_hash),
Err(e) => eprintln!("전송 실패: {}", e),
}
// 4. 이벤트 이력 조회
println!("\n=== Transfer 이벤트 이력 ===");
match client.get_transfer_history(0).await {
Ok(events) => {
for event in events {
println!(
"블록 {}: {} → {} : {}",
event.block_number, event.from, event.to, event.value
);
}
}
Err(e) => eprintln!("이벤트 조회 실패: {}", e),
}
Ok(())
}
자주 발생하는 문제
1. 반환 타입 접근
// sol! 생성 타입의 반환값은 구조체임
let result = contract.balanceOf(addr).call().await?;
// result는 ERC20::balanceOfReturn { _0: U256 }
// 단일 반환값: ._0으로 접근
let balance: U256 = result._0;
// 여러 반환값:
// function getInfo() returns (string name, uint256 value)
// → result.name, result.value
2. bytes32 처리
use alloy::primitives::FixedBytes;
// bytes32는 FixedBytes<32>로 매핑됨
let hash: FixedBytes<32> = FixedBytes::from([0u8; 32]);
// &[u8; 32] 변환
let raw: [u8; 32] = *hash;
// Vec<u8>에서 변환
let data: Vec<u8> = vec![1, 2, 3]; // 반드시 32바이트여야 함
// let hash = FixedBytes::<32>::try_from(data.as_slice())?;
3. string 처리
// Solidity string → Rust String
let result = contract.getName().call().await?;
let name: String = result._0; // 이미 String
// Rust String → Solidity string (함수 인자)
let id = "batch-001".to_string();
contract.getRecord(id).call().await?;
// &str도 자동 변환됨
contract.getRecord("batch-001".to_string()).call().await?;
요약
sol! 매크로의 핵심:
- 인라인 Solidity: 소스에 직접 ABI 작성, 간단하고 명확
- JSON ABI 파일: Foundry 출력과 연동, 빌드 파이프라인에 통합 가능
#[sol(rpc)]: RPC 호출 메서드 생성 (없으면 ABI 인코딩만).call(): view 함수 → 즉시 결과.send(): 상태 변경 함수 → PendingTransaction- 이벤트:
Filter+decode_log()또는 WebSocket 구독
다음 장(20장)부터는 Hyperledger Besu와 프라이빗 체인을 다룬다.