Gloom logo

How decentralized is DeFi? If you look at the ownership of governance tokens, the answer is "not very." With few exceptions, a handful of addresses hold majority stakes in most DeFi projects.

DeFi token holders analysis

Let's say you're a founder or investor in a top project like Compound, Maker, Synthetix, Aave or Chainlink and you'd like to sell a large stake. The market caps for the tokens of these projects is in the billions, so even a 5% stake is worth hundreds of millions of USD.

DeFi token market capitalizations

How could you go about selling your tokens? There wouldn't be enough order book liquidity on a centralized exchange like Binance, nor enough pair liquidity on a decentralized AMM like Uniswap.

ETH pair liquidity for DeFi tokens on Uniswap

Large transaction today are typically negotiated off-chain through personal relationships, or in a centralized way through market makers. I think there's a better, decentralized way: a private auction – akin to an M&A sales process in traditional finance – conducted on the Ethereum blockchain.

This idea is the inspiration for Gloom, a platform I built at the ConsenSys Blockchain Developer bootcamp which enables a seller to hold an invite-only auction of ERC-20 tokens. Using Gloom, a seller invites bidders (contacts and/or "whales") to bid on the tokens, with the seller and winning bidder exchanging tokens and payment (in ETH) through an escrow contract.

Gloom homepage

Auction process

  1. Setup: seller configures the type and amount of tokens, makes an ETH deposit into the auction contract, invites bidders (Ethereum addresses) and sets a bidder ETH deposit requirement.
  2. Commit: bidders deposit into the auction contract and present their bids (in ETH) for the tokens. Bids are hidden (salted and hashed) and recorded on the blockchain.
  3. Reveal: bidders reveal their bids (only if they match the earlier commits), with the winner determined and an escrow contract deployed.
  4. Deliver: seller and winning bidder deliver tokens and payment, respectively, to the escrow contract.
  5. Withdraw: seller and winning bidder withdraw proceeds and tokens, respectively, from the escrow contract. Everyone withdraws their deposits from the auction contract.



Gloom auction setup

As the first step in conducting an auction, the seller configures the type and amount of ERC-20 tokens they wish to sell. On submit, the React frontend (using ethers.js) calls a function on the auction factory which deploys a new auction using a minimal-proxy pattern, implemented using Open Zeppelin's ProxyFactory contract.

Minimal proxies are a way to clone contracts in order to save on transaction gas fees. When I first implemented the factory using a traditional, non-proxy pattern, creating an auction required more than 1 million gwei of gas vs. a little over 200k currently. Essentially, the minimal proxy reuses the bytecode of a pre-deployed "logic" contract using delegate calls, a pattern standardized in EIP-1167.

