Skip to content

Signature Distributor

The ECDSA Distributor contracts verify claims with ECDSA signatures instead of merkle proofs. The contract stores an authorized signer rather than a Merkle tree root, and the signer signs each claim, so you do not need to generate a Merkle tree in advance. One transaction can process several claims.

Differences from Merkle distributors

Feature Merkle Distributor ECDSA Distributor
Verification Merkle proofs ECDSA signatures
Data stored Merkle root Authorized signer
Claim data Fixed by the root (the owner can replace the root) Signed per claim; new claims need no on-chain update
Claims per call One (except the multitoken batched variant) One or more, in a single claim call
Flexibility Limited High

Architecture

Signature distributor contracts and their type numbers Signature distributor contracts and their type numbers
Signature distributor contracts and their type numbers

Distributor instances are deployed by TTADeployer (formerly named MDCreate2), the same factory that deploys the Merkle distributors, as minimal-proxy clones of a registered implementation.

BaseECDSADistributor holds the core distribution logic and signature verification. FungibleTokenECDSADistributor builds on it for ERC20 and native tokens, and FungibleTokenWithFeesECDSADistributor adds a fee to each claim.

Core contracts

BaseECDSADistributor

The abstract base contract with the signature-based distribution logic. Each deployed distributor is initialized with initialize(address owner_), which sets the owner and records the calling contract (TTADeployer) as the distributor's deployer.

Storage layout (ERC7201)

Storage uses the ERC7201 namespaced storage pattern, under the namespace sign.misc.BaseECDSADistributor:

struct BaseECDSADistributorStorage {
    mapping(bytes32 userClaimId => bool claimed) claimedClaims;
    address deployer;
    address authorizedSigner;
    address token;
    address claimHook;
    uint256 startTime;
    uint256 endTime;
}

Claim mechanism

One call can submit several claims, each with its own signature:

function claim(
    address[] calldata recipients,
    bytes32[] calldata userClaimIds,
    bytes[] calldata datas,
    bytes[] calldata signatures,
    bytes[] calldata extraDatas
) external payable;
  • The arrays are read in parallel: entry i claims datas[i] for recipients[i] under userClaimIds[i], authorized by signatures[i]. recipients must not be empty.
  • Any address can submit a claim. Tokens always go to recipients[i], and the caller pays the fee.
  • The call reverts with TimeInactive unless startTime <= block.timestamp <= endTime, and with Paused while the contract is paused.
  • extraDatas[i] is not signed; it is only forwarded to the claim hook.

What the signer signs

The signer signs, as an EIP-191 personal message, the 32-byte hash returned by:

function encodeHashToBeSigned(
    address recipient,
    bytes32 userClaimId,
    bytes memory userClaimData
) public view returns (bytes32) {
    return keccak256(abi.encode(block.chainid, address(this), recipient, userClaimId, userClaimData));
}

verify(signer, hash, signature) checks the signature against toEthSignedMessageHash(hash) with Solady's SignatureCheckerLib.isValidSignatureNow, which accepts ECDSA signatures from an EOA and ERC-1271 signatures from a smart contract signer. An invalid signature reverts with InvalidSignature. The chain ID and distributor address are part of the signed hash, so a signature cannot be replayed on another distributor or chain.

Each userClaimId can be claimed once. The contract marks it as claimed whatever the recipient, so the signer must issue a unique ID for every claim. Reusing one reverts with ClaimClaimed.

Claim hooks

The owner can set an optional hook with setClaimHook(address hook). The hook implements the ECDSA IClaimHook interface, which has no proof parameter, unlike the Merkle hook. delegate is always the caller of claim. If the hook reverts, the claim reverts.

interface IClaimHook {
    function beforeClaim(
        address delegate,
        address recipient,
        bytes32 group,
        bytes calldata data,
        bytes calldata extraData
    ) external;

    function afterClaim(
        address delegate,
        address recipient,
        bytes32 group,
        bytes calldata data,
        uint256 claimedAmount,
        bytes calldata extraData
    ) external;
}

Functions

