본문으로 건너뛰기

프로그래머블 트랜잭션 블록 (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 components

실행 의미론과 관련해 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 명령에 대해 더 알아본다.

Inputs and results

입력은 PTB에 제공되는 값이고, 결과는 PTB command가 만들어 내는 값이다. 입력은 object이거나 단순한 Move 값이며, 결과는 object를 포함한 임의의 Move 값이다. 입력과 결과는 PTB 실행을 통과하는 데이터 흐름을 이룬다.

이를 값 배열을 채우는 것으로 볼 수 있다. 입력은 단일 배열이지만 결과는 각 개별 transaction command마다 하나씩 배열이 있어 결과 값의 2D 배열이 만들어진다. 타입이 허용한다면 이 값들은 가변 또는 불변으로 borrow하거나, 복사하거나, 이동해 접근할 수 있으며, 이동은 재인덱싱 없이 값을 배열 밖으로 가져간다.

inputs and results에 대해 더 알아본다.

Argument structure and 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가 원자적으로 적용된다.

Start of 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 rules

각 인자는 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 rules

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이 끝나기 전에 다시 공유되거나 삭제되어야 한다.

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한 뒤에는 타입이 고정되며, 이후의 모든 사용은 그 타입으로 이뤄져야 한다.

End of 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 checks

  • 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 상태로 반환되며 수정되었다.