From 580f168684923c2a078eb9294c6b87965b0b0d5a Mon Sep 17 00:00:00 2001 From: motechFR Date: Thu, 10 Oct 2024 07:40:49 -0500 Subject: [PATCH] Data for missing purchase events (#4773) * script to create pending transaction * Add utility for getting transactions * Simplify transactions code * Finalise script for debugging transactions * Add script for getting missing transaction data * Cleanup methods for fetching onchain data * Add script for auto-fixing NFTs * Cleaup names * Fix types * Delete old script --------- Co-authored-by: mattcasey --- apps/scoutgame/scripts/query.ts | 18 -- .../scripts/syncUserNFTsFromOnchainData.ts | 65 +++++++ package-lock.json | 6 +- packages/scoutgame/package.json | 5 +- .../scoutgame/src/builderNfts/constants.ts | 2 +- .../builderNfts/getOnchainPurchaseEvents.ts | 181 ++++++++++++++++++ .../src/builderNfts/getTokenPurchasePrice.ts | 46 +++++ packages/scoutgame/src/scripts/query.ts | 2 +- packages/utils/package.json | 3 +- packages/utils/src/strings.ts | 18 ++ 10 files changed, 321 insertions(+), 25 deletions(-) delete mode 100644 apps/scoutgame/scripts/query.ts create mode 100644 apps/scoutgamecron/src/scripts/syncUserNFTsFromOnchainData.ts create mode 100644 packages/scoutgame/src/builderNfts/getOnchainPurchaseEvents.ts create mode 100644 packages/scoutgame/src/builderNfts/getTokenPurchasePrice.ts create mode 100644 packages/utils/src/strings.ts diff --git a/apps/scoutgame/scripts/query.ts b/apps/scoutgame/scripts/query.ts deleted file mode 100644 index 653f6f1f29..0000000000 --- a/apps/scoutgame/scripts/query.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { authorizeUserByLaunchDate } from '../lib/session/authorizeUserByLaunchDate'; -import { prisma } from '@charmverse/core/prisma-client'; -async function main() { - const scout = await prisma.connectWaitlistSlot.findFirstOrThrow({ - where: { - username: 'qqsksk12' - } - }); - try { - const authorized = await authorizeUserByLaunchDate({ fid: scout.fid }); - console.log(`User ${scout.fid} is authorized: ${authorized}`); - } catch (error) { - console.error(`Error authorizing user ${scout.fid}: ${error}`); - console.log(scout); - } -} - -main(); diff --git a/apps/scoutgamecron/src/scripts/syncUserNFTsFromOnchainData.ts b/apps/scoutgamecron/src/scripts/syncUserNFTsFromOnchainData.ts new file mode 100644 index 0000000000..6cdfd1bf40 --- /dev/null +++ b/apps/scoutgamecron/src/scripts/syncUserNFTsFromOnchainData.ts @@ -0,0 +1,65 @@ + +import { log } from '@charmverse/core/log'; +import { prisma } from '@charmverse/core/prisma-client'; +import { optimismUsdcContractAddress, realOptimismMainnetBuildersContract } from '@packages/scoutgame/builderNfts/constants'; +import { getOnchainPurchaseEvents } from '@packages/scoutgame/builderNfts/getOnchainPurchaseEvents'; +import { getTokenPurchasePrice } from '@packages/scoutgame/builderNfts/getTokenPurchasePrice'; +import { handlePendingTransaction } from '@packages/scoutgame/builderNfts/handlePendingTransaction'; +import { savePendingTransaction } from '@packages/scoutgame/savePendingTransaction'; + +async function syncUserNFTsFromOnchainData({username, scoutId}: {username?: string, scoutId?: string}): Promise { + if (!username && !scoutId) { + throw new Error('Either username or scoutId must be provided'); + } else if (username && scoutId) { + throw new Error('Only one of username or scoutId can be provided'); + } + + const scout = await prisma.scout.findFirstOrThrow({ + where: { + id: scoutId, + username + } + }); + + const userPurchases = await getOnchainPurchaseEvents({ scoutId: scout.id }); + + const txRequiringReconciliation = userPurchases.filter(p => !p.nftPurchase); + + for (let i = 0; i < txRequiringReconciliation.length; i++) { + + log.info(`Processing missing tx ${i+1} / ${txRequiringReconciliation.length}`) + + const tx = txRequiringReconciliation[i]; + const expectedPrice = tx.pendingTransaction?.targetAmountReceived ?? await getTokenPurchasePrice({ + args: { + amount: BigInt(tx.amount), + tokenId: BigInt(tx.tokenId) + }, + blockNumber: BigInt(tx.blockNumber) - BigInt(1) + }); + + const pendingTx = tx.pendingTransaction ?? await savePendingTransaction({ + user: { + scoutId: scout.id, + walletAddress: tx.transferEvent.to + }, + transactionInfo: { + destinationChainId: 10, + sourceChainId: 10, + sourceChainTxHash: tx.txHash + }, + purchaseInfo: { + quotedPriceCurrency: optimismUsdcContractAddress, + builderContractAddress: realOptimismMainnetBuildersContract, + tokenId: parseInt(tx.tokenId), + quotedPrice: Number(expectedPrice.toString()), + tokenAmount: Number(tx.amount) + } + }); + + await handlePendingTransaction({ pendingTransactionId: pendingTx.id }); + } +} + + +syncUserNFTsFromOnchainData({ username: 'cryptomobile' }).then(console.log) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index bcd9d493b9..d7abd950ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68452,7 +68452,8 @@ "version": "0.0.0", "dependencies": { "@packages/github": "^0.0.0", - "@packages/onchain": "^0.0.0" + "@packages/onchain": "^0.0.0", + "@packages/utils": "^1.0.0" } }, "packages/utils": { @@ -81375,7 +81376,8 @@ "version": "file:packages/scoutgame", "requires": { "@packages/github": "^0.0.0", - "@packages/onchain": "^0.0.0" + "@packages/onchain": "^0.0.0", + "@packages/utils": "^1.0.0" } }, "@packages/utils": { diff --git a/packages/scoutgame/package.json b/packages/scoutgame/package.json index 60e2ede382..7f6fb0141c 100644 --- a/packages/scoutgame/package.json +++ b/packages/scoutgame/package.json @@ -11,7 +11,8 @@ "./builderNfts/artwork/generateNftImage": "./src/builderNfts/artwork/generateNftImage.tsx" }, "dependencies": { - "@packages/github": "^0.0.0", - "@packages/onchain": "^0.0.0" + "@packages/onchain": "^0.0.0", + "@packages/utils": "^1.0.0", + "@packages/github": "^0.0.0" } } diff --git a/packages/scoutgame/src/builderNfts/constants.ts b/packages/scoutgame/src/builderNfts/constants.ts index b984955b96..c52c2e75cf 100644 --- a/packages/scoutgame/src/builderNfts/constants.ts +++ b/packages/scoutgame/src/builderNfts/constants.ts @@ -23,7 +23,7 @@ const devOptimismSepoliaBuildersContract = '0x2f6093b70562729952bf379633dee3e899 const devOptimismMainnetBuildersContract = '0x1d305a06cb9dbdc32e08c3d230889acb9fe8a4dd'; const realOptimismSepoliaBuildersContract = '0x0b7342761a10e1b14df427681b967e67f5e6cef9'; -const realOptimismMainnetBuildersContract = '0x743ec903fe6d05e73b19a6db807271bb66100e83'; +export const realOptimismMainnetBuildersContract = '0x743ec903fe6d05e73b19a6db807271bb66100e83'; export function getBuilderContractAddress(): `0x${string}` { return (env('BUILDER_NFT_CONTRACT_ADDRESS') || process.env.REACT_APP_BUILDER_NFT_CONTRACT_ADDRESS) as `0x${string}`; diff --git a/packages/scoutgame/src/builderNfts/getOnchainPurchaseEvents.ts b/packages/scoutgame/src/builderNfts/getOnchainPurchaseEvents.ts new file mode 100644 index 0000000000..707538f580 --- /dev/null +++ b/packages/scoutgame/src/builderNfts/getOnchainPurchaseEvents.ts @@ -0,0 +1,181 @@ +import { prisma } from '@charmverse/core/prisma-client'; +import { prettyPrint } from '@packages/utils/strings'; +import { createPublicClient, http, parseEventLogs } from 'viem'; +import { mainnet } from 'viem/chains'; + +import { realOptimismMainnetBuildersContract } from './constants'; + +const transferSingle = { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'operator', type: 'address' }, + { indexed: true, internalType: 'address', name: 'from', type: 'address' }, + { indexed: true, internalType: 'address', name: 'to', type: 'address' }, + { indexed: false, internalType: 'uint256', name: 'id', type: 'uint256' }, + { indexed: false, internalType: 'uint256', name: 'value', type: 'uint256' } + ], + name: 'TransferSingle', + type: 'event' +}; + +const builderScouted = { + anonymous: false, + inputs: [ + { indexed: false, internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + { indexed: false, internalType: 'uint256', name: 'amount', type: 'uint256' }, + { indexed: false, internalType: 'string', name: 'scout', type: 'string' } + ], + name: 'BuilderScouted', + type: 'event' +}; + +type TransferSingleEvent = { + eventName: 'TransferSingle'; + args: { operator: string; from: string; to: string; id: string; value: string }; + transactionHash: string; + blockNumber: string; +}; + +type BuilderScoutedEvent = { + eventName: 'BuilderScouted'; + args: { tokenId: string; amount: string; scout: string }; + transactionHash: string; + blockNumber: string; +}; + +const contractAbi = [transferSingle, builderScouted]; + +// Set up your client for the desired chain +const client = createPublicClient({ + chain: mainnet, + transport: http(`https://opt-mainnet.g.alchemy.com/v2/vTjY0u9L7uoxZQ5GtOw4yKwn7WJelMXp`) +}); + +// Contract address and the event signature for filtering logs +const contractAddress = realOptimismMainnetBuildersContract; + +// Function to get logs for the contract and parse them against the ABI +async function getAndParseLogs() { + const logs = await client.getLogs({ + address: contractAddress, + fromBlock: 126062456n, + toBlock: 'latest' + }); + + const parsedLogs = parseEventLogs({ abi: contractAbi, logs, eventName: ['BuilderScouted', 'TransferSingle'] }); + + return parsedLogs; +} + +type ParsedLogs = Awaited>; + +type SimplifiedGroupedEvent = { + scoutId: string; + amount: string; + tokenId: string; + txHash: string; + blockNumber: string; + transferEvent: { + from: string; + to: string; + operator: string; + value: string; + }; + builderScoutedEvent: { + scout: string; + amount: string; + }; +}; + +function groupEventsByTransactionHash(events: ParsedLogs): SimplifiedGroupedEvent[] { + const eventMap: Record> = {}; + + for (const baseEvent of events) { + const event = baseEvent as ParsedLogs[number] & { + eventName: 'TransferSingle' | 'BuilderScouted'; + args: BuilderScoutedEvent['args'] | TransferSingleEvent['args']; + }; + const { transactionHash, blockNumber } = event; + + if (!eventMap[transactionHash]) { + eventMap[transactionHash] = { txHash: transactionHash, blockNumber: blockNumber as any }; + } + + if (event.eventName === 'TransferSingle') { + const transferSingleEvent = event as any as TransferSingleEvent; + eventMap[transactionHash].transferEvent = { + from: transferSingleEvent.args.from, + to: transferSingleEvent.args.to, + operator: transferSingleEvent.args.operator, + value: transferSingleEvent.args.value + }; + eventMap[transactionHash].tokenId = transferSingleEvent.args.id; + } else if (event.eventName === 'BuilderScouted') { + const builderScoutedEvent = event as any as BuilderScoutedEvent; + eventMap[transactionHash].builderScoutedEvent = { + scout: builderScoutedEvent.args.scout, + amount: builderScoutedEvent.args.amount + }; + eventMap[transactionHash].scoutId = builderScoutedEvent.args.scout; + eventMap[transactionHash].amount = builderScoutedEvent.args.amount; + } + } + + return Object.values(eventMap).map((entry) => ({ + scoutId: entry.scoutId!, + amount: entry.amount!, + tokenId: entry.tokenId!, + txHash: entry.txHash!, + blockNumber: entry.blockNumber!, + transferEvent: entry.transferEvent!, + builderScoutedEvent: entry.builderScoutedEvent! + })); +} + +export async function getOnchainPurchaseEvents({ scoutId }: { scoutId: string }) { + const logs = await getAndParseLogs(); + + const groupedEvents = groupEventsByTransactionHash(logs as any); + + const nftPurchases = await prisma.nFTPurchaseEvent.findMany({ + where: { + scoutId + }, + select: { + txHash: true, + tokensPurchased: true, + paidInPoints: true, + pointsValue: true + } + }); + + const pendingTransactions = await prisma.pendingNftTransaction.findMany({ + where: { + userId: scoutId + }, + select: { + id: true, + sourceChainTxHash: true, + sourceChainId: true, + destinationChainTxHash: true, + destinationChainId: true, + tokenAmount: true, + targetAmountReceived: true + } + }); + + const mappedEvents = groupedEvents + .filter((event) => event.scoutId === scoutId) + .map((event) => { + const nftPurchase = nftPurchases.find((nft) => nft.txHash === event.txHash) ?? null; + const pendingTransaction = + pendingTransactions.find( + (tx) => tx.sourceChainTxHash === event.txHash || tx.destinationChainTxHash === event.txHash + ) ?? null; + return { ...event, nftPurchase, pendingTransaction }; + }); + + return mappedEvents; +} + +// getOnchainPurchaseEvents({ scoutId: 'bb2e2785-ebbb-441c-9909-df87f0cac5c4' }).then(prettyPrint); diff --git a/packages/scoutgame/src/builderNfts/getTokenPurchasePrice.ts b/packages/scoutgame/src/builderNfts/getTokenPurchasePrice.ts new file mode 100644 index 0000000000..672a16e870 --- /dev/null +++ b/packages/scoutgame/src/builderNfts/getTokenPurchasePrice.ts @@ -0,0 +1,46 @@ +import { getPublicClient } from '@packages/onchain/getPublicClient'; +import { decodeFunctionResult, encodeFunctionData } from 'viem'; +import { optimism } from 'viem/chains'; + +import { getBuilderContractAddress } from './constants'; + +/** + * Optional block number to query the contract at, enabling past pricing data + */ +export async function getTokenPurchasePrice(params: { + args: { tokenId: bigint; amount: bigint }; + blockNumber?: bigint; +}): Promise { + const abi = [ + { + inputs: [ + { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' } + ], + name: 'getTokenPurchasePrice', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function' + } + ]; + + const txData = encodeFunctionData({ + abi, + functionName: 'getTokenPurchasePrice', + args: [params.args.tokenId, params.args.amount] + }); + + const { data } = await getPublicClient(optimism.id).call({ + to: getBuilderContractAddress(), + data: txData, + blockNumber: params.blockNumber + }); + + const result = decodeFunctionResult({ + abi, + functionName: 'getTokenPurchasePrice', + data: data as `0x${string}` + }); + + return result as bigint; +} diff --git a/packages/scoutgame/src/scripts/query.ts b/packages/scoutgame/src/scripts/query.ts index 23019ab787..8529baa714 100644 --- a/packages/scoutgame/src/scripts/query.ts +++ b/packages/scoutgame/src/scripts/query.ts @@ -41,4 +41,4 @@ async function query() { } -query(); +// query(); diff --git a/packages/utils/package.json b/packages/utils/package.json index 958f4738d9..31ca5f9660 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -12,6 +12,7 @@ "./react": "./src/react.ts", "./types": "./src/types.ts", "./dates": "./src/dates.ts", - "./http": "./src/http.ts" + "./http": "./src/http.ts", + "./strings": "./src/strings.ts" } } \ No newline at end of file diff --git a/packages/utils/src/strings.ts b/packages/utils/src/strings.ts new file mode 100644 index 0000000000..1987ec1a6f --- /dev/null +++ b/packages/utils/src/strings.ts @@ -0,0 +1,18 @@ +import { log } from '@charmverse/core/log'; + +export function prettyPrint(input: any): string { + if (!input) { + return ''; + } + + const pretty = + typeof input === 'object' + ? JSON.stringify(input, (key, value) => (typeof value === 'bigint' ? value.toString() : value), 2) + : input.toString + ? input.toString() + : input; + + log.info(pretty); + + return pretty; +}