본문으로 건너뛰기

래핑된 객체

Move에서 struct를 중첩하여 데이터 구조를 구성할 수 있다. 이 예제는 기본적인 wrapper 패턴을 보여준다:

public struct Foo has key {
id: UID,
bar: Bar,
}

public struct Bar has store {
value: u64,
}

Struct 타입을 (key ability가 있는) Sui 객체 struct에 임베드하려면, 해당 struct 타입은 store ability를 가져야 한다.

앞의 예제에서 Bar는 일반 struct이지만, key ability가 없으므로 Sui 객체가 아니다.

다음 코드는 Bar를 객체로 바꾸며, 이는 여전히 Foo 안에 wrap할 수 있다:

public struct Bar has key, store {
id: UID,
value: u64,
}

이제 Bar 또한 Sui 객체 타입이다. Bar 타입의 Sui 객체를 Foo 타입의 Sui 객체에 넣는다면, Foo 객체 타입은 Bar 객체 타입을 wrap한다. Foo 객체 타입은 wrapper 또는 wrapping 객체이다.

Sui 객체를 다른 객체로 wrapping하는 것에는 몇 가지 흥미로운 결과가 따른다. Object가 wrapped되면, 해당 객체는 더 이상 온체인에 독립적으로 존재하지 않는다. 더 이상 ID로 객체를 조회할 수 없다. 해당 객체는 그것을 wrap하는 객체의 데이터 일부가 된다. 가장 중요하게, Sui Move 호출에서 wrapped된 객체를 어떤 방식으로도 인수로 전달할 수 없다. 유일한 접근 지점은 wrapping 객체를 통하는 것이다.

A가 B를 wrap하고, B가 C를 wrap하며, C 또한 A를 wrap하는 순환 wrapping 동작을 생성하는 것은 불가능하다.

어느 시점에, wrapped된 객체를 꺼내서 address로 전송하거나, 수정, 삭제 또는 동결할 수 있다. 이를 unwrapping이라고 한다. Object가 unwrapped되면, 이는 다시 독립적인 객체가 되며 온체인에서 직접 접근할 수 있다. 또한 Wrapping과 unwrapping에 대한 중요한 속성이 있다: 객체의 ID는 wrapping과 unwrapping 전후에도 동일하게 유지된다.

Sui 객체를 다른 Sui 객체로 wrap하는 몇 가지 방법이 있으며, 이들의 사용 사례는 일반적으로 다르다. 이 섹션에서는 일반적인 사용 사례와 함께 Sui 객체를 wrap하는 세 가지 다른 방법을 설명한다.

Create a wrapped 객체

다음 예시는 객체를 wrap하는 데 사용되는 기본 함수를 보여준다:

public fun wrap(o: Object, ctx: &mut TxContext) {
transfer::transfer(Wrapper { id: object::new(ctx), o }, ctx.sender());
}

Unwrap a wrapped 객체

다음 예시는 객체를 unwrap하는 데 사용되는 기본 함수를 보여준다:

#[lint_allow(self_transfer)]
public fun unwrap(w: Wrapper, ctx: &TxContext) {
let Wrapper { id, o } = w;
id.delete();
transfer::public_transfer(o, ctx.sender());
}

Direct wrapping

Direct wrapping은 Sui 객체 타입이 다른 Sui 객체 타입을 직접 필드로 포함할 때 발생한다. Direct wrapping으로 얻을 수 있는 가장 중요한 특성은 다음과 같다:

  • Wrapped 객체는 wrapper를 파괴하지 않고는 추출할 수 없다
  • 강력한 캡슐화 보장을 제공한다
  • Object 잠금 패턴을 구현하는 데 이상적이다
  • 접근을 수정하기 위해 명시적인 contract 호출이 필요하다

다음 신뢰할 수 있는 스왑의 구현 예제는 direct wrapping 사용 방법을 보여준다. scarcitystyle을 가진 NFT 스타일의 Object 타입이 있다고 가정한다. 이 예제에서 scarcity는 객체의 희귀도를 결정하며 (아마도 더 희귀할수록 시장 가치가 높을 것이다), style은 객체 콘텐츠/타입 또는 렌더링 방식을 결정한다. 여러 객체를 보유하고 있으며 이를 다른 사람과 교환하려는 경우, 공정한 거래임을 보장하고 싶을 것이다. 동일한 scarcity를 가진 다른 객체와만 교환하고 싶지만, 다른 style을 원한다 (더 많은 스타일을 수집할 수 있도록).

먼저, 이러한 객체 타입을 정의한다:

public struct Object has key, store {
id: UID,
scarcity: u8,
style: u8,
}

