Part 2: Technical Overview

Deposits and Withdrawals

The Deposit Contract

  • The deposit contract is the protocol's entry point for staking.
  • Anybody may permissionlessly stake 32 ETH via the contract.
  • On receiving a valid deposit the contract emits a receipt.
  • An incremental Merkle tree maintains a Merkle root of all deposits.
  • The deposit contract cannot verify a deposit's BLS signature.
  • The balance of the deposit contract never decreases.
  • Ether sent to the deposit contract should be considered burned.

Overview

The deposit contract is the means by which stakers commit their Ether to the protocol in order to gain the right to run a validator.

The source code for the contract is available in the specs repo, and the verified byte code is deployed on-chain.

Functionality

The deposit contract is a normal Ethereum smart contract running on the execution (Eth1) layer. Anyone wishing to place a stake in order to run a validator may send 32 ETH to the deposit contract via a normal Ethereum transaction.

In addition to the Ether transferred, the deposit transaction must contain further data as follows.

First, the public key of the validator. A validator's public key is derived from its secret signing key, and is its primary identity on the consensus layer. The staker will provide the secret signing key separately to the consensus client for normal operational use.

Second, withdrawal credentials specifying which Ethereum account rewards earned will be sent to. This will also be the address that receives the validator's full balance when it eventually exits. Withdrawal credentials come in two forms, which we will discuss later.

Third, a signature over the public key, the withdrawal credentials, and the deposit amount, using the normal signing key. This signature's main role is to serve as a "proof of possession" of the secret key of the validator, which side-steps a nasty rogue public key attack.

Fourth, the deposit data root, which is an SSZ Merkleization of all of the above data that serves as a kind of checksum that the contract can verify.

The deposit contract does some verification on these parameters. In particular, the deposit amount is subject to checks, and the deposit data root is verified. If either of these fails then the deposit will be rejected - that is, the deposit transaction will be reverted.

However, the deposit contract does not validate the signature - the EVM does not yet have the elliptic curve apparatus to do this, and it would be prohibitively expensive to do in normal bytecode. The signature will be validated later by the consensus layer, and if found to be incorrect (for new validators) the deposit will fail, and the Ether will be lost.

Once the deposit contract is as satisfied as it can be that the deposit is valid, it issues a receipt (an EVM log event) containing the deposit data. This receipt will later be picked up by the consensus layer for processing.

Development

The original deposit contract was written in Vyper, a Python-like smart contract language. Work on the contract code began in January 2018, some months before the beacon chain was conceived of: it is one of the very few carry-overs from earlier Ethereum proof of stake designs. The pre-beacon chain version, however, omitted all the of the Merkle tree apparatus as it was not required1. Using the incremental (also called progressive) Merkle tree was suggested by Vitalik in January 2019.

Around April 2020, work began to rewrite the deposit contract in Solidity, a more mainstream smart contract language. The stated reason in the new repo is the following, which relates to formally verifying the contract.

The original motivation was to run the SMTChecker and the new Yul IR generator option (--ir) in the compiler.

Runtime Verification's verification work cites "community concerns about the [then] current Vyper compiler" as the motivation for the rewrite. These concerns are captured in Suhabe Bugrara's initial review of the Vyper contract, and discussed in the Ethereum Foundation's blog entry.

The deployed deposit contract was compiled from Solidity source code.

Verification work

Since Ethereum contracts are immutable once deployed, it was crucial that the deposit contract be correct: its balance would come to be a large fraction of all Ether. To this end, various analyses and formal verification activities were performed.

In June 2020 Runtime Verification performed a formal verification covering two aspects.

  1. Verification that the incremental Merkle tree algorithm is equivalent to a full Merkle tree construction.
  2. Verification that the bytecode was correctly generated from the Solidity source code, using the KEVM verifier.

Just prior to the deployment of the contract, Franck Cassez of Consensys performed some further work as described in his paper, Verification of the Incremental Merkle Tree Algorithm with Dafny, and GitHub repository. This goes further than Runtime Verification's work by fully mechanically verifying the incremental Merkle tree algorithm, using the Dafny formal verification language.

Deployment

The deposit contract was deployed on October the 14th, 2020, at 09:22:52 UTC to Ethereum address 0x00000000219ab540356cbb839cbe05303d7705fa.

