본문으로 건너뛰기

Transfer to Object

object를 address로 전송하는 것과 동일한 방식으로, 동일한 함수를 사용하여 object ID로 object를 전송할 수 있다. 이는 Sui에서 address와 object가 모두 32바이트 ID를 사용하며, 두 ID가 구분되지 않기 때문이다(이들은 중복되지 않음이 보장된다). transfer to object 작업은 이 특성을 활용하여 전송 작업의 address 입력에 object ID를 제공할 수 있도록 한다.

노트

Party Objects의 경우, 소유 address가 object ID에 해당하는 경우 Transfer to Object 메커니즘이 지원되지 않는다.

동일한 ID 구조로 인해 object를 전송할 때 address 필드에 object ID를 사용할 수 있다. 실제로 address가 소유한 object와 관련된 모든 기능은 다른 object가 소유한 object에도 동일하게 작동하며, 단지 address를 object ID로 교체하면 된다.

object를 다른 object로 전송하는 것은 기본적으로 부모–자식 인증 관계를 설정하는 것이다. 다른 object로 전송된 object는 부모 object의(전이적일 수 있는) 소유자가 수신할 수 있다. 부모(수신) object의 타입을 정의한 모듈이 자식 object 수신에 대한 접근 제어를 정의한다.

전송된 자식 object에 대한 접근 제한은 transaction 실행 중 부모 object의 UID에 대한 mutable 접근을 허용함으로써 동적으로 적용된다. 이로 인해 owned object, dynamic field object, wrapped object 및 shared object로 object를 전송하고 이들로부터 object를 수신할 수 있다.

transfer to object 작업의 이점 중 하나는, 예를 들어 온체인 지갑이나 계정이 안정적인 ID를 유지할 수 있다는 점이다. object의 전송은 수신 object의 상태와 관계없이 해당 ID에 영향을 주지 않는다. object를 전송하면 해당 object의 모든 자식 object가 함께 이동하며, object의 address는 전송하든, wrap하든, dynamic field로 보유하든 동일하다.

Transferring to object

일반적인 object 전송과 마찬가지로 수신 object ID가 실제로 존재하는지 확인해야 한다. 또한 수신 object가 immutable object가 아닌지도 확인해야 한다. immutable object로 전송된 object에는 접근할 수 없다.

전송하려는 object와 수신 object의 타입 모두에 유의해야 한다. 수신(부모) object는 항상 다음을 수행할 수 있다:

  • 전송된 object에 접근하기 위해 동적으로 검사 가능한 조건자를 정의한다.
  • 전송된 object에 대한 접근을 지원하지 않는다. 해당 패키지의 향후 버전에서 이 기능을 지원할 수 있으나, 이를 포함할지는 패키지 작성자에게 달려 있다.

전송되는 object가 key ability만 가진 경우:

  • 전송되는 object를 정의하는 모듈은 custom transfer 함수와 유사하게 해당 object에 대한 custom receive 함수를 구현해야 한다. 전송되는 object를 정의하는 모듈은 custom transfer 함수와 유사하게 해당 object에 대한 custom receive 함수를 구현해야 한다. custom transfer 함수와 마찬가지로, custom receivership 함수는 개발자가 강제할 수 있는 임의의 제약을 가질 수 있고, 사용자가 이를 인지해야 할 수도 있다. 또는 이러한 함수가 존재하지 않을 수도 있다.
  • 전송 후에는 부모(수신 대상) object 모듈이 수신 함수를 정의하고, 자식(전송되는) object 모듈 또한 수신 함수를 정의하며, 두 함수가 정의한 제약이 모두 충족되어야만 전송된 object에 접근하거나 사용할 수 있다.
// 0xADD는 address이다.
// 0x0B는 object ID이다.
// b와 c는 object이다.

// object `b`를 address 0xADD로 전송한다.
transfer::public_transfer(b, @0xADD);

// object `c`를 object ID 0x0B를 가진 object로 전송한다.
transfer::public_transfer(c, @0x0B);

