For the complete documentation index, see llms.txt. This page is also available as Markdown.

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

The token suite is deployed (see 01); the caller holds OWNER in the asset's domain (see 25); the spender modules are deployed; the Issuer knows the address of each operator it authorizes, and the chain it is on

Result

The asset has a spender policy, and transferFrom succeeds only for spenders the policy accepts

Actors and roles

Actor
Role in this flow
What they hold

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.

Policy
Module to bind
A spender is accepted when

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:

  1. The caller holds OWNER in the asset's domain. Otherwise the call reverts with AccessManagedUnauthorized.

  2. The module can be bound: at most five interactions, fewer than 25 modules, not already bound. Otherwise ArraySizeLimited, MaxModulesReached or ModuleAlreadyBound. See 11 for every check of a binding.

  3. For each allowSpender: the address is a canonical interoperable address and is not already listed. Otherwise the call reverts on the encoding, or with SpenderAlreadyAllowed.

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:

  1. The caller holds OWNER. Otherwise AccessManagedUnauthorized.

  2. The module is bound. Otherwise ModuleNotBound.

  3. 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:

  1. The caller holds OWNER. Otherwise AccessManagedUnauthorized.

  2. The module is bound. Otherwise ModuleNotBound.

  3. 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.transferFrom reverts with SpenderNotAllowed for any other caller, before the allowance is touched.

  • Token.transfer and Token.identityTransfer are unaffected. They have no spender.

  • No allowance has changed. Holders still approve their spenders themselves.

Failure modes

Symptom
Cause
Fix

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 approve or permit. 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 SPENDER module 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 calls transferFrom needs 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 a transferFrom. It is the check the token runs.

  • Index SpenderAllowed and SpenderDisallowed on 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.

Last updated