CosmWasm 컨트랙트 개발 시 전략과 신경 쓰면 좋은 점

2023. 11. 1. 10:45Blockchain/Smart contract

728x90
반응형

필자도 다양한 컨트랙트를 개발해 봤고, 서비스에도 적용해 봤지만 아직 많이 부족하다는 것을 안다. 그래서 항상 개발 중간중간에 제대로 개발했는지 확인하고, 나름대로 정한 전략에 맞는 개발을 하는지 확인 하는 습관을 가지려고 노력한다.

아래 글은 어느정도 개발 수준의 레벨에서는 당연한 말들이지만, 컨트랙트 개발 입문 단계의 레벨에서 지속적으로 신경 써서 개발하면 좋을 것 같은 항목들을 정리한 내용이다. 개인적인 생각이기 때문에 다른 사람들과 맞지 않을 수 있지만 공유해보면 좋을거 같아서 글을 작성한다.

1. 실행 주체가 누군지 명확히

컨트랙트를 execute하는 행위는 가장 기본적인 행위이다. 이 때 execute 트랜잭션 메시지를 보내는 행위자가 누군지 명확히 생각하며 개발하는 것이 가장 중요하다. 실행자는 컨트랙트 소유자가 될 수도 있고, 일반 사용자가 될 수도 있고, 다른 컨트랙트가 될 수도 있다.

메시지로 실행하는 함수가 아무나 실행해도 상관없는 것이면 괜찮지만 권한이 부여된 사람만 실행할 수 있다면 가장 먼저 처리해야 될 부분이 '실행 권한' 판별이다. CW721을 예로 들어본다. 해당 컨트랙트는 각 함수들을 실행할 수 있는 권한을 가진 사람이 명확히 구분된다. 먼저 컨트랙트 소유자(=minter)가 있을것이다.

pub fn instantiate(
    &self,
    deps: DepsMut,
    _env: Env,
    _info: MessageInfo,
    msg: InstantiateMsg,
) -> StdResult<Response<C>> {
    set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;

    let info = ContractInfoResponse {
        name: msg.name,
        symbol: msg.symbol,
    };
    self.contract_info.save(deps.storage, &info)?;
    let minter = deps.api.addr_validate(&msg.minter)?;
    self.minter.save(deps.storage, &minter)?;
    Ok(Response::default())
}

위 instantiate 함수를 보면 msg 내부에 minter가 존재한다. 이 minter를 state DB에 저장함으로써 mintburn 등의 컨트랙트 소유자만 실행해야 하는 기능들을 구분 지을 수 있다. 이렇게 따로 instantiate 메시지 안에 account address를 넣어서 등록할 수도 있지만, instantiate 메시지를 전송한 계정 주소를 바로 minter로 지정할 수 있을 것이다.

let minter = info.sender;

이렇게 지정했으면 컨트랙트 소유자만 실행해야 하는 함수에는 가장 상단에 실행 권한에 대한 로직을 추가해 놓고 시작하는 것이 좋다. CW721의 mint 함수는 아래와 같이 작성되어 있다.

pub fn mint(
    &self,
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
    msg: MintMsg<T>,
) -> Result<Response<C>, ContractError> {
    let minter = self.minter.load(deps.storage)?;

    if info.sender != minter {
        return Err(ContractError::Unauthorized {});
    }

    ...

}

이렇게 메시지를 전송한 주체가 state에 저장되어 있는 minter가 아니면 바로 실행 권한 불가 에러와 함께 리턴시켜 버린다. 그래서 필자는 항상 개발할 때 실행 권한 확인 함수부터 작성하고 시작한다.

pub fn check_owner(&self, deps: Deps, info: &MessageInfo) -> Result<bool, ContractError> {
    let minter = self.config.load(deps.storage)?.owner;
    if info.sender != minter {
        return Err(ContractError::Unauthorized { error: "invalid owner of the contract".to_string() })
    }

    Ok(true)
}

그리고 함수 상단에 먼저 박아놓고 개발을 시작한다.

pub fn _set_config(
    &self,
    deps: DepsMut,
    info: &MessageInfo,
    _env: &Env,
    msg: SetConfigMsg,
) -> Result<(), ContractError> {

    self.check_owner(deps.as_ref(), &info)?;

    ...

}

컨트랙트 소유자가 아닌 일반 사용자도 적절한 권한 부여를 실행해야 한다. CW721의 transferNftsendNft 등 token ID의 핸들링 권한이 있는 사용자가 보낸 트랜잭션 메시지만 처리할 수 있도록 pub fn check_can_send() 함수를 활용한다. 우리가 개발할 때도 실행시킬 권한이 있는 사용자만 실행하도록 메시지를 보낸 주체가 실행 접근 조건에 부합하는지 먼저 확인하는 로직을 추가하는 습관이 있으면 좋을 것 같다.

