Try   HackMD

Schnorr Sigs as a Safe Module

safeSchnorr

Schnorr signatures come with big potential but limited support in the Ethereum ecosystem. Here's a reminder of the advantages they have in a multisignature setup:

  • one EVM address signer - even if your multisig is composed of 10 signers, on-chain that is represented by a single EVM address. You get privacy as on-chain nobody knows who the real signers are
  • gas efficiency - all the signatures are aggregated into one off-chain, allowing the user to send the final, aggregated one on-chain. Verification is handled by ecrecover (~3000 gas) + a bit of math computation beforehand, bringing the total gas cost to ~3300 gas.

Nevertheless, they are not so popular and probably the main reason for this is the lack of hardware support and the difficult off-chain setup you need to handle them.

Here, I'll demonstrate how to enable and use these schnorr signatures as a Safe module. Here' the github repository

TLDR

Here's a link to the solidity contract if you don't want to waste your time with words

Safe module

safe

If you're new to Safe, you have to know two things about it:

  • it's a Smart contract wallet
  • anyone is free to write modules for it and anyone that wants to use a module, can enable it for its account

We're going to tap in its execTransactionFromModule metod to execute transactions after the schnorr signature validation

On-chain Verification

This is the standard Schnorr signature verification, tapping into ecrecover:

// secp256k1 group order
uint256 internal constant Q = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
        
function ecrecoverSchnorr(
    bytes32 commitment,
    bytes calldata signature
) public pure returns (address) {
    // Based on https://hackmd.io/@nZ-twauPRISEa6G9zg3XRw/SyjJzSLt9
    // You can use this library to produce signatures: https://github.com/borislav-itskov/schnorrkel.js
    // px := public key x-coord
    // e := schnorr signature challenge
    // s := schnorr signature
    // parity := public key y-coord parity (27 or 28)
    // last uint8 is for the Ambire sig mode - it's ignored
    (bytes32 px, bytes32 e, bytes32 s, uint8 parity) = abi.decode(
        signature,
        (bytes32, bytes32, bytes32, uint8)
    );
    // ecrecover = (m, v, r, s);
    bytes32 sp = bytes32(Q - mulmod(uint256(s), uint256(px), Q));
    bytes32 ep = bytes32(Q - mulmod(uint256(e), uint256(px), Q));

    require(sp != bytes32(Q));
    // the ecrecover precompile implementation checks that the `r` and `s`
    // inputs are non-zero (in this case, `px` and `ep`), thus we don't need to
    // check if they're zero.
    address R = ecrecover(sp, parity, px, ep);
    require(R != address(0), "SV_ZERO_SIG");
    require(
        e == keccak256(abi.encodePacked(R, uint8(parity), px, commitment)),
        "SV_SCHNORR_FAILED"
    );
    return address(uint160(uint256(px)));
}

Mind you, this is not enough. This is generally good enough to verify schnorr signatures but there are a couple of things missing:

  • at the end, you're returned the schnorr EVM address. We have to check if this address is actually authorized to do actions on behalf of the safe
  • it is not replay protected

So we'll have to a few additional things in the module to assure all's good

Safe protection

When deploying the module, we'll set a few properties:

uint256 public nonce;

struct Call {
    address to;
    uint256 value;
    bytes data;
}

constructor(Safe _safe, address _signer) {
    signer = _signer;
    safe = _safe;
}

The signer is the schnorr EVM address with privileges to execute transactions from the safe.
And the safe address is well, the safe address this module will be enabled for.
So in our example, each Safe will have to deploy his safe schnorr module.
And finally, the safe will have to enable it through enableModule

We construct the execute function that will allow this to flourish:

function execute(Call[] calldata calls, bytes calldata signature) external {
    // prevent reEntry
    uint256 currentNonce = nonce;
    nonce = currentNonce + 1;

    // prevent replays by reconstructing the hash with
    // safe addr, module addr, chainId and nonce
    bytes32 commitment = keccak256(
        abi.encode(
            address(safe),
            address(this),
            block.chainid,
            currentNonce,
            calls
        )
    );

    address retrievedSigner = ecrecoverSchnorr(commitment, signature);
    require(retrievedSigner == signer, "INSUFFICIENT_PRIVILEGE");

    // execute the batch
    uint256 len = calls.length;
    for (uint256 i = 0; i < len; i++) {
        Call memory call = calls[i];
        safe.execTransactionFromModule(
            call.to,
            call.value,
            call.data,
            // forbid delegate calls, too unpredictable
            Enum.Operation.Call
        );
    }
}

Let's point out the basics:

  • we increment the nonce at the beginning to prevent re-entrancy attacks
  • we construct a commitment from: the safe addr, the module addr, the chain id, the current nonce and finally, the calls we're going to execute. This makes sure that the signature cannot be reused twice, either for this safe or another, neither for other chains
  • after performing the schnorr validation, we make sure the signer actually is the one set in the constructor (retrievedSigner == signer)

And that's it! What remains is the execution which we perform by calling safe.execTransactionFromModule for all the calls in the commitment. All of this is a single transaction so remember, if one call is invalid, all the calls will revert

Closing thoughts

This is how you could implement Schnorr in a Safe. What can you use it for? Currently, mainly for a backup in case something happens to the original signers.

Be wary that enabling a module in a Safe bypasses the original multisig setup. So proceed only if you know what you're doing.

You can use this library to craft the schnorr signatures