From 61e3452b4a917c35be835d5788ab2a7d038a802d Mon Sep 17 00:00:00 2001 From: "Julie B." Date: Sat, 23 May 2026 17:39:16 +0200 Subject: [PATCH 1/7] Add ERC: Canonical Validator Wrapper --- ERCS/erc-xxxx.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 ERCS/erc-xxxx.md diff --git a/ERCS/erc-xxxx.md b/ERCS/erc-xxxx.md new file mode 100644 index 00000000000..e16722b4621 --- /dev/null +++ b/ERCS/erc-xxxx.md @@ -0,0 +1,68 @@ +--- +title: Canonical Validator Wrapper +description: Minimalist contract to make Beacon Chain withdrawal credentials transferable +author: Julie B. (@bbjubjub2494) +discussions-to: https://ethereum-magicians.org/t/draft-erc-canonical-validator-wrapper/28599 +status: Draft +type: Standards Track +category: ERC +created: 2026-05-23 +requires: EIP-1014, EIP-7002, EIP-7251 +--- +## Abstract + +We propose a straightforward, trustless contract that issues NFTs conferring their holder control over the stake of a single validator on the Beacon Chain. + +## Motivation + +The Beacon Chain allows a validator to provide a withdrawal address on the Execution Layer where all of their ether will be directed once it leaves the Beacon Chain. By design, this address cannot be changed once set. In practice, it is set either to the owner's wallet, or to a contract associated with a pooled staking protocol. After that, the only way to change to a different arrangement is to exit the validator entirely and create a new one. +This ERC introduces a thin wrapper around the withdrawal address. A non-fungible token, which can be kept in the user's wallet or escrowed in any protocol, trustlessly controls the withdrawal address. This allows building interoperable pooled staking protocols, whether trustless or intermediated, that validator operators can enter or exit at will depending on their liquidity needs. +In order to avoid fragmentation, there should be one standard wrapper contract. + +## Specification + +The source code for the ERC-XXXX contract is available at https://github.com/bbjubjub2494/erc-xxxx, and can be deployed trustlessly using Arachnid's deterministic deployment proxy. It consists of a minimal withdrawal receiver contract which is deployed using a proxy for each NFT, and the main contract, which implements the ERC-721 interface as well as the metadata and enumeration extensions. +The main contract allows anyone to mint a token associated with a specific validator public key. A proxy contract is immediately deployed to a unique withdrawal address. Holding the token confers full control over the withdrawal address and thus, once the validator is deployed, of its stake. More specifically, the main contract offers the following bespoke methods for the token holder to call: +- `pullNativeBalance(tokenId, destination)` move the ether in the withdrawal address to the destination via a call. +- `requestFullWithdrawal(tokenId)` use EIP-7002 to force an exit of the validator. The EIP-7002 fee will be paid from the balance of the withdrawal address. If necessary, the caller can add value to the call that will first be added to the withdrawal address balance. +- `requestPartialWithdrawal(tokenId, amount)` use EIP-7002 to withdraw part of the validator's consensus layer balance. The fee is handled as described previously. +- `requestConsolidation(tokenId, targetKeyHi, targetKeyLo)` send and EIP-7251 request to consolidate the validator's stake into another validator. The fee is handled identically to the EIP-7002 fee. +- `requestSwitchToCompounding(tokenId)` special case of the previous function to consolidate the validator into itself in order to make it compound its rewards on the beacon chain. +- `arbitraryCall(tokenId, target, data)` perform an EVM call from the withdrawal address, forwarding the call value passed by the caller. This is intended to be used for anything not anticipated by this ERC, such as collecting airdropped ERC-20s. +In addition, the main contract implements ERC-5646, which allows other contracts to query the implementation-defined state fingerprint of a given token. Since information about validators is not synchronously accessible on the execution layer, the token state in ERC-XXXX is defined as the list of past actions performed through the main contract that could affect the funds of the validator, i.e. consolidation requests, pulling the execution layer balance and performing arbitrary calls. + +## Rationale + +### ERC-721 +The decision to use non-fungible tokens stems from the fact that one unit of stake in one validator is not inherently interchangeable with one in another, since both validators can earn different rewards, get slashed, or exit independently from one another. A complex layer of abstraction would be necessary to make them fungible, which we avoid for the sake of minimalism. Wrappers on top of ERC-XXXX can implement their preferred fungibility concept if desired. + +### ERC-5646 +Implementing ERC-5646 counters the attack vector described in [[#Collateral Value]], only using one storage slot per token. It also allows ERC-XXXX to be used as collateral in generic trustless lending protocols such as [PWN](https://pwn.xyz/). + +### CREATE2 salt +When a user requests a token be minted, the resulting withdrawal address depends only on the user provided validator key and initial owner address. This means that withdrawal addresses are predictable (but not ERC-721 token ids) and thus that validator deposits can safely be performed ahead of time. This also protects in some reorg scenarios. Since the owner address is also included, this is not a DoS vector. + +### Storage Layout +The main contract keeps storage slots associated with a given token adjacent, and aligns them to a power of two. This has little impact under current conditions, but should a state tree upgrade similar to EIP-6800 or EIP-7864 occur, it ensures all the slots are warmed up at once, saving gas, and that consecutively minted batches of tokens are cheaper to manipulate. + +## Security Considerations + +### Smart Contract Correctness +A bug in the ERC-XXXX contracts could have serious consequences. To reduce the probability of one, ERC-XXXX contracts are designed to be minimal, taking on just enough complexity so that higher layers can implement all foreseeable features. They are written using high-level constructs in the Vyper language — for which a formally verified compiler is expected soon, and only perform sensible and conservative gas optimisations. Third-party code review and formal verification are TBD. + +### Collateral Value +The existence of an ERC-XXXX token does not guarantee that a validator with the same public key is present on the consensus layer and that its withdrawal credentials are correctly set. Counterparties treating ERC-XXXX tokens as collateral should ensure that this is the case by querying the consensus layer. Additionally, if the token is under the immediate control of a counterparty, they should use ERC-5646 to guard against front-running attacks, where the counterparty withdraws part of the stake at the last moment. +Smart contracts dealing with ERC-XXXX tokens can use EIP-4788 to verify proofs of the validator's state if needed. + +### Deposit Front-running +If no deposit has been made for a given validator yet, care should be taken to deal with deposit front-running by the party holding the validator key. ([An attack described in detail here](https://ethresear.ch/t/deposit-contract-exploit/6528)) There are two trustless ways to address this: one is to require the party to perform a 1 ETH pre-deposit to set the credentials, the other is to use the deposit contract Merkle root to detect the attack and revert the deposit. If EIP-8025 is activated, it can also be used to mitigate this attack. + +### Slashing and Penalties +The validator can leak or get slashed if its operator misbehaves. This issue can be handled economically by ensuring the operator retains exposure to a fraction of the validator's stake that can be seized in case the value of the stake goes down. This is also why ERC-XXXX tokens should not be naively bought or sold. + +### MEV Income +The withdrawal credential does not capture MEV-related income that the operator may be able to extract by running the node. This can be accounted for by charging a sufficient interest rate to the operator. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). From 0e8a3fd52a6d91f55acc2bfb133abeeb88b56309 Mon Sep 17 00:00:00 2001 From: "Julie B." Date: Mon, 25 May 2026 08:47:15 +0200 Subject: [PATCH 2/7] erc-8270: assign number --- ERCS/{erc-xxxx.md => erc-8270.md} | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) rename ERCS/{erc-xxxx.md => erc-8270.md} (90%) diff --git a/ERCS/erc-xxxx.md b/ERCS/erc-8270.md similarity index 90% rename from ERCS/erc-xxxx.md rename to ERCS/erc-8270.md index e16722b4621..a90f4d83e2d 100644 --- a/ERCS/erc-xxxx.md +++ b/ERCS/erc-8270.md @@ -1,8 +1,9 @@ --- +eip: 8270 title: Canonical Validator Wrapper description: Minimalist contract to make Beacon Chain withdrawal credentials transferable author: Julie B. (@bbjubjub2494) -discussions-to: https://ethereum-magicians.org/t/draft-erc-canonical-validator-wrapper/28599 +discussions-to: https://ethereum-magicians.org/t/erc-8270-canonical-validator-wrapper/28599 status: Draft type: Standards Track category: ERC @@ -21,7 +22,7 @@ In order to avoid fragmentation, there should be one standard wrapper contract. ## Specification -The source code for the ERC-XXXX contract is available at https://github.com/bbjubjub2494/erc-xxxx, and can be deployed trustlessly using Arachnid's deterministic deployment proxy. It consists of a minimal withdrawal receiver contract which is deployed using a proxy for each NFT, and the main contract, which implements the ERC-721 interface as well as the metadata and enumeration extensions. +The source code for the ERC-8270 contract is available at https://github.com/bbjubjub2494/erc-xxxx, and can be deployed trustlessly using Arachnid's deterministic deployment proxy. It consists of a minimal withdrawal receiver contract which is deployed using a proxy for each NFT, and the main contract, which implements the ERC-721 interface as well as the metadata and enumeration extensions. The main contract allows anyone to mint a token associated with a specific validator public key. A proxy contract is immediately deployed to a unique withdrawal address. Holding the token confers full control over the withdrawal address and thus, once the validator is deployed, of its stake. More specifically, the main contract offers the following bespoke methods for the token holder to call: - `pullNativeBalance(tokenId, destination)` move the ether in the withdrawal address to the destination via a call. - `requestFullWithdrawal(tokenId)` use EIP-7002 to force an exit of the validator. The EIP-7002 fee will be paid from the balance of the withdrawal address. If necessary, the caller can add value to the call that will first be added to the withdrawal address balance. @@ -29,15 +30,15 @@ The main contract allows anyone to mint a token associated with a specific valid - `requestConsolidation(tokenId, targetKeyHi, targetKeyLo)` send and EIP-7251 request to consolidate the validator's stake into another validator. The fee is handled identically to the EIP-7002 fee. - `requestSwitchToCompounding(tokenId)` special case of the previous function to consolidate the validator into itself in order to make it compound its rewards on the beacon chain. - `arbitraryCall(tokenId, target, data)` perform an EVM call from the withdrawal address, forwarding the call value passed by the caller. This is intended to be used for anything not anticipated by this ERC, such as collecting airdropped ERC-20s. -In addition, the main contract implements ERC-5646, which allows other contracts to query the implementation-defined state fingerprint of a given token. Since information about validators is not synchronously accessible on the execution layer, the token state in ERC-XXXX is defined as the list of past actions performed through the main contract that could affect the funds of the validator, i.e. consolidation requests, pulling the execution layer balance and performing arbitrary calls. +In addition, the main contract implements ERC-5646, which allows other contracts to query the implementation-defined state fingerprint of a given token. Since information about validators is not synchronously accessible on the execution layer, the token state in ERC-8270 is defined as the list of past actions performed through the main contract that could affect the funds of the validator, i.e. consolidation requests, pulling the execution layer balance and performing arbitrary calls. ## Rationale ### ERC-721 -The decision to use non-fungible tokens stems from the fact that one unit of stake in one validator is not inherently interchangeable with one in another, since both validators can earn different rewards, get slashed, or exit independently from one another. A complex layer of abstraction would be necessary to make them fungible, which we avoid for the sake of minimalism. Wrappers on top of ERC-XXXX can implement their preferred fungibility concept if desired. +The decision to use non-fungible tokens stems from the fact that one unit of stake in one validator is not inherently interchangeable with one in another, since both validators can earn different rewards, get slashed, or exit independently from one another. A complex layer of abstraction would be necessary to make them fungible, which we avoid for the sake of minimalism. Wrappers on top of ERC-8270 can implement their preferred fungibility concept if desired. ### ERC-5646 -Implementing ERC-5646 counters the attack vector described in [[#Collateral Value]], only using one storage slot per token. It also allows ERC-XXXX to be used as collateral in generic trustless lending protocols such as [PWN](https://pwn.xyz/). +Implementing ERC-5646 counters the attack vector described in [[#Collateral Value]], only using one storage slot per token. It also allows ERC-8270 to be used as collateral in generic trustless lending protocols such as [PWN](https://pwn.xyz/). ### CREATE2 salt When a user requests a token be minted, the resulting withdrawal address depends only on the user provided validator key and initial owner address. This means that withdrawal addresses are predictable (but not ERC-721 token ids) and thus that validator deposits can safely be performed ahead of time. This also protects in some reorg scenarios. Since the owner address is also included, this is not a DoS vector. @@ -48,17 +49,17 @@ The main contract keeps storage slots associated with a given token adjacent, an ## Security Considerations ### Smart Contract Correctness -A bug in the ERC-XXXX contracts could have serious consequences. To reduce the probability of one, ERC-XXXX contracts are designed to be minimal, taking on just enough complexity so that higher layers can implement all foreseeable features. They are written using high-level constructs in the Vyper language — for which a formally verified compiler is expected soon, and only perform sensible and conservative gas optimisations. Third-party code review and formal verification are TBD. +A bug in the ERC-8270 contracts could have serious consequences. To reduce the probability of one, ERC-8270 contracts are designed to be minimal, taking on just enough complexity so that higher layers can implement all foreseeable features. They are written using high-level constructs in the Vyper language — for which a formally verified compiler is expected soon, and only perform sensible and conservative gas optimisations. Third-party code review and formal verification are TBD. ### Collateral Value -The existence of an ERC-XXXX token does not guarantee that a validator with the same public key is present on the consensus layer and that its withdrawal credentials are correctly set. Counterparties treating ERC-XXXX tokens as collateral should ensure that this is the case by querying the consensus layer. Additionally, if the token is under the immediate control of a counterparty, they should use ERC-5646 to guard against front-running attacks, where the counterparty withdraws part of the stake at the last moment. -Smart contracts dealing with ERC-XXXX tokens can use EIP-4788 to verify proofs of the validator's state if needed. +The existence of an ERC-8270 token does not guarantee that a validator with the same public key is present on the consensus layer and that its withdrawal credentials are correctly set. Counterparties treating ERC-8270 tokens as collateral should ensure that this is the case by querying the consensus layer. Additionally, if the token is under the immediate control of a counterparty, they should use ERC-5646 to guard against front-running attacks, where the counterparty withdraws part of the stake at the last moment. +Smart contracts dealing with ERC-8270 tokens can use EIP-4788 to verify proofs of the validator's state if needed. ### Deposit Front-running If no deposit has been made for a given validator yet, care should be taken to deal with deposit front-running by the party holding the validator key. ([An attack described in detail here](https://ethresear.ch/t/deposit-contract-exploit/6528)) There are two trustless ways to address this: one is to require the party to perform a 1 ETH pre-deposit to set the credentials, the other is to use the deposit contract Merkle root to detect the attack and revert the deposit. If EIP-8025 is activated, it can also be used to mitigate this attack. ### Slashing and Penalties -The validator can leak or get slashed if its operator misbehaves. This issue can be handled economically by ensuring the operator retains exposure to a fraction of the validator's stake that can be seized in case the value of the stake goes down. This is also why ERC-XXXX tokens should not be naively bought or sold. +The validator can leak or get slashed if its operator misbehaves. This issue can be handled economically by ensuring the operator retains exposure to a fraction of the validator's stake that can be seized in case the value of the stake goes down. This is also why ERC-8270 tokens should not be naively bought or sold. ### MEV Income The withdrawal credential does not capture MEV-related income that the operator may be able to extract by running the node. This can be accounted for by charging a sufficient interest rate to the operator. From 4042f887e2be79e1ec5e8f0309b9d58a8e31e005 Mon Sep 17 00:00:00 2001 From: "Julie B." Date: Mon, 25 May 2026 08:48:11 +0200 Subject: [PATCH 3/7] erc-8270: update github link --- ERCS/erc-8270.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ERCS/erc-8270.md b/ERCS/erc-8270.md index a90f4d83e2d..ff12cbab7be 100644 --- a/ERCS/erc-8270.md +++ b/ERCS/erc-8270.md @@ -22,7 +22,7 @@ In order to avoid fragmentation, there should be one standard wrapper contract. ## Specification -The source code for the ERC-8270 contract is available at https://github.com/bbjubjub2494/erc-xxxx, and can be deployed trustlessly using Arachnid's deterministic deployment proxy. It consists of a minimal withdrawal receiver contract which is deployed using a proxy for each NFT, and the main contract, which implements the ERC-721 interface as well as the metadata and enumeration extensions. +The source code for the ERC-8270 contract is available at https://github.com/bbjubjub2494/erc-8270, and can be deployed trustlessly using Arachnid's deterministic deployment proxy. It consists of a minimal withdrawal receiver contract which is deployed using a proxy for each NFT, and the main contract, which implements the ERC-721 interface as well as the metadata and enumeration extensions. The main contract allows anyone to mint a token associated with a specific validator public key. A proxy contract is immediately deployed to a unique withdrawal address. Holding the token confers full control over the withdrawal address and thus, once the validator is deployed, of its stake. More specifically, the main contract offers the following bespoke methods for the token holder to call: - `pullNativeBalance(tokenId, destination)` move the ether in the withdrawal address to the destination via a call. - `requestFullWithdrawal(tokenId)` use EIP-7002 to force an exit of the validator. The EIP-7002 fee will be paid from the balance of the withdrawal address. If necessary, the caller can add value to the call that will first be added to the withdrawal address balance. From 6dd632bace0c2f288efe2a5cc342fafbbe9b8858 Mon Sep 17 00:00:00 2001 From: "Julie B." Date: Mon, 25 May 2026 09:57:24 +0200 Subject: [PATCH 4/7] fix links --- ERCS/erc-8270.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/ERCS/erc-8270.md b/ERCS/erc-8270.md index ff12cbab7be..c247b5f9e39 100644 --- a/ERCS/erc-8270.md +++ b/ERCS/erc-8270.md @@ -8,7 +8,7 @@ status: Draft type: Standards Track category: ERC created: 2026-05-23 -requires: EIP-1014, EIP-7002, EIP-7251 +requires: 1014, 7002, 7251 --- ## Abstract @@ -22,44 +22,44 @@ In order to avoid fragmentation, there should be one standard wrapper contract. ## Specification -The source code for the ERC-8270 contract is available at https://github.com/bbjubjub2494/erc-8270, and can be deployed trustlessly using Arachnid's deterministic deployment proxy. It consists of a minimal withdrawal receiver contract which is deployed using a proxy for each NFT, and the main contract, which implements the ERC-721 interface as well as the metadata and enumeration extensions. +The smart contract source code for this ERC is available at https://github.com/bbjubjub2494/erc-8270, and can be deployed trustlessly using Arachnid's deterministic deployment proxy. It consists of a minimal withdrawal receiver contract which is deployed using a proxy for each NFT, and the main contract, which implements the [ERC-721](./eip-721.md) interface as well as the metadata and enumeration extensions. The main contract allows anyone to mint a token associated with a specific validator public key. A proxy contract is immediately deployed to a unique withdrawal address. Holding the token confers full control over the withdrawal address and thus, once the validator is deployed, of its stake. More specifically, the main contract offers the following bespoke methods for the token holder to call: - `pullNativeBalance(tokenId, destination)` move the ether in the withdrawal address to the destination via a call. -- `requestFullWithdrawal(tokenId)` use EIP-7002 to force an exit of the validator. The EIP-7002 fee will be paid from the balance of the withdrawal address. If necessary, the caller can add value to the call that will first be added to the withdrawal address balance. +- `requestFullWithdrawal(tokenId)` use [EIP-7002](./eip-7002.md) to force an exit of the validator. The EIP-7002 fee will be paid from the balance of the withdrawal address. If necessary, the caller can add value to the call that will first be added to the withdrawal address balance. - `requestPartialWithdrawal(tokenId, amount)` use EIP-7002 to withdraw part of the validator's consensus layer balance. The fee is handled as described previously. -- `requestConsolidation(tokenId, targetKeyHi, targetKeyLo)` send and EIP-7251 request to consolidate the validator's stake into another validator. The fee is handled identically to the EIP-7002 fee. +- `requestConsolidation(tokenId, targetKeyHi, targetKeyLo)` send and [EIP-7251](./eip-7251.md) request to consolidate the validator's stake into another validator. The fee is handled identically to the EIP-7002 fee. - `requestSwitchToCompounding(tokenId)` special case of the previous function to consolidate the validator into itself in order to make it compound its rewards on the beacon chain. -- `arbitraryCall(tokenId, target, data)` perform an EVM call from the withdrawal address, forwarding the call value passed by the caller. This is intended to be used for anything not anticipated by this ERC, such as collecting airdropped ERC-20s. -In addition, the main contract implements ERC-5646, which allows other contracts to query the implementation-defined state fingerprint of a given token. Since information about validators is not synchronously accessible on the execution layer, the token state in ERC-8270 is defined as the list of past actions performed through the main contract that could affect the funds of the validator, i.e. consolidation requests, pulling the execution layer balance and performing arbitrary calls. +- `arbitraryCall(tokenId, target, data)` perform an EVM call from the withdrawal address, forwarding the call value passed by the caller. This is intended to be used for anything not anticipated by this ERC, such as collecting airdropped tokens. +In addition, the main contract implements [ERC-5646](./eip-5646.md), which allows other contracts to query the implementation-defined state fingerprint of a given token. Since information about validators is not synchronously accessible on the execution layer, the token state in this ERC is defined as the list of past actions performed through the main contract that could affect the funds of the validator, i.e. consolidation requests, pulling the execution layer balance and performing arbitrary calls. ## Rationale ### ERC-721 -The decision to use non-fungible tokens stems from the fact that one unit of stake in one validator is not inherently interchangeable with one in another, since both validators can earn different rewards, get slashed, or exit independently from one another. A complex layer of abstraction would be necessary to make them fungible, which we avoid for the sake of minimalism. Wrappers on top of ERC-8270 can implement their preferred fungibility concept if desired. +The decision to use non-fungible tokens stems from the fact that one unit of stake in one validator is not inherently interchangeable with one in another, since both validators can earn different rewards, get slashed, or exit independently from one another. A complex layer of abstraction would be necessary to make them fungible, which we avoid for the sake of minimalism. Wrappers on top of this ERC can implement their preferred fungibility concept if desired. ### ERC-5646 -Implementing ERC-5646 counters the attack vector described in [[#Collateral Value]], only using one storage slot per token. It also allows ERC-8270 to be used as collateral in generic trustless lending protocols such as [PWN](https://pwn.xyz/). +Implementing ERC-5646 counters the attack vector described in [[#Collateral Value]], only using one storage slot per token. It also allows wrapped validators to be used as collateral in generic trustless lending protocols such as [PWN](https://pwn.xyz/). ### CREATE2 salt When a user requests a token be minted, the resulting withdrawal address depends only on the user provided validator key and initial owner address. This means that withdrawal addresses are predictable (but not ERC-721 token ids) and thus that validator deposits can safely be performed ahead of time. This also protects in some reorg scenarios. Since the owner address is also included, this is not a DoS vector. ### Storage Layout -The main contract keeps storage slots associated with a given token adjacent, and aligns them to a power of two. This has little impact under current conditions, but should a state tree upgrade similar to EIP-6800 or EIP-7864 occur, it ensures all the slots are warmed up at once, saving gas, and that consecutively minted batches of tokens are cheaper to manipulate. +The main contract keeps storage slots associated with a given token adjacent, and aligns them to a power of two. This has little impact under current conditions, but should a state tree upgrade similar to [EIP-6800](./eip-6800.md) or [EIP-7864](./eip-7864.md) occur, it ensures all the slots are warmed up at once, saving gas, and that consecutively minted batches of tokens are cheaper to manipulate. ## Security Considerations ### Smart Contract Correctness -A bug in the ERC-8270 contracts could have serious consequences. To reduce the probability of one, ERC-8270 contracts are designed to be minimal, taking on just enough complexity so that higher layers can implement all foreseeable features. They are written using high-level constructs in the Vyper language — for which a formally verified compiler is expected soon, and only perform sensible and conservative gas optimisations. Third-party code review and formal verification are TBD. +A bug in the wrapped validator contracts could have serious consequences. To reduce the probability of one, the contracts are designed to be minimal, taking on just enough complexity so that higher layers can implement all foreseeable features. They are written using high-level constructs in the Vyper language — for which a formally verified compiler is expected soon, and only perform sensible and conservative gas optimisations. Third-party code review and formal verification are TBD. ### Collateral Value -The existence of an ERC-8270 token does not guarantee that a validator with the same public key is present on the consensus layer and that its withdrawal credentials are correctly set. Counterparties treating ERC-8270 tokens as collateral should ensure that this is the case by querying the consensus layer. Additionally, if the token is under the immediate control of a counterparty, they should use ERC-5646 to guard against front-running attacks, where the counterparty withdraws part of the stake at the last moment. -Smart contracts dealing with ERC-8270 tokens can use EIP-4788 to verify proofs of the validator's state if needed. +The existence of an wrapped validator token does not guarantee that a validator with the same public key is present on the consensus layer and that its withdrawal credentials are correctly set. Counterparties treating wrapped validator tokens as collateral should ensure that this is the case by querying the consensus layer. Additionally, if the token is under the immediate control of a counterparty, they should use ERC-5646 to guard against front-running attacks, where the counterparty withdraws part of the stake at the last moment. +Smart contracts dealing with wrapped validator tokens can use [EIP-4788](./eip-4788.md) to verify proofs of the validator's state if needed. ### Deposit Front-running -If no deposit has been made for a given validator yet, care should be taken to deal with deposit front-running by the party holding the validator key. ([An attack described in detail here](https://ethresear.ch/t/deposit-contract-exploit/6528)) There are two trustless ways to address this: one is to require the party to perform a 1 ETH pre-deposit to set the credentials, the other is to use the deposit contract Merkle root to detect the attack and revert the deposit. If EIP-8025 is activated, it can also be used to mitigate this attack. +If no deposit has been made for a given validator yet, care should be taken to deal with deposit front-running by the party holding the validator key. ([An attack described in detail here](https://ethresear.ch/t/deposit-contract-exploit/6528)) There are two trustless ways to address this: one is to require the party to perform a 1 ETH pre-deposit to set the credentials, the other is to use the deposit contract Merkle root to detect the attack and revert the deposit. If [EIP-8025](./eip-8025.md) is activated, it can also be used to mitigate this attack. ### Slashing and Penalties -The validator can leak or get slashed if its operator misbehaves. This issue can be handled economically by ensuring the operator retains exposure to a fraction of the validator's stake that can be seized in case the value of the stake goes down. This is also why ERC-8270 tokens should not be naively bought or sold. +The validator can leak or get slashed if its operator misbehaves. This issue can be handled economically by ensuring the operator retains exposure to a fraction of the validator's stake that can be seized in case the value of the stake goes down. This is also why wrapped validator tokens should not be naively bought or sold. ### MEV Income The withdrawal credential does not capture MEV-related income that the operator may be able to extract by running the node. This can be accounted for by charging a sufficient interest rate to the operator. From 131e81cb70cc8098751e027ee2ed0fd1c87db204 Mon Sep 17 00:00:00 2001 From: "Julie B." Date: Sun, 31 May 2026 22:54:10 +0200 Subject: [PATCH 5/7] avoid external links --- ERCS/erc-8270.md | 9 +- assets/erc-8270/ERC8270.vy | 510 ++++++++++++++++++++++++ assets/erc-8270/IWithdrawalReceiver.vyi | 38 ++ assets/erc-8270/WithdrawalReceiver.vy | 105 +++++ assets/erc-8270/format_helpers.vy | 175 ++++++++ assets/erc-8270/logo-with-text-data.svg | 78 ++++ assets/erc-8270/logo.svg | 66 +++ assets/erc-8270/prepare.py | 66 +++ 8 files changed, 1044 insertions(+), 3 deletions(-) create mode 100644 assets/erc-8270/ERC8270.vy create mode 100644 assets/erc-8270/IWithdrawalReceiver.vyi create mode 100644 assets/erc-8270/WithdrawalReceiver.vy create mode 100644 assets/erc-8270/format_helpers.vy create mode 100644 assets/erc-8270/logo-with-text-data.svg create mode 100644 assets/erc-8270/logo.svg create mode 100755 assets/erc-8270/prepare.py diff --git a/ERCS/erc-8270.md b/ERCS/erc-8270.md index c247b5f9e39..56db38153f8 100644 --- a/ERCS/erc-8270.md +++ b/ERCS/erc-8270.md @@ -22,7 +22,8 @@ In order to avoid fragmentation, there should be one standard wrapper contract. ## Specification -The smart contract source code for this ERC is available at https://github.com/bbjubjub2494/erc-8270, and can be deployed trustlessly using Arachnid's deterministic deployment proxy. It consists of a minimal withdrawal receiver contract which is deployed using a proxy for each NFT, and the main contract, which implements the [ERC-721](./eip-721.md) interface as well as the metadata and enumeration extensions. +The Vyper source code for the wrapper is included in the ERC repository, along with a Python script generating deployment parameters. It can be deployed trustlessly using a well-known deterministic deployment proxy. The system has two smart contracts: a minimal withdrawal receiver contract which is deployed using a proxy for each NFT, and the main contract which implements the [ERC-721](./eip-721.md) interface as well as the metadata and enumeration extensions. + The main contract allows anyone to mint a token associated with a specific validator public key. A proxy contract is immediately deployed to a unique withdrawal address. Holding the token confers full control over the withdrawal address and thus, once the validator is deployed, of its stake. More specifically, the main contract offers the following bespoke methods for the token holder to call: - `pullNativeBalance(tokenId, destination)` move the ether in the withdrawal address to the destination via a call. - `requestFullWithdrawal(tokenId)` use [EIP-7002](./eip-7002.md) to force an exit of the validator. The EIP-7002 fee will be paid from the balance of the withdrawal address. If necessary, the caller can add value to the call that will first be added to the withdrawal address balance. @@ -30,6 +31,7 @@ The main contract allows anyone to mint a token associated with a specific valid - `requestConsolidation(tokenId, targetKeyHi, targetKeyLo)` send and [EIP-7251](./eip-7251.md) request to consolidate the validator's stake into another validator. The fee is handled identically to the EIP-7002 fee. - `requestSwitchToCompounding(tokenId)` special case of the previous function to consolidate the validator into itself in order to make it compound its rewards on the beacon chain. - `arbitraryCall(tokenId, target, data)` perform an EVM call from the withdrawal address, forwarding the call value passed by the caller. This is intended to be used for anything not anticipated by this ERC, such as collecting airdropped tokens. + In addition, the main contract implements [ERC-5646](./eip-5646.md), which allows other contracts to query the implementation-defined state fingerprint of a given token. Since information about validators is not synchronously accessible on the execution layer, the token state in this ERC is defined as the list of past actions performed through the main contract that could affect the funds of the validator, i.e. consolidation requests, pulling the execution layer balance and performing arbitrary calls. ## Rationale @@ -38,7 +40,7 @@ In addition, the main contract implements [ERC-5646](./eip-5646.md), which allow The decision to use non-fungible tokens stems from the fact that one unit of stake in one validator is not inherently interchangeable with one in another, since both validators can earn different rewards, get slashed, or exit independently from one another. A complex layer of abstraction would be necessary to make them fungible, which we avoid for the sake of minimalism. Wrappers on top of this ERC can implement their preferred fungibility concept if desired. ### ERC-5646 -Implementing ERC-5646 counters the attack vector described in [[#Collateral Value]], only using one storage slot per token. It also allows wrapped validators to be used as collateral in generic trustless lending protocols such as [PWN](https://pwn.xyz/). +Implementing ERC-5646 allows smart contracts to cheaply compare the state of a wrapper to a known good state, to ensure that the owner did not change it unexpectedly. Without this, it would be difficult to prevent certain front-running attacks. ### CREATE2 salt When a user requests a token be minted, the resulting withdrawal address depends only on the user provided validator key and initial owner address. This means that withdrawal addresses are predictable (but not ERC-721 token ids) and thus that validator deposits can safely be performed ahead of time. This also protects in some reorg scenarios. Since the owner address is also included, this is not a DoS vector. @@ -56,7 +58,8 @@ The existence of an wrapped validator token does not guarantee that a validator Smart contracts dealing with wrapped validator tokens can use [EIP-4788](./eip-4788.md) to verify proofs of the validator's state if needed. ### Deposit Front-running -If no deposit has been made for a given validator yet, care should be taken to deal with deposit front-running by the party holding the validator key. ([An attack described in detail here](https://ethresear.ch/t/deposit-contract-exploit/6528)) There are two trustless ways to address this: one is to require the party to perform a 1 ETH pre-deposit to set the credentials, the other is to use the deposit contract Merkle root to detect the attack and revert the deposit. If [EIP-8025](./eip-8025.md) is activated, it can also be used to mitigate this attack. +Per [the Consensus Specifications](https://github.com/ethereum/consensus-specs/blob/6370819a35e9558822ef024126cc09ee3666827d/specs/phase0/beacon-chain.md#deposits), if multiple deposits with the same validator key are made, only the withdrawal credentials of the first deposit are taken into account. As a result, a party that knows the validator key can steal funds by front-running the intended first deposit with their own deposit, putting in place withdrawal credentials they control and capturing the funds from the original deposit. +There are two trustless ways to address this: one is to require the party to perform a 1 ETH pre-deposit to set the credentials, the other is to use the deposit contract Merkle root to detect unexpected deposits and revert. If [EIP-8025](./eip-8025.md) is activated, it can also be used to mitigate this attack. ### Slashing and Penalties The validator can leak or get slashed if its operator misbehaves. This issue can be handled economically by ensuring the operator retains exposure to a fraction of the validator's stake that can be seized in case the value of the stake goes down. This is also why wrapped validator tokens should not be naively bought or sold. diff --git a/assets/erc-8270/ERC8270.vy b/assets/erc-8270/ERC8270.vy new file mode 100644 index 00000000000..64b73c05511 --- /dev/null +++ b/assets/erc-8270/ERC8270.vy @@ -0,0 +1,510 @@ +""" +@title ERC-8270: Canonical Validator Wrapper +@license CC0 +@author bbjubjub.eth +""" + +# pragma version ==0.4.3 +# pragma evm-version prague +# pragma nonreentrancy on + +from . import IWithdrawalReceiver + +from ethereum.ercs import IERC721 + +from . import format_helpers as fmt + + +interface ERC721Receiver: + def onERC721Received( + sender: address, owner: address, token_id: uint256, data: Bytes[2**16] + ) -> bytes4: nonpayable + + +implements: IERC721 + + +event ConsolidationRequest: + token_id: indexed(uint256) + target_key_hi: bytes32 + target_key_lo: bytes16 + + +event ArbitraryCall: + token_id: indexed(uint256) + target: address + data: Bytes[2**16] + + +event PullNativeBalance: + token_id: indexed(uint256) + target: address + data: Bytes[2**16] + + +# this is just to silence a warning since we will never mint this many. +MAX_ID: constant(uint256) = 2**64 - 1 + +# we store all the data associated with a token in an array of structs +# to increase locality and reduce hashing +# preemptive optimisation for state warming update and hash gas cost increases. +struct TokenData: + index_and_owner: uint256 + approved: address + withdrawal_address: address + state_fingerprint: bytes32 + + +next_id: uint256 +image_url: String[128] # 5 slots +tokens_by_owner: HashMap[address, DynArray[uint256, MAX_ID]] +approval_for_all: HashMap[address, HashMap[address, bool]] + +# this puts the unused 0th element at 0xfc and the first NFT at 0x100 +_padding: bytes32[244] +token_data: TokenData[MAX_ID] + +WITHDRAWAL_RECEIVER_IMPL: immutable(address) + + +@pure +def _pack(index_by_owner: uint96, owner: address) -> uint256: + return convert(index_by_owner, uint256) << 160 | convert(owner, uint256) + + +@pure +def _unpack(index_and_owner: uint256) -> (uint96, address): + index_by_owner: uint96 = convert(index_and_owner >> 160, uint96) + mask: uint256 = convert(max_value(uint160), uint256) + owner: address = convert(index_and_owner & mask, address) + return index_by_owner, owner + + +@deploy +def __init__(image_url: String[128], withdrawal_receiver_code: Bytes[49152]): + """ + @dev we make this contract deploy the withdrawal receiver because both contracts need to know each other's addresses + """ + WITHDRAWAL_RECEIVER_IMPL = raw_create(withdrawal_receiver_code) + self.next_id = 1 + self.image_url = image_url + + +### ERC-165 ### + +SUPPORTED_INTERFACES: constant(bytes4[5]) = [ + 0x01ffc9a7, # ERC-165 + 0x80ac58cd, # ERC-721 + 0x780e9d63, # ERC-721 enumeration + 0x5b5e139f, # ERC-721 metadata + 0xf5112315, # ERC-5646 +] + + +@external +@view +def supportsInterface(interface_id: bytes4) -> bool: + return interface_id in SUPPORTED_INTERFACES + + +### ERC-721 Metadata ### + + +@external +@pure +def name() -> String[29]: + return "ERC-8270 Wrapped Beacon Stake" + + +@external +@pure +def symbol() -> String[7]: + return "ERC8270" + + +@external +@view +def tokenURI(token_id: uint256) -> String[2**16]: + """ + ERC-721 JSON metadata. The validator key and the withdrawal address are included as attributes. + """ + receiver: IWithdrawalReceiver = self.withdrawal_receiver(token_id) + key_hi: bytes32 = empty(bytes32) + key_lo: bytes16 = empty(bytes16) + key_hi, key_lo = staticcall receiver.validator_key() + return concat( + """data:application/json,{ + "name": "ERC-8270 Token #""" + , + uint2str(token_id), + '",', + """ + "description": "Transferable Beacon Chain Withdrawal Credentials", + "image":""" + , + '"', + self.image_url, + '",', + """ "attributes": [{ + "trait_type": "Validator Key", + "value": "0x""" + , + fmt.bytes32_to_hex(key_hi), + fmt.bytes16_to_hex(key_lo), + '"', + """ + }, { + "trait_type": "Withdrawal Address", + "value": "0x""" + , + fmt.address_to_hex_erc55(self.token_data[token_id].withdrawal_address), + '"}]}', + ) + + +## ERC-721 ## + +@view +def _owner(token_id: uint256) -> address: + owner: address = self._unpack(self.token_data[token_id].index_and_owner)[1] + assert owner != empty(address), "ERC-721: token does not exist" + return owner + + +@view +def check_exists(token_id: uint256): + assert self.token_data[token_id].index_and_owner != 0, "ERC-721: token does not exist" + + +@view +def check_allowed(token_id: uint256, owner: address): + if msg.sender != owner and msg.sender != self.token_data[token_id].approved: + assert self.approval_for_all[owner][msg.sender], "ERC-721: not owner or approved" + + +@view +def check_operator(owner: address): + if msg.sender != owner: + assert self.approval_for_all[owner][msg.sender], "ERC-721: not owner or operator" + + +@external +@view +def balanceOf(owner: address) -> uint256: + return len(self.tokens_by_owner[owner]) + + +@external +@view +def ownerOf(token_id: uint256) -> address: + return self._owner(token_id) + + +@external +@view +def getApproved(token_id: uint256) -> address: + self.check_exists(token_id) + return self.token_data[token_id].approved + + +@external +@view +def isApprovedForAll(owner: address, operator: address) -> bool: + return self.approval_for_all[owner][operator] + + +@external +@payable +def approve(approved: address, token_id: uint256): + assert msg.value == 0, "ERC-721: unexpected value" + owner: address = self._owner(token_id) + self.check_operator(owner) + self.token_data[token_id].approved = approved + log IERC721.Approval(owner=owner, approved=approved, token_id=token_id) + + +@external +def setApprovalForAll(operator: address, approved: bool): + assert operator != msg.sender, "ERC-721: approve to caller" + self.approval_for_all[msg.sender][operator] = approved + log IERC721.ApprovalForAll(owner=msg.sender, operator=operator, approved=approved) + + +def _transfer(expected_owner: address, receiver: address, token_id: uint256): + index: uint96 = 0 + owner: address = empty(address) + index, owner = self._unpack(self.token_data[token_id].index_and_owner) + assert owner == expected_owner, "ERC-721: wrong owner" + self.check_allowed(token_id, owner) + assert receiver != empty(address), "ERC-721: transfer to zero" + + last_id: uint256 = self.tokens_by_owner[owner][len(self.tokens_by_owner[owner]) - 1] + self.tokens_by_owner[owner][index] = last_id + self.tokens_by_owner[owner].pop() + self.token_data[last_id].index_and_owner = self._pack(index, owner) + + index = convert(len(self.tokens_by_owner[receiver]), uint96) + self.tokens_by_owner[receiver].append(token_id) + self.token_data[token_id].index_and_owner = self._pack(index, receiver) + self.token_data[token_id].approved = empty(address) + log IERC721.Transfer(sender=owner, receiver=receiver, token_id=token_id) + + +# caller must ensure the token_id is fresh +def _mint(receiver: address, token_id: uint256): + assert receiver != empty(address), "ERC-721: mint to zero" + index: uint96 = convert(len(self.tokens_by_owner[receiver]), uint96) + self.tokens_by_owner[receiver].append(token_id) + self.token_data[token_id].index_and_owner = self._pack(index, receiver) + self.token_data[token_id].approved = empty(address) + log IERC721.Transfer(sender=empty(address), receiver=receiver, token_id=token_id) + + +@external +@payable +def transferFrom(owner: address, receiver: address, token_id: uint256): + assert msg.value == 0, "ERC-721: unexpected value" + self._transfer(owner, receiver, token_id) + + +@external +@payable +def safeTransferFrom( + owner: address, + receiver: address, + token_id: uint256, + data: Bytes[2**16] = b"", +): + assert msg.value == 0, "ERC-721: unexpected value" + self._transfer(owner, receiver, token_id) + + if receiver.is_contract: + assert ( + extcall ERC721Receiver(receiver).onERC721Received(msg.sender, owner, token_id, data) + == 0x150b7a02 + ), "ERC-721: receiver rejected transfer" + + +## ERC-721 Enumerable ## + +@external +@view +def totalSupply() -> uint256: + return self.next_id - 1 + + +@external +@view +def tokenByIndex(index: uint256) -> uint256: + assert index < self.next_id - 1, "ERC-721: invalid index" + return index + 1 + + +@external +@view +def tokenOfOwnerByIndex(owner: address, index: uint256) -> uint256: + assert index < len(self.tokens_by_owner[owner]), "ERC-721: invalid index" + return self.tokens_by_owner[owner][index] + + +## ERC-5646 ## + +@external +@view +def getStateFingerprint(token_id: uint256) -> bytes32: + """ + @notice ERC-5646 state fingerprint. It changes when `requestConsolidation()`, `pullNativeBalance()`, and `arbitraryCall()` are used on the token. + @dev the fingerprint is an EIP-712 hash which includes the previous hash. The following signatures are used: + - `Minted()` + - `ConsolidationRequested(bytes32 previousFingerprint,bytes32 targetKeyHi,bytes16 targetKeyLo)` + - `NativeBalancePulled(bytes32 previousFingerprint,address target,bytes data)` + - `ArbitraryCall(bytes32 previousFingerprint,address target,bytes data)` + """ + state_fingerprint: bytes32 = self.token_data[token_id].state_fingerprint + assert state_fingerprint != empty(bytes32), "ERC-721: token does not exist" + return state_fingerprint + + +## ERC-8270 ## + +@view +def withdrawal_receiver(token_id: uint256) -> IWithdrawalReceiver: + withdrawal_address: address = self.token_data[token_id].withdrawal_address + assert withdrawal_address != empty(address), "ERC-721: token does not exist" + return IWithdrawalReceiver(withdrawal_address) + + +@external +def mint( + validator_key_hi: bytes32, + validator_key_lo: bytes16, + initial_owner: address = msg.sender, +) -> uint256: + """ + @notice Create a token intended to wrap the given validator. + @dev The withdrawal address of the token depends only on the parameters of this function, hence it can be determined counterfactually. + This function cannot guarantee that the validator will set its withdrawal credentials to the withdrawal address associated with this token. + @param validator_key_hi The 256 most significant bits of the validator BLS12-381 public key. + @param validator_key_lo The 128 least significant bits of the validator BLS12-381 public key. + @param initial_owner The address that should receive the ERC-721 token upon mint. + @return token_id The ERC-721 id of the new token. + """ + withdrawal_address: address = create_minimal_proxy_to( + WITHDRAWAL_RECEIVER_IMPL, + revert_on_failure=False, + salt=keccak256(abi_encode(validator_key_hi, validator_key_lo, initial_owner)), + ) + assert withdrawal_address != empty(address), "ERC-8270: already minted" + + token_id: uint256 = self.next_id + self.next_id = token_id + 1 + self._mint(initial_owner, token_id) + self.token_data[token_id].withdrawal_address = withdrawal_address + self.token_data[token_id].state_fingerprint = keccak256(keccak256("Minted()")) + extcall IWithdrawalReceiver(withdrawal_address)._set_validator_key( + validator_key_hi, validator_key_lo + ) + return token_id + + +@external +@view +def validatorKeyOf(token_id: uint256) -> (bytes32, bytes16): + """ + @return The 256 most significant bits of the validator BLS12-381 public key. + @return The 128 least significant bits of the validator BLS12-381 public key. + """ + return staticcall self.withdrawal_receiver(token_id).validator_key() + + +@external +@view +def withdrawalAddressOf(token_id: uint256) -> address: + """ + @return The address that the validator should use as its withdrawal credential. + """ + withdrawal_address: address = self.token_data[token_id].withdrawal_address + assert withdrawal_address != empty(address), "ERC-721: token does not exist" + return withdrawal_address + + +@external +@payable +def requestPartialWithdrawal(token_id: uint256, amount: uint64): + """ + @notice Request an EIP-7002 partial withdrawal of the validator controlled by this token. + @dev The fee will be paid using the withdrawal address balance. If necessary, the caller can add value to this function to cover it, + @param token_id The ERC-721 id of the token. + @param amount the amount to withdraw, in consensus layer units. + """ + self.check_allowed(token_id, self._owner(token_id)) + assert amount != 0, "ERC-8270: zero partial withdrawal amount" + extcall self.withdrawal_receiver(token_id)._request_withdrawal( + convert(amount, bytes8), + value=msg.value, + ) + + +@external +@payable +def requestFullWithdrawal(token_id: uint256): + """ + @notice Request an EIP-7002 full withdrawal and exit of the validator controlled by this token. + @dev The fee will be paid using the withdrawal address balance. If necessary, the caller can add value to this function to cover it, + @param token_id The ERC-721 id of the token. + """ + self.check_allowed(token_id, self._owner(token_id)) + extcall self.withdrawal_receiver(token_id)._request_withdrawal( + empty(bytes8), + value=msg.value, + ) + + +@external +@payable +def requestConsolidation(token_id: uint256, target_key_hi: bytes32, target_key_lo: bytes16): + """ + @notice Request an EIP-7251 consolidation of the validator controlled by this token. + @dev The fee will be paid using the withdrawal address balance. If necessary, the caller can add value to this function to cover it, + @param token_id The ERC-721 id of the token. + @param target_key_hi The 256 most significant bits of the BLS12-381 public key of the validator to consolidate into. + @param target_key_lo The 128 least significant bits of the BLS12-381 public key of the validator to consolidate into. + """ + self.check_allowed(token_id, self._owner(token_id)) + self.token_data[token_id].state_fingerprint = keccak256( + abi_encode( + keccak256( + "ConsolidationRequested(bytes32 previousFingerprint,bytes32 targetKeyHi,bytes16 targetKeyLo)" + ), + self.token_data[token_id].state_fingerprint, + target_key_hi, + target_key_lo, + ) + ) + extcall self.withdrawal_receiver(token_id)._request_consolidation( + target_key_hi, + target_key_lo, + value=msg.value, + ) + log ConsolidationRequest( + token_id=token_id, target_key_hi=target_key_hi, target_key_lo=target_key_lo + ) + + +@external +@payable +def requestSwitchToCompounding(token_id: uint256): + """ + @notice Use an EIP-7251 consolidation request to turn the validator into a compounding validator. + @dev The fee will be paid using the withdrawal address balance. If necessary, the caller can add value to this function to cover it, + @param token_id The ERC-721 id of the token. + """ + self.check_allowed(token_id, self._owner(token_id)) + extcall self.withdrawal_receiver(token_id)._request_switch_to_compounding(value=msg.value) + + +@external +def pullNativeBalance(token_id: uint256, target: address = msg.sender, data: Bytes[2**16] = b""): + """ + @notice Transfer the native balance of the withdrawal address. + @param token_id The ERC-721 id of the token. + @param target Address to receive the native balance. + @param data calldata to pass along with the balance. + """ + # check, effect, interaction + self.check_allowed(token_id, self._owner(token_id)) + assert target != empty(address), "ERC-8270: pull native balance to zero" + self.token_data[token_id].state_fingerprint = keccak256( + abi_encode( + keccak256("NativeBalancePulled(bytes32 previousFingerprint,address target,bytes data)"), + self.token_data[token_id].state_fingerprint, + target, + keccak256(data), + ) + ) + extcall self.withdrawal_receiver(token_id)._pull_native_balance(target, data) + log PullNativeBalance(token_id=token_id, target=target, data=data) + + +@external +@payable +def arbitraryCall(token_id: uint256, target: address, data: Bytes[2**16]): + """ + @notice Call a function from the withdrawal address. + @dev value from this function will be forwarded to the arbitrary call. + @param token_id The ERC-721 id of the token. + @param target Address of the contract to call. + @param data Calldata to use. + """ + # check, effect, interaction + self.check_allowed(token_id, self._owner(token_id)) + self.token_data[token_id].state_fingerprint = keccak256( + abi_encode( + keccak256("ArbitraryCall(bytes32 previousFingerprint,address target,bytes data)"), + self.token_data[token_id].state_fingerprint, + target, + keccak256(data), + ) + ) + extcall self.withdrawal_receiver(token_id)._arbitrary_call(target, data, value=msg.value) + log ArbitraryCall(token_id=token_id, target=target, data=data) diff --git a/assets/erc-8270/IWithdrawalReceiver.vyi b/assets/erc-8270/IWithdrawalReceiver.vyi new file mode 100644 index 00000000000..1619d2eb863 --- /dev/null +++ b/assets/erc-8270/IWithdrawalReceiver.vyi @@ -0,0 +1,38 @@ +@external +@view +def validator_key() -> (bytes32, bytes16): + ... + + +@external +def _set_validator_key(key_hi: bytes32, key_lo: bytes16): + ... + + +@external +@payable +def _request_withdrawal(amount: bytes8): + ... + + +@external +@payable +def _request_consolidation(target_key_hi: bytes32, target_key_lo: bytes16): + ... + + +@external +@payable +def _request_switch_to_compounding(): + ... + + +@external +def _pull_native_balance(target: address, data: Bytes[2**16]): + ... + + +@external +@payable +def _arbitrary_call(target: address, data: Bytes[2**16]): + ... diff --git a/assets/erc-8270/WithdrawalReceiver.vy b/assets/erc-8270/WithdrawalReceiver.vy new file mode 100644 index 00000000000..6cdad4fe991 --- /dev/null +++ b/assets/erc-8270/WithdrawalReceiver.vy @@ -0,0 +1,105 @@ +""" +@title ERC-8270: Canonical Validator Wrapper — Withdrawal Receiver +@license CC0 +@author bbjubjub.eth +""" + +# pragma version ==0.4.3 +# pragma evm-version prague +# pragma nonreentrancy off + +from . import IWithdrawalReceiver + +implements: IWithdrawalReceiver + +WITHDRAWAL_REQUESTS: constant(address) = 0x00000961Ef480Eb55e80D19ad83579A64c007002 +CONSOLIDATION_REQUESTS: constant(address) = 0x0000BBdDc7CE488642fb579F8B00f3a590007251 + +CONTROLLER: public(immutable(address)) + +validator_key_hi: bytes32 +validator_key_lo: bytes16 + + +@deploy +def __init__(): + CONTROLLER = msg.sender + + +@internal +@view +def _query_fee(target: address) -> uint256: + out: Bytes[32] = raw_call(target, b"", max_outsize=32, is_static_call=True) + return extract32(out, 0, output_type=uint256) + + +@external +@view +def validator_key() -> (bytes32, bytes16): + return self.validator_key_hi, self.validator_key_lo + + +@external +def _set_validator_key(hi: bytes32, lo: bytes16): + assert msg.sender == CONTROLLER + self.validator_key_hi = hi + self.validator_key_lo = lo + + +@external +@payable +def _request_withdrawal(amount: bytes8): + assert msg.sender == CONTROLLER + fee: uint256 = self._query_fee(WITHDRAWAL_REQUESTS) + raw_call( + WITHDRAWAL_REQUESTS, concat(self.validator_key_hi, self.validator_key_lo, amount), value=fee + ) + + +@external +@payable +def _request_consolidation(target_key_hi: bytes32, target_key_lo: bytes16): + assert msg.sender == CONTROLLER + fee: uint256 = self._query_fee(CONSOLIDATION_REQUESTS) + raw_call( + CONSOLIDATION_REQUESTS, + concat(self.validator_key_hi, self.validator_key_lo, target_key_hi, target_key_lo), + value=fee, + ) + + +@external +@payable +def _request_switch_to_compounding(): + assert msg.sender == CONTROLLER + fee: uint256 = self._query_fee(CONSOLIDATION_REQUESTS) + raw_call( + CONSOLIDATION_REQUESTS, + concat( + self.validator_key_hi, + self.validator_key_lo, + self.validator_key_hi, + self.validator_key_lo, + ), + value=fee, + ) + + +@external +def _pull_native_balance(target: address, data: Bytes[2**16]): + assert msg.sender == CONTROLLER + raw_call(target, data, value=self.balance) + + +@external +@payable +def _arbitrary_call(target: address, data: Bytes[2**16]): + assert msg.sender == CONTROLLER + raw_call(target, data, value=msg.value) + + +@external +@payable +def __default__(): + # accept transfers. This could be useful for MEV payments + assert len(msg.data) == 0 diff --git a/assets/erc-8270/format_helpers.vy b/assets/erc-8270/format_helpers.vy new file mode 100644 index 00000000000..d5bf5d64c3b --- /dev/null +++ b/assets/erc-8270/format_helpers.vy @@ -0,0 +1,175 @@ +""" +@title ERC-8270: Canonical Validator Wrapper — Format Helpers +@license CC0 +@author bbjubjub.eth +@dev these functions waste gas: they should be used in view functions running off-chain. +""" + +# pragma version ==0.4.3 +# pragma evm-version prague + + +@pure +def bytes32_to_hex(data: bytes32) -> String[64]: + v: uint256 = convert(data, uint256) + return concat( + self.to_hex(v >> 248), + self.to_hex(v >> 240), + self.to_hex(v >> 232), + self.to_hex(v >> 224), + self.to_hex(v >> 216), + self.to_hex(v >> 208), + self.to_hex(v >> 200), + self.to_hex(v >> 192), + self.to_hex(v >> 184), + self.to_hex(v >> 176), + self.to_hex(v >> 168), + self.to_hex(v >> 160), + self.to_hex(v >> 152), + self.to_hex(v >> 144), + self.to_hex(v >> 136), + self.to_hex(v >> 128), + self.to_hex(v >> 120), + self.to_hex(v >> 112), + self.to_hex(v >> 104), + self.to_hex(v >> 96), + self.to_hex(v >> 88), + self.to_hex(v >> 80), + self.to_hex(v >> 72), + self.to_hex(v >> 64), + self.to_hex(v >> 56), + self.to_hex(v >> 48), + self.to_hex(v >> 40), + self.to_hex(v >> 32), + self.to_hex(v >> 24), + self.to_hex(v >> 16), + self.to_hex(v >> 8), + self.to_hex(v), + ) + + +@pure +def bytes16_to_hex(data: bytes16) -> String[32]: + v: uint256 = convert(data, uint256) + return concat( + self.to_hex(v >> 120), + self.to_hex(v >> 112), + self.to_hex(v >> 104), + self.to_hex(v >> 96), + self.to_hex(v >> 88), + self.to_hex(v >> 80), + self.to_hex(v >> 72), + self.to_hex(v >> 64), + self.to_hex(v >> 56), + self.to_hex(v >> 48), + self.to_hex(v >> 40), + self.to_hex(v >> 32), + self.to_hex(v >> 24), + self.to_hex(v >> 16), + self.to_hex(v >> 8), + self.to_hex(v), + ) + + +@pure +def address_to_hex_erc55(addr: address) -> String[40]: + plain: String[40] = self.address_to_hex(addr) + checksum: uint256 = convert(keccak256(plain), uint256) + return concat( + self.erc55_process_nibble(checksum >> 252, slice(plain, 0, 1)), + self.erc55_process_nibble(checksum >> 248, slice(plain, 1, 1)), + self.erc55_process_nibble(checksum >> 244, slice(plain, 2, 1)), + self.erc55_process_nibble(checksum >> 240, slice(plain, 3, 1)), + self.erc55_process_nibble(checksum >> 236, slice(plain, 4, 1)), + self.erc55_process_nibble(checksum >> 232, slice(plain, 5, 1)), + self.erc55_process_nibble(checksum >> 228, slice(plain, 6, 1)), + self.erc55_process_nibble(checksum >> 224, slice(plain, 7, 1)), + self.erc55_process_nibble(checksum >> 220, slice(plain, 8, 1)), + self.erc55_process_nibble(checksum >> 216, slice(plain, 9, 1)), + self.erc55_process_nibble(checksum >> 212, slice(plain, 10, 1)), + self.erc55_process_nibble(checksum >> 208, slice(plain, 11, 1)), + self.erc55_process_nibble(checksum >> 204, slice(plain, 12, 1)), + self.erc55_process_nibble(checksum >> 200, slice(plain, 13, 1)), + self.erc55_process_nibble(checksum >> 196, slice(plain, 14, 1)), + self.erc55_process_nibble(checksum >> 192, slice(plain, 15, 1)), + self.erc55_process_nibble(checksum >> 188, slice(plain, 16, 1)), + self.erc55_process_nibble(checksum >> 184, slice(plain, 17, 1)), + self.erc55_process_nibble(checksum >> 180, slice(plain, 18, 1)), + self.erc55_process_nibble(checksum >> 176, slice(plain, 19, 1)), + self.erc55_process_nibble(checksum >> 172, slice(plain, 20, 1)), + self.erc55_process_nibble(checksum >> 168, slice(plain, 21, 1)), + self.erc55_process_nibble(checksum >> 164, slice(plain, 22, 1)), + self.erc55_process_nibble(checksum >> 160, slice(plain, 23, 1)), + self.erc55_process_nibble(checksum >> 156, slice(plain, 24, 1)), + self.erc55_process_nibble(checksum >> 152, slice(plain, 25, 1)), + self.erc55_process_nibble(checksum >> 148, slice(plain, 26, 1)), + self.erc55_process_nibble(checksum >> 144, slice(plain, 27, 1)), + self.erc55_process_nibble(checksum >> 140, slice(plain, 28, 1)), + self.erc55_process_nibble(checksum >> 136, slice(plain, 29, 1)), + self.erc55_process_nibble(checksum >> 132, slice(plain, 30, 1)), + self.erc55_process_nibble(checksum >> 128, slice(plain, 31, 1)), + self.erc55_process_nibble(checksum >> 124, slice(plain, 32, 1)), + self.erc55_process_nibble(checksum >> 120, slice(plain, 33, 1)), + self.erc55_process_nibble(checksum >> 116, slice(plain, 34, 1)), + self.erc55_process_nibble(checksum >> 112, slice(plain, 35, 1)), + self.erc55_process_nibble(checksum >> 108, slice(plain, 36, 1)), + self.erc55_process_nibble(checksum >> 104, slice(plain, 37, 1)), + self.erc55_process_nibble(checksum >> 100, slice(plain, 38, 1)), + self.erc55_process_nibble(checksum >> 96, slice(plain, 39, 1)), + ) + + +@pure +def address_to_hex(addr: address) -> String[40]: + v: uint256 = convert(addr, uint256) + return concat( + self.to_hex(v >> 152), + self.to_hex(v >> 144), + self.to_hex(v >> 136), + self.to_hex(v >> 128), + self.to_hex(v >> 120), + self.to_hex(v >> 112), + self.to_hex(v >> 104), + self.to_hex(v >> 96), + self.to_hex(v >> 88), + self.to_hex(v >> 80), + self.to_hex(v >> 72), + self.to_hex(v >> 64), + self.to_hex(v >> 56), + self.to_hex(v >> 48), + self.to_hex(v >> 40), + self.to_hex(v >> 32), + self.to_hex(v >> 24), + self.to_hex(v >> 16), + self.to_hex(v >> 8), + self.to_hex(v), + ) + + +@pure +def to_hex_digit(nibble: uint256) -> String[1]: + alphabet: String[16] = "0123456789abcdef" + return slice(alphabet, nibble % 16, 1) + + +@pure +def to_hex(byte: uint256) -> String[2]: + return concat(self.to_hex_digit(byte // 16), self.to_hex_digit(byte % 16)) + + +@pure +def erc55_process_nibble(checksum: uint256, char: String[1]) -> String[1]: + if checksum % 16 > 7: + if char == "a": + char = "A" + elif char == "b": + char = "B" + elif char == "c": + char = "C" + elif char == "d": + char = "D" + elif char == "e": + char = "E" + elif char == "f": + char = "F" + return char diff --git a/assets/erc-8270/logo-with-text-data.svg b/assets/erc-8270/logo-with-text-data.svg new file mode 100644 index 00000000000..b2ff31b331e --- /dev/null +++ b/assets/erc-8270/logo-with-text-data.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + ERC- + 8270 + + diff --git a/assets/erc-8270/logo.svg b/assets/erc-8270/logo.svg new file mode 100644 index 00000000000..cf573f020e5 --- /dev/null +++ b/assets/erc-8270/logo.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + diff --git a/assets/erc-8270/prepare.py b/assets/erc-8270/prepare.py new file mode 100755 index 00000000000..14ca37590a8 --- /dev/null +++ b/assets/erc-8270/prepare.py @@ -0,0 +1,66 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.13" +# dependencies = [ +# "vyper==0.4.3", +# "eth-abi>=5.0.0", +# "ipfs-cid>=1.0.0", +# ] +# /// + +import functools +import json +from pathlib import Path + +import vyper + +from eth_abi import encode as abi_encode + +from ipfs_cid import cid_sha256_hash + +CONTRACTS_DIR = Path(__file__).parent + +DEPLOYMENT_PROXY = "0x4e59b44847b379578588920cA78FbF26c0B4956C" +SALT = b"\x00" * 32 + + +def _compile_vyper(contract_path: str) -> bytes: + input_bundle = vyper.compiler.input_bundle.FilesystemInputBundle([CONTRACTS_DIR]) + result = vyper.compile_from_file_input( + input_bundle.load_file(contract_path), + input_bundle=input_bundle, + output_formats=["bytecode"], + ) + return bytes.fromhex(result["bytecode"].removeprefix("0x")) + + +@functools.cache +def get_image_url() -> str: + cid = cid_sha256_hash((CONTRACTS_DIR / "logo.svg").read_bytes()) + return f"ipfs://{cid}" + + +@functools.cache +def get_init_code() -> bytes: + wr_code = _compile_vyper("WithdrawalReceiver.vy") + erc_code = _compile_vyper("ERC8270.vy") + image_url = get_image_url() + + return erc_code + abi_encode(["string", "bytes"], [image_url, wr_code]) + + +@functools.cache +def get_deployment_tx(): + return { + "to": DEPLOYMENT_PROXY, + "input": "0x" + (SALT + get_init_code()).hex(), + } + + +if __name__ == "__main__": + result = { + "image_url": get_image_url(), + "initcode": "0x" + get_init_code().hex(), + "deployment_tx": get_deployment_tx(), + } + print(json.dumps(result, indent=4)) From e6c0a33e898bd152e04978578396a8cfb5676984 Mon Sep 17 00:00:00 2001 From: "Julie B." Date: Thu, 11 Jun 2026 20:23:26 +0200 Subject: [PATCH 6/7] refer to EIP-7997 --- ERCS/erc-8270.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ERCS/erc-8270.md b/ERCS/erc-8270.md index 56db38153f8..ebd737301d6 100644 --- a/ERCS/erc-8270.md +++ b/ERCS/erc-8270.md @@ -22,7 +22,7 @@ In order to avoid fragmentation, there should be one standard wrapper contract. ## Specification -The Vyper source code for the wrapper is included in the ERC repository, along with a Python script generating deployment parameters. It can be deployed trustlessly using a well-known deterministic deployment proxy. The system has two smart contracts: a minimal withdrawal receiver contract which is deployed using a proxy for each NFT, and the main contract which implements the [ERC-721](./eip-721.md) interface as well as the metadata and enumeration extensions. +The Vyper source code for the wrapper is included in the ERC repository, along with a Python script generating deployment parameters. It can be deployed trustlessly to a predictable address using [EIP-7997](./eip-7997.md). The system has two smart contracts: a minimal withdrawal receiver contract which is deployed using a proxy for each NFT, and the main contract which implements the [ERC-721](./eip-721.md) interface as well as the metadata and enumeration extensions. The main contract allows anyone to mint a token associated with a specific validator public key. A proxy contract is immediately deployed to a unique withdrawal address. Holding the token confers full control over the withdrawal address and thus, once the validator is deployed, of its stake. More specifically, the main contract offers the following bespoke methods for the token holder to call: - `pullNativeBalance(tokenId, destination)` move the ether in the withdrawal address to the destination via a call. From 6030f1e20c9fc479b16bbab0435488d82525d1c2 Mon Sep 17 00:00:00 2001 From: "Julie B." Date: Sat, 20 Jun 2026 11:53:04 +0200 Subject: [PATCH 7/7] compute deployment address --- assets/erc-8270/prepare.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/assets/erc-8270/prepare.py b/assets/erc-8270/prepare.py index 14ca37590a8..248e284af55 100755 --- a/assets/erc-8270/prepare.py +++ b/assets/erc-8270/prepare.py @@ -4,6 +4,7 @@ # dependencies = [ # "vyper==0.4.3", # "eth-abi>=5.0.0", +# "eth-hash[pycryptodome]>=0.5.0", # "ipfs-cid>=1.0.0", # ] # /// @@ -16,12 +17,14 @@ from eth_abi import encode as abi_encode +from eth_hash.auto import keccak + from ipfs_cid import cid_sha256_hash CONTRACTS_DIR = Path(__file__).parent DEPLOYMENT_PROXY = "0x4e59b44847b379578588920cA78FbF26c0B4956C" -SALT = b"\x00" * 32 +SALT = bytes.fromhex("41694b7d9ee12ee32937e32be0f71231655c258fed7493bcc04a854abb253491") def _compile_vyper(contract_path: str) -> bytes: @@ -49,6 +52,17 @@ def get_init_code() -> bytes: return erc_code + abi_encode(["string", "bytes"], [image_url, wr_code]) +@functools.cache +def get_deployment_address() -> str: + preimage = ( + b"\xff" + + bytes.fromhex(DEPLOYMENT_PROXY[2:]) + + SALT + + keccak(get_init_code()) + ) + return "0x" + keccak(preimage)[12:].hex() + + @functools.cache def get_deployment_tx(): return { @@ -61,6 +75,8 @@ def get_deployment_tx(): result = { "image_url": get_image_url(), "initcode": "0x" + get_init_code().hex(), + "salt": "0x" + SALT.hex(), + "deployment_address": get_deployment_address(), "deployment_tx": get_deployment_tx(), } print(json.dumps(result, indent=4))