본문으로 건너뛰기

프로그래머블 트랜잭션 블록 (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는 u64String 값처럼 바이트만으로 순수하게 구성할 수 있는 단순한 Move 값이다. 역사적인 이유로 Rust 구현에서는 InputCallArg라고 부른다.

  • 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): sourceCoinsdestinationCoin에 병합한다.

    • 예시: 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을 참조한다. 이 값은 특별한 제약이 있으므로 다른 입력과 분리되어 있다. TransferObjects command를 통해서만 값으로 꺼낼 수 있다. 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에 대해 다음 검증을 수행한다:

  • SplitCoinsMergeCoins: 인자 배열(AmountArgs, ToMergeArgs)이 비어 있지 않은지 검증한다.

  • MakeMoveVec: 비어 있는 Args vector에는 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::UIDcopy ability가 없기 때문에 항상 이동된다.

인자가 이동된 뒤 어떤 형태로든 다시 사용되면 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을 직접 호출할 수 없다. 예를 들어 transfershare_object 같은 특정 sui::transfer function은 호출 module에 정의된 type을 요구하므로 PTB에서 호출할 수 없다. 대신 public_transferpublic_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 entry call 전에 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의 HotPotatospend 호출 시 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

PublishUpgrade command는 system-created value를 return한다. 이 value는 regular object와 같은 argument usage rule을 따른다. Publish는 upgrade capability를 반환하고, upgrade는 upgrade receipt를 반환한다. 이러한 value도 transaction이 끝나기 전에 consume되어야 한다.

Execution 종료

실행 마지막에는 남아 있는 값을 검사하고 transaction의 effect를 계산한다.

For inputs:

  • 남아 있는 immutable 또는 read only input object는 건너뛴다(수정이 일어나지 않음).

  • 남아 있는 mutable input object는 원래 owner에게 반환된다(shared는 shared로, owned는 owned로 남는다).

  • 남아 있는 pure input value는 drop된다(허용되는 타입은 모두 copydrop을 가진다).

  • shared object는 삭제되거나 다시 공유되기만 한다. 그 외 operation(wrap, transfer, freezing)은 error를 일으킨다.

For results:

  • drop ability를 가진 남은 결과는 drop된다.

  • 값에 copy는 있지만 drop이 없다면 마지막 사용은 값에 의한 사용(이동으로 취급)이어야 한다.

  • 그렇지 않으면 drop이 없는 unused value에 대한 error가 발생한다.

For gas:

실행 시작 시 gas coin에서 차감된 남은 SUI는 owner가 바뀌었더라도 coin에 반환된다. 가능한 최대 gas는 실행 시작 시 차감되고, 사용되지 않은 gas는 끝에 반환된다(모두 SUI 기준). gas coin은 TransferObjects로만 값으로 꺼낼 수 있으므로 wrap되거나 삭제되지 않는다.

그 뒤 전체 effect(생성, 수정, 삭제된 object)가 실행 계층 밖으로 전달되고 Sui network가 이를 적용한다.

Execution example 예시

각 command의 실행을 따라가 보면 입력이 command를 통해 어떻게 흐르는지, 결과가 어떻게 누적되는지, 최종 transaction effect가 어떻게 결정되는지를 확인할 수 있다.

100 MIST를 지불하고 marketplace에서 item 2개를 산다고 가정해 보자. 하나는 자신이 보관하고 다른 하나와 남은 coin은 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 object, coin 분할 값이 포함된다. command는 coin을 분할하고, marketplace 함수를 호출하고, gas coin과 object 하나를 보내고, 자신의 address를 가져온 뒤(sui::tx_context::sender를 통해), 남은 object를 자신에게 전송한다.

Command 0: SplitCoins(GasCoin, [Input(2)])

gas coin에 가변 reference로 접근하고 Input(2)100u64로 적재한다(이동이 아니라 복사). 새 coin을 만든다.

StateValue
Gas coin balance499,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)은 값으로 사용한다(coin이 이동되어 소비된다).

StateValue
Results[0][0]moved
Result[Item { id: id1 }, Item { id: id2 }]

Command 2: TransferObjects([GasCoin, NestedResult(1, 0)], Input(0))

gas coin과 첫 번째 item을 0x808로 전송한다. 두 object 모두 값으로 가져가며(이동된다).

StateValue
Gas coinmoved
Results[1][0]moved
Result[]

Command 3: MoveCall("sui", "tx_context", "sender", [], [])

sender(ctx: &TxContext): address를 호출한다. sender의 address를 반환한다.

StateValue
Result[sender_address]

Command 4: TransferObjects([NestedResult(1, 1)], NestedResult(3, 0))

두 번째 item을 sender에게 전송한다. item은 값으로 이동되고 address는 값으로 복사된다.

StateValue
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: []

최대 gas budget인 500_000이 차감된 뒤 상태는 다음과 같다:

Gas Coin: Coin<SUI> { id: gas_coin, balance: 500_000u64 }

Final state 상태

Gas Coin: _ (moved)
Inputs: [Pure(@0x808), Marketplace { id: market_id } (mutated), Pure(100u64)]
Results: [
[_],
[_, _],
[],
[sender_address],
[]
]

End of execution check 확인

  • Input objects: gas coin은 이동되었다. Marketplace는 shared 상태로 남으며 수정되었다.

  • Results: 남아 있는 값은 모두 drop ability를 가진다(Pure 입력, sender address). 다른 결과는 모두 이동되었다.

  • Shared objects: Marketplace는 이동되지 않았으며 shared 상태로 남는다.

Transaction effects:

  • gas에서 분할한 coin(new_coin)은 같은 transaction 안에서 생성되고 삭제되므로 나타나지 않는다.

  • gas coin과 Item { id: id1 }0x808로 전송된다. 남은 gas는 owner 변경 여부와 관계없이 gas coin으로 반환된다.

  • Item { id: id2 }는 sender에게 전송된다.

  • Marketplace object는 shared 상태로 반환되며 수정되었다.