본문으로 건너뛰기

Address Balances 사용하기

Address Balances는 Sui 주소에 직접 연결된 각 코인 타입의 canonical balance를 제공한다. sui::coin::send_fundssui::balance::send_funds로 전송된 자금은 객체 관리 없이 코인 타입별 단일 잔액으로 자동 병합된다. 전체 사양은 SIP-58: Sui Address Balances를 참조한다.

자금 보내기

send_funds를 사용해 수신자 주소 잔액에 직접 예치한다. 수신자 잔액은 새 객체를 만들지 않고 증가하며, 서로 다른 송신자의 여러 예치도 모두 하나의 잔액으로 병합된다.

TypeScript SDK

수신자의 주소 잔액에 토큰을 예치하려면 코인 객체를 생성하는 대신 tx.balance()balance::send_funds를 사용한다.

const tx = new Transaction();

tx.moveCall({
target: '0x2::balance::send_funds',
typeArguments: ['0x2::sui::SUI'],
arguments: [tx.balance({ balance: 1_000_000_000n }), tx.pure.address('0xRecipientAddress')],
});

네트워크가 지원하는 경우 tx.balance()send_funds, redeem_funds 같은 적격 Move 호출만 사용해 빌드한 트랜잭션은 가스 수수료가 0이 될 수 있다.

CLI

Sui CLI로 자금을 보내려면:

# Send from gas coin to address balance
sui client ptb \
--split-coins gas '[5000000]' \
--assign coin \
--move-call 0x2::coin::send_funds '<0x2::sui::SUI>' coin @<RECIPIENT_ADDRESS>

# Send from another coin
sui client ptb \
--split-coins @<COIN_ID> '[5000000]' \
--assign coin \
--move-call 0x2::coin::send_funds '<COIN_TYPE>' coin @<RECIPIENT_ADDRESS>
플래그설명
--to수신자 주소.
--amountMIST 단위로 보낼 금액.
--coin-type보낼 코인 타입. 기본값은 0x2::sui::SUI이다.
--all-coins지정된 타입의 모든 코인 잔액을 보낸다. --stateless와 함께 사용할 수 없다.
--stateless주소 잔액만 사용해 stateless 트랜잭션을 빌드한다(소유 객체 입력 없음). 재생 방지를 위해 ValidDuring 만료를 사용한다.

기본적으로 명령은 코인을 먼저 선택하고 코인이 부족하면 주소 잔액으로 폴백한다. --stateless를 사용하면 주소 잔액에서만 인출한다.

Move 함수

Sui framework는 send_fundsbalance 모듈 모두에서 coin를 제공한다.

// Send a Balance<T> to an address balance
public fun send_funds<T>(balance: Balance<T>, recipient: address)

// Send a Coin<T> to an address balance (converts to balance internally)
public fun send_funds<T>(coin: Coin<T>, recipient: address)

자세한 내용은 sui-framework/sources/balance.move)와 sui-framework/sources/coin.move를 참조한다.

자금 인출

withdrawal 입력을 생성하고 redeem하여 Coin<T>를 얻는다.

const tx = new Transaction();

const [coin] = tx.moveCall({
target: '0x2::coin::redeem_funds',
typeArguments: ['0x2::sui::SUI'],
arguments: [tx.withdrawal({ amount: 1_000_000_000 })],
});

tx.transferObjects([coin], '0xRecipientAddress');

Balance<T>를 직접 얻는다.

const [balance] = tx.moveCall({
target: '0x2::balance::redeem_funds',
typeArguments: ['0x2::sui::SUI'],
arguments: [tx.withdrawal({ amount: 1_000_000_000 })],
});

SUI가 아닌 코인 타입에는 type 매개변수를 전달한다.

const [coin] = tx.moveCall({
target: '0x2::coin::redeem_funds',
typeArguments: ['0xPackageId::module::USDC'],
arguments: [tx.withdrawal({ amount: 1_000_000, type: '0xPackageId::module::USDC' })],
});

트랜잭션에서 주소 잔액 자금을 사용하려면 해당 자금을 인출한 뒤 Coin<T> 또는 Balance<T>로 redeem한다.

TypeScript SDK

가스나 트랜잭션 인자로 코인 객체를 명시적으로 선택하지 않으면, TypeScript SDK는 코인 객체와 주소 잔액을 모두 자동으로 확인하고 올바른 소스에서 인출한다.

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

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

TypeScript SDK: 수동 인출

주소 잔액 인출을 명시적으로 제어하려면 tx.withdrawal()을 사용한다. 사용 가능한 redeem_funds 또는 Coin<T>를 생성하려면 withdrawal을 Balance<T>로 redeem해야 한다.

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

const tx = new Transaction();

