EN
EN ZH

The consolev69

Secure way of verifying NFT ownership using signatures and PHP

Primary tabs

I saw a numerous requests on stackoverflow and openzeppelin forums of people asking how to verify NFT ownership server-side, but very few answers. Because extensions like Metamask are user-side and we can never rely on whether the data user sends to the server is valid or not, we need to figure out a way how to verify things server-side.

To be fair, the same / similar scripts (just a bit more complicated) are run on this website. Yes, we run PHP, not a popular choice but also the language which is used the most, so these scripts will run on any cheap hosting you can buy out there.

Web3.js scripts have their own ports to the many languages but the workflow would be the same I think.

Workflow:

1. Using Web3.js lib we will let user connect their wallet to our website.
2. User will generate their wallet signature, this is all done user-side
3. A signature is then sent to server-side script via AJAX
4. Server-side script will translate the signature back to the wallet, thus verifying the sender is the wallet itself (hacking this would take an ages)
4. PHP will then connect to fork of the chain (Infura, Alchemy etc.) and verify if the wallet owns any of the NFTs from our collection

So it is pretty simple, let's go!

User-side part of the script

    <!-- web3 and web3modal packages -->
    <script type="text/javascript" src="https://unpkg.com/web3@1.2.11/dist/web3.min.js"></script>
    <script type="text/javascript" src="https://unpkg.com/web3modal@1.9.0/dist/index.js"></script>
    <script type="text/javascript" src="https://unpkg.com/@walletconnect/web3-provider@1.2.1/dist/umd/index.min.js"></script>
    <!-- web3 functionality code -->

    <script type="module">
    
    function getData(t) {
        return $.ajax({
            url: t,
            type: "GET"
        })
    }
    
    const Web3Modal = window.Web3Modal.default;
    const WalletConnectProvider = window.WalletConnectProvider.default;
    let web3, web3Modal, userAddress;

    function init() {
        
        $("#verify").hide();

        console.log("Initializing...");
        console.log("WalletConnectProvider is", WalletConnectProvider);

        // Check that the web page is run in https (MetaMask won't be available)
        if (location.protocol !== 'https:') {
            document.querySelector("#btn-connect").setAttribute("disabled", "disabled")
            return;
        }

        const providerOptions = {
            walletconnect: {
                package: WalletConnectProvider
            },
        };

        web3Modal = new Web3Modal({
            cacheProvider: false,
            providerOptions,
            disableInjectedProvider: false,
        });

    }
    
    async function onConnect() {

        let provider;
        console.log("Opening Web3modal", web3Modal);

        try {
            provider = await web3Modal.connect();
        } catch (e) {
            alert("Could not get a wallet connection");
            return;
        }

        web3 = new Web3(provider);
        const accounts = await web3.eth.getAccounts();

        userAddress = accounts[0];

        const chainId = await web3.eth.getChainId();
        
        if (chainId == 1) {
            console.log('connected');
            
            $("#connect").hide();
            $("#verify").show();
            

        } else {
            
            alert('Please change network to Ethereum Mainnet');
            
        }

    }
    
    async function onVerify() {
    
        var web3 = new Web3(window.ethereum);
            
        const accounts = await web3.eth.getAccounts();
        
        <?php
// we can make a fun there with messages but they are not really necessary. It does not matter what the input contains, backend will translate the message to the wallet anyway.
            $messages = array(
                "Kindly please unlock this article, thank you",
                "Let me in!!",
                "Web3 let's go!",
                "Knock knock, who's there?",
                "Do I really need to sign this?",
                "Sudo this article",
                "Abrakadabra"
            );
            $random_message = $messages[array_rand($messages, 1)];
        ?>
        
        const msgtext = JSON.stringify({ message: "<?php echo $random_message; ?>", nid: <?php echo $nidArticle; ?>, timestamp: Math.floor(Date.now() / 1000) });
        const message = web3.utils.fromUtf8(msgtext);
        
        $(".status").show();
        $(".status").html('Unlocking in progress...<div class="lds-ring"><div></div><div></div><div></div><div></div></div>');
        
        const signature = await web3.eth.personal.sign(message, accounts[0]); // This line generates the personal signature which we send using AJAX to backend
        
        console.log("access_control?message=" + msgtext + "&signature=" + signature); // remember, we need to send message alongside with the signature in one request, both of those values combined will backwards generate the wallet of the signer
        
        // check signature validity on backend
        async function getContentData() {
            try {
                const res = await getData("access_control?message=" + msgtext + "&signature=" + signature); // Basicly GET request to backend, it would be better to use the POST request here but it does not really matter
                return (res);
            } catch (err) {
                return (err);
            }
        }
        
        let getAccessData = await getContentData();
        
        let output = JSON.parse(getAccessData); // In this part we should have the JSON of the result, no matter if the verification failed or not
        
        console.log(output);
        
        if (typeof output.body !== 'undefined') { // If the verification fails, the output does not have "body" key in JSON, but this all depends on the format of the data you expect from backend
            
            $(".article-detail .views-field-body").html(output.body); // Now we know the data is there and we can show them to user.
            
        }
        
        
        try {
            
            
            
        } catch (e) {
            
            console.log(e.message);
            
        }
        
        
    }
    
    init();
    $('#btn-connect').bind('click', function() {
      onConnect();
      return false;
    });
    $('#btn-verify').bind('click', function() {
      onVerify();
      return false;
    });
    
    </script>

 