function createAuction(
  address logic,
  uint256 tokenAmount,
  address tokenContractAddress
) external whenNotPaused() {
  address seller = msg.sender;
  bytes memory payload =
    abi.encodeWithSignature('initialize(address,uint256,address)', seller, tokenAmount, tokenContractAddress);
  address auction = deployMinimal(logic, payload);
  auctionExists[auction] = true;
  auctionBy[seller] = auction;
  emit LogAuctionCreated(auction, seller);


Gloom reveal bid

Since data on the Ethereum blockchain is publicly accessible, Gloom uses a commit-reveal pattern to conceal bids. In the commit phase, bid hashes are stored on the blockchain, hiding the amounts. Users supply passwords that are used as salts when hashing in order to prevent brute-force attacks.

In the reveal phase, bidders re-enter their bids and if the hashes match (which forces bidders to be honest), the bids are stored on the blockchain, with the winner declared. My code is based on Austin Griffith's commit-reveal implementation.

function getSaltedHash(bytes32 data, bytes32 salt) public view returns (bytes32) {
  return keccak256(abi.encodePacked(address(this), data, salt));

function commitBid(bytes32 dataHash) private {
  bidders[msg.sender].bidCommit = dataHash;
  bidders[msg.sender].bidCommitBlock = uint64(block.number);
  bidders[msg.sender].isBidRevealed = false;
  emit LogBidCommitted(msg.sender, bidders[msg.sender].bidCommit, bidders[msg.sender].bidCommitBlock);

function revealBid(bytes32 bidHex, bytes32 salt) external onlyBidder inReveal {
  require(bidders[msg.sender].isBidRevealed == false, 'Bid already revealed');
  require(getSaltedHash(bidHex, salt) == bidders[msg.sender].bidCommit, 'Revealed hash does not match');
  bidders[msg.sender].isBidRevealed = true;
  bidders[msg.sender].bidHex = bidHex;
  emit LogBidRevealed(msg.sender, bidHex, salt);

Escrow delivery

Escrow token & ETH delivery

When the seller triggers the deliver phase, the auction contract declares the winner and deploys a new escrow contract. The seller and bidder then deposit their tokens and payment (in ETH), respectively, into the contract, with checks to ensure both have deposited before allowing withdrawals.

function sellerDelivery() external onlySeller {
  tokenBalance += tokenAmount;
  sellerOk = true;
  require(IERC20(tokenContractAddress).transferFrom(msg.sender, address(this), tokenAmount), 'Transfer failed');
  emit LogSellerDelivered(msg.sender, tokenAmount);

function buyerPayment() external payable onlyBuyer {
  require(msg.value == winningBid, 'Incorrect amount');
  balance += msg.value;
  buyerOk = true;
  emit LogBuyerPaid(msg.sender, msg.value);

Escrow withdrawal

Once the deposits have been completed, the seller and winning bidder are able to withdraw their deposits from the auction contract, and their sale proceeds and tokens, respectively, from the escrow contract. For withdrawals, state updates are performed before transfers made in order to prevent reentrancy attacks, one example of the design patterns used in Gloom aimed at avoiding common attacks.

function sellerWithdraw() external payable onlySeller {
  require(bothOk(), 'Escrow is not complete');
  require(withdrawOk, 'Action not authorized now');
  require(address(this).balance >= winningBid, 'Insufficient balance');
  balance -= winningBid;
  (bool success, ) ='');
  require(success, 'Transfer failed');
  emit LogSellerWithdrew(msg.sender, winningBid);

function buyerWithdraw() external onlyBuyer {
  require(bothOk(), 'Escrow is not complete');
  require(withdrawOk, 'Action not authorized now');
  require(IERC20(tokenContractAddress).balanceOf(address(this)) >= tokenAmount, 'Insufficient balance');
  tokenBalance -= tokenAmount;
  require(IERC20(tokenContractAddress).transfer(msg.sender, tokenAmount), 'Transfer failed');
  emit LogBuyerWithdrew(msg.sender, tokenAmount);


I wrote unit tests for the Solidity smart contracts in JavaScript using Truffle's test framework, which builds on top of Mocha and uses Chai for assertions. I added the Truffle Assertions package for testing expected revert conditions (e.g. unauthorized message sender).

Gloom unit tests


On the React frontend, Gloom uses the web3-react package by Uniswap's Noah Zinsmeister to connect with the web3 provider that is injected into the browser (e.g. by MetaMask), putting it into a Context.

// web3Context.js
export default function Web3ContextProvider({ children }) {
  const web3Context = useWeb3React();
  const { activate } = web3Context;
  useEffect(() => {
    const injectedConnector = new InjectedConnector({ supportedChainIds: [42, 1337] });
  }, [activate]);
  return <Web3Context.Provider value={{ web3Context }}>{children}</Web3Context.Provider>;

// AppRoute.js
export default function AppRoute({ exact, path, component: Component }) {
  return (
    <Web3ReactProvider getLibrary={getLibrary}>
            component={() => (
                <Banner />
                <Component />

// App.js
export default function App() {
  return (
      <Head />
      <GlobalStyle />
        <Route exact path='/' component={Home} />
        <AppRoute exact path='/seller' component={SellerDashboard} />
        <AppRoute exact path='/bidder' component={BidderDashboard} />
        <AppRoute path='*' component={NotFound} />
      <ToastContainer />

Hooks & ethers.js

A React webapp will typical fetch data from an API, but a dApp like Gloom pulls data from the blockchain using a library like ethers.js, which can connect to Ethereum nodes through an injected provider (e.g. MetaMask, which connects to a node through the Infura API). Effectively, the smart contracts serve here as the backend.

I also frequently used ethers.js to set up contract event listeners within Effect Hooks to watch for state changes on the blockchain and then update the UI once they fire.

// useWinner.js Hook
export default function useWinner(auctionContract) {
  const { web3Context } = useContext(Web3Context);
  const { active } = web3Context;
  const [winningBid, setWinningBid] = useState(0);
  const [winningBidder, setWinningBidder] = useState('');

  useEffect(() => {
    if (!active || !auctionContract) return;
    const getWinner = async () => {
      const [bidder, bid] = await auctionContract.getWinner();
      if (bidder !== '0x0000000000000000000000000000000000000000') {
  }, [active, auctionContract]);

  return { winningBid, setWinningBid, winningBidder, setWinningBidder };

// SellerPhaseSwitcher.js watches for winner to be declared
export default function SellerPhaseSwitcher({ auctionAddress }) {
  const { web3Context } = useContext(Web3Context);
  const { active } = web3Context;
  const auctionContract = useContractAt(Auction, auctionAddress);
  const { setWinningBid, winningBidder, setWinningBidder } = useWinner(auctionContract);

  useEffect(() => {
    if (!active || !auctionContract) return null;
    auctionContract.once('LogSetWinner', (bidder, bid) => {
      toast.success(`${bidder} won the auction with a bid of ${formatEther(bid)}`);
    return () => auctionContract.removeAllListeners('LogSetWinner');


As the saying goes, "good artists copy, great artists steal." Gloom's neumorphism "soft UI" design is based on code I forked from Marta Mullor and CodingNepal.

// globalStyles.js
import { createGlobalStyle } from 'styled-components/macro';

const GlobalStyle = createGlobalStyle`
  :root {
    --textPrimary: rgba(0, 0, 0, 0.8);
    --textSecondary: rgba(0, 0, 0, 0.60);
    --textDisabled:rgba(0, 0, 0, 0.38);
    --nearWhite: #ffffff73;
    --backgroundPrimary: #dde1e7;
    --gloomBlue: #536791;
    --shadow: rgba(94, 104, 121, 0.288);
    --primary: rgba(188, 0, 45, 1);

// buttonStyles.js
import styled from 'styled-components/macro';

export const Button = styled.button`
  margin: 10px 5px;
  padding: 10px;
  outline: none;
  border: none;
  border-radius: 20px;
  background: var(--backgroundPrimary);
  box-shadow: -3px -3px 5px var(--nearWhite), 3px 3px 3px var(--shadow);
  font-weight: 600;
  font-size: ${props => (props.large ? '1.1em' : '0.8em')};
  transition: 0.1s ease-out;

  color: ${props => ( ? 'var(--primary)' : null)};
  box-shadow: ${props => (props.inactive ? null : '-3px -3px 5px var(--nearWhite), 3px 3px 3px var(--shadow);')};

  &:hover {
    cursor: pointer;
    color: var(--primary);

  &:active {
    color: var(--primary);
    box-shadow: inset -3px -3px 5px var(--nearWhite), inset 3px 3px 3px var(--shadow);
© 2021 — Designed & developed by Marguerite Roth