Skip to content
This repository was archived by the owner on Jun 18, 2025. It is now read-only.
Open
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
8 changes: 6 additions & 2 deletions script/TestPacking.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ contract TestPacking is Script {
// Exclude from coverage report
function test() public {}

function run() external {
function run() external view {
OfferItem[] memory offerItems = new OfferItem[](1);
offerItems[0] = OfferItem({tokenAddress: 0x7DA16cd402106Adaf39092215DbB54092b80B6E6, tokenId: 2});
offerItems[0] = OfferItem({
tokenAddress: 0x7DA16cd402106Adaf39092215DbB54092b80B6E6,
tokenIdOrAmount: 2,
tokenType: TokenType.ERC721
});

Offer memory offer = Offer({
sender: 0x7DA16cd402106Adaf39092215DbB54092b80B6E6,
Expand Down
20 changes: 20 additions & 0 deletions script/offer/CancelTestOffer.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;

import "forge-std/Script.sol";
import "../../src/EchoBlast.sol";

contract CancelTestOffer is Script {
// Exclude from coverage report
function test() public {}

function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
bytes32 offerId = 0x16CBC4D75FA829A5524573F169FCAC91FC8935F41F58B705F0B9E624FEC0A9CB;
EchoBlast echo = EchoBlast(0xF37c2C531a6ffEBb8d3EdCF34e54b0E26047dA4C);
echo.cancelOffer(offerId);

vm.stopBroadcast();
}
}
44 changes: 44 additions & 0 deletions script/offer/CreateTestOffer.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;

import "forge-std/Script.sol";
import "../../src/EchoBlast.sol";
import "../../test/utils/OfferUtils.sol";

contract CreateTestOffer is Script, OfferUtils {
// Exclude from coverage report
function test() public override {}

function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
EchoBlast echo = EchoBlast(0xF37c2C531a6ffEBb8d3EdCF34e54b0E26047dA4C);

address sender = address(0x213bE2f484Ab480db4f18b0Fe4C38e1C25877f09);
address receiver = address(0x20F039821DE7Db6f543c7C07D419800Eb9Bd01Af);
address NFT = address(0x43bE93945E168A205D708F1A41A124fA302e1f76);
uint256 expiration = 1818917576;
address[] memory senderTokenAddresses = new address[](1);
senderTokenAddresses[0] = NFT;
uint256[] memory senderTokenIds = new uint256[](1);
senderTokenIds[0] = 2;
uint256[] memory senderTokenAmounts = new uint256[](1);
senderTokenAmounts[0] = 0;
address[] memory receiverTokenAddresses = new address[](1);
receiverTokenAddresses[0] = NFT;
uint256[] memory receiverTokenIds = new uint256[](1);
receiverTokenIds[0] = 1;
uint256[] memory receiverTokenAmounts = new uint256[](1);
receiverTokenAmounts[0] = 0;

OfferItems memory senderItems =
generateOfferItems(senderTokenAddresses, senderTokenIds, senderTokenAmounts, block.chainid);
OfferItems memory receiverItems =
generateOfferItems(receiverTokenAddresses, receiverTokenIds, receiverTokenAmounts, block.chainid);

Offer memory offer = generateOffer(sender, senderItems, receiver, receiverItems, expiration, OfferState.OPEN);
echo.createOffer(offer);

vm.stopBroadcast();
}
}
28 changes: 8 additions & 20 deletions src/Echo.sol
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ contract Echo is ReentrancyGuard, Admin, Banker, Escrow, EchoState {
}
Offer memory offer = offers[offerId];

// @dev We dont do a check on whether the offer exsits or not because
// @dev We dont do a check on whether the offer exists or not because
// if it doesn't exist offer.receiver = address(0) which can't be msg.sender
if (offer.receiver != msg.sender) {
revert InvalidReceiver();
Expand Down Expand Up @@ -103,29 +103,17 @@ contract Echo is ReentrancyGuard, Admin, Banker, Escrow, EchoState {
revert OfferHasNotExpired();
}

// @dev Receiver has escrowed only if offer was PARTLY_REDEEMED
if (offer.state == OfferState.ACCEPTED) {
offers[offerId].state = OfferState.PARTLY_REDEEMED;
} else {
delete offers[offerId];
}

// @dev If sender, we need extra checks to make sure receiver also redeemed if offer was accepted
if (msg.sender == offer.sender) {
// @dev Receiver has escrowed only if offer was accepted
if (offer.state == OfferState.ACCEPTED) {
OfferItem memory receiverFirstOfferItem = offer.receiverItems.items[0];
ERC721 receiverFirstNft = ERC721(receiverFirstOfferItem.tokenAddress);
// @dev if Echo is not the owner, it means receiver has redeemed
if (receiverFirstNft.ownerOf(receiverFirstOfferItem.tokenId) != address(this)) {
delete offers[offerId];
}
// @dev If offer was OPEN, receiver has not escrowed, we can safely delete
} else {
delete offers[offerId];
}
_withdraw(offer.senderItems, offer.sender);
} else {
// @dev We need to check if sender has redeemed too
OfferItem memory senderFirstOfferItem = offer.senderItems.items[0];
ERC721 senderFirstNft = ERC721(senderFirstOfferItem.tokenAddress);
// @dev if Echo is not the owner, it means sender has redeemed
if (senderFirstNft.ownerOf(senderFirstOfferItem.tokenId) != address(this)) {
delete offers[offerId];
}
_withdraw(offer.receiverItems, offer.receiver);
}
emit OfferRedeeemed(offerId, msg.sender);
Expand Down
6 changes: 3 additions & 3 deletions src/EchoBlast.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import "../lib/blast/IBlast.sol";
import "../lib/blast/IBlastPoints.sol";

contract EchoBlast is Echo {
IBlast public constant BLAST = IBlast(0x4300000000000000000000000000000000000002);
IBlast private constant BLAST = IBlast(0x4300000000000000000000000000000000000002);

constructor(address owner, address blastPointsAddress) Echo(owner) {
IBlastPoints(blastPointsAddress).configurePointsOperator(owner);
BLAST.configureAutomaticYield();
BLAST.configureClaimableGas();
}

function claimGas() external onlyOwner {
BLAST.claimMaxGas(address(this), msg.sender);
function claimGas() external onlyOwner returns (uint256) {
return BLAST.claimMaxGas(address(this), msg.sender);
}
}
19 changes: 17 additions & 2 deletions src/escrow/EscrowHandler.sol
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol";
import "solmate/tokens/ERC721.sol";
import "../types/OfferItem.sol";
import "../types/OfferItems.sol";

abstract contract EscrowHandler is ERC721TokenReceiver {
Expand All @@ -10,11 +12,24 @@ abstract contract EscrowHandler is ERC721TokenReceiver {
collection.safeTransferFrom(from, to, id);
}

// @dev function to transfer items from an offer type
function _transferERC20(address tokenAddress, uint256 amount, address from, address to) internal {
if (address(this) == from) {
SafeTransferLib.safeTransfer(tokenAddress, to, amount);
} else {
SafeTransferLib.safeTransferFrom(tokenAddress, from, to, amount);
}
}

// @dev function to transfer items from an offer type. We check for the amount to distinguish ERC20
function _transferOfferItems(OfferItems memory offerItems, address from, address to) internal {
uint256 length = offerItems.items.length;
for (uint256 i = 0; i < length;) {
_transferERC721(offerItems.items[i].tokenAddress, offerItems.items[i].tokenId, from, to);
OfferItem memory item = offerItems.items[i];
if (item.tokenType == TokenType.ERC20) {
_transferERC20(item.tokenAddress, item.tokenIdOrAmount, from, to);
} else {
_transferERC721(item.tokenAddress, item.tokenIdOrAmount, from, to);
}
unchecked {
i++;
}
Expand Down
3 changes: 2 additions & 1 deletion src/types/Offer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import "./OfferItems.sol";

enum OfferState {
OPEN,
ACCEPTED
ACCEPTED,
PARTLY_REDEEMED
}

// @dev Struct representing an on-chain offer
Expand Down
12 changes: 9 additions & 3 deletions src/types/OfferItem.sol
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

// @dev Struct representing an offer item (a token)
// @dev We only support ERC721 on EVM chains for now
enum TokenType {
ERC20,
ERC721
}

// @dev Struct representing an offer item
// @dev We support ERC721 and ERC20
struct OfferItem {
address tokenAddress;
uint256 tokenId;
TokenType tokenType;
uint256 tokenIdOrAmount;
}
64 changes: 52 additions & 12 deletions test/AcceptOffer.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,24 @@ contract AcceptOfferTest is BaseTest {
senderTokenAddresses[0] = apeAddress;
uint256[] memory senderTokenIds = new uint256[](1);
senderTokenIds[0] = ape2Id;
uint256[] memory senderTokenAmounts = new uint256[](1);
senderTokenAmounts[0] = 0;

address[] memory receiverTokenAddresses = new address[](1);
receiverTokenAddresses[0] = birdAddress;
uint256[] memory receiverTokenIds = new uint256[](1);
receiverTokenIds[0] = bird2Id;
uint256[] memory receiverTokenAmounts = new uint256[](1);
receiverTokenAmounts[0] = 0;

OfferItems memory senderItems =
generateOfferItems(senderTokenAddresses, senderTokenIds, senderTokenAmounts, block.chainid);
OfferItems memory receiverItems =
generateOfferItems(receiverTokenAddresses, receiverTokenIds, receiverTokenAmounts, block.chainid);

Offer memory invalidOffer =
generateOffer(account1, senderItems, account2, receiverItems, in6hours, OfferState.OPEN);

Offer memory invalidOffer = generateOffer(
account1,
senderTokenAddresses,
senderTokenIds,
block.chainid,
account2,
receiverTokenAddresses,
receiverTokenIds,
block.chainid,
in6hours,
OfferState.OPEN
);
bytes32 offerId = generateOfferId(invalidOffer);

vm.prank(account2);
Expand Down Expand Up @@ -114,4 +114,44 @@ contract AcceptOfferTest is BaseTest {
// validate that the receiver items are in escrow
assertOfferItemsOwnership(offer.receiverItems.items, address(echo));
}

function testCanAcceptOfferMultipleTokens() public {
Offer memory offer = _createAndAcceptMultiTokensOffer();

bytes32 offerId = generateOfferId(offer);

Offer memory updatedOffer = Offer({
sender: offer.sender,
receiver: offer.receiver,
senderItems: offer.senderItems,
receiverItems: offer.receiverItems,
expiration: offer.expiration,
state: OfferState.ACCEPTED
});

(
address sender,
address receiver,
OfferItems memory senderItems,
OfferItems memory receiverItems,
uint256 expiration,
OfferState state
) = echo.offers(offerId);
assertOfferEq(
updatedOffer,
Offer({
sender: sender,
receiver: receiver,
senderItems: senderItems,
receiverItems: receiverItems,
expiration: expiration,
state: state
})
);

// validate that the sender items are in escrow
assertOfferItemsOwnership(offer.senderItems.items, address(echo));
// validate that the receiver items are in escrow
assertOfferItemsOwnership(offer.receiverItems.items, address(echo));
}
}
20 changes: 20 additions & 0 deletions test/AdminBlast.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;

import "forge-std/Test.sol";
import "./BaseTestBlast.t.sol";

contract AdminBlastTest is BaseTestBlast {
function testCannotClaimGasIfNotOwner() public {
vm.prank(account1);
vm.expectRevert("UNAUTHORIZED");
echoBlast.claimGas();
}

// TODO Should be able to test that properly
function testCanClaimGasIfNotOwner() public {
vm.prank(owner);
vm.expectRevert(bytes("must withdraw non-zero amount"));
echoBlast.claimGas();
}
}
Loading