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:
- High frequency: An agent may send hundreds of requests per session.
- Low value: Each request costs fractions of a cent to a few cents.
- Immediate delivery: The provider delivers the result synchronously with the request.
- 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:
| Dimension | This Standard | ERC-8183 |
|---|---|---|
| Payment model | Streaming / per-request | Per-job / milestone |
| Settlement | 2 on-chain txs total | 4+ on-chain txs per job |
| Trust model | Consumer controls spend rate | Evaluator-mediated |
| Best for | APIs, chat, inference, scraping | Reports, analysis, fund management |
| Quality assurance | Implicit (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:
| Field | Type | Description |
|---|---|---|
consumer | address | The party that opens the channel and signs vouchers. |
provider | address | The party that receives payment on claim. |
deposit | uint256 | Total tokens deposited into escrow. |
claimed | uint256 | Cumulative amount claimed by the provider so far. |
expiry | uint256 | Unix 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 deletedA 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
provideris the zero address. - SHALL revert if
amountis zero. - SHALL revert if
durationis zero. - SHALL transfer
amountof the payment token frommsg.senderto 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.senderis not the channel'sprovider. - 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.senderis not the channel'sconsumer. - 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.senderis not the channel'sconsumer. - 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 updateclaimed. - 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:
| Field | Value |
|---|---|
name | Implementation-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" |
chainId | The chain ID where the contract is deployed |
verifyingContract | The 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.
Service Interaction (RECOMMENDED)
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:
| Header | Description | Example |
|---|---|---|
X-Payment-Protocol | Protocol identifier | payment-channel-v1 |
X-Payment-Provider | Provider's Ethereum address | 0xABC... |
X-Payment-Contract | Payment channel contract address | 0xDEF... |
X-Payment-Chain | Chain ID | 137 |
X-Payment-Signing | URL to fetch EIP-712 signing parameters | https://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:
| Header | Description | Example |
|---|---|---|
X-Payment-Version | Semantic version of this specification's HTTP conventions | 1.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 includesX-Payment-Cost,X-Payment-Total, andX-Payment-Remainingresponse headerscooperative-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:
| Header | Description | Example |
|---|---|---|
X-Payment-Cost | Cost of this request in token units | 50000 |
X-Payment-Total | Cumulative total charged to this channel | 150000 |
X-Payment-Remaining | Remaining channel balance (deposit - total) | 850000 |
X-Payment-Channel | Channel ID | 0xABC... |
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:
| Header | Description |
|---|---|
X-Payment-Error | Error code (see below) |
Standardized error codes:
| Code | Meaning |
|---|---|
voucher_required | No voucher was provided |
insufficient_funds | Voucher amount is less than required |
invalid_signature | EIP-712 signature verification failed |
invalid_nonce | Nonce is not greater than the last seen nonce |
channel_expired | The channel has expired |
channel_not_found | The channel does not exist or has been closed |
For insufficient_funds, the provider SHOULD additionally include:
| Header | Description |
|---|---|
X-Payment-Required | Minimum voucher amount required |
X-Payment-Provided | Voucher 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.
Service interaction as a RECOMMENDED layer
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
-
Open: Consumer approves token spend, calls
open(provider, 1000000, 86400). Contract pulls 1,000,000 tokens, emitsChannelOpenedwith the returnedchannelId. After this call,getChannel(channelId).depositMUST equal1000000andgetChannel(channelId).claimedMUST equal0. -
Voucher signing: Consumer signs
Voucher{channelId, amount: 100000, nonce: 1}using EIP-712. Provider verifies signature off-chain, delivers service. -
Claim: Provider calls
claim(channelId, 100000, 1, signature). Contract transfers 100,000 tokens to provider. After this call,getChannel(channelId).claimedMUST equal100000andgetBalance(channelId)MUST equal900000. -
Subsequent voucher and claim: Consumer signs
Voucher{channelId, amount: 250000, nonce: 2}. Provider callsclaim(channelId, 250000, 2, signature). Contract transfers250000 - 100000 = 150000to provider. After this call,getChannel(channelId).claimedMUST equal250000. -
Close after expiry: After expiry, consumer calls
close(channelId). Contract transfers1000000 - 250000 = 750000to consumer, deletes channel. After this call,getChannel(channelId)MUST return zero values for all fields.
Cooperative Close
- Consumer opens channel with deposit 1,000,000 and duration 86400.
- Consumer sends vouchers for 300,000 total.
- Consumer requests close from provider. Provider signs
CloseAuthorization{channelId, finalAmount: 300000}. - Consumer calls
cooperativeClose(channelId, 300000, providerSignature). - 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)whereamount <= getChannel(channelId).claimedMUST revert.claim(channelId, amount, nonce, sig)whereamount > getChannel(channelId).depositMUST revert.claimwith signature from an address other thangetChannel(channelId).consumerMUST revert.close(channelId)whereblock.timestamp < getChannel(channelId).expiryMUST revert.cooperativeClosewith a signature from an address other thangetChannel(channelId).providerMUST 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:
| Component | Address |
|---|---|
| Payment Channel Contract | 0x0C2B3aA1e80629D572b1f200e6DF3586B3946A8A |
| 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
closeafterexpiry + 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
Copyright and related rights waived via CC0.