실제 애플리케이션에서는 객체의 공급량이 제한되도록 하고, 소유자 목록에 민트하는 메커니즘이 있을 수 있다. 단순화된 데모 목적상 이 예제는 생성 과정을 간소화한다:

public fun new(scarcity: u8, style: u8, ctx: &mut TxContext): Object {
Object { id: object::new(ctx), scarcity, style }
}

자신의 객체와 다른 사람의 객체 간의 스왑/거래를 활성화할 수도 있다. 예를 들어, 두 주소에서 두 객체를 가져와 소유권을 교환하는 함수를 정의한다. 그러나 이는 Sui에서 작동하지 않는다. Object 소유자만이 객체를 변경하는 트랜잭션을 보낼 수 있기 때문이다. 따라서 한 사람이 자신의 객체와 다른 사람의 객체를 스왑하는 트랜잭션을 보낼 수 없다.

또 다른 일반적인 해결책은 객체를 NFT 마켓플레이스나 staking pool과 같은 pool로 보내고, 그곳에서 스왑을 수행하는 것이다 (즉시 또는 나중에 수요가 있을 때). 다른 챕터에서는 누구나 변경할 수 있는 공유 객체의 개념을 다루며, 이를 통해 누구나 공유 객체 pool에서 작업할 수 있는 방법을 설명한다. 이 챕터는 소유 객체를 사용해 동일한 효과를 달성하는 방법에 초점을 맞춘다. 소유 객체만 사용하는 트랜잭션은 Sui에서 합의가 필요하지 않기 때문에, 공유 객체를 사용하는 것보다 더 빠르고 (가스 측면에서) 저렴하다.

Object를 스왑하려면, 동일한 address가 두 객체를 모두 소유해야 한다. 자신의 객체를 스왑하려는 사용자는 스왑 서비스를 제공하는 사이트와 같은 제3자에게 객체를 보낼 수 있으며, 제3자는 스왑을 수행하고 객체를 적절한 owner에게 보내는 것을 돕는다. (코인 및 NFT 같은) Object에 대한 수탁을 유지하고 제3자에게 완전한 수탁을 넘기지 않기 위해 direct wrapping을 사용한다. Wrapper 객체 타입을 다음과 같이 정의한다:

public struct SwapRequest has key {
id: UID,
owner: address,
object: Object,
fee: Balance<SUI>,
}

SwapRequest는 Sui 객체 타입을 정의하며, 스왑할 object를 wrap하고, 객체의 원래 owner를 추적한다. 또한 이 스왑에 대해 제3자에게 일정 수수료를 지불해야 할 수도 있다. Object를 소유한 사람이 스왑을 요청하는 인터페이스를 정의한다:

public fun request_swap(
object: Object,
fee: Coin<SUI>,
service: address,
ctx: &mut TxContext,
) {
assert!(coin::value(&fee) >= MIN_FEE, EFeeTooLow);

let request = SwapRequest {
id: object::new(ctx),
owner: ctx.sender(),
object,
fee: coin::into_balance(fee),
};

transfer::transfer(request, service)
}

위 함수에서는 객체를 값으로 전달해야 하며, 이를 완전히 소비하여 SwapRequest에 wrapped 되어야 스왑 요청이 만들어진다. 이 예제는 또한 수수료를 (Coin<SUI> 타입으로) 제공하고 수수료가 충분한지 확인한다. 이 예제는 Coinwrapper 객체에 들어갈 때 CoinBalance로 바꾼다. 이는 Coin이 Sui 객체 타입이며 (트랜잭션 입력 또는 address로 전송된 객체와 같은) Sui 객체로 전달하기 위해서만 사용되기 때문이다. 다른 구조체에 임베드되어야 하는 코인 잔액의 경우, 불필요한 UID 필드를 가지고 다니는 오버헤드를 피하기 위해 대신 Balance를 사용한다.

그러면 wrapper 객체는 호출에 service로 지정된 address를 가진 서비스 운영자에게 전송된다.

서비스 운영자가 두 주소에서 보낸 두 객체 간의 스왑을 수행하기 위해 호출할 수 있는 함수에 대한 함수 인터페이스는 다음과 유사하다:

public fun execute_swap(s1: SwapRequest, s2: SwapRequest): Balance<SUI>;

여기서 s1 s2는 서로 다른 object 소유자로부터 서비스 운영자에게 전송된 두 wrapped object이다. 두 wrapped object는 결국 unpacked 되어야 하므로 값으로 전달된다.

먼저, 두 객체를 unpack하여 내부 필드를 얻는다:

let SwapRequest {id: id1, owner: owner1, object: o1, fee: fee1} = s1;
let SwapRequest {id: id2, owner: owner2, object: o2, fee: fee2} = s2;

그런 다음, 스왑이 적법한지 확인한다 (두 객체의 희귀도가 동일하고 스타일이 다름):

