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

Implement EIP 7002 (withdrawal requests) #3385

Merged
merged 12 commits into from
May 1, 2024
57 changes: 57 additions & 0 deletions packages/common/src/eips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,63 @@ export const EIPs: EIPsDict = {
},
},
},
7002: {
comment: 'Execution layer triggerable withdrawals (experimental)',
url: 'https://github.com/ethereum/EIPs/blob/3b5fcad6b35782f8aaeba7d4ac26004e8fbd720f/EIPS/eip-7002.md',
status: Status.Draft,
minimumHardfork: Hardfork.Paris,
requiredEIPs: [7685],
vm: {
withdrawalRequestType: {
v: BigInt(0x01),
d: 'The withdrawal request type for EIP-7685',
},
excessWithdrawalsRequestStorageSlot: {
v: BigInt(0),
d: 'The storage slot of the excess withdrawals',
},
withdrawalsRequestCountStorage: {
v: BigInt(1),
d: 'The storage slot of the withdrawal request count',
},
withdrawalsRequestQueueHeadStorageSlot: {
v: BigInt(2),
d: 'The storage slot of the withdrawal request head of the queue',
},
withdrawalsRequestTailHeadStorageSlot: {
v: BigInt(3),
d: 'The storage slot of the withdrawal request tail of the queue',
},
withdrawalsRequestQueueStorageOffset: {
v: BigInt(4),
d: 'The storage slot of the withdrawal request queue offset',
},
maxWithdrawalRequestsPerBlock: {
v: BigInt(16),
d: 'The max withdrawal requests per block',
},
targetWithdrawalRequestsPerBlock: {
v: BigInt(2),
d: 'The target withdrawal requests per block',
},
minWithdrawalRequestFee: {
v: BigInt(1),
d: 'The minimum withdrawal request fee (in wei)',
},
withdrawalRequestFeeUpdateFraction: {
v: BigInt(17),
d: 'The withdrawal request fee update fraction (used in the fake exponential)',
},
systemAddress: {
v: BigInt('0xfffffffffffffffffffffffffffffffffffffffe'),
d: 'The system address to perform operations on the withdrawal requests predeploy address',
},
withdrawalRequestPredeployAddress: {
v: BigInt('0x00A3ca265EBcb825B45F985A16CEFB49958cE017'),
d: 'Address of the validator excess address',
},
},
},
7516: {
comment: 'BLOBBASEFEE opcode',
url: 'https://eips.ethereum.org/EIPS/eip-7516',
Expand Down
2 changes: 1 addition & 1 deletion packages/evm/src/evm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ export class EVM implements EVMInterface {
// Supported EIPs
const supportedEIPs = [
1153, 1559, 2537, 2565, 2718, 2929, 2930, 2935, 3074, 3198, 3529, 3540, 3541, 3607, 3651,
3670, 3855, 3860, 4399, 4895, 4788, 4844, 5133, 5656, 6780, 6800, 7516, 7685,
3670, 3855, 3860, 4399, 4895, 4788, 4844, 5133, 5656, 6780, 6800, 7002, 7516, 7685,
]

for (const eip of this.common.eips()) {
Expand Down
65 changes: 62 additions & 3 deletions packages/vm/src/runBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
BIGINT_0,
BIGINT_1,
BIGINT_8,
CLRequest,
GWEI_TO_WEI,
KECCAK256_RLP,
bigIntToBytes,
Expand Down Expand Up @@ -40,7 +41,7 @@ import type {
import type { VM } from './vm.js'
import type { Common } from '@ethereumjs/common'
import type { EVM, EVMInterface } from '@ethereumjs/evm'
import type { CLRequest, PrefixedHexString } from '@ethereumjs/util'
import type { CLRequestType, PrefixedHexString } from '@ethereumjs/util'

const { debug: createDebugLogger } = debugDefault

Expand Down Expand Up @@ -225,6 +226,7 @@ export async function runBlock(this: VM, opts: RunBlockOpts): Promise<RunBlockRe
}
const blockData = {
...block,
requests,
header: { ...block.header, ...generatedFields },
}
block = Block.fromBlockData(blockData, { common: this.common })
Expand Down Expand Up @@ -951,17 +953,30 @@ const DAOConfig = {
DAORefundContract: 'bf4ed7b27f1d666546e30d74d50d173d20bca754',
}

export class ValidatorWithdrawalRequest extends CLRequest implements CLRequestType {
constructor(type: number, bytes: Uint8Array) {
super(type, bytes)
}

serialize() {
return concatBytes(Uint8Array.from([this.type]), this.bytes)
}
}

/**
* This helper method generates a list of all CL requests that can be included in a pending block
* @param _vm VM instance from which to derive CL requests
* @returns an list of CL requests in ascending order by type
*/
export const accumulateRequests = async (_vm: VM): Promise<CLRequest[]> => {
export const accumulateRequests = async (vm: VM): Promise<CLRequest[]> => {
const requests: CLRequest[] = []
const common = vm.common

// TODO: Add in code to accumulate deposits (EIP-6110)

// TODO: Add in code to accumulate partial withdrawals (EIP-7002)
if (common.isActivatedEIP(7002)) {
await _accumulateEIP7002Requests(vm, requests)
}
jochem-brouwer marked this conversation as resolved.
Show resolved Hide resolved

if (requests.length > 1) {
for (let x = 1; x < requests.length; x++) {
Expand All @@ -971,3 +986,47 @@ export const accumulateRequests = async (_vm: VM): Promise<CLRequest[]> => {
}
return requests
}

const _accumulateEIP7002Requests = async (vm: VM, requests: CLRequest[]): Promise<void> => {
// Partial withdrawals logic
const addressBytes = setLengthLeft(
bigIntToBytes(vm.common.param('vm', 'withdrawalRequestPredeployAddress')),
20
)
const withdrawalsAddress = Address.fromString(bytesToHex(addressBytes))
Copy link
Member

Choose a reason for hiding this comment

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

Can we name this a bit more verbose, I would suggest withdrawalRequestContractAddress?

For withdrawalsAddress imagination can go in various - and sometimes wrong - directions what this could mean (address for sending the withdrawals towards e.g.).


const code = await vm.stateManager.getContractCode(withdrawalsAddress)

if (code.length === 0) {
throw new Error(
'Attempt to accumulate EIP-7002 requests failed: the contract does not exist. Ensure the deployment tx has been run, or that the required contract code is stored'
)
}

const systemAddressBytes = setLengthLeft(
bigIntToBytes(vm.common.param('vm', 'systemAddress')),
20
)
const systemAddress = Address.fromString(bytesToHex(systemAddressBytes))

const results = await vm.evm.runCall({
caller: systemAddress,
gasLimit: BigInt(1_000_000),
to: withdrawalsAddress,
})
Copy link
Member

Choose a reason for hiding this comment

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

Can we document the single steps a bit more, so that the whole code gets a bit easier reproduceable/understandable if one is not so deep into the EIP (any more)? 🙂

So this call basically triggers this part from the EIP, right? (respectively, in more practical terms: trigger withdrawal request contract code execution doing these things as described)

grafik

So it would be nice if we have some summary docs here like (to be freely adopted):

// Perform a system call to the withdrawal request contract. This triggers the contract to dequeue withdrawal requests from the queue, update excess withdrawal requests, reset the counter and then return the respective requests from the queue.
``

(Is this an accurate high level description?)


const resultsBytes = results.execResult.returnValue
if (resultsBytes.length > 0) {
const withdrawalRequestType = Number(vm.common.param('vm', 'withdrawalRequestType'))
// Each request is 76 bytes
for (let startByte = 0; startByte < resultsBytes.length; startByte += 76) {
const slicedBytes = resultsBytes.slice(startByte, startByte + 76)
const sourceAddress = slicedBytes.slice(0, 20) // 20 Bytes
const validatorPubkey = slicedBytes.slice(20, 68) // 48 Bytes
const amount = slicedBytes.slice(68, 76) // 8 Bytes / Uint64
const rlpData = RLP.encode([sourceAddress, validatorPubkey, amount])
const request = new ValidatorWithdrawalRequest(withdrawalRequestType, rlpData)
requests.push(request)
}
}
}
182 changes: 182 additions & 0 deletions packages/vm/test/api/EIPs/eip-7002.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { Block } from '@ethereumjs/block'
import { Chain, Common, Hardfork } from '@ethereumjs/common'
import { RLP } from '@ethereumjs/rlp'
import { LegacyTransaction } from '@ethereumjs/tx'
import {
Account,
Address,
bigIntToBytes,
bytesToHex,
concatBytes,
equalsBytes,
hexToBytes,
setLengthLeft,
zeros,
} from '@ethereumjs/util'
import { assert, describe, it } from 'vitest'

import { setupVM } from '../utils.js'

const pkey = hexToBytes(`0x${'20'.repeat(32)}`)
const addr = Address.fromPrivateKey(pkey)

const common = new Common({ chain: Chain.Mainnet, hardfork: Hardfork.Cancun, eips: [7685, 7002] })

// Note: this deployment tx data is the deployment tx in order to setup the EIP-7002 contract
// It is taken from the EIP
const deploymentTxData = {
nonce: BigInt(0),
gasLimit: BigInt('0x3d090'),
gasPrice: BigInt('0xe8d4a51000'),
data: hexToBytes(
'0x61049d5f5561013280600f5f395ff33373fffffffffffffffffffffffffffffffffffffffe146090573615156028575f545f5260205ff35b366038141561012e5760115f54600182026001905f5b5f82111560595781019083028483029004916001019190603e565b90939004341061012e57600154600101600155600354806003026004013381556001015f3581556001016020359055600101600355005b6003546002548082038060101160a4575060105b5f5b81811460dd5780604c02838201600302600401805490600101805490600101549160601b83528260140152906034015260010160a6565b910180921460ed579060025560f8565b90505f6002555f6003555b5f548061049d141561010757505f5b60015460028282011161011c5750505f610122565b01600290035b5f555f600155604c025ff35b5f5ffd'
),
jochem-brouwer marked this conversation as resolved.
Show resolved Hide resolved
v: BigInt('0x1b'),
r: BigInt('0x539'),
s: BigInt('0xaba653c9d105790c'),
}

const deploymentTx = LegacyTransaction.fromTxData(deploymentTxData)
const sender = deploymentTx.getSenderAddress()
const upfrontCost = deploymentTx.getUpfrontCost()
const acc = new Account()
acc.balance = upfrontCost

const validatorPubkey = hexToBytes(`0x${'20'.repeat(48)}`)
const amount = BigInt(12345678)
const amountBytes = setLengthLeft(bigIntToBytes(amount), 8)

function generateTx(nonce: bigint) {
const addressBytes = setLengthLeft(
bigIntToBytes(common.param('vm', 'withdrawalRequestPredeployAddress')),
20
)
const withdrawalsAddress = Address.fromString(bytesToHex(addressBytes))
Copy link
Member

Choose a reason for hiding this comment

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

Same here: rename would be nice.


return LegacyTransaction.fromTxData({
nonce,
gasPrice: BigInt(100),
data: concatBytes(validatorPubkey, amountBytes),
value: BigInt(1),
to: withdrawalsAddress,
gasLimit: 200_000,
}).sign(pkey)
}

describe('EIP-7002 tests', () => {
it('should correctly create requests', async () => {
const vm = await setupVM({ common })
const block = Block.fromBlockData(
{
header: {
number: 1,
},
transactions: [deploymentTx],
},
{ common }
)
await vm.stateManager.putAccount(sender, acc)
await vm.stateManager.putAccount(addr, acc)

// Deploy withdrawals contract
const results = await vm.runBlock({
block,
skipHeaderValidation: true,
skipBlockValidation: true,
generate: true,
})

const root = results.stateRoot

const tx = generateTx(BigInt(0))

// Call withdrawals contract with a withdrawals request
const block2 = Block.fromBlockData(
{
header: {
number: 2,
parentBeaconBlockRoot: zeros(32),
},
transactions: [tx],
},
{ common }
)

let generatedBlock: Block
vm.events.on('afterBlock', (e) => {
generatedBlock = e.block
})

await vm.runBlock({
block: block2,
skipHeaderValidation: true,
skipBlockValidation: true,
generate: true,
})

// Ensure the request is generated
assert.ok(generatedBlock!.requests!.length === 1)

const requestDecoded = RLP.decode(generatedBlock!.requests![0].bytes)

const sourceAddressRequest = requestDecoded[0] as Uint8Array
const validatorPubkeyRequest = requestDecoded[1] as Uint8Array
const amountRequest = requestDecoded[2] as Uint8Array

// Ensure the requests are correct
assert.ok(equalsBytes(sourceAddressRequest, tx.getSenderAddress().bytes))
assert.ok(equalsBytes(validatorPubkey, validatorPubkeyRequest))
assert.ok(equalsBytes(amountBytes, amountRequest))

await vm.runBlock({ block: generatedBlock!, skipHeaderValidation: true, root })

// Run block with 2 requests

const tx2 = generateTx(BigInt(1))
const tx3 = generateTx(BigInt(2))

const block3 = Block.fromBlockData(
{
header: {
number: 3,
parentBeaconBlockRoot: zeros(32),
},
transactions: [tx2, tx3],
},
{ common }
)

await vm.runBlock({
block: block3,
skipHeaderValidation: true,
skipBlockValidation: true,
generate: true,
})

// Note: generatedBlock is now overridden with the new generated block (this is thus block number 3)
// Ensure there are 2 requests
assert.ok(generatedBlock!.requests!.length === 2)
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a fantastic test. I didn't even think about being able catch the generated block from teh afterBlock event.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, actually I want to raise a point about this, isn't the entire point of generate to actually retrieve the generated block (i.e. build the block?). I was somewhat surprised that this block was not returned from runBlock so had to figure out if I would be able to extract it (if that was even possible). So this is something we could take a look at in the future (not part of this PR)

})

it('should throw when contract is not deployed', async () => {
const vm = await setupVM({ common })
const block = Block.fromBlockData(
{
header: {
number: 1,
},
},
{ common }
)
try {
await vm.runBlock({
block,
skipHeaderValidation: true,
skipBlockValidation: true,
generate: true,
})
} catch (e: any) {
assert.ok(e.message.includes('Attempt to accumulate EIP-7002 requests failed'))
}
})
})