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

AA-216 Proposal: Packing UserOperation and paymaster gas limits #363

Merged
merged 28 commits into from
Jan 8, 2024

Conversation

shahafn
Copy link
Contributor

@shahafn shahafn commented Nov 2, 2023

This PR is the biggest change we propose for erc4337 v0.7
While the changes below are distinct, it was best to implement this as a single PR

The changes:

  1. The current verificationGasLimit field was used for three different purposes (account validation, paymaster validation, and paymaster postOp). We discovered it makes the paymaster vulnerable.
    As a result, we added two new gas fields: paymasterValidationGasLimit and postOpGasLimit
  2. Instead of adding more fields to the UserOperation struct, we encode all four paymaster-related parameters into a single “paymasterData” field.
    This means that if no paymaster is in use, there is only a single “zeros” field, instead of four.
  3. We also decided to pack the two account gas parameters (verificationGasLimit and callGasLimit) and pack them into a single field.
  4. So in total, we added two more parameters but reduced the size of a simple UserOperation.
  5. Note that all the above fields are usually not in use by on-chain contracts (account or paymaster)
  6. When used off-chain, we introduced a separation between the “UserOperation” structure (the JSON API used to contact the bundler) and the “PackedUserOperation” structure in Solidity, which is passed to the contracts (the EntryPoint, account’s validateUserOp, and paymaster’s validatePaymasterUserOp)

Changes to Account

  • The validateUserOp method now has a new method signature, with the new PackedUserOperation structure
  • Account should be upgraded to the new version to support the new EntryPoint v0.7

Supporting multiple entrypoints is still possible:

  • validateUserOperation(PackedUserOperation) is the new method that supports the new entrypoint.
  • validateUserOperation(UserOperationV060) can be implemented to support the old entryPoint.
  • The execute (and executeBatch) should be modified to support calls from both entrypoint contracts

Changes to Paymasters

Paymaster’s paymasterAndData field processing now needs to account for both the 20 bytes of paymaster address and the 32 bytes of gas fields.
We added PAYMASTER_DATA_OFFSET constant to indicate the offset where paymaster-specific data starts

- Adding packed paymasterGasLimits for validatePaymasterUserOp and
postOp
- Packing gas limits of validateUserOp and call gas limits into
accountGasLimits
contracts/core/EntryPoint.sol Outdated Show resolved Hide resolved
contracts/core/UserOperationLib.sol Show resolved Hide resolved
contracts/samples/DepositPaymaster.sol Outdated Show resolved Hide resolved
contracts/samples/VerifyingPaymaster.sol Outdated Show resolved Hide resolved
@@ -163,7 +163,7 @@ contract EIP4337Manager is IAccount, GnosisSafeStorage, Executor {
sig[2] = bytes1(uint8(1));
sig[35] = bytes1(uint8(1));
uint256 nonce = uint256(IEntryPoint(manager.entryPoint()).getNonce(address(safe), 0));
UserOperation memory userOp = UserOperation(address(safe), nonce, "", "", 0, 1000000, 0, 0, 0, "", sig);
UserOperation memory userOp = UserOperation(address(safe), nonce, "", "", bytes32(bytes3(0x0f4240)), 0, 0, 0, "", sig);
Copy link
Contributor

Choose a reason for hiding this comment

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

why is this needed?

Copy link
Contributor Author

@shahafn shahafn Dec 3, 2023

Choose a reason for hiding this comment

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

What is "this" that you're asking about? bytes32(bytes3(0x0f4240)) is there since it's the account gas limits that are now packed.

Copy link
Contributor

@drortirosh drortirosh Dec 3, 2023

Choose a reason for hiding this comment

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

should use packGasLimit(callGasLimit, verificationGasLimit)

same func can be used for pmgas and for acc.gas

contracts/test/MaliciousAccount.sol Outdated Show resolved Hide resolved
return (uint128(bytes16(accountGasLimits)), uint128(uint256(accountGasLimits)));
}

