approve/transferFrom — DeFi의 근간이 되는 메커니즘
Uniswap에서 토큰을 스왑할 때, Aave에서 토큰을 담보로 예치할 때, 스테이킹 컨트랙트에 토큰을 넣을 때 — 모든 DeFi 상호작용의 첫 단계는 approve다. 이 메커니즘을 제대로 이해하지 못하면 사용자 자산을 위험에 빠뜨리는 코드를 짤 수 있다.
왜 approve/transferFrom이 필요한가
이더리움의 기본 원칙은 “자신의 토큰만 자신이 옮길 수 있다”는 것이다. 그런데 DeFi 프로토콜은 사용자의 토큰을 대신 처리해야 한다. 이때 사용자가 먼저 “이 컨트랙트에게 내 토큰 N개를 사용할 권한을 준다(approve)”고 선언하면, 이후 컨트랙트가 transferFrom으로 실제 전송을 처리할 수 있다.
// 일반 사용자 → 사용자 자신이 transfer
token.transfer(recipient, amount);
// DeFi 프로토콜 → 사용자가 approve 후 컨트랙트가 transferFrom
// Step 1: 사용자가 프로토콜에 권한 부여
token.approve(uniswapRouter, amount); // 또는 type(uint256).max (무한 승인)
// Step 2: 프로토콜이 사용자 토큰을 이동
// (Uniswap Router 컨트랙트 내부에서)
token.transferFrom(user, pool, amount);
OpenZeppelin ERC-20 내부 구현
// _allowances 매핑: owner → spender → 허용량
mapping(address => mapping(address => uint256)) private _allowances;
function approve(address spender, uint256 value) public returns (bool) {
_allowances[msg.sender][spender] = value; // SSTORE
emit Approval(msg.sender, spender, value);
return true;
}
function transferFrom(address from, address to, uint256 value) public returns (bool) {
address spender = msg.sender;
uint256 currentAllowance = _allowances[from][spender];
// 무한 승인(uint256.max)이면 allowance 차감하지 않음 (가스 절약)
if (currentAllowance != type(uint256).max) {
require(currentAllowance >= value, "ERC20: insufficient allowance");
_allowances[from][spender] = currentAllowance - value; // SSTORE
}
_transfer(from, to, value);
return true;
}
function allowance(address owner, address spender) public view returns (uint256) {
return _allowances[owner][spender];
}
무한 승인(Infinite Approve)의 위험성
많은 DeFi 프로토콜이 UX를 위해 무한 승인(type(uint256).max)을 권장한다. 한 번 approve하면 이후 매번 approve를 다시 할 필요가 없어 편리하다. 하지만 위험하다.
// 무한 승인 — 편리하지만 위험
token.approve(protocolAddress, type(uint256).max);
// 이제 protocol은 이 토큰의 전체 잔액을 언제든 가져갈 수 있다
// 권장 방식 — 필요한 만큼만 승인
token.approve(protocolAddress, exactAmount);
// 사용 후 allowance는 0이 됨 → 추가 악용 불가
실제 사고 사례: 2023년 여러 DeFi 프로토콜 해킹에서 무한 승인을 한 사용자들이 컨트랙트 취약점으로 인해 전체 잔액을 탈취당했다. 프로토콜 자체는 문제가 없어도, 승인된 컨트랙트에 버그가 있으면 무한 승인한 모든 토큰이 위험에 노출된다.
approve 이중 지출 공격 (Front-running)
approve를 기존 값에서 새 값으로 변경할 때 공격이 가능하다.
// 공격 시나리오:
// 1. 사용자가 spender에게 100 토큰 approve
// 2. 사용자가 approve를 50으로 변경하려 함
// 3. 공격자(spender)가 변경 트랜잭션 전에 100 transferFrom 실행
// 4. 이후 사용자의 50 approve 트랜잭션이 처리됨
// 5. 공격자가 다시 50 transferFrom 실행
// → 결과: 150 토큰 탈취
// 해결책: 중간에 0으로 설정 후 새 값 설정
token.approve(spender, 0); // 먼저 0으로
token.approve(spender, newAmount); // 새 값 설정
// 또는 increaseAllowance / decreaseAllowance 사용
token.increaseAllowance(spender, additionalAmount);
token.decreaseAllowance(spender, reductionAmount);
SafeERC20 — 비표준 토큰 대응
일부 토큰(USDT 등)은 transfer/transferFrom이 bool을 반환하지 않는다. 표준을 지키지 않는 토큰이다. 이런 토큰과 상호작용하면 코드가 revert된다.
// 문제: USDT는 transfer() 반환값이 없음
// → 아래 코드가 revert됨
bool success = USDT.transfer(to, amount); // 실패!
// 해결: OpenZeppelin SafeERC20 사용
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract MyProtocol {
using SafeERC20 for IERC20;
function deposit(IERC20 token, uint256 amount) external {
// safeTransferFrom은 내부적으로 반환값 없는 토큰도 처리
token.safeTransferFrom(msg.sender, address(this), amount);
// safeApprove 대신 forceApprove 사용 (이중 지출 방지)
token.forceApprove(spender, amount);
token.safeTransfer(recipient, amount);
}
}
// SafeERC20이 처리하는 토큰들:
// - USDT (반환값 없음)
// - BNB (일부 버전)
// - 기타 비표준 ERC-20
EIP-2612 Permit — approve 없이 서명으로 대체
approve는 항상 별도의 트랜잭션이 필요하다(가스 소모). EIP-2612 permit은 오프체인 서명으로 approve를 대체해, 하나의 트랜잭션으로 approve + transferFrom을 묶을 수 있다.
// Solidity: Permit 지원 컨트랙트
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
contract PermitToken is ERC20, ERC20Permit {
constructor() ERC20("Permit Token", "PTK") ERC20Permit("Permit Token") {}
}
// ethers.js: 오프체인 서명으로 approve
const domain = {
name: await token.name(),
version: "1",
chainId: (await provider.getNetwork()).chainId,
verifyingContract: token.address,
};
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1시간
const { v, r, s } = await user._signTypedData(domain, {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
}, {
owner: user.address,
spender: protocolAddress,
value: amount,
nonce: await token.nonces(user.address),
deadline,
});
// 프로토콜이 permit + 실제 동작을 하나의 TX로 처리
await protocol.depositWithPermit(
tokenAddress, amount, deadline, v, r, s
);
DeFi 컨트랙트에서 allowance 확인
// 입금 전 allowance 확인 패턴
async function depositToken(token, amount) {
const currentAllowance = await token.allowance(user.address, protocol.address);
if (currentAllowance < amount) {
// 정확한 금액만 approve
const approveTx = await token.approve(protocol.address, amount);
await approveTx.wait();
console.log("Approve 완료");
}
const depositTx = await protocol.deposit(token.address, amount);
await depositTx.wait();
console.log("Deposit 완료");
}
// 사용 후 allowance 회수 (선택적이지만 권장)
async function revokeAllowance(token, spender) {
const tx = await token.approve(spender, 0);
await tx.wait();
console.log("Allowance 취소 완료");
}
핵심 정리
- approve → DeFi 프로토콜에 토큰 사용 권한 부여
- transferFrom → 프로토콜이 사용자 대신 토큰 이동
- 무한 승인은 편리하지만 위험 — 필요한 만큼만 approve 권장
- approve 변경 시 0 → 새 값 순서로 (이중 지출 방지)
- SafeERC20: 비표준 토큰(USDT 등) 대응 필수
- EIP-2612 Permit: 서명으로 approve 대체, 하나의 TX로 묶기 가능