EIP.tools

EIP.tools

⚠️ DraftStandards Track: ERC

ERC-8190: Payment Channels with Signed Vouchers

Streaming micropayments via EIP-712 signed vouchers over unidirectional ERC-20 payment channels.

Authors
Created2026-03-10
Discussion Linkhttps://ethereum-magicians.org/t/erc-8184-draft-payment-channels-with-signed-vouchers-streaming-micropayments-for-ai-agents/28012
Requires

Markdown

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

EIP-GPT summary

Contents
AbstractMotivationRelationship to ERC-8183SpecificationInterfaceChannel DataChannel IdentifierChannel LifecycleCore FunctionsopenclaimclosecooperativeClosegetChannelgetBalanceEIP-712 Typed Data SchemasDomain SeparatorVoucher SchemaCloseAuthorization SchemaEventsFees (OPTIONAL)Service Interaction (RECOMMENDED)DiscoveryProtocol VersioningAuthenticated RequestsCost ReportingError ResponsesCooperative Close EndpointRationaleOff-chain vouchers over on-chain state channelsCumulative amounts over incrementalCooperative closeEIP-712 signaturesNo on-chain nonce trackingService interaction as a RECOMMENDED layerComplementarity with service discovery protocolsMinimal surfaceBackwards CompatibilityTest CasesChannel LifecycleCooperative CloseEdge CasesReference ImplementationIPaymentChannel.solPaymentChannel.solSecurity ConsiderationsVoucher replayFront-runningConsumer protectionProvider protectionGriefing via dust channelsToken safetyReentrancyExpiry race conditionFee-on-transfer tokensSignature malleabilityBlock timestamp manipulationCooperative close trustCopyright

Abstract

This specification defines unidirectional payment channels for streaming micropayments using EIP-712 typed signed vouchers. A consumer opens a channel by depositing ERC-20 tokens into escrow, designating a provider and an expiry timestamp. The consumer then issues incrementally increasing off-chain signed vouchers to the provider in exchange for services. The provider can claim the highest voucher on-chain at any time. Channels settle via provider claim, consumer close after expiry, or cooperative close with mutual signatures.

This standard is designed for high-frequency, low-value service transactions β€” particularly AI agent-to-provider commerce β€” where per-transaction on-chain settlement is economically infeasible.

Motivation

AI agents increasingly consume services programmatically: language model inference, image generation, code execution, web scraping, data retrieval. These interactions share common properties:

  1. High frequency: An agent may send hundreds of requests per session.
  2. Low value: Each request costs fractions of a cent to a few cents.
  3. Immediate delivery: The provider delivers the result synchronously with the request.
  4. No evaluation needed: The consumer can assess quality instantly (the response is either useful or not).

Existing on-chain payment models are poorly suited for this pattern:

  • Direct transfers require a transaction per request, making sub-cent payments infeasible due to gas costs.
  • Job escrow models (e.g. ERC-8183) require upfront job specification, evaluator attestation, and multiple state transitions. This is appropriate for discrete deliverables but introduces unnecessary overhead for immediate request-response services.
  • Subscription models lack granularity and require trust in the provider to deliver ongoing value.

Unidirectional payment channels solve this by amortizing on-chain costs across many off-chain interactions. The consumer deposits once, transacts off-chain via signed vouchers, and the provider settles once. Two on-chain transactions cover an unlimited number of service requests.

This standard formalizes the minimal interface for such channels: channel lifecycle, voucher schema, settlement, and cooperative close.

Relationship to ERC-8183

This standard is complementary to ERC-8183 (Agentic Commerce). ERC-8183 defines a Job primitive for discrete tasks with escrow and evaluator attestation β€” suited for deliverable-based commerce where quality must be verified before payment release.

This standard addresses streaming service commerce where:

  • Delivery is immediate and synchronous with each request.
  • The consumer evaluates quality implicitly by continuing or stopping the interaction.
  • The economic model is pay-per-use, not pay-per-deliverable.

Together, the two standards cover the full spectrum of agent commerce:

DimensionThis StandardERC-8183
Payment modelStreaming / per-requestPer-job / milestone
Settlement2 on-chain txs total4+ on-chain txs per job
Trust modelConsumer controls spend rateEvaluator-mediated
Best forAPIs, chat, inference, scrapingReports, analysis, fund management
Quality assuranceImplicit (stop sending vouchers)Explicit (evaluator attestation)

Specification

The key words "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.

Interface

Implementations MUST implement the IPaymentChannel interface:

interface IPaymentChannel {
    function open(address provider, uint256 amount, uint256 duration) external returns (bytes32 channelId);
    function claim(bytes32 channelId, uint256 amount, uint256 nonce, bytes calldata signature) external;
    function close(bytes32 channelId) external;
    function cooperativeClose(bytes32 channelId, uint256 finalAmount, bytes calldata providerSignature) external;
    function getChannel(bytes32 channelId) external view returns (
        address consumer, address provider, uint256 deposit, uint256 claimed, uint256 expiry
    );
    function getBalance(bytes32 channelId) external view returns (uint256);

    event ChannelOpened(bytes32 indexed channelId, address indexed consumer, address indexed provider, uint256 deposit, uint256 expiry);
    event ChannelClaimed(bytes32 indexed channelId, address indexed provider, uint256 amount);
    event ChannelClosed(bytes32 indexed channelId, address indexed consumer, uint256 refund);
}

Channel Data

Each channel SHALL have at least:

FieldTypeDescription
consumeraddressThe party that opens the channel and signs vouchers.
provideraddressThe party that receives payment on claim.
deposituint256Total tokens deposited into escrow.
claimeduint256Cumulative amount claimed by the provider so far.
expiryuint256Unix timestamp after which the consumer may close the channel.

Payment SHALL use a single ERC-20 token per contract. Implementations MAY support per-channel token selection; the specification only requires one token per contract.

Channel Identifier

Each channel SHALL be identified by a unique bytes32 value (channelId). The method of derivation is left to the implementation. Implementations SHOULD derive channelId deterministically from the channel parameters (e.g. keccak256(abi.encodePacked(consumer, provider, nonce))) to prevent collisions and enable off-chain channel identification.

Channel Lifecycle

A channel progresses through the following states:

   Consumer calls open()
          β”‚
          β–Ό
       β”Œβ”€β”€β”€β”€β”€β”€β”
       β”‚Activeβ”‚ ── Provider calls claim() ──► Provider receives (amount - claimed)
       β””β”€β”€β”€β”€β”€β”€β”˜                                claimed updated, channel stays Active
          β”‚
          β”‚ block.timestamp >= expiry
          β–Ό
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚ Expired  β”‚ ── Consumer calls close() ──► Consumer receives (deposit - claimed)
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                 Channel deleted
          
   At any time while Active:
     Consumer calls cooperativeClose() with provider signature
       ──► Provider receives finalAmount, Consumer receives (deposit - finalAmount)
           Channel deleted

A channel is Active from creation until it is closed or deleted. There is no separate "funded" state β€” the channel is funded at creation.

A channel is Expired when block.timestamp >= expiry. The provider is permitted to call claim on an expired channel, provided the channel has not been deleted. The consumer can only call close after the channel has expired.

A channel is Closed (deleted) after close() or cooperativeClose() completes. Closed channels SHOULD be deleted from contract storage. There is no on-chain distinction between a channel that was closed and a channel that never existed β€” both return zero values from getChannel.

cooperativeClose is permitted regardless of expiry status. It MAY be called both before and after the channel has expired.

Core Functions

open

function open(address provider, uint256 amount, uint256 duration) external returns (bytes32 channelId);

Creates a new payment channel.

  • SHALL revert if provider is the zero address.
  • SHALL revert if amount is zero.
  • SHALL revert if duration is zero.
  • SHALL transfer amount of the payment token from msg.sender to the contract (escrow).
  • SHALL set consumer = msg.sender, provider = provider, deposit = amount, claimed = 0, expiry = block.timestamp + duration.
  • SHALL return a unique channelId.
  • SHALL emit ChannelOpened.

The caller MUST have approved the contract to spend at least amount of the payment token before calling open. Implementations MAY support ERC-2612 permit to allow gasless approval via signature.

claim

function claim(bytes32 channelId, uint256 amount, uint256 nonce, bytes calldata signature) external;

Provider claims payment using the consumer's signed voucher.

  • SHALL revert if the channel does not exist.
  • SHALL revert if msg.sender is not the channel's provider.
  • SHALL revert if amount <= channel.claimed (no new funds to claim).
  • SHALL revert if amount > channel.deposit.
  • SHALL recover the signer from the EIP-712 typed data signature (see Voucher Schema below) and revert if the recovered address is not the channel's consumer.
  • SHALL transfer (amount - channel.claimed) of the payment token to the provider (minus optional platform fee).
  • SHALL update channel.claimed = amount.
  • SHALL emit ChannelClaimed.
  • If a platform fee is configured, SHALL transfer the fee amount to the treasury and emit FeePaid.

The nonce parameter is included in the signed message for replay protection but is not required to be validated on-chain. Off-chain, providers SHOULD reject vouchers with non-increasing nonces.

close

function close(bytes32 channelId) external;

Consumer closes an expired channel and reclaims remaining deposit.

  • SHALL revert if the channel does not exist.
  • SHALL revert if msg.sender is not the channel's consumer.
  • SHALL revert if block.timestamp < channel.expiry.
  • SHALL transfer (channel.deposit - channel.claimed) to the consumer.
  • SHALL delete the channel from storage.
  • SHALL emit ChannelClosed.

cooperativeClose

function cooperativeClose(bytes32 channelId, uint256 finalAmount, bytes calldata providerSignature) external;

Consumer closes a channel with the provider's consent. This function is permitted regardless of whether the channel has expired.

  • SHALL revert if the channel does not exist.
  • SHALL revert if msg.sender is not the channel's consumer.
  • SHALL revert if finalAmount > channel.deposit.
  • SHALL recover the signer from the EIP-712 typed data signature (see CloseAuthorization Schema below) and revert if the recovered address is not the channel's provider.
  • If finalAmount > channel.claimed, SHALL transfer (finalAmount - channel.claimed) to the provider (minus optional platform fee) and update claimed.
  • SHALL transfer (channel.deposit - finalAmount) to the consumer.
  • SHALL delete the channel from storage.
  • SHALL emit ChannelClaimed (for the provider's portion, if any).
  • SHALL emit ChannelClosed (for the consumer's refund).
  • If a platform fee is charged, SHALL emit FeePaid.

getChannel

function getChannel(bytes32 channelId) external view returns (
    address consumer,
    address provider,
    uint256 deposit,
    uint256 claimed,
    uint256 expiry
);

Returns channel state. SHALL return zero values for non-existent channels.

getBalance

function getBalance(bytes32 channelId) external view returns (uint256);

Returns deposit - claimed for the given channel. SHALL return 0 for non-existent channels.

EIP-712 Typed Data Schemas

All signatures in this protocol use EIP-712 typed structured data signing.

Domain Separator

Implementations SHALL use the following EIP-712 domain:

FieldValue
nameImplementation-defined string. Implementations SHOULD use a descriptive name (e.g. "PaymentChannel"). Note: two implementations with different domain names produce incompatible signatures. Cross-implementation voucher portability requires matching domain names.
version"1"
chainIdThe chain ID where the contract is deployed
verifyingContractThe address of the payment channel contract

Voucher Schema

Vouchers are signed by the consumer and presented to the provider off-chain as proof of payment authorization.

struct Voucher {
    bytes32 channelId;  // The channel this voucher applies to
    uint256 amount;     // Cumulative amount authorized (NOT incremental)
    uint256 nonce;      // Monotonically increasing sequence number
}

The EIP-712 type hash:

Voucher(bytes32 channelId,uint256 amount,uint256 nonce)

Critical: The amount field is cumulative, not incremental. A voucher with amount = 500000 authorizes the provider to claim up to 500,000 token units total, regardless of how many previous vouchers were issued. Each new voucher supersedes all previous vouchers for the same channel.

The nonce field SHALL be monotonically increasing within a channel. Providers SHOULD reject vouchers where nonce <= lastSeenNonce for that channel. The contract MAY ignore the nonce during on-chain claim (since only the cumulative amount matters for settlement), but it MUST be included in the signed message to prevent signature reuse across different payment contexts.

CloseAuthorization Schema

Close authorizations are signed by the provider to consent to early channel closure.

struct CloseAuthorization {
    bytes32 channelId;   // The channel to close
    uint256 finalAmount; // Final cumulative amount the provider is owed
}

The EIP-712 type hash:

CloseAuthorization(bytes32 channelId,uint256 finalAmount)

Events

Implementations SHALL emit the following events:

event ChannelOpened(
    bytes32 indexed channelId,
    address indexed consumer,
    address indexed provider,
    uint256 deposit,
    uint256 expiry
);

event ChannelClaimed(
    bytes32 indexed channelId,
    address indexed provider,
    uint256 amount
);

event ChannelClosed(
    bytes32 indexed channelId,
    address indexed consumer,
    uint256 refund
);

Implementations that charge a platform fee SHOULD emit:

event FeePaid(
    bytes32 indexed channelId,
    address indexed recipient,
    uint256 amount
);

Fees (OPTIONAL)

Implementations MAY charge a platform fee (in basis points) deducted from provider payments on claim and cooperativeClose. The fee SHALL be deducted only from the provider's portion (never from the consumer's refund). If present, the fee SHOULD be sent to a configurable treasury address.

This section defines the RECOMMENDED interaction pattern between consumers and HTTP-based service providers. While the on-chain protocol is sufficient for settlement, standardizing the off-chain request-voucher interaction reduces fragmentation across implementations and ensures interoperability.

Discovery

When an unauthenticated or unpaid request arrives, the provider SHOULD respond with HTTP 402 (Payment Required) and the following headers:

HeaderDescriptionExample
X-Payment-ProtocolProtocol identifierpayment-channel-v1
X-Payment-ProviderProvider's Ethereum address0xABC...
X-Payment-ContractPayment channel contract address0xDEF...
X-Payment-ChainChain ID137
X-Payment-SigningURL to fetch EIP-712 signing parametershttps://provider.example/signing

The signing endpoint SHOULD return a JSON object containing the EIP-712 domain and voucher type definitions, enabling the consumer to construct valid voucher signatures without prior knowledge of the contract's domain parameters.

Protocol Versioning

Providers SHOULD include an X-Payment-Version header in the HTTP 402 discovery response to signal which version of this specification's HTTP conventions they support:

HeaderDescriptionExample
X-Payment-VersionSemantic version of this specification's HTTP conventions1.0

The version 1.0 refers to the HTTP header conventions defined in this section (Discovery, Authenticated Requests, Cost Reporting, Error Responses, and Cooperative Close Endpoint).

Optionally, providers MAY include an X-Payment-Features header to advertise supported feature flags:

X-Payment-Features: ["cost-reporting", "cooperative-close"]

Standardized feature flags:

  • cost-reporting: Provider includes X-Payment-Cost, X-Payment-Total, and X-Payment-Remaining response headers
  • cooperative-close: Provider exposes the cooperative close endpoint

Consumers SHOULD check the version and features to ensure compatibility. If a consumer does not support the advertised version, it MAY choose to fall back to on-chain settlement or reject the service. This versioning mechanism prevents fragmentation as the ecosystem evolves.

Authenticated Requests

The consumer SHALL include a signed voucher in each request via a header:

X-Payment-Voucher: {"channelId":"0x...","amount":"1000000","nonce":"1","signature":"0x..."}

The header value is a JSON object containing the voucher fields and the EIP-712 signature. The amount and nonce fields are decimal string representations of uint256 values.

The voucher amount SHOULD be the consumer's previous cumulative total plus the estimated cost of the current request. Providers SHOULD validate that amount >= previousTotal + requestCost and reject vouchers that underpay.

Cost Reporting

After processing a request, the provider SHOULD include the following response headers to enable the consumer to construct accurate voucher amounts for subsequent requests:

HeaderDescriptionExample
X-Payment-CostCost of this request in token units50000
X-Payment-TotalCumulative total charged to this channel150000
X-Payment-RemainingRemaining channel balance (deposit - total)850000
X-Payment-ChannelChannel ID0xABC...

These headers allow consumers to track spend without querying on-chain state and to construct the next voucher's cumulative amount as the value of X-Payment-Total plus the estimated cost of the next request.

Error Responses

When a voucher is rejected, the provider SHOULD respond with HTTP 402 and an error indicator:

HeaderDescription
X-Payment-ErrorError code (see below)

Standardized error codes:

CodeMeaning
voucher_requiredNo voucher was provided
insufficient_fundsVoucher amount is less than required
invalid_signatureEIP-712 signature verification failed
invalid_nonceNonce is not greater than the last seen nonce
channel_expiredThe channel has expired
channel_not_foundThe channel does not exist or has been closed

For insufficient_funds, the provider SHOULD additionally include:

HeaderDescription
X-Payment-RequiredMinimum voucher amount required
X-Payment-ProvidedVoucher amount that was provided

Cooperative Close Endpoint

Providers SHOULD expose an endpoint for consumers to request a close authorization:

POST /v1/close-channel
Content-Type: application/json

{"channelId": "0x..."}

Response:

{
    "channelId": "0x...",
    "finalAmount": "500000",
    "signature": "0x..."
}

Rationale

Off-chain vouchers over on-chain state channels

Full state channels (bidirectional, with dispute periods) provide stronger guarantees but require significantly more complexity: challenge periods, watchtowers, and multiple rounds of on-chain interaction. For unidirectional service payments where the consumer always pays and the provider always delivers, the voucher model provides equivalent security with far less overhead.

Cumulative amounts over incremental

Vouchers use cumulative amounts (amount = total owed so far) rather than incremental amounts (amount = this payment only). This simplifies settlement: the provider only needs to submit the single highest voucher to claim all owed funds. There is no need to aggregate or order multiple vouchers on-chain.

Cooperative close

Without cooperative close, a consumer must wait until channel expiry to reclaim unused funds. This creates a capital efficiency problem: funds are locked for the full channel duration even if the interaction ends early. Cooperative close allows immediate settlement when both parties agree, requiring only a single provider signature.

EIP-712 signatures

EIP-712 typed data signatures (rather than raw eth_sign) provide:

  • Human-readable signing prompts in wallet UIs.
  • Domain separation preventing cross-contract and cross-chain replay.
  • Structured data that can be parsed and displayed by wallets and tools.

No on-chain nonce tracking

The contract validates only the cumulative amount and the signature. Nonce ordering is enforced off-chain by the provider. This reduces gas costs (no storage writes for nonce tracking) while maintaining security: the provider is incentivized to accept only increasing nonces, and the on-chain claim only cares about the total amount authorized.

This standard defines both the on-chain settlement primitive and a RECOMMENDED off-chain service interaction pattern. The settlement layer (channel lifecycle, voucher schema) is normative and sufficient on its own. The service interaction layer (HTTP 402 discovery, cost reporting headers, error codes) is RECOMMENDED to reduce fragmentation across implementations β€” production experience with 10+ providers showed that without standardized cost reporting and error semantics, each provider develops incompatible conventions, undermining interoperability.

Complementarity with service discovery protocols

This standard defines the settlement primitive. Service discovery β€” how consumers find providers and negotiate terms β€” is left to application-layer protocols such as the Machine Payments Protocol (MPP) or the Model Context Protocol (MCP). A payment channel can serve as a settlement backend for any service discovery protocol, enabling agents to use channel-based micropayments regardless of how they discover and connect to services.

Minimal surface

This standard deliberately excludes:

  • Discovery and negotiation: How consumers find providers is out of scope. Marketplaces, registries, and service discovery protocols handle this.
  • Pricing models: Per-token, per-request, tiered β€” left to the application layer.
  • Reputation: Job outcome signals belong in complementary standards (e.g. ERC-8004, ERC-8183).
  • Dispute resolution: The channel model has no disputes. The consumer controls spend rate; the provider can claim at any time. If either party misbehaves, the other stops interacting.
  • Multi-hop or routed payments: This standard covers direct consumer-to-provider channels only.

Backwards Compatibility

No backward compatibility issues found.

Test Cases

Channel Lifecycle

  1. Open: Consumer approves token spend, calls open(provider, 1000000, 86400). Contract pulls 1,000,000 tokens, emits ChannelOpened with the returned channelId. After this call, getChannel(channelId).deposit MUST equal 1000000 and getChannel(channelId).claimed MUST equal 0.

  2. Voucher signing: Consumer signs Voucher{channelId, amount: 100000, nonce: 1} using EIP-712. Provider verifies signature off-chain, delivers service.

  3. Claim: Provider calls claim(channelId, 100000, 1, signature). Contract transfers 100,000 tokens to provider. After this call, getChannel(channelId).claimed MUST equal 100000 and getBalance(channelId) MUST equal 900000.

  4. Subsequent voucher and claim: Consumer signs Voucher{channelId, amount: 250000, nonce: 2}. Provider calls claim(channelId, 250000, 2, signature). Contract transfers 250000 - 100000 = 150000 to provider. After this call, getChannel(channelId).claimed MUST equal 250000.

  5. Close after expiry: After expiry, consumer calls close(channelId). Contract transfers 1000000 - 250000 = 750000 to consumer, deletes channel. After this call, getChannel(channelId) MUST return zero values for all fields.

Cooperative Close

  1. Consumer opens channel with deposit 1,000,000 and duration 86400.
  2. Consumer sends vouchers for 300,000 total.
  3. Consumer requests close from provider. Provider signs CloseAuthorization{channelId, finalAmount: 300000}.
  4. Consumer calls cooperativeClose(channelId, 300000, providerSignature).
  5. Contract transfers 300,000 to provider (minus fee), 700,000 to consumer, deletes channel. After this call, getChannel(channelId) MUST return zero values.

Edge Cases

  • claim(channelId, amount, nonce, sig) where amount <= getChannel(channelId).claimed MUST revert.
  • claim(channelId, amount, nonce, sig) where amount > getChannel(channelId).deposit MUST revert.
  • claim with signature from an address other than getChannel(channelId).consumer MUST revert.
  • close(channelId) where block.timestamp < getChannel(channelId).expiry MUST revert.
  • cooperativeClose with a signature from an address other than getChannel(channelId).provider MUST revert.
  • open(address(0), amount, duration) MUST revert.
  • open(provider, 0, duration) MUST revert.
  • open(provider, amount, 0) MUST revert.

Reference Implementation

The reference implementation consists of the IPaymentChannel interface and a minimal PaymentChannel contract.

IPaymentChannel.sol

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.20;

interface IPaymentChannel {
    function open(address provider, uint256 amount, uint256 duration) external returns (bytes32 channelId);
    function claim(bytes32 channelId, uint256 amount, uint256 nonce, bytes calldata signature) external;
    function close(bytes32 channelId) external;
    function cooperativeClose(bytes32 channelId, uint256 finalAmount, bytes calldata providerSignature) external;
    function getChannel(bytes32 channelId) external view returns (
        address consumer, address provider, uint256 deposit, uint256 claimed, uint256 expiry
    );
    function getBalance(bytes32 channelId) external view returns (uint256);

    event ChannelOpened(bytes32 indexed channelId, address indexed consumer, address indexed provider, uint256 deposit, uint256 expiry);
    event ChannelClaimed(bytes32 indexed channelId, address indexed provider, uint256 amount);
    event ChannelClosed(bytes32 indexed channelId, address indexed consumer, uint256 refund);
}

PaymentChannel.sol

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "./IPaymentChannel.sol";

contract PaymentChannel is IPaymentChannel, EIP712, ReentrancyGuard {
    using SafeERC20 for IERC20;

    IERC20 public immutable token;
    uint256 private _channelNonce;

    struct Channel {
        address consumer;
        address provider;
        uint256 deposit;
        uint256 claimed;
        uint256 expiry;
    }

    mapping(bytes32 => Channel) private _channels;

    bytes32 private constant VOUCHER_TYPEHASH =
        keccak256("Voucher(bytes32 channelId,uint256 amount,uint256 nonce)");
    bytes32 private constant CLOSE_AUTHORIZATION_TYPEHASH =
        keccak256("CloseAuthorization(bytes32 channelId,uint256 finalAmount)");

    error ZeroAddress();
    error ZeroAmount();
    error ZeroDuration();
    error ChannelNotFound();
    error NotProvider();
    error NotConsumer();
    error InvalidAmount();
    error InvalidSignature();
    error NotExpired();

    constructor(address token_) EIP712("PaymentChannel", "1") {
        token = IERC20(token_);
    }

    function open(
        address provider,
        uint256 amount,
        uint256 duration
    ) external nonReentrant returns (bytes32 channelId) {
        if (provider == address(0)) revert ZeroAddress();
        if (amount == 0) revert ZeroAmount();
        if (duration == 0) revert ZeroDuration();

        channelId = keccak256(
            abi.encodePacked(msg.sender, provider, ++_channelNonce)
        );

        _channels[channelId] = Channel({
            consumer: msg.sender,
            provider: provider,
            deposit: amount,
            claimed: 0,
            expiry: block.timestamp + duration
        });

        token.safeTransferFrom(msg.sender, address(this), amount);
        emit ChannelOpened(
            channelId,
            msg.sender,
            provider,
            amount,
            block.timestamp + duration
        );
    }

    function claim(
        bytes32 channelId,
        uint256 amount,
        uint256 nonce,
        bytes calldata signature
    ) external nonReentrant {
        Channel storage ch = _channels[channelId];
        if (ch.consumer == address(0)) revert ChannelNotFound();
        if (msg.sender != ch.provider) revert NotProvider();
        if (amount <= ch.claimed) revert InvalidAmount();
        if (amount > ch.deposit) revert InvalidAmount();

        bytes32 structHash = keccak256(
            abi.encode(VOUCHER_TYPEHASH, channelId, amount, nonce)
        );
        address signer = ECDSA.recover(
            _hashTypedDataV4(structHash),
            signature
        );
        if (signer != ch.consumer) revert InvalidSignature();

        uint256 payment = amount - ch.claimed;
        ch.claimed = amount;

        token.safeTransfer(ch.provider, payment);
        emit ChannelClaimed(channelId, ch.provider, amount);
    }

    function close(bytes32 channelId) external nonReentrant {
        Channel storage ch = _channels[channelId];
        if (ch.consumer == address(0)) revert ChannelNotFound();
        if (msg.sender != ch.consumer) revert NotConsumer();
        if (block.timestamp < ch.expiry) revert NotExpired();

        uint256 refund = ch.deposit - ch.claimed;
        delete _channels[channelId];

        if (refund > 0) {
            token.safeTransfer(msg.sender, refund);
        }
        emit ChannelClosed(channelId, msg.sender, refund);
    }

    function cooperativeClose(
        bytes32 channelId,
        uint256 finalAmount,
        bytes calldata providerSignature
    ) external nonReentrant {
        Channel storage ch = _channels[channelId];
        if (ch.consumer == address(0)) revert ChannelNotFound();
        if (msg.sender != ch.consumer) revert NotConsumer();
        if (finalAmount > ch.deposit) revert InvalidAmount();

        bytes32 structHash = keccak256(
            abi.encode(
                CLOSE_AUTHORIZATION_TYPEHASH,
                channelId,
                finalAmount
            )
        );
        address signer = ECDSA.recover(
            _hashTypedDataV4(structHash),
            providerSignature
        );
        if (signer != ch.provider) revert InvalidSignature();

        address provider = ch.provider;
        uint256 providerPayment = finalAmount > ch.claimed
            ? finalAmount - ch.claimed
            : 0;
        uint256 consumerRefund = ch.deposit - finalAmount;

        delete _channels[channelId];

        if (providerPayment > 0) {
            token.safeTransfer(provider, providerPayment);
            emit ChannelClaimed(channelId, provider, finalAmount);
        }

        if (consumerRefund > 0) {
            token.safeTransfer(msg.sender, consumerRefund);
        }
        emit ChannelClosed(channelId, msg.sender, consumerRefund);
    }

    function getChannel(
        bytes32 channelId
    )
        external
        view
        returns (
            address consumer,
            address provider,
            uint256 deposit,
            uint256 claimed,
            uint256 expiry
        )
    {
        Channel storage ch = _channels[channelId];
        return (ch.consumer, ch.provider, ch.deposit, ch.claimed, ch.expiry);
    }

    function getBalance(bytes32 channelId) external view returns (uint256) {
        Channel storage ch = _channels[channelId];
        return ch.deposit - ch.claimed;
    }
}

A production implementation of this standard is deployed on Polygon Mainnet:

ComponentAddress
Payment Channel Contract0x0C2B3aA1e80629D572b1f200e6DF3586B3946A8A
Payment Token (USDC)0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359

EIP-712 domain name: DrainChannel, version: 1, chain ID: 137.

The production implementation includes a 2% platform fee (200 basis points) deducted on claim and cooperative close, paid to a configurable fee wallet.

Security Considerations

Voucher replay

EIP-712 domain separation (chain ID + contract address) prevents cross-chain and cross-contract replay. Within a channel, replay is harmless: claiming the same voucher twice has no effect because amount <= claimed causes a revert.

Front-running

A front-runner observing a claim transaction in the mempool could extract the voucher signature. However, the claim function restricts the caller to the channel's provider (msg.sender == provider), so a front-runner cannot steal the claim. The same applies to cooperativeClose which restricts the caller to the consumer.

Consumer protection

The consumer's maximum loss is bounded by the channel deposit. The consumer controls spend rate by choosing how quickly to increment voucher amounts. If the provider stops delivering, the consumer stops issuing vouchers. After expiry, the consumer reclaims all unspent funds via close.

Provider protection

The provider can claim at any time. If the consumer disappears, the provider claims the latest voucher. If the channel is about to expire, the provider should claim before expiry to avoid racing the consumer's close call. Implementations should implement auto-claim logic that monitors channel expiry and claims proactively.

Griefing via dust channels

A malicious consumer could open many small channels to force the provider to spend gas on claims. Providers should set minimum deposit thresholds and may batch claims across channels.

Token safety

Implementations must use SafeERC20 (or equivalent) for all token transfers to handle non-standard ERC-20 implementations that do not return a boolean.

Reentrancy

Functions that transfer tokens should be protected against reentrancy (e.g. via a reentrancy guard or checks-effects-interactions pattern).

Expiry race condition

When a channel expires, both the provider (claim) and the consumer (close) may submit transactions simultaneously. If the consumer's close transaction is mined first, the channel is deleted and the provider's claim reverts β€” causing the provider to lose earned funds. Mitigations:

  • Providers should claim well before expiry (e.g. one hour before). Implementations should include auto-claim logic that monitors channel expiry.
  • Implementations may introduce a grace period after expiry during which only the provider can act (e.g. the consumer can only call close after expiry + gracePeriod). This is not required by the standard but is recommended for production deployments.

Fee-on-transfer tokens

Some ERC-20 tokens deduct a fee on transfer or transferFrom, causing the contract to receive fewer tokens than the amount parameter. If such a token is used as the payment token, channel.deposit will not reflect the actual escrowed balance. Implementations should not use fee-on-transfer tokens, or should record the actual received amount (via balance-before/after measurement) as the deposit.

Signature malleability

ECDSA signatures have a known s-value malleability issue (EIP-2). While this is largely harmless in this protocol (since claim is restricted to the provider and the outcome is identical regardless of signature form), implementations should normalize signatures to low-s values using a library such as OpenZeppelin's ECDSA.

Block timestamp manipulation

Miners can manipulate block timestamps by approximately 15 seconds. For channels with very short durations (under a few minutes), this could affect expiry logic. Implementations should enforce a minimum channel duration (e.g. 1 hour) to make timestamp manipulation negligible.

Cooperative close trust

The provider signs a CloseAuthorization reflecting the highest voucher received. A dishonest provider could sign a lower finalAmount than actually received, causing the consumer to receive a larger refund than deserved. However, this only harms the provider (who forfeits earned funds), so there is no incentive to do so.

A dishonest consumer could present a cooperativeClose with a stale provider signature (lower finalAmount than the provider intended). This is prevented by the provider only signing CloseAuthorization on explicit request and using the current highest voucher amount.

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