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¶
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
iclaimsdatas[i]forrecipients[i]underuserClaimIds[i], authorized bysignatures[i].recipientsmust 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
TimeInactiveunlessstartTime <= block.timestamp <= endTime, and withPausedwhile 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.
userClaimData is abi.encode(FungibleTokenECDSADistributorData); decodeUserClaimData(bytes) decodes it.
token == address(0)selects the native token; the contract accepts native tokens throughreceive().- A claim reverts with
OutsideClaimableTimeRangeunlessstartTime <= claimableTimestamp <= block.timestamp. withdrawsends 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
beforeClaimhook is called;afterClaimis 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¶
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 |