The deploying account was presumably generated by grinding for a key whose first transaction would deploy the contract to an address with the distinctive eight zero prefix: Ethereum contract addresses are computed from the deployer's account address and nonce value. This would have taken on the order of 2322^{32} (4.3 billion) key generation attempts.

Ignoring spam, there are only three transactions associated with the deploying account.

Code

The following exposition is based on the Solidity source code as verified on Etherscan, which ought to match the source code in the consensus specs repository.

For brevity, I've omitted the interface boilerplate and some lengthy comments.

DepositContract
contract DepositContract is IDepositContract, ERC165 {
    uint constant DEPOSIT_CONTRACT_TREE_DEPTH = 32;
    // NOTE: this also ensures `deposit_count` will fit into 64-bits
    uint constant MAX_DEPOSIT_COUNT = 2**DEPOSIT_CONTRACT_TREE_DEPTH - 1;

    bytes32[DEPOSIT_CONTRACT_TREE_DEPTH] branch;
    uint256 deposit_count;

    bytes32[DEPOSIT_CONTRACT_TREE_DEPTH] zero_hashes;

    constructor() public {
        // Compute hashes in empty sparse Merkle tree
        for (uint height = 0; height < DEPOSIT_CONTRACT_TREE_DEPTH - 1; height++)
            zero_hashes[height + 1] = sha256(abi.encodePacked(zero_hashes[height], zero_hashes[height]));
    }

After declaring its interfaces – we will look at ERC165 below – comes constants and storage.

The DEPOSIT_CONTRACT_TREE_DEPTH specifies the number of levels in the internal Merkle tree. With a depth of 32, it can have 2322^{32} leaves, allowing for up to 4.3 billion deposits (MAX_DEPOSIT_COUNT2)3. A deposit is a minimum of one ETH, so there's sufficient space for every ETH in existence to be deposited 35 times over.

The underlying data structure of the deposit contract is an incremental Merkle tree. This is a Merkle tree that supports only two operations, (1) appending a leaf, and (2) calculating the root. Constraining the data like this allows us to avoid storing the entire Merkle tree, which would be huge. Instead the contract stores only the last branch – a mere 32 nodes – which is all the information that's needed to calculate the Merkle root.

To gain this efficiency, we need an array of zero_hashes. At any given level of the tree, the zero hash is the value the node would have if all of the leaves under it were zero. Since we assign leaves sequentially, huge parts of the tree can be represented by the zero hashes.

The constructor() (which takes no arguments) only initialises the zero_hashes structure, taking advantage of the EVM's default that the uninitialised zero_hashes[0] storage value will be zero.

Diagram showing how the zero_hashes array is constructed.

To construct ZnZ_n, we start with Z0=0Z_0 = 0 and define Zi+1=Hash(Zi,Zi)Z_{i+1} = \text{Hash}(Z_i, Z_i).

get_deposit_root
    function get_deposit_root() override external view returns (bytes32) {
        bytes32 node;
        uint size = deposit_count;
        for (uint height = 0; height < DEPOSIT_CONTRACT_TREE_DEPTH; height++) {
            if ((size & 1) == 1)
                node = sha256(abi.encodePacked(branch[height], node));
            else
                node = sha256(abi.encodePacked(node, zero_hashes[height]));
            size /= 2;
        }
        return sha256(abi.encodePacked(
            node,
            to_little_endian_64(uint64(deposit_count)),
            bytes24(0)
        ));
    }

Calculating the deposit root on demand saves us from having to use a storage slot to save it in. Local execution of view functions is free, while writing to blockchain state is very expensive.

The algorithm works as follows. In a binary Merkle tree, a node is either a left child or a right child.

  • If a node is a left child (size & 1 == 0), we know its sibling must be a zero hash, since the tree is incremental.
  • If a node is a right child, we take its sibling from branch. Thus, the important elements of branch are those that store the left-child nodes for the current value of deposit_count.

In effect, we are using the zero_hashes, ZnZ_n, and the branch values, BnB_n, to summarise large parts of the tree. ZnZ_n is the root of a subtree whose 2n2^n leaves are all zero, with Z0=0Z_0 = 0. BnB_n is the root of a subtree, all of whose 2n2^n leaves were previously assigned, with B0B_0 being the last left-leaf that was inserted. By the time we reach the root, we will have effectively included all the leaves in the calculation.

This being an incremental Merkle tree, we know that the value of the leaf at deposit_count is zero: the count is zero-based, so leaf deposit_count has not yet been assigned; it will be the next leaf to be assigned.

To calculate the parent node, we hash together the value of its left and right children. Solidity's abi.encodePacked() function is used to concatenate the 32 bytes of each sibling.

Note that we don't use any of the BnB_n for n>log2in > \log_2 i, where ii is deposit count - any nodes we visit higher than this will be left nodes only. We make use of this when updating branch after a new deposit.

Toy example

The beauty of the incremental Merkle tree is that we can calculate a root for a tree with up to NN leaves by maintaining just log2N\log_2 N values in storage, plus the deposit_count, with a further log2N\log_2 N constants.

A diagram illustrating how the root of an incremental Merkle tree is calculated.

Finding the root of a three-level incremental Merkle tree. Five leaves have been assigned, v0v_0 to v4v_4, although we don't store their values. In the algorithm, node visits the dashed nodes from the leaf at the top to the root at the bottom. The BnB_n are branch values maintained by deposit(), and the ZnZ_n are the pre-computed zero_hashes.

The diagram shows an incremental Merkle tree with three levels. We have filled up five of the leaves with values v0v_0 to v4v_4, but the only things that we actually store are the three BnB_n values of branch, and the three ZnZ_n values of zero_hashes. At each level nn we will use either BnB_n or ZnZ_n to calculate the parent.

The deposit_count is 5, so we start with node at the leaf labelled "5", which we know will be zero since it has not yet been assigned. This is a right child, therefore we combine it with B0B_0 as its left sibling. We know that B0B_0 will be equal to the last leaf value inserted, v4v_4. (If it were a left child, we would combine it with Z0=0Z_0 = 0.)

Moving to level 1, node is now a left child, so we combine it with the level 1 zero hash, Z1Z_1. We know that all the leaves descended from that Z1Z_1 node are zero.

On level 2, node is again a right child, so we combine it with our stored value of B2B_2 to obtain the value at the root of the tree.

Why do we need the deposit root?

As we shall see later, each staking node separately maintains its own Merkle tree of deposits, independently of the deposit contract, which it builds using the deposit receipts from the execution layer. Why, then, do we need to put all this complex apparatus into the deposit contract in order to calculate the root?

Using the deposit root provides a self-contained way to verify that the deposit data in a block is correct. In the early stages of Eth2, it was not at all clear that all beacon chain nodes would be connected to Eth1 clients. In fact, pre-Merge, it was perfectly fine for a non-staking node not to be connected to an Eth1 client. Those nodes needed some way to be able to reject blocks with fake deposits. Putting the evidence on-chain via a Merkle proof allowed them to do so.

By means of the voting process described below, validators periodically import a deposit root from the contract onto the beacon chain. When a proposer includes deposits in its block, it must add a proof that the deposits are included in that deposit root. This allows every node that processes the chain to verify every deposit without having to consult the Eth1 chain.

Interestingly, post-Merge, all nodes (whether running validators or not) are required to comprise both consensus and execution clients, and execution payloads are included in beacon blocks. Therefore, nowadays, the data we need to validate deposits is on-chain as a matter of course, and we no longer strictly need to mess about with all this deposit root stuff. In fact, EIP-6110 proposes to explicitly expose validator deposits on-chain, after which the deposit contract code for maintaining the root will be redundant. Although, being immutable, it will continue to exist forever.

get_deposit_count
    function get_deposit_count() override external view returns (bytes memory) {
        return to_little_endian_64(uint64(deposit_count));
    }

The only wrinkle here is the endianness transformation. The consensus layer uses little-endian format to serialise integers, whereas the EVM uses big-endian. The consensus layer calls this function to find out about new deposits, so it's convenient to get the output in the right format.

deposit
    function deposit(
        bytes calldata pubkey,
        bytes calldata withdrawal_credentials,
        bytes calldata signature,
        bytes32 deposit_data_root
    ) override external payable {
        // Extended ABI length checks since dynamic types are used.
        require(pubkey.length == 48, "DepositContract: invalid pubkey length");
        require(withdrawal_credentials.length == 32, "DepositContract: invalid withdrawal_credentials length");
        require(signature.length == 96, "DepositContract: invalid signature length");

        // Check deposit amount
        require(msg.value >= 1 ether, "DepositContract: deposit value too low");
        require(msg.value % 1 gwei == 0, "DepositContract: deposit value not multiple of gwei");
        uint deposit_amount = msg.value / 1 gwei;
        require(deposit_amount <= type(uint64).max, "DepositContract: deposit value too high");

This is the business part of the contract - where stakers' deposits are made.

A deposit comprises the following items.

  • The public key of the validator: pubkey is the 48 byte (compressed) BLS public key derived from the staker's secret signing key.
  • The withdrawal credentials: withdrawal_credentials is 32 bytes of either 0x00 BLS credentials or 0x01 Eth1 credentials. Apart from their length, the withdrawal credentials are not validated anywhere in the contract, or even on the consensus layer.
  • The signature is a 96 Byte BLS signature. It is generated by signing the hash tree root of a DepositMessage object (public_key, withdrawal_credentials, and deposit_amount), with the validator's signing key.
  • The deposit_data_root is basically a form of checksum. See below for how it is verified.
  • Finally, a msg.value. The message value is the amount of Ether (denominated in Wei, which are 101810^{-18} ETH) that was sent with the transaction. This will normally be 32 ETH for a new validator, but can be more or less. It must be,
    • at least one ETH,
    • a whole number of ETH, and
    • less than 2642^{64} Gwei4, which is 18.4 Billion ETH.

The very last condition is formally to avoid overflowing a consensus layer uint64, but seems kind of redundant in practice.

        // Emit `DepositEvent` log
        bytes memory amount = to_little_endian_64(uint64(deposit_amount));
        emit DepositEvent(
            pubkey,
            withdrawal_credentials,
            amount,
            signature,
            to_little_endian_64(uint64(deposit_count))
        );

The contract now emits an event log (receipt). These receipts are how information about new deposits is picked up by the consensus layer. It looks a bit weird to emit the log before finishing all the checks (we have a couple more requires to pass yet), but if the transaction reverts, the beacon chain will also revert the event log, so no real harm is done emitting it early.

See below for more detail on the receipt.

        // Compute deposit data root (`DepositData` hash tree root)
        bytes32 pubkey_root = sha256(abi.encodePacked(pubkey, bytes16(0)));
        bytes32 signature_root = sha256(abi.encodePacked(
            sha256(abi.encodePacked(signature[:64])),
            sha256(abi.encodePacked(signature[64:], bytes32(0)))
        ));
        bytes32 node = sha256(abi.encodePacked(
            sha256(abi.encodePacked(pubkey_root, withdrawal_credentials)),
            sha256(abi.encodePacked(amount, bytes24(0), signature_root))
        ));

        // Verify computed and expected deposit data roots match
        require(node == deposit_data_root, "DepositContract: reconstructed DepositData does not match supplied deposit_data_root");

Here we have a "by hand" implementation of the hash tree root for a consensus layer DepositData object.

class DepositData(Container):
    pubkey: BLSPubkey
    withdrawal_credentials: Bytes32
    amount: Gwei
    signature: BLSSignature  # Signing over DepositMessage

Using the same style as in the Merkleization chapter, we can illustrate the process in the following diagram. With a bit of head-scratching it's not too difficult to map it onto the mess of sha256 calls in the code.

A diagram showing how the hash tree root of a DepositData object is calculated from its members.

Each box is a 32 byte chunk, possibly padded with zeros (in the cases of S(Pubkey)2S{(Pubkey)}_2 and S(Amount)S(Amount)). Merkleization is the process of finding the hash tree root by iteratively hashing together pairs of chunks, in the form of binary trees, until the root is reached.

The only reason for doing this here is as a kind of checksum. The staker provides deposit_data_root, which is their independent calculation of the deposit root from the input data. The contract recalculates it to ensure that it matches the supplied data.

The deposit_data_root is the quantity (node) that will be inserted as a new leaf in the Merkle tree and forms part of the verification of a deposit on the consensus layer.

        // Avoid overflowing the Merkle tree (and prevent edge case in computing `branch`)
        require(deposit_count < MAX_DEPOSIT_COUNT, "DepositContract: merkle tree full");

        // Add deposit data root to Merkle tree (update a single `branch` node)
        deposit_count += 1;
        uint size = deposit_count;
        for (uint height = 0; height < DEPOSIT_CONTRACT_TREE_DEPTH; height++) {
            if ((size & 1) == 1) {
                branch[height] = node;
                return;
            }
            node = sha256(abi.encodePacked(branch[height], node));
            size /= 2;
        }
        // As the loop should always end prematurely with the `return` statement,
        // this code should be unreachable. We assert `false` just to be safe.
        assert(false);
    }

Finally, we must update the Merkle tree.

A very cool feature of the incremental Merkle tree is that, not only can we continuously maintain its root by maintaining only the 32 values in branch, but when we insert a new leaf value, we need to update only a single value of branch.

This is not obvious, but we can deduce it as follows. For a more formal explanation and analysis, see Franck Cassez's paper, Verification of the Incremental Merkle Tree Algorithm with Dafny.

Consider paths from adjacent leaves to the root: path PP from leaf j1j - 1, and path QQ from leaf jj, where jj is deposit_count. Beyond some level ii, paths PP and QQ will converge and visit the same nodes. At level ii, path PP will visit a left node, having visited only right nodes previously, and path QQ will visit a right node, having visited only left nodes previously5.

In short,

  • Path PP will comprise (i1)(i-1) right nodes, followed by a left node, followed by some tail shared with QQ.
  • Path QQ will comprise (i1)(i-1) left nodes, followed by a right node, followed by some tail shared with PP.

Path QQ is that path that will be used in the get_deposit_root() algorithm.

Now, by construction, the BnB_n (the branch values) always represent left nodes in the tree, and are needed only when path QQ visits a right node.

For levels greater than or equal to ii – where paths PP and QQ coincide – either the nodes visited are left nodes, in which case the BnB_n value is irrelevant, or they are right nodes, in which case the BnB_n value is unchanged since it is calculated from a sub tree whose leaves have not changed. So, no update is required to BnB_n for n>in > i.

As for levels n<in < i, all the QnQ_n are left nodes, and thus the BnB_n are irrelevant. Therefore the sole BnB_n that needs to be updated is BiB_i. The intuition is that, due to the way binary increments work, every time we need a new BnB_n, it have been be updated by the previous insertion, "just in time".

A diagram showing how branch is updated when a new leaf is appended.

We've just inserted a leaf at position jj. Next time get_deposit_root() is called, it will traverse path QQ from j+1j+1, having previously traversed PP from jj . The paths converge at height i+1i + 1. For n<in < i path QQ is entirely left nodes, so BnB_n is irrelevant. For n>in > i, BnB_n is either unchanged or irrelevant. So we need only to updated BiB_i to BiB_i'.

supportsInterface
    function supportsInterface(bytes4 interfaceId) override external pure returns (bool) {
        return interfaceId == type(ERC165).interfaceId || interfaceId == type(IDepositContract).interfaceId;
    }

This is standard code based on ERC-165 that allows calling applications to detect programmatically whether the contract supports a function interface based on the given function selector, interfaceID.

For example, according to the Ethereum ABI, the function selector for get_deposit_count() is 0x621fd130. Therefore, calling supportsInterface(0x621fd130) will return true.

I don't know of any reason for implementing this for the deposit contract, but I suppose it's considered good practice to do so.

to_little_endian_64
    function to_little_endian_64(uint64 value) internal pure returns (bytes memory ret) {
        ret = new bytes(8);
        bytes8 bytesValue = bytes8(value);
        // Byteswapping during copying to bytes.
        ret[0] = bytesValue[7];
        ret[1] = bytesValue[6];
        ret[2] = bytesValue[5];
        ret[3] = bytesValue[4];
        ret[4] = bytesValue[3];
        ret[5] = bytesValue[2];
        ret[6] = bytesValue[1];
        ret[7] = bytesValue[0];
    }

This is used in get_deposit_root(), get_deposit_count() and when emitting the DepositEvent log. All of these will be consumed by the consensus layer, which uses little-endian encoding for SSZ integers.

}

