[ZK-STARK series] #04 Application

Taron Sung
Decipher Media |디사이퍼 미디어
46 min readFeb 20, 2023

서울대학교 블록체인 학회 디사이퍼(Decipher) ZK-STARK 팀에서 영지식 증명 및 그 활용에 대한 글을 시리즈로 연재합니다. 본 시리즈는 법률 및 투자에 대한 권유 내지 조언을 일체 포함하고 있지 않으며, 본 글을 바탕으로 유관 의사결정을 내리지 마십시오.

Author

Taron(@taronsung) of Decipher Seoul Nat’l Univ. Blockchain Academy Decipher(@decipher-media)

Reviewed By Yohan Lim

목차

  1. Recap: Cairo란?
  2. ERC-20 & ERC-721 in Cairo
  3. What’s Next?

1. Recap: Cairo란?

지금까지 ZK-STARK의 이론적인 구현과 전반적인 ZK 생태계, 그리고 Starknet 및 Cairo의 구현에 대해 살펴보았습니다. 본 글에서는 이러한 내용들이 실제로 어떻게 구현되고 있는지에 대해 살펴봅니다. Cairo의 경우 기존 블록체인 개발 생태계의 헤게모니를 쥐고 있던 EVM과 사용하는 컴파일러, 메모리 구조, Instruction Set까지 모두 달리하는 바 살펴볼 구현체가 많지만, 본 글에서는 가장 익숙한 ERC-20(Token Standard), ERC-721(Non-Fungible Token Standard)의 예시를 중점적으로 살펴보고자 합니다.

Cairo 언어의 경우 앞서 설명드린 Cairo 1.0 출시 이후에도 alpha.2 업데이트를 진행하는 등 빠르게 변화하고 있습니다. 출시 초기에는 STARK 구조를 구현하기 위해 필요한 AIR과 같은 이론적인 배경을 이해하고, 이를 개발자가 직접 구현하거나 제공되는 구현체를 변경할 필요가 있었습니다. 기계어정도의 저수준 언어는 아니었지만, 어셈블리어로 직접 프로그램을 작성하는 방식으로, Solidity와 같은 고수준 언어에 비해서는 진입 장벽이 지나치게 높았다고 볼 수 있겠습니다. 이러한 방식은 당연하게도 기존 Solidity에 익숙한 개발자의 풀을 전혀 레버리지할 수 없었고, 나아가 신규 유입의 허들을 단적으로 높이는 결과를 야기했습니다.

기존 Cairo에 대해 개발자들이 제기한 문제 중 대표적인 것들은 아래와 같습니다.

  • felt(field element)라는 252 bytes 자료형의 사용으로 기존 컴퓨터 프로그래밍 언어와 통일성 부재 (허나 전술했던 것과 같이, 이는 효율적이고 간결한 STARK 구조 및 AIR 구현을 위해 필수불가결한 요소로 보입니다.) 및 Uint256 타입의 복잡성
  • 변수 선언의 복잡성 (수많은 revoked reference 에러를 야기했던)
  • 조건문 활용의 낮은 자유도 및 어려움
  • 에러 핸들링의 어려움
  • 언어의 잦은 업데이트 및 개발자 도구/IDE/라이브러리 간 호환성 이슈

그 외에도 수많은 문제점들이 Cairo 생태계 개발자들에 의해 제기되었고, 피드백의 결과물로 산출된 것이 Cairo 1.0입니다. 파이썬을 활용한 DSL로 작성된 이전 버전의 Cairo와는 달리, Cairo 1.0은 Rust를 활용해 작성되었고, 더불어 기존 Rust와 호환성을 확보한 면들도 존재합니다. (Option, Result 타입의 존재, Trait, Ownership과 Borrowing을 통한 메모리 관리 등) 풍부한 Rust 개발자 풀을 레버리지할 수 있는 환경이 마련되었다고 볼 수 있겠습니다. 특히 Cairo 언어가 차용하고 있는 메모리 모델(이른바 ‘non-deterministic read-only’ memory)이 통상적인 메모리 모델과 다른바, Rust의 접근법을 활용해 이러한 차이점을 추상화함으로써 연속적인 개발자 경험을 제공하려는 시도는 유의미하다고 사료됩니다. 이렇듯 Rust를 상당 부분 참고하여 Cairo 1.0이 만들어지고 있는 바, 이하에서는 Cairo의 특징들을 Rust의 특징과 비교하며 설명하도록 하겠습니다.

스타크웨어 미디움에서 Cairo 1.0 출시를 알리며 작성한 글 중 일부. 개발자들이 느꼈을 불편함에 대한 공감이 느껴집니다.

나아가 최근 스타크웨어에서 Cairo 1.0 alpha.2를 발표했습니다. alpha.2는 아래와 같은 특징들에 주안점을 둔 버전입니다.

  • ERC20 컨트랙트에 대한 표준화된 구현체
  • 딕셔너리, 컨트랙트 내 이벤트 지원
  • Mapping 스토리지 변수 지원
  • Trait 지원
  • 타입 인터페이스
  • 메소드

Trait이란 Rust에서 추상화된 동작을 정의하기 위해 사용됩니다. 타 언어의 인터페이스(Interface)와도 유사하다고 볼 수 있겠습니다. 즉, 특정 동작(메소드)들의 묶음을 하나의 공통된 Trait으로 정의함으로써 다른 객체들에서 공통된 동작들을 통일성 있게 구현하도록 도와주는 것입니다. 학생의 예시를 들어보겠습니다.