assert!(o1.scarcity == o2.scarcity, EBadSwap);
assert!(o1.style != o2.style, EBadSwap);

실제 스왑을 수행한다:

transfer::transfer(o1, owner2);
transfer::transfer(o2, owner1);

위 코드는 o1o2의 원래 소유자에게 전송하고, o2o1의 원래 소유자에게 전송한다. 그 후 서비스는 wrapping SwapRequest 객체들을 삭제할 수 있다:

id1.delete();
id2.delete();

마지막으로, 서비스는 fee1fee2를 함께 병합하고 반환한다. 서비스 제공자는 이것을 코인으로 바꾸거나, 모든 수수료를 수집하는 더 큰 pool로 병합할 수 있다:

fee1.join(fee2);

이 호출 후, 두 객체는 스왑되고 서비스 제공자는 서비스 수수료를 받는다.

Contract가 SwapRequest를 처리하는 유일한 방법 - execute_swap - 만을 정의했기 때문에, 서비스 운영자는 소유권이 있음에도 불구하고 SwapRequest와 상호작용할 다른 방법이 없다.

전체 소스 코드는 trusted_swap 예제에서 확인할 수 있다.

Direct wrapping을 사용하는 더 복잡한 예제를 보려면, escrow 예제를 참조한다.

Wrapping through Option

Sui 객체 타입 BarFoo로 직접 wrapped될 때, 유연성은 많지 않다: Foo 객체에는 반드시 Bar 객체가 있어야 하며, Bar 객체를 꺼내기 위해서는 Foo 객체를 파괴해야 한다. 그러나, 더 높은 유연성을 위해, wrapping 타입이 항상 wrapped 객체를 포함하지 않을 수도 있으며, wrapped 객체가 다른 객체로 교체될 수도 있다.

이 사용 사례를 보여주기 위해, 간단한 게임 캐릭터를 설계하자: 검과 방패를 든 전사이다. 전사는 검과 방패를 가지고 있을 수도, 없을 수도 있다. 전사는 언제든 검과 방패를 추가하고 기존 장비를 교체할 수 있어야 한다. 이를 설계하기 위해 SimpleWarrior 타입을 정의한다:

public struct SimpleWarrior has key {
id: UID,
sword: Option<Sword>,
shield: Option<Shield>,
}

SimpleWarrior 타입은 다음과 같이 정의된 선택적 swordshield를 wrap한다:

public struct Sword has key, store {
id: UID,
strength: u8,
}

public struct Shield has key, store {
id: UID,
armor: u8,
}

새 전사를 생성할 때, swordshieldnone으로 설정하여 아직 장비가 없음을 나타낸다:

public fun create_warrior(ctx: &mut TxContext) {
let warrior = SimpleWarrior {
id: object::new(ctx),
sword: option::none(),
shield: option::none(),
};
transfer::transfer(warrior, ctx.sender())
}

그런 다음 새 검이나 새 방패를 장착하는 함수를 정의할 수 있다:

public fun equip_sword(warrior: &mut SimpleWarrior, sword: Sword, ctx: &mut TxContext) {
if (warrior.sword.is_some()) {
let old_sword = warrior.sword.extract();
transfer::transfer(old_sword, ctx.sender());
};
warrior.sword.fill(sword);
}

앞의 예시의 함수는 warriorSimpleWarrior의 mutuable reference로 전달하고, sword를 값으로 전달하여 그것을 warrior 안에 wrap한다.

Sworddrop ability가 없는 Sui 객체 타입이므로, 전사가 이미 검을 장착한 경우 그 검을 drop 할 수 없다. 만약 당신이 기존 sword를 먼저 확인하고 꺼내지 않고 option::fill을 호출하면 오류가 발생한다. equip_sword에서는 먼저 이미 장착된 검이 있는지 확인한다. 있다면 꺼내서 sender에게 다시 보낸다. 플레이어 입장에서는 새 검을 장착할 때 기존 검이 인벤토리로 돌아오는 것이다.

소스코드는 simple_warrior 예제에서 찾을 수 있다.

더 복잡한 예제를 보려면, hero를 참조한다.

Wrapping through vector

다른 Sui 객체의 vector 필드에 객체를 wrapping하는 개념은 Option을 통해 wrapping하는 것과 매우 유사하다: 하나의 객체가 동일한 타입의 wrapped 객체를 0개, 1개 또는 여러 개 포함할 수 있다. Vector를 통한 wrapping은 다음과 유사하다:

public struct Pet has key, store {
id: UID,
cuteness: u64,
}

public struct Farm has key {
id: UID,
pets: vector<Pet>,
}

위 예시는 Pet의 vector를 Farm 안에 wrap하며, Farm 객체를 통해서만 접근할 수 있다.