And we're done.

Deposit Receipts

For every deposit accepted by the deposit contract it issues a receipt (also called a log or event6), which is generated via an EVM LOG1 opcode.

The receipt has a single topic, which is the DepositEvent signature: 0x649bbc62d0e31342afea4e5cd82d4049e7e1ee912fc0889aa790803be39038c5, equal to keccak256("DepositEvent(bytes,bytes,bytes,bytes,bytes)").

The receipt's data is the 576 byte ABI encoding of pubkey, withdrawal_credentials, amount, signature, and deposit_count, converted to little-endian where required. Here's an example.

Example receipt data

The first column is the hexadecimal byte position of the start of the data in the second column.

# Pointer to pubkey: 0x0a0
000  00000000000000000000000000000000000000000000000000000000000000a0

# Pointer to withdrawal_credentials: 0x100
020  0000000000000000000000000000000000000000000000000000000000000100

# Pointer to amount: 0x140
040  0000000000000000000000000000000000000000000000000000000000000140

# Pointer to signature: 0x180
060  0000000000000000000000000000000000000000000000000000000000000180

# Pointer to deposit_count: 0x200
080  0000000000000000000000000000000000000000000000000000000000000200

# Length of pubkey: 48 bytes
0a0  0000000000000000000000000000000000000000000000000000000000000030

