본문으로 건너뛰기

주소 잔액 마이그레이션 가이드

Address balances는 Sui address에 연결된 fungible asset용 canonical balance system을 도입한다. 이는 UTXO-style Coin<T> model을 direct address-owned balance로 대체하여 transaction 구성을 단순화하고 coin selection 복잡성을 없앤다.

전체 명세는 SIP-58: Sui Address Balances를 참조한다.

Read this first

Limited impact

이 rollout은 처음에는 영향이 매우 제한적이다:

  • deprecate되거나 제거되는 것은 없다. 현재 유효한 모든 transaction은 계속 유효하다. Coin object가 address balances로 강제 마이그레이션되지는 않는다.
  • contract를 다시 작성할 필요는 없다. contract는 이전과 같이 계속 Coin<T>&mut Coin<T>를 받을 수 있다.
  • coin은 여전히 transfer::public_transfer 또는 TransferObjects command를 통해 보낼 수 있다.

Impact for wallets, exchanges, and custody providers

wallet(custodial, non-custodial, exchange wallet 등)을 운영하는 누구나 이 변경의 영향을 받을 수 있다. 이유는 이제 사용자가 coin object를 transfer하는 대신 send_funds()를 통해 여러분의 address로 fund를 보낼 수 있기 때문이다. wallet가 이 방식으로 fund를 받았지만 그 fund를 "보지" 못하거나 접근하지 못하면, 사용자는 fund가 사라졌다고 믿을 수 있다. 실제로 fund가 사라질 수는 없지만, 이는 영향을 받는 사용자에게 혼란과 우려를 일으킬 수 있다.

처음에는 대부분의 ecosystem이 오늘날과 마찬가지로 계속 coin object를 transfer할 것이므로, 이런 일은 자주 일어나지 않을 것이다. 하지만 어떤 wallet에도 fund가 address balance transfer로 도착할 가능성이 존재하므로, wallet 구현자는 이에 대비해야 한다.

또한 사용자 fund는 coin과 address balances 양쪽에 분산될 수 있다. balance query(JSON-RPC, gRPC, GraphQL을 통한)는 결합된 총합을 보여준다. 하지만 address balance 부분이 포함된 fund를 보내려면 다음 중 하나를 해야 한다:

  • 최근 버전의 TypeScript SDK(v2+)에 있는 coinWithBalance를 사용한다. 이는 두 source에서 자동으로 자금을 끌어온다.
  • 이 가이드에서 설명하는 수동 withdrawal logic을 구현한다.

Overview

이전에는 Sui의 balance를 address가 소유한 모든 Coin<T> object의 값을 합산하여 계산했다. address balances에서는 각 address가 각 currency type T마다 address-owned balance를 하나씩 추가로 가질 수 있다. 총 balance는 모든 Coin<T> object와 해당 coin type의 address balance 값을 합한 값이다.

Coin<T> address balances는 공존한다. 기존 coin은 계속 동작하며 여전히 transfer::public_transfer로 transfer할 수 있다. 하지만 address balances는 다음과 같은 중요한 운영상 이점을 제공한다:

  • coin selection logic이 필요 없다.
  • deposit가 하나의 canonical balance로 자동 병합된다.
  • object state를 질의하지 않고도 stateless한 transaction 구성이 가능하다.
  • SUI address balances에서 직접 gas를 지불할 수 있다.

Funding transactions from address balances

gas나 transaction argument로 사용할 coin object를 명시적으로 선택하지 않는 경우, TypeScript SDK는 coin object와 address balances를 모두 자동으로 확인하고 올바른 source에서 자금을 끌어온다. gas는 기본적으로 address balances에서 지불되며, coinWithBalance는 먼저 address balances에서 자금을 끌어오고 필요할 때만 coin object로 fallback한다.

Using coinWithBalance

TypeScript SDK - coinWithBalance:

import { coinWithBalance, Transaction } from '@mysten/sui/transactions';

const tx = new Transaction();
tx.transferObjects([coinWithBalance({ balance: requiredAmount })], recipient);

fund가 address balances와 coin object 양쪽에 분산되어 있어도, SDK가 이 조합을 자동으로 처리하여 먼저 address balances를 사용하고 필요할 때만 coin으로 fallback한다.