// Withdraw SUI from address balance and redeem to Coin
const [coin] = tx.moveCall({
target: '0x2::coin::redeem_funds',
typeArguments: ['0x2::sui::SUI'],
arguments: [tx.withdrawal({ amount: 1_000_000_000 })], // 1 SUI in MIST
});

// Withdraw a 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 })],
});

// Transfer to recipient
tx.transferObjects([coin], recipient);
정보

tx.withdrawal()은 코인 또는 잔액을 만들기 위해 redeem해야 하는 Withdrawal<Balance<T>> capability를 생성한다. 이를 수행하려면 0x2::coin::redeem_funds() 또는 0x2::balance::redeem_funds()를 사용한다. coinWithBalance intent는 이 작업을 자동으로 수행한다.

Rust SDK: 수동 인출

Rust에서 주소 잔액을 인출하려면 FundsWithdrawalArg를 사용한다. 헬퍼 FundsWithdrawalArg::balance_from_sender(amount, balance_type)는 편리한 생성자를 제공한다.

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,
};

PTB에서 withdrawal 입력을 다음 중 하나에 전달한다.

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

같은 코인 타입이라도 PTB에 여러 FundsWithdrawalArg 입력을 넣을 수 있다.

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();

// Redeem to coin
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);

자세한 내용은 sui-types/src/programmable_transaction_builder.rs를 참조한다.

withdrawal 분할 및 결합

PTB 안에서 withdrawal을 분할하고 병합할 수 있다.

// Split a sub-withdrawal from an existing withdrawal
public fun withdrawal_split<T: store>(withdrawal: &mut Withdrawal<T>, sub_limit: u256): Withdrawal<T>

// Join two withdrawals together (must have same owner)
public fun withdrawal_join<T: store>(withdrawal: &mut Withdrawal<T>, other: Withdrawal<T>)

주소 잔액으로 가스 지불

주소 잔액으로 가스를 지불할 때는 setGasPayment에 빈 배열을 전달한다. 조회할 코인 객체 버전이 없으므로 완전한 오프라인 트랜잭션 구성이 가능해진다.

const tx = new Transaction();
tx.setGasPayment([]); // Empty array = use address balance for gas

Rust에서는 gas_data.payment를 빈 벡터로 설정하고 ValidDuring 만료를 사용한다.

TransactionData::V1(TransactionDataV1 {
kind: tx_kind,
sender,
gas_data: GasData {
payment: vec![], // Empty - gas paid from 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,
},
})

주소 잔액 가스 지불 요구 사항:

  • gas_data.payment는 비어 있어야 한다.
  • expirationValidDuringmin_epoch가 모두 지정된 max_epoch이어야 한다.
  • max_epoch는 최대 min_epoch + 1이어야 한다(단일 에포크 또는 1-에포크 범위).
  • 타임스탬프 기반 만료는 현재 지원되지 않는다.
  • 트랜잭션 종류는 ProgrammableTransaction이어야 한다.

nonce 필드는 그 밖에는 동일한 트랜잭션을 구분한다. EVM 체인과 달리 nonce에는 의미론적 요구 사항이 없다. 순차적일 필요가 없으며 nonce gap problem도 없다. 단지 nonce가 없으면 같은 다이제스트를 가질 두 트랜잭션을 제출할 수 있게 한다. 대부분의 사용 사례에서는 nonce를 무작위로 생성하거나 애플리케이션에서 증가 카운터를 사용한다.

// Random nonce
let nonce: u32 = rand::random();

// Or incrementing counter
let nonce: u32 = self.next_nonce.fetch_add(1, Ordering::SeqCst);
Click to open
주소 잔액 가스 지불이 활성화되어 있는지 확인

주소 잔액 가스 지불을 사용하기 전에 프로토콜 구성 플래그를 확인해 네트워크에서 기능이 활성화되어 있는지 검증할 수 있다.

const network = 'testnet';

const networkUrls = {
mainnet: 'https://fullnode.mainnet.sui.io:443',
testnet: 'https://fullnode.testnet.sui.io:443',
devnet: 'https://fullnode.devnet.sui.io:443',
};

const client = new SuiGrpcClient({
network,
baseUrl: networkUrls[network],
});

const { response } = await client.ledgerService.getEpoch({
readMask: {
paths: ['protocol_config.feature_flags'],
},
});

const enabled =
response.epoch?.protocolConfig?.featureFlags['enable_address_balance_gas_payments'] ?? false;

console.log(`enable_address_balance_gas_payments on ${network}:`, enabled);

스폰서드 트랜잭션

