Spenders

Source Code

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 new allowance_salt. A reverted call leaves the stored salt unchanged, so the wallet must pick a fresh sigma_a_new for 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.

FieldDescription
allowance_commitmentCommitment to the remaining allowance
a_tildeThe remaining allowance, encrypted under the delegation viewing key
escrowed_dvkThe delegation viewing key, encrypted to the spender's spending key
allowance_saltSalt of the current allowance state; replaced on every spender transfer
live_until_ledgerLast ledger at which the spender may spend

Events

EventTopic addressesData
SetSpenderaccount, spenderlive_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
SpenderTransferspender, from, tor_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
RevokeSpenderaccount, spendera_tilde, allowance_salt
  • SetSpender is a spendable-balance checkpoint for the owner, like a Withdraw or an outgoing Transfer. b_tilde and sigma let the owner's wallet recover its new spendable balance during recovery. The …_aud_s fields are the owner-auditor ciphertexts.
  • SpenderTransfer carries 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 with get_spender_delegation.
  • RevokeSpender is described in Revoking a Spender. It is emitted by both revoke_spender and force_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.

Further Reading