coinWithBalance option은 다음과 같다:

  • balance (required): base unit 금액이다(SUI의 경우 MIST).
  • type (optional): coin type이며 기본값은 0x2::sui::SUI이다.
  • useGasCoin (optional): SUI에 대해 gas coin에서 split할지 여부이다(기본값 true). sponsored transaction에는 false로 설정한다.
  • forceAddressBalance (optional): address balance withdrawal을 강제하고 balance/coin lookup을 건너뛴다.

TypeScript SDK: Manual withdrawals

address balance withdrawal을 명시적으로 제어하려면 tx.withdrawal()을 사용한다. usable한 Coin<T> 또는 Balance<T>를 만들려면 withdrawal을 redeem_funds로 redeem해야 한다.

TypeScript SDK - Transaction Reference:

import { Transaction } from '@mysten/sui/transactions';

const tx = new Transaction();

// address balance에서 SUI를 인출하고 Coin으로 redeem한다
const [coin] = tx.moveCall({
target: '0x2::coin::redeem_funds',
typeArguments: ['0x2::sui::SUI'],
arguments: [tx.withdrawal({ amount: 1_000_000_000 })], // MIST 단위 1 SUI
});

// custom coin type을 인출한다
const coinType = '0xPackageId::module::CoinType';
const [customCoin] = tx.moveCall({
target: '0x2::coin::redeem_funds',
typeArguments: [coinType],
arguments: [tx.withdrawal({ amount: 1_000_000, type: coinType })],
});

// 수신자에게 전송한다
tx.transferObjects([coin], recipient);

tx.withdrawal()의 parameter는 다음과 같다:

  • amount (required): base unit으로 인출할 금액이다(number, bigint, string).
  • type (optional): coin type이며 기본값은 SUI이다.

참고: tx.withdrawal()은 coin 또는 balance를 생성하기 위해 redeem되어야 하는 Withdrawal<Balance<T>> capability를 만든다. 이를 위해 0x2::coin::redeem_funds() 또는 0x2::balance::redeem_funds()를 사용한다. coinWithBalance intent는 이를 자동으로 수행한다.

Rust SDK: Manual withdrawals

Source: sui-types/src/transaction.rs - FundsWithdrawalArg:

use sui_types::transaction::{FundsWithdrawalArg, WithdrawalTypeArg, Reservation, WithdrawFrom};

let withdrawal_arg = FundsWithdrawalArg {
reservation: Reservation::MaxAmountU64(amount),
type_arg: WithdrawalTypeArg::Balance(coin_type),
withdraw_from: WithdrawFrom::Sender,
};

helper인 FundsWithdrawalArg::balance_from_sender(amount, balance_type)는 편리한 constructor를 제공한다.

PTB에서는 withdrawal input을 다음 중 하나에 전달한다:

  • 0x2::balance::redeem_funds<T>()를 사용해 Balance<T>를 얻는다.
  • 0x2::coin::redeem_funds<T>()를 사용해 Coin<T>를 얻는다.

Source: sui-types/src/programmable_transaction_builder.rs:

// Rust 예시: 인출하고 전송한다
let mut builder = ProgrammableTransactionBuilder::new();

let withdraw_arg = FundsWithdrawalArg::balance_from_sender(
withdraw_amount,
sui_types::gas_coin::GAS::type_tag(),
);
let withdraw_input = builder.funds_withdrawal(withdraw_arg).unwrap();

// coin으로 redeem한다
let coin = builder.programmable_move_call(
SUI_FRAMEWORK_PACKAGE_ID,
Identifier::new("coin").unwrap(),
Identifier::new("redeem_funds").unwrap(),
vec!["0x2::sui::SUI".parse().unwrap()],
vec![withdraw_input],
);

builder.transfer_arg(recipient, coin);

같은 coin type에 대해서도 하나의 PTB에 여러 FundsWithdrawalArg input을 둘 수 있다.

Splitting and joining withdrawals

withdrawal은 PTB 안에서 split하고 merge할 수 있다.

Source: sui-framework/sources/funds_accumulator.move:

// 기존 withdrawal에서 하위 withdrawal을 split한다
public fun withdrawal_split<T: store>(withdrawal: &mut Withdrawal<T>, sub_limit: u256): Withdrawal<T>

// 두 withdrawal을 합친다(반드시 owner가 같아야 한다)
public fun withdrawal_join<T: store>(withdrawal: &mut Withdrawal<T>, other: Withdrawal<T>)

Sending funds

Before: Coin transfers

coin을 transfer하면 수신자에게 coin object를 보낸다:

import { coinWithBalance, Transaction } from '@mysten/sui/transactions';