function unpackPaymasterStaticFields(
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we ever decode the paymaster itself, only paymasterGaslimits
(the reason is that this method is expected to be used from within validatePaymasterUserOp itself)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We do decode it..

contracts/samples/DepositPaymaster.sol Outdated Show resolved Hide resolved
@@ -123,15 +123,15 @@ contract TokenPaymaster is BasePaymaster, UniswapHelper, OracleHelper {
override
returns (bytes memory context, uint256 validationResult) {unchecked {
uint256 priceMarkup = tokenPaymasterConfig.priceMarkup;
uint256 paymasterAndDataLength = userOp.paymasterAndData.length - 20;
uint256 paymasterAndDataLength = userOp.paymasterAndData.length - 52;
Copy link
Contributor

Choose a reason for hiding this comment

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

we need to define these as constants, in UserOpLib. its not good to have so many explicit numerics spread in our code.
e.g.
PAYMASTER_GASLIMIT_OFFSET (offset of (paymasterGasLimit,paymasterPostOpGasLimit))
PAYMASTER_DATA_OFFSET (offset of "rest of paymaster data")

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

userOp.callGasLimit,
userOp.verificationGasLimit,
userOp.accountGasLimits,
uint256(bytes32(userOp.paymasterAndData[20:52])),
Copy link
Contributor

Choose a reason for hiding this comment

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

afaik, bytes32 is enough (bytes32(userOp.paymasterAndData[PAYMASTER_GASLIMT_OFFSET]))

@@ -325,8 +329,12 @@ contract EntryPoint is IEntryPoint, StakeManager, NonceManager, ReentrancyGuard,
"AA93 invalid paymasterAndData"
);
mUserOp.paymaster = address(bytes20(paymasterAndData[:20]));
mUserOp.paymasterVerificationGasLimit = uint128(bytes16(paymasterAndData[20:36]));
Copy link
Contributor

Choose a reason for hiding this comment

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

  1. use decode helper
  2. need to check if need all those in mUserOp

} else {
mUserOp.paymaster = address(0);
mUserOp.paymasterVerificationGasLimit = 0;
Copy link
Contributor

Choose a reason for hiding this comment

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

Solidity structs are pre-initialized to all-zeros.

Copy link
Contributor

Choose a reason for hiding this comment

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

Except when one does crazy stuff like recycling memory by using the free-pointer back - which we do :) So we can't assume zeros anywhere.

Copy link
Contributor

@drortirosh drortirosh Jan 8, 2024

Choose a reason for hiding this comment

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

Yes, we can assume zeros
all storage variables in solidity are initialized to zero - either "stack" or memory variables (structs and arrays).
(there are Yul injected functions "allocate_and_zero_memory..." for that)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The paymaster initialization to address(0) was there, I only added the other fields. Do you want to remove all of them?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, we can assume zeros all storage variables in solidity are initialized to zero - either "stack" or memory variables (structs and arrays). (there are Yul injected functions "allocate_and_zero_memory..." for that)

Interesting, I wonder why they do that. Solidity always allocates new memory (containing zeros) so why do they need to explicitly write zeros? (We do, due to our asm code, but normal solidity doesn't)

contracts/core/EntryPoint.sol Outdated Show resolved Hide resolved
contracts/core/EntryPoint.sol Show resolved Hide resolved
contracts/samples/gnosis/EIP4337Manager.sol Outdated Show resolved Hide resolved
sender: account.address,
paymasterAndData: paymaster.address,
paymaster: paymaster.address,
paymasterPostOpGasLimit: 3e5,
Copy link
Contributor

Choose a reason for hiding this comment

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

just like other fields, fillAndSign should have default value for postOpGasLimit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think that postOp should have a default non-zero value

@@ -313,7 +312,7 @@ describe('TokenPaymaster', function () {
})

const preChargeTokens = decodedLogs[0].args.value
const requiredGas = BigNumber.from(op.callGasLimit).add(BigNumber.from(op.verificationGasLimit).mul(2)).add(op.preVerificationGas).add(40000 /* REFUND_POSTOP_COST */)
const requiredGas = BigNumber.from(op.callGasLimit).add(BigNumber.from(op.verificationGasLimit).add(BigNumber.from(op.paymasterVerificationGasLimit))).add(BigNumber.from(op.paymasterPostOpGasLimit)).add(op.preVerificationGas).add(40000 /* REFUND_POSTOP_COST */)
Copy link
Contributor

Choose a reason for hiding this comment

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

we're missing
bnSum = function(){ return [...arguments].reduce((sum,x)=>bn.from(x).add(sum)) }
and then
bnSum(op.callGasLimit, op.paymasterVerificationGasLimit, op.paymasterPostOpGasLimit, 40000)

test/UserOp.ts Show resolved Hide resolved
test/UserOp.ts Outdated Show resolved Hide resolved
test/entrypoint.test.ts Outdated Show resolved Hide resolved
@@ -275,14 +276,18 @@ describe('EntryPoint', function () {
// we need maxFeeperGas > block.basefee + maxPriorityFeePerGas so requiredPrefund onchain is basefee + maxPriorityFeePerGas
maxFeePerGas: block.baseFeePerGas.mul(3),
Copy link
Contributor

Choose a reason for hiding this comment

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

actually, this field looks anachronism: why doesn't it use DefaultUserOp or fillUserOp ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I didn't touch this code at all

test/simple-wallet.test.ts Outdated Show resolved Hide resolved
test/testutils.ts Outdated Show resolved Hide resolved
test/testutils.ts Show resolved Hide resolved
@@ -43,9 +43,17 @@ describe('EntryPoint with VerifyingPaymaster', function () {

describe('#parsePaymasterAndData', () => {
it('should parse data properly', async () => {
const paymasterAndData = hexConcat([paymaster.address, defaultAbiCoder.encode(['uint48', 'uint48'], [MOCK_VALID_UNTIL, MOCK_VALID_AFTER]), MOCK_SIG])
const paymasterAndData = hexConcat([
Copy link
Contributor

Choose a reason for hiding this comment

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

missing helper packPaymasterAndData(paymasterr, pmGas, postOpGas, data?)
we don't want to mix "protocol" encoding (pm, gas), with "app" encoding (time-range)

gasleft() < callGasLimit + mUserOp.verificationGasLimit + 5000
gasleft() <
callGasLimit +
mUserOp.paymasterPostOpGasLimit +
Copy link
Collaborator

Choose a reason for hiding this comment

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

At this point we already have a context, so if it is empty the postOp will not be called. I think it may make sense to allow the transaction to go on in this case, instead of always adding paymasterPostOpGasLimit to the required gas left?
Say, the UserOp have requested 1'000'000 gas for a postOp but it does not need it any more, why should this check fail?

Copy link
Contributor

Choose a reason for hiding this comment

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

why should we (entrypoint) waste gas to check it? it is the paymaster's job to put a valid postOpGasLimit, even if context is empty.
There is another edge case: validatePaymasterUserOp() returns a context, and the postOpGasLimit` is zero: in this case, we still execute the callData and revert the postOp (on OOG) - again, because it is not the EntryPoint's job to validate the userop fields.

@@ -313,8 +318,7 @@ contract EntryPoint is IEntryPoint, StakeManager, NonceManager, ReentrancyGuard,
) internal pure {
mUserOp.sender = userOp.sender;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why not have the entire UserOperation => MemoryUserOp conversion in the UserOperationLib library? Construction of a MemoryUserOp takes a lot of space and will call to the UserOperationLib at least twice.

Copy link
Contributor

Choose a reason for hiding this comment

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

This copy is an internal optimization of EntryPoint. we might even remove copying some fields if we find they are not needed (e.g. used exactly once)
UserOperationLib is for general-use functions, that are expected to be used even outside the EntryPoint itself.

@shahafn shahafn changed the title Packing gas limits AA-216 Packing gas limits Dec 18, 2023
@drortirosh drortirosh changed the title AA-216 Packing gas limits AA-216 Proposal: Packing UserOperation and paymaster gas limits Dec 19, 2023
contracts/samples/LegacyTokenPaymaster.sol Outdated Show resolved Hide resolved
contracts/samples/TokenPaymaster.sol Outdated Show resolved Hide resolved
contracts/samples/VerifyingPaymaster.sol Outdated Show resolved Hide resolved
contracts/samples/gnosis/EIP4337Manager.sol Outdated Show resolved Hide resolved
test/UserOp.ts Outdated Show resolved Hide resolved
test/UserOp.ts Outdated Show resolved Hide resolved
test/UserOp.ts Show resolved Hide resolved
sender: testWarmColdAccount.address
}
const beneficiaryAddress = createAddress()
const badOpPacked = packUserOp(badOp)

This comment was marked as outdated.

test/entrypoint.test.ts Outdated Show resolved Hide resolved
@shahafn shahafn marked this pull request as ready for review December 21, 2023 13:40
forshtat
forshtat previously approved these changes Jan 3, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants