index.html
1 <!DOCTYPE html> 2 <html lang="en"> 3 4 <head> 5 <meta charset="UTF-8"> 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 <script src="https://cdn.jsdelivr.net/npm/web3@4.6.0/dist/web3.min.js" 8 integrity="sha512-DTSUnB4owPx74KyQnBL+XPSCI8elZpjR0dnHmSnHkmTirIzI/9ANYxuDZ87TVsQ1E+8rQB/V4EAaUfKN+h42+w==" 9 crossorigin="anonymous"></script> 10 11 12 <title>Fluence Token Proof Generator</title> 13 <style> 14 html { 15 width: 100%; 16 height: 100%; 17 } 18 19 body { 20 font-family: Arial, sans-serif; 21 margin: 0; 22 padding: 0; 23 background-color: #f0f0f0; 24 width: 100%; 25 height: 100%; 26 } 27 28 .outer { 29 width: 100%; 30 height: 100%; 31 justify-content: center; 32 align-items: center; 33 text-align: center; 34 display: flex; 35 } 36 37 .container { 38 margin: auto; 39 } 40 41 input[type="text"] { 42 padding: 10px; 43 width: 320px; 44 border-radius: 5px; 45 border: 1px solid #ccc; 46 font-size: 16px; 47 margin-bottom: 10px; 48 text-align: center; 49 box-sizing: border-box; 50 } 51 52 input[type="submit"] { 53 padding: 10px 20px; 54 background-color: #4CAF50; 55 color: white; 56 border: none; 57 border-radius: 5px; 58 cursor: pointer; 59 font-size: 16px; 60 text-align: center; 61 width: 320px; 62 box-sizing: border-box; 63 } 64 65 input[type="submit"]:hover { 66 background-color: #45a049; 67 } 68 69 .progress-container { 70 width: 320px; 71 margin: 20px auto; 72 border: 1px solid gray; 73 border-radius: 5px; 74 } 75 76 .progress-bar { 77 width: 0; 78 height: 20px; 79 background-color: #4CAF50; 80 border-radius: 5px; 81 transition: width 0.3s ease; 82 } 83 84 .overlay { 85 position: fixed; 86 top: 0; 87 left: 0; 88 width: 100%; 89 height: 100%; 90 background-color: rgba(0, 0, 0, 0.5); 91 justify-content: center; 92 align-items: center; 93 z-index: 999; 94 overflow-y: auto; 95 } 96 97 .popup { 98 background-color: white; 99 padding: 20px; 100 border-radius: 5px; 101 box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); 102 width: 50%; 103 margin: 10px auto; 104 } 105 106 .code-container { 107 border: 1px solid #ccc; 108 border-radius: 10px; 109 margin-top: 1em; 110 margin-bottom: 1em; 111 } 112 113 .right-button { 114 text-align: right; 115 padding-right: 5px; 116 padding-bottom: 5px; 117 } 118 119 .right-button button { 120 padding: 10px 20px; 121 background-color: #4CAF50; 122 color: white; 123 border: none; 124 border-radius: 5px; 125 cursor: pointer; 126 font-size: 16px; 127 text-align: center; 128 } 129 130 .right-button button:hover { 131 background-color: #45a049; 132 133 } 134 135 .right-button button.close { 136 background-color: red; 137 } 138 139 .right-button button.close:hover { 140 background-color: #aa0000; 141 } 142 143 code { 144 word-wrap: break-word; 145 white-space: normal; 146 } 147 148 .popup input[type=text] { 149 box-sizing: border-box; 150 } 151 </style> 152 </head> 153 154 <body> 155 <div class="outer"> 156 <div class="container"> 157 <form action="#"> 158 <input type="text" id="username" placeholder="GitHub username"> 159 <br> 160 <input type="submit" id="submit" value="Check Eligibility"> 161 </form> 162 <div class="progress-container" style="display: none;"> 163 <div class="progress-bar" id="progressBar"></div> 164 </div> 165 <script> 166 let data = null; 167 const web3 = new Web3(); 168 169 const copyCode = e => { 170 var codeElement = e.target.parentElement.parentElement.querySelector('code'); 171 var range = document.createRange(); 172 range.selectNode(codeElement); 173 window.getSelection().removeAllRanges(); 174 window.getSelection().addRange(range); 175 document.execCommand('copy'); 176 window.getSelection().removeAllRanges(); 177 alert('Copied!'); 178 } 179 180 const arraySmaller = (a, b) => { 181 for (let i = 0; i < a.length; i++) { 182 if (a[i] < b[i]) { 183 return true; 184 } 185 if (b[i] < a[i]) { 186 return false; 187 } 188 } 189 return false; 190 } 191 192 async function storeInIndexedDB(data, dbName, storeName, key) { 193 // Open IndexedDB 194 const dbOpenRequest = indexedDB.open(dbName); 195 196 return new Promise((resolve, reject) => { 197 dbOpenRequest.onerror = function (event) { 198 reject("Error opening IndexedDB database"); 199 }; 200 201 dbOpenRequest.onsuccess = async function (event) { 202 const db = event.target.result; 203 204 // Create object store 205 const transaction = db.transaction([storeName], "readwrite"); 206 const objectStore = transaction.objectStore(storeName); 207 208 // Store the Uint8Array 209 try { 210 await new Promise((resolve, reject) => { 211 const putRequest = objectStore.put(data, key); 212 putRequest.onsuccess = resolve; 213 putRequest.onerror = reject; 214 }); 215 resolve("Data stored successfully"); 216 } catch (error) { 217 reject("Error storing data: " + error.message); 218 } 219 }; 220 221 dbOpenRequest.onupgradeneeded = function (event) { 222 const db = event.target.result; 223 224 // Create object store if not exists 225 if (!db.objectStoreNames.contains(storeName)) { 226 db.createObjectStore(storeName); 227 } 228 }; 229 }); 230 } 231 232 async function getFromIndexedDB(dbName, storeName, key) { 233 // Open IndexedDB 234 const dbOpenRequest = indexedDB.open(dbName); 235 236 return new Promise((resolve, reject) => { 237 dbOpenRequest.onerror = function (event) { 238 reject("Error opening IndexedDB database"); 239 }; 240 241 dbOpenRequest.onsuccess = async function (event) { 242 const db = event.target.result; 243 244 const transaction = db.transaction([storeName], "readonly"); 245 const objectStore = transaction.objectStore(storeName); 246 247 try { 248 const getRequest = objectStore.get(key); 249 getRequest.onsuccess = function (event) { 250 const result = event.target.result; 251 if (result) { 252 resolve(result); 253 } else { 254 reject("Data not found in IndexedDB"); 255 } 256 }; 257 getRequest.onerror = function (event) { 258 reject("Error fetching data from IndexedDB"); 259 }; 260 } catch (error) { 261 reject("Error fetching data: " + error.message); 262 } 263 }; 264 265 dbOpenRequest.onupgradeneeded = function (event) { 266 // Handle database upgrade if necessary 267 }; 268 }); 269 } 270 271 272 class MerkleTree { 273 constructor(accounts) { 274 this._tree = []; 275 this._total_depth = 0; 276 const nodes = this._createLeafs(accounts); 277 this._genTree(nodes); 278 } 279 280 _genTree(nodes) { 281 this._tree.push(nodes); 282 this._total_depth = Math.ceil(Math.log2(nodes.length)); 283 284 while (nodes.length > 1) { 285 nodes = this._genPrevNodes(nodes); 286 this._tree.push(nodes); 287 } 288 } 289 290 _genPrevNodes(nodes) { 291 const newNodes = []; 292 const length = nodes.length; 293 294 for (let i = 0; i < length; i += 2) { 295 if (length % 2 !== 0 && i + 1 >= length) { 296 newNodes.push(nodes[i]); 297 break; 298 } 299 300 const a = nodes[i]; 301 const b = nodes[i + 1]; 302 303 if (arraySmaller(a, b)) { 304 let result = new Uint8Array(64); 305 result.set(a); 306 result.set(b, 32); 307 newNodes.push(web3.utils.hexToBytes(web3.utils.sha3(result))); 308 } else { 309 let result = new Uint8Array(64); 310 result.set(b); 311 result.set(a, 32); 312 newNodes.push(web3.utils.hexToBytes(web3.utils.sha3(result))); 313 } 314 } 315 316 return newNodes; 317 } 318 319 _createLeafs(accounts) { 320 return accounts.map((account, i) => { 321 return web3.utils.hexToBytes(web3.utils.soliditySha3( 322 { 323 type: "uint32", 324 value: i 325 }, 326 { 327 type: "bytes20", 328 value: account 329 })); 330 }); 331 } 332 333 getProof(index) { 334 const proof = []; 335 for (let nodes of this._tree) { 336 const length = nodes.length; 337 if (length === 1) break; 338 339 if (length % 2 !== 0 && index === length - 1) { 340 index = Math.floor(index / 2); 341 continue; 342 } 343 344 if (index % 2 === 0) { 345 proof.push(nodes[index + 1]); 346 } else { 347 proof.push(nodes[index - 1]); 348 } 349 350 index = Math.floor(index / 2); 351 } 352 return proof.map(node => web3.utils.bytesToHex(node)); 353 } 354 355 getRoot() { 356 return this._tree[this._total_depth][0]; 357 } 358 } 359 360 const generateProof = (popup, address, privateKey) => { 361 popup.innerHTML = ` 362 <div>Congratulations! This is your proof which you can supply to <a href="https://claim.fluence.network/">claim.fluence.network</a>:</div> 363 <div class="code-container"> 364 <pre><code class="proof"></code></pre> 365 <div class="right-button"><button onclick="copyCode(event)">Copy</button></div> 366 </div> 367 `; 368 369 let objs = [...popup.querySelectorAll('.proof')]; 370 let proofElement = objs[objs.length - 1]; 371 let proof = generateProofFromAccount(address, privateKey); 372 proofElement.textContent = proof; 373 } 374 375 const enterAddress = (popup, privateKey) => { 376 popup.innerHTML = ` 377 <div>Enter the Ethereum address to which you plan to receive the airdrop. You need to make a claim transaction from the entered address!</div> 378 <form style="padding-top: 1em;"> 379 <input type="text" style="width: 100%;" class="ethaddr" placeholder="Ethereum Address" /> 380 <input type="submit" style="width: 100%;" value="Continue" /> 381 </form> 382 `; 383 384 popup.querySelector("form").addEventListener("submit", e => { 385 e.preventDefault(); 386 let ethAddr = popup.querySelector(".ethaddr").value; 387 if (!ethAddr.match(/^0x[a-fA-F0-9]{40}$/)) { 388 alert("Invalid format. Ethereum address must be of format ^0x[a-fA-F0-9]{40}$"); 389 return; 390 } 391 generateProof(popup, ethAddr, privateKey); 392 }); 393 }; 394 395 const showDecryptionInstructions = (popup, command) => { 396 popup.innerHTML = ` 397 <div>Use the <a href="https://github.com/FiloSottile/age">age</a> tool to decrypt the payload. Replace <tt><path to private key></tt> with the path to your private key:</div> 398 <div class="code-container"> 399 <pre><code class="command"></code></pre> 400 <div class="right-button"><button onclick="copyCode(event)">Copy</button></div> 401 </div> 402 <form> 403 <input type="text" style="width: 100%;" class="privkey" placeholder="Decrypted payload" /> 404 <input type="submit" style="width: 100%;" value="Continue" /> 405 </form> 406 `; 407 408 let objs = [...popup.querySelectorAll('.command')]; 409 let commandElement = objs[objs.length - 1]; 410 commandElement.textContent = command; 411 412 popup.querySelector("form").addEventListener("submit", e => { 413 e.preventDefault(); 414 let privKey = popup.querySelector(".privkey").value; 415 if (!privKey.match(/^0x[a-fA-F0-9]{64}$/)) { 416 alert("Invalid format. Decrypted payload must be of format ^0x[a-fA-F0-9]{64}$"); 417 return; 418 } 419 enterAddress(popup, privKey); 420 }); 421 }; 422 423 const showInstructions = pubKeysAndCommands => { 424 let overlay = document.createElement("div"); 425 overlay.className = "overlay"; 426 427 let popup = document.createElement("div"); 428 let close = document.createElement("div"); 429 close.innerHTML = '<div class="right-button"><button class="close">Close</button></div>'; 430 popup.appendChild(close); 431 close.querySelector('button').addEventListener("click", () => { 432 document.body.removeChild(overlay); 433 }); 434 435 popup.className = "popup"; 436 437 overlay.appendChild(popup); 438 439 let popupInner = document.createElement("div"); 440 popup.appendChild(popupInner); 441 popup = popupInner; 442 443 document.body.appendChild(overlay); 444 445 let innerHTML = ` 446 <h2>The account is eligible for receiving the airdrop!</h2> 447 <div>The following SSH public keys have been included:</div> 448 `; 449 for (let [pubKey, command] of pubKeysAndCommands) { 450 innerHTML += ` 451 <div class="code-container"> 452 <pre><code class="key"></code></pre> 453 <div class="right-button"><button class="use-key">Use this key</button></div> 454 </div>`; 455 } 456 457 popup.innerHTML = innerHTML; 458 459 let i = 0; 460 for (let [pubKey, command] of pubKeysAndCommands) { 461 let pubKeyElement = [...popup.querySelectorAll('.key')][i]; 462 pubKeyElement.textContent = pubKey; 463 464 let useKeyElement = [...popup.querySelectorAll('.use-key')][i]; 465 useKeyElement.addEventListener("click", e => showDecryptionInstructions(popup, command)); 466 467 i += 1; 468 } 469 }; 470 471 const generateProofFromAccount = (ethereumAddress, privateKey) => { 472 let account = web3.eth.accounts.privateKeyToAccount(privateKey); 473 474 let tempEthAddress = account.address.toLowerCase(); 475 let tree = new MerkleTree(data.addresses); 476 let index = data.addresses.indexOf(tempEthAddress); 477 let proof = tree.getProof(index); 478 // String replacement for exact Python code behavior 479 let base64MerkleProof = btoa(JSON.stringify(proof).replace(/","/g, '", "')); 480 let signature = web3.eth.accounts.sign(ethereumAddress, privateKey).signature; 481 482 483 return [ 484 index, tempEthAddress, signature, base64MerkleProof 485 ].join(","); 486 }; 487 488 const finish = metadata => { 489 let username = document.querySelector("#username").value.trim().toLowerCase(); 490 let eligibleUsers = Object.keys(metadata.encryptedKeys).filter(x => x.trim().toLowerCase() == username); 491 if (eligibleUsers.length != 1) { 492 alert("User not eligible"); 493 return; 494 } 495 496 let userData = metadata.encryptedKeys[eligibleUsers[0]]; 497 let publicKeys = Object.keys(userData); 498 let result = []; 499 for (let i = 0; i < publicKeys.length; i++) { 500 let publicKey = publicKeys[i]; 501 let encrytedFile = userData[publicKey]; 502 503 let encryptedBase64 = btoa(encrytedFile); 504 let command = 'echo "' + encryptedBase64 + '" | base64 --decode | age --decrypt --identity <path to private key>'; 505 result.push([publicKey, command]); 506 } 507 showInstructions(result); 508 } 509 510 window.addEventListener("load", () => { 511 getFromIndexedDB("metadata", "metadata", "metadata").then(d => { 512 data = d 513 }); 514 }); 515 516 document.querySelector("form").addEventListener("submit", event => { 517 event.preventDefault(); 518 519 if (data != null) { 520 finish(data); 521 return; 522 } 523 524 var progressBar = document.getElementById("progressBar"); 525 var progressContainer = document.querySelector(".progress-container"); 526 progressContainer.style.display = "block"; 527 528 fetch("/metadata.json") 529 .then(response => { 530 const totalBytes = response.headers.get('content-length'); 531 let loadedBytes = 0; 532 533 const reader = response.body.getReader(); 534 535 let byteStream = new Uint8Array(); 536 537 const pump = () => { 538 return reader.read().then(({ value, done }) => { 539 if (done) { 540 progressBar.style.width = "100%"; 541 progressContainer.style.display = "none"; 542 let sha3sum = web3.utils.sha3(byteStream); 543 console.log("metadata.json SHA3", sha3sum); 544 if (sha3sum != "0x998318b214a74aab65f91bea0459815f899c7e6f279a0a77fb1aa55dbf7f77df") { 545 throw new Error("metadata.json integrity violated"); 546 } 547 data = JSON.parse(new TextDecoder().decode(byteStream)); 548 storeInIndexedDB(data, "metadata", "metadata", "metadata"); 549 finish(data); 550 return; 551 } 552 loadedBytes += value.length; 553 progressBar.style.width = Math.floor((loadedBytes / totalBytes) * 100) + "%"; 554 const newStream = new Uint8Array(byteStream.length + value.length); 555 newStream.set(byteStream); 556 newStream.set(value, byteStream.length); 557 byteStream = newStream; 558 559 return pump(); 560 }); 561 } 562 563 return pump(); 564 }) 565 .catch(error => { 566 alert('Error downloading file: ' + error.toString()); 567 }); 568 }); 569 </script> 570 </div> 571 </div> 572 </body> 573 574 </html>