const tx = new Transaction();
tx.transferObjects([coinWithBalance({ balance: amount })], recipient);

각 transfer는 수신자를 위해 새로운 object를 만들어 object proliferation에 기여한다. 수신자는 시간이 지나면서 많은 작은 coin object를 누적하게 되며, 주기적인 consolidation이 필요해진다.

After: Address balance deposits

address balances에서는 send_funds를 사용해 수신자의 address balance에 직접 deposit한다:

import { coinWithBalance, Transaction } from '@mysten/sui/transactions';

const tx = new Transaction();
tx.moveCall({
target: '0x2::coin::send_funds',
typeArguments: ['0x2::sui::SUI'],
arguments: [coinWithBalance({ balance: amount }), tx.pure.address(recipient)],
});

수신자의 balance는 새로운 object를 만들지 않고 증가한다. 서로 다른 sender가 여러 번 deposit해도 모두 하나의 balance로 병합된다.

Source: sui-framework/sources/coin.move - send_funds:

// After: Move contract에서
public fun send_payment(coin: Coin<SUI>, recipient: address) {
coin::send_funds(coin, recipient);
}

수신자의 balance는 새로운 object를 만들지 않고 증가한다. 서로 다른 sender가 여러 번 deposit해도 모두 하나의 balance로 병합된다.

Move functions

Source: sui-framework/sources/balance.move - send_funds:

// Balance<T>를 address balance로 보낸다
public fun send_funds<T>(balance: Balance<T>, recipient: address)

Source: sui-framework/sources/coin.move - send_funds:

// Coin<T>를 address balance로 보낸다(내부적으로 balance로 변환한다)
public fun send_funds<T>(coin: Coin<T>, recipient: address)

Sending funds via the CLI

CLI는 현재 address balances에 대한 지원이 제한적이지만, 이를 수행하도록 PTB를 수동으로 구성하면 coin에서 address balances로 fund를 보내는 데 사용할 수 있다:

Sui CLI Reference - PTB:

# gas coin에서 address balance로 보낸다
sui client ptb \
--split-coins gas '[5000000]' \
--assign coin \
--move-call 0x2::coin::send_funds '<0x2::sui::SUI>' coin @<recipient_address>

# 다른 coin에서 보낸다
sui client ptb \
--split-coins @<coin_id> '[5000000]' \
--assign coin \
--move-call 0x2::coin::send_funds '<coin_type>' coin @<recipient_address>

Paying for gas from address balances

Before: Gas coin management

coin 기반 gas payment에서는 gas coin을 질의하고 선택해야 한다:

const tx = new Transaction();
// gas coin은 build 시점에 자동 선택되며, 이를 위해 network query가 필요하다

gas coin 관리는 coordination 문제를 만든다:

  • transaction 전에 현재 gas coin 상태를 질의해야 한다.
  • 병렬 transaction에는 별도의 gas coin이 필요하거나 신중한 sequencing이 필요하다.
  • gas coin version은 각 transaction 후에 바뀐다.

After: Stateless gas payment

address balance gas payment에서는 setGasPayment에 빈 배열을 전달한다:

const tx = new Transaction();
tx.setGasPayment([]); // 빈 배열 = gas에 address balance를 사용한다

이렇게 하면 조회할 coin object version이 없으므로 완전히 offline으로 transaction을 build할 수 있다.

Rust에서는 gas_data.payment를 빈 vector로 설정하고 ValidDuring expiration을 사용한다:

Source: sui-types/src/transaction.rs - TransactionExpiration::ValidDuring:

TransactionData::V1(TransactionDataV1 {
kind: tx_kind,
sender,
gas_data: GasData {
payment: vec![], // 비어 있음 - gas는 address balance에서 지불한다
owner: sender,
price: rgp,
budget: 10_000_000,
},
expiration: TransactionExpiration::ValidDuring {
min_epoch: Some(current_epoch),
max_epoch: Some(current_epoch + 1),
min_timestamp: None,
max_timestamp: None,
chain: chain_identifier,
nonce: unique_nonce,
},
})

address balance gas payment의 요구 사항은 다음과 같다:

  • gas_data.payment는 비어 있어야 한다.
  • expirationmin_epochmax_epoch가 모두 지정된 ValidDuring이어야 한다.
  • max_epoch는 최대 min_epoch + 1이어야 한다(single epoch 또는 1-epoch range).
  • timestamp 기반 expiration은 현재 지원되지 않는다.
  • transaction kind는 ProgrammableTransaction이어야 한다.

