Avatar setting

This commit is contained in:
Mentor Palokaj 2021-11-05 11:48:31 +01:00
parent c2a851cb76
commit 0b46af4057
19 changed files with 2212 additions and 105 deletions

View File

@ -20,6 +20,10 @@
{
"source": "/testnetapi/**",
"function": "testnetMetadata"
},
{
"source": "/integrations/avatar",
"function": "setAvatarOfValidtor"
}
]
},

View File

@ -1,6 +1,7 @@
const functions = require( 'firebase-functions' )
const testnetAPI = require( './modules/testnet' )
const mainnetAPI = require( './modules/mainnet' )
const setAvatarOfValidtor = require( './integrations/avatar' )
// Runtime config
const runtime = {
@ -13,3 +14,8 @@ exports.testnetMetadata = functions.runWith( runtime ).https.onRequest( testnetA
// Mainnet endpoint
exports.mainnetMetadata = functions.runWith( runtime ).https.onRequest( mainnetAPI )
/* ///////////////////////////////
// Integrations
// /////////////////////////////*/
exports.setAvatarOfValidtor = functions.https.onRequest( setAvatarOfValidtor )

View File

@ -0,0 +1,87 @@
const { db, dataFromSnap } = require( '../modules/firebase' )
const Web3 = require( 'web3' )
const web3 = new Web3()
const { getStorage } = require( 'firebase-admin/storage' )
module.exports = async function( req, res ) {
const chain = process.env.NODE_ENV == 'development' ? '0x4' : '0x1'
try {
// Get request data
const { message, signature, signatory } = req.body
if( !message || !signatory || !signature ) throw new Error( `Malformed request` )
// Decode message
const confirmedSignatory = web3.eth.accounts.recover( message, signature )
if( signatory.toLowerCase() !== confirmedSignatory.toLowerCase() ) throw new Error( `Bad signature` )
// Validate message
const messageObject = JSON.parse( message )
const { signer, tokenId, validator, chainId } = messageObject
if( signer.toLowerCase() !== confirmedSignatory.toLowerCase() || !tokenId || !validator || chainId !== chain ) throw new Error( `Invalid message` )
// Check if validator was already assigned
const validatorProfile = await db.collection( `${ chain === '0x1' ? 'mainnet' : 'rinkeby' }Validators` ).doc( validator ).get().then( dataFromSnap )
if( validatorProfile && validatorProfile.owner !== signatory ) throw new Error( `Validator already claimed by another wallet. If this is in error, contact mentor.eth on Discord.\n\nThe reason someone else can claim your validator is that we don't want to you to have to expose your validator private key to the world for security reasons <3` )
// Write new data to db
await db.collection( `${ chain === '0x1' ? 'mainnet' : 'rinkeby' }Validators` ).doc( validator ).set( {
tokenId,
owner: signatory,
src: `https://storage.googleapis.com/rocketeer-nft.appspot.com/${ chain === '0x1' ? 'mainnet' : 'rinkeby' }Rocketeers/${ tokenId }.jpg`,
updated: Date.now()
} )
// Update the static overview JSON
const storage = getStorage()
const bucket = storage.bucket()
const cacheFile = bucket.file( `integrations/${ chain === '0x1' ? 'mainnet' : 'rinkeby' }Avatars.json` )
// Load existing json
let jsonstring = '{}'
const [ fileExists ] = await cacheFile.exists()
if( fileExists ) {
// Read old json
const [ oldJson ] = await cacheFile.download()
jsonstring = oldJson
}
const cachedJson = JSON.parse( jsonstring )
// Get items that have not been updated
const tenSecondsAgo = Date.now() - ( 10 * 1000 )
const shouldBeUpdated = await db.collection( `${ chain === '0x1' ? 'mainnet' : 'rinkeby' }Validators` ).where( 'updated', '>', cachedJson.updated || tenSecondsAgo ).get().then( dataFromSnap )
// Update items that should be updated ( including current update )
shouldBeUpdated.map( doc => {
if( !cachedJson.images ) cachedJson.images = {}
if( !cachedJson.ids ) cachedJson.ids = {}
cachedJson.images[ doc.uid ] = doc.src
cachedJson.ids[ doc.uid ] = doc.tokenId
} )
// Save new data to file
cachedJson.updated = Date.now()
cachedJson.trail = shouldBeUpdated.length
await cacheFile.save( JSON.stringify( cachedJson ) )
await cacheFile.makePublic()
console.log( 'New data: ', cachedJson )
return res.send( {
success: true,
url: cacheFile.publicUrl()
} )
} catch( e ) {
console.error( 'avatar integration error: ', e )
return res.send( {
error: e.message
} )
}
}

View File

@ -31,7 +31,7 @@ const ABI = [
async function getTotalSupply( network='mainnet' ) {
// Initialise contract connection
const web3 = new Web3( `wss://${ network }.infura.io/ws/v3/${ infura.projectid }` )
const web3 = new Web3( `wss://${ network }.infura.io/ws/v3/${ infura.projectid }` )
const contract = new web3.eth.Contract( ABI, contractAddress[ network ] )
// Return the call promise which returns the total supply

View File

@ -6,9 +6,21 @@ const { getFirestore, FieldValue, FieldPath } = require( 'firebase-admin/firesto
const app = initializeApp()
const db = getFirestore()
const dataFromSnap = ( snapOfDocOrDocs, withDocId=true ) => {
// If these are multiple docs
if( snapOfDocOrDocs.docs ) return snapOfDocOrDocs.docs.map( doc => ( { uid: doc.id, ...doc.data( ) } ) )
// If this is a single document
return { ...snapOfDocOrDocs.data(), ...( withDocId && { uid: snapOfDocOrDocs.id } ) }
}
module.exports = {
app: app,
db: db,
FieldValue: FieldValue,
FieldPath: FieldPath
FieldPath: FieldPath,
dataFromSnap: dataFromSnap
}

File diff suppressed because it is too large Load Diff

View File

@ -4,8 +4,8 @@
"scripts": {
"lint": "eslint .",
"serve": "firebase emulators:start --only functions",
"shell": "firebase functions:shell",
"start": "npm run shell",
"shell": "NODE_ENV=development firebase functions:shell",
"start": "NODE_ENV=development npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},

View File

@ -8086,6 +8086,14 @@
"resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz",
"integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ=="
},
"history": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/history/-/history-5.1.0.tgz",
"integrity": "sha512-zPuQgPacm2vH2xdORvGGz1wQMuHSIB56yNAy5FnLuwOwgSYyPKptJtcMm6Ev+hRGeS+GzhbmRacHzvlESbFwDg==",
"requires": {
"@babel/runtime": "^7.7.6"
}
},
"hmac-drbg": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
@ -13498,6 +13506,22 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz",
"integrity": "sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg=="
},
"react-router": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.0.0.tgz",
"integrity": "sha512-FcTRCihYZvERMNbG54D9+Wkv2cj/OtoxNlA/87D7vxKYlmSmbF9J9XChI9Is44j/behEiOhbovgVZBhKQn+wgA==",
"requires": {
"history": "^5.0.3"
}
},
"react-router-dom": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.0.0.tgz",
"integrity": "sha512-bPXyYipf0zu6K7mHSEmNO5YqLKq2q9N+Dsahw9Xh3oq1IirsI3vbnIYcVWin6A0zWyHmKhMGoV7Gr0j0kcuVFg==",
"requires": {
"react-router": "6.0.0"
}
},
"react-scripts": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-4.0.3.tgz",

View File

@ -9,6 +9,7 @@
"ethers": "^5.4.7",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "^6.0.0",
"react-scripts": "4.0.3",
"use-interval": "^1.4.0",
"web-vitals": "^1.1.2",

View File

@ -9,10 +9,11 @@ main {
justify-content: center;
}
body * {
body, body * {
box-sizing: border-box;
max-width: 100%;
overflow-wrap: break-word;
overflow-x: hidden;
}
code {
@ -33,6 +34,15 @@ div.container {
justify-content: center;
text-align: center;
}
div.container.wide {
width: 1024px;
}
.row {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
/*Login button*/
a.button {
@ -114,3 +124,26 @@ h1, p, label {
transform: rotate(360deg);
}
}
/* ///////////////////////////////
/// Avatar page
// /////////////////////////////*/
#avatar .rocketeer {
margin: 1rem 0;
border-radius: 50%;
padding: 1rem;
width: 200px;
height: 200px;
}
#avatar input {
padding: 1rem;
width: 350px;
}
#avatar p {
margin: 3rem 0;
max-width: 300px;
text-align: center;
}

View File

@ -1,10 +1,12 @@
import Minter from './components/minter'
import Metamask from './components/metamask'
import Verifier from './components/verifier'
import Fox from './assets/metamask-fox.svg'
import Avatar from './components/avatar'
import { Container } from './components/generic'
import { useState, useEffect } from 'react'
import { log } from './modules/helpers'
import { useAddress, getAddress } from './modules/web3'
import { HashRouter, Routes, Route } from 'react-router-dom'
function App() {
@ -12,88 +14,38 @@ function App() {
// ///////////////////////////////
// States
// ///////////////////////////////
const [ action, setAction ] = useState( undefined )
const [ loading, setLoading ] = useState( 'Detecting metamask...' )
const [ error, setError ] = useState( undefined )
const address = useAddress()
// ///////////////////////////////
// Functions
// ///////////////////////////////
function checkAction() {
const verify = window.location.href.includes( 'mode=verify' )
log( `Location is ${window.location.href}, ${ !verify ? 'not opening' : 'opening' } verifier` )
if( verify ) return setAction( 'verify' )
return setAction( 'mint' )
}
// Handle user login interaction
async function metamasklogin( e ) {
e.preventDefault()
try {
setLoading( 'Connecting to Metamask' )
const address = await getAddress()
log( 'Received: ', address )
} catch( e ) {
setError( `Metamask error: ${ e.message || JSON.stringify( e ) }. Please reload the page.` )
} finally {
setLoading( false )
}
}
// ///////////////////////////////
// Lifecycle
// ///////////////////////////////
// Check for metamask on load
// Check for web3 on load
useEffect( f => window.ethereum ? setLoading( false ) : setError( 'No web3 provider detected, please install metamask' ), [] )
// Check for action on load
useEffect( f => {
checkAction()
return window.addEventListener( 'popstate', checkAction )
}, [ address, loading ] )
// ///////////////////////////////
// Rendering
// ///////////////////////////////
log( 'Rendering with ', action, error, loading, address )
// Initialisation interface
if( error || loading || !address ) return <Container>
{ error && <p>{ error }</p> }
{ !error && loading && <div className="loading">
<div className="lds-dual-ring"></div>
<p>{ loading }</p>
</div> }
{ !address && ( !error && !loading ) && <>
<h1>Rocketeer { action === 'mint' ? 'Minter' : 'Verifier' }</h1>
{ action === 'mint' && <p>This interface is used to mint new Rocketeer NFTs. Minting is free, except for the gas fees. After minting you can view your new Rocketeer and its attributes on Opensea.</p> }
{ action === 'verify' && <p>This interface is used to verify that you are the owner of a Rocketeer</p> }
<a className="button" href="/#" onClick={ metamasklogin }>
<img alt="metamask fox" src={ Fox } />
Connect wallet
</a>
</> }
if( error || loading ) return <Container>
<p>{ error || loading }</p>
</Container>
return <HashRouter>
<Routes>
<Route exact path='/' element={ <Metamask /> } />
<Route exact path='/mint' element={ <Minter /> } />
<Route path='/verify/' element={ <Verifier /> }>
<Route path='/verify/:verificationCode' element={ <Verifier /> } />
</Route>
<Route exact path='/avatar' element={ <Avatar /> } />
</Routes>
</HashRouter>
if( action === 'mint' ) return <Minter />
if( action === 'verify' ) return <Verifier />
else return <></>
}
export default App;

View File

@ -0,0 +1,94 @@
import { Container, Loading } from './generic'
import '../App.css'
import { useState, useEffect } from 'react'
import { log } from '../modules/helpers'
import { useRocketeerImages, callApi } from '../modules/api'
import { useAddress, useChainId, useBalanceOf, useTokenIds, sign } from '../modules/web3'
export default function Verifier() {
// ///////////////////////////////
// State management
// ///////////////////////////////
const balance = useBalanceOf()
const chainId = useChainId()
const address = useAddress()
const [ loading, setLoading ] = useState( )
const metamaskAddress = useAddress()
const [ validatorAddress, setValidatorAddress ] = useState( )
const rocketeers = useRocketeerImages()
// ///////////////////////////////
// Functions
// ///////////////////////////////
async function attribute( id ) {
try {
const confirmed = window.confirm( `This will assign Rocketeer ${ id } to ${ validatorAddress }.\n\nMetamask will ask you to sign a message, this is NOT A TRANSACTION.` )
if( !confirmed ) throw new Error( `Operation cancelled` )
setLoading( 'Signing verification message' )
const signature = await sign( JSON.stringify( {
signer: address.toLowerCase(),
tokenId: id,
validator: validatorAddress.toLowerCase(),
chainId
} ), address )
log( 'Making request with ', signature )
setLoading( 'Updating profile' )
const { error, success } = await callApi( `/integrations/avatar`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify( signature )
} )
if( error ) throw new Error( error )
} catch( e ) {
alert( e.message )
log( e )
} finally {
setLoading( false )
}
}
// ///////////////////////////////
// Lifecycle
// ///////////////////////////////
useEffect( f => {
if( !validatorAddress && metamaskAddress ) setValidatorAddress( metamaskAddress )
}, [ metamaskAddress, validatorAddress ] )
// ///////////////////////////////
// Rendering
// ///////////////////////////////
if( loading ) return <Loading message={ loading } />
return <Container id="avatar" className={ rocketeers.length > 1 ? 'wide' : '' }>
<h1>Rocketeer avatar attribution</h1>
<p>Input the address you want to assign the avatar to.</p>
<input type='text' value={ validatorAddress } />
<p>Click the Rocketeer you want to assign to this address.</p>
<div className="row">
{ rocketeers.map( ( { id, src } ) => {
return <img key={ id } onClick={ f => attribute( id ) } className='rocketeer' src={ src } alt={ `Rocketeer number ${ id }` } />
} ) }
</div>
</Container>
}

View File

@ -1,8 +1,8 @@
import LaunchBackground from '../assets/undraw_launch_day_4e04.svg'
export const Container = ( { children } ) => <main>
export const Container = ( { className, children, ...props } ) => <main { ...props }>
<div className="container">
<div className={ `container ${ className }` }>
{ children }
@ -10,4 +10,13 @@ export const Container = ( { children } ) => <main>
<img className="stretchBackground" src={ LaunchBackground } alt="Launching rocket" />
</main>
</main>
export const Loading = ( { message } ) => <Container>
<div className="loading">
<div className="lds-dual-ring"></div>
<p>{ message }</p>
</div>
</Container>

View File

@ -0,0 +1,91 @@
import Fox from '../assets/metamask-fox.svg'
import { Container, Loading } from './generic'
import { useState, useEffect } from 'react'
import { log } from '../modules/helpers'
import { useAddress, getAddress } from '../modules/web3'
import { useNavigate, Navigate, Link } from 'react-router-dom'
// ///////////////////////////////
// Render component
// ///////////////////////////////
export default function ComponentName( ) {
const navigate = useNavigate()
// ///////////////////////////////
// States
// ///////////////////////////////
const [ loading, setLoading ] = useState( 'Detecting metamask...' )
const [ error, setError ] = useState( undefined )
const address = useAddress()
// ///////////////////////////////
// Functions
// ///////////////////////////////
// Handle user login interaction
async function metamasklogin( e ) {
e.preventDefault()
try {
setLoading( 'Connecting to Metamask' )
const address = await getAddress()
log( 'Received: ', address )
} catch( e ) {
setError( `Metamask error: ${ e.message || JSON.stringify( e ) }. Please reload the page.` )
} finally {
setLoading( false )
}
}
// ///////////////////////////////
// Lifecycle
// ///////////////////////////////
// Check for metamask on load
useEffect( f => window.ethereum ? setLoading( false ) : setError( 'No web3 provider detected, please install metamask' ), [] )
// ///////////////////////////////
// Render component
// ///////////////////////////////
// Loading component
if( loading ) return <Loading message={ loading } />
// Error interface
if( error ) return <Container>
<p>{ error }</p>
</Container>
// Actions menu
if( address ) return <Container>
<h1>Rocketeer Interface</h1>
<div>
<Link className='button' to='/mint'>Mint Rocketeer</Link>
<Link className='button' to='/verify'>Discord verify</Link>
<Link className='button' to='/avatar'>Set address avatar</Link>
</div>
</Container>
// Login interface
return <Container>
<h1>Rocketeer Interface</h1>
<a className="button" href="/#" onClick={ metamasklogin }>
<img alt="metamask fox" src={ Fox } />
Connect wallet
</a>
</Container>
}

View File

@ -4,6 +4,7 @@ import '../App.css'
import { useState, useEffect } from 'react'
import { log } from '../modules/helpers'
import { useAddress, useChainId, useBalanceOf } from '../modules/web3'
import { useParams } from 'react-router-dom'
export default function Verifier() {
@ -17,6 +18,7 @@ export default function Verifier() {
const [ verifyUrl, setVerifyUrl ] = useState()
const [ message, setMessage ] = useState()
const [ error, setError ] = useState( )
const { verificationCode } = useParams()
// ///////////////////////////////
// Functions
@ -28,27 +30,23 @@ export default function Verifier() {
if( !username ) return alert( 'Please fill in your Discord username to get verified' )
if( balance < 1 ) return alert( `The address ${ address } does not own Rocketeers, did you select the right address?` )
const baseUrl = `https://mint.rocketeer.fans/?mode=verify`
const baseUrl = `https://mint.rocketeer.fans/#/verify/`
const message = btoa( `{ "username": "${ username }", "address": "${ address }", "balance": "${ balance }" }` )
setVerifyUrl( baseUrl + `&message=${ message }` )
setVerifyUrl( baseUrl + message )
}
function verifyIfNeeded() {
// ///////////////////////////////
// Lifecycle
// ///////////////////////////////
useEffect( f => {
log( 'Check the need to verify' )
if( !window.location.href.includes( 'message' ) ) return
if( !verificationCode ) return
try {
log( 'Verification started' )
const { search } = window.location
const query = new URLSearchParams( search )
const message = query.get( 'message' )
const verification = atob( message )
const verification = atob( verificationCode )
log( 'Received message: ', verification )
const json = JSON.parse( verification )
@ -63,17 +61,8 @@ export default function Verifier() {
return alert( 'Verification error, contact the team on Discord' )
}
}
// ///////////////////////////////
// Lifecycle
// ///////////////////////////////
useEffect( f => {
log( 'Triggering verification check and popstate listener' )
setError( false )
verifyIfNeeded()
return window.addEventListener( 'popstate', verifyIfNeeded )
}, [] )
}, [ verificationCode ] )
// ///////////////////////////////
// Rendering

75
minter/src/modules/api.js Normal file
View File

@ -0,0 +1,75 @@
import { log } from './helpers'
import { useTokenIds } from './web3'
import { useState, useEffect } from 'react'
export async function callApi( path, options={} ) {
const api = {
mainnet: 'https://rocketeer.fans/api',
testnet: 'https://rocketeer.fans/testnetapi'
}
const querySaysTestnet = window.location.href.includes( 'testnet' )
const isLocal = window.location.hostname === 'localhost'
const chain = ( isLocal || querySaysTestnet ) ? 'testnet' : 'mainnet'
const callPath = api[ chain ] + path
log( 'Calling ', callPath )
return fetch( `${ api[ chain ] }${ path }`, options ).then( res => res.json() )
}
export function getImage( id, ext='jpg' ) {
const api = {
mainnet: 'https://storage.googleapis.com/rocketeer-nft.appspot.com/mainnetRocketeers/',
testnet: 'https://storage.googleapis.com/rocketeer-nft.appspot.com/rinkebyRocketeers'
}
const querySaysTestnet = window.location.href.includes( 'testnet' )
const isLocal = window.location.hostname === 'localhost'
const chain = ( isLocal || querySaysTestnet ) ? 'testnet' : 'mainnet'
return api[ chain ] + `/${ id }.${ext}`
}
export function useRocketeers() {
const ids = useTokenIds()
const [ rocketeers, setRocketeers ] = useState( [] )
useEffect( f => {
( async function() {
const rocketeerMetas = await Promise.all( ids.map( id => callApi( `/rocketeer/${ id }` ) ) )
log( 'Received rocketeers: ', rocketeerMetas )
setRocketeers( rocketeerMetas )
} )( )
}, [ ids ] )
return rocketeers
}
export function useRocketeerImages() {
const ids = useTokenIds()
const [ images, setImages ] = useState( [] )
useEffect( f => {
setImages( ids.map( id => ( {
id,
src: getImage( id )
} ) ) )
}, [ ids ] )
return images
}

View File

@ -6,10 +6,27 @@ import { log, setListenerAndReturnUnlistener } from './helpers'
import { ethers } from "ethers"
// Convenience objects
const { providers: { Web3Provider }, Contract } = ethers
const { providers: { Web3Provider }, Contract, utils: { verifyMessage } } = ethers
export const provider = window.ethereum && new Web3Provider( window.ethereum )
export const signer = provider && provider.getSigner()
/* ///////////////////////////////
// Wallet interactors
// /////////////////////////////*/
export async function sign( message, signatory ) {
const signature = await signer.signMessage( message )
log( `Signed ${ message } to ${ signature }` )
const verifiedSignatory = verifyMessage( message, signature ).toLowerCase()
log( `Message was signed by ${ verifiedSignatory }. Signature validity: `, signatory === verifiedSignatory )
return {
message,
signature,
signatory
}
}
// ///////////////////////////////
// Chain interactors
// ///////////////////////////////
@ -38,7 +55,10 @@ export function useAddress() {
setTimesChecked( timesChecked+1 )
log( 'Checking for address' )
if( window.ethereum && window.ethereum.selectedAddress ) return setAddress( window.ethereum.selectedAddress )
if( window.ethereum && window.ethereum.selectedAddress ) {
setAddress( window.ethereum.selectedAddress )
return setInterval( null )
}
// if checked five times and interval still running, slow it down
if( timesChecked > 5 && !!interval ) setInterval( 5000 )
@ -73,6 +93,7 @@ export function useAddress() {
}
// Totl supply hook
export function useTotalSupply() {
const [ supply, setSupply ] = useState( 'loading' )
@ -124,10 +145,11 @@ export function useTotalSupply() {
}
// Balance hook
export function useBalanceOf() {
const [ balance, setBalance ] = useState( 'loading' )
const contract = useContract( )
const contract = useContract()
const address = useAddress()
// Create listener to minting
@ -147,7 +169,7 @@ export function useBalanceOf() {
} catch( e ) {
log( 'Error getting initial supply: ', e )
log( 'Error getting balance: ', e )
}
@ -190,6 +212,50 @@ export function useChainId() {
}
// Token ids of owner hook
export function useTokenIds() {
// Deps
const address = useAddress()
const contract = useContract()
const balance = useBalanceOf()
// State
const [ tokens, setTokens ] = useState( [] )
// Grab tokens from contract
useEffect( f => {
// Do nothing if there is no data yet
if( !contract || !balance || !address ) return
// Load initial supply value
( async ( ) => {
try {
const ids = await Promise.all( Array.from( { length: balance } ).map( async ( val, index ) => {
const id = await contract.tokenOfOwnerByIndex( address, index )
return id.toString()
} ) )
log( 'Tokens detected: ', ids )
setTokens( ids )
} catch( e ) {
log( 'Error getting tokens of address: ', e )
}
} )( )
}, [ contract, address, balance ] )
return tokens
}
// ///////////////////////////////
// Contract interactors
// ///////////////////////////////
@ -272,6 +338,31 @@ const ABI = [
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"internalType": "uint256",
"name": "index",
"type": "uint256"
}
],
"name": "tokenOfOwnerByIndex",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function",
"constant": true
}
]

View File

@ -4,10 +4,11 @@
// Globals
// ////////////////////////////// */
body * {
body, body * {
box-sizing: border-box;
max-width: 100%;
overflow-wrap: break-word;
overflow-x: hidden;
}
h1 {

View File

@ -85,7 +85,9 @@ function App() {
<a href={ rocketeer.image } className="button">Download Jpeg</a>
<a href='/#' onClick={ rocketeer?.image?.replace( 'jpg', 'svg' ) } className="button">Download Svg</a>
<a href={ rocketeer?.image?.replace( 'jpg', 'svg' ) } className="button">Download Svg</a>
<a rel="noreferrer" target="_blank" href={ `https://opensea.io/assets/0xb3767b2033cf24334095dc82029dbf0e9528039d/${ rocketeerId }` } className="button">View on Opensea</a>
</div>