5/5
## Splitting a Concatenated Signature (r, s, v) in Solidity When working with cryptographic signatures in Web3, particularly within Solidity scripts for frameworks like Foundry, you'll often encounter signatures as a single, raw, concatenated hexadecimal string. This string typically represents the `r`, `s`, and `v` components of an ECDSA signature packed together. However, many smart contract functions, especially those designed for EIP-712 typed data verification or general signature recovery (e.g., `ecrecover`), require these `v`, `r`, and `s` values as separate arguments. This lesson details how to take such a raw byte string and efficiently split it into its constituent parts directly within your Solidity script. ## Storing the Raw Signature in Your Solidity Script The first step is to incorporate the raw signature into your script. If you've generated a signature using a tool like `cast wallet sign` (e.g., `cast wallet sign --no-hash <hashed_message> --private-key <your_private_key>`), you'll receive a hexadecimal string. This signature can be stored in a `bytes` variable within your Solidity script using the `hex` literal notation: ```solidity // Example signature: 0xfb2270e6f23fb5fe924848c0f4be8a4e9b077c3ad0b1333cc60b5debc511602a2a06c24085d807c830bad8baedc536 bytes private SIGNATURE = hex"fb2270e6f23fb5fe924848c0f4be8a4e9b077c3ad0b1333cc60b5debc511602a2a06c24085d807c830bad8baedc536"; ``` **Key Points:** * **`hex"..."` Literal:** The `hex` keyword allows you to define byte literals directly from a hexadecimal string. Notice that the `0x` prefix, commonly seen in hexadecimal representations, is omitted when using this literal form. * **`private` Visibility:** Declaring the variable as `private` (e.g., `bytes private SIGNATURE`) restricts its accessibility, preventing inheriting contracts or other scripts from directly accessing it if such access is not intended. This promotes encapsulation. ## Why `abi.decode` is Unsuitable for Packed Signatures A common question is whether `abi.decode` can be used to parse the raw signature. For instance, one might intuitively try `abi.decode(SIGNATURE, (uint8, bytes32, bytes32))`. However, this approach will not work for typical concatenated signatures. The reason lies in how these signatures are usually formed. They are generally the result of a direct concatenation, akin to `abi.encodePacked(r, s, v)`. `abi.encodePacked` concatenates the data directly without including any length or offset information for the encoded elements. In contrast, `abi.decode` is designed to work with data encoded using `abi.encode`, which includes metadata necessary to parse dynamically sized types or multiple elements. Since the raw signature lacks this metadata, `abi.decode` cannot correctly interpret its structure. ## Implementing the `splitSignature` Helper Function To correctly parse the concatenated signature, we implement a dedicated helper function, `splitSignature`. This function will take the packed `bytes` signature as input and return the individual `v`, `r`, and `s` components. First, let's look at how this function would be called within your main script logic, for example, when claiming an airdrop: ```solidity // Assuming CLAIMING_ADDRESS, CLAIMING_AMOUNT, and proof are defined elsewhere // And MerkleAirdrop(airdrop).claim(...) is the target function (uint8 v, bytes32 r, bytes32 s) = splitSignature(SIGNATURE); MerkleAirdrop(airdrop).claim(CLAIMING_ADDRESS, CLAIMING_AMOUNT, proof, v, r, s); ``` Now, let's define the `splitSignature` function itself. It's good practice to include a custom error for invalid input, which is more gas-efficient than `require` statements with string messages since Solidity 0.8.4. ```solidity // Define a custom error at the contract or script level error __MyScriptName_InvalidSignatureLength(); // Use a script-specific name /** * @notice Splits a 65-byte concatenated signature (r, s, v) into its components. * @param sig The concatenated signature as bytes. * @return v The recovery identifier (1 byte). * @return r The r value of the signature (32 bytes). * @return s The s value of the signature (32 bytes). */ function splitSignature(bytes memory sig) public pure returns (uint8 v, bytes32 r, bytes32 s) { // Standard ECDSA signatures are 65 bytes long: // r (32 bytes) + s (32 bytes) + v (1 byte) if (sig.length != 65) { revert __MyScriptName_InvalidSignatureLength(); } // Accessing bytes data in assembly requires careful memory management. // `sig` in assembly points to the length of the byte array. // The actual data starts 32 bytes after this pointer. assembly { // Load the first 32 bytes (r) r := mload(add(sig, 0x20)) // 0x20 is 32 in hexadecimal // Load the next 32 bytes (s) s := mload(add(sig, 0x40)) // 0x40 is 64 in hexadecimal // Load the last byte (v) // v is the first byte of the 32-byte word starting at offset 96 (0x60) v := byte(0, mload(add(sig, 0x60))) // 0x60 is 96 in hexadecimal } // Note: Further adjustment to 'v' might be needed depending on the signing library/scheme (see section below). } ``` **Function Characteristics:** * **`pure`:** The `splitSignature` function is declared `pure` because it neither reads from nor modifies the contract's state. It operates solely on its input parameters. * **Custom Error:** Using `revert __MyScriptName_InvalidSignatureLength()` is generally more gas-efficient for error handling compared to `require(condition, "error string")`. ## Deep Dive: How the Assembly Code Splits the Signature The core of the `splitSignature` function lies in its assembly block, which allows for precise low-level memory manipulation. Understanding this block is key to grasping how the signature is parsed. **Signature Structure (Packed Bytes):** A standard 65-byte ECDSA signature, as typically concatenated, is structured as follows: 1. **`r` component:** First 32 bytes. 2. **`s` component:** Next 32 bytes. 3. **`v` component:** Final 1 byte. **Assembly Operations Explained:** * **Memory Layout of `bytes memory sig`:** When a `bytes memory` variable like `sig` is passed to an assembly block, the `sig` variable itself holds a pointer to the *length* of the byte array. The actual byte data begins 32 bytes (0x20 bytes) *after* this pointer. * `add(sig, 0x20)`: This expression calculates the memory address of the first byte of the actual signature data. `0x20` is hexadecimal for 32. * **Loading `r`:** ```assembly r := mload(add(sig, 0x20)) ``` The `mload` opcode loads 32 bytes from the specified memory address. Here, it loads the first 32 bytes of the signature data (which correspond to the `r` value) from `sig + 0x20` and assigns them to the `r` return variable. * **Loading `s`:** ```assembly s := mload(add(sig, 0x40)) ``` This loads 32 bytes starting from the memory address `sig + 0x40`. `0x40` is hexadecimal for 64. This address effectively points to `start_of_data + 32_bytes_for_r`. Thus, it loads the 32 bytes representing the `s` value and assigns them to the `s` return variable. * **Loading `v`:** ```assembly v := byte(0, mload(add(sig, 0x60))) ``` This is a two-step process for the 1-byte `v` value: 1. `mload(add(sig, 0x60))`: `0x60` is hexadecimal for 96. This address points to `start_of_data + 32_bytes_for_r + 32_bytes_for_s`. `mload` reads a full 32-byte word from this location. The `v` byte is the first byte within this 32-byte word. 2. `byte(0, ...)`: The `byte` opcode extracts a single byte from a 32-byte word. `byte(N, word)` extracts the Nth byte (0-indexed from the most significant byte on the left). Since `v` is the first (and only relevant) byte in the loaded word, `byte(0, ...)` isolates it and assigns it to the `uint8 v` return variable. ## Understanding the Order of v, r, and s Components It's important to distinguish between how the signature components are packed and how they are conventionally used in function arguments: * **Packed Signature Order (e.g., in `SIGNATURE` bytes variable):** `r` (32 bytes), `s` (32 bytes), `v` (1 byte). This is the order assumed by the assembly code when reading from the `sig` byte array. * **Function Arguments/Return Values Convention:** The common convention for Solidity function arguments and return values (as seen in OpenZeppelin's ECDSA library and many contract interfaces that handle signatures) is `v, r, s`. The `splitSignature` function adheres to this by returning the components in the order `(uint8 v, bytes32 r, bytes32 s)`. ## Crucial Considerations for the 'v' Value The `v` (recovery identifier) value can sometimes require adjustment depending on the signing library used and the specific Ethereum Improvement Proposals (EIPs) in effect. * **Historical Context:** Originally, and in Bitcoin, `v` values were typically 27 or 28. Ethereum also used these values before EIP-155. * **EIP-155:** With EIP-155 (transaction replay protection on different chains), `v` values became chain-specific: `chain_id * 2 + 35` or `chain_id * 2 + 36`. * **Modern Libraries:** Some modern signing libraries or tools might return `v` as 0 or 1. In such cases, to make it compatible with `ecrecover` (which often expects 27 or 28 for non-EIP-155 signatures, or the EIP-155 compliant value), you might need to add 27 to the `v` value: ```solidity // if (v < 27) { // v = v + 27; // } ``` While the `splitSignature` function presented earlier doesn't include this adjustment, it's a critical point to be aware of. If signature verification fails, an incorrect `v` value is a common culprit. You may need to add this conditional adjustment based on the source of your signatures and the requirements of the contract function you're interacting with. ## Workflow Recap: From Raw Signature to Smart Contract Call To summarize the process of using a raw signature with a smart contract in a Foundry script: 1. **Obtain Message Hash:** If you are signing a structured message (EIP-712) or a specific piece of data, first obtain the hash that needs to be signed. This might involve calling a contract function (e.g., via `cast call`) that prepares the hash. 2. **Sign the Message:** Use a wallet or tool like `cast wallet sign` to sign the hash. If you are providing an already hashed message to `cast wallet sign`, use the `--no-hash` flag: `cast wallet sign --no-hash <message_hash_hex> --private-key <your_private_key>` This will output the raw, concatenated signature as a hexadecimal string. 3. **Store Signature in Script:** Copy the output signature and store it in a `bytes private SIGNATURE = hex"..."` variable in your Solidity script. 4. **Split the Signature:** Call your `splitSignature(SIGNATURE)` helper function to retrieve the individual `v`, `r`, and `s` components. 5. **Utilize Components:** Pass the separated `v`, `r`, and `s` values (along with any other required parameters) to the target smart contract function that expects them for verification or other operations. This methodical approach provides a robust and gas-efficient way to handle raw, concatenated signatures and prepare them for smart contract interactions directly within your Solidity and Foundry development workflow.
A detailed guide to Splitting Concatenated Signatures in Solidity - Master parsing raw 65-byte ECDSA signatures into v, r, s using a Solidity function with inline assembly for low-level control. Explore `splitSignature` mechanics, memory layout, and critical 'v' value adjustments.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on May 20, 2025
Solidity Developer
Advanced FoundryDuration: 36min
Duration: 3h 06min
Duration: 5h 02min
Duration: 6h 02min
Duration: 2h 47min
Duration: 1h 23min
Duration: 4h 28min
Duration: 1h 19min
Duration: 1h 10min
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on May 20, 2025