가장 헷갈리는 상황은 다른 컨트랙트에서 보내는 메시지를 처리할 때다. 이 때는 항상 메시지를 보낸 주체가 누군지 신경써야 하는데, 예를 들어서 설명해본다.

A, B 컨트랙트가 있고 USER라는 account가 있을 때, A의 특정 함수를 실행하면 B로 메시지를 전송하는 로직이 있다고 가정하자. USER가 A를 실행하면 A에서 확인되는 info.sender는 USER이다. 하지만 B로 메시지가 전달되었을 때, B에서 확인되는 info.sender는 A이다. 따라서 USER의 주소를 보고 B에서 로직을 처리해야 하는 부분이 있을 때에는 A에서 B로 전달하는 메시지에 해당 account의 주소를 포함시키는 메시지로 개발해야 한다.

단순하게 2개의 컨트랙트 간 상호 작용시는 어렵지 않으나, 대여섯개가 넘어가는 컨트랙트 들이 서로 execute/query를 실행할 때 자칫하면 예상한 info.sender와 다른 메시지 sender를 받을 수 있기 때문에 항상 메시지를 보내는 실행 주체가 누군지 파악하는 습관을 가지면 좋다.

2. 차근차근 예외처리

컨트랙트는 결국 요청한 데이터를 저장하고, 요청한 메시지에 맞는 데이터를 조회하는 역할이다. 그게 다다. 그렇기 때문에 어떤 데이터들을 저장할지 필터링 하는 것이 중요하며, 이것이 컨트랙트 비즈니스 로직이 된다.

예를 들어, 특정 컨트랙트가 네이티브 토큰을 funding 받고 이를 withdraw 해야하는 기능이 필요하다고 하자. 실행 권한이 있는지 체크하고, 현재 컨트랙트의 denom 밸런스가 존재하는지 체크하고, 인출을 요청한 금액만큼 부족하진 않은지 체크하고, 인출 주소가 설정되어 있는지 체크하면 BankSendMsg로 요청한 amount만큼의 전송메시지 생성 -> tx 생성 -> 블록체인 기록. 끝이다.

컨트랙트 개발은 그리 어렵지 않으며 자신이 생각한 데이터만 저장할 수 있도록 로직을 구현해서 컨트랙트를 개발하면 된다. 일단 기능 개발 먼저 해보고 리팩토링, 보안성 등을 고려하며 개발하는 것이 실력을 키우는 하나의 길이라고 생각한다.

3. ResponseCosmosMsg 꼭 포함

컨트랙트 간의 통신 데이터 교환을 위해 CosmWasm에는 CosmosMsg가 존재한다. 코인을 전송하는 bank send 메시지를 예시로 들어본다.

pub fn execute_send_coin(
    &self,
    recipient: String,
    amount: Uint128,
    denom: String,
) -> CosmosMsg {
    let message: CosmosMsg = CosmosMsg::Bank(BankMsg::Send {
        to_address: recipient,
        amount: coins(amount.u128(), denom.clone())
    });

    return message
}

이러한 메시지는 CosmosMsg 안에 다양한 기능들이 존재한다. 목적에 맞는 메시지를 생성하면 실행을 해야 하는데, 최종적으로 실행은 Response에 메시지가 추가 되어야만 한다. 그래서 execute 함수들은 Response를 가지고 있어야 한다.

Response의 add_message()에 생성한 CosmosMsg를 입력해야 하며, 만약 복수의 메시지가 존재한다면 add_messages()Vec<CosmosMsg>를 추가하면 된다. 로직을 구현하고 동작 시킬 때 다음 컨트랙트가 실행이 안된다면 보통 CosmosMsgResponse에 포함이 되지 않은 경우가 많으므로 항상 메시지가 포함되어 있는지 확인하는 습관을 가지면 좋을 것이다.

4. Amount 계산은 Uint128

cosmwasm_std 라이브러리에는 Uint128이 존재한다. 또한 Uint256도 존재한다. 컨트랙트를 로직 내에는 네이티브 토큰이던지 cw20 토큰이던지 amount를 계산해야 하는 로직이 많이 들어갈텐데, 계산을 일반 u128로 사용하지 말고 Uint128로 사용하도록 권장한다.