nonce field는 그 외에는 동일한 transaction을 구분한다. EVM chain과 달리 nonce에는 semantic requirement가 없다. sequential할 필요도 없고, "nonce gap" 문제도 없다. 이는 단지 digest가 같아질 transaction 두 개를 제출할 수 있게 해준다. 대부분의 사용 사례에서는 nonce를 무작위로 생성하거나 애플리케이션에서 증가하는 counter를 사용한다:

// 무작위 nonce
let nonce: u32 = rand::random();

// 또는 증가하는 counter
let nonce: u32 = self.next_nonce.fetch_add(1, Ordering::SeqCst);

Before: Sponsored gas with coins

coin을 사용하는 gas sponsorship은 사용자와 sponsor 사이의 coordination을 요구한다:

  1. 사용자가 gas 없이 transaction을 구성한다.
  2. sponsor가 gas coin을 선택해 transaction에 추가한다.
  3. 양쪽 모두가 서명한다.
  4. 위험: 사용자가 서명을 완료하지 않으면 sponsor의 gas coin이 lock될 수 있다.

After: Sponsored gas with address balances

address balances에서는 사용자가 sponsor보다 먼저 서명할 수 있으므로 더 단순한 async flow가 가능하다:

// 1. 사용자가 먼저 transaction을 build하고 서명한다
const tx = new Transaction();
tx.setSender(userAddress);
tx.setGasOwner(sponsorAddress);
tx.setGasPayment([]); // 빈 배열 = sponsor가 address balance에서 지불한다
// ... command를 추가한다 ...

const bytes = await tx.build({ client });
const { signature: userSignature } = await userKeypair.signTransaction(bytes);

// Option A: transaction과 signature를 sponsor에게 보내면 sponsor가 즉시 서명하고 실행할 수 있다
// (`send_tx_to_sponsor`는 placeholder이며, SDK에 이런 API는 없다)
await send_tx_to_sponsor(userSignature, bytes);

// Option B: sponsor에게서 signature를 받아 우리가 직접 제출한다. Sponsor의 서명은 비동기로 일어날 수 있다
const { signature: sponsorSignature } = await sponsorKeypair.signTransaction(bytes);

const result = await client.executeTransaction({
transaction: bytes,
signatures: [userSignature, sponsorSignature],
});

sender와 sponsor는 모두 서명해야 한다. 스토리지 리베이트는 sponsor의 address balance에 적립된다.

coin 기반 sponsorship 대비 장점은 다음과 같다:

  • gas coin lock 위험이 없다.
  • sponsor가 gas coin inventory를 관리할 필요가 없다.
  • 사용자가 sponsor보다 먼저 서명할 수 있어 더 단순한 async flow가 가능하다.
  • permissionless public 가스 스테이션(gas station)을 가능하게 한다.

Querying balances via RPC

Before: Querying coin-based balances

총 balance를 구하려면 RPC layer가 모든 coin object를 대신 합산해 준다:

TypeScript SDK - Core API:

// Before: balance를 가져온다(RPC가 모든 coin object를 합산한다)
const balance = await client.core.getBalance({ owner: address, coinType: '0x2::sui::SUI' });
console.log(balance.totalBalance); // 모든 Coin<SUI> object의 합계
// address의 모든 balance를 나열한다
const { balances } = await client.core.listBalances({ owner: address });
for (const balance of balances) {
console.log(`${balance.coinType}: ${balance.totalBalance}`);
}

JSON-RPC Reference - suix_getBalance:

curl -s https://fullnode.testnet.sui.io \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"suix_getBalance","params":["<address>", "0x2::sui::SUI"]}'

totalBalance field는 address가 소유한 모든 Coin<T> object 값의 합계를 나타냈다.

After: Including address balances

이제 같은 RPC method에 address balance 정보도 포함된다:

const balance = await client.core.getBalance({ owner: address, coinType: '0x2::sui::SUI' });
console.log(balance.totalBalance); // coin + address balance의 합계
console.log(balance.fundsInAddressBalance); // address balance에만 들어 있는 금액

새로운 fundsInAddressBalance field는 address balance 보유량을 나타낸다.

JSON-RPC Reference - suix_getBalance:

# JSON-RPC
curl -s https://fullnode.testnet.sui.io \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc":"2.0",
"id":1,
"method":"suix_getBalance",
"params":["<address>", "0x2::sui::SUI"]
}'

