안전한 스마트 계약 프로그래밍
이 섹션에서는 TON 블록체인의 가장 흥미로운 기능 몇 가지를 살펴본 다음, 펀씨에서 스마트 컨트랙트를 프로그래밍하는 개발자를 위한 모범 사례 목록을 살펴볼 것입니다.
계약 샤딩
EVM용 컨트랙트를 개발할 때 일반적으로 편의상 프로젝트를 여러 개의 컨트랙트로 분할합니다. 경우에 따라 하나의 컨트랙트에 모든 기능을 구현하는 것이 가능하며, 컨트랙트 분할이 필요한 경우(예: 자동화된 시장 조성자의 유동성 페어)에도 특별한 문제가 발생하지 않았습니다. 트랜잭션은 전체가 실행됩니다: 모든 것이 잘 되거나 모든 것이 되돌아갑니다.
TON에서는 "무제한 데이터 구조"와 단일 논리 컨트랙트를 작은 조각으로 분할하여 각각 소량의 데이터를 관리하는 것을 피할 것을 강력히 권장합니다. 기본적인 예는 TON Jettons의 구현입니다. 이는 이더리움의 ERC-20 토큰 표준에 대한 TON의 버전입니다. 간단히 설명하자면
- 총 공급량
,
민터 주소, 토큰 설명(메타데이터)과
젯톤 지갑 코드라는 두 개의 참조를 저장하는 하나의
젯톤-minter`가 있습니다. - 그리고 이 제톤의 각 소유자당 하나씩 많은 제톤 지갑이 있습니다. 이러한 각 지갑에는 소유자의 주소, 잔액, 제톤 마이너 주소, jetton_wallet_code 링크만 저장됩니다.
이는 제톤 전송이 지갑 간에 직접 이루어지고 트랜잭션 병렬 처리의 기본이 되는 고부하 주소에 영향을 미치지 않도록 하기 위해 필요합니다.
즉, 계약이 '계약 그룹'으로 바뀌고 서로 적극적으로 상호 작용할 수 있도록 준비하세요.
트랜잭션의 부분 실행 가능
트랜잭션의 부분 실행이라는 새로운 고유 속성이 컨트랙트 로직에 나타납니다.
예를 들어 표준 톤 제톤의 메시지 흐름을 생각해 보세요:
다이어그램을 보면 다음과 같습니다:
- 발신자가 지갑(
발신자_지갑
)으로op::transfer
메시지를 보냅니다; - 발신자 지갑`은 토큰 잔액을 감소시킵니다;
- 발신자지갑
은 수신자의 지갑(
대상지갑)으로
op::internal_transfer` 메시지를 보냅니다; - 대상_지갑`은 토큰 잔액을 증가시킵니다;
- 대상_지갑
은 소유자(
대상)에게
op::transfer_notification`을 전송합니다; - destinationwallet`은 '응답대상'(보통
발신자
)에op::excesses
메시지와 함께 초과 가스를 반환합니다.
대상_지갑이
op::internal_transfer 메시지를 처리할 수 없는 경우(예외가 발생했거나 가스 소진), 이 부분과 후속 단계는 실행되지 않습니다. 그러나 첫 번째 단계(
sender_wallet의 잔액 감소)는 완료됩니다. 그 결과 트랜잭션이 부분적으로 실행되고
제톤`의 상태가 일관되지 않으며 이 경우 손실이 발생합니다.
최악의 시나리오에서는 모든 토큰이 이런 방식으로 도난당할 수 있습니다. 먼저 사용자에게 보너스를 적립한 다음 Jetton 지갑에 op::burn
메시지를 보냈지만 op::burn
이 성공적으로 처리될지 보장할 수 없다고 가정해 보겠습니다.
TON 스마트 컨트랙트 개발자는 가스를 제어해야 합니다.
솔리디티에서 가스는 컨트랙트 개발자에게 큰 문제가 되지 않습니다. 사용자가 가스를 너무 적게 제공하면 아무 일도 없었던 것처럼 모든 것이 되돌려집니다(단, 가스는 반환되지 않습니다). 사용자가 충분히 제공하면 실제 비용이 자동으로 계산되어 잔액에서 차감됩니다.
TON에서는 상황이 다릅니다:
- 가스가 충분하지 않으면 트랜잭션이 부분적으로 실행됩니다;
- 가스가 너무 많으면 초과분은 반환해야 합니다. 이는 개발자의 책임입니다;
- '계약 그룹'이 메시지를 교환하는 경우 각 메시지에서 제어 및 계산을 수행해야 합니다.
톤은 가스를 자동으로 계산할 수 없습니다. 모든 결과와 함께 트랜잭션을 완전히 실행하는 데 시간이 오래 걸릴 수 있으며, 마지막에는 사용자의 지갑에 충분한 톤 코인이 없을 수 있습니다. 여기에서도 이월 가치 원칙이 다시 사용됩니다.
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>);
}
안타깝게도 <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. 항상 메시지 흐름 다이어그램 그리기
톤 제튼과 같은 간단한 컨트랙트에서도 이미 꽤 많은 메시지, 발신자, 수신자, 메시지에 포함된 데이터 조각이 존재합니다. 이제 하나의 워크플로우에 포함된 메시지 수가 10개가 넘을 수 있는 탈중앙화 거래소(DEX)와 같이 좀 더 복잡한 것을 개발할 때 어떤 모습일지 상상해 보세요.
CertiK에서는 감사 과정에서 이러한 다이어그램을 설명하고 업데이트하기 위해 DOT 언어를 사용합니다. 감사자들은 이를 통해 계약 내 및 계약 간의 복잡한 상호 작용을 시각화하고 이해하는 데 도움이 된다는 것을 알게 되었습니다.
2. 실패 방지 및 반송된 메시지 찾기
메시지 흐름을 사용하여 먼저 진입점을 정의합니다. 이것은 컨트랙트 그룹에서 일련의 메시지("결과")를 시작하는 메시지입니다. 여기에서 다음 단계의 실패 가능성을 최소화하기 위해 모든 것을 확인해야 합니다(페이로드, 가스 공급 등).
모든 계획을 이행할 수 있는지 여부(예: 사용자가 거래를 완료하기에 충분한 토큰을 보유하고 있는지 여부)가 확실하지 않다면 메시지 흐름이 잘못 구축된 것일 수 있습니다.
후속 메시지(결과)에서 모든 throw_if()/throw_unless()
는 실제로 무언가를 검사하기보다는 어서트의 역할을 합니다.
많은 계약은 만일을 대비하여 반송된 메시지를 처리하기도 합니다.
예를 들어, 톤 제튼에서는 수신자의 지갑이 토큰을 받을 수 없는 경우(수신 로직에 따라 다름) 발신자의 지갑이 반송된 메시지를 처리하고 토큰을 자체 잔액으로 반환합니다.
() 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. 캐리값 패턴 사용
이전 단락에서 계약 간 메시지에는 귀중품이 포함되어야 한다고 설명한 바 있습니다.
동일한 톤 제튼에서 이를 보여줍니다: 'senderwallet'이 잔액을 빼서 op::internal_transfer
메시지와 함께 '대상지갑'으로 보내면, 대상_지갑은 메시지와 함께 잔액을 받아 자신의 잔액에 더하거나 반송합니다(또는 반송 취소).
다음은 잘못된 구현의 예시입니다. 제톤 잔액을 온체인에서 확인할 수 없는 이유는 무엇인가요? 이러한 질문은 패턴에 맞지 않기 때문입니다. op::get_balance` 메시지에 대한 응답이 요청자에게 도달할 때쯤이면 이미 누군가가 이 잔액을 사용했을 수 있습니다.
를 구현할 수 있습니다.
- 마스터가 지갑에
op::provide_balance
메시지를 전송합니다; - 지갑은 잔액을 0으로 만들고
op::take_balance
를 다시 보냅니다; - 마스터는 돈을 받고 충분한지 판단한 후 사용하거나(대가로 무언가를 인출) 지갑으로 다시 보냅니다.
5. 거부 대신 값 반환
앞서 살펴본 바와 같이 컨트랙트 그룹은 요청만 받는 것이 아니라 값과 함께 요청을 받는 경우가 많습니다. 따라서 throw_unless()
를 통해 요청 실행을 거부할 수 없으며, 제톤을 발신자에게 다시 보내야 합니다.
예를 들어, 일반적인 흐름 시작(톤 제톤 메시지 흐름 참조)이 있습니다:
- 발신자
는
발신자지갑을 통해
your_contract지갑으로
op::transfer메시지를 보내며, 계약에 대한
forward_ton_amount와
forward_payload`를 지정합니다; - 보낸 사람 지갑
은
op::internaltransfer메시지를
당신의계약_지갑`으로 보냅니다; - 너의계약지갑
은
op::transfernotification메시지를
너의계약으로 전송하여
전송톤수,
전송페이로드,
발신자주소와
전송톤수`를 전달합니다; - 그리고 여기 컨트랙트의
handle_transfer_notification()
에서 흐름이 시작됩니다.
여기서 어떤 종류의 요청이었는지, 요청을 완료하기에 충분한 가스가 있는지, 페이로드에 모든 것이 올바른지 파악해야 합니다. 이 단계에서는 throw_if()/throw_unless()
를 사용하면 제톤이 손실되고 요청이 실행되지 않으므로 사용해서는 안 됩니다. FunC v0.4.0부터 제공되는 try-catch 문을 사용하는 것이 좋습니다(https://docs.ton.org/develop/func/statements#try-catch-statements).
계약의 기대에 미치지 못하는 부분이 있으면 제톤을 반환해야 합니다.
최근 감사에서 이러한 취약한 구현의 예를 발견했습니다.
() 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)
...
}
...
}
보시다시피 여기서 "반환"은 발신자 주소
가 아닌 jettonA_wallet_address
로 전송됩니다. 모든 결정은 in_msg_body
의 분석을 기반으로 이루어지기 때문에 공격자는 가짜 메시지를 위조하여 돈을 빼낼 수 있습니다. 항상 발신자_주소
로 리턴을 보내세요.
계약에서 제톤을 수락한 경우, 실제로 예상했던 제톤이 왔는지 아니면 누군가 보낸 op::transfer_notification
인지 알 수 없습니다.
계약서에 예상치 못한 제톤을 받거나 알 수 없는 제톤을 받은 경우에도 반환해야 합니다.
6. 가스 계산 및 msg_value 확인
메시지 흐름 다이어그램에 따르면 각 시나리오에서 각 핸들러의 비용을 추정하고 msg_value의 충분성에 대한 검사를 삽입할 수 있습니다.
이 가스는 '결과'로 나눠야 하므로 1톤(작성일 기준 메인넷의 가스 제한)과 같은 마진으로만 요구할 수 없습니다. 컨트랙트가 세 개의 메시지를 보낸다고 가정하면 각각 0.33톤만 보낼 수 있습니다. 즉, 더 적게 "요구"해야 한다는 뜻입니다. 전체 계약의 가스 요구량을 신중하게 계산하는 것이 중요합니다.
개발 중에 코드가 더 많은 메시지를 전송하기 시작하면 상황이 더 복잡해집니다. 가스 요구 사항을 다시 확인하고 업데이트해야 합니다.
7. 가스 초과분을 조심스럽게 반환하기
초과 가스가 발신자에게 반환되지 않으면 시간이 지남에 따라 계약에 자금이 누적됩니다. 원칙적으로 이는 차선책일 뿐 나쁜 일은 아닙니다. 초과분을 긁어내는 기능을 추가할 수 있지만, 톤 제튼과 같은 인기 있는 컨트랙트는 여전히 'op::초과분'이라는 메시지와 함께 발신자에게 반환됩니다.
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);
}
- 컨트랙트가 메시지를 보낼 때 값을 첨부하거나
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에서 작성