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