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.
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.
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:
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.
abi.decode
is Unsuitable for Packed SignaturesA 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.
splitSignature
Helper FunctionTo 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:
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.
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")
.
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:
r
component: First 32 bytes.
s
component: Next 32 bytes.
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
:
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
:
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
:
This is a two-step process for the 1-byte v
value:
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.
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.
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)
.
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:
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.
To summarize the process of using a raw signature with a smart contract in a Foundry script:
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.
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.
Store Signature in Script: Copy the output signature and store it in a bytes private SIGNATURE = hex"..."
variable in your Solidity script.
Split the Signature: Call your splitSignature(SIGNATURE)
helper function to retrieve the individual v
, r
, and s
components.
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 July 10, 2025
Duration: 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 July 10, 2025