Mastering Safe Transactions: A Developer's Guide to Signing with EOAs and WAGMI

Introduction

In the realm of Ethereum, the act of signing transactions is a cornerstone operation.

However, when it comes to multi-signature wallets like Gnosis Safe, the process becomes intricate and demands a deeper understanding of various signature types and methods.

This article aims to provide an introductory guide on how to sign a Gnosis Safe transaction using an Externally Owned Account (EOA).

We'll delve into hashing the transaction via the getTransactionHash method. Adhering to Safe signature schema be adding 4 to the v of an ECDSA signature, and using the WAGMI and viem signMessage hook with the raw parameter.

Gnosis Safe Signature Types

Gnosis Safe supports a variety of signature types, each with a constant length of 65 bytes. The last byte indicates the signature type. Here are some of the signature types Gnosis Safe supports:

  1. ECDSA Signature: The most common type, consisting of r, s, and v values.

  2. EIP-712 Signature: A structured data hashing and signing standard.

  3. Contract Signature via EIP-1271: Allows smart contracts to verify signatures.

For more details, you can refer to the official documentation.

ECDSA Signatures

In ECDSA signatures, the r, s, and v values are essential for recovering the signer. The v value is particularly interesting because it MUST be increased by 4 to calculate the signature when using eth_sign.

The constant part of an ECDSA signature is {32-bytes r}{32-bytes s}{1-byte v}.

EIP-712 Signatures

EIP-712 is a standard for hashing and signing of typed structured data. This signature type is useful when you want to present the user with readable information about what they are signing.

Contract Signatures via EIP-1271

This signature type allows a smart contract to verify a signature. The signature verifier is the padded address of the contract that implements the EIP-1271 interface. The dynamic part consists of {32-bytes signature length}{bytes signature data}.

The getTransactionHashMethod

The getTransactionHashMethod is a function within the Gnosis Safe contract that returns the transaction hash to be signed by the owners. Here's the code snippet:

function getTransactionHash(
    address to,
    uint256 value,
    bytes calldata data,
    Enum.Operation operation,
    uint256 safeTxGas,
    uint256 baseGas,
    uint256 gasPrice,
    address gasToken,
    address refundReceiver,
    uint256 _nonce
) public view returns (bytes32) {
    return keccak256(encodeTransactionData(to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, _nonce));
}

Deep Dive: Adding 4 to the “v” Value

Why?

In Ethereum, the v value in an ECDSA signature is usually 27 or 28.

However, Gnosis Safe allows for more complex signature types, and to accommodate these, the v value MUST be increased by 4. When using eth_sign, as the default v values (27, 28) are adjusted, but we can’t submit that signature without first mutating it.

It’s mentioned in the Safe documentation, but with no links to any example.

And also in this StackExchange answer.

Solidity Verification Logic

In the Safe contract, the signature verification logic accommodates this bit shift.

else if (v > 30) {
    // If v > 30 then default va (27,28) has been adjusted for eth_sign flow
    currentOwner = ecrecover(keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)), v - 4, r, s);
}

Mutating the Signature via JS

The signHash method available in the safe-global/safe-contracts repo demonstrates how to increase the v value of an EOA signature by searching for 1b (27) and 1f (28) hexadecimal values and replacing them with 1f (31) and 20 (32).

export const signHash = async (signer: Signer, hash: string): Promise<SafeSignature> => {
    const typedDataHash = ethers.getBytes(hash);
    const signerAddress = await signer.getAddress();
    return {
        signer: signerAddress,
        data: (
            await signer.signMessage(typedDataHash))
            .replace(/1b$/, "1f").replace(/1c$/, "20"
        ),
    };
};

After the signatures have been mutated to adhere to the Safe signature schema they must be re-ordered from lowest to highest i.e. as if the addresses were uint256 values.

export const buildSignatureBytes = (signatures: SafeSignature[]): string => {
    const SIGNATURE_LENGTH_BYTES = 65;
    signatures.sort((left, right) => left.signer.toLowerCase().localeCompare(right.signer.toLowerCase()));

    let signatureBytes = "0x";
    let dynamicBytes = "";
    for (const sig of signatures) {
        if (sig.dynamic) {
            /* 
                A contract signature has a static part of 65 bytes and the dynamic part that needs to be appended 
                at the end of signature bytes.
                The signature format is
                Signature type == 0
                Constant part: 65 bytes
                {32-bytes signature verifier}{32-bytes dynamic data position}{1-byte signature type}
                Dynamic part (solidity bytes): 32 bytes + signature data length
                {32-bytes signature length}{bytes signature data}
            */
            const dynamicPartPosition = (signatures.length * SIGNATURE_LENGTH_BYTES + dynamicBytes.length / 2)
                .toString(16)
                .padStart(64, "0");
            const dynamicPartLength = (sig.data.slice(2).length / 2).toString(16).padStart(64, "0");
            const staticSignature = `${sig.signer.slice(2).padStart(64, "0")}${dynamicPartPosition}00`;
            const dynamicPartWithLength = `${dynamicPartLength}${sig.data.slice(2)}`;

            signatureBytes += staticSignature;
            dynamicBytes += dynamicPartWithLength;
        } else {
            signatureBytes += sig.data.slice(2);
        }
    }

    return signatureBytes + dynamicBytes;
};