# Pubkey data, padded with 16 zero bytes
0c0  b73fe99acbf91f0032ae95c3ed0d663ea246d02332373e101ff5c7ed520ce098
0e0  652de3eab056a9889bb3d05d734be21400000000000000000000000000000000

# Length of withdrawal_credentials: 32 bytes
100  0000000000000000000000000000000000000000000000000000000000000020

# Withdrawal credentials (0x01 type)
120  010000000000000000000000e637a2acbc531531700fcb7d2ed7e6d96ed8bbe8

# Length of amount: 8 bytes
140  0000000000000000000000000000000000000000000000000000000000000008

# Amount, little-endian encoded. 0x0773594000 = 32,000,000,000
160  0040597307000000000000000000000000000000000000000000000000000000

# Length of signature: 96 bytes
180  0000000000000000000000000000000000000000000000000000000000000060

# Signature data
1a0  b4a7e1546b13be69d31849b4302d870a04867b9de73a973794f8be88c25dc71f
1c0  c3440141c33cf3fbf2dea328179c89550f4e19cad118dd962b07a7c40a3aa8ac
1e0  eaded660edb6e030df48074ddfbe70b26d0e9db1c3be28afc0b47096aab7a616

# Length of deposit_count: 8 bytes
200  0000000000000000000000000000000000000000000000000000000000000008