pub trait Student {
fn new(name: &'static str) -> Self;
fn name(&self) -> &'static str;
fn study(&self) -> String;
fn rest(&self) -> String;
fn exam(&self) -> String;
fn introduce(&self) {
println!("My name is {}", self.name());
}
}

학생의 경우 고유한 이름을 가지고, 공부를 하거나, 휴식을 하거나, 시험에 응시하고 각 행위에 대한 소감을 문자열로 반환합니다. 또한, 자기소개를 하는 경우 본인의 이름을 소개하게 됩니다.

struct HighSchoolStudent { name: &'static str, grade: f64 }

impl Student for HighSchoolStudent {
fn new(name: &'static str, grade: f64) -> John {
John { name: name, grade: grade }
}

fn name(&self) -> &'static str {
self.name
}
...
}

HighSchoolStudent는 고등학교 학생에 대한 구조체입니다. 고등학교 학생의 경우 학생 Trait을 구현(impl)하고 있습니다. 이런 방식으로 작성하는 경우, 중학교/초등학교/대학교 학생에 대해 공통적인 동작을 Trait로 구현하고, 통일성 있게 각 객체를 정의할 수 있을 것입니다.

이렇듯, Trait을 Cairo에 구현하는 경우 이전보다 더 확장성 있고 안정적인 프로그래밍이 가능합니다. Rust의 경우 이를 컴파일 과정에서 판단하는데, Cairo 역시도 Sierra라는 중간 인터프리터를 구현함으로써 유사한 방식을 차용하고 있습니다.

위 버전을 출시하면서, Cairo 공식 깃헙에 ERC-20의 Cairo 구현체에 대한 예제 코드가 업로드되었습니다. 해당 코드를 중심으로 새롭게 업데이트된 특징들이 어떻게 녹아있는지 구체적으로 살펴보도록 하겠습니다.

추가로, 가장 최근에 업데이트된 Cairo 1.0 alpha.3에 대해 간략하게 설명드리겠습니다.

Cairo 1.0 alpha.3는 아래와 같은 특징들을 가집니다.