Response:

{
"coinType": "0x2::sui::SUI",
"coinObjectCount": 2,
"totalBalance": "99998990120",
"lockedBalance": {},
"fundsInAddressBalance": "5000000"
}

totalBalance에는 coin object와 address balance fund가 모두 포함된다. coin 기반 balance만 얻으려면 totalBalance에서 fundsInAddressBalance를 뺀다.

JSON-RPC Reference - suix_getAllBalances:

# 모든 balance를 가져온다
curl -s https://fullnode.testnet.sui.io \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc":"2.0",
"id":1,
"method":"suix_getAllBalances",
"params":["<address>"]
}'

gRPC

StateServiceGetBalanceListBalances를 사용한다.

gRPC Reference - GetBalanceRequest:

buf curl --protocol grpc https://fullnode.testnet.sui.io/sui.rpc.v2.StateService/GetBalance \
-d '{
"owner": "<address>",
"coin_type": "0x2::sui::SUI"
}'

Response:

{
"balance": {
"coinType": "0x2::sui::SUI",
"balance": "99998990120",
"addressBalance": "5000000",
"coinBalance": "99993990120"
}
}

Field는 다음과 같다:

  • coinBalance: coin object에 보유한 총합이다.
  • addressBalance: address balance에 보유한 금액이다.
  • balance: 앞의 두 값을 합한 값이다.

gRPC Reference - ListBalancesRequest:

# 모든 balance를 나열한다
buf curl --protocol grpc https://fullnode.testnet.sui.io/sui.rpc.v2.StateService/ListBalances \
-d '{"owner": "<address>"}'

GraphQL

GraphQL Reference - IAddressable.balance | 아래 예시를 GraphQL IDE (Testnet)에 복사해 시도해 본다.

# 단일 balance
{
address(address: "0xe4ee9c157b5eb185c2df885bd7dcb4be493630a913f4b0e0c7e8ecf77930a878") {
balance(coinType: "0x2::sui::SUI") {
coinType {
repr
}
addressBalance
coinBalance
totalBalance
}
}
}

# 모든 balance
{
address(address: "0xe4ee9c157b5eb185c2df885bd7dcb4be493630a913f4b0e0c7e8ecf77930a878") {
balances {
nodes {
coinType {
repr
}
addressBalance
coinBalance
totalBalance
}
}
}
}

Computing balance changes from checkpoint data

Before: Coin object diffs

coin만 있는 checkpoint data에서 balance change를 계산하려면 다음을 수행한다:

  1. input object를 가져온다(transaction 이전 version의 coin).
  2. output object를 가져온다(transaction 이후 version의 coin).
  3. (address, coin_type) 쌍에 대해 output coin 값의 합에서 input coin 값의 합을 뺀다.
// Before: coin만 있는 balance change
fn derive_coin_balance_changes(
input_objects: &[Object],
output_objects: &[Object],
) -> BTreeMap<(SuiAddress, TypeTag), i128> {
let mut balances = BTreeMap::new();

// input coin을 뺀다
for obj in input_objects {
if let Some((owner, coin_type, value)) = extract_coin_info(obj) {
*balances.entry((owner, coin_type)).or_default() -= value as i128;
}
}

// output coin을 더한다
for obj in output_objects {
if let Some((owner, coin_type, value)) = extract_coin_info(obj) {
*balances.entry((owner, coin_type)).or_default() += value as i128;
}
}

balances
}

After: Coins plus accumulator events

address balances가 있으면 TransactionEffects의 accumulator event도 처리해야 한다.

Source: sui-types/src/balance_change.rs:

// After: accumulator event를 포함한다
use sui_types::balance_change::{derive_balance_changes, BalanceChange};

let balance_changes: Vec<BalanceChange> = derive_balance_changes(
&effects,
&input_objects,
&output_objects,
);

algorithm은 다음과 같다:

  1. input coin을 뺀다: 각 input coin object에 대해 (owner, coin_type)에서 그 값을 뺀다.
  2. output coin을 더한다: 각 mutated/created coin object에 대해 (owner, coin_type)에 그 값을 더한다.
  3. accumulator event를 처리한다: Balance<T> type을 가진 각 accumulator event에 대해 다음을 수행한다:
    • Split operation: 금액을 뺀다(address balance에서 인출된 fund).
    • Merge operation: 금액을 더한다(address balance에 deposit된 fund).

