ETH MetaMask Signature Verification with Cadence

Overview

In this post, I’ll walk a dApp developer through the process of verifying MetaMask ETH signatures on-chain using Cadence. Specifically, this will enable an ETH user(the NFT claimer) to sign a message via MetaMask, which will then be verified in Cadence, allowing the claimer to claim a free Flow NFT.

For this demonstration, the developer introduces a resource called Item. This Item stores an NFT that can be claimed by a specified ETH address. To enable this claim, the developer will utilize an ETH signature verification process, ensuring only the rightful owner(the Claimer) can interact with the resource.

While the focus is on claiming NFTs, the underlying principles and techniques have broader applications. ETH signature verification opens doors to a range of on-chain operations, not limited to NFT claims.

One of the key advantages here is the use of smart contracts, allowing us to bypass the need for a backend by executing all operations directly on-chain.

Detailed Steps

Dependencies

To effectively follow this tutorial, the developer requires a few essential libraries and contracts:

  1. Flow Client Library (FCL): FCL is the JavaScript SDK for the Flow blockchain. It enables developers to build applications that interact with the Flow blockchain and its smart contracts.
  2. Ethers.js: A comprehensive ETH library that provides a collection of utilities to interact with the ETH blockchain and its ecosystem. We use this library for creating and signing messages.
  3. ETHUtils Contract: A Cadence contract that contains utility functions for ETH, like signature verification and ETH address derivation from public keys.

Constructing the Resource: The Item

First, the developer constructs the Item resource. The resource holds another nested resource and the ETH address of the claimer

pub resource Item {
        access(contract) var resource: @AnyResource?
        pub let claimer: String

        init(
            resource: @AnyResource,
            claimer: String
        ) {
            self.resource <- resource
            self.claimer = claimer
        }

			destroy () {
            pre {
                self.resource == nil : "Can't destroy underlying resource"
            }
            destroy <-self.resource
        }
}

Crafting the Signed Message

The message must be specific and detailed to mitigate security threats by the developer. For instance, by including the ETH and Flow destination addresses, we ensure t. he NFT is sent to the intended recipient.

pub struct SignedData {
        pub let claimerETHAddress: String
        pub let destination: Address
        pub let itemId: UInt64
        pub let expiration: UInt64 // unix timestamp

        init(claimerETHAddress: String, destination: Address, itemId: UInt64, expiration: UInt64){
            self.claimerETHAddress = claimerETHAddress
            self.destination = destination
            self.itemId = itemId
            self.expiration = expiration
        }

        pub fun toString(): String {
            return self.claimerETHAddress.concat(":").concat(self.destination.toString()).concat(":").concat(self.itemId.toString()).concat(":").concat(self.expiration.toString())
        }
 }

Note: Always design the message data to be as specific and readable as possible. This helps in ensuring the expected operations take place.

Using the struct above, a sample message might look like 0x97514895ee81704cb2cc6f08d65a90a420e9ff20:0xc7d7a362f02c543a:168987481:169237178

with the key data points being separated by a colon “:”…

(ETH Address):(Flow Destination Address):(Resource ID):(Expiration)

Verifying ETH Signatures

This step validates that the received signed message corresponds to the ETH address claiming the resource. With the help of the ETHUtils contract, the developer can verify the signature and ascertain the ETH address from the public key

access(self) fun verifyWithdrawSignature(publicKey: String, signature: String, data: SignedData): Bool {
            
            let message = data.toString()

            let isValid = ETHUtils.verifySignature(hexPublicKey: publicKey, hexSignature: signature, message: message)

            // Get ETH Public address from key
            let ethAddress = ETHUtils.getETHAddressFromPublicKey(hexPublicKey: publicKey)
            
            if ethAddress != self.claimer {
                return false
            }

            if(ethAddress != data.claimerETHAddress) {
                return false
            }

            return isValid
 }

Claiming the Resource

Claiming the resource involves verifying various conditions such as the resource’s existence, destination accuracy, signature validity, and expiration.

pub fun withdraw(receiver: Capability, publicKey: String, signature: String, data: SignedData) {
            pre {
                self.uuid == data.itemId : "invalid item being claimed"
                self.resource != nil : "No resource to withdraw"
                receiver.address == data.destination
                data.expiration >= UInt64(getCurrentBlock().timestamp): "expired signature"
                self.verifyWithdrawSignature(publicKey: publicKey, signature: signature, data: data): "invalid signature for data"
            }

            var claimableItem <- self.resource <- nil
            
            let cap = receiver.borrow<&AnyResource>()!
            
            if cap.isInstance(Type<@NonFungibleToken.Collection>()) {
                let target = receiver.borrow<&AnyResource{NonFungibleToken.CollectionPublic}>()!
                let token <- claimableItem  as! @NonFungibleToken.NFT?
                target.deposit(token: <- token!)
            } else if cap.isInstance(Type<@FungibleToken.Vault>()) {
                let target = receiver.borrow<&AnyResource{FungibleToken.Receiver}>()!
                let token <- claimableItem as! @FungibleToken.Vault?
                target.deposit(from: <- token!)
                return
            } else {
                panic("Unsupported type")
            }
 }