Why do the signatures have to be ordered?

That’s a great question!

Because in the checkNSignatures method of a SafeProxy requires it. And this is required to prevent the same owner from passing multiple signatures.

require(currentOwner > lastOwner && owners[currentOwner] != address(0) && currentOwner != SENTINEL_OWNERS, "GS026");
            lastOwner = currentOwner;

The currentOwner > lastOwner comparator means the signatures have to be in sequential order because the recovered address is compared to the previously verified signature as if it were a uint256.

Using viem's signMessage Hook

The viem signMessage hook calculates an Ethereum-specific signature in EIP-191 format. It takes an account and a message as parameters and returns the signed message. The message can be passed as a string or with a raw attribute for data representation.

Example Usage with raw Parameter

const signature = await walletClient.signMessage({
  account,
  message: { raw: '0x68656c6c6f20776f726c64' },
});

A full example of signing a hex string and increasing the v value so it adheres to the Safe signature schema.

import { useWalletClient } from "wagmi";

const { data: walletClient } = useWalletClient();

const [signature, setSignature] = React.useState<{
	signed: boolean;
	signature: `0x${string}`;
}>({
	signed: false,
	signature: "0x",
});

const handleSignTransaction = async () => {
	const signMessageData = await walletClient?.signMessage?.({
		message: {
			raw: transactionHash.data as `0x${string}`,
		},
	});
	if (!signMessageData) return;
	const bitShiftedSig = signMessageData
		.replace(/1b$/, "1f")
		.replace(/1c$/, "20");
	setSignature({
		signed: true,
		signature: bitShiftedSig as `0x${string}`,
	});
};

Executing the Signed Gnosis Safe Transaction

After you've successfully signed a Gnosis Safe transaction, the next crucial step is to execute it. This involves sending the signed transaction to the Gnosis Safe contract for execution. Below, we'll explore how to do this using React hooks and Ethereum smart contract interactions.

The following snippets assumed you have generated React hooks using the WAGMI CLI and references to the Safe contracts.

Something like this:

import { defineConfig } from "@wagmi/cli";
import { foundry, react } from "@wagmi/cli/plugins";

export default defineConfig({
  out: "src/blockchain.ts",
  plugins: [
    react(),
    foundry({
      project: "../contracts/safe-contracts",
      include: [
        "Safe.json",
        "SafeProxy.json",
        "SafeProxyFactory.json",
        "MultiSend.json",
        "WalletFactory.json",
      ],
    }),
  ],
});

Preparing the Transaction

Before executing the transaction, you need to prepare it by gathering all the necessary parameters. Here's how you can do it:

  • Fetch the Safe Nonce: The nonce is a counter that ensures each transaction is unique. You can use the useSafeNonce hook to get the current nonce for the Safe.
const nonce = useSafeNonce({
    address: safeAddress,
    enabled: true,
});

Calculate the Transaction Hash: Use the useSafeGetTransactionHash hook to calculate the transaction hash based on the parameters.

const transactionHash = useSafeGetTransactionHash({
    address: safeAddress,
    args: [
        safeAddress, // to
        BigInt(0), // value
        '0x', // data
        0, // operation
        BigInt(0), // safeTxGas
        BigInt(0), // baseGas
        BigInt(0), // gasPrice
        constants.AddressZero, // gasToken
        constants.AddressZero, // refundReceiver
        nonce.data as bigint, // nonce
    ],
});

Preparing the Execution Configuration

Once you have the transaction hash and the signature, you can prepare the execution configuration using the usePrepareSafeExecTransaction hook.

const { config } = usePrepareSafeExecTransaction({
    address: safeAddress,
    args: [
        safeAddress, // to
        BigInt(0), // value
        '0x', // data
        0, // operation
        BigInt(0), // safeTxGas
        BigInt(0), // baseGas
        BigInt(0), // gasPrice
        constants.AddressZero, // gasToken
        constants.AddressZero, // refundReceiver
        signature.signature, // signatures
    ],
    enabled: signature.signed,
});

Executing the Transaction

Finally, you can execute the transaction using the write method from the useContractWrite hook. This will send the transaction to the Ethereum network for execution.

const _safe = useContractWrite(config);

const handleSubmitTransaction = () => {
    _safe?.write?.();
};

To execute the transaction, simply call the handleSubmitTransaction function. This will trigger the write method, sending the transaction to the Gnosis Safe contract for execution.

Conclusion

Signing a Gnosis Safe transaction with an EOA involves a series of steps that require a deep understanding of Ethereum signatures and the Gnosis Safe contract.

This guide has aimed to provide a comprehensive understanding of how to sign a transaction, from hashing it using the getTransactionHashMethod to signing it using the viem signMessage hook and finally submitting it to the Gnosis Safe contract.

By following these steps, developers can ensure that their transactions are not only secure but also conform to the standards set by Gnosis Safe and the Ethereum ecosystem at large.

Happy coding!

Subscribe to Kames Geraghty
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.