프로그래머블 트랜잭션 블록 (PTB)
Sui의 transaction은 transaction 결과를 정의하기 위해 입력에 대해 실행되는 명령 그룹으로 구성된다. **programmable transaction blocks (PTBs)**라고 부르는 이 명령 그룹이 Sui의 모든 사용자 transaction을 정의한다. PTB를 사용하면 사용자는 새 Move package를 publish하지 않고도 하나의 transaction 안에서 여러 Move 함수를 호출하고, 자신의 object를 관리하고, 자신의 coin을 관리할 수 있다. 자동화와 transaction builder를 염두에 두고 설계된 PTB는 transaction을 생성하는 가볍고 유연한 방식이다.
그러나 loop 같은 더 복잡한 프로그래밍 패턴은 지원되지 않는다. 이런 경우에는 새 Move package를 publish해야 한다.
PTB 안의 개별 transaction 명령은 순서대로 실행된다. 한 transaction 명령의 결과를 같은 PTB 안에 있는 이후 transaction 명령에서 사용할 수 있다. object 수정이나 transfer 같은 블록 안 각 transaction 명령의 effect는 transaction 마지막에 원자적으로 적용된다. 한 transaction 명령이 실패하면 전체 블록이 실패하고 어떤 명령의 effect도 적용되지 않는다.
PTB는 한 번의 실행에서 최대 1,024개의 고유 operation을 수행할 수 있지만, 전통적인 블록체인의 transaction은 같은 결과를 얻기 위해 1,024번의 개별 실행이 필요하다. 이 구조는 더 저렴한 gas fee도 촉진한다. 개별 transaction을 처리하는 비용은 같은 transaction을 PTB로 묶어 처리하는 비용보다 항상 더 높다.
Transaction component 구성
실행 의미론과 관련해 PTB에는 두 부분이 있 다. transaction sender나 gas limit 같은 다른 transaction 정보는 참조될 수 있지만 여기서는 범위 밖이다. PTB의 구조는 다음과 같다:
{
inputs: [Input],
commands: [Command],
}
두 주요 구성 요소를 더 자세히 보면 다음과 같다:
-
inputs값은 인자 벡터[Input]이다. 이 인자들은 command에서 사용할 수 있는 object이거나 pure value이다. object는 sender가 소유한 것이거나 shared object 또는 immutable object이다. pure value는u64나String값처럼 바이트만으로 순수하게 구성할 수 있는 단순한 Move 값이다. 역사적인 이유로 Rust 구현에서는Input을CallArg라고 부른다. -
commands값은 command 벡터[Command]이다. 가능한 command는 다음과 같다: -
tx.splitCoins(coin, amounts): 제공된 coin에서 정의한 amount만큼 분할해 새 coin을 만든다. 이후 transaction에서 사용할 수 있도록 coin을 반환한다.- 예시:
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): object 목록을 지정한 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에 전달할 수 있는 object vector를 구성한다. vector를 입력으로 정의할 다른 방법이 없기 때문에 이 command가 필요하다.- 예시:
tx.makeMoveVec({ elements: [tx.object(id1), tx.object(id2)] })
- 예시:
-
tx.publish(modules, dependencies): Move package를 publish한다. upgrade capability object를 반환한다. -
tx.upgrade(modules, dependencies, packageId: EXAMPLE_PACKAGE_ID, ticket): 기존 package를 업그레이드한다. 업그레이드된 module에 대해서는 어떤init함수도 호출되지 않는다.
각 PTB 명령에 대해 더 알아본다.
Input과 result
입력은 PTB에 제공되는 값이고, 결과는 PTB command가 만들어 내는 값이다. 입력은 object이거나 단순한 Move 값이며, 결과는 object를 포함한 임의의 Move 값이다. 입력과 결과는 PTB 실행을 통과하는 데이터 흐름을 이룬다.
이를 값 배열을 채우는 것으로 볼 수 있다. 입력은 단일 배열이지만 결과는 각 개별 transaction 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: gas 지불에 사용하는 SUI coin을 참조한다. 이 값은 특별한 제약이 있으므로 다른 입력과 분리되어 있다.TransferObjectscommand를 통해서만 값으로 꺼낼 수 있다. gas coin의 일부를 다른 곳에서 사용하려면 먼저SplitCoins로 coin을 분할한다. -
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를 실행할 때 입력 벡터는 입력 object 또는 pure value bytes로 채워진다. 그 다음 transaction command가 순서대로 실행되고, 결과는 결과 벡터에 저장된다. 마지막으로 transaction의 effect가 원자적으로 적용된다.
Execution 시작
실행 시작 시 PTB 런타임은 입력 object를 가져와 입력 배열에 적재한다. 이 object들은 존재 여부와 유효한 소유권 같은 규칙을 검사하며 네트워크가 이미 검증한 상태이다. pure value bytes도 배열에 적재되지만 사용할 때까지는 검증되지 않는다.
이 단계에서 가장 중요한 점은 gas coin에 대한 effect이다. 실행 시작 시 최대 gas budget(SUI 기준)이 gas coin에서 인출된다. 사용되지 않은 gas는 실행 끝에 gas coin으로 반환되며, coin의 owner가 바뀌었더라도 마찬가지이다.
Object consumption 처리
Move command가 생성하거나 반환한 모든 object는 소비되거나(파기, 전송, 다른 command에서 사용), 타입에 drop ability가 있다면 명시적으로 drop되어야 한다.
PTB에서 Move command를 통해 object를 생성한 뒤 이를 파기하거나 전송하거나 다음 command에서 사용하지 않으면 transaction은 error와 함께 실패한다.
Pre-execution validation 검증
transaction에 서명되면 네트워크는 특정 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이면 값을 복사하고 그렇지 않으면 이동한다. object는sui::object::UID에copyability가 없기 때문에 항상 이동된다.
인자가 이동된 뒤 어떤 형태로든 다시 사용되면 transaction은 실패한다. 이동된 인자를 원래 위치(입력 또는 결과 인덱스)로 되돌리는 방법은 없다.
인자가 복사되었지만 drop ability가 없다면 마지막 사용은 이동으로 추론된다.
그 결과 인자에 copy는 있고 drop이 없다면 마지막 사용은 값으로 이뤄져야 한다.
그렇지 않으면 drop이 없는 값이 사용되지 않았기 때문에 transaction이 실패한다.
Borrowing rule 규칙
borrow는 안전한 reference 사용을 보장하기 위해 추가 규칙을 따른다:
-
Mutable borrow: 다른 borrow가 남아 있어서는 안 된다. 겹치는 mutable borrow는 dangling reference를 만들 수 있다.
-
Immutable borrow: mutable borrow가 남아 있어서는 안 된다. 여러 immutable borrow는 허용된다.
-
Move: borrow가 남아 있어서는 안 된다. 빌린 값을 이동하면 기존 reference가 무효화된다.
-
Copy: 남 아 있는 borrow와 관계없이 허용된다.
Special object handling 처리
object 입력은 underlying object의 타입 T를 가진다.
예외는 ObjectArg::Receiving 입력이며, 이 경우 타입은 sui::transfer::Receiving<T>이다.
이 wrapper는 object가 sender가 아니라 다른 object에 의해 소유됨을 나타낸다.
부모 object와 함께 sui::transfer::receive를 호출해 이를 unwrap하고 소유권을 증명한다.
shared object는 transaction 끝까지 shared 상태를 유지하거나 삭제되도록 보장하기 위해 값으로 사용하는 데 제약이 있다:
-
읽기 전용 shared object(
mutable이 아닌 것으로 표시됨)는 값으로 사용할 수 없다. -
shared object는 transfer하거나 freeze할 수 없다. 이런 operation은 실행 중에는 성공하지만 transaction 마지막에 실패를 일으킨다.
-
shared object는 실행 중에 wrap되거나 dynamic field로 변환될 수 있지만, transaction이 끝나기 전에 다시 공유되거나 삭제되어야 한다.
Move call rule 규칙
PTB는 public function과 모든 entry function을 호출할 수 있다. 여기에는 private entry fun f()와 public(package) entry fun f()도 포함된다. Non-entry private function과 public(package) function은 PTB에서 호출할 수 없다. 이런 이유로 public function에 entry를 추가할 필요는 없다.
이전에는 entry function이 public function보다 parameter와 return type에 더 제한적인 signature restriction을 가졌다. 이러한 restriction은 제거되었다. 이제 entry function은 모든 public function과 같은 signature를 가질 수 있다.
return type:
Move call은 reference(&T 또는 &mut T)를 return할 수 없다. 이 restriction은 향후 제거될 예정이다.
Private generics:
일부 framework function에는 같은 module에 정의된 type으로만 instantiate할 수 있는 type parameter가 있다. PTB는 module이 아니므로 이러한 type을 제공할 수 없고, 따라서 해당 function을 직접 호출할 수 없다. 예를 들어 transfer와 share_object 같은 특정 sui::transfer function은 호출 module에 정의된 type을 요구하므로 PTB에서 호출할 수 없다. 대신 public_transfer 및 public_share_object variant를 사용한다.
TxContext handling:
TxContext parameter(&TxContext 또는 &mut TxContext)는 runtime이 자동으로 inject하며 caller가 제공하지 않는다. TxContext는 parameter list의 어느 위치에나 나타날 수 있고, immutable(&TxContext)인 한 하나의 function에 여러 TxContext parameter가 있을 수 있다. 이러한 parameter는 indexing을 위해 user-supplied argument count에 포함되지 않는다.
Non-public entry function 제한
Non-public entry function은 entry로 선언되었지만 public이 아닌 function이다. 이는 private(entry fun f())이거나 public(package)(public(package) entry fun f())이다. 이러한 function은 PTB에서 직접 호출할 수 있지만 다른 package에서는 호출할 수 없다.
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는 transaction이 완료되기 전에 consume(값으로 move)되어야 하며, 조용히 drop되거나 store될 수 없다.
Cliques
System은 clique를 사용해 어떤 value가 얽 혀 있는지 추적한다. 목표는 위에서 설명한 것처럼 hot potato value가 non-public entry function 호출 밖의 behavior를 강제하지 못하게 하는 것이다. Clique는 live hot potato value 수와 그 value가 상호작용한 value를 count하여 이러한 "entanglement"를 model한다:
- 각 PTB input은 hot count 0인 자체 clique에서 시작한다.
- value가 command의 argument로 함께 사용되면 clique가 merge된다. hot count는 더해진다.
- command의 hot potato return value는 merge된 clique의 hot count를 증가시킨다.
- hot potato를 move(consume)하면 해당 clique의 hot count가 감소한다.
- non-public
entrycall 전에 argument의 merged clique는 hot count 0이어야 한다.
Non-public entry function은 hot potato value를 consume할 수 있지만, 해당 clique의 마지막 hot value여야 한다. Hot count check는 argument가 consume된 뒤 function이 verify되기 전에 발생한다.
Shared objects consumed by value
Shared object를 값으로 consume하면 해당 clique는 영구적으로 "always hot"으로 표시된다. Shared object는 wrap될 수 없고 다시 share되거나 delete되어야 하므로, 값을 consume하는 것은 resolve될 수 없는 hot potato와 유사하게 처리된다. Non-public entry function은 shared object를 값으로 직접 받을 수 있지만, 이전에 값으로 consume된 shared object와 상호작용한 clique의 value는 받을 수 없다.
Examples
다음 module을 고려한다:
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해진다.