UNPKG

@animoca/ethereum-contracts-sale

Version:
1,460 lines 51.7 kB
const {network, artifacts, web3} = require('hardhat');
const {BN, balance, ether, time, expectRevert} = require('@openzeppelin/test-helpers');
const {ZeroAddress, Zero, One, Two, Three, Four} = require('@animoca/ethereum-contracts-core').constants;
const {stringToBytes32, bytes32ArrayToBytes, uintToBytes32} = require('../utils/bytes32');
const Fixture = require('@animoca/ethereum-contracts-core/test/utils/fixture');
const UniswapV2Fixture = require('../fixtures/uniswapv2.fixture');

const {shouldBeEqualWithETHDecimalPrecision} = require('@animoca/ethereum-contracts-core/test/utils/weiPrecision');

const {purchasingScenario} = require('../scenarios');

const WETH9 = artifacts.require('WETH9');

const Sale = artifacts.require('UniswapSwapSaleMock');
const ERC20 = artifacts.require('ERC20Mock');
const IERC20 = artifacts.require('node_modules/@animoca/ethereum-contracts-assets/artifacts/contracts/token/ERC20/IERC20.sol:IERC20');

const skusCapacity = One;
const tokensPerSkuCapacity = Four;
const sku = stringToBytes32('sku');
const skuTotalSupply = Three;
const skuMaxQuantityPerPurchase = Two;
const skuNotificationsReceiver = ZeroAddress;

let owner, payoutWallet, purchaser, recipient;