  • Snapshot 타입
  • @, * (각각 Snapshot, de-snapping 오퍼레이터)
  • 복사 불가능한 요소에 대한 array_at 지원 (Snapshot 오퍼레이터를 활용)
  • EC(Elliptic Curve. 타원곡선)를 구현한 라이브러리 추가
  • Neg, Not 오퍼레이터에 대한 Trait 지원
  • Into, TryInto Trait 지원
  • 새로운 오퍼레이터: +=, -=, *=, /=, %=
  • u8, u16, u32, u64 타입 지원

우선, Snapshot이 무엇인지 살펴보겠습니다. Snapshot이란, Rust의 reference(참조자)와 유사한 cairo의 타입입니다. Rust에서 참조자는 값에 대한 소유권을 넘기는 대신 참조자를 인자로 활용해 소유권을 넘기지 않고, 참조만 할 수 있도록 - 이른바 ‘빌림(Borrowing)’이 가능하도록 하는 특징입니다. 이러한 특징은 mut와 같은 가변성을 명시적으로 정의하는 Rust의 특징과 합쳐져 안전한 메모리 관리를 구현하는 커다란 축이 됩니다. 추가적인 예제와 설명을 살펴보고 싶으시다면, 해당 링크를 살펴보시길 바립니다.

위 내용을 바탕으로, Snapshot에 대해 살펴보도록 하겠습니다. Snapshot 타입이란, 특정 시점의 객체에 대한 불변 참조자를 생성하는 타입에 해당합니다. 참조자란 앞서 살펴본 것처럼, 특정 객체에 대한 접근권한이라고 볼 수 있고, 그러한 참조자가 불변이라는 것은 참조자를 통해 접근한 객체의 값을 변경할 수 없다는 것을 의미합니다. @*이 Snapshot, de-snapping 오퍼레이터라는 것은 저러한 Snapshot 타입을 생성하는 오퍼레이터가 @, Snapshot 타입을 다시 기존 타입으로 변환시키는 오퍼레이터가 *라는 것을 의미합니다. 거버넌스 과정이나, 토큰 에어드랍 과정에서 활용되는 스냅샷과 그 기능 및 의미 측면에서 유사하다고 볼 수 있겠습니다.

이외의 업데이트의 경우 Rust와의 호환성을 더욱 강화하는데 일조합니다. 대표적으로 Into, TryInto의 경우 Rust의 From Trait과 더불어 타입 변환을 가능하게 만들어줍니다.

지금까지 Cairo 언어가 무엇인지에 대한 요약, 그리고 현재 Cairo가 어떻게 변화하고 있고, 어떤 방향을 바라보고 있는지에 관해 살펴보았습니다. Starknet이나 Cairo는 이번에 처음 출시된 것이 아니라, 몇 년 전부터 출시되어 있었기에 본 글의 마지막에서 언급한 Regenesis와는 별개로 이전 버전과의 호환성을 유지하면서 마이그레이션하는 것이 중요합니다. 관련 내용은 Starknet 깃헙에 정리되어 있으니, 진행되고 있는 업데이트가 이전 버전과 어떤 차이를 보이는지 내지 호환성 측면에서 어떤 개선이 이루어지고 있는지 살펴보시는 것도 좋겠습니다.

이렇듯 Cairo는 현 시점에도 꾸준히 개선 중인 언어이기 때문에 본 글에서 설명드리는 내용이 몇 달 뒤 내지 몇 년 뒤에는 Outdate될 가능성이 있습니다. 이하에서는 개인적으로 Cairo 0. 부터 Starknet 상의 다양한 프로토콜을 살펴보고, 1.0 이전의 버전으로 컨트랙트를 작성해보면서 느꼈던 점과 해당 지점이 어떻게 개선되고 있는지 위주로 아래 구현체를 살펴볼 예정입니다.

2. ERC-20 & ERC-721 in Cairo

2.1. ERC-20

아래 코드는 각각 (1) Cairo 1.0 alpha.2 버전, 그리고 (2) Cairo 0.6.1 버전으로 구현된 ERC-20 컨트랙트입니다. (1)의 경우 Starkware의 Cairo 구현체 깃헙의 코드를, (2)의 경우 오픈제플린 깃헙의 코드를 첨부했습니다. 하나씩 살펴보며, EVM에 구현된 ERC-20 표준이 Cairo에서 어떻게 구현되는지와 Cairo의 버전 업그레이드에 따라 어떤 방식으로 바뀌었는지를 살펴보도록 하겠습니다. Cairo 1.0의 경우 향후 꾸준한 개선이 있을 것으로 기대되는 바, 하단의 코드가 바뀔 수 있다는 점 참고 부탁드립니다. 앞서 언급드린 Cairo 1.0 alpha.3의 경우 23년 2월 23일에 업데이트되어, 주요 특징만 위에서 살펴보았고, 하단에서 살펴볼 ERC-20 구현체의 경우 alpha.2와 alpha.3 간 차이가 현재로서는 존재하지 않아 편의상 alpha.2 버전을 기준으로 작성하였습니다.

2.1.1. Cairo 1.0 alpha.2 Ver.

#[contract]
mod ERC20 {
use zeroable::Zeroable;
use starknet::get_caller_address;
use starknet::contract_address_const;
use starknet::ContractAddressZeroable;

struct Storage {
name: felt,
symbol: felt,
decimals: u8,
total_supply: u256,
balances: LegacyMap::<ContractAddress, u256>,
allowances: LegacyMap::<(ContractAddress, ContractAddress), u256>,
}

#[event]
fn Transfer(from: ContractAddress, to: ContractAddress, value: u256) {}

#[event]
fn Approval(owner: ContractAddress, spender: ContractAddress, value: u256) {}

ERC-20 모듈을 정의하는 부분부터 Rust의 특징이 강하게 드러납니다. mod 키워드는 Rust에서 새로운 모듈을 선언할 때 사용하는 키워드로, 모듈은 코드의 재사용을 위해 사용하는 구조입니다. 하단의 fn 키워드 역시도 Rust에서 함수를 선언하기 위해 사용되는 키워드입니다. 후술하겠지만, Cairo 1.0 이전의 버전에서는 함수 선언을 위해 func 키워드를 사용했던 것과 대비되는 모습입니다.

해당 모듈 내부에는 Storage라는 구조체(struct)가 선언되어 있습니다. 구조체 내부에는 기존 EVM상 ERC-20 표준에 존재했던 필드값들 - 이름, 심볼, 데시멀 값, 총 공급량, 각 계정별 잔고, 계정 간 권한을 허용한 량들이 정의되어 있습니다. 기존 ERC-20에서 토큰이 계정 A에서 계정 B로 전송되는 경우 Transfer라는 이벤트가 emit되었던 것과 유사하게, 해당 모듈에서도 Transfer 이벤트가 emit될 수 있도록 정의되어 있습니다. 이렇듯 앞서 살펴본 Cairo 1.0 alpha.2 업데이트로 포함된 내용들을 확인할 수 있습니다.

요컨대, (1) Map::<ContractAddress, u256>을 통해 기존 ERC-20의 mapping(address => uint256) _balances를 구현했으며 (2) #[event] 부분의 메소드를 통해 역시 기존 ERC-20의 event Transfer(address indexed from, address indexed to, uint256 value)를 구현한 것을 확인할 수 있습니다.

#[constructor]
fn constructor(
name_: felt, symbol_: felt, decimals_: u8, initial_supply: u256, recipient: ContractAddress
) {
name::write(name_);
symbol::write(symbol_);
decimals::write(decimals_);
assert(!recipient.is_zero(), 'ERC20: mint to the 0 address');
total_supply::write(initial_supply);
balances::write(recipient, initial_supply);
Transfer(contract_address_const::<0>(), recipient, initial_supply);
}

위 생성자는 ERC-20 객체를 생성하고, recipient에게 initial_supply만큼의 ERC-20 토큰을 전송합니다. 이때 assert문을 보시면, solidity의 require문과 유사한 형태로 작성되어있는 것을 확인할 수 있는데, 이 역시도 Rust의 ‘assert’ 매크로를 차용한 것에 해당합니다. name::write와 같이 값을 특정 변수에 입력하는 것 역시도 하나의 동작으로 정의되어 있습니다. 이는 데이터 가용성(DA. Data Availability) 이슈를 고려해 Cairo에서 Low-level 시스템 콜을 추상화한 것으로, 아래 사진과 같이 정의되어 있습니다. 코드는 해당 링크를 참고하시길 바랍니다.

felt 타입에 대한 write, read 동작을 정의한 코드 블럭. 제너릭(위 <T> 부분)을 활용해 다른 타입에 대해서도 정의합니다.

앞서 설명드린 Trait를 통해 스토리지에 접근하는 경우 필요한 공유 동작(read, write)를 추상화하고, 각각에 대한 시스템 콜을 호출하는 형태입니다. felt 타입 뿐만 아니라, u8, u16과 같은 기본 자료형에 대해서도 정의가 되어 있기 때문에 상기 ERC-20 생성자의 decimals::write 형태의 호출도 가능한 것을 확인할 수 있습니다.

#[external]
fn transfer_from(sender: ContractAddress, recipient: ContractAddress, amount: u256) {
let caller = get_caller_address();
spend_allowance(sender, caller, amount);
transfer_helper(sender, recipient, amount);
}

fn transfer_helper(sender: ContractAddress, recipient: ContractAddress, amount: u256) {
assert(!sender.is_zero(), 'ERC20: transfer from 0');
assert(!recipient.is_zero(), 'ERC20: transfer to 0');
balances::write(sender, balances::read(sender) - amount);
balances::write(recipient, balances::read(recipient) + amount);
Transfer(sender, recipient, amount);
}

fn spend_allowance(owner: ContractAddress, spender: ContractAddress, amount: u256) {
let current_allowance = allowances::read((owner, spender));
let ONES_MASK = 0xffffffffffffffffffffffffffffffff_u128;
let is_unlimited_allowance =
current_allowance.low == ONES_MASK & current_allowance.high == ONES_MASK;
if !is_unlimited_allowance {
approve_helper(owner, spender, current_allowance - amount);
}
}

위 코드는 ERC-20 토큰을 전송하는 로직에 결부된 코드입니다. transfer_from 함수는 spend_allowancetransfer_helper 함수를 순차적으로 호출하는데, 함수명에서도 드러나듯 spend_allowance는 권한을 가진 자가 권한을 받은 한도를 차감시키는 기능을 하며, transfer_helper는 ERC-20 토큰 전송이 불가능한 경우(보내는 자 혹은 수령하는 자의 주소값이 0으로 유효하지 않은 경우)를 판단하고, 이후 해당 제약 조건이 만족된다면 각 주소의 잔고에 반영하고 전송이 성공적으로 발생했다는 Transfer 이벤트를 전파합니다.

owner와 spender 모두 ContractAddress라는 타입을 가지는 것에서 계정 추상화(Account Abstraction)이라는 Starknet의 특징을 살펴볼 수도 있겠습니다. 이더리움의 경우 현재 계정이 (1) EOA(Externally Owned Account)와 (2) CA(Contract Account)로 분리되어 있는데요, 이러한 계정을 하나로 통합해 추상화하자는 논의가 계정 추상화입니다. 해당 논의의 경우 2017년 비탈릭 부테린의 EIP 제안 이후로 꾸준히 진행되어 왔으며, 이더리움의 경우 다양한 EIP, 예컨대 EIP-4337 등을 통해 구현하려 노력하고 있습니다. Starknet의 경우 이를 프로토콜 단에 구현했는데요, 구체적으로 Starknet 어카운트 모델과 관련된 내용은 본 글의 범위를 벗어나니 Starknet 커뮤니티에 게재된 논의 링크를 첨부합니다. 계정 추상화의 개념과 관련해서는 argent 팀의 아티클 시리즈 혹은 디사이퍼 미디움의 관련 아티클을 참고하시길 바랍니다.

Rust 기반의 언어인 Move를 사용하는 Aptos의 aptos_token 구현체 상 전송 로직도 간단하게 살펴보겠습니다. Cairo 코드를 다루는 부분은 아니고, 로직만 살펴보기 위해 대부분의 코드를 생략하고 핵심 로직만 첨부하였습니다.

module aptos_token::token {
...
use aptos_framework::coin::{Self, BurnCapability, MintCapability};

public fun transfer(
from: &signer,
id: TokenId,
to: address,
amount: u64,
) acquires TokenStore {
let opt_in_transfer = borrow_global<TokenStore>(to).direct_transfer;
assert!(opt_in_transfer, error::permission_denied(EUSER_NOT_OPT_IN_DIRECT_TRANSFER));
let token = withdraw_token(from, id, amount);
direct_deposit(to, token);
}

코드의 경우 상기 cairo로 작성된 ERC-20의 transfer_from과 유사해보입니다. 하지만 각각 구현체에서 transfer가 어떻게 일어나는지는 상당히 다른 것을 확인할 수 있습니다. cairo의 ERC-20의 경우,

balances::write(sender, balances::read(sender) - amount);
balances::write(recipient, balances::read(recipient) + amount);

위 부분의 코드를 통해 balances라는 mapping 스토리지 변수 상 보내는 자와 수령하는 자의 잔고에 대응하는 값을 수정하는 형태로 transfer가 일어납니다. 로직 자체는 이더리움의 ERC-20 토큰이 전송되는 것과 동일하다고 할 수 있습니다. 하지만 move의 경우,

let token = withdraw_token(from, id, amount);
direct_deposit(to, token);

위 부분의 코드를 통해 전송이 일어납니다. 입금하고 출금하는 행위가 각각 한줄의 코드에 대응하는 것은 동일하나, move의 경우 리소스라는 객체로 토큰/코인을 정의하고, 해당 객체에 대해 컴파일러 단에서 여러 동작에 대한 제약 조건을 걸어두는 형태로 구현되어 있습니다. 이렇듯 동일하게 Rust 기반으로 작성된 cairo와 move가 토큰에 대해 어떻게 정의하고 있고, 기존 블록체인 생태계에서 어떤 부분을 문제로 인식하고 해결하고자 하는지에 따라 동일한 ERC-20 토큰 또한 구현체가 달라짐을 확인할 수 있습니다. Move의 구조에 대해 추가적인 정보는 디사이퍼 미디움의 해당 아티클 등을 참고하시길 바랍니다.

전술한 바와 같이, Cairo 1.0 이후부터는 Cairo로 작성된 코드가 Sierra라는 중간 단계로 컴파일된 뒤 바이트 코드로 변환됩니다. 지금까지 설명드린 ERC-20 토큰 코드가 sierra로 컴파일된 형태는 여기에서 살펴보실 수 있습니다. 일부를 발췌하여 사진으로 첨부드립니다.

erc20.sierra

지금까지 Cairo 1.0 alpha.2 버전으로 작성된 ERC-20 코드를 살펴봤습니다. 아래에서는 구 Cairo 0.6.1 버전으로 오픈제플린에서 작성한 ERC-20 구현체를 방금 설명한 코드와의 차이점을 중심으로 살펴보겠습니다.

2.2.2. Cairo 0.6.1 Ver.

// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts for Cairo v0.6.1 (token/erc20/library.cairo)
%lang starknet

from starkware.starknet.common.syscalls import get_caller_address
from starkware.cairo.common.cairo_builtins import HashBuiltin
from starkware.cairo.common.math import assert_not_zero, assert_le
from starkware.cairo.common.bool import TRUE, FALSE
from starkware.cairo.common.uint256 import Uint256, uint256_check, uint256_eq, uint256_not
from openzeppelin.security.safemath.library import SafeUint256
from openzeppelin.utils.constants.library import UINT8_MAX
//
// Events
//
@event
func Transfer(from_: felt, to: felt, value: Uint256) {
}

@event
func Approval(owner: felt, spender: felt, value: Uint256) {
}

//
// Storage
//
@storage_var
func ERC20_name() -> (name: felt) {
}

@storage_var
func ERC20_symbol() -> (symbol: felt) {
}

@storage_var
func ERC20_decimals() -> (decimals: felt) {
}

@storage_var
func ERC20_total_supply() -> (total_supply: Uint256) {
}

@storage_var
func ERC20_balances(account: felt) -> (balance: Uint256) {
}

@storage_var
func ERC20_allowances(owner: felt, spender: felt) -> (remaining: Uint256) {
}

위 코드가 앞서 살펴본 모듈 정의에 해당합니다. 가장 윗 부분의 %lang starknet은 해당 파일이 cairo 프로그램 파일이 아닌 starknet 컨트랙트 파일임을 명시하고 있습니다. cairo 프로그램의 경우 스테이트가 존재하지 않지만(stateless) Starknet 컨트랙트의 경우 스토리지와 같이 스테이트를 가질 수 있기 때문에 파일을 첫 줄에 이와 같이 정의하고 있습니다. 실제로 컴파일할 때 사용하는 명령어도 cairo-compile이 아닌 starknet-compile을 사용합니다.

Mapping 스토리지 변수가 지원되지 않는 버전이기 때문에, ERC-20 토큰에서 필요한 정보들(name, symbol 등)을 각각 @storage_var 데코레이터와 함께 함수 형태로 정의한 것을 확인할 수 있습니다. 해당 데코레이터의 경우 해당 스토리지 변수에 대한 read, write 함수를 자동으로 생성하는 역할을 합니다. 각 타입에 대해 read, write를 생성하는 cairo 1.0 이후의 방식보다 엄밀성이 저하될 여지가 있다고 판단되는 측면입니다. 나아가 전반적인 추상화 정도가 Cairo 1.0보다 낮기 때문에 주소에 대해서도 별도의 ContractAddress와 같은 정의를 하지 않고 felt로 정의하고 있습니다.

위 구조의 경우 코드를 간결하게 작성하는 것이 불편한 것은 물론, Starknet의 메모리 모델을 고려했을 때 revoked reference 에러가 발생할 여지가 높고, 각 변수에 대한 접근이 불편하며, 바로 low-level 시스템 콜에 접근하는 경우가 잦다보니 디버깅이 어렵다는 단점이 있습니다.

namespace ERC20 {
//
// Initializer
//

func initializer{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}(
name: felt, symbol: felt, decimals: felt
) {
ERC20_name.write(name);
ERC20_symbol.write(symbol);
with_attr error_message("ERC20: decimals exceed 2^8") {
assert_le(decimals, UINT8_MAX);
}
ERC20_decimals.write(decimals);
return ();
}

위 코드는 앞서 살펴본 생성자에 해당합니다. 앞서 살펴본 Cairo 1.0 이후의 생성자의 경우 인자 값으로 ERC-20 토큰에 통상적으로 필요한 값들만 받았다면, 이 경우 syscall_ptr, pedersen_ptr, range_check_ptr와 같은 값들도 명시적으로 코드에 나타나 있습니다(Implicit Argument. 암묵적 인자). 해당 값들은 실제 함수에서 필요한지와 무관하게 스토리지 변수를 사용하는 경우 필수적으로 요구되고 있습니다. 각각이 하는 역할을 살펴보면, syscall_ptr는 해당 코드가 시스템 콜을 호출할 수 있도록 하고, pedersen_ptr는 페더슨 해시 함수에 대한 계산을 가능하게 만들어주며, range_check_ptr는 정수값 비교가 가능하게 만들어줍니다.

위 생성자의 경우 스토리지 변수에 값을 작성하는 것이 low-level 시스템 콜에 해당하는 바, syscall_ptr는 필요하겠으나, 그 외 페더슨 해시 함수 계산은 실질적으로 활용되지 않을 여지가 있습니다. 그러나 상기 언급드린 바와 같이 스토리지 변수를 활용하는 함수이기에 위 3개의 값을 필수적으로 요구하고 있습니다.

Cairo 1.0의 경우 위 값들을 이처럼 매번 제공할 필요가 없습니다. 필요하다고 판단되는 경우에 위와 같은 암묵적 인자를 판단하는 함수를 호출하는 방식으로 개발자 경험을 향상시키고 있습니다.

이외의 값을 작성하는 부분은 앞서 살펴본 name::write 등과 유사한 형태로 작성된 것을 확인할 수 있습니다.

func transfer_from{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}(
sender: felt, recipient: felt, amount: Uint256
) -> (success: felt) {
let (caller) = get_caller_address();
_spend_allowance(sender, caller, amount);
_transfer(sender, recipient, amount);
return (success=TRUE);
}

func _spend_allowance{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}(
owner: felt, spender: felt, amount: Uint256
) {
alloc_locals;
with_attr error_message("ERC20: amount is not a valid Uint256") {
uint256_check(amount); // almost surely not needed, might remove after confirmation
}
let (current_allowance: Uint256) = ERC20_allowances.read(owner, spender);
let (infinite: Uint256) = uint256_not(Uint256(0, 0));
let (is_infinite: felt) = uint256_eq(current_allowance, infinite);
if (is_infinite == FALSE) {
with_attr error_message("ERC20: insufficient allowance") {
let (new_allowance: Uint256) = SafeUint256.sub_le(current_allowance, amount);
}
_approve(owner, spender, new_allowance);
return ();
}
return ();
}

func _transfer{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}(
sender: felt, recipient: felt, amount: Uint256
) {
with_attr error_message("ERC20: amount is not a valid Uint256") {
uint256_check(amount); // almost surely not needed, might remove after confirmation
}
with_attr error_message("ERC20: cannot transfer from the zero address") {
assert_not_zero(sender);
}
with_attr error_message("ERC20: cannot transfer to the zero address") {
assert_not_zero(recipient);
}
let (sender_balance: Uint256) = ERC20_balances.read(account=sender);
with_attr error_message("ERC20: transfer amount exceeds balance") {
let (new_sender_balance: Uint256) = SafeUint256.sub_le(sender_balance, amount);
}
ERC20_balances.write(sender, new_sender_balance);
// add to recipient
let (recipient_balance: Uint256) = ERC20_balances.read(account=recipient);
// overflow is not possible because sum is guaranteed by mint to be less than total supply
let (new_recipient_balance: Uint256) = SafeUint256.add(recipient_balance, amount);
ERC20_balances.write(recipient, new_recipient_balance);
Transfer.emit(sender, recipient, amount);
return ();
}

위 코드는 앞서 살펴본 ERC-20 토큰을 전송하는 로직에 해당합니다. 이전에 살펴본 Cairo 1.0으로 작성된 코드와 유사하게, transfer_from 함수에서 _spend_allowance_transfer 함수를 차례로 호출하고 있습니다.

코드의 가독성이 이전에 비해 안 좋은 이유는 여러가지가 있습니다. 우선 (1) 앞서 언급했던 암묵적 인자(implicit argument)에 대한 부분이 스토리지 변수를 다루는 함수에 대해 모두 포함되어야 하는 점 (2) assert 문과 같은 매크로 대신 with_attr error_message() 형태를 활용하고 있는 점 (3) 기본 자료형이 252 bytes인 felt를 사용하는 cairo의 특성상 uint256 등과 같은 자료형에 대해 별도의 정의가 필요한데, 해당 부분에 대한 추상화 부족으로 uint256 등의 타 언어의 기본 자료형을 다룰 때 고려해야 하는 지점들을 하나하나 직접 확인해야 하는 점 등을 들 수 있겠습니다.

추가적으로 _spend_allowance 함수의 첫 줄에 있는 alloc_locals에 대해 살펴보겠습니다. 이는 기존 cairo에서 allocation pointer(ap)function pointer(fp)를 별도로 활용하는 방식에 따라 필요한 커맨드입니다. ap는 아직 사용되지 않은 다음 메모리 셀을 바라보고 있고, fp는 함수의 시작점을 바라보고 있습니다. 따라서 함수가 처음 호출되었을 때 apfp는 같은 곳을 바라보고 있지만, 함수가 실행됨에 따라 서로 바라보고 있는 메모리 셀이 달라지게 됩니다. 모든 변수를 fp로 선언하는 것은 사실상 모든 변수를 전역 변수로 선언하는 것이기 때문에 전역이 아닌 변수에 대한 처리를 진행할 필요가 있습니다. 이때 함수 내에서 선언하는 로컬 변수의 개수를 알아야만 ap를 적절한 위치로 이동시킬 수 있습니다. ap는 “아직 사용되지 않은” 메모리 셀을 바라보고 있어야 하는데, 만일 로컬 변수의 개수를 알지 못한다면 로컬 변수를 어디에 저장할지 판단할 수 없기 때문입니다.

따라서 alloc_locals 키워드를 통해 함수 내에 존재하는 로컬 변수의 총 개수를 산출하고, ap로컬 변수의 개수 * 로컬 변수의 크기만큼을 더해 ap가 아직 사용되지 않은 메모리를 바라보게 합니다. Cairo의 메모리는 Immutable하기에 재할당이 불가능하고, cairo 프로그램에서 메모리 셀은 시작부터 끝까지 ‘연속적’으로 사용되어야 하기 때문에(unused cell을 활용하는 방식은 제외) 이러한 alloc_locals 키워드가 활용되었다고 생각할 수 있겠습니다. Cairo 1.0 이후부터는 이러한 변수 타입의 복잡성을 추상화했습니다.

2.2. ERC-721

지금까지 ERC-20 표준을 Cairo 1.0 alpha.2 버전 및 0.6.1 버전으로 살펴보았습니다. ERC-721의 경우 본 글이 작성되는 시점에 Starkware 공식 깃헙에서 Cairo 1.0 alpha.2 버전으로 작성한 표준 구현체가 존재하지 않습니다. 따라서, 오픈제플린에서 구 Cairo 0.6.1 버전으로 구현한 ERC-721 표준을 간략하게 살펴보겠습니다. 추가적인 논의는 필요하겠지만, 위 ERC-20 표준 구현체 간 차이가 유사하게 나타날 것으로 사료됩니다.

// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts for Cairo v0.6.1 (token/erc721/library.cairo)
%lang starknet
from starkware.cairo.common.cairo_builtins import HashBuiltin
from starkware.starknet.common.syscalls import get_caller_address
from starkware.cairo.common.math import assert_not_zero, assert_not_equal
from starkware.cairo.common.bool import TRUE, FALSE
from starkware.cairo.common.uint256 import Uint256, uint256_check
from openzeppelin.introspection.erc165.library import ERC165
from openzeppelin.introspection.erc165.IERC165 import IERC165
from openzeppelin.security.safemath.library import SafeUint256
from openzeppelin.token.erc721.IERC721Receiver import IERC721Receiver
from openzeppelin.utils.constants.library import (
IERC721_ID,
IERC721_METADATA_ID,
IERC721_RECEIVER_ID,
IACCOUNT_ID,
)

//
// Events
//
@event
func Transfer(from_: felt, to: felt, tokenId: Uint256) {
}

@event
func Approval(owner: felt, approved: felt, tokenId: Uint256) {
}

@event
func ApprovalForAll(owner: felt, operator: felt, approved: felt) {
}

해당 부분은 ERC-721의 모듈 정의에 해당합니다. 편의상 stoarge_var 정의부분은 생략했습니다.

namespace ERC721 {
//
// Constructor
//
func initializer{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}(
name: felt, symbol: felt
) {
ERC721_name.write(name);
ERC721_symbol.write(symbol);
ERC165.register_interface(IERC721_ID);
ERC165.register_interface(IERC721_METADATA_ID);
return ();
}

위 부분은 생성자에 해당합니다. ERC-165의 경우 표준 인터페이스에 대한 구현 여부를 확인하는 표준입니다. 해당 표준을 통해 특정 컨트랙트가 특정 인터페이스를 구현했는지 확인할 수 있습니다. ERC-165에 따르면, 함수의 시그니처를 해싱(keccak256)하고, 인터페이스에 구현된 함수 시그니처 해싱값들에 XOR을 적용하는 방식으로 인터페이스에 대한 시그니처를 생성합니다. 위 생성자는 ERC-721 인터페이스를 등록하고 있습니다.


func transfer_from{pedersen_ptr: HashBuiltin*, syscall_ptr: felt*, range_check_ptr}(
from_: felt, to: felt, token_id: Uint256
) {
alloc_locals;
with_attr error_message("ERC721: token_id is not a valid Uint256") {
uint256_check(token_id);
}
let (caller) = get_caller_address();
let is_approved = _is_approved_or_owner(caller, token_id);
with_attr error_message(
"ERC721: either is not approved or the caller is the zero address") {
assert_not_zero(caller * is_approved);
}
_transfer(from_, to, token_id);
return ();
}

func _is_approved_or_owner{pedersen_ptr: HashBuiltin*, syscall_ptr: felt*, range_check_ptr}(
spender: felt, token_id: Uint256
) -> felt {
alloc_locals;
let exists = _exists(token_id);
with_attr error_message("ERC721: token id does not exist") {
assert exists = TRUE;
}
let (owner) = owner_of(token_id);
if (owner == spender) {
return TRUE;
}
let (approved_addr) = get_approved(token_id);
if (approved_addr == spender) {
return TRUE;
}
let (is_operator) = is_approved_for_all(owner, spender);
if (is_operator == TRUE) {
return TRUE;
}
return FALSE;
}

func _transfer{syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, range_check_ptr}(
from_: felt, to: felt, token_id: Uint256
) {
// ownerOf ensures 'from_' is not the zero address
let (owner) = owner_of(token_id);
with_attr error_message("ERC721: transfer from incorrect owner") {
assert owner = from_;
}
with_attr error_message("ERC721: cannot transfer to the zero address") {
assert_not_zero(to);
}
// Clear approvals
_approve(0, token_id);
// Decrease owner balance
let (owner_bal) = ERC721_balances.read(from_);
let (new_balance: Uint256) = SafeUint256.sub_le(owner_bal, Uint256(1, 0));
ERC721_balances.write(from_, new_balance);
// Increase receiver balance
let (receiver_bal) = ERC721_balances.read(to);
let (new_balance: Uint256) = SafeUint256.add(receiver_bal, Uint256(1, 0));
ERC721_balances.write(to, new_balance);
// Update token_id owner
ERC721_owners.write(token_id, to);
Transfer.emit(from_, to, token_id);
return ();
}

transfer_from의 경우 _is_approved_or_owner_transfer 함수를 순차적으로 호출하고 있습니다. 기본적인 로직은 ERC-20에서 전송하던 로직과 동일하며, 문법 또한 위 내용을 참고하시면 기존 이더리움의 ERC-721 전송 로직과 동일한 것을 확인할 수 있습니다.

3. What’s Next?

지금까지 이더리움 생태계에서 가장 흔히 사용되는 표준인 ERC-20과 ERC-721이 Cairo로 구현되는 경우 어떤 식으로 구현되는지, 그리고 Cairo라는 언어가 어떻게 변화하고 있는지 실제 구현체를 바탕으로 통시적인 관점에서 살펴보았습니다. 이외에도 Cairo로 작성된 컨트랙트가 어떻게 Starknet에 배포되는지, 배포된 컨트랙트와 어떤 식으로 상호작용할 수 있는지 등에 대해서 이론적인 측면은 앞선 시리즈를 통해 설명드렸습니다. 그렇다면, 실제로 Starknet에 Cairo로 작성된 컨트랙트를 배포하기 위해서는 어떻게 해야 할까요?

Cairo 1.0으로 작성된 컨트랙트의 배포와 관련해서는 2월 23일에 업데이트 예정인 Starknet v0.11.0의 버전노트를 살펴보아야 합니다. (2월 23일 예정이었으나, 해당 일자에 StarkWare에서 발표한 Cairo 1.0 alpha.3 미디움에 따르면 약 2주 내로 지원될 예정이라고 합니다.) 해당 환경의 경우 2월 중순에 업데이트, 3월부터 테스트넷 지원이 예정되어 있기에, 본 글이 퍼블리시되는 시점에서 실제 배포 과정을 온전히 설명드릴 수 없는 점 양해 부탁드립니다. 추후 업데이트에 따라 새로운 글로 공유드리도록 하겠습니다.

Starknet Regenesis

위 Starknet v0.11.0은 기존 Starknet에 주요한 부분을 변경하는 업데이트 노트로, 위 타임라인의 ‘Transition Period’에 해당합니다. 새롭게 출시된 Cairo 1.0으로 작성된 컨트랙트는 위 Starknet v0.11.0 출시일부터 Starknet 상에 배포가 가능하며, 추후 Regenesis를 거쳐 구 버전의 Cairo로 작성된 컨트랙트는 더이상 지원되지 않고, Cairo 1.0으로 작성된 컨트랙트만 Starknet 상에 존재하게 됩니다.

Starknet alpha v0.11.0에 대해 보다 구체적으로 알아보도록 하겠습니다. 앞서 말씀드린 바와 같이, Cairo 1.0으로 작성된 컨트랙트가 실제 Starknet에 배포 가능한 환경이기도 하고, 그 외 여러 특징에 대해 Starknet 팀에서도 중요한 업데이트 사항으로 간주하고 있기 때문입니다.

해당 버전의 Starknet은 Cairo 1.0으로 작성된 컨트랙트를 배포할 수 있는 환경입니다. 새로운 컨트랙트를 배포함과 동시에, 구 버전의 컨트랙트와의 호환성 역시 유지되어야 하고, 종국적으로는 Cairo 1.0으로 마이그레이션되어야 하기에 양자 간 전환을 도와주는 새로운 시스템 콜 역시 추가될 예정입니다. Cairo 및 Starknet의 트랜잭션 플로우에 대해 이전 글에서 살펴보았던 바, 본 글에서는 Starknet 버전 업데이트에 따라 새롭게 추가되는 트랜잭션만 간략하게 살펴보도록 하겠습니다.

Starknet v0.9 업데이트를 통해 컨트랙트 클래스와 인스턴스가 새롭게 추가되었습니다. 컨트랙트 클래스는 해시값을 통해 구분되는, 컨트랙트의 바이트코드 등 필요한 정보에 대한 정의를 포함하고 있습니다. 인스턴스는 이러한 클래스에 대응하는 컨트랙트가 배포된 객체를 의미합니다. 즉, 컨트랙트 클래스는 Cairo 어셈블리(Casm)으로 정의되었다고 볼 수 있겠습니다. 하지만 전술한 것처럼 Cairo 1.0 출시에 따른 주요한 업데이트가 바로 Cairo와 Casm 사이 중간 레이어인 Sierra의 등장입니다. Sierra의 역할은 assert 인스트럭션을 활용해 모든 컨트랙트의 실행이 증명 가능하도록 만드는 것입니다. 구 Cairo 버전이 동시에 존재하는, Regenesis 이전의 Starknet 버전에서 (1) 이전 Cairo로 작성 또한 증명 가능하도록 만들고 (2) Sierra라는 중간 레이어를 필수적으로 거치게 만들기 위해서는 모든 컨트랙트 클래스가 Sierra에 기반한 새로운 구조가 네트워크에 전송되고, 시퀀서가 해당 구조를 Casm으로 컴파일되는 양상으로 변화해야 합니다. Starknet 팀은 이러한 구조가 최종적으로는 Starknet OS 내에서 일어나야 하지만, 전환기에는 유저가 이러한 컴파일 과정을 진행하고, 상응하는 Casm 해시값을 로컬에서 생성, DECLARE 트랜잭션에 사인하는 형태로 일어날 것이라고 합니다. 나아가 컨트랙트 클래스의 마이그레이션이 필요하므로 replace_class(class_hash: felt)라는 시스템 콜이 추가될 예정입니다.

이렇게 Starknet이 앞으로 어떻게 변화할 예정인지 업데이트 노트를 기반으로 방향성을 살펴보았습니다.

마지막으로, Starknet에 Cairo로 작성된 코드를 실제로 배포하고, 작동시켜보고 싶으신 분들은 Remix와 유사한 웹 기반 IDE인 Cairo playground를 추천드립니다. CLI(protostar, nile 등)를 통해 진행해보실 수도 있습니다. 다만 Starknet팀의 업데이트 속도가 빠르다보니, 양자 모두 cairo/starknet 버전 업데이트 여부를 확인하시는 편을 추천드립니다.

cairo playground에 접속하는 경우 보이는 화면. challenge 등을 풀이하며 튜토리얼을 진행할 수도 있습니다.

--

--