Consuming the Withdraw Method

With the withdraw method now in place, it can be called in any contract or transaction like so:

let receiver = acct.getCapability<&AnyResource{NonFungibleToken.CollectionPublic}>(ExampleNFT.CollectionPublicPath)
item.withdraw(receiver: receiver, publicKey: publicKey, signature: signature, data: data)

Client-side Signature Generation

In the client-side application, the developer will use the ethers.js library to generate a signature, compute a message hash, and extract the public key. This information can then be used to interact with the Cadence contract.

try {
	const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
    const provider = new ethers.providers.Web3Provider(window.ethereum, "any");

    // Send a request to access the user's Ethereum accounts
   	await provider.send("eth_requestAccounts", []);

    const signer = provider.getSigner();

    // Define a string message to be signed
    const user = await fcl.authenticate()
    const expiration = Math.floor(Date.now() / 1000) + 600
    const itemId = 2 // Your relevant id goes here
    const toSign = `${accounts[0]}:${user.addr}:${itemId}:${expiration}`

    const ethSig = await signer.signMessage(toSign);

    // Remove the '0x' prefix from the signature string
    const removedPrefix = ethSig.replace(/^0x/, '');

    // Construct the sigObj object that consists of the following parts
    let sigObj = {
   	 	r: removedPrefix.slice(0, 64),  // first 32 bytes of the signature
    	s: removedPrefix.slice(64, 128),  // next 32 bytes of the signature
    	recoveryParam: parseInt(removedPrefix.slice(128, 130), 16),  // the final byte (called v), used for recovering the public key
  	};

    // Combine the 'r' and 's' parts to form the full signature
    const signature = sigObj.r + sigObj.s;

    // Construct the Ethereum signed message, following Ethereum's \x19Ethereum Signed Message:\n<length of message><message> convention.
    // The purpose of this convention is to prevent the signed data from being a valid Ethereum transaction
    const ethMessageVersion = `\x19Ethereum Signed Message:\n${toSign.length}${toSign}`;

    // Compute the Keccak-256 hash of the message, which is used to recover the public key
  	const messageHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(ethMessageVersion));

    const pubKeyWithPrefix = ethers.utils.recoverPublicKey(messageHash, ethSig);

    // Remove the prefix of the recovered public key
    const publicKey = pubKeyWithPrefix.slice(4);

    // The pubKey, toSign, and signature can now be used to interact with Cadence

} catch (err) {
  	console.error(err);  // Log any errors
}

Tip: Step by step list of what is happening…

  1. Set up an ETH provider: Uses window.ethereum, which is provided by MetaMask or other ETH wallet extensions.
  2. Request account access: Asks the user to unlock their ETH wallet.
  3. Get a signer: A signer is an ETH account that can sign transactions and messages.
  4. Create a message to sign: For this example, the message is Hello Cadence World!.
  5. Sign the message: The signer signs the message.
  6. Clean up the signature: Removes the ‘0x’ prefix from the signature string.
  7. Create a signature object: Constructs an object with the signature components.
  8. Create the full signature: Combines the ‘r’ and ‘s’ components to form the full signature.
  9. Format the message: Follows ETH’s signed message convention to prevent the signed data from being a valid ETH transaction.
  10. Hash the message: Computes the Keccak-256 hash of the message, which will be used to recover the public key.
  11. Recover the public key: Uses the hashed message and the signature to recover the public key.
  12. Clean up the public key: Removes the prefix of the recovered public key.

Sample Gifting Product

I built a sample product/demo that allows senders to mint Flow NFTs on testnet and send them to ETH accounts. The claimer can then see the gifted NFTs and sign a message to claim them and send them to a designated Flow account. This is a basic gifting demo that prioritizes functionality over UX, but it still demonstrates the capability.

For those interested, here is the smart contract cadence code I utilized

Link to actual Sample Gifting Product Site

This is a foundational demonstration of the potential unlocked here. By establishing this link between Cadence and ETH accounts, a lot of opportunities arise. Examples include facilitating airdrops, enabling Flow accounts to “recognize” specific ETH accounts, or even allowing users to make purchases without the immediate need to set up a Flow account, among other possibilities.

A quick sneak peak of sending a Flow NFT to an ETH Address

mint_gif

Followed by an ETH user claiming a Flow NFT….

claim_gif

6 Likes

This is a huge unlock for the “check your wallet” moment for airdrops and how ETH wallets can interact on Flow natively.

Awesome to see!

this is awesome!

do you know if the same approach could be applied to bitcoin wallet signing?