Compare commits
8 Commits
@0x/contra
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f240ffc89 | ||
|
|
c68b5d7844 | ||
|
|
09ed106d4c | ||
|
|
a6b92fc658 | ||
|
|
4be4a1a30b | ||
|
|
9bede5d331 | ||
|
|
b50d4aee6d | ||
|
|
55bc367bd6 |
@@ -1,4 +1,13 @@
|
||||
[
|
||||
{
|
||||
"version": "2.0.37",
|
||||
"changes": [
|
||||
{
|
||||
"note": "Patch epoch finalization issue",
|
||||
"pr": 221
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1619596077,
|
||||
"version": "2.0.36",
|
||||
|
||||
55
contracts/staking/contracts/src/StakingPatch.sol
Normal file
55
contracts/staking/contracts/src/StakingPatch.sol
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
|
||||
Copyright 2019 ZeroEx Intl.
|
||||
|
||||
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.9;
|
||||
pragma experimental ABIEncoderV2;
|
||||
|
||||
import "./interfaces/IStaking.sol";
|
||||
import "./sys/MixinParams.sol";
|
||||
import "./stake/MixinStake.sol";
|
||||
import "./fees/MixinExchangeFees.sol";
|
||||
|
||||
|
||||
contract StakingPatch is
|
||||
IStaking,
|
||||
MixinParams,
|
||||
MixinStake,
|
||||
MixinExchangeFees
|
||||
{
|
||||
/// @dev Initialize storage owned by this contract.
|
||||
/// This function should not be called directly.
|
||||
/// The StakingProxy contract will call it in `attachStakingContract()`.
|
||||
function init()
|
||||
public
|
||||
onlyAuthorized
|
||||
{
|
||||
uint256 currentEpoch_ = currentEpoch;
|
||||
uint256 prevEpoch = currentEpoch_.safeSub(1);
|
||||
|
||||
// Patch corrupted state
|
||||
aggregatedStatsByEpoch[prevEpoch].numPoolsToFinalize = 0;
|
||||
this.endEpoch();
|
||||
|
||||
uint256 lastPoolId_ = 57;
|
||||
for (uint256 i = 1; i <= lastPoolId_; i++) {
|
||||
this.finalizePool(bytes32(i));
|
||||
}
|
||||
// Ensure that current epoch's state is not corrupted
|
||||
aggregatedStatsByEpoch[currentEpoch_].numPoolsToFinalize = 0;
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,10 @@ contract MixinExchangeFees is
|
||||
{
|
||||
_assertValidProtocolFee(protocolFee);
|
||||
|
||||
if (protocolFee == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Transfer the protocol fee to this address if it should be paid in
|
||||
// WETH.
|
||||
if (msg.value == 0) {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"config": {
|
||||
"publicInterfaceContracts": "IStaking,IStakingEvents,IStakingProxy,IZrxVault,LibStakingRichErrors,Staking,StakingProxy,ZrxVault,TestStaking",
|
||||
"abis:comment": "This list is auto-generated by contracts-gen. Don't edit manually.",
|
||||
"abis": "./test/generated-artifacts/@(IStaking|IStakingEvents|IStakingProxy|IStorage|IStorageInit|IStructs|IZrxVault|LibCobbDouglas|LibFixedMath|LibFixedMathRichErrors|LibSafeDowncast|LibStakingRichErrors|MixinAbstract|MixinConstants|MixinCumulativeRewards|MixinDeploymentConstants|MixinExchangeFees|MixinExchangeManager|MixinFinalizer|MixinParams|MixinScheduler|MixinStake|MixinStakeBalances|MixinStakeStorage|MixinStakingPool|MixinStakingPoolRewards|MixinStorage|Staking|StakingProxy|TestAssertStorageParams|TestCobbDouglas|TestCumulativeRewardTracking|TestDelegatorRewards|TestExchangeManager|TestFinalizer|TestInitTarget|TestLibFixedMath|TestLibSafeDowncast|TestMixinCumulativeRewards|TestMixinParams|TestMixinScheduler|TestMixinStake|TestMixinStakeBalances|TestMixinStakeStorage|TestMixinStakingPool|TestMixinStakingPoolRewards|TestProtocolFees|TestProxyDestination|TestStaking|TestStakingNoWETH|TestStakingProxy|TestStakingProxyUnit|TestStorageLayoutAndConstants|ZrxVault).json"
|
||||
"abis": "./test/generated-artifacts/@(IStaking|IStakingEvents|IStakingProxy|IStorage|IStorageInit|IStructs|IZrxVault|LibCobbDouglas|LibFixedMath|LibFixedMathRichErrors|LibSafeDowncast|LibStakingRichErrors|MixinAbstract|MixinConstants|MixinCumulativeRewards|MixinDeploymentConstants|MixinExchangeFees|MixinExchangeManager|MixinFinalizer|MixinParams|MixinScheduler|MixinStake|MixinStakeBalances|MixinStakeStorage|MixinStakingPool|MixinStakingPoolRewards|MixinStorage|Staking|StakingPatch|StakingProxy|TestAssertStorageParams|TestCobbDouglas|TestCumulativeRewardTracking|TestDelegatorRewards|TestExchangeManager|TestFinalizer|TestInitTarget|TestLibFixedMath|TestLibSafeDowncast|TestMixinCumulativeRewards|TestMixinParams|TestMixinScheduler|TestMixinStake|TestMixinStakeBalances|TestMixinStakeStorage|TestMixinStakingPool|TestMixinStakingPoolRewards|TestProtocolFees|TestProxyDestination|TestStaking|TestStakingNoWETH|TestStakingProxy|TestStakingProxyUnit|TestStorageLayoutAndConstants|ZrxVault).json"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -33,6 +33,7 @@ import * as MixinStakingPool from '../test/generated-artifacts/MixinStakingPool.
|
||||
import * as MixinStakingPoolRewards from '../test/generated-artifacts/MixinStakingPoolRewards.json';
|
||||
import * as MixinStorage from '../test/generated-artifacts/MixinStorage.json';
|
||||
import * as Staking from '../test/generated-artifacts/Staking.json';
|
||||
import * as StakingPatch from '../test/generated-artifacts/StakingPatch.json';
|
||||
import * as StakingProxy from '../test/generated-artifacts/StakingProxy.json';
|
||||
import * as TestAssertStorageParams from '../test/generated-artifacts/TestAssertStorageParams.json';
|
||||
import * as TestCobbDouglas from '../test/generated-artifacts/TestCobbDouglas.json';
|
||||
@@ -61,6 +62,7 @@ import * as TestStorageLayoutAndConstants from '../test/generated-artifacts/Test
|
||||
import * as ZrxVault from '../test/generated-artifacts/ZrxVault.json';
|
||||
export const artifacts = {
|
||||
Staking: Staking as ContractArtifact,
|
||||
StakingPatch: StakingPatch as ContractArtifact,
|
||||
StakingProxy: StakingProxy as ContractArtifact,
|
||||
ZrxVault: ZrxVault as ContractArtifact,
|
||||
MixinExchangeFees: MixinExchangeFees as ContractArtifact,
|
||||
|
||||
66
contracts/staking/test/patch_mainnet_test.ts
Normal file
66
contracts/staking/test/patch_mainnet_test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { blockchainTests, constants, expect, filterLogsToArguments } from '@0x/contracts-test-utils';
|
||||
import { BigNumber, logUtils } from '@0x/utils';
|
||||
import * as _ from 'lodash';
|
||||
|
||||
import { artifacts } from './artifacts';
|
||||
import { StakingEvents, StakingPatchContract, StakingProxyContract, StakingProxyEvents } from './wrappers';
|
||||
|
||||
const abis = _.mapValues(artifacts, v => v.compilerOutput.abi);
|
||||
const STAKING_PROXY = '0xa26e80e7dea86279c6d778d702cc413e6cffa777';
|
||||
const STAKING_OWNER = '0x7d3455421bbc5ed534a83c88fd80387dc8271392';
|
||||
const EXCHANGE_PROXY = '0xdef1c0ded9bec7f1a1670819833240f027b25eff';
|
||||
blockchainTests.configure({
|
||||
fork: {
|
||||
unlockedAccounts: [STAKING_OWNER, EXCHANGE_PROXY],
|
||||
},
|
||||
});
|
||||
|
||||
blockchainTests.fork('Staking patch mainnet fork tests', env => {
|
||||
let stakingProxyContract: StakingProxyContract;
|
||||
let patchedStakingPatchContract: StakingPatchContract;
|
||||
|
||||
before(async () => {
|
||||
stakingProxyContract = new StakingProxyContract(STAKING_PROXY, env.provider, undefined, abis);
|
||||
patchedStakingPatchContract = await StakingPatchContract.deployFrom0xArtifactAsync(
|
||||
artifacts.Staking,
|
||||
env.provider,
|
||||
env.txDefaults,
|
||||
artifacts,
|
||||
);
|
||||
});
|
||||
|
||||
it('Staking proxy successfully attaches to patched logic', async () => {
|
||||
const tx = await stakingProxyContract
|
||||
.attachStakingContract(patchedStakingPatchContract.address)
|
||||
.awaitTransactionSuccessAsync({ from: STAKING_OWNER, gasPrice: 0 }, { shouldValidate: false });
|
||||
expect(filterLogsToArguments(tx.logs, StakingProxyEvents.StakingContractAttachedToProxy)).to.deep.equal([
|
||||
{
|
||||
newStakingPatchContractAddress: patchedStakingPatchContract.address,
|
||||
},
|
||||
]);
|
||||
expect(filterLogsToArguments(tx.logs, StakingEvents.EpochEnded).length).to.equal(1);
|
||||
expect(filterLogsToArguments(tx.logs, StakingEvents.EpochFinalized).length).to.equal(1);
|
||||
logUtils.log(`${tx.gasUsed} gas used`);
|
||||
});
|
||||
|
||||
it('Patched staking handles 0 gas protocol fees', async () => {
|
||||
const staking = new StakingPatchContract(STAKING_PROXY, env.provider, undefined, abis);
|
||||
const maker = '0x7b1886e49ab5433bb46f7258548092dc8cdca28b';
|
||||
const zeroFeeTx = await staking
|
||||
.payProtocolFee(maker, constants.NULL_ADDRESS, constants.ZERO_AMOUNT)
|
||||
.awaitTransactionSuccessAsync({ from: EXCHANGE_PROXY, gasPrice: 0 }, { shouldValidate: false });
|
||||
// StakingPoolEarnedRewardsInEpoch should _not_ be emitted for a zero protocol fee.
|
||||
// tslint:disable-next-line:no-unused-expression
|
||||
expect(filterLogsToArguments(zeroFeeTx.logs, StakingEvents.StakingPoolEarnedRewardsInEpoch)).to.be.empty;
|
||||
|
||||
// Coincidentally there's some ETH in the ExchangeProxy
|
||||
const nonZeroFeeTx = await staking
|
||||
.payProtocolFee(maker, constants.NULL_ADDRESS, new BigNumber(1))
|
||||
.awaitTransactionSuccessAsync({ from: EXCHANGE_PROXY, gasPrice: 0, value: 1 }, { shouldValidate: false });
|
||||
// StakingPoolEarnedRewardsInEpoch _should_ be emitted for a non-zero protocol fee.
|
||||
expect(
|
||||
filterLogsToArguments(nonZeroFeeTx.logs, StakingEvents.StakingPoolEarnedRewardsInEpoch),
|
||||
).to.have.lengthOf(1);
|
||||
});
|
||||
});
|
||||
// tslint:enable:no-unnecessary-type-assertion
|
||||
@@ -31,6 +31,7 @@ export * from '../test/generated-wrappers/mixin_staking_pool';
|
||||
export * from '../test/generated-wrappers/mixin_staking_pool_rewards';
|
||||
export * from '../test/generated-wrappers/mixin_storage';
|
||||
export * from '../test/generated-wrappers/staking';
|
||||
export * from '../test/generated-wrappers/staking_patch';
|
||||
export * from '../test/generated-wrappers/staking_proxy';
|
||||
export * from '../test/generated-wrappers/test_assert_storage_params';
|
||||
export * from '../test/generated-wrappers/test_cobb_douglas';
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"test/generated-artifacts/MixinStakingPoolRewards.json",
|
||||
"test/generated-artifacts/MixinStorage.json",
|
||||
"test/generated-artifacts/Staking.json",
|
||||
"test/generated-artifacts/StakingPatch.json",
|
||||
"test/generated-artifacts/StakingProxy.json",
|
||||
"test/generated-artifacts/TestAssertStorageParams.json",
|
||||
"test/generated-artifacts/TestCobbDouglas.json",
|
||||
|
||||
@@ -478,7 +478,7 @@ contract MetaTransactionsFeature is
|
||||
|
||||
/// @dev Execute a `INativeOrdersFeature.fillRfqOrder()` meta-transaction call
|
||||
/// by decoding the call args and translating the call to the internal
|
||||
/// `INativeOrdersFeature._fillRfqOrder()` variant, where we can overrideunimpleme
|
||||
/// `INativeOrdersFeature._fillRfqOrder()` variant, where we can override
|
||||
/// the taker address.
|
||||
function _executeFillRfqOrderCall(ExecuteState memory state)
|
||||
private
|
||||
|
||||
@@ -55,7 +55,7 @@ contract MultiplexFeature is
|
||||
/// @dev Name of this feature.
|
||||
string public constant override FEATURE_NAME = "MultiplexFeature";
|
||||
/// @dev Version of this feature.
|
||||
uint256 public immutable override FEATURE_VERSION = _encodeVersion(1, 0, 1);
|
||||
uint256 public immutable override FEATURE_VERSION = _encodeVersion(1, 1, 0);
|
||||
|
||||
/// @dev The WETH token contract.
|
||||
IEtherTokenV06 private immutable weth;
|
||||
@@ -231,26 +231,55 @@ contract MultiplexFeature is
|
||||
);
|
||||
continue;
|
||||
}
|
||||
require(
|
||||
order.takerToken == fillData.inputToken &&
|
||||
order.makerToken == fillData.outputToken,
|
||||
"MultiplexFeature::_batchFill/RFQ_ORDER_INVALID_TOKENS"
|
||||
);
|
||||
// Try filling the RFQ order. Swallows reverts.
|
||||
try
|
||||
INativeOrdersFeature(address(this))._fillRfqOrder
|
||||
(
|
||||
order,
|
||||
signature,
|
||||
inputTokenAmount.safeDowncastToUint128(),
|
||||
msg.sender
|
||||
)
|
||||
returns (uint128 takerTokenFilledAmount, uint128 makerTokenFilledAmount)
|
||||
{
|
||||
// Increment the sold and bought amounts.
|
||||
soldAmount = soldAmount.safeAdd(takerTokenFilledAmount);
|
||||
outputTokenAmount = outputTokenAmount.safeAdd(makerTokenFilledAmount);
|
||||
} catch {}
|
||||
if (fillData.inputToken.isTokenETH()) {
|
||||
require(
|
||||
order.takerToken == weth &&
|
||||
order.makerToken == fillData.outputToken,
|
||||
"MultiplexFeature::_batchFill/RFQ_ORDER_INVALID_TOKENS"
|
||||
);
|
||||
inputTokenAmount = LibSafeMathV06.min256(
|
||||
inputTokenAmount,
|
||||
remainingEth
|
||||
);
|
||||
// Try filling the RFQ order. Swallows reverts.
|
||||
try
|
||||
INativeOrdersFeature(address(this))._fillRfqOrderWithEth
|
||||
{value: inputTokenAmount}
|
||||
(
|
||||
order,
|
||||
signature,
|
||||
inputTokenAmount.safeDowncastToUint128(),
|
||||
msg.sender
|
||||
)
|
||||
returns (uint128 takerTokenFilledAmount, uint128 makerTokenFilledAmount)
|
||||
{
|
||||
// Increment the sold and bought amounts.
|
||||
soldAmount = soldAmount.safeAdd(takerTokenFilledAmount);
|
||||
outputTokenAmount = outputTokenAmount.safeAdd(makerTokenFilledAmount);
|
||||
remainingEth = remainingEth.safeSub(takerTokenFilledAmount);
|
||||
} catch {}
|
||||
} else {
|
||||
require(
|
||||
order.takerToken == fillData.inputToken &&
|
||||
order.makerToken == fillData.outputToken,
|
||||
"MultiplexFeature::_batchFill/RFQ_ORDER_INVALID_TOKENS"
|
||||
);
|
||||
// Try filling the RFQ order. Swallows reverts.
|
||||
try
|
||||
INativeOrdersFeature(address(this))._fillRfqOrder
|
||||
(
|
||||
order,
|
||||
signature,
|
||||
inputTokenAmount.safeDowncastToUint128(),
|
||||
msg.sender
|
||||
)
|
||||
returns (uint128 takerTokenFilledAmount, uint128 makerTokenFilledAmount)
|
||||
{
|
||||
// Increment the sold and bought amounts.
|
||||
soldAmount = soldAmount.safeAdd(takerTokenFilledAmount);
|
||||
outputTokenAmount = outputTokenAmount.safeAdd(makerTokenFilledAmount);
|
||||
} catch {}
|
||||
}
|
||||
} else if (wrappedCall.selector == this._sellToUniswap.selector) {
|
||||
(address[] memory tokens, bool isSushi) = abi.decode(
|
||||
wrappedCall.data,
|
||||
|
||||
@@ -34,7 +34,7 @@ contract NativeOrdersFeature is
|
||||
/// @dev Name of this feature.
|
||||
string public constant override FEATURE_NAME = "LimitOrders";
|
||||
/// @dev Version of this feature.
|
||||
uint256 public immutable override FEATURE_VERSION = _encodeVersion(1, 2, 0);
|
||||
uint256 public immutable override FEATURE_VERSION = _encodeVersion(1, 3, 0);
|
||||
|
||||
constructor(
|
||||
address zeroExAddress,
|
||||
@@ -69,6 +69,7 @@ contract NativeOrdersFeature is
|
||||
_registerFeatureFunction(this.fillOrKillRfqOrder.selector);
|
||||
_registerFeatureFunction(this._fillLimitOrder.selector);
|
||||
_registerFeatureFunction(this._fillRfqOrder.selector);
|
||||
_registerFeatureFunction(this._fillRfqOrderWithEth.selector);
|
||||
_registerFeatureFunction(this.cancelLimitOrder.selector);
|
||||
_registerFeatureFunction(this.cancelRfqOrder.selector);
|
||||
_registerFeatureFunction(this.batchCancelLimitOrders.selector);
|
||||
|
||||
@@ -137,6 +137,24 @@ interface INativeOrdersFeature is
|
||||
external
|
||||
returns (uint128 takerTokenFilledAmount, uint128 makerTokenFilledAmount);
|
||||
|
||||
/// @dev Fill an RFQ order using ETH. Taker token must be WETH.
|
||||
/// Internal variant.
|
||||
/// @param order The RFQ order.
|
||||
/// @param signature The order signature.
|
||||
/// @param takerTokenFillAmount Maximum taker token to fill this order with.
|
||||
/// @param taker The order taker.
|
||||
/// @return takerTokenFilledAmount How much maker token was filled.
|
||||
/// @return makerTokenFilledAmount How much maker token was filled.
|
||||
function _fillRfqOrderWithEth(
|
||||
LibNativeOrder.RfqOrder calldata order,
|
||||
LibSignature.Signature calldata signature,
|
||||
uint128 takerTokenFillAmount,
|
||||
address taker
|
||||
)
|
||||
external
|
||||
payable
|
||||
returns (uint128 takerTokenFilledAmount, uint128 makerTokenFilledAmount);
|
||||
|
||||
/// @dev Cancel a single limit order. The caller must be the maker or a valid order signer.
|
||||
/// Silently succeeds if the order has already been cancelled.
|
||||
/// @param order The limit order.
|
||||
|
||||
@@ -66,6 +66,8 @@ abstract contract NativeOrdersSettlement is
|
||||
uint128 takerTokenFillAmount;
|
||||
// How much taker token amount has already been filled in this order.
|
||||
uint128 takerTokenFilledAmount;
|
||||
|
||||
bool useEthBalance;
|
||||
}
|
||||
|
||||
/// @dev Params for `_fillLimitOrderPrivate()`
|
||||
@@ -158,7 +160,8 @@ abstract contract NativeOrdersSettlement is
|
||||
order,
|
||||
signature,
|
||||
takerTokenFillAmount,
|
||||
msg.sender
|
||||
msg.sender,
|
||||
false
|
||||
);
|
||||
(takerTokenFilledAmount, makerTokenFilledAmount) = (
|
||||
results.takerTokenFilledAmount,
|
||||
@@ -224,7 +227,8 @@ abstract contract NativeOrdersSettlement is
|
||||
order,
|
||||
signature,
|
||||
takerTokenFillAmount,
|
||||
msg.sender
|
||||
msg.sender,
|
||||
false
|
||||
);
|
||||
// Must have filled exactly the amount requested.
|
||||
if (results.takerTokenFilledAmount < takerTokenFillAmount) {
|
||||
@@ -273,9 +277,7 @@ abstract contract NativeOrdersSettlement is
|
||||
);
|
||||
}
|
||||
|
||||
/// @dev Fill an RFQ order. Internal variant. ETH protocol fees can be
|
||||
/// attached to this call. Any unspent ETH will be refunded to
|
||||
/// `msg.sender` (not `sender`).
|
||||
/// @dev Fill an RFQ order. Internal variant.
|
||||
/// @param order The RFQ order.
|
||||
/// @param signature The order signature.
|
||||
/// @param takerTokenFillAmount Maximum taker token to fill this order with.
|
||||
@@ -298,7 +300,8 @@ abstract contract NativeOrdersSettlement is
|
||||
order,
|
||||
signature,
|
||||
takerTokenFillAmount,
|
||||
taker
|
||||
taker,
|
||||
false
|
||||
);
|
||||
(takerTokenFilledAmount, makerTokenFilledAmount) = (
|
||||
results.takerTokenFilledAmount,
|
||||
@@ -306,6 +309,49 @@ abstract contract NativeOrdersSettlement is
|
||||
);
|
||||
}
|
||||
|
||||
/// @dev Fill an RFQ order using ETH. Taker token must be WETH.
|
||||
/// Internal variant.
|
||||
/// @param order The RFQ order.
|
||||
/// @param signature The order signature.
|
||||
/// @param takerTokenFillAmount Maximum taker token to fill this order with.
|
||||
/// @param taker The order taker.
|
||||
/// @return takerTokenFilledAmount How much maker token was filled.
|
||||
/// @return makerTokenFilledAmount How much maker token was filled.
|
||||
function _fillRfqOrderWithEth(
|
||||
LibNativeOrder.RfqOrder memory order,
|
||||
LibSignature.Signature memory signature,
|
||||
uint128 takerTokenFillAmount,
|
||||
address taker
|
||||
)
|
||||
public
|
||||
virtual
|
||||
payable
|
||||
onlySelf
|
||||
returns (uint128 takerTokenFilledAmount, uint128 makerTokenFilledAmount)
|
||||
{
|
||||
require(
|
||||
msg.value == takerTokenFillAmount,
|
||||
"NativeOrdersFeature/ETH_FILL_AMOUNT_MISMATCH"
|
||||
);
|
||||
FillNativeOrderResults memory results =
|
||||
_fillRfqOrderPrivate(
|
||||
order,
|
||||
signature,
|
||||
takerTokenFillAmount,
|
||||
taker,
|
||||
true
|
||||
);
|
||||
(takerTokenFilledAmount, makerTokenFilledAmount) = (
|
||||
results.takerTokenFilledAmount,
|
||||
results.makerTokenFilledAmount
|
||||
);
|
||||
if (takerTokenFilledAmount < msg.value) {
|
||||
uint256 refundAmount = msg.value.safeSub(takerTokenFilledAmount);
|
||||
(bool success,) = msg.sender.call{value: refundAmount}("");
|
||||
require(success, "NativeOrdersFeature/ETH_REFUND_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
/// @dev Mark what tx.origin addresses are allowed to fill an order that
|
||||
/// specifies the message sender as its txOrigin.
|
||||
/// @param origins An array of origin addresses to update.
|
||||
@@ -393,7 +439,8 @@ abstract contract NativeOrdersSettlement is
|
||||
makerAmount: params.order.makerAmount,
|
||||
takerAmount: params.order.takerAmount,
|
||||
takerTokenFillAmount: params.takerTokenFillAmount,
|
||||
takerTokenFilledAmount: orderInfo.takerTokenFilledAmount
|
||||
takerTokenFilledAmount: orderInfo.takerTokenFilledAmount,
|
||||
useEthBalance: false
|
||||
})
|
||||
);
|
||||
|
||||
@@ -437,7 +484,8 @@ abstract contract NativeOrdersSettlement is
|
||||
LibNativeOrder.RfqOrder memory order,
|
||||
LibSignature.Signature memory signature,
|
||||
uint128 takerTokenFillAmount,
|
||||
address taker
|
||||
address taker,
|
||||
bool useEthBalance
|
||||
)
|
||||
private
|
||||
returns (FillNativeOrderResults memory results)
|
||||
@@ -498,7 +546,8 @@ abstract contract NativeOrdersSettlement is
|
||||
makerAmount: order.makerAmount,
|
||||
takerAmount: order.takerAmount,
|
||||
takerTokenFillAmount: takerTokenFillAmount,
|
||||
takerTokenFilledAmount: orderInfo.takerTokenFilledAmount
|
||||
takerTokenFilledAmount: orderInfo.takerTokenFilledAmount,
|
||||
useEthBalance: useEthBalance
|
||||
})
|
||||
);
|
||||
|
||||
@@ -549,13 +598,22 @@ abstract contract NativeOrdersSettlement is
|
||||
// function if the order is cancelled.
|
||||
settleInfo.takerTokenFilledAmount.safeAdd128(takerTokenFilledAmount);
|
||||
|
||||
// Transfer taker -> maker.
|
||||
_transferERC20Tokens(
|
||||
settleInfo.takerToken,
|
||||
settleInfo.taker,
|
||||
settleInfo.maker,
|
||||
takerTokenFilledAmount
|
||||
);
|
||||
if (settleInfo.useEthBalance) {
|
||||
require(
|
||||
settleInfo.takerToken == WETH,
|
||||
"NativeOrdersFeature/USE_ETH_BALANCE_INVALID"
|
||||
);
|
||||
WETH.deposit{value: takerTokenFilledAmount}();
|
||||
WETH.transfer(settleInfo.maker, takerTokenFilledAmount);
|
||||
} else {
|
||||
// Transfer taker -> maker.
|
||||
_transferERC20Tokens(
|
||||
settleInfo.takerToken,
|
||||
settleInfo.taker,
|
||||
settleInfo.maker,
|
||||
takerTokenFilledAmount
|
||||
);
|
||||
}
|
||||
|
||||
// Transfer maker -> taker.
|
||||
_transferERC20Tokens(
|
||||
|
||||
@@ -32,12 +32,12 @@ abstract contract FixinProtocolFees {
|
||||
|
||||
/// @dev The protocol fee multiplier.
|
||||
uint32 public immutable PROTOCOL_FEE_MULTIPLIER;
|
||||
/// @dev The WETH token contract.
|
||||
IEtherTokenV06 internal immutable WETH;
|
||||
/// @dev The `FeeCollectorController` contract.
|
||||
FeeCollectorController private immutable FEE_COLLECTOR_CONTROLLER;
|
||||
/// @dev Hash of the fee collector init code.
|
||||
bytes32 private immutable FEE_COLLECTOR_INIT_CODE_HASH;
|
||||
/// @dev The WETH token contract.
|
||||
IEtherTokenV06 private immutable WETH;
|
||||
/// @dev The staking contract.
|
||||
IStaking private immutable STAKING;
|
||||
|
||||
|
||||
@@ -87,6 +87,60 @@ abstract contract FixinTokenSpender {
|
||||
}
|
||||
}
|
||||
|
||||
/// @dev Transfers ERC20 tokens from `address(this)` to `to`.
|
||||
/// @param token The token to spend.
|
||||
/// @param to The recipient of the tokens.
|
||||
/// @param amount The amount of `token` to transfer.
|
||||
function _transferERC20Tokens(
|
||||
IERC20TokenV06 token,
|
||||
address to,
|
||||
uint256 amount
|
||||
)
|
||||
internal
|
||||
{
|
||||
require(address(token) != address(this), "FixinTokenSpender/CANNOT_INVOKE_SELF");
|
||||
|
||||
assembly {
|
||||
let ptr := mload(0x40) // free memory pointer
|
||||
|
||||
// selector for transfer(address,uint256)
|
||||
mstore(ptr, 0xa9059cbb00000000000000000000000000000000000000000000000000000000)
|
||||
mstore(add(ptr, 0x04), and(to, ADDRESS_MASK))
|
||||
mstore(add(ptr, 0x24), amount)
|
||||
|
||||
let success := call(
|
||||
gas(),
|
||||
and(token, ADDRESS_MASK),
|
||||
0,
|
||||
ptr,
|
||||
0x44,
|
||||
ptr,
|
||||
32
|
||||
)
|
||||
|
||||
let rdsize := returndatasize()
|
||||
|
||||
// Check for ERC20 success. ERC20 tokens should return a boolean,
|
||||
// but some don't. We accept 0-length return data as success, or at
|
||||
// least 32 bytes that starts with a 32-byte boolean true.
|
||||
success := and(
|
||||
success, // call itself succeeded
|
||||
or(
|
||||
iszero(rdsize), // no return data, or
|
||||
and(
|
||||
iszero(lt(rdsize, 32)), // at least 32 bytes
|
||||
eq(mload(ptr), 1) // starts with uint256(1)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if iszero(success) {
|
||||
returndatacopy(ptr, 0, rdsize)
|
||||
revert(ptr, rdsize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// @dev Gets the maximum amount of an ERC20 token `token` that can be
|
||||
/// pulled from `owner` by this address.
|
||||
/// @param token The token to spend.
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
[
|
||||
{
|
||||
"version": "6.10.1",
|
||||
"changes": [
|
||||
{
|
||||
"note": "Reactivate PancakeSwapV2 and BakerySwap VIP on BSC",
|
||||
"pr": 222
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "6.10.0",
|
||||
"changes": [
|
||||
{
|
||||
"note": "Add LUSD Curve pool",
|
||||
"pr": 218
|
||||
},
|
||||
{
|
||||
"note": "Fix exchangeProxyGasOverhead for fallback path",
|
||||
"pr": 215
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "6.9.1",
|
||||
"changes": [
|
||||
|
||||
@@ -197,9 +197,8 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase {
|
||||
this.chainId === ChainId.BSC &&
|
||||
isDirectSwapCompatible(quote, optsWithDefaults, [
|
||||
ERC20BridgeSource.PancakeSwap,
|
||||
// Temporarily removed until latest PancakeSwap VIP has been deployed
|
||||
// ERC20BridgeSource.PancakeSwapV2,
|
||||
// ERC20BridgeSource.BakerySwap,
|
||||
ERC20BridgeSource.PancakeSwapV2,
|
||||
ERC20BridgeSource.BakerySwap,
|
||||
ERC20BridgeSource.SushiSwap,
|
||||
ERC20BridgeSource.ApeSwap,
|
||||
ERC20BridgeSource.CafeSwap,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
SUSHISWAP_ROUTER_BY_CHAIN_ID,
|
||||
SWERVE_MAINNET_INFOS,
|
||||
UNISWAPV2_ROUTER_BY_CHAIN_ID,
|
||||
XSIGMA_MAINNET_INFOS,
|
||||
} from './constants';
|
||||
import { CurveInfo, ERC20BridgeSource } from './types';
|
||||
|
||||
@@ -203,6 +204,19 @@ export function getSaddleInfosForPair(chainId: ChainId, takerToken: string, make
|
||||
);
|
||||
}
|
||||
|
||||
export function getXSigmaInfosForPair(chainId: ChainId, takerToken: string, makerToken: string): CurveInfo[] {
|
||||
if (chainId !== ChainId.Mainnet) {
|
||||
return [];
|
||||
}
|
||||
return Object.values(XSIGMA_MAINNET_INFOS).filter(c =>
|
||||
[makerToken, takerToken].every(
|
||||
t =>
|
||||
(c.tokens.includes(t) && c.metaToken === undefined) ||
|
||||
(c.tokens.includes(t) && c.metaToken !== undefined && [makerToken, takerToken].includes(c.metaToken)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getShellLikeInfosForPair(
|
||||
chainId: ChainId,
|
||||
takerToken: string,
|
||||
@@ -231,7 +245,8 @@ export function getCurveLikeInfosForPair(
|
||||
| ERC20BridgeSource.Belt
|
||||
| ERC20BridgeSource.Ellipsis
|
||||
| ERC20BridgeSource.Smoothy
|
||||
| ERC20BridgeSource.Saddle,
|
||||
| ERC20BridgeSource.Saddle
|
||||
| ERC20BridgeSource.XSigma,
|
||||
): CurveInfo[] {
|
||||
switch (source) {
|
||||
case ERC20BridgeSource.Curve:
|
||||
@@ -250,6 +265,8 @@ export function getCurveLikeInfosForPair(
|
||||
return getSmoothyInfosForPair(chainId, takerToken, makerToken);
|
||||
case ERC20BridgeSource.Saddle:
|
||||
return getSaddleInfosForPair(chainId, takerToken, makerToken);
|
||||
case ERC20BridgeSource.XSigma:
|
||||
return getXSigmaInfosForPair(chainId, takerToken, makerToken);
|
||||
default:
|
||||
throw new Error(`Unknown Curve like source ${source}`);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ export const SELL_SOURCE_FILTER_BY_CHAIN_ID = valueByChainId<SourceFilters>(
|
||||
ERC20BridgeSource.Smoothy,
|
||||
ERC20BridgeSource.Component,
|
||||
ERC20BridgeSource.Saddle,
|
||||
ERC20BridgeSource.XSigma,
|
||||
]),
|
||||
[ChainId.Ropsten]: new SourceFilters([
|
||||
ERC20BridgeSource.Kyber,
|
||||
@@ -152,6 +153,7 @@ export const BUY_SOURCE_FILTER_BY_CHAIN_ID = valueByChainId<SourceFilters>(
|
||||
ERC20BridgeSource.Smoothy,
|
||||
ERC20BridgeSource.Component,
|
||||
ERC20BridgeSource.Saddle,
|
||||
ERC20BridgeSource.XSigma,
|
||||
]),
|
||||
[ChainId.Ropsten]: new SourceFilters([
|
||||
ERC20BridgeSource.Kyber,
|
||||
@@ -293,6 +295,7 @@ export const MAINNET_TOKENS = {
|
||||
STABLEx: '0xcd91538b91b4ba7797d39a2f66e63810b50a33d0',
|
||||
alUSD: '0xbc6da0fe9ad5f3b0d58160288917aa56653660e9',
|
||||
FRAX: '0x853d955acef822db058eb8505911ed77f175b99e',
|
||||
LUSD: '0x5f98805a4e8be255a32880fdec7f6728c6568ba0',
|
||||
};
|
||||
|
||||
export const BSC_TOKENS = {
|
||||
@@ -342,6 +345,7 @@ export const CURVE_POOLS = {
|
||||
STABLEx: '0x3252efd4ea2d6c78091a1f43982ee2c3659cc3d1',
|
||||
alUSD: '0x43b4fdfd4ff969587185cdb6f0bd875c5fc83f8c',
|
||||
FRAX: '0xd632f22692fac7611d2aa1c0d552930d43caed3b',
|
||||
LUSD: '0xed279fdd11ca84beef15af5d39bb4d4bee23f0ca',
|
||||
};
|
||||
|
||||
export const SWERVE_POOLS = {
|
||||
@@ -375,6 +379,10 @@ export const ELLIPSIS_POOLS = {
|
||||
threePool: '0x160caed03795365f3a589f10c379ffa7d75d4e76',
|
||||
};
|
||||
|
||||
export const XSIGMA_POOLS = {
|
||||
stable: '0x3333333ACdEdBbC9Ad7bda0876e60714195681c5',
|
||||
};
|
||||
|
||||
export const DEFAULT_INTERMEDIATE_TOKENS_BY_CHAIN_ID = valueByChainId<string[]>(
|
||||
{
|
||||
[ChainId.Mainnet]: [
|
||||
@@ -628,6 +636,11 @@ export const CURVE_MAINNET_INFOS: { [name: string]: CurveInfo } = {
|
||||
pool: CURVE_POOLS.FRAX,
|
||||
gasSchedule: 387e3,
|
||||
}),
|
||||
[CURVE_POOLS.LUSD]: createCurveMetaTriPool({
|
||||
token: MAINNET_TOKENS.LUSD,
|
||||
pool: CURVE_POOLS.LUSD,
|
||||
gasSchedule: 387e3,
|
||||
}),
|
||||
};
|
||||
|
||||
export const SWERVE_MAINNET_INFOS: { [name: string]: CurveInfo } = {
|
||||
@@ -677,6 +690,14 @@ export const ELLIPSIS_BSC_INFOS: { [name: string]: CurveInfo } = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const XSIGMA_MAINNET_INFOS: { [name: string]: CurveInfo } = {
|
||||
[XSIGMA_POOLS.stable]: createCurveExchangePool({
|
||||
tokens: [MAINNET_TOKENS.DAI, MAINNET_TOKENS.USDC, MAINNET_TOKENS.USDT],
|
||||
pool: XSIGMA_POOLS.stable,
|
||||
gasSchedule: 150e3,
|
||||
}),
|
||||
};
|
||||
|
||||
// Curve pools like using custom selectors
|
||||
export const SADDLE_MAINNET_INFOS: { [name: string]: CurveInfo } = {
|
||||
[SADDLE_POOLS.stables]: {
|
||||
@@ -1073,6 +1094,7 @@ export const DEFAULT_GAS_SCHEDULE: Required<FeeSchedule> = {
|
||||
[ERC20BridgeSource.Ellipsis]: fillData => (fillData as CurveFillData).pool.gasSchedule,
|
||||
[ERC20BridgeSource.Smoothy]: fillData => (fillData as CurveFillData).pool.gasSchedule,
|
||||
[ERC20BridgeSource.Saddle]: fillData => (fillData as CurveFillData).pool.gasSchedule,
|
||||
[ERC20BridgeSource.XSigma]: fillData => (fillData as CurveFillData).pool.gasSchedule,
|
||||
[ERC20BridgeSource.MultiBridge]: () => 350e3,
|
||||
[ERC20BridgeSource.UniswapV2]: (fillData?: FillData) => {
|
||||
// TODO: Different base cost if to/from ETH.
|
||||
|
||||
@@ -560,7 +560,12 @@ export class MarketOperationUtils {
|
||||
// We create a fallback path that is exclusive of Native liquidity
|
||||
// This is the optimal on-chain path for the entire input amount
|
||||
const nonNativeFills = fills.filter(p => p.length > 0 && p[0].source !== ERC20BridgeSource.Native);
|
||||
const nonNativeOptimalPath = await findOptimalPathAsync(side, nonNativeFills, inputAmount, opts.runLimit);
|
||||
const nonNativeOptimalPath = await findOptimalPathAsync(side, nonNativeFills, inputAmount, opts.runLimit, {
|
||||
...penaltyOpts,
|
||||
exchangeProxyOverhead: (sourceFlags: number) =>
|
||||
// tslint:disable-next-line: no-bitwise
|
||||
penaltyOpts.exchangeProxyOverhead(sourceFlags | optimalPath.sourceFlags),
|
||||
});
|
||||
// Calculate the slippage of on-chain sources compared to the most optimal path
|
||||
if (
|
||||
nonNativeOptimalPath !== undefined &&
|
||||
|
||||
@@ -136,6 +136,8 @@ export function getErc20BridgeSourceToBridgeSource(source: ERC20BridgeSource): s
|
||||
return encodeBridgeSourceId(BridgeProtocol.Curve, 'Smoothy');
|
||||
case ERC20BridgeSource.Saddle:
|
||||
return encodeBridgeSourceId(BridgeProtocol.Nerve, 'Saddle');
|
||||
case ERC20BridgeSource.XSigma:
|
||||
return encodeBridgeSourceId(BridgeProtocol.Curve, 'xSigma');
|
||||
case ERC20BridgeSource.ApeSwap:
|
||||
return encodeBridgeSourceId(BridgeProtocol.UniswapV2, 'ApeSwap');
|
||||
case ERC20BridgeSource.CafeSwap:
|
||||
@@ -173,6 +175,7 @@ export function createBridgeDataForBridgeOrder(order: OptimizedMarketBridgeOrder
|
||||
case ERC20BridgeSource.Ellipsis:
|
||||
case ERC20BridgeSource.Smoothy:
|
||||
case ERC20BridgeSource.Saddle:
|
||||
case ERC20BridgeSource.XSigma:
|
||||
const curveFillData = (order as OptimizedMarketBridgeOrder<CurveFillData>).fillData;
|
||||
bridgeData = encoder.encode([
|
||||
curveFillData.pool.poolAddress,
|
||||
@@ -328,6 +331,7 @@ export const BRIDGE_ENCODERS: {
|
||||
[ERC20BridgeSource.Ellipsis]: curveEncoder,
|
||||
[ERC20BridgeSource.Smoothy]: curveEncoder,
|
||||
[ERC20BridgeSource.Saddle]: curveEncoder,
|
||||
[ERC20BridgeSource.XSigma]: curveEncoder,
|
||||
// UniswapV2 like, (router, address[])
|
||||
[ERC20BridgeSource.Bancor]: routerAddressPathEncoder,
|
||||
[ERC20BridgeSource.UniswapV2]: routerAddressPathEncoder,
|
||||
|
||||
@@ -1132,6 +1132,7 @@ export class SamplerOperations {
|
||||
case ERC20BridgeSource.Belt:
|
||||
case ERC20BridgeSource.Ellipsis:
|
||||
case ERC20BridgeSource.Saddle:
|
||||
case ERC20BridgeSource.XSigma:
|
||||
return getCurveLikeInfosForPair(this.chainId, takerToken, makerToken, source).map(pool =>
|
||||
this.getCurveSellQuotes(
|
||||
pool,
|
||||
@@ -1346,6 +1347,7 @@ export class SamplerOperations {
|
||||
case ERC20BridgeSource.Belt:
|
||||
case ERC20BridgeSource.Ellipsis:
|
||||
case ERC20BridgeSource.Saddle:
|
||||
case ERC20BridgeSource.XSigma:
|
||||
return getCurveLikeInfosForPair(this.chainId, takerToken, makerToken, source).map(pool =>
|
||||
this.getCurveBuyQuotes(
|
||||
pool,
|
||||
|
||||
@@ -63,6 +63,7 @@ export enum ERC20BridgeSource {
|
||||
Smoothy = 'Smoothy',
|
||||
Component = 'Component',
|
||||
Saddle = 'Saddle',
|
||||
XSigma = 'xSigma',
|
||||
// BSC only
|
||||
PancakeSwap = 'PancakeSwap',
|
||||
PancakeSwapV2 = 'PancakeSwap_V2',
|
||||
|
||||
@@ -47,33 +47,16 @@ import {
|
||||
const MAKER_TOKEN = randomAddress();
|
||||
const TAKER_TOKEN = randomAddress();
|
||||
|
||||
const DEFAULT_EXCLUDED = [
|
||||
ERC20BridgeSource.UniswapV2,
|
||||
ERC20BridgeSource.Curve,
|
||||
ERC20BridgeSource.Balancer,
|
||||
ERC20BridgeSource.MStable,
|
||||
ERC20BridgeSource.Mooniswap,
|
||||
ERC20BridgeSource.Bancor,
|
||||
ERC20BridgeSource.Swerve,
|
||||
ERC20BridgeSource.SnowSwap,
|
||||
ERC20BridgeSource.SushiSwap,
|
||||
ERC20BridgeSource.MultiHop,
|
||||
ERC20BridgeSource.Shell,
|
||||
ERC20BridgeSource.Cream,
|
||||
ERC20BridgeSource.Dodo,
|
||||
ERC20BridgeSource.DodoV2,
|
||||
ERC20BridgeSource.LiquidityProvider,
|
||||
ERC20BridgeSource.CryptoCom,
|
||||
ERC20BridgeSource.Linkswap,
|
||||
ERC20BridgeSource.PancakeSwap,
|
||||
ERC20BridgeSource.BakerySwap,
|
||||
ERC20BridgeSource.MakerPsm,
|
||||
ERC20BridgeSource.KyberDmm,
|
||||
ERC20BridgeSource.Smoothy,
|
||||
ERC20BridgeSource.Component,
|
||||
ERC20BridgeSource.Saddle,
|
||||
ERC20BridgeSource.PancakeSwapV2,
|
||||
const DEFAULT_INCLUDED = [
|
||||
ERC20BridgeSource.Eth2Dai,
|
||||
ERC20BridgeSource.Kyber,
|
||||
ERC20BridgeSource.Native,
|
||||
ERC20BridgeSource.Uniswap,
|
||||
];
|
||||
|
||||
const DEFAULT_EXCLUDED = SELL_SOURCE_FILTER_BY_CHAIN_ID[ChainId.Mainnet].sources.filter(
|
||||
s => !DEFAULT_INCLUDED.includes(s),
|
||||
);
|
||||
const BUY_SOURCES = BUY_SOURCE_FILTER_BY_CHAIN_ID[ChainId.Mainnet].sources;
|
||||
const SELL_SOURCES = SELL_SOURCE_FILTER_BY_CHAIN_ID[ChainId.Mainnet].sources;
|
||||
const TOKEN_ADJACENCY_GRAPH: TokenAdjacencyGraph = { default: [] };
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
[
|
||||
{
|
||||
"version": "6.1.0",
|
||||
"changes": [
|
||||
{
|
||||
"note": "Deployed FQT on mainnet and ropsten for `Balancer_V2`"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "6.0.0",
|
||||
"changes": [
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"wethTransformer": "0xb2bc06a4efb20fc6553a69dbfa49b7be938034a7",
|
||||
"payTakerTransformer": "0x4638a7ebe75b911b995d0ec73a81e4f85f41f24e",
|
||||
"affiliateFeeTransformer": "0xda6d9fc5998f550a094585cf9171f0e8ee3ac59f",
|
||||
"fillQuoteTransformer": "0xb8e40acea68db2a7a2020a3eba2664ba4c3b3e3d",
|
||||
"fillQuoteTransformer": "0x025b4124732b1bf90cdd574975a99a6215de4a55",
|
||||
"positiveSlippageFeeTransformer": "0xa9416ce1dbde8d331210c07b5c253d94ee4cc3fd"
|
||||
}
|
||||
},
|
||||
@@ -77,7 +77,7 @@
|
||||
"wethTransformer": "0x05ad19aa3826e0609a19568ffbd1dfe86c6c7184",
|
||||
"payTakerTransformer": "0x6d0ebf2bcd9cc93ec553b60ad201943dcca4e291",
|
||||
"affiliateFeeTransformer": "0x6588256778ca4432fa43983ac685c45efb2379e2",
|
||||
"fillQuoteTransformer": "0xc0c6fc6911978a65fe3b17391bb30b630bfc637d",
|
||||
"fillQuoteTransformer": "0x27cd03bf6c49c15b7a2f5e9cde56329ebfaf153f",
|
||||
"positiveSlippageFeeTransformer": "0x8b332f700fd37e71c5c5b26c4d78b5ca63dd33b2"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user