EIP.tools

EIP.tools

āš ļø DraftStandards Track: ERC

ERC-8233: Warden - Secure Token Custody Contract

An interface for controller-scoped ERC-20 token custody with time-locked funds and account designation.

Authors
Created2026-04-15
Discussion Linkhttps://ethereum-magicians.org/t/erc-8233-warden-secure-token-custody-contract/28252
Requires

Markdown

https://raw.githubusercontent.com/ethereum/ERCs/re...
Pull Request#1687PR open

EIP-GPT summary

Contents
AbstractMotivationSpecificationDefinitionsTypesFund state machineController IdentityAccount IdentitySmart contract deployment and maintenanceOperationslockdeposittransferdesignateburnDesignatedburnAccountsealFundwithdrawwithdrawByRecipientQuery FunctionsInvariantsErrorsEventsInterfaceExtensionsLock Extension (extendLock)Token Streaming (flow)Warden Discovery (getWarden)RationaleController-as-caller identityFundId chosen by the controllerNo FundId reuseBurn address 0x000...dEaD instead of address(0)withdrawByRecipient not pausableEvent designController access restrictionReference ImplementationSecurity ConsiderationsRe-entrancyInteger arithmeticFund namespace isolationWithdrawal completenessCopyright

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 withdrawByRecipient directly, 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

TermDefinition
WardenThe custody contract defined by this standard. Holds ERC-20 tokens on behalf of controllers and enforces all invariants.
ControllerA 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.
FundA top-level grouping of accounts owned by one controller. Carries a time-lock lifecycle and a unique FundId chosen by the controller.
AccountA subdivision of a fund belonging to one holder. Tracks an available balance and a designated balance separately. Identified by an AccountId.
HolderThe address that is entitled to withdraw the tokens in an account. Encoded in the high-order 20 bytes of the AccountId.
DiscriminatorA 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 balanceThe portion of an account's balance that the controller can still transfer to other accounts or designate.
Designated balanceThe 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.
DesignationThe act of moving tokens from available to designated. Irreversible within the lifetime of a fund.
BurningPermanently destroying tokens by sending them to address 0x000000000000000000000000000000000000dEaD or using the burn() function of the used ERC-20 token.
LockThe time-window during which the controller may operate on a fund. Defined by lockExpiry - the timestamp at which the fund transitions to Withdrawing.
SealA 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:

TypeUnderlyingDescription
FundIdbytes32Identifies a fund within a controller's namespace. Chosen by the controller.
AccountIdbytes32Identifies an account. Encodes a 20-byte holder address (high bits) and a 12-byte discriminator (low bits).
Timestampuint40A 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:

Fund 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.
  • withdrawByRecipient MUST 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 WardenFundAlreadyLocked if the fund is not in Inactive state.
  • On success: the fund transitions to Locked.
  • MUST emit WardenFundLocked. Implementations that do not implement the Lock Extension MUST emit maximum equal to expiry.

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 WardenFundNotLocked if the fund is not in Locked state.
  • MUST transfer amount of the Warden's ERC-20 token from msg.sender to the Warden contract using safeTransferFrom. 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 WardenFundNotLocked if the fund is not in Locked state.
  • MUST revert with WardenInsufficientBalance if amount > from.balance.available.
  • After the solvency check passes: from.balance.available -= amount, to.balance.available += amount.
  • MUST emit WardenTransferred, including when from == 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 WardenFundNotLocked if the fund is not in Locked state.
  • MUST revert with WardenInsufficientBalance if amount > 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 WardenFundNotLocked if the fund is not in Locked state.
  • MUST revert with WardenInsufficientBalance if amount > account.balance.designated.
  • On success: account.balance.designated -= amount. The amount of tokens MUST be transferred to address 0x000000000000000000000000000000000000dEaD or burned using the burn() 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 WardenFundNotLocked if the fund is not in Locked state.
  • On success: deletes the account record and transfers available + designated tokens to address 0x000000000000000000000000000000000000dEaD or burned using the burn() 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 WardenFundNotLocked if the fund is not in Locked state.
  • On success: the fund enters Sealed state.
  • 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 WardenFundNotUnlocked if the fund is not in Withdrawing state.
  • MUST compute total = account.balance.available + account.balance.designated.
  • MUST delete the account record before transferring (so a second withdraw call for the same account transfers zero tokens).
  • MUST transfer total of the ERC-20 token to the holder address extracted from accountId.
  • MUST emit WardenWithdrawn, reporting the available and designated balances separately, even when both are zero (as for a second withdraw of 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 WardenOnlyAccountHolder if msg.sender is not equal to the holder address encoded in accountId.
  • Otherwise identical to withdraw, using the provided controller to scope the fund lookup.
  • MUST emit WardenWithdrawn with the controller argument, not msg.sender, so that the event is indistinguishable from one emitted by withdraw.

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

ErrorCondition
WardenFundAlreadyLockedlock called on a fund that is not Inactive
WardenFundNotLockedA controller operation requiring Locked state was called on a fund that is not Locked
WardenFundNotUnlockedwithdraw called on a fund that is not Withdrawing
WardenInsufficientBalanceAn operation would exceed the available or designated balance
WardenOnlyAccountHolderwithdrawByRecipient 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.

EventEmitted when
WardenFundLockedlock activates a fund, transitioning Inactive to Locked
WardenFundSealedsealFund seals a locked fund, transitioning Locked to Sealed
WardenDepositeddeposit credits an account's available balance
WardenTransferredtransfer moves available tokens between two accounts
WardenDesignateddesignate commits available tokens to the account holder
WardenDesignatedBurnedburnDesignated destroys designated tokens
WardenAccountBurnedburnAccount destroys an entire account
WardenWithdrawnwithdraw 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 WardenInvalidExpiry if expiry > maximum.
  • On success: sets fund.lockExpiry = expiry and fund.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 WardenFundNotLocked if the fund is not in Locked state.
  • MUST revert with WardenInvalidExpiry if expiry < fund.lockExpiry (cannot move expiry backward).
  • MUST revert with WardenInvalidExpiry if expiry > 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.lockMaximum

The lock expiry can never exceed the maximum established at lock time. Checked on lock and extendLock.

New error
ErrorCondition
WardenInvalidExpirylock 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 WardenFundNotLocked if the fund is not in Locked state.
  • 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.available and added to to.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.available

where 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 and related rights waived via CC0.

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth