diff --git a/contracts/SolutionSubmitter.sol b/contracts/SolutionSubmitter.sol new file mode 100644 index 000000000..77a0fbf28 --- /dev/null +++ b/contracts/SolutionSubmitter.sol @@ -0,0 +1,77 @@ +pragma solidity ^0.5.0; + +import "./BatchExchange.sol"; +import "@openzeppelin/contracts/ownership/Ownable.sol"; + +interface GasToken { + function freeUpTo(uint256 value) external returns (uint256 freed); +} + +/** @title SolutionSubmitter - A simple wrapper contract allowing to submit settlements + * to the BatchExchange contract while saving gas via the GST2 or CHI GasToken + */ +contract SolutionSubmitter is Ownable { + BatchExchange public exchange; + GasToken public gasToken; + uint256 public gasPriceThreshold; + + constructor( + BatchExchange exchange_, + GasToken gasToken_, + uint256 gasPriceThreshold_ + ) public { + exchange = exchange_; + setGasToken(gasToken_); + setGasPriceThreshold(gasPriceThreshold_); + } + + function setGasToken(GasToken gasToken_) public onlyOwner { + gasToken = gasToken_; + } + + function setGasPriceThreshold(uint256 gasPriceThreshold_) public onlyOwner { + gasPriceThreshold = gasPriceThreshold_; + } + + /** + * @dev Allow the owner to execute arbitrary code (e.g. transfer out solution fee rewards from BatchExchange) + */ + function execute(address target, bytes calldata data) external onlyOwner returns (bool, bytes memory) { + // solium-disable-next-line security/no-low-level-calls + return target.call(data); + } + + /** + * @dev Wrapper around actual solution submission that uses gas tokens for discounts + */ + function submitSolution( + uint32 batchId, + uint256 claimedObjectiveValue, + address[] memory owners, + uint16[] memory orderIds, + uint128[] memory buyVolumes, + uint128[] memory prices, + uint16[] memory tokenIdsForPrice + ) public gasDiscounted returns (uint256) { + return exchange.submitSolution(batchId, claimedObjectiveValue, owners, orderIds, buyVolumes, prices, tokenIdsForPrice); + } + + /** + * @dev Modifier to invoke original method and freeing up to half the maximum gas refund in gas tokens. + * Logic adjusted from https://1inch-exchange.medium.com/1inch-introduces-chi-gastoken-d0bd5bb0f92b + */ + modifier gasDiscounted { + if (tx.gasprice >= gasPriceThreshold) { + uint256 gasStart = gasleft(); + _; + uint256 gasSpent = 21000 + gasStart - gasleft() + (16 * msg.data.length); + // The refund is 24k per token and since we can refund up to half of the total gas spent, + // we should free one gas token for every 48k gas spent. This doesn't account for the cost + // of freeUpTo itself and this slightly underestimating the amout of tokens to burn. This is + // fine as we cannot account for other refunds coming from the solution submission intself. + gasToken.freeUpTo((gasSpent) / 48000); + } else { + _; + } + } +} diff --git a/package.json b/package.json index 80d5e5b4e..106bdbdc9 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "eslint-plugin-prettier": "^3.1.4", "eslint-plugin-tsdoc": "^0.2.7", "eth-gas-reporter": "^0.2.19", - "ganache-cli": "^6.12.1", + "ganache-cli": "6.12.0", "mocha": "^8.2.0", "prettier": "^2.1.2", "prettier-plugin-solidity": "^1.0.0-alpha.59", diff --git a/test/SolutionSubmitter.spec.ts b/test/SolutionSubmitter.spec.ts new file mode 100644 index 000000000..03c085773 --- /dev/null +++ b/test/SolutionSubmitter.spec.ts @@ -0,0 +1,135 @@ +const ERC20 = artifacts.require("ERC20"); +const GasToken = artifacts.require("GasToken"); +const MockContract = artifacts.require("MockContract"); +const SolutionSubmitter = artifacts.require("SolutionSubmitter"); + +import truffleAssert from "truffle-assertions"; +import { + closeAuction, + makeDeposits, + placeOrders, + setupGenericStableX, +} from "./utilities"; +import { solutionSubmissionParams, basicRingTrade } from "./resources/examples"; + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +contract("SolutionSubmitter", (accounts) => { + const [owner, non_owner] = accounts; + + describe("execute", () => { + it("allows arbitrary functions calls on behalf of instance from owner", async function () { + const instance = await SolutionSubmitter.new( + ZERO_ADDRESS, + ZERO_ADDRESS, + 0, + { from: owner }, + ); + + const mock = await MockContract.new(); + const mockedToken = await ERC20.at(mock.address); + const transfer = mockedToken.contract.methods + .transfer(owner, 100) + .encodeABI(); + + await instance.execute(mock.address, transfer, { from: owner }); + + const invocationCount = await mock.invocationCountForMethod.call( + transfer, + ); + assert.equal(1, invocationCount.toNumber()); + }); + + it("reverts functions calls from non-owner", async function () { + const instance = await SolutionSubmitter.new( + ZERO_ADDRESS, + ZERO_ADDRESS, + 0, + { from: owner }, + ); + const mock = await MockContract.new(); + const mockedToken = await ERC20.at(mock.address); + const transfer = mockedToken.contract.methods + .transfer(owner, 100) + .encodeABI(); + + await truffleAssert.reverts( + instance.execute(mock.address, transfer, { from: non_owner }), + ); + }); + }); + + describe("submit solution", () => { + it("frees gas tokens to get ~50% of estimated gas", async () => { + // Use real BatchExchange so we incur some significant gas costs + const exchange = await setupGenericStableX(3); + await makeDeposits(exchange, accounts, basicRingTrade.deposits); + + const batchId = (await exchange.getCurrentBatchId()).toNumber(); + const orderIds = await placeOrders( + exchange, + accounts, + basicRingTrade.orders, + batchId + 1, + ); + + await closeAuction(exchange); + const solution = solutionSubmissionParams( + basicRingTrade.solutions[0], + accounts, + orderIds, + ); + const { prices, volumes } = solution; + + const gasTokenMock = await MockContract.new(); + const submitter = await SolutionSubmitter.new( + exchange.address, + gasTokenMock.address, + 0, + ); + + const call = submitter.contract.methods.submitSolution( + batchId, + solution.objectiveValue.toString(), + solution.owners, + solution.touchedorderIds, + volumes.map((v) => v.toString()), + prices.map((p) => p.toString()), + solution.tokenIdsForPrice, + ); + const estimate = await call.estimateGas(); + await call.send({ from: accounts[0], gas: estimate }); + + // Each token refunds 24k gas and we can free up to half of the gas used + const expectedTokenFreed = Math.ceil(estimate / 2 / 24000); + + const gasToken = await GasToken.at(gasTokenMock.address); + const freeInvocation = gasToken.contract.methods + .freeUpTo(expectedTokenFreed) + .encodeABI(); + assert.equal( + 1, + ( + await gasTokenMock.invocationCountForMethod.call(freeInvocation) + ).toNumber(), + ); + }); + + it("respects gasThreshold", async () => { + const exchange = await MockContract.new(); + const gasToken = await MockContract.new(); + const instance = await SolutionSubmitter.new( + exchange.address, + gasToken.address, + 100, + { from: owner }, + ); + + await instance.submitSolution(1, 1, [], [], [], [], [], { + gasPrice: 50, + }); + + assert.equal(0, (await gasToken.invocationCount.call()).toNumber()); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 0b0199782..1b7a35b27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4636,7 +4636,16 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= -ganache-cli@^6.11.0, ganache-cli@^6.12.1: +ganache-cli@6.12.0: + version "6.12.0" + resolved "https://registry.yarnpkg.com/ganache-cli/-/ganache-cli-6.12.0.tgz#0cfe3ae2287b2bb036c1ec1fa7360c1ff837535b" + integrity sha512-WV354mOSCbVH+qR609ftpz/1zsZPRsHMaQ4jo9ioBQAkguYNVU5arfgIE0+0daU0Vl9WJ/OMhRyl0XRswd/j9A== + dependencies: + ethereumjs-util "6.2.1" + source-map-support "0.5.12" + yargs "13.2.4" + +ganache-cli@^6.11.0: version "6.12.1" resolved "https://registry.yarnpkg.com/ganache-cli/-/ganache-cli-6.12.1.tgz#148cf6541494ef1691bd68a77e4414981910cb3e" integrity sha512-zoefZLQpQyEJH9jgtVYgM+ENFLAC9iwys07IDCsju2Ieq9KSTLH89RxSP4bhizXKV/h/+qaWpfyCBGWnBfqgIQ==