객체로 전송
객체를 address로 전송하는 것과 동일한 방식으로, 동일한 함수를 사용하여 객체 ID로 객체를 전송할 수 있다. 이는 Sui에서 address와 객체가 모두 32바이트 ID를 사용하며, 두 ID가 구분되지 않기 때문이다(이들은 중복되지 않음이 보장된다). transfer to 객체 작업은 이 특성을 활용하여 전송 작업의 address 입력에 객체 ID를 제공할 수 있도록 한다.
Party 객체의 경우, 소유 address가 object ID에 해당하는 경우 객체로 전송 메커니즘이 지원되지 않는다.
동일한 ID 구조로 인해 객체를 전송할 때 address 필드에 객체 ID를 사용할 수 있다. 실제로 address가 소유한 객체와 관련된 모든 기능은 다른 객체가 소유한 객체에도 동일하게 작동하며, 단지 address를 객체 ID로 교체하면 된다.
객체를 다른 객체로 전송하는 것은 기본적으로 부모–자식 인증 관계를 설정하는 것이다. 다른 객체로 전송된 객체는 부모 객체의(전이적일 수 있는) 소유자가 수신할 수 있다. 부모(수신) 객체의 타입을 정의한 모듈이 자식 객체 수신에 대한 접근 제어를 정의한다.
전송된 자식 객체에 대한 접근 제한은 트랜잭션 실행 중 부모 객체의 UID에 대한 mutable 접근을 허용함으로써 동적으로 적용된다. 이로 인해 소유 객체, dynamic 필드 객체, wrapped 객체 및 공유 객체로 객체를 전송하고 이들로부터 객체를 수신할 수 있다.
transfer to 객체 작업의 이점 중 하나는, 예를 들어 온체인 지갑이나 계정이 안정적인 ID를 유지할 수 있다는 점이다. 객체의 전송은 수신 객체의 상태와 관계없이 해당 ID에 영향을 주지 않는다. 객체를 전송하면 해당 객체의 모든 자식 객체가 함께 이동하며, 객체의 address는 전송하든, wrap하든, dynamic 필드로 보유하든 동일하다.
객체로 transfer할 때 고려 사항
일반적인 객체 전송과 마찬가지로 수신 객체 ID가 실제로 존재하는지 확인해야 한다. 또한 수신 객체가 불변 객체가 아닌지도 확인해야 한다. 불변 객체로 전송된 객체에는 접근할 수 없다.
전송하려는 객체와 수신 객체의 타입 모두에 유의해야 한다. 수신(부모) 객체는 항상 다음을 수행할 수 있다:
- 전송된 객체에 접근하기 위해 동적으로 검사 가능한 조건자를 정의한다.
- 전송된 객체에 대한 접근을 지원하지 않는다. 해당 패키지의 향후 버전에서 이 기능을 지원할 수 있으나, 이를 포함할지는 패키지 작성자에게 달려 있다.
전송되는 객체가 key ability만 가진 경우:
- 전송되는 객체를 정의하는 모듈은 custom transfer 함수와 유사하게 해당 객체에 대한 custom receive 함수를 구현해야 한다. 전송되는 객체를 정의하는 모듈은 custom transfer 함수와 유사하게 해당 객체에 대한 custom receive 함수를 구현해야 한다. custom transfer 함수와 마찬가지로, custom receivership 함수는 개발자가 강제할 수 있는 임의의 제약을 가질 수 있고, 사용자가 이를 인지해야 할 수도 있다. 또는 이러한 함수가 존재하지 않을 수도 있다.
- 전송 후에는 부모(수신 대상) 객체 모듈이 수신 함수를 정의하고, 자식(전송되는) 객체 모듈 또한 수신 함수를 정의하며, 두 함수가 정의한 제약이 모두 충족되어야만 전송된 객체에 접근하거나 사용할 수 있다.
// 0xADD is an address
// 0x0B is an object ID
// b and c are objects
// Transfers the object `b` to the address 0xADD
transfer::public_transfer(b, @0xADD);
// Transfers the object `c` to the object with object ID 0x0B
transfer::public_transfer(c, @0x0B);
객체를 객체 ID로 전송하면 address로 전송한 것과 동일한 결과가 나오며, 객체의 소유자는 제공된 32바이트 address 또는 객체 ID가 된다. 또한 객체 전송의 결과에 차이가 없기 때문에 32바이트 ID에 대해 기존 RPC 메서드(예: getOwnedObjects)를 사용할 수 있다. ID가 address를 나타내면 메서드는 해당 address가 소유한 객체를 반환하고, ID가 객체 ID이면 메서드는 해당 객체 ID가 소유한 객체(전송된 객체)를 반환한다.
RPC method
// Get the objects owned by the address 0xADD. Returns `b`.
{
"jsonrpc": "2.0",
"id": 1,
"method": "suix_getOwnedObjects",
"params": [
"0xADD"
]
}
// Get the objects owned by the object with object ID 0x0B. Returns `c`
{
"jsonrpc": "2.0",
"id": 1,
"method": "suix_getOwnedObjects",
"params": [
"0x0B"
]
}
객체 받기
객체 c가 다른 객체 p로 전송된 후, p는 이를 사용하기 위해 c를 수신해야 한다. 객체 c를 수신하려면 프로그래머블 트랜잭션 블록(PTBs)에서 Receiving(o: ObjectRef) 인자 타입을 사용하며, 이는 수신될 객체의 ObjectID, Version, Digest를 포함하는 객체 참조를 받는다(이는 PTB의 소유 객체 인자와 동일한 방식이다). 그러나 Receiving PTB 인자는 트랜잭션 내에서 owned 값이나 mutable 참조로 전달되지 않는다.
자세히 설명하자면, Sui 프레임워크의 transfer 모듈에 정의된 Move의 수신 인터페이스 핵심은 다음과 같다:
module sui::transfer;
/// Represents the ability to receive an object of type `T`. Cannot be stored.
public struct Receiving<phantom T: key> has drop { ... }
/// Given mutable (i.e., locked) access to the `parent` and a `Receiving`
/// object referencing an object owned by `parent` use the `Receiving` ticket
/// and return the corresponding object.
///
/// This function has custom rules that the Sui Move bytecode verifier enforces to ensure
/// that `T` is an object defined in the module where `receive` is invoked. Use
/// `public_receive` to receive an object with `store` outside of its defining module.
///
/// NB: &mut UID here allows the defining module of the parent type to
/// define custom access/permission policies around receiving objects sent
/// to objects of a type that it defines. You can see this more in the examples.
public native fun receive<T: key>(parent: &mut UID, object: Receiving<T>): T;
/// Given mutable (locked) access to the `parent` and a `Receiving` argument
/// referencing an object of type `T` owned by `parent` use the `object`
/// argument to receive and return the referenced owned object of type `T`.
/// The object `T` must have `store` to be received by this function, and
/// this can be called outside of the module that defines `T`.
public native fun public_receive<T: key + store>(parent: &mut UID, object: Receiving<T>): T;
...
PTB에서 타입 T의 전송된 객체를 참조하는 각 Receiving 인자는 정확히 하나의 Move 타입 sui::transfer::Receiving<T> 인자로 변환되며, 그런 다음 이 인자를 사용해 transfer::receive 함수로 타입 T의 전송된 객체를 수신할 수 있다.
transfer::receive를 호출할 때는 부모 object의 UID에 대한 가변 참조를 전달해야 한다. 그러나 object의 정의 모듈이 이를 노출하지 않으면 UID의 가변 참조를 얻을 수 없다. 결과적으로 자식 object를 수신하는 부모 object의 타입을 정의하는 모듈이, 자신에게 전송된 object를 수신하는 데 대한 접근 제어 정책 및 기타 제약을 정의한다(자세한 내용은 authorization example 참고). 전달된 UID가 실제로 Receiving 인자가 참조하는 object를 소유하는지는 런타임에서 동적으로 검증되어 적용된다. 이는 예를 들어 소유권 체인이 동적으로만 확립될 수 있는 dynamic field로 전송된 object에 대한 접근을 가능하게 한다.
sui::transfer::Receiving은 drop ability만 가지므로 Receiving<T> 인자의 존재는 T 유형의 객체를 수신할 수 있는 능력을 나타내지만, PTB 내 객체 참조로 지정된 해당 객체를 반드시 수신해야 하는 의무는 아니다. Receiving 인자는 해당 트랜잭션 동안 PTB에서 Receiving 인자를 일부만 사용해도, 전혀 사용하지 않아도, 모두 사용해도 문제가 없다. Receiving 인자에 해당하는 객체는 수신되지 않는 한 그대로 유지된다(특히 객체 참조가 동일하게 유지됨).
공유 객체에서 객체 받기
일반적으로 모듈에 정의된 공유 객체에서 전송된 객체를 수신할 수 있도록 하려면 동적 권한 검사를 추가한다. 그렇지 않으면 누구나 전송된 객체를 수신할 수 있다. 이 예제에서는 공유 객 체(SharedObject)는 카운터를 보유하며 누구나 증가시킬 수 있지만, address 0xB0B만 공유 객체로부터 객체를 수신할 수 있다.
receive_object 함수는 수신되는 객체에 대해 generic이므로 key와 store를 모두 가진 객체만 수신할 수 있다. receive_object는 또한 객체를 수신하기 위해 반드시 transfer::public_receive 함수를 사용해야 하며, transfer::receive를 사용할 수 없다. 왜냐하면 receive는 현재 모듈에 정의된 객체에만 사용할 수 있기 때문이다.
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);
}
/// Anyone can increment the counter in the shared object.
public fun increment(obj: &mut SharedObject) {
obj.counter = obj.counter + 1;
}
/// Objects can only be received from the `SharedObject` by the
/// `AuthorizedReceiverAddr` otherwise the transaction aborts.
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)
}
객체를 받아 dynamic 필드로 추가
이 예제는 기본 account-type 모델을 정의하며, Account 객체가 dynamic 필드에 코인 잔액을 저장한다. 이 Account는 다른 address나 객체로 전송할 수도 있다.
중요한 점은 이 Account 객체와 함께 코인이 전송될 address가 Account 객체가 전송되든, wrapped되든(예: escrow 계정에서) 또는 dynamic 필드로 이동되든 관계없이 동일하게 유지된다는 것이다. 특히, 소유권 변경과 무관하게 객체의 수명 주기 전반에 걸쳐 주어진 Account 객체에 대해 안정적인 ID가 존재한다.
module examples::account;
use sui::dynamic_field as df;
use sui::coin::{Self, Coin};
use transfer::Receiving;
const EBalanceDONE: u64 = 1;
/// Account object that `Coin`s can be sent to. Balances of different types
/// are held as dynamic fields indexed by the `Coin` type's `type_name`.
public struct Account has key {
id: UID,
}
/// Dynamic field key representing a balance of a particular coin type.
public struct AccountBalance<phantom T> has copy, drop, store { }
/// This function will receive a coin sent to the `Account` object and then
/// join it to the balance for each coin type.
/// Dynamic fields are used to index the balances by their coin type.
public fun accept_payment<T>(account: &mut Account, sent: Receiving<Coin<T>>) {
// Receive the coin that was sent to the `account` object
// Since `Coin` is not defined in this module, and since it has the `store`
// ability we receive the coin object using the `transfer::public_receive` function.
let coin = transfer::public_receive(&mut account.id, sent);
let account_balance_type = AccountBalance<T>{};
let account_uid = &mut account.id;
// Check if a balance of that coin type already exists.
// If it does then merge the coin we just received into it,
// otherwise create new balance.
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);
}
}
/// Withdraw `amount` of coins of type `T` from `account`.
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;
// Make sure what we are withdrawing exists
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)
}
/// Can transfer this account to a different address
/// (for example, to an object or address).
public fun transfer_account(account: Account, to: address, _ctx: &mut TxContext) {
// Perform some authorization checks here and if they pass then transfer the account
// ...
transfer::transfer(account, to);
}
custom receiving rule
custom transfer policies와 마찬가지로 Sui는 key-only object에 대한 custom receivership 규칙의 정의를 허용한다. 특히 transfer::receive 함수는 transfer::receive가 호출되는 모듈에 정의된 object에만 사용할 수 있으며, 이는 transfer::transfer 함수를 해당 함수가 사용되는 모듈에 정의된 object에만 사용할 수 있는 것과 동일하다.
마찬가지로 store ability도 가진 object는 누구나 transfer::public_receive 함수로 수신할 수 있다. 이는 transfer::public_transfer가 store ability를 가진 모든 object를 전송할 수 있는 것과 동일하다.
부모 객체가 receivership에 대한 custom 규칙을 정의할 수 있다는 사실과 결합될 때, 자식 객체의 ability를 기반으로 객체 수신 및 전송되는 객체의 ability에 관한 다음의 권한 매트릭스를 반드시 고려해야 한다.
| Child abilities | Parent can restrict access | Child can restrict access |
|---|---|---|
key | Yes | Yes |
key + store | Yes | No |
custom transfer 정책과 마찬가지로 이러한 제약을 결합해 강력한 정책을 설계할 수 있다. 예를 들어 soul-bound objects는 custom transfer와 receivership 규칙을 함께 사용해 구현할 수 있다.
TypeScript 및 Rust SDK
Transaction 생성 시 Receiving 입력은 Sui TypeScript SDK의 다른 object 인자와 거의 동일하게 다룬다. 예를 들어 Simple Account 예제에서 ID가 0xc0ffee인 coin object를 account 0xcafe로 수신하는 transaction을 보내려면 Sui TypeScript SDK 또는 Sui Rust SDK를 사용하여 다음과 같이 수행할 수 있다:
- TypeScript
- Rust
... // 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
])
...
또한 일반 객체 인자에 명시적 객체 ID, version, digest를 제공하는 ObjectRef 생성자가 있는 것처럼, Receiving 인자에도 동일한 인자를 받는 ReceivingRef 생성자가 있다.
Soulbound 객체
객체가 언제, 어떻게 수신될 수 있는지와 언제, 어떻게 전송될 수 있는지를 제어할 수 있는 ability는, 트랜잭션에서 값으로 사용할 수 있지만 항상 동일한 위치에 머물러야 하거나 동일한 객체로 반환되어야 하는 일종의 soul-bound 객체를 정의할 수 있게 한다.
다음 모듈을 통해 이를 단순한 형태로 구현할 수 있다. 여기서 get_object 함수는 soul-bound 객체를 수신하고, 트랜잭션이 성공적으로 실행되기 위해 반드시 파괴되어야 하는 receipt를 생성한다. 그러나 이 receipt를 파괴하려면, 수신된 객체를 트랜잭션 내에서 return_object 함수를 사용해 수신된 객체를 원래 수신한 객체로 다시 돌려보내야 한다.
module examples::soul_bound;
use transfer::{Self, Receiving};
/// Tried to return the wrong object.
const EWrongObject: u64 = 0;
/// This object has `key` only -- if this had `store` we would not be
/// able to ensure it is bound to whatever address we sent it to
public struct SoulBound has key {
id: UID,
}
/// A non-store, non-drop, non-copy struct. When you receive a `SoulBound`
/// object, we'll also give you one of these. In order to successfully
/// execute the transaction you need to destroy this `ReturnReceipt` and
/// the only way to do that is to transfer it back to the same object you
/// received it from in the transaction using the `return_object` function.
public struct ReturnReceipt {
/// The object ID of the object that needs to be returned.
/// This field is required to prevent swapping of soul bound objects if
/// multiple are present in the same transaction.
object_id: ID,
/// The address (object ID) it needs to be returned to.
return_to: address,
}
/// Takes the object UID that owns the `SoulBound` object and a `SoulBound`
/// receiving ticket. It then receives the `SoulBound` object and returns a
/// `ReturnReceipt` that must be destroyed in the transaction by calling `return_object`.
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)
}
/// Given a `SoulBound` object and a return receipt returns it to the
/// object it was received from. Verifies that the `receipt`
/// is for the given `soul_bound` object before returning it.
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);
}