Using Allowances
An allowance is a shared object of type sui::allowance::Allowance<Balance<T>> that authorizes one spender to withdraw coin type T from the funder's address balance. The funder issues it, the spender uses it in their own transactions, and the funder revokes it. For the concepts and use cases, see What Are Allowances?. For the full module reference, see sui::allowance.
- Prerequisites
- Install Sui v1.80 or later for Devnet and Testnet, or v1.81 or later for Mainnet.
- A funded address balance on the funder's address.
- For the TypeScript examples,
@mysten/sui2.31 or later:npm install @mysten/sui.
Issue an allowance
The funder issues a direct allowance with sui::allowance::new. It is an entry function, so only a programmable transaction block (PTB) signed by the funder can call it. A Move contract cannot issue an allowance over a caller's balance from inside another call.
entry fun new<T>(
name: String,
spender: address,
lifetime_cap: Option<u256>,
start_timestamp_ms: Option<u64>,
expiration_timestamp_ms: Option<u64>,
rate_limit: Option<RateLimit>,
ctx: &mut TxContext,
)
The type argument T is the balance type, for example Balance<0x2::sui::SUI>, not the coin type. The arguments are:
name: A label for wallets and dashboards, at most 128 bytes. Sui never reads it.spender: The address allowed to spend. The spender must be the sender of every spending transaction.lifetime_cap: The total that can ever be spent through this allowance, in the coin's smallest unit.start_timestamp_ms: Optional activation time in milliseconds. Spends before it abort.expiration_timestamp_ms: Optional expiration in milliseconds. Spends at or after it abort.rate_limit: Optional recurring cap, built with one of the rate limit constructors below.
Set at least one of lifetime_cap and rate_limit, and at least one of expiration_timestamp_ms and rate_limit. The call creates the allowance as a shared object and sends an AllowanceCap to the funder. The cap can be used for revocation. It has only the key ability, so it cannot be transferred or wrapped.
Rate limits
Build a rate limit with one of the constructors in the same module and pass it as some(rate_limit):
// At most `limit` per `period_ms`, counted from the first spend.
public fun periodic_rate_limit(period_ms: u64, limit: u256): RateLimit
// At most `limit` per `months` civil months (UTC), renewing on the anchor's day of month.
public fun calendar_rate_limit(months: u8, limit: u256): RateLimit
public fun monthly_rate_limit(limit: u256): RateLimit
public fun quarterly_rate_limit(limit: u256): RateLimit
public fun yearly_rate_limit(limit: u256): RateLimit
Windows are fixed, not sliding, and unused amounts do not carry over. See the Allowances FAQ for how windows are anchored and how calendar months behave.
TypeScript SDK
The TypeScript examples on this page share the following setup:
import { SuiGrpcClient } from '@mysten/sui/grpc';
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
export const client = new SuiGrpcClient({
network: 'testnet',
baseUrl: 'https://fullnode.testnet.sui.io:443',
});
// Load secret keys from the environment, never hardcode them.
export const funderKeypair = Ed25519Keypair.fromSecretKey(process.env.FUNDER_SECRET_KEY!);
export const spenderKeypair = Ed25519Keypair.fromSecretKey(process.env.SPENDER_SECRET_KEY!);
export const funderAddress = funderKeypair.toSuiAddress();
export const spenderAddress = spenderKeypair.toSuiAddress();
The following transaction issues an allowance of 1 SUI in total, with no rate limit and no start time, that expires in 30 days. Because Option<RateLimit> is not a pure value, the none comes from a Move call:
import { Transaction } from '@mysten/sui/transactions';
import { client, funderAddress, funderKeypair, spenderAddress } from './setup';
const ALLOWANCE_TYPE = '0x2::balance::Balance<0x2::sui::SUI>';
const expirationMs = Date.now() + 30 * 24 * 60 * 60 * 1000;
const tx = new Transaction();
tx.setSender(funderAddress);
const noRateLimit = tx.moveCall({
target: '0x1::option::none',
typeArguments: ['0x2::allowance::RateLimit'],
});
tx.moveCall({
target: '0x2::allowance::new',
typeArguments: [ALLOWANCE_TYPE],
arguments: [
tx.pure.string('Streaming plan'),
tx.pure.address(spenderAddress),
tx.pure.option('u256', 1_000_000_000n), // lifetime cap: 1 SUI
tx.pure.option('u64', null), // no start time
tx.pure.option('u64', expirationMs), // expiration
noRateLimit,
],
});
const result = await client.signAndExecuteTransaction({
transaction: tx,
signer: funderKeypair,
include: { effects: true },
});
if (result.$kind === 'FailedTransaction') {
throw new Error(`Issuing the allowance failed: ${result.FailedTransaction.status.error?.message}`);
}
// Wait until the full node has indexed the new objects before reading them.
await client.waitForTransaction({ result });
// The allowance is the created shared object. The AllowanceCap is the created object owned by the funder.
const allowance = result.Transaction.effects.changedObjects.find(
(change) => change.idOperation === 'Created' && change.outputOwner?.$kind === 'Shared',
);
if (!allowance) throw new Error('No shared object created');
const allowanceId = allowance.objectId;
Rust SDK
The following PTB issues the same allowance:
use move_core_types::identifier::Identifier;
use move_core_types::u256::U256;
use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder;
use sui_types::{MOVE_STDLIB_PACKAGE_ID, SUI_FRAMEWORK_PACKAGE_ID, TypeTag};
let balance_type: TypeTag = "0x2::balance::Balance<0x2::sui::SUI>".parse().unwrap();
let rate_limit_type: TypeTag = "0x2::allowance::RateLimit".parse().unwrap();
let mut builder = ProgrammableTransactionBuilder::new();
let no_rate_limit = builder.programmable_move_call(
MOVE_STDLIB_PACKAGE_ID,
Identifier::new("option").unwrap(),
Identifier::new("none").unwrap(),
vec![rate_limit_type],
vec![],
);
let args = vec![
builder.pure("Streaming plan".to_string()).unwrap(),
builder.pure(spender).unwrap(),
builder.pure(Some(U256::from(1_000_000_000u64))).unwrap(), // lifetime cap: 1 SUI
builder.pure(None::<u64>).unwrap(), // no start time
builder.pure(Some(expiration_ms)).unwrap(), // expiration
no_rate_limit,
];
builder.programmable_move_call(
SUI_FRAMEWORK_PACKAGE_ID,
Identifier::new("allowance").unwrap(),
Identifier::new("new").unwrap(),
vec![balance_type],
args,
);
let pt = builder.finish();
// Sign and execute as the funder.
The transaction effects list 2 created objects: the shared Allowance and the funder-owned AllowanceCap. Record the allowance's object ID. The spender needs it, along with the funder's address.
Spend from an allowance
The spender sends a spending transaction as a normal PTB. It has 3 parts:
- A funds withdrawal input whose source is
SenderAllowance { funder, allowance }. This declares the amount, the funder whose balance Sui debits, and the allowance that authorizes it. - The allowance as a mutable shared object input.
- A call to
sui::allowance::balance_spend, which checks the limits, charges them, and returns aBalance<T>for the rest of the transaction to use.
public fun balance_spend<C>(
self: &mut Allowance<Balance<C>>,
w: AllowanceWithdrawal<Balance<C>>,
clock: &Clock,
ctx: &TxContext,
): Balance<C>
Before the transaction executes, Sui verifies that the allowance is an input of the transaction, that its funder matches the declared funder, that the transaction sender is its spender, and that the coin type matches. Sui rejects a transaction that fails these checks before execution, so it costs no gas. Sui reserves the amount against the funder's balance, exactly like a withdrawal from the sender's own balance.
TypeScript SDK
tx.balance() accepts an allowance option. The SDK reads the allowance to find the funder, adds the withdrawal input, and calls balance_spend for you. The following transaction spends 0.1 SUI through the allowance and deposits it into the spender's address balance:
import { Transaction } from '@mysten/sui/transactions';
import { client, spenderAddress, spenderKeypair } from './setup';
const allowanceId = 'ALLOWANCE_ID'; // from the issuing transaction
const tx = new Transaction();
tx.setSender(spenderAddress);
const balance = tx.balance({ allowance: allowanceId, balance: 100_000_000n }); // 0.1 SUI
tx.moveCall({
target: '0x2::balance::send_funds',
typeArguments: ['0x2::sui::SUI'],
arguments: [balance, tx.pure.address(spenderAddress)],
});
const result = await client.signAndExecuteTransaction({ transaction: tx, signer: spenderKeypair });
if (result.$kind === 'FailedTransaction') {
throw new Error(`Spend failed: ${result.FailedTransaction.status.error?.message}`);
}
Pass type for a coin other than SUI. The returned Balance<T> works in any Move call. To skip the allowance lookup when you already know the funder, pass { objectId, funder } as the allowance. For an app-bound allowance, pass { objectId, app: { type, permit } }, where permit is a SpendPermit your module returned earlier in the same transaction.
For full control over the transaction, replace the tx.balance() call above with a withdrawal input built with from: 'allowance' and a direct call to balance_spend:
import { funderAddress } from './setup';
const SUI = '0x2::sui::SUI';
const withdrawal = tx.withdrawal({
amount: 100_000_000n,
type: SUI,
from: 'allowance',
allowance: allowanceId,
funder: funderAddress,
});
const balance = tx.moveCall({
target: '0x2::allowance::balance_spend',
typeArguments: [SUI],
arguments: [tx.object(allowanceId), withdrawal, tx.object.clock()],
});
Both forms take the coin type, while the funder issued the allowance with the balance type Balance<0x2::sui::SUI>. The spender pays gas.
Rust SDK
The following PTB spends amount MIST from funder through the allowance and keeps the resulting coin:
use move_core_types::identifier::Identifier;
use sui_types::gas_coin::GAS;
use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder;
use sui_types::transaction::{FundsWithdrawalArg, ObjectArg, SharedObjectMutability};
use sui_types::{SUI_CLOCK_OBJECT_ID, SUI_CLOCK_OBJECT_SHARED_VERSION, SUI_FRAMEWORK_PACKAGE_ID};
let mut builder = ProgrammableTransactionBuilder::new();
let allowance_arg = builder
.obj(ObjectArg::SharedObject {
id: allowance_id,
initial_shared_version,
mutability: SharedObjectMutability::Mutable,
})
.unwrap();
let withdraw_arg = builder
.funds_withdrawal(FundsWithdrawalArg::balance_from_allowance(
amount,
GAS::type_tag(),
funder,
allowance_id,
))
.unwrap();
let clock_arg = builder
.obj(ObjectArg::SharedObject {
id: SUI_CLOCK_OBJECT_ID,
initial_shared_version: SUI_CLOCK_OBJECT_SHARED_VERSION,
mutability: SharedObjectMutability::Immutable,
})
.unwrap();
let spent = builder.programmable_move_call(
SUI_FRAMEWORK_PACKAGE_ID,
Identifier::new("allowance").unwrap(),
Identifier::new("balance_spend").unwrap(),
vec!["0x2::sui::SUI".parse().unwrap()],
vec![allowance_arg, withdraw_arg, clock_arg],
);
let coin = builder.programmable_move_call(
SUI_FRAMEWORK_PACKAGE_ID,
Identifier::new("coin").unwrap(),
Identifier::new("from_balance").unwrap(),
vec!["0x2::sui::SUI".parse().unwrap()],
vec![spent],
);
builder.transfer_arg(spender, coin);
let pt = builder.finish();
// Sign and execute as the spender. The spender pays gas.
FundsWithdrawalArg::balance_from_allowance and balance_spend both take the coin type (0x2::sui::SUI).
The returned Balance<C> behaves like any other balance. Convert it to a coin with coin::from_balance, pass it to a Move function, or deposit it into an address balance with balance::send_funds.
Bind an allowance to an app
An app-bound allowance routes every spend through the Move module that defines the app type A. Use it when your contract, not just the spender's key, must decide whether each pull is allowed, or when you need to rotate the spender key without involving the funder.
Issuance has 2 steps. The funder calls sui::allowance::propose_for_app in a PTB, which returns an AllowanceProposal instead of creating anything. In the same PTB, the funder passes the proposal to a function in your module that accepts it with sui::allowance::issue:
entry fun propose_for_app<T, A>(
name: String,
spender: address,
lifetime_cap: Option<u256>,
start_timestamp_ms: Option<u64>,
expiration_timestamp_ms: Option<u64>,
rate_limit: Option<RateLimit>,
ctx: &TxContext,
): AllowanceProposal<T>
public fun issue<T, A>(proposal: AllowanceProposal<T>, _: SettingsPermit<A>, ctx: &mut TxContext)
Your module obtains the permits from std::internal::permit<A>(), which only the module that defines A can call. A SettingsPermit<A> authorizes issuing and rotating the spender, and a SpendPermit<A> authorizes a single spend. The following module accepts proposals, spends, and rotates the spender for its APP type. Add your own checks before each call:
module my_app::allowance_app;
use sui::allowance::{Self, Allowance, AllowanceProposal, AllowanceWithdrawal};
use sui::balance::Balance;
use sui::clock::Clock;
public struct APP has drop {}
public fun issue<T>(proposal: AllowanceProposal<T>, ctx: &mut TxContext) {
allowance::issue(proposal, allowance::settings_permit(internal::permit<APP>()), ctx)
}
public fun rotate<T>(a: &mut Allowance<T>, new_spender: address) {
allowance::rotate_spender(a, allowance::settings_permit(internal::permit<APP>()), new_spender)
}
public fun spend<C>(
a: &mut Allowance<Balance<C>>,
w: AllowanceWithdrawal<Balance<C>>,
clock: &Clock,
ctx: &TxContext,
): Balance<C> {
allowance::app_balance_spend(a, allowance::spend_permit(internal::permit<APP>()), w, clock, ctx)
}
Spending works as in the direct case, except the spender calls your module's spend function instead of balance_spend. The transaction must still come from the allowance's spender. balance_spend aborts with EHasApp on an app-bound allowance, and app_balance_spend aborts with EWrongApp if the app type does not match.
Any holder of SettingsPermit<A> can rotate the spender of every allowance bound to A, with no funder involvement. Guard the functions that mint permits as carefully as any other admin path in your module.
Revoke an allowance
The funder revokes by passing the AllowanceCap and the allowance to sui::allowance::revoke. Sui deletes both objects, which reclaims their storage rebates:
public fun revoke<T>(self: AllowanceCap<T>, allowance: Allowance<T>)
From the TypeScript SDK:
import { Transaction } from '@mysten/sui/transactions';
import { client, funderAddress, funderKeypair } from './setup';
const allowanceId = 'ALLOWANCE_ID';
const allowanceCapId = 'ALLOWANCE_CAP_ID';
const tx = new Transaction();
tx.setSender(funderAddress);
tx.moveCall({
target: '0x2::allowance::revoke',
typeArguments: ['0x2::balance::Balance<0x2::sui::SUI>'],
arguments: [tx.object(allowanceCapId), tx.object(allowanceId)],
});
await client.signAndExecuteTransaction({ transaction: tx, signer: funderKeypair });
From the Sui CLI, using the balance type as the type argument:
$ sui client ptb \
--move-call 0x2::allowance::revoke '<0x2::balance::Balance<0x2::sui::SUI>>' @ALLOWANCE_CAP_ID @ALLOWANCE_ID
After revocation, the allowance no longer exists, so Sui rejects any spend that names it before execution. A spend that was already in flight fails at execution instead. There is no way to update an allowance in place. To change its terms, revoke it and issue a new one.
Find allowances
The funder's AllowanceCap objects point at the allowances they granted. List the caps an address owns, filtered by type 0x2::allowance::AllowanceCap<...>, and read each cap's allowance field to get the allowance's object ID. Read the allowance object itself for its settings and cumulative spend:
settings.funder,settings.spender,settings.app: Who is involved.settings.lifetime_cap,settings.rate_limit,settings.start_timestamp_ms,settings.expiration_timestamp_ms: The limits.current_spend: Total spent so far.
Spenders learn about allowances offchain: the funder shares the allowance ID, or an indexer maps spender addresses to allowance objects. The module does not emit events. Issuance and revocation show up as created and deleted objects in transaction effects, and spends show up as balance changes on the funder's address.