Description:
Multi-signature wallet contract requiring multiple confirmations for transaction execution.
Blockchain: Ethereum
Source Code: View Code On The Blockchain
Solidity Source Code:
{{
"language": "Solidity",
"sources": {
"src/BlobKitEscrow.sol": {
"content": "// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
/**
* @title BlobKitEscrow
* @notice Escrow contract for BlobKit blob storage payments
* @dev Handles trustless payments for blob transaction execution
*
* Features:
* - Job payment management with automatic refunds
* - Proxy authorization and fee collection
* - Configurable timeouts and replay protection
* - Emergency pause functionality
*
* @author Zak Cole (zcole@linux.com)
*/
contract BlobKitEscrow is Ownable, ReentrancyGuard, Pausable {
/*//////////////////////////////////////////////////////////////
CONSTANTS
//////////////////////////////////////////////////////////////*/
/// @notice Maximum proxy fee percentage (10%)
uint256 public constant MAX_PROXY_FEE_PERCENT = 10;
/// @notice Default job timeout (5 minutes)
uint256 public constant DEFAULT_JOB_TIMEOUT = 5 minutes;
/*//////////////////////////////////////////////////////////////
STORAGE
//////////////////////////////////////////////////////////////*/
/// @notice Job timeout duration in seconds
uint256 public jobTimeout;
/// @notice Mapping from proxy address to fee percentage
mapping(address => uint256) public proxyFees;
/// @notice Mapping from proxy address to authorization status
mapping(address => bool) public authorizedProxies;
/// @notice Job data structure
struct Job {
address user; // User who paid for the job
uint256 amount; // Amount paid in wei
bool completed; // Whether job has been completed
uint256 timestamp; // When the job was created
bytes32 blobTxHash; // Transaction hash of blob (set when completed)
}
/// @notice Mapping from job ID to job data
mapping(bytes32 => Job) public jobs;
/*//////////////////////////////////////////////////////////////
EVENTS
//////////////////////////////////////////////////////////////*/
/// @notice Emitted when a new job is created with payment
event JobCreated(bytes32 indexed jobId, address indexed user, uint256 amount);
/// @notice Emitted when a job is completed by a proxy
event JobCompleted(bytes32 indexed jobId, bytes32 blobTxHash, uint256 proxyFee);
/// @notice Emitted when a job is refunded
event JobRefunded(bytes32 indexed jobId, string reason);
/// @notice Emitted when job timeout is updated
event JobTimeoutUpdated(uint256 oldTimeout, uint256 newTimeout);
/// @notice Emitted when a proxy is authorized or deauthorized
event ProxyAuthorizationChanged(address indexed proxy, bool authorized);
/// @notice Emitted when a proxy fee is updated
event ProxyFeeUpdated(address indexed proxy, uint256 oldFee, uint256 newFee);
/*//////////////////////////////////////////////////////////////
ERRORS
//////////////////////////////////////////////////////////////*/
error JobAlreadyExists();
error JobNotFound();
error JobAlreadyCompleted();
error JobExpired();
error JobNotExpired();
error UnauthorizedProxy();
error InvalidProxyFee();
error InvalidJobTimeout();
error InvalidProof();
error TransferFailed();
error ZeroAmount();
/*//////////////////////////////////////////////////////////////
CONSTRUCTOR
//////////////////////////////////////////////////////////////*/
/**
* @notice Initialize the escrow contract
* @param _owner Address of the contract owner
*/
constructor(address _owner) {
_transferOwnership(_owner);
jobTimeout = DEFAULT_JOB_TIMEOUT;
}
/*//////////////////////////////////////////////////////////////
EXTERNAL FUNCTIONS
//////////////////////////////////////////////////////////////*/
/**
* @notice Deposit payment for a blob job
* @param jobId Unique identifier for the job
* @dev Creates a new job with payment. Job ID must be unique.
*/
function depositForBlob(bytes32 jobId) external payable nonReentrant whenNotPaused {
if (msg.value == 0) revert ZeroAmount();
if (jobs[jobId].user != address(0)) revert JobAlreadyExists();
jobs[jobId] = Job({
user: msg.sender,
amount: msg.value,
completed: false,
timestamp: block.timestamp,
blobTxHash: bytes32(0)
});
emit JobCreated(jobId, msg.sender, msg.value);
}
/**
* @notice Complete a job and claim payment (proxy only)
* @param jobId Job identifier
* @param blobTxHash Transaction hash of the blob
* @param proof Cryptographic proof of job completion
* @dev Only authorized proxies can complete jobs. Includes replay protection.
*/
function completeJob(bytes32 jobId, bytes32 blobTxHash, bytes calldata proof) external nonReentrant whenNotPaused {
if (!authorizedProxies[msg.sender]) revert UnauthorizedProxy();
Job storage job = jobs[jobId];
if (job.user == address(0)) revert JobNotFound();
if (job.completed) revert JobAlreadyCompleted();
// Check if job is still valid (not expired)
if (block.timestamp > job.timestamp + jobTimeout) {
revert JobExpired();
}
// Verify proof (simple signature verification)
if (!_verifyProof(jobId, blobTxHash, proof, msg.sender)) {
revert InvalidProof();
}
// Mark job as completed (replay protection)
job.completed = true;
job.blobTxHash = blobTxHash;
// Calculate proxy fee
uint256 proxyFeePercent = proxyFees[msg.sender];
uint256 proxyFee = (job.amount * proxyFeePercent) / 100;
// Transfer entire amount to proxy (proxy covers blob costs)
(bool success,) = payable(msg.sender).call{value: job.amount}("");
if (!success) revert TransferFailed();
emit JobCompleted(jobId, blobTxHash, proxyFee);
}
/**
* @notice Refund an expired job
* @param jobId Job identifier
* @dev Anyone can trigger refunds for expired jobs
*/
function refundExpiredJob(bytes32 jobId) external nonReentrant {
Job storage job = jobs[jobId];
if (job.user == address(0)) revert JobNotFound();
if (job.completed) revert JobAlreadyCompleted();
// Check if job has expired
if (block.timestamp <= job.timestamp + jobTimeout) {
revert JobNotExpired();
}
// Mark job as completed to prevent double refunds
job.completed = true;
// Refund full amount to user
(bool success,) = payable(job.user).call{value: job.amount}("");
if (!success) revert TransferFailed();
emit JobRefunded(jobId, "Job expired");
}
/**
* @notice Set proxy fee percentage (proxy only)
* @param percent Fee percentage (0-10)
* @dev Only authorized proxies can set their own fees
*/
function setProxyFee(uint256 percent) external {
if (!authorizedProxies[msg.sender]) revert UnauthorizedProxy();
if (percent > MAX_PROXY_FEE_PERCENT) revert InvalidProxyFee();
uint256 oldFee = proxyFees[msg.sender];
proxyFees[msg.sender] = percent;
emit ProxyFeeUpdated(msg.sender, oldFee, percent);
}
/*//////////////////////////////////////////////////////////////
OWNER FUNCTIONS
//////////////////////////////////////////////////////////////*/
/**
* @notice Set job timeout duration (owner only)
* @param _timeout New timeout in seconds
* @dev Minimum timeout is 1 minute, maximum is 24 hours
*/
function setJobTimeout(uint256 _timeout) external onlyOwner {
if (_timeout < 1 minutes || _timeout > 24 hours) {
revert InvalidJobTimeout();
}
uint256 oldTimeout = jobTimeout;
jobTimeout = _timeout;
emit JobTimeoutUpdated(oldTimeout, _timeout);
}
/**
* @notice Authorize or deauthorize a proxy (owner only)
* @param proxy Proxy address
* @param authorized Authorization status
*/
function setProxyAuthorization(address proxy, bool authorized) external onlyOwner {
authorizedProxies[proxy] = authorized;
emit ProxyAuthorizationChanged(proxy, authorized);
}
/**
* @notice Pause the contract (owner only)
* @dev Prevents new deposits and job completions
*/
function pause() external onlyOwner {
_pause();
}
/**
* @notice Unpause the contract (owner only)
*/
function unpause() external onlyOwner {
_unpause();
}
/**
* @notice Emergency withdrawal (owner only)
* @dev Only usable when contract is paused
*/
function emergencyWithdraw() external onlyOwner whenPaused {
uint256 balance = address(this).balance;
(bool success,) = payable(owner()).call{value: balance}("");
if (!success) revert TransferFailed();
}
/*//////////////////////////////////////////////////////////////
VIEW FUNCTIONS
//////////////////////////////////////////////////////////////*/
/**
* @notice Get job timeout duration
* @return Current job timeout in seconds
*/
function getJobTimeout() external view returns (uint256) {
return jobTimeout;
}
/**
* @notice Check if a job exists and get its details
* @param jobId Job identifier
* @return job Job details
*/
function getJob(bytes32 jobId) external view returns (Job memory job) {
return jobs[jobId];
}
/**
* @notice Check if a job is expired
* @param jobId Job identifier
* @return True if job is expired
*/
function isJobExpired(bytes32 jobId) external view returns (bool) {
Job memory job = jobs[jobId];
if (job.user == address(0) || job.completed) return false;
return block.timestamp > job.timestamp + jobTimeout;
}
/**
* @notice Get proxy fee for a specific proxy
* @param proxy Proxy address
* @return Fee percentage
*/
function getProxyFee(address proxy) external view returns (uint256) {
return proxyFees[proxy];
}
/**
* @notice Check if a proxy is authorized
* @param proxy Proxy address
* @return True if proxy is authorized
*/
function isProxyAuthorized(address proxy) external view returns (bool) {
return authorizedProxies[proxy];
}
/*//////////////////////////////////////////////////////////////
INTERNAL FUNCTIONS
//////////////////////////////////////////////////////////////*/
/**
* @notice Verify job completion proof
* @param jobId Job identifier
* @param blobTxHash Blob transaction hash
* @param proof Signature proof
* @param signer Expected signer address
* @return True if proof is valid
* @dev Verifies signature includes proxy address to prevent cross-proxy claims
*/
function _verifyProof(bytes32 jobId, bytes32 blobTxHash, bytes calldata proof, address signer)
internal
pure
returns (bool)
{
if (proof.length != 65) return false;
// Create message hash that includes the proxy address
bytes32 messageHash = keccak256(abi.encodePacked(jobId, blobTxHash, signer));
bytes32 ethSignedMessageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\
32", messageHash));
// Extract signature components
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := calldataload(proof.offset)
s := calldataload(add(proof.offset, 0x20))
v := byte(0, calldataload(add(proof.offset, 0x40)))
}
// Recover signer address
address recoveredSigner = ecrecover(ethSignedMessageHash, v, r, s);
return recoveredSigner == signer;
}
}
"
},
"lib/openzeppelin-contracts/contracts/access/Ownable.sol": {
"content": "// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.9.0) (access/Ownable.sol)
pragma solidity ^0.8.0;
import "../utils/Context.sol";
/**
* @dev Contract module which provides a basic access control mechanism, where
* there is an account (an owner) that can be granted exclusive access to
* specific functions.
*
* By default, the owner account will be the one that deploys the contract. This
* can later be changed with {transferOwnership}.
*
* This module is used through inheritance. It will make available the modifier
* `onlyOwner`, which can be applied to your functions to restrict their use to
* the owner.
*/
abstract contract Ownable is Context {
address private _owner;
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
/**
* @dev Initializes the contract setting the deployer as the initial owner.
*/
constructor() {
_transferOwnership(_msgSender());
}
/**
* @dev Throws if called by any account other than the owner.
*/
modifier onlyOwner() {
_checkOwner();
_;
}
/**
* @dev Returns the address of the current owner.
*/
function owner() public view virtual returns (address) {
return _owner;
}
/**
* @dev Throws if the sender is not the owner.
*/
function _checkOwner() internal view virtual {
require(owner() == _msgSender(), "Ownable: caller is not the owner");
}
/**
* @dev Leaves the contract without owner. It will not be possible to call
* `onlyOwner` functions. Can only be called by the current owner.
*
* NOTE: Renouncing ownership will leave the contract without an owner,
* thereby disabling any functionality that is only available to the owner.
*/
function renounceOwnership() public virtual onlyOwner {
_transferOwnership(address(0));
}
/**
* @dev Transfers ownership of the contract to a new account (`newOwner`).
* Can only be called by the current owner.
*/
function transferOwnership(address newOwner) public virtual onlyOwner {
require(newOwner != address(0), "Ownable: new owner is the zero address");
_transferOwnership(newOwner);
}
/**
* @dev Transfers ownership of the contract to a new account (`newOwner`).
* Internal function without access restriction.
*/
function _transferOwnership(address newOwner) internal virtual {
address oldOwner = _owner;
_owner = newOwner;
emit OwnershipTransferred(oldOwner, newOwner);
}
}
"
},
"lib/openzeppelin-contracts/contracts/security/ReentrancyGuard.sol": {
"content": "// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.9.0) (security/ReentrancyGuard.sol)
pragma solidity ^0.8.0;
/**
* @dev Contract module that helps prevent reentrant calls to a function.
*
* Inheriting from `ReentrancyGuard` will make the {nonReentrant} modifier
* available, which can be applied to functions to make sure there are no nested
* (reentrant) calls to them.
*
* Note that because there is a single `nonReentrant` guard, functions marked as
* `nonReentrant` may not call one another. This can be worked around by making
* those functions `private`, and then adding `external` `nonReentrant` entry
* points to them.
*
* TIP: If you would like to learn more about reentrancy and alternative ways
* to protect against it, check out our blog post
* https://blog.openzeppelin.com/reentrancy-after-istanbul/[Reentrancy After Istanbul].
*/
abstract contract ReentrancyGuard {
// Booleans are more expensive than uint256 or any type that takes up a full
// word because each write operation emits an extra SLOAD to first read the
// slot's contents, replace the bits taken up by the boolean, and then write
// back. This is the compiler's defense against contract upgrades and
// pointer aliasing, and it cannot be disabled.
// The values being non-zero value makes deployment a bit more expensive,
// but in exchange the refund on every call to nonReentrant will be lower in
// amount. Since refunds are capped to a percentage of the total
// transaction's gas, it is best to keep them low in cases like this one, to
// increase the likelihood of the full refund coming into effect.
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;
constructor() {
_status = _NOT_ENTERED;
}
/**
* @dev Prevents a contract from calling itself, directly or indirectly.
* Calling a `nonReentrant` function from another `nonReentrant`
* function is not supported. It is possible to prevent this from happening
* by making the `nonReentrant` function external, and making it call a
* `private` function that does the actual work.
*/
modifier nonReentrant() {
_nonReentrantBefore();
_;
_nonReentrantAfter();
}
function _nonReentrantBefore() private {
// On the first call to nonReentrant, _status will be _NOT_ENTERED
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
// Any calls to nonReentrant after this point will fail
_status = _ENTERED;
}
function _nonReentrantAfter() private {
// By storing the original value once again, a refund is triggered (see
// https://eips.ethereum.org/EIPS/eip-2200)
_status = _NOT_ENTERED;
}
/**
* @dev Returns true if the reentrancy guard is currently set to "entered", which indicates there is a
* `nonReentrant` function in the call stack.
*/
function _reentrancyGuardEntered() internal view returns (bool) {
return _status == _ENTERED;
}
}
"
},
"lib/openzeppelin-contracts/contracts/security/Pausable.sol": {
"content": "// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.7.0) (security/Pausable.sol)
pragma solidity ^0.8.0;
import "../utils/Context.sol";
/**
* @dev Contract module which allows children to implement an emergency stop
* mechanism that can be triggered by an authorized account.
*
* This module is used through inheritance. It will make available the
* modifiers `whenNotPaused` and `whenPaused`, which can be applied to
* the functions of your contract. Note that they will not be pausable by
* simply including this module, only once the modifiers are put in place.
*/
abstract contract Pausable is Context {
/**
* @dev Emitted when the pause is triggered by `account`.
*/
event Paused(address account);
/**
* @dev Emitted when the pause is lifted by `account`.
*/
event Unpaused(address account);
bool private _paused;
/**
* @dev Initializes the contract in unpaused state.
*/
constructor() {
_paused = false;
}
/**
* @dev Modifier to make a function callable only when the contract is not paused.
*
* Requirements:
*
* - The contract must not be paused.
*/
modifier whenNotPaused() {
_requireNotPaused();
_;
}
/**
* @dev Modifier to make a function callable only when the contract is paused.
*
* Requirements:
*
* - The contract must be paused.
*/
modifier whenPaused() {
_requirePaused();
_;
}
/**
* @dev Returns true if the contract is paused, and false otherwise.
*/
function paused() public view virtual returns (bool) {
return _paused;
}
/**
* @dev Throws if the contract is paused.
*/
function _requireNotPaused() internal view virtual {
require(!paused(), "Pausable: paused");
}
/**
* @dev Throws if the contract is not paused.
*/
function _requirePaused() internal view virtual {
require(paused(), "Pausable: not paused");
}
/**
* @dev Triggers stopped state.
*
* Requirements:
*
* - The contract must not be paused.
*/
function _pause() internal virtual whenNotPaused {
_paused = true;
emit Paused(_msgSender());
}
/**
* @dev Returns to normal state.
*
* Requirements:
*
* - The contract must be paused.
*/
function _unpause() internal virtual whenPaused {
_paused = false;
emit Unpaused(_msgSender());
}
}
"
},
"lib/openzeppelin-contracts/contracts/utils/Context.sol": {
"content": "// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts v4.4.1 (utils/Context.sol)
pragma solidity ^0.8.0;
/**
* @dev Provides information about the current execution context, including the
* sender of the transaction and its data. While these are generally available
* via msg.sender and msg.data, they should not be accessed in such a direct
* manner, since when dealing with meta-transactions the account sending and
* paying for execution may not be the actual sender (as far as an application
* is concerned).
*
* This contract is only required for intermediate, library-like contracts.
*/
abstract contract Context {
function _msgSender() internal view virtual returns (address) {
return msg.sender;
}
function _msgData() internal view virtual returns (bytes calldata) {
return msg.data;
}
}
"
}
},
"settings": {
"remappings": [
"@forge-std/=lib/forge-std/src/",
"@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/",
"ds-test/=lib/openzeppelin-contracts/lib/forge-std/lib/ds-test/src/",
"erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/",
"forge-std/=lib/forge-std/src/",
"openzeppelin-contracts/=lib/openzeppelin-contracts/",
"openzeppelin/=lib/openzeppelin-contracts/contracts/"
],
"optimizer": {
"enabled": true,
"runs": 200
},
"metadata": {
"useLiteralContent": false,
"bytecodeHash": "ipfs",
"appendCBOR": true
},
"outputSelection": {
"*": {
"*": [
"evm.bytecode",
"evm.deployedBytecode",
"devdoc",
"userdoc",
"metadata",
"abi"
]
}
},
"evmVersion": "shanghai",
"viaIR": false
}
}}
Submitted on: 2025-10-24 09:30:31
Comments
Log in to comment.
No comments yet.