Skip to main content

Operators and Controllers

ERC-7540 introduces two concepts that do not exist in vanilla ERC-4626: operators and controllers. This page explains how Olla implements them and how to use them in an integration.

Vocabulary

  • Owner. The address that holds the stAztec being redeemed. Shares are burned from the owner's balance.
  • Controller. The address against which a withdrawal request is tracked. The controller is the only address (aside from its approved operators) that can claim the finalized request.
  • Operator. An address that a user has authorized to act on their behalf. An operator can call requestRedeem with the user as owner and can claim finalized requests whose controller is the user.

In most integrations, owner, controller, and the caller are the same address. Operators and split owner/controller roles are needed when one contract manages redemptions on behalf of another.

Approving an operator

function setOperator(address operator, bool approved) external returns (bool);

Called by the user who wants to grant or revoke operator rights. Emits OperatorSet(controller, operator, approved).

A user cannot set themselves as their own operator; the call reverts with OllaVault__InvalidParameter.

Check an existing approval:

function isOperator(address controller, address operator) external view returns (bool);

What operators can do

Given an approval by controller, an operator can:

  • Call requestRedeem(shares, controller, owner) where either msg.sender == owner or the operator is approved by owner. Shares are burned from owner's balance.
  • Call claimRequestById(requestId) for requests whose controller is the approving address.
  • Call redeem(shares, receiver, controller) (the ERC-4626 claim alias) for the same set of requests.

Operators cannot change setOperator approvals on behalf of another user, cannot deposit on behalf of another user, and cannot call instantRedeem on behalf of another user.

Typical flows

Self-service exit (no operator)

// owner == controller == msg.sender
uint256 requestId = vault.requestRedeem(shares, msg.sender, msg.sender);
// ... wait for finalization
uint256 assets = vault.claimRequestById(requestId);

This is the common case. You do not need setOperator for this flow.

Keeper-managed exit

A user authorizes a keeper contract to enqueue and claim on their behalf:

// called once by the user
vault.setOperator(address(keeper), true);

// called by the keeper, on behalf of the user
uint256 requestId = vault.requestRedeem(shares, user, user);
// ... later, also by the keeper
uint256 assets = vault.claimRequestById(requestId);

Because controller == user, the keeper can claim the request once it is finalized. The claimed assets go to the user by default, because claimRequestById sends to the request's original recipient.

Strategy contract holding user shares

When the shares sit on a contract (a yield strategy, for example) and the end user is a different address, the strategy is both owner and controller:

// strategy holds the shares
uint256 requestId = vault.requestRedeem(shares, address(this), address(this));
// strategy claims, then forwards to the user internally
uint256 assets = vault.claimRequestById(requestId);

The strategy does not need to grant operator rights because msg.sender == owner == controller.

The requestRedeemWithPermit asymmetry

requestRedeemWithPermit looks like the permit version of requestRedeem, but its arguments are different:

function requestRedeemWithPermit(
uint256 shares,
address controller,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external returns (uint256 requestId);

There is no owner parameter. The function always treats msg.sender as the owner and pulls shares into the vault via safeTransferFrom using the permit-set allowance. This means:

  • The permit variant cannot be used by an operator to redeem on behalf of another user. For operator flows, use the non-permit requestRedeem(shares, controller, owner).
  • The controller can still differ from msg.sender, so you can queue a request where a different address is responsible for claiming.
caution

If your integration assumed symmetry with requestRedeem(shares, controller, owner), be explicit about the difference in your client code. The permit variant is a convenience for the common case of "user pays their own gas and exits their own shares". It is not a general replacement.

Permit frontrun protection

Every WithPermit function in the vault wraps the permit call in a try/catch. If the permit reverts (for example because another transaction already consumed the signature), the vault checks whether the allowance is already sufficient for the operation and proceeds if it is. This applies to:

  • depositWithPermit
  • instantRedeemWithPermit
  • requestRedeemWithPermit

Only when the permit fails and there is no prior allowance does the call revert with OllaVault__PermitFailed. You generally do not need to handle permit frontrunning in client code.