u128이나 String 변환도 제공할 뿐만 아니라, 단위가 큰 amount들에 대한 계산 시 overflow가 일어날 수 있으므로 check_add() 등 계산식에 대한 변수를 제거할 수 있는 기능을 가지고 있기 때문에 컨트랙트 내에서 계산은 Uint128로 실행하도록 하자.

5. Query 리턴은 error로 할지, empty로 할지

여러 컨트랙트가 유기적으로 동작하는 시스템 상에서는 한 컨트랙트에서 다른 컨트랙트에 현재 상태를 조회하여 가져온 값을 사용해야 할 때가 있다. CW721의 query 함수를 예로 들어본다.

fn contract_info(&self, deps: Deps) -> StdResult<ContractInfoResponse> {
    self.contract_info.load(deps.storage)
}

Query의 리턴 타입은 StdResult이다 이것은 Result<T, StdError>로써 오류가 발생 시 에러로 리턴한다는 뜻이다.

만약 특정 컨트랙트에서 쿼리를 하여 로직을 처리해야 하고, 이 쿼리값이 그렇게 중요하지 않거나 예외 처리로 지나가야 하는 부분이라면 쿼리 할 때 에러가 발생하면 전체 동작에 오류가 발생한다. 그렇기 때문에 다른 컨트랙트에서 사용할 쿼리라면 에러처리 말고 빈값으로 전송하는 것이 좋다. 예를 들어 본다.

pub struct StateResponse {
    pub str: String,
    pub account_address: Addr,
    pub expiration_time: Expiration,
    pub timestamp: Timestamp,
    pub option_address: Option<Addr>,
}

impl StateResponse {
    pub fn new(
        str: String,
        account_address: Addr,
        expiration_time: Expiration,
        timestamp: Timestamp,
        option_address: Option<Addr>
    ) -> Self {
        StateResponse { 
            str,
            account_address, 
            expiration_time, 
            timestamp,
            option_address,
        }
    }

    pub fn empty() -> Self {
        StateResponse {
            str: "".to_string(),
            account_address: Addr::unchecked(""), 
            expiration_time: Expiration::Never {}, 
            timestamp: Timestamp::from_seconds(0),
            option_address: None,
        }
    }
}

이런 식으로 특정 응답메시지 타입에 empty()를 두어서 쿼리 에러가 나면 안되는 상황에서는 빈값을 전송하는 방법을 사용한다.

6. Option을 잘 활용하자

바로 위의 예시처럼 빈값을 사용할 때 다른 파라미터들은 선언하기 위해서 귀찮은 과정이 필요하지만 option_address: None으로 선언한 것처럼 Option을 사용하면 더 복잡하게 정의하지 않아도 빈값을 리턴할 수 있다. 또한 execute/query 메시지 전송 시에 필수값이 아닌 옵셔널 값에 대해서는 모두 Option 처리를 하도록 하자. 예를 들어 본다.

pub struct msg {
    pub mandatory_param: String,
    pub optional_param: Option<String>,
}

으로 선언한다면 아래와 같이 JSON 형태의 메시지로 전송할 때 옵셔널 값은 제외하고 보내도 되기 때문에 중요하지 않은 파라미터에 대해서는 Option으로 선언하는 것이 좋다.

// 옵션이 들어갈 때
{
    "mandatory_param":"abc",
    "optional_param":"123"
}

// 옵션이 들어가지 않을 때
{
    "mandatory_param":"abc"
}

그리고, Option 파라미터 들은 .unwrap() 시에 None이면 에러를 리턴하니 에러 처리가 필요한 부분에서는 .is_none()이나 .is_some()을 사용하여 값이 있는지 확인하고 로직 처리를 하면 좋다.

7. 컨트랙트가 크면 나누자

Cosmos는 트랜잭션 최대 사이즈 설정값이 존재하기 때문에 너무 큰 컨트랙트는 배포 되지 않는다. Rust optimizer를 사용해도 그 한계는 분명히 있다. 사이즈가 클것이라 예상된다면 미리 분리해서 개발을 진행하는 것이 좋다.

CosmWasm은 컨트랙트간 연동을 위한 라이브러리가 잘되어 있으므로 그렇게 부담스럽지 않게 연결할 수 있을 것이다. 다만 앞서 언급했던것처럼 Response에 메시지 꼭 포함하고, 실행 주체가 누군지 명확히 설정하여 개발해야 할 것이다.

또한, 비즈니스 로직을 구현하는 컨트랙트와 데이터를 저장하는 컨트랙트를 분리하면 더욱 이점이 있다. 만약 사소한 로직 오류가 있어서 코드 수정이 필요할 때, 통짜로 구현되어 있는 컨트랙트는 전체를 다 들어내서 마이그레이션 해야하지만 로직 전담 컨트랙트로 존재한다면 해당 컨트랙트만 수정하고, 데이터 저장 컨트랙트는 그대로 유지 할 수 있으니 유지 보수에도 더 용이하다.  

