From 17b00e51d8690beef7d160c543e57b057397fbcc Mon Sep 17 00:00:00 2001 From: Brendan Chou Date: Tue, 20 Aug 2019 17:00:44 -0700 Subject: [PATCH 1/7] add concluder contract --- .soliumignore | 1 + contracts/external/traders/Expiry.sol | 198 ++++++---- contracts/external/traders/ExpiryOld.sol | 477 +++++++++++++++++++++++ 3 files changed, 592 insertions(+), 84 deletions(-) create mode 100644 contracts/external/traders/ExpiryOld.sol diff --git a/.soliumignore b/.soliumignore index e0f4d8e3..1465fcfa 100644 --- a/.soliumignore +++ b/.soliumignore @@ -1,3 +1,4 @@ node_modules +contracts/external/traders/Expiry.sol contracts/testing contracts/Migrations.sol diff --git a/contracts/external/traders/Expiry.sol b/contracts/external/traders/Expiry.sol index bde4a8bf..05b4a163 100644 --- a/contracts/external/traders/Expiry.sol +++ b/contracts/external/traders/Expiry.sol @@ -34,19 +34,18 @@ import { OnlySolo } from "../helpers/OnlySolo.sol"; /** - * @title Expiry + * @title ExpiryV2 * @author dYdX * - * Sets the negative balance for an account to expire at a certain time. This allows any other - * account to repay that negative balance after expiry using any positive balance in the same - * account. The arbitrage incentive is the same as liquidation in the base protocol. + * Expiry contract that also allows approved senders to set expiry to be 28 days in the future. */ -contract Expiry is +contract ExpiryV2 is Ownable, OnlySolo, ICallee, IAutoTrader { + using Math for uint256; using SafeMath for uint32; using SafeMath for uint256; using Types for Types.Par; @@ -56,6 +55,26 @@ contract Expiry is bytes32 constant FILE = "Expiry"; + // ============ Enums ============ + + enum CallFunctionType { + SetExpiry, + SetApproval + } + + // ============ Structs ============ + + struct SetExpiryArg { + Account.Info account; + uint256 marketId; + uint32 timeDelta; + } + + struct SetApprovalArg { + address sender; + uint32 minTimeDelta; + } + // ============ Events ============ event ExpirySet( @@ -69,11 +88,20 @@ contract Expiry is uint256 expiryRampTime ); + event LogSenderApproved( + address approver, + address sender, + uint32 minTimeDelta + ); + // ============ Storage ============ // owner => number => market => time mapping (address => mapping (uint256 => mapping (uint256 => uint32))) g_expiries; + // owner => sender => minimum time delta + mapping (address => mapping (address => uint32)) public g_approvedSender; + // time over which the liquidation ratio goes from zero to maximum uint256 public g_expiryRampTime; @@ -101,6 +129,17 @@ contract Expiry is g_expiryRampTime = newExpiryRampTime; } + // ============ Approval Functions ============ + + function approveSender( + address sender, + uint32 minTimeDelta + ) + external + { + setApproval(msg.sender, sender, minTimeDelta); + } + // ============ Getters ============ function getExpiry( @@ -154,17 +193,12 @@ contract Expiry is public onlySolo(msg.sender) { - ( - uint256 marketId, - uint32 expiryTime - ) = parseCallArgs(data); - - // don't set expiry time for accounts with positive balance - if (expiryTime != 0 && !SOLO_MARGIN.getAccountPar(account, marketId).isNegative()) { - return; + CallFunctionType callType = abi.decode(data, (CallFunctionType)); + if (callType == CallFunctionType.SetExpiry) { + callFunctionSetExpiry(account.owner, data); + } else { + callFunctionSetApproval(account.owner, data); } - - setExpiry(account, marketId, expiryTime); } function getTradeCost( @@ -191,10 +225,7 @@ contract Expiry is }); } - ( - uint256 owedMarketId, - uint32 maxExpiry - ) = parseTradeArgs(data); + (uint256 owedMarketId, uint256 maxExpiry) = abi.decode(data, (uint256, uint256)); uint32 expiry = getExpiry(makerAccount, owedMarketId); @@ -234,6 +265,59 @@ contract Expiry is // ============ Private Functions ============ + function callFunctionSetExpiry( + address sender, + bytes memory data + ) + private + { + ( + CallFunctionType callType, + SetExpiryArg[] memory expiries + ) = abi.decode(data, (CallFunctionType, SetExpiryArg[])); + + assert(callType == CallFunctionType.SetExpiry); + + for (uint256 i = 0; i < expiries.length; i++) { + SetExpiryArg memory exp = expiries[i]; + uint32 timeDelta = exp.timeDelta; + if (exp.account.owner != sender) { + uint32 minApprovedTimeDelta = g_approvedSender[exp.account.owner][sender]; + if (minApprovedTimeDelta == 0) { + // don't do anything if sender is not approved + continue; + } else { + // bound the time by the minimum approved timeDelta + timeDelta = Math.max(minApprovedTimeDelta, exp.timeDelta).to32(); + } + } + + // if timeDelta is zero, interpret it as unset expiry + if ( + timeDelta > 0 && + SOLO_MARGIN.getAccountPar(exp.account, exp.marketId).isNegative() + ) { + setExpiry(exp.account, exp.marketId, Time.currentTime().add(timeDelta).to32()); + } else { + setExpiry(exp.account, exp.marketId, 0); + } + } + } + + function callFunctionSetApproval( + address sender, + bytes memory data + ) + private + { + ( + CallFunctionType callType, + SetApprovalArg memory approvalArg + ) = abi.decode(data, (CallFunctionType, SetApprovalArg)); + assert(callType == CallFunctionType.SetApproval); + setApproval(sender, approvalArg.sender, approvalArg.minTimeDelta); + } + function getTradeCostInternal( uint256 inputMarketId, uint256 outputMarketId, @@ -336,7 +420,6 @@ contract Expiry is private { g_expiries[account.owner][account.number][marketId] = time; - emit ExpirySet( account.owner, account.number, @@ -345,6 +428,17 @@ contract Expiry is ); } + function setApproval( + address approver, + address sender, + uint32 minTimeDelta + ) + private + { + g_approvedSender[approver][sender] = minTimeDelta; + emit LogSenderApproved(approver, sender, minTimeDelta); + } + function heldWeiToOwedWei( Types.Wei memory heldWei, uint256 heldMarketId, @@ -410,68 +504,4 @@ contract Expiry is value: heldAmount }); } - - function parseCallArgs( - bytes memory data - ) - private - pure - returns ( - uint256, - uint32 - ) - { - Require.that( - data.length == 64, - FILE, - "Call data invalid length", - data.length - ); - - uint256 marketId; - uint256 rawExpiry; - - /* solium-disable-next-line security/no-inline-assembly */ - assembly { - marketId := mload(add(data, 32)) - rawExpiry := mload(add(data, 64)) - } - - return ( - marketId, - Math.to32(rawExpiry) - ); - } - - function parseTradeArgs( - bytes memory data - ) - private - pure - returns ( - uint256, - uint32 - ) - { - Require.that( - data.length == 64, - FILE, - "Trade data invalid length", - data.length - ); - - uint256 owedMarketId; - uint256 rawExpiry; - - /* solium-disable-next-line security/no-inline-assembly */ - assembly { - owedMarketId := mload(add(data, 32)) - rawExpiry := mload(add(data, 64)) - } - - return ( - owedMarketId, - Math.to32(rawExpiry) - ); - } } diff --git a/contracts/external/traders/ExpiryOld.sol b/contracts/external/traders/ExpiryOld.sol new file mode 100644 index 00000000..449b9fcf --- /dev/null +++ b/contracts/external/traders/ExpiryOld.sol @@ -0,0 +1,477 @@ +/* + + Copyright 2019 dYdX Trading Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +pragma solidity 0.5.7; +pragma experimental ABIEncoderV2; + +import { SafeMath } from "openzeppelin-solidity/contracts/math/SafeMath.sol"; +import { Ownable } from "openzeppelin-solidity/contracts/ownership/Ownable.sol"; +import { IAutoTrader } from "../../protocol/interfaces/IAutoTrader.sol"; +import { ICallee } from "../../protocol/interfaces/ICallee.sol"; +import { Account } from "../../protocol/lib/Account.sol"; +import { Decimal } from "../../protocol/lib/Decimal.sol"; +import { Math } from "../../protocol/lib/Math.sol"; +import { Monetary } from "../../protocol/lib/Monetary.sol"; +import { Require } from "../../protocol/lib/Require.sol"; +import { Time } from "../../protocol/lib/Time.sol"; +import { Types } from "../../protocol/lib/Types.sol"; +import { OnlySolo } from "../helpers/OnlySolo.sol"; + + +/** + * @title ExpiryOld + * @author dYdX + * + * Sets the negative balance for an account to expire at a certain time. This allows any other + * account to repay that negative balance after expiry using any positive balance in the same + * account. The arbitrage incentive is the same as liquidation in the base protocol. + */ +contract ExpiryOld is + Ownable, + OnlySolo, + ICallee, + IAutoTrader +{ + using SafeMath for uint32; + using SafeMath for uint256; + using Types for Types.Par; + using Types for Types.Wei; + + // ============ Constants ============ + + bytes32 constant FILE = "Expiry"; + + // ============ Events ============ + + event ExpirySet( + address owner, + uint256 number, + uint256 marketId, + uint32 time + ); + + event LogExpiryRampTimeSet( + uint256 expiryRampTime + ); + + // ============ Storage ============ + + // owner => number => market => time + mapping (address => mapping (uint256 => mapping (uint256 => uint32))) g_expiries; + + // time over which the liquidation ratio goes from zero to maximum + uint256 public g_expiryRampTime; + + // ============ Constructor ============ + + constructor ( + address soloMargin, + uint256 expiryRampTime + ) + public + OnlySolo(soloMargin) + { + g_expiryRampTime = expiryRampTime; + } + + // ============ Owner Functions ============ + + function ownerSetExpiryRampTime( + uint256 newExpiryRampTime + ) + external + onlyOwner + { + emit LogExpiryRampTimeSet(newExpiryRampTime); + g_expiryRampTime = newExpiryRampTime; + } + + // ============ Getters ============ + + function getExpiry( + Account.Info memory account, + uint256 marketId + ) + public + view + returns (uint32) + { + return g_expiries[account.owner][account.number][marketId]; + } + + function getSpreadAdjustedPrices( + uint256 heldMarketId, + uint256 owedMarketId, + uint32 expiry + ) + public + view + returns ( + Monetary.Price memory, + Monetary.Price memory + ) + { + Decimal.D256 memory spread = SOLO_MARGIN.getLiquidationSpreadForPair( + heldMarketId, + owedMarketId + ); + + uint256 expiryAge = Time.currentTime().sub(expiry); + + if (expiryAge < g_expiryRampTime) { + spread.value = Math.getPartial(spread.value, expiryAge, g_expiryRampTime); + } + + Monetary.Price memory heldPrice = SOLO_MARGIN.getMarketPrice(heldMarketId); + Monetary.Price memory owedPrice = SOLO_MARGIN.getMarketPrice(owedMarketId); + owedPrice.value = owedPrice.value.add(Decimal.mul(owedPrice.value, spread)); + + return (heldPrice, owedPrice); + } + + // ============ Only-Solo Functions ============ + + function callFunction( + address /* sender */, + Account.Info memory account, + bytes memory data + ) + public + onlySolo(msg.sender) + { + ( + uint256 marketId, + uint32 expiryTime + ) = parseCallArgs(data); + + // don't set expiry time for accounts with positive balance + if (expiryTime != 0 && !SOLO_MARGIN.getAccountPar(account, marketId).isNegative()) { + return; + } + + setExpiry(account, marketId, expiryTime); + } + + function getTradeCost( + uint256 inputMarketId, + uint256 outputMarketId, + Account.Info memory makerAccount, + Account.Info memory /* takerAccount */, + Types.Par memory oldInputPar, + Types.Par memory newInputPar, + Types.Wei memory inputWei, + bytes memory data + ) + public + onlySolo(msg.sender) + returns (Types.AssetAmount memory) + { + // return zero if input amount is zero + if (inputWei.isZero()) { + return Types.AssetAmount({ + sign: true, + denomination: Types.AssetDenomination.Par, + ref: Types.AssetReference.Delta, + value: 0 + }); + } + + ( + uint256 owedMarketId, + uint32 maxExpiry + ) = parseTradeArgs(data); + + uint32 expiry = getExpiry(makerAccount, owedMarketId); + + // validate expiry + Require.that( + expiry != 0, + FILE, + "Expiry not set", + makerAccount.owner, + makerAccount.number, + owedMarketId + ); + Require.that( + expiry <= Time.currentTime(), + FILE, + "Borrow not yet expired", + expiry + ); + Require.that( + expiry <= maxExpiry, + FILE, + "Expiry past maxExpiry", + expiry + ); + + return getTradeCostInternal( + inputMarketId, + outputMarketId, + makerAccount, + oldInputPar, + newInputPar, + inputWei, + owedMarketId, + expiry + ); + } + + // ============ Private Functions ============ + + function getTradeCostInternal( + uint256 inputMarketId, + uint256 outputMarketId, + Account.Info memory makerAccount, + Types.Par memory oldInputPar, + Types.Par memory newInputPar, + Types.Wei memory inputWei, + uint256 owedMarketId, + uint32 expiry + ) + private + returns (Types.AssetAmount memory) + { + Types.AssetAmount memory output; + Types.Wei memory maxOutputWei = SOLO_MARGIN.getAccountWei(makerAccount, outputMarketId); + + if (inputWei.isPositive()) { + Require.that( + inputMarketId == owedMarketId, + FILE, + "inputMarket mismatch", + inputMarketId + ); + Require.that( + !newInputPar.isPositive(), + FILE, + "Borrows cannot be overpaid", + newInputPar.value + ); + assert(oldInputPar.isNegative()); + Require.that( + maxOutputWei.isPositive(), + FILE, + "Collateral must be positive", + outputMarketId, + maxOutputWei.value + ); + output = owedWeiToHeldWei( + inputWei, + outputMarketId, + inputMarketId, + expiry + ); + + // clear expiry if borrow is fully repaid + if (newInputPar.isZero()) { + setExpiry(makerAccount, owedMarketId, 0); + } + } else { + Require.that( + outputMarketId == owedMarketId, + FILE, + "outputMarket mismatch", + outputMarketId + ); + Require.that( + !newInputPar.isNegative(), + FILE, + "Collateral cannot be overused", + newInputPar.value + ); + assert(oldInputPar.isPositive()); + Require.that( + maxOutputWei.isNegative(), + FILE, + "Borrows must be negative", + outputMarketId, + maxOutputWei.value + ); + output = heldWeiToOwedWei( + inputWei, + inputMarketId, + outputMarketId, + expiry + ); + + // clear expiry if borrow is fully repaid + if (output.value == maxOutputWei.value) { + setExpiry(makerAccount, owedMarketId, 0); + } + } + + Require.that( + output.value <= maxOutputWei.value, + FILE, + "outputMarket too small", + output.value, + maxOutputWei.value + ); + assert(output.sign != maxOutputWei.sign); + + return output; + } + + function setExpiry( + Account.Info memory account, + uint256 marketId, + uint32 time + ) + private + { + g_expiries[account.owner][account.number][marketId] = time; + + emit ExpirySet( + account.owner, + account.number, + marketId, + time + ); + } + + function heldWeiToOwedWei( + Types.Wei memory heldWei, + uint256 heldMarketId, + uint256 owedMarketId, + uint32 expiry + ) + private + view + returns (Types.AssetAmount memory) + { + ( + Monetary.Price memory heldPrice, + Monetary.Price memory owedPrice + ) = getSpreadAdjustedPrices( + heldMarketId, + owedMarketId, + expiry + ); + + uint256 owedAmount = Math.getPartialRoundUp( + heldWei.value, + heldPrice.value, + owedPrice.value + ); + + return Types.AssetAmount({ + sign: true, + denomination: Types.AssetDenomination.Wei, + ref: Types.AssetReference.Delta, + value: owedAmount + }); + } + + function owedWeiToHeldWei( + Types.Wei memory owedWei, + uint256 heldMarketId, + uint256 owedMarketId, + uint32 expiry + ) + private + view + returns (Types.AssetAmount memory) + { + ( + Monetary.Price memory heldPrice, + Monetary.Price memory owedPrice + ) = getSpreadAdjustedPrices( + heldMarketId, + owedMarketId, + expiry + ); + + uint256 heldAmount = Math.getPartial( + owedWei.value, + owedPrice.value, + heldPrice.value + ); + + return Types.AssetAmount({ + sign: false, + denomination: Types.AssetDenomination.Wei, + ref: Types.AssetReference.Delta, + value: heldAmount + }); + } + + function parseCallArgs( + bytes memory data + ) + private + pure + returns ( + uint256, + uint32 + ) + { + Require.that( + data.length == 64, + FILE, + "Call data invalid length", + data.length + ); + + uint256 marketId; + uint256 rawExpiry; + + /* solium-disable-next-line security/no-inline-assembly */ + assembly { + marketId := mload(add(data, 32)) + rawExpiry := mload(add(data, 64)) + } + + return ( + marketId, + Math.to32(rawExpiry) + ); + } + + function parseTradeArgs( + bytes memory data + ) + private + pure + returns ( + uint256, + uint32 + ) + { + Require.that( + data.length == 64, + FILE, + "Trade data invalid length", + data.length + ); + + uint256 owedMarketId; + uint256 rawExpiry; + + /* solium-disable-next-line security/no-inline-assembly */ + assembly { + owedMarketId := mload(add(data, 32)) + rawExpiry := mload(add(data, 64)) + } + + return ( + owedMarketId, + Math.to32(rawExpiry) + ); + } +} From 4ee37de3810bdc35d6fac43765e38d278a094f2c Mon Sep 17 00:00:00 2001 From: Brendan Chou Date: Thu, 22 Aug 2019 16:26:54 -0700 Subject: [PATCH 2/7] move to expiryv2 --- __tests__/traders/ExpiryV2.test.ts | 937 ++++++++++++++++++ contracts/external/traders/Expiry.sol | 198 ++-- .../traders/{ExpiryOld.sol => ExpiryV2.sol} | 200 ++-- src/lib/Contracts.ts | 6 + src/modules/ExpiryV2.ts | 106 ++ src/modules/operate/AccountOperation.ts | 88 +- src/types.ts | 11 + 7 files changed, 1346 insertions(+), 200 deletions(-) create mode 100644 __tests__/traders/ExpiryV2.test.ts rename contracts/external/traders/{ExpiryOld.sol => ExpiryV2.sol} (76%) create mode 100644 src/modules/ExpiryV2.ts diff --git a/__tests__/traders/ExpiryV2.test.ts b/__tests__/traders/ExpiryV2.test.ts new file mode 100644 index 00000000..ee21ab94 --- /dev/null +++ b/__tests__/traders/ExpiryV2.test.ts @@ -0,0 +1,937 @@ +import BigNumber from 'bignumber.js'; +import { getSolo } from '../helpers/Solo'; +import { Solo } from '../../src/Solo'; +import { fastForward, mineAvgBlock, resetEVM, snapshot } from '../helpers/EVM'; +import { setupMarkets } from '../helpers/SoloHelpers'; +import { toBytes } from '../../src/lib/BytesHelper'; +import { INTEGERS } from '../../src/lib/Constants'; +import { expectThrow } from '../../src/lib/Expect'; +import { + address, + AmountDenomination, + AmountReference, + Trade, + TxResult, +} from '../../src/types'; + +let solo: Solo; +let accounts: address[]; +let snapshotId: string; +let admin: address; +let owner1: address; +let owner2: address; + +const accountNumber1 = INTEGERS.ZERO; +const accountNumber2 = INTEGERS.ONE; +const heldMarket = INTEGERS.ZERO; +const owedMarket = INTEGERS.ONE; +const collateralMarket = new BigNumber(2); +const par = new BigNumber(10000); +const zero = new BigNumber(0); +const premium = new BigNumber('1.05'); +const defaultPrice = new BigNumber('1e40'); +let defaultGlob: Trade; +let heldGlob: Trade; + +describe('Expiry', () => { + beforeAll(async () => { + const r = await getSolo(); + solo = r.solo; + accounts = r.accounts; + admin = accounts[0]; + owner1 = solo.getDefaultAccount(); + owner2 = accounts[3]; + defaultGlob = { + primaryAccountOwner: owner1, + primaryAccountId: accountNumber1, + otherAccountOwner: owner2, + otherAccountId: accountNumber2, + inputMarketId: owedMarket, + outputMarketId: heldMarket, + autoTrader: solo.contracts.expiryV2.options.address, + amount: { + value: zero, + denomination: AmountDenomination.Principal, + reference: AmountReference.Target, + }, + data: toBytes(owedMarket, INTEGERS.ONES_31), + }; + heldGlob = { + primaryAccountOwner: owner1, + primaryAccountId: accountNumber1, + otherAccountOwner: owner2, + otherAccountId: accountNumber2, + inputMarketId: heldMarket, + outputMarketId: owedMarket, + autoTrader: solo.contracts.expiryV2.options.address, + amount: { + value: zero, + denomination: AmountDenomination.Principal, + reference: AmountReference.Target, + }, + data: toBytes(owedMarket, INTEGERS.ONES_31), + }; + + await resetEVM(); + await Promise.all([ + setupMarkets(solo, accounts), + solo.testing.setAccountBalance(owner2, accountNumber2, owedMarket, par.times(-1)), + solo.testing.setAccountBalance(owner2, accountNumber2, heldMarket, par.times(2)), + solo.testing.setAccountBalance(owner1, accountNumber1, owedMarket, par), + solo.testing.setAccountBalance(owner2, accountNumber2, collateralMarket, par.times(4)), + ]); + + // set expiry for one second from now, then wait + await setExpiryForSelf(INTEGERS.ONE); + await fastForward(60 * 60 * 24); + await mineAvgBlock(); + + snapshotId = await snapshot(); + }); + + beforeEach(async () => { + await resetEVM(snapshotId); + }); + + describe('callFunction (invalid)', () => { + it('Fails for invalid callType', async () => { + await expectThrow( + solo.operation.initiate().call({ + primaryAccountOwner: owner1, + primaryAccountId: accountNumber1, + callee: solo.contracts.expiryV2.options.address, + data: toBytes(2, 2, 2, 2), + }).commit(), + ); + }); + + it('Fails for zero bytes', async () => { + await expectThrow( + solo.operation.initiate().call({ + primaryAccountOwner: owner1, + primaryAccountId: accountNumber1, + callee: solo.contracts.expiryV2.options.address, + data: [], + }).commit(), + ); + }); + }); + + describe('callFunctionSetApproval', () => { + it('Succeeds in setting approval', async () => { + const minTimeDeltas = [INTEGERS.ZERO, new BigNumber(1234)]; + for (let i = 0; i < minTimeDeltas.length; i += 1) { + // make transaction + const txResult = await solo.operation.initiate().setApprovalForExpiryV2({ + primaryAccountOwner: owner2, + primaryAccountId: INTEGERS.ZERO, + sender: owner1, + minTimeDelta: minTimeDeltas[i], + }).commit({ from: owner2 }); + + // check logs + const logs = solo.logs.parseLogs(txResult, { skipOperationLogs: true }); + expect(logs.length).toEqual(1); + const log = logs[0]; + expect(log.name).toEqual('LogSenderApproved'); + expect(log.args.approver).toEqual(owner2); + expect(log.args.sender).toEqual(owner1); + expect(log.args.minTimeDelta).toEqual(minTimeDeltas[i]); + + // check approval set + const actualMinTimeDelta = await solo.expiryV2.getApproval(owner2, owner1); + expect(actualMinTimeDelta).toEqual(minTimeDeltas[i]); + } + }); + }); + + describe('callFunctionSetExpiry', () => { + it('Succeeds in setting expiry', async () => { + const timeDelta = new BigNumber(1234); + const txResult = await setExpiryForSelf(timeDelta); + await expectExpiry( + txResult, + owner2, + accountNumber2, + owedMarket, + timeDelta, + ); + + console.log(`\tSet expiry gas used: ${txResult.gasUsed}`); + }); + + it('Skips logs when necessary', async () => { + const timeDelta = new BigNumber(1234); + const txResult = await setExpiryForSelf(timeDelta); + const noLogs = solo.logs.parseLogs(txResult, { skipExpiryLogs: true }); + const logs = solo.logs.parseLogs(txResult, { skipExpiryLogs: false }); + expect(noLogs.filter((e: any) => e.name === 'ExpirySet').length).toEqual(0); + expect(logs.filter((e: any) => e.name === 'ExpirySet').length).not.toEqual(0); + }); + + it('Sets expiry to zero for non-negative balances', async () => { + const timeDelta = new BigNumber(1234); + await solo.testing.setAccountBalance(owner2, accountNumber2, owedMarket, par); + const txResult = await setExpiryForSelf(timeDelta); + await expectExpiry( + txResult, + owner2, + accountNumber2, + owedMarket, + zero, + ); + }); + + it('Allows setting expiry back to zero even for non-negative balances', async () => { + await solo.testing.setAccountBalance(owner2, accountNumber2, owedMarket, par); + const txResult = await setExpiryForSelf(zero); + await expectExpiry( + txResult, + owner2, + accountNumber2, + owedMarket, + zero, + ); + }); + }); + + describe('expire account (heldAmount)', () => { + beforeEach(async () => { + await solo.testing.setAccountBalance(owner2, accountNumber2, heldMarket, par); + }); + + it('Succeeds in expiring', async () => { + const txResult = await expectExpireOkay(heldGlob); + + const logs = solo.logs.parseLogs(txResult); + logs.forEach((log: any) => expect(log.name).not.toEqual('ExpirySet')); + + const [ + held1, + owed1, + held2, + owed2, + ] = await Promise.all([ + solo.getters.getAccountPar(owner1, accountNumber1, heldMarket), + solo.getters.getAccountPar(owner1, accountNumber1, owedMarket), + solo.getters.getAccountPar(owner2, accountNumber2, heldMarket), + solo.getters.getAccountPar(owner2, accountNumber2, owedMarket), + ]); + + expect(owed1).toEqual(par.minus(par.div(premium)).integerValue(BigNumber.ROUND_DOWN)); + expect(owed2).toEqual(owed1.times(-1)); + expect(held1).toEqual(par); + expect(held2).toEqual(zero); + + console.log(`\tExpiring (held) gas used: ${txResult.gasUsed}`); + }); + + it('Succeeds in expiring and setting expiry back to zero', async () => { + await solo.testing.setAccountBalance(owner2, accountNumber2, heldMarket, par.times(premium)); + const txResult = await expectExpireOkay(heldGlob); + + const logs = solo.logs.parseLogs(txResult, { skipOperationLogs: true }); + expect(logs.length).toEqual(1); + const expiryLog = logs[0]; + expect(expiryLog.name).toEqual('ExpirySet'); + expect(expiryLog.args.owner).toEqual(owner2); + expect(expiryLog.args.number).toEqual(accountNumber2); + expect(expiryLog.args.marketId).toEqual(owedMarket); + expect(expiryLog.args.time).toEqual(zero); + }); + + it('Succeeds in expiring part of a position', async () => { + const txResult = await expectExpireOkay({ + ...heldGlob, + amount: { + value: par.div(2), + denomination: AmountDenomination.Actual, + reference: AmountReference.Target, + }, + }); + + const logs = solo.logs.parseLogs(txResult); + logs.forEach((log: any) => expect(log.name).not.toEqual('ExpirySet')); + + const [ + held1, + owed1, + held2, + owed2, + ] = await Promise.all([ + solo.getters.getAccountPar(owner1, accountNumber1, heldMarket), + solo.getters.getAccountPar(owner1, accountNumber1, owedMarket), + solo.getters.getAccountPar(owner2, accountNumber2, heldMarket), + solo.getters.getAccountPar(owner2, accountNumber2, owedMarket), + ]); + + expect(owed1).toEqual(par.minus(par.div(premium).div(2)).integerValue(BigNumber.ROUND_DOWN)); + expect(owed2).toEqual(owed1.times(-1)); + expect(held1).toEqual(par.div(2)); + expect(held2).toEqual(par.minus(held1)); + }); + + it('Succeeds in expiring including premiums', async () => { + const owedPremium = new BigNumber('0.5'); + const heldPremium = new BigNumber('1.0'); + const adjustedPremium = premium.minus(1).times( + owedPremium.plus(1), + ).times( + heldPremium.plus(1), + ).plus(1); + await Promise.all([ + solo.admin.setSpreadPremium(owedMarket, owedPremium, { from: admin }), + solo.admin.setSpreadPremium(heldMarket, heldPremium, { from: admin }), + ]); + + await expectExpireOkay(heldGlob); + + const [ + held1, + owed1, + held2, + owed2, + ] = await Promise.all([ + solo.getters.getAccountPar(owner1, accountNumber1, heldMarket), + solo.getters.getAccountPar(owner1, accountNumber1, owedMarket), + solo.getters.getAccountPar(owner2, accountNumber2, heldMarket), + solo.getters.getAccountPar(owner2, accountNumber2, owedMarket), + ]); + + expect(owed1).toEqual(par.minus(par.div(adjustedPremium)).integerValue(BigNumber.ROUND_DOWN)); + expect(owed2).toEqual(owed1.times(-1)); + expect(held1).toEqual(par); + expect(held2).toEqual(zero); + }); + + it('Succeeds for zero inputMarket', async () => { + const getAllBalances = [ + solo.getters.getAccountPar(owner1, accountNumber1, heldMarket), + solo.getters.getAccountPar(owner1, accountNumber1, owedMarket), + solo.getters.getAccountPar(owner2, accountNumber2, heldMarket), + solo.getters.getAccountPar(owner2, accountNumber2, owedMarket), + ]; + const start = await Promise.all(getAllBalances); + + await solo.testing.setAccountBalance(owner2, accountNumber2, heldMarket, zero); + await expectExpireOkay(heldGlob); + + const end = await Promise.all(getAllBalances); + expect(start).toEqual(end); + }); + + it('Fails for negative inputMarket', async () => { + await solo.testing.setAccountBalance(owner2, accountNumber2, heldMarket, par.times(-1)); + await expectExpireRevert( + heldGlob, + 'ExpiryV2: inputMarket mismatch', + ); + }); + + it('Fails for overusing collateral', async () => { + await expectExpireRevert( + { + ...heldGlob, + amount: { + value: par.times(-1), + denomination: AmountDenomination.Actual, + reference: AmountReference.Target, + }, + }, + 'ExpiryV2: Collateral cannot be overused', + ); + }); + + it('Fails for increasing the heldAmount', async () => { + await expectExpireRevert( + { + ...heldGlob, + amount: { + value: par.times(4), + denomination: AmountDenomination.Actual, + reference: AmountReference.Target, + }, + }, + 'ExpiryV2: inputMarket mismatch', + ); + }); + + it('Fails for a zero expiry', async () => { + await setExpiryForSelf(zero); + await expectExpireRevert( + heldGlob, + 'ExpiryV2: Expiry not set', + ); + }); + + it('Fails for a future expiry', async () => { + await setExpiryForSelf(new BigNumber(1234)); + await expectExpireRevert( + heldGlob, + 'ExpiryV2: Borrow not yet expired', + ); + }); + + it('Fails for an expiry past maxExpiry', async () => { + await expectExpireRevert( + { + ...heldGlob, + data: toBytes(owedMarket, new BigNumber(1234)), + }, + 'ExpiryV2: Expiry past maxExpiry', + ); + }); + + it('Fails for invalid trade data', async () => { + await expectExpireRevert( + { + ...heldGlob, + data: toBytes(owedMarket), + }, + ); + }); + + it('Fails for zero owedMarket', async () => { + await solo.testing.setAccountBalance(owner2, accountNumber2, owedMarket, zero); + await expectExpireRevert( + heldGlob, + 'ExpiryV2: Borrows must be negative', + ); + }); + + it('Fails for positive owedMarket', async () => { + await solo.testing.setAccountBalance(owner2, accountNumber2, owedMarket, par); + await expectExpireRevert( + heldGlob, + 'ExpiryV2: Borrows must be negative', + ); + }); + + it('Fails for over-repaying the borrow', async () => { + await solo.testing.setAccountBalance(owner2, accountNumber2, heldMarket, par.times(2)); + await expectExpireRevert( + heldGlob, + 'ExpiryV2: outputMarket too small', + ); + }); + }); + + describe('expire account (owedAmount)', () => { + it('Succeeds in expiring', async () => { + const txResult = await expectExpireOkay({}); + + const logs = solo.logs.parseLogs(txResult, { skipOperationLogs: true }); + expect(logs.length).toEqual(1); + const expiryLog = logs[0]; + expect(expiryLog.name).toEqual('ExpirySet'); + expect(expiryLog.args.owner).toEqual(owner2); + expect(expiryLog.args.number).toEqual(accountNumber2); + expect(expiryLog.args.marketId).toEqual(owedMarket); + expect(expiryLog.args.time).toEqual(zero); + + const [ + held1, + owed1, + held2, + owed2, + ] = await Promise.all([ + solo.getters.getAccountPar(owner1, accountNumber1, heldMarket), + solo.getters.getAccountPar(owner1, accountNumber1, owedMarket), + solo.getters.getAccountPar(owner2, accountNumber2, heldMarket), + solo.getters.getAccountPar(owner2, accountNumber2, owedMarket), + ]); + + expect(owed1).toEqual(zero); + expect(owed2).toEqual(zero); + expect(held1).toEqual(par.times(premium)); + expect(held2).toEqual(par.times(2).minus(held1)); + + console.log(`\tExpiring (owed) gas used: ${txResult.gasUsed}`); + }); + + it('Succeeds in expiring part of a position', async () => { + const txResult = await expectExpireOkay({ + amount: { + value: par.div(-2), + denomination: AmountDenomination.Actual, + reference: AmountReference.Target, + }, + }); + + const logs = solo.logs.parseLogs(txResult); + logs.forEach((log: any) => expect(log.name).not.toEqual('ExpirySet')); + + const [ + held1, + owed1, + held2, + owed2, + ] = await Promise.all([ + solo.getters.getAccountPar(owner1, accountNumber1, heldMarket), + solo.getters.getAccountPar(owner1, accountNumber1, owedMarket), + solo.getters.getAccountPar(owner2, accountNumber2, heldMarket), + solo.getters.getAccountPar(owner2, accountNumber2, owedMarket), + ]); + + expect(owed1).toEqual(par.div(2)); + expect(owed2).toEqual(par.div(-2)); + expect(held1).toEqual(par.times(premium).div(2)); + expect(held2).toEqual(par.times(2).minus(held1)); + }); + + it('Succeeds in expiring including premiums', async () => { + const owedPremium = new BigNumber('0.5'); + const heldPremium = new BigNumber('1.0'); + const adjustedPremium = premium.minus(1).times( + owedPremium.plus(1), + ).times( + heldPremium.plus(1), + ).plus(1); + await Promise.all([ + solo.admin.setSpreadPremium(owedMarket, owedPremium, { from: admin }), + solo.admin.setSpreadPremium(heldMarket, heldPremium, { from: admin }), + ]); + + await expectExpireOkay({}); + + const [ + held1, + owed1, + held2, + owed2, + ] = await Promise.all([ + solo.getters.getAccountPar(owner1, accountNumber1, heldMarket), + solo.getters.getAccountPar(owner1, accountNumber1, owedMarket), + solo.getters.getAccountPar(owner2, accountNumber2, heldMarket), + solo.getters.getAccountPar(owner2, accountNumber2, owedMarket), + ]); + + expect(owed1).toEqual(zero); + expect(owed2).toEqual(zero); + expect(held1).toEqual(par.times(adjustedPremium)); + expect(held2).toEqual(par.times(2).minus(held1)); + }); + + it('Fails for non-solo calls', async () => { + await expectThrow( + solo.contracts.callContractFunction( + solo.contracts.expiryV2.methods.callFunction( + owner1, + { + owner: owner1, + number: accountNumber1.toFixed(0), + }, + [], + ), + ), + 'OnlySolo: Only Solo can call function', + ); + }); + + it('Succeeds for zero inputMarket', async () => { + const getAllBalances = [ + solo.getters.getAccountPar(owner1, accountNumber1, heldMarket), + solo.getters.getAccountPar(owner1, accountNumber1, owedMarket), + solo.getters.getAccountPar(owner2, accountNumber2, heldMarket), + solo.getters.getAccountPar(owner2, accountNumber2, owedMarket), + ]; + const start = await Promise.all(getAllBalances); + + await solo.testing.setAccountBalance(owner2, accountNumber2, owedMarket, zero); + await expectExpireOkay({}); + + const end = await Promise.all(getAllBalances); + expect(start).toEqual(end); + }); + + it('Fails for positive inputMarket', async () => { + await solo.testing.setAccountBalance(owner2, accountNumber2, owedMarket, par); + await expectExpireRevert( + {}, + 'ExpiryV2: outputMarket mismatch', + ); + }); + + it('Fails for overpaying a borrow', async () => { + await expectExpireRevert( + { + amount: { + value: par, + denomination: AmountDenomination.Actual, + reference: AmountReference.Target, + }, + }, + 'ExpiryV2: Borrows cannot be overpaid', + ); + }); + + it('Fails for increasing a borrow', async () => { + await expectExpireRevert( + { + amount: { + value: par.times(-2), + denomination: AmountDenomination.Actual, + reference: AmountReference.Target, + }, + }, + 'ExpiryV2: outputMarket mismatch', + ); + }); + + it('Fails for a zero expiry', async () => { + await setExpiryForSelf(zero); + await expectExpireRevert( + {}, + 'ExpiryV2: Expiry not set', + ); + }); + + it('Fails for a future expiry', async () => { + await setExpiryForSelf(new BigNumber(1234)); + await expectExpireRevert( + {}, + 'ExpiryV2: Borrow not yet expired', + ); + }); + + it('Fails for an expiry past maxExpiry', async () => { + await expectExpireRevert( + { + data: toBytes(owedMarket, new BigNumber(1234)), + }, + 'ExpiryV2: Expiry past maxExpiry', + ); + }); + + it('Fails for invalid trade data', async () => { + await expectExpireRevert( + { + data: toBytes(owedMarket), + }, + // No message, abi.decode reverts. + ); + }); + + it('Fails for zero collateral', async () => { + await solo.testing.setAccountBalance(owner2, accountNumber2, heldMarket, zero); + await expectExpireRevert( + {}, + 'ExpiryV2: Collateral must be positive', + ); + }); + + it('Fails for negative collateral', async () => { + await solo.testing.setAccountBalance(owner2, accountNumber2, heldMarket, par.times(-1)); + await expectExpireRevert( + {}, + 'ExpiryV2: Collateral must be positive', + ); + }); + + it('Fails for overtaking collateral', async () => { + await solo.testing.setAccountBalance(owner2, accountNumber2, heldMarket, par); + await expectExpireRevert( + {}, + 'ExpiryV2: outputMarket too small', + ); + }); + }); + + describe('AccountOperation#fullyLiquidateExpiredAccountV2', () => { + it('Succeeds for two assets', async () => { + const prices = [ + INTEGERS.ONES_31, + INTEGERS.ONES_31, + INTEGERS.ONES_31, + ]; + const premiums = [ + INTEGERS.ZERO, + INTEGERS.ZERO, + INTEGERS.ZERO, + ]; + const collateralPreferences = [ + owedMarket, + heldMarket, + collateralMarket, + ]; + const weis = await Promise.all([ + solo.getters.getAccountWei(owner2, accountNumber2, new BigNumber(0)), + solo.getters.getAccountWei(owner2, accountNumber2, new BigNumber(1)), + solo.getters.getAccountWei(owner2, accountNumber2, new BigNumber(2)), + ]); + const expiryTimestamp = await solo.expiryV2.getExpiry(owner2, accountNumber2, owedMarket); + await solo.operation.initiate().fullyLiquidateExpiredAccountV2( + owner1, + accountNumber1, + owner2, + accountNumber2, + owedMarket, + expiryTimestamp, + expiryTimestamp.plus(INTEGERS.ONE_DAY_IN_SECONDS), + weis, + prices, + premiums, + collateralPreferences, + ).commit(); + + const balances = await Promise.all([ + solo.getters.getAccountPar(owner2, accountNumber2, owedMarket), + solo.getters.getAccountPar(owner2, accountNumber2, heldMarket), + solo.getters.getAccountPar(owner2, accountNumber2, collateralMarket), + ]); + + expect(balances[0]).toEqual(zero); + expect(balances[1]).toEqual(par.times(2).minus(par.times(premium))); + expect(balances[2]).toEqual(par.times(4)); + }); + + it('Succeeds for three assets', async () => { + const prices = [ + INTEGERS.ONES_31, + INTEGERS.ONES_31, + INTEGERS.ONES_31, + ]; + const premiums = [ + INTEGERS.ZERO, + INTEGERS.ZERO, + INTEGERS.ZERO, + ]; + const collateralPreferences = [ + owedMarket, + heldMarket, + collateralMarket, + ]; + await Promise.all([ + solo.testing.setAccountBalance(owner2, accountNumber2, heldMarket, par), + solo.testing.setAccountBalance(owner2, accountNumber2, collateralMarket, par), + ]); + const weis = await Promise.all([ + solo.getters.getAccountWei(owner2, accountNumber2, new BigNumber(0)), + solo.getters.getAccountWei(owner2, accountNumber2, new BigNumber(1)), + solo.getters.getAccountWei(owner2, accountNumber2, new BigNumber(2)), + ]); + const expiryTimestamp = await solo.expiryV2.getExpiry(owner2, accountNumber2, owedMarket); + await solo.operation.initiate().fullyLiquidateExpiredAccountV2( + owner1, + accountNumber1, + owner2, + accountNumber2, + owedMarket, + expiryTimestamp, + expiryTimestamp.plus(INTEGERS.ONE_DAY_IN_SECONDS), + weis, + prices, + premiums, + collateralPreferences, + ).commit(); + + const balances = await Promise.all([ + solo.getters.getAccountPar(owner2, accountNumber2, owedMarket), + solo.getters.getAccountPar(owner2, accountNumber2, heldMarket), + solo.getters.getAccountPar(owner2, accountNumber2, collateralMarket), + ]); + + expect(balances[0]).toEqual(zero); + expect(balances[1]).toEqual(zero); + + // calculate the last expected value + const remainingOwed = par.minus(par.div(premium)); + expect(balances[2]).toEqual( + par.minus(remainingOwed.times(premium)).integerValue(BigNumber.ROUND_UP), + ); + }); + + it('Succeeds for three assets (with premiums)', async () => { + const prices = [ + INTEGERS.ONES_31, + INTEGERS.ONES_31, + INTEGERS.ONES_31, + ]; + const premiums = [ + new BigNumber('0.1'), + new BigNumber('0.2'), + new BigNumber('0.3'), + ]; + const collateralPreferences = [ + owedMarket, + heldMarket, + collateralMarket, + ]; + await Promise.all([ + solo.admin.setSpreadPremium(heldMarket, premiums[0], { from: admin }), + solo.admin.setSpreadPremium(owedMarket, premiums[1], { from: admin }), + solo.admin.setSpreadPremium(collateralMarket, premiums[2], { from: admin }), + solo.testing.setAccountBalance(owner2, accountNumber2, heldMarket, par.times(premium)), + solo.testing.setAccountBalance(owner2, accountNumber2, collateralMarket, par), + ]); + const weis = await Promise.all([ + solo.getters.getAccountWei(owner2, accountNumber2, new BigNumber(0)), + solo.getters.getAccountWei(owner2, accountNumber2, new BigNumber(1)), + solo.getters.getAccountWei(owner2, accountNumber2, new BigNumber(2)), + ]); + const expiryTimestamp = await solo.expiryV2.getExpiry(owner2, accountNumber2, owedMarket); + await solo.operation.initiate().fullyLiquidateExpiredAccountV2( + owner1, + accountNumber1, + owner2, + accountNumber2, + owedMarket, + expiryTimestamp, + expiryTimestamp.plus(INTEGERS.ONE_DAY_IN_SECONDS), + weis, + prices, + premiums, + collateralPreferences, + ).commit(); + + const balances = await Promise.all([ + solo.getters.getAccountPar(owner2, accountNumber2, owedMarket), + solo.getters.getAccountPar(owner2, accountNumber2, heldMarket), + solo.getters.getAccountPar(owner2, accountNumber2, collateralMarket), + ]); + + expect(balances[0]).toEqual(zero); + expect(balances[1]).toEqual(zero); + + // calculate the last expected value + const firstPrem = premium.minus(1).times('1.1').times('1.2').plus(1); + const secondPrem = premium.minus(1).times('1.2').times('1.3').plus(1); + const remainingOwed = par.minus(par.times(premium).div(firstPrem)); + expect(balances[2]).toEqual( + par.minus(remainingOwed.times(secondPrem)).integerValue(BigNumber.ROUND_UP), + ); + }); + }); + + describe('#getSpreadAdjustedPrices', () => { + it('Succeeds for recently expired positions', async () => { + const txResult = await setExpiryForSelf(zero); + const { timestamp } = await solo.web3.eth.getBlock(txResult.blockNumber); + await mineAvgBlock(); + const prices = await solo.expiryV2.getPrices( + heldMarket, + owedMarket, + new BigNumber(timestamp), + ); + expect(prices.owedPrice.lt(defaultPrice.times(premium))).toEqual(true); + expect(prices.owedPrice.gt(defaultPrice)).toEqual(true); + expect(prices.heldPrice).toEqual(defaultPrice); + }); + + it('Succeeds for very expired positions', async () => { + const prices = await solo.expiryV2.getPrices( + heldMarket, + owedMarket, + INTEGERS.ONE, + ); + expect(prices.owedPrice).toEqual(defaultPrice.times(premium)); + expect(prices.heldPrice).toEqual(defaultPrice); + }); + }); + + describe('#ownerSetExpiryRampTime', () => { + it('Succeeds for owner', async () => { + const oldValue = await solo.expiryV2.getRampTime(); + expect(oldValue).toEqual(INTEGERS.ONE_HOUR_IN_SECONDS); + await solo.expiryV2.setRampTime(INTEGERS.ONE_DAY_IN_SECONDS, { from: admin }); + const newValue = await solo.expiryV2.getRampTime(); + expect(newValue).toEqual(INTEGERS.ONE_DAY_IN_SECONDS); + }); + + it('Fails for non-owner', async () => { + await expectThrow( + solo.expiryV2.setRampTime(INTEGERS.ONE_DAY_IN_SECONDS, { from: owner1 }), + ); + }); + }); + + describe('#liquidateExpiredAccount', () => { + it('Succeeds', async () => { + await solo.operation.initiate().liquidateExpiredAccountV2({ + liquidMarketId: owedMarket, + payoutMarketId: heldMarket, + primaryAccountOwner: owner1, + primaryAccountId: accountNumber1, + liquidAccountOwner: owner2, + liquidAccountId: accountNumber2, + amount: { + value: INTEGERS.ZERO, + denomination: AmountDenomination.Principal, + reference: AmountReference.Target, + }, + }).commit(); + + const [ + held1, + owed1, + held2, + owed2, + ] = await Promise.all([ + solo.getters.getAccountPar(owner1, accountNumber1, heldMarket), + solo.getters.getAccountPar(owner1, accountNumber1, owedMarket), + solo.getters.getAccountPar(owner2, accountNumber2, heldMarket), + solo.getters.getAccountPar(owner2, accountNumber2, owedMarket), + ]); + + expect(owed1).toEqual(zero); + expect(owed2).toEqual(zero); + expect(held1).toEqual(par.times(premium)); + expect(held2).toEqual(par.times(2).minus(held1)); + }); + }); +}); + +// ============ Helper Functions ============ + +async function setExpiryForSelf(timeDelta: BigNumber, options?: any) { + const txResult = await solo.operation.initiate().setExpiryV2({ + primaryAccountOwner: owner2, + primaryAccountId: accountNumber2, + expiryV2Args: [{ + timeDelta, + accountOwner: owner2, + accountId: accountNumber2, + marketId: owedMarket, + }], + }).commit({ ...options, from: owner2 }); + return txResult; +} + +async function expectExpireOkay( + glob: Object, + options?: Object, +) { + const combinedGlob = { ...defaultGlob, ...glob }; + return solo.operation.initiate().trade(combinedGlob).commit(options); +} + +async function expectExpireRevert( + glob: Object, + reason?: string, + options?: Object, +) { + await expectThrow(expectExpireOkay(glob, options), reason); +} + +async function expectExpiry( + txResult: TxResult, + owner: address, + accountNumber: BigNumber, + market: BigNumber, + timeDelta: BigNumber, +) { + const { timestamp } = await solo.web3.eth.getBlock(txResult.blockNumber); + const expectedExpiryTime = timeDelta.isZero() ? zero : timeDelta.plus(timestamp); + + const logs = solo.logs.parseLogs(txResult, { skipOperationLogs: true }); + expect(logs.length).toEqual(1); + const expirySetLog = logs[0]; + expect(expirySetLog.name).toEqual('ExpirySet'); + expect(expirySetLog.args.owner).toEqual(owner); + expect(expirySetLog.args.number).toEqual(accountNumber); + expect(expirySetLog.args.marketId).toEqual(market); + expect(expirySetLog.args.time).toEqual(expectedExpiryTime); + + const expiry = await solo.expiryV2.getExpiry(owner2, accountNumber2, owedMarket); + expect(expiry).toEqual(expectedExpiryTime); +} diff --git a/contracts/external/traders/Expiry.sol b/contracts/external/traders/Expiry.sol index 05b4a163..449b9fcf 100644 --- a/contracts/external/traders/Expiry.sol +++ b/contracts/external/traders/Expiry.sol @@ -34,18 +34,19 @@ import { OnlySolo } from "../helpers/OnlySolo.sol"; /** - * @title ExpiryV2 + * @title ExpiryOld * @author dYdX * - * Expiry contract that also allows approved senders to set expiry to be 28 days in the future. + * Sets the negative balance for an account to expire at a certain time. This allows any other + * account to repay that negative balance after expiry using any positive balance in the same + * account. The arbitrage incentive is the same as liquidation in the base protocol. */ -contract ExpiryV2 is +contract ExpiryOld is Ownable, OnlySolo, ICallee, IAutoTrader { - using Math for uint256; using SafeMath for uint32; using SafeMath for uint256; using Types for Types.Par; @@ -55,26 +56,6 @@ contract ExpiryV2 is bytes32 constant FILE = "Expiry"; - // ============ Enums ============ - - enum CallFunctionType { - SetExpiry, - SetApproval - } - - // ============ Structs ============ - - struct SetExpiryArg { - Account.Info account; - uint256 marketId; - uint32 timeDelta; - } - - struct SetApprovalArg { - address sender; - uint32 minTimeDelta; - } - // ============ Events ============ event ExpirySet( @@ -88,20 +69,11 @@ contract ExpiryV2 is uint256 expiryRampTime ); - event LogSenderApproved( - address approver, - address sender, - uint32 minTimeDelta - ); - // ============ Storage ============ // owner => number => market => time mapping (address => mapping (uint256 => mapping (uint256 => uint32))) g_expiries; - // owner => sender => minimum time delta - mapping (address => mapping (address => uint32)) public g_approvedSender; - // time over which the liquidation ratio goes from zero to maximum uint256 public g_expiryRampTime; @@ -129,17 +101,6 @@ contract ExpiryV2 is g_expiryRampTime = newExpiryRampTime; } - // ============ Approval Functions ============ - - function approveSender( - address sender, - uint32 minTimeDelta - ) - external - { - setApproval(msg.sender, sender, minTimeDelta); - } - // ============ Getters ============ function getExpiry( @@ -193,12 +154,17 @@ contract ExpiryV2 is public onlySolo(msg.sender) { - CallFunctionType callType = abi.decode(data, (CallFunctionType)); - if (callType == CallFunctionType.SetExpiry) { - callFunctionSetExpiry(account.owner, data); - } else { - callFunctionSetApproval(account.owner, data); + ( + uint256 marketId, + uint32 expiryTime + ) = parseCallArgs(data); + + // don't set expiry time for accounts with positive balance + if (expiryTime != 0 && !SOLO_MARGIN.getAccountPar(account, marketId).isNegative()) { + return; } + + setExpiry(account, marketId, expiryTime); } function getTradeCost( @@ -225,7 +191,10 @@ contract ExpiryV2 is }); } - (uint256 owedMarketId, uint256 maxExpiry) = abi.decode(data, (uint256, uint256)); + ( + uint256 owedMarketId, + uint32 maxExpiry + ) = parseTradeArgs(data); uint32 expiry = getExpiry(makerAccount, owedMarketId); @@ -265,59 +234,6 @@ contract ExpiryV2 is // ============ Private Functions ============ - function callFunctionSetExpiry( - address sender, - bytes memory data - ) - private - { - ( - CallFunctionType callType, - SetExpiryArg[] memory expiries - ) = abi.decode(data, (CallFunctionType, SetExpiryArg[])); - - assert(callType == CallFunctionType.SetExpiry); - - for (uint256 i = 0; i < expiries.length; i++) { - SetExpiryArg memory exp = expiries[i]; - uint32 timeDelta = exp.timeDelta; - if (exp.account.owner != sender) { - uint32 minApprovedTimeDelta = g_approvedSender[exp.account.owner][sender]; - if (minApprovedTimeDelta == 0) { - // don't do anything if sender is not approved - continue; - } else { - // bound the time by the minimum approved timeDelta - timeDelta = Math.max(minApprovedTimeDelta, exp.timeDelta).to32(); - } - } - - // if timeDelta is zero, interpret it as unset expiry - if ( - timeDelta > 0 && - SOLO_MARGIN.getAccountPar(exp.account, exp.marketId).isNegative() - ) { - setExpiry(exp.account, exp.marketId, Time.currentTime().add(timeDelta).to32()); - } else { - setExpiry(exp.account, exp.marketId, 0); - } - } - } - - function callFunctionSetApproval( - address sender, - bytes memory data - ) - private - { - ( - CallFunctionType callType, - SetApprovalArg memory approvalArg - ) = abi.decode(data, (CallFunctionType, SetApprovalArg)); - assert(callType == CallFunctionType.SetApproval); - setApproval(sender, approvalArg.sender, approvalArg.minTimeDelta); - } - function getTradeCostInternal( uint256 inputMarketId, uint256 outputMarketId, @@ -420,6 +336,7 @@ contract ExpiryV2 is private { g_expiries[account.owner][account.number][marketId] = time; + emit ExpirySet( account.owner, account.number, @@ -428,17 +345,6 @@ contract ExpiryV2 is ); } - function setApproval( - address approver, - address sender, - uint32 minTimeDelta - ) - private - { - g_approvedSender[approver][sender] = minTimeDelta; - emit LogSenderApproved(approver, sender, minTimeDelta); - } - function heldWeiToOwedWei( Types.Wei memory heldWei, uint256 heldMarketId, @@ -504,4 +410,68 @@ contract ExpiryV2 is value: heldAmount }); } + + function parseCallArgs( + bytes memory data + ) + private + pure + returns ( + uint256, + uint32 + ) + { + Require.that( + data.length == 64, + FILE, + "Call data invalid length", + data.length + ); + + uint256 marketId; + uint256 rawExpiry; + + /* solium-disable-next-line security/no-inline-assembly */ + assembly { + marketId := mload(add(data, 32)) + rawExpiry := mload(add(data, 64)) + } + + return ( + marketId, + Math.to32(rawExpiry) + ); + } + + function parseTradeArgs( + bytes memory data + ) + private + pure + returns ( + uint256, + uint32 + ) + { + Require.that( + data.length == 64, + FILE, + "Trade data invalid length", + data.length + ); + + uint256 owedMarketId; + uint256 rawExpiry; + + /* solium-disable-next-line security/no-inline-assembly */ + assembly { + owedMarketId := mload(add(data, 32)) + rawExpiry := mload(add(data, 64)) + } + + return ( + owedMarketId, + Math.to32(rawExpiry) + ); + } } diff --git a/contracts/external/traders/ExpiryOld.sol b/contracts/external/traders/ExpiryV2.sol similarity index 76% rename from contracts/external/traders/ExpiryOld.sol rename to contracts/external/traders/ExpiryV2.sol index 449b9fcf..198d38c7 100644 --- a/contracts/external/traders/ExpiryOld.sol +++ b/contracts/external/traders/ExpiryV2.sol @@ -34,19 +34,18 @@ import { OnlySolo } from "../helpers/OnlySolo.sol"; /** - * @title ExpiryOld + * @title ExpiryV2 * @author dYdX * - * Sets the negative balance for an account to expire at a certain time. This allows any other - * account to repay that negative balance after expiry using any positive balance in the same - * account. The arbitrage incentive is the same as liquidation in the base protocol. + * Expiry contract that also allows approved senders to set expiry to be 28 days in the future. */ -contract ExpiryOld is +contract ExpiryV2 is Ownable, OnlySolo, ICallee, IAutoTrader { + using Math for uint256; using SafeMath for uint32; using SafeMath for uint256; using Types for Types.Par; @@ -54,7 +53,27 @@ contract ExpiryOld is // ============ Constants ============ - bytes32 constant FILE = "Expiry"; + bytes32 constant FILE = "ExpiryV2"; + + // ============ Enums ============ + + enum CallFunctionType { + SetExpiry, + SetApproval + } + + // ============ Structs ============ + + struct SetExpiryArg { + Account.Info account; + uint256 marketId; + uint32 timeDelta; + } + + struct SetApprovalArg { + address sender; + uint32 minTimeDelta; + } // ============ Events ============ @@ -69,11 +88,20 @@ contract ExpiryOld is uint256 expiryRampTime ); + event LogSenderApproved( + address approver, + address sender, + uint32 minTimeDelta + ); + // ============ Storage ============ // owner => number => market => time mapping (address => mapping (uint256 => mapping (uint256 => uint32))) g_expiries; + // owner => sender => minimum time delta + mapping (address => mapping (address => uint32)) public g_approvedSender; + // time over which the liquidation ratio goes from zero to maximum uint256 public g_expiryRampTime; @@ -101,6 +129,17 @@ contract ExpiryOld is g_expiryRampTime = newExpiryRampTime; } + // ============ Approval Functions ============ + + function approveSender( + address sender, + uint32 minTimeDelta + ) + external + { + setApproval(msg.sender, sender, minTimeDelta); + } + // ============ Getters ============ function getExpiry( @@ -154,17 +193,12 @@ contract ExpiryOld is public onlySolo(msg.sender) { - ( - uint256 marketId, - uint32 expiryTime - ) = parseCallArgs(data); - - // don't set expiry time for accounts with positive balance - if (expiryTime != 0 && !SOLO_MARGIN.getAccountPar(account, marketId).isNegative()) { - return; + CallFunctionType callType = abi.decode(data, (CallFunctionType)); + if (callType == CallFunctionType.SetExpiry) { + callFunctionSetExpiry(account.owner, data); + } else { + callFunctionSetApproval(account.owner, data); } - - setExpiry(account, marketId, expiryTime); } function getTradeCost( @@ -191,10 +225,7 @@ contract ExpiryOld is }); } - ( - uint256 owedMarketId, - uint32 maxExpiry - ) = parseTradeArgs(data); + (uint256 owedMarketId, uint32 maxExpiry) = abi.decode(data, (uint256, uint32)); uint32 expiry = getExpiry(makerAccount, owedMarketId); @@ -234,6 +265,59 @@ contract ExpiryOld is // ============ Private Functions ============ + function callFunctionSetExpiry( + address sender, + bytes memory data + ) + private + { + ( + CallFunctionType callType, + SetExpiryArg[] memory expiries + ) = abi.decode(data, (CallFunctionType, SetExpiryArg[])); + + assert(callType == CallFunctionType.SetExpiry); + + for (uint256 i = 0; i < expiries.length; i++) { + SetExpiryArg memory exp = expiries[i]; + uint32 timeDelta = exp.timeDelta; + if (exp.account.owner != sender) { + uint32 minApprovedTimeDelta = g_approvedSender[exp.account.owner][sender]; + if (minApprovedTimeDelta == 0) { + // don't do anything if sender is not approved + continue; + } else { + // bound the time by the minimum approved timeDelta + timeDelta = Math.max(minApprovedTimeDelta, exp.timeDelta).to32(); + } + } + + // if timeDelta is zero, interpret it as unset expiry + if ( + timeDelta > 0 && + SOLO_MARGIN.getAccountPar(exp.account, exp.marketId).isNegative() + ) { + setExpiry(exp.account, exp.marketId, Time.currentTime().add(timeDelta).to32()); + } else { + setExpiry(exp.account, exp.marketId, 0); + } + } + } + + function callFunctionSetApproval( + address sender, + bytes memory data + ) + private + { + ( + CallFunctionType callType, + SetApprovalArg memory approvalArg + ) = abi.decode(data, (CallFunctionType, SetApprovalArg)); + assert(callType == CallFunctionType.SetApproval); + setApproval(sender, approvalArg.sender, approvalArg.minTimeDelta); + } + function getTradeCostInternal( uint256 inputMarketId, uint256 outputMarketId, @@ -336,7 +420,6 @@ contract ExpiryOld is private { g_expiries[account.owner][account.number][marketId] = time; - emit ExpirySet( account.owner, account.number, @@ -345,6 +428,17 @@ contract ExpiryOld is ); } + function setApproval( + address approver, + address sender, + uint32 minTimeDelta + ) + private + { + g_approvedSender[approver][sender] = minTimeDelta; + emit LogSenderApproved(approver, sender, minTimeDelta); + } + function heldWeiToOwedWei( Types.Wei memory heldWei, uint256 heldMarketId, @@ -410,68 +504,4 @@ contract ExpiryOld is value: heldAmount }); } - - function parseCallArgs( - bytes memory data - ) - private - pure - returns ( - uint256, - uint32 - ) - { - Require.that( - data.length == 64, - FILE, - "Call data invalid length", - data.length - ); - - uint256 marketId; - uint256 rawExpiry; - - /* solium-disable-next-line security/no-inline-assembly */ - assembly { - marketId := mload(add(data, 32)) - rawExpiry := mload(add(data, 64)) - } - - return ( - marketId, - Math.to32(rawExpiry) - ); - } - - function parseTradeArgs( - bytes memory data - ) - private - pure - returns ( - uint256, - uint32 - ) - { - Require.that( - data.length == 64, - FILE, - "Trade data invalid length", - data.length - ); - - uint256 owedMarketId; - uint256 rawExpiry; - - /* solium-disable-next-line security/no-inline-assembly */ - assembly { - owedMarketId := mload(add(data, 32)) - rawExpiry := mload(add(data, 64)) - } - - return ( - owedMarketId, - Math.to32(rawExpiry) - ); - } } diff --git a/src/lib/Contracts.ts b/src/lib/Contracts.ts index cf35895c..35dcc1d1 100644 --- a/src/lib/Contracts.ts +++ b/src/lib/Contracts.ts @@ -30,6 +30,7 @@ import { IErc20 as ERC20 } from '../../build/wrappers/IErc20'; import { IInterestSetter as InterestSetter } from '../../build/wrappers/IInterestSetter'; import { IPriceOracle as PriceOracle } from '../../build/wrappers/IPriceOracle'; import { Expiry } from '../../build/wrappers/Expiry'; +import { ExpiryV2 } from '../../build/wrappers/ExpiryV2'; import { Refunder } from '../../build/wrappers/Refunder'; import { LimitOrders } from '../../build/wrappers/LimitOrders'; import { @@ -63,6 +64,7 @@ import erc20Json from '../../build/published_contracts/IErc20.json'; import interestSetterJson from '../../build/published_contracts/IInterestSetter.json'; import priceOracleJson from '../../build/published_contracts/IPriceOracle.json'; import expiryJson from '../../build/published_contracts/Expiry.json'; +import expiryV2Json from '../../build/published_contracts/ExpiryV2.json'; import refunderJson from '../../build/published_contracts/Refunder.json'; import limitOrdersJson from '../../build/published_contracts/LimitOrders.json'; import payableProxyJson from '../../build/published_contracts/PayableProxyForSoloMargin.json'; @@ -119,6 +121,7 @@ export class Contracts { public interestSetter: InterestSetter; public priceOracle: PriceOracle; public expiry: Expiry; + public expiryV2: ExpiryV2; public refunder: Refunder; public limitOrders: LimitOrders; public payableProxy: PayableProxy; @@ -167,6 +170,7 @@ export class Contracts { this.interestSetter = new this.web3.eth.Contract(interestSetterJson.abi) as InterestSetter; this.priceOracle = new this.web3.eth.Contract(priceOracleJson.abi) as PriceOracle; this.expiry = new this.web3.eth.Contract(expiryJson.abi) as Expiry; + this.expiryV2 = new this.web3.eth.Contract(expiryV2Json.abi) as ExpiryV2; this.refunder = new this.web3.eth.Contract(refunderJson.abi) as Refunder; this.limitOrders = new this.web3.eth.Contract(limitOrdersJson.abi) as LimitOrders; this.payableProxy = new this.web3.eth.Contract(payableProxyJson.abi) as PayableProxy; @@ -223,6 +227,7 @@ export class Contracts { { contract: this.interestSetter, json: interestSetterJson }, { contract: this.priceOracle, json: priceOracleJson }, { contract: this.expiry, json: expiryJson }, + { contract: this.expiryV2, json: expiryV2Json }, { contract: this.refunder, json: refunderJson }, { contract: this.limitOrders, json: limitOrdersJson }, { contract: this.payableProxy, json: payableProxyJson }, @@ -272,6 +277,7 @@ export class Contracts { this.interestSetter.options.from = account; this.priceOracle.options.from = account; this.expiry.options.from = account; + this.expiryV2.options.from = account; this.refunder.options.from = account; this.limitOrders.options.from = account; this.payableProxy.options.from = account; diff --git a/src/modules/ExpiryV2.ts b/src/modules/ExpiryV2.ts new file mode 100644 index 00000000..612d3873 --- /dev/null +++ b/src/modules/ExpiryV2.ts @@ -0,0 +1,106 @@ +import BigNumber from 'bignumber.js'; +import { Contracts } from '../lib/Contracts'; +import { + address, + ContractCallOptions, + ContractConstantCallOptions, + Integer, + TxResult, +} from '../../src/types'; + +export class ExpiryV2 { + private contracts: Contracts; + + // ============ Constructor ============ + + constructor( + contracts: Contracts, + ) { + this.contracts = contracts; + } + + // ============ Getters ============ + + public async getAdmin( + options?: ContractConstantCallOptions, + ): Promise
{ + return this.contracts.callConstantContractFunction( + this.contracts.expiryV2.methods.owner(), + options, + ); + } + + public async getExpiry( + accountOwner: address, + accountNumber: Integer, + marketId: Integer, + options?: ContractConstantCallOptions, + ): Promise { + const result = await this.contracts.callConstantContractFunction( + this.contracts.expiryV2.methods.getExpiry( + { + owner: accountOwner, + number: accountNumber.toFixed(0), + }, + marketId.toFixed(0), + ), + options, + ); + return new BigNumber(result); + } + + public async getApproval( + approver: address, + sender: address, + options?: ContractConstantCallOptions, + ): Promise { + const result = await this.contracts.callConstantContractFunction( + this.contracts.expiryV2.methods.g_approvedSender(approver, sender), + options, + ); + return new BigNumber(result); + } + + public async getPrices( + heldMarketId: Integer, + owedMarketId: Integer, + expiryTimestamp: Integer, + options?: ContractConstantCallOptions, + ): Promise<{heldPrice: Integer, owedPrice: Integer}> { + const result = await this.contracts.callConstantContractFunction( + this.contracts.expiryV2.methods.getSpreadAdjustedPrices( + heldMarketId.toFixed(0), + owedMarketId.toFixed(0), + expiryTimestamp.toFixed(0), + ), + options, + ); + + return { + heldPrice: new BigNumber(result[0].value), + owedPrice: new BigNumber(result[1].value), + }; + } + + public async getRampTime( + options?: ContractConstantCallOptions, + ): Promise { + const result = await this.contracts.callConstantContractFunction( + this.contracts.expiryV2.methods.g_expiryRampTime(), + options, + ); + return new BigNumber(result); + } + + // ============ Getters ============ + + public async setRampTime( + newExpiryRampTime: Integer, + options?: ContractCallOptions, + ): Promise { + return this.contracts.callContractFunction( + this.contracts.expiryV2.methods.ownerSetExpiryRampTime(newExpiryRampTime.toFixed(0)), + options, + ); + } +} diff --git a/src/modules/operate/AccountOperation.ts b/src/modules/operate/AccountOperation.ts index b26afc13..4f257075 100644 --- a/src/modules/operate/AccountOperation.ts +++ b/src/modules/operate/AccountOperation.ts @@ -23,6 +23,7 @@ import { AccountInfo, OperationAuthorization, SetExpiry, + SetExpiryV2, Refund, AccountActionWithOrder, Call, @@ -189,6 +190,32 @@ export class AccountOperation { return this; } + public setExpiryV2(args: SetExpiryV2): AccountOperation { + const callType = toBytes(INTEGERS.ZERO); + let callData = callType; + callData = callData.concat(toBytes(new BigNumber(64))); + callData = callData.concat(toBytes(new BigNumber(args.expiryV2Args.length))); + for (let i = 0; i < args.expiryV2Args.length; i += 1) { + const expiryV2Arg = args.expiryV2Args[i]; + callData = callData.concat(toBytes( + expiryV2Arg.accountOwner, + expiryV2Arg.accountId, + expiryV2Arg.marketId, + expiryV2Arg.timeDelta, + )); + } + this.addActionArgs( + args, + { + actionType: ActionType.Call, + otherAddress: this.contracts.expiryV2.options.address, + data: callData, + }, + ); + + return this; + } + public approveLimitOrder(args: AccountActionWithOrder): AccountOperation { this.addActionArgs( args, @@ -330,6 +357,65 @@ export class AccountOperation { prices: Integer[], spreadPremiums: Integer[], collateralPreferences: Integer[], + ): AccountOperation { + return this.fullyLiquidateExpiredAccountInternal( + primaryAccountOwner, + primaryAccountNumber, + expiredAccountOwner, + expiredAccountNumber, + expiredMarket, + expiryTimestamp, + blockTimestamp, + weis, + prices, + spreadPremiums, + collateralPreferences, + this.contracts.expiry.options.address, + ); + } + + public fullyLiquidateExpiredAccountV2( + primaryAccountOwner: address, + primaryAccountNumber: Integer, + expiredAccountOwner: address, + expiredAccountNumber: Integer, + expiredMarket: Integer, + expiryTimestamp: Integer, + blockTimestamp: Integer, + weis: Integer[], + prices: Integer[], + spreadPremiums: Integer[], + collateralPreferences: Integer[], + ): AccountOperation { + return this.fullyLiquidateExpiredAccountInternal( + primaryAccountOwner, + primaryAccountNumber, + expiredAccountOwner, + expiredAccountNumber, + expiredMarket, + expiryTimestamp, + blockTimestamp, + weis, + prices, + spreadPremiums, + collateralPreferences, + this.contracts.expiryV2.options.address, + ); + } + + private fullyLiquidateExpiredAccountInternal( + primaryAccountOwner: address, + primaryAccountNumber: Integer, + expiredAccountOwner: address, + expiredAccountNumber: Integer, + expiredMarket: Integer, + expiryTimestamp: Integer, + blockTimestamp: Integer, + weis: Integer[], + prices: Integer[], + spreadPremiums: Integer[], + collateralPreferences: Integer[], + contractAddress: address, ): AccountOperation { // hardcoded values const networkExpiryConstants = expiryConstants[this.networkId]; @@ -409,7 +495,7 @@ export class AccountOperation { expiredAccountOwner, expiredAccountNumber, ), - otherAddress: this.contracts.expiry.options.address, + otherAddress: contractAddress, data: toBytes(expiredMarket, expiryTimestamp), }, ); diff --git a/src/types.ts b/src/types.ts index 6da9c49d..ad72d326 100644 --- a/src/types.ts +++ b/src/types.ts @@ -226,6 +226,17 @@ export interface SetExpiry extends AccountAction { expiryTime: Integer; } +export interface ExpiryV2Arg { + accountOwner: address; + accountId: Integer; + marketId: Integer; + timeDelta: Integer; +} + +export interface SetExpiryV2 extends AccountAction { + expiryV2Args: ExpiryV2Arg[]; +} + export interface Refund extends AccountAction { receiverAccountOwner: address; receiverAccountId: Integer; From d7036fbe4eefd0bcddea48039a015ef4cf040d79 Mon Sep 17 00:00:00 2001 From: Brendan Chou Date: Fri, 23 Aug 2019 17:10:13 -0700 Subject: [PATCH 3/7] add basic tests --- .soliumignore | 2 +- __tests__/traders/ExpiryV2.test.ts | 313 ++++++++++++++++++++++-- contracts/external/traders/Expiry.sol | 4 +- contracts/external/traders/ExpiryV2.sol | 2 +- migrations/2_deploy.js | 10 + migrations/4_ownership.js | 4 + scripts/Artifacts.ts | 2 + src/Solo.ts | 3 + src/modules/ExpiryV2.ts | 15 +- src/modules/Logs.ts | 5 + src/modules/operate/AccountOperation.ts | 46 +++- src/types.ts | 28 +++ 12 files changed, 400 insertions(+), 34 deletions(-) diff --git a/.soliumignore b/.soliumignore index 1465fcfa..45985e82 100644 --- a/.soliumignore +++ b/.soliumignore @@ -1,4 +1,4 @@ node_modules -contracts/external/traders/Expiry.sol +contracts/external/traders/ExpiryV2.sol contracts/testing contracts/Migrations.sol diff --git a/__tests__/traders/ExpiryV2.test.ts b/__tests__/traders/ExpiryV2.test.ts index ee21ab94..9010f235 100644 --- a/__tests__/traders/ExpiryV2.test.ts +++ b/__tests__/traders/ExpiryV2.test.ts @@ -20,6 +20,7 @@ let snapshotId: string; let admin: address; let owner1: address; let owner2: address; +let rando: address; const accountNumber1 = INTEGERS.ZERO; const accountNumber2 = INTEGERS.ONE; @@ -30,6 +31,7 @@ const par = new BigNumber(10000); const zero = new BigNumber(0); const premium = new BigNumber('1.05'); const defaultPrice = new BigNumber('1e40'); +const defaultTimeDelta = new BigNumber(1234); let defaultGlob: Trade; let heldGlob: Trade; @@ -39,8 +41,9 @@ describe('Expiry', () => { solo = r.solo; accounts = r.accounts; admin = accounts[0]; - owner1 = solo.getDefaultAccount(); + owner1 = accounts[2]; owner2 = accounts[3]; + rando = accounts[4]; defaultGlob = { primaryAccountOwner: owner1, primaryAccountId: accountNumber1, @@ -73,16 +76,18 @@ describe('Expiry', () => { }; await resetEVM(); + await setupMarkets(solo, accounts); await Promise.all([ - setupMarkets(solo, accounts), solo.testing.setAccountBalance(owner2, accountNumber2, owedMarket, par.times(-1)), solo.testing.setAccountBalance(owner2, accountNumber2, heldMarket, par.times(2)), solo.testing.setAccountBalance(owner1, accountNumber1, owedMarket, par), solo.testing.setAccountBalance(owner2, accountNumber2, collateralMarket, par.times(4)), ]); + await Promise.all([ + setExpiryForSelf(INTEGERS.ONE), + solo.expiryV2.setApproval(owner1, defaultTimeDelta, { from: owner2 }), + ]); - // set expiry for one second from now, then wait - await setExpiryForSelf(INTEGERS.ONE); await fastForward(60 * 60 * 24); await mineAvgBlock(); @@ -93,6 +98,51 @@ describe('Expiry', () => { await resetEVM(snapshotId); }); + describe('setApproval', () => { + it('Succeeds for zero', async () => { + const txResult = await solo.expiryV2.setApproval( + owner1, + zero, + { from: owner2 }, + ); + + // check storage + const approval = await solo.expiryV2.getApproval(owner2, owner1); + expect(approval).toEqual(zero); + + // check logs + const logs = solo.logs.parseLogs(txResult); + expect(logs.length).toEqual(1); + const log = logs[0]; + expect(log.name).toEqual('LogSenderApproved'); + expect(log.args.approver).toEqual(owner2); + expect(log.args.sender).toEqual(owner1); + expect(log.args.minTimeDelta).toEqual(zero); + }); + + it('Succeeds for non-zero', async () => { + const defaultDelay = new BigNumber(425); + const txResult = await solo.expiryV2.setApproval( + owner1, + defaultDelay, + { from: owner2 }, + ); + + // check storage + const approval = await solo.expiryV2.getApproval(owner2, owner1); + expect(approval).toEqual(defaultDelay); + + // check logs + const logs = solo.logs.parseLogs(txResult); + expect(logs.length).toEqual(1); + const log = logs[0]; + expect(log.name).toEqual('LogSenderApproved'); + expect(log.args.approver).toEqual(owner2); + expect(log.args.sender).toEqual(owner1); + expect(log.args.minTimeDelta).toEqual(defaultDelay); + }); + }); + describe('callFunction (invalid)', () => { it('Fails for invalid callType', async () => { await expectThrow( @@ -119,7 +169,7 @@ describe('Expiry', () => { describe('callFunctionSetApproval', () => { it('Succeeds in setting approval', async () => { - const minTimeDeltas = [INTEGERS.ZERO, new BigNumber(1234)]; + const minTimeDeltas = [INTEGERS.ZERO, defaultTimeDelta]; for (let i = 0; i < minTimeDeltas.length; i += 1) { // make transaction const txResult = await solo.operation.initiate().setApprovalForExpiryV2({ @@ -145,24 +195,22 @@ describe('Expiry', () => { }); }); - describe('callFunctionSetExpiry', () => { + describe('callFunctionSetExpiry (self)', () => { it('Succeeds in setting expiry', async () => { - const timeDelta = new BigNumber(1234); - const txResult = await setExpiryForSelf(timeDelta); + const txResult = await setExpiryForSelf(defaultTimeDelta); await expectExpiry( txResult, owner2, accountNumber2, owedMarket, - timeDelta, + defaultTimeDelta, ); - console.log(`\tSet expiry gas used: ${txResult.gasUsed}`); + console.log(`\tSet expiry (self) gas used: ${txResult.gasUsed}`); }); it('Skips logs when necessary', async () => { - const timeDelta = new BigNumber(1234); - const txResult = await setExpiryForSelf(timeDelta); + const txResult = await setExpiryForSelf(defaultTimeDelta); const noLogs = solo.logs.parseLogs(txResult, { skipExpiryLogs: true }); const logs = solo.logs.parseLogs(txResult, { skipExpiryLogs: false }); expect(noLogs.filter((e: any) => e.name === 'ExpirySet').length).toEqual(0); @@ -170,9 +218,8 @@ describe('Expiry', () => { }); it('Sets expiry to zero for non-negative balances', async () => { - const timeDelta = new BigNumber(1234); await solo.testing.setAccountBalance(owner2, accountNumber2, owedMarket, par); - const txResult = await setExpiryForSelf(timeDelta); + const txResult = await setExpiryForSelf(defaultTimeDelta); await expectExpiry( txResult, owner2, @@ -195,6 +242,210 @@ describe('Expiry', () => { }); }); + describe('callFunctionSetExpiry (other)', () => { + it('Succeeds in setting expiry', async () => { + const txResult = await setExpiryForOther(defaultTimeDelta); + await expectExpiry( + txResult, + owner2, + accountNumber2, + owedMarket, + defaultTimeDelta, + ); + console.log(`\tSet expiry (other) gas used: ${txResult.gasUsed}`); + }); + + it('Bounds time by minimum approved timeDelta', async () => { + const txResult = await setExpiryForOther(defaultTimeDelta.div(2)); + await expectExpiry( + txResult, + owner2, + accountNumber2, + owedMarket, + defaultTimeDelta, + ); + }); + + it('Allows longer than minimum approved timeDelta', async () => { + const txResult = await setExpiryForOther(defaultTimeDelta.times(2)); + await expectExpiry( + txResult, + owner2, + accountNumber2, + owedMarket, + defaultTimeDelta.times(2), + ); + }); + + it('Do nothing if sender not approved', async () => { + const timestamp1 = await solo.expiryV2.getExpiry(owner2, accountNumber2, owedMarket); + + const txResult1 = await solo.operation.initiate().setExpiryV2({ + primaryAccountOwner: rando, + primaryAccountId: accountNumber1, + expiryV2Args: [{ + accountOwner: owner2, + accountId: accountNumber2, + marketId: owedMarket, + timeDelta: defaultTimeDelta, + }], + }).commit({ from: rando }); + expect(solo.logs.parseLogs(txResult1, { skipOperationLogs: true }).length).toEqual(0); + + const txResult2 = await solo.operation.initiate().setExpiryV2({ + primaryAccountOwner: rando, + primaryAccountId: accountNumber1, + expiryV2Args: [{ + accountOwner: owner2, + accountId: accountNumber2, + marketId: owedMarket, + timeDelta: zero, + }], + }).commit({ from: rando }); + expect(solo.logs.parseLogs(txResult2, { skipOperationLogs: true }).length).toEqual(0); + + const timestamp2 = await solo.expiryV2.getExpiry(owner2, accountNumber2, owedMarket); + expect(timestamp2).toEqual(timestamp1); + }); + + it('Do nothing if sender not approved (non-negative balance)', async () => { + await solo.testing.setAccountBalance(owner2, accountNumber2, owedMarket, par); + const timestamp1 = await solo.expiryV2.getExpiry(owner2, accountNumber2, owedMarket); + + const txResult1 = await solo.operation.initiate().setExpiryV2({ + primaryAccountOwner: rando, + primaryAccountId: accountNumber1, + expiryV2Args: [{ + accountOwner: owner2, + accountId: accountNumber2, + marketId: owedMarket, + timeDelta: defaultTimeDelta, + }], + }).commit({ from: rando }); + expect(solo.logs.parseLogs(txResult1, { skipOperationLogs: true }).length).toEqual(0); + + const txResult2 = await solo.operation.initiate().setExpiryV2({ + primaryAccountOwner: rando, + primaryAccountId: accountNumber1, + expiryV2Args: [{ + accountOwner: owner2, + accountId: accountNumber2, + marketId: owedMarket, + timeDelta: zero, + }], + }).commit({ from: rando }); + expect(solo.logs.parseLogs(txResult2, { skipOperationLogs: true }).length).toEqual(0); + + const timestamp2 = await solo.expiryV2.getExpiry(owner2, accountNumber2, owedMarket); + expect(timestamp2).toEqual(timestamp1); + }); + + it('Set it for multiple', async () => { + await Promise.all([ + solo.testing.setAccountBalance(owner1, accountNumber1, owedMarket, par.times(-1)), + solo.testing.setAccountBalance(owner1, accountNumber1, collateralMarket, par.times(4)), + solo.testing.setAccountBalance(rando, accountNumber1, heldMarket, par.times(-1)), + solo.expiryV2.setApproval(owner1, defaultTimeDelta, { from: rando }), + ]); + const txResult = await solo.operation.initiate().setExpiryV2({ + primaryAccountOwner: owner1, + primaryAccountId: accountNumber1, + expiryV2Args: [ + { + accountOwner: owner2, + accountId: accountNumber2, + marketId: owedMarket, + timeDelta: zero, + }, + { + accountOwner: owner1, + accountId: accountNumber1, + marketId: owedMarket, + timeDelta: defaultTimeDelta.div(2), + }, + { + accountOwner: rando, + accountId: accountNumber1, + marketId: heldMarket, + timeDelta: defaultTimeDelta.times(2), + }, + { + accountOwner: rando, + accountId: accountNumber1, + marketId: owedMarket, + timeDelta: defaultTimeDelta.div(2), + }, + ], + }).commit({ from: owner1 }); + + // check logs + const { timestamp } = await solo.web3.eth.getBlock(txResult.blockNumber); + const logs = solo.logs.parseLogs(txResult, { skipOperationLogs: true }); + expect(logs.length).toEqual(4); + expect(logs[0].name).toEqual('ExpirySet'); + expect(logs[1].name).toEqual('ExpirySet'); + expect(logs[2].name).toEqual('ExpirySet'); + expect(logs[3].name).toEqual('ExpirySet'); + expect(logs[0].args.owner).toEqual(owner2); + expect(logs[0].args.number).toEqual(accountNumber2); + expect(logs[0].args.marketId).toEqual(owedMarket); + expect(logs[0].args.time).toEqual(zero); + expect(logs[1].args.owner).toEqual(owner1); + expect(logs[1].args.number).toEqual(accountNumber1); + expect(logs[1].args.marketId).toEqual(owedMarket); + expect(logs[1].args.time).toEqual(defaultTimeDelta.div(2).plus(timestamp)); + expect(logs[2].args.owner).toEqual(rando); + expect(logs[2].args.number).toEqual(accountNumber1); + expect(logs[2].args.marketId).toEqual(heldMarket); + expect(logs[2].args.time).toEqual(defaultTimeDelta.times(2).plus(timestamp)); + expect(logs[3].args.owner).toEqual(rando); + expect(logs[3].args.number).toEqual(accountNumber1); + expect(logs[3].args.marketId).toEqual(owedMarket); + expect(logs[3].args.time).toEqual(zero); + + // check storage + const [ + expiry1, + expiry2, + expiry3, + expiry4, + ] = await Promise.all([ + solo.expiryV2.getExpiry(owner2, accountNumber2, owedMarket), + solo.expiryV2.getExpiry(owner1, accountNumber1, owedMarket), + solo.expiryV2.getExpiry(rando, accountNumber1, heldMarket), + solo.expiryV2.getExpiry(rando, accountNumber1, owedMarket), + ]); + expect(expiry1).toEqual(zero); + expect(expiry2).toEqual(defaultTimeDelta.div(2).plus(timestamp)); + expect(expiry3).toEqual(defaultTimeDelta.times(2).plus(timestamp)); + expect(expiry4).toEqual(zero); + }); + + it('Sets expiry to zero for non-negative balances', async () => { + await solo.testing.setAccountBalance(owner2, accountNumber2, owedMarket, par); + const txResult = await setExpiryForOther(defaultTimeDelta); + await expectExpiry( + txResult, + owner2, + accountNumber2, + owedMarket, + zero, + ); + }); + + it('Allows setting expiry back to zero even for non-negative balances', async () => { + await solo.testing.setAccountBalance(owner2, accountNumber2, owedMarket, par); + const txResult = await setExpiryForOther(zero); + await expectExpiry( + txResult, + owner2, + accountNumber2, + owedMarket, + zero, + ); + }); + }); + describe('expire account (heldAmount)', () => { beforeEach(async () => { await solo.testing.setAccountBalance(owner2, accountNumber2, heldMarket, par); @@ -365,7 +616,7 @@ describe('Expiry', () => { }); it('Fails for a future expiry', async () => { - await setExpiryForSelf(new BigNumber(1234)); + await setExpiryForSelf(defaultTimeDelta); await expectExpireRevert( heldGlob, 'ExpiryV2: Borrow not yet expired', @@ -376,7 +627,7 @@ describe('Expiry', () => { await expectExpireRevert( { ...heldGlob, - data: toBytes(owedMarket, new BigNumber(1234)), + data: toBytes(owedMarket, defaultTimeDelta), }, 'ExpiryV2: Expiry past maxExpiry', ); @@ -587,7 +838,7 @@ describe('Expiry', () => { }); it('Fails for a future expiry', async () => { - await setExpiryForSelf(new BigNumber(1234)); + await setExpiryForSelf(defaultTimeDelta); await expectExpireRevert( {}, 'ExpiryV2: Borrow not yet expired', @@ -597,7 +848,7 @@ describe('Expiry', () => { it('Fails for an expiry past maxExpiry', async () => { await expectExpireRevert( { - data: toBytes(owedMarket, new BigNumber(1234)), + data: toBytes(owedMarket, defaultTimeDelta), }, 'ExpiryV2: Expiry past maxExpiry', ); @@ -672,7 +923,7 @@ describe('Expiry', () => { prices, premiums, collateralPreferences, - ).commit(); + ).commit({ from: owner1 }); const balances = await Promise.all([ solo.getters.getAccountPar(owner2, accountNumber2, owedMarket), @@ -723,7 +974,7 @@ describe('Expiry', () => { prices, premiums, collateralPreferences, - ).commit(); + ).commit({ from: owner1 }); const balances = await Promise.all([ solo.getters.getAccountPar(owner2, accountNumber2, owedMarket), @@ -782,7 +1033,7 @@ describe('Expiry', () => { prices, premiums, collateralPreferences, - ).commit(); + ).commit({ from: owner1 }); const balances = await Promise.all([ solo.getters.getAccountPar(owner2, accountNumber2, owedMarket), @@ -859,7 +1110,7 @@ describe('Expiry', () => { denomination: AmountDenomination.Principal, reference: AmountReference.Target, }, - }).commit(); + }).commit({ from: owner1 }); const [ held1, @@ -884,7 +1135,7 @@ describe('Expiry', () => { // ============ Helper Functions ============ async function setExpiryForSelf(timeDelta: BigNumber, options?: any) { - const txResult = await solo.operation.initiate().setExpiryV2({ + return solo.operation.initiate().setExpiryV2({ primaryAccountOwner: owner2, primaryAccountId: accountNumber2, expiryV2Args: [{ @@ -894,7 +1145,19 @@ async function setExpiryForSelf(timeDelta: BigNumber, options?: any) { marketId: owedMarket, }], }).commit({ ...options, from: owner2 }); - return txResult; +} + +async function setExpiryForOther(timeDelta: BigNumber, options?: any) { + return solo.operation.initiate().setExpiryV2({ + primaryAccountOwner: owner1, + primaryAccountId: accountNumber1, + expiryV2Args: [{ + timeDelta, + accountOwner: owner2, + accountId: accountNumber2, + marketId: owedMarket, + }], + }).commit({ ...options, from: owner1 }); } async function expectExpireOkay( @@ -902,7 +1165,7 @@ async function expectExpireOkay( options?: Object, ) { const combinedGlob = { ...defaultGlob, ...glob }; - return solo.operation.initiate().trade(combinedGlob).commit(options); + return solo.operation.initiate().trade(combinedGlob).commit({ ...options, from: owner1 }); } async function expectExpireRevert( diff --git a/contracts/external/traders/Expiry.sol b/contracts/external/traders/Expiry.sol index 449b9fcf..bde4a8bf 100644 --- a/contracts/external/traders/Expiry.sol +++ b/contracts/external/traders/Expiry.sol @@ -34,14 +34,14 @@ import { OnlySolo } from "../helpers/OnlySolo.sol"; /** - * @title ExpiryOld + * @title Expiry * @author dYdX * * Sets the negative balance for an account to expire at a certain time. This allows any other * account to repay that negative balance after expiry using any positive balance in the same * account. The arbitrage incentive is the same as liquidation in the base protocol. */ -contract ExpiryOld is +contract Expiry is Ownable, OnlySolo, ICallee, diff --git a/contracts/external/traders/ExpiryV2.sol b/contracts/external/traders/ExpiryV2.sol index 198d38c7..6e2041db 100644 --- a/contracts/external/traders/ExpiryV2.sol +++ b/contracts/external/traders/ExpiryV2.sol @@ -286,7 +286,7 @@ contract ExpiryV2 is if (minApprovedTimeDelta == 0) { // don't do anything if sender is not approved continue; - } else { + } else if (exp.timeDelta > 0) { // bound the time by the minimum approved timeDelta timeDelta = Math.max(minApprovedTimeDelta, exp.timeDelta).to32(); } diff --git a/migrations/2_deploy.js b/migrations/2_deploy.js index 805116c0..3bb85809 100644 --- a/migrations/2_deploy.js +++ b/migrations/2_deploy.js @@ -59,6 +59,7 @@ const WETH9 = artifacts.require('WETH9'); // Second-Layer Contracts const PayableProxyForSoloMargin = artifacts.require('PayableProxyForSoloMargin'); const Expiry = artifacts.require('Expiry'); +const ExpiryV2 = artifacts.require('ExpiryV2'); const Refunder = artifacts.require('Refunder'); const LiquidatorProxyV1ForSoloMargin = artifacts.require('LiquidatorProxyV1ForSoloMargin'); const LimitOrders = artifacts.require('LimitOrders'); @@ -186,6 +187,11 @@ async function deploySecondLayer(deployer, network) { soloMargin.address, getExpiryRampTime(), ), + deployer.deploy( + ExpiryV2, + soloMargin.address, + getExpiryRampTime(), + ), deployer.deploy( Refunder, soloMargin.address, @@ -216,6 +222,10 @@ async function deploySecondLayer(deployer, network) { Expiry.address, true, ), + soloMargin.ownerSetGlobalOperator( + ExpiryV2.address, + true, + ), soloMargin.ownerSetGlobalOperator( Refunder.address, true, diff --git a/migrations/4_ownership.js b/migrations/4_ownership.js index ee6f211f..6d74faf6 100644 --- a/migrations/4_ownership.js +++ b/migrations/4_ownership.js @@ -26,6 +26,7 @@ const { const SoloMargin = artifacts.require('SoloMargin'); const Expiry = artifacts.require('Expiry'); +const ExpiryV2 = artifacts.require('ExpiryV2'); const Refunder = artifacts.require('Refunder'); const DaiPriceOracle = artifacts.require('DaiPriceOracle'); const LimitOrders = artifacts.require('LimitOrders'); @@ -42,6 +43,7 @@ const migration = async (deployer, network) => { deployedSoloMargin, deployedDaiPriceOracle, deployedExpiry, + deployedExpiryV2, deployedRefunder, deployedLimitOrders, deployedSignedOperationProxy, @@ -49,6 +51,7 @@ const migration = async (deployer, network) => { SoloMargin.deployed(), DaiPriceOracle.deployed(), Expiry.deployed(), + ExpiryV2.deployed(), Refunder.deployed(), LimitOrders.deployed(), SignedOperationProxy.deployed(), @@ -58,6 +61,7 @@ const migration = async (deployer, network) => { deployedSoloMargin.transferOwnership(partiallyDelayedMultisig), deployedDaiPriceOracle.transferOwnership(nonDelayedMultisig), deployedExpiry.transferOwnership(partiallyDelayedMultisig), + deployedExpiryV2.transferOwnership(partiallyDelayedMultisig), deployedRefunder.transferOwnership(partiallyDelayedMultisig), deployedLimitOrders.transferOwnership(partiallyDelayedMultisig), deployedSignedOperationProxy.transferOwnership(partiallyDelayedMultisig), diff --git a/scripts/Artifacts.ts b/scripts/Artifacts.ts index 18101a82..8636cfad 100644 --- a/scripts/Artifacts.ts +++ b/scripts/Artifacts.ts @@ -7,6 +7,7 @@ import { default as IErc20 } from '../build/contracts/IErc20.json'; import { default as IInterestSetter } from '../build/contracts/IInterestSetter.json'; import { default as IPriceOracle } from '../build/contracts/IPriceOracle.json'; import { default as Expiry } from '../build/contracts/Expiry.json'; +import { default as ExpiryV2 } from '../build/contracts/ExpiryV2.json'; import { default as Refunder } from '../build/contracts/Refunder.json'; import { default as LimitOrders } from '../build/contracts/LimitOrders.json'; import { default as PayableProxyForSoloMargin } @@ -52,6 +53,7 @@ export default { IInterestSetter, IPriceOracle, Expiry, + ExpiryV2, Refunder, LimitOrders, PayableProxyForSoloMargin, diff --git a/src/Solo.ts b/src/Solo.ts index c6e37b84..7bbf20f6 100644 --- a/src/Solo.ts +++ b/src/Solo.ts @@ -22,6 +22,7 @@ import { Contracts } from './lib/Contracts'; import { Interest } from './lib/Interest'; import { Operation } from './modules/operate/Operation'; import { Token } from './modules/Token'; +import { ExpiryV2 } from './modules/ExpiryV2'; import { Oracle } from './modules/Oracle'; import { Weth } from './modules/Weth'; import { Admin } from './modules/Admin'; @@ -41,6 +42,7 @@ export class Solo { public interest: Interest; public testing: Testing; public token: Token; + public expiryV2: ExpiryV2; public oracle: Oracle; public weth: Weth; public web3: Web3; @@ -78,6 +80,7 @@ export class Solo { this.contracts = new Contracts(realProvider, networkId, this.web3, options); this.interest = new Interest(networkId); this.token = new Token(this.contracts); + this.expiryV2 = new ExpiryV2(this.contracts); this.oracle = new Oracle(this.contracts); this.weth = new Weth(this.contracts, this.token); this.testing = new Testing(realProvider, this.contracts, this.token); diff --git a/src/modules/ExpiryV2.ts b/src/modules/ExpiryV2.ts index 612d3873..b7e8609c 100644 --- a/src/modules/ExpiryV2.ts +++ b/src/modules/ExpiryV2.ts @@ -92,7 +92,20 @@ export class ExpiryV2 { return new BigNumber(result); } - // ============ Getters ============ + // ============ Setters ============ + + public async setApproval( + sender: address, + minTimeDelta: Integer, + options?: ContractCallOptions, + ): Promise { + return this.contracts.callContractFunction( + this.contracts.expiryV2.methods.approveSender(sender, minTimeDelta.toFixed(0)), + options, + ); + } + + // ============ Admin ============ public async setRampTime( newExpiryRampTime: Integer, diff --git a/src/modules/Logs.ts b/src/modules/Logs.ts index a625d695..c3debc81 100644 --- a/src/modules/Logs.ts +++ b/src/modules/Logs.ts @@ -16,6 +16,7 @@ import { abi as operationAbi } from '../../build/published_contracts/Events.json import { abi as adminAbi } from '../../build/published_contracts/AdminImpl.json'; import { abi as permissionAbi } from '../../build/published_contracts/Permission.json'; import { abi as expiryAbi } from '../../build/published_contracts/Expiry.json'; +import { abi as expiryV2Abi } from '../../build/published_contracts/ExpiryV2.json'; import { abi as refunderAbi } from '../../build/published_contracts/Refunder.json'; import { abi as limitOrdersAbi } from '../../build/published_contracts/LimitOrders.json'; import { @@ -51,6 +52,7 @@ export class Logs { } if (options.skipExpiryLogs) { logs = logs.filter((log: any) => !this.logIsFrom(log, expiryAbi)); + logs = logs.filter((log: any) => !this.logIsFrom(log, expiryV2Abi)); } if (options.skipRefunderLogs) { logs = logs.filter((log: any) => !this.logIsFrom(log, refunderAbi)); @@ -116,6 +118,9 @@ export class Logs { case this.contracts.expiry.options.address: { return this.parseLogWithContract(this.contracts.expiry, log); } + case this.contracts.expiryV2.options.address: { + return this.parseLogWithContract(this.contracts.expiryV2, log); + } case this.contracts.refunder.options.address: { return this.parseLogWithContract(this.contracts.refunder, log); } diff --git a/src/modules/operate/AccountOperation.ts b/src/modules/operate/AccountOperation.ts index 4f257075..53ba35da 100644 --- a/src/modules/operate/AccountOperation.ts +++ b/src/modules/operate/AccountOperation.ts @@ -24,6 +24,8 @@ import { OperationAuthorization, SetExpiry, SetExpiryV2, + SetApprovalForExpiryV2, + ExpiryV2CallFunctionType, Refund, AccountActionWithOrder, Call, @@ -190,8 +192,25 @@ export class AccountOperation { return this; } + public setApprovalForExpiryV2(args: SetApprovalForExpiryV2): AccountOperation { + this.addActionArgs( + args, + { + actionType: ActionType.Call, + otherAddress: this.contracts.expiryV2.options.address, + data: toBytes( + ExpiryV2CallFunctionType.SetApproval, + args.sender, + args.minTimeDelta, + ), + }, + ); + + return this; + } + public setExpiryV2(args: SetExpiryV2): AccountOperation { - const callType = toBytes(INTEGERS.ZERO); + const callType = toBytes(ExpiryV2CallFunctionType.SetExpiry); let callData = callType; callData = callData.concat(toBytes(new BigNumber(64))); callData = callData.concat(toBytes(new BigNumber(args.expiryV2Args.length))); @@ -328,8 +347,27 @@ export class AccountOperation { }); } - public liquidateExpiredAccount(liquidate: Liquidate, minExpiry?: Integer): AccountOperation { - const maxExpiryTimestamp = minExpiry || INTEGERS.ONES_31; + public liquidateExpiredAccount(liquidate: Liquidate, maxExpiry?: Integer): AccountOperation { + return this.liquidateExpiredAccountInternal( + liquidate, + maxExpiry || INTEGERS.ONES_31, + this.contracts.expiry.options.address, + ); + } + + public liquidateExpiredAccountV2(liquidate: Liquidate, maxExpiry?: Integer): AccountOperation { + return this.liquidateExpiredAccountInternal( + liquidate, + maxExpiry || INTEGERS.ONES_31, + this.contracts.expiryV2.options.address, + ); + } + + private liquidateExpiredAccountInternal( + liquidate: Liquidate, + maxExpiryTimestamp: Integer, + contractAddress: address, + ): AccountOperation { this.addActionArgs( liquidate, { @@ -338,7 +376,7 @@ export class AccountOperation { primaryMarketId: liquidate.liquidMarketId.toFixed(0), secondaryMarketId: liquidate.payoutMarketId.toFixed(0), otherAccountId: this.getAccountId(liquidate.liquidAccountOwner, liquidate.liquidAccountId), - otherAddress: this.contracts.expiry.options.address, + otherAddress: contractAddress, data: toBytes(liquidate.liquidMarketId, maxExpiryTimestamp), }, ); diff --git a/src/types.ts b/src/types.ts index ad72d326..51882439 100644 --- a/src/types.ts +++ b/src/types.ts @@ -336,6 +336,34 @@ export interface BalanceUpdate { newPar: Integer; } +// ============ Expiry ============ + +export interface SetExpiry extends AccountAction { + marketId: Integer; + expiryTime: Integer; +} + +export interface ExpiryV2Arg { + accountOwner: address; + accountId: Integer; + marketId: Integer; + timeDelta: Integer; +} + +export interface SetExpiryV2 extends AccountAction { + expiryV2Args: ExpiryV2Arg[]; +} + +export interface SetApprovalForExpiryV2 extends AccountAction { + sender: address; + minTimeDelta: Integer; +} + +export enum ExpiryV2CallFunctionType { + SetExpiry = 0, + SetApproval = 1, +} + // ============ Limit Orders ============ export interface LimitOrder { From 5d79c54697f289bea124824a76850cfeb2754b5f Mon Sep 17 00:00:00 2001 From: Brendan Chou Date: Mon, 26 Aug 2019 15:09:05 -0700 Subject: [PATCH 4/7] small changes for comments --- .../external/multisig/DelayedMultiSig.sol | 2 +- contracts/external/multisig/MultiSig.sol | 2 +- contracts/external/traders/Expiry.sol | 88 +++++++++---------- contracts/external/traders/ExpiryV2.sol | 88 +++++++++---------- contracts/external/traders/Refunder.sol | 2 +- 5 files changed, 91 insertions(+), 91 deletions(-) diff --git a/contracts/external/multisig/DelayedMultiSig.sol b/contracts/external/multisig/DelayedMultiSig.sol index d468ffad..fc181841 100644 --- a/contracts/external/multisig/DelayedMultiSig.sol +++ b/contracts/external/multisig/DelayedMultiSig.sol @@ -119,7 +119,7 @@ contract DelayedMultiSig is emit TimeLockChange(_secondsTimeLocked); } - // ============ Owner Functions ============ + // ============ Admin Functions ============ /** * Allows an owner to confirm a transaction. diff --git a/contracts/external/multisig/MultiSig.sol b/contracts/external/multisig/MultiSig.sol index 2d70af8f..ce313594 100644 --- a/contracts/external/multisig/MultiSig.sol +++ b/contracts/external/multisig/MultiSig.sol @@ -272,7 +272,7 @@ contract MultiSig { emit RequirementChange(_required); } - // ============ Owner Functions ============ + // ============ Admin Functions ============ /** * Allows an owner to submit and confirm a transaction. diff --git a/contracts/external/traders/Expiry.sol b/contracts/external/traders/Expiry.sol index bde4a8bf..7707829e 100644 --- a/contracts/external/traders/Expiry.sol +++ b/contracts/external/traders/Expiry.sol @@ -89,7 +89,7 @@ contract Expiry is g_expiryRampTime = expiryRampTime; } - // ============ Owner Functions ============ + // ============ Admin Functions ============ function ownerSetExpiryRampTime( uint256 newExpiryRampTime @@ -101,49 +101,6 @@ contract Expiry is g_expiryRampTime = newExpiryRampTime; } - // ============ Getters ============ - - function getExpiry( - Account.Info memory account, - uint256 marketId - ) - public - view - returns (uint32) - { - return g_expiries[account.owner][account.number][marketId]; - } - - function getSpreadAdjustedPrices( - uint256 heldMarketId, - uint256 owedMarketId, - uint32 expiry - ) - public - view - returns ( - Monetary.Price memory, - Monetary.Price memory - ) - { - Decimal.D256 memory spread = SOLO_MARGIN.getLiquidationSpreadForPair( - heldMarketId, - owedMarketId - ); - - uint256 expiryAge = Time.currentTime().sub(expiry); - - if (expiryAge < g_expiryRampTime) { - spread.value = Math.getPartial(spread.value, expiryAge, g_expiryRampTime); - } - - Monetary.Price memory heldPrice = SOLO_MARGIN.getMarketPrice(heldMarketId); - Monetary.Price memory owedPrice = SOLO_MARGIN.getMarketPrice(owedMarketId); - owedPrice.value = owedPrice.value.add(Decimal.mul(owedPrice.value, spread)); - - return (heldPrice, owedPrice); - } - // ============ Only-Solo Functions ============ function callFunction( @@ -232,6 +189,49 @@ contract Expiry is ); } + // ============ Getters ============ + + function getExpiry( + Account.Info memory account, + uint256 marketId + ) + public + view + returns (uint32) + { + return g_expiries[account.owner][account.number][marketId]; + } + + function getSpreadAdjustedPrices( + uint256 heldMarketId, + uint256 owedMarketId, + uint32 expiry + ) + public + view + returns ( + Monetary.Price memory, + Monetary.Price memory + ) + { + Decimal.D256 memory spread = SOLO_MARGIN.getLiquidationSpreadForPair( + heldMarketId, + owedMarketId + ); + + uint256 expiryAge = Time.currentTime().sub(expiry); + + if (expiryAge < g_expiryRampTime) { + spread.value = Math.getPartial(spread.value, expiryAge, g_expiryRampTime); + } + + Monetary.Price memory heldPrice = SOLO_MARGIN.getMarketPrice(heldMarketId); + Monetary.Price memory owedPrice = SOLO_MARGIN.getMarketPrice(owedMarketId); + owedPrice.value = owedPrice.value.add(Decimal.mul(owedPrice.value, spread)); + + return (heldPrice, owedPrice); + } + // ============ Private Functions ============ function getTradeCostInternal( diff --git a/contracts/external/traders/ExpiryV2.sol b/contracts/external/traders/ExpiryV2.sol index 6e2041db..38c99ea0 100644 --- a/contracts/external/traders/ExpiryV2.sol +++ b/contracts/external/traders/ExpiryV2.sol @@ -117,7 +117,7 @@ contract ExpiryV2 is g_expiryRampTime = expiryRampTime; } - // ============ Owner Functions ============ + // ============ Admin Functions ============ function ownerSetExpiryRampTime( uint256 newExpiryRampTime @@ -140,49 +140,6 @@ contract ExpiryV2 is setApproval(msg.sender, sender, minTimeDelta); } - // ============ Getters ============ - - function getExpiry( - Account.Info memory account, - uint256 marketId - ) - public - view - returns (uint32) - { - return g_expiries[account.owner][account.number][marketId]; - } - - function getSpreadAdjustedPrices( - uint256 heldMarketId, - uint256 owedMarketId, - uint32 expiry - ) - public - view - returns ( - Monetary.Price memory, - Monetary.Price memory - ) - { - Decimal.D256 memory spread = SOLO_MARGIN.getLiquidationSpreadForPair( - heldMarketId, - owedMarketId - ); - - uint256 expiryAge = Time.currentTime().sub(expiry); - - if (expiryAge < g_expiryRampTime) { - spread.value = Math.getPartial(spread.value, expiryAge, g_expiryRampTime); - } - - Monetary.Price memory heldPrice = SOLO_MARGIN.getMarketPrice(heldMarketId); - Monetary.Price memory owedPrice = SOLO_MARGIN.getMarketPrice(owedMarketId); - owedPrice.value = owedPrice.value.add(Decimal.mul(owedPrice.value, spread)); - - return (heldPrice, owedPrice); - } - // ============ Only-Solo Functions ============ function callFunction( @@ -263,6 +220,49 @@ contract ExpiryV2 is ); } + // ============ Getters ============ + + function getExpiry( + Account.Info memory account, + uint256 marketId + ) + public + view + returns (uint32) + { + return g_expiries[account.owner][account.number][marketId]; + } + + function getSpreadAdjustedPrices( + uint256 heldMarketId, + uint256 owedMarketId, + uint32 expiry + ) + public + view + returns ( + Monetary.Price memory, + Monetary.Price memory + ) + { + Decimal.D256 memory spread = SOLO_MARGIN.getLiquidationSpreadForPair( + heldMarketId, + owedMarketId + ); + + uint256 expiryAge = Time.currentTime().sub(expiry); + + if (expiryAge < g_expiryRampTime) { + spread.value = Math.getPartial(spread.value, expiryAge, g_expiryRampTime); + } + + Monetary.Price memory heldPrice = SOLO_MARGIN.getMarketPrice(heldMarketId); + Monetary.Price memory owedPrice = SOLO_MARGIN.getMarketPrice(owedMarketId); + owedPrice.value = owedPrice.value.add(Decimal.mul(owedPrice.value, spread)); + + return (heldPrice, owedPrice); + } + // ============ Private Functions ============ function callFunctionSetExpiry( diff --git a/contracts/external/traders/Refunder.sol b/contracts/external/traders/Refunder.sol index 69c61ffd..758a54c6 100644 --- a/contracts/external/traders/Refunder.sol +++ b/contracts/external/traders/Refunder.sol @@ -79,7 +79,7 @@ contract Refunder is } } - // ============ Owner Functions ============ + // ============ Admin Functions ============ function addGiver( address giver From d18ea89bd91863edde559e8c0ac04e903e3b9dde Mon Sep 17 00:00:00 2001 From: Brendan Chou Date: Tue, 27 Aug 2019 11:03:32 -0700 Subject: [PATCH 5/7] add explicit assertion --- contracts/external/traders/ExpiryV2.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/external/traders/ExpiryV2.sol b/contracts/external/traders/ExpiryV2.sol index 38c99ea0..0daf49b8 100644 --- a/contracts/external/traders/ExpiryV2.sol +++ b/contracts/external/traders/ExpiryV2.sol @@ -289,6 +289,8 @@ contract ExpiryV2 is } else if (exp.timeDelta > 0) { // bound the time by the minimum approved timeDelta timeDelta = Math.max(minApprovedTimeDelta, exp.timeDelta).to32(); + } else { + assert(exp.timeDelta == 0); } } From 9d76bc50ef9ae01813b8251849dd8aaea5da09a7 Mon Sep 17 00:00:00 2001 From: Brendan Chou Date: Tue, 27 Aug 2019 11:20:11 -0700 Subject: [PATCH 6/7] deploy ExpiryV2 --- .env | 2 +- migrations/deployed.json | 12 ++++++++++++ scripts/SaveDeployedAddresses.ts | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.env b/.env index 7c81ca44..507d4258 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ RPC_NODE_URI=http://0.0.0.0:8445 RESET_SNAPSHOT_ID=0x2 NETWORK_ID=1001 -GAS_PRICE=12000000000 +GAS_PRICE=12000000003 diff --git a/migrations/deployed.json b/migrations/deployed.json index 06aacc6f..94fa556e 100644 --- a/migrations/deployed.json +++ b/migrations/deployed.json @@ -174,5 +174,17 @@ "address": "0x6d23ef7EAC5d4aD14597D2EBb1D28Ef5E2dF68c6", "transactionHash": "0x809b9263f66b557004ff6e7faf33aad0cf514400ef0505a5fa99d26f5d9ac14b" } + }, + "ExpiryV2": { + "1": { + "links": {}, + "address": "0x36c6655ddBF0a6c48C5b93C18A7cCc5650B167be", + "transactionHash": "0x38c74a15760f9b8283b1f8440c926eb858d98eab73515ecdd6d85da6bfd79328" + }, + "42": { + "links": {}, + "address": "0x213D833B56D0D39D3f1Ec67f25a4d49D951e5BC4", + "transactionHash": "0x652a14a39fb3df91209ed9934bc88f7e71f737008c1d916b96c83118e23ed4a1" + } } } diff --git a/scripts/SaveDeployedAddresses.ts b/scripts/SaveDeployedAddresses.ts index 9ad8bc4f..3a171140 100644 --- a/scripts/SaveDeployedAddresses.ts +++ b/scripts/SaveDeployedAddresses.ts @@ -29,7 +29,7 @@ async function run() { }); }); - const json = JSON.stringify(deployed, null, 4); + const json = JSON.stringify(deployed, null, 4) + '\n'; const filename = 'deployed.json'; await writeFileAsync(directory + filename, json, null); From 463a4704ca348d0486d596ccf65e4ad76db58e0a Mon Sep 17 00:00:00 2001 From: Brendan Chou Date: Tue, 27 Aug 2019 11:57:43 -0700 Subject: [PATCH 7/7] 0.14.0 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0fd610c4..4de36d68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@dydxprotocol/solo", - "version": "0.13.4", + "version": "0.14.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index d681a9b0..37b13a9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dydxprotocol/solo", - "version": "0.13.4", + "version": "0.14.0", "description": "Ethereum Smart Contracts and TypeScript library used for the dYdX Solo-Margin Trading Protocol", "main": "dist/src/index.js", "types": "dist/src/index.d.ts",