AuctionManager Diamond Proxy & WebUI — Recent Work

Share

AuctionManager Diamond Proxy & WebUI — Recent Work

The AuctionManager suite is a full-featured NFT marketplace built on the EIP-2535 Diamond proxy pattern. It supports English and Dutch auctions for both ERC-721 and ERC-1155 tokens, sale listings, offers, treasury fee splits, and a per-class reward model. The recent work covered the contract architecture, a per-chain admin/dev webui, and the deployment pipeline. This post walks through what was built and why.

The Diamond Proxy Pattern

The original monolithic AuctionManager exceeded Ethereum's 24,576-byte contract size limit (EIP-170). The Diamond pattern splits the system into a lightweight router plus independent facets — each facet is a standalone contract holding a subset of the logic, while all facets share one diamond storage slot. Calls route through the diamond's fallback(), which does a delegatecall to the facet registered for that function selector.

The router (AuctionManagerDiamond) handles:

  • diamondCut() — add/replace/remove facet selectors, gated by owner or the admin list
  • getFacets() / facet() / diamondCutLength() — discovery queries for the frontend
  • receive() / onERC1155Received() — escrow acceptance for ERC-1155 lots
  • _authorizeUpgrade() — UUPS upgrade gate (owner only)

Key design point: shared facets share code, never storage. delegatecall executes facet bytecode against the calling proxy's storage, so each proxy gets its own independent state even when multiple proxies point at the same facet addresses.

Facet Split

The suite is organised into five facets plus a shared storage library:

  • Core (AuctionManagerCore) — initialisation, ownership, pausable/reentrancy guards, fee percentages, admin list, and the core read helpers (getHighBid, buyerFeeForBid)
  • English (AuctionManagerEnglish) — English auction lifecycle: createAuction, submitNewBid, finalizeAuction, finalizeEarly, reserve-met auto-finalise, outbid refunds, ERC-2981 royalty splits, and analytics getters (getFloorPrice, getTotalVolume, getGlobalTotalVolume)
  • Dutch (AuctionManagerDutch) — linear price decay from start to reserve, active-Dutch tracking
  • ERC-1155 (AuctionManagerERC1155) — per-lot quantity auctions, sale listings with quantity, batch-create with atomic escrow, Dutch pricing, offer management, and per-volume-type tracking
  • TokenRescueFacet — admin-only rescue of stuck ERC-721/ERC-1155 tokens from a discarded proxy

The shared library (LibDiamond) holds the DiamondStorage struct, the slot-key derivation helpers (k256AddrUint, k256AddrAddr), and listing/volume/floor helpers. All nested mappings were replaced with flat bytes32 keys to cut SLOAD gas — for example auctionBids[keccak256(nftContract, tokenId)] instead of auctionBids[nftContract][tokenId].

Storage Layout & Flat Keys

The DiamondStorage struct lives at a fixed slot derived from keccak256("diamond.storage") - 1. It contains all state: counters, fee config, admin list, auction mappings, ERC-1155 balances, sale listings, offers, trades, and analytics. Because the diamond is the only contract that ever initializes this slot, no facet accidentally overwrites another's data.

Key mappings use pre-computed keys:

auctionBids[      keccak256(contract, tokenId) ]       → AuctionCore
auctionBidMeta[   keccak256(contract, tokenId) ]       → AuctionMeta
saleListings[     keccak256(contract, tokenId) ]       → SaleListing
saleListingHistory[ key ]                              → uint256[] (capped at 20, most recent)
saleListingsById[ listingId ]                          → SaleListing
currentlyRunning[ ]                                    → uint256[] (active auction IDs)
dutchCurrentPrice[ auctionId ]                         → uint256 (price at auction start for decay)
contractFloorPrice[ contract ]                         → uint256 (lowest ask, updated on new listings)
contractTotalVolume[ contract ]                        → uint256 (sum of all sales)
contractVolumeByType[ contract ][ type ]               → uint256 per VenueType (SALE_LISTING, AUCTION_ENGLISH, etc.)

This flat structure not only reduces gas but also enables efficient batch reads from the frontend via multicall. The Diamond proxy can return multiple values in a single call.

ERC-1155 Quantity Model

The suite adopted a per-lot quantity model (designated D1/D4 in the decisions log): one class per listing, any quantity the holder owns. Creating an auction for 25 Gold units sells all 25 to the single winner, atomic and trustless. The same principle applies to sale listings: createERC1155SaleListing locks a chosen quantity, and acceptERC1155SaleListing

