주소 잔액 마이그레이션 가이드
Address Balances는 Sui 주소에 연결된 대체 가능 자산용 canonical balance system을 도입한다.
이는 UTXO 스타일 Coin<T> 모델을 직접 주소 소유 잔액으로 대체하여 트랜잭션 구성을 단순화하고 코인 선택 복잡성을 없앤다.
전체 명세는 SIP-58: Sui Address Balances를 참조한다.
먼저 읽기
제한적인 영향
이 rollout은 처음에는 영향이 매우 제한적이다:
- deprecated되거나 제거되는 것은 없다. 현재 유효한 모든 트랜잭션은 계속 유효하다. Coin 객체가 주소 잔액으로 강제 마이그레이션되지는 않는다.
- contract를 다시 작성할 필요는 없다. contract는 이전과 같이 계속
Coin<T>와&mut Coin<T>를 받을 수 있다. - 코인은 여전히
transfer::public_transfer또는TransferObjects명령를 통해 보낼 수 있다.
지갑, 거래소, 수탁 제공자에 미치는 영향
지갑(custodial, non-custodial, exchange 지갑 등)을 운영하는 누구나 이 변경의 영향을 받을 수 있다.
이유는 이제 사용자가 코인 객체를 전송하는 대신 send_funds()를 통해 여러분의 주소로 fund를 보낼 수 있기 때문이다.
지갑가 이 방식으로 fund를 받았지만 그 fund를 "보지" 못하거나 접근하지 못하면, 사용자는 fund가 사라졌다고 믿을 수 있다.
실제 로 fund가 사라질 수는 없지만, 이는 영향을 받는 사용자에게 혼란과 우려를 일으킬 수 있다.
처음에는 대부분의 ecosystem이 오늘날과 마찬가지로 계속 코인 객체를 전송할 것이므로, 이런 일은 자주 일어나지 않을 것이다. 하지만 어떤 지갑에도 fund가 주소 잔액 transfer로 도착할 가능성이 존재하므로, 지갑 구현자는 이에 대비해야 한다.
또한 사용자 fund는 코인과 주소 잔액 양쪽에 분산될 수 있다. 잔액 쿼리(JSON-RPC, gRPC, GraphQL을 통한)는 결합된 총합을 보여준다. 하지만 주소 잔액 부분이 포함된 fund를 보내려면 다음 중 하나를 해야 한다:
- 최근 버전의 TypeScript SDK(v2+)에 있는
coinWithBalance를 사용한다. 이는 두 source에서 자동으로 자금을 끌어온다. - 이 가이드에서 설명하는 수동 출금 logic을 구현한다.
개요
이전에는 Sui의 잔액을 주소가 소유한 모든 Coin<T> 객체의 값을 합산하여 계산했다.
주소 잔액에서는 각 주소가 각 currency 타입 T마다 주소 소유 잔액을 하나씩 추가로 가질 수 있다.
총 잔액은 모든 Coin<T> 객체와 해당 코인 타입의 주소 잔액 값을 합한 값이다.
Coin<T>와 주소 잔액은 공존한다.
기존 코인은 계속 동작하며 여전히 transfer::public_transfer로 전송할 수 있다.
하지만 주소 잔액은 다음과 같은 중요한 운영상 이점을 제공한다:
- 코인 선택 로직이 필요 없다.
- 예치가 하나의 canonical balance으로 자동 병 합된다.
- 객체 상태를 질의하지 않고도 stateless한 트랜잭션 구성이 가능하다.
- SUI 주소 잔액에서 직접 gas를 지불할 수 있다.
주소 잔액에서 트랜잭션 funding
gas나 트랜잭션 argument로 사용할 코인 객체를 명시적으로 선택하지 않는 경우, TypeScript SDK는 코인 객체와 주소 잔액을 모두 자동으로 확인하고 올바른 source에서 자금을 끌어온다.
gas는 기본적으로 주소 잔액에서 지불되며, coinWithBalance는 먼저 주소 잔액에서 자금을 끌어오고 필요할 때만 코인 객체로 fallback한다.
coinWithBalance 사용
TypeScript SDK - coinWithBalance:
import { coinWithBalance, Transaction } from '@mysten/sui/transactions';
const tx = new Transaction();
tx.transferObjects([coinWithBalance({ balance: requiredAmount })], recipient);
fund가 주소 잔액와 코인 객체 양쪽에 분산되어 있어도, SDK가 이 조합을 자동으로 처리하여 먼저 주소 잔액을 사용하고 필요할 때만 코인으로 fallback한다.
coinWithBalance option은 다음과 같다:
balance(required): base unit 금액이다(SUI의 경우 MIST).type(optional): 코인 타입이며 기본값은0x2::sui::SUI이다.useGasCoin(optional): SUI에 대해 가스 코인에서 split할지 여부이다(기본값true). sponsored 트랜잭션에는false로 설정한다.forceAddressBalance(optional): 주소 잔액 출금을 강제하고 잔액/코인 lookup을 건너뛴다.
TypeScript SDK: 수동 인출
주소 잔액 출금을 명시적으로 제어하려면 tx.withdrawal()을 사용한다.
usable한 redeem_funds 또는 Coin<T>를 만들려면 출금을 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()의 파라미터는 다음과 같다:
amount(required): base unit으로 인출할 금액이다(number, bigint, string).type(optional): 코인 타입이며 기본값은 SUI이다.
참고: tx.withdrawal()은 코인 또는 잔액을 생성하기 위해 redeem되어야 하는 Withdrawal<Balance<T>> capability를 만든다.
이를 위해 0x2::coin::redeem_funds() 또는 0x2::balance::redeem_funds()를 사용한다.
coinWithBalance intent는 이를 자동으로 수행한다.
Rust SDK: 수동 인출
소스: 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에서는 출금 입력을 다음 중 하나에 전달한다:
0x2::balance::redeem_funds<T>()를 사용해Balance<T>를 얻는다.0x2::coin::redeem_funds<T>()를 사용해Coin<T>를 얻는다.
소스: sui-types/src/programmable_transaction_builder.rs:
// Rust example: withdraw and transfer
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);
같은 코인 타입에 대해서도 하나의 PTB에 여러 FundsWithdrawalArg 입력을 둘 수 있다.
출금 split 및 join
출금은 PTB 안에서 split하고 병합할 수 있다.
소스: sui-framework/sources/funds_accumulator.move:
// 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>)
fund 보내기
이전: Coin transfer
코인을 transfer하면 수신자에게 코인 객체를 보낸다:
import { coinWithBalance, Transaction } from '@mysten/sui/transactions';
const tx = new Transaction();
tx.transferObjects([coinWithBalance({ balance: amount })], recipient);
각 transfer는 수신자를 위해 새로운 객체를 만들어 객체 proliferation에 기여한다. 수신자는 시간이 지나면서 많은 작은 코인 객체를 누적하게 되며, 주기적인 consolidation이 필요해진다.
이후: 주소 잔액 예치
주소 잔액에서는 send_funds를 사용해 수신자의 주소 잔액에 직접 예치한다:
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)],
});
수신자의 잔액은 새로운 객체를 만들지 않고 증가한다. 서로 다른 송신자가 여러 번 예치해도 모두 하나의 잔액으로 병합된다.
소스: sui-framework/sources/coin.move - send_funds:
// After: In Move contract
public fun send_payment(coin: Coin<SUI>, recipient: address) {
coin::send_funds(coin, recipient);
}
수신자의 잔액은 새로운 객체를 만들지 않고 증가한다. 서로 다른 송신자가 여러 번 예치해도 모두 하나의 잔액으로 병합된다.
Move 함수
소스: sui-framework/sources/balance.move - send_funds:
// Send a Balance<T> to an address balance
public fun send_funds<T>(balance: Balance<T>, recipient: address)
소스: sui-framework/sources/coin.move - send_funds:
// Send a Coin<T> to an address balance (converts to balance internally)
public fun send_funds<T>(coin: Coin<T>, recipient: address)
CLI를 통해 fund 보내기
CLI는 현재 주소 잔액에 대한 지원이 제한적이지만, 이를 수행하도록 PTB를 수동으로 구성하면 코인에서 주소 잔액으로 fund를 보내는 데 사용할 수 있다:
# 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>
주소 잔액에서 가스 지불하기
주소 잔액 가스 결제 활성화 여부 확인
주소 잔액 가스 결제를 사용하기 전에 protocol configuration flag를 확인하여 network에서 feature가 활성화되어 있는지 검증할 수 있다:
// Choose your network: 'mainnet', 'testnet', or 'devnet'
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);
이전: 가스 코인 관리
코인 기반 가스 결제에서는 가스 코인을 질의하고 선택해야 한다:
const tx = new Transaction();
// Gas coins are selected automatically at build time, requiring network queries
가스 코인 관리는 coordination 문제를 만든다:
- 각 트랜잭션 전에 현재 가스 코인 상태를 질의해야 한다.
- 병렬 트랜잭션에는 별도의 가스 코인이 필요하거나 신중한 sequencing이 필요하다.
- 가스 코인 version은 각 트랜잭션 후에 바뀐다.
이후: stateless 가스 결제
주소 잔액 가스 결제에서는 setGasPayment에 빈 배열을 전달한다:
const tx = new Transaction();
tx.setGasPayment([]); // Empty array = use address balance for gas
이렇게 하면 조회할 코인 객체 version이 없으므로 완전히 offline으로 트랜잭션을 build할 수 있다.
Rust에서는 gas_data.payment를 빈 vector로 설정하고 ValidDuring expiration을 사용한다:
소스: sui-types/src/transaction.rs - TransactionExpiration::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는 비어 있어야 한다.expiration은ValidDuring와min_epoch가 모두 지정된max_epoch이어야 한다.max_epoch는 최대min_epoch + 1이어야 한다(single epoch 또는 1-epoch range).- 타임스탬프 기반 expiration은 현재 지원되지 않는다.
- 트랜잭션 kind는
ProgrammableTransaction이어야 한다.
nonce 필드는 그 외에는 동일한 트랜잭션을 구분한다.
EVM chain과 달리 nonce에는 semantic 요구 사항가 없다.
sequential할 필요도 없고, "nonce gap" 문제도 없다.
이는 단지 digest가 같아질 트랜잭션 두 개를 제출할 수 있게 해준다.
대부분의 사용 사례에서는 nonce를 무작위로 생성하거나 애플리케이션에서 증가하는 counter를 사용한다:
// Random nonce
let nonce: u32 = rand::random();
// Or incrementing counter
let nonce: u32 = self.next_nonce.fetch_add(1, Ordering::SeqCst);
스폰서드 트랜잭션
이전: 코인을 사용하는 sponsored gas
코인을 사용하는 gas sponsorship은 사용자와 sponsor 사이의 coordination을 요구한다:
- 사용자가 gas 없이 트랜잭션을 구성한다.
- sponsor가 가스 코인을 선택해 트랜잭션에 추가한다.
- 양쪽 모두가 서명한다.
- 위험: 사용자가 서명을 완료하지 않으면 sponsor의 가스 코인이 lock될 수 있다.
이후: 주소 잔액을 사용하는 sponsored gas
주소 잔액에서는 사용자가 sponsor보다 먼저 서명할 수 있으므로 더 단순한 async flow가 가능하다:
// 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],
});
송신자와 sponsor는 모두 서명해야 한다. 스토리지 리베이트는 sponsor의 주소 잔액에 적립된다.
코인 기반 sponsorship 대비 장점은 다음과 같다:
- 가스 코인 lock 위험이 없다.
- sponsor가 가스 코인 inventory를 관리할 필요가 없다.
- 사용자가 sponsor보다 먼저 서명할 수 있어 더 단순한 async flow가 가능하다.
- permissionless public 가스 스테이션(gas station)을 가능하게 한다.
RPC를 통해 잔액 질의하기
이전: 코인 기반 잔액 질의
총 잔액을 구하려면 RPC layer가 모든 코인 객체를 대신 합산해 준다:
// Before: Get balance (RPC sums all coin objects)
const balance = await client.core.getBalance({ owner: address, coinType: '0x2::sui::SUI' });
console.log(balance.totalBalance); // Sum of all Coin<SUI> objects
// List all balances for an address
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 필드는 주소가 소유한 모든 Coin<T> 객체 값의 합계를 나타냈다.