# Deposit count, little-endian. 0x0a7b1a = 686,874
220  1a7b0a0000000000000000000000000000000000000000000000000000000000

A consensus client can request these receipts from its attached execution client via the standard eth_getLogs RPC method, filtering by the deposit contract address, block numbers, and event topic. This is how the consensus layer becomes aware of the details of new deposits.

The use of event logs here is an optimisation. The deposit contract could instead store all the Merkle tree's leaves and make them available via an eth_call method. However, since logs are not stored in the chain's state, only in block history, it is much cheaper to use them than it would be to store the leaves in the contract's state. However, this places a constraint on the amount of history we must keep around - we cannot now discard block history from before the deployment of the deposit contract. A newly activated consensus client needs access to the full receipt history in order to rebuild its internal view of the Merkle tree, even if it is able to checkpoint sync its beacon state. For convenience, some clients now support starting from a deposit snapshot of the Merkle tree that can be shared with other clients in much the same way as checkpoint states. This allows aggressive pruning of block history for those who want to do that.


  1. With EIP-6110 we might end up going back in that direction, with the Merkle root no longer needed.
  2. Regarding the comment on MAX_DEPOSIT_COUNT, it will of course fit into 32 bits, which is definitely less than 64. The point is that uints on the consensus layer are standardised at a size of 64 bits, and we don't want to overflow that.
  3. With the proposed mechanism in EIP-6110 for deposit handling, we would no longer need the Merkle proofs and could in principle lift this limit. However, it is immutably encoded into the deposit contract, so would not be possible in practice.
  4. A Gwei is 10910^9 Wei, or 10910^{-9} ETH, and is the unit of account on the consensus layer.
  5. If it's not clear from thinking about paths, then consider binary numbers. If j1j-1 is 0111, then jj is 1000. The zeros represent left nodes, and the ones represent right nodes.
  6. Naming of these things is really messed up. I believe that Eth1 logs, events, and receipts are all the same thing. Etherscan hedges its bets by calling them "Transaction Receipt Event Logs".

Created by Ben Edgington. Licensed under CC BY-SA 4.0. Published 2023-09-29 14:16 UTC. Commit ebfcf50.