Function Description
setBaseParams(address token, uint256 startTime, uint256 endTime, address authorizedSigner) Owner only. Sets token (address(0) for native), time window and authorized signer. Reverts with UnsupportedOperation if startTime >= endTime
setClaimHook(address hook) Owner only. Sets the optional claim hook
claim(...) Processes one or more signed claims
togglePause() Owner only. Pauses or unpauses claims
paused() Returns whether claims are paused
withdraw(bytes extraData) Owner only. Withdraws tokens to the owner
getClaimStatus(bytes32[] userClaimIds) Returns, for each ID, whether it has been claimed
getStorage() Returns version, owner, paused flag, deployer, signer, token, hook, start and end time
encodeHashToBeSigned(...), verify(...) Hash and signature check used by claims
version() Returns the version string, for example 0.4.7-FungibleTokenECDSADistributor

Extension contracts

FungibleTokenECDSADistributor

Distributes an ERC20 or native token.

struct FungibleTokenECDSADistributorData {
    uint256 claimableTimestamp;
    uint256 claimableAmount;
}

userClaimData is abi.encode(FungibleTokenECDSADistributorData); decodeUserClaimData(bytes) decodes it.

  • token == address(0) selects the native token; the contract accepts native tokens through receive().
  • A claim reverts with OutsideClaimableTimeRange unless startTime <= claimableTimestamp <= block.timestamp.
  • withdraw sends the contract's whole token balance (or native balance) to the owner.

FungibleTokenWithFeesECDSADistributor

Adds a fee amount to each claim's data, so every claim can carry its own fee.

struct FungibleTokenWithFeesECDSADistributorData {
    uint256 claimableTimestamp;
    uint256 claimableAmount;
    uint256 fees;
}

userClaimData is decoded with decodeUserClaimDataWithFees(bytes); decodeUserClaimData reverts with UnsupportedOperation.

  • The fees of all claims in the call are added up and charged to the caller once.
  • If the total fee is above 0, a fee collector must be set; otherwise the call reverts with FeeCollectorNotSet.
  • The fee token is the one the fee collector returns for this distributor (native or ERC20).
  • Only the beforeClaim hook is called; afterClaim is not.

Fee system

TokenTable service fees are configured outside the distributor: TTADeployer decides which fee collector applies to each distributor (feeCollectors(distributor)), and the fee token comes from that fee collector (feeTokens(distributor)). The distributor's owner cannot change them. Fees are charged once per claim call, after all claims in it.

Fee flow

Signature claim, hook and fee flow Signature claim, hook and fee flow
Signature claim, hook and fee flow

Fee amounts

BaseECDSADistributor, which FungibleTokenECDSADistributor uses, charges a fixed fee. It asks the fee collector for getFee(distributor, 1), the fee for a nominal amount of 1, and multiplies it by 1 if every recipient in the call is the caller, or by the number of recipients otherwise. If no fee collector is set or the fee is 0, it charges no fee.

FungibleTokenWithFeesECDSADistributor charges a custom fee: the sum of the fees fields of the claims in the call.

In both cases a native fee must be sent as msg.value exactly and is forwarded to the fee collector. An ERC20 fee requires msg.value to be 0 and is transferred from the caller with transferFrom, so the caller must approve the distributor first. A mismatch reverts with IncorrectFees.

Security

Access control

Role Permissions
Owner Set parameters, configure signer, set hook, toggle pause, withdraw
Authorized signer Sign claim authorizations
Users Submit claims with valid signatures, for themselves or for others
TTADeployer / fee collector owners Choose the fee collector and fee token (and, for the fixed fee, the amount)

Protections

Every claim needs a valid ECDSA (or ERC-1271) signature and must fall inside the distribution window. claim has a reentrancy guard, and claim ID tracking prevents double claims. The owner can pause claims as an emergency stop. Signed hashes include the chain ID and distributor address, which prevents replay.

Events and errors

Events

The ECDSA distributors define no events of their own. Ownership changes emit the standard OwnershipTransferred event from Solady's Ownable.

Errors

Error Condition Selector
UnsupportedOperation Operation not allowed 0x9ba6061b
TimeInactive Outside distribution window 0x0c143eb8
OutsideClaimableTimeRange Claim's timestamp not reached or before startTime 0xf5d66d05
InvalidSignature Signature verification failed 0x8baa579f
ClaimClaimed Claim already processed 0xadeff36f
IncorrectFees Fee amount mismatch 0x1669aa83
FeeCollectorNotSet Missing fee collector 0xb4b53f42
Paused Claim while paused 0x9e87fac8
Unauthorized Caller is not the owner 0x82b42900