Building the Contracts

Build the contracts for your app.

Project Setup

Make a new folder for the project called zksync-webauthn and navigate to that folder in your terminal:

mkdir zksync-webauthn
cd zksync-webauthn

We can start by creating the contracts for the smart account, paymaster, and NFT.

npx zksync-cli create contracts --template hardhat_solidity --project contracts

The CLI will prompt you to enter a private key for deploying. Enter the private key below for a pre-configured rich wallet:

? Private key of the wallet responsible for deploying contracts (optional)
0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110

Once that is done, move into the contracts folder and delete the template contracts, scripts, and tests:

cd contracts
rm -rf ./contracts/*
rm -rf ./deploy/*
rm -rf ./test/*

Contracts

Open the zksync-webauthn/contracts folder in your preferred IDE.

We will be creating 4 contracts:

  • GeneralPaymaster.sol: a general paymaster contract to sponsor transactions.
  • MyNFT.sol: a generic NFT contract.
  • AAFactory.sol: a factory contract to deploy new instances of smart accounts.
  • Account.sol: the contract for the smart account.

Paymaster Contract

Create a new file in the contracts/contracts folder called GeneralPaymaster.sol.

touch contracts/GeneralPaymaster.sol

This is a basic paymaster contract that allows us to sponsor transactions for users of our app.

NFT Contract

Create another file in the contracts/contracts folder called MyNFT.sol.

touch contracts/MyNFT.sol

This is a generic NFT contract that mints svg images of Zeek using different colors. We will use this to test interacting with smart contracts using WebAuthn.

AA Factory

Create a file in the contracts/contracts folder called AAFactory.sol.

touch contracts/AAFactory.sol

This contract is a factory contract responsbile for deploying new instances of the Account.sol contract.

Smart Contract Account

Finally, create a file in the contracts/contracts folder called Account.sol.

touch contracts/Account.sol

The smart account contract is where we perform the WebAuthn signature validation.

Understanding WebAuthn Signature Validation

In the Account.sol contract, the _validateTransaction function checks to see if there is a valid secp256k1 signature from the account owner, just like the example in the Native AA Multisig tutorial. If the transaction is not signed by the standard account owner, the next check is to see if the transaction contains a valid WebAuthn signature.

contracts/Account.sol
    if (isValidSignature(txHash, _transaction.signature) == EIP1271_SUCCESS_RETURN_VALUE) {
      magic = ACCOUNT_VALIDATION_SUCCESS_MAGIC;
    } else {
      bool valid = validateWebAuthnSignature(_transaction.signature, txHash);
      if (valid) {
        magic = ACCOUNT_VALIDATION_SUCCESS_MAGIC;
      } else {
        magic = bytes4(0);
      }
    }

The contract contains a state variable r1Owner to store the public key of the WebAuthn account that the owner wants to authorize. The updateR1Owner function can update the value of the r1Owner.

contracts/Account.sol
  // the secp256r1 public key of the owner
  bytes public r1Owner;
contracts/Account.sol
  function updateR1Owner(bytes memory _r1Owner) external onlySelf {
    r1Owner = _r1Owner;
  }

To validate the signature, the _validateWebAuthnSignature function first extracts the authenticatorData, clientData, and rs values from the decoded signature.

It's important to note that the WebAuthn authentication process doesn't just sign the transaction data we send as a challenge. The WebAuthn authentication process returns three pieces of information that we need for the validation process: the WebAuthn signature, the authenticatorData, and the clientData.

The extractChallengeFromClientData function extracts the WebAuthn challenge from the clientData. Note that the extractChallengeFromClientData function relies on the specific implementation of how the challenge is created in the frontend section of the tutorial in order to work.

The extracted challenge and the transaction hash are checked to ensure they are equal. If the values are equal, this means that the exact transaction data that is submitted to the contract matches the transaction data signed by the WebAuthn account.

Next, the _validateWebAuthnSignature function checks for the malleability of the signature, and checks to see if the WebAuthn user flags are set. We will ensure that the signature conforms to these checks in the frontend section.

Finally, it calls the callVerifier function to call the P256Verify precompile.

contracts/Account.sol
  function _validateWebAuthnSignature(
    bytes32 txHash,
    bytes memory webauthnSignature,
    bytes32[2] memory pubKey
  ) public view returns (bool valid) {
    (bytes memory authenticatorData, bytes memory clientData, bytes32[2] memory rs) = _decodeWebAuthnSignature(
      webauthnSignature
    );

    // challenge is a base64url
    string memory challenge = extractChallengeFromClientData(clientData);
    string memory encodedHash = txHashToBase64Url(txHash);

    if (keccak256(abi.encodePacked(challenge)) != keccak256(abi.encodePacked(encodedHash))) {
      return false;
    }

    // malleability check
    if (rs[1] > lowSmax) {
      return false;
    }

    // check if the webauthn user flags are set
    if (authenticatorData[32] & AUTH_DATA_MASK != AUTH_DATA_MASK) {
        return false;
    }

    bytes32 clientDataHash = sha256(clientData);
    bytes32 message = sha256(bytes.concat(authenticatorData, clientDataHash));

    valid = callVerifier(message, rs, pubKey);
  }

The callVerifier function encodes the input into the correct format and calls the P256Verify precompile.

If the returned output is empty, the precompile validation evaluated to false. This means that the public key derived from the WebAuthn signature does not match the registered r1Owner public key.

The output data should be 1 (in 32 bytes format) if the signature verification process succeeds, or nothing if it fails.

contracts/Account.sol
  function callVerifier(bytes32 hash, bytes32[2] memory rs, bytes32[2] memory pubKey) internal view returns (bool) {
    /**
     * Prepare the input format
     * input[  0: 32] = signed data hash
     * input[ 32: 64] = signature r
     * input[ 64: 96] = signature s
     * input[ 96:128] = public key x
     * input[128:160] = public key y
     */
    bytes memory input = abi.encodePacked(hash, rs[0], rs[1], pubKey[0], pubKey[1]);

    (bool __, bytes memory output) = P256.staticcall(input);

    // the precompiled contract does not return a false value
    if (output.length == 0) {
      return false;
    }

    return abi.decode(output, (bool));
  }

Deploying

Using a Local Node

Open a new terminal and start a local in-memory node with era_test_node:

era_test_node run

Next, replace your hardhat.config.ts file with the file below:

Adding the Deploy Script

Create a new file inside the deploy folder called deploy.ts:

touch deploy/deploy.ts

Copy and paste the code below.

This script will:

  • deploy the AAFactory contract
  • deploy the NFT contract and mint an NFT
  • deploy the paymaster contract and send funds to it from the pre-configured rich account.

Running the Deploy Script

Finally, compile and deploy the contracts with:

npm run compile
npm run deploy

Save the output of this command, as we will use these deployed contract addresses in the frontend.

That's all for the contracts! In the next section, we will build the frontend.


Made with ❤️ by the ZKsync Community