Skip to main content

Add-on approvals

An add-on can hold a consequential function until the required number of people approve that exact call, then run it once.

Add-on approvals make a checked add-on function wait until a set number of approvers approve one exact call, then run it once. They exist for actions on an add-on such as the custody gateway that are too consequential for one person: moving a payout address, releasing a frozen withdrawal, changing a fee account. A role check alone does not help here, because every holder of the role can act on their own. Add-on approvals add a second decision to the function itself, on chain, so a call whose rule needs approvals runs only after the required number of approvers approved that exact call. The count does not bind a holder of role management on the access manager, or a System manager, as What the guarantee does not cover explains.

The mechanism belongs to the add-on's own access manager, the contract that holds the add-on's roles. A function the add-on marks as checked either runs at once for a role holder, or waits for a proposal to collect enough approvals. Approval is bound to the call data, so an approval given for one call cannot carry a different one.

A checked call runs once, exactly as proposed

A role holder proposes a call: the target contract, the role the proposal is made under, and the full call data. The access manager stores the proposal and answers with its identifier. Approvers then approve that identifier, and a wallet holding the proposal's maker role sends the same call to the target. The access manager compares the call data it receives with the one it stored, then records the proposal as executed before the target's own code runs.

The proposal does not carry a value, so a payable function cannot be checked, and an add-on that offers a multicall cannot use the check on the functions it exposes through that path: a function reached through another function of the same contract has a different signature and is refused.

Proposals are keyed by the target, the call data and the maker role, and only one for that combination is open at a time.

A call that reverts leaves the proposal open. The whole transaction rolls back, including the record that the proposal ran, so the approvers' decisions survive a failed run and you can send the call again. A call that succeeds cannot run a second time on the same approvals.

What an approval rule sets

A rule is set per function, by its selector, the short identifier that names the function in the call data, and applies to every contract that calls the access manager. A rule on one contract overrides the function's rule for that contract, and clearing the override puts the function's rule back. A contract created later follows the function's rule, so a new custody gateway does not start unchecked.

Each rule carries three values:

Value
Meaning
Bound
Approver role
The role whose holders approve. Empty means the proposal's maker role.
Any role on the same access manager
Approvals needed
How many valid approvals the call needs before it runs. Zero runs the function directly.
0 to 32
Lifetime
How long the proposal stays open, in seconds.
Up to 31536000 seconds, 365 days

A rule that needs approvals needs a lifetime. A function with no rule, or a rule of zero approvals, runs at once for a role holder.

Changing a rule's approver role makes every open proposal under it superseded, because the people who approved may no longer be the people the rule names. Changing the count or the lifetime leaves open proposals alone. A proposal created under a contract override ignores later changes to the function's own rule.

Setting a rule is itself an action the access manager checks. A local administrator or a holder of the system role the access manager is bound to may set it, and that action can require approvals too. In that case the API answers 422 Unprocessable Entity with platform.addonApprovals.APPROVAL_REQUIRED and the call to propose: the access manager as the target, the default administrator role as the maker role, and the configuration call as the data.

The custody gateway is the add-on that ships these rules. On a newly created gateway access manager it sets one approval and a seven-day window on every checked function, so the first change to any of them needs one approval from someone other than the proposer.

Who proposes and who approves

The proposer holds the maker role the function names. An approver holds the rule's approver role at the moment their approval is counted, so an approval recorded before a role was revoked stops counting. The higher of the count the proposal was created with and the count in force now applies, so lowering a rule to a smaller count does not shorten an open proposal's requirement. A rule lowered to zero lets the function run at once for a holder of its role, and that call closes any open proposal for the same call data as executed.

The proposer cannot approve their own proposal. The access manager compares people, not wallets: it looks up each wallet's identity in the system's identity registry. It refuses an approval from any wallet registered to the proposer's identity, refuses a second approval from any wallet of an identity that already approved, and counts each identity once when the call runs. A wallet with no registered identity counts as a person of its own, so the check reaches only the wallets the identity registry links to a person. When a participant's wallets hold an identity, their smart wallet is registered under it too, so the two count as one person. The platform behind the API goes further, because it knows every wallet of a person and a person can act for several participants. The API refuses a person who proposed from any of their wallets, and refuses a second approval from any of their wallets, even when the approving wallet differs. A person who holds the maker role and the approver role on different wallets therefore sees the proposal as waiting for other approvers, not as waiting for them.

An approver cannot approve twice. An approval whose transaction failed does not count, because its record rolled back with the transaction.

The proposer or an approver can cancel a proposal while it is open.

The bound system role

Roles on an add-on's access manager are managed in two ways. Each role keeps its own administrator chain, as any role does, and the access manager can also be bound to one role on the system's access manager. A holder of that bound role grants and revokes every role on the add-on's access manager except the default administrator role.

The binding is set once, by the add-on's own factory, and never changed. The factory proves the system by the fact that it minted the manager: the system access manager it names must be the one its own factory serves. A manager deployed outside that factory cannot be bound at all.

Two limits hold for every caller. Nobody can change the administrator of the default administrator role, and no role can administer itself. Revoking the bound role on the system ends the permission on the next call, and roles it already granted stay until someone revokes them.

The API for proposals and rules

Proposals and rules live under /api/v2/system/addon-access-managers/{accessManagerAddress}.

