From a704bf3bfb96aea883708cca7e9537acbe27b7e9 Mon Sep 17 00:00:00 2001 From: Julien Genestoux Date: Fri, 8 Nov 2024 16:40:28 -0500 Subject: [PATCH] feat(locksmith): API for eventcaster is now async (#15039) * wip * adding file * refactor * more * setting value from 1password * wip * adding async jobs * cleaned up * cleanup --- locksmith/.op.env.production | 4 +- locksmith/.op.env.staging | 4 +- .../v2/eventCasterController.test.ts | 81 ++++++----- .../operations/eventCasterOperations.test.ts | 66 +++++++++ locksmith/src/config/config.ts | 1 + .../controllers/v2/eventCasterController.ts | 79 +++------- .../src/operations/eventCasterOperations.ts | 135 +++++++++++++++++- .../eventCaster/createEventCasterEvent.ts | 37 +++++ .../eventCaster/rsvpForEventCasterEvent.ts | 30 ++++ 9 files changed, 337 insertions(+), 100 deletions(-) create mode 100644 locksmith/__tests__/operations/eventCasterOperations.test.ts create mode 100644 locksmith/src/worker/tasks/eventCaster/createEventCasterEvent.ts create mode 100644 locksmith/src/worker/tasks/eventCaster/rsvpForEventCasterEvent.ts diff --git a/locksmith/.op.env.production b/locksmith/.op.env.production index 7c7b045ccf8..12214f08c9a 100644 --- a/locksmith/.op.env.production +++ b/locksmith/.op.env.production @@ -58,4 +58,6 @@ APPLE_WALLET_WWDR_CERT=op://secrets/apple-wallet/wwdr-cert APPLE_WALLET_SIGNER_KEY_PASSPHRASE=op://secrets/apple-wallet/signer-key-passphrase PRIVY_APP_ID=op://secrets/privy/app-id -PRIVY_APP_SECRET=op://secrets/privy/app-secret \ No newline at end of file +PRIVY_APP_SECRET=op://secrets/privy/app-secret + +EVENTCASTER_API_KEY=op://secrets/eventcaster/api-key \ No newline at end of file diff --git a/locksmith/.op.env.staging b/locksmith/.op.env.staging index 82321968c95..e803fa7c20f 100644 --- a/locksmith/.op.env.staging +++ b/locksmith/.op.env.staging @@ -27,4 +27,6 @@ APPLE_WALLET_WWDR_CERT=op://secrets/apple-wallet/wwdr-cert APPLE_WALLET_SIGNER_KEY_PASSPHRASE=op://secrets/apple-wallet/signer-key-passphrase PRIVY_APP_ID=op://secrets/privy/staging-app-id -PRIVY_APP_SECRET=op://secrets/privy/staging-app-secret \ No newline at end of file +PRIVY_APP_SECRET=op://secrets/privy/staging-app-secret + +EVENTCASTER_API_KEY=op://secrets/eventcaster/api-key \ No newline at end of file diff --git a/locksmith/__tests__/controllers/v2/eventCasterController.test.ts b/locksmith/__tests__/controllers/v2/eventCasterController.test.ts index 6a35c690569..abfcaf75c05 100644 --- a/locksmith/__tests__/controllers/v2/eventCasterController.test.ts +++ b/locksmith/__tests__/controllers/v2/eventCasterController.test.ts @@ -4,6 +4,7 @@ import app from '../../app' import { Application } from '../../../src/models/application' import { EVENT_CASTER_ADDRESS } from '../../../src/utils/constants' import { ethers } from 'ethers' +import { addJob } from '../../../src/worker/worker' const lockAddress = '0xce332211f030567bd301507443AD9240e0b13644' const tokenId = 1337 @@ -42,6 +43,16 @@ vi.mock('../../../src/operations/eventCasterOperations', () => ({ deployLockForEventCaster: async () => { return { address: lockAddress, network: 84532 } }, + getEventFormEventCaster: async () => { + return { contract: { network: 84532, address: lockAddress } } + }, + mintNFTForRsvp: async () => { + return { id: tokenId, owner, network: 84532, address: lockAddress } + }, +})) + +vi.mock('../../../src/worker/worker', () => ({ + addJob: vi.fn().mockResolvedValue(Promise.resolve(true)), })) // https://events.xyz/api/v1/event?event_id=195ede7f @@ -258,58 +269,58 @@ describe('eventcaster endpoints', () => { expect(response.status).toBe(403) }) - it('creates the contract and returns its address', async () => { + it('creates a job to deploy the contract', async () => { const response = await request(app) .post(`/v2/eventcaster/create-event`) .set('Accept', 'json') .set('Authorization', `Api-key ${eventCasterApplication.key}`) .send(eventCasterEvent) - - expect(response.status).toBe(201) - expect(response.body.address).toBe(lockAddress) + expect(response.status).toBe(204) + expect(addJob).toHaveBeenCalledWith('createEventCasterEvent', { + description: eventCasterEvent.description, + eventId: eventCasterEvent.id, + imageUrl: eventCasterEvent.image_url, + title: eventCasterEvent.title, + hosts: [ + { + verified_addresses: { + eth_addresses: [ + '0xdcf37d8Aa17142f053AAA7dc56025aB00D897a19', + '0x05e189E1BbaF77f1654F0983872fd938AE592eDD', + '0x70abdCd7A5A8Ff9cDef1ccA9eA15a5d315780986', + ], + }, + }, + { + verified_addresses: { + eth_addresses: ['0xCEEd9585854F12F81A0103861b83b995A64AD915'], + }, + }, + ], + }) }) }) describe('rsvp-for-event endpoint', () => { - it('mints the token and returns its id', async () => { + it('triggers the job to mint the NFT', async () => { fetchMock.mockResponseOnce( JSON.stringify({ success: true, event: eventCasterEvent }) ) - const response = await request(app) .post(`/v2/eventcaster/${eventCasterEvent.id}/rsvp`) .set('Accept', 'json') .set('Authorization', `Api-key ${eventCasterApplication.key}`) .send(eventCasterRsvp) - expect(response.status).toBe(201) - expect(response.body.id).toBe(tokenId) - expect(response.body.owner).toBe(owner) - expect(response.body.network).toBe(eventCasterEvent.contract.network) - expect(response.body.address).toBe(eventCasterEvent.contract.address) - }) - it('returns the existing token if one already exists', async () => { - fetchMock.mockResponseOnce( - JSON.stringify({ success: true, event: eventCasterEvent }) - ) - - const response = await request(app) - .post(`/v2/eventcaster/${eventCasterEvent.id}/rsvp`) - .set('Accept', 'json') - .set('Authorization', `Api-key ${eventCasterApplication.key}`) - .send({ - ...eventCasterRsvp, - user: { - verified_addresses: { - eth_addresses: ['0xCEEd9585854F12F81A0103861b83b995A64AD915'], - }, - }, - }) - - expect(response.status).toBe(200) - expect(response.body.id).toBe(1337) - expect(response.body.owner).toBe(owner) - expect(response.body.network).toBe(eventCasterEvent.contract.network) - expect(response.body.address).toBe(eventCasterEvent.contract.address) + expect(response.status).toBe(204) + expect(addJob).toHaveBeenCalledWith('rsvpForEventCasterEvent', { + contract: { + address: lockAddress, + network: 84532, + }, + eventId: eventCasterEvent.id, + farcasterId: eventCasterRsvp.user.fid, + ownerAddress: eventCasterRsvp.user.verified_addresses.eth_addresses[0], + }) }) }) describe('delete-event endpoint', () => { diff --git a/locksmith/__tests__/operations/eventCasterOperations.test.ts b/locksmith/__tests__/operations/eventCasterOperations.test.ts new file mode 100644 index 00000000000..af631583b37 --- /dev/null +++ b/locksmith/__tests__/operations/eventCasterOperations.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + saveContractOnEventCasterEvent, + saveTokenOnEventCasterRSVP, +} from '../../src/operations/eventCasterOperations' + +describe('saveContractOnEventCasterEvent', () => { + beforeEach(() => { + fetchMock.mockResponseOnce( + JSON.stringify({ + address: '0x662208945C988B1769d493d94e4DFdc9c681B6fF', + event_id: 'e7618561-dbb5-4b0d-81dd-5101e5c9729f', + network: 84532, + success: true, + }), + { + status: 200, + } + ) + }) + it("shoud call the EventCaster API to save the contract's address", async () => { + const result = await saveContractOnEventCasterEvent({ + eventId: 'e76185', + contract: '0x662208945C988B1769d493d94e4DFdc9c681B6fF', + network: 84532, + }) + expect(result).toEqual({ + address: '0x662208945C988B1769d493d94e4DFdc9c681B6fF', + event_id: 'e7618561-dbb5-4b0d-81dd-5101e5c9729f', + network: 84532, + success: true, + }) + }) +}) + +describe('saveTokenOnEventCasterRSVP', () => { + beforeEach(() => { + fetchMock.mockResponseOnce( + JSON.stringify({ + event_id: 'e7618561-dbb5-4b0d-81dd-5101e5c9729f', + fid: 6801, + rsvp_id: '360ad3c1-33d9-469a-a5b2-7785c88dd2b5', + success: true, + token_id: '5656', + }), + { + status: 200, + } + ) + }) + + it('should update the token id for an RSVP to an event on EventCaster', async () => { + const result = await saveTokenOnEventCasterRSVP({ + eventId: 'e76185', + farcasterId: '6801', + tokenId: '5656', + }) + expect(result).toEqual({ + event_id: 'e7618561-dbb5-4b0d-81dd-5101e5c9729f', + fid: 6801, + rsvp_id: '360ad3c1-33d9-469a-a5b2-7785c88dd2b5', + success: true, + token_id: '5656', + }) + }) +}) diff --git a/locksmith/src/config/config.ts b/locksmith/src/config/config.ts index 6766dc0172a..2632cba3196 100644 --- a/locksmith/src/config/config.ts +++ b/locksmith/src/config/config.ts @@ -131,6 +131,7 @@ const config = { signerKeyPassphrase: process.env.APPLE_WALLET_SIGNER_KEY_PASSPHRASE, privyAppId: process.env.PRIVY_APP_ID, privyAppSecret: process.env.PRIVY_APP_SECRET, + eventCasterApiKey: process.env.EVENTCASTER_API_KEY, logtailSourceToken: process.env.LOGTAIL, sessionDuration: Number(process.env.SESSION_DURATION || 86400 * 60), // 60 days diff --git a/locksmith/src/controllers/v2/eventCasterController.ts b/locksmith/src/controllers/v2/eventCasterController.ts index 1798b6e4451..293728cf968 100644 --- a/locksmith/src/controllers/v2/eventCasterController.ts +++ b/locksmith/src/controllers/v2/eventCasterController.ts @@ -1,12 +1,7 @@ -import networks from '@unlock-protocol/networks' -import { WalletService, Web3Service } from '@unlock-protocol/unlock-js' import { RequestHandler } from 'express' import { z } from 'zod' -import { - getProviderForNetwork, - getPurchaser, -} from '../../fulfillment/dispatcher' -import { deployLockForEventCaster } from '../../operations/eventCasterOperations' +import { getEventFormEventCaster } from '../../operations/eventCasterOperations' +import { addJob } from '../../worker/worker' // This is the API endpoint used by EventCaster to create events const CreateEventBody = z.object({ @@ -29,9 +24,11 @@ const RsvpBody = z.object({ verified_addresses: z.object({ eth_addresses: z.array(z.string()), }), + fid: z.number(), }), }) +// Asynchronously creates an event on EventCaster export const createEvent: RequestHandler = async (request, response) => { const { title, @@ -40,14 +37,14 @@ export const createEvent: RequestHandler = async (request, response) => { description, image_url, } = await CreateEventBody.parseAsync(request.body) - const { address, network } = await deployLockForEventCaster({ + await addJob('createEventCasterEvent', { title, hosts, eventId, imageUrl: image_url, description, }) - response.status(201).json({ address, network }) + response.sendStatus(204) return } @@ -55,75 +52,37 @@ export const createEvent: RequestHandler = async (request, response) => { export const rsvpForEvent: RequestHandler = async (request, response) => { const { user } = await RsvpBody.parseAsync(request.body) - // make the request to @event api - const eventCasterResponse = await fetch( - `https://events.xyz/api/v1/event?event_id=${request.params.eventId}` - ) - // parse the response and continue - const { success, event } = await eventCasterResponse.json() - - if (!success) { - response.status(422).json({ message: 'Could not retrieve event' }) - return - } - - if (!(event.contract?.address && event.contract?.network)) { - response - .status(422) - .json({ message: 'This event does not have a contract attached.' }) + let event + try { + event = await getEventFormEventCaster(request.params.eventId) + } catch (error) { + response.status(422).json({ message: error.message }) return } // Get the recipient - if (!user.verified_addresses.eth_addresses[0]) { + const ownerAddress = user.verified_addresses.eth_addresses[0] + if (!ownerAddress) { response .status(422) .json({ message: 'User does not have a verified address.' }) return } - const [provider, wallet] = await Promise.all([ - getProviderForNetwork(event.contract.network), - getPurchaser({ network: event.contract.network }), - ]) - - const ownerAddress = user.verified_addresses.eth_addresses[0] - // Check first if the user has a key - const web3Service = new Web3Service(networks) - const existingKey = await web3Service.getKeyByLockForOwner( - event.contract.address, + await addJob('rsvpForEventCasterEvent', { + farcasterId: user.fid, ownerAddress, - event.contract.network - ) - - if (existingKey.tokenId > 0) { - response.status(200).json({ - network: event.contract.network, - address: event.contract.address, - id: Number(existingKey.tokenId), - owner: ownerAddress, - }) - return - } - - const walletService = new WalletService(networks) - await walletService.connect(provider, wallet) - - const token = await walletService.grantKey({ - lockAddress: event.contract.address, - recipient: ownerAddress, + contract: event.contract, + eventId: request.params.eventId, }) - response.status(201).json({ - network: event.contract.network, - address: event.contract.address, - ...token, - }) + response.sendStatus(204) return } // Deletes an event. Unsure how to proceed here... export const deleteEvent: RequestHandler = async (_request, response) => { + // TODO: implement this response.status(200).json({}) return } diff --git a/locksmith/src/operations/eventCasterOperations.ts b/locksmith/src/operations/eventCasterOperations.ts index eeb439cd556..4e0cc601e93 100644 --- a/locksmith/src/operations/eventCasterOperations.ts +++ b/locksmith/src/operations/eventCasterOperations.ts @@ -1,15 +1,16 @@ import { PublicLock } from '@unlock-protocol/contracts' import { ethers } from 'ethers' -import { isProduction } from '../config/config' +import config, { isProduction } from '../config/config' import { getAllPurchasers, getProviderForNetwork, getPurchaser, } from '../fulfillment/dispatcher' import networks from '@unlock-protocol/networks' -import { WalletService } from '@unlock-protocol/unlock-js' +import { WalletService, Web3Service } from '@unlock-protocol/unlock-js' import { EVENT_CASTER_ADDRESS } from '../utils/constants' import { LockMetadata } from '../models' +import logger from '../logger' const DEFAULT_NETWORK = isProduction ? 8453 : 84532 // Base or Base Sepolia @@ -43,7 +44,11 @@ export const deployLockForEventCaster = async ({ description, }: { title: string - hosts: any[] + hosts: { + verified_addresses: { + eth_addresses: string[] + } + }[] eventId: string imageUrl: string description: string @@ -180,3 +185,127 @@ export const deployLockForEventCaster = async ({ network: DEFAULT_NETWORK, } } + +export const getEventFormEventCaster = async (eventId: string) => { + // make the request to @event api + const eventCasterResponse = await fetch( + `https://events.xyz/api/v1/event?event_id=${eventId}` + ) + // parse the response and continue + const { success, event } = await eventCasterResponse.json() + + if (!success) { + return new Error('Could not retrieve event') + } + + if (!(event.contract?.address && event.contract?.network)) { + return new Error('This event does not have a contract attached.') + } + return event +} + +export const mintNFTForRsvp = async ({ + ownerAddress, + contract, +}: { + ownerAddress: string + contract: { + address: string + network: number + } +}): Promise<{ + network: number + address: string + id: number + owner: string +}> => { + // Get the recipient + + const [provider, wallet] = await Promise.all([ + getProviderForNetwork(contract.network), + getPurchaser({ network: contract.network }), + ]) + + // Check first if the user has a key + const web3Service = new Web3Service(networks) + const existingKey = await web3Service.getKeyByLockForOwner( + contract.address, + ownerAddress, + contract.network + ) + + if (existingKey.tokenId > 0) { + return { + network: contract.network, + address: contract.address, + id: existingKey.tokenId, + owner: ownerAddress, + } + } + + const walletService = new WalletService(networks) + await walletService.connect(provider, wallet) + + return await walletService.grantKey({ + lockAddress: contract.address, + recipient: ownerAddress, + }) +} + +export const saveContractOnEventCasterEvent = async ({ + eventId, + address, + network, +}: { + eventId: string + address: string + network: number +}) => { + const response = await fetch( + `https://events.xyz/api/v1/unlock/update-event?event_id=${eventId}&address=${address}&network=${network}`, + { + method: 'POST', + headers: { + 'x-api-key': `${config.eventCasterApiKey}`, + }, + body: '', + } + ) + if (response.status !== 200) { + logger.error('Failed to save contract on EventCaster') + return + } + const responseBody = await response.json() + if (!responseBody.success) { + logger.error('Failed to save contract on EventCaster', responseBody) + return + } + return responseBody +} + +export const saveTokenOnEventCasterRSVP = async ({ + eventId, + farcasterId, + tokenId, +}: { + eventId: string + farcasterId: string + tokenId: number +}) => { + const response = await fetch( + `https://events.xyz/api/v1/unlock/update-rsvp?event_id=${eventId}&fid=${farcasterId}&token_id=${tokenId}`, + { + method: 'POST', + headers: { + 'x-api-key': `${config.eventCasterApiKey}`, + }, + body: '', + } + ) + const responseBody = await response.json() + if (response.status !== 200) { + logger.error('Failed to save RSVP on EventCaster') + return + } + return responseBody +} diff --git a/locksmith/src/worker/tasks/eventCaster/createEventCasterEvent.ts b/locksmith/src/worker/tasks/eventCaster/createEventCasterEvent.ts new file mode 100644 index 00000000000..a682471b5c1 --- /dev/null +++ b/locksmith/src/worker/tasks/eventCaster/createEventCasterEvent.ts @@ -0,0 +1,37 @@ +import { Task } from 'graphile-worker' +import { + deployLockForEventCaster, + saveContractOnEventCasterEvent, +} from '../../../operations/eventCasterOperations' +import { z } from 'zod' + +export const CreateEventCasterEventPayload = z.object({ + title: z.string(), + hosts: z.array( + z.object({ + verified_addresses: z.object({ + eth_addresses: z.array(z.string()), + }), + }) + ), + eventId: z.string(), + imageUrl: z.string(), + description: z.string(), +}) + +export const createEventCasterEvent: Task = async (payload) => { + const { title, hosts, eventId, imageUrl, description } = + await CreateEventCasterEventPayload.parse(payload) + const { address, network } = await deployLockForEventCaster({ + title, + hosts, + eventId, + imageUrl, + description, + }) + await saveContractOnEventCasterEvent({ + eventId, + network, + address, + }) +} diff --git a/locksmith/src/worker/tasks/eventCaster/rsvpForEventCasterEvent.ts b/locksmith/src/worker/tasks/eventCaster/rsvpForEventCasterEvent.ts new file mode 100644 index 00000000000..c04eba32e62 --- /dev/null +++ b/locksmith/src/worker/tasks/eventCaster/rsvpForEventCasterEvent.ts @@ -0,0 +1,30 @@ +import { Task } from 'graphile-worker' +import { + mintNFTForRsvp, + saveTokenOnEventCasterRSVP, +} from '../../../operations/eventCasterOperations' +import { z } from 'zod' + +export const RsvpForEventCasterEventPayload = z.object({ + ownerAddress: z.string(), + eventId: z.string(), + contract: z.object({ + network: z.number(), + address: z.string(), + }), + farcasterId: z.string(), +}) + +export const rsvpForEventCasterEvent: Task = async (payload) => { + const { ownerAddress, eventId, contract, farcasterId } = + await RsvpForEventCasterEventPayload.parse(payload) + const token = await mintNFTForRsvp({ + ownerAddress, + contract: contract, + }) + await saveTokenOnEventCasterRSVP({ + eventId, + tokenId: token.id, + farcasterId, + }) +}