describe('UniswapSwapSale', function () {
  let loadFixture;

  before(async function () {
    let accounts = await web3.eth.getAccounts();

    loadFixture = Fixture.createFixtureLoader(accounts, web3.eth.currentProvider);

    [owner, payoutWallet, purchaser, recipient] = accounts;

    UniswapV2Fixture.reset();
  });

  // uniswapv2 fixture adds `contract` field to each token when it's loaded
  const tokens = {
    WETH: {
      abstraction: WETH9,
      supply: ether('1000'),
    },
    ReferenceToken: {
      abstraction: ERC20,
      supply: ether('1000'),
    },
    TokenA: {
      abstraction: ERC20,
      supply: ether('1000'),
    },
    TokenB: {
      abstraction: ERC20,
      supply: ether('1000'),
    },
    TokenC: {
      abstraction: ERC20,
      supply: ether('1000'),
    },
    TokenD: {
      abstraction: ERC20,
      supply: ether('1000'),
    },
  };

  const tokenPairs = {
    Pair1: ['WETH', 'ReferenceToken'],
    Pair2: ['TokenA', 'ReferenceToken'],
    Pair3: ['TokenB', 'ReferenceToken'],
    Pair4: ['TokenC', 'ReferenceToken'],
  };

  const liquidity = {
    ReferenceToken: {
      amount: new BN('1000000'),
      price: new BN('1000'),
    },
    TokenA: {
      amount: new BN('2000'),
      price: new BN('2'),
    },
    TokenB: {
      amount: new BN('3000000000'),
      price: new BN('3000000'),
    },
    TokenC: {
      amount: new BN('1000000'),
      price: new BN('1000'),
    },
  };

  async function doLoadFixture(params = {}) {
    const fixture = UniswapV2Fixture.get(params.tokens || tokens, params.tokenPairs || tokenPairs);

    this.fixtureData = await loadFixture(fixture);
  }

  async function doAddLiquidity(params = {}) {
    const timestamp = await time.latest();
    const deadline = params.deadline || timestamp.add(time.duration.minutes(5));

    const amountRefTokenMin = params.amountRefTokenMin || Zero;
    const amountTokenMin = params.amountTokenMin || Zero;
    const amountEthMin = params.amountEthMin || Zero;
    const router = this.fixtureData.router;

    const refTokenKey = 'ReferenceToken';
    const refTokenContract = tokens[refTokenKey].contract;
    const refLiquidityData = liquidity[refTokenKey];

    for (const [tokenPairKey, tokenPair] of Object.entries(tokenPairs)) {
      if (tokenPair.length !== 2) {
        throw new Error(`Invalid token pair length: ${tokenPairKey}`);
      }

      const refTokenIndex = tokenPair.indexOf(refTokenKey);

      if (refTokenIndex === -1) {
        throw new Error(`Missing expected reference token in pair: ${tokenPairKey}`);
      }

      const tokenIndex = refTokenIndex === 0 ? 1 : 0;
      const tokenKey = tokenPair[tokenIndex];

      await refTokenContract.approve(router.address, refLiquidityData.amount, {from: refLiquidityData.owner || owner});

      if (tokenKey === 'WETH') {
        await router.addLiquidityETH(
          refTokenContract.address,
          refLiquidityData.amount,
          amountRefTokenMin,
          amountEthMin,
          refLiquidityData.lpTokenRecipient || owner,
          deadline,
          {
            from: refLiquidityData.owner || owner,
            value: refLiquidityData.amount.mul(refLiquidityData.price),
          }
        );
      } else {
        const tokenContract = tokens[tokenKey].contract;
        const liquidityData = liquidity[tokenKey];

        await tokenContract.approve(router.address, liquidityData.amount, {from: liquidityData.owner || owner});

        await router.addLiquidity(
          tokenContract.address,
          refTokenContract.address,
          liquidityData.amount,
          refLiquidityData.amount,
          amountTokenMin,
          amountRefTokenMin,
          liquidityData.lpTokenRecipient || owner,
          deadline,
          {
            from: liquidityData.owner || owner,
          }
        );

        await tokenContract.approve(router.address, Zero, {from: liquidityData.owner || owner});
      }

      await refTokenContract.approve(router.address, Zero, {from: refLiquidityData.owner || owner});
    }
  }

  async function doDeploy(params = {}) {
    const registry = await artifacts.require('ForwarderRegistry').new();
    const forwarder = await artifacts.require('UniversalForwarder').new();
    this.erc20Token = await ERC20.new([owner], [params.erc20TokenSupply || ether('1000')], registry.address, forwarder.address, {from: owner});

    this.contract = await Sale.new(
      params.payoutWallet || payoutWallet,
      params.skusCapacity || skusCapacity,
      params.tokensPerSkuCapacity || tokensPerSkuCapacity,
      params.referenceToken || tokens['ReferenceToken'].contract.address,
      params.router || this.fixtureData.router.address,
      {from: params.owner || owner}
    );
  }

  async function doCreateSku(params = {}) {
    return await this.contract.createSku(
      params.sku || sku,
      params.skuTotalSupply || skuTotalSupply,
      params.skuMaxQuantityPerPurchase || skuMaxQuantityPerPurchase,
      params.skuNotificationsReceiver || skuNotificationsReceiver,
      {from: params.owner || owner}
    );
  }

  async function doUpdateSkuPricing(params = {}) {
    this.ethTokenAddress = await this.contract.TOKEN_ETH();
    this.oraclePrice = await this.contract.PRICE_SWAP_VIA_ORACLE();

    const skuTokens = [
      tokens['ReferenceToken'].contract.address,
      this.ethTokenAddress,
      tokens['TokenA'].contract.address,
      tokens['TokenB'].contract.address,
    ];

    const tokenPrices = [
      liquidity['ReferenceToken'].price, // reference token
      this.oraclePrice, // ETH
      this.oraclePrice, // Token A
      this.oraclePrice,
    ]; // Token B

    return await this.contract.updateSkuPricing(params.sku || sku, params.tokens || skuTokens, params.prices || tokenPrices, {
      from: params.owner || owner,
    });
  }

  async function doStart(params = {}) {
    return await this.contract.start({from: params.owner || owner});
  }

  beforeEach(async function () {
    await doLoadFixture.bind(this)();
  });

  describe('_validation()', function () {
    beforeEach(async function () {
      await doAddLiquidity.bind(this)();
      await doDeploy.bind(this)();
      await doCreateSku.bind(this)();
      await doUpdateSkuPricing.bind(this)();
    });

    it('should revert if the purchase user data is undefined', async function () {
      const userData = '0x';

      await expectRevert(this.contract.callUnderscoreValidation(recipient, this.ethTokenAddress, sku, One, userData), 'Sale: missing user data');
    });

    it('should revert if the purchase user data is missing information', async function () {
      const userData = bytes32ArrayToBytes([uintToBytes32(Zero)]);

      await expectRevert(this.contract.callUnderscoreValidation(recipient, this.ethTokenAddress, sku, One, userData), 'Sale: missing user data');
    });
  });

  describe('_payment()', function () {
    function isEthToken(token, overrides = {}) {
      return token === (overrides.ethTokenAddress || this.ethTokenAddress);
    }

    async function getBalance(token, account, overrides = {}) {
      if (isEthToken.bind(this)(token, overrides)) {
        return await balance.current(account);
      }
      const contract = await IERC20.at(token);
      return await contract.balanceOf(account);
    }

    async function doCallUnderscorePayment(purchase, overrides = {}) {
      const contract = overrides.contract || this.contract;

      const result = await contract.callUnderscorePricing(purchase.recipient, purchase.token, purchase.sku, purchase.quantity, purchase.userData, {
        from: purchase.purchaser,
      });

      const totalPrice = result.totalPrice;
      const pricingData = result.pricingData;

      let amount = overrides.amount || totalPrice;
      let amountVariance = overrides.amountVariance;

      if (!amountVariance) {
        amountVariance = Zero;
      }

      amount = amount.add(amountVariance);

      let etherValue;

      if (isEthToken.bind(this)(purchase.token, overrides)) {
        etherValue = amount;
      } else {
        const erc20Contract = await ERC20.at(purchase.token);
        await erc20Contract.approve(contract.address, amount, {from: purchase.purchaser});
        etherValue = Zero;
      }

      const callUnderscorePayment = contract.callUnderscorePayment(
        purchase.recipient,
        purchase.token,
        purchase.sku,
        purchase.quantity,
        purchase.userData,
        totalPrice,
        pricingData,
        {
          from: purchase.purchaser,
          value: etherValue,
        }
      );

      return {
        callUnderscorePayment,
        totalPrice,
      };
    }

    async function shouldHandlePayment(purchase, overrides = {}) {
      const balanceBefore = await getBalance.bind(this)(purchase.token, purchase.purchaser, overrides);

      const {callUnderscorePayment, totalPrice} = await doCallUnderscorePayment.bind(this)(purchase, overrides);

      const receipt = await callUnderscorePayment;
      const contract = overrides.contract || this.contract;

      if (isEthToken.bind(this)(purchase.token, overrides)) {
        const tx = await web3.eth.getTransaction(receipt.tx);
        const transferredValue = new BN(tx.value);

        if (overrides.amountVariance) {
          transferredValue.should.be.bignumber.equal(totalPrice.add(overrides.amountVariance));
        } else if (overrides.totalPricePrecision) {
          shouldBeEqualWithETHDecimalPrecision(transferredValue, totalPrice, overrides.totalPricePrecision);
        } else {
          transferredValue.should.be.bignumber.equal(totalPrice);
        }
      } else {
        const balanceAfter = await getBalance.bind(this)(purchase.token, purchase.purchaser, overrides);
        const balanceDiff = balanceBefore.sub(balanceAfter);
        if (overrides.totalPricePrecision) {
          shouldBeEqualWithETHDecimalPrecision(balanceDiff, totalPrice, overrides.totalPricePrecision);
        } else {
          balanceDiff.should.be.bignumber.equal(totalPrice);
        }
      }
    }

    async function shouldRevertAndNotHandlePayment(revertMessage, purchase, overrides = {}) {
      const {callUnderscorePayment} = await doCallUnderscorePayment.bind(this)(purchase, overrides);

      if (revertMessage) {
        await expectRevert(callUnderscorePayment, revertMessage);
      } else {
        await expectRevert.unspecified(callUnderscorePayment);
      }
    }

    beforeEach(async function () {
      await doAddLiquidity.bind(this)();
      await doDeploy.bind(this)();
      await doCreateSku.bind(this)();
      await doUpdateSkuPricing.bind(this)();
      await doStart.bind(this)();

      await tokens['TokenA'].contract.transfer(purchaser, ether('1'));
      await tokens['TokenA'].contract.transfer(recipient, ether('1'));
      await tokens['ReferenceToken'].contract.transfer(this.contract.address, ether('1'));

      this.erc20TokenAddress = tokens['TokenA'].contract.address;
    });

    describe('when paying with ETH', function () {
      const quantity = One;

      describe('when the purchaser and the recipient are the same', function () {
        describe('when the payment token max amount to swap does not equal the amount sent', function () {
          it('should revert and not handle payment', async function () {
            const unitPrice = liquidity['ReferenceToken'].price;
            const totalPrice = unitPrice.mul(quantity);

            const fromAmount = await this.contract.callUnderscoreEstimateSwap(
              this.ethTokenAddress,
              tokens['ReferenceToken'].contract.address,
              totalPrice,
              '0x'
            );

            const maxFromAmount = uintToBytes32(fromAmount.subn(1));
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldRevertAndNotHandlePayment.bind(this)(
              'UniswapV2: invalid maxAmountIn',
              {
                purchaser: recipient,
                recipient: recipient,
                token: this.ethTokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {
                amountVariance: new BN(1),
                totalPricePrecision: 14,
              }
            );
          });
        });

        describe('when the payment token max amount to swap is insufficient', function () {
          it('should revert and not handle payment', async function () {
            const unitPrice = liquidity['ReferenceToken'].price;
            const totalPrice = unitPrice.mul(quantity);

            const fromAmount = await this.contract.callUnderscoreEstimateSwap(
              this.ethTokenAddress,
              tokens['ReferenceToken'].contract.address,
              totalPrice,
              '0x'
            );

            const maxFromAmount = uintToBytes32(fromAmount.subn(1));
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldRevertAndNotHandlePayment.bind(this)(
              'Sale: insufficient ETH',
              {
                purchaser: recipient,
                recipient: recipient,
                token: this.ethTokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {
                amountVariance: new BN(-1),
                totalPricePrecision: 14,
              }
            );
          });
        });

        describe('when the payment token max amount to swap is sufficient', function () {
          it('should handle payment', async function () {
            const unitPrice = liquidity['ReferenceToken'].price;
            const totalPrice = unitPrice.mul(quantity);

            const fromAmount = await this.contract.callUnderscoreEstimateSwap(
              this.ethTokenAddress,
              tokens['ReferenceToken'].contract.address,
              totalPrice,
              '0x'
            );

            const maxFromAmount = uintToBytes32(fromAmount);
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldHandlePayment.bind(this)(
              {
                purchaser: recipient,
                recipient: recipient,
                token: this.ethTokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {totalPricePrecision: 14}
            );
          });
        });

        describe('when the payment token max amount to swap is more than sufficient', function () {
          it('should handle payment', async function () {
            const unitPrice = liquidity['ReferenceToken'].price;
            const totalPrice = unitPrice.mul(quantity);

            const fromAmount = await this.contract.callUnderscoreEstimateSwap(
              this.ethTokenAddress,
              tokens['ReferenceToken'].contract.address,
              totalPrice,
              '0x'
            );

            const maxFromAmount = uintToBytes32(fromAmount.addn(1));
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldHandlePayment.bind(this)(
              {
                purchaser: recipient,
                recipient: recipient,
                token: this.ethTokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {
                amountVariance: new BN(1),
                totalPricePrecision: 14,
              }
            );
          });
        });

        describe('when the payment token max amount to swap is the default 0 value (no limit)', function () {
          it('should handle payment', async function () {
            const maxFromAmount = uintToBytes32(Zero);
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldHandlePayment.bind(this)(
              {
                purchaser: recipient,
                recipient: recipient,
                token: this.ethTokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {totalPricePrecision: 14}
            );
          });
        });
      });

      describe('when the purchaser and the recipient are different', function () {
        describe('when the payment token max amount to swap does not equal the amount sent', function () {
          it('should revert and not handle payment', async function () {
            const unitPrice = liquidity['ReferenceToken'].price;
            const totalPrice = unitPrice.mul(quantity);

            const fromAmount = await this.contract.callUnderscoreEstimateSwap(
              this.ethTokenAddress,
              tokens['ReferenceToken'].contract.address,
              totalPrice,
              '0x'
            );

            const maxFromAmount = uintToBytes32(fromAmount.subn(1));
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldRevertAndNotHandlePayment.bind(this)(
              'UniswapV2: invalid maxAmountIn',
              {
                purchaser: purchaser,
                recipient: recipient,
                token: this.ethTokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {
                amountVariance: new BN(1),
                totalPricePrecision: 14,
              }
            );
          });
        });

        describe('when the payment token max amount to swap is insufficient', function () {
          it('should revert and not handle payment', async function () {
            const unitPrice = liquidity['ReferenceToken'].price;
            const totalPrice = unitPrice.mul(quantity);

            const fromAmount = await this.contract.callUnderscoreEstimateSwap(
              this.ethTokenAddress,
              tokens['ReferenceToken'].contract.address,
              totalPrice,
              '0x'
            );

            const maxFromAmount = uintToBytes32(fromAmount.subn(1));
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldRevertAndNotHandlePayment.bind(this)(
              'Sale: insufficient ETH',
              {
                purchaser: purchaser,
                recipient: recipient,
                token: this.ethTokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {
                amountVariance: new BN(-1),
                totalPricePrecision: 14,
              }
            );
          });
        });

        describe('when the payment token max amount to swap is sufficient', function () {
          it('should handle payment', async function () {
            const unitPrice = liquidity['ReferenceToken'].price;
            const totalPrice = unitPrice.mul(quantity);

            const fromAmount = await this.contract.callUnderscoreEstimateSwap(
              this.ethTokenAddress,
              tokens['ReferenceToken'].contract.address,
              totalPrice,
              '0x'
            );

            const maxFromAmount = uintToBytes32(fromAmount);
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldHandlePayment.bind(this)(
              {
                purchaser: purchaser,
                recipient: recipient,
                token: this.ethTokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {totalPricePrecision: 14}
            );
          });
        });

        describe('when the payment token max amount to swap is more than sufficient', function () {
          it('should handle payment', async function () {
            const unitPrice = liquidity['ReferenceToken'].price;
            const totalPrice = unitPrice.mul(quantity);

            const fromAmount = await this.contract.callUnderscoreEstimateSwap(
              this.ethTokenAddress,
              tokens['ReferenceToken'].contract.address,
              totalPrice,
              '0x'
            );

            const maxFromAmount = uintToBytes32(fromAmount.addn(1));
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldHandlePayment.bind(this)(
              {
                purchaser: purchaser,
                recipient: recipient,
                token: this.ethTokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {
                amountVariance: new BN(1),
                totalPricePrecision: 14,
              }
            );
          });
        });

        describe('when the payment token max amount to swap is the maximum amount supported', function () {
          it('should handle payment', async function () {
            const maxFromAmount = uintToBytes32(Zero);
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldHandlePayment.bind(this)(
              {
                purchaser: purchaser,
                recipient: recipient,
                token: this.ethTokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {totalPricePrecision: 14}
            );
          });
        });
      });
    });

    describe('when paying with ERC20', function () {
      const quantity = One;

      describe('when the purchaser and the recipient are the same', function () {
        describe('when the payment token max amount to swap is insufficient', function () {
          it('should revert and not handle payment', async function () {
            const unitPrice = liquidity['ReferenceToken'].price;
            const totalPrice = unitPrice.mul(quantity);

            const fromAmount = await this.contract.callUnderscoreEstimateSwap(
              this.erc20TokenAddress,
              tokens['ReferenceToken'].contract.address,
              totalPrice,
              '0x'
            );

            const maxFromAmount = uintToBytes32(fromAmount.subn(1));
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldRevertAndNotHandlePayment.bind(this)(
              'UniswapV2Router: EXCESSIVE_INPUT_AMOUNT',
              {
                purchaser: recipient,
                recipient: recipient,
                token: this.erc20TokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {}
            );
          });
        });

        describe('when the payment token max amount to swap is sufficient', function () {
          it('should handle payment', async function () {
            const unitPrice = liquidity['ReferenceToken'].price;
            const totalPrice = unitPrice.mul(quantity);

            const fromAmount = await this.contract.callUnderscoreEstimateSwap(
              this.erc20TokenAddress,
              tokens['ReferenceToken'].contract.address,
              totalPrice,
              '0x'
            );

            const maxFromAmount = uintToBytes32(fromAmount);
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldHandlePayment.bind(this)(
              {
                purchaser: recipient,
                recipient: recipient,
                token: this.erc20TokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {}
            );
          });
        });

        describe('when the payment token max amount to swap is more than sufficient', function () {
          it('should handle payment', async function () {
            const unitPrice = liquidity['ReferenceToken'].price;
            const totalPrice = unitPrice.mul(quantity);

            const fromAmount = await this.contract.callUnderscoreEstimateSwap(
              this.erc20TokenAddress,
              tokens['ReferenceToken'].contract.address,
              totalPrice,
              '0x'
            );

            const maxFromAmount = uintToBytes32(fromAmount.addn(1));
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldHandlePayment.bind(this)(
              {
                purchaser: recipient,
                recipient: recipient,
                token: this.erc20TokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {}
            );
          });
        });

        describe('when the payment token max amount to swap is the maximum amount supported', function () {
          it('should handle payment', async function () {
            const maxFromAmount = uintToBytes32(Zero);
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldHandlePayment.bind(this)(
              {
                purchaser: recipient,
                recipient: recipient,
                token: this.erc20TokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {}
            );
          });
        });
      });

      describe('when the purchaser and the recipient are different', function () {
        describe('when the payment token max amount to swap is insufficient', function () {
          it('should revert and not handle payment', async function () {
            const unitPrice = liquidity['ReferenceToken'].price;
            const totalPrice = unitPrice.mul(quantity);

            const fromAmount = await this.contract.callUnderscoreEstimateSwap(
              this.erc20TokenAddress,
              tokens['ReferenceToken'].contract.address,
              totalPrice,
              '0x'
            );

            const maxFromAmount = uintToBytes32(fromAmount.subn(1));
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldRevertAndNotHandlePayment.bind(this)(
              'UniswapV2Router: EXCESSIVE_INPUT_AMOUNT',
              {
                purchaser: purchaser,
                recipient: recipient,
                token: this.erc20TokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {}
            );
          });
        });

        describe('when the payment token max amount to swap is sufficient', function () {
          it('should handle payment', async function () {
            const unitPrice = liquidity['ReferenceToken'].price;
            const totalPrice = unitPrice.mul(quantity);

            const fromAmount = await this.contract.callUnderscoreEstimateSwap(
              this.erc20TokenAddress,
              tokens['ReferenceToken'].contract.address,
              totalPrice,
              '0x'
            );

            const maxFromAmount = uintToBytes32(fromAmount);
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldHandlePayment.bind(this)(
              {
                purchaser: purchaser,
                recipient: recipient,
                token: this.erc20TokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {}
            );
          });
        });

        describe('when the payment token max amount to swap is more than sufficient', function () {
          it('should handle payment', async function () {
            const unitPrice = liquidity['ReferenceToken'].price;
            const totalPrice = unitPrice.mul(quantity);

            const fromAmount = await this.contract.callUnderscoreEstimateSwap(
              this.erc20TokenAddress,
              tokens['ReferenceToken'].contract.address,
              totalPrice,
              '0x'
            );

            const maxFromAmount = uintToBytes32(fromAmount.addn(1));
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldHandlePayment.bind(this)(
              {
                purchaser: purchaser,
                recipient: recipient,
                token: this.erc20TokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {}
            );
          });
        });

        describe('when the payment token max amount to swap is the maximum amount supported', function () {
          it('should handle payment', async function () {
            const maxFromAmount = uintToBytes32(Zero);
            const deadlineDuration = uintToBytes32(Zero);

            const userData = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

            await shouldHandlePayment.bind(this)(
              {
                purchaser: purchaser,
                recipient: recipient,
                token: this.erc20TokenAddress,
                sku: sku,
                quantity: quantity,
                userData: userData,
              },
              {}
            );
          });
        });
      });
    });
  });

  describe('_conversionRate()', function () {
    const userData = bytes32ArrayToBytes([uintToBytes32(Zero), uintToBytes32(Zero)]);

    beforeEach(async function () {
      await doAddLiquidity.bind(this)();
      await doDeploy.bind(this)();
    });

    it('should revert if the source token to convert from is the zero address', async function () {
      await expectRevert(
        this.contract.callUnderscoreConversionRate(ZeroAddress, tokens['TokenA'].contract.address, userData),
        'UniswapV2: zero address'
      );
    });

    it('should revert if the destination token to convert to is the zero address', async function () {
      await expectRevert(
        this.contract.callUnderscoreConversionRate(tokens['TokenA'].contract.address, ZeroAddress, userData),
        'UniswapV2: zero address'
      );
    });

    it('should revert if the source and destination token are the same', async function () {
      await expectRevert(
        this.contract.callUnderscoreConversionRate(tokens['TokenA'].contract.address, tokens['TokenA'].contract.address, userData),
        'UniswapV2: same addresses'
      );
    });

    it('should revert if the source token to convert from does not belong to a token pair', async function () {
      await expectRevert(
        this.contract.callUnderscoreConversionRate(tokens['TokenD'].contract.address, tokens['TokenA'].contract.address, userData),
        'revert'
      );
    });

    it('should revert if the destination token to convert to does not belong to a token pair', async function () {
      await expectRevert(
        this.contract.callUnderscoreConversionRate(tokens['TokenA'].contract.address, tokens['TokenD'].contract.address, userData),
        'revert'
      );
    });

    describe(`should return the correct conversion rates`, function () {
      it('when the source token has a reserve less than the destination token', async function () {
        const fromToken = tokens['TokenA'].contract.address;
        const toToken = tokens['ReferenceToken'].contract.address;

        const actualRate = await this.contract.callUnderscoreConversionRate(fromToken, toToken, userData);

        const reserves = await this.contract.getReserves(fromToken, toToken);

        const expectedRate = reserves.reserveB.mul(new BN(10).pow(new BN(18))).div(reserves.reserveA);

        actualRate.should.be.bignumber.equal(expectedRate);
      });

      it('when the source token has a reserve more than the destination token', async function () {
        const fromToken = tokens['TokenB'].contract.address;
        const toToken = tokens['ReferenceToken'].contract.address;

        const actualRate = await this.contract.callUnderscoreConversionRate(fromToken, toToken, userData);

        const reserves = await this.contract.getReserves(fromToken, toToken);

        const expectedRate = reserves.reserveB.mul(new BN(10).pow(new BN(18))).div(reserves.reserveA);

        actualRate.should.be.bignumber.equal(expectedRate);
      });

      it('when the source token has a reserve equal to the destination token', async function () {
        const fromToken = tokens['TokenC'].contract.address;
        const toToken = tokens['ReferenceToken'].contract.address;

        const actualRate = await this.contract.callUnderscoreConversionRate(fromToken, toToken, userData);

        const reserves = await this.contract.getReserves(fromToken, toToken);

        const expectedRate = reserves.reserveB.mul(new BN(10).pow(new BN(18))).div(reserves.reserveA);

        actualRate.should.be.bignumber.equal(expectedRate);
      });

      it('when the source token is the ETH token', async function () {
        const fromToken = await this.contract.TOKEN_ETH();
        const toToken = tokens['ReferenceToken'].contract.address;

        const actualRate = await this.contract.callUnderscoreConversionRate(fromToken, toToken, userData);

        const reserves = await this.contract.getReserves(fromToken, toToken);

        const expectedRate = reserves.reserveB.mul(new BN(10).pow(new BN(18))).div(reserves.reserveA);

        actualRate.should.be.bignumber.equal(expectedRate);
      });
    });
  });

  describe('_estimateSwap()', function () {
    const userData = bytes32ArrayToBytes([uintToBytes32(Zero), uintToBytes32(Zero)]);

    beforeEach(async function () {
      await doAddLiquidity.bind(this)();
      await doDeploy.bind(this)();
    });

    it('should revert if the source token is the zero address', async function () {
      await expectRevert(
        this.contract.callUnderscoreEstimateSwap(ZeroAddress, tokens['TokenA'].contract.address, liquidity['ReferenceToken'].price, userData),
        'UniswapV2Library: ZERO_ADDRESS'
      );
    });

    it('should revert if the destination token is the zero address', async function () {
      await expectRevert(
        this.contract.callUnderscoreEstimateSwap(tokens['TokenA'].contract.address, ZeroAddress, liquidity['ReferenceToken'].price, userData),
        'UniswapV2Library: ZERO_ADDRESS'
      );
    });

    it('should revert if the source and destination token are the same', async function () {
      await expectRevert(
        this.contract.callUnderscoreEstimateSwap(
          tokens['TokenA'].contract.address,
          tokens['TokenA'].contract.address,
          liquidity['ReferenceToken'].price,
          userData
        ),
        'UniswapV2Library: IDENTICAL_ADDRESSES'
      );
    });

    it('should revert if the source token does not belong to a token pair', async function () {
      await expectRevert(
        this.contract.callUnderscoreEstimateSwap(
          tokens['TokenD'].contract.address,
          tokens['TokenA'].contract.address,
          liquidity['ReferenceToken'].price,
          userData
        ),
        'revert'
      );
    });

    it('should revert if the destination token does not belong to a token pair', async function () {
      await expectRevert(
        this.contract.callUnderscoreEstimateSwap(
          tokens['TokenA'].contract.address,
          tokens['TokenD'].contract.address,
          liquidity['ReferenceToken'].price,
          userData
        ),
        'revert'
      );
    });

    describe(`should return a swap estimate`, function () {
      it('when the source token is an ERC20', async function () {
        const fromToken = tokens['TokenA'].contract.address;
        const toToken = tokens['ReferenceToken'].contract.address;

        const fromAmount = await this.contract.callUnderscoreEstimateSwap(fromToken, toToken, liquidity['ReferenceToken'].price, userData);

        // TODO replace with computed estimation
        fromAmount.should.be.bignumber.not.equal(Zero);
      });

      it('when the source token is ETH', async function () {
        const fromToken = await this.contract.TOKEN_ETH();
        const toToken = tokens['ReferenceToken'].contract.address;

        const fromAmount = await this.contract.callUnderscoreEstimateSwap(fromToken, toToken, liquidity['ReferenceToken'].price, userData);

        // TODO replace with computed estimation
        fromAmount.should.be.bignumber.not.equal(Zero);
      });
    });
  });

  describe('_swap()', function () {
    function isEthToken(token, overrides = {}) {
      return token === (overrides.ethTokenAddress || this.ethTokenAddress);
    }

    async function doCallUnderscoreSwap(swap, overrides = {}) {
      const contract = overrides.contract || this.contract;

      let amount = overrides.amount || swap.fromAmount;
      let amountVariance = overrides.amountVariance;

      if (!amountVariance) {
        amountVariance = Zero;
      }

      amount = amount.add(amountVariance);

      let etherValue;

      if (isEthToken.bind(this)(swap.fromToken, overrides)) {
        etherValue = amount;
      } else {
        const erc20Contract = await IERC20.at(swap.fromToken);
        await erc20Contract.approve(this.contract.address, amount, {from: purchaser});
        etherValue = Zero;
      }

      const callUnderscoreSwap = contract.callUnderscoreSwap(swap.fromToken, amount, swap.toToken, swap.toAmount, swap.data, {
        from: purchaser,
        value: etherValue,
      });

      return {callUnderscoreSwap};
    }

    async function shouldHandleSwap(swap, overrides = {}) {
      const contract = overrides.contract || this.contract;

      const {callUnderscoreSwap} = await doCallUnderscoreSwap.bind(this)(swap, overrides);

      const receipt = await callUnderscoreSwap;

      const events = await this.contract.getPastEvents('UnderscoreSwapResult', {fromBlock: 'latest'});

      const actualFromAmount = events[0].args.fromAmount;

      actualFromAmount.should.be.bignumber.equal(swap.fromAmount);
    }

    async function shouldRevertAndNotHandleSwap(revertMessage, swap, overrides = {}) {
      const {callUnderscoreSwap} = await doCallUnderscoreSwap.bind(this)(swap, overrides);

      if (revertMessage) {
        await expectRevert(callUnderscoreSwap, revertMessage);
      } else {
        await expectRevert.unspecified(callUnderscoreSwap);
      }
    }

    beforeEach(async function () {
      await doAddLiquidity.bind(this)();
      await doDeploy.bind(this)();
      await doCreateSku.bind(this)();

      this.ethTokenAddress = await this.contract.TOKEN_ETH();

      await tokens['TokenA'].contract.transfer(purchaser, ether('1'));

      this.erc20TokenAddress = tokens['TokenA'].contract.address;
    });

    describe('when swapping ETH', function () {
      describe('when the max amount to swap does not equal the amount sent', function () {
        it('should revert and not handle swap', async function () {
          const fromToken = this.ethTokenAddress;
          const toToken = tokens['ReferenceToken'].contract.address;
          const toAmount = liquidity['ReferenceToken'].price;

          const fromAmount = await this.contract.callUnderscoreEstimateSwap(fromToken, toToken, toAmount, '0x');

          const maxFromAmount = uintToBytes32(fromAmount.subn(1));
          const deadlineDuration = uintToBytes32(Zero);

          const data = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

          await shouldRevertAndNotHandleSwap.bind(this)(
            'UniswapV2: invalid maxAmountIn',
            {
              fromToken: fromToken,
              fromAmount: fromAmount,
              toToken: toToken,
              toAmount: toAmount,
              data: data,
            },
            {
              amountVariance: new BN(1),
            }
          );
        });
      });

      describe('when the amount to swap is insufficient', function () {
        it('should revert and not handle swap', async function () {
          const fromToken = this.ethTokenAddress;
          const toToken = tokens['ReferenceToken'].contract.address;
          const toAmount = liquidity['ReferenceToken'].price;

          const fromAmount = await this.contract.callUnderscoreEstimateSwap(fromToken, toToken, toAmount, '0x');

          const maxFromAmount = uintToBytes32(fromAmount.subn(1));
          const deadlineDuration = uintToBytes32(Zero);

          const data = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

          await shouldRevertAndNotHandleSwap.bind(this)(
            'UniswapV2Router: EXCESSIVE_INPUT_AMOUNT',
            {
              fromToken: fromToken,
              fromAmount: fromAmount,
              toToken: toToken,
              toAmount: toAmount,
              data: data,
            },
            {
              amountVariance: new BN(-1),
            }
          );
        });
      });

      describe('when the amount to swap is sufficient', function () {
        it('should handle swap', async function () {
          const fromToken = this.ethTokenAddress;
          const toToken = tokens['ReferenceToken'].contract.address;
          const toAmount = liquidity['ReferenceToken'].price;

          const fromAmount = await this.contract.callUnderscoreEstimateSwap(fromToken, toToken, toAmount, '0x');

          const maxFromAmount = uintToBytes32(fromAmount);
          const deadlineDuration = uintToBytes32(Zero);

          const data = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

          await shouldHandleSwap.bind(this)(
            {
              fromToken: fromToken,
              fromAmount: fromAmount,
              toToken: toToken,
              toAmount: toAmount,
              data: data,
            },
            {}
          );
        });
      });

      describe('when the amount to swap is more than sufficient', function () {
        it('should handle swap', async function () {
          const fromToken = this.ethTokenAddress;
          const toToken = tokens['ReferenceToken'].contract.address;
          const toAmount = liquidity['ReferenceToken'].price;

          const fromAmount = await this.contract.callUnderscoreEstimateSwap(fromToken, toToken, toAmount, '0x');

          const maxFromAmount = uintToBytes32(fromAmount.addn(1));
          const deadlineDuration = uintToBytes32(Zero);

          const data = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

          await shouldHandleSwap.bind(this)(
            {
              fromToken: fromToken,
              fromAmount: fromAmount,
              toToken: toToken,
              toAmount: toAmount,
              data: data,
            },
            {
              amountVariance: new BN(1),
            }
          );
        });
      });

      describe('when the amount to swap is the maximum amount supported', function () {
        it('should handle swap', async function () {
          const fromToken = this.ethTokenAddress;
          const toToken = tokens['ReferenceToken'].contract.address;
          const toAmount = liquidity['ReferenceToken'].price;

          const fromAmount = await this.contract.callUnderscoreEstimateSwap(fromToken, toToken, toAmount, '0x');

          const maxFromAmount = uintToBytes32(Zero);
          const deadlineDuration = uintToBytes32(Zero);

          const data = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

          await shouldHandleSwap.bind(this)(
            {
              fromToken: fromToken,
              fromAmount: fromAmount,
              toToken: toToken,
              toAmount: toAmount,
              data: data,
            },
            {}
          );
        });
      });
    });

    describe('when swapping ERC20', function () {
      describe('when the amount to swap is insufficient', function () {
        it('should revert and not handle swap', async function () {
          const fromToken = this.erc20TokenAddress;
          const toToken = tokens['ReferenceToken'].contract.address;
          const toAmount = liquidity['ReferenceToken'].price;

          const fromAmount = await this.contract.callUnderscoreEstimateSwap(fromToken, toToken, toAmount, '0x');

          const maxFromAmount = uintToBytes32(fromAmount.subn(1));
          const deadlineDuration = uintToBytes32(Zero);

          const data = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

          await shouldRevertAndNotHandleSwap.bind(this)(
            'UniswapV2Router: EXCESSIVE_INPUT_AMOUNT',
            {
              fromToken: fromToken,
              fromAmount: fromAmount,
              toToken: toToken,
              toAmount: toAmount,
              data: data,
            },
            {
              amountVariance: new BN(-1),
            }
          );
        });
      });

      describe('when the amount to swap is sufficient', function () {
        it('should handle swap', async function () {
          const fromToken = this.erc20TokenAddress;
          const toToken = tokens['ReferenceToken'].contract.address;
          const toAmount = liquidity['ReferenceToken'].price;

          const fromAmount = await this.contract.callUnderscoreEstimateSwap(fromToken, toToken, toAmount, '0x');

          const maxFromAmount = uintToBytes32(fromAmount);
          const deadlineDuration = uintToBytes32(Zero);

          const data = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

          await shouldHandleSwap.bind(this)(
            {
              fromToken: fromToken,
              fromAmount: fromAmount,
              toToken: toToken,
              toAmount: toAmount,
              data: data,
            },
            {}
          );
        });
      });

      describe('when the amount to swap is more than sufficient', function () {
        it('should handle swap', async function () {
          const fromToken = this.erc20TokenAddress;
          const toToken = tokens['ReferenceToken'].contract.address;
          const toAmount = liquidity['ReferenceToken'].price;

          const fromAmount = await this.contract.callUnderscoreEstimateSwap(fromToken, toToken, toAmount, '0x');

          const maxFromAmount = uintToBytes32(fromAmount.addn(1));
          const deadlineDuration = uintToBytes32(Zero);

          const data = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

          await shouldHandleSwap.bind(this)(
            {
              fromToken: fromToken,
              fromAmount: fromAmount,
              toToken: toToken,
              toAmount: toAmount,
              data: data,
            },
            {amountVariance: new BN(1)}
          );
        });
      });

      describe('when the amount to swap is the maximum amount supported', function () {
        it('should handle swap', async function () {
          const fromToken = this.erc20TokenAddress;
          const toToken = tokens['ReferenceToken'].contract.address;
          const toAmount = liquidity['ReferenceToken'].price;

          const fromAmount = await this.contract.callUnderscoreEstimateSwap(fromToken, toToken, toAmount, '0x');

          const maxFromAmount = uintToBytes32(Zero);
          const deadlineDuration = uintToBytes32(Zero);

          const data = bytes32ArrayToBytes([maxFromAmount, deadlineDuration]);

          await shouldHandleSwap.bind(this)(
            {
              fromToken: fromToken,
              fromAmount: fromAmount,
              toToken: toToken,
              toAmount: toAmount,
              data: data,
            },
            {}
          );
        });
      });
    });
  });

  describe('scenarios', function () {
    beforeEach(async function () {
      await doAddLiquidity.bind(this)();
      await doDeploy.bind(this)();
      await doCreateSku.bind(this)();
      await doUpdateSkuPricing.bind(this)();
      await doStart.bind(this)();
    });

    describe('purchasing', function () {
      const userData = bytes32ArrayToBytes([uintToBytes32(Zero), uintToBytes32(Zero)]);

      beforeEach(async function () {
        this.erc20TokenAddress = tokens['TokenA'].contract.address;

        const erc20TokenContract = await ERC20.at(this.erc20TokenAddress);
        await erc20TokenContract.transfer(purchaser, ether('1'));
        await erc20TokenContract.transfer(recipient, ether('1'));

        await this.contract.addEth({
          from: owner,
          value: ether('1'),
        });

        await tokens['ReferenceToken'].contract.transfer(this.contract.address, ether('1'));

        this.purchaser = purchaser;
        this.recipient = recipient;
      });

      // This scenario requires an explicit total price precision set
      // because the shouldPurchaseFor() behavior relies on the estimate
      // purchase total amount, which is not always accurate to what was
      // actually spent in payment. This is due solely to the fact that
      // the Oracle swap produces an approximate exchange rate.
      purchasingScenario(sku, userData, {totalPricePrecision: 14});
    });
  });
});