Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New TokenPaymaster #286

Merged
merged 25 commits into from
Jun 19, 2023
Merged

New TokenPaymaster #286

merged 25 commits into from
Jun 19, 2023

Conversation

forshtat
Copy link
Collaborator

No description provided.

}
}
uint256 tokenAmount = weiToToken(preChargeNative, cachedPriceWithMarkup, false);
SafeERC20.safeTransferFrom(token, userOp.sender, address(this), tokenAmount);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor, IIRC from our past debug experience, SafeERC20 uses banned selfbalance opcode

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I did not know that! Created a small PR in the OpenZeppelin repo, hope this is all there is to this issue:
OpenZeppelin/openzeppelin-contracts#4271

I hope to avoid having to copy SafeERC20.sol library into the account-abstraction repo, but maybe we will have no choice.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

);
} else {
// If the token amount is not greater than the actual amount needed, revert to remove incentive to cheat
revert("TPM: preCharge was too low");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it's worth of attempting to transfer the owed token (preCharge - actualTokenNeeded) from sender here

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this may actually work, will bring this up with the team. So far I did add an attempt to withdraw the owed amount (actualTokenNeeded - preCharge) from the UserOp sender to the Paymaster.

function setTokenPaymasterConfig(
TokenPaymasterConfig memory _tokenPaymasterConfig
) public onlyOwner {
require(_tokenPaymasterConfig.priceMarkup <= 1900000, "TPM: price markup too high");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, how do we pick the upper bound (1900000)?

Copy link
Collaborator Author

@forshtat forshtat May 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of these hard-coded parameters are selected yet, but in my opinion the maximum possible value for the markup should just be there to prevent human error or malicious configuration.

For example, Pimlico Paymaster used 120e4 which sounds to me like it may be a bit too limiting:
https://github.com/pimlicolabs/erc20-paymaster-contracts/blob/c9911402a49928398441b07fb9caa9f4b4d0352f/src/PimlicoERC20Paymaster.sol#L68

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe 2e6 is good upper limit

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@livingrockrises Agreed. Will set for 200% for now. Will also make it explicitly related to the PRICE_DENOMINATOR value.

contracts/samples/TokenPaymaster.sol Outdated Show resolved Hide resolved
}

emit UserOperationSponsored(userOpSender, actualTokenNeeded, actualGasCost, cachedPrice);
refillEntryPointDeposit(_cachedPrice);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean router swapping cost is dumped on to the users?
since it is conditional postOp cost would change
if not user who is paying for this computation - paymaster's gas deposit?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A single user should not get stuck with the bill for the Paymaster's swap operation on its own.
I am not sure but I think what we should do is to set the POSTOP_COST parameter such that it kind of spreads the cost of these periodic swaps over all other transactions.
Of course this way it may end up accumulating some loss or profit over time if left completely unattended.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If paymaster is always charging premium, POSTOP_COST can just be everything unaccounted on-chain on entry point and postOp - swap executionGas.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@livingrockrises
A paymaster is expected to set refundPostopCost to actual cost of postOp, with faction of the cost of the uniswap, since the swap is expected to happen only every "N" transaction.

uint256 preCharge = uint256(bytes32(context[0 : 32]));
uint256 maxFeePerGas = uint256(bytes32(context[32 : 64]));
address userOpSender = address(bytes20(context[64 : 84]));
if (mode == PostOpMode.postOpReverted) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch cannot be reached since there is a immediate return

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. Fixed.

// note: as price is in ether-per-token and we want more tokens increasing it means dividing it by markup
uint256 cachedPriceWithMarkup = _cachedPrice * PRICE_DENOMINATOR / priceMarkup;
// Refund tokens based on actual gas cost
uint256 actualChargeNative = actualGasCost + REFUND_POSTOP_COST * maxFeePerGas;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of using maxFeePerGas, does it make sense to use tx.gasprice ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, thanks! However, we cannot just use tx.gasprice as the bundler can manipulate this value. I think we should use userOp.gasPrice() here. Updated.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userop.gasPrice() touches BASEFEE which forbidden opcode. this would be fine in postOp(), but you shouldn't calculate it in validatePaymasterUserOp and pass it in context.
Instead pass maxPriorityFee and maxFee in context and calculate like below in the postOp

uint256 opGasPrice;
        unchecked {
            if (maxFeePerGas == maxPriorityFeePerGas) {
                opGasPrice = maxFeePerGas;
            } else {
                opGasPrice = Math.min(maxFeePerGas, maxPriorityFeePerGas + block.basefee);
            }
        }

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right 😀

event Received(address indexed sender, uint256 value);

/// @notice All 'price' variables are multiplied by this value to avoid rounding up
uint256 private constant PRICE_DENOMINATOR = 1e26;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the rationale behind using 1e26?

Copy link
Collaborator Author

@forshtat forshtat Jun 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point constants are actually pretty random and will be finalised after we make some gas usage optimisations. Do you have suggestions on what value should be used and why?

require(paymasterAndDataLength == 0 || paymasterAndDataLength == 32,
"TPM: invalid data length"
);
uint256 gasPrice = userOp.gasPrice();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will revert on the bundler
cc @leekt

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup this will revert for sure

preCharge - actualTokenNeeded
);
} else if (preCharge < actualTokenNeeded) {
// Attempt to cover Paymaster's gas expenses by withdrawing the 'overdraft' from the client
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about this part. Won't this make clientSuppliedPrice invalid? clientSuppliedPrice should be the guard for how much token user is willing to pay but this part will end up forcing user to pay the full slippage

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, yes, with this implementation the user-supplied price can only increase the pre-charge for the user, but does not set an upper limit of the token price, so once the users approve this paymaster they basically agree to pay for gas at any current market price.

@drortirosh what do you think about this feature?

CC: @huaweigu

uint256 private constant PRICE_DENOMINATOR = 1e26;

/// @notice Estimated gas cost for refunding tokens after the transaction is completed
uint256 public constant REFUND_POSTOP_COST = 40000;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

postop_cost depends on price of token transfer, which varies with different token implementation.
to be accurate, this is better be a constructor parameter

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

}

