ERC-7540 비동기 볼트
도입
ERC-4626은 즉시 deposit/redeem이 가능한 vault를 이해하는 데 좋다. 하지만 RWA fund, private credit, T-bill wrapper, 일부 lending vault는 같은 transaction 안에서 입출금을 끝낼 수 없다. cutoff time, NAV 산정, compliance review, liquidity gate, offchain settlement가 중간에 들어가기 때문이다.
ERC-7540은 ERC-4626에 asynchronous request flow를 더한다. 사용자는 먼저 requestDeposit 또는 requestRedeem을 만들고, request가 claimable 상태가 된 뒤 별도 claim function을 호출해 shares 또는 assets를 받는다. 이 두 단계 구조가 RWA redemption UX의 핵심이다.
학습 목표
- 비동기 deposit/redeem 요청 모델을 설명한다.
- 요청, 처리, claim 상태를 제품 흐름으로 설계한다.
개념 설명
개념 읽기
비동기 deposit/redeem 요청 모델을 설명한다.
실패 상태 확인
share와 asset 권리가 분리되는가
실습 산출물 작성
ERC-7540 비동기 볼트 이해 점검
완료 기준 대조
비동기 vault의 상태를 설명했다.
ERC-7540의 기본 lifecycle은 Request, Pending, Claimable, Claimed다. 중요한 점은 request가 곧바로 사용자에게 결과물을 push하지 않는다는 것이다. 사용자가 다시 claim function을 호출해 output token을 pull한다.
| 상태 | 의미 | 제품 화면 문구 |
|---|---|---|
| Request | 사용자가 deposit 또는 redeem 요청을 제출함 | 요청이 접수됐습니다 |
| Pending | vault가 아직 처리하지 않았음 | 처리 대기 중입니다 |
| Claimable | vault가 처리했고 사용자가 claim 가능함 | 수령 가능 상태입니다 |
| Claimed | 사용자가 shares 또는 assets를 수령함 | 수령 완료 |
비동기 deposit과 redeem은 ERC-4626 함수의 의미도 바꾼다.
| 흐름 | request 단계 | claim 단계 | 주의점 |
|---|---|---|---|
| Async deposit | requestDeposit(assets, controller, owner)로 asset을 vault에 넣고 pending 기록 | deposit 또는 mint로 claimable request를 shares로 수령 | request 시점의 conversion과 claim 시점의 shares가 다를 수 있다 |
| Async redeem | requestRedeem(shares, controller, owner)로 shares를 vault 통제 아래 둠 | withdraw 또는 redeem으로 claimable request를 assets로 수령 | pending 기간에 yield 또는 exchange rate가 고정되지 않을 수 있다 |
ERC-7540은 request를 requestId와 controller로 식별한다. 같은 requestId가 여러 요청에 쓰일 수 있으므로 product DB는 requestId만 저장하면 부족하다. controller, owner, request type, assets/shares, submittedAt, claimableAt, claimedAt, exchangeRate policy를 함께 저장해야 한다.
코드로 확인하기
비동기 vault의 핵심은 요청과 실행 사이에 시간이 있다는 점이다. 코드는 "사용자가 요청했다"와 "vault가 처리했다"를 같은 상태로 취급하지 않아야 한다.
컨트랙트비동기 redemption 요청 큐
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";using SafeERC20 for IERC20;struct RedemptionRequest { address owner; uint256 shares; uint64 epoch; bool claimed;}IERC20 public immutable asset;mapping(uint256 requestId => RedemptionRequest) public redemptionRequests;mapping(uint64 epoch => uint256 assetsPerShare) public navByEpoch;uint256 public nextRequestId;uint64 public currentEpoch;event RedeemRequested(uint256 indexed requestId, address indexed owner, uint256 shares, uint64 epoch);event RedeemClaimed(uint256 indexed requestId, address indexed owner, uint256 assets, uint64 epoch);error ZeroShares();error NotRequestOwner();error NotClaimable();error AlreadyClaimed();function requestRedeem(uint256 shares) external returns (uint256 requestId) { if (shares == 0) revert ZeroShares(); requestId = ++nextRequestId; redemptionRequests[requestId] = RedemptionRequest({ owner: msg.sender, shares: shares, epoch: currentEpoch, claimed: false }); _transfer(msg.sender, address(this), shares); emit RedeemRequested(requestId, msg.sender, shares, currentEpoch);}function claimRedeem(uint256 requestId) external returns (uint256 assets) { RedemptionRequest storage request = redemptionRequests[requestId]; if (request.owner != msg.sender) revert NotRequestOwner(); if (request.claimed) revert AlreadyClaimed(); uint256 price = navByEpoch[request.epoch]; if (price == 0) revert NotClaimable(); request.claimed = true; assets = request.shares * price / 1e6; // NAV fixed-point scale: 1 USDC = 1_000_000 asset.safeTransfer(msg.sender, assets); emit RedeemClaimed(requestId, msg.sender, assets, request.epoch);}요청 시점에는 asset을 바로 돌려주지 않는다. 대신 share를 vault 안으로 잠그고, 어떤 epoch의 NAV로 처리할지 기록한다. 이 기록이 없으면 나중에 "어느 가격으로 환매됐는가"를 설명할 수 없다.
백엔드NAV 확정 후 claim 가능 여부 계산
type RedemptionRequest = { id: bigint; owner: `0x${string}`; shares: bigint; epoch: number; claimed: boolean;};const NAV_SCALE = 1_000_000n; // NAV는 USDC 6 decimals 기준 assets-per-share fixed point로 저장한다.function claimableAssetAmount(request: RedemptionRequest, navByEpoch: Map<number, bigint>) { const sharePrice = navByEpoch.get(request.epoch); if (!sharePrice) { return { ready: false, assets: 0n }; } const assets = request.shares * sharePrice / NAV_SCALE; return { ready: !request.claimed, assets };}운영자는 이 계산 결과를 dashboard에 노출한다. NAV_SCALE은 "share 1개당 asset 수량"을 USDC 6자리 fixed point로 저장하기 위한 단위다. 사용자는 "대기 중", "NAV 확정", "claim 가능", "claim 완료"를 구분해서 볼 수 있어야 한다.
강의 포인트
| 관점 | 확인할 질문 | 증거로 남길 것 |
|---|---|---|
| request identity | requestId와 controller를 함께 저장했는가 | request key schema |
| user custody | request 단계에서 asset 또는 share가 어디에 있는가 | custody transition 표 |
| claim UX | 사용자가 언제 무엇을 직접 claim해야 하는가 | status copy와 CTA |
| partial 처리 | 같은 requestId의 일부가 claimable이면 어떤 pro-rata 정책을 쓸 것인가 | partial fulfillment policy |
| stale request | 오래 pending인 request를 어떻게 review하고 해소할 것인가 | manual resolution runbook |
실무 예시
투자자가 RWA vault share를 redeem하고 싶다고 가정한다. 즉시 asset을 받을 수 없다. fund는 cutoff 이후 NAV를 계산하고, liquidity를 확보한 다음 redeem request를 claimable로 바꾼다.
| 시간 | 사용자 상태 | vault/accounting 상태 | 운영 확인 |
|---|---|---|---|
| T0 | redeem 요청 제출 | shares locked 또는 burned policy 적용 | requestId, controller, owner |
| T1 | 처리 대기 | pendingRedeemRequest 증가 | cutoff batch 포함 여부 |
| T2 | 수령 가능 | claimableRedeemRequest 증가 | exchange rate, fee, available assets |
| T3 | claim 완료 | assets transferred, request consumed | claim tx, final amount |
이 표가 없으면 사용자는 "출금 버튼을 눌렀는데 왜 돈이 바로 안 오나"라고 느낀다. RWA UX는 지연을 숨기는 것이 아니라 지연의 이유와 다음 행동을 명확히 알려야 한다.
흔한 오해와 실패 시나리오
| 오해 | 실제로 확인할 것 |
|---|---|
| request가 성공하면 deposit/redeem이 끝났다고 본다. | request와 claim은 별도 단계이며 사용자가 다시 claim해야 할 수 있다. |
| preview 함수가 비동기 흐름에서도 항상 유효하다고 본다. | async deposit/redeem에서는 preview가 revert하거나 의미가 달라질 수 있다. |
| requestId만 저장하면 충분하다고 본다. | controller와 requestId를 함께 봐야 하며 requestId 0 aggregation도 고려한다. |
| pending 기간의 exchange rate가 고정된다고 가정한다. | 표준은 pending redemption request가 yield-bearing이거나 fixed rate라고 보장하지 않는다. |
| operator approval을 편의 기능으로만 본다. | operator는 controller 대신 request를 관리할 수 있으므로 권한 탈취 리스크를 검토해야 한다. |
실습 과제
- ERC-7540 비동기 볼트 이해 점검: requestId, controller, owner, asset, shares, pending, claimable, claimed 필드를 포함한 request record를 설계한다.
- redemption UX 설계: Requested, Pending, Claimable, Claimed, ManualReview, Canceled 상태별 사용자 문구와 CTA를 작성한다.
- partial 처리 기준 작성: 같은 requestId가 일부 claimable이 될 때 pro-rata 처리, rounding, fee, disclosure 기준을 정한다.
- operator risk 검토: controller가 operator를 승인했을 때 어떤 함수 호출이 허용되는지와 revoke UX를 정리한다.
완료 기준
- 비동기 vault의 상태를 설명했다.
- redemption UX를 설계했다.
- partial 처리 기준을 남겼다.
근거 자료
- ERC7540 비동기 볼트: 04-RWA-토큰화/02-ERC7540-비동기-볼트.md
- ERC-7540: Asynchronous ERC-4626 Tokenized Vaults: https://eips.ethereum.org/EIPS/eip-7540