8. Receive, ReceiveNft 선언

한 컨트랙트가 다른 컨트랙트의 cw20 토큰이나 NFT token ID를 받아야 하는 상황이 있을 때에는 반드시 Execute 메시지에 Receive 또는 ReceiveNft 메시지를 선언해야 한다.

표준화 되어 있으므로 다른 컨트랙트에 Send 또는 SendNft로 전송하면 receive 메시지를 포함하여 수신 컨트랙트를 호출하기 때문에 받는쪽 컨트랙트에서 무조건 메시지 인입을 선언하고 로직을 처리해야 한다. 그렇지 않다면 전송하는 컨트랙트에서부터 'unknown variant receive_nft, expected one of

~

'라는 에러메시지가 뜨기 때문에 이것 또한 신경써야 한다.

9. 메시지 이름은 쉽게

컨트랙트 메시지는 쉽게 선언되는것이 좋다. CW721의 execute 메시지를 본다.

pub fn execute(
    &self,
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    msg: ExecuteMsg<T, E>,
) -> Result<Response<C>, ContractError> {
    match msg {
        ExecuteMsg::Mint(msg) => self.mint(deps, env, info, msg),
        ExecuteMsg::Approve {
            spender,
            token_id,
            expires,
        } => self.approve(deps, env, info, spender, token_id, expires),
        ExecuteMsg::Revoke { spender, token_id } => {
            self.revoke(deps, env, info, spender, token_id)
        }
        ExecuteMsg::ApproveAll { operator, expires } => {
            self.approve_all(deps, env, info, operator, expires)
        }
        ExecuteMsg::RevokeAll { operator } => self.revoke_all(deps, env, info, operator),
        ExecuteMsg::TransferNft {
            recipient,
            token_id,
        } => self.transfer_nft(deps, env, info, recipient, token_id),
        ExecuteMsg::SendNft {
            contract,
            token_id,
            msg,
        } => self.send_nft(deps, env, info, contract, token_id, msg),
        ExecuteMsg::Burn { token_id } => self.burn(deps, env, info, token_id),
        ExecuteMsg::Extension { msg: _ } => Ok(Response::default()),
    }
}

여기서 보이는 Mint, ApproveAll 등이 execute 메시지이며 이를 json으로 요청할 때는 아래와 같다.

{
    "mint":{ ~~ }
}

{
    "approve_all":{ ~~ }
}

이름이 길면 서버나 게이트웨이에서 처리하는 것이야 쉽지만 CLI로 수행한다면 사용자 입장에서 메시지 적어넣기가 귀찮다. 간단하게 줄여도 의미는 다 통하므로 사용자를 위해서 줄이는게 좋을듯 하다. 물론 사용자가 아닌 다른 컨트랙트가 실행하는 함수는 굳이 짧게 안해도 괜찮을것이다.

10. Test 코드 활용

CosmWasm은 테스트코드 작성을 쉽게 하도록 지원한다. mock_dependencies()mock_info()가 그 예시다. 코드 수정 때마다 Rust 컴파일 기다리기도 힘들고 store, instantiate 하기도 귀찮을테니 코드 개발 시에 테스트 코드를 사용하는 것이 좋다.

먼저 필자는 테스트코드 시작 시에 deps, info, env를 생성하는 것을 먼저 한다.

pub fn test_environment() -> (
    OwnedDeps<MemoryStorage, MockApi, MockQuerier>,
    MessageInfo,
    Env,
    Addr,
) {
    let deps = mock_dependencies();
    let info = mock_info(OWNER, &[]);
    let env = mock_env();
    let addr = deps.api.addr_validate(OWNER).unwrap();

    return (deps, info, env, addr)
}

이후에 테스트 코드를 짤 때에 반환받은 값을 사용하면 된다. mock_infomock_env는 아래와 같다.

pub fn mock_env() -> Env {
    Env {
        block: BlockInfo {
            height: 12_345,
            time: Timestamp::from_nanos(1_571_797_419_879_305_533),
            chain_id: "cosmos-testnet-14002".to_string(),
        },
        transaction: Some(TransactionInfo { index: 3 }),
        contract: ContractInfo {
            address: Addr::unchecked(MOCK_CONTRACT_ADDR),
        },
    }
}