Server-side part of the script (PHP, enjoy):

1. We can use Composer to donwload all the necessary packages we will work with, use the json file below:

{
    "name": "wekisen/php-ecrecover",
    "description": "PHP ECRecover Example",
    "keywords": ["ecrecover"],
    "license": "MIT",
    "authors": [
        {
            "name": "Wekisen",
            "homepage": "https://github.com/Wekisen/php-ecrecover"
        }
    ],
    "require": {
        "php": ">=7.1.0",
        "kornrunner/keccak": "^1.0",
        "ext-gmp": "*",
        "web3p/web3.php": "^0.1.6"
    },
    "config": {
        "platform-check": false
    }
}

If you never worked with the Composer before, it is easy and very similar like NPM in nodejs. You can just copy-pase the code above to "composer.json" file in your working directory, head to the directory with terminal / powershell and run "composer install" command. If you happen not to have Composer installed at all, install it globally via their documentation here.

Why do we need Composer? Well, not really, you can probably download those packages by hand manually and connect them together _somehow_, it is entirely possible, this is just faster and provides autoload script for you to access all the classes from the modules we have just downloaded.

Anyway, create index.php file in your working directory, or the file you wish to use to send AJAX to, naming does not really matter.

Now the following code will verify the ownership, I will write the breakdown of the code in the comments below:


<?php

    use Web3\Contract;

    $output = array(); // We will create an array of values which we will use to generate json at the end
    
    if(isset($_GET["message"]) && isset($_GET["signature"])) { // we check if the message and signature is present in the GET request
        
        require_once 'vendor/autoload.php'; // This will autoload all the classes we will need further below
        require_once './ecrecover_helper.php'; // Handy library to recover wallet address from signature
        
        $message = $_GET["message"];
        $signature = $_GET["signature"];
        
        $address = personal_ecRecover($message, $signature); // I guess the most important line is there, this recovers the address

            // check requirements  
            $number_of_upgrades = 0;
            $continue = false;
            
            // Check for Upgrade ownership
            $abi = file_get_contents("ABI/cyber_upgrades.json"); // You need ABI of the contract. You can get this on etherscan and just copy-paste it to json file like that.
            $contractAddress = "0xE5a6575a0DAA9ef683Fab5583E234C4A6698bF05"; // This is our Cyber Upgrades contract address
            
            $contract = new Contract('https://mainnet.infura.io/v3/<your infura API key>', $abi);
            
            // call contract function, specifically balanceOf function. But you can call any function you wish
            $contract->at($contractAddress)->call("balanceOf", $address, function ($err, $result) use (&$number_of_upgrades) {
                
                if ($err !== null) {
                    throw $err;
                }
                if ($result) {
                    $number_of_upgrades = $result[0]->value; // number of cyber upgrades
                }
                
            });
            
            if ($number_of_upgrades > 0) {
                $continue = true;
            }
            else {
                $output["err_02"] = "Number of upgrades not met";
            }
               
                
            if ($continue == true) {
                
                $output["body"] =  $array["body"]; // Of course I did not put the whole script here like we do it but you get the point, here you can just print the content which should be locked behind the ownership
                
            }
            else {
                
                $output["error"] = "Requirements for unlocking not met";
                
            }
            
        
        print json_encode($output);
        
        
    }
    
?>

This should be safe and kind-of unhackable. I mean, to break the signature and send the signature for other person you would need insane amount of power and current computers can not do that (but, this might one day change with quantum computers but let's hope this day is far, far away).

The same process can be replicated in any language you pick, I just used the library to recover the wallet but the process is well documented online, so you can do it without the library easily.

Thanks for reading and happy coding!

Difficulty: 
Requirements: 
Locked: 
Unlocked