@animoca/ethereum-contracts-sale
Version:
1,460 lines • 51.7 kB
JavaScript
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});
});
});
});