diff --git a/functions/endpoints/mainnet.js b/functions/endpoints/mainnet.js index 3f808b9..1ca6baa 100644 --- a/functions/endpoints/mainnet.js +++ b/functions/endpoints/mainnet.js @@ -1,67 +1,21 @@ const app = require( './express' )() const { getTotalSupply } = require( '../modules/contract' ) -const { safelyReturnRocketeer, web2domain, safelyReturnMultipleRocketeers } = require( '../nft-media/rocketeer' ) +const { web2domain } = require( '../nft-media/rocketeer' ) const { setAvatar, resetAvatar } = require( '../integrations/avatar' ) -const { generateNewOutfit, setPrimaryOutfit } = require( '../integrations/changingroom' ) +const { rocketeerFromRequest, multipleRocketeersFromRequest } = require( '../integrations/rocketeers' ) +const { generateNewOutfit, setPrimaryOutfit, generateMultipleNewOutfits } = require( '../integrations/changingroom' ) // /////////////////////////////// // Specific Rocketeer instances // /////////////////////////////// -app.get( '/api/rocketeer/:id', async ( req, res ) => { - - // Parse the request - let { id } = req.params - if( !id ) return res.json( { error: `No ID specified in URL` } ) - - // Protect against malformed input - id = Math.floor( Math.abs( id ) ) - if( typeof id !== 'number' ) return res.json( { error: `Malformed request` } ) - - // Set ID to string so firestore can handle it - id = `${ id }` - - try { - - // Get old rocketeer if it exists - const rocketeer = await safelyReturnRocketeer( id, 'mainnet' ) - - // Return the new rocketeer - return res.json( rocketeer ) - - } catch( e ) { - - // Log error for debugging - console.error( `Mainnet api error for ${ id }: `, e ) - - // Return error to frontend - return res.json( { error: e.mesage || e.toString() } ) - - } - -} ) - -app.get( '/api/rocketeers/', async ( req, res ) => { - - try { - - // Parse the request - let { ids } = req.query - ids = ids.split( ',' ) - if( ids.length > 100 ) throw new Error( 'Please do not ask for so much data at once :)' ) - const rocketeers = await safelyReturnMultipleRocketeers( ids, 'testnet' ) - return res.json( rocketeers ) - - } catch( e ) { - return res.json( { error: e.message || e.toString() } ) - } - - -} ) +app.get( '/api/rocketeer/:id', async ( req, res ) => rocketeerFromRequest( req, res, 'mainnet' ) ) +app.get( '/api/rocketeers/', async ( req, res ) => multipleRocketeersFromRequest( req, res, 'mainnet' ) ) /* /////////////////////////////// // VGR's dashboard integration // /////////////////////////////*/ app.post( '/api/integrations/avatar/', setAvatar ) +app.post( '/api/rocketeers/:address', generateMultipleNewOutfits ) app.delete( '/api/integrations/avatar/', resetAvatar ) /* /////////////////////////////// diff --git a/functions/endpoints/testnet.js b/functions/endpoints/testnet.js index 977c614..efe9e4d 100644 --- a/functions/endpoints/testnet.js +++ b/functions/endpoints/testnet.js @@ -1,66 +1,20 @@ const app = require( './express' )() const { getTotalSupply } = require( '../modules/contract' ) -const { safelyReturnRocketeer, web2domain, safelyReturnMultipleRocketeers } = require( '../nft-media/rocketeer' ) -const { generateNewOutfit, setPrimaryOutfit } = require( '../integrations/changingroom' ) +const { web2domain } = require( '../nft-media/rocketeer' ) +const { rocketeerFromRequest, multipleRocketeersFromRequest } = require( '../integrations/rocketeers' ) +const { generateNewOutfit, setPrimaryOutfit, generateMultipleNewOutfits } = require( '../integrations/changingroom' ) //////////////////////////////// // Specific Rocketeer instances //////////////////////////////// -app.get( '/testnetapi/rocketeer/:id', async ( req, res ) => { - - // Parse the request - let { id } = req.params - if( !id ) return res.json( { error: `No ID specified in URL` } ) - - // Protect against malformed input - id = Math.floor( Math.abs( id ) ) - if( typeof id !== 'number' ) return res.json( { error: `Malformed request` } ) - - // Set ID to string so firestore can handle it - id = `${ id }` - - try { - - // Get old rocketeer if it exists - const rocketeer = await safelyReturnRocketeer( id, 'rinkeby' ) - - // Return the new rocketeer - return res.json( rocketeer ) - - } catch( e ) { - - // Log error for debugging - console.error( `Testnet api error for ${ id }: `, e ) - - // Return error to frontend - return res.json( { error: e.mesage || e.toString() } ) - - } - -} ) - -app.get( '/testnetapi/rocketeers/', async ( req, res ) => { - - try { - - // Parse the request - let { ids } = req.query - ids = ids.split( ',' ) - if( ids.length > 100 ) throw new Error( 'Please do not ask for so much data at once :)' ) - const rocketeers = await safelyReturnMultipleRocketeers( ids, 'testnet' ) - return res.json( rocketeers ) - - } catch( e ) { - return res.json( { error: e.message || e.toString() } ) - } - - -} ) +app.get( '/testnetapi/rocketeer/:id', ( req, res ) => rocketeerFromRequest( req, res, 'rinkeby' ) ) +app.get( '/testnetapi/rocketeers/', ( req, res ) => multipleRocketeersFromRequest( req, res, 'rinkeby' ) ) /* /////////////////////////////// // Changing room endpoints // /////////////////////////////*/ app.post( '/testnetapi/rocketeer/:id/outfits', generateNewOutfit ) +app.post( '/testnetapi/rocketeers/:address', generateMultipleNewOutfits ) app.put( '/testnetapi/rocketeer/:id/outfits', setPrimaryOutfit ) // Collection data diff --git a/functions/integrations/changingroom.js b/functions/integrations/changingroom.js index 9720f4e..0560e7e 100644 --- a/functions/integrations/changingroom.js +++ b/functions/integrations/changingroom.js @@ -1,4 +1,4 @@ -const { generateNewOutfitFromId } = require( '../nft-media/changing-room' ) +const { generateNewOutfitFromId, generateNewOutfitsByAddress } = require( '../nft-media/changing-room' ) const { db, dataFromSnap } = require( '../modules/firebase' ) // Web3 APIs @@ -67,6 +67,57 @@ exports.generateNewOutfit = async function( req, res ) { } +/* /////////////////////////////// +// POST handler for new avatars +// /////////////////////////////*/ +exports.generateMultipleNewOutfits = async function( req, res ) { + + // Parse the request + let { address } = req.params + if( !address ) return res.json( { error: `No address specified in URL` } ) + + // Protect against malformed input + if( !address.match( /0x.{40}/ ) ) return res.json( { error: `Malformed request` } ) + + // ⚠️ WIP + const network = 'rinkeby' + if( !process.env.NODE_ENV == 'development' ) return res.json( { error: `This endpoint is not live yet. While I appreciate your enthusiasm please don't touch this one yet :)` } ) + + 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 ) + // let { signer, action, chainId } = messageObject + // const network = chainId == '0x1' ? 'mainnet' : 'rinkeby' + // if( signer.toLowerCase() !== confirmedSignatory.toLowerCase() || action != 'generateMultipleNewOutfits' || !network ) throw new Error( `Invalid setPrimaryOutfit message with ${ signer }, ${confirmedSignatory}, ${action}, ${chainId}, ${network}` ) + + // Check that the signer is the owner of the token + const outfits = await generateNewOutfitsByAddress( address, network ) + + return res.json( outfits ) + + + + } catch( e ) { + + // Log error for debugging + console.error( `POST generateMultipleNewOutfits Changing room api error for ${ address }: `, e ) + + // Return error to frontend + return res.json( { error: e.mesage || e.toString() } ) + + } + +} + /* /////////////////////////////// // PUT handler for changing the // current outfit diff --git a/functions/integrations/rocketeers.js b/functions/integrations/rocketeers.js new file mode 100644 index 0000000..f7a16cd --- /dev/null +++ b/functions/integrations/rocketeers.js @@ -0,0 +1,53 @@ +const { safelyReturnRocketeer, safelyReturnMultipleRocketeers } = require( '../nft-media/rocketeer' ) + +exports.rocketeerFromRequest = async function( req, res, network='mainnet' ) { + + + // Parse the request + let { id } = req.params + if( !id ) return res.json( { error: `No ID specified in URL` } ) + + // Protect against malformed input + id = Math.floor( Math.abs( id ) ) + if( typeof id !== 'number' ) return res.json( { error: `Malformed request` } ) + + // Set ID to string so firestore can handle it + id = `${ id }` + + try { + + // Get old rocketeer if it exists + const rocketeer = await safelyReturnRocketeer( id, network ) + + // Return the new rocketeer + return res.json( rocketeer ) + + } catch( e ) { + + // Log error for debugging + console.error( `${ network } api error for ${ id }: `, e ) + + // Return error to frontend + return res.json( { error: e.mesage || e.toString() } ) + + } + + +} + +exports.multipleRocketeersFromRequest = async function( req, res, network='mainnet' ) { + + try { + + // Parse the request + let { ids } = req.query + ids = ids.split( ',' ) + if( ids.length > 100 ) throw new Error( 'Please do not ask for so much data at once :)' ) + const rocketeers = await safelyReturnMultipleRocketeers( ids, network ) + return res.json( rocketeers ) + + } catch( e ) { + return res.json( { error: e.message || e.toString() } ) + } + +} \ No newline at end of file diff --git a/functions/modules/contract.js b/functions/modules/contract.js index ed1ce3c..514627e 100644 --- a/functions/modules/contract.js +++ b/functions/modules/contract.js @@ -24,27 +24,72 @@ const ABI = [ "stateMutability": "view", "type": "function", "constant": true - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "tokenId", - "type": "uint256" - } - ], - "name": "ownerOf", - "outputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function", - "constant": true - } + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "ownerOf", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function", + "constant": true + }, + { + "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 + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function", + "constant": true + } ] // Total current supply, in accordance with ERC721 spec @@ -71,8 +116,28 @@ async function getOwingAddressOfTokenId( id, network='mainnet' ) { } +async function getTokenIdsOfAddress( address, network='mainnet' ) { + + // Initialise contract connection + const web3 = new Web3( `wss://${ network }.infura.io/ws/v3/${ infura.projectid }` ) + const contract = new web3.eth.Contract( ABI, contractAddress[ network ] ) + + // Get balance of address + const balance = await contract.methods.balanceOf( address ).call() + + // Get tokens of address + const ids = await Promise.all( Array.from( { length: balance } ).map( async ( val, index ) => { + const id = await contract.methods.tokenOfOwnerByIndex( address, index ).call() + return id.toString() + } ) ) + + return ids + +} + module.exports = { getTotalSupply, contractAddress, - getOwingAddressOfTokenId + getOwingAddressOfTokenId, + getTokenIdsOfAddress } \ No newline at end of file diff --git a/functions/nft-media/changing-room.js b/functions/nft-media/changing-room.js index 1290559..a650256 100644 --- a/functions/nft-media/changing-room.js +++ b/functions/nft-media/changing-room.js @@ -1,11 +1,13 @@ const { db, dataFromSnap } = require( '../modules/firebase' ) const { getRgbArrayFromColorName, randomNumberBetween } = require( '../modules/helpers' ) +const { getTokenIdsOfAddress } = require( '../modules/contract' ) const svgFromAttributes = require( './svg-generator' ) +const Throttle = require( 'promise-parallel-throttle' ) // /////////////////////////////// // Rocketeer generator // /////////////////////////////// -exports.generateNewOutfitFromId = async function( id, network='mainnet' ) { +async function generateNewOutfitFromId( id, network='mainnet' ) { /* /////////////////////////////// @@ -27,7 +29,7 @@ exports.generateNewOutfitFromId = async function( id, network='mainnet' ) { // Apply entropy levels based on edition status and outfits available const { value: edition } = rocketeer.attributes.find( ( { trait_type } ) => trait_type == "edition" ) if( edition != 'regular' ) colorEntropy *= specialEditionMultiplier - if( available_outfits ) colorEntropy *= ( entropyMultiplier * available_outfits ) + if( available_outfits ) colorEntropy *= ( entropyMultiplier ** available_outfits ) // Check whether this Rocketeer is allowed to change const timeUntilAllowedToChange = newOutfitAllowedInterval - ( Date.now() - last_outfit_change ) @@ -87,4 +89,32 @@ exports.generateNewOutfitFromId = async function( id, network='mainnet' ) { return newOutfitSvg +} + +async function generateNewOutfitsByAddress( address, network='mainnet' ) { + + + const ids = await getTokenIdsOfAddress( address, network ) + const queue = ids.map( id => function() { + return generateNewOutfitFromId( id, network ).then( outfit => ( { id: id, src: outfit } ) ).catch( e => { + console.log( e ) + return ( { id: id, error: e.message } ) + } ) + } ) + const outfits = await Throttle.all( queue, { + maxInProgress: 2, + progressCallback: ( { amountDone } ) => process.env.NODE_ENV == 'development' ? console.log( `Completed ${amountDone}/${queue.length}` ) : false + } ) + + return { + success: outfits.filter( ( { src } ) => src ), + error: outfits.filter( ( { error } ) => error ), + } + + +} + +module.exports = { + generateNewOutfitFromId, + generateNewOutfitsByAddress } \ No newline at end of file diff --git a/functions/nft-media/rocketeer.js b/functions/nft-media/rocketeer.js index bffe86e..dd74c2b 100644 --- a/functions/nft-media/rocketeer.js +++ b/functions/nft-media/rocketeer.js @@ -151,13 +151,14 @@ async function safelyReturnRocketeer( id, network ) { async function safelyReturnMultipleRocketeers( ids=[], network='mainnet' ) { + // Chech if this is an illegal ID const invalidIds = await Promise.all( ids.map( id => isInvalidRocketeerId( id, network ) ) ) if( invalidIds.includes( true ) ) throw invalidIds // Get old rocketeers and append their ids const rocketeers = await Promise.all( ids.map( async id => ( { - ...await getExistingRocketeer( id ), + ...await getExistingRocketeer( id, network ), id: id } ) ) ) diff --git a/functions/package-lock.json b/functions/package-lock.json index a24ea00..ad2c129 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -4837,6 +4837,11 @@ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==" }, + "promise-parallel-throttle": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/promise-parallel-throttle/-/promise-parallel-throttle-3.3.0.tgz", + "integrity": "sha512-tThe11SfFXlGMhuO2D+Nba6L8FJFM17w2zwlMV1kqaLfuT2E8NMtMF1WhJBZaSpWz6V76pP/bGAj8BXTAMOncw==" + }, "proto3-json-serializer": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-0.1.5.tgz", diff --git a/functions/package.json b/functions/package.json index 7edabdf..4dc0401 100644 --- a/functions/package.json +++ b/functions/package.json @@ -22,6 +22,7 @@ "firebase-admin": "^10.0.0", "firebase-functions": "^3.11.0", "jsdom": "^18.0.0", + "promise-parallel-throttle": "^3.3.0", "puppeteer": "^12.0.0", "puppeteer-extra": "^3.2.3", "puppeteer-extra-plugin-stealth": "^2.9.0",