Abstract
This proposal defines a standard interface for a Warden - a smart contract that holds ERC-20 tokens on behalf of other smart contracts (called controllers). Controllers instruct the Warden to move tokens between internal accounts; they never hold tokens themselves. The Warden organises accounts into funds that carry a time-lock lifecycle. Tokens can be irreversibly committed to an account holder (designation) or destroyed (burning). The lock invariant is enforced at every state-changing operation.
Motivation
Most DeFi contracts hold their own ERC-20 token balances. When a bug or exploit is found in the business logic, an attacker can often drain the entire balance in a single transaction. The Warden pattern introduces defence in depth: the token custody contract enforces invariants that constrain what even a fully compromised controller can do.
A second, often overlooked threat is the controller itself. Many production contracts use upgradability patterns such as ERC-1822's Universal Upgradeable Proxy (UUPS) or transparent proxies. This means the contract owner can push a new implementation at any time - intentionally or after their owner account is compromised - that redirects all held tokens. Because the Warden holds the tokens and enforces its own rules independently of the controller's logic, no controller upgrade can circumvent them.
Concretely, the Warden addresses these threat scenarios:
- Redirecting funds - A time-lock prevents an attacker from withdrawing tokens immediately; by the time the lock expires, the balances are fixed in place.
- Stealing collateral - Designation makes tokens permanently committed to their rightful holder; no controller operation can transfer them away.
- Blocking withdrawals - Account holders can call
withdrawByRecipientdirectly, bypassing the controller entirely. - Upgradability rug-pull - If the controller uses an upgradability pattern, a malicious or compromised owner cannot push an upgrade that steals funds; the Warden's constraints apply regardless of the controller's implementation.
Specification
The keywords "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Definitions
| Term | Definition |
|---|---|
| Warden | The custody contract defined by this standard. Holds ERC-20 tokens on behalf of controllers and enforces all invariants. |
| Controller | A smart contract that instructs the Warden to move tokens. Identified by its address (msg.sender). Each controller has an isolated namespace of funds and cannot access another controller's funds. |
| Fund | A top-level grouping of accounts owned by one controller. Carries a time-lock lifecycle and a unique FundId chosen by the controller. |
| Account | A subdivision of a fund belonging to one holder. Tracks an available balance and a designated balance separately. Identified by an AccountId. |
| Holder | The address that is entitled to withdraw the tokens in an account. Encoded in the high-order 20 bytes of the AccountId. |
| Discriminator | A 12-byte value packed into the low-order bytes of an AccountId. Allows one holder address to own multiple independent accounts within the same fund. |
| Available balance | The portion of an account's balance that the controller can still transfer to other accounts or designate. |
| Designated balance | The portion of an account's balance that has been irreversibly committed to the holder. Cannot be transferred to any other account; can only be burned or withdrawn by the holder. |
| Designation | The act of moving tokens from available to designated. Irreversible within the lifetime of a fund. |
| Burning | Permanently destroying tokens by sending them to address 0x000000000000000000000000000000000000dEaD or using the burn() function of the used ERC-20 token. |
| Lock | The time-window during which the controller may operate on a fund. Defined by lockExpiry - the timestamp at which the fund transitions to Withdrawing. |
| Seal | A controller-initiated transition that closes a fund to all further operations before the lock expires naturally. The fund remains sealed until lockExpiry, then transitions to Withdrawing. |
Types
For better readability this specification introduces the following alias types:
| Type | Underlying | Description |
|---|---|---|
FundId | bytes32 | Identifies a fund within a controller's namespace. Chosen by the controller. |
AccountId | bytes32 | Identifies an account. Encodes a 20-byte holder address (high bits) and a 12-byte discriminator (low bits). |
Timestamp | uint40 | A Unix timestamp in seconds. Used for lock expiry values. |
A compliant Warden MUST use these types (or their underlying equivalents) in its interface.
Fund state machine
A compliant Warden MUST implement the following fund states:
enum FundStatus {
Inactive, // No lock set; no tokens held.
Locked, // Time-lock is active; controller operations are permitted.
Sealed, // Seal account balances - no further transfers or any other changes until the fund unlocks.
Withdrawing // Lock has expired; withdrawals are permitted.
}A fund progresses through states according to the following state machine:
The transition out of Locked or Sealed into Withdrawing is derived from block.timestamp and happens without a transaction. No event can mark it; see Events.
A fund MUST begin in the Inactive state. The Inactive state means no lock has ever been set on that (controller, fundId) pair. Once a lock has been set, the (controller, fundId) pair MUST NOT transition back to Inactive, even if all account balances are zero. Reuse of a FundId by the same controller is therefore impossible after locking.
Controller Identity
The Warden uses msg.sender as the controller address for all fund-scoped operations. Each controller has an isolated namespace of funds; one controller cannot access another controller's funds.
Account Identity
An AccountId MUST pack the holder address into the 20 high-order bytes and the discriminator into the 12 low-order bytes:
AccountId = bytes32(bytes20(holder)) | bytes32(uint256(uint96(discriminator)))The holder address embedded in the AccountId is the only address to which tokens can be withdrawn from that account. The discriminator allows a single address to hold multiple independent accounts within the same fund.
Smart contract deployment and maintenance
A Warden implementation MUST NOT utilize upgradeability patterns like UUPS or transparent proxies. If the Warden deployer requires operational control, they MAY support pausing by an owner or governance contract. If pausing is implemented:
- All controller operations (
lock,deposit,transfer,designate,burnDesignated,burnAccount,sealFund,withdraw) SHOULD be blocked when paused. withdrawByRecipientMUST remain callable when paused. Account holders must always be able to recover their tokens.
Operations
lock
function lock(FundId fundId, Timestamp expiry) external;Activates a fund by setting its time-lock.
- MUST revert with
WardenFundAlreadyLockedif the fund is not inInactivestate. - On success: the fund transitions to
Locked. - MUST emit
WardenFundLocked. Implementations that do not implement the Lock Extension MUST emitmaximumequal toexpiry.
deposit
function deposit(FundId fundId, AccountId accountId, uint128 amount) external;Moves ERC-20 tokens from msg.sender into the Warden and credits the account's available balance.
- MUST revert with
WardenFundNotLockedif the fund is not inLockedstate. - MUST transfer
amountof the Warden's ERC-20 token frommsg.senderto the Warden contract usingsafeTransferFrom. MUST revert if the transfer fails. - On success:
account.balance.available += amount. - MUST emit
WardenDeposited.
transfer
function transfer(FundId fundId, AccountId from, AccountId to, uint128 amount) external;Moves available tokens between two accounts within the same fund. Only available tokens (not designated) can be transferred.
- MUST revert with
WardenFundNotLockedif the fund is not inLockedstate. - MUST revert with
WardenInsufficientBalanceifamount > from.balance.available. - After the solvency check passes:
from.balance.available -= amount,to.balance.available += amount. - MUST emit
WardenTransferred, including whenfrom == to.
designate
function designate(FundId fundId, AccountId accountId, uint128 amount) external;Irreversibly commits available tokens to the account holder. Once designated, tokens cannot be transferred to any other account.
- MUST revert with
WardenFundNotLockedif the fund is not inLockedstate. - MUST revert with
WardenInsufficientBalanceifamount > account.balance.available. - On success:
account.balance.available -= amount,account.balance.designated += amount. - MUST emit
WardenDesignated.
burnDesignated
function burnDesignated(FundId fundId, AccountId accountId, uint128 amount) external;Destroys a specified quantity of designated tokens from an account (penalty/slashing).
- MUST revert with
WardenFundNotLockedif the fund is not inLockedstate. - MUST revert with
WardenInsufficientBalanceifamount > account.balance.designated. - On success:
account.balance.designated -= amount. Theamountof tokens MUST be transferred to address0x000000000000000000000000000000000000dEaDor burned using theburn()function of the ERC-20 token, if it supports it. - MUST emit
WardenDesignatedBurned.
burnAccount
function burnAccount(FundId fundId, AccountId accountId) external;Destroys the entire balance (available + designated) of an account.
- MUST revert with
WardenFundNotLockedif the fund is not inLockedstate. - On success: deletes the account record and transfers
available + designatedtokens to address0x000000000000000000000000000000000000dEaDor burned using theburn()function of the ERC-20 token, if it supports it. - MUST emit
WardenAccountBurned, reporting the available and designated balances separately, even when both are zero.
sealFund
function sealFund(FundId fundId) external;Seals account balances - no further transfers, designations, deposits, or burns are permitted until the lock expires and withdrawals begin.
- MUST revert with
WardenFundNotLockedif the fund is not inLockedstate. - On success: the fund enters
Sealedstate. - MUST emit
WardenFundSealed.
withdraw
function withdraw(FundId fundId, AccountId accountId) external;Called by the controller to send an account's full balance to its holder.
- MUST revert with
WardenFundNotUnlockedif the fund is not inWithdrawingstate. - MUST compute
total = account.balance.available + account.balance.designated. - MUST delete the account record before transferring (so a second
withdrawcall for the same account transfers zero tokens). - MUST transfer
totalof the ERC-20 token to the holder address extracted fromaccountId. - MUST emit
WardenWithdrawn, reporting the available and designated balances separately, even when both are zero (as for a secondwithdrawof the same account).
withdrawByRecipient
function withdrawByRecipient(
address controller,
FundId fundId,
AccountId accountId
) external;Called directly by the account holder, bypassing the controller. This is a critical safety escape hatch.
- MUST revert with
WardenOnlyAccountHolderifmsg.senderis not equal to the holder address encoded inaccountId. - Otherwise identical to
withdraw, using the providedcontrollerto scope the fund lookup. - MUST emit
WardenWithdrawnwith thecontrollerargument, notmsg.sender, so that the event is indistinguishable from one emitted bywithdraw.
This function MUST NOT be subject to the pause mechanism (if any), so that account holders can always recover their tokens even when the Warden is paused.
Query Functions
A compliant Warden MUST expose the following view functions. For fund-scoped queries, msg.sender determines the controller namespace:
function getToken() external view returns (IERC20);Returns the ERC-20 token that this Warden holds custody of.
function getBalance(FundId fundId, AccountId accountId) external view returns (uint128);Returns the total token balance of an account (available + designated). Returns 0 for Inactive funds.
function getDesignatedBalance(FundId fundId, AccountId accountId) external view returns (uint128);Returns only the designated portion of the balance. Returns 0 for Inactive funds.
function getFundStatus(FundId fundId) external view returns (FundStatus);Returns the current state of a fund.
function getLockExpiry(FundId fundId) external view returns (Timestamp);Returns the lockExpiry timestamp of the fund.
Invariants
The core specification defines no invariants beyond the fund state machine transition rules. Each operation enforces its preconditions via the status checks described above. Extensions may introduce additional invariants; see the relevant extension sections.
Errors
| Error | Condition |
|---|---|
WardenFundAlreadyLocked | lock called on a fund that is not Inactive |
WardenFundNotLocked | A controller operation requiring Locked state was called on a fund that is not Locked |
WardenFundNotUnlocked | withdraw called on a fund that is not Withdrawing |
WardenInsufficientBalance | An operation would exceed the available or designated balance |
WardenOnlyAccountHolder | withdrawByRecipient called by an address that is not the account holder |
Events
A compliant Warden MUST emit the following events. Every event is scoped by (controller, fundId), and both MUST be indexed: the controller is msg.sender on every operation except withdrawByRecipient and so appears nowhere else in the log, and a FundId is unique only within a single controller.
| Event | Emitted when |
|---|---|
WardenFundLocked | lock activates a fund, transitioning Inactive to Locked |
WardenFundSealed | sealFund seals a locked fund, transitioning Locked to Sealed |
WardenDeposited | deposit credits an account's available balance |
WardenTransferred | transfer moves available tokens between two accounts |
WardenDesignated | designate commits available tokens to the account holder |
WardenDesignatedBurned | burnDesignated destroys designated tokens |
WardenAccountBurned | burnAccount destroys an entire account |
WardenWithdrawn | withdraw or withdrawByRecipient empties an account |
Extensions define further events.
Each event MUST be emitted after the fund or account state has been updated and before any ERC-20 transfer the operation performs. This guarantees that the Warden's accounting event always precedes the corresponding ERC-20 Transfer event in the log, including when a re-entrant token calls back into the Warden.
Each event MUST be emitted even when the operation moves zero tokens, so that consumers can rely on exactly one event per successful call. This applies to a transfer where from == to, to burnAccount on an empty account, and to a repeated withdraw of an already-emptied account.
The Locked to Withdrawing and Sealed to Withdrawing transitions are derived from block.timestamp and occur without a transaction, so no event marks them. Consumers MUST derive the transition from the expiry reported by WardenFundLocked, or by WardenLockExtended where the Lock Extension is implemented.
Interface
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/// @title IERC-8233 Secure Token Custody Warden
interface IWarden {
// -------------------------------------------------------------------------
// Types
// -------------------------------------------------------------------------
type FundId is bytes32;
// AccountId encodes: bytes20(holder) || bytes12(discriminator)
type AccountId is bytes32;
type Timestamp is uint40;
enum FundStatus {
Inactive,
Locked,
Sealed,
Withdrawing
}
// -------------------------------------------------------------------------
// Errors
// -------------------------------------------------------------------------
error WardenFundAlreadyLocked();
error WardenFundNotLocked();
error WardenFundNotUnlocked();
error WardenInsufficientBalance();
error WardenOnlyAccountHolder();
// -------------------------------------------------------------------------
// Events
// -------------------------------------------------------------------------
/// @notice Implementations without the Lock Extension emit `maximum` equal
/// to `expiry`. The later transition to `Withdrawing` is derived
/// from `expiry` and emits no event.
event WardenFundLocked(
address indexed controller,
FundId indexed fundId,
Timestamp expiry,
Timestamp maximum
);
event WardenFundSealed(address indexed controller, FundId indexed fundId);
event WardenDeposited(
address indexed controller,
FundId indexed fundId,
AccountId indexed accountId,
uint128 amount
);
/// @notice `from` and `to` are unindexed: only three topics are available
/// and `controller` and `fundId` claim two of them.
event WardenTransferred(
address indexed controller,
FundId indexed fundId,
AccountId from,
AccountId to,
uint128 amount
);
event WardenDesignated(
address indexed controller,
FundId indexed fundId,
AccountId indexed accountId,
uint128 amount
);
event WardenDesignatedBurned(
address indexed controller,
FundId indexed fundId,
AccountId indexed accountId,
uint128 amount
);
event WardenAccountBurned(
address indexed controller,
FundId indexed fundId,
AccountId indexed accountId,
uint128 available,
uint128 designated
);
event WardenWithdrawn(
address indexed controller,
FundId indexed fundId,
AccountId indexed accountId,
uint128 available,
uint128 designated
);
// -------------------------------------------------------------------------
// Query functions (msg.sender is the controller)
// -------------------------------------------------------------------------
function getToken() external view returns (IERC20);
function getBalance(FundId fundId, AccountId accountId)
external view returns (uint128);
function getDesignatedBalance(FundId fundId, AccountId accountId)
external view returns (uint128);
function getFundStatus(FundId fundId) external view returns (FundStatus);
function getLockExpiry(FundId fundId) external view returns (Timestamp);
// -------------------------------------------------------------------------
// Fund lifecycle (msg.sender is the controller)
// -------------------------------------------------------------------------
function lock(FundId fundId, Timestamp expiry) external;
function sealFund(FundId fundId) external;
// -------------------------------------------------------------------------
// Token operations (msg.sender is the controller; fund must be Locked)
// -------------------------------------------------------------------------
function deposit(FundId fundId, AccountId accountId, uint128 amount) external;
function transfer(
FundId fundId,
AccountId from,
AccountId to,
uint128 amount
) external;
function designate(
FundId fundId,
AccountId accountId,
uint128 amount
) external;
function burnDesignated(
FundId fundId,
AccountId accountId,
uint128 amount
) external;
function burnAccount(FundId fundId, AccountId accountId) external;
// -------------------------------------------------------------------------
// Withdrawal (fund must be Withdrawing)
// -------------------------------------------------------------------------
/// @notice Called by the controller to withdraw on behalf of an account holder.
function withdraw(FundId fundId, AccountId accountId) external;
/// @notice Called directly by the account holder; bypasses the controller.
/// MUST remain callable even when the Warden is paused.
function withdrawByRecipient(
address controller,
FundId fundId,
AccountId accountId
) external;
}Extensions
This section describes optional extensions that implementations MAY support. Extensions MUST NOT break compliance with the core specification; a Warden that does not implement an extension remains fully compliant.
Lock Extension (extendLock)
By default, a fund's lock expiry is fixed at the time lock is called. This extension allows the controller to push the expiry forward after locking, up to a ceiling (lockMaximum) established at lock time. This is useful when the duration of a deal or agreement may need to be extended without creating a new fund. Sealing a fund terminates the ability to extend its lock: extendLock requires Locked state and MUST revert on a Sealed fund.
Changes to lock
When this extension is supported, lock accepts an additional maximum parameter:
function lock(FundId fundId, Timestamp expiry, Timestamp maximum) external;- MUST revert with
WardenInvalidExpiryifexpiry > maximum. - On success: sets
fund.lockExpiry = expiryandfund.lockMaximum = maximum.
The maximum is fixed at lock creation time and MUST NOT be modified thereafter.
New operation
function extendLock(FundId fundId, Timestamp expiry) external;Pushes the lock expiry forward.
- MUST revert with
WardenFundNotLockedif the fund is not inLockedstate. - MUST revert with
WardenInvalidExpiryifexpiry < fund.lockExpiry(cannot move expiry backward). - MUST revert with
WardenInvalidExpiryifexpiry > fund.lockMaximum. - On success: sets
fund.lockExpiry = expiry. - MUST emit
WardenLockExtended, reporting both the previous and the new expiry so that consumers can invalidate the unlock time they had recorded.
event WardenLockExtended(
address indexed controller,
FundId indexed fundId,
Timestamp previousExpiry,
Timestamp expiry
);New invariant
fund.lockExpiry ⤠fund.lockMaximumThe lock expiry can never exceed the maximum established at lock time. Checked on lock and extendLock.
New error
| Error | Condition |
|---|---|
WardenInvalidExpiry | lock or extendLock called with an expiry outside the valid range |
Interaction with the Token Streaming extension
When both extensions are active, the solvency invariant uses lockMaximum as the upper time bound (rather than lockExpiry), ensuring that a streaming account holds enough available balance to cover outgoing flows even if extendLock is called later.
Token Streaming (flow)
The streaming extension allows a controller to establish a continuous per-second token transfer between two accounts within the same fund. Rather than executing a transfer at every block, accumulated amounts are computed lazily on-demand whenever state changes, keeping gas costs proportional to operations rather than time elapsed.
Tokens flowing into an account become designated immediately on arrival - they cannot be redirected to any other account.
New type
type TokensPerSecond is uint96;New operations
function flow(
FundId fundId,
AccountId from,
AccountId to,
TokensPerSecond rate
) external;Establishes or updates a continuous token stream from from to to at rate tokens per second. Setting rate to zero cancels an existing stream.
- MUST revert with
WardenFundNotLockedif the fund is not inLockedstate. - MUST enforce the solvency invariant (see below) on the sending account after updating the rate. MUST revert if the invariant would be violated.
- Accumulated flow since the last update MUST be settled before applying the new rate: the elapsed amount is deducted from
from.balance.availableand added toto.balance.designated. - MUST emit
WardenFlowUpdated.
event WardenFlowUpdated(
address indexed controller,
FundId indexed fundId,
AccountId from,
AccountId to,
TokensPerSecond rate
);Lazy settlement moves tokens without emitting an event: it has no transaction of its own and is applied as a side effect of whichever operation triggers it. Consumers MUST recompute accrued amounts from the rate and the elapsed time, bounded by fund.sealedAt once the fund has been sealed. This is the same class of silently-derived state as the Withdrawing transition described in Events.
New invariants
Two additional invariants apply when the streaming extension is active.
Solvency invariant - the sending account must hold enough available balance to cover all outgoing flow from now until the latest possible expiry:
flow.rate Ć (lockBound ā now) ⤠account.balance.availablewhere lockBound is fund.lockMaximum if the Lock Extension is also implemented, or fund.lockExpiry otherwise. This is checked at the time flow is called and guarantees the stream is fully funded for the entire remaining lock duration.
Flow conservation invariant - for every fund, total incoming flow rate across all accounts equals total outgoing flow rate:
Σ incoming rates = Σ outgoing rates (per fund)This invariant is maintained naturally because each flow call sets one outgoing stream from one account to one other account within the same fund.
Interaction with sealFund
When this extension is active, the Warden MUST record the sealing timestamp on sealFund (sealedAt = block.timestamp). All flow calculations use fund.sealedAt as the cut-off timestamp instead of lockExpiry. No further accumulation occurs after sealing.
Interaction with burnAccount
burnAccount MUST revert if the account has any active incoming or outgoing flows. The controller must set those flows to zero before burning the account.
Security remarks
Solvency at setup time, not at withdrawal time. The solvency invariant is enforced when flow is called, not continuously. If additional transfers out of the sending account are made after a flow is established, the invariant must be re-checked; implementations SHOULD enforce it on every operation that reduces available balance on an account with outgoing flows.
Re-entrancy during settlement. Lazy settlement computes and applies accumulated amounts at the start of each state-changing operation. Implementations MUST apply settlement before performing any ERC-20 transfer to prevent re-entrancy from observing an unsettled state.
Warden Discovery (getWarden)
Unlike the extensions above (which are implemented by the Warden contract), this convention applies to controller contracts that use a Warden as their custody backend.
A controller MAY expose the Warden address through the following view function:
function getWarden() external view returns (IWarden);This allows off-chain tools, indexers, and other on-chain contracts to discover which Warden instance a controller is backed by without inspecting deployment scripts or contract storage directly.
Rationale
Controller-as-caller identity
Using msg.sender as the controller address removes the need for explicit access control lists inside the Warden. A controller can only access funds it created. This keeps the interface minimal and avoids a separate registration step.
FundId chosen by the controller
Controllers typically derive FundId from a domain object. This allows deterministic lookup without requiring the Warden to issue IDs, and it means the controller can lock a fund in the same transaction that creates the domain object.
No FundId reuse
Once a (controller, fundId) pair has been locked, re-locking the same pair is rejected. This ensures that account state from a previous lifecycle of the fund cannot bleed into a new one.
Burn address 0x000...dEaD instead of address(0)
Some ERC-20 implementations revert on transfer to address(0). Using 0xdEaD avoids this while making burned tokens visibly auditable on-chain.
withdrawByRecipient not pausable
The ability for account holders to withdraw directly is the ultimate safety guarantee. If the Warden owner or governance is also compromised, pausing the Warden must not be able to trap account holders' funds. A paused Warden that blocks all withdrawals would be indistinguishable from a compromised one.
Event design
Events are normative rather than left to the implementer, because the Warden's identifiers are otherwise unobservable. The controller is msg.sender on every operation except withdrawByRecipient, so it appears in no return value and in no log unless the Warden puts it there, and a FundId is chosen by the controller and unique only within that controller. Without a standard event set no consumer could attribute a fund to its controller from chain data at all, and transfer and designate, which move no ERC-20 tokens, would be entirely invisible.
Only three topics are available per event, and controller and fundId claim two of them. For single-account operations the third holds accountId. WardenTransferred has four candidates and cannot fit both accounts, so from and to are placed in the data section rather than indexing one side of a transfer and not the other; consumers filter a fund's transfers by the two indexed topics and match accounts in the returned data.
An AccountId embeds the holder address in its high 20 bytes, but topic matching is exact over all 32 bytes. A consumer therefore cannot use topics alone to find every event for a given holder across unknown discriminators, and must instead ingest a controller's or fund's events and match the address prefix itself.
WardenFundLocked carries maximum even though the core lock takes no such parameter, where it equals expiry. Keeping the field in the core event, rather than adding it under the Lock Extension, means the event signature and therefore its topic hash is identical across core-only and extended implementations, so a consumer can decode both without knowing which extensions a deployment supports.
WardenAccountBurned and WardenWithdrawn report the available and designated balances separately. Neither operation takes an amount; both compute it and then delete the account. Reporting both keeps each event self-contained and preserves the distinction between available and designated tokens on which the rest of this specification depends.
Controller access restriction
It is recommended that Warden deployments restrict which addresses may act as controllers - for example, by allowlisting only the deployer's own contracts. An unrestricted Warden accepts any msg.sender as a controller, which means third parties could use it to custody their users' funds.
This matters most when the Warden has an owner or pausing mechanism. An owner who can pause the Warden gains effective control over every fund stored in it, regardless of which controller created the fund. If third-party funds are stored in a Warden that the deployer can pause, the deployer may be considered a custodian of those funds in some jurisdictions, with the associated regulatory and legal liability. Restricting the controller set to contracts the deployer controls keeps the deployer's custodial exposure limited to their own application.
Reference Implementation
MIT/Apache licensed reference implementation is available in the assets folder.
Security Considerations
Re-entrancy
deposit uses safeTransferFrom, and withdraw/burnDesignated/burnAccount use safeTransfer. Implementations MUST delete or zero out account state before calling safeTransfer to prevent re-entrancy from inflating balances. The reference implementation deletes the account record before transferring in withdraw and burnAccount.
Integer arithmetic
All balance arithmetic uses uint128. Implementations MUST ensure that balance.available + balance.designated cannot overflow when computing a withdrawal amount.
Fund namespace isolation
Because msg.sender determines the controller namespace, a Warden that is itself a controller (e.g. a proxy or aggregator) creates a shared namespace for all callers of that contract. Implementers of controller contracts MUST ensure that distinct callers cannot affect each other's funds through the shared controller address.
Withdrawal completeness
withdraw and withdrawByRecipient both delete the account record after computing the payout. A second withdrawal call for the same account returns zero. Controllers SHOULD NOT assume that a zero withdrawal means an error; it may indicate a previously withdrawn or empty account.
Copyright
Copyright and related rights waived via CC0.
