프로그래머블 트랜잭션 블록 (PTB)
Sui의 트랜잭션은 트랜잭션 결과를 정의하기 위해 입력에 대해 실행되는 명령 그룹으로 구성된다. **프로그래머블 트랜잭션 블록 (PTB)**라고 부르는 이 명령 그룹이 Sui의 모든 사용자 트랜잭션을 정의한다. PTB를 사용하면 사용자는 새 Move 패키지를 publish하지 않고도 하나의 트랜잭션 안에서 여러 Move 함수를 호출하고, 자신의 객체를 관리하고, 자신의 코인을 관리할 수 있다. 자동화와 트랜잭션 builder를 염두에 두고 설계된 PTB는 트랜잭션을 생성하는 가볍고 유연한 방식이다.
그러나 loop 같은 더 복잡한 프로그래밍 패턴은 지원되지 않는다. 이런 경우에는 새 Move 패키지를 publish해야 한다.
PTB 안의 개별 트랜잭션 명령은 순서대로 실행된다. 한 트랜잭션 명령의 결과를 같은 PTB 안에 있는 이후 트랜잭션 명령에서 사용할 수 있다. 객체 수정이나 transfer 같은 블록 안 각 트랜잭션 명령의 effect는 트랜잭션 마지막에 원자적으로 적용된다. 한 트랜잭션 명령이 실패하면 전체 블록이 실패하고 어떤 명령의 effect도 적용되지 않는다.
PTB는 한 번의 실행에서 최대 1,024개의 고유 operation을 수행할 수 있지만, 전통적인 블록체인의 트랜잭션은 같은 결과를 얻기 위해 1,024번의 개별 실행이 필요하다. 이 구조는 더 저렴한 가스 수수료도 촉진한다. 개별 트랜잭션을 처리하는 비용은 같은 트랜잭션을 PTB로 묶어 처리하는 비용보다 항상 더 높다.
Transaction component 구성
실행 의미론과 관련해 PTB에는 두 부분이 있다. 트랜잭션 sender나 gas limit 같은 다른 트랜잭션 정보는 참조될 수 있지만 여기서는 범위 밖이다. PTB의 구조는 다음과 같다:
{
inputs: [Input],
commands: [Command],
}
두 주요 구성 요소를 더 자세히 보면 다음과 같다:
-
inputs값은 인자 벡터[Input]이다. 이 인자들은 command에서 사용할 수 있는 객체이거나 pure value이다. 객체는 sender가 소유한 것이거나 공유 객체 또는 불변 객체이다. pure value는u64나String값처럼 바이트만으로 순수하게 구성할 수 있는 단순한 Move 값이다. 역사적인 이유로 Rust 구현에서는Input을CallArg라고 부른다. -
commands값은 command 벡터[Command]이다. 가능한 command는 다음과 같다: -
tx.splitCoins(coin, amounts): 제공된 코인에서 정의한 amount만큼 분할해 새 코인을 만든다. 이후 트랜잭션에서 사용할 수 있도록 코인을 반환한다.- 예시:
tx.splitCoins(tx.gas, [tx.pure.u64(100), tx.pure.u64(200)])
- 예시:
-
tx.mergeCoins(destinationCoin, sourceCoins):sourceCoins를destinationCoin에 병합한다.- 예시:
tx.mergeCoins(tx.object(coin1), [tx.object(coin2), tx.object(coin3)])
- 예시:
-
tx.transferObjects(objects, address): 객체 목록을 지정한 address로 전송한다.- 예시:
tx.transferObjects([tx.object(thing1), tx.object(thing2)], tx.pure.address(myAddress))
- 예시:
-
tx.moveCall({ target, arguments, typeArguments }): Move 호출을 실행한다. Sui Move 호출이 반환하는 값을 그대로 반환한다.- 예시:
tx.moveCall({ target: '0x2::devnet_nft::mint', arguments: [tx.pure.string(name), tx.pure.string(description), tx.pure.string(image)] })
- 예시:
-
tx.makeMoveVec({ type, elements }):moveCall에 전달할 수 있는 객체 vector를 구성한다. vector를 입력으로 정의할 다른 방법이 없기 때문에 이 command가 필요하다.- 예시:
tx.makeMoveVec({ elements: [tx.object(id1), tx.object(id2)] })
- 예시:
-
tx.publish(modules, dependencies): Move 패키지를 publish한다. upgrade capability 객체를 반환한다. -
tx.upgrade(modules, dependencies, packageId: EXAMPLE_PACKAGE_ID, ticket): 기존 패키지를 업그레이드한다. 업그레이드된 모듈에 대해서는 어떤init함수도 호출되지 않는다.
각 PTB 명령에 대해 더 알아본다.
Input과 결과
입력은 PTB에 제공되는 값이고, 결과는 PTB command가 만들어 내는 값이다. 입력은 객체이거나 단순한 Move 값이며, 결과는 객체를 포함한 임의의 Move 값이다. 입력과 결과는 PTB 실행을 통과하는 데이터 흐름을 이룬다.
이를 값 배열을 채우는 것으로 볼 수 있다. 입력은 단일 배열이지만 결과는 각 개별 트랜잭션 command마다 하나씩 배열이 있어 결과 값의 2D 배열이 만들어진다. 타입이 허용한다면 이 값들은 가변 또는 불변으로 borrow하거나, 복사하거나, 이동해 접근할 수 있으며, 이동은 재인덱싱 없이 값을 배열 밖으로 가져간다.
inputs and results에 대해 더 알아본다.
Argument structure와 usage
command는 사용할 입력 또는 결과를 지정하는 Argument 값을 받는다.
런타임은 command가 기대하는 타입을 바탕으로 인자를 reference로 전달할지 값으로 전달할지를 추론한다.
Argument enum에는 4개 variant가 있다:
-
Input(u16): 인덱스로 입력을 참조한다. 예를 들어 입력이[Object1, Object2, Pure1, Object3]라면Object1에는Input(0),Pure1에는Input(2)를 사용한다. -
GasCoin: 가스 지불에 사용하는 SUI 코인을 참조한다. 주소 잔액 기반 가스 지불(빈 가스 지불 목록)을 사용할 때GasCoin은 송신자 또는 스폰서 주소 잔액에서 생성된 synthetic coin을 참조한다. 이 값은 특별한 제약이 있으므로 다른 입력과 분리되어 있다:- 어디에서든
&mut또는&로 사용할 수 있다. TransferObjects또는sui::coin::send_funds를 통해서만 값으로 사용한다.GasCoin에서 소유Coin<SUI>이 필요하면 먼저SplitCoins를 사용한다.
- 어디에서든
-
NestedResult(u16, u16): 이전 command의 결과를 참조한다. 첫 번째 인덱스는 command를, 두 번째 인덱스는 그 command에서 어떤 결과인지를 지정한다. 예를 들어 command 1이[Value1, Value2]를 반환한다면Value1에는NestedResult(1, 0),Value2에는NestedResult(1, 1)을 사용한다. -
Result(u16):NestedResult(i, 0)의 축약형이지만 인덱스i의 command가 정확히 1개의 결과를 반환할 때만 유효하다. 0개 또는 여러 결과를 반환하는 command에는NestedResult를 사용한다.
Execution 실행
PTB를 실행할 때 입력 벡터는 입력 객체 또는 pure value bytes로 채워진다. 그 다음 트랜잭션 command가 순서대로 실행되고, 결과는 결과 벡터에 저장된다. 마지막으로 트랜잭션의 effect가 원자적으로 적용된다.
Execution 시작
실행 시작 시 PTB 런타임은 입력 객체를 가져와 입력 배열에 적재한다. 이 객체들은 존재 여부와 유효한 소유권 같은 규칙을 검사하며 네트워크가 이미 검증한 상태이다. pure value bytes도 배열에 적재되지만 사용할 때까지는 검증되지 않는다.
이 단계에서 가장 중요한 점은 가스 코인에 대한 effect이다. 실행 시작 시 최대 가스 예산(SUI 기준)이 가스 코인에서 인출된다. 사용되지 않은 gas는 실행 끝에 가스 코인으로 반환되며, 코인의 owner가 바뀌었더라도 마찬가지이다.
Object consumption 처리
Move command가 생성하거나 반환한 모든 객체는 소비되거나(파기, 전송, 다른 command에서 사용), 타입에 drop ability가 있다면 명시적으로 drop되어야 한다.
PTB에서 Move command를 통해 객체를 생성한 뒤 이를 파기하거나 전송하거나 다음 command에서 사용하지 않으면 트랜잭션은 error와 함께 실패한다.
Pre-execution validation 검증
트랜잭션에 서명되면 네트워크는 특정 command에 대해 다음 검증을 수행한다:
-
SplitCoins와MergeCoins: 인자 배열(AmountArgs,ToMergeArgs)이 비어 있지 않은지 검증한다. -
MakeMoveVec: 비어 있는Argsvector에는 type이 지정되어야 함을 검증한다. -
Publish:ModuleBytes가 비어 있지 않은지 검증한다.
Argument usage rule 규칙
각 인자는 reference 또는 값으로 사용될 수 있다. 사용 방식은 인자의 타입과 command의 타입 signature에 따라 결정된다:
-
signature가
&mut T를 기대하면 런타임은 인자의 타입이T인지 확인하고 이후 가변 borrow를 수행한다. -
signature가
&T를 기대하면 런타임은 인자 의 타입이T인지 확인하고 이후 불변 borrow를 수행한다. -
signature가
T를 기대하면 런타임은 인자의 타입이T인지 검증한 다음,T: copy이면 값을 복사하고 그렇지 않으면 이동한다. 객체는sui::object::UID에copyability가 없기 때문에 항상 이동된다.
인자가 이동된 뒤 어떤 형태로든 다시 사용되면 트랜잭션은 실패한다. 이동된 인자를 원래 위치(입력 또는 결과 인덱스)로 되돌리는 방법은 없다.
인자가 복사되었지만 drop ability가 없다면 마지막 사용은 이동으로 추론된다.
그 결과 인자에 copy는 있고 drop이 없다면 마지막 사용은 값으로 이뤄져야 한다.
그렇지 않으면 drop이 없는 값이 사용되지 않았기 때문에 트랜잭션이 실패한다.
Borrowing rule 규칙
borrow는 안전한 reference 사용을 보장하기 위해 추가 규칙을 따른다:
-
Mutable borrow: 다른 borrow가 남아 있어서는 안 된다. 겹치는 mutable borrow는 dangling reference를 만들 수 있다.
-
Immutable borrow: mutable borrow가 남아 있어서는 안 된다. 여러 immutable borrow는 허용된다.
-
Move: borrow가 남아 있어서는 안 된다. 빌린 값을 이동하면 기존 reference가 무효화된다.
-
Copy: 남아 있는 borrow와 관계없이 허용된다.
Special 객체 handling 처리
객체 입력은 underlying 객체의 타입 T를 가진다.
예외는 ObjectArg::Receiving 입력이며, 이 경우 타입은 sui::transfer::Receiving<T>이다.
이 wrapper는 객체가 sender가 아니라 다른 객체에 의해 소유됨을 나타낸다.
부모 객체와 함께 sui::transfer::receive를 호출해 이를 unwrap하고 소유권을 증명한다.
공유 객체는 트랜잭션 끝까지 shared 상태를 유지하거나 삭제되도록 보장하기 위해 값으로 사용하는 데 제약이 있다:
-
읽기 전용 공유 객체(
mutable이 아닌 것으로 표시됨)는 값으로 사용할 수 없다. -
공유 객체는 transfer하거나 freeze할 수 없다. 이런 operation은 실행 중에는 성공하지만 트랜잭션 마지막에 실패를 일으킨다.
-
공유 객체는 실행 중에 wrap되거나 dynamic 필드로 변환될 수 있지만, 트랜잭션이 끝나기 전에 다시 공유되거나 삭제되어야 한다.
Move call rule 규칙
PTB는 public 함수과 모든 entry 함수를 호출할 수 있다. 여기에는 private entry fun f()와 public(package) entry fun f()도 포함된다. Non-entry private 함수과 public(package) 함수는 PTB에서 호출할 수 없다. 이런 이유로 public 함수에 entry를 추가할 필요는 없다.
이전에는 entry 함수가 public 함수보다 parameter와 return type에 더 제한적인 signature restriction을 가졌다. 이러한 restriction은 제거되었다. 이제 entry 함수는 모든 public 함수과 같은 signature를 가질 수 있다.
return type:
Move call은 reference(&T 또는 &mut T)를 return할 수 없다. 이 restriction은 향후 제거될 예정이다.
Private generics:
일부 framework 함수에는 같은 모듈에 정의된 type으로만 instantiate할 수 있는 type parameter가 있다. PTB는 모듈이 아니므로 이러한 type을 제공할 수 없고, 따라서 해당 함수를 직접 호출할 수 없다. 예를 들어 transfer와 share_object 같은 특정 sui::transfer 함수는 호출 모듈에 정의된 type을 요구하므로 PTB에서 호출할 수 없다. 대신 public_transfer 및 public_share_object variant를 사용한다.
TxContext handling:
TxContext parameter(&TxContext 또는 &mut TxContext)는 runtime이 자동으로 inject하며 caller가 제공하지 않는다. TxContext는 parameter list의 어느 위치에나 나타날 수 있고, immutable(&TxContext)인 한 하나의 함수에 여러 TxContext parameter가 있을 수 있다. 이러한 parameter는 indexing을 위해 user-supplied 인자 count에 포함되지 않는다.
Non-public entry 함수 제한
Non-public entry 함수는 entry로 선언되었지만 public이 아닌 함수가다. 이는 private(entry fun f())이거나 public(package)(public(package) entry fun f())이다. 이러한 함수는 PTB에서 직접 호출할 수 있지만 다른 패키지에서는 호출할 수 없다.
Non-public entry function에는 하나의 restriction이 있다. Function이 호출될 때 argument가 "hot" clique(아래 참조)에 있으면 안 된다. 이 restriction의 직관은 non-public entry function의 argument가 function 실행 후 behavior를 강제할 수 있는 outstanding hot potato value와 얽히지 않도록 하는 것이다.
Hot potato values
Type에 drop ability와 store ability가 모두 없으면 그 value는 hot potato이다. Hot potato value는 트랜잭션이 완료되기 전에 consume(값으로 move)되어야 하며, 조용히 drop되거나 store될 수 없다.
Cliques
System은 clique를 사용해 어떤 value가 얽혀 있는지 추적한다. 목표는 위에서 설명한 것처럼 hot potato value가 non-public entry 함수 호출 밖의 behavior를 강제하지 못하게 하는 것이다. Clique는 live hot potato value 수와 그 value가 상호작 용한 value를 count하여 이러한 "entanglement"를 model한다:
- 각 PTB 입력은 hot count 0인 자체 clique에서 시작한다.
- value가 command의 인자로 함께 사용되면 clique가 merge된다. hot count는 더해진다.
- command의 hot potato return value는 merge된 clique의 hot count를 증가시킨다.
- hot potato를 move(consume)하면 해당 clique의 hot count가 감소한다.
- non-public
entrycall 전에 인자의 merged clique는 hot count 0이어야 한다.
Non-public entry 함수는 hot potato value를 consume할 수 있지만, 해당 clique의 마지막 hot value여야 한다. Hot count check는 인자가 consume된 뒤 함수가 verify되기 전에 발생한다.
Shared 객체 consumed by value
공유 객체를 값으로 consume하면 해당 clique는 영구적으로 "always hot"으로 표시된다. 공유 객체는 wrap될 수 없고 다시 share되거나 delete되어야 하므로, 값을 consume하는 것은 resolve될 수 없는 hot potato와 유사하게 처리된다. Non-public entry 함수는 공유 객체를 값으로 직접 받을 수 있지만, 이전에 값으로 consume된 공유 객체와 상호작용한 clique의 value는 받을 수 없다.
Examples
다음 모듈을 고려한다:
module ex::m;
public struct HotPotato()
public fun hot<T>(x: &mut Coin<T>): HotPotato { ... }
entry fun spend<T>(x: &mut Coin<T>) { ... }
public fun cool(h: HotPotato) { ... }
다음 invalid PTB에서는 command 0의 HotPotato가 spend 호출 시 Input(0)과 같은 clique에서 아직 live 상태이다:
// Invalid PTB
// Input 0: Coin<SUI>
// cliques: { Input(0) } => 0
0: ex::m::hot(Input(0));
// cliques: { Input(0), Result(0) } => 1
1: ex::m::spend(Input(0)); // INVALID: Input(0)'s clique has count > 0
2: ex::m::cool(Result(0));
하지만 spend를 호출하기 전에 hot potato가 consume되면 clique count가 0으로 내려가고 call이 성공한다:
// Valid PTB
// Input 0: Coin<SUI>
// cliques: { Input(0) } => 0
0: ex::m::hot(Input(0));
// cliques: { Input(0), Result(0) } => 1
1: ex::m::cool(Result(0));
// cliques: { Input(0) } => 0
2: ex::m::spend(Input(0)); // Valid: Input(0)'s clique has count 0
Flash loan scenario에서는 entanglement가 transitively 확장된다. 어떤 value가 loan에 직접 관여하지 않았더라도 loan의 hot potato와 같은 clique에 있으면 non-public entry call에 사용할 수 없다:
module flash::loan;
public struct Loan { amount: u64 }
public fun issue(bank: &mut Bank, amount: u64): (Balance<SUI>, Loan) { ERROR }
public fun repay(bank: &mut Bank, loan: Loan, repayment: Balance<SUI>) { ERROR }
// Invalid PTB
// Input 0: flash::loan::Bank, Input 1: u64
// cliques: { Input(0) } => 0, { Input(1) } => 0
0: flash::loan::issue(Input(0), Input(1))
// cliques: { Input(0), Input(1), NestedResult(0,0), NestedResult(0,1) } => 1
1: sui::coin::from_balance(NestedResult(0,0));
// cliques: { Input(0), Input(1), NestedResult(0,1), Result(1) } => 1
2: ex::m::spend(Result(1)); // INVALID: Result(1)'s clique has count > 0
3: sui::coin::into_balance(Result(1));
4: flash::loan::repay(Input(0), NestedResult(0,1), Result(3));
Command 1에서 만든 Coin이 flash loan에 직접 관여하지 않았더라도 outstanding Loan hot potato(NestedResult(0,1))와 같은 clique의 일부이다. spend를 호출하기 전에 loan을 repay하면 PTB가 valid해진다.
Pure value type checking 검증
pure value는 사용 시점까지 타입 검사를 받지 않는다.
pure value의 타입이 T인지 검사할 때 시스템은 먼저 T가 pure value에 유효한 타입인지 확인한다(Inputs section의 목록을 참조한다).
유효하다면 바이트를 검증한다.
bytes가 각 타입에 대해 모두 유효하기만 하면 하나의 pure value를 여러 타입과 함께 사용할 수 있다.
예를 들어 문자열은 ASCII string인 std::ascii::String으로도, UTF8 string인 std::string::String으로도 사용할 수 있다.
그러나 pure value를 가변 borrow한 뒤에는 타입이 고정되며, 이후의 모든 사용은 그 타입으로 이뤄져야 한다.
Publish 및 upgrade rule
두 command는 module bytes와 dependency ID를 command 구조 안에 직접 embedded한다. 하지만 이 값들은 PTB Argument 값이 아니다.
Publish:
- 하나의
sui::package::UpgradeCap을 반환한다. - 패키지가 staged된 뒤 runtime은 각 모듈의
init함수가 있으면 제공된 모듈 순서(직렬화된 bytes의 vector 순서)대로 호출한다.init은&mut TxContext와 선택적으로 one-time witness를 받는다. 추가init인자는 아직 지원되지 않지만 계획되어 있다. - 패키지는 실행 중
init함수에서 사용할 수 있다. 패키지는init함수가 실행되기 전에 staged된다.
Upgrade:
- 정확히 하나의 PTB 인자, 즉
sui::package::UpgradeTicket(by value)을 받는다. - 하나의
sui::package::UpgradeReceipt를 반환한다. init함수를 호출하지 않는다. 업그레이드에서 새로 추가된 모듈은init함수를 가질 수 없다. 이 제한은 향후 릴리스에서 해제될 예정이다.- ticket의 module digest와 package ID가 정확히 일치해야 한다.
- ticket에서 upgrade policy(compatible, additive, dep-only)를 enforcement한다.
Execution 종료
실행 마지막에는 남아 있는 값을 검사하고 트랜잭션의 effect를 계산한다.
For 입력:
-
남아 있는 immutable 또는 read only 입력 객체는 건너뛴다(수정이 일어나지 않음).
-
남아 있는 mutable 입력 객체는 원래 owner에게 반환된다(shared는 shared로, owned는 owned로 남는다).
-
남아 있는 pure 입력 value는 drop된다(허용되는 타입은 모두
copy와drop을 가진다). -
공유 객체는 삭제되거나 다시 공유되기만 한다. 그 외 operation(wrap, transfer, freezing)은 error를 일으킨다.
For 결과:
-
dropability를 가진 남은 결과는 drop된다. -
값에
copy는 있지만drop이 없다면 마지막 사용은 값에 의한 사용(이동으로 취급)이어야 한다. -
그렇지 않으면
drop이 없는 unused value에 대한 error가 발생한다.
For gas:
실행 시작 시 가스 코인에서 차감된 남은 SUI는 owner가 바뀌었더라도 코인에 반환된다.
가능한 최대 gas는 실행 시작 시 차감되고, 사용되지 않은 gas는 끝에 반환된다(모두 SUI 기준).
가스 코인은 TransferObjects로만 값으로 꺼낼 수 있으므로 wrap되거나 삭제되지 않는다.
그 뒤 전체 effect(생성, 수정, 삭제된 객체)가 실행 계층 밖으로 전달되고 Sui network가 이를 적용한다.
Execution example 예시
각 command의 실행을 따라가 보면 입력이 command를 통해 어떻게 흐르는지, 결과가 어떻게 누적되는지, 최종 트랜잭션 effect가 어떻게 결정되는지를 확인할 수 있다.
100 MIST를 지불하고 marketplace에서 item 2개를 산다고 가정해 보자.
하나는 자신이 보관하고 다른 하나와 남은 코인은 address 0x808에 있는 친구에게 보낸다.
{
inputs: [
Pure(/* @0x808 BCS bytes */ ...),
Object(SharedObject { /* Marketplace shared object */ id: market_id, ... }),
Pure(/* 100u64 BCS bytes */ ...),
]
commands: [
SplitCoins(GasCoin, [Input(2)]),
MoveCall("some_package", "some_marketplace", "buy_two", [], [Input(1), NestedResult(0, 0)]),
TransferObjects([GasCoin, NestedResult(1, 0)], Input(0)),
MoveCall("sui", "tx_context", "sender", [], []),
TransferObjects([NestedResult(1, 1)], NestedResult(3, 0)),
]
}
입력에는 친구의 address, marketplace 객체, 코인 분할 값이 포함된다.
command는 코인을 분할하고, marketplace 함수를 호출하고, 가스 코인과 객체 하나를 보내고, 자신의 address를 가져온 뒤(sui::tx_context::sender를 통해), 남은 객체를 자신에게 전송한다.
Command 0: SplitCoins(GasCoin, [Input(2)])
가스 코인에 가변 reference로 접근하고 Input(2)를 100u64로 적재한다(이동이 아니라 복사).
새 코인을 만든다.
| State | Value |
|---|---|
| Gas coin balance | 499,900 MIST |
| Result | [Coin<SUI> { id: new_coin, value: 100 }] |
Command 1: MoveCall("some_package", "some_marketplace", "buy_two", ...)
buy_two(marketplace: &mut Marketplace, coin: Coin<SUI>, ctx: &mut TxContext): (Item, Item)를 호출한다.
Input(1)은 mutable reference로, NestedResult(0, 0)은 값으로 사용한다(코인이 이동되어 소비된다).
| State | Value |
|---|---|
Results[0][0] | moved |
| Result | [Item { id: id1 }, Item { id: id2 }] |
Command 2: TransferObjects([GasCoin, NestedResult(1, 0)], Input(0))
가스 코인과 첫 번째 item을 0x808로 전송한다.
두 객체 모두 값으로 가져가며(이동된다).
| State | Value |
|---|---|
| Gas coin | moved |
Results[1][0] | moved |
| Result | [] |
Command 3: MoveCall("sui", "tx_context", "sender", [], [])
sender(ctx: &TxContext): address를 호출한다.
sender의 address를 반환한다.
| State | Value |
|---|---|
| Result | [sender_address] |
Command 4: TransferObjects([NestedResult(1, 1)], NestedResult(3, 0))
두 번째 item을 sender에게 전송한다. item은 값으로 이동되고 address는 값으로 복사된다.
| State | Value |
|---|---|
Results[1][1] | moved |
| Result | [] |
Initial state 상태
Gas Coin: Coin<SUI> { id: gas_coin, balance: 1_000_000u64 }
Inputs: [Pure(@0x808), Marketplace { id: market_id }, Pure(100u64)]
Results: []
최대 가스 예산인 500_000이 차감된 뒤 상태는 다음과 같다:
Gas Coin: Coin<SUI> { id: gas_coin, balance: 500_000u64 }