From 793d876786f5aa28b6afefa8e5c67115116fd971 Mon Sep 17 00:00:00 2001 From: Felix Leupold Date: Fri, 20 Nov 2020 17:43:36 +0100 Subject: [PATCH] Gas token solution submission (#1045) This PR adds a wrapper contract around solution submissions that allows burning an appropriate amount of [gas tokens](https://1inch-exchange.medium.com/1inch-introduces-chi-gastoken-d0bd5bb0f92b) to offset the cost of settling an auction. It's using the same signature for settlement as batch exchange but wraps the call into a modifier taken from the block article above (slightly adjusted to not use `freeFrom` as this is more expensive than if the wrapping contract actually had gas token balance). It allows setting a threshold below which gas tokens would not be used (as it might not be worth burning tokens in times of loww gas prices) In order to allow future withdrawl of fee rewards, it also exposes an "execute" method that can make arbitrary calls by the owner (by default the account deploying the contract). Submit solution itself is called permissionlessly by any account. ### Test Plan Unit tests, once reviewed I will deploy on mainnet and run some "simulations" on how previous solution submissions would have performed before making the solver point at this contract instead of the real exchange. --- contracts/SolutionSubmitter.sol | 77 ++++++++++++++++++ package.json | 2 +- test/SolutionSubmitter.spec.ts | 135 ++++++++++++++++++++++++++++++++ yarn.lock | 11 ++- 4 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 contracts/SolutionSubmitter.sol create mode 100644 test/SolutionSubmitter.spec.ts 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==