object를 object ID로 전송하면 address로 전송한 것과 동일한 결과가 나오며, object의 소유자는 제공된 32바이트 address 또는 object ID가 된다. 또한 object 전송의 결과에 차이가 없기 때문에 32바이트 ID에 대해 기존 RPC 메서드(예: getOwnedObjects)를 사용할 수 있다. ID가 address를 나타내면 메서드는 해당 address가 소유한 object를 반환하고, ID가 object ID이면 메서드는 해당 object ID가 소유한 object(전송된 object)를 반환한다.

// address 0xADD가 소유한 object를 가져온다. `b`를 반환한다.
{
"jsonrpc": "2.0",
"id": 1,
"method": "suix_getOwnedObjects",
"params": ["0xADD"]
}

// object ID 0x0B를 가진 object가 소유한 object를 가져온다. `c`를 반환한다.
{
"jsonrpc": "2.0",
"id": 1,
"method": "suix_getOwnedObjects",
"params": ["0x0B"]
}

Receiving objects

object c가 다른 object p로 전송된 후, p는 이를 사용하기 위해 c를 수신해야 한다. object c를 수신하려면 programmable transaction blocks(PTBs)에서 Receiving(o: ObjectRef) 인자 타입을 사용하며, 이는 수신될 object의 ObjectID, Version, Digest를 포함하는 object 참조를 받는다(이는 PTB의 owned object 인자와 동일한 방식이다). 그러나 Receiving PTB 인자는 transaction 내에서 owned 값이나 mutable 참조로 전달되지 않는다.

자세히 설명하자면, Sui 프레임워크의 transfer 모듈에 정의된 Move의 수신 인터페이스 핵심은 다음과 같다:

module sui::transfer;

/// 타입 `T`의 object를 수신할 수 있는 능력을 나타낸다. 저장할 수 없다.
public struct Receiving<phantom T: key> has drop { ... }

/// `parent`에 대한 가변(즉, locked) 접근과 `parent`가 소유한 object를
/// 참조하는 `Receiving` object가 주어지면 티켓을 사용하여 해당 object를 반환한다.
///
/// 이 함수에는 Sui Move 바이트코드 verifier가 적용하는 custom 규칙이 있으며,
/// `T`가 `receive`가 호출되는 모듈에 정의된 object임을 보장한다.
/// 정의 모듈 외부에서 `store`를 가진 object를 수신하려면 `public_receive`를 사용한다.
///
/// 참고: &mut UID는 수신 object의 타입을 정의한 모듈이 자신이 정의한 타입으로 전송된
/// object를 수신하는 데 필요한 사용자 정의 접근·권한 정책을 정의하도록 허용한다.
public native fun receive<T: key>(parent: &mut UID, object: Receiving<T>): T;

/// `parent`에 대한 가변(locked) 접근과 `parent`가 소유한 타입 `T`의 object를
/// 참조하는 `Receiving` 인자가 주어지면 이를 수신하여 반환한다.
/// object `T`는 이 함수로 수신되려면 `store`를 가져야 하며,
/// 이는 `T`의 정의 모듈 외부에서도 호출될 수 있다.
public native fun public_receive<T: key + store>(parent: &mut UID, object: Receiving<T>): T;

...

PTB에서 타입 T의 전송된 object를 참조하는 각 Receiving 인자는 정확히 하나의 Move 타입 sui::transfer::Receiving<T> 인자로 변환되며, 그런 다음 이 인자를 사용해 transfer::receive 함수로 타입 T의 전송된 object를 수신할 수 있다.