function setOracleConfiguration(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need setters? if the data is immutable, it the logic much cheaper.
There is no harm at re-deploy the paymaster with different configuration.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This paymaster will accumulate client base by getting 'approval' in ERC-20 token used. Deploying a new paymaster just to switch to a different price oracle will lose all existing clients until they migrate their approval.
Do you want me to make the fields immutable and make the paymaster an upgradeable proxy instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@drortirosh
Bumping this one. Should we?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a user, I trust the config values the paymaster have - but I really can't fully trust it, since it can change its configuration at will (e.g. increase the postOp cost, or switch to a malicious oracle)

I want to trust an address given by some authority, and know this address can't change.
Better yet - I want to know this address define all parameters values.

Also other parameters (like minEPbalance) are not expected to be modified much.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at Biconomy we are returning a fee quote to the sender and allow them to update userop.calldata with the approval transaction with necessary amount each time. then partialUserOp is filled with paymasterAndData and signature. SDK account APIs can facilitate this for decoding calldata and append approval transaction in a batch and have new callData, callGasLimit

uint256 tokenAmount = weiToToken(preChargeNative, cachedPriceWithMarkup);
SafeERC20.safeTransferFrom(token, userOp.sender, address(this), tokenAmount);
context = abi.encodePacked(tokenAmount, userOp.maxFeePerGas, userOp.maxPriorityFeePerGas, userOp.sender);
validationResult = 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better return validUntil as cachedPriceTimestamp + maxCacheAge

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

function _postOp(PostOpMode mode, bytes calldata context, uint256 actualGasCost) internal override {
unchecked {
uint256 priceMarkup = tokenPaymasterConfig.priceMarkup;
uint256 preCharge = uint256(bytes32(context[0 : 32]));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

abi.decode() is that bad?
it surely much clearer to understand.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. I was trying to same some bytes by doing abi.encodePacked.

IOracle nativeOracle = oracleHelperConfig.nativeOracle;

uint256 cacheAge = block.timestamp - cachedPriceTimestamp;
if (!force && cacheAge <= cacheTimeToLive) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

better be moved up, and avoid loading storage fields before the quick bail-out.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Copy link
Contributor

@drortirosh drortirosh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should also add gas-check test in gas-calc/

context = abi.encode(tokenAmount, userOp.maxFeePerGas, userOp.maxPriorityFeePerGas, userOp.sender);
validationResult = _packValidationData(
false,
uint48(cachedPriceTimestamp + tokenPaymasterConfig.priceMaxAge),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storage optimization: instead referencing 2 storage slots on each call, use
cachedPriceExpirationTimestamp = timestamp + priceMaxAge

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please elaborate where does the timestamp variable come from?

@@ -102,6 +102,8 @@ describe.only('OracleHelper', function () {
const owner = await ethersSigner.getAddress()

const tokenPaymasterConfig: TokenPaymaster.TokenPaymasterConfigStruct = {
priceMaxAge: 86400,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^ remove "only" from describe.only('OracleHelper')

@@ -1,2 +0,0 @@
export { UserOperationStruct } from './types/EntryPoint'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true, should be removed (along with the code to use it during "yarn publish"), but unrelated to TokenPaymaster.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was messing up the environment for me though.


/// @notice Simplified code copied from here:
/// https://github.com/Uniswap/v3-periphery/blob/main/contracts/base/PeripheryPayments.sol#L19
function unwrapWETH9(uint256 amountMinimum, address recipient) public payable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not? This is used by the UniswapHelper.sol file:

    function unwrapWeth(uint256 amount) internal {
        IPeripheryPayments(address(uniswap)).unwrapWETH9(amount, address(this));
    }

);
}

emit UserOperationSponsored(userOpSender, actualTokenNeeded, actualGasCost, cachedPrice);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is _cachedPrice, not cachedPrice

}

emit UserOperationSponsored(userOpSender, actualTokenNeeded, actualGasCost, cachedPrice);
refillEntryPointDeposit(_cachedPrice);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@livingrockrises
A paymaster is expected to set refundPostopCost to actual cost of postOp, with faction of the cost of the uniswap, since the swap is expected to happen only every "N" transaction.

}

function setOracleConfiguration(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a user, I trust the config values the paymaster have - but I really can't fully trust it, since it can change its configuration at will (e.g. increase the postOp cost, or switch to a malicious oracle)

I want to trust an address given by some authority, and know this address can't change.
Better yet - I want to know this address define all parameters values.

Also other parameters (like minEPbalance) are not expected to be modified much.

1. Remove 'setOracleConfiguration' - only constructor can set oracles

2. Use '_cachedPrice' instead of 'cachedPrice' to avoid extra state read

3. Remove 'only' from TokenPaymaster related tests
@@ -67,7 +67,7 @@ abstract contract OracleHelper {

function _setOracleConfiguration(
OracleHelperConfig memory _oracleHelperConfig
) internal {
) private {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

leaving this method (called only from constructor) means that we still pay the SLOAD when using these parameters.
(and you can't make a member "immutable" if it is set from a private method)

Currently, the paymaster uses 15 slots (14, if we exclude "owner")
(checked with solc --storage-layout)
So it uses 14 storage slots on each operation, which is at minimum 14*2100 = 29400 gas .
But I suggest adding a "gascalc" test before optimizing it.

@forshtat forshtat merged commit 05691c0 into develop Jun 19, 2023
@forshtat forshtat deleted the new_token_paymaster branch June 19, 2023 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants