I’m working on a ICO claimTokens function that uses ECDSA signature verification, and I’ve run into a strange issue: everything works correctly when the contract owner calls the claim and tokens goes to MetaMask, but when any other account trying to submits, the call reverts with an “Invalid signature” error.
I’m testing this on Sepolia testnet, not mainnet!
Below are the relevant snippets:
Solidity contract
pragma solidity ^0.8.20;
function claimTokens(uint256 amount, bytes memory signature) external nonReentrant {
require(!paused, "Paused");
require(balanceOf(address(this)) >= amount, "Not enough tokens in contract");
bytes32 message = keccak256(abi.encodePacked(msg.sender, amount));
bytes32 ethSignedMessage = message.toEthSignedMessageHash();
emit DebugHash(message);
require(ethSignedMessage.recover(signature) == signer, "Invalid signature");
claimedAmounts[msg.sender] += amount;
_transfer(address(this), msg.sender, amount);
emit TokensClaimed(msg.sender, amount);
}
Front taking user address from MetaMask
<script src="https://cdn.jsdelivr.net/npm/ethers@5.7.2/dist/ethers.umd.min.js"></script>
<label for="amountInput">How many tokens do you want?</label>
<input type="number" id="amountInput" placeholder="Enter amount (e.g. 1)" min="1" step="1" />
<button id="claimButton">Claim tokens</button>
<div id="status"></div>
<script>
async function claimTokens() {
const statusDiv = document.getElementById('status');
statusDiv.textContent = '';
// Connect and get the address from MetaMask
let provider, signer, userAddress;
try {
provider = new ethers.providers.Web3Provider(window.ethereum);
await provider.send("eth_requestAccounts", []); // MetaMask prompt
signer = provider.getSigner();
userAddress = await signer.getAddress();
} catch (e) {
statusDiv.textContent = 'Error: user denied account access.';
return;
}
// Display exactly who is connected and which step we're at
statusDiv.textContent =
'Connected as: ' + userAddress + '\n' +
'Sending request to PHP for signature...';
// Call PHP and send only the token count (tokenCount), not WEI
// (PHP will calculate amountWei = tokenCount * 10^18 before signing)
const phpUrl =
'https:mysite.com/claim.php'
+ '?address=' + encodeURIComponent(userAddress)
+ '&amount=' + encodeURIComponent(tokenCount);
console.log("-> Fetching from PHP:", phpUrl);
let signature, phpData;
try {
const res = await fetch(phpUrl);
phpData = await res.json();
console.log(">>> PHP returned:", phpData);
if (!phpData.signature) {
throw new Error(phpData.message || 'PHP did not return signature');
}
signature = phpData.signature;
} catch (e) {
statusDiv.textContent = 'Error obtaining signature: ' + e.message;
return;
}
// Show what PHP actually signed for that address
statusDiv.textContent =
'PHP signed for: ' + userAddress + '\n' +
'Signature: ' + signature + '\n' +
'Sending transaction to blockchain...';
// Prepare the smart contract call
const contractAddress = "0x..."; // Contract address
const abi = [
"function claimTokens(uint256 amount, bytes signature) external"
];
const contract = new ethers.Contract(contractAddress, abi, signer);
// Convert tokenCount to Wei: tokenCount * 10^18
let amountWei;
try {
// Instead of ethers.BigNumber.from(...).mul(...), we use parseUnits
amountWei = ethers.utils.parseUnits(tokenCount, 18);
console.log("-> Converted to Wei:", amountWei.toString());
} catch (e) {
statusDiv.textContent = 'Error converting tokenCount to Wei.';
return;
}
// Calling the claimTokens function
try {
const tx = await contract.claimTokens(amountWei, signature);
statusDiv.textContent = 'Waiting for transaction confirmation...';
await tx.wait();
statusDiv.textContent = `✅ You have successfully claimed ${tokenCount} tokens!`;
} catch (err) {
console.error(err);
const reason = (err.error && err.error.message) || err.message;
statusDiv.textContent = '❌ Claim failed: ' + reason;
}
}
document.getElementById('claimButton').addEventListener('click', claimTokens);
</script>
I also have a PHP endpoint that can generate the signature server-side.
require_once __DIR__ . '/vendor/autoload.php';
use kornrunner\Keccak;
use Elliptic\EC;
try {
// 1) Retrieve and validate GET parameters
if (!isset($_GET['address'], $_GET['amount'])) {
throw new \Exception('Missing GET parameters.');
}
$address = trim($_GET['address']);
$amountStr = trim($_GET['amount']);
if (!preg_match('/^0x[a-f0-9]{40}$/i', $address) || !ctype_digit($amountStr)) {
throw new \Exception('Invalid address or amount.');
}
// 2) Convert tokenCount to Wei (decimal string)
$amountWei = bcmul($amountStr, bcpow('10', '18')); // e.g. "10000000000000000000000"
// 3) Helper function: decimal string → hex (big endian)
function bcdechex($dec) {
$hex = '';
while (bccomp($dec, 0) > 0) {
$mod = bcmod($dec, 16);
$hex = dechex($mod) . $hex;
$dec = bcdiv($dec, 16, 0);
}
return $hex === '' ? '0' : $hex;
}
// 4) Form binary arrays for abi.encodePacked(address, amountWei)
$addrBin = hex2bin(substr(strtolower($address), 2));
$amtHex = str_pad(bcdechex($amountWei), 64, '0', STR_PAD_LEFT);
$amtBin = hex2bin($amtHex);
if ($addrBin === false || $amtBin === false) {
throw new \Exception('Error during hex2bin conversion.');
}
$data = $addrBin . $amtBin; // 52 bytes
// 5) Calculate rawHash = keccak256(data)
$rawHashHex = Keccak::hash($data, 256); // 64-character hex
// 6) Sign rawHashHex (hex string) with the private key
$ec = new EC('secp256k1');
$key = $ec->keyFromPrivate('...'); // private key hidden
$sig = $key->sign($rawHashHex, ['canonical' => true]);
// 7) Assemble signature r, s, v
$r = str_pad($sig->r->toString(16), 64, '0', STR_PAD_LEFT);
$s = str_pad($sig->s->toString(16), 64, '0', STR_PAD_LEFT);
$v = dechex($sig->recoveryParam + 27);
$sigHex = '0x' . $r . $s . $v;
// 8) Return JSON response
echo json_encode([
'status' => 'success',
'signature' => $sigHex
], JSON_PRETTY_PRINT);
} catch (\Throwable $e) {
http_response_code(400);
echo json_encode([
'status' => 'error',
'message' => $e->getMessage()
], JSON_PRETTY_PRINT);
exit;
}