transfer::receive를 호출할 때는 부모 object의 UID에 대한 가변 참조를 전달해야 한다. 그러나 object의 정의 모듈이 이를 노출하지 않으면 UID의 가변 참조를 얻을 수 없다. 결과적으로 자식 object를 수신하는 부모 object의 타입을 정의하는 모듈이, 자신에게 전송된 object를 수신하는 데 대한 접근 제어 정책 및 기타 제약을 정의한다(자세한 내용은 authorization example 참고). 전달된 UID가 실제로 Receiving 인자가 참조하는 object를 소유하는지는 런타임에서 동적으로 검증되어 적용된다. 이는 예를 들어 소유권 체인이 동적으로만 확립될 수 있는 dynamic field로 전송된 object에 대한 접근을 가능하게 한다.

sui::transfer::Receivingdrop ability만 가지므로 Receiving<T> 인자의 존재는 T 유형의 object를 수신할 수 있는 능력을 나타내지만, PTB 내 object 참조로 지정된 해당 object를 반드시 수신해야 하는 의무는 아니다. Receiving 인자는 해당 transaction 동안 PTB에서 Receiving 인자를 일부만 사용해도, 전혀 사용하지 않아도, 모두 사용해도 문제가 없다. Receiving 인자에 해당하는 object는 수신되지 않는 한 그대로 유지된다(특히 object 참조가 동일하게 유지됨).

Custom receiving rules

custom transfer policies와 마찬가지로 Sui는 key-only object에 대한 custom receivership 규칙의 정의를 허용한다. 특히 transfer::receive 함수는 transfer::receive가 호출되는 모듈에 정의된 object에만 사용할 수 있으며, 이는 transfer::transfer 함수 사용 규칙과 동일하다.

마찬가지로 store ability를 가진 object는 누구나 transfer::public_receive로 수신할 수 있다. 이는 transfer::public_transferstore ability를 가진 모든 object를 전송할 수 있는 것과 동일하다.

부모 object가 receivership에 대한 custom 규칙을 정의할 수 있다는 사실과 결합될 때, 자식 object의 ability를 기반으로 object 수신 및 전송되는 object의 ability에 관한 다음의 권한 매트릭스를 반드시 고려해야 한다.

자식의 ability부모의 접근 제한 가능자식의 접근 제한 가능
keyYesYes
key + storeYesNo

custom transfer 정책과 마찬가지로 이러한 제약을 결합해 강력한 정책을 설계할 수 있다. 예를 들어 soul-bound objects는 custom transfer와 receivership 규칙을 함께 사용해 구현할 수 있다.

Using SDKs

Transaction 생성 시 Receiving 입력은 Sui TypeScript SDK의 다른 object 인자와 거의 동일하게 다룬다. 예를 들어 Simple Account 예제에서 ID가 0xc0ffee 인 coin object를 당신의 account인 0xcafe로 수신하는 transaction을 보내려면 Sui TypeScript SDK 또는 Sui Rust SDK를 사용하여 다음과 같이 수행할 수 있다:

... // Setup TypeScript SDK as normal.
const tx = new Transaction();
tx.moveCall({
target: `${examplePackageId}::account::accept_payment`,
arguments: [tx.object("0xcafe"), tx.object("0xc0ffee")]
});
const result = await client.signAndExecuteTransaction({
transaction: tx,
});
...
... // setup Rust SDK client as normal
client
.transaction_builder()
.move_call(
sending_account,
example_package_id,
"account",
"accept_payment",
vec!["0x2::sui::SUI"],
vec![
SuiJsonValue::from_object_id("0xcafe"),
SuiJsonValue::from_object_id("0xc0ffee") // 0xcoffee is turned into the `Receiving<...>` argument of `accept_payment` by the SDK
])
...

또한 일반 object 인자에 명시적 object ID, version, digest를 제공하는 ObjectRef 생성자가 있는 것처럼, Receiving 인자에도 동일한 인자를 받는 ReceivingRef 생성자가 있다.

Examples

다음 예시는 이전에 전송된 object를 수신하는 방법을 보여준다.

Receiving objects from shared objects

