Last active
July 13, 2022 18:52
-
-
Save Athiriyya/ecb8b9bf0268399f9d338903cec42293 to your computer and use it in GitHub Desktop.
Parse Defi Kingdoms Quests, Node.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "name": "parse_quests_node", | |
| "version": "1.0.0", | |
| "description": "Parse Defi Kingdoms quest rewards", | |
| "main": "parse_dfk_quest.js", | |
| "type": "module", | |
| "scripts": { | |
| "start": "node parse_dfk_quest.js" | |
| }, | |
| "author": "Athiriyya", | |
| "email" : "athiriyya@gmail.com", | |
| "url": "https://gist.github.com/Athiriyya/ecb8b9bf0268399f9d338903cec42293", | |
| "license": "ISC", | |
| "dependencies": { | |
| "@thanpolas/degenking": "^1.0.1", | |
| "argparse": "^2.0.1", | |
| "ethers": "^5.6.9" | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Note that this depends on @thanpolas's great Node package: | |
| // https://github.com/degen-heroes/degenking | |
| // Install with `npm i @thanpolas/degenking` or use the package.json in this gist | |
| // Also needed: argparse, ethers.js | |
| import * as url from 'node:url'; | |
| import { ethers } from 'ethers'; | |
| import { ArgumentParser } from 'argparse'; | |
| import * as degenKing from '@thanpolas/degenking'; | |
| const { GOLD_DECIMALS, JEWEL_DECIMALS, ZERO_ADDRESS } = degenKing.CONSTANTS; | |
| const CRYSTAL_DECIMALS = 18; | |
| import { abiQuestCoreV1, abiQuestCoreV2, ALL_ITEMS } from '@thanpolas/degenking'; | |
| const { HARMONY, DFKN } = degenKing.CONSTANTS.NETWORK_IDS; | |
| const GOLD_ADDRESSES = [ | |
| '0xeCCC54b836cD5bf114daec723de1c0d89B0C2A7b', | |
| '0x576C260513204392F0eC0bc865450872025CB1cA', | |
| '0x3a4EDcf3312f44EF027acfd8c21382a5259936e7', | |
| '0x24B46b91E0862221D39dd30FAAd63999717860Ab', | |
| ]; | |
| const JEWEL_ADDRESSES = [ | |
| '0x72Cb10C6bfA5624dD07Ef608027E366bd690048F', | |
| '0x63882d0438AdA0dD76ed2E6B7C2D53A55284A557', | |
| ]; | |
| const CRYSTAL_ADDRESSES = [ | |
| '0xa5c47B4bEb35215fB0CF0Ea6516F9921591c3aCE', | |
| '0x04b9dA42306B023f3572e106B11D82aAd9D32EBb', | |
| ]; | |
| // =========== | |
| // = GLOBALS = | |
| // =========== | |
| let activeChainId = HARMONY; | |
| let ITEM_NAMES = {}; | |
| export async function main() { | |
| const demoMode = true; | |
| if (demoMode) { | |
| runDemos(); | |
| } else { | |
| const args = await parseAllArgs(); | |
| let { txid, chainId } = args; | |
| const results = await getQuestResults(txid, chainId); | |
| console.log(`Rewards for txid ${txid} on chain ${chainId}:\n${JSON.stringify(results, null, 4)}`); | |
| } | |
| } | |
| async function runDemos() { | |
| const demos = [ | |
| // A single-hero v1 harmony quest: | |
| ['0x252a70c70bcbd32e02c1389f77cdfd0b3bc16eecdc61ba7e83b392b187383136', HARMONY], | |
| // 2-hero v2 harmony quest: | |
| ['0x252a70c70bcbd32e02c1389f77cdfd0b3bc16eecdc61ba7e83b392b187383136', HARMONY], | |
| // 2-hero v2 DFK Chain quest: | |
| ['0x1d3af48baeadcbc737cd1205acdd5d40da7bc8f3052811d46de087042268c72c', DFKN], | |
| ]; | |
| demos.forEach(async ([txid, chainId]) => { | |
| const results = await getQuestResults(txid, chainId); | |
| const resultsStr = JSON.stringify(results, null, 4); | |
| console.log(`Rewards for txid ${txid} on chain ${chainId}:\n${resultsStr}\n\n`); | |
| }); | |
| } | |
| export async function parseAllArgs() { | |
| const description = `Parse Defi Kingdoms quest results`; | |
| const parser = new ArgumentParser({ description }); | |
| parser.add_argument('--txid', '-t', { type: 'str', help: 'Transaction ID', required: true }); | |
| parser.add_argument('--chain', '-c', { | |
| choices: ['harmony', 'dfk'], | |
| help: 'Which blockchain <TXID> occurred on', | |
| default: 'harmony', | |
| }); | |
| let args = parser.parse_args(); | |
| // Internally we use chainId rather than a string. Set that on the args namespace here. | |
| args.chainId = args.chain === 'dfk' ? DFKN : HARMONY; | |
| return args; | |
| } | |
| export async function getQuestResults(txId, chainId = undefined) { | |
| chainId = chainId || activeChainId; | |
| const { provider } = getRPCProviderByChain(chainId); | |
| const txReceipt = await provider.getTransactionReceipt(txId); | |
| const result = parseEndedQuestReceipt(txReceipt, chainId); | |
| return result; | |
| } | |
| export function parseEndedQuestReceipt(txReceipt, chainId = undefined) { | |
| chainId = chainId || activeChainId; | |
| let result; | |
| const heroDicts = {}; | |
| if (!txReceipt) { | |
| return {}; | |
| } | |
| const iface = getEthersQuestInterface(txReceipt, chainId); | |
| txReceipt.logs.forEach((l) => { | |
| try { | |
| const parsedLog = iface.parseLog(l); | |
| const { name, args } = parsedLog; | |
| if (name === 'QuestXP') { | |
| let { heroId, xpEarned, questId, player } = args; | |
| heroId = heroId.toNumber(); | |
| xpEarned = xpEarned.toNumber(); | |
| if (!heroDicts[heroId]) { | |
| heroDicts[heroId] = { heroId, rewards: [], skillUp: 0, xpEarned: 0 }; | |
| } | |
| heroDicts[heroId].xpEarned += xpEarned; | |
| // the `result` object contains info describing the | |
| // entire quest. V2 training quests have no skillUp and may | |
| // have no found items, but all completed quests should return | |
| // some XP, so set that info here if it hasn't been set yet. | |
| if (!result) { | |
| result = { | |
| questId: questId.toNumber(), | |
| player, | |
| rewards: [], | |
| transactionHash: l.transactionHash, | |
| blockNumber: txReceipt.blockNumber, | |
| }; | |
| } | |
| } | |
| // QuestReward is the v1 Reward Event | |
| else if (name === 'QuestReward') { | |
| let { heroId, itemQuantity: quantity, rewardItem } = args; | |
| heroId = heroId.toNumber(); | |
| if (!heroDicts[heroId]) { | |
| heroDicts[heroId] = { heroId, rewards: [], skillUp: 0, xpEarned: 0 }; | |
| } | |
| if (rewardItem !== ZERO_ADDRESS) { | |
| const rewardDesc = labelForReward(rewardItem, quantity, chainId); | |
| heroDicts[heroId].rewards.push(rewardDesc); | |
| } | |
| } | |
| // RewardMinted is the V2 Reward Event | |
| if (name === 'RewardMinted') { | |
| let { heroId, amount: quantity, reward: rewardItem } = args; | |
| heroId = heroId.toNumber(); | |
| if (!heroDicts[heroId]) { | |
| heroDicts[heroId] = { heroId, rewards: [], skillUp: 0, xpEarned: 0 }; | |
| } | |
| if (rewardItem !== ZERO_ADDRESS) { | |
| const rewardDesc = labelForReward(rewardItem, quantity, chainId); | |
| heroDicts[heroId].rewards.push(rewardDesc); | |
| } | |
| } | |
| // Profession quests sometimes return a skill increase | |
| else if (name === 'QuestSkillUp') { | |
| let { heroId, skillUp } = args; | |
| heroId = heroId.toNumber(); | |
| if (skillUp) { | |
| if (!heroDicts[heroId]) { | |
| heroDicts[heroId] = { heroId, rewards: [], skillUp: 0, xpEarned: 0 }; | |
| } | |
| heroDicts[heroId].skillUp += skillUp / 10; | |
| } | |
| } | |
| } catch (e) { | |
| // Ignore unparseable logs | |
| // FIXME: raise any errors that AREN't the unparseable logs | |
| if (e.code === 'INVALID_ARGUMENT') { | |
| // null logs have throw 'INVALID_ARGUMENT'; ignore them | |
| } else { | |
| // Unexpected error; raise | |
| console.log(`Error parsing quest results: ${e.stack}`); | |
| throw e; | |
| } | |
| } | |
| }); | |
| /* | |
| TODO: We'd also like to know: | |
| - quest type (jewel mining, gold mining, foraging, etc) | |
| - liquidity pool id for gardening quests | |
| - time quest ended: maybe derivable from txReceipt.blockNumber? | |
| */ | |
| // Put all the per-hero rewards into the result object | |
| result.rewards = Object.values(heroDicts); | |
| return result; | |
| } | |
| export function labelForReward(rewardAddress, itemQuantityBigNum, chainId = undefined) { | |
| chainId = chainId || activeChainId; | |
| // Initialize the global ITEM_NAMES object so we can look up names | |
| if (Object.keys(ITEM_NAMES).length === 0) { | |
| ITEM_NAMES = createItemNamesMap(ALL_ITEMS); | |
| } | |
| let decimals; | |
| if (GOLD_ADDRESSES.includes(rewardAddress)) { | |
| decimals = GOLD_DECIMALS; | |
| } else if (JEWEL_ADDRESSES.includes(rewardAddress)) { | |
| decimals = JEWEL_DECIMALS; | |
| } else if (CRYSTAL_ADDRESSES.includes(rewardAddress)) { | |
| decimals = CRYSTAL_DECIMALS; | |
| } else { | |
| decimals = 0; | |
| } | |
| let name = ITEM_NAMES[chainId][rewardAddress]; | |
| name = name || `Unknown item: (${rewardAddress})`; | |
| const amountStr = ethers.utils.formatUnits(itemQuantityBigNum, decimals); | |
| const label = `${amountStr} ${name}`; | |
| return label; | |
| } | |
| // =========== | |
| // = HELPERS = | |
| // =========== | |
| function getRPCProviderByChain(chainId) { | |
| // This can be used to get multi-chain behavior with degenking. | |
| // For simpler ethers.js use, you really just need a url | |
| chainId = chainId || activeChainId; | |
| if (chainId === DFKN) { | |
| return { | |
| name: 'DFK Chain', | |
| provider: new ethers.providers.JsonRpcProvider( | |
| 'https://subnets.avax.network/defi-kingdoms/dfk-chain/rpc', | |
| ), | |
| chainId: DFKN, | |
| }; | |
| } else if (chainId === HARMONY) { | |
| return { | |
| name: 'harmony-official', | |
| provider: new ethers.providers.JsonRpcProvider('https://api.harmony.one/'), | |
| chainId: HARMONY, | |
| }; | |
| } | |
| } | |
| function getEthersQuestInterface(txReceipt, chainId = undefined) { | |
| chainId = chainId || activeChainId; | |
| const QUEST_CORE_V2 = getContractAddress('QUEST_CORE_V2', chainId); | |
| const contractAbi = txReceipt.to == QUEST_CORE_V2 ? abiQuestCoreV2 : abiQuestCoreV1; | |
| const iface = new ethers.utils.Interface(contractAbi); | |
| return iface; | |
| } | |
| function getContractAddress(contractName, chainId = undefined) { | |
| chainId = chainId || activeChainId; | |
| let address; | |
| if (chainId == DFKN) { | |
| address = degenKing.ADDRESSES_DFKN[contractName]; | |
| } else if (chainId == HARMONY) { | |
| address = degenKing.ADDRESSES_HARMONY[contractName]; | |
| } | |
| // Not all degenking addresses are checksummed; do that here | |
| return ethers.utils.getAddress(address); | |
| } | |
| function createItemNamesMap(degenKingItemsSource) { | |
| // degenKing.ALL_ITEMS contains a comprehensive list | |
| // of all known items and metadata, but it has to be | |
| // searched in O(N) time. We just want lookups by chain and address, | |
| // so create that here. | |
| const itemNames = { | |
| 335: {}, // DFK Chain testnet? | |
| 53935: {}, // DFK Chain | |
| 1666600000: {}, // Harmony | |
| 1666700000: {}, // Harmony testnet? | |
| }; | |
| degenKingItemsSource.forEach((i) => { | |
| const { name, addresses } = i; | |
| Object.entries(addresses).forEach(([chain, addr]) => { | |
| itemNames[chain][addr] = name; | |
| }); | |
| }); | |
| return itemNames; | |
| } | |
| // This equivalent of Python's `if __name__ == '__main__':` was found here: | |
| // https://2ality.com/2022/07/nodejs-esm-main.html | |
| // NOTE: import * as url from 'node:url'; required above | |
| if (import.meta.url.startsWith('file:')) { | |
| const modulePath = url.fileURLToPath(import.meta.url); | |
| if (process.argv[1] === modulePath) { | |
| main(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment