264 lines
9.0 KiB
Solidity
264 lines
9.0 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity ^0.8.19;
|
|
|
|
import {IPaymentChannelManager} from "./IPaymentChannelManager.sol";
|
|
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
|
|
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
|
|
|
|
/**
|
|
* @title PaymentChannelManager
|
|
* @notice Manages payment channels: open, cooperative/unilateral close, challenge window (newest state wins).
|
|
* @dev Deployable on both Mainnet and Chain-138. Holds ETH; no ERC20 in v1.
|
|
*/
|
|
contract PaymentChannelManager is IPaymentChannelManager, ReentrancyGuard {
|
|
address public admin;
|
|
bool public paused;
|
|
|
|
/// Challenge period in seconds (e.g. 24h = 86400)
|
|
uint256 public challengeWindowSeconds;
|
|
|
|
uint256 private _nextChannelId;
|
|
mapping(uint256 => Channel) private _channels;
|
|
|
|
/// For enumeration
|
|
uint256[] private _channelIds;
|
|
|
|
modifier onlyAdmin() {
|
|
require(msg.sender == admin, "only admin");
|
|
_;
|
|
}
|
|
|
|
modifier whenNotPaused() {
|
|
require(!paused, "paused");
|
|
_;
|
|
}
|
|
|
|
constructor(address _admin, uint256 _challengeWindowSeconds) {
|
|
require(_admin != address(0), "zero admin");
|
|
require(_challengeWindowSeconds > 0, "zero challenge window");
|
|
admin = _admin;
|
|
challengeWindowSeconds = _challengeWindowSeconds;
|
|
}
|
|
|
|
/// @inheritdoc IPaymentChannelManager
|
|
function openChannel(address participantB) external payable whenNotPaused returns (uint256 channelId) {
|
|
require(participantB != address(0), "zero participant");
|
|
require(participantB != msg.sender, "self channel");
|
|
require(msg.value > 0, "zero deposit");
|
|
|
|
channelId = _nextChannelId++;
|
|
_channels[channelId] = Channel({
|
|
participantA: msg.sender,
|
|
participantB: participantB,
|
|
depositA: msg.value,
|
|
depositB: 0,
|
|
status: ChannelStatus.Open,
|
|
disputeNonce: 0,
|
|
disputeBalanceA: 0,
|
|
disputeBalanceB: 0,
|
|
disputeDeadline: 0
|
|
});
|
|
_channelIds.push(channelId);
|
|
|
|
emit ChannelOpened(channelId, msg.sender, participantB, msg.value, 0);
|
|
return channelId;
|
|
}
|
|
|
|
/// @inheritdoc IPaymentChannelManager
|
|
function fundChannel(uint256 channelId) external payable whenNotPaused {
|
|
Channel storage ch = _channels[channelId];
|
|
require(ch.status == ChannelStatus.Open, "not open");
|
|
require(ch.depositB == 0, "already funded");
|
|
require(msg.sender == ch.participantB, "not participant B");
|
|
require(msg.value > 0, "zero deposit");
|
|
|
|
ch.depositB = msg.value;
|
|
emit ChannelOpened(channelId, ch.participantA, ch.participantB, ch.depositA, ch.depositB);
|
|
}
|
|
|
|
/// @inheritdoc IPaymentChannelManager
|
|
function closeChannelCooperative(
|
|
uint256 channelId,
|
|
uint256 nonce,
|
|
uint256 balanceA,
|
|
uint256 balanceB,
|
|
uint8 vA,
|
|
bytes32 rA,
|
|
bytes32 sA,
|
|
uint8 vB,
|
|
bytes32 rB,
|
|
bytes32 sB
|
|
) external nonReentrant {
|
|
Channel storage ch = _channels[channelId];
|
|
require(ch.status == ChannelStatus.Open, "not open");
|
|
uint256 total = ch.depositA + ch.depositB;
|
|
require(balanceA + balanceB == total, "balance sum");
|
|
_verifyStateSignatures(channelId, ch.participantA, ch.participantB, nonce, balanceA, balanceB, vA, rA, sA, vB, rB, sB);
|
|
|
|
ch.status = ChannelStatus.Closed;
|
|
_transfer(ch.participantA, balanceA);
|
|
_transfer(ch.participantB, balanceB);
|
|
|
|
emit ChannelClosed(channelId, balanceA, balanceB, true);
|
|
}
|
|
|
|
/// @inheritdoc IPaymentChannelManager
|
|
function submitClose(
|
|
uint256 channelId,
|
|
uint256 nonce,
|
|
uint256 balanceA,
|
|
uint256 balanceB,
|
|
uint8 vA,
|
|
bytes32 rA,
|
|
bytes32 sA,
|
|
uint8 vB,
|
|
bytes32 rB,
|
|
bytes32 sB
|
|
) external {
|
|
Channel storage ch = _channels[channelId];
|
|
require(ch.status == ChannelStatus.Open || ch.status == ChannelStatus.Dispute, "wrong status");
|
|
uint256 total = ch.depositA + ch.depositB;
|
|
require(balanceA + balanceB == total, "balance sum");
|
|
_verifyStateSignatures(channelId, ch.participantA, ch.participantB, nonce, balanceA, balanceB, vA, rA, sA, vB, rB, sB);
|
|
|
|
if (ch.status == ChannelStatus.Open) {
|
|
ch.status = ChannelStatus.Dispute;
|
|
} else {
|
|
require(nonce > ch.disputeNonce, "older state");
|
|
}
|
|
|
|
ch.disputeNonce = nonce;
|
|
ch.disputeBalanceA = balanceA;
|
|
ch.disputeBalanceB = balanceB;
|
|
ch.disputeDeadline = block.timestamp + challengeWindowSeconds;
|
|
|
|
emit ChallengeSubmitted(channelId, nonce, balanceA, balanceB, ch.disputeDeadline);
|
|
}
|
|
|
|
/// @inheritdoc IPaymentChannelManager
|
|
function challengeClose(
|
|
uint256 channelId,
|
|
uint256 nonce,
|
|
uint256 balanceA,
|
|
uint256 balanceB,
|
|
uint8 vA,
|
|
bytes32 rA,
|
|
bytes32 sA,
|
|
uint8 vB,
|
|
bytes32 rB,
|
|
bytes32 sB
|
|
) external {
|
|
Channel storage ch = _channels[channelId];
|
|
require(ch.status == ChannelStatus.Dispute, "not in dispute");
|
|
uint256 total = ch.depositA + ch.depositB;
|
|
require(balanceA + balanceB == total, "balance sum");
|
|
require(nonce > ch.disputeNonce, "not newer");
|
|
_verifyStateSignatures(channelId, ch.participantA, ch.participantB, nonce, balanceA, balanceB, vA, rA, sA, vB, rB, sB);
|
|
|
|
ch.disputeNonce = nonce;
|
|
ch.disputeBalanceA = balanceA;
|
|
ch.disputeBalanceB = balanceB;
|
|
ch.disputeDeadline = block.timestamp + challengeWindowSeconds;
|
|
|
|
emit ChallengeSubmitted(channelId, nonce, balanceA, balanceB, ch.disputeDeadline);
|
|
}
|
|
|
|
/// @inheritdoc IPaymentChannelManager
|
|
function finalizeClose(uint256 channelId) external nonReentrant {
|
|
Channel storage ch = _channels[channelId];
|
|
require(ch.status == ChannelStatus.Dispute, "not in dispute");
|
|
require(block.timestamp >= ch.disputeDeadline, "window open");
|
|
|
|
ch.status = ChannelStatus.Closed;
|
|
_transfer(ch.participantA, ch.disputeBalanceA);
|
|
_transfer(ch.participantB, ch.disputeBalanceB);
|
|
|
|
emit ChannelClosed(channelId, ch.disputeBalanceA, ch.disputeBalanceB, false);
|
|
}
|
|
|
|
/// @inheritdoc IPaymentChannelManager
|
|
function getChannel(uint256 channelId) external view returns (Channel memory) {
|
|
return _channels[channelId];
|
|
}
|
|
|
|
/// @inheritdoc IPaymentChannelManager
|
|
function getChannelCount() external view returns (uint256) {
|
|
return _channelIds.length;
|
|
}
|
|
|
|
/// Returns first channel id for the pair (0 if none). For multiple channels per pair, enumerate via getChannelCount/getChannel.
|
|
function getChannelId(address participantA, address participantB) external view returns (uint256) {
|
|
for (uint256 i = 0; i < _channelIds.length; i++) {
|
|
Channel storage ch = _channels[_channelIds[i]];
|
|
if (
|
|
(ch.participantA == participantA && ch.participantB == participantB) ||
|
|
(ch.participantA == participantB && ch.participantB == participantA)
|
|
) {
|
|
if (ch.status == ChannelStatus.Open || ch.status == ChannelStatus.Dispute) {
|
|
return _channelIds[i];
|
|
}
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function getChannelIdByIndex(uint256 index) external view returns (uint256) {
|
|
require(index < _channelIds.length, "out of bounds");
|
|
return _channelIds[index];
|
|
}
|
|
|
|
function setAdmin(address newAdmin) external onlyAdmin {
|
|
require(newAdmin != address(0), "zero admin");
|
|
admin = newAdmin;
|
|
emit AdminChanged(newAdmin);
|
|
}
|
|
|
|
function setChallengeWindow(uint256 newWindow) external onlyAdmin {
|
|
require(newWindow > 0, "zero window");
|
|
uint256 old = challengeWindowSeconds;
|
|
challengeWindowSeconds = newWindow;
|
|
emit ChallengeWindowUpdated(old, newWindow);
|
|
}
|
|
|
|
function pause() external onlyAdmin {
|
|
paused = true;
|
|
emit Paused();
|
|
}
|
|
|
|
function unpause() external onlyAdmin {
|
|
paused = false;
|
|
emit Unpaused();
|
|
}
|
|
|
|
function _verifyStateSignatures(
|
|
uint256 channelId,
|
|
address participantA,
|
|
address participantB,
|
|
uint256 nonce,
|
|
uint256 balanceA,
|
|
uint256 balanceB,
|
|
uint8 vA,
|
|
bytes32 rA,
|
|
bytes32 sA,
|
|
uint8 vB,
|
|
bytes32 rB,
|
|
bytes32 sB
|
|
) internal pure {
|
|
bytes32 stateHash = keccak256(abi.encodePacked(channelId, nonce, balanceA, balanceB));
|
|
bytes32 ethSigned = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", stateHash));
|
|
|
|
address signerA = ECDSA.recover(ethSigned, vA, rA, sA);
|
|
address signerB = ECDSA.recover(ethSigned, vB, rB, sB);
|
|
require(signerA == participantA && signerB == participantB, "invalid sigs");
|
|
}
|
|
|
|
function _transfer(address to, uint256 amount) internal {
|
|
if (amount > 0) {
|
|
(bool ok,) = payable(to).call{value: amount}("");
|
|
require(ok, "transfer failed");
|
|
}
|
|
}
|
|
|
|
receive() external payable {}
|
|
}
|