pub fn mock_info(sender: &str, funds: &[Coin]) -> MessageInfo {
    MessageInfo {
        sender: Addr::unchecked(sender),
        funds: funds.to_vec(),
    }
}

로직 상에서 Expiration 등에 활용할 목적으로, 시간이 흐르거나 블록 생성이 지나가야 한다면 새롭게 Env를 생성해서 height와 time을 조절해주면 된다. 메시지 sender가 변경이 필요하거나 컨트랙트에 funding이 필요하다면 새로운 MessageInfo를 생성해서 컨트랙트 동작에 넣어주면 된다.

만약 다른 컨트랙트와의 상호작용이 필요할 때는? 체인에 배포하면 컨트랙트 주소를 통해 알아서 통신 하겠지만 테스트 코드다 보니 그러한 동작은 무리가 있다. 따라서 다른 컨트랙트에서 메시지를 수신할 때 예상되는 동작을 미리 구현해 놓는다. 아래 cosmwasm_std의 코드를 보자.

querier.update_handler(|request| {
    let constract1 = Addr::unchecked("contract1");
    let mut storage1 = HashMap::<Binary, Binary>::default();
    storage1.insert(b"the key".into(), b"the value".into());

    match request {
        WasmQuery::Raw { contract_addr, key } => {
            if *contract_addr == constract1 {
                if let Some(value) = storage1.get(key) {
                    SystemResult::Ok(ContractResult::Ok(value.clone()))
                } else {
                    SystemResult::Ok(ContractResult::Ok(Binary::default()))
                }
            } else {
                SystemResult::Err(SystemError::NoSuchContract {
                    addr: contract_addr.clone(),
                })
            }
        }
        WasmQuery::Smart { contract_addr, msg } => {
            if *contract_addr == constract1 {
                #[derive(Deserialize)]
                struct MyMsg {}
                let _msg: MyMsg = match from_binary(msg) {
                    Ok(msg) => msg,
                    Err(err) => {
                        return SystemResult::Ok(ContractResult::Err(err.to_string()))
                    }
                };
                let response: Response = Response::new().set_data(b"good");
                SystemResult::Ok(ContractResult::Ok(to_binary(&response).unwrap()))
            } else {
                SystemResult::Err(SystemError::NoSuchContract {
                    addr: contract_addr.clone(),
                })
            }
        }
        WasmQuery::ContractInfo { contract_addr } => {
            if *contract_addr == constract1 {
                let response = ContractInfoResponse {
                    code_id: 4,
                    creator: "lalala".into(),
                    admin: None,
                    pinned: false,
                    ibc_port: None,
                };
                SystemResult::Ok(ContractResult::Ok(to_binary(&response).unwrap()))
            } else {
                SystemResult::Err(SystemError::NoSuchContract {
                    addr: contract_addr.clone(),
                })
            }
        }
        #[cfg(feature = "cosmwasm_1_2")]
        WasmQuery::CodeInfo { code_id } => {
            use crate::{CodeInfoResponse, HexBinary};
            let code_id = *code_id;
            if code_id == 4 {
                let response = CodeInfoResponse {
                    code_id,
                    creator: "lalala".into(),
                    checksum: HexBinary::from_hex(
                        "84cf20810fd429caf58898c3210fcb71759a27becddae08dbde8668ea2f4725d",
                    )
                    .unwrap(),
                };
                SystemResult::Ok(ContractResult::Ok(to_binary(&response).unwrap()))
            } else {
                SystemResult::Err(SystemError::NoSuchCode { code_id })
            }
        }
    }
});

Deps 내에 있는 Querier에서 update_handler()를 활용한다. 이 핸들러로 들어오는 요청 컨트랙트 주소에 따라 분기처리릃 하고, 또 다시 인입되는 메시지를 match로 분류한다. 그렇게 하면 응답이 필요한 형태로 미리 값을 리턴하도록 하고, 테스트 코드 상에서 리턴 받은 데이터를 이용해서 로직을 구현하면 된다.

Conclusion

더 많이 신경써야 할 부분들이 존재하지만 CosmWasm으로 컨트랙트를 개발할 때의 전략 들 중 간단한 10가지만 정리해 보았다. 사실 필자의 이 전략들은 별 건아니고, 중간에 수정하기 귀찮은 항목들이나, 까먹기 쉬운 항목들에 대해서 정리를 해놓은 것에 불과하다. 컨트랙트 개발 시에 신경써야 할 부분은 이보다 더 복잡하다. 하지만, 나름대로 자신만의 전략을 세우고 개발을 하면 시간 단축과 코드 퀄리티가 올라 갈 수 있을 것이다.

728x90
반응형