Bid pricing is per lot, not per unit. The minimum next bid increments by a percentage of the current bid (default 5%), ensuring a healthy bidding progression. Reserve prices work alongside the "buy it now" feature via finalizeOnReserveMet.

Per-Chain Diamond Proxy

The deploy script reads the chainid→RPC map from the webui's chain config at runtime, writes state to the info directory, and preserves the same source while working copies live in the work directory.

The useAuctionManager hook dynamically selects the correct proxy based on the wallet's connected chain. If no chain-specific address exists, it falls back to the Anvil testnet deployment. This enables seamless multi-network support in the admin webui without configuration duplication.

Admin & Dev WebUI

The admin interface is a Vite + React + TanStack Router + wagmi + RainbowKit SPA that loads contract ABIs from a generated manifest (built from the Foundry compiled output), and the hook resolves which facet owns each function.

The useAuctionManager hook centralises all contract interaction:

  • Function routing — every function name maps to its owning facet. For example, createAuctionEnglish, getCurrentDutchPriceDutch, createERC1155SaleListingERC1155, diamondCutDiamond
  • Read/write separation — derived automatically from ABI stateMutability: view/pure calls use eth_call via the public client; everything else is sent as a transaction via the connected wallet
  • Per-chain proxy — a chainAddrMap in the manifest lets the hook follow the wallet across Anvil, Flare, Coston2, and Songbird without manual configuration
  • Auto-bound methods — every entry in FN_ROUTES is exposed as a convenience wrapper, plus generic callRead/callWrite for ad-hoc calls

The AuctionPanel component renders all of this into a usable UI: create auctions, submit bids, finalise (early or normally), view active listings, and see real-time countdowns synced to the chain clock (not the browser clock). Approval is handled transparently — if the NFT isn't approved for the proxy, the panel triggers an approve call, waits for confirmation, then proceeds to create the auction. The ERC-1155 batch flow works the same way, with a single atomic safeBatchTransferFrom escrowing all lot quantities.

Deployment Pipeline

Deployment uses a single shell script — deploy-all-auction-contracts.sh — that runs end-to-end:

  1. Build all five facet contracts plus the ERC1967 proxy implementation via Foundry
  2. Deploy each facet using forge create with the configured signer, auto-copying each ABI into the abis directory (archiving any previous version to the legacy subdirectory)
  3. Extract function selectors from each compiled ABI using cast sig
  4. Build diamondCut calldata — a Python helper encodes the FacetCut[] array, mapping every selector to its facet address with the Add action
  5. Deploy the Diamond proxy — an ERC1967 proxy with the Diamond router as implementation and initialize() as the constructor init data
  6. Call diamondCut() on the proxy to wire all facets
  7. Initialise each facetinitialize() called on the proxy for Core, English, Dutch, and ERC1155 facets (the router's own initialize seeds the shared LibDiamond storage)
  8. Verify — read diamond storage slot, diamondCutLength, getFacets(), getActiveAuctionIDs(), and proxy owner
  9. Write state to a chainid-suffixed state file

The script selects the target chain via CHAINID (looked up in the chain map from the webui's chain config), with PRIVATEKEY overriding DEPLOYER_KEY. The state file is chainid-suffixed so deployments to different networks never collide.

Key Decisions & Lessons

Several principles guided the implementation:

  • Size matters — factories must fit under EIP-170's 24,576-byte limit. The assembly-optimised 1155_asm.sol achieves 24,307 bytes (viaIR off, runs=1), while the 721 factory ships at ~23,477 bytes. Storage/config is deliberately kept out of the factory and moved into NFTConfigurator to keep bytecode lean.
  • Storage is append-only — appending fields to a struct is safe; reordering or inserting mid-struct breaks old offsets. A genuinely new layout needs a new slot-key, not an edit to an old one.
  • Admin is not the bottleneck — splitting only admin functions saves negligible size. The real weight is in the business paths (payable mints, multi-struct configs, overloaded hooks). The Diamond's shared FacetRegistry matters more for fleet-wide upgrade cost (one replaceSelector vs updating N per-collection maps).
  • Gas analytics are off-chain — floor price, average sale price, and time-series metrics are computed by the backend from on-chain events and served to the frontend. The contract keeps only a cheap per-contract floor mapping.
  • Frontend follows the chain — the webui's proxy resolution is derived from the connected wallet's chainid, keeping the admin experience consistent across testnet and production.

The work demonstrates that the Diamond pattern is not just a theoretical exercise — it delivers real upgradeability and gas advantages for a production marketplace. By separating concerns into facets, the suite can evolve individual functions without redeploying the entire system, while the per-chain webui makes the developer experience seamless across networks.