일반적으로 모듈에 정의된 shared object에서 전송된 object를 수신할 수 있도록 하려면 동적 권한 검사를 추가한다. 그렇지 않으면 누구나 전송된 object를 수신할 수 있다. 이 예제에서는 shared object(SharedObject)는 카운터를 보유하며 누구나 증가시킬 수 있지만, address 0xB0B만 shared object로부터 object를 수신할 수 있다.

receive_object 함수는 수신되는 object에 대해 generic이므로 keystore를 모두 가진 object만 수신할 수 있다. receive_object는 또한 object를 수신하기 위해 반드시 transfer::public_receive 함수를 사용해야 하며, transfer::receive를 사용할 수 없다. 왜냐하면 receive는 현재 모듈에 정의된 object에만 사용할 수 있기 때문이다.

module examples::shared_object_auth;

use transfer::Receiving;

const EAccessDenied: u64 = 0;
const AuthorizedReceiverAddr: address = @0xB0B;

public struct SharedObject has key {
id: UID,
counter: u64,
}

public fun create(ctx: &mut TxContext) {
let s = SharedObject {
id: object::new(ctx),
counter: 0,
};
transfer::share_object(s);
}

/// 누구나 shared object의 카운터를 증가시킬 수 있다.
public fun increment(obj: &mut SharedObject) {
obj.counter = obj.counter + 1;
}

/// object는 `AuthorizedReceiverAddr`만 `SharedObject`로부터 수신할 수 있다.
/// 그렇지 않으면 transaction이 중단된다.
public fun receive_object<T: key + store>(
obj: &mut SharedObject,
sent: Receiving<T>,
ctx: &TxContext
): T {
assert!(ctx.sender() == AuthorizedReceiverAddr, EAccessDenied);
transfer::public_receive(&mut obj.id, sent)
}

Receiving objects and adding them as dynamic fields

이 예제는 기본 account-type 모델을 정의하며, Account object가 dynamic field에 coin 잔액을 저장한다. 이 Account는 다른 address나 object로 전송할 수도 있다.

중요한 점은 이 Account object와 함께 coin이 전송될 address가 Account object가 전송되든, wrapped되든(예: escrow 계정에서) 또는 dynamic field로 이동되든 관계없이 동일하게 유지된다는 것이다. 특히, 소유권 변경과 무관하게 object의 수명 주기 전반에 걸쳐 주어진 Account object에 대해 안정적인 ID가 존재한다.

module examples::account;

use sui::dynamic_field as df;
use sui::coin::{Self, Coin};
use transfer::Receiving;

const EBalanceDONE: u64 = 1;

/// `Coin`이 전송될 수 있는 Account object. 다른 타입의 잔액은
/// `Coin` 타입의 `type_name`으로 인덱싱된 dynamic field로 보유된다.
public struct Account has key {
id: UID,
}

/// 특정 coin 타입의 잔액을 나타내는 Dynamic field 키.
public struct AccountBalance<phantom T> has copy, drop, store { }

/// 이 함수는 `Account` object로 전송된 coin을 수신한 다음
/// 각 coin 타입의 잔액에 합친다.
/// Dynamic field는 coin 타입별로 잔액을 인덱싱하는 데 사용된다.
public fun accept_payment<T>(account: &mut Account, sent: Receiving<Coin<T>>) {
// `account` object로 전송된 coin을 수신한다
// `Coin`은 이 모듈에 정의되지 않았고 `store` ability를 가지므로
// `transfer::public_receive` 함수를 사용하여 coin object를 수신한다.
let coin = transfer::public_receive(&mut account.id, sent);
let account_balance_type = AccountBalance<T>{};
let account_uid = &mut account.id;

// 해당 coin 타입의 잔액이 이미 존재하는지 확인한다.
// 존재하면 방금 받은 coin을 여기에 합치고,
// 그렇지 않으면 새 잔액을 생성한다.
if (df::exists_(account_uid, account_balance_type)) {
let balance: &mut Coin<T> = df::borrow_mut(account_uid, account_balance_type);
balance.join(coin);
} else {
df::add(account_uid, account_balance_type, coin);
}
}

