Spenders
A spender is an address the owner authorizes to send confidential transfers from a capped allowance that expires. Spenders let automated services, payment processors, custodians, or DeFi integrations move funds for an owner without the owner sharing their spending key.
Unlike a SEP-41 allowance, which is only a permission to pull from the owner's balance, a confidential
allowance is escrowed. set_spender moves the amount out of the owner's spendable balance into a
separate per-spender commitment. The spender can only ever reach that escrow, so a compromised or
malicious spender can lose at most the allowance.
Lifecycle
A delegation is active from set_spender until the ledger passes live_until_ledger, and expired
after that. Expiry only stops spending: the remaining allowance stays escrowed in the delegation entry
until revoke_spender deletes the entry and returns the allowance to the owner's spendable balance. In
deployments with the compliance extension, the token admin can also end a frozen account's delegation
with force_revoke_spender (see Revoking a Spender).
Each (owner, spender) pair holds at most one delegation. set_spender reverts with
DelegationAlreadyExists while an entry exists for the pair, even if it has expired. To re-delegate to
the same spender, the owner first calls revoke_spender and then set_spender again.
The owner's spendable balance and each allowance are separate commitments, so they never need to be
kept in sync. The owner can transfer, withdraw, merge, or set up more spenders while delegations are
active. None of these touch an allowance, so a spender's in-flight proof stays valid. Spender
transfers never touch the owner's spendable balance either. The one owner action that does affect a
spender is revoke_spender, which removes the allowance the spender's next proof would reference.
Setting a Spender
// `set_spender_data` is the XDR-encoded `SetSpenderData` built by the owner's wallet.
// `spender` must already be registered. Moves the allowance out of the owner's spendable
// balance into escrow.
token_client.set_spender(&owner, &spender, &live_until_ledger, &set_spender_data);The owner's wallet needs the spender's spending public key to hand over the key the spender uses to read its allowance.
The owner's proof shows that:
- the allowance comes out of the spendable balance, and the balance covers it;
- the new spendable commitment and the allowance commitment are well formed;
- a delegation viewing key for this spender is derived correctly and encrypted to the spender's spending key;
- the owner's auditor receives the escrowed amount, the owner's new balance and its blinding factor, and the blinding factor of the allowance commitment.
The delegation viewing key is derived from the owner's viewing key and the spender's address. It reveals only this spender's allowance in this token contract, and it cannot be used to recover the owner's viewing key. Because it is escrowed on-chain inside the delegation entry, owner and spender never need to exchange keys off-chain.
Spending from an Allowance
// Authorized by the spender, not the owner. Reverts with `DelegationNotFound` if there is
// no delegation, and with `DelegationExpired` once the ledger is past `live_until_ledger`.
token_client.confidential_transfer_from(&spender, &owner, &recipient, &spender_transfer_data);The owner approved in advance at set_spender, and that approval lives in the delegation entry until
it expires or is revoked.
The spender's proof shows that the allowance covers the amount and that the new allowance commitment is
correct. It also encrypts the amount for the recipient and both auditors, as a direct transfer does.
The recipient decrypts it as it would a direct
confidential transfer, except
that the credit arrives as a SpenderTransfer event rather than Transfer, and the salt to use is the
event's sigma_a_new. A recipient wallet that watches only Transfer events misses these credits.
Two more details differ from a direct transfer:
- Sender-side auditing follows the funds. The ciphertexts on the sender side go to the owner's auditor, not the spender's, and include the remaining allowance after the transfer.
- Masks use a replacement salt. The delegation stores the salt of its current state,
allowance_salt, which only opens the current allowance. The spender's wallet picks a new salt,sigma_a_new, keys every encryption mask to it, and the contract stores it as the newallowance_salt. A reverted call leaves the stored salt unchanged, so the wallet must pick a freshsigma_a_newfor every attempt, retries included. The circuit rejects only a value equal to the stored salt; it can't enforce freshness.
Revoking a Spender
// Needs no proof. Adds the remaining allowance commitment back to the owner's spendable
// commitment and deletes the delegation entry, whether active or expired.
token_client.revoke_spender(&owner, &spender);The RevokeSpender event carries the encrypted allowance and its salt. Because the call deletes the
entry, the event is the only place the owner's wallet can read how much came back.
In deployments with the compliance extension, the token admin can also end a frozen account's
delegation with force_revoke_spender.
It needs no authorization from the owner and runs no hook, but it folds the allowance back the same way
and emits the same RevokeSpender event. Owner wallets must therefore handle a RevokeSpender they did
not initiate.
Expiry
The spender can call confidential_transfer_from while ledger.sequence() <= live_until_ledger. After
that, spending reverts, but the escrow stays in the delegation entry until the owner revokes it (see
Lifecycle).
set_spender does not validate live_until_ledger. The value is bound by the owner's authorization,
but the contract does not compare it with the current ledger and the proof does not cover it. A value
already in the past creates a delegation that is expired from the start; its escrow comes back only
through revoke_spender. Wallets should check the value before submitting.
Delegations live in persistent storage, not temporary storage, so an expired escrow is never deleted automatically. Its TTL is still finite: a new delegation starts with the network's minimum TTL, and the library extends it only when an operation reads the entry.
Reading Delegation State
is_spender(account, spender) returns true only if a delegation exists and has not expired. It
returns false for a missing, revoked, or expired delegation, so it answers "can this spender spend
now?", not "is value escrowed?".
get_spender_delegation(account, spender) returns the raw SpenderDelegation without the expiry
filter, and reverts if no entry exists. Compare live_until_ledger with the current ledger to tell an
active delegation from an expired one.
| Field | Description |
|---|---|
allowance_commitment | Commitment to the remaining allowance |
a_tilde | The remaining allowance, encrypted under the delegation viewing key |
escrowed_dvk | The delegation viewing key, encrypted to the spender's spending key |
allowance_salt | Salt of the current allowance state; replaced on every spender transfer |
live_until_ledger | Last ledger at which the spender may spend |
Events
| Event | Topic addresses | Data |
|---|---|---|
SetSpender | account, spender | live_until_ledger, r_e_point, sigma, b_tilde, v_tilde_aud_s, b_tilde_aud_s, r_tilde_aud_s, r_a_tilde_aud_s |
SpenderTransfer | spender, from, to | r_e_point, v_tilde, sigma_a_new, v_tilde_aud_r, r_tilde_aud_r, v_tilde_aud_s, a_tilde_aud_s, r_tilde_aud_s |
RevokeSpender | account, spender | a_tilde, allowance_salt |
SetSpenderis a spendable-balance checkpoint for the owner, like aWithdrawor an outgoingTransfer.b_tildeandsigmalet the owner's wallet recover its new spendable balance during recovery. The…_aud_sfields are the owner-auditor ciphertexts.SpenderTransfercarries the amount encrypted for the recipient (v_tilde), the ciphertexts for the recipient's auditor and the owner's auditor, and the new salt. It does not carry the new encrypted allowance, which the contract writes only to the delegation entry; read it withget_spender_delegation.RevokeSpenderis described in Revoking a Spender. It is emitted by bothrevoke_spenderandforce_revoke_spender.
Spender Wallets
A spender rebuilds its allowance from the delegation entry instead of replaying events. Its wallet
reads the entry and decrypts escrowed_dvk with its own spending key. It then reads the current
allowance from a_tilde and allowance_salt, and builds the next confidential_transfer_from proof.
The owner's wallet can read the same allowance, because it can derive the delegation viewing key from
its own viewing key. See Wallet Integration.
A spender can see its own allowance, but never the owner's spendable balance.
Auditing and Disclosure
The owner's auditor sees the escrowed amount at set_spender, and the amount and remaining allowance on
every spender transfer. See Auditing.
A spender transfer derives its encryption randomness from the spender's own viewing key. The owner therefore cannot produce a sender-side selective disclosure for it on its own: either the spender cooperates, or the owner's auditor discloses the transfer instead.