Reference implementation

Accessing accumulator events

accumulator event는 TransactionEffects에 내장되어 있다.

Source: sui-types/src/effects/mod.rs - TransactionEffectsAPI:

use sui_types::effects::TransactionEffectsAPI;

let events = effects.accumulator_events();
for event in events {
let address = event.write.address.address;
let balance_type = &event.write.address.ty;

// Balance<T> type만 balance change를 나타낸다
if let Some(coin_type) = Balance::maybe_get_balance_type_param(balance_type) {
let amount = match &event.write.value {
AccumulatorValue::Integer(v) => *v as i128,
_ => continue,
};

let signed_amount = match event.write.operation {
AccumulatorOperation::Split => -amount, // Withdrawal
AccumulatorOperation::Merge => amount, // Deposit
};

// (address, coin_type, signed_amount)가 balance change를 나타낸다
}
}

Key types

Source: sui-types/src/balance_change.rs:

pub struct BalanceChange {
pub address: SuiAddress,
pub coin_type: TypeTag,
pub amount: i128, // 음수 = 지출, 양수 = 수령
}

Converting existing coins to address balances

마이그레이션은 선택 사항이다. SDK는 필요에 따라 coin 또는 address balances를 자동으로 선택한다.

coin을 address balances로 통합하려면 다음을 수행한다:

Sui CLI Reference - PTB:

sui client ptb \
--merge-coins @<coin1> '[@<coin2>, @<coin3>]' \
--move-call 0x2::coin::send_funds '<0x2::sui::SUI>' @<coin1> @<your_address>

또는 TypeScript에서 coinWithBalance를 사용할 수 있다:

TypeScript SDK - coinWithBalance:

import { coinWithBalance, Transaction } from '@mysten/sui/transactions';

const tx = new Transaction();

tx.moveCall({
target: '0x2::coin::send_funds',
typeArguments: ['0x2::sui::SUI'],
arguments: [coinWithBalance({ balance: totalAmount }), tx.pure.address(yourAddress)],
});

이렇게 하면 삭제된 coin object에서 스토리지 리베이트를 얻을 수 있다.

Backward compatibility

Existing contracts

Coin<T> 또는 Balance<T>를 받는 contract는 계속 호출할 수 있다. redeem_funds function는 PTB 안에서 withdrawal을 기대하는 type으로 변환한다.

Legacy clients

JSON-RPC compatibility layer는 address balance reservation을 나타내는 fake coins를 제공한다. 이 layer는 업그레이드할 수 없는 client를 위해 기본 기능을 보존하지만, 새로운 개발에서 이에 의존해서는 안 된다.

Framework functions reference

balance.move

Source: sui-framework/sources/balance.move:

// balance를 address의 funds accumulator로 보낸다
public fun send_funds<T>(balance: Balance<T>, recipient: address)

// withdrawal을 redeem해 Balance<T>를 얻는다
public fun redeem_funds<T>(withdrawal: Withdrawal<Balance<T>>): Balance<T>

// object의 balance에서 withdrawal을 생성한다
public fun withdraw_funds_from_object<T>(obj: &mut UID, value: u64): Withdrawal<Balance<T>>

coin.move

Source: sui-framework/sources/coin.move:

// withdrawal을 redeem해 Coin<T>를 만든다
public fun redeem_funds<T>(withdrawal: Withdrawal<Balance<T>>, ctx: &mut TxContext): Coin<T>

// coin을 address balance로 보낸다
public fun send_funds<T>(coin: Coin<T>, recipient: address)

funds_accumulator.move

Source: sui-framework/sources/funds_accumulator.move:

// Withdrawal struct - FundsWithdrawalArg 또는 withdraw_from_object로 생성된다
public struct Withdrawal<phantom T: store> has drop {
owner: address,
limit: u256,
}

// withdrawal limit를 가져온다
public fun withdrawal_limit<T: store>(withdrawal: &Withdrawal<T>): u256

// withdrawal owner를 가져온다
public fun withdrawal_owner<T: store>(withdrawal: &Withdrawal<T>): address

// 하위 withdrawal을 split한다
public fun withdrawal_split<T: store>(withdrawal: &mut Withdrawal<T>, sub_limit: u256): Withdrawal<T>

// withdrawal을 합친다(owner가 같아야 한다)
public fun withdrawal_join<T: store>(withdrawal: &mut Withdrawal<T>, other: Withdrawal<T>)