Skip to content

Commit 42a72f9

Browse files
Merge pull request #4 from BuidlGuidl/durable-listings
Add QuantityListings contract and deployment scripts
2 parents 8b1325e + 04a5b04 commit 42a72f9

28 files changed

+3168
-658
lines changed
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
//SPDX-License-Identifier: MIT
2+
pragma solidity >=0.8.0 <0.9.0;
3+
4+
import { IListingType } from "./IListingType.sol";
5+
import { Marketplace } from "./Marketplace.sol";
6+
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
7+
8+
/**
9+
* @title QuantityListings
10+
* @notice Listing type with per-unit pricing and optional quantity tracking.
11+
* - pricePerUnit is paid per purchased unit
12+
* - initialQuantity == 0 encodes "unlimited", remainingQuantity stays 0 and never decrements
13+
* - For limited listings, remainingQuantity decreases on each purchase; when it hits 0, listing is deactivated
14+
* - buy(...) accepts optional bytes data with abi.encode(uint256 quantity). If omitted or zero, defaults to 1
15+
*/
16+
contract QuantityListings is IListingType {
17+
error NotActive();
18+
error IncorrectEth();
19+
error NoEthWithErc20();
20+
error Erc20TransferFailed();
21+
error NotCreator();
22+
error NotMarketplace();
23+
error MarketplaceZeroAddress();
24+
error EthSendFailed();
25+
error UnknownAction();
26+
error NotSelf();
27+
error QuantityZero();
28+
error InsufficientQuantity();
29+
30+
struct QuantityListing {
31+
address paymentToken; // address(0) for ETH, ERC20 otherwise
32+
uint256 pricePerUnit; // price per unit
33+
uint256 initialQuantity; // 0 => unlimited
34+
uint256 remainingQuantity; // 0 => unlimited semantics, otherwise remaining count
35+
}
36+
37+
address public immutable marketplace;
38+
mapping(uint256 => QuantityListing) public listings;
39+
40+
event QuantityListingCreated(
41+
uint256 indexed listingId,
42+
address indexed creator,
43+
address paymentToken,
44+
uint256 pricePerUnit,
45+
uint256 initialQuantity
46+
);
47+
event QuantityListingSold(
48+
uint256 indexed listingId,
49+
address indexed buyer,
50+
uint256 unitPrice,
51+
uint256 quantity,
52+
uint256 totalPrice,
53+
address paymentToken,
54+
uint256 remainingQuantity
55+
);
56+
event QuantityListingClosed(uint256 indexed listingId, address indexed caller);
57+
58+
constructor(address _marketplace) {
59+
if (_marketplace == address(0)) revert MarketplaceZeroAddress();
60+
marketplace = _marketplace;
61+
}
62+
63+
modifier onlyMarketplace() {
64+
if (msg.sender != marketplace) revert NotMarketplace();
65+
_;
66+
}
67+
68+
modifier onlySelf() {
69+
if (msg.sender != address(this)) revert NotSelf();
70+
_;
71+
}
72+
73+
modifier isActive(bool active) {
74+
if (!active) revert NotActive();
75+
_;
76+
}
77+
78+
// View helpers
79+
function getListing(uint256 listingId) external view returns (bytes memory data) {
80+
QuantityListing memory l = listings[listingId];
81+
return abi.encode(l.paymentToken, l.pricePerUnit, l.initialQuantity, l.remainingQuantity);
82+
}
83+
84+
// IListingType: create a new listing bound to the marketplace-provided id
85+
// data = abi.encode(address paymentToken, uint256 pricePerUnit, uint256 initialQuantity)
86+
function create(address creator, uint256 listingId, bytes calldata data) external onlyMarketplace returns (bool success) {
87+
(address paymentToken, uint256 pricePerUnit, uint256 initialQuantity) =
88+
abi.decode(data, (address, uint256, uint256));
89+
// remainingQuantity mirrors initialQuantity unless unlimited (0)
90+
listings[listingId] = QuantityListing({
91+
paymentToken: paymentToken,
92+
pricePerUnit: pricePerUnit,
93+
initialQuantity: initialQuantity,
94+
remainingQuantity: initialQuantity
95+
});
96+
emit QuantityListingCreated(listingId, creator, paymentToken, pricePerUnit, initialQuantity);
97+
return true;
98+
}
99+
100+
// Exposed entrypoints for dynamic dispatch; guarded so they can only be invoked via handleAction
101+
// data for buy = abi.encode(uint256 quantity) (defaults to 1 if omitted or zero)
102+
function buy(
103+
uint256 listingId,
104+
address creator,
105+
bool active,
106+
address buyer,
107+
bytes calldata data
108+
) external payable onlySelf isActive(active) {
109+
QuantityListing storage l = listings[listingId];
110+
111+
uint256 quantity = 1;
112+
if (data.length > 0) {
113+
quantity = abi.decode(data, (uint256));
114+
if (quantity == 0) quantity = 1;
115+
}
116+
117+
// limited if initialQuantity > 0
118+
bool limited = l.initialQuantity > 0;
119+
if (limited) {
120+
if (l.remainingQuantity < quantity) revert InsufficientQuantity();
121+
}
122+
123+
uint256 totalPrice = l.pricePerUnit * quantity;
124+
125+
if (l.paymentToken == address(0)) {
126+
if (msg.value != totalPrice) revert IncorrectEth();
127+
(bool sent, ) = creator.call{ value: msg.value }("");
128+
if (!sent) revert EthSendFailed();
129+
} else {
130+
if (msg.value != 0) revert NoEthWithErc20();
131+
bool ok = IERC20(l.paymentToken).transferFrom(buyer, creator, totalPrice);
132+
if (!ok) revert Erc20TransferFailed();
133+
}
134+
135+
// Update remaining and close if we hit zero for limited listings
136+
if (limited) {
137+
unchecked {
138+
l.remainingQuantity = l.remainingQuantity - quantity;
139+
}
140+
if (l.remainingQuantity == 0) {
141+
Marketplace(marketplace).setActive(listingId, false);
142+
}
143+
}
144+
145+
emit QuantityListingSold(listingId, buyer, l.pricePerUnit, quantity, totalPrice, l.paymentToken, l.remainingQuantity);
146+
}
147+
148+
function close(
149+
uint256 listingId,
150+
address creator,
151+
bool active,
152+
address caller,
153+
bytes calldata /*data*/
154+
) external onlySelf isActive(active) {
155+
if (creator != caller) revert NotCreator();
156+
Marketplace(marketplace).setActive(listingId, false);
157+
emit QuantityListingClosed(listingId, caller);
158+
}
159+
160+
function handleAction(
161+
uint256 listingId,
162+
address creator,
163+
bool active,
164+
address caller,
165+
bytes32 action,
166+
bytes calldata data
167+
) external payable onlyMarketplace {
168+
// dynamic dispatch to self with the provided selector; functions are protected by onlySelf
169+
bytes4 selector = bytes4(action);
170+
(bool ok, bytes memory reason) = address(this).call{ value: msg.value }(
171+
abi.encodeWithSelector(selector, listingId, creator, active, caller, data)
172+
);
173+
if (!ok) {
174+
if (reason.length > 0) {
175+
assembly {
176+
revert(add(reason, 0x20), mload(reason))
177+
}
178+
}
179+
revert UnknownAction();
180+
}
181+
}
182+
}
183+
184+
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { HardhatRuntimeEnvironment } from "hardhat/types";
2+
import { DeployFunction } from "hardhat-deploy/types";
3+
4+
const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
5+
const { deployer } = await hre.getNamedAccounts();
6+
const { deploy, get } = hre.deployments;
7+
8+
const marketplace = await get("Marketplace");
9+
10+
await deploy("QuantityListings", {
11+
from: deployer,
12+
args: [marketplace.address],
13+
log: true,
14+
autoMine: true,
15+
});
16+
17+
console.log("QuantityListings:", (await get("QuantityListings")).address);
18+
};
19+
20+
export default func;
21+
func.tags = ["QuantityListings"];
22+
func.dependencies = ["Marketplace"];

0 commit comments

Comments
 (0)