Skip to main content

Disperse Payment Signature Structure

Centralized Verification

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},{senderExecutor},{hashPayload},{originExecutor},{nonce},{isAsyncExec}

Components:

  1. evvmId: Network identifier (uint256, typically 1)
  2. senderExecutor: Address that can call the function via msg.sender (address, 0x0...0 for anyone)
  3. hashPayload: Hash of disperse payment parameters (bytes32, from CoreHashUtils)
  4. originExecutor: EOA that can initiate the transaction via tx.origin (address, 0x0...0 for anyone)
  5. nonce: User's centralized nonce from Core.sol (uint256)
  6. isAsyncExec: Execution mode - true for async, false for sync (boolean)

Dual-Executor Model:

  • senderExecutor: Controls which address can execute via msg.sender (contract or EOA)
  • originExecutor: Controls which EOA can initiate via tx.origin
  • Both can be address(0) for maximum flexibility (anyone can execute/initiate)
  • Hash payload does NOT include executors (only operation parameters)

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:

// Actual CoreHashUtils implementation
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:

  • toData is an array of DispersePayMetadata structs
  • Hash includes the action identifier "dispersePay"
  • Total amount must equal sum of individual recipient amounts
  • Hash is deterministic: same parameters → same hash
  • Executors are NOT part of the hash - they're only in the signature payload

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
senderExecutor, // Who can call via msg.sender
hashPayload, // From CoreHashUtils
originExecutor, // Who can initiate via tx.origin
nonce, // User's nonce
isAsyncExec, // Execution mode
signature // EIP-191 signature
);

Verification Steps:

  1. Constructs signature message with all 6 components
  2. Applies EIP-191 wrapping and hashing
  3. Recovers signer from signature
  4. Validates signer matches user parameter
  5. Checks nonce status
  6. Validates senderExecutor authorization (if not 0x0...0, checks msg.sender)
  7. Validates originExecutor authorization (if not 0x0...0, checks tx.origin)
  8. 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: 0x742d7b6b472c8f4bd58e6f9f6c82e8e6e7c82d8c,
toIdentity: "" // Empty for address-only
});

// Recipient 2: Username recipient (0.05 ETH)
toData[1] = CoreStructs.DispersePayMetadata({
amount: 50000000000000000,
to: address(0),
toIdentity: "alice" // Username for NameService resolution
});

// Recipient 3: Address recipient (0.02 ETH)
toData[2] = CoreStructs.DispersePayMetadata({
amount: 20000000000000000,
to: 0x8e3f2b4c5d6a7f8e9c1b2a3d4e5f6c7d8e9f0a1b,
toIdentity: "" // Empty for address-only
});

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: 1
  • senderExecutor: 0x0000000000000000000000000000000000000000 (anyone can call)
  • hashPayload: 0xb7c3f2e9a4d5c8e7f9b2a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4
  • originExecutor: 0x0000000000000000000000000000000000000000 (anyone can initiate)
  • nonce: 25
  • isAsyncExec: false

Final Message:

1,0x0000000000000000000000000000000000000000,0xb7c3f2e9a4d5c8e7f9b2a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4,0x0000000000000000000000000000000000000000,25,false

Step 4: EIP-191 Formatted Hash

keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n138",
"1,0x0000000000000000000000000000000000000000,0xb7c3f2e9a4d5c8e7f9b2a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4,0x0000000000000000000000000000000000000000,25,false"
))

Step 5: User Signs Message

// Frontend example (ethers.js)
const message = "1,0x0000000000000000000000000000000000000000,0xb7c3...d3e4,0x0000000000000000000000000000000000000000,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

Key Takeaway

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 _priorityFlag is true (asynchronous)
  • "false": If _priorityFlag is false (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
tip
  • AdvancedStrings.bytes32ToString converts a bytes32 hash to lowercase hexadecimal string with "0x" prefix
  • AdvancedStrings.addressToString converts an address to a lowercase string
  • Strings.toString converts a number to a string
  • _priorityFlag indicates whether the payment will be executed asynchronously (true) or synchronously (false)
  • The signature verification uses the SignatureRecover.signatureVerification function 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:

  1. 0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b - Hash of recipient data
  2. 0x0000000000000000000000000000000000000000 - Token address (ETH)
  3. 100000000000000000 - Total amount in wei (0.1 ETH)
  4. 5000000000000000 - Priority fee in wei (0.005 ETH)
  5. 25 - Nonce
  6. false - Priority flag (synchronous)
  7. 0x0000000000000000000000000000000000000000 - Executor (unrestricted)

Signature Implementation Details

The SignatureUtil library performs signature verification in the following steps:

  1. Message Construction: Concatenates evvmID, functionName, and inputs with commas
  2. EIP-191 Formatting: Prepends "\x19Ethereum Signed Message:\n" + message length
  3. Hashing: Applies keccak256 to the formatted message
  4. Signature Parsing: Splits the 65-byte signature into r, s, and v components
  5. Recovery: Uses ecrecover via SignatureRecover.recoverSigner to recover the signer's address
  6. 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)
Technical Details
  • 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: keccak256 is used for the final message hash before signing
  • Signature Recovery: Uses ecrecover to verify the signature against the expected signer
  • String Conversion:
    • AdvancedStrings.addressToString converts addresses to lowercase hex with "0x" prefix
    • AdvancedStrings.bytes32ToString converts bytes32 hash to lowercase hexadecimal with "0x" prefix
    • Strings.toString converts numbers to decimal strings
  • Hash List Integrity: hashList = sha256(abi.encode(toData)) ensures signature covers specific recipients
  • Amount Validation: Total _amount should equal sum of all individual amounts in toData array
  • 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)