Call
Purpose
POST …/proposals
Propose a call from the target, the maker role and the call data. Answers the hash of the proposed call.
POST …/proposals/{proposalId}/decisions with {"decision":"approve"}
Approve the proposal.
POST …/proposals/{proposalId}/transitions with {"transition":"cancel"}
Cancel the proposal.
POST …/proposals/{proposalId}/transitions with {"transition":"execute"}
Send the approved call to its target from your wallet.
POST …/action-configs
Set one rule for up to 50 actions, each on one contract or, with no target, as the function's default.
GET …/proposals and GET …/action-configs
Read proposals and the rules in force.

The proposal identifier is assigned on chain, so the create call answers the call hash instead. Read the proposal identifier by listing the proposals for that target.

Every write needs a wallet verification, the PIN, authenticator code, recovery code or passkey your wallet asks for. A write without one answers 400 Bad Request with platform.http.REQUEST_INVALID. Send an Idempotency-Key with each write. The platform generates one when you omit it, and then you have nothing to retry with if the response is lost. A write that settles within the request's preference window answers 200 OK with the transaction hash. A write still queued answers 202 Accepted with a status URL to poll, and Preference-Applied: respond-async.

Before a proposal is queued, the API tries the call against the chain from your wallet. A call the access manager needs no approval for answers 422 Unprocessable Entity with platform.addonApprovals.APPROVAL_NOT_REQUIRED, telling you to send it to the target directly. A refusal answers 422 with platform.addonApprovals.PROPOSAL_REFUSED and a reason:

Reason
What happened
proposer-cannot-approve
A wallet of yours proposed it, or the signed-in user proposed it through the platform.
already-approved
A wallet of yours approved it, or the signed-in user approved it through the platform.
checker-unauthorized
The wallet does not hold the role the call needs.
proposal-not-open
The proposal was executed or cancelled.
proposal-expired
The lifetime ended before the next block.
proposal-superseded
The rule's approver role changed after the proposal was made.
proposal-already-open
A live proposal exists for the same target, call data and maker role.
not-approved
The call has fewer valid approvals than the rule requires.
target-not-managed
The target does not name this access manager as its checker.
invalid-proposal-call
The proposal targets the access manager with a call other than setting a rule, or carries no selector.
not-maker
An execute request came from a wallet without the proposal's maker role.
refused
The access manager refused the call for another reason. The error's revert names the contract error.

Setting a rule from a wallet with no administrator authority on the access manager answers 403 Forbidden with platform.http.ACCESS_DENIED, not with a refusal reason.

Reading, approving and cancelling a proposal reads the indexed record first. A proposal the caller cannot see answers 404 Not Found. Approving a proposal whose expiry has already passed at the next block answers 422 with proposal-expired.

The platform holds one approval per person and proposal. Approving the same proposal twice from the same person answers 422 with already-approved while the first request is still in flight, and a request that never reached the queue stops holding that slot after two minutes. An administrator who impersonates a user counts as the administrator for this check, not as the user.

Where approvers see the work

The Console and the Investor portal list a proposal in the actions inbox of the participant it concerns, under the row type “Add-on approval”. The row names the call in words, for example “Release a frozen withdrawal”, and reads from the wallet that proposed it to the contract it changes. Its status reads “Waiting for your approval”, “Waiting for other approvers”, or “Ready to run”, with the approvals so far out of those needed. An approver of a call the Console cannot read sees “Can't be approved here”. Add-ons and add-on approvals walks through the Console steps.

The Investor portal offers no decision. It tells an approver “Waiting for your approval in the Console” and points them at the Console, which is where an approval, a cancellation and an execution each need a wallet verification.

In the Console, “Review” opens the proposal. An approver approves, a wallet holding the maker role runs an approved change with “Run change”, and the proposer or an approver cancels with “Cancel request”. The sheet shows the contract, the call in words and its arguments with labels, the approvals so far, the approvals needed, and the expiry. A change to a rule shows the rule it writes: how many actions it covers, each action with its contract or “Every contract”, the approvals required, the approval window, the approver, and whether it goes back to the default.

A call the platform cannot read, because it does not know the target's add-on type, reads “Unrecognized call”. The sheet then shows the selector and the raw call data, and the Console offers no “Approve” button, because approving bytes you cannot read is what the check exists to prevent.

A person is never offered a decision on a proposal any of their wallets proposed or approved, nor on one they proposed or approved through the platform while acting for another participant.

What the guarantee does not cover

An approval count binds the people who lack role-management authority over the access manager. A holder of the default administrator role, or of the bound system role, can grant themselves the approver role, or set the rule's count to zero, unless setting a rule is itself configured to need approvals. Configure that before you rely on a count. A System manager on the organisation's system can also replace the logic of every add-on access manager and every custody gateway with one call that no approval rule gates.

Nothing stops one person holding the maker role, the approver role and role management at once. The contracts do not prevent it, and only a grant made through the platform reaches the audit trail. Decide who holds each role before you turn a rule on.

On chain, the access manager tells people apart through the identity registry. A wallet that is not registered to its owner's identity counts as someone else, so a person who holds the approver role on such a wallet can still approve their own proposal from it outside the platform. The identity managers who register wallets are therefore part of the approval guarantee.

Expiry follows the chain's block time, not the wall clock, so a lifetime is a block-time interval and a stalled chain does not expire a proposal.

Was this page helpful?