FunC에서 안전한 스마트 컨트랙트 프로그래밍
이 섹션에서는 TON 블록체인의 흥미로운 특징들과 FunC에서 스마트 컨트랙트를 프로그래밍할 때 개발자들이 따라야 할 모범 사례들을 살펴보겠습니다.
컨트랙트 샤딩
EVM 컨트랙트를 개발할 때는 편의성을 위해 프로젝트를 여러 컨트랙트로 나누는 것이 일반적입니다. 일부 경우에는 하나의 컨트랙트에서 모든 기능을 구현할 수 있고, 컨트랙트 분할이 필요한 경우에도(예: 자동화된 마켓 메이커의 유동성 페어) 특별한 어려움이 없었습니다. 트랜잭션은 전체적으로 실행됩니다: 모든 것이 성공하거나 모든 것이 되돌려집니다.
TON에서는 "제한 없는 데이터 구조"를 피하고 하나의 논리적 컨트랙트를 작은 조각들로 분할하는 것이 강력히 권장됩니다. 각 조각은 소량의 데이터만 관리합니다. 기본적인 예시는 TON Jettons의 구현입니다. 이는 이더리움의 ERC-20 토큰 표준의 TON 버전입니다. 간단히 말하면:
total_supply
,minter_address
, 그리고 토큰 설명(메타데이터)와jetton_wallet_code
라는 몇 개의 참조를 저장하는 하나의jetton-minter
- jetton 소유자마다 하나씩 있는 많은 jetton-wallet. 각 지갑은 소유자의 주소, 잔액, jetton-minter 주소, jetton_wallet_code에 대한 링크만 저장합니다.
이는 Jettons의 전송이 지갑 간에 직접 일어나고 고부하 주소에 영향을 미치지 않도록 하기 위한 것으로, 트랜잭션의 병렬 처리에 필수적입니다.
즉, 당신의 컨트랙트가 "컨트랙트 그룹"으로 바뀌고 이들이 서로 활발하게 상호작용할 것이라는 점을 준비해야 합니다.
트랜잭션의 부분 실행이 가능
컨트랙트 로직에 새로운 고유한 속성이 나타납니다: 트랜잭션의 부분 실행.
예를 들어 표준 TON Jetton의 메시지 흐름을 살펴보겠습니다:
다이어그램에서 보듯이:
- sender가 자신의 지갑(
sender_wallet
)에op::transfer
메시지를 보냅니다; sender_wallet
이 토큰 잔액을 감소시킵니다;sender_wallet
이 수신자의 지갑(destination_wallet
)에op::internal_transfer
메시지를 보냅니다;destination_wallet
이 토큰 잔액을 증가시킵니다;destination_wallet
이 소유자(destination
)에게op::transfer_notification
을 보냅니다;destination_wallet
이 초과 가스를op::excesses
메시지와 함께response_destination
(보통sender
)으로 반환합니다.
destination_wallet
이 op::internal_transfer
메시지를 처리할 수 없는 경우(예외 발생 또는 가스 부족) 이 부분과 후속 단계는 실행되지 않지만, 첫 번째 단계(sender_wallet
의 잔액 감소)는 완료된다는 점에 주목하세요. 결과적으로 트랜잭션이 부분적으로 실행되어 Jetton
의 상태가 불일치하고 이 경우 금전 손실이 발생합니다.
최악의 경우, 모든 토큰이 이런 식으로 도난당할 수 있습니다. 먼저 사용자에게 보너스를 지급한 다음 그들의 Jetton 지갑에 op::burn
메시지를 보내지만 op::burn
이 성공적으로 처리될 것이라고 보장할 수 없다고 상상해보세요.
TON 스마트 컨트랙트 개발자는 가스를 제어해야 함
Solidity에서 가스는 컨트랙트 개 발자에게 큰 관심사가 아닙니다. 사용자가 너무 적은 가스를 제공하면 모든 것이 아무 일도 없었던 것처럼 되돌려집니다(하지만 가스는 반환되지 않습니다). 충분히 제공하면 실제 비용이 자동으로 계산되어 잔액에서 차감됩니다.
TON에서는 상황이 다릅니다:
- 가스가 부족하면 트랜잭션이 부분적으로 실행됩니다;
- 가스가 너무 많으면 초과분을 반환해야 합니다. 이는 개발자의 책임입니다;
- "컨트랙트 그룹"이 메시지를 교환하는 경우 각 메시지에서 제어와 계산이 이루어져야 합니다.
TON은 가스를 자동으로 계산할 수 없습니다. 모든 결과를 포함한 트랜잭션의 완전한 실행에는 시간이 오래 걸릴 수 있으며, 마지막에는 사용자의 지갑에 toncoin이 부족할 수 있습니다. 여기서도 carry-value 원칙이 사용됩니다.
TON 스마트 컨트랙트 개발자는 저장소를 관리해야 함
TON의 일반적인 메시지 핸들러는 다음과 같은 접근 방식을 따릅니다:
() handle_something(...) impure {
(int total_supply, <a lot of vars>) = load_data();
... ;; do something, change data
save_data(total_supply, <a lot of vars>);
}
불행히도 <많은 변수>
가 모든 컨트랙트 데이터 필드의 실제 열거임을 확인하고 있습니다. 예시:
(
int total_supply, int swap_fee, int min_amount, int is_stopped, int user_count, int max_user_count,
slice admin_address, slice router_address, slice jettonA_address, slice jettonA_wallet_address,
int jettonA_balance, int jettonA_pending_balance, slice jettonB_address, slice jettonB_wallet_address,
int jettonB_balance, int jettonB_pending_balance, int mining_amount, int datetime_amount, int minable_time,
int half_life, int last_index, int last_mined, cell mining_rate_cell, cell user_info_dict, cell operation_gas,
cell content, cell lp_wallet_code
) = load_data();
이 접근 방식에는 여러 단점이 있습니다.
첫째, is_paused
와 같은 다른 필드를 추가하기로 결정하면 컨트랙트 전체에서 load_data()/save_data()
문을 업데이트해야 합니다. 이는 노동 집약적일 뿐만 아니라 찾기 어려운 오류로 이어집니다.
최근 CertiK 감사에서 개발자가 두 인수를 섞어 다음과 같이 작성한 것을 발견했습니다:
save_data(total_supply, min_amount, swap_fee, ...)
전문가 팀이 수행한 외부 감사 없이는 이러한 버그를 찾기가 매우 어렵습니다. 함수는 거의 사용되지 않았고, 혼동된 두 매개변수는 보통 0 값을 가졌습니다. 이런 오류를 발견하려면 무엇을 찾는지 정말로 알아야 합니다.
둘째, "네임스페이스 오염"이 있습니다. 다른 예시로 감사에서 발견한 문제를 설명해보겠습니다. 함수 중간에 입력 매개변수를 다음과 같이 읽었습니다:
int min_amount = in_msg_body~load_coins();
즉, 로컬 변수에 의해 저장소 필드가 가려졌고, 함수 끝에서 이 대체된 값이 저장소에 저장되었습니다. 공격자는 컨트랙트의 상태를 덮어쓸 기회를 가졌습니다. FunC가 변수 재선언을 허용한다는 사실로 상황이 악화됩니다: "이것은 선언이 아니라 min_amount가 int 타입이라는 컴파일 타임 보험일 뿐입니다."
마지막으로, 모든 함수를 호출할 때마다 전체 저장소를 파싱하고 다시 패킹하는 것은 가스 비용을 증가시킵니다.
팁
1. 항상 메시지 흐름 다이어그램을 그리세요
TON Jetton과 같은 간단한 컨트랙트에서도 이미 상당히 많은 메시지, 발신자, 수신자, 메시지에 포함된 데이터들이 있습니다. 이제 한 워크플로우에서 메시지 수가 10개를 초과할 수 있는 탈중앙화 거래소(DEX)와 같은 조금 더 복잡한 것을 개발할 때는 어떨지 상상해보세요.
CertiK에서는 감사 과정 동안 이러한 다이어그램을 설명하고 업데이트하기 위해 DOT 언어를 사용합니다. 우리 감사자들은 이것이 컨트랙트 내부와 컨트랙트 간의 복잡한 상호작용을 시각화하고 이해하는 데 도움이 된다고 합니다.
2. 실패를 피하고 바운스된 메시지를 캐치하세요
메시지 흐름을 사용하여 먼저 진입점을 정의하세요. 이것은 컨트랙트 그룹의 메시지 연쇄를 시작하는 메시지입니다("결과"). 여기서 후속 단계에서 실패 가능성을 최소화하기 위해 모든 것을 확인해야 합니다(페이로드, 가스 공급 등).
모든 계획을 실행할 수 있을지 확실하지 않다면(예: 사용자가 거래를 완료하기에 충분한 토큰을 가지고 있는지) 메시지 흐름이 잘못 구축되었을 가능성이 높습니다.
후속 메시지(결과)에서 모든 throw_if()/throw_unless()
는 실제로 무언가를 확인하는 것이 아니라 assert의 역할을 합니다.
많은 컨트랙트가 만약을 위해 바운스된 메시지도 처리합니다.
예를 들어 TON Jetton에서 수신자의 지갑이 토큰을 받을 수 없는 경우(수신 로직에 따라 다름) 발신자의 지갑이 바운스된 메시지를 처리하고 토큰을 자신의 잔액으로 반환합니다.
() on_bounce (slice in_msg_body) impure {
in_msg_body~skip_bits(32); ;;0xFFFFFFFF
(int balance, slice owner_address, slice jetton_master_address, cell jetton_wallet_code) = load_data();
int op = in_msg_body~load_op();
throw_unless(error::unknown_op, (op == op::internal_transfer) | (op == op::burn_notification));
int query_id = in_msg_body~load_query_id();
int jetton_amount = in_msg_body~load_coins();
balance += jetton_amount;
save_data(balance, owner_address, jetton_master_address, jetton_wallet_code);
}
일반적으로 바운스된 메시지를 처리하는 것을 권장하지만, 메시지 처리 실패와 불완전한 실행으로부터의 완전한 보호 수단으로는 사용할 수 없습니다.
바운스된 메시지를 보내고 처리하는 데는 가스가 필요하며, 발신자가 충분히 제공하지 않으면 바운스되지 않습니다.
둘째로, TON은 점프의 연쇄를 제공하지 않습니다. 이는 바운스된 메시지를 다시 바운스할 수 없다는 것을 의미합니다. 예를 들어, 두 번째 메시지가 진입 메시지 후에 전송되고 두 번째가 세 번째를 트리거하는 경우, 진입 컨트랙트는 세 번째 메시지의 처리 실패를 알 수 없습니다. 마찬가지로 첫 번째가 두 번째와 세 번째를 보내는 경우, 두 번째의 실패는 세 번째의 처리에 영향을 미치지 않습니다.
3. 메시지 흐름 중간의 공격자를 예상하세요
메시지 연쇄는 여러 블록에 걸쳐 처리될 수 있습니다. 한 메시지 흐름이 실행되는 동안 공격자가 두 번째 흐름을 병렬로 시작할 수 있다고 가정하세요. 즉, 처음에 속성을 확인했다면(예: 사용자가 충분한 토큰을 가지고 있는지) 같은 컨트랙트의 세 번째 단계에서도 이 속성을 여전히 만족할 것이라고 가정하지 마세요.
4. Carry-Value 패턴을 사용하세요
이전 단락에서 알 수 있듯이 컨트랙트 간의 메시지는 가치를 전달해야 합니다.
TON Jetton에서도 이것이 입증됩니다: sender_wallet
이 잔액을 차감하고 op::internal_transfer
메시지로 destination_wallet
에 보내고, 이는 메시지와 함께 잔액을 받아 자신의 잔액에 추가합니다(또는 다시 바운스합니다).
잘못된 구현의 예시를 살펴보겠습니다. 왜 온체인에서 Jetton 잔액을 조회할 수 없을까요? 이러한 질문이 패턴에 맞지 않기 때문입니다. op::get_balance
메시지에 대한 응답이 요청자에게 도달할 때까지 이 잔액은 이미 누군가에 의해 소비되었을 수 있습니다.
이 경우 대안을 구현할 수 있습니다:
- master가 지갑에
op::provide_balance
메시지를 보냅니 다; - 지갑이 잔액을 0으로 만들고
op::take_balance
를 돌려보냅니다; - master가 돈을 받고 충분한지 결정한 다음 사용하거나(대신 무언가를 차감) 지갑으로 다시 보냅니다.
5. 거부하는 대신 값을 반환하세요
이전 관찰에서 알 수 있듯이 컨트랙트 그룹은 종종 단순한 요청이 아닌 값과 함께 요청을 받게 됩니다. 따라서 단순히 요청 실행을 거부할 수 없고(throw_unless()
를 통해) Jetton을 발신자에게 돌려보내야 합니다.
예를 들어 일반적인 흐름 시작(TON Jetton 메시지 흐름 참조):
sender
가sender_wallet
을 통해 컨트랙트용forward_ton_amount
와forward_payload
를 지정하여op::transfer
메시지를your_contract_wallet
에 보냅니다;sender_wallet
이op::internal_transfer
메시지를your_contract_wallet
에 보냅니다;your_contract_wallet
이op::transfer_notification
메시지를your_contract
에 보내forward_ton_amount
,forward_payload
,sender_address
,jetton_amount
를 전달합니다;- 그리고 여기 당신의 컨트랙트에서
handle_transfer_notification()
에서 흐름이 시작됩니다.
거기서 어떤 종류의 요청인지, 완료할 가스가 충분한지, 페이로드의 모든 것이 올바른지 파악해야 합니다. 이 단계에서는 throw_if()/throw_unless()
를 사용하지 말아야 합니다. 그러면 Jetton이 단순히 손실되고 요청이 실 행되지 않을 것이기 때문입니다. FunC v0.4.0부터 사용 가능한 try-catch 문을 사용하는 것이 좋습니다.
컨트랙트의 기대를 충족하지 않는다면 Jetton을 반환해야 합니다.
최근 감사에서 이러한 취약한 구현의 예시를 발견했습니다.
() handle_transfer_notification(...) impure {
...
int jetton_amount = in_msg_body~load_coins();
slice from_address = in_msg_body~load_msg_addr();
slice from_jetton_address = in_msg_body~load_msg_addr();
if (msg_value < gas_consumption) { ;; not enough gas provided
if (equal_slices(from_jetton_address, jettonA_address)) {
var msg = begin_cell()
.store_uint(0x18, 6)
.store_slice(jettonA_wallet_address)
.store_coins(0)
.store_uint(1, 1 + 4 + 4 + 64 + 32 + 1 + 1)
...
}
...
}
보시다시피, 여기서 "반환"은 sender_address
가 아닌 jettonA_wallet_address
로 보내집니다. 모든 결정이 in_msg_body
분석을 기반으로 이루어지므로, 공격자는 가짜 메시지를 위조하여 돈을 추출할 수 있습니다. 항상 반환을 sender_address
로 보내세요.
컨트랙트가 Jetton을 수락하는 경우, 예상한 Jetton이 왔는지 아니면 누군가의 단순한 op::transfer_notification
인지 알 수 없습니다.
컨트랙트가 예상치 못하거나 알 수 없는 Jetton을 받으면 이것들도 반환해야 합니다.
6. 가스를 계산하고 msg_value를 확인하세요
메시지 흐름 다이어그램에 따라 각 시나리오에서 각 핸들러의 비용을 추정하고 msg_value의 충분성을 확인할 수 있습니다.
단순히 여유를 두고 요구할 수는 없습니다. 예를 들어 1 TON(이 글을 쓰는 시점의 mainnet gas_limit)을 요구하면 이 가스를 "결과" 사이에 나눠야 하기 때문입니다. 컨트랙트가 세 개의 메시지를 보낸다면 각각에 0.33 TON만 보낼 수 있습니다. 이는 그들이 더 적게 "요구"해야 한다는 것을 의미합니다. 전체 컨트랙트의 가스 요구사항을 신중하게 계산하는 것이 중요합니다.
개발 중에 코드가 더 많은 메시지를 보내기 시작하면 상황이 더 복잡해집니다. 가스 요구사항을 재확인하고 업데이트해야 합니다.
7. 초과 가스를 조심스럽게 반환하세요
초과 가스가 발신자에게 반환되지 않으면 시간이 지남에 따라 자금이 컨트랙트에 누적될 것입니다. 원칙적으로 끔찍한 일은 아니며, 이는 그저 최적이 아닌 관행입니다. 초과분을 긁어내는 함수를 추가할 수 있지만, TON Jetton과 같은 인기 있는 컨트랙트는 여전히 op::excesses
메시지와 함께 발신자에게 반환합니다.
TON에는 유용한 메커니즘이 있습니다: SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE = 64
. send_raw_message()
에서 이 모드를 사용할 때, 남은 가스가 메시지와 함께 새로운 수신자에게 전달됩니다(또는 되돌아갑니다). 메시지 흐름이 선형인 경우에 편리합니다: 각 메시지 핸들러가 하나의 메시지만 보냅니다. 하지만 이 메커니즘을 사용하지 않는 것이 좋은 경우가 있습니다:
- 컨트랙트에 비선형 핸들러가 없는 경우. storage_fee는 수신 가스가 아닌 컨트랙트 잔액에서 차감됩니다. 이는 들어오는 모든 것이 나가야 하기 때문에 시간이 지나면서 storage_fee가 전체 잔액을 소비할 수 있음을 의미합니다;
- 컨트랙트가 이벤트를 발생시키는 경우, 즉 외부 주소로 메시지를 보냅니다. 이 작업의 비용은 msg_value가 아닌 컨트랙트의 잔액에서 차감됩니다.
() emit_log_simple (int event_id, int query_id) impure inline {
var msg = begin_cell()
.store_uint (12, 4) ;; ext_out_msg_info$11 addr$00
.store_uint (1, 2) ;; addr_extern$01
.store_uint (256, 9) ;; len:(## 9)
.store_uint(event_id, 256); ;; external_address:(bits len)
.store_uint(0, 64 + 32 + 1 + 1) ;; lt, at, init, body
.store_query_id(query_id)
.end_cell();
send_raw_message(msg, SEND_MODE_REGULAR);
}
- 컨트랙트가 메시지를 보낼 때 value를 첨부하거나
SEND_MODE_PAY_FEES_SEPARETELY = 1
을 사용하는 경우. 이러한 작업은 컨트랙트 잔액에서 차감되므로 미사용분을 반환하는 것은 "손실을 보면서 작업하는" 것입니다.
표시된 경우에는 잉여분의 수동 근사 계산이 사용됩니다:
int ton_balance_before_msg = my_ton_balance - msg_value;
int storage_fee = const::min_tons_for_storage - min(ton_balance_before_msg, const::min_tons_for_storage);
msg_value -= storage_fee + const::gas_consumption;
if(forward_ton_amount) {
msg_value -= (forward_ton_amount + fwd_fee);
...
}
if (msg_value > 0) { ;; there is still something to return
var msg = begin_cell()
.store_uint(0x10, 6)
.store_slice(response_address)
.store_coins(msg_value)
...
}
컨트랙트 잔액이 소진되면 트랜잭션이 부분적으로 실행되며, 이는 허용할 수 없다는 점을 기억하세요.
8. 중첩된 저장소를 사용하세요
다음과 같은 저장소 구성 방식을 권장합니다:
() handle_something(...) impure {
(slice swap_data, cell liquidity_data, cell mining_data, cell discovery_data) = load_data();
(int total_supply, int swap_fee, int min_amount, int is_stopped) = swap_data.parse_swap_data();
…
swap_data = pack_swap_data(total_supply + lp_amount, swap_fee, min_amount, is_stopped);
save_data(swap_data, liquidity_data, mining_data, discovery_data);
}
저장소는 관련 데이터의 블록으로 구성됩니다. 매개변수가 각 함수에서 사용되는 경우(예: is_paused
) load_data()
에 의해 즉시 제공됩니다. 매개변수 그룹이 하나의 시나리오에서만 필요한 경우 언팩할 필요가 없어 더 이상 팩할 필요가 없고 네임스페이스를 복잡하게 만들지 않습니다.
저장소 구조를 변경해야 하는 경우(보통 새 필드 추가) 훨씬 적은 수정만 하면 됩니다.
더욱이 이 접근 방식을 반복할 수 있습니다. 컨트랙트에 저장소 필드가 30개 있다면 처음에는 4개의 그룹을 얻고, 첫 번째 그룹에서 몇 개의 변수와 다른 하위 그룹을 얻을 수 있습니다. 중요한 것은 과도하게 하지 않는 것입니다.
셀은 최대 1023비트의 데이터와 최대 4개의 참조를 저장할 수 있으므로 어쨌든 데이터를 여러 셀로 분할해야 합니다.
계층적 데이터는 TON의 주요 기능 중 하나이므로, 의도한 대로 사용합시다.
특히 컨트랙트에 무엇이 저장될지 완전히 명확하지 않은 프로토타이핑 단계에서는 전역 변수를 사용할 수 있습니다.
global int var1;
global cell var2;
global slice var3;
() load_data() impure {
var cs = get_data().begin_parse();
var1 = cs~load_coins();
var2 = cs~load_ref();
var3 = cs~load_bits(512);
}
() save_data() impure {
set_data(
begin_cell()
.store_coins(var1)
.store_ref(var2)
.store_bits(var3)
.end_cell()
);
}
이렇게 하면 다른 변수가 필요하다는 것을 알게 되면 새 전역 변수를 추가하고 load_data()
와 save_data()
만 수정하면 됩니다. 컨트랙트 전체에 걸친 변경은 필요하지 않습니다. 하지만 전역 변수의 수가 제한되어 있기 때문에(31개 이하), 이 패턴을 위에서 권장한 "중첩된 저장소"와 결합할 수 있습니다.
전역 변수는 또한 종종 스택에 모든 것을 저장하는 것보다 더 비쌉니다. 하지만 이는 스택 순열의 수에 따라 달라지므로, 전역 변수로 프로토타입을 만들고 저장소 구조가 완전히 명확해졌을 때 "중첩된" 패턴을 사용하는 스택 변수로 전환하는 것이 좋습니다.
9. end_parse()를 사용하세요
저장소와 메시지 페이로드에서 데이터를 읽을 때 가능한 한 end_parse()
를 사용하세요. TON은 가변 데이터 형식의 비트 스트림을 사용하기 때문에 작성한 만큼 읽었는지 확인하는 것이 디버깅 시간을 절약할 수 있습니다.
10. 더 많은 헬퍼 함수를 사용하고 매직 넘버를 피하세요
이 팁은 FunC에만 국한된 것이 아니지만 여기서 특히 관련이 있습니다. 더 많은 래퍼와 헬퍼 함수를 작성하고 더 많은 상수를 선언하세요.
FunC는 처음부터 엄청난 양의 매직 넘버를 가지 고 있습니다. 개발자가 사용을 제한하기 위한 노력을 하지 않으면 다음과 같은 결과가 됩니다:
var msg = begin_cell()
.store_uint(0xc4ff, 17) ;; 0 11000100 0xff
.store_uint(config_addr, 256)
.store_grams(1 << 30) ;; ~1 gram of value
.store_uint(0, 107)
.store_uint(0x4e565354, 32)
.store_uint(query_id, 64)
.store_ref(vset);
send_raw_message(msg.end_cell(), 1);
이는 실제 프로젝트의 코드이며 초보자들을 겁먹게 합니다.
다행히도 FunC의 최신 버전에서는 몇 가지 표준 선언으로 코드를 더 명확하고 표현력 있게 만들 수 있습니다. 예시:
const int SEND_MODE_REGULAR = 0;
const int SEND_MODE_PAY_FEES_SEPARETELY = 1;
const int SEND_MODE_IGNORE_ERRORS = 2;
const int SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE = 64;
builder store_msgbody_prefix_stateinit(builder b) inline {
return b.store_uint(4 + 2 + 1, 1 + 4 + 4 + 64 + 32 + 1 + 1 + 1);
}
builder store_body_header(builder b, int op, int query_id) inline {
return b.store_uint(op, 32).store_uint(query_id, 64);
}
() mint_tokens(slice to_address, cell jetton_wallet_code, int amount, cell master_msg) impure {
cell state_init = calculate_jetton_wallet_state_init(to_address, my_address(), jetton_wallet_code);
slice to_wallet_address = calculate_address_by_state_init(state_init);
var msg = begin_cell()
.store_msg_flags(BOUNCEABLE)
.store_slice(to_wallet_address)
.store_coins(amount)
.store_msgbody_prefix_stateinit()
.store_ref(state_init)
.store_ref(master_msg);
send_raw_message(msg.end_cell(), SEND_MODE_REGULAR);
}
참조
원문 작성: CertiK