/// `account`에서 타입 `T`의 coin `amount`를 인출한다.
public fun withdraw<T>(account: &mut Account, amount: u64, ctx: &mut TxContext): Coin<T> {
let account_balance_type = AccountBalance<T>{};
let account_uid = &mut account.id;
// 인출하려는 것이 존재하는지 확인한다
assert!(df::exists_(account_uid, account_balance_type), EBalanceDONE);
let balance: &mut Coin<T> = df::borrow_mut(account_uid, account_balance_type);
balance.split(amount, ctx)
}

/// 이 계정을 다른 address로 전송할 수 있다
/// (예: object나 address로).
public fun transfer_account(account: Account, to: address, _ctx: &mut TxContext) {
// 여기서 일부 authorization 검사를 수행하고 통과하면 계정을 전송한다
// ...
transfer::transfer(account, to);
}

Soul-bound objects

object가 언제, 어떻게 수신될 수 있는지와 언제, 어떻게 전송될 수 있는지를 제어할 수 있는 ability는, transaction에서 값으로 사용할 수 있지만 항상 동일한 위치에 머물러야 하거나 동일한 object로 반환되어야 하는 일종의 soul-bound object를 정의할 수 있게 한다.

다음 모듈을 통해 이를 단순한 형태로 구현할 수 있다. 여기서 get_object 함수는 soul-bound object를 수신하고, 트랜잭션이 성공적으로 실행되기 위해 반드시 파괴되어야 하는 receipt를 생성한다. 그러나 이 receipt를 파괴하려면, 수신된 object를 transaction 내에서 return_object 함수를 사용해 수신된 object를 원래 수신한 object로 다시 돌려보내야 한다.

module examples::soul_bound;

use transfer::{Self, Receiving};

/// 잘못된 object를 반환하려고 시도했다.
const EWrongObject: u64 = 0;

/// 이 object는 `key`만 가진다 -- 만약 `store`를 가졌다면
/// 전송된 address에 바인딩되도록 보장할 수 없다
public struct SoulBound has key {
id: UID,
}

/// non-store, non-drop, non-copy struct. `SoulBound` object를 수신하면
/// 이것 중 하나도 제공된다. transaction을 성공적으로 실행하려면
/// 이 `ReturnReceipt`를 파괴해야 하며 이를 수행하는 유일한 방법은
/// `return_object` 함수를 사용하여 transaction에서 수신한 동일한 object로
/// 다시 전송하는 것이다.
public struct ReturnReceipt {
/// 반환되어야 하는 object의 object ID.
/// 이 필드는 동일한 transaction에 여러 개가 있는 경우
/// soul bound object의 교환을 방지하는 데 필요하다.
object_id: ID,
/// 반환되어야 하는 address(object ID).
return_to: address,
}

/// `SoulBound` object를 소유하는 object UID와 `SoulBound` receiving 티켓을 받는다.
/// 그런 다음 `SoulBound` object를 수신하고 `return_object`를 호출하여
/// transaction에서 파괴되어야 하는 `ReturnReceipt`를 반환한다.
public fun get_object(parent: &mut UID, soul_bound_ticket: Receiving<SoulBound>): (SoulBound, ReturnReceipt) {
let soul_bound = transfer::receive(parent, soul_bound_ticket);
let return_receipt = ReturnReceipt {
return_to: parent.to_address(),
object_id: object::id(&soul_bound),
};
(soul_bound, return_receipt)
}

/// `SoulBound` object와 return receipt가 주어지면 수신된 object로
/// 반환한다. 반환하기 전에 `receipt`가 주어진 `soul_bound` object에
/// 대한 것인지 확인한다.
public fun return_object(soul_bound: SoulBound, receipt: ReturnReceipt) {
let ReturnReceipt { return_to, object_id } = receipt;
assert!(object::id(&soul_bound) == object_id, EWrongObject);
transfer::transfer(soul_bound, return_to);
}