ERC-3475 추상 스토리지 채권 (Abstract Storage Bonds)
Final · 토큰 · 2021-04-05 작성
채권처럼 상환 조건이 붙은 의무를 클래스와 논스라는 두 단계 ID로 토큰화하고 상환 조건과 메타데이터를 정해진 틀 없이 체인 위에 직접 저장하게 하는 표준입니다.
왜 필요한가
기존 토큰 표준으로는 상환 조건이 여러 개 붙은 채권을 표현할 수 없습니다. 예를 들어 탈중앙 거래소에 유동성을 맡기면 받는 LP 토큰(Liquidity Provider token)은 단순한 ERC-20 토큰이라서 복잡한 보상·상환 규칙을 체인 위에 담지 못합니다. 발행 시기마다 조건이 다른 채권을 만들려면 그때마다 별도 컨트랙트를 배포해야 하고 지갑이나 거래소 같은 외부 서비스는 각 채권의 잔액과 상환 조건을 일관된 방법으로 읽을 수 없습니다. 또 여러 채권을 묶어서 한 번에 옮기거나 2차 시장에서 쪼개어 거래하는 일도 표준화된 방식이 없었습니다. 이 표준은 여러 토큰 ID, 자유로운 구조의 온체인 메타데이터, 배치 처리를 한 인터페이스로 제공해 이런 문제를 풉니다.
구현해야 하는 것
function transferFrom(address _from, address _to, Transaction[] calldata _transactions) external;: isApprovedFor 승인을 전제로, 지정한 클래스·논스·수량 묶음(Transaction 배열)의 채권을 _from에서 _to로 옮깁니다.function transferAllowanceFrom(address _from,address _to, Transaction[] calldata _transactions) public ;: allowance로 허용받은 수량 범위 안에서만 해당 채권들을 _from에서 _to로 옮깁니다.function issue(address _to, Transaction[] calldata _transaction) external;: 단일 발행 주체가 지정한 클래스·논스·수량의 채권을 _to 주소에 발행합니다.function redeem(address _from, Transaction[] calldata _transactions) external;: 만기 조건이 충족된 뒤 _from 주소의 채권을 상환합니다. 발행자 컨트랙트만 호출하도록 제한해야 합니다.function burn(address _from, Transaction[] calldata _transactions) external;: 채권을 무효화합니다(address(0)으로 보내는 것과 같음). 컨트롤러 컨트랙트만 호출해야 합니다.function approve(address _spender, Transaction[] calldata _transactions) external;: _spender가 호출자의 특정 클래스·논스 채권을 지정한 수량만큼 쓰도록 허용합니다. 다시 호출하면 기존 허용량을 덮어씁니다.function setApprovalFor(address _operator, bool _approved) external returns(bool approved);: _operator에게 호출자의 채권 전체를 관리할 운영자 권한을 주거나 거둡니다.function totalSupply(uint256 classId, uint256 nonceId) external view returns (uint256);: 해당 클래스·논스 채권의 총 공급량을 돌려줍니다. 소각분과 상환분을 포함합니다.function redeemedSupply(uint256 classId, uint256 nonceId) external view returns (uint256);: 해당 클래스·논스에서 이미 상환된 채권 수량을 돌려줍니다.function activeSupply(uint256 classId, uint256 nonceId) external view returns (uint256);: 아직 상환되지 않고 살아 있는 채권 수량을 돌려줍니다.function burnedSupply(uint256 classId, uint256 nonceId) external view returns (uint256);: 해당 클래스·논스에서 소각된 채권 수량을 돌려줍니다.function balanceOf(address _account, uint256 classId, uint256 nonceId) external view returns (uint256);: 특정 주소가 보유한 해당 클래스·논스 채권의 잔액을 돌려줍니다. 상환된 채권도 포함합니다.function classMetadata(uint256 metadataId) external view returns (Metadata memory);: 클래스 메타데이터의 JSON 구조(제목, 타입, 설명)를 돌려줍니다.function nonceMetadata(uint256 classId, uint256 metadataId) external view returns (Metadata memory);: 특정 클래스에 속한 논스 메타데이터의 JSON 구조를 돌려줍니다.function classValues(uint256 classId, uint256 metadataId) external view returns (Values memory);: 주어진 클래스의 메타데이터 항목에 저장된 실제 값(문자열, 숫자, 주소 등)을 누구나 읽을 수 있게 합니다.function nonceValues(uint256 classId, uint256 nonceId, uint256 metadataId) external view returns (Values memory);: 주어진 클래스·논스 채권의 메타데이터 항목에 저장된 실제 값을 돌려줍니다.function getProgress(uint256 classId, uint256 nonceId) external view returns (uint256 progressAchieved, uint256 progressRemaining);: 채권 만기의 현재 진행 상태와 남은 진행량을 돌려줍니다. 기준은 시간일 수도, 유동성 비율일 수도 있습니다.function allowance(address _owner, address _spender, uint256 classId, uint256 nonceId) external returns(uint256);: _owner가 _spender에게 허용한 해당 클래스·논스 채권의 남은 사용 가능 수량을 돌려줍니다.function isApprovedFor(address _owner, address _operator) external view returns (bool);: _operator가 _owner의 채권을 관리할 운영자 권한이 있는지 여부를 돌려줍니다.event Issue(address indexed _operator, address indexed _to, Transaction[] _transactions);: 채권이 발행될 때 반드시 발생하는 이벤트입니다. 수량이 0인 발행은 포함하지 않아야 합니다.event Redeem(address indexed _operator, address indexed _from, Transaction[] _transactions);: 채권이 상환될 때 반드시 발생하는 이벤트입니다. 수량이 0인 상환은 포함하지 않아야 합니다.event burn(address _operator, address _owner, Transaction[] _transactions);: 은행 컨트랙트가 채권을 소각하거나 무효화할 때 반드시 발생하는 이벤트입니다.event Transfer(address indexed _operator, address indexed _from, address indexed _to, Transaction[] _transactions);: 채권이 전송될 때 반드시 발생하는 이벤트입니다. _from이 0x0인 전송에는 쓰지 않고 Issue 이벤트를 씁니다.event ApprovalFor(address indexed _owner, address indexed _operator, bool _approved);: 소유자가 운영자에게 채권 전송 권한을 주거나 거둘 때 발생하는 이벤트입니다.
심층 설명
클래스와 논스의 2단계 ID 구조
하나의 컨트랙트 안에서 채권은 classId와 nonceId 두 값으로 식별됩니다. 클래스는 채권의 종류를 나타내고 논스는 같은 클래스 안에서 발행 시기나 발행 조건의 차이를 나타냅니다. 같은 클래스에 속한 논스들은 클래스 메타데이터를 공유하지만 논스끼리는 대체 불가능하며 각 논스는 자기만의 메타데이터, 공급량, 상환 조건을 따로 가질 수 있습니다. 채권을 전송하면 그 메타데이터 전체가 새 소유자에게 넘어갑니다.
Transaction 구조체와 배치 처리
모든 상태 변경 함수는 Transaction 구조체의 배열을 받습니다. 구조체는 uint256 classId, uint256 nonceId, uint256 _amount 세 필드로 이루어지며 각각 채권 클래스, 발행 조건을 구분하는 논스, 수량을 뜻합니다. 덕분에 서로 다른 클래스와 논스의 채권 여러 개를 한 트랜잭션으로 발행, 전송, 상환, 소각, 승인할 수 있습니다. 실행이 끝나면 새 소유자가 각 채권의 액면가를 청구할 권리를 갖게 되며 이런 묶음 처리 방식은 2차 시장 거래 같은 용도에 유용합니다.
발행, 상환, 소각의 권한 규칙
issue()는 단일 주체가 발행해야 하며(MUST), 예시로는 담보 유동성 풀과 연동된 역할 기반 소유 컨트랙트가 제시됩니다. redeem()은 호출을 채권 발행자 컨트랙트로 제한해야 하고 만기 조건(종료 시각, 총 활성 유동성 등)이 충족된 뒤에만 실행되어야 하며(MUST), 은행 또는 2차 시장 마켓 메이커 컨트랙트만 호출해야 합니다(SHOULD). burn()은 컨트롤러 컨트랙트만 호출해야 하며(MUST) 역시 만기 조건 충족 후에만 실행됩니다. 여기서 은행(Bank)이란 유동성을 받은 뒤 채권을 발행, 상환, 소각하는 주체로, 보통 풀의 관리자 권한을 가진 단일 주체를 뜻합니다.
두 가지 승인 체계
approve()는 _spender에게 특정 (classId, nonceId)의 채권을 지정 수량만큼 쓸 권한을 주며 다시 호출하면 기존 허용량을 덮어씁니다. setApprovalFor()는 운영자(operator)에게 호출자의 채권 클래스 전체를 관리할 권한을 켜거나 끕니다. transferFrom()은 isApprovedFor(_from, _to, classId) 승인이 있어야 하고 transferAllowanceFrom()은 각 Transaction 항목마다 allowance(_from, msg.sender, classId, nonceId)가 있어야 합니다. 두 승인 함수 모두 은행이나 계정 소유자만 호출할 수 있어야 합니다.
공급량 집계와 만기 진행 조회
공급량은 네 가지로 나뉘어 집계됩니다. totalSupply()는 소각분과 상환분을 포함한 전체 공급량, activeSupply()는 상환되지 않은 활성 공급량, redeemedSupply()는 상환된 수량, burnedSupply()는 소각된 수량입니다. balanceOf()는 특정 주소의 잔액을 돌려주는데 상환된 채권도 포함합니다. getProgress()는 progressAchieved와 progressRemaining 두 값을 돌려주어 만기까지의 진행 상태를 보여 주며 기준은 유동성 비율이나 시간 등 구현에 따라 다를 수 있고 상환 조건은 내부 함수로 정의되어야 합니다(SHOULD).
메타데이터를 체인 위에 추상 저장
메타데이터는 title, _type, description 세 문자열을 가진 Metadata 구조체로 스키마를 정의하고 실제 값은 stringValue, uintValue, addressValue, boolValue, bytesValue 필드를 가진 Values 구조체에 담습니다. classMetadata()와 nonceMetadata()로 스키마를 읽고, classValues()와 nonceValues()에 인덱스 키를 넘겨 해당 값을 읽는 방식입니다. 예를 들어 symbol 항목이라면 클래스 ID를 키로 values 배열에서 해당 클래스의 심볼 문자열을 찾습니다. 고정된 저장 패턴을 강제하지 않으면서 외부 저장소 없이 모든 명세를 담을 수 있다는 점이 표준 이름(추상 스토리지)의 유래입니다.
이벤트와 기존 표준과의 호환성
채권 생애주기(발행, 상환, 전송)를 인덱싱할 수 있도록 Issue, Redeem, Transfer, burn, ApprovalFor 이벤트를 해당 트랜잭션마다 반드시 발생시켜야 합니다. 수량이 0인 동작은 이벤트에 포함하지 않아야 하고 _from이 0x0인 전송에는 Transfer 대신 Issue 이벤트를 써야 합니다. 이 표준의 인터페이스는 EIP-20, EIP-721, EIP-1155 인터페이스와 호환되며 기존 EIP-20 토큰도 민팅 권한을 이 표준을 내장한 은행 컨트랙트에 위임해 채권을 발행할 수 있습니다. 담보는 EIP-20 같은 대체 가능 토큰, EIP-721과 EIP-1155 같은 대체 불가능 토큰, 또는 이 표준으로 만든 다른 채권일 수 있습니다. 조회와 향후 업그레이드를 위해 별도의 은행 컨트랙트를 두는 것이 권장됩니다.
AMM 유동성 풀 통합
초기 자동화 마켓 메이커(AMM)는 토큰 쌍마다 별도 컨트랙트와 EIP-20 LP 토큰으로 유동성을 관리해서 풀 하나에 모이는 유동성이 줄고 불필요한 가스 비용과 슬리피지가 생겼습니다. 이 표준을 쓰면 클래스와 논스에 대응하는 유동성 데이터 구조 덕분에 모든 쌍을 담은 큰 유동성 풀을 만들 수 있고 채권 소유자의 유동성을 풀 안 토큰 쌍의 비율로 나타낼 수 있습니다. 풀 팩토리마다 따로 배포되던 EIP-20 LP 토큰이 하나의 채권으로 집계되어 단일 풀로 통합되는 효과입니다.
쓸 때 주의할 점
- setApprovalFor는 전체 권한을 넘깁니다: setApprovalFor(_operatorAddress)는 운영자 역할을 부여하며 기본적으로 채권의 전송, 소각, 상환까지 모든 권한을 갖게 됩니다. 신중하게 사용해야 합니다.
- 일회성 허용은 approve를 씁니다: 특정 채권(classId, nonceId)에 대해 한 번만 쓸 권한을 주려면 setApprovalFor로 모든 클래스를 승인하지 말고 허용할 Transaction 배열을 지정해 approve()를 호출해야 합니다.