26. Authorize spenders
A spender is a third party that executes a transfer on a holder's behalf: a venue, a custodian, a settlement contract. By default, any spender a holder approves can call transferFrom. The Issuer restricts this with a spender policy, a compliance module that decides which spenders the asset accepts. This flow sets that policy and maintains the list of authorized operators. The Compliance Owner runs it when the asset first connects to a venue, and each time an operator is added or withdrawn.
At a glance
Triggered by
Compliance Owner
Actors
Compliance Owner, Issuer, Venue or application, Investor, Platform
Contracts
ModularCompliance, SpenderWhitelistModule, SpenderVerificationModule, TREXRegistry (read only, during verification)
Preconditions
Result
The asset has a spender policy, and transferFrom succeeds only for spenders the policy accepts
Actors and roles
Compliance Owner
Binds the spender module and maintains the list.
OWNER on the ModularCompliance
Issuer
Decides which policy the asset applies and which operators it accepts. Usually the same party as the Compliance Owner.
Its agreements with venues and operators
Venue or application
Becomes an authorized spender. Does not transact in this flow.
An address on the reference chain or on a satellite. For the verification policy, an ONCHAINID that satisfies the asset's requirements
Investor
Still approves each spender itself. Does not transact in this flow.
A position
Platform
Encodes addresses and calls, runs the reads and submits with the Compliance Owner's key.
The Compliance Owner's signing key, or a key authorized for it
Sequence
Step by step
Step 1. Choose the policy
Who: Issuer, with the Platform
Call: Off-chain, using read calls
Before the call: The Issuer knows which operators move tokens for holders.
What happens: The Platform reads ModularCompliance.getModulesByType(SPENDER) to see the current policy. The Issuer chooses one.
Any spender a holder approves
None
Always
Eligible spenders only
SpenderVerificationModule
The spender's address resolves to an identity that satisfies the asset's required claims
Named operators only
SpenderWhitelistModule
The spender is on the Issuer's list
Named operators that are also eligible
Both
Both conditions hold
For the list, the Platform encodes each operator as an ERC-7930 interoperable address in canonical form: the chain it is on and its address. One list covers operators on the reference chain and on satellites.
What changes: Nothing on-chain.
Step 2. Bind the policy
Who: Compliance Owner (OWNER)
Call: For a list: ModularCompliance.addAndSetModule(spenderListModule, interactions), where each interaction is an encoded allowSpender(spender). For the verification policy: ModularCompliance.addModule(verificationModule).
Before the call: Step 1 is done. For a list, the first operators are known.
What the contract checks:
The caller holds
OWNERin the asset's domain. Otherwise the call reverts withAccessManagedUnauthorized.The module can be bound: at most five interactions, fewer than 25 modules, not already bound. Otherwise
ArraySizeLimited,MaxModulesReachedorModuleAlreadyBound. See 11 for every check of a binding.For each
allowSpender: the address is a canonical interoperable address and is not already listed. Otherwise the call reverts on the encoding, or withSpenderAlreadyAllowed.
What changes: The module is bound and recorded as a SPENDER module. The list holds its first entries. The module emits SpenderAllowed(compliance, key, spender) for each. From the next transferFrom, the spender is checked.
Step 3. Add an operator
Who: Compliance Owner (OWNER)
Call: ModularCompliance.callModuleFunction(callData, spenderListModule), where callData is the encoded allowSpender(spender)
Before the call: The Issuer has agreed to accept the operator.
What the contract checks:
The caller holds
OWNER. OtherwiseAccessManagedUnauthorized.The module is bound. Otherwise
ModuleNotBound.The address is canonical and not already listed. Otherwise the call reverts on the encoding, or with
SpenderAlreadyAllowed.
What changes: The operator is on the list. The module emits SpenderAllowed. The compliance emits ModuleInteraction.
Step 4. Withdraw an operator
Who: Compliance Owner (OWNER)
Call: ModularCompliance.callModuleFunction(callData, spenderListModule), where callData is the encoded disallowSpender(spender)
Before the call: The Issuer has decided to stop accepting the operator.
What the contract checks:
The caller holds
OWNER. OtherwiseAccessManagedUnauthorized.The module is bound. Otherwise
ModuleNotBound.The operator is on the list. Otherwise
SpenderNotListed.
What changes: The operator leaves the list. The module emits SpenderDisallowed. Its next transferFrom reverts. Allowances that holders gave it remain and are of no use to it.
Step 5. Verify
Who: Platform
Call: Off-chain, using read calls
Before the call: The previous steps are confirmed.
What happens: The Platform reads ModularCompliance.canSpenderCall(spender, from, to, amount) for an authorized operator and for an address that is not authorized. For a list, it also reads SpenderWhitelistModule.isSpenderAllowed(compliance, spender).
What changes: Nothing on-chain.
State after the flow
ModularCompliance.getModulesByType(SPENDER)returns the bound spender modules.SpenderWhitelistModule.isSpenderAllowed(compliance, spender)is true for each listed operator.ModularCompliance.canSpenderCall(spender, from, to, amount)is true only for a spender every bound spender module accepts.Token.transferFromreverts withSpenderNotAllowedfor any other caller, before the allowance is touched.Token.transferandToken.identityTransferare unaffected. They have no spender.No allowance has changed. Holders still approve their spenders themselves.
Failure modes
Reverts with AccessManagedUnauthorized
The caller does not hold OWNER in the asset's domain (check 1)
Grant the role: 25
Reverts with SpenderAlreadyAllowed
The operator is already on the list (steps 2 and 3, check 3)
Nothing to do
Reverts with SpenderNotListed
The operator is not on the list (step 4, check 3)
Read isSpenderAllowed first
allowSpender reverts on the address
The interoperable address is malformed or not in canonical form
Encode the chain and the address in the canonical ERC-7930 form
Reverts with OnlyBoundComplianceCanCall
allowSpender was called on the module directly
Call it through callModuleFunction
Every transferFrom reverts with SpenderNotAllowed after binding
The list module was bound with no entry
List the operators. Bind with addAndSetModule next time
A listed venue is still refused
Both spender modules are bound and the venue's address is not eligible, or the venue was listed under another chain
Read canSpenderCall. Give the venue an identity with the required claims, or list the address of the right chain
A venue is accepted and its transfers still revert
The spender is accepted, and the transfer itself fails a gate: recipient not eligible, a rule, a freeze, the pause
Read isVerified(to) and canTransfer(from, to, amount)
A cross-chain validation is refused with ValidationSpenderRefused
The validation names a spender the policy does not accept
Authorize the operator, with the address it has on that satellite
Common misunderstandings
Wrong: "Authorizing a spender lets it move holders' tokens."
Right: It makes the spender acceptable to the asset. The spender still needs an allowance from each holder, given with
approveorpermit. The policy and the allowance are two separate conditions, and both must hold.
Wrong: "A whitelisted spender bypasses compliance."
Right: The spender check comes first, and the transfer then passes the same gates as any other: eligibility of the recipient, the rules, freezes, the pause. Accepting a spender exempts nothing.
Wrong: "Without a spender module, nobody can use
transferFrom."Right: Without a spender module, every spender is accepted, as with any ERC-20. A policy exists only once a
SPENDERmodule is bound.
Wrong: "The list is a list of eligible investors."
Right: It is a list of operators. Eligibility of holders is configured in the
TREXRegistry, with claim topics and trusted issuers, and has nothing to do with this list.
Integration notes
Bind the list module with its first entries in one call. Bound empty, it blocks every
transferFrom.A venue that operates on a satellite is listed with the address it has on that chain. The same contract on two chains is two entries.
A venue contract takes part under its own ONCHAINID, of type
SMART_CONTRACT, with the claims the asset requires. A venue that only callstransferFromneeds to be an accepted spender. A venue that holds tokens in escrow also needs to be an eligible holder, which the spender policy does not decide: see Identity-based control and spender compliance.Read
canSpenderCall(spender, from, to, amount)before submitting atransferFrom. It is the check the token runs.Index
SpenderAllowedandSpenderDisallowedon the module, filtered by compliance, to keep the list off-chain. The list cannot be enumerated on-chain.The list belongs to the binding. If the module is removed and bound again, the list is empty.
Withdrawing an operator does not cancel validations already issued that name it. They expire on their own, or the keeper discards them.
Related flows
11 Add a compliance rule : every check of a module binding
12 Remove a compliance rule : removing the policy
10 Transfer between investors :
transferFromand its checks24 Transfer through the identity : moving a holder's tokens without a spender
31 Transfer on a satellite : a spender named in a validation
Last updated