Address Balances는 코인 기반 후원보다 스폰서드 트랜잭션을 단순화한다.

  • 가스 코인 잠금 위험이 없다.
  • 스폰서가 가스 코인 인벤토리를 관리할 필요가 없다.
  • 사용자가 스폰서보다 먼저 서명할 수 있어 더 단순한 비동기 플로우가 가능하다.
  • 무허가 공개 가스 스테이션을 가능하게 한다.
주의

여기서도 GasCoin 인자는 여전히 유효하며, 주소 잔액 가스 지불을 사용하더라도 스폰서드 트랜잭션에서 사용 여부를 계속 모니터링해야 한다.

Address Balances에서는 사용자가 먼저 서명하고 스폰서가 나중에 서명한다.

// 1. User builds and signs the transaction first
const tx = new Transaction();
tx.setSender(userAddress);
tx.setGasOwner(sponsorAddress);
tx.setGasPayment([]); // Empty array = sponsor pays from address balance
// ... add commands ...

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

// Option A: Send transaction and signature to sponsor, sponsor can sign and execute immediately:
// (`send_tx_to_sponsor` is a placeholder, there is no such API in the SDK)
await send_tx_to_sponsor(userSignature, bytes);

// Option B: Get signature from sponsor and submit it ourselves. Sponsor signs (can happen asynchronously)
const { signature: sponsorSignature } = await sponsorKeypair.signTransaction(bytes);

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

송신자와 스폰서는 모두 서명해야 한다. 스토리지 리베이트는 스폰서 주소 잔액에 적립된다.

잔액 쿼리

TypeScript SDK

코인 객체와 주소 잔액을 모두 보려면 getBalance를 사용한다.

const { balance } = await grpcClient.getBalance({
owner: '0xMyAddress',
});

console.log(balance.balance); // total balance as string (coin objects + address balance)
console.log(balance.coinBalance); // balance from coin objects only
console.log(balance.addressBalance); // balance from address balance only
console.log(balance.coinType); // e.g. "0x2::sui::SUI"

모든 잔액 값은 문자열로 반환된다. 숫자 타입에는 BigInt(balance.balance)를 사용한다.

gRPC

GetBalanceListBalancesStateService를 사용한다.

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

# List all balances
buf curl --protocol grpc https://fullnode.testnet.sui.io/sui.rpc.v2.StateService/ListBalances \
-d '{"owner": "<ADDRESS>"}'

GraphQL

아래 예시를 GraphQL IDE (Testnet)에 복사해 실행해 볼 수 있다.

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

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

자세한 내용은 GraphQL Reference - IAddressable.balance를 참조한다.

체크포인트 데이터에서 잔액 변경 계산

Address Balances를 사용할 때 잔액 변경 내역을 계산하려면 TransactionEffects의 accumulator 이벤트도 처리해야 한다.

use sui_types::balance_change::{derive_balance_changes, BalanceChange};

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

알고리즘:

  1. 입력 코인 차감: 각 입력 코인 객체의 값을 (owner, coin_type)에서 차감한다.
  2. 출력 코인 추가: 변경되었거나 생성된 각 코인 객체의 값을 (owner, coin_type)에 추가한다.
  3. accumulator 이벤트 처리: Balance<T> 타입을 가진 각 accumulator 이벤트에 대해:
    • Split 작업: 금액을 차감한다(주소 잔액에서 인출된 자금).
    • Merge 작업: 금액을 추가한다(주소 잔액에 예치된 자금).

전체 알고리즘은 참조 구현을 참조한다.

accumulator 이벤트 접근

Accumulator 이벤트는 TransactionEffects에 포함되어 있다.

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;

// Only Balance<T> types represent balance changes
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) represents the balance change
}
}

BalanceChange 구조체는 결과를 나타낸다.

pub struct BalanceChange {
pub address: SuiAddress,
pub coin_type: TypeTag,
pub amount: i128, // Negative = spent, positive = received
}

기존 코인을 Address Balances로 변환

마이그레이션은 선택 사항이다. Sui CLI를 사용해 코인을 주소 잔액으로 통합하려면:

sui client ptb \
--merge-coins @<COIN_1> '[@<COIN_2>, @<COIN_3>]' \
--move-call 0x2::coin::send_funds '<0x2::sui::SUI>' @<COIN_1> @<YOUR_ADDRESS>

SUI 코인은 가스로 스매싱한 뒤 --move-call 0x2::coin::send_funds --gas-coin <COIN>을 사용할 수 있다.

TypeScript SDK는 필요에 따라 코인 또는 주소 잔액을 자동으로 선택한다. Coin<T> 또는 Balance<T>를 받는 컨트랙트는 계속 호출할 수 있다. redeem_funds 함수는 PTB 안에서 인출을 예상 타입으로 변환한다.

TypeScript SDK 사용:

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

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

이 방법으로 삭제된 코인 객체에서 스토리지 리베이트를 받을 수 있다.