Disperse Payment Signature Structure
dispersePay signatures are verified by Core.sol using validateAndConsumeNonce(). The signature format uses hash-based payload encoding instead of individual parameters.
To authorize disperse payment operations (splitting payments to multiple recipients), the user must generate a cryptographic signature compliant with the EIP-191 standard using the Ethereum Signed Message format.
Disperse payments allow distributing a total amount of tokens to multiple recipients in a single transaction, with individual amounts specified for each recipient.
Signature Format
{evvmId},{serviceAddress},{hashPayload},{executor},{nonce},{isAsyncExec}
Components:
- evvmId: Network identifier (uint256, typically
1) - serviceAddress: Core.sol contract address
- hashPayload: Hash of disperse payment parameters (bytes32, from CoreHashUtils)
- executor: Address authorized to execute (address,
0x0...0for unrestricted) - nonce: User's centralized nonce from Core.sol (uint256)
- isAsyncExec: Execution mode -
truefor async,falsefor sync (boolean)
Hash Payload Generation
The hashPayload is generated using CoreHashUtils.hashDataForDispersePay():
import {CoreHashUtils} from "@evvm/testnet-contracts/library/signature/CoreHashUtils.sol";
import {CoreStructs} from "@evvm/testnet-contracts/library/CoreStructs.sol";
bytes32 hashPayload = CoreHashUtils.hashDataForDispersePay(
toData, // Array of recipients and amounts
token, // ERC20 token address (0x0...0 for ETH)
amount, // Total amount (must equal sum of individual amounts)
priorityFee // Fee amount in wei
);
Hash Generation Process
CoreHashUtils creates a deterministic hash that includes the recipient array:
// Internal implementation (simplified)
function hashDataForDispersePay(
CoreStructs.DispersePayMetadata[] memory toData,
address token,
uint256 amount,
uint256 priorityFee
) internal pure returns (bytes32) {
return keccak256(
abi.encode("dispersePay", toData, token, amount, priorityFee)
);
}
Key Points:
toDatais an array ofDispersePayMetadatastructs- Hash includes the action identifier
"dispersePay" - Total
amountmust equal sum of individual recipient amounts - Hash is deterministic: same parameters → same hash
DispersePayMetadata Struct
Each recipient is defined by:
struct DispersePayMetadata {
uint256 amount; // Amount to send to this recipient
bytes32 to; // Recipient identifier (address or username hash)
}
Centralized Verification
Core.sol verifies the signature using validateAndConsumeNonce():
// Called internally by Core.sol.dispersePay()
Core(coreAddress).validateAndConsumeNonce(
user, // Signer's address
hashPayload, // From CoreHashUtils
executor, // Who can execute
nonce, // User's nonce
isAsyncExec, // Execution mode
signature // EIP-191 signature
);
Verification Steps:
- Constructs signature message with all 6 components
- Applies EIP-191 wrapping and hashing
- Recovers signer from signature
- Validates signer matches
userparameter - Checks nonce status
- Validates executor authorization
- Marks nonce as consumed
Complete Example: Disperse 0.1 ETH to 3 Recipients
Scenario: User distributes 0.1 ETH to three recipients (0.03 + 0.05 + 0.02 ETH)
Step 1: Prepare Recipient Data
import {CoreStructs} from "@evvm/testnet-contracts/library/CoreStructs.sol";
CoreStructs.DispersePayMetadata[] memory toData =
new CoreStructs.DispersePayMetadata[](3);
// Recipient 1: Address recipient (0.03 ETH)
toData[0] = CoreStructs.DispersePayMetadata({
amount: 30000000000000000,
to: bytes32(uint256(uint160(0x742d7b6b472c8f4bd58e6f9f6c82e8e6e7c82d8c)))
});
// Recipient 2: Username recipient (0.05 ETH)
bytes32 aliceHash = keccak256(abi.encodePacked("alice"));
toData[1] = CoreStructs.DispersePayMetadata({
amount: 50000000000000000,
to: aliceHash
});
// Recipient 3: Address recipient (0.02 ETH)
toData[2] = CoreStructs.DispersePayMetadata({
amount: 20000000000000000,
to: bytes32(uint256(uint160(0x8e3f2b4c5d6a7f8e9c1b2a3d4e5f6c7d8e9f0a1b)))
});
Step 2: Generate Hash Payload
address token = address(0); // ETH
uint256 amount = 100000000000000000; // 0.1 ETH total
uint256 priorityFee = 5000000000000000; // 0.005 ETH
bytes32 hashPayload = CoreHashUtils.hashDataForDispersePay(
toData,
token,
amount,
priorityFee
);
// Result: 0xb7c3f2e9a4d5c8e7f9b2a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4
Step 3: Construct Signature Message
Parameters:
evvmId:1serviceAddress:0xCoreContractAddress(deployed Core.sol)hashPayload:0xb7c3f2e9a4d5c8e7f9b2a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4executor:0x0000000000000000000000000000000000000000(unrestricted)nonce:25isAsyncExec:false
Final Message:
1,0xCoreContractAddress,0xb7c3f2e9a4d5c8e7f9b2a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4,0x0000000000000000000000000000000000000000,25,false
Step 4: EIP-191 Formatted Hash
keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n138",
"1,0xCoreContractAddress,0xb7c3f2e9a4d5c8e7f9b2a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4,0x0000000000000000000000000000000000000000,25,false"
))
Step 5: User Signs Message
// Frontend example (ethers.js)
const message = "1,0xCoreAddress,0xb7c3...d3e4,0x0000...0000,25,false";
const signature = await signer.signMessage(message);
Step 6: Submit Transaction
// Call Core.sol dispersePay function
Core(coreAddress).dispersePay(
toData, // Recipient array
token, // ETH
amount, // 0.1 ETH
priorityFee, // 0.005 ETH
executor, // Unrestricted
nonce, // 25
isAsyncExec, // false (sync)
signature // User's signature
);
Amount Validation
The dispersePay function validates that the total matches sum of individual amounts:
// Internal validation in Core.sol
uint256 sum = 0;
for (uint i = 0; i < toData.length; i++) {
sum += toData[i].amount;
}
require(sum == amount, "Amount mismatch");
Critical: Always ensure amount parameter equals the sum of all recipient amounts.
Username Resolution
Recipients can be specified as addresses or usernames:
Address Recipient:
bytes32 recipient = bytes32(uint256(uint160(0x742d...))); // Left-pad address
Username Recipient:
bytes32 recipient = keccak256(abi.encodePacked("alice")); // Hash username
Core.sol resolves usernames via NameService integration during payment processing.
Gas Efficiency
Disperse payments are more gas-efficient than multiple individual payments:
Multiple pay() Calls:
- Gas cost: ~52,000 per payment
- 3 payments = ~156,000 gas
Single dispersePay() Call:
- Gas cost: ~80,000 base + ~25,000 per recipient
- 3 recipients = ~155,000 gas (similar but atomic)
Benefits:
- Atomic execution (all or nothing)
- Single signature required
- Single nonce consumption
- Better UX for multi-recipient payments
Best Practices
Security
- Validate recipient count: Check array length before signing
- Verify amounts: Ensure individual amounts sum to total
- Check recipients: Validate each recipient address/username
- Never reuse nonces: Each signature needs unique nonce
Development
- Use CoreHashUtils: Don't manually construct
hashPayload - Test recipient arrays: Verify data structure before signing
- Handle username resolution: Ensure usernames exist in NameService
- Track nonces: Query Core.sol for next available nonce
Gas Optimization
- Batch when possible: Use dispersePay instead of multiple pay() calls
- Prefer sync execution: Async costs more due to nonce reservation
- Optimize recipient count: Balance atomic execution vs. gas costs
- Consider payment size: Large recipient arrays may hit gas limits
Error Handling
Common validation failures:
// Total amount mismatch
require(sum == amount, "Amount mismatch");
// Empty recipient array
require(toData.length > 0, "Empty recipients");
// Insufficient balance
require(balance >= amount + priorityFee, "Insufficient funds");
// Invalid recipient
// Checked during username resolution
Related Operations
- Single Payment Signatures - One-to-one payments
- Withdrawal Signatures - Withdraw from Core balance
- Core.sol Payment Functions - Function reference
dispersePay provides atomic multi-recipient payments with centralized verification, hash-based payload encoding, and improved gas efficiency compared to multiple individual payments.
Signed Message Format
The signature verification uses the SignatureUtil.verifySignature function with the following structure:
SignatureUtil.verifySignature(
evvmID, // EVVM ID as uint256
"dispersePay", // Action type
string.concat( // Concatenated parameters
AdvancedStrings.bytes32ToString(hashList),
",",
AdvancedStrings.addressToString(_token),
",",
AdvancedStrings.uintToString(_amount),
",",
AdvancedStrings.uintToString(_priorityFee),
",",
AdvancedStrings.uintToString(_nonce),
",",
_priorityFlag ? "true" : "false",
",",
AdvancedStrings.addressToString(_executor)
),
signature,
signer
);
Internal Message Construction
Internally, the SignatureUtil.verifySignature function constructs the final message by concatenating:
string.concat(
AdvancedStrings.uintToString(evvmID),
",",
functionName,
",",
inputs
)
This results in a message format:
"{evvmID},dispersePay,{hashList},{token},{amount},{priorityFee},{nonce},{priorityFlag},{executor}"
EIP-191 Message Hashing
The message is then hashed according to EIP-191 standard:
bytes32 messageHash = keccak256(
abi.encodePacked(
"\x19Ethereum Signed Message:\n",
AdvancedStrings.uintToString(bytes(message).length),
message
)
);
This creates the final hash that the user must sign with their private key.
Message Components
The signature verification takes three main parameters:
1. EVVM ID (String):
- The result of
AdvancedStrings.uintToString(evvmID) - Purpose: Identifies the specific EVVM instance
2. Action Type (String):
- Fixed value:
"dispersePay" - Purpose: Identifies this as a disperse payment operation
3. Concatenated Parameters (String): The parameters are concatenated with comma separators:
3.1. Hash List (String):
- The result of
AdvancedStrings.bytes32ToString(hashList) - Where
hashList = sha256(abi.encode(toData)) - Purpose: Ensures signature covers the specific recipient list and amounts
3.2. Token Address (String):
- The result of
AdvancedStrings.addressToString(_token) - Purpose: Identifies the token being distributed
3.3. Total Amount (String):
- The result of
AdvancedStrings.uintToString(_amount) - Purpose: Specifies the total amount being distributed across all recipients
3.4. Priority Fee (String):
- The result of
AdvancedStrings.uintToString(_priorityFee) - Purpose: Specifies the fee paid to staking holders
3.5. Nonce (String):
- The result of
AdvancedStrings.uintToString(_nonce) - Purpose: Provides replay protection for the transaction
3.6. Priority Flag (String):
"true": If_priorityFlagistrue(asynchronous)"false": If_priorityFlagisfalse(synchronous)- Purpose: Explicitly includes the execution mode in the signed message
3.7. Executor Address (String):
- The result of
AdvancedStrings.addressToString(_executor) - Purpose: Specifies the address authorized to submit this payment request
AdvancedStrings.bytes32ToStringconverts a bytes32 hash to lowercase hexadecimal string with "0x" prefixAdvancedStrings.addressToStringconverts an address to a lowercase stringStrings.toStringconverts a number to a string_priorityFlagindicates whether the payment will be executed asynchronously (true) or synchronously (false)- The signature verification uses the
SignatureRecover.signatureVerificationfunction with structured parameters
Hash List Structure
The hashList component within the signature message is derived by ABI-encoding the entire toData array and then computing its sha256 hash:
bytes32 hashList = sha256(abi.encode(toData));
This ensures that the signature covers the specific recipient list and amounts.
Example
Here's a practical example of constructing a signature message for distributing 0.1 ETH to multiple recipients:
Scenario: User wants to distribute 0.1 ETH to three recipients using synchronous processing
Recipients (toData array):
DispersePayMetadata[] memory toData = new DispersePayMetadata[](3);
toData[0] = DispersePayMetadata({
amount: 30000000000000000, // 0.03 ETH
to_address: 0x742c7b6b472c8f4bd58e6f9f6c82e8e6e7c82d8c,
to_identity: ""
});
toData[1] = DispersePayMetadata({
amount: 50000000000000000, // 0.05 ETH
to_address: address(0),
to_identity: "alice"
});
toData[2] = DispersePayMetadata({
amount: 20000000000000000, // 0.02 ETH
to_address: 0x8e3f2b4c5d6a7f8e9c1b2a3d4e5f6c7d8e9f0a1b,
to_identity: ""
});
Parameters:
evvmID:1(EVVM instance ID)hashList:sha256(abi.encode(toData))=0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b_token:address(0)(ETH)_amount:100000000000000000(0.1 ETH total)_priorityFee:5000000000000000(0.005 ETH)_nonce:25_priorityFlag:false(synchronous)_executor:address(0)(unrestricted)
Signature verification call:
SignatureUtil.verifySignature(
1, // evvmID as uint256
"dispersePay", // action type
"0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b,0x0000000000000000000000000000000000000000,100000000000000000,5000000000000000,25,false,0x0000000000000000000000000000000000000000",
signature,
signer
);
Final message to be signed (after internal concatenation):
1,dispersePay,0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b,0x0000000000000000000000000000000000000000,100000000000000000,5000000000000000,25,false,0x0000000000000000000000000000000000000000
EIP-191 formatted message hash:
keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n188",
"1,dispersePay,0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b,0x0000000000000000000000000000000000000000,100000000000000000,5000000000000000,25,false,0x0000000000000000000000000000000000000000"
))
Concatenated parameters breakdown:
0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b- Hash of recipient data0x0000000000000000000000000000000000000000- Token address (ETH)100000000000000000- Total amount in wei (0.1 ETH)5000000000000000- Priority fee in wei (0.005 ETH)25- Noncefalse- Priority flag (synchronous)0x0000000000000000000000000000000000000000- Executor (unrestricted)
Signature Implementation Details
The SignatureUtil library performs signature verification in the following steps:
- Message Construction: Concatenates
evvmID,functionName, andinputswith commas - EIP-191 Formatting: Prepends
"\x19Ethereum Signed Message:\n"+ message length - Hashing: Applies
keccak256to the formatted message - Signature Parsing: Splits the 65-byte signature into
r,s, andvcomponents - Recovery: Uses
ecrecoverviaSignatureRecover.recoverSignerto recover the signer's address - Verification: Compares recovered address with expected signer
Signature Format Requirements
- Length: Exactly 65 bytes
- Structure:
[r (32 bytes)][s (32 bytes)][v (1 byte)] - V Value: Must be 27 or 28 (automatically adjusted if < 27)
- Message Format: The final message follows the pattern
"{evvmID},{functionName},{parameters}" - EIP-191 Compliance: Uses
"\x19Ethereum Signed Message:\n"prefix with message length - Hash Function:
keccak256is used for the final message hash before signing - Signature Recovery: Uses
ecrecoverto verify the signature against the expected signer - String Conversion:
AdvancedStrings.addressToStringconverts addresses to lowercase hex with "0x" prefixAdvancedStrings.bytes32ToStringconverts bytes32 hash to lowercase hexadecimal with "0x" prefixStrings.toStringconverts numbers to decimal strings
- Hash List Integrity:
hashList = sha256(abi.encode(toData))ensures signature covers specific recipients - Amount Validation: Total
_amountshould equal sum of all individual amounts intoDataarray - Priority Flag: Determines execution mode (async=
true, sync=false) - EVVM ID: Identifies the specific EVVM instance for signature verification
DispersePayMetadata Struct
Defines the payment details for a single recipient within the toData array.
struct DispersePayMetadata {
uint256 amount;
address to_address;
string to_identity;
}
- amount: The amount to send to this specific recipient
- to_address: Direct address (use
address(0)if using identity) - to_identity: Username/identity string (empty if using address)