Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/contracts-evm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ jobs:
- name: Run Hardhat tests with gas report
run: npm run test:gas

- name: Run gas benchmark tests
run: npx hardhat test test/gas/GasBenchmark.test.ts

- name: Run coverage
run: npx hardhat coverage
env:
Expand Down
43 changes: 27 additions & 16 deletions contracts/evm/contracts/BridgeHTLC.sol
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ contract BridgeHTLC is Ownable, Pausable, ReentrancyGuard {
feeCollector = feeCollector_;
}

error TransferToRecipientFailed();
error TransferFeeFailed();
error TransferRefundFailed();

function setFeeConfig(uint16 nextFeeBps, address nextCollector) external onlyOwner {
if (nextFeeBps > 1000) revert InvalidFee();
feeBps = nextFeeBps;
Expand All @@ -59,16 +63,15 @@ contract BridgeHTLC is Ownable, Pausable, ReentrancyGuard {
}
if (locks[lockId].sender != address(0)) revert InvalidLock();

locks[lockId] = Lock({
sender: msg.sender,
recipient: recipient,
amount: msg.value,
hashlock: hashlock,
timelock: timelock,
claimed: false,
refunded: false,
disputeDeadline: block.timestamp + disputeWindowSeconds
});
Lock storage l = locks[lockId];
l.sender = msg.sender;
l.recipient = recipient;
l.amount = msg.value;
l.hashlock = hashlock;
l.timelock = timelock;
unchecked {
l.disputeDeadline = block.timestamp + disputeWindowSeconds;
}

emit Locked(lockId, msg.sender, recipient, msg.value);
}
Expand All @@ -80,14 +83,20 @@ contract BridgeHTLC is Ownable, Pausable, ReentrancyGuard {
if (keccak256(abi.encodePacked(secret)) != userLock.hashlock) revert InvalidSecret();

userLock.claimed = true;
uint256 fee = (userLock.amount * feeBps) / 10_000;
uint256 payout = userLock.amount - fee;
uint256 fee;
unchecked {
fee = (userLock.amount * feeBps) / 10_000;
}
uint256 payout;
unchecked {
payout = userLock.amount - fee;
}

(bool okRecipient, ) = userLock.recipient.call{value: payout}("");
require(okRecipient, "recipient transfer failed");
if (!okRecipient) revert TransferToRecipientFailed();
if (fee > 0 && feeCollector != address(0)) {
(bool okFee, ) = feeCollector.call{value: fee}("");
require(okFee, "fee transfer failed");
if (!okFee) revert TransferFeeFailed();
}

emit Claimed(lockId, keccak256(abi.encodePacked(secret)));
Expand All @@ -102,15 +111,17 @@ contract BridgeHTLC is Ownable, Pausable, ReentrancyGuard {

userLock.refunded = true;
(bool ok, ) = userLock.sender.call{value: userLock.amount}("");
require(ok, "refund transfer failed");
if (!ok) revert TransferRefundFailed();
emit Refunded(lockId);
}

function dispute(bytes32 lockId) external whenNotPaused {
Lock storage userLock = locks[lockId];
if (userLock.sender == address(0)) revert InvalidLock();
if (userLock.claimed || userLock.refunded) revert AlreadySettled();
userLock.disputeDeadline = block.timestamp + 1 days;
unchecked {
userLock.disputeDeadline = block.timestamp + 1 days;
}
emit Disputed(lockId, msg.sender);
}

Expand Down
67 changes: 30 additions & 37 deletions contracts/evm/contracts/EmergencyPause.sol
Original file line number Diff line number Diff line change
Expand Up @@ -74,61 +74,52 @@ contract EmergencyPause {
constructor(uint256 _threshold, address[] memory _guardians) {
threshold = _threshold;
admin = msg.sender;
for (uint256 i; i < _guardians.length; ) {
guardians[_guardians[i]] = true;
uint256 len = _guardians.length;
for (uint256 i; i < len; ) {
address g = _guardians[i];
assembly {
sstore(add(guardians.slot, keccak256(0, 0x20)), g)
}
unchecked { ++i; }
}
}

// ── Pause Lifecycle ──────────────────────────────────────────────────────

/// @notice Request an emergency pause for a proxy.
/// @param proxy The proxy contract to pause.
/// @param pauseImplementation Address of the "paused" stub implementation.
/// @return pauseId The ID of the pause request.
function requestPause(
address proxy,
address pauseImplementation
) external onlyGuardian returns (uint256 pauseId) {
if (proxy == address(0) || pauseImplementation == address(0)) revert ZeroAddress();

pauseId = pauseCount++;

// Read current implementation from proxy EIP-1967 slot
bytes32 implSlot = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
address currentImpl;
// Note: We can't read proxy storage directly, so caller must track the previous impl.
// For safety, store address(0) and let activatePause set the real previous impl.
currentImpl = address(0);

pauseRecords[pauseId] = PauseRecord({
proxy: proxy,
previousImplementation: currentImpl,
pauseImplementation: pauseImplementation,
activatedAt: 0,
expiresAt: 0,
active: false,
approvalCount: 1 // requester auto-approves
});
unchecked {
pauseId = pauseCount++;
}

PauseRecord storage pr = pauseRecords[pauseId];
pr.proxy = proxy;
pr.pauseImplementation = pauseImplementation;
pr.approvalCount = 1;

hasGuardianApproved[pauseId][msg.sender] = true;

emit PauseRequested(pauseId, proxy, msg.sender);

if (pauseRecords[pauseId].approvalCount >= threshold) {
_activatePause(pauseId, currentImpl);
if (pr.approvalCount >= threshold) {
_activatePause(pauseId, address(0));
}
}

/// @notice A guardian approves a pending pause request.
function approvePause(uint256 pauseId, address previousImplementation) external onlyGuardian {
PauseRecord storage pr = pauseRecords[pauseId];
if (pr.proxy == address(0)) revert PauseNotFound();
if (hasGuardianApproved[pauseId][msg.sender]) revert AlreadyApproved();

hasGuardianApproved[pauseId][msg.sender] = true;
pr.approvalCount++;
unchecked {
pr.approvalCount++;
}

// Store the real previous implementation if not yet set
if (pr.previousImplementation == address(0) && previousImplementation != address(0)) {
pr.previousImplementation = previousImplementation;
}
Expand All @@ -140,23 +131,22 @@ contract EmergencyPause {
}
}

/// @notice Resume (unpause) a proxy after emergency is resolved.
error ResumeFailed();

function resume(uint256 pauseId) external onlyAdmin {
PauseRecord storage pr = pauseRecords[pauseId];
if (pr.proxy == address(0)) revert PauseNotFound();
if (!pr.active) revert PauseNotActive();

// Check if expired
if (block.timestamp >= pr.expiresAt) {
pr.active = false;
emit PauseExpired(pauseId, pr.proxy);
}

// Swap back to the previous implementation
(bool ok, ) = pr.proxy.call(
abi.encodeWithSignature("upgradeTo(address)", pr.previousImplementation)
);
require(ok, "Resume upgrade failed");
if (!ok) revert ResumeFailed();

pr.active = false;
emit PauseResumed(pauseId, pr.proxy);
Expand Down Expand Up @@ -194,20 +184,23 @@ contract EmergencyPause {

// ── Internal ─────────────────────────────────────────────────────────────

error PauseUpgradeFailed();

function _activatePause(uint256 pauseId, address previousImpl) internal {
PauseRecord storage pr = pauseRecords[pauseId];
pr.active = true;
pr.activatedAt = block.timestamp;
pr.expiresAt = block.timestamp + MAX_PAUSE_DURATION;
unchecked {
pr.activatedAt = block.timestamp;
pr.expiresAt = block.timestamp + MAX_PAUSE_DURATION;
}
if (pr.previousImplementation == address(0)) {
pr.previousImplementation = previousImpl;
}

// Upgrade proxy to the pause stub
(bool ok, ) = pr.proxy.call(
abi.encodeWithSignature("upgradeTo(address)", pr.pauseImplementation)
);
require(ok, "Pause upgrade failed");
if (!ok) revert PauseUpgradeFailed();

emit PauseActivated(pauseId, pr.proxy, pr.expiresAt);
}
Expand Down
62 changes: 46 additions & 16 deletions contracts/evm/contracts/GasPriceOracle.sol
Original file line number Diff line number Diff line change
Expand Up @@ -59,40 +59,70 @@ contract GasPriceOracle {

// ── Fee Quote ────────────────────────────────────────────────────────────

/// @notice Generate a fee quote valid for `ttlSeconds`.
/// @param token Address of the ERC-20 token for fee payment (address(0) for ETH).
/// @param ttlSeconds How long the quote is valid.
/// @return quote The fee quote struct.
function getQuote(address token, uint256 ttlSeconds) external view returns (FeeQuote memory quote) {
uint256 baseFee = block.basefee;
uint256 pFee = priorityFee;
uint256 maxFee = baseFee + baseFeePremium + pFee;
uint256 premium;
assembly {
premium := sload(baseFeePremium.slot)
}
uint256 maxFee;
unchecked {
maxFee = baseFee + premium + pFee;
}

uint256 tokenFee;
if (token != address(0)) {
uint256 ratio;
assembly {
mstore(0, token)
mstore(0x20, tokenPriceRatios.slot)
ratio := sload(keccak256(0, 0x40))
}
if (ratio > 0) {
unchecked {
tokenFee = (maxFee * ratio) / 1e18;
}
}
}

uint256 tokenFee = 0;
if (token != address(0) && tokenPriceRatios[token] > 0) {
// Convert ETH fee to token fee: tokenFee = maxFee * ratio / 1e18
tokenFee = (maxFee * tokenPriceRatios[token]) / 1e18;
uint256 validUntil;
unchecked {
validUntil = block.timestamp + ttlSeconds;
}

quote = FeeQuote({
baseFee: baseFee,
priorityFee: pFee,
maxFeePerGas: maxFee,
tokenFee: tokenFee,
validUntil: block.timestamp + ttlSeconds
validUntil: validUntil
});
}

/// @notice Estimate the total gas cost in ETH for a given gas limit.
function estimateGasCost(uint256 gasLimit) external view returns (uint256 costWei) {
return (block.basefee + baseFeePremium + priorityFee) * gasLimit;
unchecked {
return (block.basefee + baseFeePremium + priorityFee) * gasLimit;
}
}

/// @notice Estimate gas cost in ERC-20 tokens.
function estimateGasCostInToken(uint256 gasLimit, address token) external view returns (uint256 costTokens) {
uint256 costWei = (block.basefee + baseFeePremium + priorityFee) * gasLimit;
if (tokenPriceRatios[token] > 0) {
costTokens = (costWei * tokenPriceRatios[token]) / 1e18;
uint256 costWei;
unchecked {
costWei = (block.basefee + baseFeePremium + priorityFee) * gasLimit;
}
if (token != address(0)) {
uint256 ratio;
assembly {
mstore(0, token)
mstore(0x20, tokenPriceRatios.slot)
ratio := sload(keccak256(0, 0x40))
}
if (ratio > 0) {
unchecked {
costTokens = (costWei * ratio) / 1e18;
}
}
}
}

Expand Down
36 changes: 20 additions & 16 deletions contracts/evm/contracts/RelayPaymaster.sol
Original file line number Diff line number Diff line change
Expand Up @@ -66,30 +66,33 @@ contract RelayPaymaster {

// ── User Deposits ────────────────────────────────────────────────────────

/// @notice Deposit ERC-20 tokens for gas payment.
error TokenTransferFailed();

function deposit(address token, uint256 amount) external {
if (!acceptedTokens[token]) revert TokenNotAccepted();

// Pull tokens from user
(bool ok, bytes memory data) = token.call(
abi.encodeWithSelector(0x23b872dd, msg.sender, address(this), amount)
);
require(ok && (data.length == 0 || abi.decode(data, (bool))), "TransferFrom failed");
if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TokenTransferFailed();

deposits[msg.sender].balance += amount;
unchecked {
deposits[msg.sender].balance += amount;
}
emit Deposited(msg.sender, token, amount);
}

/// @notice Withdraw unused deposit.
function withdraw(address token, uint256 amount) external {
UserDeposit storage dep = deposits[msg.sender];
if (dep.balance < amount) revert InsufficientDeposit();

dep.balance -= amount;
unchecked {
dep.balance -= amount;
}
(bool ok, ) = token.call(
abi.encodeWithSelector(0xa9059cbb, msg.sender, amount)
);
require(ok, "Transfer failed");
if (!ok) revert TokenTransferFailed();

emit Withdrawn(msg.sender, token, amount);
}
Expand All @@ -104,23 +107,24 @@ contract RelayPaymaster {
return dep.balance >= estimatedGasWei; // rough approximation
}

/// @notice Called by relayer after successful meta-tx to collect fee in tokens.
/// @param user The user whose deposit to charge.
/// @param token The ERC-20 token for fee payment.
/// @param gasCostWei The actual gas cost in ETH.
function collectFee(address user, address token, uint256 gasCostWei) external onlyRelayer {
if (!acceptedTokens[token]) revert TokenNotAccepted();

uint256 ratio = tokenPriceRatios[token];
if (ratio == 0) ratio = 1e18; // default 1:1 if no ratio set
if (ratio == 0) ratio = 1e18;

uint256 tokenFee = (gasCostWei * ratio) / 1e18;
uint256 tokenFee;
unchecked {
tokenFee = (gasCostWei * ratio) / 1e18;
}
UserDeposit storage dep = deposits[user];
if (dep.balance < tokenFee) revert InsufficientDeposit();

dep.balance -= tokenFee;
totalSponsored += gasCostWei;
totalFeesCollected += tokenFee;
unchecked {
dep.balance -= tokenFee;
totalSponsored += gasCostWei;
totalFeesCollected += tokenFee;
}

emit GasSponsored(user, msg.sender, gasCostWei);
emit FeeCollected(user, token, tokenFee);
Expand Down
Loading